Compare commits

..

13 Commits

Author SHA1 Message Date
theis.gaedigk e346cf9445 e 2026-01-20 20:33:41 +01:00
theis.gaedigk c030b6dbe6 Merge branch 'dev' into prod 2026-01-20 20:33:31 +01:00
theis.gaedigk 6f26b9bbc3 e 2026-01-20 20:22:59 +01:00
theis.gaedigk a34a70572f edited 2026-01-20 20:19:12 +01:00
theis.gaedigk 4b3c8a2424 edited compose file 2026-01-20 20:17:53 +01:00
theis.gaedigk 568b3bf495 edited 2026-01-20 20:08:14 +01:00
theis.gaedigk 5653d32857 fix: update WireGuard PASSWORD_HASH to a static value 2026-01-20 20:06:44 +01:00
theis.gaedigk 7cf5b8df48 Merge branch 'dev' into prod 2026-01-20 20:03:51 +01:00
theis.gaedigk 65c5fc0f8f Merge branch 'dev' into prod 2026-01-20 19:59:27 +01:00
theis.gaedigk b626a67907 Merge branch 'dev' into prod 2026-01-20 19:46:56 +01:00
theis.gaedigk 6643a176a6 Merge branch 'dev' into prod 2026-01-20 19:43:53 +01:00
theis.gaedigk 89803754a7 Merge branch 'dev' into prod 2026-01-20 19:38:27 +01:00
theis.gaedigk 5052b3e83a changed fetch urls 2026-01-20 19:23:53 +01:00
14 changed files with 496 additions and 705 deletions
-73
View File
@@ -1,73 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright 2026 theis.gaedigk
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+1 -163
View File
@@ -1,164 +1,2 @@
# CA-Lose
# ca-lose
CA-Lose is a small full-stack app for collecting ticket-sale/order form entries.
## 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)
![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)
![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff)
![Nginx](https://img.shields.io/badge/Nginx-009639?logo=nginx&logoColor=fff)
## Architecture
- **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=<name>` → validates user and returns metadata (e.g. next id)
- `POST /default/new-entry?username=<name>` → 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.
```bash
docker compose up --build
```
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):
```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
```
Notes:
- `docker-compose.prod.yml` uses an **external** network called `proxynet`. Create it if it does not exist:
```bash
docker network create proxynet
```
- The production file assigns static IPs; adjust the subnets/IPs if they conflict with your environment.
## Lint / build
Frontend:
```bash
cd frontend
npm run lint
npm run build
```
## Troubleshooting
- **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.
## License
Licensed under the Apache License 2.0. See [LICENSE](LICENSE).
+2 -2
View File
@@ -12,7 +12,7 @@ const pool = mysql
.promise();
export const getUser = async () => {
const [rows] = await pool.query("SELECT username FROM users");
const [rows] = await pool.query("SELECT username FROM usersNEW");
if (rows.length > 0) {
const users = rows.map((r) => r.username);
@@ -23,7 +23,7 @@ export const getUser = async () => {
};
export const confirmUser = async (username) => {
const [rows] = await pool.query("SELECT * FROM users WHERE username = ?", [
const [rows] = await pool.query("SELECT * FROM usersNEW WHERE username = ?", [
username,
]);
+20 -57
View File
@@ -4,15 +4,11 @@ services:
hostname: lose-verkaufen
build: ./frontend
networks:
ca-lose-internal:
ipv4_address: 172.25.0.2
proxynet:
ipv4_address: 172.20.0.61
- ca-lose-internal
restart: unless-stopped
backend:
container_name: ca-lose-backend
hostname: backend
build: ./backend
environment:
NODE_ENV: production
@@ -23,13 +19,11 @@ services:
depends_on:
- database
networks:
ca-lose-internal:
ipv4_address: 172.25.0.3
- ca-lose-internal
restart: unless-stopped
database:
container_name: ca-lose-mysql
hostname: database
image: mysql:8.0
restart: unless-stopped
environment:
@@ -40,65 +34,37 @@ services:
- ca-lose_mysql:/var/lib/mysql
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
networks:
ca-lose-internal:
ipv4_address: 172.25.0.4
proxynet:
ipv4_address: 172.20.0.60
- ca-lose-internal
# DNS Server für Hostname-Auflösung innerhalb des VPN
dnsmasq:
container_name: ca-lose-dns
image: andyshinn/dnsmasq:latest
restart: unless-stopped
cap_add:
- NET_ADMIN
command: >
--no-daemon
--log-queries
--address=/lose-verkaufen/172.25.0.2
--address=/frontend/172.25.0.2
--address=/backend/172.25.0.3
--address=/database/172.25.0.4
--address=/wg-admin/172.25.0.10
networks:
ca-lose-internal:
ipv4_address: 172.25.0.53
# WireGuard VPN mit Web-UI (wg-easy)
wireguard:
image: ghcr.io/wg-easy/wg-easy:latest
container_name: ca-lose-wireguard
image: lscr.io/linuxserver/wireguard:latest
container_name: wireguard
cap_add:
- NET_ADMIN
- SYS_MODULE
- SYS_MODULE #optional
environment:
LANG: de
WG_HOST: dus3.the1s.de
WG_PORT: "51830"
PORT: "51821"
WG_DEFAULT_ADDRESS: 10.14.14.x
WG_DEFAULT_DNS: "172.25.0.53"
WG_ALLOWED_IPS: 172.25.0.0/24
WG_PERSISTENT_KEEPALIVE: "25"
WG_POST_UP: "iptables -t nat -A POSTROUTING -s 10.14.14.0/24 -o eth0 -j MASQUERADE; iptables -A FORWARD -i wg0 -o eth0 -j ACCEPT; iptables -A FORWARD -i eth0 -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT; iptables -A FORWARD -i wg0 -d 172.25.0.2 -j ACCEPT; iptables -A FORWARD -i wg0 -d 172.25.0.53 -j ACCEPT; iptables -A FORWARD -i wg0 -j DROP"
WG_POST_DOWN: "iptables -t nat -D POSTROUTING -s 10.14.14.0/24 -o eth0 -j MASQUERADE; iptables -D FORWARD -i wg0 -o eth0 -j ACCEPT; iptables -D FORWARD -i eth0 -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT; iptables -D FORWARD -i wg0 -d 172.25.0.2 -j ACCEPT; iptables -D FORWARD -i wg0 -d 172.25.0.53 -j ACCEPT; iptables -D FORWARD -i wg0 -j DROP"
- PUID=1000
- PGID=1000
- TZ=Etc/UTC
- SERVERURL=dus3.the1s.de #optional
- SERVERPORT=51830 #optional
- PEERS=2 #optional
- PEERDNS=auto #optional
- INTERNAL_SUBNET=10.13.13.0 #optional
- ALLOWEDIPS=0.0.0.0/0 #optional
- PERSISTENTKEEPALIVE_PEERS= #optional
- LOG_CONFS=true #optional
volumes:
- wireguard-data:/etc/wireguard
- /lib/modules:/lib/modules:ro
- ./config:/config
- /lib/modules:/lib/modules #optional
ports:
- "51830:51830/udp"
- 51830:51830/udp
sysctls:
- net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1
restart: unless-stopped
depends_on:
- dnsmasq
- frontend
networks:
ca-lose-internal:
ipv4_address: 172.25.0.10
proxynet:
ipv4_address: 172.20.0.50
volumes:
ca-lose_mysql:
@@ -110,6 +76,3 @@ networks:
ipam:
config:
- subnet: 172.25.0.0/24
gateway: 172.25.0.1
proxynet:
external: true
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lose verkaufen</title>
<title>frontend</title>
</head>
<body>
<div id="root"></div>
+1 -18
View File
@@ -17,9 +17,7 @@
"@tailwindcss/vite": "^4.1.11",
"i18next": "^25.7.4",
"js-cookie": "^3.0.5",
"lucide": "^0.562.0",
"lucide-react": "^0.562.0",
"react": "^19.2.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.5.3",
"react-router-dom": "^7.11.0",
@@ -3786,21 +3784,6 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide": {
"version": "0.562.0",
"resolved": "https://registry.npmjs.org/lucide/-/lucide-0.562.0.tgz",
"integrity": "sha512-k1Fb8ZMnRQovWRlea7Jr0b9UKA29IM7/cu79+mJrhVohvA2YC/Ti3Sk+G+h/SIu3IlrKT4RAbWMHUBBQd1O6XA==",
"license": "ISC"
},
"node_modules/lucide-react": {
"version": "0.562.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+1 -3
View File
@@ -19,9 +19,7 @@
"@tailwindcss/vite": "^4.1.11",
"i18next": "^25.7.4",
"js-cookie": "^3.0.5",
"lucide": "^0.562.0",
"lucide-react": "^0.562.0",
"react": "^19.2.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.5.3",
"react-router-dom": "^7.11.0",
-2
View File
@@ -1,14 +1,12 @@
import "./App.css";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { MainForm } from "./pages/MainForm";
import { SuccessPage } from "./pages/SuccessPage";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<MainForm />} />
<Route path="/success" element={<SuccessPage />} />
</Routes>
</BrowserRouter>
);
+434
View File
@@ -0,0 +1,434 @@
import {
Box,
Stack,
TextField,
FormControlLabel,
Checkbox,
Button,
Alert,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { useState } from "react";
const phonePattern = /^[+]?[- 0-9()]{7,}$/;
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const MainForm = () => {
const { t } = useTranslation();
const [invoice, setInvoice] = useState(false);
const [paymentMethod, setPaymentMethod] = useState<
"cash" | "paypal" | "transfer" | null
>(null);
const [formValues, setFormValues] = useState({
firstName: "",
lastName: "",
email: "",
phoneNumber: "",
tickets: "",
code: "",
companyName: "",
invoiceFirstName: "",
invoiceLastName: "",
street: "",
postalCode: "",
invoicePhoneNumber: "",
invoiceEmail: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [submitMessage, setSubmitMessage] = useState<string | null>(null);
const updateField =
(field: keyof typeof formValues) =>
(event: React.ChangeEvent<HTMLInputElement>) => {
setFormValues((prev) => ({ ...prev, [field]: event.target.value }));
};
const validate = () => {
const nextErrors: Record<string, string> = {};
if (!formValues.firstName.trim()) nextErrors.firstName = "Required";
if (!formValues.lastName.trim()) nextErrors.lastName = "Required";
if (!formValues.email.trim()) nextErrors.email = "Required";
else if (!emailPattern.test(formValues.email))
nextErrors.email = "Invalid email";
if (!formValues.phoneNumber.trim()) nextErrors.phoneNumber = "Required";
else if (!phonePattern.test(formValues.phoneNumber))
nextErrors.phoneNumber = "Invalid phone number";
const ticketsNumber = Number(formValues.tickets);
if (!formValues.tickets.trim()) nextErrors.tickets = "Required";
else if (!Number.isFinite(ticketsNumber) || ticketsNumber <= 0)
nextErrors.tickets = "Must be a positive number";
if (!paymentMethod) nextErrors.paymentMethod = "Select a payment method";
if (!formValues.code.trim()) nextErrors.code = "Required";
if (invoice) {
if (!formValues.companyName.trim()) nextErrors.companyName = "Required";
if (!formValues.invoiceFirstName.trim())
nextErrors.invoiceFirstName = "Required";
if (!formValues.invoiceLastName.trim())
nextErrors.invoiceLastName = "Required";
if (!formValues.street.trim()) nextErrors.street = "Required";
if (!formValues.postalCode.trim()) nextErrors.postalCode = "Required";
if (!formValues.invoicePhoneNumber.trim())
nextErrors.invoicePhoneNumber = "Required";
else if (!phonePattern.test(formValues.invoicePhoneNumber))
nextErrors.invoicePhoneNumber = "Invalid phone number";
if (!formValues.invoiceEmail.trim()) nextErrors.invoiceEmail = "Required";
else if (!emailPattern.test(formValues.invoiceEmail))
nextErrors.invoiceEmail = "Invalid email";
}
setErrors(nextErrors);
return Object.keys(nextErrors).length === 0;
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitMessage(null);
const isValid = validate();
if (!isValid) return;
setSubmitting(true);
try {
const payload = {
invoice,
paymentMethod,
firstName: formValues.firstName.trim(),
lastName: formValues.lastName.trim(),
email: formValues.email.trim(),
phoneNumber: formValues.phoneNumber.trim(),
tickets: Number(formValues.tickets),
code: Number(formValues.code),
invoiceDetails: invoice
? {
companyName: formValues.companyName.trim(),
firstName: formValues.invoiceFirstName.trim(),
lastName: formValues.invoiceLastName.trim(),
street: formValues.street.trim(),
postalCode: formValues.postalCode.trim(),
phoneNumber: formValues.invoicePhoneNumber.trim(),
email: formValues.invoiceEmail.trim(),
}
: null,
};
const response = await fetch("/backend/default/frontend", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
setSubmitMessage("Submitted successfully.");
} catch (error) {
setSubmitMessage("Submit failed. Please try again.");
} finally {
setSubmitting(false);
}
};
return (
<Box className="min-h-screen bg-neutral-900 flex justify-center items-start py-10 px-4">
<Box
component="form"
action=""
method="post"
onSubmit={handleSubmit}
className="w-full max-w-md bg-white shadow-sm rounded-md"
sx={{ display: "flex", flexDirection: "column", gap: 2, p: 3 }}
>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<TextField
required
id="first-name"
label={t("first_name")}
variant="filled"
size="small"
fullWidth
margin="dense"
value={formValues.firstName}
onChange={updateField("firstName")}
error={Boolean(errors.firstName)}
helperText={errors.firstName}
/>
<TextField
required
id="last-name"
label={t("last_name")}
variant="filled"
size="small"
fullWidth
margin="dense"
value={formValues.lastName}
onChange={updateField("lastName")}
error={Boolean(errors.lastName)}
helperText={errors.lastName}
/>
</Box>
<TextField
required
id="email"
label={t("email")}
variant="filled"
size="small"
fullWidth
margin="dense"
type="email"
value={formValues.email}
onChange={updateField("email")}
error={Boolean(errors.email)}
helperText={errors.email}
/>
<TextField
required
id="phone-number"
label={t("phone-number")}
variant="filled"
size="small"
fullWidth
margin="dense"
type="tel"
value={formValues.phoneNumber}
onChange={updateField("phoneNumber")}
error={Boolean(errors.phoneNumber)}
helperText={errors.phoneNumber}
/>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "2fr 1fr" },
gap: 2,
alignItems: "center",
}}
>
<TextField
required
id="tickets"
label={t("tickets")}
variant="filled"
size="small"
fullWidth
margin="dense"
type="number"
value={formValues.tickets}
onChange={updateField("tickets")}
error={Boolean(errors.tickets)}
helperText={errors.tickets}
/>
<FormControlLabel
control={
<Checkbox
checked={invoice}
onChange={(event) => setInvoice(event.target.checked)}
/>
}
label={t("invoice")}
/>
</Box>
{invoice && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<TextField
required
id="company-name"
label={t("company_name")}
variant="filled"
size="small"
fullWidth
margin="dense"
value={formValues.companyName}
onChange={updateField("companyName")}
error={Boolean(errors.companyName)}
helperText={errors.companyName}
/>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<TextField
required
id="first-name_invoice"
label={t("first_name")}
variant="filled"
size="small"
fullWidth
margin="dense"
value={formValues.invoiceFirstName}
onChange={updateField("invoiceFirstName")}
error={Boolean(errors.invoiceFirstName)}
helperText={errors.invoiceFirstName}
/>
<TextField
required
id="last-name_invoice"
label={t("last_name")}
variant="filled"
size="small"
fullWidth
margin="dense"
value={formValues.invoiceLastName}
onChange={updateField("invoiceLastName")}
error={Boolean(errors.invoiceLastName)}
helperText={errors.invoiceLastName}
/>
</Box>
<TextField
required
id="street"
label={t("street")}
variant="filled"
size="small"
fullWidth
margin="dense"
value={formValues.street}
onChange={updateField("street")}
error={Boolean(errors.street)}
helperText={errors.street}
/>
<TextField
required
id="postal-code"
label={t("postal_code")}
variant="filled"
size="small"
fullWidth
margin="dense"
value={formValues.postalCode}
onChange={updateField("postalCode")}
error={Boolean(errors.postalCode)}
helperText={errors.postalCode}
/>
<TextField
required
id="phone-number_invoice"
label={t("phone_number")}
variant="filled"
size="small"
fullWidth
margin="dense"
type="tel"
value={formValues.invoicePhoneNumber}
onChange={updateField("invoicePhoneNumber")}
error={Boolean(errors.invoicePhoneNumber)}
helperText={errors.invoicePhoneNumber}
/>
<TextField
required
id="email_invoice"
label={t("email")}
variant="filled"
size="small"
fullWidth
margin="dense"
type="email"
value={formValues.invoiceEmail}
onChange={updateField("invoiceEmail")}
error={Boolean(errors.invoiceEmail)}
helperText={errors.invoiceEmail}
/>
</Box>
)}
<Stack direction="row" spacing={2} flexWrap="wrap" pl={1}>
{/* Payment methods - only one must be selected */}
<FormControlLabel
control={
<Checkbox
checked={paymentMethod === "cash"}
onChange={() =>
setPaymentMethod((current) =>
current === "cash" ? null : "cash"
)
}
/>
}
label={t("cash")}
sx={{ color: errors.paymentMethod ? "error.main" : undefined }}
/>
<FormControlLabel
control={
<Checkbox
checked={paymentMethod === "paypal"}
onChange={() =>
setPaymentMethod((current) =>
current === "paypal" ? null : "paypal"
)
}
/>
}
label={t("paypal")}
sx={{ color: errors.paymentMethod ? "error.main" : undefined }}
/>
<FormControlLabel
control={
<Checkbox
checked={paymentMethod === "transfer"}
onChange={() =>
setPaymentMethod((current) =>
current === "transfer" ? null : "transfer"
)
}
/>
}
label={t("transfer")}
sx={{ color: errors.paymentMethod ? "error.main" : undefined }}
/>
</Stack>
<TextField
required
id="code"
label={t("code")}
variant="filled"
size="small"
fullWidth
margin="dense"
value={formValues.code}
onChange={updateField("code")}
error={Boolean(errors.code)}
helperText={errors.code}
/>
<Button
type="submit"
variant="contained"
size="large"
sx={{ mt: 1 }}
fullWidth
disabled={submitting}
>
{t("submit")}
</Button>
{submitMessage && (
<Alert
severity={submitMessage.includes("failed") ? "error" : "success"}
>
{submitMessage}
</Alert>
)}
</Box>
</Box>
);
};
+25 -148
View File
@@ -9,13 +9,11 @@ import {
Chip,
Box,
Paper,
Typography,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { useState, useEffect } from "react";
import { submitFormData } from "../utils/sender";
import Cookies from "js-cookie";
import * as React from "react";
interface Message {
type: "error" | "info" | "success" | "warning";
@@ -51,7 +49,7 @@ export const MainForm = () => {
// Fetch user data or any other data needed for the form
try {
const fetchUsers = async () => {
const response = await fetch("http://localhost:8004/default/users");
const response = await fetch("/backend/default/users");
const data = await response.json();
setUsers(data.users);
};
@@ -71,7 +69,7 @@ export const MainForm = () => {
setSelectedUser(cookieUser);
confirmUser(cookieUser);
}
}, [isLoading]);
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
@@ -80,7 +78,7 @@ export const MainForm = () => {
const confirmUser = async (selectedUser: string) => {
try {
const response = await fetch(
`http://localhost:8004/default/confirm-user?username=${selectedUser}`,
`/backend/default/confirm-user?username=${selectedUser}`
);
const data = await response.json();
setNextID(data.nextID);
@@ -101,7 +99,11 @@ export const MainForm = () => {
try {
const result = await submitFormData(formData, selectedUser || "");
if (result.success) {
document.location.href = `/success?id=${nextID}&tickets=${formData.tickets}`;
setMsg({
type: "success",
headline: t("success"),
text: t("form-submitted-successfully"),
});
} else {
setMsg({
type: "error",
@@ -115,33 +117,11 @@ export const MainForm = () => {
};
return (
<Box
className="bg-gray-100 py-10 px-4"
sx={{
minHeight: "100vh",
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Box className="min-h-screen bg-gray-800 flex items-center justify-center p-4">
<Paper
elevation={6}
className="w-full rounded-2xl"
sx={{
backgroundColor: "#fff",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)",
width: "100%",
maxWidth: {
xs: 360, // kompakte Handy-Ansicht
sm: 420, // kleine Tablets / große Phones
md: 480, // Desktop, bleibt angenehm schmal
},
padding: {
xs: "1.5rem",
sm: "2rem",
},
}}
elevation={3}
className="w-full max-w-md p-6 rounded-lg"
sx={{ backgroundColor: "#fff" }}
>
<form
onSubmit={(e) => {
@@ -173,14 +153,10 @@ export const MainForm = () => {
color="primary"
sx={{
alignSelf: "flex-start",
fontWeight: 500,
fontSize: "0.9rem",
mt: 0.5,
mb: 0.5,
py: 0.5,
px: 1.25,
borderRadius: "999px",
background: "linear-gradient(135deg, #1976d2 0%, #1565c0 100%)",
fontWeight: "bold",
fontSize: "1rem",
py: 2,
px: 1,
}}
/>
@@ -262,14 +238,7 @@ export const MainForm = () => {
{/* Invoice Fields */}
{invoice && (
<Box
className="flex flex-col gap-2 pt-3 mt-2"
sx={{
borderTop: "2px solid",
borderColor: "primary.light",
borderRadius: "0",
}}
>
<Box className="flex flex-col gap-3 pt-2 border-t border-gray-200">
<TextField
required
id="company-name"
@@ -352,118 +321,26 @@ export const MainForm = () => {
/>
</Box>
)}
{/* Payment Methods */}
<Box className="flex flex-col gap-2 mt-2">
<Typography
component="label"
sx={{
fontSize: "0.875rem",
fontWeight: 500,
color: "text.secondary",
display: "flex",
alignItems: "center",
gap: 0.5,
}}
>
{t("select-payment-method")} *
</Typography>
<Box className="flex gap-2 flex-wrap">
<Button
variant={
formData.paymentMethod === "bar" ? "contained" : "outlined"
}
onClick={() =>
setFormData({ ...formData, paymentMethod: "bar" })
}
sx={{
flex: 1,
minWidth: "100px",
py: 1.5,
borderRadius: "12px",
textTransform: "none",
fontWeight: formData.paymentMethod === "bar" ? 600 : 400,
}}
>
{t("cash")}
</Button>
<Button
variant={
formData.paymentMethod === "paypal" ? "contained" : "outlined"
}
onClick={() =>
setFormData({ ...formData, paymentMethod: "paypal" })
}
sx={{
flex: 1,
minWidth: "100px",
py: 1.5,
borderRadius: "12px",
textTransform: "none",
fontWeight: formData.paymentMethod === "paypal" ? 600 : 400,
}}
>
{t("paypal")}
</Button>
<Button
variant={
formData.paymentMethod === "andere" ? "contained" : "outlined"
}
onClick={() =>
setFormData({ ...formData, paymentMethod: "andere" })
}
sx={{
flex: 1,
minWidth: "100px",
py: 1.5,
borderRadius: "12px",
textTransform: "none",
fontWeight: formData.paymentMethod === "andere" ? 600 : 400,
}}
>
{t("transfer")}
</Button>
</Box>
{!formData.paymentMethod && (
<input
tabIndex={-1}
autoComplete="off"
style={{
opacity: 0,
width: 0,
height: 0,
position: "absolute",
}}
required
value={formData.paymentMethod}
onChange={() => {}}
/>
)}
<Box className="flex justify-center gap-4 pt-2">
<FormControlLabel control={<Checkbox />} label={t("cash")} />
<FormControlLabel control={<Checkbox />} label={t("paypal")} />
<FormControlLabel control={<Checkbox />} label={t("transfer")} />
</Box>
{/* Submit Button */}
<Button
type="submit"
variant="contained"
disabled={isLoading || !formData.paymentMethod}
disabled={isLoading}
fullWidth
size="large"
sx={{
mt: 3,
py: 2,
mt: 2,
py: 1.5,
textTransform: "uppercase",
fontWeight: "bold",
borderRadius: "12px",
fontSize: "1rem",
background: "linear-gradient(135deg, #1976d2 0%, #1565c0 100%)",
boxShadow: "0 4px 14px 0 rgba(25, 118, 210, 0.39)",
"&:hover": {
background: "linear-gradient(135deg, #1565c0 0%, #0d47a1 100%)",
boxShadow: "0 6px 20px 0 rgba(25, 118, 210, 0.5)",
},
"&:disabled": {
background: "#e0e0e0",
boxShadow: "none",
},
}}
>
{isLoading ? (
-210
View File
@@ -1,210 +0,0 @@
import { Box, Paper, Typography, Chip, Button } from "@mui/material";
import { useEffect, useState } from "react";
import { CircleCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
export const SuccessPage = () => {
const [orderId, setOrderId] = useState<string | null>(null);
const [tickets, setNumberOfTickets] = useState<number>(0);
const [animate, setAnimate] = useState(false);
const { t } = useTranslation();
const [seconds, setSeconds] = useState(30);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const id = params.get("id");
const numberOfTickets = params.get("tickets");
setOrderId(id);
setNumberOfTickets(numberOfTickets ? parseInt(numberOfTickets, 10) : 0);
setTimeout(() => setAnimate(true), 100);
}, []);
useEffect(() => {
if (seconds === 0) {
window.location.href = "/";
return;
}
const timer = setTimeout(() => setSeconds(seconds - 1), 1000);
return () => clearTimeout(timer);
}, [seconds]);
return (
<Box className="min-h-screen bg-gray-800 flex items-center justify-center p-4">
<Paper
elevation={3}
className="w-full max-w-md p-8 rounded-lg"
sx={{
backgroundColor: "#fff",
textAlign: "center",
position: "relative",
overflow: "hidden",
}}
>
{/* Animated Success Icon */}
<Box
sx={{
display: "flex",
justifyContent: "center",
mb: 3,
transition: "all 0.6s cubic-bezier(0.34, 1.56, 0.64, 1)",
transform: animate ? "scale(1)" : "scale(0)",
opacity: animate ? 1 : 0,
}}
>
<CircleCheck size={80} className="text-green-500" strokeWidth={2.5} />
</Box>
{/* Success Message */}
<Typography
variant="h4"
component="h1"
gutterBottom
sx={{
fontWeight: "bold",
color: "#2e7d32",
mb: 2,
transition: "all 0.5s ease-in-out 0.2s",
transform: animate ? "translateY(0)" : "translateY(20px)",
opacity: animate ? 1 : 0,
}}
>
{t("form-submitted-successfully")}
</Typography>
<Typography
variant="body1"
sx={{
color: "#666",
mb: 3,
transition: "all 0.5s ease-in-out 0.3s",
transform: animate ? "translateY(0)" : "translateY(20px)",
opacity: animate ? 1 : 0,
}}
>
{t("ticket-payment", { count: tickets })}
</Typography>
{/* Tickets Display */}
{tickets > 0 && (
<Box
sx={{
mb: 2,
transition: "all 0.5s ease-in-out 0.35s",
transform: animate ? "translateY(0)" : "translateY(20px)",
opacity: animate ? 1 : 0,
}}
>
<Chip
label={`${tickets} ${tickets === 1 ? t("ticket") : t("tickets")}`}
color="secondary"
sx={{
fontWeight: "bold",
fontSize: "1rem",
py: 2.5,
px: 2,
}}
/>
</Box>
)}
{/* Order ID Display */}
{orderId && (
<Box
sx={{
mb: 3,
transition: "all 0.5s ease-in-out 0.4s",
transform: animate ? "translateY(0)" : "translateY(20px)",
opacity: animate ? 1 : 0,
}}
>
<Typography
variant="body2"
sx={{
color: "#888",
mb: 1,
fontSize: "0.875rem",
}}
>
{t("entry-id")}
</Typography>
<Chip
label={`#${orderId}`}
color="primary"
sx={{
fontWeight: "bold",
fontSize: "1.25rem",
py: 3,
px: 2,
}}
/>
</Box>
)}
{/* Return button */}
<Box
sx={{
mb: 3,
transition: "all 0.5s ease-in-out 0.4s",
transform: animate ? "translateY(0)" : "translateY(20px)",
opacity: animate ? 1 : 0,
}}
>
<Button
href="/"
variant="contained"
color="primary"
sx={{
fontWeight: "bold",
fontSize: "1.25rem",
py: 3,
px: 2,
}}
>
{seconds + " " + t("return-to-homepage")}
</Button>
</Box>
{/* Additional Info */}
<Box
sx={{
mt: 4,
pt: 3,
borderTop: "1px solid #e0e0e0",
transition: "all 0.5s ease-in-out 0.5s",
transform: animate ? "translateY(0)" : "translateY(20px)",
opacity: animate ? 1 : 0,
}}
>
<Typography
variant="body2"
sx={{
color: "#666",
lineHeight: 1.6,
}}
>
{t("thank-you")}
</Typography>
</Box>
{/* Decorative Elements */}
<Box
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "4px",
background: "linear-gradient(90deg, #4caf50 0%, #81c784 100%)",
transition: "all 0.8s ease-in-out 0.6s",
transform: animate ? "scaleX(1)" : "scaleX(0)",
transformOrigin: "left",
}}
/>
</Paper>
</Box>
);
};
+2 -8
View File
@@ -8,7 +8,7 @@
"street": "Straße + Haus Nr.",
"postal-code": "Plz + Stadt",
"email": "E-Mail",
"submit": "Abschicken",
"submit": "Kaufen",
"failed-to-load-users": "Das Laden der Benutzer ist fehlgeschlagen.",
"user": "Benutzer",
"next-id": "Nächste Eintragsnummer: ",
@@ -18,11 +18,5 @@
"error": "Fehler",
"cash": "Bar",
"paypal": "PayPal",
"transfer": "Andere (notieren)",
"ticket-payment_one": "Sie haben erfolgreich {{count}} Los gekauft.",
"ticket-payment_other": "Sie haben erfolgreich {{count}} Lose gekauft.",
"entry-id": "Eintrags-ID",
"thank-you": "Vielen Dank für Ihre Unterstützung der Claudius Akademie! Wir wünschen Ihnen viel Glück mit dem Los.",
"select-payment-method": "Zahlungsmethode auswählen",
"return-to-homepage": "Zurück"
"transfer": "Überweisung"
}
+3 -10
View File
@@ -2,12 +2,13 @@
"first-name": "First Name",
"last-name": "Last Name",
"phone-number": "Phone Number",
"tickets": "Tickets",
"invoice": "Invoice",
"company-name": "Company Name",
"street": "Street + House No.",
"postal-code": "Postal Code + City",
"email": "Email",
"submit": "Submit Form",
"submit": "Buy",
"failed-to-load-users": "Failed to load users.",
"user": "User",
"next-id": "Next Entry Number: ",
@@ -17,13 +18,5 @@
"error": "Error",
"cash": "Cash",
"paypal": "PayPal",
"transfer": "Other (note down)",
"ticket-payment_one": "You have successfully purchased {{count}} ticket.",
"ticket-payment_other": "You have successfully purchased {{count}} tickets.",
"ticket": "Ticket",
"tickets": "Tickets",
"entry-id": "Entry ID",
"thank-you": "Thank you for supporting the Claudius Akademie! We wish you the best of luck with your ticket.",
"select-payment-method": "Select Payment Method",
"return-to-homepage": "Return"
"transfer": "Bank Transfer"
}
+2 -6
View File
@@ -15,18 +15,14 @@ interface FormData {
}
export const submitFormData = async (data: FormData, username: string) => {
console.log(data);
try {
const response = await fetch(
`http://localhost:8004/default/new-entry?username=${username}`,
{
const response = await fetch(`/backend/default/new-entry?username=${username}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
},
);
});
if (!response.ok) {
const errorText = await response.text();