Compare commits

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

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

Ikony her se řídí zvoleným vzhledem. Stav her je v paměti serveru,
předpokládá se jedna replika.
2026-10-12 14:25:45 +02:00
batmanisko abeee19259 feat: možnost vypnout motýlky v nastavení
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 42s
CI / Playwright E2E tests (push) Successful in 1m53s
CI / Build and push Docker image (push) Successful in 46s
CI / Server unit tests (push) Successful in 26s
CI / Notify (push) Successful in 2s
2026-10-12 13:14:53 +02:00
mates f91193c7de feat: přidání vzhledu Hell Girl
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 30s
CI / Build client (push) Successful in 42s
CI / Playwright E2E tests (push) Successful in 1m51s
CI / Build and push Docker image (push) Successful in 46s
CI / Server unit tests (push) Successful in 27s
CI / Notify (push) Successful in 1s
2026-10-12 13:05:34 +02:00
mates 93d86fe0a5 feat: přidání vzhledu My Little Pony
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 30s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m53s
CI / Build and push Docker image (push) Successful in 57s
CI / Server unit tests (push) Successful in 27s
CI / Notify (push) Successful in 2s
2026-10-12 11:04:54 +02:00
mates bf631eb913 test: oprava Playwright testů po odstranění podniku Šenk Šeříková
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 31s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m50s
CI / Build and push Docker image (push) Successful in 1m1s
CI / Server unit tests (push) Successful in 27s
CI / Notify (push) Successful in 2s
2026-10-09 15:46:41 +02:00
mates 49508dacd0 fix: dočasné skrytí podniku Šenk Šeříková
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 40s
CI / Playwright E2E tests (push) Failing after 2m44s
CI / Build and push Docker image (push) Skipped
CI / Server unit tests (push) Successful in 27s
CI / Notify (push) Successful in 2s
2026-10-09 14:45:00 +02:00
mates 9fee4a1538 fix: oprava parsování TechTower po změně jejich webu
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 2m0s
CI / Build and push Docker image (push) Successful in 47s
CI / Server unit tests (push) Successful in 30s
CI / Notify (push) Successful in 2s
2026-10-09 14:37:09 +02:00
batmanisko 7cda7cfdb6 fix: hlavičky s identitou přijímat jen z důvěryhodných adres
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 27s
CI / Build client (push) Successful in 47s
CI / Playwright E2E tests (push) Successful in 1m39s
CI / Build and push Docker image (push) Successful in 45s
CI / Server unit tests (push) Successful in 31s
CI / Notify (push) Successful in 2s
2026-09-24 16:12:46 +02:00
batmanisko 89d2cc2bc3 fix: delší zobrazení konfet kola štěstí
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 36s
CI / Build client (push) Successful in 40s
CI / Playwright E2E tests (push) Successful in 1m48s
CI / Build and push Docker image (push) Successful in 53s
CI / Server unit tests (push) Successful in 26s
CI / Notify (push) Successful in 4s
2026-09-24 16:03:29 +02:00
mates a29e329c11 feat: přidání detekce polévek pro TechTower
CI / Generate TypeScript types (push) Successful in 10s
CI / Build client (push) Successful in 42s
CI / Build server (push) Successful in 1m48s
CI / Playwright E2E tests (push) Successful in 2m26s
CI / Build and push Docker image (push) Successful in 3m39s
CI / Server unit tests (push) Successful in 25s
CI / Notify (push) Successful in 1s
2026-09-24 10:18:43 +02:00
batmanisko 5f54925808 feat: způsob přihlášení a správa obchodů podle konfigurace serveru
CI / Generate TypeScript types (push) Successful in 12s
CI / Build server (push) Successful in 32s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Build and push Docker image (push) Successful in 49s
CI / Server unit tests (push) Successful in 26s
CI / Notify (push) Successful in 2s
2026-09-24 09:30:04 +02:00
batmanisko 103445dba8 feat: limity skupinových objednávek nastavitelné přes env
CI / Generate TypeScript types (push) Successful in 12s
CI / Build server (push) Successful in 30s
CI / Build client (push) Successful in 39s
CI / Playwright E2E tests (push) Successful in 3m55s
CI / Build and push Docker image (push) Successful in 49s
CI / Server unit tests (push) Successful in 25s
CI / Notify (push) Successful in 2s
2026-09-24 09:18:56 +02:00
batmanisko 076b8db179 feat: opakovaný výběr jídla zvýší počet kusů (2x)
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 27s
CI / Build client (push) Successful in 40s
CI / Playwright E2E tests (push) Successful in 1m50s
CI / Build and push Docker image (push) Successful in 44s
CI / Server unit tests (push) Successful in 25s
CI / Notify (push) Successful in 1s
2026-09-23 16:37:01 +02:00
batmanisko f9a7b791a0 feat: odebírání položek objednávky ve sloupci Z nabídky
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 58s
CI / Build client (push) Successful in 1m4s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Build and push Docker image (push) Successful in 45s
CI / Server unit tests (push) Successful in 26s
CI / Notify (push) Successful in 2s
2026-09-23 16:26:10 +02:00
batmanisko bd52ae071e fix: kolo štěstí točí až od dvou možností
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m50s
CI / Build and push Docker image (push) Successful in 46s
CI / Server unit tests (push) Successful in 25s
CI / Notify (push) Successful in 2s
2026-09-23 16:18:11 +02:00
batmanisko 0a59f97c5c feat: nápověda k admin heslu u obnovení nabídky
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 32s
CI / Build client (push) Successful in 42s
CI / Playwright E2E tests (push) Successful in 1m53s
CI / Build and push Docker image (push) Successful in 46s
CI / Server unit tests (push) Successful in 25s
CI / Notify (push) Successful in 1s
2026-09-23 16:10:55 +02:00
batmanisko e7c4ba09ff feat: souhrn historie cen od začátku
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m49s
CI / Build and push Docker image (push) Successful in 48s
CI / Server unit tests (push) Successful in 26s
CI / Notify (push) Successful in 1s
2026-09-23 15:55:43 +02:00
batmanisko e281615cc3 feat: spin the wheel, objednavani scrape menu, historie cen
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 39s
CI / Playwright E2E tests (push) Successful in 1m52s
CI / Build and push Docker image (push) Successful in 51s
CI / Server unit tests (push) Successful in 25s
CI / Notify (push) Successful in 2s
2026-09-23 15:41:15 +02:00
mates 682deaf20b feat: přidání detekce polévek pro TechTower
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m43s
CI / Build and push Docker image (push) Successful in 1m15s
CI / Server unit tests (push) Successful in 24s
CI / Notify (push) Successful in 2s
2026-09-15 08:12:07 +02:00
mates 92ea78a870 test: oprava testu poražení zloděje
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 27s
CI / Build client (push) Successful in 39s
CI / Playwright E2E tests (push) Successful in 1m44s
CI / Build and push Docker image (push) Successful in 3m2s
CI / Server unit tests (push) Successful in 24s
CI / Notify (push) Successful in 2s
2026-09-09 09:03:40 +02:00
mates 51df4c812e chore: povýšení na node 26
CI / Generate TypeScript types (push) Successful in 1m1s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 37s
CI / Playwright E2E tests (push) Successful in 1m43s
CI / Build and push Docker image (push) Skipped
CI / Server unit tests (push) Failing after 36s
CI / 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
99 changed files with 11744 additions and 1039 deletions

No files matched your search

+13 -10
View File
@@ -22,7 +22,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "22" node-version: "26"
- run: corepack enable - run: corepack enable
@@ -49,7 +49,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "22" node-version: "26"
- run: corepack enable - run: corepack enable
@@ -71,7 +71,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "22" node-version: "26"
- run: corepack enable - run: corepack enable
@@ -100,7 +100,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "22" node-version: "26"
- run: corepack enable - run: corepack enable
@@ -187,7 +187,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "22" node-version: "26"
- run: corepack enable - run: corepack enable
@@ -231,8 +231,7 @@ jobs:
steps: steps:
- name: Send webhook - name: Send webhook
env: env:
DISCORD_WEBHOOK_ID: ${{ secrets.DISCORD_WEBHOOK_ID }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_WEBHOOK_TOKEN: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
NTFY_URL: ${{ secrets.NTFY_URL }} NTFY_URL: ${{ secrets.NTFY_URL }}
BUILD_RESULT: ${{ needs.docker-build.result }} BUILD_RESULT: ${{ needs.docker-build.result }}
RUN_NUMBER: ${{ github.run_number }} RUN_NUMBER: ${{ github.run_number }}
@@ -250,12 +249,16 @@ jobs:
fi fi
FULL_MSG="$(printf '%s\n\nPipeline: %s\nPoslední commit: %sAutor: %s' \ FULL_MSG="$(printf '%s\n\nPipeline: %s\nPoslední commit: %sAutor: %s' \
"$MSG" "$RUN_URL" "$COMMIT_MESSAGE" "$COMMIT_AUTHOR")" "$MSG" "$RUN_URL" "$COMMIT_MESSAGE" "$COMMIT_AUTHOR")"
curl -s -X POST \ # Nevyplněné URL = daný kanál notifikací se přeskočí
"https://discord.com/api/webhooks/${DISCORD_WEBHOOK_ID}/${DISCORD_WEBHOOK_TOKEN}" \ if [ -n "$DISCORD_WEBHOOK_URL" ]; then
curl -sS -f -X POST "${DISCORD_WEBHOOK_URL}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
--data "$(jq -n --arg content "$FULL_MSG" '{content: $content}')" --data "$(jq -n --arg content "$FULL_MSG" '{content: $content}')"
curl -s -X POST "${NTFY_URL}" \ fi
if [ -n "$NTFY_URL" ]; then
curl -sS -f -X POST "${NTFY_URL}" \
-H "Title: Luncher CI #${RUN_NUMBER}" \ -H "Title: Luncher CI #${RUN_NUMBER}" \
-H "Tags: ${NTFY_TAGS}" \ -H "Tags: ${NTFY_TAGS}" \
-H "Click: ${RUN_URL}" \ -H "Click: ${RUN_URL}" \
-d "${FULL_MSG}" -d "${FULL_MSG}"
fi
+1
View File
@@ -8,3 +8,4 @@ server/public/
.claude/worktrees .claude/worktrees
.playwright-mcp .playwright-mcp
.idea/ .idea/
**/.yarn/install-state.gz
+1 -1
View File
@@ -1,4 +1,4 @@
ARG NODE_VERSION="node:22-alpine" ARG NODE_VERSION="node:26-alpine"
# ─── Builder ────────────────────────────────────────────────────────────────── # ─── Builder ──────────────────────────────────────────────────────────────────
FROM ${NODE_VERSION} AS builder FROM ${NODE_VERSION} AS builder
+28 -28
View File
@@ -4,41 +4,41 @@
- [ ] `foodRoutes.ts` per-pod rate limiter (`rateLimits` map) — s více replikami může uživatel překročit limit ~N× rychleji; přesunout do Redis (např. `INCR` + `EXPIRE`) - [ ] `foodRoutes.ts` per-pod rate limiter (`rateLimits` map) — s více replikami může uživatel překročit limit ~N× rychleji; přesunout do Redis (např. `INCR` + `EXPIRE`)
- [ ] `easterEggRoutes.ts` — náhodně generované URL easter eggů jsou per-pod; URL funguje pouze na podu, který ji vygeneroval; zvážit deterministické seedy nebo sdílení přes Redis - [ ] `easterEggRoutes.ts` — náhodně generované URL easter eggů jsou per-pod; URL funguje pouze na podu, který ji vygeneroval; zvážit deterministické seedy nebo sdílení přes Redis
- [ ] `service.ts` — komplexní víceúrovňové funkce (`addChoice`, `removeChoiceIfPresent`) provádějí více po sobě jdoucích zápisů do stejného Redis klíče; pro plnou atomicitu je potřeba per-klíčový distribuovaný zámek (Redlock nebo `SET NX EX`) nebo sloučení logiky do jednoho `updateData` volání - [ ] `service.ts` — komplexní víceúrovňové funkce (`addChoice`, `removeChoiceIfPresent`) provádějí více po sobě jdoucích zápisů do stejného Redis klíče; pro plnou atomicitu je potřeba per-klíčový distribuovaný zámek (Redlock nebo `SET NX EX`) nebo sloučení logiky do jednoho `updateData` volání
- [ ] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli - [X] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli — kontroluje se skutečná adresa spojení (trustedHeaders.ts)
- [ ] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď) - [X] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď)
- [ ] Nemělo by se jít dostat na přihlašovací formulář (měla by tam být nanejvýš hláška nebo přesměrování) - [X] Nemělo by se jít dostat na přihlašovací formulář (měla by tam být nanejvýš hláška nebo přesměrování)
- [ ] Možnost úhrady celé útraty jednou osobou - [X] Možnost úhrady celé útraty jednou osobou
- Základní myšlenka: jedna osoba uhradí celou útratu (v zájmu rychlosti odbavení), ostatním se automaticky vygeneruje QR kód, kterým následně uhradí svoji část útraty - Základní myšlenka: jedna osoba uhradí celou útratu (v zájmu rychlosti odbavení), ostatním se automaticky vygeneruje QR kód, kterým následně uhradí svoji část útraty
- Obecně to bude problém např. pokud si někdo objedná něco navíc (pití apod.) - Obecně to bude problém např. pokud si někdo objedná něco navíc (pití apod.)
- [ ] Tlačítko "Uhradit" u každého řádku podniku - platí ten, kdo kliknul - [X] Tlačítko "Uhradit" u každého řádku podniku - platí ten, kdo kliknul
- [ ] Zobrazeno bude pouze, pokud má daný uživatel nastaveno číslo účtu - [X] Zobrazeno bude pouze, pokud má daný uživatel nastaveno číslo účtu
- [ ] Dialog pro zadání spropitného, které se následně rozpočte rovnoměrně všem strávníkům - [X] Dialog pro zadání spropitného, které se následně rozpočte rovnoměrně všem strávníkům
- [ ] Generování a zobrazení QR kódů ostatním strávníkům - [X] Generování a zobrazení QR kódů ostatním strávníkům
- [ ] Umožnit u každého strávníka připočíst vlastní částku (např. za pití) - [X] Umožnit u každého strávníka připočíst vlastní částku (např. za pití)
- [ ] Umožnit (např. zaškrtávátky) vybrat, za koho bude zaplaceno (pokud někdo bude platit zvlášť) - [X] Umožnit (např. zaškrtávátky) vybrat, za koho bude zaplaceno (pokud někdo bude platit zvlášť)
- [ ] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams) - [X] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)
- [ ] Umožnit zadat URL/tokeny uživatelem - [X] Umožnit zadat URL/tokeny uživatelem — ntfy, Discord, MS Teams (Gotify jen globálně na serveru)
- [ ] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat - [X] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat
- [ ] Zavést notifikace typu "Jdeme na oběd" - [X] Zavést notifikace typu "Jdeme na oběd"
- [ ] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu - [X] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu
- [ ] Vylepšit parsery restaurací - [X] Vylepšit parsery restaurací
- [ ] Sladovnická - [X] Sladovnická
- [ ] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru - [X] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru — parser mapuje dny přímo, TODO v parseru už není
- [ ] U Motlíků - [X] U Motlíků — odpadá, podnik už v aplikaci není
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.') - [X] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.')
- [ ] Jídelní lístek se stahuje jednou každý den, teoreticky by stačilo jednou týdně (za předpokladu, že se během týdne nemění) - [X] Jídelní lístek se stahuje jednou každý den, teoreticky by stačilo jednou týdně (za předpokladu, že se během týdne nemění)
- [ ] TechTower - [X] TechTower
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (typicky 'Obědy 12. 6. - 16. 6. 2023 (každý den vždy i obědový bufet)') - [X] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (typicky 'Obědy 12. 6. - 16. 6. 2023 (každý den vždy i obědový bufet)') — finalizeTechTower, StaleWeekError
- [ ] Jídelní lístek se stahuje v rámci prvního požadavku daný den, ale často se jídelní lístek na stránkách aktualizuje až v průběhu pondělního dopoledne a ten zobrazený je proto neaktuální - [X] Jídelní lístek se stahuje v rámci prvního požadavku daný den, ale často se jídelní lístek na stránkách aktualizuje až v průběhu pondělního dopoledne a ten zobrazený je proto neaktuální — zastaralé menu se po TTL stahuje znovu
- Stránka neposílá hlavičku o času poslední modifikace, takže o to se nelze opřít - Stránka neposílá hlavičku o času poslední modifikace, takže o to se nelze opřít
- Nevím aktuálně jak řešit jinak, než častějším scrapováním celé stránky - Nevím aktuálně jak řešit jinak, než častějším scrapováním celé stránky
- [X] Někdy jsou v názvech jídel přebytečné mezery kolem čárek ( , ) - [X] Někdy jsou v názvech jídel přebytečné mezery kolem čárek ( , )
- [ ] Nasazení nové verze v Docker smaže veškerá data (protože data.json není vystrčený ven z kontejneru) - [X] Nasazení nové verze v Docker smaže veškerá data (protože data.json není vystrčený ven z kontejneru) — data jsou v Redis s volume /data
- [ ] Zavést složku /data - [X] Zavést složku /data
- [ ] Mazat z databáze data z minulosti, aktuálně je to k ničemu - [ ] Mazat z databáze data z minulosti, aktuálně je to k ničemu
- [ ] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh) - [X] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh) — run_dev.ps1
- [ ] Implementovat Pizza day - [ ] Implementovat Pizza day
- [ ] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day - [X] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day
- [ ] Pizzy se samy budou při naklikání přidávat do košíku - [ ] Pizzy se samy budou při naklikání přidávat do košíku
- [ ] Nutno nejprve vyřešit předávání PHPSESSIONID cookie na pizzachefie.cz pomocí fetch() - [ ] Nutno nejprve vyřešit předávání PHPSESSIONID cookie na pizzachefie.cz pomocí fetch()
- [ ] Ceny krabic za pizzu jsou napevno v kódu - problém, pokud se někdy změní - [ ] Ceny krabic za pizzu jsou napevno v kódu - problém, pokud se někdy změní
+6
View File
@@ -9,6 +9,9 @@
<meta name="description" content="Moderní webová aplikace pro lepší správu obědových preferencí" /> <meta name="description" content="Moderní webová aplikace pro lepší správu obědových preferencí" />
<link rel="apple-touch-icon" href="/logo192.png" /> <link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=M+PLUS+Rounded+1c:wght@500;800&family=Nunito:wght@600;800;900&family=Fredoka:wght@500;600;700&family=Shippori+Mincho:wght@600;700;800&family=Zen+Maru+Gothic:wght@500;700;900&family=Yusei+Magic&family=Dela+Gothic+One&display=swap" rel="stylesheet" />
<title>Luncher</title> <title>Luncher</title>
<script> <script>
(function() { (function() {
@@ -24,6 +27,9 @@
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} }
document.documentElement.setAttribute('data-bs-theme', theme); document.documentElement.setAttribute('data-bs-theme', theme);
var style = localStorage.getItem('style_theme');
var styles = ['umamusume', 'mlp', 'hellgirl', 'kon', 'bocchi'];
document.documentElement.setAttribute('data-luncher-theme', styles.indexOf(style) >= 0 ? style : 'simple');
} catch (e) { } catch (e) {
// Fallback pokud localStorage není dostupný // Fallback pokud localStorage není dostupný
document.documentElement.setAttribute('data-bs-theme', 'light'); document.documentElement.setAttribute('data-bs-theme', 'light');
+73 -6
View File
@@ -10,19 +10,29 @@ import PizzaOrderList from './components/PizzaOrderList';
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from 'react-select-search'; import SelectSearch, { SelectedOptionValue, SelectSearchOption } from 'react-select-search';
import 'react-select-search/style.css'; import 'react-select-search/style.css';
import './App.scss'; import './App.scss';
import './themes/umamusume.scss';
import './themes/mlp.scss';
import './themes/hellgirl.scss';
import './themes/kon.scss';
import './themes/bocchi.scss';
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons'; import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
import { useSettings } from './context/settings'; import { useSettings } from './context/settings';
import Footer from './components/Footer'; import Footer from './components/Footer';
import { faArrowUpRightFromSquare, faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; import { faArrowUpRightFromSquare, faBasketShopping, faTag, faChainBroken, faChevronLeft, faChevronRight, faDharmachakra, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import Loader from './components/Loader'; import Loader from './components/Loader';
import { getHumanDateTime, isInTheFuture } from './Utils'; import { getHumanDateTime, isInTheFuture } from './Utils';
import NoteModal from './components/modals/NoteModal'; import NoteModal from './components/modals/NoteModal';
import PriceModal from './components/modals/PriceModal';
import { parsePriceCzk } from './utils/parsePrice';
import PayForAllModal from './components/modals/PayForAllModal'; import PayForAllModal from './components/modals/PayForAllModal';
import SpinWheelModal, { WheelPick } from './components/modals/SpinWheelModal';
import EaterBadge from './components/EaterBadge';
import PendingPayments from './components/PendingPayments'; import PendingPayments from './components/PendingPayments';
import { useEasterEgg } from './context/eggs'; import { useEasterEgg } from './context/eggs';
import { ClientData, Food, MealSlot, PendingQr, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, generateQr } from '../../types'; import { ClientData, Food, MealSlot, PendingQr, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, updatePrice, changeDepartureTime, setBuyer, generateQr } from '../../types';
import { getLunchChoiceName } from './enums'; import { CHOICES_WITHOUT_PRICE, getLunchChoiceName } from './enums';
import { isArrowNavigationBlocked } from './utils/arrowNavigation';
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves'; // import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
// import './FallingLeaves.scss'; // import './FallingLeaves.scss';
@@ -80,7 +90,9 @@ function App() {
const [dayIndex, setDayIndex] = useState<number>(); const [dayIndex, setDayIndex] = useState<number>();
const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false); const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false);
const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false); const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false);
const [priceModalOpen, setPriceModalOpen] = useState<boolean>(false);
const [payForAllLocationKey, setPayForAllLocationKey] = useState<LunchChoice | null>(null); const [payForAllLocationKey, setPayForAllLocationKey] = useState<LunchChoice | null>(null);
const [spinWheelOpen, setSpinWheelOpen] = useState<boolean>(false);
const [eggImage, setEggImage] = useState<Blob>(); const [eggImage, setEggImage] = useState<Blob>();
const eggRef = useRef<HTMLImageElement>(null); const eggRef = useRef<HTMLImageElement>(null);
// Prazvláštní workaround, aby socket.io listener viděl aktuální hodnotu // Prazvláštní workaround, aby socket.io listener viděl aktuální hodnotu
@@ -236,6 +248,7 @@ function App() {
// Navigace mezi dny pomocí klávesových šípek // Navigace mezi dny pomocí klávesových šípek
const handleKeyDown = useCallback((e: any) => { const handleKeyDown = useCallback((e: any) => {
if (isArrowNavigationBlocked(e)) return;
if (e.keyCode === 37 && dayIndex != null && dayIndex > 0) { if (e.keyCode === 37 && dayIndex != null && dayIndex > 0) {
handleDayChange(dayIndex - 1); handleDayChange(dayIndex - 1);
} else if (e.keyCode === 39 && dayIndex != null && dayIndex < 4) { } else if (e.keyCode === 39 && dayIndex != null && dayIndex < 4) {
@@ -408,6 +421,24 @@ function App() {
} }
} }
// Převzetí výsledku kola štěstí — podnik s menu vybere i jídlo, rozvoz se zapíše do poznámky
const handleWheelPick = async (pick: WheelPick) => {
if (!auth?.login) return;
try {
if (pick.kind === 'menu') {
await addChoice({ body: { locationKey: pick.restaurant, foodIndex: pick.foodIndex, dayIndex } });
} else {
await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, dayIndex } });
// Server přijímá poznámku max. 70 znaků
const note = pick.dish ? `${pick.store}: ${pick.dish}` : pick.store;
await updateNote({ body: { note: note.length > 70 ? note.slice(0, 69) + '…' : note, dayIndex } });
}
await tryAutoSelectDepartureTime();
} catch (error: any) {
alert(`Chyba při změně volby: ${error.message || error}`);
}
}
const saveNote = async (note?: string) => { const saveNote = async (note?: string) => {
if (auth?.login) { if (auth?.login) {
await updateNote({ body: { note, dayIndex } }); await updateNote({ body: { note, dayIndex } });
@@ -415,6 +446,13 @@ function App() {
} }
} }
const savePrice = async (price?: number) => {
if (auth?.login) {
await updatePrice({ body: { price, dayIndex } });
setPriceModalOpen(false);
}
}
const copyNote = async (note: string) => { const copyNote = async (note: string) => {
if (auth?.login && note) { if (auth?.login && note) {
await updateNote({ body: { note, dayIndex } }); await updateNote({ body: { note, dayIndex } });
@@ -587,7 +625,7 @@ function App() {
} else { } else {
content = <div className="restaurant-error">Chyba načtení dat</div> content = <div className="restaurant-error">Chyba načtení dat</div>
} }
return <Col md={6} lg={3} className='mt-3'> return <Col md={6} lg className='mt-3'>
<div className="restaurant-card"> <div className="restaurant-card">
<div className="restaurant-header" style={{ cursor: canChangeChoice ? 'pointer' : 'default' }} onClick={() => doAddClickFoodChoice(location)}> <div className="restaurant-header" style={{ cursor: canChangeChoice ? 'pointer' : 'default' }} onClick={() => doAddClickFoodChoice(location)}>
<div className="restaurant-header-content"> <div className="restaurant-header-content">
@@ -638,6 +676,15 @@ function App() {
const noOrders = data?.pizzaDay?.orders?.length === 0; const noOrders = data?.pizzaDay?.orders?.length === 0;
const canChangeChoice = dayIndex == null || data.todayDayIndex == null || dayIndex >= data.todayDayIndex; const canChangeChoice = dayIndex == null || data.todayDayIndex == null || dayIndex >= data.todayDayIndex;
const ownChoice = auth?.login ? Object.entries(data.choices)
.map(([locationKey, loginMap]) => ({ locationKey: locationKey as LunchChoice, choice: loginMap?.[auth.login!] }))
.find(entry => entry.choice != null) as { locationKey: LunchChoice, choice: UserLunchChoice } | undefined : undefined;
// Součet cen vybraných jídel z menu jako návrh ceny; pokud některou cenu nelze přečíst, nenavrhujeme nic
const ownFoodPrices = ownChoice?.choice.selectedFoods?.map(index => parsePriceCzk(food[ownChoice.locationKey as Restaurant]?.food?.[index]?.price)) ?? [];
const ownSuggestedPrice = ownFoodPrices.length && !ownFoodPrices.includes(null)
? Math.round((ownFoodPrices as number[]).reduce((sum, price) => sum + price, 0) * 100)
: undefined;
const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {}; const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {};
return ( return (
@@ -662,7 +709,7 @@ function App() {
</span> </span>
</div> </div>
} }
<Row className='food-tables'> <Row className='food-tables justify-content-center'>
{Object.keys(Restaurant).map(key => { {Object.keys(Restaurant).map(key => {
const locationKey = key as Restaurant; const locationKey = key as Restaurant;
return food[locationKey] && renderFoodTable(locationKey, food[locationKey]); return food[locationKey] && renderFoodTable(locationKey, food[locationKey]);
@@ -677,7 +724,11 @@ function App() {
{Object.entries(LunchChoice) {Object.entries(LunchChoice)
.filter(entry => { .filter(entry => {
const locationKey = entry[0] as Restaurant; const locationKey = entry[0] as Restaurant;
return !food[locationKey]?.closed; // Podniky, pro které server nevrátil menu (dočasně uzavřené), nenabízíme
if (Object.keys(Restaurant).includes(locationKey) && !food?.[locationKey]) {
return false;
}
return !food?.[locationKey]?.closed;
}) })
.map(entry => <option key={entry[0]} value={entry[0]}>{getLunchChoiceName(entry[1])}</option>)} .map(entry => <option key={entry[0]} value={entry[0]}>{getLunchChoiceName(entry[1])}</option>)}
</Form.Select> </Form.Select>
@@ -745,9 +796,11 @@ function App() {
{trusted && <span className='trusted-icon' title='Uživatel ověřený doménovým přihlášením'> {trusted && <span className='trusted-icon' title='Uživatel ověřený doménovým přihlášením'>
<FontAwesomeIcon icon={faCircleCheck} style={{ cursor: "help" }} /> <FontAwesomeIcon icon={faCircleCheck} style={{ cursor: "help" }} />
</span>} </span>}
<EaterBadge items={userChoices?.length ?? 0} />
<strong>{login}</strong> <strong>{login}</strong>
{userPayload.departureTime && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>({userPayload.departureTime})</small>} {userPayload.departureTime && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>({userPayload.departureTime})</small>}
{userPayload.note && <span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>({userPayload.note})</span>} {userPayload.note && <span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>({userPayload.note})</span>}
{login === auth.login && userPayload.price != null && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>{userPayload.price / 100} Kč</small>}
</div> </div>
<div className="user-actions"> <div className="user-actions">
{login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && <span title='Označit/odznačit se jako objednávající'> {login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && <span title='Označit/odznačit se jako objednávající'>
@@ -768,6 +821,12 @@ function App() {
copyNote(userPayload.note!); copyNote(userPayload.note!);
}} className='action-icon' icon={faComment} /> }} className='action-icon' icon={faComment} />
</span>} </span>}
{login === auth.login && canChangeChoice && locationKey === LunchChoice.ROZHODUJI && <span title='Roztočit kolo štěstí'>
<FontAwesomeIcon onClick={() => setSpinWheelOpen(true)} className='action-icon spin-wheel-icon' icon={faDharmachakra} />
</span>}
{login === auth.login && canChangeChoice && !CHOICES_WITHOUT_PRICE.includes(locationKey) && <span title='Zadat cenu oběda do přehledu stravování'>
<FontAwesomeIcon onClick={() => setPriceModalOpen(true)} className='action-icon' icon={faTag} />
</span>}
{login === auth.login && canChangeChoice && <span title='Upravit poznámku'> {login === auth.login && canChangeChoice && <span title='Upravit poznámku'>
<FontAwesomeIcon onClick={() => { <FontAwesomeIcon onClick={() => {
setNoteModalOpen(true); setNoteModalOpen(true);
@@ -931,7 +990,15 @@ function App() {
leafVariants={LEAF_COLOR_THEMES.AUTUMN} leafVariants={LEAF_COLOR_THEMES.AUTUMN}
/> */} /> */}
<Footer /> <Footer />
{spinWheelOpen && <SpinWheelModal isOpen onClose={() => setSpinWheelOpen(false)} menus={food} onPick={handleWheelPick} />}
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} /> <NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
<PriceModal
isOpen={priceModalOpen}
onClose={() => setPriceModalOpen(false)}
price={ownChoice?.choice.price}
suggestedPrice={ownSuggestedPrice}
onSave={savePrice}
/>
{payForAllLocationKey && data && ( {payForAllLocationKey && data && (
<PayForAllModal <PayForAllModal
isOpen isOpen
+22 -11
View File
@@ -1,5 +1,5 @@
import { Routes, Route } from "react-router-dom"; import { Routes, Route } from "react-router-dom";
import { ProvideSettings } from "./context/settings"; import { ProvideSettings, useSettings } from "./context/settings";
import FlyingButterflies, { BUTTERFLY_PRESETS } from "./FlyingButterflies"; import FlyingButterflies, { BUTTERFLY_PRESETS } from "./FlyingButterflies";
import "./FlyingButterflies.scss"; import "./FlyingButterflies.scss";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
@@ -8,39 +8,50 @@ import StatsPage from "./pages/StatsPage";
import OrderGroupsPage from "./pages/OrderGroupsPage"; import OrderGroupsPage from "./pages/OrderGroupsPage";
import SuggestionsPage from "./pages/SuggestionsPage"; import SuggestionsPage from "./pages/SuggestionsPage";
import App from "./App"; import App from "./App";
import HellGirlFlames from "./components/HellGirlFlames";
export const STATS_URL = '/stats'; export const STATS_URL = '/stats';
export const OBJEDNANI_URL = '/objednani'; export const OBJEDNANI_URL = '/objednani';
export const NAVRHY_URL = '/navrhy'; export const NAVRHY_URL = '/navrhy';
/** Motýlci se vykreslují jen pokud je uživatel nevypnul v nastavení. */
function Butterflies() {
const settings = useSettings();
if (settings?.showButterflies === false) {
return null;
}
return <FlyingButterflies numButterflies={BUTTERFLY_PRESETS.NORMAL} />;
}
/** Doplňkové efekty vizuálních stylů — společné pro všechny stránky (např. plamen při otevření dialogu). */
function StyleThemeEffects() {
const settings = useSettings();
return settings?.styleTheme === 'hellgirl' ? <HellGirlFlames /> : null;
}
export default function AppRoutes() { export default function AppRoutes() {
return ( return (
<ProvideSettings>
<StyleThemeEffects />
<Routes> <Routes>
<Route path={STATS_URL} element={<StatsPage />} /> <Route path={STATS_URL} element={<StatsPage />} />
<Route path={NAVRHY_URL} element={ <Route path={NAVRHY_URL} element={<SuggestionsPage />} />
<ProvideSettings>
<SuggestionsPage />
</ProvideSettings>
} />
<Route path={OBJEDNANI_URL} element={ <Route path={OBJEDNANI_URL} element={
<ProvideSettings>
<SocketContext.Provider value={socket}> <SocketContext.Provider value={socket}>
<OrderGroupsPage /> <OrderGroupsPage />
<ToastContainer /> <ToastContainer />
</SocketContext.Provider> </SocketContext.Provider>
</ProvideSettings>
} /> } />
<Route path="/" element={ <Route path="/" element={
<ProvideSettings>
<SocketContext.Provider value={socket}> <SocketContext.Provider value={socket}>
<> <>
<FlyingButterflies numButterflies={BUTTERFLY_PRESETS.NORMAL} /> <Butterflies />
<App /> <App />
</> </>
<ToastContainer /> <ToastContainer />
</SocketContext.Provider> </SocketContext.Provider>
</ProvideSettings>
} /> } />
</Routes> </Routes>
</ProvideSettings>
); );
} }
+10
View File
@@ -87,3 +87,13 @@
font-weight: 600; font-weight: 600;
margin-top: 8px; margin-top: 8px;
} }
.login-card > .hint {
color: var(--luncher-text-muted);
}
.login-error {
color: var(--luncher-danger);
line-height: 1.5;
margin: 0;
}
+46 -14
View File
@@ -1,29 +1,37 @@
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from 'react-bootstrap'; import { Button } from 'react-bootstrap';
import { useAuth } from './context/auth'; import { useAuth } from './context/auth';
import { login } from '../../types'; import { login } from '../../types';
import { useAppConfig } from './hooks/useGroupLimits';
import './Login.css'; import './Login.css';
/** /**
* Formulář pro prvotní zadání přihlašovacího jména. * Přihlášení. Podle konfigurace serveru buď přihlásí uživatele automaticky přes důvěryhodné
* hlavičky z proxy (bez formuláře), nebo zobrazí formulář pro zadání jména. Když se konfigurace
* nenačte, zkusí nejprve přihlášení „naprázdno" a jinak zobrazí formulář (původní chování).
*/ */
export default function Login() { export default function Login() {
const auth = useAuth(); const auth = useAuth();
const { loaded, trustedHeaders } = useAppConfig();
const loginRef = useRef<HTMLInputElement>(null); const loginRef = useRef<HTMLInputElement>(null);
const [trustedError, setTrustedError] = useState<string>();
const [attempt, setAttempt] = useState(0);
useEffect(() => { useEffect(() => {
if (auth && !auth.login) { if (!auth || auth.login || !loaded || trustedHeaders === false) return;
// Vyzkoušíme přihlášení "naprázdno", pokud projde, přihlásili nás trusted headers // Přihlášení přes hlavičky z proxy (případně pokus „naprázdno", pokud nevíme, jak server přihlašuje)
setTrustedError(undefined);
login().then(response => { login().then(response => {
const token = response.data; const token = response.data;
if (token) { if (token) {
auth?.setToken(token as unknown as string); // TODO vyřešit, API definice je špatně, je to skutečně string auth.setToken(token as unknown as string); // TODO vyřešit, API definice je špatně, je to skutečně string
} else if (trustedHeaders) {
setTrustedError((response.error as any)?.error ?? 'Přihlášení přes proxy se nezdařilo.');
} }
}).catch(error => { }).catch(() => {
// nezajímá nás if (trustedHeaders) setTrustedError('Server je nedostupný. Zkuste to prosím za chvíli.');
}); });
} }, [auth, loaded, trustedHeaders, attempt]);
}, [auth]);
const doLogin = useCallback(async () => { const doLogin = useCallback(async () => {
const length = loginRef?.current?.value.length && loginRef.current.value.replaceAll(/\s/g, '').length const length = loginRef?.current?.value.length && loginRef.current.value.replaceAll(/\s/g, '').length
@@ -35,12 +43,38 @@ export default function Login() {
} }
}, [auth]); }, [auth]);
if (!auth?.login) { if (auth?.login) {
return <div>Neplatný stav</div>
}
const header = <>
<h1 className='login-logo'>Luncher</h1>
<p className='login-subtitle'>Aplikace pro profesionální management obědů</p>
</>;
// Přihlašuje proxy — formulář se jménem nedává smysl
if (!loaded || trustedHeaders) {
return ( return (
<div className='login-page'> <div className='login-page'>
<div className='login-card'> <div className='login-card'>
<h1 className='login-logo'>Luncher</h1> {header}
<p className='login-subtitle'>Aplikace pro profesionální management obědů</p> {trustedError ? (
<div className='login-form'>
<p className='login-error'>{trustedError}</p>
<Button onClick={() => setAttempt(a => a + 1)}>Zkusit znovu</Button>
</div>
) : (
<p className='hint'>Přihlašuji…</p>
)}
</div>
</div>
);
}
return (
<div className='login-page'>
<div className='login-card'>
{header}
<div className='login-form'> <div className='login-form'>
<div> <div>
<label htmlFor="login-input">Zobrazované jméno</label> <label htmlFor="login-input">Zobrazované jméno</label>
@@ -66,5 +100,3 @@ export default function Login() {
</div> </div>
); );
} }
return <div>Neplatný stav</div>
}
+181
View File
@@ -0,0 +1,181 @@
.duel-invite {
position: fixed;
left: 16px;
bottom: 16px;
z-index: 1040;
padding: 10px 16px;
border: none;
border-radius: 999px;
background: var(--luncher-primary, #198754);
color: #fff;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
cursor: pointer;
animation: duel-pulse 1.6s ease-in-out infinite;
}
@keyframes duel-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.04); }
}
.drawing-game-modal {
.draw-topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 10px;
font-size: 1.05rem;
}
.draw-hint {
// Mezery mezi slovy v masce se nesmí slít, jinak nejde poznat počet slov
white-space: pre;
font-family: monospace;
font-size: 1.4rem;
letter-spacing: 0.15em;
}
.draw-word strong {
font-size: 1.3rem;
}
.draw-timer.urgent {
color: #ef4444;
font-weight: 700;
}
.draw-layout {
display: flex;
gap: 16px;
align-items: stretch;
}
.draw-canvas-col {
flex: 1 1 auto;
min-width: 0;
}
.draw-canvas {
display: block;
width: 100%;
height: auto;
border-radius: 8px;
background: #fff;
touch-action: none;
&.active {
cursor: crosshair;
}
}
.draw-tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 8px;
}
.draw-color {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid rgba(127, 127, 127, 0.5);
padding: 0;
&.selected {
outline: 3px solid var(--luncher-primary, #198754);
outline-offset: 1px;
}
}
.draw-size {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border: 1px solid rgba(127, 127, 127, 0.5);
background: transparent;
span {
display: block;
border-radius: 50%;
background: currentColor;
}
&.selected {
outline: 3px solid var(--luncher-primary, #198754);
}
}
.draw-sep {
width: 8px;
}
.draw-side {
flex: 0 0 260px;
display: flex;
flex-direction: column;
gap: 8px;
}
.draw-scores {
list-style: none;
padding: 0;
margin: 0;
li {
display: flex;
justify-content: space-between;
padding: 2px 0;
}
.guessed {
color: #22c55e;
}
}
.draw-chat {
flex: 1 1 auto;
min-height: 200px;
max-height: 360px;
overflow-y: auto;
border: 1px solid rgba(127, 127, 127, 0.35);
border-radius: 6px;
padding: 6px 8px;
font-size: 0.9rem;
.system {
color: #22c55e;
font-style: italic;
}
}
.draw-players {
columns: 2;
}
// Na úzkém displeji (telefon na výšku) jde chat pod plátno, jinak by se plátno smrsklo na proužek
@media (max-width: 767.98px) {
.draw-topbar {
flex-wrap: wrap;
gap: 4px 16px;
}
.draw-layout {
flex-direction: column;
}
.draw-side {
flex: 0 0 auto;
}
.draw-chat {
min-height: 120px;
max-height: 200px;
}
}
}
+429
View File
@@ -0,0 +1,429 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Form, Modal, Table } from "react-bootstrap";
import { socket } from "../context/socket";
import { useAuth } from "../context/auth";
import { getToken } from "../Utils";
import { useGameIcon } from "../games/themeIcons";
import "./DrawingGame.scss";
// Typy musí odpovídat server/src/drawingGame.ts (stav chodí přes websocket, ne přes OpenAPI)
type DrawPhase = 'idle' | 'lobby' | 'drawing' | 'reveal' | 'finished';
type DrawPlayer = { login: string, score: number, guessed: boolean, place?: number };
type ChatMessage = { login?: string, text: string, system?: boolean };
type Stroke = { color: string, size: number, points: [number, number][], start: boolean };
type DrawState = {
phase: DrawPhase,
host?: string,
players: DrawPlayer[],
drawer?: string,
round: number,
totalRounds: number,
hint?: string,
answer?: string,
remainingMs?: number,
messages: ChatMessage[],
minPlayers: number,
};
const OPEN_EVENT = 'luncher:open-drawing';
/** Otevře okno Kreslení. */
export const openDrawingGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
const CANVAS_W = 800;
const CANVAS_H = 600;
/** Souřadnice tahů jsou 0–1000 v obou osách, nezávisle na velikosti plátna. */
const COORD_MAX = 1000;
const COLORS = ['#111827', '#ef4444', '#f97316', '#facc15', '#22c55e', '#3b82f6', '#a855f7', '#92400e', '#ffffff'];
const SIZES = [3, 8, 18];
/** Jak často se rozkreslený tah posílá ostatním (ms). */
const FLUSH_MS = 40;
const MEDALS = ['🥇', '🥈', '🥉'];
function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke) {
const sx = CANVAS_W / COORD_MAX;
const sy = CANVAS_H / COORD_MAX;
ctx.strokeStyle = stroke.color;
ctx.fillStyle = stroke.color;
ctx.lineWidth = stroke.size;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const [first, ...rest] = stroke.points;
if (rest.length === 0) {
ctx.beginPath();
ctx.arc(first[0] * sx, first[1] * sy, stroke.size / 2, 0, Math.PI * 2);
ctx.fill();
return;
}
ctx.beginPath();
ctx.moveTo(first[0] * sx, first[1] * sy);
rest.forEach(([x, y]) => ctx.lineTo(x * sx, y * sy));
ctx.stroke();
}
function clearCanvas(ctx: CanvasRenderingContext2D) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
}
/** Kreslení — jeden kreslí jídlo z dnešních menu, ostatní hádají v chatu. */
export default function DrawingGame() {
const auth = useAuth();
const login = auth?.login;
const [state, setState] = useState<DrawState | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [word, setWord] = useState<string | null>(null);
const [color, setColor] = useState(COLORS[0]);
const [size, setSize] = useState(SIZES[1]);
const [guess, setGuess] = useState('');
const [secondsLeft, setSecondsLeft] = useState(0);
const canvasRef = useRef<HTMLCanvasElement>(null);
const chatRef = useRef<HTMLDivElement>(null);
const prevPhase = useRef<DrawPhase>('idle');
/** Rozkreslený tah: body čekající na odeslání a poslední odeslaný bod pro navázání. */
const pending = useRef<{ points: [number, number][], start: boolean } | null>(null);
const drawingRef = useRef(false);
const icon = useGameIcon('drawing');
const send = useCallback((event: string, extra?: object) => {
setError(null);
socket.emit(event, { token: getToken(), ...extra });
}, []);
const ctx = () => canvasRef.current?.getContext('2d') ?? null;
useEffect(() => {
const onState = (s: DrawState) => setState(s);
const onError = (msg: string) => setError(msg);
const onOpen = () => setOpen(true);
const sync = () => socket.emit('draw:sync');
socket.on('draw:state', onState);
socket.on('draw:error', onError);
socket.on('connect', sync);
window.addEventListener(OPEN_EVENT, onOpen);
sync();
return () => {
socket.off('draw:state', onState);
socket.off('draw:error', onError);
socket.off('connect', sync);
window.removeEventListener(OPEN_EVENT, onOpen);
};
}, []);
const isDrawer = !!login && state?.phase === 'drawing' && state.drawer === login;
const isDrawerRef = useRef(isDrawer);
isDrawerRef.current = isDrawer;
// Kresba a slovo chodí jen klientům s otevřeným oknem
useEffect(() => {
if (!open) return;
const watch = () => socket.emit('draw:watch', { token: getToken() });
const onStrokes = (strokes: Stroke[]) => {
const c = ctx();
if (!c) return;
clearCanvas(c);
strokes.forEach(s => drawStroke(c, s));
};
// Vlastní tahy kreslíř vykresluje hned lokálně
const onStroke = (s: Stroke) => {
const c = ctx();
if (c && !isDrawerRef.current) drawStroke(c, s);
};
const onClear = () => {
const c = ctx();
if (c) clearCanvas(c);
};
const onWord = (w: string) => setWord(w);
socket.on('draw:strokes', onStrokes);
socket.on('draw:stroke', onStroke);
socket.on('draw:clear', onClear);
socket.on('draw:word', onWord);
socket.on('connect', watch);
watch();
return () => {
socket.off('draw:strokes', onStrokes);
socket.off('draw:stroke', onStroke);
socket.off('draw:clear', onClear);
socket.off('draw:word', onWord);
socket.off('connect', watch);
socket.emit('draw:unwatch');
};
}, [open]);
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
const phase = state?.phase ?? 'idle';
const inGame = phase === 'drawing' || phase === 'reveal';
useEffect(() => {
if (prevPhase.current === 'lobby' && phase === 'drawing' && isPlayer) {
setOpen(true);
}
prevPhase.current = phase;
}, [phase, isPlayer]);
// Slovo patří jen k aktuálnímu kolu
useEffect(() => {
if (!isDrawer) setWord(null);
}, [isDrawer, state?.round]);
// Plátno se po vykreslení (nové kolo, otevření okna) vyplní bílou a znovu vyžádá kresba
useEffect(() => {
if (open && inGame) {
const c = ctx();
if (c) clearCanvas(c);
socket.emit('draw:watch', { token: getToken() });
}
}, [open, inGame]);
// Odpočet kola počítaný lokálně od posledního stavu ze serveru
useEffect(() => {
if (phase !== 'drawing' || state?.remainingMs === undefined) {
setSecondsLeft(0);
return;
}
const endsAt = Date.now() + state.remainingMs;
const update = () => setSecondsLeft(Math.max(0, Math.ceil((endsAt - Date.now()) / 1000)));
update();
const id = window.setInterval(update, 250);
return () => window.clearInterval(id);
}, [phase, state?.remainingMs]);
useEffect(() => {
chatRef.current?.scrollTo({ top: chatRef.current.scrollHeight });
}, [state?.messages.length]);
// Průběžné odesílání rozkresleného tahu
useEffect(() => {
if (!isDrawer) return;
const id = window.setInterval(() => {
const p = pending.current;
if (!p || p.points.length === 0 || (p.points.length === 1 && !p.start)) return;
send('draw:stroke', { color, size, points: p.points, start: p.start });
// Další dávka začne posledním bodem, aby čára navazovala
pending.current = drawingRef.current ? { points: [p.points[p.points.length - 1]], start: false } : null;
}, FLUSH_MS);
return () => window.clearInterval(id);
}, [isDrawer, color, size, send]);
const toCoords = (e: React.PointerEvent<HTMLCanvasElement>): [number, number] => {
const rect = e.currentTarget.getBoundingClientRect();
const x = Math.round(((e.clientX - rect.left) / rect.width) * COORD_MAX);
const y = Math.round(((e.clientY - rect.top) / rect.height) * COORD_MAX);
return [Math.min(COORD_MAX, Math.max(0, x)), Math.min(COORD_MAX, Math.max(0, y))];
};
const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawer) return;
e.currentTarget.setPointerCapture(e.pointerId);
drawingRef.current = true;
const point = toCoords(e);
pending.current = { points: [point], start: true };
const c = ctx();
if (c) drawStroke(c, { color, size, points: [point], start: true });
};
const onPointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawer || !drawingRef.current || !pending.current) return;
const point = toCoords(e);
const prev = pending.current.points[pending.current.points.length - 1];
if (prev && prev[0] === point[0] && prev[1] === point[1]) return;
pending.current.points.push(point);
const c = ctx();
if (c && prev) drawStroke(c, { color, size, points: [prev, point], start: false });
};
const onPointerUp = () => {
drawingRef.current = false;
};
const submitGuess = (e: React.FormEvent) => {
e.preventDefault();
if (!guess.trim()) return;
send('draw:guess', { text: guess });
setGuess('');
};
if (!login) return null;
const me = state?.players.find(p => p.login === login);
const canChat = isPlayer && !isDrawer && !(phase === 'drawing' && me?.guessed);
const ranked = [...(state?.players ?? [])].sort((a, b) => b.score - a.score);
const showInvite = !open && phase === 'lobby' && !isPlayer;
const showReturn = !open && isPlayer && inGame;
const renderTopBar = () => {
if (phase === 'reveal' || phase === 'finished') {
return <div className="draw-topbar">Bylo to: <strong>{state?.answer}</strong></div>;
}
return (
<div className="draw-topbar">
<span>Kolo {state?.round}/{state?.totalRounds} · kreslí <strong>{state?.drawer}</strong></span>
{isDrawer
? <span className="draw-word">Nakreslete: <strong>{word ?? '…'}</strong></span>
: <span className="draw-hint">{state?.hint}</span>}
<span className={secondsLeft <= 10 ? 'draw-timer urgent' : 'draw-timer'}>⏱ {secondsLeft} s</span>
</div>
);
};
const renderGame = () => (
<>
{renderTopBar()}
<div className="draw-layout">
<div className="draw-canvas-col">
<canvas
ref={canvasRef}
className={`draw-canvas ${isDrawer ? 'active' : ''}`}
width={CANVAS_W}
height={CANVAS_H}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
/>
{isDrawer && (
<div className="draw-tools">
{COLORS.map(c => (
<button key={c} className={`draw-color ${c === color ? 'selected' : ''}`}
style={{ background: c }} onClick={() => setColor(c)} aria-label={`Barva ${c}`} />
))}
<span className="draw-sep" />
{SIZES.map(s => (
<button key={s} className={`draw-size ${s === size ? 'selected' : ''}`} onClick={() => setSize(s)}
aria-label={`Tloušťka ${s}`}>
<span style={{ width: s, height: s }} />
</button>
))}
<span className="draw-sep" />
<Button size="sm" variant="outline-secondary" onClick={() => send('draw:clear')}>Smazat</Button>
</div>
)}
</div>
<div className="draw-side">
<ul className="draw-scores">
{ranked.map(p => (
<li key={p.login} className={p.guessed && phase === 'drawing' ? 'guessed' : undefined}>
<span>{p.login === state?.drawer && phase === 'drawing' ? '✏️ ' : ''}{p.login}{p.login === login && ' (vy)'}</span>
<strong>{p.score}</strong>
</li>
))}
</ul>
{renderChat()}
</div>
</div>
</>
);
const renderChat = () => (
<>
<div className="draw-chat" ref={chatRef}>
{state?.messages.map((m, i) => (
<div key={i} className={m.system ? 'system' : undefined}>
{m.login && <strong>{m.login}: </strong>}{m.text}
</div>
))}
</div>
<Form onSubmit={submitGuess}>
<Form.Control
value={guess}
onChange={e => setGuess(e.target.value)}
placeholder={isDrawer ? 'Kreslíte…' : canChat ? 'Váš tip…' : 'Uhodli jste ✔'}
disabled={!canChat}
maxLength={100}
onKeyDown={e => e.stopPropagation()}
/>
</Form>
</>
);
const renderBody = () => {
switch (phase) {
case 'idle':
return (
<p>
Jeden kreslí jídlo z dnešních menu, ostatní hádají v chatu. Stačí trefit hlavní slovo
(např. „guláš"), diakritika nevadí. Čím dřív uhodnete, tím víc bodů; kreslíř dostává body
za každého, kdo uhodl. Každý kreslí jednou.
</p>
);
case 'lobby':
return (
<>
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
<ul className="draw-players">
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
</ul>
{renderChat()}
</>
);
case 'drawing':
case 'reveal':
return renderGame();
case 'finished':
return (
<>
<Table size="sm">
<thead><tr><th>#</th><th>Hráč</th><th>Body</th></tr></thead>
<tbody>
{ranked.map((p, i) => (
<tr key={p.login}>
<td>{i < 3 ? MEDALS[i] : i + 1}</td>
<td>{p.login}{p.login === login && ' (vy)'}</td>
<td>{p.score}</td>
</tr>
))}
</tbody>
</Table>
</>
);
}
};
const renderFooter = () => {
const buttons = [];
if (phase === 'idle' || phase === 'finished') {
buttons.push(<Button key="new" onClick={() => send('draw:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
}
if (phase === 'lobby') {
if (!isPlayer) {
buttons.push(<Button key="join" onClick={() => send('draw:join')}>Připojit se</Button>);
} else {
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('draw:leave')}>
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
</Button>);
if (login === state?.host) {
buttons.push(<Button key="start" onClick={() => send('draw:start')}
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 2)}>Spustit hru</Button>);
}
}
}
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
return buttons;
};
return (
<>
{showInvite && (
<button className="duel-invite draw-invite" onClick={() => setOpen(true)}>
{icon} <strong>{state?.host}</strong> zve na kreslení — připojit se
</button>
)}
{showReturn && (
<button className="duel-invite draw-invite" onClick={() => setOpen(true)}>
{icon} Kreslení běží — zpět do hry
</button>
)}
<Modal show={open} onHide={() => setOpen(false)} size="xl" fullscreen="sm-down" className="drawing-game-modal">
<Modal.Header closeButton>
<Modal.Title><h2>{icon} Kreslení</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{renderBody()}
{error && <div className="text-danger mt-2">{error}</div>}
</Modal.Body>
<Modal.Footer>{renderFooter()}</Modal.Footer>
</Modal>
</>
);
}
+76
View File
@@ -0,0 +1,76 @@
// Kulatý portrét postavy vedle jména strávníka s více jídly (styl Umamusume)
.eater-badge {
width: 30px;
height: 30px;
object-fit: cover;
flex: none;
margin-right: 6px;
vertical-align: middle;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 2px #ff6fa3;
cursor: help;
transition: transform 0.2s ease;
&:hover {
transform: scale(2);
position: relative;
z-index: 5;
}
}
// Styl My Little Pony: cutie mark s emoji v kulatém odznaku
.eater-badge-mlp {
display: inline-grid;
place-items: center;
font-size: 17px;
line-height: 1;
background: radial-gradient(circle at 35% 30%, #fff, #f6ecff);
box-shadow: 0 0 0 2px #8e4fd1;
}
// Styl Hell Girl: emoji v černém odznaku s rudým lemem (rudá nit)
.eater-badge-hellgirl {
display: inline-grid;
place-items: center;
font-size: 16px;
line-height: 1;
border-color: #1c1012;
background: radial-gradient(circle at 50% 60%, #3a0d16, #12070a);
box-shadow: 0 0 0 2px #c8102e, 0 0 6px rgb(200 16 46 / 0.5);
}
// Styl K-ON!: emoji na krémovém „trsátku" s lemem v barvě kytary Gitah (cherry sunburst)
.eater-badge-kon {
display: inline-grid;
place-items: center;
font-size: 16px;
line-height: 1;
border-radius: 50% 50% 50% 50% / 42% 42% 58% 58%;
background: radial-gradient(circle at 40% 35%, #fffaf0, #fbe6c8);
box-shadow: 0 0 0 2px #d43f62, 0 2px 0 3px #27315f;
}
// Styl Bocchi the Rock!: emoji na žluté samolepce s černou linkou a růžovým stínem
.eater-badge-bocchi {
display: inline-grid;
place-items: center;
font-size: 15px;
line-height: 1;
border-radius: 6px;
border-color: #1d1a22;
background: #ffe066;
box-shadow: 2px 2px 0 #e0457b;
transform: rotate(-4deg);
&:hover {
transform: rotate(0deg) scale(2);
}
}
// Odznak a jméno drží na jednom řádku (úzký sloupec „Člen" na stránce objednávek)
.user-info:has(.eater-badge) {
display: inline-flex;
align-items: center;
white-space: nowrap;
}
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useState } from "react";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
import { StyleTheme, useSettings } from "../context/settings";
import { UmaCharacter, loadUmaCharacters } from "../utils/umaCharacters";
import "./EaterBadge.scss";
/**
* Úrovně „jedlíka" podle počtu jídel: Special Week sní hodně, Oguri Cap ještě víc.
* Seřazeno od nejvyšší úrovně.
*/
const LEVELS = [
{ minItems: 3, character: 'Oguri Cap', title: 'Úroveň Oguri Cap — bezedný žaludek' },
{ minItems: 2, character: 'Special Week', title: 'Úroveň Special Week — pořádný apetit' },
];
/** Úrovně ve stylu My Little Pony — místo portrétu cutie mark (emoji). Seřazeno od nejvyšší úrovně. */
const MLP_LEVELS = [
{ minItems: 3, emoji: '🧁', title: 'Úroveň Pinkie Pie — cupcaků není nikdy dost' },
{ minItems: 2, emoji: '🍎', title: 'Úroveň Applejack — poctivý apetit' },
];
/** Úrovně ve stylu Hell Girl — emoji v černém odznaku s rudým lemem. Seřazeno od nejvyšší úrovně. */
const HG_LEVELS = [
{ minItems: 3, emoji: '🔥', title: 'Úroveň Wanyūdō — hlad jako pekelný oheň' },
{ minItems: 2, emoji: '🥀', title: 'Úroveň Ai Enma — tichý, ale nenasytný apetit' },
];
/** Úrovně ve stylu K-ON! — emoji v odznaku jako trsátko. Seřazeno od nejvyšší úrovně. */
const KON_LEVELS = [
{ minItems: 3, emoji: '🍰', title: 'Úroveň Yui — sní všechno a nepřibere ani gram' },
{ minItems: 2, emoji: '☕', title: 'Úroveň Tea Time — k obědu ještě čaj a dortík' },
];
/** Úrovně ve stylu Bocchi the Rock! — emoji na samolepce jako z flyeru live house. Seřazeno od nejvyšší úrovně. */
const BOCCHI_LEVELS = [
{ minItems: 3, emoji: '🌿', title: 'Úroveň Ryo — když dojdou peníze, sní i trávu u cesty' },
{ minItems: 2, emoji: '🥭', title: 'Úroveň Bocchi — dvojitý oběd v klidu v krabici od manga' },
];
/** Úrovně podle stylu pro odznaky s emoji. */
const EMOJI_LEVELS: Partial<Record<StyleTheme, typeof MLP_LEVELS>> = {
mlp: MLP_LEVELS,
hellgirl: HG_LEVELS,
kon: KON_LEVELS,
bocchi: BOCCHI_LEVELS,
};
type Props = {
/** Počet jídel/položek, které si strávník vybral či objednal */
items: number,
};
/** Odznak s postavou u strávníků s více jídly — jen ve stylech Umamusume, My Little Pony, Hell Girl, K-ON! a Bocchi the Rock!. */
export default function EaterBadge({ items }: Readonly<Props>) {
const styleTheme = useSettings()?.styleTheme;
const isUma = styleTheme === 'umamusume';
const [characters, setCharacters] = useState<UmaCharacter[]>([]);
useEffect(() => {
if (isUma) loadUmaCharacters().then(setCharacters);
}, [isUma]);
const emojiLevels = styleTheme && EMOJI_LEVELS[styleTheme];
if (emojiLevels) {
const emojiLevel = emojiLevels.find(l => items >= l.minItems);
if (!emojiLevel) return null;
return (
<OverlayTrigger placement="top" overlay={<Tooltip>{emojiLevel.title} ({items} jídla)</Tooltip>}>
<span className={`eater-badge eater-badge-${styleTheme}`} role="img" aria-label={emojiLevel.title}>{emojiLevel.emoji}</span>
</OverlayTrigger>
);
}
const level = LEVELS.find(l => items >= l.minItems);
const character = level && characters.find(c => c.name === level.character);
// Malý odznak potřebuje obličej — portrét (icon), případně kulatý obrázek postavy
const src = character?.icon ?? (character?.round ? character.images[0] : undefined);
if (!isUma || !level || !src) return null;
return (
<OverlayTrigger placement="top" overlay={<Tooltip>{level.title} ({items} jídla)</Tooltip>}>
<img src={src} alt={level.character} className="eater-badge" />
</OverlayTrigger>
);
}
+31
View File
@@ -0,0 +1,31 @@
// Bez zoomu dvojitým ťuknutím, jinak by iOS sérii ťuknutí na logo spolkl
.navbar-brand {
touch-action: manipulation;
}
// Skryté pole pro kód minihry — musí jít zaměřit (kvůli softwarové klávesnici), proto ne display: none.
// Písmo aspoň 16px, jinak iOS při zaměření přiblíží stránku.
.minigame-secret-input {
position: fixed;
top: 0;
left: 0;
width: 1px;
height: 1px;
padding: 0;
border: 0;
font-size: 16px;
opacity: 0;
pointer-events: none;
}
// Nenápadné potvrzení, že logo přijalo sérii kliknutí a čeká na tajné slovo
.navbar-brand.duel-armed {
display: inline-block;
animation: duel-wiggle 0.35s ease-in-out 2;
}
@keyframes duel-wiggle {
0%, 100% { transform: rotate(0); }
25% { transform: rotate(-4deg); }
75% { transform: rotate(4deg); }
}
+13 -3
View File
@@ -2,6 +2,9 @@ import { useEffect, useState } from "react";
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap"; import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
import { useAuth } from "../context/auth"; import { useAuth } from "../context/auth";
import SettingsModal from "./modals/SettingsModal"; import SettingsModal from "./modals/SettingsModal";
import DrawingGame from "./DrawingGame";
import "./Header.scss";
import { useMinigameSecret } from "../hooks/useMinigameSecret";
import { useSettings, ThemePreference } from "../context/settings"; import { useSettings, ThemePreference } from "../context/settings";
import HuePicker from "./HuePicker"; import HuePicker from "./HuePicker";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal"; import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
@@ -39,6 +42,7 @@ export default function Header({ choices, dayIndex }: Props) {
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false); const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
const effectiveDark = settings?.effectiveDark ?? false; const effectiveDark = settings?.effectiveDark ?? false;
const minigameSecret = useMinigameSecret("/");
useEffect(() => { useEffect(() => {
if (!auth?.login) return; if (!auth?.login) return;
@@ -77,6 +81,9 @@ export default function Header({ choices, dayIndex }: Props) {
setQrModalOpen(true); setQrModalOpen(true);
} }
// Tematické styly mají pevnou paletu, výběr barvy má smysl jen u jednoduchého stylu
const isSimpleStyle = (settings?.styleTheme ?? 'simple') === 'simple';
const toggleTheme = () => { const toggleTheme = () => {
const newTheme: ThemePreference = effectiveDark ? 'light' : 'dark'; const newTheme: ThemePreference = effectiveDark ? 'light' : 'dark';
settings?.setThemePreference(newTheme); settings?.setThemePreference(newTheme);
@@ -144,7 +151,9 @@ export default function Header({ choices, dayIndex }: Props) {
} }
return <Navbar variant='dark' expand="lg"> return <Navbar variant='dark' expand="lg">
<Navbar.Brand href="/">Luncher</Navbar.Brand> <Navbar.Brand href="/" onClick={minigameSecret.onLogoClick} className={minigameSecret.armed ? "duel-armed" : undefined}>Luncher</Navbar.Brand>
<input ref={minigameSecret.inputRef} className="minigame-secret-input" tabIndex={-1} aria-hidden="true"
autoComplete="off" autoCorrect="off" autoCapitalize="none" spellCheck={false} />
<Navbar.Toggle aria-controls="basic-navbar-nav" /> <Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav"> <Navbar.Collapse id="basic-navbar-nav">
<Nav className="nav"> <Nav className="nav">
@@ -156,11 +165,11 @@ export default function Header({ choices, dayIndex }: Props) {
> >
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} /> <FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
</button> </button>
<HuePicker {isSimpleStyle && <HuePicker
accentHue={settings?.accentHue ?? 142} accentHue={settings?.accentHue ?? 142}
isDark={effectiveDark} isDark={effectiveDark}
onChange={hue => settings?.setAccentHue(hue)} onChange={hue => settings?.setAccentHue(hue)}
/> />}
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown"> <NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item> <NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item> <NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
@@ -192,6 +201,7 @@ export default function Header({ choices, dayIndex }: Props) {
</NavDropdown> </NavDropdown>
</Nav> </Nav>
</Navbar.Collapse> </Navbar.Collapse>
<DrawingGame />
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} /> <SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} /> <RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} /> <PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
+24
View File
@@ -0,0 +1,24 @@
// Plamen stylu Hell Girl — hoří na černé přes obsah dialogu (.modal-content je relative a ve stylu
// Hell Girl ořezává přetečení), pak černá odezní a odhalí obsah.
.hg-burn {
position: absolute;
inset: 0;
z-index: 10;
background: #020204; // barva pozadí GIFu, aby kolem plamene nebyl vidět obdélník
pointer-events: none;
animation: hg-burn-reveal 1.5s ease-in forwards;
}
.hg-burn-flame {
position: absolute;
left: 50%;
// Dlouhý dialog může přesahovat obrazovku — plamen držet ve viditelné části
top: min(50%, 45vh);
height: min(85%, 60vh);
transform: translate(-50%, -50%);
}
@keyframes hg-burn-reveal {
0%, 50% { opacity: 1; }
100% { opacity: 0; }
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { createSymbolMask, loadHellGirlImages } from "../utils/hellGirlImages";
import "./HellGirlFlames.scss";
/** Plamen vzplane a rozpadne se v jiskry za ~1,2 s, pak GIF drží černý snímek — mezitím se obsah vynoří z černé. */
const BURN_MS = 1500;
/** `target` = obsah dialogu, ve kterém se hoří. */
type Burn = { src: string, target: HTMLElement };
/**
* Doplňky stylu Hell Girl: při otevření dialogu jeho obsah zčerná, uprostřed jednou
* vzplane plamen („Ippen, shinde miru?") a obsah se pak z černé vynoří. Dále symbol ohně ze znělky
* u titulku dne (nastaví CSS proměnnou --hg-symbol, dokud se nenačte, zůstávají v titulku původní květy).
*/
const HellGirlFlames: React.FC = () => {
const [burning, setBurning] = useState<Burn>();
const flameBlobRef = useRef<Blob | undefined>(undefined);
const burn = (target: HTMLElement) => {
const blob = flameBlobRef.current;
if (!blob || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
// Pokaždé nová URL — jinak by prohlížeč GIF (bez opakování) znovu nepřehrál
setBurning({ src: URL.createObjectURL(blob), target });
};
useEffect(() => {
if (!burning) return;
const timeout = setTimeout(() => setBurning(undefined), BURN_MS);
return () => {
clearTimeout(timeout);
URL.revokeObjectURL(burning.src);
};
}, [burning]);
useEffect(() => {
let cancelled = false;
const root = document.documentElement;
loadHellGirlImages().then(images => {
if (!images || cancelled) return;
fetch(images.flame)
.then(r => r.ok ? r.blob() : undefined)
.then(blob => { flameBlobRef.current = blob; })
.catch(() => { /* bez plamenů */ });
createSymbolMask(images.symbol).then(mask => {
if (!mask || cancelled) return;
root.style.setProperty('--hg-symbol', `url(${mask})`);
root.setAttribute('data-hg-symbol', '');
});
});
return () => {
cancelled = true;
root.style.removeProperty('--hg-symbol');
root.removeAttribute('data-hg-symbol');
};
}, []);
// Dialogy (react-bootstrap) se vkládají přes portál přímo do <body>
useEffect(() => {
const observer = new MutationObserver(mutations => {
const modalContent = mutations
.flatMap(m => Array.from(m.addedNodes))
.map(node => node instanceof HTMLElement ? node.querySelector<HTMLElement>('.modal-content') : null)
.find(element => !!element);
if (modalContent) burn(modalContent);
});
observer.observe(document.body, { childList: true });
return () => observer.disconnect();
}, []);
if (!burning) return null;
return createPortal(
<div className="hg-burn" aria-hidden="true">
<img className="hg-burn-flame" src={burning.src} alt="" />
</div>,
burning.target,
);
};
export default HellGirlFlames;
+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>
);
}
+57
View File
@@ -0,0 +1,57 @@
.price-history {
margin: 4px 0 8px;
padding: 8px 10px;
max-height: 260px;
overflow-y: auto;
border-left: 3px solid var(--luncher-border);
font-size: 0.8rem;
ul {
list-style: none;
margin: 2px 0 6px;
padding: 0;
}
li {
padding: 1px 0;
}
.price-tag {
display: inline-block;
min-width: 72px;
font-weight: 700;
}
.price-up .price-tag { color: var(--luncher-danger); }
.price-down .price-tag { color: var(--luncher-success); }
.price-new .price-tag { color: var(--luncher-primary); }
.price-removed { color: var(--luncher-text-muted); }
}
.price-history-entry + .price-history-entry {
margin-top: 6px;
}
.price-history-tabs {
display: inline-flex;
gap: 2px;
padding: 2px;
margin-bottom: 6px;
border-radius: 999px;
background: var(--luncher-bg-hover);
button {
border: none;
background: none;
border-radius: 999px;
padding: 2px 10px;
font-size: 0.75rem;
font-weight: 700;
color: var(--luncher-text-secondary);
&.active {
background: var(--luncher-bg-card);
color: var(--luncher-primary);
}
}
}
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useMemo, useState } from "react";
import { getStoreMenuHistory, StoreMenuProvider, StorePriceChange, StorePriceHistoryEntry } from "../../../types";
import { getHumanDateTime } from "../Utils";
import { computeSinceStart, plural, SinceStart } from "../utils/priceHistory";
import "./PriceHistory.scss";
type Props = {
store: string,
provider: StoreMenuProvider,
};
/** Popis jedné změny: zdražení/zlevnění s rozdílem, nová nebo odebraná položka. */
function ChangeRow({ change }: Readonly<{ change: StorePriceChange }>) {
const { name, before, after } = change;
if (before == null && after != null) {
return <li className="price-new"><span className="price-tag">nové</span> {name} <strong>{after} Kč</strong></li>;
}
if (after == null) {
return <li className="price-removed"><span className="price-tag">odebráno</span> <s>{name}</s>{before != null && <> ({before} Kč)</>}</li>;
}
const diff = after - (before ?? after);
const up = diff > 0;
return (
<li className={up ? 'price-up' : 'price-down'}>
<span className="price-tag">{up ? '▲' : '▼'} {up ? '+' : ''}{diff} Kč</span> {name}: {before} → <strong>{after} Kč</strong>
</li>
);
}
function EntryRow({ entry }: Readonly<{ entry: StorePriceHistoryEntry }>) {
const when = getHumanDateTime(new Date(entry.at));
if (entry.initial) {
return <div className="price-history-entry"><small className="text-muted">{when} — první stažení ({plural(entry.itemCount ?? 0, 'položka', 'položky', 'položek')})</small></div>;
}
const ups = entry.changes.filter(c => c.before != null && c.after != null && c.after > c.before).length;
const downs = entry.changes.filter(c => c.before != null && c.after != null && c.after < c.before).length;
return (
<div className="price-history-entry">
<small className="text-muted">
{when} — {plural(entry.changes.length, 'změna', 'změny', 'změn')}{ups > 0 && `, ${ups}× zdražení`}{downs > 0 && `, ${downs}× zlevnění`}
</small>
<ul>{entry.changes.map((c, i) => <ChangeRow key={i} change={c} />)}</ul>
</div>
);
}
function SinceStartView({ summary }: Readonly<{ summary: SinceStart }>) {
const { since, changed, added, removed } = summary;
if (changed.length + added.length + removed.length === 0) {
return <small className="text-muted">Od {getHumanDateTime(new Date(since))} beze změn.</small>;
}
const ups = changed.filter(c => c.current > c.start).length;
const avgPct = changed.length
? changed.reduce((sum, c) => sum + (c.current - c.start) / c.start * 100, 0) / changed.length
: 0;
return (
<div className="price-history-entry">
<small className="text-muted">
Od {getHumanDateTime(new Date(since))}: {plural(changed.length, 'změna ceny', 'změny cen', 'změn cen')} ({ups}× zdražení, {changed.length - ups}× zlevnění)
{changed.length > 0 && `, průměrně ${avgPct > 0 ? '+' : ''}${avgPct.toFixed(1)} %`}
{added.length > 0 && `, ${plural(added.length, 'nová položka', 'nové položky', 'nových položek')}`}{removed.length > 0 && `, ${plural(removed.length, 'odebraná', 'odebrané', 'odebraných')}`}
</small>
<ul>
{changed.map(c => {
const diff = c.current - c.start;
const up = diff > 0;
return (
<li key={c.name} className={up ? 'price-up' : 'price-down'}>
<span className="price-tag">{up ? '▲' : '▼'} {up ? '+' : ''}{diff} Kč</span> {c.name}: {c.start} → <strong>{c.current} Kč</strong>
<small className="text-muted"> ({up ? '+' : ''}{(diff / c.start * 100).toFixed(0)} %)</small>
</li>
);
})}
{added.map(a => <li key={`+${a.name}`} className="price-new"><span className="price-tag">nové</span> {a.name}{a.price != null && <> <strong>{a.price} Kč</strong></>}</li>)}
{removed.map(r => <li key={`-${r.name}`} className="price-removed"><span className="price-tag">odebráno</span> <s>{r.name}</s>{r.price != null && <> ({r.price} Kč)</>}</li>)}
</ul>
</div>
);
}
/** Historie cen nabídky obchodu u jedné služby (verze z jednotlivých stažení, nejnovější nahoře). */
export default function PriceHistory({ store, provider }: Readonly<Props>) {
const [entries, setEntries] = useState<StorePriceHistoryEntry[]>();
const [view, setView] = useState<'versions' | 'since'>('versions');
const summary = useMemo(() => entries ? computeSinceStart(entries) : undefined, [entries]);
useEffect(() => {
getStoreMenuHistory({ query: { name: store } })
.then(r => setEntries(r.data?.providers.find(p => p.provider === provider)?.entries ?? []))
.catch(() => setEntries([]));
}, [store, provider]);
if (!entries) return <small className="text-muted">Načítám historii…</small>;
if (entries.length === 0) return <small className="text-muted">Historie cen zatím není — vznikne s prvním stažením nabídky.</small>;
const hasChanges = entries.some(e => e.changes.length > 0);
return (
<div className="price-history">
{hasChanges && (
<div className="price-history-tabs">
<button type="button" className={view === 'versions' ? 'active' : ''} onClick={() => setView('versions')}>Po verzích</button>
<button type="button" className={view === 'since' ? 'active' : ''} onClick={() => setView('since')}>Od začátku</button>
</div>
)}
{view === 'since' && summary
? <SinceStartView summary={summary} />
: entries.map(e => <EntryRow key={e.at} entry={e} />)}
{entries.length === 1 && entries[0].initial && (
<small className="text-muted d-block mt-1">Změny se objeví po dalším stažení (↻).</small>
)}
</div>
);
}
+475
View File
@@ -0,0 +1,475 @@
.wheel-confetti {
position: fixed;
inset: 0;
z-index: 2000; // nad modalem (bootstrap 1055)
pointer-events: none;
overflow: hidden;
> span {
position: absolute;
will-change: transform, opacity;
animation-fill-mode: both;
}
}
// Vrh: rychle vzhůru do vrcholu (zpomalení), pak volný pád (zrychlení)
@keyframes confetti-throw {
0% { transform: translate3d(0, 0, 0) rotate(0deg) scale(0.4); opacity: 0; animation-timing-function: cubic-bezier(0.2, 0.7, 0.4, 1); }
6% { opacity: 1; }
38% { transform: translate3d(var(--x1), var(--y1), 0) rotate(var(--r1)) scale(1); animation-timing-function: cubic-bezier(0.5, 0, 0.9, 0.6); }
88% { opacity: 1; }
100% { transform: translate3d(var(--x2), var(--y2), 0) rotate(var(--r2)) scale(1); opacity: 0; }
}
// Déšť: plynulý pád shora s mírným houpáním
@keyframes confetti-rain {
0% { transform: translate3d(0, 0, 0) rotate(0deg); opacity: 0; animation-timing-function: ease-in-out; }
8% { opacity: 1; }
40% { transform: translate3d(var(--x1), var(--y1), 0) rotate(var(--r1)); animation-timing-function: ease-in; }
88% { opacity: 1; }
100% { transform: translate3d(var(--x2), var(--y2), 0) rotate(var(--r2)); opacity: 0; }
}
// Samolepky Umamusume — stín pro čitelnost na libovolném pozadí
.confetti-uma {
filter: drop-shadow(0 3px 3px rgb(0 0 0 / 0.2));
}
.confetti-uma-emoji {
line-height: 1;
}
.confetti-uma-badge {
padding: 4px 14px;
border-radius: 999px;
border: 3px solid #fff;
color: #fff;
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
font-weight: 900;
font-size: 1.15rem;
white-space: nowrap;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.2);
box-shadow: 0 4px 0 rgb(0 0 0 / 0.15);
}
.confetti-uma-img img {
width: 92px;
height: auto;
}
// Portréty (bez průhlednosti) jako kulaté samolepky s barevným lemem
.confetti-uma-img.round img,
.confetti-uma-voice img.round {
width: 84px;
height: 84px;
object-fit: cover;
border-radius: 50%;
border: 4px solid #fff;
box-shadow: 0 0 0 3px #ff6fa3, 0 4px 8px rgb(0 0 0 / 0.3);
}
.confetti-uma-voice img.round {
width: 96px;
height: 96px;
margin-bottom: 8px;
}
.confetti-uma-banner {
position: absolute;
top: 38%;
left: 50%;
transform: translate(-50%, -50%);
padding: 12px 48px;
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
font-weight: 900;
font-size: clamp(2rem, 7vw, 4rem);
color: #fff;
background: linear-gradient(90deg, #e84d86, #ff6fa3);
clip-path: polygon(24px 0, 100% 0, calc(100% - 24px) 100%, 0 100%);
text-shadow: 0 3px 0 rgb(0 0 0 / 0.2);
border-top: 5px solid #ffc531;
border-bottom: 5px solid #ffc531;
animation: uma-banner 1.8s cubic-bezier(0.2, 1.4, 0.4, 1) both;
}
@keyframes uma-banner {
0% { transform: translate(-150%, -50%) skewX(-12deg); opacity: 0; }
25% { transform: translate(-50%, -50%) skewX(-12deg); opacity: 1; }
75% { transform: translate(-50%, -50%) skewX(-12deg) scale(1.04); opacity: 1; }
100% { transform: translate(60%, -50%) skewX(-12deg); opacity: 0; }
}
// „Hláška" postavy při finálním výsledku
.confetti-uma-voice {
position: absolute;
left: 16px;
bottom: 16px;
display: flex;
align-items: flex-end;
gap: 8px;
max-width: min(460px, calc(100vw - 32px));
animation: uma-voice 4.6s ease both;
img {
flex: none;
width: 110px;
height: auto;
filter: drop-shadow(0 4px 6px rgb(0 0 0 / 0.3));
}
}
.confetti-uma-bubble {
position: relative;
margin-bottom: 40px;
padding: 12px 16px;
background: #fff;
color: #1d3322;
border: 3px solid #ff6fa3;
border-radius: 18px;
box-shadow: 0 4px 0 #e84d86;
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
p {
margin: 0;
font-weight: 800;
font-size: 0.95rem;
line-height: 1.35;
}
small {
display: block;
margin-top: 4px;
color: #e84d86;
font-weight: 800;
}
// ocásek bubliny směrem k postavě
&::before {
content: '';
position: absolute;
left: -14px;
bottom: 14px;
border: 8px solid transparent;
border-right: 10px solid #ff6fa3;
}
}
@keyframes uma-voice {
0% { opacity: 0; transform: translateY(40px) scale(0.8); }
10% { opacity: 1; transform: translateY(0) scale(1.03); }
15% { transform: translateY(0) scale(1); }
85% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(20px); }
}
// ============================================
// Konfety ve stylu My Little Pony
// ============================================
.confetti-mlp {
filter: drop-shadow(0 3px 3px rgb(0 0 0 / 0.2));
}
.confetti-mlp-badge {
padding: 4px 14px;
border-radius: 999px;
border: 3px solid #fff;
color: #fff;
font-family: 'Fredoka', 'Nunito', sans-serif;
font-weight: 700;
font-size: 1.1rem;
white-space: nowrap;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.2);
box-shadow: 0 0 12px rgb(255 255 255 / 0.7), 0 4px 0 rgb(0 0 0 / 0.15);
}
// Duhový nápis „Sonic Rainboom" přes celou obrazovku
.confetti-mlp-banner {
position: absolute;
top: 38%;
left: 50%;
transform: translate(-50%, -50%);
padding: 12px 44px;
font-family: 'Fredoka', 'Nunito', sans-serif;
font-weight: 700;
font-size: clamp(1.8rem, 6.5vw, 3.6rem);
white-space: nowrap;
color: #fff;
border-radius: 999px;
background: linear-gradient(90deg, #ee4144, #f37033, #e3b829, #62bc4d, #1e98d3, #672f89);
border: 5px solid #fff;
text-shadow: 0 3px 0 rgb(0 0 0 / 0.25);
box-shadow: 0 0 30px rgb(236 61 143 / 0.5), 0 8px 0 rgb(0 0 0 / 0.12);
animation: mlp-banner 2s cubic-bezier(0.2, 1.4, 0.4, 1) both;
}
@keyframes mlp-banner {
0% { transform: translate(-50%, -50%) scale(0.2) rotate(-8deg); opacity: 0; }
25% { transform: translate(-50%, -50%) scale(1.08) rotate(0deg); opacity: 1; }
35% { transform: translate(-50%, -50%) scale(1); }
75% { transform: translate(-50%, -50%) scale(1.03); opacity: 1; }
100% { transform: translate(-50%, -160%) scale(0.9); opacity: 0; }
}
// Postava s hláškou — místo obrázku kulatý „cutie mark" s emoji
.confetti-mlp-avatar {
flex: none;
display: grid;
place-items: center;
width: 84px;
height: 84px;
margin-bottom: 8px;
font-size: 46px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff, #f6ecff);
border: 4px solid #fff;
box-shadow: 0 0 0 4px var(--mlp-speaker, #8e4fd1), 0 4px 10px rgb(0 0 0 / 0.3);
}
.confetti-mlp-bubble {
position: relative;
margin-bottom: 40px;
padding: 12px 16px;
background: #fff;
color: #2e1f47;
border: 3px solid var(--mlp-speaker, #8e4fd1);
border-radius: 22px;
box-shadow: 0 4px 0 var(--mlp-speaker, #8e4fd1);
font-family: 'Fredoka', 'Nunito', sans-serif;
p {
margin: 0;
font-weight: 600;
font-size: 1rem;
line-height: 1.35;
}
small {
display: block;
margin-top: 4px;
color: var(--mlp-speaker, #8e4fd1);
font-weight: 700;
}
// ocásek bubliny směrem k postavě
&::before {
content: '';
position: absolute;
left: -14px;
bottom: 14px;
border: 8px solid transparent;
border-right: 10px solid var(--mlp-speaker, #8e4fd1);
}
}
// ============================================
// Konfety ve stylu Hell Girl
// ============================================
.confetti-hg {
filter: drop-shadow(0 0 6px rgb(200 16 46 / 0.55)) drop-shadow(0 3px 3px rgb(0 0 0 / 0.3));
}
.confetti-hg-badge {
padding: 4px 14px;
border: 1px solid #c8102e;
outline: 1px solid rgb(200 16 46 / 0.4);
outline-offset: 2px;
color: #fff4ee;
font-family: 'Shippori Mincho', 'Noto Serif JP', serif;
font-weight: 800;
font-size: 1.1rem;
letter-spacing: 1px;
white-space: nowrap;
text-shadow: 0 0 8px rgb(232 52 79 / 0.7);
}
// Nápis „poslání do pekla" — černá deska s rudým lemem, vynoří se z mlhy
.confetti-hg-banner {
position: absolute;
top: 38%;
left: 50%;
transform: translate(-50%, -50%);
padding: 14px 48px;
font-family: 'Shippori Mincho', 'Noto Serif JP', serif;
font-weight: 800;
font-size: clamp(2rem, 7vw, 4rem);
letter-spacing: 0.3em;
white-space: nowrap;
color: #fff4ee;
background: linear-gradient(90deg, transparent, #12070a 15%, #12070a 85%, transparent);
border-top: 2px solid #c8102e;
border-bottom: 2px solid #c8102e;
text-shadow: 0 0 18px rgb(232 52 79 / 0.9), 0 0 4px #c8102e;
animation: hg-banner 2.6s ease both;
}
@keyframes hg-banner {
0% { transform: translate(-50%, -50%) scale(1.15); opacity: 0; filter: blur(12px); }
25% { transform: translate(-50%, -50%) scale(1); opacity: 1; filter: blur(0); }
75% { transform: translate(-50%, -50%) scale(1); opacity: 1; filter: blur(0); }
100% { transform: translate(-50%, -50%) scale(0.96); opacity: 0; filter: blur(8px); }
}
// Postava s hláškou — tmavý odznak s rudou září a bublina jako svitek
.confetti-hellgirl-voice {
.confetti-mlp-avatar {
border-color: #12070a;
background: radial-gradient(circle at 50% 60%, #3a0d16, #12070a);
box-shadow: 0 0 0 3px var(--mlp-speaker, #c8102e), 0 0 18px rgb(200 16 46 / 0.6);
}
.confetti-mlp-bubble {
background: #170c0f;
color: #f3e6e2;
border: 1px solid var(--mlp-speaker, #c8102e);
border-radius: 3px;
box-shadow: 0 0 16px rgb(0 0 0 / 0.6), 0 0 10px rgb(200 16 46 / 0.3);
font-family: 'Shippori Mincho', 'Noto Serif JP', serif;
}
}
// ============================================
// Konfety ve stylu K-ON!
// ============================================
.confetti-kon {
filter: drop-shadow(0 3px 3px rgb(39 49 95 / 0.25));
}
// Samolepky jako na pouzdru kytary — bílý okraj a lehce natočené
.confetti-kon-badge {
padding: 4px 14px;
border-radius: 10px;
border: 3px solid #fff;
color: #fff;
font-family: 'Zen Maru Gothic', 'M PLUS Rounded 1c', sans-serif;
font-weight: 900;
font-size: 1.05rem;
white-space: nowrap;
text-shadow: 0 1px 0 rgb(0 0 0 / 0.2);
box-shadow: 0 3px 0 rgb(39 49 95 / 0.25);
}
// Nápis „Houkago Tea Time" — vstupenka na školní koncert s notovou osnovou
.confetti-kon-banner {
position: absolute;
top: 38%;
left: 50%;
transform: translate(-50%, -50%);
padding: 14px 44px;
font-family: 'Zen Maru Gothic', 'M PLUS Rounded 1c', sans-serif;
font-weight: 900;
font-size: clamp(1.6rem, 6vw, 3.4rem);
white-space: nowrap;
color: #27315f;
border-radius: 18px;
background:
repeating-linear-gradient(180deg, transparent 0 9px, rgb(39 49 95 / 0.12) 9px 10px) center / 100% 50px no-repeat,
#fffaf0;
border: 4px solid #27315f;
box-shadow: 0 0 0 6px #fffaf0, 0 0 0 9px #d43f62, 0 12px 26px rgb(0 0 0 / 0.25);
animation: kon-banner 2.2s cubic-bezier(0.3, 1.5, 0.5, 1) both;
}
// Vyskočí do rytmu (raz-dva) a odplave nahoru
@keyframes kon-banner {
0% { transform: translate(-50%, -50%) scale(0.3) rotate(6deg); opacity: 0; }
18% { transform: translate(-50%, -50%) scale(1.1) rotate(-3deg); opacity: 1; }
28% { transform: translate(-50%, -50%) scale(1) rotate(-2deg); }
40% { transform: translate(-50%, -50%) scale(1.05) rotate(-2deg); }
50% { transform: translate(-50%, -50%) scale(1) rotate(-2deg); }
80% { transform: translate(-50%, -50%) scale(1) rotate(-2deg); opacity: 1; }
100% { transform: translate(-50%, -150%) scale(0.9) rotate(-2deg); opacity: 0; }
}
// Postava s hláškou — krémový odznak a bublina jako stránka notového sešitu
.confetti-kon-voice {
.confetti-mlp-avatar {
background: radial-gradient(circle at 40% 35%, #fffaf0, #fbe6c8);
box-shadow: 0 0 0 4px var(--mlp-speaker, #d43f62), 0 4px 10px rgb(0 0 0 / 0.3);
}
.confetti-mlp-bubble {
background:
repeating-linear-gradient(180deg, transparent 0 21px, rgb(39 49 95 / 0.08) 21px 22px),
#fffaf0;
color: #27315f;
border-radius: 16px;
font-family: 'Zen Maru Gothic', 'M PLUS Rounded 1c', sans-serif;
p {
font-weight: 700;
}
}
}
// ============================================
// Konfety ve stylu Bocchi the Rock!
// ============================================
.confetti-bocchi {
filter: drop-shadow(3px 3px 0 rgb(29 26 34 / 0.85));
}
// Samolepky z flyeru live house — černá linka a tvrdý stín
.confetti-bocchi-badge {
padding: 4px 14px;
border-radius: 6px;
border: 3px solid #1d1a22;
color: #fff;
font-family: 'Dela Gothic One', 'M PLUS Rounded 1c', sans-serif;
font-size: 1.05rem;
white-space: nowrap;
}
// Nápis „Kessoku Band" — žlutá samolepka s růžovým stínem, při dopadu se zachvěje jako Bocchi
.confetti-bocchi-banner {
position: absolute;
top: 38%;
left: 50%;
transform: translate(-50%, -50%);
padding: 12px 40px;
font-family: 'Dela Gothic One', 'M PLUS Rounded 1c', sans-serif;
font-size: clamp(1.8rem, 7vw, 4rem);
white-space: nowrap;
color: #1d1a22;
background: #ffcf33;
border: 5px solid #1d1a22;
border-radius: 10px;
box-shadow: 10px 10px 0 #e0457b, 18px 18px 0 #1d1a22;
animation: bocchi-banner 2.4s ease both;
}
@keyframes bocchi-banner {
0% { transform: translate(-50%, -50%) scale(2.2) rotate(-8deg); opacity: 0; }
14% { transform: translate(-50%, -50%) scale(1) rotate(-3deg); opacity: 1; }
17% { transform: translate(calc(-50% - 6px), -50%) rotate(-3deg); }
20% { transform: translate(calc(-50% + 6px), -50%) rotate(-3deg); }
23% { transform: translate(calc(-50% - 3px), -50%) rotate(-3deg); }
26%, 80% { transform: translate(-50%, -50%) rotate(-3deg); opacity: 1; }
100% { transform: translate(-50%, 60%) rotate(4deg); opacity: 0; }
}
// Postava s hláškou — hranatý odznak a bublina jako z mangy s tvrdým stínem
.confetti-bocchi-voice {
.confetti-mlp-avatar {
border-radius: 12px;
border: 3px solid #1d1a22;
background: #ffcf33;
box-shadow: 5px 5px 0 var(--mlp-speaker, #e0457b);
}
.confetti-mlp-bubble {
background: #fff;
color: #1d1a22;
border: 3px solid #1d1a22;
border-radius: 10px;
box-shadow: 5px 5px 0 var(--mlp-speaker, #e0457b);
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
p {
font-weight: 800;
}
&::before {
border-right-color: #1d1a22;
}
}
}
+278
View File
@@ -0,0 +1,278 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { StyleTheme } from "../context/settings";
import { UmaCharacter, loadUmaCharacters } from "../utils/umaCharacters";
import "./WheelConfetti.scss";
type WheelTheme = StyleTheme;
type Props = {
theme: WheelTheme,
/** Mění se s každým výsledkem — nová hodnota spustí novou salvu. */
burstKey: number,
/** Velká salva (finální výsledek) vs. menší (mezivýsledek). */
big?: boolean,
};
type UmaImage = { src: string, round: boolean };
const SIMPLE_COLORS = ['#16a34a', '#0ea5e9', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#ef4444'];
const UMA_EMOJI = ['🐴', '🏇', '🥕', '🏆', '🌸', '✨', '⭐', '🍙', '🎀', '🥇'];
// Hlášky z fandomu Umamusume (Umapyoi Densetsu, Teio step, hachimī, Golshi, Oguri „okawari"...)
const UMA_BADGES = ['1着!', 'Umazing!', 'Umapyoi!', 'Teio step!', 'Hachimī~', 'Golshi approves', 'Okawari!', 'Pakapaka~', 'ウマい!'];
const UMA_BADGE_COLORS = ['#ff6fa3', '#2e9a3a', '#43b8f0', '#f2a91a'];
const MLP_EMOJI = ['🦄', '🌈', '✨', '⭐', '🧁', '🍎', '🦋', '💎', '🎈', '🎉', '🍰', '💜'];
// Hlášky a narážky z My Little Pony: Friendship is Magic
const MLP_BADGES = ['Friendship is Magic!', '20% cooler!', 'Sonic Rainboom!', 'Yay!', 'Party!', 'Yeehaw!', 'Everypony!', 'Awesome!', 'Darling!', 'Pinkie Promise'];
const MLP_BADGE_COLORS = ['#8e4fd1', '#f06aa6', '#1e98d3', '#f37033', '#e3b829', '#5b4fc9'];
/** Postavy Mane 6 (+ Spike) s „hláškou" po finálním výsledku — místo obrázků cutie mark emoji. */
const MLP_SPEAKERS = [
{ name: 'Twilight Sparkle', emoji: '⭐', color: '#8e4fd1', quote: 'Podle mého seznamu je to ta nejlogičtější volba!' },
{ name: 'Rainbow Dash', emoji: '🌈', color: '#1e98d3', quote: 'Tohle je o 20 % úžasnější než ostatní!' },
{ name: 'Pinkie Pie', emoji: '🎈', color: '#f06aa6', quote: 'Tohle volá po PÁRTY! S dortem! A s cupcaky!' },
{ name: 'Applejack', emoji: '🍎', color: '#f37033', quote: 'Poctivé jídlo pro poctivou práci. Jíhá!' },
{ name: 'Fluttershy', emoji: '🦋', color: '#e3b829', quote: 'Jé… teda, jestli to nevadí… yay.' },
{ name: 'Rarity', emoji: '💎', color: '#5b4fc9', quote: 'Naprosto božská volba, drahoušku!' },
{ name: 'Spike', emoji: '🐉', color: '#62bc4d', quote: 'Zapsáno do svitku a odesláno princezně!' },
];
const HG_EMOJI = ['🥀', '🏮', '⛩️', '🔥', '🕯️', '🌑', '🦋', '👁️', '🕸️', '🌸'];
// Hlášky z Jigoku Shōjo (Pekelná korespondence, „Ippen, shinde miru?", poslání do pekla...)
const HG_BADGES = ['いっぺん、死んでみる?', '地獄通信', '地獄流し', 'Ippen… shinde miru?', 'Pomsta vykonána', '0:00', 'Uvaž to dobře…', 'Smlouva uzavřena'];
const HG_BADGE_COLORS = ['#12070a', '#7a0c1f', '#2e0d24'];
/** Ai Enma a její služebníci s „hláškou" po finálním výsledku — místo obrázků emoji. */
const HG_SPEAKERS = [
{ name: 'Ai Enma', emoji: '🥀', color: '#c8102e', quote: 'Smlouva je uzavřena. Tenhle oběd tě bude provázet až na věčnost.' },
{ name: 'Ichimoku Ren', emoji: '👁️', color: '#6b5a8a', quote: 'Prohlédl jsem svým okem všechny podniky. Tenhle jediný obstál.' },
{ name: 'Hone Onna', emoji: '💀', color: '#b8862b', quote: 'Dobré až na kost, zlato. Mně z toho stejně nic nezůstane.' },
{ name: 'Wanyūdō', emoji: '🔥', color: '#d9582b', quote: 'Já jsem kolo. Kolo rozhodlo. Víc k tomu nemám.' },
{ name: 'Kikuri', emoji: '🎀', color: '#d4407a', quote: 'Hihi! Tohle bych do pekla poslala jako první!' },
{ name: 'Babička', emoji: '🕸️', color: '#8c7672', quote: 'Ai, oběd je na stole… jako každý den už čtyři sta let.' },
];
const KON_EMOJI = ['🎸', '🥁', '🎹', '🎵', '🎶', '🍰', '☕', '🍓', '🐢', '🎀', '🍪', '🫖'];
// Hlášky z K-ON! (Houkago Tea Time, Fuwa Fuwa Time, Gitah, Ton-chan, Mugi a její čaj...)
const KON_BADGES = ['放課後ティータイム', 'Fuwa Fuwa Time ♪', 'Tea time!', 'Gitah!', 'Ton-chan ♡', 'けいおん!', 'Nejdřív čaj, pak zkouška', "Don't say lazy", 'Azunyan~', 'Ui!'];
const KON_BADGE_COLORS = ['#d43f62', '#3d6fc4', '#e8a10f', '#8a6bc7', '#e46f9a', '#27315f'];
/** Členky Houkago Tea Time (+ Ui, Sawa-chan a Ton-chan) s „hláškou" po finálním výsledku. */
const KON_SPEAKERS = [
{ name: 'Yui Hirasawa', emoji: '🎸', color: '#d43f62', quote: 'Gitah říká, že tohle bude mňamka! Fuwa fuwa~' },
{ name: 'Mio Akiyama', emoji: '🎼', color: '#3d6fc4', quote: 'Dobře… ale hned po obědě se jde zkoušet! Opravdu!' },
{ name: 'Ritsu Tainaka', emoji: '🥁', color: '#e8a10f', quote: 'Prezidentka klubu rozhodla! Raz, dva, raz dva tři čtyři!' },
{ name: 'Tsumugi Kotobuki', emoji: '🫖', color: '#c9a227', quote: 'Čaj a dortík k tomu přinesu já. Těším se!' },
{ name: 'Azusa Nakano', emoji: '🐱', color: '#8a6bc7', quote: 'Zase jídlo místo zkoušky? …No dobře, ale jen chvilku!' },
{ name: 'Ui Hirasawa', emoji: '🍳', color: '#e46f9a', quote: 'Sestřičko, nezapomeň si vzít ubrousek!' },
{ name: 'Sawako Yamanaka', emoji: '👗', color: '#27315f', quote: 'Oběd? Výborně. Mezitím vám ušiju nové kostýmy!' },
{ name: 'Ton-chan', emoji: '🐢', color: '#3c9a6b', quote: '(spokojeně mává ploutvičkou)' },
];
const BOCCHI_EMOJI = ['🎸', '🥁', '🎤', '📦', '🥭', '🟨', '🟦', '✨', '🌿', '🫠', '⭐', '🔊'];
// Hlášky z Bocchi the Rock! (Kessoku Band, Kita-aura, krabice od manga, guitarhero, live house STARRY...)
const BOCCHI_BADGES = ['結束バンド', 'Kessoku Band!', 'Kitaaan ✨', 'guitarhero', 'A-ano…', 'Live at STARRY', 'Bocchi-chan!', 'ぼっち・ざ・ろっく!', 'Mango box', 'Rock!'];
const BOCCHI_BADGE_COLORS = ['#e0457b', '#e8b400', '#3a63c9', '#d93a30', '#1d1a22'];
/** Kessoku Band a jejich okolí s „hláškou" po finálním výsledku. */
const BOCCHI_SPEAKERS = [
{ name: 'Hitori Gotoh', emoji: '🎸', color: '#e0457b', quote: 'A-ano… t-tohle by šlo… teda, pokud to nikomu nevadí…' },
{ name: 'Nijika Ijichi', emoji: '🥁', color: '#e8b400', quote: 'Tak jo, kapelo, jdeme jíst! Bocchi, vylez z té krabice!' },
{ name: 'Ryo Yamada', emoji: '🌿', color: '#3a63c9', quote: 'Dobrá volba. Mimochodem… nepůjčíš mi na oběd?' },
{ name: 'Ikuyo Kita', emoji: '✨', color: '#d93a30', quote: 'Kitaaan! Tohle se mi děsně líbí, vyfotím to na Instagram!' },
{ name: 'Seika Ijichi', emoji: '🎛️', color: '#7a4fc0', quote: 'Najezte se pořádně. Večer v STARRY neodfláknete zkoušku.' },
{ name: 'Futari Gotoh', emoji: '🐕', color: '#f08fb4', quote: 'Ségra zase obědvá ve skříni! Jimihen, pojď se podívat!' },
];
/** Styly, jejichž konfety tvoří emoji a samolepky s hláškami (a po finále promluví postava s emoji). */
const EMOJI_CONFETTI: Partial<Record<WheelTheme, {
prefix: string, badges: string[], colors: string[], emoji: string[],
speakers: { name: string, emoji: string, color: string, quote: string }[],
banner: string,
}>> = {
mlp: { prefix: 'confetti-mlp', badges: MLP_BADGES, colors: MLP_BADGE_COLORS, emoji: MLP_EMOJI, speakers: MLP_SPEAKERS, banner: '🌈 Sonic Rainboom! 🌈' },
hellgirl: { prefix: 'confetti-hg', badges: HG_BADGES, colors: HG_BADGE_COLORS, emoji: HG_EMOJI, speakers: HG_SPEAKERS, banner: '🏮 地獄流し 🏮' },
kon: { prefix: 'confetti-kon', badges: KON_BADGES, colors: KON_BADGE_COLORS, emoji: KON_EMOJI, speakers: KON_SPEAKERS, banner: '🎸 放課後ティータイム 🍰' },
bocchi: { prefix: 'confetti-bocchi', badges: BOCCHI_BADGES, colors: BOCCHI_BADGE_COLORS, emoji: BOCCHI_EMOJI, speakers: BOCCHI_SPEAKERS, banner: '🎸 結束バンド 🎸' },
};
const DURATION_MS = 6400;
const rand = (min: number, max: number) => min + Math.random() * (max - min);
const pick = <T,>(list: T[]) => list[Math.floor(Math.random() * list.length)];
type Piece = { id: number, style: React.CSSProperties, content: React.ReactNode, className: string };
type Launch = 'cannon-left' | 'cannon-right' | 'burst' | 'rain';
const LAUNCHES: Launch[] = ['cannon-left', 'cannon-left', 'cannon-right', 'cannon-right', 'burst', 'rain'];
/**
* Výchozí pozice a dráha jednoho kousku: děla z dolních rohů střílí šikmo vzhůru,
* „výbuch" se rozletí ze středu a déšť padá opožděně shora. Kousek nejprve vyletí
* do vrcholu (--x1/--y1) a pak dopadne mimo obrazovku (--x2/--y2).
*/
function launchStyle(launch: Launch): React.CSSProperties {
let left: number, top: number, x1: number, y1: number, x2: number, y2: number, delay: number;
switch (launch) {
case 'cannon-left':
case 'cannon-right': {
const dir = launch === 'cannon-left' ? 1 : -1;
left = launch === 'cannon-left' ? rand(-3, 3) : rand(97, 103);
top = rand(88, 100);
x1 = dir * rand(12, 62);
y1 = -rand(45, 88);
x2 = x1 + dir * rand(6, 22);
y2 = rand(12, 25);
delay = rand(0, 450);
break;
}
case 'burst': {
const angle = rand(0, Math.PI * 2);
const radius = rand(18, 48);
left = 50;
top = 42;
x1 = Math.cos(angle) * radius;
y1 = Math.sin(angle) * radius * 0.8 - rand(5, 15);
x2 = x1 * 1.35;
y2 = rand(70, 85);
delay = rand(0, 250);
break;
}
default: {
left = rand(0, 100);
top = -12;
x1 = rand(-8, 8);
y1 = rand(35, 55);
x2 = x1 + rand(-10, 10);
y2 = rand(115, 125);
delay = rand(400, 1800);
}
}
const spin = rand(180, 720) * (Math.random() < 0.5 ? -1 : 1);
return {
left: `${left}vw`,
top: `${top}vh`,
animationName: launch === 'rain' ? 'confetti-rain' : 'confetti-throw',
animationDelay: `${delay}ms`,
animationDuration: `${rand(2600, 3900)}ms`,
['--x1' as any]: `${x1}vw`,
['--y1' as any]: `${y1}vh`,
['--x2' as any]: `${x2}vw`,
['--y2' as any]: `${y2}vh`,
['--r1' as any]: `${spin * 0.4}deg`,
['--r2' as any]: `${spin}deg`,
};
}
function buildPieces(theme: WheelTheme, big: boolean, images: UmaImage[]): Piece[] {
if (theme === 'simple') {
const count = big ? 170 : 80;
return Array.from({ length: count }, (_, id) => ({
id,
className: 'confetti-paper',
content: null,
style: {
...launchStyle(pick(LAUNCHES)),
width: `${rand(6, 11)}px`,
height: `${rand(10, 18)}px`,
background: pick(SIMPLE_COLORS),
borderRadius: Math.random() < 0.3 ? '50%' : '2px',
},
}));
}
const count = big ? 50 : 24;
const emojiConfetti = EMOJI_CONFETTI[theme];
if (emojiConfetti) {
const { prefix, badges, colors, emoji } = emojiConfetti;
return Array.from({ length: count }, (_, id) => {
const base: React.CSSProperties = {
...launchStyle(pick(LAUNCHES)),
['--r1' as any]: `${rand(-25, 25)}deg`,
['--r2' as any]: `${rand(-40, 40)}deg`,
};
if (Math.random() < 0.35) {
return { id, className: `${prefix} ${prefix}-badge`, content: pick(badges),
style: { ...base, background: pick(colors) } };
}
return { id, className: `${prefix} confetti-uma-emoji`, style: { ...base, fontSize: `${rand(28, 52)}px` }, content: pick(emoji) };
});
}
return Array.from({ length: count }, (_, id) => {
const roll = Math.random();
// Samolepky se tolik netočí, ať jsou čitelné
const base: React.CSSProperties = {
...launchStyle(pick(LAUNCHES)),
['--r1' as any]: `${rand(-25, 25)}deg`,
['--r2' as any]: `${rand(-40, 40)}deg`,
};
if (images.length && roll < 0.45) {
const image = pick(images);
return { id, className: `confetti-uma confetti-uma-img${image.round ? ' round' : ''}`, style: base,
content: <img src={image.src} alt="" loading="eager" /> };
}
if (roll < 0.7) {
return { id, className: 'confetti-uma confetti-uma-badge', content: pick(UMA_BADGES),
style: { ...base, background: pick(UMA_BADGE_COLORS) } };
}
return { id, className: 'confetti-uma confetti-uma-emoji', style: { ...base, fontSize: `${rand(28, 52)}px` }, content: pick(UMA_EMOJI) };
});
}
/** Salva konfet po výsledku kola štěstí, ve vzhledu podle zvoleného stylu. */
export default function WheelConfetti({ theme, burstKey, big = false }: Readonly<Props>) {
const [characters, setCharacters] = useState<UmaCharacter[]>([]);
const [active, setActive] = useState(false);
useEffect(() => {
if (theme === 'umamusume') loadUmaCharacters().then(setCharacters);
}, [theme]);
useEffect(() => {
if (!burstKey) return;
setActive(true);
const t = setTimeout(() => setActive(false), DURATION_MS + 900);
return () => clearTimeout(t);
}, [burstKey]);
const pieces = useMemo(
() => buildPieces(theme, big, characters.flatMap(c => c.images.map(src => ({ src, round: !!c.round })))),
// eslint-disable-next-line react-hooks/exhaustive-deps
[burstKey],
);
// Při finálním výsledku „promluví" náhodná postava svou hláškou
const speaker = useMemo(() => {
const withQuote = characters.filter(c => c.quote);
return theme === 'umamusume' && big && withQuote.length ? pick(withQuote) : undefined;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [burstKey]);
// Ostatní tematické styly: hlášku pronese náhodná postava (Mane 6, Ai Enma, Houkago Tea Time, Kessoku Band...)
const emojiConfetti = EMOJI_CONFETTI[theme];
const emojiSpeaker = useMemo(
() => big && emojiConfetti ? pick(emojiConfetti.speakers) : undefined,
// eslint-disable-next-line react-hooks/exhaustive-deps
[burstKey],
);
if (!active || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return null;
return createPortal(
<div className={`wheel-confetti wheel-confetti-${theme}`} key={burstKey} aria-hidden>
{theme === 'umamusume' && big && <div className="confetti-uma-banner">🏆 1着 🏆</div>}
{emojiConfetti && big && <div className={`${emojiConfetti.prefix}-banner`}>{emojiConfetti.banner}</div>}
{pieces.map(p => <span key={p.id} className={p.className} style={p.style}>{p.content}</span>)}
{speaker && (
<div className="confetti-uma-voice">
<img src={pick(speaker.images)} alt="" className={speaker.round ? 'round' : undefined} />
<div className="confetti-uma-bubble">
<p>„{speaker.quote}"</p>
<small>— {speaker.name}</small>
</div>
</div>
)}
{emojiSpeaker && (
<div className={`confetti-uma-voice confetti-${theme}-voice`} style={{ ['--mlp-speaker' as any]: emojiSpeaker.color }}>
<span className="confetti-mlp-avatar">{emojiSpeaker.emoji}</span>
<div className="confetti-mlp-bubble">
<p>„{emojiSpeaker.quote}"</p>
<small>— {emojiSpeaker.name}</small>
</div>
</div>
)}
</div>,
document.body,
);
}
@@ -49,9 +49,10 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
if (!isOpen) return; if (!isOpen) return;
const entries: DinerEntry[] = Object.entries(locationChoices).map(([login, choice]) => { const entries: DinerEntry[] = Object.entries(locationChoices).map(([login, choice]) => {
const selectedFoods = choice.selectedFoods ?? []; const selectedFoods = choice.selectedFoods ?? [];
let baseAmount = 0; // Cena, kterou si strávník zadal sám, má přednost před cenami z menu
let baseAmount = choice.price ?? 0;
let baseAmountParseFailed = false; let baseAmountParseFailed = false;
if (menu) { if (menu && choice.price == null) {
for (const idx of selectedFoods) { for (const idx of selectedFoods) {
const priceKc = parsePriceCzk(menu.food?.[idx]?.price); const priceKc = parsePriceCzk(menu.food?.[idx]?.price);
if (priceKc === null) { if (priceKc === null) {
@@ -168,7 +169,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
<> <>
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a společné poplatky, poté vygenerujte QR kódy pro ostatní.</p> <p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a společné poplatky, poté vygenerujte QR kódy pro ostatní.</p>
{!hasMenu && ( {!hasMenu && !diners.some(d => d.baseAmount > 0) && (
<Alert variant="info"> <Alert variant="info">
Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně. Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně.
</Alert> </Alert>
@@ -219,7 +220,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
<td> <td>
<small> <small>
{foodNames || <span className="text-muted">—</span>} {foodNames || <span className="text-muted">—</span>}
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount / 100} Kč)</span>} {d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount / 100} Kč)</span>}
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>} {d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
</small> </small>
</td> </td>
@@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
import { Modal, Button, Form } from "react-bootstrap";
type Props = {
isOpen: boolean,
onClose: () => void,
/** Uložená cena v haléřích */
price?: number,
/** Cena v haléřích spočtená z vybraných jídel v menu, nabídne se, pokud cena ještě není uložená */
suggestedPrice?: number,
/** Cena v haléřích, undefined cenu odstraní */
onSave: (price?: number) => void,
}
const toCrownsText = (amount?: number) => amount ? String(amount / 100).replace('.', ',') : '';
/** Modální dialog pro zadání ceny oběda, která se promítne do přehledu stravování. */
const PriceModal = ({ isOpen, onClose, price, suggestedPrice, onSave }: Readonly<Props>) => {
const [value, setValue] = useState('');
useEffect(() => {
if (isOpen) {
setValue(toCrownsText(price ?? suggestedPrice));
}
}, [isOpen, price, suggestedPrice]);
const parsed = value.trim().length ? Number.parseFloat(value.replace(',', '.')) : undefined;
const invalid = parsed != null && (Number.isNaN(parsed) || parsed < 0);
const save = () => {
if (invalid) return;
onSave(parsed == null ? undefined : Math.round(parsed * 100));
}
return <Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>Cena oběda</Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="text-muted small">Cena se promítne do vašeho měsíčního přehledu stravování (export).</p>
<div className="d-flex align-items-center gap-2">
<Form.Control
autoFocus
type="text"
inputMode="decimal"
placeholder="např. 145"
value={value}
isInvalid={invalid}
onChange={e => setValue(e.target.value.replace(/[^0-9.,]/g, ''))}
onKeyDown={event => {
if (event.key === 'Enter') {
save();
}
event.stopPropagation();
}}
/>
<span>Kč</span>
</div>
</Modal.Body>
<Modal.Footer>
{price != null && <Button variant="outline-danger" className="me-auto" onClick={() => onSave(undefined)}>
Odstranit cenu
</Button>}
<Button variant="secondary" onClick={() => onClose()}>
Storno
</Button>
<Button variant="primary" onClick={() => save()} disabled={invalid}>
Uložit
</Button>
</Modal.Footer>
</Modal>
}
export default PriceModal;
+86 -38
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Modal, Button, Form } from "react-bootstrap" import { Modal, Button, Form, Row, Col } from "react-bootstrap"
import { useSettings, ThemePreference } from "../../context/settings"; import { useSettings, ThemePreference, StyleTheme, STYLE_THEMES } from "../../context/settings";
import { NotificationSettings, UdalostEnum, getNotificationSettings, updateNotificationSettings } from "../../../../types"; import { NotificationSettings, UdalostEnum, getNotificationSettings, updateNotificationSettings } from "../../../../types";
import { useAuth } from "../../context/auth"; import { useAuth } from "../../context/auth";
import { subscribeToPush, unsubscribeFromPush } from "../../hooks/usePushReminder"; import { subscribeToPush, unsubscribeFromPush } from "../../hooks/usePushReminder";
@@ -19,6 +19,9 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
const nameRef = useRef<HTMLInputElement>(null); const nameRef = useRef<HTMLInputElement>(null);
const hideSoupsRef = useRef<HTMLInputElement>(null); const hideSoupsRef = useRef<HTMLInputElement>(null);
const themeRef = useRef<HTMLSelectElement>(null); const themeRef = useRef<HTMLSelectElement>(null);
const styleThemeRef = useRef<HTMLSelectElement>(null);
const showButterfliesRef = useRef<HTMLInputElement>(null);
const autoOrderChoiceRef = useRef<HTMLInputElement>(null);
const reminderTimeRef = useRef<HTMLInputElement>(null); const reminderTimeRef = useRef<HTMLInputElement>(null);
const boltDeliveredRef = useRef<HTMLInputElement>(null); const boltDeliveredRef = useRef<HTMLInputElement>(null);
@@ -75,6 +78,15 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
} }
// Uložení ostatních nastavení (localStorage) // Uložení ostatních nastavení (localStorage)
if (styleThemeRef.current?.value) {
settings?.setStyleTheme(styleThemeRef.current.value as StyleTheme);
}
if (showButterfliesRef.current) {
settings?.setShowButterflies(showButterfliesRef.current.checked);
}
if (autoOrderChoiceRef.current) {
settings?.setAutoOrderChoice(autoOrderChoiceRef.current.checked);
}
onSave( onSave(
bankAccountRef.current?.value, bankAccountRef.current?.value,
nameRef.current?.value, nameRef.current?.value,
@@ -84,12 +96,21 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
}; };
return ( return (
<Modal show={isOpen} onHide={onClose} size="lg"> <Modal show={isOpen} onHide={onClose} size="xl">
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title><h2>Nastavení</h2></Modal.Title> <Modal.Title><h2>Nastavení</h2></Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
{/* Na širších obrazovkách rozdělení do dvou sloupců, na užších pod sebou */}
<Row className="gx-5">
<Col lg={6}>
<h4>Vzhled</h4> <h4>Vzhled</h4>
<Form.Group className="mb-3">
<Form.Label>Styl</Form.Label>
<Form.Select ref={styleThemeRef} defaultValue={settings?.styleTheme}>
{STYLE_THEMES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</Form.Select>
</Form.Group>
<Form.Group className="mb-3"> <Form.Group className="mb-3">
<Form.Label>Barevný motiv</Form.Label> <Form.Label>Barevný motiv</Form.Label>
<Form.Select ref={themeRef} defaultValue={settings?.themePreference}> <Form.Select ref={themeRef} defaultValue={settings?.themePreference}>
@@ -98,6 +119,18 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
<option value="dark">Tmavý</option> <option value="dark">Tmavý</option>
</Form.Select> </Form.Select>
</Form.Group> </Form.Group>
<Form.Group className="mb-3">
<Form.Check
id="showButterfliesCheckbox"
ref={showButterfliesRef}
type="checkbox"
label="Zobrazit motýlky"
defaultChecked={settings?.showButterflies ?? true}
/>
<Form.Text className="text-muted">
Po vypnutí se poletující motýlci ani minihra nebudou vůbec zobrazovat.
</Form.Text>
</Form.Group>
<hr /> <hr />
@@ -115,9 +148,57 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
Experimentální funkce - zejména u TechTower bývá problém polévky spolehlivě rozeznat. Experimentální funkce - zejména u TechTower bývá problém polévky spolehlivě rozeznat.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group className="mb-3">
<Form.Check
id="autoOrderChoiceCheckbox"
ref={autoOrderChoiceRef}
type="checkbox"
label='Při objednávání automaticky zvolit "Budu objednávat"'
defaultChecked={settings?.autoOrderChoice ?? false}
/>
<Form.Text className="text-muted">
Po založení skupinové objednávky nebo připojení k existující se vaše dnešní volba stravování přepne na "Budu objednávat". Volba Pizza day zůstane beze změny.
</Form.Text>
</Form.Group>
<hr /> <hr />
<h4>Bankovní účet</h4>
<p>
Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.
</p>
<Form.Group className="mb-3">
<Form.Label>Číslo účtu</Form.Label>
<Form.Control
ref={bankAccountRef}
type="text"
placeholder="123456-1234567890/1234"
defaultValue={settings?.bankAccount}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Název příjemce</Form.Label>
<Form.Control
ref={nameRef}
type="text"
placeholder="Jan Novák"
defaultValue={settings?.holderName}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
Jméno majitele účtu pro QR platbu.
</Form.Text>
</Form.Group>
</Col>
<Col lg={6}>
<hr className="d-lg-none" />
<h4>Notifikace</h4> <h4>Notifikace</h4>
<p> <p>
Nastavením notifikací budete dostávat upozornění o událostech (např. "Jdeme na oběd") přímo do vámi zvoleného komunikačního kanálu. Nastavením notifikací budete dostávat upozornění o událostech (např. "Jdeme na oběd") přímo do vámi zvoleného komunikačního kanálu.
@@ -212,41 +293,8 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
Zvolte události, o kterých chcete být notifikováni. Notifikace jsou odesílány pouze uživatelům se stejnou zvolenou lokalitou. Zvolte události, o kterých chcete být notifikováni. Notifikace jsou odesílány pouze uživatelům se stejnou zvolenou lokalitou.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
</Col>
<hr /> </Row>
<h4>Bankovní účet</h4>
<p>
Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.
</p>
<Form.Group className="mb-3">
<Form.Label>Číslo účtu</Form.Label>
<Form.Control
ref={bankAccountRef}
type="text"
placeholder="123456-1234567890/1234"
defaultValue={settings?.bankAccount}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Název příjemce</Form.Label>
<Form.Control
ref={nameRef}
type="text"
placeholder="Jan Novák"
defaultValue={settings?.holderName}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
Jméno majitele účtu pro QR platbu.
</Form.Text>
</Form.Group>
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button variant="secondary" onClick={onClose}> <Button variant="secondary" onClick={onClose}>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,407 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Modal, Button, ButtonGroup } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark, faRotateLeft } from "@fortawesome/free-solid-svg-icons";
import { Restaurant, RestaurantDayMenuMap, Store, StoreMenu, StoreMenuSource, getStoreMenu, listStores } from "../../../../types";
import { getLunchChoiceName } from "../../enums";
import WheelConfetti from "../WheelConfetti";
import { useSettings } from "../../context/settings";
import "./SpinWheelModal.scss";
/** Výsledek kola štěstí — buď jídlo z dnešního menu podniku, nebo jídlo z rozvozu. */
export type WheelPick =
| { kind: 'menu', restaurant: Restaurant, foodIndex?: number, foodName?: string }
| { kind: 'store', store: string, dish?: string };
type Source = 'all' | 'menu' | 'store';
type Place = {
id: string,
label: string,
kind: 'menu' | 'store',
restaurant?: Restaurant,
/** Jídla dnešního menu; u rozvozu se nabídka dotahuje až při druhém kole. */
dishes: Dish[],
};
type Dish = { name: string, index?: number, price?: string };
const SOURCE_LABELS: Record<StoreMenuSource, string> = {
bolt: 'Nabídka z Bolt Food',
wolt: 'Nabídka z Wolt',
history: 'Nabídku se nepodařilo stáhnout — jídla z historie objednávek',
none: 'Nabídku se nepodařilo stáhnout',
};
type Props = {
isOpen: boolean,
onClose: () => void,
menus?: RestaurantDayMenuMap,
onPick: (pick: WheelPick) => Promise<void>,
};
const SPIN_MS = 4800;
const WHEEL_SIZE = 360;
const RADIUS = WHEEL_SIZE / 2 - 6;
const SEGMENT_COLORS = 6;
const MAX_LABELED_SEGMENTS = 40;
/** Točit má smysl až od dvou možností — jediná zbylá se vybere rovnou. */
const MIN_SPIN_ITEMS = 2;
/** Kryptograficky náhodný index v rozsahu 0..n-1 (bez modulo zkreslení to u malých n nevadí). */
function randomIndex(n: number): number {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return buf[0] % n;
}
function truncate(text: string, max: number): string {
return text.length > max ? text.slice(0, max - 1) + '…' : text;
}
/** Bod na kružnici; úhel ve stupních měřený od „12 hodin" po směru hodinových ručiček. */
function polar(angle: number, r: number) {
const rad = (angle - 90) * Math.PI / 180;
return { x: WHEEL_SIZE / 2 + r * Math.cos(rad), y: WHEEL_SIZE / 2 + r * Math.sin(rad) };
}
function segmentPath(start: number, end: number): string {
const c = WHEEL_SIZE / 2;
if (end - start >= 360) {
return `M ${c} ${c - RADIUS} A ${RADIUS} ${RADIUS} 0 1 1 ${c - 0.01} ${c - RADIUS} Z`;
}
const a = polar(start, RADIUS);
const b = polar(end, RADIUS);
const large = end - start > 180 ? 1 : 0;
return `M ${c} ${c} L ${a.x} ${a.y} A ${RADIUS} ${RADIUS} 0 ${large} 1 ${b.x} ${b.y} Z`;
}
type WheelProps = {
labels: string[],
rotation: number,
spinning: boolean,
onSpinEnd: () => void,
};
function Wheel({ labels, rotation, spinning, onSpinEnd }: Readonly<WheelProps>) {
const n = labels.length;
const angle = 360 / Math.max(n, 1);
const fontSize = n > 24 ? 8.5 : n > 14 ? 10 : n > 8 ? 12 : 14;
const maxChars = n > 24 ? 26 : n > 14 ? 24 : 20;
// U velkých nabídek jsou popisky nečitelné — názvy jsou v seznamu vedle kola
const showLabels = n <= MAX_LABELED_SEGMENTS;
return (
<div className="spin-wheel">
<div className="spin-wheel-pointer" aria-hidden />
<svg
viewBox={`0 0 ${WHEEL_SIZE} ${WHEEL_SIZE}`}
className="spin-wheel-svg"
style={{
transform: `rotate(${rotation}deg)`,
transition: spinning ? `transform ${SPIN_MS}ms cubic-bezier(0.12, 0.72, 0.16, 1)` : 'none',
}}
onTransitionEnd={e => { if (e.propertyName === 'transform') onSpinEnd(); }}
role="img"
aria-label={`Kolo s ${n} možnostmi`}
>
<circle cx={WHEEL_SIZE / 2} cy={WHEEL_SIZE / 2} r={RADIUS + 4} className="spin-wheel-rim" />
{labels.map((label, i) => {
const start = i * angle;
const mid = start + angle / 2;
return (
<g key={`${i}-${label}`}>
<path d={segmentPath(start, start + angle)} className={`spin-seg spin-seg-${i % SEGMENT_COLORS}`} />
{showLabels && <text
x={WHEEL_SIZE / 2 + 34}
y={WHEEL_SIZE / 2}
transform={`rotate(${mid - 90} ${WHEEL_SIZE / 2} ${WHEEL_SIZE / 2})`}
dominantBaseline="middle"
fontSize={fontSize}
className="spin-seg-label"
>
{truncate(label, maxChars)}
</text>}
</g>
);
})}
<circle cx={WHEEL_SIZE / 2} cy={WHEEL_SIZE / 2} r={26} className="spin-wheel-hub" />
</svg>
</div>
);
}
/**
* Kolo štěstí pro nerozhodnuté: první kolo vybere podnik (dnešní menu nebo rozvoz),
* druhé kolo jídlo z něj (bez polévek). Možnosti lze z kola průběžně vyřazovat.
*/
export default function SpinWheelModal({ isOpen, onClose, menus, onPick }: Readonly<Props>) {
const [stores, setStores] = useState<Store[]>();
const [storeMenus, setStoreMenus] = useState<Record<string, StoreMenu | 'loading' | 'error'>>({});
const [source, setSource] = useState<Source>('all');
const [excludedPlaces, setExcludedPlaces] = useState<Set<string>>(new Set());
const [excludedDishes, setExcludedDishes] = useState<Set<string>>(new Set());
const [place, setPlace] = useState<Place>();
const [dish, setDish] = useState<Dish>();
const [phase, setPhase] = useState<'place' | 'dish'>('place');
const [rotation, setRotation] = useState(0);
const [spinning, setSpinning] = useState(false);
const [saving, setSaving] = useState(false);
const [respinRequested, setRespinRequested] = useState(false);
const theme = useSettings()?.styleTheme ?? 'simple';
const [confetti, setConfetti] = useState<{ key: number, big: boolean }>({ key: 0, big: false });
const pendingResult = useRef<number>(undefined);
useEffect(() => {
if (isOpen && !stores) {
listStores().then(r => setStores(r.data ?? [])).catch(() => setStores([]));
}
}, [isOpen, stores]);
const allPlaces = useMemo<Place[]>(() => {
const result: Place[] = [];
for (const key of Object.keys(Restaurant) as Restaurant[]) {
const menu = menus?.[key];
if (!menu || menu.closed || !menu.food?.length) continue;
const dishes = menu.food
.map((f, index) => ({ name: f.name, index, price: f.price, isSoup: f.isSoup }))
.filter(f => !f.isSoup)
.map(({ name, index, price }) => ({ name, index, price }));
result.push({ id: `menu:${key}`, label: getLunchChoiceName(key), kind: 'menu', restaurant: key, dishes });
}
for (const s of stores ?? []) {
result.push({ id: `store:${s.name}`, label: s.name, kind: 'store', dishes: [] });
}
return result;
}, [menus, stores]);
const storeMenu = place?.kind === 'store' ? storeMenus[place.label] : undefined;
const allDishes = useMemo<Dish[]>(() => {
if (place?.kind !== 'store') return place?.dishes ?? [];
if (!storeMenu || typeof storeMenu === 'string') return [];
return storeMenu.dishes
.filter(d => d.isMain !== false)
.map(d => ({ name: d.name, price: d.price != null ? `${d.price} Kč` : undefined }));
}, [place, storeMenu]);
const places = useMemo(
() => allPlaces.filter(p => (source === 'all' || p.kind === source) && !excludedPlaces.has(p.id)),
[allPlaces, source, excludedPlaces],
);
const dishes = useMemo(
() => allDishes.filter(d => !excludedDishes.has(`${place?.id}|${d.name}`)),
[place, allDishes, excludedDishes],
);
const wheelItems = phase === 'place' ? places.map(p => p.label) : dishes.map(d => d.name);
const result = phase === 'place' ? place : dish;
const resetWheel = () => {
setSpinning(false);
setRotation(r => r % 360);
};
// Zbyla jediná možnost — není co losovat, vybere se rovnou (lze pokračovat dál / převzít)
const onlyOne = wheelItems.length === 1;
useEffect(() => {
if (spinning || !onlyOne) return;
if (phase === 'place' && place?.id !== places[0].id) setPlace(places[0]);
else if (phase === 'dish' && dish?.name !== dishes[0].name) setDish(dishes[0]);
}, [onlyOne, phase, places, dishes, spinning, place?.id, dish?.name]);
const spin = () => {
const n = wheelItems.length;
if (n < MIN_SPIN_ITEMS || spinning) return;
const index = randomIndex(n);
const angle = 360 / n;
const jitter = (Math.random() - 0.5) * angle * 0.6;
const target = 360 - (index * angle + angle / 2) + jitter;
const turns = 5 + randomIndex(3);
pendingResult.current = index;
if (phase === 'place') setPlace(undefined); else setDish(undefined);
setSpinning(true);
setRotation(r => r - (r % 360) + turns * 360 + ((target % 360) + 360) % 360);
};
const handleSpinEnd = () => {
const index = pendingResult.current;
if (index == null) return;
pendingResult.current = undefined;
setSpinning(false);
if (phase === 'place') setPlace(places[index]);
else setDish(dishes[index]);
setConfetti(c => ({ key: c.key + 1, big: phase === 'dish' }));
};
const goToDishes = () => {
if (place?.kind === 'store' && (!storeMenus[place.label] || storeMenus[place.label] === 'error')) {
const name = place.label;
setStoreMenus(prev => ({ ...prev, [name]: 'loading' }));
getStoreMenu({ query: { name } })
.then(r => setStoreMenus(prev => ({ ...prev, [name]: r.data ?? 'error' })))
.catch(() => setStoreMenus(prev => ({ ...prev, [name]: 'error' })));
}
setDish(undefined);
setPhase('dish');
resetWheel();
};
const backToPlaces = () => {
setDish(undefined);
setPlace(undefined);
setPhase('place');
resetWheel();
};
const removeOption = (i: number) => {
if (spinning) return;
if (phase === 'place') {
const p = places[i];
setExcludedPlaces(prev => new Set(prev).add(p.id));
if (place?.id === p.id) setPlace(undefined);
} else if (place) {
const d = dishes[i];
setExcludedDishes(prev => new Set(prev).add(`${place.id}|${d.name}`));
if (dish?.name === d.name) setDish(undefined);
}
};
const removeResultAndRespin = () => {
const i = phase === 'place' ? places.findIndex(p => p.id === place?.id) : dishes.findIndex(d => d.name === dish?.name);
if (i < 0) return;
removeOption(i);
setRespinRequested(true);
};
// Po vyřazení se kolo překreslí s menším počtem výsečí — roztočíme ho až nad novými daty
useEffect(() => {
if (respinRequested) {
setRespinRequested(false);
spin();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [respinRequested]);
const restoreAll = () => {
if (phase === 'place') setExcludedPlaces(new Set());
else if (place) setExcludedDishes(prev => new Set([...prev].filter(k => !k.startsWith(`${place.id}|`))));
};
const accept = async () => {
if (!place) return;
setSaving(true);
try {
if (place.kind === 'menu') {
await onPick({ kind: 'menu', restaurant: place.restaurant!, foodIndex: dish?.index, foodName: dish?.name });
} else {
await onPick({ kind: 'store', store: place.label, dish: dish?.name });
}
onClose();
} finally {
setSaving(false);
}
};
const excludedCount = phase === 'place'
? allPlaces.filter(p => (source === 'all' || p.kind === source) && excludedPlaces.has(p.id)).length
: allDishes.length - dishes.length;
const menuLoading = phase === 'dish' && storeMenu === 'loading';
return (
<Modal show={isOpen} onHide={onClose} size="lg" className={`spin-wheel-modal spin-theme-${theme}`}>
<Modal.Header closeButton>
<Modal.Title>
<h2>{phase === 'place' ? 'Kolo štěstí: kam?' : `Kolo štěstí: co v ${place?.label}?`}</h2>
</Modal.Title>
</Modal.Header>
<WheelConfetti theme={theme} burstKey={confetti.key} big={confetti.big} />
<Modal.Body>
<div className="spin-steps">
<span className={phase === 'place' ? 'active' : 'done'}>1. Podnik</span>
<span className={phase === 'dish' ? 'active' : ''}>2. Jídlo</span>
{phase === 'dish' && storeMenu && typeof storeMenu !== 'string' && (
<small className="spin-source-info">{SOURCE_LABELS[storeMenu.source]}</small>
)}
</div>
{phase === 'place' && (
<ButtonGroup className="spin-source mb-3" aria-label="Zdroj podniků">
{([['all', 'Vše'], ['menu', 'Dnešní menu'], ['store', 'Rozvoz']] as [Source, string][]).map(([value, label]) => (
<Button key={value} variant={source === value ? 'primary' : 'outline-primary'} disabled={spinning}
onClick={() => { setSource(value); setPlace(undefined); }}>
{label}
</Button>
))}
</ButtonGroup>
)}
<div className="spin-layout">
<div className="spin-wheel-column">
{(phase === 'place' && !stores) || menuLoading ? (
<div className="spin-empty">{menuLoading ? 'Stahuji nabídku podniku…' : 'Načítám podniky…'}</div>
) : wheelItems.length === 0 ? (
<div className="spin-empty">
{phase === 'dish'
? 'Tady nemáme žádná jídla k točení — vyber si na místě.'
: 'Na kole nezbyla žádná možnost.'}
</div>
) : (
<Wheel labels={wheelItems} rotation={rotation} spinning={spinning} onSpinEnd={handleSpinEnd} />
)}
<div className={`spin-result${result ? ' visible' : ''}`} aria-live="polite">
{result && (phase === 'place'
? <>{onlyOne ? 'Zbývá jediná možnost' : 'Padlo'}: <strong>{place!.label}</strong> <small>({place!.kind === 'menu' ? 'dnešní menu' : 'rozvoz'})</small></>
: <>{onlyOne ? 'Zbývá jediné jídlo' : 'Padlo'}: <strong>{dish!.name}</strong>{dish!.price && <small> ({dish!.price})</small>}</>)}
</div>
<div className="spin-actions">
<Button onClick={spin} disabled={spinning || wheelItems.length < MIN_SPIN_ITEMS} className="spin-button"
title={wheelItems.length < MIN_SPIN_ITEMS ? 'Na kole musí být aspoň 2 možnosti' : undefined}>
{spinning ? 'Točí se…' : result && !onlyOne ? 'Točit znovu' : 'Roztočit!'}
</Button>
{result && !spinning && wheelItems.length > 1 && (
<Button variant="outline-primary" onClick={removeResultAndRespin}>Vyřadit a točit znovu</Button>
)}
</div>
{onlyOne && (
<small className="text-muted">Na kole musí být aspoň 2 možnosti — vrať nějaké vyřazené, nebo pokračuj s touto.</small>
)}
</div>
<div className="spin-options">
<div className="spin-options-header">
<span>Na kole ({wheelItems.length})</span>
{excludedCount > 0 && (
<button className="spin-restore" onClick={restoreAll} disabled={spinning} title="Vrátit vyřazené možnosti">
<FontAwesomeIcon icon={faRotateLeft} /> Vrátit {excludedCount}
</button>
)}
</div>
<ul>
{wheelItems.map((label, i) => (
<li key={`${i}-${label}`}>
<span className={`spin-dot spin-seg-${i % SEGMENT_COLORS}`} />
<span className="spin-option-label" title={label}>{label}</span>
{phase === 'place'
? <small className="spin-option-kind">{places[i].kind === 'menu' ? 'menu' : 'rozvoz'}</small>
: dishes[i].price && <small className="spin-option-kind">{dishes[i].price}</small>}
<button className="spin-remove" onClick={() => removeOption(i)} disabled={spinning}
title={`Vyřadit z kola: ${label}`} aria-label={`Vyřadit ${label}`}>
<FontAwesomeIcon icon={faXmark} />
</button>
</li>
))}
</ul>
</div>
</div>
</Modal.Body>
<Modal.Footer>
{phase === 'dish' && <Button variant="secondary" onClick={backToPlaces} disabled={spinning}>Zpět na podniky</Button>}
{phase === 'place' && place && !spinning && (
<Button variant="outline-primary" onClick={accept} disabled={saving}>Beru jen podnik</Button>
)}
{phase === 'place' && place && !spinning && (
<Button onClick={goToDishes}>Dál: točit jídlo</Button>
)}
{phase === 'dish' && !spinning && (dish || dishes.length === 0) && (
<Button onClick={accept} disabled={saving}>{dish ? 'Beru to!' : 'Beru podnik'}</Button>
)}
</Modal.Footer>
</Modal>
);
}
@@ -1,9 +1,12 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { Modal, Button, Form, ListGroup, Alert } from "react-bootstrap"; import { Modal, Button, Form, ListGroup, Alert, OverlayTrigger, Tooltip } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrashCan } from "@fortawesome/free-regular-svg-icons"; import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { faPen, faPlus, faUpRightFromSquare, faXmark } from "@fortawesome/free-solid-svg-icons"; import { faArrowsRotate, faPen, faPlus, faUpRightFromSquare, faXmark } from "@fortawesome/free-solid-svg-icons";
import { addStore, deleteStore, updateStore, Store } from "../../../../types"; import { addStore, deleteStore, updateStore, getStoreMenuStatus, refreshStoreMenu, Store, StoreMenuProvider, StoreScrapeInfo, StoreScrapeStatus } from "../../../../types";
import { getHumanDateTime } from "../../Utils";
import PriceHistory from "../PriceHistory";
import { useAppConfig } from "../../hooks/useGroupLimits";
import { getStoreUrls, storeUrlLabel } from "../../utils/storeUrls"; import { getStoreUrls, storeUrlLabel } from "../../utils/storeUrls";
type Props = { type Props = {
@@ -20,6 +23,23 @@ type EditState = {
urls: string[]; urls: string[];
}; };
const PROVIDER_LABELS: Record<StoreMenuProvider, string> = { bolt: 'Bolt Food', wolt: 'Wolt' };
/** Krátký popis stavu stahování nabídky od jedné služby. */
function describeScrape(p: StoreScrapeStatus): { text: string, className: string, title?: string } {
const when = (iso?: string) => iso ? getHumanDateTime(new Date(iso)) : '';
switch (p.state) {
case 'ok':
return { text: `${p.dishCount} hlavních jídel (${when(p.lastSuccessAt)})${p.lastError ? ` · poslední obnovení selhalo: ${p.lastError}` : ''}`, className: 'text-success' };
case 'failed':
return { text: `selhalo ${p.failures}× (${when(p.lastAttemptAt)}): ${p.lastError ?? ''} · zkusí se znovu`, className: 'text-warning' };
case 'blocked':
return { text: `zablokováno po ${p.failures} neúspěších (${when(p.lastAttemptAt)}): ${p.lastError ?? ''}`, className: 'text-danger' };
default:
return { text: 'nestaženo', className: 'text-muted', title: 'Stáhne se automaticky, až bude nabídka potřeba v kole štěstí (a žádná jiná služba ji nemá)' };
}
}
/** Vstupní pole pro URL nabídek — vždy alespoň jedno prázdné, aby bylo kam psát. */ /** Vstupní pole pro URL nabídek — vždy alespoň jedno prázdné, aby bylo kam psát. */
function toUrlInputs(urls: string[]): string[] { function toUrlInputs(urls: string[]): string[] {
return urls.length > 0 ? urls : ['']; return urls.length > 0 ? urls : [''];
@@ -33,6 +53,41 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
const [heslo, setHeslo] = useState(''); const [heslo, setHeslo] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Server bez admin hesla — správa (přidání/úprava/odebrání/obnovení) nejde, zbývá jen přehled.
// undefined = konfigurace nenačtena, chováme se jako dřív (správa zapnutá).
const adminEnabled = useAppConfig().storeAdmin !== false;
const [scrapeInfo, setScrapeInfo] = useState<Record<string, StoreScrapeInfo>>({});
const [refreshing, setRefreshing] = useState<string | null>(null);
// Rozbalená historie cen („obchod|služba")
const [historyOpen, setHistoryOpen] = useState<string | null>(null);
// Stav stahování nabídek (Bolt Food/Wolt) — při otevření a po změně seznamu obchodů
useEffect(() => {
if (!isOpen) return;
getStoreMenuStatus().then(res => {
setScrapeInfo(Object.fromEntries((res.data ?? []).map(i => [i.store, i])));
}).catch(() => { /* stav je jen informativní */ });
}, [isOpen, stores]);
const handleRefreshMenu = async (name: string, provider: StoreMenuProvider) => {
setError(null);
setRefreshing(`${name}|${provider}`);
try {
const res = await refreshStoreMenu({ body: { name, provider, heslo } });
if (res.data) {
setScrapeInfo(prev => ({ ...prev, [name]: res.data! }));
}
// Znovu načíst případně otevřenou historii (mohla přibýt nová verze)
if (historyOpen === `${name}|${provider}`) {
setHistoryOpen(null);
setTimeout(() => setHistoryOpen(`${name}|${provider}`));
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setRefreshing(null);
}
};
const addUrlRow = (urls: string[]) => [...urls, '']; const addUrlRow = (urls: string[]) => [...urls, ''];
@@ -147,7 +202,7 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
); );
return ( return (
<Modal show={isOpen} onHide={onClose}> <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title><h2>Správa obchodů</h2></Modal.Title> <Modal.Title><h2>Správa obchodů</h2></Modal.Title>
</Modal.Header> </Modal.Header>
@@ -158,6 +213,14 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
</Alert> </Alert>
)} )}
{!adminEnabled && (
<Alert variant="info">
Správa obchodů není na tomto serveru zapnutá (chybí <code>ADMIN_PASSWORD</code>).
Seznam obchodů, stav nabídek a historii cen lze jen prohlížet.
</Alert>
)}
{adminEnabled && <>
<Form.Group className="mb-3"> <Form.Group className="mb-3">
<Form.Label>Admin heslo</Form.Label> <Form.Label>Admin heslo</Form.Label>
<Form.Control <Form.Control
@@ -167,6 +230,9 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
onChange={e => setHeslo(e.target.value)} onChange={e => setHeslo(e.target.value)}
onKeyDown={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}
/> />
<Form.Text className="text-muted">
Potřeba pro přidání, úpravu a odebrání obchodu i pro obnovení nabídky (<FontAwesomeIcon icon={faArrowsRotate} />).
</Form.Text>
</Form.Group> </Form.Group>
<hr /> <hr />
@@ -185,6 +251,7 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
Přidat Přidat
</Button> </Button>
</div> </div>
</>}
<h6>Aktuální seznam</h6> <h6>Aktuální seznam</h6>
{stores.length === 0 ? ( {stores.length === 0 ? (
@@ -244,8 +311,52 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
))} ))}
</small> </small>
)} )}
{scrapeInfo[s.name]?.providers.map(p => {
const { text, className, title } = describeScrape(p);
const key = `${s.name}|${p.provider}`;
return (
<div key={p.provider}>
<small className="d-flex align-items-baseline gap-2 mt-1">
<span className="text-nowrap text-muted">Menu {PROVIDER_LABELS[p.provider]}:</span>
<span className={className} title={title}>{text}</span>
{/* Zakázané tlačítko nezobrazí tooltip — obalení do span ho zobrazí i bez hesla */}
{adminEnabled && <OverlayTrigger
placement="top"
overlay={
<Tooltip>
{heslo
? `Znovu stáhnout nabídku z ${PROVIDER_LABELS[p.provider]}`
: 'Pro obnovení nabídky zadejte nahoře admin heslo'}
</Tooltip>
}
>
<span className="d-inline-block" style={{ cursor: heslo ? undefined : 'not-allowed' }}>
<Button
variant="link"
size="sm"
className="p-0"
disabled={!heslo || refreshing != null}
style={heslo ? undefined : { pointerEvents: 'none' }}
aria-label={`Znovu stáhnout nabídku z ${PROVIDER_LABELS[p.provider]}`}
onClick={() => handleRefreshMenu(s.name, p.provider)}
>
<FontAwesomeIcon icon={faArrowsRotate} spin={refreshing === key} />
</Button>
</span>
</OverlayTrigger>}
{p.dishCount > 0 && (
<Button variant="link" size="sm" className="p-0 text-nowrap"
onClick={() => setHistoryOpen(historyOpen === key ? null : key)}>
{historyOpen === key ? 'skrýt historii' : 'historie cen'}
</Button>
)}
</small>
{historyOpen === key && <PriceHistory store={s.name} provider={p.provider} />}
</div> </div>
<div className="d-flex gap-3"> );
})}
</div>
{adminEnabled && <div className="d-flex gap-3">
<FontAwesomeIcon <FontAwesomeIcon
icon={faPen} icon={faPen}
className="action-icon" className="action-icon"
@@ -260,7 +371,7 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
onClick={() => handleRemove(s.name)} onClick={() => handleRemove(s.name)}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
/> />
</div> </div>}
</ListGroup.Item> </ListGroup.Item>
); );
})} })}
+99 -2
View File
@@ -6,9 +6,24 @@ const HIDE_SOUPS_KEY = 'hide_soups';
const THEME_KEY = 'theme_preference'; const THEME_KEY = 'theme_preference';
const ACCENT_HUE_KEY = 'accent_hue'; const ACCENT_HUE_KEY = 'accent_hue';
const LEGACY_COLOR_THEME_KEY = 'color_theme'; const LEGACY_COLOR_THEME_KEY = 'color_theme';
const STYLE_THEME_KEY = 'style_theme';
const SHOW_BUTTERFLIES_KEY = 'show_butterflies';
const AUTO_ORDER_CHOICE_KEY = 'auto_order_choice';
export type ThemePreference = 'system' | 'light' | 'dark'; export type ThemePreference = 'system' | 'light' | 'dark';
/** Vizuální styl aplikace (nezávislý na světlém/tmavém režimu). */
export type StyleTheme = 'simple' | 'umamusume' | 'mlp' | 'hellgirl' | 'kon' | 'bocchi';
export const STYLE_THEMES: { value: StyleTheme, label: string }[] = [
{ value: 'simple', label: 'Jednoduchý' },
{ value: 'umamusume', label: 'Umamusume' },
{ value: 'mlp', label: 'My Little Pony' },
{ value: 'hellgirl', label: 'Hell Girl' },
{ value: 'kon', label: 'K-ON!' },
{ value: 'bocchi', label: 'Bocchi the Rock!' },
];
export type SettingsContextProps = { export type SettingsContextProps = {
bankAccount?: string, bankAccount?: string,
holderName?: string, holderName?: string,
@@ -16,11 +31,17 @@ export type SettingsContextProps = {
themePreference: ThemePreference, themePreference: ThemePreference,
accentHue: number, accentHue: number,
effectiveDark: boolean, effectiveDark: boolean,
styleTheme: StyleTheme,
showButterflies: boolean,
autoOrderChoice: boolean,
setBankAccountNumber: (accountNumber?: string) => void, setBankAccountNumber: (accountNumber?: string) => void,
setBankAccountHolderName: (holderName?: string) => void, setBankAccountHolderName: (holderName?: string) => void,
setHideSoupsOption: (hideSoups?: boolean) => void, setHideSoupsOption: (hideSoups?: boolean) => void,
setThemePreference: (theme: ThemePreference) => void, setThemePreference: (theme: ThemePreference) => void,
setAccentHue: (hue: number) => void, setAccentHue: (hue: number) => void,
setStyleTheme: (theme: StyleTheme) => void,
setShowButterflies: (show: boolean) => void,
setAutoOrderChoice: (enabled: boolean) => void,
} }
type ContextProps = { type ContextProps = {
@@ -50,6 +71,37 @@ function getInitialTheme(): ThemePreference {
return 'system'; return 'system';
} }
function getInitialStyleTheme(): StyleTheme {
try {
const saved = localStorage.getItem(STYLE_THEME_KEY);
const known = STYLE_THEMES.find(t => t.value === saved);
if (known) {
return known.value;
}
} catch {
// localStorage nedostupný
}
return 'simple';
}
function getInitialShowButterflies(): boolean {
try {
return localStorage.getItem(SHOW_BUTTERFLIES_KEY) !== 'false';
} catch {
// localStorage nedostupný
}
return true;
}
function getInitialAutoOrderChoice(): boolean {
try {
return localStorage.getItem(AUTO_ORDER_CHOICE_KEY) === 'true';
} catch {
// localStorage nedostupný
}
return false;
}
function getInitialAccentHue(): number { function getInitialAccentHue(): number {
try { try {
const saved = localStorage.getItem(ACCENT_HUE_KEY); const saved = localStorage.getItem(ACCENT_HUE_KEY);
@@ -90,6 +142,12 @@ function adjustedL(hue: number, sat: number, targetL: number): number {
return l; return l;
} }
const ACCENT_PROPERTIES = ['--luncher-primary', '--luncher-primary-hover', '--luncher-primary-light', '--luncher-action-icon', '--luncher-success'];
function clearAccentColors(): void {
ACCENT_PROPERTIES.forEach(p => document.documentElement.style.removeProperty(p));
}
function applyAccentColors(hue: number, isDark: boolean): void { function applyAccentColors(hue: number, isDark: boolean): void {
const sat = 70; const sat = 70;
const baseL = adjustedL(hue, sat, isDark ? 55 : 38); const baseL = adjustedL(hue, sat, isDark ? 55 : 38);
@@ -110,6 +168,9 @@ function useProvideSettings(): SettingsContextProps {
const [hideSoups, setHideSoups] = useState<boolean | undefined>(); const [hideSoups, setHideSoups] = useState<boolean | undefined>();
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme); const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme);
const [accentHue, setHue] = useState<number>(getInitialAccentHue); const [accentHue, setHue] = useState<number>(getInitialAccentHue);
const [styleTheme, setStyle] = useState<StyleTheme>(getInitialStyleTheme);
const [showButterflies, setShowButterfliesState] = useState<boolean>(getInitialShowButterflies);
const [autoOrderChoice, setAutoOrderChoiceState] = useState<boolean>(getInitialAutoOrderChoice);
const [effectiveDark, setEffectiveDark] = useState<boolean>(() => { const [effectiveDark, setEffectiveDark] = useState<boolean>(() => {
try { try {
const pref = localStorage.getItem(THEME_KEY) as ThemePreference | null; const pref = localStorage.getItem(THEME_KEY) as ThemePreference | null;
@@ -178,11 +239,29 @@ function useProvideSettings(): SettingsContextProps {
} }
}, [themePreference]); }, [themePreference]);
// Aplikuje accent barvy při změně hue nebo přepnutí světlý/tmavý useEffect(() => {
localStorage.setItem(STYLE_THEME_KEY, styleTheme);
document.documentElement.setAttribute('data-luncher-theme', styleTheme);
}, [styleTheme]);
useEffect(() => {
localStorage.setItem(SHOW_BUTTERFLIES_KEY, showButterflies ? 'true' : 'false');
}, [showButterflies]);
useEffect(() => {
localStorage.setItem(AUTO_ORDER_CHOICE_KEY, autoOrderChoice ? 'true' : 'false');
}, [autoOrderChoice]);
// Aplikuje accent barvy při změně hue nebo přepnutí světlý/tmavý.
// Tematické styly (Umamusume, My Little Pony, Hell Girl, K-ON!, Bocchi the Rock!) mají vlastní pevnou paletu, accent barvu proto uplatňuje jen jednoduchý styl.
useEffect(() => { useEffect(() => {
localStorage.setItem(ACCENT_HUE_KEY, String(accentHue)); localStorage.setItem(ACCENT_HUE_KEY, String(accentHue));
if (styleTheme === 'simple') {
applyAccentColors(accentHue, effectiveDark); applyAccentColors(accentHue, effectiveDark);
}, [accentHue, effectiveDark]); } else {
clearAccentColors();
}
}, [accentHue, effectiveDark, styleTheme]);
function setBankAccountNumber(bankAccount?: string) { function setBankAccountNumber(bankAccount?: string) {
setBankAccount(bankAccount); setBankAccount(bankAccount);
@@ -204,6 +283,18 @@ function useProvideSettings(): SettingsContextProps {
setHue(hue); setHue(hue);
} }
function setStyleTheme(theme: StyleTheme) {
setStyle(theme);
}
function setShowButterflies(show: boolean) {
setShowButterfliesState(show);
}
function setAutoOrderChoice(enabled: boolean) {
setAutoOrderChoiceState(enabled);
}
return { return {
bankAccount, bankAccount,
holderName, holderName,
@@ -216,5 +307,11 @@ function useProvideSettings(): SettingsContextProps {
setHideSoupsOption, setHideSoupsOption,
setThemePreference, setThemePreference,
setAccentHue, setAccentHue,
styleTheme,
setStyleTheme,
showButterflies,
setShowButterflies,
autoOrderChoice,
setAutoOrderChoice,
} }
} }
+2 -1
View File
@@ -4,7 +4,8 @@ import socketio from "socket.io-client";
let socketUrl; let socketUrl;
let socketPath; let socketPath;
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
socketUrl = `http://localhost:3001`; // Stejný stroj jako stránka, aby šlo vývojovou verzi zkoušet i z telefonu či tabletu v síti
socketUrl = `http://${globalThis.location.hostname}:3001`;
socketPath = undefined; socketPath = undefined;
} else { } else {
socketUrl = `${globalThis.location.host}`; socketUrl = `${globalThis.location.host}`;
+3
View File
@@ -1,5 +1,8 @@
import { LunchChoice, Restaurant } from "../../types"; import { LunchChoice, Restaurant } from "../../types";
/** Volby, u kterých se nezadává cena oběda — cena je dána objednávkou, Pizza day, nebo se neobědvá. */
export const CHOICES_WITHOUT_PRICE: LunchChoice[] = [LunchChoice.OBJEDNAVAM, LunchChoice.PIZZA, LunchChoice.NEOBEDVAM, LunchChoice.ROZHODUJI];
export function getRestaurantName(restaurant: Restaurant) { export function getRestaurantName(restaurant: Restaurant) {
switch (restaurant) { switch (restaurant) {
case Restaurant.SLADOVNICKA: case Restaurant.SLADOVNICKA:
+33
View File
@@ -0,0 +1,33 @@
import { StyleTheme, useSettings } from "../context/settings";
export type GameId = 'drawing';
/** Ikony miniher podle vizuálního stylu aplikace (ostatní minihry jsou v samostatné aplikaci Lobby). */
const ICONS: Record<StyleTheme, Record<GameId, string>> = {
simple: { drawing: '🎨' },
// Dostihy: trofej
umamusume: { drawing: '🏆' },
// Paleta Rarity
mlp: { drawing: '🖌️' },
// Pekelná korespondence: svitek
hellgirl: { drawing: '📜' },
// Klub lehké hudby: notový zápis
kon: { drawing: '🎼' },
// Kessoku Band: sešit s texty Bocchi
bocchi: { drawing: '📝' },
};
/** Styl z <html data-luncher-theme> — pro místa mimo React kontext nastavení. */
function currentTheme(): StyleTheme {
const theme = document.documentElement.getAttribute('data-luncher-theme') as StyleTheme | null;
return theme && theme in ICONS ? theme : 'simple';
}
/** Ikona hry pro aktuální styl (mimo React, např. výpis do konzole). */
export const gameIcon = (game: GameId, theme: StyleTheme = currentTheme()) => ICONS[theme]?.[game] ?? ICONS.simple[game];
/** Ikona hry pro aktuální styl; stránky bez nastavení (statistiky) použijí atribut na <html>. */
export function useGameIcon(game: GameId) {
const settings = useSettings();
return gameIcon(game, settings?.styleTheme ?? currentTheme());
}
+12
View File
@@ -0,0 +1,12 @@
import { useSyncExternalStore } from "react";
import { getAppConfig, getGroupLimits, subscribeAppConfig } from "../utils/appConfig";
/** Aktuální limity skupinových objednávek (překreslí se po načtení konfigurace ze serveru). */
export function useGroupLimits() {
return useSyncExternalStore(subscribeAppConfig, getGroupLimits);
}
/** Celá runtime konfigurace ze serveru (způsob přihlášení, zapnuté funkce, limity). */
export function useAppConfig() {
return useSyncExternalStore(subscribeAppConfig, getAppConfig);
}
+149
View File
@@ -0,0 +1,149 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { openDrawingGame } from "../components/DrawingGame";
import { GameId, gameIcon } from "../games/themeIcons";
import { useAppConfig } from "./useGroupLimits";
type Minigame = {
id: GameId,
/** Kód, který se napíše po sérii kliknutí na logo. */
code: string,
name: string,
description: string,
open: () => void,
};
/**
* Skryté minihry, které zůstaly v Luncheru (ostatní jsou v samostatné aplikaci Lobby).
* Novou hru stačí přidat sem.
*/
const MINIGAMES: Minigame[] = [
{
id: 'drawing',
code: 'kresleni',
name: 'Kreslení',
description: 'Jeden kreslí jídlo z dnešních menu, ostatní hádají.',
open: openDrawingGame,
},
];
const HELP_CODE = 'help';
const SECRET_CLICKS = 3;
/** Max. pauza mezi kliknutími na logo, aby se počítala do série (ms). */
const SECRET_CLICK_GAP_MS = 400;
/** Jak dlouho po sérii kliknutí (případně po návratu z Lobby do této záložky) lze napsat kód (ms). */
const SECRET_ARM_MS = 8000;
function printHelp(lobbyUrl?: string) {
console.log('%c🎮 Luncher minihry', 'font-size: 16px; font-weight: bold');
if (lobbyUrl) {
console.log(`Klikněte 3× na logo — otevře se Lobby s dalšími minihrami (${lobbyUrl}).`);
console.log('Po návratu do Luncheru lze napsat kód hry:');
} else {
console.log('Klikněte 3× na logo a napište kód hry:');
}
console.table(MINIGAMES.map(g => ({ kód: g.code, hra: `${gameIcon(g.id)} ${g.name}`, popis: g.description })));
}
/** Malá písmena bez diakritiky — česká klávesnice na mobilu může "kresleni" opravit na "Kreslení". */
const normalizeCode = (text: string) => text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim();
/**
* Skrytý vstup do miniher: 3× rychle kliknout na logo. Je-li nastavena adresa Lobby, otevře se v nové záložce.
* Poté lze (po návratu do Luncheru) napsat kód hry, která zůstala v Luncheru, případně "help" pro výpis do konzole prohlížeče.
* Jedno či dvě kliknutí se chovají jako běžný odkaz (jen s malým zpožděním).
* Bez Lobby se na dotykových zařízeních kód píše do skrytého pole (`inputRef`), které otevře softwarovou klávesnici.
* Po otevření Lobby klávesnici po návratu do záložky otevřít nelze (chce gesto uživatele), na mobilu tak kód zadat nepůjde.
*/
export function useMinigameSecret(href: string) {
const { lobbyUrl } = useAppConfig();
const [armed, setArmed] = useState(false);
const clicks = useRef(0);
const timer = useRef<number | undefined>(undefined);
const inputRef = useRef<HTMLInputElement>(null);
const onLogoClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
// Otevření v novém tabu apod. necháme prohlížeči
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
window.clearTimeout(timer.current);
clicks.current++;
if (clicks.current >= SECRET_CLICKS) {
clicks.current = 0;
if (lobbyUrl) {
// Otevření musí proběhnout přímo v obsluze kliknutí, jinak ho prohlížeč zablokuje jako vyskakovací okno
window.open(lobbyUrl, '_blank', 'noopener');
} else {
// Fokus musí proběhnout přímo v obsluze kliknutí, jinak iOS klávesnici neotevře
const input = inputRef.current;
if (input) {
input.value = '';
input.focus({ preventScroll: true });
}
}
setArmed(true);
return;
}
timer.current = window.setTimeout(() => {
clicks.current = 0;
window.location.assign(href);
}, SECRET_CLICK_GAP_MS);
}, [href, lobbyUrl]);
useEffect(() => {
if (!armed) return;
const input = inputRef.current;
const maxLength = Math.max(HELP_CODE.length, ...MINIGAMES.map(g => g.code.length));
let typed = '';
const check = (text: string) => {
if (text.endsWith(HELP_CODE)) {
setArmed(false);
printHelp(lobbyUrl);
return;
}
const game = MINIGAMES.find(g => text.endsWith(g.code));
if (game) {
setArmed(false);
game.open();
}
};
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target?.closest('input, textarea, select, [contenteditable="true"]') || e.key.length !== 1) return;
typed = (typed + normalizeCode(e.key)).slice(-maxLength);
check(typed);
};
// Softwarová klávesnice (Android) posílá v keydown jen "Unidentified", proto se čte celý text pole
const onInput = () => check(normalizeCode(input?.value ?? ''));
// Limit běží, jen když je záložka vidět — po odchodu do Lobby se zastaví a po návratu začne znovu
let disarm: number | undefined;
const startDisarm = () => {
window.clearTimeout(disarm);
disarm = window.setTimeout(() => setArmed(false), SECRET_ARM_MS);
};
const onVisibility = () => {
if (document.visibilityState === 'visible') {
startDisarm();
} else {
window.clearTimeout(disarm);
}
};
startDisarm();
window.addEventListener('keydown', onKey);
input?.addEventListener('input', onInput);
document.addEventListener('visibilitychange', onVisibility);
return () => {
window.clearTimeout(disarm);
window.removeEventListener('keydown', onKey);
input?.removeEventListener('input', onInput);
document.removeEventListener('visibilitychange', onVisibility);
if (input) {
input.value = '';
input.blur();
}
};
}, [armed, lobbyUrl]);
useEffect(() => () => window.clearTimeout(timer.current), []);
return { armed, onLogoClick, inputRef };
}
+13 -2
View File
@@ -10,6 +10,7 @@ import { getConfig } from '../../types/gen/sdk.gen';
import { getToken } from './Utils'; import { getToken } from './Utils';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import { setAppConfig } from './utils/appConfig';
client.setConfig({ client.setConfig({
auth: () => getToken(), auth: () => getToken(),
@@ -17,15 +18,25 @@ client.setConfig({
}); });
// Sentry se inicializuje až podle runtime konfigurace ze serveru (bez DSN zůstává vypnuté). // Sentry se inicializuje až podle runtime konfigurace ze serveru (bez DSN zůstává vypnuté).
// Klient je statický build, takže DSN nejde zapéct při buildu — server ho zná z env. // Klient je statický build, takže DSN ani limity nejde zapéct při buildu — server je zná z env.
getConfig().then(({ data }) => { getConfig().then(({ data }) => {
setAppConfig({
loaded: true,
trustedHeaders: data?.auth?.trustedHeaders,
storeAdmin: data?.features?.storeAdmin,
lobbyUrl: data?.links?.lobbyUrl ?? undefined,
groupLimits: data?.limits?.group,
});
if (data?.sentry.dsn) { if (data?.sentry.dsn) {
Sentry.init({ Sentry.init({
dsn: data.sentry.dsn, dsn: data.sentry.dsn,
environment: data.sentry.environment, environment: data.sentry.environment,
}); });
} }
}).catch(() => { /* config endpoint nedostupný — běžíme bez Sentry */ }); }).catch(() => {
// Config endpoint nedostupný — běžíme bez Sentry, s výchozími limity a původním chováním přihlášení
setAppConfig({ loaded: true });
});
// Interceptor na vyhození toasteru při chybě // Interceptor na vyhození toasteru při chybě
client.interceptors.response.use(async response => { client.interceptors.response.use(async response => {
+91 -8
View File
@@ -7,9 +7,12 @@ import DatePicker, { registerLocale } from 'react-datepicker';
import { cs } from 'date-fns/locale'; import { cs } from 'date-fns/locale';
import 'react-datepicker/dist/react-datepicker.css'; import 'react-datepicker/dist/react-datepicker.css';
import { import {
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr, TrackingProvider, ClientData, GroupState, LunchChoice, MealSlot, OrderGroup, OrderGroupMember, PendingQr, TrackingProvider,
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setTracking, getOrderDates, StoreMenuDish, getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setTracking, getOrderDates, getStoreMenu, addChoice,
} from '../../../types'; } from '../../../types';
import MenuHintCell from '../components/MenuHintCell';
import EaterBadge from '../components/EaterBadge';
import { addItemLimitError, addNoteItem, countNoteItems, removeNoteItem } from '../utils/menuMatch';
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees'; import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
import { getStoreUrls, isHttpUrl, storeUrlLabel } from '../utils/storeUrls'; import { getStoreUrls, isHttpUrl, storeUrlLabel } from '../utils/storeUrls';
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket'; import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
@@ -27,6 +30,7 @@ import BoltSimulationModal from '../components/modals/BoltSimulationModal';
import GenerateMockOrdersModal from '../components/modals/GenerateMockOrdersModal'; import GenerateMockOrdersModal from '../components/modals/GenerateMockOrdersModal';
import PendingPayments from '../components/PendingPayments'; import PendingPayments from '../components/PendingPayments';
import OrderProgress, { PROVIDER_LABEL } from '../components/OrderProgress'; import OrderProgress, { PROVIDER_LABEL } from '../components/OrderProgress';
import { isArrowNavigationBlocked } from '../utils/arrowNavigation';
const SLOT = MealSlot.EXTRA; const SLOT = MealSlot.EXTRA;
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/; const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
@@ -147,6 +151,8 @@ export default function OrderGroupsPage() {
const [mockOrdersModalOpen, setMockOrdersModalOpen] = useState(false); const [mockOrdersModalOpen, setMockOrdersModalOpen] = useState(false);
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null); const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
const [pageError, setPageError] = useState<string | null>(null); const [pageError, setPageError] = useState<string | null>(null);
// Stažené nabídky podniků (dle názvu obchodu) pro sloupec „Z nabídky"
const [storeMenus, setStoreMenus] = useState<Record<string, StoreMenuDish[] | null>>({});
const fetchData = async (date?: string) => { const fetchData = async (date?: string) => {
try { try {
@@ -219,9 +225,8 @@ export default function OrderGroupsPage() {
// Navigace mezi dny pomocí klávesových šipek (←/→), obdobně jako na hlavní stránce // Navigace mezi dny pomocí klávesových šipek (←/→), obdobně jako na hlavní stránce
const handleKeyDown = useCallback((e: KeyboardEvent) => { const handleKeyDown = useCallback((e: KeyboardEvent) => {
// Ignorujeme, pokud uživatel právě píše do formulářového pole // Ignorujeme, pokud uživatel právě píše do formulářového pole nebo je otevřený dialog (např. minihra)
const tag = (e.target as HTMLElement)?.tagName; if (isArrowNavigationBlocked(e)) return;
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
const currentIso = data?.isoDate; const currentIso = data?.isoDate;
if (!currentIso) return; if (!currentIso) return;
if (e.keyCode === 37) { if (e.keyCode === 37) {
@@ -256,6 +261,22 @@ export default function OrderGroupsPage() {
return true; return true;
}; };
/**
* Pokud to má uživatel zapnuté v nastavení, přepne jeho dnešní volbu stravování na "Budu objednávat".
* Volbu nemění, pokud již objednává, nebo je přihlášen na Pizza day (změnou by přišel o objednané pizzy).
*/
const switchChoiceToOrdering = async () => {
if (!settings?.autoOrderChoice || !auth?.login) return;
const lunch = await getData({ query: { slot: MealSlot.OBED } });
const choices = lunch.data?.choices;
if (!choices) return;
if (auth.login in (choices[LunchChoice.OBJEDNAVAM] ?? {}) || auth.login in (choices[LunchChoice.PIZZA] ?? {})) return;
const result = await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: MealSlot.OBED } });
if (result.error) {
setPageError(`Nepodařilo se přepnout volbu stravování: ${(result.error as any).error || 'Nastala chyba'}`);
}
};
const handleCreate = async () => { const handleCreate = async () => {
if (!newGroupName || !auth?.login) return; if (!newGroupName || !auth?.login) return;
setCreating(true); setCreating(true);
@@ -266,13 +287,17 @@ export default function OrderGroupsPage() {
if (ok) { if (ok) {
setNewGroupName(''); setNewGroupName('');
setNewGroupUrl(''); setNewGroupUrl('');
await switchChoiceToOrdering();
} }
setCreating(false); setCreating(false);
}; };
const handleJoin = (groupId: string) => const handleJoin = async (groupId: string) => {
refresh(() => addGroupMember({ body: { id: groupId } })); if (await refresh(() => addGroupMember({ body: { id: groupId } }))) {
await switchChoiceToOrdering();
}
};
const handleToggleLock = (group: OrderGroup) => { const handleToggleLock = (group: OrderGroup) => {
const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN; const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN;
@@ -308,6 +333,49 @@ export default function OrderGroupsPage() {
if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; }); if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
}; };
// Nabídky podniků zobrazených skupin — jen pro obchody ze seznamu, každou jednou
const groupStoreNames = (data?.groups ?? [])
.map(g => (data?.stores ?? []).find(s => s.name.toLowerCase() === g.name.toLowerCase())?.name)
.filter((n): n is string => !!n);
const groupStoreKey = [...new Set(groupStoreNames)].sort().join('|');
useEffect(() => {
for (const name of groupStoreKey ? groupStoreKey.split('|') : []) {
if (name in storeMenus) continue;
setStoreMenus(prev => ({ ...prev, [name]: null }));
getStoreMenu({ query: { name } })
.then(r => setStoreMenus(prev => ({ ...prev, [name]: r.data?.dishes ?? [] })))
.catch(() => setStoreMenus(prev => ({ ...prev, [name]: [] })));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groupStoreKey]);
const getGroupMenu = (group: OrderGroup): StoreMenuDish[] | undefined =>
Object.entries(storeMenus).find(([name]) => name.toLowerCase() === group.name.toLowerCase())?.[1] ?? undefined;
/** Výběr položky z nabídky — připojí ji k poznámce (opakovaný výběr zvýší počet „2x") a cenu přičte k částce. */
const handlePickDish = async (group: OrderGroup, login: string, member: OrderGroupMember, dish: StoreMenuDish) => {
const limitError = addItemLimitError(member.note, dish.name, getGroupMenu(group) ?? []);
if (limitError) {
setPageError(limitError);
return false;
}
const note = addNoteItem(member.note, dish.name, getGroupMenu(group) ?? []);
const amount = dish.price != null ? (member.amount ?? 0) + Math.round(dish.price * 100) : member.amount;
return refresh(() => updateGroupMember({ body: { id: group.id, login, note, ...(amount != null && { amount }) } }));
};
/** Odebere jeden kus položky z poznámky a odečte její cenu z částky (nikdy pod nulu). */
const handleRemoveDish = (group: OrderGroup, login: string, member: OrderGroupMember, index: number) => {
const { note, removedPrice } = removeNoteItem(member.note, index, getGroupMenu(group) ?? []);
const amount = removedPrice != null && member.amount != null
? Math.max(0, member.amount - Math.round(removedPrice * 100))
: member.amount;
return refresh(() => updateGroupMember({ body: { id: group.id, login, note, ...(amount != null && { amount }) } }));
};
const handleUseMenuPrice = (group: OrderGroup, login: string, priceKc: number) =>
refresh(() => updateGroupMember({ body: { id: group.id, login, amount: Math.round(priceKc * 100) } }));
const handleSaveNote = async (groupId: string, login: string) => { const handleSaveNote = async (groupId: string, login: string) => {
const key = `${groupId}:${login}`; const key = `${groupId}:${login}`;
const note = editNotes[key] ?? ''; const note = editNotes[key] ?? '';
@@ -630,6 +698,7 @@ export default function OrderGroupsPage() {
<th style={{ width: 180 }}>Částka (bez slev)</th> <th style={{ width: 180 }}>Částka (bez slev)</th>
<th style={{ width: 220 }}>Příplatek</th> <th style={{ width: 220 }}>Příplatek</th>
<th>Poznámka</th> <th>Poznámka</th>
{getGroupMenu(group)?.length ? <th style={{ width: 240 }}>Z nabídky</th> : null}
<th style={{ width: 160 }}>Celkem (s poplatky)</th> <th style={{ width: 160 }}>Celkem (s poplatky)</th>
<th style={{ width: 40 }}></th> <th style={{ width: 40 }}></th>
</tr> </tr>
@@ -646,6 +715,7 @@ export default function OrderGroupsPage() {
<tr key={memberLogin}> <tr key={memberLogin}>
<td> <td>
<span className="user-info"> <span className="user-info">
<EaterBadge items={countNoteItems(member.note, getGroupMenu(group) ?? [])} />
<strong>{memberLogin}</strong> <strong>{memberLogin}</strong>
{memberLogin === group.creatorLogin && ( {memberLogin === group.creatorLogin && (
<OverlayTrigger placement="top" overlay={<Tooltip>Zakladatel / objednávající</Tooltip>}> <OverlayTrigger placement="top" overlay={<Tooltip>Zakladatel / objednávající</Tooltip>}>
@@ -744,6 +814,19 @@ export default function OrderGroupsPage() {
</span> </span>
)} )}
</td> </td>
{getGroupMenu(group)?.length ? (
<td>
<MenuHintCell
menu={getGroupMenu(group)}
note={member.note}
amount={member.amount}
canEdit={canEdit}
onPick={dish => handlePickDish(group, memberLogin, member, dish)}
onUsePrice={price => handleUseMenuPrice(group, memberLogin, price)}
onRemoveItem={index => handleRemoveDish(group, memberLogin, member, index)}
/>
</td>
) : null}
<td className="text-end"> <td className="text-end">
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}> <small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
{memberTotal > 0 ? `${memberTotal / 100} Kč` : '—'} {memberTotal > 0 ? `${memberTotal / 100} Kč` : '—'}
@@ -775,7 +858,7 @@ export default function OrderGroupsPage() {
return groupTotal > 0 ? ( return groupTotal > 0 ? (
<tfoot> <tfoot>
<tr style={{ fontWeight: 700, borderTop: '2px solid var(--luncher-border)' }}> <tr style={{ fontWeight: 700, borderTop: '2px solid var(--luncher-border)' }}>
<td colSpan={4} className="text-end" style={{ fontSize: '0.9em' }}>Celkem za skupinu:</td> <td colSpan={getGroupMenu(group)?.length ? 5 : 4} className="text-end" style={{ fontSize: '0.9em' }}>Celkem za skupinu:</td>
<td className="text-end">{groupTotal / 100} Kč</td> <td className="text-end">{groupTotal / 100} Kč</td>
<td></td> <td></td>
</tr> </tr>
+2
View File
@@ -12,6 +12,7 @@ import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { getLunchChoiceName } from "../enums"; import { getLunchChoiceName } from "../enums";
import './StatsPage.scss'; import './StatsPage.scss';
import { isArrowNavigationBlocked } from '../utils/arrowNavigation';
const CHART_WIDTH = 1400; const CHART_WIDTH = 1400;
const CHART_HEIGHT = 700; const CHART_HEIGHT = 700;
@@ -131,6 +132,7 @@ export default function StatsPage() {
}, [dateRange]); }, [dateRange]);
const handleKeyDown = useCallback((e: any) => { const handleKeyDown = useCallback((e: any) => {
if (isArrowNavigationBlocked(e)) return;
if (e.keyCode === 37) { if (e.keyCode === 37) {
handlePreviousWeek(); handlePreviousWeek();
} else if (e.keyCode === 39 && !isCurrentOrFutureWeek) { } else if (e.keyCode === 39 && !isCurrentOrFutureWeek) {
+466
View File
@@ -0,0 +1,466 @@
// ============================================
// STYL "BOCCHI THE ROCK!" (volitelný, výchozí je jednoduchý styl)
// Inspirováno anime Bocchi the Rock! (ぼっち・ざ・ろっく!) a kapelou Kessoku Band (結束バンド).
// Růžová tepláková bunda Bocchi, žlutá a modrá kostička z jejích vlasů,
// černý Les Paul, flyery z live house STARRY — tlusté černé linky, tvrdé stíny,
// rastr jako z kopírky a samolepky. Občas se nápis „zasekne" Bocchiinou úzkostí.
// Tmavý režim = koncert v live house STARRY pod barevnými světly.
// Aktivní, pokud má <html> atribut data-luncher-theme="bocchi".
// ============================================
$bocchi: 'html[data-luncher-theme="bocchi"]';
// Kessoku Band: Bocchi (růžová), Nijika (žlutá), Ryo (modrá), Kita (červená)
$bocchi-band: linear-gradient(90deg, #e0457b 0 25%, #ffcf33 25% 50%, #3a63c9 50% 75%, #d93a30 75% 100%);
#{$bocchi} {
--bocchi-font: 'M PLUS Rounded 1c', 'Nunito', -apple-system, sans-serif;
--bocchi-display: 'Dela Gothic One', 'M PLUS Rounded 1c', sans-serif;
--bocchi-pink: #cc3570; // tepláková bunda Bocchi
--bocchi-pink-deep: #a8245a;
--bocchi-pink-light: #ffd6e5;
--bocchi-nijika: #ffcf33; // kostička ve vlasech / Nijika
--bocchi-ryo: #3a63c9; // kostička ve vlasech / Ryo
--bocchi-kita: #d93a30;
--bocchi-ink: #1d1a22; // černý Les Paul, linky flyeru
--bocchi-line: var(--bocchi-ink);
--bocchi-hard-shadow: rgb(29 26 34 / 0.9);
--bocchi-halftone: rgb(224 69 123 / 0.13);
--luncher-primary: var(--bocchi-pink);
--luncher-primary-hover: var(--bocchi-pink-deep);
--luncher-primary-light: #ffe4ee;
--luncher-bg: #fff6f9;
--luncher-bg-card: #ffffff;
--luncher-bg-hover: #fff0f5;
--luncher-text: var(--bocchi-ink);
--luncher-text-secondary: #4a4252;
--luncher-text-muted: #8f8597;
--luncher-border: #e9d9e1;
--luncher-border-light: #f5e9ef;
--luncher-navbar-bg: var(--bocchi-ink);
--luncher-navbar-text: #ffffff;
--luncher-action-icon: var(--bocchi-pink);
--luncher-buyer-icon: var(--bocchi-ryo);
--luncher-success: #2e8a4f;
--luncher-warning: #d08f00;
--luncher-danger: var(--bocchi-kita);
--luncher-radius-sm: 6px;
--luncher-radius: 8px;
--luncher-radius-lg: 10px;
--luncher-radius-xl: 12px;
--luncher-shadow: 4px 4px 0 var(--bocchi-hard-shadow);
--luncher-shadow-lg: 6px 6px 0 var(--bocchi-pink);
&[data-bs-theme="dark"] {
// Live house STARRY — tma, kouř a barevná světla nad pódiem
--bocchi-pink: #ff6fa0;
--bocchi-pink-deep: #e0457b;
--bocchi-pink-light: #4a1f33;
--bocchi-ryo: #7d9cff;
--bocchi-kita: #ff6a5c;
--bocchi-line: #5a4d66;
--bocchi-hard-shadow: #8c2350;
--bocchi-halftone: rgb(255 111 160 / 0.10);
--luncher-primary: #ff7aa8;
--luncher-primary-hover: #ff9cbf;
--luncher-primary-light: #3a1a2b;
--luncher-bg: #121016;
--luncher-bg-card: #1d1a24;
--luncher-bg-hover: #282231;
--luncher-text: #f5eff8;
--luncher-text-secondary: #c9bfd2;
--luncher-text-muted: #8f8499;
--luncher-border: #3a3246;
--luncher-border-light: #2a2433;
--luncher-navbar-bg: #0a090c;
--luncher-action-icon: #ff7aa8;
--luncher-buyer-icon: #ffcf33;
--luncher-success: #6fd59a;
--luncher-warning: #ffcf33;
--luncher-danger: #ff6a5c;
--luncher-shadow: 4px 4px 0 var(--bocchi-hard-shadow);
--luncher-shadow-lg: 6px 6px 0 var(--bocchi-ryo);
}
body {
font-family: var(--bocchi-font);
background-color: var(--luncher-bg);
// Rastr jako z kopírovaného flyeru, nahoře růžový nádech
background-image:
radial-gradient(circle, var(--bocchi-halftone) 0 1.4px, transparent 1.9px),
linear-gradient(180deg, #ffe0ec 0, var(--luncher-bg) 360px);
background-size: 12px 12px, 100% 100%;
background-attachment: fixed;
}
&[data-bs-theme="dark"] body {
// Růžové a modré světlo z rampy nad pódiem
background-image:
radial-gradient(circle, var(--bocchi-halftone) 0 1.4px, transparent 1.9px),
radial-gradient(ellipse 40% 55% at 15% -10%, rgb(255 111 160 / 0.22), transparent 70%),
radial-gradient(ellipse 40% 55% at 85% -10%, rgb(125 156 255 / 0.20), transparent 70%),
linear-gradient(180deg, #1c1622 0, var(--luncher-bg) 420px);
background-size: 12px 12px, 100% 100%, 100% 100%, 100% 100%;
}
.app-container {
background: transparent;
}
h1, h2, h3, h4, .navbar-brand {
font-family: var(--bocchi-display);
font-weight: 400;
}
.btn {
font-family: var(--bocchi-font);
}
// ---------- Navbar: černá jako Les Paul, pod ním pruh Kessoku Band ----------
.navbar {
background:
#{$bocchi-band} bottom / 100% 5px no-repeat,
var(--luncher-navbar-bg) !important;
padding-bottom: calc(var(--bs-navbar-padding-y, 0.5rem) + 5px);
.navbar-brand {
letter-spacing: 0.5px;
animation: bocchi-glitch 7s steps(1, end) infinite;
// Žlutá a modrá kostička z vlasů Bocchi
&::after {
content: '';
display: inline-block;
width: 0.42em;
height: 0.42em;
margin-left: 8px;
margin-right: 0.6em;
vertical-align: super;
background: var(--bocchi-nijika);
box-shadow: 0.55em 0.18em 0 var(--bocchi-ryo);
transform: rotate(-12deg);
}
}
.dropdown-menu {
border: 2px solid var(--bocchi-line);
border-radius: var(--luncher-radius);
box-shadow: var(--luncher-shadow);
}
}
// ---------- Titulek dne: žlutá samolepka z flyeru ----------
.day-navigator .title {
position: relative;
display: inline-block;
padding: 6px 24px 8px;
font-family: var(--bocchi-display);
font-weight: 400;
font-size: 1.6rem;
color: var(--bocchi-ink) !important;
background: var(--bocchi-nijika);
border: 3px solid var(--bocchi-ink);
border-radius: var(--luncher-radius-sm);
box-shadow: 5px 5px 0 var(--bocchi-pink);
transform: rotate(1.2deg);
&::before, &::after {
content: '★';
font-size: 0.7em;
margin: 0 10px;
vertical-align: middle;
color: var(--bocchi-pink-deep);
}
&.text-muted {
background: #e6e0e8;
color: #6b6273 !important;
box-shadow: 5px 5px 0 #b9afbf;
&::before, &::after {
color: #8f8597;
}
}
}
// ---------- Karty podniků: flyery s tvrdým stínem ----------
.restaurant-card {
border: 2px solid var(--bocchi-line);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
&:hover {
transform: translate(-2px, -2px);
border-color: var(--bocchi-line);
box-shadow: var(--luncher-shadow-lg);
}
.restaurant-header {
position: relative;
background:
#{$bocchi-band} bottom / 100% 4px no-repeat,
var(--bocchi-ink);
&:hover {
background:
#{$bocchi-band} bottom / 100% 4px no-repeat,
linear-gradient(90deg, var(--bocchi-pink-deep), var(--bocchi-pink));
}
h3 {
letter-spacing: 0.3px;
}
small {
color: rgb(255 255 255 / 0.85);
}
}
}
&[data-bs-theme="dark"] .restaurant-card .restaurant-header:not(:hover) {
background:
#{$bocchi-band} bottom / 100% 4px no-repeat,
#0a090c;
}
.food-table tbody tr:hover > td {
background: #fff0f5;
}
&[data-bs-theme="dark"] .food-table tbody tr:hover > td {
background: #2e2236;
}
.food-price {
color: var(--bocchi-pink);
font-weight: 800;
}
// ---------- Panely a tabulka voleb ----------
.choice-section,
.choices-table,
.pizza-section,
.no-votes {
border: 2px solid var(--bocchi-line);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
}
// <table> s border-collapse: collapse ignoruje border-radius — kulaté rohy jen se „separate"
.choices-table {
border-collapse: separate;
border-spacing: 0;
}
// Sloupec s podnikem: světle žlutý jako Nijika, oddělený černou linkou
.choices-table > tbody > tr > td:first-child {
background: #fff4c7;
color: var(--bocchi-ink);
font-weight: 800;
border-right: 2px solid var(--bocchi-line);
}
&[data-bs-theme="dark"] .choices-table > tbody > tr > td:first-child {
background: #2a2416;
color: var(--bocchi-nijika);
}
// Bootstrap karty (skupinové objednávky apod.) a seznamy — stejné linky a stíny jako ostatní panely
.card {
border: 2px solid var(--bocchi-line);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
overflow: hidden;
background: var(--luncher-bg-card);
// Rozbalovací výběr z nabídky nesmí karta oříznout
&:has(.select-search-container) {
overflow: visible;
}
> .card-header {
background: var(--luncher-primary-light);
border-bottom: 2px solid var(--bocchi-line);
font-weight: 800;
&:first-child {
border-radius: calc(var(--luncher-radius-lg) - 2px) calc(var(--luncher-radius-lg) - 2px) 0 0;
}
}
// Průhledná tabulka — rohy pak zaobluje karta sama (i když nesmí ořezávat obsah)
.table {
--bs-table-bg: transparent;
margin-bottom: 0;
// Spodní linka posledního řádku by přečnívala přes zaoblené rohy
> :last-child > tr:last-child > * {
border-bottom-width: 0;
}
}
}
.list-group {
border-radius: var(--luncher-radius);
overflow: hidden;
border: 2px solid var(--bocchi-line);
> .list-group-item {
border-left: none;
border-right: none;
&:first-child {
border-top: none;
}
&:last-child {
border-bottom: none;
}
}
}
.form-select, .form-control,
input[type="text"], input[type="email"], input[type="password"] {
border-radius: var(--luncher-radius-sm);
border: 2px solid var(--bocchi-line);
&:focus {
border-color: var(--bocchi-pink);
box-shadow: 3px 3px 0 var(--bocchi-pink);
}
}
// ---------- Tlačítka: samolepky s černou linkou, po stisku „zaklapnou" do stínu ----------
.btn {
border-radius: var(--luncher-radius-sm);
font-weight: 800;
letter-spacing: 0.2px;
transition: transform 0.1s ease, box-shadow 0.1s ease, background 0.2s ease;
&:active {
transform: translate(3px, 3px) !important;
box-shadow: none !important;
}
}
.btn-primary {
background: var(--bocchi-pink);
border: 2px solid var(--bocchi-ink);
color: #fff;
box-shadow: 3px 3px 0 var(--bocchi-ink);
&:hover {
background: var(--bocchi-pink-deep);
border-color: var(--bocchi-ink);
box-shadow: 3px 3px 0 var(--bocchi-ink), 0 0 0 3px rgb(255 207 51 / 0.6);
}
&:disabled {
background: #cfc6d3;
border-color: #8f8597;
box-shadow: 3px 3px 0 #8f8597;
}
}
&[data-bs-theme="dark"] .btn-primary {
color: #1d1a22;
&:hover {
color: #fff;
}
}
.btn-outline-primary {
background: var(--luncher-bg-card);
border: 2px solid var(--bocchi-line);
color: var(--luncher-primary);
box-shadow: 3px 3px 0 var(--bocchi-hard-shadow);
&:hover {
background: var(--luncher-primary-light);
border-color: var(--bocchi-line);
color: var(--luncher-primary);
}
}
.btn-secondary {
background: var(--luncher-bg-card);
border: 2px solid var(--bocchi-line);
color: var(--luncher-text);
box-shadow: 3px 3px 0 var(--bocchi-hard-shadow);
&:hover {
background: var(--luncher-bg-hover);
border-color: var(--bocchi-line);
color: var(--luncher-text);
}
}
.btn-danger {
background: var(--bocchi-kita);
border: 2px solid var(--bocchi-ink);
box-shadow: 3px 3px 0 var(--bocchi-ink);
}
.action-icon:hover {
background: var(--luncher-primary-light);
color: var(--bocchi-pink);
}
// ---------- Modaly ----------
.modal-content {
border: 3px solid var(--bocchi-line);
border-radius: var(--luncher-radius-xl);
box-shadow: 8px 8px 0 var(--bocchi-pink);
overflow: hidden;
}
.modal-header {
background:
#{$bocchi-band} bottom / 100% 5px no-repeat,
#0f0d12;
border-bottom: none;
padding-bottom: calc(var(--bs-modal-header-padding-y, 1rem) + 5px);
.modal-title, .modal-title h2 {
color: #fff;
font-family: var(--bocchi-display);
font-weight: 400;
}
.btn-close {
filter: invert(1) grayscale(1) brightness(2);
}
}
.footer {
border-top: none;
background-image: #{$bocchi-band};
background-size: 100% 4px;
background-repeat: no-repeat;
background-position: top;
}
}
// Bocchiina úzkost: nápis se na okamžik „rozpadne" do růžového a modrého posunu
@keyframes bocchi-glitch {
0%, 92%, 100% { text-shadow: none; transform: none; }
93% { text-shadow: -3px 0 #ff6fa0, 3px 0 #7d9cff; transform: translateX(1px) skewX(-6deg); }
94% { text-shadow: 2px 0 #ff6fa0, -2px 0 #7d9cff; transform: translateX(-2px); }
95% { text-shadow: -1px 0 #ff6fa0, 1px 0 #7d9cff; transform: translateX(1px) skewX(4deg); }
96% { text-shadow: none; transform: none; }
}
@media (max-width: 575.98px) {
#{$bocchi} .day-navigator .title {
font-size: 1.3rem;
}
}
@media (prefers-reduced-motion: reduce) {
#{$bocchi} .navbar .navbar-brand {
animation: none;
}
}
+445
View File
@@ -0,0 +1,445 @@
// ============================================
// STYL "HELL GIRL" (volitelný, výchozí je jednoduchý styl)
// Inspirováno anime Jigoku Shōjo (地獄少女).
// Věčný soumrak Ai Enmy (purpurovo-rudé nebe), papír washi, karmínová rudá
// nit ze slaměné panenky, květy lilie pavoukovité (higanbana) a ostré,
// tradičně japonské tvary s písmem mincho.
// Tmavý režim = půlnoc, kdy se otevírá Pekelná korespondence (地獄通信).
// Aktivní, pokud má <html> atribut data-luncher-theme="hellgirl".
// ============================================
$hg: 'html[data-luncher-theme="hellgirl"]';
// Nebe věčného soumraku (fialová → karmínová → oranžová)
$hg-twilight: linear-gradient(90deg, #1e0a1c, #4a0f2a 35%, #8e0f24 70%, #c8452a);
// Rudá nit — tenká linka s jemnou září
$hg-thread: linear-gradient(90deg, transparent, #c8102e 12%, #e8344f 50%, #c8102e 88%, transparent);
#{$hg} {
--hg-font: 'Shippori Mincho', 'Noto Serif JP', 'Georgia', serif;
--hg-crimson: #a3122a; // kimono Ai / rudá nit
--hg-crimson-deep: #7a0c1f;
--hg-crimson-shadow: #4a0612;
--hg-higanbana: #d4203c; // lilie pavoukovitá
--hg-dusk: #c8452a; // oranžové světlo soumraku
--hg-ink: #1c1012; // černé vlasy / tuš
--hg-washi: #f4ece0; // papír washi
--hg-card-border: #dccbbb;
--hg-petal: rgb(163 18 42 / 0.10);
--hg-petal-2: rgb(200 69 42 / 0.08);
--luncher-primary: var(--hg-crimson);
--luncher-primary-hover: var(--hg-crimson-deep);
--luncher-primary-light: #f7e3e2;
--luncher-bg: var(--hg-washi);
--luncher-bg-card: #fffbf5;
--luncher-bg-hover: #f8ebe6;
--luncher-text: var(--hg-ink);
--luncher-text-secondary: #4f3a3c;
--luncher-text-muted: #8c7672;
--luncher-border: var(--hg-card-border);
--luncher-border-light: #eadfd2;
--luncher-navbar-bg: var(--hg-crimson-deep);
--luncher-navbar-text: #fff4ee;
--luncher-action-icon: var(--hg-crimson);
--luncher-buyer-icon: var(--hg-dusk);
--luncher-success: #3d7a4a;
--luncher-warning: #c07a1a;
--luncher-danger: #c8102e;
// Ostré, tradiční tvary — téměř bez zaoblení
--luncher-radius-sm: 2px;
--luncher-radius: 3px;
--luncher-radius-lg: 4px;
--luncher-radius-xl: 6px;
--luncher-shadow: 0 3px 12px -4px rgb(74 6 18 / 0.25);
--luncher-shadow-lg: 0 10px 26px -8px rgb(74 6 18 / 0.38);
&[data-bs-theme="dark"] {
// Půlnoc — Pekelná korespondence je otevřena
--hg-crimson: #e0334f;
--hg-crimson-deep: #b81c38;
--hg-crimson-shadow: #3a0510;
--hg-higanbana: #ff4d63;
--hg-dusk: #ff7a4a;
--hg-card-border: #3d1c22;
--hg-petal: rgb(224 51 79 / 0.22);
--hg-petal-2: rgb(255 122 74 / 0.12);
--luncher-primary: #ec4a63;
--luncher-primary-hover: #ff6b80;
--luncher-primary-light: #2e1015;
--luncher-bg: #0c0608;
--luncher-bg-card: #170c0f;
--luncher-bg-hover: #221216;
--luncher-text: #f3e6e2;
--luncher-text-secondary: #c9aeaa;
--luncher-text-muted: #8a6c6a;
--luncher-border: #3d1c22;
--luncher-border-light: #2a1418;
--luncher-navbar-bg: #12070a;
--luncher-action-icon: #ec4a63;
--luncher-buyer-icon: #ff9a6b;
--luncher-success: #6fbf7e;
--luncher-warning: #f0b45a;
--luncher-danger: #ff4d63;
--luncher-shadow: 0 4px 16px -4px rgb(0 0 0 / 0.7);
--luncher-shadow-lg: 0 12px 30px -8px rgb(0 0 0 / 0.8), 0 0 18px -6px rgb(224 51 79 / 0.25);
}
body {
font-family: var(--hg-font);
background-color: var(--luncher-bg);
// Rozfoukané okvětní lístky higanbany nad papírem, nahoře záře soumraku
background-image:
radial-gradient(ellipse 3px 1.5px at 20% 30%, var(--hg-petal) 0 100%, transparent 101%),
radial-gradient(ellipse 2px 4px at 70% 75%, var(--hg-petal-2) 0 100%, transparent 101%),
radial-gradient(ellipse 2.5px 1.2px at 85% 15%, var(--hg-petal) 0 100%, transparent 101%),
linear-gradient(180deg, #f3d9cc 0, var(--luncher-bg) 380px);
background-size: 150px 150px, 210px 210px, 260px 260px, 100% 100%;
background-attachment: fixed;
}
&[data-bs-theme="dark"] body {
// Půlnoční tma s rudou září pekelného ohně odspodu
background-image:
radial-gradient(ellipse 3px 1.5px at 20% 30%, var(--hg-petal) 0 100%, transparent 101%),
radial-gradient(ellipse 2px 4px at 70% 75%, var(--hg-petal-2) 0 100%, transparent 101%),
radial-gradient(ellipse 2.5px 1.2px at 85% 15%, var(--hg-petal) 0 100%, transparent 101%),
radial-gradient(ellipse 120% 45% at 50% 115%, rgb(140 12 36 / 0.35), transparent 70%),
linear-gradient(180deg, #1c0a12 0, var(--luncher-bg) 420px);
background-size: 150px 150px, 210px 210px, 260px 260px, 100% 100%, 100% 100%;
}
.app-container {
background: transparent;
}
h1, h2, h3, h4, .navbar-brand, .btn {
font-family: var(--hg-font);
}
// ---------- Navbar: nebe soumraku s rudou nití dole ----------
.navbar {
background:
#{$hg-thread} bottom / 100% 2px no-repeat,
#{$hg-twilight} !important;
padding-bottom: calc(var(--bs-navbar-padding-y, 0.5rem) + 2px);
box-shadow: 0 3px 14px -2px rgb(74 6 18 / 0.5);
.navbar-brand {
font-weight: 800;
letter-spacing: 1.5px;
text-shadow: 0 0 12px rgb(232 52 79 / 0.6);
// Pekelná korespondence — otevírá se jen o půlnoci
&::after {
content: '地獄通信';
display: inline-block;
margin-left: 8px;
font-size: 0.55em;
font-weight: 600;
letter-spacing: 2px;
vertical-align: middle;
color: #ffb3a8;
opacity: 0.85;
animation: hg-flicker 5s ease-in-out infinite;
}
}
.dropdown-menu {
border: 1px solid var(--hg-crimson);
border-radius: var(--luncher-radius);
}
}
&[data-bs-theme="dark"] .navbar {
background:
#{$hg-thread} bottom / 100% 2px no-repeat,
linear-gradient(90deg, #0a0406, #1f0810 55%, #3d0a18) !important;
}
// ---------- Titulek dne: černá lakovaná deska s rudým lemem ----------
.day-navigator .title {
position: relative;
display: inline-block;
padding: 6px 28px 8px;
font-weight: 800;
font-size: 1.7rem;
letter-spacing: 1px;
color: #fff4ee !important;
background: linear-gradient(180deg, #2a1216, #12070a);
border: 1px solid var(--hg-crimson);
outline: 1px solid rgb(163 18 42 / 0.35);
outline-offset: 3px;
border-radius: var(--luncher-radius-sm);
text-shadow: 0 0 10px rgb(232 52 79 / 0.55);
box-shadow: var(--luncher-shadow);
// Květy higanbany po stranách
&::before, &::after {
content: '✿';
font-size: 0.6em;
margin: 0 10px;
vertical-align: middle;
color: var(--hg-higanbana);
text-shadow: 0 0 6px rgb(212 32 60 / 0.7);
}
&.text-muted {
background: linear-gradient(180deg, #6b5a5a, #4a3d3e);
border-color: #8c7672;
outline-color: rgb(140 118 114 / 0.35);
text-shadow: none;
&::before, &::after {
color: #b3a19d;
text-shadow: none;
}
}
}
// Symbol ohně ze znělky místo květů (masku dodá HellGirlFlames, až se obrázek načte)
&[data-hg-symbol] .day-navigator .title {
&::before, &::after {
content: '';
display: inline-block;
font-size: 1em;
width: 0.85em;
height: 0.85em;
margin: 0 14px;
vertical-align: -0.08em;
background: linear-gradient(180deg, var(--hg-dusk), var(--hg-higanbana) 45%, var(--hg-crimson-deep));
-webkit-mask: var(--hg-symbol) center / contain no-repeat;
mask: var(--hg-symbol) center / contain no-repeat;
}
&.text-muted::before, &.text-muted::after {
background: #b3a19d;
}
}
// ---------- Karty podniků ----------
.restaurant-card {
border: 1px solid var(--hg-card-border);
border-top: 3px solid var(--hg-crimson);
border-radius: var(--luncher-radius-lg);
&:hover {
transform: translateY(-2px);
border-color: var(--hg-crimson);
box-shadow: var(--luncher-shadow-lg), 0 0 0 1px rgb(200 16 46 / 0.25);
}
.restaurant-header {
position: relative;
background:
#{$hg-thread} bottom / 100% 2px no-repeat,
linear-gradient(90deg, #1c0a10, var(--hg-crimson-deep));
&:hover {
background:
#{$hg-thread} bottom / 100% 2px no-repeat,
#{$hg-twilight};
}
h3 {
font-weight: 800;
letter-spacing: 0.5px;
text-shadow: 0 1px 0 rgb(0 0 0 / 0.4);
}
small {
color: rgb(255 244 238 / 0.85);
}
}
}
.food-table tbody tr:hover > td {
background: #f8e6e2;
}
&[data-bs-theme="dark"] .food-table tbody tr:hover > td {
background: #2a1218;
}
.food-price {
color: var(--hg-crimson);
font-weight: 700;
}
// ---------- Panely a tabulka voleb ----------
.choice-section,
.choices-table,
.pizza-section,
.no-votes {
border: 1px solid var(--hg-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
}
// Sloupec s podnikem: černá tuš s rudou nití na okraji
.choices-table > tbody > tr > td:first-child {
background: linear-gradient(90deg, #1c1012, #2e1418);
color: #fff4ee;
font-weight: 700;
border-right: 2px solid var(--hg-crimson);
}
&[data-bs-theme="dark"] .choices-table > tbody > tr > td:first-child {
background: linear-gradient(90deg, #22090f, #170c0f);
color: var(--hg-higanbana);
}
// Bootstrap karty (skupinové objednávky apod.) a seznamy
.card {
border: 1px solid var(--hg-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
background: var(--luncher-bg-card);
> .card-header {
background: var(--luncher-primary-light);
border-bottom: 2px solid var(--hg-crimson);
font-weight: 700;
}
.table {
--bs-table-bg: transparent;
margin-bottom: 0;
}
}
.list-group {
border-radius: var(--luncher-radius);
border: 1px solid var(--hg-card-border);
}
.form-select, .form-control,
input[type="text"], input[type="email"], input[type="password"] {
border-radius: var(--luncher-radius-sm);
&:focus {
border-color: var(--hg-crimson);
box-shadow: 0 0 0 3px rgb(200 16 46 / 0.2);
}
}
// ---------- Tlačítka: ostré, karmínová s dvojitou linkou jako pás obi ----------
.btn {
border-radius: var(--luncher-radius-sm);
font-weight: 700;
letter-spacing: 0.4px;
transition: transform 0.12s ease, box-shadow 0.2s ease, background 0.2s ease;
&:active {
transform: translateY(1px) !important;
}
}
.btn-primary {
background: linear-gradient(180deg, #c01733, var(--hg-crimson-deep));
border: 1px solid var(--hg-crimson-shadow);
color: #fff4ee;
box-shadow: inset 0 0 0 2px rgb(255 255 255 / 0.12);
// Rudá záře pekelného ohně při najetí
&:hover {
background: linear-gradient(180deg, #d41d3b, #8e0f24);
box-shadow: inset 0 0 0 2px rgb(255 255 255 / 0.18), 0 0 14px rgb(212 32 60 / 0.5);
}
&:disabled {
background: #b9a3a0;
border-color: #9c8582;
box-shadow: none;
}
}
.btn-outline-primary {
background: transparent;
border: 1px solid var(--hg-crimson);
color: var(--luncher-primary);
&:hover {
background: var(--hg-crimson);
color: #fff4ee;
}
}
.btn-secondary {
background: var(--luncher-bg-card);
border: 1px solid var(--luncher-border);
color: var(--luncher-text);
&:hover {
border-color: var(--hg-crimson);
background: var(--luncher-bg-hover);
color: var(--luncher-text);
}
}
.btn-danger {
background: linear-gradient(180deg, #e0243f, #8e0f24);
border: 1px solid var(--hg-crimson-shadow);
}
.action-icon:hover {
background: var(--luncher-primary-light);
color: var(--hg-higanbana);
}
// ---------- Modaly ----------
.modal-content {
border: 1px solid var(--hg-crimson);
border-radius: var(--luncher-radius-xl);
overflow: hidden;
}
.modal-header {
background:
#{$hg-thread} bottom / 100% 2px no-repeat,
#{$hg-twilight};
border-bottom: none;
.modal-title, .modal-title h2 {
color: #fff4ee;
font-weight: 800;
letter-spacing: 0.5px;
text-shadow: 0 0 10px rgb(232 52 79 / 0.5);
}
.btn-close {
filter: invert(1) grayscale(1) brightness(2);
}
}
.footer {
border-top: none;
background-image: #{$hg-thread};
background-size: 100% 1px;
background-repeat: no-repeat;
background-position: top;
}
}
// Mihotání nápisu jako plamen svíčky
@keyframes hg-flicker {
0%, 100% { opacity: 0.85; }
45% { opacity: 0.85; }
48% { opacity: 0.35; }
50% { opacity: 0.9; }
53% { opacity: 0.5; }
56% { opacity: 0.85; }
}
@media (max-width: 575.98px) {
#{$hg} .navbar .navbar-brand::after {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
#{$hg} .navbar .navbar-brand::after {
animation: none;
}
}
+483
View File
@@ -0,0 +1,483 @@
// ============================================
// STYL "K-ON!" (volitelný, výchozí je jednoduchý styl)
// Inspirováno anime K-ON! (けいおん!) — klub lehké hudby na dívčí škole Sakuragaoka.
// Tmavě modré školní sako, kytara Gitah v barvě cherry sunburst, mléčný čaj
// a dortíky z odpoledních dýchánků (Houkago Tea Time), notová osnova
// a ručně psané nápisy jako ze školního sešitu.
// Tmavý režim = večerní koncert v klubu (live house) pod reflektory.
// Aktivní, pokud má <html> atribut data-luncher-theme="kon".
// ============================================
$kon: 'html[data-luncher-theme="kon"]';
// Pruh v barvách členek Houkago Tea Time: Yui (červená Gitah), Mio (modrá),
// Ritsu (žlutá), Mugi (zlatavá) a Azusa (fialová)
$kon-members: linear-gradient(90deg, #d43f62 0 20%, #3d6fc4 20% 40%, #f2b81d 40% 60%, #e8cf7a 60% 80%, #8a6bc7 80% 100%);
// Notová osnova (pět linek) — opakuje se na pozadí po 220 px
$kon-staff-stops: transparent 0 100px,
var(--kon-staff) 100px 101px, transparent 101px 109px,
var(--kon-staff) 109px 110px, transparent 110px 118px,
var(--kon-staff) 118px 119px, transparent 119px 127px,
var(--kon-staff) 127px 128px, transparent 128px 136px,
var(--kon-staff) 136px 137px, transparent 137px;
// Rozeseté noty ♪ ♫ (ve dne červená a modrá, v noci světlejší)
$kon-notes: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Ctext x='34' y='95' font-size='26' fill='%23d43f62' fill-opacity='0.16'%3E%E2%99%AA%3C/text%3E%3Ctext x='150' y='128' font-size='30' fill='%233d6fc4' fill-opacity='0.14'%3E%E2%99%AB%3C/text%3E%3Ctext x='96' y='210' font-size='18' fill='%23e8a10f' fill-opacity='0.18'%3E%E2%99%AA%3C/text%3E%3C/svg%3E");
$kon-notes-dark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Ctext x='34' y='95' font-size='26' fill='%23ff8fa8' fill-opacity='0.22'%3E%E2%99%AA%3C/text%3E%3Ctext x='150' y='128' font-size='30' fill='%238fb2ff' fill-opacity='0.2'%3E%E2%99%AB%3C/text%3E%3Ctext x='96' y='210' font-size='18' fill='%23ffd166' fill-opacity='0.22'%3E%E2%99%AA%3C/text%3E%3C/svg%3E");
#{$kon} {
--kon-font: 'Zen Maru Gothic', 'M PLUS Rounded 1c', 'Nunito', -apple-system, sans-serif;
--kon-hand: 'Yusei Magic', 'Zen Maru Gothic', 'M PLUS Rounded 1c', sans-serif;
--kon-blazer: #27315f; // tmavě modré sako Sakuragaoka
--kon-blazer-light: #3a4782;
--kon-blazer-shadow: #171d3d;
--kon-cherry: #c43358; // Gitah — cherry sunburst
--kon-cherry-deep: #a3234a;
--kon-sunburst: #f2a33a; // jantarový střed sunburstu
--kon-ritsu: #f2b81d;
--kon-tea: #fbf3e4; // mléčný čaj
--kon-paper: #fffaf0; // notový sešit
--kon-card-border: #e6d8c0;
--kon-staff: rgb(39 49 95 / 0.07);
--luncher-primary: var(--kon-cherry);
--luncher-primary-hover: var(--kon-cherry-deep);
--luncher-primary-light: #fbe4ea;
--luncher-bg: var(--kon-tea);
--luncher-bg-card: var(--kon-paper);
--luncher-bg-hover: #f8ecd8;
--luncher-text: #222849;
--luncher-text-secondary: #4a5179;
--luncher-text-muted: #8a8aa3;
--luncher-border: var(--kon-card-border);
--luncher-border-light: #f0e6d3;
--luncher-navbar-bg: var(--kon-blazer);
--luncher-navbar-text: #ffffff;
--luncher-action-icon: var(--kon-cherry);
--luncher-buyer-icon: #e8a10f;
--luncher-success: #2f8a57;
--luncher-warning: #d98a0b;
--luncher-danger: #d0304a;
--luncher-radius-sm: 10px;
--luncher-radius: 14px;
--luncher-radius-lg: 18px;
--luncher-radius-xl: 22px;
--luncher-shadow: 0 4px 14px -6px rgb(39 49 95 / 0.25);
--luncher-shadow-lg: 0 10px 26px -8px rgb(39 49 95 / 0.35);
&[data-bs-theme="dark"] {
// Večerní koncert v klubu — reflektory nad pódiem
--kon-blazer: #1a2150;
--kon-blazer-light: #2b367a;
--kon-blazer-shadow: #0a0e26;
--kon-cherry: #ff7a9a;
--kon-cherry-deep: #e85a7e;
--kon-ritsu: #ffd166;
--kon-card-border: #2c3566;
--kon-staff: rgb(180 196 255 / 0.06);
--luncher-primary: #ff86a3;
--luncher-primary-hover: #ffa8bd;
--luncher-primary-light: #3a1d35;
--luncher-bg: #0e1230;
--luncher-bg-card: #171c42;
--luncher-bg-hover: #212858;
--luncher-text: #f1f0ff;
--luncher-text-secondary: #bfc2e6;
--luncher-text-muted: #8085b3;
--luncher-border: #2c3566;
--luncher-border-light: #232a58;
--luncher-navbar-bg: #141a44;
--luncher-action-icon: #ff86a3;
--luncher-buyer-icon: #ffd166;
--luncher-success: #6fd59a;
--luncher-warning: #ffd166;
--luncher-danger: #ff6f86;
--luncher-shadow: 0 4px 16px -4px rgb(0 0 0 / 0.55);
--luncher-shadow-lg: 0 12px 30px -8px rgb(0 0 0 / 0.65);
}
body {
font-family: var(--kon-font);
background-color: var(--luncher-bg);
// Notový sešit: osnova s notami, nahoře teplé odpolední světlo klubovny
background-image:
#{$kon-notes},
linear-gradient(180deg, #{$kon-staff-stops}),
linear-gradient(180deg, #fde4d6 0, var(--luncher-bg) 360px);
background-size: 220px 220px, 100% 220px, 100% 100%;
background-attachment: fixed;
}
&[data-bs-theme="dark"] body {
// Dva reflektory (růžový a jantarový) svítí z horních rohů na pódium
background-image:
#{$kon-notes-dark},
linear-gradient(180deg, #{$kon-staff-stops}),
radial-gradient(ellipse 45% 60% at 10% -10%, rgb(255 122 154 / 0.18), transparent 70%),
radial-gradient(ellipse 45% 60% at 90% -10%, rgb(255 209 102 / 0.14), transparent 70%),
linear-gradient(180deg, #1a1f4d 0, var(--luncher-bg) 420px);
background-size: 220px 220px, 100% 220px, 100% 100%, 100% 100%, 100% 100%;
}
.app-container {
background: transparent;
}
h1, h2, h3, h4, .navbar-brand, .btn {
font-family: var(--kon-font);
}
// ---------- Navbar: školní sako s pruhem v barvách kapely ----------
.navbar {
background:
#{$kon-members} bottom / 100% 4px no-repeat,
linear-gradient(90deg, var(--kon-blazer-shadow), var(--kon-blazer) 40%, var(--kon-blazer-light)) !important;
padding-bottom: calc(var(--bs-navbar-padding-y, 0.5rem) + 4px);
box-shadow: 0 3px 10px -2px rgb(23 29 61 / 0.4);
.navbar-brand {
font-weight: 900;
letter-spacing: 0.5px;
text-shadow: 0 2px 0 var(--kon-blazer-shadow);
// Nota, která poskakuje do rytmu
&::after {
content: '♪';
display: inline-block;
margin-left: 6px;
color: var(--kon-ritsu);
font-size: 0.85em;
vertical-align: super;
animation: kon-bounce 1.2s ease-in-out infinite;
}
}
.dropdown-menu {
border: 2px solid var(--kon-card-border);
border-radius: var(--luncher-radius);
}
}
// ---------- Titulek dne: ručně psaná cedulka z dveří klubovny ----------
.day-navigator .title {
position: relative;
display: inline-block;
padding: 4px 26px 6px;
font-family: var(--kon-hand);
font-weight: 400;
font-size: 1.8rem;
color: var(--kon-blazer) !important;
background:
repeating-linear-gradient(180deg, transparent 0 11px, rgb(61 111 196 / 0.12) 11px 12px),
var(--kon-paper);
border: 3px solid var(--kon-blazer);
border-radius: 12px;
box-shadow: 4px 4px 0 var(--kon-cherry);
transform: rotate(-1.5deg);
&::before {
content: '♪';
margin-right: 10px;
color: var(--kon-cherry);
}
&::after {
content: '♫';
margin-left: 10px;
color: #3d6fc4;
}
&.text-muted {
color: #8a8aa3 !important;
border-color: #a9a9be;
box-shadow: 4px 4px 0 #cfcfdc;
&::before, &::after {
color: #a9a9be;
}
}
}
&[data-bs-theme="dark"] .day-navigator .title {
color: #f1f0ff !important;
background:
repeating-linear-gradient(180deg, transparent 0 11px, rgb(143 178 255 / 0.12) 11px 12px),
var(--luncher-bg-card);
border-color: #8fb2ff;
&::after {
color: #8fb2ff;
}
&.text-muted {
color: #8085b3 !important;
border-color: #4a5288;
box-shadow: 4px 4px 0 #2c3566;
}
}
// ---------- Karty podniků ----------
.restaurant-card {
border: 2px solid var(--kon-card-border);
border-radius: var(--luncher-radius-lg);
&:hover {
transform: translateY(-3px) rotate(-0.3deg);
border-color: var(--kon-cherry);
box-shadow: var(--luncher-shadow-lg);
}
.restaurant-header {
position: relative;
background:
#{$kon-members} bottom / 100% 4px no-repeat,
linear-gradient(90deg, var(--kon-blazer), var(--kon-blazer-light));
// Gitah: jantarový střed přechází do třešňové
&:hover {
background:
#{$kon-members} bottom / 100% 4px no-repeat,
radial-gradient(ellipse 80% 160% at 30% 50%, var(--kon-sunburst), var(--kon-cherry) 55%, #6e1530);
}
h3 {
font-weight: 900;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.2);
}
small {
color: rgb(255 255 255 / 0.88);
}
}
}
.food-table tbody tr:hover > td {
background: #fdeee0;
}
&[data-bs-theme="dark"] .food-table tbody tr:hover > td {
background: #232a5c;
}
.food-price {
color: var(--kon-cherry);
font-weight: 700;
}
// ---------- Panely a tabulka voleb ----------
.choice-section,
.choices-table,
.pizza-section,
.no-votes {
border: 2px solid var(--kon-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
}
// <table> s border-collapse: collapse ignoruje border-radius — kulaté rohy jen se „separate"
.choices-table {
border-collapse: separate;
border-spacing: 0;
}
// Sloupec s podnikem: linkovaný papír se svislou okrajovou linkou jako v sešitě
.choices-table > tbody > tr > td:first-child {
background: #fdf1dc;
color: var(--kon-blazer);
font-weight: 700;
border-right: 2px solid rgb(196 51 88 / 0.45);
}
&[data-bs-theme="dark"] .choices-table > tbody > tr > td:first-child {
background: #1f2552;
color: #c9d6ff;
border-right-color: rgb(255 134 163 / 0.5);
}
// Bootstrap karty (skupinové objednávky apod.) a seznamy — stejné zaoblení a lem jako ostatní panely
.card {
border: 2px solid var(--kon-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
overflow: hidden;
background: var(--luncher-bg-card);
// Rozbalovací výběr z nabídky nesmí karta oříznout
&:has(.select-search-container) {
overflow: visible;
}
> .card-header {
background: var(--luncher-primary-light);
border-bottom: 2px solid var(--kon-card-border);
font-weight: 700;
&:first-child {
border-radius: calc(var(--luncher-radius-lg) - 2px) calc(var(--luncher-radius-lg) - 2px) 0 0;
}
}
// Průhledná tabulka — rohy pak zaobluje karta sama (i když nesmí ořezávat obsah)
.table {
--bs-table-bg: transparent;
margin-bottom: 0;
// Spodní linka posledního řádku by přečnívala přes zaoblené rohy
> :last-child > tr:last-child > * {
border-bottom-width: 0;
}
}
}
.list-group {
border-radius: var(--luncher-radius);
overflow: hidden;
border: 2px solid var(--kon-card-border);
> .list-group-item {
border-left: none;
border-right: none;
&:first-child {
border-top: none;
}
&:last-child {
border-bottom: none;
}
}
}
.form-select, .form-control,
input[type="text"], input[type="email"], input[type="password"] {
border-radius: var(--luncher-radius-sm);
border-width: 2px;
&:focus {
border-color: var(--kon-cherry);
box-shadow: 0 0 0 4px rgb(196 51 88 / 0.18);
}
}
// ---------- Tlačítka: zaoblené jako pedály efektů, s tmavě modrým „podstavcem" ----------
.btn {
border-radius: var(--luncher-radius-sm);
font-weight: 700;
letter-spacing: 0.2px;
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.2s ease;
&:active {
transform: translateY(3px) !important;
box-shadow: 0 1px 0 var(--kon-btn-shadow, transparent) !important;
}
}
.btn-primary {
--kon-btn-shadow: var(--kon-blazer-shadow);
background: linear-gradient(180deg, #e0567a, var(--kon-cherry-deep));
border: none;
color: #fff;
box-shadow: 0 4px 0 var(--kon-blazer);
text-shadow: 0 1px 0 rgb(0 0 0 / 0.2);
// Sunburst při najetí
&:hover {
background: radial-gradient(ellipse 90% 140% at 50% 40%, var(--kon-sunburst), var(--kon-cherry) 60%, var(--kon-cherry-deep));
box-shadow: 0 4px 0 var(--kon-blazer), 0 0 12px rgb(242 163 58 / 0.45);
}
&:disabled {
background: #c8c3cf;
box-shadow: 0 4px 0 #a5a1b0;
}
}
.btn-outline-primary {
--kon-btn-shadow: var(--kon-card-border);
background: var(--luncher-bg-card);
border: 2px solid var(--kon-blazer);
color: var(--kon-blazer);
box-shadow: 0 4px 0 var(--kon-card-border);
&:hover {
background: var(--kon-blazer);
color: #fff;
}
}
&[data-bs-theme="dark"] .btn-outline-primary {
border-color: #8fb2ff;
color: #c9d6ff;
&:hover {
background: #2b367a;
color: #fff;
}
}
.btn-secondary {
--kon-btn-shadow: var(--luncher-border);
background: var(--luncher-bg-card);
border: 2px solid var(--luncher-border);
color: var(--luncher-text);
box-shadow: 0 4px 0 var(--luncher-border);
}
.btn-danger {
--kon-btn-shadow: #8e1d33;
background: linear-gradient(180deg, #ef5a74, var(--luncher-danger));
border: none;
box-shadow: 0 4px 0 #8e1d33;
}
.action-icon:hover {
background: var(--luncher-primary-light);
color: var(--kon-cherry);
}
// ---------- Modaly ----------
.modal-content {
border: 2px solid var(--kon-card-border);
border-radius: var(--luncher-radius-xl);
overflow: hidden;
}
.modal-header {
background:
#{$kon-members} bottom / 100% 4px no-repeat,
linear-gradient(90deg, var(--kon-blazer-shadow), var(--kon-blazer) 40%, var(--kon-blazer-light));
border-bottom: none;
padding-bottom: calc(var(--bs-modal-header-padding-y, 1rem) + 4px);
.modal-title, .modal-title h2 {
color: #fff;
font-weight: 900;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.2);
}
.btn-close {
filter: invert(1) grayscale(1) brightness(2);
}
}
.footer {
border-top: none;
background-image: #{$kon-members};
background-size: 100% 3px;
background-repeat: no-repeat;
background-position: top;
}
}
@keyframes kon-bounce {
0%, 100% { transform: translateY(0) rotate(0deg); }
25% { transform: translateY(-4px) rotate(-12deg); }
50% { transform: translateY(0) rotate(0deg); }
75% { transform: translateY(-2px) rotate(10deg); }
}
@media (prefers-reduced-motion: reduce) {
#{$kon} .navbar .navbar-brand::after {
animation: none;
}
}
+442
View File
@@ -0,0 +1,442 @@
// ============================================
// STYL "MY LITTLE PONY" (volitelný, výchozí je jednoduchý styl)
// Inspirováno 4. generací — My Little Pony: Friendship is Magic.
// Fialová Twilight Sparkle, růžová Pinkie Pie, pastelová obloha Ponyville,
// duhový pruh Rainbow Dash (Sonic Rainboom) a hvězdička z cutie marku Twilight.
// Tmavý režim = noční obloha princezny Luny.
// Aktivní, pokud má <html> atribut data-luncher-theme="mlp".
// ============================================
$mlp: 'html[data-luncher-theme="mlp"]';
// Duha Rainbow Dash (červená, oranžová, žlutá, zelená, modrá, fialová)
$mlp-rainbow: linear-gradient(90deg, #ee4144 0 16.6%, #f37033 16.6% 33.3%, #fdf6af 33.3% 50%, #62bc4d 50% 66.6%, #1e98d3 66.6% 83.3%, #672f89 83.3% 100%);
$mlp-rainbow-soft: linear-gradient(90deg, #ee4144, #f37033, #fdf6af, #62bc4d, #1e98d3, #672f89);
#{$mlp} {
--mlp-font: 'Fredoka', 'Nunito', 'Inter', -apple-system, sans-serif;
--mlp-twilight: #8e4fd1; // srst Twilight (sytější kvůli kontrastu)
--mlp-twilight-deep: #6a35b0;
--mlp-twilight-shadow: #4b2287;
--mlp-twilight-light: #d9b8f5;
--mlp-pinkie: #f06aa6; // Pinkie Pie
--mlp-pinkie-deep: #d6417f;
--mlp-star: #ec3d8f; // cutie mark Twilight
--mlp-fluttershy: #fff3b0; // Fluttershy
--mlp-sky: #5ec8f2; // Rainbow Dash
--mlp-card-border: #e5d4f7;
--mlp-sparkle: rgb(142 79 209 / 0.10);
--mlp-sparkle-2: rgb(240 106 166 / 0.10);
--luncher-primary: var(--mlp-twilight-deep);
--luncher-primary-hover: var(--mlp-twilight-shadow);
--luncher-primary-light: #f1e6fc;
--luncher-bg: #faf5ff;
--luncher-bg-card: #ffffff;
--luncher-bg-hover: #f6edff;
--luncher-text: #2e1f47;
--luncher-text-secondary: #5d4a7a;
--luncher-text-muted: #9a8ab3;
--luncher-border: #e5d4f7;
--luncher-border-light: #f0e6fb;
--luncher-navbar-bg: var(--mlp-twilight-deep);
--luncher-navbar-text: #ffffff;
--luncher-action-icon: var(--mlp-twilight-deep);
--luncher-buyer-icon: #f37033;
--luncher-success: #3f9d3a;
--luncher-warning: #e8891c;
--luncher-danger: #e0395c;
--luncher-radius-sm: 12px;
--luncher-radius: 16px;
--luncher-radius-lg: 22px;
--luncher-radius-xl: 26px;
--luncher-shadow: 0 4px 14px -4px rgb(106 53 176 / 0.22);
--luncher-shadow-lg: 0 10px 28px -8px rgb(106 53 176 / 0.32);
&[data-bs-theme="dark"] {
// Noční obloha princezny Luny
--mlp-twilight: #b78af0;
--mlp-twilight-deep: #9b6be0;
--mlp-twilight-shadow: #3d2380;
--mlp-twilight-light: #3a2b6e;
--mlp-pinkie: #ff8cc0;
--mlp-pinkie-deep: #f06aa6;
--mlp-card-border: #362c6e;
--mlp-sparkle: rgb(255 255 255 / 0.55);
--mlp-sparkle-2: rgb(183 198 255 / 0.35);
--luncher-primary: #c4a0f7;
--luncher-primary-hover: #d9c0fb;
--luncher-primary-light: #2b2160;
--luncher-bg: #120e2e;
--luncher-bg-card: #1d1745;
--luncher-bg-hover: #282058;
--luncher-text: #f2edff;
--luncher-text-secondary: #c2b6e6;
--luncher-text-muted: #8a7fb5;
--luncher-border: #362c6e;
--luncher-border-light: #2b2360;
--luncher-navbar-bg: #2a1f66;
--luncher-action-icon: #c4a0f7;
--luncher-buyer-icon: #ffb36b;
--luncher-success: #7fd67a;
--luncher-warning: #ffcf5c;
--luncher-danger: #ff6f8f;
--luncher-shadow: 0 4px 16px -4px rgb(0 0 0 / 0.55);
--luncher-shadow-lg: 0 12px 30px -8px rgb(0 0 0 / 0.65);
}
body {
font-family: var(--mlp-font);
background-color: var(--luncher-bg);
// Rozeseté jiskřičky (ve dne pastelové, v noci hvězdy)
background-image:
radial-gradient(circle at 20% 30%, var(--mlp-sparkle) 0 2px, transparent 3px),
radial-gradient(circle at 70% 80%, var(--mlp-sparkle-2) 0 2px, transparent 3px),
radial-gradient(circle at 85% 15%, var(--mlp-sparkle) 0 1.5px, transparent 2.5px),
linear-gradient(180deg, #fde9f4 0, var(--luncher-bg) 360px);
background-size: 140px 140px, 190px 190px, 230px 230px, 100% 100%;
background-attachment: fixed;
}
&[data-bs-theme="dark"] body {
background-image:
radial-gradient(circle at 20% 30%, var(--mlp-sparkle) 0 1.5px, transparent 2.5px),
radial-gradient(circle at 70% 80%, var(--mlp-sparkle-2) 0 1px, transparent 2px),
radial-gradient(circle at 85% 15%, var(--mlp-sparkle) 0 1px, transparent 2px),
linear-gradient(180deg, #221a55 0, var(--luncher-bg) 420px);
}
.app-container {
background: transparent;
}
h1, h2, h3, h4, .navbar-brand, .btn {
font-family: var(--mlp-font);
}
// ---------- Navbar: fialová Twilight s duhovým pruhem dole ----------
.navbar {
background:
#{$mlp-rainbow} bottom / 100% 5px no-repeat,
linear-gradient(90deg, var(--mlp-twilight-deep), var(--mlp-twilight) 60%, var(--mlp-pinkie)) !important;
padding-bottom: calc(var(--bs-navbar-padding-y, 0.5rem) + 5px);
box-shadow: 0 3px 10px -2px rgb(106 53 176 / 0.35);
.navbar-brand {
font-weight: 700;
letter-spacing: 0.4px;
text-shadow: 0 2px 0 var(--mlp-twilight-shadow), 0 0 14px rgb(255 255 255 / 0.3);
// Hvězdička z cutie marku Twilight Sparkle
&::after {
content: '✦';
display: inline-block;
margin-left: 6px;
color: #ffd1ea;
font-size: 0.8em;
vertical-align: super;
text-shadow: 0 0 8px var(--mlp-star), 0 0 2px #fff;
animation: mlp-twinkle 2.4s ease-in-out infinite;
}
}
.dropdown-menu {
border: 2px solid var(--mlp-card-border);
}
}
&[data-bs-theme="dark"] .navbar {
background:
#{$mlp-rainbow} bottom / 100% 5px no-repeat,
linear-gradient(90deg, #1f1650, #2f2276 60%, #3d2d8a) !important;
}
// ---------- Titulek dne: pilulka s duhovou linkou a jiskřičkami ----------
.day-navigator .title {
position: relative;
display: inline-block;
padding: 6px 30px 10px;
font-weight: 700;
font-size: 1.7rem;
color: #fff !important;
border-radius: 999px;
background:
#{$mlp-rainbow-soft} bottom / 100% 5px no-repeat,
linear-gradient(90deg, var(--mlp-twilight-deep), var(--mlp-pinkie));
text-shadow: 0 2px 0 rgb(0 0 0 / 0.15);
box-shadow: 0 4px 0 var(--mlp-twilight-shadow);
overflow: hidden;
&::before, &::after {
content: '✨';
font-size: 0.7em;
margin: 0 6px;
vertical-align: middle;
}
&.text-muted {
background: linear-gradient(90deg, #a597bd, #c3b8d6);
box-shadow: 0 4px 0 #8a7ca3;
}
}
// ---------- Karty podniků ----------
.restaurant-card {
border: 3px solid var(--mlp-card-border);
border-radius: var(--luncher-radius-lg);
&:hover {
transform: translateY(-3px);
border-color: var(--mlp-twilight-light);
box-shadow: var(--luncher-shadow-lg), 0 0 0 4px rgb(240 106 166 / 0.18);
}
.restaurant-header {
position: relative;
background:
#{$mlp-rainbow} bottom / 100% 4px no-repeat,
linear-gradient(90deg, var(--mlp-twilight-deep), var(--mlp-twilight));
&:hover {
background:
#{$mlp-rainbow} bottom / 100% 4px no-repeat,
linear-gradient(90deg, var(--mlp-pinkie-deep), var(--mlp-pinkie));
}
h3 {
font-weight: 700;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.18);
}
small {
color: rgb(255 255 255 / 0.88);
}
}
}
.food-table tbody tr:hover > td {
background: #fff0f7;
}
&[data-bs-theme="dark"] .food-table tbody tr:hover > td {
background: #2e2160;
}
.food-price {
color: var(--mlp-pinkie-deep);
font-weight: 700;
}
&[data-bs-theme="dark"] .food-price {
color: var(--mlp-pinkie);
}
// ---------- Panely a tabulka voleb ----------
.choice-section,
.choices-table,
.pizza-section,
.no-votes {
border: 3px solid var(--mlp-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
}
// <table> s border-collapse: collapse ignoruje border-radius — kulaté rohy jen se „separate"
.choices-table {
border-collapse: separate;
border-spacing: 0;
}
// Sloupec s podnikem: jemná žlutá Fluttershy přecházející do růžové
.choices-table > tbody > tr > td:first-child {
background: linear-gradient(90deg, var(--mlp-fluttershy), #ffe6f2);
color: var(--mlp-twilight-deep);
font-weight: 700;
border-right: 3px solid var(--mlp-card-border);
}
&[data-bs-theme="dark"] .choices-table > tbody > tr > td:first-child {
background: linear-gradient(90deg, #2f2468, #241c55);
color: var(--mlp-twilight);
}
// Bootstrap karty (skupinové objednávky apod.) a seznamy — stejné zaoblení a lem jako ostatní panely
.card {
border: 3px solid var(--mlp-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
overflow: hidden;
background: var(--luncher-bg-card);
// Rozbalovací výběr z nabídky nesmí karta oříznout
&:has(.select-search-container) {
overflow: visible;
}
> .card-header {
background: var(--luncher-primary-light);
border-bottom: 3px solid var(--mlp-card-border);
&:first-child {
border-radius: calc(var(--luncher-radius-lg) - 3px) calc(var(--luncher-radius-lg) - 3px) 0 0;
}
}
// Průhledná tabulka — rohy pak zaobluje karta sama (i když nesmí ořezávat obsah)
.table {
--bs-table-bg: transparent;
margin-bottom: 0;
// Spodní linka posledního řádku by přečnívala přes zaoblené rohy
> :last-child > tr:last-child > * {
border-bottom-width: 0;
}
}
}
.list-group {
border-radius: var(--luncher-radius);
overflow: hidden;
border: 2px solid var(--mlp-card-border);
> .list-group-item {
border-left: none;
border-right: none;
&:first-child {
border-top: none;
}
&:last-child {
border-bottom: none;
}
}
}
.form-select, .form-control,
input[type="text"], input[type="email"], input[type="password"] {
border-radius: 999px;
border-width: 2px;
&:focus {
border-color: var(--mlp-twilight);
box-shadow: 0 0 0 4px rgb(240 106 166 / 0.25);
}
}
// ---------- Tlačítka: kulaté „bonbony" s magickou září ----------
.btn {
border-radius: 999px;
font-weight: 600;
letter-spacing: 0.2px;
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.2s ease, filter 0.2s ease;
&:active {
transform: translateY(3px) !important;
box-shadow: 0 1px 0 var(--mlp-btn-shadow, transparent) !important;
}
}
.btn-primary {
--mlp-btn-shadow: var(--mlp-twilight-shadow);
background: linear-gradient(180deg, #b07ae6, var(--mlp-twilight-deep));
border: none;
color: #fff;
box-shadow: 0 4px 0 var(--mlp-twilight-shadow);
text-shadow: 0 1px 0 rgb(0 0 0 / 0.2);
// Magická aura Twilight při najetí
&:hover {
background: linear-gradient(180deg, #bf8cf0, #7a42c4);
box-shadow: 0 4px 0 var(--mlp-twilight-shadow), 0 0 14px rgb(236 61 143 / 0.45);
}
&:disabled {
background: #c9bdd9;
box-shadow: 0 4px 0 #a99cbc;
}
}
.btn-outline-primary {
--mlp-btn-shadow: var(--mlp-card-border);
background: var(--luncher-bg-card);
border: 2px solid var(--mlp-twilight);
color: var(--luncher-primary);
box-shadow: 0 4px 0 var(--mlp-card-border);
&:hover {
background: var(--luncher-primary-light);
color: var(--luncher-primary);
}
}
.btn-secondary {
--mlp-btn-shadow: var(--luncher-border);
background: var(--luncher-bg-card);
border: 2px solid var(--luncher-border);
color: var(--luncher-text);
box-shadow: 0 4px 0 var(--luncher-border);
}
.btn-danger {
--mlp-btn-shadow: #a8264a;
background: linear-gradient(180deg, #ff7e9a, var(--luncher-danger));
border: none;
box-shadow: 0 4px 0 #a8264a;
}
.action-icon:hover {
background: #ffe3f0;
color: var(--mlp-pinkie-deep);
}
&[data-bs-theme="dark"] .action-icon:hover {
background: #3a2462;
color: var(--mlp-pinkie);
}
// ---------- Modaly ----------
.modal-content {
border: 3px solid var(--mlp-card-border);
border-radius: var(--luncher-radius-xl);
overflow: hidden;
}
.modal-header {
background:
#{$mlp-rainbow} bottom / 100% 5px no-repeat,
linear-gradient(90deg, var(--mlp-twilight-deep), var(--mlp-twilight) 60%, var(--mlp-pinkie));
border-bottom: none;
padding-bottom: calc(var(--bs-modal-header-padding-y, 1rem) + 5px);
.modal-title, .modal-title h2 {
color: #fff;
font-weight: 700;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.18);
}
.btn-close {
filter: invert(1) grayscale(1) brightness(2);
}
}
.footer {
border-top: none;
background-image: #{$mlp-rainbow};
background-size: 100% 5px;
background-repeat: no-repeat;
background-position: top;
}
}
@keyframes mlp-twinkle {
0%, 100% { transform: scale(1) rotate(0deg); opacity: 1; }
50% { transform: scale(1.35) rotate(20deg); opacity: 0.75; }
}
@media (prefers-reduced-motion: reduce) {
#{$mlp} .navbar .navbar-brand::after {
animation: none;
}
}
+371
View File
@@ -0,0 +1,371 @@
// ============================================
// STYL "UMAMUSUME" (volitelný, výchozí je jednoduchý styl)
// Inspirováno Umamusume: Pretty Derby — trávník dostihové dráhy, růžové
// a nebesky modré akcenty, zlaté lemy, zaoblené tučné písmo a „tlačítka z her".
// Aktivní, pokud má <html> atribut data-luncher-theme="umamusume".
// Jednoduchý styl (data-luncher-theme="simple") zůstává beze změny v App.scss.
// ============================================
$uma: 'html[data-luncher-theme="umamusume"]';
#{$uma} {
--uma-font: 'M PLUS Rounded 1c', 'Nunito', 'Inter', -apple-system, sans-serif;
--uma-turf: #5cc23d;
--uma-turf-deep: #2e9a3a;
--uma-turf-shadow: #1f7a2c;
--uma-pink: #ff6fa3;
--uma-pink-deep: #e84d86;
--uma-sky: #43b8f0;
--uma-gold: #ffc531;
--uma-card-border: #c6ebb2;
--uma-stripe: rgb(92 194 61 / 0.06);
--luncher-primary: var(--uma-turf-deep);
--luncher-primary-hover: #268a31;
--luncher-primary-light: #e7f8dc;
--luncher-bg: #f4fbef;
--luncher-bg-card: #ffffff;
--luncher-bg-hover: #eef8e7;
--luncher-text: #1d3322;
--luncher-text-secondary: #4a6450;
--luncher-text-muted: #8aa290;
--luncher-border: #d7ecca;
--luncher-border-light: #e6f4dc;
--luncher-navbar-bg: var(--uma-turf-deep);
--luncher-navbar-text: #ffffff;
--luncher-action-icon: var(--uma-turf-deep);
--luncher-buyer-icon: #f08c00;
--luncher-success: var(--uma-turf-deep);
--luncher-warning: #f08c00;
--luncher-danger: #e8436a;
--luncher-radius-sm: 10px;
--luncher-radius: 14px;
--luncher-radius-lg: 18px;
--luncher-radius-xl: 22px;
--luncher-shadow: 0 4px 0 rgb(46 154 58 / 0.12), 0 8px 18px -8px rgb(29 51 34 / 0.18);
--luncher-shadow-lg: 0 6px 0 rgb(46 154 58 / 0.14), 0 14px 28px -10px rgb(29 51 34 / 0.25);
&[data-bs-theme="dark"] {
// Noční dostih pod reflektory
--uma-turf: #58c93a;
--uma-turf-deep: #3cae3f;
--uma-turf-shadow: #1c5e25;
--uma-card-border: #2c3a66;
--uma-stripe: rgb(255 255 255 / 0.025);
--luncher-primary: #6fdc4a;
--luncher-primary-hover: #8ae86a;
--luncher-primary-light: #1d3a24;
--luncher-bg: #10172b;
--luncher-bg-card: #19223d;
--luncher-bg-hover: #222d4f;
--luncher-text: #f3f6ff;
--luncher-text-secondary: #b8c2e0;
--luncher-text-muted: #7d89ad;
--luncher-border: #2c3a66;
--luncher-border-light: #232e52;
--luncher-navbar-bg: #1b6b2c;
--luncher-action-icon: #6fdc4a;
--luncher-buyer-icon: var(--uma-gold);
--luncher-success: #6fdc4a;
--luncher-warning: var(--uma-gold);
--luncher-danger: #ff6f93;
--luncher-shadow: 0 4px 0 rgb(0 0 0 / 0.35), 0 10px 20px -8px rgb(0 0 0 / 0.5);
--luncher-shadow-lg: 0 6px 0 rgb(0 0 0 / 0.4), 0 16px 30px -10px rgb(0 0 0 / 0.6);
}
body {
font-family: var(--uma-font);
background-color: var(--luncher-bg);
background-image: repeating-linear-gradient(135deg, var(--uma-stripe) 0 18px, transparent 18px 36px);
background-attachment: fixed;
}
.app-container {
background: transparent;
}
h1, h2, h3, h4, .navbar-brand, .btn {
font-family: var(--uma-font);
}
// ---------- Navbar: zelený pruh se šikmými proužky a zlatým lemem ----------
.navbar {
background:
repeating-linear-gradient(115deg, rgb(255 255 255 / 0.10) 0 14px, transparent 14px 28px),
linear-gradient(90deg, var(--uma-turf-deep), var(--uma-turf)) !important;
border-bottom: 4px solid var(--uma-gold);
box-shadow: 0 3px 0 rgb(0 0 0 / 0.08);
.navbar-brand {
font-weight: 800;
letter-spacing: 0.5px;
text-shadow: 0 2px 0 var(--uma-turf-shadow), 0 0 12px rgb(255 255 255 / 0.25);
&::after {
content: '';
display: inline-block;
width: 10px;
height: 10px;
margin-left: 6px;
border-radius: 50%;
background: var(--uma-pink);
box-shadow: 0 0 0 3px rgb(255 255 255 / 0.9);
vertical-align: super;
}
}
.dropdown-menu {
border: 2px solid var(--uma-card-border);
}
}
// ---------- Titulek dne: šikmá stuha jako název dostihu ----------
.day-navigator .title {
position: relative;
display: inline-block;
padding: 6px 28px;
font-weight: 800;
font-size: 1.7rem;
color: #fff !important;
background: linear-gradient(90deg, var(--uma-pink-deep), var(--uma-pink));
clip-path: polygon(14px 0, 100% 0, calc(100% - 14px) 100%, 0 100%);
text-shadow: 0 2px 0 rgb(0 0 0 / 0.15);
&.text-muted {
background: linear-gradient(90deg, #8aa290, #a9bfae);
}
}
// ---------- Karty podniků ----------
.restaurant-card {
border: 3px solid var(--uma-card-border);
border-radius: var(--luncher-radius-lg);
&:hover {
transform: translateY(-3px);
border-color: var(--uma-turf);
}
.restaurant-header {
position: relative;
background:
repeating-linear-gradient(115deg, rgb(255 255 255 / 0.12) 0 10px, transparent 10px 20px),
linear-gradient(90deg, var(--uma-turf-deep), var(--uma-turf));
border-bottom: 3px solid var(--uma-gold);
&:hover {
background:
repeating-linear-gradient(115deg, rgb(255 255 255 / 0.12) 0 10px, transparent 10px 20px),
linear-gradient(90deg, var(--uma-pink-deep), var(--uma-pink));
}
h3 {
font-weight: 800;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.18);
}
small {
color: rgb(255 255 255 / 0.85);
}
}
}
.food-table tbody tr:hover > td {
background: #fff0f6;
}
&[data-bs-theme="dark"] .food-table tbody tr:hover > td {
background: #2a2344;
}
.food-price {
color: var(--uma-pink-deep);
font-weight: 800;
}
&[data-bs-theme="dark"] .food-price {
color: var(--uma-pink);
}
// ---------- Panely a tabulka voleb ----------
.choice-section,
.choices-table,
.pizza-section,
.no-votes {
border: 3px solid var(--uma-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
}
// <table> s border-collapse: collapse ignoruje border-radius — kulaté rohy jen se „separate"
.choices-table {
border-collapse: separate;
border-spacing: 0;
}
.choices-table > tbody > tr > td:first-child {
background: linear-gradient(90deg, #ffe3ee, #fff3f8);
color: var(--uma-pink-deep);
font-weight: 800;
border-right: 3px solid var(--uma-card-border);
}
&[data-bs-theme="dark"] .choices-table > tbody > tr > td:first-child {
background: linear-gradient(90deg, #3b2446, #2a2140);
color: var(--uma-pink);
}
// Bootstrap karty (skupinové objednávky apod.) a seznamy — stejné zaoblení a lem jako ostatní panely
.card {
border: 3px solid var(--uma-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
overflow: hidden;
background: var(--luncher-bg-card);
// Rozbalovací výběr z nabídky nesmí karta oříznout
&:has(.select-search-container) {
overflow: visible;
}
> .card-header {
background: var(--luncher-primary-light);
border-bottom: 3px solid var(--uma-card-border);
&:first-child {
border-radius: calc(var(--luncher-radius-lg) - 3px) calc(var(--luncher-radius-lg) - 3px) 0 0;
}
}
// Průhledná tabulka — rohy pak zaobluje karta sama (i když nesmí ořezávat obsah)
.table {
--bs-table-bg: transparent;
margin-bottom: 0;
// Spodní linka posledního řádku by přečnívala přes zaoblené rohy
> :last-child > tr:last-child > * {
border-bottom-width: 0;
}
}
}
.list-group {
border-radius: var(--luncher-radius);
overflow: hidden;
border: 2px solid var(--uma-card-border);
> .list-group-item {
border-left: none;
border-right: none;
&:first-child {
border-top: none;
}
&:last-child {
border-bottom: none;
}
}
}
.form-select, .form-control,
input[type="text"], input[type="email"], input[type="password"] {
border-radius: 999px;
border-width: 2px;
}
// ---------- Tlačítka „ze hry": pilulka s vystouplým spodním okrajem ----------
.btn {
border-radius: 999px;
font-weight: 800;
letter-spacing: 0.2px;
transition: transform 0.08s ease, box-shadow 0.08s ease, background 0.2s ease;
&:active {
transform: translateY(3px) !important;
box-shadow: 0 1px 0 var(--uma-btn-shadow, transparent) !important;
}
}
.btn-primary {
--uma-btn-shadow: var(--uma-turf-shadow);
background: linear-gradient(180deg, #7ad85a, var(--uma-turf-deep));
box-shadow: 0 4px 0 var(--uma-turf-shadow);
text-shadow: 0 1px 0 rgb(0 0 0 / 0.2);
&:hover {
background: linear-gradient(180deg, #8be26b, #35a940);
}
&:disabled {
background: #b9cbbd;
box-shadow: 0 4px 0 #97aa9b;
}
}
.btn-outline-primary {
--uma-btn-shadow: var(--uma-card-border);
background: var(--luncher-bg-card);
border: 2px solid var(--uma-turf);
color: var(--luncher-primary);
box-shadow: 0 4px 0 var(--uma-card-border);
&:hover {
background: var(--luncher-primary-light);
color: var(--luncher-primary);
}
}
.btn-secondary {
--uma-btn-shadow: #c9d3cb;
background: var(--luncher-bg-card);
border: 2px solid var(--luncher-border);
box-shadow: 0 4px 0 var(--luncher-border);
}
.btn-danger {
--uma-btn-shadow: #b3264b;
background: linear-gradient(180deg, #ff7f9d, var(--luncher-danger));
box-shadow: 0 4px 0 #b3264b;
}
.action-icon:hover {
background: #ffe3ee;
color: var(--uma-pink-deep);
}
&[data-bs-theme="dark"] .action-icon:hover {
background: #3b2446;
color: var(--uma-pink);
}
// ---------- Modaly ----------
.modal-content {
border: 3px solid var(--uma-card-border);
border-radius: var(--luncher-radius-xl);
overflow: hidden;
}
.modal-header {
background:
repeating-linear-gradient(115deg, rgb(255 255 255 / 0.12) 0 12px, transparent 12px 24px),
linear-gradient(90deg, var(--uma-turf-deep), var(--uma-turf));
border-bottom: 3px solid var(--uma-gold);
.modal-title, .modal-title h2 {
color: #fff;
font-weight: 800;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.18);
}
.btn-close {
filter: invert(1) grayscale(1) brightness(2);
}
}
.footer {
border-top: 4px solid var(--uma-gold);
}
}
+55
View File
@@ -0,0 +1,55 @@
import type { GroupLimits } from "../../../types";
/**
* Výchozí limity skupinových objednávek — stejné jako výchozí hodnoty na serveru
* (server/src/limits.ts). Platí do načtení konfigurace nebo když endpoint neodpoví.
*/
export const DEFAULT_GROUP_LIMITS: GroupLimits = {
maxNoteLength: 300,
maxSurchargeTextLength: 100,
maxNameLength: 100,
maxUrlLength: 500,
maxAmountKc: 10_000,
maxItemQuantity: 10,
maxItems: 20,
};
/** Runtime konfigurace ze serveru (GET /api/config). */
export type AppRuntimeConfig = {
/** Pokus o načtení konfigurace skončil (úspěšně i neúspěšně) */
loaded: boolean,
/** Přihlášení přes důvěryhodné hlavičky z proxy; undefined = nevíme (konfigurace se nenačetla) */
trustedHeaders?: boolean,
/** Správa obchodů je zapnutá (server má admin heslo); undefined = nevíme */
storeAdmin?: boolean,
/** Adresa aplikace Lobby s minihrami; undefined = není nastavena nebo nevíme */
lobbyUrl?: string,
groupLimits: GroupLimits,
};
let config: AppRuntimeConfig = { loaded: false, groupLimits: DEFAULT_GROUP_LIMITS };
const listeners = new Set<() => void>();
export function getAppConfig(): AppRuntimeConfig {
return config;
}
export function getGroupLimits(): GroupLimits {
return config.groupLimits;
}
/** Uloží konfiguraci ze serveru (chybějící limity doplní výchozími) a upozorní odběratele. */
export function setAppConfig(patch: Partial<Omit<AppRuntimeConfig, 'groupLimits'>> & { groupLimits?: Partial<GroupLimits> }): void {
const { groupLimits, ...rest } = patch;
config = {
...config,
...rest,
groupLimits: groupLimits ? { ...DEFAULT_GROUP_LIMITS, ...groupLimits } : config.groupLimits,
};
listeners.forEach(l => l());
}
export function subscribeAppConfig(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Zda se má stisk klávesy ignorovat pro navigaci šipkami mezi dny/týdny na pozadí stránky.
* Navigace se nemá provádět, pokud uživatel píše do formulářového pole, nebo pokud je otevřený
* modální dialog (např. minihra, která klávesnici používá k ovládání) –
* jinak by se stránka na pozadí neustále přepínala a zbytečně přenačítala data ze serveru.
*/
export function isArrowNavigationBlocked(e: KeyboardEvent): boolean {
const target = e.target as HTMLElement | null;
if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return true;
return document.body.classList.contains('modal-open') || document.querySelector('.modal.show') != null;
}
+48
View File
@@ -0,0 +1,48 @@
/** Obrázky stylu Hell Girl (plamen + symbol ohně) — server je cachuje, viz server/src/confetti.ts. */
const HELLGIRL_IMAGES_URL = '/api/confetti/hellgirl';
export type HellGirlImages = { flame: string, symbol: string };
let imagesPromise: Promise<HellGirlImages | undefined> | undefined;
/** Načte URL obrázků (jednou za běh aplikace). Při chybě vrátí undefined a příště to zkusí znovu. */
export const loadHellGirlImages = (): Promise<HellGirlImages | undefined> => {
imagesPromise ??= fetch(HELLGIRL_IMAGES_URL)
.then(r => r.ok ? r.json() : undefined)
.then(images => images?.flame && images?.symbol ? images as HellGirlImages : undefined)
.catch(() => undefined)
.then(images => {
if (!images) imagesPromise = undefined;
return images;
});
return imagesPromise;
};
/**
* Z obrázku symbolu (rudý tvar na bílém pozadí) udělá masku — průhlednost podle toho, jak
* moc pixel není bílý (zelený kanál). Barvu pak symbolu dodá CSS gradientem v paletě stylu.
*/
export const createSymbolMask = (src: string, size = 96): Promise<string | undefined> => new Promise(resolve => {
const img = new Image();
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return resolve(undefined);
ctx.drawImage(img, 0, 0, size, size);
const pixels = ctx.getImageData(0, 0, size, size);
const data = pixels.data;
for (let i = 0; i < data.length; i += 4) {
data[i + 3] = 255 - data[i + 1];
data[i] = data[i + 1] = data[i + 2] = 0;
}
ctx.putImageData(pixels, 0, 0);
resolve(canvas.toDataURL('image/png'));
} catch {
resolve(undefined);
}
};
img.onerror = () => resolve(undefined);
img.src = src;
});
+147
View File
@@ -0,0 +1,147 @@
import type { GroupLimits, StoreMenuDish } from "../../../types";
import { getGroupLimits } from "./appConfig";
/** Normalizace pro porovnání: malá písmena, bez diakritiky, emoji, čísel položek („28." / „13A.") a interpunkce. */
export function normalizeDishName(text: string): string {
return text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/^\s*\*?\s*\d+[a-z]?\s*[.)]\s*/, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
export type MatchedPart = {
/** Původní text části poznámky */
text: string,
quantity: number,
dish?: StoreMenuDish,
};
export type NoteMatch = {
parts: MatchedPart[],
/** Cena v Kč dle nabídky — jen pokud byly nalezeny všechny části a mají cenu */
total?: number,
};
/** Množství zapsané jako „2x burger", „2 × burger" nebo „burger 2x". */
function parseQuantity(text: string): { quantity: number, rest: string } {
const prefix = text.match(/^\s*(\d+)\s*[x×]\s*(.+)$/i);
if (prefix) return { quantity: Number(prefix[1]), rest: prefix[2] };
const suffix = text.match(/^(.+?)\s*[x×]?\s*(\d+)\s*[x×]\s*$/i);
if (suffix) return { quantity: Number(suffix[2]), rest: suffix[1] };
return { quantity: 1, rest: text };
}
/** Najde položku nabídky k textu — přesná shoda, jinak nejbližší položka obsahující text (či naopak). */
function findDish(text: string, dishes: { dish: StoreMenuDish, norm: string }[]): StoreMenuDish | undefined {
const norm = normalizeDishName(text);
if (norm.length < 3) return undefined;
const exact = dishes.find(d => d.norm === norm);
if (exact) return exact.dish;
const candidates = dishes.filter(d => d.norm.length >= 3 && (d.norm.includes(norm) || norm.includes(d.norm)));
// Nejbližší délkou = nejspecifičtější shoda („Kebab" vs. „Kebab box s hranolky")
candidates.sort((a, b) => Math.abs(a.norm.length - norm.length) - Math.abs(b.norm.length - norm.length));
return candidates[0]?.dish;
}
/**
* Rozpozná v poznámce člena objednávky položky z nabídky podniku („Twister speciál + 2x Hranolky")
* a spočítá jejich cenu. Nejprve zkusí celou poznámku (názvy jídel mohou obsahovat „+").
*/
export function matchNoteToMenu(note: string | undefined, menu: StoreMenuDish[]): NoteMatch | undefined {
if (!note?.trim() || menu.length === 0) return undefined;
const dishes = menu.map(dish => ({ dish, norm: normalizeDishName(dish.name) }));
const parts: MatchedPart[] = splitNoteItems(note, menu).map(text => {
const exact = dishes.find(d => d.norm === normalizeDishName(text));
if (exact) return { text, quantity: 1, dish: exact.dish };
const { quantity, rest } = parseQuantity(text);
return { text, quantity, dish: findDish(rest, dishes) };
});
if (!parts.some(p => p.dish)) return undefined;
const complete = parts.every(p => p.dish?.price != null);
return {
parts,
total: complete ? parts.reduce((sum, p) => sum + p.dish!.price! * p.quantity, 0) : undefined,
};
}
/** Počet položek v poznámce objednávky — části oddělené „+", včetně množství („2x Hranolky" = 2). */
export function countNoteItems(note: string | undefined, menu: StoreMenuDish[] = []): number {
return splitNoteItems(note, menu).reduce((sum, part) => sum + parseQuantity(part).quantity, 0);
}
/**
* Části poznámky oddělené „+" (tak, jak je uživatel zapsal). Pokud celá poznámka
* odpovídá jednomu jídlu z nabídky (název může obsahovat „+"), je to jediná položka.
*/
export function splitNoteItems(note: string | undefined, menu: StoreMenuDish[] = []): string[] {
const tokens = (note ?? '').split('+').map(p => p.trim()).filter(Boolean);
if (menu.length === 0 || tokens.length < 2) return tokens;
const names = new Set(menu.map(d => normalizeDishName(d.name)));
const isDish = (text: string) => names.has(normalizeDishName(text)) || names.has(normalizeDishName(parseQuantity(text).rest));
// Sousední části, které dohromady tvoří název jídla s „+" („Stripsy 6 Ks + Coleslaw"), spojíme zpět
const result: string[] = [];
for (let i = 0; i < tokens.length;) {
let j = tokens.length;
while (j > i + 1 && !isDish(tokens.slice(i, j).join(' + '))) j--;
result.push(tokens.slice(i, j).join(' + '));
i = j;
}
return result;
}
/**
* Odebere z poznámky jeden kus položky na daném indexu: „2x Hranolky" → „Hranolky",
* jinak celou část. Vrátí novou poznámku a cenu odebraného kusu dle nabídky (Kč), je-li známa.
*/
export function removeNoteItem(note: string | undefined, index: number, menu: StoreMenuDish[]): { note: string, removedPrice?: number } {
const parts = splitNoteItems(note, menu);
const part = parts[index];
if (part == null) return { note: note ?? '' };
const { quantity, rest } = parseQuantity(part);
const dishes = menu.map(dish => ({ dish, norm: normalizeDishName(dish.name) }));
const dish = dishes.find(d => d.norm === normalizeDishName(part))?.dish ?? findDish(rest, dishes);
if (quantity > 2) parts[index] = `${quantity - 1}x ${rest.trim()}`;
else if (quantity === 2) parts[index] = rest.trim();
else parts.splice(index, 1);
return { note: parts.join(' + '), removedPrice: dish?.price };
}
/**
* Přidá do poznámky jeden kus jídla. Pokud už v poznámce je (i s počtem, např. „2x Hranolky"),
* zvýší jeho počet („Hranolky" → „2x Hranolky"), jinak ho připojí za „+".
*/
export function addNoteItem(note: string | undefined, dishName: string, menu: StoreMenuDish[] = []): string {
const parts = splitNoteItems(note, menu);
const target = normalizeDishName(dishName);
const index = parts.findIndex(p => normalizeDishName(p) === target || normalizeDishName(parseQuantity(p).rest) === target);
if (index < 0) return [...parts, dishName].join(' + ');
const existing = normalizeDishName(parts[index]) === target ? { quantity: 1, rest: parts[index] } : parseQuantity(parts[index]);
parts[index] = `${existing.quantity + 1}x ${existing.rest.trim()}`;
return parts.join(' + ');
}
/** Kolik kusů daného jídla už poznámka obsahuje (0 = není tam). */
export function noteItemQuantity(note: string | undefined, dishName: string, menu: StoreMenuDish[] = []): number {
const target = normalizeDishName(dishName);
const part = splitNoteItems(note, menu).find(p => normalizeDishName(p) === target || normalizeDishName(parseQuantity(p).rest) === target);
if (!part) return 0;
return normalizeDishName(part) === target ? 1 : parseQuantity(part).quantity;
}
/**
* Vrátí důvod, proč jídlo nejde přidat (limit kusů/položek — pojistky proti překlepům,
* hodnoty z konfigurace serveru), jinak undefined.
*/
export function addItemLimitError(note: string | undefined, dishName: string, menu: StoreMenuDish[] = [], limits: GroupLimits = getGroupLimits()): string | undefined {
if (noteItemQuantity(note, dishName, menu) >= limits.maxItemQuantity) {
return `Jedné položky lze přidat nejvýše ${limits.maxItemQuantity} kusů`;
}
if (countNoteItems(note, menu) >= limits.maxItems) {
return `Objednávka může mít nejvýše ${limits.maxItems} položek`;
}
return undefined;
}
+50
View File
@@ -0,0 +1,50 @@
import type { StorePriceHistoryEntry } from "../../../types";
export type SinceStart = {
/** Od kdy se počítá (první známá verze) */
since: string,
changed: { name: string, start: number, current: number }[],
added: { name: string, price?: number }[],
removed: { name: string, price?: number }[],
};
/**
* Souhrn „od začátku": počáteční cena položky = „before" její nejstarší změny,
* aktuální = „after" nejnovější. Položka přidaná a později zase odebraná se vynechá.
*/
export function computeSinceStart(entries: StorePriceHistoryEntry[]): SinceStart | undefined {
if (entries.length === 0) return undefined;
const chronological = [...entries].reverse();
const items = new Map<string, { name: string, start?: number, current?: number, addedLater: boolean, removed: boolean }>();
for (const entry of chronological) {
for (const c of entry.changes) {
const key = c.name.trim().toLowerCase();
let item = items.get(key);
if (!item) {
item = { name: c.name, start: c.before, addedLater: c.before == null, removed: false };
items.set(key, item);
}
item.name = c.name;
item.current = c.after;
item.removed = c.after == null;
}
}
const result: SinceStart = { since: chronological[0].at, changed: [], added: [], removed: [] };
for (const item of items.values()) {
if (item.addedLater && item.removed) continue;
if (item.addedLater) result.added.push({ name: item.name, price: item.current });
else if (item.removed) result.removed.push({ name: item.name, price: item.start });
else if (item.start != null && item.current != null && item.start !== item.current) {
result.changed.push({ name: item.name, start: item.start, current: item.current });
}
}
// Největší změny nahoře
result.changed.sort((a, b) => Math.abs(b.current - b.start) - Math.abs(a.current - a.start));
return result;
}
/** Český plurál: 1 změna, 2–4 změny, 5+ změn. */
export function plural(n: number, one: string, few: string, many: string): string {
const form = n === 1 ? one : n >= 2 && n <= 4 ? few : many;
return `${n} ${form}`;
}
+23
View File
@@ -0,0 +1,23 @@
/** Postavy Umamusume (chibi obrázky / portréty + hláška) — server obrázky cachuje, viz server/src/confetti.ts. */
const UMA_CHARACTERS_URL = '/api/confetti/umamusume';
/** `round` = čtvercový portrét, zobrazí se jako kulatá samolepka (chibi obrázky jsou průhledné). */
export type UmaCharacter = { name: string, quote?: string, round?: boolean, images: string[], /** portrét pro malé odznaky */ icon?: string };
let umaCharactersPromise: Promise<UmaCharacter[]> | undefined;
/**
* Načte seznam postav (jednou za běh aplikace). Při chybě vrátí prázdný seznam
* a výsledek si nepamatuje — příště se zkusí znovu (např. po restartu serveru).
*/
export function loadUmaCharacters(): Promise<UmaCharacter[]> {
umaCharactersPromise ??= fetch(UMA_CHARACTERS_URL)
.then(r => r.ok ? r.json() : [])
.then(list => Array.isArray(list) ? list.filter((c: UmaCharacter) => Array.isArray(c?.images)) : [])
.catch(() => [])
.then((list: UmaCharacter[]) => {
if (list.length === 0) umaCharactersPromise = undefined;
return list;
});
return umaCharactersPromise;
}
+160 -156
View File
@@ -518,9 +518,9 @@
"@fortawesome/fontawesome-common-types" "7.3.1" "@fortawesome/fontawesome-common-types" "7.3.1"
"@fortawesome/react-fontawesome@^3.1.0": "@fortawesome/react-fontawesome@^3.1.0":
version "3.4.0" version "3.5.0"
resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-3.4.0.tgz#17b989390349ded9214ffb35ad279da7c9a728ba" resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-3.5.0.tgz#ce8152e556809326e1253682088bfdd116a453bd"
integrity sha512-EgY5CXzzm6WGnUB40j9qNajYmoJHC8TUV/rvpcmHUoPZmQyRlh8WuRaxaerjMq61NZAz0vS+MOoJaPbZWRDibw== integrity sha512-63mlRr6fiBbJ0wjr1Cf6dsDGtP2lNvk9lnatKgxs/fIkhslsZT291hIUzJkuUkI9yr69ZvWnWfgb2qXm4QyVaA==
"@jest/diff-sequences@30.0.1": "@jest/diff-sequences@30.0.1":
version "30.0.1" version "30.0.1"
@@ -609,94 +609,88 @@
"@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14" "@jridgewell/sourcemap-codec" "^1.4.14"
"@parcel/watcher-android-arm64@2.5.6": "@parcel/watcher-android-arm64@2.6.0":
version "2.5.6" version "2.6.0"
resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz#5f32e0dba356f4ac9a11068d2a5c134ca3ba6564" resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz#99aaa3223d43807c9340af439cad7e9b6d26ada6"
integrity sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A== integrity sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==
"@parcel/watcher-darwin-arm64@2.5.6": "@parcel/watcher-darwin-arm64@2.6.0":
version "2.5.6" version "2.6.0"
resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz#88d3e720b59b1eceffce98dac46d7c40e8be5e8e" resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz#024496e586b4744f09ce532bbe89fe38ef02a64e"
integrity sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA== integrity sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==
"@parcel/watcher-darwin-x64@2.5.6": "@parcel/watcher-darwin-x64@2.6.0":
version "2.5.6" version "2.6.0"
resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz#bf05d76a78bc15974f15ec3671848698b0838063" resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz#a4621df1359a93d39a332d9bab5ff09016a0608f"
integrity sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg== integrity sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==
"@parcel/watcher-freebsd-x64@2.5.6": "@parcel/watcher-freebsd-x64@2.6.0":
version "2.5.6" version "2.6.0"
resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz#8bc26e9848e7303ac82922a5ae1b1ef1bdb48a53" resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz#7f565ed1a5b3a5e604e6a4799121518265d62a3d"
integrity sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng== integrity sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==
"@parcel/watcher-linux-arm-glibc@2.5.6": "@parcel/watcher-linux-arm-glibc@2.6.0":
version "2.5.6" version "2.6.0"
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz#1328fee1deb0c2d7865079ef53a2ba4cc2f8b40a" resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz#ad7d3825e67b81999165da42593022045abc0889"
integrity sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ== integrity sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==
"@parcel/watcher-linux-arm-musl@2.5.6": "@parcel/watcher-linux-arm-musl@2.6.0":
version "2.5.6" version "2.6.0"
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz#bad0f45cb3e2157746db8b9d22db6a125711f152" resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz#fe7d1cccb2c483215c090e938cf5cf404d2f9a8c"
integrity sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg== integrity sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==
"@parcel/watcher-linux-arm64-glibc@2.5.6": "@parcel/watcher-linux-arm64-glibc@2.6.0":
version "2.5.6" version "2.6.0"
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz#b75913fbd501d9523c5f35d420957bf7d0204809" resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz#7e239dcb4646c4c79f006a7131a48238249530da"
integrity sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA== integrity sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==
"@parcel/watcher-linux-arm64-musl@2.5.6": "@parcel/watcher-linux-arm64-musl@2.6.0":
version "2.5.6" version "2.6.0"
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz#da5621a6a576070c8c0de60dea8b46dc9c3827d4" resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz#c58b8d9c6d8d81594be00dd83aab741c1aaf7e0e"
integrity sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA== integrity sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==
"@parcel/watcher-linux-x64-glibc@2.5.6": "@parcel/watcher-linux-x64-glibc@2.6.0":
version "2.5.6" version "2.6.0"
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz#ce437accdc4b30f93a090b4a221fd95cd9b89639" resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz#5184fa9a770478d86e56875f4ee163a0abdc8791"
integrity sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ== integrity sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==
"@parcel/watcher-linux-x64-musl@2.5.6": "@parcel/watcher-linux-x64-musl@2.6.0":
version "2.5.6" version "2.6.0"
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz#02400c54b4a67efcc7e2327b249711920ac969e2" resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz#2d1c55aa7246cbc7670e2612058a8a542c9cf246"
integrity sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg== integrity sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==
"@parcel/watcher-win32-arm64@2.5.6": "@parcel/watcher-win32-arm64@2.6.0":
version "2.5.6" version "2.6.0"
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz#caae3d3c7583ca0a7171e6bd142c34d20ea1691e" resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz#15e09432040fee9e2213aa9c10ed589012526def"
integrity sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q== integrity sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==
"@parcel/watcher-win32-ia32@2.5.6": "@parcel/watcher-win32-x64@2.6.0":
version "2.5.6" version "2.6.0"
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz#9ac922550896dfe47bfc5ae3be4f1bcaf8155d6d" resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz#9bee199a2a4accd557b451ac2c1c793f305ae012"
integrity sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g== integrity sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==
"@parcel/watcher-win32-x64@2.5.6":
version "2.5.6"
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz#73fdafba2e21c448f0e456bbe13178d8fe11739d"
integrity sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==
"@parcel/watcher@^2.4.1": "@parcel/watcher@^2.4.1":
version "2.5.6" version "2.6.0"
resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.5.6.tgz#3f932828c894f06d0ad9cfefade1756ecc6ef1f1" resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.6.0.tgz#99661f6220070b76a766aba6b7e313a087a1be4f"
integrity sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ== integrity sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==
dependencies: dependencies:
detect-libc "^2.0.3" detect-libc "^2.0.3"
is-glob "^4.0.3" is-glob "^4.0.3"
node-addon-api "^7.0.0" node-addon-api "^7.0.0"
picomatch "^4.0.3" picomatch "^4.0.4"
optionalDependencies: optionalDependencies:
"@parcel/watcher-android-arm64" "2.5.6" "@parcel/watcher-android-arm64" "2.6.0"
"@parcel/watcher-darwin-arm64" "2.5.6" "@parcel/watcher-darwin-arm64" "2.6.0"
"@parcel/watcher-darwin-x64" "2.5.6" "@parcel/watcher-darwin-x64" "2.6.0"
"@parcel/watcher-freebsd-x64" "2.5.6" "@parcel/watcher-freebsd-x64" "2.6.0"
"@parcel/watcher-linux-arm-glibc" "2.5.6" "@parcel/watcher-linux-arm-glibc" "2.6.0"
"@parcel/watcher-linux-arm-musl" "2.5.6" "@parcel/watcher-linux-arm-musl" "2.6.0"
"@parcel/watcher-linux-arm64-glibc" "2.5.6" "@parcel/watcher-linux-arm64-glibc" "2.6.0"
"@parcel/watcher-linux-arm64-musl" "2.5.6" "@parcel/watcher-linux-arm64-musl" "2.6.0"
"@parcel/watcher-linux-x64-glibc" "2.5.6" "@parcel/watcher-linux-x64-glibc" "2.6.0"
"@parcel/watcher-linux-x64-musl" "2.5.6" "@parcel/watcher-linux-x64-musl" "2.6.0"
"@parcel/watcher-win32-arm64" "2.5.6" "@parcel/watcher-win32-arm64" "2.6.0"
"@parcel/watcher-win32-ia32" "2.5.6" "@parcel/watcher-win32-x64" "2.6.0"
"@parcel/watcher-win32-x64" "2.5.6"
"@popperjs/core@^2.11.8": "@popperjs/core@^2.11.8":
version "2.11.8" version "2.11.8"
@@ -881,69 +875,69 @@
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz#3671ce3f9b928d5c01f879792d5c0b60ae14d4ad" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz#3671ce3f9b928d5c01f879792d5c0b60ae14d4ad"
integrity sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA== integrity sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==
"@sentry/browser-utils@10.66.0": "@sentry/browser-utils@10.73.0":
version "10.66.0" version "10.73.0"
resolved "https://registry.yarnpkg.com/@sentry/browser-utils/-/browser-utils-10.66.0.tgz#6f617bd14c1a4f46b27b598ddcbfec7176ca4cb6" resolved "https://registry.yarnpkg.com/@sentry/browser-utils/-/browser-utils-10.73.0.tgz#7349f5414f1663c005cca69db48cef1d63b3b1e2"
integrity sha512-sHuALvJMMEilUz84F1ZOQuDoZ3MhjwUHWHkXcElcVMrDIlnTO7Ra0E6Kh0e8JyJaLBMk4Sy9srKSqH9zimQVTQ== integrity sha512-qQygxJZ+RV779+iL1+lrJ4f4sZLgbgW0/JWPNp0YlcEAE62yCsdKbqoTEjB/EugdS4mSjBMX0chZC6rblu2Ycw==
dependencies: dependencies:
"@sentry/conventions" "^0.16.0" "@sentry/conventions" "^0.16.0"
"@sentry/core" "10.66.0" "@sentry/core" "10.73.0"
"@sentry/browser@10.66.0": "@sentry/browser@10.73.0":
version "10.66.0" version "10.73.0"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.66.0.tgz#62663d6a552e11ade2e2e86754cf1f95dadc5790" resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.73.0.tgz#22c3636dd170479a58c9c81507bab72f88e94233"
integrity sha512-MaPoBqKvI7O3UhexSJ/KO/o6T4Tr3A+vQWLZYTos0mxd59jVKMKbbJ6LxM/0uWQ5JAO2XYC/kCMRvk6VqQ5QZw== integrity sha512-HqTe1S5RrWLufhX2LaFP3yNoMxfNDroh120bq1zdGHZfFDBMJQ0CDXxHO+L4UJfQ5dWdCCzWbXIAiZuWGa/DFQ==
dependencies: dependencies:
"@sentry/browser-utils" "10.66.0" "@sentry/browser-utils" "10.73.0"
"@sentry/conventions" "^0.16.0" "@sentry/conventions" "^0.16.0"
"@sentry/core" "10.66.0" "@sentry/core" "10.73.0"
"@sentry/feedback" "10.66.0" "@sentry/feedback" "10.73.0"
"@sentry/replay" "10.66.0" "@sentry/replay" "10.73.0"
"@sentry/replay-canvas" "10.66.0" "@sentry/replay-canvas" "10.73.0"
"@sentry/conventions@^0.16.0": "@sentry/conventions@^0.16.0":
version "0.16.0" version "0.16.0"
resolved "https://registry.yarnpkg.com/@sentry/conventions/-/conventions-0.16.0.tgz#3b58d15714cf44dca1518496c00749eec5525009" resolved "https://registry.yarnpkg.com/@sentry/conventions/-/conventions-0.16.0.tgz#3b58d15714cf44dca1518496c00749eec5525009"
integrity sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ== integrity sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==
"@sentry/core@10.66.0": "@sentry/core@10.73.0":
version "10.66.0" version "10.73.0"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.66.0.tgz#5473d3129b3ce26454ffb70f2e8a9af904ad0c30" resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.73.0.tgz#464593d1a2cec6655da681911a2d60d26033e4ab"
integrity sha512-9UbgSvds7bMJsP561eWmeyMLcfOmnwxtnx2QuW3yLobzP2Ob7CyJCOzP4tGzlTAGDrzShkFEZhiyuBUKiEK2oQ== integrity sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==
dependencies: dependencies:
"@sentry/conventions" "^0.16.0" "@sentry/conventions" "^0.16.0"
"@sentry/feedback@10.66.0": "@sentry/feedback@10.73.0":
version "10.66.0" version "10.73.0"
resolved "https://registry.yarnpkg.com/@sentry/feedback/-/feedback-10.66.0.tgz#d9a388e470f4657f97bfb03c8c71421a9625c2b5" resolved "https://registry.yarnpkg.com/@sentry/feedback/-/feedback-10.73.0.tgz#e086f789cf7f1f671d6a1ef08e80b2ef6db655a4"
integrity sha512-fr69K76Gz7RRyfMerChdNjWIeQdFh+k21GJcmPCqk43dscUChOsLc3cYLS5cK7iZ/XiUmH9lVzf7EYL2kf2qsA== integrity sha512-D6nSngX+e46Mae2/oh2bxBvxNK1z2NERbuMAhB5sx9x4xMBWIyGnYYTECehvEqV9+AqGAgxxhOZoYIG3AmRwww==
dependencies: dependencies:
"@sentry/core" "10.66.0" "@sentry/core" "10.73.0"
"@sentry/react@^10.65.0": "@sentry/react@^10.65.0":
version "10.66.0" version "10.73.0"
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-10.66.0.tgz#167c6e2496b127c1e3aac6526a4b3f519931f8b6" resolved "https://registry.yarnpkg.com/@sentry/react/-/react-10.73.0.tgz#03dbb3fb70ea15dd0c7161d307afca37ab24c7da"
integrity sha512-aodV9C2NnaZBqJvaBceIIaVyMVEisO38EWtH5xL7IMbD6QSmBYsuNU7yYzRcdOj/+99FxJYRPaqwzGnIWnNTHw== integrity sha512-wJrzS98ddPvhGS/MKNHZyE8X7ecd4KwKdz7fHino2qkqBBrF4cxWr+q/uLTIk5PYWMkeJ4oKMjHAjOqmzGR4tg==
dependencies: dependencies:
"@sentry/browser" "10.66.0" "@sentry/browser" "10.73.0"
"@sentry/conventions" "^0.16.0" "@sentry/conventions" "^0.16.0"
"@sentry/core" "10.66.0" "@sentry/core" "10.73.0"
"@sentry/replay-canvas@10.66.0": "@sentry/replay-canvas@10.73.0":
version "10.66.0" version "10.73.0"
resolved "https://registry.yarnpkg.com/@sentry/replay-canvas/-/replay-canvas-10.66.0.tgz#f04f1ae17f195d92a32a25491d2cb86c25ae25a0" resolved "https://registry.yarnpkg.com/@sentry/replay-canvas/-/replay-canvas-10.73.0.tgz#32fe5a7120d05c97fd9828ac15f09d37e1848f76"
integrity sha512-z7fPWEIfAJMJySKXFIqXAzFpkHjp13WGMAmB9+hlHhi9+gjAO8owD9R4XTd86lBswveGwMHKvIx5lFKNVqDcow== integrity sha512-sxa2lKkHPfF/j5xFpW7gocthWXRqyoHz8KCPy6yGc8plT477nl57iGSKhQDYsx1Ny10TLjs7YkIuPP1GY/Ax2Q==
dependencies: dependencies:
"@sentry/core" "10.66.0" "@sentry/core" "10.73.0"
"@sentry/replay" "10.66.0" "@sentry/replay" "10.73.0"
"@sentry/replay@10.66.0": "@sentry/replay@10.73.0":
version "10.66.0" version "10.73.0"
resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-10.66.0.tgz#9ef6eb7ef47470f1f738f3843099ff9d1095db91" resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-10.73.0.tgz#25d53335e3dee9324c0b17511506ea4be17ac6dd"
integrity sha512-Djb4FxQa9bF8z3PoCO00Nw1u+6hma2YuI8JkMoE+F14KWRfCZKQ28sScsvk+OokbXgjtuIsxDEZj/qFGY0w01w== integrity sha512-nN2wjN/Y0J5BOJV5hqRHUEBfxwUsipp1PKjcDHh6Fpxnrtfldu3Y99E8cQInseo5heFdzEvrOHBBrqWZXOHVKQ==
dependencies: dependencies:
"@sentry/browser-utils" "10.66.0" "@sentry/browser-utils" "10.73.0"
"@sentry/core" "10.66.0" "@sentry/core" "10.73.0"
"@sinclair/typebox@^0.34.0": "@sinclair/typebox@^0.34.0":
version "0.34.41" version "0.34.41"
@@ -1040,9 +1034,9 @@
"@types/d3-time" "*" "@types/d3-time" "*"
"@types/d3-shape@^3.1.0": "@types/d3-shape@^3.1.0":
version "3.1.8" version "3.2.0"
resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.8.tgz#d1516cc508753be06852cd06758e3bb54a22b0e3" resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.2.0.tgz#66ff342011dc243c6c20e6b899523d148aca412d"
integrity sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w== integrity sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==
dependencies: dependencies:
"@types/d3-path" "*" "@types/d3-path" "*"
@@ -1108,9 +1102,9 @@
integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==
"@types/react-dom@^19.2.2": "@types/react-dom@^19.2.2":
version "19.2.3" version "19.2.7"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.7.tgz#eba78fc6201e564986ec40d56c86c8434593cace"
integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== integrity sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==
"@types/react-transition-group@^4.4.6": "@types/react-transition-group@^4.4.6":
version "4.4.12" version "4.4.12"
@@ -1125,9 +1119,9 @@
csstype "^3.0.2" csstype "^3.0.2"
"@types/react@^19.2.2": "@types/react@^19.2.2":
version "19.2.17" version "19.2.18"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.17.tgz#dccac365baa0f1734ec270ff4b51c89465e8dc7f" resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.18.tgz#eb0b6a1fb635d1a9692d5f84a3495bd8ad153707"
integrity sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw== integrity sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==
dependencies: dependencies:
csstype "^3.2.2" csstype "^3.2.2"
@@ -1407,9 +1401,9 @@ engine.io-parser@~5.2.1:
integrity sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q== integrity sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==
es-toolkit@^1.39.3: es-toolkit@^1.39.3:
version "1.49.0" version "1.52.0"
resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.49.0.tgz#93c5b031865792fc03cbf5bd20c132a4f976a52a" resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.52.0.tgz#71eaf1a8b18834ef77637eccbb885ba4c03cd6dd"
integrity sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g== integrity sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==
"esbuild@^0.27.0 || ^0.28.0": "esbuild@^0.27.0 || ^0.28.0":
version "0.28.1" version "0.28.1"
@@ -1518,9 +1512,9 @@ has-flag@^4.0.0:
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
immer@^11.0.0, immer@^11.1.8: immer@^11.0.0, immer@^11.1.8:
version "11.1.11" version "11.1.18"
resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.11.tgz#bbf825a333ae1b16fd450d8da5f61d54de6a553d" resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.18.tgz#87d9bced1e25157dc23bced66811de89460ec8f3"
integrity sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw== integrity sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==
immutable@^5.1.5: immutable@^5.1.5:
version "5.1.9" version "5.1.9"
@@ -1694,7 +1688,12 @@ picomatch@^4.0.2:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
picomatch@^4.0.3, picomatch@^4.0.4: picomatch@^4.0.3:
version "4.0.7"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.7.tgz#6313360034ccb36b3dc61ecbdff78121f90fe21f"
integrity sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==
picomatch@^4.0.4:
version "4.0.5" version "4.0.5"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab"
integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==
@@ -1709,9 +1708,9 @@ postcss@^8.5.6:
source-map-js "^1.2.1" source-map-js "^1.2.1"
prettier@^3.6.2: prettier@^3.6.2:
version "3.9.5" version "3.9.6"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.5.tgz#4fec97736e33b9d0b620b48914fe93b530e835ad" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.6.tgz#b3ea5146515d40fc53f18aa63f74dfab1e10dbf6"
integrity sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg== integrity sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==
pretty-format@30.2.0, pretty-format@^30.0.0: pretty-format@30.2.0, pretty-format@^30.0.0:
version "30.2.0" version "30.2.0"
@@ -1768,9 +1767,9 @@ react-datepicker@^9.1.0:
date-fns "^4.1.0" date-fns "^4.1.0"
react-dom@^19.2.0: react-dom@^19.2.0:
version "19.2.7" version "19.2.8"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.7.tgz#0450dc9ae9ddbff76ef196401cd8b8c7fb466ccc" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.8.tgz#3b46b9eeda877cdff2cf13d2770fff4ae36c2ec2"
integrity sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ== integrity sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==
dependencies: dependencies:
scheduler "^0.27.0" scheduler "^0.27.0"
@@ -1825,16 +1824,16 @@ react-refresh@^0.18.0:
integrity sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw== integrity sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==
react-router-dom@^7.9.5: react-router-dom@^7.9.5:
version "7.18.1" version "7.18.3"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.18.1.tgz#0d1b138e291393059ad481c3e10e366385a978a4" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.18.3.tgz#0688c91b4b376c8b0dcaf0a0064975d52a32e0f7"
integrity sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg== integrity sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==
dependencies: dependencies:
react-router "7.18.1" react-router "7.18.3"
react-router@7.18.1, react-router@^7.9.5: react-router@7.18.3, react-router@^7.9.5:
version "7.18.1" version "7.18.3"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.18.1.tgz#61259d1594b95c1ace299ee4c57453570f0c22f1" resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.18.3.tgz#2a3257aa7c5edd5a71f878063e4c7f3fcfc4b76a"
integrity sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg== integrity sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==
dependencies: dependencies:
cookie "^1.0.1" cookie "^1.0.1"
set-cookie-parser "^2.6.0" set-cookie-parser "^2.6.0"
@@ -1874,19 +1873,19 @@ react-transition-group@^4.4.5:
prop-types "^15.6.2" prop-types "^15.6.2"
react@^19.2.0: react@^19.2.0:
version "19.2.7" version "19.2.8"
resolved "https://registry.yarnpkg.com/react/-/react-19.2.7.tgz#1f47a1bfc06f8ec885752c6f4af14369a9f8260b" resolved "https://registry.yarnpkg.com/react/-/react-19.2.8.tgz#a80663dbb58d69c6fe3fd291d3cb324e8a7dff2d"
integrity sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ== integrity sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==
readdirp@^5.0.0: readdirp@^5.0.0:
version "5.0.0" version "5.1.1"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-5.0.0.tgz#fbf1f71a727891d685bb1786f9ba74084f6e2f91" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-5.1.1.tgz#520bca06f9d1ae1b96cc0800dbe84b983d19422c"
integrity sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ== integrity sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==
recharts@^3.4.1: recharts@^3.4.1:
version "3.9.2" version "3.10.1"
resolved "https://registry.yarnpkg.com/recharts/-/recharts-3.9.2.tgz#ccdadd197335102a91bd64d51fafc6c9a5b69ebb" resolved "https://registry.yarnpkg.com/recharts/-/recharts-3.10.1.tgz#e6f52c2604a38f728b6d47f95881595fa6604c6c"
integrity sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw== integrity sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==
dependencies: dependencies:
"@reduxjs/toolkit" "^1.9.0 || 2.x.x" "@reduxjs/toolkit" "^1.9.0 || 2.x.x"
clsx "^2.1.1" clsx "^2.1.1"
@@ -1915,11 +1914,16 @@ regenerator-runtime@^0.14.0:
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f"
integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==
reselect@5.2.0, reselect@^5.1.0: reselect@5.2.0:
version "5.2.0" version "5.2.0"
resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.2.0.tgz#f380ef7664332d26ea06c1cba04bdbbdcaa955f1" resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.2.0.tgz#f380ef7664332d26ea06c1cba04bdbbdcaa955f1"
integrity sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw== integrity sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==
reselect@^5.1.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.3.0.tgz#0a3e3ed4436bdf2ab7c5e0f392dab2c062595d61"
integrity sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==
rollup@^4.43.0: rollup@^4.43.0:
version "4.62.2" version "4.62.2"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.62.2.tgz#d90fc4cb811f071303c890b779595634f35f9541" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.62.2.tgz#d90fc4cb811f071303c890b779595634f35f9541"
@@ -1955,9 +1959,9 @@ rollup@^4.43.0:
fsevents "~2.3.2" fsevents "~2.3.2"
sass@^1.93.3: sass@^1.93.3:
version "1.101.0" version "1.104.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.101.0.tgz#c2db5bbf2f956be7277f6223b899d0d4be3c899b" resolved "https://registry.yarnpkg.com/sass/-/sass-1.104.0.tgz#40a6dfc42f8cc469f7f2c10b40c6a0b8577895ac"
integrity sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw== integrity sha512-btHMApW2bgolvClhRW8AlQJzgI9lUB3pPSofBQQT+E46GWvf9o0TVQ13SYv5riWZVFyPN+JNz3TKW9XhBlc10w==
dependencies: dependencies:
chokidar "^5.0.0" chokidar "^5.0.0"
immutable "^5.1.5" immutable "^5.1.5"
+15 -20
View File
@@ -1,36 +1,31 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
// Tento test záměrně NEPOUŽÍVÁ trusted-header – testuje reálný login formulář. // Tento soubor záměrně NEPOUŽÍVÁ výchozí trusted-header — testuje chování přihlašovací stránky.
test.use({ extraHTTPHeaders: {} }); test.use({ extraHTTPHeaders: {} });
test('uživatel se přihlásí formulářem a uvidí obsah aplikace', async ({ page }) => { test('při přihlášení přes proxy se formulář nezobrazí — bez hlavičky hláška, opakování s hlavičkou přihlásí', async ({ page }) => {
// Server běží s HTTP_REMOTE_USER_ENABLED=true, takže POST /api/login vždy vyžaduje // Server běží s HTTP_REMOTE_USER_ENABLED=true (přihlašuje proxy), takže formulář se jménem
// hlavičku remote-user. Zachytíme požadavky z formuláře (mají tělo s polem login) // nemá smysl. Bez hlavičky remote-user se zobrazí hláška s tlačítkem „Zkusit znovu".
// a přidáme hlavičku; požadavek auto-loginu (bez těla) projde bez hlavičky a selže, let sendHeader = false;
// čímž formulář zůstane viditelný.
await page.route('**/api/login', async (route) => { await page.route('**/api/login', async (route) => {
const body = route.request().postData();
let login: string | undefined;
try { login = body ? JSON.parse(body)?.login : undefined; } catch {}
await route.continue({ await route.continue({
headers: login headers: sendHeader
? { ...route.request().headers(), 'remote-user': login } ? { ...route.request().headers(), 'remote-user': 'testuser' }
: route.request().headers(), : route.request().headers(),
}); });
}); });
await page.goto('/'); await page.goto('/');
// Formulář musí být viditelný – auto-login selhal (nepřišla hlavička) const retry = page.getByRole('button', { name: 'Zkusit znovu' });
const loginInput = page.locator('#login-input'); await expect(retry).toBeVisible({ timeout: 10_000 });
await expect(loginInput).toBeVisible({ timeout: 10_000 }); await expect(page.getByText('Přihlášení probíhá přes firemní přihlášení (proxy)')).toBeVisible();
await expect(page.locator('#login-input')).toHaveCount(0);
// Vyplnění loginu a odeslání Enterem // Proxy teď identitu pošle — opakování přihlásí
await loginInput.fill('testuser'); sendHeader = true;
await loginInput.press('Enter'); await retry.click();
await expect(retry).not.toBeVisible({ timeout: 10_000 });
// Po přihlášení musí zmizet login formulář
await expect(loginInput).not.toBeVisible({ timeout: 10_000 });
// JWT musí být uloženo v localStorage jako 3-dílný token // JWT musí být uloženo v localStorage jako 3-dílný token
const token = await page.evaluate(() => localStorage.getItem('token')); const token = await page.evaluate(() => localStorage.getItem('token'));
+2 -2
View File
@@ -12,10 +12,10 @@ test('zobrazí mock datum 10.01.2025', async ({ page }) => {
await expect(page.locator('text=10.01')).toBeVisible({ timeout: 10_000 }); await expect(page.locator('text=10.01')).toBeVisible({ timeout: 10_000 });
}); });
test('zobrazí čtyři restaurační karty z mock dat', async ({ page }) => { test('zobrazí tři restaurační karty z mock dat', async ({ page }) => {
// Každá restaurace je obalena v .restaurant-card // Každá restaurace je obalena v .restaurant-card
const cards = page.locator('.restaurant-card'); const cards = page.locator('.restaurant-card');
await expect(cards).toHaveCount(4, { timeout: 10_000 }); await expect(cards).toHaveCount(3, { timeout: 10_000 });
}); });
test('zobrazí alespoň jedno jídlo v menu každé restaurace', async ({ page }) => { test('zobrazí alespoň jedno jídlo v menu každé restaurace', async ({ page }) => {
+1
View File
@@ -10,3 +10,4 @@ data:
REDIS_PORT: "6379" REDIS_PORT: "6379"
PORT: "3001" PORT: "3001"
HOST: "0.0.0.0" HOST: "0.0.0.0"
LOBBY_URL: "http://lobby.localhost"
+24 -1
View File
@@ -33,7 +33,10 @@
# V případě zapnutí je nutno vyplnit také HTTP_REMOTE_TRUSTED_IPS. # V případě zapnutí je nutno vyplnit také HTTP_REMOTE_TRUSTED_IPS.
# HTTP_REMOTE_USER_ENABLED=true # HTTP_REMOTE_USER_ENABLED=true
# Seznam IP adres nebo rozsahů oddělených čárkou, ze kterých budou akceptovány důvěryhodné hlavičky. # Seznam IP adres nebo rozsahů (CIDR, IPv4 i IPv6) oddělených čárkou, ze kterých budou akceptovány důvěryhodné hlavičky.
# Rozhoduje skutečná adresa spojení (tj. proxy před aplikací, ne klient), hlavička od jiné adresy se ignoruje.
# Uveďte co nejužší rozsah — jen adresu/síť proxy (v Kubernetes síť podů, např. 10.42.0.0/16 u RKE2), ne celé privátní rozsahy.
# Neplatná položka = server nenaběhne.
# HTTP_REMOTE_TRUSTED_IPS=127.0.0.1,192.168.1.0/24 # HTTP_REMOTE_TRUSTED_IPS=127.0.0.1,192.168.1.0/24
# Název důvěryhodné hlavičky obsahující login uživatele. Výchozí hodnota je 'remote-user'. # Název důvěryhodné hlavičky obsahující login uživatele. Výchozí hodnota je 'remote-user'.
@@ -49,6 +52,10 @@
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu). # Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
# REFRESH_BYPASS_PASSWORD= # REFRESH_BYPASS_PASSWORD=
# Adresa aplikace Lobby s minihrami. Skrytý odkaz (3× kliknutí na logo) ji otevře v nové záložce.
# Bez vyplnění 3× kliknutí jen čeká na kód hry, která zůstala v Luncheru (kreslení).
# LOBBY_URL='https://lobby.example.com'
# Admin heslo pro správu seznamu obchodů na stránce /objednani. # Admin heslo pro správu seznamu obchodů na stránce /objednani.
# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403). # Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403).
# ADMIN_PASSWORD= # ADMIN_PASSWORD=
@@ -64,3 +71,19 @@
# SENTRY_CLIENT_DSN=https://...@sentry.example.com/2 # SENTRY_CLIENT_DSN=https://...@sentry.example.com/2
# Interní logy Sentry SDK do konzole — pro ladění, když eventy nedorazí do Sentry. # Interní logy Sentry SDK do konzole — pro ladění, když eventy nedorazí do Sentry.
# SENTRY_DEBUG=true # SENTRY_DEBUG=true
# Limity skupinových objednávek (pojistky proti překlepům, ne ochrana proti zlému úmyslu).
# Nevyplněné nebo neplatné hodnoty (ne celé kladné číslo) = výchozí hodnota. Klient je dostává přes GET /api/config.
# Max. délka poznámky člena (co si objednává), výchozí 300 znaků.
# GROUP_MAX_NOTE_LENGTH=300
# Max. délka popisu příplatku, výchozí 100 znaků.
# GROUP_MAX_SURCHARGE_TEXT_LENGTH=100
# Max. délka názvu skupiny, výchozí 100 znaků.
# GROUP_MAX_NAME_LENGTH=100
# Max. délka URL nabídky / odkazu na sledování, výchozí 500 znaků.
# GROUP_MAX_URL_LENGTH=500
# Max. výše jedné částky (útrata člena, příplatek, poplatky, doprava, spropitné, pevná sleva) v Kč, výchozí 10000.
# GROUP_MAX_AMOUNT_KC=10000
# Výběr z nabídky: max. kusů jedné položky (výchozí 10) a max. položek celkem (výchozí 20).
# GROUP_MAX_ITEM_QUANTITY=10
# GROUP_MAX_ITEMS=20
+5
View File
@@ -0,0 +1,5 @@
[
"Kolo štěstí pro nerozhodnuté — vylosuje podnik (dnešní menu i rozvoz) a pak konkrétní jídlo z jeho nabídky (u rozvozu staženou z Bolt Food/Wolt), možnosti lze z kola vyřazovat",
"Objednání: nový sloupec „Z nabídky\" — výběr jídla z aktuální nabídky podniku (doplní poznámku i cenu) a kontrola ceny podle nabídky",
"Objednání → Obchody: historie cen nabídek podniků — kdy a jak se změnily ceny, nové a odebrané položky"
]
+4
View File
@@ -0,0 +1,4 @@
[
"Opraveno načítání jídelního lístku TechTower po změně jejich webu",
"Šenk Šeříková je dočasně skryt, protože je podnik uzavřen"
]
+4
View File
@@ -0,0 +1,4 @@
[
"Možnost v nastavení úplně vypnout poletující motýlky",
"Přidány minihry 😉"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Export stravování už neobsahuje objednávky, ve kterých jste si nic neobjednali a nemáte k nim poznámku"
]
+4
View File
@@ -0,0 +1,4 @@
[
"Nové nastavení: po založení skupinové objednávky nebo připojení k ní se vaše dnešní volba automaticky přepne na \"Budu objednávat\"",
"Ovládání miniher šipkami už na pozadí nepřepíná dny ani týdny"
]
+6
View File
@@ -0,0 +1,6 @@
[
"Tetris: kostky se otáčí proti směru hodinových ručiček kolem svého středu, tyčka a S/Z kostky už jen přepínají mezi vodorovnou a svislou polohou",
"Tetris: nová kostka se objeví nad hrací plochou, takže ji jde otočit dřív, než do plochy vjede",
"Minihry jdou hrát i na mobilu a tabletu 📱 — Tetris, had i Pong mají dotykové ovládání a okna her se na telefonu roztáhnou přes celou obrazovku",
"Přibyla další minihra — tentokrát se bude střílet 😉"
]
+7
View File
@@ -0,0 +1,7 @@
[
"U oběda v restauraci si můžete zadat cenu (ikonka cenovky u vaší volby) — promítne se do měsíčního přehledu stravování a předvyplní se i v „Zaplatit za všechny“",
"V lobby miniher (Pong, Reakční hra, Had, Tetris, Tanky) si hráči mohou psát v chatu, emotikony se vkládají jedním kliknutím 😉",
"Tanky mají nově výchozí směrové ovládání — šipka nebo WSAD určuje směr jízdy na mapě; původní tankové ovládání (vpřed/vzad a otáčení) lze zapnout volbou Ovládání",
"Tanky: větší mapy s více překážkami, vlastní tank je výrazně označený (při startu a po návratu do hry na něj ukazuje šipka) a pod hrací plochou je legenda bonusů",
"Minihry se přestěhovaly do vlastní aplikace 😉"
]
+364
View File
@@ -0,0 +1,364 @@
import crypto from "crypto";
import axios from "axios";
import getStorage from "./storage";
const storage = getStorage();
/**
* Postavy Umamusume pro konfety kola štěstí (styl Umamusume): chibi obrázky (případně
* portrét s `round: true`) a typická hláška.
* Zdroj: Umamusume Wiki (umamusume.fandom.com) — obrázky se do repozitáře neukládají,
* server je při prvním použití stáhne a nacachuje ve storage (viz {@link getConfettiImage}).
*/
export const UMA_CHARACTERS: { name: string, quote?: string, round?: boolean, images: string[], icon?: string }[] = [
{
name: "Air Groove",
quote: "Just as my esteemed mother once did, I must train to perfection and lead others along my path!",
images: [
'https://static.wikia.nocookie.net/umamusume/images/c/c3/Air_Groove_Original_Chibi_2.png',
'https://static.wikia.nocookie.net/umamusume/images/f/f9/Air_Groove_Original_Chibi_1.png',
],
},
{
name: "Bamboo Memory",
quote: "Let's keep the pecking order nice and neat. We don't want anything bad to happen, right?",
images: [
'https://static.wikia.nocookie.net/umamusume/images/c/c5/Bamboo_Memory_Original_Chibi_1.png',
'https://static.wikia.nocookie.net/umamusume/images/e/e7/Bamboo_Memory_Original_Chibi_2.png',
],
},
{
name: "Cesario",
quote: "I will not give up this Tiara. It's time for me to show the way.",
images: [
'https://static.wikia.nocookie.net/umamusume/images/7/74/Cesario_Original_Chibi_2.png',
'https://static.wikia.nocookie.net/umamusume/images/a/ad/Cesario_Original_Chibi_1.png',
],
},
{
name: "El Condor Pasa",
quote: "Soaring towards victory with the heart of a wrestler! El's the world's strooongest!",
images: [
'https://static.wikia.nocookie.net/umamusume/images/7/72/El_Condor_Pasa_Original_Chibi_1.png',
'https://static.wikia.nocookie.net/umamusume/images/c/c0/El_Condor_Pasa_Original_Chibi_2.png',
],
},
{
name: "Gold Ship",
quote: "So long as it's fun, right? Right?!",
images: [
'https://static.wikia.nocookie.net/umamusume/images/8/84/Gold_Ship_Original_Chibi_2.png',
'https://static.wikia.nocookie.net/umamusume/images/d/db/Gold_Ship_Original_Chibi_1.png',
],
},
{
name: "Manhattan Cafe",
quote: "I'm... right here, with someone you can't see.",
images: [
'https://static.wikia.nocookie.net/umamusume/images/8/8f/Manhattan_Cafe_Original_Chibi_1.png',
'https://static.wikia.nocookie.net/umamusume/images/d/de/Manhattan_Cafe_Original_Chibi_2.png',
],
},
{
name: "Matikanetannhauser",
quote: "Y'know, I kinda get the feeling I can really do it this time!",
images: [
'https://static.wikia.nocookie.net/umamusume/images/6/6b/Matikanetannhauser_Original_Chibi_2.png',
'https://static.wikia.nocookie.net/umamusume/images/9/90/Matikanetannhauser_Original_Chibi_1.png',
],
},
{
name: "Mejiro Bright",
quote: "Oh, is it time for my race already? Well then, I suppose I shall get ready.",
images: [
'https://static.wikia.nocookie.net/umamusume/images/3/3d/Mejiro_Bright_Original_Chibi_2.png',
'https://static.wikia.nocookie.net/umamusume/images/c/cc/Mejiro_Bright_Original_Chibi_1.png',
],
},
{
name: "Nakayama Festa",
quote: "Cold… is that so? When my passion heats up it'll be hot enough to burn you.",
images: [
'https://static.wikia.nocookie.net/umamusume/images/1/16/Nakayama_Festa_Original_Chibi_1.png',
'https://static.wikia.nocookie.net/umamusume/images/5/57/Nakayama_Festa_Original_Chibi_2.png',
],
},
{
name: "Neo Universe",
quote: "Neo Universe would like to request a friendly \"ENCOUNTER\". In other words, I'm in your hands.",
images: [
'https://static.wikia.nocookie.net/umamusume/images/3/34/Neo_Universe_Original_Chibi_1.png',
'https://static.wikia.nocookie.net/umamusume/images/4/40/Neo_Universe_Original_Chibi_2.png',
],
},
{
name: "Phalaenopsis",
quote: "Frail health, poor form—what are they to me? Legs that dull at such trifles have no business claiming victory.",
images: [
'https://static.wikia.nocookie.net/umamusume/images/6/65/Phalaenopsis_Original_Chibi_2.png',
'https://static.wikia.nocookie.net/umamusume/images/9/95/Phalaenopsis_Original_Chibi_1.png',
],
},
{
name: "Satono Diamond",
quote: "Everyone believes in my ability to win. I shall shine on like a brilliant diamond for them!",
images: [
'https://static.wikia.nocookie.net/umamusume/images/3/3b/Satono_Diamond_Original_Chibi_1.png',
'https://static.wikia.nocookie.net/umamusume/images/9/9d/Satono_Diamond_Original_Chibi_2.png',
],
},
{
name: "Silence Suzuka",
quote: "I just want to see... that scenery from the lead.",
images: [
'https://static.wikia.nocookie.net/umamusume/images/1/1f/Silence_Suzuka_Original_Chibi_1.png',
'https://static.wikia.nocookie.net/umamusume/images/d/df/Silence_Suzuka_Original_Chibi_2.png',
],
},
{
name: "Special Week",
images: [
'https://static.wikia.nocookie.net/umamusume/images/4/4f/Special_Week_Original_Chibi_2.png',
'https://static.wikia.nocookie.net/umamusume/images/6/6c/Special_Week_Original_Chibi_1.png',
],
// Portrét pro odznak „jedlíka" (chibi je v malé velikosti nečitelný)
icon: 'https://static.wikia.nocookie.net/umamusume/images/3/3a/Special_Week_%28icon%29.png/revision/latest/scale-to-width-down/128',
},
{
name: "Tokai Teio",
quote: "Here begins the legend of the invincible Teio!",
images: [
'https://static.wikia.nocookie.net/umamusume/images/3/36/Tokai_Teio_Original_Chibi_1.png',
'https://static.wikia.nocookie.net/umamusume/images/9/9e/Tokai_Teio_Original_Chibi_2.png',
],
},
{
name: "Tosen Jordan",
quote: "My dream is... actually, serious topics are NG~☆ Anyways, look at my nails! Aren't they cool?",
images: [
'https://static.wikia.nocookie.net/umamusume/images/3/3e/Tosen_Jordan_Original_Chibi_1.png',
'https://static.wikia.nocookie.net/umamusume/images/7/7b/Tosen_Jordan_Original_Chibi_2.png',
],
},
{
name: "Yaeno Muteki",
images: [
'https://static.wikia.nocookie.net/umamusume/images/9/9d/Yaeno_Muteki_Original_Chibi_2.png',
'https://static.wikia.nocookie.net/umamusume/images/d/d7/Yaeno_Muteki_Original_Chibi_1.png',
],
},
// Postavy bez chibi obrázku na wiki — čtvercový portrét (ikona), klient ho zobrazí jako kulatou samolepku
{
name: "Kitasan Black",
quote: "I'll give it a go! You'll never know unless you try, right?",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/c/c8/Kitasan_Black_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Oguri Cap",
quote: "I'm going to win this for the folks back home... But first, sustenance.",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/5/57/Oguri_Cap_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Haru Urara",
quote: "I won't let losing get me down! Urara, la la la! ♪",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/7/7b/Haru_Urara_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Mejiro McQueen",
quote: "I'll bring honor and glory to the Mejiro family!",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/1/15/Mejiro_McQueen_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Rice Shower",
quote: "Rice only brings misfortune to everyone... I really... really hope I can change that!",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/0/07/Rice_Shower_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Daiwa Scarlet",
quote: "Second-best won't cut it. Everything I do is for the sake of being number one!",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/d/d7/Daiwa_Scarlet_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Vodka",
quote: "I've just gotta be the coolest to ever do it!",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/0/0d/Vodka_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Agnes Tachyon",
quote: "Life is an experiment, and we its guinea pigs. You, me, the other Umamusume—everyone!",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/9/9d/Agnes_Tachyon_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Twin Turbo",
quote: "Turbo's my name, and speed's my game! Let's race, right now! Hey! You listenin' to me?!",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/9/97/Twin_Turbo_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Symboli Rudolf",
quote: "Fortune smiles upon the brave. Let us create a world where all Umamusume can live in bliss.",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/5/56/Symboli_Rudolf_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Nice Nature",
quote: "Yup, I'm happy with a third place finish—it's all an extra like me can ask for.",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/7/77/Nice_Nature_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Maruzensky",
quote: "The track's like a dance floor, and I'm feelin' groovy today!♪",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/b/b4/Maruzensky_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Grass Wonder",
quote: "Noble and gallant, I march on... towards my ambitions.",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/d/d0/Grass_Wonder_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Mihono Bourbon",
quote: "Target acquired. Commencing Operation: Triple Crown.",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/3/37/Mihono_Bourbon_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Curren Chan",
quote: "New day, same adorable Curren! ☆ Spread the word with #CurrenChan!",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/9/91/Curren_Chan_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
{
name: "Super Creek",
quote: "I can spoil you as much as you'd like. Aww, there, there. ♪",
round: true,
images: [
'https://static.wikia.nocookie.net/umamusume/images/5/5e/Super_Creek_%28icon%29.png/revision/latest/scale-to-width-down/256',
],
},
];
/**
* Obrázky stylu Hell Girl — plamen (jednorázová animace při otevření dialogu a změně dne)
* a symbol ohně ze znělky (ozdoba u titulku dne). Cachují se stejně jako obrázky postav.
*/
const HELLGIRL_IMAGES = {
flame: 'https://www.jigokutsushin.it/images/flame.gif',
symbol: 'https://images.seeklogo.com/logo-png/6/1/hell-girl-fire-symbol-logo-png_seeklogo-66173.png',
};
/** Po neúspěšném stažení obrázku se další pokus smí provést až po této době. */
const RETRY_AFTER_MS = 10 * 60 * 1000;
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
type CachedImage = { contentType: string, data: string };
/** Stabilní ID obrázku z jeho URL — nemění se při úpravách seznamu (prohlížeč obrázky cachuje natrvalo). */
const imageId = (url: string) => crypto.createHash('sha1').update(url).digest('hex').slice(0, 16);
const imageKey = (url: string) => `confetti_img_${imageId(url)}`;
const imageUrl = (url: string) => `/api/confetti/image/${imageId(url)}`;
/** Povolené obrázky: ID → zdrojová URL (nic jiného server nestáhne). */
const ALLOWED_IMAGES = new Map([
...UMA_CHARACTERS.flatMap(c => c.icon ? [...c.images, c.icon] : c.images),
...Object.values(HELLGIRL_IMAGES),
].map(url => [imageId(url), url]));
const failedAt = new Map<string, number>();
const inFlight = new Map<string, Promise<CachedImage | undefined>>();
/** Seznam postav pro klienta — obrázky vedou na cachující proxy tohoto serveru. */
export function getConfettiCharacters() {
return UMA_CHARACTERS.map(c => ({
name: c.name,
...(c.quote && { quote: c.quote }),
...(c.round && { round: true }),
images: c.images.map(imageUrl),
...(c.icon && { icon: imageUrl(c.icon) }),
}));
}
/** Obrázky stylu Hell Girl pro klienta — vedou na cachující proxy tohoto serveru. */
export function getHellGirlImages() {
return { flame: imageUrl(HELLGIRL_IMAGES.flame), symbol: imageUrl(HELLGIRL_IMAGES.symbol) };
}
/**
* Vrátí obrázek konfet dle jeho ID. Poprvé ho stáhne a uloží do storage, další
* požadavky (i při výpadku internetu) obslouží z cache. Stahují se jen URL ze seznamu výše.
*/
export async function getConfettiImage(id: string): Promise<{ contentType: string, data: Buffer } | undefined> {
const url = ALLOWED_IMAGES.get(id);
if (!url) return undefined;
const key = imageKey(url);
const cached = await storage.getData<CachedImage>(key);
if (cached?.data) {
return { contentType: cached.contentType, data: Buffer.from(cached.data, 'base64') };
}
const lastFailure = failedAt.get(id);
if (lastFailure && Date.now() - lastFailure < RETRY_AFTER_MS) return undefined;
let pending = inFlight.get(id);
if (!pending) {
pending = downloadImage(url, key)
.catch(e => {
console.warn(`Konfety: obrázek ${url} se nepodařilo stáhnout — ${e?.message ?? e}`);
failedAt.set(id, Date.now());
return undefined;
})
.finally(() => inFlight.delete(id));
inFlight.set(id, pending);
}
const image = await pending;
return image && { contentType: image.contentType, data: Buffer.from(image.data, 'base64') };
}
async function downloadImage(url: string, key: string): Promise<CachedImage> {
const res = await axios.get<ArrayBuffer>(url, { responseType: 'arraybuffer', timeout: 10_000, maxContentLength: MAX_IMAGE_BYTES });
const contentType = String(res.headers['content-type'] ?? '');
if (!contentType.startsWith('image/')) {
throw new Error(`Neočekávaný typ obsahu ${contentType}`);
}
const image = { contentType, data: Buffer.from(res.data).toString('base64') };
await storage.setData<CachedImage>(key, image);
return image;
}
+401
View File
@@ -0,0 +1,401 @@
import { Server } from "socket.io";
import { Clock, defaultClock, GameError, gameHandler, loginFromPayload, minPlayersForEnv } from "./minigames";
import { getActiveRestaurants, getRestaurantMenu } from "./service";
/**
* Kreslení — jeden hráč kreslí jídlo z dnešních menu, ostatní hádají v chatu.
*
* Každý hráč jednou kreslí. Hádá se klíčové slovo jídla (např. "guláš" u
* "Plzeňský hovězí guláš"), diakritika ani drobné koncovky nevadí. Kdo uhodne dřív,
* dostane víc bodů; kreslíř dostává body za každého, kdo uhodl.
*
* Hádané slovo dostává jen kreslíř (osobní místnost), ostatním chodí maska s nápovědou.
* Stav je v paměti, předpokládá jednu repliku.
*/
export const ROUND_MS = 80_000;
export const REVEAL_MS = 5000;
export const MAX_PLAYERS = 10;
export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
export const GUESS_MAX_POINTS = 100;
export const GUESS_MIN_POINTS = 20;
export const DRAWER_POINTS_PER_GUESS = 25;
/** Podíly času kola, kdy se v nápovědě odkryje další písmeno. */
const HINT_REVEALS = [0.4, 0.7];
const MAX_MESSAGES = 30;
const MAX_MESSAGE_LENGTH = 100;
/** Pojistka proti nekonečné kresbě (součet bodů všech tahů v kole). */
const MAX_POINTS_PER_ROUND = 20_000;
export const WATCH_ROOM = 'draw-watchers';
export const userRoom = (login: string) => `draw-user:${login}`;
/** Záložní slova, když dnešní menu nejsou k dispozici (víkend, výpadek). */
export const FALLBACK_WORDS = [
'pizza', 'guláš', 'řízek', 'svíčková', 'burger', 'palačinky', 'knedlíky', 'špagety', 'sushi', 'kebab',
'salát', 'hranolky', 'párek', 'koláč', 'dort', 'bramboráky', 'rizoto', 'lasagne', 'tacos', 'vepřo knedlo zelo',
];
export type DrawPhase = 'idle' | 'lobby' | 'drawing' | 'reveal' | 'finished';
export type DrawPlayer = { login: string, score: number, guessed: boolean, place?: number };
export type ChatMessage = { login?: string, text: string, system?: boolean };
/** Tah štětcem; souřadnice 0–1000 nezávisle na velikosti plátna. */
export type Stroke = { color: string, size: number, points: [number, number][], start: boolean };
export type DrawState = {
phase: DrawPhase,
host?: string,
players: DrawPlayer[],
drawer?: string,
round: number,
totalRounds: number,
/** Maska hádaného jídla, např. "g _ _ _ š". */
hint?: string,
/** Řešení — jen ve fázi reveal a finished. */
answer?: string,
/** Zbývající čas kola (ms) v okamžiku odeslání. */
remainingMs?: number,
messages: ChatMessage[],
minPlayers: number,
};
type Word = { display: string, keywords: string[] };
/** Malá písmena bez diakritiky a nadbytečných mezer. */
export const normalize = (s: string) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/\s+/g, ' ').trim();
/**
* Z názvu jídla v menu udělá hádatelné slovo: bez alergenů, jen hlavní část
* (před čárkou a přílohou "s/se ..."), klíčová slova bez přídavných jmen.
*/
export function toWord(dish: string): Word {
let display = dish.replace(/\([^)]*\)/g, '').split(',')[0].split(/\s+(?:s|se|a|na)\s+/i)[0].replace(/\s+/g, ' ').trim();
const words = display.split(' ');
if (display.length > 30) display = words.slice(0, 3).join(' ');
// Přídavná jména v češtině končí obvykle na -ý/-á/-é/-í ("Plzeňský hovězí guláš" → guláš)
let keywords = display.split(' ').filter(w => w.length >= 4 && !/[ýáéí]$/i.test(w)).map(normalize);
if (keywords.length === 0) keywords = display.split(' ').filter(w => w.length >= 3).map(normalize);
if (keywords.length === 0) keywords = [normalize(display)];
return { display, keywords };
}
/** Uhodl hráč? Stačí jedno klíčové slovo; tolerují se koncovky (knedlík ~ knedlíky). */
export function isCorrectGuess(guess: string, word: Word): boolean {
const g = normalize(guess);
if (!g) return false;
if (g === normalize(word.display)) return true;
return g.split(' ').some(gw => word.keywords.some(kw => {
if (gw === kw) return true;
const need = Math.max(4, kw.length - 2);
return gw.length >= need && kw.slice(0, need) === gw.slice(0, need);
}));
}
export class DrawingGame {
private phase: DrawPhase = 'idle';
private host?: string;
private players: DrawPlayer[] = [];
private order: string[] = [];
private round = 0;
private word?: Word;
private words: Word[] = [];
private revealed = new Set<number>();
private roundEndsAt = 0;
private messages: ChatMessage[] = [];
private strokes: Stroke[] = [];
private strokePoints = 0;
private starting = false;
private timer: unknown;
private hintTimers: unknown[] = [];
constructor(
private readonly broadcastAll: (state: DrawState) => void,
private readonly sendWord: (login: string, word: string) => void,
private readonly relayStroke: (stroke: Stroke) => void,
private readonly relayClear: () => void,
private readonly loadDishes: () => Promise<string[]>,
private readonly minPlayers = 2,
private readonly clock: Clock = defaultClock,
) { }
get drawer(): string | undefined {
return this.phase === 'drawing' || this.phase === 'reveal' ? this.order[this.round - 1] : undefined;
}
getState(): DrawState {
const showAnswer = this.phase === 'reveal' || this.phase === 'finished';
return {
phase: this.phase,
host: this.host,
players: this.players.map(p => ({ ...p })),
drawer: this.drawer,
round: this.round,
totalRounds: this.order.length,
hint: this.phase === 'drawing' ? this.hint() : undefined,
answer: showAnswer ? this.word?.display : undefined,
remainingMs: this.phase === 'drawing' ? Math.max(0, this.roundEndsAt - this.clock.now()) : undefined,
messages: [...this.messages],
minPlayers: this.minPlayers,
};
}
getStrokes(): Stroke[] {
return this.strokes;
}
/** Hádané slovo pro daného hráče (jen kreslíř během kola). */
wordFor(login: string): string | undefined {
return this.phase === 'drawing' && login === this.drawer ? this.word?.display : undefined;
}
join(login: string) {
if (this.phase === 'idle' || this.phase === 'finished') {
this.reset();
this.phase = 'lobby';
this.host = login;
this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS);
} else if (this.phase !== 'lobby') {
throw new GameError('Hra už běží, počkejte na další.');
}
if (this.players.some(p => p.login === login)) return;
if (this.players.length >= MAX_PLAYERS) {
throw new GameError('Lobby je plné.');
}
this.players.push({ login, score: 0, guessed: false });
this.emit();
}
leave(login: string) {
if (this.phase !== 'lobby' || !this.players.some(p => p.login === login)) return;
if (login === this.host) {
this.cancel();
return;
}
this.players = this.players.filter(p => p.login !== login);
this.emit();
}
async start(login: string) {
if (this.phase !== 'lobby' || this.starting) {
throw new GameError('Není otevřené žádné lobby.');
}
if (login !== this.host) {
throw new GameError('Hru může spustit jen ten, kdo lobby založil.');
}
if (this.players.length < this.minPlayers) {
throw new GameError(`Ke hře je potřeba alespoň ${this.minPlayers} hráčů.`);
}
this.starting = true;
let dishes: string[] = [];
try {
dishes = await this.loadDishes();
} catch {
// Menu nejsou k dispozici — hraje se se záložními slovy
} finally {
this.starting = false;
}
if (this.phase !== 'lobby') return;
const unique = [...new Map(dishes.map(toWord).map(w => [normalize(w.display), w])).values()];
// Dnešní jídla mají přednost; když dojdou, doplní se záložní slova (viz nextRound)
this.words = unique.length > 0 ? unique : FALLBACK_WORDS.map(toWord);
this.order = this.players.map(p => p.login);
this.round = 0;
this.nextRound();
}
stroke(login: string, payload: unknown) {
if (this.phase !== 'drawing' || login !== this.drawer) return;
const { color, size, points, start } = (payload ?? {}) as Partial<Stroke>;
if (typeof color !== 'string' || !/^#[0-9a-f]{6}$/i.test(color)) return;
if (typeof size !== 'number' || size < 1 || size > 40) return;
if (!Array.isArray(points) || points.length === 0 || points.length > 200) return;
if (!points.every(p => Array.isArray(p) && p.length === 2 && p.every(v => Number.isFinite(v) && v >= 0 && v <= 1000))) return;
if (this.strokePoints + points.length > MAX_POINTS_PER_ROUND) return;
const stroke: Stroke = { color, size, points: points.map(([x, y]) => [Math.round(x), Math.round(y)]), start: !!start };
this.strokes.push(stroke);
this.strokePoints += points.length;
this.relayStroke(stroke);
}
clear(login: string) {
if (this.phase !== 'drawing' || login !== this.drawer) return;
this.strokes = [];
this.strokePoints = 0;
this.relayClear();
}
guess(login: string, text: unknown) {
if (typeof text !== 'string') return;
const message = text.trim().slice(0, MAX_MESSAGE_LENGTH);
const player = this.players.find(p => p.login === login);
if (!message || !player) return;
if (this.phase !== 'drawing') {
this.pushMessage({ login, text: message });
this.emit();
return;
}
// Kreslíř a ti, kdo už uhodli, by mohli prozradit řešení
if (login === this.drawer || player.guessed || !this.word) return;
if (isCorrectGuess(message, this.word)) {
const share = Math.max(0, this.roundEndsAt - this.clock.now()) / ROUND_MS;
player.guessed = true;
player.score += Math.max(GUESS_MIN_POINTS, Math.round(GUESS_MAX_POINTS * share));
const drawer = this.players.find(p => p.login === this.drawer);
if (drawer) drawer.score += DRAWER_POINTS_PER_GUESS;
this.pushMessage({ text: `${login} uhodl(a)! 🎉`, system: true });
const guessers = this.players.filter(p => p.login !== this.drawer);
if (guessers.every(p => p.guessed)) {
this.endRound();
return;
}
} else {
this.pushMessage({ login, text: message });
}
this.emit();
}
private nextRound() {
this.round++;
if (this.round > this.order.length) {
this.finish();
return;
}
const index = Math.floor(this.clock.random() * this.words.length);
this.word = this.words.splice(index, 1)[0] ?? toWord(FALLBACK_WORDS[0]);
if (this.words.length === 0) this.words = FALLBACK_WORDS.map(toWord);
this.revealed.clear();
this.strokes = [];
this.strokePoints = 0;
this.players.forEach(p => { p.guessed = false; });
this.phase = 'drawing';
this.roundEndsAt = this.clock.now() + ROUND_MS;
this.pushMessage({ text: `Kreslí ${this.drawer}.`, system: true });
this.relayClear();
this.schedule(() => this.endRound(), ROUND_MS);
this.hintTimers = HINT_REVEALS.map(share => this.clock.setTimeout(() => this.revealLetter(), ROUND_MS * share));
this.emit();
this.sendWord(this.drawer!, this.word.display);
}
private revealLetter() {
if (this.phase !== 'drawing' || !this.word) return;
const hidden = [...this.word.display].map((c, i) => i).filter(i => this.word!.display[i] !== ' ' && !this.revealed.has(i));
// Poslední písmeno nikdy neodkrýváme
if (hidden.length <= 1) return;
this.revealed.add(hidden[Math.floor(this.clock.random() * hidden.length)]);
this.emit();
}
private hint(): string {
if (!this.word) return '';
return [...this.word.display].map((c, i) => c === ' ' ? ' ' : this.revealed.has(i) ? c : '_').join(' ');
}
private endRound() {
this.clearHintTimers();
this.phase = 'reveal';
this.pushMessage({ text: `Bylo to: ${this.word?.display}`, system: true });
this.schedule(() => this.nextRound(), REVEAL_MS);
this.emit();
}
private finish() {
const ranked = [...this.players].sort((a, b) => b.score - a.score);
ranked.forEach((p, i) => { p.place = i + 1; });
this.phase = 'finished';
this.clearTimer();
this.emit();
}
private pushMessage(message: ChatMessage) {
this.messages.push(message);
if (this.messages.length > MAX_MESSAGES) this.messages.shift();
}
private cancel() {
this.reset();
this.emit();
}
private reset() {
this.clearTimer();
this.clearHintTimers();
this.phase = 'idle';
this.host = undefined;
this.players = [];
this.order = [];
this.round = 0;
this.word = undefined;
this.messages = [];
this.strokes = [];
this.strokePoints = 0;
}
private schedule(fn: () => void, ms: number) {
this.clearTimer();
this.timer = this.clock.setTimeout(() => {
this.timer = undefined;
fn();
}, ms);
}
private clearTimer() {
if (this.timer !== undefined) {
this.clock.clearTimeout(this.timer);
this.timer = undefined;
}
}
private clearHintTimers() {
this.hintTimers.forEach(t => this.clock.clearTimeout(t));
this.hintTimers = [];
}
private emit() {
this.broadcastAll(this.getState());
}
}
/** Názvy dnešních jídel (bez polévek) ze všech aktivních podniků. */
async function loadTodaysDishes(): Promise<string[]> {
const menus = await Promise.all(getActiveRestaurants().map(r => getRestaurantMenu(r).catch(() => undefined)));
return menus.flatMap(m => m?.food ?? []).filter(f => !f.isSoup && f.name).map(f => f.name);
}
/** Zaregistruje websocket handlery Kreslení. */
export function registerDrawingGame(io: Server) {
const game = new DrawingGame(
state => io.emit('draw:state', state),
(login, word) => io.to(userRoom(login)).emit('draw:word', word),
stroke => io.to(WATCH_ROOM).emit('draw:stroke', stroke),
() => io.to(WATCH_ROOM).emit('draw:clear'),
loadTodaysDishes,
minPlayersForEnv(2),
);
io.on('connection', socket => {
const handle = (action: (login: string, payload: unknown) => void | Promise<void>) => gameHandler(socket, 'draw:error', action);
socket.on('draw:sync', () => socket.emit('draw:state', game.getState()));
// Sledování vyžaduje přihlášení — kreslíř si tak po znovupřipojení vyzvedne své slovo
socket.on('draw:watch', payload => {
let login: string | undefined;
try { login = loginFromPayload(payload); } catch { /* sleduje anonymně */ }
socket.join(WATCH_ROOM);
if (login) socket.join(userRoom(login));
socket.emit('draw:state', game.getState());
socket.emit('draw:strokes', game.getStrokes());
const word = login ? game.wordFor(login) : undefined;
if (word) socket.emit('draw:word', word);
});
socket.on('draw:unwatch', () => {
socket.leave(WATCH_ROOM);
[...socket.rooms].filter(r => r.startsWith('draw-user:')).forEach(r => socket.leave(r));
});
socket.on('draw:join', handle(login => game.join(login)));
socket.on('draw:leave', handle(login => game.leave(login)));
socket.on('draw:start', handle(login => game.start(login)));
socket.on('draw:stroke', handle((login, payload) => game.stroke(login, payload)));
socket.on('draw:clear', handle(login => game.clear(login)));
socket.on('draw:guess', handle((login, payload) => game.guess(login, (payload as { text?: unknown })?.text)));
});
}
+4
View File
@@ -240,6 +240,10 @@ export async function updateGroupFees(login: string, groupId: string, fees?: num
if (tip !== undefined) group.tip = tip > 0 ? tip : undefined; if (tip !== undefined) group.tip = tip > 0 ? tip : undefined;
if (discountType !== undefined) group.discountType = (discountType as any) || undefined; if (discountType !== undefined) group.discountType = (discountType as any) || undefined;
if (discountValue !== undefined) group.discountValue = discountValue > 0 ? discountValue : undefined; if (discountValue !== undefined) group.discountValue = discountValue > 0 ? discountValue : undefined;
// Pojistka i pro změnu jen hodnoty při již nastavené procentní slevě — nad 100 % by vyšly záporné částky
if (group.discountType === 'percent' && (group.discountValue ?? 0) > 100) {
throw new Error('Procentní sleva může být nejvýše 100 %');
}
return saveExtraData(data, date); return saveExtraData(data, date);
} }
+48 -9
View File
@@ -29,6 +29,9 @@ import changelogRoutes from "./routes/changelogRoutes";
import groupRoutes from "./routes/groupRoutes"; import groupRoutes from "./routes/groupRoutes";
import storeRoutes from "./routes/storeRoutes"; import storeRoutes from "./routes/storeRoutes";
import butterflyRoutes from "./routes/butterflyRoutes"; import butterflyRoutes from "./routes/butterflyRoutes";
import { getConfettiCharacters, getConfettiImage, getHellGirlImages } from "./confetti";
import { getTrustedRemoteUser, parseTrustedIps } from "./trustedHeaders";
import { getGroupLimits } from "./limits";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
@@ -53,12 +56,15 @@ app.use(cors({ origin: '*' }));
const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false; const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false;
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user'; const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user';
let trustedProxies = parseTrustedIps('');
if (HTTP_REMOTE_USER_ENABLED) { if (HTTP_REMOTE_USER_ENABLED) {
if (!process.env.HTTP_REMOTE_TRUSTED_IPS) { if (!process.env.HTTP_REMOTE_TRUSTED_IPS) {
throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.'); throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.');
} }
const HTTP_REMOTE_TRUSTED_IPS = process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim()); const HTTP_REMOTE_TRUSTED_IPS = process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim());
app.set('trust proxy', HTTP_REMOTE_TRUSTED_IPS); app.set('trust proxy', HTTP_REMOTE_TRUSTED_IPS);
// Hlavička s identitou se přijímá jen od těchto adres (kontrola skutečné adresy spojení)
trustedProxies = parseTrustedIps(process.env.HTTP_REMOTE_TRUSTED_IPS);
console.log('Zapnutý login přes hlavičky z proxy.'); console.log('Zapnutý login přes hlavičky z proxy.');
} }
@@ -135,6 +141,18 @@ app.get("/api/config", (_req, res) => {
dsn: process.env.SENTRY_CLIENT_DSN ?? process.env.SENTRY_DSN ?? null, dsn: process.env.SENTRY_CLIENT_DSN ?? process.env.SENTRY_DSN ?? null,
environment: ENVIRONMENT, environment: ENVIRONMENT,
}, },
limits: {
group: getGroupLimits(),
},
auth: {
trustedHeaders: HTTP_REMOTE_USER_ENABLED,
},
features: {
storeAdmin: !!process.env.ADMIN_PASSWORD,
},
links: {
lobbyUrl: process.env.LOBBY_URL || null,
},
}); });
}); });
@@ -146,16 +164,17 @@ app.get("/api/whoami", (req, res) => {
delete req.headers["cookie"] delete req.headers["cookie"]
console.log(req.headers) console.log(req.headers)
} }
res.send(req.header(HTTP_REMOTE_USER_HEADER_NAME)); res.send(getTrustedRemoteUser(req, HTTP_REMOTE_USER_HEADER_NAME, trustedProxies) ?? '');
}) })
app.post("/api/login", (req, res) => { app.post("/api/login", (req, res) => {
if (HTTP_REMOTE_USER_ENABLED) { if (HTTP_REMOTE_USER_ENABLED) {
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME); const remoteUser = getTrustedRemoteUser(req, HTTP_REMOTE_USER_HEADER_NAME, trustedProxies);
if (remoteUser && remoteUser.length > 0) { if (remoteUser && remoteUser.length > 0) {
res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true)); res.status(200).json(generateToken(remoteUser, true));
} else { } else {
throw new Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??"); // Přihlašuje proxy (trusted headers) — bez hlavičky nejde o chybu serveru, ale o nepřihlášeného uživatele
res.status(401).json({ error: 'Přihlášení probíhá přes firemní přihlášení (proxy), ale nepřišla identita uživatele. Zkuste stránku obnovit nebo se přihlásit znovu.' });
} }
} else { } else {
if (!req.body?.login || req.body.login.trim().length === 0) { if (!req.body?.login || req.body.login.trim().length === 0) {
@@ -200,22 +219,42 @@ app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
} catch (e: any) { next(e); } } catch (e: any) { next(e); }
}); });
// Konfety kola štěstí — veřejné (načítá je <img>), obrázky jen z pevného seznamu, cachované ve storage
app.get("/api/confetti/umamusume", (_req, res) => {
// Seznam je malý — vždy revalidovat (ETag → 304), aby se změny projevily hned po nasazení
res.set('Cache-Control', 'no-cache').json(getConfettiCharacters());
});
app.get("/api/confetti/hellgirl", (_req, res) => {
res.set('Cache-Control', 'no-cache').json(getHellGirlImages());
});
app.get("/api/confetti/image/:id", async (req, res, next) => {
try {
const id = req.params.id;
const image = /^[0-9a-f]{16}$/.test(id) ? await getConfettiImage(id) : undefined;
if (!image) {
return res.status(404).json({ error: 'Obrázek není dostupný' });
}
res.set('Content-Type', image.contentType);
res.set('Cache-Control', 'public, max-age=2592000, immutable');
res.end(image.data);
} catch (e: any) { next(e); }
});
// ─── Auth middleware ────────────────────────────────────────────────────────── // ─── Auth middleware ──────────────────────────────────────────────────────────
app.use("/api/", (req, res, next) => { app.use("/api/", (req, res, next) => {
if (HTTP_REMOTE_USER_ENABLED) { if (HTTP_REMOTE_USER_ENABLED) {
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME); const remoteName = getTrustedRemoteUser(req, HTTP_REMOTE_USER_HEADER_NAME, trustedProxies);
if (process.env.ENABLE_HEADERS_LOGGING === 'yes') { if (process.env.ENABLE_HEADERS_LOGGING === 'yes') {
delete req.headers["cookie"] delete req.headers["cookie"]
console.log(req.headers) console.log(req.headers)
} }
if (remoteUser && remoteUser.length > 0) { if (remoteName && ENVIRONMENT !== "production") {
const remoteName = Buffer.from(remoteUser, 'latin1').toString();
if (ENVIRONMENT !== "production") {
console.log("Tvuj username: %s.", remoteName); console.log("Tvuj username: %s.", remoteName);
} }
} }
}
if (!req.headers.authorization) { if (!req.headers.authorization) {
return res.status(401).json({ error: 'Nebyl předán autentizační token' }); return res.status(401).json({ error: 'Nebyl předán autentizační token' });
} }
+56
View File
@@ -0,0 +1,56 @@
import { GroupLimits } from "../../types/gen/types.gen";
/**
* Výchozí limity skupinových objednávek — pojistky proti překlepům (dvojklik, omylem vložený text),
* ne ochrana proti zlému úmyslu. Lze je přepsat proměnnými prostředí (viz .env.template).
*/
export const DEFAULT_GROUP_LIMITS: GroupLimits = {
maxNoteLength: 300,
maxSurchargeTextLength: 100,
maxNameLength: 100,
maxUrlLength: 500,
maxAmountKc: 10_000,
maxItemQuantity: 10,
maxItems: 20,
};
/** Proměnná prostředí a rozumný strop pro každý limit. */
const ENV: Record<keyof GroupLimits, { name: string, max: number }> = {
maxNoteLength: { name: 'GROUP_MAX_NOTE_LENGTH', max: 5_000 },
maxSurchargeTextLength: { name: 'GROUP_MAX_SURCHARGE_TEXT_LENGTH', max: 1_000 },
maxNameLength: { name: 'GROUP_MAX_NAME_LENGTH', max: 1_000 },
maxUrlLength: { name: 'GROUP_MAX_URL_LENGTH', max: 5_000 },
maxAmountKc: { name: 'GROUP_MAX_AMOUNT_KC', max: 1_000_000 },
maxItemQuantity: { name: 'GROUP_MAX_ITEM_QUANTITY', max: 1_000 },
maxItems: { name: 'GROUP_MAX_ITEMS', max: 1_000 },
};
let cached: GroupLimits | undefined;
/**
* Vrátí limity skupinových objednávek (výchozí hodnoty přepsané z env). Čte se líně při prvním
* použití — moduly s routami se importují dřív, než dotenv načte .env soubor.
* Neplatná hodnota (ne celé kladné číslo, nad stropem) se ignoruje s varováním.
*/
export function getGroupLimits(): GroupLimits {
if (cached) return cached;
const limits = { ...DEFAULT_GROUP_LIMITS };
for (const key of Object.keys(ENV) as (keyof GroupLimits)[]) {
const { name, max } = ENV[key];
const raw = process.env[name];
if (raw === undefined || raw.trim() === '') continue;
const value = Number(raw);
if (!Number.isInteger(value) || value < 1 || value > max) {
console.warn(`Limity: neplatná hodnota ${name}="${raw}" (povoleno 1–${max}), používám výchozí ${limits[key]}`);
continue;
}
limits[key] = value;
}
cached = limits;
return limits;
}
/** Pro testy — znovu načte limity z env při dalším volání. */
export function resetGroupLimits(): void {
cached = undefined;
}
+62
View File
@@ -0,0 +1,62 @@
import { Socket } from "socket.io";
import { getLogin } from "./auth";
/** Pomůcky pro websocketové minihry (kreslení). Ostatní minihry jsou v samostatné aplikaci Lobby. */
/** Chyba určená hráči — posílá se zpět klientovi, nic se neloguje. */
export class GameError extends Error { }
/** Hodiny a náhoda vyčleněné kvůli testům. */
export type Clock = {
now: () => number,
setTimeout: (fn: () => void, ms: number) => unknown,
clearTimeout: (handle: unknown) => void,
random: () => number,
};
export const defaultClock: Clock = {
now: () => Date.now(),
setTimeout: (fn, ms) => setTimeout(fn, ms),
clearTimeout: handle => clearTimeout(handle as NodeJS.Timeout),
random: () => Math.random(),
};
/** Ověří token z eventu a vrátí login, jinak vyhodí chybu. */
export function loginFromPayload(payload: unknown): string {
const token = (payload as { token?: unknown } | undefined)?.token;
if (typeof token !== 'string') {
throw new GameError('Chybí přihlášení.');
}
try {
const login = getLogin(token);
if (!login) throw new Error();
return login;
} catch {
throw new GameError('Neplatné přihlášení.');
}
}
/**
* Vytvoří handler websocket eventu, který ověří přihlášení a herní chyby
* pošle zpět jen danému klientovi jako `errorEvent`.
*/
export function gameHandler(socket: Socket, errorEvent: string, action: (login: string, payload: unknown) => void | Promise<void>) {
const onError = (e: unknown) => {
if (e instanceof GameError) {
socket.emit(errorEvent, e.message);
} else {
console.error(`Chyba minihry (${errorEvent})`, e);
}
};
return (payload: unknown) => {
try {
const result = action(loginFromPayload(payload), payload);
if (result instanceof Promise) result.catch(onError);
} catch (e) {
onError(e);
}
};
}
/** Minimální počet hráčů — lokálně lze hrát i sám. */
export const minPlayersForEnv = (prod: number) => process.env.NODE_ENV === 'development' ? 1 : prod;
+31 -1
View File
@@ -20,11 +20,22 @@ const SOUP_NAMES = [
'boršč', 'boršč',
'slepičí s ', 'slepičí s ',
'zeleninová s ', 'zeleninová s ',
'zeleninový krém',
'hovězí s ', 'hovězí s ',
'kachní kaldoun', 'kachní kaldoun',
'dršťková', 'dršťková',
'žampionový krém', 'žampionový krém',
'zelná', 'zelná',
'čočková',
'gulášová',
'bramboračka',
'dýňový krém',
'ovarová',
'fazolačka',
'kyselica',
'rajčatová s ',
'hrášková s ',
'hrachová s ',
]; ];
const DAYS_IN_WEEK = ['pondělí', 'úterý', 'středa', 'čtvrtek', 'pátek', 'sobota', 'neděle']; const DAYS_IN_WEEK = ['pondělí', 'úterý', 'středa', 'čtvrtek', 'pátek', 'sobota', 'neděle'];
@@ -328,6 +339,13 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
if (!font) { if (!font) {
throw new Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.'); throw new Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.');
} }
// Šestý pokus - formát z 10/2026: hlavička je <p><strong><font class="wsw-41">,
// jednotlivé dny a položky jsou sourozenecké <p> v témže bloku. Třetí pokus sice najde
// hlavičku, ale <font> nemá žádné sourozenecké <p>, proto se použije parsing pátého pokusu.
if (thirdTry && $(font).parent().siblings('p').length === 0) {
thirdTry = false;
fifthTry = true;
}
const result: Food[][] = []; const result: Food[][] = [];
@@ -365,7 +383,11 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
if (fifthTry) { if (fifthTry) {
let parsing = false; let parsing = false;
let currentDayIndex = 0; let currentDayIndex = 0;
const container = $(font).parent().parent(); let container = $(font).parent().parent();
if (container.find('p').length === 0) {
// Formát 10/2026 - hlavička je přímo v <p>, kontejnerem je jeho rodič
container = $(font).closest('p').parent();
}
container.find('p').each((_, p) => { container.find('p').each((_, p) => {
const pHtml = $(p).html() ?? ''; const pHtml = $(p).html() ?? '';
const lines = pHtml.split(/<br\s*\/?>/i) const lines = pHtml.split(/<br\s*\/?>/i)
@@ -373,7 +395,15 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
.filter(line => line.length > 0); .filter(line => line.length > 0);
for (const text of lines) { for (const text of lines) {
const lower = text.toLocaleLowerCase(); const lower = text.toLocaleLowerCase();
// Za aktuálním týdnem může následovat menu na další týden - to již nezpracováváme
if (parsing && lower.startsWith('následující týden')) {
return false;
}
if (DAYS_IN_WEEK.includes(lower)) { if (DAYS_IN_WEEK.includes(lower)) {
if (result[DAYS_IN_WEEK.indexOf(lower)]) {
// Opakující se den = začátek dalšího týdne
return false;
}
currentDayIndex = DAYS_IN_WEEK.indexOf(lower); currentDayIndex = DAYS_IN_WEEK.indexOf(lower);
parsing = true; parsing = true;
result[currentDayIndex] ??= []; result[currentDayIndex] ??= [];
+5 -10
View File
@@ -1,12 +1,12 @@
import express, { Request } from "express"; import express, { Request } from "express";
import { getDateForWeekIndex, getData, getRestaurantMenu, getToday, initIfNeeded } from "../service"; import { getDateForWeekIndex, getData, isRestaurantDisabled, getActiveRestaurants, getRestaurantMenu, getToday, initIfNeeded } from "../service";
import { formatDate, getDayOfWeekIndex } from "../utils"; import { formatDate, getDayOfWeekIndex } from "../utils";
import getStorage from "../storage"; import getStorage from "../storage";
import { getWebsocket } from "../websocket"; import { getWebsocket } from "../websocket";
import { getLogin } from "../auth"; import { getLogin } from "../auth";
import { parseToken } from "../utils"; import { parseToken } from "../utils";
import webpush from 'web-push'; import webpush from 'web-push';
import { ClientData, GroupState, MealSlot, OrderGroupMember, TrackingProvider } from "../../../types/gen/types.gen"; import { ClientData, GroupState, MealSlot, OrderGroupMember, Restaurant, TrackingProvider } from "../../../types/gen/types.gen";
import { getStores } from "../stores"; import { getStores } from "../stores";
import { createGroup } from "../groups"; import { createGroup } from "../groups";
import { import {
@@ -36,7 +36,7 @@ const LUNCH_CHOICES = [
'OBJEDNAVAM', 'OBJEDNAVAM',
'NEOBEDVAM', 'NEOBEDVAM',
'ROZHODUJI', 'ROZHODUJI',
]; ].filter(choice => !isRestaurantDisabled(choice as Restaurant));
// Náhodné názvy jídel do poznámek mock objednávek // Náhodné názvy jídel do poznámek mock objednávek
const MOCK_ORDER_FOODS = [ const MOCK_ORDER_FOODS = [
@@ -72,12 +72,7 @@ const MAX_ORDER_MEMBER_COUNT = 50;
const MAX_ORDER_AMOUNT = 100000; const MAX_ORDER_AMOUNT = 100000;
// Restaurace s menu // Restaurace s menu
const RESTAURANTS_WITH_MENU = [ const RESTAURANTS_WITH_MENU = getActiveRestaurants();
'SLADOVNICKA',
'TECHTOWER',
'ZASTAVKAUMICHALA',
'SENKSERIKOVA',
];
/** /**
* Middleware pro kontrolu DEV režimu * Middleware pro kontrolu DEV režimu
@@ -138,7 +133,7 @@ async function generateMockDataForDay(dayIndex: number, requestedCount?: number)
}; };
// Pokud má restaurace menu, vybereme náhodné jídlo // Pokud má restaurace menu, vybereme náhodné jídlo
if (RESTAURANTS_WITH_MENU.includes(choice) && menus[choice]?.length) { if (RESTAURANTS_WITH_MENU.includes(choice as Restaurant) && menus[choice]?.length) {
const foods = menus[choice]; const foods = menus[choice];
// Vybereme náhodné jídlo (ne polévku) // Vybereme náhodné jídlo (ne polévku)
const mainFoods = foods.filter((f: any) => !f.isSoup); const mainFoods = foods.filter((f: any) => !f.isSoup);
+23 -5
View File
@@ -1,10 +1,10 @@
import express, { Request, Response } from "express"; import express, { Request, Response } from "express";
import { getLogin, getTrusted } from "../auth"; import { getLogin, getTrusted } from "../auth";
import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote, fetchRestaurantWeekMenuData, saveRestaurantWeekMenu, updateBuyer } from "../service"; import { addChoice, getActiveRestaurants, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote, updatePrice, fetchRestaurantWeekMenuData, saveRestaurantWeekMenu, updateBuyer } from "../service";
import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils"; import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils";
import { getWebsocket } from "../websocket"; import { getWebsocket } from "../websocket";
import { callNotifikace } from "../notifikace"; import { callNotifikace } from "../notifikace";
import { AddChoiceData, ChangeDepartureTimeData, MealSlot, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen"; import { AddChoiceData, ChangeDepartureTimeData, MealSlot, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData, UpdatePriceData } from "../../../types/gen/types.gen";
// RateLimit na refresh endpoint // RateLimit na refresh endpoint
@@ -54,7 +54,7 @@ function checkRateLimit(key: string, limit: number = RATE_LIMIT): boolean {
* @param req request * @param req request
* @returns index dne v týdnu * @returns index dne v týdnu
*/ */
const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] | UpdateNoteData["body"]>) => { const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] | UpdateNoteData["body"] | UpdatePriceData["body"]>) => {
if (req.body.dayIndex == null) { if (req.body.dayIndex == null) {
throw new Error(`Nebyl předán index dne v týdnu.`); throw new Error(`Nebyl předán index dne v týdnu.`);
} }
@@ -171,6 +171,24 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
router.post("/updatePrice", async (req: Request<{}, any, UpdatePriceData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
if (req.body.dayIndex != null) {
try {
date = getDateForWeekIndex(parseValidateFutureDayIndex(req));
} catch (e: any) {
return res.status(400).json({ error: e.message });
}
}
try {
const data = await updatePrice(login, trusted, req.body.price ?? undefined, date);
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDepartureTimeData["body"]>, res, next) => { router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDepartureTimeData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
let date = undefined; let date = undefined;
@@ -232,8 +250,8 @@ export const refreshMetoda = async (req: Request, res: Response) => {
return res.status(400).json({ error: "ještě neumim TODO..." }); return res.status(400).json({ error: "ještě neumim TODO..." });
} }
try { try {
// Pro všechny restaurace refreshni menu na aktuální týden // Pro všechny aktivní restaurace refreshni menu na aktuální týden
const restaurants = ["SLADOVNICKA", "TECHTOWER", "ZASTAVKAUMICHALA", "SENKSERIKOVA"] as const; const restaurants = getActiveRestaurants();
const firstDay = getFirstWorkDayOfWeek(getToday()); const firstDay = getFirstWorkDayOfWeek(getToday());
const results: Record<string, any> = {}; const results: Record<string, any> = {};
const successfulRestaurants: string[] = []; const successfulRestaurants: string[] = [];
+43 -17
View File
@@ -5,9 +5,20 @@ import { getWebsocket } from "../websocket";
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupTracking, getOrderDates } from "../groups"; import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupTracking, getOrderDates } from "../groups";
import { GroupState } from "../../../types/gen/types.gen"; import { GroupState } from "../../../types/gen/types.gen";
import { checkOrderTracking } from "../orderTracking"; import { checkOrderTracking } from "../orderTracking";
import { getGroupLimits } from "../limits";
const router = express.Router(); const router = express.Router();
// Pojistky proti překlepům (dvojklik, omylem vložený text) — limity viz limits.ts (lze přepsat env)
/** Vrátí chybovou hlášku, pokud částka (v haléřích) není celé číslo v rozsahu 0 až limit. */
function invalidAmount(value: unknown, label: string): string | undefined {
const { maxAmountKc } = getGroupLimits();
if (!Number.isInteger(value) || (value as number) < 0) return `Neplatná výše: ${label}`;
if ((value as number) > maxAmountKc * 100) return `${label} může být nejvýše ${maxAmountKc} Kč`;
return undefined;
}
function broadcastExtra(data: any) { function broadcastExtra(data: any) {
getWebsocket().emit("message", data); getWebsocket().emit("message", data);
} }
@@ -25,7 +36,11 @@ router.post("/create", async (req: Request, res, next) => {
if (!name || typeof name !== 'string') { if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Nebyl předán název skupiny' }); return res.status(400).json({ error: 'Nebyl předán název skupiny' });
} }
if (url != null && typeof url !== 'string') { const limits = getGroupLimits();
if (name.length > limits.maxNameLength) {
return res.status(400).json({ error: `Název skupiny může mít nejvýše ${limits.maxNameLength} znaků` });
}
if (url != null && (typeof url !== 'string' || url.length > limits.maxUrlLength)) {
return res.status(400).json({ error: 'Neplatná URL nabídky' }); return res.status(400).json({ error: 'Neplatná URL nabídky' });
} }
try { try {
@@ -80,23 +95,29 @@ router.post("/updateMember", async (req: Request, res, next) => {
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' }); if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
const patch: Record<string, any> = {}; const patch: Record<string, any> = {};
if (amount !== undefined) { if (amount !== undefined) {
if (!Number.isInteger(amount) || amount < 0) { const error = invalidAmount(amount, 'Částka');
return res.status(400).json({ error: 'Neplatná částka' }); if (error) return res.status(400).json({ error });
}
patch.amount = amount; patch.amount = amount;
} }
if (note !== undefined) { if (note !== undefined) {
if (typeof note !== 'string') return res.status(400).json({ error: 'Neplatná poznámka' }); if (typeof note !== 'string') return res.status(400).json({ error: 'Neplatná poznámka' });
const { maxNoteLength } = getGroupLimits();
if (note.length > maxNoteLength) {
return res.status(400).json({ error: `Poznámka může mít nejvýše ${maxNoteLength} znaků` });
}
patch.note = note; patch.note = note;
} }
if (surchargeText !== undefined) { if (surchargeText !== undefined) {
if (typeof surchargeText !== 'string') return res.status(400).json({ error: 'Neplatný text příplatku' }); if (typeof surchargeText !== 'string') return res.status(400).json({ error: 'Neplatný text příplatku' });
const { maxSurchargeTextLength } = getGroupLimits();
if (surchargeText.length > maxSurchargeTextLength) {
return res.status(400).json({ error: `Popis příplatku může mít nejvýše ${maxSurchargeTextLength} znaků` });
}
patch.surchargeText = surchargeText; patch.surchargeText = surchargeText;
} }
if (surchargeAmount !== undefined) { if (surchargeAmount !== undefined) {
if (!Number.isInteger(surchargeAmount) || surchargeAmount < 0) { const error = invalidAmount(surchargeAmount, 'Příplatek');
return res.status(400).json({ error: 'Neplatná výše příplatku' }); if (error) return res.status(400).json({ error });
}
patch.surchargeAmount = surchargeAmount; patch.surchargeAmount = surchargeAmount;
} }
try { try {
@@ -124,21 +145,26 @@ router.post("/updateFees", async (req: Request, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const { id, fees, shipping, tip, discountType, discountValue } = req.body ?? {}; const { id, fees, shipping, tip, discountType, discountValue } = req.body ?? {};
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' }); if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
if (fees !== undefined && (!Number.isInteger(fees) || fees < 0)) { for (const [value, label] of [[fees, 'Poplatky'], [shipping, 'Doprava'], [tip, 'Spropitné']] as const) {
return res.status(400).json({ error: 'Neplatná výše poplatků' }); const error = value !== undefined ? invalidAmount(value, label) : undefined;
} if (error) return res.status(400).json({ error });
if (shipping !== undefined && (!Number.isInteger(shipping) || shipping < 0)) {
return res.status(400).json({ error: 'Neplatná výše dopravy' });
}
if (tip !== undefined && (!Number.isInteger(tip) || tip < 0)) {
return res.status(400).json({ error: 'Neplatná výše spropitného' });
} }
if (discountType !== undefined && discountType !== '' && !['percent', 'fixed'].includes(discountType)) { if (discountType !== undefined && discountType !== '' && !['percent', 'fixed'].includes(discountType)) {
return res.status(400).json({ error: 'Neplatný typ slevy' }); return res.status(400).json({ error: 'Neplatný typ slevy' });
} }
if (discountValue !== undefined && (!Number.isInteger(discountValue) || discountValue < 0)) { if (discountValue !== undefined) {
if (!Number.isInteger(discountValue) || discountValue < 0) {
return res.status(400).json({ error: 'Neplatná výše slevy' }); return res.status(400).json({ error: 'Neplatná výše slevy' });
} }
// Procentní sleva nad 100 % by vedla k záporným částkám
if (discountType === 'percent' && discountValue > 100) {
return res.status(400).json({ error: 'Procentní sleva může být nejvýše 100 %' });
}
const { maxAmountKc } = getGroupLimits();
if (discountType !== 'percent' && discountValue > maxAmountKc * 100) {
return res.status(400).json({ error: `Sleva může být nejvýše ${maxAmountKc} Kč` });
}
}
try { try {
const data = await updateGroupFees(login, id, fees, shipping, tip, discountType, discountValue); const data = await updateGroupFees(login, id, fees, shipping, tip, discountType, discountValue);
broadcastExtra(data); broadcastExtra(data);
@@ -168,7 +194,7 @@ router.post("/setTracking", async (req: Request, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const { id, shareUrl } = req.body ?? {}; const { id, shareUrl } = req.body ?? {};
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' }); if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
if (shareUrl !== undefined && typeof shareUrl !== 'string') { if (shareUrl !== undefined && (typeof shareUrl !== 'string' || shareUrl.length > getGroupLimits().maxUrlLength)) {
return res.status(400).json({ error: 'Neplatný odkaz na sledování objednávky' }); return res.status(400).json({ error: 'Neplatný odkaz na sledování objednávky' });
} }
try { try {
+72
View File
@@ -1,5 +1,7 @@
import express from "express"; import express from "express";
import { getStores, addStore, updateStore, removeStore } from "../stores"; import { getStores, addStore, updateStore, removeStore } from "../stores";
import { getStoreMenu, getStoreMenuHistory, getStoreMenuStatus, refreshStoreMenu, renameStoreMenus } from "../storeMenus";
import { StoreMenuProvider } from "../../../types/gen/types.gen";
const router = express.Router(); const router = express.Router();
@@ -10,6 +12,69 @@ router.get("/", async (_req, res, next) => {
} catch (e: any) { next(e); } } catch (e: any) { next(e); }
}); });
router.get("/menu", async (req, res, next) => {
const name = req.query.name;
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
}
try {
res.status(200).json(await getStoreMenu(name));
} catch (e: any) {
if (e.message === 'Obchod nebyl nalezen') {
return res.status(404).json({ error: e.message });
}
next(e);
}
});
router.get("/menu/status", async (_req, res, next) => {
try {
res.status(200).json(await getStoreMenuStatus());
} catch (e: any) { next(e); }
});
router.get("/menu/history", async (req, res, next) => {
const name = req.query.name;
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
}
try {
res.status(200).json(await getStoreMenuHistory(name));
} catch (e: any) {
if (e.message === 'Obchod nebyl nalezen') {
return res.status(404).json({ error: e.message });
}
next(e);
}
});
router.post("/menu/refresh", async (req, res, next) => {
const { name, provider, heslo } = req.body ?? {};
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
}
if (!Object.values<string>(StoreMenuProvider).includes(provider)) {
return res.status(400).json({ error: 'Neplatná dovozová služba' });
}
if (!heslo || typeof heslo !== 'string') {
return res.status(400).json({ error: 'Nebylo předáno heslo' });
}
try {
res.status(200).json(await refreshStoreMenu(name, provider, heslo));
} catch (e: any) {
if (e.message === 'UNAUTHORIZED') {
return res.status(403).json({ error: 'Nesprávné heslo' });
}
if (e.message === 'Obchod nebyl nalezen') {
return res.status(404).json({ error: e.message });
}
if (e.message?.startsWith('Obchod nemá URL')) {
return res.status(400).json({ error: e.message });
}
next(e);
}
});
router.post("/add", async (req, res, next) => { router.post("/add", async (req, res, next) => {
const { name, heslo, urls } = req.body ?? {}; const { name, heslo, urls } = req.body ?? {};
if (!name || typeof name !== 'string') { if (!name || typeof name !== 'string') {
@@ -48,6 +113,12 @@ router.post("/update", async (req, res, next) => {
} }
try { try {
const stores = await updateStore(name, heslo, newName ?? undefined, urls ?? undefined); const stores = await updateStore(name, heslo, newName ?? undefined, urls ?? undefined);
// Uložené nabídky jsou vázané na název obchodu
const renamed = newName && stores.find(s => s.name.toLowerCase() === newName.trim().toLowerCase());
const original = stores.find(s => s.name.toLowerCase() === name.trim().toLowerCase());
if (renamed && !original) {
await renameStoreMenus(name.trim(), renamed.name);
}
res.status(200).json(stores); res.status(200).json(stores);
} catch (e: any) { } catch (e: any) {
if (e.message === 'UNAUTHORIZED') { if (e.message === 'UNAUTHORIZED') {
@@ -67,6 +138,7 @@ router.post("/delete", async (req, res, next) => {
} }
try { try {
const stores = await removeStore(name, heslo); const stores = await removeStore(name, heslo);
await renameStoreMenus(name.trim());
res.status(200).json(stores); res.status(200).json(stores);
} catch (e: any) { } catch (e: any) {
if (e.message === 'UNAUTHORIZED') { if (e.message === 'UNAUTHORIZED') {
+59 -7
View File
@@ -9,6 +9,24 @@ import { ClientData, DepartureTime, LunchChoice, LunchChoices, MealSlot, PizzaDa
const storage = getStorage(); const storage = getStorage();
const MENU_PREFIX = 'menu'; const MENU_PREFIX = 'menu';
/**
* Dočasně uzavřené podniky – nezobrazují se na hlavní stránce, nelze je zvolit
* a neprovádí se pro ně načítání menu z jejich webu.
*/
export const DISABLED_RESTAURANTS: readonly Restaurant[] = [
Restaurant.SENKSERIKOVA, // dočasně uzavřen
];
/** Vrátí true, pokud je podnik dočasně vyřazen z nabídky. */
export function isRestaurantDisabled(restaurant: Restaurant | LunchChoice): boolean {
return DISABLED_RESTAURANTS.includes(restaurant as Restaurant);
}
/** Vrátí podniky s menu, které nejsou dočasně vyřazeny. */
export function getActiveRestaurants(): Restaurant[] {
return (Object.values(Restaurant) as Restaurant[]).filter(r => !isRestaurantDisabled(r));
}
function getDataKey(date: Date, slot?: MealSlot): string { function getDataKey(date: Date, slot?: MealSlot): string {
const base = formatDate(date); const base = formatDate(date);
return slot === MealSlot.EXTRA ? `${base}_extra` : base; return slot === MealSlot.EXTRA ? `${base}_extra` : base;
@@ -55,13 +73,12 @@ export async function getData(date?: Date, slot?: MealSlot): Promise<ClientData>
if (slot === MealSlot.EXTRA) { if (slot === MealSlot.EXTRA) {
clientData.stores = await getStores(); clientData.stores = await getStores();
} else { } else {
clientData.menus = { // Dočasně vyřazené podniky se do odpovědi vůbec nezahrnují (klient je pak nezobrazí)
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date), const menus: RestaurantDayMenuMap = {};
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date), for (const restaurant of getActiveRestaurants()) {
TECHTOWER: await getRestaurantMenu('TECHTOWER', date), menus[restaurant] = await getRestaurantMenu(restaurant, date);
ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
} }
clientData.menus = menus;
// Očistí volby jídel odkazující na indexy, které v aktuálním menu již neexistují. // Očistí volby jídel odkazující na indexy, které v aktuálním menu již neexistují.
// Pokud se v odpovědi něco změnilo, promítne očistu i do uložených dat (self-heal). // Pokud se v odpovědi něco změnilo, promítne očistu i do uložených dat (self-heal).
if (pruneStaleFoodChoices(clientData.choices, clientData.menus)) { if (pruneStaleFoodChoices(clientData.choices, clientData.menus)) {
@@ -236,7 +253,8 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, for
const usedDate = date ?? getToday(); const usedDate = date ?? getToday();
const dayOfWeekIndex = getDayOfWeekIndex(usedDate); const dayOfWeekIndex = getDayOfWeekIndex(usedDate);
const now = new Date().getTime(); const now = new Date().getTime();
if (getIsWeekend(usedDate)) { // O víkendu a u dočasně vyřazených podniků menu vůbec nenačítáme
if (getIsWeekend(usedDate) || isRestaurantDisabled(restaurant)) {
return { return {
lastUpdate: now, lastUpdate: now,
closed: true, closed: true,
@@ -468,6 +486,9 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
await initIfNeeded(usedDate, slot); await initIfNeeded(usedDate, slot);
let data = await getClientData(usedDate, slot); let data = await getClientData(usedDate, slot);
validateTrusted(data, login, trusted); validateTrusted(data, login, trusted);
if (isRestaurantDisabled(locationKey)) {
throw new Error(`Podnik ${locationKey} je dočasně uzavřen`);
}
await validateFoodIndex(locationKey, foodIndex, date); await validateFoodIndex(locationKey, foodIndex, date);
if (!slot || slot === MealSlot.OBED) { if (!slot || slot === MealSlot.OBED) {
@@ -569,6 +590,37 @@ export async function updateNote(login: string, trusted: boolean, note?: string,
}); });
} }
/** Volby, u kterých nemá smysl zadávat cenu ručně — cena je dána objednávkou nebo se neobědvá. */
export const CHOICES_WITHOUT_PRICE: LunchChoice[] = [LunchChoice.OBJEDNAVAM, LunchChoice.PIZZA, LunchChoice.NEOBEDVAM, LunchChoice.ROZHODUJI];
/**
* Nastaví nebo odstraní cenu oběda u aktuálně vybrané možnosti uživatele.
*
* @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @param price cena v haléřích, při nevyplnění se cena odstraní
* @param date datum, ke kterému se volba vztahuje
*/
export async function updatePrice(login: string, trusted: boolean, price?: number, date?: Date) {
if (price != null && (!Number.isInteger(price) || price < 0)) {
throw new Error(`Neplatná cena: ${price}`);
}
const usedDate = date ?? getToday();
const snapshot = await getClientData(usedDate);
validateTrusted(snapshot, login, trusted);
return storage.updateData<ClientData>(getDataKey(usedDate), (current) => {
const data = current ?? getEmptyData(date);
const userEntry = Object.entries(data.choices ?? {}).find(entry => entry[1][login] != null);
if (!userEntry) throw new Error("Cenu lze zadat jen k vybrané volbě");
if (CHOICES_WITHOUT_PRICE.includes(userEntry[0] as LunchChoice)) {
throw new Error("K této volbě nelze zadat cenu");
}
if (price == null) delete userEntry[1][login].price;
else userEntry[1][login].price = price;
return data;
});
}
/** /**
* Aktualizuje preferovaný čas odchodu strávníka. * Aktualizuje preferovaný čas odchodu strávníka.
* *
+505
View File
@@ -0,0 +1,505 @@
import axios from "axios";
import crypto from "crypto";
import getStorage from "./storage";
import { getStoreDishes, getStores } from "./stores";
import { Store, StoreMenu, StoreMenuDish, StoreMenuProvider, StoreMenuSource, StorePriceChange, StorePriceHistory, StorePriceHistoryEntry, StoreScrapeInfo, StoreScrapeState, StoreScrapeStatus } from "../../types/gen/types.gen";
const storage = getStorage();
/**
* Klíč ve storage s nabídkami všech obchodů: název obchodu → záznamy dle služby.
* Nabídka se stahuje jen tehdy, když pro službu ještě žádná není (žádná expirace),
* nebo při ručním obnovení adminem.
*/
const STORE_MENUS_KEY = 'store_menus';
/** Historie cen: název obchodu → služba → verze (nejnovější první). Ukládají se jen rozdíly. */
const STORE_MENU_HISTORY_KEY = 'store_menu_history';
/** Kolik verzí historie cen se drží na obchod a službu. */
const MAX_HISTORY_ENTRIES = 200;
/** Kolikrát se po sobě automaticky zkusí stáhnout nabídku, než se služba pro obchod zablokuje. */
const MAX_AUTO_ATTEMPTS = 2;
/** Minimální odstup automatických pokusů po selhání (tj. nejvýše ~2× týdně). */
const RETRY_AFTER_MS = 3 * 24 * 60 * 60 * 1000;
/** Minimální rozestup požadavků na jednu službu — Bolt po ~24 rychlých dotazech vrací 429. */
const MIN_REQUEST_INTERVAL_MS = process.env.NODE_ENV === 'test' ? 0 : 1500;
/** Pauza služby po odpovědi 429, pokud služba neuvede Retry-After. */
const RATE_LIMIT_PAUSE_MS = 5 * 60 * 1000;
const BOLT_MENU_URL = 'https://deliveryuser.live.boltsvc.net/deliveryClient/public/getMenuCategories';
const WOLT_MENU_URL = 'https://consumer-api.wolt.com/consumer-api/consumer-assortment/v1/venues/slug/';
const DEVICE_ID = crypto.randomUUID();
/**
* Kategorie, které nejsou hlavním jídlem — do kola štěstí je nezahrnujeme
* (polévky, nápoje, omáčky/dipy, přílohy, dezerty, přídavky, příbory apod.).
*/
const EXCLUDED_CATEGORY_REGEX = /pol[eé]vk|soup|n[aá]poj|drink|nealko|alkohol|\bpit[ií]|limon[aá]d|\bpiv[oa]\b|\bv[ií]n[oa]\b|\bk[aá]v[ay]\b|coffee|om[aá][cč]k|sauce|\bdip|p[řr][ií]loh|\bsides?\b|p[řr]edkrm|starter|appetizer|dezert|dessert|sladk[eé]|p[řr][ií]davk|p[řr][ií]plat|\badd-?ons?\b|\bextras?\b|p[řr][ií]bor|cutlery|\bobal|krabic|d[ěe]tsk|chutney|nakl[aá]dan|raita|\bnaa?n\b|parant?ha|chl[ée]b|pe[čc]ivo|^\W*(basmati\s+)?r[ýy][žz]e\W*$/i;
/** Jednotlivé položky mimo vyloučené kategorie, které nejsou hlavním jídlem (polévky, příplatky). */
const SOUP_ITEM_REGEX = /pol[eé]vk|v[ýy]var|soup|kulajd|gul[aá][sš]ovk|dr[šs][ťt]kov[aá]$|[čc]esne[čc]k|bor[šs][čc]|p[řr][ií]platek/i;
/** Uložený stav stahování nabídky obchodu od jedné služby. */
type ProviderRecord = {
/** URL podniku u služby — při změně URL se záznam zahodí */
url: string,
dishes?: StoreMenuDish[],
lastSuccessAt?: string,
lastAttemptAt?: string,
/** Počet neúspěšných pokusů od posledního úspěchu (resp. ručního obnovení) */
failures: number,
blocked: boolean,
lastError?: string,
};
type StoreMenuRecord = Partial<Record<StoreMenuProvider, ProviderRecord>>;
type StoreMenusData = Record<string, StoreMenuRecord>;
type StoreMenuHistoryData = Record<string, Partial<Record<StoreMenuProvider, StorePriceHistoryEntry[]>>>;
const PROVIDERS: StoreMenuProvider[] = [StoreMenuProvider.BOLT, StoreMenuProvider.WOLT];
/** Vytáhne ID podniku z URL Bolt Food (…/p/67293-ruds-pizza-skvrnany/). */
export function parseBoltProviderId(url: string): string | undefined {
try {
const u = new URL(url);
if (!u.hostname.endsWith('bolt.eu')) return undefined;
return u.pathname.match(/\/p\/(\d+)/)?.[1];
} catch {
return undefined;
}
}
/** Vytáhne slug podniku z URL Wolt (…/restaurant/ruds-pizza-prazska). */
export function parseWoltSlug(url: string): string | undefined {
try {
const u = new URL(url);
if (!u.hostname.endsWith('wolt.com')) return undefined;
return u.pathname.match(/\/(?:restaurant|venue)\/([^/?#]+)/)?.[1];
} catch {
return undefined;
}
}
const isMainDish = (name: string, category?: string) =>
!(category && EXCLUDED_CATEGORY_REGEX.test(category)) && !SOUP_ITEM_REGEX.test(name);
/** Odstraní duplicity (stejný název) a prázdné názvy. */
function dedupe(dishes: StoreMenuDish[]): StoreMenuDish[] {
const seen = new Set<string>();
return dishes.filter(d => {
const key = d.name.trim().toLowerCase();
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}
type BoltItem = {
type: string,
id: number,
parent_id: number | null,
index?: number,
name?: { value?: string },
price?: { value?: number },
availability?: string,
};
/** Stáhne nabídku podniku z Bolt Food — dostupné položky, hlavní jídla označená `isMain`. */
export async function fetchBoltMenu(providerId: string): Promise<StoreMenuDish[]> {
const res = await axios.get(BOLT_MENU_URL, {
params: {
provider_id: providerId,
version: 'FW.1.113',
language: 'cs-CZ',
country: 'cz',
device_name: 'web',
device_os_version: 'web',
deviceType: 'web',
session_id: DEVICE_ID,
distinct_id: `$device:${DEVICE_ID}`,
deviceId: DEVICE_ID,
},
timeout: 10_000,
});
if (res.data?.code !== 0) {
throw new Error(`Bolt API vrátilo kód ${res.data?.code}: ${res.data?.message}`);
}
const items: Record<string, BoltItem> = res.data?.data?.items ?? {};
const all = Object.values(items);
const categories = new Map(all.filter(i => i.type === 'category').map(c => [c.id, c]));
return dedupe(all
.filter(i => i.type === 'dish' && i.name?.value && i.availability !== 'out_of_stock')
.map(i => {
const category = i.parent_id != null ? categories.get(i.parent_id) : undefined;
return { dish: i, category };
})
.sort((a, b) => (a.category?.index ?? 0) - (b.category?.index ?? 0) || (a.dish.index ?? 0) - (b.dish.index ?? 0))
.map(({ dish, category }) => ({
name: dish.name!.value!.trim(),
price: dish.price?.value,
category: category?.name?.value?.trim(),
isMain: isMainDish(dish.name!.value!, category?.name?.value),
})));
}
type WoltAssortment = {
categories?: { name?: string, item_ids?: string[], subcategories?: { name?: string, item_ids?: string[] }[] }[],
items?: { id: string, name?: string, price?: number, disabled_info?: unknown, is_cutlery?: boolean }[],
};
/** Stáhne nabídku podniku z Wolt — dostupné položky, hlavní jídla označená `isMain`. */
export async function fetchWoltMenu(slug: string): Promise<StoreMenuDish[]> {
const res = await axios.get<WoltAssortment>(`${WOLT_MENU_URL}${encodeURIComponent(slug)}/assortment`, {
params: { language: 'cs' },
headers: { Accept: 'application/json', 'Accept-Language': 'cs' },
timeout: 10_000,
});
const items = new Map((res.data?.items ?? []).map(i => [i.id, i]));
const dishes: StoreMenuDish[] = [];
for (const category of res.data?.categories ?? []) {
const groups = [{ name: category.name, item_ids: category.item_ids }, ...(category.subcategories ?? [])];
for (const group of groups) {
const categoryName = [category.name, group.name !== category.name ? group.name : undefined].filter(Boolean).join(' / ');
for (const id of group.item_ids ?? []) {
const item = items.get(id);
if (!item?.name || item.disabled_info || item.is_cutlery) continue;
dishes.push({
name: item.name.trim(),
// Wolt uvádí ceny v haléřích
price: item.price != null ? Math.round(item.price / 100) : undefined,
category: categoryName.trim() || undefined,
isMain: isMainDish(item.name, categoryName),
});
}
}
}
return dedupe(dishes);
}
/** Vrátí URL podniku u dané služby (první odpovídající). */
function providerUrl(store: Store, provider: StoreMenuProvider): string | undefined {
const parse = provider === StoreMenuProvider.BOLT ? parseBoltProviderId : parseWoltSlug;
return (store.urls ?? []).find(url => parse(url));
}
/** Služba odmítla požadavek kvůli omezení počtu dotazů (HTTP 429) — nejde o chybu podniku. */
export class RateLimitedError extends Error {
constructor(provider: StoreMenuProvider, public readonly until: number) {
super(`${provider === StoreMenuProvider.BOLT ? 'Bolt Food' : 'Wolt'} omezil počet dotazů (HTTP 429), další pokus po ${new Date(until).toLocaleTimeString('cs-CZ')}`);
}
}
/** Fronta požadavků každé služby (sériově, s rozestupem) a případná pauza po 429. */
const providerQueue = new Map<StoreMenuProvider, Promise<unknown>>();
const providerPausedUntil = new Map<StoreMenuProvider, number>();
/** Délka pauzy dle hlavičky Retry-After (sekundy nebo datum), jinak výchozí. */
function retryAfterMs(header: unknown): number {
const value = Array.isArray(header) ? header[0] : header;
if (typeof value === 'string' || typeof value === 'number') {
const seconds = Number(value);
if (Number.isFinite(seconds) && seconds > 0) return seconds * 1000;
const date = Date.parse(String(value));
if (!Number.isNaN(date) && date > Date.now()) return date - Date.now();
}
return RATE_LIMIT_PAUSE_MS;
}
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
/**
* Stáhne nabídku přes frontu dané služby: požadavky jdou po jednom s rozestupem
* {@link MIN_REQUEST_INTERVAL_MS}; po 429 se služba pozastaví a další požadavky
* rovnou selžou s {@link RateLimitedError} (nezatěžujeme službu dalšími dotazy).
*/
function fetchProviderMenu(provider: StoreMenuProvider, url: string): Promise<StoreMenuDish[]> {
const run = async () => {
const pausedUntil = providerPausedUntil.get(provider) ?? 0;
if (Date.now() < pausedUntil) {
throw new RateLimitedError(provider, pausedUntil);
}
try {
return await (provider === StoreMenuProvider.BOLT
? fetchBoltMenu(parseBoltProviderId(url)!)
: fetchWoltMenu(parseWoltSlug(url)!));
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 429) {
const until = Date.now() + retryAfterMs(e.response.headers?.['retry-after']);
providerPausedUntil.set(provider, until);
throw new RateLimitedError(provider, until);
}
throw e;
} finally {
if (MIN_REQUEST_INTERVAL_MS > 0) await sleep(MIN_REQUEST_INTERVAL_MS);
}
};
const previous = providerQueue.get(provider) ?? Promise.resolve();
const result = previous.catch(() => undefined).then(run);
providerQueue.set(provider, result.catch(() => undefined));
return result;
}
/** Pro testy — zruší pauzy a frontu služeb. */
export function resetProviderThrottle(): void {
providerQueue.clear();
providerPausedUntil.clear();
}
/** Záznam služby pro aktuální URL obchodu — při změně URL začíná stav nanovo. */
function currentRecord(record: StoreMenuRecord | undefined, provider: StoreMenuProvider, url: string): ProviderRecord {
const existing = record?.[provider];
return existing && existing.url === url ? existing : { url, failures: 0, blocked: false };
}
function scrapeState(rec: ProviderRecord): StoreScrapeState {
if (rec.dishes?.length) return StoreScrapeState.OK;
if (rec.blocked) return StoreScrapeState.BLOCKED;
return rec.failures > 0 ? StoreScrapeState.FAILED : StoreScrapeState.PENDING;
}
/** Smí se nabídka od služby stáhnout automaticky? Jen pokud chybí a služba není zablokovaná. */
function canAutoScrape(rec: ProviderRecord, now: number): boolean {
if (rec.dishes?.length || rec.blocked) return false;
return !rec.lastAttemptAt || now - Date.parse(rec.lastAttemptAt) >= RETRY_AFTER_MS;
}
/**
* Rozdíl dvou verzí nabídky: změněné ceny, nové a odebrané položky (porovnání dle názvu).
*/
export function diffMenus(before: StoreMenuDish[], after: StoreMenuDish[]): StorePriceChange[] {
const key = (d: StoreMenuDish) => d.name.trim().toLowerCase();
const old = new Map(before.map(d => [key(d), d]));
const changes: StorePriceChange[] = [];
for (const dish of after) {
const prev = old.get(key(dish));
old.delete(key(dish));
if (!prev) {
changes.push({ name: dish.name, ...(dish.price != null && { after: dish.price }) });
} else if (prev.price !== dish.price) {
changes.push({ name: dish.name, ...(prev.price != null && { before: prev.price }), ...(dish.price != null && { after: dish.price }) });
}
}
for (const removed of old.values()) {
changes.push({ name: removed.name, ...(removed.price != null && { before: removed.price }) });
}
return changes;
}
/** Zapíše novou verzi do historie cen — výchozí stav při prvním stažení, dále jen při změně. */
async function recordPriceHistory(storeName: string, provider: StoreMenuProvider, previous: ProviderRecord | undefined, dishes: StoreMenuDish[], at: string): Promise<void> {
const before = previous?.dishes;
const entry: StorePriceHistoryEntry = before?.length
? { at, itemCount: dishes.length, changes: diffMenus(before, dishes) }
: { at, initial: true, itemCount: dishes.length, changes: [] };
if (!entry.initial && entry.changes.length === 0) return;
await storage.updateData<StoreMenuHistoryData>(STORE_MENU_HISTORY_KEY, current => {
const all = current ?? {};
let existing = all[storeName]?.[provider] ?? [];
// Nabídka stažená před zavedením historie — její původní stav uložíme jako výchozí verzi
if (existing.length === 0 && !entry.initial && previous?.lastSuccessAt) {
existing = [{ at: previous.lastSuccessAt, initial: true, itemCount: before!.length, changes: [] }];
}
const entries = [entry, ...existing].slice(0, MAX_HISTORY_ENTRIES);
all[storeName] = { ...all[storeName], [provider]: entries };
return all;
});
}
/**
* Provede jeden pokus o stažení nabídky a výsledek zapíše do storage.
* Při neúspěchu ponechá případnou dříve staženou nabídku; po {@link MAX_AUTO_ATTEMPTS}
* neúspěších v řadě službu pro obchod zablokuje (automaticky se už nezkouší).
*/
async function attemptScrape(storeName: string, provider: StoreMenuProvider, url: string, manual: boolean): Promise<ProviderRecord> {
const now = new Date().toISOString();
let result: Partial<ProviderRecord>;
try {
const dishes = await fetchProviderMenu(provider, url);
if (!dishes.some(d => d.isMain)) {
throw new Error('Nabídka neobsahuje žádná hlavní jídla');
}
result = { dishes, lastSuccessAt: now, failures: 0, blocked: false, lastError: undefined };
} catch (e: any) {
console.warn(`Nabídka podniku "${storeName}" (${provider}): stažení selhalo — ${e?.message ?? e}`);
result = { lastError: String(e?.message ?? e).slice(0, 300) };
if (e instanceof RateLimitedError) {
// Omezení dotazů není chyba podniku — nepočítá se do pokusů ani nespouští 3denní odklad
const data = await storage.updateData<StoreMenusData>(STORE_MENUS_KEY, current => {
const all = current ?? {};
const rec = currentRecord(all[storeName], provider, url);
all[storeName] = { ...all[storeName], [provider]: { ...rec, lastError: result.lastError } };
return all;
});
return data[storeName]![provider]!;
}
}
let previousRecord: ProviderRecord | undefined;
const data = await storage.updateData<StoreMenusData>(STORE_MENUS_KEY, current => {
const all = current ?? {};
const rec = currentRecord(all[storeName], provider, url);
previousRecord = rec;
const next: ProviderRecord = { ...rec, ...result, lastAttemptAt: now };
if (!result.dishes) {
next.failures = rec.failures + 1;
// Ruční obnovení službu nezablokuje — admin to může zkoušet dál
next.blocked = manual ? rec.blocked : next.failures >= MAX_AUTO_ATTEMPTS;
}
if (!next.lastError) delete next.lastError;
all[storeName] = { ...all[storeName], [provider]: next };
return all;
});
if (result.dishes) {
await recordPriceHistory(storeName, provider, previousRecord, result.dishes, now);
}
return data[storeName]![provider]!;
}
/** Vrátí historii cen obchodu po službách (nejnovější verze první). */
export async function getStoreMenuHistory(name: string): Promise<StorePriceHistory> {
const store = (await getStores()).find(s => s.name.toLowerCase() === name.trim().toLowerCase());
if (!store) {
throw new Error('Obchod nebyl nalezen');
}
const [historyData, menus] = await Promise.all([
storage.getData<StoreMenuHistoryData>(STORE_MENU_HISTORY_KEY),
storage.getData<StoreMenusData>(STORE_MENUS_KEY),
]);
const history = historyData?.[store.name] ?? {};
const record = menus?.[store.name];
return {
store: store.name,
providers: PROVIDERS
.map(provider => {
const entries = history[provider] ?? [];
const rec = record?.[provider];
// Nabídka stažená dřív, než existovala historie — její stav je výchozí verzí
if (entries.length === 0 && rec?.dishes?.length && rec.lastSuccessAt) {
return { provider, entries: [{ at: rec.lastSuccessAt, initial: true, itemCount: rec.dishes.length, changes: [] }] };
}
return { provider, entries };
})
.filter(p => p.entries.length > 0),
};
}
/** Souběžné požadavky na stejný obchod sdílí jedno stahování. */
const inFlight = new Map<string, Promise<StoreMenu>>();
/**
* Vrátí nabídku hlavních jídel obchodu pro kolo štěstí. Použije uloženou nabídku
* z Bolt Food (přednostně) nebo Wolt; pokud žádná není, zkusí ji stáhnout (s omezeným
* počtem pokusů). Jinak použije jídla z historie objednávek.
*
* @param name název obchodu ze seznamu povolených
*/
export async function getStoreMenu(name: string): Promise<StoreMenu> {
const store = (await getStores()).find(s => s.name.toLowerCase() === name.trim().toLowerCase());
if (!store) {
throw new Error('Obchod nebyl nalezen');
}
const pending = inFlight.get(store.name);
if (pending) return pending;
const promise = resolveStoreMenu(store).finally(() => inFlight.delete(store.name));
inFlight.set(store.name, promise);
return promise;
}
async function resolveStoreMenu(store: Store): Promise<StoreMenu> {
const record = (await storage.getData<StoreMenusData>(STORE_MENUS_KEY))?.[store.name];
const candidates = PROVIDERS
.map(provider => ({ provider, url: providerUrl(store, provider) }))
.filter((c): c is { provider: StoreMenuProvider, url: string } => !!c.url)
.map(c => ({ ...c, rec: currentRecord(record, c.provider, c.url) }));
const stored = candidates.find(c => c.rec.dishes?.length);
if (stored) {
return { store: store.name, source: stored.provider, dishes: stored.rec.dishes! };
}
const now = Date.now();
for (const c of candidates.filter(c => canAutoScrape(c.rec, now))) {
const rec = await attemptScrape(store.name, c.provider, c.url, false);
if (rec.dishes?.length) {
return { store: store.name, source: c.provider, dishes: rec.dishes };
}
}
// Záloha — historie objednávek
const history = (await getStoreDishes()).find(s => s.store === store.name)?.dishes ?? [];
return {
store: store.name,
source: history.length > 0 ? StoreMenuSource.HISTORY : StoreMenuSource.NONE,
dishes: history.map(dishName => ({ name: dishName, isMain: true })),
};
}
function toStatus(provider: StoreMenuProvider, rec: ProviderRecord): StoreScrapeStatus {
return {
provider,
url: rec.url,
state: scrapeState(rec),
dishCount: rec.dishes?.filter(d => d.isMain !== false).length ?? 0,
failures: rec.failures,
...(rec.lastSuccessAt && { lastSuccessAt: rec.lastSuccessAt }),
...(rec.lastAttemptAt && { lastAttemptAt: rec.lastAttemptAt }),
...(rec.lastError && { lastError: rec.lastError }),
};
}
function scrapeInfo(store: Store, record: StoreMenuRecord | undefined): StoreScrapeInfo {
const providers: StoreScrapeStatus[] = [];
for (const provider of PROVIDERS) {
const url = providerUrl(store, provider);
if (url) providers.push(toStatus(provider, currentRecord(record, provider, url)));
}
return { store: store.name, providers };
}
/** Vrátí stav stahování nabídek pro všechny obchody (pro správu obchodů). */
export async function getStoreMenuStatus(): Promise<StoreScrapeInfo[]> {
const [stores, data] = await Promise.all([getStores(), storage.getData<StoreMenusData>(STORE_MENUS_KEY)]);
return stores.map(store => scrapeInfo(store, data?.[store.name]));
}
/**
* Ručně znovu stáhne nabídku obchodu od dané služby (admin). Při úspěchu nahradí
* uloženou nabídku a zruší zablokování; při neúspěchu ponechá původní nabídku.
*/
export async function refreshStoreMenu(name: string, provider: StoreMenuProvider, heslo: string): Promise<StoreScrapeInfo> {
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminPassword || heslo !== adminPassword) {
throw new Error('UNAUTHORIZED');
}
if (!PROVIDERS.includes(provider)) {
throw new Error('Nepodporovaná služba');
}
const store = (await getStores()).find(s => s.name.toLowerCase() === name.trim().toLowerCase());
if (!store) {
throw new Error('Obchod nebyl nalezen');
}
const url = providerUrl(store, provider);
if (!url) {
throw new Error(`Obchod nemá URL pro ${provider === StoreMenuProvider.BOLT ? 'Bolt Food' : 'Wolt'}`);
}
await attemptScrape(store.name, provider, url, true);
const data = await storage.getData<StoreMenusData>(STORE_MENUS_KEY);
return scrapeInfo(store, data?.[store.name]);
}
/** Přejmenuje/odebere uložené nabídky obchodu (volá se při úpravě seznamu obchodů). */
export async function renameStoreMenus(oldName: string, newName?: string): Promise<void> {
await storage.updateData<StoreMenuHistoryData>(STORE_MENU_HISTORY_KEY, current => {
const all = current ?? {};
const key = Object.keys(all).find(k => k.toLowerCase() === oldName.trim().toLowerCase());
if (!key) return all;
const record = all[key];
delete all[key];
if (newName) all[newName] = record;
return all;
});
await storage.updateData<StoreMenusData>(STORE_MENUS_KEY, current => {
const all = current ?? {};
const key = Object.keys(all).find(k => k.toLowerCase() === oldName.trim().toLowerCase());
if (!key) return all;
const record = all[key];
delete all[key];
if (newName) all[newName] = record;
return all;
});
}
+57 -1
View File
@@ -1,4 +1,4 @@
import { Store } from "../../types/gen/types.gen"; import { ClientData, Store } from "../../types/gen/types.gen";
import getStorage from "./storage"; import getStorage from "./storage";
const storage = getStorage(); const storage = getStorage();
@@ -151,3 +151,59 @@ export async function removeStore(name: string, heslo: string): Promise<Store[]>
await storage.setData(STORES_KEY, updated); await storage.setData(STORES_KEY, updated);
return updated; return updated;
} }
/** Maximální počet jídel vrácených pro jeden obchod v {@link getStoreDishes}. */
const MAX_DISHES_PER_STORE = 40;
/** Rozpoznání polévek v poznámkách objednávek — ty se do kola štěstí nezahrnují. */
/** Jídla historicky objednaná u daného obchodu. */
export type StoreDishes = { store: string, dishes: string[] };
const SOUP_REGEX = /pol[eé]vk|v[ýy]var|soup|\bpho\b|ramen|tom yum|kulajda|gul[aá][sš]ovka/i;
/**
* Vrátí pro každý povolený obchod seznam jídel, která si u něj uživatelé v minulosti
* objednali (z poznámek členů skupinových objednávek). Slouží jako záloha pro druhé
* kolo „kola štěstí", pokud se nabídku z Bolt Food/Wolt nepodaří stáhnout.
* Jídla jsou seřazena dle četnosti, bez duplicit a bez polévek.
*/
export async function getStoreDishes(): Promise<StoreDishes[]> {
const stores = await getStores();
const counts = new Map<string, Map<string, { name: string, count: number }>>();
for (const store of stores) {
counts.set(store.name.toLowerCase(), new Map());
}
const keys = await storage.listKeys('_extra');
for (const key of keys) {
if (!key.endsWith('_extra')) continue;
const data = await storage.getData<ClientData>(key);
for (const group of data?.groups ?? []) {
const dishes = counts.get(group.name?.toLowerCase());
if (!dishes) continue;
for (const member of Object.values(group.members ?? {})) {
const dish = normalizeDish(member?.note);
if (!dish || SOUP_REGEX.test(dish)) continue;
const dishKey = dish.toLowerCase();
const existing = dishes.get(dishKey);
if (existing) existing.count++;
else dishes.set(dishKey, { name: dish, count: 1 });
}
}
}
return stores.map(store => ({
store: store.name,
dishes: [...counts.get(store.name.toLowerCase())!.values()]
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
.slice(0, MAX_DISHES_PER_STORE)
.map(d => d.name),
}));
}
/** Očistí poznámku člena objednávky (mezery, číslo položky z menu typu „28. ") na název jídla; poznámky nezačínající písmenem/číslicí zahodí. */
function normalizeDish(note?: string): string | undefined {
if (!note) return undefined;
const cleaned = note.replace(/\s+/g, ' ').replace(/^\d+\s*[.)]\s*/, '').trim();
// Poznámky typu „⬇️ totéž" nebo „(bez cibule)" nejsou názvy jídel
if (!/^[\p{L}\p{N}]/u.test(cleaned)) return undefined;
return cleaned.length >= 2 && cleaned.length <= 120 ? cleaned : undefined;
}
+9 -2
View File
@@ -357,8 +357,15 @@ describe('zloděj', () => {
}); });
test('poražení zloděje dá odměnu a zvýší statistiku', async () => { test('poražení zloděje dá odměnu a zvýší statistiku', async () => {
await seed(USER, { caught: 0, coins: 10, goldenCaught: 0 }); const now = 4_000_000;
const stats = await defeatThief(USER); const day = formatDate(new Date(now));
// Denní úkol pevně nastavíme na jiný druh – jinak by se v některé dny
// (kdy je úkolem "poraž 1 zloděje") k odměně přičetla i jeho výplata.
await seed(USER, {
caught: 0, coins: 10, goldenCaught: 0,
daily: { day, taskId: 'wasp', progress: 0, target: 15, done: false },
});
const stats = await defeatThief(USER, now);
expect(stats.coins).toBe(10 + THIEF_REWARD); expect(stats.coins).toBe(10 + THIEF_REWARD);
expect(stats.thievesDefeated).toBe(1); expect(stats.thievesDefeated).toBe(1);
}); });
+39
View File
@@ -0,0 +1,39 @@
import axios from 'axios';
import { resetMemoryStorage } from '../storage/memory';
import { getConfettiCharacters, getConfettiImage } from '../confetti';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
beforeEach(() => {
resetMemoryStorage();
mockedAxios.get.mockReset();
});
test('postavy odkazují jen na cachující proxy serveru', () => {
const images = getConfettiCharacters().flatMap(c => c.images);
expect(images.length).toBeGreaterThan(0);
expect(images.every(u => /^\/api\/confetti\/image\/[0-9a-f]{16}$/.test(u))).toBe(true);
// ID jsou stabilní (hash URL) a unikátní
expect(new Set(images).size).toBe(images.length);
});
const idAt = (i: number) => getConfettiCharacters().flatMap(c => c.images)[i].split('/').pop()!;
test('obrázek stáhne jen jednou, pak ho vrací ze storage', async () => {
mockedAxios.get.mockResolvedValue({ data: Buffer.from('PNGDATA'), headers: { 'content-type': 'image/png' } });
const first = await getConfettiImage(idAt(0));
const second = await getConfettiImage(idAt(0));
expect(first?.contentType).toBe('image/png');
expect(second?.data.toString()).toBe('PNGDATA');
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
});
test('neznámé ID ani ne-obrázek nevrátí', async () => {
expect(await getConfettiImage('ffffffffffffffff')).toBeUndefined();
mockedAxios.get.mockResolvedValue({ data: Buffer.from('<html>'), headers: { 'content-type': 'text/html' } });
expect(await getConfettiImage(idAt(1))).toBeUndefined();
// po selhání se hned znovu nestahuje
expect(await getConfettiImage(idAt(1))).toBeUndefined();
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
});
+137
View File
@@ -0,0 +1,137 @@
import { DrawingGame, DrawState, Stroke, toWord, isCorrectGuess, ROUND_MS, REVEAL_MS, GUESS_MAX_POINTS, DRAWER_POINTS_PER_GUESS, FALLBACK_WORDS } from '../drawingGame';
import { GameError } from '../minigames';
import { fakeClock } from './helpers/fakeClock';
describe('kreslení — slova z menu', () => {
test.each([
['Plzeňský hovězí guláš s kynutým knedlíkem (1, 3, 7)', 'Plzeňský hovězí guláš', ['gulas']],
['Tortilla wrap s trhaným vepřovým masem, slaninou a čedarem', 'Tortilla wrap', ['tortilla', 'wrap']],
['Čevabčiči, brambory, cibule, hořčice (1, 3, 7)', 'Čevabčiči', ['cevabcici']],
['Sýrová pizza, čerstvá bazalka (1, 7)', 'Sýrová pizza', ['pizza']],
['Medailonky z panenky se slaninovým kroupotem', 'Medailonky z panenky', ['medailonky', 'panenky']],
])('%s', (dish, display, keywords) => {
expect(toWord(dish)).toEqual({ display, keywords });
});
test('hádání toleruje diakritiku, velikost písmen a koncovky', () => {
const gulas = toWord('Plzeňský hovězí guláš');
expect(isCorrectGuess('gulas', gulas)).toBe(true);
expect(isCorrectGuess(' GULÁŠ ', gulas)).toBe(true);
expect(isCorrectGuess('je to guláš?', gulas)).toBe(true);
expect(isCorrectGuess('hovězí', gulas)).toBe(false);
expect(isCorrectGuess('gul', gulas)).toBe(false);
expect(isCorrectGuess('knedliky', toWord('Knedlík'))).toBe(true);
expect(isCorrectGuess('knedliky', toWord('Bramborový knedlík'))).toBe(true);
expect(isCorrectGuess('pizzu', toWord('Sýrová pizza'))).toBe(true);
});
});
function setup(dishes: string[] = ['Plzeňský hovězí guláš', 'Sýrová pizza', 'Smažený řízek']) {
const clock = fakeClock();
const states: DrawState[] = [];
const words: { login: string, word: string }[] = [];
const strokes: Stroke[] = [];
let clears = 0;
const game = new DrawingGame(
s => states.push(s),
(login, word) => words.push({ login, word }),
s => strokes.push(s),
() => { clears++; },
async () => dishes,
2,
clock,
);
const last = () => states[states.length - 1];
const player = (login: string) => last().players.find(p => p.login === login)!;
const startGame = async (...logins: string[]) => {
logins.forEach(l => game.join(l));
await game.start(logins[0]);
};
return { game, clock, states, words, strokes, last, player, startGame, clears: () => clears };
}
describe('kreslení — průběh hry', () => {
test('slovo dostane jen kreslíř, ostatní vidí masku', async () => {
const ctx = setup();
await ctx.startGame('alice', 'bob');
const state = ctx.last();
expect(state).toMatchObject({ phase: 'drawing', drawer: 'alice', round: 1, totalRounds: 2 });
expect(ctx.words).toHaveLength(1);
expect(ctx.words[0].login).toBe('alice');
expect(JSON.stringify(ctx.states)).not.toContain(ctx.words[0].word);
expect(state.hint).toMatch(/^[_ ]+$/);
expect(ctx.game.wordFor('bob')).toBeUndefined();
});
test('správný tip dá body hádajícímu i kreslíři a ukončí kolo, když uhodli všichni', async () => {
const ctx = setup(['Plzeňský hovězí guláš', 'Plzeňský hovězí guláš']);
await ctx.startGame('alice', 'bob');
ctx.game.guess('bob', 'svíčková');
expect(ctx.last().messages.at(-1)).toEqual({ login: 'bob', text: 'svíčková' });
ctx.game.guess('bob', 'guláš');
expect(ctx.player('bob').score).toBe(GUESS_MAX_POINTS);
expect(ctx.player('alice').score).toBe(DRAWER_POINTS_PER_GUESS);
expect(ctx.last()).toMatchObject({ phase: 'reveal', answer: 'Plzeňský hovězí guláš' });
ctx.clock.advance(REVEAL_MS);
expect(ctx.last()).toMatchObject({ phase: 'drawing', drawer: 'bob', round: 2 });
});
test('kreslíř a ti, kdo už uhodli, nemohou psát do chatu', async () => {
const ctx = setup(['Plzeňský hovězí guláš', 'Plzeňský hovězí guláš']);
await ctx.startGame('alice', 'bob', 'carol');
const before = ctx.last().messages.length;
ctx.game.guess('alice', 'je to guláš');
ctx.game.guess('bob', 'guláš');
ctx.game.guess('bob', 'guláš s knedlíkem!');
const texts = ctx.last().messages.slice(before).map(m => m.text);
expect(texts).toEqual(['bob uhodl(a)! 🎉']);
});
test('pozdější tip má méně bodů a nápověda postupně odkrývá písmena', async () => {
const ctx = setup(['Plzeňský hovězí guláš', 'Plzeňský hovězí guláš']);
await ctx.startGame('alice', 'bob', 'carol');
ctx.clock.advance(ROUND_MS * 0.75);
expect(ctx.last().hint!.replace(/[_ ]/g, '')).toHaveLength(2);
ctx.game.guess('bob', 'gulas');
expect(ctx.player('bob').score).toBe(25);
});
test('po vypršení času kolo skončí a po všech kolech hra', async () => {
const ctx = setup();
await ctx.startGame('alice', 'bob');
ctx.clock.advance(ROUND_MS);
expect(ctx.last().phase).toBe('reveal');
ctx.clock.advance(REVEAL_MS + ROUND_MS + REVEAL_MS);
expect(ctx.last().phase).toBe('finished');
expect(ctx.last().players.every(p => p.place !== undefined)).toBe(true);
});
test('kreslit smí jen kreslíř a neplatné tahy se zahodí', async () => {
const ctx = setup();
await ctx.startGame('alice', 'bob');
const stroke = { color: '#ff0000', size: 4, points: [[10, 10], [20, 20]], start: true };
ctx.game.stroke('bob', stroke);
ctx.game.stroke('alice', { ...stroke, color: 'red' });
ctx.game.stroke('alice', { ...stroke, points: [[5000, 1]] });
expect(ctx.strokes).toHaveLength(0);
ctx.game.stroke('alice', stroke);
expect(ctx.strokes).toHaveLength(1);
expect(ctx.game.getStrokes()).toHaveLength(1);
ctx.game.clear('alice');
expect(ctx.game.getStrokes()).toHaveLength(0);
});
test('bez menu se hraje se záložními slovy', async () => {
const ctx = setup([]);
await ctx.startGame('alice', 'bob');
expect(FALLBACK_WORDS.map(w => toWord(w).display)).toContain(ctx.words[0].word);
});
test('hru spustí jen zakladatel s dostatkem hráčů', async () => {
const ctx = setup();
ctx.game.join('alice');
await expect(ctx.game.start('alice')).rejects.toThrow(GameError);
ctx.game.join('bob');
await expect(ctx.game.start('bob')).rejects.toThrow(GameError);
});
});
+305
View File
@@ -0,0 +1,305 @@
<!DOCTYPE html>
<html class="no-js" prefix="og: https://ogp.me/ns#" lang="cs">
<head><link rel="preconnect" href="https://duyn491kcolsw.cloudfront.net" crossorigin><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><meta charset="utf-8"><link rel="shortcut icon" href="https://duyn491kcolsw.cloudfront.net/files/1x/1xh/1xh0pi.ico?ph=59d3cce388"><link rel="apple-touch-icon" href="https://duyn491kcolsw.cloudfront.net/files/1x/1xh/1xh0pi.ico?ph=59d3cce388"><link rel="icon" href="https://duyn491kcolsw.cloudfront.net/files/1x/1xh/1xh0pi.ico?ph=59d3cce388">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Restaurace :: Equifarm</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="msapplication-tap-highlight" content="no">
<link href="https://duyn491kcolsw.cloudfront.net/files/0r/0rk/0rklmm.css?ph=59d3cce388" media="print" rel="stylesheet">
<link href="https://duyn491kcolsw.cloudfront.net/files/4f/4fk/4fkxao.css?ph=59d3cce388" media="screen and (min-width:100000em)" rel="stylesheet" data-type="cq" disabled>
<link rel="stylesheet" href="https://duyn491kcolsw.cloudfront.net/files/01/018/018gum.css?ph=59d3cce388"><link rel="stylesheet" href="https://duyn491kcolsw.cloudfront.net/files/0k/0k1/0k1ucs.css?ph=59d3cce388" media="screen and (min-width:37.5em)"><link rel="stylesheet" href="https://duyn491kcolsw.cloudfront.net/files/0o/0on/0onzbj.css?ph=59d3cce388" data-wnd_color_scheme_file=""><link rel="stylesheet" href="https://duyn491kcolsw.cloudfront.net/files/34/34q/34qmjb.css?ph=59d3cce388" data-wnd_color_scheme_desktop_file="" media="screen and (min-width:37.5em)" disabled=""><link rel="stylesheet" href="https://duyn491kcolsw.cloudfront.net/files/1j/1jo/1jorv9.css?ph=59d3cce388" data-wnd_additive_color_file=""><link rel="stylesheet" href="https://duyn491kcolsw.cloudfront.net/files/48/48g/48gira.css?ph=59d3cce388" data-wnd_typography_file=""><link rel="stylesheet" href="https://duyn491kcolsw.cloudfront.net/files/2b/2b9/2b9a9p.css?ph=59d3cce388" data-wnd_typography_desktop_file="" media="screen and (min-width:37.5em)" disabled=""><script>(()=>{let e=!1;const t=()=>{if(!e&&window.innerWidth>=600){for(let e=0,t=document.querySelectorAll('head > link[href*="css"][media="screen and (min-width:37.5em)"]');e<t.length;e++)t[e].removeAttribute("disabled");e=!0}};t(),window.addEventListener("resize",t),"container"in document.documentElement.style||fetch(document.querySelector('head > link[data-type="cq"]').getAttribute("href")).then((e=>{e.text().then((e=>{const t=document.createElement("style");document.head.appendChild(t),t.appendChild(document.createTextNode(e)),import("https://duyn491kcolsw.cloudfront.net/client/js.polyfill/container-query-polyfill.modern.js").then((()=>{let e=setInterval((function(){document.body&&(document.body.classList.add("cq-polyfill-loaded"),clearInterval(e))}),100)}))}))}))})()</script>
<link rel="preload stylesheet" href="https://duyn491kcolsw.cloudfront.net/files/2m/2m8/2m8msc.css?ph=59d3cce388" as="style"><meta name="description" content="Otevírací dobapo – pá 10.00 - 14.00obědy 10.00 - 13:45"><meta name="keywords" content=""><meta name="generator" content="Webnode 2"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="black"><meta name="format-detection" content="telephone=no">
<meta property="og:url" content="https://www.equifarm.cz/restaurace-techtower/"><meta property="og:title" content="Restaurace :: Equifarm"><meta property="og:type" content="article"><meta property="og:description" content="Otevírací dobapo – pá 10.00 - 14.00obědy 10.00 - 13:45"><meta property="og:site_name" content="Equifarm"><meta property="og:article:published_time" content="2026-10-05 00:00:00"><meta name="robots" content="index,follow"><link rel="canonical" href="https://www.equifarm.cz/restaurace-techtower/"><script>window.checkAndChangeSvgColor=function(c){try{var a=document.getElementById(c);if(a){c=[["border","borderColor"],["outline","outlineColor"],["color","color"]];for(var h,b,d,f=[],e=0,m=c.length;e<m;e++)if(h=window.getComputedStyle(a)[c[e][1]].replace(/\s/g,"").match(/^rgb[a]?\(([0-9]{1,3}),([0-9]{1,3}),([0-9]{1,3})/i)){b="";for(var g=1;3>=g;g++)b+=("0"+parseInt(h[g],10).toString(16)).slice(-2);"0"===b.charAt(0)&&(d=parseInt(b.substr(0,2),16),d=Math.max(16,d),b=d.toString(16)+b.slice(-4));f.push(c[e][0]+"="+b)}if(f.length){var k=a.getAttribute("data-src"),l=k+(0>k.indexOf("?")?"?":"&")+f.join("&");a.src!=l&&(a.src=l,a.outerHTML=a.outerHTML)}}}catch(n){}};</script></head>
<body class="l wt-page ac-i l-default l-d-none b-btn-sq b-btn-s-l b-btn-dn b-btn-bw-1 img-d-n img-t-o img-h-z line-solid b-e-ds lbox-d c-s-s wnd-fe"><noscript>
<iframe
src="https://www.googletagmanager.com/ns.html?id=GTM-542MMSL"
height="0"
width="0"
style="display:none;visibility:hidden"
>
</iframe>
</noscript>
<div class="wnd-page l-page cs-gray ac-none t-t-fs-l t-t-fw-l t-t-sp-n t-t-d-n t-s-fs-m t-s-fw-l t-s-sp-n t-s-d-n t-p-fs-s t-p-fw-m t-p-sp-n t-h-fs-m t-h-fw-l t-h-sp-n t-bq-fs-s t-bq-fw-l t-bq-sp-n t-bq-d-q t-btn-fw-l t-nav-fw-l t-pd-fw-s t-nav-tt-u">
<div class="l-w t cf t-30">
<div class="l-bg cf">
<div class="s-bg-l">
</div>
</div>
<header class="l-h cf">
<div class="sw cf">
<div class="sw-c cf"><section data-space="true" class="s s-hn s-hn-default wnd-mt-classic wnd-na-c logo-classic sc-ml wnd-background-pattern wnd-w-wider wnd-nh-m hm-hidden wnd-nav-border menu-default-underline">
<div class="s-w">
<div class="s-o">
<div class="s-bg">
<div class="s-bg-l wnd-background-pattern bgpos-center-center bgatt-scroll" style="background-image:url('https://duyn491kcolsw.cloudfront.net/files/17/17i/17ib2g.png?ph=59d3cce388')">
</div>
</div>
<div class="h-w h-f wnd-fixed">
<div class="n-l">
<div class="s-c menu-nav">
<div class="logo-block">
<div class="b b-l logo logo-default logo-nb proxima-nova logo-32 wnd-logo-with-text wnd-iar-1-1 b-ls-l" id="wnd_LogoBlock_193721444" data-wnd_mvc_type="wnd.fe.LogoBlock">
<div class="b-l-c logo-content">
<a class="b-l-link logo-link" href="/">
<div class="b-l-image logo-image">
<div class="b-l-image-w logo-image-cell">
<picture><source type="image/webp" srcset="https://59d3cce388.clvaw-cdnwnd.com/bd8e4bd40b5bb1412d0aee3843c4e306/200000384-5e4ab5e4ae/450/EQF_Logo%20%28orig.%29.webp?ph=59d3cce388 366w, https://59d3cce388.clvaw-cdnwnd.com/bd8e4bd40b5bb1412d0aee3843c4e306/200000384-5e4ab5e4ae/700/EQF_Logo%20%28orig.%29.webp?ph=59d3cce388 366w, https://59d3cce388.clvaw-cdnwnd.com/bd8e4bd40b5bb1412d0aee3843c4e306/200000384-5e4ab5e4ae/EQF_Logo%20%28orig.%29.webp?ph=59d3cce388 366w" sizes="(min-width: 600px) 450px, (min-width: 360px) calc(100vw * 0.8), 100vw" ><img src="https://59d3cce388.clvaw-cdnwnd.com/bd8e4bd40b5bb1412d0aee3843c4e306/200000003-2ad852ad86/EQF_Logo%20%28orig.%29.png?ph=59d3cce388" alt="" width="366" height="356" class="wnd-logo-img" ></picture>
</div>
</div>
<div class="b-l-br logo-br"></div>
<div class="b-l-text logo-text-wrapper">
<div class="b-l-text-w logo-text">
<span class="b-l-text-c logo-text-cell">Equifarm, s.r.o.</span>
</div>
</div>
</a>
</div>
</div>
</div>
<div id="menu-slider">
<div id="menu-block">
<nav id="menu"><div class="menu-font menu-wrapper">
<a href="#" class="menu-close" rel="nofollow" title="Close Menu"></a>
<ul role="menubar" aria-label="Menu" class="level-1">
<li role="none" class="wnd-homepage">
<a class="menu-item" role="menuitem" href="/"><span class="menu-item-text">Úvod</span></a>
</li><li role="none" class="wnd-with-submenu">
<a class="menu-item" role="menuitem" href="/firemni-stravovani/"><span class="menu-item-text">Firemní stravování</span></a>
<ul role="menubar" aria-label="Menu" class="level-2">
<li role="none">
<a class="menu-item" role="menuitem" href="/specialiste-na-japonskou-kuchyni/"><span class="menu-item-text">Specialisté na japonskou kuchyni</span></a>
</li><li role="none">
<a class="menu-item" role="menuitem" href="/vlastni-cukrarska-vyroba/"><span class="menu-item-text">Vlastní cukrářská výroba</span></a>
</li><li role="none">
<a class="menu-item" role="menuitem" href="/reference/"><span class="menu-item-text">Reference</span></a>
</li><li role="none">
<a class="menu-item" role="menuitem" href="/kontakty2/"><span class="menu-item-text">Kontakty </span></a>
</li>
</ul>
</li><li role="none">
<a class="menu-item" role="menuitem" href="/catering-a-eventy/"><span class="menu-item-text">Catering</span></a>
</li><li role="none" class="wnd-with-submenu">
<a class="menu-item" role="menuitem" href="/rekreacni-areal2/"><span class="menu-item-text">Rekreační areál</span></a>
<ul role="menubar" aria-label="Menu" class="level-2">
<li role="none">
<a class="menu-item" role="menuitem" href="/areal/"><span class="menu-item-text">Areál</span></a>
</li><li role="none">
<a class="menu-item" role="menuitem" href="/kontakty/"><span class="menu-item-text">Kontakty</span></a>
</li><li role="none">
<a class="menu-item" role="menuitem" href="/cenik/"><span class="menu-item-text">Ceník</span></a>
</li><li role="none">
<a class="menu-item" role="menuitem" href="/kalendar/"><span class="menu-item-text">Kalendář</span></a>
</li><li role="none">
<a class="menu-item" role="menuitem" href="/fotogalerie/"><span class="menu-item-text">Fotogalerie</span></a>
</li><li role="none">
<a class="menu-item" role="menuitem" href="/vylety-do-okoli/"><span class="menu-item-text">Výlety do okolí</span></a>
</li>
</ul>
</li><li role="none" class="wnd-active-path wnd-with-submenu">
<a class="menu-item" role="menuitem" href="/techtower/"><span class="menu-item-text">TechTower</span></a>
<ul role="menubar" aria-label="Menu" class="level-2">
<li role="none">
<a class="menu-item" role="menuitem" href="/kavarna-techtower/"><span class="menu-item-text">SeedUp Space</span></a>
</li><li role="none" class="wnd-active wnd-active-path">
<a class="menu-item" role="menuitem" href="/restaurace-techtower/"><span class="menu-item-text">Restaurace </span></a>
</li><li role="none">
<a class="menu-item" role="menuitem" href="/coffee-breaks/"><span class="menu-item-text">Coffee breaks</span></a>
</li>
</ul>
</li><li role="none">
<a class="menu-item" role="menuitem" href="/kontakt/"><span class="menu-item-text">Kontakt</span></a>
</li>
</ul>
<span class="more-text">Více</span>
</div></nav>
</div>
</div>
<div class="cart-and-mobile">
<div id="menu-mobile">
<a href="#" id="menu-submit"><span></span>Menu</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section></div>
</div>
</header>
<main class="l-m cf">
<div class="sw cf">
<div class="sw-c cf"><section class="s s-hm s-hm-hidden wnd-h-hidden cf sc-w hn-default"><div></div></section><section class="s s-basic cf sc-w wnd-w-default wnd-s-normal wnd-h-auto">
<div class="s-w cf">
<div class="s-o s-fs cf">
<div class="s-bg cf">
<div class="s-bg-l">
</div>
</div>
<div class="s-c s-fs cf">
<div class="ez cf wnd-no-cols">
<div class="ez-c"><div class="b b-text cf">
<div class="b-c b-text-c b-s b-s-t60 b-s-b60 b-cs cf"><h1 class="wnd-align-center"><strong><font class="wsw-31">Restaurace&nbsp;</font></strong></h1>
</div>
</div><div class="b b-text cf">
<div class="b-c b-text-c b-s b-s-t60 b-s-b60 b-cs cf"></div>
</div><div class="b b-s b-hr-line line-auto">
<hr class="line-style line-color">
</div><div class="b b-text cf">
<div class="b-c b-text-c b-s b-s-t60 b-s-b60 b-cs cf"><h3 class="wnd-align-center"><font class="wsw-02"><p class=""><strong>Otevírací doba</strong><br></p><p class="">po – pá 10.00 - 14.00</p></font><p class=""><font class="wsw-41">obědy 10.00 - 13:45</font></p></h3></div>
</div><div class="b b-text cf">
<div class="b-c b-text-c b-s b-s-t60 b-s-b60 b-cs cf"><p class="wnd-align-center"><strong><font class="wsw-41 wnd-font-size-110">Jídelní lístek 5. 10.– 9. 10.</font></strong></p><p class="wnd-align-center"><strong>Pondělí</strong></p><p class="wnd-align-center">Mexická fazolačka s kukuřicí a masem 1 — 45 Kč</p><p class="wnd-align-center">Špagety s kuřecím masem ve smetanové omáčce, pórek, sýr 1,7 — 140 Kč</p><p class="wnd-align-center">Wrap se zeleninou a grilovaným halloumi sýrem 1,7</p><p class="wnd-align-center">Vepřové medailonky na zeleném pepři, fazolky se slaninou, šťouchané brambory 1,7</p><p class="wnd-align-center">Kuřecí paličky se sladkokyselou omáčkou, jasmínová rýže, barevný sezam</p><p class="wnd-align-center"><strong>Úterý</strong></p><p class="wnd-align-center">Rajčatová s cizrnou a bazalkou 1 — 45 Kč</p><p class="wnd-align-center">Maďarský hovězí guláš, houskový knedlík 1,3,7 — 140 Kč</p><p class="wnd-align-center">Tagliatelle s dýňovou omáčkou a semínky, trhaná burrata 1,7</p><p class="wnd-align-center">Dušená šunka od kosti s čerstvým křenem s jablky, hrubozrnná hořčice, podmáslový chléb 1,10</p><p class="wnd-align-center">Cordon bleu, hranolky, pikantní dip 1,3,7</p><p class="wnd-align-center"><strong>Středa</strong></p><p class="wnd-align-center">Uzený vývar s rýží a zeleninou 9 — 45 Kč</p><p class="wnd-align-center">Rozlítaný vepřový ptáček, rýže 1,3 — 140 Kč</p><p class="wnd-align-center">Halušky s pečeným ajvarem a feta sýrem, bylinkový olej 1,7</p><p class="wnd-align-center">Krůtí medailonky s grilovanou zeleninou, silná omáčka 1</p><p class="wnd-align-center">Smažený karbanátek, bramborové pyré, řepový salát s jablky 1,3,7</p><p class="wnd-align-center"><strong>Čtvrtek</strong></p><p class="wnd-align-center">Valašská kyselica s klobásou 1,7 — 45 Kč</p><p class="wnd-align-center">Rizoto z vepřového masa se zeleninou, sypané sýrem Gouda 1,7 — 140 Kč</p><p class="wnd-align-center">Žemlovka s jablky a tvarohem, cukr, máslo 1,3,7</p><p class="wnd-align-center">Zapečené bramborové plátky s kuřecím masem a smetanovo-máslovou omáčkou, mozzarella 1,3,7</p><p class="wnd-align-center">Hovězí roštěná na česneku s jasmínovou rýží 1</p><p class="wnd-align-center"><strong>Pátek</strong></p><p class="wnd-align-center">Hovězí vývar se zeleninou a masem, vaječné kapání 1,3,7,9 — 45 Kč</p><p class="wnd-align-center">Zapečené těstoviny s uzeným masem, okurka 1,3,7 — 140 Kč</p><p class="wnd-align-center">Houbový kuba, naše čalamáda 1</p><p class="wnd-align-center">Pečené kachní stehýnko s dušeným zelím, bramborové knedlíky s bylinkami a špekem 1,3,7</p><p class="wnd-align-center">Smažený sýr se šunkou v bulce, hranolky, tatarská omáčka 1,3,7</p><p class="wnd-align-center"><br></p><p class="wnd-align-center"><strong><font class="wsw-41 wnd-font-size-110">Následující týden</font></strong></p><p class="wnd-align-center"><strong>Pondělí</strong></p><p class="wnd-align-center">Hrášková s krutonem 1,7 — 45 Kč</p><p class="wnd-align-center">Čevabčiči, brambory, cibule, hořčice 1,3,7 — 140 Kč</p><p class="wnd-align-center">Sýrová pizza, čerstvá bazalka 1,7</p><p class="wnd-align-center">Vepřová panenka s žampionovou omáčkou, jasmínová rýže 1,7</p><p class="wnd-align-center">Pečený kuřecí gyros, hranolky, tzatziki 7</p><p class="wnd-align-center"><strong>Úterý</strong></p><p class="wnd-align-center">Telecí vývar se zeleninou a játrovými knedlíčky 1,3,7,9 — 45 Kč</p><p class="wnd-align-center">Francouzské brambory, okurka 1,3,7 — 140 Kč</p><p class="wnd-align-center">Halušky s brynzou a řepou, smažená cibulka 1,7</p><p class="wnd-align-center">Quesadilla s kuřecím masem a jalapeňos, červené fazole, salát coleslaw 1,3</p><p class="wnd-align-center">Pečená selečí roláda, baby mrkev na másle, bramborový knedlík 1,3,7</p><p class="wnd-align-center"><strong>Středa</strong></p><p class="wnd-align-center">Krémová slepičí polévka se zeleninou 1,3,7,9 — 45 Kč</p><p class="wnd-align-center">Játra po anglicku, opečené brambory 1 — 140 Kč</p><p class="wnd-align-center">Těstoviny Alfredo, pažitka 1,3,7</p><p class="wnd-align-center">Hovězí roštěná po cikánsku, jasmínová rýže 1,7</p><p class="wnd-align-center">Smažený sekaný řízek s cheddarem, bramborové pyré 1,3,7</p><p class="wnd-align-center"><strong>Čtvrtek</strong></p><p class="wnd-align-center">Hrachová s klobásou 1,7 — 45 Kč</p><p class="wnd-align-center">Houbová omáčka, hovězí maso, houskový knedlík 1,3,7 — 140 Kč</p><p class="wnd-align-center">Tvarohové knedlíky s ovocem, cukr, máslo, tvaroh 1,3,7</p><p class="wnd-align-center">Vepřové plátky na japonský způsob, jasmínová rýže, barevný sezam 1,6,11</p><p class="wnd-align-center">Steak z tuňákLine truncated
</div><div class="b b-text cf">
<div class="b-c b-text-c b-s b-s-t60 b-s-b60 b-cs cf"><p id="wnd_TextBlock_76217_inner_1" data-wnd_is_separable="1" data-wnd_separable_index="1" data-wnd_separable_id="wnd_TextBlock_76217"></p></div>
</div><div class="b b-text cf">
<div class="b-c b-text-c b-s b-s-t60 b-s-b60 b-cs cf"><p class="wnd-align-center"><a href="https://www.equifarm.cz" target="_blank">www.equifarm.cz</a></p><p class="wnd-align-center"><font class="wsw-31">techtower@equifarm.cz</font></p><p class="wnd-align-center">+420 608 180 311</p></div>
</div></div>
</div>
</div>
</div>
</div>
</section></div>
</div>
</main>
<footer class="l-f cf">
<div class="sw cf">
<div class="sw-c cf"><section data-wn-border-element="s-f-border" class="s s-f s-f-basic s-f-simple sc-w wnd-w-default wnd-s-higher">
<div class="s-w">
<div class="s-o">
<div class="s-bg">
<div class="s-bg-l">
</div>
</div>
<div class="s-c s-f-l-w s-f-border">
<div class="s-f-l b-s b-s-t0 b-s-b0">
<div class="s-f-l-c s-f-l-c-first">
<div class="s-f-cr"><span class="it b link">
<span class="it-c">© 2022 Equifarm, s.r.o. Všechna práva vyhrazena.</span>
</span></div>
<div class="s-f-sf">
<span class="sf b">
<span class="sf-content sf-c link">Vytvořeno službou <a href="https://www.webnode.cz?utm_source=text&utm_medium=footer&utm_campaign=free3&utm_content=wnd2" rel="nofollow noopener" target="_blank">Webnode</a></span>
</span>
<span class="sf-cbr link">
<a href="#" rel="nofollow">Cookies</a>
</span>
</div>
</div>
<div class="s-f-l-c s-f-l-c-last">
<div class="s-f-lang lang-select cf">
</div>
</div>
<div class="s-f-l-c s-f-l-c-currency">
<div class="s-f-ccy ccy-select cf">
</div>
</div>
</div>
</div>
</div>
</div>
</section></div>
</div>
</footer>
</div>
</div>
<section class="cb cb-dark" id="cookiebar" style="display:none;" id="cookiebar" style="display:none;">
<div class="cb-bar cb-scrollable">
<div class="cb-bar-c">
<div class="cb-bar-text">Používáme cookies, abychom zajistili správné fungování a bezpečnost našich stránek. Tím vám můžeme zajistit tu nejlepší zkušenost při jejich návštěvě.</div>
<div class="cb-bar-buttons">
<button class="cb-button cb-close-basic" data-action="accept-necessary">
<span class="cb-button-content">Přijmout nezbytné</span>
</button>
<button class="cb-button cb-close-basic" data-action="accept-all">
<span class="cb-button-content">Přijmout vše</span>
</button>
</div>
</div>
<div class="cb-close cb-close-basic" data-action="accept-necessary"></div>
</div>
<div class="cb-popup cb-light close">
<div class="cb-popup-c">
<div class="cb-popup-head">
<h3 class="cb-popup-title">Pokročilá nastavení</h3>
<p class="cb-popup-text">Zde můžete upravit své preference ohledně cookies. Následující kategorie můžete povolit či zakázat a svůj výběr uložit.</p>
</div>
<div class="cb-popup-options cb-scrollable">
<div class="cb-option">
<input class="cb-option-input" type="checkbox" id="necessary" name="necessary" readonly="readonly" disabled="disabled" checked="checked">
<div class="cb-option-checkbox"></div>
<label class="cb-option-label" for="necessary">Nezbytné krátkodobé cookies</label>
<div class="cb-option-text"></div>
</div>
</div>
<div class="cb-popup-footer">
<button class="cb-button cb-save-popup" data-action="advanced-save">
<span class="cb-button-content">Uložit</span>
</button>
</div>
<div class="cb-close cb-close-popup" data-action="close"></div>
</div>
</div>
</section>
<script src="https://duyn491kcolsw.cloudfront.net/files/35/35w/35wjn2.js?ph=59d3cce388" crossorigin="anonymous" type="module"></script><script>document.querySelector(".wnd-fe")&&[...document.querySelectorAll(".c")].forEach((e=>{const t=e.querySelector(".b-text:only-child");t&&""===t.querySelector(".b-text-c").innerText&&e.classList.add("column-empty")}))</script>
<script src="https://duyn491kcolsw.cloudfront.net/client.fe/js.compiled/lang.cz.a9c1a985e49aad81.js?ph=59d3cce388" crossorigin="anonymous"></script><script src="https://duyn491kcolsw.cloudfront.net/client.fe/js.compiled/compiled.multi.64ecbadac2103be4.js?ph=59d3cce388" crossorigin="anonymous"></script><script>var wnd = wnd || {};wnd.$data = {"image_content_items":{"wnd_ThumbnailBlock_1":{"id":"wnd_ThumbnailBlock_1","type":"wnd.pc.ThumbnailBlock"},"wnd_HeaderSection_header_main_412360230":{"id":"wnd_HeaderSection_header_main_412360230","type":"wnd.pc.HeaderSection"},"wnd_ImageBlock_793396992":{"id":"wnd_ImageBlock_793396992","type":"wnd.pc.ImageBlock"},"wnd_Section_default_762442558":{"id":"wnd_Section_default_762442558","type":"wnd.pc.Section"},"wnd_LogoBlock_193721444":{"id":"wnd_LogoBlock_193721444","type":"wnd.pc.LogoBlock"},"wnd_FooterSection_footer_306617015":{"id":"wnd_FooterSection_footer_306617015","type":"wnd.pc.FooterSection"}},"svg_content_items":{"wnd_ImageBlock_793396992":{"id":"wnd_ImageBlock_793396992","type":"wnd.pc.ImageBlock"},"wnd_LogoBlock_193721444":{"id":"wnd_LogoBlock_193721444","type":"wnd.pc.LogoBlock"}},"content_items":[],"eshopSettings":{"ESHOP_SETTINGS_DISPLAY_PRICE_WITHOUT_VAT":false,"ESHOP_SETTINGS_DISPLAY_ADDITIONAL_VAT":false,"ESHOP_SETTINGS_DISPLAY_SHIPPING_COST":false},"project_info":{"isMultilanguage":false,"isMulticurrency":false,"eshop_tax_enabled":"1","country_code":"","contact_state":null,"eshop_tax_type":"VAT","eshop_discounts":false,"graphQLURL":"https:\/\/equifarm.cz\/servers\/graphql\/","iubendaSettings":{"cookieBarCode":"","cookiePolicyCode":"","privacyPolicyCode":"","termsAndConditionsCode":""}}};</script><script>wnd.$system = {"fileSystemType":"aws_s3","localFilesPath":"https:\/\/www.equifarm.cz\/_files\/","awsS3FilesPath":"https:\/\/59d3cce388.clvaw-cdnwnd.com\/bd8e4bd40b5bb1412d0aee3843c4e306\/","staticFiles":"https:\/\/duyn491kcolsw.cloudfront.net\/files","isCms":false,"staticCDNServers":["https:\/\/duyn491kcolsw.cloudfront.net\/"],"fileUploadAllowExtension":["jpg","jpeg","jfif","png","gif","bmp","ico","svg","webp","tiff","pdf","doc","docx","ppt","pptx","pps","ppsx","odt","xls","xlsx","txt","rtf","mp3","wma","wav","ogg","amr","flac","m4a","3gp","avi","wmv","mov","mpg","mkv","mp4","mpeg","m4v","swf","gpx","stl","csv","xml","txt","dxf","dwg","iges","igs","step","stp"],"maxUserFormFileLimit":4194304,"frontendLanguage":"cz","backendLanguage":"cz","frontendLanguageId":"1","page":{"id":200005119,"identifier":"restaurace-techtower","template":{"id":200000048,"styles":{"typography":"t-30_new","scheme":"cs-gray","additiveColor":"ac-none","acHeadings":false,"acSubheadings":false,"acIcons":true,"acOthers":false,"acMenu":false,"imageStyle":"img-d-n","imageHover":"img-h-z","imageTitle":"img-t-o","buttonStyle":"b-btn-sq","buttonSize":"b-btn-s-l","buttonDecoration":"b-btn-dn","buttonBorders":"b-btn-bw-1","lineStyle":"line-solid","eshopGridItemStyle":"b-e-ds","formStyle":"default","menuType":"","menuStyle":"menu-default-underline","lightboxStyle":"lbox-d","columnSpaces":"c-s-s","sectionWidth":"wnd-w-default","sectionSpace":"wnd-s-normal","layoutType":"l-default","layoutDecoration":"l-d-none","background":{"default":null},"backgroundSettings":{"default":""},"typoTitleSizes":"t-t-fs-l","typoTitleWeights":"t-t-fw-l","typoTitleSpacings":"t-t-sp-n","typoTitleDecorations":"t-t-d-n","typoHeadingSizes":"t-h-fs-m","typoHeadingWeights":"t-h-fw-l","typoHeadingSpacings":"t-h-sp-n","typoSubtitleSizes":"t-s-fs-m","typoSubtitleWeights":"t-s-fw-l","typoSubtitleSpacings":"t-s-sp-n","typoSubtitleDecorations":"t-s-d-n","typoParagraphSizes":"t-p-fs-s","typoParagraphWeights":"t-p-fw-m","typoParagraphSpacings":"t-p-sp-n","typoBlockquoteSizes":"t-bq-fs-s","typoBlockquoteWeights":"t-bq-fw-l","typoBlockquoteSpacings":"t-bq-sp-n","typoBlockquoteDecorations":"t-bq-d-q","buttonWeight":"t-btn-fw-l","menuWeight":"t-nav-fw-l","productWeight":"t-pd-fw-s","menuTextTransform":"t-nav-tt-u"}},"layout":"default","name":"Restaurace ","html_title":"","language":"cz","langId":1,"isHomepage":false,"meta_description":"","meta_keywords":"","header_code":"","footer_code":"","styles":{"pageBased":{"navline":{"style":{"sectionWidth":"wnd-w-wider","sectionHeight":"wnd-nh-m","sectionPosition":"wnd-na-c"}}}},"countFormsEntries":[]},"listingsPrefix":"\/l\/","productPrefix":"\/p\/","cartPrefix":"\/cart\/","checkoutPrefix":"\/checkout\/","searchPrefix":"\/search\/","isCheckout":false,"isEshop":false,"hasBlog":false,"isProductDetail":false,"isListingDetail":false,"listing_page":[],"hasEshopAnalytics":false,"gTagId":null,"gAdsId":null,"format":{"be":{"DATE_TIME":{"mask":"%d.%m.%Y %H:%M","regexp":"^(((0?[1-9]|[1,2][0-9]|3[0,1])\\.(0?[1-9]|1[0-2])\\.[0-9]{1,4})(( [0-1][0-9]| 2[0-3]):[0-5][0-9])?|(([0-9]{4}(0[1-9]|1[0-2])(0[1-9]|[1,2][0-9]|3[0,1])(0[0-9]|1[0-9]|2[0-3])[0-5][0-9][0-5][0-9])))?$"},"DATE":{"mask":"%d.%m.%Y","regexp":"^((0?[1-9]|[1,2][0-9]|3[0,1])\\.(0?[1-9]|1[0-2])\\.[0-9]{1,4})$"},"CURRENCY":{"mask":{"point":",","tLine truncated
events: {"error":{"name":"Error"},"publish":{"name":"Publish page"},"open_premium_popup":{"name":"Open premium popup"},"publish_from_premium_popup":{"name":"Publish from premium content popup"},"upgrade_your_plan":{"name":"Upgrade your plan"},"ml_lock_page":{"name":"Lock access to page"},"ml_unlock_page":{"name":"Unlock access to page"},"ml_start_add_page_member_area":{"name":"Start - add page Member Area"},"ml_end_add_page_member_area":{"name":"End - add page Member Area"},"ml_show_activation_popup":{"name":"Show activation popup"},"ml_activation":{"name":"Member Login Activation"},"ml_deactivation":{"name":"Member Login Deactivation"},"ml_enable_require_approval":{"name":"Enable approve registration manually"},"ml_disable_require_approval":{"name":"Disable approve registration manually"},"ml_fe_member_registration":{"name":"Member Registration on FE"},"ml_fe_member_login":{"name":"Login Member"},"ml_fe_member_recovery":{"name":"Sent recovery email"},"ml_fe_member_change_password":{"name":"Change Password"},"undoredo_click_undo":{"name":"Undo\/Redo - click Undo"},"undoredo_click_redo":{"name":"Undo\/Redo - click Redo"},"add_anchor":{"name":"Anchor adding completed"},"add_page":{"name":"Page adding completed"},"change_page_order":{"name":"Change of page order"},"delete_page":{"name":"Page deleted"},"background_options_change":{"name":"Background setting completed"},"add_content":{"name":"Content adding completed"},"delete_content":{"name":"Content deleting completed"},"change_text":{"name":"Editing of text completed"},"add_image":{"name":"Image adding completed"},"add_photo":{"name":"Adding photo to galery completed"},"change_logo":{"name":"Logo changing completed"},"open_pages":{"name":"Open Pages"},"show_page_cms":{"name":"Show Page - CMS"},"new_section":{"name":"New section completed"},"delete_section":{"name":"Section deleting completed"},"add_video":{"name":"Video adding completed"},"add_maps":{"name":"Maps adding completed"},"add_button":{"name":"Button adding completed"},"add_file":{"name":"File adding completed"},"add_hr":{"name":"Horizontal line adding completed"},"delete_cell":{"name":"Cell deleting completed"},"delete_microtemplate":{"name":"Microtemplate deleting completed"},"add_blog_page":{"name":"Blog page adding completed"},"new_blog_post":{"name":"New blog post created"},"new_blog_recent_posts":{"name":"Blog recent posts list created"},"e_show_products_popup":{"name":"Show products popup"},"e_show_add_product":{"name":"Show add product popup"},"e_show_edit_product":{"name":"Show edit product popup"},"e_show_collections_popup":{"name":"Show collections manager popup"},"e_show_eshop_settings":{"name":"Show eshop settings popup"},"e_add_product":{"name":"Add product"},"e_edit_product":{"name":"Edit product"},"e_remove_product":{"name":"Remove product"},"e_bulk_show":{"name":"Bulk show products"},"e_bulk_hide":{"name":"Bulk hide products"},"e_bulk_remove":{"name":"Bulk remove products"},"e_move_product":{"name":"Order products"},"e_add_collection":{"name":"Add collection"},"e_rename_collection":{"name":"Rename collection"},"e_remove_collection":{"name":"Remove collection"},"e_add_product_to_collection":{"name":"Assign product to collection"},"e_remove_product_from_collection":{"name":"Unassign product from collection"},"e_move_collection":{"name":"Order collections"},"e_add_products_page":{"name":"Add products page"},"e_add_procucts_section":{"name":"Add products list section"},"e_add_products_content":{"name":"Add products list content block"},"e_change_products_zone_style":{"name":"Change style in products list"},"e_change_products_zone_collection":{"name":"Change collection in products list"},"e_show_products_zone_collection_filter":{"name":"Show collection filter in products list"},"e_hide_products_zone_collection_filter":{"name":"Hide collection filter in products list"},"e_show_product_detail_page":{"name":"Show product detail page"},"e_add_to_cart":{"name":"Add product to cart"},"e_remove_from_cart":{"name":"Remove product from cart"},"e_checkout_step":{"name":"From cart to checkout"},"e_finish_checkout":{"name":"Finish order"},"welcome_window_play_video":{"name":"Welcome window - play video"},"e_import_file":{"name":"Eshop - products import - file info"},"e_import_upload_time":{"name":"Eshop - products import - upload file duration"},"e_import_result":{"name":"Eshop - products import result"},"e_import_error":{"name":"Eshop - products import - errors"},"e_promo_blogpost":{"name":"Eshop promo - blogpost"},"e_promo_activate":{"name":"Eshop promo - activate"},"e_settings_activate":{"name":"Online store - activate"},"e_promo_hide":{"name":"Eshop promo - close"},"e_activation_popup_step":{"name":"Eshop activation popup - step"},"e_activation_popup_activate":{"name":"Eshop activation popup - activate"},"e_activation_popup_hide":{"name":"Eshop activation popup - close"},"e_deactivation":{"name":"Eshop deactivation"},"e_reactivation":{"name":"Eshop reactivation"},"e_variant_create_first":Line truncated
data: {"user":{"u":0,"p":44019905,"lc":"CZ","t":"1"},"action":{"identifier":"","name":"","category":"project","platform":"WND2"},"browser":{"url":"https:\/\/www.equifarm.cz\/restaurace-techtower","ua":"axios\/1.20.0","referer_url":"","resolution":"","ip":"90.181.238.210"}},
urlPrefix: "https://events.webnode.com/projects/-/events/",
collection: "PROD",
}</script></body>
</html>
+125
View File
@@ -0,0 +1,125 @@
import express from 'express';
import request from 'supertest';
import bodyParser from 'body-parser';
import { generateToken } from '../auth';
import { resetMemoryStorage } from '../storage/memory';
import getStorage from '../storage';
import groupRouter from '../routes/groupRoutes';
import { DEFAULT_GROUP_LIMITS, getGroupLimits, resetGroupLimits } from '../limits';
const MAX_NOTE_LENGTH = DEFAULT_GROUP_LIMITS.maxNoteLength;
const MAX_SURCHARGE_TEXT_LENGTH = DEFAULT_GROUP_LIMITS.maxSurchargeTextLength;
/** Maximum částky v haléřích */
const MAX_AMOUNT = DEFAULT_GROUP_LIMITS.maxAmountKc * 100;
jest.mock('../websocket', () => ({ getWebsocket: () => ({ emit: jest.fn() }) }));
const CREATOR = 'kreator';
const TOKEN = `Bearer ${generateToken(CREATOR)}`;
function buildApp() {
const app = express();
app.use(bodyParser.json());
app.use('/api/groups', groupRouter);
app.use((err: any, _req: any, res: any, _next: any) => {
res.status(500).json({ error: err.message });
});
return app;
}
/** Založí skupinu a vrátí její ID. */
async function createTestGroup(app: express.Express): Promise<string> {
const res = await request(app).post('/api/groups/create').set('Authorization', TOKEN).send({ name: 'Bistro' });
expect(res.status).toBe(200);
return res.body.groups[0].id;
}
beforeEach(async () => {
resetMemoryStorage();
resetGroupLimits();
await getStorage().setData('stores', [{ name: 'Bistro' }]);
});
describe('limity vstupů skupinových objednávek', () => {
test('poznámka a popis příplatku mají maximální délku', async () => {
const app = buildApp();
const id = await createTestGroup(app);
const post = (body: object) => request(app).post('/api/groups/updateMember').set('Authorization', TOKEN).send({ id, login: CREATOR, ...body });
expect((await post({ note: 'x'.repeat(MAX_NOTE_LENGTH) })).status).toBe(200);
const tooLong = await post({ note: 'x'.repeat(MAX_NOTE_LENGTH + 1) });
expect(tooLong.status).toBe(400);
expect(tooLong.body.error).toContain(`${MAX_NOTE_LENGTH} znaků`);
expect((await post({ surchargeText: 'x'.repeat(MAX_SURCHARGE_TEXT_LENGTH + 1) })).status).toBe(400);
});
test('částka a příplatek mají maximální výši', async () => {
const app = buildApp();
const id = await createTestGroup(app);
const post = (body: object) => request(app).post('/api/groups/updateMember').set('Authorization', TOKEN).send({ id, login: CREATOR, ...body });
expect((await post({ amount: MAX_AMOUNT })).status).toBe(200);
const tooMuch = await post({ amount: MAX_AMOUNT + 1 });
expect(tooMuch.status).toBe(400);
expect(tooMuch.body.error).toContain('10000 Kč');
expect((await post({ surchargeAmount: MAX_AMOUNT + 1 })).status).toBe(400);
expect((await post({ amount: -1 })).status).toBe(400);
});
test('poplatky mají maximum a procentní sleva nesmí přesáhnout 100 %', async () => {
const app = buildApp();
const id = await createTestGroup(app);
const post = (body: object) => request(app).post('/api/groups/updateFees').set('Authorization', TOKEN).send({ id, ...body });
expect((await post({ shipping: MAX_AMOUNT + 1 })).status).toBe(400);
expect((await post({ discountType: 'percent', discountValue: 101 })).status).toBe(400);
expect((await post({ discountType: 'percent', discountValue: 100 })).status).toBe(200);
// Jen hodnota při již nastavené procentní slevě — pojistka v groups.ts
const onlyValue = await post({ discountValue: 150 });
expect(onlyValue.status).toBe(500);
expect(onlyValue.body.error).toContain('100 %');
expect((await post({ discountType: 'fixed', discountValue: MAX_AMOUNT + 1 })).status).toBe(400);
});
test('název skupiny a odkaz na sledování mají maximální délku', async () => {
const app = buildApp();
expect((await request(app).post('/api/groups/create').set('Authorization', TOKEN).send({ name: 'x'.repeat(101) })).status).toBe(400);
const id = await createTestGroup(app);
const res = await request(app).post('/api/groups/setTracking').set('Authorization', TOKEN).send({ id, shareUrl: 'https://x.cz/' + 'a'.repeat(600) });
expect(res.status).toBe(400);
});
});
describe('limity z proměnných prostředí', () => {
const ENV_KEYS = ['GROUP_MAX_NOTE_LENGTH', 'GROUP_MAX_AMOUNT_KC', 'GROUP_MAX_ITEMS'];
afterEach(() => {
ENV_KEYS.forEach(k => delete process.env[k]);
resetGroupLimits();
});
test('env přepíše výchozí hodnoty a limity se uplatní v API', async () => {
process.env.GROUP_MAX_NOTE_LENGTH = '20';
process.env.GROUP_MAX_AMOUNT_KC = '500';
process.env.GROUP_MAX_ITEMS = '5';
resetGroupLimits();
expect(getGroupLimits()).toEqual({ ...DEFAULT_GROUP_LIMITS, maxNoteLength: 20, maxAmountKc: 500, maxItems: 5 });
const app = buildApp();
const id = await createTestGroup(app);
const post = (body: object) => request(app).post('/api/groups/updateMember').set('Authorization', TOKEN).send({ id, login: CREATOR, ...body });
expect((await post({ note: 'x'.repeat(21) })).body.error).toContain('20 znaků');
expect((await post({ amount: 50001 })).body.error).toContain('500 Kč');
expect((await post({ amount: 50000 })).status).toBe(200);
});
test('neplatná hodnota v env se ignoruje (zůstane výchozí)', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => { });
process.env.GROUP_MAX_NOTE_LENGTH = 'hodně';
process.env.GROUP_MAX_AMOUNT_KC = '-5';
process.env.GROUP_MAX_ITEMS = '999999';
resetGroupLimits();
expect(getGroupLimits()).toEqual(DEFAULT_GROUP_LIMITS);
expect(warn).toHaveBeenCalledTimes(3);
warn.mockRestore();
});
});
+27
View File
@@ -0,0 +1,27 @@
/** Ruční hodiny — časovače se spouští jen přes advance(). */
export function fakeClock(random: () => number = () => 0) {
let now = 1_000_000;
let timers: { at: number, fn: () => void, id: number }[] = [];
let nextId = 1;
return {
now: () => now,
random,
setTimeout: (fn: () => void, ms: number) => {
const id = nextId++;
timers.push({ at: now + ms, fn, id });
return id;
},
clearTimeout: (id: unknown) => { timers = timers.filter(t => t.id !== id); },
advance(ms: number) {
const target = now + ms;
for (;;) {
const due = timers.filter(t => t.at <= target).sort((a, b) => a.at - b.at)[0];
if (!due) break;
timers = timers.filter(t => t !== due);
now = due.at;
due.fn();
}
now = target;
},
};
}
+37
View File
@@ -129,6 +129,43 @@ describe('TechTower parser – nový formát 07/2026', () => {
}); });
}); });
describe('TechTower parser – nový formát 10/2026', () => {
// Pondělí 5.10.2026, stránka obsahuje i menu na následující týden
const MONDAY_2026_10 = new Date('2026-10-05');
beforeEach(() => {
mockedAxios.get = jest.fn().mockResolvedValue({ data: loadFixture('techtower-2026-10.html') });
});
test('vrátí pole o délce 5 s jídly pro každý den', async () => {
const menu = await getMenuTechTower(MONDAY_2026_10);
expect(menu).toHaveLength(5);
for (const den of menu) {
expect(den).toHaveLength(5);
}
});
test('nezahrne menu následujícího týdne', async () => {
const menu = await getMenuTechTower(MONDAY_2026_10);
expect(menu[0][0].name).toBe('Mexická fazolačka s kukuřicí a masem');
expect(menu.flat().some(f => f.name.startsWith('Hrášková'))).toBe(false);
});
test('každý den začíná polévkou s cenou a alergeny', async () => {
const menu = await getMenuTechTower(MONDAY_2026_10);
for (const den of menu) {
expect(den[0].isSoup).toBe(true);
expect(den[0].price).toBe('45\xA0Kč');
expect(den[0].allergens?.length).toBeGreaterThan(0);
expect(den.slice(1).some(f => f.isSoup)).toBe(false);
}
});
test('hodí StaleWeekError pro jiný týden', async () => {
await expect(getMenuTechTower(new Date('2026-10-12'))).rejects.toBeInstanceOf(StaleWeekError);
});
});
describe('SenkSerikova parser', () => { describe('SenkSerikova parser', () => {
beforeEach(() => { beforeEach(() => {
// SenkSerikova parsuje arraybuffer – musíme vrátit Buffer, ne string // SenkSerikova parsuje arraybuffer – musíme vrátit Buffer, ne string
+42
View File
@@ -0,0 +1,42 @@
import getStorage from '../storage';
import { resetMemoryStorage } from '../storage/memory';
import { updatePrice } from '../service';
import { ClientData } from '../../../types/gen/types.gen';
const storage = getStorage();
const USER = 'petr';
const DATE = new Date('2025-03-04');
const ISO_DATE = '2025-03-04';
/** Uloží volby uživatelů pro testovací den. */
async function saveChoices(choices: ClientData["choices"]) {
await storage.setData<Partial<ClientData>>(ISO_DATE, { choices });
}
beforeEach(() => {
resetMemoryStorage();
});
describe('updatePrice', () => {
test('nastaví a odstraní cenu u volby podniku', async () => {
await saveChoices({ SPSE: { [USER]: { note: 'polévka' } } });
let data = await updatePrice(USER, false, 9500, DATE);
expect(data.choices.SPSE?.[USER]).toMatchObject({ price: 9500, note: 'polévka' });
data = await updatePrice(USER, false, undefined, DATE);
expect(data.choices.SPSE?.[USER]?.price).toBeUndefined();
expect(data.choices.SPSE?.[USER]?.note).toBe('polévka');
});
test.each(['OBJEDNAVAM', 'PIZZA', 'NEOBEDVAM', 'ROZHODUJI'])('odmítne cenu u volby %s', async (choice) => {
await saveChoices({ [choice]: { [USER]: {} } });
await expect(updatePrice(USER, false, 10000, DATE)).rejects.toThrow('K této volbě nelze zadat cenu');
});
test('odmítne cenu bez vybrané volby i zápornou cenu', async () => {
await saveChoices({ SPSE: { tomas: {} } });
await expect(updatePrice(USER, false, 10000, DATE)).rejects.toThrow('Cenu lze zadat jen k vybrané volbě');
await expect(updatePrice(USER, false, -1, DATE)).rejects.toThrow('Neplatná cena');
});
});
+315
View File
@@ -0,0 +1,315 @@
import axios from 'axios';
import { resetMemoryStorage } from '../storage/memory';
import getStorage from '../storage';
import { diffMenus, fetchBoltMenu, fetchWoltMenu, getStoreMenu, getStoreMenuHistory, getStoreMenuStatus, parseBoltProviderId, parseWoltSlug, refreshStoreMenu, renameStoreMenus, resetProviderThrottle } from '../storeMenus';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
const BOLT_URL = 'https://food.bolt.eu/cs-cz/427-plzen/p/27744-new-york-burger-chicken/';
const WOLT_URL = 'https://wolt.com/cs/cze/pilsen/restaurant/new-york-burger-chicken';
/** Odpověď Bolt getMenuCategories ve zkrácené podobě (plochá mapa položek). */
const boltResponse = () => ({
data: {
code: 0,
message: 'OK',
data: {
items: {
1: { type: 'menu', id: 1, parent_id: null },
10: { type: 'category', id: 10, parent_id: 1, index: 1, name: { value: 'Burgery 🍔' } },
11: { type: 'category', id: 11, parent_id: 1, index: 2, name: { value: 'Přílohy 🍟' } },
12: { type: 'category', id: 12, parent_id: 1, index: 0, name: { value: 'Polévky' } },
100: { type: 'dish', id: 100, parent_id: 10, index: 1, name: { value: 'Big Cheeseburger ' }, price: { value: 189 }, availability: 'in_stock' },
101: { type: 'dish', id: 101, parent_id: 10, index: 0, name: { value: 'Chicken Burger' }, price: { value: 169 }, availability: 'in_stock' },
102: { type: 'dish', id: 102, parent_id: 10, index: 2, name: { value: 'Vyprodaný burger' }, price: { value: 1 }, availability: 'out_of_stock' },
103: { type: 'dish', id: 103, parent_id: 11, index: 0, name: { value: 'Hranolky' }, price: { value: 59 }, availability: 'in_stock' },
104: { type: 'dish', id: 104, parent_id: 12, index: 0, name: { value: 'Gulášovka' }, price: { value: 79 }, availability: 'in_stock' },
13: { type: 'category', id: 13, parent_id: 1, index: 3, name: { value: 'Nealko' } },
105: { type: 'dish', id: 105, parent_id: 13, index: 0, name: { value: 'Red Bull 0.25' }, price: { value: 69 }, availability: 'in_stock' },
106: { type: 'dish', id: 106, parent_id: 10, index: 3, name: { value: 'Půlená pizza (příplatek)' }, price: { value: 20 }, availability: 'in_stock' },
107: { type: 'dish', id: 107, parent_id: 10, index: 4, name: { value: 'DRŠŤKOVÁ' }, price: { value: 89 }, availability: 'in_stock' },
200: { type: 'option_select', id: 200, parent_id: 100, name: { value: 'Extra sýr' } },
},
},
},
});
/** Odpověď Wolt assortment ve zkrácené podobě (ceny v haléřích). */
const woltResponse = () => ({
data: {
categories: [
{ name: '🥩 BEEF BURGER', item_ids: ['a', 'b'], subcategories: [] },
{ name: '🥤 Nápoje', item_ids: ['c'] },
{ name: 'Menu', item_ids: [], subcategories: [{ name: 'Tortilly', item_ids: ['d', 'e'] }] },
],
items: [
{ id: 'a', name: 'Cheeseburger', price: 18900, disabled_info: null },
{ id: 'b', name: 'Nedostupný burger', price: 100, disabled_info: { reason: 'sold out' } },
{ id: 'c', name: 'Cola', price: 4900, disabled_info: null },
{ id: 'd', name: 'Kuřecí tortilla', price: 15950, disabled_info: null },
{ id: 'e', name: 'Kuřecí polévka', price: 6900, disabled_info: null },
],
},
});
beforeEach(() => {
resetMemoryStorage();
resetProviderThrottle();
mockedAxios.get.mockReset();
mockedAxios.isAxiosError.mockImplementation((e: any) => !!e?.isAxiosError);
});
/** Chyba axiosu s HTTP 429 (omezení počtu dotazů). */
const tooManyRequests = (retryAfter?: string) =>
Object.assign(new Error('Request failed with status code 429'), {
isAxiosError: true,
response: { status: 429, headers: retryAfter ? { 'retry-after': retryAfter } : {} },
});
describe('parsování URL', () => {
test('ID podniku z Bolt URL', () => {
expect(parseBoltProviderId(BOLT_URL)).toBe('27744');
expect(parseBoltProviderId(WOLT_URL)).toBeUndefined();
expect(parseBoltProviderId('nesmysl')).toBeUndefined();
});
test('slug podniku z Wolt URL', () => {
expect(parseWoltSlug(WOLT_URL)).toBe('new-york-burger-chicken');
expect(parseWoltSlug(BOLT_URL)).toBeUndefined();
});
});
describe('fetchBoltMenu', () => {
test('vrátí dostupné položky dle pořadí v nabídce, hlavní jídla označí (ne přílohy, nápoje, příplatky, polévky)', async () => {
mockedAxios.get.mockResolvedValue(boltResponse());
const dishes = await fetchBoltMenu('27744');
expect(dishes).toEqual([
{ name: 'Gulášovka', price: 79, category: 'Polévky', isMain: false },
{ name: 'Chicken Burger', price: 169, category: 'Burgery 🍔', isMain: true },
{ name: 'Big Cheeseburger', price: 189, category: 'Burgery 🍔', isMain: true },
{ name: 'Půlená pizza (příplatek)', price: 20, category: 'Burgery 🍔', isMain: false },
{ name: 'DRŠŤKOVÁ', price: 89, category: 'Burgery 🍔', isMain: false },
{ name: 'Hranolky', price: 59, category: 'Přílohy 🍟', isMain: false },
{ name: 'Red Bull 0.25', price: 69, category: 'Nealko', isMain: false },
]);
expect(mockedAxios.get).toHaveBeenCalledWith(expect.stringContaining('getMenuCategories'),
expect.objectContaining({ params: expect.objectContaining({ provider_id: '27744' }) }));
});
test('chybový kód Bolt API vyhodí výjimku', async () => {
mockedAxios.get.mockResolvedValue({ data: { code: 702, message: 'INVALID_REQUEST' } });
await expect(fetchBoltMenu('1')).rejects.toThrow('702');
});
});
describe('fetchWoltMenu', () => {
test('vrátí dostupné položky včetně podkategorií, ceny v Kč, hlavní jídla označí', async () => {
mockedAxios.get.mockResolvedValue(woltResponse());
const dishes = await fetchWoltMenu('new-york-burger-chicken');
expect(dishes).toEqual([
{ name: 'Cheeseburger', price: 189, category: '🥩 BEEF BURGER', isMain: true },
{ name: 'Cola', price: 49, category: '🥤 Nápoje', isMain: false },
{ name: 'Kuřecí tortilla', price: 160, category: 'Menu / Tortilly', isMain: true },
{ name: 'Kuřecí polévka', price: 69, category: 'Menu / Tortilly', isMain: false },
]);
});
});
const DAY = 24 * 60 * 60 * 1000;
const ADMIN_PW = 'testadmin';
describe('getStoreMenu', () => {
beforeEach(async () => {
process.env.ADMIN_PASSWORD = ADMIN_PW;
await getStorage().setData('stores', [{ name: 'New York', urls: [BOLT_URL, WOLT_URL] }, { name: 'Bez URL' }]);
});
afterEach(() => {
jest.useRealTimers();
delete process.env.ADMIN_PASSWORD;
});
test('stáhne Bolt jen jednou — uložená nabídka nemá expiraci', async () => {
jest.useFakeTimers({ now: new Date('2026-09-01T10:00:00Z'), doNotFake: ['nextTick', 'setImmediate'] });
mockedAxios.get.mockResolvedValue(boltResponse());
const menu = await getStoreMenu('new york');
expect(menu.source).toBe('bolt');
expect(menu.dishes.filter(d => d.isMain)).toHaveLength(2);
jest.setSystemTime(new Date('2026-12-01T10:00:00Z'));
await getStoreMenu('New York');
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
});
test('při selhání Boltu použije Wolt', async () => {
mockedAxios.get
.mockRejectedValueOnce(new Error('timeout'))
.mockResolvedValueOnce(woltResponse());
const menu = await getStoreMenu('New York');
expect(menu.source).toBe('wolt');
expect(menu.dishes.filter(d => d.isMain).map(d => d.name)).toEqual(['Cheeseburger', 'Kuřecí tortilla']);
const [status] = await getStoreMenuStatus();
expect(status.providers).toEqual([
expect.objectContaining({ provider: 'bolt', state: 'failed', failures: 1, lastError: 'timeout' }),
expect.objectContaining({ provider: 'wolt', state: 'ok', dishCount: 2 }),
]);
});
test('po selhání zkouší nejdřív za 3 dny a po 2 neúspěších službu zablokuje', async () => {
jest.useFakeTimers({ now: new Date('2026-09-01T10:00:00Z'), doNotFake: ['nextTick', 'setImmediate'] });
await getStorage().setData('stores', [{ name: 'Jen Bolt', urls: [BOLT_URL] }]);
mockedAxios.get.mockRejectedValue(new Error('HTTP 500'));
await getStoreMenu('Jen Bolt');
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
jest.setSystemTime(Date.now() + 2 * DAY);
await getStoreMenu('Jen Bolt'); // příliš brzy — bez pokusu
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
jest.setSystemTime(Date.now() + 1 * DAY);
await getStoreMenu('Jen Bolt'); // druhý pokus → zablokováno
expect(mockedAxios.get).toHaveBeenCalledTimes(2);
jest.setSystemTime(Date.now() + 30 * DAY);
const menu = await getStoreMenu('Jen Bolt');
expect(mockedAxios.get).toHaveBeenCalledTimes(2);
expect(menu.source).toBe('none');
const [status] = await getStoreMenuStatus();
expect(status.providers[0]).toEqual(expect.objectContaining({ state: 'blocked', failures: 2, lastError: 'HTTP 500' }));
});
test('HTTP 429 se nepočítá jako selhání a službu dočasně pozastaví', async () => {
jest.useFakeTimers({ now: new Date('2026-09-01T10:00:00Z'), doNotFake: ['nextTick', 'setImmediate'] });
await getStorage().setData('stores', [{ name: 'Jen Bolt', urls: [BOLT_URL] }, { name: 'Jiný Bolt', urls: ['https://food.bolt.eu/cs-cz/427-plzen/p/2-jiny/'] }]);
mockedAxios.get.mockRejectedValueOnce(tooManyRequests('120'));
await getStoreMenu('Jen Bolt');
// Během pauzy se na Bolt vůbec nedotazujeme — ani pro jiný obchod
await getStoreMenu('Jiný Bolt');
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
const status = await getStoreMenuStatus();
expect(status[0].providers[0]).toEqual(expect.objectContaining({ state: 'pending', failures: 0 }));
expect(status[0].providers[0].lastError).toContain('429');
// Po uplynutí Retry-After se hned zkusí znovu (žádný 3denní odklad)
jest.setSystemTime(Date.now() + 121 * 1000);
mockedAxios.get.mockResolvedValue(boltResponse());
const menu = await getStoreMenu('Jen Bolt');
expect(menu.source).toBe('bolt');
});
test('ruční obnovení stáhne i zablokovanou službu a zablokování zruší', async () => {
await getStorage().setData('store_menus', {
'New York': { bolt: { url: BOLT_URL, failures: 2, blocked: true, lastError: 'x', lastAttemptAt: new Date().toISOString() } },
});
mockedAxios.get.mockResolvedValue(boltResponse());
const info = await refreshStoreMenu('new york', 'bolt', ADMIN_PW);
expect(info.providers[0]).toEqual(expect.objectContaining({ provider: 'bolt', state: 'ok', dishCount: 2, failures: 0 }));
expect(info.providers[0].lastError).toBeUndefined();
});
test('neúspěšné ruční obnovení ponechá dříve staženou nabídku', async () => {
mockedAxios.get.mockResolvedValueOnce(boltResponse()).mockRejectedValueOnce(new Error('HTTP 403'));
await getStoreMenu('New York');
const info = await refreshStoreMenu('New York', 'bolt', ADMIN_PW);
expect(info.providers[0]).toEqual(expect.objectContaining({ state: 'ok', dishCount: 2, failures: 1, lastError: 'HTTP 403' }));
});
test('ruční obnovení vyžaduje heslo a URL služby', async () => {
await expect(refreshStoreMenu('New York', 'bolt', 'spatne')).rejects.toThrow('UNAUTHORIZED');
await expect(refreshStoreMenu('Bez URL', 'wolt', ADMIN_PW)).rejects.toThrow('Obchod nemá URL');
});
test('změna URL služby zahodí uloženou nabídku', async () => {
await getStorage().setData('store_menus', {
'New York': { bolt: { url: 'https://food.bolt.eu/cs-cz/427-plzen/p/1-stary/', dishes: [{ name: 'Staré' }], failures: 0, blocked: false } },
});
mockedAxios.get.mockResolvedValue(boltResponse());
const menu = await getStoreMenu('New York');
expect(menu.dishes.filter(d => d.isMain).map(d => d.name)).toEqual(['Chicken Burger', 'Big Cheeseburger']);
});
test('přejmenování obchodu přenese uloženou nabídku', async () => {
await getStorage().setData('store_menus', { 'New York': { bolt: { url: BOLT_URL, dishes: [{ name: 'Burger' }], failures: 0, blocked: false } } });
await renameStoreMenus('new york', 'NY Burger');
expect(await getStorage().getData('store_menus')).toEqual({ 'NY Burger': expect.any(Object) });
});
test('bez stažitelné nabídky použije historii objednávek', async () => {
await getStorage().setData('2026-09-01_extra', {
groups: [{ id: 'g', name: 'Bez URL', creatorLogin: 'a', state: 'delivered', members: { a: { note: 'Kebab box' } } }],
});
const menu = await getStoreMenu('Bez URL');
expect(menu).toEqual({ store: 'Bez URL', source: 'history', dishes: [{ name: 'Kebab box', isMain: true }] });
expect(mockedAxios.get).not.toHaveBeenCalled();
});
test('neznámý obchod vyhodí chybu', async () => {
await expect(getStoreMenu('Neexistuje')).rejects.toThrow('Obchod nebyl nalezen');
});
});
describe('historie cen', () => {
beforeEach(async () => {
process.env.ADMIN_PASSWORD = ADMIN_PW;
await getStorage().setData('stores', [{ name: 'New York', urls: [BOLT_URL] }]);
});
afterEach(() => {
delete process.env.ADMIN_PASSWORD;
});
test('diffMenus najde změněné ceny, nové i odebrané položky', () => {
expect(diffMenus(
[{ name: 'Burger', price: 150 }, { name: 'Hranolky', price: 59 }, { name: 'Salát', price: 99 }],
[{ name: 'burger', price: 165 }, { name: 'Hranolky', price: 59 }, { name: 'Wrap', price: 129 }],
)).toEqual([
{ name: 'burger', before: 150, after: 165 },
{ name: 'Wrap', after: 129 },
{ name: 'Salát', before: 99 },
]);
});
test('první stažení uloží výchozí stav, další jen změny (beze změn nic)', async () => {
const changed = boltResponse();
changed.data.data.items[100 as any].price = { value: 199 };
mockedAxios.get.mockResolvedValueOnce(boltResponse()).mockResolvedValueOnce(boltResponse()).mockResolvedValueOnce(changed);
await getStoreMenu('New York');
await refreshStoreMenu('New York', 'bolt', ADMIN_PW); // beze změny
await refreshStoreMenu('New York', 'bolt', ADMIN_PW); // Big Cheeseburger 189 → 199
const history = await getStoreMenuHistory('new york');
expect(history.store).toBe('New York');
const entries = history.providers[0].entries;
expect(entries).toHaveLength(2);
expect(entries[0].changes).toEqual([{ name: 'Big Cheeseburger', before: 189, after: 199 }]);
expect(entries[1]).toEqual(expect.objectContaining({ initial: true, itemCount: 7, changes: [] }));
});
test('neúspěšné stažení historii nemění', async () => {
mockedAxios.get.mockRejectedValue(new Error('HTTP 500'));
await getStoreMenu('New York');
expect((await getStoreMenuHistory('New York')).providers).toEqual([]);
});
test('nabídka stažená před zavedením historie se zobrazí jako výchozí verze', async () => {
await getStorage().setData('store_menus', {
'New York': { bolt: { url: BOLT_URL, dishes: [{ name: 'Burger', price: 150 }], failures: 0, blocked: false, lastSuccessAt: '2026-09-01T10:00:00.000Z' } },
});
const history = await getStoreMenuHistory('New York');
expect(history.providers).toEqual([
{ provider: 'bolt', entries: [{ at: '2026-09-01T10:00:00.000Z', initial: true, itemCount: 1, changes: [] }] },
]);
});
test('první změna u nabídky stažené před zavedením historie zachová výchozí verzi', async () => {
await getStorage().setData('store_menus', {
'New York': { bolt: { url: BOLT_URL, dishes: [{ name: 'Chicken Burger', price: 150, isMain: true }], failures: 0, blocked: false, lastSuccessAt: '2026-09-01T10:00:00.000Z' } },
});
mockedAxios.get.mockResolvedValue(boltResponse());
await refreshStoreMenu('New York', 'bolt', ADMIN_PW);
const entries = (await getStoreMenuHistory('New York')).providers[0].entries;
expect(entries).toHaveLength(2);
expect(entries[0].changes).toContainEqual({ name: 'Chicken Burger', before: 150, after: 169 });
expect(entries[1]).toEqual({ at: '2026-09-01T10:00:00.000Z', initial: true, itemCount: 1, changes: [] });
});
});
+25 -1
View File
@@ -1,6 +1,6 @@
import { resetMemoryStorage } from '../storage/memory'; import { resetMemoryStorage } from '../storage/memory';
import getStorage from '../storage'; import getStorage from '../storage';
import { getStores, addStore, updateStore, removeStore } from '../stores'; import { getStores, addStore, updateStore, removeStore, getStoreDishes } from '../stores';
const ADMIN_PW = 'testadmin'; const ADMIN_PW = 'testadmin';
@@ -213,3 +213,27 @@ describe('removeStore', () => {
expect(names(stores)).toContain('McDonald\'s'); expect(names(stores)).toContain('McDonald\'s');
}); });
}); });
describe('getStoreDishes', () => {
/** Uloží skupinové objednávky pro daný den ve formátu `YYYY-MM-DD_extra`. */
const saveGroups = (date: string, groups: { name: string, notes: string[] }[]) =>
getStorage().setData(`${date}_extra`, {
groups: groups.map((g, gi) => ({
id: `${date}-${gi}`, name: g.name, creatorLogin: 'a', state: 'delivered',
members: Object.fromEntries(g.notes.map((note, i) => [`user${i}`, { note }])),
})),
});
test('vrátí jídla dle četnosti, bez duplicit, polévek a čísel položek', async () => {
await getStorage().setData('stores', [{ name: 'China Express' }, { name: 'Everest' }]);
await saveGroups('2026-09-01', [{ name: 'China Express', notes: ['28. Vepřové v pekingské omáčce', 'Kuře na medu', 'Polévka Pho'] }]);
await saveGroups('2026-09-02', [{ name: 'china express', notes: ['kuře na medu', ' ', '⬇️ (to samé)', 'Hovězí se zeleninou'] }]);
await saveGroups('2026-09-03', [{ name: 'Neznámý podnik', notes: ['Burger'] }]);
const result = await getStoreDishes();
expect(result).toEqual([
{ store: 'China Express', dishes: ['Kuře na medu', 'Hovězí se zeleninou', 'Vepřové v pekingské omáčce'] },
{ store: 'Everest', dishes: [] },
]);
});
});
+64
View File
@@ -0,0 +1,64 @@
import express from 'express';
import request from 'supertest';
import { getTrustedRemoteUser, isTrustedPeer, parseTrustedIps } from '../trustedHeaders';
describe('parseTrustedIps / isTrustedPeer', () => {
const trusted = parseTrustedIps('10.42.0.0/16, 127.0.0.1, ::1, fd00::/8');
test('rozsah CIDR i jednotlivé adresy (IPv4 i IPv6)', () => {
expect(isTrustedPeer('10.42.0.84', trusted)).toBe(true);
expect(isTrustedPeer('10.43.0.1', trusted)).toBe(false);
expect(isTrustedPeer('127.0.0.1', trusted)).toBe(true);
expect(isTrustedPeer('::1', trusted)).toBe(true);
expect(isTrustedPeer('fd12:3456::1', trusted)).toBe(true);
expect(isTrustedPeer('192.168.1.10', trusted)).toBe(false);
});
test('IPv4 mapovaná do IPv6 (jak ji hlásí Node) se porovnává jako IPv4', () => {
expect(isTrustedPeer('::ffff:10.42.0.84', trusted)).toBe(true);
expect(isTrustedPeer('::FFFF:8.8.8.8', trusted)).toBe(false);
});
test('chybějící nebo nesmyslná adresa není důvěryhodná', () => {
expect(isTrustedPeer(undefined, trusted)).toBe(false);
expect(isTrustedPeer('nesmysl', trusted)).toBe(false);
});
test('neplatná konfigurace vyhodí chybu (server nenaběhne)', () => {
expect(() => parseTrustedIps('10.0.0.0/33')).toThrow('neplatný rozsah');
expect(() => parseTrustedIps('proxy.local')).toThrow('neplatná adresa');
expect(() => parseTrustedIps('10.0.0.0/x')).toThrow('neplatný rozsah');
});
test('prázdný seznam nedůvěřuje nikomu', () => {
expect(isTrustedPeer('127.0.0.1', parseTrustedIps(''))).toBe(false);
});
});
describe('getTrustedRemoteUser', () => {
/** Mini aplikace, která vrátí identitu z hlavičky (jako /api/whoami). */
function buildApp(trustedList: string) {
const trusted = parseTrustedIps(trustedList);
const app = express();
app.get('/whoami', (req, res) => res.send(getTrustedRemoteUser(req, 'remote-user', trusted) ?? ''));
return app;
}
test('hlavička od důvěryhodné adresy se přijme (i s diakritikou v latin1)', async () => {
const res = await request(buildApp('127.0.0.1,::1,::ffff:127.0.0.1'))
.get('/whoami')
.set('remote-user', Buffer.from('Novák', 'utf8').toString('latin1'));
expect(res.text).toBe('Novák');
});
test('hlavička od nedůvěryhodné adresy se ignoruje — ani X-Forwarded-For nepomůže', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => { });
const res = await request(buildApp('10.42.0.0/16'))
.get('/whoami')
.set('remote-user', 'utocnik')
.set('X-Forwarded-For', '10.42.0.84');
expect(res.text).toBe('');
expect(warn).toHaveBeenCalledWith(expect.stringContaining('nedůvěryhodné adresy'));
warn.mockRestore();
});
});
+24 -3
View File
@@ -67,6 +67,14 @@ describe('getUserExportRows', () => {
}); });
}); });
test('u volby podniku vyplní uživatelem zadanou cenu', async () => {
await saveLunch('2025-03-04', { SPSE: { [USER]: { price: 9500 } } });
const rows = await getUserExportRows(USER, YEAR, MONTH);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ type: 'SPŠE', amount: 9500 });
});
test('u volby "budu objednávat" sloučí záznam s objednávkovou skupinou včetně ceny po slevě', async () => { test('u volby "budu objednávat" sloučí záznam s objednávkovou skupinou včetně ceny po slevě', async () => {
await saveLunch('2025-03-06', { OBJEDNAVAM: { [USER]: { note: 'platím kartou' } } }); await saveLunch('2025-03-06', { OBJEDNAVAM: { [USER]: { note: 'platím kartou' } } });
await saveExtra('2025-03-06', [{ await saveExtra('2025-03-06', [{
@@ -167,18 +175,31 @@ describe('getUserExportRows', () => {
}); });
}); });
test('člen skupiny, který si nic neobjednal, nemá vyplněnou částku', async () => { test('skupinu, kde si člen nic neobjednal a nemá poznámku, vynechá', async () => {
await saveLunch('2025-03-12', { OBJEDNAVAM: { [USER]: {} } });
await saveExtra('2025-03-12', [
{ id: 'g3', name: 'KFC', creatorLogin: USER, state: GroupState.ORDERED, members: { [USER]: {}, [OTHER]: { amount: 15000 } } },
{ id: 'g4', name: 'Namaste', creatorLogin: USER, state: GroupState.ORDERED, members: { [USER]: { amount: 22000, note: 'Chicken Tikka' } } },
]);
const rows = await getUserExportRows(USER, YEAR, MONTH);
// Volba "budu objednávat" se sloučí až se skupinou, kde si uživatel reálně objednal
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ type: 'Budu objednávat', store: 'Namaste', food: 'Chicken Tikka', amount: 22000 });
});
test('člen skupiny, který si nic neobjednal, ale má poznámku, nemá vyplněnou částku', async () => {
await saveExtra('2025-03-12', [{ await saveExtra('2025-03-12', [{
id: 'g3', id: 'g3',
name: 'KFC', name: 'KFC',
creatorLogin: USER, creatorLogin: USER,
state: GroupState.ORDERED, state: GroupState.ORDERED,
members: { [USER]: {}, [OTHER]: { amount: 15000 } }, members: { [USER]: { note: 'beru si jen pití od kolegy' }, [OTHER]: { amount: 15000 } },
}]); }]);
const rows = await getUserExportRows(USER, YEAR, MONTH); const rows = await getUserExportRows(USER, YEAR, MONTH);
expect(rows).toHaveLength(1); expect(rows).toHaveLength(1);
// Objednávající je uveden, ale částka zůstává prázdná — není co platit // Záznam je uveden kvůli poznámce, ale částka zůstává prázdná — není co platit
expect(rows[0].orderedBy).toBe(USER); expect(rows[0].orderedBy).toBe(USER);
expect(rows[0].amount).toBeUndefined(); expect(rows[0].amount).toBeUndefined();
expect(buildCsv(rows).split('\r\n')[1].split(';')[7]).toBe(''); expect(buildCsv(rows).split('\r\n')[1].split(';')[7]).toBe('');
+64
View File
@@ -0,0 +1,64 @@
import net from "net";
import type { Request } from "express";
/**
* Seznam adres, ze kterých smí přijít hlavička s identitou uživatele (přihlášení přes proxy).
* Podporuje jednotlivé adresy i rozsahy CIDR, IPv4 i IPv6 (např. "10.42.0.0/16,127.0.0.1,::1").
* Neplatná položka je chyba konfigurace — server kvůli ní nenaběhne (lepší než tiše důvěřovat).
*/
export function parseTrustedIps(list: string): net.BlockList {
const blockList = new net.BlockList();
for (const raw of list.split(',').map(s => s.trim()).filter(Boolean)) {
const [address, prefix] = raw.split('/');
const family = net.isIP(address);
if (!family) {
throw new Error(`HTTP_REMOTE_TRUSTED_IPS: neplatná adresa "${raw}"`);
}
const type = family === 4 ? 'ipv4' : 'ipv6';
if (prefix === undefined) {
blockList.addAddress(address, type);
continue;
}
const bits = Number(prefix);
if (!Number.isInteger(bits) || bits < 0 || bits > (family === 4 ? 32 : 128)) {
throw new Error(`HTTP_REMOTE_TRUSTED_IPS: neplatný rozsah "${raw}"`);
}
blockList.addSubnet(address, bits, type);
}
return blockList;
}
/**
* Je adresa přímého protějšku spojení mezi důvěryhodnými? IPv4 mapovaná do IPv6
* („::ffff:10.42.0.84", jak ji hlásí Node na dual-stack socketu) se porovnává jako IPv4.
*/
export function isTrustedPeer(address: string | undefined, trusted: net.BlockList): boolean {
if (!address) return false;
const mapped = address.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
const normalized = mapped ? mapped[1] : address;
const family = net.isIP(normalized);
if (!family) return false;
return trusted.check(normalized, family === 4 ? 'ipv4' : 'ipv6');
}
const warnedPeers = new Set<string>();
/**
* Vrátí identitu uživatele z hlavičky proxy, ale jen pokud spojení přišlo z důvěryhodné adresy.
* Rozhoduje skutečná adresa TCP spojení (ne req.ip, které lze ovlivnit hlavičkou X-Forwarded-For).
* Hlavičku od nedůvěryhodné adresy ignoruje a jednou za běh ji zaloguje (odhalí chybnou konfiguraci).
*/
export function getTrustedRemoteUser(req: Request, headerName: string, trusted: net.BlockList): string | undefined {
const value = req.header(headerName);
if (!value) return undefined;
const peer = req.socket?.remoteAddress;
if (!isTrustedPeer(peer, trusted)) {
const key = peer ?? 'neznámá adresa';
if (!warnedPeers.has(key)) {
warnedPeers.add(key);
console.warn(`Přihlášení přes hlavičky: ignoruji hlavičku ${headerName} z nedůvěryhodné adresy ${key} (viz HTTP_REMOTE_TRUSTED_IPS)`);
}
return undefined;
}
return Buffer.from(value, 'latin1').toString();
}
+9 -3
View File
@@ -59,6 +59,11 @@ function isActiveMember(member: OrderGroupMember): boolean {
return (member.amount ?? 0) + (member.surchargeAmount ?? 0) > 0; return (member.amount ?? 0) + (member.surchargeAmount ?? 0) > 0;
} }
/** Vrátí true, pokud má členství ve skupině do přehledu co přinést (položku nebo poznámku). */
function isRelevantMember(member: OrderGroupMember | undefined): boolean {
return !!member && (isActiveMember(member) || !!member.note?.trim().length);
}
/** /**
* Vypočte částku člena skupiny po rozpočítání poplatků a slevy (v haléřích). * Vypočte částku člena skupiny po rozpočítání poplatků a slevy (v haléřích).
* Poplatky i pevná sleva se dělí pouze mezi členy, kteří si něco objednali. * Poplatky i pevná sleva se dělí pouze mezi členy, kteří si něco objednali.
@@ -152,7 +157,7 @@ export async function getUserExportRows(login: string, year: number, month: numb
let food: string | undefined; let food: string | undefined;
let note = userChoice.note; let note = userChoice.note;
let amount: number | undefined; let amount = userChoice.price;
if (locationKey === LunchChoice.PIZZA) { if (locationKey === LunchChoice.PIZZA) {
const pizza = getPizzaText(lunchData, login); const pizza = getPizzaText(lunchData, login);
food = pizza.food; food = pizza.food;
@@ -177,8 +182,9 @@ export async function getUserExportRows(login: string, year: number, month: numb
break; break;
} }
// Objednávkové skupiny, ve kterých byl uživatel členem // Objednávkové skupiny, ve kterých si uživatel něco objednal nebo k nim má poznámku.
const groups = (extraData?.groups ?? []).filter(group => !!group.members[login]); // Zakladatel skupiny, který si z ní nic nevzal, v ní figuruje jen formálně — řádek by byl prázdný.
const groups = (extraData?.groups ?? []).filter(group => isRelevantMember(group.members[login]));
const groupRows: UserExportRow[] = []; const groupRows: UserExportRow[] = [];
let mergedGroup = false; let mergedGroup = false;
for (const group of groups) { for (const group of groups) {
+2
View File
@@ -1,6 +1,7 @@
import { DefaultEventsMap, Server } from "socket.io"; import { DefaultEventsMap, Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter"; import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis"; import { createClient } from "redis";
import { registerDrawingGame } from "./drawingGame";
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>; let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
let pubClient: ReturnType<typeof createClient>; let pubClient: ReturnType<typeof createClient>;
@@ -28,6 +29,7 @@ export const initWebsocket = (server: any) => {
// console.log(`Client disconnected: ${socket.id}`); // console.log(`Client disconnected: ${socket.id}`);
}); });
}); });
registerDrawingGame(io);
return io; return io;
}; };
+727 -664
View File
File diff suppressed because it is too large. Load diff
+10
View File
@@ -24,6 +24,8 @@ paths:
$ref: "./paths/food/removeChoice.yml" $ref: "./paths/food/removeChoice.yml"
/food/updateNote: /food/updateNote:
$ref: "./paths/food/updateNote.yml" $ref: "./paths/food/updateNote.yml"
/food/updatePrice:
$ref: "./paths/food/updatePrice.yml"
/food/removeChoices: /food/removeChoices:
$ref: "./paths/food/removeChoices.yml" $ref: "./paths/food/removeChoices.yml"
/food/changeDepartureTime: /food/changeDepartureTime:
@@ -150,6 +152,14 @@ paths:
# Správa obchodů (/api/stores) # Správa obchodů (/api/stores)
/stores: /stores:
$ref: "./paths/stores/listStores.yml" $ref: "./paths/stores/listStores.yml"
/stores/menu:
$ref: "./paths/stores/storeMenu.yml"
/stores/menu/status:
$ref: "./paths/stores/storeMenuStatus.yml"
/stores/menu/refresh:
$ref: "./paths/stores/refreshStoreMenu.yml"
/stores/menu/history:
$ref: "./paths/stores/storeMenuHistory.yml"
/stores/add: /stores/add:
$ref: "./paths/stores/addStore.yml" $ref: "./paths/stores/addStore.yml"
/stores/update: /stores/update:
+30 -2
View File
@@ -1,6 +1,6 @@
get: get:
operationId: getConfig operationId: getConfig
summary: Vrátí veřejnou runtime konfiguraci pro klienta (např. Sentry DSN). Nevyžaduje autentizaci. summary: Vrátí veřejnou runtime konfiguraci pro klienta (Sentry DSN, limity objednávek, způsob přihlášení, zapnuté funkce, odkazy). Nevyžaduje autentizaci.
security: [] # Nevyžaduje autentizaci security: [] # Nevyžaduje autentizaci
responses: responses:
"200": "200":
@@ -9,8 +9,36 @@ get:
application/json: application/json:
schema: schema:
type: object type: object
required: [sentry] required: [sentry, limits, auth, features, links]
properties: properties:
auth:
type: object
required: [trustedHeaders]
properties:
trustedHeaders:
type: boolean
description: Přihlášení probíhá přes důvěryhodné hlavičky z proxy (HTTP_REMOTE_USER_ENABLED) — klient nezobrazuje formulář se jménem
features:
type: object
required: [storeAdmin]
properties:
storeAdmin:
type: boolean
description: Správa obchodů je zapnutá (server má nastavené ADMIN_PASSWORD)
links:
type: object
required: [lobbyUrl]
properties:
lobbyUrl:
type: string
nullable: true
description: Adresa aplikace Lobby s minihrami (LOBBY_URL), null = odkaz není nastaven
limits:
type: object
required: [group]
properties:
group:
$ref: "../../schemas/_index.yml#/GroupLimits"
sentry: sentry:
type: object type: object
required: [dsn, environment] required: [dsn, environment]
+18
View File
@@ -0,0 +1,18 @@
post:
operationId: updatePrice
summary: Nastavení ceny oběda k volbě uživatele (pro přehled stravování). Lze jen u voleb podniků, ne u objednávání, Pizza day, "neobědvám" a "rozhoduji se".
requestBody:
required: true
content:
application/json:
schema:
properties:
dayIndex:
$ref: "../../schemas/_index.yml#/DayIndex"
price:
description: Cena v haléřích. Pokud není předána, cena se odstraní.
type: integer
minimum: 0
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"
+29
View File
@@ -0,0 +1,29 @@
post:
operationId: refreshStoreMenu
summary: Ručně znovu stáhne nabídku obchodu od dané služby (vyžaduje admin heslo). Zruší případné zablokování automatického stahování.
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
- provider
- heslo
properties:
name:
description: Název obchodu
type: string
provider:
$ref: "../../schemas/_index.yml#/StoreMenuProvider"
heslo:
description: Admin heslo (ADMIN_PASSWORD)
type: string
responses:
"200":
description: Aktuální stav stahování nabídek obchodu (i při neúspěchu — chyba je v lastError)
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/StoreScrapeInfo"
+19
View File
@@ -0,0 +1,19 @@
get:
operationId: getStoreMenu
summary: Vrátí nabídku obchodu — staženou z Bolt Food/Wolt (hlavní jídla označena isMain), případně jídla z historie objednávek.
parameters:
- in: query
name: name
required: true
description: Název obchodu ze seznamu povolených
schema:
type: string
responses:
"200":
description: Nabídka obchodu
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/StoreMenu"
"404":
description: Obchod nebyl nalezen
+19
View File
@@ -0,0 +1,19 @@
get:
operationId: getStoreMenuHistory
summary: Vrátí historii cen nabídky obchodu (verze z jednotlivých stažení z Bolt Food/Wolt).
parameters:
- in: query
name: name
required: true
description: Název obchodu
schema:
type: string
responses:
"200":
description: Historie cen
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/StorePriceHistory"
"404":
description: Obchod nebyl nalezen
+12
View File
@@ -0,0 +1,12 @@
get:
operationId: getStoreMenuStatus
summary: Vrátí stav stahování nabídek (Bolt Food/Wolt) pro všechny obchody.
responses:
"200":
description: Stav stahování nabídek
content:
application/json:
schema:
type: array
items:
$ref: "../../schemas/_index.yml#/StoreScrapeInfo"
+231
View File
@@ -101,6 +101,9 @@ UserLunchChoice:
note: note:
description: Volitelná, veřejně viditelná uživatelská poznámka k vybrané volbě description: Volitelná, veřejně viditelná uživatelská poznámka k vybrané volbě
type: string type: string
price:
description: Uživatelem zadaná cena oběda v haléřích, promítá se do přehledu stravování
type: integer
isBuyer: isBuyer:
description: Příznak, zda je tento uživatel objednatelem pro stav "Budu objednávat" description: Příznak, zda je tento uživatel objednatelem pro stav "Budu objednávat"
type: boolean type: boolean
@@ -933,6 +936,234 @@ Store:
items: items:
type: string type: string
StoreMenuSource:
description: Zdroj nabídky obchodu pro kolo štěstí
type: string
enum:
- bolt
- wolt
- history
- none
x-enum-varnames:
- BOLT
- WOLT
- HISTORY
- NONE
GroupLimits:
description: Limity skupinových objednávek (pojistky proti překlepům). Výchozí hodnoty lze přepsat env proměnnými GROUP_MAX_*.
type: object
additionalProperties: false
required:
- maxNoteLength
- maxSurchargeTextLength
- maxNameLength
- maxUrlLength
- maxAmountKc
- maxItemQuantity
- maxItems
properties:
maxNoteLength:
description: Max. délka poznámky člena (znaky)
type: integer
maxSurchargeTextLength:
description: Max. délka popisu příplatku (znaky)
type: integer
maxNameLength:
description: Max. délka názvu skupiny (znaky)
type: integer
maxUrlLength:
description: Max. délka URL nabídky / sledování (znaky)
type: integer
maxAmountKc:
description: Max. výše jedné částky (útrata, příplatek, poplatky, pevná sleva) v Kč
type: integer
maxItemQuantity:
description: Max. počet kusů jedné položky při výběru z nabídky
type: integer
maxItems:
description: Max. celkový počet položek objednávky při výběru z nabídky
type: integer
StoreMenuProvider:
description: Dovozová služba, ze které lze stáhnout nabídku podniku
type: string
enum:
- bolt
- wolt
x-enum-varnames:
- BOLT
- WOLT
StoreScrapeState:
description: |
Stav stahování nabídky od dané služby:
ok = nabídka stažena, pending = zatím nezkoušeno, failed = selhalo (automaticky se ještě zkusí),
blocked = opakovaně selhalo, automaticky se už nezkouší (jen ruční obnovení)
type: string
enum:
- ok
- pending
- failed
- blocked
x-enum-varnames:
- OK
- PENDING
- FAILED
- BLOCKED
StoreScrapeStatus:
description: Informace o stahování nabídky obchodu od jedné dovozové služby
type: object
additionalProperties: false
required:
- provider
- url
- state
- dishCount
- failures
properties:
provider:
$ref: "#/StoreMenuProvider"
url:
description: URL podniku u dané služby
type: string
state:
$ref: "#/StoreScrapeState"
dishCount:
description: Počet uložených hlavních jídel
type: integer
failures:
description: Počet neúspěšných pokusů od posledního úspěchu
type: integer
lastSuccessAt:
description: Čas posledního úspěšného stažení (ISO)
type: string
lastAttemptAt:
description: Čas posledního pokusu (ISO)
type: string
lastError:
description: Chyba posledního neúspěšného pokusu
type: string
StoreScrapeInfo:
description: Stav stahování nabídek obchodu od všech podporovaných služeb
type: object
additionalProperties: false
required:
- store
- providers
properties:
store:
type: string
providers:
type: array
items:
$ref: "#/StoreScrapeStatus"
StorePriceChange:
description: Změna jedné položky nabídky mezi dvěma staženími (bez before = nová položka, bez after = odebraná)
type: object
additionalProperties: false
required:
- name
properties:
name:
type: string
before:
description: Předchozí cena v Kč
type: number
after:
description: Nová cena v Kč
type: number
StorePriceHistoryEntry:
description: Jedna verze nabídky — výchozí stav (initial) nebo změny oproti předchozímu stažení
type: object
additionalProperties: false
required:
- at
- changes
properties:
at:
description: Čas stažení (ISO)
type: string
initial:
description: První stažená verze nabídky (výchozí stav, bez změn)
type: boolean
itemCount:
description: Počet položek v nabídce po tomto stažení
type: integer
changes:
type: array
items:
$ref: "#/StorePriceChange"
StorePriceHistory:
description: Historie cen nabídky obchodu dle dovozové služby (nejnovější první)
type: object
additionalProperties: false
required:
- store
- providers
properties:
store:
type: string
providers:
type: array
items:
type: object
additionalProperties: false
required:
- provider
- entries
properties:
provider:
$ref: "#/StoreMenuProvider"
entries:
type: array
items:
$ref: "#/StorePriceHistoryEntry"
StoreMenuDish:
description: Jídlo z nabídky obchodu
type: object
additionalProperties: false
required:
- name
properties:
name:
description: Název jídla
type: string
price:
description: Cena v Kč (pokud je známa)
type: number
category:
description: Kategorie v nabídce podniku
type: string
isMain:
description: Hlavní jídlo (ne polévka, nápoj, příloha, omáčka...) — jen ta jdou do kola štěstí
type: boolean
StoreMenu:
description: Nabídka obchodu (kolo štěstí bere jen hlavní jídla, nápověda u objednávek celou nabídku)
type: object
additionalProperties: false
required:
- store
- source
- dishes
properties:
store:
description: Název obchodu/restaurace
type: string
source:
$ref: "#/StoreMenuSource"
dishes:
type: array
items:
$ref: "#/StoreMenuDish"
TrackingProvider: TrackingProvider:
description: Rozvozová služba, přes kterou lze sledovat stav objednávky description: Rozvozová služba, přes kterou lze sledovat stav objednávky
type: string type: string