1 Commits
Author SHA1 Message Date
Reallag b44d21d476 feat: podpora notifikací z notify 2023-10-15 20:07:05 +02:00
406 changed files with 12447 additions and 55366 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
+22 -10
View File
@@ -1,11 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
types/gen
**.DS_Store
.mcp.json
.claude/settings.local.json
server/public/
.claude/*.lock
.claude/worktrees
.playwright-mcp
.idea/
**/.yarn/install-state.gz
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
-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": []
}
]
}
-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.
+14 -92
View File
@@ -1,121 +1,43 @@
ARG NODE_VERSION="node:26-alpine"
# ─── Builder ──────────────────────────────────────────────────────────────────
FROM ${NODE_VERSION} AS builder
# Builder
FROM node:18-alpine3.18 as builder
WORKDIR /build
# Zkopírování závislostí - OpenAPI generátor
COPY types/package.json ./types/
COPY types/yarn.lock ./types/
COPY types/api.yml ./types/
COPY types/schemas ./types/schemas/
COPY types/paths ./types/paths/
COPY types/openapi-ts.config.ts ./types/
COPY package.json .
COPY yarn.lock .
# Zkopírování závislostí - server
COPY server/package.json ./server/
COPY server/yarn.lock ./server/
# Zkopírování závislostí - klient
COPY client/package.json ./client/
COPY client/yarn.lock ./client/
# Instalace závislostí - OpenAPI generátor
WORKDIR /build/types
RUN yarn install --frozen-lockfile
# Instalace závislostí - server
WORKDIR /build/server
RUN yarn install --frozen-lockfile
# Instalace závislostí - klient
WORKDIR /build/client
RUN yarn install --frozen-lockfile
WORKDIR /build
# Zkopírování build závislostí - server
COPY server/tsconfig.json ./server/
COPY server/src ./server/src/
# Zkopírování build závislostí - klient
COPY client/tsconfig.json ./client/
COPY client/vite.config.ts ./client/
COPY client/vite-env.d.ts ./client/
COPY client/index.html ./client/
COPY client/src ./client/src
COPY client/public ./client/public
# Zkopírování společných typů
COPY types/index.ts ./types/
COPY types ./types/
# Vygenerování společných typů z OpenAPI
WORKDIR /build/types
RUN yarn openapi-ts
# Sestavení serveru
WORKDIR /build/server
RUN yarn build
# Sestavení klienta
WORKDIR /build/client
RUN yarn build
# ─── Runner base ──────────────────────────────────────────────────────────────
# Společný základ pro oba runner targety – nastaví prostředí a metadata běhu.
FROM ${NODE_VERSION} AS runner-base
RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \
LC_ALL=cs_CZ.UTF-8 \
NODE_ENV=production
# Runner
FROM node:18-alpine3.18
ENV LANG cs_CZ.UTF-8
ENV NODE_ENV production
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
COPY --from=builder /build/server/node_modules ./server/node_modules
COPY --from=builder /build/node_modules ./node_modules
COPY --from=builder /build/server/dist ./
COPY --from=builder /build/client/build ./public
COPY /server/.env.production ./server/src
# Vykopírování sestaveného klienta
COPY --from=builder /build/client/dist ./public
EXPOSE 3000
# Zkopírování produkčních .env serveru
COPY /server/.env.production ./server
# 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
# ─── Runner (prebuilt) ────────────────────────────────────────────────────────
# Použití: docker build --target runner-prebuilt .
# Očekává předem sestavené artefakty v build kontextu (server/dist,
# client/dist, server/node_modules) – využívá Gitea Actions, kde se
# server i klient buildí v separátních jobech a sem se jen kopírují.
FROM runner-base AS runner-prebuilt
# Vykopírování sestaveného serveru
COPY ./server/node_modules ./server/node_modules
COPY ./server/dist ./
# Vykopírování sestaveného klienta
COPY ./client/dist ./public
# Zkopírování changelogů (seznamu novinek)
COPY ./server/changelogs ./server/changelogs
# Zkopírování konfigurace easter eggů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
CMD [ "node", "./server/src/index.js" ]
+3 -13
View File
@@ -1,9 +1,7 @@
# Luncher
Aplikace pro profesionální management obědů.
Aplikace sestává ze tří modulů.
- types
- OpenAPI definice společných typů, generované přes [openapi-ts](https://github.com/hey-api/openapi-ts)
Aplikace sestává ze dvou modulů + společných TypeScript definic (adresář `types`).
- server
- backend psaný v [node.js](https://nodejs.dev)
- client
@@ -12,27 +10,19 @@ Aplikace sestává ze tří modulů.
## Spuštění pro vývoj
### Závislosti
#### Klient/server
- [Node.js 22.x (>= 22.11)](https://nodejs.dev)
- [Node.js 18.x](https://nodejs.dev)
- [Yarn 1.22.x (Classic)](https://classic.yarnpkg.com)
### Spuštění na *nix platformách
- Nainstalovat závislosti viz předchozí bod
- Zkopírovat `server/.env.template` do `server/.env.development` a upravit dle potřeby
- Vygenerovat společné TypeScript typy
- `cd types && yarn install && yarn openapi-ts`
- Server
- `cd server && yarn install && export NODE_ENV=development && yarn startReload`
- Klient
- `cd client && yarn install && yarn start`
- Spustit `./run_dev.sh`. Na jiných platformách se lze inspirovat jeho obsahem, postup by měl být víceméně stejný.
## Sestavení a spuštění produkční verze v Docker
### Závislosti
- [Docker](https://www.docker.com)
- [Docker Compose](https://docs.docker.com/compose)
### Spuštění
- `docker compose up --build -d`
### Spuštení s traefik
- `docker compose -f compose-traefik.yml up --build -d`
+26 -34
View File
@@ -1,48 +1,40 @@
# TODO
## HA / multi-replica follow-ups
- [ ] `foodRoutes.ts` per-pod rate limiter (`rateLimits` map) — s více replikami může uživatel překročit limit ~N× rychleji; přesunout do Redis (např. `INCR` + `EXPIRE`)
- [ ] `easterEggRoutes.ts` — náhodně generované URL easter eggů jsou per-pod; URL funguje pouze na podu, který ji vygeneroval; zvážit deterministické seedy nebo sdílení přes Redis
- [ ] `service.ts` — komplexní víceúrovňové funkce (`addChoice`, `removeChoiceIfPresent`) provádějí více po sobě jdoucích zápisů do stejného Redis klíče; pro plnou atomicitu je potřeba per-klíčový distribuovaný zámek (Redlock nebo `SET NX EX`) nebo sloučení logiky do jednoho `updateData` volání
- [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
- [ ] 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
- 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
- [X] 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
- [X] 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í)
- [X] 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)
- [X] Umožnit zadat URL/tokeny uživatelem — ntfy, Discord, MS Teams (Gotify jen globálně na serveru)
- [X] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat
- [X] Zavést notifikace typu "Jdeme na oběd"
- [X] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu
- [X] Vylepšit parsery restaurací
- [X] 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í
- [X] U Motlíků — odpadá, podnik už v aplikaci není
- [X] 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í)
- [X] 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
- [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
- [ ] Tlačítko "Uhradit" u každého řádku podniku - platí ten, kdo kliknul
- [ ] Zobrazeno bude pouze, pokud má daný uživatel nastaveno číslo účtu
- [ ] Dialog pro zadání spropitného, které se následně rozpočte rovnoměrně všem strávníkům
- [ ] Generování a zobrazení QR kódů ostatním strávníkům
- [ ] Umožnit u každého strávníka připočíst vlastní částku (např. za pití)
- [ ] Umožnit (např. zaškrtávátky) vybrat, za koho bude zaplaceno (pokud někdo bude platit zvlášť)
- [ ] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)
- [ ] Umožnit zadat URL/tokeny uživatelem
- [ ] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat
- [ ] Zavést notifikace typu "Jdeme na oběd"
- [ ] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu
- [ ] Vylepšit parsery restaurací
- [ ] Sladovnická
- [ ] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru
- [ ] U Motlíků
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.')
- [ ] Jídelní lístek se stahuje jednou každý den, teoreticky by stačilo jednou týdně (za předpokladu, že se během týdne nemění)
- [ ] TechTower
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (typicky 'Obědy 12. 6. - 16. 6. 2023 (každý den vždy i obědový bufet)')
- [ ] 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
- 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] 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
- [X] Zavést složku /data
- [ ] Nasazení nové verze v Docker smaže veškerá data (protože data.json není vystrčený ven z kontejneru)
- [ ] Zavést složku /data
- [ ] 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
- [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
- [ ] 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í
- [X] Umožnit u Pizza day ručně připočíst cenu za přísady
- [ ] Umožnit u Pizza day ručně připočíst cenu za přísady
- [X] Prvotní načtení pizz při založení Pizza Day trvá a nic se během toho nezobrazuje (např. loader)
- [X] Po doručení zobrazit komu zaplatit (kdo objednával)
- [x] Zbytečně nescrapovat každý den pizzy z Pizza Chefie, dokud není založen Pizza Day
+1 -1
View File
@@ -1,2 +1,2 @@
build
dist
src/types
-47
View File
@@ -1,47 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<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="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&family=Barlow+Condensed:wght@500;600;700&family=Kiwi+Maru:wght@400;500&family=Playfair+Display:ital,wght@0,800;1,800&family=Courier+Prime:wght@700&family=Cormorant+Garamond:wght@600;700&family=Baloo+2:wght@600;800&family=Metal+Mania&family=Klee+One:wght@600&display=swap" rel="stylesheet" />
<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', 'evangelion', 'yurucamp', 'spyfamily', 'apothecary', 'frieren', 'mlpg5', 'aggretsuko', 'sumikko'];
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>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+26 -33
View File
@@ -3,46 +3,39 @@
"version": "0.1.0",
"license": "MIT",
"private": true,
"type": "module",
"homepage": ".",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0",
"@sentry/react": "^10.65.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"bootstrap": "^5.3.8",
"react": "^19.2.0",
"react-bootstrap": "^2.10.10",
"react-datepicker": "^9.1.0",
"react-dom": "^19.2.0",
"react-jwt": "^1.3.0",
"react-modal": "^3.16.3",
"react-router": "^7.9.5",
"react-router-dom": "^7.9.5",
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.23",
"@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11",
"bootstrap": "^5.2.3",
"react": "^18.2.0",
"react-bootstrap": "^2.7.2",
"react-dom": "^18.2.0",
"react-jwt": "^1.2.0",
"react-modal": "^3.16.1",
"react-scripts": "5.0.1",
"react-select-search": "^4.1.6",
"react-snow-overlay": "^1.0.14",
"react-snowfall": "^2.3.0",
"react-toastify": "^11.0.5",
"recharts": "^3.4.1",
"sass": "^1.93.3",
"react-toastify": "^9.1.3",
"socket.io-client": "^4.6.1",
"typescript": "^5.9.3",
"vite": "^7.2.2",
"vite-tsconfig-paths": "^5.1.4"
"typescript": "^4.9.5"
},
"scripts": {
"start": "yarn vite",
"build": "tsc --noEmit && yarn vite build"
"copy-types": "cp -r ../types ./src",
"start": "yarn copy-types && react-scripts start",
"build": "yarn copy-types && react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app"
"react-app",
"react-app/jest"
]
},
"browserslist": {
@@ -59,6 +52,6 @@
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"prettier": "^3.6.2"
"prettier": "^2.8.8"
}
}
}
-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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Moderní webová aplikace pro lepší správu obědových preferencí" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Luncher</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
-22
View File
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
<path fill="#8B4513" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

-22
View File
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
<path fill="#228B22" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

-22
View File
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
<path fill="#D2691E" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

-22
View File
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
<path fill="#B22222" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

-22
View File
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
<path fill="#FFD700" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

-22
View File
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
<path fill="#471C37" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 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);
})
);
});
+126
View File
@@ -0,0 +1,126 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.loader {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.loader>.loader-icon {
font-size: 64px;
}
.wrapper {
padding: 20px;
}
.title {
margin: 50px 30px;
}
.food-tables {
margin-bottom: 50px;
}
.content-wrapper {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.navbar {
background-color: #3c3c3c;
padding-left: 20px;
padding-right: 20px;
}
#basic-navbar-nav {
justify-content: flex-end;
}
.table {
margin-bottom: 0;
}
.table> :not(caption) .action-icon {
color: rgb(0, 89, 255);
cursor: pointer;
margin-left: 10px;
padding: 0;
}
.table ul {
padding: 0;
margin-left: 20px;
margin-bottom: 0;
}
.table td {
vertical-align: top;
}
.table>tbody>tr>td>table>tbody>tr>td {
border: none;
}
.qr-code {
text-align: center;
margin-top: 30px;
}
.select-search-container {
margin: auto;
}
.trusted-icon {
color: rgb(0, 89, 255);
margin-right: 10px;
}
.day-navigator {
display: flex;
align-items: center;
font-size: xx-large;
}
-1227
View File
File diff suppressed because it is too large. Load diff
+300 -730
View File
File diff suppressed because it is too large. Load diff
-57
View File
@@ -1,57 +0,0 @@
import { Routes, Route } from "react-router-dom";
import { ProvideSettings, useSettings } from "./context/settings";
import FlyingButterflies, { BUTTERFLY_PRESETS } from "./FlyingButterflies";
import "./FlyingButterflies.scss";
import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage";
import OrderGroupsPage from "./pages/OrderGroupsPage";
import SuggestionsPage from "./pages/SuggestionsPage";
import App from "./App";
import HellGirlFlames from "./components/HellGirlFlames";
export const STATS_URL = '/stats';
export const OBJEDNANI_URL = '/objednani';
export const NAVRHY_URL = '/navrhy';
/** Motýlci se vykreslují jen pokud je uživatel nevypnul v nastavení. */
function Butterflies() {
const settings = useSettings();
if (settings?.showButterflies === false) {
return null;
}
return <FlyingButterflies numButterflies={BUTTERFLY_PRESETS.NORMAL} />;
}
/** Doplňkové efekty vizuálních stylů — společné pro všechny stránky (např. plamen při otevření dialogu). */
function StyleThemeEffects() {
const settings = useSettings();
return settings?.styleTheme === 'hellgirl' ? <HellGirlFlames /> : null;
}
export default function AppRoutes() {
return (
<ProvideSettings>
<StyleThemeEffects />
<Routes>
<Route path={STATS_URL} element={<StatsPage />} />
<Route path={NAVRHY_URL} element={<SuggestionsPage />} />
<Route path={OBJEDNANI_URL} element={
<SocketContext.Provider value={socket}>
<OrderGroupsPage />
<ToastContainer />
</SocketContext.Provider>
} />
<Route path="/" element={
<SocketContext.Provider value={socket}>
<>
<Butterflies />
<App />
</>
<ToastContainer />
</SocketContext.Provider>
} />
</Routes>
</ProvideSettings>
);
}
-31
View File
@@ -1,31 +0,0 @@
.falling-leaves {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.leaf-scene {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 100%;
transform-style: preserve-3d;
div {
position: absolute;
top: 0;
left: 0;
width: 20px;
height: 20px;
background-repeat: no-repeat;
background-size: 100%;
transform-style: preserve-3d;
backface-visibility: visible;
}
}
-317
View File
@@ -1,317 +0,0 @@
import React, { useEffect, useRef, useCallback } from 'react';
// Různé barevné varianty listů
const LEAF_VARIANTS = [
'leaf.svg', // Původní tmavě hnědá
'leaf-orange.svg', // Oranžová
'leaf-yellow.svg', // Žlutá
'leaf-red.svg', // Červená
'leaf-brown.svg', // Světle hnědá
'leaf-green.svg', // Zelená
] as const;
interface LeafData {
el: HTMLDivElement;
x: number;
y: number;
z: number;
rotation: {
axis: 'X' | 'Y' | 'Z';
value: number;
speed: number;
x: number;
};
xSpeedVariation: number;
ySpeed: number;
path: {
type: number;
start: number;
};
image: number;
}
interface WindOptions {
magnitude: number;
maxSpeed: number;
duration: number;
start: number;
speed: (t: number, y: number) => number;
}
interface LeafSceneOptions {
numLeaves: number;
wind: WindOptions;
}
interface FallingLeavesProps {
/** Počet padających listů (výchozí: 20) */
numLeaves?: number;
/** CSS třída pro kontejner (výchozí: 'falling-leaves') */
className?: string;
/** Barevné varianty listů k použití (výchozí: všechny) */
leafVariants?: readonly string[];
}
class LeafScene {
private viewport: HTMLElement;
private world: HTMLDivElement;
private leaves: LeafData[] = [];
private options: LeafSceneOptions;
private width: number;
private height: number;
private timer: number = 0;
private animationId: number | null = null;
private leafVariants: readonly string[];
constructor(el: HTMLElement, numLeaves: number = 20, leafVariants: readonly string[] = LEAF_VARIANTS) {
this.viewport = el;
this.world = document.createElement('div');
this.leafVariants = leafVariants;
this.options = {
numLeaves,
wind: {
magnitude: 1.2,
maxSpeed: 12,
duration: 300,
start: 0,
speed: () => 0
},
};
this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight;
}
private resetLeaf = (leaf: LeafData): LeafData => {
// place leaf towards the top left
leaf.x = this.width * 2 - Math.random() * this.width * 1.75;
leaf.y = -10;
leaf.z = Math.random() * 200;
if (leaf.x > this.width) {
leaf.x = this.width + 10;
leaf.y = Math.random() * this.height / 2;
}
// at the start, the leaf can be anywhere
if (this.timer === 0) {
leaf.y = Math.random() * this.height;
}
// Choose axis of rotation.
// If axis is not X, chose a random static x-rotation for greater variability
leaf.rotation.speed = Math.random() * 10;
const randomAxis = Math.random();
if (randomAxis > 0.5) {
leaf.rotation.axis = 'X';
} else if (randomAxis > 0.25) {
leaf.rotation.axis = 'Y';
leaf.rotation.x = Math.random() * 180 + 90;
} else {
leaf.rotation.axis = 'Z';
leaf.rotation.x = Math.random() * 360 - 180;
// looks weird if the rotation is too fast around this axis
leaf.rotation.speed = Math.random() * 3;
}
// random speed
leaf.xSpeedVariation = Math.random() * 0.8 - 0.4;
leaf.ySpeed = Math.random() + 1.5;
// randomly select leaf color variant
const randomVariantIndex = Math.floor(Math.random() * this.leafVariants.length);
leaf.image = randomVariantIndex;
// apply the background image to the leaf element
const leafVariant = this.leafVariants[randomVariantIndex];
leaf.el.style.backgroundImage = `url(${leafVariant})`;
return leaf;
};
private updateLeaf = (leaf: LeafData): void => {
const leafWindSpeed = this.options.wind.speed(this.timer - this.options.wind.start, leaf.y);
const xSpeed = leafWindSpeed + leaf.xSpeedVariation;
leaf.x -= xSpeed;
leaf.y += leaf.ySpeed;
leaf.rotation.value += leaf.rotation.speed;
const transform = `translateX(${leaf.x}px) translateY(${leaf.y}px) translateZ(${leaf.z}px) rotate${leaf.rotation.axis}(${leaf.rotation.value}deg)${leaf.rotation.axis !== 'X' ? ` rotateX(${leaf.rotation.x}deg)` : ''
}`;
leaf.el.style.transform = transform;
// reset if out of view
if (leaf.x < -10 || leaf.y > this.height + 10) {
this.resetLeaf(leaf);
}
};
private updateWind = (): void => {
// wind follows a sine curve: asin(b*time + c) + a
// where a = wind magnitude as a function of leaf position, b = wind.duration, c = offset
// wind duration should be related to wind magnitude, e.g. higher windspeed means longer gust duration
if (this.timer === 0 || this.timer > (this.options.wind.start + this.options.wind.duration)) {
this.options.wind.magnitude = Math.random() * this.options.wind.maxSpeed;
this.options.wind.duration = this.options.wind.magnitude * 50 + (Math.random() * 20 - 10);
this.options.wind.start = this.timer;
const screenHeight = this.height;
this.options.wind.speed = function (t: number, y: number) {
// should go from full wind speed at the top, to 1/2 speed at the bottom, using leaf Y
const a = this.magnitude / 2 * (screenHeight - 2 * y / 3) / screenHeight;
return a * Math.sin(2 * Math.PI / this.duration * t + (3 * Math.PI / 2)) + a;
};
}
};
public init = (): void => {
// Clear existing leaves
this.leaves = [];
this.world.innerHTML = '';
for (let i = 0; i < this.options.numLeaves; i++) {
const leaf: LeafData = {
el: document.createElement('div'),
x: 0,
y: 0,
z: 0,
rotation: {
axis: 'X',
value: 0,
speed: 0,
x: 0
},
xSpeedVariation: 0,
ySpeed: 0,
path: {
type: 1,
start: 0,
},
image: 1
};
this.resetLeaf(leaf);
this.leaves.push(leaf);
this.world.appendChild(leaf.el);
}
this.world.className = 'leaf-scene';
this.viewport.appendChild(this.world);
// set perspective
this.world.style.perspective = "400px";
// reset window height/width on resize
const handleResize = (): void => {
this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight;
};
window.addEventListener('resize', handleResize);
};
public render = (): void => {
this.updateWind();
for (let i = 0; i < this.leaves.length; i++) {
this.updateLeaf(this.leaves[i]);
}
this.timer++;
this.animationId = requestAnimationFrame(this.render);
};
public destroy = (): void => {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
if (this.world && this.world.parentNode) {
this.world.parentNode.removeChild(this.world);
}
window.removeEventListener('resize', () => {
this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight;
});
};
}
/**
* Komponenta pro zobrazení padajících listů na pozadí stránky
*
* @param numLeaves - Počet padajících listů (výchozí: 20)
* @param className - CSS třída pro kontejner (výchozí: 'falling-leaves')
* @param leafVariants - Barevné varianty listů k použití (výchozí: všechny)
*
* @example
* // Základní použití s výchozím počtem listů
* <FallingLeaves />
*
* @example
* // Použití s vlastním počtem listů
* <FallingLeaves numLeaves={50} />
*
* @example
* // Použití s vlastní CSS třídou a pouze podzimními barvami
* <FallingLeaves
* numLeaves={15}
* className="autumn-leaves"
* leafVariants={['leaf-orange.svg', 'leaf-red.svg', 'leaf-yellow.svg']}
* />
*/
const FallingLeaves: React.FC<FallingLeavesProps> = ({
numLeaves = 20,
className = 'falling-leaves',
leafVariants = LEAF_VARIANTS
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const leafSceneRef = useRef<LeafScene | null>(null);
const initializeLeafScene = useCallback(() => {
if (containerRef.current) {
leafSceneRef.current = new LeafScene(containerRef.current, numLeaves, leafVariants);
leafSceneRef.current.init();
leafSceneRef.current.render();
}
}, [numLeaves, leafVariants]);
useEffect(() => {
initializeLeafScene();
return () => {
if (leafSceneRef.current) {
leafSceneRef.current.destroy();
leafSceneRef.current = null;
}
};
}, [initializeLeafScene]);
return <div ref={containerRef} className={className} />;
};
// Přednastavení pro různé účely
export const LEAF_PRESETS = {
LIGHT: 10, // Lehký podzimní efekt
NORMAL: 20, // Standardní množství
HEAVY: 40, // Silný podzimní vítr
BLIZZARD: 80 // Hustý pád listí
} as const;
// Přednastavené barevné kombinace
export const LEAF_COLOR_THEMES = {
ALL: LEAF_VARIANTS, // Všechny barvy
AUTUMN: ['leaf-orange.svg', 'leaf-red.svg', 'leaf-yellow.svg', 'leaf-brown.svg'] as const, // Podzimní barvy
WARM: ['leaf-orange.svg', 'leaf-red.svg', 'leaf-brown.svg'] as const, // Teplé barvy
CLASSIC: ['leaf.svg', 'leaf-brown.svg'] as const, // Klasické hnědé odstíny
BRIGHT: ['leaf-yellow.svg', 'leaf-orange.svg'] as const, // Světlé barvy
} as const;
export default FallingLeaves;
-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
+9 -95
View File
@@ -1,99 +1,13 @@
.login-page {
min-height: 100vh;
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 {
.login {
height: 100%;
display: flex;
flex-direction: column;
gap: 20px;
text-align: center;
justify-content: center;
}
.login-form label {
display: block;
text-align: left;
font-weight: 500;
color: var(--luncher-text);
margin-bottom: 8px;
}
.login-form .hint {
font-size: 0.85rem;
color: var(--luncher-text-muted);
margin-top: 8px;
text-align: left;
line-height: 1.5;
}
.login-form input[type="text"] {
width: 100%;
padding: 14px 18px;
font-size: 1rem;
border: 2px solid var(--luncher-border);
border-radius: var(--luncher-radius-sm);
background: var(--luncher-bg);
color: var(--luncher-text);
transition: var(--luncher-transition);
}
.login-form input[type="text"]:hover {
border-color: var(--luncher-text-muted);
}
.login-form input[type="text"]:focus {
border-color: var(--luncher-primary);
box-shadow: 0 0 0 3px var(--luncher-primary-light);
outline: none;
}
.login-form input[type="text"]::placeholder {
color: var(--luncher-text-muted);
}
.login-form .btn {
width: 100%;
padding: 14px 24px;
font-size: 1rem;
font-weight: 600;
margin-top: 8px;
}
.login-card > .hint {
color: var(--luncher-text-muted);
}
.login-error {
color: var(--luncher-danger);
line-height: 1.5;
margin: 0;
}
.login-inner {
display: flex;
flex-direction: column;
align-items: center;
}
+35 -81
View File
@@ -1,102 +1,56 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import { Button } from 'react-bootstrap';
import { useAuth } from './context/auth';
import { login } from '../../types';
import { useAppConfig } from './hooks/useGroupLimits';
import { login } from './api/Api';
import './Login.css';
/**
* Přihlášení. Podle konfigurace serveru buď přihlásí uživatele automaticky přes důvěryhodné
* hlavičky z proxy (bez formuláře), nebo zobrazí formulář pro zadání jména. Když se konfigurace
* nenačte, zkusí nejprve přihlášení „naprázdno" a jinak zobrazí formulář (původní chování).
* Formulář pro prvotní zadání přihlašovacího jména.
*/
export default function Login() {
const auth = useAuth();
const { loaded, trustedHeaders } = useAppConfig();
const loginRef = useRef<HTMLInputElement>(null);
const [trustedError, setTrustedError] = useState<string>();
const [attempt, setAttempt] = useState(0);
useEffect(() => {
if (!auth || auth.login || !loaded || trustedHeaders === false) return;
// Přihlášení přes hlavičky z proxy (případně pokus „naprázdno", pokud nevíme, jak server přihlašuje)
setTrustedError(undefined);
login().then(response => {
const token = response.data;
if (token) {
auth.setToken(token as unknown as string); // TODO vyřešit, API definice je špatně, je to skutečně string
} else if (trustedHeaders) {
setTrustedError((response.error as any)?.error ?? 'Přihlášení přes proxy se nezdařilo.');
}
}).catch(() => {
if (trustedHeaders) setTrustedError('Server je nedostupný. Zkuste to prosím za chvíli.');
});
}, [auth, loaded, trustedHeaders, attempt]);
if (auth && !auth.login) {
// Vyzkoušíme přihlášení "naprázdno", pokud projde, přihlásili nás trusted headers
login().then(token => {
if (token) {
auth?.setToken(token);
}
}).catch(error => {
// nezajímá nás
});
}
}, [auth]);
const doLogin = useCallback(async () => {
const length = loginRef?.current?.value.length && loginRef.current.value.replaceAll(/\s/g, '').length
const length = loginRef?.current?.value && loginRef?.current?.value.length && loginRef.current.value.replace(/\s/g, '').length
if (length) {
const response = await login({ body: { login: loginRef.current?.value } });
if (response.data) {
auth?.setToken(response.data as unknown as string); // TODO vyřešit
// TODO odchytávat cokoliv mimo 200
const token = await login(loginRef.current.value);
if (token) {
auth?.setToken(token);
}
}
}, [auth]);
if (auth?.login) {
return <div>Neplatný stav</div>
}
const header = <>
<h1 className='login-logo'>Luncher</h1>
<p className='login-subtitle'>Aplikace pro profesionální management obědů</p>
</>;
// Přihlašuje proxy — formulář se jménem nedává smysl
if (!loaded || trustedHeaders) {
return (
<div className='login-page'>
<div className='login-card'>
{header}
{trustedError ? (
<div className='login-form'>
<p className='login-error'>{trustedError}</p>
<Button onClick={() => setAttempt(a => a + 1)}>Zkusit znovu</Button>
</div>
) : (
<p className='hint'>Přihlašuji…</p>
)}
</div>
</div>
);
}
return (
<div className='login-page'>
<div className='login-card'>
{header}
<div className='login-form'>
<div>
<label htmlFor="login-input">Zobrazované jméno</label>
<input
id="login-input"
ref={loginRef}
type='text'
placeholder="Např. Jan Novák"
onKeyDown={event => {
if (event.key === 'Enter') {
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>
if (!auth || !auth.login) {
return <div className='login'>
<h1>Luncher</h1>
<h4 style={{ marginBottom: "50px" }}>Aplikace pro profesionální management obědů</h4>
<div className='login-inner'>
<p style={{ fontSize: "12px", marginTop: "10px" }}>
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 => {
if (event.key === 'Enter') {
doLogin()
}
}} />
<Button onClick={doLogin} style={{ marginTop: "20px" }}>Uložit</Button>
</div>
</div>
);
}
return <div>Neplatný stav</div>
}
+13 -100
View File
@@ -1,4 +1,14 @@
import { DepartureTime } from "../../types";
/**
* Vrátí kořenovou URL serveru na základě aktuálního prostředí (vývojovou či produkční).
*
* @returns kořenová URL serveru
*/
export const getBaseUrl = (): string => {
if (process.env.PUBLIC_URL) {
return process.env.PUBLIC_URL;
}
return 'http://127.0.0.1:3001';
}
const TOKEN_KEY = "token";
@@ -16,8 +26,8 @@ export const storeToken = (token: string) => {
*
* @returns token nebo null
*/
export const getToken = (): string | undefined => {
return localStorage.getItem(TOKEN_KEY) ?? undefined;
export const getToken = (): string | null => {
return localStorage.getItem(TOKEN_KEY);
}
/**
@@ -25,101 +35,4 @@ export const getToken = (): string | undefined => {
*/
export const deleteToken = () => {
localStorage.removeItem(TOKEN_KEY);
}
/**
* Vrátí human-readable reprezentaci předaného data a času pro zobrazení.
* Příklady:
* - dnes 10:52
* - 10.05.2023 10:52
*/
export function getHumanDateTime(datetime: Date) {
let hours = String(datetime.getHours()).padStart(2, '0');
let minutes = String(datetime.getMinutes()).padStart(2, "0");
if (new Date().toDateString() === datetime.toDateString()) {
return `dnes ${hours}:${minutes}`;
} else {
let day = String(datetime.getDate()).padStart(2, '0');
let month = String(datetime.getMonth() + 1).padStart(2, "0");
let year = datetime.getFullYear();
return `${day}.${month}.${year} ${hours}:${minutes}`;
}
}
/**
* Vrátí true, pokud je předaný čas větší než aktuální čas.
*/
export function isInTheFuture(time: DepartureTime) {
const now = new Date();
const currentHours = now.getHours();
const currentMinutes = now.getMinutes();
const currentDate = now.toDateString();
const [hours, minutes] = time.split(':').map(Number);
if (currentDate === now.toDateString()) {
return hours > currentHours || (hours === currentHours && minutes > currentMinutes);
}
return true;
}
/**
* Vrátí index dne v týdnu, kde pondělí=0, neděle=6
*
* @param date datum
* @returns index dne v týdnu
*/
export const getDayOfWeekIndex = (date: Date) => {
// https://stackoverflow.com/a/4467559
return (((date.getDay() - 1) % 7) + 7) % 7;
}
/** Vrátí první pracovní den v týdnu předaného data. */
export function getFirstWorkDayOfWeek(date: Date) {
const firstDay = new Date(date);
firstDay.setDate(date.getDate() - getDayOfWeekIndex(date));
return firstDay;
}
/** Vrátí poslední pracovní den v týdnu předaného data. */
export function getLastWorkDayOfWeek(date: Date) {
const lastDay = new Date(date);
lastDay.setDate(date.getDate() + (4 - getDayOfWeekIndex(date)));
return lastDay;
}
/** Vrátí datum v ISO formátu. */
export function formatDate(date: Date, format?: string) {
let day = String(date.getDate()).padStart(2, '0');
let month = String(date.getMonth() + 1).padStart(2, "0");
let year = String(date.getFullYear());
const f = format ?? 'YYYY-MM-DD';
return f.replace('DD', day).replace('MM', month).replace('YYYY', year);
}
/** Vrátí human-readable reprezentaci předaného data pro zobrazení. */
export function getHumanDate(date: Date) {
let currentDay = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear();
return `${currentDay}.${currentMonth}.${currentYear}`;
}
/** Převede datum ve formátu YYYY-MM-DD na DD.MM.YYYY */
export function formatDateString(dateString: string): string {
const [year, month, day] = dateString.split('-');
return `${day}.${month}.${year}`;
}
/**
* Očistí zprávu (účel platby) pro QR platbu – musí odpovídat serverové logice (qr.ts):
* transliteruje diakritiku na základní písmena (š→s, č→c, ...), odstraní znaky mimo
* ISO 8859-1 a hvězdičku (oddělovač polí v QR platbě) a ořízne na max. 60 znaků.
*/
export function sanitizeQrMessage(message: string): string {
const sanitized = message
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // diakritika → základní písmeno
.replace(/[^\x00-\xff]/g, '') // znaky mimo ISO 8859-1
.replace(/\*/g, ''); // '*' je v QR platbě oddělovač
return sanitized.length > 60 ? sanitized.substring(0, 60) : sanitized;
}
+57
View File
@@ -0,0 +1,57 @@
import { toast } from "react-toastify";
import { getBaseUrl, getToken } from "../Utils";
/**
* Wrapper pro volání API, u kterých chceme automaticky zobrazit toaster s chybou ze serveru.
*
* @param apiFunction volaná API funkce
*/
export function errorHandler<T>(apiFunction: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
apiFunction().then((result) => {
resolve(result);
}).catch(e => {
toast.error(e.message, { theme: "colored" });
});
});
}
async function request<TResponse>(
url: string,
config: RequestInit = {}
): Promise<TResponse> {
config.headers = config?.headers ? new Headers(config.headers) : new Headers();
config.headers.set("Authorization", `Bearer ${getToken()}`);
try {
const response = await fetch(getBaseUrl() + url, config);
if (!response.ok) {
const json = await response.json();
// Vyhodíme samotnou hlášku z odpovědi, odchytí si jí errorHandler
throw new Error(json.error);
}
return response.json() as TResponse;
} catch (e) {
return Promise.reject(e);
}
}
export const api = {
get: <TResponse>(url: string) => request<TResponse>(url),
post: <TBody extends BodyInit, TResponse>(url: string, body: TBody) => request<TResponse>(url, { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }),
}
export const getQrUrl = (login: string) => {
return `${getBaseUrl()}/api/qr?login=${login}`;
}
export const getData = async (dayIndex?: number) => {
let url = '/api/data';
if (dayIndex != null) {
url += '?dayIndex=' + dayIndex;
}
return await api.get<any>(url);
}
export const login = async (login?: string) => {
return await api.post<any, any>('/api/login', JSON.stringify({ login }));
}
+22
View File
@@ -0,0 +1,22 @@
import { api } from "./Api";
const FOOD_API_PREFIX = '/api/food';
export const addChoice = async (locationIndex: number, foodIndex?: number, dayIndex?: number) => {
return await api.post<any, any>(`${FOOD_API_PREFIX}/addChoice`, JSON.stringify({ locationIndex, foodIndex, dayIndex }));
}
export const removeChoices = async (locationIndex: number, dayIndex?: number) => {
return await api.post<any, any>(`${FOOD_API_PREFIX}/removeChoices`, JSON.stringify({ locationIndex, dayIndex }));
}
export const removeChoice = async (locationIndex: number, foodIndex: number, dayIndex?: number) => {
return await api.post<any, any>(`${FOOD_API_PREFIX}/removeChoice`, JSON.stringify({ locationIndex, foodIndex, dayIndex }));
}
export const changeDepartureTime = async (time: string, dayIndex?: number) => {
return await api.post<any, any>(`${FOOD_API_PREFIX}/changeDepartureTime`, JSON.stringify({ time, dayIndex }));
}
export const jdemeObed = async () => {
return await api.post<any, any>(`${FOOD_API_PREFIX}/jdemeObed`, JSON.stringify({}));
}
+44
View File
@@ -0,0 +1,44 @@
import { PizzaOrder } from "../types";
import { api } from "./Api";
const PIZZADAY_API_PREFIX = '/api/pizzaDay';
export const createPizzaDay = async () => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/create`, undefined);
}
export const deletePizzaDay = async () => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/delete`, undefined);
}
export const lockPizzaDay = async () => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/lock`, undefined);
}
export const unlockPizzaDay = async () => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/unlock`, undefined);
}
export const finishOrder = async () => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/finishOrder`, undefined);
}
export const finishDelivery = async (bankAccount?: string, bankAccountHolder?: string) => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/finishDelivery`, JSON.stringify({ bankAccount, bankAccountHolder }));
}
export const addPizza = async (pizzaIndex: number, pizzaSizeIndex: number) => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/add`, JSON.stringify({ pizzaIndex, pizzaSizeIndex }));
}
export const removePizza = async (pizzaOrder: PizzaOrder) => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/remove`, JSON.stringify({ pizzaOrder }));
}
export const updatePizzaDayNote = async (note?: string) => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/updatePizzaDayNote`, JSON.stringify({ note }));
}
export const updatePizzaFee = async (login: string, text?: string, price?: number) => {
return await api.post<any, any>(`${PIZZADAY_API_PREFIX}/updatePizzaFee`, JSON.stringify({ login, text, price }));
}
+12
View File
@@ -0,0 +1,12 @@
import { FeatureRequest } from "../types";
import { api } from "./Api";
const VOTING_API_PREFIX = '/api/voting';
export const getFeatureVotes = async () => {
return await api.get<any>(`${VOTING_API_PREFIX}/getVotes`);
}
export const updateFeatureVote = async (option: FeatureRequest, active: boolean) => {
return await api.post<any, any>(`${VOTING_API_PREFIX}/updateVote`, JSON.stringify({ option, active }));
}
-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>
</>
);
}
-172
View File
@@ -1,172 +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);
}
}
// Styl Evangelion: emoji v oranžovém šestiúhelníku jako AT pole
.eater-badge-evangelion {
display: inline-grid;
place-items: center;
font-size: 15px;
line-height: 1;
border: none;
border-radius: 0;
clip-path: polygon(25% 3%, 75% 3%, 100% 50%, 75% 97%, 25% 97%, 0 50%);
background: radial-gradient(circle, #ffe3c4 0 45%, #f26b00 46% 100%);
box-shadow: none;
}
// Styl Yuru Camp△: emoji na kulaté nášivce z kempu s prošitým okrajem
.eater-badge-yurucamp {
display: inline-grid;
place-items: center;
font-size: 15px;
line-height: 1;
border-color: #fffdf8;
background: radial-gradient(circle at 40% 35%, #fffdf8, #f3e3c3);
box-shadow: 0 0 0 2px #3f6b4a;
outline: 1px dashed #3f6b4a;
outline-offset: -5px;
}
// Styl Spy × Family: emoji na zlaté medaili Eden Academy s petrolejovým lemem
.eater-badge-spyfamily {
display: inline-grid;
place-items: center;
font-size: 15px;
line-height: 1;
border-color: #e8cc82;
background: radial-gradient(circle at 40% 35%, #fffcf5, #f2e2b9);
box-shadow: 0 0 0 2px #1e6f6c;
}
// Styl Apothecary Diaries: emoji na nefritovém disku se zlatým lemem
.eater-badge-apothecary {
display: inline-grid;
place-items: center;
font-size: 15px;
line-height: 1;
border-color: #d9b65e;
background: radial-gradient(circle at 40% 35%, #cfe9dc, #6fae92);
box-shadow: 0 0 0 2px #a8322a;
}
// Styl Frieren: emoji ve stříbrném kruhu se zlatým lemem a levandulovou září
.eater-badge-frieren {
display: inline-grid;
place-items: center;
font-size: 15px;
line-height: 1;
border-color: #e9c86a;
background: radial-gradient(circle at 40% 35%, #ffffff, #e4def6);
box-shadow: 0 0 0 2px #8e7cc3, 0 0 8px rgb(179 163 234 / 0.7);
}
// Styl My Little Pony G5: emoji v bílém kruhu s duhovým lemem Mane 5
.eater-badge-mlpg5 {
display: inline-grid;
place-items: center;
font-size: 15px;
line-height: 1;
border-color: transparent;
background:
radial-gradient(circle at 40% 35%, #fff, #ffeef6) padding-box,
linear-gradient(135deg, #f08a3c, #ee5fa8, #9b6fe0, #2fa89c) border-box;
box-shadow: 0 2px 6px rgb(238 95 168 / 0.45);
}
// Styl Aggretsuko: emoji v kruhovém razítku hanko
.eater-badge-aggretsuko {
display: inline-grid;
place-items: center;
font-size: 15px;
line-height: 1;
border-color: #c8102e;
background: radial-gradient(circle at 40% 35%, #fff, #fff4e8);
box-shadow: 0 0 0 1px #fff, 0 0 0 3px #c8102e;
}
// Styl Sumikko Gurashi: emoji na krémovém kolečku s hnědou čárkovanou linkou
.eater-badge-sumikko {
display: inline-grid;
place-items: center;
font-size: 15px;
line-height: 1;
border-color: #fffdf8;
background: radial-gradient(circle at 40% 35%, #fffdf8, #f5e7d4);
box-shadow: 0 0 0 2px #8b5e3c;
outline: 1px dashed rgb(139 94 60 / 0.5);
outline-offset: -5px;
}
// 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;
}
-141
View File
@@ -1,141 +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ě ve stylu Evangelion — emoji v oranžovém šestiúhelníku jako AT pole. Seřazeno od nejvyšší úrovně. */
const EVA_LEVELS = [
{ minItems: 3, emoji: '👹', title: 'Úroveň Unit-01 v berserku — sežere i S² motor Anděla' },
{ minItems: 2, emoji: '🍛', title: 'Úroveň Misato — dvojitá porce jejího legendárního kari (na vlastní nebezpečí)' },
];
/** Úrovně ve stylu Yuru Camp△ — emoji na nášivce z kempu. Seřazeno od nejvyšší úrovně. */
const YC_LEVELS = [
{ minItems: 3, emoji: '🍲', title: 'Úroveň Nadeshiko — sní celý kotlík nabe a ještě si přidá' },
{ minItems: 2, emoji: '🍜', title: 'Úroveň Rin — k obědu ještě instantní nudle s výhledem na Fuji' },
];
/** Úrovně ve stylu Spy × Family — emoji na zlaté medaili Eden Academy. Seřazeno od nejvyšší úrovně. */
const SXF_LEVELS = [
{ minItems: 3, emoji: '🐶', title: 'Úroveň Bond — předvídal budoucnost a v ní je ještě jeden talíř' },
{ minItems: 2, emoji: '🥜', title: 'Úroveň Anya — k obědu ještě hrst buráků (waku waku!)' },
];
/** Úrovně ve stylu Apothecary Diaries — emoji na nefritovém disku se zlatým lemem. Seřazeno od nejvyšší úrovně. */
const APO_LEVELS = [
{ minItems: 3, emoji: '🧪', title: 'Úroveň Maomao — ochutná úplně všechno, hlavně kdyby to bylo otrávené' },
{ minItems: 2, emoji: '🍡', title: 'Úroveň Xiaolan — k obědu ještě sladkosti a klepy z paláce' },
];
/** Úrovně ve stylu Frieren — emoji ve stříbrném kruhu s levandulovou září. Seřazeno od nejvyšší úrovně. */
const FR_LEVELS = [
{ minItems: 3, emoji: '📦', title: 'Úroveň mimik — spolkne cokoli, klidně i Frieren' },
{ minItems: 2, emoji: '🍖', title: 'Úroveň Stark — obří hamburger steak, který mu k narozeninám udělala Fern' },
];
/** Úrovně ve stylu My Little Pony G5 — emoji v kruhu s duhovým lemem Mane 5. Seřazeno od nejvyšší úrovně. */
const G5_LEVELS = [
{ minItems: 3, emoji: '🐉', title: 'Úroveň Sparky — malý drak sní všechno, i Hitchovu svačinu' },
{ minItems: 2, emoji: '🥤', title: 'Úroveň Sunny — k obědu ještě smoothie z jejího stánku' },
];
/** Úrovně ve stylu Aggretsuko — emoji v kruhovém razítku hanko. Seřazeno od nejvyšší úrovně. */
const AGG_LEVELS = [
{ minItems: 3, emoji: '🐷', title: 'Úroveň šéfa Tona — sní všechno a ještě si přidá, šéf má přece vždycky pravdu' },
{ minItems: 2, emoji: '🍙', title: 'Úroveň Retsuko — po ránu se šéfem k obědu ještě onigiri na uklidnění' },
];
/** Úrovně ve stylu Sumikko Gurashi — emoji na krémovém kolečku s hnědou čárkovanou linkou. Seřazeno od nejvyšší úrovně. */
const SK_LEVELS = [
{ minItems: 3, emoji: '🐷', title: 'Úroveň tonkatsu — 99 % tuku, 1 % masa, a stejně si ještě přidá' },
{ minItems: 2, emoji: '🥒', title: 'Úroveň Penguin? — k obědu ještě okurka, aby se cítil jako kappa' },
];
/** Ú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,
evangelion: EVA_LEVELS,
yurucamp: YC_LEVELS,
spyfamily: SXF_LEVELS,
apothecary: APO_LEVELS,
frieren: FR_LEVELS,
mlpg5: G5_LEVELS,
aggretsuko: AGG_LEVELS,
sumikko: SK_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!, Bocchi the Rock!, Evangelion, Yuru Camp△, Spy × Family, Apothecary Diaries, Frieren, My Little Pony G5, Aggretsuko a Sumikko Gurashi. */
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>
);
}
+6 -11
View File
@@ -1,12 +1,7 @@
import { Navbar } from "react-bootstrap";
export default function Footer() {
return (
<footer className="footer">
<span>
Zdroj. kódy dostupné na{' '}
<a href="https://gitea.melancholik.eu/mates/Luncher" target="_blank" rel="noopener noreferrer">
Gitea
</a>
</span>
</footer>
);
}
return <Navbar className="text-light" variant='dark' expand="lg" style={{ display: "flex", justifyContent: "center" }}>
<span>🄯 Kancelář 51, Marbes s.r.o. Žádná práva nevyhrazena. TODO a zdrojové kódy dostupné <a href="https://gitea.melancholik.eu/mates/Luncher">zde</a>.</span>
</Navbar >
}
-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); }
}
+48 -187
View File
@@ -1,92 +1,35 @@
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 SettingsModal from "./modals/SettingsModal";
import DrawingGame from "./DrawingGame";
import "./Header.scss";
import { useMinigameSecret } from "../hooks/useMinigameSecret";
import { useSettings, ThemePreference } from "../context/settings";
import HuePicker from "./HuePicker";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
import RefreshMenuModal from "./modals/RefreshMenuModal";
import GenerateQrModal from "./modals/GenerateQrModal";
import GenerateMockDataModal from "./modals/GenerateMockDataModal";
import ClearMockDataModal from "./modals/ClearMockDataModal";
import { useNavigate } from "react-router";
import { STATS_URL, OBJEDNANI_URL, NAVRHY_URL } from "../AppRoutes";
import { LunchChoices, getChangelogs } from "../../../types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
import { formatDateString } from "../Utils";
import BankAccountModal from "./modals/BankAccountModal";
import { useBank } from "../context/bank";
import FeaturesVotingModal from "./modals/FeaturesVotingModal";
import { FeatureRequest } from "../types";
import { errorHandler } from "../api/Api";
import { getFeatureVotes, updateFeatureVote } from "../api/VotingApi";
const LAST_SEEN_CHANGELOG_KEY = "lastChangelogDate";
const IS_DEV = process.env.NODE_ENV === 'development';
type Props = {
choices?: LunchChoices;
dayIndex?: number;
};
export default function Header({ choices, dayIndex }: Props) {
export default function Header() {
const auth = useAuth();
const settings = useSettings();
const navigate = useNavigate();
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
const [changelogEntries, setChangelogEntries] = useState<Record<string, string[]>>({});
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
const effectiveDark = settings?.effectiveDark ?? false;
const minigameSecret = useMinigameSecret("/");
const bank = useBank();
const [bankModalOpen, setBankModalOpen] = useState<boolean>(false);
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[]>([]);
useEffect(() => {
if (!auth?.login) return;
const lastSeen = localStorage.getItem(LAST_SEEN_CHANGELOG_KEY) ?? undefined;
getChangelogs({ query: lastSeen ? { since: lastSeen } : {} }).then(response => {
const entries = response.data;
if (!entries || Object.keys(entries).length === 0) return;
setChangelogEntries(entries);
setChangelogModalOpen(true);
const newestDate = Object.keys(entries).sort((a, b) => b.localeCompare(a))[0];
localStorage.setItem(LAST_SEEN_CHANGELOG_KEY, newestDate);
});
if (auth?.login) {
getFeatureVotes().then(votes => {
setFeatureVotes(votes);
})
}
}, [auth?.login]);
const closeSettingsModal = () => {
setSettingsModalOpen(false);
const closeBankModal = () => {
setBankModalOpen(false);
}
const closePizzaModal = () => {
setPizzaModalOpen(false);
}
const closeRefreshMenuModal = () => {
setRefreshMenuModalOpen(false);
}
const closeQrModal = () => {
setQrModalOpen(false);
}
const handleQrMenuClick = () => {
if (!settings?.bankAccount || !settings?.holderName) {
alert('Pro generování QR kódů je nutné mít v nastavení vyplněné číslo účtu a jméno držitele účtu.');
return;
}
setQrModalOpen(true);
}
// Tematické styly mají pevnou paletu, výběr barvy má smysl jen u jednoduchého stylu
const isSimpleStyle = (settings?.styleTheme ?? 'simple') === 'simple';
const toggleTheme = () => {
const newTheme: ThemePreference = effectiveDark ? 'light' : 'dark';
settings?.setThemePreference(newTheme);
const closeVotingModal = () => {
setVotingModalOpen(false);
}
const isValidInteger = (str: string) => {
@@ -99,19 +42,19 @@ export default function Header({ choices, dayIndex }: Props) {
return n !== Infinity && String(n) === str && n >= 0;
}
const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean, themePreference?: ThemePreference) => {
const saveBankAccount = (bankAccountNumber?: string, bankAccountHolderName?: string) => {
if (bankAccountNumber) {
try {
// Validace kódu banky
if (!bankAccountNumber.includes('/')) {
throw new Error("Číslo účtu neobsahuje lomítko/kód banky")
if (bankAccountNumber.indexOf('/') < 0) {
throw Error("Číslo účtu neobsahuje lomítko/kód banky")
}
const split = bankAccountNumber.split("/");
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])) {
throw new Error("Kód banky není číslo")
throw Error("Kód banky není číslo")
}
// Validace čísla a předčíslí
@@ -121,137 +64,55 @@ export default function Header({ choices, dayIndex }: Props) {
cislo = cislo.replace('-', '');
}
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) {
cislo = cislo.padStart(16, '0');
}
let sum = 0;
for (let i = 0; i < cislo.length; i++) {
for (var i = 0; i < cislo.length; i++) {
const char = cislo.charAt(i);
const order = (cislo.length - 1) - i;
const weight = (2 ** order) % 11;
sum += Number.parseInt(char) * weight
}
if (sum % 11 !== 0) {
throw new Error("Číslo účtu je neplatné")
throw Error("Číslo účtu je neplatné")
}
} catch (e: any) {
alert(e.message)
return
}
}
settings?.setBankAccountNumber(bankAccountNumber);
settings?.setBankAccountHolderName(bankAccountHolderName);
settings?.setHideSoupsOption(hideSoupsOption);
if (themePreference) {
settings?.setThemePreference(themePreference);
bank?.setBankAccountNumber(bankAccountNumber);
bank?.setBankAccountHolderName(bankAccountHolderName);
closeBankModal();
}
const saveFeatureVote = async (option: FeatureRequest, active: boolean) => {
await errorHandler(() => updateFeatureVote(option, active));
const votes = [...featureVotes];
if (active) {
votes.push(option);
} else {
votes.splice(votes.indexOf(option), 1);
}
closeSettingsModal();
setFeatureVotes(votes);
}
return <Navbar variant='dark' expand="lg">
<Navbar.Brand href="/" onClick={minigameSecret.onLogoClick} className={minigameSecret.armed ? "duel-armed" : undefined}>Luncher</Navbar.Brand>
<input ref={minigameSecret.inputRef} className="minigame-secret-input" tabIndex={-1} aria-hidden="true"
autoComplete="off" autoCorrect="off" autoCapitalize="none" spellCheck={false} />
<Navbar.Brand>Luncher</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-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.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(NAVRHY_URL)}>Návrhy na vylepšení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(OBJEDNANI_URL)}>Objednání</NavDropdown.Item>
<NavDropdown.Item onClick={() => {
getChangelogs().then(response => {
const entries = response.data ?? {};
setChangelogEntries(entries);
setChangelogModalOpen(true);
const dates = Object.keys(entries).sort((a, b) => b.localeCompare(a));
if (dates.length > 0) {
localStorage.setItem(LAST_SEEN_CHANGELOG_KEY, dates[0]);
}
});
}}>Novinky</NavDropdown.Item>
{IS_DEV && (
<>
<NavDropdown.Divider />
<NavDropdown.Item onClick={() => setGenerateMockModalOpen(true)}>🔧 Generovat mock data</NavDropdown.Item>
<NavDropdown.Item onClick={() => setClearMockModalOpen(true)}>🔧 Smazat mock data</NavDropdown.Item>
</>
)}
<NavDropdown.Divider />
<NavDropdown.Item onClick={() => setBankModalOpen(true)}>Nastavit číslo účtu</NavDropdown.Item>
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
</NavDropdown>
</Nav>
</Navbar.Collapse>
<DrawingGame />
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
<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>
<BankAccountModal isOpen={bankModalOpen} onClose={closeBankModal} onSave={saveBankAccount} />
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
</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>
);
}
+9 -11
View File
@@ -2,20 +2,18 @@ import { IconDefinition } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
type Props = {
title?: string,
title?: String,
icon: IconDefinition,
description: string,
animation?: string,
description: String,
animation?: String,
}
function Loader(props: Readonly<Props>) {
return (
<div className='loader'>
<FontAwesomeIcon icon={props.icon} className={`loader-icon ${props.animation ?? ''}`} />
<h2 className='loader-title'>{props.title ?? 'Prosím čekejte...'}</h2>
<p className='loader-description'>{props.description}</p>
</div>
);
function Loader(props: Props) {
return <div className='loader'>
<h1>{props.title || 'Prosím čekejte...'}</h1>
<FontAwesomeIcon icon={props.icon} className={`loader-icon mb-3 ` + (props.animation ?? '')} />
<p>{props.description}</p>
</div>
}
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?.();
}}
/>
</>
);
}
+30 -41
View File
@@ -1,57 +1,46 @@
import { Table } from "react-bootstrap";
import { Order, PizzaDayState, PizzaOrder } from "../types";
import PizzaOrderRow from "./PizzaOrderRow";
import { PizzaDayState, PizzaOrder, PizzaVariant, updatePizzaFee } from "../../../types";
import { updatePizzaFee } from "../api/PizzaDayApi";
type Props = {
state: PizzaDayState,
orders: PizzaOrder[],
onDelete: (pizzaOrder: PizzaVariant) => void,
orders: Order[],
onDelete: (pizzaOrder: PizzaOrder) => void,
creator: string,
}
export default function PizzaOrderList({ state, orders, onDelete, creator }: Readonly<Props>) {
export default function PizzaOrderList({ state, orders, onDelete, creator }: Props) {
const saveFees = async (customer: string, text?: string, price?: number) => {
await updatePizzaFee({ body: { login: customer, text, price } });
await updatePizzaFee(customer, text, price);
}
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);
return (
<div className="mt-4" style={{
background: 'var(--luncher-bg-card)',
borderRadius: 'var(--luncher-radius-lg)',
overflow: 'hidden',
border: '1px solid var(--luncher-border-light)',
boxShadow: 'var(--luncher-shadow)'
}}>
<Table className="mb-0" style={{ color: 'var(--luncher-text)' }}>
<thead style={{ background: 'var(--luncher-primary-light)' }}>
<tr>
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Jméno</th>
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Objednávka</th>
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Poznámka</th>
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Příplatek</th>
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none', textAlign: 'right' }}>Cena</th>
</tr>
</thead>
<tbody>
{orders.map(order => <tr key={order.customer} style={{ borderColor: 'var(--luncher-border-light)' }}>
<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>
);
}
return <>
<Table className="mt-3" striped bordered hover>
<thead>
<tr>
<th>Jméno</th>
<th>Objednávka</th>
<th>Poznámka</th>
<th>Příplatek</th>
<th>Cena</th>
</tr>
</thead>
<tbody>
{orders.map(order => <tr key={order.customer}>
<PizzaOrderRow creator={creator} state={state} order={order} onDelete={onDelete} onFeeModalSave={saveFees} />
</tr>)}
<tr style={{ fontWeight: 'bold' }}>
<td colSpan={4}>Celkem</td>
<td>{`${total} Kč`}</td>
</tr>
</tbody>
</Table>
</>
}
+16 -18
View File
@@ -2,46 +2,44 @@ import React, { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMoneyBill1, faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { useAuth } from "../context/auth";
import { Order, PizzaDayState, PizzaOrder } from "../types";
import PizzaAdditionalFeeModal from "./modals/PizzaAdditionalFeeModal";
import { PizzaDayState, PizzaOrder, PizzaVariant } from "../../../types";
type Props = {
creator: string,
order: PizzaOrder,
order: Order,
state: PizzaDayState,
onDelete: (order: PizzaVariant) => void,
onDelete: (order: PizzaOrder) => void,
onFeeModalSave: (customer: string, name?: string, price?: number) => void,
}
export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeModalSave }: Readonly<Props>) {
export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeModalSave }: Props) {
const auth = useAuth();
const [isFeeModalOpen, setIsFeeModalOpen] = useState<boolean>(false);
const [isFeeModalOpen, setFeeModalOpen] = useState<boolean>(false);
const saveFees = (customer: string, text?: string, price?: number) => {
onFeeModalSave(customer, text, price);
setIsFeeModalOpen(false);
setFeeModalOpen(false);
}
return <>
<td>{order.customer}</td>
<td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder =>
<span key={pizzaOrder.name}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price / 100} Kč)`}
<td>{order.pizzaList.map<React.ReactNode>((pizzaOrder, index) =>
<span key={index}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
{auth?.login === order.customer && state === PizzaDayState.CREATED &&
<span title='Odstranit'>
<FontAwesomeIcon onClick={() => {
onDelete(pizzaOrder);
}} className='action-icon' icon={faTrashCan} />
</span>
<FontAwesomeIcon onClick={() => {
onDelete(pizzaOrder);
}} title='Odstranit' className='action-icon' icon={faTrashCan} />
}
</span>)
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
</td>
<td style={{ maxWidth: "200px" }}>{order.note ?? '-'}</td>
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price / 100} Kč${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
<td style={{ maxWidth: "200px" }}>{order.note || '-'}</td>
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price} Kč${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
<td>
{order.totalPrice / 100} Kč{auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>}
{order.totalPrice} Kč{auth?.login === creator && state === PizzaDayState.CREATED && <FontAwesomeIcon onClick={() => { setFeeModalOpen(true) }} title='Nastavit příplatek' className='action-icon' icon={faMoneyBill1} />}
</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={() => setFeeModalOpen(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>
);
}
File diff suppressed because it is too large. Load diff
-406
View File
@@ -1,406 +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!' },
];
const EVA_EMOJI = ['🟣', '🟢', '🔶', '⚠️', '🌙', '🍛', '🐧', '🎧', '💠', '🔴', '🛰️', '📐'];
// Hlášky z Evangelionu (PATTERN BLUE, AT pole, MAGI, „Anta baka?!", „Nesmím utéct", Pen Pen...)
const EVA_BADGES = ['PATTERN BLUE', 'EMERGENCY', 'MAGI: SCHVÁLENO', 'SYNC 400 %', 'AT FIELD', 'Nesmím utéct!', 'Anta baka?!', '使徒、襲来', 'NERV', 'Pen Pen!'];
const EVA_BADGE_COLORS = ['#f26b00', '#d81e1e', '#5b2a86', '#111214', '#3f8a2a'];
/** Piloti a velení NERV s „hláškou" po finálním výsledku. */
const EVA_SPEAKERS = [
{ name: 'Shinji Ikari', emoji: '🎧', color: '#5b2a86', quote: 'Nesmím utéct… nesmím utéct… dobře, jdu na oběd.' },
{ name: 'Rei Ayanami', emoji: '🌙', color: '#6fa8d6', quote: 'Nevím, co na to říct. Ale maso jíst nebudu.' },
{ name: 'Asuka Langley Sōryū', emoji: '🔴', color: '#d81e1e', quote: 'Anta baka?! Tohle je přece jasně nejlepší volba!' },
{ name: 'Misato Katsuragi', emoji: '🍺', color: '#3b3b8f', quote: 'Výborně! A k tomu jedno vychlazené Yebisu… ááách!' },
{ name: 'Gendō Ikari', emoji: '🕶️', color: '#111214', quote: 'Všechno probíhá podle scénáře.' },
{ name: 'Ritsuko Akagi', emoji: '🧪', color: '#c9a227', quote: 'MAGI rozhodly jednomyslně: Melchior, Balthasar i Caspar.' },
{ name: 'Kaworu Nagisa', emoji: '🎹', color: '#8c93a8', quote: 'Oběd je vrcholem lidské kultury. Nemyslíš, Shinji-kun?' },
{ name: 'Pen Pen', emoji: '🐧', color: '#2f8fb3', quote: '(spokojeně zakváká a míří k lednici)' },
];
const YC_EMOJI = ['⛺', '🏕️', '🗻', '🔥', '🍂', '🍁', '🍜', '🍲', '🌲', '☕', '🐕', '🌙', '⭐', '🧣'];
// Hlášky z Yuru Camp△ (Nokuru, Shimarin, Chikuwa, nabe, výhled na Fuji...)
const YC_BADGES = ['ゆるキャン△', '野クル', 'Fuji-san!', 'Nabe party!', 'Chikuwa ♡', 'Shimarin', 'Jdeme kempovat!', 'Káva u ohně', 'Mňam~', 'Hokkori~'];
const YC_BADGE_COLORS = ['#3f6b4a', '#c8553d', '#e8833a', '#3b6fa8', '#8a5a3b', '#d9739b'];
/** Členky Nokuru, Rin a jejich okolí s „hláškou" po finálním výsledku. */
const YC_SPEAKERS = [
{ name: 'Nadeshiko Kagamihara', emoji: '🍲', color: '#d9739b', quote: 'Tohle bude mňamka! A večer si k tomu uvaříme ještě nabe, jo?' },
{ name: 'Rin Shima', emoji: '🏕️', color: '#3b6fa8', quote: 'Hm. Dobrá volba. Já si to ale dám v klidu sama u jezera.' },
{ name: 'Chiaki Ōgaki', emoji: '🗺️', color: '#c8553d', quote: 'Nokuru to oficiálně schvaluje! Hned to zapíšu do plánu výpravy!' },
{ name: 'Aoi Inuyama', emoji: '🍳', color: '#a87b4f', quote: 'Prý tam vaří nejlepší jídlo v celé prefektuře… Kecám. Ale vypadá to dobře.' },
{ name: 'Ena Saitō', emoji: '🐕', color: '#7d6a9e', quote: 'Chikuwa souhlasí. Teda spí, ale určitě by souhlasil.' },
{ name: 'Minami Toba', emoji: '🍶', color: '#6b8f71', quote: 'Výborně… a já si k tomu dám jen malou skleničku. Jen jednu.' },
{ name: 'Sakura Kagamihara', emoji: '🚗', color: '#5a6b7d', quote: 'Nastupte, odvezu vás. Ale zpátky už jedete autobusem.' },
{ name: 'Chikuwa', emoji: '🐶', color: '#d9a35b', quote: '(vrtí ocáskem a čeká, jestli mu něco kápne)' },
];
const SXF_EMOJI = ['🥜', '⭐', '🌹', '🕵️', '🐶', '🔍', '📰', '☕', '🎀', '💌', '🗝️', '🌟', '🍰', '🖍️'];
// Hlášky ze Spy × Family (Operace Strix, waku waku, hvězda Stella, Anyino „heh", Henderson a elegance...)
const SXF_BADGES = ['Waku waku!', 'Operace Strix', 'Stella ⭐', 'Heh.', 'PŘÍSNĚ TAJNÉ', 'Mise splněna', 'Elegance!', 'Buráky!', 'Eden Academy', 'Twilight'];
const SXF_BADGE_COLORS = ['#1e6f6c', '#d9483b', '#e2a93b', '#e98aa8', '#22303c', '#b3263a'];
/** Forgerovi a jejich okolí s „hláškou" po finálním výsledku. */
const SXF_SPEAKERS = [
{ name: 'Anya Forger', emoji: '🥜', color: '#e98aa8', quote: 'Waku waku! Anya chce tohle! A k tomu buráky!' },
{ name: 'Loid Forger', emoji: '🕵️', color: '#1e6f6c', quote: 'Výborná volba. Operace Oběd proběhne přesně podle plánu.' },
{ name: 'Yor Forger', emoji: '🌹', color: '#b3263a', quote: 'Chtěla jsem dnes uvařit sama… ale tohle vypadá taky moc dobře!' },
{ name: 'Bond Forger', emoji: '🐶', color: '#8a97a6', quote: '(viděl budoucnost — a v ní je tohle jídlo)' },
{ name: 'Yuri Briar', emoji: '💼', color: '#3c4a6b', quote: 'Sestřičko, jestli tohle budeš jíst ty, sním to taky!' },
{ name: 'Damian Desmond', emoji: '💢', color: '#4b3f72', quote: 'N-není to tak, že bych s tebou chtěl jít na oběd, ty jedna…!' },
{ name: 'Becky Blackbell', emoji: '🎀', color: '#d9a441', quote: 'Anyo, vždyť tohle je úplně jako z romantického dramatu!' },
{ name: 'Henry Henderson', emoji: '🎩', color: '#5a4a3a', quote: 'Elegantní volba! Vskutku elegantní!' },
];
const APO_EMOJI = ['🌿', '🧪', '🍵', '🏮', '🌸', '🐸', '🍄', '📜', '🪷', '🌙', '💮', '🍡', '🪭', '🐛'];
// Hlášky z Apothecary Diaries (ochutnávání jedů, Nefritový pavilon, zahradní slavnost, Maomaův pohled jako na slimáka...)
const APO_BADGES = ['薬屋のひとりごと', 'Ochutnáno ✓', 'Jed nenalezen', 'Jinshi-sama', 'Nefritový pavilon', 'Zahradní slavnost', 'Bylinky!', 'Vnitřní palác', 'Pohled jako na slimáka', 'Housenice čínská'];
const APO_BADGE_COLORS = ['#a8322a', '#2f7d62', '#b8902e', '#24201c', '#6b5aa6', '#c76a86'];
/** Maomao a obyvatelé vnitřního paláce s „hláškou" po finálním výsledku. */
const APO_SPEAKERS = [
{ name: 'Maomao', emoji: '🧪', color: '#6f8f6b', quote: 'Žádný jed. Škoda… ale chutnat to bude i tak.' },
{ name: 'Jinshi', emoji: '🌙', color: '#6b5aa6', quote: 'Výborná volba. Jen se na mě, Maomao, nedívej jako na slimáka.' },
{ name: 'Gaoshun', emoji: '🍵', color: '#4f5d6b', quote: 'Zařídím to. Mistře Jinshi, prosím, jezte tentokrát včas.' },
{ name: 'Konkubína Gyokuyou', emoji: '💎', color: '#a8322a', quote: 'Jak báječné! Maomao, ochutnáš to nejdřív za mě, že ano?' },
{ name: 'Xiaolan', emoji: '🍡', color: '#d97a95', quote: 'Maomao, Maomao! Prý tam dávají sladkosti navíc!' },
{ name: 'Luomen', emoji: '🌿', color: '#8a7a5a', quote: 'Moudrá volba. Jen nezapomeň, že i lék může být jedem.' },
{ name: 'Lakan', emoji: '♟️', color: '#7a5c3a', quote: 'Lidi vidím jen jako figurky na desce… ale tahle figurka je k snědku.' },
{ name: 'Konkubína Lishu', emoji: '🌸', color: '#c98fb0', quote: 'J-já si dám to samé… když to Maomao ochutnala.' },
];
const FR_EMOJI = ['✨', '🌸', '🌼', '💐', '📖', '🪄', '📦', '🍇', '🌙', '⭐', '💜', '🦋', '🍖', '🔮'];
// Hlášky z Frieren (Zoltraak, mimik, kouzlo květinového pole, „Himmel by to udělal"...)
const FR_BADGES = ['葬送のフリーレン', 'Zoltraak', 'Mimik!', 'Pole květin', 'Himmel by to udělal', 'Fern se zlobí', 'O 1000 let později', 'Grimoár ✦', 'Potlačená mana', 'Zbytečné kouzlo'];
const FR_BADGE_COLORS = ['#5b4a9e', '#8e7cc3', '#5b8fc7', '#bf9b4a', '#6f7a92', '#c76a96'];
/** Družina Frieren (dnešní i Himmelova) s „hláškou" po finálním výsledku. */
const FR_SPEAKERS = [
{ name: 'Frieren', emoji: '✨', color: '#8a9a7a', quote: 'Dobře. Ale potom mi pomůžete otevřít tu truhlu… určitě to není mimik.' },
{ name: 'Fern', emoji: '💜', color: '#5b4a9e', quote: 'Paní Frieren, jestli zase zaspíte, sním vaši porci.' },
{ name: 'Stark', emoji: '🪓', color: '#a5523a', quote: 'J-já se vůbec nebojím! …Jen mám trochu hlad.' },
{ name: 'Himmel', emoji: '💐', color: '#5b8fc7', quote: 'Tohle by udělal Himmel Hrdina. Tedy… já. Pojďme!' },
{ name: 'Heiter', emoji: '🍷', color: '#8a6d9e', quote: 'Výborně. A k obědu malou sklenku — z Boží vůle, samozřejmě.' },
{ name: 'Eisen', emoji: '🪨', color: '#8a7a5a', quote: '…Jídlo je dobré. To stačí.' },
{ name: 'Aura', emoji: '⚖️', color: '#c76a96', quote: 'Poklekněte před tímto obědem! Váhy rozhodly.' },
{ name: 'Mimik', emoji: '📦', color: '#8a6a3a', quote: '(cvak!) …(spokojeně žvýká Frieren)' },
];
const G5_EMOJI = ['☀️', '🦄', '🌈', '💎', '📱', '❤️', '🥤', '🐉', '⭐', '🌊', '🎨', '🔍', '🌺', '✨'];
// Hlášky z My Little Pony G5 (Make Your Mark, Pippsqueaks, Maretime Bay, Zephyr Heights, Bridlewood...)
const G5_BADGES = ['Make Your Mark!', 'Fit Right In', 'Pippsqueaks ♡', 'Maretime Bay', 'Zephyr Heights', 'Bridlewood', '♥ Like!', 'Smoothie time!', 'Šerif Hitch', 'Unity!'];
const G5_BADGE_COLORS = ['#f08a3c', '#ee5fa8', '#9b6fe0', '#5a7bd8', '#2fa89c', '#6fb1d6'];
/** Mane 5, Misty, Sparky a královna Haven s „hláškou" po finálním výsledku. */
const G5_SPEAKERS = [
{ name: 'Sunny Starscout', emoji: '☀️', color: '#f08a3c', quote: 'Super! Kdo jde se mnou? Po obědě dáme smoothie!' },
{ name: 'Izzy Moonbow', emoji: '🦄', color: '#9b6fe0', quote: 'Jééé! A z obalu si potom něco vyrobím — upcycling!' },
{ name: 'Pipp Petals', emoji: '📱', color: '#ee5fa8', quote: 'Ahoj, Pippsqueaks! Dneska vám ukážu ten NEJLEPŠÍ oběd. Lajkujte!' },
{ name: 'Zipp Storm', emoji: '🔍', color: '#5a7bd8', quote: 'Vyšetřila jsem to. Všechny stopy vedou k tomuhle podniku.' },
{ name: 'Hitch Trailblazer', emoji: '⭐', color: '#2fa89c', quote: 'Jako šerif Maretime Bay to schvaluju. Sparky, nesahej mi na talíř!' },
{ name: 'Misty Brightdawn', emoji: '💙', color: '#6fb1d6', quote: 'J-já… já bych to taky ráda zkusila. Jestli teda můžu.' },
{ name: 'Sparky Sparkeroni', emoji: '🐉', color: '#7cb342', quote: '(škytne a vypustí obláček duhových jisker)' },
{ name: 'Královna Haven', emoji: '👑', color: '#d4a21e', quote: 'Královská volba, mé drahé. Zephyr Heights by souhlasily.' },
];
const AGG_EMOJI = ['🎤', '🤘', '🔥', '💢', '☕', '📎', '📊', '🗂️', '🍺', '🍙', '📱', '🐷', '🎸', '💼'];
// Hlášky z Aggretsuko (karaoke, přesčasy, účetnictví, šéf Ton, pátek...)
const AGG_BADGES = ['DEATH METAL!', 'Přesčas', 'Účetnictví', 'Karaoke!', 'Šéf Ton…', 'Excel', 'Pátek!!!', 'Rage mode', '承認', 'Ještě jedna tabulka'];
const AGG_BADGE_COLORS = ['#d9673a', '#c8102e', '#4a5d7a', '#1a1414', '#e8a0b8', '#c9a227'];
/** Retsuko a kolegové z Carrier Man Trading s „hláškou" po finálním výsledku. */
const AGG_SPEAKERS = [
{ name: 'Retsuko', emoji: '🎤', color: '#d9673a', quote: 'Ano, hned to bude… (v duchu: KONEČNĚ OBĚD, VY PROKLETÉ TABULKY!!!)' },
{ name: 'Fenneko', emoji: '📱', color: '#c9a27a', quote: 'Hmm. O tom podniku už jsem na sítích našla úplně všechno. Hehehe.' },
{ name: 'Haida', emoji: '🎸', color: '#6b6f7a', quote: 'R-Retsuko, můžu jít s tebou? Teda… jestli chceš.' },
{ name: 'Šéf Ton', emoji: '🐷', color: '#d48a9a', quote: 'Hej, holka! Dones mi taky jeden, a ať je to rychle!' },
{ name: 'Gori', emoji: '🦍', color: '#8a6aa8', quote: 'Retsuko, to je tak inspirativní volba! Jsem na tebe pyšná!' },
{ name: 'Washimi', emoji: '🦅', color: '#4a5d7a', quote: 'Efektivní rozhodnutí. Oceňuji, že na tom nestrávila celou poradu.' },
{ name: 'Tsunoda', emoji: '🦌', color: '#e8a0b8', quote: 'Jééé, to je tak roztomilý podnik! Šéfe, pojďte taky~' },
{ name: 'Kabae', emoji: '🦛', color: '#9ab0c8', quote: 'Prý tam vaří skvěle — a taky jsem o nich slyšela pár drbů…' },
];
const SK_EMOJI = ['🐧', '🐻‍❄️', '🐱', '🦎', '🍤', '🥒', '🍵', '🧋', '🌱', '🍄', '⛰️', '🐌', '🧺', '☁️'];
// Hlášky ze Sumikko Gurashi (kouty, tonkatsu, ocásek krevety, Penguin?, tapioka...)
const SK_BADGES = ['すみっコぐらし', 'Do kouta!', 'Tonkatsu ♡', 'Ebifurai no shippo', 'Klid v koutě', 'Brrr, zima…', 'Okurka!', 'Tapioka', 'Penguin?', 'Rohový stoleček'];
const SK_BADGE_COLORS = ['#8fbf7a', '#d9a066', '#7fb3d0', '#e8a0b8', '#a8988a', '#c9a07a'];
/** Postavičky z koutků s „hláškou" po finálním výsledku. */
const SK_SPEAKERS = [
{ name: 'Shirokuma', emoji: '🐻‍❄️', color: '#8fa8bf', quote: 'Brrr… vezmu si deku a horký čaj. A ten nejteplejší kout.' },
{ name: 'Penguin?', emoji: '🐧', color: '#8fbf7a', quote: 'Nejsem si jistý, jestli jsem tučňák… ale okurku k tomu si dám určitě.' },
{ name: 'Tonkatsu', emoji: '🐷', color: '#d9a066', quote: 'Jsem jen kraj řízku, 99 % tuku… snad mě tam na talíři nenechají.' },
{ name: 'Neko', emoji: '🐱', color: '#c9a88a', quote: 'Jé, to vypadá dobře… ale hlídám si postavu. Tak jen malou porci.' },
{ name: 'Tokage', emoji: '🦎', color: '#7fb3d0', quote: 'Psst, já jsem vlastně dinosaurus. Ale oběd si dám jako ještěrka.' },
{ name: 'Ebifurai no shippo', emoji: '🍤', color: '#e8a86a', quote: 'S tonkatsu jsme nejlepší kamarádi — zbytky drží spolu!' },
{ name: 'Zassou', emoji: '🌱', color: '#9cc06f', quote: 'Jednou ze mě bude krásná kytice. Do té doby si dám oběd.' },
{ name: 'Tapioka', emoji: '🧋', color: '#e8a0b8', quote: 'Nás perliček zbylo na dně hodně… tak jdeme na to!' },
];
/** 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: '🎸 結束バンド 🎸' },
evangelion: { prefix: 'confetti-eva', badges: EVA_BADGES, colors: EVA_BADGE_COLORS, emoji: EVA_EMOJI, speakers: EVA_SPEAKERS, banner: '👏 おめでとう 👏' },
yurucamp: { prefix: 'confetti-yc', badges: YC_BADGES, colors: YC_BADGE_COLORS, emoji: YC_EMOJI, speakers: YC_SPEAKERS, banner: '⛺ ゆるキャン△ 🔥' },
spyfamily: { prefix: 'confetti-sxf', badges: SXF_BADGES, colors: SXF_BADGE_COLORS, emoji: SXF_EMOJI, speakers: SXF_SPEAKERS, banner: '🥜 わくわく! 🥜' },
apothecary: { prefix: 'confetti-apo', badges: APO_BADGES, colors: APO_BADGE_COLORS, emoji: APO_EMOJI, speakers: APO_SPEAKERS, banner: '🏮 薬屋のひとりごと 🏮' },
frieren: { prefix: 'confetti-fr', badges: FR_BADGES, colors: FR_BADGE_COLORS, emoji: FR_EMOJI, speakers: FR_SPEAKERS, banner: '✦ 葬送のフリーレン ✦' },
mlpg5: { prefix: 'confetti-g5', badges: G5_BADGES, colors: G5_BADGE_COLORS, emoji: G5_EMOJI, speakers: G5_SPEAKERS, banner: '☀️ Make Your Mark! ☀️' },
aggretsuko: { prefix: 'confetti-agg', badges: AGG_BADGES, colors: AGG_BADGE_COLORS, emoji: AGG_EMOJI, speakers: AGG_SPEAKERS, banner: '🤘 RAAAAAH!!! 🎤' },
sumikko: { prefix: 'confetti-sk', badges: SK_BADGES, colors: SK_BADGE_COLORS, emoji: SK_EMOJI, speakers: SK_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, piloti NERV, Nokuru, Forgerovi, vnitřní palác, družina Frieren, poníci z Maretime Bay, kancelář Retsuko, postavičky z koutků...)
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>
);
}
@@ -0,0 +1,35 @@
import { useRef } from "react";
import { Modal, Button } from "react-bootstrap"
import { useBank } from "../../context/bank";
type Props = {
isOpen: boolean,
onClose: () => void,
onSave: (bankAccountNumber?: string, bankAccountHolderName?: string) => void,
}
/** Modální dialog pro nastavení čísla účtu a jména majitele. */
export default function BankAccountModal({ isOpen, onClose, onSave }: Props) {
const bank = useBank();
const bankAccountRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>Bankovní účet</Modal.Title>
</Modal.Header>
<Modal.Body>
<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>
Číslo účtu: <input className="mb-3" ref={bankAccountRef} type="text" placeholder="123456-1234567890/1234" defaultValue={bank?.bankAccount} /> <br />
Název příjemce (jméno majitele účtu): <input ref={nameRef} type="text" placeholder="Jan Novák" defaultValue={bank?.holderName} />
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
Storno
</Button>
<Button variant="primary" onClick={() => onSave(bankAccountRef.current?.value, nameRef.current?.value)}>
Uložit
</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 }: 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ě 3 možnosti</p>
</Modal.Title>
</Modal.Header>
<Modal.Body>
{(Object.keys(FeatureRequest) as Array<keyof typeof FeatureRequest>).map(key => {
return <Form.Check
key={key}
type='checkbox'
id={key}
label={FeatureRequest[key]}
onChange={handleChange}
value={key}
defaultChecked={initialValues && initialValues.includes(key as FeatureRequest)}
/>
})}
<p className="mt-3" style={{ fontSize: '12px' }}>Něco jiného? Dejte vědět.</p>
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={onClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
}
@@ -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,36 +0,0 @@
import { useRef } from "react";
import { Modal, Button, Form } from "react-bootstrap"
type Props = {
isOpen: boolean,
onClose: () => void,
onSave: (note?: string) => void,
}
/** Modální dialog pro úpravu obecné poznámky. */
export default function NoteModal({ isOpen, onClose, onSave }: Readonly<Props>) {
const note = useRef<HTMLInputElement>(null);
const save = () => {
onSave(note?.current?.value);
}
return <Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>Úprava poznámky</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Control ref={note} autoFocus={true} type="text" id="note" onKeyDown={event => {
if (event.key === 'Enter') {
save();
}
event.stopPropagation();
}} />
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={save}>
Uložit
</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>
);
}
@@ -10,17 +10,17 @@ type Props = {
}
/** Modální dialog pro nastavení příplatků za pizzu. */
export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose, onSave, initialValues }: Readonly<Props>) {
export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose, onSave, initialValues }: Props) {
const textRef = useRef<HTMLInputElement>(null);
const priceRef = useRef<HTMLInputElement>(null);
const doSubmit = () => {
onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100));
onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value || "0"));
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100));
onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value || "0"));
}
}
@@ -1,137 +0,0 @@
import { useRef, useState } from "react";
import { Modal, Button, Row, Col } from "react-bootstrap"
type Props = {
isOpen: boolean,
onClose: () => void,
}
type Result = {
pizza1?: {
diameter?: number,
area?: number,
pricePerM?: number,
},
pizza2?: {
diameter?: number,
area?: number,
pricePerM?: number,
}
choice?: number,
ratio?: number,
diameterDiff?: number,
}
/** Modální dialog pro výpočet výhodnosti pizzy. */
export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<Props>) {
const diameter1Ref = useRef<HTMLInputElement>(null);
const price1Ref = useRef<HTMLInputElement>(null);
const diameter2Ref = useRef<HTMLInputElement>(null);
const price2Ref = useRef<HTMLInputElement>(null);
const [result, setResult] = useState<Result | null>(null);
const recalculate = () => {
const r: Result = { ...result }
// 1. pizza
if (diameter1Ref.current?.value) {
const diameter1 = Number.parseInt(diameter1Ref.current?.value);
r.pizza1 ??= {};
if (diameter1 && diameter1 > 0) {
r.pizza1.diameter = diameter1;
r.pizza1.area = Math.PI * Math.pow(diameter1 / 2, 2);
if (price1Ref.current?.value) {
const price1 = Number.parseInt(price1Ref.current?.value);
if (price1) {
r.pizza1.pricePerM = price1 / r.pizza1.area;
} else {
r.pizza1.pricePerM = undefined;
}
}
} else {
r.pizza1.area = undefined;
}
}
// 2. pizza
if (diameter2Ref.current?.value) {
const diameter2 = Number.parseInt(diameter2Ref.current?.value);
r.pizza2 ??= {};
if (diameter2 && diameter2 > 0) {
r.pizza2.diameter = diameter2;
r.pizza2.area = Math.PI * Math.pow(diameter2 / 2, 2);
if (price2Ref.current?.value) {
const price2 = Number.parseInt(price2Ref.current?.value);
if (price2) {
r.pizza2.pricePerM = price2 / r.pizza2.area;
} else {
r.pizza2.pricePerM = undefined;
}
}
} else {
r.pizza2.area = undefined;
}
}
// Srovnání
if (r.pizza1?.pricePerM && r.pizza2?.pricePerM && r.pizza1.diameter && r.pizza2.diameter) {
r.choice = r.pizza1.pricePerM < r.pizza2.pricePerM ? 1 : 2;
const bigger = Math.max(r.pizza1.pricePerM, r.pizza2.pricePerM);
const smaller = Math.min(r.pizza1.pricePerM, r.pizza2.pricePerM);
r.ratio = (bigger / smaller) - 1;
r.diameterDiff = Math.abs(r.pizza1.diameter - r.pizza2.diameter);
} else {
r.choice = undefined;
r.ratio = undefined;
r.diameterDiff = undefined;
}
setResult(r);
}
const close = () => {
setResult(null);
onClose();
}
return <Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>Pizza kalkulačka</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Zadejte parametry pizzy pro jejich srovnání.</p>
<Row>
<Col size="6">
<input className="mb-3" ref={diameter1Ref} type="number" step="1" min="1" placeholder="Průměr 1. pizzy (cm)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
<Col size="6">
<input className="mb-3" ref={diameter2Ref} type="number" step="1" min="1" placeholder="Průměr 2. pizzy (cm)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
</Row>
<Row>
<Col size="6">
<input className="mb-3" ref={price1Ref} type="number" min="1" placeholder="Cena 1. pizzy (Kč)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
<Col size="6">
<input className="mb-3" ref={price2Ref} type="number" min="1" placeholder="Cena 2. pizzy (Kč)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
</Row>
<Row>
<Col size="6">
{result?.pizza1?.area && <p>Plocha: <b>{Math.round(result.pizza1.area * 10) / 10}</b> cm²</p>}
{result?.pizza1?.pricePerM && <p>Cena za m²: <b>{Math.round(result.pizza1.pricePerM * 1000000) / 100}</b> Kč</p>}
</Col>
<Col size="6">
{result?.pizza2?.area && <p>Plocha: <b>{Math.round(result.pizza2.area * 10) / 10}</b> cm²</p>}
{result?.pizza2?.pricePerM && <p>Cena za m²: <b>{Math.round(result.pizza2.pricePerM * 1000000) / 100}</b> Kč</p>}
</Col>
</Row>
{(result?.choice && result?.ratio && result?.ratio > 0 && result?.diameterDiff != null && <p><b>{result.choice}. pizza</b> je zhruba o <b>{Math.round(result.ratio * 1000) / 10}%</b> výhodnější než {result.choice === 1 ? "2" : "1"}. pizza.</p>) || ''}
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={close}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
}
@@ -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,97 +0,0 @@
import { useRef, useState } from "react";
import { Modal, Button, Alert, Form } from "react-bootstrap";
type Props = {
isOpen: boolean;
onClose: () => void;
};
/** Modální dialog pro přenačtení menu z restaurací. */
export default function RefreshMenuModal({ isOpen, onClose }: Readonly<Props>) {
const refreshPassRef = useRef<HTMLInputElement>(null);
const refreshTypeRef = useRef<HTMLSelectElement>(null);
const [refreshLoading, setRefreshLoading] = useState(false);
const [refreshMessage, setRefreshMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const handleRefresh = async () => {
const password = refreshPassRef.current?.value;
const type = refreshTypeRef.current?.value;
if (!password || !type) {
setRefreshMessage({ type: 'error', text: 'Zadejte heslo a typ refresh.' });
return;
}
setRefreshLoading(true);
setRefreshMessage(null);
try {
const res = await fetch(`/api/food/refresh?type=${type}&heslo=${encodeURIComponent(password)}`);
const data = await res.json();
if (res.ok) {
setRefreshMessage({ type: 'success', text: 'Uspesny fetch' });
if (refreshPassRef.current) {
refreshPassRef.current.value = '';
}
} else {
setRefreshMessage({ type: 'error', text: data.error || 'Chyba při obnovování jídelníčku.' });
}
} catch (error) {
console.error('Error refreshing menu:', error);
setRefreshMessage({ type: 'error', text: 'Chyba při obnovování jídelníčku.' });
} finally {
setRefreshLoading(false);
}
};
const handleClose = () => {
setRefreshMessage(null);
onClose();
};
return (
<Modal show={isOpen} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title><h2>Přenačtení menu</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Ruční refresh dat z restaurací.</p>
{refreshMessage && (
<Alert variant={refreshMessage.type === 'success' ? 'success' : 'danger'}>
{refreshMessage.text}
</Alert>
)}
<Form.Group className="mb-3">
<Form.Label>Heslo</Form.Label>
<Form.Control
ref={refreshPassRef}
type="password"
placeholder="Zadejte heslo"
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Typ refreshe</Form.Label>
<Form.Select ref={refreshTypeRef} defaultValue="week">
<option value="week">Týden</option>
<option value="day">Den</option>
</Form.Select>
</Form.Group>
<Button
onClick={handleRefresh}
disabled={refreshLoading}
>
{refreshLoading ? 'Načítám...' : 'Obnovit menu'}
</Button>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -1,309 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { Modal, Button, Form, Row, Col } from "react-bootstrap"
import { useSettings, ThemePreference, StyleTheme, STYLE_THEMES } from "../../context/settings";
import { NotificationSettings, UdalostEnum, getNotificationSettings, updateNotificationSettings } from "../../../../types";
import { useAuth } from "../../context/auth";
import { subscribeToPush, unsubscribeFromPush } from "../../hooks/usePushReminder";
type Props = {
isOpen: boolean,
onClose: () => void,
onSave: (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean, themePreference?: ThemePreference) => void,
}
/** Modální dialog pro uživatelská nastavení. */
export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Props>) {
const auth = useAuth();
const settings = useSettings();
const bankAccountRef = useRef<HTMLInputElement>(null);
const nameRef = 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);
const boltDeliveredRef = useRef<HTMLInputElement>(null);
const ntfyTopicRef = useRef<HTMLInputElement>(null);
const discordWebhookRef = useRef<HTMLInputElement>(null);
const teamsWebhookRef = useRef<HTMLInputElement>(null);
const [notifSettings, setNotifSettings] = useState<NotificationSettings>({});
const [enabledEvents, setEnabledEvents] = useState<UdalostEnum[]>([]);
useEffect(() => {
if (isOpen && auth?.login) {
getNotificationSettings().then(response => {
if (response.data) {
setNotifSettings(response.data);
setEnabledEvents(response.data.enabledEvents ?? []);
}
}).catch(() => {});
}
}, [isOpen, auth?.login]);
const toggleEvent = (event: UdalostEnum) => {
setEnabledEvents(prev =>
prev.includes(event) ? prev.filter(e => e !== event) : [...prev, event]
);
};
const handleSave = async () => {
const newReminderTime = reminderTimeRef.current?.value || undefined;
const oldReminderTime = notifSettings.reminderTime;
const newBoltDelivered = boltDeliveredRef.current?.checked ?? false;
const oldBoltDelivered = notifSettings.boltDeliveredPush ?? false;
// Uložení notifikačních nastavení na server
const newSettings: NotificationSettings = {
ntfyTopic: ntfyTopicRef.current?.value || undefined,
discordWebhookUrl: discordWebhookRef.current?.value || undefined,
teamsWebhookUrl: teamsWebhookRef.current?.value || undefined,
enabledEvents,
reminderTime: newReminderTime,
boltDeliveredPush: newBoltDelivered,
};
await updateNotificationSettings({ body: newSettings }).catch(() => {});
setNotifSettings(newSettings);
// Správa push subscription — drží ji naživu kterákoli z push funkcí.
// Záměrně bez await: subscribeToPush si vyžádá oprávnění prohlížeče a modal
// by na tu dobu zůstal otevřený.
const wantsPush = !!newReminderTime || newBoltDelivered;
const hadPush = !!oldReminderTime || oldBoltDelivered;
if (wantsPush && (newReminderTime !== oldReminderTime || newBoltDelivered !== oldBoltDelivered)) {
subscribeToPush(newReminderTime);
} else if (!wantsPush && hadPush) {
unsubscribeFromPush();
}
// Uložení ostatních nastavení (localStorage)
if (styleThemeRef.current?.value) {
settings?.setStyleTheme(styleThemeRef.current.value as StyleTheme);
}
if (showButterfliesRef.current) {
settings?.setShowButterflies(showButterfliesRef.current.checked);
}
if (autoOrderChoiceRef.current) {
settings?.setAutoOrderChoice(autoOrderChoiceRef.current.checked);
}
onSave(
bankAccountRef.current?.value,
nameRef.current?.value,
hideSoupsRef.current?.checked,
themeRef.current?.value as ThemePreference,
);
};
return (
<Modal show={isOpen} onHide={onClose} size="xl">
<Modal.Header closeButton>
<Modal.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>
);
}
Loaded 100 of 406 files, more files were not shown because too many files have changed in this diff. Show more