Initial commit: Wachplan v1.1
This commit is contained in:
commit
897597bb54
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
325
README.md
Normal file
325
README.md
Normal file
@ -0,0 +1,325 @@
|
||||
# PlusFit24 – Mitgliedschafts-Anmeldesystem
|
||||
## Vollständige Installationsanleitung
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Übersicht deiner Server-Infrastruktur
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
▼
|
||||
NGINX (192.168.0.157) ← SSL Zertifikat hier
|
||||
│ plusfit24.software-joksch.com
|
||||
│ Proxy → Port 3100
|
||||
▼
|
||||
Node.js App Server (192.168.0.163:3100)
|
||||
│
|
||||
▼
|
||||
MariaDB (85.215.63.122:3306)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SCHRITT 1: Datenbank einrichten (MariaDB 85.215.63.122)
|
||||
|
||||
Verbinde dich mit deinem MariaDB Server:
|
||||
|
||||
```bash
|
||||
mysql -h 85.215.63.122 -u root -p
|
||||
```
|
||||
|
||||
Führe das Schema aus:
|
||||
|
||||
```bash
|
||||
mysql -h 85.215.63.122 -u root -p < database/schema.sql
|
||||
```
|
||||
|
||||
Erstelle einen dedizierten DB-User (sicherer als root):
|
||||
|
||||
```sql
|
||||
CREATE USER 'plusfit24'@'192.168.0.163' IDENTIFIED BY 'DEIN_SICHERES_PASSWORT';
|
||||
GRANT ALL PRIVILEGES ON plusfit24.* TO 'plusfit24'@'192.168.0.163';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
⚠️ **Wichtig:** Ersetze `DEIN_SICHERES_PASSWORT` mit einem echten starken Passwort!
|
||||
|
||||
---
|
||||
|
||||
## SCHRITT 2: App auf Node.js Server einrichten (192.168.0.163)
|
||||
|
||||
### 2.1 Projekt auf den Server übertragen
|
||||
|
||||
**Option A – via Gitea (empfohlen):**
|
||||
```bash
|
||||
# Auf dem Gitea-Server (192.168.0.221): Neues Repository anlegen
|
||||
# Dann auf dem App-Server:
|
||||
ssh user@192.168.0.163
|
||||
cd /var/www
|
||||
git clone http://192.168.0.221/dein-user/plusfit24.git
|
||||
cd plusfit24
|
||||
```
|
||||
|
||||
**Option B – direkt per SCP:**
|
||||
```bash
|
||||
# Von deinem lokalen Rechner:
|
||||
scp -r /pfad/zu/plusfit24 user@192.168.0.163:/var/www/plusfit24
|
||||
```
|
||||
|
||||
### 2.2 Node.js installieren (falls noch nicht vorhanden)
|
||||
|
||||
```bash
|
||||
# Node.js 20 LTS installieren
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
|
||||
# Version prüfen
|
||||
node --version # sollte v20.x.x zeigen
|
||||
npm --version
|
||||
```
|
||||
|
||||
### 2.3 Abhängigkeiten installieren
|
||||
|
||||
```bash
|
||||
cd /var/www/plusfit24
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2.4 Umgebungsvariablen konfigurieren
|
||||
|
||||
```bash
|
||||
cp .env.example .env # oder direkt:
|
||||
nano .env
|
||||
```
|
||||
|
||||
Fülle die `.env` aus:
|
||||
|
||||
```env
|
||||
DB_HOST=85.215.63.122
|
||||
DB_PORT=3306
|
||||
DB_USER=plusfit24
|
||||
DB_PASSWORD=DEIN_SICHERES_PASSWORT ← gleich wie in Schritt 1
|
||||
DB_NAME=plusfit24
|
||||
|
||||
SESSION_SECRET=ein-sehr-langer-zufaelliger-string-hier-aendern
|
||||
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=DeinSicheresAdminPasswort123!
|
||||
|
||||
PORT=3100
|
||||
```
|
||||
|
||||
🔐 **Tipp:** Session Secret generieren:
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||
```
|
||||
|
||||
### 2.5 Test-Start (einmalig zum Prüfen)
|
||||
|
||||
```bash
|
||||
node app.js
|
||||
# Ausgabe: 🚀 PlusFit24 Server läuft auf Port 3100
|
||||
# Ausgabe: ✅ Admin Account erstellt: admin
|
||||
```
|
||||
|
||||
Mit Ctrl+C stoppen.
|
||||
|
||||
---
|
||||
|
||||
## SCHRITT 3: PM2 installieren (Prozess-Manager)
|
||||
|
||||
```bash
|
||||
# PM2 global installieren
|
||||
sudo npm install -g pm2
|
||||
|
||||
# App mit PM2 starten
|
||||
cd /var/www/plusfit24
|
||||
pm2 start ecosystem.config.js
|
||||
|
||||
# Status prüfen
|
||||
pm2 status
|
||||
|
||||
# Logs ansehen
|
||||
pm2 logs plusfit24
|
||||
|
||||
# PM2 beim Systemstart automatisch starten
|
||||
pm2 startup
|
||||
# Den angezeigten Befehl kopieren und ausführen, dann:
|
||||
pm2 save
|
||||
```
|
||||
|
||||
**Wichtige PM2 Befehle:**
|
||||
```bash
|
||||
pm2 restart plusfit24 # App neu starten
|
||||
pm2 stop plusfit24 # App stoppen
|
||||
pm2 logs plusfit24 # Live-Logs anzeigen
|
||||
pm2 monit # Monitor-Ansicht
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SCHRITT 4: NGINX konfigurieren (192.168.0.157)
|
||||
|
||||
### 4.1 NGINX Konfiguration kopieren
|
||||
|
||||
```bash
|
||||
# Auf dem NGINX Server einloggen
|
||||
ssh user@192.168.0.157
|
||||
|
||||
# Konfigurationsdatei erstellen
|
||||
sudo nano /etc/nginx/sites-available/plusfit24
|
||||
```
|
||||
|
||||
Inhalt aus der Datei `nginx/plusfit24.conf` einfügen.
|
||||
|
||||
⚠️ **SSL Pfade anpassen!** Suche im File nach:
|
||||
```
|
||||
ssl_certificate /etc/ssl/certs/...
|
||||
ssl_certificate_key /etc/ssl/private/...
|
||||
```
|
||||
|
||||
Passe die Pfade auf deine tatsächlichen Zertifikat-Dateien an.
|
||||
|
||||
### 4.2 Site aktivieren
|
||||
|
||||
```bash
|
||||
# Symlink erstellen
|
||||
sudo ln -s /etc/nginx/sites-available/plusfit24 /etc/nginx/sites-enabled/
|
||||
|
||||
# Konfiguration prüfen
|
||||
sudo nginx -t
|
||||
|
||||
# Bei "syntax is ok": NGINX neu laden
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 4.3 Firewall prüfen (falls aktiv)
|
||||
|
||||
```bash
|
||||
# Auf App-Server: Port 3100 für NGINX-Server freigeben
|
||||
sudo ufw allow from 192.168.0.157 to any port 3100
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SCHRITT 5: DNS einrichten
|
||||
|
||||
Bei deinem DNS-Anbieter einen A-Record erstellen:
|
||||
|
||||
```
|
||||
Typ: A
|
||||
Name: plusfit24.software-joksch.com
|
||||
Ziel: [Öffentliche IP deines NGINX-Servers]
|
||||
TTL: 300
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SCHRITT 6: Testen
|
||||
|
||||
1. **Datenbank-Verbindung:** `node -e "require('./config/database').query('SELECT 1').then(()=>console.log('DB OK')).catch(console.error)"`
|
||||
|
||||
2. **App läuft:** `pm2 status` → Status sollte "online" sein
|
||||
|
||||
3. **Webseite:** https://plusfit24.software-joksch.com aufrufen
|
||||
|
||||
4. **Admin-Bereich:** https://plusfit24.software-joksch.com/admin/login
|
||||
- Benutzername: `admin` (oder was du in .env gesetzt hast)
|
||||
- Passwort: aus der `.env` Datei
|
||||
|
||||
---
|
||||
|
||||
## 📋 Admin-Bereich Anleitung
|
||||
|
||||
### Tarife verwalten
|
||||
- **Neuer Tarif:** Button "+ Neuer Tarif" → Formular ausfüllen
|
||||
- **Deaktivieren:** Tarif-Card → "⏸ Deaktivieren" (Tarif bleibt erhalten, erscheint nicht mehr auf der Webseite)
|
||||
- **Preis ändern:** Alten Tarif deaktivieren → Neuen Tarif mit neuem Preis erstellen
|
||||
- **Löschen:** Nur möglich wenn keine Mitglieder den Tarif haben
|
||||
|
||||
### Mitglieder ansehen
|
||||
- Vollständige Liste aller angemeldeten Mitglieder
|
||||
- Suchfunktion nach Name oder E-Mail
|
||||
- ⚠️-Symbol zeigt Minderjährige
|
||||
|
||||
### Passwort ändern
|
||||
- Admin → Einstellungen → Passwort ändern
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
**App startet nicht:**
|
||||
```bash
|
||||
pm2 logs plusfit24 --lines 50
|
||||
# Häufige Ursachen: DB nicht erreichbar, .env fehlt, Port belegt
|
||||
```
|
||||
|
||||
**DB-Verbindungsfehler:**
|
||||
```bash
|
||||
# Verbindung testen
|
||||
mysql -h 85.215.63.122 -u plusfit24 -p plusfit24
|
||||
```
|
||||
|
||||
**NGINX 502 Bad Gateway:**
|
||||
```bash
|
||||
# Prüfen ob App läuft
|
||||
pm2 status
|
||||
# Prüfen ob Port erreichbar
|
||||
curl http://192.168.0.163:3100
|
||||
```
|
||||
|
||||
**Nach Code-Updates:**
|
||||
```bash
|
||||
cd /var/www/plusfit24
|
||||
git pull # falls Gitea genutzt
|
||||
npm install # falls neue Dependencies
|
||||
pm2 restart plusfit24
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Projektstruktur
|
||||
|
||||
```
|
||||
plusfit24/
|
||||
├── app.js # Haupt-Einstiegspunkt
|
||||
├── package.json
|
||||
├── .env # ⚠️ Nicht in Git einchecken!
|
||||
├── ecosystem.config.js # PM2 Konfiguration
|
||||
├── config/
|
||||
│ └── database.js # DB-Verbindung
|
||||
├── middleware/
|
||||
│ └── auth.js # Admin-Authentifizierung
|
||||
├── routes/
|
||||
│ ├── index.js # Öffentliche Routen (Tarife, Anmeldung)
|
||||
│ ├── admin.js # Admin-Routen
|
||||
│ └── api.js # API (E-Mail-Prüfung, Formular-Submit)
|
||||
├── views/
|
||||
│ ├── index.ejs # Tarif-Auswahl
|
||||
│ ├── signup.ejs # Anmelde-Formular (4 Schritte)
|
||||
│ ├── success.ejs # Erfolgsseite
|
||||
│ ├── error.ejs # Fehlerseite
|
||||
│ └── admin/
|
||||
│ ├── login.ejs # Admin Login
|
||||
│ └── dashboard.ejs # Admin Dashboard
|
||||
├── public/
|
||||
│ ├── css/style.css # Alle Styles
|
||||
│ └── pdfs/
|
||||
│ └── Einverstaendniserklaerung.pdf
|
||||
├── database/
|
||||
│ └── schema.sql # Datenbank-Schema
|
||||
└── nginx/
|
||||
└── plusfit24.conf # NGINX Konfiguration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Sicherheitshinweise
|
||||
|
||||
1. `.env` niemals in Git einchecken (ist in `.gitignore`)
|
||||
2. Starke Passwörter für DB und Admin verwenden
|
||||
3. DB-User nur von App-Server-IP Zugriff erlauben
|
||||
4. SESSION_SECRET muss ein langer, zufälliger String sein
|
||||
5. Admin-URL ist `/admin/login` – nicht öffentlich bewerben
|
||||
73
app.js
Normal file
73
app.js
Normal file
@ -0,0 +1,73 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const db = require('./config/database');
|
||||
|
||||
const app = express();
|
||||
|
||||
// View Engine
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
|
||||
// Static Files
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// Body Parser
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Session
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET || 'plusfit24-secret',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: false, // auf true setzen wenn HTTPS direkt (nicht via Proxy)
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 Stunden
|
||||
}
|
||||
}));
|
||||
|
||||
// Routen
|
||||
const indexRouter = require('./routes/index');
|
||||
const adminRouter = require('./routes/admin');
|
||||
const apiRouter = require('./routes/api');
|
||||
|
||||
app.use('/', indexRouter);
|
||||
app.use('/admin', adminRouter);
|
||||
app.use('/api', apiRouter);
|
||||
|
||||
// 404 Handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).render('error', { message: 'Seite nicht gefunden' });
|
||||
});
|
||||
|
||||
// Fehler Handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(err.stack);
|
||||
res.status(500).render('error', { message: 'Ein Fehler ist aufgetreten' });
|
||||
});
|
||||
|
||||
// Admin Account beim Start erstellen falls keiner existiert
|
||||
async function initAdmin() {
|
||||
try {
|
||||
const [rows] = await db.query('SELECT COUNT(*) as count FROM admins');
|
||||
if (rows[0].count === 0) {
|
||||
const hash = await bcrypt.hash(process.env.ADMIN_PASSWORD || 'Admin1234!', 12);
|
||||
await db.query(
|
||||
'INSERT INTO admins (username, password_hash) VALUES (?, ?)',
|
||||
[process.env.ADMIN_USER || 'admin', hash]
|
||||
);
|
||||
console.log('✅ Admin Account erstellt:', process.env.ADMIN_USER || 'admin');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ Fehler beim Erstellen des Admin Accounts:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 3100;
|
||||
app.listen(PORT, async () => {
|
||||
console.log(`🚀 PlusFit24 Server läuft auf Port ${PORT}`);
|
||||
await initAdmin();
|
||||
});
|
||||
16
config/database.js
Normal file
16
config/database.js
Normal file
@ -0,0 +1,16 @@
|
||||
require('dotenv').config();
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT || 3306,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
charset: 'utf8mb4'
|
||||
});
|
||||
|
||||
module.exports = pool;
|
||||
85
database/schema.sql
Normal file
85
database/schema.sql
Normal file
@ -0,0 +1,85 @@
|
||||
-- ============================================
|
||||
-- PlusFit24 Datenbank Schema
|
||||
-- Auf dem MariaDB Server ausführen
|
||||
-- ============================================
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS plusfit24
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE plusfit24;
|
||||
|
||||
-- Kategorien Tabelle
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO categories (name) VALUES
|
||||
('Single'),
|
||||
('Schüler / Studenten / Vereine'),
|
||||
('Family'),
|
||||
('Sonstige');
|
||||
|
||||
-- Tarife Tabelle
|
||||
CREATE TABLE IF NOT EXISTS tariffs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
category_id INT,
|
||||
duration_months INT NOT NULL,
|
||||
price_monthly DECIMAL(10,2) NOT NULL,
|
||||
start_package_price DECIMAL(10,2) DEFAULT 35.00,
|
||||
description TEXT,
|
||||
active TINYINT(1) DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
INSERT INTO tariffs (name, category_id, duration_months, price_monthly, start_package_price, description) VALUES
|
||||
('Single 12 Monate', 1, 12, 29.95, 35.00, 'Einzelmitgliedschaft für 12 Monate'),
|
||||
('Single 24 Monate', 1, 24, 19.95, 35.00, 'Einzelmitgliedschaft für 24 Monate'),
|
||||
('Schüler - Studenten - Vereine 12 Monate', 2, 12, 28.95, 35.00, 'Ermäßigter Tarif'),
|
||||
('Family 24 Monate', 3, 24, 49.95, 35.00, 'Familientarif für 24 Monate');
|
||||
|
||||
-- Mitgliedschaften Tabelle
|
||||
CREATE TABLE IF NOT EXISTS memberships (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tariff_id INT NOT NULL,
|
||||
salutation VARCHAR(20) NOT NULL,
|
||||
title VARCHAR(50),
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
birth_date DATE NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
phone VARCHAR(50),
|
||||
street VARCHAR(200) NOT NULL,
|
||||
address_addition VARCHAR(200),
|
||||
zip VARCHAR(10) NOT NULL,
|
||||
city VARCHAR(100) NOT NULL,
|
||||
bank_name VARCHAR(200),
|
||||
account_holder VARCHAR(200),
|
||||
iban VARCHAR(34),
|
||||
sepa_accepted TINYINT(1) DEFAULT 0,
|
||||
agb_accepted TINYINT(1) DEFAULT 0,
|
||||
datenschutz_accepted TINYINT(1) DEFAULT 0,
|
||||
data_correct TINYINT(1) DEFAULT 0,
|
||||
guardian_consent TINYINT(1) DEFAULT 0,
|
||||
is_minor TINYINT(1) DEFAULT 0,
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tariff_id) REFERENCES tariffs(id)
|
||||
);
|
||||
|
||||
-- Admin Tabelle
|
||||
CREATE TABLE IF NOT EXISTS admins (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_memberships_email ON memberships(email);
|
||||
CREATE INDEX idx_memberships_status ON memberships(status);
|
||||
CREATE INDEX idx_tariffs_active ON tariffs(active);
|
||||
24
ecosystem.config.js
Normal file
24
ecosystem.config.js
Normal file
@ -0,0 +1,24 @@
|
||||
// PM2 Ecosystem Konfiguration
|
||||
// Starten mit: pm2 start ecosystem.config.js
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'plusfit24',
|
||||
script: 'app.js',
|
||||
cwd: '/var/www/plusfit24',
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '300M',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 3100
|
||||
},
|
||||
error_file: '/var/log/pm2/plusfit24-error.log',
|
||||
out_file: '/var/log/pm2/plusfit24-out.log',
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss',
|
||||
time: true
|
||||
}
|
||||
]
|
||||
};
|
||||
8
middleware/auth.js
Normal file
8
middleware/auth.js
Normal file
@ -0,0 +1,8 @@
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.session && req.session.adminId) {
|
||||
return next();
|
||||
}
|
||||
res.redirect('/admin/login');
|
||||
}
|
||||
|
||||
module.exports = { requireAdmin };
|
||||
28
nginx/plusfit24.conf
Normal file
28
nginx/plusfit24.conf
Normal file
@ -0,0 +1,28 @@
|
||||
# =====================================================
|
||||
# NGINX Konfiguration für plusfit24.software-joksch.com
|
||||
# Datei speichern unter: /etc/nginx/sites-available/plusfit24
|
||||
# Symlink: ln -s /etc/nginx/sites-available/plusfit24 /etc/nginx/sites-enabled/
|
||||
# =====================================================
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name plusfit24.software-joksch.com;
|
||||
|
||||
# SSL – gleiche Zertifikate wie deine anderen vHosts
|
||||
ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/software-joksch.com.key;
|
||||
|
||||
client_max_body_size 10M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://192.168.0.163:3100;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_redirect off;
|
||||
proxy_read_timeout 3600;
|
||||
}
|
||||
}
|
||||
22
package.json
Normal file
22
package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "plusfit24-signup",
|
||||
"version": "1.0.0",
|
||||
"description": "PlusFit24 Mitgliedschaft Anmeldesystem",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"start": "node app.js",
|
||||
"dev": "nodemon app.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"ejs": "^3.1.9",
|
||||
"mysql2": "^3.6.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dotenv": "^16.3.1",
|
||||
"dns": "^0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
819
public/css/style.css
Normal file
819
public/css/style.css
Normal file
@ -0,0 +1,819 @@
|
||||
/* =========================================
|
||||
PlusFit24 – Stylesheet
|
||||
========================================= */
|
||||
|
||||
:root {
|
||||
--primary: #2d2dcc;
|
||||
--primary-dark: #1a1a9e;
|
||||
--accent: #5555ff;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #6b7280;
|
||||
--border: #e2e4ed;
|
||||
--bg: #f8f9ff;
|
||||
--white: #ffffff;
|
||||
--success: #16a34a;
|
||||
--error: #dc2626;
|
||||
--warning: #d97706;
|
||||
--radius: 12px;
|
||||
--shadow: 0 2px 16px rgba(45,45,204,0.08);
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--white);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---- HEADER ---- */
|
||||
.site-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 16px 0;
|
||||
background: var(--white);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.logo {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.logo span { color: var(--primary); }
|
||||
|
||||
/* ---- PROGRESS NAV ---- */
|
||||
.progress-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.step {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
padding: 4px 0;
|
||||
position: relative;
|
||||
cursor: default;
|
||||
}
|
||||
.step.active {
|
||||
color: var(--primary);
|
||||
border-bottom: 2px solid var(--primary);
|
||||
}
|
||||
.step.done { color: var(--success); }
|
||||
.step-dot {
|
||||
width: 20px;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* ---- TARIF SECTION ---- */
|
||||
.tarif-section {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px;
|
||||
flex: 1;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
color: var(--text);
|
||||
}
|
||||
.page-title.italic { font-style: italic; font-weight: 400; font-size: 1.5rem; }
|
||||
|
||||
.tarif-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
.tarif-card {
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
}
|
||||
.tarif-card:hover {
|
||||
box-shadow: var(--shadow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.tarif-card-inner { padding: 28px 24px; }
|
||||
.tarif-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tarif-icon { font-size: 1rem; }
|
||||
.tarif-name {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.tarif-feature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.tarif-price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.price-amount {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 800;
|
||||
color: var(--primary);
|
||||
}
|
||||
.price-period { font-size: 0.9rem; color: var(--text-muted); }
|
||||
.no-tarifs {
|
||||
grid-column: 1/-1;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
/* ---- BUTTONS ---- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 24px;
|
||||
border-radius: 10px;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--white);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.btn-primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn-outline:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.btn-full { width: 100%; }
|
||||
.btn-success { background: var(--success); color: white; border-color: var(--success); }
|
||||
.btn-warning { background: var(--warning); color: white; border-color: var(--warning); }
|
||||
.btn-danger { background: var(--error); color: white; border-color: var(--error); }
|
||||
.btn-sm { padding: 6px 12px; font-size: 0.82rem; border-radius: 7px; }
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-submit:hover { background: var(--primary-dark); }
|
||||
.btn-submit:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
/* ---- SIGNUP FORM ---- */
|
||||
.signup-main { flex: 1; }
|
||||
.signup-container {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 60px;
|
||||
}
|
||||
.form-step { display: none; }
|
||||
.form-step.active { display: block; }
|
||||
|
||||
.step-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.step-title.italic { font-style: italic; font-weight: 400; font-size: 1.3rem; }
|
||||
.step-subtitle {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 32px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Anrede Buttons */
|
||||
.salutation-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sal-btn {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
padding: 12px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.sal-btn:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.sal-btn.active { border-color: var(--primary); background: var(--primary); color: white; }
|
||||
|
||||
/* Form Groups */
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
.req { color: var(--error); }
|
||||
.input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0 14px;
|
||||
background: white;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.input-wrap:focus-within { border-color: var(--primary); }
|
||||
.input-icon { font-size: 0.9rem; margin-right: 10px; opacity: 0.5; flex-shrink: 0; }
|
||||
.input-wrap input,
|
||||
.input-wrap textarea,
|
||||
.input-wrap select {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 13px 0;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
}
|
||||
.input-wrap input::placeholder { color: #9ca3af; }
|
||||
.email-status { font-size: 1rem; margin-left: 8px; }
|
||||
.email-message { font-size: 0.8rem; margin-top: 4px; padding-left: 4px; }
|
||||
.email-message.error { color: var(--error); }
|
||||
|
||||
/* Checkboxes */
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 14px;
|
||||
line-height: 1.5;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.checkbox-label input[type="checkbox"] { display: none; }
|
||||
.checkbox-custom {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
min-width: 22px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.checkbox-label input:checked + .checkbox-custom {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.checkbox-label input:checked + .checkbox-custom::after {
|
||||
content: '✓';
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* SEPA Mandate */
|
||||
.sepa-mandate {
|
||||
background: #f0f0ff;
|
||||
border: 1.5px solid var(--primary);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.form-label-section {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Step Buttons */
|
||||
.step-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
.step-buttons .btn { min-width: 140px; }
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.alert-success { background: #dcfce7; color: var(--success); border: 1px solid #bbf7d0; }
|
||||
.alert-error { background: #fee2e2; color: var(--error); border: 1px solid #fecaca; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* Warning Box */
|
||||
.minor-warning { margin-top: 16px; }
|
||||
.warning-box {
|
||||
background: #fffbeb;
|
||||
border: 1.5px solid var(--warning);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.warning-box strong { display: block; margin-bottom: 6px; }
|
||||
.minor-info-box {
|
||||
background: #fffbeb;
|
||||
border: 1.5px solid var(--warning);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.minor-info-box strong { display: block; margin-bottom: 8px; }
|
||||
.minor-info-box p { margin-bottom: 10px; }
|
||||
|
||||
/* ---- ABSCHLUSS ---- */
|
||||
.abschluss-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
@media (max-width: 640px) { .abschluss-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.tarif-summary-card {
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
.summary-badge {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tarif-summary-card h3 { font-size: 1.3rem; font-weight: 700; margin-bottom: 16px; }
|
||||
.summary-feature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.summary-price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.price-big { font-size: 2rem; font-weight: 800; color: var(--primary); }
|
||||
|
||||
.final-checks { padding: 4px 0; }
|
||||
.final-checks h3 { font-size: 1.2rem; font-weight: 700; margin-bottom: 16px; }
|
||||
.doc-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.doc-btn {
|
||||
padding: 8px 14px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
transition: all 0.2s;
|
||||
display: inline-block;
|
||||
}
|
||||
.doc-btn:hover { border-color: var(--primary); color: var(--primary); }
|
||||
|
||||
.submit-section { text-align: center; padding: 20px 0; }
|
||||
.submit-notice { font-size: 0.9rem; margin-bottom: 16px; }
|
||||
.ssl-notice { font-size: 0.8rem; color: var(--text-muted); margin-top: 12px; }
|
||||
|
||||
/* ---- SUCCESS ---- */
|
||||
.success-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
.success-card {
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
padding: 48px 32px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 20px;
|
||||
}
|
||||
.success-icon { font-size: 3rem; margin-bottom: 16px; }
|
||||
.success-card h1 { font-size: 1.8rem; font-weight: 800; margin-bottom: 12px; }
|
||||
.success-card p { color: var(--text-muted); margin-bottom: 8px; }
|
||||
.success-sub { font-size: 0.9rem; margin-bottom: 24px !important; }
|
||||
|
||||
/* ---- FOOTER ---- */
|
||||
.site-footer {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: auto;
|
||||
}
|
||||
.site-footer a { color: var(--primary); text-decoration: none; }
|
||||
|
||||
/* ================================================
|
||||
ADMIN STYLES
|
||||
================================================ */
|
||||
.admin-body { background: var(--bg); }
|
||||
|
||||
/* Admin Login */
|
||||
.admin-login-wrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.admin-login-card {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 48px 40px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1.5px solid var(--border);
|
||||
}
|
||||
.admin-logo { margin-bottom: 24px; }
|
||||
.admin-login-card h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 24px; }
|
||||
|
||||
/* Admin Layout */
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.admin-sidebar {
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
background: var(--text);
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.admin-sidebar .logo { padding: 0 24px 32px; color: white; }
|
||||
.admin-sidebar .logo span { color: #7c7cff; }
|
||||
.admin-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
.nav-link {
|
||||
display: block;
|
||||
padding: 12px 24px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.nav-link:hover, .nav-link.active {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
.sidebar-footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid rgba(255,255,255,0.15);
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255,255,255,0.6);
|
||||
}
|
||||
.logout-link {
|
||||
display: block;
|
||||
color: #ff6b6b;
|
||||
text-decoration: none;
|
||||
margin-top: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Admin Main */
|
||||
.admin-main {
|
||||
flex: 1;
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.admin-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.stat-card {
|
||||
background: white;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-number { font-size: 2rem; font-weight: 800; color: var(--primary); }
|
||||
.stat-label { font-size: 0.8rem; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
/* Admin Section */
|
||||
.admin-section { margin-bottom: 40px; }
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section-header h2 { font-size: 1.4rem; font-weight: 700; }
|
||||
|
||||
/* Tariff Admin Cards */
|
||||
.tariff-admin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.tariff-admin-card {
|
||||
background: white;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
}
|
||||
.tariff-admin-card.inactive { opacity: 0.65; }
|
||||
.tariff-admin-header { margin-bottom: 10px; }
|
||||
.tariff-status-badge {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.tariff-status-badge.active { background: #dcfce7; color: var(--success); }
|
||||
.tariff-status-badge.inactive { background: #fee2e2; color: var(--error); }
|
||||
.tariff-admin-card h3 { font-size: 1.05rem; font-weight: 700; margin-bottom: 12px; }
|
||||
.tariff-admin-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tariff-admin-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.table-wrap { overflow-x: auto; background: white; border-radius: 14px; border: 1.5px solid var(--border); }
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.admin-table th {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
background: var(--bg);
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1.5px solid var(--border);
|
||||
}
|
||||
.admin-table td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.admin-table tr:last-child td { border-bottom: none; }
|
||||
.admin-table tr:hover td { background: var(--bg); }
|
||||
.iban-cell { font-family: monospace; font-size: 0.8rem; }
|
||||
.no-data { text-align: center; color: var(--text-muted); padding: 32px !important; }
|
||||
|
||||
/* Search */
|
||||
.search-input {
|
||||
padding: 8px 14px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
width: 260px;
|
||||
}
|
||||
.search-input:focus { border-color: var(--primary); }
|
||||
|
||||
/* Settings */
|
||||
.settings-card {
|
||||
background: white;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 28px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
/* Form Controls (Admin) */
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 0.92rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.form-control:focus { border-color: var(--primary); }
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 16px;
|
||||
}
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1.5px solid var(--border);
|
||||
}
|
||||
.modal-header h3 { font-size: 1.1rem; font-weight: 700; }
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.modal-close:hover { background: var(--bg); }
|
||||
.modal form { padding: 24px; }
|
||||
.modal .form-group { margin-bottom: 16px; }
|
||||
.modal .form-group label { display: block; font-size: 0.88rem; font-weight: 600; margin-bottom: 6px; }
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1.5px solid var(--border);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.admin-layout { flex-direction: column; }
|
||||
.admin-sidebar { width: 100%; height: auto; position: relative; }
|
||||
.admin-nav { flex-direction: row; overflow-x: auto; }
|
||||
.page-title { font-size: 1.8rem; }
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ---- KATEGORIEN ---- */
|
||||
.category-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.category-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: white;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
.category-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.category-name {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.category-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg);
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.category-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.category-pill {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: #ede9fe;
|
||||
color: #5b21b6;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.no-data-card {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
background: white;
|
||||
border: 1.5px dashed var(--border);
|
||||
border-radius: 14px;
|
||||
}
|
||||
.info-box {
|
||||
background: #eff6ff;
|
||||
border: 1.5px solid #bfdbfe;
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
font-size: 0.88rem;
|
||||
color: #1e40af;
|
||||
}
|
||||
BIN
public/pdfs/Einverstaendniserklaerung.pdf
Normal file
BIN
public/pdfs/Einverstaendniserklaerung.pdf
Normal file
Binary file not shown.
170
routes/admin.js
Normal file
170
routes/admin.js
Normal file
@ -0,0 +1,170 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const db = require('../config/database');
|
||||
const { requireAdmin } = require('../middleware/auth');
|
||||
|
||||
// Login
|
||||
router.get('/login', (req, res) => {
|
||||
if (req.session.adminId) return res.redirect('/admin');
|
||||
res.render('admin/login', { error: null });
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
try {
|
||||
const [admins] = await db.query('SELECT * FROM admins WHERE username = ?', [username]);
|
||||
if (admins.length === 0) return res.render('admin/login', { error: 'Ungültige Anmeldedaten.' });
|
||||
const valid = await bcrypt.compare(password, admins[0].password_hash);
|
||||
if (!valid) return res.render('admin/login', { error: 'Ungültige Anmeldedaten.' });
|
||||
req.session.adminId = admins[0].id;
|
||||
req.session.adminUser = admins[0].username;
|
||||
res.redirect('/admin');
|
||||
} catch (err) {
|
||||
res.render('admin/login', { error: 'Serverfehler.' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/logout', (req, res) => {
|
||||
req.session.destroy();
|
||||
res.redirect('/admin/login');
|
||||
});
|
||||
|
||||
// Dashboard
|
||||
router.get('/', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const [tariffs] = await db.query(`
|
||||
SELECT t.*, c.name as category_name
|
||||
FROM tariffs t LEFT JOIN categories c ON t.category_id = c.id
|
||||
ORDER BY t.active DESC, t.created_at DESC
|
||||
`);
|
||||
const [categories] = await db.query('SELECT * FROM categories ORDER BY name ASC');
|
||||
const [memberships] = await db.query(`
|
||||
SELECT m.*, t.name as tariff_name, t.price_monthly
|
||||
FROM memberships m LEFT JOIN tariffs t ON m.tariff_id = t.id
|
||||
ORDER BY m.created_at DESC
|
||||
`);
|
||||
const [stats] = await db.query(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active_count,
|
||||
SUM(CASE WHEN is_minor = 1 THEN 1 ELSE 0 END) as minors,
|
||||
SUM(CASE WHEN created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN 1 ELSE 0 END) as last_30_days
|
||||
FROM memberships
|
||||
`);
|
||||
res.render('admin/dashboard', {
|
||||
tariffs, categories, memberships, stats: stats[0],
|
||||
admin: req.session.adminUser,
|
||||
success: req.query.success || null,
|
||||
error: req.query.error || null
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.render('admin/dashboard', {
|
||||
tariffs: [], categories: [], memberships: [], stats: {},
|
||||
admin: req.session.adminUser,
|
||||
success: null, error: 'Datenbankfehler: ' + err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ===== KATEGORIEN =====
|
||||
router.post('/categories', requireAdmin, async (req, res) => {
|
||||
const { name } = req.body;
|
||||
if (!name || !name.trim()) return res.redirect('/admin?error=Kategoriename+fehlt');
|
||||
try {
|
||||
await db.query('INSERT INTO categories (name) VALUES (?)', [name.trim()]);
|
||||
res.redirect('/admin?success=Kategorie+erstellt#kategorien');
|
||||
} catch (err) {
|
||||
res.redirect('/admin?error=Fehler+beim+Erstellen');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/categories/:id/update', requireAdmin, async (req, res) => {
|
||||
const { name } = req.body;
|
||||
if (!name || !name.trim()) return res.redirect('/admin?error=Kategoriename+fehlt');
|
||||
try {
|
||||
await db.query('UPDATE categories SET name = ? WHERE id = ?', [name.trim(), req.params.id]);
|
||||
res.redirect('/admin?success=Kategorie+aktualisiert#kategorien');
|
||||
} catch (err) {
|
||||
res.redirect('/admin?error=Fehler+beim+Aktualisieren');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/categories/:id/delete', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const [used] = await db.query('SELECT COUNT(*) as c FROM tariffs WHERE category_id = ?', [req.params.id]);
|
||||
if (used[0].c > 0) {
|
||||
return res.redirect('/admin?error=Kategorie+wird+von+' + used[0].c + '+Tarifen+verwendet+–+bitte+erst+Tarife+umziehen#kategorien');
|
||||
}
|
||||
await db.query('DELETE FROM categories WHERE id = ?', [req.params.id]);
|
||||
res.redirect('/admin?success=Kategorie+gelöscht#kategorien');
|
||||
} catch (err) {
|
||||
res.redirect('/admin?error=Fehler+beim+Löschen');
|
||||
}
|
||||
});
|
||||
|
||||
// ===== TARIFE =====
|
||||
router.post('/tariffs', requireAdmin, async (req, res) => {
|
||||
const { name, category_id, duration_months, price_monthly, start_package_price, description } = req.body;
|
||||
try {
|
||||
await db.query(
|
||||
'INSERT INTO tariffs (name, category_id, duration_months, price_monthly, start_package_price, description) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[name, category_id || null, duration_months, price_monthly, start_package_price || 35.00, description || '']
|
||||
);
|
||||
res.redirect('/admin?success=Tarif+erstellt');
|
||||
} catch (err) {
|
||||
res.redirect('/admin?error=Fehler+beim+Erstellen+des+Tarifs');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/tariffs/:id/toggle', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await db.query('UPDATE tariffs SET active = NOT active WHERE id = ?', [req.params.id]);
|
||||
res.redirect('/admin?success=Tarif+aktualisiert');
|
||||
} catch (err) {
|
||||
res.redirect('/admin?error=Fehler+beim+Aktualisieren');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/tariffs/:id/update', requireAdmin, async (req, res) => {
|
||||
const { name, category_id, duration_months, price_monthly, start_package_price, description } = req.body;
|
||||
try {
|
||||
await db.query(
|
||||
'UPDATE tariffs SET name=?, category_id=?, duration_months=?, price_monthly=?, start_package_price=?, description=? WHERE id=?',
|
||||
[name, category_id || null, duration_months, price_monthly, start_package_price, description, req.params.id]
|
||||
);
|
||||
res.redirect('/admin?success=Tarif+aktualisiert');
|
||||
} catch (err) {
|
||||
res.redirect('/admin?error=Fehler+beim+Aktualisieren');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/tariffs/:id/delete', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const [members] = await db.query('SELECT COUNT(*) as c FROM memberships WHERE tariff_id = ?', [req.params.id]);
|
||||
if (members[0].c > 0) return res.redirect('/admin?error=Tarif+hat+Mitglieder+–+bitte+deaktivieren');
|
||||
await db.query('DELETE FROM tariffs WHERE id = ?', [req.params.id]);
|
||||
res.redirect('/admin?success=Tarif+gelöscht');
|
||||
} catch (err) {
|
||||
res.redirect('/admin?error=Fehler+beim+Löschen');
|
||||
}
|
||||
});
|
||||
|
||||
// Passwort ändern
|
||||
router.post('/change-password', requireAdmin, async (req, res) => {
|
||||
const { current_password, new_password, confirm_password } = req.body;
|
||||
if (new_password !== confirm_password) return res.redirect('/admin?error=Passwörter+stimmen+nicht+überein');
|
||||
try {
|
||||
const [admins] = await db.query('SELECT * FROM admins WHERE id = ?', [req.session.adminId]);
|
||||
const valid = await bcrypt.compare(current_password, admins[0].password_hash);
|
||||
if (!valid) return res.redirect('/admin?error=Aktuelles+Passwort+falsch');
|
||||
const hash = await bcrypt.hash(new_password, 12);
|
||||
await db.query('UPDATE admins SET password_hash = ? WHERE id = ?', [hash, req.session.adminId]);
|
||||
res.redirect('/admin?success=Passwort+geändert');
|
||||
} catch (err) {
|
||||
res.redirect('/admin?error=Fehler+beim+Ändern+des+Passworts');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
105
routes/api.js
Normal file
105
routes/api.js
Normal file
@ -0,0 +1,105 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const dns = require('dns').promises;
|
||||
const db = require('../config/database');
|
||||
|
||||
// Email Validierung via DNS MX-Record Check
|
||||
async function verifyEmailDomain(email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) return { valid: false, reason: 'Ungültiges E-Mail-Format' };
|
||||
|
||||
const domain = email.split('@')[1];
|
||||
try {
|
||||
const records = await dns.resolveMx(domain);
|
||||
if (records && records.length > 0) {
|
||||
return { valid: true };
|
||||
}
|
||||
return { valid: false, reason: 'Domain hat keine E-Mail-Server (MX-Records fehlen)' };
|
||||
} catch (err) {
|
||||
return { valid: false, reason: 'E-Mail-Domain konnte nicht verifiziert werden' };
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/verify-email
|
||||
router.post('/verify-email', async (req, res) => {
|
||||
const { email } = req.body;
|
||||
if (!email) return res.json({ valid: false, reason: 'Keine E-Mail angegeben' });
|
||||
|
||||
const result = await verifyEmailDomain(email);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// POST /api/submit-membership
|
||||
router.post('/submit-membership', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
tariff_id, salutation, title, first_name, last_name, birth_date,
|
||||
email, phone, street, address_addition, zip, city,
|
||||
bank_name, account_holder, iban,
|
||||
sepa_accepted, agb_accepted, datenschutz_accepted, data_correct,
|
||||
guardian_consent
|
||||
} = req.body;
|
||||
|
||||
// E-Mail validieren
|
||||
const emailCheck = await verifyEmailDomain(email);
|
||||
if (!emailCheck.valid) {
|
||||
return res.json({ success: false, error: 'E-Mail-Adresse ist nicht erreichbar: ' + emailCheck.reason });
|
||||
}
|
||||
|
||||
// Pflichtfelder prüfen
|
||||
if (!tariff_id || !first_name || !last_name || !birth_date || !email || !street || !zip || !city) {
|
||||
return res.json({ success: false, error: 'Bitte alle Pflichtfelder ausfüllen.' });
|
||||
}
|
||||
|
||||
if (!agb_accepted || !datenschutz_accepted || !data_correct) {
|
||||
return res.json({ success: false, error: 'Bitte alle Einverständniserklärungen bestätigen.' });
|
||||
}
|
||||
|
||||
// Alter berechnen
|
||||
const birthDateObj = new Date(birth_date);
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - birthDateObj.getFullYear();
|
||||
const m = today.getMonth() - birthDateObj.getMonth();
|
||||
if (m < 0 || (m === 0 && today.getDate() < birthDateObj.getDate())) age--;
|
||||
|
||||
const is_minor = age < 18 ? 1 : 0;
|
||||
|
||||
if (is_minor && !guardian_consent) {
|
||||
return res.json({ success: false, error: 'Bei Minderjährigen ist die Einverständniserklärung der Erziehungsberechtigten erforderlich.' });
|
||||
}
|
||||
|
||||
if (age < 14) {
|
||||
return res.json({ success: false, error: 'Das Mindestalter für eine Mitgliedschaft beträgt 14 Jahre.' });
|
||||
}
|
||||
|
||||
// Tarif prüfen
|
||||
const [tariffs] = await db.query('SELECT * FROM tariffs WHERE id = ? AND active = 1', [tariff_id]);
|
||||
if (tariffs.length === 0) {
|
||||
return res.json({ success: false, error: 'Ungültiger oder inaktiver Tarif.' });
|
||||
}
|
||||
|
||||
// In DB speichern
|
||||
await db.query(`
|
||||
INSERT INTO memberships
|
||||
(tariff_id, salutation, title, first_name, last_name, birth_date, email, phone,
|
||||
street, address_addition, zip, city, bank_name, account_holder, iban,
|
||||
sepa_accepted, agb_accepted, datenschutz_accepted, data_correct, guardian_consent, is_minor)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
tariff_id, salutation, title || '', first_name, last_name, birth_date,
|
||||
email, phone || '', street, address_addition || '', zip, city,
|
||||
bank_name || '', account_holder || '', iban || '',
|
||||
sepa_accepted ? 1 : 0, agb_accepted ? 1 : 0,
|
||||
datenschutz_accepted ? 1 : 0, data_correct ? 1 : 0,
|
||||
guardian_consent ? 1 : 0, is_minor
|
||||
]);
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
} catch (err) {
|
||||
console.error('Submit error:', err);
|
||||
res.json({ success: false, error: 'Serverfehler. Bitte versuche es später erneut.' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
40
routes/index.js
Normal file
40
routes/index.js
Normal file
@ -0,0 +1,40 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../config/database');
|
||||
|
||||
// Startseite - Tarife anzeigen
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const [tariffs] = await db.query(
|
||||
'SELECT * FROM tariffs WHERE active = 1 ORDER BY price_monthly ASC'
|
||||
);
|
||||
res.render('index', { tariffs, error: null });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.render('index', { tariffs: [], error: 'Tarife konnten nicht geladen werden.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Anmelde-Formular für gewählten Tarif
|
||||
router.get('/anmelden/:tariffId', async (req, res) => {
|
||||
try {
|
||||
const [tariffs] = await db.query(
|
||||
'SELECT * FROM tariffs WHERE id = ? AND active = 1',
|
||||
[req.params.tariffId]
|
||||
);
|
||||
if (tariffs.length === 0) {
|
||||
return res.redirect('/');
|
||||
}
|
||||
res.render('signup', { tariff: tariffs[0] });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.redirect('/');
|
||||
}
|
||||
});
|
||||
|
||||
// Erfolgsseite
|
||||
router.get('/erfolg', (req, res) => {
|
||||
res.render('success');
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
385
views/admin/dashboard.ejs
Normal file
385
views/admin/dashboard.ejs
Normal file
@ -0,0 +1,385 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PlusFit24 – Admin Dashboard</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body class="admin-body">
|
||||
<div class="admin-layout">
|
||||
|
||||
<aside class="admin-sidebar">
|
||||
<div class="logo admin-logo">Plusfit<span>24</span></div>
|
||||
<nav class="admin-nav">
|
||||
<a href="#" class="nav-link active" onclick="showSection('tarife', this)">📋 Tarife</a>
|
||||
<a href="#" class="nav-link" onclick="showSection('kategorien', this)">🏷️ Kategorien</a>
|
||||
<a href="#" class="nav-link" onclick="showSection('mitglieder', this)">👥 Mitglieder</a>
|
||||
<a href="#" class="nav-link" onclick="showSection('einstellungen', this)">⚙️ Einstellungen</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<span>👤 <%= admin %></span>
|
||||
<a href="/admin/logout" class="logout-link">Abmelden</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="admin-main">
|
||||
<div class="admin-header">
|
||||
<h1 id="sectionTitle">Tarife</h1>
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number"><%= stats.total || 0 %></div>
|
||||
<div class="stat-label">Gesamt Mitglieder</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number"><%= stats.active_count || 0 %></div>
|
||||
<div class="stat-label">Aktive Mitglieder</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number"><%= stats.last_30_days || 0 %></div>
|
||||
<div class="stat-label">Letzte 30 Tage</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number"><%= stats.minors || 0 %></div>
|
||||
<div class="stat-label">Minderjährige</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (success) { %><div class="alert alert-success"><%= success %></div><% } %>
|
||||
<% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
|
||||
|
||||
<!-- ===== TARIFE ===== -->
|
||||
<section id="section-tarife" class="admin-section">
|
||||
<div class="section-header">
|
||||
<h2>Tarife verwalten</h2>
|
||||
<button class="btn btn-primary" onclick="toggleModal('createTariffModal')">+ Neuer Tarif</button>
|
||||
</div>
|
||||
<div class="tariff-admin-grid">
|
||||
<% tariffs.forEach(tariff => { %>
|
||||
<div class="tariff-admin-card <%= tariff.active ? '' : 'inactive' %>">
|
||||
<div class="tariff-admin-header">
|
||||
<span class="tariff-status-badge <%= tariff.active ? 'active' : 'inactive' %>">
|
||||
<%= tariff.active ? '✅ Aktiv' : '❌ Inaktiv' %>
|
||||
</span>
|
||||
<% if (tariff.category_name) { %>
|
||||
<span class="category-pill"><%= tariff.category_name %></span>
|
||||
<% } %>
|
||||
</div>
|
||||
<h3><%= tariff.name %></h3>
|
||||
<div class="tariff-admin-details">
|
||||
<span>💰 <%= Number(tariff.price_monthly).toFixed(2) %>€/Monat</span>
|
||||
<span>📅 <%= tariff.duration_months %> Monate</span>
|
||||
<span>📦 Startpaket: <%= Number(tariff.start_package_price).toFixed(2) %>€</span>
|
||||
</div>
|
||||
<div class="tariff-admin-actions">
|
||||
<button class="btn btn-sm btn-outline" onclick="editTariff(<%= JSON.stringify(tariff) %>)">✏️ Bearbeiten</button>
|
||||
<form method="POST" action="/admin/tariffs/<%= tariff.id %>/toggle" style="display:inline">
|
||||
<button type="submit" class="btn btn-sm <%= tariff.active ? 'btn-warning' : 'btn-success' %>">
|
||||
<%= tariff.active ? '⏸ Deaktivieren' : '▶ Aktivieren' %>
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/admin/tariffs/<%= tariff.id %>/delete" style="display:inline"
|
||||
onsubmit="return confirm('Tarif wirklich löschen?')">
|
||||
<button type="submit" class="btn btn-sm btn-danger">🗑 Löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
<% if (tariffs.length === 0) { %>
|
||||
<div class="no-data-card">Noch keine Tarife angelegt.</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== KATEGORIEN ===== -->
|
||||
<section id="section-kategorien" class="admin-section hidden">
|
||||
<div class="section-header">
|
||||
<h2>Kategorien verwalten</h2>
|
||||
<button class="btn btn-primary" onclick="toggleModal('createCategoryModal')">+ Neue Kategorie</button>
|
||||
</div>
|
||||
|
||||
<div class="category-list">
|
||||
<% if (categories.length === 0) { %>
|
||||
<div class="no-data-card">Noch keine Kategorien angelegt.</div>
|
||||
<% } %>
|
||||
<% categories.forEach(cat => { %>
|
||||
<div class="category-row">
|
||||
<div class="category-info">
|
||||
<span class="category-name">🏷️ <%= cat.name %></span>
|
||||
<span class="category-meta">
|
||||
<%= tariffs.filter(t => t.category_id === cat.id).length %> Tarif(e)
|
||||
</span>
|
||||
</div>
|
||||
<div class="category-actions">
|
||||
<button class="btn btn-sm btn-outline"
|
||||
onclick="editCategory(<%= cat.id %>, '<%= cat.name.replace(/'/g, `\\'`) %>')">
|
||||
✏️ Umbenennen
|
||||
</button>
|
||||
<form method="POST" action="/admin/categories/<%= cat.id %>/delete" style="display:inline"
|
||||
onsubmit="return confirm('Kategorie \'<%= cat.name %>\' wirklich löschen?')">
|
||||
<button type="submit" class="btn btn-sm btn-danger">🗑 Löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>ℹ️ Hinweis:</strong> Kategorien können nur gelöscht werden wenn keine Tarife zugeordnet sind.
|
||||
Weise die Tarife zuerst einer anderen Kategorie zu.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== MITGLIEDER ===== -->
|
||||
<section id="section-mitglieder" class="admin-section hidden">
|
||||
<div class="section-header">
|
||||
<h2>Mitglieder</h2>
|
||||
<input type="text" id="memberSearch" placeholder="Suche nach Name, E-Mail..."
|
||||
class="search-input" onkeyup="filterMembers()">
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="admin-table" id="memberTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Tarif</th>
|
||||
<th>Geburtsdatum</th>
|
||||
<th>Min.</th>
|
||||
<th>IBAN</th>
|
||||
<th>Angemeldet am</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% memberships.forEach(m => { %>
|
||||
<tr class="member-row">
|
||||
<td><strong><%= m.salutation %> <%= m.first_name %> <%= m.last_name %></strong></td>
|
||||
<td><%= m.email %></td>
|
||||
<td><%= m.tariff_name || '–' %></td>
|
||||
<td><%= m.birth_date ? new Date(m.birth_date).toLocaleDateString('de-DE') : '–' %></td>
|
||||
<td><%= m.is_minor ? '⚠️ Ja' : 'Nein' %></td>
|
||||
<td class="iban-cell"><%= m.iban ? m.iban.replace(/(.{4})/g, '$1 ').trim() : '–' %></td>
|
||||
<td><%= new Date(m.created_at).toLocaleDateString('de-DE') %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
<% if (memberships.length === 0) { %>
|
||||
<tr><td colspan="7" class="no-data">Noch keine Mitglieder registriert.</td></tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== EINSTELLUNGEN ===== -->
|
||||
<section id="section-einstellungen" class="admin-section hidden">
|
||||
<h2>Passwort ändern</h2>
|
||||
<div class="settings-card">
|
||||
<form method="POST" action="/admin/change-password">
|
||||
<div class="form-group">
|
||||
<label>Aktuelles Passwort</label>
|
||||
<div class="input-wrap"><input type="password" name="current_password" required></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Neues Passwort</label>
|
||||
<div class="input-wrap"><input type="password" name="new_password" required minlength="8"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Neues Passwort bestätigen</label>
|
||||
<div class="input-wrap"><input type="password" name="confirm_password" required minlength="8"></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Passwort ändern</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Neuer Tarif -->
|
||||
<div class="modal-overlay hidden" id="createTariffModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Neuer Tarif</h3>
|
||||
<button onclick="toggleModal('createTariffModal')" class="modal-close">✕</button>
|
||||
</div>
|
||||
<form method="POST" action="/admin/tariffs">
|
||||
<div class="form-group">
|
||||
<label>Name *</label>
|
||||
<input type="text" name="name" required class="form-control" placeholder="z.B. Single 12 Monate">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Kategorie</label>
|
||||
<select name="category_id" class="form-control">
|
||||
<option value="">– Keine Kategorie –</option>
|
||||
<% categories.forEach(cat => { %>
|
||||
<option value="<%= cat.id %>"><%= cat.name %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Laufzeit (Monate) *</label>
|
||||
<input type="number" name="duration_months" required class="form-control" min="1" value="12">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Preis/Monat (€) *</label>
|
||||
<input type="number" name="price_monthly" required class="form-control" step="0.01" min="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Startpaket Preis (€)</label>
|
||||
<input type="number" name="start_package_price" class="form-control" step="0.01" value="35.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Beschreibung</label>
|
||||
<textarea name="description" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" onclick="toggleModal('createTariffModal')" class="btn btn-outline">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Tarif erstellen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Tarif bearbeiten -->
|
||||
<div class="modal-overlay hidden" id="editTariffModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Tarif bearbeiten</h3>
|
||||
<button onclick="toggleModal('editTariffModal')" class="modal-close">✕</button>
|
||||
</div>
|
||||
<form method="POST" id="editTariffForm">
|
||||
<div class="form-group">
|
||||
<label>Name *</label>
|
||||
<input type="text" name="name" id="edit_name" required class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Kategorie</label>
|
||||
<select name="category_id" id="edit_category" class="form-control">
|
||||
<option value="">– Keine Kategorie –</option>
|
||||
<% categories.forEach(cat => { %>
|
||||
<option value="<%= cat.id %>"><%= cat.name %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Laufzeit (Monate) *</label>
|
||||
<input type="number" name="duration_months" id="edit_duration" required class="form-control" min="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Preis/Monat (€) *</label>
|
||||
<input type="number" name="price_monthly" id="edit_price" required class="form-control" step="0.01" min="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Startpaket Preis (€)</label>
|
||||
<input type="number" name="start_package_price" id="edit_start_package" class="form-control" step="0.01">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Beschreibung</label>
|
||||
<textarea name="description" id="edit_description" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" onclick="toggleModal('editTariffModal')" class="btn btn-outline">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Neue Kategorie -->
|
||||
<div class="modal-overlay hidden" id="createCategoryModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Neue Kategorie</h3>
|
||||
<button onclick="toggleModal('createCategoryModal')" class="modal-close">✕</button>
|
||||
</div>
|
||||
<form method="POST" action="/admin/categories">
|
||||
<div class="form-group">
|
||||
<label>Kategoriename *</label>
|
||||
<input type="text" name="name" required class="form-control" placeholder="z.B. Senioren">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" onclick="toggleModal('createCategoryModal')" class="btn btn-outline">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Kategorie erstellen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Kategorie bearbeiten -->
|
||||
<div class="modal-overlay hidden" id="editCategoryModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>Kategorie umbenennen</h3>
|
||||
<button onclick="toggleModal('editCategoryModal')" class="modal-close">✕</button>
|
||||
</div>
|
||||
<form method="POST" id="editCategoryForm">
|
||||
<div class="form-group">
|
||||
<label>Neuer Name *</label>
|
||||
<input type="text" name="name" id="edit_cat_name" required class="form-control">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" onclick="toggleModal('editCategoryModal')" class="btn btn-outline">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const sectionTitles = {
|
||||
tarife: 'Tarife', kategorien: 'Kategorien',
|
||||
mitglieder: 'Mitglieder', einstellungen: 'Einstellungen'
|
||||
};
|
||||
|
||||
function showSection(name, el) {
|
||||
document.querySelectorAll('.admin-section').forEach(s => s.classList.add('hidden'));
|
||||
document.getElementById('section-' + name).classList.remove('hidden');
|
||||
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
|
||||
if (el) el.classList.add('active');
|
||||
document.getElementById('sectionTitle').textContent = sectionTitles[name] || name;
|
||||
event && event.preventDefault();
|
||||
}
|
||||
|
||||
function toggleModal(id) {
|
||||
document.getElementById(id).classList.toggle('hidden');
|
||||
}
|
||||
|
||||
function editTariff(tariff) {
|
||||
document.getElementById('edit_name').value = tariff.name;
|
||||
document.getElementById('edit_category').value = tariff.category_id || '';
|
||||
document.getElementById('edit_duration').value = tariff.duration_months;
|
||||
document.getElementById('edit_price').value = tariff.price_monthly;
|
||||
document.getElementById('edit_start_package').value = tariff.start_package_price;
|
||||
document.getElementById('edit_description').value = tariff.description || '';
|
||||
document.getElementById('editTariffForm').action = '/admin/tariffs/' + tariff.id + '/update';
|
||||
toggleModal('editTariffModal');
|
||||
}
|
||||
|
||||
function editCategory(id, name) {
|
||||
document.getElementById('edit_cat_name').value = name;
|
||||
document.getElementById('editCategoryForm').action = '/admin/categories/' + id + '/update';
|
||||
toggleModal('editCategoryModal');
|
||||
}
|
||||
|
||||
function filterMembers() {
|
||||
const q = document.getElementById('memberSearch').value.toLowerCase();
|
||||
document.querySelectorAll('.member-row').forEach(row => {
|
||||
row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// URL-Hash auswerten (nach Redirect mit #kategorien etc.)
|
||||
const hash = window.location.hash.replace('#', '');
|
||||
if (hash && document.getElementById('section-' + hash)) {
|
||||
const navLink = document.querySelector(`[onclick*="'${hash}'"]`);
|
||||
showSection(hash, navLink);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
40
views/admin/login.ejs
Normal file
40
views/admin/login.ejs
Normal file
@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PlusFit24 – Admin Login</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body class="admin-body">
|
||||
<div class="admin-login-wrap">
|
||||
<div class="admin-login-card">
|
||||
<div class="logo admin-logo">Plusfit<span>24</span></div>
|
||||
<h1>Admin Login</h1>
|
||||
|
||||
<% if (error) { %>
|
||||
<div class="alert alert-error"><%= error %></div>
|
||||
<% } %>
|
||||
|
||||
<form method="POST" action="/admin/login">
|
||||
<div class="form-group">
|
||||
<label>Benutzername</label>
|
||||
<div class="input-wrap">
|
||||
<span class="input-icon">👤</span>
|
||||
<input type="text" name="username" required autofocus>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Passwort</label>
|
||||
<div class="input-wrap">
|
||||
<span class="input-icon">🔒</span>
|
||||
<input type="password" name="password" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
20
views/error.ejs
Normal file
20
views/error.ejs
Normal file
@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>PlusFit24 – Fehler</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header"><div class="header-inner"><div class="logo">Plusfit<span>24</span></div></div></header>
|
||||
<main class="success-main">
|
||||
<div class="success-card">
|
||||
<div class="success-icon">⚠️</div>
|
||||
<h1>Fehler</h1>
|
||||
<p><%= message %></p>
|
||||
<a href="/" class="btn btn-primary">Zurück zur Startseite</a>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
76
views/index.ejs
Normal file
76
views/index.ejs
Normal file
@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PlusFit24 – Tarif Wählen</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<div class="logo">Plusfit<span>24</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Progress bar (step 1) -->
|
||||
<nav class="progress-nav">
|
||||
<span class="step active">TARIF</span>
|
||||
<span class="step-dot active"></span>
|
||||
<span class="step">4</span>
|
||||
<span class="step-dot"></span>
|
||||
<span class="step">ANSCHRIFT</span>
|
||||
<span class="step-dot"></span>
|
||||
<span class="step">LASTSCHRIFT</span>
|
||||
<span class="step-dot"></span>
|
||||
<span class="step">ABSCHLUSS</span>
|
||||
</nav>
|
||||
|
||||
<section class="tarif-section">
|
||||
<h1 class="page-title">Tarif Wählen</h1>
|
||||
|
||||
<% if (error) { %>
|
||||
<div class="alert alert-error"><%= error %></div>
|
||||
<% } %>
|
||||
|
||||
<div class="tarif-grid">
|
||||
<% tariffs.forEach(tariff => { %>
|
||||
<div class="tarif-card">
|
||||
<div class="tarif-card-inner">
|
||||
<div class="tarif-badge">
|
||||
<span class="tarif-icon">◑</span>
|
||||
<span><%= tariff.name %></span>
|
||||
</div>
|
||||
<h2 class="tarif-name"><%= tariff.name %></h2>
|
||||
<div class="tarif-feature">
|
||||
<span class="feature-icon">📦</span>
|
||||
<span>Startpaket <%= Number(tariff.start_package_price).toFixed(2).replace('.', ',') %>€/einmalig</span>
|
||||
</div>
|
||||
<div class="tarif-price">
|
||||
<span class="price-amount"><%= Number(tariff.price_monthly).toFixed(2).replace('.', ',') %>€</span>
|
||||
<span class="price-period">/Monat</span>
|
||||
</div>
|
||||
<a href="/anmelden/<%= tariff.id %>" class="btn btn-primary btn-full">
|
||||
Tarif auswählen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
|
||||
<% if (tariffs.length === 0) { %>
|
||||
<div class="no-tarifs">
|
||||
<p>Aktuell sind keine Tarife verfügbar. Bitte versuche es später erneut.</p>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p>© 2024 PlusFit24 UG · <a href="https://plusfit24.de/datenschutz-2/" target="_blank">Datenschutz</a> · <a href="/public/pdfs/AG_PlusFit24.pdf" target="_blank">AGB</a></p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
473
views/signup.ejs
Normal file
473
views/signup.ejs
Normal file
@ -0,0 +1,473 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PlusFit24 – Mitgliedschaft abschließen</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<div class="logo">Plusfit<span>24</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="signup-main">
|
||||
<!-- Fortschrittsleiste -->
|
||||
<nav class="progress-nav" id="progressNav">
|
||||
<span class="step" data-step="0">TARIF</span>
|
||||
<span class="step-dot"></span>
|
||||
<span class="step active" data-step="1">4</span>
|
||||
<span class="step-dot"></span>
|
||||
<span class="step" data-step="2">ANSCHRIFT</span>
|
||||
<span class="step-dot"></span>
|
||||
<span class="step" data-step="3">LASTSCHRIFT</span>
|
||||
<span class="step-dot"></span>
|
||||
<span class="step" data-step="4">ABSCHLUSS</span>
|
||||
</nav>
|
||||
|
||||
<div class="signup-container">
|
||||
|
||||
<!-- ===== SCHRITT 1: Persönliche Daten ===== -->
|
||||
<div class="form-step active" id="step1">
|
||||
<h2 class="step-title italic">Geburtsdatum</h2>
|
||||
|
||||
<div class="salutation-group">
|
||||
<button type="button" class="sal-btn" data-value="Herr" onclick="selectSalutation(this)">Herr</button>
|
||||
<button type="button" class="sal-btn" data-value="Frau" onclick="selectSalutation(this)">Frau</button>
|
||||
<button type="button" class="sal-btn" data-value="Keine Angabe" onclick="selectSalutation(this)">Keine Angabe</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Titel</label>
|
||||
<div class="input-wrap">
|
||||
<span class="input-icon">👤</span>
|
||||
<input type="text" id="title" placeholder="Dein Titel">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Vorname <span class="req">*</span></label>
|
||||
<div class="input-wrap">
|
||||
<span class="input-icon">👤</span>
|
||||
<input type="text" id="first_name" placeholder="Dein Vorname" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Nachname <span class="req">*</span></label>
|
||||
<div class="input-wrap">
|
||||
<span class="input-icon">👤</span>
|
||||
<input type="text" id="last_name" placeholder="Dein Nachname" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Geburtsdatum <span class="req">*</span></label>
|
||||
<div class="input-wrap">
|
||||
<span class="input-icon">📅</span>
|
||||
<input type="date" id="birth_date" placeholder="TT.mm.jjjj" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Email <span class="req">*</span></label>
|
||||
<div class="input-wrap">
|
||||
<span class="input-icon">✉️</span>
|
||||
<input type="email" id="email" placeholder="Deine Email" required>
|
||||
<span class="email-status" id="emailStatus"></span>
|
||||
</div>
|
||||
<div class="email-message" id="emailMessage"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Telefon</label>
|
||||
<div class="input-wrap">
|
||||
<span class="input-icon">📞</span>
|
||||
<input type="tel" id="phone" placeholder="Deine Telefon-/ Handynummer">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="minor-warning hidden" id="minorWarning">
|
||||
<div class="warning-box">
|
||||
<strong>⚠️ Hinweis für Minderjährige</strong>
|
||||
<p>Da du noch nicht 18 Jahre alt bist, benötigst du die Einverständniserklärung deiner Erziehungsberechtigten. Du wirst am Ende aufgefordert, diese zu bestätigen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-buttons">
|
||||
<a href="/" class="btn btn-outline">Zurück</a>
|
||||
<button type="button" class="btn btn-primary" onclick="validateStep1()">Weiter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SCHRITT 2: Anschrift ===== -->
|
||||
<div class="form-step" id="step2">
|
||||
<h2 class="step-title bold">Anschrift</h2>
|
||||
<p class="step-subtitle italic">Wo wohnst du?</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Deine Adresse <span class="req">*</span></label>
|
||||
<div class="input-wrap">
|
||||
<span class="input-icon">📍</span>
|
||||
<input type="text" id="street" placeholder="Straße, Hausnummer" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="input-wrap">
|
||||
<span class="input-icon">📍</span>
|
||||
<input type="text" id="address_addition" placeholder="Adress-Zusatz">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="input-wrap">
|
||||
<span class="input-icon">📍</span>
|
||||
<input type="text" id="zip" placeholder="PLZ" maxlength="5" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="input-wrap">
|
||||
<span class="input-icon">📍</span>
|
||||
<input type="text" id="city" placeholder="Ort" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-buttons">
|
||||
<button type="button" class="btn btn-outline" onclick="goToStep(1)">Zurück</button>
|
||||
<button type="button" class="btn btn-primary" onclick="validateStep2()">Weiter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SCHRITT 3: Lastschrift ===== -->
|
||||
<div class="form-step" id="step3">
|
||||
<h2 class="step-title bold">Lastschrift</h2>
|
||||
<p class="step-subtitle italic">Damit wir deinen Mitgliedsbeitrag abbuchen können</p>
|
||||
|
||||
<div class="sepa-mandate">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="sepa_accepted">
|
||||
<span class="checkbox-custom"></span>
|
||||
<span>Ich ermächtige PlusFit24 UG - NL: Moosleiten 12 84089 Aiglsbach, Zahlungen von meinem Konto mittels Lastschrift einzuziehen. Zugleich weise ich mein Kreditinstitut an, die von PlusFit24 UG - NL: Moosleiten 12 84089 Aiglsbach auf mein Konto gezogenen Lastschriften einzulösen. Hinweis: Ich kann innerhalb von acht Wochen, beginnend mit dem Belastungsdatum, die Erstattung des belasteten Betrages verlangen. Es gelten dabei die mit meinem Kreditinstitut vereinbarten Bedingungen. Gläubiger-Identifikationsnummer: DE1200100002549495</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="form-label-section">Sepa Lastschriftmandat</label>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="input-wrap">
|
||||
<span class="input-icon">🏦</span>
|
||||
<input type="text" id="bank_name" placeholder="Geldinstitut">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="input-wrap">
|
||||
<span class="input-icon">👤</span>
|
||||
<input type="text" id="account_holder" placeholder="Name des Kontoinhabers">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="input-wrap">
|
||||
<span class="input-icon">💳</span>
|
||||
<input type="text" id="iban" placeholder="IBAN" maxlength="22">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-buttons">
|
||||
<button type="button" class="btn btn-outline" onclick="goToStep(2)">Zurück</button>
|
||||
<button type="button" class="btn btn-primary" onclick="validateStep3()">Weiter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SCHRITT 4: Abschluss ===== -->
|
||||
<div class="form-step" id="step4">
|
||||
<h2 class="step-title bold">Abschluss</h2>
|
||||
<p class="step-subtitle italic">Alles auf einen Blick</p>
|
||||
|
||||
<div class="abschluss-grid">
|
||||
<!-- Tarif Übersicht -->
|
||||
<div class="tarif-summary-card">
|
||||
<div class="summary-badge">◑ Wir freuen uns auf dich!</div>
|
||||
<h3>Dein Wunsch-Tarif</h3>
|
||||
<div class="summary-feature">
|
||||
<span class="feature-icon">📦</span>
|
||||
<span>Laufzeit (Monate): <%= tariff.duration_months %></span>
|
||||
</div>
|
||||
<div class="summary-feature">
|
||||
<span class="feature-icon">📦</span>
|
||||
<span>Tarif: <%= tariff.name %></span>
|
||||
</div>
|
||||
<div class="summary-feature">
|
||||
<span class="feature-icon">📦</span>
|
||||
<span>Zusätzlich: Startpaket <%= Number(tariff.start_package_price).toFixed(2).replace('.', ',') %>€ einmalig</span>
|
||||
</div>
|
||||
<div class="summary-price">
|
||||
<span class="price-big"><%= Number(tariff.price_monthly).toFixed(2).replace('.', ',') %>€</span>
|
||||
<span class="price-period">/Monat</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Letzter Schritt -->
|
||||
<div class="final-checks">
|
||||
<h3>Letzter Schritt</h3>
|
||||
<div class="doc-buttons">
|
||||
<a href="https://plusfit24.de/wp-content/uploads/2022/11/AG_PlusFit24.pdf" target="_blank" class="doc-btn">Widerrufsbelehrung</a>
|
||||
<a href="https://plusfit24.de/datenschutz-2/" target="_blank" class="doc-btn">Datenschutz</a>
|
||||
<a href="https://plusfit24.de/wp-content/uploads/2022/11/AG_PlusFit24.pdf" target="_blank" class="doc-btn">AGB</a>
|
||||
</div>
|
||||
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="agb_accepted">
|
||||
<span class="checkbox-custom"></span>
|
||||
<span>Ich habe die AGBs und Widerrufsbelehrung gelesen und akzeptiert</span>
|
||||
</label>
|
||||
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="datenschutz_accepted">
|
||||
<span class="checkbox-custom"></span>
|
||||
<span>Ich akzeptiere die Erhebung und Verarbeitung meiner eingegebenen Daten</span>
|
||||
</label>
|
||||
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="data_correct">
|
||||
<span class="checkbox-custom"></span>
|
||||
<span>Ich bestätige die Richtigkeit meiner Angaben</span>
|
||||
</label>
|
||||
|
||||
<!-- Einverständniserklärung für Minderjährige -->
|
||||
<div id="guardianSection" class="hidden">
|
||||
<div class="minor-info-box">
|
||||
<strong>Einverständniserklärung der Erziehungsberechtigten</strong>
|
||||
<p>Da du noch nicht 18 Jahre alt bist, muss ein Erziehungsberechtigter zustimmen.</p>
|
||||
<a href="/pdfs/Einverstaendniserklaerung.pdf" target="_blank" class="doc-btn">📄 Einverständniserklärung öffnen</a>
|
||||
</div>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="guardian_consent">
|
||||
<span class="checkbox-custom"></span>
|
||||
<span>Die Einverständniserklärung der Erziehungsberechtigten liegt vor und wurde unterschrieben.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="submit-section">
|
||||
<p class="submit-notice"><strong>Schließe deinen Mitgliedschaftsvertrag mit einem Klick auf den untenstehenden Button verbindlich ab.</strong></p>
|
||||
<div id="submitError" class="alert alert-error hidden"></div>
|
||||
<button type="button" class="btn btn-submit" id="submitBtn" onclick="submitForm()">
|
||||
Kostenpflichtige Mitgliedschaft abschließen
|
||||
</button>
|
||||
<p class="ssl-notice">🔒 Deine Daten werden ausschließlich verschlüsselt übermittelt.</p>
|
||||
</div>
|
||||
|
||||
<div class="step-buttons">
|
||||
<button type="button" class="btn btn-outline" onclick="goToStep(3)">Zurück</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p>© 2024 PlusFit24 UG · <a href="https://plusfit24.de/datenschutz-2/" target="_blank">Datenschutz</a> · <a href="https://plusfit24.de/wp-content/uploads/2022/11/AG_PlusFit24.pdf" target="_blank">AGB</a></p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const TARIFF_ID = '<%= tariff.id %>';
|
||||
let currentStep = 1;
|
||||
let salutation = '';
|
||||
let emailVerified = false;
|
||||
let emailVerifyTimer = null;
|
||||
let isMinor = false;
|
||||
|
||||
// Anrede auswählen
|
||||
function selectSalutation(btn) {
|
||||
document.querySelectorAll('.sal-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
salutation = btn.dataset.value;
|
||||
}
|
||||
|
||||
// Schritt wechseln
|
||||
function goToStep(step) {
|
||||
document.querySelectorAll('.form-step').forEach(s => s.classList.remove('active'));
|
||||
document.getElementById('step' + step).classList.add('active');
|
||||
|
||||
// Progress nav aktualisieren
|
||||
const steps = document.querySelectorAll('.progress-nav .step');
|
||||
steps.forEach((s, i) => {
|
||||
s.classList.toggle('active', i === step);
|
||||
s.classList.toggle('done', i < step);
|
||||
});
|
||||
|
||||
currentStep = step;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// E-Mail Verifizierung
|
||||
document.getElementById('email').addEventListener('input', function() {
|
||||
clearTimeout(emailVerifyTimer);
|
||||
emailVerified = false;
|
||||
document.getElementById('emailStatus').textContent = '';
|
||||
document.getElementById('emailMessage').textContent = '';
|
||||
|
||||
const email = this.value.trim();
|
||||
if (email.includes('@') && email.includes('.')) {
|
||||
document.getElementById('emailStatus').textContent = '⏳';
|
||||
emailVerifyTimer = setTimeout(() => verifyEmail(email), 800);
|
||||
}
|
||||
});
|
||||
|
||||
async function verifyEmail(email) {
|
||||
try {
|
||||
const res = await fetch('/api/verify-email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.valid) {
|
||||
emailVerified = true;
|
||||
document.getElementById('emailStatus').textContent = '✅';
|
||||
document.getElementById('emailMessage').textContent = '';
|
||||
document.getElementById('emailMessage').className = 'email-message';
|
||||
} else {
|
||||
emailVerified = false;
|
||||
document.getElementById('emailStatus').textContent = '❌';
|
||||
document.getElementById('emailMessage').textContent = data.reason;
|
||||
document.getElementById('emailMessage').className = 'email-message error';
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('emailStatus').textContent = '⚠️';
|
||||
}
|
||||
}
|
||||
|
||||
// Geburtsdatum → Minderjähriger prüfen
|
||||
document.getElementById('birth_date').addEventListener('change', function() {
|
||||
const bd = new Date(this.value);
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - bd.getFullYear();
|
||||
const m = today.getMonth() - bd.getMonth();
|
||||
if (m < 0 || (m === 0 && today.getDate() < bd.getDate())) age--;
|
||||
isMinor = age < 18;
|
||||
document.getElementById('minorWarning').classList.toggle('hidden', !isMinor);
|
||||
});
|
||||
|
||||
// Schritt 1 validieren
|
||||
async function validateStep1() {
|
||||
const firstName = document.getElementById('first_name').value.trim();
|
||||
const lastName = document.getElementById('last_name').value.trim();
|
||||
const birthDate = document.getElementById('birth_date').value;
|
||||
const email = document.getElementById('email').value.trim();
|
||||
|
||||
if (!firstName || !lastName) return alert('Bitte Vor- und Nachname eingeben.');
|
||||
if (!birthDate) return alert('Bitte Geburtsdatum eingeben.');
|
||||
if (!email) return alert('Bitte E-Mail-Adresse eingeben.');
|
||||
|
||||
// Alter prüfen
|
||||
const bd = new Date(birthDate);
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - bd.getFullYear();
|
||||
const m = today.getMonth() - bd.getMonth();
|
||||
if (m < 0 || (m === 0 && today.getDate() < bd.getDate())) age--;
|
||||
if (age < 14) return alert('Das Mindestalter für eine Mitgliedschaft beträgt 14 Jahre.');
|
||||
|
||||
// E-Mail verifizieren falls noch nicht geschehen
|
||||
if (!emailVerified) {
|
||||
document.getElementById('emailStatus').textContent = '⏳';
|
||||
await verifyEmail(email);
|
||||
if (!emailVerified) return alert('Bitte eine gültige E-Mail-Adresse eingeben.');
|
||||
}
|
||||
|
||||
goToStep(2);
|
||||
}
|
||||
|
||||
// Schritt 2 validieren
|
||||
function validateStep2() {
|
||||
const street = document.getElementById('street').value.trim();
|
||||
const zip = document.getElementById('zip').value.trim();
|
||||
const city = document.getElementById('city').value.trim();
|
||||
if (!street || !zip || !city) return alert('Bitte Straße, PLZ und Ort ausfüllen.');
|
||||
goToStep(3);
|
||||
}
|
||||
|
||||
// Schritt 3 validieren
|
||||
function validateStep3() {
|
||||
// Sepa ist optional aber Checkbox sollte gecheckt sein wenn Daten eingegeben
|
||||
// Zeige Minderjährigen-Abschnitt im Abschluss
|
||||
if (isMinor) {
|
||||
document.getElementById('guardianSection').classList.remove('hidden');
|
||||
}
|
||||
goToStep(4);
|
||||
}
|
||||
|
||||
// Formular absenden
|
||||
async function submitForm() {
|
||||
if (!document.getElementById('agb_accepted').checked) return alert('Bitte AGBs und Widerrufsbelehrung akzeptieren.');
|
||||
if (!document.getElementById('datenschutz_accepted').checked) return alert('Bitte Datenschutzerklärung akzeptieren.');
|
||||
if (!document.getElementById('data_correct').checked) return alert('Bitte die Richtigkeit Ihrer Angaben bestätigen.');
|
||||
if (isMinor && !document.getElementById('guardian_consent').checked) {
|
||||
return alert('Bei Minderjährigen ist die Einverständniserklärung der Erziehungsberechtigten erforderlich.');
|
||||
}
|
||||
|
||||
const btn = document.getElementById('submitBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Wird gesendet...';
|
||||
|
||||
const errorDiv = document.getElementById('submitError');
|
||||
errorDiv.classList.add('hidden');
|
||||
|
||||
const payload = {
|
||||
tariff_id: TARIFF_ID,
|
||||
salutation: salutation,
|
||||
title: document.getElementById('title').value.trim(),
|
||||
first_name: document.getElementById('first_name').value.trim(),
|
||||
last_name: document.getElementById('last_name').value.trim(),
|
||||
birth_date: document.getElementById('birth_date').value,
|
||||
email: document.getElementById('email').value.trim(),
|
||||
phone: document.getElementById('phone').value.trim(),
|
||||
street: document.getElementById('street').value.trim(),
|
||||
address_addition: document.getElementById('address_addition').value.trim(),
|
||||
zip: document.getElementById('zip').value.trim(),
|
||||
city: document.getElementById('city').value.trim(),
|
||||
bank_name: document.getElementById('bank_name').value.trim(),
|
||||
account_holder: document.getElementById('account_holder').value.trim(),
|
||||
iban: document.getElementById('iban').value.trim().replace(/\s/g, ''),
|
||||
sepa_accepted: document.getElementById('sepa_accepted').checked,
|
||||
agb_accepted: document.getElementById('agb_accepted').checked,
|
||||
datenschutz_accepted: document.getElementById('datenschutz_accepted').checked,
|
||||
data_correct: document.getElementById('data_correct').checked,
|
||||
guardian_consent: isMinor ? document.getElementById('guardian_consent').checked : false
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/submit-membership', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
window.location.href = '/erfolg';
|
||||
} else {
|
||||
errorDiv.textContent = data.error || 'Fehler beim Absenden.';
|
||||
errorDiv.classList.remove('hidden');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Kostenpflichtige Mitgliedschaft abschließen';
|
||||
}
|
||||
} catch (e) {
|
||||
errorDiv.textContent = 'Netzwerkfehler. Bitte versuche es erneut.';
|
||||
errorDiv.classList.remove('hidden');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Kostenpflichtige Mitgliedschaft abschließen';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
30
views/success.ejs
Normal file
30
views/success.ejs
Normal file
@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PlusFit24 – Anmeldung erfolgreich</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<div class="logo">Plusfit<span>24</span></div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="success-main">
|
||||
<div class="success-card">
|
||||
<div class="success-icon">✅</div>
|
||||
<h1>Herzlich Willkommen!</h1>
|
||||
<p>Deine Mitgliedschaft wurde erfolgreich abgeschlossen. Wir freuen uns auf dich!</p>
|
||||
<p class="success-sub">Du erhältst in Kürze eine Bestätigung.</p>
|
||||
<a href="/" class="btn btn-primary">Zurück zur Startseite</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="site-footer">
|
||||
<p>© 2024 PlusFit24 UG</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user