Author SHA1 Message Date
mates 0179afca75 feat: část refaktoru databáze 2025-11-25 13:42:06 +01:00
348 changed files with 4498 additions and 39176 deletions

No files matched your search

-264
View File
@@ -1,264 +0,0 @@
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_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
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")"
# Nevyplněné URL = daný kanál notifikací se přeskočí
if [ -n "$DISCORD_WEBHOOK_URL" ]; then
curl -sS -f -X POST "${DISCORD_WEBHOOK_URL}" \
-H "Content-Type: application/json" \
--data "$(jq -n --arg content "$FULL_MSG" '{content: $content}')"
fi
if [ -n "$NTFY_URL" ]; then
curl -sS -f -X POST "${NTFY_URL}" \
-H "Title: Luncher CI #${RUN_NUMBER}" \
-H "Tags: ${NTFY_TAGS}" \
-H "Click: ${RUN_URL}" \
-d "${FULL_MSG}"
fi
-9
View File
@@ -1,11 +1,2 @@
node_modules node_modules
types/gen types/gen
**.DS_Store
.mcp.json
.claude/settings.local.json
server/public/
.claude/*.lock
.claude/worktrees
.playwright-mcp
.idea/
**/.yarn/install-state.gz
-32
View File
@@ -1,32 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Server (ts-node, debug)",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}/server",
"runtimeArgs": ["-r", "ts-node/register"],
"program": "${workspaceFolder}/server/src/index.ts",
"env": { "NODE_ENV": "development" },
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"],
"preLaunchTask": "types: openapi-ts"
},
{
"name": "Client (vite + Edge)",
"type": "msedge",
"request": "launch",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/client",
"preLaunchTask": "client: vite"
}
],
"compounds": [
{
"name": "Dev: server + client",
"configurations": ["Server (ts-node, debug)", "Client (vite + Edge)"],
"stopAll": true
}
]
}
-67
View File
@@ -1,67 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "types: openapi-ts",
"type": "shell",
"command": "yarn openapi-ts",
"options": {
"cwd": "${workspaceFolder}/types"
},
"presentation": {
"reveal": "silent",
"panel": "dedicated"
},
"problemMatcher": []
},
{
"label": "server: startReload",
"type": "shell",
"command": "yarn startReload",
"options": {
"cwd": "${workspaceFolder}/server",
"env": {
"NODE_ENV": "development"
}
},
"isBackground": true,
"presentation": {
"reveal": "always",
"panel": "dedicated",
"group": "dev"
},
"problemMatcher": []
},
{
"label": "client: vite",
"type": "shell",
"command": "yarn start",
"options": {
"cwd": "${workspaceFolder}/client"
},
"isBackground": true,
"presentation": {
"reveal": "always",
"panel": "dedicated",
"group": "dev"
},
"problemMatcher": []
},
{
"label": "dev: server+client",
"dependsOn": [
"server: startReload",
"client: vite"
]
},
{
"label": "dev: all",
"dependsOrder": "sequence",
"dependsOn": [
"types: openapi-ts",
"dev: server+client"
],
"problemMatcher": []
}
]
}
+64
View File
@@ -0,0 +1,64 @@
variables:
- &node_image "node:22-alpine"
- &branch "master"
when:
- event: push
branch: *branch
steps:
- name: Generate TypeScript types
image: *node_image
commands:
- cd types
- yarn install --frozen-lockfile
- yarn openapi-ts
- name: Install server dependencies
image: *node_image
commands:
- cd server
- yarn install --frozen-lockfile
depends_on: [Generate TypeScript types]
- name: Install client dependencies
image: *node_image
commands:
- cd client
- yarn install --frozen-lockfile
depends_on: [Generate TypeScript types]
- name: Build server
depends_on: [Install server dependencies]
image: *node_image
commands:
- cd server
- yarn build
- name: Build client
depends_on: [Install client dependencies]
image: *node_image
commands:
- cd client
- yarn build
- name: Build Docker image
depends_on: [Build server, Build client]
image: woodpeckerci/plugin-docker-buildx
settings:
dockerfile: Dockerfile-Woodpecker
platforms: linux/amd64
registry:
from_secret: REPO_URL
username:
from_secret: REPO_USERNAME
password:
from_secret: REPO_PASSWORD
repo:
from_secret: REPO_NAME
- name: Discord notification - build
image: appleboy/drone-discord
depends_on: [Build Docker image]
when:
- status: [success, failure]
settings:
webhook_id:
from_secret: DISCORD_WEBHOOK_ID
webhook_token:
from_secret: DISCORD_WEBHOOK_TOKEN
message: "{{#success build.status}}✅ Sestavení {{build.number}} proběhlo úspěšně.{{else}}❌ Sestavení {{build.number}} selhalo.{{/success}}\n\nPipeline: {{build.link}}\nPoslední commit: {{commit.message}}Autor: {{commit.author}}"
-146
View File
@@ -1,146 +0,0 @@
# 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.
+9 -37
View File
@@ -1,6 +1,6 @@
ARG NODE_VERSION="node:26-alpine" ARG NODE_VERSION="node:22-alpine"
# ─── Builder ────────────────────────────────────────────────────────────────── # Builder
FROM ${NODE_VERSION} AS builder FROM ${NODE_VERSION} AS builder
WORKDIR /build WORKDIR /build
@@ -62,9 +62,8 @@ RUN yarn build
WORKDIR /build/client WORKDIR /build/client
RUN yarn build RUN yarn build
# ─── Runner base ────────────────────────────────────────────────────────────── # Runner
# Společný základ pro oba runner targety – nastaví prostředí a metadata běhu. FROM ${NODE_VERSION}
FROM ${NODE_VERSION} AS runner-base
RUN apk add --no-cache tzdata RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \ ENV TZ=Europe/Prague \
@@ -73,17 +72,6 @@ ENV TZ=Europe/Prague \
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 ./
@@ -94,28 +82,12 @@ 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 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
# ─── Runner (prebuilt) ──────────────────────────────────────────────────────── # Export /data/db.json do složky /data
# Použití: docker build --target runner-prebuilt . VOLUME ["/data"]
# 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
# Vykopírování sestaveného serveru EXPOSE 3000
COPY ./server/node_modules ./server/node_modules
COPY ./server/dist ./
# Vykopírování sestaveného klienta CMD [ "node", "./server/src/index.js" ]
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
+26
View File
@@ -0,0 +1,26 @@
ARG NODE_VERSION="node:22-alpine"
FROM ${NODE_VERSION}
RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \
LC_ALL=cs_CZ.UTF-8 \
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" ]
+28 -33
View File
@@ -1,44 +1,39 @@
# TODO # TODO
- [ ] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli
## HA / multi-replica follow-ups - [ ] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď)
- [ ] `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`) - [ ] 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í)
- [ ] `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 - [ ] Možnost úhrady celé útraty jednou osobou
- [ ] `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.)
- [X] Tlačítko "Uhradit" u každého řádku podniku - platí ten, kdo kliknul - [ ] Tlačítko "Uhradit" u každého řádku podniku - platí ten, kdo kliknul
- [X] Zobrazeno bude pouze, pokud má daný uživatel nastaveno číslo účtu - [ ] Zobrazeno bude pouze, pokud má daný uživatel nastaveno číslo účtu
- [X] Dialog pro zadání spropitného, které se následně rozpočte rovnoměrně všem strávníkům - [ ] Dialog pro zadání spropitného, které se následně rozpočte rovnoměrně všem strávníkům
- [X] Generování a zobrazení QR kódů ostatním strávníkům - [ ] Generování a zobrazení QR kódů ostatním strávníkům
- [X] Umožnit u každého strávníka připočíst vlastní částku (např. za pití) - [ ] Umožnit u každého strávníka připočíst vlastní částku (např. za pití)
- [X] Umožnit (např. zaškrtávátky) vybrat, za koho bude zaplaceno (pokud někdo bude platit zvlášť) - [ ] Umožnit (např. zaškrtávátky) vybrat, za koho bude zaplaceno (pokud někdo bude platit zvlášť)
- [X] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams) - [ ] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)
- [X] Umožnit zadat URL/tokeny uživatelem — ntfy, Discord, MS Teams (Gotify jen globálně na serveru) - [ ] Umožnit zadat URL/tokeny uživatelem
- [X] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat - [ ] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat
- [X] Zavést notifikace typu "Jdeme na oběd" - [ ] Zavést notifikace typu "Jdeme na oběd"
- [X] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu - [ ] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu
- [X] Vylepšit parsery restaurací - [ ] Vylepšit parsery restaurací
- [X] Sladovnická - [ ] Sladovnická
- [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í - [ ] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru
- [X] U Motlíků — odpadá, podnik už v aplikaci není - [ ] U Motlíků
- [X] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.') - [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.')
- [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í) - [ ] 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] TechTower - [ ] TechTower
- [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 - [ ] 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] 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 - [ ] 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í
- 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 ( , )
- [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 - [ ] Nasazení nové verze v Docker smaže veškerá data (protože data.json není vystrčený ven z kontejneru)
- [X] Zavést složku /data - [ ] 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
- [X] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh) — run_dev.ps1 - [ ] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh)
- [ ] Implementovat Pizza day - [ ] Implementovat Pizza day
- [X] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day - [ ] 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í
-26
View File
@@ -9,33 +9,7 @@
<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>
-3
View File
@@ -10,7 +10,6 @@
"@fortawesome/free-regular-svg-icons": "^7.1.0", "@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0", "@fortawesome/react-fontawesome": "^3.1.0",
"@sentry/react": "^10.65.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
@@ -19,14 +18,12 @@
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"react": "^19.2.0", "react": "^19.2.0",
"react-bootstrap": "^2.10.10", "react-bootstrap": "^2.10.10",
"react-datepicker": "^9.1.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-jwt": "^1.3.0", "react-jwt": "^1.3.0",
"react-modal": "^3.16.3", "react-modal": "^3.16.3",
"react-router": "^7.9.5", "react-router": "^7.9.5",
"react-router-dom": "^7.9.5", "react-router-dom": "^7.9.5",
"react-select-search": "^4.1.6", "react-select-search": "^4.1.6",
"react-snow-overlay": "^1.0.14",
"react-snowfall": "^2.3.0", "react-snowfall": "^2.3.0",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"recharts": "^3.4.1", "recharts": "^3.4.1",
-18
View File
@@ -1,18 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<!-- Netopýr (čelem doprava; JS natáčí dle směru letu) -->
<g fill="#2B2733" stroke="#15121A" stroke-width="1.5" stroke-linejoin="round">
<!-- křídla -->
<path d="M50,52 C34,30 18,30 6,40 C16,42 18,52 14,62 C24,54 34,52 44,58 Z"/>
<path d="M50,52 C66,30 82,30 94,40 C84,42 82,52 86,62 C76,54 66,52 56,58 Z"/>
<!-- tělo -->
<ellipse cx="50" cy="56" rx="9" ry="13"/>
<!-- uši -->
<path d="M44,44 L40,32 L48,42 Z"/>
<path d="M56,44 L60,32 L52,42 Z"/>
</g>
<!-- oči -->
<g fill="#F5C518">
<circle cx="46" cy="50" r="1.8"/>
<circle cx="54" cy="50" r="1.8"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 716 B

-26
View File
@@ -1,26 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<!-- Vosa letící doprava (JS překlápí podle směru) -->
<!-- křídla -->
<g fill="#DDEEFF" stroke="#9BB6CC" stroke-width="1" opacity="0.85">
<ellipse cx="46" cy="30" rx="14" ry="8" transform="rotate(-18 46 30)"/>
<ellipse cx="58" cy="32" rx="11" ry="6" transform="rotate(-8 58 32)"/>
</g>
<!-- žihadlo -->
<path fill="#3A2E00" d="M18,50 L6,50 L18,46 Z"/>
<!-- tělo -->
<g stroke="#3A2E00" stroke-width="1.4" stroke-linejoin="round">
<ellipse cx="46" cy="50" rx="30" ry="16" fill="#F5C518"/>
<!-- pruhy -->
<path fill="#2B2200" stroke="none" d="M34,36 q6,14 0,28 q-8,-2 -10,-14 q2,-12 10,-14 z"/>
<path fill="#2B2200" stroke="none" d="M50,35 q6,15 0,30 q-8,-3 -8,-15 q0,-12 8,-15 z"/>
<!-- hlava -->
<circle cx="72" cy="50" r="10" fill="#2B2200"/>
</g>
<!-- oko a tykadla -->
<circle fill="#FFFFFF" cx="75" cy="47" r="2.4"/>
<circle fill="#20313F" cx="75.6" cy="47" r="1.2"/>
<g stroke="#2B2200" stroke-width="1.2" fill="none" stroke-linecap="round">
<path d="M78,42 C84,36 88,36 90,38"/>
<path d="M80,44 C86,40 90,41 92,43"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

-18
View File
@@ -1,18 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<!-- Pták letící doprava (JS překlápí podle směru) -->
<g stroke="#3A4A5A" stroke-width="1.5" stroke-linejoin="round">
<!-- ocas -->
<path fill="#5C6B7A" d="M18,52 L4,44 L6,56 L2,64 L18,60 Z"/>
<!-- tělo -->
<path fill="#6E7F90" d="M16,58 C24,44 44,40 64,46 C74,49 82,54 84,56 C78,62 66,66 52,66 C36,66 22,64 16,58 Z"/>
<!-- křídlo (nahoru) -->
<path fill="#55636F" d="M40,52 C46,30 60,24 70,26 C64,38 58,48 54,56 C50,58 44,57 40,52 Z"/>
<!-- hlava -->
<circle fill="#6E7F90" cx="78" cy="50" r="12"/>
</g>
<!-- zobák -->
<path fill="#F6A21E" stroke="#B9740A" stroke-width="1" stroke-linejoin="round" d="M88,48 L98,50 L88,54 Z"/>
<!-- oko -->
<circle fill="#20313F" cx="82" cy="47" r="2.2"/>
<circle fill="#FFFFFF" cx="82.8" cy="46.3" r="0.7"/>
</svg>

Before

Width:  |  Height:  |  Size: 898 B

-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<g stroke="#1B3F73" stroke-width="1.5" stroke-linejoin="round">
<path fill="#4A8FE7" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
<path fill="#4A8FE7" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
<path fill="#4A8FE7" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
<path fill="#4A8FE7" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
</g>
<g fill="#BBD7FF" stroke="none">
<circle cx="20" cy="30" r="4"/>
<circle cx="80" cy="30" r="4"/>
<circle cx="30" cy="70" r="3"/>
<circle cx="70" cy="70" r="3"/>
</g>
<g stroke="#17233B" stroke-width="1.6" fill="none" stroke-linecap="round">
<path d="M49,28 C44,18 40,14 36,11"/>
<path d="M51,28 C56,18 60,14 64,11"/>
</g>
<g fill="#17233B" stroke="none">
<circle cx="36" cy="11" r="1.8"/>
<circle cx="64" cy="11" r="1.8"/>
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
<circle cx="50" cy="24" r="4.5"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-66
View File
@@ -1,66 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<defs>
<radialGradient id="goldGlow" cx="0.5" cy="0.5" r="0.55">
<stop offset="0%" stop-color="#FFF6C8" stop-opacity="0.9"/>
<stop offset="55%" stop-color="#FFD24A" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#FFD24A" stop-opacity="0"/>
</radialGradient>
<linearGradient id="goldWing" x1="0" y1="0" x2="0.7" y2="1">
<stop offset="0%" stop-color="#FFFBE6"/>
<stop offset="35%" stop-color="#FFDE6A"/>
<stop offset="70%" stop-color="#F5B617"/>
<stop offset="100%" stop-color="#C97B00"/>
</linearGradient>
<linearGradient id="goldSheen" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- Zářivá aura -->
<circle cx="50" cy="48" r="48" fill="url(#goldGlow)"/>
<!-- Křídla -->
<g stroke="#8A5A00" stroke-width="1.6" stroke-linejoin="round">
<path fill="url(#goldWing)" d="M48,32 C30,8 6,12 6,34 C6,52 30,54 48,50 Z"/>
<path fill="url(#goldWing)" d="M52,32 C70,8 94,12 94,34 C94,52 70,54 52,50 Z"/>
<path fill="url(#goldWing)" d="M47,50 C33,54 14,58 16,76 C18,89 41,86 47,66 Z"/>
<path fill="url(#goldWing)" d="M53,50 C67,54 86,58 84,76 C82,89 59,86 53,66 Z"/>
</g>
<!-- Drahokamové body na křídlech -->
<g fill="#FFF8D8" stroke="#E8B23A" stroke-width="0.6">
<circle cx="20" cy="30" r="4.5"/>
<circle cx="80" cy="30" r="4.5"/>
<circle cx="29" cy="72" r="3.5"/>
<circle cx="71" cy="72" r="3.5"/>
</g>
<g fill="#B8860B">
<circle cx="20" cy="30" r="1.6"/>
<circle cx="80" cy="30" r="1.6"/>
</g>
<!-- Lesklý přeliv přes horní křídla -->
<path fill="url(#goldSheen)" opacity="0.55" d="M48,33 C34,15 16,17 12,31 C24,26 40,27 48,40 Z"/>
<!-- Tykadla -->
<g stroke="#6A4A00" stroke-width="1.6" fill="none" stroke-linecap="round">
<path d="M49,28 C44,17 40,13 36,10"/>
<path d="M51,28 C56,17 60,13 64,10"/>
</g>
<!-- Tělo -->
<g fill="#5A3E00" stroke="none">
<circle cx="36" cy="10" r="2"/>
<circle cx="64" cy="10" r="2"/>
<rect x="47" y="24" width="6" height="52" rx="3"/>
<circle cx="50" cy="24" r="5"/>
</g>
<!-- Jiskry -->
<g fill="#FFFFFF">
<path d="M50 2 l1.4 3.4 l3.4 1.4 l-3.4 1.4 l-1.4 3.4 l-1.4 -3.4 l-3.4 -1.4 l3.4 -1.4 z"/>
<path d="M90 20 l1 2.6 l2.6 1 l-2.6 1 l-1 2.6 l-1 -2.6 l-2.6 -1 l2.6 -1 z" opacity="0.85"/>
<path d="M12 66 l0.9 2.3 l2.3 0.9 l-2.3 0.9 l-0.9 2.3 l-0.9 -2.3 l-2.3 -0.9 l2.3 -0.9 z" opacity="0.8"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

-34
View File
@@ -1,34 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<defs>
<linearGradient id="mothWing" x1="0" y1="0" x2="0.6" y2="1">
<stop offset="0%" stop-color="#5A5560"/>
<stop offset="60%" stop-color="#3A3540"/>
<stop offset="100%" stop-color="#211E26"/>
</linearGradient>
</defs>
<g stroke="#15121A" stroke-width="1.5" stroke-linejoin="round">
<path fill="url(#mothWing)" d="M48,34 C32,16 10,20 10,36 C10,50 30,50 48,48 Z"/>
<path fill="url(#mothWing)" d="M52,34 C68,16 90,20 90,36 C90,50 70,50 52,48 Z"/>
<path fill="url(#mothWing)" d="M47,49 C35,52 20,56 22,70 C24,80 42,78 47,62 Z"/>
<path fill="url(#mothWing)" d="M53,49 C65,52 80,56 78,70 C76,80 58,78 53,62 Z"/>
</g>
<!-- zlověstné oční skvrny -->
<g fill="#C9302C" stroke="#3a0d0b" stroke-width="1">
<circle cx="24" cy="34" r="4"/>
<circle cx="76" cy="34" r="4"/>
</g>
<g fill="#1a0605">
<circle cx="24" cy="34" r="1.6"/>
<circle cx="76" cy="34" r="1.6"/>
</g>
<!-- huňatá tykadla -->
<g stroke="#15121A" stroke-width="1.8" fill="none" stroke-linecap="round">
<path d="M49,30 C44,20 39,16 34,14"/>
<path d="M51,30 C56,20 61,16 66,14"/>
</g>
<!-- tělo -->
<g fill="#15121A" stroke="none">
<rect x="47" y="26" width="6" height="46" rx="3"/>
<circle cx="50" cy="26" r="5"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-79
View File
@@ -1,79 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
width="100"
height="100">
<defs>
<linearGradient id="netFillGold" x1="0" y1="0" x2="0.4" y2="1">
<stop offset="0%" stop-color="#FFF3C4" stop-opacity="0.28"/>
<stop offset="100%" stop-color="#F0C24A" stop-opacity="0.12"/>
</linearGradient>
<clipPath id="netBagClipGold">
<path d="
M 36 8
C 21 8, 9 20, 9 35
C 9 51, 13 70, 19 84
C 22 92, 27 94, 31 87
C 40 72, 54 51, 63 35
C 63 20, 51 8, 36 8
Z"/>
</clipPath>
</defs>
<!-- Síťový vak za obručí -->
<path d="
M 36 8
C 21 8, 9 20, 9 35
C 9 51, 13 70, 19 84
C 22 92, 27 94, 31 87
C 40 72, 54 51, 63 35
C 63 20, 51 8, 36 8
Z"
fill="url(#netFillGold)"
stroke="#E8B23A"
stroke-width="1"
opacity="0.85"/>
<!-- Výplet síťky -->
<g clip-path="url(#netBagClipGold)"
fill="none"
stroke="#E8B23A"
stroke-width="0.8"
opacity="0.7"
stroke-linecap="round">
<path d="M 16 17 C 15 42, 19 69, 25 90"/>
<path d="M 25 10 C 23 39, 24 69, 25 90"/>
<path d="M 36 8 C 34 39, 30 69, 25 90"/>
<path d="M 47 10 C 44 40, 36 70, 25 90"/>
<path d="M 56 18 C 51 44, 41 72, 25 90"/>
<path d="M 62 30 C 55 51, 43 75, 25 90"/>
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
<path d="M 10 28 C 25 34, 47 34, 62 29"/>
<path d="M 9 40 C 25 46, 45 47, 59 42"/>
<path d="M 11 52 C 24 58, 41 59, 53 54"/>
<path d="M 13 64 C 24 69, 36 70, 45 66"/>
<path d="M 16 75 C 24 79, 31 80, 38 77"/>
<path d="M 20 84 C 24 87, 28 87, 32 84"/>
</g>
<!-- Rukojeť -->
<line x1="57" y1="57" x2="93" y2="93" stroke="#8B5A2B" stroke-width="6" stroke-linecap="round"/>
<line x1="57" y1="57" x2="93" y2="93" stroke="#A9713A" stroke-width="3" stroke-linecap="round"/>
<!-- Kovové napojení rukojeti -->
<line x1="56" y1="55" x2="61" y2="61" stroke="#B8860B" stroke-width="6" stroke-linecap="round"/>
<line x1="56" y1="55" x2="61" y2="61" stroke="#FFE79A" stroke-width="2.5" stroke-linecap="round"/>
<!-- Zlatá obruč -->
<circle cx="36" cy="35" r="27" fill="none" stroke="#C8891A" stroke-width="4.5"/>
<circle cx="36" cy="35" r="27" fill="none" stroke="#FFD24A" stroke-width="2"/>
<!-- Třpytky -->
<g fill="#FFF7D6" stroke="none">
<path d="M 33 6 l 1.2 3 l 3 1.2 l -3 1.2 l -1.2 3 l -1.2 -3 l -3 -1.2 l 3 -1.2 z"/>
<path d="M 60 40 l 1 2.4 l 2.4 1 l -2.4 1 l -1 2.4 l -1 -2.4 l -2.4 -1 l 2.4 -1 z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

-74
View File
@@ -1,74 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
width="100"
height="100">
<defs>
<linearGradient id="netFillTorn" x1="0" y1="0" x2="0.4" y2="1">
<stop offset="0%" stop-color="#E4EDF5" stop-opacity="0.14"/>
<stop offset="100%" stop-color="#B9C4CF" stop-opacity="0.06"/>
</linearGradient>
<!-- Vak s vyříznutou dírou uprostřed -->
<clipPath id="netBagClipTorn">
<path d="
M 36 8 C 21 8, 9 20, 9 35 C 9 51, 13 70, 19 84
C 22 92, 27 94, 31 87 C 40 72, 54 51, 63 35 C 63 20, 51 8, 36 8 Z
M 27 40 C 20 44, 22 55, 30 58 C 38 61, 46 54, 43 46 C 41 40, 33 37, 27 40 Z"
clip-rule="evenodd"/>
</clipPath>
</defs>
<!-- Síťový vak (s dírou) -->
<path d="
M 36 8 C 21 8, 9 20, 9 35 C 9 51, 13 70, 19 84
C 22 92, 27 94, 31 87 C 40 72, 54 51, 63 35 C 63 20, 51 8, 36 8 Z
M 27 40 C 20 44, 22 55, 30 58 C 38 61, 46 54, 43 46 C 41 40, 33 37, 27 40 Z"
fill="url(#netFillTorn)"
stroke="#AEBBC8"
stroke-width="1"
opacity="0.7"
fill-rule="evenodd"/>
<!-- Výplet síťky (oříznutý dírou) -->
<g clip-path="url(#netBagClipTorn)"
fill="none"
stroke="#AEBBC8"
stroke-width="0.8"
opacity="0.55"
stroke-linecap="round">
<path d="M 16 17 C 15 42, 19 69, 25 90"/>
<path d="M 25 10 C 23 39, 24 69, 25 90"/>
<path d="M 36 8 C 34 39, 30 69, 25 90"/>
<path d="M 47 10 C 44 40, 36 70, 25 90"/>
<path d="M 56 18 C 51 44, 41 72, 25 90"/>
<path d="M 62 30 C 55 51, 43 75, 25 90"/>
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
<path d="M 13 64 C 24 69, 36 70, 45 66"/>
<path d="M 16 75 C 24 79, 31 80, 38 77"/>
<path d="M 20 84 C 24 87, 28 87, 32 84"/>
</g>
<!-- Roztřepená vlákna kolem díry -->
<g stroke="#9AA7B4" stroke-width="0.8" fill="none" stroke-linecap="round" opacity="0.8">
<path d="M 27 40 l -3 -2"/>
<path d="M 43 46 l 3 -1"/>
<path d="M 30 58 l -1 3"/>
<path d="M 38 52 l 3 2"/>
<path d="M 24 49 l -3 1"/>
</g>
<!-- Rukojeť -->
<line x1="57" y1="57" x2="93" y2="93" stroke="#8B5A2B" stroke-width="6" stroke-linecap="round"/>
<line x1="57" y1="57" x2="93" y2="93" stroke="#A9713A" stroke-width="3" stroke-linecap="round"/>
<!-- Kovové napojení rukojeti -->
<line x1="56" y1="55" x2="61" y2="61" stroke="#71808E" stroke-width="6" stroke-linecap="round"/>
<line x1="56" y1="55" x2="61" y2="61" stroke="#B8C4CF" stroke-width="2.5" stroke-linecap="round"/>
<!-- Obruč -->
<circle cx="36" cy="35" r="27" fill="none" stroke="#7A8896" stroke-width="4.5"/>
<circle cx="36" cy="35" r="27" fill="none" stroke="#AEBBC8" stroke-width="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

-106
View File
@@ -1,106 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
width="100"
height="100">
<defs>
<linearGradient id="netFill" x1="0" y1="0" x2="0.4" y2="1">
<stop offset="0%" stop-color="#E4EDF5" stop-opacity="0.18"/>
<stop offset="100%" stop-color="#B9C4CF" stop-opacity="0.08"/>
</linearGradient>
<!-- Celý tvar síťového vaku -->
<clipPath id="netBagClip">
<path d="
M 36 8
C 21 8, 9 20, 9 35
C 9 51, 13 70, 19 84
C 22 92, 27 94, 31 87
C 40 72, 54 51, 63 35
C 63 20, 51 8, 36 8
Z"/>
</clipPath>
</defs>
<!-- Síťový vak za obručí -->
<path d="
M 36 8
C 21 8, 9 20, 9 35
C 9 51, 13 70, 19 84
C 22 92, 27 94, 31 87
C 40 72, 54 51, 63 35
C 63 20, 51 8, 36 8
Z"
fill="url(#netFill)"
stroke="#AEBBC8"
stroke-width="1"
opacity="0.7"/>
<!-- Výplet síťky -->
<g clip-path="url(#netBagClip)"
fill="none"
stroke="#AEBBC8"
stroke-width="0.8"
opacity="0.6"
stroke-linecap="round">
<!-- Vlákna začínající na horní a boční obruči -->
<path d="M 16 17 C 15 42, 19 69, 25 90"/>
<path d="M 25 10 C 23 39, 24 69, 25 90"/>
<path d="M 36 8 C 34 39, 30 69, 25 90"/>
<path d="M 47 10 C 44 40, 36 70, 25 90"/>
<path d="M 56 18 C 51 44, 41 72, 25 90"/>
<path d="M 62 30 C 55 51, 43 75, 25 90"/>
<!-- Vlákna od levého okraje obruče -->
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
<!-- Příčná oka -->
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
<path d="M 10 28 C 25 34, 47 34, 62 29"/>
<path d="M 9 40 C 25 46, 45 47, 59 42"/>
<path d="M 11 52 C 24 58, 41 59, 53 54"/>
<path d="M 13 64 C 24 69, 36 70, 45 66"/>
<path d="M 16 75 C 24 79, 31 80, 38 77"/>
<path d="M 20 84 C 24 87, 28 87, 32 84"/>
</g>
<!-- Rukojeť -->
<line x1="57" y1="57"
x2="93" y2="93"
stroke="#8B5A2B"
stroke-width="6"
stroke-linecap="round"/>
<line x1="57" y1="57"
x2="93" y2="93"
stroke="#A9713A"
stroke-width="3"
stroke-linecap="round"/>
<!-- Kovové napojení rukojeti -->
<line x1="56" y1="55"
x2="61" y2="61"
stroke="#71808E"
stroke-width="6"
stroke-linecap="round"/>
<line x1="56" y1="55"
x2="61" y2="61"
stroke="#B8C4CF"
stroke-width="2.5"
stroke-linecap="round"/>
<!-- Hlavní obruč – nezměněná -->
<circle cx="36" cy="35" r="27"
fill="none"
stroke="#7A8896"
stroke-width="4.5"/>
<circle cx="36" cy="35" r="27"
fill="none"
stroke="#AEBBC8"
stroke-width="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<g stroke="#7A3B00" stroke-width="1.5" stroke-linejoin="round">
<path fill="#F59E1E" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
<path fill="#F59E1E" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
<path fill="#F59E1E" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
<path fill="#F59E1E" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
</g>
<g fill="#FFE0A3" stroke="none">
<circle cx="20" cy="30" r="4"/>
<circle cx="80" cy="30" r="4"/>
<circle cx="30" cy="70" r="3"/>
<circle cx="70" cy="70" r="3"/>
</g>
<g stroke="#2B1A0B" stroke-width="1.6" fill="none" stroke-linecap="round">
<path d="M49,28 C44,18 40,14 36,11"/>
<path d="M51,28 C56,18 60,14 64,11"/>
</g>
<g fill="#2B1A0B" stroke="none">
<circle cx="36" cy="11" r="1.8"/>
<circle cx="64" cy="11" r="1.8"/>
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
<circle cx="50" cy="24" r="4.5"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<g stroke="#8A2255" stroke-width="1.5" stroke-linejoin="round">
<path fill="#F06B9B" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
<path fill="#F06B9B" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
<path fill="#F06B9B" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
<path fill="#F06B9B" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
</g>
<g fill="#FFD1E3" stroke="none">
<circle cx="20" cy="30" r="4"/>
<circle cx="80" cy="30" r="4"/>
<circle cx="30" cy="70" r="3"/>
<circle cx="70" cy="70" r="3"/>
</g>
<g stroke="#3B0E24" stroke-width="1.6" fill="none" stroke-linecap="round">
<path d="M49,28 C44,18 40,14 36,11"/>
<path d="M51,28 C56,18 60,14 64,11"/>
</g>
<g fill="#3B0E24" stroke="none">
<circle cx="36" cy="11" r="1.8"/>
<circle cx="64" cy="11" r="1.8"/>
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
<circle cx="50" cy="24" r="4.5"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<g stroke="#8A6D00" stroke-width="1.5" stroke-linejoin="round">
<path fill="#FFD23F" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
<path fill="#FFD23F" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
<path fill="#FFD23F" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
<path fill="#FFD23F" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
</g>
<g fill="#FFF3C4" stroke="none">
<circle cx="20" cy="30" r="4"/>
<circle cx="80" cy="30" r="4"/>
<circle cx="30" cy="70" r="3"/>
<circle cx="70" cy="70" r="3"/>
</g>
<g stroke="#3A2E00" stroke-width="1.6" fill="none" stroke-linecap="round">
<path d="M49,28 C44,18 40,14 36,11"/>
<path d="M51,28 C56,18 60,14 64,11"/>
</g>
<g fill="#3A2E00" stroke="none">
<circle cx="36" cy="11" r="1.8"/>
<circle cx="64" cy="11" r="1.8"/>
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
<circle cx="50" cy="24" r="4.5"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-22
View File
@@ -1,22 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<defs>
<radialGradient id="coinFace" cx="0.4" cy="0.35" r="0.75">
<stop offset="0%" stop-color="#FFE9A3"/>
<stop offset="60%" stop-color="#FFCB3D"/>
<stop offset="100%" stop-color="#E0A200"/>
</radialGradient>
</defs>
<circle cx="50" cy="50" r="42" fill="#C88A12"/>
<circle cx="50" cy="50" r="38" fill="url(#coinFace)" stroke="#F2D774" stroke-width="2"/>
<circle cx="50" cy="50" r="30" fill="none" stroke="#B8860B" stroke-width="2" opacity="0.6"/>
<!-- stylizovaný motýlek na minci -->
<g fill="#B8860B" opacity="0.85">
<path d="M50,42 C42,32 30,34 30,44 C30,52 42,52 50,50 Z"/>
<path d="M50,42 C58,32 70,34 70,44 C70,52 58,52 50,50 Z"/>
<path d="M49,50 C42,52 34,56 36,64 C38,70 48,66 49,58 Z"/>
<path d="M51,50 C58,52 66,56 64,64 C62,70 52,66 51,58 Z"/>
<rect x="48.5" y="40" width="3" height="26" rx="1.5"/>
</g>
<!-- lesk -->
<path d="M 30 30 C 38 24, 50 24, 58 28" fill="none" stroke="#FFF7DC" stroke-width="3" stroke-linecap="round" opacity="0.7"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-27
View File
@@ -1,27 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<!-- vlákno -->
<line x1="50" y1="0" x2="50" y2="30" stroke="#8A8A8A" stroke-width="1.5"/>
<g stroke="#15121A" stroke-width="3" fill="none" stroke-linecap="round">
<!-- nohy -->
<path d="M40,52 C24,44 18,52 12,46"/>
<path d="M40,58 C24,58 16,66 10,64"/>
<path d="M40,64 C26,70 20,80 12,82"/>
<path d="M40,70 C30,80 28,90 22,94"/>
<path d="M60,52 C76,44 82,52 88,46"/>
<path d="M60,58 C76,58 84,66 90,64"/>
<path d="M60,64 C74,70 80,80 88,82"/>
<path d="M60,70 C70,80 72,90 78,94"/>
</g>
<!-- tělo -->
<g stroke="#15121A" stroke-width="1.5">
<ellipse cx="50" cy="46" rx="11" ry="9" fill="#2B2733"/>
<ellipse cx="50" cy="64" rx="15" ry="16" fill="#211E26"/>
<!-- přesýpací hodiny (varovná značka) -->
<path d="M50,58 l5,6 l-5,6 l-5,-6 z" fill="#C9302C" stroke="none"/>
</g>
<!-- oči -->
<g fill="#F5C518">
<circle cx="46" cy="44" r="1.6"/>
<circle cx="54" cy="44" r="1.6"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-48
View File
@@ -1,48 +0,0 @@
// 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);
})
);
});
+92 -1135
View File
File diff suppressed because it is too large. Load diff
+272 -561
View File
File diff suppressed because it is too large. Load diff
+15 -39
View File
@@ -1,57 +1,33 @@
import { Routes, Route } from "react-router-dom"; import { Routes, Route } from "react-router-dom";
import { ProvideSettings, useSettings } from "./context/settings"; import { ProvideSettings } from "./context/settings";
import FlyingButterflies, { BUTTERFLY_PRESETS } from "./FlyingButterflies"; // import Snowfall from "react-snowfall";
import "./FlyingButterflies.scss";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket"; import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage"; import StatsPage from "./pages/StatsPage";
import OrderGroupsPage from "./pages/OrderGroupsPage";
import SuggestionsPage from "./pages/SuggestionsPage";
import App from "./App"; import App from "./App";
import HellGirlFlames from "./components/HellGirlFlames";
export const STATS_URL = '/stats'; export const STATS_URL = '/stats';
export const OBJEDNANI_URL = '/objednani';
export const NAVRHY_URL = '/navrhy';
/** Motýlci se vykreslují jen pokud je uživatel nevypnul v nastavení. */
function Butterflies() {
const settings = useSettings();
if (settings?.showButterflies === false) {
return null;
}
return <FlyingButterflies numButterflies={BUTTERFLY_PRESETS.NORMAL} />;
}
/** Doplňkové efekty vizuálních stylů — společné pro všechny stránky (např. plamen při otevření dialogu). */
function StyleThemeEffects() {
const settings = useSettings();
return settings?.styleTheme === 'hellgirl' ? <HellGirlFlames /> : null;
}
export default function AppRoutes() { export default function AppRoutes() {
return ( return (
<ProvideSettings> <Routes>
<StyleThemeEffects /> <Route path={STATS_URL} element={<StatsPage />} />
<Routes> <Route path="/" element={
<Route path={STATS_URL} element={<StatsPage />} /> <ProvideSettings>
<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}> <SocketContext.Provider value={socket}>
<> <>
<Butterflies /> {/* <Snowfall style={{
zIndex: 2,
position: 'fixed',
width: '100vw',
height: '100vh'
}} /> */}
<App /> <App />
</> </>
<ToastContainer /> <ToastContainer />
</SocketContext.Provider> </SocketContext.Provider>
} /> </ProvideSettings>
</Routes> } />
</ProvideSettings> </Routes>
); );
} }
-749
View File
@@ -1,749 +0,0 @@
.flying-butterflies {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 2;
overflow: hidden;
// V režimu hraní herní vrstva zachytává kliknutí, aby se nedalo omylem
// překliknout do objednávek. V režimu objednávání propouští vše dál (none).
&.playing {
pointer-events: auto;
}
}
// Přepínač režimu hraní / objednávání
.butterfly-mode-toggle {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 7;
pointer-events: auto;
padding: 8px 14px;
border: none;
border-radius: 999px;
background: rgba(33, 37, 41, 0.88);
color: #fff;
font-weight: 700;
font-size: 0.9rem;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(4px);
transition: background 0.15s ease, transform 0.08s ease;
&:hover { background: rgba(33, 37, 41, 1); }
&:active { transform: scale(0.96); }
&.playing {
background: #2f9e44;
&:hover { background: #2b8a3e; }
}
}
// Vrstva herních prvků (síťka, škůdci, pavučina) – skrývá se v režimu objednávání
.butterfly-game-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.butterfly-scene {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 100%;
// Vnější element – pozici a natočení nastavuje JS přes transform
> div {
position: absolute;
top: 0;
left: 0;
width: 34px;
height: 34px;
will-change: transform;
}
}
// Vnitřní element se sprite motýla – máchá křídly (scaleX kolem svislé osy těla)
.butterfly-sprite {
width: 100%;
height: 100%;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
animation: butterfly-flap 0.45s ease-in-out infinite;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15));
// Vzácný zlatý motýl – WOW: silná pulzující zlatá záře + duhový nádech
&.golden {
animation:
butterfly-flap 0.35s ease-in-out infinite,
butterfly-golden-glow 1.4s ease-in-out infinite;
}
}
@keyframes butterfly-golden-glow {
0%,
100% {
filter:
saturate(1.15) hue-rotate(0deg)
drop-shadow(0 0 5px rgba(255, 215, 80, 0.85))
drop-shadow(0 0 10px rgba(255, 180, 40, 0.55))
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.2));
}
50% {
filter:
saturate(1.4) hue-rotate(-16deg)
drop-shadow(0 0 13px rgba(255, 235, 130, 1))
drop-shadow(0 0 24px rgba(255, 190, 50, 0.95))
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.2));
}
}
@keyframes butterfly-flap {
0%,
100% {
transform: scaleX(1);
}
50% {
transform: scaleX(0.35);
}
}
// Korunovaný motýl (anti-bot inspekce) – nepřehlédnutelná duhová záře
.butterfly-sprite.royal {
animation: butterfly-flap 0.35s ease-in-out infinite, butterfly-royal-glow 0.9s ease-in-out infinite;
}
@keyframes butterfly-royal-glow {
0%, 100% {
filter: drop-shadow(0 0 8px rgba(255, 215, 80, 1)) drop-shadow(0 0 16px rgba(120, 200, 255, 0.8));
}
50% {
filter: drop-shadow(0 0 16px rgba(255, 120, 220, 1)) drop-shadow(0 0 28px rgba(120, 255, 160, 0.9));
}
}
// Černá můra – zlověstný tmavý nádech
.butterfly-sprite.moth {
filter: drop-shadow(0 0 4px rgba(120, 60, 160, 0.6)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4));
animation: butterfly-flap 0.5s ease-in-out infinite, butterfly-moth-pulse 1.6s ease-in-out infinite;
}
@keyframes butterfly-moth-pulse {
0%, 100% { filter: drop-shadow(0 0 4px rgba(120, 60, 160, 0.5)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4)); }
50% { filter: drop-shadow(0 0 9px rgba(150, 40, 40, 0.8)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4)); }
}
// --- Havěť (ptáci a vosy) ----------------------------------------------------
.butterfly-critter {
position: absolute;
top: 0;
left: 0;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
pointer-events: none;
will-change: transform;
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.2));
z-index: 3;
// Vosa jde zaklikat (plácačka)
&.wasp {
pointer-events: auto;
cursor: crosshair;
touch-action: none;
}
// Netopýr jde zahnat klikáním
&.bat {
pointer-events: auto;
cursor: crosshair;
touch-action: none;
z-index: 5;
filter: drop-shadow(0 0 6px rgba(60, 40, 90, 0.8));
}
&.hit {
animation: butterfly-stalker-hit 0.18s ease-out;
}
}
// Pavučina přes síťku – blokuje chytání, dokud ji hráč nestrhne
.butterfly-web {
position: fixed;
top: 0;
left: 0;
width: 120px;
height: 120px;
background-image: url(/spider.svg);
background-repeat: no-repeat;
background-position: center;
background-size: contain;
pointer-events: auto;
cursor: crosshair;
touch-action: none;
z-index: 6;
opacity: 0.9;
filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.4));
&.hit {
animation: butterfly-stalker-hit 0.15s ease-out;
}
}
// --- Síťka na chytání --------------------------------------------------------
.butterfly-net {
position: fixed;
top: 0;
left: 0;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
pointer-events: auto;
cursor: grab;
z-index: 4;
will-change: transform;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
transition: filter 0.15s ease;
touch-action: none;
user-select: none;
&.grabbed {
cursor: grabbing;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.35));
}
// Prémiová (zlatá) síťka – zlatá záře a jemný pulz
&.premium {
filter: drop-shadow(0 0 8px rgba(255, 200, 60, 0.85)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
animation: butterfly-net-glow 1.3s ease-in-out infinite;
}
// Protržená síťka – nedá se chytat
&.torn {
cursor: not-allowed;
opacity: 0.9;
filter: drop-shadow(0 2px 3px rgba(150, 30, 30, 0.35));
}
&.catch-pop {
animation: butterfly-net-pop 0.3s ease-out;
}
&.stung {
animation: butterfly-net-stung 0.4s ease-in-out;
}
// Záblesk při chycení černé můry (chyba)
&.moth-hit {
animation: butterfly-net-moth 0.4s ease-out;
}
// Omráčená síťka (po žihnutí vosou) – po celou dobu, kdy nejde chytat.
// Má přednost před grabbed kurzorem.
&.stunned {
cursor: not-allowed !important;
animation: butterfly-net-dizzy 0.9s ease-in-out infinite;
}
}
// Zešednutí + červený nádech, ať je jasné, že síťka teď nechytá.
// NEanimujeme transform (ten řídí JS inline) – jen filter.
@keyframes butterfly-net-dizzy {
0%,
100% {
filter: grayscale(0.7) brightness(0.85) drop-shadow(0 0 5px rgba(217, 72, 15, 0.55));
}
50% {
filter: grayscale(0.85) brightness(0.8) drop-shadow(0 0 9px rgba(217, 72, 15, 0.8));
}
}
@keyframes butterfly-net-pop {
0% {
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1);
}
40% {
filter: drop-shadow(0 2px 8px rgba(120, 200, 120, 0.7)) brightness(1.25);
}
100% {
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1);
}
}
@keyframes butterfly-net-glow {
0%,
100% {
filter: drop-shadow(0 0 6px rgba(255, 200, 60, 0.6)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
}
50% {
filter: drop-shadow(0 0 12px rgba(255, 220, 100, 1)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
}
}
// Pozn.: NEanimujeme transform – ten nastavuje JS inline (poloha síťky).
// Žihnutí vosou proto naznačíme jen červeným zábleskem filtru.
@keyframes butterfly-net-stung {
0%,
100% {
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1);
}
30% {
filter: drop-shadow(0 0 8px rgba(217, 72, 15, 0.85)) brightness(1.1) hue-rotate(-15deg);
}
}
// Prchavé texty (chycení, protržení, žihnutí)
.butterfly-catch-fx,
.butterfly-tear-fx,
.butterfly-sting-fx {
position: fixed;
margin: -12px 0 0 10px;
font-weight: 700;
font-size: 1.1rem;
pointer-events: none;
z-index: 5;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
animation: butterfly-catch-float 0.8s ease-out forwards;
}
.butterfly-catch-fx {
color: #2f9e44;
&.golden {
color: #e0a200;
font-size: 1.35rem;
text-shadow: 0 0 6px rgba(255, 220, 100, 0.9), 0 1px 2px rgba(0, 0, 0, 0.3);
}
&.moth {
color: #c92a2a;
font-size: 1.35rem;
text-shadow: 0 0 6px rgba(120, 40, 160, 0.7), 0 1px 2px rgba(0, 0, 0, 0.3);
}
}
@keyframes butterfly-net-moth {
0%, 100% { filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1); }
30% { filter: drop-shadow(0 0 9px rgba(150, 40, 160, 0.9)) brightness(0.8); }
}
.butterfly-tear-fx {
color: #c92a2a;
}
.butterfly-sting-fx {
color: #d9480f;
}
@keyframes butterfly-catch-float {
0% {
opacity: 0;
transform: translateY(0) scale(0.9);
}
20% {
opacity: 1;
transform: translateY(-6px) scale(1);
}
100% {
opacity: 0;
transform: translateY(-40px) scale(1);
}
}
// --- HUD: počítadlo, mince, úroveň, kombo, hlášky ----------------------------
.butterfly-hud {
position: fixed;
bottom: 16px;
left: 16px;
z-index: 5;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
// HUD sám o sobě neblokuje klikání; interaktivní je jen tlačítko opravy
pointer-events: none;
}
.butterfly-shop-open {
pointer-events: auto;
align-self: flex-start;
padding: 6px 12px;
border: none;
border-radius: 999px;
background: #f59e1e;
color: #fff;
font-weight: 700;
font-size: 0.85rem;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
transition: background 0.15s ease, transform 0.08s ease;
&:hover { background: #e8890a; }
&:active { transform: scale(0.96); }
}
.butterfly-counter {
display: inline-flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(4px);
font-weight: 600;
font-size: 0.95rem;
color: #333;
user-select: none;
pointer-events: auto;
cursor: pointer;
transition: box-shadow 0.15s ease;
&:hover {
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.22);
}
&.bump {
animation: butterfly-counter-bump 0.3s ease-out;
}
}
.butterfly-counter-row {
display: inline-flex;
align-items: center;
gap: 6px;
}
.butterfly-counter-icon,
.butterfly-coin-icon {
display: inline-block;
width: 20px;
height: 20px;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
.butterfly-counter-icon {
background-image: url(/butterfly-orange.svg);
}
.butterfly-coin-icon {
background-image: url(/coin.svg);
margin-left: 4px;
}
.butterfly-counter-value {
min-width: 1ch;
text-align: center;
font-variant-numeric: tabular-nums;
}
.butterfly-level {
display: flex;
flex-direction: column;
gap: 3px;
}
.butterfly-level-title {
font-size: 0.72rem;
font-weight: 600;
color: #6b5b2e;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
}
.butterfly-progress {
width: 100%;
height: 5px;
background: rgba(0, 0, 0, 0.1);
border-radius: 999px;
overflow: hidden;
}
.butterfly-progress-bar {
height: 100%;
background: linear-gradient(90deg, #ffcb3d, #e0a200);
border-radius: 999px;
transition: width 0.4s ease;
}
.butterfly-pests {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8rem;
font-weight: 700;
}
.butterfly-pest {
color: #868e96;
font-variant-numeric: tabular-nums;
&.warn {
color: #d9480f;
}
&.ok {
color: #2f9e44;
}
}
.butterfly-torn-note {
margin-top: 4px;
font-size: 0.76rem;
font-weight: 600;
color: #c92a2a;
}
.butterfly-action-btn {
margin-top: 4px;
padding: 5px 10px;
border: none;
border-radius: 999px;
background: #1c7ed6;
color: #fff;
font-weight: 700;
font-size: 0.82rem;
cursor: pointer;
pointer-events: auto;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
transition: background 0.15s ease, transform 0.1s ease;
&:hover:not(:disabled) {
background: #1971c2;
}
&:active:not(:disabled) {
transform: scale(0.96);
}
&:disabled {
background: #adb5bd;
cursor: not-allowed;
}
&.danger {
background: #c92a2a;
&:hover:not(:disabled) {
background: #e03131;
}
}
}
// --- Bossové (zloděj / housenka) ---------------------------------------------
.butterfly-stalker {
position: fixed;
top: 0;
left: 0;
width: 56px;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: auto;
cursor: crosshair;
user-select: none;
touch-action: none;
z-index: 6;
will-change: transform;
&.hit .stalker-emoji {
animation: butterfly-stalker-hit 0.18s ease-out;
}
}
.stalker-emoji {
font-size: 44px;
line-height: 1;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.35));
}
.stalker-hp {
width: 46px;
height: 6px;
margin-bottom: 3px;
background: rgba(0, 0, 0, 0.25);
border-radius: 999px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.stalker-hp-inner {
height: 100%;
width: 100%;
background: linear-gradient(90deg, #ff6b6b, #c92a2a);
border-radius: 999px;
transition: width 0.1s linear;
}
@keyframes butterfly-stalker-hit {
0% { transform: scale(1) rotate(0); }
50% { transform: scale(0.82) rotate(-8deg); filter: brightness(1.6); }
100% { transform: scale(1) rotate(0); }
}
// Efekt při plácnutí vosy
.butterfly-swat-fx {
position: fixed;
margin: -14px 0 0 -6px;
font-size: 1.4rem;
pointer-events: none;
z-index: 5;
animation: butterfly-catch-float 0.8s ease-out forwards;
}
.butterfly-combo {
padding: 4px 10px;
border-radius: 999px;
background: rgba(224, 162, 0, 0.92);
color: #fff;
font-weight: 800;
font-size: 0.9rem;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
animation: butterfly-counter-bump 0.3s ease-out;
}
.butterfly-flash {
max-width: 260px;
padding: 8px 12px;
border-radius: 12px;
background: rgba(33, 37, 41, 0.92);
color: #fff;
font-weight: 600;
font-size: 0.85rem;
line-height: 1.25;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.25);
animation: butterfly-flash-in 0.25s ease-out;
pointer-events: auto;
cursor: pointer;
}
@keyframes butterfly-flash-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes butterfly-counter-bump {
0% {
transform: scale(1);
}
40% {
transform: scale(1.18);
}
100% {
transform: scale(1);
}
}
// Ohleduplnost k uživatelům, kteří nechtějí pohyb
@media (prefers-reduced-motion: reduce) {
.butterfly-sprite,
.butterfly-sprite.golden {
animation: none;
}
.butterfly-net.catch-pop,
.butterfly-net.premium,
.butterfly-net.stung,
.butterfly-net.stunned,
.butterfly-counter.bump,
.butterfly-combo {
animation: none;
}
// Bez animace aspoň staticky ukážeme, že síťka je omráčená
.butterfly-net.stunned {
filter: grayscale(0.8) brightness(0.82) drop-shadow(0 0 6px rgba(217, 72, 15, 0.7));
}
// Zlatý motýl bez animace aspoň staticky září
.butterfly-sprite.golden {
filter: saturate(1.3) drop-shadow(0 0 8px rgba(255, 215, 80, 0.95)) drop-shadow(0 0 16px rgba(255, 190, 50, 0.7));
}
}
// --- Anti-bot inspekce a vězení ---------------------------------------------
.butterfly-inspection {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 8;
pointer-events: none;
max-width: 92vw;
padding: 10px 16px;
border-radius: 999px;
background: rgba(28, 126, 214, 0.95);
color: #fff;
font-weight: 700;
font-size: 0.95rem;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.3);
animation: butterfly-flash-in 0.25s ease-out;
}
.butterfly-jail {
position: fixed;
inset: 0;
z-index: 6;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
}
// Svislé mříže přes celou obrazovku
.butterfly-jail-bars {
position: absolute;
inset: 0;
background:
repeating-linear-gradient(
90deg,
rgba(20, 22, 28, 0.92) 0px,
rgba(20, 22, 28, 0.92) 14px,
rgba(20, 22, 28, 0) 14px,
rgba(20, 22, 28, 0) 70px
);
box-shadow: inset 0 0 120px rgba(0, 0, 0, 0.6);
}
.butterfly-jail-note {
position: relative;
text-align: center;
padding: 22px 26px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
max-width: 90vw;
.jail-emoji { font-size: 2.4rem; }
.jail-title { font-weight: 800; font-size: 1.2rem; color: #c92a2a; margin-top: 4px; }
.jail-sub { margin-top: 6px; font-size: 0.95rem; color: #333; font-variant-numeric: tabular-nums; }
.jail-hint { margin-top: 8px; font-size: 0.8rem; color: #868e96; }
}
@media (prefers-reduced-motion: reduce) {
.butterfly-sprite.royal { animation: none; }
}
File diff suppressed because it is too large. Load diff
+8 -94
View File
@@ -1,99 +1,13 @@
.login-page { .login {
min-height: 100vh; height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--luncher-bg);
padding: 24px;
}
.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;
gap: 20px; text-align: center;
justify-content: center;
} }
.login-form label { .login-inner {
display: block; display: flex;
text-align: left; flex-direction: column;
font-weight: 500; align-items: center;
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;
} }
+31 -77
View File
@@ -1,40 +1,32 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { Button } from 'react-bootstrap'; import { Button } from 'react-bootstrap';
import { useAuth } from './context/auth'; import { useAuth } from './context/auth';
import { login } from '../../types'; import { login } from '../../types';
import { useAppConfig } from './hooks/useGroupLimits';
import './Login.css'; import './Login.css';
/** /**
* Přihlášení. Podle konfigurace serveru buď přihlásí uživatele automaticky přes důvěryhodné * Formulář pro prvotní zadání přihlašovacího jména.
* 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 || !loaded || trustedHeaders === false) return; if (auth && !auth.login) {
// Přihlášení přes hlavičky z proxy (případně pokus „naprázdno", pokud nevíme, jak server přihlašuje) // Vyzkoušíme přihlášení "naprázdno", pokud projde, přihlásili nás trusted headers
setTrustedError(undefined); login().then(response => {
login().then(response => { const token = response.data;
const token = response.data; if (token) {
if (token) { auth?.setToken(token as unknown as string); // TODO vyřešit, API definice je špatně, je to skutečně string
auth.setToken(token as unknown as string); // TODO vyřešit, API definice je špatně, je to skutečně string }
} else if (trustedHeaders) { }).catch(error => {
setTrustedError((response.error as any)?.error ?? 'Přihlášení přes proxy se nezdařilo.'); // nezajímá nás
} });
}).catch(() => { }
if (trustedHeaders) setTrustedError('Server je nedostupný. Zkuste to prosím za chvíli.'); }, [auth]);
});
}, [auth, loaded, trustedHeaders, attempt]);
const doLogin = useCallback(async () => { const doLogin = useCallback(async () => {
const length = loginRef?.current?.value.length && loginRef.current.value.replaceAll(/\s/g, '').length const length = loginRef?.current?.value.length && loginRef.current.value.replace(/\s/g, '').length
if (length) { if (length) {
const response = await login({ body: { login: loginRef.current?.value } }); const response = await login({ body: { login: loginRef.current?.value } });
if (response.data) { if (response.data) {
@@ -43,60 +35,22 @@ export default function Login() {
} }
}, [auth]); }, [auth]);
if (auth?.login) { if (!auth?.login) {
return <div>Neplatný stav</div> return <div className='login'>
} <h1>Luncher</h1>
<h4 style={{ marginBottom: "50px" }}>Aplikace pro profesionální management obědů</h4>
const header = <> <div className='login-inner'>
<h1 className='login-logo'>Luncher</h1> <p style={{ fontSize: "12px", marginTop: "10px" }}>
<p className='login-subtitle'>Aplikace pro profesionální management obědů</p> 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>
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 (event.key === 'Enter') {
if (!loaded || trustedHeaders) { doLogin()
return ( }
<div className='login-page'> }} />
<div className='login-card'> <Button onClick={doLogin} style={{ marginTop: "20px" }}>Uložit</Button>
{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') {
doLogin()
}
}}
/>
<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>
} }
+8 -21
View File
@@ -73,16 +73,22 @@ export const getDayOfWeekIndex = (date: Date) => {
return (((date.getDay() - 1) % 7) + 7) % 7; return (((date.getDay() - 1) % 7) + 7) % 7;
} }
/** Vrátí true, pokud je předané datum o víkendu. */
export function getIsWeekend(date: Date) {
const index = getDayOfWeekIndex(date);
return index == 5 || index == 6;
}
/** Vrátí první pracovní den v týdnu předaného data. */ /** Vrátí první pracovní den v týdnu předaného data. */
export function getFirstWorkDayOfWeek(date: Date) { export function getFirstWorkDayOfWeek(date: Date) {
const firstDay = new Date(date); const firstDay = new Date(date.getTime());
firstDay.setDate(date.getDate() - getDayOfWeekIndex(date)); firstDay.setDate(date.getDate() - getDayOfWeekIndex(date));
return firstDay; return firstDay;
} }
/** Vrátí poslední pracovní den v týdnu předaného data. */ /** Vrátí poslední pracovní den v týdnu předaného data. */
export function getLastWorkDayOfWeek(date: Date) { export function getLastWorkDayOfWeek(date: Date) {
const lastDay = new Date(date); const lastDay = new Date(date.getTime());
lastDay.setDate(date.getDate() + (4 - getDayOfWeekIndex(date))); lastDay.setDate(date.getDate() + (4 - getDayOfWeekIndex(date)));
return lastDay; return lastDay;
} }
@@ -104,22 +110,3 @@ export function getHumanDate(date: Date) {
let currentYear = date.getFullYear(); let currentYear = date.getFullYear();
return `${currentDay}.${currentMonth}.${currentYear}`; 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;
}
-181
View File
@@ -1,181 +0,0 @@
.duel-invite {
position: fixed;
left: 16px;
bottom: 16px;
z-index: 1040;
padding: 10px 16px;
border: none;
border-radius: 999px;
background: var(--luncher-primary, #198754);
color: #fff;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
cursor: pointer;
animation: duel-pulse 1.6s ease-in-out infinite;
}
@keyframes duel-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.04); }
}
.drawing-game-modal {
.draw-topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 10px;
font-size: 1.05rem;
}
.draw-hint {
// Mezery mezi slovy v masce se nesmí slít, jinak nejde poznat počet slov
white-space: pre;
font-family: monospace;
font-size: 1.4rem;
letter-spacing: 0.15em;
}
.draw-word strong {
font-size: 1.3rem;
}
.draw-timer.urgent {
color: #ef4444;
font-weight: 700;
}
.draw-layout {
display: flex;
gap: 16px;
align-items: stretch;
}
.draw-canvas-col {
flex: 1 1 auto;
min-width: 0;
}
.draw-canvas {
display: block;
width: 100%;
height: auto;
border-radius: 8px;
background: #fff;
touch-action: none;
&.active {
cursor: crosshair;
}
}
.draw-tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 8px;
}
.draw-color {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid rgba(127, 127, 127, 0.5);
padding: 0;
&.selected {
outline: 3px solid var(--luncher-primary, #198754);
outline-offset: 1px;
}
}
.draw-size {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border: 1px solid rgba(127, 127, 127, 0.5);
background: transparent;
span {
display: block;
border-radius: 50%;
background: currentColor;
}
&.selected {
outline: 3px solid var(--luncher-primary, #198754);
}
}
.draw-sep {
width: 8px;
}
.draw-side {
flex: 0 0 260px;
display: flex;
flex-direction: column;
gap: 8px;
}
.draw-scores {
list-style: none;
padding: 0;
margin: 0;
li {
display: flex;
justify-content: space-between;
padding: 2px 0;
}
.guessed {
color: #22c55e;
}
}
.draw-chat {
flex: 1 1 auto;
min-height: 200px;
max-height: 360px;
overflow-y: auto;
border: 1px solid rgba(127, 127, 127, 0.35);
border-radius: 6px;
padding: 6px 8px;
font-size: 0.9rem;
.system {
color: #22c55e;
font-style: italic;
}
}
.draw-players {
columns: 2;
}
// Na úzkém displeji (telefon na výšku) jde chat pod plátno, jinak by se plátno smrsklo na proužek
@media (max-width: 767.98px) {
.draw-topbar {
flex-wrap: wrap;
gap: 4px 16px;
}
.draw-layout {
flex-direction: column;
}
.draw-side {
flex: 0 0 auto;
}
.draw-chat {
min-height: 120px;
max-height: 200px;
}
}
}
-429
View File
@@ -1,429 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Form, Modal, Table } from "react-bootstrap";
import { socket } from "../context/socket";
import { useAuth } from "../context/auth";
import { getToken } from "../Utils";
import { useGameIcon } from "../games/themeIcons";
import "./DrawingGame.scss";
// Typy musí odpovídat server/src/drawingGame.ts (stav chodí přes websocket, ne přes OpenAPI)
type DrawPhase = 'idle' | 'lobby' | 'drawing' | 'reveal' | 'finished';
type DrawPlayer = { login: string, score: number, guessed: boolean, place?: number };
type ChatMessage = { login?: string, text: string, system?: boolean };
type Stroke = { color: string, size: number, points: [number, number][], start: boolean };
type DrawState = {
phase: DrawPhase,
host?: string,
players: DrawPlayer[],
drawer?: string,
round: number,
totalRounds: number,
hint?: string,
answer?: string,
remainingMs?: number,
messages: ChatMessage[],
minPlayers: number,
};
const OPEN_EVENT = 'luncher:open-drawing';
/** Otevře okno Kreslení. */
export const openDrawingGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
const CANVAS_W = 800;
const CANVAS_H = 600;
/** Souřadnice tahů jsou 0–1000 v obou osách, nezávisle na velikosti plátna. */
const COORD_MAX = 1000;
const COLORS = ['#111827', '#ef4444', '#f97316', '#facc15', '#22c55e', '#3b82f6', '#a855f7', '#92400e', '#ffffff'];
const SIZES = [3, 8, 18];
/** Jak často se rozkreslený tah posílá ostatním (ms). */
const FLUSH_MS = 40;
const MEDALS = ['🥇', '🥈', '🥉'];
function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke) {
const sx = CANVAS_W / COORD_MAX;
const sy = CANVAS_H / COORD_MAX;
ctx.strokeStyle = stroke.color;
ctx.fillStyle = stroke.color;
ctx.lineWidth = stroke.size;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const [first, ...rest] = stroke.points;
if (rest.length === 0) {
ctx.beginPath();
ctx.arc(first[0] * sx, first[1] * sy, stroke.size / 2, 0, Math.PI * 2);
ctx.fill();
return;
}
ctx.beginPath();
ctx.moveTo(first[0] * sx, first[1] * sy);
rest.forEach(([x, y]) => ctx.lineTo(x * sx, y * sy));
ctx.stroke();
}
function clearCanvas(ctx: CanvasRenderingContext2D) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
}
/** Kreslení — jeden kreslí jídlo z dnešních menu, ostatní hádají v chatu. */
export default function DrawingGame() {
const auth = useAuth();
const login = auth?.login;
const [state, setState] = useState<DrawState | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [word, setWord] = useState<string | null>(null);
const [color, setColor] = useState(COLORS[0]);
const [size, setSize] = useState(SIZES[1]);
const [guess, setGuess] = useState('');
const [secondsLeft, setSecondsLeft] = useState(0);
const canvasRef = useRef<HTMLCanvasElement>(null);
const chatRef = useRef<HTMLDivElement>(null);
const prevPhase = useRef<DrawPhase>('idle');
/** Rozkreslený tah: body čekající na odeslání a poslední odeslaný bod pro navázání. */
const pending = useRef<{ points: [number, number][], start: boolean } | null>(null);
const drawingRef = useRef(false);
const icon = useGameIcon('drawing');
const send = useCallback((event: string, extra?: object) => {
setError(null);
socket.emit(event, { token: getToken(), ...extra });
}, []);
const ctx = () => canvasRef.current?.getContext('2d') ?? null;
useEffect(() => {
const onState = (s: DrawState) => setState(s);
const onError = (msg: string) => setError(msg);
const onOpen = () => setOpen(true);
const sync = () => socket.emit('draw:sync');
socket.on('draw:state', onState);
socket.on('draw:error', onError);
socket.on('connect', sync);
window.addEventListener(OPEN_EVENT, onOpen);
sync();
return () => {
socket.off('draw:state', onState);
socket.off('draw:error', onError);
socket.off('connect', sync);
window.removeEventListener(OPEN_EVENT, onOpen);
};
}, []);
const isDrawer = !!login && state?.phase === 'drawing' && state.drawer === login;
const isDrawerRef = useRef(isDrawer);
isDrawerRef.current = isDrawer;
// Kresba a slovo chodí jen klientům s otevřeným oknem
useEffect(() => {
if (!open) return;
const watch = () => socket.emit('draw:watch', { token: getToken() });
const onStrokes = (strokes: Stroke[]) => {
const c = ctx();
if (!c) return;
clearCanvas(c);
strokes.forEach(s => drawStroke(c, s));
};
// Vlastní tahy kreslíř vykresluje hned lokálně
const onStroke = (s: Stroke) => {
const c = ctx();
if (c && !isDrawerRef.current) drawStroke(c, s);
};
const onClear = () => {
const c = ctx();
if (c) clearCanvas(c);
};
const onWord = (w: string) => setWord(w);
socket.on('draw:strokes', onStrokes);
socket.on('draw:stroke', onStroke);
socket.on('draw:clear', onClear);
socket.on('draw:word', onWord);
socket.on('connect', watch);
watch();
return () => {
socket.off('draw:strokes', onStrokes);
socket.off('draw:stroke', onStroke);
socket.off('draw:clear', onClear);
socket.off('draw:word', onWord);
socket.off('connect', watch);
socket.emit('draw:unwatch');
};
}, [open]);
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
const phase = state?.phase ?? 'idle';
const inGame = phase === 'drawing' || phase === 'reveal';
useEffect(() => {
if (prevPhase.current === 'lobby' && phase === 'drawing' && isPlayer) {
setOpen(true);
}
prevPhase.current = phase;
}, [phase, isPlayer]);
// Slovo patří jen k aktuálnímu kolu
useEffect(() => {
if (!isDrawer) setWord(null);
}, [isDrawer, state?.round]);
// Plátno se po vykreslení (nové kolo, otevření okna) vyplní bílou a znovu vyžádá kresba
useEffect(() => {
if (open && inGame) {
const c = ctx();
if (c) clearCanvas(c);
socket.emit('draw:watch', { token: getToken() });
}
}, [open, inGame]);
// Odpočet kola počítaný lokálně od posledního stavu ze serveru
useEffect(() => {
if (phase !== 'drawing' || state?.remainingMs === undefined) {
setSecondsLeft(0);
return;
}
const endsAt = Date.now() + state.remainingMs;
const update = () => setSecondsLeft(Math.max(0, Math.ceil((endsAt - Date.now()) / 1000)));
update();
const id = window.setInterval(update, 250);
return () => window.clearInterval(id);
}, [phase, state?.remainingMs]);
useEffect(() => {
chatRef.current?.scrollTo({ top: chatRef.current.scrollHeight });
}, [state?.messages.length]);
// Průběžné odesílání rozkresleného tahu
useEffect(() => {
if (!isDrawer) return;
const id = window.setInterval(() => {
const p = pending.current;
if (!p || p.points.length === 0 || (p.points.length === 1 && !p.start)) return;
send('draw:stroke', { color, size, points: p.points, start: p.start });
// Další dávka začne posledním bodem, aby čára navazovala
pending.current = drawingRef.current ? { points: [p.points[p.points.length - 1]], start: false } : null;
}, FLUSH_MS);
return () => window.clearInterval(id);
}, [isDrawer, color, size, send]);
const toCoords = (e: React.PointerEvent<HTMLCanvasElement>): [number, number] => {
const rect = e.currentTarget.getBoundingClientRect();
const x = Math.round(((e.clientX - rect.left) / rect.width) * COORD_MAX);
const y = Math.round(((e.clientY - rect.top) / rect.height) * COORD_MAX);
return [Math.min(COORD_MAX, Math.max(0, x)), Math.min(COORD_MAX, Math.max(0, y))];
};
const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawer) return;
e.currentTarget.setPointerCapture(e.pointerId);
drawingRef.current = true;
const point = toCoords(e);
pending.current = { points: [point], start: true };
const c = ctx();
if (c) drawStroke(c, { color, size, points: [point], start: true });
};
const onPointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawer || !drawingRef.current || !pending.current) return;
const point = toCoords(e);
const prev = pending.current.points[pending.current.points.length - 1];
if (prev && prev[0] === point[0] && prev[1] === point[1]) return;
pending.current.points.push(point);
const c = ctx();
if (c && prev) drawStroke(c, { color, size, points: [prev, point], start: false });
};
const onPointerUp = () => {
drawingRef.current = false;
};
const submitGuess = (e: React.FormEvent) => {
e.preventDefault();
if (!guess.trim()) return;
send('draw:guess', { text: guess });
setGuess('');
};
if (!login) return null;
const me = state?.players.find(p => p.login === login);
const canChat = isPlayer && !isDrawer && !(phase === 'drawing' && me?.guessed);
const ranked = [...(state?.players ?? [])].sort((a, b) => b.score - a.score);
const showInvite = !open && phase === 'lobby' && !isPlayer;
const showReturn = !open && isPlayer && inGame;
const renderTopBar = () => {
if (phase === 'reveal' || phase === 'finished') {
return <div className="draw-topbar">Bylo to: <strong>{state?.answer}</strong></div>;
}
return (
<div className="draw-topbar">
<span>Kolo {state?.round}/{state?.totalRounds} · kreslí <strong>{state?.drawer}</strong></span>
{isDrawer
? <span className="draw-word">Nakreslete: <strong>{word ?? '…'}</strong></span>
: <span className="draw-hint">{state?.hint}</span>}
<span className={secondsLeft <= 10 ? 'draw-timer urgent' : 'draw-timer'}>⏱ {secondsLeft} s</span>
</div>
);
};
const renderGame = () => (
<>
{renderTopBar()}
<div className="draw-layout">
<div className="draw-canvas-col">
<canvas
ref={canvasRef}
className={`draw-canvas ${isDrawer ? 'active' : ''}`}
width={CANVAS_W}
height={CANVAS_H}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
/>
{isDrawer && (
<div className="draw-tools">
{COLORS.map(c => (
<button key={c} className={`draw-color ${c === color ? 'selected' : ''}`}
style={{ background: c }} onClick={() => setColor(c)} aria-label={`Barva ${c}`} />
))}
<span className="draw-sep" />
{SIZES.map(s => (
<button key={s} className={`draw-size ${s === size ? 'selected' : ''}`} onClick={() => setSize(s)}
aria-label={`Tloušťka ${s}`}>
<span style={{ width: s, height: s }} />
</button>
))}
<span className="draw-sep" />
<Button size="sm" variant="outline-secondary" onClick={() => send('draw:clear')}>Smazat</Button>
</div>
)}
</div>
<div className="draw-side">
<ul className="draw-scores">
{ranked.map(p => (
<li key={p.login} className={p.guessed && phase === 'drawing' ? 'guessed' : undefined}>
<span>{p.login === state?.drawer && phase === 'drawing' ? '✏️ ' : ''}{p.login}{p.login === login && ' (vy)'}</span>
<strong>{p.score}</strong>
</li>
))}
</ul>
{renderChat()}
</div>
</div>
</>
);
const renderChat = () => (
<>
<div className="draw-chat" ref={chatRef}>
{state?.messages.map((m, i) => (
<div key={i} className={m.system ? 'system' : undefined}>
{m.login && <strong>{m.login}: </strong>}{m.text}
</div>
))}
</div>
<Form onSubmit={submitGuess}>
<Form.Control
value={guess}
onChange={e => setGuess(e.target.value)}
placeholder={isDrawer ? 'Kreslíte…' : canChat ? 'Váš tip…' : 'Uhodli jste ✔'}
disabled={!canChat}
maxLength={100}
onKeyDown={e => e.stopPropagation()}
/>
</Form>
</>
);
const renderBody = () => {
switch (phase) {
case 'idle':
return (
<p>
Jeden kreslí jídlo z dnešních menu, ostatní hádají v chatu. Stačí trefit hlavní slovo
(např. „guláš"), diakritika nevadí. Čím dřív uhodnete, tím víc bodů; kreslíř dostává body
za každého, kdo uhodl. Každý kreslí jednou.
</p>
);
case 'lobby':
return (
<>
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
<ul className="draw-players">
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
</ul>
{renderChat()}
</>
);
case 'drawing':
case 'reveal':
return renderGame();
case 'finished':
return (
<>
<Table size="sm">
<thead><tr><th>#</th><th>Hráč</th><th>Body</th></tr></thead>
<tbody>
{ranked.map((p, i) => (
<tr key={p.login}>
<td>{i < 3 ? MEDALS[i] : i + 1}</td>
<td>{p.login}{p.login === login && ' (vy)'}</td>
<td>{p.score}</td>
</tr>
))}
</tbody>
</Table>
</>
);
}
};
const renderFooter = () => {
const buttons = [];
if (phase === 'idle' || phase === 'finished') {
buttons.push(<Button key="new" onClick={() => send('draw:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
}
if (phase === 'lobby') {
if (!isPlayer) {
buttons.push(<Button key="join" onClick={() => send('draw:join')}>Připojit se</Button>);
} else {
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('draw:leave')}>
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
</Button>);
if (login === state?.host) {
buttons.push(<Button key="start" onClick={() => send('draw:start')}
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 2)}>Spustit hru</Button>);
}
}
}
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
return buttons;
};
return (
<>
{showInvite && (
<button className="duel-invite draw-invite" onClick={() => setOpen(true)}>
{icon} <strong>{state?.host}</strong> zve na kreslení — připojit se
</button>
)}
{showReturn && (
<button className="duel-invite draw-invite" onClick={() => setOpen(true)}>
{icon} Kreslení běží — zpět do hry
</button>
)}
<Modal show={open} onHide={() => setOpen(false)} size="xl" fullscreen="sm-down" className="drawing-game-modal">
<Modal.Header closeButton>
<Modal.Title><h2>{icon} Kreslení</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{renderBody()}
{error && <div className="text-danger mt-2">{error}</div>}
</Modal.Body>
<Modal.Footer>{renderFooter()}</Modal.Footer>
</Modal>
</>
);
}
-76
View File
@@ -1,76 +0,0 @@
// Kulatý portrét postavy vedle jména strávníka s více jídly (styl Umamusume)
.eater-badge {
width: 30px;
height: 30px;
object-fit: cover;
flex: none;
margin-right: 6px;
vertical-align: middle;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 2px #ff6fa3;
cursor: help;
transition: transform 0.2s ease;
&:hover {
transform: scale(2);
position: relative;
z-index: 5;
}
}
// Styl My Little Pony: cutie mark s emoji v kulatém odznaku
.eater-badge-mlp {
display: inline-grid;
place-items: center;
font-size: 17px;
line-height: 1;
background: radial-gradient(circle at 35% 30%, #fff, #f6ecff);
box-shadow: 0 0 0 2px #8e4fd1;
}
// Styl Hell Girl: emoji v černém odznaku s rudým lemem (rudá nit)
.eater-badge-hellgirl {
display: inline-grid;
place-items: center;
font-size: 16px;
line-height: 1;
border-color: #1c1012;
background: radial-gradient(circle at 50% 60%, #3a0d16, #12070a);
box-shadow: 0 0 0 2px #c8102e, 0 0 6px rgb(200 16 46 / 0.5);
}
// Styl K-ON!: emoji na krémovém „trsátku" s lemem v barvě kytary Gitah (cherry sunburst)
.eater-badge-kon {
display: inline-grid;
place-items: center;
font-size: 16px;
line-height: 1;
border-radius: 50% 50% 50% 50% / 42% 42% 58% 58%;
background: radial-gradient(circle at 40% 35%, #fffaf0, #fbe6c8);
box-shadow: 0 0 0 2px #d43f62, 0 2px 0 3px #27315f;
}
// Styl Bocchi the Rock!: emoji na žluté samolepce s černou linkou a růžovým stínem
.eater-badge-bocchi {
display: inline-grid;
place-items: center;
font-size: 15px;
line-height: 1;
border-radius: 6px;
border-color: #1d1a22;
background: #ffe066;
box-shadow: 2px 2px 0 #e0457b;
transform: rotate(-4deg);
&:hover {
transform: rotate(0deg) scale(2);
}
}
// Odznak a jméno drží na jednom řádku (úzký sloupec „Člen" na stránce objednávek)
.user-info:has(.eater-badge) {
display: inline-flex;
align-items: center;
white-space: nowrap;
}
-85
View File
@@ -1,85 +0,0 @@
import { useEffect, useState } from "react";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
import { StyleTheme, useSettings } from "../context/settings";
import { UmaCharacter, loadUmaCharacters } from "../utils/umaCharacters";
import "./EaterBadge.scss";
/**
* Úrovně „jedlíka" podle počtu jídel: Special Week sní hodně, Oguri Cap ještě víc.
* Seřazeno od nejvyšší úrovně.
*/
const LEVELS = [
{ minItems: 3, character: 'Oguri Cap', title: 'Úroveň Oguri Cap — bezedný žaludek' },
{ minItems: 2, character: 'Special Week', title: 'Úroveň Special Week — pořádný apetit' },
];
/** Úrovně ve stylu My Little Pony — místo portrétu cutie mark (emoji). Seřazeno od nejvyšší úrovně. */
const MLP_LEVELS = [
{ minItems: 3, emoji: '🧁', title: 'Úroveň Pinkie Pie — cupcaků není nikdy dost' },
{ minItems: 2, emoji: '🍎', title: 'Úroveň Applejack — poctivý apetit' },
];
/** Úrovně ve stylu Hell Girl — emoji v černém odznaku s rudým lemem. Seřazeno od nejvyšší úrovně. */
const HG_LEVELS = [
{ minItems: 3, emoji: '🔥', title: 'Úroveň Wanyūdō — hlad jako pekelný oheň' },
{ minItems: 2, emoji: '🥀', title: 'Úroveň Ai Enma — tichý, ale nenasytný apetit' },
];
/** Úrovně ve stylu K-ON! — emoji v odznaku jako trsátko. Seřazeno od nejvyšší úrovně. */
const KON_LEVELS = [
{ minItems: 3, emoji: '🍰', title: 'Úroveň Yui — sní všechno a nepřibere ani gram' },
{ minItems: 2, emoji: '☕', title: 'Úroveň Tea Time — k obědu ještě čaj a dortík' },
];
/** Úrovně ve stylu Bocchi the Rock! — emoji na samolepce jako z flyeru live house. Seřazeno od nejvyšší úrovně. */
const BOCCHI_LEVELS = [
{ minItems: 3, emoji: '🌿', title: 'Úroveň Ryo — když dojdou peníze, sní i trávu u cesty' },
{ minItems: 2, emoji: '🥭', title: 'Úroveň Bocchi — dvojitý oběd v klidu v krabici od manga' },
];
/** Úrovně podle stylu pro odznaky s emoji. */
const EMOJI_LEVELS: Partial<Record<StyleTheme, typeof MLP_LEVELS>> = {
mlp: MLP_LEVELS,
hellgirl: HG_LEVELS,
kon: KON_LEVELS,
bocchi: BOCCHI_LEVELS,
};
type Props = {
/** Počet jídel/položek, které si strávník vybral či objednal */
items: number,
};
/** Odznak s postavou u strávníků s více jídly — jen ve stylech Umamusume, My Little Pony, Hell Girl, K-ON! a Bocchi the Rock!. */
export default function EaterBadge({ items }: Readonly<Props>) {
const styleTheme = useSettings()?.styleTheme;
const isUma = styleTheme === 'umamusume';
const [characters, setCharacters] = useState<UmaCharacter[]>([]);
useEffect(() => {
if (isUma) loadUmaCharacters().then(setCharacters);
}, [isUma]);
const emojiLevels = styleTheme && EMOJI_LEVELS[styleTheme];
if (emojiLevels) {
const emojiLevel = emojiLevels.find(l => items >= l.minItems);
if (!emojiLevel) return null;
return (
<OverlayTrigger placement="top" overlay={<Tooltip>{emojiLevel.title} ({items} jídla)</Tooltip>}>
<span className={`eater-badge eater-badge-${styleTheme}`} role="img" aria-label={emojiLevel.title}>{emojiLevel.emoji}</span>
</OverlayTrigger>
);
}
const level = LEVELS.find(l => items >= l.minItems);
const character = level && characters.find(c => c.name === level.character);
// Malý odznak potřebuje obličej — portrét (icon), případně kulatý obrázek postavy
const src = character?.icon ?? (character?.round ? character.images[0] : undefined);
if (!isUma || !level || !src) return null;
return (
<OverlayTrigger placement="top" overlay={<Tooltip>{level.title} ({items} jídla)</Tooltip>}>
<img src={src} alt={level.character} className="eater-badge" />
</OverlayTrigger>
);
}
+10 -10
View File
@@ -1,12 +1,12 @@
import { Navbar } from "react-bootstrap";
export default function Footer() { export default function Footer() {
return ( return <Navbar className="text-light" variant='dark' expand="lg" style={{
<footer className="footer"> display: "flex",
<span> justifyContent: "center",
Zdroj. kódy dostupné na{' '} marginTop: "auto", // Pushne footer na spodek
<a href="https://gitea.melancholik.eu/mates/Luncher" target="_blank" rel="noopener noreferrer"> flexShrink: 0 // Zabrání zmenšování při malém obsahu
Gitea }}>
</a> <span>🄯 Žádná práva nevyhrazena. TODO a zdrojové kódy dostupné <a href="https://gitea.melancholik.eu/mates/Luncher">zde</a>.</span>
</span> </Navbar >
</footer>
);
} }
-31
View File
@@ -1,31 +0,0 @@
// Bez zoomu dvojitým ťuknutím, jinak by iOS sérii ťuknutí na logo spolkl
.navbar-brand {
touch-action: manipulation;
}
// Skryté pole pro kód minihry — musí jít zaměřit (kvůli softwarové klávesnici), proto ne display: none.
// Písmo aspoň 16px, jinak iOS při zaměření přiblíží stránku.
.minigame-secret-input {
position: fixed;
top: 0;
left: 0;
width: 1px;
height: 1px;
padding: 0;
border: 0;
font-size: 16px;
opacity: 0;
pointer-events: none;
}
// Nenápadné potvrzení, že logo přijalo sérii kliknutí a čeká na tajné slovo
.navbar-brand.duel-armed {
display: inline-block;
animation: duel-wiggle 0.35s ease-in-out 2;
}
@keyframes duel-wiggle {
0%, 100% { transform: rotate(0); }
25% { transform: rotate(-4deg); }
75% { transform: rotate(4deg); }
}
+38 -158
View File
@@ -1,66 +1,41 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap"; import { Navbar, Nav, NavDropdown } from "react-bootstrap";
import { useAuth } from "../context/auth"; import { useAuth } from "../context/auth";
import SettingsModal from "./modals/SettingsModal"; import SettingsModal from "./modals/SettingsModal";
import DrawingGame from "./DrawingGame"; import { useSettings } from "../context/settings";
import "./Header.scss"; import FeaturesVotingModal from "./modals/FeaturesVotingModal";
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 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 { useNavigate } from "react-router";
import { STATS_URL, OBJEDNANI_URL, NAVRHY_URL } from "../AppRoutes"; import { STATS_URL } from "../AppRoutes";
import { LunchChoices, getChangelogs } from "../../../types"; import { FeatureRequest, getVotes, updateVote } 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 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 [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false); const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false); const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
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) return; if (auth?.login) {
const lastSeen = localStorage.getItem(LAST_SEEN_CHANGELOG_KEY) ?? undefined; getVotes().then(response => {
getChangelogs({ query: lastSeen ? { since: lastSeen } : {} }).then(response => { setFeatureVotes(response.data);
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);
} }
@@ -69,26 +44,6 @@ export default function Header({ choices, dayIndex }: Props) {
setRefreshMenuModalOpen(false); 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) {
@@ -99,19 +54,19 @@ export default function Header({ choices, dayIndex }: Props) {
return n !== Infinity && String(n) === str && n >= 0; return n !== Infinity && String(n) === str && n >= 0;
} }
const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean, themePreference?: ThemePreference) => { const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => {
if (bankAccountNumber) { if (bankAccountNumber) {
try { try {
// Validace kódu banky // Validace kódu banky
if (!bankAccountNumber.includes('/')) { if (bankAccountNumber.indexOf('/') < 0) {
throw new Error("Číslo účtu neobsahuje lomítko/kód banky") throw 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 new Error("Kód banky musí být 4 číslice") throw Error("Kód banky musí být 4 číslice")
} }
if (!isValidInteger(split[1])) { if (!isValidInteger(split[1])) {
throw new Error("Kód banky není číslo") throw Error("Kód banky není číslo")
} }
// Validace čísla a předčíslí // Validace čísla a předčíslí
@@ -121,7 +76,7 @@ export default function Header({ choices, dayIndex }: Props) {
cislo = cislo.replace('-', ''); cislo = cislo.replace('-', '');
} }
if (!isValidInteger(cislo)) { if (!isValidInteger(cislo)) {
throw new Error("Předčíslí nebo číslo účtu neobsahuje pouze číslice") throw 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');
@@ -134,7 +89,7 @@ export default function Header({ choices, dayIndex }: Props) {
sum += Number.parseInt(char) * weight sum += Number.parseInt(char) * weight
} }
if (sum % 11 !== 0) { if (sum % 11 !== 0) {
throw new Error("Číslo účtu je neplatné") throw Error("Číslo účtu je neplatné")
} }
} catch (e: any) { } catch (e: any) {
alert(e.message) alert(e.message)
@@ -144,114 +99,39 @@ export default function Header({ choices, dayIndex }: Props) {
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 updateVote({ body: { option, active } });
const votes = [...featureVotes || []];
if (active) {
votes.push(option);
} else {
votes.splice(votes.indexOf(option), 1);
}
setFeatureVotes(votes);
}
return <Navbar variant='dark' expand="lg"> return <Navbar variant='dark' expand="lg">
<Navbar.Brand href="/" onClick={minigameSecret.onLogoClick} className={minigameSecret.armed ? "duel-armed" : undefined}>Luncher</Navbar.Brand> <Navbar.Brand href="/">Luncher</Navbar.Brand>
<input ref={minigameSecret.inputRef} className="minigame-secret-input" tabIndex={-1} aria-hidden="true"
autoComplete="off" autoCorrect="off" autoCapitalize="none" spellCheck={false} />
<Navbar.Toggle aria-controls="basic-navbar-nav" /> <Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav"> <Navbar.Collapse id="basic-navbar-nav">
<Nav className="nav"> <Nav className="nav">
<button
className="theme-toggle"
onClick={toggleTheme}
title={effectiveDark ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
aria-label="Přepnout světlý/tmavý režim"
>
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
</button>
{isSimpleStyle && <HuePicker
accentHue={settings?.accentHue ?? 142}
isDark={effectiveDark}
onChange={hue => settings?.setAccentHue(hue)}
/>}
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown"> <NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item> <NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item> <NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(NAVRHY_URL)}>Návrhy na vylepšení</NavDropdown.Item> <NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item> <NavDropdown.Item onClick={() => 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(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>
<DrawingGame />
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} /> <SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} /> <RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} /> <PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
{choices && settings?.bankAccount && settings?.holderName && (
<GenerateQrModal
isOpen={qrModalOpen}
onClose={closeQrModal}
choices={choices}
bankAccount={settings.bankAccount}
bankAccountHolder={settings.holderName}
/>
)}
{IS_DEV && (
<>
<GenerateMockDataModal
isOpen={generateMockModalOpen}
onClose={() => setGenerateMockModalOpen(false)}
currentDayIndex={dayIndex}
/>
<ClearMockDataModal
isOpen={clearMockModalOpen}
onClose={() => setClearMockModalOpen(false)}
currentDayIndex={dayIndex}
/>
</>
)}
<Modal show={changelogModalOpen} onHide={() => setChangelogModalOpen(false)} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Novinky</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{Object.keys(changelogEntries).sort((a, b) => b.localeCompare(a)).map(date => (
<div key={date}>
<strong>{formatDateString(date)}</strong>
<ul>
{changelogEntries[date].map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
))}
{Object.keys(changelogEntries).length === 0 && (
<p>Žádné novinky.</p>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setChangelogModalOpen(false)}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
</Navbar> </Navbar>
} }
-24
View File
@@ -1,24 +0,0 @@
// Plamen stylu Hell Girl — hoří na černé přes obsah dialogu (.modal-content je relative a ve stylu
// Hell Girl ořezává přetečení), pak černá odezní a odhalí obsah.
.hg-burn {
position: absolute;
inset: 0;
z-index: 10;
background: #020204; // barva pozadí GIFu, aby kolem plamene nebyl vidět obdélník
pointer-events: none;
animation: hg-burn-reveal 1.5s ease-in forwards;
}
.hg-burn-flame {
position: absolute;
left: 50%;
// Dlouhý dialog může přesahovat obrazovku — plamen držet ve viditelné části
top: min(50%, 45vh);
height: min(85%, 60vh);
transform: translate(-50%, -50%);
}
@keyframes hg-burn-reveal {
0%, 50% { opacity: 1; }
100% { opacity: 0; }
}
-81
View File
@@ -1,81 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { createSymbolMask, loadHellGirlImages } from "../utils/hellGirlImages";
import "./HellGirlFlames.scss";
/** Plamen vzplane a rozpadne se v jiskry za ~1,2 s, pak GIF drží černý snímek — mezitím se obsah vynoří z černé. */
const BURN_MS = 1500;
/** `target` = obsah dialogu, ve kterém se hoří. */
type Burn = { src: string, target: HTMLElement };
/**
* Doplňky stylu Hell Girl: při otevření dialogu jeho obsah zčerná, uprostřed jednou
* vzplane plamen („Ippen, shinde miru?") a obsah se pak z černé vynoří. Dále symbol ohně ze znělky
* u titulku dne (nastaví CSS proměnnou --hg-symbol, dokud se nenačte, zůstávají v titulku původní květy).
*/
const HellGirlFlames: React.FC = () => {
const [burning, setBurning] = useState<Burn>();
const flameBlobRef = useRef<Blob | undefined>(undefined);
const burn = (target: HTMLElement) => {
const blob = flameBlobRef.current;
if (!blob || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
// Pokaždé nová URL — jinak by prohlížeč GIF (bez opakování) znovu nepřehrál
setBurning({ src: URL.createObjectURL(blob), target });
};
useEffect(() => {
if (!burning) return;
const timeout = setTimeout(() => setBurning(undefined), BURN_MS);
return () => {
clearTimeout(timeout);
URL.revokeObjectURL(burning.src);
};
}, [burning]);
useEffect(() => {
let cancelled = false;
const root = document.documentElement;
loadHellGirlImages().then(images => {
if (!images || cancelled) return;
fetch(images.flame)
.then(r => r.ok ? r.blob() : undefined)
.then(blob => { flameBlobRef.current = blob; })
.catch(() => { /* bez plamenů */ });
createSymbolMask(images.symbol).then(mask => {
if (!mask || cancelled) return;
root.style.setProperty('--hg-symbol', `url(${mask})`);
root.setAttribute('data-hg-symbol', '');
});
});
return () => {
cancelled = true;
root.style.removeProperty('--hg-symbol');
root.removeAttribute('data-hg-symbol');
};
}, []);
// Dialogy (react-bootstrap) se vkládají přes portál přímo do <body>
useEffect(() => {
const observer = new MutationObserver(mutations => {
const modalContent = mutations
.flatMap(m => Array.from(m.addedNodes))
.map(node => node instanceof HTMLElement ? node.querySelector<HTMLElement>('.modal-content') : null)
.find(element => !!element);
if (modalContent) burn(modalContent);
});
observer.observe(document.body, { childList: true });
return () => observer.disconnect();
}, []);
if (!burning) return null;
return createPortal(
<div className="hg-burn" aria-hidden="true">
<img className="hg-burn-flame" src={burning.src} alt="" />
</div>,
burning.target,
);
};
export default HellGirlFlames;
-141
View File
@@ -1,141 +0,0 @@
.hue-picker-dropdown {
display: flex;
align-items: center;
.dropdown-toggle {
background: transparent !important;
border: none !important;
color: var(--luncher-navbar-text) !important;
padding: 8px 12px;
font-size: 1.1rem;
display: flex;
align-items: center;
cursor: pointer;
border-radius: var(--luncher-radius-sm);
transition: var(--luncher-transition);
&::after {
display: none;
}
&:hover {
background: rgba(255, 255, 255, 0.1) !important;
transform: scale(1.1);
}
&:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3) !important;
}
}
}
.hue-picker-panel {
padding: 0 !important;
min-width: 240px;
.hue-picker-inner {
padding: 14px 16px;
}
.hue-picker-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--luncher-text-secondary);
margin-bottom: 12px;
}
}
.hue-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 12px;
border-radius: 6px;
background: linear-gradient(
to right,
hsl(0 70% 50%), hsl(30 70% 50%), hsl(60 70% 50%), hsl(90 70% 50%),
hsl(120 70% 50%), hsl(150 70% 50%), hsl(180 70% 50%), hsl(210 70% 50%),
hsl(240 70% 50%), hsl(270 70% 50%), hsl(300 70% 50%), hsl(330 70% 50%), hsl(360 70% 50%)
);
outline: none;
cursor: pointer;
margin-bottom: 14px;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: white;
border: 2px solid rgba(0, 0, 0, 0.25);
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
transition: transform 0.15s ease;
&:hover {
transform: scale(1.15);
}
}
&::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: white;
border: 2px solid rgba(0, 0, 0, 0.25);
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
}
.hue-presets {
display: flex;
gap: 8px;
margin-bottom: 14px;
.hue-swatch {
width: 26px;
height: 26px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
transition: transform 0.15s ease, border-color 0.15s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
&:hover {
transform: scale(1.15);
}
&.active {
border-color: var(--luncher-text);
transform: scale(1.1);
}
}
}
.hue-preview {
display: flex;
align-items: center;
gap: 10px;
padding-top: 4px;
border-top: 1px solid var(--luncher-border);
.hue-preview-chip {
width: 32px;
height: 32px;
border-radius: var(--luncher-radius-sm);
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: background 0.2s ease;
}
span {
font-size: 0.8rem;
color: var(--luncher-text-secondary);
}
}
-71
View File
@@ -1,71 +0,0 @@
import { Dropdown } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPalette } from '@fortawesome/free-solid-svg-icons';
import './HuePicker.scss';
const PRESETS = [
{ hue: 142, label: 'Zelená' },
{ hue: 217, label: 'Modrá' },
{ hue: 263, label: 'Fialová' },
{ hue: 0, label: 'Červená' },
{ hue: 28, label: 'Oranžová' },
{ hue: 340, label: 'Růžová' },
];
type Props = {
accentHue: number;
isDark: boolean;
onChange: (hue: number) => void;
};
function swatchColor(hue: number, isDark: boolean): string {
return `hsl(${hue} 70% ${isDark ? 55 : 38}%)`;
}
export default function HuePicker({ accentHue, isDark, onChange }: Props) {
return (
<Dropdown align="end" autoClose="outside" className="hue-picker-dropdown">
<Dropdown.Toggle
as="button"
className="theme-toggle"
aria-label="Barva zvýraznění"
title="Barva zvýraznění"
>
<FontAwesomeIcon icon={faPalette} />
</Dropdown.Toggle>
<Dropdown.Menu className="hue-picker-panel">
<div className="hue-picker-inner">
<div className="hue-picker-label">Barva zvýraznění</div>
<input
type="range"
min={0}
max={360}
value={accentHue}
onChange={e => onChange(parseInt(e.target.value, 10))}
className="hue-slider"
aria-label="Odstín barvy zvýraznění"
/>
<div className="hue-presets">
{PRESETS.map(p => (
<button
key={p.hue}
className={`hue-swatch${accentHue === p.hue ? ' active' : ''}`}
style={{ background: swatchColor(p.hue, isDark) }}
title={p.label}
onClick={() => onChange(p.hue)}
aria-label={p.label}
/>
))}
</div>
<div className="hue-preview">
<div
className="hue-preview-chip"
style={{ background: swatchColor(accentHue, isDark) }}
/>
<span>Aktuální barva zvýraznění</span>
</div>
</div>
</Dropdown.Menu>
</Dropdown>
);
}
+5 -7
View File
@@ -9,13 +9,11 @@ type Props = {
} }
function Loader(props: Readonly<Props>) { function Loader(props: Readonly<Props>) {
return ( return <div className='loader'>
<div className='loader'> <h1>{props.title ?? 'Prosím čekejte...'}</h1>
<FontAwesomeIcon icon={props.icon} className={`loader-icon ${props.animation ?? ''}`} /> <FontAwesomeIcon icon={props.icon} className={`loader-icon mb-3 ` + (props.animation ?? '')} />
<h2 className='loader-title'>{props.title ?? 'Prosím čekejte...'}</h2> <p>{props.description}</p>
<p className='loader-description'>{props.description}</p> </div>
</div>
);
} }
export default Loader; export default Loader;
-117
View File
@@ -1,117 +0,0 @@
// Kompaktní výběr z nabídky podniku v tabulce skupinové objednávky
.menu-hint-cell {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 200px;
.select-search-container {
// Barvy knihovny napojené na témata aplikace (jinak bílé pozadí i v tmavém režimu)
--select-search-background: var(--luncher-bg-card);
--select-search-border: var(--luncher-border);
--select-search-text: var(--luncher-text);
--select-search-subtle-text: var(--luncher-text-muted);
--select-search-highlight: var(--luncher-bg-hover);
--select-search-selected: var(--luncher-primary);
--select-search-inverted-text: #fff;
--select-search-font: inherit;
width: 100%;
margin: 0;
position: relative;
.select-search-input {
height: 32px;
padding: 0 12px;
font-size: 0.85rem;
border-width: 1px;
background: var(--luncher-bg);
color: var(--luncher-text);
}
// Seznam přes okraje tabulky/karty (jinak by ho karta ořízla)
.select-search-select {
position: absolute;
z-index: 20;
left: 0;
min-width: 320px;
max-height: 320px;
overflow-y: auto;
}
.select-search-options, .select-search-group ul {
margin: 0;
padding: 0;
list-style: none;
}
.select-search-option {
height: auto;
padding: 6px 12px;
font-size: 0.85rem;
background: transparent;
color: var(--luncher-text);
text-align: left;
}
// Jídlo na limitu kusů — nejde přidat
.select-search-option:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.select-search-group-header {
padding: 4px 12px;
font-size: 0.7rem;
}
}
.menu-hint-items {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 2px;
}
.menu-hint-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 4px 1px 8px;
border-radius: 999px;
background: var(--luncher-bg-hover);
border: 1px solid var(--luncher-border);
font-size: 0.75rem;
line-height: 1.4;
small {
color: var(--luncher-text-muted);
}
button {
border: none;
background: none;
padding: 0 4px;
color: var(--luncher-text-muted);
font-weight: 700;
line-height: 1;
&:hover {
color: var(--luncher-danger);
}
}
}
.menu-hint-price {
cursor: help;
}
.menu-hint-use {
margin-left: 6px;
padding: 0;
border: none;
background: none;
color: var(--luncher-primary);
font-weight: 700;
text-decoration: underline;
}
}
-120
View File
@@ -1,120 +0,0 @@
import { useMemo } from "react";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from "react-select-search";
import { StoreMenuDish } from "../../../types";
import { countNoteItems, matchNoteToMenu, noteItemQuantity, splitNoteItems } from "../utils/menuMatch";
import { useGroupLimits } from "../hooks/useGroupLimits";
import "./MenuHintCell.scss";
type Props = {
/** Celá nabídka podniku (stažená z Bolt Food/Wolt), undefined = načítá se / není */
menu?: StoreMenuDish[],
note?: string,
/** Zadaná částka člena v haléřích */
amount?: number,
canEdit: boolean,
onPick: (dish: StoreMenuDish) => void,
onUsePrice: (priceKc: number) => void,
/** Odebere jeden kus položky poznámky na daném indexu (a odečte její cenu) */
onRemoveItem: (index: number) => void,
};
const formatDish = (d: StoreMenuDish) => d.price != null ? `${d.name} (${d.price} Kč)` : d.name;
/**
* Buňka „Z nabídky" ve skupinové objednávce: výběr položky ze stažené nabídky podniku
* (doplní poznámku i částku) a kontrola ceny rozpoznané z poznámky proti zadané částce.
*/
export default function MenuHintCell({ menu, note, amount, canEdit, onPick, onUsePrice, onRemoveItem }: Readonly<Props>) {
const { maxItemQuantity, maxItems } = useGroupLimits();
const options = useMemo<SelectSearchOption[]>(() => {
if (!menu?.length) return [];
const groups = new Map<string, SelectSearchOption[]>();
menu.forEach((d, index) => {
const category = d.category || 'Ostatní';
if (!groups.has(category)) groups.set(category, []);
// Jídlo, kterého už je v objednávce maximum kusů, nejde přidat znovu
const atLimit = noteItemQuantity(note, d.name, menu) >= maxItemQuantity;
groups.get(category)!.push({ name: atLimit ? `${formatDish(d)} — max ${maxItemQuantity} ks` : formatDish(d), value: String(index), disabled: atLimit });
});
return [...groups.entries()].map(([name, items]) => ({ name, type: 'group', items }));
}, [menu, note, maxItemQuantity]);
const itemLimitReached = useMemo(() => countNoteItems(note, menu ?? []) >= maxItems, [note, menu, maxItems]);
const match = useMemo(() => menu ? matchNoteToMenu(note, menu) : undefined, [menu, note]);
const items = useMemo(() => splitNoteItems(note, menu), [note, menu]);
if (!menu?.length) {
return <small className="text-muted">—</small>;
}
const menuPrice = match?.total;
const differs = menuPrice != null && amount != null && Math.round(menuPrice * 100) !== amount;
const unmatched = match?.parts.filter(p => !p.dish).map(p => p.text) ?? [];
return (
<div className="menu-hint-cell">
{canEdit && itemLimitReached && (
<small className="text-muted">Dosažen limit {maxItems} položek — nejdřív něco odeber (×).</small>
)}
{canEdit && !itemLimitReached && (
<SelectSearch
search
options={options}
placeholder="Přidat z nabídky…"
value=""
onBlur={() => { }}
onFocus={() => { }}
onChange={(value: SelectedOptionValue | SelectedOptionValue[]) => {
const dish = menu[Number(value)];
if (dish) onPick(dish);
}}
/>
)}
{canEdit && items.length > 0 && (
<div className="menu-hint-items">
{items.map((text, i) => {
// Rozpoznaná položka ze stejného rozdělení poznámky — kvůli ceně na čipu
const part = match && match.parts.length === items.length ? match.parts[i] : undefined;
const price = part?.dish?.price;
const quantity = part?.quantity ?? 1;
return (
<span key={`${i}-${text}`} className="menu-hint-item">
{text}{price != null && <small> {quantity > 1 ? `${quantity}× ${price} = ${quantity * price}` : price} Kč</small>}
<button type="button" onClick={() => onRemoveItem(i)} title={`Odebrat 1× ${text}`} aria-label={`Odebrat ${text}`}>×</button>
</span>
);
})}
</div>
)}
{match && (
<OverlayTrigger
placement="top"
overlay={
<Tooltip>
{match.parts.map((p, i) => (
<div key={i}>
{p.dish
? `${p.quantity > 1 ? `${p.quantity}× ` : ''}${formatDish(p.dish)}`
: `„${p.text}" — v nabídce nenalezeno`}
</div>
))}
</Tooltip>
}
>
<small className={`menu-hint-price ${differs ? 'text-warning' : 'text-muted'}`}>
{menuPrice != null
? <>Dle nabídky: <strong>{menuPrice} Kč</strong></>
: <>Nalezeno {match.parts.length - unmatched.length}/{match.parts.length} položek</>}
{differs && canEdit && (
<button type="button" className="menu-hint-use" onClick={() => onUsePrice(menuPrice!)}
title="Nastavit částku podle ceny v nabídce">
použít
</button>
)}
</small>
</OverlayTrigger>
)}
</div>
);
}
-71
View File
@@ -1,71 +0,0 @@
.tracking-progress {
display: flex;
align-items: flex-start;
max-width: 400px;
.tracking-step {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
flex: 1;
min-width: 64px;
.tracking-dot {
position: relative;
z-index: 1;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--luncher-border, #ced4da);
}
.tracking-label {
margin-top: 2px;
font-size: 0.7em;
color: var(--luncher-text-muted, #6c757d);
white-space: nowrap;
}
// Spojnice k předchozímu kroku – vede od středu této tečky doleva ke středu předchozí.
// Tečka má průměr 12px, takže její střed je 6px od levého okraje segmentu.
&:not(:first-child)::before {
content: '';
position: absolute;
top: 5px;
left: calc(6px - 100%);
width: 100%;
height: 2px;
background: var(--luncher-border, #ced4da);
}
&.done {
.tracking-dot {
background: var(--bs-success, #198754);
}
&:not(:first-child)::before {
background: var(--bs-success, #198754);
}
}
&.active .tracking-label {
font-weight: 600;
color: inherit;
}
}
// Pulzování aktivního kroku, dokud sledování běží
&.live .tracking-step.active .tracking-dot {
animation: tracking-pulse 2s ease-in-out infinite;
}
}
@keyframes tracking-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.5);
}
50% {
box-shadow: 0 0 0 5px rgba(25, 135, 84, 0);
}
}
-153
View File
@@ -1,153 +0,0 @@
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
import { TrackingProvider } from '../../../types';
import './OrderProgress.scss';
const STEPS = ['Přijato', 'Příprava', 'Vyzvedávání', 'Na cestě', 'Doručeno'];
/** Lidský název rozvozové služby do tooltipu. */
export const PROVIDER_LABEL: Record<TrackingProvider, string> = {
bolt: 'Bolt Food',
wolt: 'Wolt',
};
/**
* Známé stavy objednávky z Bolt API → index kroku ve stepperu.
* Pozor: waiting_delivery znamená "jídlo čeká v podniku na vyzvednutí",
* nikoli "na cestě" — tu signalizuje až stav kurýra (picked_up apod.).
* Krok „Vyzvedávání" je vyhrazen pro kombinaci „kurýr u podniku + jídlo hotové"
* (viz logika níže), samotný order_state ho neudělí — jinak bychom hlásili
* vyzvedávání i když se ještě peče (chování, na které si Bolt sám občas stěžuje).
*/
const BOLT_STATE_TO_STEP: Record<string, number> = {
created: 0,
pending: 0,
waiting_acceptance: 0,
accepted: 0,
waiting_preparation: 0,
preparing: 1,
waiting_delivery: 1,
ready_for_pickup: 1,
waiting_courier: 1,
waiting_pickup: 1,
picked_up: 3,
in_delivery: 3,
delivering: 3,
heading_to_client: 3,
delivered: 4,
finished: 4,
};
/** Stavy kurýra z Bolt API → index kroku. Kurýr u podniku ještě neznamená "na cestě". */
const BOLT_COURIER_STATE_TO_STEP: Record<string, number> = {
matched: 0,
accepted: 0,
heading_to_provider: 1,
arrived_to_provider: 1,
picked_up: 3,
heading_to_client: 3,
delivering: 3,
arrived_to_client: 3,
delivered: 4,
};
/**
* Známé stavy objednávky z Wolt tracking API → index kroku ve stepperu.
* Pozor: Wolt hlásí stav podniku, ne rozvozu — "ready" (jídlo hotové) zůstává
* i celou dobu, co kurýr objednávku veze. Kroky „Vyzvedávání" a „Na cestě" proto
* neurčuje status, ale odvozený stav kurýra (viz trackingProviders.ts).
*/
const WOLT_STATE_TO_STEP: Record<string, number> = {
received: 0,
acknowledged: 0,
confirmed: 0,
waiting: 0,
production: 1,
preparing: 1,
ready: 1,
picked_up: 3,
transport: 3,
delivering: 3,
delivered: 4,
};
/** Popis odvozeného stavu kurýra u Woltu do tooltipu. */
const WOLT_COURIER_INFO: Record<string, string> = {
assigned: 'kurýr přiřazen',
delivering: 'kurýr veze objednávku',
delivering_other: 'kurýr veze objednávku, ale nejdřív doručuje jinou',
};
/** Stavy, ve kterých už jídlo čeká připravené na kurýra. */
const PICKUP_READY_STATES: Record<TrackingProvider, Set<string>> = {
bolt: new Set(['waiting_delivery', 'ready_for_pickup', 'waiting_courier', 'waiting_pickup']),
wolt: new Set(['ready']),
};
/** Neznámé stavy se mapují heuristicky podle klíčových slov. */
function stepForOrderState(provider: TrackingProvider, state: string): number | 'cancelled' {
const s = state.toLowerCase();
const known = provider === 'wolt' ? WOLT_STATE_TO_STEP : BOLT_STATE_TO_STEP;
if (s in known) return known[s];
if (/cancel|reject|fail/.test(s)) return 'cancelled';
if (/delivered|finished/.test(s)) return 4;
if (/accept|pending|created|received/.test(s)) return 0;
if (/^waiting|prepar|ready|cook|production/.test(s)) return 1;
if (/picked|delivering|heading_to_client|transport/.test(s)) return 3;
return 0;
}
function stepForCourierState(provider: TrackingProvider, state?: string): number {
if (!state) return 0;
const s = state.toLowerCase();
// Wolt má jen odvozené stavy: 'assigned' o kroku nic neříká, 'delivering'
// i 'delivering_other' znamenají, že kurýr naši objednávku už veze
if (provider === 'wolt') return s.startsWith('delivering') ? 3 : 0;
if (s in BOLT_COURIER_STATE_TO_STEP) return BOLT_COURIER_STATE_TO_STEP[s];
if (/picked|client|delivering|transport/.test(s)) return 3;
return 0;
}
interface Props {
/** Rozvozová služba, ze které stav pochází */
provider: TrackingProvider;
/** Raw stav objednávky (Bolt order_state, Wolt status) */
state: string;
/** Raw stav kurýra (Bolt courier.state, Wolt 'assigned') */
courierState?: string;
/** Zda sledování stále běží (skupina má trackingCode) */
tracking: boolean;
}
/** Mini progress stepper se stavem objednávky u rozvozové služby. */
export default function OrderProgress({ provider, state, courierState, tracking }: Props) {
const label = PROVIDER_LABEL[provider] ?? provider;
const orderStep = stepForOrderState(provider, state);
if (orderStep === 'cancelled') {
return <small className="text-danger">Objednávka {label} byla zrušena</small>;
}
// Stav kurýra může krok jen zpřesnit dopředu (např. waiting_delivery + picked_up → Na cestě)
let step = Math.max(orderStep, stepForCourierState(provider, courierState));
// Vyzvedávání = kurýr u podniku a jídlo skutečně hotové. Sám arrived_to_provider
// (bez toho, že by jídlo bylo hotové) nestačí — viz komentář u BOLT_STATE_TO_STEP.
const courierAtProvider = provider === 'wolt'
? !!courierState
: courierState?.toLowerCase() === 'arrived_to_provider';
const foodReadyForPickup = PICKUP_READY_STATES[provider]?.has(state.toLowerCase());
if (step < 3 && courierAtProvider && foodReadyForPickup) step = 2;
const courierInfo = provider === 'wolt'
? WOLT_COURIER_INFO[courierState ?? ''] ?? 'kurýr přiřazen'
: `kurýr: ${courierState}`;
const rawInfo = courierState ? `${state} / ${courierInfo}` : state;
return (
<OverlayTrigger overlay={<Tooltip>Stav z {label}: {rawInfo}</Tooltip>}>
<div className={`tracking-progress${tracking && step < 4 ? ' live' : ''}`}>
{STEPS.map((stepLabel, i) => (
<div key={stepLabel} className={`tracking-step${i <= step ? ' done' : ''}${i === step ? ' active' : ''}`}>
<div className="tracking-dot" />
<div className="tracking-label">{stepLabel}</div>
</div>
))}
</div>
</OverlayTrigger>
);
}
-102
View File
@@ -1,102 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Modal } from 'react-bootstrap';
import { PendingQr, dismissQr } from '../../../types';
import { formatDateString } from '../Utils';
import ConfirmModal from './modals/ConfirmModal';
type Props = {
pendingQrs?: PendingQr[];
login?: string;
// Zavolá se po úspěšném potvrzení platby, aby si rodič mohl znovu načíst data
onDismissed?: () => void | Promise<void>;
};
// Sekce "Nevyřízené platby" – zobrazí QR kódy neuhrazených plateb přihlášeného uživatele
// včetně tlačítka "Zaplatil jsem" a potvrzovacího dialogu. Sdíleno hlavní stránkou i stránkou objednávek.
// Při příchodu nových nevyřízených plateb se navíc automaticky otevře modální dialog,
// aby si uživatel QR kódů určitě všiml (často si jich nevšimnou, protože sekce je dole na stránce).
export default function PendingPayments({ pendingQrs, login, onDismissed }: Readonly<Props>) {
const [dismissQrId, setDismissQrId] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
// ID QR kódů, pro které už byl v rámci tohoto načtení stránky automaticky zobrazen
// modální dialog. Drží se jen v paměti (ne v sessionStorage), takže se při každém
// ručním přenačtení stránky vynuluje a dialog se znovu otevře, dokud uživatel platby
// neuhradí. Zároveň se nepřekrývá při pouhém obnovení dat či příchodu už zobrazeného QR.
const autoShownQrIds = useRef<Set<string>>(new Set());
const qrIdsKey = (pendingQrs ?? []).map(qr => qr.id).join(',');
// Automaticky otevřeme modální dialog, jakmile přijdou nové (dosud nezobrazené) platby.
useEffect(() => {
const ids = (pendingQrs ?? []).map(qr => qr.id);
if (ids.length === 0) return;
const unseen = ids.filter(id => !autoShownQrIds.current.has(id));
if (unseen.length > 0) {
setModalOpen(true);
unseen.forEach(id => autoShownQrIds.current.add(id));
}
}, [qrIdsKey, pendingQrs]);
if (!pendingQrs || pendingQrs.length === 0) return null;
// Vykreslení jednoho QR kódu i s tlačítkem "Zaplatil jsem" – sdíleno sekcí i modálem.
const renderQr = (qr: PendingQr) => (
<div key={qr.id} className='qr-code mb-3'>
<p>
<strong>{formatDateString(qr.date)}</strong> — {qr.creator} ({qr.totalPrice / 100} Kč)
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
</p>
<img src={`/api/qr?login=${login}&id=${qr.id}`} alt='QR kód' />
<div className='mt-2'>
<Button variant="success" onClick={() => setDismissQrId(qr.id)}>
Zaplatil jsem
</Button>
</div>
</div>
);
return (
<>
<div className='pizza-section fade-in mt-4'>
<h3>Nevyřízené platby</h3>
<p>
Máte neuhrazené platby.{' '}
<Button variant="link" className="p-0 align-baseline" onClick={() => setModalOpen(true)}>
Zobrazit QR kódy
</Button>
</p>
{pendingQrs.map(renderQr)}
</div>
<Modal show={modalOpen} onHide={() => setModalOpen(false)} centered scrollable>
<Modal.Header closeButton>
<Modal.Title>Nevyřízené platby</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Máte neuhrazené platby. Naskenujte QR kód pro zaplacení.</p>
{pendingQrs.map(renderQr)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setModalOpen(false)}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
<ConfirmModal
isOpen={dismissQrId !== null}
title="Potvrzení platby"
message="Opravdu jste zaplatili? QR kód bude odstraněn."
confirmLabel="Zaplatil jsem"
confirmVariant="success"
onClose={() => setDismissQrId(null)}
onConfirm={async () => {
if (!dismissQrId) return;
const id = dismissQrId;
setDismissQrId(null);
await dismissQr({ body: { id } });
await onDismissed?.();
}}
/>
</>
);
}
+21 -35
View File
@@ -15,43 +15,29 @@ export default function PizzaOrderList({ state, orders, onDelete, creator }: Rea
} }
if (!orders?.length) { if (!orders?.length) {
return <p className="mt-4" style={{ color: 'var(--luncher-text-muted)', fontStyle: 'italic' }}>Zatím žádné objednávky...</p> return <p className="mt-3"><i>Zatím žádné objednávky...</i></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)', <tr>
borderRadius: 'var(--luncher-radius-lg)', <th>Jméno</th>
overflow: 'hidden', <th>Objednávka</th>
border: '1px solid var(--luncher-border-light)', <th>Poznámka</th>
boxShadow: 'var(--luncher-shadow)' <th>Příplatek</th>
}}> <th>Cena</th>
<Table className="mb-0" style={{ color: 'var(--luncher-text)' }}> </tr>
<thead style={{ background: 'var(--luncher-primary-light)' }}> </thead>
<tr> <tbody>
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Jméno</th> {orders.map(order => <tr key={order.customer}>
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Objednávka</th> <PizzaOrderRow creator={creator} state={state} order={order} onDelete={onDelete} onFeeModalSave={saveFees} />
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Poznámka</th> </tr>)}
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Příplatek</th> <tr style={{ fontWeight: 'bold' }}>
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none', textAlign: 'right' }}>Cena</th> <td colSpan={4}>Celkem</td>
</tr> <td>{`${total} Kč`}</td>
</thead> </tr>
<tbody> </tbody>
{orders.map(order => <tr key={order.customer} style={{ borderColor: 'var(--luncher-border-light)' }}> </Table>
<PizzaOrderRow creator={creator} state={state} order={order} onDelete={onDelete} onFeeModalSave={saveFees} />
</tr>)}
<tr style={{
fontWeight: 700,
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>
</tbody>
</Table>
</div>
);
} }
+4 -4
View File
@@ -26,7 +26,7 @@ export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeMo
<td>{order.customer}</td> <td>{order.customer}</td>
<td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder => <td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder =>
<span key={pizzaOrder.name}> <span key={pizzaOrder.name}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price / 100} Kč)`} {`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
{auth?.login === order.customer && state === PizzaDayState.CREATED && {auth?.login === order.customer && state === PizzaDayState.CREATED &&
<span title='Odstranit'> <span title='Odstranit'>
<FontAwesomeIcon onClick={() => { <FontAwesomeIcon onClick={() => {
@@ -38,10 +38,10 @@ export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeMo
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])} .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 / 100} Kč${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td> <td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price} Kč${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
<td> <td>
{order.totalPrice / 100} Kč{auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>} {order.totalPrice} Kč{auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>}
</td> </td>
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price != null ? String(order.fee.price / 100) : undefined }} /> <PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} />
</> </>
} }
-57
View File
@@ -1,57 +0,0 @@
.price-history {
margin: 4px 0 8px;
padding: 8px 10px;
max-height: 260px;
overflow-y: auto;
border-left: 3px solid var(--luncher-border);
font-size: 0.8rem;
ul {
list-style: none;
margin: 2px 0 6px;
padding: 0;
}
li {
padding: 1px 0;
}
.price-tag {
display: inline-block;
min-width: 72px;
font-weight: 700;
}
.price-up .price-tag { color: var(--luncher-danger); }
.price-down .price-tag { color: var(--luncher-success); }
.price-new .price-tag { color: var(--luncher-primary); }
.price-removed { color: var(--luncher-text-muted); }
}
.price-history-entry + .price-history-entry {
margin-top: 6px;
}
.price-history-tabs {
display: inline-flex;
gap: 2px;
padding: 2px;
margin-bottom: 6px;
border-radius: 999px;
background: var(--luncher-bg-hover);
button {
border: none;
background: none;
border-radius: 999px;
padding: 2px 10px;
font-size: 0.75rem;
font-weight: 700;
color: var(--luncher-text-secondary);
&.active {
background: var(--luncher-bg-card);
color: var(--luncher-primary);
}
}
}
-112
View File
@@ -1,112 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { getStoreMenuHistory, StoreMenuProvider, StorePriceChange, StorePriceHistoryEntry } from "../../../types";
import { getHumanDateTime } from "../Utils";
import { computeSinceStart, plural, SinceStart } from "../utils/priceHistory";
import "./PriceHistory.scss";
type Props = {
store: string,
provider: StoreMenuProvider,
};
/** Popis jedné změny: zdražení/zlevnění s rozdílem, nová nebo odebraná položka. */
function ChangeRow({ change }: Readonly<{ change: StorePriceChange }>) {
const { name, before, after } = change;
if (before == null && after != null) {
return <li className="price-new"><span className="price-tag">nové</span> {name} <strong>{after} Kč</strong></li>;
}
if (after == null) {
return <li className="price-removed"><span className="price-tag">odebráno</span> <s>{name}</s>{before != null && <> ({before} Kč)</>}</li>;
}
const diff = after - (before ?? after);
const up = diff > 0;
return (
<li className={up ? 'price-up' : 'price-down'}>
<span className="price-tag">{up ? '▲' : '▼'} {up ? '+' : ''}{diff} Kč</span> {name}: {before} → <strong>{after} Kč</strong>
</li>
);
}
function EntryRow({ entry }: Readonly<{ entry: StorePriceHistoryEntry }>) {
const when = getHumanDateTime(new Date(entry.at));
if (entry.initial) {
return <div className="price-history-entry"><small className="text-muted">{when} — první stažení ({plural(entry.itemCount ?? 0, 'položka', 'položky', 'položek')})</small></div>;
}
const ups = entry.changes.filter(c => c.before != null && c.after != null && c.after > c.before).length;
const downs = entry.changes.filter(c => c.before != null && c.after != null && c.after < c.before).length;
return (
<div className="price-history-entry">
<small className="text-muted">
{when} — {plural(entry.changes.length, 'změna', 'změny', 'změn')}{ups > 0 && `, ${ups}× zdražení`}{downs > 0 && `, ${downs}× zlevnění`}
</small>
<ul>{entry.changes.map((c, i) => <ChangeRow key={i} change={c} />)}</ul>
</div>
);
}
function SinceStartView({ summary }: Readonly<{ summary: SinceStart }>) {
const { since, changed, added, removed } = summary;
if (changed.length + added.length + removed.length === 0) {
return <small className="text-muted">Od {getHumanDateTime(new Date(since))} beze změn.</small>;
}
const ups = changed.filter(c => c.current > c.start).length;
const avgPct = changed.length
? changed.reduce((sum, c) => sum + (c.current - c.start) / c.start * 100, 0) / changed.length
: 0;
return (
<div className="price-history-entry">
<small className="text-muted">
Od {getHumanDateTime(new Date(since))}: {plural(changed.length, 'změna ceny', 'změny cen', 'změn cen')} ({ups}× zdražení, {changed.length - ups}× zlevnění)
{changed.length > 0 && `, průměrně ${avgPct > 0 ? '+' : ''}${avgPct.toFixed(1)} %`}
{added.length > 0 && `, ${plural(added.length, 'nová položka', 'nové položky', 'nových položek')}`}{removed.length > 0 && `, ${plural(removed.length, 'odebraná', 'odebrané', 'odebraných')}`}
</small>
<ul>
{changed.map(c => {
const diff = c.current - c.start;
const up = diff > 0;
return (
<li key={c.name} className={up ? 'price-up' : 'price-down'}>
<span className="price-tag">{up ? '▲' : '▼'} {up ? '+' : ''}{diff} Kč</span> {c.name}: {c.start} → <strong>{c.current} Kč</strong>
<small className="text-muted"> ({up ? '+' : ''}{(diff / c.start * 100).toFixed(0)} %)</small>
</li>
);
})}
{added.map(a => <li key={`+${a.name}`} className="price-new"><span className="price-tag">nové</span> {a.name}{a.price != null && <> <strong>{a.price} Kč</strong></>}</li>)}
{removed.map(r => <li key={`-${r.name}`} className="price-removed"><span className="price-tag">odebráno</span> <s>{r.name}</s>{r.price != null && <> ({r.price} Kč)</>}</li>)}
</ul>
</div>
);
}
/** Historie cen nabídky obchodu u jedné služby (verze z jednotlivých stažení, nejnovější nahoře). */
export default function PriceHistory({ store, provider }: Readonly<Props>) {
const [entries, setEntries] = useState<StorePriceHistoryEntry[]>();
const [view, setView] = useState<'versions' | 'since'>('versions');
const summary = useMemo(() => entries ? computeSinceStart(entries) : undefined, [entries]);
useEffect(() => {
getStoreMenuHistory({ query: { name: store } })
.then(r => setEntries(r.data?.providers.find(p => p.provider === provider)?.entries ?? []))
.catch(() => setEntries([]));
}, [store, provider]);
if (!entries) return <small className="text-muted">Načítám historii…</small>;
if (entries.length === 0) return <small className="text-muted">Historie cen zatím není — vznikne s prvním stažením nabídky.</small>;
const hasChanges = entries.some(e => e.changes.length > 0);
return (
<div className="price-history">
{hasChanges && (
<div className="price-history-tabs">
<button type="button" className={view === 'versions' ? 'active' : ''} onClick={() => setView('versions')}>Po verzích</button>
<button type="button" className={view === 'since' ? 'active' : ''} onClick={() => setView('since')}>Od začátku</button>
</div>
)}
{view === 'since' && summary
? <SinceStartView summary={summary} />
: entries.map(e => <EntryRow key={e.at} entry={e} />)}
{entries.length === 1 && entries[0].initial && (
<small className="text-muted d-block mt-1">Změny se objeví po dalším stažení (↻).</small>
)}
</div>
);
}
-475
View File
@@ -1,475 +0,0 @@
.wheel-confetti {
position: fixed;
inset: 0;
z-index: 2000; // nad modalem (bootstrap 1055)
pointer-events: none;
overflow: hidden;
> span {
position: absolute;
will-change: transform, opacity;
animation-fill-mode: both;
}
}
// Vrh: rychle vzhůru do vrcholu (zpomalení), pak volný pád (zrychlení)
@keyframes confetti-throw {
0% { transform: translate3d(0, 0, 0) rotate(0deg) scale(0.4); opacity: 0; animation-timing-function: cubic-bezier(0.2, 0.7, 0.4, 1); }
6% { opacity: 1; }
38% { transform: translate3d(var(--x1), var(--y1), 0) rotate(var(--r1)) scale(1); animation-timing-function: cubic-bezier(0.5, 0, 0.9, 0.6); }
88% { opacity: 1; }
100% { transform: translate3d(var(--x2), var(--y2), 0) rotate(var(--r2)) scale(1); opacity: 0; }
}
// Déšť: plynulý pád shora s mírným houpáním
@keyframes confetti-rain {
0% { transform: translate3d(0, 0, 0) rotate(0deg); opacity: 0; animation-timing-function: ease-in-out; }
8% { opacity: 1; }
40% { transform: translate3d(var(--x1), var(--y1), 0) rotate(var(--r1)); animation-timing-function: ease-in; }
88% { opacity: 1; }
100% { transform: translate3d(var(--x2), var(--y2), 0) rotate(var(--r2)); opacity: 0; }
}
// Samolepky Umamusume — stín pro čitelnost na libovolném pozadí
.confetti-uma {
filter: drop-shadow(0 3px 3px rgb(0 0 0 / 0.2));
}
.confetti-uma-emoji {
line-height: 1;
}
.confetti-uma-badge {
padding: 4px 14px;
border-radius: 999px;
border: 3px solid #fff;
color: #fff;
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
font-weight: 900;
font-size: 1.15rem;
white-space: nowrap;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.2);
box-shadow: 0 4px 0 rgb(0 0 0 / 0.15);
}
.confetti-uma-img img {
width: 92px;
height: auto;
}
// Portréty (bez průhlednosti) jako kulaté samolepky s barevným lemem
.confetti-uma-img.round img,
.confetti-uma-voice img.round {
width: 84px;
height: 84px;
object-fit: cover;
border-radius: 50%;
border: 4px solid #fff;
box-shadow: 0 0 0 3px #ff6fa3, 0 4px 8px rgb(0 0 0 / 0.3);
}
.confetti-uma-voice img.round {
width: 96px;
height: 96px;
margin-bottom: 8px;
}
.confetti-uma-banner {
position: absolute;
top: 38%;
left: 50%;
transform: translate(-50%, -50%);
padding: 12px 48px;
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
font-weight: 900;
font-size: clamp(2rem, 7vw, 4rem);
color: #fff;
background: linear-gradient(90deg, #e84d86, #ff6fa3);
clip-path: polygon(24px 0, 100% 0, calc(100% - 24px) 100%, 0 100%);
text-shadow: 0 3px 0 rgb(0 0 0 / 0.2);
border-top: 5px solid #ffc531;
border-bottom: 5px solid #ffc531;
animation: uma-banner 1.8s cubic-bezier(0.2, 1.4, 0.4, 1) both;
}
@keyframes uma-banner {
0% { transform: translate(-150%, -50%) skewX(-12deg); opacity: 0; }
25% { transform: translate(-50%, -50%) skewX(-12deg); opacity: 1; }
75% { transform: translate(-50%, -50%) skewX(-12deg) scale(1.04); opacity: 1; }
100% { transform: translate(60%, -50%) skewX(-12deg); opacity: 0; }
}
// „Hláška" postavy při finálním výsledku
.confetti-uma-voice {
position: absolute;
left: 16px;
bottom: 16px;
display: flex;
align-items: flex-end;
gap: 8px;
max-width: min(460px, calc(100vw - 32px));
animation: uma-voice 4.6s ease both;
img {
flex: none;
width: 110px;
height: auto;
filter: drop-shadow(0 4px 6px rgb(0 0 0 / 0.3));
}
}
.confetti-uma-bubble {
position: relative;
margin-bottom: 40px;
padding: 12px 16px;
background: #fff;
color: #1d3322;
border: 3px solid #ff6fa3;
border-radius: 18px;
box-shadow: 0 4px 0 #e84d86;
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
p {
margin: 0;
font-weight: 800;
font-size: 0.95rem;
line-height: 1.35;
}
small {
display: block;
margin-top: 4px;
color: #e84d86;
font-weight: 800;
}
// ocásek bubliny směrem k postavě
&::before {
content: '';
position: absolute;
left: -14px;
bottom: 14px;
border: 8px solid transparent;
border-right: 10px solid #ff6fa3;
}
}
@keyframes uma-voice {
0% { opacity: 0; transform: translateY(40px) scale(0.8); }
10% { opacity: 1; transform: translateY(0) scale(1.03); }
15% { transform: translateY(0) scale(1); }
85% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(20px); }
}
// ============================================
// Konfety ve stylu My Little Pony
// ============================================
.confetti-mlp {
filter: drop-shadow(0 3px 3px rgb(0 0 0 / 0.2));
}
.confetti-mlp-badge {
padding: 4px 14px;
border-radius: 999px;
border: 3px solid #fff;
color: #fff;
font-family: 'Fredoka', 'Nunito', sans-serif;
font-weight: 700;
font-size: 1.1rem;
white-space: nowrap;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.2);
box-shadow: 0 0 12px rgb(255 255 255 / 0.7), 0 4px 0 rgb(0 0 0 / 0.15);
}
// Duhový nápis „Sonic Rainboom" přes celou obrazovku
.confetti-mlp-banner {
position: absolute;
top: 38%;
left: 50%;
transform: translate(-50%, -50%);
padding: 12px 44px;
font-family: 'Fredoka', 'Nunito', sans-serif;
font-weight: 700;
font-size: clamp(1.8rem, 6.5vw, 3.6rem);
white-space: nowrap;
color: #fff;
border-radius: 999px;
background: linear-gradient(90deg, #ee4144, #f37033, #e3b829, #62bc4d, #1e98d3, #672f89);
border: 5px solid #fff;
text-shadow: 0 3px 0 rgb(0 0 0 / 0.25);
box-shadow: 0 0 30px rgb(236 61 143 / 0.5), 0 8px 0 rgb(0 0 0 / 0.12);
animation: mlp-banner 2s cubic-bezier(0.2, 1.4, 0.4, 1) both;
}
@keyframes mlp-banner {
0% { transform: translate(-50%, -50%) scale(0.2) rotate(-8deg); opacity: 0; }
25% { transform: translate(-50%, -50%) scale(1.08) rotate(0deg); opacity: 1; }
35% { transform: translate(-50%, -50%) scale(1); }
75% { transform: translate(-50%, -50%) scale(1.03); opacity: 1; }
100% { transform: translate(-50%, -160%) scale(0.9); opacity: 0; }
}
// Postava s hláškou — místo obrázku kulatý „cutie mark" s emoji
.confetti-mlp-avatar {
flex: none;
display: grid;
place-items: center;
width: 84px;
height: 84px;
margin-bottom: 8px;
font-size: 46px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff, #f6ecff);
border: 4px solid #fff;
box-shadow: 0 0 0 4px var(--mlp-speaker, #8e4fd1), 0 4px 10px rgb(0 0 0 / 0.3);
}
.confetti-mlp-bubble {
position: relative;
margin-bottom: 40px;
padding: 12px 16px;
background: #fff;
color: #2e1f47;
border: 3px solid var(--mlp-speaker, #8e4fd1);
border-radius: 22px;
box-shadow: 0 4px 0 var(--mlp-speaker, #8e4fd1);
font-family: 'Fredoka', 'Nunito', sans-serif;
p {
margin: 0;
font-weight: 600;
font-size: 1rem;
line-height: 1.35;
}
small {
display: block;
margin-top: 4px;
color: var(--mlp-speaker, #8e4fd1);
font-weight: 700;
}
// ocásek bubliny směrem k postavě
&::before {
content: '';
position: absolute;
left: -14px;
bottom: 14px;
border: 8px solid transparent;
border-right: 10px solid var(--mlp-speaker, #8e4fd1);
}
}
// ============================================
// Konfety ve stylu Hell Girl
// ============================================
.confetti-hg {
filter: drop-shadow(0 0 6px rgb(200 16 46 / 0.55)) drop-shadow(0 3px 3px rgb(0 0 0 / 0.3));
}
.confetti-hg-badge {
padding: 4px 14px;
border: 1px solid #c8102e;
outline: 1px solid rgb(200 16 46 / 0.4);
outline-offset: 2px;
color: #fff4ee;
font-family: 'Shippori Mincho', 'Noto Serif JP', serif;
font-weight: 800;
font-size: 1.1rem;
letter-spacing: 1px;
white-space: nowrap;
text-shadow: 0 0 8px rgb(232 52 79 / 0.7);
}
// Nápis „poslání do pekla" — černá deska s rudým lemem, vynoří se z mlhy
.confetti-hg-banner {
position: absolute;
top: 38%;
left: 50%;
transform: translate(-50%, -50%);
padding: 14px 48px;
font-family: 'Shippori Mincho', 'Noto Serif JP', serif;
font-weight: 800;
font-size: clamp(2rem, 7vw, 4rem);
letter-spacing: 0.3em;
white-space: nowrap;
color: #fff4ee;
background: linear-gradient(90deg, transparent, #12070a 15%, #12070a 85%, transparent);
border-top: 2px solid #c8102e;
border-bottom: 2px solid #c8102e;
text-shadow: 0 0 18px rgb(232 52 79 / 0.9), 0 0 4px #c8102e;
animation: hg-banner 2.6s ease both;
}
@keyframes hg-banner {
0% { transform: translate(-50%, -50%) scale(1.15); opacity: 0; filter: blur(12px); }
25% { transform: translate(-50%, -50%) scale(1); opacity: 1; filter: blur(0); }
75% { transform: translate(-50%, -50%) scale(1); opacity: 1; filter: blur(0); }
100% { transform: translate(-50%, -50%) scale(0.96); opacity: 0; filter: blur(8px); }
}
// Postava s hláškou — tmavý odznak s rudou září a bublina jako svitek
.confetti-hellgirl-voice {
.confetti-mlp-avatar {
border-color: #12070a;
background: radial-gradient(circle at 50% 60%, #3a0d16, #12070a);
box-shadow: 0 0 0 3px var(--mlp-speaker, #c8102e), 0 0 18px rgb(200 16 46 / 0.6);
}
.confetti-mlp-bubble {
background: #170c0f;
color: #f3e6e2;
border: 1px solid var(--mlp-speaker, #c8102e);
border-radius: 3px;
box-shadow: 0 0 16px rgb(0 0 0 / 0.6), 0 0 10px rgb(200 16 46 / 0.3);
font-family: 'Shippori Mincho', 'Noto Serif JP', serif;
}
}
// ============================================
// Konfety ve stylu K-ON!
// ============================================
.confetti-kon {
filter: drop-shadow(0 3px 3px rgb(39 49 95 / 0.25));
}
// Samolepky jako na pouzdru kytary — bílý okraj a lehce natočené
.confetti-kon-badge {
padding: 4px 14px;
border-radius: 10px;
border: 3px solid #fff;
color: #fff;
font-family: 'Zen Maru Gothic', 'M PLUS Rounded 1c', sans-serif;
font-weight: 900;
font-size: 1.05rem;
white-space: nowrap;
text-shadow: 0 1px 0 rgb(0 0 0 / 0.2);
box-shadow: 0 3px 0 rgb(39 49 95 / 0.25);
}
// Nápis „Houkago Tea Time" — vstupenka na školní koncert s notovou osnovou
.confetti-kon-banner {
position: absolute;
top: 38%;
left: 50%;
transform: translate(-50%, -50%);
padding: 14px 44px;
font-family: 'Zen Maru Gothic', 'M PLUS Rounded 1c', sans-serif;
font-weight: 900;
font-size: clamp(1.6rem, 6vw, 3.4rem);
white-space: nowrap;
color: #27315f;
border-radius: 18px;
background:
repeating-linear-gradient(180deg, transparent 0 9px, rgb(39 49 95 / 0.12) 9px 10px) center / 100% 50px no-repeat,
#fffaf0;
border: 4px solid #27315f;
box-shadow: 0 0 0 6px #fffaf0, 0 0 0 9px #d43f62, 0 12px 26px rgb(0 0 0 / 0.25);
animation: kon-banner 2.2s cubic-bezier(0.3, 1.5, 0.5, 1) both;
}
// Vyskočí do rytmu (raz-dva) a odplave nahoru
@keyframes kon-banner {
0% { transform: translate(-50%, -50%) scale(0.3) rotate(6deg); opacity: 0; }
18% { transform: translate(-50%, -50%) scale(1.1) rotate(-3deg); opacity: 1; }
28% { transform: translate(-50%, -50%) scale(1) rotate(-2deg); }
40% { transform: translate(-50%, -50%) scale(1.05) rotate(-2deg); }
50% { transform: translate(-50%, -50%) scale(1) rotate(-2deg); }
80% { transform: translate(-50%, -50%) scale(1) rotate(-2deg); opacity: 1; }
100% { transform: translate(-50%, -150%) scale(0.9) rotate(-2deg); opacity: 0; }
}
// Postava s hláškou — krémový odznak a bublina jako stránka notového sešitu
.confetti-kon-voice {
.confetti-mlp-avatar {
background: radial-gradient(circle at 40% 35%, #fffaf0, #fbe6c8);
box-shadow: 0 0 0 4px var(--mlp-speaker, #d43f62), 0 4px 10px rgb(0 0 0 / 0.3);
}
.confetti-mlp-bubble {
background:
repeating-linear-gradient(180deg, transparent 0 21px, rgb(39 49 95 / 0.08) 21px 22px),
#fffaf0;
color: #27315f;
border-radius: 16px;
font-family: 'Zen Maru Gothic', 'M PLUS Rounded 1c', sans-serif;
p {
font-weight: 700;
}
}
}
// ============================================
// Konfety ve stylu Bocchi the Rock!
// ============================================
.confetti-bocchi {
filter: drop-shadow(3px 3px 0 rgb(29 26 34 / 0.85));
}
// Samolepky z flyeru live house — černá linka a tvrdý stín
.confetti-bocchi-badge {
padding: 4px 14px;
border-radius: 6px;
border: 3px solid #1d1a22;
color: #fff;
font-family: 'Dela Gothic One', 'M PLUS Rounded 1c', sans-serif;
font-size: 1.05rem;
white-space: nowrap;
}
// Nápis „Kessoku Band" — žlutá samolepka s růžovým stínem, při dopadu se zachvěje jako Bocchi
.confetti-bocchi-banner {
position: absolute;
top: 38%;
left: 50%;
transform: translate(-50%, -50%);
padding: 12px 40px;
font-family: 'Dela Gothic One', 'M PLUS Rounded 1c', sans-serif;
font-size: clamp(1.8rem, 7vw, 4rem);
white-space: nowrap;
color: #1d1a22;
background: #ffcf33;
border: 5px solid #1d1a22;
border-radius: 10px;
box-shadow: 10px 10px 0 #e0457b, 18px 18px 0 #1d1a22;
animation: bocchi-banner 2.4s ease both;
}
@keyframes bocchi-banner {
0% { transform: translate(-50%, -50%) scale(2.2) rotate(-8deg); opacity: 0; }
14% { transform: translate(-50%, -50%) scale(1) rotate(-3deg); opacity: 1; }
17% { transform: translate(calc(-50% - 6px), -50%) rotate(-3deg); }
20% { transform: translate(calc(-50% + 6px), -50%) rotate(-3deg); }
23% { transform: translate(calc(-50% - 3px), -50%) rotate(-3deg); }
26%, 80% { transform: translate(-50%, -50%) rotate(-3deg); opacity: 1; }
100% { transform: translate(-50%, 60%) rotate(4deg); opacity: 0; }
}
// Postava s hláškou — hranatý odznak a bublina jako z mangy s tvrdým stínem
.confetti-bocchi-voice {
.confetti-mlp-avatar {
border-radius: 12px;
border: 3px solid #1d1a22;
background: #ffcf33;
box-shadow: 5px 5px 0 var(--mlp-speaker, #e0457b);
}
.confetti-mlp-bubble {
background: #fff;
color: #1d1a22;
border: 3px solid #1d1a22;
border-radius: 10px;
box-shadow: 5px 5px 0 var(--mlp-speaker, #e0457b);
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
p {
font-weight: 800;
}
&::before {
border-right-color: #1d1a22;
}
}
}
-278
View File
@@ -1,278 +0,0 @@
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,
);
}
@@ -1,79 +0,0 @@
import { useState } from "react";
import { Modal, Button, Form } from "react-bootstrap";
type Props = {
isOpen: boolean;
onClose: () => void;
onSubmit: (title: string, description: string) => Promise<void>;
};
/** Modální dialog pro přidání nového návrhu na vylepšení. */
export default function AddSuggestionModal({ isOpen, onClose, onSubmit }: Readonly<Props>) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [submitting, setSubmitting] = useState(false);
const reset = () => {
setTitle("");
setDescription("");
};
const handleClose = () => {
reset();
onClose();
};
const handleSubmit = async () => {
if (!title.trim() || !description.trim()) return;
setSubmitting(true);
try {
await onSubmit(title.trim(), description.trim());
reset();
onClose();
} finally {
setSubmitting(false);
}
};
return (
<Modal show={isOpen} onHide={handleClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Nový návrh na vylepšení</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Group className="mb-3">
<Form.Label>Název</Form.Label>
<Form.Control
type="text"
placeholder="Stručný název návrhu"
value={title}
maxLength={120}
onChange={e => setTitle(e.target.value)}
onKeyDown={e => e.stopPropagation()}
autoFocus
/>
<Form.Text className="text-muted">Krátký, výstižný název navrhované úpravy.</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Popis</Form.Label>
<Form.Control
as="textarea"
rows={5}
placeholder="Detailní popis navrhované úpravy, řešení apod."
value={description}
onChange={e => setDescription(e.target.value)}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>
Storno
</Button>
<Button onClick={handleSubmit} disabled={submitting || !title.trim() || !description.trim()}>
Přidat
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -1,174 +0,0 @@
import { useState } from "react";
import { Modal, Button, Form, Alert, Badge } from "react-bootstrap";
import {
OrderGroup,
simulateBoltTracking, advanceBoltTracking, setBoltTrackingState,
pollBoltTracking, stopBoltTrackingSimulation,
} from "../../../../types";
type Props = {
isOpen: boolean;
onClose: () => void;
group: OrderGroup;
};
/** Nabídka stavů pro ruční nastavení (odpovídá mapování v 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>
);
}
@@ -1,142 +0,0 @@
.butterfly-mystats {
margin-bottom: 16px;
padding: 12px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(255, 203, 61, 0.16), rgba(224, 162, 0, 0.08));
border: 1px solid rgba(224, 162, 0, 0.35);
}
.butterfly-mystats-title {
font-weight: 700;
color: #6b5b2e;
margin-bottom: 8px;
}
.butterfly-mystats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: 8px;
> div {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.v {
font-size: 1.3rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
line-height: 1.1;
&.gold {
color: #e0a200;
}
}
.l {
font-size: 0.72rem;
color: #6c757d;
}
}
.butterfly-leaderboard-heading {
font-weight: 700;
margin: 12px 0 8px;
}
.butterfly-achievements {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 6px;
}
.butterfly-achievement {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.04);
font-size: 0.78rem;
opacity: 0.55;
filter: grayscale(1);
&.unlocked {
opacity: 1;
filter: none;
background: rgba(255, 203, 61, 0.18);
}
.ach-icon { flex: 0 0 auto; }
.ach-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.butterfly-leaderboard {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.butterfly-leaderboard-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.03);
&.me {
background: rgba(255, 203, 61, 0.22);
border: 1px solid rgba(224, 162, 0, 0.5);
font-weight: 700;
}
}
.butterfly-leaderboard-rank {
flex: 0 0 auto;
width: 28px;
text-align: center;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.butterfly-leaderboard-name {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.butterfly-leaderboard-title {
font-size: 0.72rem;
font-weight: 500;
color: #6b5b2e;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.butterfly-leaderboard-stats {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
font-variant-numeric: tabular-nums;
}
.butterfly-leaderboard-golden {
color: #e0a200;
font-weight: 700;
}
@@ -1,111 +0,0 @@
import { useEffect, useState } from "react";
import { Modal } from "react-bootstrap";
import {
ButterflyLeaderboardEntry, ButterflyStats, ButterflyAchievement,
getButterflyLeaderboard, getButterflyAchievements,
} from "../../../../types";
import { useAuth } from "../../context/auth";
import "./ButterflyLeaderboardModal.scss";
type Props = {
isOpen: boolean;
onClose: () => void;
myStats?: ButterflyStats;
};
const MEDALS = ['🥇', '🥈', '🥉'];
/** Modální dialog s osobními statistikami, achievementy a týmovým žebříčkem. */
export default function ButterflyLeaderboardModal({ isOpen, onClose, myStats }: Readonly<Props>) {
const auth = useAuth();
const [entries, setEntries] = useState<ButterflyLeaderboardEntry[]>();
const [achievements, setAchievements] = useState<ButterflyAchievement[]>();
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
setLoading(true);
Promise.all([
getButterflyLeaderboard({ query: { limit: 20 } }).then(r => r.data ?? []).catch(() => []),
getButterflyAchievements().then(r => r.data ?? []).catch(() => []),
]).then(([lb, ach]) => {
if (cancelled) return;
setEntries(lb);
setAchievements(ach);
}).finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [isOpen]);
return (
<Modal show={isOpen} onHide={onClose} centered>
<Modal.Header closeButton>
<Modal.Title>🦋 Chytání motýlků</Modal.Title>
</Modal.Header>
<Modal.Body>
{myStats && (
<div className="butterfly-mystats">
<div className="butterfly-mystats-title">
{myStats.level}. {myStats.title}
</div>
<div className="butterfly-mystats-grid">
<div><span className="v">{myStats.caught}</span><span className="l">🦋 chyceno</span></div>
<div><span className="v gold">{myStats.goldenCaught}</span><span className="l">✨ zlatých</span></div>
<div><span className="v">{myStats.coins}</span><span className="l">🪙 mincí</span></div>
<div><span className="v">{myStats.waspsKilled}</span><span className="l">🪰 vos zabito</span></div>
<div><span className="v">{myStats.birdsScared}</span><span className="l">🦅 vyplašeno</span></div>
<div><span className="v">{myStats.thievesDefeated}</span><span className="l">🦹 zlodějů</span></div>
<div><span className="v">{myStats.caterpillarsDefeated}</span><span className="l">🐛 housenek</span></div>
</div>
</div>
)}
{achievements && achievements.length > 0 && (
<>
<h6 className="butterfly-leaderboard-heading">Odznaky</h6>
<div className="butterfly-achievements">
{achievements.map(a => (
<div
key={a.id}
className={a.unlocked ? 'butterfly-achievement unlocked' : 'butterfly-achievement'}
title={a.description}
>
<span className="ach-icon">{a.unlocked ? '🏅' : '🔒'}</span>
<span className="ach-title">{a.title}</span>
</div>
))}
</div>
</>
)}
<h6 className="butterfly-leaderboard-heading">Žebříček týmu</h6>
{loading && <p className="text-center text-muted mb-0">Načítám žebříček…</p>}
{!loading && entries && entries.length === 0 && (
<p className="text-center text-muted mb-0">Zatím nikdo nic nechytil. Buď první!</p>
)}
{!loading && entries && entries.length > 0 && (
<ol className="butterfly-leaderboard">
{entries.map((e, i) => (
<li
key={e.login}
className={e.login === auth?.login ? 'butterfly-leaderboard-row me' : 'butterfly-leaderboard-row'}
>
<span className="butterfly-leaderboard-rank">{MEDALS[i] ?? `${i + 1}.`}</span>
<span className="butterfly-leaderboard-name" title={e.title}>
{e.login}
<span className="butterfly-leaderboard-title">{e.level}. {e.title}</span>
</span>
<span className="butterfly-leaderboard-stats">
<span className="butterfly-leaderboard-caught">{e.caught} 🦋</span>
{e.goldenCaught > 0 && (
<span className="butterfly-leaderboard-golden">{e.goldenCaught} ✨</span>
)}
</span>
</li>
))}
</ol>
)}
</Modal.Body>
</Modal>
);
}
@@ -1,106 +0,0 @@
.shop-balance {
font-size: 0.9rem;
font-weight: 700;
color: #e0a200;
margin-left: 8px;
}
.shop-heading {
font-weight: 700;
margin: 14px 0 8px;
}
.butterfly-shop-daily {
padding: 10px 12px;
border-radius: 12px;
background: rgba(28, 126, 214, 0.12);
border: 1px solid rgba(28, 126, 214, 0.35);
margin-bottom: 12px;
.daily-title {
font-weight: 700;
font-size: 0.9rem;
margin-bottom: 6px;
}
.daily-bar {
height: 8px;
background: rgba(0, 0, 0, 0.1);
border-radius: 999px;
overflow: hidden;
}
.daily-bar-inner {
height: 100%;
background: linear-gradient(90deg, #4dabf7, #1c7ed6);
border-radius: 999px;
transition: width 0.3s ease;
}
.daily-status {
margin-top: 5px;
font-size: 0.8rem;
color: #495057;
font-variant-numeric: tabular-nums;
}
}
.butterfly-shop-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.butterfly-shop-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 10px;
background: #fff;
cursor: pointer;
text-align: left;
transition: background 0.12s ease, transform 0.08s ease;
&:hover:not(:disabled) {
background: #f1f3f5;
}
&:active:not(:disabled) {
transform: scale(0.99);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.shop-item-label {
display: flex;
flex-direction: column;
font-weight: 600;
font-size: 0.9rem;
}
.shop-item-lvl {
display: inline;
color: #1c7ed6;
font-weight: 700;
}
.shop-item-desc {
font-weight: 400;
font-size: 0.74rem;
color: #868e96;
}
.shop-item-cost {
flex: 0 0 auto;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #e0a200;
white-space: nowrap;
}
}
@@ -1,113 +0,0 @@
import { Modal } from "react-bootstrap";
import { ButterflyStats } from "../../../../types";
import "./ButterflyShopModal.scss";
type UpgradeId = 'net' | 'scarecrow' | 'reinforced';
type Props = {
isOpen: boolean;
onClose: () => void;
stats?: ButterflyStats;
onBuyPremium: () => void;
onWaspSpray: () => void;
onRepellent: () => void;
onBuyInsurance: () => void;
onBuyUpgrade: (item: UpgradeId) => void;
};
// Zrcadlí serverové hodnoty (server/src/butterflies.ts)
const PREMIUM_BUY_COST = 40;
const WASP_SPRAY_COST = 25;
const REPELLENT_COST = 70;
const INSURANCE_COST = 50;
const UPGRADE_BASE: Record<UpgradeId, number> = { net: 100, scarecrow: 120, reinforced: 150 };
const UPGRADE_MAX: Record<UpgradeId, number> = { net: 5, scarecrow: 4, reinforced: 3 };
const UPGRADE_META: Record<UpgradeId, { icon: string; title: string; desc: string }> = {
net: { icon: '🥅', title: 'Větší síťka', desc: 'Zvětší dosah chytání' },
scarecrow: { icon: '🐦‍⬛', title: 'Strašák', desc: 'Ptáci přibývají pomaleji' },
reinforced: { icon: '🧵', title: 'Zpevněná síťka', desc: 'Rychlejší samo-zašití' },
};
function upgradeCost(item: UpgradeId, level: number): number {
return UPGRADE_BASE[item] * Math.pow(2, level);
}
/** Obchod: spotřební pomůcky a trvalá vylepšení za mince. */
export default function ButterflyShopModal({
isOpen, onClose, stats, onBuyPremium, onWaspSpray, onRepellent, onBuyInsurance, onBuyUpgrade,
}: Readonly<Props>) {
const coins = stats?.coins ?? 0;
const insuranceActive = !!stats && stats.insuranceUntil > Date.now();
const consumable = (label: string, cost: number, onBuy: () => void, extra?: string) => (
<button
type="button"
className="butterfly-shop-item"
onClick={onBuy}
disabled={coins < cost}
>
<span className="shop-item-label">{label}{extra ? ` ${extra}` : ''}</span>
<span className="shop-item-cost">{cost} 🪙</span>
</button>
);
return (
<Modal show={isOpen} onHide={onClose} centered>
<Modal.Header closeButton>
<Modal.Title>🛒 Obchod <span className="shop-balance">{coins} 🪙</span></Modal.Title>
</Modal.Header>
<Modal.Body>
{stats?.daily && (
<div className="butterfly-shop-daily">
<div className="daily-title">🎯 Denní úkol: {stats.daily.title}</div>
<div className="daily-bar">
<div
className="daily-bar-inner"
style={{ width: `${Math.min(100, Math.round((stats.daily.progress / stats.daily.target) * 100))}%` }}
/>
</div>
<div className="daily-status">
{stats.daily.done
? `✅ Splněno (+${stats.daily.reward} 🪙)`
: `${stats.daily.progress}/${stats.daily.target} · odměna ${stats.daily.reward} 🪙`}
</div>
</div>
)}
<h6 className="shop-heading">Pomůcky</h6>
<div className="butterfly-shop-list">
{consumable('🪄 Prémiová síťka (60 s)', PREMIUM_BUY_COST, onBuyPremium)}
{consumable('💨 Vosí sprej (vyhubí vosy)', WASP_SPRAY_COST, onWaspSpray)}
{consumable('🦅🚫 Plašič ptáků', REPELLENT_COST, onRepellent)}
{consumable('🛡️ Pojistka proti zloději', INSURANCE_COST, onBuyInsurance, insuranceActive ? '(aktivní)' : '')}
</div>
<h6 className="shop-heading">Trvalá vylepšení</h6>
<div className="butterfly-shop-list">
{(Object.keys(UPGRADE_META) as UpgradeId[]).map(item => {
const level = stats?.upgrades?.[item] ?? 0;
const max = UPGRADE_MAX[item];
const maxed = level >= max;
const cost = upgradeCost(item, level);
const meta = UPGRADE_META[item];
return (
<button
key={item}
type="button"
className="butterfly-shop-item upgrade"
onClick={() => onBuyUpgrade(item)}
disabled={maxed || coins < cost}
>
<span className="shop-item-label">
{meta.icon} {meta.title} <span className="shop-item-lvl">{level}/{max}</span>
<span className="shop-item-desc">{meta.desc}</span>
</span>
<span className="shop-item-cost">{maxed ? 'MAX' : `${cost} 🪙`}</span>
</button>
);
})}
</div>
</Modal.Body>
</Modal>
);
}
@@ -1,134 +0,0 @@
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>
);
}
@@ -1,26 +0,0 @@
import { Modal, Button } from "react-bootstrap";
type Props = {
isOpen: boolean;
title: string;
message: string;
confirmLabel?: string;
confirmVariant?: string;
onConfirm: () => void;
onClose: () => void;
};
export default function ConfirmModal({ isOpen, title, message, confirmLabel = "Potvrdit", confirmVariant = "primary", onConfirm, onClose }: Readonly<Props>) {
return (
<Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>{message}</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>Zrušit</Button>
<Button variant={confirmVariant} onClick={onConfirm}>{confirmLabel}</Button>
</Modal.Footer>
</Modal>
);
}
@@ -1,193 +0,0 @@
import { useState, useEffect } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { updateGroupFees, OrderGroup, OrderGroupMember } from "../../../../types";
import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees";
type Props = {
isOpen: boolean;
onClose: () => void;
group: OrderGroup;
onSaved: (data: any) => void;
};
function parseHal(s: string): number {
const n = parseFloat(s.replace(',', '.'));
return isNaN(n) || n < 0 ? 0 : Math.round(n * 100);
}
function parsePercent(s: string): number {
const n = parseFloat(s.replace(',', '.'));
return isNaN(n) || n < 0 ? 0 : Math.round(n);
}
export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }: Readonly<Props>) {
const [fees, setFees] = useState('');
const [shipping, setShipping] = useState('');
const [tip, setTip] = useState('');
const [discountType, setDiscountType] = useState<'percent' | 'fixed'>('percent');
const [discountValue, setDiscountValue] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!isOpen) return;
setFees(group.fees ? String(group.fees / 100) : '');
setShipping(group.shipping ? String(group.shipping / 100) : '');
setTip(group.tip ? String(group.tip / 100) : '');
setDiscountType((group.discountType as 'percent' | 'fixed') ?? 'percent');
setDiscountValue(group.discountValue
? ((group.discountType as string) === 'fixed' ? String(group.discountValue / 100) : String(group.discountValue))
: '');
setError(null);
}, [isOpen, group]);
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
const activeCount = countActiveMembers(group.members);
const feesNum = parseHal(fees);
const shippingNum = parseHal(shipping);
const tipNum = parseHal(tip);
const discountNum = discountType === 'percent' ? parsePercent(discountValue) : parseHal(discountValue);
const totalFees = feesNum + shippingNum + tipNum;
const feeShare = computeFeeShare(totalFees, activeCount);
const feeParams = { totalFees, discountType, discountValue: discountNum };
const handleSave = async () => {
setError(null);
setLoading(true);
try {
const res = await updateGroupFees({
body: {
id: group.id,
fees: feesNum,
shipping: shippingNum,
tip: tipNum,
discountType: discountNum > 0 ? discountType : undefined,
discountValue: discountNum > 0 ? discountNum : undefined,
}
});
if (res.error) {
setError((res.error as any).error || 'Nastala chyba');
} else {
onSaved(res.data);
onClose();
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setLoading(false);
}
};
return (
<Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Poplatky skupiny — {group.name}</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>{error}</Alert>
)}
<div className="d-flex gap-3 flex-wrap mb-3">
<Form.Group>
<Form.Label>Poplatky (Kč)</Form.Label>
<Form.Control
type="number" min={0} step={0.01}
value={fees} onChange={e => setFees(e.target.value)}
placeholder="0" style={{ width: 110 }}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
<Form.Group>
<Form.Label>Doprava (Kč)</Form.Label>
<Form.Control
type="number" min={0} step={0.01}
value={shipping} onChange={e => setShipping(e.target.value)}
placeholder="0" style={{ width: 110 }}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
<Form.Group>
<Form.Label>Spropitné (Kč)</Form.Label>
<Form.Control
type="number" min={0} step={0.01}
value={tip} onChange={e => setTip(e.target.value)}
placeholder="0" style={{ width: 110 }}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
</div>
<div className="d-flex gap-3 align-items-end flex-wrap mb-3">
<Form.Group>
<Form.Label>Sleva</Form.Label>
<div className="d-flex gap-2 align-items-center">
<Form.Select
value={discountType}
onChange={e => setDiscountType(e.target.value as 'percent' | 'fixed')}
style={{ width: 160 }}
>
<option value="percent">Procentuální (%)</option>
<option value="fixed">Pevná částka (Kč)</option>
</Form.Select>
<Form.Control
type="number" min={0} step={discountType === 'percent' ? 1 : 0.01}
value={discountValue} onChange={e => setDiscountValue(e.target.value)}
placeholder="0" style={{ width: 100 }}
onKeyDown={e => e.stopPropagation()}
/>
<span className="text-muted">{discountType === 'percent' ? '%' : 'Kč'}</span>
</div>
</Form.Group>
</div>
<hr />
<h6>Náhled celkových částek ({activeCount} {activeCount === 1 ? 'strávník' : 'strávníků'} s objednávkou, {feeShare > 0 ? `poplatek ${feeShare / 100} Kč/os.` : 'bez poplatku'})</h6>
<Table size="sm" bordered>
<thead>
<tr>
<th>Člen</th>
<th className="text-end">Základ</th>
<th className="text-end">Příplatek</th>
<th className="text-end">Poplatek</th>
<th className="text-end">Sleva</th>
<th className="text-end fw-bold">Celkem</th>
</tr>
</thead>
<tbody>
{memberEntries.map(([login, member]) => {
const base = member.amount ?? 0;
const surcharge = member.surchargeAmount ?? 0;
const active = isActiveMember(member);
const total = computeMemberTotal(member, feeParams, feeShare, activeCount);
// Sleva i poplatek se týkají jen aktivních strávníků.
const discount = active && discountNum > 0
? (discountType === 'percent'
? Math.round((base + surcharge) * discountNum / 100)
: Math.round(discountNum / activeCount))
: 0;
return (
<tr key={login} className={active ? '' : 'text-muted'}>
<td><strong>{login}</strong>{!active && <small className="ms-1">(jen objednává)</small>}</td>
<td className="text-end">{base > 0 ? `${base / 100} Kč` : '—'}</td>
<td className="text-end">{surcharge > 0 ? `${surcharge / 100} Kč` : '—'}</td>
<td className="text-end">{active && feeShare > 0 ? `${feeShare / 100} Kč` : '—'}</td>
<td className="text-end text-danger">{discount > 0 ? `-${discount / 100} Kč` : '—'}</td>
<td className="text-end fw-bold">{total > 0 ? `${total / 100} Kč` : '—'}</td>
</tr>
);
})}
</tbody>
</Table>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose} disabled={loading}>Storno</Button>
<Button variant="primary" onClick={handleSave} disabled={loading}>
{loading ? 'Ukládám...' : 'Uložit'}
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,45 @@
import { Modal, Button, Form } from "react-bootstrap"
import { FeatureRequest } from "../../../../types";
type Props = {
isOpen: boolean,
onClose: () => void,
onChange: (option: FeatureRequest, active: boolean) => void,
initialValues?: FeatureRequest[],
}
/** Modální dialog pro hlasování o nových funkcích. */
export default function FeaturesVotingModal({ isOpen, onClose, onChange, initialValues }: Readonly<Props>) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.currentTarget.value as FeatureRequest, e.currentTarget.checked);
}
return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>
Hlasujte pro nové funkce
<p style={{ fontSize: '12px' }}>Je možno vybrat maximálně 4 možnosti</p>
</Modal.Title>
</Modal.Header>
<Modal.Body>
{(Object.keys(FeatureRequest) as Array<keyof typeof FeatureRequest>).map(key => {
return <Form.Check
key={key}
type='checkbox'
id={key}
label={FeatureRequest[key]}
onChange={handleChange}
value={key}
defaultChecked={initialValues?.includes(key as FeatureRequest)}
/>
})}
<p className="mt-3" style={{ fontSize: '12px' }}>Něco jiného? Dejte vědět.</p>
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={onClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
}
@@ -1,153 +0,0 @@
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>
);
}
@@ -1,175 +0,0 @@
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>
);
}
@@ -1,255 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { generateQr, LunchChoices, QrRecipient } from "../../../../types";
type UserEntry = {
login: string;
selected: boolean;
purpose: string;
amount: string;
};
type Props = {
isOpen: boolean;
onClose: () => void;
choices: LunchChoices;
bankAccount: string;
bankAccountHolder: string;
};
/** Modální dialog pro generování QR kódů pro platbu. */
export default function GenerateQrModal({ isOpen, onClose, choices, bankAccount, bankAccountHolder }: Readonly<Props>) {
const [users, setUsers] = useState<UserEntry[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
// Při otevření modálu načteme seznam uživatelů z choices
useEffect(() => {
if (isOpen && choices) {
const userLogins = new Set<string>();
// Projdeme všechny lokace a získáme unikátní loginy
Object.values(choices).forEach(locationChoices => {
if (locationChoices) {
Object.keys(locationChoices).forEach(login => {
userLogins.add(login);
});
}
});
// Vytvoříme seznam uživatelů
const userList: UserEntry[] = Array.from(userLogins)
.sort((a, b) => a.localeCompare(b, 'cs'))
.map(login => ({
login,
selected: false,
purpose: '',
amount: '',
}));
setUsers(userList);
setError(null);
setSuccess(false);
}
}, [isOpen, choices]);
const handleCheckboxChange = useCallback((login: string, checked: boolean) => {
setUsers(prev => prev.map(u =>
u.login === login ? { ...u, selected: checked } : u
));
}, []);
const handlePurposeChange = useCallback((login: string, value: string) => {
setUsers(prev => prev.map(u =>
u.login === login ? { ...u, purpose: value } : u
));
}, []);
const handleAmountChange = useCallback((login: string, value: string) => {
// Povolíme pouze čísla, tečku a čárku
const sanitized = value.replace(/[^0-9.,]/g, '').replace(',', '.');
setUsers(prev => prev.map(u =>
u.login === login ? { ...u, amount: sanitized } : u
));
}, []);
const validateAmount = (amountStr: string): number | null => {
if (!amountStr || amountStr.trim().length === 0) {
return null;
}
const amount = parseFloat(amountStr);
if (isNaN(amount) || amount <= 0) {
return null;
}
// Max 2 desetinná místa
const parts = amountStr.split('.');
if (parts.length === 2 && parts[1].length > 2) {
return null;
}
return Math.round(amount * 100) / 100; // Zaokrouhlíme na 2 desetinná místa
};
const handleGenerate = async () => {
setError(null);
const selectedUsers = users.filter(u => u.selected);
if (selectedUsers.length === 0) {
setError("Nebyl vybrán žádný uživatel");
return;
}
// Validace
const recipients: QrRecipient[] = [];
for (const user of selectedUsers) {
if (!user.purpose || user.purpose.trim().length === 0) {
setError(`Uživatel ${user.login} nemá vyplněný účel platby`);
return;
}
const amount = validateAmount(user.amount);
if (amount === null) {
setError(`Uživatel ${user.login} má neplatnou částku (musí být kladné číslo s max. 2 desetinnými místy)`);
return;
}
recipients.push({
login: user.login,
purpose: user.purpose.trim(),
amount,
});
}
setLoading(true);
try {
const response = await generateQr({
body: {
recipients,
bankAccount,
bankAccountHolder,
}
});
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
} else {
setSuccess(true);
// Po 2 sekundách zavřeme modal
setTimeout(() => {
onClose();
}, 2000);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při generování QR kódů');
} finally {
setLoading(false);
}
};
const handleClose = () => {
setError(null);
setSuccess(false);
onClose();
};
const selectedCount = users.filter(u => u.selected).length;
return (
<Modal show={isOpen} onHide={handleClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Generování QR kódů</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{success ? (
<Alert variant="success">
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci "Nevyřízené platby".
</Alert>
) : (
<>
<p>
Vyberte uživatele, kterým chcete vygenerovat QR kód pro platbu.
QR kódy se uživatelům zobrazí v sekci "Nevyřízené platby".
</p>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
{users.length === 0 ? (
<Alert variant="info">
V tento den nemá žádný uživatel zvolenou možnost stravování.
</Alert>
) : (
<Table striped bordered hover responsive>
<thead>
<tr>
<th style={{ width: '50px' }}></th>
<th>Uživatel</th>
<th>Účel platby</th>
<th style={{ width: '120px' }}>Částka (Kč)</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.login} className={user.selected ? '' : 'text-muted'}>
<td className="text-center">
<Form.Check
type="checkbox"
checked={user.selected}
onChange={e => handleCheckboxChange(user.login, e.target.checked)}
/>
</td>
<td>{user.login}</td>
<td>
<Form.Control
type="text"
placeholder="např. Pizza prosciutto"
value={user.purpose}
onChange={e => handlePurposeChange(user.login, e.target.value)}
disabled={!user.selected}
size="sm"
onKeyDown={e => e.stopPropagation()}
/>
</td>
<td>
<Form.Control
type="text"
placeholder="0.00"
value={user.amount}
onChange={e => handleAmountChange(user.login, e.target.value)}
disabled={!user.selected}
size="sm"
onKeyDown={e => e.stopPropagation()}
/>
</td>
</tr>
))}
</tbody>
</Table>
)}
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<span className="me-auto text-muted">
Vybráno: {selectedCount} / {users.length}
</span>
<Button variant="secondary" onClick={handleClose} disabled={loading}>
Storno
</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={loading || selectedCount === 0}
>
{loading ? 'Generuji...' : 'Generovat'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
)}
</Modal.Footer>
</Modal>
);
}
@@ -1,306 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { generateQr, LunchChoice, LocationLunchChoicesMap, RestaurantDayMenu, QrRecipient } from "../../../../types";
import { parsePriceCzk } from "../../utils/parsePrice";
type DinerEntry = {
login: string;
selectedFoods: number[];
baseAmount: number;
baseAmountParseFailed: boolean;
surchargeText: string;
surchargeAmount: string;
included: boolean;
};
type Props = {
isOpen: boolean;
onClose: () => void;
locationKey: LunchChoice;
locationName: string;
locationChoices: LocationLunchChoicesMap;
menu: RestaurantDayMenu | undefined;
payerLogin: string;
bankAccount: string;
bankAccountHolder: string;
};
function sanitizeAmount(value: string): string {
return value.replace(/[^0-9.,]/g, '').replace(',', '.');
}
function parseAmount(s: string): number | null {
if (!s || s.trim().length === 0) return null;
const n = parseFloat(s);
if (isNaN(n) || n < 0) return null;
return Math.round(n * 100);
}
export default function PayForAllModal({ isOpen, onClose, locationName, locationChoices, menu, payerLogin, bankAccount, bankAccountHolder }: Readonly<Props>) {
const [diners, setDiners] = useState<DinerEntry[]>([]);
const [tipTotal, setTipTotal] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const hasMenu = !!menu;
useEffect(() => {
if (!isOpen) return;
const entries: DinerEntry[] = Object.entries(locationChoices).map(([login, choice]) => {
const selectedFoods = choice.selectedFoods ?? [];
// Cena, kterou si strávník zadal sám, má přednost před cenami z menu
let baseAmount = choice.price ?? 0;
let baseAmountParseFailed = false;
if (menu && choice.price == null) {
for (const idx of selectedFoods) {
const priceKc = parsePriceCzk(menu.food?.[idx]?.price);
if (priceKc === null) {
baseAmountParseFailed = true;
} else {
baseAmount += Math.round(priceKc * 100);
}
}
}
return {
login,
selectedFoods,
baseAmount,
baseAmountParseFailed,
surchargeText: '',
surchargeAmount: '',
included: login !== payerLogin,
};
});
setDiners(entries);
setTipTotal('');
setError(null);
setSuccess(false);
}, [isOpen, locationChoices, menu, payerLogin]);
const includedDiners = diners.filter(d => d.included && d.login !== payerLogin);
const tipPerPerson = (() => {
if (includedDiners.length === 0) return 0;
const tip = parseAmount(tipTotal);
if (tip === null || tip === 0) return 0;
const totalPeople = includedDiners.length + 1;
return Math.round(tip / totalPeople);
})();
const payerTipShare = (() => {
const tip = parseAmount(tipTotal);
if (!tip) return 0;
return tip - tipPerPerson * includedDiners.length;
})();
const getTotal = (d: DinerEntry): number => {
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
const tip = d.login === payerLogin ? payerTipShare : tipPerPerson;
return d.baseAmount + surcharge + tip;
};
const handleInclude = useCallback((login: string, checked: boolean) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, included: checked } : d));
}, []);
const handleSurchargeText = useCallback((login: string, value: string) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeText: value } : d));
}, []);
const handleSurchargeAmount = useCallback((login: string, value: string) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeAmount: sanitizeAmount(value) } : d));
}, []);
const handleGenerate = async () => {
setError(null);
const recipients: QrRecipient[] = [];
for (const d of diners) {
if (!d.included || d.login === payerLogin) continue;
const total = getTotal(d);
if (total <= 0) {
setError(`Celková částka pro ${d.login} musí být kladná`);
return;
}
const foods = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
const purposeBase = `Oběd ${locationName}${foods ? ` — ${foods}` : ''}`;
recipients.push({
login: d.login,
purpose: purposeBase.substring(0, 60),
amount: total,
});
}
if (recipients.length === 0) {
setError("Nebyl vybrán žádný příjemce");
return;
}
setLoading(true);
try {
const response = await generateQr({
body: { recipients, bankAccount, bankAccountHolder },
});
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
} else {
setSuccess(true);
setTimeout(() => onClose(), 2000);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při generování QR kódů');
} finally {
setLoading(false);
}
};
const anyParseFailed = diners.some(d => d.baseAmountParseFailed && d.included);
return (
<Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Zaplatit za všechny — {locationName}</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{success ? (
<Alert variant="success">
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci „Nevyřízené platby".
</Alert>
) : (
<>
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a společné poplatky, poté vygenerujte QR kódy pro ostatní.</p>
{!hasMenu && !diners.some(d => d.baseAmount > 0) && (
<Alert variant="info">
Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně.
</Alert>
)}
{anyParseFailed && (
<Alert variant="warning">
U některých jídel se nepodařilo načíst cenu — doplňte ji ručně v sloupci Příplatek.
</Alert>
)}
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
<Table striped bordered hover responsive size="sm">
<thead>
<tr>
<th style={{ width: 40 }}></th>
<th>Strávník</th>
<th>Jídla</th>
<th style={{ width: 220 }}>Příplatek</th>
<th style={{ width: 90 }}>Poplatek</th>
<th style={{ width: 90 }}>Celkem</th>
</tr>
</thead>
<tbody>
{diners.map(d => {
const isPayer = d.login === payerLogin;
const foodNames = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
const total = getTotal(d);
return (
<tr key={d.login} className={!d.included && !isPayer ? 'text-muted' : ''}>
<td className="text-center">
{isPayer ? (
<small className="text-muted">plátce</small>
) : (
<Form.Check
type="checkbox"
checked={d.included}
onChange={e => handleInclude(d.login, e.target.checked)}
/>
)}
</td>
<td><strong>{d.login}</strong></td>
<td>
<small>
{foodNames || <span className="text-muted">—</span>}
{d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount / 100} Kč)</span>}
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
</small>
</td>
<td>
<div className="d-flex gap-1">
<Form.Control
type="text"
placeholder="popis"
value={d.surchargeText}
onChange={e => handleSurchargeText(d.login, e.target.value)}
disabled={!isPayer && !d.included}
size="sm"
onKeyDown={e => e.stopPropagation()}
/>
<Form.Control
type="text"
placeholder="Kč"
value={d.surchargeAmount}
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
disabled={!isPayer && !d.included}
size="sm"
style={{ width: 70 }}
onKeyDown={e => e.stopPropagation()}
/>
</div>
</td>
<td className="text-end">
{(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s / 100} Kč` : '—'; })()}
</td>
<td className="text-end fw-bold">
{`${total / 100} Kč`}
</td>
</tr>
);
})}
</tbody>
</Table>
<div className="d-flex align-items-center gap-2 mt-2">
<label className="mb-0 text-nowrap">Poplatky celkem (Kč):</label>
<Form.Control
type="text"
placeholder="0"
value={tipTotal}
onChange={e => setTipTotal(sanitizeAmount(e.target.value))}
size="sm"
style={{ width: 100 }}
onKeyDown={e => e.stopPropagation()}
/>
<small className="text-muted">
{includedDiners.length > 0 && tipPerPerson > 0
? `(${tipPerPerson / 100} Kč / osoba)`
: ''}
</small>
</div>
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<span className="me-auto text-muted">
Příjemci: {includedDiners.length}
</span>
<Button variant="secondary" onClick={onClose} disabled={loading}>
Storno
</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={loading || includedDiners.length === 0}
>
{loading ? 'Generuji...' : 'Vygenerovat QR'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
)}
</Modal.Footer>
</Modal>
);
}
@@ -1,228 +0,0 @@
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>
);
}
@@ -15,12 +15,12 @@ export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose,
const priceRef = useRef<HTMLInputElement>(null); const priceRef = useRef<HTMLInputElement>(null);
const doSubmit = () => { const doSubmit = () => {
onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100)); onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value ?? "0"));
} }
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, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100)); onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value ?? "0"));
} }
} }
@@ -36,13 +36,13 @@ export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<Props
// 1. pizza // 1. pizza
if (diameter1Ref.current?.value) { if (diameter1Ref.current?.value) {
const diameter1 = Number.parseInt(diameter1Ref.current?.value); const diameter1 = parseInt(diameter1Ref.current?.value);
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 = Number.parseInt(price1Ref.current?.value); const price1 = parseInt(price1Ref.current?.value);
if (price1) { if (price1) {
r.pizza1.pricePerM = price1 / r.pizza1.area; r.pizza1.pricePerM = price1 / r.pizza1.area;
} else { } else {
@@ -56,13 +56,13 @@ export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<Props
// 2. pizza // 2. pizza
if (diameter2Ref.current?.value) { if (diameter2Ref.current?.value) {
const diameter2 = Number.parseInt(diameter2Ref.current?.value); const diameter2 = parseInt(diameter2Ref.current?.value);
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 = Number.parseInt(price2Ref.current?.value); const price2 = parseInt(price2Ref.current?.value);
if (price2) { if (price2) {
r.pizza2.pricePerM = price2 / r.pizza2.area; r.pizza2.pricePerM = price2 / r.pizza2.area;
} else { } else {
@@ -77,8 +77,8 @@ export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<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 = Math.max(r.pizza1.pricePerM, r.pizza2.pricePerM); const bigger = r.pizza1.pricePerM > r.pizza2.pricePerM ? r.pizza1.pricePerM : r.pizza2.pricePerM;
const smaller = Math.min(r.pizza1.pricePerM, r.pizza2.pricePerM); const smaller = r.pizza1.pricePerM < r.pizza2.pricePerM ? 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 {
@@ -1,74 +0,0 @@
import { useEffect, useState } from "react";
import { Modal, Button, Form } from "react-bootstrap";
type Props = {
isOpen: boolean,
onClose: () => void,
/** Uložená cena v haléřích */
price?: number,
/** Cena v haléřích spočtená z vybraných jídel v menu, nabídne se, pokud cena ještě není uložená */
suggestedPrice?: number,
/** Cena v haléřích, undefined cenu odstraní */
onSave: (price?: number) => void,
}
const toCrownsText = (amount?: number) => amount ? String(amount / 100).replace('.', ',') : '';
/** Modální dialog pro zadání ceny oběda, která se promítne do přehledu stravování. */
const PriceModal = ({ isOpen, onClose, price, suggestedPrice, onSave }: Readonly<Props>) => {
const [value, setValue] = useState('');
useEffect(() => {
if (isOpen) {
setValue(toCrownsText(price ?? suggestedPrice));
}
}, [isOpen, price, suggestedPrice]);
const parsed = value.trim().length ? Number.parseFloat(value.replace(',', '.')) : undefined;
const invalid = parsed != null && (Number.isNaN(parsed) || parsed < 0);
const save = () => {
if (invalid) return;
onSave(parsed == null ? undefined : Math.round(parsed * 100));
}
return <Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>Cena oběda</Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="text-muted small">Cena se promítne do vašeho měsíčního přehledu stravování (export).</p>
<div className="d-flex align-items-center gap-2">
<Form.Control
autoFocus
type="text"
inputMode="decimal"
placeholder="např. 145"
value={value}
isInvalid={invalid}
onChange={e => setValue(e.target.value.replace(/[^0-9.,]/g, ''))}
onKeyDown={event => {
if (event.key === 'Enter') {
save();
}
event.stopPropagation();
}}
/>
<span>Kč</span>
</div>
</Modal.Body>
<Modal.Footer>
{price != null && <Button variant="outline-danger" className="me-auto" onClick={() => onSave(undefined)}>
Odstranit cenu
</Button>}
<Button variant="secondary" onClick={() => onClose()}>
Storno
</Button>
<Button variant="primary" onClick={() => save()} disabled={invalid}>
Uložit
</Button>
</Modal.Footer>
</Modal>
}
export default PriceModal;
@@ -1,5 +1,5 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { Modal, Button, Alert, Form } from "react-bootstrap"; import { Modal, Button, Alert } from "react-bootstrap";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@@ -30,6 +30,7 @@ export default function RefreshMenuModal({ isOpen, onClose }: Readonly<Props>) {
if (res.ok) { if (res.ok) {
setRefreshMessage({ type: 'success', text: 'Uspesny fetch' }); setRefreshMessage({ type: 'success', text: 'Uspesny fetch' });
if (refreshPassRef.current) { if (refreshPassRef.current) {
// Clean hesla xd
refreshPassRef.current.value = ''; refreshPassRef.current.value = '';
} }
} else { } else {
@@ -49,7 +50,7 @@ export default function RefreshMenuModal({ isOpen, onClose }: Readonly<Props>) {
}; };
return ( return (
<Modal show={isOpen} onHide={handleClose}> <Modal show={isOpen} onHide={handleClose} size="lg">
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title><h2>Přenačtení menu</h2></Modal.Title> <Modal.Title><h2>Přenačtení menu</h2></Modal.Title>
</Modal.Header> </Modal.Header>
@@ -62,29 +63,36 @@ export default function RefreshMenuModal({ isOpen, onClose }: Readonly<Props>) {
</Alert> </Alert>
)} )}
<Form.Group className="mb-3"> <div className="mb-3">
<Form.Label>Heslo</Form.Label> Heslo: <input
<Form.Control
ref={refreshPassRef} ref={refreshPassRef}
type="password" type="password"
placeholder="Zadejte heslo" placeholder="Zadejte heslo"
className="form-control d-inline-block"
style={{ width: 'auto', marginLeft: '10px' }}
onKeyDown={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}
/> />
</Form.Group> </div>
<Form.Group className="mb-3"> <div className="mb-3">
<Form.Label>Typ refreshe</Form.Label> Typ refreshe: <select
<Form.Select ref={refreshTypeRef} defaultValue="week"> ref={refreshTypeRef}
className="form-select d-inline-block"
style={{ width: 'auto', marginLeft: '10px' }}
defaultValue="week"
>
<option value="week">Týden</option> <option value="week">Týden</option>
<option value="day">Den</option> <option value="day">Den</option>
</Form.Select> </select>
</Form.Group> </div>
<Button <Button
variant="info"
onClick={handleRefresh} onClick={handleRefresh}
disabled={refreshLoading} disabled={refreshLoading}
className="mb-3"
> >
{refreshLoading ? 'Načítám...' : 'Obnovit menu'} {refreshLoading ? 'Refreshing...' : 'Refresh'}
</Button> </Button>
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
+28 -295
View File
@@ -1,309 +1,42 @@
import { useEffect, useRef, useState } from "react"; import { useRef } from "react";
import { Modal, Button, Form, Row, Col } from "react-bootstrap" import { Modal, Button } from "react-bootstrap"
import { useSettings, ThemePreference, StyleTheme, STYLE_THEMES } from "../../context/settings"; import { useSettings } 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, themePreference?: ThemePreference) => void, onSave: (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => void,
} }
/** Modální dialog pro uživatelská nastavení. */ /** Modální dialog pro uživatelská nastavení. */
export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<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);
const reminderTimeRef = useRef<HTMLInputElement>(null); return <Modal show={isOpen} onHide={onClose} size="lg">
const boltDeliveredRef = useRef<HTMLInputElement>(null); <Modal.Header closeButton>
const ntfyTopicRef = useRef<HTMLInputElement>(null); <Modal.Title><h2>Nastavení</h2></Modal.Title>
const discordWebhookRef = useRef<HTMLInputElement>(null); </Modal.Header>
const teamsWebhookRef = useRef<HTMLInputElement>(null); <Modal.Body>
const [notifSettings, setNotifSettings] = useState<NotificationSettings>({}); <h4>Obecné</h4>
const [enabledEvents, setEnabledEvents] = useState<UdalostEnum[]>([]); <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" }}>
<input ref={hideSoupsRef} type="checkbox" defaultChecked={settings?.hideSoups} /> Skrýt polévky
useEffect(() => { </span>
if (isOpen && auth?.login) { <hr />
getNotificationSettings().then(response => { <h4>Bankovní účet</h4>
if (response.data) { <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>
setNotifSettings(response.data); Číslo účtu: <input className="mb-3" ref={bankAccountRef} type="text" placeholder="123456-1234567890/1234" defaultValue={settings?.bankAccount} onKeyDown={e => e.stopPropagation()} /> <br />
setEnabledEvents(response.data.enabledEvents ?? []); Název příjemce (jméno majitele účtu): <input ref={nameRef} type="text" placeholder="Jan Novák" defaultValue={settings?.holderName} onKeyDown={e => e.stopPropagation()} />
} </Modal.Body>
}).catch(() => {}); <Modal.Footer>
} <Button variant="secondary" onClick={onClose}>
}, [isOpen, auth?.login]); Storno
</Button>
const toggleEvent = (event: UdalostEnum) => { <Button variant="primary" onClick={() => onSave(bankAccountRef.current?.value, nameRef.current?.value, hideSoupsRef.current?.checked)}>
setEnabledEvents(prev => Uložit
prev.includes(event) ? prev.filter(e => e !== event) : [...prev, event] </Button>
); </Modal.Footer>
}; </Modal>
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.Title><h2>Nastavení</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{/* Na širších obrazovkách rozdělení do dvou sloupců, na užších pod sebou */}
<Row className="gx-5">
<Col lg={6}>
<h4>Vzhled</h4>
<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 />
<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>
<p>
Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.
</p>
<Form.Group className="mb-3">
<Form.Label>Číslo účtu</Form.Label>
<Form.Control
ref={bankAccountRef}
type="text"
placeholder="123456-1234567890/1234"
defaultValue={settings?.bankAccount}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Název příjemce</Form.Label>
<Form.Control
ref={nameRef}
type="text"
placeholder="Jan Novák"
defaultValue={settings?.holderName}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
Jméno majitele účtu pro QR platbu.
</Form.Text>
</Form.Group>
</Col>
<Col lg={6}>
<hr className="d-lg-none" />
<h4>Notifikace</h4>
<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.Footer>
<Button variant="secondary" onClick={onClose}>
Storno
</Button>
<Button onClick={handleSave}>
Uložit
</Button>
</Modal.Footer>
</Modal>
);
} }
File diff suppressed because it is too large. Load diff
@@ -1,407 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Modal, Button, ButtonGroup } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark, faRotateLeft } from "@fortawesome/free-solid-svg-icons";
import { Restaurant, RestaurantDayMenuMap, Store, StoreMenu, StoreMenuSource, getStoreMenu, listStores } from "../../../../types";
import { getLunchChoiceName } from "../../enums";
import WheelConfetti from "../WheelConfetti";
import { useSettings } from "../../context/settings";
import "./SpinWheelModal.scss";
/** Výsledek kola štěstí — buď jídlo z dnešního menu podniku, nebo jídlo z rozvozu. */
export type WheelPick =
| { kind: 'menu', restaurant: Restaurant, foodIndex?: number, foodName?: string }
| { kind: 'store', store: string, dish?: string };
type Source = 'all' | 'menu' | 'store';
type Place = {
id: string,
label: string,
kind: 'menu' | 'store',
restaurant?: Restaurant,
/** Jídla dnešního menu; u rozvozu se nabídka dotahuje až při druhém kole. */
dishes: Dish[],
};
type Dish = { name: string, index?: number, price?: string };
const SOURCE_LABELS: Record<StoreMenuSource, string> = {
bolt: 'Nabídka z Bolt Food',
wolt: 'Nabídka z Wolt',
history: 'Nabídku se nepodařilo stáhnout — jídla z historie objednávek',
none: 'Nabídku se nepodařilo stáhnout',
};
type Props = {
isOpen: boolean,
onClose: () => void,
menus?: RestaurantDayMenuMap,
onPick: (pick: WheelPick) => Promise<void>,
};
const SPIN_MS = 4800;
const WHEEL_SIZE = 360;
const RADIUS = WHEEL_SIZE / 2 - 6;
const SEGMENT_COLORS = 6;
const MAX_LABELED_SEGMENTS = 40;
/** Točit má smysl až od dvou možností — jediná zbylá se vybere rovnou. */
const MIN_SPIN_ITEMS = 2;
/** Kryptograficky náhodný index v rozsahu 0..n-1 (bez modulo zkreslení to u malých n nevadí). */
function randomIndex(n: number): number {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return buf[0] % n;
}
function truncate(text: string, max: number): string {
return text.length > max ? text.slice(0, max - 1) + '…' : text;
}
/** Bod na kružnici; úhel ve stupních měřený od „12 hodin" po směru hodinových ručiček. */
function polar(angle: number, r: number) {
const rad = (angle - 90) * Math.PI / 180;
return { x: WHEEL_SIZE / 2 + r * Math.cos(rad), y: WHEEL_SIZE / 2 + r * Math.sin(rad) };
}
function segmentPath(start: number, end: number): string {
const c = WHEEL_SIZE / 2;
if (end - start >= 360) {
return `M ${c} ${c - RADIUS} A ${RADIUS} ${RADIUS} 0 1 1 ${c - 0.01} ${c - RADIUS} Z`;
}
const a = polar(start, RADIUS);
const b = polar(end, RADIUS);
const large = end - start > 180 ? 1 : 0;
return `M ${c} ${c} L ${a.x} ${a.y} A ${RADIUS} ${RADIUS} 0 ${large} 1 ${b.x} ${b.y} Z`;
}
type WheelProps = {
labels: string[],
rotation: number,
spinning: boolean,
onSpinEnd: () => void,
};
function Wheel({ labels, rotation, spinning, onSpinEnd }: Readonly<WheelProps>) {
const n = labels.length;
const angle = 360 / Math.max(n, 1);
const fontSize = n > 24 ? 8.5 : n > 14 ? 10 : n > 8 ? 12 : 14;
const maxChars = n > 24 ? 26 : n > 14 ? 24 : 20;
// U velkých nabídek jsou popisky nečitelné — názvy jsou v seznamu vedle kola
const showLabels = n <= MAX_LABELED_SEGMENTS;
return (
<div className="spin-wheel">
<div className="spin-wheel-pointer" aria-hidden />
<svg
viewBox={`0 0 ${WHEEL_SIZE} ${WHEEL_SIZE}`}
className="spin-wheel-svg"
style={{
transform: `rotate(${rotation}deg)`,
transition: spinning ? `transform ${SPIN_MS}ms cubic-bezier(0.12, 0.72, 0.16, 1)` : 'none',
}}
onTransitionEnd={e => { if (e.propertyName === 'transform') onSpinEnd(); }}
role="img"
aria-label={`Kolo s ${n} možnostmi`}
>
<circle cx={WHEEL_SIZE / 2} cy={WHEEL_SIZE / 2} r={RADIUS + 4} className="spin-wheel-rim" />
{labels.map((label, i) => {
const start = i * angle;
const mid = start + angle / 2;
return (
<g key={`${i}-${label}`}>
<path d={segmentPath(start, start + angle)} className={`spin-seg spin-seg-${i % SEGMENT_COLORS}`} />
{showLabels && <text
x={WHEEL_SIZE / 2 + 34}
y={WHEEL_SIZE / 2}
transform={`rotate(${mid - 90} ${WHEEL_SIZE / 2} ${WHEEL_SIZE / 2})`}
dominantBaseline="middle"
fontSize={fontSize}
className="spin-seg-label"
>
{truncate(label, maxChars)}
</text>}
</g>
);
})}
<circle cx={WHEEL_SIZE / 2} cy={WHEEL_SIZE / 2} r={26} className="spin-wheel-hub" />
</svg>
</div>
);
}
/**
* Kolo štěstí pro nerozhodnuté: první kolo vybere podnik (dnešní menu nebo rozvoz),
* druhé kolo jídlo z něj (bez polévek). Možnosti lze z kola průběžně vyřazovat.
*/
export default function SpinWheelModal({ isOpen, onClose, menus, onPick }: Readonly<Props>) {
const [stores, setStores] = useState<Store[]>();
const [storeMenus, setStoreMenus] = useState<Record<string, StoreMenu | 'loading' | 'error'>>({});
const [source, setSource] = useState<Source>('all');
const [excludedPlaces, setExcludedPlaces] = useState<Set<string>>(new Set());
const [excludedDishes, setExcludedDishes] = useState<Set<string>>(new Set());
const [place, setPlace] = useState<Place>();
const [dish, setDish] = useState<Dish>();
const [phase, setPhase] = useState<'place' | 'dish'>('place');
const [rotation, setRotation] = useState(0);
const [spinning, setSpinning] = useState(false);
const [saving, setSaving] = useState(false);
const [respinRequested, setRespinRequested] = useState(false);
const theme = useSettings()?.styleTheme ?? 'simple';
const [confetti, setConfetti] = useState<{ key: number, big: boolean }>({ key: 0, big: false });
const pendingResult = useRef<number>(undefined);
useEffect(() => {
if (isOpen && !stores) {
listStores().then(r => setStores(r.data ?? [])).catch(() => setStores([]));
}
}, [isOpen, stores]);
const allPlaces = useMemo<Place[]>(() => {
const result: Place[] = [];
for (const key of Object.keys(Restaurant) as Restaurant[]) {
const menu = menus?.[key];
if (!menu || menu.closed || !menu.food?.length) continue;
const dishes = menu.food
.map((f, index) => ({ name: f.name, index, price: f.price, isSoup: f.isSoup }))
.filter(f => !f.isSoup)
.map(({ name, index, price }) => ({ name, index, price }));
result.push({ id: `menu:${key}`, label: getLunchChoiceName(key), kind: 'menu', restaurant: key, dishes });
}
for (const s of stores ?? []) {
result.push({ id: `store:${s.name}`, label: s.name, kind: 'store', dishes: [] });
}
return result;
}, [menus, stores]);
const storeMenu = place?.kind === 'store' ? storeMenus[place.label] : undefined;
const allDishes = useMemo<Dish[]>(() => {
if (place?.kind !== 'store') return place?.dishes ?? [];
if (!storeMenu || typeof storeMenu === 'string') return [];
return storeMenu.dishes
.filter(d => d.isMain !== false)
.map(d => ({ name: d.name, price: d.price != null ? `${d.price} Kč` : undefined }));
}, [place, storeMenu]);
const places = useMemo(
() => allPlaces.filter(p => (source === 'all' || p.kind === source) && !excludedPlaces.has(p.id)),
[allPlaces, source, excludedPlaces],
);
const dishes = useMemo(
() => allDishes.filter(d => !excludedDishes.has(`${place?.id}|${d.name}`)),
[place, allDishes, excludedDishes],
);
const wheelItems = phase === 'place' ? places.map(p => p.label) : dishes.map(d => d.name);
const result = phase === 'place' ? place : dish;
const resetWheel = () => {
setSpinning(false);
setRotation(r => r % 360);
};
// Zbyla jediná možnost — není co losovat, vybere se rovnou (lze pokračovat dál / převzít)
const onlyOne = wheelItems.length === 1;
useEffect(() => {
if (spinning || !onlyOne) return;
if (phase === 'place' && place?.id !== places[0].id) setPlace(places[0]);
else if (phase === 'dish' && dish?.name !== dishes[0].name) setDish(dishes[0]);
}, [onlyOne, phase, places, dishes, spinning, place?.id, dish?.name]);
const spin = () => {
const n = wheelItems.length;
if (n < MIN_SPIN_ITEMS || spinning) return;
const index = randomIndex(n);
const angle = 360 / n;
const jitter = (Math.random() - 0.5) * angle * 0.6;
const target = 360 - (index * angle + angle / 2) + jitter;
const turns = 5 + randomIndex(3);
pendingResult.current = index;
if (phase === 'place') setPlace(undefined); else setDish(undefined);
setSpinning(true);
setRotation(r => r - (r % 360) + turns * 360 + ((target % 360) + 360) % 360);
};
const handleSpinEnd = () => {
const index = pendingResult.current;
if (index == null) return;
pendingResult.current = undefined;
setSpinning(false);
if (phase === 'place') setPlace(places[index]);
else setDish(dishes[index]);
setConfetti(c => ({ key: c.key + 1, big: phase === 'dish' }));
};
const goToDishes = () => {
if (place?.kind === 'store' && (!storeMenus[place.label] || storeMenus[place.label] === 'error')) {
const name = place.label;
setStoreMenus(prev => ({ ...prev, [name]: 'loading' }));
getStoreMenu({ query: { name } })
.then(r => setStoreMenus(prev => ({ ...prev, [name]: r.data ?? 'error' })))
.catch(() => setStoreMenus(prev => ({ ...prev, [name]: 'error' })));
}
setDish(undefined);
setPhase('dish');
resetWheel();
};
const backToPlaces = () => {
setDish(undefined);
setPlace(undefined);
setPhase('place');
resetWheel();
};
const removeOption = (i: number) => {
if (spinning) return;
if (phase === 'place') {
const p = places[i];
setExcludedPlaces(prev => new Set(prev).add(p.id));
if (place?.id === p.id) setPlace(undefined);
} else if (place) {
const d = dishes[i];
setExcludedDishes(prev => new Set(prev).add(`${place.id}|${d.name}`));
if (dish?.name === d.name) setDish(undefined);
}
};
const removeResultAndRespin = () => {
const i = phase === 'place' ? places.findIndex(p => p.id === place?.id) : dishes.findIndex(d => d.name === dish?.name);
if (i < 0) return;
removeOption(i);
setRespinRequested(true);
};
// Po vyřazení se kolo překreslí s menším počtem výsečí — roztočíme ho až nad novými daty
useEffect(() => {
if (respinRequested) {
setRespinRequested(false);
spin();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [respinRequested]);
const restoreAll = () => {
if (phase === 'place') setExcludedPlaces(new Set());
else if (place) setExcludedDishes(prev => new Set([...prev].filter(k => !k.startsWith(`${place.id}|`))));
};
const accept = async () => {
if (!place) return;
setSaving(true);
try {
if (place.kind === 'menu') {
await onPick({ kind: 'menu', restaurant: place.restaurant!, foodIndex: dish?.index, foodName: dish?.name });
} else {
await onPick({ kind: 'store', store: place.label, dish: dish?.name });
}
onClose();
} finally {
setSaving(false);
}
};
const excludedCount = phase === 'place'
? allPlaces.filter(p => (source === 'all' || p.kind === source) && excludedPlaces.has(p.id)).length
: allDishes.length - dishes.length;
const menuLoading = phase === 'dish' && storeMenu === 'loading';
return (
<Modal show={isOpen} onHide={onClose} size="lg" className={`spin-wheel-modal spin-theme-${theme}`}>
<Modal.Header closeButton>
<Modal.Title>
<h2>{phase === 'place' ? 'Kolo štěstí: kam?' : `Kolo štěstí: co v ${place?.label}?`}</h2>
</Modal.Title>
</Modal.Header>
<WheelConfetti theme={theme} burstKey={confetti.key} big={confetti.big} />
<Modal.Body>
<div className="spin-steps">
<span className={phase === 'place' ? 'active' : 'done'}>1. Podnik</span>
<span className={phase === 'dish' ? 'active' : ''}>2. Jídlo</span>
{phase === 'dish' && storeMenu && typeof storeMenu !== 'string' && (
<small className="spin-source-info">{SOURCE_LABELS[storeMenu.source]}</small>
)}
</div>
{phase === 'place' && (
<ButtonGroup className="spin-source mb-3" aria-label="Zdroj podniků">
{([['all', 'Vše'], ['menu', 'Dnešní menu'], ['store', 'Rozvoz']] as [Source, string][]).map(([value, label]) => (
<Button key={value} variant={source === value ? 'primary' : 'outline-primary'} disabled={spinning}
onClick={() => { setSource(value); setPlace(undefined); }}>
{label}
</Button>
))}
</ButtonGroup>
)}
<div className="spin-layout">
<div className="spin-wheel-column">
{(phase === 'place' && !stores) || menuLoading ? (
<div className="spin-empty">{menuLoading ? 'Stahuji nabídku podniku…' : 'Načítám podniky…'}</div>
) : wheelItems.length === 0 ? (
<div className="spin-empty">
{phase === 'dish'
? 'Tady nemáme žádná jídla k točení — vyber si na místě.'
: 'Na kole nezbyla žádná možnost.'}
</div>
) : (
<Wheel labels={wheelItems} rotation={rotation} spinning={spinning} onSpinEnd={handleSpinEnd} />
)}
<div className={`spin-result${result ? ' visible' : ''}`} aria-live="polite">
{result && (phase === 'place'
? <>{onlyOne ? 'Zbývá jediná možnost' : 'Padlo'}: <strong>{place!.label}</strong> <small>({place!.kind === 'menu' ? 'dnešní menu' : 'rozvoz'})</small></>
: <>{onlyOne ? 'Zbývá jediné jídlo' : 'Padlo'}: <strong>{dish!.name}</strong>{dish!.price && <small> ({dish!.price})</small>}</>)}
</div>
<div className="spin-actions">
<Button onClick={spin} disabled={spinning || wheelItems.length < MIN_SPIN_ITEMS} className="spin-button"
title={wheelItems.length < MIN_SPIN_ITEMS ? 'Na kole musí být aspoň 2 možnosti' : undefined}>
{spinning ? 'Točí se…' : result && !onlyOne ? 'Točit znovu' : 'Roztočit!'}
</Button>
{result && !spinning && wheelItems.length > 1 && (
<Button variant="outline-primary" onClick={removeResultAndRespin}>Vyřadit a točit znovu</Button>
)}
</div>
{onlyOne && (
<small className="text-muted">Na kole musí být aspoň 2 možnosti — vrať nějaké vyřazené, nebo pokračuj s touto.</small>
)}
</div>
<div className="spin-options">
<div className="spin-options-header">
<span>Na kole ({wheelItems.length})</span>
{excludedCount > 0 && (
<button className="spin-restore" onClick={restoreAll} disabled={spinning} title="Vrátit vyřazené možnosti">
<FontAwesomeIcon icon={faRotateLeft} /> Vrátit {excludedCount}
</button>
)}
</div>
<ul>
{wheelItems.map((label, i) => (
<li key={`${i}-${label}`}>
<span className={`spin-dot spin-seg-${i % SEGMENT_COLORS}`} />
<span className="spin-option-label" title={label}>{label}</span>
{phase === 'place'
? <small className="spin-option-kind">{places[i].kind === 'menu' ? 'menu' : 'rozvoz'}</small>
: dishes[i].price && <small className="spin-option-kind">{dishes[i].price}</small>}
<button className="spin-remove" onClick={() => removeOption(i)} disabled={spinning}
title={`Vyřadit z kola: ${label}`} aria-label={`Vyřadit ${label}`}>
<FontAwesomeIcon icon={faXmark} />
</button>
</li>
))}
</ul>
</div>
</div>
</Modal.Body>
<Modal.Footer>
{phase === 'dish' && <Button variant="secondary" onClick={backToPlaces} disabled={spinning}>Zpět na podniky</Button>}
{phase === 'place' && place && !spinning && (
<Button variant="outline-primary" onClick={accept} disabled={saving}>Beru jen podnik</Button>
)}
{phase === 'place' && place && !spinning && (
<Button onClick={goToDishes}>Dál: točit jídlo</Button>
)}
{phase === 'dish' && !spinning && (dish || dishes.length === 0) && (
<Button onClick={accept} disabled={saving}>{dish ? 'Beru to!' : 'Beru podnik'}</Button>
)}
</Modal.Footer>
</Modal>
);
}
@@ -1,386 +0,0 @@
import { useEffect, useState } from "react";
import { Modal, Button, Form, ListGroup, Alert, OverlayTrigger, Tooltip } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { faArrowsRotate, faPen, faPlus, faUpRightFromSquare, faXmark } from "@fortawesome/free-solid-svg-icons";
import { addStore, deleteStore, updateStore, getStoreMenuStatus, refreshStoreMenu, Store, StoreMenuProvider, StoreScrapeInfo, StoreScrapeStatus } from "../../../../types";
import { getHumanDateTime } from "../../Utils";
import PriceHistory from "../PriceHistory";
import { useAppConfig } from "../../hooks/useGroupLimits";
import { getStoreUrls, storeUrlLabel } from "../../utils/storeUrls";
type Props = {
isOpen: boolean;
onClose: () => void;
stores: Store[];
onStoresChanged: (stores: Store[]) => void;
};
/** Rozpracovaná editace obchodu — původní název slouží k jeho identifikaci na serveru. */
type EditState = {
originalName: string;
name: string;
urls: string[];
};
const PROVIDER_LABELS: Record<StoreMenuProvider, string> = { bolt: 'Bolt Food', wolt: 'Wolt' };
/** Krátký popis stavu stahování nabídky od jedné služby. */
function describeScrape(p: StoreScrapeStatus): { text: string, className: string, title?: string } {
const when = (iso?: string) => iso ? getHumanDateTime(new Date(iso)) : '';
switch (p.state) {
case 'ok':
return { text: `${p.dishCount} hlavních jídel (${when(p.lastSuccessAt)})${p.lastError ? ` · poslední obnovení selhalo: ${p.lastError}` : ''}`, className: 'text-success' };
case 'failed':
return { text: `selhalo ${p.failures}× (${when(p.lastAttemptAt)}): ${p.lastError ?? ''} · zkusí se znovu`, className: 'text-warning' };
case 'blocked':
return { text: `zablokováno po ${p.failures} neúspěších (${when(p.lastAttemptAt)}): ${p.lastError ?? ''}`, className: 'text-danger' };
default:
return { text: 'nestaženo', className: 'text-muted', title: 'Stáhne se automaticky, až bude nabídka potřeba v kole štěstí (a žádná jiná služba ji nemá)' };
}
}
/** Vstupní pole pro URL nabídek — vždy alespoň jedno prázdné, aby bylo kam psát. */
function toUrlInputs(urls: string[]): string[] {
return urls.length > 0 ? urls : [''];
}
export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly<Props>) {
const [newName, setNewName] = useState('');
// Jeden podnik může být dostupný přes více dovozových služeb — proto seznam URL
const [newUrls, setNewUrls] = useState<string[]>(['']);
const [edit, setEdit] = useState<EditState | null>(null);
const [heslo, setHeslo] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Server bez admin hesla — správa (přidání/úprava/odebrání/obnovení) nejde, zbývá jen přehled.
// undefined = konfigurace nenačtena, chováme se jako dřív (správa zapnutá).
const adminEnabled = useAppConfig().storeAdmin !== false;
const [scrapeInfo, setScrapeInfo] = useState<Record<string, StoreScrapeInfo>>({});
const [refreshing, setRefreshing] = useState<string | null>(null);
// Rozbalená historie cen („obchod|služba")
const [historyOpen, setHistoryOpen] = useState<string | null>(null);
// Stav stahování nabídek (Bolt Food/Wolt) — při otevření a po změně seznamu obchodů
useEffect(() => {
if (!isOpen) return;
getStoreMenuStatus().then(res => {
setScrapeInfo(Object.fromEntries((res.data ?? []).map(i => [i.store, i])));
}).catch(() => { /* stav je jen informativní */ });
}, [isOpen, stores]);
const handleRefreshMenu = async (name: string, provider: StoreMenuProvider) => {
setError(null);
setRefreshing(`${name}|${provider}`);
try {
const res = await refreshStoreMenu({ body: { name, provider, heslo } });
if (res.data) {
setScrapeInfo(prev => ({ ...prev, [name]: res.data! }));
}
// Znovu načíst případně otevřenou historii (mohla přibýt nová verze)
if (historyOpen === `${name}|${provider}`) {
setHistoryOpen(null);
setTimeout(() => setHistoryOpen(`${name}|${provider}`));
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setRefreshing(null);
}
};
const addUrlRow = (urls: string[]) => [...urls, ''];
const removeUrlRow = (urls: string[], index: number) =>
urls.length === 1 ? [''] : urls.filter((_, i) => i !== index);
const setUrlAt = (urls: string[], index: number, value: string) =>
urls.map((u, i) => (i === index ? value : u));
/** Zpracuje odpověď API — vrací true při úspěchu. Chybu z API hlásí globální toaster. */
const applyResult = (res: { data?: unknown; error?: unknown }): boolean => {
if (res.error) {
return false;
}
if (res.data) {
onStoresChanged(res.data as Store[]);
return true;
}
return false;
};
const handleAdd = async () => {
if (!newName.trim()) return;
setError(null);
setLoading(true);
try {
const urls = newUrls.map(u => u.trim()).filter(Boolean);
const res = await addStore({ body: { name: newName.trim(), urls: urls.length > 0 ? urls : undefined, heslo } });
if (applyResult(res)) {
setNewName('');
setNewUrls(['']);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setLoading(false);
}
};
const startEdit = (store: Store) => {
setError(null);
setEdit({ originalName: store.name, name: store.name, urls: toUrlInputs(store.urls ?? []) });
};
const handleSaveEdit = async () => {
if (!edit || !edit.name.trim()) return;
setError(null);
setLoading(true);
try {
const res = await updateStore({
body: {
name: edit.originalName,
newName: edit.name.trim(),
urls: edit.urls.map(u => u.trim()).filter(Boolean),
heslo,
},
});
if (applyResult(res)) {
setEdit(null);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setLoading(false);
}
};
const handleRemove = async (name: string) => {
setError(null);
setLoading(true);
try {
const res = await deleteStore({ body: { name, heslo } });
if (applyResult(res) && edit?.originalName === name) {
setEdit(null);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setLoading(false);
}
};
/** Řádky se vstupy pro URL — používá se pro přidání i pro editaci obchodu. */
const renderUrlInputs = (urls: string[], onChange: (urls: string[]) => void, onSubmit: () => void) => (
<>
{urls.map((url, index) => (
<div key={index} className="d-flex gap-2 mb-2 align-items-center">
<Form.Control
type="url"
placeholder={index === 0
? 'URL na nabídku (volitelné, např. Bolt Food/Wolt)'
: 'Další URL na nabídku (jiná dovozová služba)'}
value={url}
onChange={e => onChange(setUrlAt(urls, index, e.target.value))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') onSubmit(); }}
/>
<Button
variant="outline-secondary"
onClick={() => onChange(removeUrlRow(urls, index))}
disabled={urls.length === 1 && !url}
title="Odebrat tuto URL"
>
<FontAwesomeIcon icon={faXmark} />
</Button>
</div>
))}
<Button variant="link" size="sm" className="p-0" onClick={() => onChange(addUrlRow(urls))}>
<FontAwesomeIcon icon={faPlus} className="me-1" />
Přidat další URL
</Button>
</>
);
return (
<Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Správa obchodů</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
{!adminEnabled && (
<Alert variant="info">
Správa obchodů není na tomto serveru zapnutá (chybí <code>ADMIN_PASSWORD</code>).
Seznam obchodů, stav nabídek a historii cen lze jen prohlížet.
</Alert>
)}
{adminEnabled && <>
<Form.Group className="mb-3">
<Form.Label>Admin heslo</Form.Label>
<Form.Control
type="password"
placeholder="Heslo"
value={heslo}
onChange={e => setHeslo(e.target.value)}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
Potřeba pro přidání, úpravu a odebrání obchodu i pro obnovení nabídky (<FontAwesomeIcon icon={faArrowsRotate} />).
</Form.Text>
</Form.Group>
<hr />
<h6>Přidat obchod</h6>
<Form.Control
className="mb-2"
type="text"
placeholder="Název obchodu"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
/>
{renderUrlInputs(newUrls, setNewUrls, handleAdd)}
<div className="d-flex justify-content-end mb-3">
<Button variant="primary" onClick={handleAdd} disabled={loading || !newName.trim() || !heslo}>
Přidat
</Button>
</div>
</>}
<h6>Aktuální seznam</h6>
{stores.length === 0 ? (
<p className="text-muted">Žádné obchody v seznamu</p>
) : (
<ListGroup>
{stores.map(s => {
const urls = getStoreUrls(s);
const isEditing = edit?.originalName === s.name;
if (isEditing) {
return (
<ListGroup.Item key={s.name}>
<Form.Control
className="mb-2"
type="text"
placeholder="Název obchodu"
value={edit.name}
onChange={e => setEdit({ ...edit, name: e.target.value })}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveEdit(); }}
/>
{renderUrlInputs(edit.urls, next => setEdit({ ...edit, urls: next }), handleSaveEdit)}
<div className="d-flex justify-content-end gap-2 mt-2">
<Button variant="secondary" size="sm" onClick={() => setEdit(null)}>
Zrušit
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSaveEdit}
disabled={loading || !edit.name.trim() || !heslo}
>
Uložit
</Button>
</div>
</ListGroup.Item>
);
}
return (
<ListGroup.Item key={s.name} className="d-flex justify-content-between align-items-start">
<div>
<div>{s.name}</div>
{urls.length > 0 && (
<small className="text-muted d-flex flex-wrap gap-2">
{urls.map(url => (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
title={url}
>
{storeUrlLabel(url)}
<FontAwesomeIcon icon={faUpRightFromSquare} className="ms-1" />
</a>
))}
</small>
)}
{scrapeInfo[s.name]?.providers.map(p => {
const { text, className, title } = describeScrape(p);
const key = `${s.name}|${p.provider}`;
return (
<div key={p.provider}>
<small className="d-flex align-items-baseline gap-2 mt-1">
<span className="text-nowrap text-muted">Menu {PROVIDER_LABELS[p.provider]}:</span>
<span className={className} title={title}>{text}</span>
{/* Zakázané tlačítko nezobrazí tooltip — obalení do span ho zobrazí i bez hesla */}
{adminEnabled && <OverlayTrigger
placement="top"
overlay={
<Tooltip>
{heslo
? `Znovu stáhnout nabídku z ${PROVIDER_LABELS[p.provider]}`
: 'Pro obnovení nabídky zadejte nahoře admin heslo'}
</Tooltip>
}
>
<span className="d-inline-block" style={{ cursor: heslo ? undefined : 'not-allowed' }}>
<Button
variant="link"
size="sm"
className="p-0"
disabled={!heslo || refreshing != null}
style={heslo ? undefined : { pointerEvents: 'none' }}
aria-label={`Znovu stáhnout nabídku z ${PROVIDER_LABELS[p.provider]}`}
onClick={() => handleRefreshMenu(s.name, p.provider)}
>
<FontAwesomeIcon icon={faArrowsRotate} spin={refreshing === key} />
</Button>
</span>
</OverlayTrigger>}
{p.dishCount > 0 && (
<Button variant="link" size="sm" className="p-0 text-nowrap"
onClick={() => setHistoryOpen(historyOpen === key ? null : key)}>
{historyOpen === key ? 'skrýt historii' : 'historie cen'}
</Button>
)}
</small>
{historyOpen === key && <PriceHistory store={s.name} provider={p.provider} />}
</div>
);
})}
</div>
{adminEnabled && <div className="d-flex gap-3">
<FontAwesomeIcon
icon={faPen}
className="action-icon"
title="Upravit"
onClick={() => startEdit(s)}
style={{ cursor: 'pointer' }}
/>
<FontAwesomeIcon
icon={faTrashCan}
className="action-icon"
title="Odebrat"
onClick={() => handleRemove(s.name)}
style={{ cursor: 'pointer' }}
/>
</div>}
</ListGroup.Item>
);
})}
</ListGroup>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
</Modal.Footer>
</Modal>
);
}
@@ -1,30 +0,0 @@
import { Modal, Button } from "react-bootstrap";
import { Suggestion } from "../../../../types";
type Props = {
suggestion?: Suggestion;
onClose: () => void;
};
/** Modální dialog zobrazující celý detail návrhu na vylepšení. */
export default function SuggestionDetailModal({ suggestion, onClose }: Readonly<Props>) {
return (
<Modal show={!!suggestion} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>{suggestion?.title}</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="text-muted mb-3">
Navrhovatel: <strong>{suggestion?.author}</strong> · Hlasy: <strong>{suggestion?.voteScore}</strong>
{suggestion?.resolved && <> · <strong>Vyřešeno</strong></>}
</p>
<p style={{ whiteSpace: "pre-wrap" }}>{suggestion?.description}</p>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
);
}
+1 -1
View File
@@ -55,7 +55,7 @@ function useProvideAuth(): AuthContextProps {
setLoginName(undefined); setLoginName(undefined);
setTrusted(undefined); setTrusted(undefined);
if (trusted && logoutUrl?.length) { if (trusted && logoutUrl?.length) {
globalThis.location.replace(logoutUrl); window.location.replace(logoutUrl);
} }
} }
-222
View File
@@ -3,45 +3,14 @@ import React, { ReactNode, useContext, useEffect, useState } from "react"
const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number'; const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number';
const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name'; const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
const HIDE_SOUPS_KEY = 'hide_soups'; const HIDE_SOUPS_KEY = 'hide_soups';
const THEME_KEY = 'theme_preference';
const ACCENT_HUE_KEY = 'accent_hue';
const LEGACY_COLOR_THEME_KEY = 'color_theme';
const STYLE_THEME_KEY = 'style_theme';
const SHOW_BUTTERFLIES_KEY = 'show_butterflies';
const AUTO_ORDER_CHOICE_KEY = 'auto_order_choice';
export type ThemePreference = 'system' | 'light' | 'dark';
/** Vizuální styl aplikace (nezávislý na světlém/tmavém režimu). */
export type StyleTheme = 'simple' | 'umamusume' | 'mlp' | 'hellgirl' | 'kon' | 'bocchi';
export const STYLE_THEMES: { value: StyleTheme, label: string }[] = [
{ value: 'simple', label: 'Jednoduchý' },
{ value: 'umamusume', label: 'Umamusume' },
{ value: 'mlp', label: 'My Little Pony' },
{ value: 'hellgirl', label: 'Hell Girl' },
{ value: 'kon', label: 'K-ON!' },
{ value: 'bocchi', label: 'Bocchi the Rock!' },
];
export type SettingsContextProps = { export type SettingsContextProps = {
bankAccount?: string, bankAccount?: string,
holderName?: string, holderName?: string,
hideSoups?: boolean, hideSoups?: boolean,
themePreference: ThemePreference,
accentHue: number,
effectiveDark: boolean,
styleTheme: StyleTheme,
showButterflies: boolean,
autoOrderChoice: boolean,
setBankAccountNumber: (accountNumber?: string) => void, setBankAccountNumber: (accountNumber?: string) => void,
setBankAccountHolderName: (holderName?: string) => void, setBankAccountHolderName: (holderName?: string) => void,
setHideSoupsOption: (hideSoups?: boolean) => void, setHideSoupsOption: (hideSoups?: boolean) => void,
setThemePreference: (theme: ThemePreference) => void,
setAccentHue: (hue: number) => void,
setStyleTheme: (theme: StyleTheme) => void,
setShowButterflies: (show: boolean) => void,
setAutoOrderChoice: (enabled: boolean) => void,
} }
type ContextProps = { type ContextProps = {
@@ -59,126 +28,10 @@ export const useSettings = () => {
return useContext(settingsContext); return useContext(settingsContext);
} }
function getInitialTheme(): ThemePreference {
try {
const saved = localStorage.getItem(THEME_KEY) as ThemePreference | null;
if (saved && ['system', 'light', 'dark'].includes(saved)) {
return saved;
}
} catch (e) {
// localStorage nedostupný
}
return 'system';
}
function getInitialStyleTheme(): StyleTheme {
try {
const saved = localStorage.getItem(STYLE_THEME_KEY);
const known = STYLE_THEMES.find(t => t.value === saved);
if (known) {
return known.value;
}
} catch {
// localStorage nedostupný
}
return 'simple';
}
function getInitialShowButterflies(): boolean {
try {
return localStorage.getItem(SHOW_BUTTERFLIES_KEY) !== 'false';
} catch {
// localStorage nedostupný
}
return true;
}
function getInitialAutoOrderChoice(): boolean {
try {
return localStorage.getItem(AUTO_ORDER_CHOICE_KEY) === 'true';
} catch {
// localStorage nedostupný
}
return false;
}
function getInitialAccentHue(): number {
try {
const saved = localStorage.getItem(ACCENT_HUE_KEY);
if (saved !== null) {
const n = parseInt(saved, 10);
if (!isNaN(n) && n >= 0 && n <= 360) return n;
}
// Migrace ze starého string formátu (green/blue/purple)
const old = localStorage.getItem(LEGACY_COLOR_THEME_KEY);
if (old === 'blue') return 217;
if (old === 'purple') return 263;
} catch {
// localStorage nedostupný
}
return 142;
}
// Převod HSL na relativní jas dle WCAG (pro výpočet kontrastu s bílým textem)
function hslToRelativeLuminance(h: number, s: number, l: number): number {
const sn = s / 100, ln = l / 100;
const a = sn * Math.min(ln, 1 - ln);
const ch = (n: number) => {
const k = (n + h / 30) % 12;
return ln - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
};
const toLinear = (c: number) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
return 0.2126 * toLinear(ch(0)) + 0.7152 * toLinear(ch(8)) + 0.0722 * toLinear(ch(4));
}
// Najde nejnižší světlost, při které má barva dostatečný kontrast s bílým textem (WCAG AA 4.5:1)
function adjustedL(hue: number, sat: number, targetL: number): number {
let l = targetL;
while (l >= 5) {
const lum = hslToRelativeLuminance(hue, sat, l);
if (1.05 / (lum + 0.05) >= 4.5) return l;
l -= 1;
}
return l;
}
const ACCENT_PROPERTIES = ['--luncher-primary', '--luncher-primary-hover', '--luncher-primary-light', '--luncher-action-icon', '--luncher-success'];
function clearAccentColors(): void {
ACCENT_PROPERTIES.forEach(p => document.documentElement.style.removeProperty(p));
}
function applyAccentColors(hue: number, isDark: boolean): void {
const sat = 70;
const baseL = adjustedL(hue, sat, isDark ? 55 : 38);
const hoverL = isDark ? Math.min(baseL + 10, 80) : Math.max(baseL - 10, 10);
const root = document.documentElement;
root.style.setProperty('--luncher-primary', `hsl(${hue} ${sat}% ${baseL}%)`);
root.style.setProperty('--luncher-primary-hover', `hsl(${hue} ${sat}% ${hoverL}%)`);
root.style.setProperty('--luncher-primary-light', isDark
? `hsl(${hue} 60% 12%)`
: `hsl(${hue} 60% 92%)`);
root.style.setProperty('--luncher-action-icon', `hsl(${hue} ${sat}% ${baseL}%)`);
root.style.setProperty('--luncher-success', `hsl(${hue} ${sat}% ${baseL}%)`);
}
function useProvideSettings(): SettingsContextProps { function useProvideSettings(): SettingsContextProps {
const [bankAccount, setBankAccount] = useState<string | undefined>(); const [bankAccount, setBankAccount] = useState<string | undefined>();
const [holderName, setHolderName] = useState<string | undefined>(); const [holderName, setHolderName] = useState<string | undefined>();
const [hideSoups, setHideSoups] = useState<boolean | undefined>(); const [hideSoups, setHideSoups] = useState<boolean | undefined>();
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme);
const [accentHue, setHue] = useState<number>(getInitialAccentHue);
const [styleTheme, setStyle] = useState<StyleTheme>(getInitialStyleTheme);
const [showButterflies, setShowButterfliesState] = useState<boolean>(getInitialShowButterflies);
const [autoOrderChoice, setAutoOrderChoiceState] = useState<boolean>(getInitialAutoOrderChoice);
const [effectiveDark, setEffectiveDark] = useState<boolean>(() => {
try {
const pref = localStorage.getItem(THEME_KEY) as ThemePreference | null;
if (pref === 'dark') return true;
if (pref === 'light') return false;
} catch { /* noop */ }
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
});
useEffect(() => { useEffect(() => {
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY); const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
@@ -219,50 +72,6 @@ function useProvideSettings(): SettingsContextProps {
} }
}, [hideSoups]); }, [hideSoups]);
useEffect(() => {
localStorage.setItem(THEME_KEY, themePreference);
}, [themePreference]);
useEffect(() => {
const applyTheme = (dark: boolean) => {
document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
setEffectiveDark(dark);
};
if (themePreference === 'system') {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
applyTheme(mq.matches);
const handler = (e: MediaQueryListEvent) => applyTheme(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
} else {
applyTheme(themePreference === 'dark');
}
}, [themePreference]);
useEffect(() => {
localStorage.setItem(STYLE_THEME_KEY, styleTheme);
document.documentElement.setAttribute('data-luncher-theme', styleTheme);
}, [styleTheme]);
useEffect(() => {
localStorage.setItem(SHOW_BUTTERFLIES_KEY, showButterflies ? 'true' : 'false');
}, [showButterflies]);
useEffect(() => {
localStorage.setItem(AUTO_ORDER_CHOICE_KEY, autoOrderChoice ? 'true' : 'false');
}, [autoOrderChoice]);
// Aplikuje accent barvy při změně hue nebo přepnutí světlý/tmavý.
// Tematické styly (Umamusume, My Little Pony, Hell Girl, K-ON!, Bocchi the Rock!) mají vlastní pevnou paletu, accent barvu proto uplatňuje jen jednoduchý styl.
useEffect(() => {
localStorage.setItem(ACCENT_HUE_KEY, String(accentHue));
if (styleTheme === 'simple') {
applyAccentColors(accentHue, effectiveDark);
} else {
clearAccentColors();
}
}, [accentHue, effectiveDark, styleTheme]);
function setBankAccountNumber(bankAccount?: string) { function setBankAccountNumber(bankAccount?: string) {
setBankAccount(bankAccount); setBankAccount(bankAccount);
} }
@@ -275,43 +84,12 @@ function useProvideSettings(): SettingsContextProps {
setHideSoups(hideSoups); setHideSoups(hideSoups);
} }
function setThemePreference(theme: ThemePreference) {
setTheme(theme);
}
function setAccentHue(hue: number) {
setHue(hue);
}
function setStyleTheme(theme: StyleTheme) {
setStyle(theme);
}
function setShowButterflies(show: boolean) {
setShowButterfliesState(show);
}
function setAutoOrderChoice(enabled: boolean) {
setAutoOrderChoiceState(enabled);
}
return { return {
bankAccount, bankAccount,
holderName, holderName,
hideSoups, hideSoups,
themePreference,
accentHue,
effectiveDark,
setBankAccountNumber, setBankAccountNumber,
setBankAccountHolderName, setBankAccountHolderName,
setHideSoupsOption, setHideSoupsOption,
setThemePreference,
setAccentHue,
styleTheme,
setStyleTheme,
showButterflies,
setShowButterflies,
autoOrderChoice,
setAutoOrderChoice,
} }
} }
+3 -18
View File
@@ -4,32 +4,17 @@ import socketio from "socket.io-client";
let socketUrl; let socketUrl;
let socketPath; let socketPath;
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
// Stejný stroj jako stránka, aby šlo vývojovou verzi zkoušet i z telefonu či tabletu v síti socketUrl = `http://localhost:3001`;
socketUrl = `http://${globalThis.location.hostname}:3001`;
socketPath = undefined; socketPath = undefined;
} else { } else {
socketUrl = `${globalThis.location.host}`; socketUrl = `${window.location.host}`;
socketPath = '/socket.io'; socketPath = `${window.location.pathname}socket.io`;
} }
export const socket = socketio.connect(socketUrl, { path: socketPath, transports: ["websocket"] }); export const socket = socketio.connect(socketUrl, { path: socketPath, transports: ["websocket"] });
export const SocketContext = React.createContext(); export const SocketContext = React.createContext();
// Prohlížeče throttlují setTimeout v neaktivních tabech, což zdržuje automatické
// znovupřipojení socket.io. Po návratu do tabu nebo focusu okna se připojíme hned.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && !socket.connected) {
socket.connect();
}
});
window.addEventListener('focus', () => {
if (!socket.connected) {
socket.connect();
}
});
// Konstanty websocket eventů, musí odpovídat těm na serveru! // Konstanty websocket eventů, musí odpovídat těm na serveru!
export const EVENT_CONNECT = 'connect'; export const EVENT_CONNECT = 'connect';
export const EVENT_DISCONNECT = 'disconnect'; export const EVENT_DISCONNECT = 'disconnect';
export const EVENT_MESSAGE = 'message'; export const EVENT_MESSAGE = 'message';
export const EVENT_PENDING_QR = 'pendingQr';
-3
View File
@@ -1,8 +1,5 @@
import { LunchChoice, Restaurant } from "../../types"; import { LunchChoice, Restaurant } from "../../types";
/** Volby, u kterých se nezadává cena oběda — cena je dána objednávkou, Pizza day, nebo se neobědvá. */
export const CHOICES_WITHOUT_PRICE: LunchChoice[] = [LunchChoice.OBJEDNAVAM, LunchChoice.PIZZA, LunchChoice.NEOBEDVAM, LunchChoice.ROZHODUJI];
export function getRestaurantName(restaurant: Restaurant) { export function getRestaurantName(restaurant: Restaurant) {
switch (restaurant) { switch (restaurant) {
case Restaurant.SLADOVNICKA: case Restaurant.SLADOVNICKA:
-33
View File
@@ -1,33 +0,0 @@
import { StyleTheme, useSettings } from "../context/settings";
export type GameId = 'drawing';
/** Ikony miniher podle vizuálního stylu aplikace (ostatní minihry jsou v samostatné aplikaci Lobby). */
const ICONS: Record<StyleTheme, Record<GameId, string>> = {
simple: { drawing: '🎨' },
// Dostihy: trofej
umamusume: { drawing: '🏆' },
// Paleta Rarity
mlp: { drawing: '🖌️' },
// Pekelná korespondence: svitek
hellgirl: { drawing: '📜' },
// Klub lehké hudby: notový zápis
kon: { drawing: '🎼' },
// Kessoku Band: sešit s texty Bocchi
bocchi: { drawing: '📝' },
};
/** Styl z <html data-luncher-theme> — pro místa mimo React kontext nastavení. */
function currentTheme(): StyleTheme {
const theme = document.documentElement.getAttribute('data-luncher-theme') as StyleTheme | null;
return theme && theme in ICONS ? theme : 'simple';
}
/** Ikona hry pro aktuální styl (mimo React, např. výpis do konzole). */
export const gameIcon = (game: GameId, theme: StyleTheme = currentTheme()) => ICONS[theme]?.[game] ?? ICONS.simple[game];
/** Ikona hry pro aktuální styl; stránky bez nastavení (statistiky) použijí atribut na <html>. */
export function useGameIcon(game: GameId) {
const settings = useSettings();
return gameIcon(game, settings?.styleTheme ?? currentTheme());
}
-256
View File
@@ -1,256 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
ButterflyStats,
getButterflyStats,
catchButterflies,
repairNet as repairNetApi,
killWasp as killWaspApi,
buyRepellent as buyRepellentApi,
reportNetTorn as reportTearApi,
reportRobbery as reportRobberyApi,
defeatThief as defeatThiefApi,
reportCaterpillarAte as caterpillarAteApi,
defeatCaterpillar as defeatCaterpillarApi,
reportMothHit as mothHitApi,
reportInspectionFail as inspectionFailApi,
buyPremiumNet as buyPremiumApi,
buyWaspSpray as waspSprayApi,
buyInsurance as buyInsuranceApi,
buyUpgrade as buyUpgradeApi,
} from '../../../types';
/** Druhy trvalých vylepšení v obchodě. */
export type UpgradeItem = 'net' | 'scarecrow' | 'reinforced';
/** Výsledek nákupu: úspěch / nedostatek mincí / jiná chyba (např. starý server). */
export type BuyResult = 'ok' | 'poor' | 'error';
/** Jak dlouho se čeká, než se nasbíraná dávka úlovků odešle na server. */
const FLUSH_DEBOUNCE_MS = 1500;
/** Jak často se přenačte stav ze serveru (kvůli přibývajícím škůdcům). */
const POLL_INTERVAL_MS = 30_000;
/** Událost odměny hlášená scéně po úspěšném odeslání dávky. */
export interface RewardEvent {
coinsAwarded: number;
dailyBonusApplied: boolean;
leveledUp: boolean;
premiumUnlocked: boolean;
newLevel: number;
newTitle: string;
}
/**
* Hook spravující serverovou perzistenci chytání motýlků: načtení stavu, dávkové
* odesílání úlovků, hubení vos, plašič ptáků, protržení/opravu síťky, zloděje a
* periodické přenačítání (růst škůdců). Server je zdroj pravdy; počítadlo se
* zobrazuje jako serverová hodnota + zatím neodeslané úlovky.
*/
export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
const [stats, setStats] = useState<ButterflyStats | undefined>();
/** Optimisticky zobrazený přírůstek chycených, který ještě neodešel na server. */
const [pendingShown, setPendingShown] = useState(0);
/** Živá hodnota mincí pro scénu (aby nečetla zastaralý stav přes closure). */
const coinsRef = useRef(0);
const pending = useRef({ normal: 0, golden: 0 });
const flushTimer = useRef<number | null>(null);
const onRewardRef = useRef(onReward);
onRewardRef.current = onReward;
const applyStats = useCallback((incoming: ButterflyStats) => {
coinsRef.current = incoming.coins;
setStats(incoming);
}, []);
const flush = useCallback(async () => {
if (flushTimer.current) {
clearTimeout(flushTimer.current);
flushTimer.current = null;
}
const batch = pending.current;
const batchTotal = batch.normal + batch.golden;
if (batchTotal === 0) return;
pending.current = { normal: 0, golden: 0 };
try {
const res = await catchButterflies({ body: { normal: batch.normal, golden: batch.golden } });
if (res.data) {
applyStats(res.data.stats);
// Odeslané úlovky už jsou v serverové hodnotě → sundáme je z optimistického přírůstku
setPendingShown(p => Math.max(0, p - batchTotal));
onRewardRef.current?.({
coinsAwarded: res.data.coinsAwarded,
dailyBonusApplied: res.data.dailyBonusApplied,
leveledUp: res.data.leveledUp,
premiumUnlocked: res.data.premiumUnlocked,
newLevel: res.data.stats.level,
newTitle: res.data.stats.title,
});
} else {
setPendingShown(p => Math.max(0, p - batchTotal));
}
} catch {
// Při chybě vrátíme dávku zpět (přírůstek necháme zobrazený)
pending.current.normal += batch.normal;
pending.current.golden += batch.golden;
}
}, [applyStats]);
/** Nahlásí chycení jednoho motýla (optimisticky + naplánuje odeslání dávky). */
const reportCatch = useCallback((golden: boolean) => {
if (golden) pending.current.golden += 1;
else pending.current.normal += 1;
setPendingShown(p => p + 1);
if (!flushTimer.current) {
flushTimer.current = window.setTimeout(() => { void flush(); }, FLUSH_DEBOUNCE_MS);
}
}, [flush]);
const repair = useCallback(async (): Promise<boolean> => {
try {
const res = await repairNetApi();
if (res.data) { applyStats(res.data); return true; }
return false;
} catch { return false; }
}, [applyStats]);
const killWasp = useCallback(async () => {
try {
const res = await killWaspApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
const buyRepellent = useCallback(async (): Promise<BuyResult> => {
try {
const res = await buyRepellentApi();
if (res.data) { applyStats(res.data); return 'ok'; }
return res.response?.status === 402 ? 'poor' : 'error';
} catch { return 'error'; }
}, [applyStats]);
const reportTear = useCallback(async () => {
try {
const res = await reportTearApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
/** Zloděj se dostal k penězům – server strhne podíl mincí. */
const reportRobbery = useCallback(async () => {
try {
const res = await reportRobberyApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
/** Hráč porazil zloděje – odměna a statistika. */
const defeatThief = useCallback(async () => {
try {
const res = await defeatThiefApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
/** Housenka se dostala k úlovkům – server sníží počet chycených. */
const caterpillarAte = useCallback(async () => {
try {
const res = await caterpillarAteApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
/** Hráč omylem chytil černou můru – server sníží úlovky. */
const mothHit = useCallback(async () => {
try {
const res = await mothHitApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
/** Selhání anti-bot inspekce – server počítá selhání a případně udělí ban. */
const reportInspectionFail = useCallback(async () => {
try {
const res = await inspectionFailApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
/** Vyhodnotí výsledek nákupu z odpovědi (402 = nedostatek mincí, jinak chyba). */
const buyResult = useCallback((res: { data?: unknown; response?: Response }): BuyResult => {
if (res.data) { applyStats(res.data as ButterflyStats); return 'ok'; }
return res.response?.status === 402 ? 'poor' : 'error';
}, [applyStats]);
/** Obchod: koupě spotřebního předmětu / vylepšení. */
const buyPremium = useCallback(async (): Promise<BuyResult> => {
try { return buyResult(await buyPremiumApi()); } catch { return 'error'; }
}, [buyResult]);
const waspSpray = useCallback(async (): Promise<BuyResult> => {
try { return buyResult(await waspSprayApi()); } catch { return 'error'; }
}, [buyResult]);
const buyInsurance = useCallback(async (): Promise<BuyResult> => {
try { return buyResult(await buyInsuranceApi()); } catch { return 'error'; }
}, [buyResult]);
const buyUpgrade = useCallback(async (item: UpgradeItem): Promise<BuyResult> => {
try { return buyResult(await buyUpgradeApi({ body: { item } })); } catch { return 'error'; }
}, [buyResult]);
/** Hráč porazil housenku – odměna a statistika. */
const defeatCaterpillar = useCallback(async () => {
try {
const res = await defeatCaterpillarApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
const refresh = useCallback(async () => {
try {
const res = await getButterflyStats();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
// Načtení stavu
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await getButterflyStats();
if (!cancelled && res.data) applyStats(res.data);
} catch { /* server nedostupný – hra jede dál bez perzistence */ }
})();
return () => { cancelled = true; };
}, [applyStats]);
// Periodické přenačítání (růst škůdců) + při návratu na záložku
useEffect(() => {
const id = window.setInterval(() => { void refresh(); }, POLL_INTERVAL_MS);
const onVisible = () => { if (document.visibilityState === 'visible') void refresh(); };
document.addEventListener('visibilitychange', onVisible);
return () => {
window.clearInterval(id);
document.removeEventListener('visibilitychange', onVisible);
};
}, [refresh]);
// Odeslání rozdělané dávky při skrytí/opuštění stránky a při odmontování
useEffect(() => {
const onVisibility = () => { if (document.visibilityState === 'hidden') void flush(); };
const onPageHide = () => { void flush(); };
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('pagehide', onPageHide);
return () => {
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('pagehide', onPageHide);
void flush();
};
}, [flush]);
const displayCaught = (stats?.caught ?? 0) + pendingShown;
return {
stats, displayCaught, coinsRef, reportCatch, repair, killWasp, buyRepellent,
reportTear, reportRobbery, defeatThief, caterpillarAte, defeatCaterpillar,
mothHit, reportInspectionFail, buyPremium, waspSpray, buyInsurance, buyUpgrade,
};
}
-12
View File
@@ -1,12 +0,0 @@
import { useSyncExternalStore } from "react";
import { getAppConfig, getGroupLimits, subscribeAppConfig } from "../utils/appConfig";
/** Aktuální limity skupinových objednávek (překreslí se po načtení konfigurace ze serveru). */
export function useGroupLimits() {
return useSyncExternalStore(subscribeAppConfig, getGroupLimits);
}
/** Celá runtime konfigurace ze serveru (způsob přihlášení, zapnuté funkce, limity). */
export function useAppConfig() {
return useSyncExternalStore(subscribeAppConfig, getAppConfig);
}
-149
View File
@@ -1,149 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { openDrawingGame } from "../components/DrawingGame";
import { GameId, gameIcon } from "../games/themeIcons";
import { useAppConfig } from "./useGroupLimits";
type Minigame = {
id: GameId,
/** Kód, který se napíše po sérii kliknutí na logo. */
code: string,
name: string,
description: string,
open: () => void,
};
/**
* Skryté minihry, které zůstaly v Luncheru (ostatní jsou v samostatné aplikaci Lobby).
* Novou hru stačí přidat sem.
*/
const MINIGAMES: Minigame[] = [
{
id: 'drawing',
code: 'kresleni',
name: 'Kreslení',
description: 'Jeden kreslí jídlo z dnešních menu, ostatní hádají.',
open: openDrawingGame,
},
];
const HELP_CODE = 'help';
const SECRET_CLICKS = 3;
/** Max. pauza mezi kliknutími na logo, aby se počítala do série (ms). */
const SECRET_CLICK_GAP_MS = 400;
/** Jak dlouho po sérii kliknutí (případně po návratu z Lobby do této záložky) lze napsat kód (ms). */
const SECRET_ARM_MS = 8000;
function printHelp(lobbyUrl?: string) {
console.log('%c🎮 Luncher minihry', 'font-size: 16px; font-weight: bold');
if (lobbyUrl) {
console.log(`Klikněte 3× na logo — otevře se Lobby s dalšími minihrami (${lobbyUrl}).`);
console.log('Po návratu do Luncheru lze napsat kód hry:');
} else {
console.log('Klikněte 3× na logo a napište kód hry:');
}
console.table(MINIGAMES.map(g => ({ kód: g.code, hra: `${gameIcon(g.id)} ${g.name}`, popis: g.description })));
}
/** Malá písmena bez diakritiky — česká klávesnice na mobilu může "kresleni" opravit na "Kreslení". */
const normalizeCode = (text: string) => text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim();
/**
* Skrytý vstup do miniher: 3× rychle kliknout na logo. Je-li nastavena adresa Lobby, otevře se v nové záložce.
* Poté lze (po návratu do Luncheru) napsat kód hry, která zůstala v Luncheru, případně "help" pro výpis do konzole prohlížeče.
* Jedno či dvě kliknutí se chovají jako běžný odkaz (jen s malým zpožděním).
* Bez Lobby se na dotykových zařízeních kód píše do skrytého pole (`inputRef`), které otevře softwarovou klávesnici.
* Po otevření Lobby klávesnici po návratu do záložky otevřít nelze (chce gesto uživatele), na mobilu tak kód zadat nepůjde.
*/
export function useMinigameSecret(href: string) {
const { lobbyUrl } = useAppConfig();
const [armed, setArmed] = useState(false);
const clicks = useRef(0);
const timer = useRef<number | undefined>(undefined);
const inputRef = useRef<HTMLInputElement>(null);
const onLogoClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
// Otevření v novém tabu apod. necháme prohlížeči
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
window.clearTimeout(timer.current);
clicks.current++;
if (clicks.current >= SECRET_CLICKS) {
clicks.current = 0;
if (lobbyUrl) {
// Otevření musí proběhnout přímo v obsluze kliknutí, jinak ho prohlížeč zablokuje jako vyskakovací okno
window.open(lobbyUrl, '_blank', 'noopener');
} else {
// Fokus musí proběhnout přímo v obsluze kliknutí, jinak iOS klávesnici neotevře
const input = inputRef.current;
if (input) {
input.value = '';
input.focus({ preventScroll: true });
}
}
setArmed(true);
return;
}
timer.current = window.setTimeout(() => {
clicks.current = 0;
window.location.assign(href);
}, SECRET_CLICK_GAP_MS);
}, [href, lobbyUrl]);
useEffect(() => {
if (!armed) return;
const input = inputRef.current;
const maxLength = Math.max(HELP_CODE.length, ...MINIGAMES.map(g => g.code.length));
let typed = '';
const check = (text: string) => {
if (text.endsWith(HELP_CODE)) {
setArmed(false);
printHelp(lobbyUrl);
return;
}
const game = MINIGAMES.find(g => text.endsWith(g.code));
if (game) {
setArmed(false);
game.open();
}
};
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target?.closest('input, textarea, select, [contenteditable="true"]') || e.key.length !== 1) return;
typed = (typed + normalizeCode(e.key)).slice(-maxLength);
check(typed);
};
// Softwarová klávesnice (Android) posílá v keydown jen "Unidentified", proto se čte celý text pole
const onInput = () => check(normalizeCode(input?.value ?? ''));
// Limit běží, jen když je záložka vidět — po odchodu do Lobby se zastaví a po návratu začne znovu
let disarm: number | undefined;
const startDisarm = () => {
window.clearTimeout(disarm);
disarm = window.setTimeout(() => setArmed(false), SECRET_ARM_MS);
};
const onVisibility = () => {
if (document.visibilityState === 'visible') {
startDisarm();
} else {
window.clearTimeout(disarm);
}
};
startDisarm();
window.addEventListener('keydown', onKey);
input?.addEventListener('input', onInput);
document.addEventListener('visibilitychange', onVisibility);
return () => {
window.clearTimeout(disarm);
window.removeEventListener('keydown', onKey);
input?.removeEventListener('input', onInput);
document.removeEventListener('visibilitychange', onVisibility);
if (input) {
input.value = '';
input.blur();
}
};
}, [armed, lobbyUrl]);
useEffect(() => () => window.clearTimeout(timer.current), []);
return { armed, onLogoClick, inputRef };
}
-109
View File
@@ -1,109 +0,0 @@
import { getToken } from '../Utils';
/** Převede base64url VAPID klíč na Uint8Array pro PushManager. */
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
/** Helper pro autorizované API volání na push endpointy. */
async function pushApiFetch(path: string, options: RequestInit = {}): Promise<Response> {
const token = getToken();
return fetch(`/api/notifications/push${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
}
/**
* Zaregistruje service worker, přihlásí se k push notifikacím
* a odešle subscription na server. `reminderTime` je volitelný — bez něj
* uživatel odebírá jen ostatní push notifikace (např. doručení objednávky).
*/
export async function subscribeToPush(reminderTime?: string): Promise<boolean> {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.warn('Push notifikace nejsou v tomto prohlížeči podporovány');
return false;
}
try {
// Registrace service workeru
const registration = await navigator.serviceWorker.register('/sw.js');
await navigator.serviceWorker.ready;
// Vyžádání oprávnění
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.warn('Push notifikace: oprávnění zamítnuto');
return false;
}
// Získání VAPID veřejného klíče ze serveru
const vapidResponse = await pushApiFetch('/vapidKey');
if (!vapidResponse.ok) {
console.error('Push notifikace: nepodařilo se získat VAPID klíč');
return false;
}
const { key: vapidPublicKey } = await vapidResponse.json();
// Přihlášení k push
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource,
});
// Odeslání subscription na server
const response = await pushApiFetch('/subscribe', {
method: 'POST',
body: JSON.stringify({
subscription: subscription.toJSON(),
reminderTime,
}),
});
if (!response.ok) {
console.error('Push notifikace: nepodařilo se odeslat subscription na server');
return false;
}
console.log('Push notifikace: úspěšně přihlášeno', reminderTime ? `(připomínka v ${reminderTime})` : '(bez připomínky oběda)');
return true;
} catch (error) {
console.error('Push notifikace: chyba při registraci', error);
return false;
}
}
/**
* Odhlásí se z push notifikací a informuje server.
*/
export async function unsubscribeFromPush(): Promise<void> {
if (!('serviceWorker' in navigator)) {
return;
}
try {
const registration = await navigator.serviceWorker.getRegistration('/sw.js');
if (registration) {
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
}
}
await pushApiFetch('/unsubscribe', { method: 'POST' });
console.log('Push notifikace: úspěšně odhlášeno');
} catch (error) {
console.error('Push notifikace: chyba při odhlášení', error);
}
}
+1 -19
View File
@@ -7,32 +7,14 @@ body,
body { body {
margin: 0; margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif; sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
line-height: 1.5;
} }
code { code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace; monospace;
} }
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Better focus styles */
:focus-visible {
outline: 2px solid var(--luncher-primary);
outline-offset: 2px;
}
/* Selection color */
::selection {
background: var(--luncher-primary-light);
color: var(--luncher-primary);
}
+7 -44
View File
@@ -6,49 +6,20 @@ import './index.css';
import AppRoutes from './AppRoutes'; import AppRoutes from './AppRoutes';
import { BrowserRouter } from 'react-router'; import { BrowserRouter } from 'react-router';
import { client } from '../../types/gen/client.gen'; import { client } from '../../types/gen/client.gen';
import { getConfig } from '../../types/gen/sdk.gen';
import { getToken } from './Utils'; import { getToken } from './Utils';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import * as Sentry from '@sentry/react';
import { setAppConfig } from './utils/appConfig';
client.setConfig({ client.setConfig({
auth: () => getToken(), auth: () => getToken(),
baseUrl: '/api', // openapi-ts si to z nějakého důvodu neumí převzít z api.yml baseUrl: '/api', // openapi-ts si to z nějakého důvodu neumí převzít z api.yml
}); });
// Sentry se inicializuje až podle runtime konfigurace ze serveru (bez DSN zůstává vypnuté).
// Klient je statický build, takže DSN ani limity nejde zapéct při buildu — server je zná z env.
getConfig().then(({ data }) => {
setAppConfig({
loaded: true,
trustedHeaders: data?.auth?.trustedHeaders,
storeAdmin: data?.features?.storeAdmin,
lobbyUrl: data?.links?.lobbyUrl ?? undefined,
groupLimits: data?.limits?.group,
});
if (data?.sentry.dsn) {
Sentry.init({
dsn: data.sentry.dsn,
environment: data.sentry.environment,
});
}
}).catch(() => {
// Config endpoint nedostupný — běžíme bez Sentry, s výchozími limity a původním chováním přihlášení
setAppConfig({ loaded: true });
});
// Interceptor na vyhození toasteru při chybě // Interceptor na vyhození toasteru při chybě
client.interceptors.response.use(async response => { client.interceptors.response.use(async response => {
// TODO opravit - login je zatím výjimka, voláme ho "naprázdno" abychom zjistili, zda nás nepřihlásily trusted headers // TODO opravit - login je zatím výjimka, voláme ho "naprázdno" abychom zjistili, zda nás nepřihlásily trusted headers
if (!response.ok && !response.url.includes("/login")) { if (!response.ok && response.url.indexOf("/login") == -1) {
// Čteme z klonu, aby tělo odpovědi zůstalo k dispozici i generovanému SDK const json = await response.json();
const json = await response.clone().json();
toast.error(json.error, { theme: "colored" }); toast.error(json.error, { theme: "colored" });
// Serverové chyby hlásíme do Sentry; 4xx jsou očekávané (chyby uživatele)
if (response.status >= 500) {
Sentry.captureMessage(`API ${response.status}: ${new URL(response.url).pathname} — ${json.error}`, 'error');
}
} }
return response; return response;
}); });
@@ -58,18 +29,10 @@ const root = ReactDOM.createRoot(
); );
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<Sentry.ErrorBoundary fallback={ <BrowserRouter>
<div className="text-center p-5"> <ProvideAuth>
<h4>Něco se pokazilo 😕</h4> <AppRoutes />
<p>Chyba byla nahlášena. Zkuste stránku načíst znovu.</p> </ProvideAuth>
<button className="btn btn-primary" onClick={() => window.location.reload()}>Načíst znovu</button> </BrowserRouter>
</div>
}>
<BrowserRouter>
<ProvideAuth>
<AppRoutes />
</ProvideAuth>
</BrowserRouter>
</Sentry.ErrorBoundary>
</React.StrictMode> </React.StrictMode>
); );
File diff suppressed because it is too large. Load diff
+3 -120
View File
@@ -2,132 +2,15 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 32px 24px; padding: 20px;
min-height: calc(100vh - 140px);
background: var(--luncher-bg);
h1 {
font-size: 2rem;
font-weight: 700;
color: var(--luncher-text);
margin-bottom: 24px;
}
.week-navigator { .week-navigator {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 24px; font-size: xx-large;
margin-bottom: 32px;
svg {
font-size: 1.5rem;
color: var(--luncher-text-secondary);
cursor: pointer;
padding: 12px;
border-radius: 50%;
background: var(--luncher-bg-card);
box-shadow: var(--luncher-shadow-sm);
transition: var(--luncher-transition);
&:hover {
color: var(--luncher-primary);
background: var(--luncher-primary-light);
transform: scale(1.05);
}
}
.date-range { .date-range {
margin: 0; margin: 5px 20px;
font-size: 1.25rem;
font-weight: 600;
color: var(--luncher-text);
min-width: 280px;
text-align: center;
} }
} }
.export-panel {
margin-top: 32px;
padding: 24px;
width: 100%;
max-width: 560px;
background: var(--luncher-bg-card);
border: 1px solid var(--luncher-border-light);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow-sm);
text-align: center;
h3 {
font-size: 1.1rem;
font-weight: 600;
color: var(--luncher-text);
margin-bottom: 8px;
}
.export-hint {
font-size: 0.85rem;
color: var(--luncher-text-secondary);
margin-bottom: 16px;
}
.export-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 12px;
input {
max-width: 180px;
}
select {
max-width: 160px;
}
}
}
// Chart container
.recharts-wrapper {
background: var(--luncher-bg-card);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
padding: 24px;
border: 1px solid var(--luncher-border-light);
}
// Chart text styling
.recharts-cartesian-axis-tick-value {
fill: var(--luncher-text-secondary);
font-size: 0.85rem;
}
.recharts-legend-item-text {
color: var(--luncher-text) !important;
font-weight: 500;
}
.recharts-tooltip-wrapper {
.recharts-default-tooltip {
background: var(--luncher-bg-card) !important;
border: 1px solid var(--luncher-border) !important;
border-radius: var(--luncher-radius-sm) !important;
box-shadow: var(--luncher-shadow-lg) !important;
.recharts-tooltip-label {
color: var(--luncher-text) !important;
font-weight: 600;
margin-bottom: 8px;
}
.recharts-tooltip-item {
color: var(--luncher-text-secondary) !important;
}
}
}
.recharts-cartesian-grid-horizontal line,
.recharts-cartesian-grid-vertical line {
stroke: var(--luncher-border);
}
} }
+16 -108
View File
@@ -1,64 +1,38 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import Footer from "../components/Footer"; import Footer from "../components/Footer";
import Header from "../components/Header"; import Header from "../components/Header";
import { useAuth } from "../context/auth"; import { useAuth } from "../context/auth";
import Login from "../Login"; import Login from "../Login";
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils"; import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils";
import { WeeklyStats, LunchChoice, getStats, getStatsExport } from "../../../types"; import { WeeklyStats, LunchChoice, getStats } from "../../../types";
import Loader from "../components/Loader"; import Loader from "../components/Loader";
import { faChevronLeft, faChevronRight, faGear, faFileExcel, faFileCsv, faFileCode } from "@fortawesome/free-solid-svg-icons"; import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts"; import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { getLunchChoiceName } from "../enums"; import { getLunchChoiceName } from "../enums";
import './StatsPage.scss'; import './StatsPage.scss';
import { isArrowNavigationBlocked } from '../utils/arrowNavigation';
const CHART_WIDTH = 1400; const CHART_WIDTH = 1400;
const CHART_HEIGHT = 700; const CHART_HEIGHT = 700;
const STROKE_WIDTH = 2.5; const STROKE_WIDTH = 2.5;
const COLORS = [ const COLORS = [
'#ff1493', // Komentáře jsou kvůli vizualizaci barev ve VS Code
'#1e90ff', '#ff1493', // #ff1493
'#c5a700', '#1e90ff', // #1e90ff
'#006400', '#c5a700', // #c5a700
'#b300ff', '#006400', // #006400
'#ff4500', '#b300ff', // #b300ff
'#bc8f8f', '#ff4500', // #ff4500
'#00ff00', '#bc8f8f', // #bc8f8f
'#7c7c7c', '#00ff00', // #00ff00
'#7c7c7c', // #7c7c7c
] ]
/** Podporované formáty exportu přehledu (viz GET /api/stats/export). */
const EXPORT_FORMATS = ['xlsx', 'csv', 'json'] as const;
type ExportFormat = typeof EXPORT_FORMATS[number];
const EXPORT_FORMAT_LABELS: Record<ExportFormat, string> = {
xlsx: 'Excel (.xlsx)',
csv: 'CSV (.csv)',
json: 'JSON (.json)',
};
const EXPORT_FORMAT_ICONS: Record<ExportFormat, IconDefinition> = {
xlsx: faFileExcel,
csv: faFileCsv,
json: faFileCode,
};
/** Vrátí měsíc předaného data ve formátu YYYY-MM (hodnota pro input type="month"). */
function getMonthValue(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}
export default function StatsPage() { export default function StatsPage() {
const auth = useAuth(); const auth = useAuth();
const [dateRange, setDateRange] = useState<Date[]>(); const [dateRange, setDateRange] = useState<Date[]>();
const [data, setData] = useState<WeeklyStats>(); const [data, setData] = useState<WeeklyStats>();
const [exportMonth, setExportMonth] = useState<string>(() => getMonthValue(new Date()));
const [exportFormat, setExportFormat] = useState<ExportFormat>('xlsx');
const [exporting, setExporting] = useState(false);
// Prvotní nastavení aktuálního týdne // Prvotní nastavení aktuálního týdne
useEffect(() => { useEffect(() => {
@@ -80,31 +54,6 @@ export default function StatsPage() {
return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} /> return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
} }
/** Stáhne přehled stravování přihlášeného uživatele za vybraný měsíc ve vybraném formátu. */
const handleExport = async () => {
const [year, month] = exportMonth.split('-').map(Number);
if (!year || !month) {
return;
}
setExporting(true);
try {
// parseAs 'blob' — odpověď chceme stáhnout tak, jak přišla, bez parsování dle typu
const { data: file } = await getStatsExport({ query: { year, month, format: exportFormat }, parseAs: 'blob' });
// Chyby řeší globální interceptor (toaster), stahujeme jen při úspěchu
if (!(file instanceof Blob)) {
return;
}
const url = URL.createObjectURL(file);
const link = document.createElement('a');
link.href = url;
link.download = `luncher-${auth?.login ?? 'prehled'}-${exportMonth}.${exportFormat}`;
link.click();
URL.revokeObjectURL(url);
} finally {
setExporting(false);
}
}
const handlePreviousWeek = () => { const handlePreviousWeek = () => {
if (dateRange) { if (dateRange) {
const previousStartDate = new Date(dateRange[0]); const previousStartDate = new Date(dateRange[0]);
@@ -125,20 +74,13 @@ export default function StatsPage() {
} }
} }
const isCurrentOrFutureWeek = useMemo(() => {
if (!dateRange) return true;
// Porovnáváme jen dny (obě data nesou čas svého vzniku), dopředu jde jít nejvýše na aktuální týden
return formatDate(dateRange[1]) >= formatDate(getLastWorkDayOfWeek(new Date()));
}, [dateRange]);
const handleKeyDown = useCallback((e: any) => { const handleKeyDown = useCallback((e: any) => {
if (isArrowNavigationBlocked(e)) return;
if (e.keyCode === 37) { if (e.keyCode === 37) {
handlePreviousWeek(); handlePreviousWeek();
} else if (e.keyCode === 39 && !isCurrentOrFutureWeek) { } else if (e.keyCode === 39) {
handleNextWeek() handleNextWeek()
} }
}, [dateRange, isCurrentOrFutureWeek]); }, [dateRange]);
useEffect(() => { useEffect(() => {
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown);
@@ -170,7 +112,7 @@ export default function StatsPage() {
</span> </span>
<h2 className="date-range">{getHumanDate(dateRange[0])} - {getHumanDate(dateRange[1])}</h2> <h2 className="date-range">{getHumanDate(dateRange[0])} - {getHumanDate(dateRange[1])}</h2>
<span title="Následující týden"> <span title="Následující týden">
<FontAwesomeIcon icon={faChevronRight} style={{ cursor: "pointer", visibility: isCurrentOrFutureWeek ? "hidden" : "visible" }} onClick={handleNextWeek} /> <FontAwesomeIcon icon={faChevronRight} style={{ cursor: "pointer" }} onClick={handleNextWeek} />
</span> </span>
</div> </div>
<LineChart width={CHART_WIDTH} height={CHART_HEIGHT} data={data}> <LineChart width={CHART_WIDTH} height={CHART_HEIGHT} data={data}>
@@ -180,40 +122,6 @@ export default function StatsPage() {
<Tooltip /> <Tooltip />
<Legend /> <Legend />
</LineChart> </LineChart>
<div className="export-panel">
<h3>Můj přehled</h3>
<p className="export-hint">
Přehled vašich záznamů za vybraný měsíc — datum, kde jste jedli, vybrané jídlo, poznámka
a u objednávek i částka po slevě.
</p>
<div className="export-controls">
<input
type="month"
className="form-control"
value={exportMonth}
max={getMonthValue(new Date())}
onChange={e => setExportMonth(e.target.value)}
/>
<select
className="form-select"
value={exportFormat}
onChange={e => setExportFormat(e.target.value as ExportFormat)}
>
{EXPORT_FORMATS.map(format => (
<option key={format} value={format}>{EXPORT_FORMAT_LABELS[format]}</option>
))}
</select>
<button
type="button"
className="btn btn-primary"
disabled={exporting || !exportMonth}
onClick={handleExport}
>
<FontAwesomeIcon icon={EXPORT_FORMAT_ICONS[exportFormat]} />{' '}
{exporting ? 'Generuji...' : 'Stáhnout přehled'}
</button>
</div>
</div>
</div> </div>
<Footer /> <Footer />
</> </>
-145
View File
@@ -1,145 +0,0 @@
.suggestions-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 24px;
min-height: calc(100vh - 140px);
background: var(--luncher-bg);
.suggestions-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
width: 100%;
max-width: 900px;
flex-wrap: wrap;
h1 {
font-size: 2rem;
font-weight: 700;
color: var(--luncher-text);
margin: 0;
}
}
.suggestions-info {
width: 100%;
max-width: 900px;
margin: 12px 0 24px;
color: var(--luncher-text-secondary);
font-size: 0.95rem;
}
.suggestions-empty {
color: var(--luncher-text-secondary);
margin-top: 32px;
}
.resolved-section {
width: 100%;
max-width: 900px;
margin-top: 48px;
h2 {
font-size: 1.4rem;
font-weight: 700;
color: var(--luncher-text);
margin-bottom: 8px;
}
}
.suggestions-table.resolved {
th {
background: var(--luncher-text-secondary);
}
td.col-score {
color: var(--luncher-text-secondary);
}
}
.suggestions-table {
width: 100%;
max-width: 900px;
background: var(--luncher-bg-card);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
border: 1px solid var(--luncher-border-light);
overflow: hidden;
border-collapse: collapse;
th {
background: var(--luncher-primary);
color: #ffffff;
padding: 12px 20px;
text-align: left;
font-weight: 600;
font-size: 0.9rem;
}
td {
padding: 12px 20px;
border-bottom: 1px solid var(--luncher-border-light);
color: var(--luncher-text);
font-size: 0.9rem;
vertical-align: middle;
}
.col-score {
text-align: center;
width: 80px;
font-weight: 600;
}
td.col-score {
color: var(--luncher-primary);
}
.col-actions {
text-align: center;
width: 150px;
white-space: nowrap;
}
tbody tr {
cursor: pointer;
transition: var(--luncher-transition);
&:hover {
background: var(--luncher-bg-hover);
}
&:last-child td {
border-bottom: none;
}
}
.vote-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 6px 8px;
border-radius: var(--luncher-radius-sm, 6px);
color: var(--luncher-text-secondary);
transition: var(--luncher-transition);
&:hover {
background: var(--luncher-bg-hover);
color: var(--luncher-text);
}
&.vote-up.active {
color: #2e7d32;
}
&.vote-down.active {
color: #c62828;
}
&.delete-btn:hover {
color: #c62828;
}
}
}
}
-187
View File
@@ -1,187 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { Button, OverlayTrigger, Tooltip } from "react-bootstrap";
import { ToastContainer } from "react-toastify";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faThumbsUp, faThumbsDown, faTrash, faPlus, faGear } from "@fortawesome/free-solid-svg-icons";
import Header from "../components/Header";
import Footer from "../components/Footer";
import Loader from "../components/Loader";
import { useAuth } from "../context/auth";
import Login from "../Login";
import AddSuggestionModal from "../components/modals/AddSuggestionModal";
import SuggestionDetailModal from "../components/modals/SuggestionDetailModal";
import {
Suggestion,
VoteDirection,
listSuggestions,
addSuggestion,
voteSuggestion,
deleteSuggestion,
} from "../../../types";
import "./SuggestionsPage.scss";
export default function SuggestionsPage() {
const auth = useAuth();
const [suggestions, setSuggestions] = useState<Suggestion[]>();
const [addModalOpen, setAddModalOpen] = useState(false);
const [detail, setDetail] = useState<Suggestion>();
const reload = useCallback(async () => {
if (!auth?.login) return;
const response = await listSuggestions();
setSuggestions(response.data ?? []);
}, [auth?.login]);
useEffect(() => {
reload();
}, [reload]);
const handleAdd = async (title: string, description: string) => {
const response = await addSuggestion({ body: { title, description } });
if (response.data) {
setSuggestions(response.data);
}
};
const handleVote = async (id: string, direction: VoteDirection) => {
const response = await voteSuggestion({ body: { id, direction } });
if (response.data) {
setSuggestions(response.data);
}
};
const handleDelete = async (suggestion: Suggestion) => {
if (!window.confirm(`Opravdu chcete smazat návrh „${suggestion.title}“? Smažou se i všechny jeho hlasy.`)) {
return;
}
const response = await deleteSuggestion({ body: { id: suggestion.id } });
if (response.data) {
setSuggestions(response.data);
}
};
// Vykreslí jeden řádek tabulky. Vyřešené návrhy jsou read-only (bez hlasování),
// ale autor je stále může smazat.
const renderRow = (suggestion: Suggestion) => (
<OverlayTrigger
key={suggestion.id}
placement="top"
overlay={<Tooltip id={`tooltip-${suggestion.id}`}>{suggestion.description}</Tooltip>}
>
<tr onClick={() => setDetail(suggestion)}>
<td>{suggestion.author}</td>
<td>{suggestion.title}</td>
<td className="col-score">{suggestion.voteScore}</td>
<td className="col-actions" onClick={e => e.stopPropagation()}>
{!suggestion.resolved && (
<>
<button
type="button"
className={`vote-btn vote-up ${suggestion.myVote === VoteDirection.UP ? "active" : ""}`}
title="Hlasovat pro"
onClick={() => handleVote(suggestion.id, VoteDirection.UP)}
>
<FontAwesomeIcon icon={faThumbsUp} />
</button>
<button
type="button"
className={`vote-btn vote-down ${suggestion.myVote === VoteDirection.DOWN ? "active" : ""}`}
title="Hlasovat proti"
onClick={() => handleVote(suggestion.id, VoteDirection.DOWN)}
>
<FontAwesomeIcon icon={faThumbsDown} />
</button>
</>
)}
{suggestion.isMine && (
<button
type="button"
className="vote-btn delete-btn"
title="Smazat návrh"
onClick={() => handleDelete(suggestion)}
>
<FontAwesomeIcon icon={faTrash} />
</button>
)}
</td>
</tr>
</OverlayTrigger>
);
if (!auth?.login) {
return <Login />;
}
if (!suggestions) {
return <Loader icon={faGear} description={"Načítám návrhy..."} animation={"fa-bounce"} />;
}
const activeSuggestions = suggestions.filter(s => !s.resolved);
const resolvedSuggestions = suggestions.filter(s => s.resolved);
return (
<>
<Header />
<div className="suggestions-page">
<div className="suggestions-header">
<h1>Návrhy na vylepšení</h1>
<Button onClick={() => setAddModalOpen(true)}>
<FontAwesomeIcon icon={faPlus} /> Přidat návrh
</Button>
</div>
<p className="suggestions-info">
Zde můžete navrhovat vylepšení aplikace a hlasovat o návrzích ostatních. U každého návrhu je
zobrazeno jméno navrhovatele. Jména hlasujících jsou dostupná pouze administrátorům.
</p>
{suggestions.length === 0 ? (
<p className="suggestions-empty">Zatím nebyly přidány žádné návrhy. Buďte první!</p>
) : (
<>
{activeSuggestions.length > 0 && (
<table className="suggestions-table">
<thead>
<tr>
<th>Navrhovatel</th>
<th>Název</th>
<th className="col-score">Hlasy</th>
<th className="col-actions">Akce</th>
</tr>
</thead>
<tbody>
{activeSuggestions.map(renderRow)}
</tbody>
</table>
)}
{resolvedSuggestions.length > 0 && (
<div className="resolved-section">
<h2>Vyřešené návrhy</h2>
<p className="suggestions-info">
Tyto návrhy již byly zapracovány. Nelze pro ně hlasovat, autor je však může odstranit.
</p>
<table className="suggestions-table resolved">
<thead>
<tr>
<th>Navrhovatel</th>
<th>Název</th>
<th className="col-score">Hlasy</th>
<th className="col-actions">Akce</th>
</tr>
</thead>
<tbody>
{resolvedSuggestions.map(renderRow)}
</tbody>
</table>
</div>
)}
</>
)}
</div>
<Footer />
<AddSuggestionModal isOpen={addModalOpen} onClose={() => setAddModalOpen(false)} onSubmit={handleAdd} />
<SuggestionDetailModal suggestion={detail} onClose={() => setDetail(undefined)} />
<ToastContainer />
</>
);
}
-466
View File
@@ -1,466 +0,0 @@
// ============================================
// STYL "BOCCHI THE ROCK!" (volitelný, výchozí je jednoduchý styl)
// Inspirováno anime Bocchi the Rock! (ぼっち・ざ・ろっく!) a kapelou Kessoku Band (結束バンド).
// Růžová tepláková bunda Bocchi, žlutá a modrá kostička z jejích vlasů,
// černý Les Paul, flyery z live house STARRY — tlusté černé linky, tvrdé stíny,
// rastr jako z kopírky a samolepky. Občas se nápis „zasekne" Bocchiinou úzkostí.
// Tmavý režim = koncert v live house STARRY pod barevnými světly.
// Aktivní, pokud má <html> atribut data-luncher-theme="bocchi".
// ============================================
$bocchi: 'html[data-luncher-theme="bocchi"]';
// Kessoku Band: Bocchi (růžová), Nijika (žlutá), Ryo (modrá), Kita (červená)
$bocchi-band: linear-gradient(90deg, #e0457b 0 25%, #ffcf33 25% 50%, #3a63c9 50% 75%, #d93a30 75% 100%);
#{$bocchi} {
--bocchi-font: 'M PLUS Rounded 1c', 'Nunito', -apple-system, sans-serif;
--bocchi-display: 'Dela Gothic One', 'M PLUS Rounded 1c', sans-serif;
--bocchi-pink: #cc3570; // tepláková bunda Bocchi
--bocchi-pink-deep: #a8245a;
--bocchi-pink-light: #ffd6e5;
--bocchi-nijika: #ffcf33; // kostička ve vlasech / Nijika
--bocchi-ryo: #3a63c9; // kostička ve vlasech / Ryo
--bocchi-kita: #d93a30;
--bocchi-ink: #1d1a22; // černý Les Paul, linky flyeru
--bocchi-line: var(--bocchi-ink);
--bocchi-hard-shadow: rgb(29 26 34 / 0.9);
--bocchi-halftone: rgb(224 69 123 / 0.13);
--luncher-primary: var(--bocchi-pink);
--luncher-primary-hover: var(--bocchi-pink-deep);
--luncher-primary-light: #ffe4ee;
--luncher-bg: #fff6f9;
--luncher-bg-card: #ffffff;
--luncher-bg-hover: #fff0f5;
--luncher-text: var(--bocchi-ink);
--luncher-text-secondary: #4a4252;
--luncher-text-muted: #8f8597;
--luncher-border: #e9d9e1;
--luncher-border-light: #f5e9ef;
--luncher-navbar-bg: var(--bocchi-ink);
--luncher-navbar-text: #ffffff;
--luncher-action-icon: var(--bocchi-pink);
--luncher-buyer-icon: var(--bocchi-ryo);
--luncher-success: #2e8a4f;
--luncher-warning: #d08f00;
--luncher-danger: var(--bocchi-kita);
--luncher-radius-sm: 6px;
--luncher-radius: 8px;
--luncher-radius-lg: 10px;
--luncher-radius-xl: 12px;
--luncher-shadow: 4px 4px 0 var(--bocchi-hard-shadow);
--luncher-shadow-lg: 6px 6px 0 var(--bocchi-pink);
&[data-bs-theme="dark"] {
// Live house STARRY — tma, kouř a barevná světla nad pódiem
--bocchi-pink: #ff6fa0;
--bocchi-pink-deep: #e0457b;
--bocchi-pink-light: #4a1f33;
--bocchi-ryo: #7d9cff;
--bocchi-kita: #ff6a5c;
--bocchi-line: #5a4d66;
--bocchi-hard-shadow: #8c2350;
--bocchi-halftone: rgb(255 111 160 / 0.10);
--luncher-primary: #ff7aa8;
--luncher-primary-hover: #ff9cbf;
--luncher-primary-light: #3a1a2b;
--luncher-bg: #121016;
--luncher-bg-card: #1d1a24;
--luncher-bg-hover: #282231;
--luncher-text: #f5eff8;
--luncher-text-secondary: #c9bfd2;
--luncher-text-muted: #8f8499;
--luncher-border: #3a3246;
--luncher-border-light: #2a2433;
--luncher-navbar-bg: #0a090c;
--luncher-action-icon: #ff7aa8;
--luncher-buyer-icon: #ffcf33;
--luncher-success: #6fd59a;
--luncher-warning: #ffcf33;
--luncher-danger: #ff6a5c;
--luncher-shadow: 4px 4px 0 var(--bocchi-hard-shadow);
--luncher-shadow-lg: 6px 6px 0 var(--bocchi-ryo);
}
body {
font-family: var(--bocchi-font);
background-color: var(--luncher-bg);
// Rastr jako z kopírovaného flyeru, nahoře růžový nádech
background-image:
radial-gradient(circle, var(--bocchi-halftone) 0 1.4px, transparent 1.9px),
linear-gradient(180deg, #ffe0ec 0, var(--luncher-bg) 360px);
background-size: 12px 12px, 100% 100%;
background-attachment: fixed;
}
&[data-bs-theme="dark"] body {
// Růžové a modré světlo z rampy nad pódiem
background-image:
radial-gradient(circle, var(--bocchi-halftone) 0 1.4px, transparent 1.9px),
radial-gradient(ellipse 40% 55% at 15% -10%, rgb(255 111 160 / 0.22), transparent 70%),
radial-gradient(ellipse 40% 55% at 85% -10%, rgb(125 156 255 / 0.20), transparent 70%),
linear-gradient(180deg, #1c1622 0, var(--luncher-bg) 420px);
background-size: 12px 12px, 100% 100%, 100% 100%, 100% 100%;
}
.app-container {
background: transparent;
}
h1, h2, h3, h4, .navbar-brand {
font-family: var(--bocchi-display);
font-weight: 400;
}
.btn {
font-family: var(--bocchi-font);
}
// ---------- Navbar: černá jako Les Paul, pod ním pruh Kessoku Band ----------
.navbar {
background:
#{$bocchi-band} bottom / 100% 5px no-repeat,
var(--luncher-navbar-bg) !important;
padding-bottom: calc(var(--bs-navbar-padding-y, 0.5rem) + 5px);
.navbar-brand {
letter-spacing: 0.5px;
animation: bocchi-glitch 7s steps(1, end) infinite;
// Žlutá a modrá kostička z vlasů Bocchi
&::after {
content: '';
display: inline-block;
width: 0.42em;
height: 0.42em;
margin-left: 8px;
margin-right: 0.6em;
vertical-align: super;
background: var(--bocchi-nijika);
box-shadow: 0.55em 0.18em 0 var(--bocchi-ryo);
transform: rotate(-12deg);
}
}
.dropdown-menu {
border: 2px solid var(--bocchi-line);
border-radius: var(--luncher-radius);
box-shadow: var(--luncher-shadow);
}
}
// ---------- Titulek dne: žlutá samolepka z flyeru ----------
.day-navigator .title {
position: relative;
display: inline-block;
padding: 6px 24px 8px;
font-family: var(--bocchi-display);
font-weight: 400;
font-size: 1.6rem;
color: var(--bocchi-ink) !important;
background: var(--bocchi-nijika);
border: 3px solid var(--bocchi-ink);
border-radius: var(--luncher-radius-sm);
box-shadow: 5px 5px 0 var(--bocchi-pink);
transform: rotate(1.2deg);
&::before, &::after {
content: '★';
font-size: 0.7em;
margin: 0 10px;
vertical-align: middle;
color: var(--bocchi-pink-deep);
}
&.text-muted {
background: #e6e0e8;
color: #6b6273 !important;
box-shadow: 5px 5px 0 #b9afbf;
&::before, &::after {
color: #8f8597;
}
}
}
// ---------- Karty podniků: flyery s tvrdým stínem ----------
.restaurant-card {
border: 2px solid var(--bocchi-line);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
&:hover {
transform: translate(-2px, -2px);
border-color: var(--bocchi-line);
box-shadow: var(--luncher-shadow-lg);
}
.restaurant-header {
position: relative;
background:
#{$bocchi-band} bottom / 100% 4px no-repeat,
var(--bocchi-ink);
&:hover {
background:
#{$bocchi-band} bottom / 100% 4px no-repeat,
linear-gradient(90deg, var(--bocchi-pink-deep), var(--bocchi-pink));
}
h3 {
letter-spacing: 0.3px;
}
small {
color: rgb(255 255 255 / 0.85);
}
}
}
&[data-bs-theme="dark"] .restaurant-card .restaurant-header:not(:hover) {
background:
#{$bocchi-band} bottom / 100% 4px no-repeat,
#0a090c;
}
.food-table tbody tr:hover > td {
background: #fff0f5;
}
&[data-bs-theme="dark"] .food-table tbody tr:hover > td {
background: #2e2236;
}
.food-price {
color: var(--bocchi-pink);
font-weight: 800;
}
// ---------- Panely a tabulka voleb ----------
.choice-section,
.choices-table,
.pizza-section,
.no-votes {
border: 2px solid var(--bocchi-line);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
}
// <table> s border-collapse: collapse ignoruje border-radius — kulaté rohy jen se „separate"
.choices-table {
border-collapse: separate;
border-spacing: 0;
}
// Sloupec s podnikem: světle žlutý jako Nijika, oddělený černou linkou
.choices-table > tbody > tr > td:first-child {
background: #fff4c7;
color: var(--bocchi-ink);
font-weight: 800;
border-right: 2px solid var(--bocchi-line);
}
&[data-bs-theme="dark"] .choices-table > tbody > tr > td:first-child {
background: #2a2416;
color: var(--bocchi-nijika);
}
// Bootstrap karty (skupinové objednávky apod.) a seznamy — stejné linky a stíny jako ostatní panely
.card {
border: 2px solid var(--bocchi-line);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
overflow: hidden;
background: var(--luncher-bg-card);
// Rozbalovací výběr z nabídky nesmí karta oříznout
&:has(.select-search-container) {
overflow: visible;
}
> .card-header {
background: var(--luncher-primary-light);
border-bottom: 2px solid var(--bocchi-line);
font-weight: 800;
&:first-child {
border-radius: calc(var(--luncher-radius-lg) - 2px) calc(var(--luncher-radius-lg) - 2px) 0 0;
}
}
// Průhledná tabulka — rohy pak zaobluje karta sama (i když nesmí ořezávat obsah)
.table {
--bs-table-bg: transparent;
margin-bottom: 0;
// Spodní linka posledního řádku by přečnívala přes zaoblené rohy
> :last-child > tr:last-child > * {
border-bottom-width: 0;
}
}
}
.list-group {
border-radius: var(--luncher-radius);
overflow: hidden;
border: 2px solid var(--bocchi-line);
> .list-group-item {
border-left: none;
border-right: none;
&:first-child {
border-top: none;
}
&:last-child {
border-bottom: none;
}
}
}
.form-select, .form-control,
input[type="text"], input[type="email"], input[type="password"] {
border-radius: var(--luncher-radius-sm);
border: 2px solid var(--bocchi-line);
&:focus {
border-color: var(--bocchi-pink);
box-shadow: 3px 3px 0 var(--bocchi-pink);
}
}
// ---------- Tlačítka: samolepky s černou linkou, po stisku „zaklapnou" do stínu ----------
.btn {
border-radius: var(--luncher-radius-sm);
font-weight: 800;
letter-spacing: 0.2px;
transition: transform 0.1s ease, box-shadow 0.1s ease, background 0.2s ease;
&:active {
transform: translate(3px, 3px) !important;
box-shadow: none !important;
}
}
.btn-primary {
background: var(--bocchi-pink);
border: 2px solid var(--bocchi-ink);
color: #fff;
box-shadow: 3px 3px 0 var(--bocchi-ink);
&:hover {
background: var(--bocchi-pink-deep);
border-color: var(--bocchi-ink);
box-shadow: 3px 3px 0 var(--bocchi-ink), 0 0 0 3px rgb(255 207 51 / 0.6);
}
&:disabled {
background: #cfc6d3;
border-color: #8f8597;
box-shadow: 3px 3px 0 #8f8597;
}
}
&[data-bs-theme="dark"] .btn-primary {
color: #1d1a22;
&:hover {
color: #fff;
}
}
.btn-outline-primary {
background: var(--luncher-bg-card);
border: 2px solid var(--bocchi-line);
color: var(--luncher-primary);
box-shadow: 3px 3px 0 var(--bocchi-hard-shadow);
&:hover {
background: var(--luncher-primary-light);
border-color: var(--bocchi-line);
color: var(--luncher-primary);
}
}
.btn-secondary {
background: var(--luncher-bg-card);
border: 2px solid var(--bocchi-line);
color: var(--luncher-text);
box-shadow: 3px 3px 0 var(--bocchi-hard-shadow);
&:hover {
background: var(--luncher-bg-hover);
border-color: var(--bocchi-line);
color: var(--luncher-text);
}
}
.btn-danger {
background: var(--bocchi-kita);
border: 2px solid var(--bocchi-ink);
box-shadow: 3px 3px 0 var(--bocchi-ink);
}
.action-icon:hover {
background: var(--luncher-primary-light);
color: var(--bocchi-pink);
}
// ---------- Modaly ----------
.modal-content {
border: 3px solid var(--bocchi-line);
border-radius: var(--luncher-radius-xl);
box-shadow: 8px 8px 0 var(--bocchi-pink);
overflow: hidden;
}
.modal-header {
background:
#{$bocchi-band} bottom / 100% 5px no-repeat,
#0f0d12;
border-bottom: none;
padding-bottom: calc(var(--bs-modal-header-padding-y, 1rem) + 5px);
.modal-title, .modal-title h2 {
color: #fff;
font-family: var(--bocchi-display);
font-weight: 400;
}
.btn-close {
filter: invert(1) grayscale(1) brightness(2);
}
}
.footer {
border-top: none;
background-image: #{$bocchi-band};
background-size: 100% 4px;
background-repeat: no-repeat;
background-position: top;
}
}
// Bocchiina úzkost: nápis se na okamžik „rozpadne" do růžového a modrého posunu
@keyframes bocchi-glitch {
0%, 92%, 100% { text-shadow: none; transform: none; }
93% { text-shadow: -3px 0 #ff6fa0, 3px 0 #7d9cff; transform: translateX(1px) skewX(-6deg); }
94% { text-shadow: 2px 0 #ff6fa0, -2px 0 #7d9cff; transform: translateX(-2px); }
95% { text-shadow: -1px 0 #ff6fa0, 1px 0 #7d9cff; transform: translateX(1px) skewX(4deg); }
96% { text-shadow: none; transform: none; }
}
@media (max-width: 575.98px) {
#{$bocchi} .day-navigator .title {
font-size: 1.3rem;
}
}
@media (prefers-reduced-motion: reduce) {
#{$bocchi} .navbar .navbar-brand {
animation: none;
}
}
-445
View File
@@ -1,445 +0,0 @@
// ============================================
// STYL "HELL GIRL" (volitelný, výchozí je jednoduchý styl)
// Inspirováno anime Jigoku Shōjo (地獄少女).
// Věčný soumrak Ai Enmy (purpurovo-rudé nebe), papír washi, karmínová rudá
// nit ze slaměné panenky, květy lilie pavoukovité (higanbana) a ostré,
// tradičně japonské tvary s písmem mincho.
// Tmavý režim = půlnoc, kdy se otevírá Pekelná korespondence (地獄通信).
// Aktivní, pokud má <html> atribut data-luncher-theme="hellgirl".
// ============================================
$hg: 'html[data-luncher-theme="hellgirl"]';
// Nebe věčného soumraku (fialová → karmínová → oranžová)
$hg-twilight: linear-gradient(90deg, #1e0a1c, #4a0f2a 35%, #8e0f24 70%, #c8452a);
// Rudá nit — tenká linka s jemnou září
$hg-thread: linear-gradient(90deg, transparent, #c8102e 12%, #e8344f 50%, #c8102e 88%, transparent);
#{$hg} {
--hg-font: 'Shippori Mincho', 'Noto Serif JP', 'Georgia', serif;
--hg-crimson: #a3122a; // kimono Ai / rudá nit
--hg-crimson-deep: #7a0c1f;
--hg-crimson-shadow: #4a0612;
--hg-higanbana: #d4203c; // lilie pavoukovitá
--hg-dusk: #c8452a; // oranžové světlo soumraku
--hg-ink: #1c1012; // černé vlasy / tuš
--hg-washi: #f4ece0; // papír washi
--hg-card-border: #dccbbb;
--hg-petal: rgb(163 18 42 / 0.10);
--hg-petal-2: rgb(200 69 42 / 0.08);
--luncher-primary: var(--hg-crimson);
--luncher-primary-hover: var(--hg-crimson-deep);
--luncher-primary-light: #f7e3e2;
--luncher-bg: var(--hg-washi);
--luncher-bg-card: #fffbf5;
--luncher-bg-hover: #f8ebe6;
--luncher-text: var(--hg-ink);
--luncher-text-secondary: #4f3a3c;
--luncher-text-muted: #8c7672;
--luncher-border: var(--hg-card-border);
--luncher-border-light: #eadfd2;
--luncher-navbar-bg: var(--hg-crimson-deep);
--luncher-navbar-text: #fff4ee;
--luncher-action-icon: var(--hg-crimson);
--luncher-buyer-icon: var(--hg-dusk);
--luncher-success: #3d7a4a;
--luncher-warning: #c07a1a;
--luncher-danger: #c8102e;
// Ostré, tradiční tvary — téměř bez zaoblení
--luncher-radius-sm: 2px;
--luncher-radius: 3px;
--luncher-radius-lg: 4px;
--luncher-radius-xl: 6px;
--luncher-shadow: 0 3px 12px -4px rgb(74 6 18 / 0.25);
--luncher-shadow-lg: 0 10px 26px -8px rgb(74 6 18 / 0.38);
&[data-bs-theme="dark"] {
// Půlnoc — Pekelná korespondence je otevřena
--hg-crimson: #e0334f;
--hg-crimson-deep: #b81c38;
--hg-crimson-shadow: #3a0510;
--hg-higanbana: #ff4d63;
--hg-dusk: #ff7a4a;
--hg-card-border: #3d1c22;
--hg-petal: rgb(224 51 79 / 0.22);
--hg-petal-2: rgb(255 122 74 / 0.12);
--luncher-primary: #ec4a63;
--luncher-primary-hover: #ff6b80;
--luncher-primary-light: #2e1015;
--luncher-bg: #0c0608;
--luncher-bg-card: #170c0f;
--luncher-bg-hover: #221216;
--luncher-text: #f3e6e2;
--luncher-text-secondary: #c9aeaa;
--luncher-text-muted: #8a6c6a;
--luncher-border: #3d1c22;
--luncher-border-light: #2a1418;
--luncher-navbar-bg: #12070a;
--luncher-action-icon: #ec4a63;
--luncher-buyer-icon: #ff9a6b;
--luncher-success: #6fbf7e;
--luncher-warning: #f0b45a;
--luncher-danger: #ff4d63;
--luncher-shadow: 0 4px 16px -4px rgb(0 0 0 / 0.7);
--luncher-shadow-lg: 0 12px 30px -8px rgb(0 0 0 / 0.8), 0 0 18px -6px rgb(224 51 79 / 0.25);
}
body {
font-family: var(--hg-font);
background-color: var(--luncher-bg);
// Rozfoukané okvětní lístky higanbany nad papírem, nahoře záře soumraku
background-image:
radial-gradient(ellipse 3px 1.5px at 20% 30%, var(--hg-petal) 0 100%, transparent 101%),
radial-gradient(ellipse 2px 4px at 70% 75%, var(--hg-petal-2) 0 100%, transparent 101%),
radial-gradient(ellipse 2.5px 1.2px at 85% 15%, var(--hg-petal) 0 100%, transparent 101%),
linear-gradient(180deg, #f3d9cc 0, var(--luncher-bg) 380px);
background-size: 150px 150px, 210px 210px, 260px 260px, 100% 100%;
background-attachment: fixed;
}
&[data-bs-theme="dark"] body {
// Půlnoční tma s rudou září pekelného ohně odspodu
background-image:
radial-gradient(ellipse 3px 1.5px at 20% 30%, var(--hg-petal) 0 100%, transparent 101%),
radial-gradient(ellipse 2px 4px at 70% 75%, var(--hg-petal-2) 0 100%, transparent 101%),
radial-gradient(ellipse 2.5px 1.2px at 85% 15%, var(--hg-petal) 0 100%, transparent 101%),
radial-gradient(ellipse 120% 45% at 50% 115%, rgb(140 12 36 / 0.35), transparent 70%),
linear-gradient(180deg, #1c0a12 0, var(--luncher-bg) 420px);
background-size: 150px 150px, 210px 210px, 260px 260px, 100% 100%, 100% 100%;
}
.app-container {
background: transparent;
}
h1, h2, h3, h4, .navbar-brand, .btn {
font-family: var(--hg-font);
}
// ---------- Navbar: nebe soumraku s rudou nití dole ----------
.navbar {
background:
#{$hg-thread} bottom / 100% 2px no-repeat,
#{$hg-twilight} !important;
padding-bottom: calc(var(--bs-navbar-padding-y, 0.5rem) + 2px);
box-shadow: 0 3px 14px -2px rgb(74 6 18 / 0.5);
.navbar-brand {
font-weight: 800;
letter-spacing: 1.5px;
text-shadow: 0 0 12px rgb(232 52 79 / 0.6);
// Pekelná korespondence — otevírá se jen o půlnoci
&::after {
content: '地獄通信';
display: inline-block;
margin-left: 8px;
font-size: 0.55em;
font-weight: 600;
letter-spacing: 2px;
vertical-align: middle;
color: #ffb3a8;
opacity: 0.85;
animation: hg-flicker 5s ease-in-out infinite;
}
}
.dropdown-menu {
border: 1px solid var(--hg-crimson);
border-radius: var(--luncher-radius);
}
}
&[data-bs-theme="dark"] .navbar {
background:
#{$hg-thread} bottom / 100% 2px no-repeat,
linear-gradient(90deg, #0a0406, #1f0810 55%, #3d0a18) !important;
}
// ---------- Titulek dne: černá lakovaná deska s rudým lemem ----------
.day-navigator .title {
position: relative;
display: inline-block;
padding: 6px 28px 8px;
font-weight: 800;
font-size: 1.7rem;
letter-spacing: 1px;
color: #fff4ee !important;
background: linear-gradient(180deg, #2a1216, #12070a);
border: 1px solid var(--hg-crimson);
outline: 1px solid rgb(163 18 42 / 0.35);
outline-offset: 3px;
border-radius: var(--luncher-radius-sm);
text-shadow: 0 0 10px rgb(232 52 79 / 0.55);
box-shadow: var(--luncher-shadow);
// Květy higanbany po stranách
&::before, &::after {
content: '✿';
font-size: 0.6em;
margin: 0 10px;
vertical-align: middle;
color: var(--hg-higanbana);
text-shadow: 0 0 6px rgb(212 32 60 / 0.7);
}
&.text-muted {
background: linear-gradient(180deg, #6b5a5a, #4a3d3e);
border-color: #8c7672;
outline-color: rgb(140 118 114 / 0.35);
text-shadow: none;
&::before, &::after {
color: #b3a19d;
text-shadow: none;
}
}
}
// Symbol ohně ze znělky místo květů (masku dodá HellGirlFlames, až se obrázek načte)
&[data-hg-symbol] .day-navigator .title {
&::before, &::after {
content: '';
display: inline-block;
font-size: 1em;
width: 0.85em;
height: 0.85em;
margin: 0 14px;
vertical-align: -0.08em;
background: linear-gradient(180deg, var(--hg-dusk), var(--hg-higanbana) 45%, var(--hg-crimson-deep));
-webkit-mask: var(--hg-symbol) center / contain no-repeat;
mask: var(--hg-symbol) center / contain no-repeat;
}
&.text-muted::before, &.text-muted::after {
background: #b3a19d;
}
}
// ---------- Karty podniků ----------
.restaurant-card {
border: 1px solid var(--hg-card-border);
border-top: 3px solid var(--hg-crimson);
border-radius: var(--luncher-radius-lg);
&:hover {
transform: translateY(-2px);
border-color: var(--hg-crimson);
box-shadow: var(--luncher-shadow-lg), 0 0 0 1px rgb(200 16 46 / 0.25);
}
.restaurant-header {
position: relative;
background:
#{$hg-thread} bottom / 100% 2px no-repeat,
linear-gradient(90deg, #1c0a10, var(--hg-crimson-deep));
&:hover {
background:
#{$hg-thread} bottom / 100% 2px no-repeat,
#{$hg-twilight};
}
h3 {
font-weight: 800;
letter-spacing: 0.5px;
text-shadow: 0 1px 0 rgb(0 0 0 / 0.4);
}
small {
color: rgb(255 244 238 / 0.85);
}
}
}
.food-table tbody tr:hover > td {
background: #f8e6e2;
}
&[data-bs-theme="dark"] .food-table tbody tr:hover > td {
background: #2a1218;
}
.food-price {
color: var(--hg-crimson);
font-weight: 700;
}
// ---------- Panely a tabulka voleb ----------
.choice-section,
.choices-table,
.pizza-section,
.no-votes {
border: 1px solid var(--hg-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
}
// Sloupec s podnikem: černá tuš s rudou nití na okraji
.choices-table > tbody > tr > td:first-child {
background: linear-gradient(90deg, #1c1012, #2e1418);
color: #fff4ee;
font-weight: 700;
border-right: 2px solid var(--hg-crimson);
}
&[data-bs-theme="dark"] .choices-table > tbody > tr > td:first-child {
background: linear-gradient(90deg, #22090f, #170c0f);
color: var(--hg-higanbana);
}
// Bootstrap karty (skupinové objednávky apod.) a seznamy
.card {
border: 1px solid var(--hg-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
background: var(--luncher-bg-card);
> .card-header {
background: var(--luncher-primary-light);
border-bottom: 2px solid var(--hg-crimson);
font-weight: 700;
}
.table {
--bs-table-bg: transparent;
margin-bottom: 0;
}
}
.list-group {
border-radius: var(--luncher-radius);
border: 1px solid var(--hg-card-border);
}
.form-select, .form-control,
input[type="text"], input[type="email"], input[type="password"] {
border-radius: var(--luncher-radius-sm);
&:focus {
border-color: var(--hg-crimson);
box-shadow: 0 0 0 3px rgb(200 16 46 / 0.2);
}
}
// ---------- Tlačítka: ostré, karmínová s dvojitou linkou jako pás obi ----------
.btn {
border-radius: var(--luncher-radius-sm);
font-weight: 700;
letter-spacing: 0.4px;
transition: transform 0.12s ease, box-shadow 0.2s ease, background 0.2s ease;
&:active {
transform: translateY(1px) !important;
}
}
.btn-primary {
background: linear-gradient(180deg, #c01733, var(--hg-crimson-deep));
border: 1px solid var(--hg-crimson-shadow);
color: #fff4ee;
box-shadow: inset 0 0 0 2px rgb(255 255 255 / 0.12);
// Rudá záře pekelného ohně při najetí
&:hover {
background: linear-gradient(180deg, #d41d3b, #8e0f24);
box-shadow: inset 0 0 0 2px rgb(255 255 255 / 0.18), 0 0 14px rgb(212 32 60 / 0.5);
}
&:disabled {
background: #b9a3a0;
border-color: #9c8582;
box-shadow: none;
}
}
.btn-outline-primary {
background: transparent;
border: 1px solid var(--hg-crimson);
color: var(--luncher-primary);
&:hover {
background: var(--hg-crimson);
color: #fff4ee;
}
}
.btn-secondary {
background: var(--luncher-bg-card);
border: 1px solid var(--luncher-border);
color: var(--luncher-text);
&:hover {
border-color: var(--hg-crimson);
background: var(--luncher-bg-hover);
color: var(--luncher-text);
}
}
.btn-danger {
background: linear-gradient(180deg, #e0243f, #8e0f24);
border: 1px solid var(--hg-crimson-shadow);
}
.action-icon:hover {
background: var(--luncher-primary-light);
color: var(--hg-higanbana);
}
// ---------- Modaly ----------
.modal-content {
border: 1px solid var(--hg-crimson);
border-radius: var(--luncher-radius-xl);
overflow: hidden;
}
.modal-header {
background:
#{$hg-thread} bottom / 100% 2px no-repeat,
#{$hg-twilight};
border-bottom: none;
.modal-title, .modal-title h2 {
color: #fff4ee;
font-weight: 800;
letter-spacing: 0.5px;
text-shadow: 0 0 10px rgb(232 52 79 / 0.5);
}
.btn-close {
filter: invert(1) grayscale(1) brightness(2);
}
}
.footer {
border-top: none;
background-image: #{$hg-thread};
background-size: 100% 1px;
background-repeat: no-repeat;
background-position: top;
}
}
// Mihotání nápisu jako plamen svíčky
@keyframes hg-flicker {
0%, 100% { opacity: 0.85; }
45% { opacity: 0.85; }
48% { opacity: 0.35; }
50% { opacity: 0.9; }
53% { opacity: 0.5; }
56% { opacity: 0.85; }
}
@media (max-width: 575.98px) {
#{$hg} .navbar .navbar-brand::after {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
#{$hg} .navbar .navbar-brand::after {
animation: none;
}
}
-483
View File
@@ -1,483 +0,0 @@
// ============================================
// STYL "K-ON!" (volitelný, výchozí je jednoduchý styl)
// Inspirováno anime K-ON! (けいおん!) — klub lehké hudby na dívčí škole Sakuragaoka.
// Tmavě modré školní sako, kytara Gitah v barvě cherry sunburst, mléčný čaj
// a dortíky z odpoledních dýchánků (Houkago Tea Time), notová osnova
// a ručně psané nápisy jako ze školního sešitu.
// Tmavý režim = večerní koncert v klubu (live house) pod reflektory.
// Aktivní, pokud má <html> atribut data-luncher-theme="kon".
// ============================================
$kon: 'html[data-luncher-theme="kon"]';
// Pruh v barvách členek Houkago Tea Time: Yui (červená Gitah), Mio (modrá),
// Ritsu (žlutá), Mugi (zlatavá) a Azusa (fialová)
$kon-members: linear-gradient(90deg, #d43f62 0 20%, #3d6fc4 20% 40%, #f2b81d 40% 60%, #e8cf7a 60% 80%, #8a6bc7 80% 100%);
// Notová osnova (pět linek) — opakuje se na pozadí po 220 px
$kon-staff-stops: transparent 0 100px,
var(--kon-staff) 100px 101px, transparent 101px 109px,
var(--kon-staff) 109px 110px, transparent 110px 118px,
var(--kon-staff) 118px 119px, transparent 119px 127px,
var(--kon-staff) 127px 128px, transparent 128px 136px,
var(--kon-staff) 136px 137px, transparent 137px;
// Rozeseté noty ♪ ♫ (ve dne červená a modrá, v noci světlejší)
$kon-notes: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Ctext x='34' y='95' font-size='26' fill='%23d43f62' fill-opacity='0.16'%3E%E2%99%AA%3C/text%3E%3Ctext x='150' y='128' font-size='30' fill='%233d6fc4' fill-opacity='0.14'%3E%E2%99%AB%3C/text%3E%3Ctext x='96' y='210' font-size='18' fill='%23e8a10f' fill-opacity='0.18'%3E%E2%99%AA%3C/text%3E%3C/svg%3E");
$kon-notes-dark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Ctext x='34' y='95' font-size='26' fill='%23ff8fa8' fill-opacity='0.22'%3E%E2%99%AA%3C/text%3E%3Ctext x='150' y='128' font-size='30' fill='%238fb2ff' fill-opacity='0.2'%3E%E2%99%AB%3C/text%3E%3Ctext x='96' y='210' font-size='18' fill='%23ffd166' fill-opacity='0.22'%3E%E2%99%AA%3C/text%3E%3C/svg%3E");
#{$kon} {
--kon-font: 'Zen Maru Gothic', 'M PLUS Rounded 1c', 'Nunito', -apple-system, sans-serif;
--kon-hand: 'Yusei Magic', 'Zen Maru Gothic', 'M PLUS Rounded 1c', sans-serif;
--kon-blazer: #27315f; // tmavě modré sako Sakuragaoka
--kon-blazer-light: #3a4782;
--kon-blazer-shadow: #171d3d;
--kon-cherry: #c43358; // Gitah — cherry sunburst
--kon-cherry-deep: #a3234a;
--kon-sunburst: #f2a33a; // jantarový střed sunburstu
--kon-ritsu: #f2b81d;
--kon-tea: #fbf3e4; // mléčný čaj
--kon-paper: #fffaf0; // notový sešit
--kon-card-border: #e6d8c0;
--kon-staff: rgb(39 49 95 / 0.07);
--luncher-primary: var(--kon-cherry);
--luncher-primary-hover: var(--kon-cherry-deep);
--luncher-primary-light: #fbe4ea;
--luncher-bg: var(--kon-tea);
--luncher-bg-card: var(--kon-paper);
--luncher-bg-hover: #f8ecd8;
--luncher-text: #222849;
--luncher-text-secondary: #4a5179;
--luncher-text-muted: #8a8aa3;
--luncher-border: var(--kon-card-border);
--luncher-border-light: #f0e6d3;
--luncher-navbar-bg: var(--kon-blazer);
--luncher-navbar-text: #ffffff;
--luncher-action-icon: var(--kon-cherry);
--luncher-buyer-icon: #e8a10f;
--luncher-success: #2f8a57;
--luncher-warning: #d98a0b;
--luncher-danger: #d0304a;
--luncher-radius-sm: 10px;
--luncher-radius: 14px;
--luncher-radius-lg: 18px;
--luncher-radius-xl: 22px;
--luncher-shadow: 0 4px 14px -6px rgb(39 49 95 / 0.25);
--luncher-shadow-lg: 0 10px 26px -8px rgb(39 49 95 / 0.35);
&[data-bs-theme="dark"] {
// Večerní koncert v klubu — reflektory nad pódiem
--kon-blazer: #1a2150;
--kon-blazer-light: #2b367a;
--kon-blazer-shadow: #0a0e26;
--kon-cherry: #ff7a9a;
--kon-cherry-deep: #e85a7e;
--kon-ritsu: #ffd166;
--kon-card-border: #2c3566;
--kon-staff: rgb(180 196 255 / 0.06);
--luncher-primary: #ff86a3;
--luncher-primary-hover: #ffa8bd;
--luncher-primary-light: #3a1d35;
--luncher-bg: #0e1230;
--luncher-bg-card: #171c42;
--luncher-bg-hover: #212858;
--luncher-text: #f1f0ff;
--luncher-text-secondary: #bfc2e6;
--luncher-text-muted: #8085b3;
--luncher-border: #2c3566;
--luncher-border-light: #232a58;
--luncher-navbar-bg: #141a44;
--luncher-action-icon: #ff86a3;
--luncher-buyer-icon: #ffd166;
--luncher-success: #6fd59a;
--luncher-warning: #ffd166;
--luncher-danger: #ff6f86;
--luncher-shadow: 0 4px 16px -4px rgb(0 0 0 / 0.55);
--luncher-shadow-lg: 0 12px 30px -8px rgb(0 0 0 / 0.65);
}
body {
font-family: var(--kon-font);
background-color: var(--luncher-bg);
// Notový sešit: osnova s notami, nahoře teplé odpolední světlo klubovny
background-image:
#{$kon-notes},
linear-gradient(180deg, #{$kon-staff-stops}),
linear-gradient(180deg, #fde4d6 0, var(--luncher-bg) 360px);
background-size: 220px 220px, 100% 220px, 100% 100%;
background-attachment: fixed;
}
&[data-bs-theme="dark"] body {
// Dva reflektory (růžový a jantarový) svítí z horních rohů na pódium
background-image:
#{$kon-notes-dark},
linear-gradient(180deg, #{$kon-staff-stops}),
radial-gradient(ellipse 45% 60% at 10% -10%, rgb(255 122 154 / 0.18), transparent 70%),
radial-gradient(ellipse 45% 60% at 90% -10%, rgb(255 209 102 / 0.14), transparent 70%),
linear-gradient(180deg, #1a1f4d 0, var(--luncher-bg) 420px);
background-size: 220px 220px, 100% 220px, 100% 100%, 100% 100%, 100% 100%;
}
.app-container {
background: transparent;
}
h1, h2, h3, h4, .navbar-brand, .btn {
font-family: var(--kon-font);
}
// ---------- Navbar: školní sako s pruhem v barvách kapely ----------
.navbar {
background:
#{$kon-members} bottom / 100% 4px no-repeat,
linear-gradient(90deg, var(--kon-blazer-shadow), var(--kon-blazer) 40%, var(--kon-blazer-light)) !important;
padding-bottom: calc(var(--bs-navbar-padding-y, 0.5rem) + 4px);
box-shadow: 0 3px 10px -2px rgb(23 29 61 / 0.4);
.navbar-brand {
font-weight: 900;
letter-spacing: 0.5px;
text-shadow: 0 2px 0 var(--kon-blazer-shadow);
// Nota, která poskakuje do rytmu
&::after {
content: '♪';
display: inline-block;
margin-left: 6px;
color: var(--kon-ritsu);
font-size: 0.85em;
vertical-align: super;
animation: kon-bounce 1.2s ease-in-out infinite;
}
}
.dropdown-menu {
border: 2px solid var(--kon-card-border);
border-radius: var(--luncher-radius);
}
}
// ---------- Titulek dne: ručně psaná cedulka z dveří klubovny ----------
.day-navigator .title {
position: relative;
display: inline-block;
padding: 4px 26px 6px;
font-family: var(--kon-hand);
font-weight: 400;
font-size: 1.8rem;
color: var(--kon-blazer) !important;
background:
repeating-linear-gradient(180deg, transparent 0 11px, rgb(61 111 196 / 0.12) 11px 12px),
var(--kon-paper);
border: 3px solid var(--kon-blazer);
border-radius: 12px;
box-shadow: 4px 4px 0 var(--kon-cherry);
transform: rotate(-1.5deg);
&::before {
content: '♪';
margin-right: 10px;
color: var(--kon-cherry);
}
&::after {
content: '♫';
margin-left: 10px;
color: #3d6fc4;
}
&.text-muted {
color: #8a8aa3 !important;
border-color: #a9a9be;
box-shadow: 4px 4px 0 #cfcfdc;
&::before, &::after {
color: #a9a9be;
}
}
}
&[data-bs-theme="dark"] .day-navigator .title {
color: #f1f0ff !important;
background:
repeating-linear-gradient(180deg, transparent 0 11px, rgb(143 178 255 / 0.12) 11px 12px),
var(--luncher-bg-card);
border-color: #8fb2ff;
&::after {
color: #8fb2ff;
}
&.text-muted {
color: #8085b3 !important;
border-color: #4a5288;
box-shadow: 4px 4px 0 #2c3566;
}
}
// ---------- Karty podniků ----------
.restaurant-card {
border: 2px solid var(--kon-card-border);
border-radius: var(--luncher-radius-lg);
&:hover {
transform: translateY(-3px) rotate(-0.3deg);
border-color: var(--kon-cherry);
box-shadow: var(--luncher-shadow-lg);
}
.restaurant-header {
position: relative;
background:
#{$kon-members} bottom / 100% 4px no-repeat,
linear-gradient(90deg, var(--kon-blazer), var(--kon-blazer-light));
// Gitah: jantarový střed přechází do třešňové
&:hover {
background:
#{$kon-members} bottom / 100% 4px no-repeat,
radial-gradient(ellipse 80% 160% at 30% 50%, var(--kon-sunburst), var(--kon-cherry) 55%, #6e1530);
}
h3 {
font-weight: 900;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.2);
}
small {
color: rgb(255 255 255 / 0.88);
}
}
}
.food-table tbody tr:hover > td {
background: #fdeee0;
}
&[data-bs-theme="dark"] .food-table tbody tr:hover > td {
background: #232a5c;
}
.food-price {
color: var(--kon-cherry);
font-weight: 700;
}
// ---------- Panely a tabulka voleb ----------
.choice-section,
.choices-table,
.pizza-section,
.no-votes {
border: 2px solid var(--kon-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
}
// <table> s border-collapse: collapse ignoruje border-radius — kulaté rohy jen se „separate"
.choices-table {
border-collapse: separate;
border-spacing: 0;
}
// Sloupec s podnikem: linkovaný papír se svislou okrajovou linkou jako v sešitě
.choices-table > tbody > tr > td:first-child {
background: #fdf1dc;
color: var(--kon-blazer);
font-weight: 700;
border-right: 2px solid rgb(196 51 88 / 0.45);
}
&[data-bs-theme="dark"] .choices-table > tbody > tr > td:first-child {
background: #1f2552;
color: #c9d6ff;
border-right-color: rgb(255 134 163 / 0.5);
}
// Bootstrap karty (skupinové objednávky apod.) a seznamy — stejné zaoblení a lem jako ostatní panely
.card {
border: 2px solid var(--kon-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
overflow: hidden;
background: var(--luncher-bg-card);
// Rozbalovací výběr z nabídky nesmí karta oříznout
&:has(.select-search-container) {
overflow: visible;
}
> .card-header {
background: var(--luncher-primary-light);
border-bottom: 2px solid var(--kon-card-border);
font-weight: 700;
&:first-child {
border-radius: calc(var(--luncher-radius-lg) - 2px) calc(var(--luncher-radius-lg) - 2px) 0 0;
}
}
// Průhledná tabulka — rohy pak zaobluje karta sama (i když nesmí ořezávat obsah)
.table {
--bs-table-bg: transparent;
margin-bottom: 0;
// Spodní linka posledního řádku by přečnívala přes zaoblené rohy
> :last-child > tr:last-child > * {
border-bottom-width: 0;
}
}
}
.list-group {
border-radius: var(--luncher-radius);
overflow: hidden;
border: 2px solid var(--kon-card-border);
> .list-group-item {
border-left: none;
border-right: none;
&:first-child {
border-top: none;
}
&:last-child {
border-bottom: none;
}
}
}
.form-select, .form-control,
input[type="text"], input[type="email"], input[type="password"] {
border-radius: var(--luncher-radius-sm);
border-width: 2px;
&:focus {
border-color: var(--kon-cherry);
box-shadow: 0 0 0 4px rgb(196 51 88 / 0.18);
}
}
// ---------- Tlačítka: zaoblené jako pedály efektů, s tmavě modrým „podstavcem" ----------
.btn {
border-radius: var(--luncher-radius-sm);
font-weight: 700;
letter-spacing: 0.2px;
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.2s ease;
&:active {
transform: translateY(3px) !important;
box-shadow: 0 1px 0 var(--kon-btn-shadow, transparent) !important;
}
}
.btn-primary {
--kon-btn-shadow: var(--kon-blazer-shadow);
background: linear-gradient(180deg, #e0567a, var(--kon-cherry-deep));
border: none;
color: #fff;
box-shadow: 0 4px 0 var(--kon-blazer);
text-shadow: 0 1px 0 rgb(0 0 0 / 0.2);
// Sunburst při najetí
&:hover {
background: radial-gradient(ellipse 90% 140% at 50% 40%, var(--kon-sunburst), var(--kon-cherry) 60%, var(--kon-cherry-deep));
box-shadow: 0 4px 0 var(--kon-blazer), 0 0 12px rgb(242 163 58 / 0.45);
}
&:disabled {
background: #c8c3cf;
box-shadow: 0 4px 0 #a5a1b0;
}
}
.btn-outline-primary {
--kon-btn-shadow: var(--kon-card-border);
background: var(--luncher-bg-card);
border: 2px solid var(--kon-blazer);
color: var(--kon-blazer);
box-shadow: 0 4px 0 var(--kon-card-border);
&:hover {
background: var(--kon-blazer);
color: #fff;
}
}
&[data-bs-theme="dark"] .btn-outline-primary {
border-color: #8fb2ff;
color: #c9d6ff;
&:hover {
background: #2b367a;
color: #fff;
}
}
.btn-secondary {
--kon-btn-shadow: var(--luncher-border);
background: var(--luncher-bg-card);
border: 2px solid var(--luncher-border);
color: var(--luncher-text);
box-shadow: 0 4px 0 var(--luncher-border);
}
.btn-danger {
--kon-btn-shadow: #8e1d33;
background: linear-gradient(180deg, #ef5a74, var(--luncher-danger));
border: none;
box-shadow: 0 4px 0 #8e1d33;
}
.action-icon:hover {
background: var(--luncher-primary-light);
color: var(--kon-cherry);
}
// ---------- Modaly ----------
.modal-content {
border: 2px solid var(--kon-card-border);
border-radius: var(--luncher-radius-xl);
overflow: hidden;
}
.modal-header {
background:
#{$kon-members} bottom / 100% 4px no-repeat,
linear-gradient(90deg, var(--kon-blazer-shadow), var(--kon-blazer) 40%, var(--kon-blazer-light));
border-bottom: none;
padding-bottom: calc(var(--bs-modal-header-padding-y, 1rem) + 4px);
.modal-title, .modal-title h2 {
color: #fff;
font-weight: 900;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.2);
}
.btn-close {
filter: invert(1) grayscale(1) brightness(2);
}
}
.footer {
border-top: none;
background-image: #{$kon-members};
background-size: 100% 3px;
background-repeat: no-repeat;
background-position: top;
}
}
@keyframes kon-bounce {
0%, 100% { transform: translateY(0) rotate(0deg); }
25% { transform: translateY(-4px) rotate(-12deg); }
50% { transform: translateY(0) rotate(0deg); }
75% { transform: translateY(-2px) rotate(10deg); }
}
@media (prefers-reduced-motion: reduce) {
#{$kon} .navbar .navbar-brand::after {
animation: none;
}
}
Loaded 100 of 348 files, more files were not shown because too many files have changed in this diff. Show more