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/.env.example b/.env.example new file mode 100644 index 0000000..318f119 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Lokal nur einen Mailfänger verwenden. Keine echten Zugangsdaten eintragen. +HOST=127.0.0.1 +PORT=3000 +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/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 new file mode 100644 index 0000000..06d0290 --- /dev/null +++ b/angebotsuebersicht.html @@ -0,0 +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. +

+
+
+
+ + + + diff --git a/booking-confirmation.html b/booking-confirmation.html new file mode 100644 index 0000000..5ad23bf --- /dev/null +++ b/booking-confirmation.html @@ -0,0 +1,161 @@ + + + + + + + + Buchungsbestätigung – Tri-Hub + + + +
+
+

Deine Buchungsübersicht

+

Keine Bestätigung verfügbar.

+

+ In diesem Tab liegt keine gespeicherte Buchungsbestätigung + vor. Öffne die Bestätigung im Tab deiner Buchung. Falls du + bereits gebucht hast, prüfe deine E-Mails, bevor du erneut + buchst. +

+ Zur Paketübersicht → +
+ + +
+ + + + + diff --git a/booking.html b/booking.html new file mode 100644 index 0000000..10fd45f --- /dev/null +++ b/booking.html @@ -0,0 +1,21 @@ + + + + + + + Paket buchen – Tri-Hub + + +
+ Zur Angebotsseite +

Deine Buchung

+ +
+
+ + + diff --git a/docs/booking.md b/docs/booking.md new file mode 100644 index 0000000..c6788e1 --- /dev/null +++ b/docs/booking.md @@ -0,0 +1,146 @@ +# Paketbuchung + +## Ablauf + +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. + +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. + +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 + +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. + +In separaten Terminals starten: + +- `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. + +Die Angebotsübersicht liegt unter , +der Mailfänger unter . Mailpit ist im Dev Container enthalten. + +`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 + +| 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 | + +Externe SMTP-Verbindungen erfordern TLS. Zugangsdaten gehören ausschließlich +in die Serverkonfiguration und dürfen kein `VITE_`-Präfix erhalten. + +## API + +Der vollständige Vertrag steht in `src/server/swagger.json`. + +| 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 | + +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`, `email` und optional `phone`. +Die Telefonnummer darf höchstens 50 Zeichen und keine Steuerzeichen enthalten. +Sie wird in der CSV gespeichert und in der Bestätigungsmail ausgegeben. + +Alternativ akzeptiert die API das Swagger-Objekt `customer` mit `firstName`, +`lastName`, `email` sowie optional `phone`, `ageGroup` und `notes`. Dabei muss +`acceptedTerms` den Wert `true` haben. Diese Angaben werden ebenfalls gespeichert. + +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. + +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`. + +## Speicherung und Wiederholungen + +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. + +`bookedPackage` speichert den Bestellstand als JSON, `customer` die optionalen +strukturierten Kundendaten und `acceptedTerms` die zugehörige Zustimmung. +Die optionale Telefonnummer steht zusätzlich in der Spalte `phone`. +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`. + +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. + +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. + +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. + +## Versandstatus + +| 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. | + +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. + +## Tests + +`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. + +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. + +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/index.html b/index.html index 1e74bb1..a44a444 100644 --- a/index.html +++ b/index.html @@ -1101,6 +1101,7 @@ > + >>>>>>>>> Temporary merge branch 2 diff --git a/package-lock.json b/package-lock.json index d186ecb..eb86a80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,14 @@ "": { "name": "ernaehrungsberatung", "version": "0.1.0", + "dependencies": { + "csv-parse": "^7.0.2", + "csv-stringify": "^6.8.3", + "libphonenumber-js": "^1.13.14", + "nodemailer": "^10.0.10" + }, "devDependencies": { + "@playwright/test": "^1.63.0", "prettier": "^3.6.2", "vite": "^7.1.7" }, @@ -477,6 +484,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 +896,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", @@ -948,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", @@ -967,6 +1008,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 +1037,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..cb17e0c 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,22 @@ "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/*.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", "prettier": "^3.6.2", "vite": "^7.1.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 new file mode 100644 index 0000000..e3fabf7 --- /dev/null +++ b/paket-details.html @@ -0,0 +1,27 @@ + + + + + + Paket-Details – Tri-Hub Ernährungsberatung + + + + + +
+ ← Zurück zur Angebotsübersicht +
+
+ + + + 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/src/features/angebote/angebote-entry.js b/src/features/angebote/angebote-entry.js new file mode 100644 index 0000000..2cf63e5 --- /dev/null +++ b/src/features/angebote/angebote-entry.js @@ -0,0 +1,3 @@ +import { renderAngebotsuebersicht } from "./angebotsuebersicht.js"; + +renderAngebotsuebersicht(); diff --git a/src/features/angebote/angebotsuebersicht.js b/src/features/angebote/angebotsuebersicht.js new file mode 100644 index 0000000..57133ac --- /dev/null +++ b/src/features/angebote/angebotsuebersicht.js @@ -0,0 +1,51 @@ +import { packages } from "../../shared/packages.js"; + +export function renderAngebotsuebersicht() { + const container = document.getElementById("packages-container"); + + if (!container) { + console.warn("Container für Angebotsübersicht nicht gefunden."); + return; + } + + container.innerHTML = ""; + + packages.forEach((pkg) => { + const highlightHtml = pkg.highlight + ? `${pkg.highlight}` + : ""; + + const cardHtml = ` +
+ +
+ ${pkg.type} + ${highlightHtml} +
+ +

${pkg.name}

+

${pkg.summary}

+ +
+
+
Fokus
+
${pkg.fokus}
+
+
+
Anamnese
+
${pkg.anamnese}
+
+
+
Begleitung
+
${pkg.begleitung}
+
+
+ + ${pkg.buttonText} + +
+ `; + + container.innerHTML += cardHtml; + }); +} diff --git a/src/features/angebote/paket-details.js b/src/features/angebote/paket-details.js new file mode 100644 index 0000000..5c70cc2 --- /dev/null +++ b/src/features/angebote/paket-details.js @@ -0,0 +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 pkg = packagesDetails.find((item) => item.id === params.get("id")); + const requestedVariant = params.get("variant"); + const invalidVariant = + requestedVariant && + !pkg?.variants?.some( + (variant) => variant.variantId === requestedVariant, + ); + + if (!pkg || invalidVariant) { + container.innerHTML = ` +
+

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 + ? `
+ + +
` + : ""; + + container.innerHTML = ` +
+
+ ${pkg.type} · + ${pkg.badge ? `${pkg.badge}` : ""} +
+

${pkg.title}

+

${pkg.subtitle}

+
+
+
+
+

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("")} +
+
+
+

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

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

+
+ ${variantsHtml} + +

+
+
+
`; + + container.querySelector("#booking-notice").textContent = paymentNotice; + const variantSelect = container.querySelector("#variant-select"); + if (variantSelect && requestedVariant) + variantSelect.value = requestedVariant; + const bookingBtn = container.querySelector("#proceed-to-booking-btn"); + const bookingRoot = container.querySelector("#booking-root"); + let cleanupBooking; + let activeDialog; + + function prepareBooking() { + cleanupBooking?.(); + activeDialog = null; + bookingBtn.disabled = true; + bookingBtn.textContent = "Buchungsformular wird geladen …"; + bookingBtn.setAttribute("aria-expanded", "false"); + bookingBtn.removeAttribute("aria-controls"); + const variant = pkg.variants?.find( + (item) => item.variantId === variantSelect?.value, + ); + const duration = variant + ? `${variant.durationWeeks} Wochen Begleitung` + : pkg.durationLabel; + container.querySelector("#displayed-price").textContent = formatMoney( + variant?.price ?? pkg.price, + pkg.currency, + ); + container.querySelector("#package-duration").textContent = duration; + container.querySelector("#selected-duration").textContent = duration; + cleanupBooking = mountBookingPage(bookingRoot, { + packageId: pkg.id, + variantId: variantSelect?.value, + }); + } + + // Booking lädt die Paketvorschau asynchron, auch bei einem erneuten Versuch. + const observer = new MutationObserver(() => { + const dialog = bookingRoot.querySelector(".booking__dialog"); + if (!dialog || dialog === activeDialog) return; + activeDialog = dialog; + bookingBtn.disabled = false; + bookingBtn.textContent = "Jetzt buchen"; + bookingBtn.setAttribute("aria-controls", dialog.id); + dialog.addEventListener("close", () => { + bookingBtn.setAttribute("aria-expanded", "false"); + bookingBtn.focus(); + }); + }); + observer.observe(bookingRoot, { childList: true, subtree: true }); + prepareBooking(); + variantSelect?.addEventListener("change", prepareBooking); + + bookingBtn.addEventListener("click", () => { + if (!activeDialog || activeDialog.open) return; + activeDialog.showModal(); + bookingBtn.setAttribute("aria-expanded", "true"); + const result = activeDialog.querySelector( + ".booking__result:not([hidden])", + ); + (result || activeDialog.querySelector('[name="name"]')).focus(); + }); +}); diff --git a/src/features/booking/booking-api.js b/src/features/booking/booking-api.js new file mode 100644 index 0000000..ba978d6 --- /dev/null +++ b/src/features/booking/booking-api.js @@ -0,0 +1,54 @@ +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 submitBooking(payload, idempotencyKey) { + return request("/api/bookings", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": 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-confirmation.css b/src/features/booking/booking-confirmation.css new file mode 100644 index 0000000..58d7eeb --- /dev/null +++ b/src/features/booking/booking-confirmation.css @@ -0,0 +1,288 @@ +.confirmation { + margin: 0; + min-height: 100vh; + color: #f8fafc; + background: + radial-gradient(ellipse at 5% 20%, #10b98114, transparent 45%), + radial-gradient(ellipse at 95% 45%, #1a1a2e, transparent 55%), #0d0d1a; + font-family: Inter, system-ui, sans-serif; + line-height: 1.65; + color-scheme: dark; + overflow-wrap: anywhere; +} +.confirmation * { + box-sizing: border-box; +} +.confirmation [hidden] { + display: none !important; +} +.confirmation a { + color: inherit; + text-decoration: none; +} +.confirmation a:focus-visible { + outline: 3px solid #10b981; + outline-offset: 6px; +} +.confirmation__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1.2rem clamp(1rem, 5vw, 5rem); + border-bottom: 1px solid #ffffff12; + background: #09091299; +} +.confirmation__brand { + display: flex; + align-items: center; + gap: 0.8rem; + letter-spacing: 0.22em; + font-weight: 600; +} +.confirmation__brand small { + display: block; + color: #94a3b8; + letter-spacing: normal; + font-size: 0.7rem; + font-weight: 400; +} +.confirmation__mark { + display: grid; + place-items: center; + width: 3rem; + height: 3rem; + background: #f8fafc; + color: #087452; + border-radius: 0.9rem; + letter-spacing: -0.08em; + font-family: Georgia, serif; + font-size: 1.3rem; +} +.confirmation__main { + max-width: 74rem; + padding: 4rem 1.5rem; + margin: auto; +} +.confirmation__hero { + max-width: 49rem; + margin-bottom: 3rem; +} +.confirmation__eyebrow { + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.17em; + font-size: 0.72rem; + font-weight: 600; +} +.confirmation__eyebrow span { + display: inline-grid; + place-items: center; + width: 1.6rem; + height: 1.6rem; + margin-right: 0.5rem; + border: 1px solid #10b98170; + border-radius: 50%; + color: #10b981; +} +.confirmation h1 { + margin: 1rem 0 1.5rem; + font-family: "Playfair Display", Georgia, serif; + font-weight: 500; + font-size: clamp(2.5rem, 5vw, 4.5rem); + line-height: 1.12; + letter-spacing: -0.035em; +} +.confirmation h1 em { + color: #10b981; + font-weight: inherit; +} +.confirmation h2 { + margin: 0.5rem 0 1rem; + font-family: "Playfair Display", Georgia, serif; + font-weight: 500; + font-size: 1.75rem; + line-height: 1.3; +} +.confirmation h3 { + font-size: 1rem; + margin: 1.5rem 0 0.65rem; +} +.confirmation__hero > p:not(.confirmation__eyebrow) { + max-width: 42rem; +} +.confirmation__simulation { + color: #cbd5e1; + padding-left: 1rem; + border-left: 2px solid #10b981; + font-size: 0.85rem; +} +.confirmation__grid { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); + gap: 1.5rem; + align-items: start; +} +.confirmation__card { + border: 1px solid #ffffff14; + border-radius: 1.4rem; + padding: clamp(1.25rem, 3vw, 2.25rem); + background: linear-gradient(145deg, #ffffff06, #ffffff02); +} +.confirmation__sidebar { + display: grid; + gap: 1.5rem; +} +.confirmation__muted, +.confirmation__steps p { + color: #94a3b8; +} +.confirmation__details { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 0.75rem 1rem; + margin: 1.75rem 0 0; + font-size: 0.9rem; +} +.confirmation__details dt { + color: #94a3b8; +} +.confirmation__details dd { + margin: 0; + text-align: right; +} +.confirmation__details:not(.confirmation__details--stacked) dt:last-of-type, +.confirmation__details:not(.confirmation__details--stacked) dd:last-of-type { + border-top: 1px solid #ffffff20; + padding-top: 1rem; + font-size: 1.1rem; + color: #f8fafc; + font-weight: 600; +} +.confirmation__details--stacked { + display: block; +} +.confirmation__details--stacked dd { + text-align: left; + margin: 0.2rem 0 1rem; +} +.confirmation__mail { + border-color: #10b98140; + background: #10b98108; +} +.confirmation__mail h2 { + font-family: inherit; + font-size: 1.1rem; +} +.confirmation__mail p { + color: #cbd5e1; + font-size: 0.9rem; +} +.confirmation__features { + list-style: none; + padding: 0; + color: #cbd5e1; + font-size: 0.9rem; +} +.confirmation__features li { + position: relative; + padding-left: 1.5rem; + margin: 0.8rem 0; +} +.confirmation__features li::before { + content: "✓"; + position: absolute; + left: 0; + color: #10b981; +} +.confirmation__next { + margin-top: 1.5rem; +} +.confirmation__steps { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: 1.5rem; + list-style: none; + counter-reset: steps; + padding: 0; +} +.confirmation__steps li { + counter-increment: steps; + border-top: 1px solid #ffffff20; + padding-top: 1rem; +} +.confirmation__steps li::before { + content: "0" counter(steps); + color: #10b981; + font-size: 0.8rem; +} +.confirmation__steps h3 { + margin-top: 0.5rem; +} +.confirmation__steps p { + font-size: 0.9rem; +} +.confirmation__actions { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + margin-top: 2rem; +} +.confirmation__button, +.confirmation__link { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 1rem; + min-height: 2.75rem; + padding: 0.65rem 1.3rem; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 600; +} +.confirmation__button { + background: #10b981; + color: #071a14 !important; +} +.confirmation__link { + border: 1px solid #ffffff20; +} +.confirmation__button:hover { + background: #34d399; +} +.confirmation__link:hover { + background: #ffffff08; +} +.confirmation__empty { + max-width: 42rem; + min-height: 60vh; +} +.confirmation__footer { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 1rem; + border-top: 1px solid #ffffff12; + padding: 1.5rem clamp(1rem, 5vw, 5rem); + color: #94a3b8; + font-size: 0.75rem; + letter-spacing: 0.12em; +} +.confirmation__footer span { + letter-spacing: normal; +} +@media (max-width: 45rem) { + .confirmation__grid { + grid-template-columns: minmax(0, 1fr); + } + .confirmation__main { + padding: 2.5rem 1rem; + } + .confirmation__header { + flex-wrap: wrap; + } + .confirmation__brand small { + max-width: 12rem; + } +} diff --git a/src/features/booking/booking-confirmation.js b/src/features/booking/booking-confirmation.js new file mode 100644 index 0000000..7311709 --- /dev/null +++ b/src/features/booking/booking-confirmation.js @@ -0,0 +1,98 @@ +import "./booking-confirmation.css"; +import { orderSummaryRows } from "../../shared/order-summary.js"; +import { paymentNotice } from "../../shared/booking-copy.js"; + +const mailMessages = { + accepted: + "Die Bestätigung wurde vom Mailserver zum Versand angenommen. Die Zustellung ist noch nicht bestätigt. Bitte prüfe auch deinen Spam-Ordner.", + pending: + "Deine Buchung ist gespeichert. Der Bestätigungsversand steht noch aus. Du kannst den Versand mit derselben Buchung erneut versuchen.", + failed: "Deine Buchung ist gespeichert, aber der Bestätigungsversand ist fehlgeschlagen. Bitte buche nicht erneut. Wende dich mit deiner Buchungsnummer an Tri-Hub.", + unknown: + "Deine Buchung ist gespeichert. Der Versandstatus ist unklar. Bitte buche nicht erneut. Wende dich mit deiner Buchungsnummer an Tri-Hub.", +}; +const select = (id) => document.getElementById(id); +function rows(id, values) { + const list = select(id); + for (const [label, value] of values) { + const term = document.createElement("dt"); + const description = document.createElement("dd"); + term.textContent = label; + description.textContent = value || "Nicht verfügbar"; + list.append(term, description); + } +} + +// Nur die Antwort der gespeicherten Buchung anzeigen, keine aktuellen +// Katalogpreise nachladen und keine erneute Buchungsanfrage auslösen. +let body; +try { + body = JSON.parse(sessionStorage.getItem("trihub.booking.confirmation")); +} catch { + // Ohne lesbaren Sitzungsspeicher bleibt der Hinweis sichtbar. +} +if ( + body?.saved === true && + typeof body.bookingId === "string" && + body.bookingId +) { + const order = body.bookedPackage; + select("confirmation-payment").textContent = paymentNotice; + select("summary-title").textContent = + body.packageName || order?.title || "Dein Paket"; + select("confirmation-summary").textContent = order?.summaryForBooking || ""; + const date = new Date(body.createdAt); + rows("confirmation-receipt", [ + ["Buchungsnummer", body.bookingId], + [ + "Buchungsdatum", + Number.isNaN(date.getTime()) + ? "Nicht verfügbar" + : new Intl.DateTimeFormat("de-DE", { + dateStyle: "long", + timeStyle: "short", + }).format(date), + ], + ["E-Mail-Adresse", body.customerEmail], + [ + "Buchungsstatus", + body.status === "CONFIRMED" + ? "Bestätigt (Simulation)" + : "Gespeichert (Simulation)", + ], + ]); + select("confirmation-mail-status").textContent = + mailMessages[body.emailStatus] || mailMessages.unknown; + if (order) { + rows("confirmation-totals", orderSummaryRows(order)); + for (const feature of order.includedFeatures || []) { + const item = document.createElement("li"); + item.textContent = feature; + select("confirmation-features").append(item); + } + select("confirmation-features-section").hidden = + !order.includedFeatures?.length; + for (const step of order.processSteps || []) { + const item = document.createElement("li"); + const title = document.createElement("h3"); + const text = document.createElement("p"); + title.textContent = step.step; + text.textContent = step.text; + item.append(title, text); + select("confirmation-steps").append(item); + } + select("confirmation-steps-section").hidden = + !order.processSteps?.length; + } else { + select("confirmation-no-snapshot").hidden = false; + rows("confirmation-totals", [["Paket-ID", body.packageId]]); + } + if (body.emailStatus === "pending" && body.packageId) { + const params = new URLSearchParams({ id: body.packageId }); + if (order?.variantId) params.set("variant", order.variantId); + select("confirmation-retry").href = `/paket-details.html?${params}`; + select("confirmation-retry").hidden = false; + } + select("confirmation-empty").hidden = true; + select("confirmation-content").hidden = false; +} diff --git a/src/features/booking/booking-entry.js b/src/features/booking/booking-entry.js new file mode 100644 index 0000000..54c7513 --- /dev/null +++ b/src/features/booking/booking-entry.js @@ -0,0 +1,16 @@ +import "../../styles/styles.css"; +import { mountBookingPage } from "./booking-page.js"; + +const root = document.querySelector("#booking-root"); +let cleanup; +function render() { + cleanup?.(); + const match = location.hash.match(/^#\/buchen\/([a-z0-9-]+)$/); + 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 new file mode 100644 index 0000000..2366c50 --- /dev/null +++ b/src/features/booking/booking-page.js @@ -0,0 +1,410 @@ +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"; +import { paymentNotice } from "../../shared/booking-copy.js"; + +// Die Detailseite kann dieselbe Section direkt mounten und später aufräumen. +// Die Zieladresse kann bei der Integration bei Bedarf überschrieben werden. +export function mountBookingPage( + root, + { + packageId, + variantId, + confirmationUrl = root.dataset.confirmationUrl || + "/booking-confirmation.html", + } = {}, +) { + const controller = new AbortController(); + const { signal } = controller; + const instanceId = `booking-${crypto.randomUUID()}`; + root.innerHTML = ` +
+

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 selected = await previewBooking(packageId, variantId, signal); + if (signal.aborted) return; + intro.remove(); + renderForm(selected); + } 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"); + 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 = ` +
+

+

+

+
+

Enthaltene Leistungen

+
    +
    +
    + + + + +

    Paket buchen

    +

    Bitte gib deine Kontaktdaten ein. Name und E-Mail-Adresse sind Pflichtfelder.

    +
    + +
    + + +

    +
    +
    + + +

    An diese Adresse senden wir deine Bestätigung.

    +

    +
    +
    + + +

    +
    +
    + + +
    +

    +
    + +
    `; + const dialog = content.querySelector("dialog"); + const opener = content.querySelector(".booking__open"); + opener.addEventListener( + "click", + () => { + dialog.showModal(); + opener.setAttribute("aria-expanded", "true"); + const focusTarget = + content.querySelector(".booking__result:not([hidden])") || + content.querySelector('[name="name"]'); + focusTarget.focus(); + }, + { signal }, + ); + for (const close of content.querySelectorAll( + ".booking__close, .booking__cancel", + )) { + close.addEventListener("click", () => dialog.close(), { signal }); + } + dialog.addEventListener( + "close", + () => { + opener.setAttribute("aria-expanded", "false"); + opener.focus(); + }, + { signal }, + ); + function renderOrder(order) { + content.querySelector(".booking__package-name").textContent = + order.title; + content.querySelector(".booking__summary").textContent = + order.summaryForBooking; + content.querySelector(".booking__description").textContent = + order.subtitle; + const totals = content.querySelector(".booking__totals"); + totals.replaceChildren(); + for (const [label, value] of orderSummaryRows(order)) { + const term = document.createElement("dt"); + const description = document.createElement("dd"); + term.textContent = label; + description.textContent = value; + totals.append(term, description); + } + const features = content.querySelector(".booking__features"); + features.replaceChildren( + ...order.includedFeatures.map((text) => { + const item = document.createElement("li"); + item.textContent = text; + return item; + }), + ); + const steps = content.querySelector(".booking__steps"); + steps.replaceChildren(); + if (order.processSteps?.length) { + const heading = document.createElement("h3"); + heading.textContent = "So geht es weiter"; + steps.append(heading); + for (const entry of order.processSteps) { + const paragraph = document.createElement("p"); + paragraph.textContent = `${entry.step}: ${entry.text}`; + steps.append(paragraph); + } + } + } + renderOrder(selected); + content.querySelector(".booking__payment-notice").textContent = + paymentNotice; + const form = content.querySelector("form"); + const name = form.elements.namedItem("name"); + const email = form.elements.namedItem("email"); + const phone = form.elements.namedItem("phone"); + const button = form.querySelector('button[type="submit"]'); + const errors = form.querySelector(".booking__errors"); + const progress = form.querySelector(".booking__progress"); + const result = content.querySelector(".booking__result"); + const storageKey = `trihub.booking.${selected.packageId}${selected.variantId ? `.${selected.variantId}` : ""}`; + let attempt = null; + let busy = false; + let finished = false; + + function lockFields(locked) { + name.readOnly = locked; + email.readOnly = locked; + phone.readOnly = locked; + } + function showError(message, fields = {}) { + errors.textContent = message; + errors.hidden = false; + for (const field of [name, email, phone]) { + const message = fields[field.name] || ""; + content.querySelector( + `#${instanceId}-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, phone]) { + field.removeAttribute("aria-invalid"); + content.querySelector( + `#${instanceId}-booking-${field.name}-error`, + ).textContent = ""; + } + } + function showResult(body, focus = true) { + if (body.bookedPackage) renderOrder(body.bookedPackage); + 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.packageId + ) { + attempt = stored; + if (stored.order) renderOrder(stored.order); + name.value = stored.payload.name; + email.value = stored.payload.email; + phone.value = stored.payload.phone || ""; + 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."; + phone.value = phone.value.trim(); + if (!isValidBookingPhone(phone.value)) + fields.phone = phoneError; + if (Object.keys(fields).length) { + showError( + "Bitte die markierten Angaben prüfen.", + fields, + ); + return; + } + const nextAttempt = { + key: crypto.randomUUID(), + order: selected, + payload: { + packageId: selected.packageId, + ...(selected.variantId + ? { variantId: selected.variantId } + : {}), + name: name.value, + email: email.value, + ...(phone.value ? { phone: phone.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); + if ( + body.saved && + body.emailStatus === "accepted" && + confirmationUrl + ) { + // Nur weiterleiten, wenn die Bestätigungsseite die + // Serverantwort in diesem Tab auch lesen kann. + try { + sessionStorage.setItem( + "trihub.booking.confirmation", + JSON.stringify(body), + ); + } catch { + return; // Die bestehende Bestätigung bleibt sichtbar. + } + window.location.assign(confirmationUrl); + } + } + } catch (error) { + // Nur eindeutige Ablehnungen vor dem Speichern erlauben eine + // Korrektur. Bei Verbindungsfehlern bleiben Daten und Key fest. + if ( + [ + "INVALID_INPUT", + "UNKNOWN_PACKAGE", + "INVALID_VARIANT", + "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(); + root.querySelector(".booking__dialog")?.close(); + root.replaceChildren(); + }; +} diff --git a/src/features/booking/booking.css b/src/features/booking/booking.css new file mode 100644 index 0000000..2416eb9 --- /dev/null +++ b/src/features/booking/booking.css @@ -0,0 +1,192 @@ +.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%; + } +} + +.booking__totals { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 0.75rem 1rem; + padding-block: 1rem; + border-block: 1px solid #d4ddd7; +} +.booking__totals dd { + margin: 0; + text-align: right; +} +.booking__totals dt:last-of-type, +.booking__totals dd:last-of-type { + font-weight: 700; + font-size: 1.15rem; +} +.booking__features { + padding-left: 1.25rem; +} +.booking__features li { + margin-block: 0.5rem; +} + +/* Sämtliche Dialogregeln bleiben innerhalb der Booking-Section. */ +.booking .booking__dialog { + box-sizing: border-box; + width: min(32rem, calc(100vw - 2rem)); + max-height: calc(100dvh - 2rem); + margin: auto; + padding: 1.75rem; + overflow-y: auto; + color: #edf5f5; + background: #071316; + border: 1px solid #324449; + border-radius: 1.25rem; + box-shadow: 0 1.5rem 4rem #0006; + color-scheme: dark; +} +.booking .booking__dialog::backdrop { + background: rgb(0 0 0 / 65%); +} +.booking__dialog h2 { + margin: 0 2rem 0.75rem 0; + font-size: 1.35rem; +} +.booking .booking__close { + position: absolute; + top: 0.5rem; + right: 0.5rem; + width: 2.75rem; + height: 2.75rem; + border: 0; + background: transparent; + color: inherit; + font: inherit; + font-size: 1.75rem; + cursor: pointer; +} +.booking__dialog input { + box-sizing: border-box; + background: #101e22; + border-color: #61777d; + border-radius: 0.75rem; +} +.booking__dialog .booking__button { + background: #00b6c7; + border-color: #00b6c7; + color: #061619; + border-radius: 0.75rem; +} +.booking__dialog .booking__cancel { + background: transparent; + border-color: #61777d; + color: inherit; +} +.booking__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} +.booking__actions > button { + flex: 1 1 auto; +} +.booking__dialog .booking__errors, +.booking__dialog .booking__field-error { + color: #ffb5b5; +} +.booking__dialog [aria-invalid="true"] { + border-color: #ffb5b5; +} +.booking__dialog :focus-visible, +.booking__dialog .booking__errors:focus, +.booking__dialog .booking__result:focus { + outline-color: #00d6e8; +} diff --git a/src/main.js b/src/main.js index a8ccb3d..a9589da 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,5 @@ import "./styles/styles.css"; import "./components/navigationsbar/navigation.js"; + document.querySelector("#year").textContent = new Date().getFullYear(); diff --git a/src/server/data/.gitkeep b/src/server/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/server/index.js b/src/server/index.js new file mode 100644 index 0000000..b9abd1c --- /dev/null +++ b/src/server/index.js @@ -0,0 +1,115 @@ +import { createServer } from "node:http"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { packages } from "../shared/packages.js"; +import { + BookingError, + 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)), + ), + details, + 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, + details, + 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 }) => ({ + id, + name, + summary, + })), + }); + } + if (path === "/api/packages" && request.method === "GET") { + return json( + response, + 200, + catalog.map(({ name, summary, ...entry }) => ({ + ...entry, + title: name, + description: summary, + })), + ); + } + if (path.startsWith("/api/packages/") && request.method === "GET") { + try { + return json( + response, + 200, + service.getPackage(path.slice("/api/packages/".length)), + ); + } catch (error) { + if (!(error instanceof BookingError)) throw error; + return json(response, error.status, { + error: { + code: error.code, + message: error.message, + fields: error.fields, + }, + }); + } + } + if (path === "/api/bookings" || path === "/api/bookings/preview") { + if (request.method !== "POST") { + response.setHeader("Allow", "POST"); + return json(response, 405, { + error: { + code: "METHOD_NOT_ALLOWED", + message: "Bitte POST verwenden.", + }, + }); + } + return handleBooking(request, response, service, { + preview: path.endsWith("/preview"), + }); + } + // 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 port = Number(process.env.PORT || 3000); + const server = createApp(); + server.listen(port, process.env.HOST || "127.0.0.1", () => { + console.log(`Buchungs-API auf Port ${port}`); + }); +} diff --git a/src/server/routes/bookings.js b/src/server/routes/bookings.js new file mode 100644 index 0000000..9209502 --- /dev/null +++ b/src/server/routes/bookings.js @@ -0,0 +1,81 @@ +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, + { preview = false } = {}, +) { + try { + const input = await readJson(request); + if (preview) return json(response, 200, service.preview(input)); + 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..5ecf2e2 --- /dev/null +++ b/src/server/services/booking-service.js @@ -0,0 +1,367 @@ +import { + isValidBookingPhone, + phoneError, +} from "../../shared/phone-validation.js"; +import { createHash } from "node:crypto"; +import { packagesDetails } from "../../shared/packagesdetails.js"; + +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.", + ); + } + let customer; + if (Object.hasOwn(input, "customer")) { + if ( + !input.customer || + typeof input.customer !== "object" || + Array.isArray(input.customer) || + input.acceptedTerms !== true + ) { + throw new BookingError( + 400, + "INVALID_INPUT", + "Kundendaten und Zustimmung zu den Bedingungen sind erforderlich.", + ); + } + customer = {}; + for (const [field, max, required] of [ + ["firstName", 60, true], + ["lastName", 60, true], + ["email", 254, true], + ["phone", 50, false], + ["ageGroup", 40, false], + ["notes", 2000, false], + ]) { + const value = input.customer[field]; + if (value === undefined && !required) continue; + if ( + typeof value !== "string" || + value.length > max || + (required && !value.trim()) || + /[\x00-\x1f\x7f]/.test(value) + ) { + throw new BookingError( + 400, + "INVALID_INPUT", + "Bitte die Kundendaten prüfen.", + { [field]: "Ungültige Angabe." }, + ); + } + customer[field] = value.trim(); + } + input = { + ...input, + name: `${customer.firstName} ${customer.lastName}`, + email: customer.email, + }; + } + const fields = {}; + const 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."; + } + 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) { + throw new BookingError( + 400, + "INVALID_INPUT", + "Bitte die markierten Angaben prüfen.", + fields, + ); + } + if (input.variantId != null) { + if ( + typeof input.variantId !== "string" || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.variantId) || + input.variantId.length > 80 + ) { + throw new BookingError( + 400, + "INVALID_VARIANT", + "Bitte eine gültige Variante auswählen.", + ); + } + result.variantId = input.variantId; + } + if (customer) { + result.customer = customer; + result.acceptedTerms = true; + } + return result; +} + +// 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, + bookedPackage: record.bookedPackage || null, + customerEmail: record.email, + status: "CONFIRMED", + saved: true, + emailStatus, + replayed, + }, + }; +} + +export function createBookingService({ + storage, + sendConfirmation, + packages, + details = packagesDetails, +}) { + function getPackage(id) { + const selected = + packages.some((entry) => entry.id === id) && + details.find((entry) => entry.id === id); + if (!selected) + throw new BookingError( + 404, + "UNKNOWN_PACKAGE", + "Dieses Paket ist nicht verfügbar. Bitte wähle ein Paket auf der Angebotsseite aus.", + ); + return structuredClone(selected); + } + function preview(input) { + if ( + !input || + typeof input !== "object" || + Array.isArray(input) || + typeof input.packageId !== "string" + ) { + throw new BookingError( + 400, + "INVALID_INPUT", + "Bitte ein gültiges Paket auswählen.", + ); + } + let selected; + try { + selected = getPackage(input.packageId); + } catch (error) { + error.status = 400; + throw error; + } + const variants = selected.variants || []; + const variantId = input.variantId ?? variants[0]?.variantId ?? null; + const variant = variants.find((entry) => entry.variantId === variantId); + if ( + (variants.length && !variant) || + (!variants.length && + variantId !== null && + variantId !== selected.id) + ) { + throw new BookingError( + 400, + "INVALID_VARIANT", + "Diese Variante gehört nicht zum ausgewählten Paket.", + ); + } + const grossCents = Math.round((variant?.price ?? selected.price) * 100); + const netCents = Math.round(grossCents / (1 + selected.taxRate / 100)); + return { + packageId: selected.id, + variantId: variant?.variantId ?? null, + variantLabel: variant?.label ?? null, + type: selected.type, + title: selected.title, + subtitle: selected.subtitle, + summaryForBooking: selected.summaryForBooking, + durationWeeks: variant?.durationWeeks ?? selected.durationWeeks, + durationLabel: variant + ? `${variant.durationWeeks} Wochen Begleitung` + : selected.durationLabel, + quantity: 1, + billingInterval: selected.billingInterval, + includedFeatures: selected.includedFeatures, + processSteps: selected.processSteps, + netPrice: netCents / 100, + taxRate: selected.taxRate, + taxAmount: (grossCents - netCents) / 100, + grossPrice: grossCents / 100, + currency: selected.currency, + }; + } + // Lesen, Speichern und Versand laufen innerhalb eines Prozesses nacheinander. + let queue = Promise.resolve(); + 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." }, + ); + } + const bookedPackage = preview(data); + record = { + bookingId: nextBookingId(records), + createdAt: new Date().toISOString(), + packageId: selected.id, + packageName: selected.name, + bookedPackage, + customer: data.customer ?? null, + acceptedTerms: data.acceptedTerms ?? null, + name: data.name, + email: data.email, + phone: data.customer?.phone ?? data.phone ?? "", + 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 { + getPackage, + preview, + 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..2236e99 --- /dev/null +++ b/src/server/services/csv-storage.js @@ -0,0 +1,107 @@ +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"; + +const legacyColumns = [ + "bookingId", + "createdAt", + "packageId", + "packageName", + "name", + "email", + "emailStatus", + "idempotencyKey", + "requestHash", +]; + +const orderColumns = [...legacyColumns, "bookedPackage"]; +const customerColumns = [...orderColumns, "customer", "acceptedTerms"]; +export const columns = [...customerColumns, "phone"]; +const jsonColumns = ["bookedPackage", "customer", "acceptedTerms"]; + +// Apostroph-Präfix schützt Tabellenprogramme. Ein vorhandenes Apostroph wird +// 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(",") && + header.join(",") !== customerColumns.join(",") && + header.join(",") !== legacyColumns.join(",") && + header.join(",") !== orderColumns.join(",") + ) { + throw new Error("Unbekanntes CSV-Format"); + } + return header; + }, + skip_empty_lines: true, + }).map((row) => + Object.fromEntries( + Object.entries(row).map(([key, value]) => [ + key, + jsonColumns.includes(key) + ? value + ? JSON.parse(restoreCell(value)) + : null + : 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( + jsonColumns.includes(column) && + row[column] != null + ? JSON.stringify(row[column]) + : 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..a3415ec --- /dev/null +++ b/src/server/services/email-service.js @@ -0,0 +1,124 @@ +import nodemailer from "nodemailer"; +import { orderSummaryRows } from "../../shared/order-summary.js"; +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) { + const order = booking.bookedPackage; + const phone = booking.phone || booking.customer?.phone; + const lines = [ + `Hallo ${booking.name},`, + "", + "vielen Dank für deine Bestellung bei Tri-Hub. Hier findest du die Zusammenfassung deiner Buchung.", + `Buchungsnummer: ${booking.bookingId}`, + ...(booking.createdAt + ? [ + `Bestelldatum: ${new Intl.DateTimeFormat("de-DE", { dateStyle: "long", timeStyle: "short", timeZone: "Europe/Berlin" }).format(new Date(booking.createdAt))} (Europe/Berlin)`, + ] + : []), + `Kunde: ${booking.name}`, + `E-Mail: ${booking.email}`, + ...(phone ? [`Telefon: ${phone}`] : []), + ...(booking.customer?.notes + ? [`Deine Anmerkungen: ${booking.customer.notes}`] + : []), + "", + `Paket: ${order?.title ?? booking.packageName}`, + ...(order + ? [ + order.summaryForBooking, + order.subtitle, + "", + ...orderSummaryRows(order).map( + ([label, value]) => `${label}: ${value}`, + ), + "", + "Enthaltene Leistungen:", + ...order.includedFeatures.map( + (feature) => `• ${feature}`, + ), + "", + "So geht es weiter:", + ...(order.processSteps || []).map( + ({ step, text }) => `${step}: ${text}`, + ), + ] + : []), + "", + paymentNotice, + "", + "Bitte bewahre diese Bestätigung auf und gib bei Rückfragen deine Buchungsnummer an.", + "Dein Tri-Hub-Team", + ]; + const escapeHtml = (value) => + String(value).replace( + /[&<>"']/g, + (char) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[char], + ); + let info; + try { + info = await transport.sendMail({ + from, + to: { address: booking.email, name: booking.name }, + subject: `Bestellbestätigung ${booking.bookingId} – Tri-Hub`, + text: lines.join("\n"), + html: `

    Deine Bestellbestätigung

    ${lines.map((line) => (line ? `

    ${escapeHtml(line)}

    ` : "")).join("")}`, + }); + } 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/server/swagger.json b/src/server/swagger.json new file mode 100644 index 0000000..7ba05eb --- /dev/null +++ b/src/server/swagger.json @@ -0,0 +1,682 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Tri-Hub Ernährungsberatung & Booking API", + "description": "API-Spezifikation für die Angebotsübersicht (US2.1), die Paket-Detailansicht und den Buchungsservice (US2.4 Paket buchen).", + "version": "1.1.0" + }, + "servers": [ + { + "url": "http://localhost:3000/api", + "description": "Lokaler Node.js Entwicklungsserver" + } + ], + "paths": { + "/packages": { + "get": { + "summary": "Alle Ernährungs-Pakete abrufen (Kurzübersicht)", + "description": "Liefert die Basisdaten aus packages.js für die Angebotsübersicht (US2.1).", + "tags": ["Packages"], + "responses": { + "200": { + "description": "Erfolgreiche Rückgabe der Paketliste", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PackageOverview" + } + } + } + } + } + } + } + }, + "/packages/{id}": { + "get": { + "summary": "Detaillierte Paketinformationen für Detailseite & Bestellprozess abrufen", + "description": "Liefert die ausführlichen Paketdaten aus packagesdetails.js (inkl. Preis, Laufzeit, Leistungen und Varianten), um sie auf der Detailseite und in der Bestellzusammenfassung des Booking-Service anzuzeigen.", + "tags": ["Packages", "Booking"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Eindeutige ID des Pakets (z. B. ernaehrung-starter)", + "schema": { + "type": "string", + "example": "ernaehrung-standard" + } + } + ], + "responses": { + "200": { + "description": "Detaillierte Paketdaten erfolgreich geladen", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackageDetail" + } + } + } + }, + "404": { + "description": "Paket mit dieser ID wurde nicht gefunden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/bookings/preview": { + "post": { + "summary": "Preis- und Leistungsübersicht für den Bestellprozess berechnen", + "description": "Berechnet die Bestellübersicht aus dem serverseitigen Paketkatalog. Preisangaben aus der Anfrage werden ignoriert. Ohne Varianten-ID wird bei Paketen mit Varianten die erste Variante gewählt. Es wird keine Buchung angelegt.", + "tags": ["Booking"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingPreviewRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Berechnete Bestellübersicht für das Checkout-Formular", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingPreviewResponse" + } + } + } + }, + "400": { + "description": "Ungültige Paket- oder Varianten-ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/bookings": { + "post": { + "summary": "Paket verbindlich buchen (US2.4)", + "description": "Speichert Paket, gewählte Variante, Leistungen und Preise zum Buchungszeitpunkt und startet den Bestätigungsversand. Der aktuelle Ablauf simuliert die Bestellung ohne Zahlung.", + "tags": ["Booking"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Buchung erfolgreich angelegt (Bestellbestätigung)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingConfirmation" + } + } + } + }, + "400": { + "description": "Fehlende oder ungültige Eingabedaten", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "200": { + "description": "Bereits gespeicherte Buchung; keine zweite Buchung oder Mail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingConfirmation" + } + } + } + }, + "202": { + "description": "Buchung gespeichert; Versand ausstehend, fehlgeschlagen oder unklar", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BookingConfirmation" + } + } + } + }, + "409": { + "description": "Anfrageschlüssel wurde mit anderen Buchungsdaten verwendet", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "413": { + "description": "Anfrage größer als 8 KiB", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "415": { + "description": "JSON-Content-Type erforderlich", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "503": { + "description": "Speicherung oder Statusprüfung nicht verfügbar", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "parameters": [ + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "UUID v4; bei Wiederholung denselben Schlüssel und dieselben Buchungsdaten verwenden." + } + ] + } + } + }, + "components": { + "schemas": { + "PackageOverview": { + "type": "object", + "required": [ + "id", + "type", + "title", + "description", + "fokus", + "anamnese", + "begleitung", + "buttonText" + ], + "properties": { + "id": { + "type": "string", + "example": "ernaehrung-standard" + }, + "type": { + "type": "string", + "example": "STANDARD" + }, + "title": { + "type": "string", + "example": "Für Best Ager mit ersten Erfahrungen und klaren Zielen" + }, + "description": { + "type": "string" + }, + "fokus": { + "type": "string" + }, + "anamnese": { + "type": "string" + }, + "begleitung": { + "type": "string" + }, + "buttonText": { + "type": "string", + "example": "Standard-Paket ansehen" + }, + "highlight": { + "type": "string", + "nullable": true, + "example": "BESONDERS PASSEND" + } + } + }, + "PackageVariant": { + "type": "object", + "properties": { + "variantId": { + "type": "string", + "example": "ernaehrung-premium-24" + }, + "label": { + "type": "string", + "example": "24-Wochen-Variante" + }, + "durationWeeks": { + "type": "integer", + "example": 24 + }, + "price": { + "type": "number", + "format": "float", + "example": 799.0, + "description": "Bruttopreis der Variante inklusive MwSt." + } + } + }, + "PackageDetail": { + "type": "object", + "required": [ + "id", + "type", + "title", + "subtitle", + "durationWeeks", + "price", + "currency", + "taxRate", + "includedFeatures" + ], + "properties": { + "id": { + "type": "string", + "example": "ernaehrung-standard" + }, + "type": { + "type": "string", + "example": "STANDARD" + }, + "badge": { + "type": "string", + "example": "BESONDERS PASSEND" + }, + "title": { + "type": "string", + "example": "Für Best Ager mit ersten Erfahrungen und klaren Zielen" + }, + "subtitle": { + "type": "string" + }, + "durationWeeks": { + "type": "integer", + "example": 24 + }, + "durationLabel": { + "type": "string", + "example": "24 Wochen Begleitung" + }, + "price": { + "type": "number", + "format": "float", + "example": 449.0, + "description": "Bruttopreis inklusive der angegebenen MwSt." + }, + "currency": { + "type": "string", + "example": "EUR" + }, + "billingInterval": { + "type": "string", + "example": "einmalig" + }, + "taxRate": { + "type": "integer", + "example": 19 + }, + "summaryForBooking": { + "type": "string", + "example": "Standard-Paket Sporternährung (24 Wochen Coaching)" + }, + "targetGroup": { + "type": "array", + "items": { + "type": "string" + } + }, + "includedFeatures": { + "type": "array", + "items": { + "type": "string" + } + }, + "processSteps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "step": { + "type": "string" + }, + "text": { + "type": "string" + } + } + } + }, + "variants": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/PackageVariant" + } + }, + "ctaButtonText": { + "type": "string", + "example": "Standard-Paket jetzt buchen" + } + } + }, + "BookingPreviewRequest": { + "type": "object", + "required": ["packageId"], + "properties": { + "packageId": { + "type": "string", + "example": "ernaehrung-premium" + }, + "variantId": { + "type": "string", + "nullable": true, + "example": "ernaehrung-premium-52" + } + } + }, + "BookingPreviewResponse": { + "type": "object", + "properties": { + "packageId": { + "type": "string", + "example": "ernaehrung-premium" + }, + "variantId": { + "type": "string", + "example": "ernaehrung-premium-52", + "nullable": true + }, + "title": { + "type": "string", + "example": "Für maximale Individualität und intensive 1:1-Begleitung" + }, + "summaryForBooking": { + "type": "string" + }, + "durationWeeks": { + "type": "integer", + "example": 52 + }, + "netPrice": { + "type": "number", + "format": "float", + "example": 1175.63 + }, + "taxAmount": { + "type": "number", + "format": "float", + "example": 223.37 + }, + "grossPrice": { + "type": "number", + "format": "float", + "example": 1399.0 + }, + "currency": { + "type": "string", + "example": "EUR" + }, + "variantLabel": { + "type": "string", + "nullable": true + }, + "type": { + "type": "string" + }, + "subtitle": { + "type": "string" + }, + "durationLabel": { + "type": "string" + }, + "quantity": { + "type": "integer", + "enum": [1] + }, + "billingInterval": { + "type": "string", + "example": "einmalig" + }, + "taxRate": { + "type": "number", + "example": 19 + }, + "includedFeatures": { + "type": "array", + "items": { + "type": "string" + } + }, + "processSteps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "step": { + "type": "string" + }, + "text": { + "type": "string" + } + } + } + } + }, + "required": [ + "packageId", + "variantId", + "title", + "summaryForBooking", + "durationWeeks", + "netPrice", + "taxAmount", + "grossPrice", + "currency", + "variantLabel", + "type", + "subtitle", + "durationLabel", + "quantity", + "billingInterval", + "taxRate", + "includedFeatures", + "processSteps" + ] + }, + "BookingCreateRequest": { + "type": "object", + "required": ["packageId"], + "properties": { + "packageId": { + "type": "string", + "example": "ernaehrung-standard" + }, + "variantId": { + "type": "string", + "nullable": true, + "example": "ernaehrung-premium-52" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "example": "Thomas Müller" + }, + "email": { + "type": "string", + "format": "email", + "maxLength": 254, + "example": "thomas.mueller@example.de" + }, + "phone": { + "type": "string", + "maxLength": 50, + "description": "Optionale Telefonnummer: deutsche Nummer mit Vorwahl oder internationale Nummer mit Ländervorwahl. Prüfung des Nummernformats, keine Bestätigung der Erreichbarkeit. Bei customer gilt customer.phone.", + "example": "+49 170 1234567" + }, + "customer": { + "type": "object", + "required": ["firstName", "lastName", "email"], + "properties": { + "firstName": { + "type": "string", + "example": "Thomas" + }, + "lastName": { + "type": "string", + "example": "Müller" + }, + "email": { + "type": "string", + "format": "email", + "example": "thomas.mueller@example.de" + }, + "phone": { + "type": "string", + "maxLength": 50, + "description": "Optional. Deutsche Nummer mit Vorwahl oder internationale Nummer mit Ländervorwahl; keine Bestätigung der Erreichbarkeit.", + "example": "+49 170 1234567" + }, + "ageGroup": { + "type": "string", + "example": "50-59" + }, + "notes": { + "type": "string", + "example": "Vorbereitung auf meine erste Mitteldistanz im August." + } + } + }, + "acceptedTerms": { + "type": "boolean", + "example": true, + "enum": [true] + } + }, + "anyOf": [ + { + "required": ["customer", "acceptedTerms"] + }, + { + "required": ["name", "email"] + } + ], + "description": "Kundendaten gemäß customer mit acceptedTerms=true. Bestehende Clients können alternativ name und email sowie optional phone übergeben." + }, + "BookingConfirmation": { + "type": "object", + "properties": { + "bookingId": { + "type": "string", + "example": "TH-000001" + }, + "status": { + "type": "string", + "example": "CONFIRMED" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "bookedPackage": { + "allOf": [ + { + "$ref": "#/components/schemas/BookingPreviewResponse" + } + ], + "nullable": true, + "description": "Gespeicherter Bestellstand; bei älteren Buchungen ohne diese Daten null." + }, + "customerEmail": { + "type": "string", + "example": "thomas.mueller@example.de" + }, + "packageId": { + "type": "string" + }, + "packageName": { + "type": "string" + }, + "saved": { + "type": "boolean" + }, + "emailStatus": { + "type": "string", + "enum": ["pending", "accepted", "failed", "unknown"], + "description": "accepted bedeutet SMTP-Annahme, keine bestätigte Zustellung." + }, + "replayed": { + "type": "boolean" + } + } + }, + "ErrorResponse": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "example": "INVALID_VARIANT" + }, + "message": { + "type": "string" + }, + "fields": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + } + } +} diff --git a/src/shared/booking-copy.js b/src/shared/booking-copy.js new file mode 100644 index 0000000..1d958a7 --- /dev/null +++ b/src/shared/booking-copy.js @@ -0,0 +1,2 @@ +export const paymentNotice = + "Dieser Buchungsablauf ist eine Simulation. Der Zahlungsvorgang wird übersprungen: Es erfolgt keine Abbuchung und keine verbindliche Beratungsbuchung."; diff --git a/src/shared/order-summary.js b/src/shared/order-summary.js new file mode 100644 index 0000000..9bf80b8 --- /dev/null +++ b/src/shared/order-summary.js @@ -0,0 +1,28 @@ +export function formatMoney(amount, currency) { + return new Intl.NumberFormat("de-DE", { + style: "currency", + currency, + }).format(amount); +} + +export function orderSummaryRows(order) { + return [ + ["Paket", order.type], + ["Paket-ID", order.packageId], + ...(order.variantId + ? [ + ["Variante", order.variantLabel], + ["Varianten-ID", order.variantId], + ] + : []), + ["Laufzeit", order.durationLabel || `${order.durationWeeks} Wochen`], + ["Menge", String(order.quantity)], + ["Abrechnung", order.billingInterval], + ["Nettopreis", formatMoney(order.netPrice, order.currency)], + [ + `MwSt. (${order.taxRate} %)`, + formatMoney(order.taxAmount, order.currency), + ], + ["Gesamtbetrag", formatMoney(order.grossPrice, order.currency)], + ]; +} diff --git a/src/shared/packages.js b/src/shared/packages.js new file mode 100644 index 0000000..510ebb0 --- /dev/null +++ b/src/shared/packages.js @@ -0,0 +1,44 @@ +export const packages = [ + { + id: "ernaehrung-starter", + type: "STARTER", + 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: + "Zu Beginn gehört eine ausführliche gemeinsame Anamnese dazu, damit deine Essgewohnheiten und Ziele sauber eingeordnet werden.", + begleitung: + "Ein individueller Basis-Ernährungsplan und regelmäßige Check-ins im ersten Monat.", + buttonText: "Starter-Paket ansehen", + highlight: null, + }, + { + id: "ernaehrung-standard", + type: "STANDARD", + name: "Für Best Ager mit ersten Erfahrungen und klaren Zielen", + summary: + "Für Athletinnen und Athleten, die ihre Ernährung für die Kurzdistanz und im Alltag gezielt weiterentwickeln wollen.", + fokus: "Carb-Loading, Pacing-Strategien für den Magen und Timing der Nahrungsaufnahme.", + anamnese: + "Auch hier startet das Coaching mit einer ausführlichen gemeinsamen Anamnese, damit Trainingsrealität, Erfahrung und Verträglichkeit präzise erfasst werden.", + begleitung: + "Monatliche Anpassung des Plans und enger Austausch bei Magen-Darm-Herausforderungen.", + buttonText: "Standard-Paket ansehen", + highlight: "BESONDERS PASSEND", + }, + { + id: "ernaehrung-premium", + type: "PREMIUM", + name: "Für maximale Individualität mit klarem Fokus", + summary: + "Für Menschen, die sich eine besonders enge Coaching-Beziehung und laufende Anpassung ihrer Ernährung wünschen.", + fokus: "Individuelle Stoffwechselanalyse, Mikronährstoff-Optimierung und Raceday-Nutrition.", + anamnese: + "Wie in allen Paketen ist eine ausführliche gemeinsame Anamnese enthalten, damit Betreuung, Regeneration und Zielbild von Beginn an abgestimmt werden.", + begleitung: + "Wöchentliche Anpassungen, direkte Feedback-Schleifen und passgenaue Einkaufslisten.", + buttonText: "Premium-Paket ansehen", + highlight: null, + }, +]; 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", + }, +]; 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 new file mode 100644 index 0000000..dd8c95b --- /dev/null +++ b/src/styles/packages.css @@ -0,0 +1,279 @@ +.packages-page { + margin: 0; + min-height: 100vh; + color: #f8fafc; + background: + radial-gradient(circle at 12% 0%, #10b98117, transparent 30%), + radial-gradient(circle at 88% 26%, #10b9810e, transparent 27%), + linear-gradient(145deg, #0d0d1a, #1a1a2e); + font-family: Inter, system-ui, sans-serif; + line-height: 1.6; + color-scheme: dark; +} +.packages-section, +.packages-section * { + box-sizing: border-box; +} +.packages-section { + width: min(1180px, calc(100% - 3rem)); + margin: auto; + padding-block: 5rem; + overflow-wrap: anywhere; +} +.packages-header { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); + gap: 3rem; + align-items: end; + margin-bottom: 3rem; +} +.packages-eyebrow, +.package-type { + color: #10b981; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; +} +.packages-header h1, +.detail-heading h1 { + margin: 0.75rem 0 1rem; + font-size: clamp(2rem, 4.5vw, 3.75rem); + font-weight: 600; + line-height: 1.15; + letter-spacing: -0.035em; +} +.packages-intro, +.package-desc, +.detail-text, +.detail-list, +.price-note, +.booking-notice { + color: #94a3b8; +} +.packages-intro { + margin: 0; + max-width: 40rem; +} +.packages-container { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1.5rem; +} +.package-card, +.package-checkout { + min-width: 0; + padding: 1.75rem; + border: 1px solid #f8fafc1f; + border-radius: 1.4rem; + background: #ffffff0b; +} +.package-card { + display: flex; + flex-direction: column; +} +.package-type-container { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} +.package-highlight { + border-radius: 999px; + padding: 0.25rem 0.65rem; + background: #10b98114; + color: #34d399; + border: 1px solid #10b98133; + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.05em; +} +.package-title { + margin: 0 0 1rem; + color: #fff; + font-size: 1.55rem; + font-weight: 600; + line-height: 1.25; + letter-spacing: -0.025em; +} +.package-desc { + margin: 0 0 1.5rem; +} +.package-details-box { + padding-block: 1.5rem; + border-top: 1px solid #f8fafc1f; + flex: 1; +} +.detail-item + .detail-item { + margin-top: 1.25rem; +} +.detail-title { + margin: 0 0 0.5rem; + color: #f8fafc; + font-size: 1rem; + font-weight: 600; +} +.detail-text { + font-size: 0.9rem; +} +.package-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + width: 100%; + min-height: 3rem; + padding: 0.8rem 1.1rem; + border: 1px solid #94a3b840; + border-radius: 999px; + background: #ffffff06; + color: #f8fafc; + font: inherit; + font-size: 0.9rem; + font-weight: 600; + text-align: center; + text-decoration: none; + cursor: pointer; +} +.package-btn:hover { + border-color: #10b981; + background: #10b98114; +} +.package-btn.primary-cta { + background: #10b981; + border-color: #10b981; + color: #06130f; +} +.package-btn.primary-cta:hover { + background: #34d399; +} +.package-btn:disabled { + opacity: 0.65; + cursor: wait; +} +.packages-section :focus-visible { + outline: 3px solid #10b981; + outline-offset: 4px; +} +.packages-back { + display: inline-block; + margin-bottom: 2rem; + padding-block: 0.5rem; + color: #34d399; + text-decoration: none; +} +.detail-heading { + max-width: 52rem; + margin-bottom: 3rem; +} +.detail-grid { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); + gap: 2.5rem; + align-items: start; +} +.detail-content > section + section { + margin-top: 2.5rem; +} +.detail-content h2, +.package-checkout h2 { + margin: 0 0 1rem; + font-size: 1.25rem; +} +.detail-list { + padding-left: 1.25rem; +} +.detail-list li { + margin-block: 0.65rem; +} +.detail-list li::marker { + color: #10b981; +} +.process-step { + margin-top: 1rem; + padding: 1.25rem; + border: 1px solid #f8fafc1f; + border-radius: 1rem; + background: #ffffff06; +} +.process-step strong { + display: block; + margin-bottom: 0.4rem; +} +.process-step p { + margin: 0; +} +.price-box { + margin-bottom: 1.5rem; +} +.price-amount { + display: block; + font-size: clamp(2rem, 4vw, 3rem); + font-weight: 600; + letter-spacing: -0.04em; + color: #34d399; +} +.price-note, +.booking-notice { + font-size: 0.85rem; +} +.detail-box { + margin-bottom: 1.5rem; +} +.detail-box label { + display: block; + margin-bottom: 0.5rem; +} +.variant-select { + width: 100%; + max-width: 100%; + min-width: 0; + min-height: 3rem; + padding: 0.65rem; + border: 1px solid #94a3b866; + border-radius: 0.75rem; + color: #f8fafc; + background: #0d0d1a; + font: inherit; +} +/* Die Detailseite zeigt ihre eigene Übersicht; Booking liefert den Dialog. */ +.packages-section #booking-root > .booking > h2, +.packages-section #booking-root .booking__package, +.packages-section #booking-root .booking__payment, +.packages-section #booking-root .booking__open { + display: none; +} +.packages-section .booking__dialog { + background: #0d0d1a; + color: #f8fafc; + border-color: #94a3b840; +} +.packages-section .booking__dialog .booking__button:not(.booking__cancel) { + background: #10b981; + border-color: #10b981; + color: #06130f; +} +.packages-section .booking__dialog :focus-visible { + outline-color: #10b981; +} +@media (max-width: 60rem) { + .packages-container { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); + } + .packages-header, + .detail-grid { + grid-template-columns: minmax(0, 1fr); + gap: 1.5rem; + } +} +@media (max-width: 30rem) { + .packages-section { + width: calc(100% - 2rem); + padding-block: 2.5rem; + } + .package-card, + .package-checkout { + padding: 1.25rem; + } +} diff --git a/src/tests/angebotsuebersicht.test.js b/src/tests/angebotsuebersicht.test.js new file mode 100644 index 0000000..8ace7f4 --- /dev/null +++ b/src/tests/angebotsuebersicht.test.js @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createApp } from "../server/index.js"; +import { packages } from "../shared/packages.js"; + +test("API liefert für jedes angezeigte Angebot dieselbe ID, Namen und Beschreibung", async (t) => { + const app = createApp({ sendConfirmation: async () => {} }); + await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve)); + t.after( + () => + new Promise((resolve) => { + app.closeAllConnections(); + app.close(resolve); + }), + ); + const response = await fetch( + `http://127.0.0.1:${app.address().port}/api/angebote`, + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.deepEqual( + body.packages, + packages.map(({ id, name, summary }) => ({ + id, + name, + summary, + })), + ); +}); diff --git a/src/tests/bookings.test.js b/src/tests/bookings.test.js new file mode 100644 index 0000000..2be862c --- /dev/null +++ b/src/tests/bookings.test.js @@ -0,0 +1,662 @@ +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 { packages } from "../shared/packages.js"; + +const input = { + packageId: packages[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: packages, + 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, packages[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: packages, + 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: packages, + 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: packages, + 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: packages[0].name, + }); + assert.ok(message.text.includes("TH-000001")); + assert.ok(message.text.includes(packages[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, + packages.map(({ id, name, summary }) => ({ id, name, summary })), + ); + assert.equal((await fetch(`${url}/api/packages`)).status, 200); +}); + +test("Paketvertrag lehnt fehlende, numerische und doppelte IDs ab", () => { + for (const catalog of [ + [null], + [{ ...packages[0], id: 123 }], + [{ ...packages[0], id: undefined }], + [packages[0], packages[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: packages, + 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"); +}); + +test("Detail-API und Vorschau liefern die gewählte Variante mit centgenauen Preisen", async (t) => { + const { url, storage, sent } = await setup(t, { catalog: packages }); + const details = await ( + await fetch(`${url}/api/packages/ernaehrung-premium`) + ).json(); + assert.equal(details.variants.length, 2); + assert.ok(details.includedFeatures.length > 0); + assert.equal((await fetch(`${url}/api/packages/unbekannt`)).status, 404); + const response = await fetch(`${url}/api/bookings/preview`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + packageId: details.id, + variantId: "ernaehrung-premium-52", + price: 1, + }), + }); + assert.equal(response.status, 200); + const preview = await response.json(); + assert.equal(preview.durationWeeks, 52); + assert.equal(preview.grossPrice, 1399); + assert.equal(preview.netPrice, 1175.63); + assert.equal(preview.taxAmount, 223.37); + assert.equal(preview.taxRate, 19); + assert.equal(preview.currency, "EUR"); + assert.deepEqual(preview.includedFeatures, details.includedFeatures); + assert.deepEqual(await storage.readAll(), []); + assert.equal(sent.length, 0); +}); + +test("Bestelldaten bleiben nach Katalogänderung und Neustart unverändert", async (t) => { + const fixture = await setup(t, { catalog: packages }); + const key = randomUUID(); + const data = { + ...input, + packageId: "ernaehrung-premium", + variantId: "ernaehrung-premium-52", + price: 1, + includedFeatures: ["Manipuliert"], + }; + const result = await fixture.post(data, key); + assert.equal(result.status, 201); + const order = result.body.bookedPackage; + assert.equal(order.grossPrice, 1399); + assert.equal(order.durationWeeks, 52); + assert.equal(order.variantId, data.variantId); + assert.ok(!order.includedFeatures.includes("Manipuliert")); + assert.deepEqual((await fixture.storage.readAll())[0].bookedPackage, order); + assert.deepEqual(fixture.sent[0].bookedPackage, order); + const restarted = createBookingService({ + storage: fixture.storage, + packages: [], + details: [], + sendConfirmation: async () => assert.fail("Doppelversand"), + }); + assert.deepEqual( + (await restarted.book(data, key)).body.bookedPackage, + order, + ); + assert.equal( + ( + await fixture.post( + { ...data, variantId: "ernaehrung-premium-24" }, + key, + ) + ).status, + 409, + ); +}); + +test("Unbekannte und paketfremde Varianten werden vor dem Speichern abgelehnt", async (t) => { + const { post, storage, sent, url } = await setup(t, { catalog: packages }); + for (const data of [ + { packageId: "ernaehrung-premium", variantId: "unbekannt" }, + { packageId: "ernaehrung-starter", variantId: "ernaehrung-premium-52" }, + { packageId: "ernaehrung-premium", variantId: 52 }, + ]) { + assert.equal((await post({ ...input, ...data })).status, 400); + assert.equal( + ( + await fetch(`${url}/api/bookings/preview`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }) + ).status, + 400, + ); + } + assert.deepEqual(await storage.readAll(), []); + assert.equal(sent.length, 0); +}); + +test("Bestehende CSV ohne Bestelldaten bleibt lesbar und wird verlustfrei erweitert", async (t) => { + const { post, file, storage } = await setup(t); + const key = randomUUID(); + await post(input, key); + const [record] = await storage.readAll(); + await writeFile( + file, + `bookingId,createdAt,packageId,packageName,name,email,emailStatus,idempotencyKey,requestHash\nTH-000001,${record.createdAt},ernaehrung-starter,Altes Paket,Alte Person,person@example.test,accepted,alter-key,alter-hash\n`, + ); + assert.equal((await post()).status, 201); + const records = await storage.readAll(); + assert.equal(records.length, 2); + assert.equal(records[0].packageName, "Altes Paket"); + assert.equal(records[0].bookedPackage, null); + assert.equal(records[1].bookedPackage.packageId, "ernaehrung-starter"); +}); + +test("Bestätigungsmail enthält gespeicherte Leistungen, Variante, Preise und maskiertes HTML", async (t) => { + const { post } = await setup(t, { catalog: packages }); + const result = await post({ + ...input, + packageId: "ernaehrung-premium", + variantId: "ernaehrung-premium-52", + }); + let message; + const send = createEmailService({}, () => ({ + sendMail: async (mail) => { + message = mail; + return { accepted: [input.email] }; + }, + })); + await send({ ...input, name: "", ...result.body }); + assert.match(message.subject, /Bestellbestätigung TH-/); + for (const text of [ + "52-Wochen-Variante", + "52 Wochen", + "1.399,00", + "1.175,63", + "223,37", + "19 %", + "einmalig", + "Bestelldatum:", + ...result.body.bookedPackage.includedFeatures, + ]) { + assert.ok(message.text.includes(text), text); + } + assert.ok(message.html.includes("<img src=x>")); + assert.ok(!message.html.includes("")); +}); + +test("Swagger-Kundendaten und Zustimmung werden validiert und dauerhaft gespeichert", async (t) => { + const { post, storage, sent } = await setup(t, { catalog: packages }); + const customer = { + firstName: "Thomas", + lastName: "Müller", + email: "thomas@example.test", + phone: "+49 170 1234567", + ageGroup: "50-59", + notes: "Vorbereitung auf die Mitteldistanz.", + }; + const request = { + packageId: "ernaehrung-standard", + customer, + acceptedTerms: true, + }; + for (const body of [ + { ...request, acceptedTerms: false }, + { ...request, acceptedTerms: "true" }, + { ...request, customer: [] }, + { ...request, customer: { ...customer, firstName: "" } }, + { ...request, customer: { ...customer, email: "ungültig" } }, + { ...request, customer: { ...customer, phone: 123 } }, + ]) + assert.equal((await post(body)).status, 400); + const key = randomUUID(); + const result = await post(request, key); + assert.equal(result.status, 201); + assert.equal(result.body.customerEmail, customer.email); + const [record] = await storage.readAll(); + assert.equal(record.name, "Thomas Müller"); + assert.deepEqual(record.customer, customer); + assert.equal(record.acceptedTerms, true); + assert.deepEqual(sent[0].customer, customer); + assert.equal((await post(request, key)).status, 200); + assert.equal( + ( + await post( + { + ...request, + customer: { ...customer, notes: "Anderer Auftrag" }, + }, + key, + ) + ).status, + 409, + ); +}); + +test("Telefonnummern werden in beiden Anfrageformaten geprüft", async (t) => { + const { post, sent } = await setup(t); + for (const phone of [ + "123", + "00000000000", + "abc", + "+999 123456789", + "0170 test 1234567", + ]) { + for (const data of [ + { ...input, phone }, + { + packageId: input.packageId, + acceptedTerms: true, + customer: { + firstName: "Test", + lastName: "Person", + email: input.email, + phone, + }, + }, + ]) { + const result = await post(data); + assert.equal(result.status, 400, phone); + assert.ok(result.body.error.fields.phone); + } + } + assert.equal(sent.length, 0); + for (const phone of [ + "", + "0170 1234567", + "+49 170 1234567", + "0049 170 1234567", + "030 123456", + "+44 20 7946 0958", + ]) { + const result = await post({ ...input, phone }); + assert.equal(result.status, 201, phone); + } +}); diff --git a/src/tests/browser/booking.spec.js b/src/tests/browser/booking.spec.js new file mode 100644 index 0000000..41b86ae --- /dev/null +++ b/src/tests/browser/booking.spec.js @@ -0,0 +1,294 @@ +import { test, expect } from "@playwright/test"; + +import { packages } from "../../shared/packages.js"; +import { packagesDetails } from "../../shared/packagesdetails.js"; + +const pageUrl = "/booking.html#/buchen/ernaehrung-starter"; +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: packagesDetails[0].title }), + ).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()); + } +}); + +for (const pkg of packages) { + test(`Von der Startseite zur Buchung: ${pkg.type}`, async ({ page }) => { + await page.goto("/"); + await page.getByRole("link", { name: "Beratung kennenlernen" }).click(); + await expect(page.locator(".package-card")).toHaveCount( + packages.length, + ); + await page.getByRole("link", { name: pkg.buttonText }).click(); + await expect(page).toHaveURL( + new RegExp(`paket-details\\.html\\?id=${pkg.id}$`), + ); + await page.locator("#proceed-to-booking-btn").click(); + await expect(page).toHaveURL( + new RegExp(`booking\\.html\\?id=${pkg.id}`), + ); + const details = packagesDetails.find((entry) => entry.id === pkg.id); + await expect( + page.getByRole("heading", { name: details.title, exact: true }), + ).toBeVisible(); + await expect(page.locator(".booking__summary")).toHaveText( + details.summaryForBooking, + ); + await expect(page.locator(".booking__features li")).toHaveCount( + details.includedFeatures.length, + ); + await fill(page); + const responsePromise = page.waitForResponse( + (response) => + response.url().endsWith("/api/bookings") && + response.request().method() === "POST", + ); + await page + .getByRole("button", { name: "Paket buchen", exact: true }) + .click(); + const response = await responsePromise; + expect(response.status()).toBe(201); + const booking = await response.json(); + expect(booking.packageId).toBe(pkg.id); + expect(booking.packageName).toBe(pkg.name); + await expect(page.locator(".booking__result")).toContainText( + booking.bookingId, + ); + await page.getByRole("link", { name: "Zur Angebotsseite" }).click(); + await expect(page.locator(".package-card")).toHaveCount( + packages.length, + ); + }); +} + +test("Angebotsübersicht passt auf ein schmales Display", async ({ page }) => { + await page.setViewportSize({ width: 320, height: 640 }); + await page.goto("/angebotsuebersicht.html"); + await expect(page.locator(".package-card")).toHaveCount(packages.length); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ).toBe(true); +}); + +test("Premium-Jahresvariante wird von der Detailseite bis zur Bestätigung übernommen", async ({ + page, +}) => { + await page.goto("/paket-details.html?id=ernaehrung-premium"); + await page.locator("#variant-select").selectOption("ernaehrung-premium-52"); + await page.locator("#proceed-to-booking-btn").click(); + await expect(page).toHaveURL( + /booking\.html\?id=ernaehrung-premium&variant=ernaehrung-premium-52$/, + ); + await expect(page.locator(".booking__totals")).toContainText( + "52 Wochen Begleitung", + ); + await expect(page.locator(".booking__totals")).toContainText("1.399,00"); + await page.reload(); + await fill(page); + const responsePromise = page.waitForResponse( + (response) => + response.url().endsWith("/api/bookings") && + response.request().method() === "POST", + ); + await page + .getByRole("button", { name: "Paket buchen", exact: true }) + .click(); + const response = await responsePromise; + expect(response.request().postDataJSON().variantId).toBe( + "ernaehrung-premium-52", + ); + const booking = await response.json(); + expect(booking.bookedPackage.grossPrice).toBe(1399); + expect(booking.bookedPackage.durationWeeks).toBe(52); + await expect(page.locator(".booking__result")).toContainText( + booking.bookingId, + ); + await page.reload(); + await expect(page.locator(".booking__totals")).toContainText("1.399,00"); + await expect(page.locator(".booking__result")).toContainText( + booking.bookingId, + ); +}); + +test("Paketfremde Variante zeigt kein Buchungsformular", async ({ page }) => { + await page.goto( + "/booking.html?id=ernaehrung-starter&variant=ernaehrung-premium-52", + ); + await expect( + page.getByText("Diese Variante gehört nicht zum ausgewählten Paket."), + ).toBeVisible(); + await expect(page.locator("form")).toHaveCount(0); +}); diff --git a/src/tests/helpers/browser-server.js b/src/tests/helpers/browser-server.js new file mode 100644 index 0000000..f83d189 --- /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 { packages } from "../../shared/packages.js"; + +const directory = await mkdtemp(join(tmpdir(), "trihub-browser-")); +const server = createApp({ + catalog: packages, + 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/src/tests/packages.test.js b/src/tests/packages.test.js new file mode 100644 index 0000000..04fd5dd --- /dev/null +++ b/src/tests/packages.test.js @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { packages } from "../shared/packages.js"; +import { createApp } from "../server/index.js"; + +test("Angebote erfüllen den gemeinsamen Vertrag von Übersicht und Buchung", () => { + assert.ok(packages.length > 0); + assert.doesNotThrow(() => + createApp({ + catalog: packages, + sendConfirmation: async () => {}, + }), + ); + for (const pkg of packages) { + for (const field of [ + "name", + "summary", + "type", + "fokus", + "anamnese", + "begleitung", + "buttonText", + ]) { + assert.equal(typeof pkg[field], "string"); + assert.ok(pkg[field].trim()); + } + } +}); diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..a849d45 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,38 @@ +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: { + angebote: fileURLToPath( + new URL("./angebotsuebersicht.html", import.meta.url), + ), + details: fileURLToPath( + new URL("./paket-details.html", import.meta.url), + ), + main: fileURLToPath(new URL("./index.html", import.meta.url)), + bookingConfirmation: fileURLToPath( + new URL("./booking-confirmation.html", import.meta.url), + ), + booking: fileURLToPath( + new URL("./booking.html", import.meta.url), + ), + }, + }, + }, +});