Compare commits

..

No commits in common. "feature/booking-component" and "main" have entirely different histories.

46 changed files with 7 additions and 4647 deletions

View File

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

View File

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

View File

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

View File

@ -1,11 +0,0 @@
# 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 <buchung@example.test>"
# Für echtes SMTP erst nach ausdrücklicher Freigabe ändern.
SMTP_ALLOW_EXTERNAL=false

6
.gitignore vendored
View File

@ -6,9 +6,3 @@ dist/
.DS_Store
Thumbs.db
*.log
# Private Buchungsdaten und lokale Testergebnisse
src/server/data/*
!src/server/data/.gitkeep
playwright-report/
test-results/

View File

@ -1,7 +1,3 @@
node_modules/
dist/
package-lock.json
src/server/data/
playwright-report/
test-results/

View File

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

View File

@ -1,19 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ernährungs-Pakete – Tri-Hub</title>
</head>
<body>
<header class="container"><a href="/">Zur Startseite</a></header>
<main class="packages-section" id="angebotsuebersicht-section">
<div class="packages-header"><h1>Unsere Ernährungs-Pakete</h1></div>
<div class="packages-container" id="packages-container"></div>
</main>
<script
type="module"
src="/src/features/angebote/angebote-entry.js"
></script>
</body>
</html>

View File

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

View File

@ -1,21 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<title>Paket buchen – Tri-Hub</title>
</head>
<body>
<main class="container">
<a href="/angebotsuebersicht.html">Zur Angebotsseite</a>
<h1>Deine Buchung</h1>
<!-- Nur diesen Mount-Punkt später in die Paketdetailseite übernehmen. -->
<div id="booking-root"></div>
</main>
<script
type="module"
src="/src/features/booking/booking-entry.js"
></script>
</body>
</html>

View File

@ -1,143 +0,0 @@
# 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 <http://localhost:5173/angebotsuebersicht.html>,
der Mailfänger unter <http://localhost:8025>. 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 <buchung@example.test>` | 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` und `email`.
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.
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.

View File

@ -20,13 +20,12 @@
Individuelle 1:1-Beratung für deinen Alltag und deine
sportlichen Ziele.
</p>
<a href="/angebotsuebersicht.html">Beratung kennenlernen</a>
<a href="#beratung">Beratung kennenlernen</a>
<section id="beratung" aria-labelledby="beratung-heading">
<h2 id="beratung-heading">Deine persönliche Beratung</h2>
<p>
Entdecke unsere Pakete für deine Ernährung im Alltag und
Sport.
Hier stellen wir bald unseren Ablauf und unser Angebot vor.
</p>
</section>
</main>

72
package-lock.json generated
View File

@ -7,13 +7,7 @@
"": {
"name": "ernaehrungsberatung",
"version": "0.1.0",
"dependencies": {
"csv-parse": "^7.0.2",
"csv-stringify": "^6.8.3",
"nodemailer": "^10.0.10"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"prettier": "^3.6.2",
"vite": "^7.1.7"
},
@ -483,22 +477,6 @@
"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",
@ -895,18 +873,6 @@
"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",
@ -1001,15 +967,6 @@
"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",
@ -1030,35 +987,6 @@
"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",

View File

@ -11,21 +11,10 @@
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 5173 --strictPort",
"format": "prettier --write .",
"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"
"format:check": "prettier --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",
"nodemailer": "^10.0.10"
}
}

View File

@ -1,32 +0,0 @@
<!doctype html>
<html lang="de" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Paket-Details – Tri-Hub Ernährungsberatung</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Playfair+Display:wght@600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/src/styles/packages.css" />
</head>
<body>
<header class="container">
<a href="angebotsuebersicht.html">← Zurück zur Angebotsübersicht</a>
</header>
<main class="packages-section">
<div id="package-detail-container" class="detail-page-container">
<!-- Wird dynamisch durch paket-details.js gefüllt -->
</div>
</main>
<script
type="module"
src="/src/features/angebote/paket-details.js"
></script>
</body>
</html>

View File

@ -1,20 +0,0 @@
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,
},
],
});

View File

View File

View File

@ -1,5 +0,0 @@
import "../../styles/styles.css";
import "../../styles/packages.css";
import { renderAngebotsuebersicht } from "./angebotsuebersicht.js";
renderAngebotsuebersicht();

View File

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

View File

@ -1,102 +0,0 @@
import { packagesDetails } from "../../shared/packagesdetails.js";
document.addEventListener("DOMContentLoaded", () => {
const container = document.getElementById("package-detail-container");
if (!container) return;
const params = new URLSearchParams(window.location.search);
const packageId = params.get("id");
const pkg = packagesDetails.find((item) => item.id === packageId);
if (!pkg) {
container.innerHTML = `
<div class="package-card">
<h2>Paket nicht gefunden</h2>
<p>Bitte wähle ein gültiges Angebot aus der Übersicht.</p>
<a href="angebotsuebersicht.html" class="package-btn">Zur Übersicht</a>
</div>
`;
return;
}
const variantsHtml = pkg.variants
? `<div class="detail-box">
<h3>Wähle deine Variante</h3>
<select id="variant-select" class="variant-select">
${pkg.variants.map((v) => `<option value="${v.variantId}" data-price="${v.price}">${v.label} –${v.price.toFixed(2)} €</option>`).join("")}
</select>
</div>`
: "";
container.innerHTML = `
<div class="package-card detail-view">
<div class="package-type-container">
<span class="package-type">${pkg.type} · ${pkg.durationLabel}</span>
<span class="package-highlight">${pkg.badge}</span>
</div>
<h1 class="package-title">${pkg.title}</h1>
<p class="package-desc">${pkg.subtitle}</p>
<div class="price-box">
<span class="price-amount" id="displayed-price">${pkg.price.toFixed(2)} €</span>
<span class="price-note">inkl. ${pkg.taxRate}% MwSt. (${pkg.billingInterval})</span>
</div>
${variantsHtml}
<div class="package-details-box">
<div class="detail-item">
<h3 class="detail-title">Für wen ist dieses Paket ideal?</h3>
<ul class="detail-list">
${pkg.targetGroup.map((item) => `<li>${item}</li>`).join("")}
</ul>
</div>
<div class="detail-item">
<h3 class="detail-title">Das ist im Paket enthalten</h3>
<ul class="detail-list">
${pkg.includedFeatures.map((feat) => `<li>${feat}</li>`).join("")}
</ul>
</div>
<div class="detail-item">
<h3 class="detail-title">So läuft deine Begleitung ab</h3>
${pkg.processSteps
.map(
(s) => `
<div class="process-step">
<strong>${s.step}:</strong> <span class="detail-text">${s.text}</span>
</div>
`,
)
.join("")}
</div>
</div>
<button id="proceed-to-booking-btn" class="package-btn primary-cta">
${pkg.ctaButtonText}
</button>
</div>
`;
const variantSelect = document.getElementById("variant-select");
const displayedPrice = document.getElementById("displayed-price");
if (variantSelect && displayedPrice) {
variantSelect.addEventListener("change", (e) => {
const selectedOption = e.target.selectedOptions[0];
const newPrice = parseFloat(
selectedOption.getAttribute("data-price"),
);
displayedPrice.textContent = `${newPrice.toFixed(2)} €`;
});
}
const bookingBtn = document.getElementById("proceed-to-booking-btn");
bookingBtn.addEventListener("click", () => {
const params = new URLSearchParams({ id: pkg.id });
if (variantSelect) params.set("variant", variantSelect.value);
window.location.href = `booking.html?${params}`;
});
});

View File

@ -1,54 +0,0 @@
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,
});
}

View File

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

View File

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

View File

@ -1,16 +0,0 @@
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();

View File

@ -1,412 +0,0 @@
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 = `
<section class="booking" aria-labelledby="${instanceId}-heading">
<h2 id="${instanceId}-heading">Paket buchen</h2>
<p class="booking__intro" role="status">Paket wird geladen …</p>
<div class="booking__content"></div>
</section>`;
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 = `
<div class="booking__package">
<h2 class="booking__package-name"></h2>
<p class="booking__summary"></p>
<p class="booking__description"></p>
<dl class="booking__totals"></dl>
<h3>Enthaltene Leistungen</h3>
<ul class="booking__features"></ul>
<div class="booking__steps"></div>
</div>
<aside class="booking__payment" aria-labelledby="${instanceId}-booking-payment-heading">
<h2 id="${instanceId}-booking-payment-heading">Hinweis zum Buchungsablauf</h2>
<p class="booking__payment-notice"></p>
</aside>
<button class="booking__button booking__open" type="button" aria-haspopup="dialog" aria-controls="${instanceId}-dialog" aria-expanded="false">Jetzt buchen</button>
<dialog class="booking__dialog" id="${instanceId}-dialog" aria-labelledby="${instanceId}-dialog-heading" aria-describedby="${instanceId}-dialog-intro">
<button class="booking__close" type="button" aria-label="Buchungsdialog schließen">×</button>
<h2 id="${instanceId}-dialog-heading">Paket buchen</h2>
<p id="${instanceId}-dialog-intro">Bitte gib deine Kontaktdaten ein. Name und E-Mail-Adresse sind Pflichtfelder.</p>
<form novalidate>
<div class="booking__errors" role="alert" tabindex="-1" hidden></div>
<div class="booking__field">
<label for="${instanceId}-booking-name">Name (Pflichtfeld)</label>
<input id="${instanceId}-booking-name" name="name" type="text" autocomplete="name" required maxlength="120" aria-describedby="${instanceId}-booking-name-error" />
<p class="booking__field-error" id="${instanceId}-booking-name-error"></p>
</div>
<div class="booking__field">
<label for="${instanceId}-booking-email">E-Mail-Adresse (Pflichtfeld)</label>
<input id="${instanceId}-booking-email" name="email" type="email" autocomplete="email" inputmode="email" required maxlength="254" aria-describedby="${instanceId}-booking-email-hint ${instanceId}-booking-email-error" />
<p id="${instanceId}-booking-email-hint">An diese Adresse senden wir deine Bestätigung.</p>
<p class="booking__field-error" id="${instanceId}-booking-email-error"></p>
</div>
<div class="booking__field">
<label for="${instanceId}-booking-phone">Telefonnummer (optional)</label>
<input id="${instanceId}-booking-phone" name="phone" type="tel" autocomplete="tel" maxlength="50" aria-describedby="${instanceId}-booking-phone-hint ${instanceId}-booking-phone-error" />
<p id="${instanceId}-booking-phone-hint">Die Telefonnummer wird derzeit noch nicht an die Buchungsstelle übermittelt.</p>
<p class="booking__field-error" id="${instanceId}-booking-phone-error"></p>
</div>
<div class="booking__actions">
<button class="booking__button booking__cancel" type="button">Abbrechen</button>
<button class="booking__button" type="submit">Paket buchen</button>
</div>
<p class="booking__progress" role="status"></p>
</form>
<div class="booking__result" role="status" tabindex="-1" hidden></div>
</dialog>`;
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.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 (
phone.value.length > 50 ||
/[\x00-\x1f\x7f]/.test(phone.value)
)
fields.phone =
"Bitte höchstens 50 Zeichen ohne Zeilenumbrüche eingeben.";
if (Object.keys(fields).length) {
showError(
"Bitte die markierten Angaben prüfen.",
fields,
);
return;
}
const nextAttempt = {
key: crypto.randomUUID(),
order: selected,
// Bis zur Backend-Erweiterung nur für die Wiederherstellung.
phone: phone.value,
payload: {
packageId: selected.packageId,
...(selected.variantId
? { variantId: selected.variantId }
: {}),
name: name.value,
email: email.value,
},
};
try {
sessionStorage.setItem(
storageKey,
JSON.stringify(nextAttempt),
);
} catch {
showError(
"Der Browser kann die Anfrage nicht für einen sicheren erneuten Versuch merken. Bitte erlaube den Sitzungsspeicher und versuche es erneut.",
);
return;
}
attempt = nextAttempt;
}
busy = true;
button.disabled = true;
button.textContent = "Buchung wird verarbeitet …";
form.setAttribute("aria-busy", "true");
lockFields(true);
progress.textContent =
"Bitte warten. Deine Buchung wird geprüft und gespeichert.";
try {
const body = await submitBooking(
attempt.payload,
attempt.key,
);
attempt.result = body;
try {
sessionStorage.setItem(
storageKey,
JSON.stringify(attempt),
);
} catch {
/* Der ursprüngliche Schlüssel bleibt erhalten. */
}
if (!signal.aborted) {
showResult(body);
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();
};
}

View File

@ -1,192 +0,0 @@
.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;
}

View File

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

View File

@ -1,115 +0,0 @@
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}`);
});
}

View File

@ -1,81 +0,0 @@
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.",
},
});
}
}
}

View File

@ -1,354 +0,0 @@
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.";
}
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,
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;
},
};
}

View File

@ -1,105 +0,0 @@
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"];
export const columns = [...orderColumns, "customer", "acceptedTerms"];
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(",") !== 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 });
}
},
};
}

View File

@ -1,125 +0,0 @@
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 <buchung@example.test>";
return async function sendConfirmation(booking) {
const order = booking.bookedPackage;
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}`,
...(booking.customer?.phone
? [`Telefon: ${booking.customer.phone}`]
: []),
...(booking.customer?.notes
? [`Deine Anmerkungen: ${booking.customer.notes}`]
: []),
"",
`Paket: ${order?.title ?? booking.packageName}`,
...(order
? [
order.summaryForBooking,
order.subtitle,
"",
...orderSummaryRows(order).map(
([label, value]) => `${label}: ${value}`,
),
"",
"Enthaltene Leistungen:",
...order.includedFeatures.map(
(feature) => `• ${feature}`,
),
"",
"So geht es weiter:",
...(order.processSteps || []).map(
({ step, text }) => `${step}: ${text}`,
),
]
: []),
"",
paymentNotice,
"",
"Bitte bewahre diese Bestätigung auf und gib bei Rückfragen deine Buchungsnummer an.",
"Dein Tri-Hub-Team",
];
const escapeHtml = (value) =>
String(value).replace(
/[&<>"']/g,
(char) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char],
);
let info;
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: `<html lang="de"><body><h1>Deine Bestellbestätigung</h1>${lines.map((line) => (line ? `<p>${escapeHtml(line)}</p>` : "")).join("")}</body></html>`,
});
} 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");
}
};
}

View File

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

View File

@ -1,2 +0,0 @@
export const paymentNotice =
"Dieser Buchungsablauf ist eine Simulation. Der Zahlungsvorgang wird übersprungen: Es erfolgt keine Abbuchung und keine verbindliche Beratungsbuchung.";

View File

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

View File

@ -1,44 +0,0 @@
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,
},
];

View File

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

View File

@ -1,123 +0,0 @@
:root {
--bg-dark: #0d0d1a;
--bg-card: #1a1a2e;
--accent-teal: #10b981;
--text-white: #ffffff;
--text-lightgray: #f8fafc;
--text-muted: #94a3b8;
--font-display: "Playfair Display", serif;
--font-body: "Inter", system-ui, sans-serif;
}
.packages-section {
background-color: var(--bg-dark);
padding: 4rem 2rem;
font-family: var(--font-body);
color: var(--text-lightgray);
}
.packages-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
gap: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.package-card {
background-color: var(--bg-card);
border-radius: 12px;
padding: 2rem;
display: flex;
flex-direction: column;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.package-type-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.package-type {
color: var(--text-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.package-highlight {
background-color: rgba(16, 185, 129, 0.1);
color: var(--accent-teal);
font-size: 0.7rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-weight: 600;
text-transform: uppercase;
}
.package-title {
font-family: var(--font-display);
font-size: 1.75rem;
color: var(--text-white);
margin-bottom: 1rem;
line-height: 1.2;
}
.package-desc {
color: var(--text-lightgray);
font-size: 0.95rem;
line-height: 1.5;
margin-bottom: 2rem;
}
.package-details-box {
background-color: rgba(255, 255, 255, 0.03);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
flex-grow: 1; /* Schiebt den Button immer ganz nach unten */
}
.detail-item {
margin-bottom: 1.25rem;
}
.detail-item:last-child {
margin-bottom: 0;
}
.detail-title {
color: var(--text-white);
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.detail-text {
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.5;
}
.package-btn {
display: block;
text-align: center;
background-color: transparent;
color: var(--text-white);
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 1rem;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
transition: all 0.2s ease;
}
.package-btn:hover {
background-color: rgba(255, 255, 255, 0.05);
border-color: var(--accent-teal);
color: var(--accent-teal);
}

View File

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

View File

@ -1,621 +0,0 @@
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: "<img src=x>", ...result.body });
assert.match(message.subject, /Bestellbestätigung TH-/);
for (const text of [
"52-Wochen-Variante",
"52 Wochen",
"1.399,00",
"1.175,63",
"223,37",
"19 %",
"einmalig",
"Bestelldatum:",
...result.body.bookedPackage.includedFeatures,
]) {
assert.ok(message.text.includes(text), text);
}
assert.ok(message.html.includes("&lt;img src=x&gt;"));
assert.ok(!message.html.includes("<img src=x>"));
});
test("Swagger-Kundendaten und Zustimmung werden validiert und dauerhaft gespeichert", async (t) => {
const { post, storage, sent } = await setup(t, { catalog: packages });
const customer = {
firstName: "Thomas",
lastName: "Müller",
email: "thomas@example.test",
phone: "+49 170 1234567",
ageGroup: "50-59",
notes: "Vorbereitung auf die Mitteldistanz.",
};
const request = {
packageId: "ernaehrung-standard",
customer,
acceptedTerms: true,
};
for (const body of [
{ ...request, acceptedTerms: false },
{ ...request, acceptedTerms: "true" },
{ ...request, customer: [] },
{ ...request, customer: { ...customer, firstName: "" } },
{ ...request, customer: { ...customer, email: "ungültig" } },
{ ...request, customer: { ...customer, phone: 123 } },
])
assert.equal((await post(body)).status, 400);
const key = randomUUID();
const result = await post(request, key);
assert.equal(result.status, 201);
assert.equal(result.body.customerEmail, customer.email);
const [record] = await storage.readAll();
assert.equal(record.name, "Thomas Müller");
assert.deepEqual(record.customer, customer);
assert.equal(record.acceptedTerms, true);
assert.deepEqual(sent[0].customer, customer);
assert.equal((await post(request, key)).status, 200);
assert.equal(
(
await post(
{
...request,
customer: { ...customer, notes: "Anderer Auftrag" },
},
key,
)
).status,
409,
);
});

View File

@ -1,294 +0,0 @@
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);
});

View File

@ -1,23 +0,0 @@
// 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);

View File

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

View File

@ -1,38 +0,0 @@
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),
),
},
},
},
});