Initial CAT application and deployment update
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
Options -Indexes
|
||||
|
||||
<FilesMatch "\.(sql|log|ini|env|bak|backup|dist|swp)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
<FilesMatch "^(README.*|composer\.(json|lock)|package(-lock)?\.json)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
@@ -0,0 +1,21 @@
|
||||
# CAT
|
||||
|
||||
CAT – Chorwochenende-Planungs- und Buchungsanwendung.
|
||||
|
||||
## Inhalt
|
||||
|
||||
- PHP-Anwendung aus `/var/www/html/cat`
|
||||
- `cat_update_2026-09-19.sql`: idempotentes Datenbank-Update für die Zimmerplanung
|
||||
- `cat_update_2026-09-19.zip`: separates Update-Paket der geänderten Webdateien
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Die produktiven Dateien `inc/db.inc.php` und `inc/mail.inc.php` sind aus Sicherheitsgründen nicht versioniert. Sie müssen auf dem Zielsystem separat konfiguriert werden.
|
||||
|
||||
## Datenbank-Update
|
||||
|
||||
```text
|
||||
mariadb cat < cat_update_2026-09-19.sql
|
||||
```
|
||||
|
||||
Das Update legt die drei Zimmeranzahl-Einstellungen an, ohne vorhandene Daten zu überschreiben.
|
||||
@@ -0,0 +1,948 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
session_set_cookie_params([
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'use_strict_mode' => true,
|
||||
]);
|
||||
session_start();
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'");
|
||||
|
||||
require_once __DIR__ . '/inc/db.inc.php';
|
||||
require_once __DIR__ . '/inc/mail.inc.php';
|
||||
require_once __DIR__ . '/inc/room_planner.php';
|
||||
|
||||
function postString(string $key): string
|
||||
{
|
||||
$value = $_POST[$key] ?? '';
|
||||
|
||||
return is_string($value) ? trim($value) : '';
|
||||
}
|
||||
|
||||
function escape(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
function accommodationLabel(?string $value): string
|
||||
{
|
||||
return match ($value) {
|
||||
'single' => 'Einzelzimmer',
|
||||
'double' => 'Doppelzimmer',
|
||||
'multi' => 'Mehrbettzimmer',
|
||||
null, '' => '–',
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
function dayLabel(mixed $value): string
|
||||
{
|
||||
return match ((string) $value) {
|
||||
'1' => 'Fr.',
|
||||
'2' => 'Sa.',
|
||||
'3' => 'So.',
|
||||
default => (string) $value,
|
||||
};
|
||||
}
|
||||
|
||||
function mealLabel(mixed $value): string
|
||||
{
|
||||
return match ((string) $value) {
|
||||
'breakfast' => 'Frühstück',
|
||||
'lunch' => 'Mittagessen',
|
||||
'coffee' => 'Kaffee',
|
||||
'drinks' => 'Getränkepauschale',
|
||||
'dinner' => 'Abendessen',
|
||||
default => (string) $value,
|
||||
};
|
||||
}
|
||||
|
||||
function earlyDepartureLabel(mixed $value): string
|
||||
{
|
||||
return match ((string) $value) {
|
||||
'sat_breakfast' => 'Samstag nach dem Frühstück',
|
||||
'sat_lunch' => 'Samstag nach dem Mittagessen',
|
||||
'sat_coffee' => 'Samstag nach dem Kaffee',
|
||||
'sat_dinner' => 'Samstag nach dem Abendessen',
|
||||
'sun_breakfast' => 'Sonntag nach dem Frühstück',
|
||||
default => (string) $value,
|
||||
};
|
||||
}
|
||||
|
||||
function mealSummary(array $days, array $mealsByDay): string
|
||||
{
|
||||
$parts = [];
|
||||
foreach ($days as $day) {
|
||||
$meals = array_map('mealLabel', $mealsByDay[(string) $day] ?? []);
|
||||
$meals[] = 'Getränkepauschale';
|
||||
$meals[] = 'Proberaumpauschale';
|
||||
$parts[] = dayLabel($day) . ': ' . implode(', ', $meals);
|
||||
}
|
||||
|
||||
return implode('; ', $parts);
|
||||
}
|
||||
|
||||
function calculateBookingTotal(PDO $pdo, bool $under27, string $guestType, ?string $roomType, array $mealsByDay): float
|
||||
{
|
||||
$ageSuffix = $under27 ? 'under_27' : 'over_27';
|
||||
$keys = [];
|
||||
|
||||
if ($guestType === 'regular_guest' && $roomType !== null) {
|
||||
$keys[] = $roomType . '_' . $ageSuffix;
|
||||
}
|
||||
if ($guestType === 'day_guest') {
|
||||
foreach ($mealsByDay as $meals) {
|
||||
foreach (array_unique(array_merge($meals, ['drinks'])) as $meal) {
|
||||
if (in_array($meal, ['breakfast', 'lunch', 'coffee', 'dinner'], true)) {
|
||||
$keys[] = $meal . '_' . $ageSuffix;
|
||||
} elseif ($meal === 'drinks') {
|
||||
$keys[] = 'drinks';
|
||||
}
|
||||
}
|
||||
$keys[] = 'rehearsal_room';
|
||||
}
|
||||
}
|
||||
if ($keys === []) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$uniqueKeys = array_values(array_unique($keys));
|
||||
$placeholders = implode(',', array_fill(0, count($uniqueKeys), '?'));
|
||||
$statement = $pdo->prepare("SELECT setting_key, price FROM pricing_settings WHERE setting_key IN ($placeholders)");
|
||||
$statement->execute($uniqueKeys);
|
||||
$prices = [];
|
||||
foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$prices[(string) $row['setting_key']] = (float) $row['price'];
|
||||
}
|
||||
|
||||
return round(array_sum(array_map(
|
||||
static fn (string $key): float => $prices[$key] ?? 0.0,
|
||||
$keys
|
||||
)), 2);
|
||||
}
|
||||
|
||||
function fetchUnpaidRecipients(PDO $pdo): array
|
||||
{
|
||||
return $pdo->query(
|
||||
"SELECT p.name, p.email, COALESCE(b.manual_total_price, b.total_price) AS effective_price
|
||||
FROM bookings b
|
||||
INNER JOIN participants p ON p.id = b.participant_id
|
||||
WHERE b.is_paid = 0 AND p.email IS NOT NULL AND p.email <> ''
|
||||
ORDER BY p.name"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
function personalizeBulkEmail(string $template, array $recipient): string
|
||||
{
|
||||
return str_replace(
|
||||
['{{preis}}', '{{name}}'],
|
||||
[
|
||||
number_format((float) $recipient['effective_price'], 2, ',', '.') . ' €',
|
||||
(string) $recipient['name'],
|
||||
],
|
||||
$template
|
||||
);
|
||||
}
|
||||
|
||||
function redirectToAdmin(): never
|
||||
{
|
||||
header('Location: admin.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
function csrfToken(): string
|
||||
{
|
||||
if (!isset($_SESSION['admin_csrf'])) {
|
||||
$_SESSION['admin_csrf'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
return $_SESSION['admin_csrf'];
|
||||
}
|
||||
|
||||
function requireCsrf(): void
|
||||
{
|
||||
$submitted = postString('csrf_token');
|
||||
$stored = $_SESSION['admin_csrf'] ?? '';
|
||||
|
||||
if ($stored === '' || !hash_equals($stored, $submitted)) {
|
||||
http_response_code(403);
|
||||
exit('Ungültige Anfrage.');
|
||||
}
|
||||
}
|
||||
|
||||
function createPdo(array $dbConfig): PDO
|
||||
{
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$dbConfig['host'],
|
||||
$dbConfig['port'],
|
||||
$dbConfig['database'],
|
||||
$dbConfig['charset']
|
||||
);
|
||||
|
||||
return new PDO($dsn, $dbConfig['username'], $dbConfig['password'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
}
|
||||
|
||||
$pdo = createPdo($dbConfig);
|
||||
|
||||
if (isset($_SESSION['admin_id'])) {
|
||||
$lastActivity = (int) ($_SESSION['admin_last_activity'] ?? 0);
|
||||
if ($lastActivity > 0 && $lastActivity < time() - 1800) {
|
||||
$_SESSION = [];
|
||||
session_regenerate_id(true);
|
||||
} else {
|
||||
$_SESSION['admin_last_activity'] = time();
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($_SESSION['admin_id'])) {
|
||||
$activeStatement = $pdo->prepare('SELECT id FROM admin_users WHERE id = :id AND is_active = 1 LIMIT 1');
|
||||
$activeStatement->execute([':id' => (int) $_SESSION['admin_id']]);
|
||||
if ($activeStatement->fetchColumn() === false) {
|
||||
$_SESSION = [];
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
}
|
||||
|
||||
$message = $_SESSION['admin_message'] ?? '';
|
||||
unset($_SESSION['admin_message']);
|
||||
|
||||
if (postString('action') === 'login') {
|
||||
$username = postString('username');
|
||||
$password = postString('password');
|
||||
$lockedUntil = (int) ($_SESSION['admin_locked_until'] ?? 0);
|
||||
|
||||
if ($lockedUntil > time()) {
|
||||
$message = 'Zu viele Fehlversuche. Bitte später erneut versuchen.';
|
||||
} else {
|
||||
$statement = $pdo->prepare(
|
||||
'SELECT id, username, password_hash
|
||||
FROM admin_users
|
||||
WHERE username = :username AND is_active = 1
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute([':username' => $username]);
|
||||
$admin = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($admin !== false && password_verify($password, $admin['password_hash'])) {
|
||||
if (password_needs_rehash($admin['password_hash'], PASSWORD_DEFAULT)) {
|
||||
$rehash = $pdo->prepare('UPDATE admin_users SET password_hash = :password_hash WHERE id = :id');
|
||||
$rehash->execute([
|
||||
':password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
||||
':id' => (int) $admin['id'],
|
||||
]);
|
||||
}
|
||||
unset($_SESSION['admin_login_failures'], $_SESSION['admin_locked_until']);
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['admin_id'] = (int) $admin['id'];
|
||||
$_SESSION['admin_username'] = $admin['username'];
|
||||
$_SESSION['admin_last_activity'] = time();
|
||||
$_SESSION['admin_csrf'] = bin2hex(random_bytes(32));
|
||||
redirectToAdmin();
|
||||
}
|
||||
|
||||
$failures = (int) ($_SESSION['admin_login_failures'] ?? 0) + 1;
|
||||
$_SESSION['admin_login_failures'] = $failures;
|
||||
if ($failures >= 5) {
|
||||
$_SESSION['admin_locked_until'] = time() + 300;
|
||||
}
|
||||
usleep(500000);
|
||||
$message = 'Benutzername oder Passwort ist ungültig.';
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($_SESSION['admin_id'])) {
|
||||
?><!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin-Login</title>
|
||||
<link rel="stylesheet" href="./css/admin.css?v=20260918-3">
|
||||
</head>
|
||||
<body class="admin-page">
|
||||
<main class="login-card">
|
||||
<p class="eyebrow">Chorwochenende</p>
|
||||
<h1>Admin-Login</h1>
|
||||
<?php if ($message !== ''): ?>
|
||||
<p class="message error"><?= escape($message) ?></p>
|
||||
<?php endif; ?>
|
||||
<form method="post" autocomplete="off">
|
||||
<input type="hidden" name="action" value="login">
|
||||
<label for="username">Benutzername</label>
|
||||
<input type="text" id="username" name="username" required autocomplete="username">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
<button type="submit">Anmelden</button>
|
||||
</form>
|
||||
</main>
|
||||
<script src="./scripts/security.js?v=20260918-3"></script>
|
||||
</body>
|
||||
</html><?php
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
requireCsrf();
|
||||
$action = postString('action');
|
||||
|
||||
try {
|
||||
if ($action === 'logout') {
|
||||
$_SESSION = [];
|
||||
setcookie(session_name(), '', [
|
||||
'expires' => time() - 42000,
|
||||
'path' => '/',
|
||||
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
session_destroy();
|
||||
redirectToAdmin();
|
||||
}
|
||||
|
||||
if ($action === 'delete_booking') {
|
||||
$bookingId = filter_var($_POST['booking_id'] ?? null, FILTER_VALIDATE_INT);
|
||||
if ($bookingId === false || $bookingId === null) {
|
||||
throw new InvalidArgumentException('Ungültige Registrierung.');
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
$participantStatement = $pdo->prepare(
|
||||
'SELECT participant_id FROM bookings WHERE id = :id FOR UPDATE'
|
||||
);
|
||||
$participantStatement->execute([':id' => $bookingId]);
|
||||
$participantId = $participantStatement->fetchColumn();
|
||||
|
||||
if ($participantId === false) {
|
||||
throw new InvalidArgumentException('Registrierung nicht gefunden.');
|
||||
}
|
||||
|
||||
$deleteBookings = $pdo->prepare('DELETE FROM bookings WHERE participant_id = :participant_id');
|
||||
$deleteBookings->execute([':participant_id' => $participantId]);
|
||||
$deleteParticipant = $pdo->prepare('DELETE FROM participants WHERE id = :id');
|
||||
$deleteParticipant->execute([':id' => $participantId]);
|
||||
$pdo->commit();
|
||||
$_SESSION['admin_message'] = 'Registrierung wurde gelöscht.';
|
||||
}
|
||||
|
||||
if ($action === 'toggle_paid') {
|
||||
$bookingId = filter_var($_POST['booking_id'] ?? null, FILTER_VALIDATE_INT);
|
||||
$paid = filter_var($_POST['is_paid'] ?? null, FILTER_VALIDATE_INT);
|
||||
if ($bookingId === false || $bookingId === null || !in_array($paid, [0, 1], true)) {
|
||||
throw new InvalidArgumentException('Ungültiger Zahlungsstatus.');
|
||||
}
|
||||
|
||||
$statement = $pdo->prepare('UPDATE bookings SET is_paid = :is_paid WHERE id = :id');
|
||||
$statement->execute([':is_paid' => $paid, ':id' => $bookingId]);
|
||||
$_SESSION['admin_message'] = $paid === 1
|
||||
? 'Registrierung als bezahlt markiert.'
|
||||
: 'Zahlungsmarkierung entfernt.';
|
||||
}
|
||||
|
||||
if ($action === 'create_user') {
|
||||
$username = postString('new_username');
|
||||
$email = postString('new_email');
|
||||
$password = postString('new_password');
|
||||
|
||||
if (!preg_match('/^[A-Za-z0-9_.-]{3,80}$/', $username)) {
|
||||
throw new InvalidArgumentException('Der Benutzername enthält ungültige Zeichen.');
|
||||
}
|
||||
if (strlen($password) < 12) {
|
||||
throw new InvalidArgumentException('Passwörter müssen mindestens 12 Zeichen lang sein.');
|
||||
}
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($email) > 255) {
|
||||
throw new InvalidArgumentException('Bitte eine gültige Admin-E-Mail-Adresse eingeben.');
|
||||
}
|
||||
|
||||
$statement = $pdo->prepare(
|
||||
'INSERT INTO admin_users (username, email, password_hash) VALUES (:username, :email, :password_hash)'
|
||||
);
|
||||
$statement->execute([
|
||||
':username' => $username,
|
||||
':email' => $email,
|
||||
':password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
||||
]);
|
||||
$_SESSION['admin_message'] = 'Admin-Benutzer wurde angelegt.';
|
||||
}
|
||||
|
||||
if ($action === 'update_room_settings') {
|
||||
$roomSettings = [
|
||||
'single' => postString('room_count_single'),
|
||||
'double' => postString('room_count_double'),
|
||||
'multi' => postString('room_count_multi'),
|
||||
];
|
||||
$upsertRoomSetting = $pdo->prepare(
|
||||
'INSERT INTO pricing_settings (setting_key, price) VALUES (:key, :price)
|
||||
ON DUPLICATE KEY UPDATE price = VALUES(price)'
|
||||
);
|
||||
foreach ($roomSettings as $roomType => $rawCount) {
|
||||
if ($rawCount === '' || !ctype_digit($rawCount) || (int) $rawCount > 9999) {
|
||||
throw new InvalidArgumentException('Bitte gültige Zimmeranzahlen zwischen 0 und 9.999 eingeben.');
|
||||
}
|
||||
$upsertRoomSetting->execute([
|
||||
':key' => 'room_count_' . $roomType,
|
||||
':price' => $rawCount,
|
||||
]);
|
||||
}
|
||||
$_SESSION['admin_message'] = 'Zimmeranzahlen wurden gespeichert.';
|
||||
}
|
||||
|
||||
if ($action === 'prepare_unpaid_email') {
|
||||
$template = postString('bulk_email_text');
|
||||
if ($template === '' || mb_strlen($template) > 10000) {
|
||||
throw new InvalidArgumentException('Bitte einen Nachrichtentext mit höchstens 10.000 Zeichen eingeben.');
|
||||
}
|
||||
$recipients = fetchUnpaidRecipients($pdo);
|
||||
if ($recipients === []) {
|
||||
throw new InvalidArgumentException('Es wurden keine unbezahlten Registrierungen mit E-Mail-Adresse gefunden.');
|
||||
}
|
||||
$_SESSION['bulk_email_draft'] = [
|
||||
'text' => $template,
|
||||
'created_at' => time(),
|
||||
];
|
||||
$_SESSION['admin_message'] = 'Vorschau vorbereitet. Bitte Empfängerzahl und Nachricht prüfen.';
|
||||
}
|
||||
|
||||
if ($action === 'send_unpaid_email') {
|
||||
$draft = $_SESSION['bulk_email_draft'] ?? null;
|
||||
if (!is_array($draft) || !is_string($draft['text'] ?? null) || (int) ($draft['created_at'] ?? 0) < time() - 1800) {
|
||||
unset($_SESSION['bulk_email_draft']);
|
||||
throw new InvalidArgumentException('Die Vorschau ist abgelaufen. Bitte die Nachricht erneut vorbereiten.');
|
||||
}
|
||||
$recipients = fetchUnpaidRecipients($pdo);
|
||||
$sent = 0;
|
||||
$failed = 0;
|
||||
foreach ($recipients as $recipient) {
|
||||
try {
|
||||
sendConfiguredTextEmail(
|
||||
$mailConfig,
|
||||
(string) $recipient['email'],
|
||||
'One Voice - Chorwochenende - Deine Teilnahmegebühr',
|
||||
personalizeBulkEmail($draft['text'], $recipient)
|
||||
);
|
||||
$sent++;
|
||||
} catch (Throwable $exception) {
|
||||
$failed++;
|
||||
error_log('Massenmail konnte nicht an ' . (string) $recipient['email'] . ' versendet werden: ' . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
unset($_SESSION['bulk_email_draft']);
|
||||
$_SESSION['admin_message'] = $failed === 0
|
||||
? $sent . ' Zahlungserinnerung(en) wurden versendet.'
|
||||
: $sent . ' Zahlungserinnerung(en) versendet, ' . $failed . ' Versand/Versände fehlgeschlagen.';
|
||||
}
|
||||
|
||||
if ($action === 'update_booking_price') {
|
||||
$bookingId = filter_var($_POST['booking_id'] ?? null, FILTER_VALIDATE_INT);
|
||||
if ($bookingId === false || $bookingId === null) {
|
||||
throw new InvalidArgumentException('Ungültige Registrierung.');
|
||||
}
|
||||
$correctPrice = isset($_POST['correct_price']) && $_POST['correct_price'] === '1';
|
||||
$rawManualPrice = $correctPrice ? str_replace(',', '.', postString('manual_total_price')) : '';
|
||||
$bookingStatement = $pdo->prepare(
|
||||
'SELECT b.id, b.type, b.accommodation_type, b.meal_preferences, p.under_27
|
||||
FROM bookings b
|
||||
INNER JOIN participants p ON p.id = b.participant_id
|
||||
WHERE b.id = :id'
|
||||
);
|
||||
$bookingStatement->execute([':id' => $bookingId]);
|
||||
$bookingRow = $bookingStatement->fetch(PDO::FETCH_ASSOC);
|
||||
if ($bookingRow === false) {
|
||||
throw new InvalidArgumentException('Registrierung nicht gefunden.');
|
||||
}
|
||||
if ($rawManualPrice !== '' && (!is_numeric($rawManualPrice) || (float) $rawManualPrice < 0 || (float) $rawManualPrice > 999999.99)) {
|
||||
throw new InvalidArgumentException('Bitte einen gültigen manuellen Gesamtpreis zwischen 0,00 und 999.999,99 eingeben.');
|
||||
}
|
||||
$manualPrice = $rawManualPrice === '' ? null : number_format((float) $rawManualPrice, 2, '.', '');
|
||||
$mealData = $bookingRow['meal_preferences'] !== null
|
||||
? json_decode($bookingRow['meal_preferences'], true)
|
||||
: [];
|
||||
$mealData = is_array($mealData) ? $mealData : [];
|
||||
$mealsByDay = is_array($mealData['meals_by_day'] ?? null) ? $mealData['meals_by_day'] : [];
|
||||
if ($mealsByDay === [] && is_array($mealData['meals'] ?? null)) {
|
||||
foreach ($mealData['days'] ?? [] as $day) {
|
||||
$mealsByDay[(string) $day] = $mealData['meals'];
|
||||
}
|
||||
}
|
||||
foreach ($mealData['days'] ?? [] as $day) {
|
||||
$mealsByDay[(string) $day] ??= [];
|
||||
}
|
||||
$automaticPrice = calculateBookingTotal(
|
||||
$pdo,
|
||||
(int) $bookingRow['under_27'] === 1,
|
||||
$bookingRow['type'] === 'Day' ? 'day_guest' : 'regular_guest',
|
||||
$bookingRow['accommodation_type'],
|
||||
$mealsByDay
|
||||
);
|
||||
$statement = $pdo->prepare(
|
||||
'UPDATE bookings SET manual_total_price = :manual_total_price, total_price = :total_price WHERE id = :id'
|
||||
);
|
||||
$statement->execute([
|
||||
':manual_total_price' => $manualPrice,
|
||||
':total_price' => $manualPrice ?? number_format($automaticPrice, 2, '.', ''),
|
||||
':id' => $bookingId,
|
||||
]);
|
||||
$_SESSION['admin_message'] = $manualPrice === null
|
||||
? 'Manueller Preis entfernt; automatische Berechnung wurde wiederhergestellt.'
|
||||
: 'Preis wurde korrigiert.';
|
||||
}
|
||||
|
||||
if ($action === 'update_prices') {
|
||||
$priceKeys = [];
|
||||
foreach (['single', 'double', 'multi', 'breakfast', 'lunch', 'coffee', 'dinner'] as $baseKey) {
|
||||
$priceKeys[] = $baseKey . '_under_27';
|
||||
$priceKeys[] = $baseKey . '_over_27';
|
||||
}
|
||||
$priceKeys[] = 'drinks';
|
||||
$priceKeys[] = 'rehearsal_room';
|
||||
$upsert = $pdo->prepare(
|
||||
'INSERT INTO pricing_settings (setting_key, price) VALUES (:key, :price)
|
||||
ON DUPLICATE KEY UPDATE price = VALUES(price)'
|
||||
);
|
||||
foreach ($priceKeys as $priceKey) {
|
||||
$rawPrice = str_replace(',', '.', postString('price_' . $priceKey));
|
||||
if ($rawPrice === '' || !is_numeric($rawPrice) || (float) $rawPrice < 0 || (float) $rawPrice > 999999.99) {
|
||||
throw new InvalidArgumentException('Bitte gültige Preise zwischen 0,00 und 999.999,99 eingeben.');
|
||||
}
|
||||
$upsert->execute([':key' => $priceKey, ':price' => number_format((float) $rawPrice, 2, '.', '')]);
|
||||
}
|
||||
|
||||
$bookingRows = $pdo->query(
|
||||
'SELECT b.id, b.type, b.accommodation_type, b.meal_preferences, b.manual_total_price, p.under_27
|
||||
FROM bookings b
|
||||
INNER JOIN participants p ON p.id = b.participant_id'
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
$updateTotal = $pdo->prepare('UPDATE bookings SET total_price = :total_price WHERE id = :id');
|
||||
foreach ($bookingRows as $bookingRow) {
|
||||
if ($bookingRow['manual_total_price'] !== null) {
|
||||
continue;
|
||||
}
|
||||
$mealData = $bookingRow['meal_preferences'] !== null
|
||||
? json_decode($bookingRow['meal_preferences'], true)
|
||||
: [];
|
||||
$mealData = is_array($mealData) ? $mealData : [];
|
||||
$guestType = $bookingRow['type'] === 'Day' ? 'day_guest' : 'regular_guest';
|
||||
$mealsByDay = is_array($mealData['meals_by_day'] ?? null) ? $mealData['meals_by_day'] : [];
|
||||
if ($mealsByDay === [] && is_array($mealData['meals'] ?? null)) {
|
||||
foreach ($mealData['days'] ?? [] as $day) {
|
||||
$mealsByDay[(string) $day] = $mealData['meals'];
|
||||
}
|
||||
}
|
||||
foreach ($mealData['days'] ?? [] as $day) {
|
||||
$mealsByDay[(string) $day] ??= [];
|
||||
}
|
||||
$totalPrice = calculateBookingTotal(
|
||||
$pdo,
|
||||
(int) $bookingRow['under_27'] === 1,
|
||||
$guestType,
|
||||
$bookingRow['accommodation_type'],
|
||||
$mealsByDay
|
||||
);
|
||||
$updateTotal->execute([
|
||||
':total_price' => number_format($totalPrice, 2, '.', ''),
|
||||
':id' => (int) $bookingRow['id'],
|
||||
]);
|
||||
}
|
||||
$_SESSION['admin_message'] = 'Preise wurden gespeichert.';
|
||||
}
|
||||
|
||||
if ($action === 'change_password') {
|
||||
$adminId = filter_var($_POST['admin_id'] ?? null, FILTER_VALIDATE_INT);
|
||||
$password = postString('changed_password');
|
||||
|
||||
if ($adminId === false || $adminId === null || strlen($password) < 12) {
|
||||
throw new InvalidArgumentException('Passwort muss mindestens 12 Zeichen lang sein.');
|
||||
}
|
||||
|
||||
$statement = $pdo->prepare(
|
||||
'UPDATE admin_users SET password_hash = :password_hash, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id AND is_active = 1'
|
||||
);
|
||||
$statement->execute([
|
||||
':password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
||||
':id' => $adminId,
|
||||
]);
|
||||
$_SESSION['admin_message'] = 'Passwort wurde geändert.';
|
||||
}
|
||||
|
||||
if ($action === 'delete_user') {
|
||||
$adminId = filter_var($_POST['admin_id'] ?? null, FILTER_VALIDATE_INT);
|
||||
if ($adminId === false || $adminId === null || $adminId === (int) $_SESSION['admin_id']) {
|
||||
throw new InvalidArgumentException('Der eigene Benutzer kann nicht gelöscht werden.');
|
||||
}
|
||||
|
||||
$activeCount = (int) $pdo->query('SELECT COUNT(*) FROM admin_users WHERE is_active = 1')->fetchColumn();
|
||||
if ($activeCount <= 1) {
|
||||
throw new InvalidArgumentException('Der letzte Admin-Benutzer kann nicht gelöscht werden.');
|
||||
}
|
||||
|
||||
$statement = $pdo->prepare('DELETE FROM admin_users WHERE id = :id AND is_active = 1');
|
||||
$statement->execute([':id' => $adminId]);
|
||||
$_SESSION['admin_message'] = 'Admin-Benutzer wurde gelöscht.';
|
||||
}
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
$_SESSION['admin_message'] = $exception->getMessage();
|
||||
} catch (PDOException $exception) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
error_log($exception->getMessage());
|
||||
$_SESSION['admin_message'] = 'Die Änderung konnte nicht gespeichert werden.';
|
||||
}
|
||||
|
||||
redirectToAdmin();
|
||||
}
|
||||
|
||||
$bookings = $pdo->query(
|
||||
'SELECT b.id AS booking_id, p.name, p.email, p.role, p.under_27, p.allergies,
|
||||
b.type, b.accommodation_type, b.meal_preferences, b.is_paid, b.total_price,
|
||||
b.manual_total_price, b.roommate_requests, b.early_departure, b.other_notes
|
||||
FROM bookings b
|
||||
INNER JOIN participants p ON p.id = b.participant_id
|
||||
ORDER BY b.id DESC'
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$roomPlanningSettings = loadRoomPlanningSettings($pdo);
|
||||
$roomPlanningParticipants = $pdo->query(
|
||||
"SELECT p.id, p.name, p.email, b.accommodation_type AS room_type,
|
||||
b.roommate_requests
|
||||
FROM bookings b
|
||||
INNER JOIN participants p ON p.id = b.participant_id
|
||||
WHERE b.type = 'Stay'
|
||||
AND b.accommodation_type IN ('single', 'double', 'multi')
|
||||
ORDER BY p.name"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
$roomPlan = buildRoomPlan($roomPlanningParticipants, $roomPlanningSettings);
|
||||
|
||||
$admins = $pdo->query(
|
||||
'SELECT id, username, email, created_at, updated_at
|
||||
FROM admin_users
|
||||
WHERE is_active = 1
|
||||
ORDER BY username'
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$pricingRows = $pdo->query('SELECT setting_key, price FROM pricing_settings')->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$priceGroups = [
|
||||
'Zimmer' => ['single' => 'Einzelzimmer', 'double' => 'Doppelzimmer', 'multi' => 'Mehrbettzimmer'],
|
||||
'Mahlzeiten' => ['breakfast' => 'Frühstück', 'lunch' => 'Mittagessen', 'coffee' => 'Kaffee', 'dinner' => 'Abendessen'],
|
||||
];
|
||||
$flatPriceItems = [
|
||||
'drinks' => 'Getränkepauschale',
|
||||
'rehearsal_room' => 'Proberaumpauschale',
|
||||
];
|
||||
|
||||
$csrf = csrfToken();
|
||||
$bulkEmailDraft = $_SESSION['bulk_email_draft'] ?? null;
|
||||
$bulkEmailRecipients = is_array($bulkEmailDraft) && (int) ($bulkEmailDraft['created_at'] ?? 0) >= time() - 1800
|
||||
? fetchUnpaidRecipients($pdo)
|
||||
: [];
|
||||
$bulkEmailPreview = $bulkEmailRecipients !== [] && is_array($bulkEmailDraft)
|
||||
? personalizeBulkEmail((string) $bulkEmailDraft['text'], $bulkEmailRecipients[0])
|
||||
: '';
|
||||
?><!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Administration</title>
|
||||
<link rel="stylesheet" href="./css/admin.css?v=20260918-3">
|
||||
</head>
|
||||
<body class="admin-page">
|
||||
<header class="admin-header">
|
||||
<div>
|
||||
<p class="eyebrow">Chorwochenende</p>
|
||||
<h1>Administration</h1>
|
||||
</div>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="logout">
|
||||
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
|
||||
<button class="button-secondary" type="submit">Abmelden</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<main class="admin-content">
|
||||
<?php if ($message !== ''): ?>
|
||||
<p class="message"><?= escape($message) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Anmeldungen</p>
|
||||
<h2>Registrierungen</h2>
|
||||
</div>
|
||||
<div>
|
||||
<span class="count"><?= count($bookings) ?></span>
|
||||
<a class="button-secondary export-button" href="export.php">Excel-Export</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Altersgruppe</th>
|
||||
<th>Bezahlt</th>
|
||||
<th>Preis</th>
|
||||
<th>Typ</th>
|
||||
<th>Zimmer</th>
|
||||
<th>Zimmerpartner-Wunsch</th>
|
||||
<th>Frühzeitige Abreise</th>
|
||||
<th>Mahlzeiten / Tage</th>
|
||||
<th>Allergien</th>
|
||||
<th>Sonstiges</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($bookings as $booking):
|
||||
$mealData = $booking['meal_preferences'] !== null
|
||||
? json_decode($booking['meal_preferences'], true)
|
||||
: null;
|
||||
$mealData = is_array($mealData) ? $mealData : [];
|
||||
$days = is_array($mealData['days'] ?? null) ? $mealData['days'] : [];
|
||||
$mealsByDay = is_array($mealData['meals_by_day'] ?? null) ? $mealData['meals_by_day'] : [];
|
||||
if ($mealsByDay === [] && is_array($mealData['meals'] ?? null)) {
|
||||
foreach ($days as $day) {
|
||||
$mealsByDay[(string) $day] = $mealData['meals'];
|
||||
}
|
||||
}
|
||||
$mealText = mealSummary($days, $mealsByDay);
|
||||
?>
|
||||
<tr>
|
||||
<td><?= escape($booking['name']) ?></td>
|
||||
<td><a href="mailto:<?= escape($booking['email']) ?>"><?= escape($booking['email']) ?></a></td>
|
||||
<td><?= (int) $booking['under_27'] === 1 ? 'Unter 27' : 'Ab 27' ?></td>
|
||||
<td>
|
||||
<form method="post" class="paid-form">
|
||||
<input type="hidden" name="action" value="toggle_paid">
|
||||
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
|
||||
<input type="hidden" name="booking_id" value="<?= (int) $booking['booking_id'] ?>">
|
||||
<input type="hidden" name="is_paid" value="0">
|
||||
<input type="checkbox" name="is_paid" value="1" data-submit-on-change="true" <?= (int) $booking['is_paid'] === 1 ? 'checked' : '' ?> aria-label="Als bezahlt markieren">
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<div><?= number_format((float) $booking['total_price'], 2, ',', '.') ?> €</div>
|
||||
<small><?= $booking['manual_total_price'] !== null ? 'korrigiert' : 'automatisch' ?></small>
|
||||
<form method="post" class="manual-price-form">
|
||||
<input type="hidden" name="action" value="update_booking_price">
|
||||
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
|
||||
<input type="hidden" name="booking_id" value="<?= (int) $booking['booking_id'] ?>">
|
||||
<label class="checkbox-label" for="correct_price_<?= (int) $booking['booking_id'] ?>">
|
||||
<input type="checkbox" id="correct_price_<?= (int) $booking['booking_id'] ?>" name="correct_price" value="1" data-toggle-target="manual_total_price_<?= (int) $booking['booking_id'] ?>" <?= $booking['manual_total_price'] !== null ? 'checked' : '' ?>>
|
||||
Preis korrigieren
|
||||
</label>
|
||||
<div class="manual-price-input" data-toggle-content="manual_total_price_<?= (int) $booking['booking_id'] ?>" <?= $booking['manual_total_price'] === null ? 'hidden' : '' ?>>
|
||||
<label class="sr-only" for="manual_total_price_<?= (int) $booking['booking_id'] ?>">Preis korrigieren</label>
|
||||
<input type="number" id="manual_total_price_<?= (int) $booking['booking_id'] ?>" name="manual_total_price" min="0" max="999999.99" step="0.01" placeholder="Neuer Gesamtpreis" value="<?= $booking['manual_total_price'] !== null ? escape((string) $booking['manual_total_price']) : '' ?>" <?= $booking['manual_total_price'] === null ? 'disabled' : '' ?>>
|
||||
<button class="button-secondary" type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
<td><?= escape($booking['role']) ?></td>
|
||||
<td><?= escape(accommodationLabel($booking['accommodation_type'])) ?></td>
|
||||
<td><?= escape((string) ($booking['roommate_requests'] ?? '–')) ?></td>
|
||||
<td><?= escape($booking['early_departure'] !== null && $booking['early_departure'] !== '' ? earlyDepartureLabel($booking['early_departure']) : '–') ?></td>
|
||||
<td><?= escape($mealText !== '' ? $mealText : '–') ?></td>
|
||||
<td><?= escape((string) ($booking['allergies'] ?? '–')) ?></td>
|
||||
<td><?= escape((string) ($booking['other_notes'] ?? '–')) ?></td>
|
||||
<td>
|
||||
<form method="post" data-confirm="Diese Registrierung wirklich löschen?">
|
||||
<input type="hidden" name="action" value="delete_booking">
|
||||
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
|
||||
<input type="hidden" name="booking_id" value="<?= (int) $booking['booking_id'] ?>">
|
||||
<button class="button-danger" type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($bookings === []): ?>
|
||||
<tr><td colspan="13">Noch keine Registrierungen vorhanden.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<p class="eyebrow">Auswertung</p>
|
||||
<h2>Zahlungserinnerung an Nichtzahler</h2>
|
||||
<p>Angeschrieben werden alle angemeldeten Teilnehmer mit hinterlegter E-Mail-Adresse, deren Registrierung noch nicht als bezahlt markiert ist.</p>
|
||||
<form method="post" class="stacked-form">
|
||||
<input type="hidden" name="action" value="prepare_unpaid_email">
|
||||
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
|
||||
<label for="bulk_email_text">Nachrichtentext</label>
|
||||
<textarea id="bulk_email_text" name="bulk_email_text" rows="8" maxlength="10000" required><?= is_array($bulkEmailDraft) ? escape((string) ($bulkEmailDraft['text'] ?? '')) : '' ?></textarea>
|
||||
<small>Platzhalter: <code>{{name}}</code> für den Namen und <code>{{preis}}</code> für den jeweils offenen Preis.</small>
|
||||
<button type="submit">Vorschau vorbereiten</button>
|
||||
</form>
|
||||
|
||||
<?php if ($bulkEmailRecipients !== []): ?>
|
||||
<div class="bulk-email-preview">
|
||||
<h3>Versand bestätigen</h3>
|
||||
<p><strong><?= count($bulkEmailRecipients) ?></strong> Empfänger gefunden. Beispiel für <?= escape((string) $bulkEmailRecipients[0]['name']) ?>:</p>
|
||||
<pre><?= escape($bulkEmailPreview) ?></pre>
|
||||
<form method="post" data-confirm="Die Zahlungserinnerung jetzt an alle <?= count($bulkEmailRecipients) ?> Empfänger senden?">
|
||||
<input type="hidden" name="action" value="send_unpaid_email">
|
||||
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
|
||||
<button type="submit">Jetzt an alle Nichtzahler senden</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<p class="eyebrow">Auswertung</p>
|
||||
<h2>Zimmerbelegung planen</h2>
|
||||
<p>Berücksichtigt werden nur Übernachtungsgäste. Mehrbettzimmer werden mit maximal vier Schlafplätzen geplant.</p>
|
||||
<form method="post" class="price-grid">
|
||||
<input type="hidden" name="action" value="update_room_settings">
|
||||
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
|
||||
<div class="price-group">
|
||||
<h3>Verfügbare Zimmer</h3>
|
||||
<div class="price-item">
|
||||
<label for="room_count_single">Einzelzimmer</label>
|
||||
<input type="number" id="room_count_single" name="room_count_single" min="0" max="9999" step="1" value="<?= (int) $roomPlanningSettings['single'] ?>" required>
|
||||
</div>
|
||||
<div class="price-item">
|
||||
<label for="room_count_double">Doppelzimmer</label>
|
||||
<input type="number" id="room_count_double" name="room_count_double" min="0" max="9999" step="1" value="<?= (int) $roomPlanningSettings['double'] ?>" required>
|
||||
</div>
|
||||
<div class="price-item">
|
||||
<label for="room_count_multi">Mehrbettzimmer (max. 4 Plätze)</label>
|
||||
<input type="number" id="room_count_multi" name="room_count_multi" min="0" max="9999" step="1" value="<?= (int) $roomPlanningSettings['multi'] ?>" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit">Zimmeranzahlen speichern</button>
|
||||
</form>
|
||||
|
||||
<?php if ($roomPlan['warnings'] !== []): ?>
|
||||
<div class="message error">
|
||||
<strong>Planungshinweise</strong>
|
||||
<ul>
|
||||
<?php foreach ($roomPlan['warnings'] as $warning): ?>
|
||||
<li><?= escape($warning) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead><tr><th>Zimmer</th><th>Belegung</th><th>Kapazität</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($roomPlan['rooms'] as $room): ?>
|
||||
<tr>
|
||||
<td><?= escape(accommodationLabel($room['type'])) ?> <?= (int) $room['number'] ?></td>
|
||||
<td><?= escape(implode(', ', array_column($room['members'], 'name'))) ?></td>
|
||||
<td><?= count($room['members']) ?> / <?= (int) $room['capacity'] ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($roomPlan['rooms'] === []): ?>
|
||||
<tr><td colspan="3">Noch keine Zimmerplanung möglich. Bitte Zimmeranzahlen und Übernachtungsgäste prüfen.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Preise</h2>
|
||||
<form method="post" class="price-grid">
|
||||
<input type="hidden" name="action" value="update_prices">
|
||||
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
|
||||
<?php foreach ($priceGroups as $groupLabel => $priceItems): ?>
|
||||
<div class="price-group">
|
||||
<h3><?= escape($groupLabel) ?></h3>
|
||||
<?php foreach ($priceItems as $priceKey => $priceLabel): ?>
|
||||
<div class="price-item">
|
||||
<strong><?= escape($priceLabel) ?></strong>
|
||||
<label for="price_<?= $priceKey ?>_under_27">Unter 27</label>
|
||||
<input type="number" id="price_<?= $priceKey ?>_under_27" name="price_<?= $priceKey ?>_under_27" min="0" max="999999.99" step="0.01" value="<?= escape((string) ($pricingRows[$priceKey . '_under_27'] ?? '0.00')) ?>" required>
|
||||
<label for="price_<?= $priceKey ?>_over_27">Ab 27</label>
|
||||
<input type="number" id="price_<?= $priceKey ?>_over_27" name="price_<?= $priceKey ?>_over_27" min="0" max="999999.99" step="0.01" value="<?= escape((string) ($pricingRows[$priceKey . '_over_27'] ?? '0.00')) ?>" required>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<div class="price-group">
|
||||
<h3>Pauschalen</h3>
|
||||
<?php foreach ($flatPriceItems as $priceKey => $priceLabel): ?>
|
||||
<div class="price-item">
|
||||
<strong><?= escape($priceLabel) ?></strong>
|
||||
<label for="price_<?= $priceKey ?>">Preis</label>
|
||||
<input type="number" id="price_<?= $priceKey ?>" name="price_<?= $priceKey ?>" min="0" max="999999.99" step="0.01" value="<?= escape((string) ($pricingRows[$priceKey] ?? '0.00')) ?>" required>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<button type="submit">Preise speichern</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div class="admin-grid">
|
||||
<section class="panel">
|
||||
<p class="eyebrow">Zugänge</p>
|
||||
<h2>Admin-Benutzer</h2>
|
||||
<div class="user-list">
|
||||
<?php foreach ($admins as $admin): ?>
|
||||
<div class="user-row">
|
||||
<div><strong><?= escape($admin['username']) ?></strong><br><small><?= escape($admin['email']) ?></small></div>
|
||||
<div class="user-actions">
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="change_password">
|
||||
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
|
||||
<input type="hidden" name="admin_id" value="<?= (int) $admin['id'] ?>">
|
||||
<input type="password" name="changed_password" minlength="12" placeholder="Neues Passwort" required>
|
||||
<button class="button-secondary" type="submit">Ändern</button>
|
||||
</form>
|
||||
<?php if ((int) $admin['id'] !== (int) $_SESSION['admin_id']): ?>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="delete_user">
|
||||
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
|
||||
<input type="hidden" name="admin_id" value="<?= (int) $admin['id'] ?>">
|
||||
<button class="button-danger" type="submit">Löschen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<p class="eyebrow">Zugänge</p>
|
||||
<h2>Benutzer anlegen</h2>
|
||||
<form method="post" class="stacked-form">
|
||||
<input type="hidden" name="action" value="create_user">
|
||||
<input type="hidden" name="csrf_token" value="<?= escape($csrf) ?>">
|
||||
<label for="new_username">Benutzername</label>
|
||||
<input type="text" id="new_username" name="new_username" pattern="[A-Za-z0-9_.-]{3,80}" required>
|
||||
<label for="new_email">E-Mail-Adresse</label>
|
||||
<input type="email" id="new_email" name="new_email" maxlength="255" required>
|
||||
<label for="new_password">Passwort</label>
|
||||
<input type="password" id="new_password" name="new_password" minlength="12" required>
|
||||
<button type="submit">Benutzer anlegen</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<script src="./scripts/security.js?v=20260918-3"></script>
|
||||
</body>
|
||||
</html>
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
session_set_cookie_params([
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'use_strict_mode' => true,
|
||||
]);
|
||||
session_start();
|
||||
$_SESSION['registration_csrf'] ??= bin2hex(random_bytes(32));
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'");
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Gastregistrierung</title>
|
||||
<link rel="stylesheet" href="./css/booking.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Gastregistrierung</h1>
|
||||
|
||||
<form action="process_registration.php" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['registration_csrf'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">
|
||||
|
||||
<!-- 1. Name und E-Mail des Gastes -->
|
||||
<div class="form-group">
|
||||
<label for="name">Name des Gastes:</label>
|
||||
<input type="text" id="name" name="name" maxlength="255" required>
|
||||
|
||||
<label for="email">E-Mail-Adresse:</label>
|
||||
<input type="email" id="email" name="email" maxlength="255" required>
|
||||
|
||||
<label class="checkbox-label" for="under_27">
|
||||
<input type="checkbox" id="under_27" name="under_27" value="1">
|
||||
Unter 27
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 2. Gasttyp (Dropdown) -->
|
||||
<div class="form-group">
|
||||
<label for="guest_type">Gasttyp:</label>
|
||||
<select id="guest_type" name="guest_type" required>
|
||||
<option value="">Bitte auswählen</option>
|
||||
<option value="day_guest">Tagesgast</option>
|
||||
<option value="regular_guest">Dauergast</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 3. Bedingte Auswahl basierend auf Gasttyp -->
|
||||
<div class="form-group" id="room_details">
|
||||
<!-- Dauergast-Optionen (wird angezeigt, wenn regelmäßiger Gast gewählt wird) -->
|
||||
<div id="regular_guest_options">
|
||||
<label for="room_type">Zimmerart:</label>
|
||||
<select id="room_type" name="room_type">
|
||||
<option value="single">Einzelzimmer</option>
|
||||
<option value="double">Doppelzimmer</option>
|
||||
<option value="multi">Mehrbettzimmer</option>
|
||||
</select>
|
||||
|
||||
<!-- Bedingte Felder für Doppel/Mehrbettzimmer -->
|
||||
<div id="roommate_info" style="display: none;">
|
||||
<label for="roommates">Mit wem möchten Sie das Zimmer teilen?</label>
|
||||
<input type="text" id="roommates" name="roommates" placeholder="Name des Mitbewohners" autocomplete="off" list="roommate_suggestions">
|
||||
<datalist id="roommate_suggestions"></datalist>
|
||||
</div>
|
||||
<label class="checkbox-label" for="early_departure_enabled">
|
||||
<input type="checkbox" id="early_departure_enabled" name="early_departure_enabled" value="1">
|
||||
Ich reise vorzeitig ab.
|
||||
</label>
|
||||
<div id="early_departure_options" style="display: none;">
|
||||
<label>Abreisezeitpunkt:</label>
|
||||
<div class="checkbox-group">
|
||||
<label><input type="radio" name="early_departure" value="sat_breakfast"> Samstag nach dem Frühstück</label>
|
||||
<label><input type="radio" name="early_departure" value="sat_lunch"> Samstag nach dem Mittagessen</label>
|
||||
<label><input type="radio" name="early_departure" value="sat_coffee"> Samstag nach dem Kaffee</label>
|
||||
<label><input type="radio" name="early_departure" value="sat_dinner"> Samstag nach dem Abendessen</label>
|
||||
<label><input type="radio" name="early_departure" value="sun_breakfast"> Sonntag nach dem Frühstück</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tagesgast-Optionen (wird angezeigt, wenn Tagesgast gewählt wird) -->
|
||||
<div id="day_guest_options" style="display: none;">
|
||||
<label>Anwesenheitstage (Checkboxen):</label>
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" name="days[]" value="1"> Fr.
|
||||
<input type="checkbox" name="days[]" value="2"> Sa.
|
||||
<input type="checkbox" name="days[]" value="3"> So.
|
||||
<!-- Fügen Sie hier weitere Tage hinzu -->
|
||||
</div>
|
||||
|
||||
<label>Gewünschte Mahlzeiten je Anwesenheitstag:</label>
|
||||
<?php $mealOptionsByDay = [
|
||||
'1' => ['dinner' => 'Abendessen'],
|
||||
'2' => ['breakfast' => 'Frühstück', 'lunch' => 'Mittagessen', 'coffee' => 'Kaffee', 'dinner' => 'Abendessen'],
|
||||
'3' => ['breakfast' => 'Frühstück', 'lunch' => 'Mittagessen'],
|
||||
]; ?>
|
||||
<?php foreach (['1' => 'Fr.', '2' => 'Sa.', '3' => 'So.'] as $dayValue => $dayLabel): ?>
|
||||
<fieldset class="meal-day">
|
||||
<legend><?= htmlspecialchars($dayLabel, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></legend>
|
||||
<div class="checkbox-group">
|
||||
<?php foreach ($mealOptionsByDay[$dayValue] as $mealValue => $mealLabel): ?>
|
||||
<label><input type="checkbox" name="meals[<?= $dayValue ?>][]" value="<?= $mealValue ?>"> <?= $mealLabel ?></label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</fieldset>
|
||||
<?php endforeach; ?>
|
||||
<p class="form-help">Die Getränkepauschale und die Proberaumpauschale werden für Tagesgäste automatisch pro Anwesenheitstag berechnet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Allergien und Essensvorlieben (für beide Gasttypen) -->
|
||||
<div class="form-group">
|
||||
<label>Allergien, Essensvorlieben oder religiöse Einschränkungen:</label>
|
||||
<input type="text" name="allergies" placeholder="z.B. Vegetarier, Vegan, Glutenunverträglichkeit, etc.">
|
||||
</div>
|
||||
|
||||
<!-- 5. Sonstige Hinweise -->
|
||||
<div class="form-group">
|
||||
<label for="other_notes">Sonstiges:</label>
|
||||
<textarea id="other_notes" name="other_notes" rows="5" maxlength="5000" placeholder="Weitere Hinweise oder Wünsche"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit">Registrieren</button>
|
||||
|
||||
</form>
|
||||
|
||||
<script src="./scripts/functions.js?v=20260918-2"></script>
|
||||
<script src="./scripts/security.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
-- CAT production update: room-planning settings
|
||||
-- Generated from the comparison of /home/raine/cat/cat_current_2026-09-14.zip
|
||||
-- with /var/www/html/cat.
|
||||
-- This migration does not alter participant, booking, admin, or price data.
|
||||
|
||||
USE cat;
|
||||
|
||||
INSERT INTO pricing_settings (setting_key, price) VALUES
|
||||
('room_count_single', 0.00),
|
||||
('room_count_double', 0.00),
|
||||
('room_count_multi', 0.00)
|
||||
ON DUPLICATE KEY UPDATE setting_key = VALUES(setting_key);
|
||||
Binary file not shown.
+119
@@ -0,0 +1,119 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700;900&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #f7f7f7;
|
||||
--surface: #ffffff;
|
||||
--text: #131313;
|
||||
--muted: #7c7c7c;
|
||||
--border: #dcdcdc;
|
||||
--accent: #008f95;
|
||||
--accent-soft: #c8f0ee;
|
||||
--focus: #006d73;
|
||||
--danger: #a51d00;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 32px;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-family: Roboto, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.admin-header,
|
||||
.admin-content {
|
||||
width: min(100%, 1600px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
h1, h2 { margin: 0; line-height: 1.1; }
|
||||
h1 { font-size: clamp(2rem, 5vw, 3.5rem); font-weight: 900; letter-spacing: -0.04em; }
|
||||
h2 { margin-bottom: 24px; font-size: 1.6rem; font-weight: 700; }
|
||||
.eyebrow { margin: 0 0 8px; color: var(--accent); font-size: .75rem; font-weight: 900; letter-spacing: .12em; text-transform: uppercase; }
|
||||
|
||||
.panel,
|
||||
.login-card {
|
||||
padding: 28px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 8px 24px rgba(19, 19, 19, .08);
|
||||
}
|
||||
|
||||
.panel { margin-bottom: 24px; }
|
||||
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
|
||||
.count { display: inline-block; min-width: 40px; padding: 5px 10px; color: #fff; background: var(--accent); text-align: center; font-weight: 700; }
|
||||
.export-button { display: inline-block; margin-left: 8px; padding: 8px 12px; text-decoration: none; }
|
||||
.table-wrapper { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
|
||||
th, td { padding: 13px 12px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
|
||||
th { color: var(--muted); font-size: .75rem; letter-spacing: .04em; text-transform: uppercase; }
|
||||
td { min-width: 120px; }
|
||||
.paid-form { margin: 0; }
|
||||
.manual-price-form { display: grid; gap: 6px; min-width: 150px; margin-top: 8px; }
|
||||
.manual-price-form .checkbox-label { display: flex; align-items: center; gap: 7px; margin: 0; cursor: pointer; font-size: .8rem; }
|
||||
.manual-price-form .checkbox-label input[type="checkbox"] { width: 18px; min-height: 18px; margin: 0; accent-color: var(--accent); }
|
||||
.manual-price-input { display: grid; gap: 6px; }
|
||||
.manual-price-input[hidden] { display: none; }
|
||||
.manual-price-form input[type="number"] { min-height: 36px; padding: 6px 8px; font-size: .85rem; }
|
||||
.manual-price-form button { min-height: 34px; padding: 6px 10px; font-size: .8rem; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
.paid-form input[type="checkbox"] { width: 20px; height: 20px; accent-color: var(--accent); cursor: pointer; }
|
||||
a { color: var(--text); }
|
||||
a:hover { color: var(--accent); }
|
||||
|
||||
.admin-grid { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(280px, .7fr); gap: 24px; }
|
||||
.price-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
|
||||
.price-grid h3 { margin: 0 0 16px; font-size: 1.1rem; }
|
||||
.price-grid input { margin-bottom: 12px; }
|
||||
.price-group { padding: 16px; border: 1px solid var(--border); }
|
||||
.price-item { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 10px; margin-bottom: 16px; }
|
||||
.price-item:last-child { margin-bottom: 0; }
|
||||
.price-item strong { grid-column: 1 / -1; }
|
||||
.price-item input { margin-bottom: 0; }
|
||||
.price-grid button { grid-column: 1 / -1; justify-self: start; }
|
||||
.user-list { display: grid; gap: 14px; }
|
||||
.user-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--border); }
|
||||
.user-actions, .user-actions form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
|
||||
label { display: block; margin: 0 0 7px; font-size: .875rem; font-weight: 700; }
|
||||
input { width: 100%; min-height: 44px; padding: 10px 12px; color: var(--text); background: var(--surface); border: 1px solid var(--muted); border-radius: 0; font: inherit; }
|
||||
.user-actions input { width: 180px; min-height: 38px; }
|
||||
input:focus, button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.stacked-form { display: grid; gap: 10px; }
|
||||
.stacked-form label:not(:first-of-type) { margin-top: 8px; }
|
||||
textarea { width: 100%; padding: 10px 12px; color: var(--text); background: var(--surface); border: 1px solid var(--muted); border-radius: 0; font: inherit; resize: vertical; }
|
||||
.bulk-email-preview { margin-top: 24px; padding: 16px; background: #f3fbfb; border: 1px solid var(--accent); }
|
||||
.bulk-email-preview h3 { margin: 0 0 8px; }
|
||||
.bulk-email-preview pre { max-height: 260px; margin: 12px 0 16px; padding: 12px; overflow: auto; white-space: pre-wrap; font: inherit; background: var(--surface); border: 1px solid var(--border); }
|
||||
|
||||
button { min-height: 42px; padding: 9px 16px; color: var(--text); background: var(--accent); border: 2px solid var(--accent); border-radius: 0; cursor: pointer; font: inherit; font-weight: 700; }
|
||||
button:hover { color: #fff; background: var(--text); border-color: var(--text); }
|
||||
.button-secondary { color: var(--text); background: var(--surface); border-color: var(--text); }
|
||||
.button-danger { background: var(--danger); border-color: var(--danger); font-size: .85rem; }
|
||||
.message { margin: 0 0 24px; padding: 14px 16px; background: var(--accent-soft); border-left: 4px solid var(--accent); }
|
||||
.message.error { margin-bottom: 20px; }
|
||||
|
||||
.login-card { width: min(100%, 440px); margin: 10vh auto 0; }
|
||||
.login-card h1 { margin-bottom: 24px; }
|
||||
.login-card form { display: grid; gap: 10px; }
|
||||
.login-card label:not(:first-of-type) { margin-top: 8px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
body { padding: 20px 12px; }
|
||||
.admin-grid { grid-template-columns: 1fr; }
|
||||
.price-grid { grid-template-columns: 1fr; }
|
||||
.price-grid button { grid-column: auto; }
|
||||
|
||||
.user-row { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700;900&display=swap');
|
||||
|
||||
:root {
|
||||
--theater-bg: #f7f7f7;
|
||||
--theater-surface: #ffffff;
|
||||
--theater-text: #131313;
|
||||
--theater-muted: #7c7c7c;
|
||||
--theater-border: #dcdcdc;
|
||||
--theater-accent: #008f95;
|
||||
--theater-accent-soft: #c8f0ee;
|
||||
--theater-focus: #006d73;
|
||||
--theater-radius: 0;
|
||||
--theater-shadow: 0 8px 24px rgba(19, 19, 19, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 48px 20px;
|
||||
color: var(--theater-text);
|
||||
background: var(--theater-bg);
|
||||
font-family: Roboto, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
h1 {
|
||||
width: min(100%, 760px);
|
||||
margin: 0 auto 32px;
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
form {
|
||||
width: min(100%, 760px);
|
||||
margin: 0 auto;
|
||||
padding: clamp(24px, 5vw, 48px);
|
||||
background: var(--theater-surface);
|
||||
box-shadow: var(--theater-shadow);
|
||||
}
|
||||
|
||||
.success-card {
|
||||
width: min(100%, 760px);
|
||||
margin: 0 auto;
|
||||
padding: clamp(24px, 5vw, 48px);
|
||||
background: var(--theater-surface);
|
||||
box-shadow: var(--theater-shadow);
|
||||
}
|
||||
|
||||
.credentials {
|
||||
margin: 28px 0;
|
||||
padding: 20px;
|
||||
background: var(--theater-bg);
|
||||
border-left: 4px solid var(--theater-accent);
|
||||
}
|
||||
|
||||
.credentials dt {
|
||||
color: var(--theater-muted);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.credentials dd {
|
||||
margin: 0 0 16px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.credentials dd:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.credentials code {
|
||||
color: var(--theater-accent);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.success-card a {
|
||||
color: var(--theater-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 0 0 24px;
|
||||
padding: 14px 16px;
|
||||
background: var(--theater-accent-soft);
|
||||
border-left: 4px solid var(--theater-accent);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
padding: 0 0 24px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--theater-border);
|
||||
}
|
||||
|
||||
.form-group:last-of-type {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 0 0 8px;
|
||||
color: var(--theater-text);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
accent-color: var(--theater-accent);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
select,
|
||||
textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
margin-bottom: 18px;
|
||||
padding: 12px 14px;
|
||||
color: var(--theater-text);
|
||||
background: var(--theater-surface);
|
||||
border: 1px solid var(--theater-muted);
|
||||
border-radius: var(--theater-radius);
|
||||
font: inherit;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
input[type="text"]:last-child,
|
||||
input[type="email"]:last-child,
|
||||
select:last-child,
|
||||
textarea:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--theater-muted);
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="email"]:focus,
|
||||
select:focus,
|
||||
textarea:focus,
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--theater-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="email"]:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--theater-accent);
|
||||
box-shadow: 0 0 0 1px var(--theater-accent);
|
||||
}
|
||||
|
||||
#room_details {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
#regular_guest_options,
|
||||
#day_guest_options {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
#roommate_info {
|
||||
margin-top: 18px;
|
||||
padding-left: 16px;
|
||||
border-left: 4px solid var(--theater-accent-soft);
|
||||
}
|
||||
|
||||
.meal-day {
|
||||
margin: 0 0 16px;
|
||||
padding: 12px 16px 4px;
|
||||
border: 1px solid var(--theater-border);
|
||||
}
|
||||
|
||||
.meal-day legend {
|
||||
padding: 0 8px;
|
||||
color: var(--theater-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
color: var(--theater-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 20px;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.checkbox-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0 6px 0 0;
|
||||
accent-color: var(--theater-accent);
|
||||
vertical-align: -3px;
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
display: inline-flex;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 24px;
|
||||
color: var(--theater-text);
|
||||
background: var(--theater-accent);
|
||||
border: 2px solid var(--theater-accent);
|
||||
border-radius: var(--theater-radius);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
button[type="submit"]:hover {
|
||||
background: var(--theater-text);
|
||||
border-color: var(--theater-text);
|
||||
}
|
||||
|
||||
button[type="submit"]:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
padding: 28px 12px;
|
||||
}
|
||||
|
||||
form {
|
||||
padding: 24px 18px;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700;900&display=swap');
|
||||
|
||||
:root {
|
||||
--background: #f7f7f7;
|
||||
--surface: #ffffff;
|
||||
--text: #131313;
|
||||
--muted: #7c7c7c;
|
||||
--accent: #008f95;
|
||||
--accent-soft: #c8f0ee;
|
||||
--focus: #006d73;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
place-items: center;
|
||||
color: var(--text);
|
||||
background: var(--background);
|
||||
font-family: Roboto, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.start-page {
|
||||
width: min(100%, 640px);
|
||||
padding: clamp(32px, 8vw, 72px);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 8px 24px rgba(19, 19, 19, 0.08);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.5rem, 8vw, 5rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 20px 0 36px;
|
||||
color: var(--muted);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.main-actions {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
min-height: 52px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 13px 20px;
|
||||
color: var(--text);
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.action-button:hover:not(:disabled) {
|
||||
color: #ffffff;
|
||||
background: var(--text);
|
||||
border-color: var(--text);
|
||||
}
|
||||
|
||||
.action-button:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.action-button:disabled {
|
||||
color: var(--muted);
|
||||
background: #eeeeee;
|
||||
border-color: #eeeeee;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-button-secondary {
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
border-color: var(--text);
|
||||
}
|
||||
|
||||
.action-button-secondary:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Not found</title></head><body></body></html>
|
||||
@@ -0,0 +1,731 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
session_set_cookie_params([
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'use_strict_mode' => true,
|
||||
]);
|
||||
session_start();
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'");
|
||||
|
||||
require_once __DIR__ . '/inc/db.inc.php';
|
||||
require_once __DIR__ . '/inc/mail.inc.php';
|
||||
|
||||
function postString(string $key): string
|
||||
{
|
||||
$value = $_POST[$key] ?? '';
|
||||
|
||||
return is_string($value) ? trim($value) : '';
|
||||
}
|
||||
|
||||
function postStringList(string $key): array
|
||||
{
|
||||
$value = $_POST[$key] ?? [];
|
||||
$values = is_array($value) ? $value : [$value];
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map(
|
||||
static fn ($item): string => is_string($item) ? trim($item) : '',
|
||||
$values
|
||||
),
|
||||
static fn (string $item): bool => $item !== ''
|
||||
));
|
||||
}
|
||||
|
||||
function postMealSelectionsByDay(): array
|
||||
{
|
||||
$value = $_POST['meals'] ?? [];
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($value as $day => $meals) {
|
||||
if (!is_array($meals)) {
|
||||
continue;
|
||||
}
|
||||
$result[(string) $day] = array_values(array_filter(
|
||||
array_map(static fn ($item): string => is_string($item) ? trim($item) : '', $meals),
|
||||
static fn (string $item): bool => $item !== ''
|
||||
));
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
function escapeHtml(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
function earlyDepartureLabel(?string $value): string
|
||||
{
|
||||
return match ($value) {
|
||||
'sat_breakfast' => 'Samstag nach dem Frühstück',
|
||||
'sat_lunch' => 'Samstag nach dem Mittagessen',
|
||||
'sat_coffee' => 'Samstag nach dem Kaffee',
|
||||
'sat_dinner' => 'Samstag nach dem Abendessen',
|
||||
'sun_breakfast' => 'Sonntag nach dem Frühstück',
|
||||
null, '' => 'Keine vorzeitige Abreise',
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateBookingTotal(PDO $pdo, bool $under27, string $guestType, ?string $roomType, array $mealsByDay): float
|
||||
{
|
||||
$ageSuffix = $under27 ? 'under_27' : 'over_27';
|
||||
$keys = [];
|
||||
|
||||
if ($guestType === 'regular_guest' && $roomType !== null) {
|
||||
$keys[] = $roomType . '_' . $ageSuffix;
|
||||
}
|
||||
if ($guestType === 'day_guest') {
|
||||
foreach ($mealsByDay as $meals) {
|
||||
foreach (array_unique(array_merge($meals, ['drinks'])) as $meal) {
|
||||
if (in_array($meal, ['breakfast', 'lunch', 'coffee', 'dinner'], true)) {
|
||||
$keys[] = $meal . '_' . $ageSuffix;
|
||||
} elseif ($meal === 'drinks') {
|
||||
$keys[] = 'drinks';
|
||||
}
|
||||
}
|
||||
$keys[] = 'rehearsal_room';
|
||||
}
|
||||
}
|
||||
if ($keys === []) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$uniqueKeys = array_values(array_unique($keys));
|
||||
$placeholders = implode(',', array_fill(0, count($uniqueKeys), '?'));
|
||||
$statement = $pdo->prepare("SELECT setting_key, price FROM pricing_settings WHERE setting_key IN ($placeholders)");
|
||||
$statement->execute($uniqueKeys);
|
||||
$prices = [];
|
||||
foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$prices[(string) $row['setting_key']] = (float) $row['price'];
|
||||
}
|
||||
|
||||
return round(array_sum(array_map(
|
||||
static fn (string $key): float => $prices[$key] ?? 0.0,
|
||||
$keys
|
||||
)), 2);
|
||||
}
|
||||
|
||||
function createPdo(array $dbConfig): PDO
|
||||
{
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$dbConfig['host'],
|
||||
$dbConfig['port'],
|
||||
$dbConfig['database'],
|
||||
$dbConfig['charset']
|
||||
);
|
||||
|
||||
return new PDO($dsn, $dbConfig['username'], $dbConfig['password'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
}
|
||||
|
||||
function editSmtpResponse($socket): string
|
||||
{
|
||||
$response = '';
|
||||
do {
|
||||
$line = fgets($socket);
|
||||
if ($line === false) {
|
||||
throw new RuntimeException('Keine gültige SMTP-Antwort erhalten.');
|
||||
}
|
||||
$response .= $line;
|
||||
} while (isset($line[3]) && $line[3] === '-');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
function editSmtpCommand($socket, string $command, array $expectedCodes): void
|
||||
{
|
||||
fwrite($socket, $command . "\r\n");
|
||||
$response = editSmtpResponse($socket);
|
||||
if (!in_array((int) substr($response, 0, 3), $expectedCodes, true)) {
|
||||
throw new RuntimeException('SMTP-Befehl wurde abgelehnt.');
|
||||
}
|
||||
}
|
||||
|
||||
function editEncodedHeader(string $value): string
|
||||
{
|
||||
return '=?UTF-8?B?' . base64_encode($value) . '?=';
|
||||
}
|
||||
|
||||
function sendDeletionAdminEmail(array $mailConfig, string $recipient, array $booking): void
|
||||
{
|
||||
$message = 'From: ' . editEncodedHeader($mailConfig['from_name']) . ' <' . $mailConfig['from_address'] . ">\r\n"
|
||||
. 'To: ' . $recipient . "\r\n"
|
||||
. 'Subject: ' . editEncodedHeader('Registrierung gelöscht – Chorwochenende') . "\r\n"
|
||||
. "MIME-Version: 1.0\r\n"
|
||||
. "Content-Type: text/plain; charset=UTF-8\r\n"
|
||||
. "Content-Transfer-Encoding: 8bit\r\n\r\n"
|
||||
. "Eine Teilnehmerregistrierung wurde selbstständig gelöscht.\n\n"
|
||||
. "Name: {$booking['name']}\n"
|
||||
. "E-Mail: {$booking['email']}\n"
|
||||
. "Gasttyp: {$booking['role']}\n"
|
||||
. "Zimmerart: {$booking['accommodation_type']}\n"
|
||||
. "Zimmerpartner-Wunsch: {$booking['roommate_requests']}\n"
|
||||
. "Sonstiges: {$booking['other_notes']}\n";
|
||||
|
||||
$context = stream_context_create([
|
||||
'ssl' => [
|
||||
'peer_name' => $mailConfig['host'],
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
],
|
||||
]);
|
||||
$socket = stream_socket_client(
|
||||
'tcp://' . $mailConfig['host'] . ':' . $mailConfig['port'],
|
||||
$errorNumber,
|
||||
$errorMessage,
|
||||
20,
|
||||
STREAM_CLIENT_CONNECT,
|
||||
$context
|
||||
);
|
||||
if ($socket === false) {
|
||||
throw new RuntimeException('SMTP-Server nicht erreichbar.');
|
||||
}
|
||||
stream_set_timeout($socket, 20);
|
||||
|
||||
try {
|
||||
if ((int) substr(editSmtpResponse($socket), 0, 3) !== 220) {
|
||||
throw new RuntimeException('SMTP-Verbindung wurde abgelehnt.');
|
||||
}
|
||||
editSmtpCommand($socket, 'EHLO localhost', [250]);
|
||||
editSmtpCommand($socket, 'STARTTLS', [220]);
|
||||
if (!stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||
throw new RuntimeException('STARTTLS konnte nicht aktiviert werden.');
|
||||
}
|
||||
editSmtpCommand($socket, 'EHLO localhost', [250]);
|
||||
editSmtpCommand($socket, 'AUTH LOGIN', [334]);
|
||||
editSmtpCommand($socket, base64_encode($mailConfig['username']), [334]);
|
||||
editSmtpCommand($socket, base64_encode($mailConfig['password']), [235]);
|
||||
editSmtpCommand($socket, 'MAIL FROM:<' . $mailConfig['from_address'] . '>', [250]);
|
||||
editSmtpCommand($socket, 'RCPT TO:<' . $recipient . '>', [250, 251]);
|
||||
editSmtpCommand($socket, 'DATA', [354]);
|
||||
fwrite($socket, preg_replace('/^\./m', '..', $message) . "\r\n.\r\n");
|
||||
if ((int) substr(editSmtpResponse($socket), 0, 3) !== 250) {
|
||||
throw new RuntimeException('E-Mail konnte nicht angenommen werden.');
|
||||
}
|
||||
fwrite($socket, "QUIT\r\n");
|
||||
} finally {
|
||||
fclose($socket);
|
||||
}
|
||||
}
|
||||
|
||||
function redirectToEdit(): never
|
||||
{
|
||||
header('Location: edit_registration.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
function editCsrfToken(): string
|
||||
{
|
||||
if (!isset($_SESSION['edit_csrf'])) {
|
||||
$_SESSION['edit_csrf'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
return $_SESSION['edit_csrf'];
|
||||
}
|
||||
|
||||
function requireEditCsrf(): void
|
||||
{
|
||||
$submitted = postString('csrf_token');
|
||||
$stored = $_SESSION['edit_csrf'] ?? '';
|
||||
|
||||
if ($stored === '' || !hash_equals($stored, $submitted)) {
|
||||
http_response_code(403);
|
||||
exit('Ungültige Anfrage.');
|
||||
}
|
||||
}
|
||||
|
||||
function validateBookingInput(): array
|
||||
{
|
||||
$data = [
|
||||
'name' => postString('name'),
|
||||
'email' => postString('email'),
|
||||
'guest_type' => postString('guest_type'),
|
||||
'room_type' => postString('room_type'),
|
||||
'roommates' => postString('roommates'),
|
||||
'early_departure' => postString('early_departure'),
|
||||
'allergies' => postString('allergies'),
|
||||
'other_notes' => postString('other_notes'),
|
||||
'days' => postStringList('days'),
|
||||
'meals_by_day' => postMealSelectionsByDay(),
|
||||
];
|
||||
$errors = [];
|
||||
|
||||
if ($data['name'] === '' || mb_strlen($data['name']) > 255) {
|
||||
$errors[] = 'Bitte einen gültigen Namen eingeben.';
|
||||
}
|
||||
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL) || mb_strlen($data['email']) > 255) {
|
||||
$errors[] = 'Bitte eine gültige E-Mail-Adresse eingeben.';
|
||||
}
|
||||
if (!in_array($data['guest_type'], ['day_guest', 'regular_guest'], true)) {
|
||||
$errors[] = 'Bitte einen gültigen Gasttyp auswählen.';
|
||||
}
|
||||
if ($data['guest_type'] === 'regular_guest'
|
||||
&& !in_array($data['room_type'], ['single', 'double', 'multi'], true)) {
|
||||
$errors[] = 'Bitte eine gültige Zimmerart auswählen.';
|
||||
}
|
||||
$allowedEarlyDepartures = ['sat_breakfast', 'sat_lunch', 'sat_coffee', 'sat_dinner', 'sun_breakfast'];
|
||||
if ($data['early_departure'] !== ''
|
||||
&& ($data['guest_type'] !== 'regular_guest' || !in_array($data['early_departure'], $allowedEarlyDepartures, true))) {
|
||||
$errors[] = 'Bitte einen gültigen Abreisezeitpunkt auswählen.';
|
||||
}
|
||||
if (mb_strlen($data['other_notes']) > 5000) {
|
||||
$errors[] = 'Das Feld Sonstiges darf höchstens 5000 Zeichen enthalten.';
|
||||
}
|
||||
if (mb_strlen($data['roommates']) > 1000 || mb_strlen($data['allergies']) > 1000) {
|
||||
$errors[] = 'Zimmerwunsch und Allergieangaben dürfen höchstens 1000 Zeichen enthalten.';
|
||||
}
|
||||
|
||||
$allowedDays = ['1', '2', '3'];
|
||||
$allowedMealsByDay = [
|
||||
'1' => ['dinner'],
|
||||
'2' => ['breakfast', 'lunch', 'coffee', 'dinner'],
|
||||
'3' => ['breakfast', 'lunch'],
|
||||
];
|
||||
$data['days'] = array_values(array_unique($data['days']));
|
||||
foreach ($data['meals_by_day'] as $day => $dayMeals) {
|
||||
$data['meals_by_day'][$day] = array_values(array_unique($dayMeals));
|
||||
}
|
||||
if (count($data['days']) > count($allowedDays) || array_diff($data['days'], $allowedDays) !== []) {
|
||||
$errors[] = 'Bitte nur gültige Anwesenheitstage auswählen.';
|
||||
}
|
||||
if (array_diff(array_keys($data['meals_by_day']), $allowedDays) !== []) {
|
||||
$errors[] = 'Bitte nur gültige Mahlzeitentage auswählen.';
|
||||
}
|
||||
foreach ($data['meals_by_day'] as $day => $dayMeals) {
|
||||
$allowedMeals = $allowedMealsByDay[(string) $day] ?? [];
|
||||
if (array_diff($dayMeals, $allowedMeals) !== []) {
|
||||
$errors[] = 'Bitte nur gültige Mahlzeiten auswählen.';
|
||||
}
|
||||
if (!in_array((string) $day, $data['days'], true) && $dayMeals !== []) {
|
||||
$errors[] = 'Mahlzeiten dürfen nur für ausgewählte Anwesenheitstage angegeben werden.';
|
||||
}
|
||||
}
|
||||
foreach ($data['days'] as $day) {
|
||||
$data['meals_by_day'][$day] ??= [];
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
throw new InvalidArgumentException(implode(' ', $errors));
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
$pdo = createPdo($dbConfig);
|
||||
$message = $_SESSION['edit_message'] ?? '';
|
||||
unset($_SESSION['edit_message']);
|
||||
|
||||
if (postString('action') === 'login') {
|
||||
$email = postString('email');
|
||||
$password = postString('password');
|
||||
$lockedUntil = (int) ($_SESSION['edit_locked_until'] ?? 0);
|
||||
|
||||
if ($lockedUntil > time()) {
|
||||
$message = 'Zu viele Fehlversuche. Bitte später erneut versuchen.';
|
||||
} else {
|
||||
$statement = $pdo->prepare(
|
||||
'SELECT p.id AS participant_id, b.id AS booking_id, p.password_hash
|
||||
FROM participants p
|
||||
INNER JOIN bookings b ON b.participant_id = p.id
|
||||
WHERE p.email = :email
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute([':email' => $email]);
|
||||
$participant = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($participant !== false && password_verify($password, $participant['password_hash'])) {
|
||||
unset($_SESSION['edit_login_failures'], $_SESSION['edit_locked_until']);
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['edit_participant_id'] = $participant['participant_id'];
|
||||
$_SESSION['edit_booking_id'] = (int) $participant['booking_id'];
|
||||
$_SESSION['edit_csrf'] = bin2hex(random_bytes(32));
|
||||
$_SESSION['edit_last_activity'] = time();
|
||||
redirectToEdit();
|
||||
}
|
||||
|
||||
$failures = (int) ($_SESSION['edit_login_failures'] ?? 0) + 1;
|
||||
$_SESSION['edit_login_failures'] = $failures;
|
||||
if ($failures >= 5) {
|
||||
$_SESSION['edit_locked_until'] = time() + 300;
|
||||
}
|
||||
usleep(500000);
|
||||
$message = 'E-Mail-Adresse oder Passwort ist ungültig.';
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($_SESSION['edit_participant_id'], $_SESSION['edit_booking_id'])) {
|
||||
$lastActivity = (int) ($_SESSION['edit_last_activity'] ?? 0);
|
||||
if ($lastActivity > 0 && $lastActivity < time() - 1800) {
|
||||
unset($_SESSION['edit_participant_id'], $_SESSION['edit_booking_id'], $_SESSION['edit_csrf']);
|
||||
session_regenerate_id(true);
|
||||
} else {
|
||||
$_SESSION['edit_last_activity'] = time();
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($_SESSION['edit_participant_id'], $_SESSION['edit_booking_id'])) {
|
||||
?><!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Registrierung ändern</title>
|
||||
<link rel="stylesheet" href="./css/booking.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="success-card">
|
||||
<h1>Registrierung ändern</h1>
|
||||
<p>Gib die E-Mail-Adresse und das Passwort ein, die du nach der Registrierung erhalten hast.</p>
|
||||
<?php if ($message !== ''): ?>
|
||||
<p class="message error"><?= escapeHtml($message) ?></p>
|
||||
<?php endif; ?>
|
||||
<form method="post" autocomplete="off">
|
||||
<input type="hidden" name="action" value="login">
|
||||
<div class="form-group">
|
||||
<label for="email">E-Mail-Adresse:</label>
|
||||
<input type="email" id="email" name="email" required autocomplete="username">
|
||||
<label for="password">Passwort:</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit">Registrierung laden</button>
|
||||
</form>
|
||||
<p><a href="index.php">Zur Startseite</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html><?php
|
||||
exit;
|
||||
}
|
||||
|
||||
$participantId = (string) $_SESSION['edit_participant_id'];
|
||||
$bookingId = (int) $_SESSION['edit_booking_id'];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
requireEditCsrf();
|
||||
$action = postString('action');
|
||||
|
||||
try {
|
||||
if ($action === 'logout') {
|
||||
unset($_SESSION['edit_participant_id'], $_SESSION['edit_booking_id'], $_SESSION['edit_csrf']);
|
||||
setcookie(session_name(), '', [
|
||||
'expires' => time() - 42000,
|
||||
'path' => '/',
|
||||
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
session_destroy();
|
||||
redirectToEdit();
|
||||
}
|
||||
|
||||
if ($action === 'delete_booking') {
|
||||
$deletedStatement = $pdo->prepare(
|
||||
'SELECT p.name, p.email, p.role, b.accommodation_type,
|
||||
b.roommate_requests, b.early_departure, b.other_notes
|
||||
FROM participants p
|
||||
INNER JOIN bookings b ON b.participant_id = p.id
|
||||
WHERE p.id = :participant_id AND b.id = :booking_id'
|
||||
);
|
||||
$deletedStatement->execute([
|
||||
':participant_id' => $participantId,
|
||||
':booking_id' => $bookingId,
|
||||
]);
|
||||
$deletedBooking = $deletedStatement->fetch(PDO::FETCH_ASSOC);
|
||||
if ($deletedBooking === false) {
|
||||
throw new InvalidArgumentException('Registrierung nicht gefunden.');
|
||||
}
|
||||
$deletedBooking['accommodation_type'] = match ($deletedBooking['accommodation_type']) {
|
||||
'single' => 'Einzelzimmer',
|
||||
'double' => 'Doppelzimmer',
|
||||
'multi' => 'Mehrbettzimmer',
|
||||
default => '–',
|
||||
};
|
||||
$deletedBooking['roommate_requests'] = $deletedBooking['roommate_requests'] ?: '–';
|
||||
$deletedBooking['other_notes'] = $deletedBooking['other_notes'] ?: '–';
|
||||
|
||||
$pdo->beginTransaction();
|
||||
$deleteBooking = $pdo->prepare(
|
||||
'DELETE FROM bookings WHERE id = :booking_id AND participant_id = :participant_id'
|
||||
);
|
||||
$deleteBooking->execute([
|
||||
':booking_id' => $bookingId,
|
||||
':participant_id' => $participantId,
|
||||
]);
|
||||
$deleteParticipant = $pdo->prepare('DELETE FROM participants WHERE id = :participant_id');
|
||||
$deleteParticipant->execute([':participant_id' => $participantId]);
|
||||
$pdo->commit();
|
||||
|
||||
$adminEmails = $pdo->query(
|
||||
"SELECT email FROM admin_users WHERE is_active = 1 AND email <> ''"
|
||||
)->fetchAll(PDO::FETCH_COLUMN);
|
||||
foreach ($adminEmails as $adminEmail) {
|
||||
try {
|
||||
sendDeletionAdminEmail($mailConfig, (string) $adminEmail, $deletedBooking);
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Admin-Benachrichtigung über Löschung konnte nicht versendet werden: ' . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
unset($_SESSION['edit_participant_id'], $_SESSION['edit_booking_id'], $_SESSION['edit_csrf']);
|
||||
$_SESSION['edit_message'] = 'Die Registrierung wurde gelöscht.';
|
||||
redirectToEdit();
|
||||
}
|
||||
|
||||
if ($action === 'update_booking') {
|
||||
$data = validateBookingInput();
|
||||
$role = $data['guest_type'] === 'day_guest' ? 'Tagesgast' : 'Dauergast';
|
||||
$bookingType = $data['guest_type'] === 'day_guest' ? 'Day' : 'Stay';
|
||||
$accommodationType = $data['guest_type'] === 'regular_guest' ? $data['room_type'] : null;
|
||||
$roommateRequest = $data['guest_type'] === 'regular_guest' && $data['roommates'] !== ''
|
||||
? $data['roommates']
|
||||
: null;
|
||||
$allowedEarlyDepartures = ['sat_breakfast', 'sat_lunch', 'sat_coffee', 'sat_dinner', 'sun_breakfast'];
|
||||
$earlyDeparture = $data['guest_type'] === 'regular_guest'
|
||||
&& in_array($data['early_departure'], $allowedEarlyDepartures, true)
|
||||
? $data['early_departure']
|
||||
: null;
|
||||
$mealPreferences = $data['guest_type'] === 'day_guest'
|
||||
? json_encode(['days' => $data['days'], 'meals_by_day' => $data['meals_by_day']], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)
|
||||
: null;
|
||||
|
||||
$pdo->beginTransaction();
|
||||
$existingPriceStatement = $pdo->prepare(
|
||||
'SELECT manual_total_price FROM bookings WHERE id = :booking_id AND participant_id = :participant_id FOR UPDATE'
|
||||
);
|
||||
$existingPriceStatement->execute([
|
||||
':booking_id' => $bookingId,
|
||||
':participant_id' => $participantId,
|
||||
]);
|
||||
$existingManualPrice = $existingPriceStatement->fetchColumn();
|
||||
if ($existingManualPrice === false) {
|
||||
throw new InvalidArgumentException('Registrierung nicht gefunden.');
|
||||
}
|
||||
|
||||
$participantStatement = $pdo->prepare(
|
||||
'UPDATE participants
|
||||
SET name = :name, email = :email, allergies = :allergies, role = :role, under_27 = :under_27
|
||||
WHERE id = :participant_id'
|
||||
);
|
||||
$participantStatement->execute([
|
||||
':name' => $data['name'],
|
||||
':email' => $data['email'],
|
||||
':allergies' => $data['allergies'] !== '' ? $data['allergies'] : null,
|
||||
':role' => $role,
|
||||
':under_27' => isset($_POST['under_27']) && $_POST['under_27'] === '1' ? 1 : 0,
|
||||
':participant_id' => $participantId,
|
||||
]);
|
||||
|
||||
$totalPrice = calculateBookingTotal(
|
||||
$pdo,
|
||||
isset($_POST['under_27']) && $_POST['under_27'] === '1',
|
||||
$data['guest_type'],
|
||||
$accommodationType,
|
||||
$data['meals_by_day']
|
||||
);
|
||||
$bookingStatement = $pdo->prepare(
|
||||
'UPDATE bookings
|
||||
SET type = :type, accommodation_type = :accommodation_type,
|
||||
meal_preferences = :meal_preferences, roommate_requests = :roommate_requests,
|
||||
early_departure = :early_departure,
|
||||
other_notes = :other_notes, total_price = :total_price
|
||||
WHERE id = :booking_id AND participant_id = :participant_id'
|
||||
);
|
||||
$bookingStatement->execute([
|
||||
':type' => $bookingType,
|
||||
':accommodation_type' => $accommodationType,
|
||||
':meal_preferences' => $mealPreferences,
|
||||
':roommate_requests' => $roommateRequest,
|
||||
':early_departure' => $earlyDeparture,
|
||||
':other_notes' => $data['other_notes'] !== '' ? $data['other_notes'] : null,
|
||||
':total_price' => $existingManualPrice !== null
|
||||
? number_format((float) $existingManualPrice, 2, '.', '')
|
||||
: number_format($totalPrice, 2, '.', ''),
|
||||
':booking_id' => $bookingId,
|
||||
':participant_id' => $participantId,
|
||||
]);
|
||||
$pdo->commit();
|
||||
$_SESSION['edit_message'] = 'Die Registrierung wurde aktualisiert.';
|
||||
redirectToEdit();
|
||||
}
|
||||
} catch (InvalidArgumentException | JsonException $exception) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
$message = $exception->getMessage();
|
||||
} catch (PDOException $exception) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
error_log($exception->getMessage());
|
||||
$message = 'Die Änderung konnte nicht gespeichert werden. Prüfe bitte deine Eingaben.';
|
||||
}
|
||||
}
|
||||
|
||||
$statement = $pdo->prepare(
|
||||
'SELECT p.name, p.email, p.role, p.under_27, p.allergies,
|
||||
b.type, b.accommodation_type, b.meal_preferences,
|
||||
b.roommate_requests, b.early_departure, b.other_notes
|
||||
FROM participants p
|
||||
INNER JOIN bookings b ON b.participant_id = p.id
|
||||
WHERE p.id = :participant_id AND b.id = :booking_id'
|
||||
);
|
||||
$statement->execute([':participant_id' => $participantId, ':booking_id' => $bookingId]);
|
||||
$booking = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($booking === false) {
|
||||
unset($_SESSION['edit_participant_id'], $_SESSION['edit_booking_id'], $_SESSION['edit_csrf']);
|
||||
http_response_code(404);
|
||||
exit('Registrierung nicht gefunden.');
|
||||
}
|
||||
|
||||
$mealData = $booking['meal_preferences'] !== null
|
||||
? json_decode($booking['meal_preferences'], true)
|
||||
: [];
|
||||
$mealData = is_array($mealData) ? $mealData : [];
|
||||
$selectedMealsByDay = is_array($mealData['meals_by_day'] ?? null) ? $mealData['meals_by_day'] : [];
|
||||
if ($selectedMealsByDay === [] && is_array($mealData['meals'] ?? null)) {
|
||||
foreach ($mealData['days'] ?? [] as $day) {
|
||||
$selectedMealsByDay[(string) $day] = $mealData['meals'];
|
||||
}
|
||||
}
|
||||
$guestType = $booking['type'] === 'Day' ? 'day_guest' : 'regular_guest';
|
||||
$csrf = editCsrfToken();
|
||||
?><!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Registrierung ändern</title>
|
||||
<link rel="stylesheet" href="./css/booking.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="success-card">
|
||||
<h1>Registrierung ändern</h1>
|
||||
<?php if ($message !== ''): ?>
|
||||
<p class="message"><?= escapeHtml($message) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="update_booking">
|
||||
<input type="hidden" name="csrf_token" value="<?= escapeHtml($csrf) ?>">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Name des Gastes:</label>
|
||||
<input type="text" id="name" name="name" maxlength="255" value="<?= escapeHtml($booking['name']) ?>" required>
|
||||
<label for="email">E-Mail-Adresse:</label>
|
||||
<input type="email" id="email" name="email" maxlength="255" value="<?= escapeHtml($booking['email']) ?>" required>
|
||||
<label class="checkbox-label" for="under_27">
|
||||
<input type="checkbox" id="under_27" name="under_27" value="1" <?= (int) $booking['under_27'] === 1 ? 'checked' : '' ?>>
|
||||
Unter 27
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="guest_type">Gasttyp:</label>
|
||||
<select id="guest_type" name="guest_type" required>
|
||||
<option value="">Bitte auswählen</option>
|
||||
<option value="day_guest" <?= $guestType === 'day_guest' ? 'selected' : '' ?>>Tagesgast</option>
|
||||
<option value="regular_guest" <?= $guestType === 'regular_guest' ? 'selected' : '' ?>>Dauergast</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="room_details">
|
||||
<div id="regular_guest_options">
|
||||
<label for="room_type">Zimmerart:</label>
|
||||
<select id="room_type" name="room_type">
|
||||
<option value="single" <?= $booking['accommodation_type'] === 'single' ? 'selected' : '' ?>>Einzelzimmer</option>
|
||||
<option value="double" <?= $booking['accommodation_type'] === 'double' ? 'selected' : '' ?>>Doppelzimmer</option>
|
||||
<option value="multi" <?= $booking['accommodation_type'] === 'multi' ? 'selected' : '' ?>>Mehrbettzimmer</option>
|
||||
</select>
|
||||
<div id="roommate_info" style="display: none;">
|
||||
<label for="roommates">Mit wem möchtest du das Zimmer teilen?</label>
|
||||
<input type="text" id="roommates" name="roommates" value="<?= escapeHtml((string) ($booking['roommate_requests'] ?? '')) ?>" placeholder="Name des Mitbewohners">
|
||||
</div>
|
||||
<label class="checkbox-label" for="early_departure_enabled">
|
||||
<input type="checkbox" id="early_departure_enabled" name="early_departure_enabled" value="1" <?= !empty($booking['early_departure']) ? 'checked' : '' ?>>
|
||||
Ich reise vorzeitig ab.
|
||||
</label>
|
||||
<div id="early_departure_options" style="display: none;">
|
||||
<label>Abreisezeitpunkt:</label>
|
||||
<?php $earlyDepartureOptions = [
|
||||
'sat_breakfast' => 'Samstag nach dem Frühstück',
|
||||
'sat_lunch' => 'Samstag nach dem Mittagessen',
|
||||
'sat_coffee' => 'Samstag nach dem Kaffee',
|
||||
'sat_dinner' => 'Samstag nach dem Abendessen',
|
||||
'sun_breakfast' => 'Sonntag nach dem Frühstück',
|
||||
]; ?>
|
||||
<div class="checkbox-group">
|
||||
<?php foreach ($earlyDepartureOptions as $value => $label): ?>
|
||||
<label><input type="radio" name="early_departure" value="<?= $value ?>" <?= $booking['early_departure'] === $value ? 'checked' : '' ?>> <?= $label ?></label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="day_guest_options" style="display: none;">
|
||||
<label>Anwesenheitstage:</label>
|
||||
<div class="checkbox-group">
|
||||
<?php foreach (['1' => 'Fr.', '2' => 'Sa.', '3' => 'So.'] as $value => $label): ?>
|
||||
<label><input type="checkbox" name="days[]" value="<?= $value ?>" <?= in_array($value, $mealData['days'] ?? [], true) ? 'checked' : '' ?>> <?= $label ?></label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<label>Gewünschte Mahlzeiten je Anwesenheitstag:</label>
|
||||
<?php $mealOptionsByDay = [
|
||||
'1' => ['dinner' => 'Abendessen'],
|
||||
'2' => ['breakfast' => 'Frühstück', 'lunch' => 'Mittagessen', 'coffee' => 'Kaffee', 'dinner' => 'Abendessen'],
|
||||
'3' => ['breakfast' => 'Frühstück', 'lunch' => 'Mittagessen'],
|
||||
]; ?>
|
||||
<?php foreach (['1' => 'Fr.', '2' => 'Sa.', '3' => 'So.'] as $dayValue => $dayLabel): ?>
|
||||
<fieldset class="meal-day">
|
||||
<legend><?= escapeHtml($dayLabel) ?></legend>
|
||||
<div class="checkbox-group">
|
||||
<?php foreach ($mealOptionsByDay[$dayValue] as $value => $label): ?>
|
||||
<label><input type="checkbox" name="meals[<?= $dayValue ?>][]" value="<?= $value ?>" <?= in_array($value, $selectedMealsByDay[$dayValue] ?? [], true) ? 'checked' : '' ?>> <?= $label ?></label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</fieldset>
|
||||
<?php endforeach; ?>
|
||||
<p class="form-help">Die Getränkepauschale und die Proberaumpauschale werden für Tagesgäste automatisch pro Anwesenheitstag berechnet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="allergies">Allergien, Essensvorlieben oder religiöse Einschränkungen:</label>
|
||||
<input type="text" id="allergies" name="allergies" value="<?= escapeHtml((string) ($booking['allergies'] ?? '')) ?>" placeholder="z.B. Vegetarier, Vegan, Glutenunverträglichkeit, etc.">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="other_notes">Sonstiges:</label>
|
||||
<textarea id="other_notes" name="other_notes" rows="5" maxlength="5000" placeholder="Weitere Hinweise oder Wünsche"><?= escapeHtml((string) ($booking['other_notes'] ?? '')) ?></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit">Änderungen speichern</button>
|
||||
</form>
|
||||
|
||||
<form method="post" data-confirm="Möchtest du die Buchung wirklich löschen?" style="margin-top: 16px;">
|
||||
<input type="hidden" name="action" value="delete_booking">
|
||||
<input type="hidden" name="csrf_token" value="<?= escapeHtml($csrf) ?>">
|
||||
<button class="button-danger" type="submit">Gesamte Buchung löschen</button>
|
||||
</form>
|
||||
<p><a href="index.php">Zur Startseite</a></p>
|
||||
</main>
|
||||
<script src="./scripts/functions.js?v=20260918-2"></script>
|
||||
<script src="./scripts/security.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
session_set_cookie_params([
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'use_strict_mode' => true,
|
||||
]);
|
||||
session_start();
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
|
||||
header("Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; base-uri 'self'; frame-ancestors 'none'");
|
||||
|
||||
require_once __DIR__ . '/inc/db.inc.php';
|
||||
|
||||
if (!isset($_SESSION['admin_id'])) {
|
||||
http_response_code(403);
|
||||
exit('Zugriff verweigert.');
|
||||
}
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$dbConfig['host'],
|
||||
$dbConfig['port'],
|
||||
$dbConfig['database'],
|
||||
$dbConfig['charset']
|
||||
);
|
||||
$pdo = new PDO($dsn, $dbConfig['username'], $dbConfig['password'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
|
||||
$adminCheck = $pdo->prepare(
|
||||
'SELECT id FROM admin_users WHERE id = :id AND is_active = 1 LIMIT 1'
|
||||
);
|
||||
$adminCheck->execute([':id' => (int) $_SESSION['admin_id']]);
|
||||
if ($adminCheck->fetchColumn() === false) {
|
||||
http_response_code(403);
|
||||
exit('Zugriff verweigert.');
|
||||
}
|
||||
|
||||
function exportAccommodation(?string $value): string
|
||||
{
|
||||
return match ($value) {
|
||||
'single' => 'Einzelzimmer',
|
||||
'double' => 'Doppelzimmer',
|
||||
'multi' => 'Mehrbettzimmer',
|
||||
null, '' => '',
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
function exportDay(mixed $value): string
|
||||
{
|
||||
return match ((string) $value) {
|
||||
'1' => 'Fr.',
|
||||
'2' => 'Sa.',
|
||||
'3' => 'So.',
|
||||
default => (string) $value,
|
||||
};
|
||||
}
|
||||
|
||||
function csvSafe(mixed $value): string
|
||||
{
|
||||
$value = (string) $value;
|
||||
return preg_match('/^[=+\-@]/', $value) === 1 ? "'" . $value : $value;
|
||||
}
|
||||
|
||||
function exportMeal(mixed $value): string
|
||||
{
|
||||
return match ((string) $value) {
|
||||
'breakfast' => 'Frühstück',
|
||||
'lunch' => 'Mittagessen',
|
||||
'coffee' => 'Kaffee',
|
||||
'drinks' => 'Getränkepauschale',
|
||||
'dinner' => 'Abendessen',
|
||||
default => (string) $value,
|
||||
};
|
||||
}
|
||||
|
||||
function exportEarlyDeparture(mixed $value): string
|
||||
{
|
||||
return match ((string) $value) {
|
||||
'sat_breakfast' => 'Samstag nach dem Frühstück',
|
||||
'sat_lunch' => 'Samstag nach dem Mittagessen',
|
||||
'sat_coffee' => 'Samstag nach dem Kaffee',
|
||||
'sat_dinner' => 'Samstag nach dem Abendessen',
|
||||
'sun_breakfast' => 'Sonntag nach dem Frühstück',
|
||||
default => (string) $value,
|
||||
};
|
||||
}
|
||||
|
||||
$rows = $pdo->query(
|
||||
'SELECT p.name, p.email, p.role, p.under_27, p.allergies,
|
||||
b.type, b.accommodation_type, b.meal_preferences, b.is_paid, b.total_price,
|
||||
b.roommate_requests, b.early_departure, b.other_notes
|
||||
FROM bookings b
|
||||
INNER JOIN participants p ON p.id = b.participant_id
|
||||
ORDER BY b.id DESC'
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
header('Content-Type: text/csv; charset=UTF-8');
|
||||
header('Content-Disposition: attachment; filename="registrierungen.csv"');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
|
||||
echo "\xEF\xBB\xBF";
|
||||
$output = fopen('php://output', 'wb');
|
||||
fputcsv($output, [
|
||||
'Name',
|
||||
'E-Mail',
|
||||
'Altersgruppe',
|
||||
'Bezahlt',
|
||||
'Preis',
|
||||
'Gasttyp',
|
||||
'Zimmerart',
|
||||
'Zimmerpartner-Wunsch',
|
||||
'Frühzeitige Abreise',
|
||||
'Anwesenheitstage',
|
||||
'Mahlzeiten',
|
||||
'Allergien / Essensvorlieben',
|
||||
'Sonstiges',
|
||||
], ';', '"', '\\');
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$mealData = $row['meal_preferences'] !== null
|
||||
? json_decode($row['meal_preferences'], true)
|
||||
: null;
|
||||
$daysRaw = is_array($mealData['days'] ?? null) ? $mealData['days'] : [];
|
||||
$mealsByDay = is_array($mealData['meals_by_day'] ?? null) ? $mealData['meals_by_day'] : [];
|
||||
if ($mealsByDay === [] && is_array($mealData['meals'] ?? null)) {
|
||||
foreach ($daysRaw as $day) {
|
||||
$mealsByDay[(string) $day] = $mealData['meals'];
|
||||
}
|
||||
}
|
||||
$days = array_map('exportDay', $daysRaw);
|
||||
$meals = [];
|
||||
foreach ($daysRaw as $day) {
|
||||
$dayMeals = array_map('exportMeal', $mealsByDay[(string) $day] ?? []);
|
||||
$dayMeals[] = 'Getränkepauschale';
|
||||
$dayMeals[] = 'Proberaumpauschale';
|
||||
$meals[] = exportDay($day) . ': ' . implode(', ', $dayMeals);
|
||||
}
|
||||
|
||||
fputcsv($output, [
|
||||
csvSafe($row['name']),
|
||||
csvSafe($row['email']),
|
||||
csvSafe((int) $row['under_27'] === 1 ? 'Unter 27' : 'Ab 27'),
|
||||
csvSafe((int) $row['is_paid'] === 1 ? 'Ja' : 'Nein'),
|
||||
csvSafe(number_format((float) $row['total_price'], 2, ',', '.') . ' €'),
|
||||
csvSafe($row['role']),
|
||||
csvSafe(exportAccommodation($row['accommodation_type'])),
|
||||
csvSafe($row['roommate_requests'] ?? ''),
|
||||
csvSafe($row['early_departure'] !== null && $row['early_departure'] !== '' ? exportEarlyDeparture($row['early_departure']) : ''),
|
||||
csvSafe(implode(', ', $days)),
|
||||
csvSafe(implode(', ', $meals)),
|
||||
csvSafe($row['allergies'] ?? ''),
|
||||
csvSafe($row['other_notes'] ?? ''),
|
||||
], ';', '"', '\\');
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Not found</title></head><body></body></html>
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function loadRoomPlanningSettings(PDO $pdo): array
|
||||
{
|
||||
$defaults = [
|
||||
'single' => 0,
|
||||
'double' => 0,
|
||||
'multi' => 0,
|
||||
];
|
||||
$statement = $pdo->query(
|
||||
"SELECT setting_key, price FROM pricing_settings
|
||||
WHERE setting_key IN ('room_count_single', 'room_count_double', 'room_count_multi')"
|
||||
);
|
||||
foreach ($statement->fetchAll(PDO::FETCH_KEY_PAIR) as $key => $value) {
|
||||
$type = str_replace('room_count_', '', (string) $key);
|
||||
if (array_key_exists($type, $defaults)) {
|
||||
$defaults[$type] = max(0, (int) $value);
|
||||
}
|
||||
}
|
||||
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
function normalizedRoommateName(string $name): string
|
||||
{
|
||||
return mb_strtolower(preg_replace('/\\s+/u', ' ', trim($name)) ?? '', 'UTF-8');
|
||||
}
|
||||
|
||||
function buildRoomPlan(array $participants, array $roomCounts): array
|
||||
{
|
||||
$rooms = [];
|
||||
$warnings = [];
|
||||
$unassigned = [];
|
||||
$available = [
|
||||
'single' => max(0, (int) ($roomCounts['single'] ?? 0)),
|
||||
'double' => max(0, (int) ($roomCounts['double'] ?? 0)),
|
||||
'multi' => max(0, (int) ($roomCounts['multi'] ?? 0)),
|
||||
];
|
||||
$roomNumber = ['single' => 0, 'double' => 0, 'multi' => 0];
|
||||
$assigned = [];
|
||||
$blocked = [];
|
||||
$byName = [];
|
||||
|
||||
foreach ($participants as &$participant) {
|
||||
$participant['roommate_key'] = normalizedRoommateName((string) ($participant['roommate_requests'] ?? ''));
|
||||
$participant['name_key'] = normalizedRoommateName((string) $participant['name']);
|
||||
$byName[$participant['name_key']][] = $participant;
|
||||
}
|
||||
unset($participant);
|
||||
|
||||
$addRoom = static function (string $type, array $members) use (&$rooms, &$roomNumber, &$available, &$assigned): bool {
|
||||
if (($available[$type] ?? 0) < 1) {
|
||||
return false;
|
||||
}
|
||||
$roomNumber[$type]++;
|
||||
$capacity = $type === 'single' ? 1 : ($type === 'double' ? 2 : 4);
|
||||
$rooms[] = [
|
||||
'type' => $type,
|
||||
'number' => $roomNumber[$type],
|
||||
'capacity' => $capacity,
|
||||
'members' => $members,
|
||||
];
|
||||
$available[$type]--;
|
||||
foreach ($members as $member) {
|
||||
$assigned[(string) $member['id']] = true;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Mutual wishes are handled first, but only when both participants requested the same room type.
|
||||
foreach ($participants as $participant) {
|
||||
$id = (string) $participant['id'];
|
||||
if (isset($assigned[$id]) || $participant['roommate_key'] === '') {
|
||||
continue;
|
||||
}
|
||||
foreach ($byName[$participant['roommate_key']] ?? [] as $requested) {
|
||||
$requestedId = (string) $requested['id'];
|
||||
if ($requestedId === $id || isset($assigned[$requestedId])) {
|
||||
continue;
|
||||
}
|
||||
$mutual = $requested['roommate_key'] === $participant['name_key'];
|
||||
$sameType = $requested['room_type'] === $participant['room_type'];
|
||||
if (!$mutual || !$sameType) {
|
||||
continue;
|
||||
}
|
||||
if (!$addRoom((string) $participant['room_type'], [$participant, $requested])) {
|
||||
$warnings[] = sprintf(
|
||||
'Der gegenseitige Zimmerwunsch von %s und %s konnte nicht erfüllt werden: kein %s-Zimmer verfügbar.',
|
||||
$participant['name'],
|
||||
$requested['name'],
|
||||
$participant['room_type'] === 'double' ? 'Doppel' : 'Mehrbett'
|
||||
);
|
||||
$unassigned[$id] = $participant;
|
||||
$unassigned[$requestedId] = $requested;
|
||||
$blocked[$id] = true;
|
||||
$blocked[$requestedId] = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['single', 'double', 'multi'] as $type) {
|
||||
$waiting = [];
|
||||
foreach ($participants as $participant) {
|
||||
$id = (string) $participant['id'];
|
||||
if (!isset($assigned[$id]) && !isset($blocked[$id]) && $participant['room_type'] === $type) {
|
||||
$waiting[] = $participant;
|
||||
}
|
||||
}
|
||||
$capacity = $type === 'single' ? 1 : ($type === 'double' ? 2 : 4);
|
||||
while ($waiting !== []) {
|
||||
$group = array_splice($waiting, 0, $capacity);
|
||||
if (!$addRoom($type, $group)) {
|
||||
foreach ($group as $participant) {
|
||||
$unassigned[(string) $participant['id']] = $participant;
|
||||
}
|
||||
$warnings[] = sprintf(
|
||||
'%d Teilnehmer mit Wunsch %s konnten keinem verfügbaren Zimmer zugeordnet werden.',
|
||||
count($group),
|
||||
$type === 'single' ? 'Einzelzimmer' : ($type === 'double' ? 'Doppelzimmer' : 'Mehrbettzimmer')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($unassigned as $participant) {
|
||||
$warnings[] = sprintf('Nicht eingeplant: %s (%s).', $participant['name'], $participant['email']);
|
||||
}
|
||||
|
||||
return [
|
||||
'rooms' => $rooms,
|
||||
'warnings' => array_values(array_unique($warnings)),
|
||||
'unassigned' => array_values($unassigned),
|
||||
'available' => $available,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
session_set_cookie_params([
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'use_strict_mode' => true,
|
||||
]);
|
||||
session_start();
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
|
||||
header("Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'");
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chorwochenende</title>
|
||||
<link rel="stylesheet" href="./css/index.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="start-page">
|
||||
<p class="eyebrow">Chorwochenende</p>
|
||||
<h1>Willkommen</h1>
|
||||
<p class="intro">Bitte wählen Sie den gewünschten Bereich aus.</p>
|
||||
|
||||
<nav class="main-actions" aria-label="Hauptnavigation">
|
||||
<a class="action-button" href="booking.php">Neue Registrierung</a>
|
||||
<a class="action-button" href="edit_registration.php">Registrierung ändern</a>
|
||||
<a class="action-button action-button-secondary" href="admin.php">Administration</a>
|
||||
</nav>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,662 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
session_set_cookie_params([
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'use_strict_mode' => true,
|
||||
]);
|
||||
session_start();
|
||||
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 1048576) {
|
||||
http_response_code(413);
|
||||
exit('Anfrage zu groß.');
|
||||
}
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'");
|
||||
|
||||
require_once __DIR__ . '/inc/db.inc.php';
|
||||
require_once __DIR__ . '/inc/mail.inc.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
header('Allow: POST');
|
||||
exit('Methode nicht erlaubt.');
|
||||
}
|
||||
|
||||
$submittedCsrf = $_POST['csrf_token'] ?? '';
|
||||
$storedCsrf = $_SESSION['registration_csrf'] ?? '';
|
||||
if (!is_string($submittedCsrf) || $storedCsrf === '' || !hash_equals($storedCsrf, $submittedCsrf)) {
|
||||
http_response_code(403);
|
||||
exit('Ungültige Anfrage.');
|
||||
}
|
||||
unset($_SESSION['registration_csrf']);
|
||||
|
||||
function postString(string $key): string
|
||||
{
|
||||
$value = $_POST[$key] ?? '';
|
||||
|
||||
return is_string($value) ? trim($value) : '';
|
||||
}
|
||||
|
||||
function postStringList(string $key): array
|
||||
{
|
||||
$value = $_POST[$key] ?? [];
|
||||
$values = is_array($value) ? $value : [$value];
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map(
|
||||
static fn ($item): string => is_string($item) ? trim($item) : '',
|
||||
$values
|
||||
),
|
||||
static fn (string $item): bool => $item !== ''
|
||||
));
|
||||
}
|
||||
|
||||
function postMealSelectionsByDay(): array
|
||||
{
|
||||
$value = $_POST['meals'] ?? [];
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($value as $day => $meals) {
|
||||
if (!is_array($meals)) {
|
||||
continue;
|
||||
}
|
||||
$result[(string) $day] = array_values(array_filter(
|
||||
array_map(static fn ($item): string => is_string($item) ? trim($item) : '', $meals),
|
||||
static fn (string $item): bool => $item !== ''
|
||||
));
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
function formatMealSummary(array $days, array $mealsByDay): string
|
||||
{
|
||||
$dayLabels = ['1' => 'Fr.', '2' => 'Sa.', '3' => 'So.'];
|
||||
$mealLabels = [
|
||||
'breakfast' => 'Frühstück',
|
||||
'lunch' => 'Mittagessen',
|
||||
'coffee' => 'Kaffee',
|
||||
'dinner' => 'Abendessen',
|
||||
'drinks' => 'Getränkepauschale',
|
||||
];
|
||||
$parts = [];
|
||||
foreach ($days as $day) {
|
||||
$labels = array_map(static fn (string $meal): string => $mealLabels[$meal] ?? $meal, $mealsByDay[$day] ?? []);
|
||||
$labels[] = $mealLabels['drinks'];
|
||||
$labels[] = $mealLabels['rehearsal_room'];
|
||||
$parts[] = ($dayLabels[$day] ?? $day) . ': ' . implode(', ', $labels);
|
||||
}
|
||||
|
||||
return implode('; ', $parts) ?: '–';
|
||||
}
|
||||
|
||||
function escapeHtml(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
function smtpResponse($socket): string
|
||||
{
|
||||
$response = '';
|
||||
do {
|
||||
$line = fgets($socket);
|
||||
if ($line === false) {
|
||||
throw new RuntimeException('Keine gültige SMTP-Antwort erhalten.');
|
||||
}
|
||||
$response .= $line;
|
||||
} while (isset($line[3]) && $line[3] === '-');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
function smtpCommand($socket, string $command, array $expectedCodes): void
|
||||
{
|
||||
fwrite($socket, $command . "\r\n");
|
||||
$response = smtpResponse($socket);
|
||||
$code = (int) substr($response, 0, 3);
|
||||
|
||||
if (!in_array($code, $expectedCodes, true)) {
|
||||
throw new RuntimeException('SMTP-Befehl wurde abgelehnt.');
|
||||
}
|
||||
}
|
||||
|
||||
function encodedHeader(string $value): string
|
||||
{
|
||||
return '=?UTF-8?B?' . base64_encode($value) . '?=';
|
||||
}
|
||||
|
||||
function calculateBookingTotal(PDO $pdo, bool $under27, string $guestType, ?string $roomType, array $mealsByDay): float
|
||||
{
|
||||
$ageSuffix = $under27 ? 'under_27' : 'over_27';
|
||||
$keys = [];
|
||||
if ($guestType === 'regular_guest' && $roomType !== null) {
|
||||
$keys[] = $roomType . '_' . $ageSuffix;
|
||||
}
|
||||
if ($guestType === 'day_guest') {
|
||||
foreach ($mealsByDay as $meals) {
|
||||
foreach (array_unique(array_merge($meals, ['drinks'])) as $meal) {
|
||||
if (in_array($meal, ['breakfast', 'lunch', 'coffee', 'dinner'], true)) {
|
||||
$keys[] = $meal . '_' . $ageSuffix;
|
||||
} elseif ($meal === 'drinks') {
|
||||
$keys[] = 'drinks';
|
||||
}
|
||||
}
|
||||
$keys[] = 'rehearsal_room';
|
||||
}
|
||||
}
|
||||
if ($keys === []) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$uniqueKeys = array_values(array_unique($keys));
|
||||
$placeholders = implode(',', array_fill(0, count($uniqueKeys), '?'));
|
||||
$statement = $pdo->prepare("SELECT setting_key, price FROM pricing_settings WHERE setting_key IN ($placeholders)");
|
||||
$statement->execute($uniqueKeys);
|
||||
$prices = [];
|
||||
foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$prices[(string) $row['setting_key']] = (float) $row['price'];
|
||||
}
|
||||
|
||||
return round(array_sum(array_map(
|
||||
static fn (string $key): float => $prices[$key] ?? 0.0,
|
||||
$keys
|
||||
)), 2);
|
||||
}
|
||||
|
||||
function sendRegistrationEmail(
|
||||
array $mailConfig,
|
||||
string $recipient,
|
||||
string $name,
|
||||
string $participantPassword,
|
||||
string $guestType,
|
||||
bool $under27,
|
||||
?string $roomType,
|
||||
array $days,
|
||||
array $mealsByDay,
|
||||
string $allergies,
|
||||
string $roommates,
|
||||
string $otherNotes
|
||||
): void {
|
||||
$roomLabels = [
|
||||
'single' => 'Einzelzimmer',
|
||||
'double' => 'Doppelzimmer',
|
||||
'multi' => 'Mehrbettzimmer',
|
||||
];
|
||||
$dayLabels = ['1' => 'Fr.', '2' => 'Sa.', '3' => 'So.'];
|
||||
$mealLabels = [
|
||||
'breakfast' => 'Frühstück',
|
||||
'lunch' => 'Mittagessen',
|
||||
'coffee' => 'Kaffee',
|
||||
'drinks' => 'Getränkepauschale',
|
||||
'dinner' => 'Abendessen',
|
||||
'rehearsal_room' => 'Proberaumpauschale',
|
||||
];
|
||||
$guestLabel = $guestType === 'day_guest' ? 'Tagesgast' : 'Dauergast';
|
||||
$ageLabel = $under27 ? 'Unter 27' : 'Ab 27';
|
||||
$roomLabel = $roomLabels[$roomType ?? ''] ?? '–';
|
||||
$dayText = implode(', ', array_map(static fn (string $day): string => $dayLabels[$day] ?? $day, $days)) ?: '–';
|
||||
$mealText = formatMealSummary($days, $mealsByDay);
|
||||
$roommates = $roommates !== '' ? $roommates : '–';
|
||||
$allergies = $allergies !== '' ? $allergies : '–';
|
||||
$otherNotes = $otherNotes !== '' ? $otherNotes : '–';
|
||||
|
||||
$textBody = "Hallo {$name},\n\n"
|
||||
. "deine Registrierung für das Chorwochenende wurde erfolgreich gespeichert.\n\n"
|
||||
. "Zugang zur Änderung oder Löschung:\n"
|
||||
. "E-Mail-Adresse: {$recipient}\n"
|
||||
. "Passwort: {$participantPassword}\n\n"
|
||||
. "Zusammenfassung:\n"
|
||||
. "Altersgruppe: {$ageLabel}\n"
|
||||
. "Gasttyp: {$guestLabel}\n"
|
||||
. "Zimmerart: {$roomLabel}\n"
|
||||
. "Anwesenheitstage: {$dayText}\n"
|
||||
. "Mahlzeiten: {$mealText}\n"
|
||||
. "Zimmerpartner-Wunsch: {$roommates}\n"
|
||||
. "Allergien / Essensvorlieben: {$allergies}\n"
|
||||
. "Sonstiges: {$otherNotes}\n\n"
|
||||
. "Viele Grüße\n{$mailConfig['from_name']}";
|
||||
|
||||
$html = static fn (string $value): string => escapeHtml($value);
|
||||
$htmlBody = '<!doctype html><html lang="de"><body>'
|
||||
. '<p>Hallo ' . $html($name) . ',</p>'
|
||||
. '<p>deine Registrierung für das Chorwochenende wurde erfolgreich gespeichert.</p>'
|
||||
. '<h2>Deine Zugangsdaten</h2><p><strong>E-Mail-Adresse:</strong> ' . $html($recipient)
|
||||
. '<br><strong>Passwort:</strong> <code>' . $html($participantPassword) . '</code></p>'
|
||||
. '<h2>Zusammenfassung</h2><ul>'
|
||||
. '<li><strong>Altersgruppe:</strong> ' . $html($ageLabel) . '</li>'
|
||||
. '<li><strong>Gasttyp:</strong> ' . $html($guestLabel) . '</li>'
|
||||
. '<li><strong>Zimmerart:</strong> ' . $html($roomLabel) . '</li>'
|
||||
. '<li><strong>Anwesenheitstage:</strong> ' . $html($dayText) . '</li>'
|
||||
. '<li><strong>Mahlzeiten:</strong> ' . $html($mealText) . '</li>'
|
||||
. '<li><strong>Zimmerpartner-Wunsch:</strong> ' . $html($roommates) . '</li>'
|
||||
. '<li><strong>Allergien / Essensvorlieben:</strong> ' . $html($allergies) . '</li>'
|
||||
. '<li><strong>Sonstiges:</strong> ' . $html($otherNotes) . '</li></ul>'
|
||||
. '<p>Viele Grüße<br>' . $html($mailConfig['from_name']) . '</p></body></html>';
|
||||
|
||||
$boundary = '=_cat_' . bin2hex(random_bytes(12));
|
||||
$message = 'From: ' . encodedHeader($mailConfig['from_name']) . ' <' . $mailConfig['from_address'] . ">\r\n"
|
||||
. 'To: ' . $recipient . "\r\n"
|
||||
. 'Subject: ' . encodedHeader('Deine Registrierung für das Chorwochenende') . "\r\n"
|
||||
. "MIME-Version: 1.0\r\n"
|
||||
. 'Content-Type: multipart/alternative; boundary="' . $boundary . "\"\r\n\r\n"
|
||||
. '--' . $boundary . "\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n"
|
||||
. $textBody . "\r\n\r\n"
|
||||
. '--' . $boundary . "\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n"
|
||||
. $htmlBody . "\r\n\r\n"
|
||||
. '--' . $boundary . "--\r\n";
|
||||
|
||||
$context = stream_context_create([
|
||||
'ssl' => [
|
||||
'peer_name' => $mailConfig['host'],
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
],
|
||||
]);
|
||||
$socket = stream_socket_client(
|
||||
'tcp://' . $mailConfig['host'] . ':' . $mailConfig['port'],
|
||||
$errorNumber,
|
||||
$errorMessage,
|
||||
20,
|
||||
STREAM_CLIENT_CONNECT,
|
||||
$context
|
||||
);
|
||||
if ($socket === false) {
|
||||
throw new RuntimeException('SMTP-Server nicht erreichbar.');
|
||||
}
|
||||
stream_set_timeout($socket, 20);
|
||||
|
||||
try {
|
||||
$response = smtpResponse($socket);
|
||||
if ((int) substr($response, 0, 3) !== 220) {
|
||||
throw new RuntimeException('SMTP-Verbindung wurde abgelehnt.');
|
||||
}
|
||||
smtpCommand($socket, 'EHLO localhost', [250]);
|
||||
smtpCommand($socket, 'STARTTLS', [220]);
|
||||
if (!stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||
throw new RuntimeException('STARTTLS konnte nicht aktiviert werden.');
|
||||
}
|
||||
smtpCommand($socket, 'EHLO localhost', [250]);
|
||||
smtpCommand($socket, 'AUTH LOGIN', [334]);
|
||||
smtpCommand($socket, base64_encode($mailConfig['username']), [334]);
|
||||
smtpCommand($socket, base64_encode($mailConfig['password']), [235]);
|
||||
smtpCommand($socket, 'MAIL FROM:<' . $mailConfig['from_address'] . '>', [250]);
|
||||
smtpCommand($socket, 'RCPT TO:<' . $recipient . '>', [250, 251]);
|
||||
smtpCommand($socket, 'DATA', [354]);
|
||||
$message = preg_replace('/^\./m', '..', $message);
|
||||
fwrite($socket, $message . "\r\n.\r\n");
|
||||
$response = smtpResponse($socket);
|
||||
if ((int) substr($response, 0, 3) !== 250) {
|
||||
throw new RuntimeException('E-Mail konnte nicht angenommen werden.');
|
||||
}
|
||||
fwrite($socket, "QUIT\r\n");
|
||||
} finally {
|
||||
fclose($socket);
|
||||
}
|
||||
}
|
||||
|
||||
function sendAdminNotificationEmail(
|
||||
array $mailConfig,
|
||||
string $recipient,
|
||||
string $name,
|
||||
string $participantEmail,
|
||||
string $guestLabel,
|
||||
string $ageLabel,
|
||||
string $roomLabel,
|
||||
string $dayText,
|
||||
string $mealText,
|
||||
string $roommates,
|
||||
string $allergies,
|
||||
string $otherNotes
|
||||
): void {
|
||||
$textBody = "Neue Registrierung für das Chorwochenende\n\n"
|
||||
. "Name: {$name}\n"
|
||||
. "E-Mail: {$participantEmail}\n"
|
||||
. "Altersgruppe: {$ageLabel}\n"
|
||||
. "Gasttyp: {$guestLabel}\n"
|
||||
. "Zimmerart: {$roomLabel}\n"
|
||||
. "Anwesenheitstage: {$dayText}\n"
|
||||
. "Mahlzeiten: {$mealText}\n"
|
||||
. "Zimmerpartner-Wunsch: {$roommates}\n"
|
||||
. "Allergien / Essensvorlieben: {$allergies}\n"
|
||||
. "Sonstiges: {$otherNotes}\n";
|
||||
$subject = encodedHeader('Neue Registrierung – Chorwochenende');
|
||||
$message = 'From: ' . encodedHeader($mailConfig['from_name']) . ' <' . $mailConfig['from_address'] . ">\r\n"
|
||||
. 'To: ' . $recipient . "\r\n"
|
||||
. 'Subject: ' . $subject . "\r\n"
|
||||
. "MIME-Version: 1.0\r\n"
|
||||
. "Content-Type: text/plain; charset=UTF-8\r\n"
|
||||
. "Content-Transfer-Encoding: 8bit\r\n\r\n"
|
||||
. $textBody;
|
||||
|
||||
$context = stream_context_create([
|
||||
'ssl' => [
|
||||
'peer_name' => $mailConfig['host'],
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
],
|
||||
]);
|
||||
$socket = stream_socket_client(
|
||||
'tcp://' . $mailConfig['host'] . ':' . $mailConfig['port'],
|
||||
$errorNumber,
|
||||
$errorMessage,
|
||||
20,
|
||||
STREAM_CLIENT_CONNECT,
|
||||
$context
|
||||
);
|
||||
if ($socket === false) {
|
||||
throw new RuntimeException('SMTP-Server nicht erreichbar.');
|
||||
}
|
||||
stream_set_timeout($socket, 20);
|
||||
|
||||
try {
|
||||
$response = smtpResponse($socket);
|
||||
if ((int) substr($response, 0, 3) !== 220) {
|
||||
throw new RuntimeException('SMTP-Verbindung wurde abgelehnt.');
|
||||
}
|
||||
smtpCommand($socket, 'EHLO localhost', [250]);
|
||||
smtpCommand($socket, 'STARTTLS', [220]);
|
||||
if (!stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||
throw new RuntimeException('STARTTLS konnte nicht aktiviert werden.');
|
||||
}
|
||||
smtpCommand($socket, 'EHLO localhost', [250]);
|
||||
smtpCommand($socket, 'AUTH LOGIN', [334]);
|
||||
smtpCommand($socket, base64_encode($mailConfig['username']), [334]);
|
||||
smtpCommand($socket, base64_encode($mailConfig['password']), [235]);
|
||||
smtpCommand($socket, 'MAIL FROM:<' . $mailConfig['from_address'] . '>', [250]);
|
||||
smtpCommand($socket, 'RCPT TO:<' . $recipient . '>', [250, 251]);
|
||||
smtpCommand($socket, 'DATA', [354]);
|
||||
fwrite($socket, preg_replace('/^\./m', '..', $message) . "\r\n.\r\n");
|
||||
$response = smtpResponse($socket);
|
||||
if ((int) substr($response, 0, 3) !== 250) {
|
||||
throw new RuntimeException('E-Mail konnte nicht angenommen werden.');
|
||||
}
|
||||
fwrite($socket, "QUIT\r\n");
|
||||
} finally {
|
||||
fclose($socket);
|
||||
}
|
||||
}
|
||||
|
||||
$name = postString('name');
|
||||
$email = postString('email');
|
||||
$under27 = isset($_POST['under_27']) && $_POST['under_27'] === '1';
|
||||
$guestType = postString('guest_type');
|
||||
$roomType = postString('room_type');
|
||||
$roommates = postString('roommates');
|
||||
$earlyDeparture = postString('early_departure');
|
||||
$allergies = postString('allergies');
|
||||
$otherNotes = postString('other_notes');
|
||||
$days = postStringList('days');
|
||||
$mealsByDay = postMealSelectionsByDay();
|
||||
|
||||
$errors = [];
|
||||
$allowedDays = ['1', '2', '3'];
|
||||
$allowedEarlyDepartures = [
|
||||
'sat_breakfast',
|
||||
'sat_lunch',
|
||||
'sat_coffee',
|
||||
'sat_dinner',
|
||||
'sun_breakfast',
|
||||
];
|
||||
$allowedMealsByDay = [
|
||||
'1' => ['dinner'],
|
||||
'2' => ['breakfast', 'lunch', 'coffee', 'dinner'],
|
||||
'3' => ['breakfast', 'lunch'],
|
||||
];
|
||||
$days = array_values(array_unique($days));
|
||||
foreach ($mealsByDay as $day => $dayMeals) {
|
||||
$mealsByDay[$day] = array_values(array_unique($dayMeals));
|
||||
}
|
||||
|
||||
if (count($days) > count($allowedDays) || array_diff($days, $allowedDays) !== []) {
|
||||
$errors[] = 'Bitte nur gültige Anwesenheitstage auswählen.';
|
||||
}
|
||||
if (array_diff(array_keys($mealsByDay), $allowedDays) !== []) {
|
||||
$errors[] = 'Bitte nur gültige Mahlzeitentage auswählen.';
|
||||
}
|
||||
foreach ($mealsByDay as $day => $dayMeals) {
|
||||
$allowedMeals = $allowedMealsByDay[(string) $day] ?? [];
|
||||
if (array_diff($dayMeals, $allowedMeals) !== []) {
|
||||
$errors[] = 'Bitte nur gültige Mahlzeiten auswählen.';
|
||||
}
|
||||
if (!in_array((string) $day, $days, true) && $dayMeals !== []) {
|
||||
$errors[] = 'Mahlzeiten dürfen nur für ausgewählte Anwesenheitstage angegeben werden.';
|
||||
}
|
||||
}
|
||||
foreach ($days as $day) {
|
||||
$mealsByDay[$day] ??= [];
|
||||
}
|
||||
if (mb_strlen($roommates) > 1000 || mb_strlen($allergies) > 1000) {
|
||||
$errors[] = 'Zimmerwunsch und Allergieangaben dürfen höchstens 1000 Zeichen enthalten.';
|
||||
}
|
||||
|
||||
if ($name === '' || mb_strlen($name) > 255) {
|
||||
$errors[] = 'Bitte einen gültigen Namen eingeben.';
|
||||
}
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($email) > 255) {
|
||||
$errors[] = 'Bitte eine gültige E-Mail-Adresse eingeben.';
|
||||
}
|
||||
|
||||
if ($otherNotes !== '' && mb_strlen($otherNotes) > 5000) {
|
||||
$errors[] = 'Das Feld Sonstiges darf höchstens 5000 Zeichen enthalten.';
|
||||
}
|
||||
|
||||
if (!in_array($guestType, ['day_guest', 'regular_guest'], true)) {
|
||||
$errors[] = 'Bitte einen gültigen Gasttyp auswählen.';
|
||||
}
|
||||
|
||||
if ($guestType === 'regular_guest' && !in_array($roomType, ['single', 'double', 'multi'], true)) {
|
||||
$errors[] = 'Bitte eine gültige Zimmerart auswählen.';
|
||||
}
|
||||
if ($earlyDeparture !== '' && ($guestType !== 'regular_guest' || !in_array($earlyDeparture, $allowedEarlyDepartures, true))) {
|
||||
$errors[] = 'Bitte einen gültigen Abreisezeitpunkt auswählen.';
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
http_response_code(422);
|
||||
exit(implode("\n", $errors));
|
||||
}
|
||||
|
||||
$roommateEligible = $guestType === 'regular_guest' && in_array($roomType, ['double', 'multi'], true);
|
||||
if (!$roommateEligible) {
|
||||
$roommates = '';
|
||||
}
|
||||
|
||||
$role = $guestType === 'day_guest' ? 'Tagesgast' : 'Dauergast';
|
||||
$bookingType = $guestType === 'day_guest' ? 'Day' : 'Stay';
|
||||
$accommodationType = $guestType === 'regular_guest' ? $roomType : null;
|
||||
$roommateRequest = $roommateEligible && $roommates !== '' ? $roommates : null;
|
||||
$earlyDeparture = $guestType === 'regular_guest' && in_array($earlyDeparture, $allowedEarlyDepartures, true)
|
||||
? $earlyDeparture
|
||||
: null;
|
||||
|
||||
$mealPreferences = null;
|
||||
if ($guestType === 'day_guest') {
|
||||
$mealPreferences = json_encode(
|
||||
[
|
||||
'days' => $days,
|
||||
'meals_by_day' => $mealsByDay,
|
||||
],
|
||||
JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
$participantId = sprintf(
|
||||
'%s-%s-%s-%s-%s',
|
||||
bin2hex(random_bytes(4)),
|
||||
bin2hex(random_bytes(2)),
|
||||
bin2hex(random_bytes(2)),
|
||||
bin2hex(random_bytes(2)),
|
||||
bin2hex(random_bytes(6))
|
||||
);
|
||||
$participantPassword = bin2hex(random_bytes(8));
|
||||
$participantPasswordHash = password_hash($participantPassword, PASSWORD_DEFAULT);
|
||||
|
||||
try {
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$dbConfig['host'],
|
||||
$dbConfig['port'],
|
||||
$dbConfig['database'],
|
||||
$dbConfig['charset']
|
||||
);
|
||||
|
||||
$pdo = new PDO($dsn, $dbConfig['username'], $dbConfig['password'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
|
||||
$totalPrice = calculateBookingTotal($pdo, $under27, $guestType, $accommodationType, $mealsByDay);
|
||||
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$participantStatement = $pdo->prepare(
|
||||
'INSERT INTO participants (id, name, email, allergies, role, under_27, password_hash)
|
||||
VALUES (:id, :name, :email, :allergies, :role, :under_27, :password_hash)'
|
||||
);
|
||||
$participantStatement->execute([
|
||||
':id' => $participantId,
|
||||
':name' => $name,
|
||||
':email' => $email,
|
||||
':allergies' => $allergies !== '' ? $allergies : null,
|
||||
':role' => $role,
|
||||
':under_27' => $under27 ? 1 : 0,
|
||||
':password_hash' => $participantPasswordHash,
|
||||
]);
|
||||
|
||||
$bookingStatement = $pdo->prepare(
|
||||
'INSERT INTO bookings
|
||||
(participant_id, type, accommodation_type, meal_preferences, roommate_requests, early_departure, other_notes, total_price)
|
||||
VALUES
|
||||
(:participant_id, :type, :accommodation_type, :meal_preferences, :roommate_requests, :early_departure, :other_notes, :total_price)'
|
||||
);
|
||||
$bookingStatement->execute([
|
||||
':participant_id' => $participantId,
|
||||
':type' => $bookingType,
|
||||
':accommodation_type' => $accommodationType,
|
||||
':meal_preferences' => $mealPreferences,
|
||||
':roommate_requests' => $roommateRequest,
|
||||
':early_departure' => $earlyDeparture,
|
||||
':other_notes' => $otherNotes !== '' ? $otherNotes : null,
|
||||
':total_price' => number_format($totalPrice, 2, '.', ''),
|
||||
]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
$mailError = '';
|
||||
try {
|
||||
sendRegistrationEmail(
|
||||
$mailConfig,
|
||||
$email,
|
||||
$name,
|
||||
$participantPassword,
|
||||
$guestType,
|
||||
$under27,
|
||||
$roomType,
|
||||
$days,
|
||||
$mealsByDay,
|
||||
$allergies,
|
||||
$roommates,
|
||||
$otherNotes
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Registrierung gespeichert, E-Mail konnte nicht versendet werden: ' . $exception->getMessage());
|
||||
$mailError = '<p class="message error">Die Registrierung wurde gespeichert, aber die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte bewahre die Zugangsdaten auf dieser Seite auf.</p>';
|
||||
}
|
||||
|
||||
$roomLabels = ['single' => 'Einzelzimmer', 'double' => 'Doppelzimmer', 'multi' => 'Mehrbettzimmer'];
|
||||
$dayLabels = ['1' => 'Fr.', '2' => 'Sa.', '3' => 'So.'];
|
||||
$mealLabels = [
|
||||
'breakfast' => 'Frühstück',
|
||||
'lunch' => 'Mittagessen',
|
||||
'coffee' => 'Kaffee',
|
||||
'drinks' => 'Getränkepauschale',
|
||||
'dinner' => 'Abendessen',
|
||||
'rehearsal_room' => 'Proberaumpauschale',
|
||||
];
|
||||
$guestLabel = $guestType === 'day_guest' ? 'Tagesgast' : 'Dauergast';
|
||||
$ageLabel = $under27 ? 'Unter 27' : 'Ab 27';
|
||||
$roomLabel = $roomLabels[$roomType] ?? '–';
|
||||
$dayText = implode(', ', array_map(static fn (string $day): string => $dayLabels[$day] ?? $day, $days)) ?: '–';
|
||||
$mealText = formatMealSummary($days, $mealsByDay);
|
||||
$adminEmails = $pdo->query(
|
||||
"SELECT email FROM admin_users WHERE is_active = 1 AND email <> ''"
|
||||
)->fetchAll(PDO::FETCH_COLUMN);
|
||||
foreach ($adminEmails as $adminEmail) {
|
||||
if (!is_string($adminEmail) || !filter_var($adminEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
sendAdminNotificationEmail(
|
||||
$mailConfig,
|
||||
(string) $adminEmail,
|
||||
$name,
|
||||
$email,
|
||||
$guestLabel,
|
||||
$ageLabel,
|
||||
$roomLabel,
|
||||
$dayText,
|
||||
$mealText,
|
||||
$roommates !== '' ? $roommates : '–',
|
||||
$allergies !== '' ? $allergies : '–',
|
||||
$otherNotes !== '' ? $otherNotes : '–'
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Admin-Benachrichtigung konnte nicht versendet werden: ' . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
http_response_code(201);
|
||||
$safeEmail = escapeHtml($email);
|
||||
$safePassword = escapeHtml($participantPassword);
|
||||
exit(<<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Registrierung erfolgreich</title>
|
||||
<link rel="stylesheet" href="./css/booking.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="success-card">
|
||||
<h1>Registrierung erfolgreich</h1>
|
||||
{$mailError}
|
||||
<p>Bitte bewahre die folgenden Zugangsdaten auf. Du benötigst sie, um deine Registrierung später zu ändern oder zu löschen.</p>
|
||||
<dl class="credentials">
|
||||
<dt>E-Mail-Adresse</dt>
|
||||
<dd>{$safeEmail}</dd>
|
||||
<dt>Passwort</dt>
|
||||
<dd><code>{$safePassword}</code></dd>
|
||||
</dl>
|
||||
<p><a href="edit_registration.php">Registrierung ändern oder löschen</a></p>
|
||||
<p><a href="index.php">Zur Startseite</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
HTML);
|
||||
} catch (Throwable $exception) {
|
||||
if (isset($pdo) && $pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
error_log($exception->getMessage());
|
||||
if ($exception instanceof PDOException && $exception->getCode() === '23000'
|
||||
&& str_contains($exception->getMessage(), "for key 'email'")) {
|
||||
http_response_code(409);
|
||||
exit('Diese E-Mail-Adresse ist bereits registriert. Bitte nutze die Änderungs- oder Löschfunktion deiner bestehenden Registrierung.');
|
||||
}
|
||||
|
||||
http_response_code(500);
|
||||
exit('Die Registrierung konnte nicht gespeichert werden.');
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
session_set_cookie_params([
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'use_strict_mode' => true,
|
||||
]);
|
||||
session_start();
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
|
||||
header("Content-Security-Policy: default-src 'none'; frame-ancestors 'none'");
|
||||
|
||||
require_once __DIR__ . '/inc/db.inc.php';
|
||||
|
||||
$query = trim((string) ($_GET['q'] ?? ''));
|
||||
$roomType = trim((string) ($_GET['room_type'] ?? ''));
|
||||
|
||||
if (!in_array($roomType, ['double', 'multi'], true) || mb_strlen($query) < 2) {
|
||||
echo json_encode([], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$dbConfig['host'],
|
||||
$dbConfig['port'],
|
||||
$dbConfig['database'],
|
||||
$dbConfig['charset']
|
||||
),
|
||||
$dbConfig['username'],
|
||||
$dbConfig['password'],
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_COLUMN,
|
||||
]
|
||||
);
|
||||
|
||||
$statement = $pdo->prepare(
|
||||
'SELECT DISTINCT p.name
|
||||
FROM participants p
|
||||
INNER JOIN bookings b ON b.participant_id = p.id
|
||||
WHERE p.name LIKE :query
|
||||
ORDER BY p.name
|
||||
LIMIT 10'
|
||||
);
|
||||
$statement->execute([
|
||||
':query' => '%' . $query . '%',
|
||||
]);
|
||||
|
||||
echo json_encode($statement->fetchAll(), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Zimmernachbar-Suche fehlgeschlagen: ' . $exception->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Vorschläge konnten nicht geladen werden.'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
let roommateSearchController = null;
|
||||
|
||||
function updateRoommateSuggestions() {
|
||||
const guestTypeElement = document.getElementById('guest_type');
|
||||
const roomTypeElement = document.getElementById('room_type');
|
||||
const roommateInput = document.getElementById('roommates');
|
||||
const suggestionList = document.getElementById('roommate_suggestions');
|
||||
|
||||
if (!guestTypeElement || !roomTypeElement || !roommateInput || !suggestionList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eligible = guestTypeElement.value === 'regular_guest'
|
||||
&& ['double', 'multi'].includes(roomTypeElement.value);
|
||||
const query = roommateInput.value.trim();
|
||||
suggestionList.replaceChildren();
|
||||
|
||||
if (!eligible || query.length < 2) {
|
||||
if (roommateSearchController) {
|
||||
roommateSearchController.abort();
|
||||
roommateSearchController = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (roommateSearchController) {
|
||||
roommateSearchController.abort();
|
||||
}
|
||||
roommateSearchController = new AbortController();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
room_type: roomTypeElement.value,
|
||||
});
|
||||
fetch(`./roommate_suggestions.php?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: roommateSearchController.signal,
|
||||
})
|
||||
.then((response) => response.ok ? response.json() : [])
|
||||
.then((names) => {
|
||||
if (!Array.isArray(names)) {
|
||||
return;
|
||||
}
|
||||
names.forEach((name) => {
|
||||
if (typeof name !== 'string') {
|
||||
return;
|
||||
}
|
||||
const option = document.createElement('option');
|
||||
option.value = name;
|
||||
suggestionList.appendChild(option);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.warn('Zimmernachbar-Vorschläge konnten nicht geladen werden.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateRoomOptions() {
|
||||
const guestTypeElement = document.getElementById('guest_type');
|
||||
const roomTypeElement = document.getElementById('room_type');
|
||||
const regularOptions = document.getElementById('regular_guest_options');
|
||||
const dayOptions = document.getElementById('day_guest_options');
|
||||
const roommateInfo = document.getElementById('roommate_info');
|
||||
const roommateInput = document.getElementById('roommates');
|
||||
|
||||
const guestType = guestTypeElement ? guestTypeElement.value : '';
|
||||
const roomType = roomTypeElement ? roomTypeElement.value : '';
|
||||
const roommateEligible = guestType === 'regular_guest' && ['double', 'multi'].includes(roomType);
|
||||
|
||||
if (regularOptions) {
|
||||
regularOptions.style.display = guestType === 'regular_guest' ? 'block' : 'none';
|
||||
}
|
||||
if (dayOptions) {
|
||||
dayOptions.style.display = guestType === 'day_guest' ? 'block' : 'none';
|
||||
}
|
||||
if (roommateInfo) {
|
||||
roommateInfo.style.display = roommateEligible ? 'block' : 'none';
|
||||
}
|
||||
if (roommateInput) {
|
||||
roommateInput.disabled = !roommateEligible;
|
||||
}
|
||||
updateRoommateSuggestions();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const guestTypeElement = document.getElementById('guest_type');
|
||||
const roomTypeElement = document.getElementById('room_type');
|
||||
const roommateInput = document.getElementById('roommates');
|
||||
|
||||
if (guestTypeElement) {
|
||||
guestTypeElement.addEventListener('change', updateRoomOptions);
|
||||
}
|
||||
if (roomTypeElement) {
|
||||
roomTypeElement.addEventListener('change', updateRoomOptions);
|
||||
}
|
||||
if (roommateInput) {
|
||||
roommateInput.addEventListener('input', updateRoommateSuggestions);
|
||||
}
|
||||
updateRoomOptions();
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
// Gemeinsame JavaScript-Funktionen für das Projekt "cat".
|
||||
|
||||
let roommateSearchController = null;
|
||||
|
||||
function updateRoommateSuggestions() {
|
||||
const guestTypeElement = document.getElementById('guest_type');
|
||||
const roomTypeElement = document.getElementById('room_type');
|
||||
const roommateInput = document.getElementById('roommates');
|
||||
const suggestionList = document.getElementById('roommate_suggestions');
|
||||
|
||||
if (!guestTypeElement || !roomTypeElement || !roommateInput || !suggestionList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eligible = guestTypeElement.value === 'regular_guest'
|
||||
&& ['double', 'multi'].includes(roomTypeElement.value);
|
||||
const query = roommateInput.value.trim();
|
||||
suggestionList.replaceChildren();
|
||||
|
||||
if (!eligible || query.length < 2) {
|
||||
if (roommateSearchController) {
|
||||
roommateSearchController.abort();
|
||||
roommateSearchController = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (roommateSearchController) {
|
||||
roommateSearchController.abort();
|
||||
}
|
||||
roommateSearchController = new AbortController();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
room_type: roomTypeElement.value,
|
||||
});
|
||||
fetch(`./roommate_suggestions.php?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: roommateSearchController.signal,
|
||||
})
|
||||
.then((response) => response.ok ? response.json() : [])
|
||||
.then((names) => {
|
||||
if (!Array.isArray(names)) {
|
||||
return;
|
||||
}
|
||||
names.forEach((name) => {
|
||||
if (typeof name !== 'string') {
|
||||
return;
|
||||
}
|
||||
const option = document.createElement('option');
|
||||
option.value = name;
|
||||
suggestionList.appendChild(option);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.warn('Zimmernachbar-Vorschläge konnten nicht geladen werden.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateRoomOptions() {
|
||||
const guestTypeElement = document.getElementById('guest_type');
|
||||
const roomTypeElement = document.getElementById('room_type');
|
||||
const regularOptions = document.getElementById('regular_guest_options');
|
||||
const dayOptions = document.getElementById('day_guest_options');
|
||||
const roommateInfo = document.getElementById('roommate_info');
|
||||
const roommateInput = document.getElementById('roommates');
|
||||
const earlyDepartureEnabled = document.getElementById('early_departure_enabled');
|
||||
const earlyDepartureOptions = document.getElementById('early_departure_options');
|
||||
const earlyDepartureInputs = document.querySelectorAll('input[name="early_departure"]');
|
||||
|
||||
const guestType = guestTypeElement ? guestTypeElement.value : '';
|
||||
const roomType = roomTypeElement ? roomTypeElement.value : '';
|
||||
const roommateEligible = guestType === 'regular_guest' && ['double', 'multi'].includes(roomType);
|
||||
|
||||
if (regularOptions) {
|
||||
regularOptions.style.display = guestType === 'regular_guest' ? 'block' : 'none';
|
||||
}
|
||||
if (dayOptions) {
|
||||
dayOptions.style.display = guestType === 'day_guest' ? 'block' : 'none';
|
||||
}
|
||||
if (roommateInfo) {
|
||||
roommateInfo.style.display = roommateEligible ? 'block' : 'none';
|
||||
}
|
||||
if (roommateInput) {
|
||||
roommateInput.disabled = !roommateEligible;
|
||||
}
|
||||
const earlyDepartureVisible = guestType === 'regular_guest'
|
||||
&& Boolean(earlyDepartureEnabled && earlyDepartureEnabled.checked);
|
||||
if (earlyDepartureEnabled) {
|
||||
earlyDepartureEnabled.disabled = guestType !== 'regular_guest';
|
||||
if (guestType !== 'regular_guest') {
|
||||
earlyDepartureEnabled.checked = false;
|
||||
}
|
||||
}
|
||||
if (earlyDepartureOptions) {
|
||||
earlyDepartureOptions.style.display = earlyDepartureVisible ? 'block' : 'none';
|
||||
}
|
||||
earlyDepartureInputs.forEach((input) => {
|
||||
input.disabled = !earlyDepartureVisible;
|
||||
});
|
||||
updateRoommateSuggestions();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const guestTypeElement = document.getElementById('guest_type');
|
||||
const roomTypeElement = document.getElementById('room_type');
|
||||
const roommateInput = document.getElementById('roommates');
|
||||
|
||||
if (guestTypeElement) {
|
||||
guestTypeElement.addEventListener('change', updateRoomOptions);
|
||||
}
|
||||
if (roomTypeElement) {
|
||||
roomTypeElement.addEventListener('change', updateRoomOptions);
|
||||
}
|
||||
if (roommateInput) {
|
||||
roommateInput.addEventListener('input', updateRoommateSuggestions);
|
||||
}
|
||||
const earlyDepartureEnabled = document.getElementById('early_departure_enabled');
|
||||
if (earlyDepartureEnabled) {
|
||||
earlyDepartureEnabled.addEventListener('change', updateRoomOptions);
|
||||
}
|
||||
updateRoomOptions();
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Not found</title></head><body></body></html>
|
||||
@@ -0,0 +1,30 @@
|
||||
function syncToggle(toggle) {
|
||||
const targetId = toggle.dataset.toggleTarget;
|
||||
const content = document.querySelector(`[data-toggle-content="${targetId}"]`);
|
||||
const input = document.getElementById(targetId);
|
||||
if (!content || !input) {
|
||||
return;
|
||||
}
|
||||
content.hidden = !toggle.checked;
|
||||
input.disabled = !toggle.checked;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('[data-toggle-target]').forEach(syncToggle);
|
||||
});
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
if (event.target.matches('[data-toggle-target]')) {
|
||||
syncToggle(event.target);
|
||||
}
|
||||
if (event.target.matches('[data-submit-on-change="true"]')) {
|
||||
event.target.form.submit();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('submit', function (event) {
|
||||
const message = event.target.dataset.confirm;
|
||||
if (message && !window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user