Compare commits
35 Commits
471c0c7a49
..
v2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 636730d4b2 | |||
| 968e1e7727 | |||
| 6d31433321 | |||
| 542d832eab | |||
| 1f11a4ecab | |||
| ccb09caa4f | |||
| 1cd0379654 | |||
| 39b15aec65 | |||
| 6915e60cec | |||
| 9dead72e1e | |||
| cca303c1f6 | |||
| 101bd5c060 | |||
| f32931ded3 | |||
| 4ce1817bd0 | |||
| 11c2372cae | |||
| d5b6c9665c | |||
| ce2d0bb329 | |||
| c25ab48880 | |||
| 8932f5d004 | |||
| 3832aca12c | |||
| a88c141f28 | |||
| 843a5d07d9 | |||
| 4008b3de94 | |||
| dbacc172d8 | |||
| 08cb38df8f | |||
| 8650745009 | |||
| 5c035ba1c0 | |||
| 746530ae4c | |||
| 3fd0889ebf | |||
| e3a26de92d | |||
| 75b75169a8 | |||
| 1b4cc81e61 | |||
| 2a4825269b | |||
| 4df6d243f3 | |||
| ab9cfd6cbf |
+2
-1
@@ -155,8 +155,9 @@ backend/public/uploads/
|
|||||||
secrets/
|
secrets/
|
||||||
keys/
|
keys/
|
||||||
|
|
||||||
|
# Own files
|
||||||
ToDo.txt
|
ToDo.txt
|
||||||
|
PayPal-QR-Code.png
|
||||||
|
|
||||||
# only in development branch
|
# only in development branch
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -1,2 +1,98 @@
|
|||||||
# ca-lose
|
# LuckySign
|
||||||
|
|
||||||
|
Ticket intake and validation app with a React frontend and an Express + MySQL backend.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

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

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
### Production Tech Stack
|
||||||
|
|
||||||
|
> **Note** For production, check the [prod branch](https://git.the1s.de/theis.gaedigk/ca-lose/src/branch/prod/) which contains an VPN git submodule of wg-easy to run the app securely on a private network. It also contains a dnsmasq container to resolve the backend service name from the frontend container. The main branch is meant for local development and testing, so it doesn't include those components to keep things simple.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- Frontend (Vite + React + Tailwind + MUI): [frontend](frontend)
|
||||||
|
- 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)).
|
||||||
|
|||||||
@@ -28,6 +28,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,10 +40,8 @@ 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,
|
||||||
Vorname VARCHAR(100) NOT NULL,
|
Vorname VARCHAR(100) NOT NULL,
|
||||||
Nachname Varchar(100) NOT NULL,
|
Nachname Varchar(100) NOT NULL,
|
||||||
@@ -56,21 +57,23 @@ 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;
|
||||||
@@ -87,8 +90,9 @@ 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 ?? (Vorname, Nachname, EMail, Telefonnummer, Lose, Firmenname, Vorname_Geschaeftlich, Nachname_Geschaeftlich, EMail_Geschaeftlich, Telefonnummer_Geschaeftlich, Strasse_Hausnr, Plz_Ort, Zahlungsmethode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
|
tableName,
|
||||||
formData.firstName,
|
formData.firstName,
|
||||||
formData.lastName,
|
formData.lastName,
|
||||||
formData.email,
|
formData.email,
|
||||||
@@ -102,7 +106,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 };
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ router.post("/new-entry", async (req, res) => {
|
|||||||
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ CREATE TABLE users (
|
|||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
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,
|
||||||
Vorname VARCHAR(100) NOT NULL,
|
Vorname VARCHAR(100) NOT NULL,
|
||||||
|
|||||||
@@ -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
|
|
||||||
+2
-2
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Generated
+1301
-1754
File diff suppressed because it is too large
Load Diff
+34
-27
@@ -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 |
@@ -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 +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 |
@@ -1 +1,12 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.success-bg {
|
||||||
|
background: linear-gradient(135deg, #0f172a, #111827);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,15 +2,21 @@ import "./App.css";
|
|||||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
import { 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 (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<main className="flex-1 flex">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<MainForm />} />
|
<Route path="/" element={<MainForm />} />
|
||||||
<Route path="/success" element={<SuccessPage />} />
|
<Route path="/success" element={<SuccessPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</main>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
<PageFooter />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -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 |
@@ -0,0 +1,42 @@
|
|||||||
|
import { Link, Sheet, Typography } from "@mui/joy";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import qrCode from "../assets/Portfolio-QR-Code.png";
|
||||||
|
|
||||||
|
export const PageFooter = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="w-full mt-auto px-3 pb-3">
|
||||||
|
<Sheet
|
||||||
|
variant="soft"
|
||||||
|
className="mx-auto w-full max-w-3xl rounded-2xl border border-slate-200/70 bg-white/80 backdrop-blur"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2 p-2.5 sm:p-3">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<Typography
|
||||||
|
level="title-sm"
|
||||||
|
className="text-slate-800 tracking-wide sm:pr-4"
|
||||||
|
>
|
||||||
|
{t("footer-headline")}
|
||||||
|
<Link
|
||||||
|
href="https://portfolio-theis.de/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="ml-2 inline-flex items-center text-slate-700 underline decoration-slate-300 underline-offset-4 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
portfolio-theis.de
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
<div className="flex items-center justify-center sm:justify-end sm:flex-none">
|
||||||
|
<img
|
||||||
|
src={qrCode}
|
||||||
|
alt="https://portfolio-theis.de/"
|
||||||
|
className="h-20 w-20 shrink-0 rounded-md border border-slate-200 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { TextFieldProps } from "../config/interfaces.config";
|
||||||
|
import { FormControl, FormLabel, Input } from "@mui/joy";
|
||||||
|
|
||||||
|
export const TextField = ({
|
||||||
|
label,
|
||||||
|
type = "text",
|
||||||
|
required,
|
||||||
|
errors,
|
||||||
|
value,
|
||||||
|
onBlur,
|
||||||
|
onChange,
|
||||||
|
slotProps,
|
||||||
|
afterInput,
|
||||||
|
}: TextFieldProps) => (
|
||||||
|
<FormControl required={required}>
|
||||||
|
<FormLabel>{label}</FormLabel>
|
||||||
|
<Input
|
||||||
|
value={value ?? ""}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
type={type}
|
||||||
|
variant="soft"
|
||||||
|
sx={{ borderRadius: "10px" }}
|
||||||
|
slotProps={slotProps}
|
||||||
|
/>
|
||||||
|
{afterInput}
|
||||||
|
{errors[0] ? (
|
||||||
|
<span className="text-red-500 text-sm">{errors[0]}</span>
|
||||||
|
) : null}
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
@@ -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,40 @@
|
|||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalDialog,
|
||||||
|
Typography,
|
||||||
|
ModalClose,
|
||||||
|
Autocomplete,
|
||||||
|
} from "@mui/joy";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface SelectUserModalProps {
|
||||||
|
showSelectUser: boolean;
|
||||||
|
setShowSelectUser: (value: boolean) => void;
|
||||||
|
usernameData: { users: string[] };
|
||||||
|
usernameDataIsLoading: boolean;
|
||||||
|
selectedUser: string | null;
|
||||||
|
handleUserSelection: (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.usernameData?.users ?? []}
|
||||||
|
loading={props.usernameDataIsLoading}
|
||||||
|
loadingText={t("loading")}
|
||||||
|
value={props.selectedUser}
|
||||||
|
onChange={(_, value) => props.handleUserSelection(value)}
|
||||||
|
placeholder={t("user")}
|
||||||
|
variant="soft"
|
||||||
|
sx={{ borderRadius: "10px" }}
|
||||||
|
/>
|
||||||
|
</ModalDialog>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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),
|
||||||
|
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")),
|
||||||
|
});
|
||||||
@@ -1 +1,12 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: large;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
+477
-392
@@ -1,35 +1,92 @@
|
|||||||
import {
|
|
||||||
TextField,
|
|
||||||
FormControlLabel,
|
|
||||||
Checkbox,
|
|
||||||
Button,
|
|
||||||
Alert,
|
|
||||||
CircularProgress,
|
|
||||||
Autocomplete,
|
|
||||||
Chip,
|
|
||||||
Box,
|
|
||||||
Paper,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { submitFormData } from "../utils/sender";
|
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import * as React from "react";
|
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, fetchUsers } from "../utils/api/users";
|
||||||
|
import { QRcodeModal } from "../components/modals/QR-CodeModal";
|
||||||
|
import { SelectUserModal } from "../components/modals/SelectUserModal";
|
||||||
|
import { useForm } from "@tanstack/react-form";
|
||||||
|
import { changeTranslation } from "../utils/uxFncs";
|
||||||
|
import { createFormSchema } from "../config/interfaces.config";
|
||||||
|
import type { ZodObject, ZodRawShape } from "zod";
|
||||||
|
import { TextField } from "../components/TextField";
|
||||||
|
|
||||||
interface Message {
|
const PAYMENT_METHODS = ["bar", "paypal", "andere"] as const;
|
||||||
type: "error" | "info" | "success" | "warning";
|
const PAYMENT_LABELS: Record<string, string> = {
|
||||||
headline: string;
|
bar: "Cash",
|
||||||
text: string;
|
paypal: "PayPal",
|
||||||
|
andere: "Transfer",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a single field against the full Zod schema.
|
||||||
|
* Returns the first error message for that field, or undefined if valid.
|
||||||
|
*/
|
||||||
|
function validateFieldWithZod(
|
||||||
|
schema: ZodObject<ZodRawShape>,
|
||||||
|
fieldName: string,
|
||||||
|
allValues: Record<string, unknown>,
|
||||||
|
): string | undefined {
|
||||||
|
const result = schema.safeParse(allValues);
|
||||||
|
if (result.success) return undefined;
|
||||||
|
const issue = result.error.issues.find(
|
||||||
|
(i) => i.path.length === 1 && i.path[0] === fieldName,
|
||||||
|
);
|
||||||
|
return issue?.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MainForm = () => {
|
export const MainForm = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [invoice, setInvoice] = useState(false);
|
const [invoice, setInvoice] = useState(false);
|
||||||
const [msg, setMsg] = useState<Message | null>(null);
|
const [msg, setMsg] = useState<Message | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
||||||
const [nextID, setNextID] = useState<number | null>(null);
|
const [showSelectUser, setShowSelectUser] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [QRmodal, setQRmodal] = useState(false);
|
||||||
|
|
||||||
|
const formSchema = createFormSchema(t, invoice);
|
||||||
|
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { Field, Subscribe, handleSubmit, setFieldValue } = useForm({
|
||||||
|
defaultValues: {
|
||||||
firstName: "",
|
firstName: "",
|
||||||
lastName: "",
|
lastName: "",
|
||||||
email: "",
|
email: "",
|
||||||
@@ -43,106 +100,132 @@ export const MainForm = () => {
|
|||||||
street: "",
|
street: "",
|
||||||
postalCode: "",
|
postalCode: "",
|
||||||
paymentMethod: "",
|
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);
|
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(() => {
|
useEffect(() => {
|
||||||
// Fetch user data or any other data needed for the form
|
const savedUser = Cookies.get("selectedUser");
|
||||||
try {
|
if (savedUser) {
|
||||||
const fetchUsers = async () => {
|
setSelectedUser(savedUser);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`;
|
|
||||||
} else {
|
} else {
|
||||||
setMsg({
|
setMsg({
|
||||||
type: "error",
|
type: "warning",
|
||||||
headline: t("error"),
|
headline: t("set-username-headline"),
|
||||||
text: result.error || t("form-submission-failed"),
|
text: t("set-username-text"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
}, []);
|
||||||
setIsLoading(false);
|
|
||||||
|
const { data: usernameData, isLoading: usernameDataIsLoading } = useQuery({
|
||||||
|
queryKey: ["users"],
|
||||||
|
queryFn: fetchUsers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: userData } = useQuery({
|
||||||
|
queryKey: ["user", selectedUser],
|
||||||
|
enabled: !!selectedUser,
|
||||||
|
queryFn: () => confirmUser(selectedUser),
|
||||||
|
});
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
const handleUserSelection = (username: string | null) => {
|
||||||
|
if (username == null || username == "") {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setSelectedUser(username);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<>
|
||||||
className="bg-gray-100 py-10 px-4"
|
<SelectUserModal
|
||||||
|
showSelectUser={showSelectUser}
|
||||||
|
setShowSelectUser={setShowSelectUser}
|
||||||
|
usernameData={usernameData}
|
||||||
|
usernameDataIsLoading={usernameDataIsLoading}
|
||||||
|
selectedUser={selectedUser}
|
||||||
|
handleUserSelection={handleUserSelection}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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={{
|
sx={{
|
||||||
minHeight: "100vh",
|
position: "relative",
|
||||||
width: "100%",
|
maxWidth: 460,
|
||||||
display: "flex",
|
borderRadius: "24px",
|
||||||
alignItems: "center",
|
p: { xs: "1.5rem", sm: "2rem" },
|
||||||
justifyContent: "center",
|
boxShadow: "0 24px 64px -12px rgba(0,0,0,0.18)",
|
||||||
|
background: "#fff",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Paper
|
<ButtonGroup
|
||||||
elevation={6}
|
color="primary"
|
||||||
className="w-full rounded-2xl"
|
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={{
|
sx={{
|
||||||
backgroundColor: "#fff",
|
fontFamily: "monospace",
|
||||||
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)",
|
opacity: "100%",
|
||||||
width: "100%",
|
alignSelf: "center",
|
||||||
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",
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{`${t("greeting")} ${userData?.fullname ?? t("loading")}`}
|
||||||
|
</Typography>
|
||||||
|
</ButtonGroup>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -150,337 +233,339 @@ export const MainForm = () => {
|
|||||||
}}
|
}}
|
||||||
className="flex flex-col gap-4"
|
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
|
<Chip
|
||||||
label={`#${nextID ?? "N/A"}`}
|
size="lg"
|
||||||
color="primary"
|
variant="solid"
|
||||||
|
color="neutral"
|
||||||
sx={{
|
sx={{
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
fontWeight: 500,
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
mt: 0.5,
|
|
||||||
mb: 0.5,
|
|
||||||
py: 0.5,
|
|
||||||
px: 1.25,
|
|
||||||
borderRadius: "999px",
|
borderRadius: "999px",
|
||||||
background: "linear-gradient(135deg, #1976d2 0%, #1565c0 100%)",
|
fontWeight: 600,
|
||||||
|
marginTop: 1,
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
#{nextID ?? "N/A"}
|
||||||
|
</Chip>
|
||||||
|
|
||||||
{/* Name Fields - Two Columns */}
|
{/* Name row */}
|
||||||
<Box className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<TextField
|
<Field
|
||||||
required
|
|
||||||
id="first-name"
|
|
||||||
label={t("first-name")}
|
|
||||||
variant="filled"
|
|
||||||
value={formData.firstName}
|
|
||||||
onChange={handleChange}
|
|
||||||
name="firstName"
|
name="firstName"
|
||||||
fullWidth
|
validators={makeFieldValidator("firstName")}
|
||||||
/>
|
>
|
||||||
|
{(field) => (
|
||||||
<TextField
|
<TextField
|
||||||
|
label={t("first-name")}
|
||||||
required
|
required
|
||||||
id="last-name"
|
value={field.state.value}
|
||||||
label={t("last-name")}
|
onBlur={field.handleBlur}
|
||||||
variant="filled"
|
onChange={field.handleChange}
|
||||||
value={formData.lastName}
|
errors={getErrors(field)}
|
||||||
onChange={handleChange}
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
name="lastName"
|
name="lastName"
|
||||||
fullWidth
|
validators={makeFieldValidator("lastName")}
|
||||||
/>
|
>
|
||||||
</Box>
|
{(field) => (
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<TextField
|
<TextField
|
||||||
|
label={t("last-name")}
|
||||||
required
|
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")}
|
label={t("email")}
|
||||||
variant="filled"
|
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.email}
|
|
||||||
onChange={handleChange}
|
|
||||||
name="email"
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Phone Number */}
|
|
||||||
<TextField
|
|
||||||
required
|
required
|
||||||
id="phone-number"
|
value={field.state.value}
|
||||||
label={t("phone-number")}
|
onBlur={field.handleBlur}
|
||||||
variant="filled"
|
onChange={field.handleChange}
|
||||||
type="tel"
|
errors={getErrors(field)}
|
||||||
value={formData.phoneNumber}
|
/>
|
||||||
onChange={handleChange}
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
name="phoneNumber"
|
name="phoneNumber"
|
||||||
fullWidth
|
validators={makeFieldValidator("phoneNumber")}
|
||||||
/>
|
>
|
||||||
|
{(field) => (
|
||||||
{/* Tickets and Invoice Checkbox */}
|
|
||||||
<Box className="grid grid-cols-2 gap-3 items-center">
|
|
||||||
<TextField
|
<TextField
|
||||||
|
label={t("phone-number")}
|
||||||
|
type="tel"
|
||||||
required
|
required
|
||||||
id="tickets"
|
value={field.state.value}
|
||||||
type="number"
|
onBlur={field.handleBlur}
|
||||||
label={t("tickets")}
|
onChange={field.handleChange}
|
||||||
variant="filled"
|
errors={getErrors(field)}
|
||||||
value={formData.tickets}
|
|
||||||
onChange={handleChange}
|
|
||||||
name="tickets"
|
|
||||||
fullWidth
|
|
||||||
inputProps={{ min: 1 }}
|
|
||||||
/>
|
/>
|
||||||
<FormControlLabel
|
)}
|
||||||
control={
|
</Field>
|
||||||
|
|
||||||
|
{/* Tickets + Invoice 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 ?? ""}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.handleChange(Number(e.target.value))
|
||||||
|
}
|
||||||
|
slotProps={{ input: { min: 1 } }}
|
||||||
|
variant="soft"
|
||||||
|
sx={{ borderRadius: "10px" }}
|
||||||
|
/>
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<span className="text-red-500 text-sm">
|
||||||
|
{errors[0]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Field>
|
||||||
|
</FormControl>
|
||||||
|
<div className="flex items-center pb-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={invoice}
|
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")}
|
label={t("invoice")}
|
||||||
className="justify-end"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Invoice Fields */}
|
{/* Invoice details (conditional) */}
|
||||||
{invoice && (
|
{invoice && (
|
||||||
<Box
|
<div className="flex flex-col gap-3 pt-4 border-t border-blue-200">
|
||||||
className="flex flex-col gap-2 pt-3 mt-2"
|
<Typography level="title-sm" color="primary">
|
||||||
sx={{
|
{t("invoice-details")}
|
||||||
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")} *
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box className="flex gap-2 flex-wrap">
|
|
||||||
<Button
|
<Field
|
||||||
variant={
|
name="companyName"
|
||||||
formData.paymentMethod === "bar" ? "contained" : "outlined"
|
validators={makeFieldValidator("companyName")}
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
setFormData({ ...formData, paymentMethod: "bar" })
|
|
||||||
}
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
minWidth: "100px",
|
|
||||||
py: 1.5,
|
|
||||||
borderRadius: "12px",
|
|
||||||
textTransform: "none",
|
|
||||||
fontWeight: formData.paymentMethod === "bar" ? 600 : 400,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t("cash")}
|
{(field) => (
|
||||||
</Button>
|
<TextField
|
||||||
<Button
|
label={t("company-name")}
|
||||||
variant={
|
|
||||||
formData.paymentMethod === "paypal" ? "contained" : "outlined"
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
setFormData({ ...formData, paymentMethod: "paypal" })
|
|
||||||
}
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
minWidth: "100px",
|
|
||||||
py: 1.5,
|
|
||||||
borderRadius: "12px",
|
|
||||||
textTransform: "none",
|
|
||||||
fontWeight: formData.paymentMethod === "paypal" ? 600 : 400,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("paypal")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={
|
|
||||||
formData.paymentMethod === "andere" ? "contained" : "outlined"
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
setFormData({ ...formData, paymentMethod: "andere" })
|
|
||||||
}
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
minWidth: "100px",
|
|
||||||
py: 1.5,
|
|
||||||
borderRadius: "12px",
|
|
||||||
textTransform: "none",
|
|
||||||
fontWeight: formData.paymentMethod === "andere" ? 600 : 400,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("transfer")}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{!formData.paymentMethod && (
|
|
||||||
<input
|
|
||||||
tabIndex={-1}
|
|
||||||
autoComplete="off"
|
|
||||||
style={{
|
|
||||||
opacity: 0,
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
position: "absolute",
|
|
||||||
}}
|
|
||||||
required
|
required
|
||||||
value={formData.paymentMethod}
|
value={field.state.value}
|
||||||
onChange={() => {}}
|
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
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="contained"
|
disabled={!paymentMethod}
|
||||||
disabled={isLoading || !formData.paymentMethod}
|
size="lg"
|
||||||
fullWidth
|
|
||||||
size="large"
|
|
||||||
sx={{
|
sx={{
|
||||||
mt: 3,
|
mt: 2,
|
||||||
py: 2,
|
borderRadius: "14px",
|
||||||
textTransform: "uppercase",
|
fontWeight: 700,
|
||||||
fontWeight: "bold",
|
letterSpacing: "0.05em",
|
||||||
borderRadius: "12px",
|
background: "linear-gradient(135deg, #2563eb, #1d4ed8)",
|
||||||
fontSize: "1rem",
|
|
||||||
background: "linear-gradient(135deg, #1976d2 0%, #1565c0 100%)",
|
|
||||||
boxShadow: "0 4px 14px 0 rgba(25, 118, 210, 0.39)",
|
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
background: "linear-gradient(135deg, #1565c0 0%, #0d47a1 100%)",
|
background: "linear-gradient(135deg, #1d4ed8, #1e40af)",
|
||||||
boxShadow: "0 6px 20px 0 rgba(25, 118, 210, 0.5)",
|
|
||||||
},
|
|
||||||
"&:disabled": {
|
|
||||||
background: "#e0e0e0",
|
|
||||||
boxShadow: "none",
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{t("submit")}
|
||||||
<CircularProgress size={24} color="inherit" />
|
|
||||||
) : (
|
|
||||||
t("submit")
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
</Subscribe>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Alert Message */}
|
|
||||||
{msg && (
|
{msg && (
|
||||||
<Alert severity={msg.type} sx={{ mt: 2 }}>
|
<Alert
|
||||||
{msg.headline}: {msg.text}
|
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>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Paper>
|
</Sheet>
|
||||||
</Box>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+142
-172
@@ -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 — {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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { API_BASE } from "../../config/api.config";
|
||||||
|
import type { FormData } from "../../config/interfaces.config";
|
||||||
|
|
||||||
|
export const submitFormData = async (
|
||||||
|
data: FormData,
|
||||||
|
username: string | null,
|
||||||
|
) => {
|
||||||
|
console.warn("submitFormData is fetching!");
|
||||||
|
|
||||||
|
// await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE}/default/new-entry?username=${username}`,
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { API_BASE } from "../../config/api.config";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
|
||||||
|
export const fetchUsers = async () => {
|
||||||
|
console.warn("fetchUsers is fetching!");
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/default/users`);
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -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,7 +14,7 @@
|
|||||||
"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.",
|
||||||
"success": "Erfolg",
|
"success": "Erfolg",
|
||||||
"error": "Fehler",
|
"error": "Fehler",
|
||||||
"cash": "Bar",
|
"cash": "Bar",
|
||||||
@@ -24,5 +25,14 @@
|
|||||||
"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: "
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
@@ -25,5 +26,14 @@
|
|||||||
"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: "
|
||||||
}
|
}
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import i18n from "./i18n";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{js,jsx,ts,tsx}",
|
|
||||||
// add other paths if needed
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()],
|
||||||
})
|
});
|
||||||
|
|||||||
Submodule
+1
Submodule wg-easy-ca-lose added at 9581e6eacb
Reference in New Issue
Block a user