Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d979aab7cb |
@@ -1,261 +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: "22"
|
||||
|
||||
- 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: "22"
|
||||
|
||||
- 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: "22"
|
||||
|
||||
- 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: "22"
|
||||
|
||||
- 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: "22"
|
||||
|
||||
- run: corepack enable
|
||||
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: server-dist
|
||||
path: server/dist
|
||||
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: client-dist
|
||||
path: client/dist
|
||||
|
||||
- name: Install server production dependencies
|
||||
run: cd server && yarn install --frozen-lockfile --production
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ secrets.REPO_URL }}
|
||||
username: ${{ secrets.REPO_USERNAME }}
|
||||
password: ${{ secrets.REPO_PASSWORD }}
|
||||
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
target: runner-prebuilt
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ secrets.REPO_URL }}/${{ secrets.REPO_NAME }}:latest
|
||||
|
||||
# ─── 5. Notifications ────────────────────────
|
||||
|
||||
notify:
|
||||
name: Notify
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ server-build, client-build, server-test, e2e, docker-build ]
|
||||
if: always() && github.event_name == 'push'
|
||||
steps:
|
||||
- name: Send webhook
|
||||
env:
|
||||
DISCORD_WEBHOOK_ID: ${{ secrets.DISCORD_WEBHOOK_ID }}
|
||||
DISCORD_WEBHOOK_TOKEN: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
|
||||
NTFY_URL: ${{ secrets.NTFY_URL }}
|
||||
BUILD_RESULT: ${{ needs.docker-build.result }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{
|
||||
github.run_id }}
|
||||
COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
COMMIT_AUTHOR: ${{ github.event.head_commit.author.name }}
|
||||
run: |
|
||||
if [ "$BUILD_RESULT" = "success" ]; then
|
||||
MSG="✅ Sestavení #${RUN_NUMBER} proběhlo úspěšně."
|
||||
NTFY_TAGS="white_check_mark"
|
||||
else
|
||||
MSG="❌ Sestavení #${RUN_NUMBER} selhalo."
|
||||
NTFY_TAGS="x"
|
||||
fi
|
||||
FULL_MSG="$(printf '%s\n\nPipeline: %s\nPoslední commit: %sAutor: %s' \
|
||||
"$MSG" "$RUN_URL" "$COMMIT_MESSAGE" "$COMMIT_AUTHOR")"
|
||||
curl -s -X POST \
|
||||
"https://discord.com/api/webhooks/${DISCORD_WEBHOOK_ID}/${DISCORD_WEBHOOK_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "$(jq -n --arg content "$FULL_MSG" '{content: $content}')"
|
||||
curl -s -X POST "${NTFY_URL}" \
|
||||
-H "Title: Luncher CI #${RUN_NUMBER}" \
|
||||
-H "Tags: ${NTFY_TAGS}" \
|
||||
-H "Click: ${RUN_URL}" \
|
||||
-d "${FULL_MSG}"
|
||||
@@ -1,9 +1,26 @@
|
||||
# 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
|
||||
/.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*
|
||||
|
||||
__pycache__
|
||||
venv
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Server (ts-node, debug)",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}/server",
|
||||
"runtimeArgs": ["-r", "ts-node/register"],
|
||||
"program": "${workspaceFolder}/server/src/index.ts",
|
||||
"env": { "NODE_ENV": "development" },
|
||||
"console": "integratedTerminal",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"preLaunchTask": "types: openapi-ts"
|
||||
},
|
||||
{
|
||||
"name": "Client (vite + Edge)",
|
||||
"type": "msedge",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000",
|
||||
"webRoot": "${workspaceFolder}/client",
|
||||
"preLaunchTask": "client: vite"
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Dev: server + client",
|
||||
"configurations": ["Server (ts-node, debug)", "Client (vite + Edge)"],
|
||||
"stopAll": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "types: openapi-ts",
|
||||
"type": "shell",
|
||||
"command": "yarn openapi-ts",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/types"
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "silent",
|
||||
"panel": "dedicated"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "server: startReload",
|
||||
"type": "shell",
|
||||
"command": "yarn startReload",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/server",
|
||||
"env": {
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"isBackground": true,
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"group": "dev"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "client: vite",
|
||||
"type": "shell",
|
||||
"command": "yarn start",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/client"
|
||||
},
|
||||
"isBackground": true,
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"group": "dev"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "dev: server+client",
|
||||
"dependsOn": [
|
||||
"server: startReload",
|
||||
"client: vite"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "dev: all",
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"types: openapi-ts",
|
||||
"dev: server+client"
|
||||
],
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -1,121 +0,0 @@
|
||||
ARG NODE_VERSION="node:22-alpine"
|
||||
|
||||
# ─── Builder ──────────────────────────────────────────────────────────────────
|
||||
FROM ${NODE_VERSION} 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/
|
||||
|
||||
# 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/
|
||||
|
||||
# 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
|
||||
|
||||
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/server/dist ./
|
||||
|
||||
# Vykopírování sestaveného klienta
|
||||
COPY --from=builder /build/client/dist ./public
|
||||
|
||||
# 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
|
||||
@@ -1,29 +1,30 @@
|
||||
# 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 tří (čtyř) modulů.
|
||||
- food_api
|
||||
- Python scraper/parser pro zpracování obědových menu restaurací
|
||||
- server
|
||||
- backend psaný v [node.js](https://nodejs.dev)
|
||||
- client
|
||||
- frontend psaný v [React.js](https://react.dev)
|
||||
- [nginx](https://nginx.org)
|
||||
- proxy pro snadné propojení Docker kontejnerů pod jednou URL
|
||||
|
||||
## Spuštění pro vývoj
|
||||
### Závislosti
|
||||
#### Food API
|
||||
- [Python 3](https://www.python.org)
|
||||
- [pip](https://pypi.org/project/pip)
|
||||
#### 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 `client/.env.template` do `client/.env.development` a upravit dle potřeby
|
||||
- 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
|
||||
@@ -33,8 +34,30 @@ Aplikace sestává ze tří modulů.
|
||||
### Spuštění
|
||||
- `docker compose up --build -d`
|
||||
|
||||
### Spuštení s traefik
|
||||
- `docker compose -f compose-traefik.yml up --build -d`
|
||||
|
||||
## TODO
|
||||
Dostupné [zde](TODO.md).
|
||||
- [x] Umožnit smazání aktuální volby "popelnicí", místo nutnosti vybrat prázdnou položku v selectu
|
||||
- [x] Přívětivější možnost odhlašování
|
||||
- [x] Vyřešit responzivní design pro použití na mobilu
|
||||
- [x] Vyndat URL na Food API do .env
|
||||
- [x] Neselhat při nedostupnosti nebo chybě z Food API
|
||||
- [x] Dokončit docker-compose pro kompletní funkčnost
|
||||
- [x] Implementovat Pizza day
|
||||
- [x] Umožnit uzamčení objednávek zakladatelem
|
||||
- [x] Možnost uložení čísla účtu
|
||||
- [ ] Automatické generování a zobrazení QR kódů
|
||||
- [ ] https://qr-platba.cz/pro-vyvojare/restful-api/
|
||||
- [ ] Zobrazovat celkovou cenu objednávky pod tabulkou objednávek
|
||||
- [ ] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day
|
||||
- [ ] Umožnit přidat k objednávce poznámku (např. "bez oliv")
|
||||
- [ ] Předvyplnění poslední vybrané hodnoty občas nefunguje, viz komentář
|
||||
- [ ] Nasazení nové verze v Docker smaže veškerá data (protože data.json není venku)
|
||||
- [ ] Vylepšit dokumentaci projektu
|
||||
- [ ] Popsat Food API, nginx
|
||||
- [x] Popsat závislosti, co je nutné provést před vývojem a postup spuštění pro vývoj
|
||||
- [x] Popsat dostupné env
|
||||
- [ ] 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()
|
||||
- [ ] Přesunout autentizaci na server (JWT?)
|
||||
- [x] Zavést .env.template a přidat .env do .gitignore
|
||||
- [ ] Zkrášlit dialog pro vyplnění čísla účtu, vypadá mizerně
|
||||
- [ ] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)
|
||||
@@ -1,78 +0,0 @@
|
||||
# TODO
|
||||
|
||||
## HA / multi-replica follow-ups
|
||||
- [ ] `foodRoutes.ts` per-pod rate limiter (`rateLimits` map) — s více replikami může uživatel překročit limit ~N× rychleji; přesunout do Redis (např. `INCR` + `EXPIRE`)
|
||||
- [ ] `easterEggRoutes.ts` — náhodně generované URL easter eggů jsou per-pod; URL funguje pouze na podu, který ji vygeneroval; zvážit deterministické seedy nebo sdílení přes Redis
|
||||
- [ ] `service.ts` — komplexní víceúrovňové funkce (`addChoice`, `removeChoiceIfPresent`) provádějí více po sobě jdoucích zápisů do stejného Redis klíče; pro plnou atomicitu je potřeba per-klíčový distribuovaný zámek (Redlock nebo `SET NX EX`) nebo sloučení logiky do jednoho `updateData` volání
|
||||
- [ ] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli
|
||||
- [ ] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď)
|
||||
- [ ] Nemělo by se jít dostat na přihlašovací formulář (měla by tam být nanejvýš hláška nebo přesměrování)
|
||||
- [ ] 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.)
|
||||
- [ ] 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 ( , )
|
||||
- [ ] 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
|
||||
- [ ] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh)
|
||||
- [ ] Implementovat 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
|
||||
- [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
|
||||
- [x] Umožnit uzamčení objednávek zakladatelem
|
||||
- [x] Možnost uložení čísla účtu
|
||||
- [x] Automatické generování a zobrazení QR kódů
|
||||
- [x] https://qr-platba.cz/pro-vyvojare/restful-api/
|
||||
- [x] Zobrazovat celkovou cenu objednávky pod tabulkou objednávek
|
||||
- [x] Umožnit přidat k objednávce poznámku (např. "bez oliv")
|
||||
- [x] Negenerovat QR kód pro objednávajícího
|
||||
- [X] Možnost náhledu na ostatní dny v týdnu (např. pomocí šipek)
|
||||
- [X] Možnost výběru oběda na následující dny v týdnu
|
||||
- [X] Umožnit vybrat libovolný čas odchodu
|
||||
- [X] Validace zadání smysluplného času (ideálně i klientská)
|
||||
- [x] Umožnit smazání aktuální volby "popelnicí", místo nutnosti vybrat prázdnou položku v selectu
|
||||
- [x] Přívětivější možnost odhlašování
|
||||
- [x] Vyřešit responzivní design pro použití na mobilu
|
||||
- [x] Vyndat URL na Food API do .env
|
||||
- [x] Neselhat při nedostupnosti nebo chybě z Food API
|
||||
- [x] Dokončit docker-compose pro kompletní funkčnost
|
||||
- [x] Vylepšit dokumentaci projektu
|
||||
- [x] Popsat závislosti, co je nutné provést před vývojem a postup spuštění pro vývoj
|
||||
- [x] Popsat dostupné env
|
||||
- [x] Přesunout autentizaci na server (JWT?)
|
||||
- [x] Zavést .env.template a přidat .env do .gitignore
|
||||
- [x] Zkrášlit dialog pro vyplnění čísla účtu, vypadá mizerně
|
||||
- [x] Zbavit se Food API, potřebnou funkcionalitu zahrnout do serveru
|
||||
- [x] Vyřešit API mezi serverem a klientem, aby nebyl v obou projektech duplicitní kód (viz types.ts a Types.tsx)
|
||||
- [X] Vybraná jídla strávníků zobrazovat v samostatném sloupci
|
||||
- [X] Umožnit výběr/zadání preferovaného času odchodu na oběd
|
||||
- Hodí se např. pokud má někdo schůzky
|
||||
- [X] Ukládat dostupné pizzy do DB místo souborů
|
||||
- [X] Ukládat jídla do DB místo souborů
|
||||
@@ -0,0 +1,3 @@
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
build
|
||||
@@ -0,0 +1,3 @@
|
||||
# Veřejná URL, na které bude dostupný klient (typicky přes proxy).
|
||||
# Pro vývoj není potřeba, bude použita výchozí hodnota http://localhost:3001
|
||||
# PUBLIC_URL=http://example:3001
|
||||
@@ -1,2 +1,2 @@
|
||||
build
|
||||
dist
|
||||
.env.production
|
||||
@@ -0,0 +1,23 @@
|
||||
FROM node:18-alpine3.18 AS builder
|
||||
|
||||
COPY package.json .
|
||||
COPY yarn.lock .
|
||||
COPY tsconfig.json .
|
||||
COPY .env.production .
|
||||
|
||||
RUN yarn install
|
||||
|
||||
COPY ./src ./src
|
||||
COPY ./public ./public
|
||||
|
||||
RUN yarn build
|
||||
|
||||
FROM node:18-alpine3.18
|
||||
ENV NODE_ENV production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /build .
|
||||
|
||||
RUN yarn global add serve && yarn
|
||||
CMD ["serve", "-s", "."]
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
docker build -t luncher-client .
|
||||
@@ -1,41 +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" />
|
||||
<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);
|
||||
} 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>
|
||||
@@ -1,48 +1,42 @@
|
||||
{
|
||||
"name": "@luncher/client",
|
||||
"name": "luncher-client",
|
||||
"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",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.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-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",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "yarn vite",
|
||||
"build": "tsc --noEmit && yarn vite build"
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
@@ -58,7 +52,6 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"prettier": "^3.6.2"
|
||||
"prettier": "^2.8.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<!-- Vosa letící doprava (JS překlápí podle směru) -->
|
||||
<!-- křídla -->
|
||||
<g fill="#DDEEFF" stroke="#9BB6CC" stroke-width="1" opacity="0.85">
|
||||
<ellipse cx="46" cy="30" rx="14" ry="8" transform="rotate(-18 46 30)"/>
|
||||
<ellipse cx="58" cy="32" rx="11" ry="6" transform="rotate(-8 58 32)"/>
|
||||
</g>
|
||||
<!-- žihadlo -->
|
||||
<path fill="#3A2E00" d="M18,50 L6,50 L18,46 Z"/>
|
||||
<!-- tělo -->
|
||||
<g stroke="#3A2E00" stroke-width="1.4" stroke-linejoin="round">
|
||||
<ellipse cx="46" cy="50" rx="30" ry="16" fill="#F5C518"/>
|
||||
<!-- pruhy -->
|
||||
<path fill="#2B2200" stroke="none" d="M34,36 q6,14 0,28 q-8,-2 -10,-14 q2,-12 10,-14 z"/>
|
||||
<path fill="#2B2200" stroke="none" d="M50,35 q6,15 0,30 q-8,-3 -8,-15 q0,-12 8,-15 z"/>
|
||||
<!-- hlava -->
|
||||
<circle cx="72" cy="50" r="10" fill="#2B2200"/>
|
||||
</g>
|
||||
<!-- oko a tykadla -->
|
||||
<circle fill="#FFFFFF" cx="75" cy="47" r="2.4"/>
|
||||
<circle fill="#20313F" cx="75.6" cy="47" r="1.2"/>
|
||||
<g stroke="#2B2200" stroke-width="1.2" fill="none" stroke-linecap="round">
|
||||
<path d="M78,42 C84,36 88,36 90,38"/>
|
||||
<path d="M80,44 C86,40 90,41 92,43"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,18 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<!-- Pták letící doprava (JS překlápí podle směru) -->
|
||||
<g stroke="#3A4A5A" stroke-width="1.5" stroke-linejoin="round">
|
||||
<!-- ocas -->
|
||||
<path fill="#5C6B7A" d="M18,52 L4,44 L6,56 L2,64 L18,60 Z"/>
|
||||
<!-- tělo -->
|
||||
<path fill="#6E7F90" d="M16,58 C24,44 44,40 64,46 C74,49 82,54 84,56 C78,62 66,66 52,66 C36,66 22,64 16,58 Z"/>
|
||||
<!-- křídlo (nahoru) -->
|
||||
<path fill="#55636F" d="M40,52 C46,30 60,24 70,26 C64,38 58,48 54,56 C50,58 44,57 40,52 Z"/>
|
||||
<!-- hlava -->
|
||||
<circle fill="#6E7F90" cx="78" cy="50" r="12"/>
|
||||
</g>
|
||||
<!-- zobák -->
|
||||
<path fill="#F6A21E" stroke="#B9740A" stroke-width="1" stroke-linejoin="round" d="M88,48 L98,50 L88,54 Z"/>
|
||||
<!-- oko -->
|
||||
<circle fill="#20313F" cx="82" cy="47" r="2.2"/>
|
||||
<circle fill="#FFFFFF" cx="82.8" cy="46.3" r="0.7"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 898 B |
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<g stroke="#1B3F73" stroke-width="1.5" stroke-linejoin="round">
|
||||
<path fill="#4A8FE7" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
|
||||
<path fill="#4A8FE7" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
|
||||
<path fill="#4A8FE7" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
|
||||
<path fill="#4A8FE7" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
|
||||
</g>
|
||||
<g fill="#BBD7FF" stroke="none">
|
||||
<circle cx="20" cy="30" r="4"/>
|
||||
<circle cx="80" cy="30" r="4"/>
|
||||
<circle cx="30" cy="70" r="3"/>
|
||||
<circle cx="70" cy="70" r="3"/>
|
||||
</g>
|
||||
<g stroke="#17233B" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||
<path d="M49,28 C44,18 40,14 36,11"/>
|
||||
<path d="M51,28 C56,18 60,14 64,11"/>
|
||||
</g>
|
||||
<g fill="#17233B" stroke="none">
|
||||
<circle cx="36" cy="11" r="1.8"/>
|
||||
<circle cx="64" cy="11" r="1.8"/>
|
||||
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
|
||||
<circle cx="50" cy="24" r="4.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,66 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<defs>
|
||||
<radialGradient id="goldGlow" cx="0.5" cy="0.5" r="0.55">
|
||||
<stop offset="0%" stop-color="#FFF6C8" stop-opacity="0.9"/>
|
||||
<stop offset="55%" stop-color="#FFD24A" stop-opacity="0.35"/>
|
||||
<stop offset="100%" stop-color="#FFD24A" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="goldWing" x1="0" y1="0" x2="0.7" y2="1">
|
||||
<stop offset="0%" stop-color="#FFFBE6"/>
|
||||
<stop offset="35%" stop-color="#FFDE6A"/>
|
||||
<stop offset="70%" stop-color="#F5B617"/>
|
||||
<stop offset="100%" stop-color="#C97B00"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="goldSheen" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Zářivá aura -->
|
||||
<circle cx="50" cy="48" r="48" fill="url(#goldGlow)"/>
|
||||
|
||||
<!-- Křídla -->
|
||||
<g stroke="#8A5A00" stroke-width="1.6" stroke-linejoin="round">
|
||||
<path fill="url(#goldWing)" d="M48,32 C30,8 6,12 6,34 C6,52 30,54 48,50 Z"/>
|
||||
<path fill="url(#goldWing)" d="M52,32 C70,8 94,12 94,34 C94,52 70,54 52,50 Z"/>
|
||||
<path fill="url(#goldWing)" d="M47,50 C33,54 14,58 16,76 C18,89 41,86 47,66 Z"/>
|
||||
<path fill="url(#goldWing)" d="M53,50 C67,54 86,58 84,76 C82,89 59,86 53,66 Z"/>
|
||||
</g>
|
||||
|
||||
<!-- Drahokamové body na křídlech -->
|
||||
<g fill="#FFF8D8" stroke="#E8B23A" stroke-width="0.6">
|
||||
<circle cx="20" cy="30" r="4.5"/>
|
||||
<circle cx="80" cy="30" r="4.5"/>
|
||||
<circle cx="29" cy="72" r="3.5"/>
|
||||
<circle cx="71" cy="72" r="3.5"/>
|
||||
</g>
|
||||
<g fill="#B8860B">
|
||||
<circle cx="20" cy="30" r="1.6"/>
|
||||
<circle cx="80" cy="30" r="1.6"/>
|
||||
</g>
|
||||
|
||||
<!-- Lesklý přeliv přes horní křídla -->
|
||||
<path fill="url(#goldSheen)" opacity="0.55" d="M48,33 C34,15 16,17 12,31 C24,26 40,27 48,40 Z"/>
|
||||
|
||||
<!-- Tykadla -->
|
||||
<g stroke="#6A4A00" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||
<path d="M49,28 C44,17 40,13 36,10"/>
|
||||
<path d="M51,28 C56,17 60,13 64,10"/>
|
||||
</g>
|
||||
|
||||
<!-- Tělo -->
|
||||
<g fill="#5A3E00" stroke="none">
|
||||
<circle cx="36" cy="10" r="2"/>
|
||||
<circle cx="64" cy="10" r="2"/>
|
||||
<rect x="47" y="24" width="6" height="52" rx="3"/>
|
||||
<circle cx="50" cy="24" r="5"/>
|
||||
</g>
|
||||
|
||||
<!-- Jiskry -->
|
||||
<g fill="#FFFFFF">
|
||||
<path d="M50 2 l1.4 3.4 l3.4 1.4 l-3.4 1.4 l-1.4 3.4 l-1.4 -3.4 l-3.4 -1.4 l3.4 -1.4 z"/>
|
||||
<path d="M90 20 l1 2.6 l2.6 1 l-2.6 1 l-1 2.6 l-1 -2.6 l-2.6 -1 l2.6 -1 z" opacity="0.85"/>
|
||||
<path d="M12 66 l0.9 2.3 l2.3 0.9 l-2.3 0.9 l-0.9 2.3 l-0.9 -2.3 l-2.3 -0.9 l2.3 -0.9 z" opacity="0.8"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1,79 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
width="100"
|
||||
height="100">
|
||||
|
||||
<defs>
|
||||
<linearGradient id="netFillGold" x1="0" y1="0" x2="0.4" y2="1">
|
||||
<stop offset="0%" stop-color="#FFF3C4" stop-opacity="0.28"/>
|
||||
<stop offset="100%" stop-color="#F0C24A" stop-opacity="0.12"/>
|
||||
</linearGradient>
|
||||
|
||||
<clipPath id="netBagClipGold">
|
||||
<path d="
|
||||
M 36 8
|
||||
C 21 8, 9 20, 9 35
|
||||
C 9 51, 13 70, 19 84
|
||||
C 22 92, 27 94, 31 87
|
||||
C 40 72, 54 51, 63 35
|
||||
C 63 20, 51 8, 36 8
|
||||
Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<!-- Síťový vak za obručí -->
|
||||
<path d="
|
||||
M 36 8
|
||||
C 21 8, 9 20, 9 35
|
||||
C 9 51, 13 70, 19 84
|
||||
C 22 92, 27 94, 31 87
|
||||
C 40 72, 54 51, 63 35
|
||||
C 63 20, 51 8, 36 8
|
||||
Z"
|
||||
fill="url(#netFillGold)"
|
||||
stroke="#E8B23A"
|
||||
stroke-width="1"
|
||||
opacity="0.85"/>
|
||||
|
||||
<!-- Výplet síťky -->
|
||||
<g clip-path="url(#netBagClipGold)"
|
||||
fill="none"
|
||||
stroke="#E8B23A"
|
||||
stroke-width="0.8"
|
||||
opacity="0.7"
|
||||
stroke-linecap="round">
|
||||
<path d="M 16 17 C 15 42, 19 69, 25 90"/>
|
||||
<path d="M 25 10 C 23 39, 24 69, 25 90"/>
|
||||
<path d="M 36 8 C 34 39, 30 69, 25 90"/>
|
||||
<path d="M 47 10 C 44 40, 36 70, 25 90"/>
|
||||
<path d="M 56 18 C 51 44, 41 72, 25 90"/>
|
||||
<path d="M 62 30 C 55 51, 43 75, 25 90"/>
|
||||
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
|
||||
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
|
||||
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
|
||||
<path d="M 10 28 C 25 34, 47 34, 62 29"/>
|
||||
<path d="M 9 40 C 25 46, 45 47, 59 42"/>
|
||||
<path d="M 11 52 C 24 58, 41 59, 53 54"/>
|
||||
<path d="M 13 64 C 24 69, 36 70, 45 66"/>
|
||||
<path d="M 16 75 C 24 79, 31 80, 38 77"/>
|
||||
<path d="M 20 84 C 24 87, 28 87, 32 84"/>
|
||||
</g>
|
||||
|
||||
<!-- Rukojeť -->
|
||||
<line x1="57" y1="57" x2="93" y2="93" stroke="#8B5A2B" stroke-width="6" stroke-linecap="round"/>
|
||||
<line x1="57" y1="57" x2="93" y2="93" stroke="#A9713A" stroke-width="3" stroke-linecap="round"/>
|
||||
|
||||
<!-- Kovové napojení rukojeti -->
|
||||
<line x1="56" y1="55" x2="61" y2="61" stroke="#B8860B" stroke-width="6" stroke-linecap="round"/>
|
||||
<line x1="56" y1="55" x2="61" y2="61" stroke="#FFE79A" stroke-width="2.5" stroke-linecap="round"/>
|
||||
|
||||
<!-- Zlatá obruč -->
|
||||
<circle cx="36" cy="35" r="27" fill="none" stroke="#C8891A" stroke-width="4.5"/>
|
||||
<circle cx="36" cy="35" r="27" fill="none" stroke="#FFD24A" stroke-width="2"/>
|
||||
|
||||
<!-- Třpytky -->
|
||||
<g fill="#FFF7D6" stroke="none">
|
||||
<path d="M 33 6 l 1.2 3 l 3 1.2 l -3 1.2 l -1.2 3 l -1.2 -3 l -3 -1.2 l 3 -1.2 z"/>
|
||||
<path d="M 60 40 l 1 2.4 l 2.4 1 l -2.4 1 l -1 2.4 l -1 -2.4 l -2.4 -1 l 2.4 -1 z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1,74 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
width="100"
|
||||
height="100">
|
||||
|
||||
<defs>
|
||||
<linearGradient id="netFillTorn" x1="0" y1="0" x2="0.4" y2="1">
|
||||
<stop offset="0%" stop-color="#E4EDF5" stop-opacity="0.14"/>
|
||||
<stop offset="100%" stop-color="#B9C4CF" stop-opacity="0.06"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Vak s vyříznutou dírou uprostřed -->
|
||||
<clipPath id="netBagClipTorn">
|
||||
<path d="
|
||||
M 36 8 C 21 8, 9 20, 9 35 C 9 51, 13 70, 19 84
|
||||
C 22 92, 27 94, 31 87 C 40 72, 54 51, 63 35 C 63 20, 51 8, 36 8 Z
|
||||
M 27 40 C 20 44, 22 55, 30 58 C 38 61, 46 54, 43 46 C 41 40, 33 37, 27 40 Z"
|
||||
clip-rule="evenodd"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<!-- Síťový vak (s dírou) -->
|
||||
<path d="
|
||||
M 36 8 C 21 8, 9 20, 9 35 C 9 51, 13 70, 19 84
|
||||
C 22 92, 27 94, 31 87 C 40 72, 54 51, 63 35 C 63 20, 51 8, 36 8 Z
|
||||
M 27 40 C 20 44, 22 55, 30 58 C 38 61, 46 54, 43 46 C 41 40, 33 37, 27 40 Z"
|
||||
fill="url(#netFillTorn)"
|
||||
stroke="#AEBBC8"
|
||||
stroke-width="1"
|
||||
opacity="0.7"
|
||||
fill-rule="evenodd"/>
|
||||
|
||||
<!-- Výplet síťky (oříznutý dírou) -->
|
||||
<g clip-path="url(#netBagClipTorn)"
|
||||
fill="none"
|
||||
stroke="#AEBBC8"
|
||||
stroke-width="0.8"
|
||||
opacity="0.55"
|
||||
stroke-linecap="round">
|
||||
<path d="M 16 17 C 15 42, 19 69, 25 90"/>
|
||||
<path d="M 25 10 C 23 39, 24 69, 25 90"/>
|
||||
<path d="M 36 8 C 34 39, 30 69, 25 90"/>
|
||||
<path d="M 47 10 C 44 40, 36 70, 25 90"/>
|
||||
<path d="M 56 18 C 51 44, 41 72, 25 90"/>
|
||||
<path d="M 62 30 C 55 51, 43 75, 25 90"/>
|
||||
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
|
||||
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
|
||||
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
|
||||
<path d="M 13 64 C 24 69, 36 70, 45 66"/>
|
||||
<path d="M 16 75 C 24 79, 31 80, 38 77"/>
|
||||
<path d="M 20 84 C 24 87, 28 87, 32 84"/>
|
||||
</g>
|
||||
|
||||
<!-- Roztřepená vlákna kolem díry -->
|
||||
<g stroke="#9AA7B4" stroke-width="0.8" fill="none" stroke-linecap="round" opacity="0.8">
|
||||
<path d="M 27 40 l -3 -2"/>
|
||||
<path d="M 43 46 l 3 -1"/>
|
||||
<path d="M 30 58 l -1 3"/>
|
||||
<path d="M 38 52 l 3 2"/>
|
||||
<path d="M 24 49 l -3 1"/>
|
||||
</g>
|
||||
|
||||
<!-- Rukojeť -->
|
||||
<line x1="57" y1="57" x2="93" y2="93" stroke="#8B5A2B" stroke-width="6" stroke-linecap="round"/>
|
||||
<line x1="57" y1="57" x2="93" y2="93" stroke="#A9713A" stroke-width="3" stroke-linecap="round"/>
|
||||
|
||||
<!-- Kovové napojení rukojeti -->
|
||||
<line x1="56" y1="55" x2="61" y2="61" stroke="#71808E" stroke-width="6" stroke-linecap="round"/>
|
||||
<line x1="56" y1="55" x2="61" y2="61" stroke="#B8C4CF" stroke-width="2.5" stroke-linecap="round"/>
|
||||
|
||||
<!-- Obruč -->
|
||||
<circle cx="36" cy="35" r="27" fill="none" stroke="#7A8896" stroke-width="4.5"/>
|
||||
<circle cx="36" cy="35" r="27" fill="none" stroke="#AEBBC8" stroke-width="2"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.9 KiB |
@@ -1,106 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
width="100"
|
||||
height="100">
|
||||
|
||||
<defs>
|
||||
<linearGradient id="netFill" x1="0" y1="0" x2="0.4" y2="1">
|
||||
<stop offset="0%" stop-color="#E4EDF5" stop-opacity="0.18"/>
|
||||
<stop offset="100%" stop-color="#B9C4CF" stop-opacity="0.08"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Celý tvar síťového vaku -->
|
||||
<clipPath id="netBagClip">
|
||||
<path d="
|
||||
M 36 8
|
||||
C 21 8, 9 20, 9 35
|
||||
C 9 51, 13 70, 19 84
|
||||
C 22 92, 27 94, 31 87
|
||||
C 40 72, 54 51, 63 35
|
||||
C 63 20, 51 8, 36 8
|
||||
Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<!-- Síťový vak za obručí -->
|
||||
<path d="
|
||||
M 36 8
|
||||
C 21 8, 9 20, 9 35
|
||||
C 9 51, 13 70, 19 84
|
||||
C 22 92, 27 94, 31 87
|
||||
C 40 72, 54 51, 63 35
|
||||
C 63 20, 51 8, 36 8
|
||||
Z"
|
||||
fill="url(#netFill)"
|
||||
stroke="#AEBBC8"
|
||||
stroke-width="1"
|
||||
opacity="0.7"/>
|
||||
|
||||
<!-- Výplet síťky -->
|
||||
<g clip-path="url(#netBagClip)"
|
||||
fill="none"
|
||||
stroke="#AEBBC8"
|
||||
stroke-width="0.8"
|
||||
opacity="0.6"
|
||||
stroke-linecap="round">
|
||||
|
||||
<!-- Vlákna začínající na horní a boční obruči -->
|
||||
<path d="M 16 17 C 15 42, 19 69, 25 90"/>
|
||||
<path d="M 25 10 C 23 39, 24 69, 25 90"/>
|
||||
<path d="M 36 8 C 34 39, 30 69, 25 90"/>
|
||||
<path d="M 47 10 C 44 40, 36 70, 25 90"/>
|
||||
<path d="M 56 18 C 51 44, 41 72, 25 90"/>
|
||||
<path d="M 62 30 C 55 51, 43 75, 25 90"/>
|
||||
|
||||
<!-- Vlákna od levého okraje obruče -->
|
||||
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
|
||||
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
|
||||
|
||||
<!-- Příčná oka -->
|
||||
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
|
||||
<path d="M 10 28 C 25 34, 47 34, 62 29"/>
|
||||
<path d="M 9 40 C 25 46, 45 47, 59 42"/>
|
||||
<path d="M 11 52 C 24 58, 41 59, 53 54"/>
|
||||
<path d="M 13 64 C 24 69, 36 70, 45 66"/>
|
||||
<path d="M 16 75 C 24 79, 31 80, 38 77"/>
|
||||
<path d="M 20 84 C 24 87, 28 87, 32 84"/>
|
||||
</g>
|
||||
|
||||
<!-- Rukojeť -->
|
||||
<line x1="57" y1="57"
|
||||
x2="93" y2="93"
|
||||
stroke="#8B5A2B"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<line x1="57" y1="57"
|
||||
x2="93" y2="93"
|
||||
stroke="#A9713A"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<!-- Kovové napojení rukojeti -->
|
||||
<line x1="56" y1="55"
|
||||
x2="61" y2="61"
|
||||
stroke="#71808E"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<line x1="56" y1="55"
|
||||
x2="61" y2="61"
|
||||
stroke="#B8C4CF"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<!-- Hlavní obruč – nezměněná -->
|
||||
<circle cx="36" cy="35" r="27"
|
||||
fill="none"
|
||||
stroke="#7A8896"
|
||||
stroke-width="4.5"/>
|
||||
|
||||
<circle cx="36" cy="35" r="27"
|
||||
fill="none"
|
||||
stroke="#AEBBC8"
|
||||
stroke-width="2"/>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.9 KiB |
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<g stroke="#7A3B00" stroke-width="1.5" stroke-linejoin="round">
|
||||
<path fill="#F59E1E" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
|
||||
<path fill="#F59E1E" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
|
||||
<path fill="#F59E1E" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
|
||||
<path fill="#F59E1E" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
|
||||
</g>
|
||||
<g fill="#FFE0A3" stroke="none">
|
||||
<circle cx="20" cy="30" r="4"/>
|
||||
<circle cx="80" cy="30" r="4"/>
|
||||
<circle cx="30" cy="70" r="3"/>
|
||||
<circle cx="70" cy="70" r="3"/>
|
||||
</g>
|
||||
<g stroke="#2B1A0B" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||
<path d="M49,28 C44,18 40,14 36,11"/>
|
||||
<path d="M51,28 C56,18 60,14 64,11"/>
|
||||
</g>
|
||||
<g fill="#2B1A0B" stroke="none">
|
||||
<circle cx="36" cy="11" r="1.8"/>
|
||||
<circle cx="64" cy="11" r="1.8"/>
|
||||
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
|
||||
<circle cx="50" cy="24" r="4.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<g stroke="#8A2255" stroke-width="1.5" stroke-linejoin="round">
|
||||
<path fill="#F06B9B" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
|
||||
<path fill="#F06B9B" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
|
||||
<path fill="#F06B9B" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
|
||||
<path fill="#F06B9B" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
|
||||
</g>
|
||||
<g fill="#FFD1E3" stroke="none">
|
||||
<circle cx="20" cy="30" r="4"/>
|
||||
<circle cx="80" cy="30" r="4"/>
|
||||
<circle cx="30" cy="70" r="3"/>
|
||||
<circle cx="70" cy="70" r="3"/>
|
||||
</g>
|
||||
<g stroke="#3B0E24" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||
<path d="M49,28 C44,18 40,14 36,11"/>
|
||||
<path d="M51,28 C56,18 60,14 64,11"/>
|
||||
</g>
|
||||
<g fill="#3B0E24" stroke="none">
|
||||
<circle cx="36" cy="11" r="1.8"/>
|
||||
<circle cx="64" cy="11" r="1.8"/>
|
||||
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
|
||||
<circle cx="50" cy="24" r="4.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<g stroke="#8A6D00" stroke-width="1.5" stroke-linejoin="round">
|
||||
<path fill="#FFD23F" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
|
||||
<path fill="#FFD23F" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
|
||||
<path fill="#FFD23F" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
|
||||
<path fill="#FFD23F" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
|
||||
</g>
|
||||
<g fill="#FFF3C4" stroke="none">
|
||||
<circle cx="20" cy="30" r="4"/>
|
||||
<circle cx="80" cy="30" r="4"/>
|
||||
<circle cx="30" cy="70" r="3"/>
|
||||
<circle cx="70" cy="70" r="3"/>
|
||||
</g>
|
||||
<g stroke="#3A2E00" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||
<path d="M49,28 C44,18 40,14 36,11"/>
|
||||
<path d="M51,28 C56,18 60,14 64,11"/>
|
||||
</g>
|
||||
<g fill="#3A2E00" stroke="none">
|
||||
<circle cx="36" cy="11" r="1.8"/>
|
||||
<circle cx="64" cy="11" r="1.8"/>
|
||||
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
|
||||
<circle cx="50" cy="24" r="4.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,22 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<defs>
|
||||
<radialGradient id="coinFace" cx="0.4" cy="0.35" r="0.75">
|
||||
<stop offset="0%" stop-color="#FFE9A3"/>
|
||||
<stop offset="60%" stop-color="#FFCB3D"/>
|
||||
<stop offset="100%" stop-color="#E0A200"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="42" fill="#C88A12"/>
|
||||
<circle cx="50" cy="50" r="38" fill="url(#coinFace)" stroke="#F2D774" stroke-width="2"/>
|
||||
<circle cx="50" cy="50" r="30" fill="none" stroke="#B8860B" stroke-width="2" opacity="0.6"/>
|
||||
<!-- stylizovaný motýlek na minci -->
|
||||
<g fill="#B8860B" opacity="0.85">
|
||||
<path d="M50,42 C42,32 30,34 30,44 C30,52 42,52 50,50 Z"/>
|
||||
<path d="M50,42 C58,32 70,34 70,44 C70,52 58,52 50,50 Z"/>
|
||||
<path d="M49,50 C42,52 34,56 36,64 C38,70 48,66 49,58 Z"/>
|
||||
<path d="M51,50 C58,52 66,56 64,64 C62,70 52,66 51,58 Z"/>
|
||||
<rect x="48.5" y="40" width="3" height="26" rx="1.5"/>
|
||||
</g>
|
||||
<!-- lesk -->
|
||||
<path d="M 30 30 C 38 24, 50 24, 58 28" fill="none" stroke="#FFF7DC" stroke-width="3" stroke-linecap="round" opacity="0.7"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 48 KiB |
@@ -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>
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 32 KiB |
@@ -1,45 +0,0 @@
|
||||
// Service Worker pro Web Push notifikace (připomínka výběru oběda)
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
const data = event.data?.json() ?? { title: 'Luncher', body: 'Ještě nemáte zvolený oběd!' };
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: '/favicon.ico',
|
||||
tag: 'lunch-reminder',
|
||||
data: { login: data.login, token: data.token },
|
||||
actions: [
|
||||
{ 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;
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
return self.clients.openWindow('/');
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { PizzaOrder } from "./Types";
|
||||
import { getBaseUrl } from "./Utils";
|
||||
|
||||
async function request<TResponse>(
|
||||
url: string,
|
||||
config: RequestInit = {}
|
||||
): Promise<TResponse> {
|
||||
return fetch(getBaseUrl() + url, config).then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
return response.json() as TResponse;
|
||||
});
|
||||
}
|
||||
|
||||
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 getData = async () => {
|
||||
return await api.get<any>('/api/data');
|
||||
}
|
||||
|
||||
export const getFood = async () => {
|
||||
return await api.get<any>('/api/food');
|
||||
}
|
||||
|
||||
export const getPizzy = async () => {
|
||||
return await api.get<any>('/api/pizza');
|
||||
}
|
||||
|
||||
export const createPizzaDay = async (creator) => {
|
||||
return await api.post<any, any>('/api/createPizzaDay', JSON.stringify({ creator }));
|
||||
}
|
||||
|
||||
export const deletePizzaDay = async (login) => {
|
||||
return await api.post<any, any>('/api/deletePizzaDay', JSON.stringify({ login }));
|
||||
}
|
||||
|
||||
export const lockPizzaDay = async (login) => {
|
||||
return await api.post<any, any>('/api/lockPizzaDay', JSON.stringify({ login }));
|
||||
}
|
||||
|
||||
export const unlockPizzaDay = async (login) => {
|
||||
return await api.post<any, any>('/api/unlockPizzaDay', JSON.stringify({ login }));
|
||||
}
|
||||
|
||||
export const finishOrder = async (login) => {
|
||||
return await api.post<any, any>('/api/finishOrder', JSON.stringify({ login }));
|
||||
}
|
||||
|
||||
export const finishDelivery = async (login) => {
|
||||
return await api.post<any, any>('/api/finishDelivery', JSON.stringify({ login }));
|
||||
}
|
||||
|
||||
export const updateChoice = async (name: string, choice: number | null) => {
|
||||
return await api.post<any, any>('/api/updateChoice', JSON.stringify({ name, choice }));
|
||||
}
|
||||
|
||||
export const addPizza = async (login: string, pizzaIndex: number, pizzaSizeIndex: number) => {
|
||||
return await api.post<any, any>('/api/addPizza', JSON.stringify({ login, pizzaIndex, pizzaSizeIndex }));
|
||||
}
|
||||
|
||||
export const removePizza = async (login: string, pizzaOrder: PizzaOrder) => {
|
||||
return await api.post<any, any>('/api/removePizza', JSON.stringify({ login, pizzaOrder }));
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 50px 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.trash-icon {
|
||||
color: rgb(0, 89, 255);
|
||||
cursor: pointer;
|
||||
margin-left: 10px;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { ProvideSettings } 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";
|
||||
|
||||
export const STATS_URL = '/stats';
|
||||
export const OBJEDNANI_URL = '/objednani';
|
||||
export const NAVRHY_URL = '/navrhy';
|
||||
|
||||
export default function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path={STATS_URL} element={<StatsPage />} />
|
||||
<Route path={NAVRHY_URL} element={
|
||||
<ProvideSettings>
|
||||
<SuggestionsPage />
|
||||
</ProvideSettings>
|
||||
} />
|
||||
<Route path={OBJEDNANI_URL} element={
|
||||
<ProvideSettings>
|
||||
<SocketContext.Provider value={socket}>
|
||||
<OrderGroupsPage />
|
||||
<ToastContainer />
|
||||
</SocketContext.Provider>
|
||||
</ProvideSettings>
|
||||
} />
|
||||
<Route path="/" element={
|
||||
<ProvideSettings>
|
||||
<SocketContext.Provider value={socket}>
|
||||
<>
|
||||
<FlyingButterflies numButterflies={BUTTERFLY_PRESETS.NORMAL} />
|
||||
<App />
|
||||
</>
|
||||
<ToastContainer />
|
||||
</SocketContext.Provider>
|
||||
</ProvideSettings>
|
||||
} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,547 +0,0 @@
|
||||
.flying-butterflies {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
.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-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));
|
||||
}
|
||||
}
|
||||
@@ -1,916 +0,0 @@
|
||||
import React, { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useButterflyStats, RewardEvent } from './hooks/useButterflyStats';
|
||||
import ButterflyLeaderboardModal from './components/modals/ButterflyLeaderboardModal';
|
||||
|
||||
// Barevné varianty motýlů (soubory v public/, referencované root-absolutně)
|
||||
const BUTTERFLY_VARIANTS = [
|
||||
'/butterfly-orange.svg',
|
||||
'/butterfly-blue.svg',
|
||||
'/butterfly-yellow.svg',
|
||||
'/butterfly-pink.svg',
|
||||
] as const;
|
||||
|
||||
const GOLDEN_VARIANT = '/butterfly-golden.svg';
|
||||
const GOLDEN_CHANCE = 0.015; // ~1,5 % motýlů je zlatých (vzácnější)
|
||||
|
||||
const NET_NORMAL = '/butterfly-net.svg';
|
||||
const NET_GOLDEN = '/butterfly-net-golden.svg';
|
||||
const NET_TORN = '/butterfly-net-torn.svg';
|
||||
|
||||
// Herní konstanty (zrcadlí serverové hodnoty v server/src/butterflies.ts)
|
||||
const PREMIUM_DURATION_MS = 60_000;
|
||||
export const REPAIR_COST = 20;
|
||||
export const REPELLENT_COST = 60;
|
||||
const GOLD_VALUE = 25;
|
||||
|
||||
// „Bossové" (zloděj / housenka)
|
||||
const THIEF_HP = 22;
|
||||
const CATERPILLAR_HP = 18;
|
||||
const THIEF_MIN_COINS = 20; // zloděj přijde, jen když je co ukrást
|
||||
const CATERPILLAR_MIN_CAUGHT = 15; // housenka přijde, jen když je co sežrat
|
||||
|
||||
/** Naformátuje zbývající čas jako „m:ss" nebo „s s". */
|
||||
function formatRemaining(ms: number): string {
|
||||
const s = Math.ceil(ms / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const ss = s % 60;
|
||||
return m > 0 ? `${m}:${String(ss).padStart(2, '0')}` : `${ss} s`;
|
||||
}
|
||||
|
||||
interface ButterflyData {
|
||||
el: HTMLDivElement;
|
||||
sprite: HTMLDivElement;
|
||||
x: number; y: number;
|
||||
dir: number; speed: number; size: number;
|
||||
golden: boolean;
|
||||
bobFreq1: number; bobPhase1: number; bobAmp1: number;
|
||||
bobFreq2: number; bobPhase2: number; bobAmp2: number;
|
||||
}
|
||||
|
||||
/** Létající havěť (pták nebo vosa) – nalétává na síťku. */
|
||||
interface CritterData {
|
||||
el: HTMLDivElement;
|
||||
x: number; y: number;
|
||||
dir: number; speed: number; size: number;
|
||||
bobPhase: number; bobAmp: number;
|
||||
/** Pták: už protrhl síťku v tomto průletu */
|
||||
triggered: boolean;
|
||||
/** Vosa: fáze útoku – nálet do síťky, nebo stažení před dalším náletem */
|
||||
mode?: 'dive' | 'retreat';
|
||||
/** Vosa: cíl stažení */
|
||||
tx?: number; ty?: number;
|
||||
cleanup?: () => void;
|
||||
}
|
||||
|
||||
/** Boss plížící se k počítadlu (zloděj krade mince, housenka žere úlovky). */
|
||||
interface StalkerData {
|
||||
el: HTMLDivElement;
|
||||
hpInner: HTMLDivElement;
|
||||
x: number; y: number;
|
||||
vx: number; vy: number;
|
||||
tx: number; ty: number;
|
||||
hp: number; maxHp: number;
|
||||
type: 'thief' | 'caterpillar';
|
||||
done: boolean;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
interface SceneCallbacks {
|
||||
onCatch: (golden: boolean) => void;
|
||||
getCoins: () => number;
|
||||
getCaught: () => number;
|
||||
onTornChange: (torn: boolean) => void;
|
||||
onCombo: (count: number) => void;
|
||||
onSting: () => void;
|
||||
onSwatWasp: () => void;
|
||||
onRobbery: () => void;
|
||||
onDefeatThief: () => void;
|
||||
onCaterpillarAte: () => void;
|
||||
onDefeatCaterpillar: () => void;
|
||||
onStalkerAppear: (type: 'thief' | 'caterpillar') => void;
|
||||
}
|
||||
|
||||
interface FlyingButterfliesProps {
|
||||
numButterflies?: number;
|
||||
className?: string;
|
||||
butterflyVariants?: readonly string[];
|
||||
enableNet?: boolean;
|
||||
}
|
||||
|
||||
class ButterflyScene {
|
||||
private viewport: HTMLElement;
|
||||
private world: HTMLDivElement;
|
||||
private butterflies: ButterflyData[] = [];
|
||||
private birds: CritterData[] = [];
|
||||
private wasps: CritterData[] = [];
|
||||
private stalker: StalkerData | null = null;
|
||||
private numButterflies: number;
|
||||
private variants: readonly string[];
|
||||
private width: number;
|
||||
private height: number;
|
||||
private timer: number = 0;
|
||||
private animationId: number | null = null;
|
||||
private handleResize: () => void;
|
||||
|
||||
private net: HTMLDivElement;
|
||||
private netEnabled: boolean;
|
||||
private netGrabbed: boolean = false;
|
||||
private netX: number = 0;
|
||||
private netY: number = 0;
|
||||
private cb: SceneCallbacks;
|
||||
|
||||
private torn: boolean = false;
|
||||
private premiumUntil: number = 0;
|
||||
private stunUntil: number = 0;
|
||||
private lastMagnetAt: number = 0;
|
||||
private nextCatchAt: number = 0;
|
||||
private nextStalkerAt: number = 1800; // první možný boss ~30 s
|
||||
|
||||
private comboCount: number = 0;
|
||||
private lastCatchTs: number = 0;
|
||||
private comboTimer: number = 0;
|
||||
|
||||
private static readonly BASE_SIZE = 34;
|
||||
private static readonly NET_SIZE_NORMAL = 120;
|
||||
private static readonly NET_SIZE_PREMIUM = 168;
|
||||
private static readonly CATCH_RADIUS_NORMAL = 40;
|
||||
private static readonly CATCH_RADIUS_PREMIUM = 62;
|
||||
private static readonly HOOP_FRAC_X = 0.36;
|
||||
private static readonly HOOP_FRAC_Y = 0.35;
|
||||
|
||||
private static readonly BIRD_SIZE = 64;
|
||||
private static readonly WASP_SIZE = 44;
|
||||
private static readonly TEAR_RADIUS = 46;
|
||||
private static readonly STING_RADIUS = 46;
|
||||
private static readonly STUN_FRAMES = 300; // ~5 s omráčení
|
||||
private static readonly MAGNET_RADIUS = 150;
|
||||
private static readonly MAGNET_INTERVAL = 60; // ~1 s
|
||||
private static readonly CATCH_COOLDOWN = 8; // max ~7 chycení/s (proti zmenšení okna)
|
||||
private static readonly COMBO_WINDOW_MS = 1200;
|
||||
|
||||
private static readonly STALKER_TRAVEL_FRAMES = 1700; // ~28 s na doplížení
|
||||
private static readonly STALKER_MIN_GAP = 3600; // 60 s
|
||||
private static readonly STALKER_MAX_GAP = 9000; // 150 s
|
||||
|
||||
constructor(el: HTMLElement, numButterflies: number, variants: readonly string[], netEnabled: boolean, cb: SceneCallbacks) {
|
||||
this.viewport = el;
|
||||
this.world = document.createElement('div');
|
||||
this.numButterflies = numButterflies;
|
||||
this.variants = variants;
|
||||
this.netEnabled = netEnabled;
|
||||
this.cb = cb;
|
||||
this.width = this.viewport.offsetWidth;
|
||||
this.height = this.viewport.offsetHeight;
|
||||
this.net = document.createElement('div');
|
||||
this.handleResize = () => {
|
||||
this.width = this.viewport.offsetWidth;
|
||||
this.height = this.viewport.offsetHeight;
|
||||
};
|
||||
}
|
||||
|
||||
private get premiumActive(): boolean { return Date.now() < this.premiumUntil; }
|
||||
private get netSize(): number { return this.premiumActive ? ButterflyScene.NET_SIZE_PREMIUM : ButterflyScene.NET_SIZE_NORMAL; }
|
||||
private get catchRadius(): number { return this.premiumActive ? ButterflyScene.CATCH_RADIUS_PREMIUM : ButterflyScene.CATCH_RADIUS_NORMAL; }
|
||||
private get hoopOffsetX(): number { return this.netSize * ButterflyScene.HOOP_FRAC_X; }
|
||||
private get hoopOffsetY(): number { return this.netSize * ButterflyScene.HOOP_FRAC_Y; }
|
||||
|
||||
// --- Motýli --------------------------------------------------------------
|
||||
|
||||
private resetButterfly = (b: ButterflyData): void => {
|
||||
b.dir = Math.random() > 0.5 ? 1 : -1;
|
||||
b.speed = Math.random() * 1.1 + 0.7;
|
||||
b.x = this.timer === 0 ? Math.random() * this.width : (b.dir === 1 ? -40 : this.width + 40);
|
||||
b.y = Math.random() * (this.height - 60) + 30;
|
||||
b.size = Math.random() * 0.6 + 0.6;
|
||||
b.bobFreq1 = Math.random() * 0.02 + 0.03;
|
||||
b.bobPhase1 = Math.random() * Math.PI * 2;
|
||||
b.bobAmp1 = Math.random() * 0.8 + 0.6;
|
||||
b.bobFreq2 = Math.random() * 0.015 + 0.01;
|
||||
b.bobPhase2 = Math.random() * Math.PI * 2;
|
||||
b.bobAmp2 = Math.random() * 0.6 + 0.4;
|
||||
|
||||
b.golden = Math.random() < GOLDEN_CHANCE;
|
||||
if (b.golden) {
|
||||
b.sprite.style.backgroundImage = `url(${GOLDEN_VARIANT})`;
|
||||
b.sprite.classList.add('golden');
|
||||
b.size = Math.random() * 0.5 + 1.0;
|
||||
} else {
|
||||
b.sprite.style.backgroundImage = `url(${this.variants[Math.floor(Math.random() * this.variants.length)]})`;
|
||||
b.sprite.classList.remove('golden');
|
||||
}
|
||||
b.sprite.style.animationDuration = `${Math.random() * 0.25 + 0.3}s`;
|
||||
};
|
||||
|
||||
private updateButterfly = (b: ButterflyData): void => {
|
||||
const vx = b.dir * b.speed;
|
||||
const vy =
|
||||
Math.sin(this.timer * b.bobFreq1 + b.bobPhase1) * b.bobAmp1 +
|
||||
Math.sin(this.timer * b.bobFreq2 + b.bobPhase2) * b.bobAmp2;
|
||||
b.x += vx; b.y += vy;
|
||||
const angle = Math.atan2(vy, vx) * (180 / Math.PI) + 90;
|
||||
b.el.style.transform = `translate(${b.x}px, ${b.y}px) rotate(${angle}deg) scale(${b.size})`;
|
||||
if ((b.dir === 1 && b.x > this.width + 50) || (b.dir === -1 && b.x < -50) || b.y < -60 || b.y > this.height + 60) {
|
||||
this.resetButterfly(b);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Síťka ---------------------------------------------------------------
|
||||
|
||||
private updateNetTransform = (): void => {
|
||||
this.net.style.transform = `translate(${this.netX - this.hoopOffsetX}px, ${this.netY - this.hoopOffsetY}px)`;
|
||||
};
|
||||
|
||||
private applyNetAppearance = (): void => {
|
||||
const size = this.netSize;
|
||||
this.net.style.width = `${size}px`;
|
||||
this.net.style.height = `${size}px`;
|
||||
let image = NET_NORMAL;
|
||||
if (this.torn) image = NET_TORN;
|
||||
else if (this.premiumActive) image = NET_GOLDEN;
|
||||
this.net.style.backgroundImage = `url(${image})`;
|
||||
this.net.classList.toggle('premium', this.premiumActive && !this.torn);
|
||||
this.net.classList.toggle('torn', this.torn);
|
||||
this.updateNetTransform();
|
||||
};
|
||||
|
||||
private onPointerDown = (e: PointerEvent): void => {
|
||||
e.preventDefault();
|
||||
this.netGrabbed = true;
|
||||
this.net.classList.add('grabbed');
|
||||
this.netX = e.clientX; this.netY = e.clientY;
|
||||
this.updateNetTransform();
|
||||
};
|
||||
private onPointerMove = (e: PointerEvent): void => {
|
||||
if (!this.netGrabbed) return;
|
||||
this.netX = e.clientX; this.netY = e.clientY;
|
||||
this.updateNetTransform();
|
||||
};
|
||||
private onPointerUp = (): void => {
|
||||
if (!this.netGrabbed) return;
|
||||
this.netGrabbed = false;
|
||||
this.net.classList.remove('grabbed');
|
||||
};
|
||||
|
||||
private spawnFx = (text: string, cssClass: string, x: number, y: number): void => {
|
||||
const fx = document.createElement('div');
|
||||
fx.className = cssClass;
|
||||
fx.textContent = text;
|
||||
fx.style.left = `${x}px`;
|
||||
fx.style.top = `${y}px`;
|
||||
this.viewport.appendChild(fx);
|
||||
window.setTimeout(() => { if (fx.parentNode) fx.parentNode.removeChild(fx); }, 800);
|
||||
};
|
||||
|
||||
private popNet = (): void => {
|
||||
this.net.classList.remove('catch-pop');
|
||||
void this.net.offsetWidth;
|
||||
this.net.classList.add('catch-pop');
|
||||
};
|
||||
|
||||
private registerCatch = (b: ButterflyData, atX: number, atY: number): void => {
|
||||
this.cb.onCatch(b.golden);
|
||||
this.spawnFx(b.golden ? `+${GOLD_VALUE}` : '+1', b.golden ? 'butterfly-catch-fx golden' : 'butterfly-catch-fx', atX, atY);
|
||||
this.resetButterfly(b);
|
||||
this.nextCatchAt = this.timer + ButterflyScene.CATCH_COOLDOWN;
|
||||
|
||||
const now = Date.now();
|
||||
this.comboCount = (now - this.lastCatchTs < ButterflyScene.COMBO_WINDOW_MS) ? this.comboCount + 1 : 1;
|
||||
this.lastCatchTs = now;
|
||||
if (this.comboCount >= 2) {
|
||||
this.cb.onCombo(this.comboCount);
|
||||
if (this.comboTimer) window.clearTimeout(this.comboTimer);
|
||||
this.comboTimer = window.setTimeout(() => this.cb.onCombo(0), ButterflyScene.COMBO_WINDOW_MS);
|
||||
}
|
||||
};
|
||||
|
||||
// Chytá max 1 motýla za CATCH_COOLDOWN snímků (fér nezávisle na velikosti okna)
|
||||
private checkCatches = (): void => {
|
||||
if (this.torn || this.timer < this.stunUntil || this.timer < this.nextCatchAt) return;
|
||||
const r2 = this.catchRadius * this.catchRadius;
|
||||
const half = ButterflyScene.BASE_SIZE / 2;
|
||||
for (const b of this.butterflies) {
|
||||
const dx = (b.x + half * b.size) - this.netX;
|
||||
const dy = (b.y + half * b.size) - this.netY;
|
||||
if (dx * dx + dy * dy < r2) {
|
||||
this.registerCatch(b, this.netX, this.netY);
|
||||
this.popNet();
|
||||
return; // jen jeden za cooldown
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private runMagnet = (): void => {
|
||||
if (this.torn || this.timer < this.stunUntil || this.timer < this.nextCatchAt) return;
|
||||
if (this.timer - this.lastMagnetAt < ButterflyScene.MAGNET_INTERVAL) return;
|
||||
this.lastMagnetAt = this.timer;
|
||||
const half = ButterflyScene.BASE_SIZE / 2;
|
||||
let nearest: ButterflyData | null = null;
|
||||
let nearestD2 = ButterflyScene.MAGNET_RADIUS * ButterflyScene.MAGNET_RADIUS;
|
||||
for (const b of this.butterflies) {
|
||||
const dx = (b.x + half * b.size) - this.netX;
|
||||
const dy = (b.y + half * b.size) - this.netY;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 < nearestD2) { nearestD2 = d2; nearest = b; }
|
||||
}
|
||||
if (nearest) { this.registerCatch(nearest, this.netX, this.netY); this.popNet(); }
|
||||
};
|
||||
|
||||
public activatePremiumNet = (): void => {
|
||||
this.premiumUntil = Date.now() + PREMIUM_DURATION_MS;
|
||||
this.applyNetAppearance();
|
||||
};
|
||||
public repairNetExternally = (): void => {
|
||||
if (!this.torn) return;
|
||||
this.torn = false;
|
||||
this.applyNetAppearance();
|
||||
};
|
||||
public setTornFromServer = (torn: boolean): void => {
|
||||
if (this.torn === torn) return;
|
||||
this.torn = torn;
|
||||
this.applyNetAppearance();
|
||||
};
|
||||
|
||||
private tearNet = (atX: number, atY: number): void => {
|
||||
if (this.torn) return;
|
||||
this.torn = true;
|
||||
this.applyNetAppearance();
|
||||
this.spawnFx('Ratata!', 'butterfly-tear-fx', atX, atY);
|
||||
this.cb.onTornChange(true);
|
||||
};
|
||||
|
||||
// --- Havěť (ptáci a vosy) -----------------------------------------------
|
||||
|
||||
private critterHitsHoop = (c: CritterData, radius: number): boolean => {
|
||||
const dx = (c.x + c.size / 2) - this.netX;
|
||||
const dy = (c.y + c.size / 2) - this.netY;
|
||||
return dx * dx + dy * dy < radius * radius;
|
||||
};
|
||||
|
||||
private spawnCritter = (type: 'wasp' | 'bird'): CritterData => {
|
||||
const size = type === 'bird' ? ButterflyScene.BIRD_SIZE : ButterflyScene.WASP_SIZE;
|
||||
const el = document.createElement('div');
|
||||
el.className = type === 'bird' ? 'butterfly-critter bird' : 'butterfly-critter wasp';
|
||||
el.style.width = `${size}px`;
|
||||
el.style.height = `${size}px`;
|
||||
el.style.backgroundImage = `url(${type === 'bird' ? '/bird.svg' : '/bee.svg'})`;
|
||||
|
||||
const dir = Math.random() > 0.5 ? 1 : -1;
|
||||
const c: CritterData = {
|
||||
el,
|
||||
x: dir === 1 ? -size : this.width + size,
|
||||
y: Math.random() * (this.height * 0.6) + this.height * 0.1,
|
||||
dir,
|
||||
speed: type === 'bird' ? Math.random() * 2 + 3 : Math.random() * 1.4 + 2,
|
||||
size,
|
||||
bobPhase: Math.random() * Math.PI * 2,
|
||||
bobAmp: Math.random() * 0.8 + 0.5,
|
||||
triggered: false,
|
||||
mode: type === 'wasp' ? 'dive' : undefined,
|
||||
};
|
||||
|
||||
if (type === 'wasp') {
|
||||
el.title = 'Plácni vosu!';
|
||||
const swat = (ev: Event) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.spawnFx('💥', 'butterfly-swat-fx', c.x + c.size / 2, c.y + c.size / 2);
|
||||
const idx = this.wasps.indexOf(c);
|
||||
if (idx >= 0) this.wasps.splice(idx, 1);
|
||||
if (c.el.parentNode) c.el.parentNode.removeChild(c.el);
|
||||
this.cb.onSwatWasp();
|
||||
};
|
||||
el.addEventListener('pointerdown', swat);
|
||||
c.cleanup = () => el.removeEventListener('pointerdown', swat);
|
||||
}
|
||||
|
||||
this.world.appendChild(el);
|
||||
return c;
|
||||
};
|
||||
|
||||
private wrapCritter = (c: CritterData): void => {
|
||||
c.dir = Math.random() > 0.5 ? 1 : -1;
|
||||
c.x = c.dir === 1 ? -c.size : this.width + c.size;
|
||||
c.y = Math.random() * (this.height * 0.6) + this.height * 0.1;
|
||||
c.triggered = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pohyb havěti. `homing` 0 = letí rovně jedním směrem (ptáci), >0 = agresivně
|
||||
* nalétává k síťce (vosy). Sprite se natáčí podle směru letu.
|
||||
*/
|
||||
private moveCritter = (c: CritterData, homingX: number, homingY: number): void => {
|
||||
const cx = c.x + c.size / 2;
|
||||
const cy = c.y + c.size / 2;
|
||||
const bob = Math.sin(this.timer * 0.05 + c.bobPhase) * c.bobAmp * 0.5;
|
||||
const vx = c.dir * c.speed + (homingX ? (this.netX - cx) * homingX : 0);
|
||||
const vy = (homingY ? (this.netY - cy) * homingY : 0) + bob;
|
||||
c.x += vx;
|
||||
c.y += vy;
|
||||
|
||||
// Natočení podle směru letu (sprite je nakreslený doprava); při letu doleva
|
||||
// se svisle překlopí, aby nebyl vzhůru nohama.
|
||||
const angle = Math.atan2(vy, vx) * (180 / Math.PI);
|
||||
const flipY = Math.abs(angle) > 90 ? -1 : 1;
|
||||
c.el.style.transform = `translate(${c.x}px, ${c.y}px) rotate(${angle}deg) scaleY(${flipY})`;
|
||||
|
||||
if (c.x > this.width + c.size * 2 || c.x < -c.size * 2 || c.y < -c.size * 2 || c.y > this.height + c.size * 2) {
|
||||
this.wrapCritter(c);
|
||||
}
|
||||
};
|
||||
|
||||
private removeLastCritter = (arr: CritterData[]): void => {
|
||||
const c = arr.pop();
|
||||
if (c) { c.cleanup?.(); if (c.el.parentNode) c.el.parentNode.removeChild(c.el); }
|
||||
};
|
||||
|
||||
public syncPests = (wasps: number, birds: number): void => {
|
||||
if (!this.netEnabled) return;
|
||||
while (this.wasps.length < wasps) this.wasps.push(this.spawnCritter('wasp'));
|
||||
while (this.wasps.length > wasps) this.removeLastCritter(this.wasps);
|
||||
while (this.birds.length < birds) this.birds.push(this.spawnCritter('bird'));
|
||||
while (this.birds.length > birds) this.removeLastCritter(this.birds);
|
||||
};
|
||||
|
||||
/** Posune tvora plnou rychlostí k cíli a natočí ho po směru letu. */
|
||||
private steerTo = (c: CritterData, targetX: number, targetY: number): void => {
|
||||
const cx = c.x + c.size / 2;
|
||||
const cy = c.y + c.size / 2;
|
||||
const dx = targetX - cx;
|
||||
const dy = targetY - cy;
|
||||
const dist = Math.hypot(dx, dy) || 1;
|
||||
const vx = (dx / dist) * c.speed;
|
||||
const vy = (dy / dist) * c.speed;
|
||||
c.x += vx;
|
||||
c.y += vy;
|
||||
const angle = Math.atan2(vy, vx) * (180 / Math.PI);
|
||||
const flipY = Math.abs(angle) > 90 ? -1 : 1;
|
||||
c.el.style.transform = `translate(${c.x}px, ${c.y}px) rotate(${angle}deg) scaleY(${flipY})`;
|
||||
};
|
||||
|
||||
private updateWasps = (): void => {
|
||||
for (const wasp of this.wasps) {
|
||||
if (wasp.mode === 'retreat') {
|
||||
// Stáhnutí kousek od síťky před dalším náletem
|
||||
this.steerTo(wasp, wasp.tx ?? this.netX, wasp.ty ?? this.netY);
|
||||
const dx = (wasp.x + wasp.size / 2) - (wasp.tx ?? this.netX);
|
||||
const dy = (wasp.y + wasp.size / 2) - (wasp.ty ?? this.netY);
|
||||
if (dx * dx + dy * dy < 26 * 26) wasp.mode = 'dive';
|
||||
} else {
|
||||
// Nálet přímo do síťky
|
||||
this.steerTo(wasp, this.netX, this.netY);
|
||||
if (this.critterHitsHoop(wasp, ButterflyScene.STING_RADIUS)) {
|
||||
// Vytažení a příprava na další nálet
|
||||
wasp.mode = 'retreat';
|
||||
wasp.tx = Math.max(30, Math.min(this.width - 30, this.netX + (Math.random() * 2 - 1) * 220));
|
||||
wasp.ty = Math.max(30, this.netY - (70 + Math.random() * 150));
|
||||
// Žihnutí (jen když už pominulo předchozí omráčení)
|
||||
if (!this.torn && this.timer >= this.stunUntil) {
|
||||
this.stunUntil = this.timer + ButterflyScene.STUN_FRAMES;
|
||||
this.net.classList.remove('stung');
|
||||
void this.net.offsetWidth;
|
||||
this.net.classList.add('stung');
|
||||
this.spawnFx('Au!', 'butterfly-sting-fx', this.netX, this.netY);
|
||||
this.cb.onSting();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private updateBirds = (): void => {
|
||||
for (const bird of this.birds) {
|
||||
// Svisle míří na výšku síťky jen ptáci, kteří k ní letí (mají ji před sebou);
|
||||
// kdo síťku minul, dolétne rovně.
|
||||
const cx = bird.x + bird.size / 2;
|
||||
const towardNet = (bird.dir === 1 && this.netX > cx) || (bird.dir === -1 && this.netX < cx);
|
||||
this.moveCritter(bird, 0, towardNet ? 0.02 : 0);
|
||||
// Pták protrhne síťku vždy (i bez mincí); síťka se pak sama zašije po čase
|
||||
if (!bird.triggered && this.netGrabbed && !this.torn && !this.premiumActive
|
||||
&& this.critterHitsHoop(bird, ButterflyScene.TEAR_RADIUS)) {
|
||||
bird.triggered = true;
|
||||
this.tearNet(this.netX, this.netY);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- Bossové (zloděj / housenka) ----------------------------------------
|
||||
|
||||
private trySpawnStalker = (): void => {
|
||||
if (this.timer < this.nextStalkerAt || this.stalker) return;
|
||||
const canThief = this.cb.getCoins() >= THIEF_MIN_COINS;
|
||||
const canCat = this.cb.getCaught() >= CATERPILLAR_MIN_CAUGHT;
|
||||
const options: ('thief' | 'caterpillar')[] = [];
|
||||
if (canThief) options.push('thief');
|
||||
if (canCat) options.push('caterpillar');
|
||||
if (options.length === 0) {
|
||||
this.nextStalkerAt = this.timer + 600; // zkus to znovu za ~10 s
|
||||
return;
|
||||
}
|
||||
const type = options[Math.floor(Math.random() * options.length)];
|
||||
this.spawnStalker(type);
|
||||
this.nextStalkerAt = this.timer + ButterflyScene.STALKER_MIN_GAP
|
||||
+ Math.floor(Math.random() * (ButterflyScene.STALKER_MAX_GAP - ButterflyScene.STALKER_MIN_GAP));
|
||||
};
|
||||
|
||||
private spawnStalker = (type: 'thief' | 'caterpillar'): void => {
|
||||
const maxHp = type === 'thief' ? THIEF_HP : CATERPILLAR_HP;
|
||||
const el = document.createElement('div');
|
||||
el.className = `butterfly-stalker ${type}`;
|
||||
el.title = type === 'thief' ? 'Zaklikej zloděje!' : 'Zaklikej housenku!';
|
||||
|
||||
const hp = document.createElement('div');
|
||||
hp.className = 'stalker-hp';
|
||||
const hpInner = document.createElement('div');
|
||||
hpInner.className = 'stalker-hp-inner';
|
||||
hp.appendChild(hpInner);
|
||||
|
||||
const emoji = document.createElement('div');
|
||||
emoji.className = 'stalker-emoji';
|
||||
emoji.textContent = type === 'thief' ? '🦹' : '🐛';
|
||||
|
||||
el.appendChild(hp);
|
||||
el.appendChild(emoji);
|
||||
|
||||
// Start v horním rohu, cíl u počítadla (vlevo dole)
|
||||
const fromLeft = Math.random() < 0.5;
|
||||
const startX = fromLeft ? 10 : this.width - 70;
|
||||
const startY = 10;
|
||||
const tx = type === 'thief' ? 95 : 45;
|
||||
const ty = this.height - 55;
|
||||
|
||||
const s: StalkerData = {
|
||||
el, hpInner,
|
||||
x: startX, y: startY,
|
||||
vx: (tx - startX) / ButterflyScene.STALKER_TRAVEL_FRAMES,
|
||||
vy: (ty - startY) / ButterflyScene.STALKER_TRAVEL_FRAMES,
|
||||
tx, ty, hp: maxHp, maxHp, type, done: false,
|
||||
cleanup: () => { /* nahrazeno níže */ },
|
||||
};
|
||||
|
||||
const hit = (ev: Event) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (s.done) return;
|
||||
s.hp -= 1;
|
||||
s.hpInner.style.width = `${Math.max(0, (s.hp / s.maxHp) * 100)}%`;
|
||||
el.classList.remove('hit');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('hit');
|
||||
if (s.hp <= 0) this.defeatStalker(s);
|
||||
};
|
||||
el.addEventListener('pointerdown', hit);
|
||||
s.cleanup = () => el.removeEventListener('pointerdown', hit);
|
||||
|
||||
this.viewport.appendChild(el);
|
||||
this.stalker = s;
|
||||
this.cb.onStalkerAppear(type);
|
||||
};
|
||||
|
||||
private removeStalker = (): void => {
|
||||
const s = this.stalker;
|
||||
if (!s) return;
|
||||
s.cleanup();
|
||||
if (s.el.parentNode) s.el.parentNode.removeChild(s.el);
|
||||
this.stalker = null;
|
||||
};
|
||||
|
||||
private defeatStalker = (s: StalkerData): void => {
|
||||
if (s.done) return;
|
||||
s.done = true;
|
||||
this.spawnFx(s.type === 'thief' ? '💰' : '🍃', 'butterfly-swat-fx', s.x + 24, s.y + 24);
|
||||
if (s.type === 'thief') this.cb.onDefeatThief(); else this.cb.onDefeatCaterpillar();
|
||||
this.removeStalker();
|
||||
};
|
||||
|
||||
private updateStalker = (): void => {
|
||||
const s = this.stalker;
|
||||
if (!s || s.done) return;
|
||||
s.x += s.vx; s.y += s.vy;
|
||||
s.el.style.transform = `translate(${s.x}px, ${s.y}px)`;
|
||||
const dx = s.x - s.tx;
|
||||
const dy = s.y - s.ty;
|
||||
if (dx * dx + dy * dy < 400) {
|
||||
s.done = true;
|
||||
if (s.type === 'thief') {
|
||||
this.spawnFx('💸 Okradeno!', 'butterfly-tear-fx', s.tx, s.ty - 20);
|
||||
this.cb.onRobbery();
|
||||
} else {
|
||||
this.spawnFx('🐛 Sežráno!', 'butterfly-tear-fx', s.tx, s.ty - 20);
|
||||
this.cb.onCaterpillarAte();
|
||||
}
|
||||
this.removeStalker();
|
||||
}
|
||||
};
|
||||
|
||||
// --- Init / render ------------------------------------------------------
|
||||
|
||||
private initNet = (): void => {
|
||||
this.net.className = 'butterfly-net';
|
||||
this.applyNetAppearance();
|
||||
this.netX = this.width * 0.82;
|
||||
this.netY = this.height * 0.72;
|
||||
this.updateNetTransform();
|
||||
this.net.addEventListener('pointerdown', this.onPointerDown);
|
||||
window.addEventListener('pointermove', this.onPointerMove);
|
||||
window.addEventListener('pointerup', this.onPointerUp);
|
||||
window.addEventListener('pointercancel', this.onPointerUp);
|
||||
this.viewport.appendChild(this.net);
|
||||
};
|
||||
|
||||
public init = (): void => {
|
||||
this.butterflies = [];
|
||||
this.world.innerHTML = '';
|
||||
this.world.className = 'butterfly-scene';
|
||||
for (let i = 0; i < this.numButterflies; i++) {
|
||||
const el = document.createElement('div');
|
||||
const sprite = document.createElement('div');
|
||||
sprite.className = 'butterfly-sprite';
|
||||
el.appendChild(sprite);
|
||||
const b: ButterflyData = {
|
||||
el, sprite, x: 0, y: 0, dir: 1, speed: 1, size: 1, golden: false,
|
||||
bobFreq1: 0, bobPhase1: 0, bobAmp1: 0, bobFreq2: 0, bobPhase2: 0, bobAmp2: 0,
|
||||
};
|
||||
this.resetButterfly(b);
|
||||
this.butterflies.push(b);
|
||||
this.world.appendChild(el);
|
||||
}
|
||||
this.viewport.appendChild(this.world);
|
||||
if (this.netEnabled) this.initNet();
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
};
|
||||
|
||||
private wasPremium: boolean = false;
|
||||
private wasStunned: boolean = false;
|
||||
|
||||
public render = (): void => {
|
||||
for (const b of this.butterflies) this.updateButterfly(b);
|
||||
|
||||
if (this.netEnabled) {
|
||||
const nowPremium = this.premiumActive;
|
||||
if (this.wasPremium && !nowPremium) this.applyNetAppearance();
|
||||
this.wasPremium = nowPremium;
|
||||
|
||||
this.updateWasps();
|
||||
this.updateBirds();
|
||||
this.trySpawnStalker();
|
||||
this.updateStalker();
|
||||
|
||||
const stunned = this.timer < this.stunUntil;
|
||||
if (stunned !== this.wasStunned) {
|
||||
this.net.classList.toggle('stunned', stunned);
|
||||
this.wasStunned = stunned;
|
||||
}
|
||||
|
||||
if (this.netGrabbed) this.checkCatches();
|
||||
if (nowPremium) this.runMagnet();
|
||||
}
|
||||
|
||||
this.timer++;
|
||||
this.animationId = requestAnimationFrame(this.render);
|
||||
};
|
||||
|
||||
public destroy = (): void => {
|
||||
if (this.animationId) { cancelAnimationFrame(this.animationId); this.animationId = null; }
|
||||
if (this.comboTimer) window.clearTimeout(this.comboTimer);
|
||||
for (const w of this.wasps) w.cleanup?.();
|
||||
this.removeStalker();
|
||||
if (this.world && this.world.parentNode) this.world.parentNode.removeChild(this.world);
|
||||
this.net.removeEventListener('pointerdown', this.onPointerDown);
|
||||
window.removeEventListener('pointermove', this.onPointerMove);
|
||||
window.removeEventListener('pointerup', this.onPointerUp);
|
||||
window.removeEventListener('pointercancel', this.onPointerUp);
|
||||
if (this.net.parentNode) this.net.parentNode.removeChild(this.net);
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Minihra chytání motýlků: mince, úrovně (strmá křivka), prémiová zlatá síťka,
|
||||
* vzácní zlatí motýli, kombo, perzistentní škůdci (vosy na zaklikání, nalétávající
|
||||
* ptáci), zloděj mincí a housenka žeroucí úlovky. Stav na serveru přes
|
||||
* {@link useButterflyStats}; rate-cap chrání proti podvádění.
|
||||
*/
|
||||
const FlyingButterflies: React.FC<FlyingButterfliesProps> = ({
|
||||
numButterflies = 9,
|
||||
className = 'flying-butterflies',
|
||||
butterflyVariants = BUTTERFLY_VARIANTS,
|
||||
enableNet = true,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sceneRef = useRef<ButterflyScene | null>(null);
|
||||
const badgeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [combo, setCombo] = useState(0);
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
const [leaderboardOpen, setLeaderboardOpen] = useState(false);
|
||||
const [nowTs, setNowTs] = useState(() => Date.now());
|
||||
const flashTimer = useRef<number | null>(null);
|
||||
|
||||
const showFlash = useCallback((msg: string) => {
|
||||
setFlash(msg);
|
||||
if (flashTimer.current) window.clearTimeout(flashTimer.current);
|
||||
flashTimer.current = window.setTimeout(() => setFlash(null), 6000);
|
||||
}, []);
|
||||
|
||||
const handleReward = useCallback((e: RewardEvent) => {
|
||||
if (e.premiumUnlocked) {
|
||||
sceneRef.current?.activatePremiumNet();
|
||||
showFlash('🪄 Zlatá síťka aktivní! Větší, magnetická a odolná proti ptákům.');
|
||||
} else if (e.leveledUp) {
|
||||
showFlash(`⭐ Nová úroveň: ${e.newTitle}!`);
|
||||
} else if (e.dailyBonusApplied) {
|
||||
showFlash('☀️ Denní bonus mincí!');
|
||||
}
|
||||
}, [showFlash]);
|
||||
|
||||
const {
|
||||
stats, displayCaught, coinsRef, reportCatch, repair,
|
||||
killWasp, buyRepellent, reportTear, reportRobbery, defeatThief,
|
||||
caterpillarAte, defeatCaterpillar,
|
||||
} = useButterflyStats(handleReward);
|
||||
|
||||
const callbacksRef = useRef<SceneCallbacks>({
|
||||
onCatch: () => { }, getCoins: () => 0, getCaught: () => 0, onTornChange: () => { },
|
||||
onCombo: () => { }, onSting: () => { }, onSwatWasp: () => { },
|
||||
onRobbery: () => { }, onDefeatThief: () => { }, onCaterpillarAte: () => { },
|
||||
onDefeatCaterpillar: () => { }, onStalkerAppear: () => { },
|
||||
});
|
||||
callbacksRef.current = {
|
||||
onCatch: (golden) => reportCatch(golden),
|
||||
getCoins: () => coinsRef.current,
|
||||
getCaught: () => stats?.caught ?? 0,
|
||||
onTornChange: (t) => { if (t) void reportTear(); },
|
||||
onCombo: (c) => setCombo(c),
|
||||
onSting: () => showFlash('🐝 Au! Vosa tě žihla – síťka teď 5 s nechytá. Zaklikej vosy!'),
|
||||
onSwatWasp: () => void killWasp(),
|
||||
onRobbery: () => { void reportRobbery(); showFlash('💸 Zloděj ti ukradl většinu mincí! Příště ho zaklikej.'); },
|
||||
onDefeatThief: () => { void defeatThief(); showFlash('💰 Zloděj poražen! Malá odměna a mince v bezpečí.'); },
|
||||
onCaterpillarAte: () => { void caterpillarAte(); showFlash('🐛 Housenka ti sežrala část úlovků!'); },
|
||||
onDefeatCaterpillar: () => { void defeatCaterpillar(); showFlash('🍃 Housenka poražena! Úlovky v bezpečí.'); },
|
||||
onStalkerAppear: (type) => showFlash(type === 'thief'
|
||||
? '🦹 Zloděj! Míří k tvým mincím – zaklikej ho (chce to hodně ran)!'
|
||||
: '🐛 Housenka! Plíží se k tvým úlovkům – zaklikej ji!'),
|
||||
};
|
||||
|
||||
const initialize = useCallback(() => {
|
||||
if (containerRef.current) {
|
||||
sceneRef.current = new ButterflyScene(containerRef.current, numButterflies, butterflyVariants, enableNet, {
|
||||
onCatch: (g) => callbacksRef.current.onCatch(g),
|
||||
getCoins: () => callbacksRef.current.getCoins(),
|
||||
getCaught: () => callbacksRef.current.getCaught(),
|
||||
onTornChange: (t) => callbacksRef.current.onTornChange(t),
|
||||
onCombo: (c) => callbacksRef.current.onCombo(c),
|
||||
onSting: () => callbacksRef.current.onSting(),
|
||||
onSwatWasp: () => callbacksRef.current.onSwatWasp(),
|
||||
onRobbery: () => callbacksRef.current.onRobbery(),
|
||||
onDefeatThief: () => callbacksRef.current.onDefeatThief(),
|
||||
onCaterpillarAte: () => callbacksRef.current.onCaterpillarAte(),
|
||||
onDefeatCaterpillar: () => callbacksRef.current.onDefeatCaterpillar(),
|
||||
onStalkerAppear: (t) => callbacksRef.current.onStalkerAppear(t),
|
||||
});
|
||||
sceneRef.current.init();
|
||||
sceneRef.current.render();
|
||||
}
|
||||
}, [numButterflies, butterflyVariants, enableNet]);
|
||||
|
||||
useEffect(() => {
|
||||
initialize();
|
||||
return () => {
|
||||
if (sceneRef.current) { sceneRef.current.destroy(); sceneRef.current = null; }
|
||||
if (flashTimer.current) window.clearTimeout(flashTimer.current);
|
||||
};
|
||||
}, [initialize]);
|
||||
|
||||
const wasps = stats?.wasps ?? 0;
|
||||
const birds = stats?.birds ?? 0;
|
||||
const netTornUntil = stats?.netTornUntil ?? 0;
|
||||
const torn = netTornUntil > nowTs;
|
||||
const tornRemainingMs = Math.max(0, netTornUntil - nowTs);
|
||||
useEffect(() => { sceneRef.current?.syncPests(wasps, birds); }, [wasps, birds]);
|
||||
useEffect(() => { sceneRef.current?.setTornFromServer(torn); }, [torn]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = badgeRef.current;
|
||||
if (!node || displayCaught === 0) return;
|
||||
node.classList.remove('bump');
|
||||
void node.offsetWidth;
|
||||
node.classList.add('bump');
|
||||
}, [displayCaught]);
|
||||
|
||||
const coins = stats?.coins ?? 0;
|
||||
const canAffordRepair = coins >= REPAIR_COST;
|
||||
const canAffordRepellent = coins >= REPELLENT_COST;
|
||||
const levelProgress = stats?.levelProgress ?? 0;
|
||||
const repellentUntil = stats?.repellentUntil ?? 0;
|
||||
const repellentRemainingMs = Math.max(0, repellentUntil - nowTs);
|
||||
const repellentActive = repellentRemainingMs > 0;
|
||||
|
||||
// Tik po sekundách pro odpočty plašiče i protržené síťky (jen dokud běží)
|
||||
useEffect(() => {
|
||||
const until = Math.max(repellentUntil, netTornUntil);
|
||||
if (until <= Date.now()) return;
|
||||
const id = window.setInterval(() => setNowTs(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [repellentUntil, netTornUntil]);
|
||||
|
||||
const onRepairClick = useCallback(async () => {
|
||||
const ok = await repair();
|
||||
if (ok) sceneRef.current?.repairNetExternally();
|
||||
}, [repair]);
|
||||
|
||||
const onRepellentClick = useCallback(async () => {
|
||||
const ok = await buyRepellent();
|
||||
if (ok) showFlash('🦅🚫 Plašič ptáků koupen – ptáci na chvíli zmizeli.');
|
||||
}, [buyRepellent, showFlash]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className={className} />
|
||||
{enableNet && (
|
||||
<div className="butterfly-hud">
|
||||
{flash && (
|
||||
<div className="butterfly-flash" onClick={() => setFlash(null)} title="Klikni pro zavření">
|
||||
{flash}
|
||||
</div>
|
||||
)}
|
||||
{combo > 1 && <div className="butterfly-combo">Kombo ×{combo}!</div>}
|
||||
|
||||
<div
|
||||
ref={badgeRef}
|
||||
className="butterfly-counter"
|
||||
title="Zobrazit žebříček a statistiky"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setLeaderboardOpen(true)}
|
||||
onKeyDown={(ev) => { if (ev.key === 'Enter' || ev.key === ' ') setLeaderboardOpen(true); }}
|
||||
>
|
||||
<div className="butterfly-counter-row">
|
||||
<span className="butterfly-counter-icon" aria-hidden="true" />
|
||||
<span className="butterfly-counter-value">{displayCaught}</span>
|
||||
<span className="butterfly-coin-icon" aria-hidden="true" />
|
||||
<span className="butterfly-counter-value">{coins}</span>
|
||||
</div>
|
||||
<div className="butterfly-level">
|
||||
<span className="butterfly-level-title">
|
||||
{stats ? `${stats.level}. ${stats.title}` : '…'}
|
||||
</span>
|
||||
<div className="butterfly-progress" title="Postup do další úrovně">
|
||||
<div className="butterfly-progress-bar" style={{ width: `${levelProgress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="butterfly-pests" title="Škůdci u síťky">
|
||||
<span className={wasps > 0 ? 'butterfly-pest warn' : 'butterfly-pest'}>🐝 {wasps}</span>
|
||||
<span className={birds > 0 ? 'butterfly-pest warn' : 'butterfly-pest'}>🦅 {birds}</span>
|
||||
{repellentActive && (
|
||||
<span className="butterfly-pest ok" title="Zbývající doba plašiče">
|
||||
🚫 {formatRemaining(repellentRemainingMs)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{torn && (
|
||||
<>
|
||||
<div className="butterfly-torn-note">
|
||||
🕳️ Protržená síťka – sama se zašije za {formatRemaining(tornRemainingMs)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="butterfly-action-btn danger"
|
||||
onClick={(ev) => { ev.stopPropagation(); void onRepairClick(); }}
|
||||
disabled={!canAffordRepair}
|
||||
title={canAffordRepair ? 'Zašít hned za mince' : 'Nemáš dost mincí – počkej, než se zašije sama'}
|
||||
>
|
||||
🪡 Zašít hned za {REPAIR_COST} 🪙
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{birds > 0 && !repellentActive && (
|
||||
<button
|
||||
type="button"
|
||||
className="butterfly-action-btn"
|
||||
onClick={(ev) => { ev.stopPropagation(); void onRepellentClick(); }}
|
||||
disabled={!canAffordRepellent}
|
||||
title={canAffordRepellent ? 'Vyžene všechny ptáky a chvíli brání novým' : 'Nemáš dost mincí na plašič'}
|
||||
>
|
||||
🦅🚫 Plašič za {REPELLENT_COST} 🪙
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ButterflyLeaderboardModal isOpen={leaderboardOpen} onClose={() => setLeaderboardOpen(false)} myStats={stats} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const BUTTERFLY_PRESETS = { LIGHT: 5, NORMAL: 9, HEAVY: 16 } as const;
|
||||
export const BUTTERFLY_COLOR_THEMES = {
|
||||
ALL: BUTTERFLY_VARIANTS,
|
||||
WARM: ['/butterfly-orange.svg', '/butterfly-yellow.svg', '/butterfly-pink.svg'] as const,
|
||||
COOL: ['/butterfly-blue.svg'] as const,
|
||||
} as const;
|
||||
|
||||
export default FlyingButterflies;
|
||||
@@ -1,89 +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-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { Button } from 'react-bootstrap';
|
||||
import { useAuth } from './context/auth';
|
||||
import { login } from '../../types';
|
||||
import './Login.css';
|
||||
|
||||
/**
|
||||
@@ -11,60 +10,29 @@ export default function Login() {
|
||||
const auth = useAuth();
|
||||
const loginRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth && !auth.login) {
|
||||
// Vyzkoušíme přihlášení "naprázdno", pokud projde, přihlásili nás trusted headers
|
||||
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
|
||||
}
|
||||
}).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 doLogin = useCallback(() => {
|
||||
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
|
||||
}
|
||||
auth?.setLogin(loginRef.current.value);
|
||||
}
|
||||
}, [auth]);
|
||||
|
||||
if (!auth?.login) {
|
||||
return (
|
||||
<div className='login-page'>
|
||||
<div className='login-card'>
|
||||
<h1 className='login-logo'>Luncher</h1>
|
||||
<p className='login-subtitle'>Aplikace pro profesionální management obědů</p>
|
||||
<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 (!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()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
}} />
|
||||
<Button onClick={doLogin} style={{ marginTop: "20px" }}>Uložit</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div>Neplatný stav</div>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// TODO všechno v tomto souboru jsou duplicity se serverem, ale aktuálně nevím jaký je nejlepší způsob jejich sdílení
|
||||
|
||||
export interface PizzaSize {
|
||||
varId: number, // unikátní ID varianty pizzy
|
||||
size: string, // velikost pizzy, např. "30cm"
|
||||
pizzaPrice: number, // cena samotné pizzy
|
||||
boxPrice: number, // cena krabice
|
||||
price: number, // celková cena (pizza + krabice)
|
||||
}
|
||||
|
||||
/** Jedna konkrétní pizza */
|
||||
export interface Pizza {
|
||||
name: string, // název pizzy
|
||||
ingredients: string[], // seznam ingrediencí
|
||||
sizes: PizzaSize[], // dostupné velikosti pizzy
|
||||
}
|
||||
|
||||
/** Objednávka jedné konkrétní pizzy */
|
||||
export interface PizzaOrder {
|
||||
varId: number, // unikátní ID varianty pizzy
|
||||
name: string, // název pizzy
|
||||
size: string, // velikost pizzy jako string (30cm)
|
||||
price: number, // cena pizzy v Kč, včetně krabice
|
||||
}
|
||||
|
||||
/** Celková objednávka jednoho člověka */
|
||||
export interface Order {
|
||||
customer: string, // jméno objednatele
|
||||
pizzaList: PizzaOrder[], // seznam objednaných pizz
|
||||
totalPrice: number, // celková cena všech objednaných pizz a krabic
|
||||
}
|
||||
|
||||
export interface Choices {
|
||||
[location: string]: string[],
|
||||
}
|
||||
|
||||
/** Údaje o Pizza day. */
|
||||
export interface PizzaDay {
|
||||
state: State,
|
||||
creator: string,
|
||||
orders: Order[]
|
||||
}
|
||||
|
||||
export interface ClientData {
|
||||
date: string, // dnešní datum pro zobrazení
|
||||
isWeekend: boolean, // příznak zda je dnešní den víkend
|
||||
choices: Choices, // seznam voleb
|
||||
pizzaDay?: PizzaDay, // údaje o pizza day, pokud je pro dnešek založen
|
||||
}
|
||||
|
||||
export enum Locations {
|
||||
SLADOVNICKA = 'Sladovnická',
|
||||
UMOTLIKU = 'U Motlíků',
|
||||
TECHTOWER = 'TechTower',
|
||||
SPSE = 'SPŠE',
|
||||
VLASTNI = 'Mám vlastní',
|
||||
OBJEDNAVAM = 'Objednávám',
|
||||
NEOBEDVAM = 'Neobědvám',
|
||||
}
|
||||
|
||||
export enum State {
|
||||
NOT_CREATED, // Pizza day nebyl založen
|
||||
CREATED, // Pizza day je založen
|
||||
LOCKED, // Objednávky uzamčeny
|
||||
ORDERED, // Objednáno
|
||||
DELIVERED, // Doručeno
|
||||
}
|
||||
@@ -1,125 +1,38 @@
|
||||
import { DepartureTime } from "../../types";
|
||||
|
||||
const TOKEN_KEY = "token";
|
||||
|
||||
/**
|
||||
* Uloží token do local storage prohlížeče.
|
||||
* Vrátí kořenovou URL serveru na základě aktuálního prostředí (vývojovou či produkční).
|
||||
*
|
||||
* @param token token
|
||||
* @returns kořenová URL serveru
|
||||
*/
|
||||
export const storeToken = (token: string) => {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
export const getBaseUrl = (): string => {
|
||||
if (process.env.PUBLIC_URL) {
|
||||
return process.env.PUBLIC_URL;
|
||||
}
|
||||
return 'http://localhost:3001';
|
||||
}
|
||||
|
||||
const LOGIN_KEY = "login";
|
||||
|
||||
/**
|
||||
* Vrátí token z local storage, pokud tam je.
|
||||
* Uloží login do local storage prohlížeče.
|
||||
*
|
||||
* @returns token nebo null
|
||||
* @param login login
|
||||
*/
|
||||
export const getToken = (): string | undefined => {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? undefined;
|
||||
export const storeLogin = (login: string) => {
|
||||
localStorage.setItem(LOGIN_KEY, login);
|
||||
}
|
||||
|
||||
/**
|
||||
* Odstraní token z local storage, pokud tam je.
|
||||
*/
|
||||
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
|
||||
* Vrátí login z local storage, pokud tam je.
|
||||
*
|
||||
* @param date datum
|
||||
* @returns index dne v týdnu
|
||||
* @returns login nebo null
|
||||
*/
|
||||
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}`;
|
||||
export const getLogin = (): string | null => {
|
||||
return localStorage.getItem(LOGIN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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ů.
|
||||
* Odstraní login z local storage, pokud tam je.
|
||||
*/
|
||||
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;
|
||||
export const deleteLogin = () => {
|
||||
localStorage.removeItem(LOGIN_KEY);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
.bolt-progress {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
max-width: 400px;
|
||||
|
||||
.bolt-step {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex: 1;
|
||||
min-width: 64px;
|
||||
|
||||
.bolt-dot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--luncher-border, #ced4da);
|
||||
}
|
||||
|
||||
.bolt-label {
|
||||
margin-top: 2px;
|
||||
font-size: 0.7em;
|
||||
color: var(--luncher-text-muted, #6c757d);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Spojnice k předchozímu kroku – vede od středu této tečky doleva ke středu předchozí.
|
||||
// Tečka má průměr 12px, takže její střed je 6px od levého okraje segmentu.
|
||||
&:not(:first-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: calc(6px - 100%);
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--luncher-border, #ced4da);
|
||||
}
|
||||
|
||||
&.done {
|
||||
.bolt-dot {
|
||||
background: var(--bs-success, #198754);
|
||||
}
|
||||
|
||||
&:not(:first-child)::before {
|
||||
background: var(--bs-success, #198754);
|
||||
}
|
||||
}
|
||||
|
||||
&.active .bolt-label {
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
// Pulzování aktivního kroku, dokud sledování běží
|
||||
&.live .bolt-step.active .bolt-dot {
|
||||
animation: bolt-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bolt-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 5px rgba(25, 135, 84, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
|
||||
import './BoltOrderProgress.scss';
|
||||
|
||||
const STEPS = ['Přijato', 'Příprava', 'Vyzvedávání', 'Na cestě', 'Doručeno'];
|
||||
|
||||
/**
|
||||
* Známé stavy objednávky z Bolt API → index kroku ve stepperu.
|
||||
* Pozor: waiting_delivery znamená "jídlo čeká v podniku na vyzvednutí",
|
||||
* nikoli "na cestě" — tu signalizuje až stav kurýra (picked_up apod.).
|
||||
* Krok „Vyzvedávání" je vyhrazen pro kombinaci „kurýr u podniku + jídlo hotové"
|
||||
* (viz logika níže), samotný order_state ho neudělí — jinak bychom hlásili
|
||||
* vyzvedávání i když se ještě peče (chování, na které si Bolt sám občas stěžuje).
|
||||
*/
|
||||
const ORDER_STATE_TO_STEP: Record<string, number> = {
|
||||
created: 0,
|
||||
pending: 0,
|
||||
waiting_acceptance: 0,
|
||||
accepted: 0,
|
||||
waiting_preparation: 0,
|
||||
preparing: 1,
|
||||
waiting_delivery: 1,
|
||||
ready_for_pickup: 1,
|
||||
waiting_courier: 1,
|
||||
waiting_pickup: 1,
|
||||
picked_up: 3,
|
||||
in_delivery: 3,
|
||||
delivering: 3,
|
||||
heading_to_client: 3,
|
||||
delivered: 4,
|
||||
finished: 4,
|
||||
};
|
||||
|
||||
/** Stavy kurýra z Bolt API → index kroku. Kurýr u podniku ještě neznamená "na cestě". */
|
||||
const COURIER_STATE_TO_STEP: Record<string, number> = {
|
||||
matched: 0,
|
||||
accepted: 0,
|
||||
heading_to_provider: 1,
|
||||
arrived_to_provider: 1,
|
||||
picked_up: 3,
|
||||
heading_to_client: 3,
|
||||
delivering: 3,
|
||||
arrived_to_client: 3,
|
||||
delivered: 4,
|
||||
};
|
||||
|
||||
/** Order states ve kterých už jídlo čeká připravené na kurýra. */
|
||||
const PICKUP_READY_ORDER_STATES = new Set(['waiting_delivery', 'ready_for_pickup', 'waiting_courier', 'waiting_pickup']);
|
||||
|
||||
/** Neznámé stavy se mapují heuristicky podle klíčových slov. */
|
||||
function stepForOrderState(state: string): number | 'cancelled' {
|
||||
const s = state.toLowerCase();
|
||||
if (s in ORDER_STATE_TO_STEP) return ORDER_STATE_TO_STEP[s];
|
||||
if (/cancel|reject|fail/.test(s)) return 'cancelled';
|
||||
if (/delivered|finished/.test(s)) return 4;
|
||||
if (/accept|pending|created/.test(s)) return 0;
|
||||
if (/^waiting|prepar|ready|cook/.test(s)) return 1;
|
||||
if (/picked|delivering|heading_to_client|transport/.test(s)) return 3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function stepForCourierState(state?: string): number {
|
||||
if (!state) return 0;
|
||||
const s = state.toLowerCase();
|
||||
if (s in COURIER_STATE_TO_STEP) return COURIER_STATE_TO_STEP[s];
|
||||
if (/picked|client|delivering|transport/.test(s)) return 3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Raw order_state z Bolt API (např. waiting_preparation) */
|
||||
state: string;
|
||||
/** Raw courier.state z Bolt API (např. arrived_to_provider) */
|
||||
courierState?: string;
|
||||
/** Zda sledování stále běží (skupina má boltTrackingToken) */
|
||||
tracking: boolean;
|
||||
}
|
||||
|
||||
/** Mini progress stepper se stavem objednávky Bolt Food. */
|
||||
export default function BoltOrderProgress({ state, courierState, tracking }: Props) {
|
||||
const orderStep = stepForOrderState(state);
|
||||
if (orderStep === 'cancelled') {
|
||||
return <small className="text-danger">Objednávka Bolt byla zrušena</small>;
|
||||
}
|
||||
// Stav kurýra může krok jen zpřesnit dopředu (např. waiting_delivery + picked_up → Na cestě)
|
||||
let step = Math.max(orderStep, stepForCourierState(courierState));
|
||||
// Vyzvedávání = kurýr u podniku a jídlo skutečně hotové. Sám arrived_to_provider
|
||||
// (bez toho, že by jídlo bylo hotové) nestačí — viz komentář u ORDER_STATE_TO_STEP.
|
||||
const courierAtProvider = courierState?.toLowerCase() === 'arrived_to_provider';
|
||||
const foodReadyForPickup = PICKUP_READY_ORDER_STATES.has(state.toLowerCase());
|
||||
if (step < 3 && courierAtProvider && foodReadyForPickup) step = 2;
|
||||
const rawInfo = courierState ? `${state} / kurýr: ${courierState}` : state;
|
||||
return (
|
||||
<OverlayTrigger overlay={<Tooltip>Stav z Bolt Food: {rawInfo}</Tooltip>}>
|
||||
<div className={`bolt-progress${tracking && step < 4 ? ' live' : ''}`}>
|
||||
{STEPS.map((label, i) => (
|
||||
<div key={label} className={`bolt-step${i <= step ? ' done' : ''}${i === step ? ' active' : ''}`}>
|
||||
<div className="bolt-dot" />
|
||||
<div className="bolt-label">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,245 +1,57 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
|
||||
import { useAuth } from "../context/auth";
|
||||
import SettingsModal from "./modals/SettingsModal";
|
||||
import { useSettings, ThemePreference } from "../context/settings";
|
||||
import HuePicker from "./HuePicker";
|
||||
import 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 { useBank } from "../context/bank";
|
||||
|
||||
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 bank = useBank();
|
||||
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||
const bankAccountRef = useRef<HTMLInputElement>(null);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const effectiveDark = settings?.effectiveDark ?? false;
|
||||
|
||||
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);
|
||||
});
|
||||
}, [auth?.login]);
|
||||
|
||||
const closeSettingsModal = () => {
|
||||
setSettingsModalOpen(false);
|
||||
const openBankSettings = () => {
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
const closePizzaModal = () => {
|
||||
setPizzaModalOpen(false);
|
||||
const closeModal = () => {
|
||||
setModalOpen(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);
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newTheme: ThemePreference = effectiveDark ? 'light' : 'dark';
|
||||
settings?.setThemePreference(newTheme);
|
||||
}
|
||||
|
||||
const isValidInteger = (str: string) => {
|
||||
str = str.trim();
|
||||
if (!str) {
|
||||
return false;
|
||||
}
|
||||
str = str.replace(/^0+/, "") || "0";
|
||||
const n = Math.floor(Number(str));
|
||||
return n !== Infinity && String(n) === str && n >= 0;
|
||||
}
|
||||
|
||||
const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean, themePreference?: ThemePreference) => {
|
||||
if (bankAccountNumber) {
|
||||
try {
|
||||
// Validace kódu banky
|
||||
if (!bankAccountNumber.includes('/')) {
|
||||
throw new 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")
|
||||
}
|
||||
if (!isValidInteger(split[1])) {
|
||||
throw new Error("Kód banky není číslo")
|
||||
}
|
||||
|
||||
// Validace čísla a předčíslí
|
||||
let cislo = split[0];
|
||||
|
||||
if (cislo.indexOf('-') > 0) {
|
||||
cislo = cislo.replace('-', '');
|
||||
}
|
||||
if (!isValidInteger(cislo)) {
|
||||
throw new 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++) {
|
||||
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é")
|
||||
}
|
||||
} catch (e: any) {
|
||||
alert(e.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
settings?.setBankAccountNumber(bankAccountNumber);
|
||||
settings?.setBankAccountHolderName(bankAccountHolderName);
|
||||
settings?.setHideSoupsOption(hideSoupsOption);
|
||||
if (themePreference) {
|
||||
settings?.setThemePreference(themePreference);
|
||||
}
|
||||
closeSettingsModal();
|
||||
const save = () => {
|
||||
// TODO validace na modulo 11
|
||||
bank?.setBankAccountNumber(bankAccountRef.current?.value);
|
||||
bank?.setBankAccountHolderName(nameRef.current?.value);
|
||||
closeModal();
|
||||
}
|
||||
|
||||
return <Navbar variant='dark' expand="lg">
|
||||
<Navbar.Brand href="/">Luncher</Navbar.Brand>
|
||||
<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>
|
||||
<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 data dne</NavDropdown.Item>
|
||||
</>
|
||||
)}
|
||||
<NavDropdown.Divider />
|
||||
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={openBankSettings}>Nastavit číslo účtu</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={auth?.clearLogin}>Odhlásit se</NavDropdown.Item>
|
||||
</NavDropdown>
|
||||
</Nav>
|
||||
</Navbar.Collapse>
|
||||
<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 show={modalOpen} onHide={closeModal}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Novinky</h2></Modal.Title>
|
||||
<Modal.Title>Bankovní účet</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>
|
||||
)}
|
||||
<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 />Poznámka: Číslo účtu není aktuálně nijak validováno. Ověřte si jeho správnost.</p>
|
||||
Číslo účtu: <input ref={bankAccountRef} type="text" placeholder="123456-1234567890/1234" /> <br />
|
||||
Název příjemce (nepovinné): <input ref={nameRef} type="text" placeholder="Jan Novák" />
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={() => setChangelogModalOpen(false)}>
|
||||
Zavřít
|
||||
<Button variant="secondary" onClick={closeModal}>
|
||||
Storno
|
||||
</Button>
|
||||
<Button variant="primary" onClick={save}>
|
||||
Uložit
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
.hue-picker-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.dropdown-toggle {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
color: var(--luncher-navbar-text) !important;
|
||||
padding: 8px 12px;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border-radius: var(--luncher-radius-sm);
|
||||
transition: var(--luncher-transition);
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hue-picker-panel {
|
||||
padding: 0 !important;
|
||||
min-width: 240px;
|
||||
|
||||
.hue-picker-inner {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.hue-picker-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--luncher-text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.hue-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
hsl(0 70% 50%), hsl(30 70% 50%), hsl(60 70% 50%), hsl(90 70% 50%),
|
||||
hsl(120 70% 50%), hsl(150 70% 50%), hsl(180 70% 50%), hsl(210 70% 50%),
|
||||
hsl(240 70% 50%), hsl(270 70% 50%), hsl(300 70% 50%), hsl(330 70% 50%), hsl(360 70% 50%)
|
||||
);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
margin-bottom: 14px;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
border: 2px solid rgba(0, 0, 0, 0.25);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
border: 2px solid rgba(0, 0, 0, 0.25);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.hue-presets {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
.hue-swatch {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--luncher-text);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hue-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--luncher-border);
|
||||
|
||||
.hue-preview-chip {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--luncher-radius-sm);
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--luncher-text-secondary);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Dropdown } from 'react-bootstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPalette } from '@fortawesome/free-solid-svg-icons';
|
||||
import './HuePicker.scss';
|
||||
|
||||
const PRESETS = [
|
||||
{ hue: 142, label: 'Zelená' },
|
||||
{ hue: 217, label: 'Modrá' },
|
||||
{ hue: 263, label: 'Fialová' },
|
||||
{ hue: 0, label: 'Červená' },
|
||||
{ hue: 28, label: 'Oranžová' },
|
||||
{ hue: 340, label: 'Růžová' },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
accentHue: number;
|
||||
isDark: boolean;
|
||||
onChange: (hue: number) => void;
|
||||
};
|
||||
|
||||
function swatchColor(hue: number, isDark: boolean): string {
|
||||
return `hsl(${hue} 70% ${isDark ? 55 : 38}%)`;
|
||||
}
|
||||
|
||||
export default function HuePicker({ accentHue, isDark, onChange }: Props) {
|
||||
return (
|
||||
<Dropdown align="end" autoClose="outside" className="hue-picker-dropdown">
|
||||
<Dropdown.Toggle
|
||||
as="button"
|
||||
className="theme-toggle"
|
||||
aria-label="Barva zvýraznění"
|
||||
title="Barva zvýraznění"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPalette} />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="hue-picker-panel">
|
||||
<div className="hue-picker-inner">
|
||||
<div className="hue-picker-label">Barva zvýraznění</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={360}
|
||||
value={accentHue}
|
||||
onChange={e => onChange(parseInt(e.target.value, 10))}
|
||||
className="hue-slider"
|
||||
aria-label="Odstín barvy zvýraznění"
|
||||
/>
|
||||
<div className="hue-presets">
|
||||
{PRESETS.map(p => (
|
||||
<button
|
||||
key={p.hue}
|
||||
className={`hue-swatch${accentHue === p.hue ? ' active' : ''}`}
|
||||
style={{ background: swatchColor(p.hue, isDark) }}
|
||||
title={p.label}
|
||||
onClick={() => onChange(p.hue)}
|
||||
aria-label={p.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="hue-preview">
|
||||
<div
|
||||
className="hue-preview-chip"
|
||||
style={{ background: swatchColor(accentHue, isDark) }}
|
||||
/>
|
||||
<span>Aktuální barva zvýraznění</span>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { IconDefinition } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
|
||||
type Props = {
|
||||
title?: string,
|
||||
icon: IconDefinition,
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default Loader;
|
||||
@@ -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?.();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +1,41 @@
|
||||
import React from "react";
|
||||
import { Table } from "react-bootstrap";
|
||||
import PizzaOrderRow from "./PizzaOrderRow";
|
||||
import { PizzaDayState, PizzaOrder, PizzaVariant, updatePizzaFee } from "../../../types";
|
||||
import { Order, PizzaOrder, State } from "../Types";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
|
||||
import { useAuth } from "../context/auth";
|
||||
|
||||
type Props = {
|
||||
state: PizzaDayState,
|
||||
orders: PizzaOrder[],
|
||||
onDelete: (pizzaOrder: PizzaVariant) => void,
|
||||
creator: string,
|
||||
}
|
||||
|
||||
export default function PizzaOrderList({ state, orders, onDelete, creator }: Readonly<Props>) {
|
||||
const saveFees = async (customer: string, text?: string, price?: number) => {
|
||||
await updatePizzaFee({ body: { login: customer, text, price } });
|
||||
}
|
||||
export default function PizzaOrderList({ state, orders, onDelete }: { state: State, orders: Order[], onDelete: (pizzaOrder: PizzaOrder) => void }) {
|
||||
const auth = useAuth();
|
||||
|
||||
if (!orders?.length) {
|
||||
return <p className="mt-4" style={{ color: 'var(--luncher-text-muted)', fontStyle: 'italic' }}>Zatím žádné objednávky...</p>
|
||||
return <p><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)' }}>
|
||||
return <Table className="mt-3" striped bordered hover>
|
||||
<thead>
|
||||
<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>
|
||||
<th>Jméno</th>
|
||||
<th>Objednávka</th>
|
||||
<th>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} />
|
||||
{orders.map(order => <tr key={order.customer}>
|
||||
<td>{order.customer}</td>
|
||||
<td>{order.pizzaList.map<React.ReactNode>((pizzaOrder, index) =>
|
||||
<span key={index}>
|
||||
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
|
||||
{auth?.login === order.customer && state === State.CREATED &&
|
||||
<FontAwesomeIcon onClick={() => {
|
||||
onDelete(pizzaOrder);
|
||||
}} title='Odstranit' className='trash-icon' icon={faTrashCan} />
|
||||
}
|
||||
</span>)
|
||||
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
|
||||
</td>
|
||||
<td>{order.totalPrice} Kč</td>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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 PizzaAdditionalFeeModal from "./modals/PizzaAdditionalFeeModal";
|
||||
import { PizzaDayState, PizzaOrder, PizzaVariant } from "../../../types";
|
||||
|
||||
type Props = {
|
||||
creator: string,
|
||||
order: PizzaOrder,
|
||||
state: PizzaDayState,
|
||||
onDelete: (order: PizzaVariant) => void,
|
||||
onFeeModalSave: (customer: string, name?: string, price?: number) => void,
|
||||
}
|
||||
|
||||
export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeModalSave }: Readonly<Props>) {
|
||||
const auth = useAuth();
|
||||
const [isFeeModalOpen, setIsFeeModalOpen] = useState<boolean>(false);
|
||||
|
||||
const saveFees = (customer: string, text?: string, price?: number) => {
|
||||
onFeeModalSave(customer, text, price);
|
||||
setIsFeeModalOpen(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č)`}
|
||||
{auth?.login === order.customer && state === PizzaDayState.CREATED &&
|
||||
<span title='Odstranit'>
|
||||
<FontAwesomeIcon onClick={() => {
|
||||
onDelete(pizzaOrder);
|
||||
}} className='action-icon' icon={faTrashCan} />
|
||||
</span>
|
||||
}
|
||||
</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>
|
||||
{order.totalPrice / 100} Kč{auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>}
|
||||
</td>
|
||||
<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 }} />
|
||||
</>
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Modal, Button, Form } from "react-bootstrap";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (title: string, description: string) => Promise<void>;
|
||||
};
|
||||
|
||||
/** Modální dialog pro přidání nového návrhu na vylepšení. */
|
||||
export default function AddSuggestionModal({ isOpen, onClose, onSubmit }: Readonly<Props>) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim() || !description.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(title.trim(), description.trim());
|
||||
reset();
|
||||
onClose();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={handleClose} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Nový návrh na vylepšení</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Název</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="Stručný název návrhu"
|
||||
value={title}
|
||||
maxLength={120}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
autoFocus
|
||||
/>
|
||||
<Form.Text className="text-muted">Krátký, výstižný název navrhované úpravy.</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Popis</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={5}
|
||||
placeholder="Detailní popis navrhované úpravy, řešení apod."
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Storno
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting || !title.trim() || !description.trim()}>
|
||||
Přidat
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Modal, Button, Form, Alert, Badge } from "react-bootstrap";
|
||||
import {
|
||||
OrderGroup,
|
||||
simulateBoltTracking, advanceBoltTracking, setBoltTrackingState,
|
||||
pollBoltTracking, stopBoltTrackingSimulation,
|
||||
} from "../../../../types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
group: OrderGroup;
|
||||
};
|
||||
|
||||
/** Nabídka stavů pro ruční nastavení (odpovídá mapování v BoltOrderProgress). */
|
||||
const STATE_OPTIONS: { key: string; label: string; order_state: string; courier_state?: string }[] = [
|
||||
{ key: 'accepted', label: 'Přijato', order_state: 'accepted' },
|
||||
{ key: 'preparing', label: 'Příprava', order_state: 'preparing' },
|
||||
{ key: 'waiting_delivery', label: 'Příprava (čeká na vyzvednutí)', order_state: 'waiting_delivery' },
|
||||
{ key: 'pickup', label: 'Vyzvedávání', order_state: 'waiting_delivery', courier_state: 'arrived_to_provider' },
|
||||
{ key: 'in_delivery', label: 'Na cestě', order_state: 'in_delivery', courier_state: 'heading_to_client' },
|
||||
{ key: 'delivered', label: 'Doručeno', order_state: 'delivered' },
|
||||
{ key: 'cancelled', label: 'Zrušeno', order_state: 'cancelled' },
|
||||
];
|
||||
|
||||
/** Modální dialog pro simulaci sledování Bolt objednávky (pouze DEV). */
|
||||
export default function BoltSimulationModal({ isOpen, onClose, group }: Readonly<Props>) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [manualKey, setManualKey] = useState<string>('preparing');
|
||||
|
||||
const running = !!group.boltTrackingToken;
|
||||
|
||||
/** Obecný runner — spustí akci, ošetří chybu a krátce zobrazí výsledek. */
|
||||
const run = async (action: () => Promise<{ error?: unknown }>, okMsg: string) => {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await action();
|
||||
if (response.error) {
|
||||
setError((response.error as any)?.error || 'Akce simulace selhala');
|
||||
} else {
|
||||
setInfo(okMsg);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Akce simulace selhala');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = () => run(
|
||||
() => simulateBoltTracking({ body: { groupId: group.id } }),
|
||||
'Simulace spuštěna — objednávka přijata.',
|
||||
);
|
||||
const handleAdvance = () => run(
|
||||
() => advanceBoltTracking({ body: { groupId: group.id } }),
|
||||
'Posunuto na další krok.',
|
||||
);
|
||||
const handleSetState = () => {
|
||||
const opt = STATE_OPTIONS.find(o => o.key === manualKey);
|
||||
if (!opt) return;
|
||||
return run(
|
||||
() => setBoltTrackingState({
|
||||
body: {
|
||||
groupId: group.id,
|
||||
order_state: opt.order_state,
|
||||
courier_state: opt.courier_state,
|
||||
},
|
||||
}),
|
||||
`Stav nastaven na „${opt.label}".`,
|
||||
);
|
||||
};
|
||||
const handlePoll = () => run(
|
||||
() => pollBoltTracking(),
|
||||
'Scheduler spuštěn (poll proběhl).',
|
||||
);
|
||||
const handleStop = () => run(
|
||||
() => stopBoltTrackingSimulation({ body: { groupId: group.id } }),
|
||||
'Simulace ukončena.',
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={handleClose}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Simulace sledování Bolt</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Alert variant="warning">
|
||||
<strong>DEV režim</strong> – simuluje sledování objednávky Bolt pro skupinu{' '}
|
||||
<strong>{group.name}</strong> bez reálné objednávky.
|
||||
</Alert>
|
||||
|
||||
{error && (
|
||||
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{info && (
|
||||
<Alert variant="success" onClose={() => setInfo(null)} dismissible>
|
||||
{info}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<p className="mb-2">
|
||||
Stav simulace:{' '}
|
||||
{running
|
||||
? <Badge bg="success">běží{group.boltOrderState ? ` — ${group.boltOrderState}` : ''}</Badge>
|
||||
: <Badge bg="secondary">neběží</Badge>}
|
||||
</p>
|
||||
|
||||
{!running ? (
|
||||
<p className="text-muted">
|
||||
Spuštěním se skupina přepne do stavu „Objednáno", přiřadí se simulovaný
|
||||
sledovací token a provede se první poll (stav „Přijato").
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted">
|
||||
Krokuj objednávku tlačítkem <strong>Další krok</strong> (Přijato → Příprava →
|
||||
Vyzvedávání → Na cestě → Doručeno) nebo nastav konkrétní stav ručně:
|
||||
</p>
|
||||
<Form.Group className="mb-3 d-flex gap-2 align-items-end">
|
||||
<div className="flex-grow-1">
|
||||
<Form.Label>Konkrétní stav</Form.Label>
|
||||
<Form.Select
|
||||
value={manualKey}
|
||||
onChange={e => setManualKey(e.target.value)}
|
||||
>
|
||||
{STATE_OPTIONS.map(o => (
|
||||
<option key={o.key} value={o.key}>{o.label}</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</div>
|
||||
<Button variant="outline-primary" onClick={handleSetState} disabled={loading}>
|
||||
Nastavit
|
||||
</Button>
|
||||
</Form.Group>
|
||||
</>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="d-flex flex-wrap gap-2">
|
||||
{!running ? (
|
||||
<Button variant="primary" onClick={handleStart} disabled={loading}>
|
||||
{loading ? 'Spouštím…' : 'Spustit simulaci'}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="success" onClick={handleAdvance} disabled={loading}>
|
||||
Další krok
|
||||
</Button>
|
||||
<Button variant="outline-secondary" onClick={handlePoll} disabled={loading}>
|
||||
Aktualizovat teď
|
||||
</Button>
|
||||
<Button variant="outline-danger" onClick={handleStop} disabled={loading}>
|
||||
Ukončit simulaci
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="secondary" onClick={handleClose} disabled={loading}>
|
||||
Zavřít
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +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: 4px 0 8px;
|
||||
}
|
||||
|
||||
.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,85 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal } from "react-bootstrap";
|
||||
import { ButterflyLeaderboardEntry, ButterflyStats, getButterflyLeaderboard } 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 a týmovým žebříčkem chytačů motýlů. */
|
||||
export default function ButterflyLeaderboardModal({ isOpen, onClose, myStats }: Readonly<Props>) {
|
||||
const auth = useAuth();
|
||||
const [entries, setEntries] = useState<ButterflyLeaderboardEntry[]>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getButterflyLeaderboard({ query: { limit: 20 } })
|
||||
.then(res => { if (!cancelled) setEntries(res.data ?? []); })
|
||||
.catch(() => { if (!cancelled) setEntries([]); })
|
||||
.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>
|
||||
)}
|
||||
|
||||
<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,104 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Modal, Button, 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'];
|
||||
|
||||
/** Modální dialog pro smazání mock dat (pouze DEV). */
|
||||
export default function ClearMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly<Props>) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleClear = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const body: any = {};
|
||||
if (currentDayIndex !== undefined) {
|
||||
body.dayIndex = currentDayIndex 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();
|
||||
};
|
||||
|
||||
const dayName = currentDayIndex !== undefined ? DAY_NAMES[currentDayIndex] : 'aktuální den';
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={handleClose}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Smazat 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>
|
||||
)}
|
||||
|
||||
<p>
|
||||
Opravdu chcete smazat všechny volby stravování pro <strong>{dayName}</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>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +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'];
|
||||
|
||||
/** Modální dialog pro generování mock dat (pouze DEV). */
|
||||
export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly<Props>) {
|
||||
const [dayIndex, setDayIndex] = useState<number | undefined>(currentDayIndex);
|
||||
const [count, setCount] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const body: any = {};
|
||||
if (dayIndex !== undefined) {
|
||||
body.dayIndex = dayIndex 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 {
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setSuccess(false);
|
||||
setCount('');
|
||||
}, 1500);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Nastala chyba při generování dat');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
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">
|
||||
Mock data byla úspěšně vygenerová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={dayIndex ?? ''}
|
||||
onChange={e => setDayIndex(e.target.value === '' ? undefined : parseInt(e.target.value, 10))}
|
||||
>
|
||||
<option value="">Aktuální den</option>
|
||||
{DAY_NAMES.map((name, index) => (
|
||||
<option key={index} value={index}>{name}</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">
|
||||
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,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,305 +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 ?? [];
|
||||
let baseAmount = 0;
|
||||
let baseAmountParseFailed = false;
|
||||
if (menu) {
|
||||
for (const idx of selectedFoods) {
|
||||
const priceKc = parsePriceCzk(menu.food?.[idx]?.price);
|
||||
if (priceKc === null) {
|
||||
baseAmountParseFailed = true;
|
||||
} else {
|
||||
baseAmount += Math.round(priceKc * 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
login,
|
||||
selectedFoods,
|
||||
baseAmount,
|
||||
baseAmountParseFailed,
|
||||
surchargeText: '',
|
||||
surchargeAmount: '',
|
||||
included: login !== payerLogin,
|
||||
};
|
||||
});
|
||||
setDiners(entries);
|
||||
setTipTotal('');
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
}, [isOpen, locationChoices, menu, payerLogin]);
|
||||
|
||||
const includedDiners = diners.filter(d => d.included && d.login !== payerLogin);
|
||||
const tipPerPerson = (() => {
|
||||
if (includedDiners.length === 0) return 0;
|
||||
const tip = parseAmount(tipTotal);
|
||||
if (tip === null || tip === 0) return 0;
|
||||
const totalPeople = includedDiners.length + 1;
|
||||
return Math.round(tip / totalPeople);
|
||||
})();
|
||||
const payerTipShare = (() => {
|
||||
const tip = parseAmount(tipTotal);
|
||||
if (!tip) return 0;
|
||||
return tip - tipPerPerson * includedDiners.length;
|
||||
})();
|
||||
|
||||
const getTotal = (d: DinerEntry): number => {
|
||||
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
|
||||
const tip = d.login === payerLogin ? payerTipShare : tipPerPerson;
|
||||
return d.baseAmount + surcharge + tip;
|
||||
};
|
||||
|
||||
const handleInclude = useCallback((login: string, checked: boolean) => {
|
||||
setDiners(prev => prev.map(d => d.login === login ? { ...d, included: checked } : d));
|
||||
}, []);
|
||||
|
||||
const handleSurchargeText = useCallback((login: string, value: string) => {
|
||||
setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeText: value } : d));
|
||||
}, []);
|
||||
|
||||
const handleSurchargeAmount = useCallback((login: string, value: string) => {
|
||||
setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeAmount: sanitizeAmount(value) } : d));
|
||||
}, []);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setError(null);
|
||||
const recipients: QrRecipient[] = [];
|
||||
|
||||
for (const d of diners) {
|
||||
if (!d.included || d.login === payerLogin) continue;
|
||||
const total = getTotal(d);
|
||||
if (total <= 0) {
|
||||
setError(`Celková částka pro ${d.login} musí být kladná`);
|
||||
return;
|
||||
}
|
||||
const foods = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
|
||||
const purposeBase = `Oběd ${locationName}${foods ? ` — ${foods}` : ''}`;
|
||||
recipients.push({
|
||||
login: d.login,
|
||||
purpose: purposeBase.substring(0, 60),
|
||||
amount: total,
|
||||
});
|
||||
}
|
||||
|
||||
if (recipients.length === 0) {
|
||||
setError("Nebyl vybrán žádný příjemce");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await generateQr({
|
||||
body: { recipients, bankAccount, bankAccountHolder },
|
||||
});
|
||||
if (response.error) {
|
||||
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
|
||||
} else {
|
||||
setSuccess(true);
|
||||
setTimeout(() => onClose(), 2000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Nastala chyba při generování QR kódů');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const anyParseFailed = diners.some(d => d.baseAmountParseFailed && d.included);
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Zaplatit za všechny — {locationName}</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{success ? (
|
||||
<Alert variant="success">
|
||||
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci „Nevyřízené platby".
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a společné poplatky, poté vygenerujte QR kódy pro ostatní.</p>
|
||||
|
||||
{!hasMenu && (
|
||||
<Alert variant="info">
|
||||
Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{anyParseFailed && (
|
||||
<Alert variant="warning">
|
||||
U některých jídel se nepodařilo načíst cenu — doplňte ji ručně v sloupci Příplatek.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Table striped bordered hover responsive size="sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 40 }}></th>
|
||||
<th>Strávník</th>
|
||||
<th>Jídla</th>
|
||||
<th style={{ width: 220 }}>Příplatek</th>
|
||||
<th style={{ width: 90 }}>Poplatek</th>
|
||||
<th style={{ width: 90 }}>Celkem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{diners.map(d => {
|
||||
const isPayer = d.login === payerLogin;
|
||||
const foodNames = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
|
||||
const total = getTotal(d);
|
||||
return (
|
||||
<tr key={d.login} className={!d.included && !isPayer ? 'text-muted' : ''}>
|
||||
<td className="text-center">
|
||||
{isPayer ? (
|
||||
<small className="text-muted">plátce</small>
|
||||
) : (
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
checked={d.included}
|
||||
onChange={e => handleInclude(d.login, e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td><strong>{d.login}</strong></td>
|
||||
<td>
|
||||
<small>
|
||||
{foodNames || <span className="text-muted">—</span>}
|
||||
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount / 100} Kč)</span>}
|
||||
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<div className="d-flex gap-1">
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="popis"
|
||||
value={d.surchargeText}
|
||||
onChange={e => handleSurchargeText(d.login, e.target.value)}
|
||||
disabled={!isPayer && !d.included}
|
||||
size="sm"
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="Kč"
|
||||
value={d.surchargeAmount}
|
||||
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
|
||||
disabled={!isPayer && !d.included}
|
||||
size="sm"
|
||||
style={{ width: 70 }}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-end">
|
||||
{(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s / 100} Kč` : '—'; })()}
|
||||
</td>
|
||||
<td className="text-end fw-bold">
|
||||
{`${total / 100} Kč`}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<div className="d-flex align-items-center gap-2 mt-2">
|
||||
<label className="mb-0 text-nowrap">Poplatky celkem (Kč):</label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="0"
|
||||
value={tipTotal}
|
||||
onChange={e => setTipTotal(sanitizeAmount(e.target.value))}
|
||||
size="sm"
|
||||
style={{ width: 100 }}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
<small className="text-muted">
|
||||
{includedDiners.length > 0 && tipPerPerson > 0
|
||||
? `(${tipPerPerson / 100} Kč / osoba)`
|
||||
: ''}
|
||||
</small>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
{!success && (
|
||||
<>
|
||||
<span className="me-auto text-muted">
|
||||
Příjemci: {includedDiners.length}
|
||||
</span>
|
||||
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
||||
Storno
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || includedDiners.length === 0}
|
||||
>
|
||||
{loading ? 'Generuji...' : 'Vygenerovat QR'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{success && (
|
||||
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
|
||||
)}
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
||||
import { generateQr, OrderGroup, OrderGroupMember, QrRecipient } from "../../../../types";
|
||||
import { sanitizeQrMessage } from "../../Utils";
|
||||
import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
group: OrderGroup;
|
||||
payerLogin: string;
|
||||
bankAccount: string;
|
||||
bankAccountHolder: string;
|
||||
groupId?: string;
|
||||
};
|
||||
|
||||
type DinerEntry = {
|
||||
login: string;
|
||||
member: OrderGroupMember;
|
||||
included: boolean;
|
||||
};
|
||||
|
||||
export default function PayForGroupModal({ isOpen, onClose, onSuccess, group, payerLogin, bankAccount, bankAccountHolder, groupId }: Readonly<Props>) {
|
||||
const [diners, setDiners] = useState<DinerEntry[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const entries: DinerEntry[] = (Object.entries(group.members) as [string, OrderGroupMember][]).map(([login, member]) => ({
|
||||
login,
|
||||
member,
|
||||
// Standardně zahrnout všechny, kdo nejsou plátce a něco si objednali.
|
||||
included: login !== payerLogin && isActiveMember(member),
|
||||
}));
|
||||
setDiners(entries);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
}, [isOpen, group, payerLogin]);
|
||||
|
||||
const fees = group.fees ?? 0;
|
||||
const shipping = group.shipping ?? 0;
|
||||
const tip = group.tip ?? 0;
|
||||
const totalFees = fees + shipping + tip;
|
||||
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
|
||||
const activeCount = countActiveMembers(group.members);
|
||||
const feeShare = computeFeeShare(totalFees, activeCount);
|
||||
const feeParams = { totalFees, discountType: group.discountType, discountValue: group.discountValue ?? 0 };
|
||||
|
||||
const getMemberTotal = (entry: DinerEntry): number =>
|
||||
computeMemberTotal(entry.member, feeParams, feeShare, activeCount);
|
||||
|
||||
const includedNonPayers = diners.filter(d => d.included && d.login !== payerLogin);
|
||||
|
||||
const handleInclude = (login: string, checked: boolean) => {
|
||||
setDiners(prev => prev.map(d => d.login === login ? { ...d, included: checked } : d));
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setError(null);
|
||||
const recipients: QrRecipient[] = [];
|
||||
|
||||
for (const d of diners) {
|
||||
if (!d.included || d.login === payerLogin) continue;
|
||||
const total = getMemberTotal(d);
|
||||
if (total <= 0) {
|
||||
setError(`Celková částka pro ${d.login} musí být kladná`);
|
||||
return;
|
||||
}
|
||||
const note = d.member.note?.trim();
|
||||
recipients.push({
|
||||
login: d.login,
|
||||
purpose: sanitizeQrMessage(note || `Objednávka ${group.name}`),
|
||||
amount: total,
|
||||
});
|
||||
}
|
||||
|
||||
if (recipients.length === 0) {
|
||||
setError("Nebyl vybrán žádný příjemce");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await generateQr({
|
||||
body: { recipients, bankAccount, bankAccountHolder, ...(groupId ? { groupId } : {}) },
|
||||
});
|
||||
if (response.error) {
|
||||
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
|
||||
} else {
|
||||
setSuccess(true);
|
||||
onSuccess?.();
|
||||
setTimeout(() => onClose(), 2000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Nastala chyba při generování QR kódů');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFees = totalFees > 0;
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Generovat QR — {group.name}</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{success ? (
|
||||
<Alert variant="success">
|
||||
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci „Nevyřízené platby".
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<p>Zaplatili jste za skupinu. Vyberte, komu vygenerovat QR kód k úhradě.</p>
|
||||
|
||||
{error && (
|
||||
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasFees && (
|
||||
<div className="d-flex gap-3 mb-2 text-muted" style={{ fontSize: '0.9em' }}>
|
||||
{fees > 0 && <span>Poplatky: <strong>{fees / 100} Kč</strong></span>}
|
||||
{shipping > 0 && <span>Doprava: <strong>{shipping / 100} Kč</strong></span>}
|
||||
{tip > 0 && <span>Spropitné: <strong>{tip / 100} Kč</strong></span>}
|
||||
<span>→ {feeShare / 100} Kč/os.</span>
|
||||
</div>
|
||||
)}
|
||||
{group.discountValue != null && group.discountValue > 0 && (
|
||||
<div className="mb-2 text-success" style={{ fontSize: '0.9em' }}>
|
||||
Sleva: {group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100} Kč`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table striped bordered hover responsive size="sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 40 }}></th>
|
||||
<th>Člen</th>
|
||||
<th style={{ width: 90 }} className="text-end">Základ</th>
|
||||
<th style={{ width: 90 }} className="text-end">Příplatek</th>
|
||||
{hasFees && <th style={{ width: 90 }} className="text-end">Poplatek</th>}
|
||||
<th style={{ width: 90 }} className="text-end fw-bold">Celkem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{diners.map(d => {
|
||||
const isPayer = d.login === payerLogin;
|
||||
const active = isActiveMember(d.member);
|
||||
const total = getMemberTotal(d);
|
||||
const surcharge = d.member.surchargeAmount ?? 0;
|
||||
return (
|
||||
<tr key={d.login} className={(!d.included && !isPayer) || !active ? 'text-muted' : ''}>
|
||||
<td className="text-center">
|
||||
{isPayer ? (
|
||||
<small className="text-muted">plátce</small>
|
||||
) : !active ? (
|
||||
<small className="text-muted">jen objednává</small>
|
||||
) : (
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
checked={d.included}
|
||||
onChange={e => handleInclude(d.login, e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<strong>{d.login}</strong>
|
||||
{d.member.surchargeText && (
|
||||
<small className="text-muted ms-1">({d.member.surchargeText})</small>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
{(d.member.amount ?? 0) > 0 ? `${d.member.amount! / 100} Kč` : <span className="text-muted">—</span>}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
{surcharge > 0 ? `${surcharge / 100} Kč` : <span className="text-muted">—</span>}
|
||||
</td>
|
||||
{hasFees && (
|
||||
<td className="text-end">
|
||||
{active && feeShare > 0 ? `${feeShare / 100} Kč` : '—'}
|
||||
</td>
|
||||
)}
|
||||
<td className="text-end fw-bold">
|
||||
{total > 0 ? `${total / 100} Kč` : <span className="text-muted">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
{!success && (
|
||||
<>
|
||||
<span className="me-auto text-muted">Příjemci: {includedNonPayers.length}</span>
|
||||
<Button variant="secondary" onClick={onClose} disabled={loading}>Storno</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || includedNonPayers.length === 0}
|
||||
>
|
||||
{loading ? 'Generuji...' : 'Vygenerovat QR'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{success && (
|
||||
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
|
||||
)}
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useRef } from "react";
|
||||
import { Modal, Button } from "react-bootstrap"
|
||||
|
||||
type Props = {
|
||||
customerName: string,
|
||||
isOpen: boolean,
|
||||
onClose: () => void,
|
||||
onSave: (customer: string, name?: string, price?: number) => void,
|
||||
initialValues?: { text?: string, price?: string },
|
||||
}
|
||||
|
||||
/** Modální dialog pro nastavení příplatků za pizzu. */
|
||||
export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose, onSave, initialValues }: Readonly<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));
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100));
|
||||
}
|
||||
}
|
||||
|
||||
return <Modal show={isOpen} onHide={onClose} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Příplatky za objednávku pro {customerName}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
Popis: <input className="mb-3" ref={textRef} type="text" placeholder="např. kuřecí maso" defaultValue={initialValues?.text} onKeyDown={handleKeyDown} /> <br />
|
||||
Cena v Kč: <input ref={priceRef} type="number" placeholder="0" defaultValue={initialValues?.price} onKeyDown={handleKeyDown} /> <br />
|
||||
<div className="mt-3" style={{ fontSize: 'small' }}>Je možné zadávat i záporné částky (např. v případě slev)</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Storno
|
||||
</Button>
|
||||
<Button variant="primary" onClick={doSubmit}>
|
||||
Uložit
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
}
|
||||
@@ -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,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,238 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Modal, Button, Form } from "react-bootstrap"
|
||||
import { useSettings, ThemePreference } 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 reminderTimeRef = 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;
|
||||
|
||||
// Uložení notifikačních nastavení na server
|
||||
await updateNotificationSettings({
|
||||
body: {
|
||||
ntfyTopic: ntfyTopicRef.current?.value || undefined,
|
||||
discordWebhookUrl: discordWebhookRef.current?.value || undefined,
|
||||
teamsWebhookUrl: teamsWebhookRef.current?.value || undefined,
|
||||
enabledEvents,
|
||||
reminderTime: newReminderTime,
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
// Správa push subscription pro připomínky
|
||||
if (newReminderTime && newReminderTime !== oldReminderTime) {
|
||||
subscribeToPush(newReminderTime);
|
||||
} else if (!newReminderTime && oldReminderTime) {
|
||||
unsubscribeFromPush();
|
||||
}
|
||||
|
||||
// Uložení ostatních nastavení (localStorage)
|
||||
onSave(
|
||||
bankAccountRef.current?.value,
|
||||
nameRef.current?.value,
|
||||
hideSoupsRef.current?.checked,
|
||||
themeRef.current?.value as ThemePreference,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Nastavení</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<h4>Vzhled</h4>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<hr />
|
||||
|
||||
<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.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>
|
||||
|
||||
<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>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Storno
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
Uložit
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Modal, Button, Form, ListGroup, Alert } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { addStore, deleteStore, Store } from "../../../../types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
stores: Store[];
|
||||
onStoresChanged: (stores: Store[]) => void;
|
||||
};
|
||||
|
||||
export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly<Props>) {
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newUrl, setNewUrl] = useState('');
|
||||
const [heslo, setHeslo] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newName.trim()) return;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await addStore({ body: { name: newName.trim(), url: newUrl.trim() || undefined, heslo } });
|
||||
if (res.error) {
|
||||
setError((res.error as any).error || 'Nastala chyba');
|
||||
} else if (res.data) {
|
||||
onStoresChanged(res.data as Store[]);
|
||||
setNewName('');
|
||||
setNewUrl('');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Nastala chyba');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (name: string) => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await deleteStore({ body: { name, heslo } });
|
||||
if (res.error) {
|
||||
setError((res.error as any).error || 'Nastala chyba');
|
||||
} else if (res.data) {
|
||||
onStoresChanged(res.data as Store[]);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Nastala chyba');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Správa obchodů</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{error && (
|
||||
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Admin heslo</Form.Label>
|
||||
<Form.Control
|
||||
type="password"
|
||||
placeholder="Heslo"
|
||||
value={heslo}
|
||||
onChange={e => setHeslo(e.target.value)}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<hr />
|
||||
<h6>Přidat obchod</h6>
|
||||
<Form.Control
|
||||
className="mb-2"
|
||||
type="text"
|
||||
placeholder="Název obchodu"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
|
||||
/>
|
||||
<div className="d-flex gap-2 mb-3">
|
||||
<Form.Control
|
||||
type="url"
|
||||
placeholder="URL na nabídku (volitelné, např. Bolt Food/Wolt)"
|
||||
value={newUrl}
|
||||
onChange={e => setNewUrl(e.target.value)}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
|
||||
/>
|
||||
<Button variant="primary" onClick={handleAdd} disabled={loading || !newName.trim() || !heslo}>
|
||||
Přidat
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h6>Aktuální seznam</h6>
|
||||
{stores.length === 0 ? (
|
||||
<p className="text-muted">Žádné obchody v seznamu</p>
|
||||
) : (
|
||||
<ListGroup>
|
||||
{stores.map(s => (
|
||||
<ListGroup.Item key={s.name} className="d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
{s.name}
|
||||
{s.url && /^https?:\/\//i.test(s.url) && (
|
||||
<a href={s.url} target="_blank" rel="noopener noreferrer" className="ms-2" title="Otevřít nabídku v nové záložce">
|
||||
<FontAwesomeIcon icon={faUpRightFromSquare} />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faTrashCan}
|
||||
className="action-icon"
|
||||
title="Odebrat"
|
||||
onClick={() => handleRemove(s.name)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</ListGroup.Item>
|
||||
))}
|
||||
</ListGroup>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Modal, Button } from "react-bootstrap";
|
||||
import { Suggestion } from "../../../../types";
|
||||
|
||||
type Props = {
|
||||
suggestion?: Suggestion;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/** Modální dialog zobrazující celý detail návrhu na vylepšení. */
|
||||
export default function SuggestionDetailModal({ suggestion, onClose }: Readonly<Props>) {
|
||||
return (
|
||||
<Modal show={!!suggestion} onHide={onClose} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>{suggestion?.title}</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p className="text-muted mb-3">
|
||||
Navrhovatel: <strong>{suggestion?.author}</strong> · Hlasy: <strong>{suggestion?.voteScore}</strong>
|
||||
{suggestion?.resolved && <> · <strong>Vyřešeno</strong></>}
|
||||
</p>
|
||||
<p style={{ whiteSpace: "pre-wrap" }}>{suggestion?.description}</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Zavřít
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { ReactNode, useContext, useEffect, useState } from "react"
|
||||
import { useJwt } from "react-jwt";
|
||||
import { deleteToken, getToken, storeToken } from "../Utils";
|
||||
import React, { ReactNode, useContext, useState } from "react"
|
||||
import { useEffect } from "react"
|
||||
|
||||
const LOGIN_KEY = 'login';
|
||||
|
||||
export type AuthContextProps = {
|
||||
login?: string,
|
||||
trusted?: boolean,
|
||||
setToken: (name: string) => void,
|
||||
logout: () => void,
|
||||
setLogin: (name: string) => void,
|
||||
clearLogin: () => void,
|
||||
}
|
||||
|
||||
type ContextProps = {
|
||||
@@ -15,7 +15,7 @@ type ContextProps = {
|
||||
|
||||
const authContext = React.createContext<AuthContextProps | null>(null);
|
||||
|
||||
export function ProvideAuth(props: Readonly<ContextProps>) {
|
||||
export function ProvideAuth(props: ContextProps) {
|
||||
const auth = useProvideAuth();
|
||||
return <authContext.Provider value={auth}>{props.children}</authContext.Provider>
|
||||
}
|
||||
@@ -26,43 +26,33 @@ export const useAuth = () => {
|
||||
|
||||
function useProvideAuth(): AuthContextProps {
|
||||
const [loginName, setLoginName] = useState<string | undefined>();
|
||||
const [trusted, setTrusted] = useState<boolean | undefined>();
|
||||
const [token, setToken] = useState<string | undefined>(getToken());
|
||||
const { decodedToken } = useJwt(token ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
if (token && token.length > 0) {
|
||||
storeToken(token);
|
||||
} else {
|
||||
deleteToken();
|
||||
const login = localStorage.getItem(LOGIN_KEY);
|
||||
if (login) {
|
||||
setLogin(login);
|
||||
}
|
||||
}, [token]);
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (decodedToken) {
|
||||
setLoginName((decodedToken as any).login);
|
||||
setTrusted((decodedToken as any).trusted);
|
||||
if (loginName) {
|
||||
localStorage.setItem(LOGIN_KEY, loginName)
|
||||
} else {
|
||||
setLoginName(undefined);
|
||||
setTrusted(undefined);
|
||||
localStorage.removeItem(LOGIN_KEY);
|
||||
}
|
||||
}, [decodedToken]);
|
||||
}, [loginName]);
|
||||
|
||||
function logout() {
|
||||
const trusted = (decodedToken as any).trusted;
|
||||
const logoutUrl = (decodedToken as any).logoutUrl;
|
||||
setToken(undefined);
|
||||
setLoginName(undefined);
|
||||
setTrusted(undefined);
|
||||
if (trusted && logoutUrl?.length) {
|
||||
globalThis.location.replace(logoutUrl);
|
||||
function setLogin(login: string) {
|
||||
setLoginName(login);
|
||||
}
|
||||
|
||||
function clearLogin() {
|
||||
setLoginName(undefined);
|
||||
}
|
||||
|
||||
return {
|
||||
login: loginName,
|
||||
trusted,
|
||||
setToken,
|
||||
logout,
|
||||
setLogin,
|
||||
clearLogin
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { ReactNode, useContext, useState } from "react"
|
||||
import { useEffect } from "react"
|
||||
|
||||
const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number';
|
||||
const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
|
||||
|
||||
export type BankContextProps = {
|
||||
bankAccount?: string,
|
||||
holderName?: string,
|
||||
setBankAccountNumber: (accountNumber?: string) => void,
|
||||
setBankAccountHolderName: (holderName?: string) => void,
|
||||
}
|
||||
|
||||
type ContextProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const bankContext = React.createContext<BankContextProps | null>(null);
|
||||
|
||||
export function ProvideBank(props: ContextProps) {
|
||||
const bank = useProvideBank();
|
||||
return <bankContext.Provider value={bank}>{props.children}</bankContext.Provider>
|
||||
}
|
||||
|
||||
export const useBank = () => {
|
||||
return useContext(bankContext);
|
||||
}
|
||||
|
||||
function useProvideBank(): BankContextProps {
|
||||
const [bankAccount, setBankAccount] = useState<string | undefined>();
|
||||
const [holderName, setHolderName] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
|
||||
if (accountNumber) {
|
||||
setBankAccount(accountNumber);
|
||||
}
|
||||
const holderName = localStorage.getItem(BANK_ACCOUNT_HOLDER_KEY);
|
||||
if (holderName) {
|
||||
setHolderName(holderName);
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (bankAccount) {
|
||||
localStorage.setItem(BANK_ACCOUNT_NUMBER_KEY, bankAccount)
|
||||
} else {
|
||||
localStorage.removeItem(BANK_ACCOUNT_NUMBER_KEY);
|
||||
}
|
||||
}, [bankAccount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (holderName) {
|
||||
localStorage.setItem(BANK_ACCOUNT_HOLDER_KEY, holderName);
|
||||
} else {
|
||||
localStorage.removeItem(BANK_ACCOUNT_HOLDER_KEY);
|
||||
}
|
||||
}, [holderName]);
|
||||
|
||||
function setBankAccountNumber(bankAccount?: string) {
|
||||
setBankAccount(bankAccount);
|
||||
}
|
||||
|
||||
function setBankAccountHolderName(holderName?: string) {
|
||||
setHolderName(holderName);
|
||||
}
|
||||
|
||||
return {
|
||||
bankAccount,
|
||||
holderName,
|
||||
setBankAccountNumber,
|
||||
setBankAccountHolderName,
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AuthContextProps } from "./auth";
|
||||
import { EasterEgg, getEasterEgg } from "../../../types";
|
||||
|
||||
export const useEasterEgg = (auth?: AuthContextProps | null): [EasterEgg | undefined, boolean] => {
|
||||
const [result, setResult] = useState<EasterEgg | undefined>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchEasterEgg() {
|
||||
if (auth?.login) {
|
||||
setLoading(true);
|
||||
const egg = (await getEasterEgg())?.data;
|
||||
setResult(egg);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchEasterEgg();
|
||||
|
||||
}, [auth?.login]);
|
||||
|
||||
return [result, loading];
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import React, { ReactNode, useContext, useEffect, useState } from "react"
|
||||
|
||||
const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number';
|
||||
const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
|
||||
const HIDE_SOUPS_KEY = 'hide_soups';
|
||||
const THEME_KEY = 'theme_preference';
|
||||
const ACCENT_HUE_KEY = 'accent_hue';
|
||||
const LEGACY_COLOR_THEME_KEY = 'color_theme';
|
||||
|
||||
export type ThemePreference = 'system' | 'light' | 'dark';
|
||||
|
||||
export type SettingsContextProps = {
|
||||
bankAccount?: string,
|
||||
holderName?: string,
|
||||
hideSoups?: boolean,
|
||||
themePreference: ThemePreference,
|
||||
accentHue: number,
|
||||
effectiveDark: boolean,
|
||||
setBankAccountNumber: (accountNumber?: string) => void,
|
||||
setBankAccountHolderName: (holderName?: string) => void,
|
||||
setHideSoupsOption: (hideSoups?: boolean) => void,
|
||||
setThemePreference: (theme: ThemePreference) => void,
|
||||
setAccentHue: (hue: number) => void,
|
||||
}
|
||||
|
||||
type ContextProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const settingsContext = React.createContext<SettingsContextProps | null>(null);
|
||||
|
||||
export function ProvideSettings(props: Readonly<ContextProps>) {
|
||||
const settings = useProvideSettings();
|
||||
return <settingsContext.Provider value={settings}>{props.children}</settingsContext.Provider>
|
||||
}
|
||||
|
||||
export const useSettings = () => {
|
||||
return useContext(settingsContext);
|
||||
}
|
||||
|
||||
function getInitialTheme(): ThemePreference {
|
||||
try {
|
||||
const saved = localStorage.getItem(THEME_KEY) as ThemePreference | null;
|
||||
if (saved && ['system', 'light', 'dark'].includes(saved)) {
|
||||
return saved;
|
||||
}
|
||||
} catch (e) {
|
||||
// localStorage nedostupný
|
||||
}
|
||||
return 'system';
|
||||
}
|
||||
|
||||
function getInitialAccentHue(): number {
|
||||
try {
|
||||
const saved = localStorage.getItem(ACCENT_HUE_KEY);
|
||||
if (saved !== null) {
|
||||
const n = parseInt(saved, 10);
|
||||
if (!isNaN(n) && n >= 0 && n <= 360) return n;
|
||||
}
|
||||
// Migrace ze starého string formátu (green/blue/purple)
|
||||
const old = localStorage.getItem(LEGACY_COLOR_THEME_KEY);
|
||||
if (old === 'blue') return 217;
|
||||
if (old === 'purple') return 263;
|
||||
} catch {
|
||||
// localStorage nedostupný
|
||||
}
|
||||
return 142;
|
||||
}
|
||||
|
||||
// Převod HSL na relativní jas dle WCAG (pro výpočet kontrastu s bílým textem)
|
||||
function hslToRelativeLuminance(h: number, s: number, l: number): number {
|
||||
const sn = s / 100, ln = l / 100;
|
||||
const a = sn * Math.min(ln, 1 - ln);
|
||||
const ch = (n: number) => {
|
||||
const k = (n + h / 30) % 12;
|
||||
return ln - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||
};
|
||||
const toLinear = (c: number) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
return 0.2126 * toLinear(ch(0)) + 0.7152 * toLinear(ch(8)) + 0.0722 * toLinear(ch(4));
|
||||
}
|
||||
|
||||
// Najde nejnižší světlost, při které má barva dostatečný kontrast s bílým textem (WCAG AA 4.5:1)
|
||||
function adjustedL(hue: number, sat: number, targetL: number): number {
|
||||
let l = targetL;
|
||||
while (l >= 5) {
|
||||
const lum = hslToRelativeLuminance(hue, sat, l);
|
||||
if (1.05 / (lum + 0.05) >= 4.5) return l;
|
||||
l -= 1;
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
function applyAccentColors(hue: number, isDark: boolean): void {
|
||||
const sat = 70;
|
||||
const baseL = adjustedL(hue, sat, isDark ? 55 : 38);
|
||||
const hoverL = isDark ? Math.min(baseL + 10, 80) : Math.max(baseL - 10, 10);
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--luncher-primary', `hsl(${hue} ${sat}% ${baseL}%)`);
|
||||
root.style.setProperty('--luncher-primary-hover', `hsl(${hue} ${sat}% ${hoverL}%)`);
|
||||
root.style.setProperty('--luncher-primary-light', isDark
|
||||
? `hsl(${hue} 60% 12%)`
|
||||
: `hsl(${hue} 60% 92%)`);
|
||||
root.style.setProperty('--luncher-action-icon', `hsl(${hue} ${sat}% ${baseL}%)`);
|
||||
root.style.setProperty('--luncher-success', `hsl(${hue} ${sat}% ${baseL}%)`);
|
||||
}
|
||||
|
||||
function useProvideSettings(): SettingsContextProps {
|
||||
const [bankAccount, setBankAccount] = useState<string | undefined>();
|
||||
const [holderName, setHolderName] = useState<string | undefined>();
|
||||
const [hideSoups, setHideSoups] = useState<boolean | undefined>();
|
||||
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme);
|
||||
const [accentHue, setHue] = useState<number>(getInitialAccentHue);
|
||||
const [effectiveDark, setEffectiveDark] = useState<boolean>(() => {
|
||||
try {
|
||||
const pref = localStorage.getItem(THEME_KEY) as ThemePreference | null;
|
||||
if (pref === 'dark') return true;
|
||||
if (pref === 'light') return false;
|
||||
} catch { /* noop */ }
|
||||
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
|
||||
if (accountNumber) {
|
||||
setBankAccount(accountNumber);
|
||||
}
|
||||
const holderName = localStorage.getItem(BANK_ACCOUNT_HOLDER_KEY);
|
||||
if (holderName) {
|
||||
setHolderName(holderName);
|
||||
}
|
||||
const hideSoups = localStorage.getItem(HIDE_SOUPS_KEY);
|
||||
if (hideSoups !== null) {
|
||||
setHideSoups(hideSoups === 'true');
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (bankAccount) {
|
||||
localStorage.setItem(BANK_ACCOUNT_NUMBER_KEY, bankAccount)
|
||||
} else {
|
||||
localStorage.removeItem(BANK_ACCOUNT_NUMBER_KEY);
|
||||
}
|
||||
}, [bankAccount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (holderName) {
|
||||
localStorage.setItem(BANK_ACCOUNT_HOLDER_KEY, holderName);
|
||||
} else {
|
||||
localStorage.removeItem(BANK_ACCOUNT_HOLDER_KEY);
|
||||
}
|
||||
}, [holderName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hideSoups) {
|
||||
localStorage.setItem(HIDE_SOUPS_KEY, hideSoups ? 'true' : 'false');
|
||||
} else {
|
||||
localStorage.removeItem(HIDE_SOUPS_KEY);
|
||||
}
|
||||
}, [hideSoups]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(THEME_KEY, themePreference);
|
||||
}, [themePreference]);
|
||||
|
||||
useEffect(() => {
|
||||
const applyTheme = (dark: boolean) => {
|
||||
document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
|
||||
setEffectiveDark(dark);
|
||||
};
|
||||
if (themePreference === 'system') {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
applyTheme(mq.matches);
|
||||
const handler = (e: MediaQueryListEvent) => applyTheme(e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
} else {
|
||||
applyTheme(themePreference === 'dark');
|
||||
}
|
||||
}, [themePreference]);
|
||||
|
||||
// Aplikuje accent barvy při změně hue nebo přepnutí světlý/tmavý
|
||||
useEffect(() => {
|
||||
localStorage.setItem(ACCENT_HUE_KEY, String(accentHue));
|
||||
applyAccentColors(accentHue, effectiveDark);
|
||||
}, [accentHue, effectiveDark]);
|
||||
|
||||
function setBankAccountNumber(bankAccount?: string) {
|
||||
setBankAccount(bankAccount);
|
||||
}
|
||||
|
||||
function setBankAccountHolderName(holderName?: string) {
|
||||
setHolderName(holderName);
|
||||
}
|
||||
|
||||
function setHideSoupsOption(hideSoups?: boolean) {
|
||||
setHideSoups(hideSoups);
|
||||
}
|
||||
|
||||
function setThemePreference(theme: ThemePreference) {
|
||||
setTheme(theme);
|
||||
}
|
||||
|
||||
function setAccentHue(hue: number) {
|
||||
setHue(hue);
|
||||
}
|
||||
|
||||
return {
|
||||
bankAccount,
|
||||
holderName,
|
||||
hideSoups,
|
||||
themePreference,
|
||||
accentHue,
|
||||
effectiveDark,
|
||||
setBankAccountNumber,
|
||||
setBankAccountHolderName,
|
||||
setHideSoupsOption,
|
||||
setThemePreference,
|
||||
setAccentHue,
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,17 @@
|
||||
import React from 'react';
|
||||
import socketio from "socket.io-client";
|
||||
import { getBaseUrl } from "../Utils";
|
||||
|
||||
let socketUrl;
|
||||
let socketPath;
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
socketUrl = `http://localhost:3001`;
|
||||
socketPath = undefined;
|
||||
} else {
|
||||
socketUrl = `${globalThis.location.host}`;
|
||||
socketPath = '/socket.io';
|
||||
}
|
||||
|
||||
export const socket = socketio.connect(socketUrl, { path: socketPath, transports: ["websocket"] });
|
||||
// Záměrně omezeno jen na websocket, aby se případně odhalilo chybné nastavení proxy serveru
|
||||
export const socket = socketio.connect(getBaseUrl(), { transports: ["websocket"] });
|
||||
export const SocketContext = React.createContext();
|
||||
|
||||
// Prohlížeče throttlují setTimeout v neaktivních tabech, což zdržuje automatické
|
||||
// znovupřipojení socket.io. Po návratu do tabu nebo focusu okna se připojíme hned.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && !socket.connected) {
|
||||
socket.connect();
|
||||
}
|
||||
});
|
||||
window.addEventListener('focus', () => {
|
||||
if (!socket.connected) {
|
||||
socket.connect();
|
||||
}
|
||||
});
|
||||
|
||||
// Konstanty websocket eventů, musí odpovídat těm na serveru!
|
||||
export const EVENT_CONNECT = 'connect';
|
||||
export const EVENT_DISCONNECT = 'disconnect';
|
||||
export const EVENT_MESSAGE = 'message';
|
||||
export const EVENT_PENDING_QR = 'pendingQr';
|
||||
// export const EVENT_CONFIG = 'config';
|
||||
// export const EVENT_TOASTER = 'toaster';
|
||||
// export const EVENT_VOTING = 'voting';
|
||||
// export const EVENT_VOTE_CONFIG = 'voteSettings';
|
||||
// export const EVENT_ADMIN = 'admin';
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { LunchChoice, Restaurant } from "../../types";
|
||||
|
||||
export function getRestaurantName(restaurant: Restaurant) {
|
||||
switch (restaurant) {
|
||||
case Restaurant.SLADOVNICKA:
|
||||
return "Sladovnická";
|
||||
case Restaurant.TECHTOWER:
|
||||
return "TechTower";
|
||||
case Restaurant.ZASTAVKAUMICHALA:
|
||||
return "Zastávka u Michala";
|
||||
case Restaurant.SENKSERIKOVA:
|
||||
return "Šenk Šeříková";
|
||||
default:
|
||||
return restaurant;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLunchChoiceName(location: LunchChoice) {
|
||||
switch (location) {
|
||||
case Restaurant.SLADOVNICKA:
|
||||
return "Sladovnická";
|
||||
case Restaurant.TECHTOWER:
|
||||
return "TechTower";
|
||||
case Restaurant.ZASTAVKAUMICHALA:
|
||||
return "Zastávka u Michala";
|
||||
case Restaurant.SENKSERIKOVA:
|
||||
return "Šenk Šeříková";
|
||||
case LunchChoice.SPSE:
|
||||
return "SPŠE";
|
||||
case LunchChoice.PIZZA:
|
||||
return "Pizza day";
|
||||
case LunchChoice.OBJEDNAVAM:
|
||||
return "Budu objednávat";
|
||||
case LunchChoice.NEOBEDVAM:
|
||||
return "Mám vlastní/neobědvám";
|
||||
case LunchChoice.ROZHODUJI:
|
||||
return "Rozhoduji se";
|
||||
default:
|
||||
return location;
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ButterflyStats,
|
||||
getButterflyStats,
|
||||
catchButterflies,
|
||||
repairNet as repairNetApi,
|
||||
killWasp as killWaspApi,
|
||||
buyRepellent as buyRepellentApi,
|
||||
reportNetTorn as reportTearApi,
|
||||
reportRobbery as reportRobberyApi,
|
||||
defeatThief as defeatThiefApi,
|
||||
reportCaterpillarAte as caterpillarAteApi,
|
||||
defeatCaterpillar as defeatCaterpillarApi,
|
||||
} from '../../../types';
|
||||
|
||||
/** Jak dlouho se čeká, než se nasbíraná dávka úlovků odešle na server. */
|
||||
const FLUSH_DEBOUNCE_MS = 1500;
|
||||
/** Jak často se přenačte stav ze serveru (kvůli přibývajícím škůdcům). */
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
/** Událost odměny hlášená scéně po úspěšném odeslání dávky. */
|
||||
export interface RewardEvent {
|
||||
coinsAwarded: number;
|
||||
dailyBonusApplied: boolean;
|
||||
leveledUp: boolean;
|
||||
premiumUnlocked: boolean;
|
||||
newLevel: number;
|
||||
newTitle: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook spravující serverovou perzistenci chytání motýlků: načtení stavu, dávkové
|
||||
* odesílání úlovků, hubení vos, plašič ptáků, protržení/opravu síťky, zloděje a
|
||||
* periodické přenačítání (růst škůdců). Server je zdroj pravdy; počítadlo se
|
||||
* zobrazuje jako serverová hodnota + zatím neodeslané úlovky.
|
||||
*/
|
||||
export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
|
||||
const [stats, setStats] = useState<ButterflyStats | undefined>();
|
||||
/** Optimisticky zobrazený přírůstek chycených, který ještě neodešel na server. */
|
||||
const [pendingShown, setPendingShown] = useState(0);
|
||||
/** Živá hodnota mincí pro scénu (aby nečetla zastaralý stav přes closure). */
|
||||
const coinsRef = useRef(0);
|
||||
const pending = useRef({ normal: 0, golden: 0 });
|
||||
const flushTimer = useRef<number | null>(null);
|
||||
const onRewardRef = useRef(onReward);
|
||||
onRewardRef.current = onReward;
|
||||
|
||||
const applyStats = useCallback((incoming: ButterflyStats) => {
|
||||
coinsRef.current = incoming.coins;
|
||||
setStats(incoming);
|
||||
}, []);
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
if (flushTimer.current) {
|
||||
clearTimeout(flushTimer.current);
|
||||
flushTimer.current = null;
|
||||
}
|
||||
const batch = pending.current;
|
||||
const batchTotal = batch.normal + batch.golden;
|
||||
if (batchTotal === 0) return;
|
||||
pending.current = { normal: 0, golden: 0 };
|
||||
try {
|
||||
const res = await catchButterflies({ body: { normal: batch.normal, golden: batch.golden } });
|
||||
if (res.data) {
|
||||
applyStats(res.data.stats);
|
||||
// Odeslané úlovky už jsou v serverové hodnotě → sundáme je z optimistického přírůstku
|
||||
setPendingShown(p => Math.max(0, p - batchTotal));
|
||||
onRewardRef.current?.({
|
||||
coinsAwarded: res.data.coinsAwarded,
|
||||
dailyBonusApplied: res.data.dailyBonusApplied,
|
||||
leveledUp: res.data.leveledUp,
|
||||
premiumUnlocked: res.data.premiumUnlocked,
|
||||
newLevel: res.data.stats.level,
|
||||
newTitle: res.data.stats.title,
|
||||
});
|
||||
} else {
|
||||
setPendingShown(p => Math.max(0, p - batchTotal));
|
||||
}
|
||||
} catch {
|
||||
// Při chybě vrátíme dávku zpět (přírůstek necháme zobrazený)
|
||||
pending.current.normal += batch.normal;
|
||||
pending.current.golden += batch.golden;
|
||||
}
|
||||
}, [applyStats]);
|
||||
|
||||
/** Nahlásí chycení jednoho motýla (optimisticky + naplánuje odeslání dávky). */
|
||||
const reportCatch = useCallback((golden: boolean) => {
|
||||
if (golden) pending.current.golden += 1;
|
||||
else pending.current.normal += 1;
|
||||
setPendingShown(p => p + 1);
|
||||
if (!flushTimer.current) {
|
||||
flushTimer.current = window.setTimeout(() => { void flush(); }, FLUSH_DEBOUNCE_MS);
|
||||
}
|
||||
}, [flush]);
|
||||
|
||||
const repair = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const res = await repairNetApi();
|
||||
if (res.data) { applyStats(res.data); return true; }
|
||||
return false;
|
||||
} catch { return false; }
|
||||
}, [applyStats]);
|
||||
|
||||
const killWasp = useCallback(async () => {
|
||||
try {
|
||||
const res = await killWaspApi();
|
||||
if (res.data) applyStats(res.data);
|
||||
} catch { /* ignore */ }
|
||||
}, [applyStats]);
|
||||
|
||||
const buyRepellent = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const res = await buyRepellentApi();
|
||||
if (res.data) { applyStats(res.data); return true; }
|
||||
return false;
|
||||
} catch { return false; }
|
||||
}, [applyStats]);
|
||||
|
||||
const reportTear = useCallback(async () => {
|
||||
try {
|
||||
const res = await reportTearApi();
|
||||
if (res.data) applyStats(res.data);
|
||||
} catch { /* ignore */ }
|
||||
}, [applyStats]);
|
||||
|
||||
/** Zloděj se dostal k penězům – server strhne podíl mincí. */
|
||||
const reportRobbery = useCallback(async () => {
|
||||
try {
|
||||
const res = await reportRobberyApi();
|
||||
if (res.data) applyStats(res.data);
|
||||
} catch { /* ignore */ }
|
||||
}, [applyStats]);
|
||||
|
||||
/** Hráč porazil zloděje – odměna a statistika. */
|
||||
const defeatThief = useCallback(async () => {
|
||||
try {
|
||||
const res = await defeatThiefApi();
|
||||
if (res.data) applyStats(res.data);
|
||||
} catch { /* ignore */ }
|
||||
}, [applyStats]);
|
||||
|
||||
/** Housenka se dostala k úlovkům – server sníží počet chycených. */
|
||||
const caterpillarAte = useCallback(async () => {
|
||||
try {
|
||||
const res = await caterpillarAteApi();
|
||||
if (res.data) applyStats(res.data);
|
||||
} catch { /* ignore */ }
|
||||
}, [applyStats]);
|
||||
|
||||
/** Hráč porazil housenku – odměna a statistika. */
|
||||
const defeatCaterpillar = useCallback(async () => {
|
||||
try {
|
||||
const res = await defeatCaterpillarApi();
|
||||
if (res.data) applyStats(res.data);
|
||||
} catch { /* ignore */ }
|
||||
}, [applyStats]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await getButterflyStats();
|
||||
if (res.data) applyStats(res.data);
|
||||
} catch { /* ignore */ }
|
||||
}, [applyStats]);
|
||||
|
||||
// Načtení stavu
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await getButterflyStats();
|
||||
if (!cancelled && res.data) applyStats(res.data);
|
||||
} catch { /* server nedostupný – hra jede dál bez perzistence */ }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [applyStats]);
|
||||
|
||||
// Periodické přenačítání (růst škůdců) + při návratu na záložku
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => { void refresh(); }, POLL_INTERVAL_MS);
|
||||
const onVisible = () => { if (document.visibilityState === 'visible') void refresh(); };
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
// Odeslání rozdělané dávky při skrytí/opuštění stránky a při odmontování
|
||||
useEffect(() => {
|
||||
const onVisibility = () => { if (document.visibilityState === 'hidden') void flush(); };
|
||||
const onPageHide = () => { void flush(); };
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
window.addEventListener('pagehide', onPageHide);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
window.removeEventListener('pagehide', onPageHide);
|
||||
void flush();
|
||||
};
|
||||
}, [flush]);
|
||||
|
||||
const displayCaught = (stats?.caught ?? 0) + pendingShown;
|
||||
|
||||
return {
|
||||
stats, displayCaught, coinsRef, reportCatch, repair, killWasp, buyRepellent,
|
||||
reportTear, reportRobbery, defeatThief, caterpillarAte, defeatCaterpillar,
|
||||
};
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { getToken } from '../Utils';
|
||||
|
||||
/** Převede base64url VAPID klíč na Uint8Array pro PushManager. */
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
/** Helper pro autorizované API volání na push endpointy. */
|
||||
async function pushApiFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
const token = getToken();
|
||||
return fetch(`/api/notifications/push${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Zaregistruje service worker, přihlásí se k push notifikacím
|
||||
* a odešle subscription na server.
|
||||
*/
|
||||
export async function subscribeToPush(reminderTime: string): Promise<boolean> {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
console.warn('Push notifikace nejsou v tomto prohlížeči podporovány');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Registrace service workeru
|
||||
const registration = await navigator.serviceWorker.register('/sw.js');
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
// Vyžádání oprávnění
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
console.warn('Push notifikace: oprávnění zamítnuto');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Získání VAPID veřejného klíče ze serveru
|
||||
const vapidResponse = await pushApiFetch('/vapidKey');
|
||||
if (!vapidResponse.ok) {
|
||||
console.error('Push notifikace: nepodařilo se získat VAPID klíč');
|
||||
return false;
|
||||
}
|
||||
const { key: vapidPublicKey } = await vapidResponse.json();
|
||||
|
||||
// Přihlášení k push
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource,
|
||||
});
|
||||
|
||||
// Odeslání subscription na server
|
||||
const response = await pushApiFetch('/subscribe', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
subscription: subscription.toJSON(),
|
||||
reminderTime,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Push notifikace: nepodařilo se odeslat subscription na server');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('Push notifikace: úspěšně přihlášeno k připomínkám v', reminderTime);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Push notifikace: chyba při registraci', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Odhlásí se z push notifikací a informuje server.
|
||||
*/
|
||||
export async function unsubscribeFromPush(): Promise<void> {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration('/sw.js');
|
||||
if (registration) {
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (subscription) {
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
await pushApiFetch('/unsubscribe', { method: 'POST' });
|
||||
console.log('Push notifikace: úspěšně odhlášeno z připomínek');
|
||||
} catch (error) {
|
||||
console.error('Push notifikace: chyba při odhlášení', error);
|
||||
}
|
||||
}
|
||||
@@ -7,32 +7,14 @@ body,
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Better focus styles */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--luncher-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
::selection {
|
||||
background: var(--luncher-primary-light);
|
||||
color: var(--luncher-primary);
|
||||
}
|
||||
|
||||
@@ -1,63 +1,25 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { SocketContext, socket } from './context/socket';
|
||||
import { ProvideAuth } from './context/auth';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import { ProvideBank } from './context/bank';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import './index.css';
|
||||
import AppRoutes from './AppRoutes';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import { client } from '../../types/gen/client.gen';
|
||||
import { getConfig } from '../../types/gen/sdk.gen';
|
||||
import { getToken } from './Utils';
|
||||
import { toast } from 'react-toastify';
|
||||
import * as Sentry from '@sentry/react';
|
||||
|
||||
client.setConfig({
|
||||
auth: () => getToken(),
|
||||
baseUrl: '/api', // openapi-ts si to z nějakého důvodu neumí převzít z api.yml
|
||||
});
|
||||
|
||||
// Sentry se inicializuje až podle runtime konfigurace ze serveru (bez DSN zůstává vypnuté).
|
||||
// Klient je statický build, takže DSN nejde zapéct při buildu — server ho zná z env.
|
||||
getConfig().then(({ data }) => {
|
||||
if (data?.sentry.dsn) {
|
||||
Sentry.init({
|
||||
dsn: data.sentry.dsn,
|
||||
environment: data.sentry.environment,
|
||||
});
|
||||
}
|
||||
}).catch(() => { /* config endpoint nedostupný — běžíme bez Sentry */ });
|
||||
|
||||
// Interceptor na vyhození toasteru při chybě
|
||||
client.interceptors.response.use(async response => {
|
||||
// TODO opravit - login je zatím výjimka, voláme ho "naprázdno" abychom zjistili, zda nás nepřihlásily trusted headers
|
||||
if (!response.ok && !response.url.includes("/login")) {
|
||||
const json = await response.json();
|
||||
toast.error(json.error, { theme: "colored" });
|
||||
// Serverové chyby hlásíme do Sentry; 4xx jsou očekávané (chyby uživatele)
|
||||
if (response.status >= 500) {
|
||||
Sentry.captureMessage(`API ${response.status}: ${new URL(response.url).pathname} — ${json.error}`, 'error');
|
||||
}
|
||||
}
|
||||
return response;
|
||||
});
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<Sentry.ErrorBoundary fallback={
|
||||
<div className="text-center p-5">
|
||||
<h4>Něco se pokazilo 😕</h4>
|
||||
<p>Chyba byla nahlášena. Zkuste stránku načíst znovu.</p>
|
||||
<button className="btn btn-primary" onClick={() => window.location.reload()}>Načíst znovu</button>
|
||||
</div>
|
||||
}>
|
||||
<BrowserRouter>
|
||||
<ProvideAuth>
|
||||
<AppRoutes />
|
||||
<ProvideBank>
|
||||
<SocketContext.Provider value={socket}>
|
||||
<App />
|
||||
<ToastContainer />
|
||||
</SocketContext.Provider>
|
||||
</ProvideBank>
|
||||
</ProvideAuth>
|
||||
</BrowserRouter>
|
||||
</Sentry.ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -1,898 +0,0 @@
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Badge, Button, Card, Form, Modal, OverlayTrigger, Table, Tooltip } from 'react-bootstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTrashCan } from '@fortawesome/free-regular-svg-icons';
|
||||
import { faBasketShopping, faChevronLeft, faChevronRight, faCircleCheck, faClockRotateLeft, faGear, faLock, faLockOpen, faPen, faSearch, faUserPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import DatePicker, { registerLocale } from 'react-datepicker';
|
||||
import { cs } from 'date-fns/locale';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import {
|
||||
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr,
|
||||
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setBoltTracking, getOrderDates,
|
||||
} from '../../../types';
|
||||
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
|
||||
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
|
||||
import { useAuth } from '../context/auth';
|
||||
import { useSettings } from '../context/settings';
|
||||
import { formatDate, formatDateString } from '../Utils';
|
||||
import Login from '../Login';
|
||||
import Header from '../components/Header';
|
||||
import Footer from '../components/Footer';
|
||||
import Loader from '../components/Loader';
|
||||
import StoreAdminModal from '../components/modals/StoreAdminModal';
|
||||
import PayForGroupModal from '../components/modals/PayForGroupModal';
|
||||
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
|
||||
import BoltSimulationModal from '../components/modals/BoltSimulationModal';
|
||||
import PendingPayments from '../components/PendingPayments';
|
||||
import BoltOrderProgress from '../components/BoltOrderProgress';
|
||||
|
||||
const SLOT = MealSlot.EXTRA;
|
||||
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
const BOLT_SHARE_URL_PREFIX = 'https://food.bolt.eu/sharedActiveOrder/';
|
||||
const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i;
|
||||
const IS_DEV = process.env.NODE_ENV === 'development';
|
||||
|
||||
/** Vytáhne sledovací token ze sdílecí URL Bolt Food, nebo přijme samotný token. Null = neplatný vstup. */
|
||||
function extractBoltToken(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
if (BOLT_TOKEN_REGEX.test(trimmed)) return trimmed;
|
||||
try {
|
||||
const segments = new URL(trimmed).pathname.split('/').filter(Boolean);
|
||||
const last = segments[segments.length - 1];
|
||||
return last && BOLT_TOKEN_REGEX.test(last) ? last : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Zkrátí dlouhý odkaz pro zobrazení v řádku (zachová začátek i konec). */
|
||||
function shortenUrl(url: string, max = 48): string {
|
||||
if (url.length <= max) return url;
|
||||
const head = Math.ceil((max - 1) / 2);
|
||||
const tail = Math.floor((max - 1) / 2);
|
||||
return `${url.slice(0, head)}…${url.slice(url.length - tail)}`;
|
||||
}
|
||||
|
||||
// Český lokál pro date picker (názvy měsíců/dnů, pondělí jako první den)
|
||||
registerLocale('cs', cs);
|
||||
|
||||
/** Vrátí ISO datum (YYYY-MM-DD) posunuté o předaný počet dní. */
|
||||
function shiftIsoDate(iso: string, days: number): string {
|
||||
const date = new Date(`${iso}T00:00:00`);
|
||||
date.setDate(date.getDate() + days);
|
||||
return formatDate(date);
|
||||
}
|
||||
|
||||
/** Převede ISO datum (YYYY-MM-DD) na lokální Date (půlnoc), nebo null. */
|
||||
function isoToDate(iso?: string): Date | null {
|
||||
return iso ? new Date(`${iso}T00:00:00`) : null;
|
||||
}
|
||||
|
||||
function stateBadge(state: GroupState) {
|
||||
const map: Record<GroupState, { bg: string; label: string }> = {
|
||||
[GroupState.OPEN]: { bg: 'success', label: 'Otevřeno' },
|
||||
[GroupState.LOCKED]: { bg: 'warning', label: 'Uzamčeno' },
|
||||
[GroupState.ORDERED]: { bg: 'secondary', label: 'Objednáno' },
|
||||
};
|
||||
const { bg, label } = map[state] ?? { bg: 'light', label: state };
|
||||
return <Badge bg={bg}>{label}</Badge>;
|
||||
}
|
||||
|
||||
export default function OrderGroupsPage() {
|
||||
const auth = useAuth();
|
||||
const settings = useSettings();
|
||||
const socket = useContext(SocketContext);
|
||||
const [data, setData] = useState<ClientData | undefined>();
|
||||
const [failure, setFailure] = useState(false);
|
||||
// Vybrané datum pro zobrazení historie (undefined = aktuální den)
|
||||
const [selectedDate, setSelectedDate] = useState<string | undefined>();
|
||||
// ISO datum dnešního dne dle serveru (horní hranice navigace), zjištěné při prvním načtení
|
||||
const [todayIso, setTodayIso] = useState<string | undefined>();
|
||||
// Ref pro socket handler – aby věděl, zda se zobrazuje historie (na ní se živé aktualizace neaplikují)
|
||||
const selectedDateRef = useRef<string | undefined>(undefined);
|
||||
// ISO data dnů, ve kterých existuje aspoň jedna objednávka (pro zvýraznění v date pickeru)
|
||||
const [orderDates, setOrderDates] = useState<string[]>([]);
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [adminModalOpen, setAdminModalOpen] = useState(false);
|
||||
const [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
|
||||
const [editNotes, setEditNotes] = useState<Record<string, string>>({});
|
||||
const [editSurcharges, setEditSurcharges] = useState<Record<string, { text: string; amount: string }>>({});
|
||||
const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string; boltUrl: string }>>({});
|
||||
const [payModal, setPayModal] = useState<OrderGroup | null>(null);
|
||||
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
|
||||
const [boltSimModal, setBoltSimModal] = useState<OrderGroup | null>(null);
|
||||
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
|
||||
const [pageError, setPageError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = async (date?: string) => {
|
||||
try {
|
||||
const r = await getData({ query: { slot: SLOT, date } });
|
||||
if (r.data) {
|
||||
setData(r.data);
|
||||
// Při zobrazení aktuálního dne si zapamatujeme dnešní ISO datum jako horní hranici navigace
|
||||
if (!date && r.data.isoDate) setTodayIso(r.data.isoDate);
|
||||
}
|
||||
} catch {
|
||||
setFailure(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Načte dny s objednávkou pro zvýraznění v date pickeru
|
||||
const fetchOrderDates = async () => {
|
||||
const r = await getOrderDates();
|
||||
if (r.data?.dates) setOrderDates(r.data.dates);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
selectedDateRef.current = selectedDate;
|
||||
}, [selectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth?.login) return;
|
||||
fetchData(selectedDate);
|
||||
}, [auth?.login, selectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth?.login) return;
|
||||
fetchOrderDates();
|
||||
}, [auth?.login]);
|
||||
|
||||
useEffect(() => {
|
||||
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
|
||||
// Živé aktualizace se týkají vždy dneška – při zobrazení historie je ignorujeme
|
||||
if (selectedDateRef.current) return;
|
||||
if (newData.slot === SLOT) setData(prev => ({
|
||||
...newData,
|
||||
stores: newData.stores ?? prev?.stores,
|
||||
}));
|
||||
});
|
||||
// Nová nevyřízená platba (QR kód) – připojíme do dat, aby se zobrazila i bez znovunačtení stránky
|
||||
socket.on(EVENT_PENDING_QR, (pendingQr: PendingQr) => {
|
||||
if (selectedDateRef.current) return;
|
||||
setData(prev => prev ? { ...prev, pendingQrs: [...(prev.pendingQrs ?? []), pendingQr] } : prev);
|
||||
});
|
||||
return () => { socket.off(EVENT_MESSAGE); socket.off(EVENT_PENDING_QR); };
|
||||
}, [socket]);
|
||||
|
||||
// Připojení do osobní socket místnosti po přihlášení – bez toho nechodí události
|
||||
// o nových nevyřízených platbách (QR kódy se posílají do místnosti user:<login>)
|
||||
useEffect(() => {
|
||||
if (auth?.login) {
|
||||
socket.emit('join', auth.login);
|
||||
}
|
||||
}, [auth?.login, socket]);
|
||||
|
||||
useEffect(() => {
|
||||
// Po znovupřipojení socketu znovu vstoupíme do osobní místnosti a načteme aktuálně
|
||||
// zobrazený den (mohli jsme přijít o živé aktualizace)
|
||||
const onReconnect = () => {
|
||||
if (auth?.login) socket.emit('join', auth.login);
|
||||
fetchData(selectedDateRef.current);
|
||||
};
|
||||
socket.io.on('reconnect', onReconnect);
|
||||
return () => { socket.io.off('reconnect', onReconnect); };
|
||||
}, [socket, auth?.login]);
|
||||
|
||||
// Navigace mezi dny pomocí klávesových šipek (←/→), obdobně jako na hlavní stránce
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
// Ignorujeme, pokud uživatel právě píše do formulářového pole
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
|
||||
const currentIso = data?.isoDate;
|
||||
if (!currentIso) return;
|
||||
if (e.keyCode === 37) {
|
||||
// Předchozí den – do minulosti bez omezení
|
||||
setSelectedDate(shiftIsoDate(currentIso, -1));
|
||||
} else if (e.keyCode === 39 && todayIso != null && currentIso < todayIso) {
|
||||
// Následující den – nejvýše po dnešek (na dnešek přes undefined kvůli živým aktualizacím)
|
||||
const target = shiftIsoDate(currentIso, 1);
|
||||
setSelectedDate(target >= todayIso ? undefined : target);
|
||||
}
|
||||
}, [data?.isoDate, todayIso]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const refresh = async (fn: () => Promise<any>): Promise<boolean> => {
|
||||
setPageError(null);
|
||||
const result = await fn();
|
||||
if (result?.error) {
|
||||
setPageError((result.error as any).error || 'Nastala chyba');
|
||||
await fetchData();
|
||||
return false;
|
||||
}
|
||||
if (result?.data) {
|
||||
setData(result.data);
|
||||
socket.emit?.('message', result.data as ClientData);
|
||||
}
|
||||
// Sada dnů s objednávkou se mohla změnit (vytvoření/smazání skupiny)
|
||||
fetchOrderDates();
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newGroupName || !auth?.login) return;
|
||||
setCreating(true);
|
||||
const ok = await refresh(() => createGroup({ body: { name: newGroupName } }));
|
||||
if (ok) setNewGroupName('');
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
const handleJoin = (groupId: string) =>
|
||||
refresh(() => addGroupMember({ body: { id: groupId } }));
|
||||
|
||||
const handleToggleLock = (group: OrderGroup) => {
|
||||
const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN;
|
||||
return refresh(() => setGroupState({ body: { id: group.id, state: next } }));
|
||||
};
|
||||
|
||||
const handleConfirmOrdered = async (group: OrderGroup) => {
|
||||
setConfirmOrderGroup(null);
|
||||
await refresh(() => setGroupState({ body: { id: group.id, state: GroupState.ORDERED } }));
|
||||
};
|
||||
|
||||
const handleRevertOrdered = (group: OrderGroup) =>
|
||||
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.LOCKED } }));
|
||||
|
||||
const handleDelete = (groupId: string) =>
|
||||
refresh(() => deleteGroup({ body: { id: groupId } }));
|
||||
|
||||
const handleSaveAmount = async (groupId: string, login: string) => {
|
||||
const key = `${groupId}:${login}`;
|
||||
const raw = editAmounts[key];
|
||||
const n = parseFloat(raw ?? '');
|
||||
if (!raw || isNaN(n) || n < 0) {
|
||||
setPageError('Zadejte platnou kladnou částku');
|
||||
return;
|
||||
}
|
||||
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, amount: Math.round(n * 100) } }));
|
||||
if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||
};
|
||||
|
||||
const handleSaveNote = async (groupId: string, login: string) => {
|
||||
const key = `${groupId}:${login}`;
|
||||
const note = editNotes[key] ?? '';
|
||||
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, note } }));
|
||||
if (ok) setEditNotes(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||
};
|
||||
|
||||
const handleSaveSurcharge = async (groupId: string, login: string) => {
|
||||
const key = `${groupId}:${login}`;
|
||||
const surchargeText = editSurcharges[key]?.text ?? '';
|
||||
const rawAmount = editSurcharges[key]?.amount ?? '';
|
||||
const surchargeAmount = rawAmount === '' ? 0 : parseFloat(rawAmount.replace(',', '.'));
|
||||
if (rawAmount !== '' && (isNaN(surchargeAmount) || surchargeAmount < 0)) {
|
||||
setPageError('Zadejte platnou výši příplatku');
|
||||
return;
|
||||
}
|
||||
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, surchargeText, surchargeAmount: rawAmount === '' ? 0 : Math.round(surchargeAmount * 100) } }));
|
||||
if (ok) setEditSurcharges(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||
};
|
||||
|
||||
const handleSaveTimes = async (group: OrderGroup) => {
|
||||
const times = editTimes[group.id];
|
||||
if (!times) return;
|
||||
const { orderedAt, deliveryAt, boltUrl } = times;
|
||||
if (orderedAt && !TIME_REGEX.test(orderedAt)) {
|
||||
setPageError('Čas objednání musí být ve formátu HH:MM');
|
||||
return;
|
||||
}
|
||||
if (deliveryAt && !TIME_REGEX.test(deliveryAt)) {
|
||||
setPageError('Čas doručení musí být ve formátu HH:MM');
|
||||
return;
|
||||
}
|
||||
// Bolt odkaz se odesílá jen při změně oproti aktuálnímu tokenu skupiny
|
||||
const boltToken = boltUrl.trim() ? extractBoltToken(boltUrl) : null;
|
||||
if (boltUrl.trim() && !boltToken) {
|
||||
setPageError('Neplatný odkaz Bolt (očekávána URL sdílení objednávky)');
|
||||
return;
|
||||
}
|
||||
const boltChanged = (boltToken ?? undefined) !== group.boltTrackingToken;
|
||||
let ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
|
||||
if (ok && boltChanged) {
|
||||
ok = await refresh(() => setBoltTracking({ body: { id: group.id, shareUrl: boltUrl.trim() } }));
|
||||
}
|
||||
if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; });
|
||||
};
|
||||
|
||||
// Pozn.: tyto funkce se volají až v renderu, kde je k dispozici `selectedDate`.
|
||||
// Historie (jiný než aktuální den) je vždy read-only.
|
||||
const canEditMember = (group: OrderGroup, targetLogin: string) => {
|
||||
if (selectedDate) return false;
|
||||
if (group.state === GroupState.ORDERED) return false;
|
||||
if (auth?.login === group.creatorLogin) return true;
|
||||
if (auth?.login === targetLogin && group.state === GroupState.OPEN) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const canManageMembers = (group: OrderGroup) => {
|
||||
if (selectedDate) return false;
|
||||
if (group.state === GroupState.ORDERED) return false;
|
||||
if (auth?.login === group.creatorLogin) return true;
|
||||
return group.state === GroupState.OPEN;
|
||||
};
|
||||
|
||||
if (!auth?.login) return <Login />;
|
||||
|
||||
if (failure) return (
|
||||
<Loader icon={faSearch} description="Nepodařilo se načíst data" animation="fa-beat" />
|
||||
);
|
||||
|
||||
if (!data) return (
|
||||
<Loader icon={faSearch} description="Načítám..." animation="fa-bounce" />
|
||||
);
|
||||
|
||||
const stores = data.stores ?? [];
|
||||
const groups = data.groups ?? [];
|
||||
|
||||
// Zobrazené datum a režim historie (vše read-only, pokud nejde o aktuální den)
|
||||
const displayedIso = data.isoDate;
|
||||
const isToday = !selectedDate || (todayIso != null && displayedIso === todayIso);
|
||||
const isReadOnly = !isToday;
|
||||
const canGoNext = todayIso != null && displayedIso != null && displayedIso < todayIso;
|
||||
|
||||
const goToDay = (offset: number) => {
|
||||
if (!displayedIso) return;
|
||||
const target = shiftIsoDate(displayedIso, offset);
|
||||
// Na dnešek (či dál) se vracíme přes undefined, aby se obnovily živé aktualizace
|
||||
setSelectedDate(todayIso != null && target >= todayIso ? undefined : target);
|
||||
};
|
||||
|
||||
const handleDatePick = (value: string) => {
|
||||
if (!value) return;
|
||||
setSelectedDate(todayIso != null && value >= todayIso ? undefined : value);
|
||||
};
|
||||
|
||||
// Dny s objednávkou jako Date objekty pro zvýraznění v kalendáři
|
||||
const highlightedOrderDates = orderDates
|
||||
.map(d => isoToDate(d))
|
||||
.filter((d): d is Date => d != null);
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Header choices={data.choices} />
|
||||
<div className="wrapper">
|
||||
<div className="d-flex align-items-center justify-content-between mb-1">
|
||||
<h1 className="title mb-0">Objednání</h1>
|
||||
<Button variant="outline-primary" size="sm" onClick={() => setAdminModalOpen(true)} title="Správa obchodů">
|
||||
<FontAwesomeIcon icon={faGear} className="me-1" />
|
||||
Obchody
|
||||
</Button>
|
||||
</div>
|
||||
<p style={{ color: 'var(--luncher-text-muted)' }}>Skupinové objednávky z obchodů a restaurací</p>
|
||||
|
||||
{/* Navigace mezi dny – šipky kolem výběru data (i klávesami ←/→) */}
|
||||
<div className="day-navigator order-day-navigator">
|
||||
<span title="Předchozí den">
|
||||
<FontAwesomeIcon icon={faChevronLeft} onClick={() => goToDay(-1)} />
|
||||
</span>
|
||||
<DatePicker
|
||||
selected={isoToDate(displayedIso)}
|
||||
onChange={(d: Date | null) => handleDatePick(d ? formatDate(d) : '')}
|
||||
maxDate={isoToDate(todayIso) ?? undefined}
|
||||
highlightDates={[{ 'luncher-order-day': highlightedOrderDates }]}
|
||||
locale="cs"
|
||||
dateFormat="d. M. yyyy"
|
||||
calendarStartDay={1}
|
||||
popperPlacement="bottom"
|
||||
className={`form-control text-center fw-semibold order-date-input ${isReadOnly ? 'text-muted' : ''}`}
|
||||
/>
|
||||
<span title="Následující den">
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
style={{ visibility: canGoNext ? 'visible' : 'hidden' }}
|
||||
onClick={() => canGoNext && goToDay(1)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isReadOnly && (
|
||||
<Alert variant="secondary" className="d-flex align-items-center gap-2 py-2">
|
||||
<FontAwesomeIcon icon={faClockRotateLeft} />
|
||||
<span>
|
||||
Prohlížíte historii ze dne <strong>{displayedIso ? formatDateString(displayedIso) : data.date}</strong> – data jsou pouze pro čtení.
|
||||
</span>
|
||||
<Button variant="link" size="sm" className="p-0 ms-auto" onClick={() => setSelectedDate(undefined)}>
|
||||
Zpět na dnešek
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{pageError && (
|
||||
<Alert variant="danger" dismissible onClose={() => setPageError(null)} className="mt-2">
|
||||
{pageError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="content-wrapper">
|
||||
<div className="content" style={{ maxWidth: 1200 }}>
|
||||
{/* Vytvoření nové skupiny – pouze pro aktuální den */}
|
||||
{!isReadOnly && (
|
||||
<div className="choice-section fade-in mb-4">
|
||||
<h5>Vytvořit skupinu</h5>
|
||||
{stores.length === 0 ? (
|
||||
<p className="text-muted">
|
||||
Nejsou přidány žádné obchody.{' '}
|
||||
<Button variant="link" size="sm" className="p-0" onClick={() => setAdminModalOpen(true)}>
|
||||
Přidat obchod
|
||||
</Button>
|
||||
</p>
|
||||
) : (
|
||||
<div className="d-flex gap-2 align-items-center flex-wrap">
|
||||
<Form.Select
|
||||
value={newGroupName}
|
||||
onChange={e => setNewGroupName(e.target.value)}
|
||||
style={{ maxWidth: 260 }}
|
||||
>
|
||||
<option value="">— vyberte obchod —</option>
|
||||
{stores.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
|
||||
</Form.Select>
|
||||
<Button variant="primary" onClick={handleCreate} disabled={creating || !newGroupName}>
|
||||
Vytvořit skupinu
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Seznam skupin */}
|
||||
{groups.length === 0 && (
|
||||
<p className="text-muted fade-in">
|
||||
{isReadOnly ? 'Pro tento den nejsou žádné skupiny.' : 'Zatím žádné skupiny pro dnešní den.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{groups.map(group => {
|
||||
const login = auth!.login ?? '';
|
||||
const isCreator = login === group.creatorLogin;
|
||||
const isMember = login in group.members;
|
||||
const isOrdered = group.state === GroupState.ORDERED;
|
||||
const isLocked = group.state === GroupState.LOCKED;
|
||||
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
|
||||
const editingTimes = group.id in editTimes;
|
||||
// URL na nabídku podniku (pokud ji má dohledatelný obchod vyplněnou).
|
||||
// Povolíme jen http(s), aby odkaz nemohl být zneužit (např. javascript:).
|
||||
const rawStoreUrl = stores.find(s => s.name === group.name)?.url;
|
||||
const storeUrl = rawStoreUrl && /^https?:\/\//i.test(rawStoreUrl) ? rawStoreUrl : undefined;
|
||||
|
||||
const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
|
||||
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
|
||||
const activeCount = countActiveMembers(group.members);
|
||||
const feeShare = computeFeeShare(totalFees, activeCount);
|
||||
const feeParams = { totalFees, discountType: group.discountType, discountValue: group.discountValue ?? 0 };
|
||||
const getMemberTotal = (m: OrderGroupMember) =>
|
||||
computeMemberTotal(m, feeParams, feeShare, activeCount);
|
||||
|
||||
return (
|
||||
<Card key={group.id} className="mb-3 fade-in">
|
||||
<Card.Header className="d-flex justify-content-between align-items-center">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
{storeUrl ? (
|
||||
<strong>
|
||||
<a href={storeUrl} target="_blank" rel="noopener noreferrer" title="Otevřít nabídku v nové záložce">
|
||||
{group.name}
|
||||
</a>
|
||||
</strong>
|
||||
) : (
|
||||
<strong>{group.name}</strong>
|
||||
)}
|
||||
{stateBadge(group.state)}
|
||||
<small className="text-muted">zakladatel: {group.creatorLogin}</small>
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
{!isReadOnly && isCreator && !isOrdered && (
|
||||
<>
|
||||
<Button variant="outline-info" size="sm" onClick={() => setFeesModal(group)} title="Upravit poplatky a slevu">
|
||||
Poplatky
|
||||
</Button>
|
||||
<Button variant="outline-secondary" size="sm" onClick={() => handleToggleLock(group)} title={isLocked ? 'Odemknout' : 'Uzamknout'}>
|
||||
<FontAwesomeIcon icon={isLocked ? faLockOpen : faLock} />
|
||||
</Button>
|
||||
{isLocked && (
|
||||
<Button variant="outline-primary" size="sm" onClick={() => setConfirmOrderGroup(group)}>
|
||||
Objednáno
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline-danger" size="sm" onClick={() => handleDelete(group.id)} title="Smazat skupinu">
|
||||
<FontAwesomeIcon icon={faTrashCan} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!isReadOnly && isCreator && isOrdered && (
|
||||
<>
|
||||
{settings?.bankAccount && settings?.holderName && !group.qrGenerated && (
|
||||
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
|
||||
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
|
||||
Generovat QR
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline-warning" size="sm" onClick={() => handleRevertOrdered(group)} title="Vrátit na Uzamčeno (smaže QR kódy)">
|
||||
<FontAwesomeIcon icon={faLockOpen} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!isReadOnly && !isMember && !isOrdered && !isLocked && (
|
||||
<Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}>
|
||||
<FontAwesomeIcon icon={faUserPlus} className="me-1" />
|
||||
Přidat se
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Body className="p-0">
|
||||
<Table className="mb-0" size="sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Člen</th>
|
||||
<th style={{ width: 180 }}>Částka (bez slev)</th>
|
||||
<th style={{ width: 220 }}>Příplatek</th>
|
||||
<th>Poznámka</th>
|
||||
<th style={{ width: 160 }}>Celkem (s poplatky)</th>
|
||||
<th style={{ width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{memberEntries.map(([memberLogin, member]) => {
|
||||
const key = `${group.id}:${memberLogin}`;
|
||||
const editingAmount = key in editAmounts;
|
||||
const editingNote = key in editNotes;
|
||||
const editingSurcharge = key in editSurcharges;
|
||||
const canEdit = canEditMember(group, memberLogin);
|
||||
const memberTotal = getMemberTotal(member);
|
||||
return (
|
||||
<tr key={memberLogin}>
|
||||
<td>
|
||||
<span className="user-info">
|
||||
<strong>{memberLogin}</strong>
|
||||
{memberLogin === group.creatorLogin && (
|
||||
<OverlayTrigger placement="top" overlay={<Tooltip>Zakladatel / objednávající</Tooltip>}>
|
||||
<span className="ms-1"><FontAwesomeIcon icon={faBasketShopping} className="buyer-icon" /></span>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
{member.paid && (
|
||||
<OverlayTrigger placement="top" overlay={<Tooltip>Zaplaceno</Tooltip>}>
|
||||
<span className="ms-1"><FontAwesomeIcon icon={faCircleCheck} className="text-success" /></span>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{canEdit && editingAmount ? (
|
||||
<div className="d-flex gap-1">
|
||||
<Form.Control
|
||||
type="number"
|
||||
size="sm"
|
||||
value={editAmounts[key]}
|
||||
onChange={e => setEditAmounts(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveAmount(group.id, memberLogin); if (e.key === 'Escape') setEditAmounts(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
|
||||
style={{ width: 95 }}
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" variant="outline-success" onClick={() => handleSaveAmount(group.id, memberLogin)}>✓</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
||||
onClick={() => canEdit && setEditAmounts(prev => ({ ...prev, [key]: member.amount != null ? String(member.amount / 100) : '' }))}
|
||||
title={canEdit ? 'Klikněte pro úpravu' : undefined}
|
||||
>
|
||||
{member.amount != null ? `${member.amount / 100} Kč` : <span className="text-muted">—</span>}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{canEdit && editingSurcharge ? (
|
||||
<div className="d-flex gap-1">
|
||||
<Form.Control
|
||||
type="text"
|
||||
size="sm"
|
||||
placeholder="popis"
|
||||
value={editSurcharges[key]?.text ?? ''}
|
||||
onChange={e => setEditSurcharges(prev => ({ ...prev, [key]: { ...prev[key], text: e.target.value } }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveSurcharge(group.id, memberLogin); if (e.key === 'Escape') setEditSurcharges(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
|
||||
style={{ width: 80 }}
|
||||
autoFocus
|
||||
/>
|
||||
<Form.Control
|
||||
type="number"
|
||||
size="sm"
|
||||
placeholder="Kč"
|
||||
value={editSurcharges[key]?.amount ?? ''}
|
||||
onChange={e => setEditSurcharges(prev => ({ ...prev, [key]: { ...prev[key], amount: e.target.value } }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveSurcharge(group.id, memberLogin); if (e.key === 'Escape') setEditSurcharges(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
<Button size="sm" variant="outline-success" onClick={() => handleSaveSurcharge(group.id, memberLogin)}>✓</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
||||
onClick={() => canEdit && setEditSurcharges(prev => ({ ...prev, [key]: { text: member.surchargeText ?? '', amount: member.surchargeAmount != null ? String(member.surchargeAmount / 100) : '' } }))}
|
||||
title={canEdit ? 'Klikněte pro úpravu příplatku' : undefined}
|
||||
>
|
||||
{member.surchargeAmount != null && member.surchargeAmount > 0 ? (
|
||||
<small>{member.surchargeText ? `${member.surchargeText}: ` : ''}<strong>{member.surchargeAmount / 100} Kč</strong></small>
|
||||
) : (
|
||||
<small className="text-muted">—</small>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{canEdit && editingNote ? (
|
||||
<div className="d-flex gap-1">
|
||||
<Form.Control
|
||||
type="text"
|
||||
size="sm"
|
||||
value={editNotes[key]}
|
||||
onChange={e => setEditNotes(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveNote(group.id, memberLogin); if (e.key === 'Escape') setEditNotes(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" variant="outline-success" onClick={() => handleSaveNote(group.id, memberLogin)}>✓</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
||||
onClick={() => canEdit && setEditNotes(prev => ({ ...prev, [key]: member.note ?? '' }))}
|
||||
title={canEdit ? 'Klikněte pro úpravu poznámky' : undefined}
|
||||
>
|
||||
<small className="text-muted">{member.note || '—'}</small>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
|
||||
{memberTotal > 0 ? `${memberTotal / 100} Kč` : '—'}
|
||||
</small>
|
||||
</td>
|
||||
<td className="align-middle">
|
||||
<div className="d-flex gap-1 justify-content-end align-items-center">
|
||||
{canManageMembers(group) && (isCreator || memberLogin === login) && (memberLogin !== group.creatorLogin) && (
|
||||
<FontAwesomeIcon
|
||||
icon={faTrashCan}
|
||||
className="action-icon"
|
||||
title={memberLogin === login ? 'Odhlásit se' : 'Odebrat z skupiny'}
|
||||
onClick={() => refresh(() => removeGroupMember({ body: { id: group.id, login: memberLogin } }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{(() => {
|
||||
const sumBase = memberEntries.reduce((sum, [, m]) => sum + (m.amount ?? 0) + (m.surchargeAmount ?? 0), 0);
|
||||
const dv = group.discountValue ?? 0;
|
||||
const totalDiscount = dv > 0
|
||||
? (group.discountType === 'percent' ? Math.round(sumBase * dv / 100) : dv)
|
||||
: 0;
|
||||
const groupTotal = sumBase + totalFees - totalDiscount;
|
||||
return groupTotal > 0 ? (
|
||||
<tfoot>
|
||||
<tr style={{ fontWeight: 700, borderTop: '2px solid var(--luncher-border)' }}>
|
||||
<td colSpan={4} className="text-end" style={{ fontSize: '0.9em' }}>Celkem za skupinu:</td>
|
||||
<td className="text-end">{groupTotal / 100} Kč</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
) : null;
|
||||
})()}
|
||||
</Table>
|
||||
|
||||
{/* Souhrn poplatků a slevy */}
|
||||
{(totalFees > 0 || (group.discountValue != null && group.discountValue > 0)) && (
|
||||
<div className="px-3 py-2 border-top d-flex gap-3 flex-wrap" style={{ fontSize: '0.85em', color: 'var(--luncher-text-muted)' }}>
|
||||
{group.fees != null && group.fees > 0 && <span>Poplatky: <strong>{group.fees / 100} Kč</strong></span>}
|
||||
{group.shipping != null && group.shipping > 0 && <span>Doprava: <strong>{group.shipping / 100} Kč</strong></span>}
|
||||
{group.tip != null && group.tip > 0 && <span>Spropitné: <strong>{group.tip / 100} Kč</strong></span>}
|
||||
{feeShare > 0 && <span>→ <strong>{feeShare / 100} Kč</strong>/os.</span>}
|
||||
{group.discountValue != null && group.discountValue > 0 && (
|
||||
<span className="text-success">
|
||||
Sleva: <strong>{group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100} Kč`}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Časy objednání a doručení */}
|
||||
{isOrdered && (
|
||||
<div className="px-3 py-2 border-top">
|
||||
{!isReadOnly && isCreator && editingTimes ? (
|
||||
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||||
<div className="d-flex align-items-center gap-1">
|
||||
<small className="text-muted text-nowrap">Objednáno v:</small>
|
||||
<Form.Control
|
||||
type="text"
|
||||
size="sm"
|
||||
placeholder="HH:MM"
|
||||
value={editTimes[group.id]?.orderedAt ?? ''}
|
||||
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], orderedAt: e.target.value } }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
|
||||
style={{ width: 75 }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-1">
|
||||
<small className="text-muted text-nowrap">Doručení v:</small>
|
||||
<Form.Control
|
||||
type="text"
|
||||
size="sm"
|
||||
placeholder="HH:MM"
|
||||
value={editTimes[group.id]?.deliveryAt ?? ''}
|
||||
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], deliveryAt: e.target.value } }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
|
||||
style={{ width: 75 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-1">
|
||||
<small className="text-muted text-nowrap">Bolt odkaz pro sledování:</small>
|
||||
<Form.Control
|
||||
type="text"
|
||||
size="sm"
|
||||
placeholder={`${BOLT_SHARE_URL_PREFIX}…`}
|
||||
value={editTimes[group.id]?.boltUrl ?? ''}
|
||||
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], boltUrl: e.target.value } }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" variant="outline-success" onClick={() => handleSaveTimes(group)}>Uložit</Button>
|
||||
<Button size="sm" variant="outline-secondary" onClick={() => setEditTimes(prev => { const n = { ...prev }; delete n[group.id]; return n; })}>Zrušit</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||||
{(() => {
|
||||
const canEdit = !isReadOnly && isCreator;
|
||||
// Aktivace editačního režimu – stejné chování jako tlačítko s tužkou
|
||||
const startEdit = () => canEdit && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '', boltUrl: group.boltTrackingToken ? `${BOLT_SHARE_URL_PREFIX}${group.boltTrackingToken}` : '' } }));
|
||||
const trackingUrl = group.boltTrackingToken ? `${BOLT_SHARE_URL_PREFIX}${group.boltTrackingToken}` : null;
|
||||
return (
|
||||
<>
|
||||
{canEdit && (
|
||||
<FontAwesomeIcon
|
||||
icon={faPen}
|
||||
className="action-icon ms-0"
|
||||
title="Upravit časy a odkaz pro sledování"
|
||||
onClick={startEdit}
|
||||
/>
|
||||
)}
|
||||
<small
|
||||
className="text-muted"
|
||||
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
||||
onClick={startEdit}
|
||||
>
|
||||
Objednáno v: <strong>{group.orderedAt ?? '—'}</strong>
|
||||
</small>
|
||||
<small
|
||||
className="text-muted"
|
||||
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
||||
onClick={startEdit}
|
||||
>
|
||||
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
|
||||
{group.boltTrackingToken && (
|
||||
<OverlayTrigger overlay={<Tooltip>Čas doručení se aktualizuje automaticky z Bolt Food</Tooltip>}>
|
||||
<Badge bg="success" className="ms-1">Bolt</Badge>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</small>
|
||||
<small className="text-muted text-nowrap">
|
||||
URL pro sledování:{' '}
|
||||
{trackingUrl ? (
|
||||
<a
|
||||
href={trackingUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={trackingUrl}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{shortenUrl(trackingUrl)}
|
||||
</a>
|
||||
) : (
|
||||
<strong>—</strong>
|
||||
)}
|
||||
</small>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{group.boltOrderState && (
|
||||
<div className="mt-2">
|
||||
<BoltOrderProgress state={group.boltOrderState} courierState={group.boltCourierState} tracking={!!group.boltTrackingToken} />
|
||||
</div>
|
||||
)}
|
||||
{IS_DEV && (
|
||||
<div className="mt-2">
|
||||
<Button variant="outline-warning" size="sm" onClick={() => setBoltSimModal(group)} title="Simulovat sledování Bolt (DEV)">
|
||||
🔧 Simulace Bolt
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Nevyřízené platby přihlášeného uživatele – jen v režimu aktuálního dne */}
|
||||
{!isReadOnly && (
|
||||
<PendingPayments
|
||||
pendingQrs={data.pendingQrs}
|
||||
login={auth.login}
|
||||
onDismissed={() => fetchData()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
|
||||
{/* Potvrzovací dialog pro přechod do stavu Objednáno */}
|
||||
<Modal show={!!confirmOrderGroup} onHide={() => setConfirmOrderGroup(null)} centered>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Potvrdit objednání</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
Opravdu chcete označit skupinu <strong>{confirmOrderGroup?.name}</strong> jako objednanou?
|
||||
Tato akce uzavře skupinu a zaznamená čas objednání.
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={() => setConfirmOrderGroup(null)}>Zrušit</Button>
|
||||
<Button variant="primary" onClick={() => confirmOrderGroup && handleConfirmOrdered(confirmOrderGroup)}>
|
||||
Objednáno
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
|
||||
<StoreAdminModal
|
||||
isOpen={adminModalOpen}
|
||||
onClose={() => setAdminModalOpen(false)}
|
||||
stores={stores}
|
||||
onStoresChanged={updated => setData(prev => prev ? { ...prev, stores: updated } : prev)}
|
||||
/>
|
||||
|
||||
{payModal && settings?.bankAccount && settings?.holderName && (
|
||||
<PayForGroupModal
|
||||
isOpen={!!payModal}
|
||||
onClose={() => setPayModal(null)}
|
||||
onSuccess={() => fetchData()}
|
||||
group={payModal}
|
||||
groupId={payModal.id}
|
||||
payerLogin={auth.login}
|
||||
bankAccount={settings.bankAccount}
|
||||
bankAccountHolder={settings.holderName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{feesModal && (
|
||||
<EditGroupFeesModal
|
||||
isOpen={!!feesModal}
|
||||
onClose={() => setFeesModal(null)}
|
||||
group={feesModal}
|
||||
onSaved={newData => {
|
||||
if (newData) {
|
||||
setData(newData);
|
||||
socket.emit?.('message', newData as ClientData);
|
||||
}
|
||||
setFeesModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{IS_DEV && boltSimModal && (
|
||||
<BoltSimulationModal
|
||||
isOpen={!!boltSimModal}
|
||||
onClose={() => setBoltSimModal(null)}
|
||||
// živá skupina z dat (aktualizuje se přes websocket), fallback na snapshot
|
||||
group={groups.find(g => g.id === boltSimModal.id) ?? boltSimModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
.stats-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 32px 24px;
|
||||
min-height: calc(100vh - 140px);
|
||||
background: var(--luncher-bg);
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--luncher-text);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.week-navigator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
svg {
|
||||
font-size: 1.5rem;
|
||||
color: var(--luncher-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--luncher-bg-card);
|
||||
box-shadow: var(--luncher-shadow-sm);
|
||||
transition: var(--luncher-transition);
|
||||
|
||||
&:hover {
|
||||
color: var(--luncher-primary);
|
||||
background: var(--luncher-primary-light);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.date-range {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--luncher-text);
|
||||
min-width: 280px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Chart container
|
||||
.recharts-wrapper {
|
||||
background: var(--luncher-bg-card);
|
||||
border-radius: var(--luncher-radius-lg);
|
||||
box-shadow: var(--luncher-shadow);
|
||||
padding: 24px;
|
||||
border: 1px solid var(--luncher-border-light);
|
||||
}
|
||||
|
||||
// Chart text styling
|
||||
.recharts-cartesian-axis-tick-value {
|
||||
fill: var(--luncher-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.recharts-legend-item-text {
|
||||
color: var(--luncher-text) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.recharts-tooltip-wrapper {
|
||||
.recharts-default-tooltip {
|
||||
background: var(--luncher-bg-card) !important;
|
||||
border: 1px solid var(--luncher-border) !important;
|
||||
border-radius: var(--luncher-radius-sm) !important;
|
||||
box-shadow: var(--luncher-shadow-lg) !important;
|
||||
|
||||
.recharts-tooltip-label {
|
||||
color: var(--luncher-text) !important;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.recharts-tooltip-item {
|
||||
color: var(--luncher-text-secondary) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recharts-cartesian-grid-horizontal line,
|
||||
.recharts-cartesian-grid-vertical line {
|
||||
stroke: var(--luncher-border);
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Footer from "../components/Footer";
|
||||
import Header from "../components/Header";
|
||||
import { useAuth } from "../context/auth";
|
||||
import Login from "../Login";
|
||||
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils";
|
||||
import { WeeklyStats, LunchChoice, getStats } from "../../../types";
|
||||
import Loader from "../components/Loader";
|
||||
import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { getLunchChoiceName } from "../enums";
|
||||
import './StatsPage.scss';
|
||||
|
||||
const CHART_WIDTH = 1400;
|
||||
const CHART_HEIGHT = 700;
|
||||
const STROKE_WIDTH = 2.5;
|
||||
|
||||
const COLORS = [
|
||||
'#ff1493',
|
||||
'#1e90ff',
|
||||
'#c5a700',
|
||||
'#006400',
|
||||
'#b300ff',
|
||||
'#ff4500',
|
||||
'#bc8f8f',
|
||||
'#00ff00',
|
||||
'#7c7c7c',
|
||||
]
|
||||
|
||||
export default function StatsPage() {
|
||||
const auth = useAuth();
|
||||
const [dateRange, setDateRange] = useState<Date[]>();
|
||||
const [data, setData] = useState<WeeklyStats>();
|
||||
|
||||
// Prvotní nastavení aktuálního týdne
|
||||
useEffect(() => {
|
||||
const today = new Date();
|
||||
setDateRange([getFirstWorkDayOfWeek(today), getLastWorkDayOfWeek(today)]);
|
||||
}, []);
|
||||
|
||||
// Přenačtení pro zvolený týden
|
||||
useEffect(() => {
|
||||
if (dateRange) {
|
||||
getStats({ query: { startDate: formatDate(dateRange[0]), endDate: formatDate(dateRange[1]) } }).then(response => {
|
||||
setData(response.data);
|
||||
});
|
||||
}
|
||||
}, [dateRange]);
|
||||
|
||||
const renderLine = (location: LunchChoice) => {
|
||||
const index = Object.values(LunchChoice).indexOf(location);
|
||||
return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
|
||||
}
|
||||
|
||||
const handlePreviousWeek = () => {
|
||||
if (dateRange) {
|
||||
const previousStartDate = new Date(dateRange[0]);
|
||||
previousStartDate.setDate(previousStartDate.getDate() - 7);
|
||||
const previousEndDate = new Date(previousStartDate);
|
||||
previousEndDate.setDate(previousEndDate.getDate() + 4);
|
||||
setDateRange([previousStartDate, previousEndDate]);
|
||||
}
|
||||
}
|
||||
|
||||
const handleNextWeek = () => {
|
||||
if (dateRange) {
|
||||
const nextStartDate = new Date(dateRange[0]);
|
||||
nextStartDate.setDate(nextStartDate.getDate() + 7);
|
||||
const nextEndDate = new Date(nextStartDate);
|
||||
nextEndDate.setDate(nextEndDate.getDate() + 4);
|
||||
setDateRange([nextStartDate, nextEndDate]);
|
||||
}
|
||||
}
|
||||
|
||||
const isCurrentOrFutureWeek = useMemo(() => {
|
||||
if (!dateRange) return true;
|
||||
const currentWeekEnd = getLastWorkDayOfWeek(new Date());
|
||||
currentWeekEnd.setHours(23, 59, 59, 999);
|
||||
return dateRange[1] >= currentWeekEnd;
|
||||
}, [dateRange]);
|
||||
|
||||
const handleKeyDown = useCallback((e: any) => {
|
||||
if (e.keyCode === 37) {
|
||||
handlePreviousWeek();
|
||||
} else if (e.keyCode === 39 && !isCurrentOrFutureWeek) {
|
||||
handleNextWeek()
|
||||
}
|
||||
}, [dateRange, isCurrentOrFutureWeek]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
}, [handleKeyDown]);
|
||||
|
||||
if (!auth?.login) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
if (!dateRange) {
|
||||
return <Loader
|
||||
icon={faGear}
|
||||
description={'Načítám data...'}
|
||||
animation={'fa-bounce'}
|
||||
/>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<div className="stats-page">
|
||||
<h1>Statistiky</h1>
|
||||
<div className="week-navigator">
|
||||
<span title="Předchozí týden">
|
||||
<FontAwesomeIcon icon={faChevronLeft} style={{ cursor: "pointer" }} onClick={handlePreviousWeek} />
|
||||
</span>
|
||||
<h2 className="date-range">{getHumanDate(dateRange[0])} - {getHumanDate(dateRange[1])}</h2>
|
||||
<span title="Následující týden">
|
||||
<FontAwesomeIcon icon={faChevronRight} style={{ cursor: "pointer", visibility: isCurrentOrFutureWeek ? "hidden" : "visible" }} onClick={handleNextWeek} />
|
||||
</span>
|
||||
</div>
|
||||
<LineChart width={CHART_WIDTH} height={CHART_HEIGHT} data={data}>
|
||||
{Object.values(LunchChoice).map(location => renderLine(location))}
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</LineChart>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
.suggestions-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 32px 24px;
|
||||
min-height: calc(100vh - 140px);
|
||||
background: var(--luncher-bg);
|
||||
|
||||
.suggestions-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--luncher-text);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestions-info {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 12px 0 24px;
|
||||
color: var(--luncher-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.suggestions-empty {
|
||||
color: var(--luncher-text-secondary);
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.resolved-section {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin-top: 48px;
|
||||
|
||||
h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--luncher-text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestions-table.resolved {
|
||||
th {
|
||||
background: var(--luncher-text-secondary);
|
||||
}
|
||||
|
||||
td.col-score {
|
||||
color: var(--luncher-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.suggestions-table {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
background: var(--luncher-bg-card);
|
||||
border-radius: var(--luncher-radius-lg);
|
||||
box-shadow: var(--luncher-shadow);
|
||||
border: 1px solid var(--luncher-border-light);
|
||||
overflow: hidden;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
background: var(--luncher-primary);
|
||||
color: #ffffff;
|
||||
padding: 12px 20px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--luncher-border-light);
|
||||
color: var(--luncher-text);
|
||||
font-size: 0.9rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.col-score {
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
td.col-score {
|
||||
color: var(--luncher-primary);
|
||||
}
|
||||
|
||||
.col-actions {
|
||||
text-align: center;
|
||||
width: 150px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
transition: var(--luncher-transition);
|
||||
|
||||
&:hover {
|
||||
background: var(--luncher-bg-hover);
|
||||
}
|
||||
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.vote-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--luncher-radius-sm, 6px);
|
||||
color: var(--luncher-text-secondary);
|
||||
transition: var(--luncher-transition);
|
||||
|
||||
&:hover {
|
||||
background: var(--luncher-bg-hover);
|
||||
color: var(--luncher-text);
|
||||
}
|
||||
|
||||
&.vote-up.active {
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
&.vote-down.active {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
&.delete-btn:hover {
|
||||
color: #c62828;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button, OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faThumbsUp, faThumbsDown, faTrash, faPlus, faGear } from "@fortawesome/free-solid-svg-icons";
|
||||
import Header from "../components/Header";
|
||||
import Footer from "../components/Footer";
|
||||
import Loader from "../components/Loader";
|
||||
import { useAuth } from "../context/auth";
|
||||
import Login from "../Login";
|
||||
import AddSuggestionModal from "../components/modals/AddSuggestionModal";
|
||||
import SuggestionDetailModal from "../components/modals/SuggestionDetailModal";
|
||||
import {
|
||||
Suggestion,
|
||||
VoteDirection,
|
||||
listSuggestions,
|
||||
addSuggestion,
|
||||
voteSuggestion,
|
||||
deleteSuggestion,
|
||||
} from "../../../types";
|
||||
import "./SuggestionsPage.scss";
|
||||
|
||||
export default function SuggestionsPage() {
|
||||
const auth = useAuth();
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>();
|
||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||
const [detail, setDetail] = useState<Suggestion>();
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!auth?.login) return;
|
||||
const response = await listSuggestions();
|
||||
setSuggestions(response.data ?? []);
|
||||
}, [auth?.login]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const handleAdd = async (title: string, description: string) => {
|
||||
const response = await addSuggestion({ body: { title, description } });
|
||||
if (response.data) {
|
||||
setSuggestions(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVote = async (id: string, direction: VoteDirection) => {
|
||||
const response = await voteSuggestion({ body: { id, direction } });
|
||||
if (response.data) {
|
||||
setSuggestions(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (suggestion: Suggestion) => {
|
||||
if (!window.confirm(`Opravdu chcete smazat návrh „${suggestion.title}“? Smažou se i všechny jeho hlasy.`)) {
|
||||
return;
|
||||
}
|
||||
const response = await deleteSuggestion({ body: { id: suggestion.id } });
|
||||
if (response.data) {
|
||||
setSuggestions(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
// Vykreslí jeden řádek tabulky. Vyřešené návrhy jsou read-only (bez hlasování),
|
||||
// ale autor je stále může smazat.
|
||||
const renderRow = (suggestion: Suggestion) => (
|
||||
<OverlayTrigger
|
||||
key={suggestion.id}
|
||||
placement="top"
|
||||
overlay={<Tooltip id={`tooltip-${suggestion.id}`}>{suggestion.description}</Tooltip>}
|
||||
>
|
||||
<tr onClick={() => setDetail(suggestion)}>
|
||||
<td>{suggestion.author}</td>
|
||||
<td>{suggestion.title}</td>
|
||||
<td className="col-score">{suggestion.voteScore}</td>
|
||||
<td className="col-actions" onClick={e => e.stopPropagation()}>
|
||||
{!suggestion.resolved && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`vote-btn vote-up ${suggestion.myVote === VoteDirection.UP ? "active" : ""}`}
|
||||
title="Hlasovat pro"
|
||||
onClick={() => handleVote(suggestion.id, VoteDirection.UP)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faThumbsUp} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vote-btn vote-down ${suggestion.myVote === VoteDirection.DOWN ? "active" : ""}`}
|
||||
title="Hlasovat proti"
|
||||
onClick={() => handleVote(suggestion.id, VoteDirection.DOWN)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faThumbsDown} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{suggestion.isMine && (
|
||||
<button
|
||||
type="button"
|
||||
className="vote-btn delete-btn"
|
||||
title="Smazat návrh"
|
||||
onClick={() => handleDelete(suggestion)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
|
||||
if (!auth?.login) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
if (!suggestions) {
|
||||
return <Loader icon={faGear} description={"Načítám návrhy..."} animation={"fa-bounce"} />;
|
||||
}
|
||||
|
||||
const activeSuggestions = suggestions.filter(s => !s.resolved);
|
||||
const resolvedSuggestions = suggestions.filter(s => s.resolved);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<div className="suggestions-page">
|
||||
<div className="suggestions-header">
|
||||
<h1>Návrhy na vylepšení</h1>
|
||||
<Button onClick={() => setAddModalOpen(true)}>
|
||||
<FontAwesomeIcon icon={faPlus} /> Přidat návrh
|
||||
</Button>
|
||||
</div>
|
||||
<p className="suggestions-info">
|
||||
Zde můžete navrhovat vylepšení aplikace a hlasovat o návrzích ostatních. U každého návrhu je
|
||||
zobrazeno jméno navrhovatele. Jména hlasujících jsou dostupná pouze administrátorům.
|
||||
</p>
|
||||
|
||||
{suggestions.length === 0 ? (
|
||||
<p className="suggestions-empty">Zatím nebyly přidány žádné návrhy. Buďte první!</p>
|
||||
) : (
|
||||
<>
|
||||
{activeSuggestions.length > 0 && (
|
||||
<table className="suggestions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Navrhovatel</th>
|
||||
<th>Název</th>
|
||||
<th className="col-score">Hlasy</th>
|
||||
<th className="col-actions">Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeSuggestions.map(renderRow)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{resolvedSuggestions.length > 0 && (
|
||||
<div className="resolved-section">
|
||||
<h2>Vyřešené návrhy</h2>
|
||||
<p className="suggestions-info">
|
||||
Tyto návrhy již byly zapracovány. Nelze pro ně hlasovat, autor je však může odstranit.
|
||||
</p>
|
||||
<table className="suggestions-table resolved">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Navrhovatel</th>
|
||||
<th>Název</th>
|
||||
<th className="col-score">Hlasy</th>
|
||||
<th className="col-actions">Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resolvedSuggestions.map(renderRow)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Footer />
|
||||
<AddSuggestionModal isOpen={addModalOpen} onClose={() => setAddModalOpen(false)} onSubmit={handleAdd} />
|
||||
<SuggestionDetailModal suggestion={detail} onClose={() => setDetail(undefined)} />
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
@@ -1,67 +0,0 @@
|
||||
import { OrderGroup, OrderGroupMember } from "../../../types";
|
||||
|
||||
/**
|
||||
* Pomocné funkce pro výpočet částek ve skupinových objednávkách.
|
||||
*
|
||||
* Klíčové pravidlo: poplatky (balné + doprava + spropitné) se rozpočítávají
|
||||
* pouze mezi "aktivní" strávníky — tedy ty, kteří si reálně něco objednali.
|
||||
* Kdo si nic neobjedná (typicky objednávající, který nakupuje jen pro ostatní),
|
||||
* neplatí nic a nezapočítává se mu ani poměrná část poplatků.
|
||||
*/
|
||||
|
||||
/** Parametry poplatků a slevy potřebné k výpočtu částky člena. */
|
||||
export type GroupFeeParams = {
|
||||
/** Celkové poplatky skupiny v haléřích (balné + doprava + spropitné). */
|
||||
totalFees: number;
|
||||
/** Typ slevy ('percent' = procenta, 'fixed' = pevná částka v haléřích). */
|
||||
discountType?: string;
|
||||
/** Hodnota slevy — procenta, nebo pevná částka v haléřích dle discountType. */
|
||||
discountValue?: number;
|
||||
};
|
||||
|
||||
/** Vrátí true, pokud si člen něco objednal (má kladnou částku nebo příplatek). */
|
||||
export function isActiveMember(member: OrderGroupMember): boolean {
|
||||
return (member.amount ?? 0) + (member.surchargeAmount ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** Počet aktivních strávníků — jen mezi ně se dělí poplatky. */
|
||||
export function countActiveMembers(members: OrderGroup["members"]): number {
|
||||
return Object.values(members).filter(isActiveMember).length;
|
||||
}
|
||||
|
||||
/** Celkové poplatky skupiny (balné + doprava + spropitné) v haléřích. */
|
||||
export function totalGroupFees(group: OrderGroup): number {
|
||||
return (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
|
||||
}
|
||||
|
||||
/** Poměrná část poplatků na jednoho aktivního strávníka v haléřích. */
|
||||
export function computeFeeShare(totalFees: number, activeCount: number): number {
|
||||
return activeCount > 0 ? Math.round(totalFees / activeCount) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Celková částka, kterou má člen zaplatit (v haléřích).
|
||||
* Neaktivní člen (nic si neobjednal) platí 0 — nepodílí se ani na poplatcích.
|
||||
*
|
||||
* @param member člen skupiny
|
||||
* @param params poplatky a sleva
|
||||
* @param feeShare poměrná část poplatků na osobu (viz computeFeeShare)
|
||||
* @param activeCount počet aktivních strávníků (dělitel pevné slevy)
|
||||
*/
|
||||
export function computeMemberTotal(
|
||||
member: OrderGroupMember,
|
||||
params: GroupFeeParams,
|
||||
feeShare: number,
|
||||
activeCount: number,
|
||||
): number {
|
||||
if (!isActiveMember(member)) return 0;
|
||||
const base = member.amount ?? 0;
|
||||
const surcharge = member.surchargeAmount ?? 0;
|
||||
const discountValue = params.discountValue ?? 0;
|
||||
const discount = discountValue > 0
|
||||
? (params.discountType === 'percent'
|
||||
? Math.round((base + surcharge) * discountValue / 100)
|
||||
: Math.round(discountValue / activeCount))
|
||||
: 0;
|
||||
return base + surcharge + feeShare - discount;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Parsuje cenu ve formátu "135 Kč", "135,50 Kč" nebo "135.50 Kč" na číslo.
|
||||
* Vrátí null při selhání.
|
||||
*/
|
||||
export function parsePriceCzk(raw: string | undefined): number | null {
|
||||
if (!raw) return null;
|
||||
const m = raw.replace(',', '.').match(/(\d+(?:\.\d+)?)/);
|
||||
if (!m) return null;
|
||||
const n = parseFloat(m[1]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
@@ -15,13 +13,14 @@
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"moduleResolution": "bundler",
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"client/src"
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||