13 Commits

Author SHA1 Message Date
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 101bd5c060 edited wg-easy 2026-05-20 13:14:28 +02:00
theis.gaedigk f32931ded3 added wg-easy 2026-05-20 10:38:25 +02:00
theis.gaedigk 4ce1817bd0 added wg-easy 2026-05-20 10:37:31 +02:00
theis.gaedigk 11c2372cae outsourced modals 2026-05-19 21:54:02 +02:00
theis.gaedigk d5b6c9665c added new warning 2026-05-18 23:23:43 +02:00
theis.gaedigk ce2d0bb329 noted out 3sec pause 2026-05-18 20:41:35 +02:00
20 changed files with 806 additions and 403 deletions
+3
View File
@@ -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
+59 -134
View File
@@ -1,164 +1,89 @@
# CA-Lose
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)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=fff)
![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=fff)
![Tailwind CSS](https://img.shields.io/badge/Tailwind%20CSS-06B6D4?logo=tailwindcss&logoColor=fff)
![MUI](https://img.shields.io/badge/MUI-007FFF?logo=mui&logoColor=fff)
![i18next](https://img.shields.io/badge/i18next-26A69A?logo=i18next&logoColor=fff)
![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&style=flat)
![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=fff&style=flat)
![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&style=flat)
![React%20Query](https://img.shields.io/badge/React%20Query-FF4154?logo=reactquery&logoColor=fff&style=flat)
![React%20Router](https://img.shields.io/badge/React%20Router-CA4245?logo=reactrouter&logoColor=fff&style=flat)
![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)
![Express](https://img.shields.io/badge/Express-000000?logo=express&logoColor=fff)
![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=fff)
## Project Structure
![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff)
![Nginx](https://img.shields.io/badge/Nginx-009639?logo=nginx&logoColor=fff)
- 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)
## Architecture
## Quick Start (Docker)
- **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
In production, the frontend is served by **Nginx** and proxies `/backend/*` to the backend container.
## Repos & folders
```
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
export 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)).
```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)).
+119 -6
View File
@@ -16,6 +16,7 @@
"@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",
@@ -25,7 +26,9 @@
"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",
@@ -33,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",
@@ -1768,6 +1772,50 @@
"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",
@@ -1778,6 +1826,28 @@
"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",
@@ -1794,6 +1864,34 @@
"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",
@@ -1882,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",
@@ -3004,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": {
@@ -4093,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",
@@ -4229,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"
+5 -1
View File
@@ -18,6 +18,7 @@
"@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",
@@ -27,7 +28,9 @@
"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",
@@ -39,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",
+4
View File
@@ -6,3 +6,7 @@ body,
height: 100%;
margin: 0;
}
body.success-bg {
background: linear-gradient(135deg, #0f172a, #111827);
}
+12 -6
View File
@@ -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 (
<BrowserRouter>
<Routes>
<Route path="/" element={<MainForm />} />
<Route path="/success" element={<SuccessPage />} />
</Routes>
</BrowserRouter>
<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

+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"
>
{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>
<Typography level="body-sm" className="text-slate-500">
<img
src={qrCode}
alt="https://portfolio-theis.de/"
className="h-20 w-20 rounded-md border border-slate-200 object-contain"
/>
</Typography>
</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>
);
@@ -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>
);
};
+48
View File
@@ -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")),
});
+365 -250
View File
@@ -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,11 +12,7 @@ import {
Typography,
FormControl,
FormLabel,
Autocomplete,
ButtonGroup,
Modal,
ModalDialog,
ModalClose,
CircularProgress,
} from "@mui/joy";
import { submitFormData } from "../utils/api/form";
@@ -25,9 +20,15 @@ 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> = {
@@ -36,72 +37,101 @@ const PAYMENT_LABELS: Record<string, string> = {
andere: "Transfer",
};
const DEFAULT_FORM: FormData = {
firstName: "",
lastName: "",
email: "",
phoneNumber: "",
tickets: 1,
companyName: "",
cmpFirstName: "",
cpmLastName: "",
cpmEmail: "",
cpmPhoneNumber: "",
street: "",
postalCode: "",
paymentMethod: "",
};
// ─── 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>
);
/**
* 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, i18n } = useTranslation();
const { t } = useTranslation();
const queryClient = useQueryClient();
const [invoice, setInvoice] = useState(false);
const [msg, setMsg] = useState<Message | null>(null);
const [nextID, setNextID] = useState<number | null>(null);
const [selectedUser, setSelectedUser] = useState("");
const [formData, setFormData] = useState<FormData>(DEFAULT_FORM);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
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 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: "",
phoneNumber: "",
tickets: 1,
companyName: "",
cmpFirstName: "",
cpmLastName: "",
cpmEmail: "",
cpmPhoneNumber: "",
street: "",
postalCode: "",
paymentMethod: "",
},
onSubmit: async ({ value }) => {
const result = formSchema.safeParse(value);
if (!result.success) return;
mutateForm(value as FormData);
},
});
const getErrors = (field: {
state: { meta: { errorMap: Record<string, unknown> } };
}) => {
const normalizeErrors = (value: unknown) => {
if (Array.isArray(value)) return value as string[];
if (typeof value === "string" && value.length > 0) return [value];
return [] as string[];
};
const blurErrors = normalizeErrors(field.state.meta.errorMap["onBlur"]);
const submitErrors = normalizeErrors(field.state.meta.errorMap["onSubmit"]);
return [...blurErrors, ...submitErrors].filter(Boolean);
};
useEffect(() => {
const savedUser = Cookies.get("selectedUser");
if (savedUser) {
setSelectedUser(savedUser);
} else {
setMsg({
type: "warning",
headline: t("set-username-headline"),
text: t("set-username-text"),
});
}
}, []);
@@ -110,116 +140,51 @@ export const MainForm = () => {
queryFn: fetchUsers,
});
const { data: userData, isSuccess: userDataIsSuccess } = useQuery({
const { data: userData } = useQuery({
queryKey: ["user", selectedUser],
enabled: !!selectedUser,
queryFn: () => confirmUser(selectedUser),
});
const {
mutate: mutateForm,
isSuccess: mutateFormIsSuccess,
isPending: mutateFormIsPending,
isError: mutateFormIsError,
} = useMutation({
mutationFn: () => submitFormData(formData, selectedUser),
});
// Redirecting to success page if mutation was successful
useEffect(() => {
if (mutateFormIsSuccess) {
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=${formData.tickets}`;
}
if (mutateFormIsError) {
document.location.href = `/success?id=${nextID}&tickets=${values.tickets}`;
},
onError: () => {
queryClient.invalidateQueries({ queryKey: ["user", selectedUser] });
setMsg({
type: "danger",
headline: t("error"),
text: t("form-submission-failed"),
});
}
}, [mutateFormIsSuccess, mutateFormIsError]);
},
});
// Setting the nextID after a user is selected
useEffect(() => {
if (!userData) return;
setNextID(userData.nextID);
}, [userDataIsSuccess]);
const nextID = userData?.nextID ?? "N/A";
const handleUserSelection = (username: string | null) => {
if (username == null || username == "") {
return;
}
setSelectedUser(username);
};
const changeTranslation = () => {
const clientLng = i18n.language;
if (clientLng === "en") {
i18n.changeLanguage("de");
Cookies.set("language", "de");
} else if (clientLng === "de") {
i18n.changeLanguage("en");
Cookies.set("language", "en");
} else {
setMsg({
type: "danger",
headline: "Error",
text: "Cannot change langugage.",
});
}
};
useEffect(() => {
if (formData.paymentMethod === "paypal") {
setQRmodal(true);
}
}, [formData.paymentMethod]);
// Shorthand so we don't repeat formData + onChange on every Field usage
const fieldProps = { formData, onChange: handleChange };
return (
<>
<Modal open={showSelectUser}>
<ModalDialog color="primary" layout="center" size="lg">
<ModalClose onClick={() => setShowSelectUser(false)} />
<Typography>{t("user")}</Typography>
{/* User selection */}
<Autocomplete
options={usernameData?.users ?? []}
loading={usernameDataIsLoading}
loadingText={t("loading")}
value={selectedUser}
onChange={(_, value) => handleUserSelection(value)}
placeholder={t("user")}
variant="soft"
sx={{ borderRadius: "10px" }}
/>
</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>
<SelectUserModal
showSelectUser={showSelectUser}
setShowSelectUser={setShowSelectUser}
usernameData={usernameData}
usernameDataIsLoading={usernameDataIsLoading}
selectedUser={selectedUser}
handleUserSelection={handleUserSelection}
/>
<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"
@@ -245,7 +210,6 @@ export const MainForm = () => {
<IconButton onClick={() => setQRmodal(true)}>
<QrCodeIcon />
</IconButton>
{/* Language toggle */}
<IconButton onClick={changeTranslation}>
<TranslateIcon />
</IconButton>
@@ -265,11 +229,10 @@ export const MainForm = () => {
<form
onSubmit={(e) => {
e.preventDefault();
mutateForm();
handleSubmit();
}}
className="flex flex-col gap-4"
>
{/* Next ID badge */}
<Chip
size="lg"
variant="solid"
@@ -286,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} />
<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")}
type="email"
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
<Field
label={t("email")}
name="email"
type="email"
{...fieldProps}
/>
<Field
label={t("phone-number")}
name="phoneNumber"
type="tel"
{...fieldProps}
/>
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 toggle */}
<div className="grid grid-cols-2 gap-3 items-end">
<FormControl required>
<FormLabel>{t("tickets")}</FormLabel>
<Input
<Field
name="tickets"
type="number"
value={formData.tickets}
onChange={handleChange}
slotProps={{ input: { min: 1 } }}
variant="soft"
sx={{ borderRadius: "10px" }}
/>
validators={makeFieldValidator("tickets")}
>
{(field) => {
const errors = getErrors(field);
return (
<>
<Input
type="number"
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"
/>
@@ -333,89 +374,154 @@ 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"
type="tel"
{...fieldProps}
/>
validators={makeFieldValidator("cpmPhoneNumber")}
>
{(field) => (
<TextField
label={t("phone-number")}
type="tel"
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
<Field
label={t("email")}
name="cpmEmail"
type="email"
{...fieldProps}
/>
validators={makeFieldValidator("cpmEmail")}
>
{(field) => (
<TextField
label={t("email")}
type="email"
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>
<div className="flex gap-2 flex-wrap mt-1">
{PAYMENT_METHODS.map((method) => (
<Button
key={method}
variant={
formData.paymentMethod === method ? "solid" : "soft"
}
color="primary"
onClick={() =>
setFormData((prev) => ({
...prev,
paymentMethod: method,
}))
}
sx={{
flex: 1,
minWidth: "90px",
borderRadius: "12px",
py: 1.5,
textTransform: "none",
fontWeight: formData.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 selector={(state) => state.values.paymentMethod}>
{(paymentMethod) => (
<div className="flex gap-2 flex-wrap mt-1">
{PAYMENT_METHODS.map((method) => (
<Button
key={method}
variant={paymentMethod === method ? "solid" : "soft"}
color="primary"
onClick={() => {
setFieldValue("paymentMethod", method);
if (method === "paypal") {
setQRmodal(true);
}
}}
sx={{
flex: 1,
minWidth: "90px",
borderRadius: "12px",
py: 1.5,
textTransform: "none",
fontWeight: paymentMethod === method ? 700 : 400,
}}
>
{PAYMENT_LABELS[method]}
</Button>
))}
</div>
)}
</Subscribe>
</FormControl>
{mutateFormIsPending ? (
@@ -423,29 +529,38 @@ export const MainForm = () => {
<CircularProgress />
</div>
) : (
<Button
type="submit"
disabled={!formData.paymentMethod}
size="lg"
sx={{
mt: 2,
borderRadius: "14px",
fontWeight: 700,
letterSpacing: "0.05em",
background: "linear-gradient(135deg, #2563eb, #1d4ed8)",
"&:hover": {
background: "linear-gradient(135deg, #1d4ed8, #1e40af)",
},
}}
>
{t("submit")}
</Button>
<Subscribe selector={(state) => state.values.paymentMethod}>
{(paymentMethod) => (
<Button
type="submit"
disabled={!paymentMethod}
size="lg"
sx={{
mt: 2,
borderRadius: "14px",
fontWeight: 700,
letterSpacing: "0.05em",
background: "linear-gradient(135deg, #2563eb, #1d4ed8)",
"&:hover": {
background: "linear-gradient(135deg, #1d4ed8, #1e40af)",
},
}}
>
{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>
+6 -1
View File
@@ -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={{
+5 -2
View File
@@ -1,10 +1,13 @@
import { API_BASE } from "../../config/api.config";
import type { FormData } from "../../config/interfaces.config";
export const submitFormData = async (data: FormData, username: string) => {
export const submitFormData = async (
data: FormData,
username: string | null,
) => {
console.warn("submitFormData is fetching!");
await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds
// await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds
const response = await fetch(
`${API_BASE}/default/new-entry?username=${username}`,
+5 -1
View File
@@ -10,7 +10,11 @@ export const fetchUsers = async () => {
return data;
};
export const confirmUser = async (username: string) => {
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}`,
+7 -1
View File
@@ -28,5 +28,11 @@
"return-to-homepage": "Zurück",
"qr-text": "PayPal QR-Code der Claudius Akademie",
"loading": "Lädt...",
"greeting": "Hallo,"
"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: "
}
+7 -1
View File
@@ -29,5 +29,11 @@
"return-to-homepage": "Return",
"qr-text": "PayPal QR-Code from the Claudius Akademie",
"loading": "Loading...",
"greeting": "Hello,"
"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: "
}
+16
View File
@@ -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.");
}
};
+1
Submodule wg-easy-ca-lose added at 9581e6eacb