diff --git a/development-system/README.md b/development-system/README.md index 6893295..00e20fa 100644 --- a/development-system/README.md +++ b/development-system/README.md @@ -60,6 +60,83 @@ Die genaue Contao-, PHP- und Template-Version der öffentlichen Seite ist nicht Die Terminlogik liegt in `public/booking/booking.js` als browsernatives ES-Modul. Für die statische Perspektivwerk-Vorschau liegen identische Kopien unter `public/perspektivwerk/js/`. +## Datenbankgestützte Buchungsdemo + +Die Buchungsdemo lädt ihre Termine aus MariaDB und speichert eine Anmeldung +über `public/booking/api.php`. Die API wird unabhängig von Contao direkt durch +Apache ausgeliefert, verwendet aber dieselbe `DATABASE_URL` wie Contao. Dadurch +funktioniert sie aus der Vorschau `/perspektivwerk/` und aus dem Contao- +HTML-Element gleichermaßen. + +Vor dem ersten Aufruf müssen die externen Migrationen eingespielt werden: + +```powershell +.\scripts\Invoke-BookingMigrations.ps1 +``` + +`database/migrations/001_booking_schema.sql` ist produktionsgeeignet +versioniert und idempotent. Sie enthält Tabellen für Termine und Anmeldungen +sowie klar erkennbare Demo-Termine. Die API prüft Kapazitäten innerhalb einer +Transaktion und sperrt den Termin dabei mit `SELECT … FOR UPDATE`; parallele +Anmeldungen können dadurch die Kapazität nicht überschreiten. + +Die Demo speichert eine gültige Anmeldung direkt als `confirmed`. Sie hat +bewusst noch **keinen** Mailversand, kein Double-Opt-in, keine Erinnerungen, +keinen ICS-Feed und keine Verwaltungsoberfläche. Die Anzeige benennt diesen +Umstand ausdrücklich. Ein Produktiveinsatz erfordert außerdem die in den +SOLL-Dokumenten genannten Entscheidungen zu Art. 13 DSGVO, Löschfristen, +Mailbetrieb, Rollen/Berechtigungen und Missbrauchsschutz. + +Der Browser erhält ein CSRF-Token per gleicher Sitzung; die API validiert alle +Pflichtdaten serverseitig und begrenzt Anmeldungen pro Sitzung. Dies ist eine +Demonstration der Schutzrichtung, ersetzt aber keine gesonderte +Sicherheitsanalyse für den Produktivbetrieb. + +### API verwenden + +Die API ist unter `/booking/api.php` erreichbar und akzeptiert ausschließlich +gleichartige Same-Origin-Anfragen. Sie liefert JSON und verwendet keine +Zugangsdaten im Browser. + +Termine eines Monats laden (`to` ist der erste Tag des Folgemonats): + +```text +GET /booking/api.php?project=perspektivwerk&from=2026-09-01&to=2026-10-01 +``` + +`project` ist entweder `perspektivwerk` oder `produktionsschule`. Der Zeitraum +darf höchstens 93 Tage umfassen. Die Antwort enthält ein `csrfToken` und eine +Liste `events`. Ein Event enthält unter anderem `id`, `title`, `startsAt`, +`endsAt`, `capacity`, `availableCapacity`, `status` (`available` oder `full`), +`format` (`vor_ort` oder `telefonisch`) und `introductionRequired`. + +Eine Anmeldung wird per `POST` angelegt. Dazu muss dieselbe Browser-Sitzung wie +beim `GET` verwendet und das erhaltene Token im Header +`X-SUFW-Booking-CSRF` übertragen werden: + +```http +POST /booking/api.php +Content-Type: application/json +X-SUFW-Booking-CSRF: + +{ + "project": "perspektivwerk", + "eventId": "6c7f216b-2704-4c81-b17a-000000000001", + "name": "Max Mustermann", + "email": "max@example.org", + "phone": "+49 351 123456", + "privacyAccepted": true, + "introductionAttended": false, + "introductionDate": "" +} +``` + +Für ein PS-`Probearbeiten` mit `introductionRequired: true` müssen +`introductionAttended` den Wert `true` und `introductionDate` ein gültiges +Datum im Format `YYYY-MM-DD` enthalten. Bei Erfolg antwortet die Demo mit +`201 Created`; bei einem inzwischen ausgebuchten oder doppelt gebuchten Termin +mit `409 Conflict`; fehlerhafte Pflichtangaben erhalten `422`. + ## Zwei Einbindungswege Die Terminbuchungskomponente hat eine Vorschau auf einem Webseiten-Nachbau der Orginalseite: @@ -71,7 +148,10 @@ Die Terminbuchungskomponente hat eine Vorschau auf einem Webseiten-Nachbau der O ## Statische Buchungsvorschau in Contao einbinden -Die erste Komponente ist absichtlich datenbankfrei. Sie zeigt Test-Slots, rendert einen Monatskalender und unterscheidet freie von belegten Terminen. Ein Klick auf einen freien Slot öffnet eine modale Eingabemaske für Name, E-Mail, Telefon, Alter, Nachricht, Ansprechperson bei AfA/Jobcenter sowie die DSGVO-Einwilligung. Die Eingaben werden derzeit ausschließlich im Browser validiert und weder gespeichert noch versendet. +Die Komponente lädt Termin- und Kapazitätsdaten über die beschriebene API. Ein +Klick auf einen freien Termin öffnet eine modale Eingabemaske für Name, +E-Mail-Adresse, Telefonnummer und die DSGVO-Kenntnisnahme; für Probearbeiten +kommen Einführungsveranstaltung und Datum hinzu. Die Komponente benötigt keinen TypeScript-Compiler und keinen Build-Schritt. Regressionstests ausführen: @@ -93,7 +173,22 @@ Contao maskiert nicht freigegebene HTML-Tags auch im Inhaltselement **HTML**. Vo Danach den Contao-Cache über **Systemwartung** leeren und das Inhaltselement erneut speichern. Keine globale Freigabe mit `*` verwenden. Wer ein HTML-Inhaltselement bearbeiten darf, kann mit dem freigegebenen `script`-Tag JavaScript auf der Website ausführen; diese Bearbeitungsrechte müssen daher auf vertrauenswürdige Backend-Nutzer beschränkt bleiben. -Die Beispieltermine stehen in `public/booking/booking.js`; `public/perspektivwerk/js/booking.js` ist die für den statischen Upload bestimmte Kopie. Ein Test stellt sicher, dass beide Dateien gleich bleiben. Der spätere serverseitige Endpunkt muss sie ersetzen; Datenbankzugänge oder SQL gehören niemals in das HTML oder das Browser-JavaScript. +Die Termin- und Kapazitätsdaten kommen aus `/booking/api.php`; Datenbankzugänge +oder SQL gehören niemals in das HTML oder das Browser-JavaScript. +`public/perspektivwerk/js/booking.js` und die zugehörige CSS-Datei sind die für +den statischen Upload bestimmte Kopie. Ein Test stellt sicher, dass beide +Asset-Paare gleich bleiben. Nach Änderungen können sie lokal synchronisiert +werden mit: + +```powershell +Copy-Item public\booking\booking.js public\perspektivwerk\js\booking.js -Force +Copy-Item public\booking\booking.css public\perspektivwerk\js\booking.css -Force +``` + +Der vorhandene SCP-Upload überträgt weiterhin nur die statische +Perspektivwerk-Seite. Für einen externen Einsatz der Buchungsdemo müssen +zusätzlich `/booking/api.php`, die Migrationen und die sichere +`DATABASE_URL`-Konfiguration auf dem Zielserver bereitgestellt werden. ## Statische Perspektivwerk-Seite übertragen diff --git a/development-system/database/README.md b/development-system/database/README.md index c164b80..e38b4d7 100644 --- a/development-system/database/README.md +++ b/development-system/database/README.md @@ -35,3 +35,23 @@ Der Import bricht ab, wenn die versionierten Contao-Tabellen bereits Inhalte ent ## Medien Medien gehören als Dateien nach `development-system/files/` und werden in Git eingecheckt. Sobald Contao-Medien verwendet werden, muss der Seed zusätzlich die Metadaten aus `tl_files` enthalten. Das ist absichtlich noch nicht automatisiert, weil die aktuelle Datenbank nur Verweise auf nicht versionierte lokale Dateien enthält. + +## Terminbuchungs-Migrationen + +Die Terminbuchung verwendet absichtlich keine Contao-Tabellen. Ihre separat +versionierten SQL-Migrationen liegen unter `database/migrations/`. Jede Datei +ist eigenständig und idempotent; sie kann deshalb auch später einzeln auf dem +Produktivsystem ausgeführt werden. + +Lokal werden alle Migrationen in Dateireihenfolge ausgeführt mit: + +```powershell +.\scripts\Invoke-BookingMigrations.ps1 +``` + +Für ein Produktivsystem wird die jeweilige Datei mit einem dort berechtigten +MariaDB-/MySQL-Client ausgeführt. Zugangsdaten gehören dabei in die +Betriebskonfiguration und niemals in die SQL-Datei oder das Repository. Die +erste Migration erstellt `sufw_booking_events` und +`sufw_booking_registrations` sowie neun ungefährliche Demo-Termine für beide +Instanzen. diff --git a/development-system/database/migrations/001_booking_schema.sql b/development-system/database/migrations/001_booking_schema.sql new file mode 100644 index 0000000..120126b --- /dev/null +++ b/development-system/database/migrations/001_booking_schema.sql @@ -0,0 +1,69 @@ +-- SUFW Terminbuchung: Anfangsschema und Demo-Termine +-- +-- Diese Migration ist idempotent. Sie kann sowohl in der lokalen MariaDB als +-- auch einzeln auf dem Produktivsystem ausgeführt werden. + +CREATE TABLE IF NOT EXISTS `sufw_booking_schema_migrations` ( + `version` varchar(100) NOT NULL, + `applied_at` datetime NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sufw_booking_events` ( + `id` char(36) NOT NULL, + `project` varchar(32) NOT NULL, + `title` varchar(160) NOT NULL, + `starts_at` datetime NOT NULL, + `ends_at` datetime NOT NULL, + `capacity` tinyint unsigned NOT NULL DEFAULT 1, + `format` varchar(24) NOT NULL, + `introduction_required` tinyint(1) NOT NULL DEFAULT 0, + `contact_name` varchar(160) NOT NULL, + `contact_email` varchar(254) NOT NULL, + `is_active` tinyint(1) NOT NULL DEFAULT 1, + `created_at` datetime NOT NULL DEFAULT current_timestamp(), + `updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + KEY `sufw_booking_events_project_starts_at` (`project`, `starts_at`), + CONSTRAINT `sufw_booking_events_project_check` CHECK (`project` IN ('perspektivwerk', 'produktionsschule')), + CONSTRAINT `sufw_booking_events_capacity_check` CHECK (`capacity` BETWEEN 1 AND 100), + CONSTRAINT `sufw_booking_events_format_check` CHECK (`format` IN ('vor_ort', 'telefonisch')), + CONSTRAINT `sufw_booking_events_dates_check` CHECK (`ends_at` > `starts_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sufw_booking_registrations` ( + `id` char(36) NOT NULL, + `event_id` char(36) NOT NULL, + `status` varchar(24) NOT NULL, + `name` varchar(160) NOT NULL, + `email` varchar(254) NOT NULL, + `phone` varchar(64) NOT NULL, + `privacy_accepted_at` datetime NOT NULL, + `introduction_attended` tinyint(1) NOT NULL DEFAULT 0, + `introduction_date` date DEFAULT NULL, + `created_at` datetime NOT NULL DEFAULT current_timestamp(), + `updated_at` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + KEY `sufw_booking_registrations_event_status` (`event_id`, `status`), + KEY `sufw_booking_registrations_person` (`email`, `name`), + CONSTRAINT `sufw_booking_registrations_status_check` CHECK (`status` IN ('requested', 'pending_confirmation', 'confirmed', 'removed', 'expired')), + CONSTRAINT `sufw_booking_registrations_introduction_check` CHECK ((`introduction_attended` = 0 AND `introduction_date` IS NULL) OR (`introduction_attended` = 1 AND `introduction_date` IS NOT NULL)), + CONSTRAINT `sufw_booking_registrations_event_fk` FOREIGN KEY (`event_id`) REFERENCES `sufw_booking_events` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Die IDs sind bewusst stabil, damit INSERT IGNORE diese Beispieldaten bei +-- wiederholter Ausführung nicht dupliziert. +INSERT IGNORE INTO `sufw_booking_events` + (`id`, `project`, `title`, `starts_at`, `ends_at`, `capacity`, `format`, `introduction_required`, `contact_name`, `contact_email`) +VALUES + ('6c7f216b-2704-4c81-b17a-000000000001', 'perspektivwerk', 'Erstgespräch PerspektivWerk', '2026-09-02 09:00:00', '2026-09-02 10:00:00', 2, 'vor_ort', 0, 'PerspektivWerk-Team', 'perspektivwerk@sufw.de'), + ('6c7f216b-2704-4c81-b17a-000000000002', 'perspektivwerk', 'Soz. Päd. Modul PerspektivWerk', '2026-09-04 09:00:00', '2026-09-04 13:00:00', 1, 'vor_ort', 0, 'PerspektivWerk-Team', 'perspektivwerk@sufw.de'), + ('6c7f216b-2704-4c81-b17a-000000000003', 'perspektivwerk', 'Psy Modul PerspektivWerk', '2026-09-09 09:00:00', '2026-09-09 13:00:00', 2, 'telefonisch', 0, 'PerspektivWerk-Team', 'perspektivwerk@sufw.de'), + ('6c7f216b-2704-4c81-b17a-000000000004', 'perspektivwerk', 'Praxis Modul PerspektivWerk', '2026-09-14 08:00:00', '2026-09-18 16:00:00', 5, 'vor_ort', 0, 'PerspektivWerk-Team', 'perspektivwerk@sufw.de'), + ('6c7f216b-2704-4c81-b17a-000000000005', 'perspektivwerk', 'Erstgespräch PerspektivWerk', '2026-10-01 11:00:00', '2026-10-01 12:00:00', 1, 'telefonisch', 0, 'PerspektivWerk-Team', 'perspektivwerk@sufw.de'), + ('6c7f216b-2704-4c81-b17a-000000000006', 'produktionsschule', 'Voranmeldung Kennenlerntermin Produktionsschule', '2026-09-01 10:00:00', '2026-09-01 11:00:00', 3, 'vor_ort', 0, 'Produktionsschule-Team', 'produktionsschule@sufw.de'), + ('6c7f216b-2704-4c81-b17a-000000000007', 'produktionsschule', 'Voranmeldung Kennenlerntermin Produktionsschule', '2026-09-08 10:00:00', '2026-09-08 11:00:00', 3, 'vor_ort', 0, 'Produktionsschule-Team', 'produktionsschule@sufw.de'), + ('6c7f216b-2704-4c81-b17a-000000000008', 'produktionsschule', 'Probearbeiten', '2026-09-14 08:00:00', '2026-09-18 16:00:00', 2, 'vor_ort', 1, 'Produktionsschule-Team', 'produktionsschule@sufw.de'), + ('6c7f216b-2704-4c81-b17a-000000000009', 'produktionsschule', 'Voranmeldung Kennenlerntermin Produktionsschule', '2026-10-06 10:00:00', '2026-10-06 11:00:00', 3, 'telefonisch', 0, 'Produktionsschule-Team', 'produktionsschule@sufw.de'); + +INSERT IGNORE INTO `sufw_booking_schema_migrations` (`version`) VALUES ('001_booking_schema'); diff --git a/development-system/integration/contao-html-element.html b/development-system/integration/contao-html-element.html index e6919d0..a1f2bdc 100644 --- a/development-system/integration/contao-html-element.html +++ b/development-system/integration/contao-html-element.html @@ -5,5 +5,5 @@ Für Produktionsschule data-project="produktionsschule" setzen. --> -
+
diff --git a/development-system/package.json b/development-system/package.json index 5429d1f..3071289 100644 --- a/development-system/package.json +++ b/development-system/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "test": "node --test tests/booking.test.mjs" + "test": "node --test tests/*.test.mjs" }, "dependencies": { "bootstrap": "^5.3.3" diff --git a/development-system/public/booking/api.php b/development-system/public/booking/api.php new file mode 100644 index 0000000..0800415 --- /dev/null +++ b/development-system/public/booking/api.php @@ -0,0 +1,270 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); +} + +function bookingSession(): void +{ + if (session_status() === PHP_SESSION_ACTIVE) { + return; + } + + session_name('sufw_booking'); + session_set_cookie_params([ + 'httponly' => true, + 'samesite' => 'Lax', + 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), + ]); + session_start(); +} + +function csrfToken(): string +{ + bookingSession(); + if (!isset($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + + return $_SESSION['csrf_token']; +} + +function ensureBookingRateLimit(): void +{ + bookingSession(); + $now = time(); + $recent = array_values(array_filter($_SESSION['booking_requests'] ?? [], static fn ($timestamp): bool => is_int($timestamp) && $timestamp > $now - 900)); + if (count($recent) >= 10) { + respond(429, ['error' => 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.']); + } + + $recent[] = $now; + $_SESSION['booking_requests'] = $recent; +} + +function readJsonBody(): array +{ + $contentLength = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0); + if ($contentLength > 32768) { + respond(413, ['error' => 'Die Anfrage ist zu groß.']); + } + + try { + $payload = json_decode((string) file_get_contents('php://input'), true, 32, JSON_THROW_ON_ERROR); + } catch (JsonException) { + respond(400, ['error' => 'Die Anfrage konnte nicht verarbeitet werden.']); + } + + if (!is_array($payload)) { + respond(400, ['error' => 'Die Anfrage konnte nicht verarbeitet werden.']); + } + + return $payload; +} + +function stringField(array $payload, string $name, int $maxLength): string +{ + $value = trim((string) ($payload[$name] ?? '')); + if ($value === '' || mb_strlen($value) > $maxLength) { + throw new InvalidArgumentException($name); + } + + return $value; +} + +function dateField(array $payload, string $name): ?string +{ + $value = trim((string) ($payload[$name] ?? '')); + if ($value === '') { + return null; + } + + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); + return $date && $date->format('Y-m-d') === $value ? $value : null; +} + +function registrationId(): string +{ + $hex = bin2hex(random_bytes(16)); + return sprintf('%s-%s-%s-%s-%s', substr($hex, 0, 8), substr($hex, 8, 4), substr($hex, 12, 4), substr($hex, 16, 4), substr($hex, 20)); +} + +function normaliseEvent(array $event): array +{ + $event['id'] = (string) $event['id']; + $event['capacity'] = (int) $event['capacity']; + $event['confirmedCount'] = (int) $event['confirmedCount']; + $event['availableCapacity'] = max(0, $event['capacity'] - $event['confirmedCount']); + $event['status'] = $event['availableCapacity'] > 0 ? 'available' : 'full'; + $event['introductionRequired'] = (bool) $event['introductionRequired']; + unset($event['confirmedCount']); + return $event; +} + +function listEvents(PDO $database): never +{ + $project = (string) ($_GET['project'] ?? ''); + $from = (string) ($_GET['from'] ?? ''); + $to = (string) ($_GET['to'] ?? ''); + if (!in_array($project, ['perspektivwerk', 'produktionsschule'], true) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $from) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) { + respond(400, ['error' => 'Ungültige Kalenderanfrage.']); + } + + $fromDate = DateTimeImmutable::createFromFormat('!Y-m-d', $from); + $toDate = DateTimeImmutable::createFromFormat('!Y-m-d', $to); + if (!$fromDate || !$toDate || $fromDate >= $toDate || $fromDate->diff($toDate)->days > 93) { + respond(400, ['error' => 'Ungültiger Kalenderzeitraum.']); + } + + $statement = $database->prepare(<<<'SQL' + SELECT e.id, e.project, e.title, e.starts_at AS startsAt, e.ends_at AS endsAt, + e.capacity, e.format, e.introduction_required AS introductionRequired, + e.contact_name AS contactName, + COUNT(r.id) AS confirmedCount + FROM sufw_booking_events e + LEFT JOIN sufw_booking_registrations r ON r.event_id = e.id AND r.status = 'confirmed' + WHERE e.project = :project AND e.is_active = 1 + AND e.starts_at < :toDate AND e.ends_at >= :fromDate + GROUP BY e.id + ORDER BY e.starts_at, e.title + SQL); + $statement->execute(['project' => $project, 'fromDate' => "$from 00:00:00", 'toDate' => "$to 00:00:00"]); + + respond(200, ['csrfToken' => csrfToken(), 'events' => array_map('normaliseEvent', $statement->fetchAll())]); +} + +function createRegistration(PDO $database): never +{ + ensureBookingRateLimit(); + $providedToken = (string) ($_SERVER['HTTP_X_SUFW_BOOKING_CSRF'] ?? ''); + if (!hash_equals(csrfToken(), $providedToken)) { + respond(403, ['error' => 'Die Sitzung ist abgelaufen. Bitte laden Sie den Kalender neu.']); + } + + $payload = readJsonBody(); + $project = (string) ($payload['project'] ?? ''); + $eventId = (string) ($payload['eventId'] ?? ''); + $privacyAccepted = ($payload['privacyAccepted'] ?? false) === true; + try { + $name = stringField($payload, 'name', 160); + $email = mb_strtolower(stringField($payload, 'email', 254)); + $phone = stringField($payload, 'phone', 64); + } catch (InvalidArgumentException) { + respond(422, ['error' => 'Bitte prüfen Sie Ihre Pflichtangaben.']); + } + + if (!in_array($project, ['perspektivwerk', 'produktionsschule'], true) || !preg_match('/^[a-f0-9-]{36}$/', $eventId) || !filter_var($email, FILTER_VALIDATE_EMAIL) || !preg_match('/^[0-9+()\-\/\s]{6,64}$/', $phone) || !$privacyAccepted) { + respond(422, ['error' => 'Bitte prüfen Sie Ihre Pflichtangaben.']); + } + + $introductionAttended = ($payload['introductionAttended'] ?? false) === true; + $introductionDate = dateField($payload, 'introductionDate'); + try { + $database->beginTransaction(); + $eventStatement = $database->prepare('SELECT id, introduction_required, capacity FROM sufw_booking_events WHERE id = :id AND project = :project AND is_active = 1 FOR UPDATE'); + $eventStatement->execute(['id' => $eventId, 'project' => $project]); + $event = $eventStatement->fetch(); + if (!$event) { + $database->rollBack(); + respond(404, ['error' => 'Dieser Termin ist nicht mehr verfügbar.']); + } + if ((bool) $event['introduction_required'] && (!$introductionAttended || $introductionDate === null)) { + $database->rollBack(); + respond(422, ['error' => 'Bitte bestätigen Sie die Einführungsveranstaltung und geben Sie das Datum an.']); + } + + $countStatement = $database->prepare("SELECT COUNT(*) FROM sufw_booking_registrations WHERE event_id = :eventId AND status = 'confirmed'"); + $countStatement->execute(['eventId' => $eventId]); + if ((int) $countStatement->fetchColumn() >= (int) $event['capacity']) { + $database->rollBack(); + respond(409, ['error' => 'Dieser Termin ist inzwischen ausgebucht. Bitte wählen Sie einen anderen Termin.']); + } + + $duplicateStatement = $database->prepare("SELECT id FROM sufw_booking_registrations WHERE event_id = :eventId AND status = 'confirmed' AND email = :email AND name = :name LIMIT 1"); + $duplicateStatement->execute(['eventId' => $eventId, 'email' => $email, 'name' => $name]); + if ($duplicateStatement->fetch()) { + $database->rollBack(); + respond(409, ['error' => 'Für diese Person liegt bereits eine bestätigte Anmeldung vor.']); + } + + $insert = $database->prepare(<<<'SQL' + INSERT INTO sufw_booking_registrations + (id, event_id, status, name, email, phone, privacy_accepted_at, introduction_attended, introduction_date) + VALUES + (:id, :eventId, 'confirmed', :name, :email, :phone, UTC_TIMESTAMP(), :introductionAttended, :introductionDate) + SQL); + $insert->execute([ + 'id' => registrationId(), 'eventId' => $eventId, 'name' => $name, 'email' => $email, 'phone' => $phone, + 'introductionAttended' => $introductionAttended ? 1 : 0, + 'introductionDate' => $introductionAttended ? $introductionDate : null, + ]); + $database->commit(); + } catch (Throwable $exception) { + if ($database->inTransaction()) { + $database->rollBack(); + } + error_log('SUFW booking API: '.$exception->getMessage()); + respond(500, ['error' => 'Die Anmeldung konnte nicht gespeichert werden. Bitte versuchen Sie es später erneut.']); + } + + respond(201, ['message' => 'Ihre Anmeldung wurde verbindlich gespeichert.']); +} + +try { + $database = bookingDatabase(); + if ($_SERVER['REQUEST_METHOD'] === 'GET') { + listEvents($database); + } + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + createRegistration($database); + } + header('Allow: GET, POST'); + respond(405, ['error' => 'Diese Methode wird nicht unterstützt.']); +} catch (Throwable $exception) { + error_log('SUFW booking API: '.$exception->getMessage()); + respond(500, ['error' => 'Der Kalender ist vorübergehend nicht verfügbar.']); +} diff --git a/development-system/public/booking/booking.css b/development-system/public/booking/booking.css index 65b464f..72c537d 100644 --- a/development-system/public/booking/booking.css +++ b/development-system/public/booking/booking.css @@ -159,6 +159,15 @@ font-style: normal; font-weight: 700; } +.sufw-booking__slot span { + display: grid; + gap: 0.15rem; +} +.sufw-booking__slot small { + color: var(--sufw-booking-muted); + font-size: 0.875rem; + font-weight: 400; +} .sufw-booking__slot:disabled { border-color: #777; background: #eee; @@ -257,6 +266,24 @@ background: #fff1f1; color: #6c0808; } +.sufw-booking__conditional { + display: grid; + gap: 0.8rem; + margin: 0; + padding: 1rem; + border: 1px solid var(--sufw-booking-line); +} +.sufw-booking__conditional legend { + padding: 0 0.25rem; + font-weight: 700; +} +.sufw-booking__load-error { + margin: 1rem 0; + padding: 1rem; + border-left: 4px solid #a31111; + background: #fff1f1; + color: #6c0808; +} .sufw-booking__form-errors ul { margin: 0; padding-left: 1.25rem; diff --git a/development-system/public/booking/booking.js b/development-system/public/booking/booking.js index 88e8eaf..45a51c2 100644 --- a/development-system/public/booking/booking.js +++ b/development-system/public/booking/booking.js @@ -1,313 +1,192 @@ -/* Browsernatives ES-Modul; wird ohne TypeScript-Compiler ausgeliefert. */ -const demoSlots = [ - { id: 'pw-2026-08-24-0900', project: 'perspektivwerk', date: '2026-08-24', start: '09:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-08-24-1100', project: 'perspektivwerk', date: '2026-08-24', start: '11:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-08-24-1400', project: 'perspektivwerk', date: '2026-08-24', start: '14:00', title: 'Soz. Päd. Modul', status: 'booked' }, - { id: 'pw-2026-08-18-1000', project: 'perspektivwerk', date: '2026-08-18', start: '10:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-08-18-1300', project: 'perspektivwerk', date: '2026-08-18', start: '13:00', title: 'Soz. Päd. Modul', status: 'booked' }, - { id: 'pw-2026-08-18-1500', project: 'perspektivwerk', date: '2026-08-18', start: '15:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-08-26-0900', project: 'perspektivwerk', date: '2026-08-26', start: '09:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-08-26-1100', project: 'perspektivwerk', date: '2026-08-26', start: '11:00', title: 'Psy Modul', status: 'booked' }, - { id: 'pw-2026-08-26-1400', project: 'perspektivwerk', date: '2026-08-26', start: '14:00', title: 'Soz. Päd. Modul', status: 'available' }, - { id: 'pw-2026-08-20-0900', project: 'perspektivwerk', date: '2026-08-20', start: '09:00', title: 'Psy Modul', status: 'available' }, - { id: 'pw-2026-08-20-1100', project: 'perspektivwerk', date: '2026-08-20', start: '11:00', title: 'Erstgespräch', status: 'booked' }, - { id: 'pw-2026-08-20-1500', project: 'perspektivwerk', date: '2026-08-20', start: '15:00', title: 'Psy Modul', status: 'available' }, - { id: 'pw-2026-08-28-0900', project: 'perspektivwerk', date: '2026-08-28', start: '09:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-08-28-1100', project: 'perspektivwerk', date: '2026-08-28', start: '11:00', title: 'Soz. Päd. Modul', status: 'available' }, - { id: 'pw-2026-08-28-1400', project: 'perspektivwerk', date: '2026-08-28', start: '14:00', title: 'Erstgespräch', status: 'booked' }, - { id: 'pw-2026-08-31-0800', project: 'perspektivwerk', date: '2026-08-31', start: '08:00', title: 'Praxis Modul', status: 'available' }, - { id: 'pw-2026-09-02-0900', project: 'perspektivwerk', date: '2026-09-02', start: '09:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-09-02-1100', project: 'perspektivwerk', date: '2026-09-02', start: '11:00', title: 'Soz. Päd. Modul', status: 'booked' }, - { id: 'pw-2026-09-02-1400', project: 'perspektivwerk', date: '2026-09-02', start: '14:00', title: 'Psy Modul', status: 'available' }, - { id: 'pw-2026-09-07-0900', project: 'perspektivwerk', date: '2026-09-07', start: '09:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-09-07-1300', project: 'perspektivwerk', date: '2026-09-07', start: '13:00', title: 'Soz. Päd. Modul', status: 'available' }, - { id: 'pw-2026-09-10-0900', project: 'perspektivwerk', date: '2026-09-10', start: '09:00', title: 'Psy Modul', status: 'available' }, - { id: 'pw-2026-09-10-1100', project: 'perspektivwerk', date: '2026-09-10', start: '11:00', title: 'Erstgespräch', status: 'booked' }, - { id: 'pw-2026-09-10-1500', project: 'perspektivwerk', date: '2026-09-10', start: '15:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-09-15-0900', project: 'perspektivwerk', date: '2026-09-15', start: '09:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-09-15-1100', project: 'perspektivwerk', date: '2026-09-15', start: '11:00', title: 'Soz. Päd. Modul', status: 'available' }, - { id: 'pw-2026-09-15-1400', project: 'perspektivwerk', date: '2026-09-15', start: '14:00', title: 'Psy Modul', status: 'booked' }, - { id: 'pw-2026-09-18-0900', project: 'perspektivwerk', date: '2026-09-18', start: '09:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-09-18-1300', project: 'perspektivwerk', date: '2026-09-18', start: '13:00', title: 'Soz. Päd. Modul', status: 'booked' }, - { id: 'pw-2026-09-22-1000', project: 'perspektivwerk', date: '2026-09-22', start: '10:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-09-22-1400', project: 'perspektivwerk', date: '2026-09-22', start: '14:00', title: 'Psy Modul', status: 'available' }, - { id: 'pw-2026-09-25-0900', project: 'perspektivwerk', date: '2026-09-25', start: '09:00', title: 'Soz. Päd. Modul', status: 'available' }, - { id: 'pw-2026-09-25-1100', project: 'perspektivwerk', date: '2026-09-25', start: '11:00', title: 'Erstgespräch', status: 'booked' }, - { id: 'pw-2026-09-29-0900', project: 'perspektivwerk', date: '2026-09-29', start: '09:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-09-29-1100', project: 'perspektivwerk', date: '2026-09-29', start: '11:00', title: 'Psy Modul', status: 'available' }, - { id: 'pw-2026-09-29-1400', project: 'perspektivwerk', date: '2026-09-29', start: '14:00', title: 'Soz. Päd. Modul', status: 'booked' }, - { id: 'ps-2026-08-25-1000', project: 'produktionsschule', date: '2026-08-25', start: '10:00', title: 'Kennenlerntermin', status: 'available' }, - { id: 'ps-2026-08-25-1300', project: 'produktionsschule', date: '2026-08-25', start: '13:00', title: 'Kennenlerntermin', status: 'booked' }, - { id: 'ps-2026-08-31-0800', project: 'produktionsschule', date: '2026-08-31', start: '08:00', title: 'Probearbeiten', status: 'available' } -]; -export function getSlotsForDate(project, date) { - return demoSlots.filter((slot) => slot.project === project && slot.date === date); -} -export function validateBookingField(fieldName, value) { - const text = typeof value === 'string' ? value.trim() : ''; - if (fieldName === 'name') - return text ? '' : 'Bitte geben Sie Ihren Namen ein.'; - if (fieldName === 'email') - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text) ? '' : 'Bitte geben Sie eine gültige E-Mail-Adresse ein.'; - if (fieldName === 'phone') - return !text || /^[0-9+()\-/\s]{6,}$/.test(text) ? '' : 'Bitte geben Sie eine gültige Telefonnummer ein.'; - if (fieldName === 'age') - return !text || (/^\d+$/.test(text) && Number(text) >= 12 && Number(text) <= 112) ? '' : 'Bitte geben Sie ein Alter zwischen 12 und 112 Jahren ein.'; - if (fieldName === 'message') - return text.length <= 2000 ? '' : 'Die Nachricht darf höchstens 2.000 Zeichen enthalten.'; - if (fieldName === 'caseWorker') - return text.length <= 160 ? '' : 'Die Angabe darf höchstens 160 Zeichen enthalten.'; - if (fieldName === 'privacy') - return value ? '' : 'Bitte bestätigen Sie die Datenschutzhinweise.'; - return ''; -} -export function validateBookingForm({ name = '', email = '', phone = '', age = '', message = '', caseWorker = '', privacyAccepted = false }) { - const values = { name, email, phone, age, message, caseWorker, privacy: privacyAccepted }; - return Object.fromEntries(Object.entries(values) - .map(([fieldName, value]) => [fieldName, validateBookingField(fieldName, value)]) - .filter(([, error]) => error)); -} -export function canSubmitBookingForm(privacyAccepted) { - return privacyAccepted; -} -export function bookingConfirmationMessage(appointmentTitle) { - return `Ihre Terminanfrage für ${appointmentTitle} wurde erfolgreich übermittelt.`; -} +/* Browsernatives ES-Modul; wird ohne Build-Schritt ausgeliefert. */ const dateFormatter = new Intl.DateTimeFormat('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); const monthFormatter = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' }); +const timeFormatter = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' }); const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; -function parseDate(value) { - return new Date(`${value}T12:00:00`); -} +const sessionProfileKey = 'sufw-booking-profile-v1'; + +function parseDateTime(value) { return new Date(value.replace(' ', 'T')); } +function parseDate(value) { return new Date(`${value}T12:00:00`); } + export function formatDateKey(date) { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } + +function eventDateRange(event) { return [event.startsAt.slice(0, 10), event.endsAt.slice(0, 10)]; } +function occursOnDate(event, date) { const [startDate, endDate] = eventDateRange(event); return startDate <= date && endDate >= date; } +export function getSlotsForDate(project, date, events = []) { return events.filter((event) => event.project === project && occursOnDate(event, date)); } +export function requiresIntroductionDetails(event = {}) { return event.project === 'produktionsschule' && event.introductionRequired === true; } + +export function formatEventDuration(event) { + const start = parseDateTime(event.startsAt); + const end = parseDateTime(event.endsAt); + const [startDate, endDate] = eventDateRange(event); + if (startDate !== endDate) { + const dayCount = Math.round((parseDate(endDate) - parseDate(startDate)) / 86400000) + 1; + return `${dayCount} Tage`; + } + const hours = Math.round((end - start) / 3600000 * 10) / 10; + return `${String(hours).replace('.', ',')} ${hours === 1 ? 'Stunde' : 'Stunden'}`; +} + +function formatEventTime(event) { return `${timeFormatter.format(parseDateTime(event.startsAt))}–${timeFormatter.format(parseDateTime(event.endsAt))} Uhr`; } +function formatEventFormat(event) { return event.format === 'telefonisch' ? 'Telefonisch' : 'Vor Ort'; } + +export function validateBookingField(fieldName, value) { + const text = typeof value === 'string' ? value.trim() : ''; + if (fieldName === 'name') return text && text.length <= 160 ? '' : 'Bitte geben Sie Ihren Namen ein.'; + if (fieldName === 'email') return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text) && text.length <= 254 ? '' : 'Bitte geben Sie eine gültige E-Mail-Adresse ein.'; + if (fieldName === 'phone') return /^[0-9+()\-/\s]{6,64}$/.test(text) ? '' : 'Bitte geben Sie eine gültige Telefonnummer ein.'; + if (fieldName === 'introductionAttended') return value ? '' : 'Bitte bestätigen Sie die Teilnahme an einer Einführungsveranstaltung.'; + if (fieldName === 'introductionDate') return /^\d{4}-\d{2}-\d{2}$/.test(text) ? '' : 'Bitte geben Sie das Datum der Einführungsveranstaltung ein.'; + if (fieldName === 'privacy') return value ? '' : 'Bitte bestätigen Sie die Datenschutzhinweise.'; + return ''; +} + +export function validateBookingForm({ name = '', email = '', phone = '', privacyAccepted = false, introductionAttended = false, introductionDate = '', event = {} }) { + const values = { name, email, phone, privacy: privacyAccepted }; + if (requiresIntroductionDetails(event)) Object.assign(values, { introductionAttended, introductionDate }); + return Object.fromEntries(Object.entries(values).map(([fieldName, value]) => [fieldName, validateBookingField(fieldName, value)]).filter(([, error]) => error)); +} + +export function canSubmitBookingForm(privacyAccepted) { return privacyAccepted; } +export function bookingConfirmationMessage(appointmentTitle) { return `Ihre Anmeldung für ${appointmentTitle} wurde verbindlich gespeichert.`; } +function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]); } + function createMonthDates(month) { - const first = new Date(month.getFullYear(), month.getMonth(), 1); - const padding = (first.getDay() + 6) % 7; - const days = new Array(padding).fill(null); - const lastDay = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate(); - for (let day = 1; day <= lastDay; day += 1) { - days.push(new Date(month.getFullYear(), month.getMonth(), day)); - } - return days; + const first = new Date(month.getFullYear(), month.getMonth(), 1); + const dates = new Array((first.getDay() + 6) % 7).fill(null); + const lastDay = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate(); + for (let day = 1; day <= lastDay; day += 1) dates.push(new Date(month.getFullYear(), month.getMonth(), day)); + return dates; } -function monthForProject(project) { - const firstSlot = demoSlots.find((slot) => slot.project === project); - return firstSlot ? new Date(parseDate(firstSlot.date).getFullYear(), parseDate(firstSlot.date).getMonth(), 1) : new Date(); -} -function escapeHtml(value) { - return value.replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]); + +function loadSessionProfile() { + try { const profile = JSON.parse(sessionStorage.getItem(sessionProfileKey) ?? '{}'); return typeof profile === 'object' && profile ? profile : {}; } catch { return {}; } } +function saveSessionProfile(profile) { try { sessionStorage.setItem(sessionProfileKey, JSON.stringify(profile)); } catch { /* Speicherung ist optional. */ } } + function showFieldError(form, fieldName, error) { + const field = form.elements.namedItem(fieldName); + if (!field) return; + const errorId = `sufw-booking-${fieldName}-error`; + let message = form.querySelector(`#${errorId}`); + if (!message) { + message = document.createElement('span'); + message.id = errorId; + message.className = 'sufw-booking__field-error'; + message.setAttribute('role', 'alert'); + const host = fieldName === 'privacy' || fieldName === 'introductionAttended' ? field.closest('label') : field; + host.insertAdjacentElement('afterend', message); + field.setAttribute('aria-describedby', errorId); + } + message.textContent = error; + message.hidden = !error; + field.setAttribute('aria-invalid', error ? 'true' : 'false'); +} + +function openConfirmationDialog(container, event) { + const dialog = document.createElement('dialog'); + dialog.className = 'sufw-booking__dialog'; + dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title'); + dialog.innerHTML = `

Termin bestätigt

Vielen Dank für Ihre Anmeldung

${escapeHtml(bookingConfirmationMessage(event.title))}

${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}
${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}

Dies ist die lokale Demoversion. Es wird noch keine Bestätigungs-, Erinnerungs- oder Kalender-E-Mail versendet.

`; + dialog.querySelector('[data-close-confirmation]').addEventListener('click', () => dialog.close()); + dialog.addEventListener('close', () => dialog.remove()); + container.append(dialog); + if (typeof dialog.showModal === 'function') dialog.showModal(); else { dialog.setAttribute('open', ''); dialog.querySelector('[data-close-confirmation]').focus(); } +} + +function openBookingDialog(container, event, state) { + const profile = loadSessionProfile(); + const requiresIntroduction = requiresIntroductionDetails(event); + const dialog = document.createElement('dialog'); + dialog.className = 'sufw-booking__dialog'; + dialog.setAttribute('aria-labelledby', 'sufw-booking-form-title'); + dialog.innerHTML = `

Termin verbindlich anmelden

Ihre Angaben

${escapeHtml(event.title)}
${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(formatEventDuration(event))} · ${escapeHtml(formatEventFormat(event))}

Pflichtfeld

${requiresIntroduction ? '
Voraussetzung für Probearbeiten
' : ''}
`; + const form = dialog.querySelector('form'); + const submitButton = dialog.querySelector('[data-submit-booking]'); + const privacy = form.elements.namedItem('privacy'); + const fieldNames = ['name', 'email', 'phone', 'privacy', ...(requiresIntroduction ? ['introductionAttended', 'introductionDate'] : [])]; + const formError = form.querySelector('.sufw-booking__form-errors'); + const validateField = (fieldName) => { const field = form.elements.namedItem(fieldName); - const errorId = `sufw-booking-${fieldName}-error`; - let message = form.querySelector(`#${errorId}`); - if (!message) { - message = document.createElement('span'); - message.id = errorId; - message.className = 'sufw-booking__field-error'; - message.setAttribute('role', 'alert'); - message.style.display = 'block'; - message.style.marginTop = '.35rem'; - message.style.color = '#6c0808'; - message.style.fontWeight = '400'; - const host = fieldName === 'privacy' ? field.closest('label') : field; - host.insertAdjacentElement('afterend', message); - field.setAttribute('aria-describedby', errorId); - } - message.textContent = error; - message.hidden = !error; - field.setAttribute('aria-invalid', error ? 'true' : 'false'); -} -function openConfirmationDialog(container, slot) { - const projectTitle = slot.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule'; - const dialog = document.createElement('dialog'); - dialog.className = 'sufw-booking__dialog'; - dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title'); - dialog.innerHTML = ` -
-
-

Termin angefragt

-

Vielen Dank für Ihre Anfrage

-

${escapeHtml(bookingConfirmationMessage(slot.title))}

-
-

${escapeHtml(projectTitle)}
${escapeHtml(dateFormatter.format(parseDate(slot.date)))} · ${escapeHtml(slot.start)} Uhr · ${escapeHtml(slot.title)}

-

Wir senden Ihnen eine E-Mail zur Bestätigung Ihres Termins.

-
-
`; - dialog.querySelector('[data-close-confirmation]').addEventListener('click', () => dialog.close()); - dialog.addEventListener('close', () => dialog.remove()); - container.append(dialog); - if (typeof dialog.showModal === 'function') { - dialog.showModal(); - } - else { - dialog.setAttribute('open', ''); - dialog.querySelector('[data-close-confirmation]').focus(); - } -} -function openBookingDialog(container, slot) { - const projectTitle = slot.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule'; - const dialog = document.createElement('dialog'); - dialog.className = 'sufw-booking__dialog'; - dialog.setAttribute('aria-labelledby', 'sufw-booking-form-title'); - dialog.innerHTML = ` -
-
-

Termin anfragen

-

Ihre Angaben

-

${escapeHtml(projectTitle)}
${escapeHtml(dateFormatter.format(parseDate(slot.date)))} · ${escapeHtml(slot.start)} Uhr · ${escapeHtml(slot.title)}

-
-

Pflichtfeld

-
- - - - -
- - - -
-
`; - const form = dialog.querySelector('form'); - const privacy = form.elements.namedItem('privacy'); - const submitButton = dialog.querySelector('[data-submit-booking]'); - const actions = dialog.querySelector('.sufw-booking__form-actions'); - dialog.style.overflowY = 'auto'; - actions.style.position = 'sticky'; - actions.style.bottom = '0'; - actions.style.zIndex = '2'; - actions.style.display = 'flex'; - actions.style.visibility = 'visible'; - actions.style.opacity = '1'; - actions.style.backgroundColor = '#fff'; - actions.style.paddingTop = '.75rem'; - const fieldNames = ['name', 'email', 'phone', 'age', 'message', 'caseWorker', 'privacy']; - const validateField = (fieldName) => { - const field = form.elements.namedItem(fieldName); - const value = fieldName === 'privacy' ? field.checked : field.value; - const error = validateBookingField(fieldName, value); - showFieldError(form, fieldName, error); - return error; - }; - const updateSubmitButton = () => { - submitButton.disabled = !canSubmitBookingForm(privacy.checked); - submitButton.hidden = false; - submitButton.style.display = 'inline-block'; - submitButton.style.visibility = 'visible'; - submitButton.style.opacity = '1'; - submitButton.style.position = 'relative'; - submitButton.style.zIndex = '3'; - submitButton.style.minHeight = '2.75rem'; - }; - fieldNames.forEach((fieldName) => { - const field = form.elements.namedItem(fieldName); - const eventName = fieldName === 'privacy' ? 'change' : 'input'; - field.addEventListener(eventName, () => validateField(fieldName)); - if (fieldName !== 'privacy') - field.addEventListener('blur', () => validateField(fieldName)); - }); - privacy.addEventListener('change', updateSubmitButton); - updateSubmitButton(); - form.addEventListener('submit', (event) => { - event.preventDefault(); - const errors = Object.fromEntries(fieldNames - .map((fieldName) => [fieldName, validateField(fieldName)]) - .filter(([, error]) => error)); - if (Object.keys(errors).length) { - const firstInvalid = form.elements.namedItem(Object.keys(errors)[0]); - firstInvalid.focus(); - return; - } - dialog.close(); - openConfirmationDialog(container, slot); - }); - dialog.querySelector('[data-close-dialog]').addEventListener('click', () => dialog.close()); - dialog.addEventListener('close', () => dialog.remove()); - container.append(dialog); - if (typeof dialog.showModal === 'function') { - dialog.showModal(); - } - else { - dialog.setAttribute('open', ''); - dialog.querySelector('input[name="name"]').focus(); + const value = fieldName === 'privacy' || fieldName === 'introductionAttended' ? field.checked : field.value; + const error = validateBookingField(fieldName, value); + showFieldError(form, fieldName, error); + return error; + }; + fieldNames.forEach((fieldName) => { + const field = form.elements.namedItem(fieldName); + field.addEventListener(field.type === 'checkbox' ? 'change' : 'input', () => validateField(fieldName)); + if (field.type !== 'checkbox') field.addEventListener('blur', () => validateField(fieldName)); + }); + privacy.addEventListener('change', () => { submitButton.disabled = !canSubmitBookingForm(privacy.checked); }); + form.addEventListener('submit', async (submitEvent) => { + submitEvent.preventDefault(); + const values = Object.fromEntries(new FormData(form).entries()); + const booking = { name: values.name ?? '', email: values.email ?? '', phone: values.phone ?? '', privacyAccepted: privacy.checked, introductionAttended: form.elements.namedItem('introductionAttended')?.checked ?? false, introductionDate: values.introductionDate ?? '', event }; + const errors = validateBookingForm(booking); + fieldNames.forEach((fieldName) => showFieldError(form, fieldName, errors[fieldName] ?? '')); + if (Object.keys(errors).length) { form.elements.namedItem(Object.keys(errors)[0]).focus(); return; } + submitButton.disabled = true; + formError.hidden = true; + try { + const response = await fetch(state.apiUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-SUFW-Booking-CSRF': state.csrfToken }, body: JSON.stringify({ project: event.project, eventId: event.id, name: booking.name, email: booking.email, phone: booking.phone, privacyAccepted: true, introductionAttended: booking.introductionAttended, introductionDate: booking.introductionDate }) }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error ?? 'Die Anmeldung konnte nicht gespeichert werden.'); + saveSessionProfile({ name: booking.name, email: booking.email, phone: booking.phone }); + dialog.close(); + await state.reload(); + openConfirmationDialog(container, event); + } catch (error) { + formError.textContent = error instanceof Error ? error.message : 'Die Anmeldung konnte nicht gespeichert werden.'; + formError.hidden = false; + submitButton.disabled = false; } + }); + dialog.querySelector('[data-close-dialog]').addEventListener('click', () => dialog.close()); + dialog.addEventListener('close', () => dialog.remove()); + container.append(dialog); + if (typeof dialog.showModal === 'function') dialog.showModal(); else { dialog.setAttribute('open', ''); form.elements.namedItem('name').focus(); } } + function renderComponent(container) { - const configuredProject = container.dataset.project; - const project = configuredProject === 'produktionsschule' ? 'produktionsschule' : 'perspektivwerk'; - let currentMonth = monthForProject(project); - let selectedDate = demoSlots.find((slot) => slot.project === project)?.date ?? ''; - const render = () => { - const slots = selectedDate ? getSlotsForDate(project, selectedDate) : []; - const selectedLabel = selectedDate ? dateFormatter.format(parseDate(selectedDate)) : 'einen Tag'; - container.innerHTML = ` -
-
-

Terminbuchung

-

${project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule'}

-

Wählen Sie einen Tag. Freie Termine sind mit „frei“ gekennzeichnet.

-
-
Termin verfügbar Termin belegt
-
- -

${monthFormatter.format(currentMonth)}

- -
- -
- ${createMonthDates(currentMonth).map((date) => { - if (!date) - return ''; - const key = formatDateKey(date); - const daySlots = getSlotsForDate(project, key); - const available = daySlots.some((slot) => slot.status === 'available'); - const booked = daySlots.length > 0 && !available; - const selected = key === selectedDate; - const state = available ? 'available' : booked ? 'booked' : 'none'; - const label = daySlots.length ? `${dateFormatter.format(date)}: ${available ? 'freie Termine verfügbar' : 'nur belegte Termine'}` : dateFormatter.format(date); - return ``; - }).join('')} -
-
-

Termine am ${selectedLabel}

- ${slots.length ? `
    ${slots.map((slot) => `
  • `).join('')}
` : '

Für diesen Tag liegen keine Termine vor.

'} -

-
-
`; - container.querySelector('[data-action="previous"]')?.addEventListener('click', () => { - currentMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1); - render(); - }); - container.querySelector('[data-action="next"]')?.addEventListener('click', () => { - currentMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1); - render(); - }); - container.querySelectorAll('[data-date]').forEach((button) => button.addEventListener('click', () => { - selectedDate = button.dataset.date ?? ''; - render(); - })); - container.querySelectorAll('[data-slot-id]').forEach((button) => button.addEventListener('click', () => { - const slot = demoSlots.find((entry) => entry.id === button.dataset.slotId); - if (slot) - openBookingDialog(container, slot); - })); - }; - render(); -} -export function initializeBookingComponents(root = document) { - root.querySelectorAll('[data-sufw-booking]').forEach(renderComponent); -} -if (typeof document !== 'undefined') { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => initializeBookingComponents()); - } - else { - initializeBookingComponents(); - } + const project = container.dataset.project === 'produktionsschule' ? 'produktionsschule' : 'perspektivwerk'; + const now = new Date(); + const state = { apiUrl: container.dataset.apiUrl || '/booking/api.php', csrfToken: '', events: [], currentMonth: new Date(now.getFullYear(), now.getMonth(), 1), selectedDate: '' }; + const fetchEvents = async () => { + const from = formatDateKey(state.currentMonth); + const to = formatDateKey(new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() + 1, 1)); + const response = await fetch(`${state.apiUrl}?${new URLSearchParams({ project, from, to })}`, { credentials: 'same-origin' }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error ?? 'Der Kalender konnte nicht geladen werden.'); + state.events = result.events; + state.csrfToken = result.csrfToken; + if (!state.selectedDate && state.events.length) state.selectedDate = state.events[0].startsAt.slice(0, 10); + }; + const render = () => { + const slots = state.selectedDate ? getSlotsForDate(project, state.selectedDate, state.events) : []; + const selectedLabel = state.selectedDate ? dateFormatter.format(parseDate(state.selectedDate)) : 'einen Tag'; + container.innerHTML = `

Terminbuchung

${project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule'}

Wählen Sie einen Tag. Termine zeigen Dauer, Format und freie Plätze.

Buchbar Ausgebucht

${monthFormatter.format(state.currentMonth)}

${createMonthDates(state.currentMonth).map((date) => { + if (!date) return ''; + const key = formatDateKey(date); const daySlots = getSlotsForDate(project, key, state.events); const available = daySlots.some((event) => event.status === 'available'); const full = daySlots.length > 0 && !available; const selected = key === state.selectedDate; + const label = daySlots.length ? `${dateFormatter.format(date)}: ${available ? 'Termine buchbar' : 'Termine ausgebucht'}` : dateFormatter.format(date); + return ``; + }).join('')}

Termine am ${escapeHtml(selectedLabel)}

${slots.length ? `
    ${slots.map((event) => `
  • `).join('')}
` : '

Für diesen Tag liegen keine Termine vor.

'}

`; + container.querySelector('[data-action="previous"]').addEventListener('click', async () => { state.currentMonth = new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() - 1, 1); state.selectedDate = ''; await loadAndRender(); }); + container.querySelector('[data-action="next"]').addEventListener('click', async () => { state.currentMonth = new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() + 1, 1); state.selectedDate = ''; await loadAndRender(); }); + container.querySelectorAll('[data-date]').forEach((button) => button.addEventListener('click', () => { state.selectedDate = button.dataset.date ?? ''; render(); })); + container.querySelectorAll('[data-event-id]').forEach((button) => button.addEventListener('click', () => { const event = state.events.find((entry) => entry.id === button.dataset.eventId); if (event) openBookingDialog(container, event, state); })); + }; + const loadAndRender = async () => { + container.setAttribute('aria-busy', 'true'); + try { await fetchEvents(); render(); } + catch (error) { container.innerHTML = ``; } + finally { container.removeAttribute('aria-busy'); } + }; + state.reload = loadAndRender; + loadAndRender(); } + +export function initializeBookingComponents(root = document) { root.querySelectorAll('[data-sufw-booking]').forEach(renderComponent); } +if (typeof document !== 'undefined') { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => initializeBookingComponents()); else initializeBookingComponents(); } diff --git a/development-system/public/perspektivwerk/index.html b/development-system/public/perspektivwerk/index.html index 308243c..8f72102 100644 --- a/development-system/public/perspektivwerk/index.html +++ b/development-system/public/perspektivwerk/index.html @@ -21,7 +21,7 @@
Wir sind für Sie da

Unser Team, bestehend aus sozialpädagogischen, pädagogischen und psychologischen Mitarbeiterinnen und Mitarbeitern, unterstützt Ihren Prozess der beruflichen Orientierung. Wir arbeiten eng mit den Bearbeiterinnen und Beratern der Agentur für Arbeit, dem Jobcenter und weiteren Beratungsstellen zusammen.

Die Teilnahme am PerspektivWerk ist kostenlos. Sie bestimmen, an welchen Angeboten Sie teilnehmen.

Beratungsgespräch im PerspektivWerk

Wer kann zu uns kommen?

Menschen zwischen 15 und 65 Jahren, die sich beruflich neu orientieren möchten, beim Einstieg Unterstützung suchen oder ihre Möglichkeiten gemeinsam klären wollen.

1. Orientierung & Ersteinschätzung

Sozialpädagogische und psychologische Module helfen dabei, persönliche Situation, Motivation, Belastbarkeit und Stärken einzuschätzen.

2. Praktische Erprobung & Belastungstest

Arbeitsproben und praktische Erprobungen machen fachliche, soziale und arbeitsbezogene Kompetenzen sichtbar.

3. Auswertung & Perspektiventwicklung

Wir führen die Ergebnisse zusammen und entwickeln konkrete, realistische nächste Schritte.

-
+

Sie haben Fragen zu dieser Maßnahme?

Haben Sie Interesse an unserem Angebot, melden Sie sich bei uns. Wir beraten Sie gern vorab zu den weiteren Schritten.

Kontakt: (0176) 60328929 · perspektivwerk@sufw.de · Dresdner Straße 16, 01326 Dresden

diff --git a/development-system/public/perspektivwerk/js/booking.css b/development-system/public/perspektivwerk/js/booking.css index 65b464f..72c537d 100644 --- a/development-system/public/perspektivwerk/js/booking.css +++ b/development-system/public/perspektivwerk/js/booking.css @@ -159,6 +159,15 @@ font-style: normal; font-weight: 700; } +.sufw-booking__slot span { + display: grid; + gap: 0.15rem; +} +.sufw-booking__slot small { + color: var(--sufw-booking-muted); + font-size: 0.875rem; + font-weight: 400; +} .sufw-booking__slot:disabled { border-color: #777; background: #eee; @@ -257,6 +266,24 @@ background: #fff1f1; color: #6c0808; } +.sufw-booking__conditional { + display: grid; + gap: 0.8rem; + margin: 0; + padding: 1rem; + border: 1px solid var(--sufw-booking-line); +} +.sufw-booking__conditional legend { + padding: 0 0.25rem; + font-weight: 700; +} +.sufw-booking__load-error { + margin: 1rem 0; + padding: 1rem; + border-left: 4px solid #a31111; + background: #fff1f1; + color: #6c0808; +} .sufw-booking__form-errors ul { margin: 0; padding-left: 1.25rem; diff --git a/development-system/public/perspektivwerk/js/booking.js b/development-system/public/perspektivwerk/js/booking.js index 88e8eaf..45a51c2 100644 --- a/development-system/public/perspektivwerk/js/booking.js +++ b/development-system/public/perspektivwerk/js/booking.js @@ -1,313 +1,192 @@ -/* Browsernatives ES-Modul; wird ohne TypeScript-Compiler ausgeliefert. */ -const demoSlots = [ - { id: 'pw-2026-08-24-0900', project: 'perspektivwerk', date: '2026-08-24', start: '09:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-08-24-1100', project: 'perspektivwerk', date: '2026-08-24', start: '11:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-08-24-1400', project: 'perspektivwerk', date: '2026-08-24', start: '14:00', title: 'Soz. Päd. Modul', status: 'booked' }, - { id: 'pw-2026-08-18-1000', project: 'perspektivwerk', date: '2026-08-18', start: '10:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-08-18-1300', project: 'perspektivwerk', date: '2026-08-18', start: '13:00', title: 'Soz. Päd. Modul', status: 'booked' }, - { id: 'pw-2026-08-18-1500', project: 'perspektivwerk', date: '2026-08-18', start: '15:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-08-26-0900', project: 'perspektivwerk', date: '2026-08-26', start: '09:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-08-26-1100', project: 'perspektivwerk', date: '2026-08-26', start: '11:00', title: 'Psy Modul', status: 'booked' }, - { id: 'pw-2026-08-26-1400', project: 'perspektivwerk', date: '2026-08-26', start: '14:00', title: 'Soz. Päd. Modul', status: 'available' }, - { id: 'pw-2026-08-20-0900', project: 'perspektivwerk', date: '2026-08-20', start: '09:00', title: 'Psy Modul', status: 'available' }, - { id: 'pw-2026-08-20-1100', project: 'perspektivwerk', date: '2026-08-20', start: '11:00', title: 'Erstgespräch', status: 'booked' }, - { id: 'pw-2026-08-20-1500', project: 'perspektivwerk', date: '2026-08-20', start: '15:00', title: 'Psy Modul', status: 'available' }, - { id: 'pw-2026-08-28-0900', project: 'perspektivwerk', date: '2026-08-28', start: '09:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-08-28-1100', project: 'perspektivwerk', date: '2026-08-28', start: '11:00', title: 'Soz. Päd. Modul', status: 'available' }, - { id: 'pw-2026-08-28-1400', project: 'perspektivwerk', date: '2026-08-28', start: '14:00', title: 'Erstgespräch', status: 'booked' }, - { id: 'pw-2026-08-31-0800', project: 'perspektivwerk', date: '2026-08-31', start: '08:00', title: 'Praxis Modul', status: 'available' }, - { id: 'pw-2026-09-02-0900', project: 'perspektivwerk', date: '2026-09-02', start: '09:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-09-02-1100', project: 'perspektivwerk', date: '2026-09-02', start: '11:00', title: 'Soz. Päd. Modul', status: 'booked' }, - { id: 'pw-2026-09-02-1400', project: 'perspektivwerk', date: '2026-09-02', start: '14:00', title: 'Psy Modul', status: 'available' }, - { id: 'pw-2026-09-07-0900', project: 'perspektivwerk', date: '2026-09-07', start: '09:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-09-07-1300', project: 'perspektivwerk', date: '2026-09-07', start: '13:00', title: 'Soz. Päd. Modul', status: 'available' }, - { id: 'pw-2026-09-10-0900', project: 'perspektivwerk', date: '2026-09-10', start: '09:00', title: 'Psy Modul', status: 'available' }, - { id: 'pw-2026-09-10-1100', project: 'perspektivwerk', date: '2026-09-10', start: '11:00', title: 'Erstgespräch', status: 'booked' }, - { id: 'pw-2026-09-10-1500', project: 'perspektivwerk', date: '2026-09-10', start: '15:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-09-15-0900', project: 'perspektivwerk', date: '2026-09-15', start: '09:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-09-15-1100', project: 'perspektivwerk', date: '2026-09-15', start: '11:00', title: 'Soz. Päd. Modul', status: 'available' }, - { id: 'pw-2026-09-15-1400', project: 'perspektivwerk', date: '2026-09-15', start: '14:00', title: 'Psy Modul', status: 'booked' }, - { id: 'pw-2026-09-18-0900', project: 'perspektivwerk', date: '2026-09-18', start: '09:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-09-18-1300', project: 'perspektivwerk', date: '2026-09-18', start: '13:00', title: 'Soz. Päd. Modul', status: 'booked' }, - { id: 'pw-2026-09-22-1000', project: 'perspektivwerk', date: '2026-09-22', start: '10:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-09-22-1400', project: 'perspektivwerk', date: '2026-09-22', start: '14:00', title: 'Psy Modul', status: 'available' }, - { id: 'pw-2026-09-25-0900', project: 'perspektivwerk', date: '2026-09-25', start: '09:00', title: 'Soz. Päd. Modul', status: 'available' }, - { id: 'pw-2026-09-25-1100', project: 'perspektivwerk', date: '2026-09-25', start: '11:00', title: 'Erstgespräch', status: 'booked' }, - { id: 'pw-2026-09-29-0900', project: 'perspektivwerk', date: '2026-09-29', start: '09:00', title: 'Erstgespräch', status: 'available' }, - { id: 'pw-2026-09-29-1100', project: 'perspektivwerk', date: '2026-09-29', start: '11:00', title: 'Psy Modul', status: 'available' }, - { id: 'pw-2026-09-29-1400', project: 'perspektivwerk', date: '2026-09-29', start: '14:00', title: 'Soz. Päd. Modul', status: 'booked' }, - { id: 'ps-2026-08-25-1000', project: 'produktionsschule', date: '2026-08-25', start: '10:00', title: 'Kennenlerntermin', status: 'available' }, - { id: 'ps-2026-08-25-1300', project: 'produktionsschule', date: '2026-08-25', start: '13:00', title: 'Kennenlerntermin', status: 'booked' }, - { id: 'ps-2026-08-31-0800', project: 'produktionsschule', date: '2026-08-31', start: '08:00', title: 'Probearbeiten', status: 'available' } -]; -export function getSlotsForDate(project, date) { - return demoSlots.filter((slot) => slot.project === project && slot.date === date); -} -export function validateBookingField(fieldName, value) { - const text = typeof value === 'string' ? value.trim() : ''; - if (fieldName === 'name') - return text ? '' : 'Bitte geben Sie Ihren Namen ein.'; - if (fieldName === 'email') - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text) ? '' : 'Bitte geben Sie eine gültige E-Mail-Adresse ein.'; - if (fieldName === 'phone') - return !text || /^[0-9+()\-/\s]{6,}$/.test(text) ? '' : 'Bitte geben Sie eine gültige Telefonnummer ein.'; - if (fieldName === 'age') - return !text || (/^\d+$/.test(text) && Number(text) >= 12 && Number(text) <= 112) ? '' : 'Bitte geben Sie ein Alter zwischen 12 und 112 Jahren ein.'; - if (fieldName === 'message') - return text.length <= 2000 ? '' : 'Die Nachricht darf höchstens 2.000 Zeichen enthalten.'; - if (fieldName === 'caseWorker') - return text.length <= 160 ? '' : 'Die Angabe darf höchstens 160 Zeichen enthalten.'; - if (fieldName === 'privacy') - return value ? '' : 'Bitte bestätigen Sie die Datenschutzhinweise.'; - return ''; -} -export function validateBookingForm({ name = '', email = '', phone = '', age = '', message = '', caseWorker = '', privacyAccepted = false }) { - const values = { name, email, phone, age, message, caseWorker, privacy: privacyAccepted }; - return Object.fromEntries(Object.entries(values) - .map(([fieldName, value]) => [fieldName, validateBookingField(fieldName, value)]) - .filter(([, error]) => error)); -} -export function canSubmitBookingForm(privacyAccepted) { - return privacyAccepted; -} -export function bookingConfirmationMessage(appointmentTitle) { - return `Ihre Terminanfrage für ${appointmentTitle} wurde erfolgreich übermittelt.`; -} +/* Browsernatives ES-Modul; wird ohne Build-Schritt ausgeliefert. */ const dateFormatter = new Intl.DateTimeFormat('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); const monthFormatter = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' }); +const timeFormatter = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' }); const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; -function parseDate(value) { - return new Date(`${value}T12:00:00`); -} +const sessionProfileKey = 'sufw-booking-profile-v1'; + +function parseDateTime(value) { return new Date(value.replace(' ', 'T')); } +function parseDate(value) { return new Date(`${value}T12:00:00`); } + export function formatDateKey(date) { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } + +function eventDateRange(event) { return [event.startsAt.slice(0, 10), event.endsAt.slice(0, 10)]; } +function occursOnDate(event, date) { const [startDate, endDate] = eventDateRange(event); return startDate <= date && endDate >= date; } +export function getSlotsForDate(project, date, events = []) { return events.filter((event) => event.project === project && occursOnDate(event, date)); } +export function requiresIntroductionDetails(event = {}) { return event.project === 'produktionsschule' && event.introductionRequired === true; } + +export function formatEventDuration(event) { + const start = parseDateTime(event.startsAt); + const end = parseDateTime(event.endsAt); + const [startDate, endDate] = eventDateRange(event); + if (startDate !== endDate) { + const dayCount = Math.round((parseDate(endDate) - parseDate(startDate)) / 86400000) + 1; + return `${dayCount} Tage`; + } + const hours = Math.round((end - start) / 3600000 * 10) / 10; + return `${String(hours).replace('.', ',')} ${hours === 1 ? 'Stunde' : 'Stunden'}`; +} + +function formatEventTime(event) { return `${timeFormatter.format(parseDateTime(event.startsAt))}–${timeFormatter.format(parseDateTime(event.endsAt))} Uhr`; } +function formatEventFormat(event) { return event.format === 'telefonisch' ? 'Telefonisch' : 'Vor Ort'; } + +export function validateBookingField(fieldName, value) { + const text = typeof value === 'string' ? value.trim() : ''; + if (fieldName === 'name') return text && text.length <= 160 ? '' : 'Bitte geben Sie Ihren Namen ein.'; + if (fieldName === 'email') return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text) && text.length <= 254 ? '' : 'Bitte geben Sie eine gültige E-Mail-Adresse ein.'; + if (fieldName === 'phone') return /^[0-9+()\-/\s]{6,64}$/.test(text) ? '' : 'Bitte geben Sie eine gültige Telefonnummer ein.'; + if (fieldName === 'introductionAttended') return value ? '' : 'Bitte bestätigen Sie die Teilnahme an einer Einführungsveranstaltung.'; + if (fieldName === 'introductionDate') return /^\d{4}-\d{2}-\d{2}$/.test(text) ? '' : 'Bitte geben Sie das Datum der Einführungsveranstaltung ein.'; + if (fieldName === 'privacy') return value ? '' : 'Bitte bestätigen Sie die Datenschutzhinweise.'; + return ''; +} + +export function validateBookingForm({ name = '', email = '', phone = '', privacyAccepted = false, introductionAttended = false, introductionDate = '', event = {} }) { + const values = { name, email, phone, privacy: privacyAccepted }; + if (requiresIntroductionDetails(event)) Object.assign(values, { introductionAttended, introductionDate }); + return Object.fromEntries(Object.entries(values).map(([fieldName, value]) => [fieldName, validateBookingField(fieldName, value)]).filter(([, error]) => error)); +} + +export function canSubmitBookingForm(privacyAccepted) { return privacyAccepted; } +export function bookingConfirmationMessage(appointmentTitle) { return `Ihre Anmeldung für ${appointmentTitle} wurde verbindlich gespeichert.`; } +function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]); } + function createMonthDates(month) { - const first = new Date(month.getFullYear(), month.getMonth(), 1); - const padding = (first.getDay() + 6) % 7; - const days = new Array(padding).fill(null); - const lastDay = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate(); - for (let day = 1; day <= lastDay; day += 1) { - days.push(new Date(month.getFullYear(), month.getMonth(), day)); - } - return days; + const first = new Date(month.getFullYear(), month.getMonth(), 1); + const dates = new Array((first.getDay() + 6) % 7).fill(null); + const lastDay = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate(); + for (let day = 1; day <= lastDay; day += 1) dates.push(new Date(month.getFullYear(), month.getMonth(), day)); + return dates; } -function monthForProject(project) { - const firstSlot = demoSlots.find((slot) => slot.project === project); - return firstSlot ? new Date(parseDate(firstSlot.date).getFullYear(), parseDate(firstSlot.date).getMonth(), 1) : new Date(); -} -function escapeHtml(value) { - return value.replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]); + +function loadSessionProfile() { + try { const profile = JSON.parse(sessionStorage.getItem(sessionProfileKey) ?? '{}'); return typeof profile === 'object' && profile ? profile : {}; } catch { return {}; } } +function saveSessionProfile(profile) { try { sessionStorage.setItem(sessionProfileKey, JSON.stringify(profile)); } catch { /* Speicherung ist optional. */ } } + function showFieldError(form, fieldName, error) { + const field = form.elements.namedItem(fieldName); + if (!field) return; + const errorId = `sufw-booking-${fieldName}-error`; + let message = form.querySelector(`#${errorId}`); + if (!message) { + message = document.createElement('span'); + message.id = errorId; + message.className = 'sufw-booking__field-error'; + message.setAttribute('role', 'alert'); + const host = fieldName === 'privacy' || fieldName === 'introductionAttended' ? field.closest('label') : field; + host.insertAdjacentElement('afterend', message); + field.setAttribute('aria-describedby', errorId); + } + message.textContent = error; + message.hidden = !error; + field.setAttribute('aria-invalid', error ? 'true' : 'false'); +} + +function openConfirmationDialog(container, event) { + const dialog = document.createElement('dialog'); + dialog.className = 'sufw-booking__dialog'; + dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title'); + dialog.innerHTML = `

Termin bestätigt

Vielen Dank für Ihre Anmeldung

${escapeHtml(bookingConfirmationMessage(event.title))}

${escapeHtml(event.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule')}
${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(event.title)}

Dies ist die lokale Demoversion. Es wird noch keine Bestätigungs-, Erinnerungs- oder Kalender-E-Mail versendet.

`; + dialog.querySelector('[data-close-confirmation]').addEventListener('click', () => dialog.close()); + dialog.addEventListener('close', () => dialog.remove()); + container.append(dialog); + if (typeof dialog.showModal === 'function') dialog.showModal(); else { dialog.setAttribute('open', ''); dialog.querySelector('[data-close-confirmation]').focus(); } +} + +function openBookingDialog(container, event, state) { + const profile = loadSessionProfile(); + const requiresIntroduction = requiresIntroductionDetails(event); + const dialog = document.createElement('dialog'); + dialog.className = 'sufw-booking__dialog'; + dialog.setAttribute('aria-labelledby', 'sufw-booking-form-title'); + dialog.innerHTML = `

Termin verbindlich anmelden

Ihre Angaben

${escapeHtml(event.title)}
${escapeHtml(dateFormatter.format(parseDate(event.startsAt.slice(0, 10))))} · ${formatEventTime(event)} · ${escapeHtml(formatEventDuration(event))} · ${escapeHtml(formatEventFormat(event))}

Pflichtfeld

${requiresIntroduction ? '
Voraussetzung für Probearbeiten
' : ''}
`; + const form = dialog.querySelector('form'); + const submitButton = dialog.querySelector('[data-submit-booking]'); + const privacy = form.elements.namedItem('privacy'); + const fieldNames = ['name', 'email', 'phone', 'privacy', ...(requiresIntroduction ? ['introductionAttended', 'introductionDate'] : [])]; + const formError = form.querySelector('.sufw-booking__form-errors'); + const validateField = (fieldName) => { const field = form.elements.namedItem(fieldName); - const errorId = `sufw-booking-${fieldName}-error`; - let message = form.querySelector(`#${errorId}`); - if (!message) { - message = document.createElement('span'); - message.id = errorId; - message.className = 'sufw-booking__field-error'; - message.setAttribute('role', 'alert'); - message.style.display = 'block'; - message.style.marginTop = '.35rem'; - message.style.color = '#6c0808'; - message.style.fontWeight = '400'; - const host = fieldName === 'privacy' ? field.closest('label') : field; - host.insertAdjacentElement('afterend', message); - field.setAttribute('aria-describedby', errorId); - } - message.textContent = error; - message.hidden = !error; - field.setAttribute('aria-invalid', error ? 'true' : 'false'); -} -function openConfirmationDialog(container, slot) { - const projectTitle = slot.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule'; - const dialog = document.createElement('dialog'); - dialog.className = 'sufw-booking__dialog'; - dialog.setAttribute('aria-labelledby', 'sufw-booking-confirmation-title'); - dialog.innerHTML = ` -
-
-

Termin angefragt

-

Vielen Dank für Ihre Anfrage

-

${escapeHtml(bookingConfirmationMessage(slot.title))}

-
-

${escapeHtml(projectTitle)}
${escapeHtml(dateFormatter.format(parseDate(slot.date)))} · ${escapeHtml(slot.start)} Uhr · ${escapeHtml(slot.title)}

-

Wir senden Ihnen eine E-Mail zur Bestätigung Ihres Termins.

-
-
`; - dialog.querySelector('[data-close-confirmation]').addEventListener('click', () => dialog.close()); - dialog.addEventListener('close', () => dialog.remove()); - container.append(dialog); - if (typeof dialog.showModal === 'function') { - dialog.showModal(); - } - else { - dialog.setAttribute('open', ''); - dialog.querySelector('[data-close-confirmation]').focus(); - } -} -function openBookingDialog(container, slot) { - const projectTitle = slot.project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule'; - const dialog = document.createElement('dialog'); - dialog.className = 'sufw-booking__dialog'; - dialog.setAttribute('aria-labelledby', 'sufw-booking-form-title'); - dialog.innerHTML = ` -
-
-

Termin anfragen

-

Ihre Angaben

-

${escapeHtml(projectTitle)}
${escapeHtml(dateFormatter.format(parseDate(slot.date)))} · ${escapeHtml(slot.start)} Uhr · ${escapeHtml(slot.title)}

-
-

Pflichtfeld

-
- - - - -
- - - -
-
`; - const form = dialog.querySelector('form'); - const privacy = form.elements.namedItem('privacy'); - const submitButton = dialog.querySelector('[data-submit-booking]'); - const actions = dialog.querySelector('.sufw-booking__form-actions'); - dialog.style.overflowY = 'auto'; - actions.style.position = 'sticky'; - actions.style.bottom = '0'; - actions.style.zIndex = '2'; - actions.style.display = 'flex'; - actions.style.visibility = 'visible'; - actions.style.opacity = '1'; - actions.style.backgroundColor = '#fff'; - actions.style.paddingTop = '.75rem'; - const fieldNames = ['name', 'email', 'phone', 'age', 'message', 'caseWorker', 'privacy']; - const validateField = (fieldName) => { - const field = form.elements.namedItem(fieldName); - const value = fieldName === 'privacy' ? field.checked : field.value; - const error = validateBookingField(fieldName, value); - showFieldError(form, fieldName, error); - return error; - }; - const updateSubmitButton = () => { - submitButton.disabled = !canSubmitBookingForm(privacy.checked); - submitButton.hidden = false; - submitButton.style.display = 'inline-block'; - submitButton.style.visibility = 'visible'; - submitButton.style.opacity = '1'; - submitButton.style.position = 'relative'; - submitButton.style.zIndex = '3'; - submitButton.style.minHeight = '2.75rem'; - }; - fieldNames.forEach((fieldName) => { - const field = form.elements.namedItem(fieldName); - const eventName = fieldName === 'privacy' ? 'change' : 'input'; - field.addEventListener(eventName, () => validateField(fieldName)); - if (fieldName !== 'privacy') - field.addEventListener('blur', () => validateField(fieldName)); - }); - privacy.addEventListener('change', updateSubmitButton); - updateSubmitButton(); - form.addEventListener('submit', (event) => { - event.preventDefault(); - const errors = Object.fromEntries(fieldNames - .map((fieldName) => [fieldName, validateField(fieldName)]) - .filter(([, error]) => error)); - if (Object.keys(errors).length) { - const firstInvalid = form.elements.namedItem(Object.keys(errors)[0]); - firstInvalid.focus(); - return; - } - dialog.close(); - openConfirmationDialog(container, slot); - }); - dialog.querySelector('[data-close-dialog]').addEventListener('click', () => dialog.close()); - dialog.addEventListener('close', () => dialog.remove()); - container.append(dialog); - if (typeof dialog.showModal === 'function') { - dialog.showModal(); - } - else { - dialog.setAttribute('open', ''); - dialog.querySelector('input[name="name"]').focus(); + const value = fieldName === 'privacy' || fieldName === 'introductionAttended' ? field.checked : field.value; + const error = validateBookingField(fieldName, value); + showFieldError(form, fieldName, error); + return error; + }; + fieldNames.forEach((fieldName) => { + const field = form.elements.namedItem(fieldName); + field.addEventListener(field.type === 'checkbox' ? 'change' : 'input', () => validateField(fieldName)); + if (field.type !== 'checkbox') field.addEventListener('blur', () => validateField(fieldName)); + }); + privacy.addEventListener('change', () => { submitButton.disabled = !canSubmitBookingForm(privacy.checked); }); + form.addEventListener('submit', async (submitEvent) => { + submitEvent.preventDefault(); + const values = Object.fromEntries(new FormData(form).entries()); + const booking = { name: values.name ?? '', email: values.email ?? '', phone: values.phone ?? '', privacyAccepted: privacy.checked, introductionAttended: form.elements.namedItem('introductionAttended')?.checked ?? false, introductionDate: values.introductionDate ?? '', event }; + const errors = validateBookingForm(booking); + fieldNames.forEach((fieldName) => showFieldError(form, fieldName, errors[fieldName] ?? '')); + if (Object.keys(errors).length) { form.elements.namedItem(Object.keys(errors)[0]).focus(); return; } + submitButton.disabled = true; + formError.hidden = true; + try { + const response = await fetch(state.apiUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-SUFW-Booking-CSRF': state.csrfToken }, body: JSON.stringify({ project: event.project, eventId: event.id, name: booking.name, email: booking.email, phone: booking.phone, privacyAccepted: true, introductionAttended: booking.introductionAttended, introductionDate: booking.introductionDate }) }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error ?? 'Die Anmeldung konnte nicht gespeichert werden.'); + saveSessionProfile({ name: booking.name, email: booking.email, phone: booking.phone }); + dialog.close(); + await state.reload(); + openConfirmationDialog(container, event); + } catch (error) { + formError.textContent = error instanceof Error ? error.message : 'Die Anmeldung konnte nicht gespeichert werden.'; + formError.hidden = false; + submitButton.disabled = false; } + }); + dialog.querySelector('[data-close-dialog]').addEventListener('click', () => dialog.close()); + dialog.addEventListener('close', () => dialog.remove()); + container.append(dialog); + if (typeof dialog.showModal === 'function') dialog.showModal(); else { dialog.setAttribute('open', ''); form.elements.namedItem('name').focus(); } } + function renderComponent(container) { - const configuredProject = container.dataset.project; - const project = configuredProject === 'produktionsschule' ? 'produktionsschule' : 'perspektivwerk'; - let currentMonth = monthForProject(project); - let selectedDate = demoSlots.find((slot) => slot.project === project)?.date ?? ''; - const render = () => { - const slots = selectedDate ? getSlotsForDate(project, selectedDate) : []; - const selectedLabel = selectedDate ? dateFormatter.format(parseDate(selectedDate)) : 'einen Tag'; - container.innerHTML = ` -
-
-

Terminbuchung

-

${project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule'}

-

Wählen Sie einen Tag. Freie Termine sind mit „frei“ gekennzeichnet.

-
-
Termin verfügbar Termin belegt
-
- -

${monthFormatter.format(currentMonth)}

- -
- -
- ${createMonthDates(currentMonth).map((date) => { - if (!date) - return ''; - const key = formatDateKey(date); - const daySlots = getSlotsForDate(project, key); - const available = daySlots.some((slot) => slot.status === 'available'); - const booked = daySlots.length > 0 && !available; - const selected = key === selectedDate; - const state = available ? 'available' : booked ? 'booked' : 'none'; - const label = daySlots.length ? `${dateFormatter.format(date)}: ${available ? 'freie Termine verfügbar' : 'nur belegte Termine'}` : dateFormatter.format(date); - return ``; - }).join('')} -
-
-

Termine am ${selectedLabel}

- ${slots.length ? `
    ${slots.map((slot) => `
  • `).join('')}
` : '

Für diesen Tag liegen keine Termine vor.

'} -

-
-
`; - container.querySelector('[data-action="previous"]')?.addEventListener('click', () => { - currentMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1); - render(); - }); - container.querySelector('[data-action="next"]')?.addEventListener('click', () => { - currentMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1); - render(); - }); - container.querySelectorAll('[data-date]').forEach((button) => button.addEventListener('click', () => { - selectedDate = button.dataset.date ?? ''; - render(); - })); - container.querySelectorAll('[data-slot-id]').forEach((button) => button.addEventListener('click', () => { - const slot = demoSlots.find((entry) => entry.id === button.dataset.slotId); - if (slot) - openBookingDialog(container, slot); - })); - }; - render(); -} -export function initializeBookingComponents(root = document) { - root.querySelectorAll('[data-sufw-booking]').forEach(renderComponent); -} -if (typeof document !== 'undefined') { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => initializeBookingComponents()); - } - else { - initializeBookingComponents(); - } + const project = container.dataset.project === 'produktionsschule' ? 'produktionsschule' : 'perspektivwerk'; + const now = new Date(); + const state = { apiUrl: container.dataset.apiUrl || '/booking/api.php', csrfToken: '', events: [], currentMonth: new Date(now.getFullYear(), now.getMonth(), 1), selectedDate: '' }; + const fetchEvents = async () => { + const from = formatDateKey(state.currentMonth); + const to = formatDateKey(new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() + 1, 1)); + const response = await fetch(`${state.apiUrl}?${new URLSearchParams({ project, from, to })}`, { credentials: 'same-origin' }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error ?? 'Der Kalender konnte nicht geladen werden.'); + state.events = result.events; + state.csrfToken = result.csrfToken; + if (!state.selectedDate && state.events.length) state.selectedDate = state.events[0].startsAt.slice(0, 10); + }; + const render = () => { + const slots = state.selectedDate ? getSlotsForDate(project, state.selectedDate, state.events) : []; + const selectedLabel = state.selectedDate ? dateFormatter.format(parseDate(state.selectedDate)) : 'einen Tag'; + container.innerHTML = `

Terminbuchung

${project === 'perspektivwerk' ? 'PerspektivWerk' : 'Produktionsschule'}

Wählen Sie einen Tag. Termine zeigen Dauer, Format und freie Plätze.

Buchbar Ausgebucht

${monthFormatter.format(state.currentMonth)}

${createMonthDates(state.currentMonth).map((date) => { + if (!date) return ''; + const key = formatDateKey(date); const daySlots = getSlotsForDate(project, key, state.events); const available = daySlots.some((event) => event.status === 'available'); const full = daySlots.length > 0 && !available; const selected = key === state.selectedDate; + const label = daySlots.length ? `${dateFormatter.format(date)}: ${available ? 'Termine buchbar' : 'Termine ausgebucht'}` : dateFormatter.format(date); + return ``; + }).join('')}

Termine am ${escapeHtml(selectedLabel)}

${slots.length ? `
    ${slots.map((event) => `
  • `).join('')}
` : '

Für diesen Tag liegen keine Termine vor.

'}

`; + container.querySelector('[data-action="previous"]').addEventListener('click', async () => { state.currentMonth = new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() - 1, 1); state.selectedDate = ''; await loadAndRender(); }); + container.querySelector('[data-action="next"]').addEventListener('click', async () => { state.currentMonth = new Date(state.currentMonth.getFullYear(), state.currentMonth.getMonth() + 1, 1); state.selectedDate = ''; await loadAndRender(); }); + container.querySelectorAll('[data-date]').forEach((button) => button.addEventListener('click', () => { state.selectedDate = button.dataset.date ?? ''; render(); })); + container.querySelectorAll('[data-event-id]').forEach((button) => button.addEventListener('click', () => { const event = state.events.find((entry) => entry.id === button.dataset.eventId); if (event) openBookingDialog(container, event, state); })); + }; + const loadAndRender = async () => { + container.setAttribute('aria-busy', 'true'); + try { await fetchEvents(); render(); } + catch (error) { container.innerHTML = ``; } + finally { container.removeAttribute('aria-busy'); } + }; + state.reload = loadAndRender; + loadAndRender(); } + +export function initializeBookingComponents(root = document) { root.querySelectorAll('[data-sufw-booking]').forEach(renderComponent); } +if (typeof document !== 'undefined') { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => initializeBookingComponents()); else initializeBookingComponents(); } diff --git a/development-system/scripts/Invoke-BookingMigrations.ps1 b/development-system/scripts/Invoke-BookingMigrations.ps1 new file mode 100644 index 0000000..1526962 --- /dev/null +++ b/development-system/scripts/Invoke-BookingMigrations.ps1 @@ -0,0 +1,34 @@ +[CmdletBinding()] +param( + [string]$ComposeFile, + [string]$MigrationDirectory +) + +$ErrorActionPreference = 'Stop' +$scriptDirectory = Split-Path -Parent $PSCommandPath +if ([string]::IsNullOrWhiteSpace($scriptDirectory)) { + $scriptDirectory = (Get-Location).Path +} +if ([string]::IsNullOrWhiteSpace($ComposeFile)) { + $ComposeFile = Join-Path $scriptDirectory '..\docker-compose.yml' +} +if ([string]::IsNullOrWhiteSpace($MigrationDirectory)) { + $MigrationDirectory = Join-Path $scriptDirectory '..\database\migrations' +} + +if (-not (Test-Path -LiteralPath $MigrationDirectory -PathType Container)) { + throw "Das Migrationsverzeichnis '$MigrationDirectory' wurde nicht gefunden." +} + +$composeFile = (Resolve-Path -LiteralPath $ComposeFile).Path +$migrations = Get-ChildItem -LiteralPath $MigrationDirectory -Filter '*.sql' -File | Sort-Object Name + +foreach ($migration in $migrations) { + $sql = Get-Content -LiteralPath $migration.FullName -Raw + $sql | & docker compose -f $composeFile exec -T db sh -lc 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' + if ($LASTEXITCODE -ne 0) { + throw "Migration '$($migration.Name)' ist fehlgeschlagen." + } + + Write-Host "Migration ausgeführt: $($migration.Name)" +} diff --git a/development-system/tests/booking-migrations.test.mjs b/development-system/tests/booking-migrations.test.mjs new file mode 100644 index 0000000..4b314c2 --- /dev/null +++ b/development-system/tests/booking-migrations.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +test('liefert eine externe, idempotente Anfangsmigration für Termine und Anmeldungen', async () => { + const migration = await readFile(new URL('../database/migrations/001_booking_schema.sql', import.meta.url), 'utf8'); + + assert.match(migration, /CREATE TABLE IF NOT EXISTS `sufw_booking_events`/); + assert.match(migration, /CREATE TABLE IF NOT EXISTS `sufw_booking_registrations`/); + assert.match(migration, /FOREIGN KEY \(`event_id`\)/); + assert.match(migration, /CHECK \(`capacity` BETWEEN 1 AND 100\)/); +}); + +test('hält die statische Vorschaukopie der Buchungsassets identisch', async () => { + const [componentScript, previewScript, componentStyle, previewStyle] = await Promise.all([ + readFile(new URL('../public/booking/booking.js', import.meta.url), 'utf8'), + readFile(new URL('../public/perspektivwerk/js/booking.js', import.meta.url), 'utf8'), + readFile(new URL('../public/booking/booking.css', import.meta.url), 'utf8'), + readFile(new URL('../public/perspektivwerk/js/booking.css', import.meta.url), 'utf8'), + ]); + + assert.equal(previewScript, componentScript); + assert.equal(previewStyle, componentStyle); +}); diff --git a/development-system/tests/booking.test.mjs b/development-system/tests/booking.test.mjs index 7c36850..3500fd3 100644 --- a/development-system/tests/booking.test.mjs +++ b/development-system/tests/booking.test.mjs @@ -1,73 +1,53 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { bookingConfirmationMessage, canSubmitBookingForm, formatDateKey, getSlotsForDate, validateBookingField, validateBookingForm } from '../public/booking/booking.js'; +import { bookingConfirmationMessage, canSubmitBookingForm, formatDateKey, formatEventDuration, getSlotsForDate, requiresIntroductionDetails, validateBookingForm } from '../public/booking/booking.js'; -test('liefert für einen Beispieltag verfügbare und belegte Slots', () => { - const slots = getSlotsForDate('perspektivwerk', '2026-08-25'); +const events = [ + { id: 'one', project: 'perspektivwerk', startsAt: '2026-09-14 08:00:00', endsAt: '2026-09-18 16:00:00', status: 'available' }, + { id: 'two', project: 'perspektivwerk', startsAt: '2026-09-15 09:00:00', endsAt: '2026-09-15 13:00:00', status: 'full' }, +]; - assert.equal(slots.length, 3); - assert.equal(slots.filter((slot) => slot.status === 'available').length, 2); - assert.equal(slots.filter((slot) => slot.status === 'booked').length, 1); +test('liefert auch mehrtägige Termine für jeden betroffenen Kalendertag', () => { + const slots = getSlotsForDate('perspektivwerk', '2026-09-15', events); + assert.equal(slots.length, 2); + assert.equal(slots.filter((slot) => slot.status === 'available').length, 1); }); test('liefert für einen Tag ohne Termin keine Slots', () => { - assert.deepEqual(getSlotsForDate('produktionsschule', '2026-08-20'), []); + assert.deepEqual(getSlotsForDate('produktionsschule', '2026-09-15', events), []); }); test('verschiebt Kalendertage in der Sommerzeit nicht auf den Vortag', () => { process.env.TZ = 'Europe/Berlin'; - assert.equal(formatDateKey(new Date(2026, 7, 24)), '2026-08-24'); }); -test('liefert Termine im aktuellen und folgenden Monat', () => { - assert.ok(getSlotsForDate('perspektivwerk', '2026-08-24').length >= 2); - assert.ok(getSlotsForDate('perspektivwerk', '2026-08-28').length >= 2); - assert.ok(getSlotsForDate('perspektivwerk', '2026-09-02').length >= 2); - assert.ok(getSlotsForDate('perspektivwerk', '2026-09-15').length >= 2); - assert.ok(getSlotsForDate('perspektivwerk', '2026-09-29').length >= 2); +test('validiert die Pflichtangaben der Buchungsmaske', () => { + assert.deepEqual(validateBookingForm({ name: '', email: 'ungültig', privacyAccepted: false }), { + name: 'Bitte geben Sie Ihren Namen ein.', email: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.', phone: 'Bitte geben Sie eine gültige Telefonnummer ein.', privacy: 'Bitte bestätigen Sie die Datenschutzhinweise.' + }); }); -test('validiert die Pflichtangaben der Buchungsmaske', () => { - assert.deepEqual( - validateBookingForm({ name: '', email: 'ungültig', privacyAccepted: false }), - { - name: 'Bitte geben Sie Ihren Namen ein.', - email: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.', - privacy: 'Bitte bestätigen Sie die Datenschutzhinweise.' - } - ); +test('verlangt im SOLL-Stand eine Telefonnummer', () => { + assert.equal(validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', privacyAccepted: true }).phone, 'Bitte geben Sie eine gültige Telefonnummer ein.'); }); test('akzeptiert die vollständigen Pflichtangaben der Buchungsmaske', () => { - assert.deepEqual( - validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', privacyAccepted: true }), - {} - ); + assert.deepEqual(validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', phone: '+49 351 123456', privacyAccepted: true }), {}); }); -test('lehnt ein eingegebenes Alter außerhalb von 12 bis 112 ab', () => { - assert.equal( - validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', age: '11', privacyAccepted: true }).age, - 'Bitte geben Sie ein Alter zwischen 12 und 112 Jahren ein.' - ); - assert.equal( - validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', age: '113', privacyAccepted: true }).age, - 'Bitte geben Sie ein Alter zwischen 12 und 112 Jahren ein.' - ); +test('setzt die Zusatzangaben für Probearbeiten durch', () => { + assert.equal(requiresIntroductionDetails({ project: 'produktionsschule', introductionRequired: true }), true); + assert.equal(requiresIntroductionDetails({ project: 'perspektivwerk', introductionRequired: true }), false); + assert.deepEqual(validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', phone: '+49 351 123456', privacyAccepted: true, event: { project: 'produktionsschule', introductionRequired: true } }), { + introductionAttended: 'Bitte bestätigen Sie die Teilnahme an einer Einführungsveranstaltung.', introductionDate: 'Bitte geben Sie das Datum der Einführungsveranstaltung ein.' + }); }); -test('akzeptiert ein leeres oder gültiges optionales Alter', () => { - assert.deepEqual(validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', age: '', privacyAccepted: true }), {}); - assert.deepEqual(validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', age: '12', privacyAccepted: true }), {}); - assert.deepEqual(validateBookingForm({ name: 'Max Mustermann', email: 'max@example.org', age: '112', privacyAccepted: true }), {}); -}); - -test('validiert optionale Telefonnummern direkt am Feld', () => { - assert.equal(validateBookingField('phone', 'abc'), 'Bitte geben Sie eine gültige Telefonnummer ein.'); - assert.equal(validateBookingField('phone', '+49 351 123456'), ''); - assert.equal(validateBookingField('phone', ''), ''); +test('formatiert kurze und mehrtägige Termindauern', () => { + assert.equal(formatEventDuration({ startsAt: '2026-09-15 09:00:00', endsAt: '2026-09-15 13:00:00' }), '4 Stunden'); + assert.equal(formatEventDuration({ startsAt: '2026-09-21 08:00:00', endsAt: '2026-09-25 16:00:00' }), '5 Tage'); }); test('gibt den Absenden-Button erst nach DSGVO-Einwilligung frei', () => { @@ -75,9 +55,8 @@ test('gibt den Absenden-Button erst nach DSGVO-Einwilligung frei', () => { assert.equal(canSubmitBookingForm(true), true); }); -test('zeigt nach dem Absenden eine Bestätigung ohne Mockup-Hinweis', () => { +test('zeigt nach dem Absenden eine verbindliche Bestätigung ohne Mockup-Hinweis', () => { const message = bookingConfirmationMessage('Erstgespräch'); - - assert.match(message, /erfolgreich übermittelt/); + assert.match(message, /verbindlich gespeichert/); assert.doesNotMatch(message, /Vorschau|Mockup/i); });