Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35cf2ca9d7
|
||
|
|
8a7bbef729
|
||
|
|
667f3c1ef1 | ||
|
|
5d13efb685 | ||
|
|
d4485db0b6 | ||
|
|
6beb15afe3 | ||
|
|
9a3987a2fa | ||
|
|
96dc3f0053 | ||
|
|
d2125a050b | ||
|
|
a8b67fd76e | ||
|
|
304c7656e9
|
||
|
|
bc2cc75d7d
|
||
|
|
fe12098ed0 | ||
|
|
95a35df6ab
|
||
|
|
297cb80662
|
||
|
|
ed64fc60e4
|
||
|
|
8a7dbd94c8 | ||
|
|
9fa00fa9c5
|
||
|
|
dc17fa0f0f
|
||
|
|
4bc2151cc4
|
||
|
|
e570f308cd
|
||
|
|
0cefaa62a0
|
||
|
|
2a54a1d41f | ||
|
|
5147651e7c | ||
|
|
6cb1214f08 | ||
|
|
abeee19259 | ||
|
|
f91193c7de
|
||
|
|
93d86fe0a5
|
||
|
|
bf631eb913
|
||
|
|
49508dacd0
|
||
|
|
9fee4a1538
|
||
|
|
7cda7cfdb6 | ||
|
|
89d2cc2bc3 | ||
|
|
a29e329c11
|
||
|
|
5f54925808 | ||
|
|
103445dba8 | ||
|
|
076b8db179 | ||
|
|
f9a7b791a0 | ||
|
|
bd52ae071e | ||
|
|
0a59f97c5c | ||
|
|
e7c4ba09ff | ||
|
|
e281615cc3 | ||
|
|
682deaf20b
|
||
|
|
92ea78a870
|
||
|
|
51df4c812e
|
||
|
|
ed53a1477e
|
||
|
|
6a12bdb179
|
||
|
|
31b398a1d6
|
||
|
|
b6834cb7aa
|
||
|
|
ecc28ee82d
|
||
|
|
1af40bd572
|
||
|
|
e5fc358c0a
|
||
|
|
6f6567a2a9
|
||
|
|
ce051447e6
|
||
|
|
93242b7c7e
|
||
|
|
9027de5f8b
|
||
|
|
936ce2a9e4 | ||
|
|
5c048fe1f1 | ||
|
|
eeb1630391 | ||
|
|
065ccaf38a | ||
|
|
377a350211 | ||
|
|
252d105408 | ||
|
|
e3f439bde0 | ||
|
|
c5ca6c9d41 | ||
|
|
c8c5ecc60c | ||
|
|
146c31b775
|
||
|
|
0232e6ea26
|
||
|
|
87c12501bb
|
||
|
|
56ceb6e3cb
|
||
|
|
e1ada97184 | ||
|
|
9ea2916c7a | ||
|
|
f8c5d28f47 | ||
|
|
12fa87f3ad | ||
|
|
a708eda421
|
||
|
|
464b14d63a
|
||
|
|
9a03dd1e5e
|
||
|
|
c3a8900283
|
No files matched your search
@@ -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
|
||||||
@@ -7,3 +7,5 @@ server/public/
|
|||||||
.claude/*.lock
|
.claude/*.lock
|
||||||
.claude/worktrees
|
.claude/worktrees
|
||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
|
.idea/
|
||||||
|
**/.yarn/install-state.gz
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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í
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.1.0",
|
"@fortawesome/react-fontawesome": "^3.1.0",
|
||||||
|
"@sentry/react": "^10.65.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.2",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<!-- Netopýr (čelem doprava; JS natáčí dle směru letu) -->
|
||||||
|
<g fill="#2B2733" stroke="#15121A" stroke-width="1.5" stroke-linejoin="round">
|
||||||
|
<!-- křídla -->
|
||||||
|
<path d="M50,52 C34,30 18,30 6,40 C16,42 18,52 14,62 C24,54 34,52 44,58 Z"/>
|
||||||
|
<path d="M50,52 C66,30 82,30 94,40 C84,42 82,52 86,62 C76,54 66,52 56,58 Z"/>
|
||||||
|
<!-- tělo -->
|
||||||
|
<ellipse cx="50" cy="56" rx="9" ry="13"/>
|
||||||
|
<!-- uši -->
|
||||||
|
<path d="M44,44 L40,32 L48,42 Z"/>
|
||||||
|
<path d="M56,44 L60,32 L52,42 Z"/>
|
||||||
|
</g>
|
||||||
|
<!-- oči -->
|
||||||
|
<g fill="#F5C518">
|
||||||
|
<circle cx="46" cy="50" r="1.8"/>
|
||||||
|
<circle cx="54" cy="50" r="1.8"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 716 B |
@@ -0,0 +1,26 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<!-- Vosa letící doprava (JS překlápí podle směru) -->
|
||||||
|
<!-- křídla -->
|
||||||
|
<g fill="#DDEEFF" stroke="#9BB6CC" stroke-width="1" opacity="0.85">
|
||||||
|
<ellipse cx="46" cy="30" rx="14" ry="8" transform="rotate(-18 46 30)"/>
|
||||||
|
<ellipse cx="58" cy="32" rx="11" ry="6" transform="rotate(-8 58 32)"/>
|
||||||
|
</g>
|
||||||
|
<!-- žihadlo -->
|
||||||
|
<path fill="#3A2E00" d="M18,50 L6,50 L18,46 Z"/>
|
||||||
|
<!-- tělo -->
|
||||||
|
<g stroke="#3A2E00" stroke-width="1.4" stroke-linejoin="round">
|
||||||
|
<ellipse cx="46" cy="50" rx="30" ry="16" fill="#F5C518"/>
|
||||||
|
<!-- pruhy -->
|
||||||
|
<path fill="#2B2200" stroke="none" d="M34,36 q6,14 0,28 q-8,-2 -10,-14 q2,-12 10,-14 z"/>
|
||||||
|
<path fill="#2B2200" stroke="none" d="M50,35 q6,15 0,30 q-8,-3 -8,-15 q0,-12 8,-15 z"/>
|
||||||
|
<!-- hlava -->
|
||||||
|
<circle cx="72" cy="50" r="10" fill="#2B2200"/>
|
||||||
|
</g>
|
||||||
|
<!-- oko a tykadla -->
|
||||||
|
<circle fill="#FFFFFF" cx="75" cy="47" r="2.4"/>
|
||||||
|
<circle fill="#20313F" cx="75.6" cy="47" r="1.2"/>
|
||||||
|
<g stroke="#2B2200" stroke-width="1.2" fill="none" stroke-linecap="round">
|
||||||
|
<path d="M78,42 C84,36 88,36 90,38"/>
|
||||||
|
<path d="M80,44 C86,40 90,41 92,43"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<!-- Pták letící doprava (JS překlápí podle směru) -->
|
||||||
|
<g stroke="#3A4A5A" stroke-width="1.5" stroke-linejoin="round">
|
||||||
|
<!-- ocas -->
|
||||||
|
<path fill="#5C6B7A" d="M18,52 L4,44 L6,56 L2,64 L18,60 Z"/>
|
||||||
|
<!-- tělo -->
|
||||||
|
<path fill="#6E7F90" d="M16,58 C24,44 44,40 64,46 C74,49 82,54 84,56 C78,62 66,66 52,66 C36,66 22,64 16,58 Z"/>
|
||||||
|
<!-- křídlo (nahoru) -->
|
||||||
|
<path fill="#55636F" d="M40,52 C46,30 60,24 70,26 C64,38 58,48 54,56 C50,58 44,57 40,52 Z"/>
|
||||||
|
<!-- hlava -->
|
||||||
|
<circle fill="#6E7F90" cx="78" cy="50" r="12"/>
|
||||||
|
</g>
|
||||||
|
<!-- zobák -->
|
||||||
|
<path fill="#F6A21E" stroke="#B9740A" stroke-width="1" stroke-linejoin="round" d="M88,48 L98,50 L88,54 Z"/>
|
||||||
|
<!-- oko -->
|
||||||
|
<circle fill="#20313F" cx="82" cy="47" r="2.2"/>
|
||||||
|
<circle fill="#FFFFFF" cx="82.8" cy="46.3" r="0.7"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 898 B |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<g stroke="#1B3F73" stroke-width="1.5" stroke-linejoin="round">
|
||||||
|
<path fill="#4A8FE7" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
|
||||||
|
<path fill="#4A8FE7" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
|
||||||
|
<path fill="#4A8FE7" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
|
||||||
|
<path fill="#4A8FE7" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#BBD7FF" stroke="none">
|
||||||
|
<circle cx="20" cy="30" r="4"/>
|
||||||
|
<circle cx="80" cy="30" r="4"/>
|
||||||
|
<circle cx="30" cy="70" r="3"/>
|
||||||
|
<circle cx="70" cy="70" r="3"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#17233B" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||||
|
<path d="M49,28 C44,18 40,14 36,11"/>
|
||||||
|
<path d="M51,28 C56,18 60,14 64,11"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#17233B" stroke="none">
|
||||||
|
<circle cx="36" cy="11" r="1.8"/>
|
||||||
|
<circle cx="64" cy="11" r="1.8"/>
|
||||||
|
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
|
||||||
|
<circle cx="50" cy="24" r="4.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,66 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="goldGlow" cx="0.5" cy="0.5" r="0.55">
|
||||||
|
<stop offset="0%" stop-color="#FFF6C8" stop-opacity="0.9"/>
|
||||||
|
<stop offset="55%" stop-color="#FFD24A" stop-opacity="0.35"/>
|
||||||
|
<stop offset="100%" stop-color="#FFD24A" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="goldWing" x1="0" y1="0" x2="0.7" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#FFFBE6"/>
|
||||||
|
<stop offset="35%" stop-color="#FFDE6A"/>
|
||||||
|
<stop offset="70%" stop-color="#F5B617"/>
|
||||||
|
<stop offset="100%" stop-color="#C97B00"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="goldSheen" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.9"/>
|
||||||
|
<stop offset="100%" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Zářivá aura -->
|
||||||
|
<circle cx="50" cy="48" r="48" fill="url(#goldGlow)"/>
|
||||||
|
|
||||||
|
<!-- Křídla -->
|
||||||
|
<g stroke="#8A5A00" stroke-width="1.6" stroke-linejoin="round">
|
||||||
|
<path fill="url(#goldWing)" d="M48,32 C30,8 6,12 6,34 C6,52 30,54 48,50 Z"/>
|
||||||
|
<path fill="url(#goldWing)" d="M52,32 C70,8 94,12 94,34 C94,52 70,54 52,50 Z"/>
|
||||||
|
<path fill="url(#goldWing)" d="M47,50 C33,54 14,58 16,76 C18,89 41,86 47,66 Z"/>
|
||||||
|
<path fill="url(#goldWing)" d="M53,50 C67,54 86,58 84,76 C82,89 59,86 53,66 Z"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Drahokamové body na křídlech -->
|
||||||
|
<g fill="#FFF8D8" stroke="#E8B23A" stroke-width="0.6">
|
||||||
|
<circle cx="20" cy="30" r="4.5"/>
|
||||||
|
<circle cx="80" cy="30" r="4.5"/>
|
||||||
|
<circle cx="29" cy="72" r="3.5"/>
|
||||||
|
<circle cx="71" cy="72" r="3.5"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#B8860B">
|
||||||
|
<circle cx="20" cy="30" r="1.6"/>
|
||||||
|
<circle cx="80" cy="30" r="1.6"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Lesklý přeliv přes horní křídla -->
|
||||||
|
<path fill="url(#goldSheen)" opacity="0.55" d="M48,33 C34,15 16,17 12,31 C24,26 40,27 48,40 Z"/>
|
||||||
|
|
||||||
|
<!-- Tykadla -->
|
||||||
|
<g stroke="#6A4A00" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||||
|
<path d="M49,28 C44,17 40,13 36,10"/>
|
||||||
|
<path d="M51,28 C56,17 60,13 64,10"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Tělo -->
|
||||||
|
<g fill="#5A3E00" stroke="none">
|
||||||
|
<circle cx="36" cy="10" r="2"/>
|
||||||
|
<circle cx="64" cy="10" r="2"/>
|
||||||
|
<rect x="47" y="24" width="6" height="52" rx="3"/>
|
||||||
|
<circle cx="50" cy="24" r="5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Jiskry -->
|
||||||
|
<g fill="#FFFFFF">
|
||||||
|
<path d="M50 2 l1.4 3.4 l3.4 1.4 l-3.4 1.4 l-1.4 3.4 l-1.4 -3.4 l-3.4 -1.4 l3.4 -1.4 z"/>
|
||||||
|
<path d="M90 20 l1 2.6 l2.6 1 l-2.6 1 l-1 2.6 l-1 -2.6 l-2.6 -1 l2.6 -1 z" opacity="0.85"/>
|
||||||
|
<path d="M12 66 l0.9 2.3 l2.3 0.9 l-2.3 0.9 l-0.9 2.3 l-0.9 -2.3 l-2.3 -0.9 l2.3 -0.9 z" opacity="0.8"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="mothWing" x1="0" y1="0" x2="0.6" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#5A5560"/>
|
||||||
|
<stop offset="60%" stop-color="#3A3540"/>
|
||||||
|
<stop offset="100%" stop-color="#211E26"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g stroke="#15121A" stroke-width="1.5" stroke-linejoin="round">
|
||||||
|
<path fill="url(#mothWing)" d="M48,34 C32,16 10,20 10,36 C10,50 30,50 48,48 Z"/>
|
||||||
|
<path fill="url(#mothWing)" d="M52,34 C68,16 90,20 90,36 C90,50 70,50 52,48 Z"/>
|
||||||
|
<path fill="url(#mothWing)" d="M47,49 C35,52 20,56 22,70 C24,80 42,78 47,62 Z"/>
|
||||||
|
<path fill="url(#mothWing)" d="M53,49 C65,52 80,56 78,70 C76,80 58,78 53,62 Z"/>
|
||||||
|
</g>
|
||||||
|
<!-- zlověstné oční skvrny -->
|
||||||
|
<g fill="#C9302C" stroke="#3a0d0b" stroke-width="1">
|
||||||
|
<circle cx="24" cy="34" r="4"/>
|
||||||
|
<circle cx="76" cy="34" r="4"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#1a0605">
|
||||||
|
<circle cx="24" cy="34" r="1.6"/>
|
||||||
|
<circle cx="76" cy="34" r="1.6"/>
|
||||||
|
</g>
|
||||||
|
<!-- huňatá tykadla -->
|
||||||
|
<g stroke="#15121A" stroke-width="1.8" fill="none" stroke-linecap="round">
|
||||||
|
<path d="M49,30 C44,20 39,16 34,14"/>
|
||||||
|
<path d="M51,30 C56,20 61,16 66,14"/>
|
||||||
|
</g>
|
||||||
|
<!-- tělo -->
|
||||||
|
<g fill="#15121A" stroke="none">
|
||||||
|
<rect x="47" y="26" width="6" height="46" rx="3"/>
|
||||||
|
<circle cx="50" cy="26" r="5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,79 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
width="100"
|
||||||
|
height="100">
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="netFillGold" x1="0" y1="0" x2="0.4" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#FFF3C4" stop-opacity="0.28"/>
|
||||||
|
<stop offset="100%" stop-color="#F0C24A" stop-opacity="0.12"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<clipPath id="netBagClipGold">
|
||||||
|
<path d="
|
||||||
|
M 36 8
|
||||||
|
C 21 8, 9 20, 9 35
|
||||||
|
C 9 51, 13 70, 19 84
|
||||||
|
C 22 92, 27 94, 31 87
|
||||||
|
C 40 72, 54 51, 63 35
|
||||||
|
C 63 20, 51 8, 36 8
|
||||||
|
Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Síťový vak za obručí -->
|
||||||
|
<path d="
|
||||||
|
M 36 8
|
||||||
|
C 21 8, 9 20, 9 35
|
||||||
|
C 9 51, 13 70, 19 84
|
||||||
|
C 22 92, 27 94, 31 87
|
||||||
|
C 40 72, 54 51, 63 35
|
||||||
|
C 63 20, 51 8, 36 8
|
||||||
|
Z"
|
||||||
|
fill="url(#netFillGold)"
|
||||||
|
stroke="#E8B23A"
|
||||||
|
stroke-width="1"
|
||||||
|
opacity="0.85"/>
|
||||||
|
|
||||||
|
<!-- Výplet síťky -->
|
||||||
|
<g clip-path="url(#netBagClipGold)"
|
||||||
|
fill="none"
|
||||||
|
stroke="#E8B23A"
|
||||||
|
stroke-width="0.8"
|
||||||
|
opacity="0.7"
|
||||||
|
stroke-linecap="round">
|
||||||
|
<path d="M 16 17 C 15 42, 19 69, 25 90"/>
|
||||||
|
<path d="M 25 10 C 23 39, 24 69, 25 90"/>
|
||||||
|
<path d="M 36 8 C 34 39, 30 69, 25 90"/>
|
||||||
|
<path d="M 47 10 C 44 40, 36 70, 25 90"/>
|
||||||
|
<path d="M 56 18 C 51 44, 41 72, 25 90"/>
|
||||||
|
<path d="M 62 30 C 55 51, 43 75, 25 90"/>
|
||||||
|
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
|
||||||
|
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
|
||||||
|
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
|
||||||
|
<path d="M 10 28 C 25 34, 47 34, 62 29"/>
|
||||||
|
<path d="M 9 40 C 25 46, 45 47, 59 42"/>
|
||||||
|
<path d="M 11 52 C 24 58, 41 59, 53 54"/>
|
||||||
|
<path d="M 13 64 C 24 69, 36 70, 45 66"/>
|
||||||
|
<path d="M 16 75 C 24 79, 31 80, 38 77"/>
|
||||||
|
<path d="M 20 84 C 24 87, 28 87, 32 84"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Rukojeť -->
|
||||||
|
<line x1="57" y1="57" x2="93" y2="93" stroke="#8B5A2B" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="57" y1="57" x2="93" y2="93" stroke="#A9713A" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Kovové napojení rukojeti -->
|
||||||
|
<line x1="56" y1="55" x2="61" y2="61" stroke="#B8860B" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="56" y1="55" x2="61" y2="61" stroke="#FFE79A" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Zlatá obruč -->
|
||||||
|
<circle cx="36" cy="35" r="27" fill="none" stroke="#C8891A" stroke-width="4.5"/>
|
||||||
|
<circle cx="36" cy="35" r="27" fill="none" stroke="#FFD24A" stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Třpytky -->
|
||||||
|
<g fill="#FFF7D6" stroke="none">
|
||||||
|
<path d="M 33 6 l 1.2 3 l 3 1.2 l -3 1.2 l -1.2 3 l -1.2 -3 l -3 -1.2 l 3 -1.2 z"/>
|
||||||
|
<path d="M 60 40 l 1 2.4 l 2.4 1 l -2.4 1 l -1 2.4 l -1 -2.4 l -2.4 -1 l 2.4 -1 z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,74 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
width="100"
|
||||||
|
height="100">
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="netFillTorn" x1="0" y1="0" x2="0.4" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#E4EDF5" stop-opacity="0.14"/>
|
||||||
|
<stop offset="100%" stop-color="#B9C4CF" stop-opacity="0.06"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Vak s vyříznutou dírou uprostřed -->
|
||||||
|
<clipPath id="netBagClipTorn">
|
||||||
|
<path d="
|
||||||
|
M 36 8 C 21 8, 9 20, 9 35 C 9 51, 13 70, 19 84
|
||||||
|
C 22 92, 27 94, 31 87 C 40 72, 54 51, 63 35 C 63 20, 51 8, 36 8 Z
|
||||||
|
M 27 40 C 20 44, 22 55, 30 58 C 38 61, 46 54, 43 46 C 41 40, 33 37, 27 40 Z"
|
||||||
|
clip-rule="evenodd"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Síťový vak (s dírou) -->
|
||||||
|
<path d="
|
||||||
|
M 36 8 C 21 8, 9 20, 9 35 C 9 51, 13 70, 19 84
|
||||||
|
C 22 92, 27 94, 31 87 C 40 72, 54 51, 63 35 C 63 20, 51 8, 36 8 Z
|
||||||
|
M 27 40 C 20 44, 22 55, 30 58 C 38 61, 46 54, 43 46 C 41 40, 33 37, 27 40 Z"
|
||||||
|
fill="url(#netFillTorn)"
|
||||||
|
stroke="#AEBBC8"
|
||||||
|
stroke-width="1"
|
||||||
|
opacity="0.7"
|
||||||
|
fill-rule="evenodd"/>
|
||||||
|
|
||||||
|
<!-- Výplet síťky (oříznutý dírou) -->
|
||||||
|
<g clip-path="url(#netBagClipTorn)"
|
||||||
|
fill="none"
|
||||||
|
stroke="#AEBBC8"
|
||||||
|
stroke-width="0.8"
|
||||||
|
opacity="0.55"
|
||||||
|
stroke-linecap="round">
|
||||||
|
<path d="M 16 17 C 15 42, 19 69, 25 90"/>
|
||||||
|
<path d="M 25 10 C 23 39, 24 69, 25 90"/>
|
||||||
|
<path d="M 36 8 C 34 39, 30 69, 25 90"/>
|
||||||
|
<path d="M 47 10 C 44 40, 36 70, 25 90"/>
|
||||||
|
<path d="M 56 18 C 51 44, 41 72, 25 90"/>
|
||||||
|
<path d="M 62 30 C 55 51, 43 75, 25 90"/>
|
||||||
|
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
|
||||||
|
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
|
||||||
|
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
|
||||||
|
<path d="M 13 64 C 24 69, 36 70, 45 66"/>
|
||||||
|
<path d="M 16 75 C 24 79, 31 80, 38 77"/>
|
||||||
|
<path d="M 20 84 C 24 87, 28 87, 32 84"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Roztřepená vlákna kolem díry -->
|
||||||
|
<g stroke="#9AA7B4" stroke-width="0.8" fill="none" stroke-linecap="round" opacity="0.8">
|
||||||
|
<path d="M 27 40 l -3 -2"/>
|
||||||
|
<path d="M 43 46 l 3 -1"/>
|
||||||
|
<path d="M 30 58 l -1 3"/>
|
||||||
|
<path d="M 38 52 l 3 2"/>
|
||||||
|
<path d="M 24 49 l -3 1"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Rukojeť -->
|
||||||
|
<line x1="57" y1="57" x2="93" y2="93" stroke="#8B5A2B" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="57" y1="57" x2="93" y2="93" stroke="#A9713A" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Kovové napojení rukojeti -->
|
||||||
|
<line x1="56" y1="55" x2="61" y2="61" stroke="#71808E" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="56" y1="55" x2="61" y2="61" stroke="#B8C4CF" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Obruč -->
|
||||||
|
<circle cx="36" cy="35" r="27" fill="none" stroke="#7A8896" stroke-width="4.5"/>
|
||||||
|
<circle cx="36" cy="35" r="27" fill="none" stroke="#AEBBC8" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,106 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
width="100"
|
||||||
|
height="100">
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="netFill" x1="0" y1="0" x2="0.4" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#E4EDF5" stop-opacity="0.18"/>
|
||||||
|
<stop offset="100%" stop-color="#B9C4CF" stop-opacity="0.08"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Celý tvar síťového vaku -->
|
||||||
|
<clipPath id="netBagClip">
|
||||||
|
<path d="
|
||||||
|
M 36 8
|
||||||
|
C 21 8, 9 20, 9 35
|
||||||
|
C 9 51, 13 70, 19 84
|
||||||
|
C 22 92, 27 94, 31 87
|
||||||
|
C 40 72, 54 51, 63 35
|
||||||
|
C 63 20, 51 8, 36 8
|
||||||
|
Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Síťový vak za obručí -->
|
||||||
|
<path d="
|
||||||
|
M 36 8
|
||||||
|
C 21 8, 9 20, 9 35
|
||||||
|
C 9 51, 13 70, 19 84
|
||||||
|
C 22 92, 27 94, 31 87
|
||||||
|
C 40 72, 54 51, 63 35
|
||||||
|
C 63 20, 51 8, 36 8
|
||||||
|
Z"
|
||||||
|
fill="url(#netFill)"
|
||||||
|
stroke="#AEBBC8"
|
||||||
|
stroke-width="1"
|
||||||
|
opacity="0.7"/>
|
||||||
|
|
||||||
|
<!-- Výplet síťky -->
|
||||||
|
<g clip-path="url(#netBagClip)"
|
||||||
|
fill="none"
|
||||||
|
stroke="#AEBBC8"
|
||||||
|
stroke-width="0.8"
|
||||||
|
opacity="0.6"
|
||||||
|
stroke-linecap="round">
|
||||||
|
|
||||||
|
<!-- Vlákna začínající na horní a boční obruči -->
|
||||||
|
<path d="M 16 17 C 15 42, 19 69, 25 90"/>
|
||||||
|
<path d="M 25 10 C 23 39, 24 69, 25 90"/>
|
||||||
|
<path d="M 36 8 C 34 39, 30 69, 25 90"/>
|
||||||
|
<path d="M 47 10 C 44 40, 36 70, 25 90"/>
|
||||||
|
<path d="M 56 18 C 51 44, 41 72, 25 90"/>
|
||||||
|
<path d="M 62 30 C 55 51, 43 75, 25 90"/>
|
||||||
|
|
||||||
|
<!-- Vlákna od levého okraje obruče -->
|
||||||
|
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
|
||||||
|
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
|
||||||
|
|
||||||
|
<!-- Příčná oka -->
|
||||||
|
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
|
||||||
|
<path d="M 10 28 C 25 34, 47 34, 62 29"/>
|
||||||
|
<path d="M 9 40 C 25 46, 45 47, 59 42"/>
|
||||||
|
<path d="M 11 52 C 24 58, 41 59, 53 54"/>
|
||||||
|
<path d="M 13 64 C 24 69, 36 70, 45 66"/>
|
||||||
|
<path d="M 16 75 C 24 79, 31 80, 38 77"/>
|
||||||
|
<path d="M 20 84 C 24 87, 28 87, 32 84"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Rukojeť -->
|
||||||
|
<line x1="57" y1="57"
|
||||||
|
x2="93" y2="93"
|
||||||
|
stroke="#8B5A2B"
|
||||||
|
stroke-width="6"
|
||||||
|
stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<line x1="57" y1="57"
|
||||||
|
x2="93" y2="93"
|
||||||
|
stroke="#A9713A"
|
||||||
|
stroke-width="3"
|
||||||
|
stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Kovové napojení rukojeti -->
|
||||||
|
<line x1="56" y1="55"
|
||||||
|
x2="61" y2="61"
|
||||||
|
stroke="#71808E"
|
||||||
|
stroke-width="6"
|
||||||
|
stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<line x1="56" y1="55"
|
||||||
|
x2="61" y2="61"
|
||||||
|
stroke="#B8C4CF"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Hlavní obruč – nezměněná -->
|
||||||
|
<circle cx="36" cy="35" r="27"
|
||||||
|
fill="none"
|
||||||
|
stroke="#7A8896"
|
||||||
|
stroke-width="4.5"/>
|
||||||
|
|
||||||
|
<circle cx="36" cy="35" r="27"
|
||||||
|
fill="none"
|
||||||
|
stroke="#AEBBC8"
|
||||||
|
stroke-width="2"/>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<g stroke="#7A3B00" stroke-width="1.5" stroke-linejoin="round">
|
||||||
|
<path fill="#F59E1E" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
|
||||||
|
<path fill="#F59E1E" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
|
||||||
|
<path fill="#F59E1E" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
|
||||||
|
<path fill="#F59E1E" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#FFE0A3" stroke="none">
|
||||||
|
<circle cx="20" cy="30" r="4"/>
|
||||||
|
<circle cx="80" cy="30" r="4"/>
|
||||||
|
<circle cx="30" cy="70" r="3"/>
|
||||||
|
<circle cx="70" cy="70" r="3"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#2B1A0B" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||||
|
<path d="M49,28 C44,18 40,14 36,11"/>
|
||||||
|
<path d="M51,28 C56,18 60,14 64,11"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#2B1A0B" stroke="none">
|
||||||
|
<circle cx="36" cy="11" r="1.8"/>
|
||||||
|
<circle cx="64" cy="11" r="1.8"/>
|
||||||
|
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
|
||||||
|
<circle cx="50" cy="24" r="4.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<g stroke="#8A2255" stroke-width="1.5" stroke-linejoin="round">
|
||||||
|
<path fill="#F06B9B" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
|
||||||
|
<path fill="#F06B9B" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
|
||||||
|
<path fill="#F06B9B" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
|
||||||
|
<path fill="#F06B9B" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#FFD1E3" stroke="none">
|
||||||
|
<circle cx="20" cy="30" r="4"/>
|
||||||
|
<circle cx="80" cy="30" r="4"/>
|
||||||
|
<circle cx="30" cy="70" r="3"/>
|
||||||
|
<circle cx="70" cy="70" r="3"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#3B0E24" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||||
|
<path d="M49,28 C44,18 40,14 36,11"/>
|
||||||
|
<path d="M51,28 C56,18 60,14 64,11"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#3B0E24" stroke="none">
|
||||||
|
<circle cx="36" cy="11" r="1.8"/>
|
||||||
|
<circle cx="64" cy="11" r="1.8"/>
|
||||||
|
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
|
||||||
|
<circle cx="50" cy="24" r="4.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<g stroke="#8A6D00" stroke-width="1.5" stroke-linejoin="round">
|
||||||
|
<path fill="#FFD23F" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
|
||||||
|
<path fill="#FFD23F" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
|
||||||
|
<path fill="#FFD23F" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
|
||||||
|
<path fill="#FFD23F" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#FFF3C4" stroke="none">
|
||||||
|
<circle cx="20" cy="30" r="4"/>
|
||||||
|
<circle cx="80" cy="30" r="4"/>
|
||||||
|
<circle cx="30" cy="70" r="3"/>
|
||||||
|
<circle cx="70" cy="70" r="3"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#3A2E00" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||||
|
<path d="M49,28 C44,18 40,14 36,11"/>
|
||||||
|
<path d="M51,28 C56,18 60,14 64,11"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#3A2E00" stroke="none">
|
||||||
|
<circle cx="36" cy="11" r="1.8"/>
|
||||||
|
<circle cx="64" cy="11" r="1.8"/>
|
||||||
|
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
|
||||||
|
<circle cx="50" cy="24" r="4.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="coinFace" cx="0.4" cy="0.35" r="0.75">
|
||||||
|
<stop offset="0%" stop-color="#FFE9A3"/>
|
||||||
|
<stop offset="60%" stop-color="#FFCB3D"/>
|
||||||
|
<stop offset="100%" stop-color="#E0A200"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="50" cy="50" r="42" fill="#C88A12"/>
|
||||||
|
<circle cx="50" cy="50" r="38" fill="url(#coinFace)" stroke="#F2D774" stroke-width="2"/>
|
||||||
|
<circle cx="50" cy="50" r="30" fill="none" stroke="#B8860B" stroke-width="2" opacity="0.6"/>
|
||||||
|
<!-- stylizovaný motýlek na minci -->
|
||||||
|
<g fill="#B8860B" opacity="0.85">
|
||||||
|
<path d="M50,42 C42,32 30,34 30,44 C30,52 42,52 50,50 Z"/>
|
||||||
|
<path d="M50,42 C58,32 70,34 70,44 C70,52 58,52 50,50 Z"/>
|
||||||
|
<path d="M49,50 C42,52 34,56 36,64 C38,70 48,66 49,58 Z"/>
|
||||||
|
<path d="M51,50 C58,52 66,56 64,64 C62,70 52,66 51,58 Z"/>
|
||||||
|
<rect x="48.5" y="40" width="3" height="26" rx="1.5"/>
|
||||||
|
</g>
|
||||||
|
<!-- lesk -->
|
||||||
|
<path d="M 30 30 C 38 24, 50 24, 58 28" fill="none" stroke="#FFF7DC" stroke-width="3" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<!-- vlákno -->
|
||||||
|
<line x1="50" y1="0" x2="50" y2="30" stroke="#8A8A8A" stroke-width="1.5"/>
|
||||||
|
<g stroke="#15121A" stroke-width="3" fill="none" stroke-linecap="round">
|
||||||
|
<!-- nohy -->
|
||||||
|
<path d="M40,52 C24,44 18,52 12,46"/>
|
||||||
|
<path d="M40,58 C24,58 16,66 10,64"/>
|
||||||
|
<path d="M40,64 C26,70 20,80 12,82"/>
|
||||||
|
<path d="M40,70 C30,80 28,90 22,94"/>
|
||||||
|
<path d="M60,52 C76,44 82,52 88,46"/>
|
||||||
|
<path d="M60,58 C76,58 84,66 90,64"/>
|
||||||
|
<path d="M60,64 C74,70 80,80 88,82"/>
|
||||||
|
<path d="M60,70 C70,80 72,90 78,94"/>
|
||||||
|
</g>
|
||||||
|
<!-- tělo -->
|
||||||
|
<g stroke="#15121A" stroke-width="1.5">
|
||||||
|
<ellipse cx="50" cy="46" rx="11" ry="9" fill="#2B2733"/>
|
||||||
|
<ellipse cx="50" cy="64" rx="15" ry="16" fill="#211E26"/>
|
||||||
|
<!-- přesýpací hodiny (varovná značka) -->
|
||||||
|
<path d="M50,58 l5,6 l-5,6 l-5,-6 z" fill="#C9302C" stroke="none"/>
|
||||||
|
</g>
|
||||||
|
<!-- oči -->
|
||||||
|
<g fill="#F5C518">
|
||||||
|
<circle cx="46" cy="44" r="1.6"/>
|
||||||
|
<circle cx="54" cy="44" r="1.6"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -1,4 +1,4 @@
|
|||||||
// Service Worker pro Web Push notifikace (připomínka výběru oběda)
|
// Service Worker pro Web Push notifikace (připomínka výběru oběda, doručení objednávky)
|
||||||
|
|
||||||
self.addEventListener('push', (event) => {
|
self.addEventListener('push', (event) => {
|
||||||
const data = event.data?.json() ?? { title: 'Luncher', body: 'Ještě nemáte zvolený oběd!' };
|
const data = event.data?.json() ?? { title: 'Luncher', body: 'Ještě nemáte zvolený oběd!' };
|
||||||
@@ -6,11 +6,12 @@ self.addEventListener('push', (event) => {
|
|||||||
self.registration.showNotification(data.title, {
|
self.registration.showNotification(data.title, {
|
||||||
body: data.body,
|
body: data.body,
|
||||||
icon: '/favicon.ico',
|
icon: '/favicon.ico',
|
||||||
tag: 'lunch-reminder',
|
tag: data.tag ?? 'lunch-reminder',
|
||||||
data: { login: data.login, token: data.token },
|
data: { login: data.login, token: data.token, url: data.url },
|
||||||
actions: [
|
// Token posílá jen připomínka oběda — ostatní notifikace tlačítko nemají.
|
||||||
{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' },
|
actions: data.token
|
||||||
],
|
? [{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' }]
|
||||||
|
: [],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -32,14 +33,16 @@ self.addEventListener('notificationclick', (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url = event.notification.data?.url ?? '/';
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.clients.matchAll({ type: 'window' }).then((clientList) => {
|
self.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||||
for (const client of clientList) {
|
for (const client of clientList) {
|
||||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||||
|
if ('navigate' in client) client.navigate(url).catch(() => {});
|
||||||
return client.focus();
|
return client.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return self.clients.openWindow('/');
|
return self.clients.openWindow(url);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -10,19 +10,29 @@ import PizzaOrderList from './components/PizzaOrderList';
|
|||||||
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from 'react-select-search';
|
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from 'react-select-search';
|
||||||
import 'react-select-search/style.css';
|
import 'react-select-search/style.css';
|
||||||
import './App.scss';
|
import './App.scss';
|
||||||
|
import './themes/umamusume.scss';
|
||||||
|
import './themes/mlp.scss';
|
||||||
|
import './themes/hellgirl.scss';
|
||||||
|
import './themes/kon.scss';
|
||||||
|
import './themes/bocchi.scss';
|
||||||
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
|
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { useSettings } from './context/settings';
|
import { useSettings } from './context/settings';
|
||||||
import Footer from './components/Footer';
|
import Footer from './components/Footer';
|
||||||
import { faArrowUpRightFromSquare, faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
import { faArrowUpRightFromSquare, faBasketShopping, faTag, faChainBroken, faChevronLeft, faChevronRight, faDharmachakra, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Loader from './components/Loader';
|
import Loader from './components/Loader';
|
||||||
import { getHumanDateTime, isInTheFuture } from './Utils';
|
import { getHumanDateTime, isInTheFuture } from './Utils';
|
||||||
import NoteModal from './components/modals/NoteModal';
|
import NoteModal from './components/modals/NoteModal';
|
||||||
|
import PriceModal from './components/modals/PriceModal';
|
||||||
|
import { parsePriceCzk } from './utils/parsePrice';
|
||||||
import PayForAllModal from './components/modals/PayForAllModal';
|
import PayForAllModal from './components/modals/PayForAllModal';
|
||||||
|
import SpinWheelModal, { WheelPick } from './components/modals/SpinWheelModal';
|
||||||
|
import EaterBadge from './components/EaterBadge';
|
||||||
import PendingPayments from './components/PendingPayments';
|
import PendingPayments from './components/PendingPayments';
|
||||||
import { useEasterEgg } from './context/eggs';
|
import { useEasterEgg } from './context/eggs';
|
||||||
import { ClientData, Food, MealSlot, PendingQr, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, generateQr } from '../../types';
|
import { ClientData, Food, MealSlot, PendingQr, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, updatePrice, changeDepartureTime, setBuyer, generateQr } from '../../types';
|
||||||
import { getLunchChoiceName } from './enums';
|
import { CHOICES_WITHOUT_PRICE, getLunchChoiceName } from './enums';
|
||||||
|
import { isArrowNavigationBlocked } from './utils/arrowNavigation';
|
||||||
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
|
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
|
||||||
// import './FallingLeaves.scss';
|
// import './FallingLeaves.scss';
|
||||||
|
|
||||||
@@ -63,13 +73,15 @@ function App() {
|
|||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [easterEgg, _] = useEasterEgg(auth);
|
const [easterEgg, _] = useEasterEgg(auth);
|
||||||
const [isConnected, setIsConnected] = useState<boolean>(false);
|
const socket = useContext(SocketContext);
|
||||||
|
// Socket je singleton mimo React, takže při návratu v historii prohlížeče (remount
|
||||||
|
// komponenty) už žádný "connect" event nepřijde - stav proto inicializujeme z něj
|
||||||
|
const [isConnected, setIsConnected] = useState<boolean>(() => socket.connected);
|
||||||
const [data, setData] = useState<ClientData>();
|
const [data, setData] = useState<ClientData>();
|
||||||
const [food, setFood] = useState<RestaurantDayMenuMap>();
|
const [food, setFood] = useState<RestaurantDayMenuMap>();
|
||||||
const [myOrder, setMyOrder] = useState<PizzaOrder>();
|
const [myOrder, setMyOrder] = useState<PizzaOrder>();
|
||||||
const [foodChoiceList, setFoodChoiceList] = useState<Food[]>();
|
const [foodChoiceList, setFoodChoiceList] = useState<Food[]>();
|
||||||
const [closed, setClosed] = useState<boolean>(false);
|
const [closed, setClosed] = useState<boolean>(false);
|
||||||
const socket = useContext(SocketContext);
|
|
||||||
const choiceRef = useRef<HTMLSelectElement>(null);
|
const choiceRef = useRef<HTMLSelectElement>(null);
|
||||||
const foodChoiceRef = useRef<HTMLSelectElement>(null);
|
const foodChoiceRef = useRef<HTMLSelectElement>(null);
|
||||||
const departureChoiceRef = useRef<HTMLSelectElement>(null);
|
const departureChoiceRef = useRef<HTMLSelectElement>(null);
|
||||||
@@ -78,7 +90,9 @@ function App() {
|
|||||||
const [dayIndex, setDayIndex] = useState<number>();
|
const [dayIndex, setDayIndex] = useState<number>();
|
||||||
const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false);
|
const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false);
|
||||||
const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false);
|
const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false);
|
||||||
|
const [priceModalOpen, setPriceModalOpen] = useState<boolean>(false);
|
||||||
const [payForAllLocationKey, setPayForAllLocationKey] = useState<LunchChoice | null>(null);
|
const [payForAllLocationKey, setPayForAllLocationKey] = useState<LunchChoice | null>(null);
|
||||||
|
const [spinWheelOpen, setSpinWheelOpen] = useState<boolean>(false);
|
||||||
const [eggImage, setEggImage] = useState<Blob>();
|
const [eggImage, setEggImage] = useState<Blob>();
|
||||||
const eggRef = useRef<HTMLImageElement>(null);
|
const eggRef = useRef<HTMLImageElement>(null);
|
||||||
// Prazvláštní workaround, aby socket.io listener viděl aktuální hodnotu
|
// Prazvláštní workaround, aby socket.io listener viděl aktuální hodnotu
|
||||||
@@ -121,6 +135,8 @@ function App() {
|
|||||||
|
|
||||||
// Registrace socket eventů
|
// Registrace socket eventů
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Srovnání se skutečným stavem socketu (mohl se změnit mezi renderem a efektem)
|
||||||
|
setIsConnected(socket.connected);
|
||||||
socket.on(EVENT_CONNECT, () => {
|
socket.on(EVENT_CONNECT, () => {
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
});
|
});
|
||||||
@@ -232,6 +248,7 @@ function App() {
|
|||||||
|
|
||||||
// Navigace mezi dny pomocí klávesových šípek
|
// Navigace mezi dny pomocí klávesových šípek
|
||||||
const handleKeyDown = useCallback((e: any) => {
|
const handleKeyDown = useCallback((e: any) => {
|
||||||
|
if (isArrowNavigationBlocked(e)) return;
|
||||||
if (e.keyCode === 37 && dayIndex != null && dayIndex > 0) {
|
if (e.keyCode === 37 && dayIndex != null && dayIndex > 0) {
|
||||||
handleDayChange(dayIndex - 1);
|
handleDayChange(dayIndex - 1);
|
||||||
} else if (e.keyCode === 39 && dayIndex != null && dayIndex < 4) {
|
} else if (e.keyCode === 39 && dayIndex != null && dayIndex < 4) {
|
||||||
@@ -404,6 +421,24 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Převzetí výsledku kola štěstí — podnik s menu vybere i jídlo, rozvoz se zapíše do poznámky
|
||||||
|
const handleWheelPick = async (pick: WheelPick) => {
|
||||||
|
if (!auth?.login) return;
|
||||||
|
try {
|
||||||
|
if (pick.kind === 'menu') {
|
||||||
|
await addChoice({ body: { locationKey: pick.restaurant, foodIndex: pick.foodIndex, dayIndex } });
|
||||||
|
} else {
|
||||||
|
await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, dayIndex } });
|
||||||
|
// Server přijímá poznámku max. 70 znaků
|
||||||
|
const note = pick.dish ? `${pick.store}: ${pick.dish}` : pick.store;
|
||||||
|
await updateNote({ body: { note: note.length > 70 ? note.slice(0, 69) + '…' : note, dayIndex } });
|
||||||
|
}
|
||||||
|
await tryAutoSelectDepartureTime();
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(`Chyba při změně volby: ${error.message || error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const saveNote = async (note?: string) => {
|
const saveNote = async (note?: string) => {
|
||||||
if (auth?.login) {
|
if (auth?.login) {
|
||||||
await updateNote({ body: { note, dayIndex } });
|
await updateNote({ body: { note, dayIndex } });
|
||||||
@@ -411,6 +446,13 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const savePrice = async (price?: number) => {
|
||||||
|
if (auth?.login) {
|
||||||
|
await updatePrice({ body: { price, dayIndex } });
|
||||||
|
setPriceModalOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const copyNote = async (note: string) => {
|
const copyNote = async (note: string) => {
|
||||||
if (auth?.login && note) {
|
if (auth?.login && note) {
|
||||||
await updateNote({ body: { note, dayIndex } });
|
await updateNote({ body: { note, dayIndex } });
|
||||||
@@ -513,7 +555,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
if (foodChoiceList?.length && choiceRef.current?.value) {
|
if (choiceRef.current?.value) {
|
||||||
await changeDepartureTime({ body: { time: event.target.value as DepartureTime, dayIndex } });
|
await changeDepartureTime({ body: { time: event.target.value as DepartureTime, dayIndex } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -528,7 +570,8 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDayChange = async (dayIndex: number) => {
|
const handleDayChange = async (requestedIndex: number) => {
|
||||||
|
const dayIndex = Math.max(0, Math.min(4, requestedIndex));
|
||||||
setDayIndex(dayIndex);
|
setDayIndex(dayIndex);
|
||||||
dayIndexRef.current = dayIndex;
|
dayIndexRef.current = dayIndex;
|
||||||
if (choiceRef?.current?.value) {
|
if (choiceRef?.current?.value) {
|
||||||
@@ -582,7 +625,7 @@ function App() {
|
|||||||
} else {
|
} else {
|
||||||
content = <div className="restaurant-error">Chyba načtení dat</div>
|
content = <div className="restaurant-error">Chyba načtení dat</div>
|
||||||
}
|
}
|
||||||
return <Col md={6} lg={3} className='mt-3'>
|
return <Col md={6} lg className='mt-3'>
|
||||||
<div className="restaurant-card">
|
<div className="restaurant-card">
|
||||||
<div className="restaurant-header" style={{ cursor: canChangeChoice ? 'pointer' : 'default' }} onClick={() => doAddClickFoodChoice(location)}>
|
<div className="restaurant-header" style={{ cursor: canChangeChoice ? 'pointer' : 'default' }} onClick={() => doAddClickFoodChoice(location)}>
|
||||||
<div className="restaurant-header-content">
|
<div className="restaurant-header-content">
|
||||||
@@ -633,6 +676,15 @@ function App() {
|
|||||||
const noOrders = data?.pizzaDay?.orders?.length === 0;
|
const noOrders = data?.pizzaDay?.orders?.length === 0;
|
||||||
const canChangeChoice = dayIndex == null || data.todayDayIndex == null || dayIndex >= data.todayDayIndex;
|
const canChangeChoice = dayIndex == null || data.todayDayIndex == null || dayIndex >= data.todayDayIndex;
|
||||||
|
|
||||||
|
const ownChoice = auth?.login ? Object.entries(data.choices)
|
||||||
|
.map(([locationKey, loginMap]) => ({ locationKey: locationKey as LunchChoice, choice: loginMap?.[auth.login!] }))
|
||||||
|
.find(entry => entry.choice != null) as { locationKey: LunchChoice, choice: UserLunchChoice } | undefined : undefined;
|
||||||
|
// Součet cen vybraných jídel z menu jako návrh ceny; pokud některou cenu nelze přečíst, nenavrhujeme nic
|
||||||
|
const ownFoodPrices = ownChoice?.choice.selectedFoods?.map(index => parsePriceCzk(food[ownChoice.locationKey as Restaurant]?.food?.[index]?.price)) ?? [];
|
||||||
|
const ownSuggestedPrice = ownFoodPrices.length && !ownFoodPrices.includes(null)
|
||||||
|
? Math.round((ownFoodPrices as number[]).reduce((sum, price) => sum + price, 0) * 100)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {};
|
const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -657,7 +709,7 @@ function App() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<Row className='food-tables'>
|
<Row className='food-tables justify-content-center'>
|
||||||
{Object.keys(Restaurant).map(key => {
|
{Object.keys(Restaurant).map(key => {
|
||||||
const locationKey = key as Restaurant;
|
const locationKey = key as Restaurant;
|
||||||
return food[locationKey] && renderFoodTable(locationKey, food[locationKey]);
|
return food[locationKey] && renderFoodTable(locationKey, food[locationKey]);
|
||||||
@@ -672,7 +724,11 @@ function App() {
|
|||||||
{Object.entries(LunchChoice)
|
{Object.entries(LunchChoice)
|
||||||
.filter(entry => {
|
.filter(entry => {
|
||||||
const locationKey = entry[0] as Restaurant;
|
const locationKey = entry[0] as Restaurant;
|
||||||
return !food[locationKey]?.closed;
|
// Podniky, pro které server nevrátil menu (dočasně uzavřené), nenabízíme
|
||||||
|
if (Object.keys(Restaurant).includes(locationKey) && !food?.[locationKey]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !food?.[locationKey]?.closed;
|
||||||
})
|
})
|
||||||
.map(entry => <option key={entry[0]} value={entry[0]}>{getLunchChoiceName(entry[1])}</option>)}
|
.map(entry => <option key={entry[0]} value={entry[0]}>{getLunchChoiceName(entry[1])}</option>)}
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
@@ -740,9 +796,11 @@ function App() {
|
|||||||
{trusted && <span className='trusted-icon' title='Uživatel ověřený doménovým přihlášením'>
|
{trusted && <span className='trusted-icon' title='Uživatel ověřený doménovým přihlášením'>
|
||||||
<FontAwesomeIcon icon={faCircleCheck} style={{ cursor: "help" }} />
|
<FontAwesomeIcon icon={faCircleCheck} style={{ cursor: "help" }} />
|
||||||
</span>}
|
</span>}
|
||||||
|
<EaterBadge items={userChoices?.length ?? 0} />
|
||||||
<strong>{login}</strong>
|
<strong>{login}</strong>
|
||||||
{userPayload.departureTime && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>({userPayload.departureTime})</small>}
|
{userPayload.departureTime && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>({userPayload.departureTime})</small>}
|
||||||
{userPayload.note && <span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>({userPayload.note})</span>}
|
{userPayload.note && <span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>({userPayload.note})</span>}
|
||||||
|
{login === auth.login && userPayload.price != null && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>{userPayload.price / 100} Kč</small>}
|
||||||
</div>
|
</div>
|
||||||
<div className="user-actions">
|
<div className="user-actions">
|
||||||
{login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && <span title='Označit/odznačit se jako objednávající'>
|
{login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && <span title='Označit/odznačit se jako objednávající'>
|
||||||
@@ -763,6 +821,12 @@ function App() {
|
|||||||
copyNote(userPayload.note!);
|
copyNote(userPayload.note!);
|
||||||
}} className='action-icon' icon={faComment} />
|
}} className='action-icon' icon={faComment} />
|
||||||
</span>}
|
</span>}
|
||||||
|
{login === auth.login && canChangeChoice && locationKey === LunchChoice.ROZHODUJI && <span title='Roztočit kolo štěstí'>
|
||||||
|
<FontAwesomeIcon onClick={() => setSpinWheelOpen(true)} className='action-icon spin-wheel-icon' icon={faDharmachakra} />
|
||||||
|
</span>}
|
||||||
|
{login === auth.login && canChangeChoice && !CHOICES_WITHOUT_PRICE.includes(locationKey) && <span title='Zadat cenu oběda do přehledu stravování'>
|
||||||
|
<FontAwesomeIcon onClick={() => setPriceModalOpen(true)} className='action-icon' icon={faTag} />
|
||||||
|
</span>}
|
||||||
{login === auth.login && canChangeChoice && <span title='Upravit poznámku'>
|
{login === auth.login && canChangeChoice && <span title='Upravit poznámku'>
|
||||||
<FontAwesomeIcon onClick={() => {
|
<FontAwesomeIcon onClick={() => {
|
||||||
setNoteModalOpen(true);
|
setNoteModalOpen(true);
|
||||||
@@ -777,9 +841,11 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
{userChoices && userChoices.length > 0 && food && (
|
{userChoices && userChoices.length > 0 && food && (
|
||||||
<div className="food-choices">
|
<div className="food-choices">
|
||||||
{userChoices.map(foodIndex => {
|
{userChoices
|
||||||
|
.filter(foodIndex => food[key as Restaurant]?.food?.[foodIndex] != null)
|
||||||
|
.map(foodIndex => {
|
||||||
const restaurantKey = key as Restaurant;
|
const restaurantKey = key as Restaurant;
|
||||||
const foodName = food[restaurantKey]?.food?.[foodIndex].name;
|
const foodName = food[restaurantKey]?.food?.[foodIndex]?.name;
|
||||||
return <div key={foodIndex} className="food-choice-item">
|
return <div key={foodIndex} className="food-choice-item">
|
||||||
<span className="food-choice-name">{foodName}</span>
|
<span className="food-choice-name">{foodName}</span>
|
||||||
{login === auth.login && canChangeChoice &&
|
{login === auth.login && canChangeChoice &&
|
||||||
@@ -924,7 +990,15 @@ function App() {
|
|||||||
leafVariants={LEAF_COLOR_THEMES.AUTUMN}
|
leafVariants={LEAF_COLOR_THEMES.AUTUMN}
|
||||||
/> */}
|
/> */}
|
||||||
<Footer />
|
<Footer />
|
||||||
|
{spinWheelOpen && <SpinWheelModal isOpen onClose={() => setSpinWheelOpen(false)} menus={food} onPick={handleWheelPick} />}
|
||||||
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
|
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
|
||||||
|
<PriceModal
|
||||||
|
isOpen={priceModalOpen}
|
||||||
|
onClose={() => setPriceModalOpen(false)}
|
||||||
|
price={ownChoice?.choice.price}
|
||||||
|
suggestedPrice={ownSuggestedPrice}
|
||||||
|
onSave={savePrice}
|
||||||
|
/>
|
||||||
{payForAllLocationKey && data && (
|
{payForAllLocationKey && data && (
|
||||||
<PayForAllModal
|
<PayForAllModal
|
||||||
isOpen
|
isOpen
|
||||||
|
|||||||
@@ -1,52 +1,57 @@
|
|||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
import { ProvideSettings } from "./context/settings";
|
import { ProvideSettings, useSettings } from "./context/settings";
|
||||||
// import Snowfall from "react-snowfall";
|
import FlyingButterflies, { BUTTERFLY_PRESETS } from "./FlyingButterflies";
|
||||||
import { SnowOverlay } from 'react-snow-overlay';
|
import "./FlyingButterflies.scss";
|
||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
import { SocketContext, socket } from "./context/socket";
|
import { SocketContext, socket } from "./context/socket";
|
||||||
import StatsPage from "./pages/StatsPage";
|
import StatsPage from "./pages/StatsPage";
|
||||||
import OrderGroupsPage from "./pages/OrderGroupsPage";
|
import OrderGroupsPage from "./pages/OrderGroupsPage";
|
||||||
import SuggestionsPage from "./pages/SuggestionsPage";
|
import SuggestionsPage from "./pages/SuggestionsPage";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import HellGirlFlames from "./components/HellGirlFlames";
|
||||||
|
|
||||||
export const STATS_URL = '/stats';
|
export const STATS_URL = '/stats';
|
||||||
export const OBJEDNANI_URL = '/objednani';
|
export const OBJEDNANI_URL = '/objednani';
|
||||||
export const NAVRHY_URL = '/navrhy';
|
export const NAVRHY_URL = '/navrhy';
|
||||||
|
|
||||||
|
/** Motýlci se vykreslují jen pokud je uživatel nevypnul v nastavení. */
|
||||||
|
function Butterflies() {
|
||||||
|
const settings = useSettings();
|
||||||
|
if (settings?.showButterflies === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <FlyingButterflies numButterflies={BUTTERFLY_PRESETS.NORMAL} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Doplňkové efekty vizuálních stylů — společné pro všechny stránky (např. plamen při otevření dialogu). */
|
||||||
|
function StyleThemeEffects() {
|
||||||
|
const settings = useSettings();
|
||||||
|
return settings?.styleTheme === 'hellgirl' ? <HellGirlFlames /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AppRoutes() {
|
export default function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
|
<ProvideSettings>
|
||||||
|
<StyleThemeEffects />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path={STATS_URL} element={<StatsPage />} />
|
<Route path={STATS_URL} element={<StatsPage />} />
|
||||||
<Route path={NAVRHY_URL} element={
|
<Route path={NAVRHY_URL} element={<SuggestionsPage />} />
|
||||||
<ProvideSettings>
|
|
||||||
<SuggestionsPage />
|
|
||||||
</ProvideSettings>
|
|
||||||
} />
|
|
||||||
<Route path={OBJEDNANI_URL} element={
|
<Route path={OBJEDNANI_URL} element={
|
||||||
<ProvideSettings>
|
|
||||||
<SocketContext.Provider value={socket}>
|
<SocketContext.Provider value={socket}>
|
||||||
<OrderGroupsPage />
|
<OrderGroupsPage />
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</SocketContext.Provider>
|
</SocketContext.Provider>
|
||||||
</ProvideSettings>
|
|
||||||
} />
|
} />
|
||||||
<Route path="/" element={
|
<Route path="/" element={
|
||||||
<ProvideSettings>
|
|
||||||
<SocketContext.Provider value={socket}>
|
<SocketContext.Provider value={socket}>
|
||||||
<>
|
<>
|
||||||
{/* <Snowfall style={{
|
<Butterflies />
|
||||||
zIndex: 2,
|
|
||||||
position: 'fixed',
|
|
||||||
width: '100vw',
|
|
||||||
height: '100vh'
|
|
||||||
}} /> */}
|
|
||||||
<SnowOverlay color={'rgba(240, 240, 240, 0.9)'} disabledOnSingleCpuDevices={true} />
|
|
||||||
<App />
|
<App />
|
||||||
</>
|
</>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</SocketContext.Provider>
|
</SocketContext.Provider>
|
||||||
</ProvideSettings>
|
|
||||||
} />
|
} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</ProvideSettings>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,749 @@
|
|||||||
|
.flying-butterflies {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
// V režimu hraní herní vrstva zachytává kliknutí, aby se nedalo omylem
|
||||||
|
// překliknout do objednávek. V režimu objednávání propouští vše dál (none).
|
||||||
|
&.playing {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Přepínač režimu hraní / objednávání
|
||||||
|
.butterfly-mode-toggle {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 7;
|
||||||
|
pointer-events: auto;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(33, 37, 41, 0.88);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
transition: background 0.15s ease, transform 0.08s ease;
|
||||||
|
|
||||||
|
&:hover { background: rgba(33, 37, 41, 1); }
|
||||||
|
&:active { transform: scale(0.96); }
|
||||||
|
|
||||||
|
&.playing {
|
||||||
|
background: #2f9e44;
|
||||||
|
&:hover { background: #2b8a3e; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vrstva herních prvků (síťka, škůdci, pavučina) – skrývá se v režimu objednávání
|
||||||
|
.butterfly-game-layer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-scene {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
// Vnější element – pozici a natočení nastavuje JS přes transform
|
||||||
|
> div {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vnitřní element se sprite motýla – máchá křídly (scaleX kolem svislé osy těla)
|
||||||
|
.butterfly-sprite {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: contain;
|
||||||
|
animation: butterfly-flap 0.45s ease-in-out infinite;
|
||||||
|
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15));
|
||||||
|
|
||||||
|
// Vzácný zlatý motýl – WOW: silná pulzující zlatá záře + duhový nádech
|
||||||
|
&.golden {
|
||||||
|
animation:
|
||||||
|
butterfly-flap 0.35s ease-in-out infinite,
|
||||||
|
butterfly-golden-glow 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-golden-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
filter:
|
||||||
|
saturate(1.15) hue-rotate(0deg)
|
||||||
|
drop-shadow(0 0 5px rgba(255, 215, 80, 0.85))
|
||||||
|
drop-shadow(0 0 10px rgba(255, 180, 40, 0.55))
|
||||||
|
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter:
|
||||||
|
saturate(1.4) hue-rotate(-16deg)
|
||||||
|
drop-shadow(0 0 13px rgba(255, 235, 130, 1))
|
||||||
|
drop-shadow(0 0 24px rgba(255, 190, 50, 0.95))
|
||||||
|
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-flap {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scaleX(0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Korunovaný motýl (anti-bot inspekce) – nepřehlédnutelná duhová záře
|
||||||
|
.butterfly-sprite.royal {
|
||||||
|
animation: butterfly-flap 0.35s ease-in-out infinite, butterfly-royal-glow 0.9s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-royal-glow {
|
||||||
|
0%, 100% {
|
||||||
|
filter: drop-shadow(0 0 8px rgba(255, 215, 80, 1)) drop-shadow(0 0 16px rgba(120, 200, 255, 0.8));
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: drop-shadow(0 0 16px rgba(255, 120, 220, 1)) drop-shadow(0 0 28px rgba(120, 255, 160, 0.9));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Černá můra – zlověstný tmavý nádech
|
||||||
|
.butterfly-sprite.moth {
|
||||||
|
filter: drop-shadow(0 0 4px rgba(120, 60, 160, 0.6)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4));
|
||||||
|
animation: butterfly-flap 0.5s ease-in-out infinite, butterfly-moth-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-moth-pulse {
|
||||||
|
0%, 100% { filter: drop-shadow(0 0 4px rgba(120, 60, 160, 0.5)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4)); }
|
||||||
|
50% { filter: drop-shadow(0 0 9px rgba(150, 40, 40, 0.8)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Havěť (ptáci a vosy) ----------------------------------------------------
|
||||||
|
|
||||||
|
.butterfly-critter {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: contain;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: transform;
|
||||||
|
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.2));
|
||||||
|
z-index: 3;
|
||||||
|
|
||||||
|
// Vosa jde zaklikat (plácačka)
|
||||||
|
&.wasp {
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: crosshair;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Netopýr jde zahnat klikáním
|
||||||
|
&.bat {
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: crosshair;
|
||||||
|
touch-action: none;
|
||||||
|
z-index: 5;
|
||||||
|
filter: drop-shadow(0 0 6px rgba(60, 40, 90, 0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hit {
|
||||||
|
animation: butterfly-stalker-hit 0.18s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pavučina přes síťku – blokuje chytání, dokud ji hráč nestrhne
|
||||||
|
.butterfly-web {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
background-image: url(/spider.svg);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: contain;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: crosshair;
|
||||||
|
touch-action: none;
|
||||||
|
z-index: 6;
|
||||||
|
opacity: 0.9;
|
||||||
|
filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.4));
|
||||||
|
|
||||||
|
&.hit {
|
||||||
|
animation: butterfly-stalker-hit 0.15s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Síťka na chytání --------------------------------------------------------
|
||||||
|
|
||||||
|
.butterfly-net {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: contain;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: grab;
|
||||||
|
z-index: 4;
|
||||||
|
will-change: transform;
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
|
||||||
|
transition: filter 0.15s ease;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&.grabbed {
|
||||||
|
cursor: grabbing;
|
||||||
|
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prémiová (zlatá) síťka – zlatá záře a jemný pulz
|
||||||
|
&.premium {
|
||||||
|
filter: drop-shadow(0 0 8px rgba(255, 200, 60, 0.85)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
|
||||||
|
animation: butterfly-net-glow 1.3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protržená síťka – nedá se chytat
|
||||||
|
&.torn {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.9;
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(150, 30, 30, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.catch-pop {
|
||||||
|
animation: butterfly-net-pop 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.stung {
|
||||||
|
animation: butterfly-net-stung 0.4s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Záblesk při chycení černé můry (chyba)
|
||||||
|
&.moth-hit {
|
||||||
|
animation: butterfly-net-moth 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Omráčená síťka (po žihnutí vosou) – po celou dobu, kdy nejde chytat.
|
||||||
|
// Má přednost před grabbed kurzorem.
|
||||||
|
&.stunned {
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
animation: butterfly-net-dizzy 0.9s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zešednutí + červený nádech, ať je jasné, že síťka teď nechytá.
|
||||||
|
// NEanimujeme transform (ten řídí JS inline) – jen filter.
|
||||||
|
@keyframes butterfly-net-dizzy {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
filter: grayscale(0.7) brightness(0.85) drop-shadow(0 0 5px rgba(217, 72, 15, 0.55));
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: grayscale(0.85) brightness(0.8) drop-shadow(0 0 9px rgba(217, 72, 15, 0.8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-net-pop {
|
||||||
|
0% {
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
filter: drop-shadow(0 2px 8px rgba(120, 200, 120, 0.7)) brightness(1.25);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-net-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
filter: drop-shadow(0 0 6px rgba(255, 200, 60, 0.6)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: drop-shadow(0 0 12px rgba(255, 220, 100, 1)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pozn.: NEanimujeme transform – ten nastavuje JS inline (poloha síťky).
|
||||||
|
// Žihnutí vosou proto naznačíme jen červeným zábleskem filtru.
|
||||||
|
@keyframes butterfly-net-stung {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
filter: drop-shadow(0 0 8px rgba(217, 72, 15, 0.85)) brightness(1.1) hue-rotate(-15deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prchavé texty (chycení, protržení, žihnutí)
|
||||||
|
.butterfly-catch-fx,
|
||||||
|
.butterfly-tear-fx,
|
||||||
|
.butterfly-sting-fx {
|
||||||
|
position: fixed;
|
||||||
|
margin: -12px 0 0 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
|
||||||
|
animation: butterfly-catch-float 0.8s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-catch-fx {
|
||||||
|
color: #2f9e44;
|
||||||
|
|
||||||
|
&.golden {
|
||||||
|
color: #e0a200;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
text-shadow: 0 0 6px rgba(255, 220, 100, 0.9), 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.moth {
|
||||||
|
color: #c92a2a;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
text-shadow: 0 0 6px rgba(120, 40, 160, 0.7), 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-net-moth {
|
||||||
|
0%, 100% { filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1); }
|
||||||
|
30% { filter: drop-shadow(0 0 9px rgba(150, 40, 160, 0.9)) brightness(0.8); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-tear-fx {
|
||||||
|
color: #c92a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-sting-fx {
|
||||||
|
color: #d9480f;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-catch-float {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0) scale(0.9);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-6px) scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-40px) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HUD: počítadlo, mince, úroveň, kombo, hlášky ----------------------------
|
||||||
|
|
||||||
|
.butterfly-hud {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
left: 16px;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
// HUD sám o sobě neblokuje klikání; interaktivní je jen tlačítko opravy
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-shop-open {
|
||||||
|
pointer-events: auto;
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f59e1e;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: background 0.15s ease, transform 0.08s ease;
|
||||||
|
|
||||||
|
&:hover { background: #e8890a; }
|
||||||
|
&:active { transform: scale(0.96); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-counter {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #333;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bump {
|
||||||
|
animation: butterfly-counter-bump 0.3s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-counter-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-counter-icon,
|
||||||
|
.butterfly-coin-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-counter-icon {
|
||||||
|
background-image: url(/butterfly-orange.svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-coin-icon {
|
||||||
|
background-image: url(/coin.svg);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-counter-value {
|
||||||
|
min-width: 1ch;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-level {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-level-title {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b5b2e;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 5px;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #ffcb3d, #e0a200);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-pests {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-pest {
|
||||||
|
color: #868e96;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
|
&.warn {
|
||||||
|
color: #d9480f;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ok {
|
||||||
|
color: #2f9e44;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-torn-note {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #c92a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-action-btn {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #1c7ed6;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: background 0.15s ease, transform 0.1s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #1971c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #adb5bd;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
background: #c92a2a;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #e03131;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bossové (zloděj / housenka) ---------------------------------------------
|
||||||
|
|
||||||
|
.butterfly-stalker {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 56px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: crosshair;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
z-index: 6;
|
||||||
|
will-change: transform;
|
||||||
|
|
||||||
|
&.hit .stalker-emoji {
|
||||||
|
animation: butterfly-stalker-hit 0.18s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stalker-emoji {
|
||||||
|
font-size: 44px;
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stalker-hp {
|
||||||
|
width: 46px;
|
||||||
|
height: 6px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stalker-hp-inner {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(90deg, #ff6b6b, #c92a2a);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.1s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-stalker-hit {
|
||||||
|
0% { transform: scale(1) rotate(0); }
|
||||||
|
50% { transform: scale(0.82) rotate(-8deg); filter: brightness(1.6); }
|
||||||
|
100% { transform: scale(1) rotate(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Efekt při plácnutí vosy
|
||||||
|
.butterfly-swat-fx {
|
||||||
|
position: fixed;
|
||||||
|
margin: -14px 0 0 -6px;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
animation: butterfly-catch-float 0.8s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-combo {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(224, 162, 0, 0.92);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
animation: butterfly-counter-bump 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-flash {
|
||||||
|
max-width: 260px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(33, 37, 41, 0.92);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.25);
|
||||||
|
animation: butterfly-flash-in 0.25s ease-out;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-flash-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-counter-bump {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1.18);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohleduplnost k uživatelům, kteří nechtějí pohyb
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.butterfly-sprite,
|
||||||
|
.butterfly-sprite.golden {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-net.catch-pop,
|
||||||
|
.butterfly-net.premium,
|
||||||
|
.butterfly-net.stung,
|
||||||
|
.butterfly-net.stunned,
|
||||||
|
.butterfly-counter.bump,
|
||||||
|
.butterfly-combo {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bez animace aspoň staticky ukážeme, že síťka je omráčená
|
||||||
|
.butterfly-net.stunned {
|
||||||
|
filter: grayscale(0.8) brightness(0.82) drop-shadow(0 0 6px rgba(217, 72, 15, 0.7));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zlatý motýl bez animace aspoň staticky září
|
||||||
|
.butterfly-sprite.golden {
|
||||||
|
filter: saturate(1.3) drop-shadow(0 0 8px rgba(255, 215, 80, 0.95)) drop-shadow(0 0 16px rgba(255, 190, 50, 0.7));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Anti-bot inspekce a vězení ---------------------------------------------
|
||||||
|
|
||||||
|
.butterfly-inspection {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 8;
|
||||||
|
pointer-events: none;
|
||||||
|
max-width: 92vw;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(28, 126, 214, 0.95);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: butterfly-flash-in 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-jail {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 6;
|
||||||
|
pointer-events: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Svislé mříže přes celou obrazovku
|
||||||
|
.butterfly-jail-bars {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(20, 22, 28, 0.92) 0px,
|
||||||
|
rgba(20, 22, 28, 0.92) 14px,
|
||||||
|
rgba(20, 22, 28, 0) 14px,
|
||||||
|
rgba(20, 22, 28, 0) 70px
|
||||||
|
);
|
||||||
|
box-shadow: inset 0 0 120px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-jail-note {
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
padding: 22px 26px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
max-width: 90vw;
|
||||||
|
|
||||||
|
.jail-emoji { font-size: 2.4rem; }
|
||||||
|
.jail-title { font-weight: 800; font-size: 1.2rem; color: #c92a2a; margin-top: 4px; }
|
||||||
|
.jail-sub { margin-top: 6px; font-size: 0.95rem; color: #333; font-variant-numeric: tabular-nums; }
|
||||||
|
.jail-hint { margin-top: 8px; font-size: 0.8rem; color: #868e96; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.butterfly-sprite.royal { animation: none; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -65,6 +99,4 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return <div>Neplatný stav</div>
|
|
||||||
}
|
}
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
|
|
||||||
import './BoltOrderProgress.scss';
|
|
||||||
|
|
||||||
const STEPS = ['Přijato', 'Příprava', 'Vyzvedávání', 'Na cestě', 'Doručeno'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Známé stavy objednávky z Bolt API → index kroku ve stepperu.
|
|
||||||
* Pozor: waiting_delivery znamená "jídlo čeká v podniku na vyzvednutí",
|
|
||||||
* nikoli "na cestě" — tu signalizuje až stav kurýra (picked_up apod.).
|
|
||||||
* Krok „Vyzvedávání" je vyhrazen pro kombinaci „kurýr u podniku + jídlo hotové"
|
|
||||||
* (viz logika níže), samotný order_state ho neudělí — jinak bychom hlásili
|
|
||||||
* vyzvedávání i když se ještě peče (chování, na které si Bolt sám občas stěžuje).
|
|
||||||
*/
|
|
||||||
const ORDER_STATE_TO_STEP: Record<string, number> = {
|
|
||||||
created: 0,
|
|
||||||
pending: 0,
|
|
||||||
accepted: 0,
|
|
||||||
waiting_preparation: 0,
|
|
||||||
preparing: 1,
|
|
||||||
waiting_delivery: 1,
|
|
||||||
ready_for_pickup: 1,
|
|
||||||
waiting_courier: 1,
|
|
||||||
waiting_pickup: 1,
|
|
||||||
picked_up: 3,
|
|
||||||
in_delivery: 3,
|
|
||||||
delivering: 3,
|
|
||||||
heading_to_client: 3,
|
|
||||||
delivered: 4,
|
|
||||||
finished: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Stavy kurýra z Bolt API → index kroku. Kurýr u podniku ještě neznamená "na cestě". */
|
|
||||||
const COURIER_STATE_TO_STEP: Record<string, number> = {
|
|
||||||
heading_to_provider: 1,
|
|
||||||
arrived_to_provider: 1,
|
|
||||||
picked_up: 3,
|
|
||||||
heading_to_client: 3,
|
|
||||||
delivering: 3,
|
|
||||||
arrived_to_client: 3,
|
|
||||||
delivered: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Order states ve kterých už jídlo čeká připravené na kurýra. */
|
|
||||||
const PICKUP_READY_ORDER_STATES = new Set(['waiting_delivery', 'ready_for_pickup', 'waiting_courier', 'waiting_pickup']);
|
|
||||||
|
|
||||||
/** Neznámé stavy se mapují heuristicky podle klíčových slov. */
|
|
||||||
function stepForOrderState(state: string): number | 'cancelled' {
|
|
||||||
const s = state.toLowerCase();
|
|
||||||
if (s in ORDER_STATE_TO_STEP) return ORDER_STATE_TO_STEP[s];
|
|
||||||
if (/cancel|reject|fail/.test(s)) return 'cancelled';
|
|
||||||
if (/delivered|finished/.test(s)) return 4;
|
|
||||||
if (/^waiting|prepar|ready|cook/.test(s)) return 1;
|
|
||||||
if (/picked|delivering|heading_to_client|transport/.test(s)) return 3;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stepForCourierState(state?: string): number {
|
|
||||||
if (!state) return 0;
|
|
||||||
const s = state.toLowerCase();
|
|
||||||
if (s in COURIER_STATE_TO_STEP) return COURIER_STATE_TO_STEP[s];
|
|
||||||
if (/picked|client|delivering|transport/.test(s)) return 3;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Raw order_state z Bolt API (např. waiting_preparation) */
|
|
||||||
state: string;
|
|
||||||
/** Raw courier.state z Bolt API (např. arrived_to_provider) */
|
|
||||||
courierState?: string;
|
|
||||||
/** Zda sledování stále běží (skupina má boltTrackingToken) */
|
|
||||||
tracking: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mini progress stepper se stavem objednávky Bolt Food. */
|
|
||||||
export default function BoltOrderProgress({ state, courierState, tracking }: Props) {
|
|
||||||
const orderStep = stepForOrderState(state);
|
|
||||||
if (orderStep === 'cancelled') {
|
|
||||||
return <small className="text-danger">Objednávka Bolt byla zrušena</small>;
|
|
||||||
}
|
|
||||||
// Stav kurýra může krok jen zpřesnit dopředu (např. waiting_delivery + picked_up → Na cestě)
|
|
||||||
let step = Math.max(orderStep, stepForCourierState(courierState));
|
|
||||||
// Vyzvedávání = kurýr u podniku a jídlo skutečně hotové. Sám arrived_to_provider
|
|
||||||
// (bez toho, že by jídlo bylo hotové) nestačí — viz komentář u ORDER_STATE_TO_STEP.
|
|
||||||
const courierAtProvider = courierState?.toLowerCase() === 'arrived_to_provider';
|
|
||||||
const foodReadyForPickup = PICKUP_READY_ORDER_STATES.has(state.toLowerCase());
|
|
||||||
if (step < 3 && courierAtProvider && foodReadyForPickup) step = 2;
|
|
||||||
const rawInfo = courierState ? `${state} / kurýr: ${courierState}` : state;
|
|
||||||
return (
|
|
||||||
<OverlayTrigger overlay={<Tooltip>Stav z Bolt Food: {rawInfo}</Tooltip>}>
|
|
||||||
<div className={`bolt-progress${tracking && step < 4 ? ' live' : ''}`}>
|
|
||||||
{STEPS.map((label, i) => (
|
|
||||||
<div key={label} className={`bolt-step${i <= step ? ' done' : ''}${i === step ? ' active' : ''}`}>
|
|
||||||
<div className="bolt-dot" />
|
|
||||||
<div className="bolt-label">{label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</OverlayTrigger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ import { useEffect, useState } from "react";
|
|||||||
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
|
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
|
||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import SettingsModal from "./modals/SettingsModal";
|
import SettingsModal from "./modals/SettingsModal";
|
||||||
|
import DrawingGame from "./DrawingGame";
|
||||||
|
import "./Header.scss";
|
||||||
|
import { useMinigameSecret } from "../hooks/useMinigameSecret";
|
||||||
import { useSettings, ThemePreference } from "../context/settings";
|
import { useSettings, ThemePreference } from "../context/settings";
|
||||||
import HuePicker from "./HuePicker";
|
import HuePicker from "./HuePicker";
|
||||||
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
||||||
@@ -39,6 +42,7 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
|
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
const effectiveDark = settings?.effectiveDark ?? false;
|
const effectiveDark = settings?.effectiveDark ?? false;
|
||||||
|
const minigameSecret = useMinigameSecret("/");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth?.login) return;
|
if (!auth?.login) return;
|
||||||
@@ -77,6 +81,9 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
setQrModalOpen(true);
|
setQrModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tematické styly mají pevnou paletu, výběr barvy má smysl jen u jednoduchého stylu
|
||||||
|
const isSimpleStyle = (settings?.styleTheme ?? 'simple') === 'simple';
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
const newTheme: ThemePreference = effectiveDark ? 'light' : 'dark';
|
const newTheme: ThemePreference = effectiveDark ? 'light' : 'dark';
|
||||||
settings?.setThemePreference(newTheme);
|
settings?.setThemePreference(newTheme);
|
||||||
@@ -144,7 +151,9 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return <Navbar variant='dark' expand="lg">
|
return <Navbar variant='dark' expand="lg">
|
||||||
<Navbar.Brand href="/">Luncher</Navbar.Brand>
|
<Navbar.Brand href="/" onClick={minigameSecret.onLogoClick} className={minigameSecret.armed ? "duel-armed" : undefined}>Luncher</Navbar.Brand>
|
||||||
|
<input ref={minigameSecret.inputRef} className="minigame-secret-input" tabIndex={-1} aria-hidden="true"
|
||||||
|
autoComplete="off" autoCorrect="off" autoCapitalize="none" spellCheck={false} />
|
||||||
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
||||||
<Navbar.Collapse id="basic-navbar-nav">
|
<Navbar.Collapse id="basic-navbar-nav">
|
||||||
<Nav className="nav">
|
<Nav className="nav">
|
||||||
@@ -156,11 +165,11 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
|
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
|
||||||
</button>
|
</button>
|
||||||
<HuePicker
|
{isSimpleStyle && <HuePicker
|
||||||
accentHue={settings?.accentHue ?? 142}
|
accentHue={settings?.accentHue ?? 142}
|
||||||
isDark={effectiveDark}
|
isDark={effectiveDark}
|
||||||
onChange={hue => settings?.setAccentHue(hue)}
|
onChange={hue => settings?.setAccentHue(hue)}
|
||||||
/>
|
/>}
|
||||||
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
|
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
|
||||||
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
|
||||||
@@ -184,7 +193,7 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
<>
|
<>
|
||||||
<NavDropdown.Divider />
|
<NavDropdown.Divider />
|
||||||
<NavDropdown.Item onClick={() => setGenerateMockModalOpen(true)}>🔧 Generovat mock data</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => setGenerateMockModalOpen(true)}>🔧 Generovat mock data</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={() => setClearMockModalOpen(true)}>🔧 Smazat data dne</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => setClearMockModalOpen(true)}>🔧 Smazat mock data</NavDropdown.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<NavDropdown.Divider />
|
<NavDropdown.Divider />
|
||||||
@@ -192,6 +201,7 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
</NavDropdown>
|
</NavDropdown>
|
||||||
</Nav>
|
</Nav>
|
||||||
</Navbar.Collapse>
|
</Navbar.Collapse>
|
||||||
|
<DrawingGame />
|
||||||
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
|
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
|
||||||
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
||||||
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
|
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
.hue-picker-dropdown {
|
.hue-picker-dropdown {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
.dropdown-toggle {
|
.dropdown-toggle {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||||
|
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from "react-select-search";
|
||||||
|
import { StoreMenuDish } from "../../../types";
|
||||||
|
import { countNoteItems, matchNoteToMenu, noteItemQuantity, splitNoteItems } from "../utils/menuMatch";
|
||||||
|
import { useGroupLimits } from "../hooks/useGroupLimits";
|
||||||
|
import "./MenuHintCell.scss";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Celá nabídka podniku (stažená z Bolt Food/Wolt), undefined = načítá se / není */
|
||||||
|
menu?: StoreMenuDish[],
|
||||||
|
note?: string,
|
||||||
|
/** Zadaná částka člena v haléřích */
|
||||||
|
amount?: number,
|
||||||
|
canEdit: boolean,
|
||||||
|
onPick: (dish: StoreMenuDish) => void,
|
||||||
|
onUsePrice: (priceKc: number) => void,
|
||||||
|
/** Odebere jeden kus položky poznámky na daném indexu (a odečte její cenu) */
|
||||||
|
onRemoveItem: (index: number) => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDish = (d: StoreMenuDish) => d.price != null ? `${d.name} (${d.price} Kč)` : d.name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buňka „Z nabídky" ve skupinové objednávce: výběr položky ze stažené nabídky podniku
|
||||||
|
* (doplní poznámku i částku) a kontrola ceny rozpoznané z poznámky proti zadané částce.
|
||||||
|
*/
|
||||||
|
export default function MenuHintCell({ menu, note, amount, canEdit, onPick, onUsePrice, onRemoveItem }: Readonly<Props>) {
|
||||||
|
const { maxItemQuantity, maxItems } = useGroupLimits();
|
||||||
|
const options = useMemo<SelectSearchOption[]>(() => {
|
||||||
|
if (!menu?.length) return [];
|
||||||
|
const groups = new Map<string, SelectSearchOption[]>();
|
||||||
|
menu.forEach((d, index) => {
|
||||||
|
const category = d.category || 'Ostatní';
|
||||||
|
if (!groups.has(category)) groups.set(category, []);
|
||||||
|
// Jídlo, kterého už je v objednávce maximum kusů, nejde přidat znovu
|
||||||
|
const atLimit = noteItemQuantity(note, d.name, menu) >= maxItemQuantity;
|
||||||
|
groups.get(category)!.push({ name: atLimit ? `${formatDish(d)} — max ${maxItemQuantity} ks` : formatDish(d), value: String(index), disabled: atLimit });
|
||||||
|
});
|
||||||
|
return [...groups.entries()].map(([name, items]) => ({ name, type: 'group', items }));
|
||||||
|
}, [menu, note, maxItemQuantity]);
|
||||||
|
const itemLimitReached = useMemo(() => countNoteItems(note, menu ?? []) >= maxItems, [note, menu, maxItems]);
|
||||||
|
|
||||||
|
const match = useMemo(() => menu ? matchNoteToMenu(note, menu) : undefined, [menu, note]);
|
||||||
|
const items = useMemo(() => splitNoteItems(note, menu), [note, menu]);
|
||||||
|
|
||||||
|
if (!menu?.length) {
|
||||||
|
return <small className="text-muted">—</small>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuPrice = match?.total;
|
||||||
|
const differs = menuPrice != null && amount != null && Math.round(menuPrice * 100) !== amount;
|
||||||
|
const unmatched = match?.parts.filter(p => !p.dish).map(p => p.text) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="menu-hint-cell">
|
||||||
|
{canEdit && itemLimitReached && (
|
||||||
|
<small className="text-muted">Dosažen limit {maxItems} položek — nejdřív něco odeber (×).</small>
|
||||||
|
)}
|
||||||
|
{canEdit && !itemLimitReached && (
|
||||||
|
<SelectSearch
|
||||||
|
search
|
||||||
|
options={options}
|
||||||
|
placeholder="Přidat z nabídky…"
|
||||||
|
value=""
|
||||||
|
onBlur={() => { }}
|
||||||
|
onFocus={() => { }}
|
||||||
|
onChange={(value: SelectedOptionValue | SelectedOptionValue[]) => {
|
||||||
|
const dish = menu[Number(value)];
|
||||||
|
if (dish) onPick(dish);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canEdit && items.length > 0 && (
|
||||||
|
<div className="menu-hint-items">
|
||||||
|
{items.map((text, i) => {
|
||||||
|
// Rozpoznaná položka ze stejného rozdělení poznámky — kvůli ceně na čipu
|
||||||
|
const part = match && match.parts.length === items.length ? match.parts[i] : undefined;
|
||||||
|
const price = part?.dish?.price;
|
||||||
|
const quantity = part?.quantity ?? 1;
|
||||||
|
return (
|
||||||
|
<span key={`${i}-${text}`} className="menu-hint-item">
|
||||||
|
{text}{price != null && <small> {quantity > 1 ? `${quantity}× ${price} = ${quantity * price}` : price} Kč</small>}
|
||||||
|
<button type="button" onClick={() => onRemoveItem(i)} title={`Odebrat 1× ${text}`} aria-label={`Odebrat ${text}`}>×</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{match && (
|
||||||
|
<OverlayTrigger
|
||||||
|
placement="top"
|
||||||
|
overlay={
|
||||||
|
<Tooltip>
|
||||||
|
{match.parts.map((p, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
{p.dish
|
||||||
|
? `${p.quantity > 1 ? `${p.quantity}× ` : ''}${formatDish(p.dish)}`
|
||||||
|
: `„${p.text}" — v nabídce nenalezeno`}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<small className={`menu-hint-price ${differs ? 'text-warning' : 'text-muted'}`}>
|
||||||
|
{menuPrice != null
|
||||||
|
? <>Dle nabídky: <strong>{menuPrice} Kč</strong></>
|
||||||
|
: <>Nalezeno {match.parts.length - unmatched.length}/{match.parts.length} položek</>}
|
||||||
|
{differs && canEdit && (
|
||||||
|
<button type="button" className="menu-hint-use" onClick={() => onUsePrice(menuPrice!)}
|
||||||
|
title="Nastavit částku podle ceny v nabídce">
|
||||||
|
použít
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</small>
|
||||||
|
</OverlayTrigger>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
.bolt-progress {
|
.tracking-progress {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
|
|
||||||
.bolt-step {
|
.tracking-step {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 64px;
|
min-width: 64px;
|
||||||
|
|
||||||
.bolt-dot {
|
.tracking-dot {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 12px;
|
width: 12px;
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
background: var(--luncher-border, #ced4da);
|
background: var(--luncher-border, #ced4da);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bolt-label {
|
.tracking-label {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
color: var(--luncher-text-muted, #6c757d);
|
color: var(--luncher-text-muted, #6c757d);
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.done {
|
&.done {
|
||||||
.bolt-dot {
|
.tracking-dot {
|
||||||
background: var(--bs-success, #198754);
|
background: var(--bs-success, #198754);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,19 +49,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active .bolt-label {
|
&.active .tracking-label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pulzování aktivního kroku, dokud sledování běží
|
// Pulzování aktivního kroku, dokud sledování běží
|
||||||
&.live .bolt-step.active .bolt-dot {
|
&.live .tracking-step.active .tracking-dot {
|
||||||
animation: bolt-pulse 2s ease-in-out infinite;
|
animation: tracking-pulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes bolt-pulse {
|
@keyframes tracking-pulse {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.5);
|
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.5);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
|
||||||
|
import { TrackingProvider } from '../../../types';
|
||||||
|
import './OrderProgress.scss';
|
||||||
|
|
||||||
|
const STEPS = ['Přijato', 'Příprava', 'Vyzvedávání', 'Na cestě', 'Doručeno'];
|
||||||
|
|
||||||
|
/** Lidský název rozvozové služby do tooltipu. */
|
||||||
|
export const PROVIDER_LABEL: Record<TrackingProvider, string> = {
|
||||||
|
bolt: 'Bolt Food',
|
||||||
|
wolt: 'Wolt',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Známé stavy objednávky z Bolt API → index kroku ve stepperu.
|
||||||
|
* Pozor: waiting_delivery znamená "jídlo čeká v podniku na vyzvednutí",
|
||||||
|
* nikoli "na cestě" — tu signalizuje až stav kurýra (picked_up apod.).
|
||||||
|
* Krok „Vyzvedávání" je vyhrazen pro kombinaci „kurýr u podniku + jídlo hotové"
|
||||||
|
* (viz logika níže), samotný order_state ho neudělí — jinak bychom hlásili
|
||||||
|
* vyzvedávání i když se ještě peče (chování, na které si Bolt sám občas stěžuje).
|
||||||
|
*/
|
||||||
|
const BOLT_STATE_TO_STEP: Record<string, number> = {
|
||||||
|
created: 0,
|
||||||
|
pending: 0,
|
||||||
|
waiting_acceptance: 0,
|
||||||
|
accepted: 0,
|
||||||
|
waiting_preparation: 0,
|
||||||
|
preparing: 1,
|
||||||
|
waiting_delivery: 1,
|
||||||
|
ready_for_pickup: 1,
|
||||||
|
waiting_courier: 1,
|
||||||
|
waiting_pickup: 1,
|
||||||
|
picked_up: 3,
|
||||||
|
in_delivery: 3,
|
||||||
|
delivering: 3,
|
||||||
|
heading_to_client: 3,
|
||||||
|
delivered: 4,
|
||||||
|
finished: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stavy kurýra z Bolt API → index kroku. Kurýr u podniku ještě neznamená "na cestě". */
|
||||||
|
const BOLT_COURIER_STATE_TO_STEP: Record<string, number> = {
|
||||||
|
matched: 0,
|
||||||
|
accepted: 0,
|
||||||
|
heading_to_provider: 1,
|
||||||
|
arrived_to_provider: 1,
|
||||||
|
picked_up: 3,
|
||||||
|
heading_to_client: 3,
|
||||||
|
delivering: 3,
|
||||||
|
arrived_to_client: 3,
|
||||||
|
delivered: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Známé stavy objednávky z Wolt tracking API → index kroku ve stepperu.
|
||||||
|
* Pozor: Wolt hlásí stav podniku, ne rozvozu — "ready" (jídlo hotové) zůstává
|
||||||
|
* i celou dobu, co kurýr objednávku veze. Kroky „Vyzvedávání" a „Na cestě" proto
|
||||||
|
* neurčuje status, ale odvozený stav kurýra (viz trackingProviders.ts).
|
||||||
|
*/
|
||||||
|
const WOLT_STATE_TO_STEP: Record<string, number> = {
|
||||||
|
received: 0,
|
||||||
|
acknowledged: 0,
|
||||||
|
confirmed: 0,
|
||||||
|
waiting: 0,
|
||||||
|
production: 1,
|
||||||
|
preparing: 1,
|
||||||
|
ready: 1,
|
||||||
|
picked_up: 3,
|
||||||
|
transport: 3,
|
||||||
|
delivering: 3,
|
||||||
|
delivered: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Popis odvozeného stavu kurýra u Woltu do tooltipu. */
|
||||||
|
const WOLT_COURIER_INFO: Record<string, string> = {
|
||||||
|
assigned: 'kurýr přiřazen',
|
||||||
|
delivering: 'kurýr veze objednávku',
|
||||||
|
delivering_other: 'kurýr veze objednávku, ale nejdřív doručuje jinou',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stavy, ve kterých už jídlo čeká připravené na kurýra. */
|
||||||
|
const PICKUP_READY_STATES: Record<TrackingProvider, Set<string>> = {
|
||||||
|
bolt: new Set(['waiting_delivery', 'ready_for_pickup', 'waiting_courier', 'waiting_pickup']),
|
||||||
|
wolt: new Set(['ready']),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Neznámé stavy se mapují heuristicky podle klíčových slov. */
|
||||||
|
function stepForOrderState(provider: TrackingProvider, state: string): number | 'cancelled' {
|
||||||
|
const s = state.toLowerCase();
|
||||||
|
const known = provider === 'wolt' ? WOLT_STATE_TO_STEP : BOLT_STATE_TO_STEP;
|
||||||
|
if (s in known) return known[s];
|
||||||
|
if (/cancel|reject|fail/.test(s)) return 'cancelled';
|
||||||
|
if (/delivered|finished/.test(s)) return 4;
|
||||||
|
if (/accept|pending|created|received/.test(s)) return 0;
|
||||||
|
if (/^waiting|prepar|ready|cook|production/.test(s)) return 1;
|
||||||
|
if (/picked|delivering|heading_to_client|transport/.test(s)) return 3;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepForCourierState(provider: TrackingProvider, state?: string): number {
|
||||||
|
if (!state) return 0;
|
||||||
|
const s = state.toLowerCase();
|
||||||
|
// Wolt má jen odvozené stavy: 'assigned' o kroku nic neříká, 'delivering'
|
||||||
|
// i 'delivering_other' znamenají, že kurýr naši objednávku už veze
|
||||||
|
if (provider === 'wolt') return s.startsWith('delivering') ? 3 : 0;
|
||||||
|
if (s in BOLT_COURIER_STATE_TO_STEP) return BOLT_COURIER_STATE_TO_STEP[s];
|
||||||
|
if (/picked|client|delivering|transport/.test(s)) return 3;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Rozvozová služba, ze které stav pochází */
|
||||||
|
provider: TrackingProvider;
|
||||||
|
/** Raw stav objednávky (Bolt order_state, Wolt status) */
|
||||||
|
state: string;
|
||||||
|
/** Raw stav kurýra (Bolt courier.state, Wolt 'assigned') */
|
||||||
|
courierState?: string;
|
||||||
|
/** Zda sledování stále běží (skupina má trackingCode) */
|
||||||
|
tracking: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mini progress stepper se stavem objednávky u rozvozové služby. */
|
||||||
|
export default function OrderProgress({ provider, state, courierState, tracking }: Props) {
|
||||||
|
const label = PROVIDER_LABEL[provider] ?? provider;
|
||||||
|
const orderStep = stepForOrderState(provider, state);
|
||||||
|
if (orderStep === 'cancelled') {
|
||||||
|
return <small className="text-danger">Objednávka {label} byla zrušena</small>;
|
||||||
|
}
|
||||||
|
// Stav kurýra může krok jen zpřesnit dopředu (např. waiting_delivery + picked_up → Na cestě)
|
||||||
|
let step = Math.max(orderStep, stepForCourierState(provider, courierState));
|
||||||
|
// Vyzvedávání = kurýr u podniku a jídlo skutečně hotové. Sám arrived_to_provider
|
||||||
|
// (bez toho, že by jídlo bylo hotové) nestačí — viz komentář u BOLT_STATE_TO_STEP.
|
||||||
|
const courierAtProvider = provider === 'wolt'
|
||||||
|
? !!courierState
|
||||||
|
: courierState?.toLowerCase() === 'arrived_to_provider';
|
||||||
|
const foodReadyForPickup = PICKUP_READY_STATES[provider]?.has(state.toLowerCase());
|
||||||
|
if (step < 3 && courierAtProvider && foodReadyForPickup) step = 2;
|
||||||
|
const courierInfo = provider === 'wolt'
|
||||||
|
? WOLT_COURIER_INFO[courierState ?? ''] ?? 'kurýr přiřazen'
|
||||||
|
: `kurýr: ${courierState}`;
|
||||||
|
const rawInfo = courierState ? `${state} / ${courierInfo}` : state;
|
||||||
|
return (
|
||||||
|
<OverlayTrigger overlay={<Tooltip>Stav z {label}: {rawInfo}</Tooltip>}>
|
||||||
|
<div className={`tracking-progress${tracking && step < 4 ? ' live' : ''}`}>
|
||||||
|
{STEPS.map((stepLabel, i) => (
|
||||||
|
<div key={stepLabel} className={`tracking-step${i <= step ? ' done' : ''}${i === step ? ' active' : ''}`}>
|
||||||
|
<div className="tracking-dot" />
|
||||||
|
<div className="tracking-label">{stepLabel}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</OverlayTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { StyleTheme } from "../context/settings";
|
||||||
|
import { UmaCharacter, loadUmaCharacters } from "../utils/umaCharacters";
|
||||||
|
import "./WheelConfetti.scss";
|
||||||
|
|
||||||
|
type WheelTheme = StyleTheme;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
theme: WheelTheme,
|
||||||
|
/** Mění se s každým výsledkem — nová hodnota spustí novou salvu. */
|
||||||
|
burstKey: number,
|
||||||
|
/** Velká salva (finální výsledek) vs. menší (mezivýsledek). */
|
||||||
|
big?: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
type UmaImage = { src: string, round: boolean };
|
||||||
|
|
||||||
|
const SIMPLE_COLORS = ['#16a34a', '#0ea5e9', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#ef4444'];
|
||||||
|
const UMA_EMOJI = ['🐴', '🏇', '🥕', '🏆', '🌸', '✨', '⭐', '🍙', '🎀', '🥇'];
|
||||||
|
// Hlášky z fandomu Umamusume (Umapyoi Densetsu, Teio step, hachimī, Golshi, Oguri „okawari"...)
|
||||||
|
const UMA_BADGES = ['1着!', 'Umazing!', 'Umapyoi!', 'Teio step!', 'Hachimī~', 'Golshi approves', 'Okawari!', 'Pakapaka~', 'ウマい!'];
|
||||||
|
const UMA_BADGE_COLORS = ['#ff6fa3', '#2e9a3a', '#43b8f0', '#f2a91a'];
|
||||||
|
const MLP_EMOJI = ['🦄', '🌈', '✨', '⭐', '🧁', '🍎', '🦋', '💎', '🎈', '🎉', '🍰', '💜'];
|
||||||
|
// Hlášky a narážky z My Little Pony: Friendship is Magic
|
||||||
|
const MLP_BADGES = ['Friendship is Magic!', '20% cooler!', 'Sonic Rainboom!', 'Yay!', 'Party!', 'Yeehaw!', 'Everypony!', 'Awesome!', 'Darling!', 'Pinkie Promise'];
|
||||||
|
const MLP_BADGE_COLORS = ['#8e4fd1', '#f06aa6', '#1e98d3', '#f37033', '#e3b829', '#5b4fc9'];
|
||||||
|
/** Postavy Mane 6 (+ Spike) s „hláškou" po finálním výsledku — místo obrázků cutie mark emoji. */
|
||||||
|
const MLP_SPEAKERS = [
|
||||||
|
{ name: 'Twilight Sparkle', emoji: '⭐', color: '#8e4fd1', quote: 'Podle mého seznamu je to ta nejlogičtější volba!' },
|
||||||
|
{ name: 'Rainbow Dash', emoji: '🌈', color: '#1e98d3', quote: 'Tohle je o 20 % úžasnější než ostatní!' },
|
||||||
|
{ name: 'Pinkie Pie', emoji: '🎈', color: '#f06aa6', quote: 'Tohle volá po PÁRTY! S dortem! A s cupcaky!' },
|
||||||
|
{ name: 'Applejack', emoji: '🍎', color: '#f37033', quote: 'Poctivé jídlo pro poctivou práci. Jíhá!' },
|
||||||
|
{ name: 'Fluttershy', emoji: '🦋', color: '#e3b829', quote: 'Jé… teda, jestli to nevadí… yay.' },
|
||||||
|
{ name: 'Rarity', emoji: '💎', color: '#5b4fc9', quote: 'Naprosto božská volba, drahoušku!' },
|
||||||
|
{ name: 'Spike', emoji: '🐉', color: '#62bc4d', quote: 'Zapsáno do svitku a odesláno princezně!' },
|
||||||
|
];
|
||||||
|
const HG_EMOJI = ['🥀', '🏮', '⛩️', '🔥', '🕯️', '🌑', '🦋', '👁️', '🕸️', '🌸'];
|
||||||
|
// Hlášky z Jigoku Shōjo (Pekelná korespondence, „Ippen, shinde miru?", poslání do pekla...)
|
||||||
|
const HG_BADGES = ['いっぺん、死んでみる?', '地獄通信', '地獄流し', 'Ippen… shinde miru?', 'Pomsta vykonána', '0:00', 'Uvaž to dobře…', 'Smlouva uzavřena'];
|
||||||
|
const HG_BADGE_COLORS = ['#12070a', '#7a0c1f', '#2e0d24'];
|
||||||
|
/** Ai Enma a její služebníci s „hláškou" po finálním výsledku — místo obrázků emoji. */
|
||||||
|
const HG_SPEAKERS = [
|
||||||
|
{ name: 'Ai Enma', emoji: '🥀', color: '#c8102e', quote: 'Smlouva je uzavřena. Tenhle oběd tě bude provázet až na věčnost.' },
|
||||||
|
{ name: 'Ichimoku Ren', emoji: '👁️', color: '#6b5a8a', quote: 'Prohlédl jsem svým okem všechny podniky. Tenhle jediný obstál.' },
|
||||||
|
{ name: 'Hone Onna', emoji: '💀', color: '#b8862b', quote: 'Dobré až na kost, zlato. Mně z toho stejně nic nezůstane.' },
|
||||||
|
{ name: 'Wanyūdō', emoji: '🔥', color: '#d9582b', quote: 'Já jsem kolo. Kolo rozhodlo. Víc k tomu nemám.' },
|
||||||
|
{ name: 'Kikuri', emoji: '🎀', color: '#d4407a', quote: 'Hihi! Tohle bych do pekla poslala jako první!' },
|
||||||
|
{ name: 'Babička', emoji: '🕸️', color: '#8c7672', quote: 'Ai, oběd je na stole… jako každý den už čtyři sta let.' },
|
||||||
|
];
|
||||||
|
const KON_EMOJI = ['🎸', '🥁', '🎹', '🎵', '🎶', '🍰', '☕', '🍓', '🐢', '🎀', '🍪', '🫖'];
|
||||||
|
// Hlášky z K-ON! (Houkago Tea Time, Fuwa Fuwa Time, Gitah, Ton-chan, Mugi a její čaj...)
|
||||||
|
const KON_BADGES = ['放課後ティータイム', 'Fuwa Fuwa Time ♪', 'Tea time!', 'Gitah!', 'Ton-chan ♡', 'けいおん!', 'Nejdřív čaj, pak zkouška', "Don't say lazy", 'Azunyan~', 'Ui!'];
|
||||||
|
const KON_BADGE_COLORS = ['#d43f62', '#3d6fc4', '#e8a10f', '#8a6bc7', '#e46f9a', '#27315f'];
|
||||||
|
/** Členky Houkago Tea Time (+ Ui, Sawa-chan a Ton-chan) s „hláškou" po finálním výsledku. */
|
||||||
|
const KON_SPEAKERS = [
|
||||||
|
{ name: 'Yui Hirasawa', emoji: '🎸', color: '#d43f62', quote: 'Gitah říká, že tohle bude mňamka! Fuwa fuwa~' },
|
||||||
|
{ name: 'Mio Akiyama', emoji: '🎼', color: '#3d6fc4', quote: 'Dobře… ale hned po obědě se jde zkoušet! Opravdu!' },
|
||||||
|
{ name: 'Ritsu Tainaka', emoji: '🥁', color: '#e8a10f', quote: 'Prezidentka klubu rozhodla! Raz, dva, raz dva tři čtyři!' },
|
||||||
|
{ name: 'Tsumugi Kotobuki', emoji: '🫖', color: '#c9a227', quote: 'Čaj a dortík k tomu přinesu já. Těším se!' },
|
||||||
|
{ name: 'Azusa Nakano', emoji: '🐱', color: '#8a6bc7', quote: 'Zase jídlo místo zkoušky? …No dobře, ale jen chvilku!' },
|
||||||
|
{ name: 'Ui Hirasawa', emoji: '🍳', color: '#e46f9a', quote: 'Sestřičko, nezapomeň si vzít ubrousek!' },
|
||||||
|
{ name: 'Sawako Yamanaka', emoji: '👗', color: '#27315f', quote: 'Oběd? Výborně. Mezitím vám ušiju nové kostýmy!' },
|
||||||
|
{ name: 'Ton-chan', emoji: '🐢', color: '#3c9a6b', quote: '(spokojeně mává ploutvičkou)' },
|
||||||
|
];
|
||||||
|
const BOCCHI_EMOJI = ['🎸', '🥁', '🎤', '📦', '🥭', '🟨', '🟦', '✨', '🌿', '🫠', '⭐', '🔊'];
|
||||||
|
// Hlášky z Bocchi the Rock! (Kessoku Band, Kita-aura, krabice od manga, guitarhero, live house STARRY...)
|
||||||
|
const BOCCHI_BADGES = ['結束バンド', 'Kessoku Band!', 'Kitaaan ✨', 'guitarhero', 'A-ano…', 'Live at STARRY', 'Bocchi-chan!', 'ぼっち・ざ・ろっく!', 'Mango box', 'Rock!'];
|
||||||
|
const BOCCHI_BADGE_COLORS = ['#e0457b', '#e8b400', '#3a63c9', '#d93a30', '#1d1a22'];
|
||||||
|
/** Kessoku Band a jejich okolí s „hláškou" po finálním výsledku. */
|
||||||
|
const BOCCHI_SPEAKERS = [
|
||||||
|
{ name: 'Hitori Gotoh', emoji: '🎸', color: '#e0457b', quote: 'A-ano… t-tohle by šlo… teda, pokud to nikomu nevadí…' },
|
||||||
|
{ name: 'Nijika Ijichi', emoji: '🥁', color: '#e8b400', quote: 'Tak jo, kapelo, jdeme jíst! Bocchi, vylez z té krabice!' },
|
||||||
|
{ name: 'Ryo Yamada', emoji: '🌿', color: '#3a63c9', quote: 'Dobrá volba. Mimochodem… nepůjčíš mi na oběd?' },
|
||||||
|
{ name: 'Ikuyo Kita', emoji: '✨', color: '#d93a30', quote: 'Kitaaan! Tohle se mi děsně líbí, vyfotím to na Instagram!' },
|
||||||
|
{ name: 'Seika Ijichi', emoji: '🎛️', color: '#7a4fc0', quote: 'Najezte se pořádně. Večer v STARRY neodfláknete zkoušku.' },
|
||||||
|
{ name: 'Futari Gotoh', emoji: '🐕', color: '#f08fb4', quote: 'Ségra zase obědvá ve skříni! Jimihen, pojď se podívat!' },
|
||||||
|
];
|
||||||
|
/** Styly, jejichž konfety tvoří emoji a samolepky s hláškami (a po finále promluví postava s emoji). */
|
||||||
|
const EMOJI_CONFETTI: Partial<Record<WheelTheme, {
|
||||||
|
prefix: string, badges: string[], colors: string[], emoji: string[],
|
||||||
|
speakers: { name: string, emoji: string, color: string, quote: string }[],
|
||||||
|
banner: string,
|
||||||
|
}>> = {
|
||||||
|
mlp: { prefix: 'confetti-mlp', badges: MLP_BADGES, colors: MLP_BADGE_COLORS, emoji: MLP_EMOJI, speakers: MLP_SPEAKERS, banner: '🌈 Sonic Rainboom! 🌈' },
|
||||||
|
hellgirl: { prefix: 'confetti-hg', badges: HG_BADGES, colors: HG_BADGE_COLORS, emoji: HG_EMOJI, speakers: HG_SPEAKERS, banner: '🏮 地獄流し 🏮' },
|
||||||
|
kon: { prefix: 'confetti-kon', badges: KON_BADGES, colors: KON_BADGE_COLORS, emoji: KON_EMOJI, speakers: KON_SPEAKERS, banner: '🎸 放課後ティータイム 🍰' },
|
||||||
|
bocchi: { prefix: 'confetti-bocchi', badges: BOCCHI_BADGES, colors: BOCCHI_BADGE_COLORS, emoji: BOCCHI_EMOJI, speakers: BOCCHI_SPEAKERS, banner: '🎸 結束バンド 🎸' },
|
||||||
|
};
|
||||||
|
const DURATION_MS = 6400;
|
||||||
|
|
||||||
|
const rand = (min: number, max: number) => min + Math.random() * (max - min);
|
||||||
|
const pick = <T,>(list: T[]) => list[Math.floor(Math.random() * list.length)];
|
||||||
|
|
||||||
|
type Piece = { id: number, style: React.CSSProperties, content: React.ReactNode, className: string };
|
||||||
|
|
||||||
|
type Launch = 'cannon-left' | 'cannon-right' | 'burst' | 'rain';
|
||||||
|
|
||||||
|
const LAUNCHES: Launch[] = ['cannon-left', 'cannon-left', 'cannon-right', 'cannon-right', 'burst', 'rain'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Výchozí pozice a dráha jednoho kousku: děla z dolních rohů střílí šikmo vzhůru,
|
||||||
|
* „výbuch" se rozletí ze středu a déšť padá opožděně shora. Kousek nejprve vyletí
|
||||||
|
* do vrcholu (--x1/--y1) a pak dopadne mimo obrazovku (--x2/--y2).
|
||||||
|
*/
|
||||||
|
function launchStyle(launch: Launch): React.CSSProperties {
|
||||||
|
let left: number, top: number, x1: number, y1: number, x2: number, y2: number, delay: number;
|
||||||
|
switch (launch) {
|
||||||
|
case 'cannon-left':
|
||||||
|
case 'cannon-right': {
|
||||||
|
const dir = launch === 'cannon-left' ? 1 : -1;
|
||||||
|
left = launch === 'cannon-left' ? rand(-3, 3) : rand(97, 103);
|
||||||
|
top = rand(88, 100);
|
||||||
|
x1 = dir * rand(12, 62);
|
||||||
|
y1 = -rand(45, 88);
|
||||||
|
x2 = x1 + dir * rand(6, 22);
|
||||||
|
y2 = rand(12, 25);
|
||||||
|
delay = rand(0, 450);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'burst': {
|
||||||
|
const angle = rand(0, Math.PI * 2);
|
||||||
|
const radius = rand(18, 48);
|
||||||
|
left = 50;
|
||||||
|
top = 42;
|
||||||
|
x1 = Math.cos(angle) * radius;
|
||||||
|
y1 = Math.sin(angle) * radius * 0.8 - rand(5, 15);
|
||||||
|
x2 = x1 * 1.35;
|
||||||
|
y2 = rand(70, 85);
|
||||||
|
delay = rand(0, 250);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
left = rand(0, 100);
|
||||||
|
top = -12;
|
||||||
|
x1 = rand(-8, 8);
|
||||||
|
y1 = rand(35, 55);
|
||||||
|
x2 = x1 + rand(-10, 10);
|
||||||
|
y2 = rand(115, 125);
|
||||||
|
delay = rand(400, 1800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const spin = rand(180, 720) * (Math.random() < 0.5 ? -1 : 1);
|
||||||
|
return {
|
||||||
|
left: `${left}vw`,
|
||||||
|
top: `${top}vh`,
|
||||||
|
animationName: launch === 'rain' ? 'confetti-rain' : 'confetti-throw',
|
||||||
|
animationDelay: `${delay}ms`,
|
||||||
|
animationDuration: `${rand(2600, 3900)}ms`,
|
||||||
|
['--x1' as any]: `${x1}vw`,
|
||||||
|
['--y1' as any]: `${y1}vh`,
|
||||||
|
['--x2' as any]: `${x2}vw`,
|
||||||
|
['--y2' as any]: `${y2}vh`,
|
||||||
|
['--r1' as any]: `${spin * 0.4}deg`,
|
||||||
|
['--r2' as any]: `${spin}deg`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPieces(theme: WheelTheme, big: boolean, images: UmaImage[]): Piece[] {
|
||||||
|
if (theme === 'simple') {
|
||||||
|
const count = big ? 170 : 80;
|
||||||
|
return Array.from({ length: count }, (_, id) => ({
|
||||||
|
id,
|
||||||
|
className: 'confetti-paper',
|
||||||
|
content: null,
|
||||||
|
style: {
|
||||||
|
...launchStyle(pick(LAUNCHES)),
|
||||||
|
width: `${rand(6, 11)}px`,
|
||||||
|
height: `${rand(10, 18)}px`,
|
||||||
|
background: pick(SIMPLE_COLORS),
|
||||||
|
borderRadius: Math.random() < 0.3 ? '50%' : '2px',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const count = big ? 50 : 24;
|
||||||
|
const emojiConfetti = EMOJI_CONFETTI[theme];
|
||||||
|
if (emojiConfetti) {
|
||||||
|
const { prefix, badges, colors, emoji } = emojiConfetti;
|
||||||
|
return Array.from({ length: count }, (_, id) => {
|
||||||
|
const base: React.CSSProperties = {
|
||||||
|
...launchStyle(pick(LAUNCHES)),
|
||||||
|
['--r1' as any]: `${rand(-25, 25)}deg`,
|
||||||
|
['--r2' as any]: `${rand(-40, 40)}deg`,
|
||||||
|
};
|
||||||
|
if (Math.random() < 0.35) {
|
||||||
|
return { id, className: `${prefix} ${prefix}-badge`, content: pick(badges),
|
||||||
|
style: { ...base, background: pick(colors) } };
|
||||||
|
}
|
||||||
|
return { id, className: `${prefix} confetti-uma-emoji`, style: { ...base, fontSize: `${rand(28, 52)}px` }, content: pick(emoji) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Array.from({ length: count }, (_, id) => {
|
||||||
|
const roll = Math.random();
|
||||||
|
// Samolepky se tolik netočí, ať jsou čitelné
|
||||||
|
const base: React.CSSProperties = {
|
||||||
|
...launchStyle(pick(LAUNCHES)),
|
||||||
|
['--r1' as any]: `${rand(-25, 25)}deg`,
|
||||||
|
['--r2' as any]: `${rand(-40, 40)}deg`,
|
||||||
|
};
|
||||||
|
if (images.length && roll < 0.45) {
|
||||||
|
const image = pick(images);
|
||||||
|
return { id, className: `confetti-uma confetti-uma-img${image.round ? ' round' : ''}`, style: base,
|
||||||
|
content: <img src={image.src} alt="" loading="eager" /> };
|
||||||
|
}
|
||||||
|
if (roll < 0.7) {
|
||||||
|
return { id, className: 'confetti-uma confetti-uma-badge', content: pick(UMA_BADGES),
|
||||||
|
style: { ...base, background: pick(UMA_BADGE_COLORS) } };
|
||||||
|
}
|
||||||
|
return { id, className: 'confetti-uma confetti-uma-emoji', style: { ...base, fontSize: `${rand(28, 52)}px` }, content: pick(UMA_EMOJI) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Salva konfet po výsledku kola štěstí, ve vzhledu podle zvoleného stylu. */
|
||||||
|
export default function WheelConfetti({ theme, burstKey, big = false }: Readonly<Props>) {
|
||||||
|
const [characters, setCharacters] = useState<UmaCharacter[]>([]);
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme === 'umamusume') loadUmaCharacters().then(setCharacters);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!burstKey) return;
|
||||||
|
setActive(true);
|
||||||
|
const t = setTimeout(() => setActive(false), DURATION_MS + 900);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [burstKey]);
|
||||||
|
|
||||||
|
const pieces = useMemo(
|
||||||
|
() => buildPieces(theme, big, characters.flatMap(c => c.images.map(src => ({ src, round: !!c.round })))),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[burstKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Při finálním výsledku „promluví" náhodná postava svou hláškou
|
||||||
|
const speaker = useMemo(() => {
|
||||||
|
const withQuote = characters.filter(c => c.quote);
|
||||||
|
return theme === 'umamusume' && big && withQuote.length ? pick(withQuote) : undefined;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [burstKey]);
|
||||||
|
|
||||||
|
// Ostatní tematické styly: hlášku pronese náhodná postava (Mane 6, Ai Enma, Houkago Tea Time, Kessoku Band...)
|
||||||
|
const emojiConfetti = EMOJI_CONFETTI[theme];
|
||||||
|
const emojiSpeaker = useMemo(
|
||||||
|
() => big && emojiConfetti ? pick(emojiConfetti.speakers) : undefined,
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[burstKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!active || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className={`wheel-confetti wheel-confetti-${theme}`} key={burstKey} aria-hidden>
|
||||||
|
{theme === 'umamusume' && big && <div className="confetti-uma-banner">🏆 1着 🏆</div>}
|
||||||
|
{emojiConfetti && big && <div className={`${emojiConfetti.prefix}-banner`}>{emojiConfetti.banner}</div>}
|
||||||
|
{pieces.map(p => <span key={p.id} className={p.className} style={p.style}>{p.content}</span>)}
|
||||||
|
{speaker && (
|
||||||
|
<div className="confetti-uma-voice">
|
||||||
|
<img src={pick(speaker.images)} alt="" className={speaker.round ? 'round' : undefined} />
|
||||||
|
<div className="confetti-uma-bubble">
|
||||||
|
<p>„{speaker.quote}"</p>
|
||||||
|
<small>— {speaker.name}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{emojiSpeaker && (
|
||||||
|
<div className={`confetti-uma-voice confetti-${theme}-voice`} style={{ ['--mlp-speaker' as any]: emojiSpeaker.color }}>
|
||||||
|
<span className="confetti-mlp-avatar">{emojiSpeaker.emoji}</span>
|
||||||
|
<div className="confetti-mlp-bubble">
|
||||||
|
<p>„{emojiSpeaker.quote}"</p>
|
||||||
|
<small>— {emojiSpeaker.name}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ type Props = {
|
|||||||
group: OrderGroup;
|
group: OrderGroup;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Nabídka stavů pro ruční nastavení (odpovídá mapování v BoltOrderProgress). */
|
/** Nabídka stavů pro ruční nastavení (odpovídá mapování v OrderProgress). */
|
||||||
const STATE_OPTIONS: { key: string; label: string; order_state: string; courier_state?: string }[] = [
|
const STATE_OPTIONS: { key: string; label: string; order_state: string; courier_state?: string }[] = [
|
||||||
{ key: 'accepted', label: 'Přijato', order_state: 'accepted' },
|
{ key: 'accepted', label: 'Přijato', order_state: 'accepted' },
|
||||||
{ key: 'preparing', label: 'Příprava', order_state: 'preparing' },
|
{ key: 'preparing', label: 'Příprava', order_state: 'preparing' },
|
||||||
@@ -30,7 +30,7 @@ export default function BoltSimulationModal({ isOpen, onClose, group }: Readonly
|
|||||||
const [info, setInfo] = useState<string | null>(null);
|
const [info, setInfo] = useState<string | null>(null);
|
||||||
const [manualKey, setManualKey] = useState<string>('preparing');
|
const [manualKey, setManualKey] = useState<string>('preparing');
|
||||||
|
|
||||||
const running = !!group.boltTrackingToken;
|
const running = !!group.trackingCode;
|
||||||
|
|
||||||
/** Obecný runner — spustí akci, ošetří chybu a krátce zobrazí výsledek. */
|
/** Obecný runner — spustí akci, ošetří chybu a krátce zobrazí výsledek. */
|
||||||
const run = async (action: () => Promise<{ error?: unknown }>, okMsg: string) => {
|
const run = async (action: () => Promise<{ error?: unknown }>, okMsg: string) => {
|
||||||
@@ -113,7 +113,7 @@ export default function BoltSimulationModal({ isOpen, onClose, group }: Readonly
|
|||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
Stav simulace:{' '}
|
Stav simulace:{' '}
|
||||||
{running
|
{running
|
||||||
? <Badge bg="success">běží{group.boltOrderState ? ` — ${group.boltOrderState}` : ''}</Badge>
|
? <Badge bg="success">běží{group.trackingOrderState ? ` — ${group.trackingOrderState}` : ''}</Badge>
|
||||||
: <Badge bg="secondary">neběží</Badge>}
|
: <Badge bg="secondary">neběží</Badge>}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
.butterfly-mystats {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 203, 61, 0.16), rgba(224, 162, 0, 0.08));
|
||||||
|
border: 1px solid rgba(224, 162, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-mystats-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #6b5b2e;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-mystats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.1;
|
||||||
|
|
||||||
|
&.gold {
|
||||||
|
color: #e0a200;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.l {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-heading {
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-achievements {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-achievement {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
opacity: 0.55;
|
||||||
|
filter: grayscale(1);
|
||||||
|
|
||||||
|
&.unlocked {
|
||||||
|
opacity: 1;
|
||||||
|
filter: none;
|
||||||
|
background: rgba(255, 203, 61, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ach-icon { flex: 0 0 auto; }
|
||||||
|
.ach-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
|
||||||
|
&.me {
|
||||||
|
background: rgba(255, 203, 61, 0.22);
|
||||||
|
border: 1px solid rgba(224, 162, 0, 0.5);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-rank {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 28px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-title {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b5b2e;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-stats {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-golden {
|
||||||
|
color: #e0a200;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Modal } from "react-bootstrap";
|
||||||
|
import {
|
||||||
|
ButterflyLeaderboardEntry, ButterflyStats, ButterflyAchievement,
|
||||||
|
getButterflyLeaderboard, getButterflyAchievements,
|
||||||
|
} from "../../../../types";
|
||||||
|
import { useAuth } from "../../context/auth";
|
||||||
|
import "./ButterflyLeaderboardModal.scss";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
myStats?: ButterflyStats;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
|
|
||||||
|
/** Modální dialog s osobními statistikami, achievementy a týmovým žebříčkem. */
|
||||||
|
export default function ButterflyLeaderboardModal({ isOpen, onClose, myStats }: Readonly<Props>) {
|
||||||
|
const auth = useAuth();
|
||||||
|
const [entries, setEntries] = useState<ButterflyLeaderboardEntry[]>();
|
||||||
|
const [achievements, setAchievements] = useState<ButterflyAchievement[]>();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([
|
||||||
|
getButterflyLeaderboard({ query: { limit: 20 } }).then(r => r.data ?? []).catch(() => []),
|
||||||
|
getButterflyAchievements().then(r => r.data ?? []).catch(() => []),
|
||||||
|
]).then(([lb, ach]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setEntries(lb);
|
||||||
|
setAchievements(ach);
|
||||||
|
}).finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={onClose} centered>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>🦋 Chytání motýlků</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{myStats && (
|
||||||
|
<div className="butterfly-mystats">
|
||||||
|
<div className="butterfly-mystats-title">
|
||||||
|
{myStats.level}. {myStats.title}
|
||||||
|
</div>
|
||||||
|
<div className="butterfly-mystats-grid">
|
||||||
|
<div><span className="v">{myStats.caught}</span><span className="l">🦋 chyceno</span></div>
|
||||||
|
<div><span className="v gold">{myStats.goldenCaught}</span><span className="l">✨ zlatých</span></div>
|
||||||
|
<div><span className="v">{myStats.coins}</span><span className="l">🪙 mincí</span></div>
|
||||||
|
<div><span className="v">{myStats.waspsKilled}</span><span className="l">🪰 vos zabito</span></div>
|
||||||
|
<div><span className="v">{myStats.birdsScared}</span><span className="l">🦅 vyplašeno</span></div>
|
||||||
|
<div><span className="v">{myStats.thievesDefeated}</span><span className="l">🦹 zlodějů</span></div>
|
||||||
|
<div><span className="v">{myStats.caterpillarsDefeated}</span><span className="l">🐛 housenek</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{achievements && achievements.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h6 className="butterfly-leaderboard-heading">Odznaky</h6>
|
||||||
|
<div className="butterfly-achievements">
|
||||||
|
{achievements.map(a => (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
className={a.unlocked ? 'butterfly-achievement unlocked' : 'butterfly-achievement'}
|
||||||
|
title={a.description}
|
||||||
|
>
|
||||||
|
<span className="ach-icon">{a.unlocked ? '🏅' : '🔒'}</span>
|
||||||
|
<span className="ach-title">{a.title}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h6 className="butterfly-leaderboard-heading">Žebříček týmu</h6>
|
||||||
|
{loading && <p className="text-center text-muted mb-0">Načítám žebříček…</p>}
|
||||||
|
{!loading && entries && entries.length === 0 && (
|
||||||
|
<p className="text-center text-muted mb-0">Zatím nikdo nic nechytil. Buď první!</p>
|
||||||
|
)}
|
||||||
|
{!loading && entries && entries.length > 0 && (
|
||||||
|
<ol className="butterfly-leaderboard">
|
||||||
|
{entries.map((e, i) => (
|
||||||
|
<li
|
||||||
|
key={e.login}
|
||||||
|
className={e.login === auth?.login ? 'butterfly-leaderboard-row me' : 'butterfly-leaderboard-row'}
|
||||||
|
>
|
||||||
|
<span className="butterfly-leaderboard-rank">{MEDALS[i] ?? `${i + 1}.`}</span>
|
||||||
|
<span className="butterfly-leaderboard-name" title={e.title}>
|
||||||
|
{e.login}
|
||||||
|
<span className="butterfly-leaderboard-title">{e.level}. {e.title}</span>
|
||||||
|
</span>
|
||||||
|
<span className="butterfly-leaderboard-stats">
|
||||||
|
<span className="butterfly-leaderboard-caught">{e.caught} 🦋</span>
|
||||||
|
{e.goldenCaught > 0 && (
|
||||||
|
<span className="butterfly-leaderboard-golden">{e.goldenCaught} ✨</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
.shop-balance {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #e0a200;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-heading {
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 14px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-shop-daily {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(28, 126, 214, 0.12);
|
||||||
|
border: 1px solid rgba(28, 126, 214, 0.35);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.daily-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-bar-inner {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #4dabf7, #1c7ed6);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-status {
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #495057;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-shop-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-shop-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.12s ease, transform 0.08s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #f1f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: scale(0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item-lvl {
|
||||||
|
display: inline;
|
||||||
|
color: #1c7ed6;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item-desc {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: #868e96;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item-cost {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #e0a200;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Modal } from "react-bootstrap";
|
||||||
|
import { ButterflyStats } from "../../../../types";
|
||||||
|
import "./ButterflyShopModal.scss";
|
||||||
|
|
||||||
|
type UpgradeId = 'net' | 'scarecrow' | 'reinforced';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
stats?: ButterflyStats;
|
||||||
|
onBuyPremium: () => void;
|
||||||
|
onWaspSpray: () => void;
|
||||||
|
onRepellent: () => void;
|
||||||
|
onBuyInsurance: () => void;
|
||||||
|
onBuyUpgrade: (item: UpgradeId) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Zrcadlí serverové hodnoty (server/src/butterflies.ts)
|
||||||
|
const PREMIUM_BUY_COST = 40;
|
||||||
|
const WASP_SPRAY_COST = 25;
|
||||||
|
const REPELLENT_COST = 70;
|
||||||
|
const INSURANCE_COST = 50;
|
||||||
|
const UPGRADE_BASE: Record<UpgradeId, number> = { net: 100, scarecrow: 120, reinforced: 150 };
|
||||||
|
const UPGRADE_MAX: Record<UpgradeId, number> = { net: 5, scarecrow: 4, reinforced: 3 };
|
||||||
|
const UPGRADE_META: Record<UpgradeId, { icon: string; title: string; desc: string }> = {
|
||||||
|
net: { icon: '🥅', title: 'Větší síťka', desc: 'Zvětší dosah chytání' },
|
||||||
|
scarecrow: { icon: '🐦⬛', title: 'Strašák', desc: 'Ptáci přibývají pomaleji' },
|
||||||
|
reinforced: { icon: '🧵', title: 'Zpevněná síťka', desc: 'Rychlejší samo-zašití' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function upgradeCost(item: UpgradeId, level: number): number {
|
||||||
|
return UPGRADE_BASE[item] * Math.pow(2, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Obchod: spotřební pomůcky a trvalá vylepšení za mince. */
|
||||||
|
export default function ButterflyShopModal({
|
||||||
|
isOpen, onClose, stats, onBuyPremium, onWaspSpray, onRepellent, onBuyInsurance, onBuyUpgrade,
|
||||||
|
}: Readonly<Props>) {
|
||||||
|
const coins = stats?.coins ?? 0;
|
||||||
|
const insuranceActive = !!stats && stats.insuranceUntil > Date.now();
|
||||||
|
|
||||||
|
const consumable = (label: string, cost: number, onBuy: () => void, extra?: string) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="butterfly-shop-item"
|
||||||
|
onClick={onBuy}
|
||||||
|
disabled={coins < cost}
|
||||||
|
>
|
||||||
|
<span className="shop-item-label">{label}{extra ? ` ${extra}` : ''}</span>
|
||||||
|
<span className="shop-item-cost">{cost} 🪙</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={onClose} centered>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>🛒 Obchod <span className="shop-balance">{coins} 🪙</span></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{stats?.daily && (
|
||||||
|
<div className="butterfly-shop-daily">
|
||||||
|
<div className="daily-title">🎯 Denní úkol: {stats.daily.title}</div>
|
||||||
|
<div className="daily-bar">
|
||||||
|
<div
|
||||||
|
className="daily-bar-inner"
|
||||||
|
style={{ width: `${Math.min(100, Math.round((stats.daily.progress / stats.daily.target) * 100))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="daily-status">
|
||||||
|
{stats.daily.done
|
||||||
|
? `✅ Splněno (+${stats.daily.reward} 🪙)`
|
||||||
|
: `${stats.daily.progress}/${stats.daily.target} · odměna ${stats.daily.reward} 🪙`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h6 className="shop-heading">Pomůcky</h6>
|
||||||
|
<div className="butterfly-shop-list">
|
||||||
|
{consumable('🪄 Prémiová síťka (60 s)', PREMIUM_BUY_COST, onBuyPremium)}
|
||||||
|
{consumable('💨 Vosí sprej (vyhubí vosy)', WASP_SPRAY_COST, onWaspSpray)}
|
||||||
|
{consumable('🦅🚫 Plašič ptáků', REPELLENT_COST, onRepellent)}
|
||||||
|
{consumable('🛡️ Pojistka proti zloději', INSURANCE_COST, onBuyInsurance, insuranceActive ? '(aktivní)' : '')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 className="shop-heading">Trvalá vylepšení</h6>
|
||||||
|
<div className="butterfly-shop-list">
|
||||||
|
{(Object.keys(UPGRADE_META) as UpgradeId[]).map(item => {
|
||||||
|
const level = stats?.upgrades?.[item] ?? 0;
|
||||||
|
const max = UPGRADE_MAX[item];
|
||||||
|
const maxed = level >= max;
|
||||||
|
const cost = upgradeCost(item, level);
|
||||||
|
const meta = UPGRADE_META[item];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
className="butterfly-shop-item upgrade"
|
||||||
|
onClick={() => onBuyUpgrade(item)}
|
||||||
|
disabled={maxed || coins < cost}
|
||||||
|
>
|
||||||
|
<span className="shop-item-label">
|
||||||
|
{meta.icon} {meta.title} <span className="shop-item-lvl">{level}/{max}</span>
|
||||||
|
<span className="shop-item-desc">{meta.desc}</span>
|
||||||
|
</span>
|
||||||
|
<span className="shop-item-cost">{maxed ? 'MAX' : `${cost} 🪙`}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Modal, Button, Alert } from "react-bootstrap";
|
import { Modal, Button, Form, Alert } from "react-bootstrap";
|
||||||
import { clearMockData, DayIndex } from "../../../../types";
|
import { clearMockData, DayIndex } from "../../../../types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -10,20 +10,28 @@ type Props = {
|
|||||||
|
|
||||||
const DAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek'];
|
const DAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek'];
|
||||||
|
|
||||||
|
/** Hodnota v selectu dne pro smazání dat celého týdne. */
|
||||||
|
const WHOLE_WEEK = 'week';
|
||||||
|
|
||||||
/** Modální dialog pro smazání mock dat (pouze DEV). */
|
/** Modální dialog pro smazání mock dat (pouze DEV). */
|
||||||
export default function ClearMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly<Props>) {
|
export default function ClearMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly<Props>) {
|
||||||
|
const [daySelection, setDaySelection] = useState<string>(currentDayIndex !== undefined ? String(currentDayIndex) : '');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const wholeWeek = daySelection === WHOLE_WEEK;
|
||||||
|
|
||||||
const handleClear = async () => {
|
const handleClear = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body: any = {};
|
const body: any = {};
|
||||||
if (currentDayIndex !== undefined) {
|
if (wholeWeek) {
|
||||||
body.dayIndex = currentDayIndex as DayIndex;
|
body.wholeWeek = true;
|
||||||
|
} else if (daySelection !== '') {
|
||||||
|
body.dayIndex = parseInt(daySelection, 10) as DayIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await clearMockData({ body });
|
const response = await clearMockData({ body });
|
||||||
@@ -49,12 +57,20 @@ export default function ClearMockDataModal({ isOpen, onClose, currentDayIndex }:
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const dayName = currentDayIndex !== undefined ? DAY_NAMES[currentDayIndex] : 'aktuální den';
|
// Popis rozsahu mazání pro potvrzovací otázku
|
||||||
|
let scopeLabel: string;
|
||||||
|
if (wholeWeek) {
|
||||||
|
scopeLabel = 'celý týden (pondělí až pátek)';
|
||||||
|
} else if (daySelection !== '') {
|
||||||
|
scopeLabel = DAY_NAMES[parseInt(daySelection, 10)];
|
||||||
|
} else {
|
||||||
|
scopeLabel = 'aktuální den';
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal show={isOpen} onHide={handleClose}>
|
<Modal show={isOpen} onHide={handleClose}>
|
||||||
<Modal.Header closeButton>
|
<Modal.Header closeButton>
|
||||||
<Modal.Title><h2>Smazat data</h2></Modal.Title>
|
<Modal.Title><h2>Smazat mock data</h2></Modal.Title>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
{success ? (
|
{success ? (
|
||||||
@@ -73,8 +89,22 @@ export default function ClearMockDataModal({ isOpen, onClose, currentDayIndex }:
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Den</Form.Label>
|
||||||
|
<Form.Select
|
||||||
|
value={daySelection}
|
||||||
|
onChange={e => setDaySelection(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Aktuální den</option>
|
||||||
|
{DAY_NAMES.map((name, index) => (
|
||||||
|
<option key={index} value={index}>{name}</option>
|
||||||
|
))}
|
||||||
|
<option value={WHOLE_WEEK}>Celý týden</option>
|
||||||
|
</Form.Select>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Opravdu chcete smazat všechny volby stravování pro <strong>{dayName}</strong>?
|
Opravdu chcete smazat všechny volby stravování pro <strong>{scopeLabel}</strong>?
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted">
|
<p className="text-muted">
|
||||||
Tato akce je nevratná.
|
Tato akce je nevratná.
|
||||||
|
|||||||
@@ -10,13 +10,18 @@ type Props = {
|
|||||||
|
|
||||||
const DAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek'];
|
const DAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek'];
|
||||||
|
|
||||||
|
/** Hodnota v selectu dne pro generování napříč celým týdnem. */
|
||||||
|
const WHOLE_WEEK = 'week';
|
||||||
|
|
||||||
/** Modální dialog pro generování mock dat (pouze DEV). */
|
/** Modální dialog pro generování mock dat (pouze DEV). */
|
||||||
export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly<Props>) {
|
export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly<Props>) {
|
||||||
const [dayIndex, setDayIndex] = useState<number | undefined>(currentDayIndex);
|
const [daySelection, setDaySelection] = useState<string>(currentDayIndex !== undefined ? String(currentDayIndex) : '');
|
||||||
const [count, setCount] = useState<string>('');
|
const [count, setCount] = useState<string>('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const wholeWeek = daySelection === WHOLE_WEEK;
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -24,8 +29,10 @@ export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body: any = {};
|
const body: any = {};
|
||||||
if (dayIndex !== undefined) {
|
if (wholeWeek) {
|
||||||
body.dayIndex = dayIndex as DayIndex;
|
body.wholeWeek = true;
|
||||||
|
} else if (daySelection !== '') {
|
||||||
|
body.dayIndex = parseInt(daySelection, 10) as DayIndex;
|
||||||
}
|
}
|
||||||
if (count && count.trim() !== '') {
|
if (count && count.trim() !== '') {
|
||||||
const countNum = parseInt(count, 10);
|
const countNum = parseInt(count, 10);
|
||||||
@@ -41,10 +48,13 @@ export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex
|
|||||||
if (response.error) {
|
if (response.error) {
|
||||||
setError((response.error as any).error || 'Nastala chyba při generování dat');
|
setError((response.error as any).error || 'Nastala chyba při generování dat');
|
||||||
} else {
|
} else {
|
||||||
setSuccess(true);
|
const total = (response.data as any)?.count;
|
||||||
|
setSuccess(wholeWeek
|
||||||
|
? `Mock data byla vygenerována pro celý týden (celkem ${total} záznamů).`
|
||||||
|
: 'Mock data byla úspěšně vygenerována!');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onClose();
|
onClose();
|
||||||
setSuccess(false);
|
setSuccess(null);
|
||||||
setCount('');
|
setCount('');
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
@@ -57,7 +67,7 @@ export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex
|
|||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(false);
|
setSuccess(null);
|
||||||
setCount('');
|
setCount('');
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -70,7 +80,7 @@ export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex
|
|||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
{success ? (
|
{success ? (
|
||||||
<Alert variant="success">
|
<Alert variant="success">
|
||||||
Mock data byla úspěšně vygenerována!
|
{success}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -87,13 +97,14 @@ export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex
|
|||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>Den</Form.Label>
|
<Form.Label>Den</Form.Label>
|
||||||
<Form.Select
|
<Form.Select
|
||||||
value={dayIndex ?? ''}
|
value={daySelection}
|
||||||
onChange={e => setDayIndex(e.target.value === '' ? undefined : parseInt(e.target.value, 10))}
|
onChange={e => setDaySelection(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">Aktuální den</option>
|
<option value="">Aktuální den</option>
|
||||||
{DAY_NAMES.map((name, index) => (
|
{DAY_NAMES.map((name, index) => (
|
||||||
<option key={index} value={index}>{name}</option>
|
<option key={index} value={index}>{name}</option>
|
||||||
))}
|
))}
|
||||||
|
<option value={WHOLE_WEEK}>Celý týden</option>
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
<Form.Text className="text-muted">
|
<Form.Text className="text-muted">
|
||||||
Pokud není vybráno, použije se aktuální den.
|
Pokud není vybráno, použije se aktuální den.
|
||||||
@@ -112,7 +123,9 @@ export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex
|
|||||||
onKeyDown={e => e.stopPropagation()}
|
onKeyDown={e => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
<Form.Text className="text-muted">
|
<Form.Text className="text-muted">
|
||||||
Pokud není zadáno, vybere se náhodný počet 5-20.
|
{wholeWeek
|
||||||
|
? 'Zadaný počet se vygeneruje pro každý den týdne. Pokud není zadáno, vybere se pro každý den náhodný počet 5-20.'
|
||||||
|
: 'Pokud není zadáno, vybere se náhodný počet 5-20.'}
|
||||||
</Form.Text>
|
</Form.Text>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Modal, Button, Form, Alert } from "react-bootstrap";
|
||||||
|
import { generateMockOrders } from "../../../../types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Příznak, zda je pro dnešní den založena alespoň jedna skupina objednávky. */
|
||||||
|
hasGroups: boolean;
|
||||||
|
/** Příznak, zda je nadefinován alespoň jeden obchod (bez něj nelze generovat). */
|
||||||
|
hasStores: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_COUNT = '3';
|
||||||
|
const DEFAULT_MIN_AMOUNT = '100';
|
||||||
|
const DEFAULT_MAX_AMOUNT = '1000';
|
||||||
|
|
||||||
|
/** Vybere český tvar slova podle počtu (1 osoba / 2 osoby / 5 osob). */
|
||||||
|
function plural(count: number, one: string, few: string, many: string): string {
|
||||||
|
if (count === 1) return one;
|
||||||
|
if (count >= 2 && count <= 4) return few;
|
||||||
|
return many;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Modální dialog pro generování mock dat sekce Objednání (pouze DEV). */
|
||||||
|
export default function GenerateMockOrdersModal({ isOpen, onClose, hasGroups, hasStores }: Readonly<Props>) {
|
||||||
|
const [count, setCount] = useState<string>(DEFAULT_COUNT);
|
||||||
|
const [minAmount, setMinAmount] = useState<string>(DEFAULT_MIN_AMOUNT);
|
||||||
|
const [maxAmount, setMaxAmount] = useState<string>(DEFAULT_MAX_AMOUNT);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const countNum = parseInt(count, 10);
|
||||||
|
if (isNaN(countNum) || countNum < 1 || countNum > 50) {
|
||||||
|
setError('Počet osob musí být číslo mezi 1 a 50');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const minNum = parseInt(minAmount, 10);
|
||||||
|
const maxNum = parseInt(maxAmount, 10);
|
||||||
|
if (isNaN(minNum) || isNaN(maxNum) || minNum < 0 || maxNum < minNum) {
|
||||||
|
setError('Zadejte platný rozsah částek (od ≤ do)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await generateMockOrders({
|
||||||
|
body: { count: countNum, minAmount: minNum, maxAmount: maxNum },
|
||||||
|
});
|
||||||
|
if (response.error) {
|
||||||
|
setError((response.error as any).error || 'Nastala chyba při generování dat');
|
||||||
|
} else {
|
||||||
|
const groups = response.data?.groups ?? [];
|
||||||
|
const total = response.data?.count ?? 0;
|
||||||
|
const created = groups.find(g => g.created);
|
||||||
|
setSuccess(
|
||||||
|
`Doplněno: ${total} ${plural(total, 'osoba', 'osoby', 'osob')}`
|
||||||
|
+ ` / ${groups.length} ${plural(groups.length, 'skupina', 'skupiny', 'skupin')}`
|
||||||
|
+ (created ? ` (skupina „${created.name}“ byla nově založena).` : '.')
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
setSuccess(null);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Nastala chyba při generování dat');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={handleClose}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>Generovat mock objednávky</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{success ? (
|
||||||
|
<Alert variant="success">{success}</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Alert variant="warning">
|
||||||
|
<strong>DEV režim</strong> - Tato funkce je dostupná pouze ve vývojovém prostředí.
|
||||||
|
Data se generují vždy jen pro aktuální den.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasStores ? (
|
||||||
|
<>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Počet osob</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="number"
|
||||||
|
value={count}
|
||||||
|
onChange={e => setCount(e.target.value)}
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
{hasGroups
|
||||||
|
? 'Zadaný počet osob se doplní do každé existující skupiny.'
|
||||||
|
: 'Není založena žádná skupina — jedna se založí z náhodně vybraného obchodu a doplní se do ní zadaný počet osob.'}
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Rozsah částek (Kč)</Form.Label>
|
||||||
|
<div className="d-flex gap-2 align-items-center">
|
||||||
|
<Form.Control
|
||||||
|
type="number"
|
||||||
|
value={minAmount}
|
||||||
|
onChange={e => setMinAmount(e.target.value)}
|
||||||
|
min={0}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<span>—</span>
|
||||||
|
<Form.Control
|
||||||
|
type="number"
|
||||||
|
value={maxAmount}
|
||||||
|
onChange={e => setMaxAmount(e.target.value)}
|
||||||
|
min={0}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Každé osobě se nastaví náhodná částka z tohoto rozmezí a do poznámky náhodné jídlo.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted mb-0">
|
||||||
|
Není nadefinován žádný obchod — mock objednávky nelze vygenerovat.
|
||||||
|
Nejprve přidejte obchod ve správě obchodů.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
{!success && (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={handleClose} disabled={loading}>
|
||||||
|
Storno
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={handleGenerate} disabled={loading || !hasStores}>
|
||||||
|
{loading ? 'Generuji...' : 'Generovat'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
|
Zavřít
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,9 +49,10 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
const entries: DinerEntry[] = Object.entries(locationChoices).map(([login, choice]) => {
|
const entries: DinerEntry[] = Object.entries(locationChoices).map(([login, choice]) => {
|
||||||
const selectedFoods = choice.selectedFoods ?? [];
|
const selectedFoods = choice.selectedFoods ?? [];
|
||||||
let baseAmount = 0;
|
// Cena, kterou si strávník zadal sám, má přednost před cenami z menu
|
||||||
|
let baseAmount = choice.price ?? 0;
|
||||||
let baseAmountParseFailed = false;
|
let baseAmountParseFailed = false;
|
||||||
if (menu) {
|
if (menu && choice.price == null) {
|
||||||
for (const idx of selectedFoods) {
|
for (const idx of selectedFoods) {
|
||||||
const priceKc = parsePriceCzk(menu.food?.[idx]?.price);
|
const priceKc = parsePriceCzk(menu.food?.[idx]?.price);
|
||||||
if (priceKc === null) {
|
if (priceKc === null) {
|
||||||
@@ -168,7 +169,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
<>
|
<>
|
||||||
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a společné poplatky, poté vygenerujte QR kódy pro ostatní.</p>
|
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a společné poplatky, poté vygenerujte QR kódy pro ostatní.</p>
|
||||||
|
|
||||||
{!hasMenu && (
|
{!hasMenu && !diners.some(d => d.baseAmount > 0) && (
|
||||||
<Alert variant="info">
|
<Alert variant="info">
|
||||||
Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně.
|
Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně.
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -219,7 +220,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
<td>
|
<td>
|
||||||
<small>
|
<small>
|
||||||
{foodNames || <span className="text-muted">—</span>}
|
{foodNames || <span className="text-muted">—</span>}
|
||||||
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount / 100} Kč)</span>}
|
{d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount / 100} Kč)</span>}
|
||||||
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
|
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
|
||||||
</small>
|
</small>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
||||||
import { generateQr, OrderGroup, OrderGroupMember, QrRecipient } from "../../../../types";
|
import { generateQr, GroupState, OrderGroup, OrderGroupMember, QrRecipient } from "../../../../types";
|
||||||
import { sanitizeQrMessage } from "../../Utils";
|
import { sanitizeQrMessage } from "../../Utils";
|
||||||
import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees";
|
import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees";
|
||||||
|
|
||||||
@@ -102,6 +102,8 @@ export default function PayForGroupModal({ isOpen, onClose, onSuccess, group, pa
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasFees = totalFees > 0;
|
const hasFees = totalFees > 0;
|
||||||
|
// Generování QR je poslední krok objednávky — server skupinu zároveň překlopí do stavu Doručeno
|
||||||
|
const willBeMarkedDelivered = group.state !== GroupState.DELIVERED;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal show={isOpen} onHide={onClose} size="lg">
|
<Modal show={isOpen} onHide={onClose} size="lg">
|
||||||
@@ -115,6 +117,12 @@ export default function PayForGroupModal({ isOpen, onClose, onSuccess, group, pa
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{willBeMarkedDelivered && (
|
||||||
|
<Alert variant="info">
|
||||||
|
Objednávka není ve stavu <strong>Doručeno</strong> — vygenerováním QR kódů se do tohoto stavu automaticky přepne.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<p>Zaplatili jste za skupinu. Vyberte, komu vygenerovat QR kód k úhradě.</p>
|
<p>Zaplatili jste za skupinu. Vyberte, komu vygenerovat QR kód k úhradě.</p>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Modal, Button, Form } from "react-bootstrap";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean,
|
||||||
|
onClose: () => void,
|
||||||
|
/** Uložená cena v haléřích */
|
||||||
|
price?: number,
|
||||||
|
/** Cena v haléřích spočtená z vybraných jídel v menu, nabídne se, pokud cena ještě není uložená */
|
||||||
|
suggestedPrice?: number,
|
||||||
|
/** Cena v haléřích, undefined cenu odstraní */
|
||||||
|
onSave: (price?: number) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCrownsText = (amount?: number) => amount ? String(amount / 100).replace('.', ',') : '';
|
||||||
|
|
||||||
|
/** Modální dialog pro zadání ceny oběda, která se promítne do přehledu stravování. */
|
||||||
|
const PriceModal = ({ isOpen, onClose, price, suggestedPrice, onSave }: Readonly<Props>) => {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setValue(toCrownsText(price ?? suggestedPrice));
|
||||||
|
}
|
||||||
|
}, [isOpen, price, suggestedPrice]);
|
||||||
|
|
||||||
|
const parsed = value.trim().length ? Number.parseFloat(value.replace(',', '.')) : undefined;
|
||||||
|
const invalid = parsed != null && (Number.isNaN(parsed) || parsed < 0);
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (invalid) return;
|
||||||
|
onSave(parsed == null ? undefined : Math.round(parsed * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Modal show={isOpen} onHide={onClose}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>Cena oběda</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<p className="text-muted small">Cena se promítne do vašeho měsíčního přehledu stravování (export).</p>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<Form.Control
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="např. 145"
|
||||||
|
value={value}
|
||||||
|
isInvalid={invalid}
|
||||||
|
onChange={e => setValue(e.target.value.replace(/[^0-9.,]/g, ''))}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>Kč</span>
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
{price != null && <Button variant="outline-danger" className="me-auto" onClick={() => onSave(undefined)}>
|
||||||
|
Odstranit cenu
|
||||||
|
</Button>}
|
||||||
|
<Button variant="secondary" onClick={() => onClose()}>
|
||||||
|
Storno
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => save()} disabled={invalid}>
|
||||||
|
Uložit
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PriceModal;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Modal, Button, Form } from "react-bootstrap"
|
import { Modal, Button, Form, Row, Col } from "react-bootstrap"
|
||||||
import { useSettings, ThemePreference } from "../../context/settings";
|
import { useSettings, ThemePreference, StyleTheme, STYLE_THEMES } from "../../context/settings";
|
||||||
import { NotificationSettings, UdalostEnum, getNotificationSettings, updateNotificationSettings } from "../../../../types";
|
import { NotificationSettings, UdalostEnum, getNotificationSettings, updateNotificationSettings } from "../../../../types";
|
||||||
import { useAuth } from "../../context/auth";
|
import { useAuth } from "../../context/auth";
|
||||||
import { subscribeToPush, unsubscribeFromPush } from "../../hooks/usePushReminder";
|
import { subscribeToPush, unsubscribeFromPush } from "../../hooks/usePushReminder";
|
||||||
@@ -19,8 +19,12 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
|||||||
const nameRef = useRef<HTMLInputElement>(null);
|
const nameRef = useRef<HTMLInputElement>(null);
|
||||||
const hideSoupsRef = useRef<HTMLInputElement>(null);
|
const hideSoupsRef = useRef<HTMLInputElement>(null);
|
||||||
const themeRef = useRef<HTMLSelectElement>(null);
|
const themeRef = useRef<HTMLSelectElement>(null);
|
||||||
|
const styleThemeRef = useRef<HTMLSelectElement>(null);
|
||||||
|
const showButterfliesRef = useRef<HTMLInputElement>(null);
|
||||||
|
const autoOrderChoiceRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const reminderTimeRef = useRef<HTMLInputElement>(null);
|
const reminderTimeRef = useRef<HTMLInputElement>(null);
|
||||||
|
const boltDeliveredRef = useRef<HTMLInputElement>(null);
|
||||||
const ntfyTopicRef = useRef<HTMLInputElement>(null);
|
const ntfyTopicRef = useRef<HTMLInputElement>(null);
|
||||||
const discordWebhookRef = useRef<HTMLInputElement>(null);
|
const discordWebhookRef = useRef<HTMLInputElement>(null);
|
||||||
const teamsWebhookRef = useRef<HTMLInputElement>(null);
|
const teamsWebhookRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -47,26 +51,42 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const newReminderTime = reminderTimeRef.current?.value || undefined;
|
const newReminderTime = reminderTimeRef.current?.value || undefined;
|
||||||
const oldReminderTime = notifSettings.reminderTime;
|
const oldReminderTime = notifSettings.reminderTime;
|
||||||
|
const newBoltDelivered = boltDeliveredRef.current?.checked ?? false;
|
||||||
|
const oldBoltDelivered = notifSettings.boltDeliveredPush ?? false;
|
||||||
|
|
||||||
// Uložení notifikačních nastavení na server
|
// Uložení notifikačních nastavení na server
|
||||||
await updateNotificationSettings({
|
const newSettings: NotificationSettings = {
|
||||||
body: {
|
|
||||||
ntfyTopic: ntfyTopicRef.current?.value || undefined,
|
ntfyTopic: ntfyTopicRef.current?.value || undefined,
|
||||||
discordWebhookUrl: discordWebhookRef.current?.value || undefined,
|
discordWebhookUrl: discordWebhookRef.current?.value || undefined,
|
||||||
teamsWebhookUrl: teamsWebhookRef.current?.value || undefined,
|
teamsWebhookUrl: teamsWebhookRef.current?.value || undefined,
|
||||||
enabledEvents,
|
enabledEvents,
|
||||||
reminderTime: newReminderTime,
|
reminderTime: newReminderTime,
|
||||||
}
|
boltDeliveredPush: newBoltDelivered,
|
||||||
}).catch(() => {});
|
};
|
||||||
|
await updateNotificationSettings({ body: newSettings }).catch(() => {});
|
||||||
|
setNotifSettings(newSettings);
|
||||||
|
|
||||||
// Správa push subscription pro připomínky
|
// Správa push subscription — drží ji naživu kterákoli z push funkcí.
|
||||||
if (newReminderTime && newReminderTime !== oldReminderTime) {
|
// Záměrně bez await: subscribeToPush si vyžádá oprávnění prohlížeče a modal
|
||||||
|
// by na tu dobu zůstal otevřený.
|
||||||
|
const wantsPush = !!newReminderTime || newBoltDelivered;
|
||||||
|
const hadPush = !!oldReminderTime || oldBoltDelivered;
|
||||||
|
if (wantsPush && (newReminderTime !== oldReminderTime || newBoltDelivered !== oldBoltDelivered)) {
|
||||||
subscribeToPush(newReminderTime);
|
subscribeToPush(newReminderTime);
|
||||||
} else if (!newReminderTime && oldReminderTime) {
|
} else if (!wantsPush && hadPush) {
|
||||||
unsubscribeFromPush();
|
unsubscribeFromPush();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uložení ostatních nastavení (localStorage)
|
// Uložení ostatních nastavení (localStorage)
|
||||||
|
if (styleThemeRef.current?.value) {
|
||||||
|
settings?.setStyleTheme(styleThemeRef.current.value as StyleTheme);
|
||||||
|
}
|
||||||
|
if (showButterfliesRef.current) {
|
||||||
|
settings?.setShowButterflies(showButterfliesRef.current.checked);
|
||||||
|
}
|
||||||
|
if (autoOrderChoiceRef.current) {
|
||||||
|
settings?.setAutoOrderChoice(autoOrderChoiceRef.current.checked);
|
||||||
|
}
|
||||||
onSave(
|
onSave(
|
||||||
bankAccountRef.current?.value,
|
bankAccountRef.current?.value,
|
||||||
nameRef.current?.value,
|
nameRef.current?.value,
|
||||||
@@ -76,12 +96,21 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal show={isOpen} onHide={onClose} size="lg">
|
<Modal show={isOpen} onHide={onClose} size="xl">
|
||||||
<Modal.Header closeButton>
|
<Modal.Header closeButton>
|
||||||
<Modal.Title><h2>Nastavení</h2></Modal.Title>
|
<Modal.Title><h2>Nastavení</h2></Modal.Title>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
|
{/* Na širších obrazovkách rozdělení do dvou sloupců, na užších pod sebou */}
|
||||||
|
<Row className="gx-5">
|
||||||
|
<Col lg={6}>
|
||||||
<h4>Vzhled</h4>
|
<h4>Vzhled</h4>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Styl</Form.Label>
|
||||||
|
<Form.Select ref={styleThemeRef} defaultValue={settings?.styleTheme}>
|
||||||
|
{STYLE_THEMES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||||
|
</Form.Select>
|
||||||
|
</Form.Group>
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>Barevný motiv</Form.Label>
|
<Form.Label>Barevný motiv</Form.Label>
|
||||||
<Form.Select ref={themeRef} defaultValue={settings?.themePreference}>
|
<Form.Select ref={themeRef} defaultValue={settings?.themePreference}>
|
||||||
@@ -90,6 +119,18 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
|||||||
<option value="dark">Tmavý</option>
|
<option value="dark">Tmavý</option>
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Check
|
||||||
|
id="showButterfliesCheckbox"
|
||||||
|
ref={showButterfliesRef}
|
||||||
|
type="checkbox"
|
||||||
|
label="Zobrazit motýlky"
|
||||||
|
defaultChecked={settings?.showButterflies ?? true}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Po vypnutí se poletující motýlci ani minihra nebudou vůbec zobrazovat.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
@@ -107,9 +148,57 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
|||||||
Experimentální funkce - zejména u TechTower bývá problém polévky spolehlivě rozeznat.
|
Experimentální funkce - zejména u TechTower bývá problém polévky spolehlivě rozeznat.
|
||||||
</Form.Text>
|
</Form.Text>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Check
|
||||||
|
id="autoOrderChoiceCheckbox"
|
||||||
|
ref={autoOrderChoiceRef}
|
||||||
|
type="checkbox"
|
||||||
|
label='Při objednávání automaticky zvolit "Budu objednávat"'
|
||||||
|
defaultChecked={settings?.autoOrderChoice ?? false}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Po založení skupinové objednávky nebo připojení k existující se vaše dnešní volba stravování přepne na "Budu objednávat". Volba Pizza day zůstane beze změny.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
|
<h4>Bankovní účet</h4>
|
||||||
|
<p>
|
||||||
|
Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Číslo účtu</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
ref={bankAccountRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="123456-1234567890/1234"
|
||||||
|
defaultValue={settings?.bankAccount}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Název příjemce</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
ref={nameRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Jan Novák"
|
||||||
|
defaultValue={settings?.holderName}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Jméno majitele účtu pro QR platbu.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
</Col>
|
||||||
|
<Col lg={6}>
|
||||||
|
<hr className="d-lg-none" />
|
||||||
|
|
||||||
<h4>Notifikace</h4>
|
<h4>Notifikace</h4>
|
||||||
<p>
|
<p>
|
||||||
Nastavením notifikací budete dostávat upozornění o událostech (např. "Jdeme na oběd") přímo do vámi zvoleného komunikačního kanálu.
|
Nastavením notifikací budete dostávat upozornění o událostech (např. "Jdeme na oběd") přímo do vámi zvoleného komunikačního kanálu.
|
||||||
@@ -128,6 +217,21 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
|||||||
</Form.Text>
|
</Form.Text>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Check
|
||||||
|
id="boltDeliveredCheckbox"
|
||||||
|
ref={boltDeliveredRef}
|
||||||
|
type="checkbox"
|
||||||
|
label="Upozornění na doručení objednávky (Bolt Food / Wolt)"
|
||||||
|
defaultChecked={notifSettings.boltDeliveredPush ?? false}
|
||||||
|
key={`bolt-delivered-${notifSettings.boltDeliveredPush ?? false}`}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Až bude sledovaná skupinová objednávka doručena, přijde vám push notifikace.
|
||||||
|
Zakladateli skupiny se neposílá — ten dostane upozornění přímo z aplikace rozvozové služby.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>ntfy téma (topic)</Form.Label>
|
<Form.Label>ntfy téma (topic)</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
@@ -189,41 +293,8 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
|||||||
Zvolte události, o kterých chcete být notifikováni. Notifikace jsou odesílány pouze uživatelům se stejnou zvolenou lokalitou.
|
Zvolte události, o kterých chcete být notifikováni. Notifikace jsou odesílány pouze uživatelům se stejnou zvolenou lokalitou.
|
||||||
</Form.Text>
|
</Form.Text>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
</Col>
|
||||||
<hr />
|
</Row>
|
||||||
|
|
||||||
<h4>Bankovní účet</h4>
|
|
||||||
<p>
|
|
||||||
Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Form.Group className="mb-3">
|
|
||||||
<Form.Label>Číslo účtu</Form.Label>
|
|
||||||
<Form.Control
|
|
||||||
ref={bankAccountRef}
|
|
||||||
type="text"
|
|
||||||
placeholder="123456-1234567890/1234"
|
|
||||||
defaultValue={settings?.bankAccount}
|
|
||||||
onKeyDown={e => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
<Form.Text className="text-muted">
|
|
||||||
Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<Form.Group className="mb-3">
|
|
||||||
<Form.Label>Název příjemce</Form.Label>
|
|
||||||
<Form.Control
|
|
||||||
ref={nameRef}
|
|
||||||
type="text"
|
|
||||||
placeholder="Jan Novák"
|
|
||||||
defaultValue={settings?.holderName}
|
|
||||||
onKeyDown={e => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
<Form.Text className="text-muted">
|
|
||||||
Jméno majitele účtu pro QR platbu.
|
|
||||||
</Form.Text>
|
|
||||||
</Form.Group>
|
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Button variant="secondary" onClick={onClose}>
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
|||||||
@@ -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,13 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Modal, Button, Form, ListGroup, Alert } from "react-bootstrap";
|
import { Modal, Button, Form, ListGroup, Alert, OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
|
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
|
||||||
import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
import { faArrowsRotate, faPen, faPlus, faUpRightFromSquare, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { addStore, deleteStore, Store } from "../../../../types";
|
import { addStore, deleteStore, updateStore, getStoreMenuStatus, refreshStoreMenu, Store, StoreMenuProvider, StoreScrapeInfo, StoreScrapeStatus } from "../../../../types";
|
||||||
|
import { getHumanDateTime } from "../../Utils";
|
||||||
|
import PriceHistory from "../PriceHistory";
|
||||||
|
import { useAppConfig } from "../../hooks/useGroupLimits";
|
||||||
|
import { getStoreUrls, storeUrlLabel } from "../../utils/storeUrls";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -12,25 +16,137 @@ type Props = {
|
|||||||
onStoresChanged: (stores: Store[]) => void;
|
onStoresChanged: (stores: Store[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Rozpracovaná editace obchodu — původní název slouží k jeho identifikaci na serveru. */
|
||||||
|
type EditState = {
|
||||||
|
originalName: string;
|
||||||
|
name: string;
|
||||||
|
urls: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER_LABELS: Record<StoreMenuProvider, string> = { bolt: 'Bolt Food', wolt: 'Wolt' };
|
||||||
|
|
||||||
|
/** Krátký popis stavu stahování nabídky od jedné služby. */
|
||||||
|
function describeScrape(p: StoreScrapeStatus): { text: string, className: string, title?: string } {
|
||||||
|
const when = (iso?: string) => iso ? getHumanDateTime(new Date(iso)) : '';
|
||||||
|
switch (p.state) {
|
||||||
|
case 'ok':
|
||||||
|
return { text: `${p.dishCount} hlavních jídel (${when(p.lastSuccessAt)})${p.lastError ? ` · poslední obnovení selhalo: ${p.lastError}` : ''}`, className: 'text-success' };
|
||||||
|
case 'failed':
|
||||||
|
return { text: `selhalo ${p.failures}× (${when(p.lastAttemptAt)}): ${p.lastError ?? ''} · zkusí se znovu`, className: 'text-warning' };
|
||||||
|
case 'blocked':
|
||||||
|
return { text: `zablokováno po ${p.failures} neúspěších (${when(p.lastAttemptAt)}): ${p.lastError ?? ''}`, className: 'text-danger' };
|
||||||
|
default:
|
||||||
|
return { text: 'nestaženo', className: 'text-muted', title: 'Stáhne se automaticky, až bude nabídka potřeba v kole štěstí (a žádná jiná služba ji nemá)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vstupní pole pro URL nabídek — vždy alespoň jedno prázdné, aby bylo kam psát. */
|
||||||
|
function toUrlInputs(urls: string[]): string[] {
|
||||||
|
return urls.length > 0 ? urls : [''];
|
||||||
|
}
|
||||||
|
|
||||||
export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly<Props>) {
|
export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly<Props>) {
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [newUrl, setNewUrl] = useState('');
|
// Jeden podnik může být dostupný přes více dovozových služeb — proto seznam URL
|
||||||
|
const [newUrls, setNewUrls] = useState<string[]>(['']);
|
||||||
|
const [edit, setEdit] = useState<EditState | null>(null);
|
||||||
const [heslo, setHeslo] = useState('');
|
const [heslo, setHeslo] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Server bez admin hesla — správa (přidání/úprava/odebrání/obnovení) nejde, zbývá jen přehled.
|
||||||
|
// undefined = konfigurace nenačtena, chováme se jako dřív (správa zapnutá).
|
||||||
|
const adminEnabled = useAppConfig().storeAdmin !== false;
|
||||||
|
const [scrapeInfo, setScrapeInfo] = useState<Record<string, StoreScrapeInfo>>({});
|
||||||
|
const [refreshing, setRefreshing] = useState<string | null>(null);
|
||||||
|
// Rozbalená historie cen („obchod|služba")
|
||||||
|
const [historyOpen, setHistoryOpen] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Stav stahování nabídek (Bolt Food/Wolt) — při otevření a po změně seznamu obchodů
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
getStoreMenuStatus().then(res => {
|
||||||
|
setScrapeInfo(Object.fromEntries((res.data ?? []).map(i => [i.store, i])));
|
||||||
|
}).catch(() => { /* stav je jen informativní */ });
|
||||||
|
}, [isOpen, stores]);
|
||||||
|
|
||||||
|
const handleRefreshMenu = async (name: string, provider: StoreMenuProvider) => {
|
||||||
|
setError(null);
|
||||||
|
setRefreshing(`${name}|${provider}`);
|
||||||
|
try {
|
||||||
|
const res = await refreshStoreMenu({ body: { name, provider, heslo } });
|
||||||
|
if (res.data) {
|
||||||
|
setScrapeInfo(prev => ({ ...prev, [name]: res.data! }));
|
||||||
|
}
|
||||||
|
// Znovu načíst případně otevřenou historii (mohla přibýt nová verze)
|
||||||
|
if (historyOpen === `${name}|${provider}`) {
|
||||||
|
setHistoryOpen(null);
|
||||||
|
setTimeout(() => setHistoryOpen(`${name}|${provider}`));
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Nastala chyba');
|
||||||
|
} finally {
|
||||||
|
setRefreshing(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addUrlRow = (urls: string[]) => [...urls, ''];
|
||||||
|
|
||||||
|
const removeUrlRow = (urls: string[], index: number) =>
|
||||||
|
urls.length === 1 ? [''] : urls.filter((_, i) => i !== index);
|
||||||
|
|
||||||
|
const setUrlAt = (urls: string[], index: number, value: string) =>
|
||||||
|
urls.map((u, i) => (i === index ? value : u));
|
||||||
|
|
||||||
|
/** Zpracuje odpověď API — vrací true při úspěchu. Chybu z API hlásí globální toaster. */
|
||||||
|
const applyResult = (res: { data?: unknown; error?: unknown }): boolean => {
|
||||||
|
if (res.error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (res.data) {
|
||||||
|
onStoresChanged(res.data as Store[]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await addStore({ body: { name: newName.trim(), url: newUrl.trim() || undefined, heslo } });
|
const urls = newUrls.map(u => u.trim()).filter(Boolean);
|
||||||
if (res.error) {
|
const res = await addStore({ body: { name: newName.trim(), urls: urls.length > 0 ? urls : undefined, heslo } });
|
||||||
setError((res.error as any).error || 'Nastala chyba');
|
if (applyResult(res)) {
|
||||||
} else if (res.data) {
|
|
||||||
onStoresChanged(res.data as Store[]);
|
|
||||||
setNewName('');
|
setNewName('');
|
||||||
setNewUrl('');
|
setNewUrls(['']);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Nastala chyba');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (store: Store) => {
|
||||||
|
setError(null);
|
||||||
|
setEdit({ originalName: store.name, name: store.name, urls: toUrlInputs(store.urls ?? []) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async () => {
|
||||||
|
if (!edit || !edit.name.trim()) return;
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await updateStore({
|
||||||
|
body: {
|
||||||
|
name: edit.originalName,
|
||||||
|
newName: edit.name.trim(),
|
||||||
|
urls: edit.urls.map(u => u.trim()).filter(Boolean),
|
||||||
|
heslo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (applyResult(res)) {
|
||||||
|
setEdit(null);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message || 'Nastala chyba');
|
setError(e.message || 'Nastala chyba');
|
||||||
@@ -44,10 +160,8 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await deleteStore({ body: { name, heslo } });
|
const res = await deleteStore({ body: { name, heslo } });
|
||||||
if (res.error) {
|
if (applyResult(res) && edit?.originalName === name) {
|
||||||
setError((res.error as any).error || 'Nastala chyba');
|
setEdit(null);
|
||||||
} else if (res.data) {
|
|
||||||
onStoresChanged(res.data as Store[]);
|
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message || 'Nastala chyba');
|
setError(e.message || 'Nastala chyba');
|
||||||
@@ -56,8 +170,39 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Řádky se vstupy pro URL — používá se pro přidání i pro editaci obchodu. */
|
||||||
|
const renderUrlInputs = (urls: string[], onChange: (urls: string[]) => void, onSubmit: () => void) => (
|
||||||
|
<>
|
||||||
|
{urls.map((url, index) => (
|
||||||
|
<div key={index} className="d-flex gap-2 mb-2 align-items-center">
|
||||||
|
<Form.Control
|
||||||
|
type="url"
|
||||||
|
placeholder={index === 0
|
||||||
|
? 'URL na nabídku (volitelné, např. Bolt Food/Wolt)'
|
||||||
|
: 'Další URL na nabídku (jiná dovozová služba)'}
|
||||||
|
value={url}
|
||||||
|
onChange={e => onChange(setUrlAt(urls, index, e.target.value))}
|
||||||
|
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') onSubmit(); }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline-secondary"
|
||||||
|
onClick={() => onChange(removeUrlRow(urls, index))}
|
||||||
|
disabled={urls.length === 1 && !url}
|
||||||
|
title="Odebrat tuto URL"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faXmark} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="link" size="sm" className="p-0" onClick={() => onChange(addUrlRow(urls))}>
|
||||||
|
<FontAwesomeIcon icon={faPlus} className="me-1" />
|
||||||
|
Přidat další URL
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal show={isOpen} onHide={onClose}>
|
<Modal show={isOpen} onHide={onClose} size="lg">
|
||||||
<Modal.Header closeButton>
|
<Modal.Header closeButton>
|
||||||
<Modal.Title><h2>Správa obchodů</h2></Modal.Title>
|
<Modal.Title><h2>Správa obchodů</h2></Modal.Title>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
@@ -68,6 +213,14 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!adminEnabled && (
|
||||||
|
<Alert variant="info">
|
||||||
|
Správa obchodů není na tomto serveru zapnutá (chybí <code>ADMIN_PASSWORD</code>).
|
||||||
|
Seznam obchodů, stav nabídek a historii cen lze jen prohlížet.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{adminEnabled && <>
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>Admin heslo</Form.Label>
|
<Form.Label>Admin heslo</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
@@ -77,6 +230,9 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
|||||||
onChange={e => setHeslo(e.target.value)}
|
onChange={e => setHeslo(e.target.value)}
|
||||||
onKeyDown={e => e.stopPropagation()}
|
onKeyDown={e => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Potřeba pro přidání, úpravu a odebrání obchodu i pro obnovení nabídky (<FontAwesomeIcon icon={faArrowsRotate} />).
|
||||||
|
</Form.Text>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
@@ -89,34 +245,125 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
|||||||
onChange={e => setNewName(e.target.value)}
|
onChange={e => setNewName(e.target.value)}
|
||||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
|
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
|
||||||
/>
|
/>
|
||||||
<div className="d-flex gap-2 mb-3">
|
{renderUrlInputs(newUrls, setNewUrls, handleAdd)}
|
||||||
<Form.Control
|
<div className="d-flex justify-content-end mb-3">
|
||||||
type="url"
|
|
||||||
placeholder="URL na nabídku (volitelné, např. Bolt Food/Wolt)"
|
|
||||||
value={newUrl}
|
|
||||||
onChange={e => setNewUrl(e.target.value)}
|
|
||||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
|
|
||||||
/>
|
|
||||||
<Button variant="primary" onClick={handleAdd} disabled={loading || !newName.trim() || !heslo}>
|
<Button variant="primary" onClick={handleAdd} disabled={loading || !newName.trim() || !heslo}>
|
||||||
Přidat
|
Přidat
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</>}
|
||||||
|
|
||||||
<h6>Aktuální seznam</h6>
|
<h6>Aktuální seznam</h6>
|
||||||
{stores.length === 0 ? (
|
{stores.length === 0 ? (
|
||||||
<p className="text-muted">Žádné obchody v seznamu</p>
|
<p className="text-muted">Žádné obchody v seznamu</p>
|
||||||
) : (
|
) : (
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
{stores.map(s => (
|
{stores.map(s => {
|
||||||
<ListGroup.Item key={s.name} className="d-flex justify-content-between align-items-center">
|
const urls = getStoreUrls(s);
|
||||||
<span>
|
const isEditing = edit?.originalName === s.name;
|
||||||
{s.name}
|
|
||||||
{s.url && /^https?:\/\//i.test(s.url) && (
|
if (isEditing) {
|
||||||
<a href={s.url} target="_blank" rel="noopener noreferrer" className="ms-2" title="Otevřít nabídku v nové záložce">
|
return (
|
||||||
<FontAwesomeIcon icon={faUpRightFromSquare} />
|
<ListGroup.Item key={s.name}>
|
||||||
|
<Form.Control
|
||||||
|
className="mb-2"
|
||||||
|
type="text"
|
||||||
|
placeholder="Název obchodu"
|
||||||
|
value={edit.name}
|
||||||
|
onChange={e => setEdit({ ...edit, name: e.target.value })}
|
||||||
|
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveEdit(); }}
|
||||||
|
/>
|
||||||
|
{renderUrlInputs(edit.urls, next => setEdit({ ...edit, urls: next }), handleSaveEdit)}
|
||||||
|
<div className="d-flex justify-content-end gap-2 mt-2">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setEdit(null)}>
|
||||||
|
Zrušit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveEdit}
|
||||||
|
disabled={loading || !edit.name.trim() || !heslo}
|
||||||
|
>
|
||||||
|
Uložit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ListGroup.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListGroup.Item key={s.name} className="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<div>{s.name}</div>
|
||||||
|
{urls.length > 0 && (
|
||||||
|
<small className="text-muted d-flex flex-wrap gap-2">
|
||||||
|
{urls.map(url => (
|
||||||
|
<a
|
||||||
|
key={url}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={url}
|
||||||
|
>
|
||||||
|
{storeUrlLabel(url)}
|
||||||
|
<FontAwesomeIcon icon={faUpRightFromSquare} className="ms-1" />
|
||||||
</a>
|
</a>
|
||||||
|
))}
|
||||||
|
</small>
|
||||||
)}
|
)}
|
||||||
|
{scrapeInfo[s.name]?.providers.map(p => {
|
||||||
|
const { text, className, title } = describeScrape(p);
|
||||||
|
const key = `${s.name}|${p.provider}`;
|
||||||
|
return (
|
||||||
|
<div key={p.provider}>
|
||||||
|
<small className="d-flex align-items-baseline gap-2 mt-1">
|
||||||
|
<span className="text-nowrap text-muted">Menu {PROVIDER_LABELS[p.provider]}:</span>
|
||||||
|
<span className={className} title={title}>{text}</span>
|
||||||
|
{/* Zakázané tlačítko nezobrazí tooltip — obalení do span ho zobrazí i bez hesla */}
|
||||||
|
{adminEnabled && <OverlayTrigger
|
||||||
|
placement="top"
|
||||||
|
overlay={
|
||||||
|
<Tooltip>
|
||||||
|
{heslo
|
||||||
|
? `Znovu stáhnout nabídku z ${PROVIDER_LABELS[p.provider]}`
|
||||||
|
: 'Pro obnovení nabídky zadejte nahoře admin heslo'}
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="d-inline-block" style={{ cursor: heslo ? undefined : 'not-allowed' }}>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
className="p-0"
|
||||||
|
disabled={!heslo || refreshing != null}
|
||||||
|
style={heslo ? undefined : { pointerEvents: 'none' }}
|
||||||
|
aria-label={`Znovu stáhnout nabídku z ${PROVIDER_LABELS[p.provider]}`}
|
||||||
|
onClick={() => handleRefreshMenu(s.name, p.provider)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faArrowsRotate} spin={refreshing === key} />
|
||||||
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
|
</OverlayTrigger>}
|
||||||
|
{p.dishCount > 0 && (
|
||||||
|
<Button variant="link" size="sm" className="p-0 text-nowrap"
|
||||||
|
onClick={() => setHistoryOpen(historyOpen === key ? null : key)}>
|
||||||
|
{historyOpen === key ? 'skrýt historii' : 'historie cen'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</small>
|
||||||
|
{historyOpen === key && <PriceHistory store={s.name} provider={p.provider} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{adminEnabled && <div className="d-flex gap-3">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faPen}
|
||||||
|
className="action-icon"
|
||||||
|
title="Upravit"
|
||||||
|
onClick={() => startEdit(s)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={faTrashCan}
|
icon={faTrashCan}
|
||||||
className="action-icon"
|
className="action-icon"
|
||||||
@@ -124,8 +371,10 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
|||||||
onClick={() => handleRemove(s.name)}
|
onClick={() => handleRemove(s.name)}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
/>
|
/>
|
||||||
|
</div>}
|
||||||
</ListGroup.Item>
|
</ListGroup.Item>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
)}
|
)}
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ButterflyStats,
|
||||||
|
getButterflyStats,
|
||||||
|
catchButterflies,
|
||||||
|
repairNet as repairNetApi,
|
||||||
|
killWasp as killWaspApi,
|
||||||
|
buyRepellent as buyRepellentApi,
|
||||||
|
reportNetTorn as reportTearApi,
|
||||||
|
reportRobbery as reportRobberyApi,
|
||||||
|
defeatThief as defeatThiefApi,
|
||||||
|
reportCaterpillarAte as caterpillarAteApi,
|
||||||
|
defeatCaterpillar as defeatCaterpillarApi,
|
||||||
|
reportMothHit as mothHitApi,
|
||||||
|
reportInspectionFail as inspectionFailApi,
|
||||||
|
buyPremiumNet as buyPremiumApi,
|
||||||
|
buyWaspSpray as waspSprayApi,
|
||||||
|
buyInsurance as buyInsuranceApi,
|
||||||
|
buyUpgrade as buyUpgradeApi,
|
||||||
|
} from '../../../types';
|
||||||
|
|
||||||
|
/** Druhy trvalých vylepšení v obchodě. */
|
||||||
|
export type UpgradeItem = 'net' | 'scarecrow' | 'reinforced';
|
||||||
|
|
||||||
|
/** Výsledek nákupu: úspěch / nedostatek mincí / jiná chyba (např. starý server). */
|
||||||
|
export type BuyResult = 'ok' | 'poor' | 'error';
|
||||||
|
|
||||||
|
/** Jak dlouho se čeká, než se nasbíraná dávka úlovků odešle na server. */
|
||||||
|
const FLUSH_DEBOUNCE_MS = 1500;
|
||||||
|
/** Jak často se přenačte stav ze serveru (kvůli přibývajícím škůdcům). */
|
||||||
|
const POLL_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
/** Událost odměny hlášená scéně po úspěšném odeslání dávky. */
|
||||||
|
export interface RewardEvent {
|
||||||
|
coinsAwarded: number;
|
||||||
|
dailyBonusApplied: boolean;
|
||||||
|
leveledUp: boolean;
|
||||||
|
premiumUnlocked: boolean;
|
||||||
|
newLevel: number;
|
||||||
|
newTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook spravující serverovou perzistenci chytání motýlků: načtení stavu, dávkové
|
||||||
|
* odesílání úlovků, hubení vos, plašič ptáků, protržení/opravu síťky, zloděje a
|
||||||
|
* periodické přenačítání (růst škůdců). Server je zdroj pravdy; počítadlo se
|
||||||
|
* zobrazuje jako serverová hodnota + zatím neodeslané úlovky.
|
||||||
|
*/
|
||||||
|
export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
|
||||||
|
const [stats, setStats] = useState<ButterflyStats | undefined>();
|
||||||
|
/** Optimisticky zobrazený přírůstek chycených, který ještě neodešel na server. */
|
||||||
|
const [pendingShown, setPendingShown] = useState(0);
|
||||||
|
/** Živá hodnota mincí pro scénu (aby nečetla zastaralý stav přes closure). */
|
||||||
|
const coinsRef = useRef(0);
|
||||||
|
const pending = useRef({ normal: 0, golden: 0 });
|
||||||
|
const flushTimer = useRef<number | null>(null);
|
||||||
|
const onRewardRef = useRef(onReward);
|
||||||
|
onRewardRef.current = onReward;
|
||||||
|
|
||||||
|
const applyStats = useCallback((incoming: ButterflyStats) => {
|
||||||
|
coinsRef.current = incoming.coins;
|
||||||
|
setStats(incoming);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const flush = useCallback(async () => {
|
||||||
|
if (flushTimer.current) {
|
||||||
|
clearTimeout(flushTimer.current);
|
||||||
|
flushTimer.current = null;
|
||||||
|
}
|
||||||
|
const batch = pending.current;
|
||||||
|
const batchTotal = batch.normal + batch.golden;
|
||||||
|
if (batchTotal === 0) return;
|
||||||
|
pending.current = { normal: 0, golden: 0 };
|
||||||
|
try {
|
||||||
|
const res = await catchButterflies({ body: { normal: batch.normal, golden: batch.golden } });
|
||||||
|
if (res.data) {
|
||||||
|
applyStats(res.data.stats);
|
||||||
|
// Odeslané úlovky už jsou v serverové hodnotě → sundáme je z optimistického přírůstku
|
||||||
|
setPendingShown(p => Math.max(0, p - batchTotal));
|
||||||
|
onRewardRef.current?.({
|
||||||
|
coinsAwarded: res.data.coinsAwarded,
|
||||||
|
dailyBonusApplied: res.data.dailyBonusApplied,
|
||||||
|
leveledUp: res.data.leveledUp,
|
||||||
|
premiumUnlocked: res.data.premiumUnlocked,
|
||||||
|
newLevel: res.data.stats.level,
|
||||||
|
newTitle: res.data.stats.title,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setPendingShown(p => Math.max(0, p - batchTotal));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Při chybě vrátíme dávku zpět (přírůstek necháme zobrazený)
|
||||||
|
pending.current.normal += batch.normal;
|
||||||
|
pending.current.golden += batch.golden;
|
||||||
|
}
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
/** Nahlásí chycení jednoho motýla (optimisticky + naplánuje odeslání dávky). */
|
||||||
|
const reportCatch = useCallback((golden: boolean) => {
|
||||||
|
if (golden) pending.current.golden += 1;
|
||||||
|
else pending.current.normal += 1;
|
||||||
|
setPendingShown(p => p + 1);
|
||||||
|
if (!flushTimer.current) {
|
||||||
|
flushTimer.current = window.setTimeout(() => { void flush(); }, FLUSH_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
}, [flush]);
|
||||||
|
|
||||||
|
const repair = useCallback(async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const res = await repairNetApi();
|
||||||
|
if (res.data) { applyStats(res.data); return true; }
|
||||||
|
return false;
|
||||||
|
} catch { return false; }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
const killWasp = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await killWaspApi();
|
||||||
|
if (res.data) applyStats(res.data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
const buyRepellent = useCallback(async (): Promise<BuyResult> => {
|
||||||
|
try {
|
||||||
|
const res = await buyRepellentApi();
|
||||||
|
if (res.data) { applyStats(res.data); return 'ok'; }
|
||||||
|
return res.response?.status === 402 ? 'poor' : 'error';
|
||||||
|
} catch { return 'error'; }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
const reportTear = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await reportTearApi();
|
||||||
|
if (res.data) applyStats(res.data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
/** Zloděj se dostal k penězům – server strhne podíl mincí. */
|
||||||
|
const reportRobbery = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await reportRobberyApi();
|
||||||
|
if (res.data) applyStats(res.data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
/** Hráč porazil zloděje – odměna a statistika. */
|
||||||
|
const defeatThief = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await defeatThiefApi();
|
||||||
|
if (res.data) applyStats(res.data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
/** Housenka se dostala k úlovkům – server sníží počet chycených. */
|
||||||
|
const caterpillarAte = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await caterpillarAteApi();
|
||||||
|
if (res.data) applyStats(res.data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
/** Hráč omylem chytil černou můru – server sníží úlovky. */
|
||||||
|
const mothHit = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await mothHitApi();
|
||||||
|
if (res.data) applyStats(res.data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
/** Selhání anti-bot inspekce – server počítá selhání a případně udělí ban. */
|
||||||
|
const reportInspectionFail = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await inspectionFailApi();
|
||||||
|
if (res.data) applyStats(res.data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
/** Vyhodnotí výsledek nákupu z odpovědi (402 = nedostatek mincí, jinak chyba). */
|
||||||
|
const buyResult = useCallback((res: { data?: unknown; response?: Response }): BuyResult => {
|
||||||
|
if (res.data) { applyStats(res.data as ButterflyStats); return 'ok'; }
|
||||||
|
return res.response?.status === 402 ? 'poor' : 'error';
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
/** Obchod: koupě spotřebního předmětu / vylepšení. */
|
||||||
|
const buyPremium = useCallback(async (): Promise<BuyResult> => {
|
||||||
|
try { return buyResult(await buyPremiumApi()); } catch { return 'error'; }
|
||||||
|
}, [buyResult]);
|
||||||
|
const waspSpray = useCallback(async (): Promise<BuyResult> => {
|
||||||
|
try { return buyResult(await waspSprayApi()); } catch { return 'error'; }
|
||||||
|
}, [buyResult]);
|
||||||
|
const buyInsurance = useCallback(async (): Promise<BuyResult> => {
|
||||||
|
try { return buyResult(await buyInsuranceApi()); } catch { return 'error'; }
|
||||||
|
}, [buyResult]);
|
||||||
|
const buyUpgrade = useCallback(async (item: UpgradeItem): Promise<BuyResult> => {
|
||||||
|
try { return buyResult(await buyUpgradeApi({ body: { item } })); } catch { return 'error'; }
|
||||||
|
}, [buyResult]);
|
||||||
|
|
||||||
|
/** Hráč porazil housenku – odměna a statistika. */
|
||||||
|
const defeatCaterpillar = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await defeatCaterpillarApi();
|
||||||
|
if (res.data) applyStats(res.data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getButterflyStats();
|
||||||
|
if (res.data) applyStats(res.data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
// Načtení stavu
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getButterflyStats();
|
||||||
|
if (!cancelled && res.data) applyStats(res.data);
|
||||||
|
} catch { /* server nedostupný – hra jede dál bez perzistence */ }
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [applyStats]);
|
||||||
|
|
||||||
|
// Periodické přenačítání (růst škůdců) + při návratu na záložku
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => { void refresh(); }, POLL_INTERVAL_MS);
|
||||||
|
const onVisible = () => { if (document.visibilityState === 'visible') void refresh(); };
|
||||||
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(id);
|
||||||
|
document.removeEventListener('visibilitychange', onVisible);
|
||||||
|
};
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
// Odeslání rozdělané dávky při skrytí/opuštění stránky a při odmontování
|
||||||
|
useEffect(() => {
|
||||||
|
const onVisibility = () => { if (document.visibilityState === 'hidden') void flush(); };
|
||||||
|
const onPageHide = () => { void flush(); };
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
window.addEventListener('pagehide', onPageHide);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
window.removeEventListener('pagehide', onPageHide);
|
||||||
|
void flush();
|
||||||
|
};
|
||||||
|
}, [flush]);
|
||||||
|
|
||||||
|
const displayCaught = (stats?.caught ?? 0) + pendingShown;
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats, displayCaught, coinsRef, reportCatch, repair, killWasp, buyRepellent,
|
||||||
|
reportTear, reportRobbery, defeatThief, caterpillarAte, defeatCaterpillar,
|
||||||
|
mothHit, reportInspectionFail, buyPremium, waspSpray, buyInsurance, buyUpgrade,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -27,9 +27,10 @@ async function pushApiFetch(path: string, options: RequestInit = {}): Promise<Re
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Zaregistruje service worker, přihlásí se k push notifikacím
|
* Zaregistruje service worker, přihlásí se k push notifikacím
|
||||||
* a odešle subscription na server.
|
* a odešle subscription na server. `reminderTime` je volitelný — bez něj
|
||||||
|
* uživatel odebírá jen ostatní push notifikace (např. doručení objednávky).
|
||||||
*/
|
*/
|
||||||
export async function subscribeToPush(reminderTime: string): Promise<boolean> {
|
export async function subscribeToPush(reminderTime?: string): Promise<boolean> {
|
||||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||||
console.warn('Push notifikace nejsou v tomto prohlížeči podporovány');
|
console.warn('Push notifikace nejsou v tomto prohlížeči podporovány');
|
||||||
return false;
|
return false;
|
||||||
@@ -75,7 +76,7 @@ export async function subscribeToPush(reminderTime: string): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Push notifikace: úspěšně přihlášeno k připomínkám v', reminderTime);
|
console.log('Push notifikace: úspěšně přihlášeno', reminderTime ? `(připomínka v ${reminderTime})` : '(bez připomínky oběda)');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Push notifikace: chyba při registraci', error);
|
console.error('Push notifikace: chyba při registraci', error);
|
||||||
@@ -101,7 +102,7 @@ export async function unsubscribeFromPush(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await pushApiFetch('/unsubscribe', { method: 'POST' });
|
await pushApiFetch('/unsubscribe', { method: 'POST' });
|
||||||
console.log('Push notifikace: úspěšně odhlášeno z připomínek');
|
console.log('Push notifikace: úspěšně odhlášeno');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Push notifikace: chyba při odhlášení', error);
|
console.error('Push notifikace: chyba při odhlášení', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,20 +6,49 @@ import './index.css';
|
|||||||
import AppRoutes from './AppRoutes';
|
import AppRoutes from './AppRoutes';
|
||||||
import { BrowserRouter } from 'react-router';
|
import { BrowserRouter } from 'react-router';
|
||||||
import { client } from '../../types/gen/client.gen';
|
import { client } from '../../types/gen/client.gen';
|
||||||
|
import { getConfig } from '../../types/gen/sdk.gen';
|
||||||
import { getToken } from './Utils';
|
import { getToken } from './Utils';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
import * as Sentry from '@sentry/react';
|
||||||
|
import { setAppConfig } from './utils/appConfig';
|
||||||
|
|
||||||
client.setConfig({
|
client.setConfig({
|
||||||
auth: () => getToken(),
|
auth: () => getToken(),
|
||||||
baseUrl: '/api', // openapi-ts si to z nějakého důvodu neumí převzít z api.yml
|
baseUrl: '/api', // openapi-ts si to z nějakého důvodu neumí převzít z api.yml
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sentry se inicializuje až podle runtime konfigurace ze serveru (bez DSN zůstává vypnuté).
|
||||||
|
// Klient je statický build, takže DSN ani limity nejde zapéct při buildu — server je zná z env.
|
||||||
|
getConfig().then(({ data }) => {
|
||||||
|
setAppConfig({
|
||||||
|
loaded: true,
|
||||||
|
trustedHeaders: data?.auth?.trustedHeaders,
|
||||||
|
storeAdmin: data?.features?.storeAdmin,
|
||||||
|
lobbyUrl: data?.links?.lobbyUrl ?? undefined,
|
||||||
|
groupLimits: data?.limits?.group,
|
||||||
|
});
|
||||||
|
if (data?.sentry.dsn) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: data.sentry.dsn,
|
||||||
|
environment: data.sentry.environment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
// Config endpoint nedostupný — běžíme bez Sentry, s výchozími limity a původním chováním přihlášení
|
||||||
|
setAppConfig({ loaded: true });
|
||||||
|
});
|
||||||
|
|
||||||
// Interceptor na vyhození toasteru při chybě
|
// Interceptor na vyhození toasteru při chybě
|
||||||
client.interceptors.response.use(async response => {
|
client.interceptors.response.use(async response => {
|
||||||
// TODO opravit - login je zatím výjimka, voláme ho "naprázdno" abychom zjistili, zda nás nepřihlásily trusted headers
|
// TODO opravit - login je zatím výjimka, voláme ho "naprázdno" abychom zjistili, zda nás nepřihlásily trusted headers
|
||||||
if (!response.ok && !response.url.includes("/login")) {
|
if (!response.ok && !response.url.includes("/login")) {
|
||||||
const json = await response.json();
|
// Čteme z klonu, aby tělo odpovědi zůstalo k dispozici i generovanému SDK
|
||||||
|
const json = await response.clone().json();
|
||||||
toast.error(json.error, { theme: "colored" });
|
toast.error(json.error, { theme: "colored" });
|
||||||
|
// Serverové chyby hlásíme do Sentry; 4xx jsou očekávané (chyby uživatele)
|
||||||
|
if (response.status >= 500) {
|
||||||
|
Sentry.captureMessage(`API ${response.status}: ${new URL(response.url).pathname} — ${json.error}`, 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
});
|
});
|
||||||
@@ -29,10 +58,18 @@ const root = ReactDOM.createRoot(
|
|||||||
);
|
);
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<Sentry.ErrorBoundary fallback={
|
||||||
|
<div className="text-center p-5">
|
||||||
|
<h4>Něco se pokazilo 😕</h4>
|
||||||
|
<p>Chyba byla nahlášena. Zkuste stránku načíst znovu.</p>
|
||||||
|
<button className="btn btn-primary" onClick={() => window.location.reload()}>Načíst znovu</button>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ProvideAuth>
|
<ProvideAuth>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</ProvideAuth>
|
</ProvideAuth>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</Sentry.ErrorBoundary>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
@@ -2,15 +2,19 @@ import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|||||||
import { Alert, Badge, Button, Card, Form, Modal, OverlayTrigger, Table, Tooltip } from 'react-bootstrap';
|
import { Alert, Badge, Button, Card, Form, Modal, OverlayTrigger, Table, Tooltip } from 'react-bootstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faTrashCan } from '@fortawesome/free-regular-svg-icons';
|
import { faTrashCan } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { faBasketShopping, faChevronLeft, faChevronRight, faCircleCheck, faClockRotateLeft, faGear, faLock, faLockOpen, faPen, faSearch, faUserPlus } from '@fortawesome/free-solid-svg-icons';
|
import { faBasketShopping, faChevronLeft, faChevronRight, faCircleCheck, faClockRotateLeft, faGear, faLock, faLockOpen, faPen, faSearch, faTruck, faUserPlus } from '@fortawesome/free-solid-svg-icons';
|
||||||
import DatePicker, { registerLocale } from 'react-datepicker';
|
import DatePicker, { registerLocale } from 'react-datepicker';
|
||||||
import { cs } from 'date-fns/locale';
|
import { cs } from 'date-fns/locale';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import {
|
import {
|
||||||
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr,
|
ClientData, GroupState, LunchChoice, MealSlot, OrderGroup, OrderGroupMember, PendingQr, TrackingProvider,
|
||||||
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setBoltTracking, getOrderDates,
|
StoreMenuDish, getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setTracking, getOrderDates, getStoreMenu, addChoice,
|
||||||
} from '../../../types';
|
} from '../../../types';
|
||||||
|
import MenuHintCell from '../components/MenuHintCell';
|
||||||
|
import EaterBadge from '../components/EaterBadge';
|
||||||
|
import { addItemLimitError, addNoteItem, countNoteItems, removeNoteItem } from '../utils/menuMatch';
|
||||||
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
|
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
|
||||||
|
import { getStoreUrls, isHttpUrl, storeUrlLabel } from '../utils/storeUrls';
|
||||||
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
|
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
|
||||||
import { useAuth } from '../context/auth';
|
import { useAuth } from '../context/auth';
|
||||||
import { useSettings } from '../context/settings';
|
import { useSettings } from '../context/settings';
|
||||||
@@ -23,27 +27,60 @@ import StoreAdminModal from '../components/modals/StoreAdminModal';
|
|||||||
import PayForGroupModal from '../components/modals/PayForGroupModal';
|
import PayForGroupModal from '../components/modals/PayForGroupModal';
|
||||||
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
|
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
|
||||||
import BoltSimulationModal from '../components/modals/BoltSimulationModal';
|
import BoltSimulationModal from '../components/modals/BoltSimulationModal';
|
||||||
|
import GenerateMockOrdersModal from '../components/modals/GenerateMockOrdersModal';
|
||||||
import PendingPayments from '../components/PendingPayments';
|
import PendingPayments from '../components/PendingPayments';
|
||||||
import BoltOrderProgress from '../components/BoltOrderProgress';
|
import OrderProgress, { PROVIDER_LABEL } from '../components/OrderProgress';
|
||||||
|
import { isArrowNavigationBlocked } from '../utils/arrowNavigation';
|
||||||
|
|
||||||
const SLOT = MealSlot.EXTRA;
|
const SLOT = MealSlot.EXTRA;
|
||||||
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
|
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
const BOLT_SHARE_URL_PREFIX = 'https://food.bolt.eu/sharedActiveOrder/';
|
|
||||||
const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i;
|
|
||||||
const IS_DEV = process.env.NODE_ENV === 'development';
|
const IS_DEV = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
/** Vytáhne sledovací token ze sdílecí URL Bolt Food, nebo přijme samotný token. Null = neplatný vstup. */
|
/** Předpony sdílecích odkazů jednotlivých služeb (pro sestavení odkazu z uloženého kódu). */
|
||||||
function extractBoltToken(input: string): string | null {
|
const TRACKING_URL_PREFIX: Record<TrackingProvider, string> = {
|
||||||
|
bolt: 'https://food.bolt.eu/sharedActiveOrder/',
|
||||||
|
wolt: 'https://track.wolt.com/',
|
||||||
|
};
|
||||||
|
/** Tvar kódu sledování u jednotlivých služeb — musí odpovídat serveru (trackingProviders.ts). */
|
||||||
|
const TRACKING_CODE_REGEX: Record<TrackingProvider, RegExp> = {
|
||||||
|
bolt: /^[0-9a-f]{64}$/i,
|
||||||
|
wolt: /^[A-Za-z0-9_-]{20,32}$/,
|
||||||
|
};
|
||||||
|
const TRACKING_HOST: Record<TrackingProvider, string> = {
|
||||||
|
bolt: 'bolt.eu',
|
||||||
|
wolt: 'wolt.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sestaví odkaz na sledování objednávky ze služby a kódu. */
|
||||||
|
function trackingUrlFor(provider: TrackingProvider, code: string): string {
|
||||||
|
return `${TRACKING_URL_PREFIX[provider] ?? ''}${code}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rozpozná rozvozovou službu podle odkazu na sledování (nebo podle samotného kódu)
|
||||||
|
* a vytáhne z něj kód sledování. Null = vstup nepatří žádné podporované službě.
|
||||||
|
*/
|
||||||
|
function extractTracking(input: string): { provider: TrackingProvider; code: string } | null {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
if (BOLT_TOKEN_REGEX.test(trimmed)) return trimmed;
|
let hostname = '';
|
||||||
|
let lastSegment = '';
|
||||||
try {
|
try {
|
||||||
const segments = new URL(trimmed).pathname.split('/').filter(Boolean);
|
const url = new URL(trimmed);
|
||||||
const last = segments[segments.length - 1];
|
hostname = url.hostname.toLowerCase();
|
||||||
return last && BOLT_TOKEN_REGEX.test(last) ? last : null;
|
const segments = url.pathname.split('/').filter(Boolean);
|
||||||
|
lastSegment = segments[segments.length - 1] ?? '';
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
// Vstup není URL — zkusíme ho vzít jako samotný kód
|
||||||
}
|
}
|
||||||
|
for (const provider of Object.values(TrackingProvider)) {
|
||||||
|
const regex = TRACKING_CODE_REGEX[provider];
|
||||||
|
if (!hostname && regex.test(trimmed)) return { provider, code: trimmed };
|
||||||
|
const host = TRACKING_HOST[provider];
|
||||||
|
const hostMatches = hostname === host || hostname.endsWith(`.${host}`);
|
||||||
|
if (hostMatches && regex.test(lastSegment)) return { provider, code: lastSegment };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zkrátí dlouhý odkaz pro zobrazení v řádku (zachová začátek i konec). */
|
/** Zkrátí dlouhý odkaz pro zobrazení v řádku (zachová začátek i konec). */
|
||||||
@@ -69,11 +106,17 @@ function isoToDate(iso?: string): Date | null {
|
|||||||
return iso ? new Date(`${iso}T00:00:00`) : null;
|
return iso ? new Date(`${iso}T00:00:00`) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stavy, ve kterých je objednávka již odeslána do podniku (objednáno/doručeno) — skupina se nesmí upravovat. */
|
||||||
|
function isSubmittedState(state: GroupState): boolean {
|
||||||
|
return state === GroupState.ORDERED || state === GroupState.DELIVERED;
|
||||||
|
}
|
||||||
|
|
||||||
function stateBadge(state: GroupState) {
|
function stateBadge(state: GroupState) {
|
||||||
const map: Record<GroupState, { bg: string; label: string }> = {
|
const map: Record<GroupState, { bg: string; label: string }> = {
|
||||||
[GroupState.OPEN]: { bg: 'success', label: 'Otevřeno' },
|
[GroupState.OPEN]: { bg: 'success', label: 'Otevřeno' },
|
||||||
[GroupState.LOCKED]: { bg: 'warning', label: 'Uzamčeno' },
|
[GroupState.LOCKED]: { bg: 'warning', label: 'Uzamčeno' },
|
||||||
[GroupState.ORDERED]: { bg: 'secondary', label: 'Objednáno' },
|
[GroupState.ORDERED]: { bg: 'secondary', label: 'Objednáno' },
|
||||||
|
[GroupState.DELIVERED]: { bg: 'info', label: 'Doručeno' },
|
||||||
};
|
};
|
||||||
const { bg, label } = map[state] ?? { bg: 'light', label: state };
|
const { bg, label } = map[state] ?? { bg: 'light', label: state };
|
||||||
return <Badge bg={bg}>{label}</Badge>;
|
return <Badge bg={bg}>{label}</Badge>;
|
||||||
@@ -94,17 +137,22 @@ export default function OrderGroupsPage() {
|
|||||||
// ISO data dnů, ve kterých existuje aspoň jedna objednávka (pro zvýraznění v date pickeru)
|
// ISO data dnů, ve kterých existuje aspoň jedna objednávka (pro zvýraznění v date pickeru)
|
||||||
const [orderDates, setOrderDates] = useState<string[]>([]);
|
const [orderDates, setOrderDates] = useState<string[]>([]);
|
||||||
const [newGroupName, setNewGroupName] = useState('');
|
const [newGroupName, setNewGroupName] = useState('');
|
||||||
|
// Vybraná URL na nabídku — podnik může být dostupný přes více dovozových služeb
|
||||||
|
const [newGroupUrl, setNewGroupUrl] = useState('');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [adminModalOpen, setAdminModalOpen] = useState(false);
|
const [adminModalOpen, setAdminModalOpen] = useState(false);
|
||||||
const [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
|
const [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
|
||||||
const [editNotes, setEditNotes] = useState<Record<string, string>>({});
|
const [editNotes, setEditNotes] = useState<Record<string, string>>({});
|
||||||
const [editSurcharges, setEditSurcharges] = useState<Record<string, { text: string; amount: string }>>({});
|
const [editSurcharges, setEditSurcharges] = useState<Record<string, { text: string; amount: string }>>({});
|
||||||
const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string; boltUrl: string }>>({});
|
const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string; trackingUrl: string }>>({});
|
||||||
const [payModal, setPayModal] = useState<OrderGroup | null>(null);
|
const [payModal, setPayModal] = useState<OrderGroup | null>(null);
|
||||||
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
|
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
|
||||||
const [boltSimModal, setBoltSimModal] = useState<OrderGroup | null>(null);
|
const [boltSimModal, setBoltSimModal] = useState<OrderGroup | null>(null);
|
||||||
|
const [mockOrdersModalOpen, setMockOrdersModalOpen] = useState(false);
|
||||||
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
|
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
|
||||||
const [pageError, setPageError] = useState<string | null>(null);
|
const [pageError, setPageError] = useState<string | null>(null);
|
||||||
|
// Stažené nabídky podniků (dle názvu obchodu) pro sloupec „Z nabídky"
|
||||||
|
const [storeMenus, setStoreMenus] = useState<Record<string, StoreMenuDish[] | null>>({});
|
||||||
|
|
||||||
const fetchData = async (date?: string) => {
|
const fetchData = async (date?: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -177,9 +225,8 @@ export default function OrderGroupsPage() {
|
|||||||
|
|
||||||
// Navigace mezi dny pomocí klávesových šipek (←/→), obdobně jako na hlavní stránce
|
// Navigace mezi dny pomocí klávesových šipek (←/→), obdobně jako na hlavní stránce
|
||||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||||
// Ignorujeme, pokud uživatel právě píše do formulářového pole
|
// Ignorujeme, pokud uživatel právě píše do formulářového pole nebo je otevřený dialog (např. minihra)
|
||||||
const tag = (e.target as HTMLElement)?.tagName;
|
if (isArrowNavigationBlocked(e)) return;
|
||||||
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
|
|
||||||
const currentIso = data?.isoDate;
|
const currentIso = data?.isoDate;
|
||||||
if (!currentIso) return;
|
if (!currentIso) return;
|
||||||
if (e.keyCode === 37) {
|
if (e.keyCode === 37) {
|
||||||
@@ -214,16 +261,43 @@ export default function OrderGroupsPage() {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pokud to má uživatel zapnuté v nastavení, přepne jeho dnešní volbu stravování na "Budu objednávat".
|
||||||
|
* Volbu nemění, pokud již objednává, nebo je přihlášen na Pizza day (změnou by přišel o objednané pizzy).
|
||||||
|
*/
|
||||||
|
const switchChoiceToOrdering = async () => {
|
||||||
|
if (!settings?.autoOrderChoice || !auth?.login) return;
|
||||||
|
const lunch = await getData({ query: { slot: MealSlot.OBED } });
|
||||||
|
const choices = lunch.data?.choices;
|
||||||
|
if (!choices) return;
|
||||||
|
if (auth.login in (choices[LunchChoice.OBJEDNAVAM] ?? {}) || auth.login in (choices[LunchChoice.PIZZA] ?? {})) return;
|
||||||
|
const result = await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: MealSlot.OBED } });
|
||||||
|
if (result.error) {
|
||||||
|
setPageError(`Nepodařilo se přepnout volbu stravování: ${(result.error as any).error || 'Nastala chyba'}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!newGroupName || !auth?.login) return;
|
if (!newGroupName || !auth?.login) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
const ok = await refresh(() => createGroup({ body: { name: newGroupName } }));
|
// Pojistka, kdyby se seznam obchodů mezitím změnil (socket) — vezmeme první URL podniku
|
||||||
if (ok) setNewGroupName('');
|
const urls = getStoreUrls((data?.stores ?? []).find(s => s.name === newGroupName));
|
||||||
|
const url = urls.includes(newGroupUrl) ? newGroupUrl : urls[0];
|
||||||
|
const ok = await refresh(() => createGroup({ body: { name: newGroupName, url } }));
|
||||||
|
if (ok) {
|
||||||
|
setNewGroupName('');
|
||||||
|
setNewGroupUrl('');
|
||||||
|
await switchChoiceToOrdering();
|
||||||
|
}
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJoin = (groupId: string) =>
|
|
||||||
refresh(() => addGroupMember({ body: { id: groupId } }));
|
const handleJoin = async (groupId: string) => {
|
||||||
|
if (await refresh(() => addGroupMember({ body: { id: groupId } }))) {
|
||||||
|
await switchChoiceToOrdering();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleLock = (group: OrderGroup) => {
|
const handleToggleLock = (group: OrderGroup) => {
|
||||||
const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN;
|
const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN;
|
||||||
@@ -238,6 +312,12 @@ export default function OrderGroupsPage() {
|
|||||||
const handleRevertOrdered = (group: OrderGroup) =>
|
const handleRevertOrdered = (group: OrderGroup) =>
|
||||||
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.LOCKED } }));
|
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.LOCKED } }));
|
||||||
|
|
||||||
|
const handleSetDelivered = (group: OrderGroup) =>
|
||||||
|
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.DELIVERED } }));
|
||||||
|
|
||||||
|
const handleRevertDelivered = (group: OrderGroup) =>
|
||||||
|
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.ORDERED } }));
|
||||||
|
|
||||||
const handleDelete = (groupId: string) =>
|
const handleDelete = (groupId: string) =>
|
||||||
refresh(() => deleteGroup({ body: { id: groupId } }));
|
refresh(() => deleteGroup({ body: { id: groupId } }));
|
||||||
|
|
||||||
@@ -253,6 +333,49 @@ export default function OrderGroupsPage() {
|
|||||||
if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
|
if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Nabídky podniků zobrazených skupin — jen pro obchody ze seznamu, každou jednou
|
||||||
|
const groupStoreNames = (data?.groups ?? [])
|
||||||
|
.map(g => (data?.stores ?? []).find(s => s.name.toLowerCase() === g.name.toLowerCase())?.name)
|
||||||
|
.filter((n): n is string => !!n);
|
||||||
|
const groupStoreKey = [...new Set(groupStoreNames)].sort().join('|');
|
||||||
|
useEffect(() => {
|
||||||
|
for (const name of groupStoreKey ? groupStoreKey.split('|') : []) {
|
||||||
|
if (name in storeMenus) continue;
|
||||||
|
setStoreMenus(prev => ({ ...prev, [name]: null }));
|
||||||
|
getStoreMenu({ query: { name } })
|
||||||
|
.then(r => setStoreMenus(prev => ({ ...prev, [name]: r.data?.dishes ?? [] })))
|
||||||
|
.catch(() => setStoreMenus(prev => ({ ...prev, [name]: [] })));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [groupStoreKey]);
|
||||||
|
|
||||||
|
const getGroupMenu = (group: OrderGroup): StoreMenuDish[] | undefined =>
|
||||||
|
Object.entries(storeMenus).find(([name]) => name.toLowerCase() === group.name.toLowerCase())?.[1] ?? undefined;
|
||||||
|
|
||||||
|
/** Výběr položky z nabídky — připojí ji k poznámce (opakovaný výběr zvýší počet „2x") a cenu přičte k částce. */
|
||||||
|
const handlePickDish = async (group: OrderGroup, login: string, member: OrderGroupMember, dish: StoreMenuDish) => {
|
||||||
|
const limitError = addItemLimitError(member.note, dish.name, getGroupMenu(group) ?? []);
|
||||||
|
if (limitError) {
|
||||||
|
setPageError(limitError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const note = addNoteItem(member.note, dish.name, getGroupMenu(group) ?? []);
|
||||||
|
const amount = dish.price != null ? (member.amount ?? 0) + Math.round(dish.price * 100) : member.amount;
|
||||||
|
return refresh(() => updateGroupMember({ body: { id: group.id, login, note, ...(amount != null && { amount }) } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Odebere jeden kus položky z poznámky a odečte její cenu z částky (nikdy pod nulu). */
|
||||||
|
const handleRemoveDish = (group: OrderGroup, login: string, member: OrderGroupMember, index: number) => {
|
||||||
|
const { note, removedPrice } = removeNoteItem(member.note, index, getGroupMenu(group) ?? []);
|
||||||
|
const amount = removedPrice != null && member.amount != null
|
||||||
|
? Math.max(0, member.amount - Math.round(removedPrice * 100))
|
||||||
|
: member.amount;
|
||||||
|
return refresh(() => updateGroupMember({ body: { id: group.id, login, note, ...(amount != null && { amount }) } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUseMenuPrice = (group: OrderGroup, login: string, priceKc: number) =>
|
||||||
|
refresh(() => updateGroupMember({ body: { id: group.id, login, amount: Math.round(priceKc * 100) } }));
|
||||||
|
|
||||||
const handleSaveNote = async (groupId: string, login: string) => {
|
const handleSaveNote = async (groupId: string, login: string) => {
|
||||||
const key = `${groupId}:${login}`;
|
const key = `${groupId}:${login}`;
|
||||||
const note = editNotes[key] ?? '';
|
const note = editNotes[key] ?? '';
|
||||||
@@ -276,7 +399,7 @@ export default function OrderGroupsPage() {
|
|||||||
const handleSaveTimes = async (group: OrderGroup) => {
|
const handleSaveTimes = async (group: OrderGroup) => {
|
||||||
const times = editTimes[group.id];
|
const times = editTimes[group.id];
|
||||||
if (!times) return;
|
if (!times) return;
|
||||||
const { orderedAt, deliveryAt, boltUrl } = times;
|
const { orderedAt, deliveryAt, trackingUrl } = times;
|
||||||
if (orderedAt && !TIME_REGEX.test(orderedAt)) {
|
if (orderedAt && !TIME_REGEX.test(orderedAt)) {
|
||||||
setPageError('Čas objednání musí být ve formátu HH:MM');
|
setPageError('Čas objednání musí být ve formátu HH:MM');
|
||||||
return;
|
return;
|
||||||
@@ -285,16 +408,16 @@ export default function OrderGroupsPage() {
|
|||||||
setPageError('Čas doručení musí být ve formátu HH:MM');
|
setPageError('Čas doručení musí být ve formátu HH:MM');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Bolt odkaz se odesílá jen při změně oproti aktuálnímu tokenu skupiny
|
// Odkaz na sledování se odesílá jen při změně oproti aktuálnímu kódu skupiny
|
||||||
const boltToken = boltUrl.trim() ? extractBoltToken(boltUrl) : null;
|
const tracking = trackingUrl.trim() ? extractTracking(trackingUrl) : null;
|
||||||
if (boltUrl.trim() && !boltToken) {
|
if (trackingUrl.trim() && !tracking) {
|
||||||
setPageError('Neplatný odkaz Bolt (očekávána URL sdílení objednávky)');
|
setPageError('Neplatný odkaz pro sledování (očekáván odkaz Bolt Food nebo Wolt)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const boltChanged = (boltToken ?? undefined) !== group.boltTrackingToken;
|
const trackingChanged = tracking?.code !== group.trackingCode || tracking?.provider !== group.trackingProvider;
|
||||||
let ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
|
let ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
|
||||||
if (ok && boltChanged) {
|
if (ok && trackingChanged) {
|
||||||
ok = await refresh(() => setBoltTracking({ body: { id: group.id, shareUrl: boltUrl.trim() } }));
|
ok = await refresh(() => setTracking({ body: { id: group.id, shareUrl: trackingUrl.trim() } }));
|
||||||
}
|
}
|
||||||
if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; });
|
if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; });
|
||||||
};
|
};
|
||||||
@@ -303,7 +426,7 @@ export default function OrderGroupsPage() {
|
|||||||
// Historie (jiný než aktuální den) je vždy read-only.
|
// Historie (jiný než aktuální den) je vždy read-only.
|
||||||
const canEditMember = (group: OrderGroup, targetLogin: string) => {
|
const canEditMember = (group: OrderGroup, targetLogin: string) => {
|
||||||
if (selectedDate) return false;
|
if (selectedDate) return false;
|
||||||
if (group.state === GroupState.ORDERED) return false;
|
if (isSubmittedState(group.state)) return false;
|
||||||
if (auth?.login === group.creatorLogin) return true;
|
if (auth?.login === group.creatorLogin) return true;
|
||||||
if (auth?.login === targetLogin && group.state === GroupState.OPEN) return true;
|
if (auth?.login === targetLogin && group.state === GroupState.OPEN) return true;
|
||||||
return false;
|
return false;
|
||||||
@@ -311,7 +434,7 @@ export default function OrderGroupsPage() {
|
|||||||
|
|
||||||
const canManageMembers = (group: OrderGroup) => {
|
const canManageMembers = (group: OrderGroup) => {
|
||||||
if (selectedDate) return false;
|
if (selectedDate) return false;
|
||||||
if (group.state === GroupState.ORDERED) return false;
|
if (isSubmittedState(group.state)) return false;
|
||||||
if (auth?.login === group.creatorLogin) return true;
|
if (auth?.login === group.creatorLogin) return true;
|
||||||
return group.state === GroupState.OPEN;
|
return group.state === GroupState.OPEN;
|
||||||
};
|
};
|
||||||
@@ -328,6 +451,8 @@ export default function OrderGroupsPage() {
|
|||||||
|
|
||||||
const stores = data.stores ?? [];
|
const stores = data.stores ?? [];
|
||||||
const groups = data.groups ?? [];
|
const groups = data.groups ?? [];
|
||||||
|
// URL na nabídku vybíraného podniku — výběr nabízíme, jen když jich je víc
|
||||||
|
const newStoreUrls = getStoreUrls(stores.find(s => s.name === newGroupName));
|
||||||
|
|
||||||
// Zobrazené datum a režim historie (vše read-only, pokud nejde o aktuální den)
|
// Zobrazené datum a režim historie (vše read-only, pokud nejde o aktuální den)
|
||||||
const displayedIso = data.isoDate;
|
const displayedIso = data.isoDate;
|
||||||
@@ -358,11 +483,18 @@ export default function OrderGroupsPage() {
|
|||||||
<div className="wrapper">
|
<div className="wrapper">
|
||||||
<div className="d-flex align-items-center justify-content-between mb-1">
|
<div className="d-flex align-items-center justify-content-between mb-1">
|
||||||
<h1 className="title mb-0">Objednání</h1>
|
<h1 className="title mb-0">Objednání</h1>
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
{IS_DEV && !isReadOnly && (
|
||||||
|
<Button variant="outline-warning" size="sm" onClick={() => setMockOrdersModalOpen(true)} title="Generovat mock objednávky (DEV)">
|
||||||
|
🔧 Mock objednávky
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline-primary" size="sm" onClick={() => setAdminModalOpen(true)} title="Správa obchodů">
|
<Button variant="outline-primary" size="sm" onClick={() => setAdminModalOpen(true)} title="Správa obchodů">
|
||||||
<FontAwesomeIcon icon={faGear} className="me-1" />
|
<FontAwesomeIcon icon={faGear} className="me-1" />
|
||||||
Obchody
|
Obchody
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<p style={{ color: 'var(--luncher-text-muted)' }}>Skupinové objednávky z obchodů a restaurací</p>
|
<p style={{ color: 'var(--luncher-text-muted)' }}>Skupinové objednávky z obchodů a restaurací</p>
|
||||||
|
|
||||||
{/* Navigace mezi dny – šipky kolem výběru data (i klávesami ←/→) */}
|
{/* Navigace mezi dny – šipky kolem výběru data (i klávesami ←/→) */}
|
||||||
@@ -425,12 +557,29 @@ export default function OrderGroupsPage() {
|
|||||||
<div className="d-flex gap-2 align-items-center flex-wrap">
|
<div className="d-flex gap-2 align-items-center flex-wrap">
|
||||||
<Form.Select
|
<Form.Select
|
||||||
value={newGroupName}
|
value={newGroupName}
|
||||||
onChange={e => setNewGroupName(e.target.value)}
|
onChange={e => {
|
||||||
|
const name = e.target.value;
|
||||||
|
setNewGroupName(name);
|
||||||
|
// Výběr nabídky se resetuje na první URL nově vybraného podniku
|
||||||
|
setNewGroupUrl(getStoreUrls(stores.find(s => s.name === name))[0] ?? '');
|
||||||
|
}}
|
||||||
style={{ maxWidth: 260 }}
|
style={{ maxWidth: 260 }}
|
||||||
>
|
>
|
||||||
<option value="">— vyberte obchod —</option>
|
<option value="">— vyberte obchod —</option>
|
||||||
{stores.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
|
{stores.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
|
{newStoreUrls.length > 1 && (
|
||||||
|
<Form.Select
|
||||||
|
value={newStoreUrls.includes(newGroupUrl) ? newGroupUrl : newStoreUrls[0]}
|
||||||
|
onChange={e => setNewGroupUrl(e.target.value)}
|
||||||
|
style={{ maxWidth: 220 }}
|
||||||
|
title="Odkud se bude objednávat"
|
||||||
|
>
|
||||||
|
{newStoreUrls.map(url => (
|
||||||
|
<option key={url} value={url}>{storeUrlLabel(url)}</option>
|
||||||
|
))}
|
||||||
|
</Form.Select>
|
||||||
|
)}
|
||||||
<Button variant="primary" onClick={handleCreate} disabled={creating || !newGroupName}>
|
<Button variant="primary" onClick={handleCreate} disabled={creating || !newGroupName}>
|
||||||
Vytvořit skupinu
|
Vytvořit skupinu
|
||||||
</Button>
|
</Button>
|
||||||
@@ -451,13 +600,19 @@ export default function OrderGroupsPage() {
|
|||||||
const isCreator = login === group.creatorLogin;
|
const isCreator = login === group.creatorLogin;
|
||||||
const isMember = login in group.members;
|
const isMember = login in group.members;
|
||||||
const isOrdered = group.state === GroupState.ORDERED;
|
const isOrdered = group.state === GroupState.ORDERED;
|
||||||
|
const isDelivered = group.state === GroupState.DELIVERED;
|
||||||
|
// Objednáno i Doručeno = objednávka je odeslána do podniku, skupina se už needituje
|
||||||
|
const isSubmitted = isOrdered || isDelivered;
|
||||||
const isLocked = group.state === GroupState.LOCKED;
|
const isLocked = group.state === GroupState.LOCKED;
|
||||||
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
|
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
|
||||||
const editingTimes = group.id in editTimes;
|
const editingTimes = group.id in editTimes;
|
||||||
// URL na nabídku podniku (pokud ji má dohledatelný obchod vyplněnou).
|
// URL na nabídku podniku — přednostně ta, kterou zakladatel vybral při vytvoření
|
||||||
|
// skupiny. Skupiny z doby před výběrem URL ji nemají, pak zkusíme jedinou URL obchodu.
|
||||||
// Povolíme jen http(s), aby odkaz nemohl být zneužit (např. javascript:).
|
// Povolíme jen http(s), aby odkaz nemohl být zneužit (např. javascript:).
|
||||||
const rawStoreUrl = stores.find(s => s.name === group.name)?.url;
|
const groupStoreUrls = getStoreUrls(stores.find(s => s.name === group.name));
|
||||||
const storeUrl = rawStoreUrl && /^https?:\/\//i.test(rawStoreUrl) ? rawStoreUrl : undefined;
|
const storeUrl = isHttpUrl(group.url)
|
||||||
|
? group.url
|
||||||
|
: (groupStoreUrls.length === 1 ? groupStoreUrls[0] : undefined);
|
||||||
|
|
||||||
const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
|
const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
|
||||||
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
|
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
|
||||||
@@ -484,7 +639,7 @@ export default function OrderGroupsPage() {
|
|||||||
<small className="text-muted">zakladatel: {group.creatorLogin}</small>
|
<small className="text-muted">zakladatel: {group.creatorLogin}</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2">
|
||||||
{!isReadOnly && isCreator && !isOrdered && (
|
{!isReadOnly && isCreator && !isSubmitted && (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline-info" size="sm" onClick={() => setFeesModal(group)} title="Upravit poplatky a slevu">
|
<Button variant="outline-info" size="sm" onClick={() => setFeesModal(group)} title="Upravit poplatky a slevu">
|
||||||
Poplatky
|
Poplatky
|
||||||
@@ -502,20 +657,32 @@ export default function OrderGroupsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isReadOnly && isCreator && isOrdered && (
|
{!isReadOnly && isCreator && isSubmitted && (
|
||||||
<>
|
<>
|
||||||
|
{isOrdered && (
|
||||||
|
<Button variant="outline-success" size="sm" onClick={() => handleSetDelivered(group)} title="Označit objednávku jako doručenou">
|
||||||
|
<FontAwesomeIcon icon={faTruck} className="me-1" />
|
||||||
|
Doručeno
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{settings?.bankAccount && settings?.holderName && !group.qrGenerated && (
|
{settings?.bankAccount && settings?.holderName && !group.qrGenerated && (
|
||||||
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
|
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
|
||||||
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
|
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
|
||||||
Generovat QR
|
Generovat QR
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{isDelivered ? (
|
||||||
|
<Button variant="outline-warning" size="sm" onClick={() => handleRevertDelivered(group)} title="Vrátit na Objednáno">
|
||||||
|
<FontAwesomeIcon icon={faClockRotateLeft} />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Button variant="outline-warning" size="sm" onClick={() => handleRevertOrdered(group)} title="Vrátit na Uzamčeno (smaže QR kódy)">
|
<Button variant="outline-warning" size="sm" onClick={() => handleRevertOrdered(group)} title="Vrátit na Uzamčeno (smaže QR kódy)">
|
||||||
<FontAwesomeIcon icon={faLockOpen} />
|
<FontAwesomeIcon icon={faLockOpen} />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isReadOnly && !isMember && !isOrdered && !isLocked && (
|
{!isReadOnly && !isMember && !isSubmitted && !isLocked && (
|
||||||
<Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}>
|
<Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}>
|
||||||
<FontAwesomeIcon icon={faUserPlus} className="me-1" />
|
<FontAwesomeIcon icon={faUserPlus} className="me-1" />
|
||||||
Přidat se
|
Přidat se
|
||||||
@@ -531,6 +698,7 @@ export default function OrderGroupsPage() {
|
|||||||
<th style={{ width: 180 }}>Částka (bez slev)</th>
|
<th style={{ width: 180 }}>Částka (bez slev)</th>
|
||||||
<th style={{ width: 220 }}>Příplatek</th>
|
<th style={{ width: 220 }}>Příplatek</th>
|
||||||
<th>Poznámka</th>
|
<th>Poznámka</th>
|
||||||
|
{getGroupMenu(group)?.length ? <th style={{ width: 240 }}>Z nabídky</th> : null}
|
||||||
<th style={{ width: 160 }}>Celkem (s poplatky)</th>
|
<th style={{ width: 160 }}>Celkem (s poplatky)</th>
|
||||||
<th style={{ width: 40 }}></th>
|
<th style={{ width: 40 }}></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -547,6 +715,7 @@ export default function OrderGroupsPage() {
|
|||||||
<tr key={memberLogin}>
|
<tr key={memberLogin}>
|
||||||
<td>
|
<td>
|
||||||
<span className="user-info">
|
<span className="user-info">
|
||||||
|
<EaterBadge items={countNoteItems(member.note, getGroupMenu(group) ?? [])} />
|
||||||
<strong>{memberLogin}</strong>
|
<strong>{memberLogin}</strong>
|
||||||
{memberLogin === group.creatorLogin && (
|
{memberLogin === group.creatorLogin && (
|
||||||
<OverlayTrigger placement="top" overlay={<Tooltip>Zakladatel / objednávající</Tooltip>}>
|
<OverlayTrigger placement="top" overlay={<Tooltip>Zakladatel / objednávající</Tooltip>}>
|
||||||
@@ -645,6 +814,19 @@ export default function OrderGroupsPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
{getGroupMenu(group)?.length ? (
|
||||||
|
<td>
|
||||||
|
<MenuHintCell
|
||||||
|
menu={getGroupMenu(group)}
|
||||||
|
note={member.note}
|
||||||
|
amount={member.amount}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onPick={dish => handlePickDish(group, memberLogin, member, dish)}
|
||||||
|
onUsePrice={price => handleUseMenuPrice(group, memberLogin, price)}
|
||||||
|
onRemoveItem={index => handleRemoveDish(group, memberLogin, member, index)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
|
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
|
||||||
{memberTotal > 0 ? `${memberTotal / 100} Kč` : '—'}
|
{memberTotal > 0 ? `${memberTotal / 100} Kč` : '—'}
|
||||||
@@ -676,7 +858,7 @@ export default function OrderGroupsPage() {
|
|||||||
return groupTotal > 0 ? (
|
return groupTotal > 0 ? (
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr style={{ fontWeight: 700, borderTop: '2px solid var(--luncher-border)' }}>
|
<tr style={{ fontWeight: 700, borderTop: '2px solid var(--luncher-border)' }}>
|
||||||
<td colSpan={4} className="text-end" style={{ fontSize: '0.9em' }}>Celkem za skupinu:</td>
|
<td colSpan={getGroupMenu(group)?.length ? 5 : 4} className="text-end" style={{ fontSize: '0.9em' }}>Celkem za skupinu:</td>
|
||||||
<td className="text-end">{groupTotal / 100} Kč</td>
|
<td className="text-end">{groupTotal / 100} Kč</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -701,7 +883,7 @@ export default function OrderGroupsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Časy objednání a doručení */}
|
{/* Časy objednání a doručení */}
|
||||||
{isOrdered && (
|
{isSubmitted && (
|
||||||
<div className="px-3 py-2 border-top">
|
<div className="px-3 py-2 border-top">
|
||||||
{!isReadOnly && isCreator && editingTimes ? (
|
{!isReadOnly && isCreator && editingTimes ? (
|
||||||
<div className="d-flex align-items-center gap-3 flex-wrap">
|
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||||||
@@ -731,13 +913,13 @@ export default function OrderGroupsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex align-items-center gap-1">
|
<div className="d-flex align-items-center gap-1">
|
||||||
<small className="text-muted text-nowrap">Bolt odkaz pro sledování:</small>
|
<small className="text-muted text-nowrap">Odkaz pro sledování (Bolt Food / Wolt):</small>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
type="text"
|
type="text"
|
||||||
size="sm"
|
size="sm"
|
||||||
placeholder={`${BOLT_SHARE_URL_PREFIX}…`}
|
placeholder={`${TRACKING_URL_PREFIX.wolt}…`}
|
||||||
value={editTimes[group.id]?.boltUrl ?? ''}
|
value={editTimes[group.id]?.trackingUrl ?? ''}
|
||||||
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], boltUrl: e.target.value } }))}
|
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], trackingUrl: e.target.value } }))}
|
||||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
|
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
|
||||||
style={{ width: 260 }}
|
style={{ width: 260 }}
|
||||||
/>
|
/>
|
||||||
@@ -750,8 +932,10 @@ export default function OrderGroupsPage() {
|
|||||||
{(() => {
|
{(() => {
|
||||||
const canEdit = !isReadOnly && isCreator;
|
const canEdit = !isReadOnly && isCreator;
|
||||||
// Aktivace editačního režimu – stejné chování jako tlačítko s tužkou
|
// Aktivace editačního režimu – stejné chování jako tlačítko s tužkou
|
||||||
const startEdit = () => canEdit && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '', boltUrl: group.boltTrackingToken ? `${BOLT_SHARE_URL_PREFIX}${group.boltTrackingToken}` : '' } }));
|
const trackingUrl = group.trackingProvider && group.trackingCode
|
||||||
const trackingUrl = group.boltTrackingToken ? `${BOLT_SHARE_URL_PREFIX}${group.boltTrackingToken}` : null;
|
? trackingUrlFor(group.trackingProvider, group.trackingCode)
|
||||||
|
: null;
|
||||||
|
const startEdit = () => canEdit && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '', trackingUrl: trackingUrl ?? '' } }));
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
@@ -775,9 +959,9 @@ export default function OrderGroupsPage() {
|
|||||||
onClick={startEdit}
|
onClick={startEdit}
|
||||||
>
|
>
|
||||||
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
|
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
|
||||||
{group.boltTrackingToken && (
|
{group.trackingCode && group.trackingProvider && (
|
||||||
<OverlayTrigger overlay={<Tooltip>Čas doručení se aktualizuje automaticky z Bolt Food</Tooltip>}>
|
<OverlayTrigger overlay={<Tooltip>Čas doručení se aktualizuje automaticky z {PROVIDER_LABEL[group.trackingProvider]}</Tooltip>}>
|
||||||
<Badge bg="success" className="ms-1">Bolt</Badge>
|
<Badge bg="success" className="ms-1">{PROVIDER_LABEL[group.trackingProvider]}</Badge>
|
||||||
</OverlayTrigger>
|
</OverlayTrigger>
|
||||||
)}
|
)}
|
||||||
</small>
|
</small>
|
||||||
@@ -802,9 +986,14 @@ export default function OrderGroupsPage() {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{group.boltOrderState && (
|
{group.trackingOrderState && group.trackingProvider && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<BoltOrderProgress state={group.boltOrderState} courierState={group.boltCourierState} tracking={!!group.boltTrackingToken} />
|
<OrderProgress
|
||||||
|
provider={group.trackingProvider}
|
||||||
|
state={group.trackingOrderState}
|
||||||
|
courierState={group.trackingCourierState}
|
||||||
|
tracking={!!group.trackingCode}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{IS_DEV && (
|
{IS_DEV && (
|
||||||
@@ -885,6 +1074,19 @@ export default function OrderGroupsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{IS_DEV && (
|
||||||
|
<GenerateMockOrdersModal
|
||||||
|
isOpen={mockOrdersModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setMockOrdersModalOpen(false);
|
||||||
|
// Data přijdou i websocketem, po založení skupiny se ale mění i dny s objednávkou
|
||||||
|
fetchData();
|
||||||
|
fetchOrderDates();
|
||||||
|
}}
|
||||||
|
hasGroups={groups.length > 0}
|
||||||
|
hasStores={stores.length > 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{IS_DEV && boltSimModal && (
|
{IS_DEV && boltSimModal && (
|
||||||
<BoltSimulationModal
|
<BoltSimulationModal
|
||||||
isOpen={!!boltSimModal}
|
isOpen={!!boltSimModal}
|
||||||
|
|||||||
@@ -46,6 +46,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-panel {
|
||||||
|
margin-top: 32px;
|
||||||
|
padding: 24px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
background: var(--luncher-bg-card);
|
||||||
|
border: 1px solid var(--luncher-border-light);
|
||||||
|
border-radius: var(--luncher-radius-lg);
|
||||||
|
box-shadow: var(--luncher-shadow-sm);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--luncher-text);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--luncher-text-secondary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Chart container
|
// Chart container
|
||||||
.recharts-wrapper {
|
.recharts-wrapper {
|
||||||
background: var(--luncher-bg-card);
|
background: var(--luncher-bg-card);
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import Header from "../components/Header";
|
|||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import Login from "../Login";
|
import Login from "../Login";
|
||||||
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils";
|
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils";
|
||||||
import { WeeklyStats, LunchChoice, getStats } from "../../../types";
|
import { WeeklyStats, LunchChoice, getStats, getStatsExport } from "../../../types";
|
||||||
import Loader from "../components/Loader";
|
import Loader from "../components/Loader";
|
||||||
import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons";
|
import { faChevronLeft, faChevronRight, faGear, faFileExcel, faFileCsv, faFileCode } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||||
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
|
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { getLunchChoiceName } from "../enums";
|
import { getLunchChoiceName } from "../enums";
|
||||||
import './StatsPage.scss';
|
import './StatsPage.scss';
|
||||||
|
import { isArrowNavigationBlocked } from '../utils/arrowNavigation';
|
||||||
|
|
||||||
const CHART_WIDTH = 1400;
|
const CHART_WIDTH = 1400;
|
||||||
const CHART_HEIGHT = 700;
|
const CHART_HEIGHT = 700;
|
||||||
@@ -28,10 +30,35 @@ const COLORS = [
|
|||||||
'#7c7c7c',
|
'#7c7c7c',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** Podporované formáty exportu přehledu (viz GET /api/stats/export). */
|
||||||
|
const EXPORT_FORMATS = ['xlsx', 'csv', 'json'] as const;
|
||||||
|
|
||||||
|
type ExportFormat = typeof EXPORT_FORMATS[number];
|
||||||
|
|
||||||
|
const EXPORT_FORMAT_LABELS: Record<ExportFormat, string> = {
|
||||||
|
xlsx: 'Excel (.xlsx)',
|
||||||
|
csv: 'CSV (.csv)',
|
||||||
|
json: 'JSON (.json)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXPORT_FORMAT_ICONS: Record<ExportFormat, IconDefinition> = {
|
||||||
|
xlsx: faFileExcel,
|
||||||
|
csv: faFileCsv,
|
||||||
|
json: faFileCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Vrátí měsíc předaného data ve formátu YYYY-MM (hodnota pro input type="month"). */
|
||||||
|
function getMonthValue(date: Date) {
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function StatsPage() {
|
export default function StatsPage() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const [dateRange, setDateRange] = useState<Date[]>();
|
const [dateRange, setDateRange] = useState<Date[]>();
|
||||||
const [data, setData] = useState<WeeklyStats>();
|
const [data, setData] = useState<WeeklyStats>();
|
||||||
|
const [exportMonth, setExportMonth] = useState<string>(() => getMonthValue(new Date()));
|
||||||
|
const [exportFormat, setExportFormat] = useState<ExportFormat>('xlsx');
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
// Prvotní nastavení aktuálního týdne
|
// Prvotní nastavení aktuálního týdne
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -53,6 +80,31 @@ export default function StatsPage() {
|
|||||||
return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
|
return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stáhne přehled stravování přihlášeného uživatele za vybraný měsíc ve vybraném formátu. */
|
||||||
|
const handleExport = async () => {
|
||||||
|
const [year, month] = exportMonth.split('-').map(Number);
|
||||||
|
if (!year || !month) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
// parseAs 'blob' — odpověď chceme stáhnout tak, jak přišla, bez parsování dle typu
|
||||||
|
const { data: file } = await getStatsExport({ query: { year, month, format: exportFormat }, parseAs: 'blob' });
|
||||||
|
// Chyby řeší globální interceptor (toaster), stahujeme jen při úspěchu
|
||||||
|
if (!(file instanceof Blob)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `luncher-${auth?.login ?? 'prehled'}-${exportMonth}.${exportFormat}`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handlePreviousWeek = () => {
|
const handlePreviousWeek = () => {
|
||||||
if (dateRange) {
|
if (dateRange) {
|
||||||
const previousStartDate = new Date(dateRange[0]);
|
const previousStartDate = new Date(dateRange[0]);
|
||||||
@@ -75,12 +127,12 @@ export default function StatsPage() {
|
|||||||
|
|
||||||
const isCurrentOrFutureWeek = useMemo(() => {
|
const isCurrentOrFutureWeek = useMemo(() => {
|
||||||
if (!dateRange) return true;
|
if (!dateRange) return true;
|
||||||
const currentWeekEnd = getLastWorkDayOfWeek(new Date());
|
// Porovnáváme jen dny (obě data nesou čas svého vzniku), dopředu jde jít nejvýše na aktuální týden
|
||||||
currentWeekEnd.setHours(23, 59, 59, 999);
|
return formatDate(dateRange[1]) >= formatDate(getLastWorkDayOfWeek(new Date()));
|
||||||
return dateRange[1] >= currentWeekEnd;
|
|
||||||
}, [dateRange]);
|
}, [dateRange]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: any) => {
|
const handleKeyDown = useCallback((e: any) => {
|
||||||
|
if (isArrowNavigationBlocked(e)) return;
|
||||||
if (e.keyCode === 37) {
|
if (e.keyCode === 37) {
|
||||||
handlePreviousWeek();
|
handlePreviousWeek();
|
||||||
} else if (e.keyCode === 39 && !isCurrentOrFutureWeek) {
|
} else if (e.keyCode === 39 && !isCurrentOrFutureWeek) {
|
||||||
@@ -128,6 +180,40 @@ export default function StatsPage() {
|
|||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Legend />
|
<Legend />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
|
<div className="export-panel">
|
||||||
|
<h3>Můj přehled</h3>
|
||||||
|
<p className="export-hint">
|
||||||
|
Přehled vašich záznamů za vybraný měsíc — datum, kde jste jedli, vybrané jídlo, poznámka
|
||||||
|
a u objednávek i částka po slevě.
|
||||||
|
</p>
|
||||||
|
<div className="export-controls">
|
||||||
|
<input
|
||||||
|
type="month"
|
||||||
|
className="form-control"
|
||||||
|
value={exportMonth}
|
||||||
|
max={getMonthValue(new Date())}
|
||||||
|
onChange={e => setExportMonth(e.target.value)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={exportFormat}
|
||||||
|
onChange={e => setExportFormat(e.target.value as ExportFormat)}
|
||||||
|
>
|
||||||
|
{EXPORT_FORMATS.map(format => (
|
||||||
|
<option key={format} value={format}>{EXPORT_FORMAT_LABELS[format]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={exporting || !exportMonth}
|
||||||
|
onClick={handleExport}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={EXPORT_FORMAT_ICONS[exportFormat]} />{' '}
|
||||||
|
{exporting ? 'Generuji...' : 'Stáhnout přehled'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Store } from '../../../types';
|
||||||
|
|
||||||
|
/** Známé dovozové služby — hostname (bez www) → čitelný název pro nabídku. */
|
||||||
|
const KNOWN_SERVICES: { match: string; label: string }[] = [
|
||||||
|
{ match: 'bolt.eu', label: 'Bolt Food' },
|
||||||
|
{ match: 'wolt.com', label: 'Wolt' },
|
||||||
|
{ match: 'foodora.cz', label: 'Foodora' },
|
||||||
|
{ match: 'damejidlo.cz', label: 'Dáme jídlo' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ověří, že jde o odkaz s protokolem http(s) — jiné protokoly (např. javascript:)
|
||||||
|
* by mohly být zneužity, proto je jako odkaz nezobrazujeme.
|
||||||
|
*/
|
||||||
|
export function isHttpUrl(url?: string): boolean {
|
||||||
|
return !!url && /^https?:\/\//i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrátí platné URL na nabídku daného obchodu. */
|
||||||
|
export function getStoreUrls(store?: Store): string[] {
|
||||||
|
return (store?.urls ?? []).filter(isHttpUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Popisek URL pro výběr nabídky — u známých dovozových služeb jejich název,
|
||||||
|
* jinak hostname odkazu (např. "restaurace-u-nas.cz").
|
||||||
|
*/
|
||||||
|
export function storeUrlLabel(url: string): string {
|
||||||
|
let hostname: string;
|
||||||
|
try {
|
||||||
|
hostname = new URL(url).hostname.toLowerCase().replace(/^www\./, '');
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
const known = KNOWN_SERVICES.find(s => hostname === s.match || hostname.endsWith(`.${s.match}`));
|
||||||
|
return known ? known.label : hostname;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ export default defineConfig({
|
|||||||
// depending on your application, base can also be "/"
|
// depending on your application, base can also be "/"
|
||||||
base: '',
|
base: '',
|
||||||
plugins: [react(), viteTsconfigPaths()],
|
plugins: [react(), viteTsconfigPaths()],
|
||||||
|
build: {
|
||||||
|
// Veřejné sourcemapy, aby Sentry umělo přeložit minifikované stack traces
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
open: true,
|
open: true,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { loginViaApi } from './helpers';
|
||||||
|
|
||||||
|
const DELIVERY_LABEL = 'Upozornění na doručení objednávky (Bolt Food / Wolt)';
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginViaApi(page, 'e2e-user');
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openSettings(page: import('@playwright/test').Page) {
|
||||||
|
await page.locator('#basic-nav-dropdown').click();
|
||||||
|
await page.locator('text=Nastavení').click();
|
||||||
|
await expect(page.locator('.modal-title')).toContainText('Nastavení', { timeout: 5_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Přepínač doručení objednávky je pod výběrem času připomínky', async ({ page }) => {
|
||||||
|
await openSettings(page);
|
||||||
|
|
||||||
|
const reminderGroup = page.locator('.modal-body .mb-3').filter({ hasText: 'Připomínka výběru oběda' });
|
||||||
|
const deliveryGroup = page.locator('.modal-body .mb-3').filter({ hasText: DELIVERY_LABEL });
|
||||||
|
await expect(reminderGroup).toBeVisible();
|
||||||
|
await expect(deliveryGroup).toBeVisible();
|
||||||
|
|
||||||
|
// Přepínač musí v DOM následovat až za skupinou s časem připomínky
|
||||||
|
const order = await page.evaluate((label) => {
|
||||||
|
const groups = Array.from(document.querySelectorAll('.modal-body .mb-3'));
|
||||||
|
return {
|
||||||
|
reminder: groups.findIndex(g => g.textContent?.includes('Připomínka výběru oběda')),
|
||||||
|
delivery: groups.findIndex(g => g.textContent?.includes(label)),
|
||||||
|
};
|
||||||
|
}, DELIVERY_LABEL);
|
||||||
|
expect(order.reminder).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(order.delivery).toBe(order.reminder + 1);
|
||||||
|
|
||||||
|
// Výchozí stav je vypnuto
|
||||||
|
await expect(page.locator('#boltDeliveredCheckbox')).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zapnutí přepínače se uloží na server a přežije znovuotevření', async ({ page }) => {
|
||||||
|
await openSettings(page);
|
||||||
|
await page.locator('#boltDeliveredCheckbox').check();
|
||||||
|
await page.locator('.modal-footer button', { hasText: 'Uložit' }).click();
|
||||||
|
await expect(page.locator('.modal')).not.toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// Server má nastavení uložené
|
||||||
|
const token = await page.evaluate(() => localStorage.getItem('token'));
|
||||||
|
const resp = await page.request.get('/api/notifications/settings', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(await resp.json()).toMatchObject({ boltDeliveredPush: true });
|
||||||
|
|
||||||
|
// Po znovunačtení stránky je přepínač stále zapnutý
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await openSettings(page);
|
||||||
|
await expect(page.locator('#boltDeliveredCheckbox')).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Vypnutí přepínače se uloží zpět', async ({ page }) => {
|
||||||
|
await openSettings(page);
|
||||||
|
await page.locator('#boltDeliveredCheckbox').check();
|
||||||
|
await page.locator('.modal-footer button', { hasText: 'Uložit' }).click();
|
||||||
|
await expect(page.locator('.modal')).not.toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
await openSettings(page);
|
||||||
|
await page.locator('#boltDeliveredCheckbox').uncheck();
|
||||||
|
await page.locator('.modal-footer button', { hasText: 'Uložit' }).click();
|
||||||
|
await expect(page.locator('.modal')).not.toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
const token = await page.evaluate(() => localStorage.getItem('token'));
|
||||||
|
const resp = await page.request.get('/api/notifications/settings', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(await resp.json()).toMatchObject({ boltDeliveredPush: false });
|
||||||
|
});
|
||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ data:
|
|||||||
REDIS_PORT: "6379"
|
REDIS_PORT: "6379"
|
||||||
PORT: "3001"
|
PORT: "3001"
|
||||||
HOST: "0.0.0.0"
|
HOST: "0.0.0.0"
|
||||||
|
LOBBY_URL: "http://lobby.localhost"
|
||||||
@@ -33,7 +33,10 @@
|
|||||||
# V případě zapnutí je nutno vyplnit také HTTP_REMOTE_TRUSTED_IPS.
|
# V případě zapnutí je nutno vyplnit také HTTP_REMOTE_TRUSTED_IPS.
|
||||||
# HTTP_REMOTE_USER_ENABLED=true
|
# HTTP_REMOTE_USER_ENABLED=true
|
||||||
|
|
||||||
# Seznam IP adres nebo rozsahů oddělených čárkou, ze kterých budou akceptovány důvěryhodné hlavičky.
|
# Seznam IP adres nebo rozsahů (CIDR, IPv4 i IPv6) oddělených čárkou, ze kterých budou akceptovány důvěryhodné hlavičky.
|
||||||
|
# Rozhoduje skutečná adresa spojení (tj. proxy před aplikací, ne klient), hlavička od jiné adresy se ignoruje.
|
||||||
|
# Uveďte co nejužší rozsah — jen adresu/síť proxy (v Kubernetes síť podů, např. 10.42.0.0/16 u RKE2), ne celé privátní rozsahy.
|
||||||
|
# Neplatná položka = server nenaběhne.
|
||||||
# HTTP_REMOTE_TRUSTED_IPS=127.0.0.1,192.168.1.0/24
|
# HTTP_REMOTE_TRUSTED_IPS=127.0.0.1,192.168.1.0/24
|
||||||
|
|
||||||
# Název důvěryhodné hlavičky obsahující login uživatele. Výchozí hodnota je 'remote-user'.
|
# Název důvěryhodné hlavičky obsahující login uživatele. Výchozí hodnota je 'remote-user'.
|
||||||
@@ -49,10 +52,38 @@
|
|||||||
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
|
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
|
||||||
# REFRESH_BYPASS_PASSWORD=
|
# REFRESH_BYPASS_PASSWORD=
|
||||||
|
|
||||||
|
# Adresa aplikace Lobby s minihrami. Skrytý odkaz (3× kliknutí na logo) ji otevře v nové záložce.
|
||||||
|
# Bez vyplnění 3× kliknutí jen čeká na kód hry, která zůstala v Luncheru (kreslení).
|
||||||
|
# LOBBY_URL='https://lobby.example.com'
|
||||||
|
|
||||||
# Admin heslo pro správu seznamu obchodů na stránce /objednani.
|
# Admin heslo pro správu seznamu obchodů na stránce /objednani.
|
||||||
# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403).
|
# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403).
|
||||||
# ADMIN_PASSWORD=
|
# ADMIN_PASSWORD=
|
||||||
|
|
||||||
# Interval (ms) scheduleru sledování objednávek Bolt Food. Výchozí 60000 (60 s).
|
# Interval (ms) scheduleru sledování objednávek (Bolt Food, Wolt). Výchozí 60000 (60 s).
|
||||||
# Pro vývoj se simulací lze zkrátit (min. 1000), aby se změny stavu projevily rychleji.
|
# Pro vývoj se simulací lze zkrátit (min. 1000), aby se změny stavu projevily rychleji.
|
||||||
# BOLT_POLL_INTERVAL_MS=3000
|
# BOLT_POLL_INTERVAL_MS=3000
|
||||||
|
|
||||||
|
# Sentry (volitelné) — když není vyplněno, Sentry se vůbec neaktivuje.
|
||||||
|
# Server hlásí chyby (5xx, pády, console.error/warn); console.log se k chybám přikládá jako breadcrumbs.
|
||||||
|
# SENTRY_DSN=https://...@sentry.example.com/1
|
||||||
|
# DSN pro prohlížeč (klient si ho vyzvedne z GET /api/config). Když není vyplněno, použije se SENTRY_DSN.
|
||||||
|
# SENTRY_CLIENT_DSN=https://...@sentry.example.com/2
|
||||||
|
# Interní logy Sentry SDK do konzole — pro ladění, když eventy nedorazí do Sentry.
|
||||||
|
# SENTRY_DEBUG=true
|
||||||
|
|
||||||
|
# Limity skupinových objednávek (pojistky proti překlepům, ne ochrana proti zlému úmyslu).
|
||||||
|
# Nevyplněné nebo neplatné hodnoty (ne celé kladné číslo) = výchozí hodnota. Klient je dostává přes GET /api/config.
|
||||||
|
# Max. délka poznámky člena (co si objednává), výchozí 300 znaků.
|
||||||
|
# GROUP_MAX_NOTE_LENGTH=300
|
||||||
|
# Max. délka popisu příplatku, výchozí 100 znaků.
|
||||||
|
# GROUP_MAX_SURCHARGE_TEXT_LENGTH=100
|
||||||
|
# Max. délka názvu skupiny, výchozí 100 znaků.
|
||||||
|
# GROUP_MAX_NAME_LENGTH=100
|
||||||
|
# Max. délka URL nabídky / odkazu na sledování, výchozí 500 znaků.
|
||||||
|
# GROUP_MAX_URL_LENGTH=500
|
||||||
|
# Max. výše jedné částky (útrata člena, příplatek, poplatky, doprava, spropitné, pevná sleva) v Kč, výchozí 10000.
|
||||||
|
# GROUP_MAX_AMOUNT_KC=10000
|
||||||
|
# Výběr z nabídky: max. kusů jedné položky (výchozí 10) a max. položek celkem (výchozí 20).
|
||||||
|
# GROUP_MAX_ITEM_QUANTITY=10
|
||||||
|
# GROUP_MAX_ITEMS=20
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
"Oprava: uložení preferovaného času odchodu i u podniku bez načteného menu",
|
||||||
|
"Oprava: načítání denního menu podniku TechTower po změně jejich webu",
|
||||||
|
"Oprava: rychlé klikání na šipky přepínání dní už nepřeskočí mimo pracovní týden",
|
||||||
|
"Letní vzhled: místo padajícího sněhu teď po stránce poletují motýli"
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
"Oprava zobrazení stavu objednávky Bolt Food — objednávka čekající na přijetí podnikem se už nezobrazuje jako v přípravě"
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
"Chytání motýlků: uchop myší síťku a lov poletující motýly – počet ulovených se ti počítá a ukládá"
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[
|
||||||
|
"Chytání motýlků má teď mince, úrovně a ukládá se na server, takže o úlovky nepřijdeš ani po přihlášení na jiném zařízení",
|
||||||
|
"Za nachytané motýly dostáváš mince a po dosažení milníku se ti na chvíli aktivuje zlatá síťka – větší, magnetická a odolná proti ptákům",
|
||||||
|
"Vzácný zlatý motýl teď opravdu září a má vyšší hodnotu",
|
||||||
|
"Vosy postupně přibývají a otravují síťku – zaklikej je (plácačka), jinak chvíli nepůjde chytat",
|
||||||
|
"Ptáci přibývají a trhají síťku (protrhnou ji vždy); protržená síťka se sama zašije za minutu, nebo si připlatíš za okamžitou opravu",
|
||||||
|
"Můžeš si koupit plašič, který ptáky na chvíli vyžene",
|
||||||
|
"Vosy i ptáci se počítají na serveru, takže je nevynuluješ obnovením stránky",
|
||||||
|
"Denní bonus, kombo za rychlé chytání, týmový žebříček a osobní statistiky (zlatí motýli, zabité vosy, vyplašení ptáci) po kliknutí na počítadlo",
|
||||||
|
"Hra je celkově těžší: mnohem víc úrovní se strmější křivkou, dražší vylepšení a vzácnější zlatý motýl",
|
||||||
|
"Ptáci teď aktivně nalétávají na síťku a škůdců je víc",
|
||||||
|
"Občas přijde zloděj, který se plíží k tvým mincím – zaklikej ho, než ti je ukradne (chce to hodně ran)",
|
||||||
|
"Pozor i na housenku, která se plíží k úlovkům a umí ti sežrat nachytané motýly (i snížit úroveň)",
|
||||||
|
"Přidána ochrana proti podvádění (zmenšování okna) – rychlost chytání je omezená",
|
||||||
|
"Nová sezóna: statistiky začínají férově od nuly pro všechny",
|
||||||
|
"Oprava: v nasazené verzi se v počítadle vlevo dole opět zobrazuje ikonka motýlka"
|
||||||
|
]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[
|
||||||
|
"Nový obchod (tlačítko 🛒): utrácej mince za pomůcky i trvalá vylepšení – větší síťka, strašák na ptáky, zpevněná síťka, prémiová síťka na přání, vosí sprej, plašič a pojistka proti zloději",
|
||||||
|
"Pozor na černé můry – míchají se mezi motýly a když nějakou omylem chytíš (i magnetem), přijdeš o hodně úlovků",
|
||||||
|
"Ptáci jsou znovu výzva: je jich víc, přibývají rychleji a plašič je dražší a kratší",
|
||||||
|
"Zloději chodí častěji a lákají je bohatí hráči – vyplatí se koupit pojistku",
|
||||||
|
"Denní úkol za odměnu mincí a odznaky (achievementy) za milníky – najdeš je po kliknutí na počítadlo",
|
||||||
|
"V noci občas přilétne netopýr, který loví motýly, a pavouk umí zamotat síťku pavučinou – odeženeš je klikáním"
|
||||||
|
]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[
|
||||||
|
"Přepínač Hraní / Objednávání: ve výchozím režimu motýli jen poletují a nic neruší objednávání; hru zapneš tlačítkem vpravo dole a herní vrstva pak odstíní kliknutí, aby ses při hraní neproklikl do objednávky",
|
||||||
|
"Vylepšení „větší síťka“ teď síťku i viditelně zvětší (a projeví se hned po koupi)",
|
||||||
|
"Občas proběhne rychlá motýlí inspekce – chyť označeného zářícího motýla; férových hráčů se to skoro nedotkne, na roboty ale platí (a recidivisty čeká i chvilka ve vězení)"
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
"Upozornění na doručení skupinové objednávky sledované přes Bolt Food (zapíná se v Nastavení → Notifikace)"
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
"Na stránce statistik lze stáhnout vlastní přehled stravování za vybraný měsíc ve formátu Excel, CSV nebo JSON (včetně vybraného jídla, poznámky a částky u objednávek po slevě)"
|
||||||
|
]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[
|
||||||
|
"Sledování objednávky nově i přes Wolt — stačí u objednané skupiny vložit odkaz z track.wolt.com",
|
||||||
|
"Upozornění na doručení objednávky nyní chodí i u objednávek sledovaných přes Wolt"
|
||||||
|
]
|
||||||