Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
304c7656e9
|
||
|
|
bc2cc75d7d
|
||
|
|
fe12098ed0 | ||
|
|
95a35df6ab
|
||
|
|
297cb80662
|
||
|
|
ed64fc60e4
|
||
|
|
8a7dbd94c8 | ||
|
|
9fa00fa9c5
|
||
|
|
dc17fa0f0f
|
||
|
|
4bc2151cc4
|
||
|
|
e570f308cd
|
||
|
|
0cefaa62a0
|
||
|
|
2a54a1d41f | ||
|
|
5147651e7c | ||
|
|
6cb1214f08 | ||
|
|
abeee19259 | ||
|
|
f91193c7de
|
||
|
|
93d86fe0a5
|
||
|
|
bf631eb913
|
||
|
|
49508dacd0
|
||
|
|
9fee4a1538
|
||
|
|
7cda7cfdb6 | ||
|
|
89d2cc2bc3 | ||
|
|
a29e329c11
|
||
|
|
5f54925808 | ||
|
|
103445dba8 | ||
|
|
076b8db179 | ||
|
|
f9a7b791a0 | ||
|
|
bd52ae071e | ||
|
|
0a59f97c5c | ||
|
|
e7c4ba09ff | ||
|
|
e281615cc3 | ||
|
|
682deaf20b
|
||
|
|
92ea78a870
|
||
|
|
51df4c812e
|
||
|
|
ed53a1477e
|
||
|
|
6a12bdb179
|
||
|
|
31b398a1d6
|
||
|
|
b6834cb7aa
|
||
|
|
ecc28ee82d
|
||
|
|
1af40bd572
|
||
|
|
e5fc358c0a
|
||
|
|
6f6567a2a9
|
||
|
|
ce051447e6
|
||
|
|
93242b7c7e
|
||
|
|
9027de5f8b
|
||
|
|
936ce2a9e4 | ||
|
|
5c048fe1f1 | ||
|
|
eeb1630391 | ||
|
|
065ccaf38a | ||
|
|
377a350211 | ||
|
|
252d105408 | ||
|
|
e3f439bde0 | ||
|
|
c5ca6c9d41 | ||
|
|
c8c5ecc60c | ||
|
|
146c31b775
|
||
|
|
0232e6ea26
|
||
|
|
87c12501bb
|
||
|
|
56ceb6e3cb
|
||
|
|
e1ada97184 | ||
|
|
9ea2916c7a | ||
|
|
f8c5d28f47 | ||
|
|
12fa87f3ad | ||
|
|
a708eda421
|
||
|
|
464b14d63a
|
||
|
|
9a03dd1e5e
|
||
|
|
c3a8900283
|
||
|
|
791ccc1af6 | ||
|
|
6a2bffa31f | ||
|
|
364d46bc18
|
||
|
|
8c3c47dba1
|
||
|
|
34f6ba520e
|
||
|
|
bcec015c37
|
||
|
|
c404a3a03b | ||
|
|
84b95c6c70 | ||
|
|
9152425d2b
|
||
|
|
b42b051e6f
|
||
|
|
88b9e20e2d
|
||
|
|
cc09ddbd2c
|
||
|
|
491ec25b52 | ||
|
|
1df21edc1a | ||
|
|
df5423511f | ||
|
|
17132d4124
|
||
|
|
f28f127a92
|
||
|
|
fb84bff687
|
||
|
|
c85842267a
|
||
|
|
c2bbf7ea60
|
||
|
|
318d188495
|
||
|
|
a26d6cf85c
|
||
|
|
640c7ed41d
|
||
|
|
a166634db8
|
||
|
|
916766450a | ||
|
|
5e596c3b99 | ||
|
|
3ba5fdd086 | ||
|
|
03f4e438a3 | ||
|
|
b591411d10 | ||
|
|
8a588cf486
|
||
|
|
0e4dc061b8
|
||
|
|
7fd3ba0fc4
|
||
|
|
94b8f0a452 | ||
|
|
3e6ecd4e6a | ||
|
|
f12dc7b562 | ||
|
|
8aef00ab05 | ||
|
|
d91c8db49c
|
||
|
|
d8714b2086 | ||
|
|
c7f78cf2c9 | ||
|
|
1efe2b8f7d | ||
|
|
5f03471541 | ||
|
|
21d7224fb4 | ||
|
|
abc3d070cc | ||
|
|
cca751752d | ||
|
|
d2f45be2d3 | ||
|
|
936b33cc80 | ||
|
|
774be3df6d | ||
|
|
5f903797f1 | ||
|
|
a2d45ad7e7 | ||
|
|
4da3ce3b10 | ||
|
|
e2615edc0f | ||
|
|
a0d4921d87 | ||
|
|
8b1703dce9 | ||
|
|
3ed781d0cf | ||
|
|
70ed59ab9d | ||
|
|
6b2deff215 | ||
|
|
ace4130171 | ||
|
|
9383cd7d4c | ||
|
|
db1fe473cd | ||
|
|
d7c8a4663d | ||
|
|
ecbbeb2cec | ||
|
|
e9c570b3d5 | ||
|
|
f400d1c5f2 | ||
|
|
ec6df8700b | ||
|
|
85cda34881 | ||
|
|
d91c48c599 | ||
|
|
e83cf14594 | ||
|
|
2067c21a29 | ||
|
|
99260a3250 | ||
|
|
091294f7f3 | ||
|
|
bfe819020d | ||
|
|
467e3c155a | ||
|
|
d3224a36d5 | ||
|
|
64d85036fd
|
||
|
|
fe6bb3290e | ||
|
|
1e1e23df80 | ||
|
|
e5999852b7
|
||
|
|
4e7b83b667
|
||
|
|
d6729388ab
|
||
|
|
e9696f722c
|
||
|
|
fdeb2636c2
|
||
|
|
82ed16715f
|
||
|
|
44cf749bc9 | ||
|
|
a1b1eed86d | ||
|
|
f8a65d7177 | ||
|
|
607bcd9bf5 | ||
|
|
b6fdf1de98 | ||
|
|
27e56954bd | ||
|
|
20cc7259a3 | ||
|
|
d62f6c1f5a | ||
|
|
b77914404b | ||
|
|
8506b4e79f | ||
|
|
5f79a9431c
|
||
|
|
cc98c2be0d
|
||
|
|
a849f4e922 | ||
|
|
ac6727efa5 | ||
|
|
f13cd4ffa9 | ||
|
|
086646fd1c | ||
|
|
b8629afef2 | ||
|
|
d366ac39d4 | ||
|
|
fdd42dc46a | ||
|
|
2b7197eff6 | ||
|
|
6f43c74769 | ||
|
|
d85c764c88 | ||
|
|
37cacd895a | ||
|
|
6a1da97ef1 | ||
|
|
f91973f1a4
|
||
|
|
7cf9179a87
|
||
|
|
54e5be6b6a
|
||
|
|
c264f9921e
|
||
|
|
e03ba45415
|
||
|
|
20f4ee0427
|
||
|
|
be4cee4cdb
|
||
|
|
8285dd2780 | ||
|
|
039d8457f3 | ||
|
|
091e25f446
|
||
|
|
00b4a0cce2
|
||
|
|
979c79e090 | ||
|
|
d29c7863dd | ||
|
|
0781b84f11 | ||
|
|
e3d217822a
|
||
|
|
ccfe9a9ae1
|
||
|
|
7407a3d881
|
||
|
|
8b139eba4e
|
||
|
|
74eeef1de9 | ||
|
|
fd67c0e646 | ||
|
|
60150889b0 | ||
|
|
9e0e842c2d | ||
|
|
86c50b315a | ||
|
|
fe7d609b5f | ||
|
|
331a890cc5
|
||
|
|
523bbbfb0f
|
||
|
|
1acf9bf092
|
||
|
|
81f67c8424
|
||
|
|
c2a001b7e5
|
||
|
|
670e45b805
|
||
|
|
52769fc981
|
||
|
|
0d90453c38
|
||
|
|
a9709a944f
|
||
|
|
593ffcf02b
|
||
|
|
b4b62870e3
|
||
|
|
480fe725f1
|
||
|
|
d2845f7d0f | ||
|
|
269f1994bc | ||
|
|
3dcda2028e | ||
|
|
cfffd2b31d | ||
|
|
58bb5f4e7d | ||
|
|
a3dfdb17e8 | ||
|
|
124fdce69d | ||
|
|
ff20394b97 | ||
|
|
a77a04bcdf | ||
|
|
42852805e0
|
||
|
|
d767730b19 | ||
|
|
fa4f9903cb | ||
|
|
cf8be8c64f | ||
|
|
4c2b08adf8 | ||
|
|
62cc82da9a
|
||
|
|
40c113a4c8
|
||
|
|
7681584d11
|
||
|
|
c670b4212a | ||
|
|
5fd90de3f8 | ||
|
|
49b8ab5c13 | ||
|
|
9a05ef1fe6 | ||
|
|
0bfea3765f | ||
|
|
962fbe2947 | ||
|
|
d6d6ebb682 | ||
|
|
5bb7de58e7 | ||
|
|
739c7707e1 | ||
|
|
d366882f6b | ||
|
|
f09bc44d63 | ||
|
|
f0d56f11aa | ||
|
|
f74ec379c8 | ||
|
|
c9fa710070 | ||
|
|
e55ee7c11e | ||
|
|
55fd368663 | ||
|
|
61f13d2132 | ||
|
|
d69e09afee | ||
|
|
d144c55bf7 | ||
|
|
999a517404 | ||
|
|
68bafa808c | ||
|
|
a34614c8db | ||
|
|
f4e31cea36 | ||
|
|
8dda6b1014 | ||
|
|
f9c7d647f7 | ||
|
|
ca400638d1 | ||
|
|
0af78e72d9 |
No files matched your search
@@ -0,0 +1,261 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**"
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
# ─── 1. Generate OpenAPI types ────────────────────────────────────────────
|
||||||
|
|
||||||
|
generate-types:
|
||||||
|
name: Generate TypeScript types
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "26"
|
||||||
|
|
||||||
|
- run: corepack enable
|
||||||
|
|
||||||
|
- run: cd types && yarn install --frozen-lockfile && yarn openapi-ts
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: types-gen
|
||||||
|
path: types/gen
|
||||||
|
|
||||||
|
# ─── 2a. Server unit tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
server-test:
|
||||||
|
name: Server unit tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: generate-types
|
||||||
|
env:
|
||||||
|
NODE_ENV: test
|
||||||
|
JWT_SECRET: test-secret-min-32-chars-aaaaaaa!
|
||||||
|
MOCK_DATA: "true"
|
||||||
|
STORAGE: json
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "26"
|
||||||
|
|
||||||
|
- run: corepack enable
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: types-gen
|
||||||
|
path: types/gen
|
||||||
|
|
||||||
|
- run: cd server && yarn install --frozen-lockfile && yarn test
|
||||||
|
|
||||||
|
# ─── 2b. Build server ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
server-build:
|
||||||
|
name: Build server
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: generate-types
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "26"
|
||||||
|
|
||||||
|
- run: corepack enable
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: types-gen
|
||||||
|
path: types/gen
|
||||||
|
|
||||||
|
- run: cd types && yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
- run: cd server && yarn install --frozen-lockfile && yarn build
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: server-dist
|
||||||
|
path: server/dist
|
||||||
|
|
||||||
|
# ─── 2c. Build client ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
client-build:
|
||||||
|
name: Build client
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: generate-types
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "26"
|
||||||
|
|
||||||
|
- run: corepack enable
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: types-gen
|
||||||
|
path: types/gen
|
||||||
|
|
||||||
|
- run: cd types && yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
- run: cd client && yarn install --frozen-lockfile && yarn build
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: client-dist
|
||||||
|
path: client/dist
|
||||||
|
|
||||||
|
# ─── 3. Playwright E2E tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
name: Playwright E2E tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [ server-build, client-build ]
|
||||||
|
container: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis/redis-stack-server:7.4.0-v1
|
||||||
|
env:
|
||||||
|
REDIS_ARGS: "--save '' --loglevel warning"
|
||||||
|
env:
|
||||||
|
CI: "true"
|
||||||
|
NODE_ENV: test
|
||||||
|
JWT_SECRET: test-secret-min-32-chars-aaaaaaa!
|
||||||
|
MOCK_DATA: "true"
|
||||||
|
STORAGE: redis
|
||||||
|
REDIS_HOST: redis
|
||||||
|
REDIS_PORT: "6379"
|
||||||
|
HTTP_REMOTE_USER_ENABLED: "true"
|
||||||
|
HTTP_REMOTE_USER_HEADER_NAME: remote-user
|
||||||
|
HTTP_REMOTE_TRUSTED_IPS: "127.0.0.1,::1,::ffff:127.0.0.1"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: server-dist
|
||||||
|
path: server/dist
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: client-dist
|
||||||
|
path: client/dist
|
||||||
|
|
||||||
|
- name: Install server dependencies
|
||||||
|
run: cd server && yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Copy client build into server/public
|
||||||
|
run: cp -r client/dist server/public
|
||||||
|
|
||||||
|
- name: Install e2e dependencies and browsers
|
||||||
|
run: cd e2e && yarn install --frozen-lockfile && yarn playwright install firefox
|
||||||
|
--with-deps
|
||||||
|
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: cd e2e && yarn test
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: |
|
||||||
|
e2e/playwright-report
|
||||||
|
e2e/test-results
|
||||||
|
|
||||||
|
# ─── 4. Build and push Docker image (master only) ─────────────────────────
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
name: Build and push Docker image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [ server-build, client-build, server-test, e2e ]
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "26"
|
||||||
|
|
||||||
|
- run: corepack enable
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: server-dist
|
||||||
|
path: server/dist
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: client-dist
|
||||||
|
path: client/dist
|
||||||
|
|
||||||
|
- name: Install server production dependencies
|
||||||
|
run: cd server && yarn install --frozen-lockfile --production
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ secrets.REPO_URL }}
|
||||||
|
username: ${{ secrets.REPO_USERNAME }}
|
||||||
|
password: ${{ secrets.REPO_PASSWORD }}
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
target: runner-prebuilt
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: ${{ secrets.REPO_URL }}/${{ secrets.REPO_NAME }}:latest
|
||||||
|
|
||||||
|
# ─── 5. Notifications ────────────────────────
|
||||||
|
|
||||||
|
notify:
|
||||||
|
name: Notify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [ server-build, client-build, server-test, e2e, docker-build ]
|
||||||
|
if: always() && github.event_name == 'push'
|
||||||
|
steps:
|
||||||
|
- name: Send webhook
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK_ID: ${{ secrets.DISCORD_WEBHOOK_ID }}
|
||||||
|
DISCORD_WEBHOOK_TOKEN: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
|
||||||
|
NTFY_URL: ${{ secrets.NTFY_URL }}
|
||||||
|
BUILD_RESULT: ${{ needs.docker-build.result }}
|
||||||
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{
|
||||||
|
github.run_id }}
|
||||||
|
COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
|
||||||
|
COMMIT_AUTHOR: ${{ github.event.head_commit.author.name }}
|
||||||
|
run: |
|
||||||
|
if [ "$BUILD_RESULT" = "success" ]; then
|
||||||
|
MSG="✅ Sestavení #${RUN_NUMBER} proběhlo úspěšně."
|
||||||
|
NTFY_TAGS="white_check_mark"
|
||||||
|
else
|
||||||
|
MSG="❌ Sestavení #${RUN_NUMBER} selhalo."
|
||||||
|
NTFY_TAGS="x"
|
||||||
|
fi
|
||||||
|
FULL_MSG="$(printf '%s\n\nPipeline: %s\nPoslední commit: %sAutor: %s' \
|
||||||
|
"$MSG" "$RUN_URL" "$COMMIT_MESSAGE" "$COMMIT_AUTHOR")"
|
||||||
|
curl -s -X POST \
|
||||||
|
"https://discord.com/api/webhooks/${DISCORD_WEBHOOK_ID}/${DISCORD_WEBHOOK_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--data "$(jq -n --arg content "$FULL_MSG" '{content: $content}')"
|
||||||
|
curl -s -X POST "${NTFY_URL}" \
|
||||||
|
-H "Title: Luncher CI #${RUN_NUMBER}" \
|
||||||
|
-H "Tags: ${NTFY_TAGS}" \
|
||||||
|
-H "Click: ${RUN_URL}" \
|
||||||
|
-d "${FULL_MSG}"
|
||||||
@@ -1 +1,10 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
types/gen
|
||||||
|
**.DS_Store
|
||||||
|
.mcp.json
|
||||||
|
.claude/settings.local.json
|
||||||
|
server/public/
|
||||||
|
.claude/*.lock
|
||||||
|
.claude/worktrees
|
||||||
|
.playwright-mcp
|
||||||
|
.idea/
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Server (ts-node, debug)",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"cwd": "${workspaceFolder}/server",
|
||||||
|
"runtimeArgs": ["-r", "ts-node/register"],
|
||||||
|
"program": "${workspaceFolder}/server/src/index.ts",
|
||||||
|
"env": { "NODE_ENV": "development" },
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"skipFiles": ["<node_internals>/**"],
|
||||||
|
"preLaunchTask": "types: openapi-ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Client (vite + Edge)",
|
||||||
|
"type": "msedge",
|
||||||
|
"request": "launch",
|
||||||
|
"url": "http://localhost:3000",
|
||||||
|
"webRoot": "${workspaceFolder}/client",
|
||||||
|
"preLaunchTask": "client: vite"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compounds": [
|
||||||
|
{
|
||||||
|
"name": "Dev: server + client",
|
||||||
|
"configurations": ["Server (ts-node, debug)", "Client (vite + Edge)"],
|
||||||
|
"stopAll": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "types: openapi-ts",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "yarn openapi-ts",
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/types"
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "silent",
|
||||||
|
"panel": "dedicated"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "server: startReload",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "yarn startReload",
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/server",
|
||||||
|
"env": {
|
||||||
|
"NODE_ENV": "development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isBackground": true,
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "dedicated",
|
||||||
|
"group": "dev"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "client: vite",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "yarn start",
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/client"
|
||||||
|
},
|
||||||
|
"isBackground": true,
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "dedicated",
|
||||||
|
"group": "dev"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "dev: server+client",
|
||||||
|
"dependsOn": [
|
||||||
|
"server: startReload",
|
||||||
|
"client: vite"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "dev: all",
|
||||||
|
"dependsOrder": "sequence",
|
||||||
|
"dependsOn": [
|
||||||
|
"types: openapi-ts",
|
||||||
|
"dev: server+client"
|
||||||
|
],
|
||||||
|
"problemMatcher": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
variables:
|
|
||||||
- &node_image 'node:18-alpine3.18'
|
|
||||||
- &branch 'master'
|
|
||||||
|
|
||||||
when:
|
|
||||||
- event: push
|
|
||||||
branch: *branch
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Install server dependencies
|
|
||||||
image: *node_image
|
|
||||||
commands:
|
|
||||||
- cd server
|
|
||||||
- yarn install --frozen-lockfile
|
|
||||||
- name: Install client dependencies
|
|
||||||
image: *node_image
|
|
||||||
commands:
|
|
||||||
- cd client
|
|
||||||
- yarn install --frozen-lockfile
|
|
||||||
- name: Build server
|
|
||||||
depends_on: [Install server dependencies]
|
|
||||||
image: *node_image
|
|
||||||
commands:
|
|
||||||
- cd server
|
|
||||||
- yarn build
|
|
||||||
- name: Build client
|
|
||||||
depends_on: [Install client dependencies]
|
|
||||||
image: *node_image
|
|
||||||
commands:
|
|
||||||
- cd client
|
|
||||||
- yarn build
|
|
||||||
- name: Build Docker image
|
|
||||||
depends_on: [Build server, Build client]
|
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
|
||||||
settings:
|
|
||||||
dockerfile: Dockerfile-Woodpecker
|
|
||||||
platforms: linux/amd64
|
|
||||||
registry:
|
|
||||||
from_secret: REPO_URL
|
|
||||||
username:
|
|
||||||
from_secret: REPO_USERNAME
|
|
||||||
password:
|
|
||||||
from_secret: REPO_PASSWORD
|
|
||||||
repo:
|
|
||||||
from_secret: REPO_NAME
|
|
||||||
- name: Discord notification - build
|
|
||||||
image: appleboy/drone-discord
|
|
||||||
depends_on: [Build Docker image]
|
|
||||||
when:
|
|
||||||
- status: [success, failure]
|
|
||||||
settings:
|
|
||||||
webhook_id:
|
|
||||||
from_secret: DISCORD_WEBHOOK_ID
|
|
||||||
webhook_token:
|
|
||||||
from_secret: DISCORD_WEBHOOK_TOKEN
|
|
||||||
message: "{{#success build.status}}✅ Sestavení {{build.number}} proběhlo úspěšně.{{else}}❌ Sestavení {{build.number}} selhalo.{{/success}}\n\nPipeline: {{build.link}}\nPoslední commit: {{commit.message}}Autor: {{commit.author}}"
|
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Luncher is a lunch management app for teams — daily restaurant menus, food ordering, pizza day events, and payment QR codes. Czech-language UI. Full-stack TypeScript monorepo.
|
||||||
|
|
||||||
|
## Monorepo Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
types/ → Shared OpenAPI-generated TypeScript types (source of truth: types/api.yml)
|
||||||
|
server/ → Express 5 backend (Node.js 22, ts-node)
|
||||||
|
client/ → React 19 frontend (Vite 7, React Bootstrap)
|
||||||
|
e2e/ → Playwright E2E tests (separate package)
|
||||||
|
```
|
||||||
|
|
||||||
|
Each of the four directories has its own `package.json`. Package manager: **Yarn Classic**.
|
||||||
|
|
||||||
|
Deployment files at repo root: `Dockerfile` (multi-stage, dva runner targety: výchozí `runner` pro lokální build, `runner-prebuilt` pro CI s předem sestavenými artefakty), `compose.yml`, `compose-traefik.yml`.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Initial setup
|
||||||
|
```bash
|
||||||
|
cd types && yarn install && yarn openapi-ts # Generate API types first
|
||||||
|
cd ../server && yarn install
|
||||||
|
cd ../client && yarn install
|
||||||
|
cd ../e2e && yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running dev environment
|
||||||
|
```bash
|
||||||
|
# All-in-one (tmux):
|
||||||
|
./run_dev.sh
|
||||||
|
|
||||||
|
# Or manually in separate terminals:
|
||||||
|
cd server && NODE_ENV=development yarn startReload # Port 3001, nodemon watch
|
||||||
|
cd client && yarn start # Port 3000, proxies /api → 3001
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building
|
||||||
|
```bash
|
||||||
|
cd types && yarn openapi-ts # Regenerate types from api.yml
|
||||||
|
cd server && yarn build # tsc → server/dist
|
||||||
|
cd client && yarn build # tsc --noEmit + vite build → client/dist
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
```bash
|
||||||
|
# Server unit tests (Jest)
|
||||||
|
cd server && yarn test # All tests in server/src/tests/
|
||||||
|
cd server && yarn test dates # Run one file by name
|
||||||
|
cd server && yarn test -t "name" # Run by test name pattern
|
||||||
|
|
||||||
|
# E2E (Playwright) — requires prebuilt server
|
||||||
|
cd server && yarn build
|
||||||
|
cd e2e && yarn test # chromium + firefox, baseURL 127.0.0.1:3001
|
||||||
|
cd e2e && yarn test:ui # interactive UI mode
|
||||||
|
cd e2e && yarn report # open last HTML report
|
||||||
|
```
|
||||||
|
|
||||||
|
Jest setup (`server/src/tests/setupEnv.ts`) forces `STORAGE=memory`, deletes `MOCK_DATA`, and sets a fixed `JWT_SECRET`. Playwright auto-starts the prebuilt server and authenticates via the `remote-user: e2e-user` trusted-header path; locally uses `STORAGE=json` + `MOCK_DATA=true`, CI uses `STORAGE=redis`.
|
||||||
|
|
||||||
|
### CI pipeline
|
||||||
|
Gitea Actions — `.gitea/workflows/ci.yaml` (no `.github/` equivalent):
|
||||||
|
|
||||||
|
1. `generate-types` — runs `yarn openapi-ts`, uploads artifact
|
||||||
|
2. `server-test` — Jest
|
||||||
|
3. `server-build` + `client-build` — parallel tsc/vite builds
|
||||||
|
4. `e2e` — Playwright in `mcr.microsoft.com/playwright:v1.59.1-jammy` with a Redis service container; only Firefox installed in CI
|
||||||
|
5. `docker-build` — master branch only, uses `Dockerfile` with `--target runner-prebuilt` (skládá image z artefaktů `server-build` + `client-build`)
|
||||||
|
6. `notify` — Discord + ntfy webhooks
|
||||||
|
|
||||||
|
### Formatting
|
||||||
|
Prettier is installed in `client/` (devDependency only, no script or config) — invoke via `yarn prettier <path>` with defaults.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### API Types (types/)
|
||||||
|
- OpenAPI 3.0 spec in `types/api.yml` — all API endpoints and DTOs defined here
|
||||||
|
- `api.yml` is a thin aggregator — actual endpoint specs live in `types/paths/<domain>/*.yml`, shared schemas in `types/schemas/_index.yml`
|
||||||
|
- `yarn openapi-ts` generates `types/gen/` (client.gen.ts, sdk.gen.ts, types.gen.ts)
|
||||||
|
- Both server and client import from these generated types
|
||||||
|
- **When changing API contracts: update api.yml first, then regenerate**
|
||||||
|
|
||||||
|
### Server (server/src/)
|
||||||
|
- **Entry:** `index.ts` — Express app + Socket.io setup
|
||||||
|
- **Routes:** `routes/` — 9 modular Express route handlers (food, pizzaDay, voting, notifications, qr, stats, easterEgg, dev, changelog)
|
||||||
|
- **Services:** domain logic files at root level (`service.ts`, `pizza.ts`, `restaurants.ts`, `chefie.ts`, `voting.ts`, `stats.ts`, `qr.ts`, `notifikace.ts`)
|
||||||
|
- **Helpers:** `mock.ts` (fake menu data for `MOCK_DATA=true`), `pushReminder.ts` (push notification reminders), `utils.ts` (shared utilities)
|
||||||
|
- **Auth:** `auth.ts` — JWT + optional trusted-header authentication
|
||||||
|
- **Storage:** `storage/index.ts` factory selects implementation; backends: `json.ts` (file-based, dev), `redis.ts` (production), `memory.ts` (tests). Data keyed by date (YYYY-MM-DD).
|
||||||
|
- **Restaurant scrapers:** Cheerio-based HTML parsing for daily menus from multiple restaurants
|
||||||
|
- **Pizza Chefie integration:** `chefie.ts` scrapes pizzachefie.cz for Pizza day menus and salads (only active when a Pizza day is open)
|
||||||
|
- **WebSocket:** `websocket.ts` — Socket.io for real-time client updates
|
||||||
|
- **Mock mode:** `MOCK_DATA=true` env var returns fake menus (useful for weekend/holiday dev)
|
||||||
|
- **Config:** `.env.development` / `.env.production` (see `.env.template` for all options)
|
||||||
|
|
||||||
|
### Client (client/src/)
|
||||||
|
- **Entry:** `index.tsx` → `App.tsx` → `AppRoutes.tsx`; `Login.tsx` is the auth screen; `FallingLeaves.tsx` is a seasonal visual effect
|
||||||
|
- **Pages:** `pages/` (StatsPage)
|
||||||
|
- **Components:** `components/` (Header, Footer, Loader, PizzaOrderList, PizzaOrderRow, and modals in `components/modals/`)
|
||||||
|
- **Context providers:** `context/` — `auth.tsx`, `settings.tsx`, `socket.js`, `eggs.tsx` (note: `socket.js` is the only non-TSX context file)
|
||||||
|
- **Hooks:** `hooks/` (`usePushReminder.ts`)
|
||||||
|
- **Utils:** `utils/` (`parsePrice.ts`)
|
||||||
|
- **Styling:** Bootstrap 5 + React Bootstrap + custom SCSS files (co-located with components)
|
||||||
|
- **API calls:** use OpenAPI-generated SDK from `types/gen/`
|
||||||
|
- **Routing:** React Router DOM v7
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
1. Client calls API via generated SDK → Express routes
|
||||||
|
2. Server scrapes restaurant websites or returns cached data
|
||||||
|
3. Storage: Redis (production) or JSON file (development)
|
||||||
|
4. Socket.io broadcasts changes to all connected clients
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- **Server env files:** `server/.env.development`, `server/.env.production` (see `server/.env.template`)
|
||||||
|
- Key vars: `JWT_SECRET`, `STORAGE` (json/redis/memory), `MOCK_DATA`, `REDIS_HOST`, `REDIS_PORT`
|
||||||
|
- **Docker:** multi-stage Dockerfile, `compose.yml` for app + Redis. Timezone: Europe/Prague.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Czech naming for domain variables and UI strings; English for infrastructure code
|
||||||
|
- TypeScript strict mode in both client and server
|
||||||
|
- Server module resolution: Node16; Client: ESNext/bundler
|
||||||
|
- `TODO.md` tracks open bugs and roadmap items — worth scanning before starting non-trivial work
|
||||||
|
|
||||||
|
## Changelog (user-facing "What's new")
|
||||||
|
|
||||||
|
When you add or change a user-visible feature, you MUST also record it in `server/changelogs/` so users see it via the in-app changelog (served by `server/src/routes/changelogRoutes.ts`, sorted newest-first with a `?since=` filter).
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
|
||||||
|
1. Create or open the file for today's date: `server/changelogs/YYYY-MM-DD.json` (use today's date; one file per day).
|
||||||
|
2. The file content is a **JSON array of Czech strings**, one short user-facing sentence per change. Example:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
"Proklik na nabídku podniku ze stránky objednávek",
|
||||||
|
"Možnost přidat URL pro sledování stavu doručení pro Bolt Food"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
3. If a file for today already exists, append your entry to its array instead of creating a new file.
|
||||||
|
4. Write entries in Czech, phrased for end users (describe the benefit, not the implementation). Skip purely internal/refactor changes that users won't notice.
|
||||||
|
5. The route caches files in memory at read time, so a server restart is needed to pick up new/changed changelogs in a running dev instance.
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
# Builder
|
ARG NODE_VERSION="node:26-alpine"
|
||||||
FROM node:18-alpine3.18 AS builder
|
|
||||||
|
# ─── Builder ──────────────────────────────────────────────────────────────────
|
||||||
|
FROM ${NODE_VERSION} AS builder
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Zkopírování závislostí - OpenAPI generátor
|
||||||
|
COPY types/package.json ./types/
|
||||||
|
COPY types/yarn.lock ./types/
|
||||||
|
COPY types/api.yml ./types/
|
||||||
|
COPY types/schemas ./types/schemas/
|
||||||
|
COPY types/paths ./types/paths/
|
||||||
|
COPY types/openapi-ts.config.ts ./types/
|
||||||
|
|
||||||
# Zkopírování závislostí - server
|
# Zkopírování závislostí - server
|
||||||
COPY server/package.json ./server/
|
COPY server/package.json ./server/
|
||||||
COPY server/yarn.lock ./server/
|
COPY server/yarn.lock ./server/
|
||||||
@@ -11,6 +21,10 @@ COPY server/yarn.lock ./server/
|
|||||||
COPY client/package.json ./client/
|
COPY client/package.json ./client/
|
||||||
COPY client/yarn.lock ./client/
|
COPY client/yarn.lock ./client/
|
||||||
|
|
||||||
|
# Instalace závislostí - OpenAPI generátor
|
||||||
|
WORKDIR /build/types
|
||||||
|
RUN yarn install --frozen-lockfile
|
||||||
|
|
||||||
# Instalace závislostí - server
|
# Instalace závislostí - server
|
||||||
WORKDIR /build/server
|
WORKDIR /build/server
|
||||||
RUN yarn install --frozen-lockfile
|
RUN yarn install --frozen-lockfile
|
||||||
@@ -34,7 +48,11 @@ COPY client/src ./client/src
|
|||||||
COPY client/public ./client/public
|
COPY client/public ./client/public
|
||||||
|
|
||||||
# Zkopírování společných typů
|
# Zkopírování společných typů
|
||||||
COPY types ./types/
|
COPY types/index.ts ./types/
|
||||||
|
|
||||||
|
# Vygenerování společných typů z OpenAPI
|
||||||
|
WORKDIR /build/types
|
||||||
|
RUN yarn openapi-ts
|
||||||
|
|
||||||
# Sestavení serveru
|
# Sestavení serveru
|
||||||
WORKDIR /build/server
|
WORKDIR /build/server
|
||||||
@@ -44,13 +62,28 @@ RUN yarn build
|
|||||||
WORKDIR /build/client
|
WORKDIR /build/client
|
||||||
RUN yarn build
|
RUN yarn build
|
||||||
|
|
||||||
# Runner
|
# ─── Runner base ──────────────────────────────────────────────────────────────
|
||||||
FROM node:18-alpine3.18
|
# Společný základ pro oba runner targety – nastaví prostředí a metadata běhu.
|
||||||
ENV LANG cs_CZ.UTF-8
|
FROM ${NODE_VERSION} AS runner-base
|
||||||
ENV NODE_ENV production
|
|
||||||
|
RUN apk add --no-cache tzdata
|
||||||
|
ENV TZ=Europe/Prague \
|
||||||
|
LC_ALL=cs_CZ.UTF-8 \
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Export /data/db.json do složky /data
|
||||||
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
CMD [ "node", "./server/src/index.js" ]
|
||||||
|
|
||||||
|
# ─── Runner (default) ─────────────────────────────────────────────────────────
|
||||||
|
# Použití: docker build . (lokální sestavení – vše se buildí uvnitř image)
|
||||||
|
FROM runner-base AS runner
|
||||||
|
|
||||||
# Vykopírování sestaveného serveru
|
# Vykopírování sestaveného serveru
|
||||||
COPY --from=builder /build/server/node_modules ./server/node_modules
|
COPY --from=builder /build/server/node_modules ./server/node_modules
|
||||||
COPY --from=builder /build/server/dist ./
|
COPY --from=builder /build/server/dist ./
|
||||||
@@ -59,14 +92,30 @@ COPY --from=builder /build/server/dist ./
|
|||||||
COPY --from=builder /build/client/dist ./public
|
COPY --from=builder /build/client/dist ./public
|
||||||
|
|
||||||
# Zkopírování produkčních .env serveru
|
# Zkopírování produkčních .env serveru
|
||||||
COPY /server/.env.production ./server/src
|
COPY /server/.env.production ./server
|
||||||
|
|
||||||
|
# Zkopírování changelogů (seznamu novinek)
|
||||||
|
COPY /server/changelogs ./server/changelogs
|
||||||
|
|
||||||
# Zkopírování konfigurace easter eggů
|
# Zkopírování konfigurace easter eggů
|
||||||
RUN if [ -f /server/.easter-eggs.json ]; then cp /server/.easter-eggs.json ./server/; fi
|
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
|
||||||
|
|
||||||
# Export /data/db.json do složky /data
|
# ─── Runner (prebuilt) ────────────────────────────────────────────────────────
|
||||||
VOLUME ["/data"]
|
# Použití: docker build --target runner-prebuilt .
|
||||||
|
# Očekává předem sestavené artefakty v build kontextu (server/dist,
|
||||||
|
# client/dist, server/node_modules) – využívá Gitea Actions, kde se
|
||||||
|
# server i klient buildí v separátních jobech a sem se jen kopírují.
|
||||||
|
FROM runner-base AS runner-prebuilt
|
||||||
|
|
||||||
EXPOSE 3000
|
# Vykopírování sestaveného serveru
|
||||||
|
COPY ./server/node_modules ./server/node_modules
|
||||||
|
COPY ./server/dist ./
|
||||||
|
|
||||||
CMD [ "node", "./server/src/index.js" ]
|
# Vykopírování sestaveného klienta
|
||||||
|
COPY ./client/dist ./public
|
||||||
|
|
||||||
|
# Zkopírování changelogů (seznamu novinek)
|
||||||
|
COPY ./server/changelogs ./server/changelogs
|
||||||
|
|
||||||
|
# Zkopírování konfigurace easter eggů
|
||||||
|
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
FROM node:18-alpine3.18
|
|
||||||
ENV LANG=cs_CZ.UTF-8
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Vykopírování sestaveného serveru
|
|
||||||
COPY ./server/node_modules ./server/node_modules
|
|
||||||
COPY ./server/dist ./
|
|
||||||
# TODO tohle není dobře, má to být součástí serveru
|
|
||||||
# COPY ./server/resources ./resources
|
|
||||||
|
|
||||||
# Vykopírování sestaveného klienta
|
|
||||||
COPY ./client/dist ./public
|
|
||||||
|
|
||||||
# Zkopírování konfigurace easter eggů
|
|
||||||
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD [ "node", "./server/src/index.js" ]
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
# Luncher
|
# Luncher
|
||||||
Aplikace pro profesionální management obědů.
|
Aplikace pro profesionální management obědů.
|
||||||
|
|
||||||
Aplikace sestává ze dvou modulů + společných TypeScript definic (adresář `types`).
|
Aplikace sestává ze tří modulů.
|
||||||
|
- types
|
||||||
|
- OpenAPI definice společných typů, generované přes [openapi-ts](https://github.com/hey-api/openapi-ts)
|
||||||
- server
|
- server
|
||||||
- backend psaný v [node.js](https://nodejs.dev)
|
- backend psaný v [node.js](https://nodejs.dev)
|
||||||
- client
|
- client
|
||||||
@@ -10,19 +12,27 @@ Aplikace sestává ze dvou modulů + společných TypeScript definic (adresář
|
|||||||
## Spuštění pro vývoj
|
## Spuštění pro vývoj
|
||||||
### Závislosti
|
### Závislosti
|
||||||
#### Klient/server
|
#### Klient/server
|
||||||
- [Node.js 18.x](https://nodejs.dev)
|
- [Node.js 22.x (>= 22.11)](https://nodejs.dev)
|
||||||
- [Yarn 1.22.x (Classic)](https://classic.yarnpkg.com)
|
- [Yarn 1.22.x (Classic)](https://classic.yarnpkg.com)
|
||||||
|
|
||||||
### Spuštění na *nix platformách
|
### Spuštění na *nix platformách
|
||||||
- Nainstalovat závislosti viz předchozí bod
|
- Nainstalovat závislosti viz předchozí bod
|
||||||
- Zkopírovat `server/.env.template` do `server/.env.development` a upravit dle potřeby
|
- Zkopírovat `server/.env.template` do `server/.env.development` a upravit dle potřeby
|
||||||
- Spustit `./run_dev.sh`. Na jiných platformách se lze inspirovat jeho obsahem, postup by měl být víceméně stejný.
|
- Vygenerovat společné TypeScript typy
|
||||||
|
- `cd types && yarn install && yarn openapi-ts`
|
||||||
|
- Server
|
||||||
|
- `cd server && yarn install && export NODE_ENV=development && yarn startReload`
|
||||||
|
- Klient
|
||||||
|
- `cd client && yarn install && yarn start`
|
||||||
|
|
||||||
## Sestavení a spuštění produkční verze v Docker
|
## Sestavení a spuštění produkční verze v Docker
|
||||||
### Závislosti
|
### Závislosti
|
||||||
- [Docker](https://www.docker.com)
|
- [Docker](https://www.docker.com)
|
||||||
- [Docker Compose](https://docs.docker.com/compose)
|
- [Docker Compose](https://docs.docker.com/compose)
|
||||||
|
|
||||||
|
### Spuštění
|
||||||
|
- `docker compose up --build -d`
|
||||||
|
|
||||||
### Spuštení s traefik
|
### Spuštení s traefik
|
||||||
- `docker compose -f compose-traefik.yml up --build -d`
|
- `docker compose -f compose-traefik.yml up --build -d`
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,44 @@
|
|||||||
# TODO
|
# TODO
|
||||||
- [ ] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli
|
|
||||||
- [ ] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď)
|
## HA / multi-replica follow-ups
|
||||||
- [ ] Nemělo by se jít dostat na přihlašovací formulář (měla by tam být nanejvýš hláška nebo přesměrování)
|
- [ ] `foodRoutes.ts` per-pod rate limiter (`rateLimits` map) — s více replikami může uživatel překročit limit ~N× rychleji; přesunout do Redis (např. `INCR` + `EXPIRE`)
|
||||||
- [ ] Možnost úhrady celé útraty jednou osobou
|
- [ ] `easterEggRoutes.ts` — náhodně generované URL easter eggů jsou per-pod; URL funguje pouze na podu, který ji vygeneroval; zvážit deterministické seedy nebo sdílení přes Redis
|
||||||
|
- [ ] `service.ts` — komplexní víceúrovňové funkce (`addChoice`, `removeChoiceIfPresent`) provádějí více po sobě jdoucích zápisů do stejného Redis klíče; pro plnou atomicitu je potřeba per-klíčový distribuovaný zámek (Redlock nebo `SET NX EX`) nebo sloučení logiky do jednoho `updateData` volání
|
||||||
|
- [X] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli — kontroluje se skutečná adresa spojení (trustedHeaders.ts)
|
||||||
|
- [X] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď)
|
||||||
|
- [X] Nemělo by se jít dostat na přihlašovací formulář (měla by tam být nanejvýš hláška nebo přesměrování)
|
||||||
|
- [X] Možnost úhrady celé útraty jednou osobou
|
||||||
- Základní myšlenka: jedna osoba uhradí celou útratu (v zájmu rychlosti odbavení), ostatním se automaticky vygeneruje QR kód, kterým následně uhradí svoji část útraty
|
- Základní myšlenka: jedna osoba uhradí celou útratu (v zájmu rychlosti odbavení), ostatním se automaticky vygeneruje QR kód, kterým následně uhradí svoji část útraty
|
||||||
- Obecně to bude problém např. pokud si někdo objedná něco navíc (pití apod.)
|
- Obecně to bude problém např. pokud si někdo objedná něco navíc (pití apod.)
|
||||||
- [ ] Tlačítko "Uhradit" u každého řádku podniku - platí ten, kdo kliknul
|
- [X] Tlačítko "Uhradit" u každého řádku podniku - platí ten, kdo kliknul
|
||||||
- [ ] Zobrazeno bude pouze, pokud má daný uživatel nastaveno číslo účtu
|
- [X] Zobrazeno bude pouze, pokud má daný uživatel nastaveno číslo účtu
|
||||||
- [ ] Dialog pro zadání spropitného, které se následně rozpočte rovnoměrně všem strávníkům
|
- [X] Dialog pro zadání spropitného, které se následně rozpočte rovnoměrně všem strávníkům
|
||||||
- [ ] Generování a zobrazení QR kódů ostatním strávníkům
|
- [X] Generování a zobrazení QR kódů ostatním strávníkům
|
||||||
- [ ] Umožnit u každého strávníka připočíst vlastní částku (např. za pití)
|
- [X] Umožnit u každého strávníka připočíst vlastní částku (např. za pití)
|
||||||
- [ ] Umožnit (např. zaškrtávátky) vybrat, za koho bude zaplaceno (pokud někdo bude platit zvlášť)
|
- [X] Umožnit (např. zaškrtávátky) vybrat, za koho bude zaplaceno (pokud někdo bude platit zvlášť)
|
||||||
- [ ] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)
|
- [X] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)
|
||||||
- [ ] Umožnit zadat URL/tokeny uživatelem
|
- [X] Umožnit zadat URL/tokeny uživatelem — ntfy, Discord, MS Teams (Gotify jen globálně na serveru)
|
||||||
- [ ] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat
|
- [X] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat
|
||||||
- [ ] Zavést notifikace typu "Jdeme na oběd"
|
- [X] Zavést notifikace typu "Jdeme na oběd"
|
||||||
- [ ] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu
|
- [X] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu
|
||||||
- [ ] Vylepšit parsery restaurací
|
- [X] Vylepšit parsery restaurací
|
||||||
- [ ] Sladovnická
|
- [X] Sladovnická
|
||||||
- [ ] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru
|
- [X] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru — parser mapuje dny přímo, TODO v parseru už není
|
||||||
- [ ] U Motlíků
|
- [X] U Motlíků — odpadá, podnik už v aplikaci není
|
||||||
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.')
|
- [X] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.')
|
||||||
- [ ] Jídelní lístek se stahuje jednou každý den, teoreticky by stačilo jednou týdně (za předpokladu, že se během týdne nemění)
|
- [X] Jídelní lístek se stahuje jednou každý den, teoreticky by stačilo jednou týdně (za předpokladu, že se během týdne nemění)
|
||||||
- [ ] TechTower
|
- [X] TechTower
|
||||||
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (typicky 'Obědy 12. 6. - 16. 6. 2023 (každý den vždy i obědový bufet)')
|
- [X] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (typicky 'Obědy 12. 6. - 16. 6. 2023 (každý den vždy i obědový bufet)') — finalizeTechTower, StaleWeekError
|
||||||
- [ ] Jídelní lístek se stahuje v rámci prvního požadavku daný den, ale často se jídelní lístek na stránkách aktualizuje až v průběhu pondělního dopoledne a ten zobrazený je proto neaktuální
|
- [X] Jídelní lístek se stahuje v rámci prvního požadavku daný den, ale často se jídelní lístek na stránkách aktualizuje až v průběhu pondělního dopoledne a ten zobrazený je proto neaktuální — zastaralé menu se po TTL stahuje znovu
|
||||||
- Stránka neposílá hlavičku o času poslední modifikace, takže o to se nelze opřít
|
- Stránka neposílá hlavičku o času poslední modifikace, takže o to se nelze opřít
|
||||||
- Nevím aktuálně jak řešit jinak, než častějším scrapováním celé stránky
|
- Nevím aktuálně jak řešit jinak, než častějším scrapováním celé stránky
|
||||||
- [X] Někdy jsou v názvech jídel přebytečné mezery kolem čárek ( , )
|
- [X] Někdy jsou v názvech jídel přebytečné mezery kolem čárek ( , )
|
||||||
- [ ] Nasazení nové verze v Docker smaže veškerá data (protože data.json není vystrčený ven z kontejneru)
|
- [X] Nasazení nové verze v Docker smaže veškerá data (protože data.json není vystrčený ven z kontejneru) — data jsou v Redis s volume /data
|
||||||
- [ ] Zavést složku /data
|
- [X] Zavést složku /data
|
||||||
- [ ] Mazat z databáze data z minulosti, aktuálně je to k ničemu
|
- [ ] Mazat z databáze data z minulosti, aktuálně je to k ničemu
|
||||||
- [ ] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh)
|
- [X] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh) — run_dev.ps1
|
||||||
- [ ] Implementovat Pizza day
|
- [ ] Implementovat Pizza day
|
||||||
- [ ] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day
|
- [X] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day
|
||||||
- [ ] Pizzy se samy budou při naklikání přidávat do košíku
|
- [ ] Pizzy se samy budou při naklikání přidávat do košíku
|
||||||
- [ ] Nutno nejprve vyřešit předávání PHPSESSIONID cookie na pizzachefie.cz pomocí fetch()
|
- [ ] Nutno nejprve vyřešit předávání PHPSESSIONID cookie na pizzachefie.cz pomocí fetch()
|
||||||
- [ ] Ceny krabic za pizzu jsou napevno v kódu - problém, pokud se někdy změní
|
- [ ] Ceny krabic za pizzu jsou napevno v kódu - problém, pokud se někdy změní
|
||||||
|
|||||||
@@ -9,7 +9,33 @@
|
|||||||
<meta name="description" content="Moderní webová aplikace pro lepší správu obědových preferencí" />
|
<meta name="description" content="Moderní webová aplikace pro lepší správu obědových preferencí" />
|
||||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=M+PLUS+Rounded+1c:wght@500;800&family=Nunito:wght@600;800;900&family=Fredoka:wght@500;600;700&family=Shippori+Mincho:wght@600;700;800&family=Zen+Maru+Gothic:wght@500;700;900&family=Yusei+Magic&family=Dela+Gothic+One&display=swap" rel="stylesheet" />
|
||||||
<title>Luncher</title>
|
<title>Luncher</title>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem('theme_preference');
|
||||||
|
var theme;
|
||||||
|
if (saved === 'dark') {
|
||||||
|
theme = 'dark';
|
||||||
|
} else if (saved === 'light') {
|
||||||
|
theme = 'light';
|
||||||
|
} else {
|
||||||
|
// 'system' nebo neuloženo - použij systémové nastavení
|
||||||
|
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||||
|
var style = localStorage.getItem('style_theme');
|
||||||
|
var styles = ['umamusume', 'mlp', 'hellgirl', 'kon', 'bocchi'];
|
||||||
|
document.documentElement.setAttribute('data-luncher-theme', styles.indexOf(style) >= 0 ? style : 'simple');
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback pokud localStorage není dostupný
|
||||||
|
document.documentElement.setAttribute('data-bs-theme', 'light');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -6,33 +6,39 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"homepage": ".",
|
"homepage": ".",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.4.0",
|
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
"@fortawesome/react-fontawesome": "^3.1.0",
|
||||||
"@types/jest": "^29.5.12",
|
"@sentry/react": "^10.65.0",
|
||||||
"@types/node": "^20.11.20",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react": "^19.2.2",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@types/react-dom": "^19.2.2",
|
||||||
"bootstrap": "^5.2.3",
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
"react": "^19.0.0",
|
"bootstrap": "^5.3.8",
|
||||||
"react-bootstrap": "^2.7.2",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-bootstrap": "^2.10.10",
|
||||||
"react-jwt": "^1.2.0",
|
"react-datepicker": "^9.1.0",
|
||||||
"react-modal": "^3.16.1",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-jwt": "^1.3.0",
|
||||||
|
"react-modal": "^3.16.3",
|
||||||
|
"react-router": "^7.9.5",
|
||||||
|
"react-router-dom": "^7.9.5",
|
||||||
"react-select-search": "^4.1.6",
|
"react-select-search": "^4.1.6",
|
||||||
"react-snowfall": "^2.2.0",
|
"react-snow-overlay": "^1.0.14",
|
||||||
"react-toastify": "^10.0.4",
|
"react-snowfall": "^2.3.0",
|
||||||
"sass": "^1.80.6",
|
"react-toastify": "^11.0.5",
|
||||||
|
"recharts": "^3.4.1",
|
||||||
|
"sass": "^1.93.3",
|
||||||
"socket.io-client": "^4.6.1",
|
"socket.io-client": "^4.6.1",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^6.0.3",
|
"vite": "^7.2.2",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "yarn vite",
|
"start": "yarn vite",
|
||||||
"build": "yarn vite build"
|
"build": "tsc --noEmit && yarn vite build"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
@@ -53,6 +59,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||||
"prettier": "^3.2.5"
|
"prettier": "^3.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<!-- Netopýr (čelem doprava; JS natáčí dle směru letu) -->
|
||||||
|
<g fill="#2B2733" stroke="#15121A" stroke-width="1.5" stroke-linejoin="round">
|
||||||
|
<!-- křídla -->
|
||||||
|
<path d="M50,52 C34,30 18,30 6,40 C16,42 18,52 14,62 C24,54 34,52 44,58 Z"/>
|
||||||
|
<path d="M50,52 C66,30 82,30 94,40 C84,42 82,52 86,62 C76,54 66,52 56,58 Z"/>
|
||||||
|
<!-- tělo -->
|
||||||
|
<ellipse cx="50" cy="56" rx="9" ry="13"/>
|
||||||
|
<!-- uši -->
|
||||||
|
<path d="M44,44 L40,32 L48,42 Z"/>
|
||||||
|
<path d="M56,44 L60,32 L52,42 Z"/>
|
||||||
|
</g>
|
||||||
|
<!-- oči -->
|
||||||
|
<g fill="#F5C518">
|
||||||
|
<circle cx="46" cy="50" r="1.8"/>
|
||||||
|
<circle cx="54" cy="50" r="1.8"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 716 B |
@@ -0,0 +1,26 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<!-- Vosa letící doprava (JS překlápí podle směru) -->
|
||||||
|
<!-- křídla -->
|
||||||
|
<g fill="#DDEEFF" stroke="#9BB6CC" stroke-width="1" opacity="0.85">
|
||||||
|
<ellipse cx="46" cy="30" rx="14" ry="8" transform="rotate(-18 46 30)"/>
|
||||||
|
<ellipse cx="58" cy="32" rx="11" ry="6" transform="rotate(-8 58 32)"/>
|
||||||
|
</g>
|
||||||
|
<!-- žihadlo -->
|
||||||
|
<path fill="#3A2E00" d="M18,50 L6,50 L18,46 Z"/>
|
||||||
|
<!-- tělo -->
|
||||||
|
<g stroke="#3A2E00" stroke-width="1.4" stroke-linejoin="round">
|
||||||
|
<ellipse cx="46" cy="50" rx="30" ry="16" fill="#F5C518"/>
|
||||||
|
<!-- pruhy -->
|
||||||
|
<path fill="#2B2200" stroke="none" d="M34,36 q6,14 0,28 q-8,-2 -10,-14 q2,-12 10,-14 z"/>
|
||||||
|
<path fill="#2B2200" stroke="none" d="M50,35 q6,15 0,30 q-8,-3 -8,-15 q0,-12 8,-15 z"/>
|
||||||
|
<!-- hlava -->
|
||||||
|
<circle cx="72" cy="50" r="10" fill="#2B2200"/>
|
||||||
|
</g>
|
||||||
|
<!-- oko a tykadla -->
|
||||||
|
<circle fill="#FFFFFF" cx="75" cy="47" r="2.4"/>
|
||||||
|
<circle fill="#20313F" cx="75.6" cy="47" r="1.2"/>
|
||||||
|
<g stroke="#2B2200" stroke-width="1.2" fill="none" stroke-linecap="round">
|
||||||
|
<path d="M78,42 C84,36 88,36 90,38"/>
|
||||||
|
<path d="M80,44 C86,40 90,41 92,43"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<!-- Pták letící doprava (JS překlápí podle směru) -->
|
||||||
|
<g stroke="#3A4A5A" stroke-width="1.5" stroke-linejoin="round">
|
||||||
|
<!-- ocas -->
|
||||||
|
<path fill="#5C6B7A" d="M18,52 L4,44 L6,56 L2,64 L18,60 Z"/>
|
||||||
|
<!-- tělo -->
|
||||||
|
<path fill="#6E7F90" d="M16,58 C24,44 44,40 64,46 C74,49 82,54 84,56 C78,62 66,66 52,66 C36,66 22,64 16,58 Z"/>
|
||||||
|
<!-- křídlo (nahoru) -->
|
||||||
|
<path fill="#55636F" d="M40,52 C46,30 60,24 70,26 C64,38 58,48 54,56 C50,58 44,57 40,52 Z"/>
|
||||||
|
<!-- hlava -->
|
||||||
|
<circle fill="#6E7F90" cx="78" cy="50" r="12"/>
|
||||||
|
</g>
|
||||||
|
<!-- zobák -->
|
||||||
|
<path fill="#F6A21E" stroke="#B9740A" stroke-width="1" stroke-linejoin="round" d="M88,48 L98,50 L88,54 Z"/>
|
||||||
|
<!-- oko -->
|
||||||
|
<circle fill="#20313F" cx="82" cy="47" r="2.2"/>
|
||||||
|
<circle fill="#FFFFFF" cx="82.8" cy="46.3" r="0.7"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 898 B |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<g stroke="#1B3F73" stroke-width="1.5" stroke-linejoin="round">
|
||||||
|
<path fill="#4A8FE7" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
|
||||||
|
<path fill="#4A8FE7" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
|
||||||
|
<path fill="#4A8FE7" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
|
||||||
|
<path fill="#4A8FE7" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#BBD7FF" stroke="none">
|
||||||
|
<circle cx="20" cy="30" r="4"/>
|
||||||
|
<circle cx="80" cy="30" r="4"/>
|
||||||
|
<circle cx="30" cy="70" r="3"/>
|
||||||
|
<circle cx="70" cy="70" r="3"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#17233B" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||||
|
<path d="M49,28 C44,18 40,14 36,11"/>
|
||||||
|
<path d="M51,28 C56,18 60,14 64,11"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#17233B" stroke="none">
|
||||||
|
<circle cx="36" cy="11" r="1.8"/>
|
||||||
|
<circle cx="64" cy="11" r="1.8"/>
|
||||||
|
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
|
||||||
|
<circle cx="50" cy="24" r="4.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,66 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="goldGlow" cx="0.5" cy="0.5" r="0.55">
|
||||||
|
<stop offset="0%" stop-color="#FFF6C8" stop-opacity="0.9"/>
|
||||||
|
<stop offset="55%" stop-color="#FFD24A" stop-opacity="0.35"/>
|
||||||
|
<stop offset="100%" stop-color="#FFD24A" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="goldWing" x1="0" y1="0" x2="0.7" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#FFFBE6"/>
|
||||||
|
<stop offset="35%" stop-color="#FFDE6A"/>
|
||||||
|
<stop offset="70%" stop-color="#F5B617"/>
|
||||||
|
<stop offset="100%" stop-color="#C97B00"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="goldSheen" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.9"/>
|
||||||
|
<stop offset="100%" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Zářivá aura -->
|
||||||
|
<circle cx="50" cy="48" r="48" fill="url(#goldGlow)"/>
|
||||||
|
|
||||||
|
<!-- Křídla -->
|
||||||
|
<g stroke="#8A5A00" stroke-width="1.6" stroke-linejoin="round">
|
||||||
|
<path fill="url(#goldWing)" d="M48,32 C30,8 6,12 6,34 C6,52 30,54 48,50 Z"/>
|
||||||
|
<path fill="url(#goldWing)" d="M52,32 C70,8 94,12 94,34 C94,52 70,54 52,50 Z"/>
|
||||||
|
<path fill="url(#goldWing)" d="M47,50 C33,54 14,58 16,76 C18,89 41,86 47,66 Z"/>
|
||||||
|
<path fill="url(#goldWing)" d="M53,50 C67,54 86,58 84,76 C82,89 59,86 53,66 Z"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Drahokamové body na křídlech -->
|
||||||
|
<g fill="#FFF8D8" stroke="#E8B23A" stroke-width="0.6">
|
||||||
|
<circle cx="20" cy="30" r="4.5"/>
|
||||||
|
<circle cx="80" cy="30" r="4.5"/>
|
||||||
|
<circle cx="29" cy="72" r="3.5"/>
|
||||||
|
<circle cx="71" cy="72" r="3.5"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#B8860B">
|
||||||
|
<circle cx="20" cy="30" r="1.6"/>
|
||||||
|
<circle cx="80" cy="30" r="1.6"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Lesklý přeliv přes horní křídla -->
|
||||||
|
<path fill="url(#goldSheen)" opacity="0.55" d="M48,33 C34,15 16,17 12,31 C24,26 40,27 48,40 Z"/>
|
||||||
|
|
||||||
|
<!-- Tykadla -->
|
||||||
|
<g stroke="#6A4A00" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||||
|
<path d="M49,28 C44,17 40,13 36,10"/>
|
||||||
|
<path d="M51,28 C56,17 60,13 64,10"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Tělo -->
|
||||||
|
<g fill="#5A3E00" stroke="none">
|
||||||
|
<circle cx="36" cy="10" r="2"/>
|
||||||
|
<circle cx="64" cy="10" r="2"/>
|
||||||
|
<rect x="47" y="24" width="6" height="52" rx="3"/>
|
||||||
|
<circle cx="50" cy="24" r="5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Jiskry -->
|
||||||
|
<g fill="#FFFFFF">
|
||||||
|
<path d="M50 2 l1.4 3.4 l3.4 1.4 l-3.4 1.4 l-1.4 3.4 l-1.4 -3.4 l-3.4 -1.4 l3.4 -1.4 z"/>
|
||||||
|
<path d="M90 20 l1 2.6 l2.6 1 l-2.6 1 l-1 2.6 l-1 -2.6 l-2.6 -1 l2.6 -1 z" opacity="0.85"/>
|
||||||
|
<path d="M12 66 l0.9 2.3 l2.3 0.9 l-2.3 0.9 l-0.9 2.3 l-0.9 -2.3 l-2.3 -0.9 l2.3 -0.9 z" opacity="0.8"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="mothWing" x1="0" y1="0" x2="0.6" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#5A5560"/>
|
||||||
|
<stop offset="60%" stop-color="#3A3540"/>
|
||||||
|
<stop offset="100%" stop-color="#211E26"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g stroke="#15121A" stroke-width="1.5" stroke-linejoin="round">
|
||||||
|
<path fill="url(#mothWing)" d="M48,34 C32,16 10,20 10,36 C10,50 30,50 48,48 Z"/>
|
||||||
|
<path fill="url(#mothWing)" d="M52,34 C68,16 90,20 90,36 C90,50 70,50 52,48 Z"/>
|
||||||
|
<path fill="url(#mothWing)" d="M47,49 C35,52 20,56 22,70 C24,80 42,78 47,62 Z"/>
|
||||||
|
<path fill="url(#mothWing)" d="M53,49 C65,52 80,56 78,70 C76,80 58,78 53,62 Z"/>
|
||||||
|
</g>
|
||||||
|
<!-- zlověstné oční skvrny -->
|
||||||
|
<g fill="#C9302C" stroke="#3a0d0b" stroke-width="1">
|
||||||
|
<circle cx="24" cy="34" r="4"/>
|
||||||
|
<circle cx="76" cy="34" r="4"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#1a0605">
|
||||||
|
<circle cx="24" cy="34" r="1.6"/>
|
||||||
|
<circle cx="76" cy="34" r="1.6"/>
|
||||||
|
</g>
|
||||||
|
<!-- huňatá tykadla -->
|
||||||
|
<g stroke="#15121A" stroke-width="1.8" fill="none" stroke-linecap="round">
|
||||||
|
<path d="M49,30 C44,20 39,16 34,14"/>
|
||||||
|
<path d="M51,30 C56,20 61,16 66,14"/>
|
||||||
|
</g>
|
||||||
|
<!-- tělo -->
|
||||||
|
<g fill="#15121A" stroke="none">
|
||||||
|
<rect x="47" y="26" width="6" height="46" rx="3"/>
|
||||||
|
<circle cx="50" cy="26" r="5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,79 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
width="100"
|
||||||
|
height="100">
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="netFillGold" x1="0" y1="0" x2="0.4" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#FFF3C4" stop-opacity="0.28"/>
|
||||||
|
<stop offset="100%" stop-color="#F0C24A" stop-opacity="0.12"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<clipPath id="netBagClipGold">
|
||||||
|
<path d="
|
||||||
|
M 36 8
|
||||||
|
C 21 8, 9 20, 9 35
|
||||||
|
C 9 51, 13 70, 19 84
|
||||||
|
C 22 92, 27 94, 31 87
|
||||||
|
C 40 72, 54 51, 63 35
|
||||||
|
C 63 20, 51 8, 36 8
|
||||||
|
Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Síťový vak za obručí -->
|
||||||
|
<path d="
|
||||||
|
M 36 8
|
||||||
|
C 21 8, 9 20, 9 35
|
||||||
|
C 9 51, 13 70, 19 84
|
||||||
|
C 22 92, 27 94, 31 87
|
||||||
|
C 40 72, 54 51, 63 35
|
||||||
|
C 63 20, 51 8, 36 8
|
||||||
|
Z"
|
||||||
|
fill="url(#netFillGold)"
|
||||||
|
stroke="#E8B23A"
|
||||||
|
stroke-width="1"
|
||||||
|
opacity="0.85"/>
|
||||||
|
|
||||||
|
<!-- Výplet síťky -->
|
||||||
|
<g clip-path="url(#netBagClipGold)"
|
||||||
|
fill="none"
|
||||||
|
stroke="#E8B23A"
|
||||||
|
stroke-width="0.8"
|
||||||
|
opacity="0.7"
|
||||||
|
stroke-linecap="round">
|
||||||
|
<path d="M 16 17 C 15 42, 19 69, 25 90"/>
|
||||||
|
<path d="M 25 10 C 23 39, 24 69, 25 90"/>
|
||||||
|
<path d="M 36 8 C 34 39, 30 69, 25 90"/>
|
||||||
|
<path d="M 47 10 C 44 40, 36 70, 25 90"/>
|
||||||
|
<path d="M 56 18 C 51 44, 41 72, 25 90"/>
|
||||||
|
<path d="M 62 30 C 55 51, 43 75, 25 90"/>
|
||||||
|
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
|
||||||
|
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
|
||||||
|
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
|
||||||
|
<path d="M 10 28 C 25 34, 47 34, 62 29"/>
|
||||||
|
<path d="M 9 40 C 25 46, 45 47, 59 42"/>
|
||||||
|
<path d="M 11 52 C 24 58, 41 59, 53 54"/>
|
||||||
|
<path d="M 13 64 C 24 69, 36 70, 45 66"/>
|
||||||
|
<path d="M 16 75 C 24 79, 31 80, 38 77"/>
|
||||||
|
<path d="M 20 84 C 24 87, 28 87, 32 84"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Rukojeť -->
|
||||||
|
<line x1="57" y1="57" x2="93" y2="93" stroke="#8B5A2B" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="57" y1="57" x2="93" y2="93" stroke="#A9713A" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Kovové napojení rukojeti -->
|
||||||
|
<line x1="56" y1="55" x2="61" y2="61" stroke="#B8860B" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="56" y1="55" x2="61" y2="61" stroke="#FFE79A" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Zlatá obruč -->
|
||||||
|
<circle cx="36" cy="35" r="27" fill="none" stroke="#C8891A" stroke-width="4.5"/>
|
||||||
|
<circle cx="36" cy="35" r="27" fill="none" stroke="#FFD24A" stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Třpytky -->
|
||||||
|
<g fill="#FFF7D6" stroke="none">
|
||||||
|
<path d="M 33 6 l 1.2 3 l 3 1.2 l -3 1.2 l -1.2 3 l -1.2 -3 l -3 -1.2 l 3 -1.2 z"/>
|
||||||
|
<path d="M 60 40 l 1 2.4 l 2.4 1 l -2.4 1 l -1 2.4 l -1 -2.4 l -2.4 -1 l 2.4 -1 z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,74 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
width="100"
|
||||||
|
height="100">
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="netFillTorn" x1="0" y1="0" x2="0.4" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#E4EDF5" stop-opacity="0.14"/>
|
||||||
|
<stop offset="100%" stop-color="#B9C4CF" stop-opacity="0.06"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Vak s vyříznutou dírou uprostřed -->
|
||||||
|
<clipPath id="netBagClipTorn">
|
||||||
|
<path d="
|
||||||
|
M 36 8 C 21 8, 9 20, 9 35 C 9 51, 13 70, 19 84
|
||||||
|
C 22 92, 27 94, 31 87 C 40 72, 54 51, 63 35 C 63 20, 51 8, 36 8 Z
|
||||||
|
M 27 40 C 20 44, 22 55, 30 58 C 38 61, 46 54, 43 46 C 41 40, 33 37, 27 40 Z"
|
||||||
|
clip-rule="evenodd"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Síťový vak (s dírou) -->
|
||||||
|
<path d="
|
||||||
|
M 36 8 C 21 8, 9 20, 9 35 C 9 51, 13 70, 19 84
|
||||||
|
C 22 92, 27 94, 31 87 C 40 72, 54 51, 63 35 C 63 20, 51 8, 36 8 Z
|
||||||
|
M 27 40 C 20 44, 22 55, 30 58 C 38 61, 46 54, 43 46 C 41 40, 33 37, 27 40 Z"
|
||||||
|
fill="url(#netFillTorn)"
|
||||||
|
stroke="#AEBBC8"
|
||||||
|
stroke-width="1"
|
||||||
|
opacity="0.7"
|
||||||
|
fill-rule="evenodd"/>
|
||||||
|
|
||||||
|
<!-- Výplet síťky (oříznutý dírou) -->
|
||||||
|
<g clip-path="url(#netBagClipTorn)"
|
||||||
|
fill="none"
|
||||||
|
stroke="#AEBBC8"
|
||||||
|
stroke-width="0.8"
|
||||||
|
opacity="0.55"
|
||||||
|
stroke-linecap="round">
|
||||||
|
<path d="M 16 17 C 15 42, 19 69, 25 90"/>
|
||||||
|
<path d="M 25 10 C 23 39, 24 69, 25 90"/>
|
||||||
|
<path d="M 36 8 C 34 39, 30 69, 25 90"/>
|
||||||
|
<path d="M 47 10 C 44 40, 36 70, 25 90"/>
|
||||||
|
<path d="M 56 18 C 51 44, 41 72, 25 90"/>
|
||||||
|
<path d="M 62 30 C 55 51, 43 75, 25 90"/>
|
||||||
|
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
|
||||||
|
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
|
||||||
|
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
|
||||||
|
<path d="M 13 64 C 24 69, 36 70, 45 66"/>
|
||||||
|
<path d="M 16 75 C 24 79, 31 80, 38 77"/>
|
||||||
|
<path d="M 20 84 C 24 87, 28 87, 32 84"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Roztřepená vlákna kolem díry -->
|
||||||
|
<g stroke="#9AA7B4" stroke-width="0.8" fill="none" stroke-linecap="round" opacity="0.8">
|
||||||
|
<path d="M 27 40 l -3 -2"/>
|
||||||
|
<path d="M 43 46 l 3 -1"/>
|
||||||
|
<path d="M 30 58 l -1 3"/>
|
||||||
|
<path d="M 38 52 l 3 2"/>
|
||||||
|
<path d="M 24 49 l -3 1"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Rukojeť -->
|
||||||
|
<line x1="57" y1="57" x2="93" y2="93" stroke="#8B5A2B" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="57" y1="57" x2="93" y2="93" stroke="#A9713A" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Kovové napojení rukojeti -->
|
||||||
|
<line x1="56" y1="55" x2="61" y2="61" stroke="#71808E" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<line x1="56" y1="55" x2="61" y2="61" stroke="#B8C4CF" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Obruč -->
|
||||||
|
<circle cx="36" cy="35" r="27" fill="none" stroke="#7A8896" stroke-width="4.5"/>
|
||||||
|
<circle cx="36" cy="35" r="27" fill="none" stroke="#AEBBC8" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,106 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
width="100"
|
||||||
|
height="100">
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="netFill" x1="0" y1="0" x2="0.4" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#E4EDF5" stop-opacity="0.18"/>
|
||||||
|
<stop offset="100%" stop-color="#B9C4CF" stop-opacity="0.08"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Celý tvar síťového vaku -->
|
||||||
|
<clipPath id="netBagClip">
|
||||||
|
<path d="
|
||||||
|
M 36 8
|
||||||
|
C 21 8, 9 20, 9 35
|
||||||
|
C 9 51, 13 70, 19 84
|
||||||
|
C 22 92, 27 94, 31 87
|
||||||
|
C 40 72, 54 51, 63 35
|
||||||
|
C 63 20, 51 8, 36 8
|
||||||
|
Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Síťový vak za obručí -->
|
||||||
|
<path d="
|
||||||
|
M 36 8
|
||||||
|
C 21 8, 9 20, 9 35
|
||||||
|
C 9 51, 13 70, 19 84
|
||||||
|
C 22 92, 27 94, 31 87
|
||||||
|
C 40 72, 54 51, 63 35
|
||||||
|
C 63 20, 51 8, 36 8
|
||||||
|
Z"
|
||||||
|
fill="url(#netFill)"
|
||||||
|
stroke="#AEBBC8"
|
||||||
|
stroke-width="1"
|
||||||
|
opacity="0.7"/>
|
||||||
|
|
||||||
|
<!-- Výplet síťky -->
|
||||||
|
<g clip-path="url(#netBagClip)"
|
||||||
|
fill="none"
|
||||||
|
stroke="#AEBBC8"
|
||||||
|
stroke-width="0.8"
|
||||||
|
opacity="0.6"
|
||||||
|
stroke-linecap="round">
|
||||||
|
|
||||||
|
<!-- Vlákna začínající na horní a boční obruči -->
|
||||||
|
<path d="M 16 17 C 15 42, 19 69, 25 90"/>
|
||||||
|
<path d="M 25 10 C 23 39, 24 69, 25 90"/>
|
||||||
|
<path d="M 36 8 C 34 39, 30 69, 25 90"/>
|
||||||
|
<path d="M 47 10 C 44 40, 36 70, 25 90"/>
|
||||||
|
<path d="M 56 18 C 51 44, 41 72, 25 90"/>
|
||||||
|
<path d="M 62 30 C 55 51, 43 75, 25 90"/>
|
||||||
|
|
||||||
|
<!-- Vlákna od levého okraje obruče -->
|
||||||
|
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
|
||||||
|
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
|
||||||
|
|
||||||
|
<!-- Příčná oka -->
|
||||||
|
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
|
||||||
|
<path d="M 10 28 C 25 34, 47 34, 62 29"/>
|
||||||
|
<path d="M 9 40 C 25 46, 45 47, 59 42"/>
|
||||||
|
<path d="M 11 52 C 24 58, 41 59, 53 54"/>
|
||||||
|
<path d="M 13 64 C 24 69, 36 70, 45 66"/>
|
||||||
|
<path d="M 16 75 C 24 79, 31 80, 38 77"/>
|
||||||
|
<path d="M 20 84 C 24 87, 28 87, 32 84"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Rukojeť -->
|
||||||
|
<line x1="57" y1="57"
|
||||||
|
x2="93" y2="93"
|
||||||
|
stroke="#8B5A2B"
|
||||||
|
stroke-width="6"
|
||||||
|
stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<line x1="57" y1="57"
|
||||||
|
x2="93" y2="93"
|
||||||
|
stroke="#A9713A"
|
||||||
|
stroke-width="3"
|
||||||
|
stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Kovové napojení rukojeti -->
|
||||||
|
<line x1="56" y1="55"
|
||||||
|
x2="61" y2="61"
|
||||||
|
stroke="#71808E"
|
||||||
|
stroke-width="6"
|
||||||
|
stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<line x1="56" y1="55"
|
||||||
|
x2="61" y2="61"
|
||||||
|
stroke="#B8C4CF"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Hlavní obruč – nezměněná -->
|
||||||
|
<circle cx="36" cy="35" r="27"
|
||||||
|
fill="none"
|
||||||
|
stroke="#7A8896"
|
||||||
|
stroke-width="4.5"/>
|
||||||
|
|
||||||
|
<circle cx="36" cy="35" r="27"
|
||||||
|
fill="none"
|
||||||
|
stroke="#AEBBC8"
|
||||||
|
stroke-width="2"/>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<g stroke="#7A3B00" stroke-width="1.5" stroke-linejoin="round">
|
||||||
|
<path fill="#F59E1E" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
|
||||||
|
<path fill="#F59E1E" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
|
||||||
|
<path fill="#F59E1E" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
|
||||||
|
<path fill="#F59E1E" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#FFE0A3" stroke="none">
|
||||||
|
<circle cx="20" cy="30" r="4"/>
|
||||||
|
<circle cx="80" cy="30" r="4"/>
|
||||||
|
<circle cx="30" cy="70" r="3"/>
|
||||||
|
<circle cx="70" cy="70" r="3"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#2B1A0B" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||||
|
<path d="M49,28 C44,18 40,14 36,11"/>
|
||||||
|
<path d="M51,28 C56,18 60,14 64,11"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#2B1A0B" stroke="none">
|
||||||
|
<circle cx="36" cy="11" r="1.8"/>
|
||||||
|
<circle cx="64" cy="11" r="1.8"/>
|
||||||
|
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
|
||||||
|
<circle cx="50" cy="24" r="4.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<g stroke="#8A2255" stroke-width="1.5" stroke-linejoin="round">
|
||||||
|
<path fill="#F06B9B" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
|
||||||
|
<path fill="#F06B9B" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
|
||||||
|
<path fill="#F06B9B" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
|
||||||
|
<path fill="#F06B9B" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#FFD1E3" stroke="none">
|
||||||
|
<circle cx="20" cy="30" r="4"/>
|
||||||
|
<circle cx="80" cy="30" r="4"/>
|
||||||
|
<circle cx="30" cy="70" r="3"/>
|
||||||
|
<circle cx="70" cy="70" r="3"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#3B0E24" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||||
|
<path d="M49,28 C44,18 40,14 36,11"/>
|
||||||
|
<path d="M51,28 C56,18 60,14 64,11"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#3B0E24" stroke="none">
|
||||||
|
<circle cx="36" cy="11" r="1.8"/>
|
||||||
|
<circle cx="64" cy="11" r="1.8"/>
|
||||||
|
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
|
||||||
|
<circle cx="50" cy="24" r="4.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<g stroke="#8A6D00" stroke-width="1.5" stroke-linejoin="round">
|
||||||
|
<path fill="#FFD23F" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
|
||||||
|
<path fill="#FFD23F" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
|
||||||
|
<path fill="#FFD23F" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
|
||||||
|
<path fill="#FFD23F" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#FFF3C4" stroke="none">
|
||||||
|
<circle cx="20" cy="30" r="4"/>
|
||||||
|
<circle cx="80" cy="30" r="4"/>
|
||||||
|
<circle cx="30" cy="70" r="3"/>
|
||||||
|
<circle cx="70" cy="70" r="3"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#3A2E00" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||||
|
<path d="M49,28 C44,18 40,14 36,11"/>
|
||||||
|
<path d="M51,28 C56,18 60,14 64,11"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#3A2E00" stroke="none">
|
||||||
|
<circle cx="36" cy="11" r="1.8"/>
|
||||||
|
<circle cx="64" cy="11" r="1.8"/>
|
||||||
|
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
|
||||||
|
<circle cx="50" cy="24" r="4.5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="coinFace" cx="0.4" cy="0.35" r="0.75">
|
||||||
|
<stop offset="0%" stop-color="#FFE9A3"/>
|
||||||
|
<stop offset="60%" stop-color="#FFCB3D"/>
|
||||||
|
<stop offset="100%" stop-color="#E0A200"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="50" cy="50" r="42" fill="#C88A12"/>
|
||||||
|
<circle cx="50" cy="50" r="38" fill="url(#coinFace)" stroke="#F2D774" stroke-width="2"/>
|
||||||
|
<circle cx="50" cy="50" r="30" fill="none" stroke="#B8860B" stroke-width="2" opacity="0.6"/>
|
||||||
|
<!-- stylizovaný motýlek na minci -->
|
||||||
|
<g fill="#B8860B" opacity="0.85">
|
||||||
|
<path d="M50,42 C42,32 30,34 30,44 C30,52 42,52 50,50 Z"/>
|
||||||
|
<path d="M50,42 C58,32 70,34 70,44 C70,52 58,52 50,50 Z"/>
|
||||||
|
<path d="M49,50 C42,52 34,56 36,64 C38,70 48,66 49,58 Z"/>
|
||||||
|
<path d="M51,50 C58,52 66,56 64,64 C62,70 52,66 51,58 Z"/>
|
||||||
|
<rect x="48.5" y="40" width="3" height="26" rx="1.5"/>
|
||||||
|
</g>
|
||||||
|
<!-- lesk -->
|
||||||
|
<path d="M 30 30 C 38 24, 50 24, 58 28" fill="none" stroke="#FFF7DC" stroke-width="3" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
|
||||||
|
<path fill="#8B4513" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
|
||||||
|
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
|
||||||
|
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
|
||||||
|
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
|
||||||
|
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
|
||||||
|
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
|
||||||
|
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
|
||||||
|
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
|
||||||
|
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
|
||||||
|
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
|
||||||
|
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
|
||||||
|
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
|
||||||
|
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
|
||||||
|
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
|
||||||
|
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
|
||||||
|
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
|
||||||
|
<path fill="#228B22" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
|
||||||
|
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
|
||||||
|
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
|
||||||
|
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
|
||||||
|
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
|
||||||
|
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
|
||||||
|
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
|
||||||
|
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
|
||||||
|
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
|
||||||
|
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
|
||||||
|
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
|
||||||
|
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
|
||||||
|
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
|
||||||
|
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
|
||||||
|
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
|
||||||
|
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
|
||||||
|
<path fill="#D2691E" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
|
||||||
|
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
|
||||||
|
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
|
||||||
|
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
|
||||||
|
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
|
||||||
|
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
|
||||||
|
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
|
||||||
|
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
|
||||||
|
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
|
||||||
|
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
|
||||||
|
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
|
||||||
|
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
|
||||||
|
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
|
||||||
|
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
|
||||||
|
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
|
||||||
|
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
|
||||||
|
<path fill="#B22222" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
|
||||||
|
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
|
||||||
|
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
|
||||||
|
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
|
||||||
|
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
|
||||||
|
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
|
||||||
|
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
|
||||||
|
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
|
||||||
|
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
|
||||||
|
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
|
||||||
|
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
|
||||||
|
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
|
||||||
|
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
|
||||||
|
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
|
||||||
|
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
|
||||||
|
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
|
||||||
|
<path fill="#FFD700" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
|
||||||
|
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
|
||||||
|
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
|
||||||
|
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
|
||||||
|
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
|
||||||
|
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
|
||||||
|
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
|
||||||
|
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
|
||||||
|
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
|
||||||
|
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
|
||||||
|
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
|
||||||
|
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
|
||||||
|
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
|
||||||
|
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
|
||||||
|
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
|
||||||
|
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
|
||||||
|
<path fill="#471C37" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
|
||||||
|
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
|
||||||
|
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
|
||||||
|
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
|
||||||
|
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
|
||||||
|
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
|
||||||
|
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
|
||||||
|
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
|
||||||
|
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
|
||||||
|
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
|
||||||
|
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
|
||||||
|
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
|
||||||
|
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
|
||||||
|
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
|
||||||
|
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
|
||||||
|
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<!-- vlákno -->
|
||||||
|
<line x1="50" y1="0" x2="50" y2="30" stroke="#8A8A8A" stroke-width="1.5"/>
|
||||||
|
<g stroke="#15121A" stroke-width="3" fill="none" stroke-linecap="round">
|
||||||
|
<!-- nohy -->
|
||||||
|
<path d="M40,52 C24,44 18,52 12,46"/>
|
||||||
|
<path d="M40,58 C24,58 16,66 10,64"/>
|
||||||
|
<path d="M40,64 C26,70 20,80 12,82"/>
|
||||||
|
<path d="M40,70 C30,80 28,90 22,94"/>
|
||||||
|
<path d="M60,52 C76,44 82,52 88,46"/>
|
||||||
|
<path d="M60,58 C76,58 84,66 90,64"/>
|
||||||
|
<path d="M60,64 C74,70 80,80 88,82"/>
|
||||||
|
<path d="M60,70 C70,80 72,90 78,94"/>
|
||||||
|
</g>
|
||||||
|
<!-- tělo -->
|
||||||
|
<g stroke="#15121A" stroke-width="1.5">
|
||||||
|
<ellipse cx="50" cy="46" rx="11" ry="9" fill="#2B2733"/>
|
||||||
|
<ellipse cx="50" cy="64" rx="15" ry="16" fill="#211E26"/>
|
||||||
|
<!-- přesýpací hodiny (varovná značka) -->
|
||||||
|
<path d="M50,58 l5,6 l-5,6 l-5,-6 z" fill="#C9302C" stroke="none"/>
|
||||||
|
</g>
|
||||||
|
<!-- oči -->
|
||||||
|
<g fill="#F5C518">
|
||||||
|
<circle cx="46" cy="44" r="1.6"/>
|
||||||
|
<circle cx="54" cy="44" r="1.6"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
// Service Worker pro Web Push notifikace (připomínka výběru oběda, doručení objednávky)
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
const data = event.data?.json() ?? { title: 'Luncher', body: 'Ještě nemáte zvolený oběd!' };
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title, {
|
||||||
|
body: data.body,
|
||||||
|
icon: '/favicon.ico',
|
||||||
|
tag: data.tag ?? 'lunch-reminder',
|
||||||
|
data: { login: data.login, token: data.token, url: data.url },
|
||||||
|
// Token posílá jen připomínka oběda — ostatní notifikace tlačítko nemají.
|
||||||
|
actions: data.token
|
||||||
|
? [{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' }]
|
||||||
|
: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
if (event.action === 'neobedvam') {
|
||||||
|
const { login, token } = event.notification.data ?? {};
|
||||||
|
if (login && token) {
|
||||||
|
event.waitUntil(
|
||||||
|
fetch('/api/notifications/push/quickChoice', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ login, token }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = event.notification.data?.url ?? '/';
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||||
|
if ('navigate' in client) client.navigate(url).catch(() => {});
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self.clients.openWindow(url);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import { ProvideSettings, useSettings } from "./context/settings";
|
||||||
|
import FlyingButterflies, { BUTTERFLY_PRESETS } from "./FlyingButterflies";
|
||||||
|
import "./FlyingButterflies.scss";
|
||||||
|
import { ToastContainer } from "react-toastify";
|
||||||
|
import { SocketContext, socket } from "./context/socket";
|
||||||
|
import StatsPage from "./pages/StatsPage";
|
||||||
|
import OrderGroupsPage from "./pages/OrderGroupsPage";
|
||||||
|
import SuggestionsPage from "./pages/SuggestionsPage";
|
||||||
|
import App from "./App";
|
||||||
|
import HellGirlFlames from "./components/HellGirlFlames";
|
||||||
|
|
||||||
|
export const STATS_URL = '/stats';
|
||||||
|
export const OBJEDNANI_URL = '/objednani';
|
||||||
|
export const NAVRHY_URL = '/navrhy';
|
||||||
|
|
||||||
|
/** Motýlci se vykreslují jen pokud je uživatel nevypnul v nastavení. */
|
||||||
|
function Butterflies() {
|
||||||
|
const settings = useSettings();
|
||||||
|
if (settings?.showButterflies === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <FlyingButterflies numButterflies={BUTTERFLY_PRESETS.NORMAL} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Doplňkové efekty vizuálních stylů — společné pro všechny stránky (např. plamen při otevření dialogu). */
|
||||||
|
function StyleThemeEffects() {
|
||||||
|
const settings = useSettings();
|
||||||
|
return settings?.styleTheme === 'hellgirl' ? <HellGirlFlames /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppRoutes() {
|
||||||
|
return (
|
||||||
|
<ProvideSettings>
|
||||||
|
<StyleThemeEffects />
|
||||||
|
<Routes>
|
||||||
|
<Route path={STATS_URL} element={<StatsPage />} />
|
||||||
|
<Route path={NAVRHY_URL} element={<SuggestionsPage />} />
|
||||||
|
<Route path={OBJEDNANI_URL} element={
|
||||||
|
<SocketContext.Provider value={socket}>
|
||||||
|
<OrderGroupsPage />
|
||||||
|
<ToastContainer />
|
||||||
|
</SocketContext.Provider>
|
||||||
|
} />
|
||||||
|
<Route path="/" element={
|
||||||
|
<SocketContext.Provider value={socket}>
|
||||||
|
<>
|
||||||
|
<Butterflies />
|
||||||
|
<App />
|
||||||
|
</>
|
||||||
|
<ToastContainer />
|
||||||
|
</SocketContext.Provider>
|
||||||
|
} />
|
||||||
|
</Routes>
|
||||||
|
</ProvideSettings>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
.falling-leaves {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaf-scene {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
|
||||||
|
div {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 100%;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
backface-visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import React, { useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
// Různé barevné varianty listů
|
||||||
|
const LEAF_VARIANTS = [
|
||||||
|
'leaf.svg', // Původní tmavě hnědá
|
||||||
|
'leaf-orange.svg', // Oranžová
|
||||||
|
'leaf-yellow.svg', // Žlutá
|
||||||
|
'leaf-red.svg', // Červená
|
||||||
|
'leaf-brown.svg', // Světle hnědá
|
||||||
|
'leaf-green.svg', // Zelená
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface LeafData {
|
||||||
|
el: HTMLDivElement;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
rotation: {
|
||||||
|
axis: 'X' | 'Y' | 'Z';
|
||||||
|
value: number;
|
||||||
|
speed: number;
|
||||||
|
x: number;
|
||||||
|
};
|
||||||
|
xSpeedVariation: number;
|
||||||
|
ySpeed: number;
|
||||||
|
path: {
|
||||||
|
type: number;
|
||||||
|
start: number;
|
||||||
|
};
|
||||||
|
image: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindOptions {
|
||||||
|
magnitude: number;
|
||||||
|
maxSpeed: number;
|
||||||
|
duration: number;
|
||||||
|
start: number;
|
||||||
|
speed: (t: number, y: number) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeafSceneOptions {
|
||||||
|
numLeaves: number;
|
||||||
|
wind: WindOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FallingLeavesProps {
|
||||||
|
/** Počet padających listů (výchozí: 20) */
|
||||||
|
numLeaves?: number;
|
||||||
|
/** CSS třída pro kontejner (výchozí: 'falling-leaves') */
|
||||||
|
className?: string;
|
||||||
|
/** Barevné varianty listů k použití (výchozí: všechny) */
|
||||||
|
leafVariants?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class LeafScene {
|
||||||
|
private viewport: HTMLElement;
|
||||||
|
private world: HTMLDivElement;
|
||||||
|
private leaves: LeafData[] = [];
|
||||||
|
private options: LeafSceneOptions;
|
||||||
|
private width: number;
|
||||||
|
private height: number;
|
||||||
|
private timer: number = 0;
|
||||||
|
private animationId: number | null = null;
|
||||||
|
private leafVariants: readonly string[];
|
||||||
|
|
||||||
|
constructor(el: HTMLElement, numLeaves: number = 20, leafVariants: readonly string[] = LEAF_VARIANTS) {
|
||||||
|
this.viewport = el;
|
||||||
|
this.world = document.createElement('div');
|
||||||
|
this.leafVariants = leafVariants;
|
||||||
|
|
||||||
|
this.options = {
|
||||||
|
numLeaves,
|
||||||
|
wind: {
|
||||||
|
magnitude: 1.2,
|
||||||
|
maxSpeed: 12,
|
||||||
|
duration: 300,
|
||||||
|
start: 0,
|
||||||
|
speed: () => 0
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.width = this.viewport.offsetWidth;
|
||||||
|
this.height = this.viewport.offsetHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetLeaf = (leaf: LeafData): LeafData => {
|
||||||
|
// place leaf towards the top left
|
||||||
|
leaf.x = this.width * 2 - Math.random() * this.width * 1.75;
|
||||||
|
leaf.y = -10;
|
||||||
|
leaf.z = Math.random() * 200;
|
||||||
|
|
||||||
|
if (leaf.x > this.width) {
|
||||||
|
leaf.x = this.width + 10;
|
||||||
|
leaf.y = Math.random() * this.height / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// at the start, the leaf can be anywhere
|
||||||
|
if (this.timer === 0) {
|
||||||
|
leaf.y = Math.random() * this.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose axis of rotation.
|
||||||
|
// If axis is not X, chose a random static x-rotation for greater variability
|
||||||
|
leaf.rotation.speed = Math.random() * 10;
|
||||||
|
const randomAxis = Math.random();
|
||||||
|
|
||||||
|
if (randomAxis > 0.5) {
|
||||||
|
leaf.rotation.axis = 'X';
|
||||||
|
} else if (randomAxis > 0.25) {
|
||||||
|
leaf.rotation.axis = 'Y';
|
||||||
|
leaf.rotation.x = Math.random() * 180 + 90;
|
||||||
|
} else {
|
||||||
|
leaf.rotation.axis = 'Z';
|
||||||
|
leaf.rotation.x = Math.random() * 360 - 180;
|
||||||
|
// looks weird if the rotation is too fast around this axis
|
||||||
|
leaf.rotation.speed = Math.random() * 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// random speed
|
||||||
|
leaf.xSpeedVariation = Math.random() * 0.8 - 0.4;
|
||||||
|
leaf.ySpeed = Math.random() + 1.5;
|
||||||
|
|
||||||
|
// randomly select leaf color variant
|
||||||
|
const randomVariantIndex = Math.floor(Math.random() * this.leafVariants.length);
|
||||||
|
leaf.image = randomVariantIndex;
|
||||||
|
|
||||||
|
// apply the background image to the leaf element
|
||||||
|
const leafVariant = this.leafVariants[randomVariantIndex];
|
||||||
|
leaf.el.style.backgroundImage = `url(${leafVariant})`;
|
||||||
|
|
||||||
|
return leaf;
|
||||||
|
};
|
||||||
|
|
||||||
|
private updateLeaf = (leaf: LeafData): void => {
|
||||||
|
const leafWindSpeed = this.options.wind.speed(this.timer - this.options.wind.start, leaf.y);
|
||||||
|
|
||||||
|
const xSpeed = leafWindSpeed + leaf.xSpeedVariation;
|
||||||
|
leaf.x -= xSpeed;
|
||||||
|
leaf.y += leaf.ySpeed;
|
||||||
|
leaf.rotation.value += leaf.rotation.speed;
|
||||||
|
|
||||||
|
const transform = `translateX(${leaf.x}px) translateY(${leaf.y}px) translateZ(${leaf.z}px) rotate${leaf.rotation.axis}(${leaf.rotation.value}deg)${leaf.rotation.axis !== 'X' ? ` rotateX(${leaf.rotation.x}deg)` : ''
|
||||||
|
}`;
|
||||||
|
|
||||||
|
leaf.el.style.transform = transform;
|
||||||
|
|
||||||
|
// reset if out of view
|
||||||
|
if (leaf.x < -10 || leaf.y > this.height + 10) {
|
||||||
|
this.resetLeaf(leaf);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private updateWind = (): void => {
|
||||||
|
// wind follows a sine curve: asin(b*time + c) + a
|
||||||
|
// where a = wind magnitude as a function of leaf position, b = wind.duration, c = offset
|
||||||
|
// wind duration should be related to wind magnitude, e.g. higher windspeed means longer gust duration
|
||||||
|
|
||||||
|
if (this.timer === 0 || this.timer > (this.options.wind.start + this.options.wind.duration)) {
|
||||||
|
this.options.wind.magnitude = Math.random() * this.options.wind.maxSpeed;
|
||||||
|
this.options.wind.duration = this.options.wind.magnitude * 50 + (Math.random() * 20 - 10);
|
||||||
|
this.options.wind.start = this.timer;
|
||||||
|
|
||||||
|
const screenHeight = this.height;
|
||||||
|
|
||||||
|
this.options.wind.speed = function (t: number, y: number) {
|
||||||
|
// should go from full wind speed at the top, to 1/2 speed at the bottom, using leaf Y
|
||||||
|
const a = this.magnitude / 2 * (screenHeight - 2 * y / 3) / screenHeight;
|
||||||
|
return a * Math.sin(2 * Math.PI / this.duration * t + (3 * Math.PI / 2)) + a;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public init = (): void => {
|
||||||
|
// Clear existing leaves
|
||||||
|
this.leaves = [];
|
||||||
|
this.world.innerHTML = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < this.options.numLeaves; i++) {
|
||||||
|
const leaf: LeafData = {
|
||||||
|
el: document.createElement('div'),
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
z: 0,
|
||||||
|
rotation: {
|
||||||
|
axis: 'X',
|
||||||
|
value: 0,
|
||||||
|
speed: 0,
|
||||||
|
x: 0
|
||||||
|
},
|
||||||
|
xSpeedVariation: 0,
|
||||||
|
ySpeed: 0,
|
||||||
|
path: {
|
||||||
|
type: 1,
|
||||||
|
start: 0,
|
||||||
|
},
|
||||||
|
image: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
this.resetLeaf(leaf);
|
||||||
|
this.leaves.push(leaf);
|
||||||
|
this.world.appendChild(leaf.el);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.world.className = 'leaf-scene';
|
||||||
|
this.viewport.appendChild(this.world);
|
||||||
|
|
||||||
|
// set perspective
|
||||||
|
this.world.style.perspective = "400px";
|
||||||
|
|
||||||
|
// reset window height/width on resize
|
||||||
|
const handleResize = (): void => {
|
||||||
|
this.width = this.viewport.offsetWidth;
|
||||||
|
this.height = this.viewport.offsetHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
|
||||||
|
public render = (): void => {
|
||||||
|
this.updateWind();
|
||||||
|
|
||||||
|
for (let i = 0; i < this.leaves.length; i++) {
|
||||||
|
this.updateLeaf(this.leaves[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.timer++;
|
||||||
|
this.animationId = requestAnimationFrame(this.render);
|
||||||
|
};
|
||||||
|
|
||||||
|
public destroy = (): void => {
|
||||||
|
if (this.animationId) {
|
||||||
|
cancelAnimationFrame(this.animationId);
|
||||||
|
this.animationId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.world && this.world.parentNode) {
|
||||||
|
this.world.parentNode.removeChild(this.world);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener('resize', () => {
|
||||||
|
this.width = this.viewport.offsetWidth;
|
||||||
|
this.height = this.viewport.offsetHeight;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Komponenta pro zobrazení padajících listů na pozadí stránky
|
||||||
|
*
|
||||||
|
* @param numLeaves - Počet padajících listů (výchozí: 20)
|
||||||
|
* @param className - CSS třída pro kontejner (výchozí: 'falling-leaves')
|
||||||
|
* @param leafVariants - Barevné varianty listů k použití (výchozí: všechny)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Základní použití s výchozím počtem listů
|
||||||
|
* <FallingLeaves />
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Použití s vlastním počtem listů
|
||||||
|
* <FallingLeaves numLeaves={50} />
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Použití s vlastní CSS třídou a pouze podzimními barvami
|
||||||
|
* <FallingLeaves
|
||||||
|
* numLeaves={15}
|
||||||
|
* className="autumn-leaves"
|
||||||
|
* leafVariants={['leaf-orange.svg', 'leaf-red.svg', 'leaf-yellow.svg']}
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
const FallingLeaves: React.FC<FallingLeavesProps> = ({
|
||||||
|
numLeaves = 20,
|
||||||
|
className = 'falling-leaves',
|
||||||
|
leafVariants = LEAF_VARIANTS
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const leafSceneRef = useRef<LeafScene | null>(null);
|
||||||
|
|
||||||
|
const initializeLeafScene = useCallback(() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
leafSceneRef.current = new LeafScene(containerRef.current, numLeaves, leafVariants);
|
||||||
|
leafSceneRef.current.init();
|
||||||
|
leafSceneRef.current.render();
|
||||||
|
}
|
||||||
|
}, [numLeaves, leafVariants]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initializeLeafScene();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (leafSceneRef.current) {
|
||||||
|
leafSceneRef.current.destroy();
|
||||||
|
leafSceneRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [initializeLeafScene]);
|
||||||
|
|
||||||
|
return <div ref={containerRef} className={className} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Přednastavení pro různé účely
|
||||||
|
export const LEAF_PRESETS = {
|
||||||
|
LIGHT: 10, // Lehký podzimní efekt
|
||||||
|
NORMAL: 20, // Standardní množství
|
||||||
|
HEAVY: 40, // Silný podzimní vítr
|
||||||
|
BLIZZARD: 80 // Hustý pád listí
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Přednastavené barevné kombinace
|
||||||
|
export const LEAF_COLOR_THEMES = {
|
||||||
|
ALL: LEAF_VARIANTS, // Všechny barvy
|
||||||
|
AUTUMN: ['leaf-orange.svg', 'leaf-red.svg', 'leaf-yellow.svg', 'leaf-brown.svg'] as const, // Podzimní barvy
|
||||||
|
WARM: ['leaf-orange.svg', 'leaf-red.svg', 'leaf-brown.svg'] as const, // Teplé barvy
|
||||||
|
CLASSIC: ['leaf.svg', 'leaf-brown.svg'] as const, // Klasické hnědé odstíny
|
||||||
|
BRIGHT: ['leaf-yellow.svg', 'leaf-orange.svg'] as const, // Světlé barvy
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default FallingLeaves;
|
||||||
@@ -0,0 +1,749 @@
|
|||||||
|
.flying-butterflies {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
// V režimu hraní herní vrstva zachytává kliknutí, aby se nedalo omylem
|
||||||
|
// překliknout do objednávek. V režimu objednávání propouští vše dál (none).
|
||||||
|
&.playing {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Přepínač režimu hraní / objednávání
|
||||||
|
.butterfly-mode-toggle {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 7;
|
||||||
|
pointer-events: auto;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(33, 37, 41, 0.88);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
transition: background 0.15s ease, transform 0.08s ease;
|
||||||
|
|
||||||
|
&:hover { background: rgba(33, 37, 41, 1); }
|
||||||
|
&:active { transform: scale(0.96); }
|
||||||
|
|
||||||
|
&.playing {
|
||||||
|
background: #2f9e44;
|
||||||
|
&:hover { background: #2b8a3e; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vrstva herních prvků (síťka, škůdci, pavučina) – skrývá se v režimu objednávání
|
||||||
|
.butterfly-game-layer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-scene {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
// Vnější element – pozici a natočení nastavuje JS přes transform
|
||||||
|
> div {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vnitřní element se sprite motýla – máchá křídly (scaleX kolem svislé osy těla)
|
||||||
|
.butterfly-sprite {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: contain;
|
||||||
|
animation: butterfly-flap 0.45s ease-in-out infinite;
|
||||||
|
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15));
|
||||||
|
|
||||||
|
// Vzácný zlatý motýl – WOW: silná pulzující zlatá záře + duhový nádech
|
||||||
|
&.golden {
|
||||||
|
animation:
|
||||||
|
butterfly-flap 0.35s ease-in-out infinite,
|
||||||
|
butterfly-golden-glow 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-golden-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
filter:
|
||||||
|
saturate(1.15) hue-rotate(0deg)
|
||||||
|
drop-shadow(0 0 5px rgba(255, 215, 80, 0.85))
|
||||||
|
drop-shadow(0 0 10px rgba(255, 180, 40, 0.55))
|
||||||
|
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter:
|
||||||
|
saturate(1.4) hue-rotate(-16deg)
|
||||||
|
drop-shadow(0 0 13px rgba(255, 235, 130, 1))
|
||||||
|
drop-shadow(0 0 24px rgba(255, 190, 50, 0.95))
|
||||||
|
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-flap {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scaleX(0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Korunovaný motýl (anti-bot inspekce) – nepřehlédnutelná duhová záře
|
||||||
|
.butterfly-sprite.royal {
|
||||||
|
animation: butterfly-flap 0.35s ease-in-out infinite, butterfly-royal-glow 0.9s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-royal-glow {
|
||||||
|
0%, 100% {
|
||||||
|
filter: drop-shadow(0 0 8px rgba(255, 215, 80, 1)) drop-shadow(0 0 16px rgba(120, 200, 255, 0.8));
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: drop-shadow(0 0 16px rgba(255, 120, 220, 1)) drop-shadow(0 0 28px rgba(120, 255, 160, 0.9));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Černá můra – zlověstný tmavý nádech
|
||||||
|
.butterfly-sprite.moth {
|
||||||
|
filter: drop-shadow(0 0 4px rgba(120, 60, 160, 0.6)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4));
|
||||||
|
animation: butterfly-flap 0.5s ease-in-out infinite, butterfly-moth-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-moth-pulse {
|
||||||
|
0%, 100% { filter: drop-shadow(0 0 4px rgba(120, 60, 160, 0.5)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4)); }
|
||||||
|
50% { filter: drop-shadow(0 0 9px rgba(150, 40, 40, 0.8)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Havěť (ptáci a vosy) ----------------------------------------------------
|
||||||
|
|
||||||
|
.butterfly-critter {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: contain;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: transform;
|
||||||
|
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.2));
|
||||||
|
z-index: 3;
|
||||||
|
|
||||||
|
// Vosa jde zaklikat (plácačka)
|
||||||
|
&.wasp {
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: crosshair;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Netopýr jde zahnat klikáním
|
||||||
|
&.bat {
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: crosshair;
|
||||||
|
touch-action: none;
|
||||||
|
z-index: 5;
|
||||||
|
filter: drop-shadow(0 0 6px rgba(60, 40, 90, 0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hit {
|
||||||
|
animation: butterfly-stalker-hit 0.18s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pavučina přes síťku – blokuje chytání, dokud ji hráč nestrhne
|
||||||
|
.butterfly-web {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
background-image: url(/spider.svg);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: contain;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: crosshair;
|
||||||
|
touch-action: none;
|
||||||
|
z-index: 6;
|
||||||
|
opacity: 0.9;
|
||||||
|
filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.4));
|
||||||
|
|
||||||
|
&.hit {
|
||||||
|
animation: butterfly-stalker-hit 0.15s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Síťka na chytání --------------------------------------------------------
|
||||||
|
|
||||||
|
.butterfly-net {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: contain;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: grab;
|
||||||
|
z-index: 4;
|
||||||
|
will-change: transform;
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
|
||||||
|
transition: filter 0.15s ease;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&.grabbed {
|
||||||
|
cursor: grabbing;
|
||||||
|
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prémiová (zlatá) síťka – zlatá záře a jemný pulz
|
||||||
|
&.premium {
|
||||||
|
filter: drop-shadow(0 0 8px rgba(255, 200, 60, 0.85)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
|
||||||
|
animation: butterfly-net-glow 1.3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protržená síťka – nedá se chytat
|
||||||
|
&.torn {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.9;
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(150, 30, 30, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.catch-pop {
|
||||||
|
animation: butterfly-net-pop 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.stung {
|
||||||
|
animation: butterfly-net-stung 0.4s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Záblesk při chycení černé můry (chyba)
|
||||||
|
&.moth-hit {
|
||||||
|
animation: butterfly-net-moth 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Omráčená síťka (po žihnutí vosou) – po celou dobu, kdy nejde chytat.
|
||||||
|
// Má přednost před grabbed kurzorem.
|
||||||
|
&.stunned {
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
animation: butterfly-net-dizzy 0.9s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zešednutí + červený nádech, ať je jasné, že síťka teď nechytá.
|
||||||
|
// NEanimujeme transform (ten řídí JS inline) – jen filter.
|
||||||
|
@keyframes butterfly-net-dizzy {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
filter: grayscale(0.7) brightness(0.85) drop-shadow(0 0 5px rgba(217, 72, 15, 0.55));
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: grayscale(0.85) brightness(0.8) drop-shadow(0 0 9px rgba(217, 72, 15, 0.8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-net-pop {
|
||||||
|
0% {
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
filter: drop-shadow(0 2px 8px rgba(120, 200, 120, 0.7)) brightness(1.25);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-net-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
filter: drop-shadow(0 0 6px rgba(255, 200, 60, 0.6)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: drop-shadow(0 0 12px rgba(255, 220, 100, 1)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pozn.: NEanimujeme transform – ten nastavuje JS inline (poloha síťky).
|
||||||
|
// Žihnutí vosou proto naznačíme jen červeným zábleskem filtru.
|
||||||
|
@keyframes butterfly-net-stung {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
filter: drop-shadow(0 0 8px rgba(217, 72, 15, 0.85)) brightness(1.1) hue-rotate(-15deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prchavé texty (chycení, protržení, žihnutí)
|
||||||
|
.butterfly-catch-fx,
|
||||||
|
.butterfly-tear-fx,
|
||||||
|
.butterfly-sting-fx {
|
||||||
|
position: fixed;
|
||||||
|
margin: -12px 0 0 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
|
||||||
|
animation: butterfly-catch-float 0.8s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-catch-fx {
|
||||||
|
color: #2f9e44;
|
||||||
|
|
||||||
|
&.golden {
|
||||||
|
color: #e0a200;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
text-shadow: 0 0 6px rgba(255, 220, 100, 0.9), 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.moth {
|
||||||
|
color: #c92a2a;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
text-shadow: 0 0 6px rgba(120, 40, 160, 0.7), 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-net-moth {
|
||||||
|
0%, 100% { filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1); }
|
||||||
|
30% { filter: drop-shadow(0 0 9px rgba(150, 40, 160, 0.9)) brightness(0.8); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-tear-fx {
|
||||||
|
color: #c92a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-sting-fx {
|
||||||
|
color: #d9480f;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-catch-float {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0) scale(0.9);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-6px) scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-40px) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HUD: počítadlo, mince, úroveň, kombo, hlášky ----------------------------
|
||||||
|
|
||||||
|
.butterfly-hud {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
left: 16px;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
// HUD sám o sobě neblokuje klikání; interaktivní je jen tlačítko opravy
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-shop-open {
|
||||||
|
pointer-events: auto;
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f59e1e;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: background 0.15s ease, transform 0.08s ease;
|
||||||
|
|
||||||
|
&:hover { background: #e8890a; }
|
||||||
|
&:active { transform: scale(0.96); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-counter {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #333;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bump {
|
||||||
|
animation: butterfly-counter-bump 0.3s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-counter-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-counter-icon,
|
||||||
|
.butterfly-coin-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-counter-icon {
|
||||||
|
background-image: url(/butterfly-orange.svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-coin-icon {
|
||||||
|
background-image: url(/coin.svg);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-counter-value {
|
||||||
|
min-width: 1ch;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-level {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-level-title {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b5b2e;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 5px;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #ffcb3d, #e0a200);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-pests {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-pest {
|
||||||
|
color: #868e96;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
|
&.warn {
|
||||||
|
color: #d9480f;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ok {
|
||||||
|
color: #2f9e44;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-torn-note {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #c92a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-action-btn {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #1c7ed6;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: background 0.15s ease, transform 0.1s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #1971c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #adb5bd;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
background: #c92a2a;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #e03131;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bossové (zloděj / housenka) ---------------------------------------------
|
||||||
|
|
||||||
|
.butterfly-stalker {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 56px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: crosshair;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
z-index: 6;
|
||||||
|
will-change: transform;
|
||||||
|
|
||||||
|
&.hit .stalker-emoji {
|
||||||
|
animation: butterfly-stalker-hit 0.18s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stalker-emoji {
|
||||||
|
font-size: 44px;
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stalker-hp {
|
||||||
|
width: 46px;
|
||||||
|
height: 6px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stalker-hp-inner {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(90deg, #ff6b6b, #c92a2a);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.1s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-stalker-hit {
|
||||||
|
0% { transform: scale(1) rotate(0); }
|
||||||
|
50% { transform: scale(0.82) rotate(-8deg); filter: brightness(1.6); }
|
||||||
|
100% { transform: scale(1) rotate(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Efekt při plácnutí vosy
|
||||||
|
.butterfly-swat-fx {
|
||||||
|
position: fixed;
|
||||||
|
margin: -14px 0 0 -6px;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
animation: butterfly-catch-float 0.8s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-combo {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(224, 162, 0, 0.92);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
animation: butterfly-counter-bump 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-flash {
|
||||||
|
max-width: 260px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(33, 37, 41, 0.92);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.25);
|
||||||
|
animation: butterfly-flash-in 0.25s ease-out;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-flash-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes butterfly-counter-bump {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1.18);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohleduplnost k uživatelům, kteří nechtějí pohyb
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.butterfly-sprite,
|
||||||
|
.butterfly-sprite.golden {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-net.catch-pop,
|
||||||
|
.butterfly-net.premium,
|
||||||
|
.butterfly-net.stung,
|
||||||
|
.butterfly-net.stunned,
|
||||||
|
.butterfly-counter.bump,
|
||||||
|
.butterfly-combo {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bez animace aspoň staticky ukážeme, že síťka je omráčená
|
||||||
|
.butterfly-net.stunned {
|
||||||
|
filter: grayscale(0.8) brightness(0.82) drop-shadow(0 0 6px rgba(217, 72, 15, 0.7));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zlatý motýl bez animace aspoň staticky září
|
||||||
|
.butterfly-sprite.golden {
|
||||||
|
filter: saturate(1.3) drop-shadow(0 0 8px rgba(255, 215, 80, 0.95)) drop-shadow(0 0 16px rgba(255, 190, 50, 0.7));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Anti-bot inspekce a vězení ---------------------------------------------
|
||||||
|
|
||||||
|
.butterfly-inspection {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 8;
|
||||||
|
pointer-events: none;
|
||||||
|
max-width: 92vw;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(28, 126, 214, 0.95);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: butterfly-flash-in 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-jail {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 6;
|
||||||
|
pointer-events: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Svislé mříže přes celou obrazovku
|
||||||
|
.butterfly-jail-bars {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(20, 22, 28, 0.92) 0px,
|
||||||
|
rgba(20, 22, 28, 0.92) 14px,
|
||||||
|
rgba(20, 22, 28, 0) 14px,
|
||||||
|
rgba(20, 22, 28, 0) 70px
|
||||||
|
);
|
||||||
|
box-shadow: inset 0 0 120px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-jail-note {
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
padding: 22px 26px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
max-width: 90vw;
|
||||||
|
|
||||||
|
.jail-emoji { font-size: 2.4rem; }
|
||||||
|
.jail-title { font-weight: 800; font-size: 1.2rem; color: #c92a2a; margin-top: 4px; }
|
||||||
|
.jail-sub { margin-top: 6px; font-size: 0.95rem; color: #333; font-variant-numeric: tabular-nums; }
|
||||||
|
.jail-hint { margin-top: 8px; font-size: 0.8rem; color: #868e96; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.butterfly-sprite.royal { animation: none; }
|
||||||
|
}
|
||||||
@@ -1,13 +1,99 @@
|
|||||||
.login {
|
.login-page {
|
||||||
height: 100%;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
text-align: center;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
background: var(--luncher-bg);
|
||||||
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-inner {
|
.login-card {
|
||||||
|
background: var(--luncher-bg-card);
|
||||||
|
border-radius: var(--luncher-radius-xl);
|
||||||
|
box-shadow: var(--luncher-shadow-lg);
|
||||||
|
padding: 48px;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid var(--luncher-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--luncher-text);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
color: var(--luncher-text-secondary);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form label {
|
||||||
|
display: block;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--luncher-text);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form .hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--luncher-text-muted);
|
||||||
|
margin-top: 8px;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 18px;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 2px solid var(--luncher-border);
|
||||||
|
border-radius: var(--luncher-radius-sm);
|
||||||
|
background: var(--luncher-bg);
|
||||||
|
color: var(--luncher-text);
|
||||||
|
transition: var(--luncher-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form input[type="text"]:hover {
|
||||||
|
border-color: var(--luncher-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form input[type="text"]:focus {
|
||||||
|
border-color: var(--luncher-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--luncher-primary-light);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form input[type="text"]::placeholder {
|
||||||
|
color: var(--luncher-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form .btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 24px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card > .hint {
|
||||||
|
color: var(--luncher-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
color: var(--luncher-danger);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -1,56 +1,102 @@
|
|||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Button } from 'react-bootstrap';
|
import { Button } from 'react-bootstrap';
|
||||||
import { useAuth } from './context/auth';
|
import { useAuth } from './context/auth';
|
||||||
import { login } from './api/Api';
|
import { login } from '../../types';
|
||||||
|
import { useAppConfig } from './hooks/useGroupLimits';
|
||||||
import './Login.css';
|
import './Login.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formulář pro prvotní zadání přihlašovacího jména.
|
* Přihlášení. Podle konfigurace serveru buď přihlásí uživatele automaticky přes důvěryhodné
|
||||||
|
* hlavičky z proxy (bez formuláře), nebo zobrazí formulář pro zadání jména. Když se konfigurace
|
||||||
|
* nenačte, zkusí nejprve přihlášení „naprázdno" a jinak zobrazí formulář (původní chování).
|
||||||
*/
|
*/
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
const { loaded, trustedHeaders } = useAppConfig();
|
||||||
const loginRef = useRef<HTMLInputElement>(null);
|
const loginRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [trustedError, setTrustedError] = useState<string>();
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (auth && !auth.login) {
|
if (!auth || auth.login || !loaded || trustedHeaders === false) return;
|
||||||
// Vyzkoušíme přihlášení "naprázdno", pokud projde, přihlásili nás trusted headers
|
// Přihlášení přes hlavičky z proxy (případně pokus „naprázdno", pokud nevíme, jak server přihlašuje)
|
||||||
login().then(token => {
|
setTrustedError(undefined);
|
||||||
|
login().then(response => {
|
||||||
|
const token = response.data;
|
||||||
if (token) {
|
if (token) {
|
||||||
auth?.setToken(token);
|
auth.setToken(token as unknown as string); // TODO vyřešit, API definice je špatně, je to skutečně string
|
||||||
|
} else if (trustedHeaders) {
|
||||||
|
setTrustedError((response.error as any)?.error ?? 'Přihlášení přes proxy se nezdařilo.');
|
||||||
}
|
}
|
||||||
}).catch(error => {
|
}).catch(() => {
|
||||||
// nezajímá nás
|
if (trustedHeaders) setTrustedError('Server je nedostupný. Zkuste to prosím za chvíli.');
|
||||||
});
|
});
|
||||||
}
|
}, [auth, loaded, trustedHeaders, attempt]);
|
||||||
}, [auth]);
|
|
||||||
|
|
||||||
const doLogin = useCallback(async () => {
|
const doLogin = useCallback(async () => {
|
||||||
const length = loginRef?.current?.value && loginRef?.current?.value.length && loginRef.current.value.replace(/\s/g, '').length
|
const length = loginRef?.current?.value.length && loginRef.current.value.replaceAll(/\s/g, '').length
|
||||||
if (length) {
|
if (length) {
|
||||||
// TODO odchytávat cokoliv mimo 200
|
const response = await login({ body: { login: loginRef.current?.value } });
|
||||||
const token = await login(loginRef.current.value);
|
if (response.data) {
|
||||||
if (token) {
|
auth?.setToken(response.data as unknown as string); // TODO vyřešit
|
||||||
auth?.setToken(token);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [auth]);
|
}, [auth]);
|
||||||
|
|
||||||
if (!auth || !auth.login) {
|
if (auth?.login) {
|
||||||
return <div className='login'>
|
return <div>Neplatný stav</div>
|
||||||
<h1>Luncher</h1>
|
}
|
||||||
<h4 style={{ marginBottom: "50px" }}>Aplikace pro profesionální management obědů</h4>
|
|
||||||
<div className='login-inner'>
|
const header = <>
|
||||||
<p style={{ fontSize: "12px", marginTop: "10px" }}>
|
<h1 className='login-logo'>Luncher</h1>
|
||||||
Zobrazované jméno by mělo být vaše jméno nebo přezdívka, pod kterou vás kolegové dokáží snadno identifikovat. Jméno je možné kdykoli změnit.
|
<p className='login-subtitle'>Aplikace pro profesionální management obědů</p>
|
||||||
</p>
|
</>;
|
||||||
Zobrazované jméno: <input style={{ marginTop: "10px" }} ref={loginRef} type='text' onKeyDown={event => {
|
|
||||||
|
// Přihlašuje proxy — formulář se jménem nedává smysl
|
||||||
|
if (!loaded || trustedHeaders) {
|
||||||
|
return (
|
||||||
|
<div className='login-page'>
|
||||||
|
<div className='login-card'>
|
||||||
|
{header}
|
||||||
|
{trustedError ? (
|
||||||
|
<div className='login-form'>
|
||||||
|
<p className='login-error'>{trustedError}</p>
|
||||||
|
<Button onClick={() => setAttempt(a => a + 1)}>Zkusit znovu</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className='hint'>Přihlašuji…</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='login-page'>
|
||||||
|
<div className='login-card'>
|
||||||
|
{header}
|
||||||
|
<div className='login-form'>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="login-input">Zobrazované jméno</label>
|
||||||
|
<input
|
||||||
|
id="login-input"
|
||||||
|
ref={loginRef}
|
||||||
|
type='text'
|
||||||
|
placeholder="Např. Jan Novák"
|
||||||
|
onKeyDown={event => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
doLogin()
|
doLogin()
|
||||||
}
|
}
|
||||||
}} />
|
}}
|
||||||
<Button onClick={doLogin} style={{ marginTop: "20px" }}>Uložit</Button>
|
/>
|
||||||
|
<p className='hint'>
|
||||||
|
Zadejte jméno nebo přezdívku, pod kterou vás kolegové snadno identifikují.
|
||||||
|
Jméno je možné kdykoli změnit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={doLogin}>Pokračovat</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
return <div>Neplatný stav</div>
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {DepartureTime} from "../../types";
|
import { DepartureTime } from "../../types";
|
||||||
|
|
||||||
const TOKEN_KEY = "token";
|
const TOKEN_KEY = "token";
|
||||||
|
|
||||||
@@ -16,8 +16,8 @@ export const storeToken = (token: string) => {
|
|||||||
*
|
*
|
||||||
* @returns token nebo null
|
* @returns token nebo null
|
||||||
*/
|
*/
|
||||||
export const getToken = (): string | null => {
|
export const getToken = (): string | undefined => {
|
||||||
return localStorage.getItem(TOKEN_KEY);
|
return localStorage.getItem(TOKEN_KEY) ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,3 +62,64 @@ export function isInTheFuture(time: DepartureTime) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí index dne v týdnu, kde pondělí=0, neděle=6
|
||||||
|
*
|
||||||
|
* @param date datum
|
||||||
|
* @returns index dne v týdnu
|
||||||
|
*/
|
||||||
|
export const getDayOfWeekIndex = (date: Date) => {
|
||||||
|
// https://stackoverflow.com/a/4467559
|
||||||
|
return (((date.getDay() - 1) % 7) + 7) % 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrátí první pracovní den v týdnu předaného data. */
|
||||||
|
export function getFirstWorkDayOfWeek(date: Date) {
|
||||||
|
const firstDay = new Date(date);
|
||||||
|
firstDay.setDate(date.getDate() - getDayOfWeekIndex(date));
|
||||||
|
return firstDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrátí poslední pracovní den v týdnu předaného data. */
|
||||||
|
export function getLastWorkDayOfWeek(date: Date) {
|
||||||
|
const lastDay = new Date(date);
|
||||||
|
lastDay.setDate(date.getDate() + (4 - getDayOfWeekIndex(date)));
|
||||||
|
return lastDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrátí datum v ISO formátu. */
|
||||||
|
export function formatDate(date: Date, format?: string) {
|
||||||
|
let day = String(date.getDate()).padStart(2, '0');
|
||||||
|
let month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
let year = String(date.getFullYear());
|
||||||
|
|
||||||
|
const f = format ?? 'YYYY-MM-DD';
|
||||||
|
return f.replace('DD', day).replace('MM', month).replace('YYYY', year);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrátí human-readable reprezentaci předaného data pro zobrazení. */
|
||||||
|
export function getHumanDate(date: Date) {
|
||||||
|
let currentDay = String(date.getDate()).padStart(2, '0');
|
||||||
|
let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
let currentYear = date.getFullYear();
|
||||||
|
return `${currentDay}.${currentMonth}.${currentYear}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Převede datum ve formátu YYYY-MM-DD na DD.MM.YYYY */
|
||||||
|
export function formatDateString(dateString: string): string {
|
||||||
|
const [year, month, day] = dateString.split('-');
|
||||||
|
return `${day}.${month}.${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Očistí zprávu (účel platby) pro QR platbu – musí odpovídat serverové logice (qr.ts):
|
||||||
|
* transliteruje diakritiku na základní písmena (š→s, č→c, ...), odstraní znaky mimo
|
||||||
|
* ISO 8859-1 a hvězdičku (oddělovač polí v QR platbě) a ořízne na max. 60 znaků.
|
||||||
|
*/
|
||||||
|
export function sanitizeQrMessage(message: string): string {
|
||||||
|
const sanitized = message
|
||||||
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // diakritika → základní písmeno
|
||||||
|
.replace(/[^\x00-\xff]/g, '') // znaky mimo ISO 8859-1
|
||||||
|
.replace(/\*/g, ''); // '*' je v QR platbě oddělovač
|
||||||
|
return sanitized.length > 60 ? sanitized.substring(0, 60) : sanitized;
|
||||||
|
}
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { toast } from "react-toastify";
|
|
||||||
import { getToken } from "../Utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrapper pro volání API, u kterých chceme automaticky zobrazit toaster s chybou ze serveru.
|
|
||||||
*
|
|
||||||
* @param apiFunction volaná API funkce
|
|
||||||
*/
|
|
||||||
export function errorHandler<T>(apiFunction: () => Promise<T>): Promise<T> {
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
apiFunction().then((result) => {
|
|
||||||
resolve(result);
|
|
||||||
}).catch(e => {
|
|
||||||
toast.error(e.message, { theme: "colored" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request<TResponse>(
|
|
||||||
url: string,
|
|
||||||
config: RequestInit = {}
|
|
||||||
): Promise<TResponse> {
|
|
||||||
config.headers = config?.headers ? new Headers(config.headers) : new Headers();
|
|
||||||
config.headers.set("Authorization", `Bearer ${getToken()}`);
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, config);
|
|
||||||
if (!response.ok) {
|
|
||||||
// TODO tohle je blbě, jelikož automaticky očekáváme, že v případě chyby přijde vždy JSON, což není pravda
|
|
||||||
const json = await response.json();
|
|
||||||
// Vyhodíme samotnou hlášku z odpovědi, odchytí si jí errorHandler
|
|
||||||
throw new Error(json.error);
|
|
||||||
}
|
|
||||||
const contentType = response.headers.get("content-type");
|
|
||||||
if (contentType && contentType.indexOf("application/json") !== -1) {
|
|
||||||
return response.json() as TResponse;
|
|
||||||
} else {
|
|
||||||
return response.text() as TResponse;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
return Promise.reject(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function blobRequest(
|
|
||||||
url: string,
|
|
||||||
config: RequestInit = {}
|
|
||||||
): Promise<Blob> {
|
|
||||||
config.headers = config?.headers ? new Headers(config.headers) : new Headers();
|
|
||||||
config.headers.set("Authorization", `Bearer ${getToken()}`);
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, config);
|
|
||||||
if (!response.ok) {
|
|
||||||
const json = await response.json();
|
|
||||||
// Vyhodíme samotnou hlášku z odpovědi, odchytí si jí errorHandler
|
|
||||||
throw new Error(json.error);
|
|
||||||
}
|
|
||||||
return response.blob()
|
|
||||||
} catch (e) {
|
|
||||||
return Promise.reject(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
get: <TResponse>(url: string) => request<TResponse>(url),
|
|
||||||
blobGet: (url: string) => blobRequest(url),
|
|
||||||
post: <TBody, TResponse>(url: string, body?: TBody) => request<TResponse>(url, { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }),
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getQrUrl = (login: string) => {
|
|
||||||
return `/api/qr?login=${login}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getData = async (dayIndex?: number) => {
|
|
||||||
let url = '/api/data';
|
|
||||||
if (dayIndex != null) {
|
|
||||||
url += '?dayIndex=' + dayIndex;
|
|
||||||
}
|
|
||||||
return await api.get<any>(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const login = async (login?: string) => {
|
|
||||||
return await api.post<any, any>('/api/login', { login });
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { EasterEgg } from "../../../types";
|
|
||||||
import { api } from "./Api";
|
|
||||||
|
|
||||||
const EASTER_EGGS_API_PREFIX = '/api/easterEggs';
|
|
||||||
|
|
||||||
export const getEasterEgg = async (): Promise<EasterEgg | undefined> => {
|
|
||||||
return await api.get<EasterEgg>(`${EASTER_EGGS_API_PREFIX}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getImage = async (url: string) => {
|
|
||||||
return await api.blobGet(`${EASTER_EGGS_API_PREFIX}/${url}`);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import {
|
|
||||||
AddChoiceRequest,
|
|
||||||
ChangeDepartureTimeRequest,
|
|
||||||
JdemeObedRequest,
|
|
||||||
LocationKey,
|
|
||||||
RemoveChoiceRequest,
|
|
||||||
RemoveChoicesRequest,
|
|
||||||
UpdateNoteRequest
|
|
||||||
} from "../../../types";
|
|
||||||
import { api } from "./Api";
|
|
||||||
|
|
||||||
const FOOD_API_PREFIX = '/api/food';
|
|
||||||
|
|
||||||
export const addChoice = async (locationKey: LocationKey, foodIndex?: number, dayIndex?: number) => {
|
|
||||||
return await api.post<AddChoiceRequest, void>(`${FOOD_API_PREFIX}/addChoice`, { locationKey, foodIndex, dayIndex });
|
|
||||||
}
|
|
||||||
|
|
||||||
export const removeChoices = async (locationKey: LocationKey, dayIndex?: number) => {
|
|
||||||
return await api.post<RemoveChoicesRequest, void>(`${FOOD_API_PREFIX}/removeChoices`, { locationKey, dayIndex });
|
|
||||||
}
|
|
||||||
|
|
||||||
export const removeChoice = async (locationKey: LocationKey, foodIndex: number, dayIndex?: number) => {
|
|
||||||
return await api.post<RemoveChoiceRequest, void>(`${FOOD_API_PREFIX}/removeChoice`, { locationKey, foodIndex, dayIndex });
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateNote = async (note?: string, dayIndex?: number) => {
|
|
||||||
return await api.post<UpdateNoteRequest, void>(`${FOOD_API_PREFIX}/updateNote`, { note, dayIndex });
|
|
||||||
}
|
|
||||||
|
|
||||||
export const changeDepartureTime = async (time: string, dayIndex?: number) => {
|
|
||||||
return await api.post<ChangeDepartureTimeRequest, void>(`${FOOD_API_PREFIX}/changeDepartureTime`, { time, dayIndex });
|
|
||||||
}
|
|
||||||
|
|
||||||
export const jdemeObed = async (locationKey: LocationKey) => {
|
|
||||||
return await api.post<JdemeObedRequest, void>(`${FOOD_API_PREFIX}/jdemeObed`, { locationKey });
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { AddPizzaRequest, FinishDeliveryRequest, PizzaOrder, RemovePizzaRequest, UpdatePizzaDayNoteRequest, UpdatePizzaFeeRequest } from "../../../types";
|
|
||||||
import { api } from "./Api";
|
|
||||||
|
|
||||||
const PIZZADAY_API_PREFIX = '/api/pizzaDay';
|
|
||||||
|
|
||||||
export const createPizzaDay = async () => {
|
|
||||||
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/create`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const deletePizzaDay = async () => {
|
|
||||||
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/delete`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const lockPizzaDay = async () => {
|
|
||||||
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/lock`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const unlockPizzaDay = async () => {
|
|
||||||
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/unlock`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const finishOrder = async () => {
|
|
||||||
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/finishOrder`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const finishDelivery = async (bankAccount?: string, bankAccountHolder?: string) => {
|
|
||||||
return await api.post<FinishDeliveryRequest, void>(`${PIZZADAY_API_PREFIX}/finishDelivery`, { bankAccount, bankAccountHolder });
|
|
||||||
}
|
|
||||||
|
|
||||||
export const addPizza = async (pizzaIndex: number, pizzaSizeIndex: number) => {
|
|
||||||
return await api.post<AddPizzaRequest, void>(`${PIZZADAY_API_PREFIX}/add`, { pizzaIndex, pizzaSizeIndex });
|
|
||||||
}
|
|
||||||
|
|
||||||
export const removePizza = async (pizzaOrder: PizzaOrder) => {
|
|
||||||
return await api.post<RemovePizzaRequest, void>(`${PIZZADAY_API_PREFIX}/remove`, { pizzaOrder });
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updatePizzaDayNote = async (note?: string) => {
|
|
||||||
return await api.post<UpdatePizzaDayNoteRequest, void>(`${PIZZADAY_API_PREFIX}/updatePizzaDayNote`, { note });
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updatePizzaFee = async (login: string, text?: string, price?: number) => {
|
|
||||||
return await api.post<UpdatePizzaFeeRequest, void>(`${PIZZADAY_API_PREFIX}/updatePizzaFee`, { login, text, price });
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { FeatureRequest, UpdateFeatureVoteRequest } from "../../../types";
|
|
||||||
import { api } from "./Api";
|
|
||||||
|
|
||||||
const VOTING_API_PREFIX = '/api/voting';
|
|
||||||
|
|
||||||
export const getFeatureVotes = async () => {
|
|
||||||
return await api.get<FeatureRequest[]>(`${VOTING_API_PREFIX}/getVotes`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateFeatureVote = async (option: FeatureRequest, active: boolean) => {
|
|
||||||
return await api.post<UpdateFeatureVoteRequest, void>(`${VOTING_API_PREFIX}/updateVote`, { option, active });
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// Pozvánka Kreslení nad pozvánkami ostatních her, aby se nepřekrývaly
|
||||||
|
.duel-invite.draw-invite {
|
||||||
|
bottom: 224px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawing-game-modal {
|
||||||
|
.draw-topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-hint {
|
||||||
|
// Mezery mezi slovy v masce se nesmí slít, jinak nejde poznat počet slov
|
||||||
|
white-space: pre;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-word strong {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-timer.urgent {
|
||||||
|
color: #ef4444;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-canvas-col {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
touch-action: none;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-tools {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-color {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(127, 127, 127, 0.5);
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
outline: 3px solid var(--luncher-primary, #198754);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-size {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(127, 127, 127, 0.5);
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
outline: 3px solid var(--luncher-primary, #198754);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-sep {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-side {
|
||||||
|
flex: 0 0 260px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-scores {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guessed {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-chat {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid rgba(127, 127, 127, 0.35);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
.system {
|
||||||
|
color: #22c55e;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-players {
|
||||||
|
columns: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button, Form, Modal, Table } from "react-bootstrap";
|
||||||
|
import { socket } from "../context/socket";
|
||||||
|
import { useAuth } from "../context/auth";
|
||||||
|
import { getToken } from "../Utils";
|
||||||
|
import { useGameIcon } from "../games/themeIcons";
|
||||||
|
import "./DrawingGame.scss";
|
||||||
|
|
||||||
|
// Typy musí odpovídat server/src/drawingGame.ts (stav chodí přes websocket, ne přes OpenAPI)
|
||||||
|
type DrawPhase = 'idle' | 'lobby' | 'drawing' | 'reveal' | 'finished';
|
||||||
|
type DrawPlayer = { login: string, score: number, guessed: boolean, place?: number };
|
||||||
|
type ChatMessage = { login?: string, text: string, system?: boolean };
|
||||||
|
type Stroke = { color: string, size: number, points: [number, number][], start: boolean };
|
||||||
|
type DrawState = {
|
||||||
|
phase: DrawPhase,
|
||||||
|
host?: string,
|
||||||
|
players: DrawPlayer[],
|
||||||
|
drawer?: string,
|
||||||
|
round: number,
|
||||||
|
totalRounds: number,
|
||||||
|
hint?: string,
|
||||||
|
answer?: string,
|
||||||
|
remainingMs?: number,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
minPlayers: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPEN_EVENT = 'luncher:open-drawing';
|
||||||
|
|
||||||
|
/** Otevře okno Kreslení. */
|
||||||
|
export const openDrawingGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
|
||||||
|
|
||||||
|
const CANVAS_W = 800;
|
||||||
|
const CANVAS_H = 600;
|
||||||
|
/** Souřadnice tahů jsou 0–1000 v obou osách, nezávisle na velikosti plátna. */
|
||||||
|
const COORD_MAX = 1000;
|
||||||
|
const COLORS = ['#111827', '#ef4444', '#f97316', '#facc15', '#22c55e', '#3b82f6', '#a855f7', '#92400e', '#ffffff'];
|
||||||
|
const SIZES = [3, 8, 18];
|
||||||
|
/** Jak často se rozkreslený tah posílá ostatním (ms). */
|
||||||
|
const FLUSH_MS = 40;
|
||||||
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
|
|
||||||
|
function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke) {
|
||||||
|
const sx = CANVAS_W / COORD_MAX;
|
||||||
|
const sy = CANVAS_H / COORD_MAX;
|
||||||
|
ctx.strokeStyle = stroke.color;
|
||||||
|
ctx.fillStyle = stroke.color;
|
||||||
|
ctx.lineWidth = stroke.size;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
const [first, ...rest] = stroke.points;
|
||||||
|
if (rest.length === 0) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(first[0] * sx, first[1] * sy, stroke.size / 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(first[0] * sx, first[1] * sy);
|
||||||
|
rest.forEach(([x, y]) => ctx.lineTo(x * sx, y * sy));
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCanvas(ctx: CanvasRenderingContext2D) {
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kreslení — jeden kreslí jídlo z dnešních menu, ostatní hádají v chatu. */
|
||||||
|
export default function DrawingGame() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const login = auth?.login;
|
||||||
|
const [state, setState] = useState<DrawState | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [word, setWord] = useState<string | null>(null);
|
||||||
|
const [color, setColor] = useState(COLORS[0]);
|
||||||
|
const [size, setSize] = useState(SIZES[1]);
|
||||||
|
const [guess, setGuess] = useState('');
|
||||||
|
const [secondsLeft, setSecondsLeft] = useState(0);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const chatRef = useRef<HTMLDivElement>(null);
|
||||||
|
const prevPhase = useRef<DrawPhase>('idle');
|
||||||
|
/** Rozkreslený tah: body čekající na odeslání a poslední odeslaný bod pro navázání. */
|
||||||
|
const pending = useRef<{ points: [number, number][], start: boolean } | null>(null);
|
||||||
|
const drawingRef = useRef(false);
|
||||||
|
const icon = useGameIcon('drawing');
|
||||||
|
|
||||||
|
const send = useCallback((event: string, extra?: object) => {
|
||||||
|
setError(null);
|
||||||
|
socket.emit(event, { token: getToken(), ...extra });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ctx = () => canvasRef.current?.getContext('2d') ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onState = (s: DrawState) => setState(s);
|
||||||
|
const onError = (msg: string) => setError(msg);
|
||||||
|
const onOpen = () => setOpen(true);
|
||||||
|
const sync = () => socket.emit('draw:sync');
|
||||||
|
socket.on('draw:state', onState);
|
||||||
|
socket.on('draw:error', onError);
|
||||||
|
socket.on('connect', sync);
|
||||||
|
window.addEventListener(OPEN_EVENT, onOpen);
|
||||||
|
sync();
|
||||||
|
return () => {
|
||||||
|
socket.off('draw:state', onState);
|
||||||
|
socket.off('draw:error', onError);
|
||||||
|
socket.off('connect', sync);
|
||||||
|
window.removeEventListener(OPEN_EVENT, onOpen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isDrawer = !!login && state?.phase === 'drawing' && state.drawer === login;
|
||||||
|
const isDrawerRef = useRef(isDrawer);
|
||||||
|
isDrawerRef.current = isDrawer;
|
||||||
|
|
||||||
|
// Kresba a slovo chodí jen klientům s otevřeným oknem
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const watch = () => socket.emit('draw:watch', { token: getToken() });
|
||||||
|
const onStrokes = (strokes: Stroke[]) => {
|
||||||
|
const c = ctx();
|
||||||
|
if (!c) return;
|
||||||
|
clearCanvas(c);
|
||||||
|
strokes.forEach(s => drawStroke(c, s));
|
||||||
|
};
|
||||||
|
// Vlastní tahy kreslíř vykresluje hned lokálně
|
||||||
|
const onStroke = (s: Stroke) => {
|
||||||
|
const c = ctx();
|
||||||
|
if (c && !isDrawerRef.current) drawStroke(c, s);
|
||||||
|
};
|
||||||
|
const onClear = () => {
|
||||||
|
const c = ctx();
|
||||||
|
if (c) clearCanvas(c);
|
||||||
|
};
|
||||||
|
const onWord = (w: string) => setWord(w);
|
||||||
|
socket.on('draw:strokes', onStrokes);
|
||||||
|
socket.on('draw:stroke', onStroke);
|
||||||
|
socket.on('draw:clear', onClear);
|
||||||
|
socket.on('draw:word', onWord);
|
||||||
|
socket.on('connect', watch);
|
||||||
|
watch();
|
||||||
|
return () => {
|
||||||
|
socket.off('draw:strokes', onStrokes);
|
||||||
|
socket.off('draw:stroke', onStroke);
|
||||||
|
socket.off('draw:clear', onClear);
|
||||||
|
socket.off('draw:word', onWord);
|
||||||
|
socket.off('connect', watch);
|
||||||
|
socket.emit('draw:unwatch');
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
|
||||||
|
const phase = state?.phase ?? 'idle';
|
||||||
|
const inGame = phase === 'drawing' || phase === 'reveal';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevPhase.current === 'lobby' && phase === 'drawing' && isPlayer) {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
prevPhase.current = phase;
|
||||||
|
}, [phase, isPlayer]);
|
||||||
|
|
||||||
|
// Slovo patří jen k aktuálnímu kolu
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDrawer) setWord(null);
|
||||||
|
}, [isDrawer, state?.round]);
|
||||||
|
|
||||||
|
// Plátno se po vykreslení (nové kolo, otevření okna) vyplní bílou a znovu vyžádá kresba
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && inGame) {
|
||||||
|
const c = ctx();
|
||||||
|
if (c) clearCanvas(c);
|
||||||
|
socket.emit('draw:watch', { token: getToken() });
|
||||||
|
}
|
||||||
|
}, [open, inGame]);
|
||||||
|
|
||||||
|
// Odpočet kola počítaný lokálně od posledního stavu ze serveru
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'drawing' || state?.remainingMs === undefined) {
|
||||||
|
setSecondsLeft(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const endsAt = Date.now() + state.remainingMs;
|
||||||
|
const update = () => setSecondsLeft(Math.max(0, Math.ceil((endsAt - Date.now()) / 1000)));
|
||||||
|
update();
|
||||||
|
const id = window.setInterval(update, 250);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [phase, state?.remainingMs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chatRef.current?.scrollTo({ top: chatRef.current.scrollHeight });
|
||||||
|
}, [state?.messages.length]);
|
||||||
|
|
||||||
|
// Průběžné odesílání rozkresleného tahu
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDrawer) return;
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
const p = pending.current;
|
||||||
|
if (!p || p.points.length === 0 || (p.points.length === 1 && !p.start)) return;
|
||||||
|
send('draw:stroke', { color, size, points: p.points, start: p.start });
|
||||||
|
// Další dávka začne posledním bodem, aby čára navazovala
|
||||||
|
pending.current = drawingRef.current ? { points: [p.points[p.points.length - 1]], start: false } : null;
|
||||||
|
}, FLUSH_MS);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [isDrawer, color, size, send]);
|
||||||
|
|
||||||
|
const toCoords = (e: React.PointerEvent<HTMLCanvasElement>): [number, number] => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = Math.round(((e.clientX - rect.left) / rect.width) * COORD_MAX);
|
||||||
|
const y = Math.round(((e.clientY - rect.top) / rect.height) * COORD_MAX);
|
||||||
|
return [Math.min(COORD_MAX, Math.max(0, x)), Math.min(COORD_MAX, Math.max(0, y))];
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!isDrawer) return;
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
drawingRef.current = true;
|
||||||
|
const point = toCoords(e);
|
||||||
|
pending.current = { points: [point], start: true };
|
||||||
|
const c = ctx();
|
||||||
|
if (c) drawStroke(c, { color, size, points: [point], start: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!isDrawer || !drawingRef.current || !pending.current) return;
|
||||||
|
const point = toCoords(e);
|
||||||
|
const prev = pending.current.points[pending.current.points.length - 1];
|
||||||
|
if (prev && prev[0] === point[0] && prev[1] === point[1]) return;
|
||||||
|
pending.current.points.push(point);
|
||||||
|
const c = ctx();
|
||||||
|
if (c && prev) drawStroke(c, { color, size, points: [prev, point], start: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = () => {
|
||||||
|
drawingRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitGuess = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!guess.trim()) return;
|
||||||
|
send('draw:guess', { text: guess });
|
||||||
|
setGuess('');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!login) return null;
|
||||||
|
|
||||||
|
const me = state?.players.find(p => p.login === login);
|
||||||
|
const canChat = isPlayer && !isDrawer && !(phase === 'drawing' && me?.guessed);
|
||||||
|
const ranked = [...(state?.players ?? [])].sort((a, b) => b.score - a.score);
|
||||||
|
const showInvite = !open && phase === 'lobby' && !isPlayer;
|
||||||
|
const showReturn = !open && isPlayer && inGame;
|
||||||
|
|
||||||
|
const renderTopBar = () => {
|
||||||
|
if (phase === 'reveal' || phase === 'finished') {
|
||||||
|
return <div className="draw-topbar">Bylo to: <strong>{state?.answer}</strong></div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="draw-topbar">
|
||||||
|
<span>Kolo {state?.round}/{state?.totalRounds} · kreslí <strong>{state?.drawer}</strong></span>
|
||||||
|
{isDrawer
|
||||||
|
? <span className="draw-word">Nakreslete: <strong>{word ?? '…'}</strong></span>
|
||||||
|
: <span className="draw-hint">{state?.hint}</span>}
|
||||||
|
<span className={secondsLeft <= 10 ? 'draw-timer urgent' : 'draw-timer'}>⏱ {secondsLeft} s</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderGame = () => (
|
||||||
|
<>
|
||||||
|
{renderTopBar()}
|
||||||
|
<div className="draw-layout">
|
||||||
|
<div className="draw-canvas-col">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={`draw-canvas ${isDrawer ? 'active' : ''}`}
|
||||||
|
width={CANVAS_W}
|
||||||
|
height={CANVAS_H}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={onPointerUp}
|
||||||
|
/>
|
||||||
|
{isDrawer && (
|
||||||
|
<div className="draw-tools">
|
||||||
|
{COLORS.map(c => (
|
||||||
|
<button key={c} className={`draw-color ${c === color ? 'selected' : ''}`}
|
||||||
|
style={{ background: c }} onClick={() => setColor(c)} aria-label={`Barva ${c}`} />
|
||||||
|
))}
|
||||||
|
<span className="draw-sep" />
|
||||||
|
{SIZES.map(s => (
|
||||||
|
<button key={s} className={`draw-size ${s === size ? 'selected' : ''}`} onClick={() => setSize(s)}
|
||||||
|
aria-label={`Tloušťka ${s}`}>
|
||||||
|
<span style={{ width: s, height: s }} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="draw-sep" />
|
||||||
|
<Button size="sm" variant="outline-secondary" onClick={() => send('draw:clear')}>Smazat</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="draw-side">
|
||||||
|
<ul className="draw-scores">
|
||||||
|
{ranked.map(p => (
|
||||||
|
<li key={p.login} className={p.guessed && phase === 'drawing' ? 'guessed' : undefined}>
|
||||||
|
<span>{p.login === state?.drawer && phase === 'drawing' ? '✏️ ' : ''}{p.login}{p.login === login && ' (vy)'}</span>
|
||||||
|
<strong>{p.score}</strong>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{renderChat()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderChat = () => (
|
||||||
|
<>
|
||||||
|
<div className="draw-chat" ref={chatRef}>
|
||||||
|
{state?.messages.map((m, i) => (
|
||||||
|
<div key={i} className={m.system ? 'system' : undefined}>
|
||||||
|
{m.login && <strong>{m.login}: </strong>}{m.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Form onSubmit={submitGuess}>
|
||||||
|
<Form.Control
|
||||||
|
value={guess}
|
||||||
|
onChange={e => setGuess(e.target.value)}
|
||||||
|
placeholder={isDrawer ? 'Kreslíte…' : canChat ? 'Váš tip…' : 'Uhodli jste ✔'}
|
||||||
|
disabled={!canChat}
|
||||||
|
maxLength={100}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBody = () => {
|
||||||
|
switch (phase) {
|
||||||
|
case 'idle':
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
Jeden kreslí jídlo z dnešních menu, ostatní hádají v chatu. Stačí trefit hlavní slovo
|
||||||
|
(např. „guláš"), diakritika nevadí. Čím dřív uhodnete, tím víc bodů; kreslíř dostává body
|
||||||
|
za každého, kdo uhodl. Každý kreslí jednou.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
case 'lobby':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
|
||||||
|
<ul className="draw-players">
|
||||||
|
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
|
||||||
|
</ul>
|
||||||
|
{renderChat()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'drawing':
|
||||||
|
case 'reveal':
|
||||||
|
return renderGame();
|
||||||
|
case 'finished':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table size="sm">
|
||||||
|
<thead><tr><th>#</th><th>Hráč</th><th>Body</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{ranked.map((p, i) => (
|
||||||
|
<tr key={p.login}>
|
||||||
|
<td>{i < 3 ? MEDALS[i] : i + 1}</td>
|
||||||
|
<td>{p.login}{p.login === login && ' (vy)'}</td>
|
||||||
|
<td>{p.score}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
const buttons = [];
|
||||||
|
if (phase === 'idle' || phase === 'finished') {
|
||||||
|
buttons.push(<Button key="new" onClick={() => send('draw:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
|
||||||
|
}
|
||||||
|
if (phase === 'lobby') {
|
||||||
|
if (!isPlayer) {
|
||||||
|
buttons.push(<Button key="join" onClick={() => send('draw:join')}>Připojit se</Button>);
|
||||||
|
} else {
|
||||||
|
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('draw:leave')}>
|
||||||
|
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
|
||||||
|
</Button>);
|
||||||
|
if (login === state?.host) {
|
||||||
|
buttons.push(<Button key="start" onClick={() => send('draw:start')}
|
||||||
|
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 2)}>Spustit hru</Button>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
||||||
|
return buttons;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showInvite && (
|
||||||
|
<button className="duel-invite draw-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} <strong>{state?.host}</strong> zve na kreslení — připojit se
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showReturn && (
|
||||||
|
<button className="duel-invite draw-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} Kreslení běží — zpět do hry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Modal show={open} onHide={() => setOpen(false)} size="xl" className="drawing-game-modal">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>{icon} Kreslení</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{renderBody()}
|
||||||
|
{error && <div className="text-danger mt-2">{error}</div>}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>{renderFooter()}</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Kulatý portrét postavy vedle jména strávníka s více jídly (styl Umamusume)
|
||||||
|
.eater-badge {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex: none;
|
||||||
|
margin-right: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 0 0 2px #ff6fa3;
|
||||||
|
cursor: help;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(2);
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styl My Little Pony: cutie mark s emoji v kulatém odznaku
|
||||||
|
.eater-badge-mlp {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1;
|
||||||
|
background: radial-gradient(circle at 35% 30%, #fff, #f6ecff);
|
||||||
|
box-shadow: 0 0 0 2px #8e4fd1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styl Hell Girl: emoji v černém odznaku s rudým lemem (rudá nit)
|
||||||
|
.eater-badge-hellgirl {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
border-color: #1c1012;
|
||||||
|
background: radial-gradient(circle at 50% 60%, #3a0d16, #12070a);
|
||||||
|
box-shadow: 0 0 0 2px #c8102e, 0 0 6px rgb(200 16 46 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styl K-ON!: emoji na krémovém „trsátku" s lemem v barvě kytary Gitah (cherry sunburst)
|
||||||
|
.eater-badge-kon {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 50% 50% 50% 50% / 42% 42% 58% 58%;
|
||||||
|
background: radial-gradient(circle at 40% 35%, #fffaf0, #fbe6c8);
|
||||||
|
box-shadow: 0 0 0 2px #d43f62, 0 2px 0 3px #27315f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styl Bocchi the Rock!: emoji na žluté samolepce s černou linkou a růžovým stínem
|
||||||
|
.eater-badge-bocchi {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-color: #1d1a22;
|
||||||
|
background: #ffe066;
|
||||||
|
box-shadow: 2px 2px 0 #e0457b;
|
||||||
|
transform: rotate(-4deg);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: rotate(0deg) scale(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Odznak a jméno drží na jednom řádku (úzký sloupec „Člen" na stránce objednávek)
|
||||||
|
.user-info:has(.eater-badge) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||||
|
import { StyleTheme, useSettings } from "../context/settings";
|
||||||
|
import { UmaCharacter, loadUmaCharacters } from "../utils/umaCharacters";
|
||||||
|
import "./EaterBadge.scss";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Úrovně „jedlíka" podle počtu jídel: Special Week sní hodně, Oguri Cap ještě víc.
|
||||||
|
* Seřazeno od nejvyšší úrovně.
|
||||||
|
*/
|
||||||
|
const LEVELS = [
|
||||||
|
{ minItems: 3, character: 'Oguri Cap', title: 'Úroveň Oguri Cap — bezedný žaludek' },
|
||||||
|
{ minItems: 2, character: 'Special Week', title: 'Úroveň Special Week — pořádný apetit' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Úrovně ve stylu My Little Pony — místo portrétu cutie mark (emoji). Seřazeno od nejvyšší úrovně. */
|
||||||
|
const MLP_LEVELS = [
|
||||||
|
{ minItems: 3, emoji: '🧁', title: 'Úroveň Pinkie Pie — cupcaků není nikdy dost' },
|
||||||
|
{ minItems: 2, emoji: '🍎', title: 'Úroveň Applejack — poctivý apetit' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Úrovně ve stylu Hell Girl — emoji v černém odznaku s rudým lemem. Seřazeno od nejvyšší úrovně. */
|
||||||
|
const HG_LEVELS = [
|
||||||
|
{ minItems: 3, emoji: '🔥', title: 'Úroveň Wanyūdō — hlad jako pekelný oheň' },
|
||||||
|
{ minItems: 2, emoji: '🥀', title: 'Úroveň Ai Enma — tichý, ale nenasytný apetit' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Úrovně ve stylu K-ON! — emoji v odznaku jako trsátko. Seřazeno od nejvyšší úrovně. */
|
||||||
|
const KON_LEVELS = [
|
||||||
|
{ minItems: 3, emoji: '🍰', title: 'Úroveň Yui — sní všechno a nepřibere ani gram' },
|
||||||
|
{ minItems: 2, emoji: '☕', title: 'Úroveň Tea Time — k obědu ještě čaj a dortík' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Úrovně ve stylu Bocchi the Rock! — emoji na samolepce jako z flyeru live house. Seřazeno od nejvyšší úrovně. */
|
||||||
|
const BOCCHI_LEVELS = [
|
||||||
|
{ minItems: 3, emoji: '🌿', title: 'Úroveň Ryo — když dojdou peníze, sní i trávu u cesty' },
|
||||||
|
{ minItems: 2, emoji: '🥭', title: 'Úroveň Bocchi — dvojitý oběd v klidu v krabici od manga' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Úrovně podle stylu pro odznaky s emoji. */
|
||||||
|
const EMOJI_LEVELS: Partial<Record<StyleTheme, typeof MLP_LEVELS>> = {
|
||||||
|
mlp: MLP_LEVELS,
|
||||||
|
hellgirl: HG_LEVELS,
|
||||||
|
kon: KON_LEVELS,
|
||||||
|
bocchi: BOCCHI_LEVELS,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Počet jídel/položek, které si strávník vybral či objednal */
|
||||||
|
items: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Odznak s postavou u strávníků s více jídly — jen ve stylech Umamusume, My Little Pony, Hell Girl, K-ON! a Bocchi the Rock!. */
|
||||||
|
export default function EaterBadge({ items }: Readonly<Props>) {
|
||||||
|
const styleTheme = useSettings()?.styleTheme;
|
||||||
|
const isUma = styleTheme === 'umamusume';
|
||||||
|
const [characters, setCharacters] = useState<UmaCharacter[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isUma) loadUmaCharacters().then(setCharacters);
|
||||||
|
}, [isUma]);
|
||||||
|
|
||||||
|
const emojiLevels = styleTheme && EMOJI_LEVELS[styleTheme];
|
||||||
|
if (emojiLevels) {
|
||||||
|
const emojiLevel = emojiLevels.find(l => items >= l.minItems);
|
||||||
|
if (!emojiLevel) return null;
|
||||||
|
return (
|
||||||
|
<OverlayTrigger placement="top" overlay={<Tooltip>{emojiLevel.title} ({items} jídla)</Tooltip>}>
|
||||||
|
<span className={`eater-badge eater-badge-${styleTheme}`} role="img" aria-label={emojiLevel.title}>{emojiLevel.emoji}</span>
|
||||||
|
</OverlayTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const level = LEVELS.find(l => items >= l.minItems);
|
||||||
|
const character = level && characters.find(c => c.name === level.character);
|
||||||
|
// Malý odznak potřebuje obličej — portrét (icon), případně kulatý obrázek postavy
|
||||||
|
const src = character?.icon ?? (character?.round ? character.images[0] : undefined);
|
||||||
|
if (!isUma || !level || !src) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OverlayTrigger placement="top" overlay={<Tooltip>{level.title} ({items} jídla)</Tooltip>}>
|
||||||
|
<img src={src} alt={level.character} className="eater-badge" />
|
||||||
|
</OverlayTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Navbar } from "react-bootstrap";
|
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return <Navbar className="text-light" variant='dark' expand="lg" style={{ display: "flex", justifyContent: "center" }}>
|
return (
|
||||||
<span>🄯 Žádná práva nevyhrazena. TODO a zdrojové kódy dostupné <a href="https://gitea.melancholik.eu/mates/Luncher">zde</a>.</span>
|
<footer className="footer">
|
||||||
</Navbar >
|
<span>
|
||||||
|
Zdroj. kódy dostupné na{' '}
|
||||||
|
<a href="https://gitea.melancholik.eu/mates/Luncher" target="_blank" rel="noopener noreferrer">
|
||||||
|
Gitea
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,43 +1,97 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Navbar, Nav, NavDropdown } from "react-bootstrap";
|
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
|
||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import SettingsModal from "./modals/SettingsModal";
|
import SettingsModal from "./modals/SettingsModal";
|
||||||
import { useSettings } from "../context/settings";
|
import ReactionDuel from "./ReactionDuel";
|
||||||
import FeaturesVotingModal from "./modals/FeaturesVotingModal";
|
import SnakeGame from "./SnakeGame";
|
||||||
import { FeatureRequest } from "../../../types";
|
import TetrisGame from "./TetrisGame";
|
||||||
import { errorHandler } from "../api/Api";
|
import PongGame from "./PongGame";
|
||||||
import { getFeatureVotes, updateFeatureVote } from "../api/VotingApi";
|
import DrawingGame from "./DrawingGame";
|
||||||
|
import { useMinigameSecret } from "../hooks/useMinigameSecret";
|
||||||
|
import { useSettings, ThemePreference } from "../context/settings";
|
||||||
|
import HuePicker from "./HuePicker";
|
||||||
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
||||||
|
import RefreshMenuModal from "./modals/RefreshMenuModal";
|
||||||
|
import GenerateQrModal from "./modals/GenerateQrModal";
|
||||||
|
import GenerateMockDataModal from "./modals/GenerateMockDataModal";
|
||||||
|
import ClearMockDataModal from "./modals/ClearMockDataModal";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { STATS_URL, OBJEDNANI_URL, NAVRHY_URL } from "../AppRoutes";
|
||||||
|
import { LunchChoices, getChangelogs } from "../../../types";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { formatDateString } from "../Utils";
|
||||||
|
|
||||||
|
const LAST_SEEN_CHANGELOG_KEY = "lastChangelogDate";
|
||||||
|
|
||||||
export default function Header() {
|
const IS_DEV = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
choices?: LunchChoices;
|
||||||
|
dayIndex?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Header({ choices, dayIndex }: Props) {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
|
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
|
||||||
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
|
|
||||||
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
|
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
|
||||||
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[]>([]);
|
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
|
||||||
|
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
|
||||||
|
const [changelogEntries, setChangelogEntries] = useState<Record<string, string[]>>({});
|
||||||
|
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
|
||||||
|
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
|
||||||
|
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const effectiveDark = settings?.effectiveDark ?? false;
|
||||||
|
const minigameSecret = useMinigameSecret("/");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (auth?.login) {
|
if (!auth?.login) return;
|
||||||
getFeatureVotes().then(votes => {
|
const lastSeen = localStorage.getItem(LAST_SEEN_CHANGELOG_KEY) ?? undefined;
|
||||||
setFeatureVotes(votes);
|
getChangelogs({ query: lastSeen ? { since: lastSeen } : {} }).then(response => {
|
||||||
})
|
const entries = response.data;
|
||||||
}
|
if (!entries || Object.keys(entries).length === 0) return;
|
||||||
|
setChangelogEntries(entries);
|
||||||
|
setChangelogModalOpen(true);
|
||||||
|
const newestDate = Object.keys(entries).sort((a, b) => b.localeCompare(a))[0];
|
||||||
|
localStorage.setItem(LAST_SEEN_CHANGELOG_KEY, newestDate);
|
||||||
|
});
|
||||||
}, [auth?.login]);
|
}, [auth?.login]);
|
||||||
|
|
||||||
const closeSettingsModal = () => {
|
const closeSettingsModal = () => {
|
||||||
setSettingsModalOpen(false);
|
setSettingsModalOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeVotingModal = () => {
|
|
||||||
setVotingModalOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const closePizzaModal = () => {
|
const closePizzaModal = () => {
|
||||||
setPizzaModalOpen(false);
|
setPizzaModalOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const closeRefreshMenuModal = () => {
|
||||||
|
setRefreshMenuModalOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeQrModal = () => {
|
||||||
|
setQrModalOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQrMenuClick = () => {
|
||||||
|
if (!settings?.bankAccount || !settings?.holderName) {
|
||||||
|
alert('Pro generování QR kódů je nutné mít v nastavení vyplněné číslo účtu a jméno držitele účtu.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setQrModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tematické styly mají pevnou paletu, výběr barvy má smysl jen u jednoduchého stylu
|
||||||
|
const isSimpleStyle = (settings?.styleTheme ?? 'simple') === 'simple';
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const newTheme: ThemePreference = effectiveDark ? 'light' : 'dark';
|
||||||
|
settings?.setThemePreference(newTheme);
|
||||||
|
}
|
||||||
|
|
||||||
const isValidInteger = (str: string) => {
|
const isValidInteger = (str: string) => {
|
||||||
str = str.trim();
|
str = str.trim();
|
||||||
if (!str) {
|
if (!str) {
|
||||||
@@ -48,19 +102,19 @@ export default function Header() {
|
|||||||
return n !== Infinity && String(n) === str && n >= 0;
|
return n !== Infinity && String(n) === str && n >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => {
|
const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean, themePreference?: ThemePreference) => {
|
||||||
if (bankAccountNumber) {
|
if (bankAccountNumber) {
|
||||||
try {
|
try {
|
||||||
// Validace kódu banky
|
// Validace kódu banky
|
||||||
if (bankAccountNumber.indexOf('/') < 0) {
|
if (!bankAccountNumber.includes('/')) {
|
||||||
throw Error("Číslo účtu neobsahuje lomítko/kód banky")
|
throw new Error("Číslo účtu neobsahuje lomítko/kód banky")
|
||||||
}
|
}
|
||||||
const split = bankAccountNumber.split("/");
|
const split = bankAccountNumber.split("/");
|
||||||
if (split[1].length !== 4) {
|
if (split[1].length !== 4) {
|
||||||
throw Error("Kód banky musí být 4 číslice")
|
throw new Error("Kód banky musí být 4 číslice")
|
||||||
}
|
}
|
||||||
if (!isValidInteger(split[1])) {
|
if (!isValidInteger(split[1])) {
|
||||||
throw Error("Kód banky není číslo")
|
throw new Error("Kód banky není číslo")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validace čísla a předčíslí
|
// Validace čísla a předčíslí
|
||||||
@@ -70,20 +124,20 @@ export default function Header() {
|
|||||||
cislo = cislo.replace('-', '');
|
cislo = cislo.replace('-', '');
|
||||||
}
|
}
|
||||||
if (!isValidInteger(cislo)) {
|
if (!isValidInteger(cislo)) {
|
||||||
throw Error("Předčíslí nebo číslo účtu neobsahuje pouze číslice")
|
throw new Error("Předčíslí nebo číslo účtu neobsahuje pouze číslice")
|
||||||
}
|
}
|
||||||
if (cislo.length < 16) {
|
if (cislo.length < 16) {
|
||||||
cislo = cislo.padStart(16, '0');
|
cislo = cislo.padStart(16, '0');
|
||||||
}
|
}
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
for (var i = 0; i < cislo.length; i++) {
|
for (let i = 0; i < cislo.length; i++) {
|
||||||
const char = cislo.charAt(i);
|
const char = cislo.charAt(i);
|
||||||
const order = (cislo.length - 1) - i;
|
const order = (cislo.length - 1) - i;
|
||||||
const weight = (2 ** order) % 11;
|
const weight = (2 ** order) % 11;
|
||||||
sum += Number.parseInt(char) * weight
|
sum += Number.parseInt(char) * weight
|
||||||
}
|
}
|
||||||
if (sum % 11 !== 0) {
|
if (sum % 11 !== 0) {
|
||||||
throw Error("Číslo účtu je neplatné")
|
throw new Error("Číslo účtu je neplatné")
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e.message)
|
alert(e.message)
|
||||||
@@ -93,36 +147,116 @@ export default function Header() {
|
|||||||
settings?.setBankAccountNumber(bankAccountNumber);
|
settings?.setBankAccountNumber(bankAccountNumber);
|
||||||
settings?.setBankAccountHolderName(bankAccountHolderName);
|
settings?.setBankAccountHolderName(bankAccountHolderName);
|
||||||
settings?.setHideSoupsOption(hideSoupsOption);
|
settings?.setHideSoupsOption(hideSoupsOption);
|
||||||
|
if (themePreference) {
|
||||||
|
settings?.setThemePreference(themePreference);
|
||||||
|
}
|
||||||
closeSettingsModal();
|
closeSettingsModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveFeatureVote = async (option: FeatureRequest, active: boolean) => {
|
|
||||||
await errorHandler(() => updateFeatureVote(option, active));
|
|
||||||
const votes = [...featureVotes];
|
|
||||||
if (active) {
|
|
||||||
votes.push(option);
|
|
||||||
} else {
|
|
||||||
votes.splice(votes.indexOf(option), 1);
|
|
||||||
}
|
|
||||||
setFeatureVotes(votes);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Navbar variant='dark' expand="lg">
|
return <Navbar variant='dark' expand="lg">
|
||||||
<Navbar.Brand>Luncher</Navbar.Brand>
|
<Navbar.Brand href="/" onClick={minigameSecret.onLogoClick} className={minigameSecret.armed ? "duel-armed" : undefined}>Luncher</Navbar.Brand>
|
||||||
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
||||||
<Navbar.Collapse id="basic-navbar-nav">
|
<Navbar.Collapse id="basic-navbar-nav">
|
||||||
<Nav className="nav">
|
<Nav className="nav">
|
||||||
|
<button
|
||||||
|
className="theme-toggle"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
title={effectiveDark ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
|
||||||
|
aria-label="Přepnout světlý/tmavý režim"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
|
||||||
|
</button>
|
||||||
|
{isSimpleStyle && <HuePicker
|
||||||
|
accentHue={settings?.accentHue ?? 142}
|
||||||
|
isDark={effectiveDark}
|
||||||
|
onChange={hue => settings?.setAccentHue(hue)}
|
||||||
|
/>}
|
||||||
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
|
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
|
||||||
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
|
||||||
|
<NavDropdown.Item onClick={() => navigate(NAVRHY_URL)}>Návrhy na vylepšení</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
|
||||||
|
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
|
||||||
|
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
|
||||||
|
<NavDropdown.Item onClick={() => navigate(OBJEDNANI_URL)}>Objednání</NavDropdown.Item>
|
||||||
|
<NavDropdown.Item onClick={() => {
|
||||||
|
getChangelogs().then(response => {
|
||||||
|
const entries = response.data ?? {};
|
||||||
|
setChangelogEntries(entries);
|
||||||
|
setChangelogModalOpen(true);
|
||||||
|
const dates = Object.keys(entries).sort((a, b) => b.localeCompare(a));
|
||||||
|
if (dates.length > 0) {
|
||||||
|
localStorage.setItem(LAST_SEEN_CHANGELOG_KEY, dates[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}>Novinky</NavDropdown.Item>
|
||||||
|
{IS_DEV && (
|
||||||
|
<>
|
||||||
|
<NavDropdown.Divider />
|
||||||
|
<NavDropdown.Item onClick={() => setGenerateMockModalOpen(true)}>🔧 Generovat mock data</NavDropdown.Item>
|
||||||
|
<NavDropdown.Item onClick={() => setClearMockModalOpen(true)}>🔧 Smazat mock data</NavDropdown.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<NavDropdown.Divider />
|
<NavDropdown.Divider />
|
||||||
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
|
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
|
||||||
</NavDropdown>
|
</NavDropdown>
|
||||||
</Nav>
|
</Nav>
|
||||||
</Navbar.Collapse>
|
</Navbar.Collapse>
|
||||||
|
<ReactionDuel />
|
||||||
|
<SnakeGame />
|
||||||
|
<TetrisGame />
|
||||||
|
<PongGame />
|
||||||
|
<DrawingGame />
|
||||||
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
|
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
|
||||||
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
|
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
||||||
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
|
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
|
||||||
|
{choices && settings?.bankAccount && settings?.holderName && (
|
||||||
|
<GenerateQrModal
|
||||||
|
isOpen={qrModalOpen}
|
||||||
|
onClose={closeQrModal}
|
||||||
|
choices={choices}
|
||||||
|
bankAccount={settings.bankAccount}
|
||||||
|
bankAccountHolder={settings.holderName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{IS_DEV && (
|
||||||
|
<>
|
||||||
|
<GenerateMockDataModal
|
||||||
|
isOpen={generateMockModalOpen}
|
||||||
|
onClose={() => setGenerateMockModalOpen(false)}
|
||||||
|
currentDayIndex={dayIndex}
|
||||||
|
/>
|
||||||
|
<ClearMockDataModal
|
||||||
|
isOpen={clearMockModalOpen}
|
||||||
|
onClose={() => setClearMockModalOpen(false)}
|
||||||
|
currentDayIndex={dayIndex}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Modal show={changelogModalOpen} onHide={() => setChangelogModalOpen(false)} size="lg">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>Novinky</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{Object.keys(changelogEntries).sort((a, b) => b.localeCompare(a)).map(date => (
|
||||||
|
<div key={date}>
|
||||||
|
<strong>{formatDateString(date)}</strong>
|
||||||
|
<ul>
|
||||||
|
{changelogEntries[date].map((item, index) => (
|
||||||
|
<li key={index}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Object.keys(changelogEntries).length === 0 && (
|
||||||
|
<p>Žádné novinky.</p>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={() => setChangelogModalOpen(false)}>
|
||||||
|
Zavřít
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Plamen stylu Hell Girl — hoří na černé přes obsah dialogu (.modal-content je relative a ve stylu
|
||||||
|
// Hell Girl ořezává přetečení), pak černá odezní a odhalí obsah.
|
||||||
|
.hg-burn {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: #020204; // barva pozadí GIFu, aby kolem plamene nebyl vidět obdélník
|
||||||
|
pointer-events: none;
|
||||||
|
animation: hg-burn-reveal 1.5s ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hg-burn-flame {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
// Dlouhý dialog může přesahovat obrazovku — plamen držet ve viditelné části
|
||||||
|
top: min(50%, 45vh);
|
||||||
|
height: min(85%, 60vh);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hg-burn-reveal {
|
||||||
|
0%, 50% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { createSymbolMask, loadHellGirlImages } from "../utils/hellGirlImages";
|
||||||
|
import "./HellGirlFlames.scss";
|
||||||
|
|
||||||
|
/** Plamen vzplane a rozpadne se v jiskry za ~1,2 s, pak GIF drží černý snímek — mezitím se obsah vynoří z černé. */
|
||||||
|
const BURN_MS = 1500;
|
||||||
|
|
||||||
|
/** `target` = obsah dialogu, ve kterém se hoří. */
|
||||||
|
type Burn = { src: string, target: HTMLElement };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doplňky stylu Hell Girl: při otevření dialogu jeho obsah zčerná, uprostřed jednou
|
||||||
|
* vzplane plamen („Ippen, shinde miru?") a obsah se pak z černé vynoří. Dále symbol ohně ze znělky
|
||||||
|
* u titulku dne (nastaví CSS proměnnou --hg-symbol, dokud se nenačte, zůstávají v titulku původní květy).
|
||||||
|
*/
|
||||||
|
const HellGirlFlames: React.FC = () => {
|
||||||
|
const [burning, setBurning] = useState<Burn>();
|
||||||
|
const flameBlobRef = useRef<Blob | undefined>(undefined);
|
||||||
|
|
||||||
|
const burn = (target: HTMLElement) => {
|
||||||
|
const blob = flameBlobRef.current;
|
||||||
|
if (!blob || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
|
// Pokaždé nová URL — jinak by prohlížeč GIF (bez opakování) znovu nepřehrál
|
||||||
|
setBurning({ src: URL.createObjectURL(blob), target });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!burning) return;
|
||||||
|
const timeout = setTimeout(() => setBurning(undefined), BURN_MS);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
URL.revokeObjectURL(burning.src);
|
||||||
|
};
|
||||||
|
}, [burning]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const root = document.documentElement;
|
||||||
|
loadHellGirlImages().then(images => {
|
||||||
|
if (!images || cancelled) return;
|
||||||
|
fetch(images.flame)
|
||||||
|
.then(r => r.ok ? r.blob() : undefined)
|
||||||
|
.then(blob => { flameBlobRef.current = blob; })
|
||||||
|
.catch(() => { /* bez plamenů */ });
|
||||||
|
createSymbolMask(images.symbol).then(mask => {
|
||||||
|
if (!mask || cancelled) return;
|
||||||
|
root.style.setProperty('--hg-symbol', `url(${mask})`);
|
||||||
|
root.setAttribute('data-hg-symbol', '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
root.style.removeProperty('--hg-symbol');
|
||||||
|
root.removeAttribute('data-hg-symbol');
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Dialogy (react-bootstrap) se vkládají přes portál přímo do <body>
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new MutationObserver(mutations => {
|
||||||
|
const modalContent = mutations
|
||||||
|
.flatMap(m => Array.from(m.addedNodes))
|
||||||
|
.map(node => node instanceof HTMLElement ? node.querySelector<HTMLElement>('.modal-content') : null)
|
||||||
|
.find(element => !!element);
|
||||||
|
if (modalContent) burn(modalContent);
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!burning) return null;
|
||||||
|
return createPortal(
|
||||||
|
<div className="hg-burn" aria-hidden="true">
|
||||||
|
<img className="hg-burn-flame" src={burning.src} alt="" />
|
||||||
|
</div>,
|
||||||
|
burning.target,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HellGirlFlames;
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
.hue-picker-dropdown {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.dropdown-toggle {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
color: var(--luncher-navbar-text) !important;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--luncher-radius-sm);
|
||||||
|
transition: var(--luncher-transition);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hue-picker-panel {
|
||||||
|
padding: 0 !important;
|
||||||
|
min-width: 240px;
|
||||||
|
|
||||||
|
.hue-picker-inner {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hue-picker-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--luncher-text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hue-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
hsl(0 70% 50%), hsl(30 70% 50%), hsl(60 70% 50%), hsl(90 70% 50%),
|
||||||
|
hsl(120 70% 50%), hsl(150 70% 50%), hsl(180 70% 50%), hsl(210 70% 50%),
|
||||||
|
hsl(240 70% 50%), hsl(270 70% 50%), hsl(300 70% 50%), hsl(330 70% 50%), hsl(360 70% 50%)
|
||||||
|
);
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.25);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.25);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hue-presets {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
|
||||||
|
.hue-swatch {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--luncher-text);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hue-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid var(--luncher-border);
|
||||||
|
|
||||||
|
.hue-preview-chip {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--luncher-radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--luncher-text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Dropdown } from 'react-bootstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPalette } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import './HuePicker.scss';
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{ hue: 142, label: 'Zelená' },
|
||||||
|
{ hue: 217, label: 'Modrá' },
|
||||||
|
{ hue: 263, label: 'Fialová' },
|
||||||
|
{ hue: 0, label: 'Červená' },
|
||||||
|
{ hue: 28, label: 'Oranžová' },
|
||||||
|
{ hue: 340, label: 'Růžová' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
accentHue: number;
|
||||||
|
isDark: boolean;
|
||||||
|
onChange: (hue: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function swatchColor(hue: number, isDark: boolean): string {
|
||||||
|
return `hsl(${hue} 70% ${isDark ? 55 : 38}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HuePicker({ accentHue, isDark, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<Dropdown align="end" autoClose="outside" className="hue-picker-dropdown">
|
||||||
|
<Dropdown.Toggle
|
||||||
|
as="button"
|
||||||
|
className="theme-toggle"
|
||||||
|
aria-label="Barva zvýraznění"
|
||||||
|
title="Barva zvýraznění"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPalette} />
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
<Dropdown.Menu className="hue-picker-panel">
|
||||||
|
<div className="hue-picker-inner">
|
||||||
|
<div className="hue-picker-label">Barva zvýraznění</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={360}
|
||||||
|
value={accentHue}
|
||||||
|
onChange={e => onChange(parseInt(e.target.value, 10))}
|
||||||
|
className="hue-slider"
|
||||||
|
aria-label="Odstín barvy zvýraznění"
|
||||||
|
/>
|
||||||
|
<div className="hue-presets">
|
||||||
|
{PRESETS.map(p => (
|
||||||
|
<button
|
||||||
|
key={p.hue}
|
||||||
|
className={`hue-swatch${accentHue === p.hue ? ' active' : ''}`}
|
||||||
|
style={{ background: swatchColor(p.hue, isDark) }}
|
||||||
|
title={p.label}
|
||||||
|
onClick={() => onChange(p.hue)}
|
||||||
|
aria-label={p.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="hue-preview">
|
||||||
|
<div
|
||||||
|
className="hue-preview-chip"
|
||||||
|
style={{ background: swatchColor(accentHue, isDark) }}
|
||||||
|
/>
|
||||||
|
<span>Aktuální barva zvýraznění</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,18 +2,20 @@ import { IconDefinition } from '@fortawesome/free-solid-svg-icons';
|
|||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title?: String,
|
title?: string,
|
||||||
icon: IconDefinition,
|
icon: IconDefinition,
|
||||||
description: String,
|
description: string,
|
||||||
animation?: String,
|
animation?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
function Loader(props: Props) {
|
function Loader(props: Readonly<Props>) {
|
||||||
return <div className='loader'>
|
return (
|
||||||
<h1>{props.title || 'Prosím čekejte...'}</h1>
|
<div className='loader'>
|
||||||
<FontAwesomeIcon icon={props.icon} className={`loader-icon mb-3 ` + (props.animation ?? '')} />
|
<FontAwesomeIcon icon={props.icon} className={`loader-icon ${props.animation ?? ''}`} />
|
||||||
<p>{props.description}</p>
|
<h2 className='loader-title'>{props.title ?? 'Prosím čekejte...'}</h2>
|
||||||
|
<p className='loader-description'>{props.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Loader;
|
export default Loader;
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// Kompaktní výběr z nabídky podniku v tabulce skupinové objednávky
|
||||||
|
.menu-hint-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 200px;
|
||||||
|
|
||||||
|
.select-search-container {
|
||||||
|
// Barvy knihovny napojené na témata aplikace (jinak bílé pozadí i v tmavém režimu)
|
||||||
|
--select-search-background: var(--luncher-bg-card);
|
||||||
|
--select-search-border: var(--luncher-border);
|
||||||
|
--select-search-text: var(--luncher-text);
|
||||||
|
--select-search-subtle-text: var(--luncher-text-muted);
|
||||||
|
--select-search-highlight: var(--luncher-bg-hover);
|
||||||
|
--select-search-selected: var(--luncher-primary);
|
||||||
|
--select-search-inverted-text: #fff;
|
||||||
|
--select-search-font: inherit;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.select-search-input {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-width: 1px;
|
||||||
|
background: var(--luncher-bg);
|
||||||
|
color: var(--luncher-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seznam přes okraje tabulky/karty (jinak by ho karta ořízla)
|
||||||
|
.select-search-select {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
left: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-search-options, .select-search-group ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-search-option {
|
||||||
|
height: auto;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--luncher-text);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jídlo na limitu kusů — nejde přidat
|
||||||
|
.select-search-option:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-search-group-header {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-hint-items {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-hint-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 1px 4px 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--luncher-bg-hover);
|
||||||
|
border: 1px solid var(--luncher-border);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: var(--luncher-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0 4px;
|
||||||
|
color: var(--luncher-text-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--luncher-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-hint-price {
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-hint-use {
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--luncher-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||||
|
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from "react-select-search";
|
||||||
|
import { StoreMenuDish } from "../../../types";
|
||||||
|
import { countNoteItems, matchNoteToMenu, noteItemQuantity, splitNoteItems } from "../utils/menuMatch";
|
||||||
|
import { useGroupLimits } from "../hooks/useGroupLimits";
|
||||||
|
import "./MenuHintCell.scss";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Celá nabídka podniku (stažená z Bolt Food/Wolt), undefined = načítá se / není */
|
||||||
|
menu?: StoreMenuDish[],
|
||||||
|
note?: string,
|
||||||
|
/** Zadaná částka člena v haléřích */
|
||||||
|
amount?: number,
|
||||||
|
canEdit: boolean,
|
||||||
|
onPick: (dish: StoreMenuDish) => void,
|
||||||
|
onUsePrice: (priceKc: number) => void,
|
||||||
|
/** Odebere jeden kus položky poznámky na daném indexu (a odečte její cenu) */
|
||||||
|
onRemoveItem: (index: number) => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDish = (d: StoreMenuDish) => d.price != null ? `${d.name} (${d.price} Kč)` : d.name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buňka „Z nabídky" ve skupinové objednávce: výběr položky ze stažené nabídky podniku
|
||||||
|
* (doplní poznámku i částku) a kontrola ceny rozpoznané z poznámky proti zadané částce.
|
||||||
|
*/
|
||||||
|
export default function MenuHintCell({ menu, note, amount, canEdit, onPick, onUsePrice, onRemoveItem }: Readonly<Props>) {
|
||||||
|
const { maxItemQuantity, maxItems } = useGroupLimits();
|
||||||
|
const options = useMemo<SelectSearchOption[]>(() => {
|
||||||
|
if (!menu?.length) return [];
|
||||||
|
const groups = new Map<string, SelectSearchOption[]>();
|
||||||
|
menu.forEach((d, index) => {
|
||||||
|
const category = d.category || 'Ostatní';
|
||||||
|
if (!groups.has(category)) groups.set(category, []);
|
||||||
|
// Jídlo, kterého už je v objednávce maximum kusů, nejde přidat znovu
|
||||||
|
const atLimit = noteItemQuantity(note, d.name, menu) >= maxItemQuantity;
|
||||||
|
groups.get(category)!.push({ name: atLimit ? `${formatDish(d)} — max ${maxItemQuantity} ks` : formatDish(d), value: String(index), disabled: atLimit });
|
||||||
|
});
|
||||||
|
return [...groups.entries()].map(([name, items]) => ({ name, type: 'group', items }));
|
||||||
|
}, [menu, note, maxItemQuantity]);
|
||||||
|
const itemLimitReached = useMemo(() => countNoteItems(note, menu ?? []) >= maxItems, [note, menu, maxItems]);
|
||||||
|
|
||||||
|
const match = useMemo(() => menu ? matchNoteToMenu(note, menu) : undefined, [menu, note]);
|
||||||
|
const items = useMemo(() => splitNoteItems(note, menu), [note, menu]);
|
||||||
|
|
||||||
|
if (!menu?.length) {
|
||||||
|
return <small className="text-muted">—</small>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuPrice = match?.total;
|
||||||
|
const differs = menuPrice != null && amount != null && Math.round(menuPrice * 100) !== amount;
|
||||||
|
const unmatched = match?.parts.filter(p => !p.dish).map(p => p.text) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="menu-hint-cell">
|
||||||
|
{canEdit && itemLimitReached && (
|
||||||
|
<small className="text-muted">Dosažen limit {maxItems} položek — nejdřív něco odeber (×).</small>
|
||||||
|
)}
|
||||||
|
{canEdit && !itemLimitReached && (
|
||||||
|
<SelectSearch
|
||||||
|
search
|
||||||
|
options={options}
|
||||||
|
placeholder="Přidat z nabídky…"
|
||||||
|
value=""
|
||||||
|
onBlur={() => { }}
|
||||||
|
onFocus={() => { }}
|
||||||
|
onChange={(value: SelectedOptionValue | SelectedOptionValue[]) => {
|
||||||
|
const dish = menu[Number(value)];
|
||||||
|
if (dish) onPick(dish);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canEdit && items.length > 0 && (
|
||||||
|
<div className="menu-hint-items">
|
||||||
|
{items.map((text, i) => {
|
||||||
|
// Rozpoznaná položka ze stejného rozdělení poznámky — kvůli ceně na čipu
|
||||||
|
const part = match && match.parts.length === items.length ? match.parts[i] : undefined;
|
||||||
|
const price = part?.dish?.price;
|
||||||
|
const quantity = part?.quantity ?? 1;
|
||||||
|
return (
|
||||||
|
<span key={`${i}-${text}`} className="menu-hint-item">
|
||||||
|
{text}{price != null && <small> {quantity > 1 ? `${quantity}× ${price} = ${quantity * price}` : price} Kč</small>}
|
||||||
|
<button type="button" onClick={() => onRemoveItem(i)} title={`Odebrat 1× ${text}`} aria-label={`Odebrat ${text}`}>×</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{match && (
|
||||||
|
<OverlayTrigger
|
||||||
|
placement="top"
|
||||||
|
overlay={
|
||||||
|
<Tooltip>
|
||||||
|
{match.parts.map((p, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
{p.dish
|
||||||
|
? `${p.quantity > 1 ? `${p.quantity}× ` : ''}${formatDish(p.dish)}`
|
||||||
|
: `„${p.text}" — v nabídce nenalezeno`}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<small className={`menu-hint-price ${differs ? 'text-warning' : 'text-muted'}`}>
|
||||||
|
{menuPrice != null
|
||||||
|
? <>Dle nabídky: <strong>{menuPrice} Kč</strong></>
|
||||||
|
: <>Nalezeno {match.parts.length - unmatched.length}/{match.parts.length} položek</>}
|
||||||
|
{differs && canEdit && (
|
||||||
|
<button type="button" className="menu-hint-use" onClick={() => onUsePrice(menuPrice!)}
|
||||||
|
title="Nastavit částku podle ceny v nabídce">
|
||||||
|
použít
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</small>
|
||||||
|
</OverlayTrigger>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
.tracking-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
max-width: 400px;
|
||||||
|
|
||||||
|
.tracking-step {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 64px;
|
||||||
|
|
||||||
|
.tracking-dot {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--luncher-border, #ced4da);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-label {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 0.7em;
|
||||||
|
color: var(--luncher-text-muted, #6c757d);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spojnice k předchozímu kroku – vede od středu této tečky doleva ke středu předchozí.
|
||||||
|
// Tečka má průměr 12px, takže její střed je 6px od levého okraje segmentu.
|
||||||
|
&:not(:first-child)::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
left: calc(6px - 100%);
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--luncher-border, #ced4da);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.done {
|
||||||
|
.tracking-dot {
|
||||||
|
background: var(--bs-success, #198754);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:first-child)::before {
|
||||||
|
background: var(--bs-success, #198754);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active .tracking-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulzování aktivního kroku, dokud sledování běží
|
||||||
|
&.live .tracking-step.active .tracking-dot {
|
||||||
|
animation: tracking-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tracking-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 5px rgba(25, 135, 84, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
|
||||||
|
import { TrackingProvider } from '../../../types';
|
||||||
|
import './OrderProgress.scss';
|
||||||
|
|
||||||
|
const STEPS = ['Přijato', 'Příprava', 'Vyzvedávání', 'Na cestě', 'Doručeno'];
|
||||||
|
|
||||||
|
/** Lidský název rozvozové služby do tooltipu. */
|
||||||
|
export const PROVIDER_LABEL: Record<TrackingProvider, string> = {
|
||||||
|
bolt: 'Bolt Food',
|
||||||
|
wolt: 'Wolt',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Známé stavy objednávky z Bolt API → index kroku ve stepperu.
|
||||||
|
* Pozor: waiting_delivery znamená "jídlo čeká v podniku na vyzvednutí",
|
||||||
|
* nikoli "na cestě" — tu signalizuje až stav kurýra (picked_up apod.).
|
||||||
|
* Krok „Vyzvedávání" je vyhrazen pro kombinaci „kurýr u podniku + jídlo hotové"
|
||||||
|
* (viz logika níže), samotný order_state ho neudělí — jinak bychom hlásili
|
||||||
|
* vyzvedávání i když se ještě peče (chování, na které si Bolt sám občas stěžuje).
|
||||||
|
*/
|
||||||
|
const BOLT_STATE_TO_STEP: Record<string, number> = {
|
||||||
|
created: 0,
|
||||||
|
pending: 0,
|
||||||
|
waiting_acceptance: 0,
|
||||||
|
accepted: 0,
|
||||||
|
waiting_preparation: 0,
|
||||||
|
preparing: 1,
|
||||||
|
waiting_delivery: 1,
|
||||||
|
ready_for_pickup: 1,
|
||||||
|
waiting_courier: 1,
|
||||||
|
waiting_pickup: 1,
|
||||||
|
picked_up: 3,
|
||||||
|
in_delivery: 3,
|
||||||
|
delivering: 3,
|
||||||
|
heading_to_client: 3,
|
||||||
|
delivered: 4,
|
||||||
|
finished: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stavy kurýra z Bolt API → index kroku. Kurýr u podniku ještě neznamená "na cestě". */
|
||||||
|
const BOLT_COURIER_STATE_TO_STEP: Record<string, number> = {
|
||||||
|
matched: 0,
|
||||||
|
accepted: 0,
|
||||||
|
heading_to_provider: 1,
|
||||||
|
arrived_to_provider: 1,
|
||||||
|
picked_up: 3,
|
||||||
|
heading_to_client: 3,
|
||||||
|
delivering: 3,
|
||||||
|
arrived_to_client: 3,
|
||||||
|
delivered: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Známé stavy objednávky z Wolt tracking API → index kroku ve stepperu.
|
||||||
|
* Pozor: Wolt hlásí stav podniku, ne rozvozu — "ready" (jídlo hotové) zůstává
|
||||||
|
* i celou dobu, co kurýr objednávku veze. Kroky „Vyzvedávání" a „Na cestě" proto
|
||||||
|
* neurčuje status, ale odvozený stav kurýra (viz trackingProviders.ts).
|
||||||
|
*/
|
||||||
|
const WOLT_STATE_TO_STEP: Record<string, number> = {
|
||||||
|
received: 0,
|
||||||
|
acknowledged: 0,
|
||||||
|
confirmed: 0,
|
||||||
|
waiting: 0,
|
||||||
|
production: 1,
|
||||||
|
preparing: 1,
|
||||||
|
ready: 1,
|
||||||
|
picked_up: 3,
|
||||||
|
transport: 3,
|
||||||
|
delivering: 3,
|
||||||
|
delivered: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Popis odvozeného stavu kurýra u Woltu do tooltipu. */
|
||||||
|
const WOLT_COURIER_INFO: Record<string, string> = {
|
||||||
|
assigned: 'kurýr přiřazen',
|
||||||
|
delivering: 'kurýr veze objednávku',
|
||||||
|
delivering_other: 'kurýr veze objednávku, ale nejdřív doručuje jinou',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stavy, ve kterých už jídlo čeká připravené na kurýra. */
|
||||||
|
const PICKUP_READY_STATES: Record<TrackingProvider, Set<string>> = {
|
||||||
|
bolt: new Set(['waiting_delivery', 'ready_for_pickup', 'waiting_courier', 'waiting_pickup']),
|
||||||
|
wolt: new Set(['ready']),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Neznámé stavy se mapují heuristicky podle klíčových slov. */
|
||||||
|
function stepForOrderState(provider: TrackingProvider, state: string): number | 'cancelled' {
|
||||||
|
const s = state.toLowerCase();
|
||||||
|
const known = provider === 'wolt' ? WOLT_STATE_TO_STEP : BOLT_STATE_TO_STEP;
|
||||||
|
if (s in known) return known[s];
|
||||||
|
if (/cancel|reject|fail/.test(s)) return 'cancelled';
|
||||||
|
if (/delivered|finished/.test(s)) return 4;
|
||||||
|
if (/accept|pending|created|received/.test(s)) return 0;
|
||||||
|
if (/^waiting|prepar|ready|cook|production/.test(s)) return 1;
|
||||||
|
if (/picked|delivering|heading_to_client|transport/.test(s)) return 3;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepForCourierState(provider: TrackingProvider, state?: string): number {
|
||||||
|
if (!state) return 0;
|
||||||
|
const s = state.toLowerCase();
|
||||||
|
// Wolt má jen odvozené stavy: 'assigned' o kroku nic neříká, 'delivering'
|
||||||
|
// i 'delivering_other' znamenají, že kurýr naši objednávku už veze
|
||||||
|
if (provider === 'wolt') return s.startsWith('delivering') ? 3 : 0;
|
||||||
|
if (s in BOLT_COURIER_STATE_TO_STEP) return BOLT_COURIER_STATE_TO_STEP[s];
|
||||||
|
if (/picked|client|delivering|transport/.test(s)) return 3;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Rozvozová služba, ze které stav pochází */
|
||||||
|
provider: TrackingProvider;
|
||||||
|
/** Raw stav objednávky (Bolt order_state, Wolt status) */
|
||||||
|
state: string;
|
||||||
|
/** Raw stav kurýra (Bolt courier.state, Wolt 'assigned') */
|
||||||
|
courierState?: string;
|
||||||
|
/** Zda sledování stále běží (skupina má trackingCode) */
|
||||||
|
tracking: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mini progress stepper se stavem objednávky u rozvozové služby. */
|
||||||
|
export default function OrderProgress({ provider, state, courierState, tracking }: Props) {
|
||||||
|
const label = PROVIDER_LABEL[provider] ?? provider;
|
||||||
|
const orderStep = stepForOrderState(provider, state);
|
||||||
|
if (orderStep === 'cancelled') {
|
||||||
|
return <small className="text-danger">Objednávka {label} byla zrušena</small>;
|
||||||
|
}
|
||||||
|
// Stav kurýra může krok jen zpřesnit dopředu (např. waiting_delivery + picked_up → Na cestě)
|
||||||
|
let step = Math.max(orderStep, stepForCourierState(provider, courierState));
|
||||||
|
// Vyzvedávání = kurýr u podniku a jídlo skutečně hotové. Sám arrived_to_provider
|
||||||
|
// (bez toho, že by jídlo bylo hotové) nestačí — viz komentář u BOLT_STATE_TO_STEP.
|
||||||
|
const courierAtProvider = provider === 'wolt'
|
||||||
|
? !!courierState
|
||||||
|
: courierState?.toLowerCase() === 'arrived_to_provider';
|
||||||
|
const foodReadyForPickup = PICKUP_READY_STATES[provider]?.has(state.toLowerCase());
|
||||||
|
if (step < 3 && courierAtProvider && foodReadyForPickup) step = 2;
|
||||||
|
const courierInfo = provider === 'wolt'
|
||||||
|
? WOLT_COURIER_INFO[courierState ?? ''] ?? 'kurýr přiřazen'
|
||||||
|
: `kurýr: ${courierState}`;
|
||||||
|
const rawInfo = courierState ? `${state} / ${courierInfo}` : state;
|
||||||
|
return (
|
||||||
|
<OverlayTrigger overlay={<Tooltip>Stav z {label}: {rawInfo}</Tooltip>}>
|
||||||
|
<div className={`tracking-progress${tracking && step < 4 ? ' live' : ''}`}>
|
||||||
|
{STEPS.map((stepLabel, i) => (
|
||||||
|
<div key={stepLabel} className={`tracking-step${i <= step ? ' done' : ''}${i === step ? ' active' : ''}`}>
|
||||||
|
<div className="tracking-dot" />
|
||||||
|
<div className="tracking-label">{stepLabel}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</OverlayTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Button, Modal } from 'react-bootstrap';
|
||||||
|
import { PendingQr, dismissQr } from '../../../types';
|
||||||
|
import { formatDateString } from '../Utils';
|
||||||
|
import ConfirmModal from './modals/ConfirmModal';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
pendingQrs?: PendingQr[];
|
||||||
|
login?: string;
|
||||||
|
// Zavolá se po úspěšném potvrzení platby, aby si rodič mohl znovu načíst data
|
||||||
|
onDismissed?: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sekce "Nevyřízené platby" – zobrazí QR kódy neuhrazených plateb přihlášeného uživatele
|
||||||
|
// včetně tlačítka "Zaplatil jsem" a potvrzovacího dialogu. Sdíleno hlavní stránkou i stránkou objednávek.
|
||||||
|
// Při příchodu nových nevyřízených plateb se navíc automaticky otevře modální dialog,
|
||||||
|
// aby si uživatel QR kódů určitě všiml (často si jich nevšimnou, protože sekce je dole na stránce).
|
||||||
|
export default function PendingPayments({ pendingQrs, login, onDismissed }: Readonly<Props>) {
|
||||||
|
const [dismissQrId, setDismissQrId] = useState<string | null>(null);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// ID QR kódů, pro které už byl v rámci tohoto načtení stránky automaticky zobrazen
|
||||||
|
// modální dialog. Drží se jen v paměti (ne v sessionStorage), takže se při každém
|
||||||
|
// ručním přenačtení stránky vynuluje a dialog se znovu otevře, dokud uživatel platby
|
||||||
|
// neuhradí. Zároveň se nepřekrývá při pouhém obnovení dat či příchodu už zobrazeného QR.
|
||||||
|
const autoShownQrIds = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const qrIdsKey = (pendingQrs ?? []).map(qr => qr.id).join(',');
|
||||||
|
|
||||||
|
// Automaticky otevřeme modální dialog, jakmile přijdou nové (dosud nezobrazené) platby.
|
||||||
|
useEffect(() => {
|
||||||
|
const ids = (pendingQrs ?? []).map(qr => qr.id);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const unseen = ids.filter(id => !autoShownQrIds.current.has(id));
|
||||||
|
if (unseen.length > 0) {
|
||||||
|
setModalOpen(true);
|
||||||
|
unseen.forEach(id => autoShownQrIds.current.add(id));
|
||||||
|
}
|
||||||
|
}, [qrIdsKey, pendingQrs]);
|
||||||
|
|
||||||
|
if (!pendingQrs || pendingQrs.length === 0) return null;
|
||||||
|
|
||||||
|
// Vykreslení jednoho QR kódu i s tlačítkem "Zaplatil jsem" – sdíleno sekcí i modálem.
|
||||||
|
const renderQr = (qr: PendingQr) => (
|
||||||
|
<div key={qr.id} className='qr-code mb-3'>
|
||||||
|
<p>
|
||||||
|
<strong>{formatDateString(qr.date)}</strong> — {qr.creator} ({qr.totalPrice / 100} Kč)
|
||||||
|
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
|
||||||
|
</p>
|
||||||
|
<img src={`/api/qr?login=${login}&id=${qr.id}`} alt='QR kód' />
|
||||||
|
<div className='mt-2'>
|
||||||
|
<Button variant="success" onClick={() => setDismissQrId(qr.id)}>
|
||||||
|
Zaplatil jsem
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='pizza-section fade-in mt-4'>
|
||||||
|
<h3>Nevyřízené platby</h3>
|
||||||
|
<p>
|
||||||
|
Máte neuhrazené platby.{' '}
|
||||||
|
<Button variant="link" className="p-0 align-baseline" onClick={() => setModalOpen(true)}>
|
||||||
|
Zobrazit QR kódy
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
{pendingQrs.map(renderQr)}
|
||||||
|
</div>
|
||||||
|
<Modal show={modalOpen} onHide={() => setModalOpen(false)} centered scrollable>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>Nevyřízené platby</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<p>Máte neuhrazené platby. Naskenujte QR kód pro zaplacení.</p>
|
||||||
|
{pendingQrs.map(renderQr)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={() => setModalOpen(false)}>
|
||||||
|
Zavřít
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={dismissQrId !== null}
|
||||||
|
title="Potvrzení platby"
|
||||||
|
message="Opravdu jste zaplatili? QR kód bude odstraněn."
|
||||||
|
confirmLabel="Zaplatil jsem"
|
||||||
|
confirmVariant="success"
|
||||||
|
onClose={() => setDismissQrId(null)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!dismissQrId) return;
|
||||||
|
const id = dismissQrId;
|
||||||
|
setDismissQrId(null);
|
||||||
|
await dismissQr({ body: { id } });
|
||||||
|
await onDismissed?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,46 +1,57 @@
|
|||||||
import { Table } from "react-bootstrap";
|
import { Table } from "react-bootstrap";
|
||||||
import { Order, PizzaDayState, PizzaOrder } from "../../../types";
|
|
||||||
import PizzaOrderRow from "./PizzaOrderRow";
|
import PizzaOrderRow from "./PizzaOrderRow";
|
||||||
import { updatePizzaFee } from "../api/PizzaDayApi";
|
import { PizzaDayState, PizzaOrder, PizzaVariant, updatePizzaFee } from "../../../types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
state: PizzaDayState,
|
state: PizzaDayState,
|
||||||
orders: Order[],
|
orders: PizzaOrder[],
|
||||||
onDelete: (pizzaOrder: PizzaOrder) => void,
|
onDelete: (pizzaOrder: PizzaVariant) => void,
|
||||||
creator: string,
|
creator: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PizzaOrderList({ state, orders, onDelete, creator }: Props) {
|
export default function PizzaOrderList({ state, orders, onDelete, creator }: Readonly<Props>) {
|
||||||
const saveFees = async (customer: string, text?: string, price?: number) => {
|
const saveFees = async (customer: string, text?: string, price?: number) => {
|
||||||
await updatePizzaFee(customer, text, price);
|
await updatePizzaFee({ body: { login: customer, text, price } });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!orders?.length) {
|
if (!orders?.length) {
|
||||||
return <p className="mt-3"><i>Zatím žádné objednávky...</i></p>
|
return <p className="mt-4" style={{ color: 'var(--luncher-text-muted)', fontStyle: 'italic' }}>Zatím žádné objednávky...</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = orders.reduce((total, order) => total + order.totalPrice, 0);
|
const total = orders.reduce((total, order) => total + order.totalPrice, 0);
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
<Table className="mt-3" striped bordered hover>
|
<div className="mt-4" style={{
|
||||||
<thead>
|
background: 'var(--luncher-bg-card)',
|
||||||
|
borderRadius: 'var(--luncher-radius-lg)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid var(--luncher-border-light)',
|
||||||
|
boxShadow: 'var(--luncher-shadow)'
|
||||||
|
}}>
|
||||||
|
<Table className="mb-0" style={{ color: 'var(--luncher-text)' }}>
|
||||||
|
<thead style={{ background: 'var(--luncher-primary-light)' }}>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Jméno</th>
|
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Jméno</th>
|
||||||
<th>Objednávka</th>
|
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Objednávka</th>
|
||||||
<th>Poznámka</th>
|
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Poznámka</th>
|
||||||
<th>Příplatek</th>
|
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Příplatek</th>
|
||||||
<th>Cena</th>
|
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none', textAlign: 'right' }}>Cena</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{orders.map(order => <tr key={order.customer}>
|
{orders.map(order => <tr key={order.customer} style={{ borderColor: 'var(--luncher-border-light)' }}>
|
||||||
<PizzaOrderRow creator={creator} state={state} order={order} onDelete={onDelete} onFeeModalSave={saveFees} />
|
<PizzaOrderRow creator={creator} state={state} order={order} onDelete={onDelete} onFeeModalSave={saveFees} />
|
||||||
</tr>)}
|
</tr>)}
|
||||||
<tr style={{ fontWeight: 'bold' }}>
|
<tr style={{
|
||||||
<td colSpan={4}>Celkem</td>
|
fontWeight: 700,
|
||||||
<td>{`${total} Kč`}</td>
|
background: 'var(--luncher-bg-hover)',
|
||||||
|
borderTop: '2px solid var(--luncher-border)'
|
||||||
|
}}>
|
||||||
|
<td colSpan={4} style={{ padding: '16px 20px', border: 'none' }}>Celkem</td>
|
||||||
|
<td style={{ padding: '16px 20px', border: 'none', textAlign: 'right', color: 'var(--luncher-primary)' }}>{`${total / 100} Kč`}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</>
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -2,44 +2,46 @@ import React, { useState } from "react";
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faMoneyBill1, faTrashCan } from "@fortawesome/free-regular-svg-icons";
|
import { faMoneyBill1, faTrashCan } from "@fortawesome/free-regular-svg-icons";
|
||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import { Order, PizzaDayState, PizzaOrder } from "../../../types";
|
|
||||||
import PizzaAdditionalFeeModal from "./modals/PizzaAdditionalFeeModal";
|
import PizzaAdditionalFeeModal from "./modals/PizzaAdditionalFeeModal";
|
||||||
|
import { PizzaDayState, PizzaOrder, PizzaVariant } from "../../../types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
creator: string,
|
creator: string,
|
||||||
order: Order,
|
order: PizzaOrder,
|
||||||
state: PizzaDayState,
|
state: PizzaDayState,
|
||||||
onDelete: (order: PizzaOrder) => void,
|
onDelete: (order: PizzaVariant) => void,
|
||||||
onFeeModalSave: (customer: string, name?: string, price?: number) => void,
|
onFeeModalSave: (customer: string, name?: string, price?: number) => void,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeModalSave }: Props) {
|
export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeModalSave }: Readonly<Props>) {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const [isFeeModalOpen, setFeeModalOpen] = useState<boolean>(false);
|
const [isFeeModalOpen, setIsFeeModalOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
const saveFees = (customer: string, text?: string, price?: number) => {
|
const saveFees = (customer: string, text?: string, price?: number) => {
|
||||||
onFeeModalSave(customer, text, price);
|
onFeeModalSave(customer, text, price);
|
||||||
setFeeModalOpen(false);
|
setIsFeeModalOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<td>{order.customer}</td>
|
<td>{order.customer}</td>
|
||||||
<td>{order.pizzaList.map<React.ReactNode>((pizzaOrder, index) =>
|
<td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder =>
|
||||||
<span key={index}>
|
<span key={pizzaOrder.name}>
|
||||||
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
|
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price / 100} Kč)`}
|
||||||
{auth?.login === order.customer && state === PizzaDayState.CREATED &&
|
{auth?.login === order.customer && state === PizzaDayState.CREATED &&
|
||||||
|
<span title='Odstranit'>
|
||||||
<FontAwesomeIcon onClick={() => {
|
<FontAwesomeIcon onClick={() => {
|
||||||
onDelete(pizzaOrder);
|
onDelete(pizzaOrder);
|
||||||
}} title='Odstranit' className='action-icon' icon={faTrashCan} />
|
}} className='action-icon' icon={faTrashCan} />
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
</span>)
|
</span>)
|
||||||
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
|
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ maxWidth: "200px" }}>{order.note || '-'}</td>
|
<td style={{ maxWidth: "200px" }}>{order.note ?? '-'}</td>
|
||||||
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price} Kč${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
|
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price / 100} Kč${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
{order.totalPrice} Kč{auth?.login === creator && state === PizzaDayState.CREATED && <FontAwesomeIcon onClick={() => { setFeeModalOpen(true) }} title='Nastavit příplatek' className='action-icon' icon={faMoneyBill1} />}
|
{order.totalPrice / 100} Kč{auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>}
|
||||||
</td>
|
</td>
|
||||||
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} />
|
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price != null ? String(order.fee.price / 100) : undefined }} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Pozvánka Pongu nad pozvánkami ostatních her, aby se nepřekrývaly
|
||||||
|
.duel-invite.pong-invite {
|
||||||
|
bottom: 172px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pong-game-modal {
|
||||||
|
.pong-scoreboard {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 24px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pong-board-wrap {
|
||||||
|
position: relative;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pong-board {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pong-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&.small {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pong-bot-picker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pong-status {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button, Form, Modal } from "react-bootstrap";
|
||||||
|
import { socket } from "../context/socket";
|
||||||
|
import { useAuth } from "../context/auth";
|
||||||
|
import { getToken } from "../Utils";
|
||||||
|
import { useGameIcon } from "../games/themeIcons";
|
||||||
|
import "./PongGame.scss";
|
||||||
|
|
||||||
|
// Typy musí odpovídat server/src/pongGame.ts (stav chodí přes websocket, ne přes OpenAPI)
|
||||||
|
type PongPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
|
||||||
|
type BotLevel = 'easy' | 'medium' | 'hard' | 'impossible';
|
||||||
|
type PongPlayer = { login: string, side: 0 | 1, y: number, score: number, bot?: BotLevel };
|
||||||
|
type PongState = {
|
||||||
|
phase: PongPhase,
|
||||||
|
host?: string,
|
||||||
|
players: PongPlayer[],
|
||||||
|
ball: { x: number, y: number },
|
||||||
|
countdown?: number,
|
||||||
|
winner?: string,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPEN_EVENT = 'luncher:open-pong';
|
||||||
|
|
||||||
|
/** Otevře okno Pongu. */
|
||||||
|
export const openPongGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
|
||||||
|
|
||||||
|
// Rozměry musí odpovídat serveru
|
||||||
|
const PADDLE_HEIGHT = 90;
|
||||||
|
const PADDLE_WIDTH = 12;
|
||||||
|
const PADDLE_MARGIN = 20;
|
||||||
|
const BALL_SIZE = 12;
|
||||||
|
const SIDE_COLORS = ['#60a5fa', '#f472b6'];
|
||||||
|
|
||||||
|
/** Jak často se posílá poloha myši (ms). */
|
||||||
|
const AIM_SEND_MS = 30;
|
||||||
|
const BOT_LEVELS: { value: BotLevel, label: string }[] = [
|
||||||
|
{ value: 'easy', label: 'Lehký' },
|
||||||
|
{ value: 'medium', label: 'Střední' },
|
||||||
|
{ value: 'hard', label: 'Těžký' },
|
||||||
|
{ value: 'impossible', label: 'Nemožný' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const UP_KEYS = new Set(['ArrowUp', 'KeyW']);
|
||||||
|
const DOWN_KEYS = new Set(['ArrowDown', 'KeyS']);
|
||||||
|
|
||||||
|
function draw(canvas: HTMLCanvasElement, state: PongState, me?: string) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
const scale = canvas.width / state.width;
|
||||||
|
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
||||||
|
ctx.fillStyle = '#111827';
|
||||||
|
ctx.fillRect(0, 0, state.width, state.height);
|
||||||
|
// Středová čára
|
||||||
|
ctx.fillStyle = '#374151';
|
||||||
|
for (let y = 0; y < state.height; y += 30) ctx.fillRect(state.width / 2 - 2, y + 5, 4, 18);
|
||||||
|
for (const p of state.players) {
|
||||||
|
const x = p.side === 0 ? PADDLE_MARGIN : state.width - PADDLE_MARGIN - PADDLE_WIDTH;
|
||||||
|
ctx.fillStyle = SIDE_COLORS[p.side];
|
||||||
|
ctx.fillRect(x, p.y - PADDLE_HEIGHT / 2, PADDLE_WIDTH, PADDLE_HEIGHT);
|
||||||
|
if (p.login === me) {
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeRect(x - 1, p.y - PADDLE_HEIGHT / 2 - 1, PADDLE_WIDTH + 2, PADDLE_HEIGHT + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.phase === 'playing') {
|
||||||
|
ctx.fillStyle = '#f9fafb';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(state.ball.x, state.ball.y, BALL_SIZE / 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pong 1v1 — fyziku drží server, klient posílá jen směr pálky. */
|
||||||
|
export default function PongGame() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const login = auth?.login;
|
||||||
|
const [state, setState] = useState<PongState | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const prevPhase = useRef<PongPhase>('idle');
|
||||||
|
const icon = useGameIcon('pong');
|
||||||
|
const [botLevel, setBotLevel] = useState<BotLevel>('medium');
|
||||||
|
/** Výška hřiště pro přepočet myši — přes ref, ať se efekt nerestartuje každým snímkem. */
|
||||||
|
const fieldHeight = useRef(500);
|
||||||
|
fieldHeight.current = state?.height ?? fieldHeight.current;
|
||||||
|
|
||||||
|
const send = useCallback((event: string, extra?: object) => {
|
||||||
|
setError(null);
|
||||||
|
socket.emit(event, { token: getToken(), ...extra });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onState = (s: PongState) => setState(s);
|
||||||
|
const onError = (msg: string) => setError(msg);
|
||||||
|
const onOpen = () => setOpen(true);
|
||||||
|
const sync = () => socket.emit('pong:sync');
|
||||||
|
socket.on('pong:state', onState);
|
||||||
|
socket.on('pong:error', onError);
|
||||||
|
socket.on('connect', sync);
|
||||||
|
window.addEventListener(OPEN_EVENT, onOpen);
|
||||||
|
sync();
|
||||||
|
return () => {
|
||||||
|
socket.off('pong:state', onState);
|
||||||
|
socket.off('pong:error', onError);
|
||||||
|
socket.off('connect', sync);
|
||||||
|
window.removeEventListener(OPEN_EVENT, onOpen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Snímky hry dostávají jen klienti s otevřeným oknem
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const watch = () => socket.emit('pong:watch');
|
||||||
|
watch();
|
||||||
|
socket.on('connect', watch);
|
||||||
|
return () => {
|
||||||
|
socket.off('connect', watch);
|
||||||
|
socket.emit('pong:unwatch');
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
|
||||||
|
const phase = state?.phase ?? 'idle';
|
||||||
|
const inGame = phase === 'countdown' || phase === 'playing';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevPhase.current === 'lobby' && phase === 'countdown' && isPlayer) {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
prevPhase.current = phase;
|
||||||
|
}, [phase, isPlayer]);
|
||||||
|
|
||||||
|
// Ovládání: drží se klávesa → pálka jede, puštění → stojí
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !inGame || !isPlayer) return;
|
||||||
|
const pressed = new Set<string>();
|
||||||
|
const update = () => {
|
||||||
|
const up = [...pressed].some(k => UP_KEYS.has(k));
|
||||||
|
const down = [...pressed].some(k => DOWN_KEYS.has(k));
|
||||||
|
send('pong:input', { input: up === down ? 'stop' : up ? 'up' : 'down' });
|
||||||
|
};
|
||||||
|
const onDown = (e: KeyboardEvent) => {
|
||||||
|
if (!UP_KEYS.has(e.code) && !DOWN_KEYS.has(e.code)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.repeat || pressed.has(e.code)) return;
|
||||||
|
pressed.add(e.code);
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
const onUp = (e: KeyboardEvent) => {
|
||||||
|
if (!pressed.delete(e.code)) return;
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
const onBlur = () => {
|
||||||
|
pressed.clear();
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onDown);
|
||||||
|
window.addEventListener('keyup', onUp);
|
||||||
|
window.addEventListener('blur', onBlur);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onDown);
|
||||||
|
window.removeEventListener('keyup', onUp);
|
||||||
|
window.removeEventListener('blur', onBlur);
|
||||||
|
};
|
||||||
|
}, [open, inGame, isPlayer, send]);
|
||||||
|
|
||||||
|
// Ovládání myší: pálka sleduje výšku kurzoru nad hřištěm (kdekoli v okně, mimo hřiště se ořízne)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !inGame || !isPlayer) return;
|
||||||
|
let latest: number | undefined;
|
||||||
|
let sent: number | undefined;
|
||||||
|
const onMove = (e: PointerEvent) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const height = fieldHeight.current;
|
||||||
|
const y = ((e.clientY - rect.top) / rect.height) * height;
|
||||||
|
latest = Math.round(Math.min(height, Math.max(0, y)));
|
||||||
|
};
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
if (latest === undefined || latest === sent) return;
|
||||||
|
sent = latest;
|
||||||
|
send('pong:aim', { y: latest });
|
||||||
|
}, AIM_SEND_MS);
|
||||||
|
window.addEventListener('pointermove', onMove);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(id);
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
};
|
||||||
|
}, [open, inGame, isPlayer, send]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canvasRef.current && state && phase !== 'idle' && phase !== 'lobby') {
|
||||||
|
draw(canvasRef.current, state, login);
|
||||||
|
}
|
||||||
|
}, [state, phase, login]);
|
||||||
|
|
||||||
|
if (!login) return null;
|
||||||
|
|
||||||
|
const left = state?.players.find(p => p.side === 0);
|
||||||
|
const right = state?.players.find(p => p.side === 1);
|
||||||
|
const showInvite = !open && phase === 'lobby' && !isPlayer && (state?.players.length ?? 0) < 2;
|
||||||
|
const showReturn = !open && isPlayer && inGame;
|
||||||
|
|
||||||
|
const renderBoard = () => state && (
|
||||||
|
<>
|
||||||
|
<div className="pong-scoreboard">
|
||||||
|
<span style={{ color: SIDE_COLORS[0] }}>{left?.login}</span>
|
||||||
|
<strong>{left?.score ?? 0} : {right?.score ?? 0}</strong>
|
||||||
|
<span style={{ color: SIDE_COLORS[1] }}>{right?.login}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pong-board-wrap">
|
||||||
|
<canvas ref={canvasRef} className="pong-board" width={state.width} height={state.height} />
|
||||||
|
{phase === 'countdown' && <div className="pong-overlay">{state.countdown}</div>}
|
||||||
|
{phase === 'finished' && <div className="pong-overlay small">🏆 {state.winner}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="pong-status">{isPlayer ? 'Myš, nebo ↑↓ / W S' : 'Sledujete zápas'}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBody = () => {
|
||||||
|
switch (phase) {
|
||||||
|
case 'idle':
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
Klasický Pong pro dva. Ovládání myší (pálka sleduje kurzor) nebo šipkami / W S, hraje se
|
||||||
|
do 5 bodů. Když není s kým hrát, přidejte si v lobby bota. Ostatní se mohou dívat.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
case 'lobby':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>Lobby založil(a) <strong>{state?.host}</strong>.</p>
|
||||||
|
<ul>{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}</ul>
|
||||||
|
{(state?.players.length ?? 0) < 2 && <p className="text-muted">Čeká se na soupeře…</p>}
|
||||||
|
{login === state?.host && (state?.players.length ?? 0) < 2 && (
|
||||||
|
<div className="pong-bot-picker">
|
||||||
|
<span>Nebo hrát proti botovi:</span>
|
||||||
|
<Form.Select size="sm" value={botLevel} onChange={e => setBotLevel(e.target.value as BotLevel)}>
|
||||||
|
{BOT_LEVELS.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
|
||||||
|
</Form.Select>
|
||||||
|
<Button size="sm" variant="outline-primary" onClick={() => send('pong:addBot', { level: botLevel })}>
|
||||||
|
Přidat bota
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{login === state?.host && state?.players.some(p => p.bot) && (
|
||||||
|
<Button size="sm" variant="outline-secondary" onClick={() => send('pong:removeBot')}>Odebrat bota</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return renderBoard();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
const buttons = [];
|
||||||
|
if (phase === 'idle' || phase === 'finished') {
|
||||||
|
buttons.push(<Button key="new" onClick={() => send('pong:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
|
||||||
|
}
|
||||||
|
if (phase === 'lobby') {
|
||||||
|
if (!isPlayer) {
|
||||||
|
if ((state?.players.length ?? 0) < 2) {
|
||||||
|
buttons.push(<Button key="join" onClick={() => send('pong:join')}>Připojit se</Button>);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('pong:leave')}>
|
||||||
|
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
|
||||||
|
</Button>);
|
||||||
|
if (login === state?.host) {
|
||||||
|
buttons.push(<Button key="start" onClick={() => send('pong:start')}
|
||||||
|
disabled={(state?.players.length ?? 0) < 2}>Spustit hru</Button>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
||||||
|
return buttons;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showInvite && (
|
||||||
|
<button className="duel-invite pong-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} <strong>{state?.host}</strong> hledá soupeře na Pong — připojit se
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showReturn && (
|
||||||
|
<button className="duel-invite pong-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} Pong běží — zpět do hry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Modal show={open} onHide={() => setOpen(false)} size="xl" className="pong-game-modal">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>{icon} Pong</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{renderBody()}
|
||||||
|
{error && <div className="text-danger mt-2">{error}</div>}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>{renderFooter()}</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
.price-history {
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-left: 3px solid var(--luncher-border);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 2px 0 6px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-tag {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 72px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-up .price-tag { color: var(--luncher-danger); }
|
||||||
|
.price-down .price-tag { color: var(--luncher-success); }
|
||||||
|
.price-new .price-tag { color: var(--luncher-primary); }
|
||||||
|
.price-removed { color: var(--luncher-text-muted); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-history-entry + .price-history-entry {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-history-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--luncher-bg-hover);
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--luncher-text-secondary);
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--luncher-bg-card);
|
||||||
|
color: var(--luncher-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { getStoreMenuHistory, StoreMenuProvider, StorePriceChange, StorePriceHistoryEntry } from "../../../types";
|
||||||
|
import { getHumanDateTime } from "../Utils";
|
||||||
|
import { computeSinceStart, plural, SinceStart } from "../utils/priceHistory";
|
||||||
|
import "./PriceHistory.scss";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
store: string,
|
||||||
|
provider: StoreMenuProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Popis jedné změny: zdražení/zlevnění s rozdílem, nová nebo odebraná položka. */
|
||||||
|
function ChangeRow({ change }: Readonly<{ change: StorePriceChange }>) {
|
||||||
|
const { name, before, after } = change;
|
||||||
|
if (before == null && after != null) {
|
||||||
|
return <li className="price-new"><span className="price-tag">nové</span> {name} <strong>{after} Kč</strong></li>;
|
||||||
|
}
|
||||||
|
if (after == null) {
|
||||||
|
return <li className="price-removed"><span className="price-tag">odebráno</span> <s>{name}</s>{before != null && <> ({before} Kč)</>}</li>;
|
||||||
|
}
|
||||||
|
const diff = after - (before ?? after);
|
||||||
|
const up = diff > 0;
|
||||||
|
return (
|
||||||
|
<li className={up ? 'price-up' : 'price-down'}>
|
||||||
|
<span className="price-tag">{up ? '▲' : '▼'} {up ? '+' : ''}{diff} Kč</span> {name}: {before} → <strong>{after} Kč</strong>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntryRow({ entry }: Readonly<{ entry: StorePriceHistoryEntry }>) {
|
||||||
|
const when = getHumanDateTime(new Date(entry.at));
|
||||||
|
if (entry.initial) {
|
||||||
|
return <div className="price-history-entry"><small className="text-muted">{when} — první stažení ({plural(entry.itemCount ?? 0, 'položka', 'položky', 'položek')})</small></div>;
|
||||||
|
}
|
||||||
|
const ups = entry.changes.filter(c => c.before != null && c.after != null && c.after > c.before).length;
|
||||||
|
const downs = entry.changes.filter(c => c.before != null && c.after != null && c.after < c.before).length;
|
||||||
|
return (
|
||||||
|
<div className="price-history-entry">
|
||||||
|
<small className="text-muted">
|
||||||
|
{when} — {plural(entry.changes.length, 'změna', 'změny', 'změn')}{ups > 0 && `, ${ups}× zdražení`}{downs > 0 && `, ${downs}× zlevnění`}
|
||||||
|
</small>
|
||||||
|
<ul>{entry.changes.map((c, i) => <ChangeRow key={i} change={c} />)}</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SinceStartView({ summary }: Readonly<{ summary: SinceStart }>) {
|
||||||
|
const { since, changed, added, removed } = summary;
|
||||||
|
if (changed.length + added.length + removed.length === 0) {
|
||||||
|
return <small className="text-muted">Od {getHumanDateTime(new Date(since))} beze změn.</small>;
|
||||||
|
}
|
||||||
|
const ups = changed.filter(c => c.current > c.start).length;
|
||||||
|
const avgPct = changed.length
|
||||||
|
? changed.reduce((sum, c) => sum + (c.current - c.start) / c.start * 100, 0) / changed.length
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<div className="price-history-entry">
|
||||||
|
<small className="text-muted">
|
||||||
|
Od {getHumanDateTime(new Date(since))}: {plural(changed.length, 'změna ceny', 'změny cen', 'změn cen')} ({ups}× zdražení, {changed.length - ups}× zlevnění)
|
||||||
|
{changed.length > 0 && `, průměrně ${avgPct > 0 ? '+' : ''}${avgPct.toFixed(1)} %`}
|
||||||
|
{added.length > 0 && `, ${plural(added.length, 'nová položka', 'nové položky', 'nových položek')}`}{removed.length > 0 && `, ${plural(removed.length, 'odebraná', 'odebrané', 'odebraných')}`}
|
||||||
|
</small>
|
||||||
|
<ul>
|
||||||
|
{changed.map(c => {
|
||||||
|
const diff = c.current - c.start;
|
||||||
|
const up = diff > 0;
|
||||||
|
return (
|
||||||
|
<li key={c.name} className={up ? 'price-up' : 'price-down'}>
|
||||||
|
<span className="price-tag">{up ? '▲' : '▼'} {up ? '+' : ''}{diff} Kč</span> {c.name}: {c.start} → <strong>{c.current} Kč</strong>
|
||||||
|
<small className="text-muted"> ({up ? '+' : ''}{(diff / c.start * 100).toFixed(0)} %)</small>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{added.map(a => <li key={`+${a.name}`} className="price-new"><span className="price-tag">nové</span> {a.name}{a.price != null && <> <strong>{a.price} Kč</strong></>}</li>)}
|
||||||
|
{removed.map(r => <li key={`-${r.name}`} className="price-removed"><span className="price-tag">odebráno</span> <s>{r.name}</s>{r.price != null && <> ({r.price} Kč)</>}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Historie cen nabídky obchodu u jedné služby (verze z jednotlivých stažení, nejnovější nahoře). */
|
||||||
|
export default function PriceHistory({ store, provider }: Readonly<Props>) {
|
||||||
|
const [entries, setEntries] = useState<StorePriceHistoryEntry[]>();
|
||||||
|
const [view, setView] = useState<'versions' | 'since'>('versions');
|
||||||
|
const summary = useMemo(() => entries ? computeSinceStart(entries) : undefined, [entries]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getStoreMenuHistory({ query: { name: store } })
|
||||||
|
.then(r => setEntries(r.data?.providers.find(p => p.provider === provider)?.entries ?? []))
|
||||||
|
.catch(() => setEntries([]));
|
||||||
|
}, [store, provider]);
|
||||||
|
|
||||||
|
if (!entries) return <small className="text-muted">Načítám historii…</small>;
|
||||||
|
if (entries.length === 0) return <small className="text-muted">Historie cen zatím není — vznikne s prvním stažením nabídky.</small>;
|
||||||
|
const hasChanges = entries.some(e => e.changes.length > 0);
|
||||||
|
return (
|
||||||
|
<div className="price-history">
|
||||||
|
{hasChanges && (
|
||||||
|
<div className="price-history-tabs">
|
||||||
|
<button type="button" className={view === 'versions' ? 'active' : ''} onClick={() => setView('versions')}>Po verzích</button>
|
||||||
|
<button type="button" className={view === 'since' ? 'active' : ''} onClick={() => setView('since')}>Od začátku</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{view === 'since' && summary
|
||||||
|
? <SinceStartView summary={summary} />
|
||||||
|
: entries.map(e => <EntryRow key={e.at} entry={e} />)}
|
||||||
|
{entries.length === 1 && entries[0].initial && (
|
||||||
|
<small className="text-muted d-block mt-1">Změny se objeví po dalším stažení (↻).</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
.duel-invite {
|
||||||
|
position: fixed;
|
||||||
|
left: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 1040;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--luncher-primary, #198754);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
|
||||||
|
cursor: pointer;
|
||||||
|
animation: duel-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes duel-pulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.04); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-duel-modal {
|
||||||
|
.duel-arena {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 280px;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #fff;
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
transition: background-color 0.05s;
|
||||||
|
|
||||||
|
&.waiting { background: #b02a37; }
|
||||||
|
&.go { background: #198754; }
|
||||||
|
&.done { background: #495057; }
|
||||||
|
&.early { background: #fd7e14; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-round {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-signal {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-hint {
|
||||||
|
opacity: 0.85;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-players {
|
||||||
|
columns: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-podium {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-place {
|
||||||
|
flex: 0 1 160px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bs-tertiary-bg);
|
||||||
|
|
||||||
|
// Vítěz uprostřed a nejvýš, jako na stupních vítězů
|
||||||
|
&.place-1 { order: 2; padding-top: 28px; }
|
||||||
|
&.place-2 { order: 1; }
|
||||||
|
&.place-3 { order: 3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-medal {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-place-name {
|
||||||
|
font-weight: 700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-place-time {
|
||||||
|
opacity: 0.75;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-last {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duel-results .duel-loser td {
|
||||||
|
background: rgba(220, 53, 69, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nenápadné potvrzení, že logo přijalo sérii kliknutí a čeká na tajné slovo
|
||||||
|
.navbar-brand.duel-armed {
|
||||||
|
display: inline-block;
|
||||||
|
animation: duel-wiggle 0.35s ease-in-out 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes duel-wiggle {
|
||||||
|
0%, 100% { transform: rotate(0); }
|
||||||
|
25% { transform: rotate(-4deg); }
|
||||||
|
75% { transform: rotate(4deg); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button, Modal, Table } from "react-bootstrap";
|
||||||
|
import { socket } from "../context/socket";
|
||||||
|
import { useAuth } from "../context/auth";
|
||||||
|
import { getToken } from "../Utils";
|
||||||
|
import { useGameIcon } from "../games/themeIcons";
|
||||||
|
import "./ReactionDuel.scss";
|
||||||
|
|
||||||
|
// Typy musí odpovídat server/src/reactionDuel.ts (stav chodí přes websocket, ne přes OpenAPI)
|
||||||
|
type DuelPhase = 'idle' | 'lobby' | 'waiting' | 'go' | 'roundResult' | 'finished';
|
||||||
|
type RoundResult = { ms: number | null, falseStart?: boolean };
|
||||||
|
type DuelPlayer = { login: string, rounds: RoundResult[], total: number };
|
||||||
|
type DuelState = {
|
||||||
|
phase: DuelPhase,
|
||||||
|
host?: string,
|
||||||
|
round: number,
|
||||||
|
totalRounds: number,
|
||||||
|
players: DuelPlayer[],
|
||||||
|
loser?: string,
|
||||||
|
winner?: string,
|
||||||
|
minPlayers: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPEN_EVENT = 'luncher:open-duel';
|
||||||
|
|
||||||
|
/** Otevře okno reakční hry. */
|
||||||
|
export const openReactionDuel = () => window.dispatchEvent(new Event(OPEN_EVENT));
|
||||||
|
|
||||||
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
|
|
||||||
|
const formatResult = (r?: RoundResult) => {
|
||||||
|
if (!r) return '—';
|
||||||
|
if (r.falseStart) return 'předčasně';
|
||||||
|
if (r.ms == null) return 'nekliknuto';
|
||||||
|
return `${r.ms} ms`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Reakční hra — lobby, kola a výsledky; časování drží server. */
|
||||||
|
export default function ReactionDuel() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const login = auth?.login;
|
||||||
|
const [state, setState] = useState<DuelState | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
/** Kolo, ve kterém už tento klient kliknul (a zda předčasně). */
|
||||||
|
const [myClick, setMyClick] = useState<{ round: number, early: boolean } | null>(null);
|
||||||
|
const prevPhase = useRef<DuelPhase>('idle');
|
||||||
|
const icon = useGameIcon('reaction');
|
||||||
|
|
||||||
|
const send = useCallback((event: string) => {
|
||||||
|
setError(null);
|
||||||
|
socket.emit(event, { token: getToken() });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onState = (s: DuelState) => setState(s);
|
||||||
|
const onError = (msg: string) => setError(msg);
|
||||||
|
const onOpen = () => setOpen(true);
|
||||||
|
const sync = () => socket.emit('duel:sync');
|
||||||
|
socket.on('duel:state', onState);
|
||||||
|
socket.on('duel:error', onError);
|
||||||
|
socket.on('connect', sync);
|
||||||
|
window.addEventListener(OPEN_EVENT, onOpen);
|
||||||
|
sync();
|
||||||
|
return () => {
|
||||||
|
socket.off('duel:state', onState);
|
||||||
|
socket.off('duel:error', onError);
|
||||||
|
socket.off('connect', sync);
|
||||||
|
window.removeEventListener(OPEN_EVENT, onOpen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
|
||||||
|
const phase = state?.phase ?? 'idle';
|
||||||
|
const inRound = phase === 'waiting' || phase === 'go';
|
||||||
|
|
||||||
|
// Hráčům se okno otevře samo, jakmile hra začne
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevPhase.current === 'lobby' && phase === 'waiting' && isPlayer) {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
prevPhase.current = phase;
|
||||||
|
}, [phase, isPlayer]);
|
||||||
|
|
||||||
|
const click = useCallback(() => {
|
||||||
|
if (!state || !isPlayer || !inRound || myClick?.round === state.round) return;
|
||||||
|
setMyClick({ round: state.round, early: state.phase === 'waiting' });
|
||||||
|
send('duel:click');
|
||||||
|
}, [state, isPlayer, inRound, myClick, send]);
|
||||||
|
|
||||||
|
// Mezerník funguje stejně jako kliknutí
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !inRound) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
e.preventDefault();
|
||||||
|
click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [open, inRound, click]);
|
||||||
|
|
||||||
|
if (!login) return null;
|
||||||
|
|
||||||
|
const showInvite = !open && phase === 'lobby' && !isPlayer;
|
||||||
|
const showReturn = !open && isPlayer && phase !== 'finished';
|
||||||
|
const clickedThisRound = myClick?.round === state?.round;
|
||||||
|
const ranked = [...(state?.players ?? [])].sort((a, b) => a.total - b.total);
|
||||||
|
|
||||||
|
const renderArena = () => {
|
||||||
|
if (!state) return null;
|
||||||
|
let cls = 'waiting';
|
||||||
|
let text = 'Počkej…';
|
||||||
|
if (phase === 'go') {
|
||||||
|
cls = 'go';
|
||||||
|
text = 'TEĎ!';
|
||||||
|
}
|
||||||
|
if (isPlayer && clickedThisRound) {
|
||||||
|
cls = myClick?.early ? 'early' : 'done';
|
||||||
|
text = myClick?.early ? 'Předčasný start! 😬' : 'Kliknuto ✔';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`duel-arena ${cls}`}
|
||||||
|
onPointerDown={click}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="duel-round">Kolo {state.round}/{state.totalRounds}</div>
|
||||||
|
<div className="duel-signal">{text}</div>
|
||||||
|
{isPlayer
|
||||||
|
? <div className="duel-hint">Klikněte nebo zmáčkněte mezerník, až zezelená.</div>
|
||||||
|
: <div className="duel-hint">Sledujete hru.</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderResults = (final: boolean) => (
|
||||||
|
<Table size="sm" className="duel-results">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Hráč</th>
|
||||||
|
{Array.from({ length: state?.totalRounds ?? 0 }, (_, i) => <th key={i}>{i + 1}. kolo</th>)}
|
||||||
|
<th>Celkem</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ranked.map((p, i) => (
|
||||||
|
<tr key={p.login} className={final && p.login === state?.loser ? 'duel-loser' : undefined}>
|
||||||
|
<td>{final && i < 3 ? MEDALS[i] : i + 1}</td>
|
||||||
|
<td>{p.login}{p.login === login && ' (vy)'}</td>
|
||||||
|
{Array.from({ length: state?.totalRounds ?? 0 }, (_, r) => <td key={r}>{formatResult(p.rounds[r])}</td>)}
|
||||||
|
<td><strong>{p.rounds.length ? `${p.total} ms` : '—'}</strong></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBody = () => {
|
||||||
|
switch (phase) {
|
||||||
|
case 'idle':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Hra má {state?.totalRounds ?? 3} kola — po náhodné chvíli se plocha rozsvítí zeleně
|
||||||
|
a počítá se, jak rychle kliknete. Předčasný start nebo nekliknutí znamená trestné 2 s.
|
||||||
|
Vyhrává nejnižší součet časů.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted">Po založení lobby dostanou ostatní online pozvánku.</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'lobby':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
|
||||||
|
<ul className="duel-players">
|
||||||
|
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
|
||||||
|
</ul>
|
||||||
|
{state && state.players.length < state.minPlayers &&
|
||||||
|
<p className="text-muted">Čeká se na další hráče…</p>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'waiting':
|
||||||
|
case 'go':
|
||||||
|
return renderArena();
|
||||||
|
case 'roundResult':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>Kolo {state?.round}/{state?.totalRounds} skončilo, další za chvíli…</p>
|
||||||
|
{renderResults(false)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'finished':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="duel-podium">
|
||||||
|
{ranked.slice(0, 3).map((p, i) => (
|
||||||
|
<div key={p.login} className={`duel-place place-${i + 1}`}>
|
||||||
|
<div className="duel-medal">{MEDALS[i]}</div>
|
||||||
|
<div className="duel-place-name">{p.login}</div>
|
||||||
|
<div className="duel-place-time">{p.total} ms</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{state?.loser && ranked.length > 3 &&
|
||||||
|
<p className="duel-last">🐢 Poslední: <strong>{state.loser}</strong></p>}
|
||||||
|
{renderResults(true)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
const buttons = [];
|
||||||
|
if (phase === 'idle' || phase === 'finished') {
|
||||||
|
buttons.push(<Button key="new" onClick={() => send('duel:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
|
||||||
|
}
|
||||||
|
if (phase === 'lobby') {
|
||||||
|
if (!isPlayer) {
|
||||||
|
buttons.push(<Button key="join" onClick={() => send('duel:join')}>Připojit se</Button>);
|
||||||
|
} else {
|
||||||
|
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('duel:leave')}>
|
||||||
|
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
|
||||||
|
</Button>);
|
||||||
|
if (login === state?.host) {
|
||||||
|
buttons.push(<Button key="start" onClick={() => send('duel:start')}
|
||||||
|
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 2)}>Spustit hru</Button>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
||||||
|
return buttons;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showInvite && (
|
||||||
|
<button className="duel-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} <strong>{state?.host}</strong> zve na reakční hru — připojit se
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showReturn && (
|
||||||
|
<button className="duel-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} Reakční hra běží — zpět do hry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Modal show={open} onHide={() => setOpen(false)} size="lg" className="reaction-duel-modal">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>{icon} Reakční hra</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{renderBody()}
|
||||||
|
{error && <div className="text-danger mt-2">{error}</div>}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>{renderFooter()}</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Pozvánka hada nad pozvánkou reakční hry, aby se nepřekrývaly
|
||||||
|
.duel-invite.snake-invite {
|
||||||
|
bottom: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-game-modal {
|
||||||
|
.snake-board-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 768px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-board {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-status {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.75;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-scores {
|
||||||
|
max-width: 768px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-dead td {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-players {
|
||||||
|
columns: 2;
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button, Modal, Table } from "react-bootstrap";
|
||||||
|
import { socket } from "../context/socket";
|
||||||
|
import { useAuth } from "../context/auth";
|
||||||
|
import { getToken } from "../Utils";
|
||||||
|
import { useGameIcon } from "../games/themeIcons";
|
||||||
|
import "./SnakeGame.scss";
|
||||||
|
|
||||||
|
// Typy musí odpovídat server/src/snakeGame.ts (stav chodí přes websocket, ne přes OpenAPI)
|
||||||
|
type Cell = [number, number];
|
||||||
|
type Direction = 'up' | 'down' | 'left' | 'right';
|
||||||
|
type SnakePhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
|
||||||
|
type SnakePlayer = { login: string, color: number, body: Cell[], alive: boolean, score: number, place?: number };
|
||||||
|
type SnakeState = {
|
||||||
|
phase: SnakePhase,
|
||||||
|
host?: string,
|
||||||
|
players: SnakePlayer[],
|
||||||
|
food: Cell[],
|
||||||
|
tick: number,
|
||||||
|
maxTicks: number,
|
||||||
|
countdown?: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
minPlayers: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPEN_EVENT = 'luncher:open-snake';
|
||||||
|
|
||||||
|
/** Otevře okno hada. */
|
||||||
|
export const openSnakeGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
|
||||||
|
|
||||||
|
const COLORS = ['#4ade80', '#60a5fa', '#f472b6', '#facc15', '#a78bfa', '#fb923c', '#2dd4bf', '#f87171'];
|
||||||
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
|
const TICK_MS = 120;
|
||||||
|
|
||||||
|
const KEY_DIRECTIONS: Record<string, Direction> = {
|
||||||
|
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
|
||||||
|
KeyW: 'up', KeyS: 'down', KeyA: 'left', KeyD: 'right',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Vykreslí hrací plochu do canvasu. */
|
||||||
|
function draw(canvas: HTMLCanvasElement, state: SnakeState, me?: string) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
const cell = canvas.width / state.width;
|
||||||
|
ctx.fillStyle = '#111827';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle = '#1f2937';
|
||||||
|
for (let x = 0; x < state.width; x++) {
|
||||||
|
for (let y = 0; y < state.height; y++) {
|
||||||
|
if ((x + y) % 2 === 0) ctx.fillRect(x * cell, y * cell, cell, cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [x, y] of state.food) {
|
||||||
|
ctx.fillStyle = '#ef4444';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc((x + 0.5) * cell, (y + 0.5) * cell, cell * 0.35, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
for (const p of state.players) {
|
||||||
|
const color = COLORS[p.color % COLORS.length];
|
||||||
|
// Tělo tlumeně, hlava se kreslí zvlášť až nakonec
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.globalAlpha = 0.65;
|
||||||
|
p.body.slice(1).forEach(([x, y]) => ctx.fillRect(x * cell + 2, y * cell + 2, cell - 4, cell - 4));
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
if (p.body[0]) drawHead(ctx, p.body, cell, color, p.login === me);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hlava: plná barva, zaoblená, přes okraj políčka a s očima ve směru pohybu. */
|
||||||
|
function drawHead(ctx: CanvasRenderingContext2D, body: Cell[], cell: number, color: string, mine: boolean) {
|
||||||
|
const [x, y] = body[0];
|
||||||
|
const neck = body[1];
|
||||||
|
const dx = neck ? Math.sign(x - neck[0]) : 1;
|
||||||
|
const dy = neck ? Math.sign(y - neck[1]) : 0;
|
||||||
|
const cx = (x + 0.5) * cell;
|
||||||
|
const cy = (y + 0.5) * cell;
|
||||||
|
const size = cell * 1.1;
|
||||||
|
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(cx - size / 2, cy - size / 2, size, size, cell * 0.35);
|
||||||
|
ctx.fill();
|
||||||
|
// Vlastní had má bílý obrys hlavy, ostatní tmavý — hlava se tak odliší od těla i pozadí
|
||||||
|
ctx.strokeStyle = mine ? '#fff' : '#0b0f19';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Oči: posunuté ve směru pohybu, rozložené kolmo na něj
|
||||||
|
const forward = cell * 0.18;
|
||||||
|
const side = cell * 0.22;
|
||||||
|
for (const s of [-1, 1]) {
|
||||||
|
const ex = cx + dx * forward + -dy * side * s;
|
||||||
|
const ey = cy + dy * forward + dx * side * s;
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(ex, ey, cell * 0.14, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(ex + dx * cell * 0.05, ey + dy * cell * 0.05, cell * 0.07, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multiplayerový had — lobby, hrací plocha a výsledky; simulaci drží server. */
|
||||||
|
export default function SnakeGame() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const login = auth?.login;
|
||||||
|
const [state, setState] = useState<SnakeState | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const prevPhase = useRef<SnakePhase>('idle');
|
||||||
|
const icon = useGameIcon('snake');
|
||||||
|
|
||||||
|
const send = useCallback((event: string, extra?: object) => {
|
||||||
|
setError(null);
|
||||||
|
socket.emit(event, { token: getToken(), ...extra });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onState = (s: SnakeState) => setState(s);
|
||||||
|
const onError = (msg: string) => setError(msg);
|
||||||
|
const onOpen = () => setOpen(true);
|
||||||
|
const sync = () => socket.emit('snake:sync');
|
||||||
|
socket.on('snake:state', onState);
|
||||||
|
socket.on('snake:error', onError);
|
||||||
|
socket.on('connect', sync);
|
||||||
|
window.addEventListener(OPEN_EVENT, onOpen);
|
||||||
|
sync();
|
||||||
|
return () => {
|
||||||
|
socket.off('snake:state', onState);
|
||||||
|
socket.off('snake:error', onError);
|
||||||
|
socket.off('connect', sync);
|
||||||
|
window.removeEventListener(OPEN_EVENT, onOpen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Průběžné snímky hry dostávají jen klienti s otevřeným oknem
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const watch = () => socket.emit('snake:watch');
|
||||||
|
watch();
|
||||||
|
socket.on('connect', watch);
|
||||||
|
return () => {
|
||||||
|
socket.off('connect', watch);
|
||||||
|
socket.emit('snake:unwatch');
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
|
||||||
|
const phase = state?.phase ?? 'idle';
|
||||||
|
const inGame = phase === 'countdown' || phase === 'playing';
|
||||||
|
|
||||||
|
// Hráčům se okno otevře samo, jakmile hra začne
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevPhase.current === 'lobby' && phase === 'countdown' && isPlayer) {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
prevPhase.current = phase;
|
||||||
|
}, [phase, isPlayer]);
|
||||||
|
|
||||||
|
// Ovládání šipkami a WSAD
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !inGame || !isPlayer) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const dir = KEY_DIRECTIONS[e.code];
|
||||||
|
if (!dir) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (!e.repeat) send('snake:turn', { dir });
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [open, inGame, isPlayer, send]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canvasRef.current && state && (inGame || phase === 'finished')) {
|
||||||
|
draw(canvasRef.current, state, login);
|
||||||
|
}
|
||||||
|
}, [state, inGame, phase, login]);
|
||||||
|
|
||||||
|
if (!login) return null;
|
||||||
|
|
||||||
|
const showInvite = !open && phase === 'lobby' && !isPlayer;
|
||||||
|
const showReturn = !open && isPlayer && inGame;
|
||||||
|
const ranked = [...(state?.players ?? [])].sort((a, b) => (a.place ?? 99) - (b.place ?? 99) || b.score - a.score);
|
||||||
|
const secondsLeft = state ? Math.max(0, Math.ceil((state.maxTicks - state.tick) * TICK_MS / 1000)) : 0;
|
||||||
|
|
||||||
|
const renderBoard = () => state && (
|
||||||
|
<div className="snake-board-wrap">
|
||||||
|
<canvas ref={canvasRef} className="snake-board" width={state.width * 24} height={state.height * 24} />
|
||||||
|
{phase === 'countdown' && <div className="snake-overlay">{state.countdown}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderScores = () => (
|
||||||
|
<Table size="sm" className="snake-scores">
|
||||||
|
<thead>
|
||||||
|
<tr><th>#</th><th>Hráč</th><th>Jídla</th><th>Délka</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ranked.map((p, i) => (
|
||||||
|
<tr key={p.login} className={p.alive || phase !== 'playing' ? undefined : 'snake-dead'}>
|
||||||
|
<td>{phase === 'finished' && i < 3 ? MEDALS[i] : (phase === 'finished' ? i + 1 : '')}</td>
|
||||||
|
<td>
|
||||||
|
<span className="snake-dot" style={{ background: COLORS[p.color % COLORS.length] }} />
|
||||||
|
{p.login}{p.login === login && ' (vy)'}{!p.alive && inGame && ' 💀'}
|
||||||
|
</td>
|
||||||
|
<td>{p.score}</td>
|
||||||
|
<td>{p.body.length || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBody = () => {
|
||||||
|
switch (phase) {
|
||||||
|
case 'idle':
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
Klasický had pro více hráčů. Ovládání šipkami nebo WSAD. Jezte jídlo a vyhněte se zdem,
|
||||||
|
sobě i ostatním — čelní srážka zabije oba. Vyhrává ten, kdo přežije nejdéle;
|
||||||
|
hra trvá nejvýš 3 minuty. Hrát se dá i sólo.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
case 'lobby':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
|
||||||
|
<ul className="snake-players">
|
||||||
|
{state?.players.map(p => (
|
||||||
|
<li key={p.login}>
|
||||||
|
<span className="snake-dot" style={{ background: COLORS[p.color % COLORS.length] }} />
|
||||||
|
{p.login}{p.login === state.host && ' 👑'}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'countdown':
|
||||||
|
case 'playing':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderBoard()}
|
||||||
|
<div className="snake-status">
|
||||||
|
{isPlayer ? 'Šipky / WSAD' : 'Sledujete hru'} · zbývá {secondsLeft} s
|
||||||
|
</div>
|
||||||
|
{renderScores()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'finished':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderBoard()}
|
||||||
|
{renderScores()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
const buttons = [];
|
||||||
|
if (phase === 'idle' || phase === 'finished') {
|
||||||
|
buttons.push(<Button key="new" onClick={() => send('snake:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
|
||||||
|
}
|
||||||
|
if (phase === 'lobby') {
|
||||||
|
if (!isPlayer) {
|
||||||
|
buttons.push(<Button key="join" onClick={() => send('snake:join')}>Připojit se</Button>);
|
||||||
|
} else {
|
||||||
|
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('snake:leave')}>
|
||||||
|
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
|
||||||
|
</Button>);
|
||||||
|
if (login === state?.host) {
|
||||||
|
buttons.push(<Button key="start" onClick={() => send('snake:start')}
|
||||||
|
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 1)}>Spustit hru</Button>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
||||||
|
return buttons;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showInvite && (
|
||||||
|
<button className="duel-invite snake-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} <strong>{state?.host}</strong> zve na hada — připojit se
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showReturn && (
|
||||||
|
<button className="duel-invite snake-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} Had běží — zpět do hry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Modal show={open} onHide={() => setOpen(false)} size="xl" className="snake-game-modal">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>{icon} Had</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{renderBody()}
|
||||||
|
{error && <div className="text-danger mt-2">{error}</div>}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>{renderFooter()}</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Pozvánka Tetrisu nad pozvánkami ostatních her, aby se nepřekrývaly
|
||||||
|
.duel-invite.tetris-invite {
|
||||||
|
bottom: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-game-modal {
|
||||||
|
.tetris-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 24px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-own {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-board-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-board {
|
||||||
|
display: block;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&.small {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-flash {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fca5a5;
|
||||||
|
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-side {
|
||||||
|
min-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-value {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-help {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-opponents {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 440px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-mini {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dead canvas {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-mini-name {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
max-width: 90px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-mini-score {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-players {
|
||||||
|
columns: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tetris-game-modal .tetris-settings {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 640px;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button, Form, Modal, Table } from "react-bootstrap";
|
||||||
|
import { socket } from "../context/socket";
|
||||||
|
import { useAuth } from "../context/auth";
|
||||||
|
import { getToken } from "../Utils";
|
||||||
|
import { TetrisSession, WIDTH, HEIGHT, pieceCells, Piece } from "../games/tetrisEngine";
|
||||||
|
import { useGameIcon } from "../games/themeIcons";
|
||||||
|
import "./TetrisGame.scss";
|
||||||
|
|
||||||
|
// Typy musí odpovídat server/src/tetrisGame.ts (stav chodí přes websocket, ne přes OpenAPI)
|
||||||
|
type TetrisPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
|
||||||
|
type TetrisPlayer = { login: string, board: string, score: number, lines: number, alive: boolean, place?: number };
|
||||||
|
type TetrisTargeting = 'random' | 'even' | 'payback';
|
||||||
|
type TetrisWinMode = 'survival' | 'score';
|
||||||
|
type TetrisSettings = { targeting: TetrisTargeting, winMode: TetrisWinMode, timeLimit: number };
|
||||||
|
type TetrisState = {
|
||||||
|
phase: TetrisPhase,
|
||||||
|
host?: string,
|
||||||
|
players: TetrisPlayer[],
|
||||||
|
seed?: number,
|
||||||
|
countdown?: number,
|
||||||
|
minPlayers: number,
|
||||||
|
settings: TetrisSettings,
|
||||||
|
remainingMs?: number,
|
||||||
|
};
|
||||||
|
type GarbageEvent = { target: string, from: string, lines: number };
|
||||||
|
|
||||||
|
const OPEN_EVENT = 'luncher:open-tetris';
|
||||||
|
|
||||||
|
const TARGETING_LABELS: Record<TetrisTargeting, { label: string, help: string }> = {
|
||||||
|
random: { label: 'Náhodně (Random)', help: 'útok jde náhodnému soupeři' },
|
||||||
|
even: { label: 'Rovnoměrně (Even)', help: 'útok jde soupeři, který zatím dostal nejméně odpadu' },
|
||||||
|
payback: { label: 'Oplácení (Payback)', help: 'útok jde tomu, kdo vás naposledy napadl, jinak rovnoměrně' },
|
||||||
|
};
|
||||||
|
const WIN_MODE_LABELS: Record<TetrisWinMode, string> = {
|
||||||
|
survival: 'Poslední ve hře (Last man standing)',
|
||||||
|
score: 'Nejvyšší skóre',
|
||||||
|
};
|
||||||
|
/** Musí odpovídat TIME_LIMITS na serveru (s). */
|
||||||
|
const TIME_LIMITS = [120, 180, 300];
|
||||||
|
|
||||||
|
const formatTime = (ms: number) => {
|
||||||
|
const total = Math.ceil(ms / 1000);
|
||||||
|
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Otevře okno Tetrisu. */
|
||||||
|
export const openTetrisGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
|
||||||
|
|
||||||
|
/** Barvy kostek I, O, T, S, Z, J, L a odpadu (index = hodnota buňky). */
|
||||||
|
const COLORS = ['', '#22d3ee', '#facc15', '#a855f7', '#22c55e', '#ef4444', '#3b82f6', '#f97316', '#6b7280'];
|
||||||
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
|
const CELL = 26;
|
||||||
|
const MINI_CELL = 9;
|
||||||
|
/** Jak často se soupeřům posílá deska s padající kostkou (ms). */
|
||||||
|
const SNAPSHOT_MS = 250;
|
||||||
|
/**
|
||||||
|
* Deska se posílá i časovačem — běží i v neaktivním tabu (na rozdíl od requestAnimationFrame),
|
||||||
|
* takže server pozná, že hráč pořád hraje. Musí být výrazně kratší než PLAYER_TIMEOUT_MS na serveru.
|
||||||
|
*/
|
||||||
|
const HEARTBEAT_MS = 2000;
|
||||||
|
|
||||||
|
const KEYS: Record<string, 'left' | 'right' | 'down' | 'rotate' | 'rotateBack' | 'drop'> = {
|
||||||
|
ArrowLeft: 'left', KeyA: 'left',
|
||||||
|
ArrowRight: 'right', KeyD: 'right',
|
||||||
|
ArrowDown: 'down', KeyS: 'down',
|
||||||
|
ArrowUp: 'rotate', KeyW: 'rotate', KeyX: 'rotate',
|
||||||
|
KeyZ: 'rotateBack', KeyY: 'rotateBack',
|
||||||
|
Space: 'drop',
|
||||||
|
};
|
||||||
|
|
||||||
|
function drawCell(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, value: number) {
|
||||||
|
ctx.fillStyle = COLORS[value];
|
||||||
|
ctx.fillRect(x * size + 1, y * size + 1, size - 2, size - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vykreslí desku zadanou řetězcem (soupeři) nebo polem (vlastní hra). */
|
||||||
|
function drawBoard(canvas: HTMLCanvasElement, cells: ArrayLike<number | string>, size: number, piece?: Piece | null, ghost?: Piece | null) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.fillStyle = '#111827';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
for (let i = 0; i < WIDTH * HEIGHT; i++) {
|
||||||
|
const v = Number(cells[i]);
|
||||||
|
if (v) drawCell(ctx, i % WIDTH, Math.floor(i / WIDTH), size, v);
|
||||||
|
}
|
||||||
|
if (ghost) {
|
||||||
|
ctx.globalAlpha = 0.25;
|
||||||
|
pieceCells(ghost).forEach(([x, y]) => y >= 0 && drawCell(ctx, x, y, size, ghost.type + 1));
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
if (piece) {
|
||||||
|
pieceCells(piece).forEach(([x, y]) => y >= 0 && drawCell(ctx, x, y, size, piece.type + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Náhled příští kostky. */
|
||||||
|
function drawNext(canvas: HTMLCanvasElement, type: number) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
const cells = pieceCells({ type, rot: 0, x: 0, y: 0 });
|
||||||
|
const minX = Math.min(...cells.map(c => c[0]));
|
||||||
|
const minY = Math.min(...cells.map(c => c[1]));
|
||||||
|
cells.forEach(([x, y]) => drawCell(ctx, x - minX + 0.5, y - minY + 0.5, 20, type + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Malá deska soupeře. */
|
||||||
|
function MiniBoard({ player }: { player: TetrisPlayer }) {
|
||||||
|
const ref = useRef<HTMLCanvasElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) drawBoard(ref.current, player.board, MINI_CELL);
|
||||||
|
}, [player.board]);
|
||||||
|
return (
|
||||||
|
<div className={`tetris-mini ${player.alive ? '' : 'dead'}`}>
|
||||||
|
<canvas ref={ref} width={WIDTH * MINI_CELL} height={HEIGHT * MINI_CELL} />
|
||||||
|
<div className="tetris-mini-name">{player.login}{!player.alive && ' 💀'}</div>
|
||||||
|
<div className="tetris-mini-score">{player.score}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multiplayerový Tetris (battle) — vlastní deska běží v prohlížeči, server koordinuje hru. */
|
||||||
|
export default function TetrisGame() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const login = auth?.login;
|
||||||
|
const [state, setState] = useState<TetrisState | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [flash, setFlash] = useState<string | null>(null);
|
||||||
|
/** Překreslení postranních údajů (skóre, řádky) po změně v session. */
|
||||||
|
const [, setVersion] = useState(0);
|
||||||
|
/** Trénink: hra jen v prohlížeči, bez serveru a soupeřů. */
|
||||||
|
const [practice, setPractice] = useState(false);
|
||||||
|
const sessionRef = useRef<TetrisSession | null>(null);
|
||||||
|
const sessionSeed = useRef<number | undefined>(undefined);
|
||||||
|
const boardRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const nextRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const prevPhase = useRef<TetrisPhase>('idle');
|
||||||
|
/** Konec hry na skóre v čase prohlížeče (ze zbývajícího času, ať nevadí rozdíl hodin se serverem). */
|
||||||
|
const deadline = useRef<number | undefined>(undefined);
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const icon = useGameIcon('tetris');
|
||||||
|
|
||||||
|
const send = useCallback((event: string, extra?: object) => {
|
||||||
|
setError(null);
|
||||||
|
socket.emit(event, { token: getToken(), ...extra });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendSnapshot = useCallback(() => {
|
||||||
|
const s = sessionRef.current;
|
||||||
|
if (s) send('tetris:update', { board: s.snapshot(), score: s.score, lines: s.lines });
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onState = (s: TetrisState) => {
|
||||||
|
deadline.current = s.remainingMs !== undefined ? Date.now() + s.remainingMs : undefined;
|
||||||
|
setState(s);
|
||||||
|
};
|
||||||
|
const onError = (msg: string) => setError(msg);
|
||||||
|
const onOpen = () => setOpen(true);
|
||||||
|
const sync = () => socket.emit('tetris:sync');
|
||||||
|
socket.on('tetris:state', onState);
|
||||||
|
socket.on('tetris:error', onError);
|
||||||
|
socket.on('connect', sync);
|
||||||
|
window.addEventListener(OPEN_EVENT, onOpen);
|
||||||
|
sync();
|
||||||
|
return () => {
|
||||||
|
socket.off('tetris:state', onState);
|
||||||
|
socket.off('tetris:error', onError);
|
||||||
|
socket.off('connect', sync);
|
||||||
|
window.removeEventListener(OPEN_EVENT, onOpen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Průběžné desky soupeřů a útoky dostávají jen klienti s otevřeným oknem
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const watch = () => socket.emit('tetris:watch');
|
||||||
|
watch();
|
||||||
|
socket.on('connect', watch);
|
||||||
|
return () => {
|
||||||
|
socket.off('connect', watch);
|
||||||
|
socket.emit('tetris:unwatch');
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onGarbage = (e: GarbageEvent) => {
|
||||||
|
if (e.target !== login || !sessionRef.current?.alive) return;
|
||||||
|
sessionRef.current.receiveGarbage(e.lines);
|
||||||
|
setFlash(`⚠️ +${e.lines} od ${e.from}`);
|
||||||
|
window.setTimeout(() => setFlash(null), 1500);
|
||||||
|
};
|
||||||
|
socket.on('tetris:garbage', onGarbage);
|
||||||
|
return () => { socket.off('tetris:garbage', onGarbage); };
|
||||||
|
}, [login]);
|
||||||
|
|
||||||
|
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
|
||||||
|
const phase = state?.phase ?? 'idle';
|
||||||
|
const inGame = phase === 'countdown' || phase === 'playing';
|
||||||
|
|
||||||
|
// Hráčům se okno otevře samo, jakmile hra začne
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevPhase.current === 'lobby' && phase === 'countdown' && isPlayer) {
|
||||||
|
setPractice(false);
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
prevPhase.current = phase;
|
||||||
|
}, [phase, isPlayer]);
|
||||||
|
|
||||||
|
// Start vlastní hry se seedem od serveru
|
||||||
|
useEffect(() => {
|
||||||
|
if (practice || phase !== 'playing' || !isPlayer || state?.seed === undefined || sessionSeed.current === state.seed) return;
|
||||||
|
sessionSeed.current = state.seed;
|
||||||
|
sessionRef.current = new TetrisSession(state.seed, {
|
||||||
|
onLock: (cleared, attack) => {
|
||||||
|
if (attack > 0) send('tetris:cleared', { count: cleared, sent: attack });
|
||||||
|
sendSnapshot();
|
||||||
|
setVersion(v => v + 1);
|
||||||
|
},
|
||||||
|
onDeath: () => {
|
||||||
|
sendSnapshot();
|
||||||
|
send('tetris:died');
|
||||||
|
setVersion(v => v + 1);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setVersion(v => v + 1);
|
||||||
|
}, [practice, phase, isPlayer, state?.seed, send, sendSnapshot]);
|
||||||
|
|
||||||
|
// Herní smyčka: gravitace, vykreslení a průběžné posílání desky (trénink se při zavřeném okně pozastaví)
|
||||||
|
useEffect(() => {
|
||||||
|
if (practice ? !open : phase !== 'playing') return;
|
||||||
|
if (!sessionRef.current) return;
|
||||||
|
let raf = 0;
|
||||||
|
let lastFall = performance.now();
|
||||||
|
let lastSnapshot = 0;
|
||||||
|
const loop = (now: number) => {
|
||||||
|
const s = sessionRef.current;
|
||||||
|
if (!s) return;
|
||||||
|
if (s.alive && now - lastFall >= s.gravityMs) {
|
||||||
|
s.tick();
|
||||||
|
lastFall = now;
|
||||||
|
}
|
||||||
|
if (!practice && s.alive && now - lastSnapshot >= SNAPSHOT_MS) {
|
||||||
|
sendSnapshot();
|
||||||
|
lastSnapshot = now;
|
||||||
|
}
|
||||||
|
if (boardRef.current) drawBoard(boardRef.current, s.board, CELL, s.active, s.ghost);
|
||||||
|
if (nextRef.current) drawNext(nextRef.current, s.nextType);
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [practice, phase, open, sendSnapshot, state?.seed]);
|
||||||
|
|
||||||
|
// Odpočet zbývajícího času hry na skóre
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'playing' || state?.settings.winMode !== 'score') return;
|
||||||
|
const id = window.setInterval(() => setNow(Date.now()), 500);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [phase, state?.settings.winMode]);
|
||||||
|
|
||||||
|
// Heartbeat pro server (viz HEARTBEAT_MS)
|
||||||
|
useEffect(() => {
|
||||||
|
if (practice || phase !== 'playing' || !isPlayer) return;
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
if (sessionRef.current?.alive) sendSnapshot();
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [practice, phase, isPlayer, sendSnapshot]);
|
||||||
|
|
||||||
|
// Ovládání
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !(practice || (phase === 'playing' && isPlayer))) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const action = KEYS[e.code];
|
||||||
|
const s = sessionRef.current;
|
||||||
|
if (!action || !s) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (!s.alive) return;
|
||||||
|
switch (action) {
|
||||||
|
case 'left': s.move(-1); break;
|
||||||
|
case 'right': s.move(1); break;
|
||||||
|
case 'down': s.softDrop(); break;
|
||||||
|
case 'rotate': if (!e.repeat) s.rotate(1); break;
|
||||||
|
case 'rotateBack': if (!e.repeat) s.rotate(-1); break;
|
||||||
|
case 'drop': if (!e.repeat) s.hardDrop(); break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [open, practice, phase, isPlayer]);
|
||||||
|
|
||||||
|
// Po skončení hry se session zahodí, ať se příště začíná načisto
|
||||||
|
useEffect(() => {
|
||||||
|
if (!practice && (phase === 'lobby' || phase === 'idle')) {
|
||||||
|
sessionRef.current = null;
|
||||||
|
sessionSeed.current = undefined;
|
||||||
|
}
|
||||||
|
}, [practice, phase]);
|
||||||
|
|
||||||
|
const startPractice = useCallback(() => {
|
||||||
|
sessionSeed.current = undefined;
|
||||||
|
sessionRef.current = new TetrisSession(Math.floor(Math.random() * 2 ** 31), {
|
||||||
|
onLock: () => setVersion(v => v + 1),
|
||||||
|
onDeath: () => setVersion(v => v + 1),
|
||||||
|
});
|
||||||
|
setPractice(true);
|
||||||
|
setVersion(v => v + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopPractice = useCallback(() => {
|
||||||
|
sessionRef.current = null;
|
||||||
|
setPractice(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!login) return null;
|
||||||
|
|
||||||
|
const session = sessionRef.current;
|
||||||
|
// Připojit se jde i během odpočtu před startem
|
||||||
|
const joinable = phase === 'lobby' || phase === 'countdown';
|
||||||
|
const showInvite = !open && joinable && !isPlayer;
|
||||||
|
const showReturn = !open && isPlayer && inGame;
|
||||||
|
const others = practice ? [] : (state?.players ?? []).filter(p => !(isPlayer && p.login === login));
|
||||||
|
const ranked = [...(state?.players ?? [])].sort((a, b) => (a.place ?? 99) - (b.place ?? 99));
|
||||||
|
const settings = state?.settings;
|
||||||
|
const isHost = login === state?.host;
|
||||||
|
const remaining = !practice && phase === 'playing' && deadline.current !== undefined
|
||||||
|
? Math.max(0, deadline.current - now) : undefined;
|
||||||
|
const configure = (change: Partial<TetrisSettings>) => send('tetris:configure', change);
|
||||||
|
|
||||||
|
const renderSettings = () => settings && (
|
||||||
|
<div className="tetris-settings">
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>Míření odpadu</Form.Label>
|
||||||
|
{isHost ? (
|
||||||
|
<Form.Select size="sm" value={settings.targeting}
|
||||||
|
onChange={e => configure({ targeting: e.target.value as TetrisTargeting })}>
|
||||||
|
{Object.entries(TARGETING_LABELS).map(([value, { label }]) => <option key={value} value={value}>{label}</option>)}
|
||||||
|
</Form.Select>
|
||||||
|
) : <div><strong>{TARGETING_LABELS[settings.targeting].label}</strong></div>}
|
||||||
|
<Form.Text>{TARGETING_LABELS[settings.targeting].help}</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>Vítěz</Form.Label>
|
||||||
|
{isHost ? (
|
||||||
|
<Form.Select size="sm" value={settings.winMode}
|
||||||
|
onChange={e => configure({ winMode: e.target.value as TetrisWinMode })}>
|
||||||
|
{Object.entries(WIN_MODE_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||||||
|
</Form.Select>
|
||||||
|
) : <div><strong>{WIN_MODE_LABELS[settings.winMode]}</strong></div>}
|
||||||
|
</Form.Group>
|
||||||
|
{settings.winMode === 'score' && (
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>Délka hry</Form.Label>
|
||||||
|
{isHost ? (
|
||||||
|
<Form.Select size="sm" value={settings.timeLimit}
|
||||||
|
onChange={e => configure({ timeLimit: Number(e.target.value) })}>
|
||||||
|
{TIME_LIMITS.map(t => <option key={t} value={t}>{t / 60} min</option>)}
|
||||||
|
</Form.Select>
|
||||||
|
) : <div><strong>{settings.timeLimit / 60} min</strong></div>}
|
||||||
|
</Form.Group>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderPlaying = () => (
|
||||||
|
<div className="tetris-layout">
|
||||||
|
{(isPlayer || practice) && (
|
||||||
|
<div className="tetris-own">
|
||||||
|
<div className="tetris-board-wrap">
|
||||||
|
<canvas ref={boardRef} className="tetris-board" width={WIDTH * CELL} height={HEIGHT * CELL} />
|
||||||
|
{!practice && phase === 'countdown' && <div className="tetris-overlay">{state?.countdown}</div>}
|
||||||
|
{session && !session.alive && <div className="tetris-overlay small">Konec 💀</div>}
|
||||||
|
{flash && <div className="tetris-flash">{flash}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="tetris-side">
|
||||||
|
{remaining !== undefined && (
|
||||||
|
<>
|
||||||
|
<div className="tetris-label">Zbývá</div>
|
||||||
|
<div className="tetris-value">{formatTime(remaining)}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="tetris-label">Další</div>
|
||||||
|
<canvas ref={nextRef} width={100} height={60} />
|
||||||
|
<div className="tetris-label">Skóre</div>
|
||||||
|
<div className="tetris-value">{session?.score ?? 0}</div>
|
||||||
|
<div className="tetris-label">Řádky</div>
|
||||||
|
<div className="tetris-value">{session?.lines ?? 0}</div>
|
||||||
|
<div className="tetris-label">Úroveň</div>
|
||||||
|
<div className="tetris-value">{session?.level ?? 0}</div>
|
||||||
|
<div className="tetris-help">
|
||||||
|
←→ / AD posun<br />↑ / W otočit<br />↓ / S dolů<br />mezerník shodit
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{others.length > 0 && (
|
||||||
|
<div className="tetris-opponents">
|
||||||
|
{others.map(p => <MiniBoard key={p.login} player={p} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBody = () => {
|
||||||
|
if (practice) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="text-muted text-center">🎯 Trénink — hra jen pro vás, nikdo ji nevidí.</p>
|
||||||
|
{renderPlaying()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
switch (phase) {
|
||||||
|
case 'idle':
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
Tetris pro více hráčů. Všichni dostávají stejné kostky; kdo smaže 2, 3 nebo 4 řádky naráz,
|
||||||
|
pošle soupeři 1, 2 nebo 4 odpadní řádky. Smazáním řádků lze zároveň odrazit odpad,
|
||||||
|
který na vás čeká. Zakladatel lobby volí, komu útoky půjdou a zda vyhrává poslední
|
||||||
|
hráč ve hře, nebo nejvyšší skóre po časovém limitu.
|
||||||
|
Hrát se dá i sólo, nebo si kdykoli zatrénovat jen pro sebe.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
case 'lobby':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
|
||||||
|
<ul className="tetris-players">
|
||||||
|
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
|
||||||
|
</ul>
|
||||||
|
{state && state.players.length < state.minPlayers &&
|
||||||
|
<p className="text-muted">Čeká se na další hráče… Pro hru o samotě použijte Trénink.</p>}
|
||||||
|
{renderSettings()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'countdown':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!isPlayer && (
|
||||||
|
<p className="text-center">
|
||||||
|
Hra začne za <strong>{state?.countdown} s</strong> — ještě se můžete připojit.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{renderPlaying()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'playing':
|
||||||
|
return renderPlaying();
|
||||||
|
case 'finished':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{settings && (
|
||||||
|
<p className="text-muted">
|
||||||
|
Hodnocení: {WIN_MODE_LABELS[settings.winMode]}
|
||||||
|
{settings.winMode === 'score' && ` (${settings.timeLimit / 60} min)`}
|
||||||
|
{' · '}míření: {TARGETING_LABELS[settings.targeting].label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Table size="sm" className="tetris-results">
|
||||||
|
<thead>
|
||||||
|
<tr><th>#</th><th>Hráč</th><th>Skóre</th><th>Řádky</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ranked.map((p, i) => (
|
||||||
|
<tr key={p.login}>
|
||||||
|
<td>{i < 3 ? MEDALS[i] : i + 1}</td>
|
||||||
|
<td>{p.login}{p.login === login && ' (vy)'}</td>
|
||||||
|
<td>{p.score}</td>
|
||||||
|
<td>{p.lines}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
const buttons = [];
|
||||||
|
if (practice) {
|
||||||
|
if (session && !session.alive) {
|
||||||
|
buttons.push(<Button key="again" onClick={startPractice}>Znovu</Button>);
|
||||||
|
}
|
||||||
|
buttons.push(<Button key="stop" variant="outline-secondary" onClick={stopPractice}>Ukončit trénink</Button>);
|
||||||
|
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
||||||
|
return buttons;
|
||||||
|
}
|
||||||
|
// Trénink jde spustit, kdykoli zrovna nehrajete online hru
|
||||||
|
if (!(isPlayer && (phase === 'lobby' || inGame))) {
|
||||||
|
buttons.push(<Button key="practice" variant="outline-primary" onClick={startPractice}>Trénink</Button>);
|
||||||
|
}
|
||||||
|
if (phase === 'idle' || phase === 'finished') {
|
||||||
|
buttons.push(<Button key="new" onClick={() => send('tetris:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
|
||||||
|
}
|
||||||
|
if (joinable && !isPlayer) {
|
||||||
|
buttons.push(<Button key="join" onClick={() => send('tetris:join')}>Připojit se</Button>);
|
||||||
|
}
|
||||||
|
if (phase === 'lobby' && isPlayer) {
|
||||||
|
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('tetris:leave')}>
|
||||||
|
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
|
||||||
|
</Button>);
|
||||||
|
if (login === state?.host) {
|
||||||
|
buttons.push(<Button key="start" onClick={() => send('tetris:start')}
|
||||||
|
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 2)}>Spustit hru</Button>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
||||||
|
return buttons;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showInvite && (
|
||||||
|
<button className="duel-invite tetris-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} {phase === 'countdown'
|
||||||
|
? <>Tetris začíná za <strong>{state?.countdown} s</strong> — připojit se</>
|
||||||
|
: <><strong>{state?.host}</strong> zve na Tetris — připojit se</>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showReturn && (
|
||||||
|
<button className="duel-invite tetris-invite" onClick={() => setOpen(true)}>
|
||||||
|
{icon} Tetris běží — zpět do hry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Modal show={open} onHide={() => setOpen(false)} size="xl" className="tetris-game-modal">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>{icon} Tetris</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{renderBody()}
|
||||||
|
{error && <div className="text-danger mt-2">{error}</div>}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>{renderFooter()}</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
.wheel-confetti {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000; // nad modalem (bootstrap 1055)
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
position: absolute;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vrh: rychle vzhůru do vrcholu (zpomalení), pak volný pád (zrychlení)
|
||||||
|
@keyframes confetti-throw {
|
||||||
|
0% { transform: translate3d(0, 0, 0) rotate(0deg) scale(0.4); opacity: 0; animation-timing-function: cubic-bezier(0.2, 0.7, 0.4, 1); }
|
||||||
|
6% { opacity: 1; }
|
||||||
|
38% { transform: translate3d(var(--x1), var(--y1), 0) rotate(var(--r1)) scale(1); animation-timing-function: cubic-bezier(0.5, 0, 0.9, 0.6); }
|
||||||
|
88% { opacity: 1; }
|
||||||
|
100% { transform: translate3d(var(--x2), var(--y2), 0) rotate(var(--r2)) scale(1); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déšť: plynulý pád shora s mírným houpáním
|
||||||
|
@keyframes confetti-rain {
|
||||||
|
0% { transform: translate3d(0, 0, 0) rotate(0deg); opacity: 0; animation-timing-function: ease-in-out; }
|
||||||
|
8% { opacity: 1; }
|
||||||
|
40% { transform: translate3d(var(--x1), var(--y1), 0) rotate(var(--r1)); animation-timing-function: ease-in; }
|
||||||
|
88% { opacity: 1; }
|
||||||
|
100% { transform: translate3d(var(--x2), var(--y2), 0) rotate(var(--r2)); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Samolepky Umamusume — stín pro čitelnost na libovolném pozadí
|
||||||
|
.confetti-uma {
|
||||||
|
filter: drop-shadow(0 3px 3px rgb(0 0 0 / 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-uma-emoji {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-uma-badge {
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 3px solid #fff;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-shadow: 0 2px 0 rgb(0 0 0 / 0.2);
|
||||||
|
box-shadow: 0 4px 0 rgb(0 0 0 / 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-uma-img img {
|
||||||
|
width: 92px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Portréty (bez průhlednosti) jako kulaté samolepky s barevným lemem
|
||||||
|
.confetti-uma-img.round img,
|
||||||
|
.confetti-uma-voice img.round {
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 4px solid #fff;
|
||||||
|
box-shadow: 0 0 0 3px #ff6fa3, 0 4px 8px rgb(0 0 0 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-uma-voice img.round {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-uma-banner {
|
||||||
|
position: absolute;
|
||||||
|
top: 38%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
padding: 12px 48px;
|
||||||
|
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: clamp(2rem, 7vw, 4rem);
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(90deg, #e84d86, #ff6fa3);
|
||||||
|
clip-path: polygon(24px 0, 100% 0, calc(100% - 24px) 100%, 0 100%);
|
||||||
|
text-shadow: 0 3px 0 rgb(0 0 0 / 0.2);
|
||||||
|
border-top: 5px solid #ffc531;
|
||||||
|
border-bottom: 5px solid #ffc531;
|
||||||
|
animation: uma-banner 1.8s cubic-bezier(0.2, 1.4, 0.4, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes uma-banner {
|
||||||
|
0% { transform: translate(-150%, -50%) skewX(-12deg); opacity: 0; }
|
||||||
|
25% { transform: translate(-50%, -50%) skewX(-12deg); opacity: 1; }
|
||||||
|
75% { transform: translate(-50%, -50%) skewX(-12deg) scale(1.04); opacity: 1; }
|
||||||
|
100% { transform: translate(60%, -50%) skewX(-12deg); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// „Hláška" postavy při finálním výsledku
|
||||||
|
.confetti-uma-voice {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: min(460px, calc(100vw - 32px));
|
||||||
|
animation: uma-voice 4.6s ease both;
|
||||||
|
|
||||||
|
img {
|
||||||
|
flex: none;
|
||||||
|
width: 110px;
|
||||||
|
height: auto;
|
||||||
|
filter: drop-shadow(0 4px 6px rgb(0 0 0 / 0.3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-uma-bubble {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
color: #1d3322;
|
||||||
|
border: 3px solid #ff6fa3;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 4px 0 #e84d86;
|
||||||
|
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #e84d86;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ocásek bubliny směrem k postavě
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -14px;
|
||||||
|
bottom: 14px;
|
||||||
|
border: 8px solid transparent;
|
||||||
|
border-right: 10px solid #ff6fa3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes uma-voice {
|
||||||
|
0% { opacity: 0; transform: translateY(40px) scale(0.8); }
|
||||||
|
10% { opacity: 1; transform: translateY(0) scale(1.03); }
|
||||||
|
15% { transform: translateY(0) scale(1); }
|
||||||
|
85% { opacity: 1; transform: translateY(0); }
|
||||||
|
100% { opacity: 0; transform: translateY(20px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Konfety ve stylu My Little Pony
|
||||||
|
// ============================================
|
||||||
|
.confetti-mlp {
|
||||||
|
filter: drop-shadow(0 3px 3px rgb(0 0 0 / 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-mlp-badge {
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 3px solid #fff;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Fredoka', 'Nunito', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-shadow: 0 2px 0 rgb(0 0 0 / 0.2);
|
||||||
|
box-shadow: 0 0 12px rgb(255 255 255 / 0.7), 0 4px 0 rgb(0 0 0 / 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duhový nápis „Sonic Rainboom" přes celou obrazovku
|
||||||
|
.confetti-mlp-banner {
|
||||||
|
position: absolute;
|
||||||
|
top: 38%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
padding: 12px 44px;
|
||||||
|
font-family: 'Fredoka', 'Nunito', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: clamp(1.8rem, 6.5vw, 3.6rem);
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, #ee4144, #f37033, #e3b829, #62bc4d, #1e98d3, #672f89);
|
||||||
|
border: 5px solid #fff;
|
||||||
|
text-shadow: 0 3px 0 rgb(0 0 0 / 0.25);
|
||||||
|
box-shadow: 0 0 30px rgb(236 61 143 / 0.5), 0 8px 0 rgb(0 0 0 / 0.12);
|
||||||
|
animation: mlp-banner 2s cubic-bezier(0.2, 1.4, 0.4, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mlp-banner {
|
||||||
|
0% { transform: translate(-50%, -50%) scale(0.2) rotate(-8deg); opacity: 0; }
|
||||||
|
25% { transform: translate(-50%, -50%) scale(1.08) rotate(0deg); opacity: 1; }
|
||||||
|
35% { transform: translate(-50%, -50%) scale(1); }
|
||||||
|
75% { transform: translate(-50%, -50%) scale(1.03); opacity: 1; }
|
||||||
|
100% { transform: translate(-50%, -160%) scale(0.9); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Postava s hláškou — místo obrázku kulatý „cutie mark" s emoji
|
||||||
|
.confetti-mlp-avatar {
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 46px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 35% 30%, #fff, #f6ecff);
|
||||||
|
border: 4px solid #fff;
|
||||||
|
box-shadow: 0 0 0 4px var(--mlp-speaker, #8e4fd1), 0 4px 10px rgb(0 0 0 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-mlp-bubble {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
color: #2e1f47;
|
||||||
|
border: 3px solid var(--mlp-speaker, #8e4fd1);
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow: 0 4px 0 var(--mlp-speaker, #8e4fd1);
|
||||||
|
font-family: 'Fredoka', 'Nunito', sans-serif;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--mlp-speaker, #8e4fd1);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ocásek bubliny směrem k postavě
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -14px;
|
||||||
|
bottom: 14px;
|
||||||
|
border: 8px solid transparent;
|
||||||
|
border-right: 10px solid var(--mlp-speaker, #8e4fd1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Konfety ve stylu Hell Girl
|
||||||
|
// ============================================
|
||||||
|
.confetti-hg {
|
||||||
|
filter: drop-shadow(0 0 6px rgb(200 16 46 / 0.55)) drop-shadow(0 3px 3px rgb(0 0 0 / 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-hg-badge {
|
||||||
|
padding: 4px 14px;
|
||||||
|
border: 1px solid #c8102e;
|
||||||
|
outline: 1px solid rgb(200 16 46 / 0.4);
|
||||||
|
outline-offset: 2px;
|
||||||
|
color: #fff4ee;
|
||||||
|
font-family: 'Shippori Mincho', 'Noto Serif JP', serif;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-shadow: 0 0 8px rgb(232 52 79 / 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nápis „poslání do pekla" — černá deska s rudým lemem, vynoří se z mlhy
|
||||||
|
.confetti-hg-banner {
|
||||||
|
position: absolute;
|
||||||
|
top: 38%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
padding: 14px 48px;
|
||||||
|
font-family: 'Shippori Mincho', 'Noto Serif JP', serif;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: clamp(2rem, 7vw, 4rem);
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #fff4ee;
|
||||||
|
background: linear-gradient(90deg, transparent, #12070a 15%, #12070a 85%, transparent);
|
||||||
|
border-top: 2px solid #c8102e;
|
||||||
|
border-bottom: 2px solid #c8102e;
|
||||||
|
text-shadow: 0 0 18px rgb(232 52 79 / 0.9), 0 0 4px #c8102e;
|
||||||
|
animation: hg-banner 2.6s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hg-banner {
|
||||||
|
0% { transform: translate(-50%, -50%) scale(1.15); opacity: 0; filter: blur(12px); }
|
||||||
|
25% { transform: translate(-50%, -50%) scale(1); opacity: 1; filter: blur(0); }
|
||||||
|
75% { transform: translate(-50%, -50%) scale(1); opacity: 1; filter: blur(0); }
|
||||||
|
100% { transform: translate(-50%, -50%) scale(0.96); opacity: 0; filter: blur(8px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Postava s hláškou — tmavý odznak s rudou září a bublina jako svitek
|
||||||
|
.confetti-hellgirl-voice {
|
||||||
|
.confetti-mlp-avatar {
|
||||||
|
border-color: #12070a;
|
||||||
|
background: radial-gradient(circle at 50% 60%, #3a0d16, #12070a);
|
||||||
|
box-shadow: 0 0 0 3px var(--mlp-speaker, #c8102e), 0 0 18px rgb(200 16 46 / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-mlp-bubble {
|
||||||
|
background: #170c0f;
|
||||||
|
color: #f3e6e2;
|
||||||
|
border: 1px solid var(--mlp-speaker, #c8102e);
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 0 16px rgb(0 0 0 / 0.6), 0 0 10px rgb(200 16 46 / 0.3);
|
||||||
|
font-family: 'Shippori Mincho', 'Noto Serif JP', serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Konfety ve stylu K-ON!
|
||||||
|
// ============================================
|
||||||
|
.confetti-kon {
|
||||||
|
filter: drop-shadow(0 3px 3px rgb(39 49 95 / 0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Samolepky jako na pouzdru kytary — bílý okraj a lehce natočené
|
||||||
|
.confetti-kon-badge {
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 3px solid #fff;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Zen Maru Gothic', 'M PLUS Rounded 1c', sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-shadow: 0 1px 0 rgb(0 0 0 / 0.2);
|
||||||
|
box-shadow: 0 3px 0 rgb(39 49 95 / 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nápis „Houkago Tea Time" — vstupenka na školní koncert s notovou osnovou
|
||||||
|
.confetti-kon-banner {
|
||||||
|
position: absolute;
|
||||||
|
top: 38%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
padding: 14px 44px;
|
||||||
|
font-family: 'Zen Maru Gothic', 'M PLUS Rounded 1c', sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: clamp(1.6rem, 6vw, 3.4rem);
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #27315f;
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(180deg, transparent 0 9px, rgb(39 49 95 / 0.12) 9px 10px) center / 100% 50px no-repeat,
|
||||||
|
#fffaf0;
|
||||||
|
border: 4px solid #27315f;
|
||||||
|
box-shadow: 0 0 0 6px #fffaf0, 0 0 0 9px #d43f62, 0 12px 26px rgb(0 0 0 / 0.25);
|
||||||
|
animation: kon-banner 2.2s cubic-bezier(0.3, 1.5, 0.5, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vyskočí do rytmu (raz-dva) a odplave nahoru
|
||||||
|
@keyframes kon-banner {
|
||||||
|
0% { transform: translate(-50%, -50%) scale(0.3) rotate(6deg); opacity: 0; }
|
||||||
|
18% { transform: translate(-50%, -50%) scale(1.1) rotate(-3deg); opacity: 1; }
|
||||||
|
28% { transform: translate(-50%, -50%) scale(1) rotate(-2deg); }
|
||||||
|
40% { transform: translate(-50%, -50%) scale(1.05) rotate(-2deg); }
|
||||||
|
50% { transform: translate(-50%, -50%) scale(1) rotate(-2deg); }
|
||||||
|
80% { transform: translate(-50%, -50%) scale(1) rotate(-2deg); opacity: 1; }
|
||||||
|
100% { transform: translate(-50%, -150%) scale(0.9) rotate(-2deg); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Postava s hláškou — krémový odznak a bublina jako stránka notového sešitu
|
||||||
|
.confetti-kon-voice {
|
||||||
|
.confetti-mlp-avatar {
|
||||||
|
background: radial-gradient(circle at 40% 35%, #fffaf0, #fbe6c8);
|
||||||
|
box-shadow: 0 0 0 4px var(--mlp-speaker, #d43f62), 0 4px 10px rgb(0 0 0 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-mlp-bubble {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(180deg, transparent 0 21px, rgb(39 49 95 / 0.08) 21px 22px),
|
||||||
|
#fffaf0;
|
||||||
|
color: #27315f;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-family: 'Zen Maru Gothic', 'M PLUS Rounded 1c', sans-serif;
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Konfety ve stylu Bocchi the Rock!
|
||||||
|
// ============================================
|
||||||
|
.confetti-bocchi {
|
||||||
|
filter: drop-shadow(3px 3px 0 rgb(29 26 34 / 0.85));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Samolepky z flyeru live house — černá linka a tvrdý stín
|
||||||
|
.confetti-bocchi-badge {
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 3px solid #1d1a22;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Dela Gothic One', 'M PLUS Rounded 1c', sans-serif;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nápis „Kessoku Band" — žlutá samolepka s růžovým stínem, při dopadu se zachvěje jako Bocchi
|
||||||
|
.confetti-bocchi-banner {
|
||||||
|
position: absolute;
|
||||||
|
top: 38%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
padding: 12px 40px;
|
||||||
|
font-family: 'Dela Gothic One', 'M PLUS Rounded 1c', sans-serif;
|
||||||
|
font-size: clamp(1.8rem, 7vw, 4rem);
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #1d1a22;
|
||||||
|
background: #ffcf33;
|
||||||
|
border: 5px solid #1d1a22;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 10px 10px 0 #e0457b, 18px 18px 0 #1d1a22;
|
||||||
|
animation: bocchi-banner 2.4s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bocchi-banner {
|
||||||
|
0% { transform: translate(-50%, -50%) scale(2.2) rotate(-8deg); opacity: 0; }
|
||||||
|
14% { transform: translate(-50%, -50%) scale(1) rotate(-3deg); opacity: 1; }
|
||||||
|
17% { transform: translate(calc(-50% - 6px), -50%) rotate(-3deg); }
|
||||||
|
20% { transform: translate(calc(-50% + 6px), -50%) rotate(-3deg); }
|
||||||
|
23% { transform: translate(calc(-50% - 3px), -50%) rotate(-3deg); }
|
||||||
|
26%, 80% { transform: translate(-50%, -50%) rotate(-3deg); opacity: 1; }
|
||||||
|
100% { transform: translate(-50%, 60%) rotate(4deg); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Postava s hláškou — hranatý odznak a bublina jako z mangy s tvrdým stínem
|
||||||
|
.confetti-bocchi-voice {
|
||||||
|
.confetti-mlp-avatar {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 3px solid #1d1a22;
|
||||||
|
background: #ffcf33;
|
||||||
|
box-shadow: 5px 5px 0 var(--mlp-speaker, #e0457b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti-mlp-bubble {
|
||||||
|
background: #fff;
|
||||||
|
color: #1d1a22;
|
||||||
|
border: 3px solid #1d1a22;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 5px 5px 0 var(--mlp-speaker, #e0457b);
|
||||||
|
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
border-right-color: #1d1a22;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { StyleTheme } from "../context/settings";
|
||||||
|
import { UmaCharacter, loadUmaCharacters } from "../utils/umaCharacters";
|
||||||
|
import "./WheelConfetti.scss";
|
||||||
|
|
||||||
|
type WheelTheme = StyleTheme;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
theme: WheelTheme,
|
||||||
|
/** Mění se s každým výsledkem — nová hodnota spustí novou salvu. */
|
||||||
|
burstKey: number,
|
||||||
|
/** Velká salva (finální výsledek) vs. menší (mezivýsledek). */
|
||||||
|
big?: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
type UmaImage = { src: string, round: boolean };
|
||||||
|
|
||||||
|
const SIMPLE_COLORS = ['#16a34a', '#0ea5e9', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#ef4444'];
|
||||||
|
const UMA_EMOJI = ['🐴', '🏇', '🥕', '🏆', '🌸', '✨', '⭐', '🍙', '🎀', '🥇'];
|
||||||
|
// Hlášky z fandomu Umamusume (Umapyoi Densetsu, Teio step, hachimī, Golshi, Oguri „okawari"...)
|
||||||
|
const UMA_BADGES = ['1着!', 'Umazing!', 'Umapyoi!', 'Teio step!', 'Hachimī~', 'Golshi approves', 'Okawari!', 'Pakapaka~', 'ウマい!'];
|
||||||
|
const UMA_BADGE_COLORS = ['#ff6fa3', '#2e9a3a', '#43b8f0', '#f2a91a'];
|
||||||
|
const MLP_EMOJI = ['🦄', '🌈', '✨', '⭐', '🧁', '🍎', '🦋', '💎', '🎈', '🎉', '🍰', '💜'];
|
||||||
|
// Hlášky a narážky z My Little Pony: Friendship is Magic
|
||||||
|
const MLP_BADGES = ['Friendship is Magic!', '20% cooler!', 'Sonic Rainboom!', 'Yay!', 'Party!', 'Yeehaw!', 'Everypony!', 'Awesome!', 'Darling!', 'Pinkie Promise'];
|
||||||
|
const MLP_BADGE_COLORS = ['#8e4fd1', '#f06aa6', '#1e98d3', '#f37033', '#e3b829', '#5b4fc9'];
|
||||||
|
/** Postavy Mane 6 (+ Spike) s „hláškou" po finálním výsledku — místo obrázků cutie mark emoji. */
|
||||||
|
const MLP_SPEAKERS = [
|
||||||
|
{ name: 'Twilight Sparkle', emoji: '⭐', color: '#8e4fd1', quote: 'Podle mého seznamu je to ta nejlogičtější volba!' },
|
||||||
|
{ name: 'Rainbow Dash', emoji: '🌈', color: '#1e98d3', quote: 'Tohle je o 20 % úžasnější než ostatní!' },
|
||||||
|
{ name: 'Pinkie Pie', emoji: '🎈', color: '#f06aa6', quote: 'Tohle volá po PÁRTY! S dortem! A s cupcaky!' },
|
||||||
|
{ name: 'Applejack', emoji: '🍎', color: '#f37033', quote: 'Poctivé jídlo pro poctivou práci. Jíhá!' },
|
||||||
|
{ name: 'Fluttershy', emoji: '🦋', color: '#e3b829', quote: 'Jé… teda, jestli to nevadí… yay.' },
|
||||||
|
{ name: 'Rarity', emoji: '💎', color: '#5b4fc9', quote: 'Naprosto božská volba, drahoušku!' },
|
||||||
|
{ name: 'Spike', emoji: '🐉', color: '#62bc4d', quote: 'Zapsáno do svitku a odesláno princezně!' },
|
||||||
|
];
|
||||||
|
const HG_EMOJI = ['🥀', '🏮', '⛩️', '🔥', '🕯️', '🌑', '🦋', '👁️', '🕸️', '🌸'];
|
||||||
|
// Hlášky z Jigoku Shōjo (Pekelná korespondence, „Ippen, shinde miru?", poslání do pekla...)
|
||||||
|
const HG_BADGES = ['いっぺん、死んでみる?', '地獄通信', '地獄流し', 'Ippen… shinde miru?', 'Pomsta vykonána', '0:00', 'Uvaž to dobře…', 'Smlouva uzavřena'];
|
||||||
|
const HG_BADGE_COLORS = ['#12070a', '#7a0c1f', '#2e0d24'];
|
||||||
|
/** Ai Enma a její služebníci s „hláškou" po finálním výsledku — místo obrázků emoji. */
|
||||||
|
const HG_SPEAKERS = [
|
||||||
|
{ name: 'Ai Enma', emoji: '🥀', color: '#c8102e', quote: 'Smlouva je uzavřena. Tenhle oběd tě bude provázet až na věčnost.' },
|
||||||
|
{ name: 'Ichimoku Ren', emoji: '👁️', color: '#6b5a8a', quote: 'Prohlédl jsem svým okem všechny podniky. Tenhle jediný obstál.' },
|
||||||
|
{ name: 'Hone Onna', emoji: '💀', color: '#b8862b', quote: 'Dobré až na kost, zlato. Mně z toho stejně nic nezůstane.' },
|
||||||
|
{ name: 'Wanyūdō', emoji: '🔥', color: '#d9582b', quote: 'Já jsem kolo. Kolo rozhodlo. Víc k tomu nemám.' },
|
||||||
|
{ name: 'Kikuri', emoji: '🎀', color: '#d4407a', quote: 'Hihi! Tohle bych do pekla poslala jako první!' },
|
||||||
|
{ name: 'Babička', emoji: '🕸️', color: '#8c7672', quote: 'Ai, oběd je na stole… jako každý den už čtyři sta let.' },
|
||||||
|
];
|
||||||
|
const KON_EMOJI = ['🎸', '🥁', '🎹', '🎵', '🎶', '🍰', '☕', '🍓', '🐢', '🎀', '🍪', '🫖'];
|
||||||
|
// Hlášky z K-ON! (Houkago Tea Time, Fuwa Fuwa Time, Gitah, Ton-chan, Mugi a její čaj...)
|
||||||
|
const KON_BADGES = ['放課後ティータイム', 'Fuwa Fuwa Time ♪', 'Tea time!', 'Gitah!', 'Ton-chan ♡', 'けいおん!', 'Nejdřív čaj, pak zkouška', "Don't say lazy", 'Azunyan~', 'Ui!'];
|
||||||
|
const KON_BADGE_COLORS = ['#d43f62', '#3d6fc4', '#e8a10f', '#8a6bc7', '#e46f9a', '#27315f'];
|
||||||
|
/** Členky Houkago Tea Time (+ Ui, Sawa-chan a Ton-chan) s „hláškou" po finálním výsledku. */
|
||||||
|
const KON_SPEAKERS = [
|
||||||
|
{ name: 'Yui Hirasawa', emoji: '🎸', color: '#d43f62', quote: 'Gitah říká, že tohle bude mňamka! Fuwa fuwa~' },
|
||||||
|
{ name: 'Mio Akiyama', emoji: '🎼', color: '#3d6fc4', quote: 'Dobře… ale hned po obědě se jde zkoušet! Opravdu!' },
|
||||||
|
{ name: 'Ritsu Tainaka', emoji: '🥁', color: '#e8a10f', quote: 'Prezidentka klubu rozhodla! Raz, dva, raz dva tři čtyři!' },
|
||||||
|
{ name: 'Tsumugi Kotobuki', emoji: '🫖', color: '#c9a227', quote: 'Čaj a dortík k tomu přinesu já. Těším se!' },
|
||||||
|
{ name: 'Azusa Nakano', emoji: '🐱', color: '#8a6bc7', quote: 'Zase jídlo místo zkoušky? …No dobře, ale jen chvilku!' },
|
||||||
|
{ name: 'Ui Hirasawa', emoji: '🍳', color: '#e46f9a', quote: 'Sestřičko, nezapomeň si vzít ubrousek!' },
|
||||||
|
{ name: 'Sawako Yamanaka', emoji: '👗', color: '#27315f', quote: 'Oběd? Výborně. Mezitím vám ušiju nové kostýmy!' },
|
||||||
|
{ name: 'Ton-chan', emoji: '🐢', color: '#3c9a6b', quote: '(spokojeně mává ploutvičkou)' },
|
||||||
|
];
|
||||||
|
const BOCCHI_EMOJI = ['🎸', '🥁', '🎤', '📦', '🥭', '🟨', '🟦', '✨', '🌿', '🫠', '⭐', '🔊'];
|
||||||
|
// Hlášky z Bocchi the Rock! (Kessoku Band, Kita-aura, krabice od manga, guitarhero, live house STARRY...)
|
||||||
|
const BOCCHI_BADGES = ['結束バンド', 'Kessoku Band!', 'Kitaaan ✨', 'guitarhero', 'A-ano…', 'Live at STARRY', 'Bocchi-chan!', 'ぼっち・ざ・ろっく!', 'Mango box', 'Rock!'];
|
||||||
|
const BOCCHI_BADGE_COLORS = ['#e0457b', '#e8b400', '#3a63c9', '#d93a30', '#1d1a22'];
|
||||||
|
/** Kessoku Band a jejich okolí s „hláškou" po finálním výsledku. */
|
||||||
|
const BOCCHI_SPEAKERS = [
|
||||||
|
{ name: 'Hitori Gotoh', emoji: '🎸', color: '#e0457b', quote: 'A-ano… t-tohle by šlo… teda, pokud to nikomu nevadí…' },
|
||||||
|
{ name: 'Nijika Ijichi', emoji: '🥁', color: '#e8b400', quote: 'Tak jo, kapelo, jdeme jíst! Bocchi, vylez z té krabice!' },
|
||||||
|
{ name: 'Ryo Yamada', emoji: '🌿', color: '#3a63c9', quote: 'Dobrá volba. Mimochodem… nepůjčíš mi na oběd?' },
|
||||||
|
{ name: 'Ikuyo Kita', emoji: '✨', color: '#d93a30', quote: 'Kitaaan! Tohle se mi děsně líbí, vyfotím to na Instagram!' },
|
||||||
|
{ name: 'Seika Ijichi', emoji: '🎛️', color: '#7a4fc0', quote: 'Najezte se pořádně. Večer v STARRY neodfláknete zkoušku.' },
|
||||||
|
{ name: 'Futari Gotoh', emoji: '🐕', color: '#f08fb4', quote: 'Ségra zase obědvá ve skříni! Jimihen, pojď se podívat!' },
|
||||||
|
];
|
||||||
|
/** Styly, jejichž konfety tvoří emoji a samolepky s hláškami (a po finále promluví postava s emoji). */
|
||||||
|
const EMOJI_CONFETTI: Partial<Record<WheelTheme, {
|
||||||
|
prefix: string, badges: string[], colors: string[], emoji: string[],
|
||||||
|
speakers: { name: string, emoji: string, color: string, quote: string }[],
|
||||||
|
banner: string,
|
||||||
|
}>> = {
|
||||||
|
mlp: { prefix: 'confetti-mlp', badges: MLP_BADGES, colors: MLP_BADGE_COLORS, emoji: MLP_EMOJI, speakers: MLP_SPEAKERS, banner: '🌈 Sonic Rainboom! 🌈' },
|
||||||
|
hellgirl: { prefix: 'confetti-hg', badges: HG_BADGES, colors: HG_BADGE_COLORS, emoji: HG_EMOJI, speakers: HG_SPEAKERS, banner: '🏮 地獄流し 🏮' },
|
||||||
|
kon: { prefix: 'confetti-kon', badges: KON_BADGES, colors: KON_BADGE_COLORS, emoji: KON_EMOJI, speakers: KON_SPEAKERS, banner: '🎸 放課後ティータイム 🍰' },
|
||||||
|
bocchi: { prefix: 'confetti-bocchi', badges: BOCCHI_BADGES, colors: BOCCHI_BADGE_COLORS, emoji: BOCCHI_EMOJI, speakers: BOCCHI_SPEAKERS, banner: '🎸 結束バンド 🎸' },
|
||||||
|
};
|
||||||
|
const DURATION_MS = 6400;
|
||||||
|
|
||||||
|
const rand = (min: number, max: number) => min + Math.random() * (max - min);
|
||||||
|
const pick = <T,>(list: T[]) => list[Math.floor(Math.random() * list.length)];
|
||||||
|
|
||||||
|
type Piece = { id: number, style: React.CSSProperties, content: React.ReactNode, className: string };
|
||||||
|
|
||||||
|
type Launch = 'cannon-left' | 'cannon-right' | 'burst' | 'rain';
|
||||||
|
|
||||||
|
const LAUNCHES: Launch[] = ['cannon-left', 'cannon-left', 'cannon-right', 'cannon-right', 'burst', 'rain'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Výchozí pozice a dráha jednoho kousku: děla z dolních rohů střílí šikmo vzhůru,
|
||||||
|
* „výbuch" se rozletí ze středu a déšť padá opožděně shora. Kousek nejprve vyletí
|
||||||
|
* do vrcholu (--x1/--y1) a pak dopadne mimo obrazovku (--x2/--y2).
|
||||||
|
*/
|
||||||
|
function launchStyle(launch: Launch): React.CSSProperties {
|
||||||
|
let left: number, top: number, x1: number, y1: number, x2: number, y2: number, delay: number;
|
||||||
|
switch (launch) {
|
||||||
|
case 'cannon-left':
|
||||||
|
case 'cannon-right': {
|
||||||
|
const dir = launch === 'cannon-left' ? 1 : -1;
|
||||||
|
left = launch === 'cannon-left' ? rand(-3, 3) : rand(97, 103);
|
||||||
|
top = rand(88, 100);
|
||||||
|
x1 = dir * rand(12, 62);
|
||||||
|
y1 = -rand(45, 88);
|
||||||
|
x2 = x1 + dir * rand(6, 22);
|
||||||
|
y2 = rand(12, 25);
|
||||||
|
delay = rand(0, 450);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'burst': {
|
||||||
|
const angle = rand(0, Math.PI * 2);
|
||||||
|
const radius = rand(18, 48);
|
||||||
|
left = 50;
|
||||||
|
top = 42;
|
||||||
|
x1 = Math.cos(angle) * radius;
|
||||||
|
y1 = Math.sin(angle) * radius * 0.8 - rand(5, 15);
|
||||||
|
x2 = x1 * 1.35;
|
||||||
|
y2 = rand(70, 85);
|
||||||
|
delay = rand(0, 250);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
left = rand(0, 100);
|
||||||
|
top = -12;
|
||||||
|
x1 = rand(-8, 8);
|
||||||
|
y1 = rand(35, 55);
|
||||||
|
x2 = x1 + rand(-10, 10);
|
||||||
|
y2 = rand(115, 125);
|
||||||
|
delay = rand(400, 1800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const spin = rand(180, 720) * (Math.random() < 0.5 ? -1 : 1);
|
||||||
|
return {
|
||||||
|
left: `${left}vw`,
|
||||||
|
top: `${top}vh`,
|
||||||
|
animationName: launch === 'rain' ? 'confetti-rain' : 'confetti-throw',
|
||||||
|
animationDelay: `${delay}ms`,
|
||||||
|
animationDuration: `${rand(2600, 3900)}ms`,
|
||||||
|
['--x1' as any]: `${x1}vw`,
|
||||||
|
['--y1' as any]: `${y1}vh`,
|
||||||
|
['--x2' as any]: `${x2}vw`,
|
||||||
|
['--y2' as any]: `${y2}vh`,
|
||||||
|
['--r1' as any]: `${spin * 0.4}deg`,
|
||||||
|
['--r2' as any]: `${spin}deg`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPieces(theme: WheelTheme, big: boolean, images: UmaImage[]): Piece[] {
|
||||||
|
if (theme === 'simple') {
|
||||||
|
const count = big ? 170 : 80;
|
||||||
|
return Array.from({ length: count }, (_, id) => ({
|
||||||
|
id,
|
||||||
|
className: 'confetti-paper',
|
||||||
|
content: null,
|
||||||
|
style: {
|
||||||
|
...launchStyle(pick(LAUNCHES)),
|
||||||
|
width: `${rand(6, 11)}px`,
|
||||||
|
height: `${rand(10, 18)}px`,
|
||||||
|
background: pick(SIMPLE_COLORS),
|
||||||
|
borderRadius: Math.random() < 0.3 ? '50%' : '2px',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const count = big ? 50 : 24;
|
||||||
|
const emojiConfetti = EMOJI_CONFETTI[theme];
|
||||||
|
if (emojiConfetti) {
|
||||||
|
const { prefix, badges, colors, emoji } = emojiConfetti;
|
||||||
|
return Array.from({ length: count }, (_, id) => {
|
||||||
|
const base: React.CSSProperties = {
|
||||||
|
...launchStyle(pick(LAUNCHES)),
|
||||||
|
['--r1' as any]: `${rand(-25, 25)}deg`,
|
||||||
|
['--r2' as any]: `${rand(-40, 40)}deg`,
|
||||||
|
};
|
||||||
|
if (Math.random() < 0.35) {
|
||||||
|
return { id, className: `${prefix} ${prefix}-badge`, content: pick(badges),
|
||||||
|
style: { ...base, background: pick(colors) } };
|
||||||
|
}
|
||||||
|
return { id, className: `${prefix} confetti-uma-emoji`, style: { ...base, fontSize: `${rand(28, 52)}px` }, content: pick(emoji) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Array.from({ length: count }, (_, id) => {
|
||||||
|
const roll = Math.random();
|
||||||
|
// Samolepky se tolik netočí, ať jsou čitelné
|
||||||
|
const base: React.CSSProperties = {
|
||||||
|
...launchStyle(pick(LAUNCHES)),
|
||||||
|
['--r1' as any]: `${rand(-25, 25)}deg`,
|
||||||
|
['--r2' as any]: `${rand(-40, 40)}deg`,
|
||||||
|
};
|
||||||
|
if (images.length && roll < 0.45) {
|
||||||
|
const image = pick(images);
|
||||||
|
return { id, className: `confetti-uma confetti-uma-img${image.round ? ' round' : ''}`, style: base,
|
||||||
|
content: <img src={image.src} alt="" loading="eager" /> };
|
||||||
|
}
|
||||||
|
if (roll < 0.7) {
|
||||||
|
return { id, className: 'confetti-uma confetti-uma-badge', content: pick(UMA_BADGES),
|
||||||
|
style: { ...base, background: pick(UMA_BADGE_COLORS) } };
|
||||||
|
}
|
||||||
|
return { id, className: 'confetti-uma confetti-uma-emoji', style: { ...base, fontSize: `${rand(28, 52)}px` }, content: pick(UMA_EMOJI) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Salva konfet po výsledku kola štěstí, ve vzhledu podle zvoleného stylu. */
|
||||||
|
export default function WheelConfetti({ theme, burstKey, big = false }: Readonly<Props>) {
|
||||||
|
const [characters, setCharacters] = useState<UmaCharacter[]>([]);
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme === 'umamusume') loadUmaCharacters().then(setCharacters);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!burstKey) return;
|
||||||
|
setActive(true);
|
||||||
|
const t = setTimeout(() => setActive(false), DURATION_MS + 900);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [burstKey]);
|
||||||
|
|
||||||
|
const pieces = useMemo(
|
||||||
|
() => buildPieces(theme, big, characters.flatMap(c => c.images.map(src => ({ src, round: !!c.round })))),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[burstKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Při finálním výsledku „promluví" náhodná postava svou hláškou
|
||||||
|
const speaker = useMemo(() => {
|
||||||
|
const withQuote = characters.filter(c => c.quote);
|
||||||
|
return theme === 'umamusume' && big && withQuote.length ? pick(withQuote) : undefined;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [burstKey]);
|
||||||
|
|
||||||
|
// Ostatní tematické styly: hlášku pronese náhodná postava (Mane 6, Ai Enma, Houkago Tea Time, Kessoku Band...)
|
||||||
|
const emojiConfetti = EMOJI_CONFETTI[theme];
|
||||||
|
const emojiSpeaker = useMemo(
|
||||||
|
() => big && emojiConfetti ? pick(emojiConfetti.speakers) : undefined,
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[burstKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!active || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className={`wheel-confetti wheel-confetti-${theme}`} key={burstKey} aria-hidden>
|
||||||
|
{theme === 'umamusume' && big && <div className="confetti-uma-banner">🏆 1着 🏆</div>}
|
||||||
|
{emojiConfetti && big && <div className={`${emojiConfetti.prefix}-banner`}>{emojiConfetti.banner}</div>}
|
||||||
|
{pieces.map(p => <span key={p.id} className={p.className} style={p.style}>{p.content}</span>)}
|
||||||
|
{speaker && (
|
||||||
|
<div className="confetti-uma-voice">
|
||||||
|
<img src={pick(speaker.images)} alt="" className={speaker.round ? 'round' : undefined} />
|
||||||
|
<div className="confetti-uma-bubble">
|
||||||
|
<p>„{speaker.quote}"</p>
|
||||||
|
<small>— {speaker.name}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{emojiSpeaker && (
|
||||||
|
<div className={`confetti-uma-voice confetti-${theme}-voice`} style={{ ['--mlp-speaker' as any]: emojiSpeaker.color }}>
|
||||||
|
<span className="confetti-mlp-avatar">{emojiSpeaker.emoji}</span>
|
||||||
|
<div className="confetti-mlp-bubble">
|
||||||
|
<p>„{emojiSpeaker.quote}"</p>
|
||||||
|
<small>— {emojiSpeaker.name}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Modal, Button, Form } from "react-bootstrap";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (title: string, description: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Modální dialog pro přidání nového návrhu na vylepšení. */
|
||||||
|
export default function AddSuggestionModal({ isOpen, onClose, onSubmit }: Readonly<Props>) {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setTitle("");
|
||||||
|
setDescription("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!title.trim() || !description.trim()) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSubmit(title.trim(), description.trim());
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={handleClose} size="lg">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>Nový návrh na vylepšení</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Název</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
placeholder="Stručný název návrhu"
|
||||||
|
value={title}
|
||||||
|
maxLength={120}
|
||||||
|
onChange={e => setTitle(e.target.value)}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">Krátký, výstižný název navrhované úpravy.</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Popis</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
as="textarea"
|
||||||
|
rows={5}
|
||||||
|
placeholder="Detailní popis navrhované úpravy, řešení apod."
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
|
Storno
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={submitting || !title.trim() || !description.trim()}>
|
||||||
|
Přidat
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Modal, Button, Form, Alert, Badge } from "react-bootstrap";
|
||||||
|
import {
|
||||||
|
OrderGroup,
|
||||||
|
simulateBoltTracking, advanceBoltTracking, setBoltTrackingState,
|
||||||
|
pollBoltTracking, stopBoltTrackingSimulation,
|
||||||
|
} from "../../../../types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
group: OrderGroup;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Nabídka stavů pro ruční nastavení (odpovídá mapování v OrderProgress). */
|
||||||
|
const STATE_OPTIONS: { key: string; label: string; order_state: string; courier_state?: string }[] = [
|
||||||
|
{ key: 'accepted', label: 'Přijato', order_state: 'accepted' },
|
||||||
|
{ key: 'preparing', label: 'Příprava', order_state: 'preparing' },
|
||||||
|
{ key: 'waiting_delivery', label: 'Příprava (čeká na vyzvednutí)', order_state: 'waiting_delivery' },
|
||||||
|
{ key: 'pickup', label: 'Vyzvedávání', order_state: 'waiting_delivery', courier_state: 'arrived_to_provider' },
|
||||||
|
{ key: 'in_delivery', label: 'Na cestě', order_state: 'in_delivery', courier_state: 'heading_to_client' },
|
||||||
|
{ key: 'delivered', label: 'Doručeno', order_state: 'delivered' },
|
||||||
|
{ key: 'cancelled', label: 'Zrušeno', order_state: 'cancelled' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Modální dialog pro simulaci sledování Bolt objednávky (pouze DEV). */
|
||||||
|
export default function BoltSimulationModal({ isOpen, onClose, group }: Readonly<Props>) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [info, setInfo] = useState<string | null>(null);
|
||||||
|
const [manualKey, setManualKey] = useState<string>('preparing');
|
||||||
|
|
||||||
|
const running = !!group.trackingCode;
|
||||||
|
|
||||||
|
/** Obecný runner — spustí akci, ošetří chybu a krátce zobrazí výsledek. */
|
||||||
|
const run = async (action: () => Promise<{ error?: unknown }>, okMsg: string) => {
|
||||||
|
setError(null);
|
||||||
|
setInfo(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await action();
|
||||||
|
if (response.error) {
|
||||||
|
setError((response.error as any)?.error || 'Akce simulace selhala');
|
||||||
|
} else {
|
||||||
|
setInfo(okMsg);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || 'Akce simulace selhala');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStart = () => run(
|
||||||
|
() => simulateBoltTracking({ body: { groupId: group.id } }),
|
||||||
|
'Simulace spuštěna — objednávka přijata.',
|
||||||
|
);
|
||||||
|
const handleAdvance = () => run(
|
||||||
|
() => advanceBoltTracking({ body: { groupId: group.id } }),
|
||||||
|
'Posunuto na další krok.',
|
||||||
|
);
|
||||||
|
const handleSetState = () => {
|
||||||
|
const opt = STATE_OPTIONS.find(o => o.key === manualKey);
|
||||||
|
if (!opt) return;
|
||||||
|
return run(
|
||||||
|
() => setBoltTrackingState({
|
||||||
|
body: {
|
||||||
|
groupId: group.id,
|
||||||
|
order_state: opt.order_state,
|
||||||
|
courier_state: opt.courier_state,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
`Stav nastaven na „${opt.label}".`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const handlePoll = () => run(
|
||||||
|
() => pollBoltTracking(),
|
||||||
|
'Scheduler spuštěn (poll proběhl).',
|
||||||
|
);
|
||||||
|
const handleStop = () => run(
|
||||||
|
() => stopBoltTrackingSimulation({ body: { groupId: group.id } }),
|
||||||
|
'Simulace ukončena.',
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setError(null);
|
||||||
|
setInfo(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={handleClose}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>Simulace sledování Bolt</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<Alert variant="warning">
|
||||||
|
<strong>DEV režim</strong> – simuluje sledování objednávky Bolt pro skupinu{' '}
|
||||||
|
<strong>{group.name}</strong> bez reálné objednávky.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{info && (
|
||||||
|
<Alert variant="success" onClose={() => setInfo(null)} dismissible>
|
||||||
|
{info}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mb-2">
|
||||||
|
Stav simulace:{' '}
|
||||||
|
{running
|
||||||
|
? <Badge bg="success">běží{group.trackingOrderState ? ` — ${group.trackingOrderState}` : ''}</Badge>
|
||||||
|
: <Badge bg="secondary">neběží</Badge>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!running ? (
|
||||||
|
<p className="text-muted">
|
||||||
|
Spuštěním se skupina přepne do stavu „Objednáno", přiřadí se simulovaný
|
||||||
|
sledovací token a provede se první poll (stav „Přijato").
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-muted">
|
||||||
|
Krokuj objednávku tlačítkem <strong>Další krok</strong> (Přijato → Příprava →
|
||||||
|
Vyzvedávání → Na cestě → Doručeno) nebo nastav konkrétní stav ručně:
|
||||||
|
</p>
|
||||||
|
<Form.Group className="mb-3 d-flex gap-2 align-items-end">
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<Form.Label>Konkrétní stav</Form.Label>
|
||||||
|
<Form.Select
|
||||||
|
value={manualKey}
|
||||||
|
onChange={e => setManualKey(e.target.value)}
|
||||||
|
>
|
||||||
|
{STATE_OPTIONS.map(o => (
|
||||||
|
<option key={o.key} value={o.key}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</Form.Select>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline-primary" onClick={handleSetState} disabled={loading}>
|
||||||
|
Nastavit
|
||||||
|
</Button>
|
||||||
|
</Form.Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer className="d-flex flex-wrap gap-2">
|
||||||
|
{!running ? (
|
||||||
|
<Button variant="primary" onClick={handleStart} disabled={loading}>
|
||||||
|
{loading ? 'Spouštím…' : 'Spustit simulaci'}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="success" onClick={handleAdvance} disabled={loading}>
|
||||||
|
Další krok
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline-secondary" onClick={handlePoll} disabled={loading}>
|
||||||
|
Aktualizovat teď
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline-danger" onClick={handleStop} disabled={loading}>
|
||||||
|
Ukončit simulaci
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button variant="secondary" onClick={handleClose} disabled={loading}>
|
||||||
|
Zavřít
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
.butterfly-mystats {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 203, 61, 0.16), rgba(224, 162, 0, 0.08));
|
||||||
|
border: 1px solid rgba(224, 162, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-mystats-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #6b5b2e;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-mystats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.1;
|
||||||
|
|
||||||
|
&.gold {
|
||||||
|
color: #e0a200;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.l {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-heading {
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-achievements {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-achievement {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
opacity: 0.55;
|
||||||
|
filter: grayscale(1);
|
||||||
|
|
||||||
|
&.unlocked {
|
||||||
|
opacity: 1;
|
||||||
|
filter: none;
|
||||||
|
background: rgba(255, 203, 61, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ach-icon { flex: 0 0 auto; }
|
||||||
|
.ach-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
|
||||||
|
&.me {
|
||||||
|
background: rgba(255, 203, 61, 0.22);
|
||||||
|
border: 1px solid rgba(224, 162, 0, 0.5);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-rank {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 28px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-title {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b5b2e;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-stats {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-leaderboard-golden {
|
||||||
|
color: #e0a200;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Modal } from "react-bootstrap";
|
||||||
|
import {
|
||||||
|
ButterflyLeaderboardEntry, ButterflyStats, ButterflyAchievement,
|
||||||
|
getButterflyLeaderboard, getButterflyAchievements,
|
||||||
|
} from "../../../../types";
|
||||||
|
import { useAuth } from "../../context/auth";
|
||||||
|
import "./ButterflyLeaderboardModal.scss";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
myStats?: ButterflyStats;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
||||||
|
|
||||||
|
/** Modální dialog s osobními statistikami, achievementy a týmovým žebříčkem. */
|
||||||
|
export default function ButterflyLeaderboardModal({ isOpen, onClose, myStats }: Readonly<Props>) {
|
||||||
|
const auth = useAuth();
|
||||||
|
const [entries, setEntries] = useState<ButterflyLeaderboardEntry[]>();
|
||||||
|
const [achievements, setAchievements] = useState<ButterflyAchievement[]>();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([
|
||||||
|
getButterflyLeaderboard({ query: { limit: 20 } }).then(r => r.data ?? []).catch(() => []),
|
||||||
|
getButterflyAchievements().then(r => r.data ?? []).catch(() => []),
|
||||||
|
]).then(([lb, ach]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setEntries(lb);
|
||||||
|
setAchievements(ach);
|
||||||
|
}).finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={onClose} centered>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>🦋 Chytání motýlků</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{myStats && (
|
||||||
|
<div className="butterfly-mystats">
|
||||||
|
<div className="butterfly-mystats-title">
|
||||||
|
{myStats.level}. {myStats.title}
|
||||||
|
</div>
|
||||||
|
<div className="butterfly-mystats-grid">
|
||||||
|
<div><span className="v">{myStats.caught}</span><span className="l">🦋 chyceno</span></div>
|
||||||
|
<div><span className="v gold">{myStats.goldenCaught}</span><span className="l">✨ zlatých</span></div>
|
||||||
|
<div><span className="v">{myStats.coins}</span><span className="l">🪙 mincí</span></div>
|
||||||
|
<div><span className="v">{myStats.waspsKilled}</span><span className="l">🪰 vos zabito</span></div>
|
||||||
|
<div><span className="v">{myStats.birdsScared}</span><span className="l">🦅 vyplašeno</span></div>
|
||||||
|
<div><span className="v">{myStats.thievesDefeated}</span><span className="l">🦹 zlodějů</span></div>
|
||||||
|
<div><span className="v">{myStats.caterpillarsDefeated}</span><span className="l">🐛 housenek</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{achievements && achievements.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h6 className="butterfly-leaderboard-heading">Odznaky</h6>
|
||||||
|
<div className="butterfly-achievements">
|
||||||
|
{achievements.map(a => (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
className={a.unlocked ? 'butterfly-achievement unlocked' : 'butterfly-achievement'}
|
||||||
|
title={a.description}
|
||||||
|
>
|
||||||
|
<span className="ach-icon">{a.unlocked ? '🏅' : '🔒'}</span>
|
||||||
|
<span className="ach-title">{a.title}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h6 className="butterfly-leaderboard-heading">Žebříček týmu</h6>
|
||||||
|
{loading && <p className="text-center text-muted mb-0">Načítám žebříček…</p>}
|
||||||
|
{!loading && entries && entries.length === 0 && (
|
||||||
|
<p className="text-center text-muted mb-0">Zatím nikdo nic nechytil. Buď první!</p>
|
||||||
|
)}
|
||||||
|
{!loading && entries && entries.length > 0 && (
|
||||||
|
<ol className="butterfly-leaderboard">
|
||||||
|
{entries.map((e, i) => (
|
||||||
|
<li
|
||||||
|
key={e.login}
|
||||||
|
className={e.login === auth?.login ? 'butterfly-leaderboard-row me' : 'butterfly-leaderboard-row'}
|
||||||
|
>
|
||||||
|
<span className="butterfly-leaderboard-rank">{MEDALS[i] ?? `${i + 1}.`}</span>
|
||||||
|
<span className="butterfly-leaderboard-name" title={e.title}>
|
||||||
|
{e.login}
|
||||||
|
<span className="butterfly-leaderboard-title">{e.level}. {e.title}</span>
|
||||||
|
</span>
|
||||||
|
<span className="butterfly-leaderboard-stats">
|
||||||
|
<span className="butterfly-leaderboard-caught">{e.caught} 🦋</span>
|
||||||
|
{e.goldenCaught > 0 && (
|
||||||
|
<span className="butterfly-leaderboard-golden">{e.goldenCaught} ✨</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
.shop-balance {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #e0a200;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-heading {
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 14px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-shop-daily {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(28, 126, 214, 0.12);
|
||||||
|
border: 1px solid rgba(28, 126, 214, 0.35);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.daily-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-bar-inner {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #4dabf7, #1c7ed6);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-status {
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #495057;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-shop-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.butterfly-shop-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.12s ease, transform 0.08s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #f1f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: scale(0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item-lvl {
|
||||||
|
display: inline;
|
||||||
|
color: #1c7ed6;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item-desc {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: #868e96;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item-cost {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #e0a200;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Modal } from "react-bootstrap";
|
||||||
|
import { ButterflyStats } from "../../../../types";
|
||||||
|
import "./ButterflyShopModal.scss";
|
||||||
|
|
||||||
|
type UpgradeId = 'net' | 'scarecrow' | 'reinforced';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
stats?: ButterflyStats;
|
||||||
|
onBuyPremium: () => void;
|
||||||
|
onWaspSpray: () => void;
|
||||||
|
onRepellent: () => void;
|
||||||
|
onBuyInsurance: () => void;
|
||||||
|
onBuyUpgrade: (item: UpgradeId) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Zrcadlí serverové hodnoty (server/src/butterflies.ts)
|
||||||
|
const PREMIUM_BUY_COST = 40;
|
||||||
|
const WASP_SPRAY_COST = 25;
|
||||||
|
const REPELLENT_COST = 70;
|
||||||
|
const INSURANCE_COST = 50;
|
||||||
|
const UPGRADE_BASE: Record<UpgradeId, number> = { net: 100, scarecrow: 120, reinforced: 150 };
|
||||||
|
const UPGRADE_MAX: Record<UpgradeId, number> = { net: 5, scarecrow: 4, reinforced: 3 };
|
||||||
|
const UPGRADE_META: Record<UpgradeId, { icon: string; title: string; desc: string }> = {
|
||||||
|
net: { icon: '🥅', title: 'Větší síťka', desc: 'Zvětší dosah chytání' },
|
||||||
|
scarecrow: { icon: '🐦⬛', title: 'Strašák', desc: 'Ptáci přibývají pomaleji' },
|
||||||
|
reinforced: { icon: '🧵', title: 'Zpevněná síťka', desc: 'Rychlejší samo-zašití' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function upgradeCost(item: UpgradeId, level: number): number {
|
||||||
|
return UPGRADE_BASE[item] * Math.pow(2, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Obchod: spotřební pomůcky a trvalá vylepšení za mince. */
|
||||||
|
export default function ButterflyShopModal({
|
||||||
|
isOpen, onClose, stats, onBuyPremium, onWaspSpray, onRepellent, onBuyInsurance, onBuyUpgrade,
|
||||||
|
}: Readonly<Props>) {
|
||||||
|
const coins = stats?.coins ?? 0;
|
||||||
|
const insuranceActive = !!stats && stats.insuranceUntil > Date.now();
|
||||||
|
|
||||||
|
const consumable = (label: string, cost: number, onBuy: () => void, extra?: string) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="butterfly-shop-item"
|
||||||
|
onClick={onBuy}
|
||||||
|
disabled={coins < cost}
|
||||||
|
>
|
||||||
|
<span className="shop-item-label">{label}{extra ? ` ${extra}` : ''}</span>
|
||||||
|
<span className="shop-item-cost">{cost} 🪙</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={onClose} centered>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>🛒 Obchod <span className="shop-balance">{coins} 🪙</span></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{stats?.daily && (
|
||||||
|
<div className="butterfly-shop-daily">
|
||||||
|
<div className="daily-title">🎯 Denní úkol: {stats.daily.title}</div>
|
||||||
|
<div className="daily-bar">
|
||||||
|
<div
|
||||||
|
className="daily-bar-inner"
|
||||||
|
style={{ width: `${Math.min(100, Math.round((stats.daily.progress / stats.daily.target) * 100))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="daily-status">
|
||||||
|
{stats.daily.done
|
||||||
|
? `✅ Splněno (+${stats.daily.reward} 🪙)`
|
||||||
|
: `${stats.daily.progress}/${stats.daily.target} · odměna ${stats.daily.reward} 🪙`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h6 className="shop-heading">Pomůcky</h6>
|
||||||
|
<div className="butterfly-shop-list">
|
||||||
|
{consumable('🪄 Prémiová síťka (60 s)', PREMIUM_BUY_COST, onBuyPremium)}
|
||||||
|
{consumable('💨 Vosí sprej (vyhubí vosy)', WASP_SPRAY_COST, onWaspSpray)}
|
||||||
|
{consumable('🦅🚫 Plašič ptáků', REPELLENT_COST, onRepellent)}
|
||||||
|
{consumable('🛡️ Pojistka proti zloději', INSURANCE_COST, onBuyInsurance, insuranceActive ? '(aktivní)' : '')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 className="shop-heading">Trvalá vylepšení</h6>
|
||||||
|
<div className="butterfly-shop-list">
|
||||||
|
{(Object.keys(UPGRADE_META) as UpgradeId[]).map(item => {
|
||||||
|
const level = stats?.upgrades?.[item] ?? 0;
|
||||||
|
const max = UPGRADE_MAX[item];
|
||||||
|
const maxed = level >= max;
|
||||||
|
const cost = upgradeCost(item, level);
|
||||||
|
const meta = UPGRADE_META[item];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
className="butterfly-shop-item upgrade"
|
||||||
|
onClick={() => onBuyUpgrade(item)}
|
||||||
|
disabled={maxed || coins < cost}
|
||||||
|
>
|
||||||
|
<span className="shop-item-label">
|
||||||
|
{meta.icon} {meta.title} <span className="shop-item-lvl">{level}/{max}</span>
|
||||||
|
<span className="shop-item-desc">{meta.desc}</span>
|
||||||
|
</span>
|
||||||
|
<span className="shop-item-cost">{maxed ? 'MAX' : `${cost} 🪙`}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Modal, Button, Form, Alert } from "react-bootstrap";
|
||||||
|
import { clearMockData, DayIndex } from "../../../../types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
currentDayIndex?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek'];
|
||||||
|
|
||||||
|
/** Hodnota v selectu dne pro smazání dat celého týdne. */
|
||||||
|
const WHOLE_WEEK = 'week';
|
||||||
|
|
||||||
|
/** Modální dialog pro smazání mock dat (pouze DEV). */
|
||||||
|
export default function ClearMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly<Props>) {
|
||||||
|
const [daySelection, setDaySelection] = useState<string>(currentDayIndex !== undefined ? String(currentDayIndex) : '');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const wholeWeek = daySelection === WHOLE_WEEK;
|
||||||
|
|
||||||
|
const handleClear = async () => {
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body: any = {};
|
||||||
|
if (wholeWeek) {
|
||||||
|
body.wholeWeek = true;
|
||||||
|
} else if (daySelection !== '') {
|
||||||
|
body.dayIndex = parseInt(daySelection, 10) as DayIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await clearMockData({ body });
|
||||||
|
if (response.error) {
|
||||||
|
setError((response.error as any).error || 'Nastala chyba při mazání dat');
|
||||||
|
} else {
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
setSuccess(false);
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Nastala chyba při mazání dat');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Popis rozsahu mazání pro potvrzovací otázku
|
||||||
|
let scopeLabel: string;
|
||||||
|
if (wholeWeek) {
|
||||||
|
scopeLabel = 'celý týden (pondělí až pátek)';
|
||||||
|
} else if (daySelection !== '') {
|
||||||
|
scopeLabel = DAY_NAMES[parseInt(daySelection, 10)];
|
||||||
|
} else {
|
||||||
|
scopeLabel = 'aktuální den';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={handleClose}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>Smazat mock data</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{success ? (
|
||||||
|
<Alert variant="success">
|
||||||
|
Data byla úspěšně smazána!
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Alert variant="warning">
|
||||||
|
<strong>DEV režim</strong> - Tato funkce je dostupná pouze ve vývojovém prostředí.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Den</Form.Label>
|
||||||
|
<Form.Select
|
||||||
|
value={daySelection}
|
||||||
|
onChange={e => setDaySelection(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Aktuální den</option>
|
||||||
|
{DAY_NAMES.map((name, index) => (
|
||||||
|
<option key={index} value={index}>{name}</option>
|
||||||
|
))}
|
||||||
|
<option value={WHOLE_WEEK}>Celý týden</option>
|
||||||
|
</Form.Select>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Opravdu chcete smazat všechny volby stravování pro <strong>{scopeLabel}</strong>?
|
||||||
|
</p>
|
||||||
|
<p className="text-muted">
|
||||||
|
Tato akce je nevratná.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
{!success && (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={handleClose} disabled={loading}>
|
||||||
|
Ne, zrušit
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={handleClear} disabled={loading}>
|
||||||
|
{loading ? 'Mažu...' : 'Ano, smazat'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
|
Zavřít
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Modal, Button } from "react-bootstrap";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
confirmVariant?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ConfirmModal({ isOpen, title, message, confirmLabel = "Potvrdit", confirmVariant = "primary", onConfirm, onClose }: Readonly<Props>) {
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={onClose}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>{title}</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>{message}</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={onClose}>Zrušit</Button>
|
||||||
|
<Button variant={confirmVariant} onClick={onConfirm}>{confirmLabel}</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
||||||
|
import { updateGroupFees, OrderGroup, OrderGroupMember } from "../../../../types";
|
||||||
|
import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
group: OrderGroup;
|
||||||
|
onSaved: (data: any) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseHal(s: string): number {
|
||||||
|
const n = parseFloat(s.replace(',', '.'));
|
||||||
|
return isNaN(n) || n < 0 ? 0 : Math.round(n * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePercent(s: string): number {
|
||||||
|
const n = parseFloat(s.replace(',', '.'));
|
||||||
|
return isNaN(n) || n < 0 ? 0 : Math.round(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }: Readonly<Props>) {
|
||||||
|
const [fees, setFees] = useState('');
|
||||||
|
const [shipping, setShipping] = useState('');
|
||||||
|
const [tip, setTip] = useState('');
|
||||||
|
const [discountType, setDiscountType] = useState<'percent' | 'fixed'>('percent');
|
||||||
|
const [discountValue, setDiscountValue] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
setFees(group.fees ? String(group.fees / 100) : '');
|
||||||
|
setShipping(group.shipping ? String(group.shipping / 100) : '');
|
||||||
|
setTip(group.tip ? String(group.tip / 100) : '');
|
||||||
|
setDiscountType((group.discountType as 'percent' | 'fixed') ?? 'percent');
|
||||||
|
setDiscountValue(group.discountValue
|
||||||
|
? ((group.discountType as string) === 'fixed' ? String(group.discountValue / 100) : String(group.discountValue))
|
||||||
|
: '');
|
||||||
|
setError(null);
|
||||||
|
}, [isOpen, group]);
|
||||||
|
|
||||||
|
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
|
||||||
|
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
|
||||||
|
const activeCount = countActiveMembers(group.members);
|
||||||
|
|
||||||
|
const feesNum = parseHal(fees);
|
||||||
|
const shippingNum = parseHal(shipping);
|
||||||
|
const tipNum = parseHal(tip);
|
||||||
|
const discountNum = discountType === 'percent' ? parsePercent(discountValue) : parseHal(discountValue);
|
||||||
|
const totalFees = feesNum + shippingNum + tipNum;
|
||||||
|
const feeShare = computeFeeShare(totalFees, activeCount);
|
||||||
|
const feeParams = { totalFees, discountType, discountValue: discountNum };
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await updateGroupFees({
|
||||||
|
body: {
|
||||||
|
id: group.id,
|
||||||
|
fees: feesNum,
|
||||||
|
shipping: shippingNum,
|
||||||
|
tip: tipNum,
|
||||||
|
discountType: discountNum > 0 ? discountType : undefined,
|
||||||
|
discountValue: discountNum > 0 ? discountNum : undefined,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (res.error) {
|
||||||
|
setError((res.error as any).error || 'Nastala chyba');
|
||||||
|
} else {
|
||||||
|
onSaved(res.data);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Nastala chyba');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={onClose} size="lg">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>Poplatky skupiny — {group.name}</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{error && (
|
||||||
|
<Alert variant="danger" onClose={() => setError(null)} dismissible>{error}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="d-flex gap-3 flex-wrap mb-3">
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>Poplatky (Kč)</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="number" min={0} step={0.01}
|
||||||
|
value={fees} onChange={e => setFees(e.target.value)}
|
||||||
|
placeholder="0" style={{ width: 110 }}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>Doprava (Kč)</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="number" min={0} step={0.01}
|
||||||
|
value={shipping} onChange={e => setShipping(e.target.value)}
|
||||||
|
placeholder="0" style={{ width: 110 }}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>Spropitné (Kč)</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="number" min={0} step={0.01}
|
||||||
|
value={tip} onChange={e => setTip(e.target.value)}
|
||||||
|
placeholder="0" style={{ width: 110 }}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex gap-3 align-items-end flex-wrap mb-3">
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>Sleva</Form.Label>
|
||||||
|
<div className="d-flex gap-2 align-items-center">
|
||||||
|
<Form.Select
|
||||||
|
value={discountType}
|
||||||
|
onChange={e => setDiscountType(e.target.value as 'percent' | 'fixed')}
|
||||||
|
style={{ width: 160 }}
|
||||||
|
>
|
||||||
|
<option value="percent">Procentuální (%)</option>
|
||||||
|
<option value="fixed">Pevná částka (Kč)</option>
|
||||||
|
</Form.Select>
|
||||||
|
<Form.Control
|
||||||
|
type="number" min={0} step={discountType === 'percent' ? 1 : 0.01}
|
||||||
|
value={discountValue} onChange={e => setDiscountValue(e.target.value)}
|
||||||
|
placeholder="0" style={{ width: 100 }}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<span className="text-muted">{discountType === 'percent' ? '%' : 'Kč'}</span>
|
||||||
|
</div>
|
||||||
|
</Form.Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<h6>Náhled celkových částek ({activeCount} {activeCount === 1 ? 'strávník' : 'strávníků'} s objednávkou, {feeShare > 0 ? `poplatek ${feeShare / 100} Kč/os.` : 'bez poplatku'})</h6>
|
||||||
|
<Table size="sm" bordered>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Člen</th>
|
||||||
|
<th className="text-end">Základ</th>
|
||||||
|
<th className="text-end">Příplatek</th>
|
||||||
|
<th className="text-end">Poplatek</th>
|
||||||
|
<th className="text-end">Sleva</th>
|
||||||
|
<th className="text-end fw-bold">Celkem</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{memberEntries.map(([login, member]) => {
|
||||||
|
const base = member.amount ?? 0;
|
||||||
|
const surcharge = member.surchargeAmount ?? 0;
|
||||||
|
const active = isActiveMember(member);
|
||||||
|
const total = computeMemberTotal(member, feeParams, feeShare, activeCount);
|
||||||
|
// Sleva i poplatek se týkají jen aktivních strávníků.
|
||||||
|
const discount = active && discountNum > 0
|
||||||
|
? (discountType === 'percent'
|
||||||
|
? Math.round((base + surcharge) * discountNum / 100)
|
||||||
|
: Math.round(discountNum / activeCount))
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<tr key={login} className={active ? '' : 'text-muted'}>
|
||||||
|
<td><strong>{login}</strong>{!active && <small className="ms-1">(jen objednává)</small>}</td>
|
||||||
|
<td className="text-end">{base > 0 ? `${base / 100} Kč` : '—'}</td>
|
||||||
|
<td className="text-end">{surcharge > 0 ? `${surcharge / 100} Kč` : '—'}</td>
|
||||||
|
<td className="text-end">{active && feeShare > 0 ? `${feeShare / 100} Kč` : '—'}</td>
|
||||||
|
<td className="text-end text-danger">{discount > 0 ? `-${discount / 100} Kč` : '—'}</td>
|
||||||
|
<td className="text-end fw-bold">{total > 0 ? `${total / 100} Kč` : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={loading}>Storno</Button>
|
||||||
|
<Button variant="primary" onClick={handleSave} disabled={loading}>
|
||||||
|
{loading ? 'Ukládám...' : 'Uložit'}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { Modal, Button, Form } from "react-bootstrap"
|
|
||||||
import { FeatureRequest } from "../../../../types";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
isOpen: boolean,
|
|
||||||
onClose: () => void,
|
|
||||||
onChange: (option: FeatureRequest, active: boolean) => void,
|
|
||||||
initialValues?: FeatureRequest[],
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Modální dialog pro hlasování o nových funkcích. */
|
|
||||||
export default function FeaturesVotingModal({ isOpen, onClose, onChange, initialValues }: Props) {
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
onChange(e.currentTarget.value as FeatureRequest, e.currentTarget.checked);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Modal show={isOpen} onHide={onClose} size="lg">
|
|
||||||
<Modal.Header closeButton>
|
|
||||||
<Modal.Title>
|
|
||||||
Hlasujte pro nové funkce
|
|
||||||
<p style={{ fontSize: '12px' }}>Je možno vybrat maximálně 4 možnosti</p>
|
|
||||||
</Modal.Title>
|
|
||||||
</Modal.Header>
|
|
||||||
<Modal.Body>
|
|
||||||
{(Object.keys(FeatureRequest) as Array<keyof typeof FeatureRequest>).map(key => {
|
|
||||||
return <Form.Check
|
|
||||||
key={key}
|
|
||||||
type='checkbox'
|
|
||||||
id={key}
|
|
||||||
label={FeatureRequest[key]}
|
|
||||||
onChange={handleChange}
|
|
||||||
value={key}
|
|
||||||
defaultChecked={initialValues && initialValues.includes(key as FeatureRequest)}
|
|
||||||
/>
|
|
||||||
})}
|
|
||||||
<p className="mt-3" style={{ fontSize: '12px' }}>Něco jiného? Dejte vědět.</p>
|
|
||||||
</Modal.Body>
|
|
||||||
<Modal.Footer>
|
|
||||||
<Button variant="primary" onClick={onClose}>
|
|
||||||
Zavřít
|
|
||||||
</Button>
|
|
||||||
</Modal.Footer>
|
|
||||||
</Modal>
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Modal, Button, Form, Alert } from "react-bootstrap";
|
||||||
|
import { generateMockData, DayIndex } from "../../../../types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
currentDayIndex?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek'];
|
||||||
|
|
||||||
|
/** Hodnota v selectu dne pro generování napříč celým týdnem. */
|
||||||
|
const WHOLE_WEEK = 'week';
|
||||||
|
|
||||||
|
/** Modální dialog pro generování mock dat (pouze DEV). */
|
||||||
|
export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly<Props>) {
|
||||||
|
const [daySelection, setDaySelection] = useState<string>(currentDayIndex !== undefined ? String(currentDayIndex) : '');
|
||||||
|
const [count, setCount] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const wholeWeek = daySelection === WHOLE_WEEK;
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body: any = {};
|
||||||
|
if (wholeWeek) {
|
||||||
|
body.wholeWeek = true;
|
||||||
|
} else if (daySelection !== '') {
|
||||||
|
body.dayIndex = parseInt(daySelection, 10) as DayIndex;
|
||||||
|
}
|
||||||
|
if (count && count.trim() !== '') {
|
||||||
|
const countNum = parseInt(count, 10);
|
||||||
|
if (isNaN(countNum) || countNum < 1 || countNum > 100) {
|
||||||
|
setError('Počet musí být číslo mezi 1 a 100');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.count = countNum;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await generateMockData({ body });
|
||||||
|
if (response.error) {
|
||||||
|
setError((response.error as any).error || 'Nastala chyba při generování dat');
|
||||||
|
} else {
|
||||||
|
const total = (response.data as any)?.count;
|
||||||
|
setSuccess(wholeWeek
|
||||||
|
? `Mock data byla vygenerována pro celý týden (celkem ${total} záznamů).`
|
||||||
|
: 'Mock data byla úspěšně vygenerována!');
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
setSuccess(null);
|
||||||
|
setCount('');
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Nastala chyba při generování dat');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
setCount('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={handleClose}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>Generovat mock data</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{success ? (
|
||||||
|
<Alert variant="success">
|
||||||
|
{success}
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Alert variant="warning">
|
||||||
|
<strong>DEV režim</strong> - Tato funkce je dostupná pouze ve vývojovém prostředí.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Den</Form.Label>
|
||||||
|
<Form.Select
|
||||||
|
value={daySelection}
|
||||||
|
onChange={e => setDaySelection(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Aktuální den</option>
|
||||||
|
{DAY_NAMES.map((name, index) => (
|
||||||
|
<option key={index} value={index}>{name}</option>
|
||||||
|
))}
|
||||||
|
<option value={WHOLE_WEEK}>Celý týden</option>
|
||||||
|
</Form.Select>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Pokud není vybráno, použije se aktuální den.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Počet záznamů</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="number"
|
||||||
|
placeholder="Náhodný (5-20)"
|
||||||
|
value={count}
|
||||||
|
onChange={e => setCount(e.target.value)}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
{wholeWeek
|
||||||
|
? 'Zadaný počet se vygeneruje pro každý den týdne. Pokud není zadáno, vybere se pro každý den náhodný počet 5-20.'
|
||||||
|
: 'Pokud není zadáno, vybere se náhodný počet 5-20.'}
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
{!success && (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={handleClose} disabled={loading}>
|
||||||
|
Storno
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={handleGenerate} disabled={loading}>
|
||||||
|
{loading ? 'Generuji...' : 'Generovat'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
|
Zavřít
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Modal, Button, Form, Alert } from "react-bootstrap";
|
||||||
|
import { generateMockOrders } from "../../../../types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Příznak, zda je pro dnešní den založena alespoň jedna skupina objednávky. */
|
||||||
|
hasGroups: boolean;
|
||||||
|
/** Příznak, zda je nadefinován alespoň jeden obchod (bez něj nelze generovat). */
|
||||||
|
hasStores: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_COUNT = '3';
|
||||||
|
const DEFAULT_MIN_AMOUNT = '100';
|
||||||
|
const DEFAULT_MAX_AMOUNT = '1000';
|
||||||
|
|
||||||
|
/** Vybere český tvar slova podle počtu (1 osoba / 2 osoby / 5 osob). */
|
||||||
|
function plural(count: number, one: string, few: string, many: string): string {
|
||||||
|
if (count === 1) return one;
|
||||||
|
if (count >= 2 && count <= 4) return few;
|
||||||
|
return many;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Modální dialog pro generování mock dat sekce Objednání (pouze DEV). */
|
||||||
|
export default function GenerateMockOrdersModal({ isOpen, onClose, hasGroups, hasStores }: Readonly<Props>) {
|
||||||
|
const [count, setCount] = useState<string>(DEFAULT_COUNT);
|
||||||
|
const [minAmount, setMinAmount] = useState<string>(DEFAULT_MIN_AMOUNT);
|
||||||
|
const [maxAmount, setMaxAmount] = useState<string>(DEFAULT_MAX_AMOUNT);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const countNum = parseInt(count, 10);
|
||||||
|
if (isNaN(countNum) || countNum < 1 || countNum > 50) {
|
||||||
|
setError('Počet osob musí být číslo mezi 1 a 50');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const minNum = parseInt(minAmount, 10);
|
||||||
|
const maxNum = parseInt(maxAmount, 10);
|
||||||
|
if (isNaN(minNum) || isNaN(maxNum) || minNum < 0 || maxNum < minNum) {
|
||||||
|
setError('Zadejte platný rozsah částek (od ≤ do)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await generateMockOrders({
|
||||||
|
body: { count: countNum, minAmount: minNum, maxAmount: maxNum },
|
||||||
|
});
|
||||||
|
if (response.error) {
|
||||||
|
setError((response.error as any).error || 'Nastala chyba při generování dat');
|
||||||
|
} else {
|
||||||
|
const groups = response.data?.groups ?? [];
|
||||||
|
const total = response.data?.count ?? 0;
|
||||||
|
const created = groups.find(g => g.created);
|
||||||
|
setSuccess(
|
||||||
|
`Doplněno: ${total} ${plural(total, 'osoba', 'osoby', 'osob')}`
|
||||||
|
+ ` / ${groups.length} ${plural(groups.length, 'skupina', 'skupiny', 'skupin')}`
|
||||||
|
+ (created ? ` (skupina „${created.name}“ byla nově založena).` : '.')
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
setSuccess(null);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Nastala chyba při generování dat');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={handleClose}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>Generovat mock objednávky</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{success ? (
|
||||||
|
<Alert variant="success">{success}</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Alert variant="warning">
|
||||||
|
<strong>DEV režim</strong> - Tato funkce je dostupná pouze ve vývojovém prostředí.
|
||||||
|
Data se generují vždy jen pro aktuální den.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasStores ? (
|
||||||
|
<>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Počet osob</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="number"
|
||||||
|
value={count}
|
||||||
|
onChange={e => setCount(e.target.value)}
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
{hasGroups
|
||||||
|
? 'Zadaný počet osob se doplní do každé existující skupiny.'
|
||||||
|
: 'Není založena žádná skupina — jedna se založí z náhodně vybraného obchodu a doplní se do ní zadaný počet osob.'}
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Rozsah částek (Kč)</Form.Label>
|
||||||
|
<div className="d-flex gap-2 align-items-center">
|
||||||
|
<Form.Control
|
||||||
|
type="number"
|
||||||
|
value={minAmount}
|
||||||
|
onChange={e => setMinAmount(e.target.value)}
|
||||||
|
min={0}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<span>—</span>
|
||||||
|
<Form.Control
|
||||||
|
type="number"
|
||||||
|
value={maxAmount}
|
||||||
|
onChange={e => setMaxAmount(e.target.value)}
|
||||||
|
min={0}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Každé osobě se nastaví náhodná částka z tohoto rozmezí a do poznámky náhodné jídlo.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted mb-0">
|
||||||
|
Není nadefinován žádný obchod — mock objednávky nelze vygenerovat.
|
||||||
|
Nejprve přidejte obchod ve správě obchodů.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
{!success && (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={handleClose} disabled={loading}>
|
||||||
|
Storno
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={handleGenerate} disabled={loading || !hasStores}>
|
||||||
|
{loading ? 'Generuji...' : 'Generovat'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
|
Zavřít
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
||||||
|
import { generateQr, LunchChoices, QrRecipient } from "../../../../types";
|
||||||
|
|
||||||
|
type UserEntry = {
|
||||||
|
login: string;
|
||||||
|
selected: boolean;
|
||||||
|
purpose: string;
|
||||||
|
amount: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
choices: LunchChoices;
|
||||||
|
bankAccount: string;
|
||||||
|
bankAccountHolder: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Modální dialog pro generování QR kódů pro platbu. */
|
||||||
|
export default function GenerateQrModal({ isOpen, onClose, choices, bankAccount, bankAccountHolder }: Readonly<Props>) {
|
||||||
|
const [users, setUsers] = useState<UserEntry[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
// Při otevření modálu načteme seznam uživatelů z choices
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && choices) {
|
||||||
|
const userLogins = new Set<string>();
|
||||||
|
// Projdeme všechny lokace a získáme unikátní loginy
|
||||||
|
Object.values(choices).forEach(locationChoices => {
|
||||||
|
if (locationChoices) {
|
||||||
|
Object.keys(locationChoices).forEach(login => {
|
||||||
|
userLogins.add(login);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Vytvoříme seznam uživatelů
|
||||||
|
const userList: UserEntry[] = Array.from(userLogins)
|
||||||
|
.sort((a, b) => a.localeCompare(b, 'cs'))
|
||||||
|
.map(login => ({
|
||||||
|
login,
|
||||||
|
selected: false,
|
||||||
|
purpose: '',
|
||||||
|
amount: '',
|
||||||
|
}));
|
||||||
|
setUsers(userList);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
}
|
||||||
|
}, [isOpen, choices]);
|
||||||
|
|
||||||
|
const handleCheckboxChange = useCallback((login: string, checked: boolean) => {
|
||||||
|
setUsers(prev => prev.map(u =>
|
||||||
|
u.login === login ? { ...u, selected: checked } : u
|
||||||
|
));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePurposeChange = useCallback((login: string, value: string) => {
|
||||||
|
setUsers(prev => prev.map(u =>
|
||||||
|
u.login === login ? { ...u, purpose: value } : u
|
||||||
|
));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAmountChange = useCallback((login: string, value: string) => {
|
||||||
|
// Povolíme pouze čísla, tečku a čárku
|
||||||
|
const sanitized = value.replace(/[^0-9.,]/g, '').replace(',', '.');
|
||||||
|
setUsers(prev => prev.map(u =>
|
||||||
|
u.login === login ? { ...u, amount: sanitized } : u
|
||||||
|
));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const validateAmount = (amountStr: string): number | null => {
|
||||||
|
if (!amountStr || amountStr.trim().length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const amount = parseFloat(amountStr);
|
||||||
|
if (isNaN(amount) || amount <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Max 2 desetinná místa
|
||||||
|
const parts = amountStr.split('.');
|
||||||
|
if (parts.length === 2 && parts[1].length > 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.round(amount * 100) / 100; // Zaokrouhlíme na 2 desetinná místa
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
setError(null);
|
||||||
|
const selectedUsers = users.filter(u => u.selected);
|
||||||
|
|
||||||
|
if (selectedUsers.length === 0) {
|
||||||
|
setError("Nebyl vybrán žádný uživatel");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validace
|
||||||
|
const recipients: QrRecipient[] = [];
|
||||||
|
for (const user of selectedUsers) {
|
||||||
|
if (!user.purpose || user.purpose.trim().length === 0) {
|
||||||
|
setError(`Uživatel ${user.login} nemá vyplněný účel platby`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const amount = validateAmount(user.amount);
|
||||||
|
if (amount === null) {
|
||||||
|
setError(`Uživatel ${user.login} má neplatnou částku (musí být kladné číslo s max. 2 desetinnými místy)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recipients.push({
|
||||||
|
login: user.login,
|
||||||
|
purpose: user.purpose.trim(),
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await generateQr({
|
||||||
|
body: {
|
||||||
|
recipients,
|
||||||
|
bankAccount,
|
||||||
|
bankAccountHolder,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (response.error) {
|
||||||
|
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
|
||||||
|
} else {
|
||||||
|
setSuccess(true);
|
||||||
|
// Po 2 sekundách zavřeme modal
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Nastala chyba při generování QR kódů');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedCount = users.filter(u => u.selected).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={handleClose} size="lg">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>Generování QR kódů</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{success ? (
|
||||||
|
<Alert variant="success">
|
||||||
|
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci "Nevyřízené platby".
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Vyberte uživatele, kterým chcete vygenerovat QR kód pro platbu.
|
||||||
|
QR kódy se uživatelům zobrazí v sekci "Nevyřízené platby".
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<Alert variant="info">
|
||||||
|
V tento den nemá žádný uživatel zvolenou možnost stravování.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Table striped bordered hover responsive>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: '50px' }}></th>
|
||||||
|
<th>Uživatel</th>
|
||||||
|
<th>Účel platby</th>
|
||||||
|
<th style={{ width: '120px' }}>Částka (Kč)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map(user => (
|
||||||
|
<tr key={user.login} className={user.selected ? '' : 'text-muted'}>
|
||||||
|
<td className="text-center">
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
checked={user.selected}
|
||||||
|
onChange={e => handleCheckboxChange(user.login, e.target.checked)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>{user.login}</td>
|
||||||
|
<td>
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
placeholder="např. Pizza prosciutto"
|
||||||
|
value={user.purpose}
|
||||||
|
onChange={e => handlePurposeChange(user.login, e.target.value)}
|
||||||
|
disabled={!user.selected}
|
||||||
|
size="sm"
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={user.amount}
|
||||||
|
onChange={e => handleAmountChange(user.login, e.target.value)}
|
||||||
|
disabled={!user.selected}
|
||||||
|
size="sm"
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
{!success && (
|
||||||
|
<>
|
||||||
|
<span className="me-auto text-muted">
|
||||||
|
Vybráno: {selectedCount} / {users.length}
|
||||||
|
</span>
|
||||||
|
<Button variant="secondary" onClick={handleClose} disabled={loading}>
|
||||||
|
Storno
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={loading || selectedCount === 0}
|
||||||
|
>
|
||||||
|
{loading ? 'Generuji...' : 'Generovat'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
|
Zavřít
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Modální dialog pro úpravu obecné poznámky. */
|
/** Modální dialog pro úpravu obecné poznámky. */
|
||||||
export default function NoteModal({ isOpen, onClose, onSave }: Props) {
|
export default function NoteModal({ isOpen, onClose, onSave }: Readonly<Props>) {
|
||||||
const note = useRef<HTMLInputElement>(null);
|
const note = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
||||||
|
import { generateQr, LunchChoice, LocationLunchChoicesMap, RestaurantDayMenu, QrRecipient } from "../../../../types";
|
||||||
|
import { parsePriceCzk } from "../../utils/parsePrice";
|
||||||
|
|
||||||
|
type DinerEntry = {
|
||||||
|
login: string;
|
||||||
|
selectedFoods: number[];
|
||||||
|
baseAmount: number;
|
||||||
|
baseAmountParseFailed: boolean;
|
||||||
|
surchargeText: string;
|
||||||
|
surchargeAmount: string;
|
||||||
|
included: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
locationKey: LunchChoice;
|
||||||
|
locationName: string;
|
||||||
|
locationChoices: LocationLunchChoicesMap;
|
||||||
|
menu: RestaurantDayMenu | undefined;
|
||||||
|
payerLogin: string;
|
||||||
|
bankAccount: string;
|
||||||
|
bankAccountHolder: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function sanitizeAmount(value: string): string {
|
||||||
|
return value.replace(/[^0-9.,]/g, '').replace(',', '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAmount(s: string): number | null {
|
||||||
|
if (!s || s.trim().length === 0) return null;
|
||||||
|
const n = parseFloat(s);
|
||||||
|
if (isNaN(n) || n < 0) return null;
|
||||||
|
return Math.round(n * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PayForAllModal({ isOpen, onClose, locationName, locationChoices, menu, payerLogin, bankAccount, bankAccountHolder }: Readonly<Props>) {
|
||||||
|
const [diners, setDiners] = useState<DinerEntry[]>([]);
|
||||||
|
const [tipTotal, setTipTotal] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const hasMenu = !!menu;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const entries: DinerEntry[] = Object.entries(locationChoices).map(([login, choice]) => {
|
||||||
|
const selectedFoods = choice.selectedFoods ?? [];
|
||||||
|
let baseAmount = 0;
|
||||||
|
let baseAmountParseFailed = false;
|
||||||
|
if (menu) {
|
||||||
|
for (const idx of selectedFoods) {
|
||||||
|
const priceKc = parsePriceCzk(menu.food?.[idx]?.price);
|
||||||
|
if (priceKc === null) {
|
||||||
|
baseAmountParseFailed = true;
|
||||||
|
} else {
|
||||||
|
baseAmount += Math.round(priceKc * 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
login,
|
||||||
|
selectedFoods,
|
||||||
|
baseAmount,
|
||||||
|
baseAmountParseFailed,
|
||||||
|
surchargeText: '',
|
||||||
|
surchargeAmount: '',
|
||||||
|
included: login !== payerLogin,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setDiners(entries);
|
||||||
|
setTipTotal('');
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
}, [isOpen, locationChoices, menu, payerLogin]);
|
||||||
|
|
||||||
|
const includedDiners = diners.filter(d => d.included && d.login !== payerLogin);
|
||||||
|
const tipPerPerson = (() => {
|
||||||
|
if (includedDiners.length === 0) return 0;
|
||||||
|
const tip = parseAmount(tipTotal);
|
||||||
|
if (tip === null || tip === 0) return 0;
|
||||||
|
const totalPeople = includedDiners.length + 1;
|
||||||
|
return Math.round(tip / totalPeople);
|
||||||
|
})();
|
||||||
|
const payerTipShare = (() => {
|
||||||
|
const tip = parseAmount(tipTotal);
|
||||||
|
if (!tip) return 0;
|
||||||
|
return tip - tipPerPerson * includedDiners.length;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const getTotal = (d: DinerEntry): number => {
|
||||||
|
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
|
||||||
|
const tip = d.login === payerLogin ? payerTipShare : tipPerPerson;
|
||||||
|
return d.baseAmount + surcharge + tip;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInclude = useCallback((login: string, checked: boolean) => {
|
||||||
|
setDiners(prev => prev.map(d => d.login === login ? { ...d, included: checked } : d));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSurchargeText = useCallback((login: string, value: string) => {
|
||||||
|
setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeText: value } : d));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSurchargeAmount = useCallback((login: string, value: string) => {
|
||||||
|
setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeAmount: sanitizeAmount(value) } : d));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
setError(null);
|
||||||
|
const recipients: QrRecipient[] = [];
|
||||||
|
|
||||||
|
for (const d of diners) {
|
||||||
|
if (!d.included || d.login === payerLogin) continue;
|
||||||
|
const total = getTotal(d);
|
||||||
|
if (total <= 0) {
|
||||||
|
setError(`Celková částka pro ${d.login} musí být kladná`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const foods = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
|
||||||
|
const purposeBase = `Oběd ${locationName}${foods ? ` — ${foods}` : ''}`;
|
||||||
|
recipients.push({
|
||||||
|
login: d.login,
|
||||||
|
purpose: purposeBase.substring(0, 60),
|
||||||
|
amount: total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
setError("Nebyl vybrán žádný příjemce");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await generateQr({
|
||||||
|
body: { recipients, bankAccount, bankAccountHolder },
|
||||||
|
});
|
||||||
|
if (response.error) {
|
||||||
|
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
|
||||||
|
} else {
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => onClose(), 2000);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Nastala chyba při generování QR kódů');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const anyParseFailed = diners.some(d => d.baseAmountParseFailed && d.included);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={onClose} size="lg">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>Zaplatit za všechny — {locationName}</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{success ? (
|
||||||
|
<Alert variant="success">
|
||||||
|
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci „Nevyřízené platby".
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a společné poplatky, poté vygenerujte QR kódy pro ostatní.</p>
|
||||||
|
|
||||||
|
{!hasMenu && (
|
||||||
|
<Alert variant="info">
|
||||||
|
Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{anyParseFailed && (
|
||||||
|
<Alert variant="warning">
|
||||||
|
U některých jídel se nepodařilo načíst cenu — doplňte ji ručně v sloupci Příplatek.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table striped bordered hover responsive size="sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 40 }}></th>
|
||||||
|
<th>Strávník</th>
|
||||||
|
<th>Jídla</th>
|
||||||
|
<th style={{ width: 220 }}>Příplatek</th>
|
||||||
|
<th style={{ width: 90 }}>Poplatek</th>
|
||||||
|
<th style={{ width: 90 }}>Celkem</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{diners.map(d => {
|
||||||
|
const isPayer = d.login === payerLogin;
|
||||||
|
const foodNames = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
|
||||||
|
const total = getTotal(d);
|
||||||
|
return (
|
||||||
|
<tr key={d.login} className={!d.included && !isPayer ? 'text-muted' : ''}>
|
||||||
|
<td className="text-center">
|
||||||
|
{isPayer ? (
|
||||||
|
<small className="text-muted">plátce</small>
|
||||||
|
) : (
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
checked={d.included}
|
||||||
|
onChange={e => handleInclude(d.login, e.target.checked)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td><strong>{d.login}</strong></td>
|
||||||
|
<td>
|
||||||
|
<small>
|
||||||
|
{foodNames || <span className="text-muted">—</span>}
|
||||||
|
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount / 100} Kč)</span>}
|
||||||
|
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
|
||||||
|
</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex gap-1">
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
placeholder="popis"
|
||||||
|
value={d.surchargeText}
|
||||||
|
onChange={e => handleSurchargeText(d.login, e.target.value)}
|
||||||
|
disabled={!isPayer && !d.included}
|
||||||
|
size="sm"
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
placeholder="Kč"
|
||||||
|
value={d.surchargeAmount}
|
||||||
|
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
|
||||||
|
disabled={!isPayer && !d.included}
|
||||||
|
size="sm"
|
||||||
|
style={{ width: 70 }}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="text-end">
|
||||||
|
{(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s / 100} Kč` : '—'; })()}
|
||||||
|
</td>
|
||||||
|
<td className="text-end fw-bold">
|
||||||
|
{`${total / 100} Kč`}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center gap-2 mt-2">
|
||||||
|
<label className="mb-0 text-nowrap">Poplatky celkem (Kč):</label>
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
placeholder="0"
|
||||||
|
value={tipTotal}
|
||||||
|
onChange={e => setTipTotal(sanitizeAmount(e.target.value))}
|
||||||
|
size="sm"
|
||||||
|
style={{ width: 100 }}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<small className="text-muted">
|
||||||
|
{includedDiners.length > 0 && tipPerPerson > 0
|
||||||
|
? `(${tipPerPerson / 100} Kč / osoba)`
|
||||||
|
: ''}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
{!success && (
|
||||||
|
<>
|
||||||
|
<span className="me-auto text-muted">
|
||||||
|
Příjemci: {includedDiners.length}
|
||||||
|
</span>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
||||||
|
Storno
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={loading || includedDiners.length === 0}
|
||||||
|
>
|
||||||
|
{loading ? 'Generuji...' : 'Vygenerovat QR'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
|
||||||
|
)}
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
||||||
|
import { generateQr, GroupState, 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;
|
||||||
|
// Generování QR je poslední krok objednávky — server skupinu zároveň překlopí do stavu Doručeno
|
||||||
|
const willBeMarkedDelivered = group.state !== GroupState.DELIVERED;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{willBeMarkedDelivered && (
|
||||||
|
<Alert variant="info">
|
||||||
|
Objednávka není ve stavu <strong>Doručeno</strong> — vygenerováním QR kódů se do tohoto stavu automaticky přepne.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p>Zaplatili jste za skupinu. Vyberte, komu vygenerovat QR kód k úhradě.</p>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,17 +10,17 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Modální dialog pro nastavení příplatků za pizzu. */
|
/** Modální dialog pro nastavení příplatků za pizzu. */
|
||||||
export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose, onSave, initialValues }: Props) {
|
export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose, onSave, initialValues }: Readonly<Props>) {
|
||||||
const textRef = useRef<HTMLInputElement>(null);
|
const textRef = useRef<HTMLInputElement>(null);
|
||||||
const priceRef = useRef<HTMLInputElement>(null);
|
const priceRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const doSubmit = () => {
|
const doSubmit = () => {
|
||||||
onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value || "0"));
|
onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value || "0"));
|
onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type Result = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Modální dialog pro výpočet výhodnosti pizzy. */
|
/** Modální dialog pro výpočet výhodnosti pizzy. */
|
||||||
export default function PizzaCalculatorModal({ isOpen, onClose }: Props) {
|
export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<Props>) {
|
||||||
const diameter1Ref = useRef<HTMLInputElement>(null);
|
const diameter1Ref = useRef<HTMLInputElement>(null);
|
||||||
const price1Ref = useRef<HTMLInputElement>(null);
|
const price1Ref = useRef<HTMLInputElement>(null);
|
||||||
const diameter2Ref = useRef<HTMLInputElement>(null);
|
const diameter2Ref = useRef<HTMLInputElement>(null);
|
||||||
@@ -36,15 +36,13 @@ export default function PizzaCalculatorModal({ isOpen, onClose }: Props) {
|
|||||||
|
|
||||||
// 1. pizza
|
// 1. pizza
|
||||||
if (diameter1Ref.current?.value) {
|
if (diameter1Ref.current?.value) {
|
||||||
const diameter1 = parseInt(diameter1Ref.current?.value);
|
const diameter1 = Number.parseInt(diameter1Ref.current?.value);
|
||||||
if (!r.pizza1) {
|
r.pizza1 ??= {};
|
||||||
r.pizza1 = {};
|
|
||||||
}
|
|
||||||
if (diameter1 && diameter1 > 0) {
|
if (diameter1 && diameter1 > 0) {
|
||||||
r.pizza1.diameter = diameter1;
|
r.pizza1.diameter = diameter1;
|
||||||
r.pizza1.area = Math.PI * Math.pow(diameter1 / 2, 2);
|
r.pizza1.area = Math.PI * Math.pow(diameter1 / 2, 2);
|
||||||
if (price1Ref.current?.value) {
|
if (price1Ref.current?.value) {
|
||||||
const price1 = parseInt(price1Ref.current?.value);
|
const price1 = Number.parseInt(price1Ref.current?.value);
|
||||||
if (price1) {
|
if (price1) {
|
||||||
r.pizza1.pricePerM = price1 / r.pizza1.area;
|
r.pizza1.pricePerM = price1 / r.pizza1.area;
|
||||||
} else {
|
} else {
|
||||||
@@ -58,15 +56,13 @@ export default function PizzaCalculatorModal({ isOpen, onClose }: Props) {
|
|||||||
|
|
||||||
// 2. pizza
|
// 2. pizza
|
||||||
if (diameter2Ref.current?.value) {
|
if (diameter2Ref.current?.value) {
|
||||||
const diameter2 = parseInt(diameter2Ref.current?.value);
|
const diameter2 = Number.parseInt(diameter2Ref.current?.value);
|
||||||
if (!r.pizza2) {
|
r.pizza2 ??= {};
|
||||||
r.pizza2 = {};
|
|
||||||
}
|
|
||||||
if (diameter2 && diameter2 > 0) {
|
if (diameter2 && diameter2 > 0) {
|
||||||
r.pizza2.diameter = diameter2;
|
r.pizza2.diameter = diameter2;
|
||||||
r.pizza2.area = Math.PI * Math.pow(diameter2 / 2, 2);
|
r.pizza2.area = Math.PI * Math.pow(diameter2 / 2, 2);
|
||||||
if (price2Ref.current?.value) {
|
if (price2Ref.current?.value) {
|
||||||
const price2 = parseInt(price2Ref.current?.value);
|
const price2 = Number.parseInt(price2Ref.current?.value);
|
||||||
if (price2) {
|
if (price2) {
|
||||||
r.pizza2.pricePerM = price2 / r.pizza2.area;
|
r.pizza2.pricePerM = price2 / r.pizza2.area;
|
||||||
} else {
|
} else {
|
||||||
@@ -81,8 +77,8 @@ export default function PizzaCalculatorModal({ isOpen, onClose }: Props) {
|
|||||||
// Srovnání
|
// Srovnání
|
||||||
if (r.pizza1?.pricePerM && r.pizza2?.pricePerM && r.pizza1.diameter && r.pizza2.diameter) {
|
if (r.pizza1?.pricePerM && r.pizza2?.pricePerM && r.pizza1.diameter && r.pizza2.diameter) {
|
||||||
r.choice = r.pizza1.pricePerM < r.pizza2.pricePerM ? 1 : 2;
|
r.choice = r.pizza1.pricePerM < r.pizza2.pricePerM ? 1 : 2;
|
||||||
const bigger = r.pizza1.pricePerM > r.pizza2.pricePerM ? r.pizza1.pricePerM : r.pizza2.pricePerM;
|
const bigger = Math.max(r.pizza1.pricePerM, r.pizza2.pricePerM);
|
||||||
const smaller = r.pizza1.pricePerM < r.pizza2.pricePerM ? r.pizza1.pricePerM : r.pizza2.pricePerM;
|
const smaller = Math.min(r.pizza1.pricePerM, r.pizza2.pricePerM);
|
||||||
r.ratio = (bigger / smaller) - 1;
|
r.ratio = (bigger / smaller) - 1;
|
||||||
r.diameterDiff = Math.abs(r.pizza1.diameter - r.pizza2.diameter);
|
r.diameterDiff = Math.abs(r.pizza1.diameter - r.pizza2.diameter);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Modal, Button, Alert, Form } from "react-bootstrap";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Modální dialog pro přenačtení menu z restaurací. */
|
||||||
|
export default function RefreshMenuModal({ isOpen, onClose }: Readonly<Props>) {
|
||||||
|
const refreshPassRef = useRef<HTMLInputElement>(null);
|
||||||
|
const refreshTypeRef = useRef<HTMLSelectElement>(null);
|
||||||
|
const [refreshLoading, setRefreshLoading] = useState(false);
|
||||||
|
const [refreshMessage, setRefreshMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
const password = refreshPassRef.current?.value;
|
||||||
|
const type = refreshTypeRef.current?.value;
|
||||||
|
if (!password || !type) {
|
||||||
|
setRefreshMessage({ type: 'error', text: 'Zadejte heslo a typ refresh.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRefreshLoading(true);
|
||||||
|
setRefreshMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/food/refresh?type=${type}&heslo=${encodeURIComponent(password)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setRefreshMessage({ type: 'success', text: 'Uspesny fetch' });
|
||||||
|
if (refreshPassRef.current) {
|
||||||
|
refreshPassRef.current.value = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setRefreshMessage({ type: 'error', text: data.error || 'Chyba při obnovování jídelníčku.' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing menu:', error);
|
||||||
|
setRefreshMessage({ type: 'error', text: 'Chyba při obnovování jídelníčku.' });
|
||||||
|
} finally {
|
||||||
|
setRefreshLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setRefreshMessage(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={handleClose}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>Přenačtení menu</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<p>Ruční refresh dat z restaurací.</p>
|
||||||
|
|
||||||
|
{refreshMessage && (
|
||||||
|
<Alert variant={refreshMessage.type === 'success' ? 'success' : 'danger'}>
|
||||||
|
{refreshMessage.text}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Heslo</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
ref={refreshPassRef}
|
||||||
|
type="password"
|
||||||
|
placeholder="Zadejte heslo"
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Typ refreshe</Form.Label>
|
||||||
|
<Form.Select ref={refreshTypeRef} defaultValue="week">
|
||||||
|
<option value="week">Týden</option>
|
||||||
|
<option value="day">Den</option>
|
||||||
|
</Form.Select>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={refreshLoading}
|
||||||
|
>
|
||||||
|
{refreshLoading ? 'Načítám...' : 'Obnovit menu'}
|
||||||
|
</Button>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
|
Zavřít
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,42 +1,309 @@
|
|||||||
import { useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Modal, Button } from "react-bootstrap"
|
import { Modal, Button, Form, Row, Col } from "react-bootstrap"
|
||||||
import { useSettings } from "../../context/settings";
|
import { useSettings, ThemePreference, StyleTheme, STYLE_THEMES } from "../../context/settings";
|
||||||
|
import { NotificationSettings, UdalostEnum, getNotificationSettings, updateNotificationSettings } from "../../../../types";
|
||||||
|
import { useAuth } from "../../context/auth";
|
||||||
|
import { subscribeToPush, unsubscribeFromPush } from "../../hooks/usePushReminder";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
onClose: () => void,
|
onClose: () => void,
|
||||||
onSave: (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => void,
|
onSave: (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean, themePreference?: ThemePreference) => void,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Modální dialog pro uživatelská nastavení. */
|
/** Modální dialog pro uživatelská nastavení. */
|
||||||
export default function SettingsModal({ isOpen, onClose, onSave }: Props) {
|
export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Props>) {
|
||||||
|
const auth = useAuth();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const bankAccountRef = useRef<HTMLInputElement>(null);
|
const bankAccountRef = useRef<HTMLInputElement>(null);
|
||||||
const nameRef = useRef<HTMLInputElement>(null);
|
const nameRef = useRef<HTMLInputElement>(null);
|
||||||
const hideSoupsRef = useRef<HTMLInputElement>(null);
|
const hideSoupsRef = useRef<HTMLInputElement>(null);
|
||||||
|
const themeRef = useRef<HTMLSelectElement>(null);
|
||||||
|
const styleThemeRef = useRef<HTMLSelectElement>(null);
|
||||||
|
const showButterfliesRef = useRef<HTMLInputElement>(null);
|
||||||
|
const autoOrderChoiceRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
return <Modal show={isOpen} onHide={onClose} size="lg">
|
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);
|
||||||
|
const [notifSettings, setNotifSettings] = useState<NotificationSettings>({});
|
||||||
|
const [enabledEvents, setEnabledEvents] = useState<UdalostEnum[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && auth?.login) {
|
||||||
|
getNotificationSettings().then(response => {
|
||||||
|
if (response.data) {
|
||||||
|
setNotifSettings(response.data);
|
||||||
|
setEnabledEvents(response.data.enabledEvents ?? []);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [isOpen, auth?.login]);
|
||||||
|
|
||||||
|
const toggleEvent = (event: UdalostEnum) => {
|
||||||
|
setEnabledEvents(prev =>
|
||||||
|
prev.includes(event) ? prev.filter(e => e !== event) : [...prev, event]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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)) {
|
||||||
|
subscribeToPush(newReminderTime);
|
||||||
|
} else if (!wantsPush && hadPush) {
|
||||||
|
unsubscribeFromPush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uložení ostatních nastavení (localStorage)
|
||||||
|
if (styleThemeRef.current?.value) {
|
||||||
|
settings?.setStyleTheme(styleThemeRef.current.value as StyleTheme);
|
||||||
|
}
|
||||||
|
if (showButterfliesRef.current) {
|
||||||
|
settings?.setShowButterflies(showButterfliesRef.current.checked);
|
||||||
|
}
|
||||||
|
if (autoOrderChoiceRef.current) {
|
||||||
|
settings?.setAutoOrderChoice(autoOrderChoiceRef.current.checked);
|
||||||
|
}
|
||||||
|
onSave(
|
||||||
|
bankAccountRef.current?.value,
|
||||||
|
nameRef.current?.value,
|
||||||
|
hideSoupsRef.current?.checked,
|
||||||
|
themeRef.current?.value as ThemePreference,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={onClose} size="xl">
|
||||||
<Modal.Header closeButton>
|
<Modal.Header closeButton>
|
||||||
<Modal.Title><h2>Nastavení</h2></Modal.Title>
|
<Modal.Title><h2>Nastavení</h2></Modal.Title>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
<h4>Obecné</h4>
|
{/* Na širších obrazovkách rozdělení do dvou sloupců, na užších pod sebou */}
|
||||||
<span title="V nabídkách nebudou zobrazovány polévky. Tato funkce je experimentální, a zejména u TechTower bývá často problém polévky spolehlivě rozeznat. V případě využití této funkce průběžně nahlašujte stále se zobrazující polévky." style={{ "cursor": "help" }}>
|
<Row className="gx-5">
|
||||||
<input ref={hideSoupsRef} type="checkbox" defaultChecked={settings?.hideSoups} /> Skrýt polévky
|
<Col lg={6}>
|
||||||
</span>
|
<h4>Vzhled</h4>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Styl</Form.Label>
|
||||||
|
<Form.Select ref={styleThemeRef} defaultValue={settings?.styleTheme}>
|
||||||
|
{STYLE_THEMES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||||
|
</Form.Select>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Barevný motiv</Form.Label>
|
||||||
|
<Form.Select ref={themeRef} defaultValue={settings?.themePreference}>
|
||||||
|
<option value="system">Podle systému</option>
|
||||||
|
<option value="light">Světlý</option>
|
||||||
|
<option value="dark">Tmavý</option>
|
||||||
|
</Form.Select>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Check
|
||||||
|
id="showButterfliesCheckbox"
|
||||||
|
ref={showButterfliesRef}
|
||||||
|
type="checkbox"
|
||||||
|
label="Zobrazit motýlky"
|
||||||
|
defaultChecked={settings?.showButterflies ?? true}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Po vypnutí se poletující motýlci ani minihra nebudou vůbec zobrazovat.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
|
<h4>Obecné</h4>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Check
|
||||||
|
id="hideSoupsCheckbox"
|
||||||
|
ref={hideSoupsRef}
|
||||||
|
type="checkbox"
|
||||||
|
label="Skrýt polévky"
|
||||||
|
defaultChecked={settings?.hideSoups}
|
||||||
|
title="V nabídkách nebudou zobrazovány polévky. Tato funkce je experimentální."
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Experimentální funkce - zejména u TechTower bývá problém polévky spolehlivě rozeznat.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Check
|
||||||
|
id="autoOrderChoiceCheckbox"
|
||||||
|
ref={autoOrderChoiceRef}
|
||||||
|
type="checkbox"
|
||||||
|
label='Při objednávání automaticky zvolit "Budu objednávat"'
|
||||||
|
defaultChecked={settings?.autoOrderChoice ?? false}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Po založení skupinové objednávky nebo připojení k existující se vaše dnešní volba stravování přepne na "Budu objednávat". Volba Pizza day zůstane beze změny.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
<h4>Bankovní účet</h4>
|
<h4>Bankovní účet</h4>
|
||||||
<p>Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.<br />Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.<br /><br />Číslo účtu není ukládáno na serveru, posílá se na něj pouze za účelem vygenerování QR kódů.</p>
|
<p>
|
||||||
Číslo účtu: <input className="mb-3" ref={bankAccountRef} type="text" placeholder="123456-1234567890/1234" defaultValue={settings?.bankAccount} onKeyDown={e => e.stopPropagation()} /> <br />
|
Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.
|
||||||
Název příjemce (jméno majitele účtu): <input ref={nameRef} type="text" placeholder="Jan Novák" defaultValue={settings?.holderName} onKeyDown={e => e.stopPropagation()} />
|
</p>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Číslo účtu</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
ref={bankAccountRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="123456-1234567890/1234"
|
||||||
|
defaultValue={settings?.bankAccount}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Název příjemce</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
ref={nameRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Jan Novák"
|
||||||
|
defaultValue={settings?.holderName}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Jméno majitele účtu pro QR platbu.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
</Col>
|
||||||
|
<Col lg={6}>
|
||||||
|
<hr className="d-lg-none" />
|
||||||
|
|
||||||
|
<h4>Notifikace</h4>
|
||||||
|
<p>
|
||||||
|
Nastavením notifikací budete dostávat upozornění o událostech (např. "Jdeme na oběd") přímo do vámi zvoleného komunikačního kanálu.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Připomínka výběru oběda</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
ref={reminderTimeRef}
|
||||||
|
type="time"
|
||||||
|
defaultValue={notifSettings.reminderTime ?? ''}
|
||||||
|
key={notifSettings.reminderTime ?? 'reminder-empty'}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
V zadaný čas vám přijde push notifikace, pokud nemáte zvolenou možnost stravování. Nechte prázdné pro vypnutí.
|
||||||
|
</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 / Wolt)"
|
||||||
|
defaultChecked={notifSettings.boltDeliveredPush ?? false}
|
||||||
|
key={`bolt-delivered-${notifSettings.boltDeliveredPush ?? false}`}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Až bude sledovaná skupinová objednávka doručena, přijde vám push notifikace.
|
||||||
|
Zakladateli skupiny se neposílá — ten dostane upozornění přímo z aplikace rozvozové služby.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>ntfy téma (topic)</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
ref={ntfyTopicRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="moje-tema"
|
||||||
|
defaultValue={notifSettings.ntfyTopic}
|
||||||
|
key={notifSettings.ntfyTopic ?? 'ntfy-empty'}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Téma pro ntfy push notifikace. Nechte prázdné pro vypnutí.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Discord webhook URL</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
ref={discordWebhookRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
|
defaultValue={notifSettings.discordWebhookUrl}
|
||||||
|
key={notifSettings.discordWebhookUrl ?? 'discord-empty'}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
URL webhooku Discord kanálu. Nechte prázdné pro vypnutí.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>MS Teams webhook URL</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
ref={teamsWebhookRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="https://outlook.office.com/webhook/..."
|
||||||
|
defaultValue={notifSettings.teamsWebhookUrl}
|
||||||
|
key={notifSettings.teamsWebhookUrl ?? 'teams-empty'}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
URL webhooku MS Teams kanálu. Nechte prázdné pro vypnutí.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Události k odběru</Form.Label>
|
||||||
|
{Object.values(UdalostEnum).map(event => (
|
||||||
|
<Form.Check
|
||||||
|
key={event}
|
||||||
|
id={`notif-event-${event}`}
|
||||||
|
type="checkbox"
|
||||||
|
label={event}
|
||||||
|
checked={enabledEvents.includes(event)}
|
||||||
|
onChange={() => toggleEvent(event)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Zvolte události, o kterých chcete být notifikováni. Notifikace jsou odesílány pouze uživatelům se stejnou zvolenou lokalitou.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Button variant="secondary" onClick={onClose}>
|
<Button variant="secondary" onClick={onClose}>
|
||||||
Storno
|
Storno
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" onClick={() => onSave(bankAccountRef.current?.value, nameRef.current?.value, hideSoupsRef.current?.checked)}>
|
<Button onClick={handleSave}>
|
||||||
Uložit
|
Uložit
|
||||||
</Button>
|
</Button>
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Modal, Button, ButtonGroup } from "react-bootstrap";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faXmark, faRotateLeft } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { Restaurant, RestaurantDayMenuMap, Store, StoreMenu, StoreMenuSource, getStoreMenu, listStores } from "../../../../types";
|
||||||
|
import { getLunchChoiceName } from "../../enums";
|
||||||
|
import WheelConfetti from "../WheelConfetti";
|
||||||
|
import { useSettings } from "../../context/settings";
|
||||||
|
import "./SpinWheelModal.scss";
|
||||||
|
|
||||||
|
/** Výsledek kola štěstí — buď jídlo z dnešního menu podniku, nebo jídlo z rozvozu. */
|
||||||
|
export type WheelPick =
|
||||||
|
| { kind: 'menu', restaurant: Restaurant, foodIndex?: number, foodName?: string }
|
||||||
|
| { kind: 'store', store: string, dish?: string };
|
||||||
|
|
||||||
|
type Source = 'all' | 'menu' | 'store';
|
||||||
|
|
||||||
|
type Place = {
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
kind: 'menu' | 'store',
|
||||||
|
restaurant?: Restaurant,
|
||||||
|
/** Jídla dnešního menu; u rozvozu se nabídka dotahuje až při druhém kole. */
|
||||||
|
dishes: Dish[],
|
||||||
|
};
|
||||||
|
|
||||||
|
type Dish = { name: string, index?: number, price?: string };
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<StoreMenuSource, string> = {
|
||||||
|
bolt: 'Nabídka z Bolt Food',
|
||||||
|
wolt: 'Nabídka z Wolt',
|
||||||
|
history: 'Nabídku se nepodařilo stáhnout — jídla z historie objednávek',
|
||||||
|
none: 'Nabídku se nepodařilo stáhnout',
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean,
|
||||||
|
onClose: () => void,
|
||||||
|
menus?: RestaurantDayMenuMap,
|
||||||
|
onPick: (pick: WheelPick) => Promise<void>,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SPIN_MS = 4800;
|
||||||
|
const WHEEL_SIZE = 360;
|
||||||
|
const RADIUS = WHEEL_SIZE / 2 - 6;
|
||||||
|
const SEGMENT_COLORS = 6;
|
||||||
|
const MAX_LABELED_SEGMENTS = 40;
|
||||||
|
/** Točit má smysl až od dvou možností — jediná zbylá se vybere rovnou. */
|
||||||
|
const MIN_SPIN_ITEMS = 2;
|
||||||
|
|
||||||
|
/** Kryptograficky náhodný index v rozsahu 0..n-1 (bez modulo zkreslení to u malých n nevadí). */
|
||||||
|
function randomIndex(n: number): number {
|
||||||
|
const buf = new Uint32Array(1);
|
||||||
|
crypto.getRandomValues(buf);
|
||||||
|
return buf[0] % n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text: string, max: number): string {
|
||||||
|
return text.length > max ? text.slice(0, max - 1) + '…' : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bod na kružnici; úhel ve stupních měřený od „12 hodin" po směru hodinových ručiček. */
|
||||||
|
function polar(angle: number, r: number) {
|
||||||
|
const rad = (angle - 90) * Math.PI / 180;
|
||||||
|
return { x: WHEEL_SIZE / 2 + r * Math.cos(rad), y: WHEEL_SIZE / 2 + r * Math.sin(rad) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentPath(start: number, end: number): string {
|
||||||
|
const c = WHEEL_SIZE / 2;
|
||||||
|
if (end - start >= 360) {
|
||||||
|
return `M ${c} ${c - RADIUS} A ${RADIUS} ${RADIUS} 0 1 1 ${c - 0.01} ${c - RADIUS} Z`;
|
||||||
|
}
|
||||||
|
const a = polar(start, RADIUS);
|
||||||
|
const b = polar(end, RADIUS);
|
||||||
|
const large = end - start > 180 ? 1 : 0;
|
||||||
|
return `M ${c} ${c} L ${a.x} ${a.y} A ${RADIUS} ${RADIUS} 0 ${large} 1 ${b.x} ${b.y} Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WheelProps = {
|
||||||
|
labels: string[],
|
||||||
|
rotation: number,
|
||||||
|
spinning: boolean,
|
||||||
|
onSpinEnd: () => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
function Wheel({ labels, rotation, spinning, onSpinEnd }: Readonly<WheelProps>) {
|
||||||
|
const n = labels.length;
|
||||||
|
const angle = 360 / Math.max(n, 1);
|
||||||
|
const fontSize = n > 24 ? 8.5 : n > 14 ? 10 : n > 8 ? 12 : 14;
|
||||||
|
const maxChars = n > 24 ? 26 : n > 14 ? 24 : 20;
|
||||||
|
// U velkých nabídek jsou popisky nečitelné — názvy jsou v seznamu vedle kola
|
||||||
|
const showLabels = n <= MAX_LABELED_SEGMENTS;
|
||||||
|
return (
|
||||||
|
<div className="spin-wheel">
|
||||||
|
<div className="spin-wheel-pointer" aria-hidden />
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${WHEEL_SIZE} ${WHEEL_SIZE}`}
|
||||||
|
className="spin-wheel-svg"
|
||||||
|
style={{
|
||||||
|
transform: `rotate(${rotation}deg)`,
|
||||||
|
transition: spinning ? `transform ${SPIN_MS}ms cubic-bezier(0.12, 0.72, 0.16, 1)` : 'none',
|
||||||
|
}}
|
||||||
|
onTransitionEnd={e => { if (e.propertyName === 'transform') onSpinEnd(); }}
|
||||||
|
role="img"
|
||||||
|
aria-label={`Kolo s ${n} možnostmi`}
|
||||||
|
>
|
||||||
|
<circle cx={WHEEL_SIZE / 2} cy={WHEEL_SIZE / 2} r={RADIUS + 4} className="spin-wheel-rim" />
|
||||||
|
{labels.map((label, i) => {
|
||||||
|
const start = i * angle;
|
||||||
|
const mid = start + angle / 2;
|
||||||
|
return (
|
||||||
|
<g key={`${i}-${label}`}>
|
||||||
|
<path d={segmentPath(start, start + angle)} className={`spin-seg spin-seg-${i % SEGMENT_COLORS}`} />
|
||||||
|
{showLabels && <text
|
||||||
|
x={WHEEL_SIZE / 2 + 34}
|
||||||
|
y={WHEEL_SIZE / 2}
|
||||||
|
transform={`rotate(${mid - 90} ${WHEEL_SIZE / 2} ${WHEEL_SIZE / 2})`}
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fontSize={fontSize}
|
||||||
|
className="spin-seg-label"
|
||||||
|
>
|
||||||
|
{truncate(label, maxChars)}
|
||||||
|
</text>}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<circle cx={WHEEL_SIZE / 2} cy={WHEEL_SIZE / 2} r={26} className="spin-wheel-hub" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kolo štěstí pro nerozhodnuté: první kolo vybere podnik (dnešní menu nebo rozvoz),
|
||||||
|
* druhé kolo jídlo z něj (bez polévek). Možnosti lze z kola průběžně vyřazovat.
|
||||||
|
*/
|
||||||
|
export default function SpinWheelModal({ isOpen, onClose, menus, onPick }: Readonly<Props>) {
|
||||||
|
const [stores, setStores] = useState<Store[]>();
|
||||||
|
const [storeMenus, setStoreMenus] = useState<Record<string, StoreMenu | 'loading' | 'error'>>({});
|
||||||
|
const [source, setSource] = useState<Source>('all');
|
||||||
|
const [excludedPlaces, setExcludedPlaces] = useState<Set<string>>(new Set());
|
||||||
|
const [excludedDishes, setExcludedDishes] = useState<Set<string>>(new Set());
|
||||||
|
const [place, setPlace] = useState<Place>();
|
||||||
|
const [dish, setDish] = useState<Dish>();
|
||||||
|
const [phase, setPhase] = useState<'place' | 'dish'>('place');
|
||||||
|
const [rotation, setRotation] = useState(0);
|
||||||
|
const [spinning, setSpinning] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [respinRequested, setRespinRequested] = useState(false);
|
||||||
|
const theme = useSettings()?.styleTheme ?? 'simple';
|
||||||
|
const [confetti, setConfetti] = useState<{ key: number, big: boolean }>({ key: 0, big: false });
|
||||||
|
const pendingResult = useRef<number>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && !stores) {
|
||||||
|
listStores().then(r => setStores(r.data ?? [])).catch(() => setStores([]));
|
||||||
|
}
|
||||||
|
}, [isOpen, stores]);
|
||||||
|
|
||||||
|
const allPlaces = useMemo<Place[]>(() => {
|
||||||
|
const result: Place[] = [];
|
||||||
|
for (const key of Object.keys(Restaurant) as Restaurant[]) {
|
||||||
|
const menu = menus?.[key];
|
||||||
|
if (!menu || menu.closed || !menu.food?.length) continue;
|
||||||
|
const dishes = menu.food
|
||||||
|
.map((f, index) => ({ name: f.name, index, price: f.price, isSoup: f.isSoup }))
|
||||||
|
.filter(f => !f.isSoup)
|
||||||
|
.map(({ name, index, price }) => ({ name, index, price }));
|
||||||
|
result.push({ id: `menu:${key}`, label: getLunchChoiceName(key), kind: 'menu', restaurant: key, dishes });
|
||||||
|
}
|
||||||
|
for (const s of stores ?? []) {
|
||||||
|
result.push({ id: `store:${s.name}`, label: s.name, kind: 'store', dishes: [] });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [menus, stores]);
|
||||||
|
|
||||||
|
const storeMenu = place?.kind === 'store' ? storeMenus[place.label] : undefined;
|
||||||
|
const allDishes = useMemo<Dish[]>(() => {
|
||||||
|
if (place?.kind !== 'store') return place?.dishes ?? [];
|
||||||
|
if (!storeMenu || typeof storeMenu === 'string') return [];
|
||||||
|
return storeMenu.dishes
|
||||||
|
.filter(d => d.isMain !== false)
|
||||||
|
.map(d => ({ name: d.name, price: d.price != null ? `${d.price} Kč` : undefined }));
|
||||||
|
}, [place, storeMenu]);
|
||||||
|
|
||||||
|
const places = useMemo(
|
||||||
|
() => allPlaces.filter(p => (source === 'all' || p.kind === source) && !excludedPlaces.has(p.id)),
|
||||||
|
[allPlaces, source, excludedPlaces],
|
||||||
|
);
|
||||||
|
const dishes = useMemo(
|
||||||
|
() => allDishes.filter(d => !excludedDishes.has(`${place?.id}|${d.name}`)),
|
||||||
|
[place, allDishes, excludedDishes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const wheelItems = phase === 'place' ? places.map(p => p.label) : dishes.map(d => d.name);
|
||||||
|
const result = phase === 'place' ? place : dish;
|
||||||
|
|
||||||
|
const resetWheel = () => {
|
||||||
|
setSpinning(false);
|
||||||
|
setRotation(r => r % 360);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Zbyla jediná možnost — není co losovat, vybere se rovnou (lze pokračovat dál / převzít)
|
||||||
|
const onlyOne = wheelItems.length === 1;
|
||||||
|
useEffect(() => {
|
||||||
|
if (spinning || !onlyOne) return;
|
||||||
|
if (phase === 'place' && place?.id !== places[0].id) setPlace(places[0]);
|
||||||
|
else if (phase === 'dish' && dish?.name !== dishes[0].name) setDish(dishes[0]);
|
||||||
|
}, [onlyOne, phase, places, dishes, spinning, place?.id, dish?.name]);
|
||||||
|
|
||||||
|
const spin = () => {
|
||||||
|
const n = wheelItems.length;
|
||||||
|
if (n < MIN_SPIN_ITEMS || spinning) return;
|
||||||
|
const index = randomIndex(n);
|
||||||
|
const angle = 360 / n;
|
||||||
|
const jitter = (Math.random() - 0.5) * angle * 0.6;
|
||||||
|
const target = 360 - (index * angle + angle / 2) + jitter;
|
||||||
|
const turns = 5 + randomIndex(3);
|
||||||
|
pendingResult.current = index;
|
||||||
|
if (phase === 'place') setPlace(undefined); else setDish(undefined);
|
||||||
|
setSpinning(true);
|
||||||
|
setRotation(r => r - (r % 360) + turns * 360 + ((target % 360) + 360) % 360);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSpinEnd = () => {
|
||||||
|
const index = pendingResult.current;
|
||||||
|
if (index == null) return;
|
||||||
|
pendingResult.current = undefined;
|
||||||
|
setSpinning(false);
|
||||||
|
if (phase === 'place') setPlace(places[index]);
|
||||||
|
else setDish(dishes[index]);
|
||||||
|
setConfetti(c => ({ key: c.key + 1, big: phase === 'dish' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToDishes = () => {
|
||||||
|
if (place?.kind === 'store' && (!storeMenus[place.label] || storeMenus[place.label] === 'error')) {
|
||||||
|
const name = place.label;
|
||||||
|
setStoreMenus(prev => ({ ...prev, [name]: 'loading' }));
|
||||||
|
getStoreMenu({ query: { name } })
|
||||||
|
.then(r => setStoreMenus(prev => ({ ...prev, [name]: r.data ?? 'error' })))
|
||||||
|
.catch(() => setStoreMenus(prev => ({ ...prev, [name]: 'error' })));
|
||||||
|
}
|
||||||
|
setDish(undefined);
|
||||||
|
setPhase('dish');
|
||||||
|
resetWheel();
|
||||||
|
};
|
||||||
|
|
||||||
|
const backToPlaces = () => {
|
||||||
|
setDish(undefined);
|
||||||
|
setPlace(undefined);
|
||||||
|
setPhase('place');
|
||||||
|
resetWheel();
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeOption = (i: number) => {
|
||||||
|
if (spinning) return;
|
||||||
|
if (phase === 'place') {
|
||||||
|
const p = places[i];
|
||||||
|
setExcludedPlaces(prev => new Set(prev).add(p.id));
|
||||||
|
if (place?.id === p.id) setPlace(undefined);
|
||||||
|
} else if (place) {
|
||||||
|
const d = dishes[i];
|
||||||
|
setExcludedDishes(prev => new Set(prev).add(`${place.id}|${d.name}`));
|
||||||
|
if (dish?.name === d.name) setDish(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeResultAndRespin = () => {
|
||||||
|
const i = phase === 'place' ? places.findIndex(p => p.id === place?.id) : dishes.findIndex(d => d.name === dish?.name);
|
||||||
|
if (i < 0) return;
|
||||||
|
removeOption(i);
|
||||||
|
setRespinRequested(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Po vyřazení se kolo překreslí s menším počtem výsečí — roztočíme ho až nad novými daty
|
||||||
|
useEffect(() => {
|
||||||
|
if (respinRequested) {
|
||||||
|
setRespinRequested(false);
|
||||||
|
spin();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [respinRequested]);
|
||||||
|
|
||||||
|
const restoreAll = () => {
|
||||||
|
if (phase === 'place') setExcludedPlaces(new Set());
|
||||||
|
else if (place) setExcludedDishes(prev => new Set([...prev].filter(k => !k.startsWith(`${place.id}|`))));
|
||||||
|
};
|
||||||
|
|
||||||
|
const accept = async () => {
|
||||||
|
if (!place) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (place.kind === 'menu') {
|
||||||
|
await onPick({ kind: 'menu', restaurant: place.restaurant!, foodIndex: dish?.index, foodName: dish?.name });
|
||||||
|
} else {
|
||||||
|
await onPick({ kind: 'store', store: place.label, dish: dish?.name });
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const excludedCount = phase === 'place'
|
||||||
|
? allPlaces.filter(p => (source === 'all' || p.kind === source) && excludedPlaces.has(p.id)).length
|
||||||
|
: allDishes.length - dishes.length;
|
||||||
|
const menuLoading = phase === 'dish' && storeMenu === 'loading';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={onClose} size="lg" className={`spin-wheel-modal spin-theme-${theme}`}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>
|
||||||
|
<h2>{phase === 'place' ? 'Kolo štěstí: kam?' : `Kolo štěstí: co v ${place?.label}?`}</h2>
|
||||||
|
</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<WheelConfetti theme={theme} burstKey={confetti.key} big={confetti.big} />
|
||||||
|
<Modal.Body>
|
||||||
|
<div className="spin-steps">
|
||||||
|
<span className={phase === 'place' ? 'active' : 'done'}>1. Podnik</span>
|
||||||
|
<span className={phase === 'dish' ? 'active' : ''}>2. Jídlo</span>
|
||||||
|
{phase === 'dish' && storeMenu && typeof storeMenu !== 'string' && (
|
||||||
|
<small className="spin-source-info">{SOURCE_LABELS[storeMenu.source]}</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{phase === 'place' && (
|
||||||
|
<ButtonGroup className="spin-source mb-3" aria-label="Zdroj podniků">
|
||||||
|
{([['all', 'Vše'], ['menu', 'Dnešní menu'], ['store', 'Rozvoz']] as [Source, string][]).map(([value, label]) => (
|
||||||
|
<Button key={value} variant={source === value ? 'primary' : 'outline-primary'} disabled={spinning}
|
||||||
|
onClick={() => { setSource(value); setPlace(undefined); }}>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</ButtonGroup>
|
||||||
|
)}
|
||||||
|
<div className="spin-layout">
|
||||||
|
<div className="spin-wheel-column">
|
||||||
|
{(phase === 'place' && !stores) || menuLoading ? (
|
||||||
|
<div className="spin-empty">{menuLoading ? 'Stahuji nabídku podniku…' : 'Načítám podniky…'}</div>
|
||||||
|
) : wheelItems.length === 0 ? (
|
||||||
|
<div className="spin-empty">
|
||||||
|
{phase === 'dish'
|
||||||
|
? 'Tady nemáme žádná jídla k točení — vyber si na místě.'
|
||||||
|
: 'Na kole nezbyla žádná možnost.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Wheel labels={wheelItems} rotation={rotation} spinning={spinning} onSpinEnd={handleSpinEnd} />
|
||||||
|
)}
|
||||||
|
<div className={`spin-result${result ? ' visible' : ''}`} aria-live="polite">
|
||||||
|
{result && (phase === 'place'
|
||||||
|
? <>{onlyOne ? 'Zbývá jediná možnost' : 'Padlo'}: <strong>{place!.label}</strong> <small>({place!.kind === 'menu' ? 'dnešní menu' : 'rozvoz'})</small></>
|
||||||
|
: <>{onlyOne ? 'Zbývá jediné jídlo' : 'Padlo'}: <strong>{dish!.name}</strong>{dish!.price && <small> ({dish!.price})</small>}</>)}
|
||||||
|
</div>
|
||||||
|
<div className="spin-actions">
|
||||||
|
<Button onClick={spin} disabled={spinning || wheelItems.length < MIN_SPIN_ITEMS} className="spin-button"
|
||||||
|
title={wheelItems.length < MIN_SPIN_ITEMS ? 'Na kole musí být aspoň 2 možnosti' : undefined}>
|
||||||
|
{spinning ? 'Točí se…' : result && !onlyOne ? 'Točit znovu' : 'Roztočit!'}
|
||||||
|
</Button>
|
||||||
|
{result && !spinning && wheelItems.length > 1 && (
|
||||||
|
<Button variant="outline-primary" onClick={removeResultAndRespin}>Vyřadit a točit znovu</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{onlyOne && (
|
||||||
|
<small className="text-muted">Na kole musí být aspoň 2 možnosti — vrať nějaké vyřazené, nebo pokračuj s touto.</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="spin-options">
|
||||||
|
<div className="spin-options-header">
|
||||||
|
<span>Na kole ({wheelItems.length})</span>
|
||||||
|
{excludedCount > 0 && (
|
||||||
|
<button className="spin-restore" onClick={restoreAll} disabled={spinning} title="Vrátit vyřazené možnosti">
|
||||||
|
<FontAwesomeIcon icon={faRotateLeft} /> Vrátit {excludedCount}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{wheelItems.map((label, i) => (
|
||||||
|
<li key={`${i}-${label}`}>
|
||||||
|
<span className={`spin-dot spin-seg-${i % SEGMENT_COLORS}`} />
|
||||||
|
<span className="spin-option-label" title={label}>{label}</span>
|
||||||
|
{phase === 'place'
|
||||||
|
? <small className="spin-option-kind">{places[i].kind === 'menu' ? 'menu' : 'rozvoz'}</small>
|
||||||
|
: dishes[i].price && <small className="spin-option-kind">{dishes[i].price}</small>}
|
||||||
|
<button className="spin-remove" onClick={() => removeOption(i)} disabled={spinning}
|
||||||
|
title={`Vyřadit z kola: ${label}`} aria-label={`Vyřadit ${label}`}>
|
||||||
|
<FontAwesomeIcon icon={faXmark} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
{phase === 'dish' && <Button variant="secondary" onClick={backToPlaces} disabled={spinning}>Zpět na podniky</Button>}
|
||||||
|
{phase === 'place' && place && !spinning && (
|
||||||
|
<Button variant="outline-primary" onClick={accept} disabled={saving}>Beru jen podnik</Button>
|
||||||
|
)}
|
||||||
|
{phase === 'place' && place && !spinning && (
|
||||||
|
<Button onClick={goToDishes}>Dál: točit jídlo</Button>
|
||||||
|
)}
|
||||||
|
{phase === 'dish' && !spinning && (dish || dishes.length === 0) && (
|
||||||
|
<Button onClick={accept} disabled={saving}>{dish ? 'Beru to!' : 'Beru podnik'}</Button>
|
||||||
|
)}
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||