23 Commits

Author SHA1 Message Date
theis.gaedigk a46cb3b9e7 Merge branch 'dev' into prod 2026-06-10 13:45:53 +02:00
theis.gaedigk d8e8062990 fixed number input 2026-06-10 13:45:13 +02:00
theis.gaedigk 528ed8ac40 Merge branch 'dev' into prod 2026-06-09 17:44:36 +02:00
theis.gaedigk 470fe75d86 added chocolates box 2026-06-09 17:43:56 +02:00
theis.gaedigk cfd3a77472 Merge branch 'dev' into prod 2026-06-03 16:33:26 +02:00
theis.gaedigk d49e62a280 updated wg easy module 2026-06-03 16:32:19 +02:00
theis.gaedigk a66b150d97 added prize draw name to tables 2026-06-03 16:29:45 +02:00
theis.gaedigk f7a0a3753c refactored code 2026-05-25 11:32:45 +02:00
theis.gaedigk d308f80341 Merge branch 'dev' into prod 2026-05-24 14:19:31 +02:00
theis.gaedigk 636730d4b2 changed branch link 2026-05-24 14:19:09 +02:00
theis.gaedigk 968e1e7727 foxed readme issue 2026-05-24 14:18:33 +02:00
theis.gaedigk c79f81ec7d fixed readme issue 2026-05-24 14:18:19 +02:00
theis.gaedigk 9af4ddaf18 Merge branch 'dev' into prod 2026-05-24 14:16:49 +02:00
theis.gaedigk 6d31433321 updated readme 2026-05-24 14:16:28 +02:00
theis.gaedigk 542d832eab improved design 2026-05-24 13:55:12 +02:00
theis.gaedigk 1f11a4ecab added page footer 2026-05-24 13:50:41 +02:00
theis.gaedigk ccb09caa4f refactored code 2026-05-24 13:26:52 +02:00
theis.gaedigk 1cd0379654 redesgigned page 2026-05-24 13:12:09 +02:00
theis.gaedigk 39b15aec65 refactored code 2026-05-24 13:08:13 +02:00
theis.gaedigk 6915e60cec implemented tanstack form 2026-05-24 13:07:06 +02:00
theis.gaedigk 9dead72e1e updated wg-easy 2026-05-22 23:32:42 +02:00
theis.gaedigk cca303c1f6 edited readme 2026-05-20 13:52:08 +02:00
theis.gaedigk 9dd9ccebdd changed docker notes 2026-05-20 13:15:33 +02:00
23 changed files with 824 additions and 373 deletions
+1
View File
@@ -158,6 +158,7 @@ keys/
# Own files # Own files
ToDo.txt ToDo.txt
PayPal-QR-Code.png PayPal-QR-Code.png
.docker/volumes
# only in development branch # only in development branch
next-env.d.ts next-env.d.ts
+66 -132
View File
@@ -1,164 +1,98 @@
# CA-Lose # LuckySign
CA-Lose is a small full-stack app for collecting ticket-sale/order form entries. Ticket intake and validation app with a React frontend and an Express + MySQL backend.
## Tech stack ## Tech Stack
![React](https://img.shields.io/badge/React-20232A?logo=react&logoColor=61DAFB) ![React](https://img.shields.io/badge/React-61DAFB?logo=react&logoColor=000&style=flat)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=fff) ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=fff&style=flat)
![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=fff) ![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=fff&style=flat)
![Tailwind CSS](https://img.shields.io/badge/Tailwind%20CSS-06B6D4?logo=tailwindcss&logoColor=fff) ![Tailwind%20CSS](https://img.shields.io/badge/Tailwind%20CSS-38B2AC?logo=tailwindcss&logoColor=fff&style=flat)
![MUI](https://img.shields.io/badge/MUI-007FFF?logo=mui&logoColor=fff) ![MUI](https://img.shields.io/badge/MUI-007FFF?logo=mui&logoColor=fff&style=flat)
![i18next](https://img.shields.io/badge/i18next-26A69A?logo=i18next&logoColor=fff) ![TanStack%20Query](https://img.shields.io/badge/TanStack%20Query-FF4154?logo=reactquery&logoColor=fff&style=flat)
![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)
![Node.js](https://img.shields.io/badge/Node.js-339933?logo=node.js&logoColor=fff) ### Production Tech Stack
![Express](https://img.shields.io/badge/Express-000000?logo=express&logoColor=fff)
![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=fff)
![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&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.
![Nginx](https://img.shields.io/badge/Nginx-009639?logo=nginx&logoColor=fff)
## Architecture ![dnsmasq](https://img.shields.io/badge/dnsmasq-314B5F?logo=isc&logoColor=fff&style=flat)
![WireGuard](https://img.shields.io/badge/WireGuard-88171A?logo=wireguard&logoColor=fff&style=flat)
- **Frontend**: React + TypeScript + Vite (UI uses MUI + Tailwind, i18n via i18next) ## Project Structure
- **Backend**: Node.js + Express (also renders a minimal EJS page at `/`)
- **Database**: MySQL 8
In production, the frontend is served by **Nginx** and proxies `/backend/*` to the backend container. - Frontend (Vite + React + Tailwind + MUI): [frontend](frontend)
- Backend (Express): [backend](backend)
- Database schema: [backend/scheme.sql](backend/scheme.sql)
- Docker compose stack: [docker-compose.yml](docker-compose.yml)
## Repos & folders ## Quick Start (Docker)
``` 1. Set the database password env var used by Docker Compose:
backend/ # Express API + EJS views
frontend/ # React app (Vite) + Nginx container
docker-compose.yml
docker-compose.prod.yml
```
## 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 --build DB_PASSWORD=your_password
``` ```
What you get: 2. Start MySQL and the backend:
- 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 ```bash
docker compose up --build database backend docker compose up -d
```
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:
- `docker-compose.prod.yml` uses an **external** network called `proxynet`. Create it if it does not exist: - The frontend service is commented out in [docker-compose.yml](docker-compose.yml). If you want the frontend container, uncomment that block and rebuild.
- 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.
```bash ## Local Development
docker network create proxynet
```
- The production file assigns static IPs; adjust the subnets/IPs if they conflict with your environment. ### Backend
## Lint / build 1. Create a .env file in the backend folder with:
Frontend: ```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
cd backend
npm install
node server.js
```
Backend listens on http://localhost:8004 and exposes routes under /default.
### Frontend
```bash ```bash
cd frontend cd frontend
npm run lint npm install
npm run build npm run dev
``` ```
## Troubleshooting 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).
- **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). ## API Endpoints
- **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.
## License - GET /default/users
- GET /default/confirm-user?username={name}
- POST /default/new-entry?username={name}
Licensed under the Apache License 2.0. See [LICENSE](LICENSE). ## Database Notes
- 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)).
+13 -7
View File
@@ -11,14 +11,16 @@ const pool = mysql
}) })
.promise(); .promise();
export const getUser = async () => { export const getInfo = async () => {
const [rows] = await pool.query("SELECT username FROM users"); const [rows] = await pool.query("SELECT username FROM users;");
const [rows2] = await pool.query("SELECT name FROM prize_draws;");
if (rows.length > 0) { if (rows.length > 0 && rows2.length > 0) {
const users = rows.map((r) => r.username); const users = rows.map((r) => r.username);
return { users }; const prize_draws = rows2.map((n) => n.name);
return { users, prize_draws };
} else { } else {
return { users: [], message: "No data found" }; return { message: "No data found" };
} }
}; };
@@ -43,11 +45,13 @@ export const confirmUser = async (username) => {
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,
Verlosung VARCHAR(100) NOT NULL,
Vorname VARCHAR(100) NOT NULL, Vorname VARCHAR(100) NOT NULL,
Nachname Varchar(100) NOT NULL, Nachname Varchar(100) NOT NULL,
EMail Varchar(100) NOT NULL, EMail Varchar(100) NOT NULL,
Telefonnummer Varchar(100) NOT NULL, Telefonnummer Varchar(100) NOT NULL,
Lose INT NOT NULL, Lose INT NOT NULL,
Schokolade BOOLEAN NOT NULL,
Firmenname Varchar(100), Firmenname Varchar(100),
Vorname_Geschaeftlich Varchar(100), Vorname_Geschaeftlich Varchar(100),
Nachname_Geschaeftlich Varchar(100), Nachname_Geschaeftlich Varchar(100),
@@ -80,7 +84,7 @@ export const confirmUser = async (username) => {
} }
}; };
export const newEntry = async (formData, username) => { export const newEntry = async (formData, username, prizeDraw) => {
const confirmation = await confirmUser(username); const confirmation = await confirmUser(username);
if (!confirmation || !confirmation.success) { if (!confirmation || !confirmation.success) {
@@ -90,14 +94,16 @@ export const newEntry = async (formData, username) => {
const tableName = confirmation.tableName; const tableName = confirmation.tableName;
const [result] = await pool.query( const [result] = await pool.query(
`INSERT INTO ?? (Vorname, Nachname, EMail, Telefonnummer, Lose, Firmenname, Vorname_Geschaeftlich, Nachname_Geschaeftlich, EMail_Geschaeftlich, Telefonnummer_Geschaeftlich, Strasse_Hausnr, Plz_Ort, Zahlungsmethode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, `INSERT INTO ?? (Verlosung, Vorname, Nachname, EMail, Telefonnummer, Lose, Schokolade, Firmenname, Vorname_Geschaeftlich, Nachname_Geschaeftlich, EMail_Geschaeftlich, Telefonnummer_Geschaeftlich, Strasse_Hausnr, Plz_Ort, Zahlungsmethode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
tableName, tableName,
prizeDraw,
formData.firstName, formData.firstName,
formData.lastName, formData.lastName,
formData.email, formData.email,
formData.phoneNumber, formData.phoneNumber,
formData.tickets, formData.tickets,
formData.chocolates,
formData.companyName, formData.companyName,
formData.cmpFirstName, formData.cmpFirstName,
formData.cpmLastName, formData.cpmLastName,
+14 -5
View File
@@ -3,24 +3,33 @@ import dotenv from "dotenv";
const router = express.Router(); const router = express.Router();
dotenv.config(); dotenv.config();
import { getUser, newEntry, confirmUser } from "./frontend.data.js"; import { getInfo, newEntry, confirmUser } from "./frontend.data.js";
router.post("/new-entry", async (req, res) => { router.post("/new-entry", async (req, res) => {
const username = req.query.username; const username = req.query.username;
const result = await newEntry(req.body, username); const draw = req.query.draw;
const result = await newEntry(req.body, username, draw);
if (!result.success) { if (!result.success) {
return res.status(500).json({ message: "Form Data Invalid" }); return res.status(500).json({ message: "Form Data Invalid" });
} }
res.sendStatus(204); res.sendStatus(204);
}); });
router.get("/users", async (req, res) => { router.get("/info", async (req, res) => {
const users = await getUser(); const info = await getInfo();
res.json(users);
if (!info) {
return res.status(500).json({ message: "Server error" });
}
res.json(info);
}); });
router.get("/confirm-user", async (req, res) => { router.get("/confirm-user", async (req, res) => {
const username = req.query.username; const username = req.query.username;
if (!username) { if (!username) {
return res.status(400).json({ message: "Username is required" }); return res.status(400).json({ message: "Username is required" });
} }
+9
View File
@@ -1,3 +1,5 @@
USE ca_lose;
CREATE TABLE users ( CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE, username VARCHAR(100) NOT NULL UNIQUE,
@@ -6,9 +8,16 @@ CREATE TABLE users (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE prize_draws (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
/* This scheme does not have to be implemented manually. It always will be generated by the backend */ /* This scheme does not have to be implemented manually. It always will be generated by the backend */
CREATE TABLE xx_DD_MM_YYYY ( CREATE TABLE xx_DD_MM_YYYY (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
Verlosung VARCHAR(100) NOT NULL,
Vorname VARCHAR(100) NOT NULL, Vorname VARCHAR(100) NOT NULL,
Nachname Varchar(100) NOT NULL, Nachname Varchar(100) NOT NULL,
EMail Varchar(100) NOT NULL, EMail Varchar(100) NOT NULL,
+2 -2
View File
@@ -43,7 +43,7 @@ services:
ca-lose-internal: ca-lose-internal:
ipv4_address: 172.25.0.4 ipv4_address: 172.25.0.4
# DNS Server für Hostname-Auflösung innerhalb des VPN # DNS Server for hostname resolution within the Docker network
dnsmasq: dnsmasq:
container_name: ca-lose-dns container_name: ca-lose-dns
image: andyshinn/dnsmasq:latest image: andyshinn/dnsmasq:latest
@@ -62,7 +62,7 @@ services:
ca-lose-internal: ca-lose-internal:
ipv4_address: 172.25.0.5 ipv4_address: 172.25.0.5
# WireGuard VPN mit Web-UI (wg-easy) # WireGuard VPN server for secure remote access to the Docker network
wireguard: wireguard:
build: ./wg-easy-ca-lose build: ./wg-easy-ca-lose
container_name: ca-lose-wireguard container_name: ca-lose-wireguard
+119 -6
View File
@@ -16,6 +16,7 @@
"@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", "@tanstack/react-query": "^5.100.10",
"i18next": "^26.0.10", "i18next": "^26.0.10",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
@@ -25,7 +26,9 @@
"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",
@@ -33,6 +36,7 @@
"@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",
@@ -1768,6 +1772,50 @@
"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": { "node_modules/@tanstack/query-core": {
"version": "5.100.10", "version": "5.100.10",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz",
@@ -1778,6 +1826,28 @@
"url": "https://github.com/sponsors/tannerlinsley" "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": { "node_modules/@tanstack/react-query": {
"version": "5.100.10", "version": "5.100.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz",
@@ -1794,6 +1864,34 @@
"react": "^18 || ^19" "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",
@@ -1882,6 +1980,13 @@
"@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",
@@ -3004,12 +3109,12 @@
} }
}, },
"node_modules/js-cookie": { "node_modules/js-cookie": {
"version": "3.0.5", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", "integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=14" "node": ">=20"
} }
}, },
"node_modules/js-tokens": { "node_modules/js-tokens": {
@@ -4093,6 +4198,15 @@
"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",
@@ -4229,7 +4343,6 @@
"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"
+5 -1
View File
@@ -18,6 +18,7 @@
"@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", "@tanstack/react-query": "^5.100.10",
"i18next": "^26.0.10", "i18next": "^26.0.10",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
@@ -27,7 +28,9 @@
"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",
@@ -39,6 +42,7 @@
"@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,3 +6,7 @@ body,
height: 100%; height: 100%;
margin: 0; margin: 0;
} }
body.success-bg {
background: linear-gradient(135deg, #0f172a, #111827);
}
+6
View File
@@ -2,15 +2,21 @@ 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.

After

Width:  |  Height:  |  Size: 4.2 KiB

+42
View File
@@ -0,0 +1,42 @@
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
@@ -0,0 +1,31 @@
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>
);
@@ -10,10 +10,12 @@ import { useTranslation } from "react-i18next";
interface SelectUserModalProps { interface SelectUserModalProps {
showSelectUser: boolean; showSelectUser: boolean;
setShowSelectUser: (value: boolean) => void; setShowSelectUser: (value: boolean) => void;
usernameData: { users: string[] }; info: { users: string[]; prize_draws: string[] };
usernameDataIsLoading: boolean; infoIsLoading: boolean;
selectedUser: string | null; selectedUser: string | null;
selectedDraw: string | null;
handleUserSelection: (value: string | null) => void; handleUserSelection: (value: string | null) => void;
handleDrawSelection: (value: string | null) => void;
} }
export const SelectUserModal = (props: SelectUserModalProps) => { export const SelectUserModal = (props: SelectUserModalProps) => {
@@ -25,8 +27,8 @@ export const SelectUserModal = (props: SelectUserModalProps) => {
<Typography>{t("user")}</Typography> <Typography>{t("user")}</Typography>
{/* User selection */} {/* User selection */}
<Autocomplete <Autocomplete
options={props.usernameData?.users ?? []} options={props.info?.users ?? []}
loading={props.usernameDataIsLoading} loading={props.infoIsLoading}
loadingText={t("loading")} loadingText={t("loading")}
value={props.selectedUser} value={props.selectedUser}
onChange={(_, value) => props.handleUserSelection(value)} onChange={(_, value) => props.handleUserSelection(value)}
@@ -34,6 +36,17 @@ export const SelectUserModal = (props: SelectUserModalProps) => {
variant="soft" variant="soft"
sx={{ borderRadius: "10px" }} sx={{ borderRadius: "10px" }}
/> />
{/* Prize selection */}
<Autocomplete
options={props.info?.prize_draws ?? []}
loading={props.infoIsLoading}
loadingText={t("loading")}
value={props.selectedDraw}
onChange={(_, value) => props.handleDrawSelection(value)}
placeholder={t("prize_draws")}
variant="soft"
sx={{ borderRadius: "10px" }}
/>
</ModalDialog> </ModalDialog>
</Modal> </Modal>
); );
+49
View File
@@ -1,3 +1,7 @@
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;
@@ -19,3 +23,48 @@ 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),
chocolates: z.boolean(t("chocolates-error")),
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")),
});
+303 -141
View File
@@ -1,6 +1,5 @@
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,
@@ -22,23 +21,59 @@ 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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { confirmUser, fetchUsers } from "../utils/api/users"; import { confirmUser, fetchInfo } from "../utils/api/users";
import { QRcodeModal } from "../components/modals/QR-CodeModal"; import { QRcodeModal } from "../components/modals/QR-CodeModal";
import { SelectUserModal } from "../components/modals/SelectUserModal"; import { SelectUserModal } from "../components/modals/SelectUserModal";
import { useForm } from "@tanstack/react-form";
import { changeTranslation } from "../utils/uxFncs";
import { createFormSchema } from "../config/interfaces.config";
import { validateFieldWithZod } from "../utils/uxFncs";
import { TextField } from "../components/TextField";
import { PAYMENT_METHODS, PAYMENT_LABELS } from "../utils/uxFncs";
const PAYMENT_METHODS = ["bar", "paypal", "andere"] as const; export const MainForm = () => {
const PAYMENT_LABELS: Record<string, string> = { const { t } = useTranslation();
bar: "Cash", const queryClient = useQueryClient();
paypal: "PayPal",
andere: "Transfer",
};
const DEFAULT_FORM: FormData = { // States
const [invoice, setInvoice] = useState(false);
const [msg, setMsg] = useState<Message | null>(null);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const [selectedDraw, setSelectedDraw] = useState<string | null>(null);
const [showSelectUser, setShowSelectUser] = useState(false);
const [QRmodal, setQRmodal] = useState(false);
const formSchema = createFormSchema(t, invoice);
// Validates the fields and makes sure that the form is maching the zod schema
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);
},
});
// Creates a form with tanstack form
const { Field, Subscribe, handleSubmit, setFieldValue } = useForm({
defaultValues: {
firstName: "", firstName: "",
lastName: "", lastName: "",
email: "", email: "",
phoneNumber: "", phoneNumber: "",
tickets: 1, tickets: 0,
chocolates: false,
companyName: "", companyName: "",
cmpFirstName: "", cmpFirstName: "",
cpmLastName: "", cpmLastName: "",
@@ -47,55 +82,37 @@ const DEFAULT_FORM: FormData = {
street: "", street: "",
postalCode: "", postalCode: "",
paymentMethod: "", paymentMethod: "",
}; },
onSubmit: async ({ value }) => {
const result = formSchema.safeParse(value);
if (!result.success) return;
mutateForm(value as FormData);
},
});
// ─── Field component lives OUTSIDE MainForm so React doesn't treat it as a // This function returns the errors for one field
// new component type on every render, which would cause inputs to lose focus. const getErrors = (field: {
const Field = ({ state: { meta: { errorMap: Record<string, unknown> } };
label, }) => {
name, const normalizeErrors = (value: unknown) => {
type = "text", if (Array.isArray(value)) return value as string[];
required = true, if (typeof value === "string" && value.length > 0) return [value];
formData, return [] as string[];
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 queryClient = useQueryClient();
const [invoice, setInvoice] = useState(false);
const [msg, setMsg] = useState<Message | null>(null);
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 blurErrors = normalizeErrors(field.state.meta.errorMap["onBlur"]);
const submitErrors = normalizeErrors(field.state.meta.errorMap["onSubmit"]);
return [...blurErrors, ...submitErrors].filter(Boolean);
};
// useEffect that only executes on mount and is checking if there is a user selcted in the cookies
useEffect(() => { useEffect(() => {
const savedUser = Cookies.get("selectedUser"); const savedUser = Cookies.get("selectedUser");
const savedDraw = Cookies.get("selectedDraw");
if (savedDraw) {
setSelectedDraw(savedDraw);
}
if (savedUser) { if (savedUser) {
setSelectedUser(savedUser); setSelectedUser(savedUser);
} else { } else {
@@ -107,22 +124,25 @@ export const MainForm = () => {
} }
}, []); }, []);
const { data: usernameData, isLoading: usernameDataIsLoading } = useQuery({ // Fetch users tanstack query
queryKey: ["users"], const { data: info, isLoading: infoIsLoading } = useQuery({
queryFn: fetchUsers, queryKey: ["info"],
queryFn: fetchInfo,
}); });
// Query for validating selcted user
const { data: userData } = useQuery({ const { data: userData } = useQuery({
queryKey: ["user", selectedUser], queryKey: ["user", selectedUser],
enabled: !!selectedUser, enabled: !!selectedUser,
queryFn: () => confirmUser(selectedUser), queryFn: () => confirmUser(selectedUser),
}); });
// Tanstack query (mutate) for sending the form
const { mutate: mutateForm, isPending: mutateFormIsPending } = useMutation({ const { mutate: mutateForm, isPending: mutateFormIsPending } = useMutation({
mutationFn: () => submitFormData(formData, selectedUser), mutationFn: (values: FormData) => submitFormData(values, selectedUser),
onSuccess: () => { onSuccess: (_, values) => {
queryClient.invalidateQueries({ queryKey: ["user", selectedUser] }); queryClient.invalidateQueries({ queryKey: ["user", selectedUser] });
document.location.href = `/success?id=${nextID}&tickets=${formData.tickets}`; document.location.href = `/success?id=${nextID}&tickets=${values.tickets}`;
}, },
onError: () => { onError: () => {
queryClient.invalidateQueries({ queryKey: ["user", selectedUser] }); queryClient.invalidateQueries({ queryKey: ["user", selectedUser] });
@@ -134,52 +154,41 @@ export const MainForm = () => {
}, },
}); });
// Setting the nextID after a user is selected
const nextID = userData?.nextID ?? "N/A"; const nextID = userData?.nextID ?? "N/A";
// function for selecting user
const handleUserSelection = (username: string | null) => { const handleUserSelection = (username: string | null) => {
if (username == null || username == "") { if (username == null || username == "") {
return; return;
} }
setSelectedUser(username); setSelectedUser(username);
}; };
const changeTranslation = () => { // function for selecting draw
const clientLng = i18n.language; const handleDrawSelection = (draw: string | null) => {
if (draw == null || draw == "") {
if (clientLng === "en") { return;
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.",
});
} }
setSelectedDraw(draw);
Cookies.set("selectedDraw", draw);
}; };
// Shorthand so we don't repeat formData + onChange on every Field usage
const fieldProps = { formData, onChange: handleChange };
return ( return (
<> <>
<SelectUserModal <SelectUserModal
showSelectUser={showSelectUser} showSelectUser={showSelectUser}
setShowSelectUser={setShowSelectUser} setShowSelectUser={setShowSelectUser}
usernameData={usernameData} info={info}
usernameDataIsLoading={usernameDataIsLoading} infoIsLoading={infoIsLoading}
selectedUser={selectedUser} selectedUser={selectedUser}
selectedDraw={selectedDraw}
handleUserSelection={handleUserSelection} handleUserSelection={handleUserSelection}
handleDrawSelection={handleDrawSelection}
/> />
<QRcodeModal setQRmodal={setQRmodal} QRmodal={QRmodal} /> <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"
@@ -205,7 +214,6 @@ 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>
@@ -225,11 +233,10 @@ export const MainForm = () => {
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
mutateForm(); handleSubmit();
}} }}
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"
@@ -246,44 +253,134 @@ export const MainForm = () => {
{/* Name row */} {/* Name row */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label={t("first-name")} name="firstName" {...fieldProps} /> <Field
<Field label={t("last-name")} name="lastName" {...fieldProps} /> name="firstName"
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 <Field name="email" validators={makeFieldValidator("email")}>
{(field) => (
<TextField
label={t("email")} label={t("email")}
name="email"
type="email" type="email"
{...fieldProps} required
/> value={field.state.value}
<Field onBlur={field.handleBlur}
label={t("phone-number")} onChange={field.handleChange}
name="phoneNumber" errors={getErrors(field)}
type="tel"
{...fieldProps}
/> />
)}
</Field>
{/* Tickets + Invoice toggle */} <Field
name="phoneNumber"
validators={makeFieldValidator("phoneNumber")}
>
{(field) => (
<TextField
label={t("phone-number")}
type="tel"
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
{/* Tickets + Invoice + Chocolate 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>
<Input <Field
name="tickets" name="tickets"
validators={makeFieldValidator("tickets")}
>
{(field) => {
const errors = getErrors(field);
return (
<>
<Input
type="number" type="number"
value={formData.tickets} value={
onChange={handleChange} field.state.value === 0 ? "" : field.state.value
slotProps={{ input: { min: 1 } }} }
onBlur={field.handleBlur}
onChange={(e) => {
const val = e.target.value;
field.handleChange(val === "" ? 0 : Number(val));
}}
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 flex-col gap-2 pb-2">
<Checkbox <Checkbox
checked={invoice} checked={invoice}
onChange={(e) => setInvoice(e.target.checked)} onChange={(e) => {
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"
/> />
<Field name="chocolates">
{(field) => (
<Checkbox
checked={!!field.state.value}
onChange={(e) => field.handleChange(e.target.checked)}
label={t("chocolates")}
variant="outlined"
/>
)}
</Field>
</div> </div>
</div> </div>
@@ -293,60 +390,135 @@ 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
label={t("company-name")}
name="companyName" name="companyName"
{...fieldProps} validators={makeFieldValidator("companyName")}
>
{(field) => (
<TextField
label={t("company-name")}
required
value={field.state.value}
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
label={t("first-name")}
name="cmpFirstName" name="cmpFirstName"
{...fieldProps} validators={makeFieldValidator("cmpFirstName")}
>
{(field) => (
<TextField
label={t("first-name")}
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/> />
)}
</Field>
<Field <Field
label={t("last-name")}
name="cpmLastName" name="cpmLastName"
{...fieldProps} validators={makeFieldValidator("cpmLastName")}
>
{(field) => (
<TextField
label={t("last-name")}
required
value={field.state.value}
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
label={t("postal-code")}
name="postalCode" name="postalCode"
{...fieldProps} validators={makeFieldValidator("postalCode")}
>
{(field) => (
<TextField
label={t("postal-code")}
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/> />
)}
</Field>
<Field <Field
label={t("phone-number")}
name="cpmPhoneNumber" name="cpmPhoneNumber"
validators={makeFieldValidator("cpmPhoneNumber")}
>
{(field) => (
<TextField
label={t("phone-number")}
type="tel" type="tel"
{...fieldProps} required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/> />
)}
</Field>
<Field <Field
label={t("email")}
name="cpmEmail" name="cpmEmail"
validators={makeFieldValidator("cpmEmail")}
>
{(field) => (
<TextField
label={t("email")}
type="email" type="email"
{...fieldProps} required
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={ variant={paymentMethod === method ? "solid" : "soft"}
formData.paymentMethod === method ? "solid" : "soft"
}
color="primary" color="primary"
onClick={() => { onClick={() => {
setFormData((prev) => ({ setFieldValue("paymentMethod", method);
...prev,
paymentMethod: method,
}));
if (method === "paypal") { if (method === "paypal") {
setQRmodal(true); setQRmodal(true);
} }
@@ -357,28 +529,15 @@ export const MainForm = () => {
borderRadius: "12px", borderRadius: "12px",
py: 1.5, py: 1.5,
textTransform: "none", textTransform: "none",
fontWeight: formData.paymentMethod === method ? 700 : 400, fontWeight: 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 ? ( {mutateFormIsPending ? (
@@ -386,9 +545,11 @@ export const MainForm = () => {
<CircularProgress /> <CircularProgress />
</div> </div>
) : ( ) : (
<Subscribe selector={(state) => state.values.paymentMethod}>
{(paymentMethod) => (
<Button <Button
type="submit" type="submit"
disabled={!formData.paymentMethod} disabled={!paymentMethod}
size="lg" size="lg"
sx={{ sx={{
mt: 2, mt: 2,
@@ -404,8 +565,9 @@ export const MainForm = () => {
{t("submit")} {t("submit")}
</Button> </Button>
)} )}
</Subscribe>
)}
{/* Message */}
{msg && ( {msg && (
<Alert <Alert
color={msg.type} color={msg.type}
+6 -1
View File
@@ -16,6 +16,11 @@ 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(() => {
@@ -36,7 +41,7 @@ export const SuccessPage = () => {
}); });
return ( return (
<div className="min-h-screen w-full flex items-center justify-center bg-linear-to-br from-slate-800 to-slate-900 p-4"> <div className="flex-1 w-full flex items-center justify-center p-4">
<Sheet <Sheet
variant="plain" variant="plain"
sx={{ sx={{
+8 -1
View File
@@ -1,5 +1,6 @@
import { API_BASE } from "../../config/api.config"; import { API_BASE } from "../../config/api.config";
import type { FormData } from "../../config/interfaces.config"; import type { FormData } from "../../config/interfaces.config";
import Cookies from "js-cookie";
export const submitFormData = async ( export const submitFormData = async (
data: FormData, data: FormData,
@@ -7,10 +8,16 @@ export const submitFormData = async (
) => { ) => {
console.warn("submitFormData is fetching!"); console.warn("submitFormData is fetching!");
const draw = Cookies.get("selectedDraw");
if (!draw) {
throw new Error("You need to set an prize draw!");
}
// await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds // await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds
const response = await fetch( const response = await fetch(
`${API_BASE}/default/new-entry?username=${username}`, `${API_BASE}/default/new-entry?username=${username}&draw=${draw}`,
{ {
method: "POST", method: "POST",
headers: { headers: {
+3 -3
View File
@@ -1,10 +1,10 @@
import { API_BASE } from "../../config/api.config"; import { API_BASE } from "../../config/api.config";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
export const fetchUsers = async () => { export const fetchInfo = async () => {
console.warn("fetchUsers is fetching!"); console.warn("fetchInfo is fetching!");
const response = await fetch(`${API_BASE}/default/users`); const response = await fetch(`${API_BASE}/default/info`);
const data = await response.json(); const data = await response.json();
return data; return data;
+9 -2
View File
@@ -14,11 +14,12 @@
"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.", "form-submission-failed": "Formularübermittlung fehlgeschlagen. Haben Sie eine Verlosung ausgewählt?",
"success": "Erfolg", "success": "Erfolg",
"error": "Fehler", "error": "Fehler",
"cash": "Bar", "cash": "Bar",
"paypal": "PayPal", "paypal": "PayPal",
"prize_draws": "Verlosungen",
"transfer": "Andere (notieren)", "transfer": "Andere (notieren)",
"ticket-payment_one": "Sie haben erfolgreich {{count}} Los gekauft.", "ticket-payment_one": "Sie haben erfolgreich {{count}} Los gekauft.",
"ticket-payment_other": "Sie haben erfolgreich {{count}} Lose gekauft.", "ticket-payment_other": "Sie haben erfolgreich {{count}} Lose gekauft.",
@@ -30,5 +31,11 @@
"loading": "Lädt...", "loading": "Lädt...",
"greeting": "Hallo,", "greeting": "Hallo,",
"set-username-headline": "Keinen Benutzer ausgewählt", "set-username-headline": "Keinen Benutzer ausgewählt",
"set-username-text": "Um mit dem Losverkauf zu beginnen, musst du einen Benutzer oben links auswählen." "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: ",
"chocolates": "Schokoladen",
"chocolates-error": "Dieses Feld kann nur wahr oder falsch sein."
} }
+9 -2
View File
@@ -13,7 +13,8 @@
"user": "User", "user": "User",
"next-id": "Next Entry Number: ", "next-id": "Next Entry Number: ",
"form-submitted-successfully": "Form submitted successfully!", "form-submitted-successfully": "Form submitted successfully!",
"orm-submission-failed": "Form submission failed.", "form-submission-failed": "Form submission failed. Have you set an prize draw?",
"prize_draws": "Prize draws",
"success": "Success", "success": "Success",
"error": "Error", "error": "Error",
"cash": "Cash", "cash": "Cash",
@@ -31,5 +32,11 @@
"loading": "Loading...", "loading": "Loading...",
"greeting": "Hello,", "greeting": "Hello,",
"set-username-headline": "No user selected", "set-username-headline": "No user selected",
"set-username-text": "To start the ticket sale, you must select a user first from the top left." "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: ",
"chocolates": "Chocolates",
"chocolates-error": "This field can only be true or false."
} }
+42
View File
@@ -0,0 +1,42 @@
import i18n from "./i18n";
import Cookies from "js-cookie";
import { ZodObject } from "zod";
import type { ZodRawShape } from "zod";
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.");
}
};
/**
* Validates a single field against the full Zod schema.
* Returns the first error message for that field, or undefined if valid.
*/
export 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 PAYMENT_METHODS = ["bar", "paypal", "andere"] as const;
export const PAYMENT_LABELS: Record<string, string> = {
bar: "Cash",
paypal: "PayPal",
andere: "Transfer",
};