From 5fb3f51f8fed28eee8ffebc6f20f93faac9fb416 Mon Sep 17 00:00:00 2001 From: Marvin Gemlin Date: Fri, 25 Sep 2026 20:04:22 +0000 Subject: [PATCH] =?UTF-8?q?Paket=C3=BCbergabe=20korrigiert,=20Bestell?= =?UTF-8?q?=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

+
    +