Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa7e2772a7 |
@@ -4,7 +4,3 @@ types/gen
|
||||
.mcp.json
|
||||
.claude/settings.local.json
|
||||
server/public/
|
||||
.claude/*.lock
|
||||
.claude/worktrees
|
||||
.playwright-mcp
|
||||
.idea/
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Server (ts-node, debug)",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}/server",
|
||||
"runtimeArgs": ["-r", "ts-node/register"],
|
||||
"program": "${workspaceFolder}/server/src/index.ts",
|
||||
"env": { "NODE_ENV": "development" },
|
||||
"console": "integratedTerminal",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"preLaunchTask": "types: openapi-ts"
|
||||
},
|
||||
{
|
||||
"name": "Client (vite + Edge)",
|
||||
"type": "msedge",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000",
|
||||
"webRoot": "${workspaceFolder}/client",
|
||||
"preLaunchTask": "client: vite"
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Dev: server + client",
|
||||
"configurations": ["Server (ts-node, debug)", "Client (vite + Edge)"],
|
||||
"stopAll": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "types: openapi-ts",
|
||||
"type": "shell",
|
||||
"command": "yarn openapi-ts",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/types"
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "silent",
|
||||
"panel": "dedicated"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "server: startReload",
|
||||
"type": "shell",
|
||||
"command": "yarn startReload",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/server",
|
||||
"env": {
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"isBackground": true,
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"group": "dev"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "client: vite",
|
||||
"type": "shell",
|
||||
"command": "yarn start",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/client"
|
||||
},
|
||||
"isBackground": true,
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"group": "dev"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "dev: server+client",
|
||||
"dependsOn": [
|
||||
"server: startReload",
|
||||
"client: vite"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "dev: all",
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"types: openapi-ts",
|
||||
"dev: server+client"
|
||||
],
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -126,21 +126,3 @@ Prettier is installed in `client/` (devDependency only, no script or config) —
|
||||
- TypeScript strict mode in both client and server
|
||||
- Server module resolution: Node16; Client: ESNext/bundler
|
||||
- `TODO.md` tracks open bugs and roadmap items — worth scanning before starting non-trivial work
|
||||
|
||||
## Changelog (user-facing "What's new")
|
||||
|
||||
When you add or change a user-visible feature, you MUST also record it in `server/changelogs/` so users see it via the in-app changelog (served by `server/src/routes/changelogRoutes.ts`, sorted newest-first with a `?since=` filter).
|
||||
|
||||
Steps:
|
||||
|
||||
1. Create or open the file for today's date: `server/changelogs/YYYY-MM-DD.json` (use today's date; one file per day).
|
||||
2. The file content is a **JSON array of Czech strings**, one short user-facing sentence per change. Example:
|
||||
```json
|
||||
[
|
||||
"Proklik na nabídku podniku ze stránky objednávek",
|
||||
"Možnost přidat URL pro sledování stavu doručení pro Bolt Food"
|
||||
]
|
||||
```
|
||||
3. If a file for today already exists, append your entry to its array instead of creating a new file.
|
||||
4. Write entries in Czech, phrased for end users (describe the benefit, not the implementation). Skip purely internal/refactor changes that users won't notice.
|
||||
5. The route caches files in memory at read time, so a server restart is needed to pick up new/changed changelogs in a running dev instance.
|
||||
|
||||
@@ -76,7 +76,7 @@ WORKDIR /app
|
||||
# Export /data/db.json do složky /data
|
||||
VOLUME ["/data"]
|
||||
|
||||
EXPOSE 3001
|
||||
EXPOSE 3000
|
||||
|
||||
CMD [ "node", "./server/src/index.js" ]
|
||||
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
# TODO
|
||||
|
||||
## HA / multi-replica follow-ups
|
||||
- [ ] `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
|
||||
- [ ] `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
|
||||
- [ ] 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í)
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^3.1.0",
|
||||
"@sentry/react": "^10.65.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.2",
|
||||
@@ -19,7 +18,6 @@
|
||||
"bootstrap": "^5.3.8",
|
||||
"react": "^19.2.0",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-datepicker": "^9.1.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-jwt": "^1.3.0",
|
||||
"react-modal": "^3.16.3",
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 716 B |
@@ -1,26 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,18 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 898 B |
@@ -1,24 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,66 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1,34 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,79 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1,74 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 2.9 KiB |
@@ -1,106 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 2.9 KiB |
@@ -1,24 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,24 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,24 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,22 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,27 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,4 +1,4 @@
|
||||
// Service Worker pro Web Push notifikace (připomínka výběru oběda, doručení objednávky)
|
||||
// Service Worker pro Web Push notifikace (připomínka výběru oběda)
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
const data = event.data?.json() ?? { title: 'Luncher', body: 'Ještě nemáte zvolený oběd!' };
|
||||
@@ -6,12 +6,11 @@ self.addEventListener('push', (event) => {
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: '/favicon.ico',
|
||||
tag: data.tag ?? 'lunch-reminder',
|
||||
data: { login: data.login, token: data.token, url: data.url },
|
||||
// Token posílá jen připomínka oběda — ostatní notifikace tlačítko nemají.
|
||||
actions: data.token
|
||||
? [{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' }]
|
||||
: [],
|
||||
tag: 'lunch-reminder',
|
||||
data: { login: data.login },
|
||||
actions: [
|
||||
{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -20,29 +19,27 @@ self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'neobedvam') {
|
||||
const { login, token } = event.notification.data ?? {};
|
||||
if (login && token) {
|
||||
const login = event.notification.data?.login;
|
||||
if (login) {
|
||||
event.waitUntil(
|
||||
fetch('/api/notifications/push/quickChoice', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ login, token }),
|
||||
body: JSON.stringify({ login }),
|
||||
})
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const url = event.notification.data?.url ?? '/';
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||
if ('navigate' in client) client.navigate(url).catch(() => {});
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
return self.clients.openWindow(url);
|
||||
return self.clients.openWindow('/');
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -226,7 +226,6 @@ body {
|
||||
&:hover svg {
|
||||
transform: rotate(15deg);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -279,105 +278,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
// Varianta navigace mezi dny na stránce objednávání – šipky kolem date pickeru
|
||||
.order-day-navigator {
|
||||
margin-bottom: 16px;
|
||||
gap: 16px;
|
||||
|
||||
// react-datepicker obaluje input do wrapperu – necháme ho zabrat jen potřebnou šířku
|
||||
.react-datepicker-wrapper {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.order-date-input {
|
||||
width: 160px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
// Zvýraznění dnů, ve kterých existuje alespoň jedna objednávka – tečka pod číslem dne
|
||||
.react-datepicker__day.luncher-order-day {
|
||||
position: relative;
|
||||
font-weight: 700;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 2px;
|
||||
transform: translateX(-50%);
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--luncher-primary, #0d6efd);
|
||||
}
|
||||
|
||||
// U vybraného dne (tmavé pozadí) je tečka světlá, aby byla vidět
|
||||
&.react-datepicker__day--selected::after,
|
||||
&.react-datepicker__day--keyboard-selected::after {
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// Vybraný den používá akcentovou barvu aplikace (v obou režimech), místo výchozí modré
|
||||
.react-datepicker__day--selected,
|
||||
.react-datepicker__day--keyboard-selected {
|
||||
background-color: var(--luncher-primary);
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--luncher-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
// Tmavý režim kalendáře (react-datepicker) – navázáno na CSS proměnné motivu
|
||||
[data-bs-theme="dark"] {
|
||||
.react-datepicker {
|
||||
background-color: var(--luncher-bg-card);
|
||||
border-color: var(--luncher-border);
|
||||
color: var(--luncher-text);
|
||||
}
|
||||
|
||||
.react-datepicker__header {
|
||||
background-color: var(--luncher-bg-hover);
|
||||
border-bottom-color: var(--luncher-border);
|
||||
}
|
||||
|
||||
.react-datepicker__current-month,
|
||||
.react-datepicker__day-name,
|
||||
.react-datepicker__day,
|
||||
.react-datepicker-year-header {
|
||||
color: var(--luncher-text);
|
||||
}
|
||||
|
||||
.react-datepicker__day:hover,
|
||||
.react-datepicker__month-text:hover {
|
||||
background-color: var(--luncher-bg-hover);
|
||||
}
|
||||
|
||||
.react-datepicker__day--today {
|
||||
color: var(--luncher-primary);
|
||||
}
|
||||
|
||||
.react-datepicker__day--disabled,
|
||||
.react-datepicker__day--outside-month {
|
||||
color: var(--luncher-text-muted);
|
||||
}
|
||||
|
||||
// Šipky pro přepínání měsíců
|
||||
.react-datepicker__navigation-icon::before {
|
||||
border-color: var(--luncher-text-secondary);
|
||||
}
|
||||
|
||||
// Špička popoveru (SVG) míří do hlavičky – sladíme barvy.
|
||||
// !important kvůli vyšší specificitě knihovního pravidla [data-placement].
|
||||
.react-datepicker__triangle {
|
||||
fill: var(--luncher-bg-hover) !important;
|
||||
color: var(--luncher-bg-hover) !important;
|
||||
stroke: var(--luncher-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// FOOD TABLES - CARD STYLE
|
||||
// ============================================
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import { EVENT_DISCONNECT, EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from './context/socket';
|
||||
import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket';
|
||||
import { useAuth } from './context/auth';
|
||||
import Login from './Login';
|
||||
import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap';
|
||||
@@ -13,15 +13,13 @@ import './App.scss';
|
||||
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
|
||||
import { useSettings } from './context/settings';
|
||||
import Footer from './components/Footer';
|
||||
import { faArrowUpRightFromSquare, faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
||||
import Loader from './components/Loader';
|
||||
import { getHumanDateTime, isInTheFuture } from './Utils';
|
||||
import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils';
|
||||
import NoteModal from './components/modals/NoteModal';
|
||||
import PayForAllModal from './components/modals/PayForAllModal';
|
||||
import PendingPayments from './components/PendingPayments';
|
||||
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, 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, dismissQr, generateQr } from '../../types';
|
||||
import { getLunchChoiceName } from './enums';
|
||||
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
|
||||
// import './FallingLeaves.scss';
|
||||
@@ -61,7 +59,6 @@ const EASTER_EGG_DEFAULT_DURATION = 0.75;
|
||||
function App() {
|
||||
const auth = useAuth();
|
||||
const settings = useSettings();
|
||||
const navigate = useNavigate();
|
||||
const [easterEgg, _] = useEasterEgg(auth);
|
||||
const [isConnected, setIsConnected] = useState<boolean>(false);
|
||||
const [data, setData] = useState<ClientData>();
|
||||
@@ -135,40 +132,14 @@ function App() {
|
||||
setData(newData);
|
||||
}
|
||||
});
|
||||
socket.on(EVENT_PENDING_QR, (pendingQr: PendingQr) => {
|
||||
setData(prev => prev ? { ...prev, pendingQrs: [...(prev.pendingQrs ?? []), pendingQr] } : prev);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off(EVENT_CONNECT);
|
||||
socket.off(EVENT_DISCONNECT);
|
||||
socket.off(EVENT_MESSAGE);
|
||||
socket.off(EVENT_PENDING_QR);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
// Připojení do osobní socket místnosti po přihlášení
|
||||
useEffect(() => {
|
||||
if (auth?.login) {
|
||||
socket.emit('join', auth.login);
|
||||
}
|
||||
}, [auth?.login, socket]);
|
||||
|
||||
// Po znovupřipojení socketu znovu vstoupit do místnosti a obnovit data
|
||||
useEffect(() => {
|
||||
const onReconnect = () => {
|
||||
if (auth?.login) socket.emit('join', auth.login);
|
||||
getData({ query: { dayIndex: dayIndexRef.current } }).then(response => {
|
||||
if (response.data) {
|
||||
setData(response.data);
|
||||
setFood(response.data.menus);
|
||||
}
|
||||
});
|
||||
};
|
||||
socket.io.on('reconnect', onReconnect);
|
||||
return () => { socket.io.off('reconnect', onReconnect); };
|
||||
}, [socket, auth?.login]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth?.login || !data?.choices) {
|
||||
return
|
||||
@@ -467,7 +438,7 @@ function App() {
|
||||
data.pizzaList?.forEach((pizza, index) => {
|
||||
const group: SelectSearchOption = { name: pizza.name, type: "group", items: [] }
|
||||
pizza.sizes.forEach((size, sizeIndex) => {
|
||||
const name = `${size.size} (${size.price / 100} Kč)`;
|
||||
const name = `${size.size} (${size.price} Kč)`;
|
||||
const value = `pizza|${index}|${sizeIndex}`;
|
||||
group.items?.push({ name, value });
|
||||
})
|
||||
@@ -476,7 +447,7 @@ function App() {
|
||||
if (data.salatList?.length) {
|
||||
const salatGroup: SelectSearchOption = { name: "Saláty", type: "group", items: [] }
|
||||
data.salatList.forEach((salat, index) => {
|
||||
salatGroup.items?.push({ name: `${salat.name} (${salat.price / 100} Kč)`, value: `salat|${index}` });
|
||||
salatGroup.items?.push({ name: `${salat.name} (${salat.price} Kč)`, value: `salat|${index}` });
|
||||
});
|
||||
suggestions.push(salatGroup);
|
||||
}
|
||||
@@ -513,7 +484,7 @@ function App() {
|
||||
}
|
||||
|
||||
const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
if (choiceRef.current?.value) {
|
||||
if (foodChoiceList?.length && choiceRef.current?.value) {
|
||||
await changeDepartureTime({ body: { time: event.target.value as DepartureTime, dayIndex } });
|
||||
}
|
||||
}
|
||||
@@ -528,8 +499,7 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDayChange = async (requestedIndex: number) => {
|
||||
const dayIndex = Math.max(0, Math.min(4, requestedIndex));
|
||||
const handleDayChange = async (dayIndex: number) => {
|
||||
setDayIndex(dayIndex);
|
||||
dayIndexRef.current = dayIndex;
|
||||
if (choiceRef?.current?.value) {
|
||||
@@ -715,15 +685,15 @@ function App() {
|
||||
{locationPickCount >= 2 && auth.login && loginObject[auth.login] !== undefined
|
||||
&& locationKey !== LunchChoice.PIZZA && locationKey !== LunchChoice.NEOBEDVAM && locationKey !== LunchChoice.ROZHODUJI
|
||||
&& settings?.bankAccount && settings?.holderName && (
|
||||
<span title='Zaplatit za všechny a vygenerovat QR kódy ostatním' className="ms-2">
|
||||
<FontAwesomeIcon
|
||||
icon={faMoneyBillTransfer}
|
||||
onClick={() => setPayForAllLocationKey(locationKey)}
|
||||
className='action-icon'
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span title='Zaplatit za všechny a vygenerovat QR kódy ostatním' className="ms-2">
|
||||
<FontAwesomeIcon
|
||||
icon={faMoneyBillTransfer}
|
||||
onClick={() => setPayForAllLocationKey(locationKey)}
|
||||
className='action-icon'
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className='p-0'>
|
||||
<Table className="nested-table">
|
||||
@@ -751,9 +721,6 @@ function App() {
|
||||
markAsBuyer();
|
||||
}} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{ cursor: 'pointer' }} />
|
||||
</span>}
|
||||
{login === auth.login && locationKey === LunchChoice.OBJEDNAVAM && <span title='Přejít na stránku objednávek'>
|
||||
<FontAwesomeIcon onClick={() => navigate('/objednani')} icon={faArrowUpRightFromSquare} className='action-icon' style={{ cursor: 'pointer' }} />
|
||||
</span>}
|
||||
{login !== auth.login && locationKey === LunchChoice.OBJEDNAVAM && isBuyer && <span title='Objednávající'>
|
||||
<FontAwesomeIcon onClick={() => {
|
||||
copyNote(userPayload.note!);
|
||||
@@ -778,11 +745,9 @@ function App() {
|
||||
</div>
|
||||
{userChoices && userChoices.length > 0 && food && (
|
||||
<div className="food-choices">
|
||||
{userChoices
|
||||
.filter(foodIndex => food[key as Restaurant]?.food?.[foodIndex] != null)
|
||||
.map(foodIndex => {
|
||||
{userChoices.map(foodIndex => {
|
||||
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">
|
||||
<span className="food-choice-name">{foodName}</span>
|
||||
{login === auth.login && canChangeChoice &&
|
||||
@@ -905,21 +870,48 @@ function App() {
|
||||
</div>
|
||||
}
|
||||
<PizzaOrderList state={data.pizzaDay.state!} orders={data.pizzaDay.orders!} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator!} />
|
||||
{
|
||||
data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr && (() => {
|
||||
const pizzaQr = data.pendingQrs?.find(qr => qr.creator === data.pizzaDay?.creator);
|
||||
return pizzaQr ? (
|
||||
<div className='qr-code'>
|
||||
<h3>QR platba</h3>
|
||||
<img src={`/api/qr?login=${auth.login}&id=${pizzaQr.id}`} alt='QR kód' />
|
||||
</div>
|
||||
) : null;
|
||||
})()
|
||||
}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<PendingPayments
|
||||
pendingQrs={data.pendingQrs}
|
||||
login={auth.login}
|
||||
onDismissed={async () => {
|
||||
const response = await getData({ query: { dayIndex } });
|
||||
if (response.data) {
|
||||
setData(response.data);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{data.pendingQrs && data.pendingQrs.length > 0 &&
|
||||
<div className='pizza-section fade-in mt-4'>
|
||||
<h3>Nevyřízené platby</h3>
|
||||
<p>Máte neuhrazené platby.</p>
|
||||
{data.pendingQrs.map(qr => (
|
||||
<div key={qr.id} className='qr-code mb-3'>
|
||||
<p>
|
||||
<strong>{formatDateString(qr.date)}</strong> — {qr.creator} ({qr.totalPrice} Kč)
|
||||
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
|
||||
</p>
|
||||
<img src={`/api/qr?login=${auth.login}&id=${qr.id}`} alt='QR kód' />
|
||||
<div className='mt-2'>
|
||||
<Button variant="success" onClick={async () => {
|
||||
await dismissQr({ body: { id: qr.id } });
|
||||
const response = await getData({ query: { dayIndex } });
|
||||
if (response.data) {
|
||||
setData(response.data);
|
||||
}
|
||||
}}>
|
||||
Zaplatil jsem
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
</div>
|
||||
{/* <FallingLeaves
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { ProvideSettings } from "./context/settings";
|
||||
import FlyingButterflies, { BUTTERFLY_PRESETS } from "./FlyingButterflies";
|
||||
import "./FlyingButterflies.scss";
|
||||
// import Snowfall from "react-snowfall";
|
||||
import { SnowOverlay } from 'react-snow-overlay';
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { SocketContext, socket } from "./context/socket";
|
||||
import StatsPage from "./pages/StatsPage";
|
||||
import OrderGroupsPage from "./pages/OrderGroupsPage";
|
||||
import SuggestionsPage from "./pages/SuggestionsPage";
|
||||
import ExtraPage from "./pages/ExtraPage";
|
||||
import App from "./App";
|
||||
|
||||
export const STATS_URL = '/stats';
|
||||
export const OBJEDNANI_URL = '/objednani';
|
||||
export const NAVRHY_URL = '/navrhy';
|
||||
export const VECERE_URL = '/vecere';
|
||||
|
||||
export default function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path={STATS_URL} element={<StatsPage />} />
|
||||
<Route path={NAVRHY_URL} element={
|
||||
<ProvideSettings>
|
||||
<SuggestionsPage />
|
||||
</ProvideSettings>
|
||||
} />
|
||||
<Route path={OBJEDNANI_URL} element={
|
||||
<Route path={VECERE_URL} element={
|
||||
<ProvideSettings>
|
||||
<SocketContext.Provider value={socket}>
|
||||
<OrderGroupsPage />
|
||||
<ExtraPage />
|
||||
<ToastContainer />
|
||||
</SocketContext.Provider>
|
||||
</ProvideSettings>
|
||||
@@ -34,7 +27,13 @@ export default function AppRoutes() {
|
||||
<ProvideSettings>
|
||||
<SocketContext.Provider value={socket}>
|
||||
<>
|
||||
<FlyingButterflies numButterflies={BUTTERFLY_PRESETS.NORMAL} />
|
||||
{/* <Snowfall style={{
|
||||
zIndex: 2,
|
||||
position: 'fixed',
|
||||
width: '100vw',
|
||||
height: '100vh'
|
||||
}} /> */}
|
||||
<SnowOverlay color={'rgba(240, 240, 240, 0.9)'} disabledOnSingleCpuDevices={true} />
|
||||
<App />
|
||||
</>
|
||||
<ToastContainer />
|
||||
|
||||
@@ -1,749 +0,0 @@
|
||||
.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; }
|
||||
}
|
||||
@@ -109,17 +109,4 @@ export function getHumanDate(date: Date) {
|
||||
export function formatDateString(dateString: string): string {
|
||||
const [year, month, day] = dateString.split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Očistí zprávu (účel platby) pro QR platbu – musí odpovídat serverové logice (qr.ts):
|
||||
* transliteruje diakritiku na základní písmena (š→s, č→c, ...), odstraní znaky mimo
|
||||
* ISO 8859-1 a hvězdičku (oddělovač polí v QR platbě) a ořízne na max. 60 znaků.
|
||||
*/
|
||||
export function sanitizeQrMessage(message: string): string {
|
||||
const sanitized = message
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // diakritika → základní písmeno
|
||||
.replace(/[^\x00-\xff]/g, '') // znaky mimo ISO 8859-1
|
||||
.replace(/\*/g, ''); // '*' je v QR platbě oddělovač
|
||||
return sanitized.length > 60 ? sanitized.substring(0, 60) : sanitized;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
.bolt-progress {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
max-width: 400px;
|
||||
|
||||
.bolt-step {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex: 1;
|
||||
min-width: 64px;
|
||||
|
||||
.bolt-dot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--luncher-border, #ced4da);
|
||||
}
|
||||
|
||||
.bolt-label {
|
||||
margin-top: 2px;
|
||||
font-size: 0.7em;
|
||||
color: var(--luncher-text-muted, #6c757d);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Spojnice k předchozímu kroku – vede od středu této tečky doleva ke středu předchozí.
|
||||
// Tečka má průměr 12px, takže její střed je 6px od levého okraje segmentu.
|
||||
&:not(:first-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: calc(6px - 100%);
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--luncher-border, #ced4da);
|
||||
}
|
||||
|
||||
&.done {
|
||||
.bolt-dot {
|
||||
background: var(--bs-success, #198754);
|
||||
}
|
||||
|
||||
&:not(:first-child)::before {
|
||||
background: var(--bs-success, #198754);
|
||||
}
|
||||
}
|
||||
|
||||
&.active .bolt-label {
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
// Pulzování aktivního kroku, dokud sledování běží
|
||||
&.live .bolt-step.active .bolt-dot {
|
||||
animation: bolt-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bolt-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 5px rgba(25, 135, 84, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,104 +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,
|
||||
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 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,
|
||||
};
|
||||
|
||||
/** 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 (/accept|pending|created/.test(s)) return 0;
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,15 +3,15 @@ import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
|
||||
import { useAuth } from "../context/auth";
|
||||
import SettingsModal from "./modals/SettingsModal";
|
||||
import { useSettings, ThemePreference } from "../context/settings";
|
||||
import HuePicker from "./HuePicker";
|
||||
import FeaturesVotingModal from "./modals/FeaturesVotingModal";
|
||||
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
||||
import RefreshMenuModal from "./modals/RefreshMenuModal";
|
||||
import GenerateQrModal from "./modals/GenerateQrModal";
|
||||
import GenerateMockDataModal from "./modals/GenerateMockDataModal";
|
||||
import ClearMockDataModal from "./modals/ClearMockDataModal";
|
||||
import { useNavigate } from "react-router";
|
||||
import { STATS_URL, OBJEDNANI_URL, NAVRHY_URL } from "../AppRoutes";
|
||||
import { LunchChoices, getChangelogs } from "../../../types";
|
||||
import { STATS_URL, VECERE_URL } from "../AppRoutes";
|
||||
import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
|
||||
import { formatDateString } from "../Utils";
|
||||
@@ -30,6 +30,7 @@ export default function Header({ choices, dayIndex }: Props) {
|
||||
const settings = useSettings();
|
||||
const navigate = useNavigate();
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
|
||||
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
|
||||
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
|
||||
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
|
||||
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
|
||||
@@ -37,8 +38,35 @@ export default function Header({ choices, dayIndex }: Props) {
|
||||
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
|
||||
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
|
||||
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
|
||||
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
|
||||
|
||||
const effectiveDark = settings?.effectiveDark ?? false;
|
||||
// Zjistíme aktuální efektivní téma (pro zobrazení správné ikony)
|
||||
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
|
||||
|
||||
useEffect(() => {
|
||||
const updateEffectiveTheme = () => {
|
||||
if (settings?.themePreference === 'system') {
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
setEffectiveTheme(isDark ? 'dark' : 'light');
|
||||
} else {
|
||||
setEffectiveTheme(settings?.themePreference || 'light');
|
||||
}
|
||||
};
|
||||
|
||||
updateEffectiveTheme();
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addEventListener('change', updateEffectiveTheme);
|
||||
return () => mediaQuery.removeEventListener('change', updateEffectiveTheme);
|
||||
}, [settings?.themePreference]);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth?.login) {
|
||||
getVotes().then(response => {
|
||||
setFeatureVotes(response.data);
|
||||
})
|
||||
}
|
||||
}, [auth?.login]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth?.login) return;
|
||||
@@ -57,6 +85,10 @@ export default function Header({ choices, dayIndex }: Props) {
|
||||
setSettingsModalOpen(false);
|
||||
}
|
||||
|
||||
const closeVotingModal = () => {
|
||||
setVotingModalOpen(false);
|
||||
}
|
||||
|
||||
const closePizzaModal = () => {
|
||||
setPizzaModalOpen(false);
|
||||
}
|
||||
@@ -78,7 +110,8 @@ export default function Header({ choices, dayIndex }: Props) {
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newTheme: ThemePreference = effectiveDark ? 'light' : 'dark';
|
||||
// Přepínáme mezi light a dark (ignorujeme system pro jednoduchost)
|
||||
const newTheme: ThemePreference = effectiveTheme === 'dark' ? 'light' : 'dark';
|
||||
settings?.setThemePreference(newTheme);
|
||||
}
|
||||
|
||||
@@ -143,6 +176,17 @@ export default function Header({ choices, dayIndex }: Props) {
|
||||
closeSettingsModal();
|
||||
}
|
||||
|
||||
const saveFeatureVote = async (option: FeatureRequest, active: boolean) => {
|
||||
await updateVote({ body: { option, active } });
|
||||
const votes = [...featureVotes || []];
|
||||
if (active) {
|
||||
votes.push(option);
|
||||
} else {
|
||||
votes.splice(votes.indexOf(option), 1);
|
||||
}
|
||||
setFeatureVotes(votes);
|
||||
}
|
||||
|
||||
return <Navbar variant='dark' expand="lg">
|
||||
<Navbar.Brand href="/">Luncher</Navbar.Brand>
|
||||
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
||||
@@ -151,24 +195,19 @@ export default function Header({ choices, dayIndex }: Props) {
|
||||
<button
|
||||
className="theme-toggle"
|
||||
onClick={toggleTheme}
|
||||
title={effectiveDark ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
|
||||
aria-label="Přepnout světlý/tmavý režim"
|
||||
title={effectiveTheme === 'dark' ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
|
||||
aria-label="Přepnout barevný motiv"
|
||||
>
|
||||
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
|
||||
<FontAwesomeIcon icon={effectiveTheme === 'dark' ? faSun : faMoon} />
|
||||
</button>
|
||||
<HuePicker
|
||||
accentHue={settings?.accentHue ?? 142}
|
||||
isDark={effectiveDark}
|
||||
onChange={hue => settings?.setAccentHue(hue)}
|
||||
/>
|
||||
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
|
||||
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => navigate(NAVRHY_URL)}>Návrhy na vylepšení</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => navigate(OBJEDNANI_URL)}>Objednání</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => navigate(VECERE_URL)}>Večeře</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => {
|
||||
getChangelogs().then(response => {
|
||||
const entries = response.data ?? {};
|
||||
@@ -194,6 +233,7 @@ export default function Header({ choices, dayIndex }: Props) {
|
||||
</Navbar.Collapse>
|
||||
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
|
||||
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
||||
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
|
||||
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
|
||||
{choices && settings?.bankAccount && settings?.holderName && (
|
||||
<GenerateQrModal
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
.hue-picker-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.dropdown-toggle {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
color: var(--luncher-navbar-text) !important;
|
||||
padding: 8px 12px;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border-radius: var(--luncher-radius-sm);
|
||||
transition: var(--luncher-transition);
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hue-picker-panel {
|
||||
padding: 0 !important;
|
||||
min-width: 240px;
|
||||
|
||||
.hue-picker-inner {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.hue-picker-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--luncher-text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.hue-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
hsl(0 70% 50%), hsl(30 70% 50%), hsl(60 70% 50%), hsl(90 70% 50%),
|
||||
hsl(120 70% 50%), hsl(150 70% 50%), hsl(180 70% 50%), hsl(210 70% 50%),
|
||||
hsl(240 70% 50%), hsl(270 70% 50%), hsl(300 70% 50%), hsl(330 70% 50%), hsl(360 70% 50%)
|
||||
);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
margin-bottom: 14px;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
border: 2px solid rgba(0, 0, 0, 0.25);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
border: 2px solid rgba(0, 0, 0, 0.25);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.hue-presets {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
.hue-swatch {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--luncher-text);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hue-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--luncher-border);
|
||||
|
||||
.hue-preview-chip {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--luncher-radius-sm);
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--luncher-text-secondary);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Dropdown } from 'react-bootstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPalette } from '@fortawesome/free-solid-svg-icons';
|
||||
import './HuePicker.scss';
|
||||
|
||||
const PRESETS = [
|
||||
{ hue: 142, label: 'Zelená' },
|
||||
{ hue: 217, label: 'Modrá' },
|
||||
{ hue: 263, label: 'Fialová' },
|
||||
{ hue: 0, label: 'Červená' },
|
||||
{ hue: 28, label: 'Oranžová' },
|
||||
{ hue: 340, label: 'Růžová' },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
accentHue: number;
|
||||
isDark: boolean;
|
||||
onChange: (hue: number) => void;
|
||||
};
|
||||
|
||||
function swatchColor(hue: number, isDark: boolean): string {
|
||||
return `hsl(${hue} 70% ${isDark ? 55 : 38}%)`;
|
||||
}
|
||||
|
||||
export default function HuePicker({ accentHue, isDark, onChange }: Props) {
|
||||
return (
|
||||
<Dropdown align="end" autoClose="outside" className="hue-picker-dropdown">
|
||||
<Dropdown.Toggle
|
||||
as="button"
|
||||
className="theme-toggle"
|
||||
aria-label="Barva zvýraznění"
|
||||
title="Barva zvýraznění"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPalette} />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="hue-picker-panel">
|
||||
<div className="hue-picker-inner">
|
||||
<div className="hue-picker-label">Barva zvýraznění</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={360}
|
||||
value={accentHue}
|
||||
onChange={e => onChange(parseInt(e.target.value, 10))}
|
||||
className="hue-slider"
|
||||
aria-label="Odstín barvy zvýraznění"
|
||||
/>
|
||||
<div className="hue-presets">
|
||||
{PRESETS.map(p => (
|
||||
<button
|
||||
key={p.hue}
|
||||
className={`hue-swatch${accentHue === p.hue ? ' active' : ''}`}
|
||||
style={{ background: swatchColor(p.hue, isDark) }}
|
||||
title={p.label}
|
||||
onClick={() => onChange(p.hue)}
|
||||
aria-label={p.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="hue-preview">
|
||||
<div
|
||||
className="hue-preview-chip"
|
||||
style={{ background: swatchColor(accentHue, isDark) }}
|
||||
/>
|
||||
<span>Aktuální barva zvýraznění</span>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Modal } from 'react-bootstrap';
|
||||
import { PendingQr, dismissQr } from '../../../types';
|
||||
import { formatDateString } from '../Utils';
|
||||
import ConfirmModal from './modals/ConfirmModal';
|
||||
|
||||
type Props = {
|
||||
pendingQrs?: PendingQr[];
|
||||
login?: string;
|
||||
// Zavolá se po úspěšném potvrzení platby, aby si rodič mohl znovu načíst data
|
||||
onDismissed?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
// Sekce "Nevyřízené platby" – zobrazí QR kódy neuhrazených plateb přihlášeného uživatele
|
||||
// včetně tlačítka "Zaplatil jsem" a potvrzovacího dialogu. Sdíleno hlavní stránkou i stránkou objednávek.
|
||||
// Při příchodu nových nevyřízených plateb se navíc automaticky otevře modální dialog,
|
||||
// aby si uživatel QR kódů určitě všiml (často si jich nevšimnou, protože sekce je dole na stránce).
|
||||
export default function PendingPayments({ pendingQrs, login, onDismissed }: Readonly<Props>) {
|
||||
const [dismissQrId, setDismissQrId] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
// ID QR kódů, pro které už byl v rámci tohoto načtení stránky automaticky zobrazen
|
||||
// modální dialog. Drží se jen v paměti (ne v sessionStorage), takže se při každém
|
||||
// ručním přenačtení stránky vynuluje a dialog se znovu otevře, dokud uživatel platby
|
||||
// neuhradí. Zároveň se nepřekrývá při pouhém obnovení dat či příchodu už zobrazeného QR.
|
||||
const autoShownQrIds = useRef<Set<string>>(new Set());
|
||||
|
||||
const qrIdsKey = (pendingQrs ?? []).map(qr => qr.id).join(',');
|
||||
|
||||
// Automaticky otevřeme modální dialog, jakmile přijdou nové (dosud nezobrazené) platby.
|
||||
useEffect(() => {
|
||||
const ids = (pendingQrs ?? []).map(qr => qr.id);
|
||||
if (ids.length === 0) return;
|
||||
const unseen = ids.filter(id => !autoShownQrIds.current.has(id));
|
||||
if (unseen.length > 0) {
|
||||
setModalOpen(true);
|
||||
unseen.forEach(id => autoShownQrIds.current.add(id));
|
||||
}
|
||||
}, [qrIdsKey, pendingQrs]);
|
||||
|
||||
if (!pendingQrs || pendingQrs.length === 0) return null;
|
||||
|
||||
// Vykreslení jednoho QR kódu i s tlačítkem "Zaplatil jsem" – sdíleno sekcí i modálem.
|
||||
const renderQr = (qr: PendingQr) => (
|
||||
<div key={qr.id} className='qr-code mb-3'>
|
||||
<p>
|
||||
<strong>{formatDateString(qr.date)}</strong> — {qr.creator} ({qr.totalPrice / 100} Kč)
|
||||
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
|
||||
</p>
|
||||
<img src={`/api/qr?login=${login}&id=${qr.id}`} alt='QR kód' />
|
||||
<div className='mt-2'>
|
||||
<Button variant="success" onClick={() => setDismissQrId(qr.id)}>
|
||||
Zaplatil jsem
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='pizza-section fade-in mt-4'>
|
||||
<h3>Nevyřízené platby</h3>
|
||||
<p>
|
||||
Máte neuhrazené platby.{' '}
|
||||
<Button variant="link" className="p-0 align-baseline" onClick={() => setModalOpen(true)}>
|
||||
Zobrazit QR kódy
|
||||
</Button>
|
||||
</p>
|
||||
{pendingQrs.map(renderQr)}
|
||||
</div>
|
||||
<Modal show={modalOpen} onHide={() => setModalOpen(false)} centered scrollable>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Nevyřízené platby</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p>Máte neuhrazené platby. Naskenujte QR kód pro zaplacení.</p>
|
||||
{pendingQrs.map(renderQr)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={() => setModalOpen(false)}>
|
||||
Zavřít
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
<ConfirmModal
|
||||
isOpen={dismissQrId !== null}
|
||||
title="Potvrzení platby"
|
||||
message="Opravdu jste zaplatili? QR kód bude odstraněn."
|
||||
confirmLabel="Zaplatil jsem"
|
||||
confirmVariant="success"
|
||||
onClose={() => setDismissQrId(null)}
|
||||
onConfirm={async () => {
|
||||
if (!dismissQrId) return;
|
||||
const id = dismissQrId;
|
||||
setDismissQrId(null);
|
||||
await dismissQr({ body: { id } });
|
||||
await onDismissed?.();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export default function PizzaOrderList({ state, orders, onDelete, creator }: Rea
|
||||
borderTop: '2px solid var(--luncher-border)'
|
||||
}}>
|
||||
<td colSpan={4} style={{ padding: '16px 20px', border: 'none' }}>Celkem</td>
|
||||
<td style={{ padding: '16px 20px', border: 'none', textAlign: 'right', color: 'var(--luncher-primary)' }}>{`${total / 100} Kč`}</td>
|
||||
<td style={{ padding: '16px 20px', border: 'none', textAlign: 'right', color: 'var(--luncher-primary)' }}>{`${total} Kč`}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeMo
|
||||
<td>{order.customer}</td>
|
||||
<td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder =>
|
||||
<span key={pizzaOrder.name}>
|
||||
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price / 100} Kč)`}
|
||||
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
|
||||
{auth?.login === order.customer && state === PizzaDayState.CREATED &&
|
||||
<span title='Odstranit'>
|
||||
<FontAwesomeIcon onClick={() => {
|
||||
@@ -38,10 +38,10 @@ export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeMo
|
||||
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
|
||||
</td>
|
||||
<td style={{ maxWidth: "200px" }}>{order.note ?? '-'}</td>
|
||||
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price / 100} Kč${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
|
||||
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price} Kč${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
|
||||
<td>
|
||||
{order.totalPrice / 100} Kč{auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>}
|
||||
{order.totalPrice} Kč{auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>}
|
||||
</td>
|
||||
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price != null ? String(order.fee.price / 100) : undefined }} />
|
||||
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} />
|
||||
</>
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Modal, Button, Form } from "react-bootstrap";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (title: string, description: string) => Promise<void>;
|
||||
};
|
||||
|
||||
/** Modální dialog pro přidání nového návrhu na vylepšení. */
|
||||
export default function AddSuggestionModal({ isOpen, onClose, onSubmit }: Readonly<Props>) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim() || !description.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(title.trim(), description.trim());
|
||||
reset();
|
||||
onClose();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={handleClose} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Nový návrh na vylepšení</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Název</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="Stručný název návrhu"
|
||||
value={title}
|
||||
maxLength={120}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
autoFocus
|
||||
/>
|
||||
<Form.Text className="text-muted">Krátký, výstižný název navrhované úpravy.</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Popis</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={5}
|
||||
placeholder="Detailní popis navrhované úpravy, řešení apod."
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Storno
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting || !title.trim() || !description.trim()}>
|
||||
Přidat
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Modal, Button, Form, Alert, Badge } from "react-bootstrap";
|
||||
import {
|
||||
OrderGroup,
|
||||
simulateBoltTracking, advanceBoltTracking, setBoltTrackingState,
|
||||
pollBoltTracking, stopBoltTrackingSimulation,
|
||||
} from "../../../../types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
group: OrderGroup;
|
||||
};
|
||||
|
||||
/** Nabídka stavů pro ruční nastavení (odpovídá mapování v BoltOrderProgress). */
|
||||
const STATE_OPTIONS: { key: string; label: string; order_state: string; courier_state?: string }[] = [
|
||||
{ key: 'accepted', label: 'Přijato', order_state: 'accepted' },
|
||||
{ key: 'preparing', label: 'Příprava', order_state: 'preparing' },
|
||||
{ key: 'waiting_delivery', label: 'Příprava (čeká na vyzvednutí)', order_state: 'waiting_delivery' },
|
||||
{ key: 'pickup', label: 'Vyzvedávání', order_state: 'waiting_delivery', courier_state: 'arrived_to_provider' },
|
||||
{ key: 'in_delivery', label: 'Na cestě', order_state: 'in_delivery', courier_state: 'heading_to_client' },
|
||||
{ key: 'delivered', label: 'Doručeno', order_state: 'delivered' },
|
||||
{ key: 'cancelled', label: 'Zrušeno', order_state: 'cancelled' },
|
||||
];
|
||||
|
||||
/** Modální dialog pro simulaci sledování Bolt objednávky (pouze DEV). */
|
||||
export default function BoltSimulationModal({ isOpen, onClose, group }: Readonly<Props>) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [manualKey, setManualKey] = useState<string>('preparing');
|
||||
|
||||
const running = !!group.boltTrackingToken;
|
||||
|
||||
/** Obecný runner — spustí akci, ošetří chybu a krátce zobrazí výsledek. */
|
||||
const run = async (action: () => Promise<{ error?: unknown }>, okMsg: string) => {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await action();
|
||||
if (response.error) {
|
||||
setError((response.error as any)?.error || 'Akce simulace selhala');
|
||||
} else {
|
||||
setInfo(okMsg);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Akce simulace selhala');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = () => run(
|
||||
() => simulateBoltTracking({ body: { groupId: group.id } }),
|
||||
'Simulace spuštěna — objednávka přijata.',
|
||||
);
|
||||
const handleAdvance = () => run(
|
||||
() => advanceBoltTracking({ body: { groupId: group.id } }),
|
||||
'Posunuto na další krok.',
|
||||
);
|
||||
const handleSetState = () => {
|
||||
const opt = STATE_OPTIONS.find(o => o.key === manualKey);
|
||||
if (!opt) return;
|
||||
return run(
|
||||
() => setBoltTrackingState({
|
||||
body: {
|
||||
groupId: group.id,
|
||||
order_state: opt.order_state,
|
||||
courier_state: opt.courier_state,
|
||||
},
|
||||
}),
|
||||
`Stav nastaven na „${opt.label}".`,
|
||||
);
|
||||
};
|
||||
const handlePoll = () => run(
|
||||
() => pollBoltTracking(),
|
||||
'Scheduler spuštěn (poll proběhl).',
|
||||
);
|
||||
const handleStop = () => run(
|
||||
() => stopBoltTrackingSimulation({ body: { groupId: group.id } }),
|
||||
'Simulace ukončena.',
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={handleClose}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Simulace sledování Bolt</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Alert variant="warning">
|
||||
<strong>DEV režim</strong> – simuluje sledování objednávky Bolt pro skupinu{' '}
|
||||
<strong>{group.name}</strong> bez reálné objednávky.
|
||||
</Alert>
|
||||
|
||||
{error && (
|
||||
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{info && (
|
||||
<Alert variant="success" onClose={() => setInfo(null)} dismissible>
|
||||
{info}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<p className="mb-2">
|
||||
Stav simulace:{' '}
|
||||
{running
|
||||
? <Badge bg="success">běží{group.boltOrderState ? ` — ${group.boltOrderState}` : ''}</Badge>
|
||||
: <Badge bg="secondary">neběží</Badge>}
|
||||
</p>
|
||||
|
||||
{!running ? (
|
||||
<p className="text-muted">
|
||||
Spuštěním se skupina přepne do stavu „Objednáno", přiřadí se simulovaný
|
||||
sledovací token a provede se první poll (stav „Přijato").
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted">
|
||||
Krokuj objednávku tlačítkem <strong>Další krok</strong> (Přijato → Příprava →
|
||||
Vyzvedávání → Na cestě → Doručeno) nebo nastav konkrétní stav ručně:
|
||||
</p>
|
||||
<Form.Group className="mb-3 d-flex gap-2 align-items-end">
|
||||
<div className="flex-grow-1">
|
||||
<Form.Label>Konkrétní stav</Form.Label>
|
||||
<Form.Select
|
||||
value={manualKey}
|
||||
onChange={e => setManualKey(e.target.value)}
|
||||
>
|
||||
{STATE_OPTIONS.map(o => (
|
||||
<option key={o.key} value={o.key}>{o.label}</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</div>
|
||||
<Button variant="outline-primary" onClick={handleSetState} disabled={loading}>
|
||||
Nastavit
|
||||
</Button>
|
||||
</Form.Group>
|
||||
</>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="d-flex flex-wrap gap-2">
|
||||
{!running ? (
|
||||
<Button variant="primary" onClick={handleStart} disabled={loading}>
|
||||
{loading ? 'Spouštím…' : 'Spustit simulaci'}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="success" onClick={handleAdvance} disabled={loading}>
|
||||
Další krok
|
||||
</Button>
|
||||
<Button variant="outline-secondary" onClick={handlePoll} disabled={loading}>
|
||||
Aktualizovat teď
|
||||
</Button>
|
||||
<Button variant="outline-danger" onClick={handleStop} disabled={loading}>
|
||||
Ukončit simulaci
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="secondary" onClick={handleClose} disabled={loading}>
|
||||
Zavřít
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
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,26 +0,0 @@
|
||||
import { Modal, Button } from "react-bootstrap";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
confirmVariant?: string;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function ConfirmModal({ isOpen, title, message, confirmLabel = "Potvrdit", confirmVariant = "primary", onConfirm, onClose }: Readonly<Props>) {
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{title}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>{message}</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={onClose}>Zrušit</Button>
|
||||
<Button variant={confirmVariant} onClick={onConfirm}>{confirmLabel}</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
||||
import { updateGroupFees, OrderGroup, OrderGroupMember } from "../../../../types";
|
||||
import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
group: OrderGroup;
|
||||
onSaved: (data: any) => void;
|
||||
};
|
||||
|
||||
function parseHal(s: string): number {
|
||||
const n = parseFloat(s.replace(',', '.'));
|
||||
return isNaN(n) || n < 0 ? 0 : Math.round(n * 100);
|
||||
}
|
||||
|
||||
function parsePercent(s: string): number {
|
||||
const n = parseFloat(s.replace(',', '.'));
|
||||
return isNaN(n) || n < 0 ? 0 : Math.round(n);
|
||||
}
|
||||
|
||||
export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }: Readonly<Props>) {
|
||||
const [fees, setFees] = useState('');
|
||||
const [shipping, setShipping] = useState('');
|
||||
const [tip, setTip] = useState('');
|
||||
const [discountType, setDiscountType] = useState<'percent' | 'fixed'>('percent');
|
||||
const [discountValue, setDiscountValue] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setFees(group.fees ? String(group.fees / 100) : '');
|
||||
setShipping(group.shipping ? String(group.shipping / 100) : '');
|
||||
setTip(group.tip ? String(group.tip / 100) : '');
|
||||
setDiscountType((group.discountType as 'percent' | 'fixed') ?? 'percent');
|
||||
setDiscountValue(group.discountValue
|
||||
? ((group.discountType as string) === 'fixed' ? String(group.discountValue / 100) : String(group.discountValue))
|
||||
: '');
|
||||
setError(null);
|
||||
}, [isOpen, group]);
|
||||
|
||||
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
|
||||
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
|
||||
const activeCount = countActiveMembers(group.members);
|
||||
|
||||
const feesNum = parseHal(fees);
|
||||
const shippingNum = parseHal(shipping);
|
||||
const tipNum = parseHal(tip);
|
||||
const discountNum = discountType === 'percent' ? parsePercent(discountValue) : parseHal(discountValue);
|
||||
const totalFees = feesNum + shippingNum + tipNum;
|
||||
const feeShare = computeFeeShare(totalFees, activeCount);
|
||||
const feeParams = { totalFees, discountType, discountValue: discountNum };
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await updateGroupFees({
|
||||
body: {
|
||||
id: group.id,
|
||||
fees: feesNum,
|
||||
shipping: shippingNum,
|
||||
tip: tipNum,
|
||||
discountType: discountNum > 0 ? discountType : undefined,
|
||||
discountValue: discountNum > 0 ? discountNum : undefined,
|
||||
}
|
||||
});
|
||||
if (res.error) {
|
||||
setError((res.error as any).error || 'Nastala chyba');
|
||||
} else {
|
||||
onSaved(res.data);
|
||||
onClose();
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Nastala chyba');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Poplatky skupiny — {group.name}</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{error && (
|
||||
<Alert variant="danger" onClose={() => setError(null)} dismissible>{error}</Alert>
|
||||
)}
|
||||
|
||||
<div className="d-flex gap-3 flex-wrap mb-3">
|
||||
<Form.Group>
|
||||
<Form.Label>Poplatky (Kč)</Form.Label>
|
||||
<Form.Control
|
||||
type="number" min={0} step={0.01}
|
||||
value={fees} onChange={e => setFees(e.target.value)}
|
||||
placeholder="0" style={{ width: 110 }}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Label>Doprava (Kč)</Form.Label>
|
||||
<Form.Control
|
||||
type="number" min={0} step={0.01}
|
||||
value={shipping} onChange={e => setShipping(e.target.value)}
|
||||
placeholder="0" style={{ width: 110 }}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Label>Spropitné (Kč)</Form.Label>
|
||||
<Form.Control
|
||||
type="number" min={0} step={0.01}
|
||||
value={tip} onChange={e => setTip(e.target.value)}
|
||||
placeholder="0" style={{ width: 110 }}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
</Form.Group>
|
||||
</div>
|
||||
|
||||
<div className="d-flex gap-3 align-items-end flex-wrap mb-3">
|
||||
<Form.Group>
|
||||
<Form.Label>Sleva</Form.Label>
|
||||
<div className="d-flex gap-2 align-items-center">
|
||||
<Form.Select
|
||||
value={discountType}
|
||||
onChange={e => setDiscountType(e.target.value as 'percent' | 'fixed')}
|
||||
style={{ width: 160 }}
|
||||
>
|
||||
<option value="percent">Procentuální (%)</option>
|
||||
<option value="fixed">Pevná částka (Kč)</option>
|
||||
</Form.Select>
|
||||
<Form.Control
|
||||
type="number" min={0} step={discountType === 'percent' ? 1 : 0.01}
|
||||
value={discountValue} onChange={e => setDiscountValue(e.target.value)}
|
||||
placeholder="0" style={{ width: 100 }}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
<span className="text-muted">{discountType === 'percent' ? '%' : 'Kč'}</span>
|
||||
</div>
|
||||
</Form.Group>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<h6>Náhled celkových částek ({activeCount} {activeCount === 1 ? 'strávník' : 'strávníků'} s objednávkou, {feeShare > 0 ? `poplatek ${feeShare / 100} Kč/os.` : 'bez poplatku'})</h6>
|
||||
<Table size="sm" bordered>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Člen</th>
|
||||
<th className="text-end">Základ</th>
|
||||
<th className="text-end">Příplatek</th>
|
||||
<th className="text-end">Poplatek</th>
|
||||
<th className="text-end">Sleva</th>
|
||||
<th className="text-end fw-bold">Celkem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{memberEntries.map(([login, member]) => {
|
||||
const base = member.amount ?? 0;
|
||||
const surcharge = member.surchargeAmount ?? 0;
|
||||
const active = isActiveMember(member);
|
||||
const total = computeMemberTotal(member, feeParams, feeShare, activeCount);
|
||||
// Sleva i poplatek se týkají jen aktivních strávníků.
|
||||
const discount = active && discountNum > 0
|
||||
? (discountType === 'percent'
|
||||
? Math.round((base + surcharge) * discountNum / 100)
|
||||
: Math.round(discountNum / activeCount))
|
||||
: 0;
|
||||
return (
|
||||
<tr key={login} className={active ? '' : 'text-muted'}>
|
||||
<td><strong>{login}</strong>{!active && <small className="ms-1">(jen objednává)</small>}</td>
|
||||
<td className="text-end">{base > 0 ? `${base / 100} Kč` : '—'}</td>
|
||||
<td className="text-end">{surcharge > 0 ? `${surcharge / 100} Kč` : '—'}</td>
|
||||
<td className="text-end">{active && feeShare > 0 ? `${feeShare / 100} Kč` : '—'}</td>
|
||||
<td className="text-end text-danger">{discount > 0 ? `-${discount / 100} Kč` : '—'}</td>
|
||||
<td className="text-end fw-bold">{total > 0 ? `${total / 100} Kč` : '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={onClose} disabled={loading}>Storno</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={loading}>
|
||||
{loading ? 'Ukládám...' : 'Uložit'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Modal, Button, Form } from "react-bootstrap"
|
||||
import { FeatureRequest } from "../../../../types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean,
|
||||
onClose: () => void,
|
||||
onChange: (option: FeatureRequest, active: boolean) => void,
|
||||
initialValues?: FeatureRequest[],
|
||||
}
|
||||
|
||||
/** Modální dialog pro hlasování o nových funkcích. */
|
||||
export default function FeaturesVotingModal({ isOpen, onClose, onChange, initialValues }: Readonly<Props>) {
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.currentTarget.value as FeatureRequest, e.currentTarget.checked);
|
||||
}
|
||||
|
||||
return <Modal show={isOpen} onHide={onClose} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
Hlasujte pro nové funkce
|
||||
<p style={{ fontSize: '12px' }}>Je možno vybrat maximálně 4 možnosti</p>
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{(Object.keys(FeatureRequest) as Array<keyof typeof FeatureRequest>).map(key => {
|
||||
return <Form.Check
|
||||
key={key}
|
||||
type='checkbox'
|
||||
id={key}
|
||||
label={FeatureRequest[key]}
|
||||
onChange={handleChange}
|
||||
value={key}
|
||||
defaultChecked={initialValues?.includes(key as FeatureRequest)}
|
||||
/>
|
||||
})}
|
||||
<p className="mt-3" style={{ fontSize: '12px' }}>Něco jiného? Dejte vědět.</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
Zavřít
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
}
|
||||
@@ -33,7 +33,9 @@ function parseAmount(s: string): number | null {
|
||||
if (!s || s.trim().length === 0) return null;
|
||||
const n = parseFloat(s);
|
||||
if (isNaN(n) || n < 0) return null;
|
||||
return Math.round(n * 100);
|
||||
const parts = s.split('.');
|
||||
if (parts.length === 2 && parts[1].length > 2) return null;
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
export default function PayForAllModal({ isOpen, onClose, locationName, locationChoices, menu, payerLogin, bankAccount, bankAccountHolder }: Readonly<Props>) {
|
||||
@@ -53,11 +55,11 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
||||
let baseAmountParseFailed = false;
|
||||
if (menu) {
|
||||
for (const idx of selectedFoods) {
|
||||
const priceKc = parsePriceCzk(menu.food?.[idx]?.price);
|
||||
if (priceKc === null) {
|
||||
const price = parsePriceCzk(menu.food?.[idx]?.price);
|
||||
if (price === null) {
|
||||
baseAmountParseFailed = true;
|
||||
} else {
|
||||
baseAmount += Math.round(priceKc * 100);
|
||||
baseAmount += price;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,19 +84,13 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
||||
if (includedDiners.length === 0) return 0;
|
||||
const tip = parseAmount(tipTotal);
|
||||
if (tip === null || tip === 0) return 0;
|
||||
const totalPeople = includedDiners.length + 1;
|
||||
return Math.round(tip / totalPeople);
|
||||
})();
|
||||
const payerTipShare = (() => {
|
||||
const tip = parseAmount(tipTotal);
|
||||
if (!tip) return 0;
|
||||
return tip - tipPerPerson * includedDiners.length;
|
||||
return Math.round((tip / includedDiners.length) * 100) / 100;
|
||||
})();
|
||||
|
||||
const getTotal = (d: DinerEntry): number => {
|
||||
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
|
||||
const tip = d.login === payerLogin ? payerTipShare : tipPerPerson;
|
||||
return d.baseAmount + surcharge + tip;
|
||||
const tip = d.included && d.login !== payerLogin ? tipPerPerson : 0;
|
||||
return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
|
||||
};
|
||||
|
||||
const handleInclude = useCallback((login: string, checked: boolean) => {
|
||||
@@ -120,6 +116,11 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
||||
setError(`Celková částka pro ${d.login} musí být kladná`);
|
||||
return;
|
||||
}
|
||||
const amountStr = total.toString();
|
||||
if (amountStr.includes('.') && amountStr.split('.')[1].length > 2) {
|
||||
setError(`Částka pro ${d.login} má více než 2 desetinná místa`);
|
||||
return;
|
||||
}
|
||||
const foods = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
|
||||
const purposeBase = `Oběd ${locationName}${foods ? ` — ${foods}` : ''}`;
|
||||
recipients.push({
|
||||
@@ -166,7 +167,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<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 dýško, poté vygenerujte QR kódy pro ostatní.</p>
|
||||
|
||||
{!hasMenu && (
|
||||
<Alert variant="info">
|
||||
@@ -193,7 +194,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
||||
<th>Strávník</th>
|
||||
<th>Jídla</th>
|
||||
<th style={{ width: 220 }}>Příplatek</th>
|
||||
<th style={{ width: 90 }}>Poplatek</th>
|
||||
<th style={{ width: 90 }}>Dýško</th>
|
||||
<th style={{ width: 90 }}>Celkem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -219,38 +220,40 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
||||
<td>
|
||||
<small>
|
||||
{foodNames || <span className="text-muted">—</span>}
|
||||
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount / 100} Kč)</span>}
|
||||
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount} Kč)</span>}
|
||||
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<div className="d-flex gap-1">
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="popis"
|
||||
value={d.surchargeText}
|
||||
onChange={e => handleSurchargeText(d.login, e.target.value)}
|
||||
disabled={!isPayer && !d.included}
|
||||
size="sm"
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="Kč"
|
||||
value={d.surchargeAmount}
|
||||
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
|
||||
disabled={!isPayer && !d.included}
|
||||
size="sm"
|
||||
style={{ width: 70 }}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
{!isPayer && (
|
||||
<div className="d-flex gap-1">
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="popis"
|
||||
value={d.surchargeText}
|
||||
onChange={e => handleSurchargeText(d.login, e.target.value)}
|
||||
disabled={!d.included}
|
||||
size="sm"
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="Kč"
|
||||
value={d.surchargeAmount}
|
||||
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
|
||||
disabled={!d.included}
|
||||
size="sm"
|
||||
style={{ width: 70 }}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
{(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s / 100} Kč` : '—'; })()}
|
||||
{!isPayer && d.included ? `${tipPerPerson} Kč` : '—'}
|
||||
</td>
|
||||
<td className="text-end fw-bold">
|
||||
{`${total / 100} Kč`}
|
||||
{!isPayer ? `${total} Kč` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -259,7 +262,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
||||
</Table>
|
||||
|
||||
<div className="d-flex align-items-center gap-2 mt-2">
|
||||
<label className="mb-0 text-nowrap">Poplatky celkem (Kč):</label>
|
||||
<label className="mb-0 text-nowrap">Dýško celkem (Kč):</label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="0"
|
||||
@@ -271,7 +274,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
||||
/>
|
||||
<small className="text-muted">
|
||||
{includedDiners.length > 0 && tipPerPerson > 0
|
||||
? `(${tipPerPerson / 100} Kč / osoba)`
|
||||
? `(${tipPerPerson} Kč / osoba)`
|
||||
: ''}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
||||
import { generateQr, OrderGroup, OrderGroupMember, QrRecipient } from "../../../../types";
|
||||
import { sanitizeQrMessage } from "../../Utils";
|
||||
import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
group: OrderGroup;
|
||||
payerLogin: string;
|
||||
bankAccount: string;
|
||||
bankAccountHolder: string;
|
||||
groupId?: string;
|
||||
};
|
||||
|
||||
type DinerEntry = {
|
||||
login: string;
|
||||
member: OrderGroupMember;
|
||||
included: boolean;
|
||||
};
|
||||
|
||||
export default function PayForGroupModal({ isOpen, onClose, onSuccess, group, payerLogin, bankAccount, bankAccountHolder, groupId }: Readonly<Props>) {
|
||||
const [diners, setDiners] = useState<DinerEntry[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const entries: DinerEntry[] = (Object.entries(group.members) as [string, OrderGroupMember][]).map(([login, member]) => ({
|
||||
login,
|
||||
member,
|
||||
// Standardně zahrnout všechny, kdo nejsou plátce a něco si objednali.
|
||||
included: login !== payerLogin && isActiveMember(member),
|
||||
}));
|
||||
setDiners(entries);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
}, [isOpen, group, payerLogin]);
|
||||
|
||||
const fees = group.fees ?? 0;
|
||||
const shipping = group.shipping ?? 0;
|
||||
const tip = group.tip ?? 0;
|
||||
const totalFees = fees + shipping + tip;
|
||||
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
|
||||
const activeCount = countActiveMembers(group.members);
|
||||
const feeShare = computeFeeShare(totalFees, activeCount);
|
||||
const feeParams = { totalFees, discountType: group.discountType, discountValue: group.discountValue ?? 0 };
|
||||
|
||||
const getMemberTotal = (entry: DinerEntry): number =>
|
||||
computeMemberTotal(entry.member, feeParams, feeShare, activeCount);
|
||||
|
||||
const includedNonPayers = diners.filter(d => d.included && d.login !== payerLogin);
|
||||
|
||||
const handleInclude = (login: string, checked: boolean) => {
|
||||
setDiners(prev => prev.map(d => d.login === login ? { ...d, included: checked } : d));
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setError(null);
|
||||
const recipients: QrRecipient[] = [];
|
||||
|
||||
for (const d of diners) {
|
||||
if (!d.included || d.login === payerLogin) continue;
|
||||
const total = getMemberTotal(d);
|
||||
if (total <= 0) {
|
||||
setError(`Celková částka pro ${d.login} musí být kladná`);
|
||||
return;
|
||||
}
|
||||
const note = d.member.note?.trim();
|
||||
recipients.push({
|
||||
login: d.login,
|
||||
purpose: sanitizeQrMessage(note || `Objednávka ${group.name}`),
|
||||
amount: total,
|
||||
});
|
||||
}
|
||||
|
||||
if (recipients.length === 0) {
|
||||
setError("Nebyl vybrán žádný příjemce");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await generateQr({
|
||||
body: { recipients, bankAccount, bankAccountHolder, ...(groupId ? { groupId } : {}) },
|
||||
});
|
||||
if (response.error) {
|
||||
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
|
||||
} else {
|
||||
setSuccess(true);
|
||||
onSuccess?.();
|
||||
setTimeout(() => onClose(), 2000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Nastala chyba při generování QR kódů');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFees = totalFees > 0;
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Generovat QR — {group.name}</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{success ? (
|
||||
<Alert variant="success">
|
||||
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci „Nevyřízené platby".
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<p>Zaplatili jste za skupinu. Vyberte, komu vygenerovat QR kód k úhradě.</p>
|
||||
|
||||
{error && (
|
||||
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasFees && (
|
||||
<div className="d-flex gap-3 mb-2 text-muted" style={{ fontSize: '0.9em' }}>
|
||||
{fees > 0 && <span>Poplatky: <strong>{fees / 100} Kč</strong></span>}
|
||||
{shipping > 0 && <span>Doprava: <strong>{shipping / 100} Kč</strong></span>}
|
||||
{tip > 0 && <span>Spropitné: <strong>{tip / 100} Kč</strong></span>}
|
||||
<span>→ {feeShare / 100} Kč/os.</span>
|
||||
</div>
|
||||
)}
|
||||
{group.discountValue != null && group.discountValue > 0 && (
|
||||
<div className="mb-2 text-success" style={{ fontSize: '0.9em' }}>
|
||||
Sleva: {group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100} Kč`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table striped bordered hover responsive size="sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 40 }}></th>
|
||||
<th>Člen</th>
|
||||
<th style={{ width: 90 }} className="text-end">Základ</th>
|
||||
<th style={{ width: 90 }} className="text-end">Příplatek</th>
|
||||
{hasFees && <th style={{ width: 90 }} className="text-end">Poplatek</th>}
|
||||
<th style={{ width: 90 }} className="text-end fw-bold">Celkem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{diners.map(d => {
|
||||
const isPayer = d.login === payerLogin;
|
||||
const active = isActiveMember(d.member);
|
||||
const total = getMemberTotal(d);
|
||||
const surcharge = d.member.surchargeAmount ?? 0;
|
||||
return (
|
||||
<tr key={d.login} className={(!d.included && !isPayer) || !active ? 'text-muted' : ''}>
|
||||
<td className="text-center">
|
||||
{isPayer ? (
|
||||
<small className="text-muted">plátce</small>
|
||||
) : !active ? (
|
||||
<small className="text-muted">jen objednává</small>
|
||||
) : (
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
checked={d.included}
|
||||
onChange={e => handleInclude(d.login, e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<strong>{d.login}</strong>
|
||||
{d.member.surchargeText && (
|
||||
<small className="text-muted ms-1">({d.member.surchargeText})</small>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
{(d.member.amount ?? 0) > 0 ? `${d.member.amount! / 100} Kč` : <span className="text-muted">—</span>}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
{surcharge > 0 ? `${surcharge / 100} Kč` : <span className="text-muted">—</span>}
|
||||
</td>
|
||||
{hasFees && (
|
||||
<td className="text-end">
|
||||
{active && feeShare > 0 ? `${feeShare / 100} Kč` : '—'}
|
||||
</td>
|
||||
)}
|
||||
<td className="text-end fw-bold">
|
||||
{total > 0 ? `${total / 100} Kč` : <span className="text-muted">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
{!success && (
|
||||
<>
|
||||
<span className="me-auto text-muted">Příjemci: {includedNonPayers.length}</span>
|
||||
<Button variant="secondary" onClick={onClose} disabled={loading}>Storno</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || includedNonPayers.length === 0}
|
||||
>
|
||||
{loading ? 'Generuji...' : 'Vygenerovat QR'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{success && (
|
||||
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
|
||||
)}
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -15,12 +15,12 @@ export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose,
|
||||
const priceRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const doSubmit = () => {
|
||||
onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100));
|
||||
onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0"));
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100));
|
||||
onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
||||
const themeRef = useRef<HTMLSelectElement>(null);
|
||||
|
||||
const reminderTimeRef = useRef<HTMLInputElement>(null);
|
||||
const boltDeliveredRef = useRef<HTMLInputElement>(null);
|
||||
const ntfyTopicRef = useRef<HTMLInputElement>(null);
|
||||
const discordWebhookRef = useRef<HTMLInputElement>(null);
|
||||
const teamsWebhookRef = useRef<HTMLInputElement>(null);
|
||||
@@ -48,29 +47,22 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
||||
const handleSave = async () => {
|
||||
const newReminderTime = reminderTimeRef.current?.value || undefined;
|
||||
const oldReminderTime = notifSettings.reminderTime;
|
||||
const newBoltDelivered = boltDeliveredRef.current?.checked ?? false;
|
||||
const oldBoltDelivered = notifSettings.boltDeliveredPush ?? false;
|
||||
|
||||
// Uložení notifikačních nastavení na server
|
||||
const newSettings: NotificationSettings = {
|
||||
ntfyTopic: ntfyTopicRef.current?.value || undefined,
|
||||
discordWebhookUrl: discordWebhookRef.current?.value || undefined,
|
||||
teamsWebhookUrl: teamsWebhookRef.current?.value || undefined,
|
||||
enabledEvents,
|
||||
reminderTime: newReminderTime,
|
||||
boltDeliveredPush: newBoltDelivered,
|
||||
};
|
||||
await updateNotificationSettings({ body: newSettings }).catch(() => {});
|
||||
setNotifSettings(newSettings);
|
||||
await updateNotificationSettings({
|
||||
body: {
|
||||
ntfyTopic: ntfyTopicRef.current?.value || undefined,
|
||||
discordWebhookUrl: discordWebhookRef.current?.value || undefined,
|
||||
teamsWebhookUrl: teamsWebhookRef.current?.value || undefined,
|
||||
enabledEvents,
|
||||
reminderTime: newReminderTime,
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
// Správa push subscription — drží ji naživu kterákoli z push funkcí.
|
||||
// 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)) {
|
||||
// Správa push subscription pro připomínky
|
||||
if (newReminderTime && newReminderTime !== oldReminderTime) {
|
||||
subscribeToPush(newReminderTime);
|
||||
} else if (!wantsPush && hadPush) {
|
||||
} else if (!newReminderTime && oldReminderTime) {
|
||||
unsubscribeFromPush();
|
||||
}
|
||||
|
||||
@@ -136,21 +128,6 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
||||
</Form.Text>
|
||||
</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)"
|
||||
defaultChecked={notifSettings.boltDeliveredPush ?? false}
|
||||
key={`bolt-delivered-${notifSettings.boltDeliveredPush ?? false}`}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
Až bude skupinová objednávka sledovaná přes Bolt Food doručena, přijde vám push notifikace.
|
||||
Zakladateli skupiny se neposílá — ten dostane upozornění přímo z aplikace Bolt.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>ntfy téma (topic)</Form.Label>
|
||||
<Form.Control
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Modal, Button, Form, ListGroup, Alert } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { addStore, deleteStore, Store } from "../../../../types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
stores: Store[];
|
||||
onStoresChanged: (stores: Store[]) => void;
|
||||
};
|
||||
|
||||
export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly<Props>) {
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newUrl, setNewUrl] = useState('');
|
||||
const [heslo, setHeslo] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newName.trim()) return;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await addStore({ body: { name: newName.trim(), url: newUrl.trim() || undefined, heslo } });
|
||||
if (res.error) {
|
||||
setError((res.error as any).error || 'Nastala chyba');
|
||||
} else if (res.data) {
|
||||
onStoresChanged(res.data as Store[]);
|
||||
setNewName('');
|
||||
setNewUrl('');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Nastala chyba');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (name: string) => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await deleteStore({ body: { name, heslo } });
|
||||
if (res.error) {
|
||||
setError((res.error as any).error || 'Nastala chyba');
|
||||
} else if (res.data) {
|
||||
onStoresChanged(res.data as Store[]);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Nastala chyba');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Správa obchodů</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{error && (
|
||||
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Admin heslo</Form.Label>
|
||||
<Form.Control
|
||||
type="password"
|
||||
placeholder="Heslo"
|
||||
value={heslo}
|
||||
onChange={e => setHeslo(e.target.value)}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<hr />
|
||||
<h6>Přidat obchod</h6>
|
||||
<Form.Control
|
||||
className="mb-2"
|
||||
type="text"
|
||||
placeholder="Název obchodu"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
|
||||
/>
|
||||
<div className="d-flex gap-2 mb-3">
|
||||
<Form.Control
|
||||
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}>
|
||||
Přidat
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h6>Aktuální seznam</h6>
|
||||
{stores.length === 0 ? (
|
||||
<p className="text-muted">Žádné obchody v seznamu</p>
|
||||
) : (
|
||||
<ListGroup>
|
||||
{stores.map(s => (
|
||||
<ListGroup.Item key={s.name} className="d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
{s.name}
|
||||
{s.url && /^https?:\/\//i.test(s.url) && (
|
||||
<a href={s.url} target="_blank" rel="noopener noreferrer" className="ms-2" title="Otevřít nabídku v nové záložce">
|
||||
<FontAwesomeIcon icon={faUpRightFromSquare} />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faTrashCan}
|
||||
className="action-icon"
|
||||
title="Odebrat"
|
||||
onClick={() => handleRemove(s.name)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</ListGroup.Item>
|
||||
))}
|
||||
</ListGroup>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Modal, Button } from "react-bootstrap";
|
||||
import { Suggestion } from "../../../../types";
|
||||
|
||||
type Props = {
|
||||
suggestion?: Suggestion;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/** Modální dialog zobrazující celý detail návrhu na vylepšení. */
|
||||
export default function SuggestionDetailModal({ suggestion, onClose }: Readonly<Props>) {
|
||||
return (
|
||||
<Modal show={!!suggestion} onHide={onClose} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>{suggestion?.title}</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p className="text-muted mb-3">
|
||||
Navrhovatel: <strong>{suggestion?.author}</strong> · Hlasy: <strong>{suggestion?.voteScore}</strong>
|
||||
{suggestion?.resolved && <> · <strong>Vyřešeno</strong></>}
|
||||
</p>
|
||||
<p style={{ whiteSpace: "pre-wrap" }}>{suggestion?.description}</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Zavřít
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,6 @@ const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number';
|
||||
const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
|
||||
const HIDE_SOUPS_KEY = 'hide_soups';
|
||||
const THEME_KEY = 'theme_preference';
|
||||
const ACCENT_HUE_KEY = 'accent_hue';
|
||||
const LEGACY_COLOR_THEME_KEY = 'color_theme';
|
||||
|
||||
export type ThemePreference = 'system' | 'light' | 'dark';
|
||||
|
||||
@@ -14,13 +12,10 @@ export type SettingsContextProps = {
|
||||
holderName?: string,
|
||||
hideSoups?: boolean,
|
||||
themePreference: ThemePreference,
|
||||
accentHue: number,
|
||||
effectiveDark: boolean,
|
||||
setBankAccountNumber: (accountNumber?: string) => void,
|
||||
setBankAccountHolderName: (holderName?: string) => void,
|
||||
setHideSoupsOption: (hideSoups?: boolean) => void,
|
||||
setThemePreference: (theme: ThemePreference) => void,
|
||||
setAccentHue: (hue: number) => void,
|
||||
}
|
||||
|
||||
type ContextProps = {
|
||||
@@ -50,74 +45,11 @@ function getInitialTheme(): ThemePreference {
|
||||
return 'system';
|
||||
}
|
||||
|
||||
function getInitialAccentHue(): number {
|
||||
try {
|
||||
const saved = localStorage.getItem(ACCENT_HUE_KEY);
|
||||
if (saved !== null) {
|
||||
const n = parseInt(saved, 10);
|
||||
if (!isNaN(n) && n >= 0 && n <= 360) return n;
|
||||
}
|
||||
// Migrace ze starého string formátu (green/blue/purple)
|
||||
const old = localStorage.getItem(LEGACY_COLOR_THEME_KEY);
|
||||
if (old === 'blue') return 217;
|
||||
if (old === 'purple') return 263;
|
||||
} catch {
|
||||
// localStorage nedostupný
|
||||
}
|
||||
return 142;
|
||||
}
|
||||
|
||||
// Převod HSL na relativní jas dle WCAG (pro výpočet kontrastu s bílým textem)
|
||||
function hslToRelativeLuminance(h: number, s: number, l: number): number {
|
||||
const sn = s / 100, ln = l / 100;
|
||||
const a = sn * Math.min(ln, 1 - ln);
|
||||
const ch = (n: number) => {
|
||||
const k = (n + h / 30) % 12;
|
||||
return ln - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||
};
|
||||
const toLinear = (c: number) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
return 0.2126 * toLinear(ch(0)) + 0.7152 * toLinear(ch(8)) + 0.0722 * toLinear(ch(4));
|
||||
}
|
||||
|
||||
// Najde nejnižší světlost, při které má barva dostatečný kontrast s bílým textem (WCAG AA 4.5:1)
|
||||
function adjustedL(hue: number, sat: number, targetL: number): number {
|
||||
let l = targetL;
|
||||
while (l >= 5) {
|
||||
const lum = hslToRelativeLuminance(hue, sat, l);
|
||||
if (1.05 / (lum + 0.05) >= 4.5) return l;
|
||||
l -= 1;
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
function applyAccentColors(hue: number, isDark: boolean): void {
|
||||
const sat = 70;
|
||||
const baseL = adjustedL(hue, sat, isDark ? 55 : 38);
|
||||
const hoverL = isDark ? Math.min(baseL + 10, 80) : Math.max(baseL - 10, 10);
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--luncher-primary', `hsl(${hue} ${sat}% ${baseL}%)`);
|
||||
root.style.setProperty('--luncher-primary-hover', `hsl(${hue} ${sat}% ${hoverL}%)`);
|
||||
root.style.setProperty('--luncher-primary-light', isDark
|
||||
? `hsl(${hue} 60% 12%)`
|
||||
: `hsl(${hue} 60% 92%)`);
|
||||
root.style.setProperty('--luncher-action-icon', `hsl(${hue} ${sat}% ${baseL}%)`);
|
||||
root.style.setProperty('--luncher-success', `hsl(${hue} ${sat}% ${baseL}%)`);
|
||||
}
|
||||
|
||||
function useProvideSettings(): SettingsContextProps {
|
||||
const [bankAccount, setBankAccount] = useState<string | undefined>();
|
||||
const [holderName, setHolderName] = useState<string | undefined>();
|
||||
const [hideSoups, setHideSoups] = useState<boolean | undefined>();
|
||||
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme);
|
||||
const [accentHue, setHue] = useState<number>(getInitialAccentHue);
|
||||
const [effectiveDark, setEffectiveDark] = useState<boolean>(() => {
|
||||
try {
|
||||
const pref = localStorage.getItem(THEME_KEY) as ThemePreference | null;
|
||||
if (pref === 'dark') return true;
|
||||
if (pref === 'light') return false;
|
||||
} catch { /* noop */ }
|
||||
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
|
||||
@@ -163,27 +95,24 @@ function useProvideSettings(): SettingsContextProps {
|
||||
}, [themePreference]);
|
||||
|
||||
useEffect(() => {
|
||||
const applyTheme = (dark: boolean) => {
|
||||
document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
|
||||
setEffectiveDark(dark);
|
||||
const applyTheme = (theme: 'light' | 'dark') => {
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
};
|
||||
|
||||
if (themePreference === 'system') {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
applyTheme(mq.matches);
|
||||
const handler = (e: MediaQueryListEvent) => applyTheme(e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
applyTheme(mediaQuery.matches ? 'dark' : 'light');
|
||||
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
applyTheme(e.matches ? 'dark' : 'light');
|
||||
};
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
} else {
|
||||
applyTheme(themePreference === 'dark');
|
||||
applyTheme(themePreference);
|
||||
}
|
||||
}, [themePreference]);
|
||||
|
||||
// Aplikuje accent barvy při změně hue nebo přepnutí světlý/tmavý
|
||||
useEffect(() => {
|
||||
localStorage.setItem(ACCENT_HUE_KEY, String(accentHue));
|
||||
applyAccentColors(accentHue, effectiveDark);
|
||||
}, [accentHue, effectiveDark]);
|
||||
|
||||
function setBankAccountNumber(bankAccount?: string) {
|
||||
setBankAccount(bankAccount);
|
||||
}
|
||||
@@ -200,21 +129,14 @@ function useProvideSettings(): SettingsContextProps {
|
||||
setTheme(theme);
|
||||
}
|
||||
|
||||
function setAccentHue(hue: number) {
|
||||
setHue(hue);
|
||||
}
|
||||
|
||||
return {
|
||||
bankAccount,
|
||||
holderName,
|
||||
hideSoups,
|
||||
themePreference,
|
||||
accentHue,
|
||||
effectiveDark,
|
||||
setBankAccountNumber,
|
||||
setBankAccountHolderName,
|
||||
setHideSoupsOption,
|
||||
setThemePreference,
|
||||
setAccentHue,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,27 +8,13 @@ if (process.env.NODE_ENV === 'development') {
|
||||
socketPath = undefined;
|
||||
} else {
|
||||
socketUrl = `${globalThis.location.host}`;
|
||||
socketPath = '/socket.io';
|
||||
socketPath = `${globalThis.location.pathname}socket.io`;
|
||||
}
|
||||
|
||||
export const socket = socketio.connect(socketUrl, { path: socketPath, transports: ["websocket"] });
|
||||
export const SocketContext = React.createContext();
|
||||
|
||||
// Prohlížeče throttlují setTimeout v neaktivních tabech, což zdržuje automatické
|
||||
// znovupřipojení socket.io. Po návratu do tabu nebo focusu okna se připojíme hned.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && !socket.connected) {
|
||||
socket.connect();
|
||||
}
|
||||
});
|
||||
window.addEventListener('focus', () => {
|
||||
if (!socket.connected) {
|
||||
socket.connect();
|
||||
}
|
||||
});
|
||||
|
||||
// Konstanty websocket eventů, musí odpovídat těm na serveru!
|
||||
export const EVENT_CONNECT = 'connect';
|
||||
export const EVENT_DISCONNECT = 'disconnect';
|
||||
export const EVENT_MESSAGE = 'message';
|
||||
export const EVENT_PENDING_QR = 'pendingQr';
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -27,10 +27,9 @@ async function pushApiFetch(path: string, options: RequestInit = {}): Promise<Re
|
||||
|
||||
/**
|
||||
* Zaregistruje service worker, přihlásí se k push notifikacím
|
||||
* 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).
|
||||
* a odešle subscription na server.
|
||||
*/
|
||||
export async function subscribeToPush(reminderTime?: string): Promise<boolean> {
|
||||
export async function subscribeToPush(reminderTime: string): Promise<boolean> {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
console.warn('Push notifikace nejsou v tomto prohlížeči podporovány');
|
||||
return false;
|
||||
@@ -76,7 +75,7 @@ export async function subscribeToPush(reminderTime?: string): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('Push notifikace: úspěšně přihlášeno', reminderTime ? `(připomínka v ${reminderTime})` : '(bez připomínky oběda)');
|
||||
console.log('Push notifikace: úspěšně přihlášeno k připomínkám v', reminderTime);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Push notifikace: chyba při registraci', error);
|
||||
@@ -102,7 +101,7 @@ export async function unsubscribeFromPush(): Promise<void> {
|
||||
}
|
||||
|
||||
await pushApiFetch('/unsubscribe', { method: 'POST' });
|
||||
console.log('Push notifikace: úspěšně odhlášeno');
|
||||
console.log('Push notifikace: úspěšně odhlášeno z připomínek');
|
||||
} catch (error) {
|
||||
console.error('Push notifikace: chyba při odhlášení', error);
|
||||
}
|
||||
|
||||
@@ -6,37 +6,20 @@ import './index.css';
|
||||
import AppRoutes from './AppRoutes';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import { client } from '../../types/gen/client.gen';
|
||||
import { getConfig } from '../../types/gen/sdk.gen';
|
||||
import { getToken } from './Utils';
|
||||
import { toast } from 'react-toastify';
|
||||
import * as Sentry from '@sentry/react';
|
||||
|
||||
client.setConfig({
|
||||
auth: () => getToken(),
|
||||
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 nejde zapéct při buildu — server ho zná z env.
|
||||
getConfig().then(({ data }) => {
|
||||
if (data?.sentry.dsn) {
|
||||
Sentry.init({
|
||||
dsn: data.sentry.dsn,
|
||||
environment: data.sentry.environment,
|
||||
});
|
||||
}
|
||||
}).catch(() => { /* config endpoint nedostupný — běžíme bez Sentry */ });
|
||||
|
||||
// Interceptor na vyhození toasteru při chybě
|
||||
client.interceptors.response.use(async response => {
|
||||
// TODO opravit - login je zatím výjimka, voláme ho "naprázdno" abychom zjistili, zda nás nepřihlásily trusted headers
|
||||
if (!response.ok && !response.url.includes("/login")) {
|
||||
const json = await response.json();
|
||||
toast.error(json.error, { theme: "colored" });
|
||||
// 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;
|
||||
});
|
||||
@@ -46,18 +29,10 @@ const root = ReactDOM.createRoot(
|
||||
);
|
||||
root.render(
|
||||
<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>
|
||||
<ProvideAuth>
|
||||
<AppRoutes />
|
||||
</ProvideAuth>
|
||||
</BrowserRouter>
|
||||
</Sentry.ErrorBoundary>
|
||||
<BrowserRouter>
|
||||
<ProvideAuth>
|
||||
<AppRoutes />
|
||||
</ProvideAuth>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Table } from 'react-bootstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCircleCheck, faNoteSticky, faTrashCan } from '@fortawesome/free-regular-svg-icons';
|
||||
import { faBasketShopping, faSearch } from '@fortawesome/free-solid-svg-icons';
|
||||
import {
|
||||
ClientData, LunchChoice, MealSlot, UserLunchChoice,
|
||||
addChoice, removeChoices, updateNote, setBuyer, getData,
|
||||
} from '../../../types';
|
||||
import { EVENT_MESSAGE, SocketContext } from '../context/socket';
|
||||
import { useAuth } from '../context/auth';
|
||||
import Login from '../Login';
|
||||
import Header from '../components/Header';
|
||||
import Footer from '../components/Footer';
|
||||
import Loader from '../components/Loader';
|
||||
import NoteModal from '../components/modals/NoteModal';
|
||||
|
||||
const SLOT = MealSlot.EXTRA;
|
||||
|
||||
export default function ExtraPage() {
|
||||
const auth = useAuth();
|
||||
const socket = useContext(SocketContext);
|
||||
const [data, setData] = useState<ClientData | undefined>();
|
||||
const [failure, setFailure] = useState(false);
|
||||
const [noteModalOpen, setNoteModalOpen] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const r = await getData({ query: { slot: SLOT } });
|
||||
if (r.data) setData(r.data);
|
||||
} catch {
|
||||
setFailure(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth?.login) return;
|
||||
fetchData();
|
||||
}, [auth?.login]);
|
||||
|
||||
useEffect(() => {
|
||||
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
|
||||
if (newData.slot === SLOT) setData(newData);
|
||||
});
|
||||
return () => { socket.off(EVENT_MESSAGE); };
|
||||
}, [socket]);
|
||||
|
||||
const myChoice = data?.choices?.OBJEDNAVAM?.[auth?.login ?? ''];
|
||||
const isIn = !!myChoice;
|
||||
const isBuyer = myChoice?.isBuyer ?? false;
|
||||
|
||||
const joinOrder = async () => {
|
||||
if (!auth?.login) return;
|
||||
await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: SLOT } });
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const joinAndBuy = async () => {
|
||||
if (!auth?.login) return;
|
||||
await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: SLOT } });
|
||||
await setBuyer({ body: { slot: SLOT } });
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const leaveOrder = async () => {
|
||||
if (!auth?.login) return;
|
||||
await removeChoices({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: SLOT } });
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const toggleBuyer = async () => {
|
||||
if (!auth?.login) return;
|
||||
await setBuyer({ body: { slot: SLOT } });
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const saveNote = async (note?: string) => {
|
||||
if (!auth?.login) return;
|
||||
await updateNote({ body: { note, slot: SLOT } });
|
||||
setNoteModalOpen(false);
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
if (!auth?.login) return <Login />;
|
||||
|
||||
if (failure) return (
|
||||
<Loader icon={faSearch} description="Nepodařilo se načíst data" animation="fa-beat" />
|
||||
);
|
||||
|
||||
if (!data) return (
|
||||
<Loader icon={faSearch} description="Načítám..." animation="fa-bounce" />
|
||||
);
|
||||
|
||||
const orderEntries = Object.entries(data.choices?.OBJEDNAVAM ?? {}) as [string, UserLunchChoice][];
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Header choices={data.choices} />
|
||||
<div className="wrapper">
|
||||
<h1 className="title">Večeře</h1>
|
||||
<p style={{ color: 'var(--luncher-text-muted)' }}>Extra jídlo pro ty, kdo zůstávají déle</p>
|
||||
|
||||
<div className="content-wrapper">
|
||||
<div className="content">
|
||||
<div className="choice-section fade-in">
|
||||
{!isIn ? (
|
||||
<div className="d-flex gap-2 flex-wrap">
|
||||
<Button variant="primary" onClick={joinOrder}>
|
||||
Přidám se
|
||||
</Button>
|
||||
<Button variant="outline-primary" onClick={joinAndBuy}>
|
||||
<FontAwesomeIcon icon={faBasketShopping} className="me-2" />
|
||||
Budu objednávat
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="d-flex gap-2 flex-wrap align-items-center">
|
||||
<span style={{ color: 'var(--luncher-text-secondary)' }}>
|
||||
{isBuyer ? 'Objednáváš.' : 'Jsi přidán/a k objednávce.'}
|
||||
</span>
|
||||
<Button variant="outline-secondary" size="sm" onClick={toggleBuyer}>
|
||||
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
|
||||
{isBuyer ? 'Odebrat roli objednávajícího' : 'Označit se jako objednávající'}
|
||||
</Button>
|
||||
<Button variant="outline-secondary" size="sm" onClick={() => setNoteModalOpen(true)}>
|
||||
<FontAwesomeIcon icon={faNoteSticky} className="me-1" />
|
||||
Poznámka
|
||||
</Button>
|
||||
<Button variant="outline-danger" size="sm" onClick={leaveOrder}>
|
||||
<FontAwesomeIcon icon={faTrashCan} className="me-1" />
|
||||
Odhlásit se
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{orderEntries.length > 0 && (
|
||||
<Table className="choices-table mt-4 fade-in">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Budu objednávat / Přidám se</td>
|
||||
<td className="p-0">
|
||||
<Table className="nested-table">
|
||||
<tbody>
|
||||
{orderEntries.map(([login, payload]) => (
|
||||
<tr key={login}>
|
||||
<td>
|
||||
<div className="user-row">
|
||||
<div className="user-info">
|
||||
{payload.trusted && (
|
||||
<span className="trusted-icon" title="Ověřený uživatel">
|
||||
<FontAwesomeIcon icon={faCircleCheck} style={{ cursor: 'help' }} />
|
||||
</span>
|
||||
)}
|
||||
<strong>{login}</strong>
|
||||
{payload.note && (
|
||||
<span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>
|
||||
({payload.note})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="user-actions">
|
||||
{payload.isBuyer && (
|
||||
<span title="Objednávající">
|
||||
<FontAwesomeIcon icon={faBasketShopping} className="buyer-icon" />
|
||||
</span>
|
||||
)}
|
||||
{login === auth.login && (
|
||||
<>
|
||||
<span title="Upravit poznámku">
|
||||
<FontAwesomeIcon
|
||||
onClick={() => setNoteModalOpen(true)}
|
||||
className="action-icon"
|
||||
icon={faNoteSticky}
|
||||
/>
|
||||
</span>
|
||||
<span title="Odhlásit se z objednávky">
|
||||
<FontAwesomeIcon
|
||||
onClick={leaveOrder}
|
||||
className="action-icon"
|
||||
icon={faTrashCan}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
<NoteModal
|
||||
isOpen={noteModalOpen}
|
||||
onClose={() => setNoteModalOpen(false)}
|
||||
onSave={saveNote}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,898 +0,0 @@
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Badge, Button, Card, Form, Modal, OverlayTrigger, Table, Tooltip } from 'react-bootstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
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 DatePicker, { registerLocale } from 'react-datepicker';
|
||||
import { cs } from 'date-fns/locale';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import {
|
||||
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr,
|
||||
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setBoltTracking, getOrderDates,
|
||||
} from '../../../types';
|
||||
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
|
||||
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
|
||||
import { useAuth } from '../context/auth';
|
||||
import { useSettings } from '../context/settings';
|
||||
import { formatDate, formatDateString } from '../Utils';
|
||||
import Login from '../Login';
|
||||
import Header from '../components/Header';
|
||||
import Footer from '../components/Footer';
|
||||
import Loader from '../components/Loader';
|
||||
import StoreAdminModal from '../components/modals/StoreAdminModal';
|
||||
import PayForGroupModal from '../components/modals/PayForGroupModal';
|
||||
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
|
||||
import BoltSimulationModal from '../components/modals/BoltSimulationModal';
|
||||
import PendingPayments from '../components/PendingPayments';
|
||||
import BoltOrderProgress from '../components/BoltOrderProgress';
|
||||
|
||||
const SLOT = MealSlot.EXTRA;
|
||||
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';
|
||||
|
||||
/** Vytáhne sledovací token ze sdílecí URL Bolt Food, nebo přijme samotný token. Null = neplatný vstup. */
|
||||
function extractBoltToken(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
if (BOLT_TOKEN_REGEX.test(trimmed)) return trimmed;
|
||||
try {
|
||||
const segments = new URL(trimmed).pathname.split('/').filter(Boolean);
|
||||
const last = segments[segments.length - 1];
|
||||
return last && BOLT_TOKEN_REGEX.test(last) ? last : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Zkrátí dlouhý odkaz pro zobrazení v řádku (zachová začátek i konec). */
|
||||
function shortenUrl(url: string, max = 48): string {
|
||||
if (url.length <= max) return url;
|
||||
const head = Math.ceil((max - 1) / 2);
|
||||
const tail = Math.floor((max - 1) / 2);
|
||||
return `${url.slice(0, head)}…${url.slice(url.length - tail)}`;
|
||||
}
|
||||
|
||||
// Český lokál pro date picker (názvy měsíců/dnů, pondělí jako první den)
|
||||
registerLocale('cs', cs);
|
||||
|
||||
/** Vrátí ISO datum (YYYY-MM-DD) posunuté o předaný počet dní. */
|
||||
function shiftIsoDate(iso: string, days: number): string {
|
||||
const date = new Date(`${iso}T00:00:00`);
|
||||
date.setDate(date.getDate() + days);
|
||||
return formatDate(date);
|
||||
}
|
||||
|
||||
/** Převede ISO datum (YYYY-MM-DD) na lokální Date (půlnoc), nebo null. */
|
||||
function isoToDate(iso?: string): Date | null {
|
||||
return iso ? new Date(`${iso}T00:00:00`) : null;
|
||||
}
|
||||
|
||||
function stateBadge(state: GroupState) {
|
||||
const map: Record<GroupState, { bg: string; label: string }> = {
|
||||
[GroupState.OPEN]: { bg: 'success', label: 'Otevřeno' },
|
||||
[GroupState.LOCKED]: { bg: 'warning', label: 'Uzamčeno' },
|
||||
[GroupState.ORDERED]: { bg: 'secondary', label: 'Objednáno' },
|
||||
};
|
||||
const { bg, label } = map[state] ?? { bg: 'light', label: state };
|
||||
return <Badge bg={bg}>{label}</Badge>;
|
||||
}
|
||||
|
||||
export default function OrderGroupsPage() {
|
||||
const auth = useAuth();
|
||||
const settings = useSettings();
|
||||
const socket = useContext(SocketContext);
|
||||
const [data, setData] = useState<ClientData | undefined>();
|
||||
const [failure, setFailure] = useState(false);
|
||||
// Vybrané datum pro zobrazení historie (undefined = aktuální den)
|
||||
const [selectedDate, setSelectedDate] = useState<string | undefined>();
|
||||
// ISO datum dnešního dne dle serveru (horní hranice navigace), zjištěné při prvním načtení
|
||||
const [todayIso, setTodayIso] = useState<string | undefined>();
|
||||
// Ref pro socket handler – aby věděl, zda se zobrazuje historie (na ní se živé aktualizace neaplikují)
|
||||
const selectedDateRef = useRef<string | undefined>(undefined);
|
||||
// ISO data dnů, ve kterých existuje aspoň jedna objednávka (pro zvýraznění v date pickeru)
|
||||
const [orderDates, setOrderDates] = useState<string[]>([]);
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [adminModalOpen, setAdminModalOpen] = useState(false);
|
||||
const [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
|
||||
const [editNotes, setEditNotes] = useState<Record<string, string>>({});
|
||||
const [editSurcharges, setEditSurcharges] = useState<Record<string, { text: string; amount: string }>>({});
|
||||
const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string; boltUrl: string }>>({});
|
||||
const [payModal, setPayModal] = useState<OrderGroup | null>(null);
|
||||
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
|
||||
const [boltSimModal, setBoltSimModal] = useState<OrderGroup | null>(null);
|
||||
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
|
||||
const [pageError, setPageError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = async (date?: string) => {
|
||||
try {
|
||||
const r = await getData({ query: { slot: SLOT, date } });
|
||||
if (r.data) {
|
||||
setData(r.data);
|
||||
// Při zobrazení aktuálního dne si zapamatujeme dnešní ISO datum jako horní hranici navigace
|
||||
if (!date && r.data.isoDate) setTodayIso(r.data.isoDate);
|
||||
}
|
||||
} catch {
|
||||
setFailure(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Načte dny s objednávkou pro zvýraznění v date pickeru
|
||||
const fetchOrderDates = async () => {
|
||||
const r = await getOrderDates();
|
||||
if (r.data?.dates) setOrderDates(r.data.dates);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
selectedDateRef.current = selectedDate;
|
||||
}, [selectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth?.login) return;
|
||||
fetchData(selectedDate);
|
||||
}, [auth?.login, selectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth?.login) return;
|
||||
fetchOrderDates();
|
||||
}, [auth?.login]);
|
||||
|
||||
useEffect(() => {
|
||||
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
|
||||
// Živé aktualizace se týkají vždy dneška – při zobrazení historie je ignorujeme
|
||||
if (selectedDateRef.current) return;
|
||||
if (newData.slot === SLOT) setData(prev => ({
|
||||
...newData,
|
||||
stores: newData.stores ?? prev?.stores,
|
||||
}));
|
||||
});
|
||||
// Nová nevyřízená platba (QR kód) – připojíme do dat, aby se zobrazila i bez znovunačtení stránky
|
||||
socket.on(EVENT_PENDING_QR, (pendingQr: PendingQr) => {
|
||||
if (selectedDateRef.current) return;
|
||||
setData(prev => prev ? { ...prev, pendingQrs: [...(prev.pendingQrs ?? []), pendingQr] } : prev);
|
||||
});
|
||||
return () => { socket.off(EVENT_MESSAGE); socket.off(EVENT_PENDING_QR); };
|
||||
}, [socket]);
|
||||
|
||||
// Připojení do osobní socket místnosti po přihlášení – bez toho nechodí události
|
||||
// o nových nevyřízených platbách (QR kódy se posílají do místnosti user:<login>)
|
||||
useEffect(() => {
|
||||
if (auth?.login) {
|
||||
socket.emit('join', auth.login);
|
||||
}
|
||||
}, [auth?.login, socket]);
|
||||
|
||||
useEffect(() => {
|
||||
// Po znovupřipojení socketu znovu vstoupíme do osobní místnosti a načteme aktuálně
|
||||
// zobrazený den (mohli jsme přijít o živé aktualizace)
|
||||
const onReconnect = () => {
|
||||
if (auth?.login) socket.emit('join', auth.login);
|
||||
fetchData(selectedDateRef.current);
|
||||
};
|
||||
socket.io.on('reconnect', onReconnect);
|
||||
return () => { socket.io.off('reconnect', onReconnect); };
|
||||
}, [socket, auth?.login]);
|
||||
|
||||
// Navigace mezi dny pomocí klávesových šipek (←/→), obdobně jako na hlavní stránce
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
// Ignorujeme, pokud uživatel právě píše do formulářového pole
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
|
||||
const currentIso = data?.isoDate;
|
||||
if (!currentIso) return;
|
||||
if (e.keyCode === 37) {
|
||||
// Předchozí den – do minulosti bez omezení
|
||||
setSelectedDate(shiftIsoDate(currentIso, -1));
|
||||
} else if (e.keyCode === 39 && todayIso != null && currentIso < todayIso) {
|
||||
// Následující den – nejvýše po dnešek (na dnešek přes undefined kvůli živým aktualizacím)
|
||||
const target = shiftIsoDate(currentIso, 1);
|
||||
setSelectedDate(target >= todayIso ? undefined : target);
|
||||
}
|
||||
}, [data?.isoDate, todayIso]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const refresh = async (fn: () => Promise<any>): Promise<boolean> => {
|
||||
setPageError(null);
|
||||
const result = await fn();
|
||||
if (result?.error) {
|
||||
setPageError((result.error as any).error || 'Nastala chyba');
|
||||
await fetchData();
|
||||
return false;
|
||||
}
|
||||
if (result?.data) {
|
||||
setData(result.data);
|
||||
socket.emit?.('message', result.data as ClientData);
|
||||
}
|
||||
// Sada dnů s objednávkou se mohla změnit (vytvoření/smazání skupiny)
|
||||
fetchOrderDates();
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newGroupName || !auth?.login) return;
|
||||
setCreating(true);
|
||||
const ok = await refresh(() => createGroup({ body: { name: newGroupName } }));
|
||||
if (ok) setNewGroupName('');
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
const handleJoin = (groupId: string) =>
|
||||
refresh(() => addGroupMember({ body: { id: groupId } }));
|
||||
|
||||
const handleToggleLock = (group: OrderGroup) => {
|
||||
const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN;
|
||||
return refresh(() => setGroupState({ body: { id: group.id, state: next } }));
|
||||
};
|
||||
|
||||
const handleConfirmOrdered = async (group: OrderGroup) => {
|
||||
setConfirmOrderGroup(null);
|
||||
await refresh(() => setGroupState({ body: { id: group.id, state: GroupState.ORDERED } }));
|
||||
};
|
||||
|
||||
const handleRevertOrdered = (group: OrderGroup) =>
|
||||
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.LOCKED } }));
|
||||
|
||||
const handleDelete = (groupId: string) =>
|
||||
refresh(() => deleteGroup({ body: { id: groupId } }));
|
||||
|
||||
const handleSaveAmount = async (groupId: string, login: string) => {
|
||||
const key = `${groupId}:${login}`;
|
||||
const raw = editAmounts[key];
|
||||
const n = parseFloat(raw ?? '');
|
||||
if (!raw || isNaN(n) || n < 0) {
|
||||
setPageError('Zadejte platnou kladnou částku');
|
||||
return;
|
||||
}
|
||||
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, amount: Math.round(n * 100) } }));
|
||||
if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||
};
|
||||
|
||||
const handleSaveNote = async (groupId: string, login: string) => {
|
||||
const key = `${groupId}:${login}`;
|
||||
const note = editNotes[key] ?? '';
|
||||
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, note } }));
|
||||
if (ok) setEditNotes(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||
};
|
||||
|
||||
const handleSaveSurcharge = async (groupId: string, login: string) => {
|
||||
const key = `${groupId}:${login}`;
|
||||
const surchargeText = editSurcharges[key]?.text ?? '';
|
||||
const rawAmount = editSurcharges[key]?.amount ?? '';
|
||||
const surchargeAmount = rawAmount === '' ? 0 : parseFloat(rawAmount.replace(',', '.'));
|
||||
if (rawAmount !== '' && (isNaN(surchargeAmount) || surchargeAmount < 0)) {
|
||||
setPageError('Zadejte platnou výši příplatku');
|
||||
return;
|
||||
}
|
||||
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, surchargeText, surchargeAmount: rawAmount === '' ? 0 : Math.round(surchargeAmount * 100) } }));
|
||||
if (ok) setEditSurcharges(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||
};
|
||||
|
||||
const handleSaveTimes = async (group: OrderGroup) => {
|
||||
const times = editTimes[group.id];
|
||||
if (!times) return;
|
||||
const { orderedAt, deliveryAt, boltUrl } = times;
|
||||
if (orderedAt && !TIME_REGEX.test(orderedAt)) {
|
||||
setPageError('Čas objednání musí být ve formátu HH:MM');
|
||||
return;
|
||||
}
|
||||
if (deliveryAt && !TIME_REGEX.test(deliveryAt)) {
|
||||
setPageError('Čas doručení musí být ve formátu HH:MM');
|
||||
return;
|
||||
}
|
||||
// Bolt odkaz se odesílá jen při změně oproti aktuálnímu tokenu skupiny
|
||||
const boltToken = boltUrl.trim() ? extractBoltToken(boltUrl) : null;
|
||||
if (boltUrl.trim() && !boltToken) {
|
||||
setPageError('Neplatný odkaz Bolt (očekávána URL sdílení objednávky)');
|
||||
return;
|
||||
}
|
||||
const boltChanged = (boltToken ?? undefined) !== group.boltTrackingToken;
|
||||
let ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
|
||||
if (ok && boltChanged) {
|
||||
ok = await refresh(() => setBoltTracking({ body: { id: group.id, shareUrl: boltUrl.trim() } }));
|
||||
}
|
||||
if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; });
|
||||
};
|
||||
|
||||
// Pozn.: tyto funkce se volají až v renderu, kde je k dispozici `selectedDate`.
|
||||
// Historie (jiný než aktuální den) je vždy read-only.
|
||||
const canEditMember = (group: OrderGroup, targetLogin: string) => {
|
||||
if (selectedDate) return false;
|
||||
if (group.state === GroupState.ORDERED) return false;
|
||||
if (auth?.login === group.creatorLogin) return true;
|
||||
if (auth?.login === targetLogin && group.state === GroupState.OPEN) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const canManageMembers = (group: OrderGroup) => {
|
||||
if (selectedDate) return false;
|
||||
if (group.state === GroupState.ORDERED) return false;
|
||||
if (auth?.login === group.creatorLogin) return true;
|
||||
return group.state === GroupState.OPEN;
|
||||
};
|
||||
|
||||
if (!auth?.login) return <Login />;
|
||||
|
||||
if (failure) return (
|
||||
<Loader icon={faSearch} description="Nepodařilo se načíst data" animation="fa-beat" />
|
||||
);
|
||||
|
||||
if (!data) return (
|
||||
<Loader icon={faSearch} description="Načítám..." animation="fa-bounce" />
|
||||
);
|
||||
|
||||
const stores = data.stores ?? [];
|
||||
const groups = data.groups ?? [];
|
||||
|
||||
// Zobrazené datum a režim historie (vše read-only, pokud nejde o aktuální den)
|
||||
const displayedIso = data.isoDate;
|
||||
const isToday = !selectedDate || (todayIso != null && displayedIso === todayIso);
|
||||
const isReadOnly = !isToday;
|
||||
const canGoNext = todayIso != null && displayedIso != null && displayedIso < todayIso;
|
||||
|
||||
const goToDay = (offset: number) => {
|
||||
if (!displayedIso) return;
|
||||
const target = shiftIsoDate(displayedIso, offset);
|
||||
// Na dnešek (či dál) se vracíme přes undefined, aby se obnovily živé aktualizace
|
||||
setSelectedDate(todayIso != null && target >= todayIso ? undefined : target);
|
||||
};
|
||||
|
||||
const handleDatePick = (value: string) => {
|
||||
if (!value) return;
|
||||
setSelectedDate(todayIso != null && value >= todayIso ? undefined : value);
|
||||
};
|
||||
|
||||
// Dny s objednávkou jako Date objekty pro zvýraznění v kalendáři
|
||||
const highlightedOrderDates = orderDates
|
||||
.map(d => isoToDate(d))
|
||||
.filter((d): d is Date => d != null);
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Header choices={data.choices} />
|
||||
<div className="wrapper">
|
||||
<div className="d-flex align-items-center justify-content-between mb-1">
|
||||
<h1 className="title mb-0">Objednání</h1>
|
||||
<Button variant="outline-primary" size="sm" onClick={() => setAdminModalOpen(true)} title="Správa obchodů">
|
||||
<FontAwesomeIcon icon={faGear} className="me-1" />
|
||||
Obchody
|
||||
</Button>
|
||||
</div>
|
||||
<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 ←/→) */}
|
||||
<div className="day-navigator order-day-navigator">
|
||||
<span title="Předchozí den">
|
||||
<FontAwesomeIcon icon={faChevronLeft} onClick={() => goToDay(-1)} />
|
||||
</span>
|
||||
<DatePicker
|
||||
selected={isoToDate(displayedIso)}
|
||||
onChange={(d: Date | null) => handleDatePick(d ? formatDate(d) : '')}
|
||||
maxDate={isoToDate(todayIso) ?? undefined}
|
||||
highlightDates={[{ 'luncher-order-day': highlightedOrderDates }]}
|
||||
locale="cs"
|
||||
dateFormat="d. M. yyyy"
|
||||
calendarStartDay={1}
|
||||
popperPlacement="bottom"
|
||||
className={`form-control text-center fw-semibold order-date-input ${isReadOnly ? 'text-muted' : ''}`}
|
||||
/>
|
||||
<span title="Následující den">
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
style={{ visibility: canGoNext ? 'visible' : 'hidden' }}
|
||||
onClick={() => canGoNext && goToDay(1)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isReadOnly && (
|
||||
<Alert variant="secondary" className="d-flex align-items-center gap-2 py-2">
|
||||
<FontAwesomeIcon icon={faClockRotateLeft} />
|
||||
<span>
|
||||
Prohlížíte historii ze dne <strong>{displayedIso ? formatDateString(displayedIso) : data.date}</strong> – data jsou pouze pro čtení.
|
||||
</span>
|
||||
<Button variant="link" size="sm" className="p-0 ms-auto" onClick={() => setSelectedDate(undefined)}>
|
||||
Zpět na dnešek
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{pageError && (
|
||||
<Alert variant="danger" dismissible onClose={() => setPageError(null)} className="mt-2">
|
||||
{pageError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="content-wrapper">
|
||||
<div className="content" style={{ maxWidth: 1200 }}>
|
||||
{/* Vytvoření nové skupiny – pouze pro aktuální den */}
|
||||
{!isReadOnly && (
|
||||
<div className="choice-section fade-in mb-4">
|
||||
<h5>Vytvořit skupinu</h5>
|
||||
{stores.length === 0 ? (
|
||||
<p className="text-muted">
|
||||
Nejsou přidány žádné obchody.{' '}
|
||||
<Button variant="link" size="sm" className="p-0" onClick={() => setAdminModalOpen(true)}>
|
||||
Přidat obchod
|
||||
</Button>
|
||||
</p>
|
||||
) : (
|
||||
<div className="d-flex gap-2 align-items-center flex-wrap">
|
||||
<Form.Select
|
||||
value={newGroupName}
|
||||
onChange={e => setNewGroupName(e.target.value)}
|
||||
style={{ maxWidth: 260 }}
|
||||
>
|
||||
<option value="">— vyberte obchod —</option>
|
||||
{stores.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
|
||||
</Form.Select>
|
||||
<Button variant="primary" onClick={handleCreate} disabled={creating || !newGroupName}>
|
||||
Vytvořit skupinu
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Seznam skupin */}
|
||||
{groups.length === 0 && (
|
||||
<p className="text-muted fade-in">
|
||||
{isReadOnly ? 'Pro tento den nejsou žádné skupiny.' : 'Zatím žádné skupiny pro dnešní den.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{groups.map(group => {
|
||||
const login = auth!.login ?? '';
|
||||
const isCreator = login === group.creatorLogin;
|
||||
const isMember = login in group.members;
|
||||
const isOrdered = group.state === GroupState.ORDERED;
|
||||
const isLocked = group.state === GroupState.LOCKED;
|
||||
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
|
||||
const editingTimes = group.id in editTimes;
|
||||
// URL na nabídku podniku (pokud ji má dohledatelný obchod vyplněnou).
|
||||
// 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 storeUrl = rawStoreUrl && /^https?:\/\//i.test(rawStoreUrl) ? rawStoreUrl : undefined;
|
||||
|
||||
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).
|
||||
const activeCount = countActiveMembers(group.members);
|
||||
const feeShare = computeFeeShare(totalFees, activeCount);
|
||||
const feeParams = { totalFees, discountType: group.discountType, discountValue: group.discountValue ?? 0 };
|
||||
const getMemberTotal = (m: OrderGroupMember) =>
|
||||
computeMemberTotal(m, feeParams, feeShare, activeCount);
|
||||
|
||||
return (
|
||||
<Card key={group.id} className="mb-3 fade-in">
|
||||
<Card.Header className="d-flex justify-content-between align-items-center">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
{storeUrl ? (
|
||||
<strong>
|
||||
<a href={storeUrl} target="_blank" rel="noopener noreferrer" title="Otevřít nabídku v nové záložce">
|
||||
{group.name}
|
||||
</a>
|
||||
</strong>
|
||||
) : (
|
||||
<strong>{group.name}</strong>
|
||||
)}
|
||||
{stateBadge(group.state)}
|
||||
<small className="text-muted">zakladatel: {group.creatorLogin}</small>
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
{!isReadOnly && isCreator && !isOrdered && (
|
||||
<>
|
||||
<Button variant="outline-info" size="sm" onClick={() => setFeesModal(group)} title="Upravit poplatky a slevu">
|
||||
Poplatky
|
||||
</Button>
|
||||
<Button variant="outline-secondary" size="sm" onClick={() => handleToggleLock(group)} title={isLocked ? 'Odemknout' : 'Uzamknout'}>
|
||||
<FontAwesomeIcon icon={isLocked ? faLockOpen : faLock} />
|
||||
</Button>
|
||||
{isLocked && (
|
||||
<Button variant="outline-primary" size="sm" onClick={() => setConfirmOrderGroup(group)}>
|
||||
Objednáno
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline-danger" size="sm" onClick={() => handleDelete(group.id)} title="Smazat skupinu">
|
||||
<FontAwesomeIcon icon={faTrashCan} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!isReadOnly && isCreator && isOrdered && (
|
||||
<>
|
||||
{settings?.bankAccount && settings?.holderName && !group.qrGenerated && (
|
||||
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
|
||||
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
|
||||
Generovat QR
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline-warning" size="sm" onClick={() => handleRevertOrdered(group)} title="Vrátit na Uzamčeno (smaže QR kódy)">
|
||||
<FontAwesomeIcon icon={faLockOpen} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!isReadOnly && !isMember && !isOrdered && !isLocked && (
|
||||
<Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}>
|
||||
<FontAwesomeIcon icon={faUserPlus} className="me-1" />
|
||||
Přidat se
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Body className="p-0">
|
||||
<Table className="mb-0" size="sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Člen</th>
|
||||
<th style={{ width: 180 }}>Částka (bez slev)</th>
|
||||
<th style={{ width: 220 }}>Příplatek</th>
|
||||
<th>Poznámka</th>
|
||||
<th style={{ width: 160 }}>Celkem (s poplatky)</th>
|
||||
<th style={{ width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{memberEntries.map(([memberLogin, member]) => {
|
||||
const key = `${group.id}:${memberLogin}`;
|
||||
const editingAmount = key in editAmounts;
|
||||
const editingNote = key in editNotes;
|
||||
const editingSurcharge = key in editSurcharges;
|
||||
const canEdit = canEditMember(group, memberLogin);
|
||||
const memberTotal = getMemberTotal(member);
|
||||
return (
|
||||
<tr key={memberLogin}>
|
||||
<td>
|
||||
<span className="user-info">
|
||||
<strong>{memberLogin}</strong>
|
||||
{memberLogin === group.creatorLogin && (
|
||||
<OverlayTrigger placement="top" overlay={<Tooltip>Zakladatel / objednávající</Tooltip>}>
|
||||
<span className="ms-1"><FontAwesomeIcon icon={faBasketShopping} className="buyer-icon" /></span>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
{member.paid && (
|
||||
<OverlayTrigger placement="top" overlay={<Tooltip>Zaplaceno</Tooltip>}>
|
||||
<span className="ms-1"><FontAwesomeIcon icon={faCircleCheck} className="text-success" /></span>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{canEdit && editingAmount ? (
|
||||
<div className="d-flex gap-1">
|
||||
<Form.Control
|
||||
type="number"
|
||||
size="sm"
|
||||
value={editAmounts[key]}
|
||||
onChange={e => setEditAmounts(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveAmount(group.id, memberLogin); if (e.key === 'Escape') setEditAmounts(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
|
||||
style={{ width: 95 }}
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" variant="outline-success" onClick={() => handleSaveAmount(group.id, memberLogin)}>✓</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
||||
onClick={() => canEdit && setEditAmounts(prev => ({ ...prev, [key]: member.amount != null ? String(member.amount / 100) : '' }))}
|
||||
title={canEdit ? 'Klikněte pro úpravu' : undefined}
|
||||
>
|
||||
{member.amount != null ? `${member.amount / 100} Kč` : <span className="text-muted">—</span>}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{canEdit && editingSurcharge ? (
|
||||
<div className="d-flex gap-1">
|
||||
<Form.Control
|
||||
type="text"
|
||||
size="sm"
|
||||
placeholder="popis"
|
||||
value={editSurcharges[key]?.text ?? ''}
|
||||
onChange={e => setEditSurcharges(prev => ({ ...prev, [key]: { ...prev[key], text: e.target.value } }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveSurcharge(group.id, memberLogin); if (e.key === 'Escape') setEditSurcharges(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
|
||||
style={{ width: 80 }}
|
||||
autoFocus
|
||||
/>
|
||||
<Form.Control
|
||||
type="number"
|
||||
size="sm"
|
||||
placeholder="Kč"
|
||||
value={editSurcharges[key]?.amount ?? ''}
|
||||
onChange={e => setEditSurcharges(prev => ({ ...prev, [key]: { ...prev[key], amount: e.target.value } }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveSurcharge(group.id, memberLogin); if (e.key === 'Escape') setEditSurcharges(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
<Button size="sm" variant="outline-success" onClick={() => handleSaveSurcharge(group.id, memberLogin)}>✓</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
||||
onClick={() => canEdit && setEditSurcharges(prev => ({ ...prev, [key]: { text: member.surchargeText ?? '', amount: member.surchargeAmount != null ? String(member.surchargeAmount / 100) : '' } }))}
|
||||
title={canEdit ? 'Klikněte pro úpravu příplatku' : undefined}
|
||||
>
|
||||
{member.surchargeAmount != null && member.surchargeAmount > 0 ? (
|
||||
<small>{member.surchargeText ? `${member.surchargeText}: ` : ''}<strong>{member.surchargeAmount / 100} Kč</strong></small>
|
||||
) : (
|
||||
<small className="text-muted">—</small>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{canEdit && editingNote ? (
|
||||
<div className="d-flex gap-1">
|
||||
<Form.Control
|
||||
type="text"
|
||||
size="sm"
|
||||
value={editNotes[key]}
|
||||
onChange={e => setEditNotes(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveNote(group.id, memberLogin); if (e.key === 'Escape') setEditNotes(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" variant="outline-success" onClick={() => handleSaveNote(group.id, memberLogin)}>✓</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
||||
onClick={() => canEdit && setEditNotes(prev => ({ ...prev, [key]: member.note ?? '' }))}
|
||||
title={canEdit ? 'Klikněte pro úpravu poznámky' : undefined}
|
||||
>
|
||||
<small className="text-muted">{member.note || '—'}</small>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
|
||||
{memberTotal > 0 ? `${memberTotal / 100} Kč` : '—'}
|
||||
</small>
|
||||
</td>
|
||||
<td className="align-middle">
|
||||
<div className="d-flex gap-1 justify-content-end align-items-center">
|
||||
{canManageMembers(group) && (isCreator || memberLogin === login) && (memberLogin !== group.creatorLogin) && (
|
||||
<FontAwesomeIcon
|
||||
icon={faTrashCan}
|
||||
className="action-icon"
|
||||
title={memberLogin === login ? 'Odhlásit se' : 'Odebrat z skupiny'}
|
||||
onClick={() => refresh(() => removeGroupMember({ body: { id: group.id, login: memberLogin } }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{(() => {
|
||||
const sumBase = memberEntries.reduce((sum, [, m]) => sum + (m.amount ?? 0) + (m.surchargeAmount ?? 0), 0);
|
||||
const dv = group.discountValue ?? 0;
|
||||
const totalDiscount = dv > 0
|
||||
? (group.discountType === 'percent' ? Math.round(sumBase * dv / 100) : dv)
|
||||
: 0;
|
||||
const groupTotal = sumBase + totalFees - totalDiscount;
|
||||
return groupTotal > 0 ? (
|
||||
<tfoot>
|
||||
<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 className="text-end">{groupTotal / 100} Kč</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
) : null;
|
||||
})()}
|
||||
</Table>
|
||||
|
||||
{/* Souhrn poplatků a slevy */}
|
||||
{(totalFees > 0 || (group.discountValue != null && group.discountValue > 0)) && (
|
||||
<div className="px-3 py-2 border-top d-flex gap-3 flex-wrap" style={{ fontSize: '0.85em', color: 'var(--luncher-text-muted)' }}>
|
||||
{group.fees != null && group.fees > 0 && <span>Poplatky: <strong>{group.fees / 100} Kč</strong></span>}
|
||||
{group.shipping != null && group.shipping > 0 && <span>Doprava: <strong>{group.shipping / 100} Kč</strong></span>}
|
||||
{group.tip != null && group.tip > 0 && <span>Spropitné: <strong>{group.tip / 100} Kč</strong></span>}
|
||||
{feeShare > 0 && <span>→ <strong>{feeShare / 100} Kč</strong>/os.</span>}
|
||||
{group.discountValue != null && group.discountValue > 0 && (
|
||||
<span className="text-success">
|
||||
Sleva: <strong>{group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100} Kč`}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Časy objednání a doručení */}
|
||||
{isOrdered && (
|
||||
<div className="px-3 py-2 border-top">
|
||||
{!isReadOnly && isCreator && editingTimes ? (
|
||||
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||||
<div className="d-flex align-items-center gap-1">
|
||||
<small className="text-muted text-nowrap">Objednáno v:</small>
|
||||
<Form.Control
|
||||
type="text"
|
||||
size="sm"
|
||||
placeholder="HH:MM"
|
||||
value={editTimes[group.id]?.orderedAt ?? ''}
|
||||
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], orderedAt: e.target.value } }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
|
||||
style={{ width: 75 }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-1">
|
||||
<small className="text-muted text-nowrap">Doručení v:</small>
|
||||
<Form.Control
|
||||
type="text"
|
||||
size="sm"
|
||||
placeholder="HH:MM"
|
||||
value={editTimes[group.id]?.deliveryAt ?? ''}
|
||||
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], deliveryAt: e.target.value } }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
|
||||
style={{ width: 75 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-1">
|
||||
<small className="text-muted text-nowrap">Bolt odkaz pro sledování:</small>
|
||||
<Form.Control
|
||||
type="text"
|
||||
size="sm"
|
||||
placeholder={`${BOLT_SHARE_URL_PREFIX}…`}
|
||||
value={editTimes[group.id]?.boltUrl ?? ''}
|
||||
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], boltUrl: e.target.value } }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" variant="outline-success" onClick={() => handleSaveTimes(group)}>Uložit</Button>
|
||||
<Button size="sm" variant="outline-secondary" onClick={() => setEditTimes(prev => { const n = { ...prev }; delete n[group.id]; return n; })}>Zrušit</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||||
{(() => {
|
||||
const canEdit = !isReadOnly && isCreator;
|
||||
// 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.boltTrackingToken ? `${BOLT_SHARE_URL_PREFIX}${group.boltTrackingToken}` : null;
|
||||
return (
|
||||
<>
|
||||
{canEdit && (
|
||||
<FontAwesomeIcon
|
||||
icon={faPen}
|
||||
className="action-icon ms-0"
|
||||
title="Upravit časy a odkaz pro sledování"
|
||||
onClick={startEdit}
|
||||
/>
|
||||
)}
|
||||
<small
|
||||
className="text-muted"
|
||||
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
||||
onClick={startEdit}
|
||||
>
|
||||
Objednáno v: <strong>{group.orderedAt ?? '—'}</strong>
|
||||
</small>
|
||||
<small
|
||||
className="text-muted"
|
||||
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
||||
onClick={startEdit}
|
||||
>
|
||||
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
|
||||
{group.boltTrackingToken && (
|
||||
<OverlayTrigger overlay={<Tooltip>Čas doručení se aktualizuje automaticky z Bolt Food</Tooltip>}>
|
||||
<Badge bg="success" className="ms-1">Bolt</Badge>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</small>
|
||||
<small className="text-muted text-nowrap">
|
||||
URL pro sledování:{' '}
|
||||
{trackingUrl ? (
|
||||
<a
|
||||
href={trackingUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={trackingUrl}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{shortenUrl(trackingUrl)}
|
||||
</a>
|
||||
) : (
|
||||
<strong>—</strong>
|
||||
)}
|
||||
</small>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{group.boltOrderState && (
|
||||
<div className="mt-2">
|
||||
<BoltOrderProgress state={group.boltOrderState} courierState={group.boltCourierState} tracking={!!group.boltTrackingToken} />
|
||||
</div>
|
||||
)}
|
||||
{IS_DEV && (
|
||||
<div className="mt-2">
|
||||
<Button variant="outline-warning" size="sm" onClick={() => setBoltSimModal(group)} title="Simulovat sledování Bolt (DEV)">
|
||||
🔧 Simulace Bolt
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Nevyřízené platby přihlášeného uživatele – jen v režimu aktuálního dne */}
|
||||
{!isReadOnly && (
|
||||
<PendingPayments
|
||||
pendingQrs={data.pendingQrs}
|
||||
login={auth.login}
|
||||
onDismissed={() => fetchData()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
|
||||
{/* Potvrzovací dialog pro přechod do stavu Objednáno */}
|
||||
<Modal show={!!confirmOrderGroup} onHide={() => setConfirmOrderGroup(null)} centered>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Potvrdit objednání</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
Opravdu chcete označit skupinu <strong>{confirmOrderGroup?.name}</strong> jako objednanou?
|
||||
Tato akce uzavře skupinu a zaznamená čas objednání.
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={() => setConfirmOrderGroup(null)}>Zrušit</Button>
|
||||
<Button variant="primary" onClick={() => confirmOrderGroup && handleConfirmOrdered(confirmOrderGroup)}>
|
||||
Objednáno
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
|
||||
<StoreAdminModal
|
||||
isOpen={adminModalOpen}
|
||||
onClose={() => setAdminModalOpen(false)}
|
||||
stores={stores}
|
||||
onStoresChanged={updated => setData(prev => prev ? { ...prev, stores: updated } : prev)}
|
||||
/>
|
||||
|
||||
{payModal && settings?.bankAccount && settings?.holderName && (
|
||||
<PayForGroupModal
|
||||
isOpen={!!payModal}
|
||||
onClose={() => setPayModal(null)}
|
||||
onSuccess={() => fetchData()}
|
||||
group={payModal}
|
||||
groupId={payModal.id}
|
||||
payerLogin={auth.login}
|
||||
bankAccount={settings.bankAccount}
|
||||
bankAccountHolder={settings.holderName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{feesModal && (
|
||||
<EditGroupFeesModal
|
||||
isOpen={!!feesModal}
|
||||
onClose={() => setFeesModal(null)}
|
||||
group={feesModal}
|
||||
onSaved={newData => {
|
||||
if (newData) {
|
||||
setData(newData);
|
||||
socket.emit?.('message', newData as ClientData);
|
||||
}
|
||||
setFeesModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{IS_DEV && boltSimModal && (
|
||||
<BoltSimulationModal
|
||||
isOpen={!!boltSimModal}
|
||||
onClose={() => setBoltSimModal(null)}
|
||||
// živá skupina z dat (aktualizuje se přes websocket), fallback na snapshot
|
||||
group={groups.find(g => g.id === boltSimModal.id) ?? boltSimModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,47 +46,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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
|
||||
.recharts-wrapper {
|
||||
background: var(--luncher-bg-card);
|
||||
@@ -130,4 +89,67 @@
|
||||
.recharts-cartesian-grid-vertical line {
|
||||
stroke: var(--luncher-border);
|
||||
}
|
||||
|
||||
.voting-stats-section {
|
||||
margin-top: 48px;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--luncher-text);
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.voting-stats-table {
|
||||
width: 100%;
|
||||
background: var(--luncher-bg-card);
|
||||
border-radius: var(--luncher-radius-lg);
|
||||
box-shadow: var(--luncher-shadow);
|
||||
border: 1px solid var(--luncher-border-light);
|
||||
overflow: hidden;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
background: var(--luncher-primary);
|
||||
color: #ffffff;
|
||||
padding: 12px 20px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:last-child {
|
||||
text-align: center;
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--luncher-border-light);
|
||||
color: var(--luncher-text);
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:last-child {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: var(--luncher-primary);
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
transition: var(--luncher-transition);
|
||||
|
||||
&:hover {
|
||||
background: var(--luncher-bg-hover);
|
||||
}
|
||||
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ import Header from "../components/Header";
|
||||
import { useAuth } from "../context/auth";
|
||||
import Login from "../Login";
|
||||
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils";
|
||||
import { WeeklyStats, LunchChoice, getStats, getStatsExport } from "../../../types";
|
||||
import { WeeklyStats, LunchChoice, VotingStats, FeatureRequest, getStats, getVotingStats } from "../../../types";
|
||||
import Loader from "../components/Loader";
|
||||
import { faChevronLeft, faChevronRight, faGear, faFileExcel, faFileCsv, faFileCode } from "@fortawesome/free-solid-svg-icons";
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { getLunchChoiceName } from "../enums";
|
||||
@@ -29,35 +28,11 @@ const COLORS = [
|
||||
'#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() {
|
||||
const auth = useAuth();
|
||||
const [dateRange, setDateRange] = useState<Date[]>();
|
||||
const [data, setData] = useState<WeeklyStats>();
|
||||
const [exportMonth, setExportMonth] = useState<string>(() => getMonthValue(new Date()));
|
||||
const [exportFormat, setExportFormat] = useState<ExportFormat>('xlsx');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [votingStats, setVotingStats] = useState<VotingStats>();
|
||||
|
||||
// Prvotní nastavení aktuálního týdne
|
||||
useEffect(() => {
|
||||
@@ -74,36 +49,24 @@ export default function StatsPage() {
|
||||
}
|
||||
}, [dateRange]);
|
||||
|
||||
// Načtení statistik hlasování
|
||||
useEffect(() => {
|
||||
getVotingStats().then(response => {
|
||||
setVotingStats(response.data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const sortedVotingStats = useMemo(() => {
|
||||
if (!votingStats) return [];
|
||||
return Object.entries(votingStats)
|
||||
.sort((a, b) => (b[1] as number) - (a[1] as number));
|
||||
}, [votingStats]);
|
||||
|
||||
const renderLine = (location: LunchChoice) => {
|
||||
const index = Object.values(LunchChoice).indexOf(location);
|
||||
return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
|
||||
}
|
||||
|
||||
/** 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 = () => {
|
||||
if (dateRange) {
|
||||
const previousStartDate = new Date(dateRange[0]);
|
||||
@@ -179,40 +142,27 @@ export default function StatsPage() {
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</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>
|
||||
{sortedVotingStats.length > 0 && (
|
||||
<div className="voting-stats-section">
|
||||
<h2>Hlasování o funkcích</h2>
|
||||
<table className="voting-stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Funkce</th>
|
||||
<th>Počet hlasů</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedVotingStats.map(([feature, count]) => (
|
||||
<tr key={feature}>
|
||||
<td>{FeatureRequest[feature as keyof typeof FeatureRequest] ?? feature}</td>
|
||||
<td>{count as number}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
.suggestions-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 32px 24px;
|
||||
min-height: calc(100vh - 140px);
|
||||
background: var(--luncher-bg);
|
||||
|
||||
.suggestions-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--luncher-text);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestions-info {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 12px 0 24px;
|
||||
color: var(--luncher-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.suggestions-empty {
|
||||
color: var(--luncher-text-secondary);
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.resolved-section {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin-top: 48px;
|
||||
|
||||
h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--luncher-text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestions-table.resolved {
|
||||
th {
|
||||
background: var(--luncher-text-secondary);
|
||||
}
|
||||
|
||||
td.col-score {
|
||||
color: var(--luncher-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.suggestions-table {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
background: var(--luncher-bg-card);
|
||||
border-radius: var(--luncher-radius-lg);
|
||||
box-shadow: var(--luncher-shadow);
|
||||
border: 1px solid var(--luncher-border-light);
|
||||
overflow: hidden;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
background: var(--luncher-primary);
|
||||
color: #ffffff;
|
||||
padding: 12px 20px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--luncher-border-light);
|
||||
color: var(--luncher-text);
|
||||
font-size: 0.9rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.col-score {
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
td.col-score {
|
||||
color: var(--luncher-primary);
|
||||
}
|
||||
|
||||
.col-actions {
|
||||
text-align: center;
|
||||
width: 150px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
transition: var(--luncher-transition);
|
||||
|
||||
&:hover {
|
||||
background: var(--luncher-bg-hover);
|
||||
}
|
||||
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.vote-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--luncher-radius-sm, 6px);
|
||||
color: var(--luncher-text-secondary);
|
||||
transition: var(--luncher-transition);
|
||||
|
||||
&:hover {
|
||||
background: var(--luncher-bg-hover);
|
||||
color: var(--luncher-text);
|
||||
}
|
||||
|
||||
&.vote-up.active {
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
&.vote-down.active {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
&.delete-btn:hover {
|
||||
color: #c62828;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button, OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faThumbsUp, faThumbsDown, faTrash, faPlus, faGear } from "@fortawesome/free-solid-svg-icons";
|
||||
import Header from "../components/Header";
|
||||
import Footer from "../components/Footer";
|
||||
import Loader from "../components/Loader";
|
||||
import { useAuth } from "../context/auth";
|
||||
import Login from "../Login";
|
||||
import AddSuggestionModal from "../components/modals/AddSuggestionModal";
|
||||
import SuggestionDetailModal from "../components/modals/SuggestionDetailModal";
|
||||
import {
|
||||
Suggestion,
|
||||
VoteDirection,
|
||||
listSuggestions,
|
||||
addSuggestion,
|
||||
voteSuggestion,
|
||||
deleteSuggestion,
|
||||
} from "../../../types";
|
||||
import "./SuggestionsPage.scss";
|
||||
|
||||
export default function SuggestionsPage() {
|
||||
const auth = useAuth();
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>();
|
||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||
const [detail, setDetail] = useState<Suggestion>();
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!auth?.login) return;
|
||||
const response = await listSuggestions();
|
||||
setSuggestions(response.data ?? []);
|
||||
}, [auth?.login]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const handleAdd = async (title: string, description: string) => {
|
||||
const response = await addSuggestion({ body: { title, description } });
|
||||
if (response.data) {
|
||||
setSuggestions(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVote = async (id: string, direction: VoteDirection) => {
|
||||
const response = await voteSuggestion({ body: { id, direction } });
|
||||
if (response.data) {
|
||||
setSuggestions(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (suggestion: Suggestion) => {
|
||||
if (!window.confirm(`Opravdu chcete smazat návrh „${suggestion.title}“? Smažou se i všechny jeho hlasy.`)) {
|
||||
return;
|
||||
}
|
||||
const response = await deleteSuggestion({ body: { id: suggestion.id } });
|
||||
if (response.data) {
|
||||
setSuggestions(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
// Vykreslí jeden řádek tabulky. Vyřešené návrhy jsou read-only (bez hlasování),
|
||||
// ale autor je stále může smazat.
|
||||
const renderRow = (suggestion: Suggestion) => (
|
||||
<OverlayTrigger
|
||||
key={suggestion.id}
|
||||
placement="top"
|
||||
overlay={<Tooltip id={`tooltip-${suggestion.id}`}>{suggestion.description}</Tooltip>}
|
||||
>
|
||||
<tr onClick={() => setDetail(suggestion)}>
|
||||
<td>{suggestion.author}</td>
|
||||
<td>{suggestion.title}</td>
|
||||
<td className="col-score">{suggestion.voteScore}</td>
|
||||
<td className="col-actions" onClick={e => e.stopPropagation()}>
|
||||
{!suggestion.resolved && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`vote-btn vote-up ${suggestion.myVote === VoteDirection.UP ? "active" : ""}`}
|
||||
title="Hlasovat pro"
|
||||
onClick={() => handleVote(suggestion.id, VoteDirection.UP)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faThumbsUp} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vote-btn vote-down ${suggestion.myVote === VoteDirection.DOWN ? "active" : ""}`}
|
||||
title="Hlasovat proti"
|
||||
onClick={() => handleVote(suggestion.id, VoteDirection.DOWN)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faThumbsDown} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{suggestion.isMine && (
|
||||
<button
|
||||
type="button"
|
||||
className="vote-btn delete-btn"
|
||||
title="Smazat návrh"
|
||||
onClick={() => handleDelete(suggestion)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
|
||||
if (!auth?.login) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
if (!suggestions) {
|
||||
return <Loader icon={faGear} description={"Načítám návrhy..."} animation={"fa-bounce"} />;
|
||||
}
|
||||
|
||||
const activeSuggestions = suggestions.filter(s => !s.resolved);
|
||||
const resolvedSuggestions = suggestions.filter(s => s.resolved);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<div className="suggestions-page">
|
||||
<div className="suggestions-header">
|
||||
<h1>Návrhy na vylepšení</h1>
|
||||
<Button onClick={() => setAddModalOpen(true)}>
|
||||
<FontAwesomeIcon icon={faPlus} /> Přidat návrh
|
||||
</Button>
|
||||
</div>
|
||||
<p className="suggestions-info">
|
||||
Zde můžete navrhovat vylepšení aplikace a hlasovat o návrzích ostatních. U každého návrhu je
|
||||
zobrazeno jméno navrhovatele. Jména hlasujících jsou dostupná pouze administrátorům.
|
||||
</p>
|
||||
|
||||
{suggestions.length === 0 ? (
|
||||
<p className="suggestions-empty">Zatím nebyly přidány žádné návrhy. Buďte první!</p>
|
||||
) : (
|
||||
<>
|
||||
{activeSuggestions.length > 0 && (
|
||||
<table className="suggestions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Navrhovatel</th>
|
||||
<th>Název</th>
|
||||
<th className="col-score">Hlasy</th>
|
||||
<th className="col-actions">Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeSuggestions.map(renderRow)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{resolvedSuggestions.length > 0 && (
|
||||
<div className="resolved-section">
|
||||
<h2>Vyřešené návrhy</h2>
|
||||
<p className="suggestions-info">
|
||||
Tyto návrhy již byly zapracovány. Nelze pro ně hlasovat, autor je však může odstranit.
|
||||
</p>
|
||||
<table className="suggestions-table resolved">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Navrhovatel</th>
|
||||
<th>Název</th>
|
||||
<th className="col-score">Hlasy</th>
|
||||
<th className="col-actions">Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resolvedSuggestions.map(renderRow)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Footer />
|
||||
<AddSuggestionModal isOpen={addModalOpen} onClose={() => setAddModalOpen(false)} onSubmit={handleAdd} />
|
||||
<SuggestionDetailModal suggestion={detail} onClose={() => setDetail(undefined)} />
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { OrderGroup, OrderGroupMember } from "../../../types";
|
||||
|
||||
/**
|
||||
* Pomocné funkce pro výpočet částek ve skupinových objednávkách.
|
||||
*
|
||||
* Klíčové pravidlo: poplatky (balné + doprava + spropitné) se rozpočítávají
|
||||
* pouze mezi "aktivní" strávníky — tedy ty, kteří si reálně něco objednali.
|
||||
* Kdo si nic neobjedná (typicky objednávající, který nakupuje jen pro ostatní),
|
||||
* neplatí nic a nezapočítává se mu ani poměrná část poplatků.
|
||||
*/
|
||||
|
||||
/** Parametry poplatků a slevy potřebné k výpočtu částky člena. */
|
||||
export type GroupFeeParams = {
|
||||
/** Celkové poplatky skupiny v haléřích (balné + doprava + spropitné). */
|
||||
totalFees: number;
|
||||
/** Typ slevy ('percent' = procenta, 'fixed' = pevná částka v haléřích). */
|
||||
discountType?: string;
|
||||
/** Hodnota slevy — procenta, nebo pevná částka v haléřích dle discountType. */
|
||||
discountValue?: number;
|
||||
};
|
||||
|
||||
/** Vrátí true, pokud si člen něco objednal (má kladnou částku nebo příplatek). */
|
||||
export function isActiveMember(member: OrderGroupMember): boolean {
|
||||
return (member.amount ?? 0) + (member.surchargeAmount ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** Počet aktivních strávníků — jen mezi ně se dělí poplatky. */
|
||||
export function countActiveMembers(members: OrderGroup["members"]): number {
|
||||
return Object.values(members).filter(isActiveMember).length;
|
||||
}
|
||||
|
||||
/** Celkové poplatky skupiny (balné + doprava + spropitné) v haléřích. */
|
||||
export function totalGroupFees(group: OrderGroup): number {
|
||||
return (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
|
||||
}
|
||||
|
||||
/** Poměrná část poplatků na jednoho aktivního strávníka v haléřích. */
|
||||
export function computeFeeShare(totalFees: number, activeCount: number): number {
|
||||
return activeCount > 0 ? Math.round(totalFees / activeCount) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Celková částka, kterou má člen zaplatit (v haléřích).
|
||||
* Neaktivní člen (nic si neobjednal) platí 0 — nepodílí se ani na poplatcích.
|
||||
*
|
||||
* @param member člen skupiny
|
||||
* @param params poplatky a sleva
|
||||
* @param feeShare poměrná část poplatků na osobu (viz computeFeeShare)
|
||||
* @param activeCount počet aktivních strávníků (dělitel pevné slevy)
|
||||
*/
|
||||
export function computeMemberTotal(
|
||||
member: OrderGroupMember,
|
||||
params: GroupFeeParams,
|
||||
feeShare: number,
|
||||
activeCount: number,
|
||||
): number {
|
||||
if (!isActiveMember(member)) return 0;
|
||||
const base = member.amount ?? 0;
|
||||
const surcharge = member.surchargeAmount ?? 0;
|
||||
const discountValue = params.discountValue ?? 0;
|
||||
const discount = discountValue > 0
|
||||
? (params.discountType === 'percent'
|
||||
? Math.round((base + surcharge) * discountValue / 100)
|
||||
: Math.round(discountValue / activeCount))
|
||||
: 0;
|
||||
return base + surcharge + feeShare - discount;
|
||||
}
|
||||
@@ -6,13 +6,8 @@ export default defineConfig({
|
||||
// depending on your application, base can also be "/"
|
||||
base: '',
|
||||
plugins: [react(), viteTsconfigPaths()],
|
||||
build: {
|
||||
// Veřejné sourcemapy, aby Sentry umělo přeložit minifikované stack traces
|
||||
sourcemap: true,
|
||||
},
|
||||
server: {
|
||||
open: true,
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3001',
|
||||
|
||||
@@ -4,10 +4,7 @@ import path from 'path';
|
||||
// Use 127.0.0.1 explicitly — on Node.js 18+/Windows, `localhost` may resolve to ::1
|
||||
// (IPv6) while the HTTP server only binds to 0.0.0.0 (IPv4), causing the webServer
|
||||
// readiness poll to time out even though the server is listening.
|
||||
// Port 3099 avoids conflicts with locally running Docker containers on 3001-3003.
|
||||
// Override with E2E_PORT env var if needed.
|
||||
const E2E_PORT = process.env.E2E_PORT ?? '3099';
|
||||
const BASE_URL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${E2E_PORT}`;
|
||||
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3001';
|
||||
|
||||
// Server env vars injected for local runs. In CI these are set at the step level.
|
||||
const serverEnv: Record<string, string> = {
|
||||
@@ -18,7 +15,6 @@ const serverEnv: Record<string, string> = {
|
||||
HTTP_REMOTE_USER_ENABLED: 'true',
|
||||
HTTP_REMOTE_USER_HEADER_NAME: 'remote-user',
|
||||
HTTP_REMOTE_TRUSTED_IPS: process.env.HTTP_REMOTE_TRUSTED_IPS ?? '127.0.0.1,::1,::ffff:127.0.0.1',
|
||||
PORT: E2E_PORT,
|
||||
};
|
||||
if (process.env.REDIS_HOST) {
|
||||
serverEnv.REDIS_HOST = process.env.REDIS_HOST;
|
||||
@@ -54,7 +50,7 @@ export default defineConfig({
|
||||
cwd: path.resolve(__dirname, '../server'),
|
||||
// Poll a dedicated health endpoint — polling '/' can stall in Express 5 when
|
||||
// server/public/ doesn't exist in the working directory (no finalhandler match).
|
||||
url: `http://127.0.0.1:${E2E_PORT}/api/health`,
|
||||
url: `http://127.0.0.1:3001/api/health`,
|
||||
timeout: 15_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: serverEnv,
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginViaApi } from './helpers';
|
||||
|
||||
const BOLT_LABEL = 'Upozornění na doručení objednávky (Bolt Food)';
|
||||
|
||||
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í Bolt 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 boltGroup = page.locator('.modal-body .mb-3').filter({ hasText: BOLT_LABEL });
|
||||
await expect(reminderGroup).toBeVisible();
|
||||
await expect(boltGroup).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')),
|
||||
bolt: groups.findIndex(g => g.textContent?.includes(label)),
|
||||
};
|
||||
}, BOLT_LABEL);
|
||||
expect(order.reminder).toBeGreaterThanOrEqual(0);
|
||||
expect(order.bolt).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 });
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
# Kubernetes — Luncher HA
|
||||
|
||||
Manifesty pro nasazení Luncheru na Kubernetes s vysokou dostupností (3 repliky, Redis adapter pro Socket.io, WATCH/MULTI atomické zápisy, graceful shutdown).
|
||||
|
||||
## Prerekvizity
|
||||
|
||||
- kubectl nakonfigurovaný na cílový cluster
|
||||
- `helm` nainstalovaný
|
||||
- Redis Stack image přístupný z clusteru (`redis/redis-stack-server:7.2.0-v14`)
|
||||
- Obraz `luncher:ha-test` načtený do clusteru (viz níže)
|
||||
|
||||
## Lokální kind cluster (testik) — setup
|
||||
|
||||
### 1. Smazat a znovu vytvořit cluster s port mappings
|
||||
|
||||
```powershell
|
||||
$env:KIND_EXPERIMENTAL_PROVIDER = "nerdctl"
|
||||
# Přidat nerdctl do PATH (Rancher Desktop)
|
||||
$env:PATH += ";$env:LOCALAPPDATA\Programs\Rancher Desktop\resources\resources\win32\bin"
|
||||
|
||||
kind delete cluster --name testik
|
||||
kind create cluster --name testik --config k8s/kind/testik.yaml
|
||||
```
|
||||
|
||||
### 2. Sestavit a načíst obraz
|
||||
|
||||
```powershell
|
||||
docker build -t luncher:ha-test .
|
||||
|
||||
# Uložit a načíst přes nerdctl (kind + nerdctl provider)
|
||||
nerdctl save luncher:ha-test -o luncher.tar
|
||||
kind load image-archive luncher.tar --name testik
|
||||
Remove-Item luncher.tar
|
||||
```
|
||||
|
||||
### 3. Nainstalovat Traefik (rke2-traefik)
|
||||
|
||||
> **Prerekvizita (Rancher Desktop):** Pokud Rancher Desktop běží s `kubernetes.options.traefik=true`,
|
||||
> host-switch.exe obsadí port 80 dříve než kind. Vypni traefik v k3s:
|
||||
> ```powershell
|
||||
> rdctl set --kubernetes.options.traefik=false
|
||||
> ```
|
||||
>
|
||||
> **Prerekvizita — inotify limity:** Čtyř-uzlový kind cluster vyčerpá výchozí
|
||||
> `fs.inotify.max_user_instances=128`. kube-proxy pak padá s „too many open files".
|
||||
> Zvyš limit v rancher-desktop WSL2 (přežije restart WSL2, ale ne reboot — přidej do
|
||||
> `/etc/sysctl.d/99-kind.conf` pro trvalost):
|
||||
> ```powershell
|
||||
> wsl -d rancher-desktop -- sysctl -w fs.inotify.max_user_instances=1280
|
||||
> ```
|
||||
|
||||
```powershell
|
||||
# rke2-traefik je v rke2-charts, ne rancher-charts
|
||||
helm repo add rke2-charts https://rke2-charts.rancher.io
|
||||
helm repo update
|
||||
|
||||
# Nejdřív CRD chart, pak samotný chart
|
||||
helm install traefik-crd rke2-charts/rke2-traefik-crd -n kube-system --create-namespace
|
||||
helm install traefik rke2-charts/rke2-traefik -n kube-system `
|
||||
--set "tolerations[0].key=node-role.kubernetes.io/control-plane" `
|
||||
--set "tolerations[0].operator=Exists" `
|
||||
--set "tolerations[0].effect=NoSchedule"
|
||||
```
|
||||
|
||||
Ověř že Traefik DaemonSet běží na control-plane (má hostPort 80):
|
||||
```powershell
|
||||
kubectl get ds -n kube-system traefik-rke2-traefik
|
||||
kubectl get pods -n kube-system -o wide | Select-String traefik
|
||||
```
|
||||
|
||||
### 4. Nainstalovat Reloader
|
||||
|
||||
[stakater/Reloader](https://github.com/stakater/Reloader) sleduje změny Secret a ConfigMap a automaticky spustí rolling restart Deploymentu — odpadá nutnost ručního `kubectl rollout restart` po rotaci `JWT_SECRET` nebo `ADMIN_PASSWORD`.
|
||||
|
||||
Manifest je vendorovaný ve verzi v1.4.16 (`k8s/base/reloader.yaml`). Nasadit do `default` namespace:
|
||||
|
||||
```powershell
|
||||
kubectl apply -f k8s/base/reloader.yaml
|
||||
kubectl rollout status deploy/reloader-reloader
|
||||
```
|
||||
|
||||
Reloader běží cluster-wide díky `ClusterRoleBinding` — nepotřebuje žádnou konfiguraci per-namespace. Deployment Luncheru má anotaci `reloader.stakater.com/auto: "true"`, která říká Reloaderu, ať sleduje všechny Secrety a ConfigMapy odkazované přes `envFrom`.
|
||||
|
||||
### 5. Nasadit Luncher
|
||||
|
||||
```powershell
|
||||
# Namespace + Redis
|
||||
kubectl apply -f k8s/base/namespace.yaml
|
||||
kubectl apply -f k8s/base/redis-statefulset.yaml
|
||||
kubectl apply -f k8s/base/redis-service.yaml
|
||||
|
||||
# Počkat na Redis
|
||||
kubectl rollout status statefulset/redis -n luncher
|
||||
|
||||
# Server secret (nebo použít šablonu server-secret.yaml)
|
||||
kubectl create secret generic luncher-secrets -n luncher `
|
||||
--from-literal=JWT_SECRET=dev-secret-change-me `
|
||||
--from-literal=ADMIN_PASSWORD=admin
|
||||
|
||||
# Server
|
||||
kubectl apply -f k8s/base/server-configmap.yaml
|
||||
kubectl apply -f k8s/base/server-deployment.yaml
|
||||
kubectl apply -f k8s/base/server-service.yaml
|
||||
kubectl apply -f k8s/base/server-pdb.yaml
|
||||
kubectl apply -f k8s/base/ingressroute.yaml
|
||||
|
||||
# Počkat na server
|
||||
kubectl rollout status deploy/luncher -n luncher
|
||||
```
|
||||
|
||||
## Testovací scénáře
|
||||
|
||||
### Baseline
|
||||
|
||||
```powershell
|
||||
kubectl get pods -n luncher -o wide
|
||||
# Ověř: 3 pody na 3 různých worker uzlech, status Running
|
||||
```
|
||||
|
||||
### Rolling update bez výpadku
|
||||
|
||||
V jednom terminálu posílej provoz:
|
||||
```powershell
|
||||
# Nainstaluj hey: go install github.com/rakyll/hey@latest
|
||||
hey -z 60s -c 20 http://luncher.localhost/api/health
|
||||
```
|
||||
|
||||
Ve druhém terminálu spusť rollout:
|
||||
```powershell
|
||||
kubectl rollout restart deploy/luncher -n luncher
|
||||
```
|
||||
|
||||
**Kritérium: 0 non-2xx odpovědí, 0 connection errors.**
|
||||
|
||||
### Node drain
|
||||
|
||||
```powershell
|
||||
kubectl cordon testik-worker2
|
||||
kubectl drain testik-worker2 --ignore-daemonsets --delete-emptydir-data
|
||||
# PDB zabrání souběžnému drainu druhého nodu
|
||||
kubectl get pods -n luncher -o wide # pody se přeplánují
|
||||
kubectl uncordon testik-worker2
|
||||
```
|
||||
|
||||
### Ověření Socket.io cross-pod
|
||||
|
||||
1. Otevři dvě záložky prohlížeče na `http://luncher.localhost`
|
||||
2. Z jednoho podu vyvolej změnu:
|
||||
```powershell
|
||||
kubectl exec -it deploy/luncher -n luncher -- curl -s -X POST localhost:3001/api/...
|
||||
```
|
||||
3. Ověř, že druhá záložka (pravděpodobně jiný pod) obdrží WebSocket event
|
||||
|
||||
### Concurrent write test
|
||||
|
||||
1. Otevři stejnou Pizza day objednávku ve dvou záložkách
|
||||
2. Simuluj souběžné odeslání (otevřít DevTools → síť → odeslat obě požadavky současně)
|
||||
3. Ověř Redis: `kubectl exec -it redis-0 -n luncher -- redis-cli JSON.GET luncher:<datum>`
|
||||
— oba zápisy musí být zachovány (WATCH/MULTI retry)
|
||||
|
||||
### Auto-rollout při změně Secret / ConfigMap
|
||||
|
||||
Reloader automaticky spustí rolling restart, kdykoli se změní `luncher-secrets` nebo `luncher-config`:
|
||||
|
||||
```powershell
|
||||
# Příklad: rotace admin hesla
|
||||
kubectl -n luncher patch secret luncher-secrets --type=merge `
|
||||
-p '{"stringData":{"ADMIN_PASSWORD":"nove-heslo"}}'
|
||||
|
||||
# Reloader detekuje změnu resourceVersion a patchne pod template
|
||||
kubectl rollout status deploy/luncher -n luncher
|
||||
|
||||
# Ověř anotaci přidanou Reloaderem na pod template
|
||||
kubectl get deploy luncher -n luncher -o yaml | Select-String "STAKATER"
|
||||
```
|
||||
|
||||
**Kritérium: pody se automaticky vyrolují bez ručního restartu. PDB zajistí, že alespoň jeden pod zůstane dostupný.**
|
||||
|
||||
## Pořadí aplikace manifestů
|
||||
|
||||
1. `reloader.yaml` (do `default` namespace — musí být před Deployment)
|
||||
2. `namespace.yaml`
|
||||
3. `redis-statefulset.yaml` + `redis-service.yaml`
|
||||
4. `server-configmap.yaml` + `server-secret.yaml`
|
||||
5. `server-deployment.yaml` + `server-service.yaml` + `server-pdb.yaml`
|
||||
6. `ingressroute.yaml`
|
||||
@@ -1,16 +0,0 @@
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: luncher
|
||||
namespace: luncher
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: traefik
|
||||
spec:
|
||||
entryPoints:
|
||||
- web
|
||||
routes:
|
||||
- match: Host(`luncher.localhost`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: luncher
|
||||
port: 3001
|
||||
@@ -1,4 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: luncher
|
||||
@@ -1,12 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: luncher
|
||||
spec:
|
||||
clusterIP: None # headless — StatefulSet pod discovery
|
||||
selector:
|
||||
app: redis
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
@@ -1,50 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: luncher
|
||||
spec:
|
||||
serviceName: redis
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
# Redis Stack je nutný — aplikace používá JSON.GET / JSON.SET (modul RedisJSON)
|
||||
image: redis/redis-stack-server:7.2.0-v14
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
@@ -1,184 +0,0 @@
|
||||
# stakater/Reloader v1.4.16
|
||||
# Zdroj: https://raw.githubusercontent.com/stakater/Reloader/v1.4.16/deployments/kubernetes/reloader.yaml
|
||||
# Aktualizace: stáhnout novou verzi ze stejné URL a nahradit tento soubor.
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: reloader-reloader
|
||||
namespace: default
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: reloader-reloader-metadata-role
|
||||
namespace: default
|
||||
rules:
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- configmaps
|
||||
verbs:
|
||||
- list
|
||||
- get
|
||||
- watch
|
||||
- create
|
||||
- update
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: reloader-reloader-role
|
||||
rules:
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- secrets
|
||||
- configmaps
|
||||
verbs:
|
||||
- list
|
||||
- get
|
||||
- watch
|
||||
- apiGroups:
|
||||
- apps
|
||||
resources:
|
||||
- deployments
|
||||
- daemonsets
|
||||
- statefulsets
|
||||
verbs:
|
||||
- list
|
||||
- get
|
||||
- update
|
||||
- patch
|
||||
- apiGroups:
|
||||
- extensions
|
||||
resources:
|
||||
- deployments
|
||||
- daemonsets
|
||||
verbs:
|
||||
- list
|
||||
- get
|
||||
- update
|
||||
- patch
|
||||
- apiGroups:
|
||||
- batch
|
||||
resources:
|
||||
- cronjobs
|
||||
verbs:
|
||||
- list
|
||||
- get
|
||||
- apiGroups:
|
||||
- batch
|
||||
resources:
|
||||
- jobs
|
||||
verbs:
|
||||
- create
|
||||
- delete
|
||||
- list
|
||||
- get
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- events
|
||||
verbs:
|
||||
- create
|
||||
- patch
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: reloader-reloader-metadata-rolebinding
|
||||
namespace: default
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: reloader-reloader-metadata-role
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: reloader-reloader
|
||||
namespace: default
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: reloader-reloader-role-binding
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: reloader-reloader-role
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: reloader-reloader
|
||||
namespace: default
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: reloader-reloader
|
||||
namespace: default
|
||||
spec:
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: reloader-reloader
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: reloader-reloader
|
||||
spec:
|
||||
containers:
|
||||
- env:
|
||||
- name: GOMAXPROCS
|
||||
valueFrom:
|
||||
resourceFieldRef:
|
||||
divisor: "1"
|
||||
resource: limits.cpu
|
||||
- name: GOMEMLIMIT
|
||||
valueFrom:
|
||||
resourceFieldRef:
|
||||
divisor: "1"
|
||||
resource: limits.memory
|
||||
- name: RELOADER_NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: RELOADER_DEPLOYMENT_NAME
|
||||
value: reloader-reloader
|
||||
image: ghcr.io/stakater/reloader:v1.4.16
|
||||
imagePullPolicy: IfNotPresent
|
||||
livenessProbe:
|
||||
failureThreshold: 5
|
||||
httpGet:
|
||||
path: /live
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
successThreshold: 1
|
||||
timeoutSeconds: 5
|
||||
name: reloader-reloader
|
||||
ports:
|
||||
- containerPort: 9090
|
||||
name: http
|
||||
readinessProbe:
|
||||
failureThreshold: 5
|
||||
httpGet:
|
||||
path: /metrics
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
successThreshold: 1
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 512Mi
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 512Mi
|
||||
securityContext: {}
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
serviceAccountName: reloader-reloader
|
||||
@@ -1,12 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: luncher-config
|
||||
namespace: luncher
|
||||
data:
|
||||
NODE_ENV: production
|
||||
STORAGE: redis
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: "6379"
|
||||
PORT: "3001"
|
||||
HOST: "0.0.0.0"
|
||||
@@ -1,85 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: luncher
|
||||
namespace: luncher
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: luncher
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 0 # nelze přidat extra pod — každý worker je obsazen
|
||||
maxUnavailable: 1 # nejdřív smaž starý pod, pak naplánuj nový
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: luncher
|
||||
annotations:
|
||||
reloader.stakater.com/auto: "true"
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 30
|
||||
|
||||
# Rozmístit každý pod na jiný worker uzel
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchLabels:
|
||||
app: luncher
|
||||
topologyKey: kubernetes.io/hostname
|
||||
|
||||
containers:
|
||||
- name: luncher
|
||||
image: luncher:ha-test
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 3001
|
||||
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: luncher-config
|
||||
- secretRef:
|
||||
name: luncher-secrets
|
||||
|
||||
env:
|
||||
# POD_ID pro leader election scheduleru připomínek
|
||||
- name: POD_ID
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
|
||||
# Liveness — levná kontrola bez externích závislostí
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3001
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
|
||||
# Readiness — kontroluje Redis; při shutdown vrací 503
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health/ready
|
||||
port: 3001
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
failureThreshold: 2
|
||||
|
||||
# preStop sleep: dá čas kube-proxy a Traefiku odebrat endpoint
|
||||
# dřív než kontejner začne odmítat nová spojení
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["sleep", "5"]
|
||||
@@ -1,10 +0,0 @@
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: luncher-pdb
|
||||
namespace: luncher
|
||||
spec:
|
||||
minAvailable: 2 # ze 3 replik, max 1 voluntary disruption najednou
|
||||
selector:
|
||||
matchLabels:
|
||||
app: luncher
|
||||
@@ -1,14 +0,0 @@
|
||||
# Šablona — hodnoty jsou zástupné symboly.
|
||||
# Pro kind test vytvoř secret příkazem:
|
||||
# kubectl create secret generic luncher-secrets -n luncher \
|
||||
# --from-literal=JWT_SECRET=<your-secret> \
|
||||
# --from-literal=ADMIN_PASSWORD=<your-password>
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: luncher-secrets
|
||||
namespace: luncher
|
||||
type: Opaque
|
||||
stringData:
|
||||
JWT_SECRET: CHANGE_ME
|
||||
ADMIN_PASSWORD: CHANGE_ME
|
||||
@@ -1,11 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: luncher
|
||||
namespace: luncher
|
||||
spec:
|
||||
selector:
|
||||
app: luncher
|
||||
ports:
|
||||
- port: 3001
|
||||
targetPort: 3001
|
||||
@@ -1,16 +0,0 @@
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
nodes:
|
||||
- role: control-plane
|
||||
# Mapuje porty na Windows localhost — luncher.localhost resolves to 127.0.0.1
|
||||
# Traefik na control-plane podu poslouchá na těchto portech přes hostPort
|
||||
extraPortMappings:
|
||||
- containerPort: 80
|
||||
hostPort: 80
|
||||
protocol: TCP
|
||||
- containerPort: 443
|
||||
hostPort: 443
|
||||
protocol: TCP
|
||||
- role: worker
|
||||
- role: worker
|
||||
- role: worker
|
||||
@@ -1,23 +0,0 @@
|
||||
# Spustí server a klienta v samostatných panelech jednoho okna Windows Terminalu.
|
||||
# Vyžaduje Windows Terminal (wt.exe) — výchozí součást Windows 11.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ScriptDir = $PSScriptRoot
|
||||
|
||||
Push-Location (Join-Path $ScriptDir 'types')
|
||||
try { yarn openapi-ts } finally { Pop-Location }
|
||||
|
||||
if (-not (Get-Command wt.exe -ErrorAction SilentlyContinue)) {
|
||||
Write-Error "wt.exe (Windows Terminal) nebyl nalezen. Nainstalujte z Microsoft Store nebo použijte run_dev.sh v WSL."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$serverDir = Join-Path $ScriptDir 'server'
|
||||
$clientDir = Join-Path $ScriptDir 'client'
|
||||
|
||||
# wt splits on ';' before respecting quoting, so encode the compound server command to avoid it
|
||||
$serverCmd = '$env:NODE_ENV = ''development''; yarn startReload'
|
||||
$serverCmdB64 = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($serverCmd))
|
||||
|
||||
wt -w 0 new-tab --title 'luncher-server' -d $serverDir pwsh -NoExit -EncodedCommand $serverCmdB64 `; `
|
||||
split-pane -H --title 'luncher-client' -d $clientDir pwsh -NoExit -Command "yarn start"
|
||||
@@ -47,20 +47,4 @@
|
||||
|
||||
# Heslo pro bypass rate limitu na endpointu /api/food/refresh (pro skripty/admin).
|
||||
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
|
||||
# REFRESH_BYPASS_PASSWORD=
|
||||
|
||||
# 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).
|
||||
# ADMIN_PASSWORD=
|
||||
|
||||
# Interval (ms) scheduleru sledování objednávek Bolt Food. Výchozí 60000 (60 s).
|
||||
# Pro vývoj se simulací lze zkrátit (min. 1000), aby se změny stavu projevily rychleji.
|
||||
# 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
|
||||
# REFRESH_BYPASS_PASSWORD=
|
||||
@@ -0,0 +1,3 @@
|
||||
[
|
||||
"Evidence večeří a pozdních obědů na samostatné stránce (/vecere)"
|
||||
]
|
||||
@@ -1,3 +0,0 @@
|
||||
[
|
||||
"Skupinové objednávky s QR platbou — stránka /objednani (více skupin, každá z jiného obchodu, stavový automat open/locked/ordered)"
|
||||
]
|
||||
@@ -1,7 +0,0 @@
|
||||
[
|
||||
"Možnost zobrazení objednávek z historie",
|
||||
"Podpora neplatících osob u objednávání",
|
||||
"Zobrazení neuhrazených plateb i na stránce objednávek",
|
||||
"Oprava duplicitního zobrazení QR kódu u Pizza day",
|
||||
"Odstranění diakritiky v platebních QR kódech"
|
||||
]
|
||||
@@ -1,3 +0,0 @@
|
||||
[
|
||||
"Nová stránka pro návrhy na vylepšení (dostupná z uživatelského menu)"
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
[
|
||||
"Proklik na nabídku podniku ze stránky objednávek",
|
||||
"Možnost přidat URL pro sledování stavu doručení pro Bolt Food",
|
||||
"Automatická aktualizace času doručení na základě sledovací URL pro Bolt Food"
|
||||
]
|
||||
@@ -1,6 +0,0 @@
|
||||
[
|
||||
"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"
|
||||
]
|
||||
@@ -1,3 +0,0 @@
|
||||
[
|
||||
"Oprava zobrazení stavu objednávky Bolt Food — objednávka čekající na přijetí podnikem se už nezobrazuje jako v přípravě"
|
||||
]
|
||||
@@ -1,3 +0,0 @@
|
||||
[
|
||||
"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á"
|
||||
]
|
||||
@@ -1,17 +0,0 @@
|
||||
[
|
||||
"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"
|
||||
]
|
||||
@@ -1,8 +0,0 @@
|
||||
[
|
||||
"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"
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
[
|
||||
"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í)"
|
||||
]
|
||||
@@ -1,3 +0,0 @@
|
||||
[
|
||||
"Upozornění na doručení skupinové objednávky sledované přes Bolt Food (zapíná se v Nastavení → Notifikace)"
|
||||
]
|
||||
@@ -1,3 +0,0 @@
|
||||
[
|
||||
"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ě)"
|
||||
]
|
||||
@@ -8,8 +8,7 @@
|
||||
"start": "ts-node src/index.ts",
|
||||
"startReload": "nodemon --watch src src/index.ts",
|
||||
"build": "tsc -p .",
|
||||
"test": "jest",
|
||||
"export:redis": "ts-node scripts/exportRedisToJson.ts"
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.5",
|
||||
@@ -30,13 +29,10 @@
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/node": "^10.65.0",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"axios": "^1.13.2",
|
||||
"cheerio": "^1.1.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"redis": "^5.9.0",
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* Vývojový skript pro export dat z Redisu do JSON souboru (data/db.json).
|
||||
*
|
||||
* Použití: typicky proti produkčnímu Redisu zpřístupněnému přes dočasný SSH tunel:
|
||||
* ssh -L 6379:localhost:6379 user@prod-host
|
||||
* cd server && yarn export:redis
|
||||
*
|
||||
* Skript jde přes stejné StorageInterface jako aplikace (RedisStorage), takže
|
||||
* korektně přečte i hodnoty uložené přes RedisJSON modul a zapíše je ve tvaru,
|
||||
* který očekává JsonStorage (simple-json-db) — tedy plochý objekt { klíč: hodnota }.
|
||||
*
|
||||
* Volitelné parametry (CLI nebo env):
|
||||
* --host <host> (REDIS_HOST) výchozí: localhost
|
||||
* --port <port> (REDIS_PORT) výchozí: 6379
|
||||
* --out <cesta> výchozí: server/data/db.json
|
||||
* --filter <text> exportovat jen klíče obsahující daný podřetězec
|
||||
* --yes přeskočit interaktivní potvrzení přepisu
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as readline from 'readline';
|
||||
import RedisStorage, { shutdownRedisStorage } from '../src/storage/redis';
|
||||
|
||||
// Bezpečnostní pojistka — skript nikdy nepouštět v produkčním režimu.
|
||||
if ((process.env.NODE_ENV ?? 'development') === 'production') {
|
||||
console.error('Tento skript nelze spustit s NODE_ENV=production.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Jednoduché parsování CLI argumentů typu --klíč hodnota a --flag. */
|
||||
function parseArgs(argv: string[]): Record<string, string | boolean> {
|
||||
const out: Record<string, string | boolean> = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (!arg.startsWith('--')) continue;
|
||||
const key = arg.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined || next.startsWith('--')) {
|
||||
out[key] = true;
|
||||
} else {
|
||||
out[key] = next;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Dotaz na potvrzení (y/n) ve stdin. */
|
||||
function confirm(question: string): Promise<boolean> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise(resolve => {
|
||||
rl.question(question, answer => {
|
||||
rl.close();
|
||||
resolve(/^(y|a|ano|yes)$/i.test(answer.trim()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
// Host/port nastavíme do env PŘED načtením RedisStorage — jeho konstruktor je čte z env.
|
||||
if (typeof args.host === 'string') process.env.REDIS_HOST = args.host;
|
||||
if (typeof args.port === 'string') process.env.REDIS_PORT = args.port;
|
||||
|
||||
const host = process.env.REDIS_HOST ?? 'localhost';
|
||||
const port = process.env.REDIS_PORT ?? '6379';
|
||||
|
||||
const outPath = typeof args.out === 'string'
|
||||
? path.resolve(process.cwd(), args.out)
|
||||
: path.resolve(__dirname, '../data/db.json');
|
||||
|
||||
const filter = typeof args.filter === 'string' ? args.filter : undefined;
|
||||
|
||||
// RedisStorage čte REDIS_HOST/PORT z env až ve svém konstruktoru (ne při importu),
|
||||
// proto stačí je nastavit výše a teprve teď vytvořit instanci.
|
||||
console.log(`Připojuji se k Redisu na ${host}:${port} ...`);
|
||||
const storage = new RedisStorage();
|
||||
await storage.initialize!();
|
||||
|
||||
const keys = await storage.listKeys(filter);
|
||||
console.log(`Nalezeno ${keys.length} klíčů${filter ? ` (filtr: "${filter}")` : ''}.`);
|
||||
|
||||
if (keys.length === 0) {
|
||||
console.warn('Žádná data k exportu — končím bez zápisu.');
|
||||
await shutdownRedisStorage();
|
||||
return;
|
||||
}
|
||||
|
||||
// Bezpečné načtení jednoho klíče — getData jde přes json.get, takže ne-JSON klíče
|
||||
// (např. lease připomínkovače uložené jako plain string přes SET NX EX) vyhodí chybu.
|
||||
// Takové klíče nejsou aplikační data a do db.json nepatří, proto je přeskočíme.
|
||||
const skipped: string[] = [];
|
||||
async function readSafe(key: string): Promise<unknown> {
|
||||
try {
|
||||
return await storage.getData(key);
|
||||
} catch {
|
||||
skipped.push(key);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Načtení hodnot po dávkách, ať zbytečně nezahltíme spojení.
|
||||
const BATCH = 20;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (let i = 0; i < keys.length; i += BATCH) {
|
||||
const batch = keys.slice(i, i + BATCH);
|
||||
const values = await Promise.all(batch.map(k => readSafe(k)));
|
||||
batch.forEach((k, idx) => {
|
||||
const value = values[idx];
|
||||
if (value !== undefined && value !== null) {
|
||||
result[k] = value;
|
||||
}
|
||||
});
|
||||
console.log(`Načteno ${Math.min(i + BATCH, keys.length)}/${keys.length} klíčů ...`);
|
||||
}
|
||||
|
||||
if (skipped.length > 0) {
|
||||
console.warn(`Přeskočeno ${skipped.length} ne-JSON klíčů: ${skipped.join(', ')}`);
|
||||
}
|
||||
|
||||
await shutdownRedisStorage();
|
||||
|
||||
// Potvrzení přepisu existujícího souboru (pokud není --yes) + záloha.
|
||||
if (fs.existsSync(outPath) && args.yes !== true) {
|
||||
const ok = await confirm(`Soubor ${outPath} už existuje a bude přepsán. Pokračovat? [a/N] `);
|
||||
if (!ok) {
|
||||
console.log('Zrušeno uživatelem.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (fs.existsSync(outPath)) {
|
||||
const backupPath = `${outPath}.bak`;
|
||||
fs.copyFileSync(outPath, backupPath);
|
||||
console.log(`Záloha původního souboru: ${backupPath}`);
|
||||
}
|
||||
|
||||
const dir = path.dirname(outPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// simple-json-db ukládá data jako plochý JSON objekt { klíč: hodnota }.
|
||||
fs.writeFileSync(outPath, JSON.stringify(result), 'utf-8');
|
||||
console.log(`Hotovo — ${Object.keys(result).length} klíčů zapsáno do ${outPath}.`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Export selhal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -9,13 +9,13 @@ import jwt from 'jsonwebtoken';
|
||||
*/
|
||||
export function generateToken(login?: string, trusted?: boolean): string {
|
||||
if (!process.env.JWT_SECRET) {
|
||||
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
||||
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
||||
}
|
||||
if (process.env.JWT_SECRET.length < 32) {
|
||||
throw new Error("Proměnná prostředí JWT_SECRET musí být minimálně 32 znaků");
|
||||
throw Error("Proměnná prostředí JWT_SECRET musí být minimálně 32 znaků");
|
||||
}
|
||||
if (!login || login.trim().length === 0) {
|
||||
throw new Error("Nebyl předán login");
|
||||
throw Error("Nebyl předán login");
|
||||
}
|
||||
const payload = { login, trusted: trusted || false, logoutUrl: process.env.LOGOUT_URL };
|
||||
return jwt.sign(payload, process.env.JWT_SECRET);
|
||||
@@ -28,7 +28,7 @@ export function generateToken(login?: string, trusted?: boolean): string {
|
||||
*/
|
||||
export function verify(token: string): boolean {
|
||||
if (!process.env.JWT_SECRET) {
|
||||
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
||||
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
||||
}
|
||||
try {
|
||||
jwt.verify(token, process.env.JWT_SECRET);
|
||||
@@ -45,10 +45,10 @@ export function verify(token: string): boolean {
|
||||
*/
|
||||
export function getLogin(token?: string): string {
|
||||
if (!process.env.JWT_SECRET) {
|
||||
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
||||
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error("Nebyl předán token");
|
||||
throw Error("Nebyl předán token");
|
||||
}
|
||||
const payload: any = jwt.verify(token, process.env.JWT_SECRET);
|
||||
return payload.login;
|
||||
@@ -61,10 +61,10 @@ export function getLogin(token?: string): string {
|
||||
*/
|
||||
export function getTrusted(token?: string): boolean {
|
||||
if (!process.env.JWT_SECRET) {
|
||||
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
||||
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error("Nebyl předán token");
|
||||
throw Error("Nebyl předán token");
|
||||
}
|
||||
const payload: any = jwt.verify(token, process.env.JWT_SECRET);
|
||||
return payload.trusted || false;
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Vývojový simulátor sledování objednávek Bolt Food.
|
||||
*
|
||||
* Drží in-memory registr „simulovaných" objednávek klíčovaný tokenem. Funkce
|
||||
* pollBoltOrder v boltTracking.ts se na začátku podívá, zda je token simulovaný,
|
||||
* a pokud ano, vrátí vyfabrikovaný stav místo dotazu na reálné Bolt API.
|
||||
*
|
||||
* Registr se plní výhradně přes dev endpointy (gated requireDevMode), takže
|
||||
* v produkci zůstává prázdný a chování pollBoltOrder se nijak nemění.
|
||||
*
|
||||
* Postup stavů je řízen ručně (krokováním), bez časové osy — viz advance/setState.
|
||||
*/
|
||||
|
||||
/** Jeden krok simulace — odpovídá tomu, co vrací Bolt API a co čte BoltOrderProgress. */
|
||||
export interface SimStep {
|
||||
order_state: string;
|
||||
courier_state?: string;
|
||||
etaSeconds?: number;
|
||||
}
|
||||
|
||||
/** Tvar objednávky, který vrací pollBoltOrder (shodný s interface BoltOrder). */
|
||||
export interface SimulatedBoltOrder {
|
||||
order_id: number;
|
||||
order_state: string;
|
||||
expected_time_to_client_in_seconds?: number;
|
||||
courier?: { state?: string } | null;
|
||||
}
|
||||
|
||||
interface Simulation {
|
||||
groupId: string;
|
||||
token: string;
|
||||
orderId: number;
|
||||
steps: SimStep[];
|
||||
index: number;
|
||||
/** Jednorázové ruční přepsání aktuálního stavu (má přednost před steps[index]). */
|
||||
override?: SimStep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Výchozí scénář „happy path". Stavy a stavy kurýra odpovídají mapování ve
|
||||
* client/src/components/BoltOrderProgress.tsx
|
||||
* (Přijato → Příprava → Vyzvedávání → Na cestě → Doručeno).
|
||||
*/
|
||||
export const DEFAULT_SCENARIO: SimStep[] = [
|
||||
{ order_state: 'waiting_acceptance', etaSeconds: 2100 },
|
||||
{ order_state: 'accepted', etaSeconds: 1800 },
|
||||
{ order_state: 'waiting_preparation', courier_state: 'accepted', etaSeconds: 1800 },
|
||||
{ order_state: 'preparing', courier_state: 'heading_to_provider', etaSeconds: 1500 },
|
||||
{ order_state: 'waiting_delivery', courier_state: 'arrived_to_provider', etaSeconds: 900 },
|
||||
{ order_state: 'in_delivery', courier_state: 'heading_to_client', etaSeconds: 300 },
|
||||
{ order_state: 'delivered', courier_state: 'delivered', etaSeconds: 0 },
|
||||
];
|
||||
|
||||
const byToken = new Map<string, Simulation>();
|
||||
|
||||
function findByGroup(groupId: string): Simulation | undefined {
|
||||
for (const sim of byToken.values()) {
|
||||
if (sim.groupId === groupId) return sim;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Sestaví Bolt objednávku z aktuálního kroku simulace. */
|
||||
function buildOrder(sim: Simulation): SimulatedBoltOrder {
|
||||
const step = sim.override ?? sim.steps[sim.index];
|
||||
return {
|
||||
order_id: sim.orderId,
|
||||
order_state: step.order_state,
|
||||
expected_time_to_client_in_seconds: step.etaSeconds,
|
||||
courier: step.courier_state ? { state: step.courier_state } : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Je token registrovaný v simulátoru? */
|
||||
export function isBoltSimulated(token: string): boolean {
|
||||
return byToken.has(token);
|
||||
}
|
||||
|
||||
/** Vrátí simulovaný stav objednávky pro daný token (nebo null, pokud není simulovaný). */
|
||||
export function getSimulatedBoltOrder(token: string): SimulatedBoltOrder | null {
|
||||
const sim = byToken.get(token);
|
||||
return sim ? buildOrder(sim) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spustí novou simulaci pro skupinu. Pokud už pro skupinu simulace běží, nahradí ji.
|
||||
* Vrátí vygenerovaný 64-hex token (validní pro extractBoltToken).
|
||||
*/
|
||||
export function startBoltSimulation(groupId: string, steps: SimStep[] = DEFAULT_SCENARIO): string {
|
||||
stopBoltSimulationByGroup(groupId);
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
byToken.set(token, {
|
||||
groupId,
|
||||
token,
|
||||
orderId: crypto.randomInt(100000, 999999),
|
||||
steps: steps.length ? steps : DEFAULT_SCENARIO,
|
||||
index: 0,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Posune simulaci skupiny na další krok (a zruší případný ruční override). Vrátí aktuální krok. */
|
||||
export function advanceBoltSimulation(groupId: string): SimStep {
|
||||
const sim = findByGroup(groupId);
|
||||
if (!sim) throw new Error('Pro skupinu neběží žádná simulace Bolt');
|
||||
sim.override = undefined;
|
||||
sim.index = Math.min(sim.index + 1, sim.steps.length - 1);
|
||||
return sim.steps[sim.index];
|
||||
}
|
||||
|
||||
/** Nastaví konkrétní stav simulace skupiny (ruční override — užitečné pro edge-case stavy). */
|
||||
export function setBoltSimulationStep(groupId: string, step: SimStep): SimStep {
|
||||
const sim = findByGroup(groupId);
|
||||
if (!sim) throw new Error('Pro skupinu neběží žádná simulace Bolt');
|
||||
sim.override = step;
|
||||
return step;
|
||||
}
|
||||
|
||||
/** Ukončí simulaci pro skupinu. */
|
||||
export function stopBoltSimulationByGroup(groupId: string): void {
|
||||
const sim = findByGroup(groupId);
|
||||
if (sim) byToken.delete(sim.token);
|
||||
}
|
||||
|
||||
/** Vrátí přehled simulace skupiny (pro stavové zobrazení v UI). */
|
||||
export function getBoltSimulation(groupId: string): { token: string; index: number; total: number; current: SimStep } | null {
|
||||
const sim = findByGroup(groupId);
|
||||
if (!sim) return null;
|
||||
return {
|
||||
token: sim.token,
|
||||
index: sim.index,
|
||||
total: sim.steps.length,
|
||||
current: sim.override ?? sim.steps[sim.index],
|
||||
};
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import crypto from 'crypto';
|
||||
import getStorage from './storage';
|
||||
import { createLeaderLease } from './leaderLease';
|
||||
import { getToday } from './service';
|
||||
import { formatDate } from './utils';
|
||||
import { getWebsocket } from './websocket';
|
||||
import { ClientData, GroupState } from '../../types/gen/types.gen';
|
||||
import { isBoltSimulated, getSimulatedBoltOrder } from './boltSimulator';
|
||||
import { notifyBoltDelivered } from './notifikace';
|
||||
|
||||
const storage = getStorage();
|
||||
const lease = createLeaderLease('luncher:bolt:leader');
|
||||
|
||||
const BOLT_POLLING_URL = 'https://deliveryuser.live.boltsvc.net/deliveryClient/public/getOrderPolling';
|
||||
const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i;
|
||||
const TERMINAL_STATE_REGEX = /delivered|finished|cancelled|rejected|failed/i;
|
||||
const DELIVERED_STATE_REGEX = /delivered|finished/i;
|
||||
const MAX_CONSECUTIVE_FAILURES = 10;
|
||||
|
||||
/** Identifikátor zařízení pro Bolt API — generuje se jednou na proces. */
|
||||
const DEVICE_ID = crypto.randomUUID();
|
||||
|
||||
let boltInterval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
/** Mapa groupId → počet po sobě jdoucích selhání dotazu na Bolt API. */
|
||||
const consecutiveFailures = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Vytáhne sledovací token ze sdílecí URL Bolt Food
|
||||
* (https://food.bolt.eu/sharedActiveOrder/<token>) nebo přijme samotný token.
|
||||
* Vrátí null, pokud vstup neobsahuje platný token (64 hex znaků).
|
||||
*/
|
||||
export function extractBoltToken(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
if (BOLT_TOKEN_REGEX.test(trimmed)) return trimmed;
|
||||
let pathname: string;
|
||||
try {
|
||||
pathname = new URL(trimmed).pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const last = segments[segments.length - 1];
|
||||
return last && BOLT_TOKEN_REGEX.test(last) ? last : null;
|
||||
}
|
||||
|
||||
/** Spočítá očekávaný čas doručení (teď + sekundy) ve formátu HH:MM. */
|
||||
export function computeDeliveryHHMM(seconds: number, now: Date = new Date()): string {
|
||||
const eta = new Date(now.getTime() + seconds * 1000);
|
||||
return `${String(eta.getHours()).padStart(2, '0')}:${String(eta.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface BoltOrder {
|
||||
order_id: number;
|
||||
order_state: string;
|
||||
expected_time_to_client_in_seconds?: number;
|
||||
courier?: { state?: string } | null;
|
||||
}
|
||||
|
||||
/** Dotáže se veřejného Bolt API na stav sdílené objednávky. Vrátí null, pokud objednávka už neexistuje. */
|
||||
export async function pollBoltOrder(token: string): Promise<BoltOrder | null> {
|
||||
// DEV simulace: simulované tokeny obsluhuje boltSimulator místo reálného Bolt API.
|
||||
// V produkci je registr vždy prázdný, takže se sem nikdy nedostane.
|
||||
if (isBoltSimulated(token)) {
|
||||
return getSimulatedBoltOrder(token);
|
||||
}
|
||||
const res = await axios.post(BOLT_POLLING_URL, { token }, {
|
||||
params: {
|
||||
version: 'FW.1.113',
|
||||
language: 'cs-CZ',
|
||||
country: 'cz',
|
||||
device_name: 'web',
|
||||
device_os_version: 'web',
|
||||
deviceType: 'web',
|
||||
session_id: DEVICE_ID,
|
||||
distinct_id: `$device:${DEVICE_ID}`,
|
||||
deviceId: DEVICE_ID,
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 10_000,
|
||||
});
|
||||
if (res.data?.code !== 0) {
|
||||
throw new Error(`Bolt API vrátilo kód ${res.data?.code}: ${res.data?.message}`);
|
||||
}
|
||||
return res.data?.data?.orders?.[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jeden tik scheduleru: pro dnešní objednané skupiny se sledovacím tokenem
|
||||
* zjistí očekávaný čas doručení z Bolt API a aktualizuje deliveryAt.
|
||||
* Sledování se automaticky ukončí (token se smaže), když objednávka skončí
|
||||
* nebo dotazy opakovaně selhávají.
|
||||
*/
|
||||
export async function checkBoltTracking(): Promise<void> {
|
||||
const isLeader = await lease.tryAcquireOrRenew();
|
||||
if (!isLeader) return;
|
||||
|
||||
const key = `${formatDate(getToday())}_extra`;
|
||||
const data = await storage.getData<ClientData>(key);
|
||||
const candidates = (data?.groups ?? []).filter(g => g.boltTrackingToken && g.state === GroupState.ORDERED);
|
||||
|
||||
// Úklid čítačů selhání pro skupiny, které už nesledujeme
|
||||
for (const groupId of consecutiveFailures.keys()) {
|
||||
if (!candidates.some(g => g.id === groupId)) consecutiveFailures.delete(groupId);
|
||||
}
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
let updated: ClientData | undefined;
|
||||
|
||||
for (const group of candidates) {
|
||||
let deliveryAt: string | undefined;
|
||||
let orderState: string | undefined;
|
||||
let courierState: string | undefined;
|
||||
let clearToken = false;
|
||||
|
||||
try {
|
||||
const order = await pollBoltOrder(group.boltTrackingToken!);
|
||||
consecutiveFailures.delete(group.id);
|
||||
if (!order) {
|
||||
// Objednávka z API zmizela — považujeme ji za doručenou
|
||||
orderState = 'delivered';
|
||||
clearToken = true;
|
||||
} else {
|
||||
orderState = order.order_state || undefined;
|
||||
courierState = order.courier?.state || undefined;
|
||||
if (TERMINAL_STATE_REGEX.test(order.order_state ?? '')) {
|
||||
clearToken = true;
|
||||
} else if (typeof order.expected_time_to_client_in_seconds === 'number') {
|
||||
deliveryAt = computeDeliveryHHMM(order.expected_time_to_client_in_seconds);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const failures = (consecutiveFailures.get(group.id) ?? 0) + 1;
|
||||
consecutiveFailures.set(group.id, failures);
|
||||
console.error(`Bolt tracking: chyba dotazu pro skupinu "${group.name}" (${failures}/${MAX_CONSECUTIVE_FAILURES})`, e);
|
||||
if (failures < MAX_CONSECUTIVE_FAILURES) continue;
|
||||
consecutiveFailures.delete(group.id);
|
||||
clearToken = true;
|
||||
}
|
||||
|
||||
const timeChanged = deliveryAt !== undefined && deliveryAt !== group.deliveryAt;
|
||||
const stateChanged = orderState !== undefined && orderState !== group.boltOrderState;
|
||||
const courierChanged = courierState !== group.boltCourierState && !clearToken;
|
||||
if (!clearToken && !timeChanged && !stateChanged && !courierChanged) continue;
|
||||
|
||||
// Notifikujeme jen skutečný přechod do doručeného stavu. Porovnání s předchozím
|
||||
// stavem hlídá duplicity jak u přechodu delivered → finished, tak u tiku, kdy
|
||||
// objednávka zmizí z API poté, co už byla označena za doručenou.
|
||||
const deliveredNow = stateChanged
|
||||
&& DELIVERED_STATE_REGEX.test(orderState ?? '')
|
||||
&& !DELIVERED_STATE_REGEX.test(group.boltOrderState ?? '');
|
||||
|
||||
// Log každého přechodu stavu — Bolt API není dokumentované, takže si takhle
|
||||
// průběžně mapujeme jeho stavový automat (viz mapování v BoltOrderProgress.tsx).
|
||||
if (stateChanged || courierChanged) {
|
||||
console.log(
|
||||
`Bolt tracking: skupina "${group.name}" stav ${group.boltOrderState ?? '(žádný)'} → ${orderState ?? '(žádný)'}` +
|
||||
`, kurýr ${group.boltCourierState ?? '(žádný)'} → ${courierState ?? '(žádný)'}`
|
||||
);
|
||||
}
|
||||
|
||||
updated = await storage.updateData<ClientData>(key, current => {
|
||||
const d = current ?? data!;
|
||||
const g = d.groups?.find(x => x.id === group.id);
|
||||
if (g?.boltTrackingToken) {
|
||||
if (timeChanged) g.deliveryAt = deliveryAt;
|
||||
if (stateChanged) g.boltOrderState = orderState;
|
||||
if (courierChanged) g.boltCourierState = courierState;
|
||||
if (clearToken) g.boltTrackingToken = undefined;
|
||||
}
|
||||
return d;
|
||||
});
|
||||
if (clearToken) {
|
||||
console.log(`Bolt tracking: sledování skupiny "${group.name}" ukončeno`);
|
||||
}
|
||||
|
||||
// Až po uložení stavu, aby pád neposlal notifikaci podruhé.
|
||||
// Selhání push nesmí shodit celý tik sledování.
|
||||
if (deliveredNow) {
|
||||
try {
|
||||
await notifyBoltDelivered(group.name, Object.keys(group.members ?? {}), group.creatorLogin);
|
||||
} catch (e) {
|
||||
console.error(`Bolt tracking: chyba při odesílání notifikace o doručení skupiny "${group.name}"`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
getWebsocket()?.emit('message', updated);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spustí scheduler pro sledování Bolt objednávek. Interval je 60 s, lze ho ale
|
||||
* zkrátit přes env BOLT_POLL_INTERVAL_MS (užitečné při vývoji se simulací).
|
||||
*/
|
||||
export function startBoltTrackingScheduler(): void {
|
||||
const parsed = Number(process.env.BOLT_POLL_INTERVAL_MS);
|
||||
const intervalMs = Number.isFinite(parsed) && parsed >= 1000 ? parsed : 60_000;
|
||||
boltInterval = setInterval(checkBoltTracking, intervalMs);
|
||||
console.log(`Bolt tracking: scheduler spuštěn (interval ${intervalMs} ms)`);
|
||||
}
|
||||
|
||||
/** Stopne scheduler sledování. Volá se při graceful shutdown. */
|
||||
export function stopBoltTrackingScheduler(): void {
|
||||
if (boltInterval) {
|
||||
clearInterval(boltInterval);
|
||||
boltInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Uvolní leader lease při graceful shutdown. */
|
||||
export async function releaseBoltTrackingLease(): Promise<void> {
|
||||
await lease.release();
|
||||
}
|
||||
@@ -1,732 +0,0 @@
|
||||
import { ButterflyStats, ButterflyCatchResult, ButterflyLeaderboardEntry } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { formatDate } from "./utils";
|
||||
|
||||
/** Interní reprezentace statistik jednoho uživatele uložená ve storage. */
|
||||
interface StoredButterflyStats {
|
||||
/** Celkový počet chycených motýlů */
|
||||
caught: number;
|
||||
/** Aktuální počet mincí k utracení */
|
||||
coins: number;
|
||||
/** Počet chycených vzácných zlatých motýlů */
|
||||
goldenCaught: number;
|
||||
/** Datum posledního úlovku (YYYY-MM-DD) pro denní bonus */
|
||||
lastCatchDay?: string;
|
||||
|
||||
// --- Perzistentní škůdci (aby je nešlo obejít obnovením stránky) ---
|
||||
/** Aktuální počet vos */
|
||||
wasps: number;
|
||||
/** Aktuální počet ptáků */
|
||||
birds: number;
|
||||
/** Celkový počet zahubených vos */
|
||||
waspsKilled: number;
|
||||
/** Celkový počet vyplašených ptáků */
|
||||
birdsScared: number;
|
||||
/** Celkový počet poražených zlodějů */
|
||||
thievesDefeated: number;
|
||||
/** Celkový počet poražených housenek */
|
||||
caterpillarsDefeated: number;
|
||||
/** Do kdy platí plašič ptáků (ms epoch), 0 = neaktivní */
|
||||
repellentUntil: number;
|
||||
/** Do kdy je síťka protržená (ms epoch); po uplynutí se sama zašije. 0 = celá */
|
||||
netTornUntil: number;
|
||||
/** Kdy naposledy „dorostla" vosa (ms epoch) */
|
||||
lastWaspAt: number;
|
||||
/** Kdy naposledy „dorostl" pták (ms epoch) */
|
||||
lastBirdAt: number;
|
||||
|
||||
// --- Ochrana proti podvádění (rychlost chytání) ---
|
||||
/** Začátek aktuálního minutového okna pro počítadlo úlovků (ms epoch) */
|
||||
catchWindowStart: number;
|
||||
/** Počet započtených úlovků v aktuálním okně */
|
||||
catchWindowCount: number;
|
||||
|
||||
// --- Anti-bot ban (opakované selhání inspekce) ---
|
||||
/** Počet selhání inspekce v aktuálním okně */
|
||||
inspectionFails: number;
|
||||
/** Začátek okna pro počítání selhání (ms epoch) */
|
||||
inspectionWindowStart: number;
|
||||
/** Do kdy platí ban za podvádění (ms epoch); 0 = bez banu */
|
||||
banUntil: number;
|
||||
|
||||
/** Kolikrát hráč omylem chytil černou můru */
|
||||
mothsHit: number;
|
||||
/** Trvalá vylepšení z obchodu */
|
||||
upgrades: { net: number; scarecrow: number; reinforced: number };
|
||||
/** Do kdy platí pojistka proti zloději (ms epoch), 0 = neaktivní */
|
||||
insuranceUntil: number;
|
||||
/** Denní úkol */
|
||||
daily: { day: string; taskId: string; progress: number; target: number; done: boolean };
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
// Sezóna 2 – nový klíč znamená čistý start pro všechny (spravedlivé po ztížení hry)
|
||||
const STORAGE_KEY = 'butterflyStats_s2';
|
||||
|
||||
// --- Herní konstanty ---------------------------------------------------------
|
||||
|
||||
/** Mince za jednoho běžného motýla */
|
||||
export const COIN_BASE = 1;
|
||||
/** Mince za jednoho vzácného zlatého motýla */
|
||||
export const GOLD_VALUE = 25;
|
||||
/** Cena zašití protržené síťky v mincích */
|
||||
export const REPAIR_COST = 20;
|
||||
/** Bonus mincí za první úlovek dne */
|
||||
export const DAILY_BONUS = 5;
|
||||
/** Po každých kolika chycených se odemkne prémiová síťka */
|
||||
export const PREMIUM_MILESTONE = 75;
|
||||
|
||||
/** Maximální počet současně poletujících vos */
|
||||
export const WASP_MAX = 6;
|
||||
/** Maximální počet současně poletujících ptáků */
|
||||
export const BIRD_MAX = 7;
|
||||
/** Jak často (ms) přibude jedna vosa */
|
||||
export const WASP_GROWTH_MS = 30_000;
|
||||
/** Jak často (ms) přibude jeden pták (základ; zpomaluje strašák) */
|
||||
export const BIRD_GROWTH_MS = 40_000;
|
||||
/** Cena plašiče ptáků v mincích */
|
||||
export const REPELLENT_COST = 70;
|
||||
/** Jak dlouho (ms) plašič ptáků drží ptáky pryč */
|
||||
export const REPELLENT_DURATION_MS = 75_000;
|
||||
/** Za jak dlouho (ms) se protržená síťka sama zašije */
|
||||
export const AUTO_REPAIR_MS = 60_000;
|
||||
/** Jaký podíl mincí ukradne zloděj, když se dostane k penězům (0–1) */
|
||||
export const ROBBERY_FRACTION = 0.85;
|
||||
/** Odměna v mincích za poražení zloděje */
|
||||
export const THIEF_REWARD = 20;
|
||||
/** Kolik zásahů (kliknutí) zloděj vydrží */
|
||||
export const THIEF_HP = 22;
|
||||
/** Kolik nachytaných motýlů sežere housenka, když se dostane k úlovkům */
|
||||
export const CATERPILLAR_EATS = 20;
|
||||
/** Odměna v mincích za poražení housenky */
|
||||
export const CATERPILLAR_REWARD = 15;
|
||||
/** Kolik zásahů (kliknutí) housenka vydrží */
|
||||
export const CATERPILLAR_HP = 18;
|
||||
/** O kolik úlovků přijdeš, když omylem chytíš černou můru */
|
||||
export const MOTH_PENALTY = 15;
|
||||
|
||||
// --- Obchod: spotřební ---
|
||||
/** Cena aktivace prémiové síťky na přání */
|
||||
export const PREMIUM_BUY_COST = 40;
|
||||
/** Cena vosího spreje (vyhubí všechny vosy) */
|
||||
export const WASP_SPRAY_COST = 25;
|
||||
/** Cena pojistky proti zloději */
|
||||
export const INSURANCE_COST = 50;
|
||||
/** Jak dlouho (ms) pojistka platí */
|
||||
export const INSURANCE_DURATION_MS = 300_000;
|
||||
/** Podíl mincí ukradený zlodějem, když je aktivní pojistka */
|
||||
export const ROBBERY_FRACTION_INSURED = 0.2;
|
||||
|
||||
// --- Obchod: trvalá vylepšení ---
|
||||
export type UpgradeId = 'net' | 'scarecrow' | 'reinforced';
|
||||
/** Maximální úrovně jednotlivých vylepšení */
|
||||
export const UPGRADE_MAX: Record<UpgradeId, number> = { net: 5, scarecrow: 4, reinforced: 3 };
|
||||
/** Základní cena vylepšení (další úroveň je 2× dražší) */
|
||||
const UPGRADE_BASE: Record<UpgradeId, number> = { net: 100, scarecrow: 120, reinforced: 150 };
|
||||
|
||||
/** Vrátí cenu příští úrovně daného vylepšení (level = aktuální úroveň). */
|
||||
export function upgradeCost(item: UpgradeId, level: number): number {
|
||||
return UPGRADE_BASE[item] * Math.pow(2, level);
|
||||
}
|
||||
|
||||
// --- Denní úkoly ---
|
||||
/** Odměna v mincích za splnění denního úkolu */
|
||||
export const DAILY_TASK_REWARD = 30;
|
||||
const DAILY_TASKS: { id: string; target: number }[] = [
|
||||
{ id: 'catch', target: 60 },
|
||||
{ id: 'golden', target: 3 },
|
||||
{ id: 'thief', target: 1 },
|
||||
{ id: 'wasp', target: 15 },
|
||||
];
|
||||
const DAILY_TITLES: Record<string, string> = {
|
||||
catch: 'Nachytej {n} motýlů',
|
||||
golden: 'Chyť {n} zlatých motýlů',
|
||||
thief: 'Poraz {n} zloděje',
|
||||
wasp: 'Zabij {n} vos',
|
||||
};
|
||||
|
||||
/** Deterministický výběr úkolu podle data (aby byl pro všechny stejný a stabilní). */
|
||||
function dailyTaskForDay(day: string): { id: string; target: number } {
|
||||
let h = 0;
|
||||
for (let i = 0; i < day.length; i++) h = (h * 31 + day.charCodeAt(i)) | 0;
|
||||
return DAILY_TASKS[Math.abs(h) % DAILY_TASKS.length];
|
||||
}
|
||||
|
||||
/** Od jaké velikosti dávky se začíná počítat kombo bonus */
|
||||
const COMBO_THRESHOLD = 4;
|
||||
/** Horní strop komba, aby dávka nedala nesmyslně moc mincí */
|
||||
const COMBO_CAP = 8;
|
||||
/** Maximální počet motýlů akceptovaný v jedné dávce (sanity limit) */
|
||||
const MAX_BATCH = 100;
|
||||
|
||||
/** Ochrana proti podvádění: max započtených úlovků za minutu (nadbytek se zahodí) */
|
||||
export const RATE_CAP_PER_MIN = 90;
|
||||
/** Délka okna pro rate-cap (ms) */
|
||||
const RATE_WINDOW_MS = 60_000;
|
||||
|
||||
/** Kolik selhání inspekce v okně vede k banu */
|
||||
export const BAN_FAIL_THRESHOLD = 3;
|
||||
/** Okno, ve kterém se selhání inspekce počítají (ms) */
|
||||
export const BAN_WINDOW_MS = 10 * 60_000;
|
||||
/** Jak dlouho trvá ban za podvádění (ms) */
|
||||
export const BAN_DURATION_MS = 90 * 60_000;
|
||||
|
||||
/**
|
||||
* Tituly úrovní. Pro úrovně nad rámec pole se použije poslední titul s hvězdičkami
|
||||
* (prestiž), takže postup nikdy „nedojde".
|
||||
*/
|
||||
const LEVEL_TITLES = [
|
||||
'Začátečník se síťkou', // 1
|
||||
'Nedělní chytač', // 2
|
||||
'Lovec luk', // 3
|
||||
'Sběratel křídel', // 4
|
||||
'Průzkumník louky', // 5
|
||||
'Mistr síťky', // 6
|
||||
'Motýlí stopař', // 7
|
||||
'Zaklínač křídel', // 8
|
||||
'Motýlí šeptač', // 9
|
||||
'Kurátor motýlů', // 10
|
||||
'Amatérský entomolog', // 11
|
||||
'Legendární entomolog', // 12
|
||||
'Strážce louky', // 13
|
||||
'Vládce louky', // 14
|
||||
'Motýlí velmistr', // 15
|
||||
'Duch luk', // 16
|
||||
'Motýlí legenda', // 17
|
||||
'Nebeský lovec', // 18
|
||||
'Motýlí božstvo', // 19
|
||||
'Pán všech křídel', // 20
|
||||
];
|
||||
/** Nejvyšší dosažitelná úroveň */
|
||||
export const MAX_LEVEL = 60;
|
||||
|
||||
// --- Čisté pomocné funkce ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Kolik celkem chycených je potřeba k dosažení úrovně `n` (1-based).
|
||||
* Úroveň 1 = 0. Křivka roste mocninně (vyšší úrovně jsou výrazně těžší).
|
||||
*/
|
||||
export function levelThreshold(n: number): number {
|
||||
if (n <= 1) return 0;
|
||||
return Math.round(6 * Math.pow(n - 1, 2.15));
|
||||
}
|
||||
|
||||
/** Vrátí úroveň (1-based) odpovídající celkovému počtu chycených motýlů. */
|
||||
export function levelForCaught(caught: number): number {
|
||||
let level = 1;
|
||||
while (level < MAX_LEVEL && caught >= levelThreshold(level + 1)) {
|
||||
level++;
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
/** Vrátí titul odpovídající dané úrovni (1-based). */
|
||||
export function titleForLevel(level: number): string {
|
||||
if (level <= LEVEL_TITLES.length) {
|
||||
return LEVEL_TITLES[Math.max(level - 1, 0)];
|
||||
}
|
||||
const stars = '★'.repeat(Math.min(level - LEVEL_TITLES.length, 10));
|
||||
return `${LEVEL_TITLES[LEVEL_TITLES.length - 1]} ${stars}`;
|
||||
}
|
||||
|
||||
/** Postup v rámci aktuální úrovně (0–100 %). Na maximální úrovni 100. */
|
||||
export function levelProgress(caught: number): number {
|
||||
const level = levelForCaught(caught);
|
||||
if (level >= MAX_LEVEL) return 100;
|
||||
const start = levelThreshold(level);
|
||||
const end = levelThreshold(level + 1);
|
||||
if (end <= start) return 100;
|
||||
return Math.max(0, Math.min(100, Math.round(((caught - start) / (end - start)) * 100)));
|
||||
}
|
||||
|
||||
/** Bonus mincí za kombo (dávku chycenou najednou). */
|
||||
export function comboBonus(batchTotal: number): number {
|
||||
if (batchTotal < COMBO_THRESHOLD) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(batchTotal - (COMBO_THRESHOLD - 1), COMBO_CAP);
|
||||
}
|
||||
|
||||
/** Vytvoří výchozí (prázdné) statistiky. */
|
||||
function defaultStats(now: number): StoredButterflyStats {
|
||||
return {
|
||||
caught: 0, coins: 0, goldenCaught: 0,
|
||||
wasps: 0, birds: 0, waspsKilled: 0, birdsScared: 0,
|
||||
thievesDefeated: 0, caterpillarsDefeated: 0,
|
||||
repellentUntil: 0, netTornUntil: 0,
|
||||
lastWaspAt: now, lastBirdAt: now,
|
||||
catchWindowStart: now, catchWindowCount: 0,
|
||||
inspectionFails: 0, inspectionWindowStart: now, banUntil: 0,
|
||||
mothsHit: 0,
|
||||
upgrades: { net: 0, scarecrow: 0, reinforced: 0 },
|
||||
insuranceUntil: 0,
|
||||
daily: { day: '', taskId: 'catch', progress: 0, target: 0, done: false },
|
||||
};
|
||||
}
|
||||
|
||||
/** Doplní chybějící pole u starších uložených záznamů (migrace za běhu). */
|
||||
function normalize(s: StoredButterflyStats, now: number): StoredButterflyStats {
|
||||
return {
|
||||
caught: s.caught ?? 0,
|
||||
coins: s.coins ?? 0,
|
||||
goldenCaught: s.goldenCaught ?? 0,
|
||||
lastCatchDay: s.lastCatchDay,
|
||||
wasps: s.wasps ?? 0,
|
||||
birds: s.birds ?? 0,
|
||||
waspsKilled: s.waspsKilled ?? 0,
|
||||
birdsScared: s.birdsScared ?? 0,
|
||||
thievesDefeated: s.thievesDefeated ?? 0,
|
||||
caterpillarsDefeated: s.caterpillarsDefeated ?? 0,
|
||||
repellentUntil: s.repellentUntil ?? 0,
|
||||
netTornUntil: s.netTornUntil ?? 0,
|
||||
lastWaspAt: s.lastWaspAt ?? now,
|
||||
lastBirdAt: s.lastBirdAt ?? now,
|
||||
catchWindowStart: s.catchWindowStart ?? now,
|
||||
catchWindowCount: s.catchWindowCount ?? 0,
|
||||
inspectionFails: s.inspectionFails ?? 0,
|
||||
inspectionWindowStart: s.inspectionWindowStart ?? now,
|
||||
banUntil: s.banUntil ?? 0,
|
||||
mothsHit: s.mothsHit ?? 0,
|
||||
upgrades: {
|
||||
net: s.upgrades?.net ?? 0,
|
||||
scarecrow: s.upgrades?.scarecrow ?? 0,
|
||||
reinforced: s.upgrades?.reinforced ?? 0,
|
||||
},
|
||||
insuranceUntil: s.insuranceUntil ?? 0,
|
||||
daily: s.daily ?? { day: '', taskId: 'catch', progress: 0, target: 0, done: false },
|
||||
};
|
||||
}
|
||||
|
||||
/** Efektivní interval růstu ptáků (zpomaluje strašák). */
|
||||
function effectiveBirdGrowth(s: StoredButterflyStats): number {
|
||||
return Math.round(BIRD_GROWTH_MS * (1 + 0.35 * (s.upgrades?.scarecrow ?? 0)));
|
||||
}
|
||||
|
||||
/** Efektivní doba samo-zašití síťky (zkracuje zpevněná síťka). */
|
||||
function effectiveAutoRepair(s: StoredButterflyStats): number {
|
||||
return Math.round(AUTO_REPAIR_MS * (1 - 0.25 * (s.upgrades?.reinforced ?? 0)));
|
||||
}
|
||||
|
||||
/** Zajistí, že denní úkol odpovídá dnešnímu dni (jinak vygeneruje nový). */
|
||||
function ensureDaily(s: StoredButterflyStats, now: number): void {
|
||||
const day = formatDate(new Date(now));
|
||||
if (!s.daily || s.daily.day !== day) {
|
||||
const def = dailyTaskForDay(day);
|
||||
s.daily = { day, taskId: def.id, progress: 0, target: def.target, done: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** Přičte progres dennímu úkolu daného druhu a případně vyplatí odměnu. */
|
||||
function bumpDaily(s: StoredButterflyStats, kind: string, amount: number): void {
|
||||
if (s.daily && !s.daily.done && s.daily.taskId === kind && amount > 0) {
|
||||
s.daily.progress += amount;
|
||||
if (s.daily.progress >= s.daily.target) {
|
||||
s.daily.done = true;
|
||||
s.coins += DAILY_TASK_REWARD;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nechá v čase „dorůst" škůdce do maxima. Zachovává rozpracovaný čas (posouvá
|
||||
* razítko jen o spotřebované celé intervaly). Ptáci nerostou, když je aktivní plašič.
|
||||
*/
|
||||
export function growPests(s: StoredButterflyStats, now: number): void {
|
||||
// Síťka se sama zašije po uplynutí odpočtu
|
||||
if (s.netTornUntil && s.netTornUntil <= now) s.netTornUntil = 0;
|
||||
// Vypršelý ban vyčistíme
|
||||
if (s.banUntil && s.banUntil <= now) s.banUntil = 0;
|
||||
|
||||
// Vosy
|
||||
if (s.wasps < WASP_MAX) {
|
||||
const add = Math.floor((now - s.lastWaspAt) / WASP_GROWTH_MS);
|
||||
if (add > 0) {
|
||||
s.wasps = Math.min(s.wasps + add, WASP_MAX);
|
||||
s.lastWaspAt = s.wasps >= WASP_MAX ? now : s.lastWaspAt + add * WASP_GROWTH_MS;
|
||||
}
|
||||
} else {
|
||||
s.lastWaspAt = now;
|
||||
}
|
||||
|
||||
// Ptáci – jen když neběží plašič. Během plašiče se lastBirdAt nemění (byl
|
||||
// nastaven na konec plašiče při koupi), takže po vypršení růst začne od té chvíle.
|
||||
const repellentActive = s.repellentUntil > now;
|
||||
if (!repellentActive) {
|
||||
if (s.birds < BIRD_MAX) {
|
||||
const birdGrowth = effectiveBirdGrowth(s);
|
||||
const add = Math.floor((now - s.lastBirdAt) / birdGrowth);
|
||||
if (add > 0) {
|
||||
s.birds = Math.min(s.birds + add, BIRD_MAX);
|
||||
s.lastBirdAt = s.birds >= BIRD_MAX ? now : s.lastBirdAt + add * birdGrowth;
|
||||
}
|
||||
} else {
|
||||
s.lastBirdAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Převede interní statistiky na DTO (doplní odvozenou úroveň a titul). */
|
||||
function toDto(s: StoredButterflyStats): ButterflyStats {
|
||||
const level = levelForCaught(s.caught);
|
||||
return {
|
||||
caught: s.caught,
|
||||
coins: s.coins,
|
||||
goldenCaught: s.goldenCaught,
|
||||
level,
|
||||
title: titleForLevel(level),
|
||||
levelProgress: levelProgress(s.caught),
|
||||
lastCatchDay: s.lastCatchDay,
|
||||
wasps: s.wasps,
|
||||
birds: s.birds,
|
||||
waspsKilled: s.waspsKilled,
|
||||
birdsScared: s.birdsScared,
|
||||
thievesDefeated: s.thievesDefeated,
|
||||
caterpillarsDefeated: s.caterpillarsDefeated,
|
||||
mothsHit: s.mothsHit,
|
||||
repellentUntil: s.repellentUntil,
|
||||
netTornUntil: s.netTornUntil,
|
||||
insuranceUntil: s.insuranceUntil,
|
||||
banUntil: s.banUntil,
|
||||
upgrades: { ...s.upgrades },
|
||||
daily: {
|
||||
taskId: s.daily.taskId,
|
||||
title: (DAILY_TITLES[s.daily.taskId] ?? '{n}').replace('{n}', String(s.daily.target)),
|
||||
progress: s.daily.progress,
|
||||
target: s.daily.target,
|
||||
done: s.daily.done,
|
||||
reward: DAILY_TASK_REWARD,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Načte mapu všech uživatelských statistik ze storage. */
|
||||
async function loadAll(): Promise<Record<string, StoredButterflyStats>> {
|
||||
return (await storage.getData<Record<string, StoredButterflyStats>>(STORAGE_KEY)) ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomicky zmutuje statistiky jednoho uživatele. Před zavoláním `mutator`
|
||||
* záznam znormalizuje a nechá dorůst škůdce.
|
||||
*/
|
||||
async function mutateUser(
|
||||
login: string,
|
||||
now: number,
|
||||
mutator: (mine: StoredButterflyStats) => void,
|
||||
): Promise<StoredButterflyStats> {
|
||||
const updated = await storage.updateData<Record<string, StoredButterflyStats>>(STORAGE_KEY, (current) => {
|
||||
const all = current ?? {};
|
||||
const mine = normalize(all[login] ?? defaultStats(now), now);
|
||||
growPests(mine, now);
|
||||
ensureDaily(mine, now);
|
||||
mutator(mine);
|
||||
all[login] = mine;
|
||||
return all;
|
||||
});
|
||||
return updated[login];
|
||||
}
|
||||
|
||||
// --- Chyby -------------------------------------------------------------------
|
||||
|
||||
/** Chyba vyhozená, když uživatel nemá dost mincí. */
|
||||
export class InsufficientCoinsError extends Error { }
|
||||
|
||||
// --- Veřejné API -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Vrátí statistiky chytání motýlků daného uživatele (a nechá dorůst škůdce).
|
||||
*/
|
||||
export async function getStats(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
const mine = await mutateUser(login, now, () => { /* jen dorůst škůdce */ });
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zaznamená dávku nachytaných motýlů, atomicky připíše mince (základ, kombo,
|
||||
* denní bonus) a aktualizuje statistiky. Vrátí i přehled odměn.
|
||||
*/
|
||||
export async function recordCatches(login: string, normal: number, golden: number, now: number = Date.now()): Promise<ButterflyCatchResult> {
|
||||
const n = Math.min(Math.max(Math.floor(normal) || 0, 0), MAX_BATCH);
|
||||
const g = Math.min(Math.max(Math.floor(golden) || 0, 0), MAX_BATCH);
|
||||
const batchTotal = n + g;
|
||||
const today = formatDate(new Date(now));
|
||||
|
||||
let coinsAwarded = 0;
|
||||
let dailyBonusApplied = false;
|
||||
let leveledUp = false;
|
||||
let premiumUnlocked = false;
|
||||
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
// Ban za podvádění: během banu se nic nezapočítává
|
||||
if (s.banUntil > now) return;
|
||||
|
||||
const oldCaught = s.caught;
|
||||
const oldLevel = levelForCaught(oldCaught);
|
||||
|
||||
// Ochrana proti podvádění: v minutovém okně se započítá max RATE_CAP_PER_MIN
|
||||
// úlovků, zbytek se tiše zahodí (nezapočte se ani mince). Přednost mají zlatí.
|
||||
if (now - s.catchWindowStart >= RATE_WINDOW_MS) {
|
||||
s.catchWindowStart = now;
|
||||
s.catchWindowCount = 0;
|
||||
}
|
||||
const allowance = Math.max(0, RATE_CAP_PER_MIN - s.catchWindowCount);
|
||||
const creditGolden = Math.min(g, allowance);
|
||||
const creditNormal = Math.min(n, Math.max(0, allowance - creditGolden));
|
||||
const credited = creditGolden + creditNormal;
|
||||
s.catchWindowCount += credited;
|
||||
|
||||
dailyBonusApplied = credited > 0 && s.lastCatchDay !== today;
|
||||
const daily = dailyBonusApplied ? DAILY_BONUS : 0;
|
||||
|
||||
coinsAwarded = creditNormal * COIN_BASE + creditGolden * GOLD_VALUE + comboBonus(credited) + daily;
|
||||
|
||||
const newCaught = oldCaught + credited;
|
||||
s.caught = newCaught;
|
||||
s.coins += coinsAwarded;
|
||||
s.goldenCaught += creditGolden;
|
||||
if (credited > 0) s.lastCatchDay = today;
|
||||
|
||||
bumpDaily(s, 'catch', credited);
|
||||
bumpDaily(s, 'golden', creditGolden);
|
||||
|
||||
leveledUp = levelForCaught(newCaught) > oldLevel;
|
||||
premiumUnlocked = Math.floor(newCaught / PREMIUM_MILESTONE) > Math.floor(oldCaught / PREMIUM_MILESTONE);
|
||||
});
|
||||
|
||||
return { stats: toDto(mine), coinsAwarded, dailyBonusApplied, leveledUp, premiumUnlocked };
|
||||
}
|
||||
|
||||
/**
|
||||
* Okamžitě zašije protrženou síťku za mince (volitelné – jinak se zašije sama).
|
||||
* Vyhodí {@link InsufficientCoinsError} při nedostatku mincí. Když síťka není
|
||||
* protržená, jen vrátí aktuální stav.
|
||||
*/
|
||||
export async function repairNet(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
let failed = false;
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
if (s.netTornUntil <= now) return; // není protržená (nebo se už zašila)
|
||||
if (s.coins < REPAIR_COST) { failed = true; return; }
|
||||
s.coins -= REPAIR_COST;
|
||||
s.netTornUntil = 0;
|
||||
});
|
||||
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na okamžitou opravu síťky');
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/** Zahubí jednu vosu (plácačkou) a zvýší statistiku zahubených vos. */
|
||||
export async function killWasp(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
if (s.wasps > 0) {
|
||||
s.wasps -= 1;
|
||||
s.waspsKilled += 1;
|
||||
bumpDaily(s, 'wasp', 1);
|
||||
}
|
||||
});
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Koupí plašič ptáků: vyžene všechny ptáky a po dobu platnosti brání příletu
|
||||
* nových. Vyhodí {@link InsufficientCoinsError} při nedostatku mincí.
|
||||
*/
|
||||
export async function buyRepellent(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
let failed = false;
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
if (s.coins < REPELLENT_COST) { failed = true; return; }
|
||||
s.coins -= REPELLENT_COST;
|
||||
s.birdsScared += s.birds;
|
||||
s.birds = 0;
|
||||
s.repellentUntil = now + REPELLENT_DURATION_MS;
|
||||
// Růst ptáků se rozběhne až od konce plašiče
|
||||
s.lastBirdAt = s.repellentUntil;
|
||||
});
|
||||
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na plašič ptáků');
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zaznamená, že pták protrhl síťku (perzistentně). Síťka se sama zašije za
|
||||
* AUTO_REPAIR_MS. Když už protržená je, běžící odpočet se neprodlužuje.
|
||||
*/
|
||||
export async function reportNetTorn(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
if (s.netTornUntil <= now) s.netTornUntil = now + effectiveAutoRepair(s);
|
||||
});
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zaznamená selhání anti-bot inspekce. Po BAN_FAIL_THRESHOLD selháních v okně
|
||||
* BAN_WINDOW_MS udělí ban na BAN_DURATION_MS (perzistentní – nejde obejít
|
||||
* obnovením stránky ani přepnutím režimu).
|
||||
*/
|
||||
export async function reportInspectionFail(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
if (s.banUntil > now) return; // už zabanovaný
|
||||
if (now - s.inspectionWindowStart > BAN_WINDOW_MS) {
|
||||
s.inspectionWindowStart = now;
|
||||
s.inspectionFails = 0;
|
||||
}
|
||||
s.inspectionFails += 1;
|
||||
if (s.inspectionFails >= BAN_FAIL_THRESHOLD) {
|
||||
s.banUntil = now + BAN_DURATION_MS;
|
||||
s.inspectionFails = 0;
|
||||
}
|
||||
});
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/** Zloděj se dostal k penězům a ukradl podíl mincí (méně, když je aktivní pojistka). */
|
||||
export async function robbery(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
const insured = s.insuranceUntil > now;
|
||||
const fraction = insured ? ROBBERY_FRACTION_INSURED : ROBBERY_FRACTION;
|
||||
s.coins = Math.round(s.coins * (1 - fraction));
|
||||
if (insured) s.insuranceUntil = 0; // pojistka se spotřebuje
|
||||
});
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/** Hráč porazil zloděje – malá odměna, statistika a progres denního úkolu. */
|
||||
export async function defeatThief(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
s.thievesDefeated += 1;
|
||||
s.coins += THIEF_REWARD;
|
||||
bumpDaily(s, 'thief', 1);
|
||||
});
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/** Housenka se dostala k úlovkům a sežrala část nachytaných motýlů (může snížit úroveň). */
|
||||
export async function caterpillarAte(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
s.caught = Math.max(0, s.caught - CATERPILLAR_EATS);
|
||||
});
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/** Hráč porazil housenku – malá odměna a statistika. */
|
||||
export async function defeatCaterpillar(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
s.caterpillarsDefeated += 1;
|
||||
s.coins += CATERPILLAR_REWARD;
|
||||
});
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/** Hráč omylem chytil černou můru – přijde o část úlovků. */
|
||||
export async function mothHit(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
s.caught = Math.max(0, s.caught - MOTH_PENALTY);
|
||||
s.mothsHit += 1;
|
||||
});
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
// --- Obchod ------------------------------------------------------------------
|
||||
|
||||
/** Koupí okamžitou aktivaci prémiové síťky (efekt řídí klient). */
|
||||
export async function buyPremium(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
let failed = false;
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
if (s.coins < PREMIUM_BUY_COST) { failed = true; return; }
|
||||
s.coins -= PREMIUM_BUY_COST;
|
||||
});
|
||||
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na prémiovou síťku');
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/** Koupí vosí sprej – vyhubí všechny vosy. */
|
||||
export async function waspSpray(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
let failed = false;
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
if (s.coins < WASP_SPRAY_COST) { failed = true; return; }
|
||||
s.coins -= WASP_SPRAY_COST;
|
||||
s.waspsKilled += s.wasps;
|
||||
s.wasps = 0;
|
||||
});
|
||||
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na vosí sprej');
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/** Koupí pojistku proti zloději (dočasně sníží ztrátu při okradení). */
|
||||
export async function buyInsurance(login: string, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
let failed = false;
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
if (s.coins < INSURANCE_COST) { failed = true; return; }
|
||||
s.coins -= INSURANCE_COST;
|
||||
s.insuranceUntil = now + INSURANCE_DURATION_MS;
|
||||
});
|
||||
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na pojistku');
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
/** Chyba pro neplatnou/vyprodanou koupi vylepšení. */
|
||||
export class UpgradeError extends Error { }
|
||||
|
||||
/** Koupí další úroveň trvalého vylepšení (cena se s úrovní zdvojnásobuje). */
|
||||
export async function buyUpgrade(login: string, item: UpgradeId, now: number = Date.now()): Promise<ButterflyStats> {
|
||||
if (!(item in UPGRADE_MAX)) throw new UpgradeError('Neznámé vylepšení');
|
||||
let failed = false;
|
||||
let maxed = false;
|
||||
const mine = await mutateUser(login, now, (s) => {
|
||||
const level = s.upgrades[item] ?? 0;
|
||||
if (level >= UPGRADE_MAX[item]) { maxed = true; return; }
|
||||
const cost = upgradeCost(item, level);
|
||||
if (s.coins < cost) { failed = true; return; }
|
||||
s.coins -= cost;
|
||||
s.upgrades[item] = level + 1;
|
||||
});
|
||||
if (maxed) throw new UpgradeError('Vylepšení je na maximální úrovni');
|
||||
if (failed) throw new InsufficientCoinsError('Nedostatek mincí na vylepšení');
|
||||
return toDto(mine);
|
||||
}
|
||||
|
||||
// --- Achievementy (odvozené z aktuálních statistik) --------------------------
|
||||
|
||||
export interface Achievement {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
unlocked: boolean;
|
||||
}
|
||||
|
||||
/** Vrátí seznam achievementů odvozený z aktuálních statistik uživatele. */
|
||||
export async function getAchievements(login: string, now: number = Date.now()): Promise<Achievement[]> {
|
||||
const s = await mutateUser(login, now, () => { /* jen načíst/dorůst */ });
|
||||
const level = levelForCaught(s.caught);
|
||||
const def: Achievement[] = [
|
||||
{ id: 'first-golden', title: 'Zlatý úlovek', description: 'Chyť prvního zlatého motýla', unlocked: s.goldenCaught >= 1 },
|
||||
{ id: 'golden-10', title: 'Zlatokop', description: 'Chyť 10 zlatých motýlů', unlocked: s.goldenCaught >= 10 },
|
||||
{ id: 'catch-500', title: 'Sběratel', description: 'Nachytej 500 motýlů', unlocked: s.caught >= 500 },
|
||||
{ id: 'catch-2000', title: 'Motýlí magnát', description: 'Nachytej 2000 motýlů', unlocked: s.caught >= 2000 },
|
||||
{ id: 'level-10', title: 'Zkušený chytač', description: 'Dosáhni úrovně 10', unlocked: level >= 10 },
|
||||
{ id: 'level-20', title: 'Legenda louky', description: 'Dosáhni úrovně 20', unlocked: level >= 20 },
|
||||
{ id: 'thief-10', title: 'Postrach zlodějů', description: 'Poraz 10 zlodějů', unlocked: s.thievesDefeated >= 10 },
|
||||
{ id: 'caterpillar-10', title: 'Zahradník', description: 'Poraz 10 housenek', unlocked: s.caterpillarsDefeated >= 10 },
|
||||
{ id: 'wasp-50', title: 'Plácačka', description: 'Zabij 50 vos', unlocked: s.waspsKilled >= 50 },
|
||||
{ id: 'bird-25', title: 'Strašák', description: 'Vyplaš 25 ptáků', unlocked: s.birdsScared >= 25 },
|
||||
{ id: 'net-max', title: 'Obří síť', description: 'Vylepši síťku na maximum', unlocked: (s.upgrades?.net ?? 0) >= UPGRADE_MAX.net },
|
||||
];
|
||||
return def;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí žebříček nejlepších chytačů seřazený sestupně dle počtu chycených
|
||||
* (při shodě dle počtu zlatých motýlů).
|
||||
*/
|
||||
export async function getLeaderboard(limit = 10): Promise<ButterflyLeaderboardEntry[]> {
|
||||
const all = await loadAll();
|
||||
return Object.entries(all)
|
||||
.map(([login, s]) => {
|
||||
const level = levelForCaught(s.caught ?? 0);
|
||||
return {
|
||||
login,
|
||||
caught: s.caught ?? 0,
|
||||
goldenCaught: s.goldenCaught ?? 0,
|
||||
level,
|
||||
title: titleForLevel(level),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.caught - a.caught || b.goldenCaught - a.goldenCaught)
|
||||
.slice(0, Math.max(1, limit));
|
||||
}
|
||||