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