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 = ` +
+ Zur Angebotsseite +

Paket buchen

+

Paket wird geladen …

+
+
`; + const intro = root.querySelector(".booking__intro"); + const content = root.querySelector(".booking__content"); + + async function load() { + if (!packageId) { + intro.textContent = + "Bitte wähle zuerst ein Paket auf der Angebotsseite aus."; + return; + } + try { + const data = await loadPackages(signal); + if (signal.aborted) return; + const selected = data.packages.find( + (entry) => entry.id === packageId, + ); + if (!selected) { + intro.textContent = + "Dieses Paket ist nicht verfügbar. Bitte wähle ein Paket auf der Angebotsseite aus."; + return; + } + intro.remove(); + renderForm(selected); + } catch { + if (signal.aborted) return; + intro.textContent = + "Das Paket konnte nicht geladen werden. Bitte prüfe deine Verbindung und versuche es erneut."; + const retry = document.createElement("button"); + retry.className = "booking__button"; + retry.textContent = "Paket erneut laden"; + retry.addEventListener( + "click", + () => { + retry.remove(); + intro.textContent = "Paket wird geladen …"; + load(); + }, + { once: true, signal }, + ); + content.replaceChildren(retry); + } + } + + function renderForm(selected) { + content.innerHTML = ` +
+

+

+
+ +
+

Alle Felder sind Pflichtfelder.

+ +
+ + +

+
+
+ + +

An diese Adresse senden wir deine Bestätigung.

+

+
+ +

+
+ `; + content.querySelector(".booking__package-name").textContent = + selected.name; + content.querySelector(".booking__summary").textContent = + selected.summary; + content.querySelector(".booking__payment-notice").textContent = + paymentNotice; + const form = content.querySelector("form"); + const name = form.elements.namedItem("name"); + const email = form.elements.namedItem("email"); + const button = form.querySelector("button"); + const errors = form.querySelector(".booking__errors"); + const progress = form.querySelector(".booking__progress"); + const result = content.querySelector(".booking__result"); + const storageKey = `trihub.booking.${selected.id}`; + let attempt = null; + let busy = false; + let finished = false; + + function lockFields(locked) { + name.readOnly = locked; + email.readOnly = locked; + } + function showError(message, fields = {}) { + errors.textContent = message; + errors.hidden = false; + for (const field of [name, email]) { + const message = fields[field.name] || ""; + content.querySelector( + `#booking-${field.name}-error`, + ).textContent = message; + field.setAttribute("aria-invalid", String(Boolean(message))); + } + errors.focus(); + } + function clearErrors() { + errors.hidden = true; + for (const field of [name, email]) { + field.removeAttribute("aria-invalid"); + content.querySelector( + `#booking-${field.name}-error`, + ).textContent = ""; + } + } + function showResult(body, focus = true) { + const messages = { + accepted: + "Buchung gespeichert. Die Bestätigung wurde vom Mailserver zum Versand angenommen. Die Zustellung ist noch nicht bestätigt.", + failed: "Buchung gespeichert, aber der Bestätigungsversand ist fehlgeschlagen. Bitte nicht erneut buchen. Wende dich mit deiner Buchungsnummer an Tri-Hub.", + pending: + "Buchung gespeichert. Der Bestätigungsversand konnte noch nicht gestartet werden. Du kannst den Versand mit der bestehenden Buchung erneut versuchen.", + unknown: + "Buchung gespeichert. Der Versandstatus der Bestätigung ist unklar. Bitte nicht erneut buchen. Wende dich mit deiner Buchungsnummer an Tri-Hub.", + }; + result.textContent = `${messages[body.emailStatus] || messages.unknown} Buchungsnummer: ${body.bookingId}`; + result.hidden = false; + lockFields(true); + finished = body.emailStatus !== "pending"; + button.disabled = finished; + button.textContent = finished + ? "Buchung gespeichert" + : "Bestätigungsversand erneut versuchen"; + if (focus) result.focus(); + } + + try { + const stored = JSON.parse(sessionStorage.getItem(storageKey)); + if (stored?.key && stored.payload?.packageId === selected.id) { + attempt = stored; + name.value = stored.payload.name; + email.value = stored.payload.email; + lockFields(true); + button.textContent = "Status prüfen / erneut versuchen"; + if (stored.result) showResult(stored.result, false); + else + progress.textContent = + "Eine frühere Anfrage ist noch offen. Bitte ihren Status prüfen, bevor du erneut buchst."; + } + } catch { + // Speichern wird vor der ersten Anfrage zwingend erneut geprüft. + } + + form.addEventListener( + "submit", + async (event) => { + event.preventDefault(); + if (busy || finished) return; + clearErrors(); + if (!attempt) { + const fields = {}; + name.value = name.value.trim(); + email.value = email.value.trim(); + if ( + !name.value || + name.value.length > 120 || + /[\x00-\x1f\x7f]/.test(name.value) + ) + fields.name = + "Bitte deinen Namen eingeben (höchstens 120 Zeichen, ohne Zeilenumbrüche)."; + if (!email.validity.valid || email.value.length > 254) + fields.email = + "Bitte eine gültige E-Mail-Adresse eingeben."; + if (Object.keys(fields).length) { + showError( + "Bitte die markierten Angaben prüfen.", + fields, + ); + return; + } + const nextAttempt = { + key: crypto.randomUUID(), + payload: { + packageId: selected.id, + name: name.value, + email: email.value, + }, + }; + try { + sessionStorage.setItem( + storageKey, + JSON.stringify(nextAttempt), + ); + } catch { + showError( + "Der Browser kann die Anfrage nicht für einen sicheren erneuten Versuch merken. Bitte erlaube den Sitzungsspeicher und versuche es erneut.", + ); + return; + } + attempt = nextAttempt; + } + busy = true; + button.disabled = true; + button.textContent = "Buchung wird verarbeitet …"; + form.setAttribute("aria-busy", "true"); + lockFields(true); + progress.textContent = + "Bitte warten. Deine Buchung wird geprüft und gespeichert."; + try { + const body = await submitBooking( + attempt.payload, + attempt.key, + ); + attempt.result = body; + try { + sessionStorage.setItem( + storageKey, + JSON.stringify(attempt), + ); + } catch { + /* Der ursprüngliche Schlüssel bleibt erhalten. */ + } + if (!signal.aborted) showResult(body); + } catch (error) { + // Nur eindeutige Ablehnungen vor dem Speichern erlauben eine + // Korrektur. Bei Verbindungsfehlern bleiben Daten und Key fest. + if ( + [ + "INVALID_INPUT", + "UNKNOWN_PACKAGE", + "INVALID_KEY", + ].includes(error.code) + ) { + attempt = null; + try { + sessionStorage.removeItem(storageKey); + } catch { + /* Keine Anfrage gespeichert. */ + } + lockFields(false); + } + if (!signal.aborted) { + showError(error.message, error.fields); + button.textContent = attempt + ? "Status prüfen / erneut versuchen" + : "Paket buchen"; + } + } finally { + busy = false; + if (!signal.aborted) { + form.removeAttribute("aria-busy"); + progress.textContent = ""; + button.disabled = finished; + } + } + }, + { signal }, + ); + } + load(); + return () => controller.abort(); +} diff --git a/src/features/booking/booking.css b/src/features/booking/booking.css new file mode 100644 index 0000000..0c99b24 --- /dev/null +++ b/src/features/booking/booking.css @@ -0,0 +1,96 @@ +.booking { + max-width: 42rem; + margin-inline: auto; + overflow-wrap: anywhere; +} + +.booking h1 { + max-width: none; +} + +.booking__package, +.booking__result { + padding: 1.25rem; + border: 1px solid currentColor; + border-radius: 0.5rem; + margin-block: 1.5rem; +} + +.booking__package h2 { + margin-top: 0; +} +.booking__payment { + margin-block: 1.5rem; + padding-left: 1rem; + border-left: 3px solid currentColor; +} + +.booking__payment h2 { + font-size: 1.1rem; +} +.booking__field { + margin-block: 1.5rem; +} +.booking label { + display: block; + font-weight: 600; + margin-bottom: 0.35rem; +} +.booking input, +.booking__button { + font: inherit; + border: 1px solid currentColor; + border-radius: 0.25rem; + min-height: 2.75rem; + padding: 0.65rem 0.8rem; +} +.booking input { + width: 100%; + color: inherit; + background: transparent; +} +.booking input[readonly] { + border-style: dashed; +} +.booking__button { + color: #f7faf7; + background: #176448; + cursor: pointer; + max-width: 100%; +} +.booking__button:disabled { + opacity: 0.7; + cursor: default; +} +.booking :focus-visible, +.booking__errors:focus, +.booking__result:focus { + outline: 3px solid currentColor; + outline-offset: 4px; +} +.booking__button:focus-visible { + outline-color: #18352b; +} +.booking__errors, +.booking__field-error { + color: #8b1e1e; +} +.booking__errors { + padding: 0.8rem; + border: 2px solid currentColor; + margin-block: 1rem; +} +.booking__field p { + margin-block: 0.35rem; +} +.booking__field-error:empty { + display: none; +} +.booking [aria-invalid="true"] { + border: 2px solid #8b1e1e; +} +@media (max-width: 30rem) { + .booking__button { + width: 100%; + } +} diff --git a/src/main.js b/src/main.js index cb01bb8..95efe73 100644 --- a/src/main.js +++ b/src/main.js @@ -1,3 +1,3 @@ -import "./styles.css"; +import "./styles/styles.css"; document.querySelector("#year").textContent = new Date().getFullYear(); diff --git a/server/data/.gitkeep b/src/server/data/.gitkeep similarity index 100% rename from server/data/.gitkeep rename to src/server/data/.gitkeep diff --git a/src/server/index.js b/src/server/index.js new file mode 100644 index 0000000..8fb4c7e --- /dev/null +++ b/src/server/index.js @@ -0,0 +1,87 @@ +import { createServer } from "node:http"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { packages, demoPackages } from "../shared/packages.js"; +import { createBookingService } from "./services/booking-service.js"; +import { createCsvStorage } from "./services/csv-storage.js"; +import { createEmailService } from "./services/email-service.js"; +import { handleBooking, json } from "./routes/bookings.js"; + +export function createApp({ + catalog = packages, + storage = createCsvStorage( + fileURLToPath(new URL("./data/bookings.csv", import.meta.url)), + ), + sendConfirmation, +} = {}) { + const ids = new Set(); + for (const entry of catalog) { + if ( + !entry || + typeof entry.id !== "string" || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id) || + entry.id.length > 80 || + ids.has(entry.id) || + typeof entry.name !== "string" || + !entry.name.trim() || + typeof entry.summary !== "string" || + !entry.summary.trim() + ) { + throw new Error( + "Paketdaten erfüllen den Vertrag in src/shared/packages.js nicht.", + ); + } + ids.add(entry.id); + } + const service = createBookingService({ + storage, + packages: catalog, + sendConfirmation: sendConfirmation ?? createEmailService(), + }); + return createServer(async (request, response) => { + const path = new URL(request.url, "http://localhost").pathname; + if (path === "/api/angebote" && request.method === "GET") { + return json(response, 200, { + packages: catalog.map(({ id, name, summary, testOnly }) => ({ + id, + name, + summary, + testOnly: Boolean(testOnly), + })), + }); + } + if (path === "/api/bookings") { + if (request.method !== "POST") { + response.setHeader("Allow", "POST"); + return json(response, 405, { + error: { + code: "METHOD_NOT_ALLOWED", + message: "Bitte POST verwenden.", + }, + }); + } + return handleBooking(request, response, service); + } + // Ausschließlich API; keine Auslieferung von CSV oder Serverdateien. + return json(response, 404, { + error: { code: "NOT_FOUND", message: "Adresse nicht gefunden." }, + }); + }); +} + +if ( + process.argv[1] && + 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 server = createApp({ + catalog: demo ? [...packages, ...demoPackages] : packages, + }); + server.listen(port, process.env.HOST || "127.0.0.1", () => { + console.log( + `Buchungs-API auf Port ${port}${demo ? " (Testpaket aktiviert)" : ""}`, + ); + }); +} diff --git a/src/server/routes/bookings.js b/src/server/routes/bookings.js new file mode 100644 index 0000000..f9f80fd --- /dev/null +++ b/src/server/routes/bookings.js @@ -0,0 +1,75 @@ +import { BookingError } from "../services/booking-service.js"; + +export function json(response, status, body) { + response.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }); + response.end(JSON.stringify(body)); +} + +async function readJson(request) { + if ( + request.headers["content-type"]?.split(";")[0].trim() !== + "application/json" + ) { + throw new BookingError( + 415, + "UNSUPPORTED_CONTENT_TYPE", + "Bitte JSON senden.", + ); + } + let size = 0; + const chunks = []; + for await (const chunk of request) { + size += chunk.length; + if (size > 8192) + throw new BookingError( + 413, + "BODY_TOO_LARGE", + "Die Anfrage ist zu groß.", + ); + chunks.push(chunk); + } + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + throw new BookingError( + 400, + "INVALID_JSON", + "Die Anfrage enthält kein gültiges JSON.", + ); + } +} + +export async function handleBooking(request, response, service) { + try { + const input = await readJson(request); + const result = await service.book( + input, + request.headers["idempotency-key"], + ); + json(response, result.status, result.body); + } catch (error) { + if (error instanceof BookingError) { + json(response, error.status, { + error: { + code: error.code, + message: error.message, + fields: error.fields, + }, + }); + } else { + // Keine persönlichen Daten oder SMTP-Zugangsdaten ins Log schreiben. + console.error("Buchungsanfrage unerwartet fehlgeschlagen."); + json(response, 500, { + error: { + code: "INTERNAL_ERROR", + message: + "Der Status ist unklar. Bitte dieselbe Anfrage erneut versuchen.", + }, + }); + } + } +} diff --git a/src/server/services/booking-service.js b/src/server/services/booking-service.js new file mode 100644 index 0000000..bb32272 --- /dev/null +++ b/src/server/services/booking-service.js @@ -0,0 +1,203 @@ +import { createHash } from "node:crypto"; + +export class BookingError extends Error { + constructor(status, code, message, fields = {}) { + super(message); + this.status = status; + this.code = code; + this.fields = fields; + } +} + +const uuidPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +// Übliche unquotierte E-Mail-Adressen; einzelne Domainlabels maximal 63 Zeichen. +const emailPattern = + /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i; + +function validateInput(input, key) { + if (!uuidPattern.test(key ?? "")) { + throw new BookingError( + 400, + "INVALID_KEY", + "Ein gültiger Idempotency-Key (UUID v4) ist erforderlich.", + ); + } + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new BookingError( + 400, + "INVALID_INPUT", + "Erwartet wird ein JSON-Objekt.", + ); + } + const fields = {}; + const result = {}; + for (const [field, max, message] of [ + ["packageId", 80, "Bitte ein gültiges Paket auswählen."], + ["name", 120, "Bitte einen Namen mit höchstens 120 Zeichen eingeben."], + [ + "email", + 254, + "Bitte eine gültige E-Mail-Adresse eingeben (maximal 254 Zeichen).", + ], + ]) { + const value = input[field]; + if (typeof value !== "string" || !value.trim() || value.length > max) { + fields[field] = message; + } else { + result[field] = value.trim(); + } + } + if ( + result.packageId && + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(result.packageId) + ) { + fields.packageId = "Die Paket-ID ist ungültig."; + } + if (result.name && /[\x00-\x1f\x7f]/.test(result.name)) { + fields.name = + "Bitte den Namen ohne Zeilenumbrüche oder Steuerzeichen eingeben."; + } + if ( + result.email && + (!emailPattern.test(result.email) || + result.email.split("@")[0].length > 64) + ) { + fields.email = "Bitte eine gültige E-Mail-Adresse eingeben."; + } + if (Object.keys(fields).length) { + throw new BookingError( + 400, + "INVALID_INPUT", + "Bitte die markierten Angaben prüfen.", + fields, + ); + } + return result; +} + +// Der höchste gespeicherte Wert ist der persistente Zähler. Die bestehende +// Warteschlange verhindert, dass parallele Anfragen dieselbe Nummer erhalten. +function nextBookingId(records) { + let highest = 0n; + for (const record of records) { + const match = /^TH-([0-9]{6,})$/.exec(record.bookingId); + if (match) { + const number = BigInt(match[1]); + if (number > highest) highest = number; + } + } + return `TH-${String(highest + 1n).padStart(6, "0")}`; +} + +function responseFor(record, replayed) { + // Nach Prozessabbruch während SMTP bleibt die tatsächliche Annahme unklar. + const emailStatus = + record.emailStatus === "sending" ? "unknown" : record.emailStatus; + return { + status: emailStatus === "accepted" ? (replayed ? 200 : 201) : 202, + body: { + bookingId: record.bookingId, + createdAt: record.createdAt, + packageId: record.packageId, + packageName: record.packageName, + saved: true, + emailStatus, + replayed, + }, + }; +} + +export function createBookingService({ storage, sendConfirmation, packages }) { + // Eine Warteschlange umfasst Lesen, Schreiben UND Versand. Nur ein Prozess! + let queue = Promise.resolve(); + async function processBooking(input, key) { + const data = validateInput(input, key); + const requestHash = createHash("sha256") + .update(JSON.stringify(data)) + .digest("hex"); + let records; + try { + records = await storage.readAll(); + } catch { + throw new BookingError( + 503, + "STORAGE_UNAVAILABLE", + "Der Buchungsstatus kann gerade nicht geprüft werden. Bitte mit derselben Anfrage erneut versuchen.", + ); + } + let record = records.find((entry) => entry.idempotencyKey === key); + const replayed = Boolean(record); + if (record && record.requestHash !== requestHash) { + throw new BookingError( + 409, + "IDEMPOTENCY_CONFLICT", + "Dieser Anfrageschlüssel gehört zu anderen Buchungsdaten. Bitte die ursprünglichen Angaben verwenden.", + ); + } + if (record && record.emailStatus !== "pending") + return responseFor(record, true); + if (!record) { + const selected = packages.find( + (entry) => entry.id === data.packageId, + ); + if (!selected) { + throw new BookingError( + 400, + "UNKNOWN_PACKAGE", + "Das ausgewählte Paket ist nicht verfügbar.", + { packageId: "Bitte ein verfügbares Paket auswählen." }, + ); + } + record = { + bookingId: nextBookingId(records), + createdAt: new Date().toISOString(), + packageId: selected.id, + packageName: selected.name, + name: data.name, + email: data.email, + emailStatus: "pending", + idempotencyKey: key, + requestHash, + }; + records.push(record); + try { + await storage.writeAll(records); + } catch { + throw new BookingError( + 503, + "BOOKING_NOT_SAVED", + "Die Buchung konnte nicht gespeichert werden. Bitte erneut versuchen.", + ); + } + } + // Versandabsicht zuerst persistieren. Nach einem Absturz niemals blind + // noch einmal senden: SMTP und CSV bilden keine gemeinsame Transaktion. + record.emailStatus = "sending"; + try { + await storage.writeAll(records); + } catch { + record.emailStatus = "pending"; + return responseFor(record, replayed); + } + try { + await sendConfirmation(record); + record.emailStatus = "accepted"; + } catch (error) { + record.emailStatus = error.deliveryUnknown ? "unknown" : "failed"; + } + try { + await storage.writeAll(records); + } catch { + record.emailStatus = "unknown"; + } + return responseFor(record, replayed); + } + return { + book(input, key) { + const task = queue.then(() => processBooking(input, key)); + queue = task.catch(() => {}); + return task; + }, + }; +} diff --git a/src/server/services/csv-storage.js b/src/server/services/csv-storage.js new file mode 100644 index 0000000..978d146 --- /dev/null +++ b/src/server/services/csv-storage.js @@ -0,0 +1,88 @@ +import { mkdir, open, readFile, rename, rm } from "node:fs/promises"; +import { dirname } from "node:path"; +import { randomUUID } from "node:crypto"; +import { parse } from "csv-parse/sync"; +import { stringify } from "csv-stringify/sync"; + +export const columns = [ + "bookingId", + "createdAt", + "packageId", + "packageName", + "name", + "email", + "emailStatus", + "idempotencyKey", + "requestHash", +]; + +// Apostroph-Präfix schützt Tabellenprogramme. Ein vorhandenes Apostroph wird +// ebenfalls maskiert, damit Lesen/Schreiben die Originalwerte exakt erhält. +function protectCell(value) { + const text = String(value ?? ""); + return /^(?:'|[\t\r\n]|\s*[=+@-])/u.test(text) ? `'${text}` : text; +} + +function restoreCell(value) { + return value.startsWith("'") ? value.slice(1) : value; +} + +export function createCsvStorage(filePath) { + return { + async readAll() { + let content; + try { + content = await readFile(filePath, "utf8"); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } + if (!content.trim()) throw new Error("Leere Buchungsdatei"); + return parse(content, { + bom: true, + columns(header) { + if (header.join(",") !== columns.join(",")) { + throw new Error("Unbekanntes CSV-Format"); + } + return header; + }, + skip_empty_lines: true, + }).map((row) => + Object.fromEntries( + Object.entries(row).map(([key, value]) => [ + key, + restoreCell(value), + ]), + ), + ); + }, + async writeAll(records) { + await mkdir(dirname(filePath), { recursive: true }); + const temporary = `${filePath}.${randomUUID()}.tmp`; + const content = stringify( + records.map((row) => + Object.fromEntries( + columns.map((column) => [ + column, + protectCell(row[column]), + ]), + ), + ), + { header: true, columns, record_delimiter: "\r\n" }, + ); + try { + const handle = await open(temporary, "wx", 0o600); + try { + await handle.writeFile(content, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + // Atomarer Austausch im selben Verzeichnis: keine halben Zeilen. + await rename(temporary, filePath); + } finally { + await rm(temporary, { force: true }); + } + }, + }; +} diff --git a/src/server/services/email-service.js b/src/server/services/email-service.js new file mode 100644 index 0000000..5b2f00e --- /dev/null +++ b/src/server/services/email-service.js @@ -0,0 +1,74 @@ +import nodemailer from "nodemailer"; +import { paymentNotice } from "../../shared/booking-copy.js"; + +export function createEmailService( + env = process.env, + makeTransport = nodemailer.createTransport, +) { + const localHosts = ["127.0.0.1", "localhost", "::1", "mailpit"]; + const host = env.SMTP_HOST || "127.0.0.1"; + const local = localHosts.includes(host); + if (!local && env.SMTP_ALLOW_EXTERNAL !== "true") { + throw new Error( + "Externes SMTP ist gesperrt. Erst nach ausdrücklicher Freigabe SMTP_ALLOW_EXTERNAL=true setzen.", + ); + } + const port = Number(env.SMTP_PORT || (local ? 1025 : 587)); + if (!Number.isInteger(port) || port < 1 || port > 65535) + throw new Error("Ungültiger SMTP_PORT"); + if (Boolean(env.SMTP_USER) !== Boolean(env.SMTP_PASS)) + throw new Error("SMTP_USER und SMTP_PASS gemeinsam setzen"); + const transport = makeTransport({ + host, + port, + secure: env.SMTP_SECURE === "true", + requireTLS: !local, + auth: env.SMTP_USER + ? { user: env.SMTP_USER, pass: env.SMTP_PASS } + : undefined, + connectionTimeout: 10000, + greetingTimeout: 10000, + socketTimeout: 20000, + disableFileAccess: true, + disableUrlAccess: true, + }); + const from = env.SMTP_FROM || "Tri-Hub "; + return async function sendConfirmation(booking) { + let info; + try { + info = await transport.sendMail({ + from, + to: { address: booking.email, name: booking.name }, + subject: `Buchungsbestätigung ${booking.bookingId}`, + text: [ + `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) { + // Bei Verbindungsabbruch kann SMTP die Nachricht schon angenommen + // haben. Nur ausdrückliche Ablehnungen gelten sicher als Fehler. + const explicitRejection = + ["EAUTH", "EENVELOPE", "ECONNECTION", "EDNS"].includes( + error.code, + ) || Number(error.responseCode) >= 400; + error.deliveryUnknown = !explicitRejection; + throw error; + } + if ( + !info.accepted?.some( + (address) => + address.toLowerCase() === booking.email.toLowerCase(), + ) + ) { + throw new Error("Empfänger vom Mailserver nicht angenommen"); + } + }; +} diff --git a/src/shared/booking-copy.js b/src/shared/booking-copy.js new file mode 100644 index 0000000..3fdac03 --- /dev/null +++ b/src/shared/booking-copy.js @@ -0,0 +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 = + "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."; diff --git a/src/shared/packages.js b/src/shared/packages.js new file mode 100644 index 0000000..1305cad --- /dev/null +++ b/src/shared/packages.js @@ -0,0 +1,14 @@ +// 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: "demo-booking", + name: "1:1-Ernährungsberatung", + summary: + "Persönliche Begleitung rund um deine Ernährung im Alltag und beim Sport.", + testOnly: true, + }, +]; diff --git a/src/tests/bookings.test.js b/src/tests/bookings.test.js new file mode 100644 index 0000000..7f3af02 --- /dev/null +++ b/src/tests/bookings.test.js @@ -0,0 +1,427 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { createApp } from "../server/index.js"; +import { createCsvStorage } from "../server/services/csv-storage.js"; +import { createBookingService } from "../server/services/booking-service.js"; +import { createEmailService } from "../server/services/email-service.js"; +import { demoPackages } from "../shared/packages.js"; + +const input = { + packageId: demoPackages[0].id, + name: 'Test, "Person"', + email: "person@example.test", +}; + +async function setup(t, options = {}) { + const directory = await mkdtemp(join(tmpdir(), "trihub-booking-")); + const file = join(directory, "bookings.csv"); + const storage = createCsvStorage(file); + const sent = []; + const sendConfirmation = + options.sendConfirmation ?? + (async (booking) => { + sent.push({ ...booking }); + }); + const dependencies = { + catalog: demoPackages, + storage, + sendConfirmation, + ...options, + }; + const app = createApp(dependencies); + await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve)); + t.after(async () => { + app.closeAllConnections(); + await new Promise((resolve) => app.close(resolve)); + await rm(directory, { recursive: true, force: true }); + }); + const url = `http://127.0.0.1:${app.address().port}`; + async function post(body = input, key = randomUUID()) { + const response = await fetch(`${url}/api/bookings`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": key, + }, + body: JSON.stringify(body), + }); + return { status: response.status, body: await response.json() }; + } + return { post, url, file, storage, sent, dependencies }; +} + +test("Buchung steht vor Versand in CSV; Paketname kommt vom Server", async (t) => { + const fixture = await setup(t); + const result = await fixture.post({ + ...input, + packageName: "Manipuliert", + price: 0, + }); + assert.equal(result.status, 201); + assert.equal(result.body.emailStatus, "accepted"); + assert.equal(result.body.packageName, demoPackages[0].name); + assert.equal(result.body.bookingId, "TH-000001"); + assert.ok(!Number.isNaN(Date.parse(result.body.createdAt))); + const records = await fixture.storage.readAll(); + assert.equal(records.length, 1); + assert.equal(records[0].name, input.name); + assert.equal(records[0].emailStatus, "accepted"); + assert.equal(fixture.sent.length, 1); + assert.equal(fixture.sent[0].emailStatus, "sending"); + assert.ok(!("email" in result.body)); +}); + +test("Pflichtfelder, Typen, Längen, E-Mail und unbekannte IDs werden abgelehnt", async (t) => { + const { post, storage, sent } = await setup(t); + for (const body of [ + null, + [], + {}, + { ...input, name: " " }, + { ...input, name: 42 }, + { ...input, name: "a".repeat(121) }, + { ...input, name: "Test\nBcc: fremd" }, + { ...input, email: "keine-adresse" }, + { ...input, email: "a..b@example.test" }, + { ...input, email: ".person@example.test" }, + { ...input, email: "person.@example.test" }, + { ...input, email: "person@example..test" }, + { ...input, email: "person@-example.test" }, + { ...input, email: `person@${"a".repeat(64)}.test` }, + { ...input, email: "a\r\nb@example.test" }, + { ...input, email: false }, + { ...input, email: "a".repeat(255) }, + { ...input, packageId: "unknown" }, + { ...input, packageId: "../demo" }, + { ...input, packageId: 1 }, + { ...input, packageId: "a".repeat(81) }, + ]) { + const result = await post(body); + assert.equal(result.status, 400, JSON.stringify(body)); + } + assert.equal((await post(input, "invalid")).status, 400); + assert.deepEqual(await storage.readAll(), []); + assert.equal(sent.length, 0); +}); + +test("Parallele Wiederholungen und Neustart erzeugen nur eine Buchung und Mail", async (t) => { + const { post, storage, sent, dependencies } = await setup(t); + const key = randomUUID(); + const results = await Promise.all( + Array.from({ length: 8 }, () => post(input, key)), + ); + assert.equal(new Set(results.map((r) => r.body.bookingId)).size, 1); + assert.equal(results.filter((r) => r.status === 201).length, 1); + assert.equal(sent.length, 1); + assert.equal((await storage.readAll()).length, 1); + // Neue Serviceinstanz liest ausschließlich den persistenten CSV-Zustand. + const restarted = createBookingService({ ...dependencies, packages: [] }); + const replay = await restarted.book(input, key); + assert.equal(replay.status, 200); + assert.equal(replay.body.bookingId, results[0].body.bookingId); + assert.equal(sent.length, 1); + const conflict = await post({ ...input, name: "Andere Person" }, key); + assert.equal(conflict.status, 409); +}); + +test("Verschiedene gleichzeitige Buchungen behalten alle CSV-Zeilen", async (t) => { + const { post, storage } = await setup(t); + const results = await Promise.all( + Array.from({ length: 12 }, (_, i) => + post({ ...input, name: `Person ${i}` }), + ), + ); + assert.ok(results.every((r) => r.status === 201)); + const records = await storage.readAll(); + assert.equal(records.length, 12); + assert.deepEqual( + records.map((record) => record.bookingId), + Array.from( + { length: 12 }, + (_, i) => `TH-${String(i + 1).padStart(6, "0")}`, + ), + ); +}); + +test("CSV schützt Formeln und erhält Kommas, Anführungszeichen, Apostrophe und Zeilenumbrüche", async (t) => { + const { storage, file } = await setup(t); + const values = [ + '=HYPERLINK("evil")', + "+1", + "-1", + "@SUM(A1)", + "\t=1", + "\r=1", + "\n=1", + " =1", + "'Original", + 'Test, "Name"\nzweite Zeile', + ]; + const rows = values.map((name) => ({ name, packageName: name })); + await storage.writeAll(rows); + assert.deepEqual( + (await storage.readAll()).map((r) => r.name), + values, + ); + const text = await readFile(file, "utf8"); + assert.ok(text.includes("'=HYPERLINK")); + assert.ok(text.includes("''Original")); + assert.ok(text.includes('"Test, ""Name""\nzweite Zeile"')); + await storage.writeAll(await storage.readAll()); + assert.deepEqual( + (await storage.readAll()).map((r) => r.name), + values, + ); +}); + +test("Fehlgeschlagenes erstes Schreiben verhindert Versand", async (t) => { + let sent = 0; + const { post } = await setup(t, { + storage: { + readAll: async () => [], + writeAll: async () => { + throw new Error("Disk full"); + }, + }, + sendConfirmation: async () => { + sent++; + }, + }); + const result = await post(); + assert.equal(result.status, 503); + assert.equal(result.body.error.code, "BOOKING_NOT_SAVED"); + assert.equal(sent, 0); +}); + +test("Beschädigte CSV wird nicht überschrieben", async (t) => { + const { post, file, sent } = await setup(t); + await writeFile(file, "falscher,header\n1,2\n"); + const result = await post(); + assert.equal(result.status, 503); + assert.equal(result.body.error.code, "STORAGE_UNAVAILABLE"); + assert.equal(await readFile(file, "utf8"), "falscher,header\n1,2\n"); + assert.equal(sent.length, 0); +}); + +test("Mailfehler speichert Buchung; Wiederholung sendet nicht erneut", async (t) => { + let calls = 0; + const { post, storage } = await setup(t, { + sendConfirmation: async () => { + calls++; + throw new Error("SMTP rejected"); + }, + }); + const key = randomUUID(); + const first = await post(input, key); + assert.equal(first.status, 202); + assert.equal(first.body.saved, true); + assert.equal(first.body.emailStatus, "failed"); + assert.equal((await storage.readAll())[0].emailStatus, "failed"); + assert.equal((await post(input, key)).body.bookingId, first.body.bookingId); + assert.equal(calls, 1); +}); + +test("Ausstehender Versand kann nach Schreibfehler sicher fortgesetzt werden", async (t) => { + const { storage } = await setup(t); + let writes = 0; + let sends = 0; + const service = createBookingService({ + packages: demoPackages, + storage: { + readAll: () => storage.readAll(), + writeAll: async (rows) => { + if (++writes === 2) throw new Error("Disk full"); + return storage.writeAll(rows); + }, + }, + sendConfirmation: async () => { + sends++; + }, + }); + const key = randomUUID(); + const first = await service.book(input, key); + assert.equal(first.body.emailStatus, "pending"); + assert.equal(sends, 0); + const retry = await service.book(input, key); + assert.equal(retry.body.emailStatus, "accepted"); + assert.equal(retry.body.bookingId, first.body.bookingId); + assert.equal(sends, 1); +}); + +test("Status-Schreibfehler nach SMTP bleibt unklar und löst keinen Doppelversand aus", async (t) => { + const { storage } = await setup(t); + let writes = 0; + let sends = 0; + const service = createBookingService({ + packages: demoPackages, + storage: { + readAll: () => storage.readAll(), + writeAll: async (rows) => { + if (++writes === 3) throw new Error("Disk full"); + return storage.writeAll(rows); + }, + }, + sendConfirmation: async () => { + sends++; + }, + }); + const key = randomUUID(); + assert.equal((await service.book(input, key)).body.emailStatus, "unknown"); + assert.equal((await storage.readAll())[0].emailStatus, "sending"); + const restarted = createBookingService({ + storage, + packages: demoPackages, + sendConfirmation: async () => { + sends++; + }, + }); + assert.equal( + (await restarted.book(input, key)).body.emailStatus, + "unknown", + ); + assert.equal(sends, 1); +}); + +test("SMTP-Nachricht enthält Buchungsnummer und Paket; externe Hosts sind standardmäßig gesperrt", async () => { + let message; + const send = createEmailService({}, () => ({ + sendMail: async (mail) => { + message = mail; + return { accepted: [input.email] }; + }, + })); + await send({ + ...input, + bookingId: "TH-000001", + packageName: demoPackages[0].name, + }); + assert.ok(message.text.includes("TH-000001")); + assert.ok(message.text.includes(demoPackages[0].name)); + assert.equal(message.to.address, input.email); + assert.ok(message.text.includes("Zahlungsvorgang wird übersprungen")); + assert.ok(message.text.includes("keine Abbuchung")); + assert.throws( + () => createEmailService({ SMTP_HOST: "smtp.example.org" }), + /gesperrt/, + ); + const rejected = createEmailService({}, () => ({ + sendMail: async () => ({ accepted: [] }), + })); + await assert.rejects(() => rejected(input)); +}); + +test("SMTP-Verbindungsabbruch unterscheidet sich von expliziter Ablehnung", async () => { + for (const [code, expected] of [ + ["ETIMEDOUT", true], + ["EAUTH", false], + ]) { + const send = createEmailService({}, () => ({ + sendMail: async () => { + throw Object.assign(new Error("SMTP"), { code }); + }, + })); + await assert.rejects( + () => send(input), + (error) => error.deliveryUnknown === expected, + ); + } +}); + +test("HTTP-Vertrag: JSON, Größenlimit, Methoden und private Dateien", async (t) => { + const { url } = await setup(t); + const response = await fetch(`${url}/api/bookings`); + assert.equal(response.status, 405); + for (const path of [ + "/src/server/data/bookings.csv", + "/.env", + "/data/bookings.csv", + ]) { + assert.equal((await fetch(`${url}${path}`)).status, 404); + } + assert.equal( + (await fetch(`${url}/api/bookings`, { method: "POST", body: "{}" })) + .status, + 415, + ); + assert.equal( + ( + await fetch(`${url}/api/bookings`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{", + }) + ).status, + 400, + ); + assert.equal( + ( + await fetch(`${url}/api/bookings`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "a".repeat(9000) }), + }) + ).status, + 413, + ); + const catalog = await (await fetch(`${url}/api/angebote`)).json(); + assert.deepEqual(catalog.packages, demoPackages); + assert.equal((await fetch(`${url}/api/packages`)).status, 404); +}); + +test("Paketvertrag lehnt fehlende, numerische und doppelte IDs ab", () => { + for (const catalog of [ + [null], + [{ ...demoPackages[0], id: 123 }], + [{ ...demoPackages[0], id: undefined }], + [demoPackages[0], demoPackages[0]], + ]) { + assert.throws( + () => createApp({ catalog, sendConfirmation: async () => {} }), + /Paketdaten erfüllen/, + ); + } +}); + +test("Fortlaufende Nummern bleiben nach Neustart erhalten und Wiederholungen verbrauchen keine Nummer", async (t) => { + const { storage, post } = await setup(t); + const key = randomUUID(); + assert.equal((await post(input, key)).body.bookingId, "TH-000001"); + assert.equal((await post(input, key)).body.bookingId, "TH-000001"); + const restarted = createBookingService({ + storage, + packages: demoPackages, + sendConfirmation: async () => {}, + }); + assert.equal( + (await restarted.book(input, randomUUID())).body.bookingId, + "TH-000002", + ); + assert.equal((await storage.readAll()).length, 2); +}); + +test("Bestehende UUID-Buchungen bleiben unverändert und weiterhin abrufbar", async (t) => { + const { storage, post } = await setup(t); + const key = randomUUID(); + await post(input, key); + const records = await storage.readAll(); + const legacyId = randomUUID(); + records[0].bookingId = legacyId; + await storage.writeAll(records); + assert.equal((await post(input, key)).body.bookingId, legacyId); + assert.equal((await post()).body.bookingId, "TH-000001"); + assert.equal((await storage.readAll())[0].bookingId, legacyId); +}); + +test("Nummernvergabe verwendet den höchsten CSV-Wert und wächst über sechs Stellen hinaus", async (t) => { + const { storage, post } = await setup(t); + await post(); + await post(); + const records = await storage.readAll(); + records[0].bookingId = "TH-999999"; + await storage.writeAll(records); + assert.equal((await post()).body.bookingId, "TH-1000000"); +}); diff --git a/src/tests/browser/booking.spec.js b/src/tests/browser/booking.spec.js new file mode 100644 index 0000000..c9d584d --- /dev/null +++ b/src/tests/browser/booking.spec.js @@ -0,0 +1,181 @@ +import { test, expect } from "@playwright/test"; + +const pageUrl = "/booking.html#/buchen/demo-booking"; +async function fill(page) { + await page + .getByLabel("Name (Pflichtfeld)", { exact: true }) + .fill("Test Person"); + await page + .getByLabel("E-Mail-Adresse (Pflichtfeld)") + .fill("person@example.test"); +} + +test("Tastaturbedienung, Validierung und vollständige Buchung über Vite-Proxy", async ({ + page, +}) => { + await page.goto(pageUrl); + await expect( + page.getByRole("heading", { name: "1:1-Ernährungsberatung" }), + ).toBeVisible(); + await expect(page.locator(".booking__payment-notice")).toContainText( + "Zahlungsvorgang wird übersprungen", + ); + await expect(page.locator(".booking__payment-notice")).toContainText( + "keine Abbuchung", + ); + await page.keyboard.press("Tab"); + await expect( + page.getByRole("link", { name: "Zur Angebotsseite" }), + ).toBeFocused(); + await page.keyboard.press("Tab"); + await expect( + page.getByLabel("Name (Pflichtfeld)", { exact: true }), + ).toBeFocused(); + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + await page.keyboard.press("Enter"); + await expect(page.getByRole("alert")).toBeFocused(); + await expect(page.locator('[aria-invalid="true"]')).toHaveCount(2); + await page.keyboard.press("Tab"); + await page.keyboard.type("Test Person"); + await page.keyboard.press("Tab"); + await page.keyboard.type("person@example.test"); + await page.keyboard.press("Tab"); + const outline = await page + .getByRole("button", { name: "Paket buchen", exact: true }) + .evaluate((element) => getComputedStyle(element).outlineStyle); + expect(outline).toBe("solid"); + await page.keyboard.press("Enter"); + await expect(page.locator(".booking__result")).toContainText( + /Buchungsnummer: TH-\d{6,}/, + ); + await expect(page.locator(".booking__result")).toContainText( + "Zustellung ist noch nicht bestätigt", + ); + await expect( + page.getByRole("button", { name: "Buchung gespeichert" }), + ).toBeDisabled(); + await page.reload(); + await expect(page.locator(".booking__result")).toContainText( + /Buchungsnummer: TH-\d{6,}/, + ); +}); + +test("Schmales Display: Formular ohne horizontalen Überlauf", async ({ + page, +}) => { + await page.setViewportSize({ width: 320, height: 640 }); + await page.goto(pageUrl); + await fill(page); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ).toBe(true); + await expect( + page.getByRole("button", { name: "Paket buchen", exact: true }), + ).toBeVisible(); +}); + +test("Verlorene Antwort: Wiederholung nach Neuladen behält Schlüssel und Buchungsnummer", async ({ + page, +}) => { + let firstKey; + let firstBooking; + let requests = 0; + await page.route("**/api/bookings", async (route) => { + requests++; + if (requests === 1) { + firstKey = route.request().headers()["idempotency-key"]; + const response = await route.fetch(); + firstBooking = (await response.json()).bookingId; + await route.abort("failed"); + } else { + expect(route.request().headers()["idempotency-key"]).toBe(firstKey); + await route.continue(); + } + }); + await page.goto(pageUrl); + await fill(page); + await page + .getByRole("button", { name: "Paket buchen", exact: true }) + .click(); + await expect(page.getByRole("alert")).toBeVisible(); + await page.reload(); + await expect( + page.getByLabel("Name (Pflichtfeld)", { exact: true }), + ).toHaveValue("Test Person"); + await expect( + page.getByLabel("Name (Pflichtfeld)", { exact: true }), + ).toHaveAttribute("readonly", ""); + await page + .getByRole("button", { name: "Status prüfen / erneut versuchen" }) + .click(); + await expect(page.locator(".booking__result")).toContainText(firstBooking); + expect(requests).toBe(2); +}); + +test("Ladezustand verhindert doppelte Klicks; Mailfehler behauptet keinen vollständigen Erfolg", async ({ + page, +}) => { + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + await page.route("**/api/bookings", async (route) => { + await gate; + await route.fulfill({ + status: 202, + contentType: "application/json", + body: JSON.stringify({ + saved: true, + bookingId: "test-mailfehler", + emailStatus: "failed", + }), + }); + }); + await page.goto(pageUrl); + await fill(page); + await page + .getByRole("button", { name: "Paket buchen", exact: true }) + .click(); + await expect( + page.getByRole("button", { name: "Buchung wird verarbeitet …" }), + ).toBeDisabled(); + release(); + await expect(page.locator(".booking__result")).toContainText( + "Bestätigungsversand ist fehlgeschlagen", + ); + await expect(page.locator(".booking__result")).toContainText( + "Bitte nicht erneut buchen", + ); + await expect( + page.getByRole("button", { name: "Buchung gespeichert" }), + ).toBeDisabled(); +}); + +test("Fehlende und unbekannte Paket-ID zeigen kein Formular", async ({ + page, +}) => { + await page.goto("/booking.html"); + await expect(page.getByText("Bitte wähle zuerst ein Paket")).toBeVisible(); + await expect(page.locator("form")).toHaveCount(0); + await page.goto("/booking.html#/buchen/unbekannt"); + await expect( + page.getByText("Dieses Paket ist nicht verfügbar"), + ).toBeVisible(); + await expect(page.locator("form")).toHaveCount(0); +}); + +test("Vite liefert Serverdateien und Umgebungsdateien nicht aus", async ({ + request, +}) => { + for (const path of [ + "/src/server/index.js", + "/src/server/data/.gitkeep", + "/.env.example", + ]) { + const response = await request.get(path); + expect([403, 404]).toContain(response.status()); + } +}); diff --git a/src/tests/helpers/browser-server.js b/src/tests/helpers/browser-server.js new file mode 100644 index 0000000..f9f02d8 --- /dev/null +++ b/src/tests/helpers/browser-server.js @@ -0,0 +1,23 @@ +// Ausschließlich Testserver: temporäre CSV und simulierter Versand. +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createApp } from "../../server/index.js"; +import { createCsvStorage } from "../../server/services/csv-storage.js"; +import { demoPackages } from "../../shared/packages.js"; + +const directory = await mkdtemp(join(tmpdir(), "trihub-browser-")); +const server = createApp({ + catalog: demoPackages, + storage: createCsvStorage(join(directory, "bookings.csv")), + sendConfirmation: async () => {}, +}); +server.listen(3000, "127.0.0.1"); +async function stop() { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + await rm(directory, { recursive: true, force: true }); + process.exit(0); +} +process.on("SIGTERM", stop); +process.on("SIGINT", stop); diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..665b58f --- /dev/null +++ b/vite.config.js @@ -0,0 +1,29 @@ +import { defineConfig } from "vite"; +import { fileURLToPath } from "node:url"; + +export default defineConfig({ + server: { + proxy: { "/api": "http://127.0.0.1:3000" }, + fs: { + // Auch Vites direkten Dateizugriff auf das Projekt absichern. + deny: [ + ".env", + ".env.*", + "*.{crt,pem}", + "**/.git/**", + "**/server/**", + "**/tests/**", + ], + }, + }, + build: { + rollupOptions: { + input: { + main: fileURLToPath(new URL("./index.html", import.meta.url)), + booking: fileURLToPath( + new URL("./booking.html", import.meta.url), + ), + }, + }, + }, +}); From 5851668bd8a456d7fa5d3f31fc3cda37ae3ba065 Mon Sep 17 00:00:00 2001 From: Majd Ibrahim <“3021285@stud.hs-mannheim.de”> Date: Thu, 24 Sep 2026 16:33:09 +0000 Subject: [PATCH 02/13] US2.1: Datenstruktur, Komponenten-Aufbau, Styling, Testing --- src/styles/packages.css | 123 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/styles/packages.css diff --git a/src/styles/packages.css b/src/styles/packages.css new file mode 100644 index 0000000..d02cbe3 --- /dev/null +++ b/src/styles/packages.css @@ -0,0 +1,123 @@ +:root { + --bg-dark: #0d0d1a; + --bg-card: #1a1a2e; + --accent-teal: #10b981; + --text-white: #ffffff; + --text-lightgray: #f8fafc; + --text-muted: #94a3b8; + + --font-display: "Playfair Display", serif; + --font-body: "Inter", system-ui, sans-serif; +} + +.packages-section { + background-color: var(--bg-dark); + padding: 4rem 2rem; + font-family: var(--font-body); + color: var(--text-lightgray); +} + +.packages-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 2rem; + max-width: 1200px; + margin: 0 auto; +} + +.package-card { + background-color: var(--bg-card); + border-radius: 12px; + padding: 2rem; + display: flex; + flex-direction: column; + position: relative; + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.package-type-container { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.package-type { + color: var(--text-muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.package-highlight { + background-color: rgba(16, 185, 129, 0.1); + color: var(--accent-teal); + font-size: 0.7rem; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-weight: 600; + text-transform: uppercase; +} + +.package-title { + font-family: var(--font-display); + font-size: 1.75rem; + color: var(--text-white); + margin-bottom: 1rem; + line-height: 1.2; +} + +.package-desc { + color: var(--text-lightgray); + font-size: 0.95rem; + line-height: 1.5; + margin-bottom: 2rem; +} + +.package-details-box { + background-color: rgba(255, 255, 255, 0.03); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 2rem; + flex-grow: 1; /* Schiebt den Button immer ganz nach unten */ +} + +.detail-item { + margin-bottom: 1.25rem; +} + +.detail-item:last-child { + margin-bottom: 0; +} + +.detail-title { + color: var(--text-white); + font-weight: 600; + font-size: 0.9rem; + margin-bottom: 0.5rem; +} + +.detail-text { + color: var(--text-muted); + font-size: 0.85rem; + line-height: 1.5; +} + +.package-btn { + display: block; + text-align: center; + background-color: transparent; + color: var(--text-white); + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 1rem; + border-radius: 8px; + text-decoration: none; + font-weight: 500; + transition: all 0.2s ease; +} + +.package-btn:hover { + background-color: rgba(255, 255, 255, 0.05); + border-color: var(--accent-teal); + color: var(--accent-teal); +} From ef0745e59df8a4bdf8535191b88dc0b4032bfec7 Mon Sep 17 00:00:00 2001 From: Majd Ibrahim <“3021285@stud.hs-mannheim.de”> Date: Thu, 24 Sep 2026 16:36:53 +0000 Subject: [PATCH 03/13] US2.1: Datenstruktur, Komponenten-Aufbau, Styling, Testing --- .../angebote/angebotsuebersicht.html | 11 ++++ src/components/angebote/angebotsuebersicht.js | 58 +++++++++++++++++++ src/shared/packages.js | 44 ++++++++++++++ src/tests/angebotsuebersicht.test.js | 36 ++++++++++++ src/tests/packages.test.js | 19 ++++++ 5 files changed, 168 insertions(+) create mode 100644 src/components/angebote/angebotsuebersicht.html create mode 100644 src/components/angebote/angebotsuebersicht.js create mode 100644 src/shared/packages.js create mode 100644 src/tests/angebotsuebersicht.test.js create mode 100644 src/tests/packages.test.js diff --git a/src/components/angebote/angebotsuebersicht.html b/src/components/angebote/angebotsuebersicht.html new file mode 100644 index 0000000..58bbf85 --- /dev/null +++ b/src/components/angebote/angebotsuebersicht.html @@ -0,0 +1,11 @@ +
+
+ +

Unsere Ernährungs-Pakete

+
+ + +
+ +
+
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 = ` +
+ +
+ ${pkg.type} + ${highlightHtml} +
+ +

${pkg.title}

+

${pkg.description}

+ +
+
+
Fokus
+
${pkg.fokus}
+
+
+
Anamnese
+
${pkg.anamnese}
+
+
+
Begleitung
+
${pkg.begleitung}
+
+
+ + + ${pkg.buttonText} + +
+ `; + + // 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 = ` +
+
+ ${pkg.type} + ${highlightHtml} +
+

${pkg.title}

+

${pkg.description}

+ + ${pkg.buttonText} +
+ `; + container.innerHTML += cardHtml; + }); + } catch (error) { + console.error("Fehler beim Laden der Angebote vom Backend:", error); + container.innerHTML = "

Angebote konnten nicht geladen werden.

"; + } +} diff --git a/src/tests/packages.test.js b/src/tests/packages.test.js new file mode 100644 index 0000000..d957691 --- /dev/null +++ b/src/tests/packages.test.js @@ -0,0 +1,19 @@ +import { packages } from "../shared/packages.js"; + +describe("Angebots-Daten (packages.js)", () => { + test("sollte ein Array mit Angeboten exportieren", () => { + expect(Array.isArray(nutritionPackages)).toBe(true); + expect(nutritionPackages.length).toBeGreaterThan(0); + }); + + test("jedes Paket muss eine ID und einen Titel für das Routing besitzen", () => { + nutritionPackages.forEach((pkg) => { + expect(pkg).toHaveProperty("id"); + expect(pkg).toHaveProperty("title"); + expect(pkg).toHaveProperty("buttonText"); + // Prüft, ob die ID das korrekte Format für die URLs hat + expect(typeof pkg.id).toBe("string"); + expect(pkg.id.length).toBeGreaterThan(0); + }); + }); +}); From 762f21dfa132d9aaf1d8b5aa2f838da8b2ddfb92 Mon Sep 17 00:00:00 2001 From: Majd Ibrahim <“3021285@stud.hs-mannheim.de”> Date: Fri, 25 Sep 2026 11:32:18 +0000 Subject: [PATCH 04/13] packagestruktur angepasst --- .../angebote/angebotsuebersicht.html => angebotsuebersicht.html | 0 src/{components => features}/angebote/angebotsuebersicht.js | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/components/angebote/angebotsuebersicht.html => angebotsuebersicht.html (100%) rename src/{components => features}/angebote/angebotsuebersicht.js (96%) diff --git a/src/components/angebote/angebotsuebersicht.html b/angebotsuebersicht.html similarity index 100% rename from src/components/angebote/angebotsuebersicht.html rename to angebotsuebersicht.html diff --git a/src/components/angebote/angebotsuebersicht.js b/src/features/angebote/angebotsuebersicht.js similarity index 96% rename from src/components/angebote/angebotsuebersicht.js rename to src/features/angebote/angebotsuebersicht.js index 01a7e7e..765dd62 100644 --- a/src/components/angebote/angebotsuebersicht.js +++ b/src/features/angebote/angebotsuebersicht.js @@ -47,7 +47,7 @@ export function renderAngebotsuebersicht() { - ${pkg.buttonText} + ${pkg.buttonText} `; From 4ed3ab54e816b6e6089f2991baa73099d54591a8 Mon Sep 17 00:00:00 2001 From: Marvin Gemlin Date: Fri, 25 Sep 2026 11:53:07 +0000 Subject: [PATCH 05/13] =?UTF-8?q?Angebots=C3=BCbersicht=20und=20Buchung=20?= =?UTF-8?q?verbinden,=20Mailpit=20einrichten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .devcontainer/Dockerfile | 5 ++ .devcontainer/devcontainer.json | 12 +++- .devcontainer/setup.sh | 10 ++++ README.md | 59 +++++++++++++++++++ angebotsuebersicht.html | 30 ++++++---- docs/booking.md | 22 ++++--- index.html | 5 +- package.json | 5 +- src/features/angebote/angebote-entry.js | 5 ++ src/features/angebote/angebotsuebersicht.js | 6 +- src/features/booking/booking-page.js | 2 +- src/shared/packages.js | 22 +++++-- src/styles/packages.css | 2 +- src/tests/angebotsuebersicht.test.js | 64 ++++++++++----------- src/tests/browser/booking.spec.js | 52 +++++++++++++++++ src/tests/helpers/browser-server.js | 4 +- src/tests/packages.test.js | 43 ++++++++------ vite.config.js | 3 + 18 files changed, 259 insertions(+), 92 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/setup.sh create mode 100644 README.md create mode 100644 src/features/angebote/angebote-entry.js diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..460e349 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,5 @@ +# 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 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c2df1da..1f24ae9 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,15 +1,18 @@ { "name": "Ernaehrungsberatung", - "image": "mcr.microsoft.com/devcontainers/javascript-node:24-bookworm", "remoteUser": "node", - "forwardPorts": [5173], + "forwardPorts": [5173, 8025], "portsAttributes": { "5173": { "label": "Webseite", "onAutoForward": "notify" + }, + "8025": { + "label": "Mailpit – Testmails", + "onAutoForward": "notify" } }, - "postCreateCommand": "if [ -f package-lock.json ]; then npm ci; else npm install; fi", + "postCreateCommand": "bash .devcontainer/setup.sh", "customizations": { "vscode": { "extensions": [ @@ -17,5 +20,8 @@ "EditorConfig.EditorConfig" ] } + }, + "build": { + "dockerfile": "Dockerfile" } } diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh new file mode 100644 index 0000000..0afa5c9 --- /dev/null +++ b/.devcontainer/setup.sh @@ -0,0 +1,10 @@ +#!/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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ca7144 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# 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. diff --git a/angebotsuebersicht.html b/angebotsuebersicht.html index 58bbf85..517d32f 100644 --- a/angebotsuebersicht.html +++ b/angebotsuebersicht.html @@ -1,11 +1,19 @@ -
-
- -

Unsere Ernährungs-Pakete

-
- - -
- -
-
+ + + + + + Ernährungs-Pakete – Tri-Hub + + +
Zur Startseite
+
+

Unsere Ernährungs-Pakete

+
+
+ + + 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.

- Beratung kennenlernen + Beratung kennenlernen

Deine persönliche Beratung

- Hier stellen wir bald unseren Ablauf und unser Angebot vor. + Entdecke unsere Pakete für deine Ernährung im Alltag und + Sport.

diff --git a/package.json b/package.json index 2af1f0a..d903bdd 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,9 @@ "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" + "test": "node --test src/tests/*.test.js", + "test:browser": "playwright test", + "dev:mail": "mailpit --listen 127.0.0.1:8025 --smtp 127.0.0.1:1025 --disable-version-check" }, "devDependencies": { "@playwright/test": "^1.63.0", diff --git a/src/features/angebote/angebote-entry.js b/src/features/angebote/angebote-entry.js new file mode 100644 index 0000000..43eb523 --- /dev/null +++ b/src/features/angebote/angebote-entry.js @@ -0,0 +1,5 @@ +import "../../styles/styles.css"; +import "../../styles/packages.css"; +import { renderAngebotsuebersicht } from "./angebotsuebersicht.js"; + +renderAngebotsuebersicht(); diff --git a/src/features/angebote/angebotsuebersicht.js b/src/features/angebote/angebotsuebersicht.js index 765dd62..02f7852 100644 --- a/src/features/angebote/angebotsuebersicht.js +++ b/src/features/angebote/angebotsuebersicht.js @@ -28,8 +28,8 @@ export function renderAngebotsuebersicht() { ${highlightHtml} -

${pkg.title}

-

${pkg.description}

+

${pkg.name}

+

${pkg.summary}

@@ -47,7 +47,7 @@ export function renderAngebotsuebersicht() {
- ${pkg.buttonText} + ${pkg.buttonText}
`; diff --git a/src/features/booking/booking-page.js b/src/features/booking/booking-page.js index e15a656..420d238 100644 --- a/src/features/booking/booking-page.js +++ b/src/features/booking/booking-page.js @@ -9,7 +9,7 @@ export function mountBookingPage(root, { packageId } = {}) { const { signal } = controller; root.innerHTML = `
- Zur Angebotsseite + Zur Angebotsseite

Paket buchen

Paket wird geladen …

diff --git a/src/shared/packages.js b/src/shared/packages.js index 76dac1a..7615755 100644 --- a/src/shared/packages.js +++ b/src/shared/packages.js @@ -2,8 +2,8 @@ export const packages = [ { id: "ernaehrung-starter", type: "STARTER", - title: "Für den sicheren Einstieg in die Sporternährung", - description: + name: "Für den sicheren Einstieg in die Sporternährung", + summary: "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: @@ -16,8 +16,8 @@ export const packages = [ { id: "ernaehrung-standard", type: "STANDARD", - title: "Für Best Ager mit ersten Erfahrungen und klaren Zielen", - description: + 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: @@ -30,8 +30,8 @@ export const packages = [ { id: "ernaehrung-premium", type: "PREMIUM", - title: "Für maximale Individualität mit klarem Fokus", - description: + 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: @@ -42,3 +42,13 @@ export const packages = [ highlight: null, }, ]; + +// Nur für lokale Demonstrationen und isolierte Tests. +export const demoPackages = [ + { + id: "demo-booking", + name: "1:1-Ernährungsberatung", + summary: "Testpaket für den simulierten Buchungsablauf.", + testOnly: true, + }, +]; diff --git a/src/styles/packages.css b/src/styles/packages.css index d02cbe3..da50358 100644 --- a/src/styles/packages.css +++ b/src/styles/packages.css @@ -19,7 +19,7 @@ .packages-container { display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); gap: 2rem; max-width: 1200px; margin: 0 auto; diff --git a/src/tests/angebotsuebersicht.test.js b/src/tests/angebotsuebersicht.test.js index 08dfee6..3a8a51c 100644 --- a/src/tests/angebotsuebersicht.test.js +++ b/src/tests/angebotsuebersicht.test.js @@ -1,36 +1,30 @@ -export async function renderAngebotsuebersicht() { - const container = document.getElementById("packages-container"); - if (!container) return; +import test from "node:test"; +import assert from "node:assert/strict"; +import { createApp } from "../server/index.js"; +import { packages } from "../shared/packages.js"; - 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 = ` -
-
- ${pkg.type} - ${highlightHtml} -
-

${pkg.title}

-

${pkg.description}

- - ${pkg.buttonText} -
- `; - container.innerHTML += cardHtml; - }); - } catch (error) { - console.error("Fehler beim Laden der Angebote vom Backend:", error); - container.innerHTML = "

Angebote konnten nicht geladen werden.

"; - } -} +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, + testOnly: false, + })), + ); +}); diff --git a/src/tests/browser/booking.spec.js b/src/tests/browser/booking.spec.js index c9d584d..551c803 100644 --- a/src/tests/browser/booking.spec.js +++ b/src/tests/browser/booking.spec.js @@ -1,5 +1,7 @@ import { test, expect } from "@playwright/test"; +import { packages } from "../../shared/packages.js"; + const pageUrl = "/booking.html#/buchen/demo-booking"; async function fill(page) { await page @@ -179,3 +181,53 @@ test("Vite liefert Serverdateien und Umgebungsdateien nicht aus", async ({ 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(`booking\\.html#/buchen/${pkg.id}$`), + ); + await expect( + page.getByRole("heading", { name: pkg.name, exact: true }), + ).toBeVisible(); + await expect(page.locator(".booking__summary")).toHaveText(pkg.summary); + 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); +}); diff --git a/src/tests/helpers/browser-server.js b/src/tests/helpers/browser-server.js index f9f02d8..395967c 100644 --- a/src/tests/helpers/browser-server.js +++ b/src/tests/helpers/browser-server.js @@ -4,11 +4,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createApp } from "../../server/index.js"; import { createCsvStorage } from "../../server/services/csv-storage.js"; -import { demoPackages } from "../../shared/packages.js"; +import { packages, demoPackages } from "../../shared/packages.js"; const directory = await mkdtemp(join(tmpdir(), "trihub-browser-")); const server = createApp({ - catalog: demoPackages, + catalog: [...packages, ...demoPackages], storage: createCsvStorage(join(directory, "bookings.csv")), sendConfirmation: async () => {}, }); diff --git a/src/tests/packages.test.js b/src/tests/packages.test.js index d957691..01fcae2 100644 --- a/src/tests/packages.test.js +++ b/src/tests/packages.test.js @@ -1,19 +1,28 @@ -import { packages } from "../shared/packages.js"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { packages, demoPackages } from "../shared/packages.js"; +import { createApp } from "../server/index.js"; -describe("Angebots-Daten (packages.js)", () => { - test("sollte ein Array mit Angeboten exportieren", () => { - expect(Array.isArray(nutritionPackages)).toBe(true); - expect(nutritionPackages.length).toBeGreaterThan(0); - }); - - test("jedes Paket muss eine ID und einen Titel für das Routing besitzen", () => { - nutritionPackages.forEach((pkg) => { - expect(pkg).toHaveProperty("id"); - expect(pkg).toHaveProperty("title"); - expect(pkg).toHaveProperty("buttonText"); - // Prüft, ob die ID das korrekte Format für die URLs hat - expect(typeof pkg.id).toBe("string"); - expect(pkg.id.length).toBeGreaterThan(0); - }); - }); +test("Angebote erfüllen den gemeinsamen Vertrag von Übersicht und Buchung", () => { + assert.ok(packages.length > 0); + assert.doesNotThrow(() => + createApp({ + catalog: [...packages, ...demoPackages], + 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()); + } + } }); diff --git a/vite.config.js b/vite.config.js index 665b58f..58bccce 100644 --- a/vite.config.js +++ b/vite.config.js @@ -19,6 +19,9 @@ export default defineConfig({ build: { rollupOptions: { input: { + angebote: fileURLToPath( + new URL("./angebotsuebersicht.html", import.meta.url), + ), main: fileURLToPath(new URL("./index.html", import.meta.url)), booking: fileURLToPath( new URL("./booking.html", import.meta.url), From e84d8ae6b3bf4c2a329e9f61cb216923e1f3cab2 Mon Sep 17 00:00:00 2001 From: Majd Ibrahim <“3021285@stud.hs-mannheim.de”> Date: Fri, 25 Sep 2026 13:03:04 +0000 Subject: [PATCH 06/13] packages erweitert und swagger erstellt --- paket-details.html | 30 ++ src/features/angebote/angebotsuebersicht.js | 2 +- src/features/angebote/paket-details.js | 132 ++++++ src/server/swagger.json | 481 ++++++++++++++++++++ src/shared/packagesdetails.js | 149 ++++++ 5 files changed, 793 insertions(+), 1 deletion(-) create mode 100644 paket-details.html create mode 100644 src/features/angebote/paket-details.js create mode 100644 src/server/swagger.json create mode 100644 src/shared/packagesdetails.js diff --git a/paket-details.html b/paket-details.html new file mode 100644 index 0000000..c79f82d --- /dev/null +++ b/paket-details.html @@ -0,0 +1,30 @@ + + + + + + + Paket-Details – Tri-Hub Ernährungsberatung + + + + + + + +
+ ← Zurück zur Angebotsübersicht +
+ +
+
+ +
+
+ + + + + diff --git a/src/features/angebote/angebotsuebersicht.js b/src/features/angebote/angebotsuebersicht.js index 02f7852..77afcf9 100644 --- a/src/features/angebote/angebotsuebersicht.js +++ b/src/features/angebote/angebotsuebersicht.js @@ -47,7 +47,7 @@ export function renderAngebotsuebersicht() {
- ${pkg.buttonText} + ${pkg.buttonText} `; diff --git a/src/features/angebote/paket-details.js b/src/features/angebote/paket-details.js new file mode 100644 index 0000000..790f481 --- /dev/null +++ b/src/features/angebote/paket-details.js @@ -0,0 +1,132 @@ +// src/features/angebote/paket-details.js +import { packagesDetails } from "../../shared/packagesdetails.js"; + +document.addEventListener("DOMContentLoaded", async () => { + 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) { + container.innerHTML = ` +
+

Paket nicht gefunden

+

Bitte wähle ein gültiges Angebot aus der Übersicht.

+ Zur Übersicht +
+ `; + return; + } + + // 3. Optional: Varianten-Auswahl rendern (für das Premium-Paket) + const variantsHtml = pkg.variants + ? `
+

Wähle deine Variante

+ +
` + : ""; + + // 4. Detailseite im Tri-Hub-Stil rendern + container.innerHTML = ` +
+
+ ${pkg.type} · ${pkg.durationLabel} + ${pkg.badge} +
+ +

${pkg.title}

+

${pkg.subtitle}

+ +
+ ${pkg.price.toFixed(2)} € + inkl. ${pkg.taxRate}% MwSt. (${pkg.billingInterval}) +
+ + ${variantsHtml} + +
+
+

Für wen ist dieses Paket ideal?

+
    + ${pkg.targetGroup.map((item) => `
  • ${item}
  • `).join("")} +
+
+ +
+

Das ist im Paket enthalten

+
    + ${pkg.includedFeatures.map((feat) => `
  • ${feat}
  • `).join("")} +
+
+ +
+

So läuft deine Begleitung ab

+ ${pkg.processSteps + .map( + (s) => ` +
+ ${s.step}: ${s.text} +
+ `, + ) + .join("")} +
+
+ + +
+ `; + + // 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 ? `

Wähle deine Variante

@@ -34,7 +29,6 @@ document.addEventListener("DOMContentLoaded", async () => {
` : ""; - // 4. Detailseite im Tri-Hub-Stil rendern container.innerHTML = `
@@ -87,7 +81,6 @@ document.addEventListener("DOMContentLoaded", async () => {
`; - // 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) { @@ -100,33 +93,10 @@ document.addEventListener("DOMContentLoaded", async () => { }); } - // 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}`; + const params = new URLSearchParams({ id: pkg.id }); + if (variantSelect) params.set("variant", variantSelect.value); + window.location.href = `booking.html?${params}`; }); }); diff --git a/src/features/booking/booking-api.js b/src/features/booking/booking-api.js index 65d8613..ba978d6 100644 --- a/src/features/booking/booking-api.js +++ b/src/features/booking/booking-api.js @@ -33,10 +33,6 @@ async function request(path, options = {}) { return body; } -export function loadPackages(signal) { - return request("/api/angebote", { signal }); -} - export function submitBooking(payload, idempotencyKey) { return request("/api/bookings", { method: "POST", @@ -47,3 +43,12 @@ export function submitBooking(payload, idempotencyKey) { 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, + }); +} diff --git a/src/features/booking/booking-entry.js b/src/features/booking/booking-entry.js index 81d6433..54c7513 100644 --- a/src/features/booking/booking-entry.js +++ b/src/features/booking/booking-entry.js @@ -5,9 +5,12 @@ 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] }); + const params = new URLSearchParams(location.search); + cleanup = mountBookingPage(root, { + packageId: params.get("id") || match?.[1], + variantId: params.get("variant") || undefined, + }); } window.addEventListener("hashchange", render); render(); diff --git a/src/features/booking/booking-page.js b/src/features/booking/booking-page.js index 420d238..30bca44 100644 --- a/src/features/booking/booking-page.js +++ b/src/features/booking/booking-page.js @@ -1,10 +1,9 @@ -import { loadPackages, submitBooking } from "./booking-api.js"; +import { previewBooking, submitBooking } from "./booking-api.js"; import "./booking.css"; +import { orderSummaryRows } from "../../shared/order-summary.js"; 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 } = {}) { +export function mountBookingPage(root, { packageId, variantId } = {}) { const controller = new AbortController(); const { signal } = controller; root.innerHTML = ` @@ -24,20 +23,22 @@ export function mountBookingPage(root, { packageId } = {}) { return; } try { - const data = await loadPackages(signal); + const selected = await previewBooking(packageId, variantId, signal); if (signal.aborted) return; - const selected = data.packages.find( - (entry) => entry.id === packageId, - ); - if (!selected) { - intro.textContent = - "Dieses Paket ist nicht verfügbar. Bitte wähle ein Paket auf der Angebotsseite aus."; - return; - } intro.remove(); renderForm(selected); - } catch { + } catch (error) { if (signal.aborted) return; + if ( + [ + "UNKNOWN_PACKAGE", + "INVALID_VARIANT", + "INVALID_INPUT", + ].includes(error.code) + ) { + intro.textContent = error.message; + return; + } intro.textContent = "Das Paket konnte nicht geladen werden. Bitte prüfe deine Verbindung und versuche es erneut."; const retry = document.createElement("button"); @@ -61,6 +62,11 @@ export function mountBookingPage(root, { packageId } = {}) {

+

+
+

Enthaltene Leistungen

+
    +
    +>>>>>>> dev diff --git a/paket-details.html b/paket-details.html index 36844f5..9ec9e46 100644 --- a/paket-details.html +++ b/paket-details.html @@ -1,32 +1,372 @@ - - - - - Paket-Details – Tri-Hub Ernährungsberatung - - - - - + - -
    - ← Zurück zur Angebotsübersicht -
    + + + + + + Paket-Details – Tri-Hub Ernährungsberatung -
    -
    - -
    -
    + + + + + + + + + + +
    +
    + +
    +
    + + + + - - diff --git a/src/features/angebote/paket-details.js b/src/features/angebote/paket-details.js index d3ad192..7f7cccd 100644 --- a/src/features/angebote/paket-details.js +++ b/src/features/angebote/paket-details.js @@ -1,4 +1,5 @@ import { packagesDetails } from "../../shared/packagesdetails.js"; +import { mountBookingPage } from "../booking/booking-page.js"; document.addEventListener("DOMContentLoaded", () => { const container = document.getElementById("package-detail-container"); @@ -11,92 +12,188 @@ document.addEventListener("DOMContentLoaded", () => { if (!pkg) { container.innerHTML = ` -
    -

    Paket nicht gefunden

    -

    Bitte wähle ein gültiges Angebot aus der Übersicht.

    - Zur Übersicht -
    - `; +
    +

    Paket nicht gefunden

    +

    Bitte wähle ein gültiges Angebot aus der Übersicht.

    + + Zur Übersicht + +
    + `; return; } - const variantsHtml = pkg.variants - ? `
    -

    Wähle deine Variante

    - -
    ` + const variantsHtml = pkg.variants?.length + ? ` +
    +

    Wähle deine Variante

    + +
    + ` : ""; container.innerHTML = ` -
    -
    - ${pkg.type} · ${pkg.durationLabel} - ${pkg.badge} -
    - -

    ${pkg.title}

    -

    ${pkg.subtitle}

    - -
    - ${pkg.price.toFixed(2)} € - inkl. ${pkg.taxRate}% MwSt. (${pkg.billingInterval}) -
    - - ${variantsHtml} - -
    -
    -

    Für wen ist dieses Paket ideal?

    -
      - ${pkg.targetGroup.map((item) => `
    • ${item}
    • `).join("")} -
    -
    - -
    -

    Das ist im Paket enthalten

    -
      - ${pkg.includedFeatures.map((feat) => `
    • ${feat}
    • `).join("")} -
    -
    - -
    -

    So läuft deine Begleitung ab

    - ${pkg.processSteps - .map( - (s) => ` -
    - ${s.step}: ${s.text} +
    +
    + + ${pkg.type} · ${pkg.durationLabel} + + ${ + pkg.badge + ? `${pkg.badge}` + : "" + }
    - `, - ) - .join("")} -
    -
    - -
    - `; +

    ${pkg.title}

    +

    ${pkg.subtitle}

    + +
    + + ${pkg.price.toFixed(2)} € + + + inkl. ${pkg.taxRate}% MwSt. (${pkg.billingInterval}) + +
    + + ${variantsHtml} + +
    +
    +

    + Für wen ist dieses Paket ideal? +

    +
      + ${pkg.targetGroup + .map((item) => `
    • ${item}
    • `) + .join("")} +
    +
    + +
    +

    + Das ist im Paket enthalten +

    +
      + ${pkg.includedFeatures + .map((feature) => `
    • ${feature}
    • `) + .join("")} +
    +
    + +
    +

    + So läuft deine Begleitung ab +

    + ${pkg.processSteps + .map( + (step) => ` +
    + ${step.step}: + + ${step.text} + +
    + `, + ) + .join("")} +
    +
    + + + +

    + Dieser Buchungsablauf ist eine Simulation. Es erfolgt keine + Abbuchung und keine verbindliche Beratungsbuchung. +

    + +

    +
    +
    + `; 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)} €`; + const bookingBtn = document.getElementById("proceed-to-booking-btn"); + const bookingRoot = document.getElementById("booking-root"); + const bookingStatus = document.getElementById("booking-status"); + + let cleanupBooking; + + function prepareBooking() { + cleanupBooking?.(); + bookingStatus.textContent = ""; + + cleanupBooking = mountBookingPage(bookingRoot, { + packageId: pkg.id, + variantId: variantSelect?.value, }); } - const bookingBtn = document.getElementById("proceed-to-booking-btn"); + function updatePrice() { + if (!variantSelect) return; + + const selectedOption = variantSelect.selectedOptions[0]; + const price = Number(selectedOption.dataset.price); + + displayedPrice.textContent = `${price.toFixed(2)} €`; + } + + updatePrice(); + prepareBooking(); + + variantSelect?.addEventListener("change", () => { + updatePrice(); + prepareBooking(); + }); + bookingBtn.addEventListener("click", () => { - const params = new URLSearchParams({ id: pkg.id }); - if (variantSelect) params.set("variant", variantSelect.value); - window.location.href = `booking.html?${params}`; + const dialog = bookingRoot.querySelector(".booking__dialog"); + + if (!dialog) { + bookingStatus.textContent = + "Das Buchungsformular ist noch nicht verfügbar. Bitte beachte den Ladehinweis im Buchungsbereich."; + return; + } + + if (dialog.open) return; + + bookingStatus.textContent = ""; + + dialog.addEventListener( + "close", + () => { + bookingBtn.focus(); + }, + { once: true }, + ); + + dialog.showModal(); + dialog.querySelector('[name="name"]').focus(); }); }); diff --git a/src/main.js b/src/main.js index a8ccb3d..83e72f9 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,7 @@ import "./styles/styles.css"; +<<<<<<< HEAD +======= import "./components/navigationsbar/navigation.js"; +>>>>>>> dev document.querySelector("#year").textContent = new Date().getFullYear(); diff --git a/src/styles/packages.css b/src/styles/packages.css index da50358..c40e4f5 100644 --- a/src/styles/packages.css +++ b/src/styles/packages.css @@ -1,123 +1,10 @@ -:root { - --bg-dark: #0d0d1a; - --bg-card: #1a1a2e; - --accent-teal: #10b981; - --text-white: #ffffff; - --text-lightgray: #f8fafc; - --text-muted: #94a3b8; - - --font-display: "Playfair Display", serif; - --font-body: "Inter", system-ui, sans-serif; +#booking-root .booking__open { + display: none; } - -.packages-section { - background-color: var(--bg-dark); - padding: 4rem 2rem; - font-family: var(--font-body); - color: var(--text-lightgray); -} - -.packages-container { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); - gap: 2rem; - max-width: 1200px; - margin: 0 auto; -} - -.package-card { - background-color: var(--bg-card); - border-radius: 12px; - padding: 2rem; - display: flex; - flex-direction: column; - position: relative; - border: 1px solid rgba(255, 255, 255, 0.05); -} - -.package-type-container { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; -} - -.package-type { - color: var(--text-muted); - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.1em; -} - -.package-highlight { - background-color: rgba(16, 185, 129, 0.1); - color: var(--accent-teal); - font-size: 0.7rem; - padding: 0.25rem 0.75rem; - border-radius: 9999px; - font-weight: 600; - text-transform: uppercase; -} - -.package-title { - font-family: var(--font-display); - font-size: 1.75rem; - color: var(--text-white); - margin-bottom: 1rem; - line-height: 1.2; -} - -.package-desc { - color: var(--text-lightgray); - font-size: 0.95rem; - line-height: 1.5; - margin-bottom: 2rem; -} - -.package-details-box { - background-color: rgba(255, 255, 255, 0.03); - border-radius: 8px; - padding: 1.5rem; - margin-bottom: 2rem; - flex-grow: 1; /* Schiebt den Button immer ganz nach unten */ -} - -.detail-item { - margin-bottom: 1.25rem; -} - -.detail-item:last-child { - margin-bottom: 0; -} - -.detail-title { - color: var(--text-white); - font-weight: 600; - font-size: 0.9rem; - margin-bottom: 0.5rem; -} - -.detail-text { - color: var(--text-muted); - font-size: 0.85rem; - line-height: 1.5; -} - -.package-btn { - display: block; - text-align: center; - background-color: transparent; - color: var(--text-white); - border: 1px solid rgba(255, 255, 255, 0.2); - padding: 1rem; - border-radius: 8px; - text-decoration: none; - font-weight: 500; - transition: all 0.2s ease; -} - -.package-btn:hover { - background-color: rgba(255, 255, 255, 0.05); - border-color: var(--accent-teal); - color: var(--accent-teal); +/* Zusätzliche Booking-Übersicht ausblenden, Dialog erhalten. */ +#booking-root > .booking > h2, +#booking-root .booking__package, +#booking-root .booking__payment, +#booking-root .booking__open { + display: none; } From 8fb2044a8168ec837b6557fd0286600555ff3c25 Mon Sep 17 00:00:00 2001 From: Marvin Gemlin Date: Mon, 28 Sep 2026 22:07:11 +0000 Subject: [PATCH 13/13] paket seiten angepasst und booking mit telefonvalidierung integriert --- angebotsuebersicht.html | 313 ++------------- package-lock.json | 7 + package.json | 1 + paket-details.html | 393 ++----------------- src/features/angebote/angebote-entry.js | 2 - src/features/angebote/angebotsuebersicht.js | 4 +- src/features/angebote/paket-details.js | 249 +++++------- src/features/booking/booking-confirmation.js | 2 +- src/features/booking/booking-page.js | 12 +- src/server/services/booking-service.js | 20 +- src/server/swagger.json | 4 +- src/shared/phone-validation.js | 14 + src/styles/packages.css | 283 ++++++++++++- src/tests/bookings.test.js | 41 ++ 14 files changed, 520 insertions(+), 825 deletions(-) create mode 100644 src/shared/phone-validation.js diff --git a/angebotsuebersicht.html b/angebotsuebersicht.html index 4cf1c87..06d0290 100644 --- a/angebotsuebersicht.html +++ b/angebotsuebersicht.html @@ -1,285 +1,34 @@ - - - - - - Ernährungs-Pakete – Tri-Hub - - - - - - - - - - - -
    -
    -
    -
    -
    -
    TRI-HUB · ERNÄHRUNG
    -

    Unsere Ernährungs-Pakete

    -
    -

    - Wähle das Paket, das zu deinem aktuellen Stand passt. Von den Grundlagen bis zur Wettkampf-Vorbereitung. -

    -
    - -
    -
    -
    -
    - - - - - + + + + Ernährungs-Pakete – Tri-Hub + + + + + +
    +
    +
    +

    TRI-HUB · ERNÄHRUNG

    +

    Unsere Ernährungs-Pakete

    +
    +

    + Wähle das Paket, das zu deinem aktuellen Stand passt. Von + den Grundlagen bis zur Wettkampf-Vorbereitung. +

    +
    +
    +
    + + + diff --git a/package-lock.json b/package-lock.json index 7e92738..eb86a80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "csv-parse": "^7.0.2", "csv-stringify": "^6.8.3", + "libphonenumber-js": "^1.13.14", "nodemailer": "^10.0.10" }, "devDependencies": { @@ -982,6 +983,12 @@ "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": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", diff --git a/package.json b/package.json index d903bdd..cb17e0c 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "dependencies": { "csv-parse": "^7.0.2", "csv-stringify": "^6.8.3", + "libphonenumber-js": "^1.13.14", "nodemailer": "^10.0.10" } } diff --git a/paket-details.html b/paket-details.html index 9ec9e46..e3fabf7 100644 --- a/paket-details.html +++ b/paket-details.html @@ -1,372 +1,27 @@ - - - - - - - Paket-Details – Tri-Hub Ernährungsberatung - - - - - - - - - - - -
    -
    - -
    -
    - - - - - + + + + Paket-Details – Tri-Hub Ernährungsberatung + + + + + +
    + ← Zurück zur Angebotsübersicht +
    +
    + + + diff --git a/src/features/angebote/angebote-entry.js b/src/features/angebote/angebote-entry.js index 43eb523..2cf63e5 100644 --- a/src/features/angebote/angebote-entry.js +++ b/src/features/angebote/angebote-entry.js @@ -1,5 +1,3 @@ -import "../../styles/styles.css"; -import "../../styles/packages.css"; import { renderAngebotsuebersicht } from "./angebotsuebersicht.js"; renderAngebotsuebersicht(); diff --git a/src/features/angebote/angebotsuebersicht.js b/src/features/angebote/angebotsuebersicht.js index c0ca328..57133ac 100644 --- a/src/features/angebote/angebotsuebersicht.js +++ b/src/features/angebote/angebotsuebersicht.js @@ -23,7 +23,7 @@ export function renderAngebotsuebersicht() { ${highlightHtml}
    -

    ${pkg.name}

    +

    ${pkg.name}

    ${pkg.summary}

    @@ -41,7 +41,7 @@ export function renderAngebotsuebersicht() {
    - ${pkg.buttonText} + ${pkg.buttonText} `; diff --git a/src/features/angebote/paket-details.js b/src/features/angebote/paket-details.js index 7f7cccd..5c70cc2 100644 --- a/src/features/angebote/paket-details.js +++ b/src/features/angebote/paket-details.js @@ -1,199 +1,158 @@ 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 packageId = params.get("id"); + 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, + ); - const pkg = packagesDetails.find((item) => item.id === packageId); - - if (!pkg) { + if (!pkg || invalidVariant) { container.innerHTML = `
    -

    Paket nicht gefunden

    -

    Bitte wähle ein gültiges Angebot aus der Übersicht.

    - - Zur Übersicht - -
    - `; +

    Paket nicht gefunden

    +

    Bitte wähle ein gültiges Paket und eine passende Variante aus.

    + Zur Übersicht + `; return; } + document.title = `${pkg.type} – Tri-Hub Ernährungsberatung`; const variantsHtml = pkg.variants?.length - ? ` -
    -

    Wähle deine Variante

    - ${pkg.variants .map( (variant) => ` - - `, + `, ) .join("")} -
    - ` + ` : ""; container.innerHTML = ` -
    +
    - - ${pkg.type} · ${pkg.durationLabel} - - ${ - pkg.badge - ? `${pkg.badge}` - : "" - } + ${pkg.type} · + ${pkg.badge ? `${pkg.badge}` : ""}
    - -

    ${pkg.title}

    +

    ${pkg.title}

    ${pkg.subtitle}

    - -
    - - ${pkg.price.toFixed(2)} € - - - inkl. ${pkg.taxRate}% MwSt. (${pkg.billingInterval}) - -
    - - ${variantsHtml} - -
    -
    -

    - Für wen ist dieses Paket ideal? -

    +
    +
    +
    +
    +

    Für wen ist dieses Paket ideal?

      - ${pkg.targetGroup - .map((item) => `
    • ${item}
    • `) - .join("")} + ${pkg.targetGroup.map((item) => `
    • ${item}
    • `).join("")}
    -
    - -
    -

    - Das ist im Paket enthalten -

    + +
    +

    Das ist im Paket enthalten

      - ${pkg.includedFeatures - .map((feature) => `
    • ${feature}
    • `) - .join("")} + ${pkg.includedFeatures.map((feature) => `
    • ${feature}
    • `).join("")}
    -
    - -
    -

    - So läuft deine Begleitung ab -

    + +
    +

    So läuft deine Begleitung ab

    ${pkg.processSteps .map( (step) => ` -
    - ${step.step}: - - ${step.text} - -
    - `, +
    + ${step.step} +

    ${step.text}

    +
    `, ) .join("")} -
    +
    +
    +

    Dein ${pkg.type.toLowerCase().replace(/^./, (letter) => letter.toUpperCase())}-Paket

    +
    + + inkl. ${pkg.taxRate} % MwSt. · ${pkg.billingInterval} +

    +
    + ${variantsHtml} + +

    +
    +
    +
    `; - - -

    - Dieser Buchungsablauf ist eine Simulation. Es erfolgt keine - Abbuchung und keine verbindliche Beratungsbuchung. -

    - -

    -
    - - `; - - const variantSelect = document.getElementById("variant-select"); - const displayedPrice = document.getElementById("displayed-price"); - const bookingBtn = document.getElementById("proceed-to-booking-btn"); - const bookingRoot = document.getElementById("booking-root"); - const bookingStatus = document.getElementById("booking-status"); - + 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?.(); - bookingStatus.textContent = ""; - + 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, }); } - function updatePrice() { - if (!variantSelect) return; - - const selectedOption = variantSelect.selectedOptions[0]; - const price = Number(selectedOption.dataset.price); - - displayedPrice.textContent = `${price.toFixed(2)} €`; - } - - updatePrice(); - prepareBooking(); - - variantSelect?.addEventListener("change", () => { - updatePrice(); - prepareBooking(); + // 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", () => { - const dialog = bookingRoot.querySelector(".booking__dialog"); - - if (!dialog) { - bookingStatus.textContent = - "Das Buchungsformular ist noch nicht verfügbar. Bitte beachte den Ladehinweis im Buchungsbereich."; - return; - } - - if (dialog.open) return; - - bookingStatus.textContent = ""; - - dialog.addEventListener( - "close", - () => { - bookingBtn.focus(); - }, - { once: true }, + if (!activeDialog || activeDialog.open) return; + activeDialog.showModal(); + bookingBtn.setAttribute("aria-expanded", "true"); + const result = activeDialog.querySelector( + ".booking__result:not([hidden])", ); - - dialog.showModal(); - dialog.querySelector('[name="name"]').focus(); + (result || activeDialog.querySelector('[name="name"]')).focus(); }); }); diff --git a/src/features/booking/booking-confirmation.js b/src/features/booking/booking-confirmation.js index 5e67a8b..7311709 100644 --- a/src/features/booking/booking-confirmation.js +++ b/src/features/booking/booking-confirmation.js @@ -90,7 +90,7 @@ if ( 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 = `/booking.html?${params}`; + select("confirmation-retry").href = `/paket-details.html?${params}`; select("confirmation-retry").hidden = false; } select("confirmation-empty").hidden = true; diff --git a/src/features/booking/booking-page.js b/src/features/booking/booking-page.js index 4d97d52..2366c50 100644 --- a/src/features/booking/booking-page.js +++ b/src/features/booking/booking-page.js @@ -1,3 +1,7 @@ +import { + isValidBookingPhone, + phoneError, +} from "../../shared/phone-validation.js"; import { previewBooking, submitBooking } from "./booking-api.js"; import "./booking.css"; import { orderSummaryRows } from "../../shared/order-summary.js"; @@ -284,12 +288,8 @@ export function mountBookingPage( fields.email = "Bitte eine gültige E-Mail-Adresse eingeben."; phone.value = phone.value.trim(); - if ( - phone.value.length > 50 || - /[\x00-\x1f\x7f]/.test(phone.value) - ) - fields.phone = - "Bitte höchstens 50 Zeichen ohne Zeilenumbrüche eingeben."; + if (!isValidBookingPhone(phone.value)) + fields.phone = phoneError; if (Object.keys(fields).length) { showError( "Bitte die markierten Angaben prüfen.", diff --git a/src/server/services/booking-service.js b/src/server/services/booking-service.js index f62c6d3..5ecf2e2 100644 --- a/src/server/services/booking-service.js +++ b/src/server/services/booking-service.js @@ -1,3 +1,7 @@ +import { + isValidBookingPhone, + phoneError, +} from "../../shared/phone-validation.js"; import { createHash } from "node:crypto"; import { packagesDetails } from "../../shared/packagesdetails.js"; @@ -112,16 +116,12 @@ function validateInput(input, key) { ) { fields.email = "Bitte eine gültige E-Mail-Adresse eingeben."; } - if (!customer && input.phone !== undefined) { - if ( - typeof input.phone !== "string" || - input.phone.length > 50 || - /[\x00-\x1f\x7f]/.test(input.phone) - ) { - fields.phone = - "Bitte höchstens 50 Zeichen ohne Zeilenumbrüche eingeben."; - } else if (input.phone.trim()) { - result.phone = input.phone.trim(); + 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) { diff --git a/src/server/swagger.json b/src/server/swagger.json index 409c41d..7ba05eb 100644 --- a/src/server/swagger.json +++ b/src/server/swagger.json @@ -553,7 +553,7 @@ "phone": { "type": "string", "maxLength": 50, - "description": "Optionale Telefonnummer bei Übergabe von name und email, ohne Steuerzeichen. Bei customer gilt customer.phone.", + "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": { @@ -575,6 +575,8 @@ }, "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": { diff --git a/src/shared/phone-validation.js b/src/shared/phone-validation.js new file mode 100644 index 0000000..8958dcf --- /dev/null +++ b/src/shared/phone-validation.js @@ -0,0 +1,14 @@ +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"); +} diff --git a/src/styles/packages.css b/src/styles/packages.css index c40e4f5..dd8c95b 100644 --- a/src/styles/packages.css +++ b/src/styles/packages.css @@ -1,10 +1,279 @@ -#booking-root .booking__open { +.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; } -/* Zusätzliche Booking-Übersicht ausblenden, Dialog erhalten. */ -#booking-root > .booking > h2, -#booking-root .booking__package, -#booking-root .booking__payment, -#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; + } } diff --git a/src/tests/bookings.test.js b/src/tests/bookings.test.js index e3b510d..2be862c 100644 --- a/src/tests/bookings.test.js +++ b/src/tests/bookings.test.js @@ -619,3 +619,44 @@ test("Swagger-Kundendaten und Zustimmung werden validiert und dauerhaft gespeich 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); + } +});