From 6bf2e170151cf609b380a810ed881675042523b1 Mon Sep 17 00:00:00 2001
From: Marvin Gemlin
Date: Thu, 24 Sep 2026 16:11:54 +0000
Subject: [PATCH 01/13] feat: Paketbuchung mit CSV, SMTP und fortlaufenden
Buchungsnummern
---
.env.example | 12 +
.gitignore | 6 +
.prettierignore | 4 +
booking.html | 16 +
docs/booking.md | 353 ++++++++++++++++++++
package-lock.json | 72 +++++
package.json | 12 +-
playwright.config.js | 20 ++
server/routes/.gitkeep | 0
server/services/.gitkeep | 0
src/features/booking/booking-api.js | 49 +++
src/features/booking/booking-entry.js | 13 +
src/features/booking/booking-page.js | 276 ++++++++++++++++
src/features/booking/booking.css | 96 ++++++
src/main.js | 2 +-
{server => src/server}/data/.gitkeep | 0
src/server/index.js | 87 +++++
src/server/routes/bookings.js | 75 +++++
src/server/services/booking-service.js | 203 ++++++++++++
src/server/services/csv-storage.js | 88 +++++
src/server/services/email-service.js | 74 +++++
src/shared/booking-copy.js | 4 +
src/shared/packages.js | 14 +
src/tests/bookings.test.js | 427 +++++++++++++++++++++++++
src/tests/browser/booking.spec.js | 181 +++++++++++
src/tests/helpers/browser-server.js | 23 ++
vite.config.js | 29 ++
27 files changed, 2134 insertions(+), 2 deletions(-)
create mode 100644 .env.example
create mode 100644 booking.html
create mode 100644 docs/booking.md
create mode 100644 playwright.config.js
delete mode 100644 server/routes/.gitkeep
delete mode 100644 server/services/.gitkeep
create mode 100644 src/features/booking/booking-api.js
create mode 100644 src/features/booking/booking-entry.js
create mode 100644 src/features/booking/booking-page.js
create mode 100644 src/features/booking/booking.css
rename {server => src/server}/data/.gitkeep (100%)
create mode 100644 src/server/index.js
create mode 100644 src/server/routes/bookings.js
create mode 100644 src/server/services/booking-service.js
create mode 100644 src/server/services/csv-storage.js
create mode 100644 src/server/services/email-service.js
create mode 100644 src/shared/booking-copy.js
create mode 100644 src/shared/packages.js
create mode 100644 src/tests/bookings.test.js
create mode 100644 src/tests/browser/booking.spec.js
create mode 100644 src/tests/helpers/browser-server.js
create mode 100644 vite.config.js
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..2968da5
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,12 @@
+# Lokal nur einen Mailfänger verwenden. Keine echten Zugangsdaten eintragen.
+HOST=127.0.0.1
+PORT=3000
+BOOKING_DEMO=true
+SMTP_HOST=127.0.0.1
+SMTP_PORT=1025
+SMTP_SECURE=false
+SMTP_USER=
+SMTP_PASS=
+SMTP_FROM="Tri-Hub "
+# Für echtes SMTP erst nach ausdrücklicher Freigabe ändern.
+SMTP_ALLOW_EXTERNAL=false
diff --git a/.gitignore b/.gitignore
index cc9ffb7..e872025 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,9 @@ dist/
.DS_Store
Thumbs.db
*.log
+
+# Private Buchungsdaten und lokale Testergebnisse
+src/server/data/*
+!src/server/data/.gitkeep
+playwright-report/
+test-results/
diff --git a/.prettierignore b/.prettierignore
index 320c107..790904b 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,3 +1,7 @@
node_modules/
dist/
package-lock.json
+
+src/server/data/
+playwright-report/
+test-results/
diff --git a/booking.html b/booking.html
new file mode 100644
index 0000000..3a92cba
--- /dev/null
+++ b/booking.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ Paket buchen – Tri-Hub
+
+
+
+
+
+
diff --git a/docs/booking.md b/docs/booking.md
new file mode 100644
index 0000000..5f0541d
--- /dev/null
+++ b/docs/booking.md
@@ -0,0 +1,353 @@
+# US2.4 – Paket buchen
+
+## Stand und Zusammenarbeit
+
+Entwicklung auf `feature/paket-buchen-backend`, späteres PR-Ziel: `dev`.
+Zu Beginn waren HEAD und frisch abgerufenes `origin/dev` identisch. Kein Merge,
+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.
+
+## So funktioniert der Ablauf
+
+1. Der Browser lädt die verfügbaren Pakete über `GET /api/angebote`.
+2. Das Formular sendet Paket-ID, Name und E-Mail an `POST /api/bookings`.
+ Es merkt sich dafür einen zufälligen Anfrageschlüssel (Idempotency-Key).
+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.
+5. Erst danach übergibt er die Bestätigung an SMTP und aktualisiert den Versandstatus.
+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
+
+Node.js 24 ist im vorhandenen Dev Container vorgesehen. Alle Befehle laufen im
+Projektverzeichnis. Vite und Backend benötigen jeweils ein eigenes Terminal.
+
+```bash
+npm ci
+cp .env.example .env
+```
+
+Eine vorhandene `.env` vorher prüfen und nicht überschreiben. Die Beispielwerte
+aktivieren das klar gekennzeichnete Testpaket und verwenden nur einen Mailfänger.
+Ohne `.env` bleibt der Demo-Modus ausgeschaltet und der echte Paketkatalog leer.
+
+Einmalig [Mailpit installieren](https://mailpit.axllent.org/docs/install/).
+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:
+
+```bash
+mailpit --listen 127.0.0.1:8025 --smtp 127.0.0.1:1025 --disable-version-check
+```
+
+In Terminal 2 das Backend starten:
+
+```bash
+npm run dev:server
+```
+
+In Terminal 3 das Frontend starten:
+
+```bash
+npm run dev
+```
+
+Öffnen: .
+Mailfänger: . 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
+
+Alle SMTP-Einstellungen werden ausschließlich in Node geladen. Niemals mit einem
+`VITE_`-Präfix versehen: Solche Variablen könnten im Browser landen.
+
+| Variable | Lokaler Beispielwert | Bedeutung |
+| ------------------------- | -------------------------------- | --------------------------------------------------------------------- |
+| `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 ` | Absender; später abgestimmten echten Absender verwenden |
+| `SMTP_ALLOW_EXTERNAL` | `false` | Externes SMTP gesperrt; erst nach ausdrücklicher Freigabe aktivieren |
+
+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).
+
+## Paketdaten und spätere Router-Anbindung
+
+Person 3 pflegt `packages` in **`src/shared/packages.js`**.
+Das derzeit leere Array enthält später ausschließlich abgestimmte Angebote:
+
+```js
+// Nur Schema-Beispiel, kein verbindliches Angebot:
+{ id: "stabile-paket-id", name: "Abgestimmter Paketname", summary: "Kurze Beschreibung" }
+```
+
+IDs: eindeutig, maximal 80 Zeichen, Kleinbuchstaben/Ziffern mit einzelnen
+Bindestrichen. `name` und `summary` sind nicht leere Strings. IDs nach Freigabe
+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 Angebotsseite kann vorerst auf die eigenständige Ansicht verlinken:
+
+```js
+link.href = `/booking.html#/buchen/${encodeURIComponent(paket.id)}`;
+```
+
+Es wurde kein Router in die gemeinsame Startseite eingebaut. Wenn Person 2 den
+Router ergänzt, kann sie bei `/#/buchen/:packageId` diese Funktion aufrufen:
+
+```js
+import { mountBookingPage } from "./features/booking/booking-page.js";
+const cleanup = mountBookingPage(container, { packageId });
+// Beim Verlassen der Route:
+cleanup();
+```
+
+Das Modul bringt seine begrenzten `.booking`-Styles mit. Farben und Schrift
+orientieren sich am vorhandenen Basis-CSS. Gemeinsame Designvariablen und eine
+Button-Komponente gibt es bisher nicht. Layout und Navigation bleiben Aufgabe
+der anderen Teammitglieder. „Zur Angebotsseite“ zeigt vorerst auf `/#beratung`.
+
+## API-Beispiele
+
+`GET /api/angebote` liefert `{ "packages": [...] }` mit `id`, `name`, `summary`
+und `testOnly`. Keine personenbezogenen Daten oder SMTP-Einstellungen.
+
+Für `POST /api/bookings` sind `Content-Type: application/json` und ein
+`Idempotency-Key` als UUID v4 erforderlich. Ein Schlüssel gehört genau zu einer
+Buchung. Bei Wiederholung denselben Schlüssel und dieselben Angaben verwenden.
+
+```http
+POST /api/bookings
+Content-Type: application/json
+Idempotency-Key: 36c45d7f-585f-47e9-bf5c-7ea1b7a162ed
+
+{"packageId":"demo-booking","name":"Test Person","email":"person@example.test"}
+```
+
+Neue Buchung mit SMTP-Annahme: HTTP `201`; gespeicherte Wiederholung: `200`.
+
+```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
+}
+```
+
+Gespeichert, aber E-Mail nicht bestätigt: HTTP `202` mit derselben Struktur und
+`emailStatus: "failed"`, `"pending"` oder `"unknown"`. Das bedeutet ausdrücklich
+nicht, dass die E-Mail zugestellt wurde. Die Buchungsnummer bleibt gültig.
+
+| Status/Code | Bedeutung und Verhalten |
+| ------------------------------ | ----------------------------------------------------------------------- |
+| `400 INVALID_INPUT` | Angaben korrigieren; Feldhinweise stehen in `error.fields` |
+| `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 |
+
+Die E-Mail-Prüfung unterstützt übliche unquotierte ASCII-Adressen. Internationale
+Domains können in Punycode angegeben werden; SMTPUTF8-Adressen sind nicht Teil
+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).
diff --git a/package-lock.json b/package-lock.json
index d186ecb..7e92738 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,7 +7,13 @@
"": {
"name": "ernaehrungsberatung",
"version": "0.1.0",
+ "dependencies": {
+ "csv-parse": "^7.0.2",
+ "csv-stringify": "^6.8.3",
+ "nodemailer": "^10.0.10"
+ },
"devDependencies": {
+ "@playwright/test": "^1.63.0",
"prettier": "^3.6.2",
"vite": "^7.1.7"
},
@@ -477,6 +483,22 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
+ "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.63.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz",
@@ -873,6 +895,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/csv-parse": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.2.tgz",
+ "integrity": "sha512-uKZghv9UmPkMVLYy//KZ9HFAIJsl7wkhoEdIL0+rhuSY9pZQlhaeGEDPIe+/w7eh81MOql8Q/9+inAGWG6ZHYA==",
+ "license": "MIT"
+ },
+ "node_modules/csv-stringify": {
+ "version": "6.8.3",
+ "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.8.3.tgz",
+ "integrity": "sha512-gIeSCvq5F4VtXV3naV3VAewLhBkiZBz+PPhTOA8H3Y8h/ELa+R1ml0GZck/4/Nzo9ep2lvOluilJ6MJlbZsKMA==",
+ "license": "MIT"
+ },
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -967,6 +1001,15 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/nodemailer": {
+ "version": "10.0.10",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.10.tgz",
+ "integrity": "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -987,6 +1030,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
diff --git a/package.json b/package.json
index f8e5685..2af1f0a 100644
--- a/package.json
+++ b/package.json
@@ -11,10 +11,20 @@
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 5173 --strictPort",
"format": "prettier --write .",
- "format:check": "prettier --check ."
+ "format:check": "prettier --check .",
+ "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",
+ "test": "node --test src/tests/bookings.test.js",
+ "test:browser": "playwright test"
},
"devDependencies": {
+ "@playwright/test": "^1.63.0",
"prettier": "^3.6.2",
"vite": "^7.1.7"
+ },
+ "dependencies": {
+ "csv-parse": "^7.0.2",
+ "csv-stringify": "^6.8.3",
+ "nodemailer": "^10.0.10"
}
}
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 0000000..892f592
--- /dev/null
+++ b/playwright.config.js
@@ -0,0 +1,20 @@
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./src/tests/browser",
+ workers: 1,
+ use: { baseURL: "http://127.0.0.1:5173", browserName: "chromium" },
+ webServer: [
+ {
+ command: "node src/tests/helpers/browser-server.js",
+ url: "http://127.0.0.1:3000/api/angebote",
+ reuseExistingServer: false,
+ gracefulShutdown: { signal: "SIGTERM", timeout: 3000 },
+ },
+ {
+ command: "npm run dev",
+ url: "http://127.0.0.1:5173/booking.html",
+ reuseExistingServer: false,
+ },
+ ],
+});
diff --git a/server/routes/.gitkeep b/server/routes/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/server/services/.gitkeep b/server/services/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/features/booking/booking-api.js b/src/features/booking/booking-api.js
new file mode 100644
index 0000000..65d8613
--- /dev/null
+++ b/src/features/booking/booking-api.js
@@ -0,0 +1,49 @@
+export class BookingApiError extends Error {
+ constructor(message, code, fields = {}) {
+ super(message);
+ this.code = code;
+ this.fields = fields;
+ }
+}
+
+async function request(path, options = {}) {
+ let response;
+ let body;
+ try {
+ response = await fetch(path, {
+ ...options,
+ signal: options.signal ?? AbortSignal.timeout(45000),
+ });
+ body = await response.json();
+ } catch (error) {
+ if (options.signal?.aborted) throw error;
+ throw new BookingApiError(
+ "Die Verbindung ist unterbrochen oder der Server nicht erreichbar. Deine Angaben bleiben erhalten. Bitte den Status mit derselben Anfrage erneut prüfen.",
+ "NETWORK_ERROR",
+ );
+ }
+ if (!response.ok) {
+ throw new BookingApiError(
+ body.error?.message ||
+ "Die Anfrage konnte nicht verarbeitet werden.",
+ body.error?.code,
+ body.error?.fields,
+ );
+ }
+ return body;
+}
+
+export function loadPackages(signal) {
+ return request("/api/angebote", { signal });
+}
+
+export function submitBooking(payload, idempotencyKey) {
+ return request("/api/bookings", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Idempotency-Key": idempotencyKey,
+ },
+ body: JSON.stringify(payload),
+ });
+}
diff --git a/src/features/booking/booking-entry.js b/src/features/booking/booking-entry.js
new file mode 100644
index 0000000..81d6433
--- /dev/null
+++ b/src/features/booking/booking-entry.js
@@ -0,0 +1,13 @@
+import "../../styles/styles.css";
+import { mountBookingPage } from "./booking-page.js";
+
+const root = document.querySelector("#booking-root");
+let cleanup;
+function render() {
+ cleanup?.();
+ // Eigenständige Testseite, bis Person 2 den gemeinsamen Router bereitstellt.
+ const match = location.hash.match(/^#\/buchen\/([a-z0-9-]+)$/);
+ cleanup = mountBookingPage(root, { packageId: match?.[1] });
+}
+window.addEventListener("hashchange", render);
+render();
diff --git a/src/features/booking/booking-page.js b/src/features/booking/booking-page.js
new file mode 100644
index 0000000..e15a656
--- /dev/null
+++ b/src/features/booking/booking-page.js
@@ -0,0 +1,276 @@
+import { loadPackages, submitBooking } from "./booking-api.js";
+import "./booking.css";
+import { paymentNotice } from "../../shared/booking-copy.js";
+
+// Kann später vom gemeinsamen Router aufgerufen werden. cleanup entfernt
+// Listener und bricht das Laden beim Verlassen der Ansicht ab.
+export function mountBookingPage(root, { packageId } = {}) {
+ const controller = new AbortController();
+ const { signal } = controller;
+ root.innerHTML = `
+
+
diff --git a/src/components/angebote/angebotsuebersicht.js b/src/components/angebote/angebotsuebersicht.js
new file mode 100644
index 0000000..01a7e7e
--- /dev/null
+++ b/src/components/angebote/angebotsuebersicht.js
@@ -0,0 +1,58 @@
+import { packages } from "../../shared/packages.js";
+
+export function renderAngebotsuebersicht() {
+ // Wir suchen den Container, der in der angebotsuebersicht.html definiert wurde
+ const container = document.getElementById("packages-container");
+
+ // Sicherheitscheck: Wenn der Container nicht da ist, brechen wir ab
+ if (!container) {
+ console.warn("Container für Angebotsübersicht nicht gefunden.");
+ return;
+ }
+
+ // Container leeren (falls die Funktion mehrfach aufgerufen wird)
+ container.innerHTML = "";
+
+ // Durch jedes Paket iterieren und das HTML generieren
+ packages.forEach((pkg) => {
+ // Highlight-Badge nur anzeigen, wenn es eins gibt
+ const highlightHtml = pkg.highlight
+ ? `${pkg.highlight}`
+ : "";
+
+ const cardHtml = `
+
+ `;
+
+ // Das generierte HTML dem Container hinzufügen
+ container.innerHTML += cardHtml;
+ });
+}
diff --git a/src/shared/packages.js b/src/shared/packages.js
new file mode 100644
index 0000000..76dac1a
--- /dev/null
+++ b/src/shared/packages.js
@@ -0,0 +1,44 @@
+export const packages = [
+ {
+ id: "ernaehrung-starter",
+ type: "STARTER",
+ title: "Für den sicheren Einstieg in die Sporternährung",
+ description:
+ "Für Menschen 50+, die mit Triathlon beginnen und ihre Ernährung an die neue Belastung anpassen möchten.",
+ fokus: "Grundlagen der Makronährstoffe, Hydration im Training und Gewichtsmanagement.",
+ 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",
+ title: "Für Best Ager mit ersten Erfahrungen und klaren Zielen",
+ description:
+ "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",
+ title: "Für maximale Individualität mit klarem Fokus",
+ description:
+ "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,
+ },
+];
diff --git a/src/tests/angebotsuebersicht.test.js b/src/tests/angebotsuebersicht.test.js
new file mode 100644
index 0000000..08dfee6
--- /dev/null
+++ b/src/tests/angebotsuebersicht.test.js
@@ -0,0 +1,36 @@
+export async function renderAngebotsuebersicht() {
+ const container = document.getElementById("packages-container");
+ if (!container) return;
+
+ try {
+ // 1. Daten vom Backend des Kollegen abfragen
+ const response = await fetch("/api/angebote"); // Die genaue URL musst du den Kollegen fragen
+ const backendPackages = await response.json();
+
+ container.innerHTML = "";
+
+ // 2. Mit den echten Daten die Karten generieren
+ backendPackages.forEach((pkg) => {
+ const highlightHtml = pkg.highlight
+ ? `${pkg.highlight}`
+ : "";
+
+ const cardHtml = `
+
+
+
+
+
+
diff --git a/docs/booking.md b/docs/booking.md
index 5f0541d..9dd1d1a 100644
--- a/docs/booking.md
+++ b/docs/booking.md
@@ -33,14 +33,16 @@ cp .env.example .env
Eine vorhandene `.env` vorher prüfen und nicht überschreiben. Die Beispielwerte
aktivieren das klar gekennzeichnete Testpaket und verwenden nur einen Mailfänger.
-Ohne `.env` bleibt der Demo-Modus ausgeschaltet und der echte Paketkatalog leer.
+Ohne `.env` bleibt der Demo-Modus ausgeschaltet; die drei regulären Pakete bleiben verfügbar.
-Einmalig [Mailpit installieren](https://mailpit.axllent.org/docs/install/).
+Mailpit ist über `.devcontainer/Dockerfile` bereits Bestandteil neuer Dev Container.
+Bestehende Container einmal neu bauen. Außerhalb davon einmalig
+[Mailpit installieren](https://mailpit.axllent.org/docs/install/).
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:
```bash
-mailpit --listen 127.0.0.1:8025 --smtp 127.0.0.1:1025 --disable-version-check
+npm run dev:mail
```
In Terminal 2 das Backend starten:
@@ -56,8 +58,8 @@ npm run dev
```
Öffnen: .
-Mailfänger: . Im Dev Container bei Bedarf Port 8025 über
-VS Codes Ansicht „Ports“ weiterleiten. Port 5173 ist bereits vorkonfiguriert.
+Mailfänger: . Im Dev Container sind die Ports 8025 und 5173
+für die Weiterleitung 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.
@@ -92,7 +94,9 @@ Die `.env.example` enthält nur Testwerte und keine Geheimnisse.
## Paketdaten und spätere Router-Anbindung
Person 3 pflegt `packages` in **`src/shared/packages.js`**.
-Das derzeit leere Array enthält später ausschließlich abgestimmte Angebote:
+Das Array enthält die drei Angebote Starter, Standard und Premium. Übersicht und
+Buchung verwenden gemeinsam `id`, `name` und `summary`; zusätzliche Felder
+beschreiben die Darstellung der Angebotskarten.
```js
// Nur Schema-Beispiel, kein verbindliches Angebot:
@@ -127,7 +131,7 @@ cleanup();
Das Modul bringt seine begrenzten `.booking`-Styles mit. Farben und Schrift
orientieren sich am vorhandenen Basis-CSS. Gemeinsame Designvariablen und eine
Button-Komponente gibt es bisher nicht. Layout und Navigation bleiben Aufgabe
-der anderen Teammitglieder. „Zur Angebotsseite“ zeigt vorerst auf `/#beratung`.
+der anderen Teammitglieder. „Zur Angebotsseite“ führt zu `/angebotsuebersicht.html`.
## API-Beispiele
@@ -336,7 +340,7 @@ Binärdatei außerhalb des Repositorys.
## Offene Punkte und gemeinsame Dateien
-Offen: echte Paketdaten, Anschluss an den gemeinsamen Router, abschließendes
+Die Angebotsübersicht ist als eigene HTML-Seite angebunden. Offen: Anschluss an einen 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
@@ -347,7 +351,7 @@ Gemeinsame Änderungen: `package.json` und Lockdatei (Abhängigkeiten/Startbefeh
`.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.
+und `playwright.config.js`. Die Dev-Container-Konfiguration installiert Mailpit und leitet dessen Web-Port weiter.
Technische Referenzen: [CSV-Parser](https://csv.js.org/parse/api/sync/),
[Playwright-Testserver](https://playwright.dev/docs/test-webserver).
diff --git a/index.html b/index.html
index dc976fa..a750b86 100644
--- a/index.html
+++ b/index.html
@@ -20,12 +20,13 @@
Individuelle 1:1-Beratung für deinen Alltag und deine
sportlichen Ziele.
+ `;
+
+ // 5. Preis aktualisieren, falls eine andere Variante (24 vs. 52 Wochen) gewählt wird
+ const variantSelect = document.getElementById("variant-select");
+ const displayedPrice = document.getElementById("displayed-price");
+ if (variantSelect && displayedPrice) {
+ variantSelect.addEventListener("change", (e) => {
+ const selectedOption = e.target.selectedOptions[0];
+ const newPrice = parseFloat(
+ selectedOption.getAttribute("data-price"),
+ );
+ displayedPrice.textContent = `${newPrice.toFixed(2)} €`;
+ });
+ }
+
+ // 6. Klick auf "Jetzt buchen" -> Daten für bookingService bereitstellen & weiterleiten
+ const bookingBtn = document.getElementById("proceed-to-booking-btn");
+ bookingBtn.addEventListener("click", () => {
+ const selectedVariantId = variantSelect ? variantSelect.value : pkg.id;
+ const selectedVariant = pkg.variants
+ ? pkg.variants.find((v) => v.variantId === selectedVariantId)
+ : null;
+
+ const bookingPayload = {
+ packageId: pkg.id,
+ variantId: selectedVariantId,
+ title: pkg.title,
+ type: pkg.type,
+ durationWeeks: selectedVariant
+ ? selectedVariant.durationWeeks
+ : pkg.durationWeeks,
+ price: selectedVariant ? selectedVariant.price : pkg.price,
+ currency: pkg.currency,
+ taxRate: pkg.taxRate,
+ summaryForBooking: pkg.summaryForBooking,
+ };
+
+ // Im sessionStorage zwischenspeichern + per URL an die Buchungsseite übergeben
+ sessionStorage.setItem(
+ "selectedPackageForBooking",
+ JSON.stringify(bookingPayload),
+ );
+ window.location.href = `booking.html?id=${pkg.id}&variant=${selectedVariantId}`;
+ });
+});
diff --git a/src/server/swagger.json b/src/server/swagger.json
new file mode 100644
index 0000000..f356c8b
--- /dev/null
+++ b/src/server/swagger.json
@@ -0,0 +1,481 @@
+{
+ "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.0.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": "Wird vom bookingService aufgerufen, um vor Abschluss der Buchung die relevanten Bestelldaten (Nettopreis, MwSt., Bruttopreis, gewählte Variante) validiert anzuzeigen.",
+ "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": "Nimmt die ausgewählten Paketdetails und die Kundendaten entgegen, speichert die Buchung im bookingService und gibt eine Bestellbestätigung zurück.",
+ "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"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "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
+ }
+ }
+ },
+ "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
+ },
+ "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"
+ },
+ "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"
+ }
+ }
+ },
+ "BookingCreateRequest": {
+ "type": "object",
+ "required": ["packageId", "customer", "acceptedTerms"],
+ "properties": {
+ "packageId": {
+ "type": "string",
+ "example": "ernaehrung-standard"
+ },
+ "variantId": {
+ "type": "string",
+ "nullable": true,
+ "example": "ernaehrung-standard"
+ },
+ "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",
+ "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
+ }
+ }
+ },
+ "BookingConfirmation": {
+ "type": "object",
+ "properties": {
+ "bookingId": {
+ "type": "string",
+ "example": "BK-2026-84920"
+ },
+ "status": {
+ "type": "string",
+ "example": "CONFIRMED"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "bookedPackage": {
+ "$ref": "#/components/schemas/BookingPreviewResponse"
+ },
+ "customerEmail": {
+ "type": "string",
+ "example": "thomas.mueller@example.de"
+ },
+ "confirmationMessage": {
+ "type": "string",
+ "example": "Vielen Dank für deine Buchung! Deine Bestellbestätigung wurde per E-Mail versendet."
+ }
+ }
+ },
+ "ErrorResponse": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string",
+ "example": "Paket nicht gefunden"
+ },
+ "code": {
+ "type": "integer",
+ "example": 404
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/shared/packagesdetails.js b/src/shared/packagesdetails.js
new file mode 100644
index 0000000..9518acb
--- /dev/null
+++ b/src/shared/packagesdetails.js
@@ -0,0 +1,149 @@
+// 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",
+ },
+];
From 5fb3f51f8fed28eee8ffebc6f20f93faac9fb416 Mon Sep 17 00:00:00 2001
From: Marvin Gemlin
Date: Fri, 25 Sep 2026 20:04:22 +0000
Subject: [PATCH 07/13] =?UTF-8?q?Paket=C3=BCbergabe=20korrigiert,=20Bestel?=
=?UTF-8?q?l=C3=BCbersicht=20und=20Best=C3=A4tigungsmail=20erweitert?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example | 1 -
docs/booking.md | 430 +++++---------------
paket-details.html | 50 +--
src/features/angebote/angebotsuebersicht.js | 7 -
src/features/angebote/paket-details.js | 38 +-
src/features/booking/booking-api.js | 13 +-
src/features/booking/booking-entry.js | 7 +-
src/features/booking/booking-page.js | 92 ++++-
src/features/booking/booking.css | 23 ++
src/server/index.js | 58 ++-
src/server/routes/bookings.js | 8 +-
src/server/services/booking-service.js | 155 ++++++-
src/server/services/csv-storage.js | 25 +-
src/server/services/email-service.js | 75 +++-
src/server/swagger.json | 237 ++++++++++-
src/shared/booking-copy.js | 4 +-
src/shared/order-summary.js | 28 ++
src/shared/packages.js | 10 -
src/tests/angebotsuebersicht.test.js | 1 -
src/tests/bookings.test.js | 224 +++++++++-
src/tests/browser/booking.spec.js | 71 +++-
src/tests/helpers/browser-server.js | 4 +-
src/tests/packages.test.js | 4 +-
vite.config.js | 3 +
24 files changed, 1059 insertions(+), 509 deletions(-)
create mode 100644 src/shared/order-summary.js
diff --git a/.env.example b/.env.example
index 2968da5..318f119 100644
--- a/.env.example
+++ b/.env.example
@@ -1,7 +1,6 @@
# Lokal nur einen Mailfänger verwenden. Keine echten Zugangsdaten eintragen.
HOST=127.0.0.1
PORT=3000
-BOOKING_DEMO=true
SMTP_HOST=127.0.0.1
SMTP_PORT=1025
SMTP_SECURE=false
diff --git a/docs/booking.md b/docs/booking.md
index 9dd1d1a..884e0d4 100644
--- a/docs/booking.md
+++ b/docs/booking.md
@@ -1,357 +1,143 @@
-# US2.4 – Paket buchen
+# Paketbuchung
-## Stand und Zusammenarbeit
+## Ablauf
-Entwicklung auf `feature/paket-buchen-backend`, späteres PR-Ziel: `dev`.
-Zu Beginn waren HEAD und frisch abgerufenes `origin/dev` identisch. Kein Merge,
-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.
+Die Angebotsübersicht führt zur Detailseite eines Pakets. Der Buchungsbutton
+übergibt die Paket-ID und gegebenenfalls die gewählte Variante als URL-Parameter
+an `booking.html`. Bestehende Links mit `#/buchen/:packageId` funktionieren weiterhin.
-## So funktioniert der Ablauf
+Booking lädt die Bestellübersicht vom Server. Sie enthält Paket und Variante,
+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.
-1. Der Browser lädt die verfügbaren Pakete über `GET /api/angebote`.
-2. Das Formular sendet Paket-ID, Name und E-Mail an `POST /api/bookings`.
- Es merkt sich dafür einen zufälligen Anfrageschlüssel (Idempotency-Key).
-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.
-5. Erst danach übergibt er die Bestätigung an SMTP und aktualisiert den Versandstatus.
-6. Bei einem erneuten Versuch mit denselben Daten und demselben Schlüssel gibt
- er die vorhandene Buchung zurück. Es entsteht keine zweite Bestellung.
+Beim Absenden speichert der Server Kundendaten und den vollständigen Bestellstand
+in der CSV. Anschließend sendet er eine Bestätigung als Text und HTML mit
+Buchungsnummer, Datum, gebuchten Leistungen und Preisen. Änderungen am Katalog
+verändern bereits gespeicherte Bestellungen nicht.
+
+Der Zahlungsvorgang ist weiterhin simuliert. Es erfolgt keine Abbuchung und
+keine verbindliche Beratungsbuchung. Darauf weisen Formular und E-Mail hin.
## Lokal starten
-Node.js 24 ist im vorhandenen Dev Container vorgesehen. Alle Befehle laufen im
-Projektverzeichnis. Vite und Backend benötigen jeweils ein eigenes Terminal.
+Voraussetzung ist Node.js 24. Abhängigkeiten mit `npm ci` installieren und die
+Werte aus `.env.example` in eine lokale `.env` übernehmen. Eine vorhandene
+Konfiguration dabei erhalten.
-```bash
-npm ci
-cp .env.example .env
-```
+In separaten Terminals starten:
-Eine vorhandene `.env` vorher prüfen und nicht überschreiben. Die Beispielwerte
-aktivieren das klar gekennzeichnete Testpaket und verwenden nur einen Mailfänger.
-Ohne `.env` bleibt der Demo-Modus ausgeschaltet; die drei regulären Pakete bleiben verfügbar.
+- `npm run dev:mail`: Mailpit mit SMTP auf Port 1025 und Weboberfläche auf 8025.
+- `npm run dev:server`: Buchungs-API auf Port 3000.
+- `npm run dev`: Frontend auf Port 5173; `/api` wird an Node weitergeleitet.
-Mailpit ist über `.devcontainer/Dockerfile` bereits Bestandteil neuer Dev Container.
-Bestehende Container einmal neu bauen. Außerhalb davon einmalig
-[Mailpit installieren](https://mailpit.axllent.org/docs/install/).
-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:
+Die Angebotsübersicht liegt unter ,
+der Mailfänger unter . Mailpit ist im Dev Container enthalten.
-```bash
-npm run dev:mail
-```
-
-In Terminal 2 das Backend starten:
-
-```bash
-npm run dev:server
-```
-
-In Terminal 3 das Frontend starten:
-
-```bash
-npm run dev
-```
-
-Öffnen: .
-Mailfänger: . Im Dev Container sind die Ports 8025 und 5173
-für die Weiterleitung 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.
+`npm run build` erzeugt die Seiten in `dist/`, einschließlich der Paketdetailseite.
+Für ein Deployment muss der Webserver `/api` an den Node-Server weiterleiten.
+`npm run start:server` startet die API ohne automatischen Neustart.
## Konfiguration
-Alle SMTP-Einstellungen werden ausschließlich in Node geladen. Niemals mit einem
-`VITE_`-Präfix versehen: Solche Variablen könnten im Browser landen.
+| Variable | Standardwert | Bedeutung |
+| ------------------------ | -------------------------------- | --------------------------------------------------- |
+| `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 ` | Absender |
+| `SMTP_ALLOW_EXTERNAL` | `false` | Versand über externe SMTP-Server aktivieren |
-| Variable | Lokaler Beispielwert | Bedeutung |
-| ------------------------- | -------------------------------- | --------------------------------------------------------------------- |
-| `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 ` | Absender; später abgestimmten echten Absender verwenden |
-| `SMTP_ALLOW_EXTERNAL` | `false` | Externes SMTP gesperrt; erst nach ausdrücklicher Freigabe aktivieren |
+Externe SMTP-Verbindungen erfordern TLS. Zugangsdaten gehören ausschließlich
+in die Serverkonfiguration und dürfen kein `VITE_`-Präfix erhalten.
-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).
+## API
-## Paketdaten und spätere Router-Anbindung
+Der vollständige Vertrag steht in `src/server/swagger.json`.
-Person 3 pflegt `packages` in **`src/shared/packages.js`**.
-Das Array enthält die drei Angebote Starter, Standard und Premium. Übersicht und
-Buchung verwenden gemeinsam `id`, `name` und `summary`; zusätzliche Felder
-beschreiben die Darstellung der Angebotskarten.
+| Endpunkt | Funktion |
+| ---------------------------- | ----------------------------------------------------------------------------------- |
+| `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 |
-```js
-// Nur Schema-Beispiel, kein verbindliches Angebot:
-{ id: "stabile-paket-id", name: "Abgestimmter Paketname", summary: "Kurze Beschreibung" }
-```
+POST-Anfragen benötigen `Content-Type: application/json`. Beim Buchen ist
+zusätzlich ein `Idempotency-Key` als UUID v4 erforderlich. Das Formular sendet
+`packageId`, gegebenenfalls `variantId`, `name` und `email`.
-IDs: eindeutig, maximal 80 Zeichen, Kleinbuchstaben/Ziffern mit einzelnen
-Bindestrichen. `name` und `summary` sind nicht leere Strings. IDs nach Freigabe
-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.
+Alternativ akzeptiert die API das Swagger-Objekt `customer` mit `firstName`,
+`lastName`, `email` sowie optional `phone`, `ageGroup` und `notes`. Dabei muss
+`acceptedTerms` den Wert `true` haben. Diese Angaben werden ebenfalls gespeichert.
-Die Angebotsseite kann vorerst auf die eigenständige Ansicht verlinken:
+Die Antwort enthält die Buchungsnummer, den Erstellungszeitpunkt, das gebuchte
+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.
-```js
-link.href = `/booking.html#/buchen/${encodeURIComponent(paket.id)}`;
-```
+Ungültige Angaben, Pakete und Varianten ergeben HTTP 400; ein unbekanntes Paket
+am Detail-Endpunkt HTTP 404. Derselbe Anfrageschlüssel mit anderen Daten ergibt
+HTTP 409. JSON-Anfragen sind auf 8 KiB begrenzt. Fehlerantworten enthalten
+`error.code`, `error.message` und gegebenenfalls `error.fields`.
-Es wurde kein Router in die gemeinsame Startseite eingebaut. Wenn Person 2 den
-Router ergänzt, kann sie bei `/#/buchen/:packageId` diese Funktion aufrufen:
+## Speicherung und Wiederholungen
-```js
-import { mountBookingPage } from "./features/booking/booking-page.js";
-const cleanup = mountBookingPage(container, { packageId });
-// Beim Verlassen der Route:
-cleanup();
-```
+Die Buchungen liegen in `src/server/data/bookings.csv`. Neue Buchungsnummern
+werden fortlaufend als `TH-000001`, `TH-000002` usw. vergeben. Der höchste
+vorhandene Wert bestimmt die nächste Nummer. Die CSV muss deshalb vollständig
+erhalten bleiben; ältere UUID-Buchungsnummern werden weiterhin unterstützt.
-Das Modul bringt seine begrenzten `.booking`-Styles mit. Farben und Schrift
-orientieren sich am vorhandenen Basis-CSS. Gemeinsame Designvariablen und eine
-Button-Komponente gibt es bisher nicht. Layout und Navigation bleiben Aufgabe
-der anderen Teammitglieder. „Zur Angebotsseite“ führt zu `/angebotsuebersicht.html`.
+`bookedPackage` speichert den Bestellstand als JSON, `customer` die optionalen
+strukturierten Kundendaten und `acceptedTerms` die zugehörige Zustimmung.
+CSV-Dateien mit dem früheren Spaltensatz bleiben lesbar und werden beim nächsten
+Speichern erweitert. Für alte Buchungen ohne Bestellstand liefert die API
+`bookedPackage: null`.
-## API-Beispiele
+Lesen, Schreiben und Versand werden innerhalb einer Warteschlange abgearbeitet.
+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.
-`GET /api/angebote` liefert `{ "packages": [...] }` mit `id`, `name`, `summary`
-und `testOnly`. Keine personenbezogenen Daten oder SMTP-Einstellungen.
+Wiederholte Anfragen mit demselben Schlüssel und denselben Daten liefern die
+vorhandene Buchung. Es wird keine zweite Bestellung angelegt. Die Auswahl einer
+anderen Variante mit demselben Schlüssel wird abgelehnt.
-Für `POST /api/bookings` sind `Content-Type: application/json` und ein
-`Idempotency-Key` als UUID v4 erforderlich. Ein Schlüssel gehört genau zu einer
-Buchung. Bei Wiederholung denselben Schlüssel und dieselben Angaben verwenden.
+Das Formular merkt sich Anfrage und Bestellübersicht je Paket und Variante im
+`sessionStorage`. Nach einem Verbindungsfehler bleiben die Angaben gesperrt,
+damit dieselbe Anfrage erneut geprüft werden kann. Ein neuer Tab oder gelöschter
+Sitzungsspeicher liegt außerhalb dieser Absicherung.
-```http
-POST /api/bookings
-Content-Type: application/json
-Idempotency-Key: 36c45d7f-585f-47e9-bf5c-7ea1b7a162ed
+## Versandstatus
-{"packageId":"demo-booking","name":"Test Person","email":"person@example.test"}
-```
+| Status | Bedeutung |
+| ---------- | ------------------------------------------------------------------------------------------------ |
+| `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. |
-Neue Buchung mit SMTP-Annahme: HTTP `201`; gespeicherte Wiederholung: `200`.
+Bei `failed` und `unknown` wird nicht automatisch erneut versendet. Vor einem
+manuellen Neuversand muss der Mailserver anhand der Buchungsnummer geprüft werden.
+CSV und SMTP bilden keine gemeinsame Transaktion.
-```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
-}
-```
+## Tests
-Gespeichert, aber E-Mail nicht bestätigt: HTTP `202` mit derselben Struktur und
-`emailStatus: "failed"`, `"pending"` oder `"unknown"`. Das bedeutet ausdrücklich
-nicht, dass die E-Mail zugestellt wurde. Die Buchungsnummer bleibt gültig.
+`npm test` prüft Validierung, Preisberechnung, Varianten, CSV-Kompatibilität,
+Wiederholungen und Versandfehler. `npm run test:browser` prüft den Weg von der
+Angebotsübersicht über die Paketdetails bis zur Bestätigung, einschließlich
+Premium-Jahresvariante, Tastaturbedienung und schmalem Bildschirm.
-| Status/Code | Bedeutung und Verhalten |
-| ------------------------------ | ----------------------------------------------------------------------- |
-| `400 INVALID_INPUT` | Angaben korrigieren; Feldhinweise stehen in `error.fields` |
-| `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 |
+Die Tests verwenden die regulären Paketdaten, temporäre CSV-Dateien und einen
+simulierten Mailversand. Playwright benötigt Chromium; die Installation erfolgt
+mit `npx playwright install --with-deps chromium`. Die Ports 3000 und 5173 müssen
+für die automatisch gestarteten Testserver frei sein.
-Die E-Mail-Prüfung unterstützt übliche unquotierte ASCII-Adressen. Internationale
-Domains können in Punycode angegeben werden; SMTPUTF8-Adressen sind nicht Teil
-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
-
-Die Angebotsübersicht ist als eigene HTML-Seite angebunden. Offen: Anschluss an einen 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 installiert Mailpit und leitet dessen Web-Port weiter.
-
-Technische Referenzen: [CSV-Parser](https://csv.js.org/parse/api/sync/),
-[Playwright-Testserver](https://playwright.dev/docs/test-webserver).
+Vor einem Commit außerdem `npm run format:check`, `npm run build` und
+`git diff --check` ausführen. Für eine manuelle Mailprüfung eine Buchung mit einer
+Adresse unter `example.test` anlegen und die Bestätigung in Mailpit kontrollieren.
diff --git a/paket-details.html b/paket-details.html
index c79f82d..36844f5 100644
--- a/paket-details.html
+++ b/paket-details.html
@@ -1,30 +1,32 @@
+
+
+
+ Paket-Details – Tri-Hub Ernährungsberatung
+
+
+
+
+
-
-
-
- Paket-Details – Tri-Hub Ernährungsberatung
-
-
-
-
-
+
+
+ ← Zurück zur Angebotsübersicht
+
-
-
- ← Zurück zur Angebotsübersicht
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/src/features/angebote/angebotsuebersicht.js b/src/features/angebote/angebotsuebersicht.js
index 77afcf9..c0ca328 100644
--- a/src/features/angebote/angebotsuebersicht.js
+++ b/src/features/angebote/angebotsuebersicht.js
@@ -1,21 +1,16 @@
import { packages } from "../../shared/packages.js";
export function renderAngebotsuebersicht() {
- // Wir suchen den Container, der in der angebotsuebersicht.html definiert wurde
const container = document.getElementById("packages-container");
- // Sicherheitscheck: Wenn der Container nicht da ist, brechen wir ab
if (!container) {
console.warn("Container für Angebotsübersicht nicht gefunden.");
return;
}
- // Container leeren (falls die Funktion mehrfach aufgerufen wird)
container.innerHTML = "";
- // Durch jedes Paket iterieren und das HTML generieren
packages.forEach((pkg) => {
- // Highlight-Badge nur anzeigen, wenn es eins gibt
const highlightHtml = pkg.highlight
? `${pkg.highlight}`
: "";
@@ -46,13 +41,11 @@ export function renderAngebotsuebersicht() {
-
${pkg.buttonText}
`;
- // Das generierte HTML dem Container hinzufügen
container.innerHTML += cardHtml;
});
}
diff --git a/src/features/angebote/paket-details.js b/src/features/angebote/paket-details.js
index 790f481..d3ad192 100644
--- a/src/features/angebote/paket-details.js
+++ b/src/features/angebote/paket-details.js
@@ -1,16 +1,12 @@
-// src/features/angebote/paket-details.js
import { packagesDetails } from "../../shared/packagesdetails.js";
-document.addEventListener("DOMContentLoaded", async () => {
+document.addEventListener("DOMContentLoaded", () => {
const container = document.getElementById("package-detail-container");
if (!container) return;
- // 1. ID aus der URL lesen (z.B. ?id=ernaehrung-standard)
const params = new URLSearchParams(window.location.search);
const packageId = params.get("id");
- // 2. Passendes Paket in packagesdetails.js suchen
- // (Später kann hier alternativ: await fetch(`/api/packages/${packageId}`) genutzt werden)
const pkg = packagesDetails.find((item) => item.id === packageId);
if (!pkg) {
@@ -24,7 +20,6 @@ document.addEventListener("DOMContentLoaded", async () => {
return;
}
- // 3. Optional: Varianten-Auswahl rendern (für das Premium-Paket)
const variantsHtml = pkg.variants
? `