Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 636730d4b2 | |||
| 968e1e7727 | |||
| 6d31433321 | |||
| 542d832eab | |||
| 1f11a4ecab | |||
| ccb09caa4f | |||
| 1cd0379654 | |||
| 39b15aec65 | |||
| 6915e60cec | |||
| 9dead72e1e | |||
| cca303c1f6 | |||
| 101bd5c060 | |||
| f32931ded3 | |||
| 4ce1817bd0 | |||
| 11c2372cae | |||
| d5b6c9665c | |||
| ce2d0bb329 | |||
| c25ab48880 | |||
| 8932f5d004 | |||
| 3832aca12c |
@@ -0,0 +1,3 @@
|
||||
[submodule "wg-easy-ca-lose"]
|
||||
path = wg-easy-ca-lose
|
||||
url = https://git.the1s.de/theis.gaedigk/wg-easy-ca-lose.git
|
||||
@@ -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
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
[](#)
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||

|
||||

|
||||

|
||||
### Production Tech Stack
|
||||
|
||||

|
||||

|
||||
> **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.
|
||||
|
||||
## Architecture
|
||||

|
||||

|
||||
|
||||
- **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
|
||||
## Project Structure
|
||||
|
||||
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)
|
||||
|
||||
```
|
||||
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.
|
||||
1. Set the database password env var used by Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
DB_PASSWORD=your_password
|
||||
```
|
||||
|
||||
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):
|
||||
2. Start MySQL and the backend:
|
||||
|
||||
```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
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
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
|
||||
docker network create proxynet
|
||||
```
|
||||
## Local Development
|
||||
|
||||
- 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
|
||||
cd frontend
|
||||
npm run lint
|
||||
npm run build
|
||||
npm install
|
||||
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).
|
||||
- **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.
|
||||
## API Endpoints
|
||||
|
||||
## 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)).
|
||||
|
||||
@@ -28,6 +28,9 @@ export const confirmUser = async (username) => {
|
||||
]);
|
||||
|
||||
if (rows.length > 0) {
|
||||
const { first_name, last_name } = rows[0];
|
||||
const fullname = first_name + " " + last_name;
|
||||
|
||||
// creating userTicketTable
|
||||
const d = new Date();
|
||||
|
||||
@@ -37,8 +40,6 @@ export const confirmUser = async (username) => {
|
||||
const date = `${day}_${month}_${year}`;
|
||||
const tableName = `${username}_${date}`;
|
||||
|
||||
console.log(tableName);
|
||||
|
||||
const [createTable] = await pool.query(
|
||||
`CREATE TABLE IF NOT EXISTS ?? (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
@@ -70,9 +71,9 @@ export const confirmUser = async (username) => {
|
||||
nextID = rows.length > 0 ? rows[0].id + 1 : 1;
|
||||
};
|
||||
await getNextID();
|
||||
return { success: true, nextID, tableName };
|
||||
return { success: true, nextID, tableName, fullname };
|
||||
} else {
|
||||
return { success: false, message: "Table creation failed" };
|
||||
return { success: false, message: "Table creation failed", fullname };
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
|
||||
@@ -11,8 +11,6 @@ router.post("/new-entry", async (req, res) => {
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ message: "Form Data Invalid" });
|
||||
}
|
||||
console.log(req.body);
|
||||
console.log(username);
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
|
||||
Generated
+153
-23
@@ -16,14 +16,19 @@
|
||||
"@mui/joy": "^5.0.0-beta.52",
|
||||
"@mui/material": "^9.0.1",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-form": "^1.32.0",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"i18next": "^26.0.10",
|
||||
"js-cookie": "^3.0.5",
|
||||
"k6": "^0.0.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^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-router-dom": "^7.15.0",
|
||||
"tailwindcss": "^4.3.0"
|
||||
"tailwindcss": "^4.3.0",
|
||||
"validator": "^13.15.35",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -31,6 +36,7 @@
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/validator": "^13.15.10",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
@@ -1766,6 +1772,126 @@
|
||||
"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": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
@@ -1854,6 +1980,13 @@
|
||||
"@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": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz",
|
||||
@@ -2976,12 +3109,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz",
|
||||
"integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
@@ -3042,6 +3175,12 @@
|
||||
"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": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -4059,6 +4198,15 @@
|
||||
"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": {
|
||||
"version": "8.0.11",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
|
||||
@@ -4178,23 +4326,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
@@ -4212,7 +4343,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
@@ -18,14 +18,19 @@
|
||||
"@mui/joy": "^5.0.0-beta.52",
|
||||
"@mui/material": "^9.0.1",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-form": "^1.32.0",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"i18next": "^26.0.10",
|
||||
"js-cookie": "^3.0.5",
|
||||
"k6": "^0.0.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^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-router-dom": "^7.15.0",
|
||||
"tailwindcss": "^4.3.0"
|
||||
"tailwindcss": "^4.3.0",
|
||||
"validator": "^13.15.35",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
@@ -37,6 +42,7 @@
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/validator": "^13.15.10",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
|
||||
@@ -6,3 +6,7 @@ body,
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body.success-bg {
|
||||
background: linear-gradient(135deg, #0f172a, #111827);
|
||||
}
|
||||
|
||||
@@ -2,15 +2,21 @@ import "./App.css";
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { MainForm } from "./pages/MainForm";
|
||||
import { SuccessPage } from "./pages/SuccessPage";
|
||||
import { PageFooter } from "./components/PageFooter";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<BrowserRouter>
|
||||
<main className="flex-1 flex">
|
||||
<Routes>
|
||||
<Route path="/" element={<MainForm />} />
|
||||
<Route path="/success" element={<SuccessPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</BrowserRouter>
|
||||
<PageFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,7 @@
|
||||
import z from "zod";
|
||||
import validator from "validator";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface FormData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
@@ -19,3 +23,47 @@ export interface Message {
|
||||
headline: 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")),
|
||||
});
|
||||
|
||||
@@ -3,9 +3,14 @@ import { createRoot } from "react-dom/client";
|
||||
import "./utils/i18n/index.ts";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
+345
-216
@@ -1,6 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useEffect } from "react";
|
||||
import * as React from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import {
|
||||
Sheet,
|
||||
@@ -13,19 +12,23 @@ import {
|
||||
Typography,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Autocomplete,
|
||||
ButtonGroup,
|
||||
Modal,
|
||||
ModalDialog,
|
||||
ModalClose,
|
||||
CircularProgress,
|
||||
} from "@mui/joy";
|
||||
import { submitFormData } from "../utils/sender";
|
||||
import { API_BASE } from "../config/api.config";
|
||||
import { submitFormData } from "../utils/api/form";
|
||||
import type { FormData, Message } from "../config/interfaces.config";
|
||||
import PersonIcon from "@mui/icons-material/Person";
|
||||
import QrCodeIcon from "@mui/icons-material/QrCode";
|
||||
import TranslateIcon from "@mui/icons-material/Translate";
|
||||
import qrCode from "../assets/PayPal-QR-Code.png";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
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_LABELS: Record<string, string> = {
|
||||
@@ -34,7 +37,56 @@ const PAYMENT_LABELS: Record<string, string> = {
|
||||
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: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
@@ -48,179 +100,91 @@ const DEFAULT_FORM: FormData = {
|
||||
street: "",
|
||||
postalCode: "",
|
||||
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
|
||||
// new component type on every render, which would cause inputs to lose focus.
|
||||
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 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 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 blurErrors = normalizeErrors(field.state.meta.errorMap["onBlur"]);
|
||||
const submitErrors = normalizeErrors(field.state.meta.errorMap["onSubmit"]);
|
||||
return [...blurErrors, ...submitErrors].filter(Boolean);
|
||||
};
|
||||
|
||||
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");
|
||||
useEffect(() => {
|
||||
const savedUser = Cookies.get("selectedUser");
|
||||
if (savedUser) {
|
||||
setSelectedUser(savedUser);
|
||||
} else {
|
||||
setMsg({
|
||||
type: "danger",
|
||||
headline: "Error",
|
||||
text: "Cannot change langugage.",
|
||||
type: "warning",
|
||||
headline: t("set-username-headline"),
|
||||
text: t("set-username-text"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (formData.paymentMethod === "paypal") {
|
||||
setQRmodal(true);
|
||||
}
|
||||
}, [formData.paymentMethod]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/default/users`);
|
||||
const data = await res.json();
|
||||
setUsers(data.users);
|
||||
} catch {
|
||||
setMsg({
|
||||
type: "danger",
|
||||
headline: t("error"),
|
||||
text: t("failed-to-load-users"),
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
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 {
|
||||
const { data: usernameData, isLoading: usernameDataIsLoading } = useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: fetchUsers,
|
||||
});
|
||||
|
||||
const { data: userData } = useQuery({
|
||||
queryKey: ["user", selectedUser],
|
||||
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({
|
||||
type: "danger",
|
||||
headline: t("error"),
|
||||
text: result.error || t("form-submission-failed"),
|
||||
text: t("form-submission-failed"),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Shorthand so we don't repeat formData + onChange on every Field usage
|
||||
const fieldProps = { formData, onChange: handleChange };
|
||||
const nextID = userData?.nextID ?? "N/A";
|
||||
|
||||
const handleUserSelection = (username: string | null) => {
|
||||
if (username == null || username == "") {
|
||||
return;
|
||||
}
|
||||
setSelectedUser(username);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={showSelectUser}>
|
||||
<ModalDialog color="primary" layout="center" size="lg">
|
||||
<ModalClose onClick={() => setShowSelectUser(false)} />
|
||||
<Typography>{t("user")}</Typography>
|
||||
{/* User selection */}
|
||||
<Autocomplete
|
||||
options={users}
|
||||
value={selectedUser}
|
||||
onChange={(_, value) => handleUserSelection(value)}
|
||||
placeholder={t("user")}
|
||||
variant="soft"
|
||||
sx={{ borderRadius: "10px" }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") e.preventDefault();
|
||||
}}
|
||||
<SelectUserModal
|
||||
showSelectUser={showSelectUser}
|
||||
setShowSelectUser={setShowSelectUser}
|
||||
usernameData={usernameData}
|
||||
usernameDataIsLoading={usernameDataIsLoading}
|
||||
selectedUser={selectedUser}
|
||||
handleUserSelection={handleUserSelection}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<div className="min-h-screen w-full flex items-center justify-center from-slate-100 to-blue-50 p-4">
|
||||
<QRcodeModal setQRmodal={setQRmodal} QRmodal={QRmodal} />
|
||||
|
||||
<div className="flex-1 w-full flex items-center justify-center from-slate-100 to-blue-50 p-4">
|
||||
<Sheet
|
||||
variant="plain"
|
||||
className="w-full"
|
||||
@@ -246,10 +210,20 @@ export const MainForm = () => {
|
||||
<IconButton onClick={() => setQRmodal(true)}>
|
||||
<QrCodeIcon />
|
||||
</IconButton>
|
||||
{/* Language toggle */}
|
||||
<IconButton onClick={changeTranslation}>
|
||||
<TranslateIcon />
|
||||
</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>
|
||||
|
||||
<form
|
||||
@@ -259,7 +233,6 @@ export const MainForm = () => {
|
||||
}}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{/* Next ID badge */}
|
||||
<Chip
|
||||
size="lg"
|
||||
variant="solid"
|
||||
@@ -276,41 +249,119 @@ export const MainForm = () => {
|
||||
|
||||
{/* Name row */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t("first-name")} name="firstName" {...fieldProps} />
|
||||
<Field label={t("last-name")} name="lastName" {...fieldProps} />
|
||||
</div>
|
||||
<Field
|
||||
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>
|
||||
|
||||
<Field name="email" validators={makeFieldValidator("email")}>
|
||||
{(field) => (
|
||||
<TextField
|
||||
label={t("email")}
|
||||
name="email"
|
||||
type="email"
|
||||
{...fieldProps}
|
||||
required
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={field.handleChange}
|
||||
errors={getErrors(field)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("phone-number")}
|
||||
name="phoneNumber"
|
||||
validators={makeFieldValidator("phoneNumber")}
|
||||
>
|
||||
{(field) => (
|
||||
<TextField
|
||||
label={t("phone-number")}
|
||||
type="tel"
|
||||
{...fieldProps}
|
||||
required
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={field.handleChange}
|
||||
errors={getErrors(field)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{/* Tickets + Invoice toggle */}
|
||||
<div className="grid grid-cols-2 gap-3 items-end">
|
||||
<FormControl required>
|
||||
<FormLabel>{t("tickets")}</FormLabel>
|
||||
<Input
|
||||
<Field
|
||||
name="tickets"
|
||||
validators={makeFieldValidator("tickets")}
|
||||
>
|
||||
{(field) => {
|
||||
const errors = getErrors(field);
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.tickets}
|
||||
onChange={handleChange}
|
||||
value={field.state.value ?? ""}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) =>
|
||||
field.handleChange(Number(e.target.value))
|
||||
}
|
||||
slotProps={{ input: { min: 1 } }}
|
||||
variant="soft"
|
||||
sx={{ borderRadius: "10px" }}
|
||||
/>
|
||||
{errors.length > 0 && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors[0]}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
</FormControl>
|
||||
<div className="flex items-center pb-2">
|
||||
<Checkbox
|
||||
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")}
|
||||
variant="outlined"
|
||||
/>
|
||||
@@ -323,96 +374,166 @@ export const MainForm = () => {
|
||||
<Typography level="title-sm" color="primary">
|
||||
{t("invoice-details")}
|
||||
</Typography>
|
||||
|
||||
<Field
|
||||
label={t("company-name")}
|
||||
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">
|
||||
<Field
|
||||
label={t("first-name")}
|
||||
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
|
||||
label={t("last-name")}
|
||||
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>
|
||||
<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
|
||||
label={t("postal-code")}
|
||||
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
|
||||
label={t("phone-number")}
|
||||
name="cpmPhoneNumber"
|
||||
validators={makeFieldValidator("cpmPhoneNumber")}
|
||||
>
|
||||
{(field) => (
|
||||
<TextField
|
||||
label={t("phone-number")}
|
||||
type="tel"
|
||||
{...fieldProps}
|
||||
required
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={field.handleChange}
|
||||
errors={getErrors(field)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("email")}
|
||||
name="cpmEmail"
|
||||
validators={makeFieldValidator("cpmEmail")}
|
||||
>
|
||||
{(field) => (
|
||||
<TextField
|
||||
label={t("email")}
|
||||
type="email"
|
||||
{...fieldProps}
|
||||
required
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={field.handleChange}
|
||||
errors={getErrors(field)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment method selection */}
|
||||
<FormControl required>
|
||||
<FormLabel>{t("select-payment-method")}</FormLabel>
|
||||
<Subscribe selector={(state) => state.values.paymentMethod}>
|
||||
{(paymentMethod) => (
|
||||
<div className="flex gap-2 flex-wrap mt-1">
|
||||
{PAYMENT_METHODS.map((method) => (
|
||||
<Button
|
||||
key={method}
|
||||
variant={
|
||||
formData.paymentMethod === method ? "solid" : "soft"
|
||||
}
|
||||
variant={paymentMethod === method ? "solid" : "soft"}
|
||||
color="primary"
|
||||
onClick={() =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
paymentMethod: method,
|
||||
}))
|
||||
onClick={() => {
|
||||
setFieldValue("paymentMethod", method);
|
||||
if (method === "paypal") {
|
||||
setQRmodal(true);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: "90px",
|
||||
borderRadius: "12px",
|
||||
py: 1.5,
|
||||
textTransform: "none",
|
||||
fontWeight: formData.paymentMethod === method ? 700 : 400,
|
||||
fontWeight: paymentMethod === method ? 700 : 400,
|
||||
}}
|
||||
>
|
||||
{PAYMENT_LABELS[method]}
|
||||
</Button>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* Submit button */}
|
||||
{mutateFormIsPending ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<CircularProgress />
|
||||
</div>
|
||||
) : (
|
||||
<Subscribe selector={(state) => state.values.paymentMethod}>
|
||||
{(paymentMethod) => (
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isLoading}
|
||||
disabled={!formData.paymentMethod}
|
||||
disabled={!paymentMethod}
|
||||
size="lg"
|
||||
sx={{
|
||||
mt: 2,
|
||||
@@ -427,11 +548,19 @@ export const MainForm = () => {
|
||||
>
|
||||
{t("submit")}
|
||||
</Button>
|
||||
)}
|
||||
</Subscribe>
|
||||
)}
|
||||
|
||||
{/* Alert message */}
|
||||
{msg && (
|
||||
<Alert color={msg.type} sx={{ borderRadius: "12px" }}>
|
||||
<strong>{msg.headline}:</strong> {msg.text}
|
||||
<Alert
|
||||
color={msg.type}
|
||||
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>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -16,6 +16,11 @@ export const SuccessPage = () => {
|
||||
setTickets(parseInt(params.get("tickets") ?? "0", 10));
|
||||
// Small delay so the CSS transition actually plays
|
||||
setTimeout(() => setAnimate(true), 100);
|
||||
|
||||
document.body.classList.add("success-bg");
|
||||
return () => {
|
||||
document.body.classList.remove("success-bg");
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -36,7 +41,7 @@ export const SuccessPage = () => {
|
||||
});
|
||||
|
||||
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
|
||||
variant="plain"
|
||||
sx={{
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
};
|
||||
@@ -14,7 +14,7 @@
|
||||
"user": "Benutzer",
|
||||
"next-id": "Nächste Eintragsnummer: ",
|
||||
"form-submitted-successfully": "Formular erfolgreich übermittelt!",
|
||||
"orm-submission-failed": "Formularübermittlung fehlgeschlagen.",
|
||||
"form-submission-failed": "Formularübermittlung fehlgeschlagen.",
|
||||
"success": "Erfolg",
|
||||
"error": "Fehler",
|
||||
"cash": "Bar",
|
||||
@@ -26,5 +26,13 @@
|
||||
"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",
|
||||
"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: "
|
||||
}
|
||||
@@ -27,5 +27,13 @@
|
||||
"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",
|
||||
"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: "
|
||||
}
|
||||
@@ -1,33 +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,
|
||||
) => {
|
||||
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 };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
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.");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// 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
+1
Submodule wg-easy-ca-lose added at 9581e6eacb
Reference in New Issue
Block a user