77 Commits

Author SHA1 Message Date
theis.gaedigk a46cb3b9e7 Merge branch 'dev' into prod 2026-06-10 13:45:53 +02:00
theis.gaedigk d8e8062990 fixed number input 2026-06-10 13:45:13 +02:00
theis.gaedigk 528ed8ac40 Merge branch 'dev' into prod 2026-06-09 17:44:36 +02:00
theis.gaedigk 470fe75d86 added chocolates box 2026-06-09 17:43:56 +02:00
theis.gaedigk cfd3a77472 Merge branch 'dev' into prod 2026-06-03 16:33:26 +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 d308f80341 Merge branch 'dev' into prod 2026-05-24 14:19:31 +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 c79f81ec7d fixed readme issue 2026-05-24 14:18:19 +02:00
theis.gaedigk 9af4ddaf18 Merge branch 'dev' into prod 2026-05-24 14:16:49 +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
theis.gaedigk 1cd0379654 redesgigned page 2026-05-24 13:12:09 +02:00
theis.gaedigk 39b15aec65 refactored code 2026-05-24 13:08:13 +02:00
theis.gaedigk 6915e60cec implemented tanstack form 2026-05-24 13:07:06 +02:00
theis.gaedigk 9dead72e1e updated wg-easy 2026-05-22 23:32:42 +02:00
theis.gaedigk cca303c1f6 edited readme 2026-05-20 13:52:08 +02:00
theis.gaedigk 9dd9ccebdd changed docker notes 2026-05-20 13:15:33 +02:00
theis.gaedigk 92605d85c2 Merge branch 'dev' into prod 2026-05-20 13:14:47 +02:00
theis.gaedigk 101bd5c060 edited wg-easy 2026-05-20 13:14:28 +02:00
theis.gaedigk 59a1ae51fa deleted unused compose 2026-05-20 13:10:11 +02:00
theis.gaedigk 80a3d4d464 changed ports again 2026-05-20 13:08:05 +02:00
theis.gaedigk 9efb93c37c changed docker config 2026-05-20 13:05:03 +02:00
theis.gaedigk cf638dc42d changed ports again 2026-05-20 12:51:13 +02:00
theis.gaedigk 65092b57d9 changed ports 2026-05-20 12:50:22 +02:00
theis.gaedigk 7045317fc6 reformatted compose 2026-05-20 12:46:50 +02:00
theis.gaedigk 0f75f55ac4 edited wg service 2026-05-20 12:45:59 +02:00
theis.gaedigk 2d2dc52012 changed docker compose 2026-05-20 12:39:08 +02:00
theis.gaedigk 566437bd71 changed port config 2026-05-20 10:49:03 +02:00
theis.gaedigk 266ee1af80 changed docker-compose 2026-05-20 10:41:11 +02:00
theis.gaedigk 43f8e00968 Merge branch 'dev' into prod 2026-05-20 10:39:43 +02:00
theis.gaedigk f32931ded3 added wg-easy 2026-05-20 10:38:25 +02:00
theis.gaedigk 4ce1817bd0 added wg-easy 2026-05-20 10:37:31 +02:00
theis.gaedigk 11c2372cae outsourced modals 2026-05-19 21:54:02 +02:00
theis.gaedigk d5b6c9665c added new warning 2026-05-18 23:23:43 +02:00
theis.gaedigk ce2d0bb329 noted out 3sec pause 2026-05-18 20:41:35 +02:00
theis.gaedigk 5c43b817a7 Merge branch 'dev' into prod 2026-05-18 20:37:04 +02:00
theis.gaedigk c25ab48880 fully implemented tanstack query 2026-05-18 19:14:48 +02:00
theis.gaedigk 8932f5d004 changed code formatting 2026-05-18 18:11:46 +02:00
theis.gaedigk 3832aca12c implemented tanstack query 2026-05-18 18:05:05 +02:00
theis.gaedigk a88c141f28 fixed code 2026-05-11 22:41:18 +02:00
theis.gaedigk 0953847f24 Merge branch 'dev' into prod 2026-05-10 21:08:23 +02:00
theis.gaedigk 843a5d07d9 added webserver config and dockerfile 2026-05-10 21:08:17 +02:00
theis.gaedigk 9e7fc530b5 Merge branch 'dev' into prod 2026-05-10 21:05:13 +02:00
theis.gaedigk 4008b3de94 changed website icon 2026-05-10 21:03:55 +02:00
theis.gaedigk dbacc172d8 deleted old frontend 2026-05-10 20:46:34 +02:00
theis.gaedigk 08cb38df8f deleted unused code 2026-05-10 20:46:29 +02:00
theis.gaedigk 8650745009 add MUI components and update localization for payment methods 2026-05-10 20:45:51 +02:00
theis.gaedigk 5c035ba1c0 add lucide-react dependency and update form handling in MainForm
- Added lucide-react to package dependencies
- Refactored MainForm to improve form handling and user selection
- Removed SuccessPage component and related logic
- Updated localization files to include new keys for invoice details
- Created interfaces for form data and messages
2026-05-10 19:39:32 +02:00
theis.gaedigk 746530ae4c improved ground structure 2026-05-10 18:31:46 +02:00
theis.gaedigk 3fd0889ebf ground structure 2026-05-10 18:21:34 +02:00
theis.gaedigk e3a26de92d added new frontend and renamed old frontend 2026-05-10 13:30:20 +02:00
theis.gaedigk 75b75169a8 added paypal-qr code 2026-05-10 13:15:11 +02:00
theis.gaedigk 18777e5f7c added allowed ips 2026-05-09 23:10:47 +02:00
theis.gaedigk b55129dfff closed ports 2026-05-09 23:06:17 +02:00
theis.gaedigk 26856ee1df edited 2026-05-09 23:03:56 +02:00
theis.gaedigk dd1d8d8d6b edited network config 2026-05-09 21:52:39 +02:00
theis.gaedigk cf2df0aaac edited code design 2026-05-09 21:46:01 +02:00
theis.gaedigk 1199d6468f noted out public web-ui port 2026-05-09 21:45:19 +02:00
theis.gaedigk 7cd958c31e edited ip adresses 2026-05-09 21:43:12 +02:00
theis.gaedigk f89cf84a38 edited docker config 2026-05-09 21:38:56 +02:00
theis.gaedigk e3fc1d8659 edited again 2026-05-09 21:30:59 +02:00
theis.gaedigk 060f8d01c6 edited again 2026-05-09 21:27:47 +02:00
theis.gaedigk 667609d70c fixed docker config 2026-05-09 21:24:42 +02:00
theis.gaedigk b05f19acd9 edited docker compose 2026-05-09 21:22:28 +02:00
theis.gaedigk 1b4cc81e61 deleted unused compose 2026-05-09 21:05:27 +02:00
theis.gaedigk 2aa9a968f5 Merge branch 'dev' into prod 2026-05-04 22:40:33 +02:00
theis.gaedigk 2a4825269b fix: update SQL queries to use parameterized table names for security
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 22:40:24 +02:00
theis.gaedigk e42a2f510a edited docker compose 2026-05-04 22:05:18 +02:00
theis.gaedigk d2b22fc71f Merge branch 'dev' into prod 2026-05-04 22:04:26 +02:00
theis.gaedigk 4df6d243f3 feat: implement dynamic API base URL and add language toggle in MainForm
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 22:03:21 +02:00
theis.gaedigk ab9cfd6cbf Added README.md and License 2026-03-15 12:36:12 +01:00
46 changed files with 2827 additions and 2632 deletions
+3 -1
View File
@@ -155,8 +155,10 @@ backend/public/uploads/
secrets/
keys/
# Own files
ToDo.txt
PayPal-QR-Code.png
.docker/volumes
# only in development branch
next-env.d.ts
+3
View File
@@ -0,0 +1,3 @@
[submodule "wg-easy-ca-lose"]
path = wg-easy-ca-lose
url = https://git.the1s.de/theis.gaedigk/wg-easy-ca-lose.git
+73
View File
@@ -0,0 +1,73 @@
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.
+97 -1
View File
@@ -1,2 +1,98 @@
# ca-lose
# LuckySign
Ticket intake and validation app with a React frontend and an Express + MySQL backend.
## Tech Stack
![React](https://img.shields.io/badge/React-61DAFB?logo=react&logoColor=000&style=flat)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=fff&style=flat)
![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=fff&style=flat)
![Tailwind%20CSS](https://img.shields.io/badge/Tailwind%20CSS-38B2AC?logo=tailwindcss&logoColor=fff&style=flat)
![MUI](https://img.shields.io/badge/MUI-007FFF?logo=mui&logoColor=fff&style=flat)
![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)
- Backend (Express): [backend](backend)
- Database schema: [backend/scheme.sql](backend/scheme.sql)
- Docker compose stack: [docker-compose.yml](docker-compose.yml)
## Quick Start (Docker)
1. Set the database password env var used by Docker Compose:
```bash
DB_PASSWORD=your_password
```
2. Start MySQL and the backend:
```bash
docker compose up -d
```
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
### Backend
1. Create a .env file in the backend folder with:
```env
PORT=8004
DB_HOST=127.0.0.1
DB_USER=root
DB_PASSWORD=your_password
DB_NAME=ca_lose
```
2. Install deps and run:
```bash
cd backend
npm install
node server.js
```
Backend listens on http://localhost:8004 and exposes routes under /default.
### Frontend
```bash
cd frontend
npm install
npm run dev
```
Vite runs the app on the default dev port and talks to the backend using the API helpers in [frontend/src/utils/api](frontend/src/utils/api).
## API Endpoints
- GET /default/users
- GET /default/confirm-user?username={name}
- POST /default/new-entry?username={name}
## Database Notes
- The base users table is defined in [backend/scheme.sql](backend/scheme.sql).
- Per-user ticket tables are created on demand by the backend (see [backend/routes/default/frontend.data.js](backend/routes/default/frontend.data.js)).
+25 -15
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" };
}
};
@@ -28,6 +30,9 @@ export const confirmUser = async (username) => {
]);
if (rows.length > 0) {
const { first_name, last_name } = rows[0];
const fullname = first_name + " " + last_name;
// creating userTicketTable
const d = new Date();
@@ -37,16 +42,16 @@ export const confirmUser = async (username) => {
const date = `${day}_${month}_${year}`;
const tableName = `${username}_${date}`;
console.log(tableName);
const [createTable] = await pool.query(
`CREATE TABLE IF NOT EXISTS ${tableName} (
`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),
@@ -56,28 +61,30 @@ export const confirmUser = async (username) => {
Plz_Ort Varchar(100),
Zahlungsmethode Varchar(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`
)`,
[tableName],
);
if (createTable) {
let nextID;
const getNextID = async () => {
const [rows] = await pool.query(
`SELECT id FROM ${tableName} ORDER BY id DESC LIMIT 1`
`SELECT id FROM ?? ORDER BY id DESC LIMIT 1`,
[tableName],
);
nextID = rows.length > 0 ? rows[0].id + 1 : 1;
};
await getNextID();
return { success: true, nextID, tableName };
return { success: true, nextID, tableName, fullname };
} else {
return { success: false, message: "Table creation failed" };
return { success: false, message: "Table creation failed", fullname };
}
} else {
return null;
}
};
export const newEntry = async (formData, username) => {
export const newEntry = async (formData, username, prizeDraw) => {
const confirmation = await confirmUser(username);
if (!confirmation || !confirmation.success) {
@@ -87,13 +94,16 @@ export const newEntry = async (formData, username) => {
const tableName = confirmation.tableName;
const [result] = await pool.query(
`INSERT INTO ${tableName} (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,
@@ -102,7 +112,7 @@ export const newEntry = async (formData, username) => {
formData.street,
formData.postalCode,
formData.paymentMethod,
]
],
);
return { success: true, insertId: result.insertId };
+14 -7
View File
@@ -3,26 +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" });
}
console.log(req.body);
console.log(username);
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" });
}
+10
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,8 +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,
-115
View File
@@ -1,115 +0,0 @@
services:
frontend:
container_name: ca-lose-frontend
hostname: lose-verkaufen
build: ./frontend
networks:
ca-lose-internal:
ipv4_address: 172.25.0.2
proxynet:
ipv4_address: 172.20.0.61
restart: unless-stopped
backend:
container_name: ca-lose-backend
hostname: backend
build: ./backend
environment:
NODE_ENV: production
DB_HOST: ca-lose-mysql
DB_USER: root
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ca_lose
depends_on:
- database
networks:
ca-lose-internal:
ipv4_address: 172.25.0.3
restart: unless-stopped
database:
container_name: ca-lose-mysql
hostname: database
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: ca_lose
TZ: Europe/Berlin
volumes:
- 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
# 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
cap_add:
- NET_ADMIN
- SYS_MODULE
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"
volumes:
- wireguard-data:/etc/wireguard
- /lib/modules:/lib/modules:ro
ports:
- "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:
wireguard-data:
networks:
ca-lose-internal:
driver: bridge
ipam:
config:
- subnet: 172.25.0.0/24
gateway: 172.25.0.1
proxynet:
external: true
+73 -13
View File
@@ -1,16 +1,19 @@
services:
# frontend:
# container_name: ca-lose-frontend
# build: ./frontend
# ports:
# - "8002:80"
# restart: unless-stopped
frontend:
container_name: ca-lose-frontend
hostname: lose-verkaufen
build: ./frontend
depends_on:
- backend
networks:
ca-lose-internal:
ipv4_address: 172.25.0.2
restart: unless-stopped
backend:
container_name: ca-lose-backend
hostname: backend
build: ./backend
ports:
- "8004:8004"
environment:
NODE_ENV: production
DB_HOST: ca-lose-mysql
@@ -19,21 +22,78 @@ services:
DB_NAME: ca_lose
depends_on:
- database
networks:
ca-lose-internal:
ipv4_address: 172.25.0.3
restart: unless-stopped
database:
container_name: ca-lose-mysql
hostname: database
image: mysql:8.0
restart: unless-stopped
ports:
- "3311:3306"
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
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
networks:
ca-lose-internal:
ipv4_address: 172.25.0.4
volumes:
ca-lose_mysql:
# DNS Server for hostname resolution within the Docker network
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=/wireguard/172.25.0.6
networks:
ca-lose-internal:
ipv4_address: 172.25.0.5
# WireGuard VPN server for secure remote access to the Docker network
wireguard:
build: ./wg-easy-ca-lose
container_name: ca-lose-wireguard
cap_add:
- NET_ADMIN
- SYS_MODULE
environment:
WG_HOST: dus3.the1s.de
INSECURE: "true"
HOST: "172.25.0.6"
PORT: "80"
volumes:
- ../docker/volumes/ca-lose-wireguard-v15:/etc/wireguard
- /lib/modules:/lib/modules:ro
ports:
- "51830:51830/udp"
# - "51831:51821/tcp" Public Web-UI Port
sysctls:
- net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1
restart: unless-stopped
depends_on:
- dnsmasq
networks:
ca-lose-internal:
ipv4_address: 172.25.0.6
networks:
ca-lose-internal:
driver: bridge
ipam:
config:
- subnet: 172.25.0.0/24
gateway: 172.25.0.1
+2 -2
View File
@@ -4,8 +4,8 @@ This template provides a minimal setup to get React working in Vite with HMR and
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
-1
View File
@@ -16,7 +16,6 @@ export default defineConfig([
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
+13 -2
View File
@@ -2,9 +2,20 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link
rel="icon"
type="image/png"
href="/favicon-light.png"
media="(prefers-color-scheme: light)"
/>
<link
rel="icon"
type="image/png"
href="/favicon-dark.png"
media="(prefers-color-scheme: dark)"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lose verkaufen</title>
<title>Lose Verkaufen</title>
</head>
<body>
<div id="root"></div>
+1301 -1754
View File
File diff suppressed because it is too large Load Diff
+34 -27
View File
@@ -12,37 +12,44 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/roboto": "^5.2.9",
"@mui/icons-material": "^7.3.6",
"@mui/material": "^7.3.6",
"@mui/styled-engine-sc": "^7.3.6",
"@tailwindcss/vite": "^4.1.11",
"i18next": "^25.7.4",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@mui/icons-material": "^9.0.1",
"@mui/joy": "^5.0.0-beta.52",
"@mui/material": "^9.0.1",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-form": "^1.32.0",
"@tanstack/react-query": "^5.100.10",
"i18next": "^26.0.10",
"js-cookie": "^3.0.5",
"lucide": "^0.562.0",
"lucide-react": "^0.562.0",
"react": "^19.2.3",
"react-dom": "^19.2.0",
"react-i18next": "^16.5.3",
"react-router-dom": "^7.11.0",
"styled-components": "^6.1.19",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tailwindcss-animate": "^1.0.7"
"k6": "^0.0.0",
"lucide-react": "^1.14.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-i18next": "^17.0.7",
"react-router-dom": "^7.15.0",
"tailwindcss": "^4.3.0",
"validator": "^13.15.35",
"zod": "^4.4.3"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@eslint/js": "^10.0.1",
"@types/js-cookie": "^3.0.6",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
"@types/validator": "^13.15.10",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+48
View File
@@ -0,0 +1,48 @@
{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"blend-mode" : "normal",
"fill-specializations" : [
{
"value" : "automatic"
},
{
"appearance" : "dark",
"value" : {
"solid" : "extended-srgb:0.00000,0.53333,1.00000,1.00000"
}
}
],
"image-name" : "Unbenannt.png",
"name" : "Unbenannt",
"position" : {
"scale" : 0.9,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+11
View File
@@ -1 +1,12 @@
@import "tailwindcss";
html,
body,
#root {
height: 100%;
margin: 0;
}
body.success-bg {
background: linear-gradient(135deg, #0f172a, #111827);
}
+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 (
<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

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 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>
);
@@ -0,0 +1,31 @@
import { Modal, ModalDialog, Typography, ModalClose } from "@mui/joy";
import { useTranslation } from "react-i18next";
import qrCode from "../../assets/PayPal-QR-Code.png";
interface QRcodeModalProps {
QRmodal: boolean;
setQRmodal: (value: boolean) => void;
}
export const QRcodeModal = (props: QRcodeModalProps) => {
const { t } = useTranslation();
return (
<Modal open={props.QRmodal}>
<ModalDialog color="primary" layout="center" size="lg">
<ModalClose onClick={() => props.setQRmodal(false)} />
<Typography>{t("qr-text")}</Typography>
<img
src={qrCode}
alt="PayPal QR Code"
style={{
width: "100%",
height: "auto",
maxHeight: "70vh",
objectFit: "contain",
}}
/>
</ModalDialog>
</Modal>
);
};
@@ -0,0 +1,53 @@
import {
Modal,
ModalDialog,
Typography,
ModalClose,
Autocomplete,
} from "@mui/joy";
import { useTranslation } from "react-i18next";
interface SelectUserModalProps {
showSelectUser: boolean;
setShowSelectUser: (value: boolean) => void;
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) => {
const { t } = useTranslation();
return (
<Modal open={props.showSelectUser}>
<ModalDialog color="primary" layout="center" size="lg">
<ModalClose onClick={() => props.setShowSelectUser(false)} />
<Typography>{t("user")}</Typography>
{/* User selection */}
<Autocomplete
options={props.info?.users ?? []}
loading={props.infoIsLoading}
loadingText={t("loading")}
value={props.selectedUser}
onChange={(_, value) => props.handleUserSelection(value)}
placeholder={t("user")}
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>
);
};
+4
View File
@@ -0,0 +1,4 @@
export const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8004";
+70
View File
@@ -0,0 +1,70 @@
import z from "zod";
import validator from "validator";
import type { ReactNode } from "react";
export interface FormData {
firstName: string;
lastName: string;
email: string;
phoneNumber: string;
tickets: number;
companyName: string;
cmpFirstName: string;
cpmLastName: string;
cpmEmail: string;
cpmPhoneNumber: string;
street: string;
postalCode: string;
paymentMethod: string;
}
export interface Message {
type: "primary" | "neutral" | "danger" | "success" | "warning";
headline: string;
text: string;
}
export type TextFieldProps = {
label: string;
type?: "text" | "email" | "tel" | "number";
required?: boolean;
errors: string[];
onBlur: () => void;
onChange: (value: string) => void;
value: string | number | null | undefined;
slotProps?: { input?: Record<string, unknown> };
afterInput?: ReactNode;
};
export const createFormSchema = (
t: (key: string) => string,
invoice: boolean,
) =>
z.object({
firstName: z.string().min(1, t("name-error")),
lastName: z.string().min(1, t("name-error")),
email: z.email(t("email-error")),
phoneNumber: z.string(t("phone-error")).refine(validator.isMobilePhone),
tickets: z.number(t("ticket-error")).min(1),
chocolates: z.boolean(t("chocolates-error")),
companyName: invoice
? z.string().min(1, t("name-error"))
: z.string().optional(),
cmpFirstName: invoice
? z.string().min(1, t("name-error"))
: z.string().optional(),
cpmLastName: invoice
? z.string().min(1, t("name-error"))
: z.string().optional(),
cpmEmail: invoice ? z.email(t("email-error")) : z.string().optional(),
cpmPhoneNumber: invoice
? z.string(t("phone-error")).refine(validator.isMobilePhone)
: z.string().optional(),
street: invoice
? z.string().min(1, t("name-error"))
: z.string().optional(),
postalCode: invoice
? z.string().min(1, t("name-error"))
: z.string().optional(),
paymentMethod: z.string().min(1, t("name-error")),
});
+11
View File
@@ -1 +1,12 @@
@import "tailwindcss";
html,
body,
#root {
height: 100%;
margin: 0;
}
html {
font-size: large;
}
+7 -2
View File
@@ -1,11 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./utils/i18n/index.ts";
import "./index.css";
import "./utils/i18n";
import App from "./App.tsx";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</StrictMode>
</QueryClientProvider>
</StrictMode>,
);
+494 -393
View File
@@ -1,40 +1,79 @@
import {
TextField,
FormControlLabel,
Checkbox,
Button,
Alert,
CircularProgress,
Autocomplete,
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";
headline: string;
text: string;
}
import {
Sheet,
Input,
Button,
Checkbox,
Chip,
IconButton,
Alert,
Typography,
FormControl,
FormLabel,
ButtonGroup,
CircularProgress,
} from "@mui/joy";
import { submitFormData } from "../utils/api/form";
import type { FormData, Message } from "../config/interfaces.config";
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, 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 { 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 [isLoading, setIsLoading] = useState(false);
const [nextID, setNextID] = useState<number | null>(null);
const [formData, setFormData] = useState({
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,
}: {
fieldApi: { form: { state: { values: Record<string, unknown> } } };
}) => {
const allValues = fieldApi.form.state.values;
return validateFieldWithZod(formSchema, fieldName, allValues);
},
onBlur: ({
fieldApi,
}: {
fieldApi: { form: { state: { values: Record<string, unknown> } } };
}) => {
const allValues = fieldApi.form.state.values;
return validateFieldWithZod(formSchema, fieldName, allValues);
},
});
// 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: "",
@@ -43,106 +82,154 @@ export const MainForm = () => {
street: "",
postalCode: "",
paymentMethod: "",
},
onSubmit: async ({ value }) => {
const result = formSchema.safeParse(value);
if (!result.success) return;
mutateForm(value as FormData);
},
});
const [users, setUsers] = useState<string[]>([]);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
// This function returns the errors for one field
const getErrors = (field: {
state: { meta: { errorMap: Record<string, unknown> } };
}) => {
const normalizeErrors = (value: unknown) => {
if (Array.isArray(value)) return value as string[];
if (typeof value === "string" && value.length > 0) return [value];
return [] as string[];
};
const blurErrors = normalizeErrors(field.state.meta.errorMap["onBlur"]);
const submitErrors = normalizeErrors(field.state.meta.errorMap["onSubmit"]);
return [...blurErrors, ...submitErrors].filter(Boolean);
};
// useEffect that only executes on mount and is checking if there is a user selcted in the cookies
useEffect(() => {
// Fetch user data or any other data needed for the form
try {
const fetchUsers = async () => {
const response = await fetch("/backend/default/users");
const data = await response.json();
setUsers(data.users);
};
fetchUsers();
console.log(users);
} catch (error) {
setMsg({
type: "error",
headline: t("error"),
text: t("failed-to-load-users"),
});
console.error("Error fetching users:", error);
const savedUser = Cookies.get("selectedUser");
const savedDraw = Cookies.get("selectedDraw");
if (savedDraw) {
setSelectedDraw(savedDraw);
}
if (Cookies.get("selectedUser")) {
const cookieUser = Cookies.get("selectedUser")!;
setSelectedUser(cookieUser);
confirmUser(cookieUser);
}
}, [isLoading]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const confirmUser = async (selectedUser: string) => {
try {
const response = await fetch(
`/backend/default/confirm-user?username=${selectedUser}`
);
const data = await response.json();
setNextID(data.nextID);
} catch (error) {
console.error("Error confirming user:", error);
}
};
const handleUserSelection = (selectedUser: string | null) => {
if (!selectedUser) return;
setSelectedUser(selectedUser);
confirmUser(selectedUser);
Cookies.set("selectedUser", selectedUser);
};
const handleSubmit = async () => {
setIsLoading(true);
try {
const result = await submitFormData(formData, selectedUser || "");
if (result.success) {
document.location.href = `/success?id=${nextID}&tickets=${formData.tickets}`;
if (savedUser) {
setSelectedUser(savedUser);
} else {
setMsg({
type: "error",
headline: t("error"),
text: result.error || t("form-submission-failed"),
type: "warning",
headline: t("set-username-headline"),
text: t("set-username-text"),
});
}
} finally {
setIsLoading(false);
}, []);
// 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) => {
queryClient.invalidateQueries({ queryKey: ["user", selectedUser] });
document.location.href = `/success?id=${nextID}&tickets=${values.tickets}`;
},
onError: () => {
queryClient.invalidateQueries({ queryKey: ["user", selectedUser] });
setMsg({
type: "danger",
headline: t("error"),
text: t("form-submission-failed"),
});
},
});
const nextID = userData?.nextID ?? "N/A";
// function for selecting user
const handleUserSelection = (username: string | null) => {
if (username == null || username == "") {
return;
}
setSelectedUser(username);
};
// function for selecting draw
const handleDrawSelection = (draw: string | null) => {
if (draw == null || draw == "") {
return;
}
setSelectedDraw(draw);
Cookies.set("selectedDraw", draw);
};
return (
<Box
className="bg-gray-100 py-10 px-4"
<>
<SelectUserModal
showSelectUser={showSelectUser}
setShowSelectUser={setShowSelectUser}
info={info}
infoIsLoading={infoIsLoading}
selectedUser={selectedUser}
selectedDraw={selectedDraw}
handleUserSelection={handleUserSelection}
handleDrawSelection={handleDrawSelection}
/>
<QRcodeModal setQRmodal={setQRmodal} QRmodal={QRmodal} />
<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"
sx={{
minHeight: "100vh",
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
maxWidth: 460,
borderRadius: "24px",
p: { xs: "1.5rem", sm: "2rem" },
boxShadow: "0 24px 64px -12px rgba(0,0,0,0.18)",
background: "#fff",
}}
>
<Paper
elevation={6}
className="w-full rounded-2xl"
<ButtonGroup
color="primary"
disabled={false}
size="lg"
spacing={1}
variant="soft"
>
<IconButton onClick={() => setShowSelectUser(true)}>
<PersonIcon />
</IconButton>
<IconButton onClick={() => setQRmodal(true)}>
<QrCodeIcon />
</IconButton>
<IconButton onClick={changeTranslation}>
<TranslateIcon />
</IconButton>
<Typography
level="title-sm"
textColor="var(--joy-palette-success-plainColor)"
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",
},
fontFamily: "monospace",
opacity: "100%",
alignSelf: "center",
}}
>
{`${t("greeting")} ${userData?.fullname ?? t("loading")}`}
</Typography>
</ButtonGroup>
<form
onSubmit={(e) => {
e.preventDefault();
@@ -150,337 +237,351 @@ export const MainForm = () => {
}}
className="flex flex-col gap-4"
>
{/* User Selection */}
<Autocomplete
disablePortal
options={users}
value={selectedUser}
fullWidth
renderInput={(params) => (
<TextField {...params} label={t("user")} variant="filled" />
)}
onChange={(_event, value) => handleUserSelection(value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.defaultMuiPrevented = true;
}
}}
/>
{/* Next ID Chip */}
<Chip
label={`#${nextID ?? "N/A"}`}
color="primary"
size="lg"
variant="solid"
color="neutral"
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: 600,
marginTop: 1,
}}
/>
>
#{nextID ?? "N/A"}
</Chip>
{/* Name Fields - Two Columns */}
<Box className="grid grid-cols-2 gap-3">
<TextField
required
id="first-name"
label={t("first-name")}
variant="filled"
value={formData.firstName}
onChange={handleChange}
{/* Name row */}
<div className="grid grid-cols-2 gap-3">
<Field
name="firstName"
fullWidth
/>
validators={makeFieldValidator("firstName")}
>
{(field) => (
<TextField
label={t("first-name")}
required
id="last-name"
label={t("last-name")}
variant="filled"
value={formData.lastName}
onChange={handleChange}
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
<Field
name="lastName"
fullWidth
/>
</Box>
{/* Email */}
validators={makeFieldValidator("lastName")}
>
{(field) => (
<TextField
label={t("last-name")}
required
id="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
</div>
<Field name="email" validators={makeFieldValidator("email")}>
{(field) => (
<TextField
label={t("email")}
variant="filled"
type="email"
value={formData.email}
onChange={handleChange}
name="email"
fullWidth
/>
{/* Phone Number */}
<TextField
required
id="phone-number"
label={t("phone-number")}
variant="filled"
type="tel"
value={formData.phoneNumber}
onChange={handleChange}
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
<Field
name="phoneNumber"
fullWidth
/>
{/* Tickets and Invoice Checkbox */}
<Box className="grid grid-cols-2 gap-3 items-center">
validators={makeFieldValidator("phoneNumber")}
>
{(field) => (
<TextField
label={t("phone-number")}
type="tel"
required
id="tickets"
type="number"
label={t("tickets")}
variant="filled"
value={formData.tickets}
onChange={handleChange}
name="tickets"
fullWidth
inputProps={{ min: 1 }}
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
<FormControlLabel
control={
)}
</Field>
{/* Tickets + Invoice + Chocolate toggle */}
<div className="grid grid-cols-2 gap-3 items-end">
<FormControl required>
<FormLabel>{t("tickets")}</FormLabel>
<Field
name="tickets"
validators={makeFieldValidator("tickets")}
>
{(field) => {
const errors = getErrors(field);
return (
<>
<Input
type="number"
value={
field.state.value === 0 ? "" : field.state.value
}
onBlur={field.handleBlur}
onChange={(e) => {
const val = e.target.value;
field.handleChange(val === "" ? 0 : Number(val));
}}
variant="soft"
sx={{ borderRadius: "10px" }}
/>
{errors.length > 0 && (
<span className="text-red-500 text-sm">
{errors[0]}
</span>
)}
</>
);
}}
</Field>
</FormControl>
<div className="flex flex-col gap-2 pb-2">
<Checkbox
checked={invoice}
onChange={(e) => setInvoice(e.target.checked)}
/>
onChange={(e) => {
const checked = e.target.checked;
setInvoice(checked);
if (!checked) {
setFieldValue("companyName", "");
setFieldValue("cmpFirstName", "");
setFieldValue("cpmLastName", "");
setFieldValue("cpmEmail", "");
setFieldValue("cpmPhoneNumber", "");
setFieldValue("street", "");
setFieldValue("postalCode", "");
}
}}
label={t("invoice")}
className="justify-end"
variant="outlined"
/>
</Box>
<Field name="chocolates">
{(field) => (
<Checkbox
checked={!!field.state.value}
onChange={(e) => field.handleChange(e.target.checked)}
label={t("chocolates")}
variant="outlined"
/>
)}
</Field>
</div>
</div>
{/* Invoice Fields */}
{/* Invoice details (conditional) */}
{invoice && (
<Box
className="flex flex-col gap-2 pt-3 mt-2"
sx={{
borderTop: "2px solid",
borderColor: "primary.light",
borderRadius: "0",
}}
>
<TextField
required
id="company-name"
label={t("company-name")}
variant="filled"
value={formData.companyName}
onChange={handleChange}
name="companyName"
fullWidth
/>
{/* Invoice Name Fields - Two Columns */}
<Box className="grid grid-cols-2 gap-3">
<TextField
required
id="first-name_invoice"
label={t("first-name")}
variant="filled"
value={formData.cmpFirstName}
onChange={handleChange}
name="cmpFirstName"
fullWidth
/>
<TextField
required
id="last-name_invoice"
label={t("last-name")}
variant="filled"
value={formData.cpmLastName}
onChange={handleChange}
name="cpmLastName"
fullWidth
/>
</Box>
<TextField
required
id="street"
label={t("street")}
variant="filled"
value={formData.street}
onChange={handleChange}
name="street"
fullWidth
/>
<TextField
required
id="postal-code"
label={t("postal-code")}
variant="filled"
value={formData.postalCode}
onChange={handleChange}
name="postalCode"
fullWidth
/>
<TextField
required
id="phone-number_invoice"
label={t("phone-number")}
variant="filled"
type="tel"
value={formData.cpmPhoneNumber}
onChange={handleChange}
name="cpmPhoneNumber"
fullWidth
/>
<TextField
required
id="email_invoice"
label={t("email")}
variant="filled"
type="email"
value={formData.cpmEmail}
onChange={handleChange}
name="cpmEmail"
fullWidth
/>
</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")} *
<div className="flex flex-col gap-3 pt-4 border-t border-blue-200">
<Typography level="title-sm" color="primary">
{t("invoice-details")}
</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,
}}
<Field
name="companyName"
validators={makeFieldValidator("companyName")}
>
{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",
}}
{(field) => (
<TextField
label={t("company-name")}
required
value={formData.paymentMethod}
onChange={() => {}}
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Box>
</Field>
{/* Submit Button */}
<div className="grid grid-cols-2 gap-3">
<Field
name="cmpFirstName"
validators={makeFieldValidator("cmpFirstName")}
>
{(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) => (
<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) => (
<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) => (
<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) => (
<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) => (
<TextField
label={t("email")}
type="email"
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={field.handleChange}
errors={getErrors(field)}
/>
)}
</Field>
</div>
)}
{/* Payment method selection */}
<FormControl required>
<FormLabel>{t("select-payment-method")}</FormLabel>
<Subscribe selector={(state) => state.values.paymentMethod}>
{(paymentMethod) => (
<div className="flex gap-2 flex-wrap mt-1">
{PAYMENT_METHODS.map((method) => (
<Button
key={method}
variant={paymentMethod === method ? "solid" : "soft"}
color="primary"
onClick={() => {
setFieldValue("paymentMethod", method);
if (method === "paypal") {
setQRmodal(true);
}
}}
sx={{
flex: 1,
minWidth: "90px",
borderRadius: "12px",
py: 1.5,
textTransform: "none",
fontWeight: paymentMethod === method ? 700 : 400,
}}
>
{PAYMENT_LABELS[method]}
</Button>
))}
</div>
)}
</Subscribe>
</FormControl>
{mutateFormIsPending ? (
<div className="flex items-center justify-center">
<CircularProgress />
</div>
) : (
<Subscribe selector={(state) => state.values.paymentMethod}>
{(paymentMethod) => (
<Button
type="submit"
variant="contained"
disabled={isLoading || !formData.paymentMethod}
fullWidth
size="large"
disabled={!paymentMethod}
size="lg"
sx={{
mt: 3,
py: 2,
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)",
mt: 2,
borderRadius: "14px",
fontWeight: 700,
letterSpacing: "0.05em",
background: "linear-gradient(135deg, #2563eb, #1d4ed8)",
"&: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",
background: "linear-gradient(135deg, #1d4ed8, #1e40af)",
},
}}
>
{isLoading ? (
<CircularProgress size={24} color="inherit" />
) : (
t("submit")
)}
{t("submit")}
</Button>
)}
</Subscribe>
)}
{/* Alert Message */}
{msg && (
<Alert severity={msg.type} sx={{ mt: 2 }}>
{msg.headline}: {msg.text}
<Alert
color={msg.type}
sx={{ flexDirection: "column", alignItems: "flex-start" }}
>
<Typography level="title-lg" sx={{ mb: 0.5 }}>
{msg.headline}
</Typography>
<Typography level="body-sm">{msg.text}</Typography>
</Alert>
)}
</form>
</Paper>
</Box>
</Sheet>
</div>
</>
);
};
+142 -172
View File
@@ -1,24 +1,26 @@
import { Box, Paper, Typography, Chip, Button } from "@mui/material";
import { useEffect, useState } from "react";
import { CircleCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Sheet, Typography, Chip, Button } from "@mui/joy";
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 [orderId, setOrderId] = useState<string | null>(null);
const [tickets, setTickets] = useState<number>(0);
const [animate, setAnimate] = useState(false);
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);
setOrderId(params.get("id"));
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(() => {
@@ -26,185 +28,153 @@ export const SuccessPage = () => {
window.location.href = "/";
return;
}
const timer = setTimeout(() => setSeconds(seconds - 1), 1000);
const timer = setTimeout(() => setSeconds((s) => s - 1), 1000);
return () => clearTimeout(timer);
}, [seconds]);
// Returns a style object that slides the element up + fades it in.
// Each section gets a slightly later delay for a staggered entrance.
const fadeUp = (delay: string): React.CSSProperties => ({
transition: `opacity 0.5s ease-in-out ${delay}, transform 0.5s ease-in-out ${delay}`,
transform: animate ? "translateY(0)" : "translateY(20px)",
opacity: animate ? 1 : 0,
});
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"
<div className="flex-1 w-full flex items-center justify-center p-4">
<Sheet
variant="plain"
sx={{
backgroundColor: "#fff",
textAlign: "center",
position: "relative",
width: "100%",
maxWidth: 440,
borderRadius: "24px",
p: { xs: "2rem", sm: "2.5rem" },
boxShadow: "0 24px 64px -12px rgba(0,0,0,0.4)",
background: "#fff",
textAlign: "center",
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={{
{/* Green accent bar at the top */}
<div
style={{
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",
background: "linear-gradient(90deg, #22c55e, #86efac)",
transition: "transform 0.8s ease-in-out 0.6s",
transform: animate ? "scaleX(1)" : "scaleX(0)",
transformOrigin: "left",
}}
/>
</Paper>
</Box>
{/* Animated success icon */}
<div
style={{
display: "flex",
justifyContent: "center",
marginBottom: "1.5rem",
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} color="#22c55e" strokeWidth={2.5} />
</div>
{/* Headline */}
<div style={fadeUp("0.2s")}>
<Typography
level="h3"
sx={{ fontWeight: 700, color: "#15803d", mb: 1 }}
>
{t("form-submitted-successfully")}
</Typography>
</div>
{/* Subtitle */}
<div style={fadeUp("0.3s")}>
<Typography level="body-md" sx={{ color: "#6b7280", mb: 3 }}>
{t("ticket-payment", { count: tickets })}
</Typography>
</div>
{/* Tickets chip */}
{tickets > 0 && (
<div style={fadeUp("0.35s")} className="flex justify-center mb-3">
<Chip
size="lg"
variant="soft"
color="success"
sx={{ fontWeight: 700, fontSize: "1rem", px: 2, py: 1 }}
>
{tickets} {tickets === 1 ? t("ticket") : t("tickets")}
</Chip>
</div>
)}
{/* Order ID chip */}
{orderId && (
<div
style={fadeUp("0.4s")}
className="flex flex-col items-center gap-1 mb-4"
>
<Typography
level="body-xs"
sx={{
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: "0.08em",
}}
>
{t("entry-id")}
</Typography>
<Chip
size="lg"
variant="solid"
color="primary"
sx={{ fontWeight: 700, fontSize: "1.25rem", px: 3, py: 1.5 }}
>
#{orderId}
</Chip>
</div>
)}
{/* Return button with countdown */}
<div style={fadeUp("0.45s")} className="mb-4">
<Button
component="a"
href="/"
size="lg"
color="primary"
variant="solid"
fullWidth
sx={{
borderRadius: "12px",
fontWeight: 700,
background: "linear-gradient(135deg, #2563eb, #1d4ed8)",
"&:hover": {
background: "linear-gradient(135deg, #1d4ed8, #1e40af)",
},
}}
>
{seconds}s &mdash; {t("return-to-homepage")}
</Button>
</div>
{/* Thank-you note */}
<div style={fadeUp("0.5s")} className="pt-4 border-t border-slate-100">
<Typography
level="body-sm"
sx={{ color: "#9ca3af", lineHeight: 1.6 }}
>
{t("thank-you")}
</Typography>
</div>
</Sheet>
</div>
);
};
+36
View File
@@ -0,0 +1,36 @@
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,
username: string | null,
) => {
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}&draw=${draw}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
},
);
if (!response.ok) {
const error = await response.text();
throw new Error(error || "Form submission failed");
}
return;
};
+25
View File
@@ -0,0 +1,25 @@
import { API_BASE } from "../../config/api.config";
import Cookies from "js-cookie";
export const fetchInfo = async () => {
console.warn("fetchInfo is fetching!");
const response = await fetch(`${API_BASE}/default/info`);
const data = await response.json();
return data;
};
export const confirmUser = async (username: string | null) => {
if (!username) {
return;
}
console.warn("confirmUser is fetching!");
const response = await fetch(
`${API_BASE}/default/confirm-user?username=${username}`,
);
const data = await response.json();
Cookies.set("selectedUser", username);
return data;
};
+15 -2
View File
@@ -4,6 +4,7 @@
"phone-number": "Telefonnummer",
"tickets": "Lose",
"invoice": "Rechnung",
"invoice-details": "Rechnungsdetails",
"company-name": "Firmenname",
"street": "Straße + Haus Nr.",
"postal-code": "Plz + Stadt",
@@ -13,16 +14,28 @@
"user": "Benutzer",
"next-id": "Nächste Eintragsnummer: ",
"form-submitted-successfully": "Formular erfolgreich übermittelt!",
"orm-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.",
"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"
"return-to-homepage": "Zurück",
"qr-text": "PayPal QR-Code der Claudius Akademie",
"loading": "Lädt...",
"greeting": "Hallo,",
"set-username-headline": "Keinen Benutzer ausgewählt",
"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!",
"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."
}
+15 -2
View File
@@ -3,6 +3,7 @@
"last-name": "Last Name",
"phone-number": "Phone Number",
"invoice": "Invoice",
"invoice-details": "Invoice Details",
"company-name": "Company Name",
"street": "Street + House No.",
"postal-code": "Postal Code + City",
@@ -12,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",
@@ -25,5 +27,16 @@
"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"
"return-to-homepage": "Return",
"qr-text": "PayPal QR-Code from the Claudius Akademie",
"loading": "Loading...",
"greeting": "Hello,",
"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!",
"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."
}
-37
View File
@@ -1,37 +0,0 @@
interface FormData {
firstName: string;
lastName: string;
email: string;
phoneNumber: string;
tickets: number;
companyName: string;
cmpFirstName: string;
cpmLastName: string;
cpmEmail: string;
cpmPhoneNumber: string;
street: string;
postalCode: string;
paymentMethod: string;
}
export const submitFormData = async (data: FormData, username: string) => {
console.log(data);
try {
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();
return { success: false, error: `Server error: ${errorText}` };
}
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
};
+42
View File
@@ -0,0 +1,42 @@
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;
if (clientLng === "en") {
i18n.changeLanguage("de");
Cookies.set("language", "de");
} else if (clientLng === "de") {
i18n.changeLanguage("en");
Cookies.set("language", "en");
} else {
alert("Cannot change language.");
}
};
/**
* 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",
};
-11
View File
@@ -1,11 +0,0 @@
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,jsx,ts,tsx}",
// add other paths if needed
],
theme: {
extend: {},
},
plugins: [],
};
+13
View File
@@ -0,0 +1,13 @@
import http from "k6/http";
import { sleep } from "k6";
export const options = {
vus: 100, // amount of users
duration: "60s", // duration of the test
};
export default function () {
http.get("http://localhost:8004/default/confirm-user?username=TheisGaedigk");
http.get("http://localhost:8004/default/users");
sleep(1);
}
+15
View File
@@ -0,0 +1,15 @@
// Before running: Establish VPN connection first
import http from "k6/http";
import { sleep } from "k6";
export const options = {
vus: 100, // amount of users
duration: "60s", // duration of the test
};
export default function () {
http.get("http://backend:8004/default/confirm-user?username=TheisGaedigk");
http.get("http://backend:8004/default/users");
sleep(0.5);
}
+4 -7
View File
@@ -1,10 +1,9 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
@@ -17,12 +16,10 @@
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+3 -5
View File
@@ -1,9 +1,9 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"target": "es2023",
"lib": ["ES2023"],
"module": "ESNext",
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
@@ -15,12 +15,10 @@
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+4 -5
View File
@@ -1,7 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
plugins: [tailwindcss()],
});
+1
Submodule wg-easy-ca-lose added at 8c70c24205