120 Commits

Author SHA1 Message Date
a1b1eed86d docs: přidána strategie vyhledávání kódu do CLAUDE.md
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2026-03-05 22:13:19 +01:00
f8a65d7177 feat: detekce starého menu TechTower, příznak isStale
Pokud TechTower vrátí menu z jiného týdne, uloží data s příznakem
isStale a zobrazí varování "Data jsou z minulého týdne" místo chybové
hlášky. Odstraněno staré varování o datech starších 24 hodin.
2026-03-05 22:11:45 +01:00
607bcd9bf5 feat: uprava refresh menu hesel
každý může udělat refresh, jen ne tak často, bypass mimo zdrojak
2026-03-05 21:50:17 +01:00
b6fdf1de98 feat: akce "Neobědvám" přímo z push notifikace
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2026-03-04 14:37:05 +01:00
27e56954bd fix: nahrazení selectu časovým inputem pro výběr času připomínky
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2026-03-04 14:14:29 +01:00
20cc7259a3 chore: test endpoint na push 2026-03-04 14:11:22 +01:00
d62f6c1f5a feat: push notifikace pro připomínku výběru oběda
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2026-03-04 13:33:58 +01:00
b77914404b retarded dsstore 2026-03-04 10:36:14 +01:00
8506b4e79f claudemd init
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2026-03-04 10:35:13 +01:00
5f79a9431c fix: oprava závislostí
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2026-02-20 14:32:02 +01:00
cc98c2be0d feat: podpora ručního generování QR kódů pro platby
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2026-02-20 14:17:39 +01:00
a849f4e922 feat: zarovnani ikony varovani doprava
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2026-02-11 13:55:40 +01:00
ac6727efa5 feat: vylepšení Pizza day
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2026-02-10 23:59:58 +01:00
Stánek Pavel
f13cd4ffa9 fix: opravy zobrazeni sekce vybranych jidel
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2026-02-05 10:18:58 +01:00
086646fd1c fix: přidání nových typů do OpenAPI spec pro přežití regenerace
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
Typy PendingQr, NotificationSettings a nové endpointy
(dismissQr, notifications/settings) byly přidány přímo
do YAML specifikace místo ručních úprav generovaných souborů.
2026-02-04 17:34:05 +01:00
b8629afef2 feat: trvalé zobrazení QR kódu do ručního zavření (#31)
QR kódy pro platbu za pizza day jsou nyní zobrazeny persistentně
i po následující dny, dokud uživatel nepotvrdí platbu tlačítkem
"Zaplatil jsem". Nevyřízené QR kódy jsou uloženy per-user v storage
a zobrazeny v sekci "Nevyřízené platby".
2026-02-04 17:34:05 +01:00
d366ac39d4 feat: podpora per-user notifikací s Discord, ntfy a Teams (#39)
Uživatelé mohou v nastavení konfigurovat vlastní webhook URL/topic
pro Discord, MS Teams a ntfy, a zvolit události k odběru.
Notifikace jsou odesílány pouze uživatelům se stejnou zvolenou lokalitou.
2026-02-04 17:33:53 +01:00
fdd42dc46a feat: zobrazení minulého týdne o víkendu místo "Užívejte víkend" (#30)
Na víkendu se nyní zobrazuje páteční menu s možností procházet celý týden.
Editační ovládací prvky jsou automaticky skryté díky existující logice canChangeChoice.
2026-02-04 14:56:24 +01:00
2b7197eff6 fix: zobrazení popisů funkcí místo varnames ve statistikách (#26)
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2026-02-04 13:40:20 +01:00
6f43c74769 fix: resolve 6 Gitea issues (#9, #10, #12, #14, #15, #21)
- #21: Add missing await in removeChoiceIfPresent() to prevent user appearing in two restaurants
- #15: Add 1-hour TTL for menu refetching to avoid scraping on every page load
- #9: Block stats API and UI navigation for future dates
- #14: Add restaurant warnings (missing soup/prices, stale data) with warning icon
- #12: Pre-fill restaurant/departure dropdowns from existing choices on page refresh
- #10: Add voting statistics endpoint and table on stats page
2026-02-04 13:18:27 +01:00
Stánek Pavel
d85c764c88 fix: opravy a ladeni vzhledu a UX 2026-02-04 12:06:17 +01:00
Stánek Pavel
37cacd895a feat: redesign aplikace pomocí claude code 2026-02-03 10:37:27 +01:00
6a1da97ef1 feat: podpora dark mode 2026-01-30 07:47:03 +01:00
f91973f1a4 Oprava parsování názvů TechTower 2026-01-19 10:18:24 +01:00
7cf9179a87 Neumožnění výběru jídla kliknutím do minulosti 2026-01-13 16:03:31 +01:00
54e5be6b6a Povýšení závislostí 2026-01-13 15:43:19 +01:00
c264f9921e Opravy dle SonarLint - klient 2026-01-13 15:35:00 +01:00
e03ba45415 Možnost označení objednávajícího 2026-01-13 14:06:16 +01:00
20f4ee0427 Zimní atmosféra 2026-01-09 08:46:14 +01:00
be4cee4cdb Oprava parsování Sladovnická a TechTower 2026-01-09 08:35:35 +01:00
8285dd2780 Povýšení závislostí
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-11-10 22:03:48 +01:00
039d8457f3 Povýšení závislostí
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-11-06 21:22:39 +01:00
091e25f446 Kopírování poznámky jen pokud je vyplněna
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 14:17:22 +01:00
00b4a0cce2 Oprava/narovnání ikony kopírování poznámky
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 14:05:01 +01:00
979c79e090 poznamka, pak icon
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 13:43:20 +01:00
d29c7863dd chat ikona misto barvicek
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 13:36:54 +01:00
0781b84f11 Kopírování komentaře na kliknutí.
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 12:57:22 +01:00
e3d217822a Oprava výběru jídla při přepínání dnů šipkami
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 12:48:56 +01:00
ccfe9a9ae1 Oprava parsování Sladovnické v případě chybějících dnů
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-31 08:59:30 +01:00
7407a3d881 Oprava hover popisků u Font Awesome ikon
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-16 08:06:22 +02:00
8b139eba4e Oprava zobrazení tooltipu při hoveru
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-16 07:39:39 +02:00
74eeef1de9 Aktualizace posledních změn
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-11 13:24:11 +02:00
fd67c0e646 Oprava buildu
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-11 13:15:47 +02:00
60150889b0 Povýšení závislostí
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-10-11 13:02:56 +02:00
9e0e842c2d Podzimní atmosféra
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-11 11:03:20 +02:00
86c50b315a Přidání alergenů do mock dat
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-10-11 11:01:42 +02:00
fe7d609b5f Oprava zobrazení patičky
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-11 10:36:21 +02:00
331a890cc5 Oddělení přenačtení menu do vlastní komponenty
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-10 14:00:01 +02:00
523bbbfb0f Proklik na seznam alergenů
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-10 13:15:18 +02:00
1acf9bf092 Úprava cesty pro čtení easter eggs
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-10 08:49:17 +02:00
81f67c8424 Podpora parsování a zobrazení alergenů
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-06 16:28:38 +02:00
c2a001b7e5 Oprava parsování TechTower dle aktuální podoby HTML
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-06 13:12:58 +02:00
670e45b805 Nevyvolávat přenačtení u zavřených podniků
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-08-11 10:30:42 +02:00
52769fc981 Opravy dle SonarQube
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-08-07 13:12:55 +02:00
0d90453c38 Oprava chybného čtení .env souborů 2025-08-07 13:02:41 +02:00
a9709a944f Úprava pro novou podobu stránek Sladovnická
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-08-04 17:30:04 +02:00
593ffcf02b Vylepšení run_dev.sh pro vývoj 2025-08-04 17:27:03 +02:00
b4b62870e3 Úprava .gitignore 2025-08-04 17:26:38 +02:00
480fe725f1 Oprava načítání jídel pro Šenk Šeříková na přelomu měsíce
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-08-01 14:00:27 +02:00
d2845f7d0f Merge pull request 'feat/odflaknutyRefreshDat' (#17) from feat/odflaknutyRefreshDat into master
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
Reviewed-on: #17
2025-08-01 09:05:50 +02:00
269f1994bc Update novinky 2025-08-01 09:04:28 +02:00
3dcda2028e Error pri fetch do klienta 2025-07-31 23:47:43 +02:00
cfffd2b31d pro refresh endpoint nevyzadovat authtoken 2025-07-31 23:45:47 +02:00
58bb5f4e7d pro refresh endpoint nevyzadovat authtoken 2025-07-31 23:41:51 +02:00
a3dfdb17e8 fix async.... 2025-07-31 23:37:21 +02:00
124fdce69d tak jsem to mozna robil, ale mozna taky ne lol 2025-07-31 23:35:38 +02:00
ff20394b97 feat: Přidání funkce pro manuální refresh jidel. 2025-07-31 23:29:19 +02:00
a77a04bcdf umichal patek fix
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-07-30 12:00:03 +02:00
42852805e0 Oprava plnění data a času poslední aktualizace menu
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-07-29 15:41:05 +02:00
d767730b19 sonar
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-07-29 15:15:16 +02:00
fa4f9903cb parametr forceupdate jidla
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-07-29 11:33:31 +02:00
cf8be8c64f fix: feat jsem to dal na spatnej radaek
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-07-29 10:55:04 +02:00
4c2b08adf8 feat: refresh jidla endpoint
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-07-29 10:43:49 +02:00
62cc82da9a Úprava parsování TechTower pro aktuální týden
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-07-22 17:10:39 +02:00
40c113a4c8 Oprava pádů při načítání z menicka.cz
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-07-07 08:47:39 +02:00
7681584d11 Oprava parsování TechTower pro aktuální stav
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-07-07 08:24:26 +02:00
c670b4212a Revert "TechTower hack pro tento specifický týden"
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
This reverts commit 5fd90de3f8.
2025-05-26 10:13:35 +02:00
5fd90de3f8 TechTower hack pro tento specifický týden
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-05-20 08:02:34 +02:00
49b8ab5c13 Update server/src/index.ts
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
delete req.headers["cookie"]
2025-04-11 12:06:52 +02:00
9a05ef1fe6 Update server/src/index.ts
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
vic logov
2025-04-11 12:01:58 +02:00
0bfea3765f properta pro logovani headeru
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-04-11 10:42:27 +02:00
962fbe2947 fix hardcoded header name xd
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-04-11 10:06:35 +02:00
d6d6ebb682 Aktualizace posledních změn
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-21 00:24:26 +01:00
5bb7de58e7 Odebrání zimní atmosféry 2025-03-21 00:24:17 +01:00
739c7707e1 Migrace serveru na OpenAPI
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-20 23:50:47 +01:00
d366882f6b Migrace klienta na OpenAPI
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-19 23:08:46 +01:00
f09bc44d63 Oprava nefunkčního odebrání prvního vybraného jídla
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-11 20:57:37 +01:00
f0d56f11aa Oprava popisu varianty "neobědvám"
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-11 19:41:37 +01:00
f74ec379c8 Oprava výběru možnosti stravování
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-06 08:03:49 +01:00
c9fa710070 Oprava buildu
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-03-06 07:59:33 +01:00
e55ee7c11e Refaktor: Nálezy SonarQube
Some checks are pending
ci/woodpecker/push/workflow Pipeline is running
2025-03-05 21:48:02 +01:00
55fd368663 Oprava Woodpecker pipeline
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-05 21:19:33 +01:00
61f13d2132 Validace TypeScript typů při sestavení klienta
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-03-05 21:05:40 +01:00
d69e09afee Migrace na OpenAPI - TypeScript typy 2025-03-05 21:05:21 +01:00
d144c55bf7 feat: #11 je tohle feat?, pridani poctu lidi k restauraci
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-05 18:56:21 +01:00
999a517404 Oprava lokalizace datumu
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-03 10:20:41 +01:00
68bafa808c Oprava #8
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 21:46:50 +01:00
a34614c8db Oprava #6
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-02-27 21:36:21 +01:00
f4e31cea36 Oprava #4, #5
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 21:29:43 +01:00
8dda6b1014 Oprava #7
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 21:19:38 +01:00
f9c7d647f7 Migrace Node v18 -> v22
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 00:28:14 +01:00
ca400638d1 Přidání základních statistik
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-02-27 00:22:34 +01:00
0af78e72d9 Nastavení časové zóny
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-24 12:06:29 +01:00
Michal Hájek
8137ca6fc0 Teamsová notifikace "Jdeme na oběd"
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-22 20:43:34 +01:00
Michal Hájek
3817126ac0 Výběr restaurace kliknutím na její název
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-19 20:39:27 +01:00
Michal Hájek
c1856b2eee Pokud bylo v osobním nastavení vypnuto zobraování polévky, předával se do funkce doAddClickFoodChoice spatně foodIndex
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-19 20:21:17 +01:00
Michal Hájek
eaf0bc353d Výběr obědu kliknutím
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-18 10:07:35 +01:00
ff650ec3b8 rm db.json
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-17 09:32:23 +01:00
f8aa293413 fix
Some checks are pending
ci/woodpecker/push/workflow Pipeline is running
2025-02-17 09:26:03 +01:00
cafcd0a467 Log username a email pri kazdem dotazu pouze pro neproduction env
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-17 09:19:28 +01:00
9e247eb2a1 Podpora sestavování přes Woodpecker CI
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-09 00:34:59 +01:00
469a6b9031 Oprava .gitignore 2025-02-08 23:28:20 +01:00
Michal Hájek
89dec1c194 Založení složky server/data, pokud neexistuje, do které je vytvořen soubor db.json 2025-02-02 19:46:20 +01:00
Michal Hájek
f3af64923c Přesun json databaze (souboru db.json) do složky data, související úpravy v Dockerfile 2025-02-02 16:09:07 +01:00
Michal Hájek
44b09a9d1a Začištění souborů .gitignore 2025-02-02 16:06:52 +01:00
Michal Hájek
c311cc2fd7 Oprava importů klienta do složky types, aby nebylo potřeba složku kokírovat 2025-02-02 16:01:21 +01:00
Michal Hájek
a9fe369abc Oprava možnosti vybrat V kolik hodin preferuješ odchod pro následující dny 2025-01-29 08:48:43 +01:00
Michal Hájek
ea9fe980f0 U restaurace Pivovarský šenk Šeříková nahrazena mezera mezi cenou a Kč pevnou mezerou, aby nedocházelo k zalomení 2025-01-29 01:29:51 +01:00
Michal Hájek
d367826ce0 Přidání restaurace Pivovarský šenk Šeříková 2025-01-29 01:14:03 +01:00
Michal Hájek
fdf1ae938f Načtení menu celého týdne restaurace Zastávka u Michala 2025-01-28 22:20:44 +01:00
125 changed files with 11246 additions and 4120 deletions

24
.gitignore vendored
View File

@@ -1,23 +1,3 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules node_modules
/.pnp types/gen
.pnp.js **.DS_Store
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

64
.woodpecker/workflow.yaml Normal file
View File

@@ -0,0 +1,64 @@
variables:
- &node_image "node:22-alpine"
- &branch "master"
when:
- event: push
branch: *branch
steps:
- name: Generate TypeScript types
image: *node_image
commands:
- cd types
- yarn install --frozen-lockfile
- yarn openapi-ts
- name: Install server dependencies
image: *node_image
commands:
- cd server
- yarn install --frozen-lockfile
depends_on: [Generate TypeScript types]
- name: Install client dependencies
image: *node_image
commands:
- cd client
- yarn install --frozen-lockfile
depends_on: [Generate TypeScript types]
- name: Build server
depends_on: [Install server dependencies]
image: *node_image
commands:
- cd server
- yarn build
- name: Build client
depends_on: [Install client dependencies]
image: *node_image
commands:
- cd client
- yarn build
- name: Build Docker image
depends_on: [Build server, Build client]
image: woodpeckerci/plugin-docker-buildx
settings:
dockerfile: Dockerfile-Woodpecker
platforms: linux/amd64
registry:
from_secret: REPO_URL
username:
from_secret: REPO_USERNAME
password:
from_secret: REPO_PASSWORD
repo:
from_secret: REPO_NAME
- name: Discord notification - build
image: appleboy/drone-discord
depends_on: [Build Docker image]
when:
- status: [success, failure]
settings:
webhook_id:
from_secret: DISCORD_WEBHOOK_ID
webhook_token:
from_secret: DISCORD_WEBHOOK_TOKEN
message: "{{#success build.status}}✅ Sestavení {{build.number}} proběhlo úspěšně.{{else}}❌ Sestavení {{build.number}} selhalo.{{/success}}\n\nPipeline: {{build.link}}\nPoslední commit: {{commit.message}}Autor: {{commit.author}}"

111
CLAUDE.md Normal file
View File

@@ -0,0 +1,111 @@
# 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)
```
Each directory has its own `package.json` and `tsconfig.json`. Package manager: **Yarn Classic**.
## Development Commands
### Initial setup
```bash
cd types && yarn install && yarn openapi-ts # Generate API types first
cd ../server && yarn install
cd ../client && 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
cd server && yarn test # Jest (tests in server/src/tests/)
```
### Formatting
```bash
# Prettier available in client (no config file — uses defaults)
```
## Architecture
### API Types (types/)
- OpenAPI 3.0 spec in `types/api.yml` — all API endpoints and DTOs defined here
- `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/` — modular Express route handlers (food, pizzaDay, voting, notifications, qr, stats, easterEgg, dev)
- **Services:** domain logic files at root level (`service.ts`, `pizza.ts`, `restaurants.ts`, `chefie.ts`, `voting.ts`, `stats.ts`, `qr.ts`, `notifikace.ts`)
- **Auth:** `auth.ts` — JWT + optional trusted-header authentication
- **Storage:** `storage/StorageInterface.ts` defines the interface; implementations in `storage/json.ts` (file-based, dev) and `storage/redis.ts` (production). Data keyed by date (YYYY-MM-DD).
- **Restaurant scrapers:** Cheerio-based HTML parsing for daily menus from multiple restaurants
- **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`
- **Pages:** `pages/` (StatsPage)
- **Components:** `components/` (Header, Footer, Loader, modals/, PizzaOrderList, PizzaOrderRow)
- **Context providers:** `context/` — AuthContext, SettingsContext, SocketContext, EasterEggContext
- **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), `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
## Code Search Strategy
When searching through the project for information, use the Task tool to spawn
subagents. Each subagent should read the relevant files and return a brief
summary of what it found (not the full file contents). This keeps the main
context window small and saves tokens. Only pull in full file contents once
you've identified the specific files that matter.
When using subagents to search, each subagent should return:
- File path
- Whether it's relevant (yes/no)
- 1-3 sentence summary of what's in the file
Do NOT return full file contents in subagent responses.

View File

@@ -1,8 +1,18 @@
ARG NODE_VERSION="node:22-alpine"
# Builder # Builder
FROM node:18-alpine3.18 AS builder FROM ${NODE_VERSION} AS builder
WORKDIR /build WORKDIR /build
# Zkopírování závislostí - OpenAPI generátor
COPY types/package.json ./types/
COPY types/yarn.lock ./types/
COPY types/api.yml ./types/
COPY types/schemas ./types/schemas/
COPY types/paths ./types/paths/
COPY types/openapi-ts.config.ts ./types/
# Zkopírování závislostí - server # Zkopírování závislostí - server
COPY server/package.json ./server/ COPY server/package.json ./server/
COPY server/yarn.lock ./server/ COPY server/yarn.lock ./server/
@@ -11,6 +21,10 @@ COPY server/yarn.lock ./server/
COPY client/package.json ./client/ COPY client/package.json ./client/
COPY client/yarn.lock ./client/ COPY client/yarn.lock ./client/
# Instalace závislostí - OpenAPI generátor
WORKDIR /build/types
RUN yarn install --frozen-lockfile
# Instalace závislostí - server # Instalace závislostí - server
WORKDIR /build/server WORKDIR /build/server
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
@@ -34,7 +48,11 @@ COPY client/src ./client/src
COPY client/public ./client/public COPY client/public ./client/public
# Zkopírování společných typů # Zkopírování společných typů
COPY types ./types/ COPY types/index.ts ./types/
# Vygenerování společných typů z OpenAPI
WORKDIR /build/types
RUN yarn openapi-ts
# Sestavení serveru # Sestavení serveru
WORKDIR /build/server WORKDIR /build/server
@@ -45,26 +63,30 @@ WORKDIR /build/client
RUN yarn build RUN yarn build
# Runner # Runner
FROM node:18-alpine3.18 FROM ${NODE_VERSION}
ENV LANG cs_CZ.UTF-8
ENV NODE_ENV production RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \
LC_ALL=cs_CZ.UTF-8 \
NODE_ENV=production
WORKDIR /app WORKDIR /app
# Vykopírování sestaveného serveru # Vykopírování sestaveného serveru
COPY --from=builder /build/server/node_modules ./server/node_modules COPY --from=builder /build/server/node_modules ./server/node_modules
COPY --from=builder /build/server/dist ./ COPY --from=builder /build/server/dist ./
COPY server/resources ./server/resources
# Vykopírování sestaveného klienta # Vykopírování sestaveného klienta
COPY --from=builder /build/client/dist ./public COPY --from=builder /build/client/dist ./public
# Zkopírování produkčních .env serveru # Zkopírování produkčních .env serveru
COPY /server/.env.production ./server/src COPY /server/.env.production ./server
# Zkopírování konfigurace easter eggů # Zkopírování konfigurace easter eggů
# TODO tohle spadne když nebude existovat! RUN if [ -f /server/.easter-eggs.json ]; then cp /server/.easter-eggs.json ./server/; fi
COPY /server/.easter-eggs.json ./server/
# Export /data/db.json do složky /data
VOLUME ["/data"]
EXPOSE 3000 EXPOSE 3000

26
Dockerfile-Woodpecker Normal file
View File

@@ -0,0 +1,26 @@
ARG NODE_VERSION="node:22-alpine"
FROM ${NODE_VERSION}
RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \
LC_ALL=cs_CZ.UTF-8 \
NODE_ENV=production
WORKDIR /app
# Vykopírování sestaveného serveru
COPY ./server/node_modules ./server/node_modules
COPY ./server/dist ./
# TODO tohle není dobře, má to být součástí serveru
# COPY ./server/resources ./resources
# Vykopírování sestaveného klienta
COPY ./client/dist ./public
# Zkopírování konfigurace easter eggů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
EXPOSE 3000
CMD [ "node", "./server/src/index.js" ]

View File

@@ -1,7 +1,9 @@
# Luncher # Luncher
Aplikace pro profesionální management obědů. Aplikace pro profesionální management obědů.
Aplikace sestává ze dvou modulů + společných TypeScript definic (adresář `types`). Aplikace sestává ze tří modulů.
- types
- OpenAPI definice společných typů, generované přes [openapi-ts](https://github.com/hey-api/openapi-ts)
- server - server
- backend psaný v [node.js](https://nodejs.dev) - backend psaný v [node.js](https://nodejs.dev)
- client - client
@@ -10,19 +12,27 @@ Aplikace sestává ze dvou modulů + společných TypeScript definic (adresář
## Spuštění pro vývoj ## Spuštění pro vývoj
### Závislosti ### Závislosti
#### Klient/server #### Klient/server
- [Node.js 18.x](https://nodejs.dev) - [Node.js 22.x (>= 22.11)](https://nodejs.dev)
- [Yarn 1.22.x (Classic)](https://classic.yarnpkg.com) - [Yarn 1.22.x (Classic)](https://classic.yarnpkg.com)
### Spuštění na *nix platformách ### Spuštění na *nix platformách
- Nainstalovat závislosti viz předchozí bod - Nainstalovat závislosti viz předchozí bod
- Zkopírovat `server/.env.template` do `server/.env.development` a upravit dle potřeby - Zkopírovat `server/.env.template` do `server/.env.development` a upravit dle potřeby
- Spustit `./run_dev.sh`. Na jiných platformách se lze inspirovat jeho obsahem, postup by měl být víceméně stejný. - Vygenerovat společné TypeScript typy
- `cd types && yarn install && yarn openapi-ts`
- Server
- `cd server && yarn install && export NODE_ENV=development && yarn startReload`
- Klient
- `cd client && yarn install && yarn start`
## Sestavení a spuštění produkční verze v Docker ## Sestavení a spuštění produkční verze v Docker
### Závislosti ### Závislosti
- [Docker](https://www.docker.com) - [Docker](https://www.docker.com)
- [Docker Compose](https://docs.docker.com/compose) - [Docker Compose](https://docs.docker.com/compose)
### Spuštění
- `docker compose up --build -d`
### Spuštení s traefik ### Spuštení s traefik
- `docker compose -f compose-traefik.yml up --build -d` - `docker compose -f compose-traefik.yml up --build -d`

1
client/.gitignore vendored
View File

@@ -1,3 +1,2 @@
build build
dist dist
src/types

View File

@@ -10,6 +10,26 @@
<link rel="apple-touch-icon" href="/logo192.png" /> <link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<title>Luncher</title> <title>Luncher</title>
<script>
(function() {
try {
var saved = localStorage.getItem('theme_preference');
var theme;
if (saved === 'dark') {
theme = 'dark';
} else if (saved === 'light') {
theme = 'light';
} else {
// 'system' nebo neuloženo - použij systémové nastavení
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-bs-theme', theme);
} catch (e) {
// Fallback pokud localStorage není dostupný
document.documentElement.setAttribute('data-bs-theme', 'light');
}
})();
</script>
</head> </head>
<body> <body>

View File

@@ -6,34 +6,37 @@
"type": "module", "type": "module",
"homepage": ".", "homepage": ".",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^6.4.0", "@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^3.1.0",
"@types/jest": "^29.5.12", "@types/jest": "^30.0.0",
"@types/node": "^20.11.20", "@types/node": "^24.10.0",
"@types/react": "^19.0.0", "@types/react": "^19.2.2",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^5.1.0",
"bootstrap": "^5.2.3", "bootstrap": "^5.3.8",
"react": "^19.0.0", "react": "^19.2.0",
"react-bootstrap": "^2.7.2", "react-bootstrap": "^2.10.10",
"react-dom": "^19.0.0", "react-dom": "^19.2.0",
"react-jwt": "^1.2.0", "react-jwt": "^1.3.0",
"react-modal": "^3.16.1", "react-modal": "^3.16.3",
"react-router": "^7.9.5",
"react-router-dom": "^7.9.5",
"react-select-search": "^4.1.6", "react-select-search": "^4.1.6",
"react-snowfall": "^2.2.0", "react-snow-overlay": "^1.0.14",
"react-toastify": "^10.0.4", "react-snowfall": "^2.3.0",
"sass": "^1.80.6", "react-toastify": "^11.0.5",
"recharts": "^3.4.1",
"sass": "^1.93.3",
"socket.io-client": "^4.6.1", "socket.io-client": "^4.6.1",
"typescript": "^5.3.3", "typescript": "^5.9.3",
"vite": "^6.0.3", "vite": "^7.2.2",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"scripts": { "scripts": {
"copy-types": "cp -r ../types ./src", "start": "yarn vite",
"start": "yarn copy-types && vite", "build": "tsc --noEmit && yarn vite build"
"build": "yarn copy-types && vite build"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
@@ -54,6 +57,6 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"prettier": "^3.2.5" "prettier": "^3.6.2"
} }
} }

View File

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

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.3 KiB

22
client/public/leaf.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 2.3 KiB

46
client/public/sw.js Normal file
View File

@@ -0,0 +1,46 @@
// 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',
actions: [
{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' },
],
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'neobedvam') {
event.waitUntil(
self.registration.pushManager.getSubscription().then((subscription) => {
if (!subscription) return;
return fetch('/api/notifications/push/quickChoice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
})
);
return;
}
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clientList) => {
// Pokud je již otevřené okno, zaostříme na něj
for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
return client.focus();
}
}
// Jinak otevřeme nové
return self.clients.openWindow('/');
})
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,27 @@
import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react'; import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react';
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket'; import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket';
import { addPizza, createPizzaDay, deletePizzaDay, finishDelivery, finishOrder, lockPizzaDay, removePizza, unlockPizzaDay, updatePizzaDayNote } from './api/PizzaDayApi';
import { useAuth } from './context/auth'; import { useAuth } from './context/auth';
import Login from './Login'; import Login from './Login';
import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap'; import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap';
import Header from './components/Header'; import Header from './components/Header';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import PizzaOrderList from './components/PizzaOrderList'; import PizzaOrderList from './components/PizzaOrderList';
import SelectSearch, { SelectedOptionValue } from 'react-select-search'; import SelectSearch, { SelectedOptionValue, SelectSearchOption } from 'react-select-search';
import 'react-select-search/style.css'; import 'react-select-search/style.css';
import './App.scss'; import './App.scss';
import { SelectSearchOption } from 'react-select-search'; import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
import { faCircleCheck, faNoteSticky, faTrashCan } from '@fortawesome/free-regular-svg-icons';
import { useSettings } from './context/settings'; import { useSettings } from './context/settings';
import { ClientData, Restaurants, Food, Order, Locations, PizzaOrder, PizzaDayState, FoodChoices, DayMenu, DepartureTime, LocationKey } from './types';
import Footer from './components/Footer'; import Footer from './components/Footer';
import { faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch } from '@fortawesome/free-solid-svg-icons'; import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
import Loader from './components/Loader'; import Loader from './components/Loader';
import { getData, errorHandler, getQrUrl } from './api/Api'; import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils';
import { addChoice, removeChoices, removeChoice, changeDepartureTime, jdemeObed, updateNote } from './api/FoodApi';
import { getHumanDateTime, isInTheFuture } from './Utils';
import NoteModal from './components/modals/NoteModal'; import NoteModal from './components/modals/NoteModal';
import { useEasterEgg } from './context/eggs'; import { useEasterEgg } from './context/eggs';
import { getImage } from './api/EasterEggApi'; import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr } from '../../types';
import { getLunchChoiceName } from './enums';
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
// import './FallingLeaves.scss';
const EVENT_CONNECT = "connect" const EVENT_CONNECT = "connect"
@@ -34,17 +32,37 @@ const EASTER_EGG_STYLE = {
animationTimingFunction: "ease" animationTimingFunction: "ease"
} }
// Mapování čísel alergenů na jejich názvy
const ALLERGENS: { [key: number]: string } = {
1: "Obiloviny obsahující lepek",
2: "Korýši a výrobky z nich",
3: "Vejce a výrobky z nich",
4: "Ryby a výrobky z nich",
5: "Arašidy a výrobky z nich",
6: "Sója a výrobky z nich",
7: "Mléko a výrobky z nich (včetně laktózy)",
8: "Skořápkové plody",
9: "Celer a výrobky z něj",
10: "Hořčice a výrobky z ní",
11: "Sezamová semena a výrobky z nich",
12: "Oxid siřičitý a siřičitany",
13: "Vlčí bob (Lupina) a výrobky z něj",
14: "Měkkýši a výrobky z nich"
}
const LINK_ALLERGENS = 'https://www.strava.cz/Strava/Napoveda/cz/Prilohy/alergeny.pdf';
// Výchozí doba trvání animace v sekundách, pokud není přetíženo v konfiguračním JSONu // Výchozí doba trvání animace v sekundách, pokud není přetíženo v konfiguračním JSONu
const EASTER_EGG_DEFAULT_DURATION = 0.75; const EASTER_EGG_DEFAULT_DURATION = 0.75;
function App() { function App() {
const auth = useAuth(); const auth = useAuth();
const settings = useSettings(); const settings = useSettings();
const [easterEgg, easterEggLoading] = useEasterEgg(auth); const [easterEgg, _] = useEasterEgg(auth);
const [isConnected, setIsConnected] = useState<boolean>(false); const [isConnected, setIsConnected] = useState<boolean>(false);
const [data, setData] = useState<ClientData>(); const [data, setData] = useState<ClientData>();
const [food, setFood] = useState<{ [key in Restaurants]?: DayMenu }>(); const [food, setFood] = useState<RestaurantDayMenuMap>();
const [myOrder, setMyOrder] = useState<Order>(); const [myOrder, setMyOrder] = useState<PizzaOrder>();
const [foodChoiceList, setFoodChoiceList] = useState<Food[]>(); const [foodChoiceList, setFoodChoiceList] = useState<Food[]>();
const [closed, setClosed] = useState<boolean>(false); const [closed, setClosed] = useState<boolean>(false);
const socket = useContext(SocketContext); const socket = useContext(SocketContext);
@@ -64,14 +82,17 @@ function App() {
// Načtení dat po přihlášení // Načtení dat po přihlášení
useEffect(() => { useEffect(() => {
if (!auth || !auth.login) { if (!auth?.login) {
return return
} }
getData().then((data: ClientData) => { getData().then(response => {
setData(data); const data = response.data
setDayIndex(data.weekIndex); if (data) {
dayIndexRef.current = data.weekIndex; setData(data);
setFood(data.menus); setDayIndex(data.dayIndex);
dayIndexRef.current = data.dayIndex;
setFood(data.menus);
}
}).catch(e => { }).catch(e => {
setFailure(true); setFailure(true);
}) })
@@ -79,12 +100,15 @@ function App() {
// Přenačtení pro zvolený den // Přenačtení pro zvolený den
useEffect(() => { useEffect(() => {
if (!auth || !auth.login) { if (!auth?.login) {
return return
} }
getData(dayIndex).then((data: ClientData) => { getData({ query: { dayIndex: dayIndex } }).then(response => {
const data = response.data;
setData(data); setData(data);
setFood(data.menus); if (data) {
setFood(data.menus);
}
}).catch(e => { }).catch(e => {
setFailure(true); setFailure(true);
}) })
@@ -93,17 +117,15 @@ function App() {
// Registrace socket eventů // Registrace socket eventů
useEffect(() => { useEffect(() => {
socket.on(EVENT_CONNECT, () => { socket.on(EVENT_CONNECT, () => {
// console.log("Connected!");
setIsConnected(true); setIsConnected(true);
}); });
socket.on(EVENT_DISCONNECT, () => { socket.on(EVENT_DISCONNECT, () => {
// console.log("Disconnected!");
setIsConnected(false); setIsConnected(false);
}); });
socket.on(EVENT_MESSAGE, (newData: ClientData) => { socket.on(EVENT_MESSAGE, (newData: ClientData) => {
// console.log("Přijata nová data ze socketu", newData); // console.log("Přijata nová data ze socketu", newData);
// Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený // Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený
if (dayIndexRef.current == null || newData.weekIndex === dayIndexRef.current) { if (dayIndexRef.current == null || newData.dayIndex === dayIndexRef.current) {
setData(newData); setData(newData);
} }
}); });
@@ -116,19 +138,33 @@ function App() {
}, [socket]); }, [socket]);
useEffect(() => { useEffect(() => {
if (!auth || !auth.login) { if (!auth?.login || !data?.choices) {
return return
} }
// TODO tohle občas náhodně nezafunguje, nutno přepsat, viz https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780 // Pre-fill form refs from existing choices
// TODO nutno opravit let foundKey: LunchChoice | undefined;
// if (data?.choices && choiceRef.current) { let foundChoice: UserLunchChoice | undefined;
// for (let entry of Object.entries(data.choices)) { for (const key of Object.keys(data.choices)) {
// if (entry[1].includes(auth.login)) { const locationKey = key as LunchChoice;
// const value = entry[0] as any as number; // TODO tohle je absurdní const locationChoices = data.choices[locationKey];
// choiceRef.current.value = Object.values(Locations)[value]; if (locationChoices && auth.login in locationChoices) {
// } foundKey = locationKey;
// } foundChoice = locationChoices[auth.login];
// } break;
}
}
if (foundKey && choiceRef.current) {
choiceRef.current.value = foundKey;
const restaurantKey = Object.keys(Restaurant).indexOf(foundKey);
if (restaurantKey > -1 && food) {
const restaurant = Object.keys(Restaurant)[restaurantKey] as Restaurant;
setFoodChoiceList(food[restaurant]?.food);
setClosed(food[restaurant]?.closed ?? false);
}
}
if (foundChoice?.departureTime && departureChoiceRef.current) {
departureChoiceRef.current.value = foundChoice.departureTime;
}
}, [auth, auth?.login, data?.choices]) }, [auth, auth?.login, data?.choices])
// Reference na mojí objednávku // Reference na mojí objednávku
@@ -139,12 +175,17 @@ function App() {
} }
}, [auth?.login, data?.pizzaDay?.orders]) }, [auth?.login, data?.pizzaDay?.orders])
// Kontrola, zda má uživatel vybranou volbu PIZZA
const userHasPizzaChoice = useMemo(() => {
return auth?.login ? data?.choices?.PIZZA?.[auth.login] != null : false;
}, [data?.choices?.PIZZA, auth?.login]);
useEffect(() => { useEffect(() => {
if (choiceRef?.current?.value && choiceRef.current.value !== "") { if (choiceRef?.current?.value && choiceRef.current.value !== "") {
const locationKey = choiceRef.current.value as LocationKey; const locationKey = choiceRef.current.value as LunchChoice;
const restaurantKey = Object.keys(Restaurants).indexOf(locationKey); const restaurantKey = Object.keys(Restaurant).indexOf(locationKey);
if (restaurantKey > -1 && food) { if (restaurantKey > -1 && food) {
const restaurant = Object.values(Restaurants)[restaurantKey]; const restaurant = Object.keys(Restaurant)[restaurantKey] as Restaurant;
setFoodChoiceList(food[restaurant]?.food); setFoodChoiceList(food[restaurant]?.food);
setClosed(food[restaurant]?.closed ?? false); setClosed(food[restaurant]?.closed ?? false);
} else { } else {
@@ -176,9 +217,9 @@ function App() {
// Stažení a nastavení easter egg obrázku // Stažení a nastavení easter egg obrázku
useEffect(() => { useEffect(() => {
if (auth?.login && easterEgg?.url && !eggImage) { if (auth?.login && easterEgg?.url && !eggImage) {
getImage(easterEgg.url).then(data => { getEasterEggImage({ path: { url: easterEgg.url } }).then(response => {
if (data) { if (response.data) {
setEggImage(data); setEggImage(response.data);
// Smazání obrázku z DOMu po animaci // Smazání obrázku z DOMu po animaci
setTimeout(() => { setTimeout(() => {
if (eggRef?.current) { if (eggRef?.current) {
@@ -190,12 +231,97 @@ function App() {
} }
}, [auth?.login, easterEgg?.duration, easterEgg?.url, eggImage]); }, [auth?.login, easterEgg?.duration, easterEgg?.url, eggImage]);
// Pomocná funkce pro kontrolu a potvrzení změny volby při existujícím Pizza day
const checkPizzaDayBeforeChange = async (newLocationKey: LunchChoice): Promise<boolean> => {
if (!auth?.login || !data) return false;
// Kontrola, zda uživatel má vybranou PIZZA a mění na něco jiného
const hasPizzaChoice = data.choices?.PIZZA?.[auth.login] != null;
const isCreator = data.pizzaDay?.creator === auth.login;
const isPizzaDayCreated = data.pizzaDay?.state === PizzaDayState.CREATED;
// Pokud není vybraná PIZZA nebo přepínáme na PIZZA, není potřeba kontrolovat
if (!hasPizzaChoice || newLocationKey === LunchChoice.PIZZA) {
return true;
}
// Pokud uživatel není zakladatel Pizza day, není potřeba dialogu
if (!isCreator || !data?.pizzaDay) {
return true;
}
// Uživatel je zakladatel Pizza day a mění volbu z PIZZA
if (!isPizzaDayCreated) {
// Pizza day není ve stavu CREATED, nelze změnit volbu
alert(`Nelze změnit volbu. Pizza day je ve stavu "${data.pizzaDay.state}" a musí být nejprve dokončen.`);
return false;
}
// Pizza day je CREATED, zobrazit potvrzovací dialog
const confirmed = window.confirm(
'Jsi zakladatel aktivního Pizza day. Změna volby smaže celý Pizza day včetně všech objednávek. Pokračovat?'
);
if (!confirmed) {
return false;
}
// Uživatel potvrdil, smazat Pizza day
try {
await deletePizzaDay();
return true;
} catch (error: any) {
alert(`Chyba při mazání Pizza day: ${error.message || error}`);
return false;
}
};
const doAddClickFoodChoice = async (location: LunchChoice, foodIndex?: number) => {
if (document.getSelection()?.type !== 'Range') { // pouze pokud se nejedná o výběr textu
if (canChangeChoice && auth?.login) {
// Kontrola Pizza day před změnou volby
const canProceed = await checkPizzaDayBeforeChange(location);
if (!canProceed) {
return;
}
try {
await addChoice({ body: { locationKey: location, foodIndex, dayIndex } });
} catch (error: any) {
alert(`Chyba při změně volby: ${error.message || error}`);
}
}
}
}
const doAddChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => { const doAddChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const locationKey = event.target.value as LocationKey; const locationKey = event.target.value as LunchChoice;
if (auth?.login) { if (canChangeChoice && auth?.login) {
await errorHandler(() => addChoice(locationKey, undefined, dayIndex)); // Kontrola Pizza day před změnou volby
if (foodChoiceRef.current?.value) { const canProceed = await checkPizzaDayBeforeChange(locationKey);
foodChoiceRef.current.value = ""; if (!canProceed) {
// Uživatel zrušil akci nebo došlo k chybě, reset výběru zpět na PIZZA
if (choiceRef.current) {
choiceRef.current.value = LunchChoice.PIZZA;
}
return;
}
try {
await addChoice({ body: { locationKey, dayIndex } });
if (foodChoiceRef.current?.value) {
foodChoiceRef.current.value = "";
}
choiceRef.current?.blur();
} catch (error: any) {
alert(`Chyba při změně volby: ${error.message || error}`);
// Reset výběru zpět
const hasPizzaChoice = data?.choices?.PIZZA?.[auth.login] != null;
if (choiceRef.current && hasPizzaChoice) {
choiceRef.current.value = LunchChoice.PIZZA;
} else if (choiceRef.current) {
choiceRef.current.value = "";
}
} }
} }
} }
@@ -208,16 +334,16 @@ function App() {
const doAddFoodChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => { const doAddFoodChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
if (event.target.value && foodChoiceList?.length && choiceRef.current?.value) { if (event.target.value && foodChoiceList?.length && choiceRef.current?.value) {
const locationKey = choiceRef.current.value as LocationKey; const locationKey = choiceRef.current.value as LunchChoice;
if (auth?.login) { if (auth?.login) {
await errorHandler(() => addChoice(locationKey, Number(event.target.value), dayIndex)); await addChoice({ body: { locationKey, foodIndex: Number(event.target.value), dayIndex } });
} }
} }
} }
const doRemoveChoices = async (locationKey: LocationKey) => { const doRemoveChoices = async (locationKey: LunchChoice) => {
if (auth?.login) { if (auth?.login) {
await errorHandler(() => removeChoices(locationKey, dayIndex)); await removeChoices({ body: { locationKey, dayIndex } });
// Vyresetujeme výběr, aby bylo jasné pro který případně vybíráme jídlo // Vyresetujeme výběr, aby bylo jasné pro který případně vybíráme jídlo
if (choiceRef?.current?.value) { if (choiceRef?.current?.value) {
choiceRef.current.value = ""; choiceRef.current.value = "";
@@ -228,9 +354,9 @@ function App() {
} }
} }
const doRemoveFoodChoice = async (locationKey: LocationKey, foodIndex: number) => { const doRemoveFoodChoice = async (locationKey: LunchChoice, foodIndex: number) => {
if (auth?.login) { if (auth?.login) {
await errorHandler(() => removeChoice(locationKey, foodIndex, dayIndex)); await removeChoice({ body: { locationKey, foodIndex, dayIndex } });
if (choiceRef?.current?.value) { if (choiceRef?.current?.value) {
choiceRef.current.value = ""; choiceRef.current.value = "";
} }
@@ -242,11 +368,23 @@ function App() {
const saveNote = async (note?: string) => { const saveNote = async (note?: string) => {
if (auth?.login) { if (auth?.login) {
await errorHandler(() => updateNote(note, dayIndex)); await updateNote({ body: { note, dayIndex } });
setNoteModalOpen(false); setNoteModalOpen(false);
} }
} }
const copyNote = async (note: string) => {
if (auth?.login && note) {
await updateNote({ body: { note, dayIndex } });
}
}
const markAsBuyer = async () => {
if (auth?.login) {
await setBuyer();
}
}
const pizzaSuggestions = useMemo(() => { const pizzaSuggestions = useMemo(() => {
if (!data?.pizzaList) { if (!data?.pizzaList) {
return []; return [];
@@ -266,18 +404,18 @@ function App() {
const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => { const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => {
if (auth?.login && data?.pizzaList) { if (auth?.login && data?.pizzaList) {
if (!(typeof value === 'string')) { if (typeof value !== 'string') {
throw Error('Nepodporovaný typ hodnoty'); throw new TypeError('Nepodporovaný typ hodnoty: ' + typeof value);
} }
const s = value.split('|'); const s = value.split('|');
const pizzaIndex = Number.parseInt(s[0]); const pizzaIndex = Number.parseInt(s[0]);
const pizzaSizeIndex = Number.parseInt(s[1]); const pizzaSizeIndex = Number.parseInt(s[1]);
await addPizza(pizzaIndex, pizzaSizeIndex); await addPizza({ body: { pizzaIndex, pizzaSizeIndex } });
} }
} }
const handlePizzaDelete = async (pizzaOrder: PizzaOrder) => { const handlePizzaDelete = async (pizzaOrder: PizzaVariant) => {
await removePizza(pizzaOrder); await removePizza({ body: { pizzaOrder } });
} }
const handlePizzaPoznamkaChange = async () => { const handlePizzaPoznamkaChange = async () => {
@@ -285,36 +423,12 @@ function App() {
alert("Poznámka může mít maximálně 70 znaků"); alert("Poznámka může mít maximálně 70 znaků");
return; return;
} }
updatePizzaDayNote(pizzaPoznamkaRef.current?.value); updatePizzaDayNote({ body: { note: pizzaPoznamkaRef.current?.value } });
} }
// const addToCart = async () => {
// TODO aktuálně nefunkční - nedokážeme poslat PHPSESSIONID cookie
// if (data?.pizzaDay?.orders) {
// for (const order of data?.pizzaDay?.orders) {
// for (const pizzaOrder of order.pizzaList) {
// const url = 'https://www.pizzachefie.cz/pridat.html';
// const payload = new URLSearchParams();
// payload.append('varId', pizzaOrder.varId.toString());
// await fetch(url, {
// method: "POST",
// mode: "no-cors",
// cache: "no-cache",
// credentials: "same-origin",
// headers: {
// 'Content-Type': 'application/x-www-form-urlencoded',
// },
// body: payload,
// })
// }
// }
// // TODO otevřít košík v nové záložce
// }
// }
const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => { const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => {
if (foodChoiceList?.length && choiceRef.current?.value) { if (foodChoiceList?.length && choiceRef.current?.value) {
await changeDepartureTime(event.target.value, dayIndex); await changeDepartureTime({ body: { time: event.target.value as DepartureTime, dayIndex } });
} }
} }
@@ -332,33 +446,67 @@ function App() {
} }
} }
const renderFoodTable = (name: string, menu: DayMenu) => { const renderFoodTable = (location: Restaurant, menu: RestaurantDayMenu) => {
let content; let content;
if (menu?.closed) { if (menu?.closed) {
content = <h3>Zavřeno</h3> content = <div className="restaurant-closed">Zavřeno</div>
} else if (menu?.food?.length > 0) { } else if (menu?.food?.length && menu.food.length > 0) {
content = <Table striped bordered hover> const hideSoups = settings?.hideSoups;
<tbody> content = <Table className="food-table">
{menu.food.filter(f => (settings?.hideSoups ? !f.isSoup : true)).map((f: any, index: number) => <tbody style={{ cursor: canChangeChoice ? 'pointer' : 'default' }}>
<tr key={index}> {menu.food.map((f: Food, index: number) =>
<td>{f.amount}</td> (!hideSoups || !f.isSoup) &&
<td>{f.name}</td> <tr key={f.name} onClick={() => doAddClickFoodChoice(location, index)}>
<td>{f.price}</td> <td>
<div className="food-name">
{f.name}
{f.allergens && f.allergens.length > 0 && (
<span className="food-allergens">
{' '}({f.allergens.map((a, idx) => (
<span key={a}>
<span className="allergen-link" title={ALLERGENS[a]} onClick={e => {
e.stopPropagation();
window.open(LINK_ALLERGENS, '_blank');
}}>{a}</span>
{idx < f.allergens!.length - 1 && ', '}
</span>
))})
</span>
)}
</div>
<div className="food-meta">
{f.amount && f.amount !== '-' && <span className="food-amount">{f.amount}</span>}
<span className="food-price">{f.price}</span>
</div>
</td>
</tr> </tr>
)} )}
</tbody> </tbody>
</Table> </Table>
} else { } else {
content = <h3>Chyba načtení dat</h3> content = <div className="restaurant-error">Chyba načtení dat</div>
} }
return <Col md={12} lg={4} className='mt-3'> return <Col md={6} lg={3} className='mt-3'>
<h3>{name}</h3> <div className="restaurant-card">
{menu?.lastUpdate && <small>Poslední aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}</small>} <div className="restaurant-header" style={{ cursor: canChangeChoice ? 'pointer' : 'default' }} onClick={() => doAddClickFoodChoice(location)}>
{content} <div className="restaurant-header-content">
<h3>
{getLunchChoiceName(location)}
</h3>
{menu?.lastUpdate && <small>Aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}</small>}
</div>
{menu?.warnings && menu.warnings.length > 0 && (
<span className="restaurant-warning" title={menu.warnings.join('\n')}>
<FontAwesomeIcon icon={faTriangleExclamation} />
</span>
)}
</div>
{content}
</div>
</Col> </Col>
} }
if (!auth || !auth.login) { if (!auth?.login) {
return <Login />; return <Login />;
} }
@@ -387,124 +535,153 @@ function App() {
} }
const noOrders = data?.pizzaDay?.orders?.length === 0; const noOrders = data?.pizzaDay?.orders?.length === 0;
const canChangeChoice = dayIndex == null || data.todayWeekIndex == null || dayIndex >= data.todayWeekIndex; const canChangeChoice = dayIndex == null || data.todayDayIndex == null || dayIndex >= data.todayDayIndex;
const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {}; const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {};
return ( return (
<> <div className="app-container">
{easterEgg && eggImage && <img ref={eggRef} alt='' src={URL.createObjectURL(eggImage)} style={{ position: 'absolute', ...EASTER_EGG_STYLE, ...style, animationDuration: `${duration ?? EASTER_EGG_DEFAULT_DURATION}s` }} />} {easterEgg && eggImage && <img ref={eggRef} alt='' src={URL.createObjectURL(eggImage)} style={{ position: 'absolute', ...EASTER_EGG_STYLE, ...style, animationDuration: `${duration ?? EASTER_EGG_DEFAULT_DURATION}s` }} />}
<Header /> <Header choices={data?.choices} dayIndex={dayIndex} />
<div className='wrapper'> <div className='wrapper'>
{data.isWeekend ? <h4>Užívejte víkend :)</h4> : <> {data.todayDayIndex != null && data.todayDayIndex > 4 &&
<Alert variant={'primary'}> <Alert variant="info" className="mb-3">
<img src='hat.png' style={{ position: "absolute", width: "70px", rotate: "-45deg", left: -40, top: -58 }} /> Zobrazujete uplynulý týden
<img src='snowman.png' style={{ position: "absolute", height: "110px", right: 10, top: 5 }} />
Poslední změny:
<ul>
<li>Zimní atmosféra</li>
<li>Odstranění podniku U Motlíků</li>
</ul>
</Alert> </Alert>
}
<>
{dayIndex != null && {dayIndex != null &&
<div className='day-navigator'> <div className='day-navigator'>
<FontAwesomeIcon title="Předchozí den" icon={faChevronLeft} style={{ cursor: "pointer", visibility: dayIndex > 0 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex - 1)} /> <span title='Předchozí den'>
<h1 className='title' style={{ color: dayIndex === data.todayWeekIndex ? 'black' : 'gray' }}>{data.date}</h1> <FontAwesomeIcon icon={faChevronLeft} style={{ cursor: "pointer", visibility: dayIndex > 0 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex - 1)} />
<FontAwesomeIcon title="Následující den" icon={faChevronRight} style={{ cursor: "pointer", visibility: dayIndex < 4 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex + 1)} /> </span>
<h1 className={`title ${dayIndex !== data.todayDayIndex ? 'text-muted' : ''}`}>{data.date}</h1>
<span title="Následující den">
<FontAwesomeIcon icon={faChevronRight} style={{ cursor: "pointer", visibility: dayIndex < 4 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex + 1)} />
</span>
</div> </div>
} }
<Row className='food-tables'> <Row className='food-tables'>
{food[Restaurants.SLADOVNICKA] && renderFoodTable('Sladovnická', food[Restaurants.SLADOVNICKA])} {Object.keys(Restaurant).map(key => {
{/* {food[Restaurants.UMOTLIKU] && renderFoodTable('U Motlíků', food[Restaurants.UMOTLIKU])} */} const locationKey = key as Restaurant;
{food[Restaurants.TECHTOWER] && renderFoodTable('TechTower', food[Restaurants.TECHTOWER])} return food[locationKey] && renderFoodTable(locationKey, food[locationKey]);
{food[Restaurants.ZASTAVKAUMICHALA] && renderFoodTable('Zastávka u Michala', food[Restaurants.ZASTAVKAUMICHALA])} })}
</Row> </Row>
<div className='content-wrapper'> <div className='content-wrapper'>
<div className='content'> <div className='content'>
{canChangeChoice && <> {canChangeChoice && <div className="choice-section fade-in">
<p>{`Jak to ${dayIndex == null || dayIndex === data.todayWeekIndex ? 'dnes' : 'tento den'} vidíš s obědem?`}</p> <p>{`Jak to ${dayIndex == null || dayIndex === data.todayDayIndex ? 'dnes' : 'tento den'} vidíš s obědem?`}</p>
<Form.Select ref={choiceRef} onChange={doAddChoice}> <Form.Select ref={choiceRef} onChange={doAddChoice}>
<option></option> <option value="">Vyber možnost...</option>
{Object.entries(Locations) {Object.entries(LunchChoice)
.filter(entry => { .filter(entry => {
const locationKey = entry[0] as LocationKey; const locationKey = entry[0] as Restaurant;
const restaurantKey = Object.keys(Restaurants).indexOf(locationKey); return !food[locationKey]?.closed;
const v = Object.values(Restaurants)[restaurantKey];
return v == null || !food[v]?.closed;
}) })
.map(entry => <option key={entry[0]} value={entry[0]}>{entry[1]}</option>)} .map(entry => <option key={entry[0]} value={entry[0]}>{getLunchChoiceName(entry[1])}</option>)}
</Form.Select> </Form.Select>
<small>Je možné vybrat jen jednu možnost. Výběr jiné odstraní předchozí.</small> <small>Je možné vybrat jen jednu možnost. Výběr jiné odstraní předchozí.</small>
{foodChoiceList && !closed && <> {foodChoiceList && !closed && <>
<p style={{ marginTop: "10px" }}>Na co dobrého? <small>(nepovinné)</small></p> <p className="mt-3">Na co dobrého? <small style={{ color: 'var(--luncher-text-muted)' }}>(nepovinné)</small></p>
<Form.Select ref={foodChoiceRef} onChange={doAddFoodChoice}> <Form.Select ref={foodChoiceRef} onChange={doAddFoodChoice}>
<option></option> <option value="">Vyber jídlo...</option>
{foodChoiceList.map((food, index) => <option key={index} value={index}>{food.name}</option>)} {foodChoiceList.map((food, index) => <option key={food.name} value={index}>{food.name}</option>)}
</Form.Select> </Form.Select>
</>} </>}
{foodChoiceList && !closed && <> {foodChoiceList && !closed && <>
<p style={{ marginTop: "10px" }}>V kolik hodin preferuješ odchod?</p> <p className="mt-3">V kolik hodin preferuješ odchod?</p>
<Form.Select ref={departureChoiceRef} onChange={handleChangeDepartureTime}> <Form.Select ref={departureChoiceRef} onChange={handleChangeDepartureTime}>
<option></option> <option value="">Vyber čas...</option>
{Object.values(DepartureTime) {Object.values(DepartureTime)
.filter(time => isInTheFuture(time)) .filter(time => isInTheFuture(time))
.map(time => <option key={time} value={time}>{time}</option>)} .map(time => <option key={time} value={time}>{time}</option>)}
</Form.Select> </Form.Select>
</>} </>}
</>} </div>}
{Object.keys(data.choices).length > 0 ? {Object.keys(data.choices).length > 0 ?
<Table bordered className='mt-5'> <Table className='choices-table mt-4 fade-in'>
<tbody> <tbody>
{Object.keys(data.choices).map(key => { {Object.keys(data.choices).map(key => {
const locationKey = key as LocationKey; const locationKey = key as LunchChoice;
const locationName = Locations[locationKey]; const locationName = getLunchChoiceName(locationKey);
const loginObject = data.choices[locationKey]; const loginObject = data.choices[locationKey];
if (!loginObject) { if (!loginObject) {
return; return null;
} }
const locationLoginList = Object.entries(loginObject); const locationLoginList = Object.entries(loginObject);
const locationPickCount = locationLoginList.length
return ( return (
<tr key={key}> <tr key={key}>
<td>{locationName}</td> <td>
{locationName}
{(locationPickCount ?? 0) > 1 && <span className="ms-1">({locationPickCount})</span>}
</td>
<td className='p-0'> <td className='p-0'>
<Table> <Table className="nested-table">
<tbody> <tbody>
{locationLoginList.map((entry: [string, FoodChoices], index) => { {locationLoginList.map((entry: [string, UserLunchChoice]) => {
const login = entry[0]; const login = entry[0];
const userPayload = entry[1]; const userPayload = entry[1];
const userChoices = userPayload?.options; const userChoices = userPayload?.selectedFoods;
const trusted = userPayload?.trusted || false; const trusted = userPayload?.trusted || false;
return <tr key={index}> const isBuyer = userPayload?.isBuyer || false;
return <tr key={entry[0]}>
<td> <td>
{trusted && <span className='trusted-icon'> <div className="user-row">
<FontAwesomeIcon title='Uživatel ověřený doménovým přihlášením' icon={faCircleCheck} style={{ cursor: "help" }} /> <div className="user-info">
</span>} {trusted && <span className='trusted-icon' title='Uživatel ověřený doménovým přihlášením'>
{login} <FontAwesomeIcon icon={faCircleCheck} style={{ cursor: "help" }} />
{userPayload.departureTime && <small> ({userPayload.departureTime})</small>} </span>}
{userPayload.note && <small style={{ overflowWrap: 'anywhere' }}> ({userPayload.note})</small>} <strong>{login}</strong>
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => { {userPayload.departureTime && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>({userPayload.departureTime})</small>}
setNoteModalOpen(true); {userPayload.note && <span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>({userPayload.note})</span>}
}} title='Upravit poznámku' className='action-icon' icon={faNoteSticky} />} </div>
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => { <div className="user-actions">
doRemoveChoices(key as LocationKey); {login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && <span title='Označit/odznačit se jako objednávající'>
}} title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`} className='action-icon' icon={faTrashCan} />} <FontAwesomeIcon onClick={() => {
markAsBuyer();
}} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{ cursor: 'pointer' }} />
</span>}
{login !== auth.login && locationKey === LunchChoice.OBJEDNAVAM && isBuyer && <span title='Objednávající'>
<FontAwesomeIcon onClick={() => {
copyNote(userPayload.note!);
}} icon={faBasketShopping} className='buyer-icon' />
</span>}
{login !== auth.login && canChangeChoice && userPayload?.note?.length && <span title='Převzít poznámku'>
<FontAwesomeIcon onClick={() => {
copyNote(userPayload.note!);
}} className='action-icon' icon={faComment} />
</span>}
{login === auth.login && canChangeChoice && <span title='Upravit poznámku'>
<FontAwesomeIcon onClick={() => {
setNoteModalOpen(true);
}} className='action-icon' icon={faNoteSticky} />
</span>}
{login === auth.login && canChangeChoice && <span title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`}>
<FontAwesomeIcon onClick={() => {
doRemoveChoices(key as LunchChoice);
}} className='action-icon' icon={faTrashCan} />
</span>}
</div>
</div>
{userChoices && userChoices.length > 0 && food && (
<div className="food-choices">
{userChoices.map(foodIndex => {
const restaurantKey = key as Restaurant;
const foodName = food[restaurantKey]?.food?.[foodIndex].name;
return <div key={foodIndex} className="food-choice-item">
<span className="food-choice-name">{foodName}</span>
{login === auth.login && canChangeChoice &&
<span title={`Odstranit ${foodName}`}>
<FontAwesomeIcon onClick={() => {
doRemoveFoodChoice(restaurantKey, foodIndex);
}} className='action-icon' icon={faTrashCan} />
</span>}
</div>
})}
</div>
)}
</td> </td>
{userChoices?.length && food ? <td>
<ul>
{userChoices?.map(foodIndex => {
// TODO narovnat, tohle je zbytečně složité
const restaurantKey = Object.keys(Restaurants).indexOf(key);
const restaurant = Object.values(Restaurants)[restaurantKey];
const foodName = food[restaurant]?.food[foodIndex].name;
return <li key={foodIndex}>
{foodName}
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => {
doRemoveFoodChoice(key as LocationKey, foodIndex);
}} title={`Odstranit ${foodName}`} className='action-icon' icon={faTrashCan} />}
</li>
})}
</ul>
</td> : null}
</tr> </tr>
} }
)} )}
@@ -516,97 +693,94 @@ function App() {
)} )}
</tbody> </tbody>
</Table> </Table>
: <div className='mt-5'><i>Zatím nikdo nehlasoval...</i></div> : <div className='no-votes mt-4'>Zatím nikdo nehlasoval...</div>
} }
</div> </div>
{dayIndex === data.todayWeekIndex && {dayIndex === data.todayDayIndex && userHasPizzaChoice &&
<div className='mt-5'> <div className='pizza-section fade-in'>
{!data.pizzaDay && {!data.pizzaDay &&
<div style={{ textAlign: 'center' }}> <>
<h3>Pizza Day</h3>
<p>Pro dnešní den není aktuálně založen Pizza day.</p> <p>Pro dnešní den není aktuálně založen Pizza day.</p>
{loadingPizzaDay ? {loadingPizzaDay ?
<span> <span style={{ color: 'var(--luncher-primary)' }}>
<FontAwesomeIcon icon={faGear} className='fa-spin' /> Zjišťujeme dostupné pizzy <FontAwesomeIcon icon={faGear} className='fa-spin me-2' /> Zjišťujeme dostupné pizzy
</span> </span>
: :
<> <div>
<Button onClick={async () => { <Button onClick={async () => {
setLoadingPizzaDay(true); setLoadingPizzaDay(true);
await createPizzaDay().then(() => setLoadingPizzaDay(false)); await createPizzaDay().then(() => setLoadingPizzaDay(false));
}}>Založit Pizza day</Button> }}>Založit Pizza day</Button>
<Button onClick={doJdemeObed} style={{ marginLeft: "14px" }}>Jdeme na oběd !</Button> <Button variant="outline-primary" onClick={doJdemeObed}>Jdeme na oběd!</Button>
</> </div>
} }
</div> </>
} }
{data.pizzaDay && {data.pizzaDay &&
<div> <>
<div style={{ textAlign: 'center' }}> <h3>Pizza Day</h3>
<h3>Pizza day</h3> {
{ data.pizzaDay.state === PizzaDayState.CREATED &&
data.pizzaDay.state === PizzaDayState.CREATED && <>
<div> <p>
<p> Pizza Day je založen a spravován uživatelem <strong>{data.pizzaDay.creator}</strong>.<br />
Pizza Day je založen a spravován uživatelem {data.pizzaDay.creator}.<br /> Můžete upravovat své objednávky.
Můžete upravovat své objednávky. </p>
</p> {
{ data.pizzaDay.creator === auth.login &&
data.pizzaDay.creator === auth.login && <div className="mb-4">
<> <Button variant="danger" title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={async () => {
<Button className='danger mb-3' title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={async () => { await deletePizzaDay();
await deletePizzaDay(); }}>Smazat Pizza day</Button>
}}>Smazat Pizza day</Button> <Button title={noOrders ? "Nelze uzamknout - neexistuje žádná objednávka" : "Zamezí přidávat/odebírat objednávky. Použij před samotným objednáním, aby již nemohlo docházet ke změnám."} disabled={noOrders} onClick={async () => {
<Button className='mb-3' style={{ marginLeft: '20px' }} title={noOrders ? "Nelze uzamknout - neexistuje žádná objednávka" : "Zamezí přidávat/odebírat objednávky. Použij před samotným objednáním, aby již nemohlo docházet ke změnám."} disabled={noOrders} onClick={async () => { await lockPizzaDay();
await lockPizzaDay(); }}>Uzamknout</Button>
}}>Uzamknout</Button> </div>
</> }
} </>
</div> }
} {
{ data.pizzaDay.state === PizzaDayState.LOCKED &&
data.pizzaDay.state === PizzaDayState.LOCKED && <>
<div> <p>Objednávky jsou uzamčeny uživatelem <strong>{data.pizzaDay.creator}</strong></p>
<p>Objednávky jsou uzamčeny uživatelem {data.pizzaDay.creator}</p> {data.pizzaDay.creator === auth.login &&
{data.pizzaDay.creator === auth.login && <div className="mb-4">
<> <Button variant="secondary" title="Umožní znovu editovat objednávky." onClick={async () => {
<Button className='danger mb-3' title="Umožní znovu editovat objednávky." onClick={async () => { await unlockPizzaDay();
await unlockPizzaDay(); }}>Odemknout</Button>
}}>Odemknout</Button> <Button title={noOrders ? "Nelze objednat - neexistuje žádná objednávka" : "Použij po objednání. Objednávky zůstanou zamčeny."} disabled={noOrders} onClick={async () => {
{/* <Button className='danger mb-3' style={{ marginLeft: '20px' }} onClick={async () => { await finishOrder();
await addToCart(); }}>Objednáno</Button>
}}>Přidat vše do košíku</Button> */} </div>
<Button className='danger mb-3' style={{ marginLeft: '20px' }} title={noOrders ? "Nelze objednat - neexistuje žádná objednávka" : "Použij po objednání. Objednávky zůstanou zamčeny."} disabled={noOrders} onClick={async () => { }
await finishOrder(); </>
}}>Objednáno</Button> }
</> {
} data.pizzaDay.state === PizzaDayState.ORDERED &&
</div> <>
} <p>Pizzy byly objednány uživatelem <strong>{data.pizzaDay.creator}</strong></p>
{ {data.pizzaDay.creator === auth.login &&
data.pizzaDay.state === PizzaDayState.ORDERED && <div className="mb-4">
<div> <Button variant="secondary" title="Vrátí stav do předchozího kroku (před objednáním)." onClick={async () => {
<p>Pizzy byly objednány uživatelem {data.pizzaDay.creator}</p> await lockPizzaDay();
{data.pizzaDay.creator === auth.login && }}>Vrátit do "uzamčeno"</Button>
<div> <Button title="Nastaví stav na 'Doručeno' - koncový stav." onClick={async () => {
<Button className='danger mb-3' title="Vrátí stav do předchozího kroku (před objednáním)." onClick={async () => { await finishDelivery({ body: { bankAccount: settings?.bankAccount, bankAccountHolder: settings?.holderName } });
await lockPizzaDay(); }}>Doručeno</Button>
}}>Vrátit do "uzamčeno"</Button> </div>
<Button className='danger mb-3' style={{ marginLeft: '20px' }} title="Nastaví stav na 'Doručeno' - koncový stav." onClick={async () => { }
await finishDelivery(settings?.bankAccount, settings?.holderName); </>
}}>Doručeno</Button> }
</div> {
} data.pizzaDay.state === PizzaDayState.DELIVERED &&
</div> <p>
} Pizzy byly doručeny.
{ {myOrder?.hasQr ? ` Objednávku můžete uživateli ${data.pizzaDay.creator} uhradit pomocí QR kódu níže.` : ''}
data.pizzaDay.state === PizzaDayState.DELIVERED && </p>
<div> }
<p>{`Pizzy byly doručeny.${myOrder?.hasQr ? ` Objednávku můžete uživateli ${data.pizzaDay.creator} uhradit pomocí QR kódu níže.` : ''}`}</p>
</div>
}
</div>
{data.pizzaDay.state === PizzaDayState.CREATED && {data.pizzaDay.state === PizzaDayState.CREATED &&
<div style={{ textAlign: 'center' }}> <div className="pizza-order-form">
<SelectSearch <SelectSearch
search={true} search={true}
options={pizzaSuggestions} options={pizzaSuggestions}
@@ -615,38 +789,71 @@ function App() {
onBlur={_ => { }} onBlur={_ => { }}
onFocus={_ => { }} onFocus={_ => { }}
/> />
Poznámka: <input ref={pizzaPoznamkaRef} className='mt-3' type="text" onKeyDown={event => { <div className="d-flex align-items-center gap-2">
if (event.key === 'Enter') { <label style={{ color: 'var(--luncher-text-secondary)' }}>Poznámka:</label>
handlePizzaPoznamkaChange(); <input ref={pizzaPoznamkaRef} type="text" placeholder="Např. bez cibule" onKeyDown={event => {
} if (event.key === 'Enter') {
event.stopPropagation(); handlePizzaPoznamkaChange();
}} /> }
<Button event.stopPropagation();
style={{ marginLeft: '20px' }} }} />
disabled={!myOrder?.pizzaList?.length} <Button
onClick={handlePizzaPoznamkaChange}> disabled={!myOrder?.pizzaList?.length}
Uložit onClick={handlePizzaPoznamkaChange}>
</Button> Uložit
</Button>
</div>
</div> </div>
} }
<PizzaOrderList state={data.pizzaDay.state} orders={data.pizzaDay.orders} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator} /> <PizzaOrderList state={data.pizzaDay.state!} orders={data.pizzaDay.orders!} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator!} />
{ {
data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr && data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr &&
<div className='qr-code'> <div className='qr-code'>
<h3>QR platba</h3> <h3>QR platba</h3>
<img src={getQrUrl(auth.login)} alt='QR kód' /> <img src={`/api/qr?login=${auth.login}`} alt='QR kód' />
</div> </div>
} }
</div> </>
} }
</div> </div>
} }
</div> </div>
</>} {data.pendingQrs && data.pendingQrs.length > 0 &&
<div className='pizza-section fade-in mt-4'>
<h3>Nevyřízené platby</h3>
<p>Máte neuhrazené platby z předchozích dní.</p>
{data.pendingQrs.map(qr => (
<div key={qr.date} className='qr-code mb-3'>
<p>
<strong>{formatDateString(qr.date)}</strong> {qr.creator} ({qr.totalPrice} )
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
</p>
<img src={`/api/qr?login=${auth.login}`} alt='QR kód' />
<div className='mt-2'>
<Button variant="success" onClick={async () => {
await dismissQr({ body: { date: qr.date } });
// Přenačteme data pro aktualizaci
const response = await getData({ query: { dayIndex } });
if (response.data) {
setData(response.data);
}
}}>
Zaplatil jsem
</Button>
</div>
</div>
))}
</div>
}
</>
</div> </div>
{/* <FallingLeaves
numLeaves={LEAF_PRESETS.NORMAL}
leafVariants={LEAF_COLOR_THEMES.AUTUMN}
/> */}
<Footer /> <Footer />
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} /> <NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
</> </div>
); );
} }

35
client/src/AppRoutes.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { Routes, Route } from "react-router-dom";
import { ProvideSettings } from "./context/settings";
// import Snowfall from "react-snowfall";
import { SnowOverlay } from 'react-snow-overlay';
import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage";
import App from "./App";
export const STATS_URL = '/stats';
export default function AppRoutes() {
return (
<Routes>
<Route path={STATS_URL} element={<StatsPage />} />
<Route path="/" element={
<ProvideSettings>
<SocketContext.Provider value={socket}>
<>
{/* <Snowfall style={{
zIndex: 2,
position: 'fixed',
width: '100vw',
height: '100vh'
}} /> */}
<SnowOverlay color={'rgba(240, 240, 240, 0.9)'} disabledOnSingleCpuDevices={true} />
<App />
</>
<ToastContainer />
</SocketContext.Provider>
</ProvideSettings>
} />
</Routes>
);
}

View File

@@ -0,0 +1,31 @@
.falling-leaves {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.leaf-scene {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 100%;
transform-style: preserve-3d;
div {
position: absolute;
top: 0;
left: 0;
width: 20px;
height: 20px;
background-repeat: no-repeat;
background-size: 100%;
transform-style: preserve-3d;
backface-visibility: visible;
}
}

View File

@@ -0,0 +1,317 @@
import React, { useEffect, useRef, useCallback } from 'react';
// Různé barevné varianty listů
const LEAF_VARIANTS = [
'leaf.svg', // Původní tmavě hnědá
'leaf-orange.svg', // Oranžová
'leaf-yellow.svg', // Žlutá
'leaf-red.svg', // Červená
'leaf-brown.svg', // Světle hnědá
'leaf-green.svg', // Zelená
] as const;
interface LeafData {
el: HTMLDivElement;
x: number;
y: number;
z: number;
rotation: {
axis: 'X' | 'Y' | 'Z';
value: number;
speed: number;
x: number;
};
xSpeedVariation: number;
ySpeed: number;
path: {
type: number;
start: number;
};
image: number;
}
interface WindOptions {
magnitude: number;
maxSpeed: number;
duration: number;
start: number;
speed: (t: number, y: number) => number;
}
interface LeafSceneOptions {
numLeaves: number;
wind: WindOptions;
}
interface FallingLeavesProps {
/** Počet padających listů (výchozí: 20) */
numLeaves?: number;
/** CSS třída pro kontejner (výchozí: 'falling-leaves') */
className?: string;
/** Barevné varianty listů k použití (výchozí: všechny) */
leafVariants?: readonly string[];
}
class LeafScene {
private viewport: HTMLElement;
private world: HTMLDivElement;
private leaves: LeafData[] = [];
private options: LeafSceneOptions;
private width: number;
private height: number;
private timer: number = 0;
private animationId: number | null = null;
private leafVariants: readonly string[];
constructor(el: HTMLElement, numLeaves: number = 20, leafVariants: readonly string[] = LEAF_VARIANTS) {
this.viewport = el;
this.world = document.createElement('div');
this.leafVariants = leafVariants;
this.options = {
numLeaves,
wind: {
magnitude: 1.2,
maxSpeed: 12,
duration: 300,
start: 0,
speed: () => 0
},
};
this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight;
}
private resetLeaf = (leaf: LeafData): LeafData => {
// place leaf towards the top left
leaf.x = this.width * 2 - Math.random() * this.width * 1.75;
leaf.y = -10;
leaf.z = Math.random() * 200;
if (leaf.x > this.width) {
leaf.x = this.width + 10;
leaf.y = Math.random() * this.height / 2;
}
// at the start, the leaf can be anywhere
if (this.timer === 0) {
leaf.y = Math.random() * this.height;
}
// Choose axis of rotation.
// If axis is not X, chose a random static x-rotation for greater variability
leaf.rotation.speed = Math.random() * 10;
const randomAxis = Math.random();
if (randomAxis > 0.5) {
leaf.rotation.axis = 'X';
} else if (randomAxis > 0.25) {
leaf.rotation.axis = 'Y';
leaf.rotation.x = Math.random() * 180 + 90;
} else {
leaf.rotation.axis = 'Z';
leaf.rotation.x = Math.random() * 360 - 180;
// looks weird if the rotation is too fast around this axis
leaf.rotation.speed = Math.random() * 3;
}
// random speed
leaf.xSpeedVariation = Math.random() * 0.8 - 0.4;
leaf.ySpeed = Math.random() + 1.5;
// randomly select leaf color variant
const randomVariantIndex = Math.floor(Math.random() * this.leafVariants.length);
leaf.image = randomVariantIndex;
// apply the background image to the leaf element
const leafVariant = this.leafVariants[randomVariantIndex];
leaf.el.style.backgroundImage = `url(${leafVariant})`;
return leaf;
};
private updateLeaf = (leaf: LeafData): void => {
const leafWindSpeed = this.options.wind.speed(this.timer - this.options.wind.start, leaf.y);
const xSpeed = leafWindSpeed + leaf.xSpeedVariation;
leaf.x -= xSpeed;
leaf.y += leaf.ySpeed;
leaf.rotation.value += leaf.rotation.speed;
const transform = `translateX(${leaf.x}px) translateY(${leaf.y}px) translateZ(${leaf.z}px) rotate${leaf.rotation.axis}(${leaf.rotation.value}deg)${leaf.rotation.axis !== 'X' ? ` rotateX(${leaf.rotation.x}deg)` : ''
}`;
leaf.el.style.transform = transform;
// reset if out of view
if (leaf.x < -10 || leaf.y > this.height + 10) {
this.resetLeaf(leaf);
}
};
private updateWind = (): void => {
// wind follows a sine curve: asin(b*time + c) + a
// where a = wind magnitude as a function of leaf position, b = wind.duration, c = offset
// wind duration should be related to wind magnitude, e.g. higher windspeed means longer gust duration
if (this.timer === 0 || this.timer > (this.options.wind.start + this.options.wind.duration)) {
this.options.wind.magnitude = Math.random() * this.options.wind.maxSpeed;
this.options.wind.duration = this.options.wind.magnitude * 50 + (Math.random() * 20 - 10);
this.options.wind.start = this.timer;
const screenHeight = this.height;
this.options.wind.speed = function (t: number, y: number) {
// should go from full wind speed at the top, to 1/2 speed at the bottom, using leaf Y
const a = this.magnitude / 2 * (screenHeight - 2 * y / 3) / screenHeight;
return a * Math.sin(2 * Math.PI / this.duration * t + (3 * Math.PI / 2)) + a;
};
}
};
public init = (): void => {
// Clear existing leaves
this.leaves = [];
this.world.innerHTML = '';
for (let i = 0; i < this.options.numLeaves; i++) {
const leaf: LeafData = {
el: document.createElement('div'),
x: 0,
y: 0,
z: 0,
rotation: {
axis: 'X',
value: 0,
speed: 0,
x: 0
},
xSpeedVariation: 0,
ySpeed: 0,
path: {
type: 1,
start: 0,
},
image: 1
};
this.resetLeaf(leaf);
this.leaves.push(leaf);
this.world.appendChild(leaf.el);
}
this.world.className = 'leaf-scene';
this.viewport.appendChild(this.world);
// set perspective
this.world.style.perspective = "400px";
// reset window height/width on resize
const handleResize = (): void => {
this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight;
};
window.addEventListener('resize', handleResize);
};
public render = (): void => {
this.updateWind();
for (let i = 0; i < this.leaves.length; i++) {
this.updateLeaf(this.leaves[i]);
}
this.timer++;
this.animationId = requestAnimationFrame(this.render);
};
public destroy = (): void => {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
if (this.world && this.world.parentNode) {
this.world.parentNode.removeChild(this.world);
}
window.removeEventListener('resize', () => {
this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight;
});
};
}
/**
* Komponenta pro zobrazení padajících listů na pozadí stránky
*
* @param numLeaves - Počet padajících listů (výchozí: 20)
* @param className - CSS třída pro kontejner (výchozí: 'falling-leaves')
* @param leafVariants - Barevné varianty listů k použití (výchozí: všechny)
*
* @example
* // Základní použití s výchozím počtem listů
* <FallingLeaves />
*
* @example
* // Použití s vlastním počtem listů
* <FallingLeaves numLeaves={50} />
*
* @example
* // Použití s vlastní CSS třídou a pouze podzimními barvami
* <FallingLeaves
* numLeaves={15}
* className="autumn-leaves"
* leafVariants={['leaf-orange.svg', 'leaf-red.svg', 'leaf-yellow.svg']}
* />
*/
const FallingLeaves: React.FC<FallingLeavesProps> = ({
numLeaves = 20,
className = 'falling-leaves',
leafVariants = LEAF_VARIANTS
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const leafSceneRef = useRef<LeafScene | null>(null);
const initializeLeafScene = useCallback(() => {
if (containerRef.current) {
leafSceneRef.current = new LeafScene(containerRef.current, numLeaves, leafVariants);
leafSceneRef.current.init();
leafSceneRef.current.render();
}
}, [numLeaves, leafVariants]);
useEffect(() => {
initializeLeafScene();
return () => {
if (leafSceneRef.current) {
leafSceneRef.current.destroy();
leafSceneRef.current = null;
}
};
}, [initializeLeafScene]);
return <div ref={containerRef} className={className} />;
};
// Přednastavení pro různé účely
export const LEAF_PRESETS = {
LIGHT: 10, // Lehký podzimní efekt
NORMAL: 20, // Standardní množství
HEAVY: 40, // Silný podzimní vítr
BLIZZARD: 80 // Hustý pád listí
} as const;
// Přednastavené barevné kombinace
export const LEAF_COLOR_THEMES = {
ALL: LEAF_VARIANTS, // Všechny barvy
AUTUMN: ['leaf-orange.svg', 'leaf-red.svg', 'leaf-yellow.svg', 'leaf-brown.svg'] as const, // Podzimní barvy
WARM: ['leaf-orange.svg', 'leaf-red.svg', 'leaf-brown.svg'] as const, // Teplé barvy
CLASSIC: ['leaf.svg', 'leaf-brown.svg'] as const, // Klasické hnědé odstíny
BRIGHT: ['leaf-yellow.svg', 'leaf-orange.svg'] as const, // Světlé barvy
} as const;
export default FallingLeaves;

View File

@@ -1,13 +1,89 @@
.login { .login-page {
height: 100%; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; align-items: center;
text-align: center;
justify-content: center; justify-content: center;
background: var(--luncher-bg);
padding: 24px;
} }
.login-inner { .login-card {
background: var(--luncher-bg-card);
border-radius: var(--luncher-radius-xl);
box-shadow: var(--luncher-shadow-lg);
padding: 48px;
max-width: 420px;
width: 100%;
text-align: center;
border: 1px solid var(--luncher-border-light);
}
.login-logo {
font-size: 2.5rem;
font-weight: 700;
color: var(--luncher-text);
margin-bottom: 8px;
letter-spacing: -0.5px;
}
.login-subtitle {
color: var(--luncher-text-secondary);
font-size: 1rem;
margin-bottom: 40px;
line-height: 1.5;
}
.login-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; gap: 20px;
} }
.login-form label {
display: block;
text-align: left;
font-weight: 500;
color: var(--luncher-text);
margin-bottom: 8px;
}
.login-form .hint {
font-size: 0.85rem;
color: var(--luncher-text-muted);
margin-top: 8px;
text-align: left;
line-height: 1.5;
}
.login-form input[type="text"] {
width: 100%;
padding: 14px 18px;
font-size: 1rem;
border: 2px solid var(--luncher-border);
border-radius: var(--luncher-radius-sm);
background: var(--luncher-bg);
color: var(--luncher-text);
transition: var(--luncher-transition);
}
.login-form input[type="text"]:hover {
border-color: var(--luncher-text-muted);
}
.login-form input[type="text"]:focus {
border-color: var(--luncher-primary);
box-shadow: 0 0 0 3px var(--luncher-primary-light);
outline: none;
}
.login-form input[type="text"]::placeholder {
color: var(--luncher-text-muted);
}
.login-form .btn {
width: 100%;
padding: 14px 24px;
font-size: 1rem;
font-weight: 600;
margin-top: 8px;
}

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { Button } from 'react-bootstrap'; import { Button } from 'react-bootstrap';
import { useAuth } from './context/auth'; import { useAuth } from './context/auth';
import { login } from './api/Api'; import { login } from '../../types';
import './Login.css'; import './Login.css';
/** /**
@@ -14,9 +14,10 @@ export default function Login() {
useEffect(() => { useEffect(() => {
if (auth && !auth.login) { if (auth && !auth.login) {
// Vyzkoušíme přihlášení "naprázdno", pokud projde, přihlásili nás trusted headers // Vyzkoušíme přihlášení "naprázdno", pokud projde, přihlásili nás trusted headers
login().then(token => { login().then(response => {
const token = response.data;
if (token) { if (token) {
auth?.setToken(token); auth?.setToken(token as unknown as string); // TODO vyřešit, API definice je špatně, je to skutečně string
} }
}).catch(error => { }).catch(error => {
// nezajímá nás // nezajímá nás
@@ -25,32 +26,45 @@ export default function Login() {
}, [auth]); }, [auth]);
const doLogin = useCallback(async () => { const doLogin = useCallback(async () => {
const length = loginRef?.current?.value && loginRef?.current?.value.length && loginRef.current.value.replace(/\s/g, '').length const length = loginRef?.current?.value.length && loginRef.current.value.replaceAll(/\s/g, '').length
if (length) { if (length) {
// TODO odchytávat cokoliv mimo 200 const response = await login({ body: { login: loginRef.current?.value } });
const token = await login(loginRef.current.value); if (response.data) {
if (token) { auth?.setToken(response.data as unknown as string); // TODO vyřešit
auth?.setToken(token);
} }
} }
}, [auth]); }, [auth]);
if (!auth || !auth.login) { if (!auth?.login) {
return <div className='login'> return (
<h1>Luncher</h1> <div className='login-page'>
<h4 style={{ marginBottom: "50px" }}>Aplikace pro profesionální management obědů</h4> <div className='login-card'>
<div className='login-inner'> <h1 className='login-logo'>Luncher</h1>
<p style={{ fontSize: "12px", marginTop: "10px" }}> <p className='login-subtitle'>Aplikace pro profesionální management obědů</p>
Zobrazované jméno by mělo být vaše jméno nebo přezdívka, pod kterou vás kolegové dokáží snadno identifikovat. Jméno je možné kdykoli změnit. <div className='login-form'>
</p> <div>
Zobrazované jméno: <input style={{ marginTop: "10px" }} ref={loginRef} type='text' onKeyDown={event => { <label htmlFor="login-input">Zobrazované jméno</label>
if (event.key === 'Enter') { <input
doLogin() id="login-input"
} ref={loginRef}
}} /> type='text'
<Button onClick={doLogin} style={{ marginTop: "20px" }}>Uložit</Button> placeholder="Např. Jan Novák"
onKeyDown={event => {
if (event.key === 'Enter') {
doLogin()
}
}}
/>
<p className='hint'>
Zadejte jméno nebo přezdívku, pod kterou vás kolegové snadno identifikují.
Jméno je možné kdykoli změnit.
</p>
</div>
<Button onClick={doLogin}>Pokračovat</Button>
</div>
</div>
</div> </div>
</div> );
} }
return <div>Neplatný stav</div> return <div>Neplatný stav</div>
} }

View File

@@ -1,4 +1,4 @@
import {DepartureTime} from "../../types"; import { DepartureTime } from "../../types";
const TOKEN_KEY = "token"; const TOKEN_KEY = "token";
@@ -16,8 +16,8 @@ export const storeToken = (token: string) => {
* *
* @returns token nebo null * @returns token nebo null
*/ */
export const getToken = (): string | null => { export const getToken = (): string | undefined => {
return localStorage.getItem(TOKEN_KEY); return localStorage.getItem(TOKEN_KEY) ?? undefined;
} }
/** /**
@@ -53,7 +53,60 @@ export function isInTheFuture(time: DepartureTime) {
const now = new Date(); const now = new Date();
const currentHours = now.getHours(); const currentHours = now.getHours();
const currentMinutes = now.getMinutes(); const currentMinutes = now.getMinutes();
const currentDate = now.toDateString();
const [hours, minutes] = time.split(':').map(Number); const [hours, minutes] = time.split(':').map(Number);
return hours > currentHours || (hours === currentHours && minutes > currentMinutes);
if (currentDate === now.toDateString()) {
return hours > currentHours || (hours === currentHours && minutes > currentMinutes);
}
return true;
} }
/**
* Vrátí index dne v týdnu, kde pondělí=0, neděle=6
*
* @param date datum
* @returns index dne v týdnu
*/
export const getDayOfWeekIndex = (date: Date) => {
// https://stackoverflow.com/a/4467559
return (((date.getDay() - 1) % 7) + 7) % 7;
}
/** Vrátí první pracovní den v týdnu předaného data. */
export function getFirstWorkDayOfWeek(date: Date) {
const firstDay = new Date(date);
firstDay.setDate(date.getDate() - getDayOfWeekIndex(date));
return firstDay;
}
/** Vrátí poslední pracovní den v týdnu předaného data. */
export function getLastWorkDayOfWeek(date: Date) {
const lastDay = new Date(date);
lastDay.setDate(date.getDate() + (4 - getDayOfWeekIndex(date)));
return lastDay;
}
/** Vrátí datum v ISO formátu. */
export function formatDate(date: Date, format?: string) {
let day = String(date.getDate()).padStart(2, '0');
let month = String(date.getMonth() + 1).padStart(2, "0");
let year = String(date.getFullYear());
const f = format ?? 'YYYY-MM-DD';
return f.replace('DD', day).replace('MM', month).replace('YYYY', year);
}
/** Vrátí human-readable reprezentaci předaného data pro zobrazení. */
export function getHumanDate(date: Date) {
let currentDay = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear();
return `${currentDay}.${currentMonth}.${currentYear}`;
}
/** Převede datum ve formátu YYYY-MM-DD na DD.MM.YYYY */
export function formatDateString(dateString: string): string {
const [year, month, day] = dateString.split('-');
return `${day}.${month}.${year}`;
}

View File

@@ -1,83 +0,0 @@
import { toast } from "react-toastify";
import { getToken } from "../Utils";
/**
* Wrapper pro volání API, u kterých chceme automaticky zobrazit toaster s chybou ze serveru.
*
* @param apiFunction volaná API funkce
*/
export function errorHandler<T>(apiFunction: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
apiFunction().then((result) => {
resolve(result);
}).catch(e => {
toast.error(e.message, { theme: "colored" });
});
});
}
async function request<TResponse>(
url: string,
config: RequestInit = {}
): Promise<TResponse> {
config.headers = config?.headers ? new Headers(config.headers) : new Headers();
config.headers.set("Authorization", `Bearer ${getToken()}`);
try {
const response = await fetch(url, config);
if (!response.ok) {
// TODO tohle je blbě, jelikož automaticky očekáváme, že v případě chyby přijde vždy JSON, což není pravda
const json = await response.json();
// Vyhodíme samotnou hlášku z odpovědi, odchytí si jí errorHandler
throw new Error(json.error);
}
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
return response.json() as TResponse;
} else {
return response.text() as TResponse;
}
} catch (e) {
return Promise.reject(e);
}
}
async function blobRequest(
url: string,
config: RequestInit = {}
): Promise<Blob> {
config.headers = config?.headers ? new Headers(config.headers) : new Headers();
config.headers.set("Authorization", `Bearer ${getToken()}`);
try {
const response = await fetch(url, config);
if (!response.ok) {
const json = await response.json();
// Vyhodíme samotnou hlášku z odpovědi, odchytí si jí errorHandler
throw new Error(json.error);
}
return response.blob()
} catch (e) {
return Promise.reject(e);
}
}
export const api = {
get: <TResponse>(url: string) => request<TResponse>(url),
blobGet: (url: string) => blobRequest(url),
post: <TBody, TResponse>(url: string, body?: TBody) => request<TResponse>(url, { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }),
}
export const getQrUrl = (login: string) => {
return `/api/qr?login=${login}`;
}
export const getData = async (dayIndex?: number) => {
let url = '/api/data';
if (dayIndex != null) {
url += '?dayIndex=' + dayIndex;
}
return await api.get<any>(url);
}
export const login = async (login?: string) => {
return await api.post<any, any>('/api/login', { login });
}

View File

@@ -1,12 +0,0 @@
import { EasterEgg } from "../types";
import { api } from "./Api";
const EASTER_EGGS_API_PREFIX = '/api/easterEggs';
export const getEasterEgg = async (): Promise<EasterEgg | undefined> => {
return await api.get<EasterEgg>(`${EASTER_EGGS_API_PREFIX}`);
}
export const getImage = async (url: string) => {
return await api.blobGet(`${EASTER_EGGS_API_PREFIX}/${url}`);
}

View File

@@ -1,28 +0,0 @@
import { AddChoiceRequest, ChangeDepartureTimeRequest, LocationKey, RemoveChoiceRequest, RemoveChoicesRequest, UpdateNoteRequest } from "../types";
import { api } from "./Api";
const FOOD_API_PREFIX = '/api/food';
export const addChoice = async (locationKey: LocationKey, foodIndex?: number, dayIndex?: number) => {
return await api.post<AddChoiceRequest, void>(`${FOOD_API_PREFIX}/addChoice`, { locationKey, foodIndex, dayIndex });
}
export const removeChoices = async (locationKey: LocationKey, dayIndex?: number) => {
return await api.post<RemoveChoicesRequest, void>(`${FOOD_API_PREFIX}/removeChoices`, { locationKey, dayIndex });
}
export const removeChoice = async (locationKey: LocationKey, foodIndex: number, dayIndex?: number) => {
return await api.post<RemoveChoiceRequest, void>(`${FOOD_API_PREFIX}/removeChoice`, { locationKey, foodIndex, dayIndex });
}
export const updateNote = async (note?: string, dayIndex?: number) => {
return await api.post<UpdateNoteRequest, void>(`${FOOD_API_PREFIX}/updateNote`, { note, dayIndex });
}
export const changeDepartureTime = async (time: string, dayIndex?: number) => {
return await api.post<ChangeDepartureTimeRequest, void>(`${FOOD_API_PREFIX}/changeDepartureTime`, { time, dayIndex });
}
export const jdemeObed = async () => {
return await api.post<undefined, void>(`${FOOD_API_PREFIX}/jdemeObed`);
}

View File

@@ -1,44 +0,0 @@
import { AddPizzaRequest, FinishDeliveryRequest, PizzaOrder, RemovePizzaRequest, UpdatePizzaDayNoteRequest, UpdatePizzaFeeRequest } from "../types";
import { api } from "./Api";
const PIZZADAY_API_PREFIX = '/api/pizzaDay';
export const createPizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/create`);
}
export const deletePizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/delete`);
}
export const lockPizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/lock`);
}
export const unlockPizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/unlock`);
}
export const finishOrder = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/finishOrder`);
}
export const finishDelivery = async (bankAccount?: string, bankAccountHolder?: string) => {
return await api.post<FinishDeliveryRequest, void>(`${PIZZADAY_API_PREFIX}/finishDelivery`, { bankAccount, bankAccountHolder });
}
export const addPizza = async (pizzaIndex: number, pizzaSizeIndex: number) => {
return await api.post<AddPizzaRequest, void>(`${PIZZADAY_API_PREFIX}/add`, { pizzaIndex, pizzaSizeIndex });
}
export const removePizza = async (pizzaOrder: PizzaOrder) => {
return await api.post<RemovePizzaRequest, void>(`${PIZZADAY_API_PREFIX}/remove`, { pizzaOrder });
}
export const updatePizzaDayNote = async (note?: string) => {
return await api.post<UpdatePizzaDayNoteRequest, void>(`${PIZZADAY_API_PREFIX}/updatePizzaDayNote`, { note });
}
export const updatePizzaFee = async (login: string, text?: string, price?: number) => {
return await api.post<UpdatePizzaFeeRequest, void>(`${PIZZADAY_API_PREFIX}/updatePizzaFee`, { login, text, price });
}

View File

@@ -1,12 +0,0 @@
import { FeatureRequest, UpdateFeatureVoteRequest } from "../types";
import { api } from "./Api";
const VOTING_API_PREFIX = '/api/voting';
export const getFeatureVotes = async () => {
return await api.get<FeatureRequest[]>(`${VOTING_API_PREFIX}/getVotes`);
}
export const updateFeatureVote = async (option: FeatureRequest, active: boolean) => {
return await api.post<UpdateFeatureVoteRequest, void>(`${VOTING_API_PREFIX}/updateVote`, { option, active });
}

View File

@@ -1,7 +1,12 @@
import { Navbar } from "react-bootstrap";
export default function Footer() { export default function Footer() {
return <Navbar className="text-light" variant='dark' expand="lg" style={{ display: "flex", justifyContent: "center" }}> return (
<span>🄯 Žádná práva nevyhrazena. TODO a zdrojové kódy dostupné <a href="https://gitea.melancholik.eu/mates/Luncher">zde</a>.</span> <footer className="footer">
</Navbar > <span>
} Zdroj. kódy dostupné na{' '}
<a href="https://gitea.melancholik.eu/mates/Luncher" target="_blank" rel="noopener noreferrer">
Gitea
</a>
</span>
</footer>
);
}

View File

@@ -1,27 +1,72 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Navbar, Nav, NavDropdown } from "react-bootstrap"; import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
import { useAuth } from "../context/auth"; import { useAuth } from "../context/auth";
import SettingsModal from "./modals/SettingsModal"; import SettingsModal from "./modals/SettingsModal";
import { useSettings } from "../context/settings"; import { useSettings, ThemePreference } from "../context/settings";
import FeaturesVotingModal from "./modals/FeaturesVotingModal"; import FeaturesVotingModal from "./modals/FeaturesVotingModal";
import { FeatureRequest } from "../types";
import { errorHandler } from "../api/Api";
import { getFeatureVotes, updateFeatureVote } from "../api/VotingApi";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal"; import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
import RefreshMenuModal from "./modals/RefreshMenuModal";
import GenerateQrModal from "./modals/GenerateQrModal";
import GenerateMockDataModal from "./modals/GenerateMockDataModal";
import ClearMockDataModal from "./modals/ClearMockDataModal";
import { useNavigate } from "react-router";
import { STATS_URL } from "../AppRoutes";
import { FeatureRequest, getVotes, updateVote, LunchChoices } from "../../../types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
const CHANGELOG = [
"Nový moderní design aplikace",
"Oprava parsování Sladovnické a TechTower",
"Možnost označit se jako objednávající u volby \"budu objednávat\"",
"Možnost generovat QR kódy pro platby (i mimo Pizza day)",
];
export default function Header() { const IS_DEV = process.env.NODE_ENV === 'development';
type Props = {
choices?: LunchChoices;
dayIndex?: number;
};
export default function Header({ choices, dayIndex }: Props) {
const auth = useAuth(); const auth = useAuth();
const settings = useSettings(); const settings = useSettings();
const navigate = useNavigate();
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false); const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false); const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false); const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[]>([]); const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
// Zjistíme aktuální efektivní téma (pro zobrazení správné ikony)
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
const updateEffectiveTheme = () => {
if (settings?.themePreference === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setEffectiveTheme(isDark ? 'dark' : 'light');
} else {
setEffectiveTheme(settings?.themePreference || 'light');
}
};
updateEffectiveTheme();
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', updateEffectiveTheme);
return () => mediaQuery.removeEventListener('change', updateEffectiveTheme);
}, [settings?.themePreference]);
useEffect(() => { useEffect(() => {
if (auth?.login) { if (auth?.login) {
getFeatureVotes().then(votes => { getVotes().then(response => {
setFeatureVotes(votes); setFeatureVotes(response.data);
}) })
} }
}, [auth?.login]); }, [auth?.login]);
@@ -38,6 +83,28 @@ export default function Header() {
setPizzaModalOpen(false); setPizzaModalOpen(false);
} }
const closeRefreshMenuModal = () => {
setRefreshMenuModalOpen(false);
}
const closeQrModal = () => {
setQrModalOpen(false);
}
const handleQrMenuClick = () => {
if (!settings?.bankAccount || !settings?.holderName) {
alert('Pro generování QR kódů je nutné mít v nastavení vyplněné číslo účtu a jméno držitele účtu.');
return;
}
setQrModalOpen(true);
}
const toggleTheme = () => {
// Přepínáme mezi light a dark (ignorujeme system pro jednoduchost)
const newTheme: ThemePreference = effectiveTheme === 'dark' ? 'light' : 'dark';
settings?.setThemePreference(newTheme);
}
const isValidInteger = (str: string) => { const isValidInteger = (str: string) => {
str = str.trim(); str = str.trim();
if (!str) { if (!str) {
@@ -48,19 +115,19 @@ export default function Header() {
return n !== Infinity && String(n) === str && n >= 0; return n !== Infinity && String(n) === str && n >= 0;
} }
const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => { const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean, themePreference?: ThemePreference) => {
if (bankAccountNumber) { if (bankAccountNumber) {
try { try {
// Validace kódu banky // Validace kódu banky
if (bankAccountNumber.indexOf('/') < 0) { if (!bankAccountNumber.includes('/')) {
throw Error("Číslo účtu neobsahuje lomítko/kód banky") throw new Error("Číslo účtu neobsahuje lomítko/kód banky")
} }
const split = bankAccountNumber.split("/"); const split = bankAccountNumber.split("/");
if (split[1].length !== 4) { if (split[1].length !== 4) {
throw Error("Kód banky musí být 4 číslice") throw new Error("Kód banky musí být 4 číslice")
} }
if (!isValidInteger(split[1])) { if (!isValidInteger(split[1])) {
throw Error("Kód banky není číslo") throw new Error("Kód banky není číslo")
} }
// Validace čísla a předčíslí // Validace čísla a předčíslí
@@ -70,20 +137,20 @@ export default function Header() {
cislo = cislo.replace('-', ''); cislo = cislo.replace('-', '');
} }
if (!isValidInteger(cislo)) { if (!isValidInteger(cislo)) {
throw Error("Předčíslí nebo číslo účtu neobsahuje pouze číslice") throw new Error("Předčíslí nebo číslo účtu neobsahuje pouze číslice")
} }
if (cislo.length < 16) { if (cislo.length < 16) {
cislo = cislo.padStart(16, '0'); cislo = cislo.padStart(16, '0');
} }
let sum = 0; let sum = 0;
for (var i = 0; i < cislo.length; i++) { for (let i = 0; i < cislo.length; i++) {
const char = cislo.charAt(i); const char = cislo.charAt(i);
const order = (cislo.length - 1) - i; const order = (cislo.length - 1) - i;
const weight = (2 ** order) % 11; const weight = (2 ** order) % 11;
sum += Number.parseInt(char) * weight sum += Number.parseInt(char) * weight
} }
if (sum % 11 !== 0) { if (sum % 11 !== 0) {
throw Error("Číslo účtu je neplatné") throw new Error("Číslo účtu je neplatné")
} }
} catch (e: any) { } catch (e: any) {
alert(e.message) alert(e.message)
@@ -93,12 +160,15 @@ export default function Header() {
settings?.setBankAccountNumber(bankAccountNumber); settings?.setBankAccountNumber(bankAccountNumber);
settings?.setBankAccountHolderName(bankAccountHolderName); settings?.setBankAccountHolderName(bankAccountHolderName);
settings?.setHideSoupsOption(hideSoupsOption); settings?.setHideSoupsOption(hideSoupsOption);
if (themePreference) {
settings?.setThemePreference(themePreference);
}
closeSettingsModal(); closeSettingsModal();
} }
const saveFeatureVote = async (option: FeatureRequest, active: boolean) => { const saveFeatureVote = async (option: FeatureRequest, active: boolean) => {
await errorHandler(() => updateFeatureVote(option, active)); await updateVote({ body: { option, active } });
const votes = [...featureVotes]; const votes = [...featureVotes || []];
if (active) { if (active) {
votes.push(option); votes.push(option);
} else { } else {
@@ -108,21 +178,81 @@ export default function Header() {
} }
return <Navbar variant='dark' expand="lg"> return <Navbar variant='dark' expand="lg">
<Navbar.Brand>Luncher</Navbar.Brand> <Navbar.Brand href="/">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={effectiveTheme === 'dark' ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
aria-label="Přepnout barevný motiv"
>
<FontAwesomeIcon icon={effectiveTheme === 'dark' ? faSun : faMoon} />
</button>
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown"> <NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item> <NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item> <NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item> <NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Item onClick={() => setChangelogModalOpen(true)}>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.Divider />
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item> <NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
</NavDropdown> </NavDropdown>
</Nav> </Nav>
</Navbar.Collapse> </Navbar.Collapse>
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} /> <SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} /> <FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} /> <PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
{choices && settings?.bankAccount && settings?.holderName && (
<GenerateQrModal
isOpen={qrModalOpen}
onClose={closeQrModal}
choices={choices}
bankAccount={settings.bankAccount}
bankAccountHolder={settings.holderName}
/>
)}
{IS_DEV && (
<>
<GenerateMockDataModal
isOpen={generateMockModalOpen}
onClose={() => setGenerateMockModalOpen(false)}
currentDayIndex={dayIndex}
/>
<ClearMockDataModal
isOpen={clearMockModalOpen}
onClose={() => setClearMockModalOpen(false)}
currentDayIndex={dayIndex}
/>
</>
)}
<Modal show={changelogModalOpen} onHide={() => setChangelogModalOpen(false)}>
<Modal.Header closeButton>
<Modal.Title><h2>Novinky</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<ul>
{CHANGELOG.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setChangelogModalOpen(false)}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
</Navbar> </Navbar>
} }

View File

@@ -2,18 +2,20 @@ import { IconDefinition } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
type Props = { type Props = {
title?: String, title?: string,
icon: IconDefinition, icon: IconDefinition,
description: String, description: string,
animation?: String, animation?: string,
} }
function Loader(props: Props) { function Loader(props: Readonly<Props>) {
return <div className='loader'> return (
<h1>{props.title || 'Prosím čekejte...'}</h1> <div className='loader'>
<FontAwesomeIcon icon={props.icon} className={`loader-icon mb-3 ` + (props.animation ?? '')} /> <FontAwesomeIcon icon={props.icon} className={`loader-icon ${props.animation ?? ''}`} />
<p>{props.description}</p> <h2 className='loader-title'>{props.title ?? 'Prosím čekejte...'}</h2>
</div> <p className='loader-description'>{props.description}</p>
</div>
);
} }
export default Loader; export default Loader;

View File

@@ -1,46 +1,57 @@
import { Table } from "react-bootstrap"; import { Table } from "react-bootstrap";
import { Order, PizzaDayState, PizzaOrder } from "../types";
import PizzaOrderRow from "./PizzaOrderRow"; import PizzaOrderRow from "./PizzaOrderRow";
import { updatePizzaFee } from "../api/PizzaDayApi"; import { PizzaDayState, PizzaOrder, PizzaVariant, updatePizzaFee } from "../../../types";
type Props = { type Props = {
state: PizzaDayState, state: PizzaDayState,
orders: Order[], orders: PizzaOrder[],
onDelete: (pizzaOrder: PizzaOrder) => void, onDelete: (pizzaOrder: PizzaVariant) => void,
creator: string, creator: string,
} }
export default function PizzaOrderList({ state, orders, onDelete, creator }: Props) { export default function PizzaOrderList({ state, orders, onDelete, creator }: Readonly<Props>) {
const saveFees = async (customer: string, text?: string, price?: number) => { const saveFees = async (customer: string, text?: string, price?: number) => {
await updatePizzaFee(customer, text, price); await updatePizzaFee({ body: { login: customer, text, price } });
} }
if (!orders?.length) { if (!orders?.length) {
return <p className="mt-3"><i>Zatím žádné objednávky...</i></p> return <p className="mt-4" style={{ color: 'var(--luncher-text-muted)', fontStyle: 'italic' }}>Zatím žádné objednávky...</p>
} }
const total = orders.reduce((total, order) => total + order.totalPrice, 0); const total = orders.reduce((total, order) => total + order.totalPrice, 0);
return <> return (
<Table className="mt-3" striped bordered hover> <div className="mt-4" style={{
<thead> background: 'var(--luncher-bg-card)',
<tr> borderRadius: 'var(--luncher-radius-lg)',
<th>Jméno</th> overflow: 'hidden',
<th>Objednávka</th> border: '1px solid var(--luncher-border-light)',
<th>Poznámka</th> boxShadow: 'var(--luncher-shadow)'
<th>Příplatek</th> }}>
<th>Cena</th> <Table className="mb-0" style={{ color: 'var(--luncher-text)' }}>
</tr> <thead style={{ background: 'var(--luncher-primary-light)' }}>
</thead> <tr>
<tbody> <th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Jméno</th>
{orders.map(order => <tr key={order.customer}> <th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Objednávka</th>
<PizzaOrderRow creator={creator} state={state} order={order} onDelete={onDelete} onFeeModalSave={saveFees} /> <th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Poznámka</th>
</tr>)} <th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Příplatek</th>
<tr style={{ fontWeight: 'bold' }}> <th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none', textAlign: 'right' }}>Cena</th>
<td colSpan={4}>Celkem</td> </tr>
<td>{`${total}`}</td> </thead>
</tr> <tbody>
</tbody> {orders.map(order => <tr key={order.customer} style={{ borderColor: 'var(--luncher-border-light)' }}>
</Table> <PizzaOrderRow creator={creator} state={state} order={order} onDelete={onDelete} onFeeModalSave={saveFees} />
</> </tr>)}
} <tr style={{
fontWeight: 700,
background: 'var(--luncher-bg-hover)',
borderTop: '2px solid var(--luncher-border)'
}}>
<td colSpan={4} style={{ padding: '16px 20px', border: 'none' }}>Celkem</td>
<td style={{ padding: '16px 20px', border: 'none', textAlign: 'right', color: 'var(--luncher-primary)' }}>{`${total}`}</td>
</tr>
</tbody>
</Table>
</div>
);
}

View File

@@ -2,44 +2,46 @@ import React, { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMoneyBill1, faTrashCan } from "@fortawesome/free-regular-svg-icons"; import { faMoneyBill1, faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { useAuth } from "../context/auth"; import { useAuth } from "../context/auth";
import { Order, PizzaDayState, PizzaOrder } from "../types";
import PizzaAdditionalFeeModal from "./modals/PizzaAdditionalFeeModal"; import PizzaAdditionalFeeModal from "./modals/PizzaAdditionalFeeModal";
import { PizzaDayState, PizzaOrder, PizzaVariant } from "../../../types";
type Props = { type Props = {
creator: string, creator: string,
order: Order, order: PizzaOrder,
state: PizzaDayState, state: PizzaDayState,
onDelete: (order: PizzaOrder) => void, onDelete: (order: PizzaVariant) => void,
onFeeModalSave: (customer: string, name?: string, price?: number) => void, onFeeModalSave: (customer: string, name?: string, price?: number) => void,
} }
export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeModalSave }: Props) { export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeModalSave }: Readonly<Props>) {
const auth = useAuth(); const auth = useAuth();
const [isFeeModalOpen, setFeeModalOpen] = useState<boolean>(false); const [isFeeModalOpen, setIsFeeModalOpen] = useState<boolean>(false);
const saveFees = (customer: string, text?: string, price?: number) => { const saveFees = (customer: string, text?: string, price?: number) => {
onFeeModalSave(customer, text, price); onFeeModalSave(customer, text, price);
setFeeModalOpen(false); setIsFeeModalOpen(false);
} }
return <> return <>
<td>{order.customer}</td> <td>{order.customer}</td>
<td>{order.pizzaList.map<React.ReactNode>((pizzaOrder, index) => <td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder =>
<span key={index}> <span key={pizzaOrder.name}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`} {`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
{auth?.login === order.customer && state === PizzaDayState.CREATED && {auth?.login === order.customer && state === PizzaDayState.CREATED &&
<FontAwesomeIcon onClick={() => { <span title='Odstranit'>
onDelete(pizzaOrder); <FontAwesomeIcon onClick={() => {
}} title='Odstranit' className='action-icon' icon={faTrashCan} /> onDelete(pizzaOrder);
}} className='action-icon' icon={faTrashCan} />
</span>
} }
</span>) </span>)
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])} .reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
</td> </td>
<td style={{ maxWidth: "200px" }}>{order.note || '-'}</td> <td style={{ maxWidth: "200px" }}>{order.note ?? '-'}</td>
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price}${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td> <td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price}${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
<td> <td>
{order.totalPrice} {auth?.login === creator && state === PizzaDayState.CREATED && <FontAwesomeIcon onClick={() => { setFeeModalOpen(true) }} title='Nastavit příplatek' className='action-icon' icon={faMoneyBill1} />} {order.totalPrice} {auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>}
</td> </td>
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} /> <PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} />
</> </>
} }

View File

@@ -0,0 +1,104 @@
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>
);
}

View File

@@ -1,5 +1,5 @@
import { Modal, Button, Form } from "react-bootstrap" import { Modal, Button, Form } from "react-bootstrap"
import { FeatureRequest } from "../../types"; import { FeatureRequest } from "../../../../types";
type Props = { type Props = {
isOpen: boolean, isOpen: boolean,
@@ -9,7 +9,7 @@ type Props = {
} }
/** Modální dialog pro hlasování o nových funkcích. */ /** Modální dialog pro hlasování o nových funkcích. */
export default function FeaturesVotingModal({ isOpen, onClose, onChange, initialValues }: Props) { export default function FeaturesVotingModal({ isOpen, onClose, onChange, initialValues }: Readonly<Props>) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.currentTarget.value as FeatureRequest, e.currentTarget.checked); onChange(e.currentTarget.value as FeatureRequest, e.currentTarget.checked);
@@ -31,7 +31,7 @@ export default function FeaturesVotingModal({ isOpen, onClose, onChange, initial
label={FeatureRequest[key]} label={FeatureRequest[key]}
onChange={handleChange} onChange={handleChange}
value={key} value={key}
defaultChecked={initialValues && initialValues.includes(key as FeatureRequest)} defaultChecked={initialValues?.includes(key as FeatureRequest)}
/> />
})} })}
<p className="mt-3" style={{ fontSize: '12px' }}>Něco jiného? Dejte vědět.</p> <p className="mt-3" style={{ fontSize: '12px' }}>Něco jiného? Dejte vědět.</p>

View File

@@ -0,0 +1,140 @@
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>
);
}

View File

@@ -0,0 +1,255 @@
import { useState, useEffect, useCallback } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { generateQr, LunchChoices, QrRecipient } from "../../../../types";
type UserEntry = {
login: string;
selected: boolean;
purpose: string;
amount: string;
};
type Props = {
isOpen: boolean;
onClose: () => void;
choices: LunchChoices;
bankAccount: string;
bankAccountHolder: string;
};
/** Modální dialog pro generování QR kódů pro platbu. */
export default function GenerateQrModal({ isOpen, onClose, choices, bankAccount, bankAccountHolder }: Readonly<Props>) {
const [users, setUsers] = useState<UserEntry[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
// Při otevření modálu načteme seznam uživatelů z choices
useEffect(() => {
if (isOpen && choices) {
const userLogins = new Set<string>();
// Projdeme všechny lokace a získáme unikátní loginy
Object.values(choices).forEach(locationChoices => {
if (locationChoices) {
Object.keys(locationChoices).forEach(login => {
userLogins.add(login);
});
}
});
// Vytvoříme seznam uživatelů
const userList: UserEntry[] = Array.from(userLogins)
.sort((a, b) => a.localeCompare(b, 'cs'))
.map(login => ({
login,
selected: false,
purpose: '',
amount: '',
}));
setUsers(userList);
setError(null);
setSuccess(false);
}
}, [isOpen, choices]);
const handleCheckboxChange = useCallback((login: string, checked: boolean) => {
setUsers(prev => prev.map(u =>
u.login === login ? { ...u, selected: checked } : u
));
}, []);
const handlePurposeChange = useCallback((login: string, value: string) => {
setUsers(prev => prev.map(u =>
u.login === login ? { ...u, purpose: value } : u
));
}, []);
const handleAmountChange = useCallback((login: string, value: string) => {
// Povolíme pouze čísla, tečku a čárku
const sanitized = value.replace(/[^0-9.,]/g, '').replace(',', '.');
setUsers(prev => prev.map(u =>
u.login === login ? { ...u, amount: sanitized } : u
));
}, []);
const validateAmount = (amountStr: string): number | null => {
if (!amountStr || amountStr.trim().length === 0) {
return null;
}
const amount = parseFloat(amountStr);
if (isNaN(amount) || amount <= 0) {
return null;
}
// Max 2 desetinná místa
const parts = amountStr.split('.');
if (parts.length === 2 && parts[1].length > 2) {
return null;
}
return Math.round(amount * 100) / 100; // Zaokrouhlíme na 2 desetinná místa
};
const handleGenerate = async () => {
setError(null);
const selectedUsers = users.filter(u => u.selected);
if (selectedUsers.length === 0) {
setError("Nebyl vybrán žádný uživatel");
return;
}
// Validace
const recipients: QrRecipient[] = [];
for (const user of selectedUsers) {
if (!user.purpose || user.purpose.trim().length === 0) {
setError(`Uživatel ${user.login} nemá vyplněný účel platby`);
return;
}
const amount = validateAmount(user.amount);
if (amount === null) {
setError(`Uživatel ${user.login} má neplatnou částku (musí být kladné číslo s max. 2 desetinnými místy)`);
return;
}
recipients.push({
login: user.login,
purpose: user.purpose.trim(),
amount,
});
}
setLoading(true);
try {
const response = await generateQr({
body: {
recipients,
bankAccount,
bankAccountHolder,
}
});
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
} else {
setSuccess(true);
// Po 2 sekundách zavřeme modal
setTimeout(() => {
onClose();
}, 2000);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při generování QR kódů');
} finally {
setLoading(false);
}
};
const handleClose = () => {
setError(null);
setSuccess(false);
onClose();
};
const selectedCount = users.filter(u => u.selected).length;
return (
<Modal show={isOpen} onHide={handleClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Generování QR kódů</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{success ? (
<Alert variant="success">
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci "Nevyřízené platby".
</Alert>
) : (
<>
<p>
Vyberte uživatele, kterým chcete vygenerovat QR kód pro platbu.
QR kódy se uživatelům zobrazí v sekci "Nevyřízené platby".
</p>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
{users.length === 0 ? (
<Alert variant="info">
V tento den nemá žádný uživatel zvolenou možnost stravování.
</Alert>
) : (
<Table striped bordered hover responsive>
<thead>
<tr>
<th style={{ width: '50px' }}></th>
<th>Uživatel</th>
<th>Účel platby</th>
<th style={{ width: '120px' }}>Částka ()</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>
);
}

View File

@@ -8,7 +8,7 @@ type Props = {
} }
/** Modální dialog pro úpravu obecné poznámky. */ /** Modální dialog pro úpravu obecné poznámky. */
export default function NoteModal({ isOpen, onClose, onSave }: Props) { export default function NoteModal({ isOpen, onClose, onSave }: Readonly<Props>) {
const note = useRef<HTMLInputElement>(null); const note = useRef<HTMLInputElement>(null);
const save = () => { const save = () => {

View File

@@ -10,17 +10,17 @@ type Props = {
} }
/** Modální dialog pro nastavení příplatků za pizzu. */ /** Modální dialog pro nastavení příplatků za pizzu. */
export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose, onSave, initialValues }: Props) { export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose, onSave, initialValues }: Readonly<Props>) {
const textRef = useRef<HTMLInputElement>(null); const textRef = useRef<HTMLInputElement>(null);
const priceRef = useRef<HTMLInputElement>(null); const priceRef = useRef<HTMLInputElement>(null);
const doSubmit = () => { const doSubmit = () => {
onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value || "0")); onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0"));
} }
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value || "0")); onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0"));
} }
} }

View File

@@ -23,7 +23,7 @@ type Result = {
} }
/** Modální dialog pro výpočet výhodnosti pizzy. */ /** Modální dialog pro výpočet výhodnosti pizzy. */
export default function PizzaCalculatorModal({ isOpen, onClose }: Props) { export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<Props>) {
const diameter1Ref = useRef<HTMLInputElement>(null); const diameter1Ref = useRef<HTMLInputElement>(null);
const price1Ref = useRef<HTMLInputElement>(null); const price1Ref = useRef<HTMLInputElement>(null);
const diameter2Ref = useRef<HTMLInputElement>(null); const diameter2Ref = useRef<HTMLInputElement>(null);
@@ -36,15 +36,13 @@ export default function PizzaCalculatorModal({ isOpen, onClose }: Props) {
// 1. pizza // 1. pizza
if (diameter1Ref.current?.value) { if (diameter1Ref.current?.value) {
const diameter1 = parseInt(diameter1Ref.current?.value); const diameter1 = Number.parseInt(diameter1Ref.current?.value);
if (!r.pizza1) { r.pizza1 ??= {};
r.pizza1 = {};
}
if (diameter1 && diameter1 > 0) { if (diameter1 && diameter1 > 0) {
r.pizza1.diameter = diameter1; r.pizza1.diameter = diameter1;
r.pizza1.area = Math.PI * Math.pow(diameter1 / 2, 2); r.pizza1.area = Math.PI * Math.pow(diameter1 / 2, 2);
if (price1Ref.current?.value) { if (price1Ref.current?.value) {
const price1 = parseInt(price1Ref.current?.value); const price1 = Number.parseInt(price1Ref.current?.value);
if (price1) { if (price1) {
r.pizza1.pricePerM = price1 / r.pizza1.area; r.pizza1.pricePerM = price1 / r.pizza1.area;
} else { } else {
@@ -58,15 +56,13 @@ export default function PizzaCalculatorModal({ isOpen, onClose }: Props) {
// 2. pizza // 2. pizza
if (diameter2Ref.current?.value) { if (diameter2Ref.current?.value) {
const diameter2 = parseInt(diameter2Ref.current?.value); const diameter2 = Number.parseInt(diameter2Ref.current?.value);
if (!r.pizza2) { r.pizza2 ??= {};
r.pizza2 = {};
}
if (diameter2 && diameter2 > 0) { if (diameter2 && diameter2 > 0) {
r.pizza2.diameter = diameter2; r.pizza2.diameter = diameter2;
r.pizza2.area = Math.PI * Math.pow(diameter2 / 2, 2); r.pizza2.area = Math.PI * Math.pow(diameter2 / 2, 2);
if (price2Ref.current?.value) { if (price2Ref.current?.value) {
const price2 = parseInt(price2Ref.current?.value); const price2 = Number.parseInt(price2Ref.current?.value);
if (price2) { if (price2) {
r.pizza2.pricePerM = price2 / r.pizza2.area; r.pizza2.pricePerM = price2 / r.pizza2.area;
} else { } else {
@@ -81,8 +77,8 @@ export default function PizzaCalculatorModal({ isOpen, onClose }: Props) {
// Srovnání // Srovnání
if (r.pizza1?.pricePerM && r.pizza2?.pricePerM && r.pizza1.diameter && r.pizza2.diameter) { if (r.pizza1?.pricePerM && r.pizza2?.pricePerM && r.pizza1.diameter && r.pizza2.diameter) {
r.choice = r.pizza1.pricePerM < r.pizza2.pricePerM ? 1 : 2; r.choice = r.pizza1.pricePerM < r.pizza2.pricePerM ? 1 : 2;
const bigger = r.pizza1.pricePerM > r.pizza2.pricePerM ? r.pizza1.pricePerM : r.pizza2.pricePerM; const bigger = Math.max(r.pizza1.pricePerM, r.pizza2.pricePerM);
const smaller = r.pizza1.pricePerM < r.pizza2.pricePerM ? r.pizza1.pricePerM : r.pizza2.pricePerM; const smaller = Math.min(r.pizza1.pricePerM, r.pizza2.pricePerM);
r.ratio = (bigger / smaller) - 1; r.ratio = (bigger / smaller) - 1;
r.diameterDiff = Math.abs(r.pizza1.diameter - r.pizza2.diameter); r.diameterDiff = Math.abs(r.pizza1.diameter - r.pizza2.diameter);
} else { } else {

View File

@@ -0,0 +1,97 @@
import { useRef, useState } from "react";
import { Modal, Button, Alert, Form } from "react-bootstrap";
type Props = {
isOpen: boolean;
onClose: () => void;
};
/** Modální dialog pro přenačtení menu z restaurací. */
export default function RefreshMenuModal({ isOpen, onClose }: Readonly<Props>) {
const refreshPassRef = useRef<HTMLInputElement>(null);
const refreshTypeRef = useRef<HTMLSelectElement>(null);
const [refreshLoading, setRefreshLoading] = useState(false);
const [refreshMessage, setRefreshMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const handleRefresh = async () => {
const password = refreshPassRef.current?.value;
const type = refreshTypeRef.current?.value;
if (!password || !type) {
setRefreshMessage({ type: 'error', text: 'Zadejte heslo a typ refresh.' });
return;
}
setRefreshLoading(true);
setRefreshMessage(null);
try {
const res = await fetch(`/api/food/refresh?type=${type}&heslo=${encodeURIComponent(password)}`);
const data = await res.json();
if (res.ok) {
setRefreshMessage({ type: 'success', text: 'Uspesny fetch' });
if (refreshPassRef.current) {
refreshPassRef.current.value = '';
}
} else {
setRefreshMessage({ type: 'error', text: data.error || 'Chyba při obnovování jídelníčku.' });
}
} catch (error) {
console.error('Error refreshing menu:', error);
setRefreshMessage({ type: 'error', text: 'Chyba při obnovování jídelníčku.' });
} finally {
setRefreshLoading(false);
}
};
const handleClose = () => {
setRefreshMessage(null);
onClose();
};
return (
<Modal show={isOpen} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title><h2>Přenačtení menu</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Ruční refresh dat z restaurací.</p>
{refreshMessage && (
<Alert variant={refreshMessage.type === 'success' ? 'success' : 'danger'}>
{refreshMessage.text}
</Alert>
)}
<Form.Group className="mb-3">
<Form.Label>Heslo</Form.Label>
<Form.Control
ref={refreshPassRef}
type="password"
placeholder="Zadejte heslo"
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Typ refreshe</Form.Label>
<Form.Select ref={refreshTypeRef} defaultValue="week">
<option value="week">Týden</option>
<option value="day">Den</option>
</Form.Select>
</Form.Group>
<Button
onClick={handleRefresh}
disabled={refreshLoading}
>
{refreshLoading ? 'Načítám...' : 'Obnovit menu'}
</Button>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
);
}

View File

@@ -1,42 +1,238 @@
import { useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { Modal, Button } from "react-bootstrap" import { Modal, Button, Form } from "react-bootstrap"
import { useSettings } from "../../context/settings"; 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 = { type Props = {
isOpen: boolean, isOpen: boolean,
onClose: () => void, onClose: () => void,
onSave: (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => void, onSave: (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean, themePreference?: ThemePreference) => void,
} }
/** Modální dialog pro uživatelská nastavení. */ /** Modální dialog pro uživatelská nastavení. */
export default function SettingsModal({ isOpen, onClose, onSave }: Props) { export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Props>) {
const auth = useAuth();
const settings = useSettings(); const settings = useSettings();
const bankAccountRef = useRef<HTMLInputElement>(null); const bankAccountRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null); const nameRef = useRef<HTMLInputElement>(null);
const hideSoupsRef = useRef<HTMLInputElement>(null); const hideSoupsRef = useRef<HTMLInputElement>(null);
const themeRef = useRef<HTMLSelectElement>(null);
return <Modal show={isOpen} onHide={onClose} size="lg"> const reminderTimeRef = useRef<HTMLInputElement>(null);
<Modal.Header closeButton> const ntfyTopicRef = useRef<HTMLInputElement>(null);
<Modal.Title><h2>Nastavení</h2></Modal.Title> const discordWebhookRef = useRef<HTMLInputElement>(null);
</Modal.Header> const teamsWebhookRef = useRef<HTMLInputElement>(null);
<Modal.Body> const [notifSettings, setNotifSettings] = useState<NotificationSettings>({});
<h4>Obecné</h4> const [enabledEvents, setEnabledEvents] = useState<UdalostEnum[]>([]);
<span title="V nabídkách nebudou zobrazovány polévky. Tato funkce je experimentální, a zejména u TechTower bývá často problém polévky spolehlivě rozeznat. V případě využití této funkce průběžně nahlašujte stále se zobrazující polévky." style={{ "cursor": "help" }}>
<input ref={hideSoupsRef} type="checkbox" defaultChecked={settings?.hideSoups} /> Skrýt polévky useEffect(() => {
</span> if (isOpen && auth?.login) {
<hr /> getNotificationSettings().then(response => {
<h4>Bankovní účet</h4> if (response.data) {
<p>Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.<br />Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.<br /><br />Číslo účtu není ukládáno na serveru, posílá se na něj pouze za účelem vygenerování QR kódů.</p> setNotifSettings(response.data);
Číslo účtu: <input className="mb-3" ref={bankAccountRef} type="text" placeholder="123456-1234567890/1234" defaultValue={settings?.bankAccount} onKeyDown={e => e.stopPropagation()} /> <br /> setEnabledEvents(response.data.enabledEvents ?? []);
Název příjemce (jméno majitele účtu): <input ref={nameRef} type="text" placeholder="Jan Novák" defaultValue={settings?.holderName} onKeyDown={e => e.stopPropagation()} /> }
</Modal.Body> }).catch(() => {});
<Modal.Footer> }
<Button variant="secondary" onClick={onClose}> }, [isOpen, auth?.login]);
Storno
</Button> const toggleEvent = (event: UdalostEnum) => {
<Button variant="primary" onClick={() => onSave(bankAccountRef.current?.value, nameRef.current?.value, hideSoupsRef.current?.checked)}> setEnabledEvents(prev =>
Uložit prev.includes(event) ? prev.filter(e => e !== event) : [...prev, event]
</Button> );
</Modal.Footer> };
</Modal>
} const handleSave = async () => {
const newReminderTime = reminderTimeRef.current?.value || undefined;
const oldReminderTime = notifSettings.reminderTime;
// 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>
);
}

View File

@@ -1,5 +1,4 @@
import React, { ReactNode, useContext, useState } from "react" import React, { ReactNode, useContext, useEffect, useState } from "react"
import { useEffect } from "react"
import { useJwt } from "react-jwt"; import { useJwt } from "react-jwt";
import { deleteToken, getToken, storeToken } from "../Utils"; import { deleteToken, getToken, storeToken } from "../Utils";
@@ -16,7 +15,7 @@ type ContextProps = {
const authContext = React.createContext<AuthContextProps | null>(null); const authContext = React.createContext<AuthContextProps | null>(null);
export function ProvideAuth(props: ContextProps) { export function ProvideAuth(props: Readonly<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>
} }
@@ -28,8 +27,8 @@ 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 [trusted, setTrusted] = useState<boolean | undefined>();
const [token, setToken] = useState<string | null>(getToken()); const [token, setToken] = useState<string | undefined>(getToken());
const { decodedToken } = useJwt(token || ''); const { decodedToken } = useJwt(token ?? '');
useEffect(() => { useEffect(() => {
if (token && token.length > 0) { if (token && token.length > 0) {
@@ -52,11 +51,11 @@ function useProvideAuth(): AuthContextProps {
function logout() { function logout() {
const trusted = (decodedToken as any).trusted; const trusted = (decodedToken as any).trusted;
const logoutUrl = (decodedToken as any).logoutUrl; const logoutUrl = (decodedToken as any).logoutUrl;
setToken(null); setToken(undefined);
setLoginName(undefined); setLoginName(undefined);
setTrusted(undefined); setTrusted(undefined);
if (trusted && logoutUrl?.length) { if (trusted && logoutUrl?.length) {
window.location.replace(logoutUrl); globalThis.location.replace(logoutUrl);
} }
} }

View File

@@ -1,7 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getEasterEgg } from "../api/EasterEggApi";
import { AuthContextProps } from "./auth"; import { AuthContextProps } from "./auth";
import { EasterEgg } from "../types"; import { EasterEgg, getEasterEgg } from "../../../types";
export const useEasterEgg = (auth?: AuthContextProps | null): [EasterEgg | undefined, boolean] => { export const useEasterEgg = (auth?: AuthContextProps | null): [EasterEgg | undefined, boolean] => {
const [result, setResult] = useState<EasterEgg | undefined>(); const [result, setResult] = useState<EasterEgg | undefined>();
@@ -11,7 +10,7 @@ export const useEasterEgg = (auth?: AuthContextProps | null): [EasterEgg | undef
async function fetchEasterEgg() { async function fetchEasterEgg() {
if (auth?.login) { if (auth?.login) {
setLoading(true); setLoading(true);
const egg = await getEasterEgg(); const egg = (await getEasterEgg())?.data;
setResult(egg); setResult(egg);
setLoading(false); setLoading(false);
} }

View File

@@ -1,17 +1,21 @@
import React, { ReactNode, useContext, useState } from "react" import React, { ReactNode, useContext, useEffect, useState } from "react"
import { useEffect } from "react"
const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number'; const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number';
const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name'; const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
const HIDE_SOUPS_KEY = 'hide_soups'; const HIDE_SOUPS_KEY = 'hide_soups';
const THEME_KEY = 'theme_preference';
export type ThemePreference = 'system' | 'light' | 'dark';
export type SettingsContextProps = { export type SettingsContextProps = {
bankAccount?: string, bankAccount?: string,
holderName?: string, holderName?: string,
hideSoups?: boolean, hideSoups?: boolean,
themePreference: ThemePreference,
setBankAccountNumber: (accountNumber?: string) => void, setBankAccountNumber: (accountNumber?: string) => void,
setBankAccountHolderName: (holderName?: string) => void, setBankAccountHolderName: (holderName?: string) => void,
setHideSoupsOption: (hideSoups?: boolean) => void, setHideSoupsOption: (hideSoups?: boolean) => void,
setThemePreference: (theme: ThemePreference) => void,
} }
type ContextProps = { type ContextProps = {
@@ -20,7 +24,7 @@ type ContextProps = {
const settingsContext = React.createContext<SettingsContextProps | null>(null); const settingsContext = React.createContext<SettingsContextProps | null>(null);
export function ProvideSettings(props: ContextProps) { export function ProvideSettings(props: Readonly<ContextProps>) {
const settings = useProvideSettings(); const settings = useProvideSettings();
return <settingsContext.Provider value={settings}>{props.children}</settingsContext.Provider> return <settingsContext.Provider value={settings}>{props.children}</settingsContext.Provider>
} }
@@ -29,10 +33,23 @@ export const useSettings = () => {
return useContext(settingsContext); return useContext(settingsContext);
} }
function getInitialTheme(): ThemePreference {
try {
const saved = localStorage.getItem(THEME_KEY) as ThemePreference | null;
if (saved && ['system', 'light', 'dark'].includes(saved)) {
return saved;
}
} catch (e) {
// localStorage nedostupný
}
return 'system';
}
function useProvideSettings(): SettingsContextProps { function useProvideSettings(): SettingsContextProps {
const [bankAccount, setBankAccount] = useState<string | undefined>(); const [bankAccount, setBankAccount] = useState<string | undefined>();
const [holderName, setHolderName] = useState<string | undefined>(); const [holderName, setHolderName] = useState<string | undefined>();
const [hideSoups, setHideSoups] = useState<boolean | undefined>(); const [hideSoups, setHideSoups] = useState<boolean | undefined>();
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme);
useEffect(() => { useEffect(() => {
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY); const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
@@ -45,7 +62,7 @@ function useProvideSettings(): SettingsContextProps {
} }
const hideSoups = localStorage.getItem(HIDE_SOUPS_KEY); const hideSoups = localStorage.getItem(HIDE_SOUPS_KEY);
if (hideSoups !== null) { if (hideSoups !== null) {
setHideSoups(hideSoups === 'true' ? true : false); setHideSoups(hideSoups === 'true');
} }
}, []) }, [])
@@ -73,6 +90,29 @@ function useProvideSettings(): SettingsContextProps {
} }
}, [hideSoups]); }, [hideSoups]);
useEffect(() => {
localStorage.setItem(THEME_KEY, themePreference);
}, [themePreference]);
useEffect(() => {
const applyTheme = (theme: 'light' | 'dark') => {
document.documentElement.setAttribute('data-bs-theme', theme);
};
if (themePreference === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
applyTheme(mediaQuery.matches ? 'dark' : 'light');
const handler = (e: MediaQueryListEvent) => {
applyTheme(e.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
} else {
applyTheme(themePreference);
}
}, [themePreference]);
function setBankAccountNumber(bankAccount?: string) { function setBankAccountNumber(bankAccount?: string) {
setBankAccount(bankAccount); setBankAccount(bankAccount);
} }
@@ -85,12 +125,18 @@ function useProvideSettings(): SettingsContextProps {
setHideSoups(hideSoups); setHideSoups(hideSoups);
} }
function setThemePreference(theme: ThemePreference) {
setTheme(theme);
}
return { return {
bankAccount, bankAccount,
holderName, holderName,
hideSoups, hideSoups,
themePreference,
setBankAccountNumber, setBankAccountNumber,
setBankAccountHolderName, setBankAccountHolderName,
setHideSoupsOption, setHideSoupsOption,
setThemePreference,
} }
} }

View File

@@ -7,8 +7,8 @@ if (process.env.NODE_ENV === 'development') {
socketUrl = `http://localhost:3001`; socketUrl = `http://localhost:3001`;
socketPath = undefined; socketPath = undefined;
} else { } else {
socketUrl = `${window.location.host}`; socketUrl = `${globalThis.location.host}`;
socketPath = `${window.location.pathname}socket.io`; socketPath = `${globalThis.location.pathname}socket.io`;
} }
export const socket = socketio.connect(socketUrl, { path: socketPath, transports: ["websocket"] }); export const socket = socketio.connect(socketUrl, { path: socketPath, transports: ["websocket"] });
@@ -18,8 +18,3 @@ export const SocketContext = React.createContext();
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_CONFIG = 'config';
// export const EVENT_TOASTER = 'toaster';
// export const EVENT_VOTING = 'voting';
// export const EVENT_VOTE_CONFIG = 'voteSettings';
// export const EVENT_ADMIN = 'admin';

41
client/src/enums.ts Normal file
View File

@@ -0,0 +1,41 @@
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;
}
}

View File

@@ -0,0 +1,108 @@
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);
}
}

View File

@@ -7,14 +7,32 @@ body,
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family: 'Inter', -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);
}

View File

@@ -1,33 +1,38 @@
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 { ProvideSettings } from './context/settings';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
import './index.css'; import './index.css';
import Snowfall from 'react-snowfall'; import AppRoutes from './AppRoutes';
import { BrowserRouter } from 'react-router';
import { client } from '../../types/gen/client.gen';
import { getToken } from './Utils';
import { toast } from 'react-toastify';
client.setConfig({
auth: () => getToken(),
baseUrl: '/api', // openapi-ts si to z nějakého důvodu neumí převzít z api.yml
});
// 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" });
}
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>
<ProvideAuth> <BrowserRouter>
<ProvideSettings> <ProvideAuth>
<SocketContext.Provider value={socket}> <AppRoutes />
<> </ProvideAuth>
<Snowfall style={{ </BrowserRouter>
zIndex: 2,
position: 'fixed',
width: '100vw',
height: '100vh'}} />
<App />
</>
<ToastContainer />
</SocketContext.Provider>
</ProvideSettings>
</ProvideAuth>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -0,0 +1,155 @@
.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);
}
.voting-stats-section {
margin-top: 48px;
width: 100%;
max-width: 800px;
h2 {
font-size: 1.5rem;
font-weight: 700;
color: var(--luncher-text);
margin-bottom: 16px;
text-align: center;
}
}
.voting-stats-table {
width: 100%;
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;
&:last-child {
text-align: center;
width: 120px;
}
}
td {
padding: 12px 20px;
border-bottom: 1px solid var(--luncher-border-light);
color: var(--luncher-text);
font-size: 0.9rem;
&:last-child {
text-align: center;
font-weight: 600;
color: var(--luncher-primary);
}
}
tbody tr {
transition: var(--luncher-transition);
&:hover {
background: var(--luncher-bg-hover);
}
&:last-child td {
border-bottom: none;
}
}
}
}

View File

@@ -0,0 +1,170 @@
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, VotingStats, FeatureRequest, getStats, getVotingStats } 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>();
const [votingStats, setVotingStats] = useState<VotingStats>();
// 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]);
// Načtení statistik hlasování
useEffect(() => {
getVotingStats().then(response => {
setVotingStats(response.data);
});
}, []);
const sortedVotingStats = useMemo(() => {
if (!votingStats) return [];
return Object.entries(votingStats)
.sort((a, b) => (b[1] as number) - (a[1] as number));
}, [votingStats]);
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>
{sortedVotingStats.length > 0 && (
<div className="voting-stats-section">
<h2>Hlasování o funkcích</h2>
<table className="voting-stats-table">
<thead>
<tr>
<th>Funkce</th>
<th>Počet hlasů</th>
</tr>
</thead>
<tbody>
{sortedVotingStats.map(([feature, count]) => (
<tr key={feature}>
<td>{FeatureRequest[feature as keyof typeof FeatureRequest] ?? feature}</td>
<td>{count as number}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<Footer />
</>
);
}

View File

@@ -1,6 +1,5 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ESNext",
"lib": [ "lib": [
"dom", "dom",
"dom.iterable", "dom.iterable",
@@ -16,10 +15,12 @@
"strict": true, "strict": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"module": "esnext", "moduleResolution": "bundler",
"moduleResolution": "node", "module": "ESNext",
"target": "ESNext",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"allowImportingTsExtensions": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx" "jsx": "react-jsx"
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,18 @@
export NODE_ENV=development #!/bin/bash
yarn install # Spustí server a klienta v samostatných panelech uvnitř stejného tmux okna.
cd server && yarn start & # Pokud už daná tmux session existuje, pouze se k ní připojí.
cd client && yarn start &
wait SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
SESSION="luncher"
if ! tmux has-session -t $SESSION 2>/dev/null; then
cd types && yarn openapi-ts && cd ..
tmux new-session -d -s $SESSION
tmux send-keys -t $SESSION:0 "cd $SCRIPT_DIR" Enter
tmux split-window -v
tmux send-keys -t $SESSION:0.0 "cd server && export NODE_ENV=development && yarn startReload" Enter
tmux send-keys -t $SESSION:0.1 "cd client && yarn start" Enter
fi
tmux attach-session -t $SESSION

View File

@@ -37,4 +37,14 @@
# HTTP_REMOTE_TRUSTED_IPS=127.0.0.1,192.168.1.0/24 # HTTP_REMOTE_TRUSTED_IPS=127.0.0.1,192.168.1.0/24
# Název důvěryhodné hlavičky obsahující login uživatele. Výchozí hodnota je 'remote-user'. # Název důvěryhodné hlavičky obsahující login uživatele. Výchozí hodnota je 'remote-user'.
# HTTP_REMOTE_USER_HEADER_NAME=remote-user # HTTP_REMOTE_USER_HEADER_NAME=remote-user
# VAPID klíče pro Web Push notifikace (připomínka výběru oběda).
# Vygenerovat pomocí: npx web-push generate-vapid-keys
# VAPID_PUBLIC_KEY=
# VAPID_PRIVATE_KEY=
# VAPID_SUBJECT=mailto:admin@example.com
# Heslo pro bypass rate limitu na endpointu /api/food/refresh (pro skripty/admin).
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
# REFRESH_BYPASS_PASSWORD=

6
server/.gitignore vendored
View File

@@ -1,7 +1,7 @@
/node_modules /data
/dist /dist
data.json /resources/easterEggs
/src/gen
.env.production .env.production
.env.development .env.development
.easter-eggs.json .easter-eggs.json
/resources/easterEggs

View File

@@ -11,29 +11,31 @@
"test": "jest" "test": "jest"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.23.0", "@babel/core": "^7.28.5",
"@babel/preset-env": "^7.22.20", "@babel/preset-env": "^7.28.5",
"@babel/preset-typescript": "^7.23.0", "@babel/preset-typescript": "^7.28.5",
"@types/express": "^4.17.17", "@types/express": "^5.0.5",
"@types/jest": "^29.5.14", "@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.11.20", "@types/node": "^24.10.0",
"@types/request-promise": "^4.1.48", "@types/request-promise": "^4.1.48",
"babel-jest": "^29.7.0", "@types/web-push": "^3.6.4",
"jest": "^29.7.0", "babel-jest": "^30.2.0",
"nodemon": "^3.1.0", "jest": "^30.2.0",
"nodemon": "^3.1.10",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.0.2" "typescript": "^5.9.3"
}, },
"dependencies": { "dependencies": {
"axios": "^1.4.0", "axios": "^1.13.2",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.1.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^17.2.3",
"express": "^4.18.2", "express": "^5.1.0",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
"redis": "^4.6.7", "redis": "^5.9.0",
"simple-json-db": "^2.0.0", "simple-json-db": "^2.0.0",
"socket.io": "^4.6.1" "socket.io": "^4.6.1",
"web-push": "^3.6.7"
} }
} }

View File

@@ -49,16 +49,16 @@ export async function downloadPizzy(mock: boolean): Promise<Pizza[]> {
const $ = load(html); const $ = load(html);
const links = $('.vypisproduktu > div > h4 > a'); const links = $('.vypisproduktu > div > h4 > a');
const urls = []; const urls = [];
for (let i = 0; i < links.length; i++) { for (const element of links) {
if (links[i].name === 'a' && links[i].attribs?.href) { if (element.name === 'a' && element.attribs?.href) {
const pizzaUrl = links[i].attribs?.href; const pizzaUrl = element.attribs?.href;
urls.push(buildPizzaUrl(pizzaUrl)); urls.push(buildPizzaUrl(pizzaUrl));
} }
} }
// Scrapneme jednotlivé pizzy // Scrapneme jednotlivé pizzy
const result: Pizza[] = []; const result: Pizza[] = [];
for (let i = 0; i < urls.length; i++) { for (const element of urls) {
const pizzaUrl = urls[i]; const pizzaUrl = element;
const pizzaHtml = await axios.get(pizzaUrl).then(res => res.data); const pizzaHtml = await axios.get(pizzaUrl).then(res => res.data);
// Název // Název
const name = $('.produkt > h2', pizzaHtml).first().text() const name = $('.produkt > h2', pizzaHtml).first().text()

View File

@@ -1,20 +1,26 @@
import express from "express"; import express from "express";
import bodyParser from "body-parser"; import bodyParser from "body-parser";
import cors from 'cors'; import cors from 'cors';
import { getData, getDateForWeekIndex } from "./service"; import { getData, getDateForWeekIndex, getToday } from "./service";
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import { getQr } from "./qr"; import { getQr } from "./qr";
import { generateToken, verify } from "./auth"; import { generateToken, getLogin, verify } from "./auth";
import { InsufficientPermissions } from "./utils"; import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
import { getPendingQrs } from "./pizza";
import { initWebsocket } from "./websocket"; import { initWebsocket } from "./websocket";
import { startReminderScheduler } from "./pushReminder";
import pizzaDayRoutes from "./routes/pizzaDayRoutes"; import pizzaDayRoutes from "./routes/pizzaDayRoutes";
import foodRoutes from "./routes/foodRoutes"; import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
import votingRoutes from "./routes/votingRoutes"; import votingRoutes from "./routes/votingRoutes";
import easterEggRoutes from "./routes/easterEggRoutes"; import easterEggRoutes from "./routes/easterEggRoutes";
import statsRoutes from "./routes/statsRoutes";
import notificationRoutes from "./routes/notificationRoutes";
import qrRoutes from "./routes/qrRoutes";
import devRoutes from "./routes/devRoutes";
const ENVIRONMENT = process.env.NODE_ENV || 'production'; const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `./.env.${ENVIRONMENT}`) }); dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
// Validace nastavení JWT tokenu - nemá bez něj smysl vůbec povolit server spustit // Validace nastavení JWT tokenu - nemá bez něj smysl vůbec povolit server spustit
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
@@ -34,7 +40,7 @@ app.use(cors({
// Zapínatelný login přes hlavičky - pokud je zapnutý nepovolí "basicauth" // Zapínatelný login přes hlavičky - pokud je zapnutý nepovolí "basicauth"
const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false; const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false;
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME || 'remote-user'; const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user';
if (HTTP_REMOTE_USER_ENABLED) { if (HTTP_REMOTE_USER_ENABLED) {
if (!process.env.HTTP_REMOTE_TRUSTED_IPS) { if (!process.env.HTTP_REMOTE_TRUSTED_IPS) {
throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.'); throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.');
@@ -53,6 +59,10 @@ app.get("/api/whoami", (req, res) => {
if (!HTTP_REMOTE_USER_ENABLED) { if (!HTTP_REMOTE_USER_ENABLED) {
res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' }); res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' });
} }
if (process.env.ENABLE_HEADERS_LOGGING === 'yes') {
delete req.headers["cookie"]
console.log(req.headers)
}
res.send(req.header(HTTP_REMOTE_USER_HEADER_NAME)); res.send(req.header(HTTP_REMOTE_USER_HEADER_NAME));
}) })
@@ -60,11 +70,11 @@ app.post("/api/login", (req, res) => {
if (HTTP_REMOTE_USER_ENABLED) { // je rovno app.enabled('trust proxy') if (HTTP_REMOTE_USER_ENABLED) { // je rovno app.enabled('trust proxy')
// Autentizace pomocí trusted headers // Autentizace pomocí trusted headers
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME); const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
const remoteName = req.header('remote-name'); //const remoteName = req.header('remote-name');
if (remoteUser && remoteUser.length > 0 && remoteName && remoteName.length > 0) { if (remoteUser && remoteUser.length > 0) {
res.status(200).json(generateToken(Buffer.from(remoteName, 'latin1').toString(), true)); res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true));
} else { } else {
throw Error("Tohle nema nastat nekdo neco dela spatne."); throw Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??");
} }
} else { } else {
// Klasická autentizace loginem // Klasická autentizace loginem
@@ -78,7 +88,6 @@ app.post("/api/login", (req, res) => {
// TODO dočasné řešení - QR se zobrazuje přes <img>, nemáme sem jak dostat token // TODO dočasné řešení - QR se zobrazuje přes <img>, nemáme sem jak dostat token
app.get("/api/qr", (req, res) => { app.get("/api/qr", (req, res) => {
// const login = getLogin(parseToken(req));
if (!req.query?.login) { if (!req.query?.login) {
throw Error("Nebyl předán login"); throw Error("Nebyl předán login");
} }
@@ -92,15 +101,23 @@ app.get("/api/qr", (req, res) => {
// ---------------------------------------------------- // ----------------------------------------------------
// Přeskočení auth pro refresh dat xd
app.use("/api/food/refresh", refreshMetoda);
/** Middleware ověřující JWT token */ /** Middleware ověřující JWT token */
app.use("/api/", (req, res, next) => { app.use("/api/", (req, res, next) => {
if (HTTP_REMOTE_USER_ENABLED) { if (HTTP_REMOTE_USER_ENABLED) {
const userHeader = req.header(HTTP_REMOTE_USER_HEADER_NAME); // Autentizace pomocí trusted headers
const nameHeader = req.header('remote-name'); const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
const emailHeader = req.header('remote-email'); if (process.env.ENABLE_HEADERS_LOGGING === 'yes') {
if (userHeader !== undefined && nameHeader !== undefined) { delete req.headers["cookie"]
const remoteName = Buffer.from(nameHeader, 'latin1').toString(); console.log(req.headers)
console.log("Tvuj username, name a email: %s, %s, %s.", userHeader, remoteName, emailHeader); }
if (remoteUser && remoteUser.length > 0) {
const remoteName = Buffer.from(remoteUser, 'latin1').toString();
if (ENVIRONMENT !== "production") {
console.log("Tvuj username: %s.", remoteName);
}
} }
} }
if (!req.headers.authorization) { if (!req.headers.authorization) {
@@ -121,8 +138,22 @@ app.get("/api/data", async (req, res) => {
if (!isNaN(index)) { if (!isNaN(index)) {
date = getDateForWeekIndex(parseInt(req.query.dayIndex)); date = getDateForWeekIndex(parseInt(req.query.dayIndex));
} }
} else if (getIsWeekend(getToday())) {
// Na víkendu zobrazíme pátek místo hlášky "Užívejte víkend"
date = getDateForWeekIndex(4);
} }
res.status(200).json(await getData(date)); const data = await getData(date);
// Připojíme nevyřízené QR kódy pro přihlášeného uživatele
try {
const login = getLogin(parseToken(req));
const pendingQrs = await getPendingQrs(login);
if (pendingQrs.length > 0) {
data.pendingQrs = pendingQrs;
}
} catch {
// Token nemusí být validní, ignorujeme
}
res.status(200).json(data);
}); });
// Ostatní routes // Ostatní routes
@@ -130,23 +161,32 @@ app.use("/api/pizzaDay", pizzaDayRoutes);
app.use("/api/food", foodRoutes); app.use("/api/food", foodRoutes);
app.use("/api/voting", votingRoutes); app.use("/api/voting", votingRoutes);
app.use("/api/easterEggs", easterEggRoutes); app.use("/api/easterEggs", easterEggRoutes);
app.use(express.static('public')) app.use("/api/stats", statsRoutes);
app.use("/api/notifications", notificationRoutes);
app.use("/api/qr", qrRoutes);
app.use("/api/dev", devRoutes);
app.use('/stats', express.static('public'));
app.use(express.static('public'));
// Middleware pro zpracování chyb // Middleware pro zpracování chyb
app.use((err: any, req: any, res: any, next: any) => { app.use((err: any, req: any, res: any, next: any) => {
if (err instanceof InsufficientPermissions) { if (err instanceof InsufficientPermissions) {
res.status(403).send({ error: err.message }) res.status(403).send({ error: err.message })
} else if (err instanceof PizzaDayConflictError) {
res.status(409).send({ error: err.message })
} else { } else {
res.status(500).send({ error: err.message }) res.status(500).send({ error: err.message })
} }
next(); next();
}); });
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT ?? 3001;
const HOST = process.env.HOST || '0.0.0.0'; const HOST = process.env.HOST ?? '0.0.0.0';
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(`Server listening on ${HOST}, port ${PORT}`); console.log(`Server listening on ${HOST}, port ${PORT}`);
startReminderScheduler();
}); });
// Umožníme vypnutí serveru přes SIGINT, jinak Docker čeká než ho sestřelí // Umožníme vypnutí serveru přes SIGINT, jinak Docker čeká než ho sestřelí

View File

@@ -1,137 +1,159 @@
import { WeeklyStats, LunchChoice } from "../../types/gen/types.gen";
// Mockovací data pro podporované podniky, na jeden týden // Mockovací data pro podporované podniky, na jeden týden
const MOCK_DATA = { const MOCK_DATA = {
'sladovnicka': { 'sladovnicka': [
'MONDAY': [ [
{ {
amount: "0,25l", amount: "0,25l",
name: "Kulajda", name: "Česnečka s uzeným masem a krutony",
price: "35\xA0Kč", price: "35\xA0Kč",
isSoup: true, isSoup: true,
allergens: [1, 3, 7, 9]
}, },
{ {
amount: "250g", amount: "250g",
name: "Kuřecí křidélka s vařeným bramborem", name: "Přírodní roštěná s jasmínovou rýží",
price: "135\xA0Kč", price: "135\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 9, 10]
}, },
{ {
amount: "150g", amount: "150g",
name: "Hovězí hamburger s BBQ omáčkou a hranolky", name: "Noky s kuřecím masem a sýrovou omáčkou",
price: "145\xA0Kč", price: "145\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
}, },
{ {
amount: "150g", amount: "150g",
name: "Frankfurtská hovězí pečeně s jasmínovou rýží", name: "Kuřecí stehno pečené na Moravance s feferony, bramborový knedlík",
price: "135\xA0Kč", price: "135\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 3, 7, 9]
} }
], ],
'TUESDAY': [ [
{
amount: "0,25l",
name: "Hovězí vývar s kapáním",
price: "35\xA0Kč",
isSoup: true,
},
{
amount: "200g",
name: "Smažený karbanátek s bramborovou kaší",
price: "135\xA0Kč",
isSoup: false,
},
{
amount: "150g",
name: "Vepřová plec na smetaně s kynutým knedlíkem",
price: "135\xA0Kč",
isSoup: false,
},
{
amount: "150g",
name: "Trhané kachní maso se zeleninovým kuskusem",
price: "135\xA0Kč",
isSoup: false,
}
],
'WEDNESDAY': [
{
amount: "0,25l",
name: "Zelná polévka s klobásou",
price: "35\xA0Kč",
isSoup: true,
},
{
amount: "150g",
name: "Hovězí na česneku s bramborovým knedlíkem",
price: "135\xA0Kč",
isSoup: false,
},
{
amount: "250g",
name: "Přírodní holandský řízek s bramborovou kaší, rajčatový salát",
price: "135\xA0Kč",
isSoup: false,
},
{
amount: "350g",
name: "Bagel s vinnou klobásou, cibulový konfit, kysané zelí, slanina a hořčicová mayo, hranolky, curry omáčka",
price: "135\xA0Kč",
isSoup: false,
}
],
'THURSDAY': [
{ {
amount: "0,25l", amount: "0,25l",
name: "Kuřecí vývar s nudlemi", name: "Kuřecí vývar s nudlemi",
price: "35\xA0Kč", price: "35\xA0Kč",
isSoup: true, isSoup: true,
allergens: [1, 3, 7, 9]
}, },
{ {
amount: "150g", amount: "200g",
name: "Kovbojské fazole s klobásou a chlebem", name: "Hovězí maso v rajské omáčce s kynutým knedlíkem",
price: "125\xA0Kč",
isSoup: false,
},
{
amount: "150g",
name: "Kuřecí rarášci s vařeným bramborem",
price: "135\xA0Kč", price: "135\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
}, },
{ {
amount: "150g", amount: "150g",
name: "Hovězí pečeně na slanině s jasmínovou rýží", name: "Krůtí roláda se sušenými rajčaty , mozzarellou a bramborovou kaší",
price: "135\xA0Kč", price: "135\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 7]
},
{
amount: "150g",
name: "Telecí játra na grilu, restované brambory, tatarská omáčka , polníčkový salát",
price: "135\xA0Kč",
isSoup: false,
allergens: [3, 7]
} }
], ],
'FRIDAY': [ [
{ {
amount: "0,25l", amount: "0,25l",
name: "Dršťková polévka", name: "Zeleninová polévka",
price: "35\xA0Kč", price: "35\xA0Kč",
isSoup: true, isSoup: true,
allergens: [3, 9]
}, },
{ {
amount: "150g", amount: "150g",
name: "Tortilla s kuřecím masem, čedarem, zeleninou a papričkami jalapeňos", name: "Smažené rybí filé s vařeným bramborem, tatarka",
price: "135\xA0Kč", price: "135\xA0Kč",
isSoup: false, isSoup: false,
allergens: [3, 7]
}, },
{ {
amount: "150g", amount: "250g",
name: "Segedínský guláš s kynutým knedlíkem", name: "Vepřové výpečky se špenátem, bramborový knedlík 1,3,7",
price: "135\xA0Kč", price: "135\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
},
{
amount: "350g",
name: "Kuřecí řízek \" Ondráš \" , kysané zelí",
price: "135\xA0Kč",
isSoup: false,
allergens: [3, 7]
}
],
[
{
amount: "0,25l",
name: "Hovězí vývar s játrovými knedlíčky",
price: "35\xA0Kč",
isSoup: true,
allergens: [1, 3]
}, },
{ {
amount: "150g", amount: "150g",
name: "Filet z krůtích prsou, omáčka z modrého sýra, pečené brambory", name: "Merguez klobáska, bílé fazole na kyselo, sázené vejce a vídeňská cibulka",
price: "125\xA0Kč",
isSoup: false,
allergens: [1, 3]
},
{
amount: "150g",
name: "Kuřecí steak s liškovou omáčkou a opečený brambor",
price: "135\xA0Kč",
isSoup: false,
allergens: [1, 7]
},
{
amount: "150g",
name: "Kaťák vepřové kostky s feferonou, cibulí, kečupem ,česnekem, smažené krokety",
price: "135\xA0Kč",
isSoup: false,
allergens: [3, 7]
}
],
[
{
amount: "0,25l",
name: "Čočková polévka",
price: "35\xA0Kč",
isSoup: true,
allergens: [9, 12]
},
{
amount: "150g",
name: "Ovocné knedlíky s tvarohem",
price: "135\xA0Kč",
isSoup: false,
allergens: [1, 3, 7]
},
{
amount: "150g",
name: "Smažený vepřový řízek s bramborovým salátem",
price: "135\xA0Kč",
isSoup: false,
allergens: [1, 3, 7, 9, 10]
},
{
amount: "150g",
name: "Znojemský hovězí guláš s jasmínovou rýží",
price: "145\xA0Kč", price: "145\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 9]
} }
] ]
}, ],
'uMotliku': [ 'uMotliku': [
[ [
{ {
@@ -268,161 +290,185 @@ const MOCK_DATA = {
[ [
{ {
amount: "-", amount: "-",
name: "Uzený vývar s kapustou", name: "Batátový krém s chilli a kokosovým mlékem",
price: "40\xA0Kč", price: "40\xA0Kč",
isSoup: true, isSoup: true,
allergens: [1, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Čočka na kyselo, opečená klobása, okurka, chléb", name: "Kuřecí stehno na paprice, knedlík",
price: "130\xA0Kč", price: "130\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Smažená brokolice, brambory, tatarská omáčka", name: "Těstoviny se sušenými rajčaty a cuketou, parmezán",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Uzený vepřový bůček, bramborové pyré", name: "Quesadilla s trham vepřovým masem, salát coleslaw, hranolky",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Kuřecí medailonky v sýrové omáčce, hranolky", name: "Smažený kuřecí řízek v sezamové strouhance, vařené brambory, wasabi majonéza",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
} }
], ],
[ [
{ {
amount: "-", amount: "-",
name: "Slepičí s nudlemi", name: "Ovarová",
price: "40\xA0Kč", price: "40\xA0Kč",
isSoup: true, isSoup: true,
allergens: [1]
}, },
{ {
amount: "-", amount: "-",
name: "Zvěřinový guláš, knedlík", name: "Zapečené těstoviny s uzeným masem, okurka",
price: "130\xA0Kč", price: "130\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Čínské nudle se zeleninou a vejcem", name: "Cheddarové kuličky s jalapeños, máslové brambory, tatarská omáčka",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Jitrnice/jelito, brambory, zelný salát s křenem, hořčice", name: "Steak z krkovice s miso omáčkou, jasmínová rýže",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 6, 11]
}, },
{ {
amount: "-", amount: "-",
name: "Vídeňská roštěná se smaženou cibulkou, jasmínová rýže", name: "Kuřecí supreme s bramborovo-mrkvovým pyré, restovaná cuketa",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [7]
} }
], ],
[ [
{ {
amount: "-", amount: "-",
name: "Dýňový krém se smetanou", name: "Hovězí s hráškem a rýží",
price: "40\xA0Kč", price: "40\xA0Kč",
isSoup: true, isSoup: true,
allergens: [9]
}, },
{ {
amount: "-", amount: "-",
name: "Kuřecí směs se zeleninou, rýže", name: "Rizoto s kuřecím masem a zeleninou, okurka, sýr",
price: "130\xA0Kč", price: "130\xA0Kč",
isSoup: false, isSoup: false,
allergens: [7, 9]
}, },
{ {
amount: "-", amount: "-",
name: "Tvarohové knedlíky s meruňkami, strouhaný tvaroh, máslo, cukr", name: "Smažené rýžové nudle Pad thai s arašídy, zeleninou a vejcem",
price: "na\xA0váhu",
isSoup: false,
allergens: [1, 3, 6, 8, 11]
},
{
amount: "-",
name: "Vykoštěné vepřové koleno s křenem a hořčicí, chléb",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
}, },
{ {
amount: "-", amount: "-",
name: "Ovar, křen, hořčice, pečivo", name: "Gordon bleu, hranolky, pikantní dip",
price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Telecí holandský řízek s uzeným sýrem, bramborové pyré",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
} }
], ],
[ [
{ {
amount: "-", amount: "-",
name: "Zeleninová s jáhly", name: "Dýňová",
price: "40\xA0Kč", price: "40\xA0Kč",
isSoup: true, isSoup: true,
allergens: [1, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Rizoto s vepřovým masem, okurka", name: "Uzená plec, křenová omáčka, knedlík",
price: "130\xA0Kč", price: "130\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Tortellini s parmezánovou omáčkou", name: "Palačinky s marmeládou přelité čokoládou, sypané cukrem",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Pečený prejt, brambory, zelný salát", name: "Smažený holandský řízek s bramborovou kaší a nakládanou zeleninou",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Chobotnice na grilu, grilovaná zelenina, bylinková bageta", name: "Kuřecí jatýrka na smetaně s čerstvou majoránkou, jasmínová rýže",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [7]
} }
], ],
[ [
{ {
amount: "-", amount: "-",
name: "Fazolová s uzeninou", name: "Hovězí vývar s játrovými knedlíčky",
price: "40\xA0Kč", price: "40\xA0Kč",
isSoup: true, isSoup: true,
allergens: [1, 3, 7, 9]
}, },
{ {
amount: "-", amount: "-",
name: "Krůtí perkelt, těstoviny", name: "Kuřecí Kung-pao, jasmínová rýže",
price: "130\xA0Kč", price: "130\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 3, 5, 6]
}, },
{ {
amount: "-", amount: "-",
name: "Grilovaný hermelín, bulgurový salát se zeleninou", name: "Sýrové tortelliny s pažitkovou omáčkou",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Zabijačkový guláš, karlovarský knedlík", name: "Teriyaki losos burger s frisée salátem a citrusovou majonézou, bramborové lupínky",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 3, 6, 7, 11]
}, },
{ {
amount: "-", amount: "-",
name: "Vepřový plátek na žampionech, jasmínová rýže", name: "Vepřové výpečky s červeným zelím, bramborové knedlíky se smaženou cibulkou",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
} }
] ]
], ],
@@ -515,7 +561,91 @@ const MOCK_DATA = {
isSoup: false, isSoup: false,
} }
], ],
] ],
'senkSerikova': [
[
{
amount: "-",
name: "Drůbeží vývar s masem a nudlemi",
price: "45\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Vepřová pečeně se zelím a houskovým knedlíkem",
price: "155\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Špagety s kuřecím masem, špenátem a smetanou",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Medailonky z vepřové panenky s fazolkami se slaninou, šťouchané brambory",
price: "185\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Mrkvová polévka se zázvorem a kokosovým mlékem",
price: "45\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Hovězí po Burgundsku, bramborová kaše",
price: "155\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Hovězí vývar s játrovými knedlíčky",
price: "45\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Kuřecí plátky na sušených rajčatech, bylinkách a česneku, bramborová kaše",
price: "155\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Kuřecí vývar s rýží",
price: "45\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Rajská s plněnou paprikou, knedlík",
price: "170\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Mexická fazolová polévka",
price: "45\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Ragú z trhané kachny, onsen vejce, soté ze špenátu a ředkvičky, bramborové pyré, lanýžová sůl, zelený olej",
price: "189\xA0Kč",
isSoup: false,
}
],
],
} }
// Mockovací data pro Pizza day // Mockovací data pro Pizza day
@@ -1272,7 +1402,7 @@ const MOCK_PIZZA_LIST = [
* Funkce vrací mock datu ve formátu YYYY-MM-DD * Funkce vrací mock datu ve formátu YYYY-MM-DD
*/ */
export const getTodayMock = (): Date => { export const getTodayMock = (): Date => {
return new Date('2025-01-08'); // pátek return new Date('2025-01-10'); // pátek
} }
export const getMenuSladovnickaMock = () => { export const getMenuSladovnickaMock = () => {
@@ -1291,6 +1421,35 @@ export const getMenuZastavkaUmichalaMock = () => {
return MOCK_DATA['zastavkaUmichala']; return MOCK_DATA['zastavkaUmichala'];
} }
export const getMenuSenkSerikovaMock = () => {
return MOCK_DATA['senkSerikova'];
}
export const getPizzaListMock = () => { export const getPizzaListMock = () => {
return MOCK_PIZZA_LIST; return MOCK_PIZZA_LIST;
}
export const getStatsMock = (): WeeklyStats => {
return [
{
date: '24.02.',
locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) }
},
{
date: '25.02.',
locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) }
},
{
date: '26.02.',
locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) }
},
{
date: '27.02.',
locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) }
},
{
date: '28.02.',
locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) }
}
];
} }

View File

@@ -1,60 +1,59 @@
/** Notifikace pro gotify*/ import axios from 'axios';
import { ClientData, GotifyServer, NotififaceInput, NotifikaceData } from '../../types';
import axios, { AxiosError } from 'axios';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import { getToday } from "./service"; import { getClientData, getToday } from "./service";
import { formatDate, getUsersByLocation } from "./utils"; import { getUsersByLocation, getHumanTime } from "./utils";
import { NotifikaceData, NotifikaceInput, NotificationSettings } from '../../types';
import getStorage from "./storage"; import getStorage from "./storage";
const storage = getStorage(); const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
const ENVIRONMENT = process.env.NODE_ENV || 'production'
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
const gotifyDataRaw = process.env.GOTIFY_SERVERS_AND_KEYS || "{}"; const storage = getStorage();
const gotifyData: GotifyServer[] = JSON.parse(gotifyDataRaw); const NOTIFICATION_SETTINGS_PREFIX = 'notif';
export const gotifyCall = async (data: NotififaceInput, gotifyServers?: GotifyServer[]): Promise<any[]> => {
if (!Array.isArray(gotifyServers)) { /** Vrátí klíč pro uložení notifikačních nastavení uživatele. */
return [] function getNotificationSettingsKey(login: string): string {
return `${NOTIFICATION_SETTINGS_PREFIX}_${login}`;
}
/** Vrátí nastavení notifikací pro daného uživatele. */
export async function getNotificationSettings(login: string): Promise<NotificationSettings> {
return await storage.getData<NotificationSettings>(getNotificationSettingsKey(login)) ?? {};
}
/** Uloží nastavení notifikací pro daného uživatele. */
export async function saveNotificationSettings(login: string, settings: NotificationSettings): Promise<NotificationSettings> {
await storage.setData(getNotificationSettingsKey(login), settings);
return settings;
}
/** Odešle ntfy notifikaci na dané téma. */
async function ntfyCallToTopic(topic: string, message: string) {
const url = process.env.NTFY_HOST;
const username = process.env.NTFY_USERNAME;
const password = process.env.NTFY_PASSWD;
if (!url || !username || !password) {
return;
} }
const urls = gotifyServers.flatMap(gotifyServer => const token = Buffer.from(`${username}:${password}`, 'utf8').toString('base64');
gotifyServer.api_keys.map(apiKey => `${gotifyServer.server}/message?token=${apiKey}`)); try {
const response = await axios({
const dataPayload = { url: `${url}/${topic}`,
title: "Luncher", method: 'POST',
message: `${data.udalost} - spustil:${data.user}`, data: message,
priority: 7, headers: {
}; 'Authorization': `Basic ${token}`,
'Tag': 'meat_on_bone'
const headers = { "Content-Type": "application/json" };
const promises = urls.map(url =>
axios.post(url, dataPayload, { headers }).then(response => {
response.data = {
success: true,
message: "Notifikace doručena",
};
return response;
}).catch(error => {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
if (axiosError.response) {
axiosError.response.data = {
success: false,
message: "fail",
};
console.log(error)
return axiosError.response;
}
} }
// Handle unknown error without a response });
console.log(error, "unknown error"); console.log(response.data);
}) } catch (error) {
); console.error(`Chyba při odesílání ntfy notifikace na topic ${topic}:`, error);
return promises; }
}; }
export const ntfyCall = async (data: NotififaceInput) => { export const ntfyCall = async (data: NotifikaceInput) => {
const url = process.env.NTFY_HOST const url = process.env.NTFY_HOST
const username = process.env.NTFY_USERNAME; const username = process.env.NTFY_USERNAME;
const password = process.env.NTFY_PASSWD; const password = process.env.NTFY_PASSWD;
@@ -70,8 +69,7 @@ export const ntfyCall = async (data: NotififaceInput) => {
console.log("NTFY_PASSWD není definován v env") console.log("NTFY_PASSWD není definován v env")
return return
} }
const today = formatDate(getToday()); let clientData = await getClientData(getToday());
let clientData: ClientData = await storage.getData(today);
const userByCLocation = getUsersByLocation(clientData.choices, data.user) const userByCLocation = getUsersByLocation(clientData.choices, data.user)
const token = Buffer.from(`${username}:${password}`, 'utf8').toString('base64'); const token = Buffer.from(`${username}:${password}`, 'utf8').toString('base64');
@@ -97,26 +95,125 @@ export const ntfyCall = async (data: NotififaceInput) => {
return promises; return promises;
} }
export const teamsCall = async (data: NotifikaceInput) => {
const url = process.env.TEAMS_WEBHOOK_URL;
const title = data.udalost;
let time = new Date();
time.setTime(time.getTime() + 1000 * 60);
const message = 'Odcházíme v ' + getHumanTime(time) + ', ' + data.user;
const card = {
'@type': 'MessageCard',
'@context': 'http://schema.org/extensions',
'themeColor': "0072C6", // light blue
summary: 'Summary description',
sections: [
{
activityTitle: title,
text: message,
},
],
};
if (!url) {
console.log("TEAMS_WEBHOOK_URL není definován v env")
return
}
try {
const response = await axios.post(url, card, {
headers: {
'content-type': 'application/vnd.microsoft.teams.card.o365connector'
},
});
return `${response.status} - ${response.statusText}`;
} catch (err) {
return err;
}
}
/** Odešle Teams notifikaci na daný webhook URL. */
async function teamsCallToUrl(webhookUrl: string, data: NotifikaceInput) {
const title = data.udalost;
let time = new Date();
time.setTime(time.getTime() + 1000 * 60);
const message = 'Odcházíme v ' + getHumanTime(time) + ', ' + data.user;
const card = {
'@type': 'MessageCard',
'@context': 'http://schema.org/extensions',
'themeColor': "0072C6",
summary: 'Summary description',
sections: [
{
activityTitle: title,
text: message,
},
],
};
try {
await axios.post(webhookUrl, card, {
headers: {
'content-type': 'application/vnd.microsoft.teams.card.o365connector'
},
});
} catch (error) {
console.error(`Chyba při odesílání Teams notifikace:`, error);
}
}
/** Odešle Discord notifikaci na daný webhook URL. */
async function discordCall(webhookUrl: string, data: NotifikaceInput) {
let time = new Date();
time.setTime(time.getTime() + 1000 * 60);
const message = `🍖 **${data.udalost}** — ${data.user} (odchod v ${getHumanTime(time)})`;
try {
await axios.post(webhookUrl, {
content: message,
}, {
headers: {
'Content-Type': 'application/json',
},
});
} catch (error) {
console.error(`Chyba při odesílání Discord notifikace:`, error);
}
}
/** Zavolá notifikace na všechny konfigurované způsoby notifikace, přetížení proměných na false pro jednotlivé způsoby je vypne*/ /** Zavolá notifikace na všechny konfigurované způsoby notifikace, přetížení proměných na false pro jednotlivé způsoby je vypne*/
export const callNotifikace = async ({ input, teams = true, gotify = false, ntfy = true }: NotifikaceData) => { export const callNotifikace = async ({ input, teams = true, gotify = false, ntfy = true }: NotifikaceData) => {
const notifications = []; const notifications: Promise<any>[] = [];
// Globální notifikace (zpětně kompatibilní)
if (ntfy) { if (ntfy) {
const ntfyPromises = await ntfyCall(input); const ntfyPromises = await ntfyCall(input);
if (ntfyPromises) { if (ntfyPromises) {
notifications.push(...ntfyPromises); notifications.push(...ntfyPromises);
} }
} }
/* Zatím není
if (teams) { if (teams) {
notifications.push(teamsCall(input)); const teamsPromises = await teamsCall(input);
}*/ if (teamsPromises) {
notifications.push(Promise.resolve(teamsPromises));
}
}
// Add more notifications as necessary // Per-user notifikace: najdeme uživatele se stejnou lokací a odešleme dle jejich nastavení
const clientData = await getClientData(getToday());
const usersToNotify = getUsersByLocation(clientData.choices, input.user);
for (const user of usersToNotify) {
if (user === input.user) continue; // Neposíláme notifikaci spouštějícímu uživateli
const userSettings = await getNotificationSettings(user);
if (!userSettings.enabledEvents?.includes(input.udalost)) continue;
//gotify bych řekl, že už je deprecated if (userSettings.ntfyTopic) {
if (gotify) { notifications.push(ntfyCallToTopic(userSettings.ntfyTopic, `${input.udalost} - spustil: ${input.user}`));
const gotifyPromises = await gotifyCall(input, gotifyData); }
notifications.push(...gotifyPromises); if (userSettings.discordWebhookUrl) {
notifications.push(discordCall(userSettings.discordWebhookUrl, input));
}
if (userSettings.teamsWebhookUrl) {
notifications.push(teamsCallToUrl(userSettings.teamsWebhookUrl, input));
}
} }
try { try {
@@ -124,6 +221,5 @@ export const callNotifikace = async ({ input, teams = true, gotify = false, ntfy
return results; return results;
} catch (error) { } catch (error) {
console.error("Error in callNotifikace: ", error); console.error("Error in callNotifikace: ", error);
// Handle the error as needed
} }
}; };

View File

@@ -1,12 +1,13 @@
import { formatDate } from "./utils"; import { formatDate } from "./utils";
import { callNotifikace } from "./notifikace"; import { callNotifikace } from "./notifikace";
import { generateQr } from "./qr"; import { generateQr } from "./qr";
import { ClientData, PizzaDayState, UdalostEnum, Pizza, PizzaSize, Order, PizzaOrder, DayData } from "../../types";
import getStorage from "./storage"; import getStorage from "./storage";
import { downloadPizzy } from "./chefie"; import { downloadPizzy } from "./chefie";
import { getToday, initIfNeeded } from "./service"; import { getClientData, getToday, initIfNeeded } from "./service";
import { Pizza, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum, PendingQr } from "../../types/gen/types.gen";
const storage = getStorage(); const storage = getStorage();
const PENDING_QR_PREFIX = 'pending_qr';
/** /**
* Vrátí seznam dostupných pizz pro dnešní den. * Vrátí seznam dostupných pizz pro dnešní den.
@@ -14,8 +15,7 @@ const storage = getStorage();
*/ */
export async function getPizzaList(): Promise<Pizza[] | undefined> { export async function getPizzaList(): Promise<Pizza[] | undefined> {
await initIfNeeded(); await initIfNeeded();
const today = formatDate(getToday()); let clientData = await getClientData(getToday());
let clientData: DayData = await storage.getData(today);
if (!clientData.pizzaList) { if (!clientData.pizzaList) {
const mock = process.env.MOCK_DATA === 'true'; const mock = process.env.MOCK_DATA === 'true';
clientData = await savePizzaList(await downloadPizzy(mock)); clientData = await savePizzaList(await downloadPizzy(mock));
@@ -31,9 +31,9 @@ export async function getPizzaList(): Promise<Pizza[] | undefined> {
export async function savePizzaList(pizzaList: Pizza[]): Promise<ClientData> { export async function savePizzaList(pizzaList: Pizza[]): Promise<ClientData> {
await initIfNeeded(); await initIfNeeded();
const today = formatDate(getToday()); const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today); const clientData = await getClientData(getToday());
clientData.pizzaList = pizzaList; clientData.pizzaList = pizzaList;
clientData.pizzaListLastUpdate = new Date(); clientData.pizzaListLastUpdate = formatDate(new Date());
await storage.setData(today, clientData); await storage.setData(today, clientData);
return clientData; return clientData;
} }
@@ -43,14 +43,14 @@ export async function savePizzaList(pizzaList: Pizza[]): Promise<ClientData> {
*/ */
export async function createPizzaDay(creator: string): Promise<ClientData> { export async function createPizzaDay(creator: string): Promise<ClientData> {
await initIfNeeded(); await initIfNeeded();
const today = formatDate(getToday()); const clientData = await getClientData(getToday());
const clientData: DayData = await storage.getData(today);
if (clientData.pizzaDay) { if (clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den již existuje"); throw Error("Pizza day pro dnešní den již existuje");
} }
// TODO berka rychlooprava, vyřešit lépe - stahovat jednou, na jediném místě! // TODO berka rychlooprava, vyřešit lépe - stahovat jednou, na jediném místě!
const pizzaList = await getPizzaList(); const pizzaList = await getPizzaList();
const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, ...clientData }; const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, ...clientData };
const today = formatDate(getToday());
await storage.setData(today, data); await storage.setData(today, data);
callNotifikace({ input: { udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator } }) callNotifikace({ input: { udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator } })
return data; return data;
@@ -60,8 +60,7 @@ export async function createPizzaDay(creator: string): Promise<ClientData> {
* Smaže pizza day pro aktuální den. * Smaže pizza day pro aktuální den.
*/ */
export async function deletePizzaDay(login: string): Promise<ClientData> { export async function deletePizzaDay(login: string): Promise<ClientData> {
const today = formatDate(getToday()); const clientData = await getClientData(getToday());
const clientData: DayData = await storage.getData(today);
if (!clientData.pizzaDay) { if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje"); throw Error("Pizza day pro dnešní den neexistuje");
} }
@@ -69,6 +68,7 @@ export async function deletePizzaDay(login: string): Promise<ClientData> {
throw Error("Login uživatele se neshoduje se zakladatelem Pizza Day"); throw Error("Login uživatele se neshoduje se zakladatelem Pizza Day");
} }
delete clientData.pizzaDay; delete clientData.pizzaDay;
const today = formatDate(getToday());
await storage.setData(today, clientData); await storage.setData(today, clientData);
return clientData; return clientData;
} }
@@ -82,60 +82,93 @@ export async function deletePizzaDay(login: string): Promise<ClientData> {
*/ */
export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize) { export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize) {
const today = formatDate(getToday()); const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today); const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) { if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje"); throw Error("Pizza day pro dnešní den neexistuje");
} }
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) { if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED); throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
} }
let order: Order | undefined = clientData.pizzaDay.orders.find(o => o.customer === login); let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
if (!order) { if (!order) {
order = { order = {
customer: login, customer: login,
pizzaList: [], pizzaList: [],
totalPrice: 0, totalPrice: 0,
hasQr: false,
} }
clientData.pizzaDay.orders ??= [];
clientData.pizzaDay.orders.push(order); clientData.pizzaDay.orders.push(order);
} }
const pizzaOrder: PizzaOrder = { const pizzaOrder: PizzaVariant = {
varId: size.varId, varId: size.varId,
name: pizza.name, name: pizza.name,
size: size.size, size: size.size,
price: size.price, price: size.price,
} }
order.pizzaList ??= [];
order.pizzaList.push(pizzaOrder); order.pizzaList.push(pizzaOrder);
order.totalPrice += pizzaOrder.price; order.totalPrice += pizzaOrder.price;
await storage.setData(today, clientData); await storage.setData(today, clientData);
return clientData; return clientData;
} }
/**
* Odstraní všechny pizzy uživatele (celou jeho objednávku).
* Pokud Pizza day neexistuje nebo není ve stavu CREATED, neudělá nic.
*
* @param login login uživatele
* @param date datum, pro které se objednávka odstraňuje (výchozí je dnešek)
* @returns aktuální data pro klienta
*/
export async function removeAllUserPizzas(login: string, date?: Date) {
const usedDate = date ?? getToday();
const today = formatDate(usedDate);
const clientData = await getClientData(usedDate);
if (!clientData.pizzaDay) {
return clientData; // Pizza day neexistuje, není co mazat
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
return clientData; // Pizza day není ve stavu CREATED, nelze mazat
}
const orderIndex = clientData.pizzaDay.orders!.findIndex(o => o.customer === login);
if (orderIndex >= 0) {
clientData.pizzaDay.orders!.splice(orderIndex, 1);
await storage.setData(today, clientData);
}
return clientData;
}
/** /**
* Odstraní danou objednávku pizzy. * Odstraní danou objednávku pizzy.
* *
* @param login login uživatele * @param login login uživatele
* @param pizzaOrder objednávka pizzy * @param pizzaOrder objednávka pizzy
*/ */
export async function removePizzaOrder(login: string, pizzaOrder: PizzaOrder) { export async function removePizzaOrder(login: string, pizzaOrder: PizzaVariant) {
const today = formatDate(getToday()); const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today); const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) { if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje"); throw Error("Pizza day pro dnešní den neexistuje");
} }
const orderIndex = clientData.pizzaDay.orders.findIndex(o => o.customer === login); const orderIndex = clientData.pizzaDay.orders!.findIndex(o => o.customer === login);
if (orderIndex < 0) { if (orderIndex < 0) {
throw Error("Nebyly nalezeny žádné objednávky pro uživatele " + login); throw Error("Nebyly nalezeny žádné objednávky pro uživatele " + login);
} }
const order = clientData.pizzaDay.orders[orderIndex]; const order = clientData.pizzaDay.orders![orderIndex];
const index = order.pizzaList.findIndex(o => o.name === pizzaOrder.name && o.size === pizzaOrder.size); const index = order.pizzaList!.findIndex(o => o.name === pizzaOrder.name && o.size === pizzaOrder.size);
if (index < 0) { if (index < 0) {
throw Error("Objednávka s danými parametry nebyla nalezena"); throw Error("Objednávka s danými parametry nebyla nalezena");
} }
const price = order.pizzaList[index].price; const price = order.pizzaList![index].price;
order.pizzaList.splice(index, 1); order.pizzaList!.splice(index, 1);
order.totalPrice -= price; order.totalPrice -= price;
if (order.pizzaList.length == 0) { if (order.pizzaList!.length == 0) {
clientData.pizzaDay.orders.splice(orderIndex, 1); clientData.pizzaDay.orders!.splice(orderIndex, 1);
} }
await storage.setData(today, clientData); await storage.setData(today, clientData);
return clientData; return clientData;
@@ -149,7 +182,7 @@ export async function removePizzaOrder(login: string, pizzaOrder: PizzaOrder) {
*/ */
export async function lockPizzaDay(login: string) { export async function lockPizzaDay(login: string) {
const today = formatDate(getToday()); const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today); const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) { if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje"); throw Error("Pizza day pro dnešní den neexistuje");
} }
@@ -172,7 +205,7 @@ export async function lockPizzaDay(login: string) {
*/ */
export async function unlockPizzaDay(login: string) { export async function unlockPizzaDay(login: string) {
const today = formatDate(getToday()); const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today); const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) { if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje"); throw Error("Pizza day pro dnešní den neexistuje");
} }
@@ -195,7 +228,7 @@ export async function unlockPizzaDay(login: string) {
*/ */
export async function finishPizzaOrder(login: string) { export async function finishPizzaOrder(login: string) {
const today = formatDate(getToday()); const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today); const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) { if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje"); throw Error("Pizza day pro dnešní den neexistuje");
} }
@@ -220,7 +253,7 @@ export async function finishPizzaOrder(login: string) {
*/ */
export async function finishPizzaDelivery(login: string, bankAccount?: string, bankAccountHolder?: string) { export async function finishPizzaDelivery(login: string, bankAccount?: string, bankAccountHolder?: string) {
const today = formatDate(getToday()); const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today); const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) { if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje"); throw Error("Pizza day pro dnešní den neexistuje");
} }
@@ -234,11 +267,18 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
// Vygenerujeme QR kód, pokud k tomu máme data // Vygenerujeme QR kód, pokud k tomu máme data
if (bankAccount?.length && bankAccountHolder?.length) { if (bankAccount?.length && bankAccountHolder?.length) {
for (const order of clientData.pizzaDay.orders) { for (const order of clientData.pizzaDay.orders!) {
if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl
let message = order.pizzaList.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', '); let message = order.pizzaList!.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', ');
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message); await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message);
order.hasQr = true; order.hasQr = true;
// Uložíme nevyřízený QR kód pro persistentní zobrazení
await addPendingQr(order.customer, {
date: today,
creator: login,
totalPrice: order.totalPrice,
purpose: message,
});
} }
} }
} }
@@ -255,15 +295,15 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
*/ */
export async function updatePizzaDayNote(login: string, note?: string) { export async function updatePizzaDayNote(login: string, note?: string) {
const today = formatDate(getToday()); const today = formatDate(getToday());
let clientData: DayData = await storage.getData(today); let clientData = await getClientData(getToday());
if (!clientData.pizzaDay) { if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje"); throw Error("Pizza day pro dnešní den neexistuje");
} }
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) { if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED); throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
} }
const myOrder = clientData.pizzaDay.orders.find(o => o.customer === login); const myOrder = clientData.pizzaDay.orders!.find(o => o.customer === login);
if (!myOrder || !myOrder.pizzaList.length) { if (!myOrder?.pizzaList?.length) {
throw Error("Pizza day neobsahuje žádné objednávky uživatele " + login); throw Error("Pizza day neobsahuje žádné objednávky uživatele " + login);
} }
myOrder.note = note; myOrder.note = note;
@@ -282,7 +322,7 @@ export async function updatePizzaDayNote(login: string, note?: string) {
*/ */
export async function updatePizzaFee(login: string, targetLogin: string, text?: string, price?: number) { export async function updatePizzaFee(login: string, targetLogin: string, text?: string, price?: number) {
const today = formatDate(getToday()); const today = formatDate(getToday());
let clientData: DayData = await storage.getData(today); let clientData = await getClientData(getToday());
if (!clientData.pizzaDay) { if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje"); throw Error("Pizza day pro dnešní den neexistuje");
} }
@@ -292,8 +332,8 @@ export async function updatePizzaFee(login: string, targetLogin: string, text?:
if (clientData.pizzaDay.creator !== login) { if (clientData.pizzaDay.creator !== login) {
throw Error("Příplatky může měnit pouze zakladatel Pizza day"); throw Error("Příplatky může měnit pouze zakladatel Pizza day");
} }
const targetOrder = clientData.pizzaDay.orders.find(o => o.customer === targetLogin); const targetOrder = clientData.pizzaDay.orders!.find(o => o.customer === targetLogin);
if (!targetOrder || !targetOrder.pizzaList.length) { if (!targetOrder?.pizzaList?.length) {
throw Error(`Pizza day neobsahuje žádné objednávky uživatele ${targetLogin}`); throw Error(`Pizza day neobsahuje žádné objednávky uživatele ${targetLogin}`);
} }
if (!price) { if (!price) {
@@ -302,7 +342,44 @@ export async function updatePizzaFee(login: string, targetLogin: string, text?:
targetOrder.fee = { text, price }; targetOrder.fee = { text, price };
} }
// Přepočet ceny // Přepočet ceny
targetOrder.totalPrice = targetOrder.pizzaList.reduce((price, pizzaOrder) => price + pizzaOrder.price, 0) + (targetOrder.fee?.price || 0); targetOrder.totalPrice = targetOrder.pizzaList.reduce((price, pizzaOrder) => price + pizzaOrder.price, 0) + (targetOrder.fee?.price ?? 0);
await storage.setData(today, clientData); await storage.setData(today, clientData);
return clientData; return clientData;
}
/**
* Vrátí klíč pro uložení nevyřízených QR kódů uživatele.
*/
function getPendingQrKey(login: string): string {
return `${PENDING_QR_PREFIX}_${login}`;
}
/**
* Přidá nevyřízený QR kód pro uživatele.
*/
export async function addPendingQr(login: string, pendingQr: PendingQr): Promise<void> {
const key = getPendingQrKey(login);
const existing = await storage.getData<PendingQr[]>(key) ?? [];
// Nepřidáváme duplicity pro stejný den
if (!existing.some(qr => qr.date === pendingQr.date)) {
existing.push(pendingQr);
await storage.setData(key, existing);
}
}
/**
* Vrátí nevyřízené QR kódy pro uživatele.
*/
export async function getPendingQrs(login: string): Promise<PendingQr[]> {
return await storage.getData<PendingQr[]>(getPendingQrKey(login)) ?? [];
}
/**
* Označí QR kód pro daný den jako uhrazený (odstraní ho ze seznamu nevyřízených).
*/
export async function dismissPendingQr(login: string, date: string): Promise<void> {
const key = getPendingQrKey(login);
const existing = await storage.getData<PendingQr[]>(key) ?? [];
const filtered = existing.filter(qr => qr.date !== date);
await storage.setData(key, filtered);
} }

163
server/src/pushReminder.ts Normal file
View File

@@ -0,0 +1,163 @@
import webpush from 'web-push';
import getStorage from './storage';
import { getClientData, getToday } from './service';
import { getIsWeekend } from './utils';
import { LunchChoices } from '../../types';
const storage = getStorage();
const REGISTRY_KEY = 'push_reminder_registry';
interface RegistryEntry {
time: string;
subscription: webpush.PushSubscription;
}
type Registry = Record<string, RegistryEntry>;
/** Mapa login → datum (YYYY-MM-DD), kdy byl uživatel naposledy upozorněn. */
const remindedToday = new Map<string, string>();
function getTodayDateString(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
function getCurrentTimeHHMM(): string {
const now = new Date();
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
}
/** Zjistí, zda má uživatel zvolenou nějakou možnost stravování. */
function userHasChoice(choices: LunchChoices, login: string): boolean {
for (const locationKey of Object.keys(choices)) {
const locationChoices = choices[locationKey as keyof LunchChoices];
if (locationChoices && login in locationChoices) {
return true;
}
}
return false;
}
async function getRegistry(): Promise<Registry> {
return await storage.getData<Registry>(REGISTRY_KEY) ?? {};
}
async function saveRegistry(registry: Registry): Promise<void> {
await storage.setData(REGISTRY_KEY, registry);
}
/** Přidá nebo aktualizuje push subscription pro uživatele. */
export async function subscribePush(login: string, subscription: webpush.PushSubscription, reminderTime: string): Promise<void> {
const registry = await getRegistry();
registry[login] = { time: reminderTime, subscription };
await saveRegistry(registry);
console.log(`Push reminder: uživatel ${login} přihlášen k připomínkám v ${reminderTime}`);
}
/** Odebere push subscription pro uživatele. */
export async function unsubscribePush(login: string): Promise<void> {
const registry = await getRegistry();
delete registry[login];
await saveRegistry(registry);
remindedToday.delete(login);
console.log(`Push reminder: uživatel ${login} odhlášen z připomínek`);
}
/** Vrátí veřejný VAPID klíč. */
export function getVapidPublicKey(): string | undefined {
return process.env.VAPID_PUBLIC_KEY;
}
/** Najde login uživatele podle push subscription endpointu. */
export async function findLoginByEndpoint(endpoint: string): Promise<string | undefined> {
const registry = await getRegistry();
for (const [login, entry] of Object.entries(registry)) {
if (entry.subscription.endpoint === endpoint) {
return login;
}
}
return undefined;
}
/** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */
async function checkAndSendReminders(): Promise<void> {
// Přeskočit víkendy
if (getIsWeekend(getToday())) {
return;
}
const registry = await getRegistry();
const entries = Object.entries(registry);
if (entries.length === 0) {
return;
}
const currentTime = getCurrentTimeHHMM();
const todayStr = getTodayDateString();
// Získáme data pro dnešek jednou pro všechny uživatele
let clientData;
try {
clientData = await getClientData(getToday());
} catch (e) {
console.error('Push reminder: chyba při získávání dat', e);
return;
}
for (const [login, entry] of entries) {
// Ještě nedosáhl čas připomínky
if (currentTime < entry.time) {
continue;
}
// Už jsme dnes připomenuli
if (remindedToday.get(login) === todayStr) {
continue;
}
// Uživatel už má zvolenou možnost
if (clientData.choices && userHasChoice(clientData.choices, login)) {
continue;
}
// Odešleme push notifikaci
try {
await webpush.sendNotification(
entry.subscription,
JSON.stringify({
title: 'Luncher',
body: 'Ještě nemáte zvolený oběd!',
})
);
remindedToday.set(login, todayStr);
console.log(`Push reminder: odeslána připomínka uživateli ${login}`);
} catch (error: any) {
if (error.statusCode === 410 || error.statusCode === 404) {
// Subscription expirovala nebo je neplatná — odebereme z registry
console.log(`Push reminder: subscription uživatele ${login} expirovala, odebírám`);
delete registry[login];
await saveRegistry(registry);
} else {
console.error(`Push reminder: chyba při odesílání notifikace uživateli ${login}:`, error);
}
}
}
}
/** Spustí scheduler pro kontrolu a odesílání připomínek každou minutu. */
export function startReminderScheduler(): void {
const publicKey = process.env.VAPID_PUBLIC_KEY;
const privateKey = process.env.VAPID_PRIVATE_KEY;
const subject = process.env.VAPID_SUBJECT;
if (!publicKey || !privateKey || !subject) {
console.log('Push reminder: VAPID klíče nejsou nastaveny, scheduler nebude spuštěn');
return;
}
webpush.setVapidDetails(subject, publicKey, privateKey);
// Spustíme kontrolu každou minutu
setInterval(checkAndSendReminders, 60_000);
console.log('Push reminder: scheduler spuštěn');
}

View File

@@ -1,7 +1,12 @@
import axios from "axios"; import axios from "axios";
import { load } from 'cheerio'; import { load } from 'cheerio';
import { DayOfWeek, DayOfWeekEnum, DayOfWeekIndex, Food, RestaurantWeeklyMenu } from "../../types"; import { getMenuSladovnickaMock, getMenuTechTowerMock, getMenuUMotlikuMock, getMenuZastavkaUmichalaMock, getMenuSenkSerikovaMock } from "./mock";
import { getMenuSladovnickaMock, getMenuTechTowerMock, getMenuUMotlikuMock, getMenuZastavkaUmichalaMock } from "./mock"; import { formatDate } from "./utils";
import { Food } from "../../types/gen/types.gen";
export class StaleWeekError extends Error {
constructor(public food: Food[][]) { super('Data jsou z jiného týdne'); }
}
// Fráze v názvech jídel, které naznačují že se jedná o polévku // Fráze v názvech jídel, které naznačují že se jedná o polévku
const SOUP_NAMES = [ const SOUP_NAMES = [
@@ -22,7 +27,7 @@ const SOUP_NAMES = [
const DAYS_IN_WEEK = ['pondělí', 'úterý', 'středa', 'čtvrtek', 'pátek', 'sobota', 'neděle']; const DAYS_IN_WEEK = ['pondělí', 'úterý', 'středa', 'čtvrtek', 'pátek', 'sobota', 'neděle'];
// URL na týdenní menu jednotlivých restaurací // URL na týdenní menu jednotlivých restaurací
const SLADOVNICKA_URL = 'https://sladovnicka.unasplzenchutna.cz/cz/denni-nabidka'; const SLADOVNICKA_URL = 'https://sladovnicka.unasplzenchutna.cz/cz/#denni-nabidka';
const U_MOTLIKU_URL = 'https://www.umotliku.cz/menu'; const U_MOTLIKU_URL = 'https://www.umotliku.cz/menu';
const TECHTOWER_URL = 'https://www.equifarm.cz/restaurace-techtower'; const TECHTOWER_URL = 'https://www.equifarm.cz/restaurace-techtower';
const ZASTAVKAUMICHALA_URL = 'https://www.zastavkaumichala.cz'; const ZASTAVKAUMICHALA_URL = 'https://www.zastavkaumichala.cz';
@@ -52,6 +57,28 @@ const sanitizeText = (text: string): string => {
return text.replace('\t', '').replace(' , ', ', ').trim(); return text.replace('\t', '').replace(' , ', ', ').trim();
} }
/**
* Parsuje čísla alergenů z názvu jídla a vrací vyčištěný název spolu s polem alergenů.
* Alergeny jsou očekávány na konci názvu ve formátu číslic oddělených čárkami.
*
* @param name původní název jídla
* @returns objekt obsahující vyčištěný název a pole alergenů
*/
const parseAllergens = (name: string): { cleanName: string, allergens: number[] } => {
// Regex pro nalezení čísel na konci řetězce oddělených čárkami a případnými mezerami
const regex = /\s+(\d+(?:\s*,\s*\d+)*)\s*$/;
const match = regex.exec(name);
if (match) {
const allergenString = match[1];
const allergens = allergenString.split(',').map(num => Number.parseInt(num.trim(), 10)).filter(num => !Number.isNaN(num));
const cleanName = name.replace(regex, '').trim();
return { cleanName, allergens };
}
return { cleanName: name, allergens: [] };
}
/** /**
* Stáhne a vrátí aktuální HTML z dané URL. * Stáhne a vrátí aktuální HTML z dané URL.
* *
@@ -69,7 +96,7 @@ const getHtml = async (url: string): Promise<any> => {
* @param mock zda vrátit mock data * @param mock zda vrátit mock data
* @returns seznam jídel pro daný týden * @returns seznam jídel pro daný týden
*/ */
export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = false): Promise<RestaurantWeeklyMenu> => { export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) { if (mock) {
return getMenuSladovnickaMock(); return getMenuSladovnickaMock();
} }
@@ -77,86 +104,70 @@ export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = f
const html = await getHtml(SLADOVNICKA_URL); const html = await getHtml(SLADOVNICKA_URL);
const $ = load(html); const $ = load(html);
const list = $('ul.tab-links').children(); // Zjistíme, které dny jsou k dispozici z tab elementů
const result: RestaurantWeeklyMenu = {}; const tabElements = $('#daily-menu-tab-list').children('button[id^="daily-menu-tab-"]');
// TODO upravit až bude enum const availableDays: { [dayIndex: number]: number } = {}; // mapování dayIndex -> contentIndex
tabElements.each((contentIndex, tabElement) => {
const dayText = $(tabElement).find('.daily-menu-tab__day').text().toLowerCase();
const dayIndex = DAYS_IN_WEEK.indexOf(dayText);
if (dayIndex !== -1 && dayIndex < 5) { // pouze pracovní dny (0-4)
availableDays[dayIndex] = contentIndex;
}
});
const menuContentElements = $('#daily-menu-content-list').children('.daily-menu-content__content').not('.daily-menu-content__content--static');
const result: Food[][] = [];
// Inicializujeme všechny pracovní dny (0-4) prázdnými poli
for (let dayIndex = 0; dayIndex < 5; dayIndex++) { for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
const currentDate = new Date(firstDayOfWeek); result[dayIndex] = [];
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
const searchedDayText = `${currentDate.getDate()}.${currentDate.getMonth() + 1}.${capitalize(DAYS_IN_WEEK[dayIndex])}`;
// Najdeme index pro vstupní datum (např. při svátcích bude posunutý)
// TODO validovat, že vstupní datum je v aktuálním týdnu
// TODO tenhle způsob je zbytečně komplikovaný - stačilo by hledat rovnou v div.tab-content, protože každý den tam má datum taky (akorát je print-only)
let index = undefined;
list.each((i, dayRow) => {
const rowText = $(dayRow).first().text().trim();
if (rowText === searchedDayText) {
index = i;
return;
}
})
if (index === undefined) {
// Pravděpodobně svátek, nebo je zavřeno
const index: number = Object.keys(DayOfWeekEnum).indexOf('Casual'); // 1
result[dayIndex as DayOfWeekEnum] = [{
amount: undefined,
name: "Pro daný den nebyla nalezena denní nabídka",
price: "",
isSoup: false,
}];
continue;
}
// Dle dohledaného indexu najdeme správný tabpanel
const rows = $('div.tab-content').children();
if (index >= rows.length) {
throw Error("V HTML nebyl nalezen řádek menu pro index " + index);
}
const tabPanel = $(rows.get(index));
// Opětovná validace, že daný tabpanel je pro vstupní datum
const headers = tabPanel.find('h2');
if (headers.length !== 3) {
throw Error("Neočekávaný počet elementů h2 v menu pro datum " + searchedDayText + ", očekávány 3, ale nalezeno bylo " + headers.length);
}
const dayText = $(headers.get(0)).text().trim();
if (dayText !== searchedDayText) {
throw Error("Neočekávaný datum na řádce nalezeného dne: '" + dayText + "', ale očekáváno bylo '" + searchedDayText + "'");
}
// V tabpanelu očekáváme dvě tabulky - pro polévku a pro hlavní jídlo
const tables = tabPanel.find('table');
if (tables.length !== 2) {
throw Error("Neočekávaný počet tabulek na řádce nalezeného dne: " + tables.length + ", ale očekávány byly 2");
}
const currentDayFood: Food[] = [];
// Polévka - div -> table -> tbody -> tr -> 3x td
const soupCells = $(tables.get(0)).children().first().children().first().children();
if (soupCells.length !== 3) {
throw Error("Neočekávaný počet buněk v tabulce polévky: " + soupCells.length + ", ale očekávány byly 3");
}
currentDayFood.push({
amount: sanitizeText($(soupCells.get(0)).text()),
name: sanitizeText($(soupCells.get(1)).text()),
price: sanitizeText($(soupCells.get(2)).text().replace(' ', '\xA0')),
isSoup: true,
});
// Hlavní jídla - div -> table -> tbody -> 3x tr
const mainCourseRows = $(tables.get(1)).children().first().children();
mainCourseRows.each((i, foodRow) => {
const foodCells = $(foodRow).children();
if (foodCells.length !== 3) {
throw Error("Neočekávaný počet buněk v řádku jídla: " + foodCells.length + ", ale očekávány byly 3");
}
currentDayFood.push({
amount: sanitizeText($(foodCells.get(0)).text()),
name: sanitizeText($(foodCells.get(1)).text()),
price: sanitizeText($(foodCells.get(2)).text().replace(' ', '\xA0')),
isSoup: false,
});
})
result[dayIndex] = currentDayFood;
} }
// Projdeme pouze dostupné dny
for (const [dayIndex, contentIndex] of Object.entries(availableDays)) {
const dayIndexNum = Number.parseInt(dayIndex);
const contentIndexNum = contentIndex;
if (contentIndexNum >= menuContentElements.length) {
continue; // Přeskočíme, pokud content element neexistuje
}
const contentElement = $(menuContentElements[contentIndexNum]);
const itemElement = contentElement.find('.daily-menu-content__item');
const table = itemElement.find('table.daily-menu-content__table tbody');
const rows = table.children('tr');
const currentDayFood: Food[] = [];
// Projdeme všechny řádky - první je polévka, zbytek jsou hlavní jídla
rows.each((i, row) => {
const cells = $(row).children('td');
if (cells.length !== 3) {
return; // Přeskočíme řádky s nesprávnou strukturou
}
const amount = sanitizeText($(cells.get(0)).text());
const nameRaw = sanitizeText($(cells.get(1)).text());
const price = sanitizeText($(cells.get(2)).text().replace(' ', '\xA0'));
const parsed = parseAllergens(nameRaw);
// Přeskočíme prázdné řádky
if (parsed.cleanName.trim().length > 0) {
currentDayFood.push({
amount,
name: parsed.cleanName,
price,
isSoup: i === 0, // První řádek je polévka
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
});
}
});
result[dayIndexNum] = currentDayFood;
}
return result; return result;
} }
@@ -178,7 +189,7 @@ export const getMenuUMotliku = async (firstDayOfWeek: Date, mock: boolean = fals
// Najdeme první tabulku, nad kterou je v H3 datum začínající co nejdřívějším dnem v aktuálním týdnu // Najdeme první tabulku, nad kterou je v H3 datum začínající co nejdřívějším dnem v aktuálním týdnu
const tables = $('table.table.table-hover.Xtable-striped'); const tables = $('table.table.table-hover.Xtable-striped');
let usedTable; let usedTable;
let usedDate = new Date(firstDayOfWeek.getTime()); let usedDate = new Date(firstDayOfWeek);
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
const dayOfWeekString = `${usedDate.getDate()}.${usedDate.getMonth() + 1}.`; const dayOfWeekString = `${usedDate.getDate()}.${usedDate.getMonth() + 1}.`;
for (const tableNode of tables) { for (const tableNode of tables) {
@@ -198,7 +209,7 @@ export const getMenuUMotliku = async (firstDayOfWeek: Date, mock: boolean = fals
if (usedTable == null) { if (usedTable == null) {
const firstDayOfWeekString = `${firstDayOfWeek.getDate()}.${firstDayOfWeek.getMonth() + 1}.`; const firstDayOfWeekString = `${firstDayOfWeek.getDate()}.${firstDayOfWeek.getMonth() + 1}.`;
throw Error(`Nepodařilo se najít tabulku pro týden začínající ${firstDayOfWeekString}`); throw new Error(`Nepodařilo se najít tabulku pro týden začínající ${firstDayOfWeekString}`);
} }
const body = usedTable.children().first(); const body = usedTable.children().first();
@@ -231,11 +242,11 @@ export const getMenuUMotliku = async (firstDayOfWeek: Date, mock: boolean = fals
} else if (foodType === 'Hlavní jídlo') { } else if (foodType === 'Hlavní jídlo') {
isSoup = false; isSoup = false;
} else { } else {
throw Error("Neočekáváný typ jídla: " + foodType); throw new Error("Neočekáváný typ jídla: " + foodType);
} }
} else { } else {
if (children.length !== 3) { if (children.length !== 3) {
throw Error("Neočekávaný počet child elementů pro jídlo: " + children.length + ", očekávány 3"); throw new Error("Neočekávaný počet child elementů pro jídlo: " + children.length + ", očekávány 3");
} }
const amount = sanitizeText($(children.get(0)).text()); const amount = sanitizeText($(children.get(0)).text());
const name = sanitizeText($(children.get(1)).text()); const name = sanitizeText($(children.get(1)).text());
@@ -288,12 +299,11 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
}) })
} }
if (!font) { if (!font) {
throw Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.'); throw new Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.');
} }
const result: Food[][] = []; const result: Food[][] = [];
// TODO validovat, že v textu nalezeného <font> je rozsah, do kterého spadá vstupní datum const siblings = secondTry ? $(font).parent().parent().parent().siblings('p') : $(font).parent().parent().siblings();
const siblings = secondTry ? $(font).parent().siblings() : $(font).parent().parent().siblings();
let parsing = false; let parsing = false;
let currentDayIndex = 0; let currentDayIndex = 0;
for (let i = 0; i < siblings.length; i++) { for (let i = 0; i < siblings.length; i++) {
@@ -311,24 +321,45 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
continue; continue;
} }
let price = 'na\xA0váhu'; let price = 'na\xA0váhu';
let name = text.replace('•', ''); let nameRaw = text.replace('•', '');
if (text.toLowerCase().endsWith('kč')) { if (text.toLowerCase().endsWith('kč')) {
const tmp = text.replace('\xA0', ' ').split(' '); const tmp = text.replace('\xA0', ' ').split(' ');
const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2)); const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2));
price = `${split.slice(1)[0]}\xA0Kč` price = `${split.slice(1)[0]}\xA0Kč`
name = split[0].replace('•', ''); nameRaw = split[0].replace('•', '');
} else if (text.toLowerCase().endsWith(',-')) {
const tmp = text.replace('\xA0', ' ').split(' ');
const split = [tmp.slice(0, -1).join(' ')].concat(tmp.slice(-1));
price = `${split.slice(1)[0].replace(',-', '')}\xA0Kč`
nameRaw = split[0].replace('•', '');
} }
if (result[currentDayIndex] == null) { if (nameRaw.endsWith('')|| nameRaw.endsWith('—')) {
result[currentDayIndex] = []; nameRaw = nameRaw.slice(0, -1).trim();
} }
const parsed = parseAllergens(nameRaw);
result[currentDayIndex] ??= [];
result[currentDayIndex].push({ result[currentDayIndex].push({
amount: '-', amount: '-',
name, name: parsed.cleanName,
price, price,
isSoup: isTextSoupName(name), isSoup: isTextSoupName(parsed.cleanName),
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
}) })
} }
} }
// Validace, zda text hlavičky obsahuje datum odpovídající požadovanému týdnu
const headerText = $(font).text().trim();
const dateMatch = headerText.match(/(\d{1,2})\.(\d{1,2})\./);
if (dateMatch) {
const foundDay = parseInt(dateMatch[1]);
const foundMonth = parseInt(dateMatch[2]) - 1; // JS months are 0-based
if (foundDay !== firstDayOfWeek.getDate() || foundMonth !== firstDayOfWeek.getMonth()) {
throw new StaleWeekError(result);
}
}
return result; return result;
} }
@@ -344,29 +375,33 @@ export const getMenuZastavkaUmichala = async (firstDayOfWeek: Date, mock: boolea
return getMenuZastavkaUmichalaMock(); return getMenuZastavkaUmichalaMock();
} }
const nowDate = new Date().getDate(); const today = new Date();
today.setHours(0,0,0,0);
const headers = {
"Cookie": "_nss=1; PHPSESSID=9e37de17e0326b0942613d6e67a30e69",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36",
};
const result: Food[][] = []; const result: Food[][] = [];
for (let dayIndex = 0; dayIndex < 5; dayIndex++) { for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
const currentDate = new Date(firstDayOfWeek); const currentDate = new Date(firstDayOfWeek);
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex); currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
currentDate.setHours(0,0,0,0);
// if (currentDate < now) { if (currentDate < today || (currentDate.getTime() === today.getTime() && new Date().getHours() >= 14)) {
if (currentDate.getDate() !== nowDate) {
result[dayIndex] = [{ result[dayIndex] = [{
amount: undefined, amount: undefined,
name: "Pro tento den není uveřejněna nabídka jídel", name: "Pro tento den není uveřejněna nabídka jídel",
price: "", price: "",
isSoup: false, isSoup: false,
}]; }];
continue;
} else { } else {
// let dateString = formatDate(currentDate, 'DD.MM.YYYY'); const url = (currentDate.getTime() === today.getTime())
// const html = await getHtml(ZASTAVKAUMICHALA_URL + '/?do=dailyMenu-changeDate&dailyMenu-dateString=' + dateString); ? ZASTAVKAUMICHALA_URL
const html = await getHtml(ZASTAVKAUMICHALA_URL); : ZASTAVKAUMICHALA_URL + '/?do=dailyMenu-changeDate&dailyMenu-dateString=' + formatDate(currentDate, 'DD.MM.YYYY');
const html = await axios.get(url, {
headers,
}).then(res => res.data).then(content => content);
const $ = load(html); const $ = load(html);
// const row = $($('.foodsList li')[0]).text();
const currentDayFood: Food[] = []; const currentDayFood: Food[] = [];
$('.foodsList li').each((index, element) => { $('.foodsList li').each((index, element) => {
currentDayFood.push({ currentDayFood.push({
@@ -381,3 +416,56 @@ export const getMenuZastavkaUmichala = async (firstDayOfWeek: Date, mock: boolea
} }
return result; return result;
} }
/**
* Získá obědovou nabídku SenkSerikova pro jeden týden.
*
* @param firstDayOfWeek první den v týdnu, pro který získat menu
* @param mock zda vrátit mock data
* @returns seznam jídel pro dané datum
*/
export const getMenuSenkSerikova = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) {
return getMenuSenkSerikovaMock();
}
const decoder = new TextDecoder('windows-1250');
const html = await axios.get(SENKSERIKOVA_URL, {
responseType: 'arraybuffer',
responseEncoding: 'binary'
}).then(res => decoder.decode(new Uint8Array(res.data))).then(content => content);
const $ = load(html);
const today = new Date();
today.setHours(0,0,0,0);
const currentDate = new Date(firstDayOfWeek);
const result: Food[][] = [];
let dayIndex = 0;
currentDate.setHours(0,0,0,0);
while (currentDate < today) {
result[dayIndex] = [{
amount: undefined,
name: "Pro tento den není uveřejněna nabídka jídel",
price: "",
isSoup: false,
}];
dayIndex = dayIndex + 1;
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
}
$('.menicka').each((i, element) => {
const currentDayFood: Food[] = [];
$(element).find('.popup-gallery li').each((j, element) => {
const rawName = $(element).children('div.polozka').text();
const nameWithoutNumber = rawName.replace(/^\d+\.\s*/, '');
currentDayFood.push({
amount: '-',
name: nameWithoutNumber,
price: $(element).children('div.cena').text().replaceAll(' ', '\xA0'),
isSoup: $(element).hasClass('polevka'),
});
});
result[dayIndex++] = currentDayFood;
});
return result;
}

View File

@@ -0,0 +1,197 @@
import express, { Request } from "express";
import { getDateForWeekIndex, getData, getRestaurantMenu, getToday, initIfNeeded } from "../service";
import { formatDate, getDayOfWeekIndex } from "../utils";
import getStorage from "../storage";
import { getWebsocket } from "../websocket";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import webpush from 'web-push';
const router = express.Router();
const storage = getStorage();
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
// Seznam náhodných jmen pro generování mock dat
const MOCK_NAMES = [
'Alice', 'Bob', 'Charlie', 'David', 'Eva', 'Filip', 'Gita', 'Honza',
'Ivana', 'Jakub', 'Kamila', 'Lukáš', 'Markéta', 'Nikola', 'Ondřej',
'Petra', 'Quido', 'Radek', 'Simona', 'Tomáš', 'Ursula', 'Viktor',
'Wanda', 'Xaver', 'Yvona', 'Zdeněk', 'Aneta', 'Boris', 'Cecílie', 'Daniel'
];
// Volby stravování pro mock data
const LUNCH_CHOICES = [
'SLADOVNICKA',
'TECHTOWER',
'ZASTAVKAUMICHALA',
'SENKSERIKOVA',
'OBJEDNAVAM',
'NEOBEDVAM',
'ROZHODUJI',
];
// Restaurace s menu
const RESTAURANTS_WITH_MENU = [
'SLADOVNICKA',
'TECHTOWER',
'ZASTAVKAUMICHALA',
'SENKSERIKOVA',
];
/**
* Middleware pro kontrolu DEV režimu
*/
function requireDevMode(req: any, res: any, next: any) {
if (ENVIRONMENT !== 'development') {
return res.status(403).json({ error: 'Tento endpoint je dostupný pouze ve vývojovém režimu' });
}
next();
}
router.use(requireDevMode);
/**
* Vygeneruje mock data pro testování.
*/
router.post("/generate", async (req: Request<{}, any, any>, res, next) => {
try {
const dayIndex = req.body?.dayIndex ?? getDayOfWeekIndex(getToday());
const count = req.body?.count ?? Math.floor(Math.random() * 16) + 5; // 5-20
if (dayIndex < 0 || dayIndex > 4) {
return res.status(400).json({ error: 'Neplatný index dne (0-4)' });
}
const date = getDateForWeekIndex(dayIndex);
await initIfNeeded(date);
const dateKey = formatDate(date);
const data = await storage.getData<any>(dateKey);
// Získání menu restaurací pro vybraný den
const menus: { [key: string]: any } = {};
for (const restaurant of RESTAURANTS_WITH_MENU) {
const menu = await getRestaurantMenu(restaurant as any, date);
if (menu?.food?.length) {
menus[restaurant] = menu.food;
}
}
// Vygenerování náhodných uživatelů
const usedNames = new Set<string>();
for (let i = 0; i < count && usedNames.size < MOCK_NAMES.length; i++) {
// Vybereme náhodné jméno, které ještě nebylo použito
let name: string;
do {
name = MOCK_NAMES[Math.floor(Math.random() * MOCK_NAMES.length)];
} while (usedNames.has(name));
usedNames.add(name);
// Vybereme náhodnou volbu stravování
const choice = LUNCH_CHOICES[Math.floor(Math.random() * LUNCH_CHOICES.length)];
// Inicializace struktury pro volbu
data.choices[choice] ??= {};
const userChoice: any = {
trusted: false,
selectedFoods: [],
};
// Pokud má restaurace menu, vybereme náhodné jídlo
if (RESTAURANTS_WITH_MENU.includes(choice) && menus[choice]?.length) {
const foods = menus[choice];
// Vybereme náhodné jídlo (ne polévku)
const mainFoods = foods.filter((f: any) => !f.isSoup);
if (mainFoods.length > 0) {
const randomFoodIndex = foods.indexOf(mainFoods[Math.floor(Math.random() * mainFoods.length)]);
userChoice.selectedFoods = [randomFoodIndex];
}
}
data.choices[choice][name] = userChoice;
}
await storage.setData(dateKey, data);
// Odeslat aktualizovaná data přes WebSocket
const clientData = await getData(date);
getWebsocket().emit("message", clientData);
res.status(200).json({ success: true, count: usedNames.size, dayIndex });
} catch (e: any) {
next(e);
}
});
/**
* Smaže všechny volby pro daný den.
*/
router.post("/clear", async (req: Request<{}, any, any>, res, next) => {
try {
const dayIndex = req.body?.dayIndex ?? getDayOfWeekIndex(getToday());
if (dayIndex < 0 || dayIndex > 4) {
return res.status(400).json({ error: 'Neplatný index dne (0-4)' });
}
const date = getDateForWeekIndex(dayIndex);
await initIfNeeded(date);
const dateKey = formatDate(date);
const data = await storage.getData<any>(dateKey);
// Vymažeme všechny volby
data.choices = {};
await storage.setData(dateKey, data);
// Odeslat aktualizovaná data přes WebSocket
const clientData = await getData(date);
getWebsocket().emit("message", clientData);
res.status(200).json({ success: true, dayIndex });
} catch (e: any) {
next(e);
}
});
/** Vrátí obsah push reminder registry (pro ladění). */
router.get("/pushRegistry", async (_req, res, next) => {
try {
const registry = await storage.getData<any>('push_reminder_registry') ?? {};
const sanitized = Object.fromEntries(
Object.entries(registry).map(([login, entry]: [string, any]) => [
login,
{ time: entry.time, endpoint: entry.subscription?.endpoint?.slice(0, 60) + '…' }
])
);
res.status(200).json(sanitized);
} catch (e: any) { next(e) }
});
/** Okamžitě odešle test push notifikaci přihlášenému uživateli (pro ladění). */
router.post("/testPush", async (req, res, next) => {
const login = getLogin(parseToken(req));
try {
const registry = await storage.getData<any>('push_reminder_registry') ?? {};
const entry = registry[login];
if (!entry) {
return res.status(404).json({ error: `Uživatel ${login} nemá uloženou push subscription. Nastav připomínku v nastavení.` });
}
const publicKey = process.env.VAPID_PUBLIC_KEY;
const privateKey = process.env.VAPID_PRIVATE_KEY;
const subject = process.env.VAPID_SUBJECT;
if (!publicKey || !privateKey || !subject) {
return res.status(503).json({ error: 'VAPID klíče nejsou nastaveny' });
}
webpush.setVapidDetails(subject, publicKey, privateKey);
await webpush.sendNotification(
entry.subscription,
JSON.stringify({ title: 'Luncher test', body: 'Push notifikace fungují!' })
);
res.status(200).json({ ok: true });
} catch (e: any) { next(e) }
});
export default router;

View File

@@ -1,11 +1,11 @@
import express, { NextFunction } from "express"; import express, { NextFunction } from "express";
import { getLogin, getTrusted } from "../auth"; import { getLogin } from "../auth";
import { parseToken } from "../utils"; import { parseToken } from "../utils";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import { EasterEgg } from "../../../types"; import { EasterEgg } from "../../../types/gen/types.gen";
const EASTER_EGGS_JSON_PATH = path.join(__dirname, "../../.easter-eggs.json"); const EASTER_EGGS_JSON_PATH = path.join(__dirname, "../../resources/.easter-eggs.json");
const IMAGES_PATH = '../../resources/easterEggs'; const IMAGES_PATH = '../../resources/easterEggs';
type EasterEggsJson = { type EasterEggsJson = {
@@ -34,16 +34,11 @@ function generateUrl() {
*/ */
function getEasterEggImage(req: any, res: any, next: NextFunction) { function getEasterEggImage(req: any, res: any, next: NextFunction) {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
try { try {
// TODO vrátit! if (login in easterEggs) {
// if (trusted) { const imagePath = easterEggs[login][Math.floor(Math.random() * easterEggs[login].length)].path;
if (true) { res.sendFile(path.join(__dirname, IMAGES_PATH, imagePath));
if (login in easterEggs) { return;
const imagePath = easterEggs[login][Math.floor(Math.random() * easterEggs[login].length)].path;
res.sendFile(path.join(__dirname, IMAGES_PATH, imagePath));
return;
}
} }
res.sendStatus(404); res.sendStatus(404);
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
@@ -124,7 +119,7 @@ let easterEggs: EasterEggsJson;
if (fs.existsSync(EASTER_EGGS_JSON_PATH)) { if (fs.existsSync(EASTER_EGGS_JSON_PATH)) {
const content = fs.readFileSync(EASTER_EGGS_JSON_PATH, 'utf-8'); const content = fs.readFileSync(EASTER_EGGS_JSON_PATH, 'utf-8');
easterEggs = JSON.parse(content); easterEggs = JSON.parse(content);
for (const [key, eggs] of Object.entries(easterEggs)) { for (const [_, eggs] of Object.entries(easterEggs)) {
for (const easterEgg of eggs) { for (const easterEgg of eggs) {
const url = generateUrl(); const url = generateUrl();
easterEgg.url = url; easterEgg.url = url;
@@ -138,16 +133,11 @@ if (fs.existsSync(EASTER_EGGS_JSON_PATH)) {
// Získání náhodného easter eggu pro přihlášeného uživatele // Získání náhodného easter eggu pro přihlášeného uživatele
router.get("/", async (req, res, next) => { router.get("/", async (req, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
try { try {
// TODO vrátit! if (easterEggs && login in easterEggs) {
// if (trusted) { const randomEasterEgg = easterEggs[login][Math.floor(Math.random() * easterEggs[login].length)];
if (true) { const { path, startOffset, endOffset, ...strippedEasterEgg } = randomEasterEgg; // Path klient k ničemu nepotřebuje a nemá ho znát
if (easterEggs && login in easterEggs) { return res.status(200).json({ ...strippedEasterEgg, ...getRandomPosition(startOffset, endOffset) });
const randomEasterEgg = easterEggs[login][Math.floor(Math.random() * easterEggs[login].length)];
const { path, startOffset, endOffset, ...strippedEasterEgg } = randomEasterEgg; // Path klient k ničemu nepotřebuje a nemá ho znát
return res.status(200).json({ ...strippedEasterEgg, ...getRandomPosition(startOffset, endOffset) });
}
} }
return res.status(200).send(); return res.status(200).send();
} catch (e: any) { next(e) } } catch (e: any) { next(e) }

View File

@@ -1,10 +1,51 @@
import express, { Request } from "express"; import express, { Request, Response } from "express";
import { getLogin, getTrusted } from "../auth"; import { getLogin, getTrusted } from "../auth";
import { addChoice, addVolatileData, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote } from "../service"; import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote, fetchRestaurantWeekMenuData, saveRestaurantWeekMenu, updateBuyer } from "../service";
import { getDayOfWeekIndex, parseToken } from "../utils"; import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils";
import { getWebsocket } from "../websocket"; import { getWebsocket } from "../websocket";
import { callNotifikace } from "../notifikace"; import { callNotifikace } from "../notifikace";
import { AddChoiceRequest, ChangeDepartureTimeRequest, IDayIndex, RemoveChoiceRequest, RemoveChoicesRequest, UdalostEnum, UpdateNoteRequest } from "../../../types"; import { AddChoiceData, ChangeDepartureTimeData, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen";
// RateLimit na refresh endpoint
interface RateLimitEntry {
count: number;
resetTime: number;
}
const rateLimits: Record<string, RateLimitEntry> = {};
const RATE_LIMIT = 1; // maximální počet požadavků za minutu
const RATE_LIMIT_WINDOW = 30 * 60 * 1000; // je to v ms (x * 1min)
// Kontrola ratelimitu
function checkRateLimit(key: string, limit: number = RATE_LIMIT): boolean {
const now = Date.now();
// Vyčištění starých záznamů
Object.keys(rateLimits).forEach(k => {
if (rateLimits[k].resetTime < now) {
delete rateLimits[k];
}
});
// Kontrola, že záznam existuje a platí
if (rateLimits[key] && rateLimits[key].resetTime > now) {
// Záznam platí a kontroluje se limit
if (rateLimits[key].count >= limit) {
return false; // Překročen limit
}
// ++ xd
rateLimits[key].count++;
return true;
} else {
// + klic
rateLimits[key] = {
count: 1,
resetTime: now + RATE_LIMIT_WINDOW
};
return true;
}
}
/** /**
* Ověří a vrátí index dne v týdnu z požadavku, za předpokladu, že byl předán, a je zároveň * Ověří a vrátí index dne v týdnu z požadavku, za předpokladu, že byl předán, a je zároveň
@@ -13,7 +54,7 @@ import { AddChoiceRequest, ChangeDepartureTimeRequest, IDayIndex, RemoveChoiceRe
* @param req request * @param req request
* @returns index dne v týdnu * @returns index dne v týdnu
*/ */
const parseValidateFutureDayIndex = (req: Request<{}, any, IDayIndex>) => { const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] | UpdateNoteData["body"]>) => {
if (req.body.dayIndex == null) { if (req.body.dayIndex == null) {
throw Error(`Nebyl předán index dne v týdnu.`); throw Error(`Nebyl předán index dne v týdnu.`);
} }
@@ -30,7 +71,7 @@ const parseValidateFutureDayIndex = (req: Request<{}, any, IDayIndex>) => {
const router = express.Router(); const router = express.Router();
router.post("/addChoice", async (req: Request<{}, any, AddChoiceRequest>, res, next) => { router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req)); const trusted = getTrusted(parseToken(req));
let date = undefined; let date = undefined;
@@ -45,12 +86,12 @@ router.post("/addChoice", async (req: Request<{}, any, AddChoiceRequest>, res, n
} }
try { try {
const data = await addChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date); const data = await addChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", data);
return res.status(200).json(data); return res.status(200).json(data);
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesRequest>, res, next) => { router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req)); const trusted = getTrusted(parseToken(req));
let date = undefined; let date = undefined;
@@ -65,12 +106,12 @@ router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesRequest>
} }
try { try {
const data = await removeChoices(login, trusted, req.body.locationKey, date); const data = await removeChoices(login, trusted, req.body.locationKey, date);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", data);
res.status(200).json(data); res.status(200).json(data);
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceRequest>, res, next) => { router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req)); const trusted = getTrusted(parseToken(req));
let date = undefined; let date = undefined;
@@ -85,12 +126,12 @@ router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceRequest>,
} }
try { try {
const data = await removeChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date); const data = await removeChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", data);
res.status(200).json(data); res.status(200).json(data);
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
router.post("/updateNote", async (req: Request<{}, any, UpdateNoteRequest>, res, next) => { router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req)); const trusted = getTrusted(parseToken(req));
const note = req.body.note; const note = req.body.note;
@@ -109,12 +150,12 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteRequest>, res,
date = getDateForWeekIndex(dayIndex); date = getDateForWeekIndex(dayIndex);
} }
const data = await updateNote(login, trusted, note, date); const data = await updateNote(login, trusted, note, date);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", data);
res.status(200).json(data); res.status(200).json(data);
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDepartureTimeRequest>, res, next) => { router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDepartureTimeData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
let date = undefined; let date = undefined;
if (req.body.dayIndex != null) { if (req.body.dayIndex != null) {
@@ -128,7 +169,7 @@ router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDeparture
} }
try { try {
const data = await updateDepartureTime(login, req.body?.time, date); const data = await updateDepartureTime(login, req.body?.time, date);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", data);
res.status(200).json(data); res.status(200).json(data);
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
@@ -136,9 +177,106 @@ router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDeparture
router.post("/jdemeObed", async (req, res, next) => { router.post("/jdemeObed", async (req, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
try { try {
await callNotifikace({ input: { user: login, udalost: UdalostEnum.JDEME_OBED }, gotify: false }) await callNotifikace({ input: { user: login, udalost: UdalostEnum.JDEME_NA_OBED }, gotify: false })
res.status(200).json({}); res.status(200).json({});
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
router.post("/updateBuyer", async (req, res, next) => {
const login = getLogin(parseToken(req));
try {
const data = await updateBuyer(login);
getWebsocket().emit("message", data);
res.status(200).json({});
} catch (e: any) { next(e) }
});
// /api/food/refresh?type=week (přihlášený uživatel, nebo ?heslo=... pro bypass rate limitu)
export const refreshMetoda = async (req: Request, res: Response) => {
const { type, heslo } = req.query as { type?: string; heslo?: string };
const bypassPassword = process.env.REFRESH_BYPASS_PASSWORD;
const isBypass = !!bypassPassword && heslo === bypassPassword;
if (!isBypass) {
try {
getLogin(parseToken(req));
} catch {
return res.status(403).json({ error: "Přihlaste se prosím" });
}
}
if (!checkRateLimit("refresh") && !isBypass) {
return res.status(429).json({ error: "Refresh už se zavolal, chvíli počkej :))" });
}
if (type !== "week" && type !== "day") {
return res.status(400).json({ error: "Neznámý typ refresh" });
}
if (type === "day") {
return res.status(400).json({ error: "ještě neumim TODO..." });
}
try {
// Pro všechny restaurace refreshni menu na aktuální týden
const restaurants = ["SLADOVNICKA", "TECHTOWER", "ZASTAVKAUMICHALA", "SENKSERIKOVA"] as const;
const firstDay = getFirstWorkDayOfWeek(getToday());
const results: Record<string, any> = {};
const successfulRestaurants: string[] = [];
const failedRestaurants: string[] = [];
// Nejdříve načíst všechna data bez ukládání
for (const rest of restaurants) {
try {
const weekData = await fetchRestaurantWeekMenuData(rest, firstDay);
results[rest] = weekData;
// Kontrola validity dat
if (weekData && weekData.length > 0 &&
weekData.some(dayMenu => dayMenu && dayMenu.length > 0)) {
successfulRestaurants.push(rest);
} else {
failedRestaurants.push(rest);
results[rest] = { error: "Žádná validní data" };
}
} catch (error) {
failedRestaurants.push(rest);
results[rest] = { error: `Chyba při načítání: ${error}` };
}
}
// Pokud se nepodařilo načíst žádnou restauraci
if (successfulRestaurants.length === 0) {
return res.status(400).json({
error: "Nepodařilo se získat validní data z žádné restaurace",
failed: failedRestaurants,
results: results
});
}
// Uložit pouze validní data
for (const rest of successfulRestaurants) {
try {
await saveRestaurantWeekMenu(rest as any, firstDay, results[rest]);
} catch (error) {
console.error(`Chyba při ukládání dat pro ${rest}:`, error);
}
}
// Připravit odpověď
const response: any = {
ok: true,
refreshed: results,
successful: successfulRestaurants
};
if (failedRestaurants.length > 0) {
response.warning = `Nepodařilo se načíst: ${failedRestaurants.join(', ')}`;
response.failed = failedRestaurants;
}
res.status(200).json(response);
} catch (e: any) {
res.status(500).json({ error: e?.message || "Chyba při refreshi" });
}
}
router.get("/refresh", refreshMetoda);
export default router; export default router;

View File

@@ -0,0 +1,86 @@
import express, { Request } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getNotificationSettings, saveNotificationSettings } from "../notifikace";
import { subscribePush, unsubscribePush, getVapidPublicKey, findLoginByEndpoint } from "../pushReminder";
import { addChoice } from "../service";
import { getWebsocket } from "../websocket";
import { UpdateNotificationSettingsData } from "../../../types";
const router = express.Router();
/** Vrátí nastavení notifikací pro přihlášeného uživatele. */
router.get("/settings", async (req, res, next) => {
const login = getLogin(parseToken(req));
try {
const settings = await getNotificationSettings(login);
res.status(200).json(settings);
} catch (e: any) { next(e) }
});
/** Uloží nastavení notifikací pro přihlášeného uživatele. */
router.post("/settings", async (req: Request<{}, any, UpdateNotificationSettingsData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
try {
const settings = await saveNotificationSettings(login, {
ntfyTopic: req.body.ntfyTopic,
discordWebhookUrl: req.body.discordWebhookUrl,
teamsWebhookUrl: req.body.teamsWebhookUrl,
enabledEvents: req.body.enabledEvents,
reminderTime: req.body.reminderTime,
});
res.status(200).json(settings);
} catch (e: any) { next(e) }
});
/** Vrátí veřejný VAPID klíč pro registraci push notifikací. */
router.get("/push/vapidKey", (req, res) => {
const key = getVapidPublicKey();
if (!key) {
return res.status(503).json({ error: "Push notifikace nejsou nakonfigurovány" });
}
res.status(200).json({ key });
});
/** Přihlásí uživatele k push připomínkám. */
router.post("/push/subscribe", async (req, res, next) => {
const login = getLogin(parseToken(req));
try {
if (!req.body.subscription) {
return res.status(400).json({ error: "Nebyla předána push subscription" });
}
if (!req.body.reminderTime) {
return res.status(400).json({ error: "Nebyl předán čas připomínky" });
}
await subscribePush(login, req.body.subscription, req.body.reminderTime);
res.status(200).json({});
} catch (e: any) { next(e) }
});
/** Odhlásí uživatele z push připomínek. */
router.post("/push/unsubscribe", async (req, res, next) => {
const login = getLogin(parseToken(req));
try {
await unsubscribePush(login);
res.status(200).json({});
} catch (e: any) { next(e) }
});
/** Rychlá akce z push notifikace — nastaví volbu bez JWT (identita přes subscription endpoint). */
router.post("/push/quickChoice", async (req, res, next) => {
try {
const { endpoint } = req.body;
if (!endpoint) {
return res.status(400).json({ error: "Nebyl předán endpoint" });
}
const login = await findLoginByEndpoint(endpoint);
if (!login) {
return res.status(404).json({ error: "Subscription nenalezena" });
}
const data = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined);
getWebsocket().emit("message", data);
res.status(200).json({});
} catch (e: any) { next(e) }
});
export default router;

View File

@@ -1,10 +1,9 @@
import express, { Request } from "express"; import express, { Request } from "express";
import { getLogin } from "../auth"; import { getLogin } from "../auth";
import { createPizzaDay, deletePizzaDay, getPizzaList, addPizzaOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee } from "../pizza"; import { createPizzaDay, deletePizzaDay, getPizzaList, addPizzaOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee, dismissPendingQr } from "../pizza";
import { parseToken } from "../utils"; import { parseToken } from "../utils";
import { getWebsocket } from "../websocket"; import { getWebsocket } from "../websocket";
import { addVolatileData } from "../service"; import { AddPizzaData, DismissQrData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types";
import { AddPizzaRequest, FinishDeliveryRequest, RemovePizzaRequest, UpdatePizzaDayNoteRequest, UpdatePizzaFeeRequest } from "../../../types";
const router = express.Router(); const router = express.Router();
@@ -13,17 +12,17 @@ router.post("/create", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const data = await createPizzaDay(login); const data = await createPizzaDay(login);
res.status(200).json(data); res.status(200).json(data);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", data);
}); });
/** Smaže pizza day pro aktuální den, za předpokladu že existuje. */ /** Smaže pizza day pro aktuální den, za předpokladu že existuje. */
router.post("/delete", async (req: Request<{}, any, undefined>, res) => { router.post("/delete", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const data = await deletePizzaDay(login); const data = await deletePizzaDay(login);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", data);
}); });
router.post("/add", async (req: Request<{}, any, AddPizzaRequest>, res) => { router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
if (isNaN(req.body?.pizzaIndex)) { if (isNaN(req.body?.pizzaIndex)) {
throw Error("Nebyl předán index pizzy"); throw Error("Nebyl předán index pizzy");
@@ -44,70 +43,82 @@ router.post("/add", async (req: Request<{}, any, AddPizzaRequest>, res) => {
throw Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex); throw Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex);
} }
const data = await addPizzaOrder(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]); const data = await addPizzaOrder(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", data);
res.status(200).json({}); res.status(200).json({});
}); });
router.post("/remove", async (req: Request<{}, any, RemovePizzaRequest>, res) => { router.post("/remove", async (req: Request<{}, any, RemovePizzaData["body"]>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
if (!req.body?.pizzaOrder) { if (!req.body?.pizzaOrder) {
throw Error("Nebyla předána objednávka"); throw Error("Nebyla předána objednávka");
} }
const data = await removePizzaOrder(login, req.body?.pizzaOrder); const data = await removePizzaOrder(login, req.body?.pizzaOrder);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", data);
res.status(200).json({}); res.status(200).json({});
}); });
router.post("/lock", async (req: Request<{}, any, undefined>, res) => { router.post("/lock", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const data = await lockPizzaDay(login); const data = await lockPizzaDay(login);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", data);
res.status(200).json({}); res.status(200).json({});
}); });
router.post("/unlock", async (req: Request<{}, any, undefined>, res) => { router.post("/unlock", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const data = await unlockPizzaDay(login); const data = await unlockPizzaDay(login);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", data);
res.status(200).json({}); res.status(200).json({});
}); });
router.post("/finishOrder", async (req: Request<{}, any, undefined>, res) => { router.post("/finishOrder", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const data = await finishPizzaOrder(login); const data = await finishPizzaOrder(login);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", data);
res.status(200).json({}); res.status(200).json({});
}); });
router.post("/finishDelivery", async (req: Request<{}, any, FinishDeliveryRequest>, res) => { router.post("/finishDelivery", async (req: Request<{}, any, FinishDeliveryData["body"]>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const data = await finishPizzaDelivery(login, req.body.bankAccount, req.body.bankAccountHolder); const data = await finishPizzaDelivery(login, req.body.bankAccount, req.body.bankAccountHolder);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", data);
res.status(200).json({}); res.status(200).json({});
}); });
router.post("/updatePizzaDayNote", async (req: Request<{}, any, UpdatePizzaDayNoteRequest>, res, next) => { router.post("/updatePizzaDayNote", async (req: Request<{}, any, UpdatePizzaDayNoteData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
try { try {
if (req.body.note && req.body.note.length > 70) { if (req.body.note && req.body.note.length > 70) {
throw Error("Poznámka může mít maximálně 70 znaků"); throw Error("Poznámka může mít maximálně 70 znaků");
} }
const data = await updatePizzaDayNote(login, req.body.note); const data = await updatePizzaDayNote(login, req.body.note);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", data);
res.status(200).json(data); res.status(200).json(data);
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
router.post("/updatePizzaFee", async (req: Request<{}, any, UpdatePizzaFeeRequest>, res, next) => { router.post("/updatePizzaFee", async (req: Request<{}, any, UpdatePizzaFeeData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
if (!req.body.login) { if (!req.body.login) {
return res.status(400).json({ error: "Nebyl předán login cílového uživatele" }); return res.status(400).json({ error: "Nebyl předán login cílového uživatele" });
} }
try { try {
const data = await updatePizzaFee(login, req.body.login, req.body.text, req.body.price); const data = await updatePizzaFee(login, req.body.login, req.body.text, req.body.price);
getWebsocket().emit("message", await addVolatileData(data)); getWebsocket().emit("message", data);
res.status(200).json(data); res.status(200).json(data);
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
/** Označí QR kód jako uhrazený. */
router.post("/dismissQr", async (req: Request<{}, any, DismissQrData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
if (!req.body.date) {
return res.status(400).json({ error: "Nebyl předán datum" });
}
try {
await dismissPendingQr(login, req.body.date);
res.status(200).json({});
} catch (e: any) { next(e) }
});
export default router; export default router;

View File

@@ -0,0 +1,64 @@
import express, { Request } from "express";
import { getLogin } from "../auth";
import { parseToken, formatDate } from "../utils";
import { generateQr } from "../qr";
import { addPendingQr } from "../pizza";
import { GenerateQrData } from "../../../types";
const router = express.Router();
/**
* Vygeneruje QR kódy pro platbu vybraným uživatelům.
*/
router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
try {
const { recipients, bankAccount, bankAccountHolder } = req.body;
if (!recipients || !Array.isArray(recipients) || recipients.length === 0) {
return res.status(400).json({ error: "Nebyl předán seznam příjemců" });
}
if (!bankAccount) {
return res.status(400).json({ error: "Nebylo předáno číslo účtu" });
}
if (!bankAccountHolder) {
return res.status(400).json({ error: "Nebylo předáno jméno držitele účtu" });
}
const today = formatDate(new Date());
for (const recipient of recipients) {
if (!recipient.login) {
return res.status(400).json({ error: "Příjemce nemá vyplněný login" });
}
if (!recipient.purpose || recipient.purpose.trim().length === 0) {
return res.status(400).json({ error: `Příjemce ${recipient.login} nemá vyplněný účel platby` });
}
if (typeof recipient.amount !== 'number' || recipient.amount <= 0) {
return res.status(400).json({ error: `Příjemce ${recipient.login} má neplatnou částku` });
}
// Validace max 2 desetinná místa
const amountStr = recipient.amount.toString();
if (amountStr.includes('.') && amountStr.split('.')[1].length > 2) {
return res.status(400).json({ error: `Částka pro ${recipient.login} má více než 2 desetinná místa` });
}
// Vygenerovat QR kód
await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount, recipient.purpose);
// Uložit jako nevyřízený QR kód
await addPendingQr(recipient.login, {
date: today,
creator: login,
totalPrice: recipient.amount,
purpose: recipient.purpose,
});
}
res.status(200).json({ success: true, count: recipients.length });
} catch (e: any) {
next(e);
}
});
export default router;

View File

@@ -0,0 +1,22 @@
import express, { Request, Response } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getStats } from "../stats";
import { WeeklyStats } from "../../../types/gen/types.gen";
const router = express.Router();
router.get("/", async (req: Request<{}, any, undefined>, res: Response<WeeklyStats>) => {
getLogin(parseToken(req));
if (typeof req.query.startDate === 'string' && typeof req.query.endDate === 'string') {
try {
const data = await getStats(req.query.startDate, req.query.endDate);
return res.status(200).json(data);
} catch (e) {
// necháme to zatím spadnout na 400
}
}
res.sendStatus(400);
});
export default router;

View File

@@ -1,18 +1,18 @@
import express, { Request, Response } from "express"; import express, { Request } from "express";
import { getLogin } from "../auth"; import { getLogin } from "../auth";
import { parseToken } from "../utils"; import { parseToken } from "../utils";
import { getUserVotes, updateFeatureVote } from "../voting"; import { getUserVotes, updateFeatureVote, getVotingStats } from "../voting";
import { FeatureRequest, UpdateFeatureVoteRequest } from "../../../types"; import { GetVotesData, UpdateVoteData } from "../../../types";
const router = express.Router(); const router = express.Router();
router.get("/getVotes", async (req: Request<{}, any, undefined>, res: Response<FeatureRequest[]>) => { router.get("/getVotes", async (req: Request<{}, any, GetVotesData["body"]>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const data = await getUserVotes(login); const data = await getUserVotes(login);
res.status(200).json(data); res.status(200).json(data);
}); });
router.post("/updateVote", async (req: Request<{}, any, UpdateFeatureVoteRequest>, res, next) => { router.post("/updateVote", async (req: Request<{}, any, UpdateVoteData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
if (req.body?.option == null || req.body?.active == null) { if (req.body?.option == null || req.body?.active == null) {
res.status(400).json({ error: "Chybné parametry volání" }); res.status(400).json({ error: "Chybné parametry volání" });
@@ -23,4 +23,11 @@ router.post("/updateVote", async (req: Request<{}, any, UpdateFeatureVoteRequest
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
router.get("/stats", async (req, res, next) => {
try {
const data = await getVotingStats();
res.status(200).json(data);
} catch (e: any) { next(e) }
});
export default router; export default router;

View File

@@ -1,8 +1,9 @@
import { InsufficientPermissions, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getHumanDate, getIsWeekend, getWeekNumber } from "./utils"; import { InsufficientPermissions, PizzaDayConflictError, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getHumanDate, getIsWeekend, getWeekNumber } from "./utils";
import { ClientData, Restaurants, RestaurantDailyMenu, DepartureTime, DayData, WeekMenu, LocationKey, DayOfWeekIndex, daysOfWeeksIndices, DayOfWeekEnum, DayOfWeek } from "../../types";
import getStorage from "./storage"; import getStorage from "./storage";
import { getMenuSladovnicka, getMenuTechTower, getMenuUMotliku, getMenuZastavkaUmichala } from "./restaurants"; import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova, StaleWeekError } from "./restaurants";
import { getTodayMock } from "./mock"; import { getTodayMock } from "./mock";
import { removeAllUserPizzas } from "./pizza";
import { ClientData, DepartureTime, LunchChoice, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
const storage = getStorage(); const storage = getStorage();
const MENU_PREFIX = 'menu'; const MENU_PREFIX = 'menu';
@@ -31,44 +32,31 @@ export const getDateForWeekIndex = (index: number) => {
function getEmptyData(date?: Date): ClientData { function getEmptyData(date?: Date): ClientData {
const usedDate = date || getToday(); const usedDate = date || getToday();
return { return {
todayDayIndex: getDayOfWeekIndex(getToday()),
date: getHumanDate(usedDate), date: getHumanDate(usedDate),
isWeekend: getIsWeekend(usedDate), isWeekend: getIsWeekend(usedDate),
weekIndex: getDayOfWeekIndex(usedDate), dayIndex: getDayOfWeekIndex(usedDate),
choices: {}, choices: {},
}; };
} }
/**
* Přidá k datům "dopočítaná" data, která nejsou přímo uložena v databázi.
*
* @param data data z databáze
* @returns obohacená data
*/
export async function addVolatileData(data: ClientData): Promise<ClientData> {
data.todayWeekIndex = getDayOfWeekIndex(getToday());
return data;
}
/** /**
* Vrátí veškerá klientská data pro předaný den, nebo aktuální den, pokud není předán. * Vrátí veškerá klientská data pro předaný den, nebo aktuální den, pokud není předán.
*/ */
export async function getData(date?: Date): Promise<ClientData> { export async function getData(date?: Date): Promise<ClientData> {
const targetDate = date ?? getToday(); const clientData = await getClientData(date);
const dateString = formatDate(targetDate);
const data: DayData = await storage.getData(dateString) || getEmptyData(date);
let clientData: ClientData = { ...data };
clientData.menus = { clientData.menus = {
[Restaurants.SLADOVNICKA]: await getRestaurantMenu(Restaurants.SLADOVNICKA, date), SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
// [Restaurants.UMOTLIKU]: await getRestaurantMenu(Restaurants.UMOTLIKU, date), // UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
[Restaurants.TECHTOWER]: await getRestaurantMenu(Restaurants.TECHTOWER, date), TECHTOWER: await getRestaurantMenu('TECHTOWER', date),
[Restaurants.ZASTAVKAUMICHALA]: await getRestaurantMenu(Restaurants.ZASTAVKAUMICHALA, date), ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
} }
clientData = await addVolatileData(clientData);
return clientData; return clientData;
} }
/** /**
* Vrátí klíč, pod kterým je uloženo menu pro předané datum. * Vrátí klíč, pod kterým je uloženo menu pro týden příslušící předanému datu.
* *
* @param date datum * @param date datum
* @returns databázový klíč * @returns databázový klíč
@@ -79,25 +67,117 @@ function getMenuKey(date: Date) {
} }
/** /**
* Vrátí menu restaurací pro předané datum, pokud již existují. * Vrátí menu všech podniků pro celý týden do kterého spadá předané datum, pokud již existují.
* *
* @param date datum * @param date datum
* @returns menu restaurací pro předané datum * @returns menu restaurací pro týden příslušící předanému datu
*/ */
async function getMenu(date: Date): Promise<WeekMenu | undefined> { async function getMenu(date: Date): Promise<WeekMenu | undefined> {
return await storage.getData(getMenuKey(date)); return await storage.getData<WeekMenu | undefined>(getMenuKey(date));
} }
// TODO přesun do restaurants.ts // TODO přesun do restaurants.ts
/**
* Načte menu dané restaurace pro celý týden bez ukládání do storage.
* Používá se pro validaci dat před uložením.
*
* @param restaurant restaurace
* @param firstDay první pracovní den týdne
* @returns pole menu pro jednotlivé dny týdne
*/
export async function fetchRestaurantWeekMenuData(restaurant: Restaurant, firstDay: Date): Promise<any[]> {
return await fetchRestaurantWeekMenu(restaurant, firstDay);
}
/**
* Uloží týdenní menu restaurace do storage.
*
* @param restaurant restaurace
* @param date datum z týdne, pro který ukládat menu
* @param weekData data týdenního menu
*/
export async function saveRestaurantWeekMenu(restaurant: Restaurant, date: Date, weekData: any[]): Promise<void> {
const now = new Date().getTime();
let weekMenu = await getMenu(date);
weekMenu ??= [{}, {}, {}, {}, {}];
// Inicializace struktury pro restauraci
for (let i = 0; i < 5; i++) {
weekMenu[i] ??= {};
weekMenu[i][restaurant] ??= {
lastUpdate: now,
closed: false,
food: [],
};
}
// Uložení dat pro všechny dny
for (let i = 0; i < weekData.length && i < weekMenu.length; i++) {
weekMenu[i][restaurant]!.food = weekData[i];
weekMenu[i][restaurant]!.lastUpdate = now;
// Detekce uzavření pro každou restauraci
switch (restaurant) {
case 'SLADOVNICKA':
if (weekData[i].length === 1 && weekData[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'TECHTOWER':
if (weekData[i]?.length === 1 && weekData[i][0].name.toLowerCase() === 'svátek') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'ZASTAVKAUMICHALA':
if (weekData[i]?.length === 1 && weekData[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'SENKSERIKOVA':
if (weekData[i]?.length === 1 && weekData[i][0].name === 'Pro tento den nebylo zadáno menu.') {
weekMenu[i][restaurant]!.closed = true;
}
break;
}
}
// Uložení do storage
await storage.setData(getMenuKey(date), weekMenu);
}
/**
* Načte menu dané restaurace pro celý týden bez ukládání do storage.
*
* @param restaurant restaurace
* @param firstDay první pracovní den týdne
* @returns pole menu pro jednotlivé dny týdne
*/
async function fetchRestaurantWeekMenu(restaurant: Restaurant, firstDay: Date): Promise<any[]> {
const mock = process.env.MOCK_DATA === 'true';
switch (restaurant) {
case 'SLADOVNICKA':
return await getMenuSladovnicka(firstDay, mock);
case 'TECHTOWER':
return await getMenuTechTower(firstDay, mock);
case 'ZASTAVKAUMICHALA':
return await getMenuZastavkaUmichala(firstDay, mock);
case 'SENKSERIKOVA':
return await getMenuSenkSerikova(firstDay, mock);
default:
throw new Error(`Nepodporovaná restaurace: ${restaurant}`);
}
}
/** /**
* Vrátí menu dané restaurace pro předaný den. * Vrátí menu dané restaurace pro předaný den.
* Pokud neexistuje, provede stažení menu pro příslušný týden a uložení do DB. * Pokud neexistuje, provede stažení menu pro příslušný týden a uložení do DB.
* *
* @param restaurant restaurace * @param restaurant restaurace
* @param date datum, ke kterému získat menu * @param date datum, ke kterému získat menu
* @param mock příznak, zda chceme pouze mock data * @param forceRefresh příznak vynuceného obnovení
*/ */
export async function getRestaurantMenu(restaurant: Restaurants, date?: Date): Promise<RestaurantDailyMenu> { export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, forceRefresh = false): Promise<RestaurantDayMenu> {
const usedDate = date ?? getToday(); const usedDate = date ?? getToday();
const dayOfWeekIndex = getDayOfWeekIndex(usedDate); const dayOfWeekIndex = getDayOfWeekIndex(usedDate);
const now = new Date().getTime(); const now = new Date().getTime();
@@ -109,94 +189,100 @@ export async function getRestaurantMenu(restaurant: Restaurants, date?: Date): P
}; };
} }
let menus = await getMenu(usedDate); let weekMenu = await getMenu(usedDate);
if (menus == null) { weekMenu ??= [{}, {}, {}, {}, {}];
menus = {}; for (let i = 0; i < 5; i++) {
weekMenu[i] ??= {};
weekMenu[i][restaurant] ??= {
lastUpdate: now,
closed: false,
food: [],
};
} }
daysOfWeeksIndices.forEach(i => { const MENU_REFETCH_TTL_MS = 60 * 60 * 1000; // 1 hour
if (menus[i] == null) { const existingMenu = weekMenu[dayOfWeekIndex][restaurant];
menus[i] = {}; const lastFetchExpired = !existingMenu?.lastUpdate ||
} existingMenu.lastUpdate === now || // freshly initialized, never fetched
if (menus[i][restaurant] == null) { (now - existingMenu.lastUpdate) > MENU_REFETCH_TTL_MS;
menus[i][restaurant] = { const shouldFetch = forceRefresh ||
lastUpdate: now, (!existingMenu?.food?.length && !existingMenu?.closed && lastFetchExpired);
closed: false, if (shouldFetch) {
food: [],
};
}
})
if (!menus[dayOfWeekIndex]) {
menus[dayOfWeekIndex] = {};
}
if (!menus[dayOfWeekIndex][restaurant]?.food?.length) {
const firstDay = getFirstWorkDayOfWeek(usedDate); const firstDay = getFirstWorkDayOfWeek(usedDate);
const mock = process.env.MOCK_DATA === 'true';
switch (restaurant) { try {
case Restaurants.SLADOVNICKA: const restaurantWeekFood = await fetchRestaurantWeekMenu(restaurant, firstDay);
try {
// TODO tady jsme v háji, protože z následujících metod vracíme arbitrárně dlouhé pole (musíme vracet omezené na maximálně 0-7 prvků) // Aktualizace menu pro všechny dny
const sladovnickaFood = await getMenuSladovnicka(firstDay, mock); for (let i = 0; i < restaurantWeekFood.length && i < weekMenu.length; i++) {
for (const i in DayOfWeekEnum) { weekMenu[i][restaurant]!.food = restaurantWeekFood[i];
menus[i][restaurant]!.food = sladovnickaFood[i]; weekMenu[i][restaurant]!.lastUpdate = now;
// Velice chatrný a nespolehlivý způsob detekce uzavření... weekMenu[i][restaurant]!.isStale = false;
if (sladovnickaFood[i].length === 1 && sladovnickaFood[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') {
menus[i][restaurant]!.closed = true; // Detekce uzavření pro každou restauraci
switch (restaurant) {
case 'SLADOVNICKA':
if (restaurantWeekFood[i].length === 1 && restaurantWeekFood[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') {
weekMenu[i][restaurant]!.closed = true;
} }
} break;
for (let i = 0; i < sladovnickaFood.length; i++) { case 'TECHTOWER':
menus[i][restaurant]!.food = sladovnickaFood[i]; if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name.toLowerCase() === 'svátek') {
// Velice chatrný a nespolehlivý způsob detekce uzavření... weekMenu[i][restaurant]!.closed = true;
if (sladovnickaFood[i].length === 1 && sladovnickaFood[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') {
menus[i][restaurant]!.closed = true;
} }
} break;
} catch (e: any) { case 'ZASTAVKAUMICHALA':
console.error("Selhalo načtení jídel pro podnik Sladovnická", e); if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'SENKSERIKOVA':
if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name === 'Pro tento den nebylo zadáno menu.') {
weekMenu[i][restaurant]!.closed = true;
}
break;
} }
break; }
// case Restaurants.UMOTLIKU:
// try { // Uložení do storage
// const uMotlikuFood = await getMenuUMotliku(firstDay, mock); await storage.setData(getMenuKey(usedDate), weekMenu);
// for (let i = 0; i < uMotlikuFood.length; i++) { } catch (e: any) {
// menus[i][restaurant]!.food = uMotlikuFood[i]; if (e instanceof StaleWeekError) {
// if (uMotlikuFood[i].length === 1 && uMotlikuFood[i][0].name.toLowerCase() === 'zavřeno') { for (let i = 0; i < e.food.length && i < weekMenu.length; i++) {
// menus[i][restaurant]!.closed = true; weekMenu[i][restaurant]!.food = e.food[i];
// } weekMenu[i][restaurant]!.lastUpdate = now;
// } weekMenu[i][restaurant]!.isStale = true;
// } catch (e: any) {
// console.error("Selhalo načtení jídel pro podnik U Motlíků", e);
// }
// break;
case Restaurants.TECHTOWER:
try {
const techTowerFood = await getMenuTechTower(firstDay, mock);
for (let i = 0; i < techTowerFood.length; i++) {
menus[i][restaurant]!.food = techTowerFood[i];
if (techTowerFood[i]?.length === 1 && techTowerFood[i][0].name.toLowerCase() === 'svátek') {
menus[i][restaurant]!.closed = true;
}
}
break;
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik TechTower", e);
}
case Restaurants.ZASTAVKAUMICHALA:
try {
const zastavkaUmichalaFood = await getMenuZastavkaUmichala(firstDay, mock);
for (let i = 0; i < zastavkaUmichalaFood.length; i++) {
menus[i][restaurant]!.food = zastavkaUmichalaFood[i];
if (zastavkaUmichalaFood[i]?.length === 1 && zastavkaUmichalaFood[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') {
menus[i][restaurant]!.closed = true;
}
}
break;
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik Zastávka u Michala", e);
} }
await storage.setData(getMenuKey(usedDate), weekMenu);
} else {
console.error(`Selhalo načtení jídel pro podnik ${restaurant}`, e);
}
} }
await storage.setData(getMenuKey(usedDate), menus);
} }
return menus[dayOfWeekIndex][restaurant]!; const result = weekMenu[dayOfWeekIndex][restaurant]!;
result.warnings = generateMenuWarnings(result);
return result;
}
/**
* Generuje varování o kvalitě/úplnosti dat menu restaurace.
*/
function generateMenuWarnings(menu: RestaurantDayMenu): string[] {
const warnings: string[] = [];
if (!menu.food?.length || menu.closed) {
return warnings;
}
if (menu.isStale) {
warnings.push('Data jsou z minulého týdne');
}
const hasSoup = menu.food.some(f => f.isSoup);
if (!hasSoup) {
warnings.push('Chybí polévka');
}
const missingPrice = menu.food.some(f => !f.isSoup && (!f.price || f.price.trim() === ''));
if (missingPrice) {
warnings.push('U některých jídel chybí cena');
}
return warnings;
} }
/** /**
@@ -221,9 +307,9 @@ export async function initIfNeeded(date?: Date) {
* @param date datum, ke kterému se volba vztahuje * @param date datum, ke kterému se volba vztahuje
* @returns * @returns
*/ */
export async function removeChoices(login: string, trusted: boolean, locationKey: LocationKey, date?: Date) { export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date) {
const selectedDay = formatDate(date ?? getToday()); const selectedDay = formatDate(date ?? getToday());
let data: DayData = await storage.getData(selectedDay); let data = await getClientData(date);
validateTrusted(data, login, trusted); validateTrusted(data, login, trusted);
if (locationKey in data.choices) { if (locationKey in data.choices) {
if (data.choices[locationKey] && login in data.choices[locationKey]) { if (data.choices[locationKey] && login in data.choices[locationKey]) {
@@ -248,15 +334,15 @@ export async function removeChoices(login: string, trusted: boolean, locationKey
* @param date datum, ke kterému se volba vztahuje * @param date datum, ke kterému se volba vztahuje
* @returns * @returns
*/ */
export async function removeChoice(login: string, trusted: boolean, locationKey: LocationKey, foodIndex: number, date?: Date) { export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date) {
const selectedDay = formatDate(date ?? getToday()); const selectedDay = formatDate(date ?? getToday());
let data: DayData = await storage.getData(selectedDay); let data = await getClientData(date);
validateTrusted(data, login, trusted); validateTrusted(data, login, trusted);
if (locationKey in data.choices) { if (locationKey in data.choices) {
if (data.choices[locationKey] && login in data.choices[locationKey]) { if (data.choices[locationKey] && login in data.choices[locationKey]) {
const index = data.choices[locationKey][login].options.indexOf(foodIndex); const index = data.choices[locationKey][login].selectedFoods?.indexOf(foodIndex);
if (index > -1) { if (index != null && index > -1) {
data.choices[locationKey][login].options.splice(index, 1) data.choices[locationKey][login].selectedFoods?.splice(index, 1);
await storage.setData(selectedDay, data); await storage.setData(selectedDay, data);
} }
} }
@@ -265,20 +351,26 @@ export async function removeChoice(login: string, trusted: boolean, locationKey:
} }
/** /**
* Odstraní kompletně volbu uživatele. * Odstraní kompletně volbu uživatele, vyjma ignoredLocationKey (pokud byla předána a existuje).
* *
* @param login login uživatele * @param login login uživatele
* @param date datum, ke kterému se volby vztahují
* @param ignoredLocationKey volba, která nebude odstraněna, pokud existuje
*/ */
async function removeChoiceIfPresent(login: string, date: string) { async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocationKey?: LunchChoice) {
let data: DayData = await storage.getData(date); const usedDate = date ?? getToday();
let data = await getClientData(usedDate);
for (const key of Object.keys(data.choices)) { for (const key of Object.keys(data.choices)) {
const locationKey = key as LocationKey; const locationKey = key as LunchChoice;
if (ignoredLocationKey != null && ignoredLocationKey == locationKey) {
continue;
}
if (data.choices[locationKey] && login in data.choices[locationKey]) { if (data.choices[locationKey] && login in data.choices[locationKey]) {
delete data.choices[locationKey][login]; delete data.choices[locationKey][login];
if (Object.keys(data.choices[locationKey]).length === 0) { if (Object.keys(data.choices[locationKey]).length === 0) {
delete data.choices[locationKey]; delete data.choices[locationKey];
} }
await storage.setData(date, data); await storage.setData(formatDate(usedDate), data);
} }
} }
return data; return data;
@@ -317,36 +409,87 @@ function validateTrusted(data: ClientData, login: string, trusted: boolean) {
* @param date datum, ke kterému se volba vztahuje * @param date datum, ke kterému se volba vztahuje
* @returns aktuální data * @returns aktuální data
*/ */
export async function addChoice(login: string, trusted: boolean, locationKey: LocationKey, foodIndex?: number, date?: Date) { export async function addChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex?: number, date?: Date) {
const usedDate = date ?? getToday(); const usedDate = date ?? getToday();
await initIfNeeded(usedDate); await initIfNeeded(usedDate);
const selectedDate = formatDate(usedDate); let data = await getClientData(usedDate);
let data: DayData = await storage.getData(selectedDate);
validateTrusted(data, login, trusted); validateTrusted(data, login, trusted);
await validateFoodIndex(locationKey, foodIndex, date);
// Pokud uživatel měl vybranou PIZZA a mění na něco jiného
const hadPizzaChoice = data.choices.PIZZA && login in data.choices.PIZZA;
if (hadPizzaChoice && locationKey !== LunchChoice.PIZZA && foodIndex == null) {
// Kontrola, zda existuje Pizza day a uživatel je jeho zakladatel
if (data.pizzaDay && data.pizzaDay.creator === login) {
// Pokud Pizza day není ve stavu CREATED, nelze změnit volbu
if (data.pizzaDay.state !== PizzaDayState.CREATED) {
throw new PizzaDayConflictError(
`Nelze změnit volbu. Pizza day je ve stavu "${data.pizzaDay.state}" a musí být nejprve dokončen nebo smazán.`
);
}
// Pizza day je ve stavu CREATED - bude smazán frontendem po potvrzení uživatelem
// (frontend volá nejprve deletePizzaDay, pak teprve addChoice)
}
// Smažeme pizzy uživatele (pokud Pizza day nebyl založen tímto uživatelem,
// nebo byl již smazán frontendem)
await removeAllUserPizzas(login, usedDate);
// Znovu načteme data, protože removeAllUserPizzas je upravila
data = await getClientData(usedDate);
}
// Pokud měníme pouze lokaci, mažeme případné předchozí // Pokud měníme pouze lokaci, mažeme případné předchozí
if (foodIndex == null) { if (foodIndex == null) {
data = await removeChoiceIfPresent(login, selectedDate); data = await removeChoiceIfPresent(login, usedDate);
} else {
// Mažeme případné ostatní volby (měla by být maximálně jedna)
data = await removeChoiceIfPresent(login, usedDate, locationKey);
} }
// TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce // TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce
if (!(data.choices[locationKey])) { data.choices[locationKey] ??= {};
data.choices[locationKey] = {}
}
if (!(login in data.choices[locationKey])) { if (!(login in data.choices[locationKey])) {
if (!data.choices[locationKey]) { if (!data.choices[locationKey]) {
data.choices[locationKey] = {} data.choices[locationKey] = {}
} }
data.choices[locationKey][login] = { data.choices[locationKey][login] = {
trusted, trusted,
options: [] selectedFoods: []
}; };
} }
if (foodIndex != null && !data.choices[locationKey][login].options.includes(foodIndex)) { if (foodIndex != null && !data.choices[locationKey][login].selectedFoods?.includes(foodIndex)) {
data.choices[locationKey][login].options.push(foodIndex); data.choices[locationKey][login].selectedFoods?.push(foodIndex);
} }
const selectedDate = formatDate(usedDate);
await storage.setData(selectedDate, data); await storage.setData(selectedDate, data);
return data; return data;
} }
/**
* Zvaliduje platnost indexu jídla pro vybranou lokalitu a datum.
*
* @param locationKey vybraná lokalita
* @param foodIndex index jídla pro danou lokalitu
* @param date datum, pro které je validace prováděna
*/
async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, date?: Date) {
if (foodIndex != null) {
if (typeof foodIndex !== 'number') {
throw Error(`Neplatný index ${foodIndex} typu ${typeof foodIndex}`);
}
if (foodIndex < 0) {
throw Error(`Neplatný index ${foodIndex}`);
}
if (!Object.keys(Restaurant).includes(locationKey)) {
throw Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey} nepodporující indexy`);
}
const usedDate = date ?? getToday();
const menu = await getRestaurantMenu(locationKey as Restaurant, usedDate);
if (menu.food?.length && foodIndex > (menu.food.length - 1)) {
throw new Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey}`);
}
}
}
/** /**
* Aktualizuje poznámku k aktuálně vybrané možnosti. * Aktualizuje poznámku k aktuálně vybrané možnosti.
* *
@@ -358,16 +501,16 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lo
export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date) { export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date) {
const usedDate = date ?? getToday(); const usedDate = date ?? getToday();
await initIfNeeded(usedDate); await initIfNeeded(usedDate);
const selectedDate = formatDate(usedDate); let data = await getClientData(usedDate);
let data: DayData = await storage.getData(selectedDate);
validateTrusted(data, login, trusted); validateTrusted(data, login, trusted);
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null); const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
if (userEntry) { if (userEntry) {
if (!note || !note.length) { if (!note?.length) {
delete userEntry[1][login].note; delete userEntry[1][login].note;
} else { } else {
userEntry[1][login].note = note; userEntry[1][login].note = note;
} }
const selectedDate = formatDate(usedDate);
await storage.setData(selectedDate, data); await storage.setData(selectedDate, data);
} }
return data; return data;
@@ -381,8 +524,8 @@ export async function updateNote(login: string, trusted: boolean, note?: string,
* @param date datum, ke kterému se čas vztahuje * @param date datum, ke kterému se čas vztahuje
*/ */
export async function updateDepartureTime(login: string, time?: string, date?: Date) { export async function updateDepartureTime(login: string, time?: string, date?: Date) {
const selectedDate = formatDate(date ?? getToday()); const usedDate = date ?? getToday();
let clientData: DayData = await storage.getData(selectedDate); let clientData = await getClientData(usedDate);
const found = Object.values(clientData.choices).find(location => login in location); const found = Object.values(clientData.choices).find(location => login in location);
// TODO validace, že se jedná o restauraci // TODO validace, že se jedná o restauraci
if (found) { if (found) {
@@ -394,7 +537,41 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
} }
found[login].departureTime = time; found[login].departureTime = time;
} }
await storage.setData(selectedDate, clientData); await storage.setData(formatDate(usedDate), clientData);
} }
return clientData; return clientData;
}
/**
* Nastaví/odnastaví uživatele jako objednatele pro dnešní den.
* Objednatelů může být více.
*
* @param login přihlašovací jméno uživatele
*/
export async function updateBuyer(login: string) {
const usedDate = getToday();
let clientData = await getClientData(usedDate);
const userEntry = clientData.choices?.['OBJEDNAVAM']?.[login];
if (!userEntry) {
throw new Error("Nelze nastavit objednatele pro uživatele s jinou volbou než \"Budu objednávat\"");
}
userEntry.isBuyer = !(userEntry.isBuyer || false);
await storage.setData(formatDate(usedDate), clientData);
return clientData;
}
/**
* Vrátí data pro klienta pro předaný nebo aktuální den.
*
* @param date datum pro který vrátit data, pokud není vyplněno, je použit dnešní den
* @returns data pro klienta
*/
export async function getClientData(date?: Date): Promise<ClientData> {
const targetDate = date ?? getToday();
const dateString = formatDate(targetDate);
const clientData = await storage.getData<ClientData>(dateString) || getEmptyData(date);
return {
...clientData,
todayDayIndex: getDayOfWeekIndex(getToday()),
}
} }

54
server/src/stats.ts Normal file
View File

@@ -0,0 +1,54 @@
import { DailyStats, LunchChoice, WeeklyStats } from "../../types/gen/types.gen";
import { getStatsMock } from "./mock";
import { getClientData } from "./service";
import getStorage from "./storage";
const storage = getStorage();
/**
* Vypočte a vrátí statistiky jednotlivých možností pro předaný rozsah dat.
*
* @param startDate počáteční datum
* @param endDate koncové datum
* @returns statistiky pro zadaný rozsah dat
*/
export async function getStats(startDate: string, endDate: string): Promise<WeeklyStats> {
if (process.env.MOCK_DATA === 'true') {
return getStatsMock();
}
const start = new Date(startDate);
const end = new Date(endDate);
// Dočasná validace, aby to někdo ručně neshodil
const daysDiff = ((end as any) - (start as any)) / (1000 * 60 * 60 * 24);
if (daysDiff > 4) {
throw Error('Neplatný rozsah');
}
const today = new Date();
today.setHours(23, 59, 59, 999);
if (end > today) {
throw Error('Nelze načíst statistiky pro budoucí datum');
}
const result = [];
for (const date = start; date <= end; date.setDate(date.getDate() + 1)) {
const locationsStats: DailyStats = {
// TODO vytáhnout do utils funkce
date: `${String(date.getDate()).padStart(2, "0")}.${String(date.getMonth() + 1).padStart(2, "0")}.`,
locations: {}
}
const data = await getClientData(date);
if (data?.choices) {
Object.keys(data.choices).forEach(locationKey => {
if (!locationsStats.locations) {
locationsStats.locations = {}
}
// TODO dořešit, tohle je zmatek a té hlášce Sonaru nerozumím
locationsStats.locations[locationKey as LunchChoice] = Object.keys(data.choices[locationKey as LunchChoice]!).length;
})
}
result.push(locationsStats);
}
return result as WeeklyStats;
}

View File

@@ -1,5 +1,3 @@
import { ClientData } from "../../../types";
/** /**
* Interface pro úložiště dat. * Interface pro úložiště dat.
* *
@@ -7,6 +5,12 @@ import { ClientData } from "../../../types";
* Postupem času lze předělat pro efektivnější využití Redis. * Postupem času lze předělat pro efektivnější využití Redis.
*/ */
export interface StorageInterface { export interface StorageInterface {
/**
* Inicializuje úložiště, pokud je potřeba (např. připojení k databázi).
*/
initialize?(): Promise<void>;
/** /**
* Vrátí příznak, zda existují data pro předaný klíč. * Vrátí příznak, zda existují data pro předaný klíč.
* @param key klíč, pro který zjišťujeme data (typicky datum) * @param key klíč, pro který zjišťujeme data (typicky datum)
@@ -17,7 +21,7 @@ export interface StorageInterface {
* Vrátí veškerá data pro předaný klíč. * Vrátí veškerá data pro předaný klíč.
* @param key klíč, pro který vrátit data (typicky datum) * @param key klíč, pro který vrátit data (typicky datum)
*/ */
getData<Type>(key: string): Promise<Type>; getData<Type>(key: string): Promise<Type | undefined>;
/** /**
* Uloží data pod předaný klíč. * Uloží data pod předaný klíč.

View File

@@ -4,8 +4,8 @@ import { StorageInterface } from "./StorageInterface";
import JsonStorage from "./json"; import JsonStorage from "./json";
import RedisStorage from "./redis"; import RedisStorage from "./redis";
const ENVIRONMENT = process.env.NODE_ENV || 'production'; const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); dotenv.config({ path: path.resolve(__dirname, `../../.env.${ENVIRONMENT}`) });
const JSON_KEY = 'json'; const JSON_KEY = 'json';
const REDIS_KEY = 'redis'; const REDIS_KEY = 'redis';
@@ -19,6 +19,13 @@ if (!process.env.STORAGE || process.env.STORAGE?.toLowerCase() === JSON_KEY) {
throw Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json' nebo 'redis'"); throw Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json' nebo 'redis'");
} }
(async () => {
if (storage.initialize) {
await storage.initialize();
}
})();
export default function getStorage(): StorageInterface { export default function getStorage(): StorageInterface {
return storage; return storage;
} }

View File

@@ -1,8 +1,17 @@
import JSONdb from 'simple-json-db'; import JSONdb from 'simple-json-db';
import { StorageInterface } from "./StorageInterface"; import { StorageInterface } from "./StorageInterface";
import * as fs from 'fs';
import * as path from 'path';
const db = new JSONdb('./data.json'); const dbPath = path.resolve(__dirname, '../../data/db.json');
const dbDir = path.dirname(dbPath);
// Zajistěte, že adresář existuje
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const db = new JSONdb(dbPath);
/** /**
* Implementace úložiště používající JSON soubor. * Implementace úložiště používající JSON soubor.
*/ */

View File

@@ -8,15 +8,18 @@ let client: RedisClientType;
*/ */
export default class RedisStorage implements StorageInterface { export default class RedisStorage implements StorageInterface {
constructor() { constructor() {
const HOST = process.env.REDIS_HOST || 'localhost'; const HOST = process.env.REDIS_HOST ?? 'localhost';
const PORT = process.env.REDIS_PORT || 6379; const PORT = process.env.REDIS_PORT ?? 6379;
client = createClient({ url: `redis://${HOST}:${PORT}` }); client = createClient({ url: `redis://${HOST}:${PORT}` });
}
async initialize() {
client.connect(); client.connect();
} }
async hasData(key: string) { async hasData(key: string) {
const data = await client.json.get(key); const data = await client.json.get(key);
return (data ? true : false); return (!!data);
} }
async getData<Type>(key: string) { async getData<Type>(key: string) {

View File

@@ -1,4 +1,6 @@
import { Choices, DayOfWeekIndex, LocationKey } from "../../types"; import { LunchChoice, LunchChoices } from "../../types/gen/types.gen";
const DAY_OF_WEEK_FORMAT = new Intl.DateTimeFormat(undefined, { weekday: 'long' });
/** Vrátí datum v ISO formátu. */ /** Vrátí datum v ISO formátu. */
export function formatDate(date: Date, format?: string) { export function formatDate(date: Date, format?: string) {
@@ -6,7 +8,7 @@ export function formatDate(date: Date, format?: string) {
let month = String(date.getMonth() + 1).padStart(2, "0"); let month = String(date.getMonth() + 1).padStart(2, "0");
let year = String(date.getFullYear()); let year = String(date.getFullYear());
const f = (format === undefined) ? 'YYYY-MM-DD' : format; const f = format ?? 'YYYY-MM-DD';
return f.replace('DD', day).replace('MM', month).replace('YYYY', year); return f.replace('DD', day).replace('MM', month).replace('YYYY', year);
} }
@@ -15,7 +17,7 @@ export function getHumanDate(date: Date) {
let currentDay = String(date.getDate()).padStart(2, '0'); let currentDay = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0"); let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear(); let currentYear = date.getFullYear();
let currentDayOfWeek = date.toLocaleDateString("CZ-cs", { weekday: 'long' }); let currentDayOfWeek = DAY_OF_WEEK_FORMAT.format(date);
return `${currentDay}.${currentMonth}.${currentYear} (${currentDayOfWeek})`; return `${currentDay}.${currentMonth}.${currentYear} (${currentDayOfWeek})`;
} }
@@ -32,9 +34,9 @@ export function getHumanTime(time: Date) {
* @param date datum * @param date datum
* @returns index dne v týdnu * @returns index dne v týdnu
*/ */
export const getDayOfWeekIndex = (date: Date): DayOfWeekIndex => { export const getDayOfWeekIndex = (date: Date) => {
// https://stackoverflow.com/a/4467559 // https://stackoverflow.com/a/4467559
return ((((date.getDay() - 1) % 7) + 7) % 7) as DayOfWeekIndex; return (((date.getDay() - 1) % 7) + 7) % 7;
} }
/** Vrátí true, pokud je předané datum o víkendu. */ /** Vrátí true, pokud je předané datum o víkendu. */
@@ -59,10 +61,10 @@ export function getLastWorkDayOfWeek(date: Date) {
/** Vrátí pořadové číslo týdne předaného data v roce dle ISO 8601. */ /** Vrátí pořadové číslo týdne předaného data v roce dle ISO 8601. */
export function getWeekNumber(inputDate: Date) { export function getWeekNumber(inputDate: Date) {
var date = new Date(inputDate.getTime()); const date = new Date(inputDate.getTime());
date.setHours(0, 0, 0, 0); date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7); date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7);
var week1 = new Date(date.getFullYear(), 0, 4); const week1 = new Date(date.getFullYear(), 0, 4);
return 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7); return 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7);
} }
@@ -112,13 +114,15 @@ export const checkBodyParams = (req: any, paramNames: string[]) => {
// TODO umístit do samostatného souboru // TODO umístit do samostatného souboru
export class InsufficientPermissions extends Error { } export class InsufficientPermissions extends Error { }
export const getUsersByLocation = (choices: Choices, login: string): string[] => { export class PizzaDayConflictError extends Error { }
export const getUsersByLocation = (choices: LunchChoices, login?: string): string[] => {
const result: string[] = []; const result: string[] = [];
for (const location of Object.entries(choices)) { for (const location of Object.entries(choices)) {
const locationKey = location[0] as LocationKey; const locationKey = location[0] as LunchChoice;
const locationValue = location[1]; const locationValue = location[1];
if (locationValue[login]) { if (login && locationValue[login]) {
for (const username in choices[locationKey]) { for (const username in choices[locationKey]) {
if (choices[locationKey].hasOwnProperty(username)) { if (choices[locationKey].hasOwnProperty(username)) {
result.push(username); result.push(username);

View File

@@ -1,10 +1,14 @@
import { FeatureRequest } from "../../types"; import { FeatureRequest, VotingStats } from "../../types/gen/types.gen";
import getStorage from "./storage"; import getStorage from "./storage";
interface VotingData { interface VotingData {
[login: string]: FeatureRequest[], [login: string]: FeatureRequest[],
} }
export interface VotingStatsResult {
[feature: string]: number;
}
const storage = getStorage(); const storage = getStorage();
const STORAGE_KEY = 'voting'; const STORAGE_KEY = 'voting';
@@ -15,7 +19,7 @@ const STORAGE_KEY = 'voting';
* @returns pole voleb * @returns pole voleb
*/ */
export async function getUserVotes(login: string) { export async function getUserVotes(login: string) {
const data: VotingData = await storage.getData(STORAGE_KEY); const data = await storage.getData<VotingData>(STORAGE_KEY);
return data?.[login] || []; return data?.[login] || [];
} }
@@ -28,10 +32,8 @@ export async function getUserVotes(login: string) {
* @returns aktuální data * @returns aktuální data
*/ */
export async function updateFeatureVote(login: string, option: FeatureRequest, active: boolean): Promise<VotingData> { export async function updateFeatureVote(login: string, option: FeatureRequest, active: boolean): Promise<VotingData> {
let data: VotingData = await storage.getData(STORAGE_KEY); let data = await storage.getData<VotingData>(STORAGE_KEY);
if (data == null) { data ??= {};
data = {};
}
if (!(login in data)) { if (!(login in data)) {
data[login] = []; data[login] = [];
} }
@@ -53,4 +55,22 @@ export async function updateFeatureVote(login: string, option: FeatureRequest, a
} }
await storage.setData(STORAGE_KEY, data); await storage.setData(STORAGE_KEY, data);
return data; return data;
}
/**
* Vrátí agregované statistiky hlasování - počet hlasů pro každou funkci.
*
* @returns objekt, kde klíčem je název funkce a hodnotou počet hlasů
*/
export async function getVotingStats(): Promise<VotingStatsResult> {
const data = await storage.getData<VotingData>(STORAGE_KEY);
const stats: VotingStatsResult = {};
if (data) {
for (const votes of Object.values(data)) {
for (const feature of votes) {
stats[feature] = (stats[feature] || 0) + 1;
}
}
}
return stats;
} }

View File

@@ -1,5 +1,4 @@
import { Server } from "socket.io"; import { DefaultEventsMap, Server } from "socket.io";
import { DefaultEventsMap } from "socket.io/dist/typed-events";
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>; let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;

View File

@@ -4,10 +4,11 @@
"../types/**/*" "../types/**/*"
], ],
"compilerOptions": { "compilerOptions": {
"target": "ES2016", "target": "ES2022",
"module": "CommonJS", "module": "Node16",
"jsx": "react", "moduleResolution": "node16",
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "../", "rootDir": "../",

File diff suppressed because it is too large Load Diff

View File

@@ -1,56 +0,0 @@
import { FeatureRequest, LocationKey, PizzaOrder } from "./Types";
export type ILocationKey = {
locationKey: LocationKey,
}
export type IDayIndex = {
dayIndex?: number,
}
export type AddChoiceRequest = IDayIndex & ILocationKey & {
foodIndex?: number,
}
export type RemoveChoicesRequest = IDayIndex & ILocationKey;
export type RemoveChoiceRequest = IDayIndex & ILocationKey & {
foodIndex: number,
}
export type UpdateNoteRequest = IDayIndex & {
note?: string,
}
export type ChangeDepartureTimeRequest = IDayIndex & {
time: string,
}
export type FinishDeliveryRequest = {
bankAccount?: string,
bankAccountHolder?: string,
}
export type AddPizzaRequest = {
pizzaIndex: number,
pizzaSizeIndex: number,
}
export type RemovePizzaRequest = {
pizzaOrder: PizzaOrder,
}
export type UpdatePizzaDayNoteRequest = {
note?: string,
}
export type UpdatePizzaFeeRequest = {
login: string,
text?: string,
price?: number,
}
export type UpdateFeatureVoteRequest = {
option: FeatureRequest,
active: boolean,
}

View File

@@ -1,236 +0,0 @@
/** Výčtový typ pro restaurace, pro které umíme získat a parsovat obědové menu. */
export enum Restaurants {
SLADOVNICKA = 'sladovnicka',
// UMOTLIKU = 'uMotliku',
TECHTOWER = 'techTower',
ZASTAVKAUMICHALA = 'zastavkaUmichala',
}
export type FoodChoices = {
trusted: boolean,
options: number[],
departureTime?: string,
note?: string,
}
// TODO okomentovat / rozdělit
export type Choices = {
[location in LocationKey]?: {
[login: string]: FoodChoices
}
}
/** Velikost konkrétní pizzy */
export type 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 type Pizza = {
name: string, // název pizzy
ingredients: string[], // seznam ingrediencí
sizes: PizzaSize[], // dostupné velikosti pizzy
}
/** Objednávka jedné konkrétní pizzy */
export type 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 type Order = {
customer: string, // jméno objednatele
pizzaList: PizzaOrder[], // seznam objednaných pizz
fee?: { text?: string, price: number }, // příplatek (např. za extra ingredience)
totalPrice: number, // celková cena všech objednaných pizz, krabic a příplatků
hasQr?: boolean, // true, pokud je k objednávce vygenerován QR kód pro platbu
note?: string, // volitelná uživatelská poznámka k objednávce
}
/** Stav pizza dne */
export enum PizzaDayState {
NOT_CREATED, // Pizza day nebyl založen
CREATED, // Pizza day je založen
LOCKED, // Objednávky uzamčeny
ORDERED, // Pizzy objednány
DELIVERED // Pizzy doručeny
}
/** Informace o pizza day pro dnešní den */
interface PizzaDay {
state: PizzaDayState, // stav pizza dne
creator: string, // jméno zakladatele
orders: Order[], // seznam objednávek jednotlivých lidí
}
/** Index dne v týdnu (0 = pondělí, 6 = neděle) */
// TODO tohle by měl být (seřazený) enum MONDAY-SUNDAY, ne číslo
export const daysOfWeeksIndices = [0, 1, 2, 3, 4, 5, 6] as const;
export type DayOfWeekIndex = typeof daysOfWeeksIndices[number]
const daysOfWeek = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'] as const;
export type DayOfWeek = typeof daysOfWeek[number];
/** Denní menu všech dostupných podniků. */
export type DailyMenu = {
[restaurant in Restaurants]?: RestaurantDailyMenu
}
/** Týdenní menu jednotlivých restaurací. */
export type WeekMenu = {
[dayIndex in DayOfWeek]?: DailyMenu
}
/** Týdenní menu jedné restaurace. */
export type RestaurantWeeklyMenu = {
[key in DayOfWeek]?: Food[]
}
/** Data vztahující se k jednomu konkrétnímu dni. */
export type DayData = {
date: string, // datum dne
isWeekend: boolean, // příznak, zda je datum víkend
weekIndex: DayOfWeekIndex, // index dne v týdnu (0-6)
choices: Choices, // seznam voleb uživatelů
menus?: { [restaurant in Restaurants]?: RestaurantDailyMenu }, // menu jednotlivých restaurací
pizzaDay?: PizzaDay, // pizza day pro dnešní den, pokud existuje
pizzaList?: Pizza[], // seznam dostupných pizz pro dnešní den
pizzaListLastUpdate?: Date, // datum a čas poslední aktualizace pizz
}
/** Veškerá data pro zobrazení na klientovi. */
export type ClientData = DayData & {
todayWeekIndex?: DayOfWeekIndex, // index dnešního dne v týdnu (0-6)
}
/** Nabídka jídel jednoho podniku pro jeden konkrétní den. */
export type RestaurantDailyMenu = {
lastUpdate: number, // UNIX timestamp poslední aktualizace menu
closed: boolean, // příznak, zda je daný podnik v tento den zavřený
food: Food[], // seznam jídel v menu
}
/** Jídlo z obědového menu restaurace. */
export type Food = {
amount?: string, // množství standardní porce, např. 0,33l nebo 150g
name: string, // název/popis jídla
price: string, // cena ve formátu '135 Kč'
isSoup: boolean, // příznak, zda se jedná o polévku
}
// TODO tohle je dost špatné pojmenování, vzhledem k tomu co to obsahuje
export enum Locations {
SLADOVNICKA = 'Sladovnická',
// UMOTLIKU = 'U Motlíků',
TECHTOWER = 'TechTower',
ZASTAVKAUMICHALA = 'Zastávka u Michala',
SPSE = 'SPŠE',
PIZZA = 'Pizza day',
OBJEDNAVAM = 'Budu objednávat',
NEOBEDVAM = 'Mám vlastní/neobědvám',
ROZHODUJI = 'Rozhoduji se',
}
// TODO totéž
export type LocationKey = keyof typeof Locations;
export enum UdalostEnum {
ZAHAJENA_PIZZA = "Zahájen pizza day",
OBJEDNANA_PIZZA = "Objednána pizza",
JDEME_OBED = "Jdeme oběd",
}
export type NotififaceInput = {
udalost: UdalostEnum,
user: string,
}
export type NotifikaceData = {
input: NotififaceInput,
gotify?: boolean,
teams?: boolean,
ntfy?: boolean,
}
export type GotifyServer = {
server: string;
api_keys: string[];
}
/** Čas preferovaného odchodu na oběd. */
export enum DepartureTime {
T10_00 = "10:00",
T10_15 = "10:15",
T10_30 = "10:30",
T10_45 = "10:45",
T11_00 = "11:00",
T11_15 = "11:15",
T11_30 = "11:30",
T11_45 = "11:45",
T12_00 = "12:00",
T12_15 = "12:15",
T12_30 = "12:30",
T12_45 = "12:45",
T13_00 = "13:00",
}
export enum FeatureRequest {
CUSTOM_QR = "Ruční generování QR kódů mimo Pizza day (např. při objednávání)",
FAVORITES = "Možnost označovat si jídla jako oblíbená (taková jídla by se uživateli následně zvýrazňovala)",
SINGLE_PAYMENT = "Možnost úhrady v podniku za všechny jednou osobou a následné generování QR ostatním",
NO_WEEKENDS = "Zrušení \"užívejte víkend\", místo toho umožnit zpětně náhled na uplynulý týden",
QR_FOREVER = "Umožnění zobrazení vygenerovaného QR kódu i po následující dny (dokud ho uživatel ručně \"nezavře\", např. tlačítkem \"Zaplatil jsem\")",
PIZZA_PICTURES = "Zobrazování náhledů (fotografií) pizz v rámci Pizza day",
STATISTICS = "Statistiky (nejoblíbenější podnik, nejpopulárnější jídla, nejobjednávanější pizzy, nejčastější uživatelé, ...)",
RESPONSIVITY = "Vylepšení responzivního designu",
SECURITY = "Zvýšení zabezpečení aplikace",
SAFETY = "Zvýšená ochrana proti chybám uživatele (potvrzovací dialogy, překliky, ...)",
UI = "Celkové vylepšení UI/UX",
DEVELOPMENT = "Zlepšení dokumentace/postupů pro ostatní vývojáře"
}
export type EasterEgg = {
path: string;
url: string;
startOffset: number;
endOffset: number;
duration: number;
width?: string;
zIndex?: number;
position?: "absolute";
animationName?: string;
animationDuration?: string;
animationTimingFunction?: string;
}
// TODO aktuálně se k ničemu nepoužívá
export type AnimationPosition = {
left?: string,
startLeft?: string,
"--start-left"?: string,
right?: string,
startRight?: string,
"--start-right"?: string,
top?: string,
startTop?: string,
"--start-top"?: string,
bottom?: string,
startBottom?: string,
"--start-bottom"?: string,
endLeft?: string,
"--end-left"?: string,
endRight?: string,
"--end-right"?: string,
endTop?: string,
"--end-top"?: string,
endBottom?: string,
"--end-bottom"?: string,
rotate?: string,
}

102
types/api.yml Normal file
View File

@@ -0,0 +1,102 @@
openapi: 3.0.4
info:
title: Luncher API
version: 1.0.0
servers:
- url: /api
paths:
# Obecné (/api)
/login:
$ref: "./paths/login.yml"
/qr:
$ref: "./paths/getPizzaQr.yml"
/qr/generate:
$ref: "./paths/qr/generate.yml"
/data:
$ref: "./paths/getData.yml"
# Restaurace a jídla (/api/food)
/food/addChoice:
$ref: "./paths/food/addChoice.yml"
/food/removeChoice:
$ref: "./paths/food/removeChoice.yml"
/food/updateNote:
$ref: "./paths/food/updateNote.yml"
/food/removeChoices:
$ref: "./paths/food/removeChoices.yml"
/food/changeDepartureTime:
$ref: "./paths/food/changeDepartureTime.yml"
/food/jdemeObed:
$ref: "./paths/food/jdemeObed.yml"
/food/updateBuyer:
$ref: "./paths/food/updateBuyer.yml"
# Pizza day (/api/pizzaDay)
/pizzaDay/create:
$ref: "./paths/pizzaDay/create.yml"
/pizzaDay/delete:
$ref: "./paths/pizzaDay/delete.yml"
/pizzaDay/lock:
$ref: "./paths/pizzaDay/lock.yml"
/pizzaDay/unlock:
$ref: "./paths/pizzaDay/unlock.yml"
/pizzaDay/finishOrder:
$ref: "./paths/pizzaDay/finishOrder.yml"
/pizzaDay/finishDelivery:
$ref: "./paths/pizzaDay/finishDelivery.yml"
/pizzaDay/add:
$ref: "./paths/pizzaDay/addPizza.yml"
/pizzaDay/remove:
$ref: "./paths/pizzaDay/removePizza.yml"
/pizzaDay/updatePizzaDayNote:
$ref: "./paths/pizzaDay/updatePizzaDayNote.yml"
/pizzaDay/updatePizzaFee:
$ref: "./paths/pizzaDay/updatePizzaFee.yml"
/pizzaDay/dismissQr:
$ref: "./paths/pizzaDay/dismissQr.yml"
# Notifikace (/api/notifications)
/notifications/settings:
$ref: "./paths/notifications/settings.yml"
# Easter eggy (/api/easterEggs)
/easterEggs:
$ref: "./paths/easterEggs/easterEggs.yml"
/easterEggs/{url}:
$ref: "./paths/easterEggs/easterEgg.yml"
# Statistiky (/api/stats)
/stats:
$ref: "./paths/stats/stats.yml"
# Hlasování (/api/voting)
/voting/getVotes:
$ref: "./paths/voting/getVotes.yml"
/voting/updateVote:
$ref: "./paths/voting/updateVote.yml"
/voting/stats:
$ref: "./paths/voting/getVotingStats.yml"
# DEV endpointy (/api/dev)
/dev/generate:
$ref: "./paths/dev/generate.yml"
/dev/clear:
$ref: "./paths/dev/clear.yml"
components:
schemas:
$ref: "./schemas/_index.yml"
responses:
ClientDataResponse:
description: Aktuální data pro klienta
content:
application/json:
schema:
$ref: "./schemas/_index.yml#/ClientData"
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
security:
- bearerAuth: []

View File

@@ -1,2 +1 @@
export * from './Types'; export * from './gen';
export * from './RequestTypes';

View File

@@ -0,0 +1,14 @@
import { defaultPlugins } from '@hey-api/openapi-ts';
export default {
input: 'api.yml',
output: 'gen',
plugins: [
...defaultPlugins,
'@hey-api/client-fetch',
{
enums: 'javascript',
name: '@hey-api/typescript',
},
],
};

11
types/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "@luncher/types",
"version": "1.0.0",
"license": "MIT",
"private": true,
"devDependencies": {
"@hey-api/client-fetch": "^0.8.2",
"@hey-api/openapi-ts": "^0.64.7",
"typescript": "^5.9.3"
}
}

23
types/paths/dev/clear.yml Normal file
View File

@@ -0,0 +1,23 @@
post:
operationId: clearMockData
summary: Smazání všech voleb pro daný den (pouze DEV režim)
requestBody:
required: false
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/ClearMockDataRequest"
responses:
"200":
description: Data byla úspěšně smazána
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
dayIndex:
type: integer
"403":
description: Endpoint není dostupný v tomto režimu

View File

@@ -0,0 +1,25 @@
post:
operationId: generateMockData
summary: Vygenerování mock dat pro testování (pouze DEV režim)
requestBody:
required: false
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/GenerateMockDataRequest"
responses:
"200":
description: Mock data byla úspěšně vygenerována
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
count:
type: integer
dayIndex:
type: integer
"403":
description: Endpoint není dostupný v tomto režimu

View File

@@ -0,0 +1,18 @@
get:
operationId: getEasterEggImage
summary: Vrátí obrázek konkrétního easter eggu
parameters:
- in: path
name: url
required: true
schema:
type: string
description: URL easter eggu
responses:
"200":
content:
image/png:
description: Obrázek easter eggu
schema:
type: string
format: binary

View File

@@ -0,0 +1,9 @@
get:
operationId: getEasterEgg
summary: Vrátí náhodně metadata jednoho z definovaných easter egg obrázků pro přihlášeného uživatele, nebo nic, pokud žádné definované nemá.
responses:
"200":
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/EasterEgg"

View File

@@ -0,0 +1,20 @@
post:
operationId: addChoice
summary: Přidání či nahrazení volby uživatele pro zvolený den/podnik
requestBody:
required: true
content:
application/json:
schema:
required:
- locationKey
properties:
locationKey:
$ref: "../../schemas/_index.yml#/LunchChoice"
dayIndex:
$ref: "../../schemas/_index.yml#/DayIndex"
foodIndex:
$ref: "../../schemas/_index.yml#/FoodIndex"
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"

View File

@@ -0,0 +1,16 @@
post:
operationId: changeDepartureTime
summary: Úprava preferovaného času odchodu do aktuálně zvoleného podniku.
requestBody:
required: true
content:
application/json:
schema:
properties:
dayIndex:
$ref: "../../schemas/_index.yml#/DayIndex"
time:
$ref: "../../schemas/_index.yml#/DepartureTime"
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"

View File

@@ -0,0 +1,6 @@
post:
operationId: jdemeObed
summary: Odeslání notifikací "jdeme na oběd" dle konfigurace.
responses:
"200":
description: Notifikace byly odeslány.

View File

@@ -0,0 +1,21 @@
post:
operationId: removeChoice
summary: Odstranění jednoho zvoleného jídla uživatele pro zvolený den/podnik
requestBody:
required: true
content:
application/json:
schema:
required:
- foodIndex
- locationKey
properties:
foodIndex:
$ref: "../../schemas/_index.yml#/FoodIndex"
locationKey:
$ref: "../../schemas/_index.yml#/LunchChoice"
dayIndex:
$ref: "../../schemas/_index.yml#/DayIndex"
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"

View File

@@ -0,0 +1,18 @@
post:
operationId: removeChoices
summary: Odstranění volby uživatele pro zvolený den/podnik, včetně případných jídel
requestBody:
required: true
content:
application/json:
schema:
required:
- locationKey
properties:
locationKey:
$ref: "../../schemas/_index.yml#/LunchChoice"
dayIndex:
$ref: "../../schemas/_index.yml#/DayIndex"
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"

Some files were not shown because too many files have changed in this diff Show More