diff --git a/README.md b/README.md index 832848e..ccfda72 100644 --- a/README.md +++ b/README.md @@ -1,164 +1,98 @@ -# CA-Lose +# LuckySign -CA-Lose is a small full-stack app for collecting ticket-sale/order form entries. +Ticket intake and validation app with a React frontend and an Express + MySQL backend. -## Tech stack +## Tech Stack -![React](https://img.shields.io/badge/React-20232A?logo=react&logoColor=61DAFB) -![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) +![TanStack%20Query](https://img.shields.io/badge/TanStack%20Query-FF4154?logo=reactquery&logoColor=fff&style=flat) +![React%20Router](https://img.shields.io/badge/React%20Router-CA4245?logo=reactrouter&logoColor=fff&style=flat) +[![TanStack%20Form](https://img.shields.io/badge/TanStack%20Form-EC5990?logo=reacthookform&logoColor=fff)](#) +![Node.js](https://img.shields.io/badge/Node.js-339933?logo=nodedotjs&logoColor=fff&style=flat) +![Express](https://img.shields.io/badge/Express-000000?logo=express&logoColor=fff&style=flat) +![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=fff&style=flat) +![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff&style=flat) +![Nginx](https://img.shields.io/badge/Nginx-009639?logo=nginx&logoColor=fff&style=flat) -![Node.js](https://img.shields.io/badge/Node.js-339933?logo=node.js&logoColor=fff) -![Express](https://img.shields.io/badge/Express-000000?logo=express&logoColor=fff) -![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=fff) +### Production Tech Stack -![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff) -![Nginx](https://img.shields.io/badge/Nginx-009639?logo=nginx&logoColor=fff) +> **Note**: For production, check the [prod 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 +![dnsmasq](https://img.shields.io/badge/dnsmasq-314B5F?logo=isc&logoColor=fff&style=flat) +![WireGuard](https://img.shields.io/badge/WireGuard-88171A?logo=wireguard&logoColor=fff&style=flat) -- **Frontend**: React + TypeScript + Vite (UI uses MUI + Tailwind, i18n via i18next) -- **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=` → validates user and returns metadata (e.g. next id) -- `POST /default/new-entry?username=` → 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)). diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e676de0..5985dea 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" diff --git a/frontend/package.json b/frontend/package.json index 8e872d4..bb4b617 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.css b/frontend/src/App.css index 7b53bf9..33d3253 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -6,3 +6,7 @@ body, height: 100%; margin: 0; } + +body.success-bg { + background: linear-gradient(135deg, #0f172a, #111827); +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b14f593..ece00e3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( - - - } /> - } /> - - +
+ +
+ + } /> + } /> + +
+
+ +
); } diff --git a/frontend/src/assets/Portfolio-QR-Code.png b/frontend/src/assets/Portfolio-QR-Code.png new file mode 100644 index 0000000..f3feb29 Binary files /dev/null and b/frontend/src/assets/Portfolio-QR-Code.png differ diff --git a/frontend/src/components/PageFooter.tsx b/frontend/src/components/PageFooter.tsx new file mode 100644 index 0000000..4d601ac --- /dev/null +++ b/frontend/src/components/PageFooter.tsx @@ -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 ( +
+ +
+
+ + {t("footer-headline")} + + portfolio-theis.de + + +
+ https://portfolio-theis.de/ +
+
+
+
+
+ ); +}; diff --git a/frontend/src/components/TextField.tsx b/frontend/src/components/TextField.tsx new file mode 100644 index 0000000..c527bb1 --- /dev/null +++ b/frontend/src/components/TextField.tsx @@ -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) => ( + + {label} + onChange(e.target.value)} + type={type} + variant="soft" + sx={{ borderRadius: "10px" }} + slotProps={slotProps} + /> + {afterInput} + {errors[0] ? ( + {errors[0]} + ) : null} + +); diff --git a/frontend/src/config/interfaces.config.ts b/frontend/src/config/interfaces.config.ts index 2a62639..2445779 100644 --- a/frontend/src/config/interfaces.config.ts +++ b/frontend/src/config/interfaces.config.ts @@ -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 }; + 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")), + }); diff --git a/frontend/src/pages/MainForm.tsx b/frontend/src/pages/MainForm.tsx index f6dacba..36a5df2 100644 --- a/frontend/src/pages/MainForm.tsx +++ b/frontend/src/pages/MainForm.tsx @@ -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, @@ -25,6 +24,11 @@ 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 = { @@ -33,65 +37,89 @@ const PAYMENT_LABELS: Record = { 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) => void; -}) => ( - - {label} - - -); +/** + * 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, + fieldName: string, + allValues: Record, +): 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(null); const [selectedUser, setSelectedUser] = useState(null); - const [formData, setFormData] = useState(DEFAULT_FORM); const [showSelectUser, setShowSelectUser] = useState(false); const [QRmodal, setQRmodal] = useState(false); - const handleChange = (e: React.ChangeEvent) => { - setFormData({ ...formData, [e.target.name]: e.target.value }); + const formSchema = createFormSchema(t, invoice); + + const makeFieldValidator = (fieldName: string) => ({ + onSubmit: ({ + fieldApi, + }: { + fieldApi: { form: { state: { values: Record } } }; + }) => { + const allValues = fieldApi.form.state.values; + return validateFieldWithZod(formSchema, fieldName, allValues); + }, + onBlur: ({ + fieldApi, + }: { + fieldApi: { form: { state: { values: Record } } }; + }) => { + 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 } }; + }) => { + 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(() => { @@ -119,10 +147,10 @@ export const MainForm = () => { }); const { mutate: mutateForm, isPending: mutateFormIsPending } = useMutation({ - mutationFn: () => submitFormData(formData, selectedUser), - onSuccess: () => { + mutationFn: (values: FormData) => submitFormData(values, selectedUser), + onSuccess: (_, values) => { queryClient.invalidateQueries({ queryKey: ["user", selectedUser] }); - document.location.href = `/success?id=${nextID}&tickets=${formData.tickets}`; + document.location.href = `/success?id=${nextID}&tickets=${values.tickets}`; }, onError: () => { queryClient.invalidateQueries({ queryKey: ["user", selectedUser] }); @@ -134,38 +162,15 @@ export const MainForm = () => { }, }); - // Setting the nextID after a user is selected 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.", - }); - } - }; - - // Shorthand so we don't repeat formData + onChange on every Field usage - const fieldProps = { formData, onChange: handleChange }; - return ( <> { -
+
{ setQRmodal(true)}> - {/* Language toggle */} @@ -225,11 +229,10 @@ export const MainForm = () => {
{ e.preventDefault(); - mutateForm(); + handleSubmit(); }} className="flex flex-col gap-4" > - {/* Next ID badge */} { {/* Name row */}
- - + + {(field) => ( + + )} + + + + {(field) => ( + + )} +
+ + {(field) => ( + + )} + + - + validators={makeFieldValidator("phoneNumber")} + > + {(field) => ( + + )} + {/* Tickets + Invoice toggle */}
{t("tickets")} - + validators={makeFieldValidator("tickets")} + > + {(field) => { + const errors = getErrors(field); + return ( + <> + + field.handleChange(Number(e.target.value)) + } + slotProps={{ input: { min: 1 } }} + variant="soft" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} +
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" /> @@ -293,92 +374,154 @@ export const MainForm = () => { {t("invoice-details")} + + validators={makeFieldValidator("companyName")} + > + {(field) => ( + + )} + +
+ validators={makeFieldValidator("cmpFirstName")} + > + {(field) => ( + + )} + + + validators={makeFieldValidator("cpmLastName")} + > + {(field) => ( + + )} +
- + + + {(field) => ( + + )} + + + validators={makeFieldValidator("postalCode")} + > + {(field) => ( + + )} + + + validators={makeFieldValidator("cpmPhoneNumber")} + > + {(field) => ( + + )} + + + validators={makeFieldValidator("cpmEmail")} + > + {(field) => ( + + )} +
)} {/* Payment method selection */} {t("select-payment-method")} -
- {PAYMENT_METHODS.map((method) => ( - - ))} -
- {/* Hidden required input to enforce payment selection on submit */} - {!formData.paymentMethod && ( - {}} - style={{ - opacity: 0, - width: 0, - height: 0, - position: "absolute", - }} - /> - )} + state.values.paymentMethod}> + {(paymentMethod) => ( +
+ {PAYMENT_METHODS.map((method) => ( + + ))} +
+ )} +
{mutateFormIsPending ? ( @@ -386,26 +529,29 @@ export const MainForm = () => {
) : ( - + state.values.paymentMethod}> + {(paymentMethod) => ( + + )} + )} - {/* Message */} {msg && ( { 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 ( -
+
{ + 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."); + } +}; diff --git a/wg-easy-ca-lose b/wg-easy-ca-lose index 7b5ba95..9581e6e 160000 --- a/wg-easy-ca-lose +++ b/wg-easy-ca-lose @@ -1 +1 @@ -Subproject commit 7b5ba959386db5a4336d60d6b5cd0c5b50df2549 +Subproject commit 9581e6eacbbbe02e77486fd139a8153d3704389a