6 Commits

Author SHA1 Message Date
theis.gaedigk 636730d4b2 changed branch link 2026-05-24 14:19:09 +02:00
theis.gaedigk 968e1e7727 foxed readme issue 2026-05-24 14:18:33 +02:00
theis.gaedigk 6d31433321 updated readme 2026-05-24 14:16:28 +02:00
theis.gaedigk 542d832eab improved design 2026-05-24 13:55:12 +02:00
theis.gaedigk 1f11a4ecab added page footer 2026-05-24 13:50:41 +02:00
theis.gaedigk ccb09caa4f refactored code 2026-05-24 13:26:52 +02:00
11 changed files with 241 additions and 234 deletions
+12 -3
View File
@@ -1,4 +1,4 @@
# CA-Lose # LuckySign
Ticket intake and validation app with a React frontend and an Express + MySQL backend. 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) ![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) ![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) ![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) ![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) ![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) ![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) ![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) ![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) ![Nginx](https://img.shields.io/badge/Nginx-009639?logo=nginx&logoColor=fff&style=flat)
### Production Tech Stack
> **Note** For production, check the [prod branch](https://git.the1s.de/theis.gaedigk/ca-lose/src/branch/prod/) which contains an VPN git submodule of wg-easy to run the app securely on a private network. It also contains a dnsmasq container to resolve the backend service name from the frontend container. The main branch is meant for local development and testing, so it doesn't include those components to keep things simple.
![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 ## Project Structure
- Frontend (Vite + React + Tailwind + MUI): [frontend](frontend) - 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: 1. Set the database password env var used by Docker Compose:
```bash ```bash
export DB_PASSWORD=your_password DB_PASSWORD=your_password
``` ```
2. Start MySQL and the backend: 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 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)). - 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 ## Local Development
+4
View File
@@ -6,3 +6,7 @@ body,
height: 100%; height: 100%;
margin: 0; margin: 0;
} }
body.success-bg {
background: linear-gradient(135deg, #0f172a, #111827);
}
+12 -6
View File
@@ -2,15 +2,21 @@ import "./App.css";
import { BrowserRouter, Route, Routes } from "react-router-dom"; import { BrowserRouter, Route, Routes } from "react-router-dom";
import { MainForm } from "./pages/MainForm"; import { MainForm } from "./pages/MainForm";
import { SuccessPage } from "./pages/SuccessPage"; import { SuccessPage } from "./pages/SuccessPage";
import { PageFooter } from "./components/PageFooter";
function App() { function App() {
return ( return (
<BrowserRouter> <div className="min-h-screen flex flex-col">
<Routes> <BrowserRouter>
<Route path="/" element={<MainForm />} /> <main className="flex-1 flex">
<Route path="/success" element={<SuccessPage />} /> <Routes>
</Routes> <Route path="/" element={<MainForm />} />
</BrowserRouter> <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 sm:pr-4"
>
{t("footer-headline")}
<Link
href="https://portfolio-theis.de/"
target="_blank"
rel="noreferrer"
className="ml-2 inline-flex items-center text-slate-700 underline decoration-slate-300 underline-offset-4 hover:text-slate-900"
>
portfolio-theis.de
</Link>
</Typography>
<div className="flex items-center justify-center sm:justify-end sm:flex-none">
<img
src={qrCode}
alt="https://portfolio-theis.de/"
className="h-20 w-20 shrink-0 rounded-md border border-slate-200 object-contain"
/>
</div>
</div>
</div>
</Sheet>
</footer>
);
};
+31
View File
@@ -0,0 +1,31 @@
import type { TextFieldProps } from "../config/interfaces.config";
import { FormControl, FormLabel, Input } from "@mui/joy";
export const TextField = ({
label,
type = "text",
required,
errors,
value,
onBlur,
onChange,
slotProps,
afterInput,
}: TextFieldProps) => (
<FormControl required={required}>
<FormLabel>{label}</FormLabel>
<Input
value={value ?? ""}
onBlur={onBlur}
onChange={(e) => onChange(e.target.value)}
type={type}
variant="soft"
sx={{ borderRadius: "10px" }}
slotProps={slotProps}
/>
{afterInput}
{errors[0] ? (
<span className="text-red-500 text-sm">{errors[0]}</span>
) : null}
</FormControl>
);
+13
View File
@@ -1,5 +1,6 @@
import z from "zod"; import z from "zod";
import validator from "validator"; import validator from "validator";
import type { ReactNode } from "react";
export interface FormData { export interface FormData {
firstName: string; firstName: string;
@@ -23,6 +24,18 @@ export interface Message {
text: string; text: string;
} }
export type TextFieldProps = {
label: string;
type?: "text" | "email" | "tel" | "number";
required?: boolean;
errors: string[];
onBlur: () => void;
onChange: (value: string) => void;
value: string | number | null | undefined;
slotProps?: { input?: Record<string, unknown> };
afterInput?: ReactNode;
};
export const createFormSchema = ( export const createFormSchema = (
t: (key: string) => string, t: (key: string) => string,
invoice: boolean, invoice: boolean,
+116 -221
View File
@@ -28,6 +28,7 @@ import { useForm } from "@tanstack/react-form";
import { changeTranslation } from "../utils/uxFncs"; import { changeTranslation } from "../utils/uxFncs";
import { createFormSchema } from "../config/interfaces.config"; import { createFormSchema } from "../config/interfaces.config";
import type { ZodObject, ZodRawShape } from "zod"; import type { ZodObject, ZodRawShape } from "zod";
import { TextField } from "../components/TextField";
const PAYMENT_METHODS = ["bar", "paypal", "andere"] as const; const PAYMENT_METHODS = ["bar", "paypal", "andere"] as const;
const PAYMENT_LABELS: Record<string, string> = { const PAYMENT_LABELS: Record<string, string> = {
@@ -183,7 +184,7 @@ export const MainForm = () => {
<QRcodeModal setQRmodal={setQRmodal} QRmodal={QRmodal} /> <QRcodeModal setQRmodal={setQRmodal} QRmodal={QRmodal} />
<div className="min-h-screen w-full flex items-center justify-center from-slate-100 to-blue-50 p-4"> <div className="flex-1 w-full flex items-center justify-center from-slate-100 to-blue-50 p-4">
<Sheet <Sheet
variant="plain" variant="plain"
className="w-full" className="w-full"
@@ -252,100 +253,64 @@ export const MainForm = () => {
name="firstName" name="firstName"
validators={makeFieldValidator("firstName")} validators={makeFieldValidator("firstName")}
> >
{(field) => { {(field) => (
const errors = getErrors(field); <TextField
return ( label={t("first-name")}
<FormControl required> required
<FormLabel>{t("first-name")}</FormLabel> value={field.state.value}
<Input onBlur={field.handleBlur}
value={field.state.value ?? ""} onChange={field.handleChange}
onBlur={field.handleBlur} errors={getErrors(field)}
onChange={(e) => field.handleChange(e.target.value)} />
variant="soft" )}
sx={{ borderRadius: "10px" }}
/>
{errors.length > 0 && (
<span className="text-red-500 text-sm">
{errors[0]}
</span>
)}
</FormControl>
);
}}
</Field> </Field>
<Field <Field
name="lastName" name="lastName"
validators={makeFieldValidator("lastName")} validators={makeFieldValidator("lastName")}
> >
{(field) => { {(field) => (
const errors = getErrors(field); <TextField
return ( label={t("last-name")}
<FormControl required> required
<FormLabel>{t("last-name")}</FormLabel> value={field.state.value}
<Input onBlur={field.handleBlur}
value={field.state.value ?? ""} onChange={field.handleChange}
onBlur={field.handleBlur} errors={getErrors(field)}
onChange={(e) => field.handleChange(e.target.value)} />
variant="soft" )}
sx={{ borderRadius: "10px" }}
/>
{errors.length > 0 && (
<span className="text-red-500 text-sm">
{errors[0]}
</span>
)}
</FormControl>
);
}}
</Field> </Field>
</div> </div>
<Field name="email" validators={makeFieldValidator("email")}> <Field name="email" validators={makeFieldValidator("email")}>
{(field) => { {(field) => (
const errors = getErrors(field); <TextField
return ( label={t("email")}
<FormControl required> type="email"
<FormLabel>{t("email")}</FormLabel> required
<Input value={field.state.value}
value={field.state.value ?? ""} onBlur={field.handleBlur}
onBlur={field.handleBlur} onChange={field.handleChange}
onChange={(e) => field.handleChange(e.target.value)} errors={getErrors(field)}
variant="soft" />
type="email" )}
sx={{ borderRadius: "10px" }}
/>
{errors.length > 0 && (
<span className="text-red-500 text-sm">{errors[0]}</span>
)}
</FormControl>
);
}}
</Field> </Field>
<Field <Field
name="phoneNumber" name="phoneNumber"
validators={makeFieldValidator("phoneNumber")} validators={makeFieldValidator("phoneNumber")}
> >
{(field) => { {(field) => (
const errors = getErrors(field); <TextField
return ( label={t("phone-number")}
<FormControl required> type="tel"
<FormLabel>{t("phone-number")}</FormLabel> required
<Input value={field.state.value}
value={field.state.value ?? ""} onBlur={field.handleBlur}
onBlur={field.handleBlur} onChange={field.handleChange}
onChange={(e) => field.handleChange(e.target.value)} errors={getErrors(field)}
variant="soft" />
type="tel" )}
sx={{ borderRadius: "10px" }}
/>
{errors.length > 0 && (
<span className="text-red-500 text-sm">{errors[0]}</span>
)}
</FormControl>
);
}}
</Field> </Field>
{/* Tickets + Invoice toggle */} {/* Tickets + Invoice toggle */}
@@ -414,26 +379,16 @@ export const MainForm = () => {
name="companyName" name="companyName"
validators={makeFieldValidator("companyName")} validators={makeFieldValidator("companyName")}
> >
{(field) => { {(field) => (
const errors = getErrors(field); <TextField
return ( label={t("company-name")}
<FormControl required> required
<FormLabel>{t("company-name")}</FormLabel> value={field.state.value}
<Input onBlur={field.handleBlur}
value={field.state.value ?? ""} onChange={field.handleChange}
onBlur={field.handleBlur} errors={getErrors(field)}
onChange={(e) => field.handleChange(e.target.value)} />
variant="soft" )}
sx={{ borderRadius: "10px" }}
/>
{errors.length > 0 && (
<span className="text-red-500 text-sm">
{errors[0]}
</span>
)}
</FormControl>
);
}}
</Field> </Field>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -441,156 +396,96 @@ export const MainForm = () => {
name="cmpFirstName" name="cmpFirstName"
validators={makeFieldValidator("cmpFirstName")} validators={makeFieldValidator("cmpFirstName")}
> >
{(field) => { {(field) => (
const errors = getErrors(field); <TextField
return ( label={t("first-name")}
<FormControl required> required
<FormLabel>{t("first-name")}</FormLabel> value={field.state.value}
<Input onBlur={field.handleBlur}
value={field.state.value ?? ""} onChange={field.handleChange}
onBlur={field.handleBlur} errors={getErrors(field)}
onChange={(e) => field.handleChange(e.target.value)} />
variant="soft" )}
sx={{ borderRadius: "10px" }}
/>
{errors.length > 0 && (
<span className="text-red-500 text-sm">
{errors[0]}
</span>
)}
</FormControl>
);
}}
</Field> </Field>
<Field <Field
name="cpmLastName" name="cpmLastName"
validators={makeFieldValidator("cpmLastName")} validators={makeFieldValidator("cpmLastName")}
> >
{(field) => { {(field) => (
const errors = getErrors(field); <TextField
return ( label={t("last-name")}
<FormControl required> required
<FormLabel>{t("last-name")}</FormLabel> value={field.state.value}
<Input onBlur={field.handleBlur}
value={field.state.value ?? ""} onChange={field.handleChange}
onBlur={field.handleBlur} errors={getErrors(field)}
onChange={(e) => field.handleChange(e.target.value)} />
variant="soft" )}
sx={{ borderRadius: "10px" }}
/>
{errors.length > 0 && (
<span className="text-red-500 text-sm">
{errors[0]}
</span>
)}
</FormControl>
);
}}
</Field> </Field>
</div> </div>
<Field name="street" validators={makeFieldValidator("street")}> <Field name="street" validators={makeFieldValidator("street")}>
{(field) => { {(field) => (
const errors = getErrors(field); <TextField
return ( label={t("street")}
<FormControl required> required
<FormLabel>{t("street")}</FormLabel> value={field.state.value}
<Input onBlur={field.handleBlur}
value={field.state.value ?? ""} onChange={field.handleChange}
onBlur={field.handleBlur} errors={getErrors(field)}
onChange={(e) => field.handleChange(e.target.value)} />
variant="soft" )}
sx={{ borderRadius: "10px" }}
/>
{errors.length > 0 && (
<span className="text-red-500 text-sm">
{errors[0]}
</span>
)}
</FormControl>
);
}}
</Field> </Field>
<Field <Field
name="postalCode" name="postalCode"
validators={makeFieldValidator("postalCode")} validators={makeFieldValidator("postalCode")}
> >
{(field) => { {(field) => (
const errors = getErrors(field); <TextField
return ( label={t("postal-code")}
<FormControl required> required
<FormLabel>{t("postal-code")}</FormLabel> value={field.state.value}
<Input onBlur={field.handleBlur}
value={field.state.value ?? ""} onChange={field.handleChange}
onBlur={field.handleBlur} errors={getErrors(field)}
onChange={(e) => field.handleChange(e.target.value)} />
variant="soft" )}
sx={{ borderRadius: "10px" }}
/>
{errors.length > 0 && (
<span className="text-red-500 text-sm">
{errors[0]}
</span>
)}
</FormControl>
);
}}
</Field> </Field>
<Field <Field
name="cpmPhoneNumber" name="cpmPhoneNumber"
validators={makeFieldValidator("cpmPhoneNumber")} validators={makeFieldValidator("cpmPhoneNumber")}
> >
{(field) => { {(field) => (
const errors = getErrors(field); <TextField
return ( label={t("phone-number")}
<FormControl required> type="tel"
<FormLabel>{t("phone-number")}</FormLabel> required
<Input value={field.state.value}
value={field.state.value ?? ""} onBlur={field.handleBlur}
onBlur={field.handleBlur} onChange={field.handleChange}
onChange={(e) => field.handleChange(e.target.value)} errors={getErrors(field)}
variant="soft" />
type="tel" )}
sx={{ borderRadius: "10px" }}
/>
{errors.length > 0 && (
<span className="text-red-500 text-sm">
{errors[0]}
</span>
)}
</FormControl>
);
}}
</Field> </Field>
<Field <Field
name="cpmEmail" name="cpmEmail"
validators={makeFieldValidator("cpmEmail")} validators={makeFieldValidator("cpmEmail")}
> >
{(field) => { {(field) => (
const errors = getErrors(field); <TextField
return ( label={t("email")}
<FormControl required> type="email"
<FormLabel>{t("email")}</FormLabel> required
<Input value={field.state.value}
value={field.state.value ?? ""} onBlur={field.handleBlur}
onBlur={field.handleBlur} onChange={field.handleChange}
onChange={(e) => field.handleChange(e.target.value)} errors={getErrors(field)}
variant="soft" />
type="email" )}
sx={{ borderRadius: "10px" }}
/>
{errors.length > 0 && (
<span className="text-red-500 text-sm">
{errors[0]}
</span>
)}
</FormControl>
);
}}
</Field> </Field>
</div> </div>
)} )}
+6 -1
View File
@@ -16,6 +16,11 @@ export const SuccessPage = () => {
setTickets(parseInt(params.get("tickets") ?? "0", 10)); setTickets(parseInt(params.get("tickets") ?? "0", 10));
// Small delay so the CSS transition actually plays // Small delay so the CSS transition actually plays
setTimeout(() => setAnimate(true), 100); setTimeout(() => setAnimate(true), 100);
document.body.classList.add("success-bg");
return () => {
document.body.classList.remove("success-bg");
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -36,7 +41,7 @@ export const SuccessPage = () => {
}); });
return ( return (
<div className="min-h-screen w-full flex items-center justify-center bg-linear-to-br from-slate-800 to-slate-900 p-4"> <div className="flex-1 w-full flex items-center justify-center p-4">
<Sheet <Sheet
variant="plain" variant="plain"
sx={{ sx={{
+2 -1
View File
@@ -33,5 +33,6 @@
"set-username-text": "Um mit dem Losverkauf zu beginnen, musst du einen Benutzer oben links auswählen.", "set-username-text": "Um mit dem Losverkauf zu beginnen, musst du einen Benutzer oben links auswählen.",
"name-error": "Sie müssen einen Namen eingeben!", "name-error": "Sie müssen einen Namen eingeben!",
"email-error": "Sie müssen eine gültige E-Mail Adresse eingeben!", "email-error": "Sie müssen eine gültige E-Mail Adresse eingeben!",
"phone-error": "Sie müssen eine gültige Telefonnummer 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: "
} }
+3 -2
View File
@@ -33,6 +33,7 @@
"set-username-headline": "No user selected", "set-username-headline": "No user selected",
"set-username-text": "To start the ticket sale, you must select a user first from the top left.", "set-username-text": "To start the ticket sale, you must select a user first from the top left.",
"name-error": "You have to enter a name!", "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!" "phone-error": "You have to enter a vaild phone number!",
"footer-headline": "This system was fully designed and developed by Theis Gaedigk. - Portfolio: "
} }