11 Commits

Author SHA1 Message Date
theis.gaedigk d8e8062990 fixed number input 2026-06-10 13:45:13 +02:00
theis.gaedigk 470fe75d86 added chocolates box 2026-06-09 17:43:56 +02:00
theis.gaedigk d49e62a280 updated wg easy module 2026-06-03 16:32:19 +02:00
theis.gaedigk a66b150d97 added prize draw name to tables 2026-06-03 16:29:45 +02:00
theis.gaedigk f7a0a3753c refactored code 2026-05-25 11:32:45 +02:00
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
21 changed files with 398 additions and 297 deletions
+1
View File
@@ -158,6 +158,7 @@ keys/
# Own files
ToDo.txt
PayPal-QR-Code.png
.docker/volumes
# only in development branch
next-env.d.ts
+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.
@@ -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](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
- 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
+13 -7
View File
@@ -11,14 +11,16 @@ const pool = mysql
})
.promise();
export const getUser = async () => {
const [rows] = await pool.query("SELECT username FROM users");
export const getInfo = async () => {
const [rows] = await pool.query("SELECT username FROM users;");
const [rows2] = await pool.query("SELECT name FROM prize_draws;");
if (rows.length > 0) {
if (rows.length > 0 && rows2.length > 0) {
const users = rows.map((r) => r.username);
return { users };
const prize_draws = rows2.map((n) => n.name);
return { users, prize_draws };
} else {
return { users: [], message: "No data found" };
return { message: "No data found" };
}
};
@@ -43,11 +45,13 @@ export const confirmUser = async (username) => {
const [createTable] = await pool.query(
`CREATE TABLE IF NOT EXISTS ?? (
id INT AUTO_INCREMENT PRIMARY KEY,
Verlosung VARCHAR(100) NOT NULL,
Vorname VARCHAR(100) NOT NULL,
Nachname Varchar(100) NOT NULL,
EMail Varchar(100) NOT NULL,
Telefonnummer Varchar(100) NOT NULL,
Lose INT NOT NULL,
Schokolade BOOLEAN NOT NULL,
Firmenname Varchar(100),
Vorname_Geschaeftlich Varchar(100),
Nachname_Geschaeftlich Varchar(100),
@@ -80,7 +84,7 @@ export const confirmUser = async (username) => {
}
};
export const newEntry = async (formData, username) => {
export const newEntry = async (formData, username, prizeDraw) => {
const confirmation = await confirmUser(username);
if (!confirmation || !confirmation.success) {
@@ -90,14 +94,16 @@ export const newEntry = async (formData, username) => {
const tableName = confirmation.tableName;
const [result] = await pool.query(
`INSERT INTO ?? (Vorname, Nachname, EMail, Telefonnummer, Lose, Firmenname, Vorname_Geschaeftlich, Nachname_Geschaeftlich, EMail_Geschaeftlich, Telefonnummer_Geschaeftlich, Strasse_Hausnr, Plz_Ort, Zahlungsmethode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO ?? (Verlosung, Vorname, Nachname, EMail, Telefonnummer, Lose, Schokolade, Firmenname, Vorname_Geschaeftlich, Nachname_Geschaeftlich, EMail_Geschaeftlich, Telefonnummer_Geschaeftlich, Strasse_Hausnr, Plz_Ort, Zahlungsmethode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
tableName,
prizeDraw,
formData.firstName,
formData.lastName,
formData.email,
formData.phoneNumber,
formData.tickets,
formData.chocolates,
formData.companyName,
formData.cmpFirstName,
formData.cpmLastName,
+14 -5
View File
@@ -3,24 +3,33 @@ import dotenv from "dotenv";
const router = express.Router();
dotenv.config();
import { getUser, newEntry, confirmUser } from "./frontend.data.js";
import { getInfo, newEntry, confirmUser } from "./frontend.data.js";
router.post("/new-entry", async (req, res) => {
const username = req.query.username;
const result = await newEntry(req.body, username);
const draw = req.query.draw;
const result = await newEntry(req.body, username, draw);
if (!result.success) {
return res.status(500).json({ message: "Form Data Invalid" });
}
res.sendStatus(204);
});
router.get("/users", async (req, res) => {
const users = await getUser();
res.json(users);
router.get("/info", async (req, res) => {
const info = await getInfo();
if (!info) {
return res.status(500).json({ message: "Server error" });
}
res.json(info);
});
router.get("/confirm-user", async (req, res) => {
const username = req.query.username;
if (!username) {
return res.status(400).json({ message: "Username is required" });
}
+9
View File
@@ -1,3 +1,5 @@
USE ca_lose;
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
@@ -6,9 +8,16 @@ CREATE TABLE users (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE prize_draws (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
/* This scheme does not have to be implemented manually. It always will be generated by the backend */
CREATE TABLE xx_DD_MM_YYYY (
id INT AUTO_INCREMENT PRIMARY KEY,
Verlosung VARCHAR(100) NOT NULL,
Vorname VARCHAR(100) NOT NULL,
Nachname Varchar(100) NOT NULL,
EMail Varchar(100) NOT NULL,
+1 -1
View File
@@ -32,7 +32,7 @@ services:
MYSQL_DATABASE: ca_lose
TZ: Europe/Berlin
volumes:
- ca-lose_mysql:/var/lib/mysql
- ./.docker/volumes/ca-lose_mysql:/var/lib/mysql
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
volumes:
+4
View File
@@ -6,3 +6,7 @@ body,
height: 100%;
margin: 0;
}
body.success-bg {
background: linear-gradient(135deg, #0f172a, #111827);
}
+12 -6
View File
@@ -2,15 +2,21 @@ import "./App.css";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { MainForm } from "./pages/MainForm";
import { SuccessPage } from "./pages/SuccessPage";
import { PageFooter } from "./components/PageFooter";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<MainForm />} />
<Route path="/success" element={<SuccessPage />} />
</Routes>
</BrowserRouter>
<div className="min-h-screen flex flex-col">
<BrowserRouter>
<main className="flex-1 flex">
<Routes>
<Route path="/" element={<MainForm />} />
<Route path="/success" element={<SuccessPage />} />
</Routes>
</main>
</BrowserRouter>
<PageFooter />
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+42
View File
@@ -0,0 +1,42 @@
import { Link, Sheet, Typography } from "@mui/joy";
import { useTranslation } from "react-i18next";
import qrCode from "../assets/Portfolio-QR-Code.png";
export const PageFooter = () => {
const { t } = useTranslation();
return (
<footer className="w-full mt-auto px-3 pb-3">
<Sheet
variant="soft"
className="mx-auto w-full max-w-3xl rounded-2xl border border-slate-200/70 bg-white/80 backdrop-blur"
>
<div className="flex flex-col gap-2 p-2.5 sm:p-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Typography
level="title-sm"
className="text-slate-800 tracking-wide 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>
);
@@ -10,10 +10,12 @@ import { useTranslation } from "react-i18next";
interface SelectUserModalProps {
showSelectUser: boolean;
setShowSelectUser: (value: boolean) => void;
usernameData: { users: string[] };
usernameDataIsLoading: boolean;
info: { users: string[]; prize_draws: string[] };
infoIsLoading: boolean;
selectedUser: string | null;
selectedDraw: string | null;
handleUserSelection: (value: string | null) => void;
handleDrawSelection: (value: string | null) => void;
}
export const SelectUserModal = (props: SelectUserModalProps) => {
@@ -25,8 +27,8 @@ export const SelectUserModal = (props: SelectUserModalProps) => {
<Typography>{t("user")}</Typography>
{/* User selection */}
<Autocomplete
options={props.usernameData?.users ?? []}
loading={props.usernameDataIsLoading}
options={props.info?.users ?? []}
loading={props.infoIsLoading}
loadingText={t("loading")}
value={props.selectedUser}
onChange={(_, value) => props.handleUserSelection(value)}
@@ -34,6 +36,17 @@ export const SelectUserModal = (props: SelectUserModalProps) => {
variant="soft"
sx={{ borderRadius: "10px" }}
/>
{/* Prize selection */}
<Autocomplete
options={props.info?.prize_draws ?? []}
loading={props.infoIsLoading}
loadingText={t("loading")}
value={props.selectedDraw}
onChange={(_, value) => props.handleDrawSelection(value)}
placeholder={t("prize_draws")}
variant="soft"
sx={{ borderRadius: "10px" }}
/>
</ModalDialog>
</Modal>
);
+14
View File
@@ -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<string, unknown> };
afterInput?: ReactNode;
};
export const createFormSchema = (
t: (key: string) => string,
invoice: boolean,
@@ -33,6 +46,7 @@ export const createFormSchema = (
email: z.email(t("email-error")),
phoneNumber: z.string(t("phone-error")).refine(validator.isMobilePhone),
tickets: z.number(t("ticket-error")).min(1),
chocolates: z.boolean(t("chocolates-error")),
companyName: invoice
? z.string().min(1, t("name-error"))
: z.string().optional(),
+171 -260
View File
@@ -21,50 +21,31 @@ import PersonIcon from "@mui/icons-material/Person";
import QrCodeIcon from "@mui/icons-material/QrCode";
import TranslateIcon from "@mui/icons-material/Translate";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { confirmUser, fetchUsers } from "../utils/api/users";
import { confirmUser, fetchInfo } 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<string, string> = {
bar: "Cash",
paypal: "PayPal",
andere: "Transfer",
};
/**
* Validates a single field against the full Zod schema.
* Returns the first error message for that field, or undefined if valid.
*/
function validateFieldWithZod(
schema: ZodObject<ZodRawShape>,
fieldName: string,
allValues: Record<string, unknown>,
): string | undefined {
const result = schema.safeParse(allValues);
if (result.success) return undefined;
const issue = result.error.issues.find(
(i) => i.path.length === 1 && i.path[0] === fieldName,
);
return issue?.message;
}
import { validateFieldWithZod } from "../utils/uxFncs";
import { TextField } from "../components/TextField";
import { PAYMENT_METHODS, PAYMENT_LABELS } from "../utils/uxFncs";
export const MainForm = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// States
const [invoice, setInvoice] = useState(false);
const [msg, setMsg] = useState<Message | null>(null);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const [selectedDraw, setSelectedDraw] = useState<string | null>(null);
const [showSelectUser, setShowSelectUser] = useState(false);
const [QRmodal, setQRmodal] = useState(false);
const formSchema = createFormSchema(t, invoice);
// Validates the fields and makes sure that the form is maching the zod schema
const makeFieldValidator = (fieldName: string) => ({
onSubmit: ({
fieldApi,
@@ -84,13 +65,15 @@ export const MainForm = () => {
},
});
// Creates a form with tanstack form
const { Field, Subscribe, handleSubmit, setFieldValue } = useForm({
defaultValues: {
firstName: "",
lastName: "",
email: "",
phoneNumber: "",
tickets: 1,
tickets: 0,
chocolates: false,
companyName: "",
cmpFirstName: "",
cpmLastName: "",
@@ -107,6 +90,7 @@ export const MainForm = () => {
},
});
// This function returns the errors for one field
const getErrors = (field: {
state: { meta: { errorMap: Record<string, unknown> } };
}) => {
@@ -121,8 +105,14 @@ export const MainForm = () => {
return [...blurErrors, ...submitErrors].filter(Boolean);
};
// useEffect that only executes on mount and is checking if there is a user selcted in the cookies
useEffect(() => {
const savedUser = Cookies.get("selectedUser");
const savedDraw = Cookies.get("selectedDraw");
if (savedDraw) {
setSelectedDraw(savedDraw);
}
if (savedUser) {
setSelectedUser(savedUser);
} else {
@@ -134,17 +124,20 @@ export const MainForm = () => {
}
}, []);
const { data: usernameData, isLoading: usernameDataIsLoading } = useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
// Fetch users tanstack query
const { data: info, isLoading: infoIsLoading } = useQuery({
queryKey: ["info"],
queryFn: fetchInfo,
});
// Query for validating selcted user
const { data: userData } = useQuery({
queryKey: ["user", selectedUser],
enabled: !!selectedUser,
queryFn: () => confirmUser(selectedUser),
});
// Tanstack query (mutate) for sending the form
const { mutate: mutateForm, isPending: mutateFormIsPending } = useMutation({
mutationFn: (values: FormData) => submitFormData(values, selectedUser),
onSuccess: (_, values) => {
@@ -163,6 +156,7 @@ export const MainForm = () => {
const nextID = userData?.nextID ?? "N/A";
// function for selecting user
const handleUserSelection = (username: string | null) => {
if (username == null || username == "") {
return;
@@ -170,20 +164,31 @@ export const MainForm = () => {
setSelectedUser(username);
};
// function for selecting draw
const handleDrawSelection = (draw: string | null) => {
if (draw == null || draw == "") {
return;
}
setSelectedDraw(draw);
Cookies.set("selectedDraw", draw);
};
return (
<>
<SelectUserModal
showSelectUser={showSelectUser}
setShowSelectUser={setShowSelectUser}
usernameData={usernameData}
usernameDataIsLoading={usernameDataIsLoading}
info={info}
infoIsLoading={infoIsLoading}
selectedUser={selectedUser}
selectedDraw={selectedDraw}
handleUserSelection={handleUserSelection}
handleDrawSelection={handleDrawSelection}
/>
<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
variant="plain"
className="w-full"
@@ -252,103 +257,67 @@ export const MainForm = () => {
name="firstName"
validators={makeFieldValidator("firstName")}
>
{(field) => {
const errors = getErrors(field);
return (
<FormControl required>
<FormLabel>{t("first-name")}</FormLabel>
<Input
value={field.state.value ?? ""}
onBlur={field.handleBlur}
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) => (
<TextField
label={t("first-name")}
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
<Field
name="lastName"
validators={makeFieldValidator("lastName")}
>
{(field) => {
const errors = getErrors(field);
return (
<FormControl required>
<FormLabel>{t("last-name")}</FormLabel>
<Input
value={field.state.value ?? ""}
onBlur={field.handleBlur}
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) => (
<TextField
label={t("last-name")}
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
</div>
<Field name="email" validators={makeFieldValidator("email")}>
{(field) => {
const errors = getErrors(field);
return (
<FormControl required>
<FormLabel>{t("email")}</FormLabel>
<Input
value={field.state.value ?? ""}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
variant="soft"
type="email"
sx={{ borderRadius: "10px" }}
/>
{errors.length > 0 && (
<span className="text-red-500 text-sm">{errors[0]}</span>
)}
</FormControl>
);
}}
{(field) => (
<TextField
label={t("email")}
type="email"
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
<Field
name="phoneNumber"
validators={makeFieldValidator("phoneNumber")}
>
{(field) => {
const errors = getErrors(field);
return (
<FormControl required>
<FormLabel>{t("phone-number")}</FormLabel>
<Input
value={field.state.value ?? ""}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
variant="soft"
type="tel"
sx={{ borderRadius: "10px" }}
/>
{errors.length > 0 && (
<span className="text-red-500 text-sm">{errors[0]}</span>
)}
</FormControl>
);
}}
{(field) => (
<TextField
label={t("phone-number")}
type="tel"
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
{/* Tickets + Invoice toggle */}
{/* Tickets + Invoice + Chocolate toggle */}
<div className="grid grid-cols-2 gap-3 items-end">
<FormControl required>
<FormLabel>{t("tickets")}</FormLabel>
@@ -362,12 +331,14 @@ export const MainForm = () => {
<>
<Input
type="number"
value={field.state.value ?? ""}
onBlur={field.handleBlur}
onChange={(e) =>
field.handleChange(Number(e.target.value))
value={
field.state.value === 0 ? "" : field.state.value
}
slotProps={{ input: { min: 1 } }}
onBlur={field.handleBlur}
onChange={(e) => {
const val = e.target.value;
field.handleChange(val === "" ? 0 : Number(val));
}}
variant="soft"
sx={{ borderRadius: "10px" }}
/>
@@ -381,7 +352,7 @@ export const MainForm = () => {
}}
</Field>
</FormControl>
<div className="flex items-center pb-2">
<div className="flex flex-col gap-2 pb-2">
<Checkbox
checked={invoice}
onChange={(e) => {
@@ -400,6 +371,16 @@ export const MainForm = () => {
label={t("invoice")}
variant="outlined"
/>
<Field name="chocolates">
{(field) => (
<Checkbox
checked={!!field.state.value}
onChange={(e) => field.handleChange(e.target.checked)}
label={t("chocolates")}
variant="outlined"
/>
)}
</Field>
</div>
</div>
@@ -414,26 +395,16 @@ export const MainForm = () => {
name="companyName"
validators={makeFieldValidator("companyName")}
>
{(field) => {
const errors = getErrors(field);
return (
<FormControl required>
<FormLabel>{t("company-name")}</FormLabel>
<Input
value={field.state.value ?? ""}
onBlur={field.handleBlur}
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) => (
<TextField
label={t("company-name")}
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
<div className="grid grid-cols-2 gap-3">
@@ -441,156 +412,96 @@ export const MainForm = () => {
name="cmpFirstName"
validators={makeFieldValidator("cmpFirstName")}
>
{(field) => {
const errors = getErrors(field);
return (
<FormControl required>
<FormLabel>{t("first-name")}</FormLabel>
<Input
value={field.state.value ?? ""}
onBlur={field.handleBlur}
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) => (
<TextField
label={t("first-name")}
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
<Field
name="cpmLastName"
validators={makeFieldValidator("cpmLastName")}
>
{(field) => {
const errors = getErrors(field);
return (
<FormControl required>
<FormLabel>{t("last-name")}</FormLabel>
<Input
value={field.state.value ?? ""}
onBlur={field.handleBlur}
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) => (
<TextField
label={t("last-name")}
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
</div>
<Field name="street" validators={makeFieldValidator("street")}>
{(field) => {
const errors = getErrors(field);
return (
<FormControl required>
<FormLabel>{t("street")}</FormLabel>
<Input
value={field.state.value ?? ""}
onBlur={field.handleBlur}
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) => (
<TextField
label={t("street")}
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
<Field
name="postalCode"
validators={makeFieldValidator("postalCode")}
>
{(field) => {
const errors = getErrors(field);
return (
<FormControl required>
<FormLabel>{t("postal-code")}</FormLabel>
<Input
value={field.state.value ?? ""}
onBlur={field.handleBlur}
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) => (
<TextField
label={t("postal-code")}
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
<Field
name="cpmPhoneNumber"
validators={makeFieldValidator("cpmPhoneNumber")}
>
{(field) => {
const errors = getErrors(field);
return (
<FormControl required>
<FormLabel>{t("phone-number")}</FormLabel>
<Input
value={field.state.value ?? ""}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
variant="soft"
type="tel"
sx={{ borderRadius: "10px" }}
/>
{errors.length > 0 && (
<span className="text-red-500 text-sm">
{errors[0]}
</span>
)}
</FormControl>
);
}}
{(field) => (
<TextField
label={t("phone-number")}
type="tel"
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
<Field
name="cpmEmail"
validators={makeFieldValidator("cpmEmail")}
>
{(field) => {
const errors = getErrors(field);
return (
<FormControl required>
<FormLabel>{t("email")}</FormLabel>
<Input
value={field.state.value ?? ""}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
variant="soft"
type="email"
sx={{ borderRadius: "10px" }}
/>
{errors.length > 0 && (
<span className="text-red-500 text-sm">
{errors[0]}
</span>
)}
</FormControl>
);
}}
{(field) => (
<TextField
label={t("email")}
type="email"
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
</div>
)}
+6 -1
View File
@@ -16,6 +16,11 @@ export const SuccessPage = () => {
setTickets(parseInt(params.get("tickets") ?? "0", 10));
// Small delay so the CSS transition actually plays
setTimeout(() => setAnimate(true), 100);
document.body.classList.add("success-bg");
return () => {
document.body.classList.remove("success-bg");
};
}, []);
useEffect(() => {
@@ -36,7 +41,7 @@ export const SuccessPage = () => {
});
return (
<div className="min-h-screen w-full flex items-center justify-center bg-linear-to-br from-slate-800 to-slate-900 p-4">
<div className="flex-1 w-full flex items-center justify-center p-4">
<Sheet
variant="plain"
sx={{
+8 -1
View File
@@ -1,5 +1,6 @@
import { API_BASE } from "../../config/api.config";
import type { FormData } from "../../config/interfaces.config";
import Cookies from "js-cookie";
export const submitFormData = async (
data: FormData,
@@ -7,10 +8,16 @@ export const submitFormData = async (
) => {
console.warn("submitFormData is fetching!");
const draw = Cookies.get("selectedDraw");
if (!draw) {
throw new Error("You need to set an prize draw!");
}
// await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds
const response = await fetch(
`${API_BASE}/default/new-entry?username=${username}`,
`${API_BASE}/default/new-entry?username=${username}&draw=${draw}`,
{
method: "POST",
headers: {
+3 -3
View File
@@ -1,10 +1,10 @@
import { API_BASE } from "../../config/api.config";
import Cookies from "js-cookie";
export const fetchUsers = async () => {
console.warn("fetchUsers is fetching!");
export const fetchInfo = async () => {
console.warn("fetchInfo is fetching!");
const response = await fetch(`${API_BASE}/default/users`);
const response = await fetch(`${API_BASE}/default/info`);
const data = await response.json();
return data;
+6 -2
View File
@@ -14,11 +14,12 @@
"user": "Benutzer",
"next-id": "Nächste Eintragsnummer: ",
"form-submitted-successfully": "Formular erfolgreich übermittelt!",
"form-submission-failed": "Formularübermittlung fehlgeschlagen.",
"form-submission-failed": "Formularübermittlung fehlgeschlagen. Haben Sie eine Verlosung ausgewählt?",
"success": "Erfolg",
"error": "Fehler",
"cash": "Bar",
"paypal": "PayPal",
"prize_draws": "Verlosungen",
"transfer": "Andere (notieren)",
"ticket-payment_one": "Sie haben erfolgreich {{count}} Los gekauft.",
"ticket-payment_other": "Sie haben erfolgreich {{count}} Lose gekauft.",
@@ -33,5 +34,8 @@
"set-username-text": "Um mit dem Losverkauf zu beginnen, musst du einen Benutzer oben links auswählen.",
"name-error": "Sie müssen einen Namen eingeben!",
"email-error": "Sie müssen eine gültige E-Mail Adresse eingeben!",
"phone-error": "Sie müssen eine gültige Telefonnummer eingeben!"
"phone-error": "Sie müssen eine gültige Telefonnummer eingeben!",
"footer-headline": "Dieses System wurde vollständig konzipiert und entwickelt von Theis Gaedigk. - Portfolio: ",
"chocolates": "Schokoladen",
"chocolates-error": "Dieses Feld kann nur wahr oder falsch sein."
}
+7 -3
View File
@@ -13,7 +13,8 @@
"user": "User",
"next-id": "Next Entry Number: ",
"form-submitted-successfully": "Form submitted successfully!",
"orm-submission-failed": "Form submission failed.",
"form-submission-failed": "Form submission failed. Have you set an prize draw?",
"prize_draws": "Prize draws",
"success": "Success",
"error": "Error",
"cash": "Cash",
@@ -33,6 +34,9 @@
"set-username-headline": "No user selected",
"set-username-text": "To start the ticket sale, you must select a user first from the top left.",
"name-error": "You have to enter a name!",
"email-error": "You have to enter a valid e-mail Adress!",
"phone-error": "You have to enter a vaild phone number!"
"email-error": "You have to enter a valid E-Mail adress!",
"phone-error": "You have to enter a vaild phone number!",
"footer-headline": "This system was fully designed and developed by Theis Gaedigk. - Portfolio: ",
"chocolates": "Chocolates",
"chocolates-error": "This field can only be true or false."
}
+26
View File
@@ -1,5 +1,7 @@
import i18n from "./i18n";
import Cookies from "js-cookie";
import { ZodObject } from "zod";
import type { ZodRawShape } from "zod";
export const changeTranslation = () => {
const clientLng = i18n.language;
@@ -14,3 +16,27 @@ export const changeTranslation = () => {
alert("Cannot change language.");
}
};
/**
* Validates a single field against the full Zod schema.
* Returns the first error message for that field, or undefined if valid.
*/
export function validateFieldWithZod(
schema: ZodObject<ZodRawShape>,
fieldName: string,
allValues: Record<string, unknown>,
): string | undefined {
const result = schema.safeParse(allValues);
if (result.success) return undefined;
const issue = result.error.issues.find(
(i) => i.path.length === 1 && i.path[0] === fieldName,
);
return issue?.message;
}
export const PAYMENT_METHODS = ["bar", "paypal", "andere"] as const;
export const PAYMENT_LABELS: Record<string, string> = {
bar: "Cash",
paypal: "PayPal",
andere: "Transfer",
};