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
|
node_modules
|
||||||
types/gen
|
/.pnp
|
||||||
**.DS_Store
|
.pnp.js
|
||||||
.mcp.json
|
|
||||||
.claude/settings.local.json
|
# testing
|
||||||
server/public/
|
/coverage
|
||||||
.claude/*.lock
|
|
||||||
.claude/worktrees
|
# production
|
||||||
.playwright-mcp
|
/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
|
# Luncher
|
||||||
Aplikace pro profesionální management obědů.
|
Aplikace pro profesionální management obědů.
|
||||||
|
|
||||||
Aplikace sestává ze tří modulů.
|
Aplikace sestává ze tří (čtyř) modulů.
|
||||||
- types
|
- food_api
|
||||||
- OpenAPI definice společných typů, generované přes [openapi-ts](https://github.com/hey-api/openapi-ts)
|
- Python scraper/parser pro zpracování obědových menu restaurací
|
||||||
- server
|
- server
|
||||||
- backend psaný v [node.js](https://nodejs.dev)
|
- backend psaný v [node.js](https://nodejs.dev)
|
||||||
- client
|
- client
|
||||||
- frontend psaný v [React.js](https://react.dev)
|
- 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
|
## Spuštění pro vývoj
|
||||||
### Závislosti
|
### Závislosti
|
||||||
|
#### Food API
|
||||||
|
- [Python 3](https://www.python.org)
|
||||||
|
- [pip](https://pypi.org/project/pip)
|
||||||
#### Klient/server
|
#### 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)
|
- [Yarn 1.22.x (Classic)](https://classic.yarnpkg.com)
|
||||||
|
|
||||||
### Spuštění na *nix platformách
|
### Spuštění na *nix platformách
|
||||||
- Nainstalovat závislosti viz předchozí bod
|
- Nainstalovat závislosti viz předchozí bod
|
||||||
|
- Zkopírovat `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
|
- Zkopírovat `server/.env.template` do `server/.env.development` a upravit dle potřeby
|
||||||
- Vygenerovat společné TypeScript typy
|
- Spustit `./run_dev.sh`. Na jiných platformách se lze inspirovat jeho obsahem, postup by měl být víceméně stejný.
|
||||||
- `cd types && yarn install && yarn openapi-ts`
|
|
||||||
- Server
|
|
||||||
- `cd server && yarn install && export NODE_ENV=development && yarn startReload`
|
|
||||||
- Klient
|
|
||||||
- `cd client && yarn install && yarn start`
|
|
||||||
|
|
||||||
## Sestavení a spuštění produkční verze v Docker
|
## Sestavení a spuštění produkční verze v Docker
|
||||||
### Závislosti
|
### Závislosti
|
||||||
@@ -33,8 +34,30 @@ Aplikace sestává ze tří modulů.
|
|||||||
### Spuštění
|
### Spuštění
|
||||||
- `docker compose up --build -d`
|
- `docker compose up --build -d`
|
||||||
|
|
||||||
### Spuštení s traefik
|
|
||||||
- `docker compose -f compose-traefik.yml up --build -d`
|
|
||||||
|
|
||||||
## TODO
|
## 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
|
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",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
|
||||||
"homepage": ".",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
"@fortawesome/free-regular-svg-icons": "^6.4.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.1.0",
|
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||||
"@sentry/react": "^10.65.0",
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
"@types/jest": "^30.0.0",
|
"@testing-library/react": "^13.4.0",
|
||||||
"@types/node": "^24.10.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"@types/react": "^19.2.2",
|
"@types/jest": "^27.5.2",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/node": "^16.18.23",
|
||||||
"@vitejs/plugin-react": "^5.1.0",
|
"@types/react": "^18.0.33",
|
||||||
"bootstrap": "^5.3.8",
|
"@types/react-dom": "^18.0.11",
|
||||||
"react": "^19.2.0",
|
"bootstrap": "^5.2.3",
|
||||||
"react-bootstrap": "^2.10.10",
|
"react": "^18.2.0",
|
||||||
"react-datepicker": "^9.1.0",
|
"react-bootstrap": "^2.7.2",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-jwt": "^1.3.0",
|
"react-modal": "^3.16.1",
|
||||||
"react-modal": "^3.16.3",
|
"react-scripts": "5.0.1",
|
||||||
"react-router": "^7.9.5",
|
|
||||||
"react-router-dom": "^7.9.5",
|
|
||||||
"react-select-search": "^4.1.6",
|
"react-select-search": "^4.1.6",
|
||||||
"react-snow-overlay": "^1.0.14",
|
"react-toastify": "^9.1.3",
|
||||||
"react-snowfall": "^2.3.0",
|
|
||||||
"react-toastify": "^11.0.5",
|
|
||||||
"recharts": "^3.4.1",
|
|
||||||
"sass": "^1.93.3",
|
|
||||||
"socket.io-client": "^4.6.1",
|
"socket.io-client": "^4.6.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^4.9.5",
|
||||||
"vite": "^7.2.2",
|
"web-vitals": "^2.1.4"
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "yarn vite",
|
"start": "react-scripts start",
|
||||||
"build": "tsc --noEmit && yarn vite build"
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
"react-app"
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
@@ -58,7 +52,6 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
"prettier": "^2.8.8"
|
||||||
"prettier": "^3.6.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
.login {
|
||||||
min-height: 100vh;
|
height: 100%;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--luncher-bg);
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card {
|
|
||||||
background: var(--luncher-bg-card);
|
|
||||||
border-radius: var(--luncher-radius-xl);
|
|
||||||
box-shadow: var(--luncher-shadow-lg);
|
|
||||||
padding: 48px;
|
|
||||||
max-width: 420px;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
border: 1px solid var(--luncher-border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-logo {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--luncher-text);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
letter-spacing: -0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-subtitle {
|
|
||||||
color: var(--luncher-text-secondary);
|
|
||||||
font-size: 1rem;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-form {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
text-align: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form label {
|
.login-inner {
|
||||||
display: block;
|
display: flex;
|
||||||
text-align: left;
|
flex-direction: column;
|
||||||
font-weight: 500;
|
align-items: center;
|
||||||
color: var(--luncher-text);
|
}
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-form .hint {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--luncher-text-muted);
|
|
||||||
margin-top: 8px;
|
|
||||||
text-align: left;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-form input[type="text"] {
|
|
||||||
width: 100%;
|
|
||||||
padding: 14px 18px;
|
|
||||||
font-size: 1rem;
|
|
||||||
border: 2px solid var(--luncher-border);
|
|
||||||
border-radius: var(--luncher-radius-sm);
|
|
||||||
background: var(--luncher-bg);
|
|
||||||
color: var(--luncher-text);
|
|
||||||
transition: var(--luncher-transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-form input[type="text"]:hover {
|
|
||||||
border-color: var(--luncher-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-form input[type="text"]:focus {
|
|
||||||
border-color: var(--luncher-primary);
|
|
||||||
box-shadow: 0 0 0 3px var(--luncher-primary-light);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-form input[type="text"]::placeholder {
|
|
||||||
color: var(--luncher-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-form .btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 14px 24px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
import React, { useCallback, useRef } from 'react';
|
||||||
import { Button } from 'react-bootstrap';
|
import { Button } from 'react-bootstrap';
|
||||||
import { useAuth } from './context/auth';
|
import { useAuth } from './context/auth';
|
||||||
import { login } from '../../types';
|
|
||||||
import './Login.css';
|
import './Login.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,60 +10,29 @@ export default function Login() {
|
|||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const loginRef = useRef<HTMLInputElement>(null);
|
const loginRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const doLogin = useCallback(() => {
|
||||||
if (auth && !auth.login) {
|
const length = loginRef?.current?.value && loginRef?.current?.value.length && loginRef.current.value.replace(/\s/g, '').length
|
||||||
// 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
|
|
||||||
if (length) {
|
if (length) {
|
||||||
const response = await login({ body: { login: loginRef.current?.value } });
|
auth?.setLogin(loginRef.current.value);
|
||||||
if (response.data) {
|
|
||||||
auth?.setToken(response.data as unknown as string); // TODO vyřešit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [auth]);
|
}, [auth]);
|
||||||
|
|
||||||
if (!auth?.login) {
|
if (!auth || !auth.login) {
|
||||||
return (
|
return <div className='login'>
|
||||||
<div className='login-page'>
|
<h1>Luncher</h1>
|
||||||
<div className='login-card'>
|
<h4 style={{ marginBottom: "50px" }}>Aplikace pro profesionální management obědů</h4>
|
||||||
<h1 className='login-logo'>Luncher</h1>
|
<div className='login-inner'>
|
||||||
<p className='login-subtitle'>Aplikace pro profesionální management obědů</p>
|
<p style={{ fontSize: "12px", marginTop: "10px" }}>
|
||||||
<div className='login-form'>
|
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.
|
||||||
<div>
|
</p>
|
||||||
<label htmlFor="login-input">Zobrazované jméno</label>
|
Zobrazované jméno: <input style={{ marginTop: "10px" }} ref={loginRef} type='text' onKeyDown={event => {
|
||||||
<input
|
if (event.key === 'Enter') {
|
||||||
id="login-input"
|
doLogin()
|
||||||
ref={loginRef}
|
}
|
||||||
type='text'
|
}} />
|
||||||
placeholder="Např. Jan Novák"
|
<Button onClick={doLogin} style={{ marginTop: "20px" }}>Uložit</Button>
|
||||||
onKeyDown={event => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
doLogin()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<p className='hint'>
|
|
||||||
Zadejte jméno nebo přezdívku, pod kterou vás kolegové snadno identifikují.
|
|
||||||
Jméno je možné kdykoli změnit.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={doLogin}>Pokračovat</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
}
|
||||||
return <div>Neplatný stav</div>
|
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) => {
|
export const getBaseUrl = (): string => {
|
||||||
localStorage.setItem(TOKEN_KEY, token);
|
if (process.env.PUBLIC_URL) {
|
||||||
}
|
return process.env.PUBLIC_URL;
|
||||||
|
|
||||||
/**
|
|
||||||
* Vrátí token z local storage, pokud tam je.
|
|
||||||
*
|
|
||||||
* @returns token nebo null
|
|
||||||
*/
|
|
||||||
export const getToken = (): string | undefined => {
|
|
||||||
return localStorage.getItem(TOKEN_KEY) ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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}`;
|
|
||||||
}
|
}
|
||||||
|
return 'http://localhost:3001';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const LOGIN_KEY = "login";
|
||||||
* 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
|
* Uloží login do local storage prohlížeče.
|
||||||
*
|
*
|
||||||
* @param date datum
|
* @param login login
|
||||||
* @returns index dne v týdnu
|
|
||||||
*/
|
*/
|
||||||
export const getDayOfWeekIndex = (date: Date) => {
|
export const storeLogin = (login: string) => {
|
||||||
// https://stackoverflow.com/a/4467559
|
localStorage.setItem(LOGIN_KEY, login);
|
||||||
return (((date.getDay() - 1) % 7) + 7) % 7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Vrátí první pracovní den v týdnu předaného data. */
|
|
||||||
export function getFirstWorkDayOfWeek(date: Date) {
|
|
||||||
const firstDay = new Date(date);
|
|
||||||
firstDay.setDate(date.getDate() - getDayOfWeekIndex(date));
|
|
||||||
return firstDay;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Vrátí poslední pracovní den v týdnu předaného data. */
|
|
||||||
export function getLastWorkDayOfWeek(date: Date) {
|
|
||||||
const lastDay = new Date(date);
|
|
||||||
lastDay.setDate(date.getDate() + (4 - getDayOfWeekIndex(date)));
|
|
||||||
return lastDay;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Vrátí datum v ISO formátu. */
|
|
||||||
export function formatDate(date: Date, format?: string) {
|
|
||||||
let day = String(date.getDate()).padStart(2, '0');
|
|
||||||
let month = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
let year = String(date.getFullYear());
|
|
||||||
|
|
||||||
const f = format ?? 'YYYY-MM-DD';
|
|
||||||
return f.replace('DD', day).replace('MM', month).replace('YYYY', year);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Vrátí human-readable reprezentaci předaného data pro zobrazení. */
|
|
||||||
export function getHumanDate(date: Date) {
|
|
||||||
let currentDay = String(date.getDate()).padStart(2, '0');
|
|
||||||
let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
let currentYear = date.getFullYear();
|
|
||||||
return `${currentDay}.${currentMonth}.${currentYear}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Převede datum ve formátu YYYY-MM-DD na DD.MM.YYYY */
|
|
||||||
export function formatDateString(dateString: string): string {
|
|
||||||
const [year, month, day] = dateString.split('-');
|
|
||||||
return `${day}.${month}.${year}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Očistí zprávu (účel platby) pro QR platbu – musí odpovídat serverové logice (qr.ts):
|
* Vrátí login z local storage, pokud tam je.
|
||||||
* 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ů.
|
* @returns login nebo null
|
||||||
*/
|
*/
|
||||||
export function sanitizeQrMessage(message: string): string {
|
export const getLogin = (): string | null => {
|
||||||
const sanitized = message
|
return localStorage.getItem(LOGIN_KEY);
|
||||||
.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;
|
* Odstraní login z local storage, pokud tam je.
|
||||||
|
*/
|
||||||
|
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,247 +1,59 @@
|
|||||||
import { useEffect, useState } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
|
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
|
||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import SettingsModal from "./modals/SettingsModal";
|
import { useBank } from "../context/bank";
|
||||||
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";
|
|
||||||
|
|
||||||
const LAST_SEEN_CHANGELOG_KEY = "lastChangelogDate";
|
|
||||||
|
|
||||||
const IS_DEV = process.env.NODE_ENV === 'development';
|
export default function Header() {
|
||||||
|
|
||||||
type Props = {
|
|
||||||
choices?: LunchChoices;
|
|
||||||
dayIndex?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Header({ choices, dayIndex }: Props) {
|
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const settings = useSettings();
|
const bank = useBank();
|
||||||
const navigate = useNavigate();
|
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||||
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
|
const bankAccountRef = useRef<HTMLInputElement>(null);
|
||||||
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
|
const nameRef = useRef<HTMLInputElement>(null);
|
||||||
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
|
|
||||||
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
|
|
||||||
const [changelogEntries, setChangelogEntries] = useState<Record<string, string[]>>({});
|
|
||||||
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
|
|
||||||
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
|
|
||||||
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const effectiveDark = settings?.effectiveDark ?? false;
|
const openBankSettings = () => {
|
||||||
|
setModalOpen(true);
|
||||||
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 closePizzaModal = () => {
|
const closeModal = () => {
|
||||||
setPizzaModalOpen(false);
|
setModalOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeRefreshMenuModal = () => {
|
const save = () => {
|
||||||
setRefreshMenuModalOpen(false);
|
// TODO validace na modulo 11
|
||||||
}
|
bank?.setBankAccountNumber(bankAccountRef.current?.value);
|
||||||
|
bank?.setBankAccountHolderName(nameRef.current?.value);
|
||||||
const closeQrModal = () => {
|
closeModal();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Navbar variant='dark' expand="lg">
|
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.Toggle aria-controls="basic-navbar-nav" />
|
||||||
<Navbar.Collapse id="basic-navbar-nav">
|
<Navbar.Collapse id="basic-navbar-nav">
|
||||||
<Nav className="nav">
|
<Nav className="nav">
|
||||||
<button
|
|
||||||
className="theme-toggle"
|
|
||||||
onClick={toggleTheme}
|
|
||||||
title={effectiveDark ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
|
|
||||||
aria-label="Přepnout světlý/tmavý režim"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
|
|
||||||
</button>
|
|
||||||
<HuePicker
|
|
||||||
accentHue={settings?.accentHue ?? 142}
|
|
||||||
isDark={effectiveDark}
|
|
||||||
onChange={hue => settings?.setAccentHue(hue)}
|
|
||||||
/>
|
|
||||||
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
|
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
|
||||||
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
|
<NavDropdown.Item onClick={openBankSettings}>Nastavit číslo účtu</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
|
<NavDropdown.Item onClick={auth?.clearLogin}>Odhlásit se</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>
|
</NavDropdown>
|
||||||
</Nav>
|
</Nav>
|
||||||
</Navbar.Collapse>
|
</Navbar.Collapse>
|
||||||
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
|
<Modal show={modalOpen} onHide={closeModal}>
|
||||||
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
|
||||||
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
|
|
||||||
{choices && settings?.bankAccount && settings?.holderName && (
|
|
||||||
<GenerateQrModal
|
|
||||||
isOpen={qrModalOpen}
|
|
||||||
onClose={closeQrModal}
|
|
||||||
choices={choices}
|
|
||||||
bankAccount={settings.bankAccount}
|
|
||||||
bankAccountHolder={settings.holderName}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{IS_DEV && (
|
|
||||||
<>
|
|
||||||
<GenerateMockDataModal
|
|
||||||
isOpen={generateMockModalOpen}
|
|
||||||
onClose={() => setGenerateMockModalOpen(false)}
|
|
||||||
currentDayIndex={dayIndex}
|
|
||||||
/>
|
|
||||||
<ClearMockDataModal
|
|
||||||
isOpen={clearMockModalOpen}
|
|
||||||
onClose={() => setClearMockModalOpen(false)}
|
|
||||||
currentDayIndex={dayIndex}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Modal show={changelogModalOpen} onHide={() => setChangelogModalOpen(false)} size="lg">
|
|
||||||
<Modal.Header closeButton>
|
<Modal.Header closeButton>
|
||||||
<Modal.Title><h2>Novinky</h2></Modal.Title>
|
<Modal.Title>Bankovní účet</Modal.Title>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
{Object.keys(changelogEntries).sort((a, b) => b.localeCompare(a)).map(date => (
|
<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>
|
||||||
<div key={date}>
|
Číslo účtu: <input ref={bankAccountRef} type="text" placeholder="123456-1234567890/1234" /> <br />
|
||||||
<strong>{formatDateString(date)}</strong>
|
Název příjemce (nepovinné): <input ref={nameRef} type="text" placeholder="Jan Novák" />
|
||||||
<ul>
|
|
||||||
{changelogEntries[date].map((item, index) => (
|
|
||||||
<li key={index}>{item}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{Object.keys(changelogEntries).length === 0 && (
|
|
||||||
<p>Žádné novinky.</p>
|
|
||||||
)}
|
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Button variant="secondary" onClick={() => setChangelogModalOpen(false)}>
|
<Button variant="secondary" onClick={closeModal}>
|
||||||
Zavřít
|
Storno
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={save}>
|
||||||
|
Uložit
|
||||||
</Button>
|
</Button>
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
}
|
}
|
||||||
@@ -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 { Table } from "react-bootstrap";
|
||||||
import PizzaOrderRow from "./PizzaOrderRow";
|
import { Order, PizzaOrder, State } from "../Types";
|
||||||
import { PizzaDayState, PizzaOrder, PizzaVariant, updatePizzaFee } from "../../../types";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
|
||||||
|
import { useAuth } from "../context/auth";
|
||||||
|
|
||||||
type Props = {
|
export default function PizzaOrderList({ state, orders, onDelete }: { state: State, orders: Order[], onDelete: (pizzaOrder: PizzaOrder) => void }) {
|
||||||
state: PizzaDayState,
|
const auth = useAuth();
|
||||||
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 } });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!orders?.length) {
|
if (!orders?.length) {
|
||||||
return <p className="mt-4" style={{ color: 'var(--luncher-text-muted)', fontStyle: 'italic' }}>Zatím žádné objednávky...</p>
|
return <p><i>Zatím žádné objednávky...</i></p>
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = orders.reduce((total, order) => total + order.totalPrice, 0);
|
return <Table className="mt-3" striped bordered hover>
|
||||||
|
<thead>
|
||||||
return (
|
<tr>
|
||||||
<div className="mt-4" style={{
|
<th>Jméno</th>
|
||||||
background: 'var(--luncher-bg-card)',
|
<th>Objednávka</th>
|
||||||
borderRadius: 'var(--luncher-radius-lg)',
|
<th>Cena</th>
|
||||||
overflow: 'hidden',
|
</tr>
|
||||||
border: '1px solid var(--luncher-border-light)',
|
</thead>
|
||||||
boxShadow: 'var(--luncher-shadow)'
|
<tbody>
|
||||||
}}>
|
{orders.map(order => <tr key={order.customer}>
|
||||||
<Table className="mb-0" style={{ color: 'var(--luncher-text)' }}>
|
<td>{order.customer}</td>
|
||||||
<thead style={{ background: 'var(--luncher-primary-light)' }}>
|
<td>{order.pizzaList.map<React.ReactNode>((pizzaOrder, index) =>
|
||||||
<tr>
|
<span key={index}>
|
||||||
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Jméno</th>
|
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
|
||||||
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Objednávka</th>
|
{auth?.login === order.customer && state === State.CREATED &&
|
||||||
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Poznámka</th>
|
<FontAwesomeIcon onClick={() => {
|
||||||
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Příplatek</th>
|
onDelete(pizzaOrder);
|
||||||
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none', textAlign: 'right' }}>Cena</th>
|
}} title='Odstranit' className='trash-icon' icon={faTrashCan} />
|
||||||
</tr>
|
}
|
||||||
</thead>
|
</span>)
|
||||||
<tbody>
|
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
|
||||||
{orders.map(order => <tr key={order.customer} style={{ borderColor: 'var(--luncher-border-light)' }}>
|
</td>
|
||||||
<PizzaOrderRow creator={creator} state={state} order={order} onDelete={onDelete} onFeeModalSave={saveFees} />
|
<td>{order.totalPrice} Kč</td>
|
||||||
</tr>)}
|
</tr>)}
|
||||||
<tr style={{
|
</tbody>
|
||||||
fontWeight: 700,
|
</Table>
|
||||||
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 React, { ReactNode, useContext, useState } from "react"
|
||||||
import { useJwt } from "react-jwt";
|
import { useEffect } from "react"
|
||||||
import { deleteToken, getToken, storeToken } from "../Utils";
|
|
||||||
|
const LOGIN_KEY = 'login';
|
||||||
|
|
||||||
export type AuthContextProps = {
|
export type AuthContextProps = {
|
||||||
login?: string,
|
login?: string,
|
||||||
trusted?: boolean,
|
setLogin: (name: string) => void,
|
||||||
setToken: (name: string) => void,
|
clearLogin: () => void,
|
||||||
logout: () => void,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContextProps = {
|
type ContextProps = {
|
||||||
@@ -15,7 +15,7 @@ type ContextProps = {
|
|||||||
|
|
||||||
const authContext = React.createContext<AuthContextProps | null>(null);
|
const authContext = React.createContext<AuthContextProps | null>(null);
|
||||||
|
|
||||||
export function ProvideAuth(props: Readonly<ContextProps>) {
|
export function ProvideAuth(props: ContextProps) {
|
||||||
const auth = useProvideAuth();
|
const auth = useProvideAuth();
|
||||||
return <authContext.Provider value={auth}>{props.children}</authContext.Provider>
|
return <authContext.Provider value={auth}>{props.children}</authContext.Provider>
|
||||||
}
|
}
|
||||||
@@ -26,43 +26,33 @@ export const useAuth = () => {
|
|||||||
|
|
||||||
function useProvideAuth(): AuthContextProps {
|
function useProvideAuth(): AuthContextProps {
|
||||||
const [loginName, setLoginName] = useState<string | undefined>();
|
const [loginName, setLoginName] = useState<string | undefined>();
|
||||||
const [trusted, setTrusted] = useState<boolean | undefined>();
|
|
||||||
const [token, setToken] = useState<string | undefined>(getToken());
|
|
||||||
const { decodedToken } = useJwt(token ?? '');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token && token.length > 0) {
|
const login = localStorage.getItem(LOGIN_KEY);
|
||||||
storeToken(token);
|
if (login) {
|
||||||
} else {
|
setLogin(login);
|
||||||
deleteToken();
|
|
||||||
}
|
}
|
||||||
}, [token]);
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (decodedToken) {
|
if (loginName) {
|
||||||
setLoginName((decodedToken as any).login);
|
localStorage.setItem(LOGIN_KEY, loginName)
|
||||||
setTrusted((decodedToken as any).trusted);
|
|
||||||
} else {
|
} else {
|
||||||
setLoginName(undefined);
|
localStorage.removeItem(LOGIN_KEY);
|
||||||
setTrusted(undefined);
|
|
||||||
}
|
}
|
||||||
}, [decodedToken]);
|
}, [loginName]);
|
||||||
|
|
||||||
function logout() {
|
function setLogin(login: string) {
|
||||||
const trusted = (decodedToken as any).trusted;
|
setLoginName(login);
|
||||||
const logoutUrl = (decodedToken as any).logoutUrl;
|
}
|
||||||
setToken(undefined);
|
|
||||||
|
function clearLogin() {
|
||||||
setLoginName(undefined);
|
setLoginName(undefined);
|
||||||
setTrusted(undefined);
|
|
||||||
if (trusted && logoutUrl?.length) {
|
|
||||||
globalThis.location.replace(logoutUrl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
login: loginName,
|
login: loginName,
|
||||||
trusted,
|
setLogin,
|
||||||
setToken,
|
clearLogin
|
||||||
logout,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 React from 'react';
|
||||||
import socketio from "socket.io-client";
|
import socketio from "socket.io-client";
|
||||||
|
import { getBaseUrl } from "../Utils";
|
||||||
|
|
||||||
let socketUrl;
|
// Záměrně omezeno jen na websocket, aby se případně odhalilo chybné nastavení proxy serveru
|
||||||
let socketPath;
|
export const socket = socketio.connect(getBaseUrl(), { transports: ["websocket"] });
|
||||||
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"] });
|
|
||||||
export const SocketContext = React.createContext();
|
export const SocketContext = React.createContext();
|
||||||
|
|
||||||
// Prohlížeče throttlují setTimeout v neaktivních tabech, což zdržuje automatické
|
|
||||||
// znovupřipojení socket.io. Po návratu do tabu nebo focusu okna se připojíme hned.
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
|
||||||
if (document.visibilityState === 'visible' && !socket.connected) {
|
|
||||||
socket.connect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
window.addEventListener('focus', () => {
|
|
||||||
if (!socket.connected) {
|
|
||||||
socket.connect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Konstanty websocket eventů, musí odpovídat těm na serveru!
|
// Konstanty websocket eventů, musí odpovídat těm na serveru!
|
||||||
export const EVENT_CONNECT = 'connect';
|
export const EVENT_CONNECT = 'connect';
|
||||||
export const EVENT_DISCONNECT = 'disconnect';
|
export const EVENT_DISCONNECT = 'disconnect';
|
||||||
export const EVENT_MESSAGE = 'message';
|
export const EVENT_MESSAGE = 'message';
|
||||||
export const EVENT_PENDING_QR = 'pendingQr';
|
// 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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
sans-serif;
|
sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
line-height: 1.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
monospace;
|
monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth scrolling */
|
|
||||||
html {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Better focus styles */
|
|
||||||
:focus-visible {
|
|
||||||
outline: 2px solid var(--luncher-primary);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Selection color */
|
|
||||||
::selection {
|
|
||||||
background: var(--luncher-primary-light);
|
|
||||||
color: var(--luncher-primary);
|
|
||||||
}
|
|
||||||
@@ -1,63 +1,25 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import { SocketContext, socket } from './context/socket';
|
||||||
import { ProvideAuth } from './context/auth';
|
import { ProvideAuth } from './context/auth';
|
||||||
|
import { ToastContainer } from 'react-toastify';
|
||||||
|
import { ProvideBank } from './context/bank';
|
||||||
import 'react-toastify/dist/ReactToastify.css';
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
import './index.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(
|
const root = ReactDOM.createRoot(
|
||||||
document.getElementById('root') as HTMLElement
|
document.getElementById('root') as HTMLElement
|
||||||
);
|
);
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<Sentry.ErrorBoundary fallback={
|
<ProvideAuth>
|
||||||
<div className="text-center p-5">
|
<ProvideBank>
|
||||||
<h4>Něco se pokazilo 😕</h4>
|
<SocketContext.Provider value={socket}>
|
||||||
<p>Chyba byla nahlášena. Zkuste stránku načíst znovu.</p>
|
<App />
|
||||||
<button className="btn btn-primary" onClick={() => window.location.reload()}>Načíst znovu</button>
|
<ToastContainer />
|
||||||
</div>
|
</SocketContext.Provider>
|
||||||
}>
|
</ProvideBank>
|
||||||
<BrowserRouter>
|
</ProvideAuth>
|
||||||
<ProvideAuth>
|
|
||||||
<AppRoutes />
|
|
||||||
</ProvideAuth>
|
|
||||||
</BrowserRouter>
|
|
||||||
</Sentry.ErrorBoundary>
|
|
||||||
</React.StrictMode>
|
</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": {
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
"lib": [
|
"lib": [
|
||||||
"dom",
|
"dom",
|
||||||
"dom.iterable",
|
"dom.iterable",
|
||||||
"esnext"
|
"esnext"
|
||||||
],
|
],
|
||||||
"types": [
|
|
||||||
"vite/client"
|
|
||||||
],
|
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
@@ -15,13 +13,14 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"moduleResolution": "bundler",
|
"module": "esnext",
|
||||||
"module": "ESNext",
|
"moduleResolution": "node",
|
||||||
"target": "ESNext",
|
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx"
|
||||||
}
|
},
|
||||||
|
"include": [
|
||||||
|
"client/src"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||