Compare commits

..

No commits in common. "main" and "feature/paket-buchen-backend" have entirely different histories.

52 changed files with 500 additions and 22855 deletions

View File

@ -1,5 +0,0 @@
# Version bewusst festhalten, damit alle dieselbe Mailpit-Version verwenden.
FROM axllent/mailpit:v1.31.2 AS mailpit
FROM mcr.microsoft.com/devcontainers/javascript-node:24-bookworm
COPY --from=mailpit /mailpit /usr/local/bin/mailpit

View File

@ -1,18 +1,15 @@
{ {
"name": "Ernaehrungsberatung", "name": "Ernaehrungsberatung",
"image": "mcr.microsoft.com/devcontainers/javascript-node:24-bookworm",
"remoteUser": "node", "remoteUser": "node",
"forwardPorts": [5173, 8025], "forwardPorts": [5173],
"portsAttributes": { "portsAttributes": {
"5173": { "5173": {
"label": "Webseite", "label": "Webseite",
"onAutoForward": "notify" "onAutoForward": "notify"
},
"8025": {
"label": "Mailpit – Testmails",
"onAutoForward": "notify"
} }
}, },
"postCreateCommand": "bash .devcontainer/setup.sh", "postCreateCommand": "if [ -f package-lock.json ]; then npm ci; else npm install; fi",
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": [
@ -20,8 +17,5 @@
"EditorConfig.EditorConfig" "EditorConfig.EditorConfig"
] ]
} }
},
"build": {
"dockerfile": "Dockerfile"
} }
} }

View File

@ -1,10 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
npm ci
if [ ! -e .env ]; then
cp .env.example .env
fi
# Buchungsdatei nicht vorab anlegen: Das Backend erstellt sie bei der ersten Buchung.
echo "Bereit. In drei Terminals starten: npm run dev:mail, npm run dev:server, npm run dev"

View File

@ -1,6 +1,7 @@
# Lokal nur einen Mailfänger verwenden. Keine echten Zugangsdaten eintragen. # Lokal nur einen Mailfänger verwenden. Keine echten Zugangsdaten eintragen.
HOST=127.0.0.1 HOST=127.0.0.1
PORT=3000 PORT=3000
BOOKING_DEMO=true
SMTP_HOST=127.0.0.1 SMTP_HOST=127.0.0.1
SMTP_PORT=1025 SMTP_PORT=1025
SMTP_SECURE=false SMTP_SECURE=false

View File

@ -1,59 +0,0 @@
# Tri-Hub Ernährungsberatung
## Im Dev Container starten
1. Repository klonen und in VS Code „Dev Containers: Reopen in Container“ wählen.
Bei einem bereits vorhandenen Container einmal „Dev Containers: Rebuild Container“
ausführen, damit Mailpit installiert wird.
2. Die Einrichtung installiert automatisch die npm-Abhängigkeiten und erstellt
`.env` aus `.env.example`, sofern noch keine `.env` vorhanden ist.
3. In drei getrennten Terminals in dieser Reihenfolge starten:
```bash
npm run dev:mail
```
```bash
npm run dev:server
```
```bash
npm run dev
```
Startseite: http://localhost:5173 — über „Beratung kennenlernen“ zur
Angebotsübersicht und von dort zum Buchungsformular des gewählten Pakets.
Direkt: http://localhost:5173/angebotsuebersicht.html
Mailansicht: http://localhost:8025. Beide Web-Ports werden vom Dev Container
weitergeleitet. Mailpit läuft im selben Container auf SMTP-Port 1025 und fängt
Testmails lokal ab, ohne sie extern zu versenden.
Die Container-Konfiguration installiert Mailpit v1.31.2 aus dem
[offiziellen Docker-Image](https://mailpit.axllent.org/docs/install/docker/).
Die Binärdatei, lokale Mails, `.env` und Buchungsdaten werden nicht committed.
Mailpit verwendet ohne Datenbankpfad einen temporären Speicher; Testmails müssen
nach einem Neustart nicht erhalten bleiben.
Ohne Dev Container: Node.js 24 und Mailpit installieren, `npm ci` ausführen und
`.env.example` nach `.env` kopieren, falls `.env` fehlt. Danach dieselben drei
Startbefehle verwenden.
Ohne Mailpit wird die Buchung gespeichert, aber der fehlgeschlagene Mailversand
angezeigt. Der Ablauf bleibt eine Simulation ohne Zahlung oder verbindliche
Beratung. Buchungen liegen lokal in `src/server/data/bookings.csv`. Diese Datei
nicht vorab leer anlegen: Das Backend erzeugt sie bei der ersten Buchung selbst.
## Prüfen
```bash
npm test
npm run build
npm run test:browser
```
Die Browsertests starten eigene Server auf 3000 und 5173; diese Ports vorher
freihalten. Sie verwenden eine temporäre CSV und simulierten Mailversand.
Falls Chromium fehlt: `npx playwright install chromium`.
Der Build enthält Startseite, Angebotsübersicht und Buchungsseite. Für ein
Deployment zusätzlich das Node-Backend betreiben und `/api` dorthin weiterleiten.

View File

@ -1,34 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ernährungs-Pakete – Tri-Hub</title>
<link rel="stylesheet" href="/src/styles/navigation.css" />
<link rel="stylesheet" href="/src/styles/packages.css" />
</head>
<body class="packages-page">
<div id="navbar-placeholder"></div>
<main class="packages-section" id="main-content" tabindex="-1">
<div class="packages-header">
<div>
<p class="packages-eyebrow">TRI-HUB · ERNÄHRUNG</p>
<h1>Unsere Ernährungs-Pakete</h1>
</div>
<p class="packages-intro">
Wähle das Paket, das zu deinem aktuellen Stand passt. Von
den Grundlagen bis zur Wettkampf-Vorbereitung.
</p>
</div>
<div class="packages-container" id="packages-container"></div>
</main>
<script
type="module"
src="/src/components/navigationsbar/navigation.js"
></script>
<script
type="module"
src="/src/features/angebote/angebote-entry.js"
></script>
</body>
</html>

View File

@ -1,161 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<link rel="stylesheet" href="/src/styles/navigation.css" />
<title>Buchungsbestätigung – Tri-Hub</title>
</head>
<body class="confirmation">
<div id="navbar-placeholder"></div>
<main id="main-content" class="confirmation__main" tabindex="-1">
<section
id="confirmation-empty"
class="confirmation__empty"
aria-labelledby="empty-title"
>
<p class="confirmation__eyebrow">Deine Buchungsübersicht</p>
<h1 id="empty-title">Keine Bestätigung verfügbar.</h1>
<p>
In diesem Tab liegt keine gespeicherte Buchungsbestätigung
vor. Öffne die Bestätigung im Tab deiner Buchung. Falls du
bereits gebucht hast, prüfe deine E-Mails, bevor du erneut
buchst.
</p>
<a class="confirmation__button" href="/angebotsuebersicht.html"
>Zur Paketübersicht →</a
>
</section>
<div id="confirmation-content" hidden>
<section
class="confirmation__hero"
aria-labelledby="confirmation-title"
>
<p class="confirmation__eyebrow">
<span aria-hidden="true">✓</span> Deine
Buchungsbestätigung
</p>
<h1 id="confirmation-title">
Dein nächster Schritt.<br /><em>Alles im Blick.</em>
</h1>
<p>
Danke für dein Vertrauen. Deine Buchungsdaten wurden
gespeichert. Hier findest du deine persönliche
Übersicht.
</p>
<p
class="confirmation__simulation"
id="confirmation-payment"
></p>
</section>
<div class="confirmation__grid">
<section
class="confirmation__card"
aria-labelledby="summary-title"
>
<p class="confirmation__eyebrow">
Dein gewähltes Paket
</p>
<h2 id="summary-title"></h2>
<p
id="confirmation-summary"
class="confirmation__muted"
></p>
<dl
id="confirmation-totals"
class="confirmation__details"
></dl>
<p id="confirmation-no-snapshot" hidden>
Für diese ältere Buchung ist keine Preis- und
Leistungsübersicht gespeichert.
</p>
<div id="confirmation-features-section" hidden>
<h3>Das ist enthalten</h3>
<ul
id="confirmation-features"
class="confirmation__features"
></ul>
</div>
</section>
<aside
class="confirmation__sidebar"
aria-label="Buchungsdaten und Bestätigungsversand"
>
<section
class="confirmation__card"
aria-labelledby="receipt-title"
>
<p class="confirmation__eyebrow">
Für deine Unterlagen
</p>
<h2 id="receipt-title">Deine Buchung</h2>
<dl
id="confirmation-receipt"
class="confirmation__details confirmation__details--stacked"
></dl>
</section>
<section
class="confirmation__card confirmation__mail"
aria-labelledby="mail-title"
>
<h2 id="mail-title">Deine E-Mail-Bestätigung</h2>
<p id="confirmation-mail-status"></p>
<a
id="confirmation-retry"
class="confirmation__link"
hidden
>Versandstatus prüfen →</a
>
</section>
</aside>
</div>
<section
id="confirmation-steps-section"
class="confirmation__card confirmation__next"
aria-labelledby="steps-title"
hidden
>
<p class="confirmation__eyebrow">Dein weiterer Weg</p>
<h2 id="steps-title">So geht es weiter</h2>
<p class="confirmation__muted">
So ist der Ablauf deines gewählten Pakets vorgesehen.
Die aktuelle Buchung ist eine Simulation.
</p>
<ol
id="confirmation-steps"
class="confirmation__steps"
></ol>
</section>
<nav
class="confirmation__actions"
aria-label="Weiterführende Links"
>
<a
class="confirmation__button"
href="/angebotsuebersicht.html"
>Zur Paketübersicht <span aria-hidden="true">→</span></a
>
<a class="confirmation__link" href="/index.html"
>Zur Startseite</a
>
</nav>
</div>
<noscript
>Bitte aktiviere JavaScript, um die gespeicherte
Buchungsbestätigung anzuzeigen.</noscript
>
</main>
<footer class="confirmation__footer">
TRI-HUB <span>Dein Weg. Deine Ausdauer. Deine Ernährung.</span>
</footer>
<script
type="module"
src="/src/features/booking/booking-confirmation.js"
></script>
<script
type="module"
src="/src/components/navigationsbar/navigation.js"
></script>
</body>
</html>

View File

@ -7,12 +7,7 @@
<title>Paket buchen – Tri-Hub</title> <title>Paket buchen – Tri-Hub</title>
</head> </head>
<body> <body>
<main class="container"> <main id="booking-root" class="container"></main>
<a href="/angebotsuebersicht.html">Zur Angebotsseite</a>
<h1>Deine Buchung</h1>
<!-- Nur diesen Mount-Punkt später in die Paketdetailseite übernehmen. -->
<div id="booking-root"></div>
</main>
<script <script
type="module" type="module"
src="/src/features/booking/booking-entry.js" src="/src/features/booking/booking-entry.js"

View File

@ -1,146 +1,353 @@
# Paketbuchung # US2.4 – Paket buchen
## Ablauf ## Stand und Zusammenarbeit
Die Angebotsübersicht führt zur Detailseite eines Pakets. Der Buchungsbutton Entwicklung auf `feature/paket-buchen-backend`, späteres PR-Ziel: `dev`.
übergibt die Paket-ID und gegebenenfalls die gewählte Variante als URL-Parameter Zu Beginn waren HEAD und frisch abgerufenes `origin/dev` identisch. Kein Merge,
an `booking.html`. Bestehende Links mit `#/buchen/:packageId` funktionieren weiterhin. Commit oder Push wurde durchgeführt. Es gab keinen Router, keine Paketdaten und
kein Backend. Die Implementierung verwendet die vereinbarte Startstruktur:
Backend unter `src/server/`, gemeinsame Paketdaten unter `src/shared/` und
Tests unter `src/tests/`. Die früheren Doppelordner im Projektwurzelverzeichnis
wurden in diese vorhandenen Ordner überführt.
Booking lädt die Bestellübersicht vom Server. Sie enthält Paket und Variante, ## So funktioniert der Ablauf
Laufzeit, Leistungen, Abrechnung, Nettopreis, MwSt. und Gesamtbetrag. Die Preise
stammen aus `src/shared/packagesdetails.js`; Preisangaben des Browsers werden
nicht übernommen. Ohne Varianten-ID wird die erste Variante des Pakets gewählt.
Beim Absenden speichert der Server Kundendaten und den vollständigen Bestellstand 1. Der Browser lädt die verfügbaren Pakete über `GET /api/angebote`.
in der CSV. Anschließend sendet er eine Bestätigung als Text und HTML mit 2. Das Formular sendet Paket-ID, Name und E-Mail an `POST /api/bookings`.
Buchungsnummer, Datum, gebuchten Leistungen und Preisen. Änderungen am Katalog Es merkt sich dafür einen zufälligen Anfrageschlüssel (Idempotency-Key).
verändern bereits gespeicherte Bestellungen nicht. 3. Der Server prüft die Angaben und liest den Paketnamen aus `src/shared/packages.js`.
4. Er speichert Buchungsnummer, Zeitpunkt, Angaben und Schlüssel in der CSV.
Der Zahlungsvorgang ist weiterhin simuliert. Es erfolgt keine Abbuchung und 5. Erst danach übergibt er die Bestätigung an SMTP und aktualisiert den Versandstatus.
keine verbindliche Beratungsbuchung. Darauf weisen Formular und E-Mail hin. 6. Bei einem erneuten Versuch mit denselben Daten und demselben Schlüssel gibt
er die vorhandene Buchung zurück. Es entsteht keine zweite Bestellung.
## Lokal starten ## Lokal starten
Voraussetzung ist Node.js 24. Abhängigkeiten mit `npm ci` installieren und die Node.js 24 ist im vorhandenen Dev Container vorgesehen. Alle Befehle laufen im
Werte aus `.env.example` in eine lokale `.env` übernehmen. Eine vorhandene Projektverzeichnis. Vite und Backend benötigen jeweils ein eigenes Terminal.
Konfiguration dabei erhalten.
In separaten Terminals starten: ```bash
npm ci
cp .env.example .env
```
- `npm run dev:mail`: Mailpit mit SMTP auf Port 1025 und Weboberfläche auf 8025. Eine vorhandene `.env` vorher prüfen und nicht überschreiben. Die Beispielwerte
- `npm run dev:server`: Buchungs-API auf Port 3000. aktivieren das klar gekennzeichnete Testpaket und verwenden nur einen Mailfänger.
- `npm run dev`: Frontend auf Port 5173; `/api` wird an Node weitergeleitet. Ohne `.env` bleibt der Demo-Modus ausgeschaltet und der echte Paketkatalog leer.
Die Angebotsübersicht liegt unter <http://localhost:5173/angebotsuebersicht.html>, Einmalig [Mailpit installieren](https://mailpit.axllent.org/docs/install/).
der Mailfänger unter <http://localhost:8025>. Mailpit ist im Dev Container enthalten. Es läuft als einzelne Binärdatei auch direkt im Dev Container. Kein Docker im
Container erforderlich. Mailpit ohne Weiterleitung an echte SMTP-Server starten:
`npm run build` erzeugt die Seiten in `dist/`, einschließlich der Paketdetailseite. ```bash
Für ein Deployment muss der Webserver `/api` an den Node-Server weiterleiten. mailpit --listen 127.0.0.1:8025 --smtp 127.0.0.1:1025 --disable-version-check
`npm run start:server` startet die API ohne automatischen Neustart. ```
In Terminal 2 das Backend starten:
```bash
npm run dev:server
```
In Terminal 3 das Frontend starten:
```bash
npm run dev
```
Öffnen: <http://localhost:5173/booking.html#/buchen/demo-booking>.
Mailfänger: <http://localhost:8025>. Im Dev Container bei Bedarf Port 8025 über
VS Codes Ansicht „Ports“ weiterleiten. Port 5173 ist bereits vorkonfiguriert.
Port 3000 muss für den Browser nicht weitergeleitet werden: Vite vermittelt `/api`.
Diese Beschreibung setzt voraus, dass Mailpit im selben Container läuft.
`npm run start:server` startet das Backend ohne automatischen Neustart.
Bestehende Befehle `dev`, `build`, `preview`, `format` und `format:check` bleiben erhalten.
`npm run preview` zeigt ausschließlich den gebauten Frontend-Stand. Für eine
vollständige Buchung im Deployment muss der Webserver `/api` separat an Node
weiterleiten; der Vite-Entwicklungsproxy ist kein Produktionsserver.
## Konfiguration ## Konfiguration
| Variable | Standardwert | Bedeutung | Alle SMTP-Einstellungen werden ausschließlich in Node geladen. Niemals mit einem
| ------------------------ | -------------------------------- | --------------------------------------------------- | `VITE_`-Präfix versehen: Solche Variablen könnten im Browser landen.
| `HOST` | `127.0.0.1` | Bind-Adresse der API |
| `PORT` | `3000` | API-Port; bei Änderung auch den Vite-Proxy anpassen |
| `SMTP_HOST` | `127.0.0.1` | SMTP-Server |
| `SMTP_PORT` | `1025` lokal, sonst `587` | SMTP-Port |
| `SMTP_SECURE` | `false` | Direktes TLS aktivieren |
| `SMTP_USER`, `SMTP_PASS` | leer | SMTP-Zugangsdaten; beide gemeinsam setzen |
| `SMTP_FROM` | `Tri-Hub <buchung@example.test>` | Absender |
| `SMTP_ALLOW_EXTERNAL` | `false` | Versand über externe SMTP-Server aktivieren |
Externe SMTP-Verbindungen erfordern TLS. Zugangsdaten gehören ausschließlich | Variable | Lokaler Beispielwert | Bedeutung |
in die Serverkonfiguration und dürfen kein `VITE_`-Präfix erhalten. | ------------------------- | -------------------------------- | --------------------------------------------------------------------- |
| `HOST` | `127.0.0.1` | Bind-Adresse des Backends |
| `PORT` | `3000` | Backend-Port; bei Änderung auch Vite-Proxy anpassen |
| `BOOKING_DEMO` | `true` | Fügt ausschließlich lokal das Testpaket hinzu; in Produktion gesperrt |
| `SMTP_HOST` | `127.0.0.1` | Mailfänger im selben Container |
| `SMTP_PORT` | `1025` | SMTP-Port des Mailfängers |
| `SMTP_SECURE` | `false` | Bei echtem SMTP mit direktem TLS üblicherweise `true` auf 465 |
| `SMTP_USER` / `SMTP_PASS` | leer | Beide gemeinsam setzen, falls Authentifizierung benötigt wird |
| `SMTP_FROM` | `Tri-Hub <buchung@example.test>` | Absender; später abgestimmten echten Absender verwenden |
| `SMTP_ALLOW_EXTERNAL` | `false` | Externes SMTP gesperrt; erst nach ausdrücklicher Freigabe aktivieren |
## API Für externe SMTP-Hosts wird TLS verlangt; Zertifikatsprüfung bleibt aktiv.
Für STARTTLS (typischerweise Port 587) bleibt `SMTP_SECURE=false`.
Die `.env.example` enthält nur Testwerte und keine Geheimnisse.
[SMTP-Optionen bei Nodemailer](https://nodemailer.com/smtp).
Der vollständige Vertrag steht in `src/server/swagger.json`. ## Paketdaten und spätere Router-Anbindung
| Endpunkt | Funktion | Person 3 pflegt `packages` in **`src/shared/packages.js`**.
| ---------------------------- | ----------------------------------------------------------------------------------- | Das derzeit leere Array enthält später ausschließlich abgestimmte Angebote:
| `GET /api/angebote` | Bestehende Kurzübersicht mit `id`, `name` und `summary` |
| `GET /api/packages` | Kurzübersicht im Swagger-Format |
| `GET /api/packages/{id}` | Paketdetails einschließlich Varianten und Leistungen |
| `POST /api/bookings/preview` | Bestellübersicht für `packageId` und optionale `variantId`; speichert keine Buchung |
| `POST /api/bookings` | Buchung speichern und Bestätigung versenden |
POST-Anfragen benötigen `Content-Type: application/json`. Beim Buchen ist ```js
zusätzlich ein `Idempotency-Key` als UUID v4 erforderlich. Das Formular sendet // Nur Schema-Beispiel, kein verbindliches Angebot:
`packageId`, gegebenenfalls `variantId`, `name`, `email` und optional `phone`. { id: "stabile-paket-id", name: "Abgestimmter Paketname", summary: "Kurze Beschreibung" }
Die Telefonnummer darf höchstens 50 Zeichen und keine Steuerzeichen enthalten. ```
Sie wird in der CSV gespeichert und in der Bestätigungsmail ausgegeben.
Alternativ akzeptiert die API das Swagger-Objekt `customer` mit `firstName`, IDs: eindeutig, maximal 80 Zeichen, Kleinbuchstaben/Ziffern mit einzelnen
`lastName`, `email` sowie optional `phone`, `ageGroup` und `notes`. Dabei muss Bindestrichen. `name` und `summary` sind nicht leere Strings. IDs nach Freigabe
`acceptedTerms` den Wert `true` haben. Diese Angaben werden ebenfalls gespeichert. nicht für andere Angebote wiederverwenden. Das Backend prüft den Vertrag beim Start.
Das separate `demoPackages`-Array in derselben Datei enthält nur `demo-booking`,
explizit als Test markiert. Es wird mit `BOOKING_DEMO=true` zugeschaltet.
Keine Preise wurden erfunden. Sobald verbindliche Preisfelder vereinbart sind,
müssen Anzeige und serverseitiger Buchungssnapshot gemeinsam ergänzt werden.
Angaben wie `packageName` oder `price` aus API-Anfragen werden nicht übernommen.
Die Antwort enthält die Buchungsnummer, den Erstellungszeitpunkt, das gebuchte Die Angebotsseite kann vorerst auf die eigenständige Ansicht verlinken:
Paket unter `bookedPackage` und den Versandstatus. Eine neue Buchung mit
SMTP-Annahme erhält HTTP 201, eine Wiederholung HTTP 200. Bei ausstehendem,
fehlgeschlagenem oder unklarem Versand wird HTTP 202 zurückgegeben.
Ungültige Angaben, Pakete und Varianten ergeben HTTP 400; ein unbekanntes Paket ```js
am Detail-Endpunkt HTTP 404. Derselbe Anfrageschlüssel mit anderen Daten ergibt link.href = `/booking.html#/buchen/${encodeURIComponent(paket.id)}`;
HTTP 409. JSON-Anfragen sind auf 8 KiB begrenzt. Fehlerantworten enthalten ```
`error.code`, `error.message` und gegebenenfalls `error.fields`.
## Speicherung und Wiederholungen Es wurde kein Router in die gemeinsame Startseite eingebaut. Wenn Person 2 den
Router ergänzt, kann sie bei `/#/buchen/:packageId` diese Funktion aufrufen:
Die Buchungen liegen in `src/server/data/bookings.csv`. Neue Buchungsnummern ```js
werden fortlaufend als `TH-000001`, `TH-000002` usw. vergeben. Der höchste import { mountBookingPage } from "./features/booking/booking-page.js";
vorhandene Wert bestimmt die nächste Nummer. Die CSV muss deshalb vollständig const cleanup = mountBookingPage(container, { packageId });
erhalten bleiben; ältere UUID-Buchungsnummern werden weiterhin unterstützt. // Beim Verlassen der Route:
cleanup();
```
`bookedPackage` speichert den Bestellstand als JSON, `customer` die optionalen Das Modul bringt seine begrenzten `.booking`-Styles mit. Farben und Schrift
strukturierten Kundendaten und `acceptedTerms` die zugehörige Zustimmung. orientieren sich am vorhandenen Basis-CSS. Gemeinsame Designvariablen und eine
Die optionale Telefonnummer steht zusätzlich in der Spalte `phone`. Button-Komponente gibt es bisher nicht. Layout und Navigation bleiben Aufgabe
CSV-Dateien mit dem früheren Spaltensatz bleiben lesbar und werden beim nächsten der anderen Teammitglieder. „Zur Angebotsseite“ zeigt vorerst auf `/#beratung`.
Speichern erweitert. Für alte Buchungen ohne Bestellstand liefert die API
`bookedPackage: null`.
Lesen, Schreiben und Versand werden innerhalb einer Warteschlange abgearbeitet. ## API-Beispiele
Die Datei wird über eine temporäre Datei atomar ersetzt. Unterstützt wird ein
Backend-Prozess auf einem lokalen Dateisystem. Mehrere Serverinstanzen benötigen
einen gemeinsam abgesicherten Speicher. CSV-Zellen werden gegen die Auswertung
als Tabellenformeln geschützt; Dateien erhalten den Zugriffsmodus 0600.
Wiederholte Anfragen mit demselben Schlüssel und denselben Daten liefern die `GET /api/angebote` liefert `{ "packages": [...] }` mit `id`, `name`, `summary`
vorhandene Buchung. Es wird keine zweite Bestellung angelegt. Die Auswahl einer und `testOnly`. Keine personenbezogenen Daten oder SMTP-Einstellungen.
anderen Variante mit demselben Schlüssel wird abgelehnt.
Das Formular merkt sich Anfrage und Bestellübersicht je Paket und Variante im Für `POST /api/bookings` sind `Content-Type: application/json` und ein
`sessionStorage`. Nach einem Verbindungsfehler bleiben die Angaben gesperrt, `Idempotency-Key` als UUID v4 erforderlich. Ein Schlüssel gehört genau zu einer
damit dieselbe Anfrage erneut geprüft werden kann. Ein neuer Tab oder gelöschter Buchung. Bei Wiederholung denselben Schlüssel und dieselben Angaben verwenden.
Sitzungsspeicher liegt außerhalb dieser Absicherung.
## Versandstatus ```http
POST /api/bookings
Content-Type: application/json
Idempotency-Key: 36c45d7f-585f-47e9-bf5c-7ea1b7a162ed
| Status | Bedeutung | {"packageId":"demo-booking","name":"Test Person","email":"person@example.test"}
| ---------- | ------------------------------------------------------------------------------------------------ | ```
| `pending` | Buchung gespeichert; Versand noch nicht gestartet. Dieselbe Anfrage kann den Versand fortsetzen. |
| `sending` | Versandabsicht gespeichert. Nach einem Prozessabbruch liefert die API `unknown`. |
| `accepted` | SMTP hat die Nachricht angenommen; die Zustellung ist noch nicht bestätigt. |
| `failed` | Versand fehlgeschlagen oder ausdrücklich abgelehnt. |
| `unknown` | SMTP-Annahme oder anschließende Statusspeicherung blieb unklar. |
Bei `failed` und `unknown` wird nicht automatisch erneut versendet. Vor einem Neue Buchung mit SMTP-Annahme: HTTP `201`; gespeicherte Wiederholung: `200`.
manuellen Neuversand muss der Mailserver anhand der Buchungsnummer geprüft werden.
CSV und SMTP bilden keine gemeinsame Transaktion.
## Tests ```json
{
"bookingId": "TH-000001",
"createdAt": "2026-09-24T12:00:00.000Z",
"packageId": "demo-booking",
"packageName": "1:1-Ernährungsberatung",
"saved": true,
"emailStatus": "accepted",
"replayed": false
}
```
`npm test` prüft Validierung, Preisberechnung, Varianten, CSV-Kompatibilität, Gespeichert, aber E-Mail nicht bestätigt: HTTP `202` mit derselben Struktur und
Wiederholungen und Versandfehler. `npm run test:browser` prüft den Weg von der `emailStatus: "failed"`, `"pending"` oder `"unknown"`. Das bedeutet ausdrücklich
Angebotsübersicht über die Paketdetails bis zur Bestätigung, einschließlich nicht, dass die E-Mail zugestellt wurde. Die Buchungsnummer bleibt gültig.
Premium-Jahresvariante, Tastaturbedienung und schmalem Bildschirm.
Die Tests verwenden die regulären Paketdaten, temporäre CSV-Dateien und einen | Status/Code | Bedeutung und Verhalten |
simulierten Mailversand. Playwright benötigt Chromium; die Installation erfolgt | ------------------------------ | ----------------------------------------------------------------------- |
mit `npx playwright install --with-deps chromium`. Die Ports 3000 und 5173 müssen | `400 INVALID_INPUT` | Angaben korrigieren; Feldhinweise stehen in `error.fields` |
für die automatisch gestarteten Testserver frei sein. | `400 UNKNOWN_PACKAGE` | Paket nicht verfügbar; verfügbares Paket auswählen |
| `400 INVALID_KEY` | UUID-v4-Anfrageschlüssel fehlt oder ist ungültig |
| `400 INVALID_JSON` | Anfrage ist kein gültiges JSON |
| `409 IDEMPOTENCY_CONFLICT` | Derselbe Schlüssel wurde mit anderen Angaben verwendet |
| `413 BODY_TOO_LARGE` | Mehr als 8 KiB Anfrageinhalt |
| `415 UNSUPPORTED_CONTENT_TYPE` | JSON-Content-Type erforderlich |
| `503 BOOKING_NOT_SAVED` | Speicherung fehlgeschlagen; mit gleichem Schlüssel wiederholen |
| `503 STORAGE_UNAVAILABLE` | Vorhandener Status nicht lesbar; mit gleichem Schlüssel wiederholen |
| `500 INTERNAL_ERROR` | Status unklar; keine neue Buchung erzeugen, gleiche Anfrage wiederholen |
Vor einem Commit außerdem `npm run format:check`, `npm run build` und Die E-Mail-Prüfung unterstützt übliche unquotierte ASCII-Adressen. Internationale
`git diff --check` ausführen. Für eine manuelle Mailprüfung eine Buchung mit einer Domains können in Punycode angegeben werden; SMTPUTF8-Adressen sind nicht Teil
Adresse unter `example.test` anlegen und die Bestätigung in Mailpit kontrollieren. dieses Sprints. Eine Formatprüfung beweist nicht, dass das Postfach existiert.
Beispiel für einen Validierungsfehler:
```json
{
"error": {
"code": "INVALID_INPUT",
"message": "Bitte die markierten Angaben prüfen.",
"fields": { "email": "Bitte eine gültige E-Mail-Adresse eingeben." }
}
}
```
## Simulation und spätere Zahlung
Das Demonstrationspaket heißt in der Oberfläche „1:1-Ernährungsberatung“.
Die ID `demo-booking` und die Freigabe über `BOOKING_DEMO` bleiben unverändert;
es wurden keine verbindlichen Angebote, Leistungsumfänge oder Preise ergänzt.
Ein gemeinsamer Hinweis aus `src/shared/booking-copy.js` erklärt vor dem Absenden
und in der E-Mail, dass der Ablauf simuliert ist: Der Zahlungsvorgang wird
übersprungen, es wird nichts abgebucht und keine verbindliche Beratung gebucht.
Bei vollständiger Integration kann der Zahlungsschritt ergänzt werden. Dafür
müssen tatsächliche Zahlungsbestätigung und Buchungsstatus serverseitig verbunden
werden; das Entfernen des Hinweises allein aktiviert keine Zahlung.
## Fortlaufende Buchungsnummern
Neue Buchungen erhalten `TH-000001`, `TH-000002` usw. Dieselbe Nummer steht in der
Antwort, CSV und E-Mail. Sie wird innerhalb der Warteschlange aus dem höchsten
vorhandenen TH-Wert berechnet und mit der Buchung gespeichert. Ein Neustart setzt
sie nicht zurück; Wiederholungen behalten dieselbe Nummer. Sechs Stellen sind
die Mindestbreite, nach `TH-999999` folgt `TH-1000000`. Es gibt keinen Jahresreset.
Bestehende UUID-Buchungen und gespeicherte Browserbestätigungen behalten ihre
bisherige Nummer, damit bereits ausgegebene Referenzen gültig bleiben. Nur neue
Buchungen verwenden das neue Format. Der technische Idempotency-Key bleibt eine
zufällige UUID; die lesbare Buchungsnummer ist kein Geheimnis oder Zugriffstoken.
Der Zähler setzt voraus, dass die CSV vollständig erhalten bleibt. Löschen von
Zeilen, Entfernen der Datei oder Zurückspielen eines älteren Backups kann Nummern
wiederverwendbar machen. Vor einer Archivierungs-/Löschfunktion ist ein separat
persistierter, transaktionaler Zähler nötig. Weiterhin nur ein Backend-Prozess.
## CSV, Wiederholungen und Fehlergrenzen
Pfad: `src/server/data/bookings.csv`. Spalten: `bookingId`, `createdAt`, `packageId`,
`packageName`, `name`, `email`, `emailStatus`, `idempotencyKey`, `requestHash`.
`requestHash` verknüpft den Schlüssel mit den normalisierten Anfragedaten, ohne
sie in einer zweiten Datenquelle zu duplizieren. Bereits gespeicherte Buchungen
können auch nach dem Entfernen eines Pakets weiterhin wiederholt abgefragt werden.
CSV-Bibliotheken behandeln Kommas, Anführungszeichen und Zeilenumbrüche. Zellen,
die wie Tabellenformeln beginnen, erhalten ein Apostroph. Bereits führende
Apostrophe werden ebenfalls maskiert, damit internes Lesen die Originalwerte
wiederherstellen kann. Persönliche Namen dürfen in der API keine Steuerzeichen
enthalten; die Speicherschicht unterstützt Zeilenumbrüche für andere CSV-Felder.
Dateien werden mit Zugriffsmodus `0600` geschrieben.
Eine Warteschlange führt komplette Buchungen nacheinander aus. Schreiben erfolgt
über eine temporäre Datei und atomisches Umbenennen. **Nur ein laufender
Backend-Prozess auf einem lokalen Dateisystem wird unterstützt.** Keine Cluster,
mehreren Containerinstanzen, manuelle Dateiedits während des Betriebs oder
Netzlaufwerke. Die gesamte CSV wird bei Änderungen gelesen/neu geschrieben;
geeignet für den ersten Sprint mit kleinem Volumen. Es gibt keine verteilte
Transaktion oder vollständige Garantie gegen Hardware-/Stromausfall.
Versandzustände:
- `pending`: Buchung gespeichert, Versand noch nicht gestartet. Derselbe Schlüssel
darf den Versand nach einem Schreibfehler fortsetzen.
- `sending`: Versandabsicht dauerhaft gespeichert. Bei Prozessabbruch zeigt die
API diesen Zustand als `unknown`; kein automatischer Neuversand.
- `accepted`: SMTP hat den Empfänger und die Nachricht angenommen.
- `failed`: Der Versand ist fehlgeschlagen bzw. wurde ausdrücklich abgelehnt.
- `unknown`: Verbindung oder Statusspeicherung brach zu einem unklaren Zeitpunkt ab.
Für `failed` und `unknown` gibt es bewusst keinen automatischen erneuten Versand.
Erst CSV und Mailserver/Mailfänger anhand der Buchungsnummer prüfen. Eine
administrative Wiederholungsfunktion ist offen; keinesfalls eine neue Buchung
anlegen oder `sending` blind zurücksetzen. SMTP und CSV können nicht gemeinsam
atomar abgeschlossen werden, weshalb ein exakt einmaliger Versand nicht garantiert
werden kann. Ein Verbindungsabbruch nach SMTP-Annahme kann doppelte Mails verursachen,
wenn später ohne Prüfung manuell erneut gesendet wird.
Das Formular speichert Schlüssel und Angaben je Paket in `sessionStorage`, also
für den aktuellen Browser-Tab, auch über Neuladen hinweg. Bei unklarem Ergebnis
bleiben die Angaben schreibgeschützt und können mit demselben Schlüssel erneut
geprüft werden. Nach gespeicherter Buchung bleibt das Formular gesperrt. Ein
bewusst neuer Auftrag benötigt einen neuen Tab bzw. eine neue Sitzung. Das
Schließen des Tabs, gelöschter Sitzungsspeicher, ein anderes Gerät oder ein neuer
Schlüssel liegen außerhalb der Doppelbuchungsabsicherung. Bei unklarem Ausgang
zuerst den bestehenden Status prüfen. Es gibt keine globale Erkennung anhand
von Name/E-Mail, weil zwei bewusst getrennte Buchungen möglich sein müssen.
`.gitignore` schließt Buchungsdaten einschließlich temporärer Dateien und `.env`
aus. Das Backend liefert ausschließlich API-Antworten aus. Vite blockiert
Serververzeichnis und Umgebungsdateien auch beim direkten Dateizugriff. Im
Deployment nur `dist/` öffentlich bereitstellen, nie den gesamten Projektordner.
## Prüfen
Automatisierte Tests erzeugen temporäre CSV-Dateien und simulieren E-Mail-Versand.
Sie schreiben nicht in `src/server/data/bookings.csv` und kontaktieren kein echtes SMTP.
Vor Browsertests lokale Server auf 3000/5173 stoppen; Playwright startet eigene
Testserver und übernimmt keine bereits laufenden Instanzen.
```bash
npm test
npx playwright install --with-deps chromium
npm run test:browser
npm run format:check
npm run build
```
Die einmalige Chromium-Installation benötigt Netzwerk und unter Linux eventuell
Systempakete. Der Dev Container wurde dafür nicht global umkonfiguriert.
Manuelle Abnahme im lokalen Mailfänger:
1. Demo-Modus, Mailpit, Backend und Vite wie oben starten.
2. Testpaket mit `person@example.test` buchen. Buchungsnummer notieren.
3. CSV im Editor öffnen: genau eine passende Zeile, Paket, Zeitpunkt und Versandstatus prüfen.
4. In Mailpit die Nachricht öffnen: Empfänger, Paket und Buchungsnummer vergleichen.
5. Seite neu laden: gleiche Buchungsnummer, keine neue CSV-Zeile und keine zweite Mail.
6. Mit Tab/Shift+Tab und Enter bedienen, Feldfehler und sichtbaren Fokus prüfen.
Auf 320 Pixel Breite und mit Browser-Zoom prüfen; ergänzend Screenreader verwenden.
7. Für den Versandfehler einen neuen Test in neuer Sitzung bei gestopptem Mailpit
ausführen: Buchung bleibt gespeichert, Anzeige meldet keine erfolgreiche Zustellung.
**Das Akzeptanzkriterium „E-Mail zugestellt“ ist mit SMTP-Annahme oder Mailpit allein
noch nicht für einen echten Empfänger nachgewiesen.** Erst nach ausdrücklicher
Freigabe echtes SMTP konfigurieren und an eine vereinbarte Testadresse senden.
Dann im tatsächlichen Empfängerpostfach (auch Spam) Eingang, Buchungsnummer und
Paket kontrollieren und Zeitpunkt sowie Ergebnis im Sprint-Nachweis festhalten.
Es gibt bisher keine automatische Bounce-/Zustellverfolgung.
## Durchgeführte Prüfungen
- 17 Backend-Tests: Validierung, CSV-Sonderzeichen/Formelschutz, parallele
Anfragen, Wiederholung mit neuer Serviceinstanz, Speicher- und SMTP-Fehler.
- 6 Chromium-Prüfungen: Tastaturbedienung/Fokus, 320-Pixel-Ansicht, verlorene
Antwort mit Neuladen, Ladezustand, Versandfehler und private Dateipfade.
- Lokaler SMTP-Test mit Mailpit und temporärer CSV: Nachricht tatsächlich im
Mailfänger empfangen, Paket/Buchungsnummer geprüft, wiederholte Anfrage ohne
zusätzliche CSV-Zeile oder E-Mail. Ausschließlich `example.test`-Empfänger.
- Projektweite Formatprüfung, Produktionsbuild und Git-Whitespace-Prüfung.
Die automatisierten Prüfungen ersetzen keine Screenreader-Abnahme oder Prüfung
in weiteren Browsern. Keine echte E-Mail-Zustellung wurde getestet. Zum Ausführen
der Browserprüfungen wurden Chromium und die benötigten Systembibliotheken in
dieser Arbeitsumgebung installiert; die Dev-Container-Konfigurationsdatei wurde
nicht verändert. Der Mailpit-Test verwendete eine temporär heruntergeladene
Binärdatei außerhalb des Repositorys.
## Offene Punkte und gemeinsame Dateien
Offen: echte Paketdaten, Anschluss an den gemeinsamen Router, abschließendes
Teamdesign, reale Zustellprüfung und Screenreader-Abnahme. Vor öffentlichem
Betrieb sind außerdem Betriebsfragen wie Zugriff auf CSV/Backups, Aufbewahrung
und Schutz des öffentlichen Buchungsendpunkts vor automatisiertem Massenversand
zu klären. Keine Zahlungen, Benutzerkonten oder Datenbank implementiert.
Gemeinsame Änderungen: `package.json` und Lockdatei (Abhängigkeiten/Startbefehle),
`src/main.js` (bestehenden falschen CSS-Import korrigiert), `.gitignore` und
`.prettierignore` (private Daten/Testergebnisse ausschließen). Neu hinzugefügt:
`vite.config.js` (Proxy, Dateizugriffsschutz, zwei HTML-Einstiegspunkte),
`src/shared/packages.js` (gemeinsamer Paketvertrag), `.env.example`, `booking.html`
und `playwright.config.js`. Die Dev-Container-Konfiguration bleibt unverändert.
Technische Referenzen: [CSV-Parser](https://csv.js.org/parse/api/sync/),
[Playwright-Testserver](https://playwright.dev/docs/test-webserver).

1142
index.html

File diff suppressed because it is too large Load Diff

7
package-lock.json generated
View File

@ -10,7 +10,6 @@
"dependencies": { "dependencies": {
"csv-parse": "^7.0.2", "csv-parse": "^7.0.2",
"csv-stringify": "^6.8.3", "csv-stringify": "^6.8.3",
"libphonenumber-js": "^1.13.14",
"nodemailer": "^10.0.10" "nodemailer": "^10.0.10"
}, },
"devDependencies": { "devDependencies": {
@ -983,12 +982,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/libphonenumber-js": {
"version": "1.13.14",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.14.tgz",
"integrity": "sha512-llihgCcx0BFLksecLP+x1J+6JDE1GsXS1RN/LoPF6qcwpeQcnjj0lcvZxY8AzbEpYwyZWPZW/nDuqkqzm3amiw==",
"license": "MIT"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.19", "version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",

View File

@ -14,9 +14,8 @@
"format:check": "prettier --check .", "format:check": "prettier --check .",
"dev:server": "node --env-file-if-exists=.env --watch src/server/index.js", "dev:server": "node --env-file-if-exists=.env --watch src/server/index.js",
"start:server": "node --env-file-if-exists=.env src/server/index.js", "start:server": "node --env-file-if-exists=.env src/server/index.js",
"test": "node --test src/tests/*.test.js", "test": "node --test src/tests/bookings.test.js",
"test:browser": "playwright test", "test:browser": "playwright test"
"dev:mail": "mailpit --listen 127.0.0.1:8025 --smtp 127.0.0.1:1025 --disable-version-check"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.63.0", "@playwright/test": "^1.63.0",
@ -26,7 +25,6 @@
"dependencies": { "dependencies": {
"csv-parse": "^7.0.2", "csv-parse": "^7.0.2",
"csv-stringify": "^6.8.3", "csv-stringify": "^6.8.3",
"libphonenumber-js": "^1.13.14",
"nodemailer": "^10.0.10" "nodemailer": "^10.0.10"
} }
} }

View File

@ -1,27 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Paket-Details – Tri-Hub Ernährungsberatung</title>
<link rel="stylesheet" href="/src/styles/navigation.css" />
<link rel="stylesheet" href="/src/styles/packages.css" />
</head>
<body class="packages-page">
<div id="navbar-placeholder"></div>
<main class="packages-section" id="main-content" tabindex="-1">
<a class="packages-back" href="/angebotsuebersicht.html"
>← Zurück zur Angebotsübersicht</a
>
<div id="package-detail-container"></div>
</main>
<script
type="module"
src="/src/components/navigationsbar/navigation.js"
></script>
<script
type="module"
src="/src/features/angebote/paket-details.js"
></script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

View File

@ -1,14 +0,0 @@
# Navigation einbinden
Füge an der gewünschten Stelle im HTML einen Platzhalter ein und binde CSS
und JavaScript separat ein:
```html
<div id="navbar-placeholder"></div>
<link rel="stylesheet" href="/src/styles/navigation.css" />
<script type="module" src="/src/components/navigationsbar/navigation.js"></script>
```
Das Script lädt `navigation.html` und initialisiert die Navigation automatisch,
`initNavbar()` muss nicht zusätzlich aufgerufen werden. Die Stylesheet-Datei wird
nicht vom Script geladen und muss deshalb separat eingebunden werden.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

View File

@ -1,74 +0,0 @@
<header class="navbar" id="site-navbar">
<div class="navbar__inner">
<a
href="#main-content"
class="navbar__brand"
aria-label="Zum Hauptinhalt springen"
>
<img src="/images/logo.png" alt="" class="navbar__logo-img" />
<span class="navbar__brand-text">
<span class="navbar__brand-title">TRI-HUB</span>
<span class="navbar__brand-tagline"
>Ernährungsberatung für Triathleten ab 50</span
>
</span>
</a>
<button
class="navbar__toggle"
type="button"
aria-controls="navbar-menu"
aria-expanded="false"
aria-label="Menü öffnen"
>
<span class="navbar__toggle-icon" aria-hidden="true"></span>
</button>
<nav class="navbar__nav" aria-label="Hauptnavigation">
<ul id="navbar-menu" class="navbar__menu">
<li>
<a href="/angebotsuebersicht.html" class="navbar__link"
>Pakete</a
>
</li>
<li>
<a href="/index.html" class="navbar__link">Ernährung</a>
</li>
<li>
<a href="/index.html" class="navbar__link">Beraterprofil</a>
</li>
<li>
<a href="/index.html" class="navbar__link">FAQ</a>
</li>
<li>
<a href="#erfahrungen" class="navbar__link">Rezensionen</a>
</li>
<li>
<a href="/index.html" class="navbar__link">Kontakt</a>
</li>
<li>
<a
href="https://tri-hub.de/"
class="navbar__link navbar__link--home"
>
<span class="navbar__home-icon" aria-hidden="true">
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 10L12 3L20 10V20a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V10Z M9 21V14H15V21"
stroke="currentColor"
stroke-linejoin="round"
stroke-linecap="round"
/>
</svg>
</span>
Startseite
</a>
</li>
</ul>
</nav>
</div>
</header>

View File

@ -1,99 +0,0 @@
import navigationHtml from "./navigation.html?raw";
/**
* Bindet das mit Vite gebündelte Navbar-Fragment (navigation.html) ein und
* ersetzt damit den Platzhalter im Header der jeweiligen Seite.
* Aufruf z. B. in main.js: initNavbar('#navbar-placeholder')
*/
export function initNavbar(placeholderSelector = "#navbar-placeholder") {
const placeholder = document.querySelector(placeholderSelector);
if (!placeholder) {
console.warn("Navbar-Platzhalter nicht gefunden:", placeholderSelector);
return;
}
placeholder.outerHTML = navigationHtml; // Platzhalter durch echtes Navbar-Markup ersetzen
setupBehavior();
markCurrentPage();
}
/**
* Richtet alle interaktiven Funktionen der Navbar ein:
* - Mobiles Menü öffnen/schließen per Klick
* - Automatischer Fokus auf ersten Link, wenn per Tastatur geöffnet
* - Escape schließt das Menü und gibt den Fokus zurück
* - Menü schließt sich, wenn der Fokus die Navbar per Tab verlässt
* - Klick außerhalb der Navbar schließt das Menü (Maus/Touch)
*/
export function setupBehavior() {
const navbar = document.querySelector(".navbar");
const toggle = document.querySelector(".navbar__toggle");
const nav = document.querySelector(".navbar__nav");
const menu = document.querySelector(".navbar__menu");
if (!navbar || !toggle || !nav || !menu) return;
let openedViaKeyboard = false;
toggle.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
openedViaKeyboard = true;
}
});
toggle.addEventListener("click", () => {
const isOpen = nav.classList.toggle("is-open");
toggle.setAttribute("aria-expanded", String(isOpen));
toggle.setAttribute(
"aria-label",
isOpen ? "Menü schließen" : "Menü öffnen",
);
if (isOpen && openedViaKeyboard) {
const firstLink = menu.querySelector(".navbar__link");
if (firstLink) firstLink.focus();
}
openedViaKeyboard = false;
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && nav.classList.contains("is-open")) {
nav.classList.remove("is-open");
toggle.setAttribute("aria-expanded", "false");
toggle.setAttribute("aria-label", "Menü öffnen");
toggle.focus();
}
});
navbar.addEventListener("focusout", (e) => {
const leavingNavbar = !navbar.contains(e.relatedTarget);
if (leavingNavbar && nav.classList.contains("is-open")) {
nav.classList.remove("is-open");
toggle.setAttribute("aria-expanded", "false");
toggle.setAttribute("aria-label", "Menü öffnen");
}
});
document.addEventListener("click", (e) => {
if (!navbar.contains(e.target) && nav.classList.contains("is-open")) {
nav.classList.remove("is-open");
toggle.setAttribute("aria-expanded", "false");
toggle.setAttribute("aria-label", "Menü öffnen");
}
});
}
/**
* Markiert den Menüpunkt der aktuell geöffneten Seite mit aria-current="page",
* damit er optisch hervorgehoben wird (siehe .navbar__link[aria-current="page"] in der CSS).
*/
export function markCurrentPage() {
const links = document.querySelectorAll(".navbar__link");
const current = window.location.pathname.replace(/\/index\.html$/, "/");
links.forEach((link) => {
if (link.getAttribute("href") === current) {
link.setAttribute("aria-current", "page");
}
});
}
document.addEventListener("DOMContentLoaded", () => initNavbar());

View File

@ -1,3 +0,0 @@
import { renderAngebotsuebersicht } from "./angebotsuebersicht.js";
renderAngebotsuebersicht();

View File

@ -1,51 +0,0 @@
import { packages } from "../../shared/packages.js";
export function renderAngebotsuebersicht() {
const container = document.getElementById("packages-container");
if (!container) {
console.warn("Container für Angebotsübersicht nicht gefunden.");
return;
}
container.innerHTML = "";
packages.forEach((pkg) => {
const highlightHtml = pkg.highlight
? `<span class="package-highlight">${pkg.highlight}</span>`
: "";
const cardHtml = `
<div class="package-card">
<div class="package-type-container">
<span class="package-type">${pkg.type}</span>
${highlightHtml}
</div>
<h2 class="package-title">${pkg.name}</h2>
<p class="package-desc">${pkg.summary}</p>
<div class="package-details-box">
<div class="detail-item">
<div class="detail-title">Fokus</div>
<div class="detail-text">${pkg.fokus}</div>
</div>
<div class="detail-item">
<div class="detail-title">Anamnese</div>
<div class="detail-text">${pkg.anamnese}</div>
</div>
<div class="detail-item">
<div class="detail-title">Begleitung</div>
<div class="detail-text">${pkg.begleitung}</div>
</div>
</div>
<a href="/paket-details.html?id=${pkg.id}" class="package-btn">${pkg.buttonText} <span aria-hidden="true">→</span></a>
</div>
`;
container.innerHTML += cardHtml;
});
}

View File

@ -1,158 +0,0 @@
import { packagesDetails } from "../../shared/packagesdetails.js";
import { mountBookingPage } from "../booking/booking-page.js";
import { formatMoney } from "../../shared/order-summary.js";
import { paymentNotice } from "../../shared/booking-copy.js";
document.addEventListener("DOMContentLoaded", () => {
const container = document.getElementById("package-detail-container");
if (!container) return;
const params = new URLSearchParams(window.location.search);
const pkg = packagesDetails.find((item) => item.id === params.get("id"));
const requestedVariant = params.get("variant");
const invalidVariant =
requestedVariant &&
!pkg?.variants?.some(
(variant) => variant.variantId === requestedVariant,
);
if (!pkg || invalidVariant) {
container.innerHTML = `
<div class="package-card">
<h1>Paket nicht gefunden</h1>
<p>Bitte wähle ein gültiges Paket und eine passende Variante aus.</p>
<a href="/angebotsuebersicht.html" class="package-btn">Zur Übersicht</a>
</div>`;
return;
}
document.title = `${pkg.type} – Tri-Hub Ernährungsberatung`;
const variantsHtml = pkg.variants?.length
? `<div class="detail-box">
<label for="variant-select">Wähle deine Variante</label>
<select id="variant-select" class="variant-select">
${pkg.variants
.map(
(variant) => `
<option value="${variant.variantId}">
${variant.label} – ${formatMoney(variant.price, pkg.currency)}
</option>`,
)
.join("")}
</select>
</div>`
: "";
container.innerHTML = `
<header class="detail-heading">
<div class="package-type-container">
<span class="package-type">${pkg.type} · <span id="package-duration"></span></span>
${pkg.badge ? `<span class="package-highlight">${pkg.badge}</span>` : ""}
</div>
<h1>${pkg.title}</h1>
<p class="package-desc">${pkg.subtitle}</p>
</header>
<div class="detail-grid">
<div class="detail-content">
<section aria-labelledby="target-heading">
<h2 id="target-heading">Für wen ist dieses Paket ideal?</h2>
<ul class="detail-list">
${pkg.targetGroup.map((item) => `<li>${item}</li>`).join("")}
</ul>
</section>
<section aria-labelledby="features-heading">
<h2 id="features-heading">Das ist im Paket enthalten</h2>
<ul class="detail-list">
${pkg.includedFeatures.map((feature) => `<li>${feature}</li>`).join("")}
</ul>
</section>
<section aria-labelledby="steps-heading">
<h2 id="steps-heading">So läuft deine Begleitung ab</h2>
${pkg.processSteps
.map(
(step) => `
<div class="process-step">
<strong>${step.step}</strong>
<p class="detail-text">${step.text}</p>
</div>`,
)
.join("")}
</section>
</div>
<section class="package-checkout" aria-labelledby="checkout-heading">
<h2 id="checkout-heading">Dein ${pkg.type.toLowerCase().replace(/^./, (letter) => letter.toUpperCase())}-Paket</h2>
<div class="price-box" aria-live="polite">
<span class="price-amount" id="displayed-price"></span>
<span class="price-note">inkl. ${pkg.taxRate} % MwSt. · ${pkg.billingInterval}</span>
<p id="selected-duration" class="price-note"></p>
</div>
${variantsHtml}
<button id="proceed-to-booking-btn" class="package-btn primary-cta" type="button" aria-haspopup="dialog" aria-expanded="false" disabled>
Buchungsformular wird geladen …
</button>
<p class="booking-notice" id="booking-notice"></p>
<div id="booking-root"></div>
</section>
</div>`;
container.querySelector("#booking-notice").textContent = paymentNotice;
const variantSelect = container.querySelector("#variant-select");
if (variantSelect && requestedVariant)
variantSelect.value = requestedVariant;
const bookingBtn = container.querySelector("#proceed-to-booking-btn");
const bookingRoot = container.querySelector("#booking-root");
let cleanupBooking;
let activeDialog;
function prepareBooking() {
cleanupBooking?.();
activeDialog = null;
bookingBtn.disabled = true;
bookingBtn.textContent = "Buchungsformular wird geladen …";
bookingBtn.setAttribute("aria-expanded", "false");
bookingBtn.removeAttribute("aria-controls");
const variant = pkg.variants?.find(
(item) => item.variantId === variantSelect?.value,
);
const duration = variant
? `${variant.durationWeeks} Wochen Begleitung`
: pkg.durationLabel;
container.querySelector("#displayed-price").textContent = formatMoney(
variant?.price ?? pkg.price,
pkg.currency,
);
container.querySelector("#package-duration").textContent = duration;
container.querySelector("#selected-duration").textContent = duration;
cleanupBooking = mountBookingPage(bookingRoot, {
packageId: pkg.id,
variantId: variantSelect?.value,
});
}
// Booking lädt die Paketvorschau asynchron, auch bei einem erneuten Versuch.
const observer = new MutationObserver(() => {
const dialog = bookingRoot.querySelector(".booking__dialog");
if (!dialog || dialog === activeDialog) return;
activeDialog = dialog;
bookingBtn.disabled = false;
bookingBtn.textContent = "Jetzt buchen";
bookingBtn.setAttribute("aria-controls", dialog.id);
dialog.addEventListener("close", () => {
bookingBtn.setAttribute("aria-expanded", "false");
bookingBtn.focus();
});
});
observer.observe(bookingRoot, { childList: true, subtree: true });
prepareBooking();
variantSelect?.addEventListener("change", prepareBooking);
bookingBtn.addEventListener("click", () => {
if (!activeDialog || activeDialog.open) return;
activeDialog.showModal();
bookingBtn.setAttribute("aria-expanded", "true");
const result = activeDialog.querySelector(
".booking__result:not([hidden])",
);
(result || activeDialog.querySelector('[name="name"]')).focus();
});
});

View File

@ -33,6 +33,10 @@ async function request(path, options = {}) {
return body; return body;
} }
export function loadPackages(signal) {
return request("/api/angebote", { signal });
}
export function submitBooking(payload, idempotencyKey) { export function submitBooking(payload, idempotencyKey) {
return request("/api/bookings", { return request("/api/bookings", {
method: "POST", method: "POST",
@ -43,12 +47,3 @@ export function submitBooking(payload, idempotencyKey) {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} }
export function previewBooking(packageId, variantId, signal) {
return request("/api/bookings/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ packageId, variantId }),
signal,
});
}

View File

@ -1,288 +0,0 @@
.confirmation {
margin: 0;
min-height: 100vh;
color: #f8fafc;
background:
radial-gradient(ellipse at 5% 20%, #10b98114, transparent 45%),
radial-gradient(ellipse at 95% 45%, #1a1a2e, transparent 55%), #0d0d1a;
font-family: Inter, system-ui, sans-serif;
line-height: 1.65;
color-scheme: dark;
overflow-wrap: anywhere;
}
.confirmation * {
box-sizing: border-box;
}
.confirmation [hidden] {
display: none !important;
}
.confirmation a {
color: inherit;
text-decoration: none;
}
.confirmation a:focus-visible {
outline: 3px solid #10b981;
outline-offset: 6px;
}
.confirmation__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.2rem clamp(1rem, 5vw, 5rem);
border-bottom: 1px solid #ffffff12;
background: #09091299;
}
.confirmation__brand {
display: flex;
align-items: center;
gap: 0.8rem;
letter-spacing: 0.22em;
font-weight: 600;
}
.confirmation__brand small {
display: block;
color: #94a3b8;
letter-spacing: normal;
font-size: 0.7rem;
font-weight: 400;
}
.confirmation__mark {
display: grid;
place-items: center;
width: 3rem;
height: 3rem;
background: #f8fafc;
color: #087452;
border-radius: 0.9rem;
letter-spacing: -0.08em;
font-family: Georgia, serif;
font-size: 1.3rem;
}
.confirmation__main {
max-width: 74rem;
padding: 4rem 1.5rem;
margin: auto;
}
.confirmation__hero {
max-width: 49rem;
margin-bottom: 3rem;
}
.confirmation__eyebrow {
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.17em;
font-size: 0.72rem;
font-weight: 600;
}
.confirmation__eyebrow span {
display: inline-grid;
place-items: center;
width: 1.6rem;
height: 1.6rem;
margin-right: 0.5rem;
border: 1px solid #10b98170;
border-radius: 50%;
color: #10b981;
}
.confirmation h1 {
margin: 1rem 0 1.5rem;
font-family: "Playfair Display", Georgia, serif;
font-weight: 500;
font-size: clamp(2.5rem, 5vw, 4.5rem);
line-height: 1.12;
letter-spacing: -0.035em;
}
.confirmation h1 em {
color: #10b981;
font-weight: inherit;
}
.confirmation h2 {
margin: 0.5rem 0 1rem;
font-family: "Playfair Display", Georgia, serif;
font-weight: 500;
font-size: 1.75rem;
line-height: 1.3;
}
.confirmation h3 {
font-size: 1rem;
margin: 1.5rem 0 0.65rem;
}
.confirmation__hero > p:not(.confirmation__eyebrow) {
max-width: 42rem;
}
.confirmation__simulation {
color: #cbd5e1;
padding-left: 1rem;
border-left: 2px solid #10b981;
font-size: 0.85rem;
}
.confirmation__grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
gap: 1.5rem;
align-items: start;
}
.confirmation__card {
border: 1px solid #ffffff14;
border-radius: 1.4rem;
padding: clamp(1.25rem, 3vw, 2.25rem);
background: linear-gradient(145deg, #ffffff06, #ffffff02);
}
.confirmation__sidebar {
display: grid;
gap: 1.5rem;
}
.confirmation__muted,
.confirmation__steps p {
color: #94a3b8;
}
.confirmation__details {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem 1rem;
margin: 1.75rem 0 0;
font-size: 0.9rem;
}
.confirmation__details dt {
color: #94a3b8;
}
.confirmation__details dd {
margin: 0;
text-align: right;
}
.confirmation__details:not(.confirmation__details--stacked) dt:last-of-type,
.confirmation__details:not(.confirmation__details--stacked) dd:last-of-type {
border-top: 1px solid #ffffff20;
padding-top: 1rem;
font-size: 1.1rem;
color: #f8fafc;
font-weight: 600;
}
.confirmation__details--stacked {
display: block;
}
.confirmation__details--stacked dd {
text-align: left;
margin: 0.2rem 0 1rem;
}
.confirmation__mail {
border-color: #10b98140;
background: #10b98108;
}
.confirmation__mail h2 {
font-family: inherit;
font-size: 1.1rem;
}
.confirmation__mail p {
color: #cbd5e1;
font-size: 0.9rem;
}
.confirmation__features {
list-style: none;
padding: 0;
color: #cbd5e1;
font-size: 0.9rem;
}
.confirmation__features li {
position: relative;
padding-left: 1.5rem;
margin: 0.8rem 0;
}
.confirmation__features li::before {
content: "✓";
position: absolute;
left: 0;
color: #10b981;
}
.confirmation__next {
margin-top: 1.5rem;
}
.confirmation__steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 1.5rem;
list-style: none;
counter-reset: steps;
padding: 0;
}
.confirmation__steps li {
counter-increment: steps;
border-top: 1px solid #ffffff20;
padding-top: 1rem;
}
.confirmation__steps li::before {
content: "0" counter(steps);
color: #10b981;
font-size: 0.8rem;
}
.confirmation__steps h3 {
margin-top: 0.5rem;
}
.confirmation__steps p {
font-size: 0.9rem;
}
.confirmation__actions {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
margin-top: 2rem;
}
.confirmation__button,
.confirmation__link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 1rem;
min-height: 2.75rem;
padding: 0.65rem 1.3rem;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 600;
}
.confirmation__button {
background: #10b981;
color: #071a14 !important;
}
.confirmation__link {
border: 1px solid #ffffff20;
}
.confirmation__button:hover {
background: #34d399;
}
.confirmation__link:hover {
background: #ffffff08;
}
.confirmation__empty {
max-width: 42rem;
min-height: 60vh;
}
.confirmation__footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 1rem;
border-top: 1px solid #ffffff12;
padding: 1.5rem clamp(1rem, 5vw, 5rem);
color: #94a3b8;
font-size: 0.75rem;
letter-spacing: 0.12em;
}
.confirmation__footer span {
letter-spacing: normal;
}
@media (max-width: 45rem) {
.confirmation__grid {
grid-template-columns: minmax(0, 1fr);
}
.confirmation__main {
padding: 2.5rem 1rem;
}
.confirmation__header {
flex-wrap: wrap;
}
.confirmation__brand small {
max-width: 12rem;
}
}

View File

@ -1,98 +0,0 @@
import "./booking-confirmation.css";
import { orderSummaryRows } from "../../shared/order-summary.js";
import { paymentNotice } from "../../shared/booking-copy.js";
const mailMessages = {
accepted:
"Die Bestätigung wurde vom Mailserver zum Versand angenommen. Die Zustellung ist noch nicht bestätigt. Bitte prüfe auch deinen Spam-Ordner.",
pending:
"Deine Buchung ist gespeichert. Der Bestätigungsversand steht noch aus. Du kannst den Versand mit derselben Buchung erneut versuchen.",
failed: "Deine Buchung ist gespeichert, aber der Bestätigungsversand ist fehlgeschlagen. Bitte buche nicht erneut. Wende dich mit deiner Buchungsnummer an Tri-Hub.",
unknown:
"Deine Buchung ist gespeichert. Der Versandstatus ist unklar. Bitte buche nicht erneut. Wende dich mit deiner Buchungsnummer an Tri-Hub.",
};
const select = (id) => document.getElementById(id);
function rows(id, values) {
const list = select(id);
for (const [label, value] of values) {
const term = document.createElement("dt");
const description = document.createElement("dd");
term.textContent = label;
description.textContent = value || "Nicht verfügbar";
list.append(term, description);
}
}
// Nur die Antwort der gespeicherten Buchung anzeigen, keine aktuellen
// Katalogpreise nachladen und keine erneute Buchungsanfrage auslösen.
let body;
try {
body = JSON.parse(sessionStorage.getItem("trihub.booking.confirmation"));
} catch {
// Ohne lesbaren Sitzungsspeicher bleibt der Hinweis sichtbar.
}
if (
body?.saved === true &&
typeof body.bookingId === "string" &&
body.bookingId
) {
const order = body.bookedPackage;
select("confirmation-payment").textContent = paymentNotice;
select("summary-title").textContent =
body.packageName || order?.title || "Dein Paket";
select("confirmation-summary").textContent = order?.summaryForBooking || "";
const date = new Date(body.createdAt);
rows("confirmation-receipt", [
["Buchungsnummer", body.bookingId],
[
"Buchungsdatum",
Number.isNaN(date.getTime())
? "Nicht verfügbar"
: new Intl.DateTimeFormat("de-DE", {
dateStyle: "long",
timeStyle: "short",
}).format(date),
],
["E-Mail-Adresse", body.customerEmail],
[
"Buchungsstatus",
body.status === "CONFIRMED"
? "Bestätigt (Simulation)"
: "Gespeichert (Simulation)",
],
]);
select("confirmation-mail-status").textContent =
mailMessages[body.emailStatus] || mailMessages.unknown;
if (order) {
rows("confirmation-totals", orderSummaryRows(order));
for (const feature of order.includedFeatures || []) {
const item = document.createElement("li");
item.textContent = feature;
select("confirmation-features").append(item);
}
select("confirmation-features-section").hidden =
!order.includedFeatures?.length;
for (const step of order.processSteps || []) {
const item = document.createElement("li");
const title = document.createElement("h3");
const text = document.createElement("p");
title.textContent = step.step;
text.textContent = step.text;
item.append(title, text);
select("confirmation-steps").append(item);
}
select("confirmation-steps-section").hidden =
!order.processSteps?.length;
} else {
select("confirmation-no-snapshot").hidden = false;
rows("confirmation-totals", [["Paket-ID", body.packageId]]);
}
if (body.emailStatus === "pending" && body.packageId) {
const params = new URLSearchParams({ id: body.packageId });
if (order?.variantId) params.set("variant", order.variantId);
select("confirmation-retry").href = `/paket-details.html?${params}`;
select("confirmation-retry").hidden = false;
}
select("confirmation-empty").hidden = true;
select("confirmation-content").hidden = false;
}

View File

@ -5,12 +5,9 @@ const root = document.querySelector("#booking-root");
let cleanup; let cleanup;
function render() { function render() {
cleanup?.(); cleanup?.();
// Eigenständige Testseite, bis Person 2 den gemeinsamen Router bereitstellt.
const match = location.hash.match(/^#\/buchen\/([a-z0-9-]+)$/); const match = location.hash.match(/^#\/buchen\/([a-z0-9-]+)$/);
const params = new URLSearchParams(location.search); cleanup = mountBookingPage(root, { packageId: match?.[1] });
cleanup = mountBookingPage(root, {
packageId: params.get("id") || match?.[1],
variantId: params.get("variant") || undefined,
});
} }
window.addEventListener("hashchange", render); window.addEventListener("hashchange", render);
render(); render();

View File

@ -1,32 +1,19 @@
import { import { loadPackages, submitBooking } from "./booking-api.js";
isValidBookingPhone,
phoneError,
} from "../../shared/phone-validation.js";
import { previewBooking, submitBooking } from "./booking-api.js";
import "./booking.css"; import "./booking.css";
import { orderSummaryRows } from "../../shared/order-summary.js";
import { paymentNotice } from "../../shared/booking-copy.js"; import { paymentNotice } from "../../shared/booking-copy.js";
// Die Detailseite kann dieselbe Section direkt mounten und später aufräumen. // Kann später vom gemeinsamen Router aufgerufen werden. cleanup entfernt
// Die Zieladresse kann bei der Integration bei Bedarf überschrieben werden. // Listener und bricht das Laden beim Verlassen der Ansicht ab.
export function mountBookingPage( export function mountBookingPage(root, { packageId } = {}) {
root,
{
packageId,
variantId,
confirmationUrl = root.dataset.confirmationUrl ||
"/booking-confirmation.html",
} = {},
) {
const controller = new AbortController(); const controller = new AbortController();
const { signal } = controller; const { signal } = controller;
const instanceId = `booking-${crypto.randomUUID()}`;
root.innerHTML = ` root.innerHTML = `
<section class="booking" aria-labelledby="${instanceId}-heading"> <div class="booking">
<h2 id="${instanceId}-heading">Paket buchen</h2> <a href="/#beratung">Zur Angebotsseite</a>
<h1>Paket buchen</h1>
<p class="booking__intro" role="status">Paket wird geladen …</p> <p class="booking__intro" role="status">Paket wird geladen …</p>
<div class="booking__content"></div> <div class="booking__content"></div>
</section>`; </div>`;
const intro = root.querySelector(".booking__intro"); const intro = root.querySelector(".booking__intro");
const content = root.querySelector(".booking__content"); const content = root.querySelector(".booking__content");
@ -37,22 +24,20 @@ export function mountBookingPage(
return; return;
} }
try { try {
const selected = await previewBooking(packageId, variantId, signal); const data = await loadPackages(signal);
if (signal.aborted) return; if (signal.aborted) return;
intro.remove(); const selected = data.packages.find(
renderForm(selected); (entry) => entry.id === packageId,
} catch (error) { );
if (signal.aborted) return; if (!selected) {
if ( intro.textContent =
[ "Dieses Paket ist nicht verfügbar. Bitte wähle ein Paket auf der Angebotsseite aus.";
"UNKNOWN_PACKAGE",
"INVALID_VARIANT",
"INVALID_INPUT",
].includes(error.code)
) {
intro.textContent = error.message;
return; return;
} }
intro.remove();
renderForm(selected);
} catch {
if (signal.aborted) return;
intro.textContent = intro.textContent =
"Das Paket konnte nicht geladen werden. Bitte prüfe deine Verbindung und versuche es erneut."; "Das Paket konnte nicht geladen werden. Bitte prüfe deine Verbindung und versuche es erneut.";
const retry = document.createElement("button"); const retry = document.createElement("button");
@ -76,123 +61,43 @@ export function mountBookingPage(
<div class="booking__package"> <div class="booking__package">
<h2 class="booking__package-name"></h2> <h2 class="booking__package-name"></h2>
<p class="booking__summary"></p> <p class="booking__summary"></p>
<p class="booking__description"></p>
<dl class="booking__totals"></dl>
<h3>Enthaltene Leistungen</h3>
<ul class="booking__features"></ul>
<div class="booking__steps"></div>
</div> </div>
<aside class="booking__payment" aria-labelledby="${instanceId}-booking-payment-heading"> <aside class="booking__payment" aria-labelledby="booking-payment-heading">
<h2 id="${instanceId}-booking-payment-heading">Hinweis zum Buchungsablauf</h2> <h2 id="booking-payment-heading">Hinweis zum Buchungsablauf</h2>
<p class="booking__payment-notice"></p> <p class="booking__payment-notice"></p>
</aside> </aside>
<button class="booking__button booking__open" type="button" aria-haspopup="dialog" aria-controls="${instanceId}-dialog" aria-expanded="false">Jetzt buchen</button>
<dialog class="booking__dialog" id="${instanceId}-dialog" aria-labelledby="${instanceId}-dialog-heading" aria-describedby="${instanceId}-dialog-intro">
<button class="booking__close" type="button" aria-label="Buchungsdialog schließen">×</button>
<h2 id="${instanceId}-dialog-heading">Paket buchen</h2>
<p id="${instanceId}-dialog-intro">Bitte gib deine Kontaktdaten ein. Name und E-Mail-Adresse sind Pflichtfelder.</p>
<form novalidate> <form novalidate>
<p>Alle Felder sind Pflichtfelder.</p>
<div class="booking__errors" role="alert" tabindex="-1" hidden></div> <div class="booking__errors" role="alert" tabindex="-1" hidden></div>
<div class="booking__field"> <div class="booking__field">
<label for="${instanceId}-booking-name">Name (Pflichtfeld)</label> <label for="booking-name">Name (Pflichtfeld)</label>
<input id="${instanceId}-booking-name" name="name" type="text" autocomplete="name" required maxlength="120" aria-describedby="${instanceId}-booking-name-error" /> <input id="booking-name" name="name" type="text" autocomplete="name" required maxlength="120" aria-describedby="booking-name-error" />
<p class="booking__field-error" id="${instanceId}-booking-name-error"></p> <p class="booking__field-error" id="booking-name-error"></p>
</div> </div>
<div class="booking__field"> <div class="booking__field">
<label for="${instanceId}-booking-email">E-Mail-Adresse (Pflichtfeld)</label> <label for="booking-email">E-Mail-Adresse (Pflichtfeld)</label>
<input id="${instanceId}-booking-email" name="email" type="email" autocomplete="email" inputmode="email" required maxlength="254" aria-describedby="${instanceId}-booking-email-hint ${instanceId}-booking-email-error" /> <input id="booking-email" name="email" type="email" autocomplete="email" inputmode="email" required maxlength="254" aria-describedby="booking-email-hint booking-email-error" />
<p id="${instanceId}-booking-email-hint">An diese Adresse senden wir deine Bestätigung.</p> <p id="booking-email-hint">An diese Adresse senden wir deine Bestätigung.</p>
<p class="booking__field-error" id="${instanceId}-booking-email-error"></p> <p class="booking__field-error" id="booking-email-error"></p>
</div>
<div class="booking__field">
<label for="${instanceId}-booking-phone">Telefonnummer (optional)</label>
<input id="${instanceId}-booking-phone" name="phone" type="tel" autocomplete="tel" maxlength="50" aria-describedby="${instanceId}-booking-phone-error" />
<p class="booking__field-error" id="${instanceId}-booking-phone-error"></p>
</div>
<div class="booking__actions">
<button class="booking__button booking__cancel" type="button">Abbrechen</button>
<button class="booking__button" type="submit">Paket buchen</button>
</div> </div>
<button class="booking__button" type="submit">Paket buchen</button>
<p class="booking__progress" role="status"></p> <p class="booking__progress" role="status"></p>
</form> </form>
<div class="booking__result" role="status" tabindex="-1" hidden></div> <div class="booking__result" role="status" tabindex="-1" hidden></div>`;
</dialog>`; content.querySelector(".booking__package-name").textContent =
const dialog = content.querySelector("dialog"); selected.name;
const opener = content.querySelector(".booking__open"); content.querySelector(".booking__summary").textContent =
opener.addEventListener( selected.summary;
"click",
() => {
dialog.showModal();
opener.setAttribute("aria-expanded", "true");
const focusTarget =
content.querySelector(".booking__result:not([hidden])") ||
content.querySelector('[name="name"]');
focusTarget.focus();
},
{ signal },
);
for (const close of content.querySelectorAll(
".booking__close, .booking__cancel",
)) {
close.addEventListener("click", () => dialog.close(), { signal });
}
dialog.addEventListener(
"close",
() => {
opener.setAttribute("aria-expanded", "false");
opener.focus();
},
{ signal },
);
function renderOrder(order) {
content.querySelector(".booking__package-name").textContent =
order.title;
content.querySelector(".booking__summary").textContent =
order.summaryForBooking;
content.querySelector(".booking__description").textContent =
order.subtitle;
const totals = content.querySelector(".booking__totals");
totals.replaceChildren();
for (const [label, value] of orderSummaryRows(order)) {
const term = document.createElement("dt");
const description = document.createElement("dd");
term.textContent = label;
description.textContent = value;
totals.append(term, description);
}
const features = content.querySelector(".booking__features");
features.replaceChildren(
...order.includedFeatures.map((text) => {
const item = document.createElement("li");
item.textContent = text;
return item;
}),
);
const steps = content.querySelector(".booking__steps");
steps.replaceChildren();
if (order.processSteps?.length) {
const heading = document.createElement("h3");
heading.textContent = "So geht es weiter";
steps.append(heading);
for (const entry of order.processSteps) {
const paragraph = document.createElement("p");
paragraph.textContent = `${entry.step}: ${entry.text}`;
steps.append(paragraph);
}
}
}
renderOrder(selected);
content.querySelector(".booking__payment-notice").textContent = content.querySelector(".booking__payment-notice").textContent =
paymentNotice; paymentNotice;
const form = content.querySelector("form"); const form = content.querySelector("form");
const name = form.elements.namedItem("name"); const name = form.elements.namedItem("name");
const email = form.elements.namedItem("email"); const email = form.elements.namedItem("email");
const phone = form.elements.namedItem("phone"); const button = form.querySelector("button");
const button = form.querySelector('button[type="submit"]');
const errors = form.querySelector(".booking__errors"); const errors = form.querySelector(".booking__errors");
const progress = form.querySelector(".booking__progress"); const progress = form.querySelector(".booking__progress");
const result = content.querySelector(".booking__result"); const result = content.querySelector(".booking__result");
const storageKey = `trihub.booking.${selected.packageId}${selected.variantId ? `.${selected.variantId}` : ""}`; const storageKey = `trihub.booking.${selected.id}`;
let attempt = null; let attempt = null;
let busy = false; let busy = false;
let finished = false; let finished = false;
@ -200,15 +105,14 @@ export function mountBookingPage(
function lockFields(locked) { function lockFields(locked) {
name.readOnly = locked; name.readOnly = locked;
email.readOnly = locked; email.readOnly = locked;
phone.readOnly = locked;
} }
function showError(message, fields = {}) { function showError(message, fields = {}) {
errors.textContent = message; errors.textContent = message;
errors.hidden = false; errors.hidden = false;
for (const field of [name, email, phone]) { for (const field of [name, email]) {
const message = fields[field.name] || ""; const message = fields[field.name] || "";
content.querySelector( content.querySelector(
`#${instanceId}-booking-${field.name}-error`, `#booking-${field.name}-error`,
).textContent = message; ).textContent = message;
field.setAttribute("aria-invalid", String(Boolean(message))); field.setAttribute("aria-invalid", String(Boolean(message)));
} }
@ -216,15 +120,14 @@ export function mountBookingPage(
} }
function clearErrors() { function clearErrors() {
errors.hidden = true; errors.hidden = true;
for (const field of [name, email, phone]) { for (const field of [name, email]) {
field.removeAttribute("aria-invalid"); field.removeAttribute("aria-invalid");
content.querySelector( content.querySelector(
`#${instanceId}-booking-${field.name}-error`, `#booking-${field.name}-error`,
).textContent = ""; ).textContent = "";
} }
} }
function showResult(body, focus = true) { function showResult(body, focus = true) {
if (body.bookedPackage) renderOrder(body.bookedPackage);
const messages = { const messages = {
accepted: accepted:
"Buchung gespeichert. Die Bestätigung wurde vom Mailserver zum Versand angenommen. Die Zustellung ist noch nicht bestätigt.", "Buchung gespeichert. Die Bestätigung wurde vom Mailserver zum Versand angenommen. Die Zustellung ist noch nicht bestätigt.",
@ -247,15 +150,10 @@ export function mountBookingPage(
try { try {
const stored = JSON.parse(sessionStorage.getItem(storageKey)); const stored = JSON.parse(sessionStorage.getItem(storageKey));
if ( if (stored?.key && stored.payload?.packageId === selected.id) {
stored?.key &&
stored.payload?.packageId === selected.packageId
) {
attempt = stored; attempt = stored;
if (stored.order) renderOrder(stored.order);
name.value = stored.payload.name; name.value = stored.payload.name;
email.value = stored.payload.email; email.value = stored.payload.email;
phone.value = stored.payload.phone || "";
lockFields(true); lockFields(true);
button.textContent = "Status prüfen / erneut versuchen"; button.textContent = "Status prüfen / erneut versuchen";
if (stored.result) showResult(stored.result, false); if (stored.result) showResult(stored.result, false);
@ -287,9 +185,6 @@ export function mountBookingPage(
if (!email.validity.valid || email.value.length > 254) if (!email.validity.valid || email.value.length > 254)
fields.email = fields.email =
"Bitte eine gültige E-Mail-Adresse eingeben."; "Bitte eine gültige E-Mail-Adresse eingeben.";
phone.value = phone.value.trim();
if (!isValidBookingPhone(phone.value))
fields.phone = phoneError;
if (Object.keys(fields).length) { if (Object.keys(fields).length) {
showError( showError(
"Bitte die markierten Angaben prüfen.", "Bitte die markierten Angaben prüfen.",
@ -299,15 +194,10 @@ export function mountBookingPage(
} }
const nextAttempt = { const nextAttempt = {
key: crypto.randomUUID(), key: crypto.randomUUID(),
order: selected,
payload: { payload: {
packageId: selected.packageId, packageId: selected.id,
...(selected.variantId
? { variantId: selected.variantId }
: {}),
name: name.value, name: name.value,
email: email.value, email: email.value,
...(phone.value ? { phone: phone.value } : {}),
}, },
}; };
try { try {
@ -344,26 +234,7 @@ export function mountBookingPage(
} catch { } catch {
/* Der ursprüngliche Schlüssel bleibt erhalten. */ /* Der ursprüngliche Schlüssel bleibt erhalten. */
} }
if (!signal.aborted) { if (!signal.aborted) showResult(body);
showResult(body);
if (
body.saved &&
body.emailStatus === "accepted" &&
confirmationUrl
) {
// Nur weiterleiten, wenn die Bestätigungsseite die
// Serverantwort in diesem Tab auch lesen kann.
try {
sessionStorage.setItem(
"trihub.booking.confirmation",
JSON.stringify(body),
);
} catch {
return; // Die bestehende Bestätigung bleibt sichtbar.
}
window.location.assign(confirmationUrl);
}
}
} catch (error) { } catch (error) {
// Nur eindeutige Ablehnungen vor dem Speichern erlauben eine // Nur eindeutige Ablehnungen vor dem Speichern erlauben eine
// Korrektur. Bei Verbindungsfehlern bleiben Daten und Key fest. // Korrektur. Bei Verbindungsfehlern bleiben Daten und Key fest.
@ -371,7 +242,6 @@ export function mountBookingPage(
[ [
"INVALID_INPUT", "INVALID_INPUT",
"UNKNOWN_PACKAGE", "UNKNOWN_PACKAGE",
"INVALID_VARIANT",
"INVALID_KEY", "INVALID_KEY",
].includes(error.code) ].includes(error.code)
) { ) {
@ -402,9 +272,5 @@ export function mountBookingPage(
); );
} }
load(); load();
return () => { return () => controller.abort();
controller.abort();
root.querySelector(".booking__dialog")?.close();
root.replaceChildren();
};
} }

View File

@ -94,99 +94,3 @@
width: 100%; width: 100%;
} }
} }
.booking__totals {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem 1rem;
padding-block: 1rem;
border-block: 1px solid #d4ddd7;
}
.booking__totals dd {
margin: 0;
text-align: right;
}
.booking__totals dt:last-of-type,
.booking__totals dd:last-of-type {
font-weight: 700;
font-size: 1.15rem;
}
.booking__features {
padding-left: 1.25rem;
}
.booking__features li {
margin-block: 0.5rem;
}
/* Sämtliche Dialogregeln bleiben innerhalb der Booking-Section. */
.booking .booking__dialog {
box-sizing: border-box;
width: min(32rem, calc(100vw - 2rem));
max-height: calc(100dvh - 2rem);
margin: auto;
padding: 1.75rem;
overflow-y: auto;
color: #edf5f5;
background: #071316;
border: 1px solid #324449;
border-radius: 1.25rem;
box-shadow: 0 1.5rem 4rem #0006;
color-scheme: dark;
}
.booking .booking__dialog::backdrop {
background: rgb(0 0 0 / 65%);
}
.booking__dialog h2 {
margin: 0 2rem 0.75rem 0;
font-size: 1.35rem;
}
.booking .booking__close {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 2.75rem;
height: 2.75rem;
border: 0;
background: transparent;
color: inherit;
font: inherit;
font-size: 1.75rem;
cursor: pointer;
}
.booking__dialog input {
box-sizing: border-box;
background: #101e22;
border-color: #61777d;
border-radius: 0.75rem;
}
.booking__dialog .booking__button {
background: #00b6c7;
border-color: #00b6c7;
color: #061619;
border-radius: 0.75rem;
}
.booking__dialog .booking__cancel {
background: transparent;
border-color: #61777d;
color: inherit;
}
.booking__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.booking__actions > button {
flex: 1 1 auto;
}
.booking__dialog .booking__errors,
.booking__dialog .booking__field-error {
color: #ffb5b5;
}
.booking__dialog [aria-invalid="true"] {
border-color: #ffb5b5;
}
.booking__dialog :focus-visible,
.booking__dialog .booking__errors:focus,
.booking__dialog .booking__result:focus {
outline-color: #00d6e8;
}

View File

@ -1,5 +1,3 @@
import "./styles/styles.css"; import "./styles/styles.css";
import "./components/navigationsbar/navigation.js";
document.querySelector("#year").textContent = new Date().getFullYear(); document.querySelector("#year").textContent = new Date().getFullYear();

View File

@ -1,10 +0,0 @@
bookingId,createdAt,packageId,packageName,name,email,emailStatus,idempotencyKey,requestHash,bookedPackage,customer,acceptedTerms
TH-000001,2026-09-25T11:43:43.074Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,kk,kk@web.de,accepted,497662ce-278c-466f-9505-c2728f8973ca,d5c69de514e6de5552e43f99256a042b59df5bd53db0bd1685f849809a455ef9,,,
TH-000002,2026-09-25T19:22:37.801Z,ernaehrung-starter,Für den sicheren Einstieg in die Sporternährung,Marvin,marvin@web.de,accepted,06b9ad79-05c3-4e37-9135-0c3cf1da3f36,7650a36bd436390b5f79dde1ea1e0d8944f1320b29bad927dfd71157c3f4133c,,,
TH-000003,2026-09-25T19:52:53.927Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,M,test@web.de,accepted,9446e525-a714-4912-bb10-1906a75cc9cd,a8fbd181361e4de7713fb0c4b5e0bf88cd43acd3e16294e7b84b33d959551458,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,
TH-000004,2026-09-25T19:54:52.744Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,m,test@web.de,accepted,1817271c-343e-4e27-ac60-9d225897ba28,4a9f445acdf5cef94e95d8cdf50e78f95884913799cd2f532aaa37302910a16b,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",,
TH-000005,2026-09-25T20:14:51.874Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,Marvin,m@web.de,accepted,678654e5-7db9-49af-97b0-64fe02257e77,89c188f086a2d72fb77ae52612288610d2f17846fb1a3e62b11e8caa011cf2b4,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",,
TH-000006,2026-09-26T08:44:42.313Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,Marvin,m@web.de,accepted,a0a8c426-fc56-4887-a0e5-42172ff9cfb2,b83cf7b2ef4c42447bd83038c60d2ca565a8b37d24cae128f4fa2214dc43b185,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,
TH-000007,2026-09-26T08:50:50.112Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,k@web.de,accepted,92e12eff-321d-49e5-a9ad-674a7d61f9a7,14b1f6b3909696f8402be66b1de33956b84ca435f58567e57e31e7152b6bff6c,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,
TH-000008,2026-09-26T09:18:17.493Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,marvin@test.de,accepted,c74460a8-0315-4e0b-9d6b-48c854b0f71b,19ebc08e678515ea3a71f9b295ac1fbbf188bf54602a531f4f3bdc53698dcda7,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,
TH-000009,2026-09-26T09:43:16.704Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,marvin,m@email.de,accepted,9c18134f-3a71-428b-8769-fda7d06790c7,eaa9a3f0dfc568c726f1b6fc26d92f8c4cc7df072f9fdd40fbea581661eeef9f,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-24"",""variantLabel"":""24-Wochen-Variante"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":671.43,""taxRate"":19,""taxAmount"":127.57,""grossPrice"":799,""currency"":""EUR""}",,
1 bookingId createdAt packageId packageName name email emailStatus idempotencyKey requestHash bookedPackage customer acceptedTerms
2 TH-000001 2026-09-25T11:43:43.074Z ernaehrung-standard Für Best Ager mit ersten Erfahrungen und klaren Zielen kk kk@web.de accepted 497662ce-278c-466f-9505-c2728f8973ca d5c69de514e6de5552e43f99256a042b59df5bd53db0bd1685f849809a455ef9
3 TH-000002 2026-09-25T19:22:37.801Z ernaehrung-starter Für den sicheren Einstieg in die Sporternährung Marvin marvin@web.de accepted 06b9ad79-05c3-4e37-9135-0c3cf1da3f36 7650a36bd436390b5f79dde1ea1e0d8944f1320b29bad927dfd71157c3f4133c
4 TH-000003 2026-09-25T19:52:53.927Z ernaehrung-standard Für Best Ager mit ersten Erfahrungen und klaren Zielen M test@web.de accepted 9446e525-a714-4912-bb10-1906a75cc9cd a8fbd181361e4de7713fb0c4b5e0bf88cd43acd3e16294e7b84b33d959551458 {"packageId":"ernaehrung-standard","variantId":null,"variantLabel":null,"type":"STANDARD","title":"Für Best Ager mit ersten Erfahrungen und klaren Zielen","subtitle":"Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf.","summaryForBooking":"Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)","durationWeeks":24,"durationLabel":"24 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","includedFeatures":["Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung","Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)","Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag","Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung","Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke","Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)"],"processSteps":[{"step":"1. Tiefen-Anamnese","text":"Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten."},{"step":"2. Periodisierte Planung","text":"Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke."},{"step":"3. Wettkampf-Simulation","text":"Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag."}],"netPrice":377.31,"taxRate":19,"taxAmount":71.69,"grossPrice":449,"currency":"EUR"}
5 TH-000004 2026-09-25T19:54:52.744Z ernaehrung-premium Für maximale Individualität mit klarem Fokus m test@web.de accepted 1817271c-343e-4e27-ac60-9d225897ba28 4a9f445acdf5cef94e95d8cdf50e78f95884913799cd2f532aaa37302910a16b {"packageId":"ernaehrung-premium","variantId":"ernaehrung-premium-52","variantLabel":"52-Wochen-Variante (Jahresbegleitung)","type":"PREMIUM","title":"Für maximale Individualität und intensive 1:1-Begleitung","subtitle":"Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten.","summaryForBooking":"Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)","durationWeeks":52,"durationLabel":"52 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","includedFeatures":["Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte","Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan","Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50","14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support","Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)","Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub"],"processSteps":[{"step":"1. Ganzheitliches Onboarding","text":"Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur."},{"step":"2. Laufende Steuerung","text":"Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration."},{"step":"3. Punktlandung am Wettkampftag","text":"Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung."}],"netPrice":1175.63,"taxRate":19,"taxAmount":223.37,"grossPrice":1399,"currency":"EUR"}
6 TH-000005 2026-09-25T20:14:51.874Z ernaehrung-premium Für maximale Individualität mit klarem Fokus Marvin m@web.de accepted 678654e5-7db9-49af-97b0-64fe02257e77 89c188f086a2d72fb77ae52612288610d2f17846fb1a3e62b11e8caa011cf2b4 {"packageId":"ernaehrung-premium","variantId":"ernaehrung-premium-52","variantLabel":"52-Wochen-Variante (Jahresbegleitung)","type":"PREMIUM","title":"Für maximale Individualität und intensive 1:1-Begleitung","subtitle":"Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten.","summaryForBooking":"Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)","durationWeeks":52,"durationLabel":"52 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","includedFeatures":["Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte","Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan","Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50","14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support","Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)","Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub"],"processSteps":[{"step":"1. Ganzheitliches Onboarding","text":"Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur."},{"step":"2. Laufende Steuerung","text":"Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration."},{"step":"3. Punktlandung am Wettkampftag","text":"Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung."}],"netPrice":1175.63,"taxRate":19,"taxAmount":223.37,"grossPrice":1399,"currency":"EUR"}
7 TH-000006 2026-09-26T08:44:42.313Z ernaehrung-standard Für Best Ager mit ersten Erfahrungen und klaren Zielen Marvin m@web.de accepted a0a8c426-fc56-4887-a0e5-42172ff9cfb2 b83cf7b2ef4c42447bd83038c60d2ca565a8b37d24cae128f4fa2214dc43b185 {"packageId":"ernaehrung-standard","variantId":null,"variantLabel":null,"type":"STANDARD","title":"Für Best Ager mit ersten Erfahrungen und klaren Zielen","subtitle":"Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf.","summaryForBooking":"Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)","durationWeeks":24,"durationLabel":"24 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","includedFeatures":["Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung","Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)","Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag","Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung","Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke","Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)"],"processSteps":[{"step":"1. Tiefen-Anamnese","text":"Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten."},{"step":"2. Periodisierte Planung","text":"Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke."},{"step":"3. Wettkampf-Simulation","text":"Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag."}],"netPrice":377.31,"taxRate":19,"taxAmount":71.69,"grossPrice":449,"currency":"EUR"}
8 TH-000007 2026-09-26T08:50:50.112Z ernaehrung-standard Für Best Ager mit ersten Erfahrungen und klaren Zielen m k@web.de accepted 92e12eff-321d-49e5-a9ad-674a7d61f9a7 14b1f6b3909696f8402be66b1de33956b84ca435f58567e57e31e7152b6bff6c {"packageId":"ernaehrung-standard","variantId":null,"variantLabel":null,"type":"STANDARD","title":"Für Best Ager mit ersten Erfahrungen und klaren Zielen","subtitle":"Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf.","summaryForBooking":"Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)","durationWeeks":24,"durationLabel":"24 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","includedFeatures":["Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung","Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)","Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag","Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung","Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke","Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)"],"processSteps":[{"step":"1. Tiefen-Anamnese","text":"Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten."},{"step":"2. Periodisierte Planung","text":"Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke."},{"step":"3. Wettkampf-Simulation","text":"Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag."}],"netPrice":377.31,"taxRate":19,"taxAmount":71.69,"grossPrice":449,"currency":"EUR"}
9 TH-000008 2026-09-26T09:18:17.493Z ernaehrung-standard Für Best Ager mit ersten Erfahrungen und klaren Zielen m marvin@test.de accepted c74460a8-0315-4e0b-9d6b-48c854b0f71b 19ebc08e678515ea3a71f9b295ac1fbbf188bf54602a531f4f3bdc53698dcda7 {"packageId":"ernaehrung-standard","variantId":null,"variantLabel":null,"type":"STANDARD","title":"Für Best Ager mit ersten Erfahrungen und klaren Zielen","subtitle":"Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf.","summaryForBooking":"Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)","durationWeeks":24,"durationLabel":"24 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","includedFeatures":["Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung","Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)","Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag","Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung","Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke","Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)"],"processSteps":[{"step":"1. Tiefen-Anamnese","text":"Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten."},{"step":"2. Periodisierte Planung","text":"Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke."},{"step":"3. Wettkampf-Simulation","text":"Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag."}],"netPrice":377.31,"taxRate":19,"taxAmount":71.69,"grossPrice":449,"currency":"EUR"}
10 TH-000009 2026-09-26T09:43:16.704Z ernaehrung-premium Für maximale Individualität mit klarem Fokus marvin m@email.de accepted 9c18134f-3a71-428b-8769-fda7d06790c7 eaa9a3f0dfc568c726f1b6fc26d92f8c4cc7df072f9fdd40fbea581661eeef9f {"packageId":"ernaehrung-premium","variantId":"ernaehrung-premium-24","variantLabel":"24-Wochen-Variante","type":"PREMIUM","title":"Für maximale Individualität und intensive 1:1-Begleitung","subtitle":"Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten.","summaryForBooking":"Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)","durationWeeks":24,"durationLabel":"24 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","includedFeatures":["Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte","Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan","Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50","14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support","Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)","Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub"],"processSteps":[{"step":"1. Ganzheitliches Onboarding","text":"Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur."},{"step":"2. Laufende Steuerung","text":"Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration."},{"step":"3. Punktlandung am Wettkampftag","text":"Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung."}],"netPrice":671.43,"taxRate":19,"taxAmount":127.57,"grossPrice":799,"currency":"EUR"}

View File

@ -1,10 +1,7 @@
import { createServer } from "node:http"; import { createServer } from "node:http";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { packages } from "../shared/packages.js"; import { packages, demoPackages } from "../shared/packages.js";
import { import { createBookingService } from "./services/booking-service.js";
BookingError,
createBookingService,
} from "./services/booking-service.js";
import { createCsvStorage } from "./services/csv-storage.js"; import { createCsvStorage } from "./services/csv-storage.js";
import { createEmailService } from "./services/email-service.js"; import { createEmailService } from "./services/email-service.js";
import { handleBooking, json } from "./routes/bookings.js"; import { handleBooking, json } from "./routes/bookings.js";
@ -14,7 +11,6 @@ export function createApp({
storage = createCsvStorage( storage = createCsvStorage(
fileURLToPath(new URL("./data/bookings.csv", import.meta.url)), fileURLToPath(new URL("./data/bookings.csv", import.meta.url)),
), ),
details,
sendConfirmation, sendConfirmation,
} = {}) { } = {}) {
const ids = new Set(); const ids = new Set();
@ -38,7 +34,6 @@ export function createApp({
} }
const service = createBookingService({ const service = createBookingService({
storage, storage,
details,
packages: catalog, packages: catalog,
sendConfirmation: sendConfirmation ?? createEmailService(), sendConfirmation: sendConfirmation ?? createEmailService(),
}); });
@ -46,43 +41,15 @@ export function createApp({
const path = new URL(request.url, "http://localhost").pathname; const path = new URL(request.url, "http://localhost").pathname;
if (path === "/api/angebote" && request.method === "GET") { if (path === "/api/angebote" && request.method === "GET") {
return json(response, 200, { return json(response, 200, {
packages: catalog.map(({ id, name, summary }) => ({ packages: catalog.map(({ id, name, summary, testOnly }) => ({
id, id,
name, name,
summary, summary,
testOnly: Boolean(testOnly),
})), })),
}); });
} }
if (path === "/api/packages" && request.method === "GET") { if (path === "/api/bookings") {
return json(
response,
200,
catalog.map(({ name, summary, ...entry }) => ({
...entry,
title: name,
description: summary,
})),
);
}
if (path.startsWith("/api/packages/") && request.method === "GET") {
try {
return json(
response,
200,
service.getPackage(path.slice("/api/packages/".length)),
);
} catch (error) {
if (!(error instanceof BookingError)) throw error;
return json(response, error.status, {
error: {
code: error.code,
message: error.message,
fields: error.fields,
},
});
}
}
if (path === "/api/bookings" || path === "/api/bookings/preview") {
if (request.method !== "POST") { if (request.method !== "POST") {
response.setHeader("Allow", "POST"); response.setHeader("Allow", "POST");
return json(response, 405, { return json(response, 405, {
@ -92,9 +59,7 @@ export function createApp({
}, },
}); });
} }
return handleBooking(request, response, service, { return handleBooking(request, response, service);
preview: path.endsWith("/preview"),
});
} }
// Ausschließlich API; keine Auslieferung von CSV oder Serverdateien. // Ausschließlich API; keine Auslieferung von CSV oder Serverdateien.
return json(response, 404, { return json(response, 404, {
@ -107,9 +72,16 @@ if (
process.argv[1] && process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href import.meta.url === pathToFileURL(process.argv[1]).href
) { ) {
const demo = process.env.BOOKING_DEMO === "true";
if (demo && process.env.NODE_ENV === "production")
throw new Error("Demo-Modus ist in Produktion gesperrt.");
const port = Number(process.env.PORT || 3000); const port = Number(process.env.PORT || 3000);
const server = createApp(); const server = createApp({
catalog: demo ? [...packages, ...demoPackages] : packages,
});
server.listen(port, process.env.HOST || "127.0.0.1", () => { server.listen(port, process.env.HOST || "127.0.0.1", () => {
console.log(`Buchungs-API auf Port ${port}`); console.log(
`Buchungs-API auf Port ${port}${demo ? " (Testpaket aktiviert)" : ""}`,
);
}); });
} }

View File

@ -43,15 +43,9 @@ async function readJson(request) {
} }
} }
export async function handleBooking( export async function handleBooking(request, response, service) {
request,
response,
service,
{ preview = false } = {},
) {
try { try {
const input = await readJson(request); const input = await readJson(request);
if (preview) return json(response, 200, service.preview(input));
const result = await service.book( const result = await service.book(
input, input,
request.headers["idempotency-key"], request.headers["idempotency-key"],

View File

@ -1,9 +1,4 @@
import {
isValidBookingPhone,
phoneError,
} from "../../shared/phone-validation.js";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { packagesDetails } from "../../shared/packagesdetails.js";
export class BookingError extends Error { export class BookingError extends Error {
constructor(status, code, message, fields = {}) { constructor(status, code, message, fields = {}) {
@ -35,52 +30,6 @@ function validateInput(input, key) {
"Erwartet wird ein JSON-Objekt.", "Erwartet wird ein JSON-Objekt.",
); );
} }
let customer;
if (Object.hasOwn(input, "customer")) {
if (
!input.customer ||
typeof input.customer !== "object" ||
Array.isArray(input.customer) ||
input.acceptedTerms !== true
) {
throw new BookingError(
400,
"INVALID_INPUT",
"Kundendaten und Zustimmung zu den Bedingungen sind erforderlich.",
);
}
customer = {};
for (const [field, max, required] of [
["firstName", 60, true],
["lastName", 60, true],
["email", 254, true],
["phone", 50, false],
["ageGroup", 40, false],
["notes", 2000, false],
]) {
const value = input.customer[field];
if (value === undefined && !required) continue;
if (
typeof value !== "string" ||
value.length > max ||
(required && !value.trim()) ||
/[\x00-\x1f\x7f]/.test(value)
) {
throw new BookingError(
400,
"INVALID_INPUT",
"Bitte die Kundendaten prüfen.",
{ [field]: "Ungültige Angabe." },
);
}
customer[field] = value.trim();
}
input = {
...input,
name: `${customer.firstName} ${customer.lastName}`,
email: customer.email,
};
}
const fields = {}; const fields = {};
const result = {}; const result = {};
for (const [field, max, message] of [ for (const [field, max, message] of [
@ -116,14 +65,6 @@ function validateInput(input, key) {
) { ) {
fields.email = "Bitte eine gültige E-Mail-Adresse eingeben."; fields.email = "Bitte eine gültige E-Mail-Adresse eingeben.";
} }
const phone = customer ? customer.phone : input.phone;
if (phone !== undefined) {
if (!isValidBookingPhone(phone)) {
fields.phone = phoneError;
} else if (!customer && phone.trim()) {
result.phone = phone.trim();
}
}
if (Object.keys(fields).length) { if (Object.keys(fields).length) {
throw new BookingError( throw new BookingError(
400, 400,
@ -132,24 +73,6 @@ function validateInput(input, key) {
fields, fields,
); );
} }
if (input.variantId != null) {
if (
typeof input.variantId !== "string" ||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.variantId) ||
input.variantId.length > 80
) {
throw new BookingError(
400,
"INVALID_VARIANT",
"Bitte eine gültige Variante auswählen.",
);
}
result.variantId = input.variantId;
}
if (customer) {
result.customer = customer;
result.acceptedTerms = true;
}
return result; return result;
} }
@ -178,9 +101,6 @@ function responseFor(record, replayed) {
createdAt: record.createdAt, createdAt: record.createdAt,
packageId: record.packageId, packageId: record.packageId,
packageName: record.packageName, packageName: record.packageName,
bookedPackage: record.bookedPackage || null,
customerEmail: record.email,
status: "CONFIRMED",
saved: true, saved: true,
emailStatus, emailStatus,
replayed, replayed,
@ -188,85 +108,8 @@ function responseFor(record, replayed) {
}; };
} }
export function createBookingService({ export function createBookingService({ storage, sendConfirmation, packages }) {
storage, // Eine Warteschlange umfasst Lesen, Schreiben UND Versand. Nur ein Prozess!
sendConfirmation,
packages,
details = packagesDetails,
}) {
function getPackage(id) {
const selected =
packages.some((entry) => entry.id === id) &&
details.find((entry) => entry.id === id);
if (!selected)
throw new BookingError(
404,
"UNKNOWN_PACKAGE",
"Dieses Paket ist nicht verfügbar. Bitte wähle ein Paket auf der Angebotsseite aus.",
);
return structuredClone(selected);
}
function preview(input) {
if (
!input ||
typeof input !== "object" ||
Array.isArray(input) ||
typeof input.packageId !== "string"
) {
throw new BookingError(
400,
"INVALID_INPUT",
"Bitte ein gültiges Paket auswählen.",
);
}
let selected;
try {
selected = getPackage(input.packageId);
} catch (error) {
error.status = 400;
throw error;
}
const variants = selected.variants || [];
const variantId = input.variantId ?? variants[0]?.variantId ?? null;
const variant = variants.find((entry) => entry.variantId === variantId);
if (
(variants.length && !variant) ||
(!variants.length &&
variantId !== null &&
variantId !== selected.id)
) {
throw new BookingError(
400,
"INVALID_VARIANT",
"Diese Variante gehört nicht zum ausgewählten Paket.",
);
}
const grossCents = Math.round((variant?.price ?? selected.price) * 100);
const netCents = Math.round(grossCents / (1 + selected.taxRate / 100));
return {
packageId: selected.id,
variantId: variant?.variantId ?? null,
variantLabel: variant?.label ?? null,
type: selected.type,
title: selected.title,
subtitle: selected.subtitle,
summaryForBooking: selected.summaryForBooking,
durationWeeks: variant?.durationWeeks ?? selected.durationWeeks,
durationLabel: variant
? `${variant.durationWeeks} Wochen Begleitung`
: selected.durationLabel,
quantity: 1,
billingInterval: selected.billingInterval,
includedFeatures: selected.includedFeatures,
processSteps: selected.processSteps,
netPrice: netCents / 100,
taxRate: selected.taxRate,
taxAmount: (grossCents - netCents) / 100,
grossPrice: grossCents / 100,
currency: selected.currency,
};
}
// Lesen, Speichern und Versand laufen innerhalb eines Prozesses nacheinander.
let queue = Promise.resolve(); let queue = Promise.resolve();
async function processBooking(input, key) { async function processBooking(input, key) {
const data = validateInput(input, key); const data = validateInput(input, key);
@ -306,18 +149,13 @@ export function createBookingService({
{ packageId: "Bitte ein verfügbares Paket auswählen." }, { packageId: "Bitte ein verfügbares Paket auswählen." },
); );
} }
const bookedPackage = preview(data);
record = { record = {
bookingId: nextBookingId(records), bookingId: nextBookingId(records),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
packageId: selected.id, packageId: selected.id,
packageName: selected.name, packageName: selected.name,
bookedPackage,
customer: data.customer ?? null,
acceptedTerms: data.acceptedTerms ?? null,
name: data.name, name: data.name,
email: data.email, email: data.email,
phone: data.customer?.phone ?? data.phone ?? "",
emailStatus: "pending", emailStatus: "pending",
idempotencyKey: key, idempotencyKey: key,
requestHash, requestHash,
@ -356,8 +194,6 @@ export function createBookingService({
return responseFor(record, replayed); return responseFor(record, replayed);
} }
return { return {
getPackage,
preview,
book(input, key) { book(input, key) {
const task = queue.then(() => processBooking(input, key)); const task = queue.then(() => processBooking(input, key));
queue = task.catch(() => {}); queue = task.catch(() => {});

View File

@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
import { parse } from "csv-parse/sync"; import { parse } from "csv-parse/sync";
import { stringify } from "csv-stringify/sync"; import { stringify } from "csv-stringify/sync";
const legacyColumns = [ export const columns = [
"bookingId", "bookingId",
"createdAt", "createdAt",
"packageId", "packageId",
@ -16,11 +16,6 @@ const legacyColumns = [
"requestHash", "requestHash",
]; ];
const orderColumns = [...legacyColumns, "bookedPackage"];
const customerColumns = [...orderColumns, "customer", "acceptedTerms"];
export const columns = [...customerColumns, "phone"];
const jsonColumns = ["bookedPackage", "customer", "acceptedTerms"];
// Apostroph-Präfix schützt Tabellenprogramme. Ein vorhandenes Apostroph wird // Apostroph-Präfix schützt Tabellenprogramme. Ein vorhandenes Apostroph wird
// ebenfalls maskiert, damit Lesen/Schreiben die Originalwerte exakt erhält. // ebenfalls maskiert, damit Lesen/Schreiben die Originalwerte exakt erhält.
function protectCell(value) { function protectCell(value) {
@ -46,12 +41,7 @@ export function createCsvStorage(filePath) {
return parse(content, { return parse(content, {
bom: true, bom: true,
columns(header) { columns(header) {
if ( if (header.join(",") !== columns.join(",")) {
header.join(",") !== columns.join(",") &&
header.join(",") !== customerColumns.join(",") &&
header.join(",") !== legacyColumns.join(",") &&
header.join(",") !== orderColumns.join(",")
) {
throw new Error("Unbekanntes CSV-Format"); throw new Error("Unbekanntes CSV-Format");
} }
return header; return header;
@ -61,11 +51,7 @@ export function createCsvStorage(filePath) {
Object.fromEntries( Object.fromEntries(
Object.entries(row).map(([key, value]) => [ Object.entries(row).map(([key, value]) => [
key, key,
jsonColumns.includes(key) restoreCell(value),
? value
? JSON.parse(restoreCell(value))
: null
: restoreCell(value),
]), ]),
), ),
); );
@ -78,12 +64,7 @@ export function createCsvStorage(filePath) {
Object.fromEntries( Object.fromEntries(
columns.map((column) => [ columns.map((column) => [
column, column,
protectCell( protectCell(row[column]),
jsonColumns.includes(column) &&
row[column] != null
? JSON.stringify(row[column])
: row[column],
),
]), ]),
), ),
), ),

View File

@ -1,5 +1,4 @@
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { orderSummaryRows } from "../../shared/order-summary.js";
import { paymentNotice } from "../../shared/booking-copy.js"; import { paymentNotice } from "../../shared/booking-copy.js";
export function createEmailService( export function createEmailService(
@ -35,72 +34,23 @@ export function createEmailService(
}); });
const from = env.SMTP_FROM || "Tri-Hub <buchung@example.test>"; const from = env.SMTP_FROM || "Tri-Hub <buchung@example.test>";
return async function sendConfirmation(booking) { return async function sendConfirmation(booking) {
const order = booking.bookedPackage;
const phone = booking.phone || booking.customer?.phone;
const lines = [
`Hallo ${booking.name},`,
"",
"vielen Dank für deine Bestellung bei Tri-Hub. Hier findest du die Zusammenfassung deiner Buchung.",
`Buchungsnummer: ${booking.bookingId}`,
...(booking.createdAt
? [
`Bestelldatum: ${new Intl.DateTimeFormat("de-DE", { dateStyle: "long", timeStyle: "short", timeZone: "Europe/Berlin" }).format(new Date(booking.createdAt))} (Europe/Berlin)`,
]
: []),
`Kunde: ${booking.name}`,
`E-Mail: ${booking.email}`,
...(phone ? [`Telefon: ${phone}`] : []),
...(booking.customer?.notes
? [`Deine Anmerkungen: ${booking.customer.notes}`]
: []),
"",
`Paket: ${order?.title ?? booking.packageName}`,
...(order
? [
order.summaryForBooking,
order.subtitle,
"",
...orderSummaryRows(order).map(
([label, value]) => `${label}: ${value}`,
),
"",
"Enthaltene Leistungen:",
...order.includedFeatures.map(
(feature) => `• ${feature}`,
),
"",
"So geht es weiter:",
...(order.processSteps || []).map(
({ step, text }) => `${step}: ${text}`,
),
]
: []),
"",
paymentNotice,
"",
"Bitte bewahre diese Bestätigung auf und gib bei Rückfragen deine Buchungsnummer an.",
"Dein Tri-Hub-Team",
];
const escapeHtml = (value) =>
String(value).replace(
/[&<>"']/g,
(char) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char],
);
let info; let info;
try { try {
info = await transport.sendMail({ info = await transport.sendMail({
from, from,
to: { address: booking.email, name: booking.name }, to: { address: booking.email, name: booking.name },
subject: `Bestellbestätigung ${booking.bookingId} – Tri-Hub`, subject: `Buchungsbestätigung ${booking.bookingId}`,
text: lines.join("\n"), text: [
html: `<html lang="de"><body><h1>Deine Bestellbestätigung</h1>${lines.map((line) => (line ? `<p>${escapeHtml(line)}</p>` : "")).join("")}</body></html>`, `Hallo ${booking.name},`,
"",
"vielen Dank für deine Buchung. Wir haben deine Angaben erhalten.",
`Buchungsnummer: ${booking.bookingId}`,
`Paket: ${booking.packageName}`,
"",
paymentNotice,
"",
"Dein Tri-Hub-Team",
].join("\n"),
}); });
} catch (error) { } catch (error) {
// Bei Verbindungsabbruch kann SMTP die Nachricht schon angenommen // Bei Verbindungsabbruch kann SMTP die Nachricht schon angenommen

View File

@ -1,682 +0,0 @@
{
"openapi": "3.0.3",
"info": {
"title": "Tri-Hub Ernährungsberatung & Booking API",
"description": "API-Spezifikation für die Angebotsübersicht (US2.1), die Paket-Detailansicht und den Buchungsservice (US2.4 Paket buchen).",
"version": "1.1.0"
},
"servers": [
{
"url": "http://localhost:3000/api",
"description": "Lokaler Node.js Entwicklungsserver"
}
],
"paths": {
"/packages": {
"get": {
"summary": "Alle Ernährungs-Pakete abrufen (Kurzübersicht)",
"description": "Liefert die Basisdaten aus packages.js für die Angebotsübersicht (US2.1).",
"tags": ["Packages"],
"responses": {
"200": {
"description": "Erfolgreiche Rückgabe der Paketliste",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PackageOverview"
}
}
}
}
}
}
}
},
"/packages/{id}": {
"get": {
"summary": "Detaillierte Paketinformationen für Detailseite & Bestellprozess abrufen",
"description": "Liefert die ausführlichen Paketdaten aus packagesdetails.js (inkl. Preis, Laufzeit, Leistungen und Varianten), um sie auf der Detailseite und in der Bestellzusammenfassung des Booking-Service anzuzeigen.",
"tags": ["Packages", "Booking"],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Eindeutige ID des Pakets (z. B. ernaehrung-starter)",
"schema": {
"type": "string",
"example": "ernaehrung-standard"
}
}
],
"responses": {
"200": {
"description": "Detaillierte Paketdaten erfolgreich geladen",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PackageDetail"
}
}
}
},
"404": {
"description": "Paket mit dieser ID wurde nicht gefunden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/bookings/preview": {
"post": {
"summary": "Preis- und Leistungsübersicht für den Bestellprozess berechnen",
"description": "Berechnet die Bestellübersicht aus dem serverseitigen Paketkatalog. Preisangaben aus der Anfrage werden ignoriert. Ohne Varianten-ID wird bei Paketen mit Varianten die erste Variante gewählt. Es wird keine Buchung angelegt.",
"tags": ["Booking"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BookingPreviewRequest"
}
}
}
},
"responses": {
"200": {
"description": "Berechnete Bestellübersicht für das Checkout-Formular",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BookingPreviewResponse"
}
}
}
},
"400": {
"description": "Ungültige Paket- oder Varianten-ID",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/bookings": {
"post": {
"summary": "Paket verbindlich buchen (US2.4)",
"description": "Speichert Paket, gewählte Variante, Leistungen und Preise zum Buchungszeitpunkt und startet den Bestätigungsversand. Der aktuelle Ablauf simuliert die Bestellung ohne Zahlung.",
"tags": ["Booking"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BookingCreateRequest"
}
}
}
},
"responses": {
"201": {
"description": "Buchung erfolgreich angelegt (Bestellbestätigung)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BookingConfirmation"
}
}
}
},
"400": {
"description": "Fehlende oder ungültige Eingabedaten",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"200": {
"description": "Bereits gespeicherte Buchung; keine zweite Buchung oder Mail",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BookingConfirmation"
}
}
}
},
"202": {
"description": "Buchung gespeichert; Versand ausstehend, fehlgeschlagen oder unklar",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BookingConfirmation"
}
}
}
},
"409": {
"description": "Anfrageschlüssel wurde mit anderen Buchungsdaten verwendet",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"413": {
"description": "Anfrage größer als 8 KiB",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"415": {
"description": "JSON-Content-Type erforderlich",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"503": {
"description": "Speicherung oder Statusprüfung nicht verfügbar",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
},
"parameters": [
{
"name": "Idempotency-Key",
"in": "header",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
},
"description": "UUID v4; bei Wiederholung denselben Schlüssel und dieselben Buchungsdaten verwenden."
}
]
}
}
},
"components": {
"schemas": {
"PackageOverview": {
"type": "object",
"required": [
"id",
"type",
"title",
"description",
"fokus",
"anamnese",
"begleitung",
"buttonText"
],
"properties": {
"id": {
"type": "string",
"example": "ernaehrung-standard"
},
"type": {
"type": "string",
"example": "STANDARD"
},
"title": {
"type": "string",
"example": "Für Best Ager mit ersten Erfahrungen und klaren Zielen"
},
"description": {
"type": "string"
},
"fokus": {
"type": "string"
},
"anamnese": {
"type": "string"
},
"begleitung": {
"type": "string"
},
"buttonText": {
"type": "string",
"example": "Standard-Paket ansehen"
},
"highlight": {
"type": "string",
"nullable": true,
"example": "BESONDERS PASSEND"
}
}
},
"PackageVariant": {
"type": "object",
"properties": {
"variantId": {
"type": "string",
"example": "ernaehrung-premium-24"
},
"label": {
"type": "string",
"example": "24-Wochen-Variante"
},
"durationWeeks": {
"type": "integer",
"example": 24
},
"price": {
"type": "number",
"format": "float",
"example": 799.0,
"description": "Bruttopreis der Variante inklusive MwSt."
}
}
},
"PackageDetail": {
"type": "object",
"required": [
"id",
"type",
"title",
"subtitle",
"durationWeeks",
"price",
"currency",
"taxRate",
"includedFeatures"
],
"properties": {
"id": {
"type": "string",
"example": "ernaehrung-standard"
},
"type": {
"type": "string",
"example": "STANDARD"
},
"badge": {
"type": "string",
"example": "BESONDERS PASSEND"
},
"title": {
"type": "string",
"example": "Für Best Ager mit ersten Erfahrungen und klaren Zielen"
},
"subtitle": {
"type": "string"
},
"durationWeeks": {
"type": "integer",
"example": 24
},
"durationLabel": {
"type": "string",
"example": "24 Wochen Begleitung"
},
"price": {
"type": "number",
"format": "float",
"example": 449.0,
"description": "Bruttopreis inklusive der angegebenen MwSt."
},
"currency": {
"type": "string",
"example": "EUR"
},
"billingInterval": {
"type": "string",
"example": "einmalig"
},
"taxRate": {
"type": "integer",
"example": 19
},
"summaryForBooking": {
"type": "string",
"example": "Standard-Paket Sporternährung (24 Wochen Coaching)"
},
"targetGroup": {
"type": "array",
"items": {
"type": "string"
}
},
"includedFeatures": {
"type": "array",
"items": {
"type": "string"
}
},
"processSteps": {
"type": "array",
"items": {
"type": "object",
"properties": {
"step": {
"type": "string"
},
"text": {
"type": "string"
}
}
}
},
"variants": {
"type": "array",
"nullable": true,
"items": {
"$ref": "#/components/schemas/PackageVariant"
}
},
"ctaButtonText": {
"type": "string",
"example": "Standard-Paket jetzt buchen"
}
}
},
"BookingPreviewRequest": {
"type": "object",
"required": ["packageId"],
"properties": {
"packageId": {
"type": "string",
"example": "ernaehrung-premium"
},
"variantId": {
"type": "string",
"nullable": true,
"example": "ernaehrung-premium-52"
}
}
},
"BookingPreviewResponse": {
"type": "object",
"properties": {
"packageId": {
"type": "string",
"example": "ernaehrung-premium"
},
"variantId": {
"type": "string",
"example": "ernaehrung-premium-52",
"nullable": true
},
"title": {
"type": "string",
"example": "Für maximale Individualität und intensive 1:1-Begleitung"
},
"summaryForBooking": {
"type": "string"
},
"durationWeeks": {
"type": "integer",
"example": 52
},
"netPrice": {
"type": "number",
"format": "float",
"example": 1175.63
},
"taxAmount": {
"type": "number",
"format": "float",
"example": 223.37
},
"grossPrice": {
"type": "number",
"format": "float",
"example": 1399.0
},
"currency": {
"type": "string",
"example": "EUR"
},
"variantLabel": {
"type": "string",
"nullable": true
},
"type": {
"type": "string"
},
"subtitle": {
"type": "string"
},
"durationLabel": {
"type": "string"
},
"quantity": {
"type": "integer",
"enum": [1]
},
"billingInterval": {
"type": "string",
"example": "einmalig"
},
"taxRate": {
"type": "number",
"example": 19
},
"includedFeatures": {
"type": "array",
"items": {
"type": "string"
}
},
"processSteps": {
"type": "array",
"items": {
"type": "object",
"properties": {
"step": {
"type": "string"
},
"text": {
"type": "string"
}
}
}
}
},
"required": [
"packageId",
"variantId",
"title",
"summaryForBooking",
"durationWeeks",
"netPrice",
"taxAmount",
"grossPrice",
"currency",
"variantLabel",
"type",
"subtitle",
"durationLabel",
"quantity",
"billingInterval",
"taxRate",
"includedFeatures",
"processSteps"
]
},
"BookingCreateRequest": {
"type": "object",
"required": ["packageId"],
"properties": {
"packageId": {
"type": "string",
"example": "ernaehrung-standard"
},
"variantId": {
"type": "string",
"nullable": true,
"example": "ernaehrung-premium-52"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 120,
"example": "Thomas Müller"
},
"email": {
"type": "string",
"format": "email",
"maxLength": 254,
"example": "thomas.mueller@example.de"
},
"phone": {
"type": "string",
"maxLength": 50,
"description": "Optionale Telefonnummer: deutsche Nummer mit Vorwahl oder internationale Nummer mit Ländervorwahl. Prüfung des Nummernformats, keine Bestätigung der Erreichbarkeit. Bei customer gilt customer.phone.",
"example": "+49 170 1234567"
},
"customer": {
"type": "object",
"required": ["firstName", "lastName", "email"],
"properties": {
"firstName": {
"type": "string",
"example": "Thomas"
},
"lastName": {
"type": "string",
"example": "Müller"
},
"email": {
"type": "string",
"format": "email",
"example": "thomas.mueller@example.de"
},
"phone": {
"type": "string",
"maxLength": 50,
"description": "Optional. Deutsche Nummer mit Vorwahl oder internationale Nummer mit Ländervorwahl; keine Bestätigung der Erreichbarkeit.",
"example": "+49 170 1234567"
},
"ageGroup": {
"type": "string",
"example": "50-59"
},
"notes": {
"type": "string",
"example": "Vorbereitung auf meine erste Mitteldistanz im August."
}
}
},
"acceptedTerms": {
"type": "boolean",
"example": true,
"enum": [true]
}
},
"anyOf": [
{
"required": ["customer", "acceptedTerms"]
},
{
"required": ["name", "email"]
}
],
"description": "Kundendaten gemäß customer mit acceptedTerms=true. Bestehende Clients können alternativ name und email sowie optional phone übergeben."
},
"BookingConfirmation": {
"type": "object",
"properties": {
"bookingId": {
"type": "string",
"example": "TH-000001"
},
"status": {
"type": "string",
"example": "CONFIRMED"
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"bookedPackage": {
"allOf": [
{
"$ref": "#/components/schemas/BookingPreviewResponse"
}
],
"nullable": true,
"description": "Gespeicherter Bestellstand; bei älteren Buchungen ohne diese Daten null."
},
"customerEmail": {
"type": "string",
"example": "thomas.mueller@example.de"
},
"packageId": {
"type": "string"
},
"packageName": {
"type": "string"
},
"saved": {
"type": "boolean"
},
"emailStatus": {
"type": "string",
"enum": ["pending", "accepted", "failed", "unknown"],
"description": "accepted bedeutet SMTP-Annahme, keine bestätigte Zustellung."
},
"replayed": {
"type": "boolean"
}
}
},
"ErrorResponse": {
"type": "object",
"required": ["error"],
"properties": {
"error": {
"type": "object",
"required": ["code", "message"],
"properties": {
"code": {
"type": "string",
"example": "INVALID_VARIANT"
},
"message": {
"type": "string"
},
"fields": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
}
}
}

View File

@ -1,2 +1,4 @@
// Gemeinsamer Hinweis für Buchungsseite und Bestätigungs-E-Mail.
// Bei der späteren Integration echter Zahlungen zusammen mit dem Ablauf ändern.
export const paymentNotice = export const paymentNotice =
"Dieser Buchungsablauf ist eine Simulation. Der Zahlungsvorgang wird übersprungen: Es erfolgt keine Abbuchung und keine verbindliche Beratungsbuchung."; "Dieser Buchungsablauf ist eine Simulation. Der Zahlungsvorgang wird übersprungen: Es erfolgt keine Abbuchung und keine verbindliche Beratungsbuchung. Bei der vollständigen Integration des Bestellprozesses kann die Zahlung ergänzt werden.";

View File

@ -1,28 +0,0 @@
export function formatMoney(amount, currency) {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency,
}).format(amount);
}
export function orderSummaryRows(order) {
return [
["Paket", order.type],
["Paket-ID", order.packageId],
...(order.variantId
? [
["Variante", order.variantLabel],
["Varianten-ID", order.variantId],
]
: []),
["Laufzeit", order.durationLabel || `${order.durationWeeks} Wochen`],
["Menge", String(order.quantity)],
["Abrechnung", order.billingInterval],
["Nettopreis", formatMoney(order.netPrice, order.currency)],
[
`MwSt. (${order.taxRate} %)`,
formatMoney(order.taxAmount, order.currency),
],
["Gesamtbetrag", formatMoney(order.grossPrice, order.currency)],
];
}

View File

@ -1,44 +1,14 @@
export const packages = [ // Gemeinsamer Vertrag mit Person 3: eindeutige id, name und summary (Strings).
// Hier ausschließlich die vom Team freigegebenen Angebote eintragen.
export const packages = [];
// Ausschließlich für lokale Entwicklung; kein echtes Angebot, kein Preis.
export const demoPackages = [
{ {
id: "ernaehrung-starter", id: "demo-booking",
type: "STARTER", name: "1:1-Ernährungsberatung",
name: "Für den sicheren Einstieg in die Sporternährung",
summary: summary:
"Für Menschen 50+, die mit Triathlon beginnen und ihre Ernährung an die neue Belastung anpassen möchten.", "Persönliche Begleitung rund um deine Ernährung im Alltag und beim Sport.",
fokus: "Grundlagen der Makronährstoffe, Hydration im Training und Gewichtsmanagement.", testOnly: true,
anamnese:
"Zu Beginn gehört eine ausführliche gemeinsame Anamnese dazu, damit deine Essgewohnheiten und Ziele sauber eingeordnet werden.",
begleitung:
"Ein individueller Basis-Ernährungsplan und regelmäßige Check-ins im ersten Monat.",
buttonText: "Starter-Paket ansehen",
highlight: null,
},
{
id: "ernaehrung-standard",
type: "STANDARD",
name: "Für Best Ager mit ersten Erfahrungen und klaren Zielen",
summary:
"Für Athletinnen und Athleten, die ihre Ernährung für die Kurzdistanz und im Alltag gezielt weiterentwickeln wollen.",
fokus: "Carb-Loading, Pacing-Strategien für den Magen und Timing der Nahrungsaufnahme.",
anamnese:
"Auch hier startet das Coaching mit einer ausführlichen gemeinsamen Anamnese, damit Trainingsrealität, Erfahrung und Verträglichkeit präzise erfasst werden.",
begleitung:
"Monatliche Anpassung des Plans und enger Austausch bei Magen-Darm-Herausforderungen.",
buttonText: "Standard-Paket ansehen",
highlight: "BESONDERS PASSEND",
},
{
id: "ernaehrung-premium",
type: "PREMIUM",
name: "Für maximale Individualität mit klarem Fokus",
summary:
"Für Menschen, die sich eine besonders enge Coaching-Beziehung und laufende Anpassung ihrer Ernährung wünschen.",
fokus: "Individuelle Stoffwechselanalyse, Mikronährstoff-Optimierung und Raceday-Nutrition.",
anamnese:
"Wie in allen Paketen ist eine ausführliche gemeinsame Anamnese enthalten, damit Betreuung, Regeneration und Zielbild von Beginn an abgestimmt werden.",
begleitung:
"Wöchentliche Anpassungen, direkte Feedback-Schleifen und passgenaue Einkaufslisten.",
buttonText: "Premium-Paket ansehen",
highlight: null,
}, },
]; ];

View File

@ -1,149 +0,0 @@
// src/shared/packagesdetails.js
export const packagesDetails = [
{
id: "ernaehrung-starter",
type: "STARTER",
badge: "EINSTIEG",
title: "Für den sicheren Einstieg in die Sporternährung",
subtitle:
"Strukturierte Grundlagen für Menschen 50+, die ihre Ernährung optimal auf das Triathlon-Training abstimmen wollen.",
durationWeeks: 12,
durationLabel: "12 Wochen Begleitung",
price: 249.0,
currency: "EUR",
billingInterval: "einmalig",
taxRate: 19,
summaryForBooking:
"Starter-Paket Sporternährung (12 Wochen Basis-Begleitung inkl. Anamnese & Basisplan)",
targetGroup: [
"Du steigst neu in den Triathlon ein oder baust nach einer Pause wieder Struktur auf.",
"Du möchtest verstehen, wie du Energielevel und Regeneration über die Alltagsernährung steuerst.",
"Du suchst einen klaren, alltagstauglichen Plan ohne komplizierte Wissenschaft.",
],
includedFeatures: [
"Ausführliche Eingangs-Anamnese (60 Min. Video-Call) zu Gesundheit, Alltag und Zielen",
"Individueller Basis-Ernährungsplan abgestimmt auf deine Trainingswoche",
"Trink- und Hydrationsstrategie für Grundlageneinheiten",
"2x Check-in-Calls (je 30 Min.) zur Plananpassung im Verlauf der 12 Wochen",
"Rezept- und Einkaufsguide für schnelle, nährstoffreiche Mahlzeiten",
],
processSteps: [
{
step: "1. Anamnese & Status Quo",
text: "Auswertung deines 7-Tage-Ernährungsprotokolls und gemeinsames Erstgespräch.",
},
{
step: "2. Dein individueller Plan",
text: "Erstellung deines alltagstauglichen Ernährungskonzepts für Trainingstage und Ruhetage.",
},
{
step: "3. Umsetzung & Rückmeldung",
text: "Regelmäßige Check-ins, um das Tempo anzupassen und Überforderung zu vermeiden.",
},
],
ctaButtonText: "Starter-Paket jetzt buchen",
},
{
id: "ernaehrung-standard",
type: "STANDARD",
badge: "BESONDERS PASSEND",
title: "Für Best Ager mit ersten Erfahrungen und klaren Zielen",
subtitle:
"Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf.",
durationWeeks: 24,
durationLabel: "24 Wochen Begleitung",
price: 449.0,
currency: "EUR",
billingInterval: "einmalig",
taxRate: 19,
summaryForBooking:
"Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)",
targetGroup: [
"Du trainierst bereits regelmäßig und möchtest deine Leistung über die Ernährung spürbar verbessern.",
"Du bereitest dich gezielt auf eine Kurz- oder Mitteldistanz vor.",
"Du möchtest Magen-Darm-Probleme im Training und Wettkampf sicher vermeiden.",
],
includedFeatures: [
"Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung",
"Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)",
"Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag",
"Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung",
"Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke",
"Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)",
],
processSteps: [
{
step: "1. Tiefen-Anamnese",
text: "Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.",
},
{
step: "2. Periodisierte Planung",
text: "Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.",
},
{
step: "3. Wettkampf-Simulation",
text: "Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.",
},
],
ctaButtonText: "Standard-Paket jetzt buchen",
},
{
id: "ernaehrung-premium",
type: "PREMIUM",
badge: "MAXIMALE INDIVIDUALITÄT",
title: "Für maximale Individualität und intensive 1:1-Begleitung",
subtitle:
"Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten.",
durationWeeks: 24,
durationLabel: "24 oder 52 Wochen (wählbar)",
price: 799.0,
currency: "EUR",
billingInterval: "einmalig",
taxRate: 19,
variants: [
{
variantId: "ernaehrung-premium-24",
label: "24-Wochen-Variante",
durationWeeks: 24,
price: 799.0,
},
{
variantId: "ernaehrung-premium-52",
label: "52-Wochen-Variante (Jahresbegleitung)",
durationWeeks: 52,
price: 1399.0,
},
],
summaryForBooking:
"Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)",
targetGroup: [
"Du wünschst dir eine besonders enge Coaching-Beziehung mit kurzen Reaktionswegen.",
"Du möchtest Training, Ernährung, Blutwerte und Regeneration ganzheitlich abstimmen.",
"Du suchst maximale Verbindlichkeit für ein großes Saison-Ziel.",
],
includedFeatures: [
"Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte",
"Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan",
"Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50",
"14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support",
"Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)",
"Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub",
],
processSteps: [
{
step: "1. Ganzheitliches Onboarding",
text: "Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.",
},
{
step: "2. Laufende Steuerung",
text: "Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.",
},
{
step: "3. Punktlandung am Wettkampftag",
text: "Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.",
},
],
ctaButtonText: "Premium-Paket jetzt buchen",
},
];

View File

@ -1,14 +0,0 @@
import { isValidPhoneNumber } from "libphonenumber-js/max";
export const phoneError =
"Bitte eine gültige Telefonnummer mit Vorwahl eingeben, z. B. 0170 1234567 oder +49 170 1234567.";
export function isValidBookingPhone(value) {
if (typeof value !== "string" || value.length > 50) return false;
if (/[\x00-\x1f\x7f]/.test(value)) return false;
const phone = value.trim();
if (!phone) return true;
if (!/^\+?[0-9 ()/.-]+$/.test(phone)) return false;
if (!/^(?:\+|0)/.test(phone)) return false;
return isValidPhoneNumber(phone, "DE");
}

View File

@ -1,221 +0,0 @@
/* Google Fonts: Inter für Fließtext, Playfair Display für Akzente (falls später gebraucht) */
@import url("https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=Inter:wght@400;500;600;700&display=swap");
/* Zentrale Design-Werte der Navbar, passend zum TriHub-Farbschema */
:root {
--nav-bg: #0d0d1a;
--nav-border: rgba(248, 250, 252, 0.08);
--nav-green: #10b981;
--nav-white: #ffffff;
--nav-light: #f8fafc;
--nav-muted: #94a3b8;
}
/* Verhindert, dass Padding die Breite von Elementen zusätzlich vergrößert */
.navbar,
.navbar *,
.navbar *::before,
.navbar *::after {
box-sizing: border-box;
}
/* ---------- Kriterium 1: durchgehend fixiert am oberen Bildschirmrand ---------- */
.navbar {
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 1000;
border-bottom: 1px solid var(--nav-border);
background: var(--nav-bg);
font-family: "Inter", system-ui, sans-serif;
}
.navbar__inner {
width: 100%;
min-height: 76px;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
/* ---------- Logo und Markenname als Sprunglink zum Hauptinhalt ---------- */
.navbar__brand {
display: inline-flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
color: inherit;
text-decoration: none;
}
.navbar__logo-img {
height: 40px;
width: auto;
display: block;
}
.navbar__brand-text {
display: flex;
flex-direction: column;
line-height: 1.25;
}
.navbar__brand-title {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--nav-muted);
}
.navbar__brand-tagline {
font-size: 0.85rem;
font-weight: 500;
color: var(--nav-muted);
}
/* ---------- Menüpunkte (rechts) ---------- */
.navbar__menu {
display: flex;
align-items: center;
gap: 14px;
list-style: none;
margin: 0;
padding: 0;
color: var(--nav-muted);
font-size: 0.85rem;
font-weight: 500;
flex-shrink: 0;
}
.navbar__link {
color: inherit;
text-decoration: none;
transition: color 0.2s ease;
padding: 0.35rem 0.1rem;
white-space: nowrap;
}
.navbar__link:hover,
.navbar__link[aria-current="page"] {
color: var(--nav-white);
}
/* ---------- "Startseite"-Button mit Haus-Icon, ganz rechts ---------- */
.navbar__link--home {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 18px 6px 10px;
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 999px;
color: var(--nav-light);
transition:
border-color 0.2s ease,
color 0.2s ease;
}
.navbar__link--home:hover {
border-color: rgba(16, 185, 129, 0.55);
color: var(--nav-white);
}
.navbar__home-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: #22d3ee;
}
.navbar__home-icon svg {
width: 18px;
height: 18px;
}
/* Linienstärke des Haus-Icons zentral hier steuern statt im SVG-Markup */
.navbar__home-icon svg path {
stroke-width: 1.5;
}
.navbar a:focus-visible,
.navbar button:focus-visible {
outline: 3px solid var(--nav-green);
outline-offset: 2px;
border-radius: 4px;
}
/* ---------- Mobiler Hamburger-Button ---------- */
.navbar__toggle {
display: none;
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
}
.navbar__toggle-icon,
.navbar__toggle-icon::before,
.navbar__toggle-icon::after {
display: block;
width: 22px;
height: 2px;
background: var(--nav-white);
position: relative;
}
.navbar__toggle-icon::before,
.navbar__toggle-icon::after {
content: "";
position: absolute;
}
.navbar__toggle-icon::before {
top: -7px;
}
.navbar__toggle-icon::after {
top: 7px;
}
@media (max-width: 1300px) {
.navbar__toggle {
display: block;
}
.navbar__nav {
position: absolute;
top: 76px;
left: 0;
right: 0;
background: var(--nav-bg);
border-bottom: 1px solid var(--nav-border);
display: none;
}
.navbar__nav.is-open {
display: block;
}
.navbar__menu {
flex-direction: column;
align-items: flex-start;
padding: 1rem 20px;
gap: 1rem;
font-size: 0.95rem;
}
}
@media (max-width: 600px) {
.navbar__inner {
padding: 0 16px;
}
.navbar__brand-tagline {
display: none;
}
}
/* Platz für die fixierte Navbar */
body {
padding-top: 76px;
}

View File

@ -1,279 +0,0 @@
.packages-page {
margin: 0;
min-height: 100vh;
color: #f8fafc;
background:
radial-gradient(circle at 12% 0%, #10b98117, transparent 30%),
radial-gradient(circle at 88% 26%, #10b9810e, transparent 27%),
linear-gradient(145deg, #0d0d1a, #1a1a2e);
font-family: Inter, system-ui, sans-serif;
line-height: 1.6;
color-scheme: dark;
}
.packages-section,
.packages-section * {
box-sizing: border-box;
}
.packages-section {
width: min(1180px, calc(100% - 3rem));
margin: auto;
padding-block: 5rem;
overflow-wrap: anywhere;
}
.packages-header {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: 3rem;
align-items: end;
margin-bottom: 3rem;
}
.packages-eyebrow,
.package-type {
color: #10b981;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.packages-header h1,
.detail-heading h1 {
margin: 0.75rem 0 1rem;
font-size: clamp(2rem, 4.5vw, 3.75rem);
font-weight: 600;
line-height: 1.15;
letter-spacing: -0.035em;
}
.packages-intro,
.package-desc,
.detail-text,
.detail-list,
.price-note,
.booking-notice {
color: #94a3b8;
}
.packages-intro {
margin: 0;
max-width: 40rem;
}
.packages-container {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1.5rem;
}
.package-card,
.package-checkout {
min-width: 0;
padding: 1.75rem;
border: 1px solid #f8fafc1f;
border-radius: 1.4rem;
background: #ffffff0b;
}
.package-card {
display: flex;
flex-direction: column;
}
.package-type-container {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.package-highlight {
border-radius: 999px;
padding: 0.25rem 0.65rem;
background: #10b98114;
color: #34d399;
border: 1px solid #10b98133;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.05em;
}
.package-title {
margin: 0 0 1rem;
color: #fff;
font-size: 1.55rem;
font-weight: 600;
line-height: 1.25;
letter-spacing: -0.025em;
}
.package-desc {
margin: 0 0 1.5rem;
}
.package-details-box {
padding-block: 1.5rem;
border-top: 1px solid #f8fafc1f;
flex: 1;
}
.detail-item + .detail-item {
margin-top: 1.25rem;
}
.detail-title {
margin: 0 0 0.5rem;
color: #f8fafc;
font-size: 1rem;
font-weight: 600;
}
.detail-text {
font-size: 0.9rem;
}
.package-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
min-height: 3rem;
padding: 0.8rem 1.1rem;
border: 1px solid #94a3b840;
border-radius: 999px;
background: #ffffff06;
color: #f8fafc;
font: inherit;
font-size: 0.9rem;
font-weight: 600;
text-align: center;
text-decoration: none;
cursor: pointer;
}
.package-btn:hover {
border-color: #10b981;
background: #10b98114;
}
.package-btn.primary-cta {
background: #10b981;
border-color: #10b981;
color: #06130f;
}
.package-btn.primary-cta:hover {
background: #34d399;
}
.package-btn:disabled {
opacity: 0.65;
cursor: wait;
}
.packages-section :focus-visible {
outline: 3px solid #10b981;
outline-offset: 4px;
}
.packages-back {
display: inline-block;
margin-bottom: 2rem;
padding-block: 0.5rem;
color: #34d399;
text-decoration: none;
}
.detail-heading {
max-width: 52rem;
margin-bottom: 3rem;
}
.detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
gap: 2.5rem;
align-items: start;
}
.detail-content > section + section {
margin-top: 2.5rem;
}
.detail-content h2,
.package-checkout h2 {
margin: 0 0 1rem;
font-size: 1.25rem;
}
.detail-list {
padding-left: 1.25rem;
}
.detail-list li {
margin-block: 0.65rem;
}
.detail-list li::marker {
color: #10b981;
}
.process-step {
margin-top: 1rem;
padding: 1.25rem;
border: 1px solid #f8fafc1f;
border-radius: 1rem;
background: #ffffff06;
}
.process-step strong {
display: block;
margin-bottom: 0.4rem;
}
.process-step p {
margin: 0;
}
.price-box {
margin-bottom: 1.5rem;
}
.price-amount {
display: block;
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 600;
letter-spacing: -0.04em;
color: #34d399;
}
.price-note,
.booking-notice {
font-size: 0.85rem;
}
.detail-box {
margin-bottom: 1.5rem;
}
.detail-box label {
display: block;
margin-bottom: 0.5rem;
}
.variant-select {
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 3rem;
padding: 0.65rem;
border: 1px solid #94a3b866;
border-radius: 0.75rem;
color: #f8fafc;
background: #0d0d1a;
font: inherit;
}
/* Die Detailseite zeigt ihre eigene Übersicht; Booking liefert den Dialog. */
.packages-section #booking-root > .booking > h2,
.packages-section #booking-root .booking__package,
.packages-section #booking-root .booking__payment,
.packages-section #booking-root .booking__open {
display: none;
}
.packages-section .booking__dialog {
background: #0d0d1a;
color: #f8fafc;
border-color: #94a3b840;
}
.packages-section .booking__dialog .booking__button:not(.booking__cancel) {
background: #10b981;
border-color: #10b981;
color: #06130f;
}
.packages-section .booking__dialog :focus-visible {
outline-color: #10b981;
}
@media (max-width: 60rem) {
.packages-container {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
}
.packages-header,
.detail-grid {
grid-template-columns: minmax(0, 1fr);
gap: 1.5rem;
}
}
@media (max-width: 30rem) {
.packages-section {
width: calc(100% - 2rem);
padding-block: 2.5rem;
}
.package-card,
.package-checkout {
padding: 1.25rem;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,29 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createApp } from "../server/index.js";
import { packages } from "../shared/packages.js";
test("API liefert für jedes angezeigte Angebot dieselbe ID, Namen und Beschreibung", async (t) => {
const app = createApp({ sendConfirmation: async () => {} });
await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve));
t.after(
() =>
new Promise((resolve) => {
app.closeAllConnections();
app.close(resolve);
}),
);
const response = await fetch(
`http://127.0.0.1:${app.address().port}/api/angebote`,
);
assert.equal(response.status, 200);
const body = await response.json();
assert.deepEqual(
body.packages,
packages.map(({ id, name, summary }) => ({
id,
name,
summary,
})),
);
});

View File

@ -8,10 +8,10 @@ import { createApp } from "../server/index.js";
import { createCsvStorage } from "../server/services/csv-storage.js"; import { createCsvStorage } from "../server/services/csv-storage.js";
import { createBookingService } from "../server/services/booking-service.js"; import { createBookingService } from "../server/services/booking-service.js";
import { createEmailService } from "../server/services/email-service.js"; import { createEmailService } from "../server/services/email-service.js";
import { packages } from "../shared/packages.js"; import { demoPackages } from "../shared/packages.js";
const input = { const input = {
packageId: packages[0].id, packageId: demoPackages[0].id,
name: 'Test, "Person"', name: 'Test, "Person"',
email: "person@example.test", email: "person@example.test",
}; };
@ -27,7 +27,7 @@ async function setup(t, options = {}) {
sent.push({ ...booking }); sent.push({ ...booking });
}); });
const dependencies = { const dependencies = {
catalog: packages, catalog: demoPackages,
storage, storage,
sendConfirmation, sendConfirmation,
...options, ...options,
@ -63,7 +63,7 @@ test("Buchung steht vor Versand in CSV; Paketname kommt vom Server", async (t) =
}); });
assert.equal(result.status, 201); assert.equal(result.status, 201);
assert.equal(result.body.emailStatus, "accepted"); assert.equal(result.body.emailStatus, "accepted");
assert.equal(result.body.packageName, packages[0].name); assert.equal(result.body.packageName, demoPackages[0].name);
assert.equal(result.body.bookingId, "TH-000001"); assert.equal(result.body.bookingId, "TH-000001");
assert.ok(!Number.isNaN(Date.parse(result.body.createdAt))); assert.ok(!Number.isNaN(Date.parse(result.body.createdAt)));
const records = await fixture.storage.readAll(); const records = await fixture.storage.readAll();
@ -230,7 +230,7 @@ test("Ausstehender Versand kann nach Schreibfehler sicher fortgesetzt werden", a
let writes = 0; let writes = 0;
let sends = 0; let sends = 0;
const service = createBookingService({ const service = createBookingService({
packages: packages, packages: demoPackages,
storage: { storage: {
readAll: () => storage.readAll(), readAll: () => storage.readAll(),
writeAll: async (rows) => { writeAll: async (rows) => {
@ -257,7 +257,7 @@ test("Status-Schreibfehler nach SMTP bleibt unklar und löst keinen Doppelversan
let writes = 0; let writes = 0;
let sends = 0; let sends = 0;
const service = createBookingService({ const service = createBookingService({
packages: packages, packages: demoPackages,
storage: { storage: {
readAll: () => storage.readAll(), readAll: () => storage.readAll(),
writeAll: async (rows) => { writeAll: async (rows) => {
@ -274,7 +274,7 @@ test("Status-Schreibfehler nach SMTP bleibt unklar und löst keinen Doppelversan
assert.equal((await storage.readAll())[0].emailStatus, "sending"); assert.equal((await storage.readAll())[0].emailStatus, "sending");
const restarted = createBookingService({ const restarted = createBookingService({
storage, storage,
packages: packages, packages: demoPackages,
sendConfirmation: async () => { sendConfirmation: async () => {
sends++; sends++;
}, },
@ -297,10 +297,10 @@ test("SMTP-Nachricht enthält Buchungsnummer und Paket; externe Hosts sind stand
await send({ await send({
...input, ...input,
bookingId: "TH-000001", bookingId: "TH-000001",
packageName: packages[0].name, packageName: demoPackages[0].name,
}); });
assert.ok(message.text.includes("TH-000001")); assert.ok(message.text.includes("TH-000001"));
assert.ok(message.text.includes(packages[0].name)); assert.ok(message.text.includes(demoPackages[0].name));
assert.equal(message.to.address, input.email); assert.equal(message.to.address, input.email);
assert.ok(message.text.includes("Zahlungsvorgang wird übersprungen")); assert.ok(message.text.includes("Zahlungsvorgang wird übersprungen"));
assert.ok(message.text.includes("keine Abbuchung")); assert.ok(message.text.includes("keine Abbuchung"));
@ -368,19 +368,16 @@ test("HTTP-Vertrag: JSON, Größenlimit, Methoden und private Dateien", async (t
413, 413,
); );
const catalog = await (await fetch(`${url}/api/angebote`)).json(); const catalog = await (await fetch(`${url}/api/angebote`)).json();
assert.deepEqual( assert.deepEqual(catalog.packages, demoPackages);
catalog.packages, assert.equal((await fetch(`${url}/api/packages`)).status, 404);
packages.map(({ id, name, summary }) => ({ id, name, summary })),
);
assert.equal((await fetch(`${url}/api/packages`)).status, 200);
}); });
test("Paketvertrag lehnt fehlende, numerische und doppelte IDs ab", () => { test("Paketvertrag lehnt fehlende, numerische und doppelte IDs ab", () => {
for (const catalog of [ for (const catalog of [
[null], [null],
[{ ...packages[0], id: 123 }], [{ ...demoPackages[0], id: 123 }],
[{ ...packages[0], id: undefined }], [{ ...demoPackages[0], id: undefined }],
[packages[0], packages[0]], [demoPackages[0], demoPackages[0]],
]) { ]) {
assert.throws( assert.throws(
() => createApp({ catalog, sendConfirmation: async () => {} }), () => createApp({ catalog, sendConfirmation: async () => {} }),
@ -396,7 +393,7 @@ test("Fortlaufende Nummern bleiben nach Neustart erhalten und Wiederholungen ver
assert.equal((await post(input, key)).body.bookingId, "TH-000001"); assert.equal((await post(input, key)).body.bookingId, "TH-000001");
const restarted = createBookingService({ const restarted = createBookingService({
storage, storage,
packages: packages, packages: demoPackages,
sendConfirmation: async () => {}, sendConfirmation: async () => {},
}); });
assert.equal( assert.equal(
@ -428,235 +425,3 @@ test("Nummernvergabe verwendet den höchsten CSV-Wert und wächst über sechs St
await storage.writeAll(records); await storage.writeAll(records);
assert.equal((await post()).body.bookingId, "TH-1000000"); assert.equal((await post()).body.bookingId, "TH-1000000");
}); });
test("Detail-API und Vorschau liefern die gewählte Variante mit centgenauen Preisen", async (t) => {
const { url, storage, sent } = await setup(t, { catalog: packages });
const details = await (
await fetch(`${url}/api/packages/ernaehrung-premium`)
).json();
assert.equal(details.variants.length, 2);
assert.ok(details.includedFeatures.length > 0);
assert.equal((await fetch(`${url}/api/packages/unbekannt`)).status, 404);
const response = await fetch(`${url}/api/bookings/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
packageId: details.id,
variantId: "ernaehrung-premium-52",
price: 1,
}),
});
assert.equal(response.status, 200);
const preview = await response.json();
assert.equal(preview.durationWeeks, 52);
assert.equal(preview.grossPrice, 1399);
assert.equal(preview.netPrice, 1175.63);
assert.equal(preview.taxAmount, 223.37);
assert.equal(preview.taxRate, 19);
assert.equal(preview.currency, "EUR");
assert.deepEqual(preview.includedFeatures, details.includedFeatures);
assert.deepEqual(await storage.readAll(), []);
assert.equal(sent.length, 0);
});
test("Bestelldaten bleiben nach Katalogänderung und Neustart unverändert", async (t) => {
const fixture = await setup(t, { catalog: packages });
const key = randomUUID();
const data = {
...input,
packageId: "ernaehrung-premium",
variantId: "ernaehrung-premium-52",
price: 1,
includedFeatures: ["Manipuliert"],
};
const result = await fixture.post(data, key);
assert.equal(result.status, 201);
const order = result.body.bookedPackage;
assert.equal(order.grossPrice, 1399);
assert.equal(order.durationWeeks, 52);
assert.equal(order.variantId, data.variantId);
assert.ok(!order.includedFeatures.includes("Manipuliert"));
assert.deepEqual((await fixture.storage.readAll())[0].bookedPackage, order);
assert.deepEqual(fixture.sent[0].bookedPackage, order);
const restarted = createBookingService({
storage: fixture.storage,
packages: [],
details: [],
sendConfirmation: async () => assert.fail("Doppelversand"),
});
assert.deepEqual(
(await restarted.book(data, key)).body.bookedPackage,
order,
);
assert.equal(
(
await fixture.post(
{ ...data, variantId: "ernaehrung-premium-24" },
key,
)
).status,
409,
);
});
test("Unbekannte und paketfremde Varianten werden vor dem Speichern abgelehnt", async (t) => {
const { post, storage, sent, url } = await setup(t, { catalog: packages });
for (const data of [
{ packageId: "ernaehrung-premium", variantId: "unbekannt" },
{ packageId: "ernaehrung-starter", variantId: "ernaehrung-premium-52" },
{ packageId: "ernaehrung-premium", variantId: 52 },
]) {
assert.equal((await post({ ...input, ...data })).status, 400);
assert.equal(
(
await fetch(`${url}/api/bookings/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
).status,
400,
);
}
assert.deepEqual(await storage.readAll(), []);
assert.equal(sent.length, 0);
});
test("Bestehende CSV ohne Bestelldaten bleibt lesbar und wird verlustfrei erweitert", async (t) => {
const { post, file, storage } = await setup(t);
const key = randomUUID();
await post(input, key);
const [record] = await storage.readAll();
await writeFile(
file,
`bookingId,createdAt,packageId,packageName,name,email,emailStatus,idempotencyKey,requestHash\nTH-000001,${record.createdAt},ernaehrung-starter,Altes Paket,Alte Person,person@example.test,accepted,alter-key,alter-hash\n`,
);
assert.equal((await post()).status, 201);
const records = await storage.readAll();
assert.equal(records.length, 2);
assert.equal(records[0].packageName, "Altes Paket");
assert.equal(records[0].bookedPackage, null);
assert.equal(records[1].bookedPackage.packageId, "ernaehrung-starter");
});
test("Bestätigungsmail enthält gespeicherte Leistungen, Variante, Preise und maskiertes HTML", async (t) => {
const { post } = await setup(t, { catalog: packages });
const result = await post({
...input,
packageId: "ernaehrung-premium",
variantId: "ernaehrung-premium-52",
});
let message;
const send = createEmailService({}, () => ({
sendMail: async (mail) => {
message = mail;
return { accepted: [input.email] };
},
}));
await send({ ...input, name: "<img src=x>", ...result.body });
assert.match(message.subject, /Bestellbestätigung TH-/);
for (const text of [
"52-Wochen-Variante",
"52 Wochen",
"1.399,00",
"1.175,63",
"223,37",
"19 %",
"einmalig",
"Bestelldatum:",
...result.body.bookedPackage.includedFeatures,
]) {
assert.ok(message.text.includes(text), text);
}
assert.ok(message.html.includes("&lt;img src=x&gt;"));
assert.ok(!message.html.includes("<img src=x>"));
});
test("Swagger-Kundendaten und Zustimmung werden validiert und dauerhaft gespeichert", async (t) => {
const { post, storage, sent } = await setup(t, { catalog: packages });
const customer = {
firstName: "Thomas",
lastName: "Müller",
email: "thomas@example.test",
phone: "+49 170 1234567",
ageGroup: "50-59",
notes: "Vorbereitung auf die Mitteldistanz.",
};
const request = {
packageId: "ernaehrung-standard",
customer,
acceptedTerms: true,
};
for (const body of [
{ ...request, acceptedTerms: false },
{ ...request, acceptedTerms: "true" },
{ ...request, customer: [] },
{ ...request, customer: { ...customer, firstName: "" } },
{ ...request, customer: { ...customer, email: "ungültig" } },
{ ...request, customer: { ...customer, phone: 123 } },
])
assert.equal((await post(body)).status, 400);
const key = randomUUID();
const result = await post(request, key);
assert.equal(result.status, 201);
assert.equal(result.body.customerEmail, customer.email);
const [record] = await storage.readAll();
assert.equal(record.name, "Thomas Müller");
assert.deepEqual(record.customer, customer);
assert.equal(record.acceptedTerms, true);
assert.deepEqual(sent[0].customer, customer);
assert.equal((await post(request, key)).status, 200);
assert.equal(
(
await post(
{
...request,
customer: { ...customer, notes: "Anderer Auftrag" },
},
key,
)
).status,
409,
);
});
test("Telefonnummern werden in beiden Anfrageformaten geprüft", async (t) => {
const { post, sent } = await setup(t);
for (const phone of [
"123",
"00000000000",
"abc",
"+999 123456789",
"0170 test 1234567",
]) {
for (const data of [
{ ...input, phone },
{
packageId: input.packageId,
acceptedTerms: true,
customer: {
firstName: "Test",
lastName: "Person",
email: input.email,
phone,
},
},
]) {
const result = await post(data);
assert.equal(result.status, 400, phone);
assert.ok(result.body.error.fields.phone);
}
}
assert.equal(sent.length, 0);
for (const phone of [
"",
"0170 1234567",
"+49 170 1234567",
"0049 170 1234567",
"030 123456",
"+44 20 7946 0958",
]) {
const result = await post({ ...input, phone });
assert.equal(result.status, 201, phone);
}
});

View File

@ -1,9 +1,6 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { packages } from "../../shared/packages.js"; const pageUrl = "/booking.html#/buchen/demo-booking";
import { packagesDetails } from "../../shared/packagesdetails.js";
const pageUrl = "/booking.html#/buchen/ernaehrung-starter";
async function fill(page) { async function fill(page) {
await page await page
.getByLabel("Name (Pflichtfeld)", { exact: true }) .getByLabel("Name (Pflichtfeld)", { exact: true })
@ -18,7 +15,7 @@ test("Tastaturbedienung, Validierung und vollständige Buchung über Vite-Proxy"
}) => { }) => {
await page.goto(pageUrl); await page.goto(pageUrl);
await expect( await expect(
page.getByRole("heading", { name: packagesDetails[0].title }), page.getByRole("heading", { name: "1:1-Ernährungsberatung" }),
).toBeVisible(); ).toBeVisible();
await expect(page.locator(".booking__payment-notice")).toContainText( await expect(page.locator(".booking__payment-notice")).toContainText(
"Zahlungsvorgang wird übersprungen", "Zahlungsvorgang wird übersprungen",
@ -182,113 +179,3 @@ test("Vite liefert Serverdateien und Umgebungsdateien nicht aus", async ({
expect([403, 404]).toContain(response.status()); expect([403, 404]).toContain(response.status());
} }
}); });
for (const pkg of packages) {
test(`Von der Startseite zur Buchung: ${pkg.type}`, async ({ page }) => {
await page.goto("/");
await page.getByRole("link", { name: "Beratung kennenlernen" }).click();
await expect(page.locator(".package-card")).toHaveCount(
packages.length,
);
await page.getByRole("link", { name: pkg.buttonText }).click();
await expect(page).toHaveURL(
new RegExp(`paket-details\\.html\\?id=${pkg.id}$`),
);
await page.locator("#proceed-to-booking-btn").click();
await expect(page).toHaveURL(
new RegExp(`booking\\.html\\?id=${pkg.id}`),
);
const details = packagesDetails.find((entry) => entry.id === pkg.id);
await expect(
page.getByRole("heading", { name: details.title, exact: true }),
).toBeVisible();
await expect(page.locator(".booking__summary")).toHaveText(
details.summaryForBooking,
);
await expect(page.locator(".booking__features li")).toHaveCount(
details.includedFeatures.length,
);
await fill(page);
const responsePromise = page.waitForResponse(
(response) =>
response.url().endsWith("/api/bookings") &&
response.request().method() === "POST",
);
await page
.getByRole("button", { name: "Paket buchen", exact: true })
.click();
const response = await responsePromise;
expect(response.status()).toBe(201);
const booking = await response.json();
expect(booking.packageId).toBe(pkg.id);
expect(booking.packageName).toBe(pkg.name);
await expect(page.locator(".booking__result")).toContainText(
booking.bookingId,
);
await page.getByRole("link", { name: "Zur Angebotsseite" }).click();
await expect(page.locator(".package-card")).toHaveCount(
packages.length,
);
});
}
test("Angebotsübersicht passt auf ein schmales Display", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 640 });
await page.goto("/angebotsuebersicht.html");
await expect(page.locator(".package-card")).toHaveCount(packages.length);
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
).toBe(true);
});
test("Premium-Jahresvariante wird von der Detailseite bis zur Bestätigung übernommen", async ({
page,
}) => {
await page.goto("/paket-details.html?id=ernaehrung-premium");
await page.locator("#variant-select").selectOption("ernaehrung-premium-52");
await page.locator("#proceed-to-booking-btn").click();
await expect(page).toHaveURL(
/booking\.html\?id=ernaehrung-premium&variant=ernaehrung-premium-52$/,
);
await expect(page.locator(".booking__totals")).toContainText(
"52 Wochen Begleitung",
);
await expect(page.locator(".booking__totals")).toContainText("1.399,00");
await page.reload();
await fill(page);
const responsePromise = page.waitForResponse(
(response) =>
response.url().endsWith("/api/bookings") &&
response.request().method() === "POST",
);
await page
.getByRole("button", { name: "Paket buchen", exact: true })
.click();
const response = await responsePromise;
expect(response.request().postDataJSON().variantId).toBe(
"ernaehrung-premium-52",
);
const booking = await response.json();
expect(booking.bookedPackage.grossPrice).toBe(1399);
expect(booking.bookedPackage.durationWeeks).toBe(52);
await expect(page.locator(".booking__result")).toContainText(
booking.bookingId,
);
await page.reload();
await expect(page.locator(".booking__totals")).toContainText("1.399,00");
await expect(page.locator(".booking__result")).toContainText(
booking.bookingId,
);
});
test("Paketfremde Variante zeigt kein Buchungsformular", async ({ page }) => {
await page.goto(
"/booking.html?id=ernaehrung-starter&variant=ernaehrung-premium-52",
);
await expect(
page.getByText("Diese Variante gehört nicht zum ausgewählten Paket."),
).toBeVisible();
await expect(page.locator("form")).toHaveCount(0);
});

View File

@ -4,11 +4,11 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createApp } from "../../server/index.js"; import { createApp } from "../../server/index.js";
import { createCsvStorage } from "../../server/services/csv-storage.js"; import { createCsvStorage } from "../../server/services/csv-storage.js";
import { packages } from "../../shared/packages.js"; import { demoPackages } from "../../shared/packages.js";
const directory = await mkdtemp(join(tmpdir(), "trihub-browser-")); const directory = await mkdtemp(join(tmpdir(), "trihub-browser-"));
const server = createApp({ const server = createApp({
catalog: packages, catalog: demoPackages,
storage: createCsvStorage(join(directory, "bookings.csv")), storage: createCsvStorage(join(directory, "bookings.csv")),
sendConfirmation: async () => {}, sendConfirmation: async () => {},
}); });

View File

@ -1,28 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { packages } from "../shared/packages.js";
import { createApp } from "../server/index.js";
test("Angebote erfüllen den gemeinsamen Vertrag von Übersicht und Buchung", () => {
assert.ok(packages.length > 0);
assert.doesNotThrow(() =>
createApp({
catalog: packages,
sendConfirmation: async () => {},
}),
);
for (const pkg of packages) {
for (const field of [
"name",
"summary",
"type",
"fokus",
"anamnese",
"begleitung",
"buttonText",
]) {
assert.equal(typeof pkg[field], "string");
assert.ok(pkg[field].trim());
}
}
});

8928
styles.css

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +0,0 @@
{
"status": "passed",
"failedTests": []
}

View File

@ -19,16 +19,7 @@ export default defineConfig({
build: { build: {
rollupOptions: { rollupOptions: {
input: { input: {
angebote: fileURLToPath(
new URL("./angebotsuebersicht.html", import.meta.url),
),
details: fileURLToPath(
new URL("./paket-details.html", import.meta.url),
),
main: fileURLToPath(new URL("./index.html", import.meta.url)), main: fileURLToPath(new URL("./index.html", import.meta.url)),
bookingConfirmation: fileURLToPath(
new URL("./booking-confirmation.html", import.meta.url),
),
booking: fileURLToPath( booking: fileURLToPath(
new URL("./booking.html", import.meta.url), new URL("./booking.html", import.meta.url),
), ),