40 Commits

Author SHA1 Message Date
theis.gaedigk d8e8062990 fixed number input 2026-06-10 13:45:13 +02:00
theis.gaedigk 470fe75d86 added chocolates box 2026-06-09 17:43:56 +02:00
theis.gaedigk d49e62a280 updated wg easy module 2026-06-03 16:32:19 +02:00
theis.gaedigk a66b150d97 added prize draw name to tables 2026-06-03 16:29:45 +02:00
theis.gaedigk f7a0a3753c refactored code 2026-05-25 11:32:45 +02:00
theis.gaedigk 636730d4b2 changed branch link 2026-05-24 14:19:09 +02:00
theis.gaedigk 968e1e7727 foxed readme issue 2026-05-24 14:18:33 +02:00
theis.gaedigk 6d31433321 updated readme 2026-05-24 14:16:28 +02:00
theis.gaedigk 542d832eab improved design 2026-05-24 13:55:12 +02:00
theis.gaedigk 1f11a4ecab added page footer 2026-05-24 13:50:41 +02:00
theis.gaedigk ccb09caa4f refactored code 2026-05-24 13:26:52 +02:00
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 101bd5c060 edited wg-easy 2026-05-20 13:14:28 +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 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 843a5d07d9 added webserver config and dockerfile 2026-05-10 21:08:17 +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 1b4cc81e61 deleted unused compose 2026-05-09 21:05:27 +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 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 2755 additions and 2623 deletions
+3 -1
View File
@@ -155,8 +155,10 @@ backend/public/uploads/
secrets/ secrets/
keys/ keys/
# Own files
ToDo.txt ToDo.txt
PayPal-QR-Code.png
.docker/volumes
# only in development branch # only in development branch
next-env.d.ts 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(); .promise();
export const getUser = async () => { export const getInfo = async () => {
const [rows] = await pool.query("SELECT username FROM users"); 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); const users = rows.map((r) => r.username);
return { users }; const prize_draws = rows2.map((n) => n.name);
return { users, prize_draws };
} else { } 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) { if (rows.length > 0) {
const { first_name, last_name } = rows[0];
const fullname = first_name + " " + last_name;
// creating userTicketTable // creating userTicketTable
const d = new Date(); const d = new Date();
@@ -37,16 +42,16 @@ export const confirmUser = async (username) => {
const date = `${day}_${month}_${year}`; const date = `${day}_${month}_${year}`;
const tableName = `${username}_${date}`; const tableName = `${username}_${date}`;
console.log(tableName);
const [createTable] = await pool.query( const [createTable] = await pool.query(
`CREATE TABLE IF NOT EXISTS ${tableName} ( `CREATE TABLE IF NOT EXISTS ?? (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
Verlosung VARCHAR(100) NOT NULL,
Vorname VARCHAR(100) NOT NULL, Vorname VARCHAR(100) NOT NULL,
Nachname Varchar(100) NOT NULL, Nachname Varchar(100) NOT NULL,
EMail Varchar(100) NOT NULL, EMail Varchar(100) NOT NULL,
Telefonnummer Varchar(100) NOT NULL, Telefonnummer Varchar(100) NOT NULL,
Lose INT NOT NULL, Lose INT NOT NULL,
Schokolade BOOLEAN NOT NULL,
Firmenname Varchar(100), Firmenname Varchar(100),
Vorname_Geschaeftlich Varchar(100), Vorname_Geschaeftlich Varchar(100),
Nachname_Geschaeftlich Varchar(100), Nachname_Geschaeftlich Varchar(100),
@@ -56,28 +61,30 @@ export const confirmUser = async (username) => {
Plz_Ort Varchar(100), Plz_Ort Varchar(100),
Zahlungsmethode Varchar(100), Zahlungsmethode Varchar(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)` )`,
[tableName],
); );
if (createTable) { if (createTable) {
let nextID; let nextID;
const getNextID = async () => { const getNextID = async () => {
const [rows] = await pool.query( 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; nextID = rows.length > 0 ? rows[0].id + 1 : 1;
}; };
await getNextID(); await getNextID();
return { success: true, nextID, tableName }; return { success: true, nextID, tableName, fullname };
} else { } else {
return { success: false, message: "Table creation failed" }; return { success: false, message: "Table creation failed", fullname };
} }
} else { } else {
return null; return null;
} }
}; };
export const newEntry = async (formData, username) => { export const newEntry = async (formData, username, prizeDraw) => {
const confirmation = await confirmUser(username); const confirmation = await confirmUser(username);
if (!confirmation || !confirmation.success) { if (!confirmation || !confirmation.success) {
@@ -87,13 +94,16 @@ export const newEntry = async (formData, username) => {
const tableName = confirmation.tableName; const tableName = confirmation.tableName;
const [result] = await pool.query( 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.firstName,
formData.lastName, formData.lastName,
formData.email, formData.email,
formData.phoneNumber, formData.phoneNumber,
formData.tickets, formData.tickets,
formData.chocolates,
formData.companyName, formData.companyName,
formData.cmpFirstName, formData.cmpFirstName,
formData.cpmLastName, formData.cpmLastName,
@@ -102,7 +112,7 @@ export const newEntry = async (formData, username) => {
formData.street, formData.street,
formData.postalCode, formData.postalCode,
formData.paymentMethod, formData.paymentMethod,
] ],
); );
return { success: true, insertId: result.insertId }; return { success: true, insertId: result.insertId };
+14 -7
View File
@@ -3,26 +3,33 @@ import dotenv from "dotenv";
const router = express.Router(); const router = express.Router();
dotenv.config(); 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) => { router.post("/new-entry", async (req, res) => {
const username = req.query.username; 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) { if (!result.success) {
return res.status(500).json({ message: "Form Data Invalid" }); return res.status(500).json({ message: "Form Data Invalid" });
} }
console.log(req.body);
console.log(username);
res.sendStatus(204); res.sendStatus(204);
}); });
router.get("/users", async (req, res) => { router.get("/info", async (req, res) => {
const users = await getUser(); const info = await getInfo();
res.json(users);
if (!info) {
return res.status(500).json({ message: "Server error" });
}
res.json(info);
}); });
router.get("/confirm-user", async (req, res) => { router.get("/confirm-user", async (req, res) => {
const username = req.query.username; const username = req.query.username;
if (!username) { if (!username) {
return res.status(400).json({ message: "Username is required" }); return res.status(400).json({ message: "Username is required" });
} }
+10
View File
@@ -1,3 +1,5 @@
USE ca_lose;
CREATE TABLE users ( CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE, username VARCHAR(100) NOT NULL UNIQUE,
@@ -6,8 +8,16 @@ CREATE TABLE users (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 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 ( CREATE TABLE xx_DD_MM_YYYY (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
Verlosung VARCHAR(100) NOT NULL,
Vorname VARCHAR(100) NOT NULL, Vorname VARCHAR(100) NOT NULL,
Nachname Varchar(100) NOT NULL, Nachname Varchar(100) NOT NULL,
EMail 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
+1 -1
View File
@@ -32,7 +32,7 @@ services:
MYSQL_DATABASE: ca_lose MYSQL_DATABASE: ca_lose
TZ: Europe/Berlin TZ: Europe/Berlin
volumes: 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 - ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
volumes: volumes:
+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: 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](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/) 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/)
## React Compiler ## React Compiler
-1
View File
@@ -16,7 +16,6 @@ export default defineConfig([
reactRefresh.configs.vite, reactRefresh.configs.vite,
], ],
languageOptions: { languageOptions: {
ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
}, },
+13 -2
View File
@@ -2,9 +2,20 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lose verkaufen</title> <title>Lose Verkaufen</title>
</head> </head>
<body> <body>
<div id="root"></div> <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": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@fontsource/roboto": "^5.2.9", "@fontsource-variable/inter": "^5.2.8",
"@mui/icons-material": "^7.3.6", "@fontsource/inter": "^5.2.8",
"@mui/material": "^7.3.6", "@mui/icons-material": "^9.0.1",
"@mui/styled-engine-sc": "^7.3.6", "@mui/joy": "^5.0.0-beta.52",
"@tailwindcss/vite": "^4.1.11", "@mui/material": "^9.0.1",
"i18next": "^25.7.4", "@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", "js-cookie": "^3.0.5",
"lucide": "^0.562.0", "k6": "^0.0.0",
"lucide-react": "^0.562.0", "lucide-react": "^1.14.0",
"react": "^19.2.3", "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^19.2.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-i18next": "^16.5.3", "react-i18next": "^17.0.7",
"react-router-dom": "^7.11.0", "react-router-dom": "^7.15.0",
"styled-components": "^6.1.19", "tailwindcss": "^4.3.0",
"tailwind-merge": "^3.3.1", "validator": "^13.15.35",
"tailwindcss": "^4.1.11", "zod": "^4.4.3"
"tailwindcss-animate": "^1.0.7" },
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^10.0.1",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/node": "^24.10.1", "@types/node": "^24.12.2",
"@types/react": "^19.2.5", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@types/validator": "^13.15.10",
"eslint": "^9.39.1", "@vitejs/plugin-react": "^6.0.1",
"eslint-plugin-react-hooks": "^7.0.1", "eslint": "^10.2.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-hooks": "^7.1.1",
"globals": "^16.5.0", "eslint-plugin-react-refresh": "^0.5.2",
"typescript": "~5.9.3", "globals": "^17.5.0",
"typescript-eslint": "^8.46.4", "typescript": "~6.0.2",
"vite": "^7.2.4" "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"; @import "tailwindcss";
html,
body,
#root {
height: 100%;
margin: 0;
}
body.success-bg {
background: linear-gradient(135deg, #0f172a, #111827);
}
+12 -6
View File
@@ -2,15 +2,21 @@ import "./App.css";
import { BrowserRouter, Route, Routes } from "react-router-dom"; import { BrowserRouter, Route, Routes } from "react-router-dom";
import { MainForm } from "./pages/MainForm"; import { MainForm } from "./pages/MainForm";
import { SuccessPage } from "./pages/SuccessPage"; import { SuccessPage } from "./pages/SuccessPage";
import { PageFooter } from "./components/PageFooter";
function App() { function App() {
return ( return (
<BrowserRouter> <div className="min-h-screen flex flex-col">
<Routes> <BrowserRouter>
<Route path="/" element={<MainForm />} /> <main className="flex-1 flex">
<Route path="/success" element={<SuccessPage />} /> <Routes>
</Routes> <Route path="/" element={<MainForm />} />
</BrowserRouter> <Route path="/success" element={<SuccessPage />} />
</Routes>
</main>
</BrowserRouter>
<PageFooter />
</div>
); );
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

-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"; @import "tailwindcss";
html,
body,
#root {
height: 100%;
margin: 0;
}
html {
font-size: large;
}
+8 -3
View File
@@ -1,11 +1,16 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "./utils/i18n/index.ts";
import "./index.css"; import "./index.css";
import "./utils/i18n";
import App from "./App.tsx"; import App from "./App.tsx";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <QueryClientProvider client={queryClient}>
</StrictMode> <App />
</QueryClientProvider>
</StrictMode>,
); );
File diff suppressed because it is too large Load Diff
+142 -172
View File
@@ -1,24 +1,26 @@
import { Box, Paper, Typography, Chip, Button } from "@mui/material";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { CircleCheck } from "lucide-react"; import { CircleCheck } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Sheet, Typography, Chip, Button } from "@mui/joy";
export const SuccessPage = () => { 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 { 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); const [seconds, setSeconds] = useState(30);
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const id = params.get("id"); setOrderId(params.get("id"));
const numberOfTickets = params.get("tickets"); setTickets(parseInt(params.get("tickets") ?? "0", 10));
// Small delay so the CSS transition actually plays
setOrderId(id);
setNumberOfTickets(numberOfTickets ? parseInt(numberOfTickets, 10) : 0);
setTimeout(() => setAnimate(true), 100); setTimeout(() => setAnimate(true), 100);
document.body.classList.add("success-bg");
return () => {
document.body.classList.remove("success-bg");
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -26,185 +28,153 @@ export const SuccessPage = () => {
window.location.href = "/"; window.location.href = "/";
return; return;
} }
const timer = setTimeout(() => setSeconds((s) => s - 1), 1000);
const timer = setTimeout(() => setSeconds(seconds - 1), 1000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [seconds]); }, [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 ( return (
<Box className="min-h-screen bg-gray-800 flex items-center justify-center p-4"> <div className="flex-1 w-full flex items-center justify-center p-4">
<Paper <Sheet
elevation={3} variant="plain"
className="w-full max-w-md p-8 rounded-lg"
sx={{ sx={{
backgroundColor: "#fff",
textAlign: "center",
position: "relative", 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", overflow: "hidden",
}} }}
> >
{/* Animated Success Icon */} {/* Green accent bar at the top */}
<Box <div
sx={{ style={{
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", position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
height: "4px", height: "4px",
background: "linear-gradient(90deg, #4caf50 0%, #81c784 100%)", background: "linear-gradient(90deg, #22c55e, #86efac)",
transition: "all 0.8s ease-in-out 0.6s", transition: "transform 0.8s ease-in-out 0.6s",
transform: animate ? "scaleX(1)" : "scaleX(0)", transform: animate ? "scaleX(1)" : "scaleX(0)",
transformOrigin: "left", 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", "phone-number": "Telefonnummer",
"tickets": "Lose", "tickets": "Lose",
"invoice": "Rechnung", "invoice": "Rechnung",
"invoice-details": "Rechnungsdetails",
"company-name": "Firmenname", "company-name": "Firmenname",
"street": "Straße + Haus Nr.", "street": "Straße + Haus Nr.",
"postal-code": "Plz + Stadt", "postal-code": "Plz + Stadt",
@@ -13,16 +14,28 @@
"user": "Benutzer", "user": "Benutzer",
"next-id": "Nächste Eintragsnummer: ", "next-id": "Nächste Eintragsnummer: ",
"form-submitted-successfully": "Formular erfolgreich übermittelt!", "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", "success": "Erfolg",
"error": "Fehler", "error": "Fehler",
"cash": "Bar", "cash": "Bar",
"paypal": "PayPal", "paypal": "PayPal",
"prize_draws": "Verlosungen",
"transfer": "Andere (notieren)", "transfer": "Andere (notieren)",
"ticket-payment_one": "Sie haben erfolgreich {{count}} Los gekauft.", "ticket-payment_one": "Sie haben erfolgreich {{count}} Los gekauft.",
"ticket-payment_other": "Sie haben erfolgreich {{count}} Lose gekauft.", "ticket-payment_other": "Sie haben erfolgreich {{count}} Lose gekauft.",
"entry-id": "Eintrags-ID", "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.", "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", "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", "last-name": "Last Name",
"phone-number": "Phone Number", "phone-number": "Phone Number",
"invoice": "Invoice", "invoice": "Invoice",
"invoice-details": "Invoice Details",
"company-name": "Company Name", "company-name": "Company Name",
"street": "Street + House No.", "street": "Street + House No.",
"postal-code": "Postal Code + City", "postal-code": "Postal Code + City",
@@ -12,7 +13,8 @@
"user": "User", "user": "User",
"next-id": "Next Entry Number: ", "next-id": "Next Entry Number: ",
"form-submitted-successfully": "Form submitted successfully!", "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", "success": "Success",
"error": "Error", "error": "Error",
"cash": "Cash", "cash": "Cash",
@@ -25,5 +27,16 @@
"entry-id": "Entry ID", "entry-id": "Entry ID",
"thank-you": "Thank you for supporting the Claudius Akademie! We wish you the best of luck with your ticket.", "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", "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."
} }
-40
View File
@@ -1,40 +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(
`http://localhost:8004/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": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022", "target": "es2023",
"useDefineForClassFields": true, "lib": ["ES2023", "DOM"],
"lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "esnext",
"module": "ESNext",
"types": ["vite/client"], "types": ["vite/client"],
"skipLibCheck": true, "skipLibCheck": true,
@@ -17,12 +16,10 @@
"jsx": "react-jsx", "jsx": "react-jsx",
/* Linting */ /* Linting */
"strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true
"noUncheckedSideEffectImports": true
}, },
"include": ["src"] "include": ["src"]
} }
+3 -5
View File
@@ -1,9 +1,9 @@
{ {
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023", "target": "es2023",
"lib": ["ES2023"], "lib": ["ES2023"],
"module": "ESNext", "module": "esnext",
"types": ["node"], "types": ["node"],
"skipLibCheck": true, "skipLibCheck": true,
@@ -15,12 +15,10 @@
"noEmit": true, "noEmit": true,
/* Linting */ /* Linting */
"strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true
"noUncheckedSideEffectImports": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }
+4 -5
View File
@@ -1,7 +1,6 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import react from '@vitejs/plugin-react' import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [tailwindcss()],
}) });
+1
Submodule wg-easy-ca-lose added at 8c70c24205