From cca303c1f6573df7e53081bd64fc2b66f4375221 Mon Sep 17 00:00:00 2001 From: Theis Date: Wed, 20 May 2026 13:52:08 +0200 Subject: [PATCH 1/9] edited readme --- README.md | 193 +++++++++++++++++------------------------------------- 1 file changed, 59 insertions(+), 134 deletions(-) diff --git a/README.md b/README.md index 832848e..1fc927c 100644 --- a/README.md +++ b/README.md @@ -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=` → 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 +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)). From 9dead72e1e1240f4012f66c9a1ae66bd7e09390f Mon Sep 17 00:00:00 2001 From: Theis Date: Fri, 22 May 2026 23:32:42 +0200 Subject: [PATCH 2/9] updated wg-easy --- wg-easy-ca-lose | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 6915e60cec09bb91ff40e15b28624b4952ce8c90 Mon Sep 17 00:00:00 2001 From: Theis Date: Sun, 24 May 2026 13:07:06 +0200 Subject: [PATCH 3/9] implemented tanstack form --- frontend/package-lock.json | 125 ++++- frontend/package.json | 6 +- frontend/src/config/interfaces.config.ts | 35 ++ frontend/src/pages/MainForm.tsx | 623 +++++++++++++++------ frontend/src/utils/i18n/locales/de/de.json | 5 +- frontend/src/utils/i18n/locales/en/en.json | 5 +- frontend/src/utils/uxFncs.ts | 16 + 7 files changed, 622 insertions(+), 193 deletions(-) create mode 100644 frontend/src/utils/uxFncs.ts 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/config/interfaces.config.ts b/frontend/src/config/interfaces.config.ts index 2a62639..dde4d15 100644 --- a/frontend/src/config/interfaces.config.ts +++ b/frontend/src/config/interfaces.config.ts @@ -1,3 +1,6 @@ +import z from "zod"; +import validator from "validator"; + export interface FormData { firstName: string; lastName: string; @@ -19,3 +22,35 @@ export interface Message { headline: string; text: string; } + +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..40445b9 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,10 @@ 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"; const PAYMENT_METHODS = ["bar", "paypal", "andere"] as const; const PAYMENT_LABELS: Record = { @@ -33,65 +36,93 @@ 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: ({ + value, + fieldApi, + }: { + value: unknown; + fieldApi: { form: { state: { values: Record } } }; + }) => { + const allValues = fieldApi.form.state.values; + return validateFieldWithZod(formSchema, fieldName, allValues); + }, + onBlur: ({ + value, + fieldApi, + }: { + value: unknown; + 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 +150,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 +165,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 +232,10 @@ export const MainForm = () => {
{ e.preventDefault(); - mutateForm(); + handleSubmit(); }} className="flex flex-col gap-4" > - {/* Next ID badge */} { {/* Name row */}
- - + {t("first-name")} + + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} + + + {t("last-name")} + + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} +
+ {t("email")} + + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + type="email" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + {errors[0]} + )} + + ); + }} + + + {t("phone-number")} - + validators={makeFieldValidator("phoneNumber")} + > + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + type="tel" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + {errors[0]} + )} + + ); + }} + {/* 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 +413,224 @@ export const MainForm = () => { {t("invoice-details")} + + {t("company-name")} + validators={makeFieldValidator("companyName")} + > + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} + +
+ {t("first-name")} + validators={makeFieldValidator("cmpFirstName")} + > + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} + + + {t("last-name")} + validators={makeFieldValidator("cpmLastName")} + > + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} +
- + + {t("street")} + + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} + + + {t("postal-code")} + validators={makeFieldValidator("postalCode")} + > + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} + + + {t("phone-number")} + validators={makeFieldValidator("cpmPhoneNumber")} + > + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + type="tel" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} + + + {t("email")} + validators={makeFieldValidator("cpmEmail")} + > + {(field) => { + const errors = getErrors(field); + return ( + <> + field.handleChange(e.target.value)} + variant="soft" + type="email" + sx={{ borderRadius: "10px" }} + /> + {errors.length > 0 && ( + + {errors[0]} + + )} + + ); + }} +
)} {/* 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 +638,29 @@ export const MainForm = () => {
) : ( - + state.values.paymentMethod}> + {(paymentMethod) => ( + + )} + )} - {/* Message */} {msg && ( { + 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."); + } +}; From 39b15aec650ad8b555d63bbe1e06e3c5ecb98e7d Mon Sep 17 00:00:00 2001 From: Theis Date: Sun, 24 May 2026 13:08:13 +0200 Subject: [PATCH 4/9] refactored code --- frontend/src/pages/MainForm.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/frontend/src/pages/MainForm.tsx b/frontend/src/pages/MainForm.tsx index 40445b9..9939e1f 100644 --- a/frontend/src/pages/MainForm.tsx +++ b/frontend/src/pages/MainForm.tsx @@ -67,20 +67,16 @@ export const MainForm = () => { const makeFieldValidator = (fieldName: string) => ({ onSubmit: ({ - value, fieldApi, }: { - value: unknown; fieldApi: { form: { state: { values: Record } } }; }) => { const allValues = fieldApi.form.state.values; return validateFieldWithZod(formSchema, fieldName, allValues); }, onBlur: ({ - value, fieldApi, }: { - value: unknown; fieldApi: { form: { state: { values: Record } } }; }) => { const allValues = fieldApi.form.state.values; From 1cd03796541a739f0d89f7b1b50408480214b377 Mon Sep 17 00:00:00 2001 From: Theis Date: Sun, 24 May 2026 13:12:09 +0200 Subject: [PATCH 5/9] redesgigned page --- frontend/src/pages/MainForm.tsx | 66 ++++++++++++++++----------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/frontend/src/pages/MainForm.tsx b/frontend/src/pages/MainForm.tsx index 9939e1f..70f0ad4 100644 --- a/frontend/src/pages/MainForm.tsx +++ b/frontend/src/pages/MainForm.tsx @@ -248,7 +248,6 @@ export const MainForm = () => { {/* Name row */}
- {t("first-name")} { {(field) => { const errors = getErrors(field); return ( - <> + + {t("first-name")} { {errors[0]} )} - + ); }} - {t("last-name")} { {(field) => { const errors = getErrors(field); return ( - <> + + {t("last-name")} { {errors[0]} )} - + ); }}
- {t("email")} {(field) => { const errors = getErrors(field); return ( - <> + + {t("email")} { {errors.length > 0 && ( {errors[0]} )} - + ); }} - {t("phone-number")} { {(field) => { const errors = getErrors(field); return ( - <> + + {t("phone-number")} { {errors.length > 0 && ( {errors[0]} )} - + ); }} @@ -410,7 +410,6 @@ export const MainForm = () => { {t("invoice-details")} - {t("company-name")} { {(field) => { const errors = getErrors(field); return ( - <> + + {t("company-name")} { {errors[0]} )} - + ); }}
- {t("first-name")} { {(field) => { const errors = getErrors(field); return ( - <> + + {t("first-name")} { {errors[0]} )} - + ); }} - {t("last-name")} { {(field) => { const errors = getErrors(field); return ( - <> + + {t("last-name")} { {errors[0]} )} - + ); }}
- {t("street")} {(field) => { const errors = getErrors(field); return ( - <> + + {t("street")} { {errors[0]} )} - + ); }} - {t("postal-code")} { {(field) => { const errors = getErrors(field); return ( - <> + + {t("postal-code")} { {errors[0]} )} - + ); }} - {t("phone-number")} { {(field) => { const errors = getErrors(field); return ( - <> + + {t("phone-number")} { {errors[0]} )} - + ); }} - {t("email")} { {(field) => { const errors = getErrors(field); return ( - <> + + {t("email")} { {errors[0]} )} - + ); }} From ccb09caa4f8b890a2afdc542905fc00db644a484 Mon Sep 17 00:00:00 2001 From: Theis Date: Sun, 24 May 2026 13:26:52 +0200 Subject: [PATCH 6/9] refactored code --- frontend/src/components/TextField.tsx | 31 ++ frontend/src/config/interfaces.config.ts | 13 + frontend/src/pages/MainForm.tsx | 335 +++++++-------------- frontend/src/utils/i18n/locales/en/en.json | 2 +- 4 files changed, 160 insertions(+), 221 deletions(-) create mode 100644 frontend/src/components/TextField.tsx 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 dde4d15..2445779 100644 --- a/frontend/src/config/interfaces.config.ts +++ b/frontend/src/config/interfaces.config.ts @@ -1,5 +1,6 @@ import z from "zod"; import validator from "validator"; +import type { ReactNode } from "react"; export interface FormData { firstName: string; @@ -23,6 +24,18 @@ export interface Message { 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, diff --git a/frontend/src/pages/MainForm.tsx b/frontend/src/pages/MainForm.tsx index 70f0ad4..2c86891 100644 --- a/frontend/src/pages/MainForm.tsx +++ b/frontend/src/pages/MainForm.tsx @@ -28,6 +28,7 @@ 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 = { @@ -252,100 +253,64 @@ export const MainForm = () => { name="firstName" validators={makeFieldValidator("firstName")} > - {(field) => { - const errors = getErrors(field); - return ( - - {t("first-name")} - field.handleChange(e.target.value)} - variant="soft" - sx={{ borderRadius: "10px" }} - /> - {errors.length > 0 && ( - - {errors[0]} - - )} - - ); - }} + {(field) => ( + + )}
- {(field) => { - const errors = getErrors(field); - return ( - - {t("last-name")} - field.handleChange(e.target.value)} - variant="soft" - sx={{ borderRadius: "10px" }} - /> - {errors.length > 0 && ( - - {errors[0]} - - )} - - ); - }} + {(field) => ( + + )} - {(field) => { - const errors = getErrors(field); - return ( - - {t("email")} - field.handleChange(e.target.value)} - variant="soft" - type="email" - sx={{ borderRadius: "10px" }} - /> - {errors.length > 0 && ( - {errors[0]} - )} - - ); - }} + {(field) => ( + + )} - {(field) => { - const errors = getErrors(field); - return ( - - {t("phone-number")} - field.handleChange(e.target.value)} - variant="soft" - type="tel" - sx={{ borderRadius: "10px" }} - /> - {errors.length > 0 && ( - {errors[0]} - )} - - ); - }} + {(field) => ( + + )} {/* Tickets + Invoice toggle */} @@ -414,26 +379,16 @@ export const MainForm = () => { name="companyName" validators={makeFieldValidator("companyName")} > - {(field) => { - const errors = getErrors(field); - return ( - - {t("company-name")} - field.handleChange(e.target.value)} - variant="soft" - sx={{ borderRadius: "10px" }} - /> - {errors.length > 0 && ( - - {errors[0]} - - )} - - ); - }} + {(field) => ( + + )}
@@ -441,156 +396,96 @@ export const MainForm = () => { name="cmpFirstName" validators={makeFieldValidator("cmpFirstName")} > - {(field) => { - const errors = getErrors(field); - return ( - - {t("first-name")} - field.handleChange(e.target.value)} - variant="soft" - sx={{ borderRadius: "10px" }} - /> - {errors.length > 0 && ( - - {errors[0]} - - )} - - ); - }} + {(field) => ( + + )} - {(field) => { - const errors = getErrors(field); - return ( - - {t("last-name")} - field.handleChange(e.target.value)} - variant="soft" - sx={{ borderRadius: "10px" }} - /> - {errors.length > 0 && ( - - {errors[0]} - - )} - - ); - }} + {(field) => ( + + )}
- {(field) => { - const errors = getErrors(field); - return ( - - {t("street")} - field.handleChange(e.target.value)} - variant="soft" - sx={{ borderRadius: "10px" }} - /> - {errors.length > 0 && ( - - {errors[0]} - - )} - - ); - }} + {(field) => ( + + )} - {(field) => { - const errors = getErrors(field); - return ( - - {t("postal-code")} - field.handleChange(e.target.value)} - variant="soft" - sx={{ borderRadius: "10px" }} - /> - {errors.length > 0 && ( - - {errors[0]} - - )} - - ); - }} + {(field) => ( + + )} - {(field) => { - const errors = getErrors(field); - return ( - - {t("phone-number")} - field.handleChange(e.target.value)} - variant="soft" - type="tel" - sx={{ borderRadius: "10px" }} - /> - {errors.length > 0 && ( - - {errors[0]} - - )} - - ); - }} + {(field) => ( + + )} - {(field) => { - const errors = getErrors(field); - return ( - - {t("email")} - field.handleChange(e.target.value)} - variant="soft" - type="email" - sx={{ borderRadius: "10px" }} - /> - {errors.length > 0 && ( - - {errors[0]} - - )} - - ); - }} + {(field) => ( + + )} )} diff --git a/frontend/src/utils/i18n/locales/en/en.json b/frontend/src/utils/i18n/locales/en/en.json index a5e09fb..24f669e 100644 --- a/frontend/src/utils/i18n/locales/en/en.json +++ b/frontend/src/utils/i18n/locales/en/en.json @@ -33,6 +33,6 @@ "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!", + "email-error": "You have to enter a valid E-Mail adress!", "phone-error": "You have to enter a vaild phone number!" } \ No newline at end of file From 1f11a4ecabf99c1d9ee859e981481ac1f510c0b2 Mon Sep 17 00:00:00 2001 From: Theis Date: Sun, 24 May 2026 13:50:41 +0200 Subject: [PATCH 7/9] added page footer --- frontend/src/App.css | 4 ++ frontend/src/App.tsx | 18 ++++++--- frontend/src/assets/Portfolio-QR-Code.png | Bin 0 -> 4277 bytes frontend/src/components/PageFooter.tsx | 42 +++++++++++++++++++++ frontend/src/pages/MainForm.tsx | 2 +- frontend/src/pages/SuccessPage.tsx | 7 +++- frontend/src/utils/i18n/locales/de/de.json | 3 +- frontend/src/utils/i18n/locales/en/en.json | 3 +- 8 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 frontend/src/assets/Portfolio-QR-Code.png create mode 100644 frontend/src/components/PageFooter.tsx 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 0000000000000000000000000000000000000000..f3feb298f55d08eaa3a92f4ae28025e46a72399a GIT binary patch literal 4277 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 literal 0 HcmV?d00001 diff --git a/frontend/src/components/PageFooter.tsx b/frontend/src/components/PageFooter.tsx new file mode 100644 index 0000000..27af4be --- /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/pages/MainForm.tsx b/frontend/src/pages/MainForm.tsx index 2c86891..36a5df2 100644 --- a/frontend/src/pages/MainForm.tsx +++ b/frontend/src/pages/MainForm.tsx @@ -184,7 +184,7 @@ export const MainForm = () => { -
+
{ 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 ( -
+
Date: Sun, 24 May 2026 13:55:12 +0200 Subject: [PATCH 8/9] improved design --- frontend/src/components/PageFooter.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/PageFooter.tsx b/frontend/src/components/PageFooter.tsx index 27af4be..4d601ac 100644 --- a/frontend/src/components/PageFooter.tsx +++ b/frontend/src/components/PageFooter.tsx @@ -15,7 +15,7 @@ export const PageFooter = () => {
{t("footer-headline")} { portfolio-theis.de - +
https://portfolio-theis.de/ - +
From 6d3143332170ad314c8ac3ea8450d2f2520f4cc9 Mon Sep 17 00:00:00 2001 From: Theis Date: Sun, 24 May 2026 14:16:28 +0200 Subject: [PATCH 9/9] updated readme --- README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1fc927c..ccfda72 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# CA-Lose +# LuckySign Ticket intake and validation app with a React frontend and an Express + MySQL backend. @@ -9,14 +9,22 @@ Ticket intake and validation app with a React frontend and an Express + MySQL ba ![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) +![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) +### Production Tech Stack + +> **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. + +![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) + ## Project Structure - Frontend (Vite + React + Tailwind + MUI): [frontend](frontend) @@ -29,7 +37,7 @@ Ticket intake and validation app with a React frontend and an Express + MySQL ba 1. Set the database password env var used by Docker Compose: ```bash -export DB_PASSWORD=your_password +DB_PASSWORD=your_password ``` 2. Start MySQL and the backend: @@ -42,6 +50,7 @@ Notes: - The frontend service is commented out in [docker-compose.yml](docker-compose.yml). If you want the frontend container, uncomment that block and rebuild. - The frontend container uses Nginx and proxies /backend to the backend service (see [frontend/nginx.conf](frontend/nginx.conf)). +- In order to use the database properly, run the scheme and create some users in the users table. ## Local Development