Initial commit: Wachplan v1.1

This commit is contained in:
cay 2026-03-27 10:17:15 +00:00
commit 897597bb54
20 changed files with 2743 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
.env
*.log
.DS_Store

325
README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

Binary file not shown.

170
routes/admin.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>