Compare commits
6 Commits
1cd0379654
...
v2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 636730d4b2 | |||
| 968e1e7727 | |||
| 6d31433321 | |||
| 542d832eab | |||
| 1f11a4ecab | |||
| ccb09caa4f |
@@ -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
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
[](#)
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -6,3 +6,7 @@ body,
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body.success-bg {
|
||||
background: linear-gradient(135deg, #0f172a, #111827);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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 |
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
+83
-188
@@ -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<string, string> = {
|
||||
@@ -183,7 +184,7 @@ export const MainForm = () => {
|
||||
|
||||
<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,100 +253,64 @@ 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 ?? ""}
|
||||
{(field) => (
|
||||
<TextField
|
||||
label={t("first-name")}
|
||||
required
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
variant="soft"
|
||||
sx={{ borderRadius: "10px" }}
|
||||
onChange={field.handleChange}
|
||||
errors={getErrors(field)}
|
||||
/>
|
||||
{errors.length > 0 && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors[0]}
|
||||
</span>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
</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 ?? ""}
|
||||
{(field) => (
|
||||
<TextField
|
||||
label={t("last-name")}
|
||||
required
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
variant="soft"
|
||||
sx={{ borderRadius: "10px" }}
|
||||
onChange={field.handleChange}
|
||||
errors={getErrors(field)}
|
||||
/>
|
||||
{errors.length > 0 && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors[0]}
|
||||
</span>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
</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"
|
||||
{(field) => (
|
||||
<TextField
|
||||
label={t("email")}
|
||||
type="email"
|
||||
sx={{ borderRadius: "10px" }}
|
||||
required
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={field.handleChange}
|
||||
errors={getErrors(field)}
|
||||
/>
|
||||
{errors.length > 0 && (
|
||||
<span className="text-red-500 text-sm">{errors[0]}</span>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
</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"
|
||||
{(field) => (
|
||||
<TextField
|
||||
label={t("phone-number")}
|
||||
type="tel"
|
||||
sx={{ borderRadius: "10px" }}
|
||||
required
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={field.handleChange}
|
||||
errors={getErrors(field)}
|
||||
/>
|
||||
{errors.length > 0 && (
|
||||
<span className="text-red-500 text-sm">{errors[0]}</span>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
|
||||
{/* Tickets + Invoice toggle */}
|
||||
@@ -414,26 +379,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 ?? ""}
|
||||
{(field) => (
|
||||
<TextField
|
||||
label={t("company-name")}
|
||||
required
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
variant="soft"
|
||||
sx={{ borderRadius: "10px" }}
|
||||
onChange={field.handleChange}
|
||||
errors={getErrors(field)}
|
||||
/>
|
||||
{errors.length > 0 && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors[0]}
|
||||
</span>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -441,156 +396,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 ?? ""}
|
||||
{(field) => (
|
||||
<TextField
|
||||
label={t("first-name")}
|
||||
required
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
variant="soft"
|
||||
sx={{ borderRadius: "10px" }}
|
||||
onChange={field.handleChange}
|
||||
errors={getErrors(field)}
|
||||
/>
|
||||
{errors.length > 0 && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors[0]}
|
||||
</span>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
</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 ?? ""}
|
||||
{(field) => (
|
||||
<TextField
|
||||
label={t("last-name")}
|
||||
required
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
variant="soft"
|
||||
sx={{ borderRadius: "10px" }}
|
||||
onChange={field.handleChange}
|
||||
errors={getErrors(field)}
|
||||
/>
|
||||
{errors.length > 0 && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors[0]}
|
||||
</span>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
</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 ?? ""}
|
||||
{(field) => (
|
||||
<TextField
|
||||
label={t("street")}
|
||||
required
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
variant="soft"
|
||||
sx={{ borderRadius: "10px" }}
|
||||
onChange={field.handleChange}
|
||||
errors={getErrors(field)}
|
||||
/>
|
||||
{errors.length > 0 && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors[0]}
|
||||
</span>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
</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 ?? ""}
|
||||
{(field) => (
|
||||
<TextField
|
||||
label={t("postal-code")}
|
||||
required
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
variant="soft"
|
||||
sx={{ borderRadius: "10px" }}
|
||||
onChange={field.handleChange}
|
||||
errors={getErrors(field)}
|
||||
/>
|
||||
{errors.length > 0 && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors[0]}
|
||||
</span>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
</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"
|
||||
{(field) => (
|
||||
<TextField
|
||||
label={t("phone-number")}
|
||||
type="tel"
|
||||
sx={{ borderRadius: "10px" }}
|
||||
required
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={field.handleChange}
|
||||
errors={getErrors(field)}
|
||||
/>
|
||||
{errors.length > 0 && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors[0]}
|
||||
</span>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
</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"
|
||||
{(field) => (
|
||||
<TextField
|
||||
label={t("email")}
|
||||
type="email"
|
||||
sx={{ borderRadius: "10px" }}
|
||||
required
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={field.handleChange}
|
||||
errors={getErrors(field)}
|
||||
/>
|
||||
{errors.length > 0 && (
|
||||
<span className="text-red-500 text-sm">
|
||||
{errors[0]}
|
||||
</span>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -33,5 +33,6 @@
|
||||
"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: "
|
||||
}
|
||||
@@ -33,6 +33,7 @@
|
||||
"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: "
|
||||
}
|
||||
Reference in New Issue
Block a user