36 Commits

Author SHA1 Message Date
theis.gaedigk 0953847f24 Merge branch 'dev' into prod 2026-05-10 21:08:23 +02:00
theis.gaedigk 9e7fc530b5 Merge branch 'dev' into prod 2026-05-10 21:05:13 +02:00
theis.gaedigk 18777e5f7c added allowed ips 2026-05-09 23:10:47 +02:00
theis.gaedigk b55129dfff closed ports 2026-05-09 23:06:17 +02:00
theis.gaedigk 26856ee1df edited 2026-05-09 23:03:56 +02:00
theis.gaedigk dd1d8d8d6b edited network config 2026-05-09 21:52:39 +02:00
theis.gaedigk cf2df0aaac edited code design 2026-05-09 21:46:01 +02:00
theis.gaedigk 1199d6468f noted out public web-ui port 2026-05-09 21:45:19 +02:00
theis.gaedigk 7cd958c31e edited ip adresses 2026-05-09 21:43:12 +02:00
theis.gaedigk f89cf84a38 edited docker config 2026-05-09 21:38:56 +02:00
theis.gaedigk e3fc1d8659 edited again 2026-05-09 21:30:59 +02:00
theis.gaedigk 060f8d01c6 edited again 2026-05-09 21:27:47 +02:00
theis.gaedigk 667609d70c fixed docker config 2026-05-09 21:24:42 +02:00
theis.gaedigk b05f19acd9 edited docker compose 2026-05-09 21:22:28 +02:00
theis.gaedigk 2aa9a968f5 Merge branch 'dev' into prod 2026-05-04 22:40:33 +02:00
theis.gaedigk e42a2f510a edited docker compose 2026-05-04 22:05:18 +02:00
theis.gaedigk d2b22fc71f Merge branch 'dev' into prod 2026-05-04 22:04:26 +02:00
theis.gaedigk 471c0c7a49 Merge branch 'dev' into prod 2026-01-21 16:33:03 +01:00
theis.gaedigk 75ff65e76b Merge branch 'dev' into prod 2026-01-21 16:28:23 +01:00
theis.gaedigk 7cf1245ef6 Merge branch 'dev' into prod 2026-01-21 14:27:37 +01:00
theis.gaedigk 2adbfa75a5 Merge branch 'dev' into prod 2026-01-21 14:07:26 +01:00
theis.gaedigk 216a1cb1d4 Merge branch 'dev' into prod 2026-01-20 20:43:59 +01:00
theis.gaedigk 7fc98d6c9f Merge branch 'dev' into prod 2026-01-20 20:34:52 +01:00
theis.gaedigk e346cf9445 e 2026-01-20 20:33:41 +01:00
theis.gaedigk c030b6dbe6 Merge branch 'dev' into prod 2026-01-20 20:33:31 +01:00
theis.gaedigk 6f26b9bbc3 e 2026-01-20 20:22:59 +01:00
theis.gaedigk a34a70572f edited 2026-01-20 20:19:12 +01:00
theis.gaedigk 4b3c8a2424 edited compose file 2026-01-20 20:17:53 +01:00
theis.gaedigk 568b3bf495 edited 2026-01-20 20:08:14 +01:00
theis.gaedigk 5653d32857 fix: update WireGuard PASSWORD_HASH to a static value 2026-01-20 20:06:44 +01:00
theis.gaedigk 7cf5b8df48 Merge branch 'dev' into prod 2026-01-20 20:03:51 +01:00
theis.gaedigk 65c5fc0f8f Merge branch 'dev' into prod 2026-01-20 19:59:27 +01:00
theis.gaedigk b626a67907 Merge branch 'dev' into prod 2026-01-20 19:46:56 +01:00
theis.gaedigk 6643a176a6 Merge branch 'dev' into prod 2026-01-20 19:43:53 +01:00
theis.gaedigk 89803754a7 Merge branch 'dev' into prod 2026-01-20 19:38:27 +01:00
theis.gaedigk 5052b3e83a changed fetch urls 2026-01-20 19:23:53 +01:00
27 changed files with 568 additions and 1015 deletions
-3
View File
@@ -1,3 +0,0 @@
[submodule "wg-easy-ca-lose"]
path = wg-easy-ca-lose
url = https://git.the1s.de/theis.gaedigk/wg-easy-ca-lose.git
+133 -67
View File
@@ -1,98 +1,164 @@
# LuckySign # CA-Lose
Ticket intake and validation app with a React frontend and an Express + MySQL backend. CA-Lose is a small full-stack app for collecting ticket-sale/order form entries.
## Tech Stack ## Tech stack
![React](https://img.shields.io/badge/React-61DAFB?logo=react&logoColor=000&style=flat) ![React](https://img.shields.io/badge/React-20232A?logo=react&logoColor=61DAFB)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=fff&style=flat) ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=fff)
![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=fff&style=flat) ![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=fff)
![Tailwind%20CSS](https://img.shields.io/badge/Tailwind%20CSS-38B2AC?logo=tailwindcss&logoColor=fff&style=flat) ![Tailwind CSS](https://img.shields.io/badge/Tailwind%20CSS-06B6D4?logo=tailwindcss&logoColor=fff)
![MUI](https://img.shields.io/badge/MUI-007FFF?logo=mui&logoColor=fff&style=flat) ![MUI](https://img.shields.io/badge/MUI-007FFF?logo=mui&logoColor=fff)
![TanStack%20Query](https://img.shields.io/badge/TanStack%20Query-FF4154?logo=reactquery&logoColor=fff&style=flat) ![i18next](https://img.shields.io/badge/i18next-26A69A?logo=i18next&logoColor=fff)
![React%20Router](https://img.shields.io/badge/React%20Router-CA4245?logo=reactrouter&logoColor=fff&style=flat)
[![TanStack%20Form](https://img.shields.io/badge/TanStack%20Form-EC5990?logo=reacthookform&logoColor=fff)](#)
![Node.js](https://img.shields.io/badge/Node.js-339933?logo=nodedotjs&logoColor=fff&style=flat)
![Express](https://img.shields.io/badge/Express-000000?logo=express&logoColor=fff&style=flat)
![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=fff&style=flat)
![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff&style=flat)
![Nginx](https://img.shields.io/badge/Nginx-009639?logo=nginx&logoColor=fff&style=flat)
### Production Tech Stack ![Node.js](https://img.shields.io/badge/Node.js-339933?logo=node.js&logoColor=fff)
![Express](https://img.shields.io/badge/Express-000000?logo=express&logoColor=fff)
![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=fff)
> **Note** For production, check the [prod branch](https://git.the1s.de/theis.gaedigk/ca-lose/src/branch/prod/) which contains an VPN git submodule of wg-easy to run the app securely on a private network. It also contains a dnsmasq container to resolve the backend service name from the frontend container. The main branch is meant for local development and testing, so it doesn't include those components to keep things simple. ![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff)
![Nginx](https://img.shields.io/badge/Nginx-009639?logo=nginx&logoColor=fff)
![dnsmasq](https://img.shields.io/badge/dnsmasq-314B5F?logo=isc&logoColor=fff&style=flat) ## Architecture
![WireGuard](https://img.shields.io/badge/WireGuard-88171A?logo=wireguard&logoColor=fff&style=flat)
## Project Structure - **Frontend**: React + TypeScript + Vite (UI uses MUI + Tailwind, i18n via i18next)
- **Backend**: Node.js + Express (also renders a minimal EJS page at `/`)
- **Database**: MySQL 8
- Frontend (Vite + React + Tailwind + MUI): [frontend](frontend) In production, the frontend is served by **Nginx** and proxies `/backend/*` to the backend container.
- Backend (Express): [backend](backend)
- Database schema: [backend/scheme.sql](backend/scheme.sql)
- Docker compose stack: [docker-compose.yml](docker-compose.yml)
## Quick Start (Docker) ## Repos & folders
1. Set the database password env var used by Docker Compose: ```
backend/ # Express API + EJS views
```bash frontend/ # React app (Vite) + Nginx container
DB_PASSWORD=your_password docker-compose.yml
docker-compose.prod.yml
``` ```
2. Start MySQL and the backend: ## API overview
The backend router is mounted at `/default`:
- `GET /default/users` → returns `{ users: string[] }`
- `GET /default/confirm-user?username=<name>` → validates user and returns metadata (e.g. next id)
- `POST /default/new-entry?username=<name>` → stores a new entry in MySQL
## Configuration
### Backend environment variables
The backend reads configuration from environment variables (and can also load a `backend/.env` file via `dotenv`).
- `PORT` (e.g. `8004`)
- `DB_HOST` (e.g. `ca-lose-mysql` when using Docker Compose)
- `DB_USER` (e.g. `root`)
- `DB_PASSWORD`
- `DB_NAME` (e.g. `ca_lose`)
### Docker Compose environment
Both compose files expect `DB_PASSWORD` to be present in your shell environment or a root `.env` file.
Create a root `.env` (not committed) like:
```env
DB_PASSWORD=change-me
```
## Local development
### Option A: Docker Compose (backend + database)
This is the quickest way to get the API + MySQL running.
```bash ```bash
docker compose up -d docker compose up --build
```
What you get:
- Backend: http://localhost:8004
- MySQL: localhost:3311 (container port 3306)
Note: In `docker-compose.yml` the `frontend` service is currently commented out.
### Option B: Run frontend locally (Vite) + run backend & DB
1. Start DB + backend (Docker):
```bash
docker compose up --build database backend
```
2. Start frontend (local Node):
```bash
cd frontend
npm ci
npm run dev
```
Open the Vite dev server URL (usually http://localhost:5173).
Important:
- During local development (Vite), the frontend currently calls the backend via a hard-coded URL (`http://localhost:8004`).
- When the frontend is served by the Nginx container, requests are expected to go through the Nginx proxy at `/backend/*` (see `frontend/nginx.conf`). In that setup the frontend should use `/backend/default/...` instead of `http://localhost:8004/...`.
## Database setup
The schema file is in `backend/scheme.sql`.
- The `users` table is required for the user dropdown.
- The backend creates an entry table dynamically per user and date when calling `GET /default/confirm-user`.
Example (inside MySQL):
```sql
INSERT INTO users (username, first_name, last_name)
VALUES ('demo', 'Demo', 'User');
```
## Production / Deployment
The production compose file builds and runs:
- `frontend` (Nginx)
- `backend` (Express)
- `database` (MySQL)
- optional infrastructure: `dnsmasq` and `wireguard` (wg-easy)
Start it:
```bash
docker compose -f docker-compose.prod.yml up -d --build
``` ```
Notes: Notes:
- The frontend service is commented out in [docker-compose.yml](docker-compose.yml). If you want the frontend container, uncomment that block and rebuild. - `docker-compose.prod.yml` uses an **external** network called `proxynet`. Create it if it does not exist:
- The frontend container uses Nginx and proxies /backend to the backend service (see [frontend/nginx.conf](frontend/nginx.conf)).
- In order to use the database properly, run the scheme and create some users in the users table.
## Local Development
### Backend
1. Create a .env file in the backend folder with:
```env
PORT=8004
DB_HOST=127.0.0.1
DB_USER=root
DB_PASSWORD=your_password
DB_NAME=ca_lose
```
2. Install deps and run:
```bash ```bash
cd backend docker network create proxynet
npm install
node server.js
``` ```
Backend listens on http://localhost:8004 and exposes routes under /default. - The production file assigns static IPs; adjust the subnets/IPs if they conflict with your environment.
### Frontend ## Lint / build
Frontend:
```bash ```bash
cd frontend cd frontend
npm install npm run lint
npm run dev npm run build
``` ```
Vite runs the app on the default dev port and talks to the backend using the API helpers in [frontend/src/utils/api](frontend/src/utils/api). ## Troubleshooting
## API Endpoints - **Backend does not start / ESM imports**: The backend code uses ESM `import` syntax. Node must treat the backend as an ESM project (commonly by adding `"type": "module"` to `backend/package.json`). Without that, the backend will fail to start (including in Docker).
- **MySQL timezone mount**: The compose files mount `./mysql-timezone.cnf` to `/etc/mysql/conf.d/timezone.cnf`. Ensure `mysql-timezone.cnf` is a file (not a directory) with valid MySQL config, or remove/adjust the mount.
- GET /default/users ## License
- GET /default/confirm-user?username={name}
- POST /default/new-entry?username={name}
## Database Notes Licensed under the Apache License 2.0. See [LICENSE](LICENSE).
- The base users table is defined in [backend/scheme.sql](backend/scheme.sql).
- Per-user ticket tables are created on demand by the backend (see [backend/routes/default/frontend.data.js](backend/routes/default/frontend.data.js)).
+4 -5
View File
@@ -28,9 +28,6 @@ export const confirmUser = async (username) => {
]); ]);
if (rows.length > 0) { if (rows.length > 0) {
const { first_name, last_name } = rows[0];
const fullname = first_name + " " + last_name;
// creating userTicketTable // creating userTicketTable
const d = new Date(); const d = new Date();
@@ -40,6 +37,8 @@ export const confirmUser = async (username) => {
const date = `${day}_${month}_${year}`; const date = `${day}_${month}_${year}`;
const tableName = `${username}_${date}`; const tableName = `${username}_${date}`;
console.log(tableName);
const [createTable] = await pool.query( const [createTable] = await pool.query(
`CREATE TABLE IF NOT EXISTS ?? ( `CREATE TABLE IF NOT EXISTS ?? (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -71,9 +70,9 @@ export const confirmUser = async (username) => {
nextID = rows.length > 0 ? rows[0].id + 1 : 1; nextID = rows.length > 0 ? rows[0].id + 1 : 1;
}; };
await getNextID(); await getNextID();
return { success: true, nextID, tableName, fullname }; return { success: true, nextID, tableName };
} else { } else {
return { success: false, message: "Table creation failed", fullname }; return { success: false, message: "Table creation failed" };
} }
} else { } else {
return null; return null;
+2
View File
@@ -11,6 +11,8 @@ router.post("/new-entry", async (req, res) => {
if (!result.success) { if (!result.success) {
return res.status(500).json({ message: "Form Data Invalid" }); return res.status(500).json({ message: "Form Data Invalid" });
} }
console.log(req.body);
console.log(username);
res.sendStatus(204); res.sendStatus(204);
}); });
+75 -12
View File
@@ -1,16 +1,19 @@
services: services:
# frontend: frontend:
# container_name: ca-lose-frontend container_name: ca-lose-frontend
# build: ./frontend hostname: lose-verkaufen
# ports: build: ./frontend
# - "8002:80" depends_on:
# restart: unless-stopped - backend
networks:
ca-lose-internal:
ipv4_address: 172.25.0.2
restart: unless-stopped
backend: backend:
container_name: ca-lose-backend container_name: ca-lose-backend
hostname: backend
build: ./backend build: ./backend
ports:
- "8004:8004"
environment: environment:
NODE_ENV: production NODE_ENV: production
DB_HOST: ca-lose-mysql DB_HOST: ca-lose-mysql
@@ -19,21 +22,81 @@ services:
DB_NAME: ca_lose DB_NAME: ca_lose
depends_on: depends_on:
- database - database
networks:
ca-lose-internal:
ipv4_address: 172.25.0.3
restart: unless-stopped restart: unless-stopped
database: database:
container_name: ca-lose-mysql container_name: ca-lose-mysql
hostname: database
image: mysql:8.0 image: mysql:8.0
restart: unless-stopped restart: unless-stopped
ports:
- "3311:3306"
environment: environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD} MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: ca_lose MYSQL_DATABASE: ca_lose
TZ: Europe/Berlin TZ: Europe/Berlin
volumes: volumes:
- ca-lose_mysql:/var/lib/mysql - ../docker/volumes/ca-lose_mysql:/var/lib/mysql
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro - ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
networks:
ca-lose-internal:
ipv4_address: 172.25.0.4
# DNS Server für Hostname-Auflösung innerhalb des VPN
dnsmasq:
container_name: ca-lose-dns
image: andyshinn/dnsmasq:latest
restart: unless-stopped
cap_add:
- NET_ADMIN
command: >
--no-daemon
--log-queries
--address=/lose-verkaufen/172.25.0.2
--address=/frontend/172.25.0.2
--address=/backend/172.25.0.3
--address=/database/172.25.0.4
--address=/wireguard/172.25.0.6
networks:
ca-lose-internal:
ipv4_address: 172.25.0.5
# WireGuard VPN mit Web-UI (wg-easy)
wireguard:
image: ghcr.io/wg-easy/wg-easy:latest
container_name: ca-lose-wireguard
cap_add:
- NET_ADMIN
- SYS_MODULE
environment:
LANG: de
WG_HOST: dus3.the1s.de
WG_PORT: "51830"
WG_DEFAULT_DNS: "172.25.0.5"
WG_ALLOWED_IPS: 172.25.0.0/24
PORT: "80" # Web-UI Port
PASSWORD_HASH: ${WIREGUARD_PASSWORD_HASH}
volumes: volumes:
ca-lose_mysql: - ../docker/volumes/ca-lose-wireguard:/etc/wireguard
- /lib/modules:/lib/modules:ro
ports:
- "51830:51830/udp"
# - "51831:80/tcp" # only for short configuration access - remove in production - external: 51831 internal: 80
sysctls:
- net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1
restart: unless-stopped
depends_on:
- dnsmasq
networks:
ca-lose-internal:
ipv4_address: 172.25.0.6
networks:
ca-lose-internal:
driver: bridge
ipam:
config:
- subnet: 172.25.0.0/24
gateway: 172.25.0.1
+23 -153
View File
@@ -16,19 +16,14 @@
"@mui/joy": "^5.0.0-beta.52", "@mui/joy": "^5.0.0-beta.52",
"@mui/material": "^9.0.1", "@mui/material": "^9.0.1",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@tanstack/react-form": "^1.32.0",
"@tanstack/react-query": "^5.100.10",
"i18next": "^26.0.10", "i18next": "^26.0.10",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"k6": "^0.0.0",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-i18next": "^17.0.7", "react-i18next": "^17.0.7",
"react-router-dom": "^7.15.0", "react-router-dom": "^7.15.0",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0"
"validator": "^13.15.35",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@@ -36,7 +31,6 @@
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/validator": "^13.15.10",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1", "eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
@@ -1772,126 +1766,6 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8" "vite": "^5.2.0 || ^6 || ^7 || ^8"
} }
}, },
"node_modules/@tanstack/devtools-event-client": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.3.tgz",
"integrity": "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw==",
"license": "MIT",
"bin": {
"intent": "bin/intent.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/form-core": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.32.0.tgz",
"integrity": "sha512-Tn5VRDSjyqjmaet2tJMuEWDRFyrCaon03vxXPlSSaiSs6C/N7lCIwGCXJbZXEUq1kTj8jYN9qyXHbsz4LQHcow==",
"license": "MIT",
"dependencies": {
"@tanstack/devtools-event-client": "^0.4.1",
"@tanstack/pacer-lite": "^0.1.1",
"@tanstack/store": "^0.9.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/pacer-lite": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.1.1.tgz",
"integrity": "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.100.10",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz",
"integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-form": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.32.0.tgz",
"integrity": "sha512-6WP5SQTA6/H9crCpvpq3ZppYWqtrdE5NjOy6ebABi6uAQPqhfTzrdjS9t40mCZCFtGI5585OhJV6zBP/KN2zcw==",
"license": "MIT",
"dependencies": {
"@tanstack/form-core": "1.32.0",
"@tanstack/react-store": "^0.9.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@tanstack/react-start": {
"optional": true
}
}
},
"node_modules/@tanstack/react-query": {
"version": "5.100.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz",
"integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.100.10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-store": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz",
"integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==",
"license": "MIT",
"dependencies": {
"@tanstack/store": "0.9.3",
"use-sync-external-store": "^1.6.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/store": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz",
"integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.2", "version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -1980,13 +1854,6 @@
"@types/react": "*" "@types/react": "*"
} }
}, },
"node_modules/@types/validator": {
"version": "13.15.10",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.2", "version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz",
@@ -3109,12 +2976,12 @@
} }
}, },
"node_modules/js-cookie": { "node_modules/js-cookie": {
"version": "3.0.7", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==", "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=20" "node": ">=14"
} }
}, },
"node_modules/js-tokens": { "node_modules/js-tokens": {
@@ -3175,12 +3042,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/k6": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/k6/-/k6-0.0.0.tgz",
"integrity": "sha512-GAQSWayS2+LjbH5bkRi+pMPYyP1JSp7o+4j58ANZ762N/RH/SdlAT3CHHztnn8s/xgg8kYNM24Gd2IPo9b5W+g==",
"license": "AGPL-3.0"
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4198,15 +4059,6 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/validator": {
"version": "13.15.35",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
"integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.11", "version": "8.0.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
@@ -4326,6 +4178,23 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": {
"version": "2.8.4",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
@@ -4343,6 +4212,7 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
+1 -7
View File
@@ -18,19 +18,14 @@
"@mui/joy": "^5.0.0-beta.52", "@mui/joy": "^5.0.0-beta.52",
"@mui/material": "^9.0.1", "@mui/material": "^9.0.1",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@tanstack/react-form": "^1.32.0",
"@tanstack/react-query": "^5.100.10",
"i18next": "^26.0.10", "i18next": "^26.0.10",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"k6": "^0.0.0",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-i18next": "^17.0.7", "react-i18next": "^17.0.7",
"react-router-dom": "^7.15.0", "react-router-dom": "^7.15.0",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0"
"validator": "^13.15.35",
"zod": "^4.4.3"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
@@ -42,7 +37,6 @@
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/validator": "^13.15.10",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1", "eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
-4
View File
@@ -6,7 +6,3 @@ body,
height: 100%; height: 100%;
margin: 0; margin: 0;
} }
body.success-bg {
background: linear-gradient(135deg, #0f172a, #111827);
}
-6
View File
@@ -2,21 +2,15 @@ import "./App.css";
import { BrowserRouter, Route, Routes } from "react-router-dom"; import { BrowserRouter, Route, Routes } from "react-router-dom";
import { MainForm } from "./pages/MainForm"; import { MainForm } from "./pages/MainForm";
import { SuccessPage } from "./pages/SuccessPage"; import { SuccessPage } from "./pages/SuccessPage";
import { PageFooter } from "./components/PageFooter";
function App() { function App() {
return ( return (
<div className="min-h-screen flex flex-col">
<BrowserRouter> <BrowserRouter>
<main className="flex-1 flex">
<Routes> <Routes>
<Route path="/" element={<MainForm />} /> <Route path="/" element={<MainForm />} />
<Route path="/success" element={<SuccessPage />} /> <Route path="/success" element={<SuccessPage />} />
</Routes> </Routes>
</main>
</BrowserRouter> </BrowserRouter>
<PageFooter />
</div>
); );
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

-42
View File
@@ -1,42 +0,0 @@
import { Link, Sheet, Typography } from "@mui/joy";
import { useTranslation } from "react-i18next";
import qrCode from "../assets/Portfolio-QR-Code.png";
export const PageFooter = () => {
const { t } = useTranslation();
return (
<footer className="w-full mt-auto px-3 pb-3">
<Sheet
variant="soft"
className="mx-auto w-full max-w-3xl rounded-2xl border border-slate-200/70 bg-white/80 backdrop-blur"
>
<div className="flex flex-col gap-2 p-2.5 sm:p-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Typography
level="title-sm"
className="text-slate-800 tracking-wide sm:pr-4"
>
{t("footer-headline")}
<Link
href="https://portfolio-theis.de/"
target="_blank"
rel="noreferrer"
className="ml-2 inline-flex items-center text-slate-700 underline decoration-slate-300 underline-offset-4 hover:text-slate-900"
>
portfolio-theis.de
</Link>
</Typography>
<div className="flex items-center justify-center sm:justify-end sm:flex-none">
<img
src={qrCode}
alt="https://portfolio-theis.de/"
className="h-20 w-20 shrink-0 rounded-md border border-slate-200 object-contain"
/>
</div>
</div>
</div>
</Sheet>
</footer>
);
};
-31
View File
@@ -1,31 +0,0 @@
import type { TextFieldProps } from "../config/interfaces.config";
import { FormControl, FormLabel, Input } from "@mui/joy";
export const TextField = ({
label,
type = "text",
required,
errors,
value,
onBlur,
onChange,
slotProps,
afterInput,
}: TextFieldProps) => (
<FormControl required={required}>
<FormLabel>{label}</FormLabel>
<Input
value={value ?? ""}
onBlur={onBlur}
onChange={(e) => onChange(e.target.value)}
type={type}
variant="soft"
sx={{ borderRadius: "10px" }}
slotProps={slotProps}
/>
{afterInput}
{errors[0] ? (
<span className="text-red-500 text-sm">{errors[0]}</span>
) : null}
</FormControl>
);
@@ -1,31 +0,0 @@
import { Modal, ModalDialog, Typography, ModalClose } from "@mui/joy";
import { useTranslation } from "react-i18next";
import qrCode from "../../assets/PayPal-QR-Code.png";
interface QRcodeModalProps {
QRmodal: boolean;
setQRmodal: (value: boolean) => void;
}
export const QRcodeModal = (props: QRcodeModalProps) => {
const { t } = useTranslation();
return (
<Modal open={props.QRmodal}>
<ModalDialog color="primary" layout="center" size="lg">
<ModalClose onClick={() => props.setQRmodal(false)} />
<Typography>{t("qr-text")}</Typography>
<img
src={qrCode}
alt="PayPal QR Code"
style={{
width: "100%",
height: "auto",
maxHeight: "70vh",
objectFit: "contain",
}}
/>
</ModalDialog>
</Modal>
);
};
@@ -1,40 +0,0 @@
import {
Modal,
ModalDialog,
Typography,
ModalClose,
Autocomplete,
} from "@mui/joy";
import { useTranslation } from "react-i18next";
interface SelectUserModalProps {
showSelectUser: boolean;
setShowSelectUser: (value: boolean) => void;
usernameData: { users: string[] };
usernameDataIsLoading: boolean;
selectedUser: string | null;
handleUserSelection: (value: string | null) => void;
}
export const SelectUserModal = (props: SelectUserModalProps) => {
const { t } = useTranslation();
return (
<Modal open={props.showSelectUser}>
<ModalDialog color="primary" layout="center" size="lg">
<ModalClose onClick={() => props.setShowSelectUser(false)} />
<Typography>{t("user")}</Typography>
{/* User selection */}
<Autocomplete
options={props.usernameData?.users ?? []}
loading={props.usernameDataIsLoading}
loadingText={t("loading")}
value={props.selectedUser}
onChange={(_, value) => props.handleUserSelection(value)}
placeholder={t("user")}
variant="soft"
sx={{ borderRadius: "10px" }}
/>
</ModalDialog>
</Modal>
);
};
-48
View File
@@ -1,7 +1,3 @@
import z from "zod";
import validator from "validator";
import type { ReactNode } from "react";
export interface FormData { export interface FormData {
firstName: string; firstName: string;
lastName: string; lastName: string;
@@ -23,47 +19,3 @@ export interface Message {
headline: string; headline: string;
text: string; text: string;
} }
export type TextFieldProps = {
label: string;
type?: "text" | "email" | "tel" | "number";
required?: boolean;
errors: string[];
onBlur: () => void;
onChange: (value: string) => void;
value: string | number | null | undefined;
slotProps?: { input?: Record<string, unknown> };
afterInput?: ReactNode;
};
export const createFormSchema = (
t: (key: string) => string,
invoice: boolean,
) =>
z.object({
firstName: z.string().min(1, t("name-error")),
lastName: z.string().min(1, t("name-error")),
email: z.email(t("email-error")),
phoneNumber: z.string(t("phone-error")).refine(validator.isMobilePhone),
tickets: z.number(t("ticket-error")).min(1),
companyName: invoice
? z.string().min(1, t("name-error"))
: z.string().optional(),
cmpFirstName: invoice
? z.string().min(1, t("name-error"))
: z.string().optional(),
cpmLastName: invoice
? z.string().min(1, t("name-error"))
: z.string().optional(),
cpmEmail: invoice ? z.email(t("email-error")) : z.string().optional(),
cpmPhoneNumber: invoice
? z.string(t("phone-error")).refine(validator.isMobilePhone)
: z.string().optional(),
street: invoice
? z.string().min(1, t("name-error"))
: z.string().optional(),
postalCode: invoice
? z.string().min(1, t("name-error"))
: z.string().optional(),
paymentMethod: z.string().min(1, t("name-error")),
});
-5
View File
@@ -3,14 +3,9 @@ import { createRoot } from "react-dom/client";
import "./utils/i18n/index.ts"; import "./utils/i18n/index.ts";
import "./index.css"; import "./index.css";
import App from "./App.tsx"; import App from "./App.tsx";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}>
<App /> <App />
</QueryClientProvider>
</StrictMode>, </StrictMode>,
); );
+225 -354
View File
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import * as React from "react";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { import {
Sheet, Sheet,
@@ -12,23 +13,19 @@ import {
Typography, Typography,
FormControl, FormControl,
FormLabel, FormLabel,
Autocomplete,
ButtonGroup, ButtonGroup,
CircularProgress, Modal,
ModalDialog,
ModalClose,
} from "@mui/joy"; } from "@mui/joy";
import { submitFormData } from "../utils/api/form"; import { submitFormData } from "../utils/sender";
import { API_BASE } from "../config/api.config";
import type { FormData, Message } from "../config/interfaces.config"; import type { FormData, Message } from "../config/interfaces.config";
import PersonIcon from "@mui/icons-material/Person"; import PersonIcon from "@mui/icons-material/Person";
import QrCodeIcon from "@mui/icons-material/QrCode"; import QrCodeIcon from "@mui/icons-material/QrCode";
import TranslateIcon from "@mui/icons-material/Translate"; import TranslateIcon from "@mui/icons-material/Translate";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import qrCode from "../assets/PayPal-QR-Code.png";
import { confirmUser, fetchUsers } from "../utils/api/users";
import { QRcodeModal } from "../components/modals/QR-CodeModal";
import { SelectUserModal } from "../components/modals/SelectUserModal";
import { useForm } from "@tanstack/react-form";
import { changeTranslation } from "../utils/uxFncs";
import { createFormSchema } from "../config/interfaces.config";
import type { ZodObject, ZodRawShape } from "zod";
import { TextField } from "../components/TextField";
const PAYMENT_METHODS = ["bar", "paypal", "andere"] as const; const PAYMENT_METHODS = ["bar", "paypal", "andere"] as const;
const PAYMENT_LABELS: Record<string, string> = { const PAYMENT_LABELS: Record<string, string> = {
@@ -37,56 +34,7 @@ const PAYMENT_LABELS: Record<string, string> = {
andere: "Transfer", andere: "Transfer",
}; };
/** const DEFAULT_FORM: FormData = {
* Validates a single field against the full Zod schema.
* Returns the first error message for that field, or undefined if valid.
*/
function validateFieldWithZod(
schema: ZodObject<ZodRawShape>,
fieldName: string,
allValues: Record<string, unknown>,
): string | undefined {
const result = schema.safeParse(allValues);
if (result.success) return undefined;
const issue = result.error.issues.find(
(i) => i.path.length === 1 && i.path[0] === fieldName,
);
return issue?.message;
}
export const MainForm = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [invoice, setInvoice] = useState(false);
const [msg, setMsg] = useState<Message | null>(null);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const [showSelectUser, setShowSelectUser] = useState(false);
const [QRmodal, setQRmodal] = useState(false);
const formSchema = createFormSchema(t, invoice);
const makeFieldValidator = (fieldName: string) => ({
onSubmit: ({
fieldApi,
}: {
fieldApi: { form: { state: { values: Record<string, unknown> } } };
}) => {
const allValues = fieldApi.form.state.values;
return validateFieldWithZod(formSchema, fieldName, allValues);
},
onBlur: ({
fieldApi,
}: {
fieldApi: { form: { state: { values: Record<string, unknown> } } };
}) => {
const allValues = fieldApi.form.state.values;
return validateFieldWithZod(formSchema, fieldName, allValues);
},
});
const { Field, Subscribe, handleSubmit, setFieldValue } = useForm({
defaultValues: {
firstName: "", firstName: "",
lastName: "", lastName: "",
email: "", email: "",
@@ -100,91 +48,179 @@ export const MainForm = () => {
street: "", street: "",
postalCode: "", postalCode: "",
paymentMethod: "", paymentMethod: "",
},
onSubmit: async ({ value }) => {
const result = formSchema.safeParse(value);
if (!result.success) return;
mutateForm(value as FormData);
},
});
const getErrors = (field: {
state: { meta: { errorMap: Record<string, unknown> } };
}) => {
const normalizeErrors = (value: unknown) => {
if (Array.isArray(value)) return value as string[];
if (typeof value === "string" && value.length > 0) return [value];
return [] as string[];
}; };
const blurErrors = normalizeErrors(field.state.meta.errorMap["onBlur"]); // ─── Field component lives OUTSIDE MainForm so React doesn't treat it as a
const submitErrors = normalizeErrors(field.state.meta.errorMap["onSubmit"]); // new component type on every render, which would cause inputs to lose focus.
return [...blurErrors, ...submitErrors].filter(Boolean); const Field = ({
label,
name,
type = "text",
required = true,
formData,
onChange,
}: {
label: string;
name: keyof FormData;
type?: string;
required?: boolean;
formData: FormData;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) => (
<FormControl required={required}>
<FormLabel>{label}</FormLabel>
<Input
name={name}
type={type}
value={formData[name] as string}
onChange={onChange}
variant="soft"
sx={{ borderRadius: "10px" }}
/>
</FormControl>
);
export const MainForm = () => {
const { t, i18n } = useTranslation();
const [invoice, setInvoice] = useState(false);
const [msg, setMsg] = useState<Message | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [nextID, setNextID] = useState<number | null>(null);
const [users, setUsers] = useState<string[]>([]);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const [formData, setFormData] = useState<FormData>(DEFAULT_FORM);
const [showSelectUser, setShowSelectUser] = useState(false);
const [QRmodal, setQRmodal] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const confirmUser = async (username: string) => {
try {
const res = await fetch(
`${API_BASE}/default/confirm-user?username=${username}`,
);
const data = await res.json();
setNextID(data.nextID);
} catch (error) {
console.error("Error confirming user:", error);
}
};
const handleUserSelection = (username: string | null) => {
if (!username) return;
setSelectedUser(username);
confirmUser(username);
Cookies.set("selectedUser", username);
};
const changeTranslation = () => {
const clientLng = i18n.language;
if (clientLng === "en") {
i18n.changeLanguage("de");
Cookies.set("language", "de");
} else if (clientLng === "de") {
i18n.changeLanguage("en");
Cookies.set("language", "en");
} else {
setMsg({
type: "danger",
headline: "Error",
text: "Cannot change langugage.",
});
}
}; };
useEffect(() => { useEffect(() => {
const savedUser = Cookies.get("selectedUser"); if (formData.paymentMethod === "paypal") {
if (savedUser) { setQRmodal(true);
setSelectedUser(savedUser);
} else {
setMsg({
type: "warning",
headline: t("set-username-headline"),
text: t("set-username-text"),
});
} }
}, []); }, [formData.paymentMethod]);
const { data: usernameData, isLoading: usernameDataIsLoading } = useQuery({ useEffect(() => {
queryKey: ["users"], (async () => {
queryFn: fetchUsers, try {
}); const res = await fetch(`${API_BASE}/default/users`);
const data = await res.json();
const { data: userData } = useQuery({ setUsers(data.users);
queryKey: ["user", selectedUser], } catch {
enabled: !!selectedUser,
queryFn: () => confirmUser(selectedUser),
});
const { mutate: mutateForm, isPending: mutateFormIsPending } = useMutation({
mutationFn: (values: FormData) => submitFormData(values, selectedUser),
onSuccess: (_, values) => {
queryClient.invalidateQueries({ queryKey: ["user", selectedUser] });
document.location.href = `/success?id=${nextID}&tickets=${values.tickets}`;
},
onError: () => {
queryClient.invalidateQueries({ queryKey: ["user", selectedUser] });
setMsg({ setMsg({
type: "danger", type: "danger",
headline: t("error"), headline: t("error"),
text: t("form-submission-failed"), text: t("failed-to-load-users"),
}); });
},
});
const nextID = userData?.nextID ?? "N/A";
const handleUserSelection = (username: string | null) => {
if (username == null || username == "") {
return;
} }
setSelectedUser(username); })();
const cookieUser = Cookies.get("selectedUser");
if (cookieUser) {
setSelectedUser(cookieUser);
confirmUser(cookieUser);
}
}, []);
const handleSubmit = async () => {
setIsLoading(true);
try {
const result = await submitFormData(formData, selectedUser || "");
if (result.success) {
document.location.href = `/success?id=${nextID}&tickets=${formData.tickets}`;
} else {
setMsg({
type: "danger",
headline: t("error"),
text: result.error || t("form-submission-failed"),
});
}
} finally {
setIsLoading(false);
}
}; };
// Shorthand so we don't repeat formData + onChange on every Field usage
const fieldProps = { formData, onChange: handleChange };
return ( return (
<> <>
<SelectUserModal <Modal open={showSelectUser}>
showSelectUser={showSelectUser} <ModalDialog color="primary" layout="center" size="lg">
setShowSelectUser={setShowSelectUser} <ModalClose onClick={() => setShowSelectUser(false)} />
usernameData={usernameData} <Typography>{t("user")}</Typography>
usernameDataIsLoading={usernameDataIsLoading} {/* User selection */}
selectedUser={selectedUser} <Autocomplete
handleUserSelection={handleUserSelection} options={users}
value={selectedUser}
onChange={(_, value) => handleUserSelection(value)}
placeholder={t("user")}
variant="soft"
sx={{ borderRadius: "10px" }}
onKeyDown={(e) => {
if (e.key === "Enter") e.preventDefault();
}}
/> />
</ModalDialog>
</Modal>
<Modal open={QRmodal}>
<ModalDialog color="primary" layout="center" size="lg">
<ModalClose onClick={() => setQRmodal(false)} />
<Typography>{t("qr-text")}</Typography>
<img
src={qrCode}
alt="PayPal QR Code"
style={{
width: "100%",
height: "auto",
maxHeight: "70vh",
objectFit: "contain",
}}
/>
</ModalDialog>
</Modal>
<QRcodeModal setQRmodal={setQRmodal} QRmodal={QRmodal} /> <div className="min-h-screen w-full flex items-center justify-center from-slate-100 to-blue-50 p-4">
<div className="flex-1 w-full flex items-center justify-center from-slate-100 to-blue-50 p-4">
<Sheet <Sheet
variant="plain" variant="plain"
className="w-full" className="w-full"
@@ -210,20 +246,10 @@ export const MainForm = () => {
<IconButton onClick={() => setQRmodal(true)}> <IconButton onClick={() => setQRmodal(true)}>
<QrCodeIcon /> <QrCodeIcon />
</IconButton> </IconButton>
{/* Language toggle */}
<IconButton onClick={changeTranslation}> <IconButton onClick={changeTranslation}>
<TranslateIcon /> <TranslateIcon />
</IconButton> </IconButton>
<Typography
level="title-sm"
textColor="var(--joy-palette-success-plainColor)"
sx={{
fontFamily: "monospace",
opacity: "100%",
alignSelf: "center",
}}
>
{`${t("greeting")} ${userData?.fullname ?? t("loading")}`}
</Typography>
</ButtonGroup> </ButtonGroup>
<form <form
@@ -233,6 +259,7 @@ export const MainForm = () => {
}} }}
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
{/* Next ID badge */}
<Chip <Chip
size="lg" size="lg"
variant="solid" variant="solid"
@@ -249,119 +276,41 @@ export const MainForm = () => {
{/* Name row */} {/* Name row */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field <Field label={t("first-name")} name="firstName" {...fieldProps} />
name="firstName" <Field label={t("last-name")} name="lastName" {...fieldProps} />
validators={makeFieldValidator("firstName")}
>
{(field) => (
<TextField
label={t("first-name")}
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
<Field
name="lastName"
validators={makeFieldValidator("lastName")}
>
{(field) => (
<TextField
label={t("last-name")}
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
</div> </div>
<Field name="email" validators={makeFieldValidator("email")}>
{(field) => (
<TextField
label={t("email")}
type="email"
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
<Field <Field
name="phoneNumber" label={t("email")}
validators={makeFieldValidator("phoneNumber")} name="email"
> type="email"
{(field) => ( {...fieldProps}
<TextField />
label={t("phone-number")} <Field
type="tel" label={t("phone-number")}
required name="phoneNumber"
value={field.state.value} type="tel"
onBlur={field.handleBlur} {...fieldProps}
onChange={field.handleChange}
errors={getErrors(field)}
/> />
)}
</Field>
{/* Tickets + Invoice toggle */} {/* Tickets + Invoice toggle */}
<div className="grid grid-cols-2 gap-3 items-end"> <div className="grid grid-cols-2 gap-3 items-end">
<FormControl required> <FormControl required>
<FormLabel>{t("tickets")}</FormLabel> <FormLabel>{t("tickets")}</FormLabel>
<Field
name="tickets"
validators={makeFieldValidator("tickets")}
>
{(field) => {
const errors = getErrors(field);
return (
<>
<Input <Input
name="tickets"
type="number" type="number"
value={field.state.value ?? ""} value={formData.tickets}
onBlur={field.handleBlur} onChange={handleChange}
onChange={(e) =>
field.handleChange(Number(e.target.value))
}
slotProps={{ input: { min: 1 } }} slotProps={{ input: { min: 1 } }}
variant="soft" variant="soft"
sx={{ borderRadius: "10px" }} sx={{ borderRadius: "10px" }}
/> />
{errors.length > 0 && (
<span className="text-red-500 text-sm">
{errors[0]}
</span>
)}
</>
);
}}
</Field>
</FormControl> </FormControl>
<div className="flex items-center pb-2"> <div className="flex items-center pb-2">
<Checkbox <Checkbox
checked={invoice} checked={invoice}
onChange={(e) => { onChange={(e) => setInvoice(e.target.checked)}
const checked = e.target.checked;
setInvoice(checked);
if (!checked) {
setFieldValue("companyName", "");
setFieldValue("cmpFirstName", "");
setFieldValue("cpmLastName", "");
setFieldValue("cpmEmail", "");
setFieldValue("cpmPhoneNumber", "");
setFieldValue("street", "");
setFieldValue("postalCode", "");
}
}}
label={t("invoice")} label={t("invoice")}
variant="outlined" variant="outlined"
/> />
@@ -374,166 +323,96 @@ export const MainForm = () => {
<Typography level="title-sm" color="primary"> <Typography level="title-sm" color="primary">
{t("invoice-details")} {t("invoice-details")}
</Typography> </Typography>
<Field <Field
name="companyName"
validators={makeFieldValidator("companyName")}
>
{(field) => (
<TextField
label={t("company-name")} label={t("company-name")}
required name="companyName"
value={field.state.value} {...fieldProps}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/> />
)}
</Field>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field <Field
name="cmpFirstName"
validators={makeFieldValidator("cmpFirstName")}
>
{(field) => (
<TextField
label={t("first-name")} label={t("first-name")}
required name="cmpFirstName"
value={field.state.value} {...fieldProps}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/> />
)}
</Field>
<Field <Field
name="cpmLastName"
validators={makeFieldValidator("cpmLastName")}
>
{(field) => (
<TextField
label={t("last-name")} label={t("last-name")}
required name="cpmLastName"
value={field.state.value} {...fieldProps}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/> />
)}
</Field>
</div> </div>
<Field label={t("street")} name="street" {...fieldProps} />
<Field name="street" validators={makeFieldValidator("street")}>
{(field) => (
<TextField
label={t("street")}
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
<Field <Field
name="postalCode"
validators={makeFieldValidator("postalCode")}
>
{(field) => (
<TextField
label={t("postal-code")} label={t("postal-code")}
required name="postalCode"
value={field.state.value} {...fieldProps}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/> />
)}
</Field>
<Field <Field
name="cpmPhoneNumber"
validators={makeFieldValidator("cpmPhoneNumber")}
>
{(field) => (
<TextField
label={t("phone-number")} label={t("phone-number")}
name="cpmPhoneNumber"
type="tel" type="tel"
required {...fieldProps}
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/> />
)}
</Field>
<Field <Field
name="cpmEmail"
validators={makeFieldValidator("cpmEmail")}
>
{(field) => (
<TextField
label={t("email")} label={t("email")}
name="cpmEmail"
type="email" type="email"
required {...fieldProps}
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/> />
)}
</Field>
</div> </div>
)} )}
{/* Payment method selection */} {/* Payment method selection */}
<FormControl required> <FormControl required>
<FormLabel>{t("select-payment-method")}</FormLabel> <FormLabel>{t("select-payment-method")}</FormLabel>
<Subscribe selector={(state) => state.values.paymentMethod}>
{(paymentMethod) => (
<div className="flex gap-2 flex-wrap mt-1"> <div className="flex gap-2 flex-wrap mt-1">
{PAYMENT_METHODS.map((method) => ( {PAYMENT_METHODS.map((method) => (
<Button <Button
key={method} key={method}
variant={paymentMethod === method ? "solid" : "soft"} variant={
color="primary" formData.paymentMethod === method ? "solid" : "soft"
onClick={() => { }
setFieldValue("paymentMethod", method); color="primary"
if (method === "paypal") { onClick={() =>
setQRmodal(true); setFormData((prev) => ({
...prev,
paymentMethod: method,
}))
} }
}}
sx={{ sx={{
flex: 1, flex: 1,
minWidth: "90px", minWidth: "90px",
borderRadius: "12px", borderRadius: "12px",
py: 1.5, py: 1.5,
textTransform: "none", textTransform: "none",
fontWeight: paymentMethod === method ? 700 : 400, fontWeight: formData.paymentMethod === method ? 700 : 400,
}} }}
> >
{PAYMENT_LABELS[method]} {PAYMENT_LABELS[method]}
</Button> </Button>
))} ))}
</div> </div>
{/* Hidden required input to enforce payment selection on submit */}
{!formData.paymentMethod && (
<input
tabIndex={-1}
required
value=""
onChange={() => {}}
style={{
opacity: 0,
width: 0,
height: 0,
position: "absolute",
}}
/>
)} )}
</Subscribe>
</FormControl> </FormControl>
{mutateFormIsPending ? ( {/* Submit button */}
<div className="flex items-center justify-center">
<CircularProgress />
</div>
) : (
<Subscribe selector={(state) => state.values.paymentMethod}>
{(paymentMethod) => (
<Button <Button
type="submit" type="submit"
disabled={!paymentMethod} loading={isLoading}
disabled={!formData.paymentMethod}
size="lg" size="lg"
sx={{ sx={{
mt: 2, mt: 2,
@@ -548,19 +427,11 @@ export const MainForm = () => {
> >
{t("submit")} {t("submit")}
</Button> </Button>
)}
</Subscribe>
)}
{/* Alert message */}
{msg && ( {msg && (
<Alert <Alert color={msg.type} sx={{ borderRadius: "12px" }}>
color={msg.type} <strong>{msg.headline}:</strong> {msg.text}
sx={{ flexDirection: "column", alignItems: "flex-start" }}
>
<Typography level="title-lg" sx={{ mb: 0.5 }}>
{msg.headline}
</Typography>
<Typography level="body-sm">{msg.text}</Typography>
</Alert> </Alert>
)} )}
</form> </form>
+9 -29
View File
@@ -16,11 +16,6 @@ export const SuccessPage = () => {
setTickets(parseInt(params.get("tickets") ?? "0", 10)); setTickets(parseInt(params.get("tickets") ?? "0", 10));
// Small delay so the CSS transition actually plays // Small delay so the CSS transition actually plays
setTimeout(() => setAnimate(true), 100); setTimeout(() => setAnimate(true), 100);
document.body.classList.add("success-bg");
return () => {
document.body.classList.remove("success-bg");
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -41,7 +36,7 @@ export const SuccessPage = () => {
}); });
return ( return (
<div className="flex-1 w-full flex items-center justify-center p-4"> <div className="min-h-screen w-full flex items-center justify-center bg-gradient-to-br from-slate-800 to-slate-900 p-4">
<Sheet <Sheet
variant="plain" variant="plain"
sx={{ sx={{
@@ -87,10 +82,7 @@ export const SuccessPage = () => {
{/* Headline */} {/* Headline */}
<div style={fadeUp("0.2s")}> <div style={fadeUp("0.2s")}>
<Typography <Typography level="h3" sx={{ fontWeight: 700, color: "#15803d", mb: 1 }}>
level="h3"
sx={{ fontWeight: 700, color: "#15803d", mb: 1 }}
>
{t("form-submitted-successfully")} {t("form-submitted-successfully")}
</Typography> </Typography>
</div> </div>
@@ -118,18 +110,8 @@ export const SuccessPage = () => {
{/* Order ID chip */} {/* Order ID chip */}
{orderId && ( {orderId && (
<div <div style={fadeUp("0.4s")} className="flex flex-col items-center gap-1 mb-4">
style={fadeUp("0.4s")} <Typography level="body-xs" sx={{ color: "#9ca3af", textTransform: "uppercase", letterSpacing: "0.08em" }}>
className="flex flex-col items-center gap-1 mb-4"
>
<Typography
level="body-xs"
sx={{
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: "0.08em",
}}
>
{t("entry-id")} {t("entry-id")}
</Typography> </Typography>
<Chip <Chip
@@ -156,9 +138,7 @@ export const SuccessPage = () => {
borderRadius: "12px", borderRadius: "12px",
fontWeight: 700, fontWeight: 700,
background: "linear-gradient(135deg, #2563eb, #1d4ed8)", background: "linear-gradient(135deg, #2563eb, #1d4ed8)",
"&:hover": { "&:hover": { background: "linear-gradient(135deg, #1d4ed8, #1e40af)" },
background: "linear-gradient(135deg, #1d4ed8, #1e40af)",
},
}} }}
> >
{seconds}s &mdash; {t("return-to-homepage")} {seconds}s &mdash; {t("return-to-homepage")}
@@ -166,11 +146,11 @@ export const SuccessPage = () => {
</div> </div>
{/* Thank-you note */} {/* Thank-you note */}
<div style={fadeUp("0.5s")} className="pt-4 border-t border-slate-100"> <div
<Typography style={fadeUp("0.5s")}
level="body-sm" className="pt-4 border-t border-slate-100"
sx={{ color: "#9ca3af", lineHeight: 1.6 }}
> >
<Typography level="body-sm" sx={{ color: "#9ca3af", lineHeight: 1.6 }}>
{t("thank-you")} {t("thank-you")}
</Typography> </Typography>
</div> </div>
-29
View File
@@ -1,29 +0,0 @@
import { API_BASE } from "../../config/api.config";
import type { FormData } from "../../config/interfaces.config";
export const submitFormData = async (
data: FormData,
username: string | null,
) => {
console.warn("submitFormData is fetching!");
// await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds
const response = await fetch(
`${API_BASE}/default/new-entry?username=${username}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
},
);
if (!response.ok) {
const error = await response.text();
throw new Error(error || "Form submission failed");
}
return;
};
-25
View File
@@ -1,25 +0,0 @@
import { API_BASE } from "../../config/api.config";
import Cookies from "js-cookie";
export const fetchUsers = async () => {
console.warn("fetchUsers is fetching!");
const response = await fetch(`${API_BASE}/default/users`);
const data = await response.json();
return data;
};
export const confirmUser = async (username: string | null) => {
if (!username) {
return;
}
console.warn("confirmUser is fetching!");
const response = await fetch(
`${API_BASE}/default/confirm-user?username=${username}`,
);
const data = await response.json();
Cookies.set("selectedUser", username);
return data;
};
+2 -10
View File
@@ -14,7 +14,7 @@
"user": "Benutzer", "user": "Benutzer",
"next-id": "Nächste Eintragsnummer: ", "next-id": "Nächste Eintragsnummer: ",
"form-submitted-successfully": "Formular erfolgreich übermittelt!", "form-submitted-successfully": "Formular erfolgreich übermittelt!",
"form-submission-failed": "Formularübermittlung fehlgeschlagen.", "orm-submission-failed": "Formularübermittlung fehlgeschlagen.",
"success": "Erfolg", "success": "Erfolg",
"error": "Fehler", "error": "Fehler",
"cash": "Bar", "cash": "Bar",
@@ -26,13 +26,5 @@
"thank-you": "Vielen Dank für Ihre Unterstützung der Claudius Akademie! Wir wünschen Ihnen viel Glück mit dem Los.", "thank-you": "Vielen Dank für Ihre Unterstützung der Claudius Akademie! Wir wünschen Ihnen viel Glück mit dem Los.",
"select-payment-method": "Zahlungsmethode auswählen", "select-payment-method": "Zahlungsmethode auswählen",
"return-to-homepage": "Zurück", "return-to-homepage": "Zurück",
"qr-text": "PayPal QR-Code der Claudius Akademie", "qr-text": "PayPal QR-Code der Claudius Akademie"
"loading": "Lädt...",
"greeting": "Hallo,",
"set-username-headline": "Keinen Benutzer ausgewählt",
"set-username-text": "Um mit dem Losverkauf zu beginnen, musst du einen Benutzer oben links auswählen.",
"name-error": "Sie müssen einen Namen eingeben!",
"email-error": "Sie müssen eine gültige E-Mail Adresse eingeben!",
"phone-error": "Sie müssen eine gültige Telefonnummer eingeben!",
"footer-headline": "Dieses System wurde vollständig konzipiert und entwickelt von Theis Gaedigk. - Portfolio: "
} }
+1 -9
View File
@@ -27,13 +27,5 @@
"thank-you": "Thank you for supporting the Claudius Akademie! We wish you the best of luck with your ticket.", "thank-you": "Thank you for supporting the Claudius Akademie! We wish you the best of luck with your ticket.",
"select-payment-method": "Select Payment Method", "select-payment-method": "Select Payment Method",
"return-to-homepage": "Return", "return-to-homepage": "Return",
"qr-text": "PayPal QR-Code from the Claudius Akademie", "qr-text": "PayPal QR-Code from the Claudius Akademie"
"loading": "Loading...",
"greeting": "Hello,",
"set-username-headline": "No user selected",
"set-username-text": "To start the ticket sale, you must select a user first from the top left.",
"name-error": "You have to enter a name!",
"email-error": "You have to enter a valid E-Mail adress!",
"phone-error": "You have to enter a vaild phone number!",
"footer-headline": "This system was fully designed and developed by Theis Gaedigk. - Portfolio: "
} }
+33
View File
@@ -0,0 +1,33 @@
import { API_BASE } from "../config/api.config";
import type { FormData } from "../config/interfaces.config";
export const submitFormData = async (
data: FormData,
username: string | null,
) => {
if (username == null) {
return { success: false, errorCode: "x001" };
}
try {
const response = await fetch(
`${API_BASE}/default/new-entry?username=${username}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
},
);
if (!response.ok) {
const errorText = await response.text();
return { success: false, error: `Server error: ${errorText}` };
}
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
};
-16
View File
@@ -1,16 +0,0 @@
import i18n from "./i18n";
import Cookies from "js-cookie";
export const changeTranslation = () => {
const clientLng = i18n.language;
if (clientLng === "en") {
i18n.changeLanguage("de");
Cookies.set("language", "de");
} else if (clientLng === "de") {
i18n.changeLanguage("en");
Cookies.set("language", "en");
} else {
alert("Cannot change language.");
}
};
-13
View File
@@ -1,13 +0,0 @@
import http from "k6/http";
import { sleep } from "k6";
export const options = {
vus: 100, // amount of users
duration: "60s", // duration of the test
};
export default function () {
http.get("http://localhost:8004/default/confirm-user?username=TheisGaedigk");
http.get("http://localhost:8004/default/users");
sleep(1);
}
-15
View File
@@ -1,15 +0,0 @@
// Before running: Establish VPN connection first
import http from "k6/http";
import { sleep } from "k6";
export const options = {
vus: 100, // amount of users
duration: "60s", // duration of the test
};
export default function () {
http.get("http://backend:8004/default/confirm-user?username=TheisGaedigk");
http.get("http://backend:8004/default/users");
sleep(0.5);
}
Submodule wg-easy-ca-lose deleted from 9581e6eacb