Compare commits

..

79 Commits

Author SHA1 Message Date
Emirhan Sahin 599fe51ef2 Merge pull request 'Cookie-Einstellungen angepasst.' (#13) from dev into main
Reviewed-on: #13
2026-09-30 18:20:11 +02:00
Majd Ibrahim 4d95aeb772 Cookie-Einstellungen angepasst. 2026-09-30 16:14:45 +00:00
Emirhan Sahin 857cd86ab2 Merge pull request 'dev' (#12) from dev into main
Reviewed-on: #12
2026-09-30 16:53:07 +02:00
Emirhan Sahin 787e7b61a6 Merge branch 'feature/rezensionen-darstellen' into dev
# Conflicts:
#	.gitignore
2026-09-30 14:33:02 +00:00
Emirhan Sahin 15e5dbe904 feat: Rezensionen mit Navigation und Tests ergänzen 2026-09-30 14:30:49 +00:00
Niels Yannick Tietze 84cf3409de Footer hinzugefügt + Kontakt aufnehmen führt zu Kontaktformular auf index.html 2026-09-30 14:27:50 +00:00
Niels Yannick Tietze ea47960393 Merge branch 'dev' into feature/FAQ 2026-09-30 14:08:19 +00:00
Majd Ibrahim e7269a819a Merge branch 'dev' into feature/bildliche_darsteullung 2026-09-30 13:42:00 +00:00
Majd Ibrahim 16e9cbbfa2 Bilder hinzugefügt und darstellung sowie Styling angepasst. 2026-09-30 13:38:28 +00:00
Marvin Gemlin dfa8aeff6c gitignore und reviews.csv bearbeitet 2026-09-30 13:35:14 +00:00
Niels Yannick Tietze f04b45bf16 Max nur eine Frage offen, Farb Korrektur Schrift 2026-09-30 13:32:55 +00:00
Marvin Gemlin 4f308c2a7b feat: Darstellung der Rezensionen implementiert. Minimale Stilanpassungen noch möglich 2026-09-30 13:30:06 +00:00
Marvin Gemlin 502041fc04 Merge branch 'dev' into feature/rezensionen-schreiben 2026-09-30 12:49:34 +00:00
Emirhan Sahin 4c8b8d77ed feat(footer): gemeinsame Footer-Komponente auf allen Seiten eingebunden 2026-09-30 12:46:20 +00:00
Marvin Gemlin daacf4b393 feat: Rezensionen speichern und Datenschutz in allen Formularen bestätigen 2026-09-30 12:41:47 +00:00
Niels Yannick Tietze 7c96a97ebf Start FAQ. Kategorien, Fragen und NavBar-Integration 2026-09-30 14:14:54 +02:00
Emirhan Sahin 42c0db48d5 Merge pull request 'Fix: Berater-Popup: Kontaktieren im Titel ergänzen' (#11) from dev into main
Reviewed-on: #11
2026-09-30 09:33:37 +02:00
Marvin Gemlin 92a9ee7c6c Berater-Popup: Kontaktieren im Titel ergänzen 2026-09-30 06:34:24 +00:00
Majd Ibrahim 7c2a4e99c3 Merge pull request 'dev' (#10) from dev into main
Reviewed-on: #10
2026-09-30 00:10:52 +02:00
Marvin Gemlin b6ba795cf4 Kontaktmails: Absender und Empfängertexte anpassen 2026-09-29 22:06:20 +00:00
Majd Ibrahim 531583b690 fix(nav): Rezensionen- und Kontaktlinks korrigieren 2026-09-29 21:48:33 +00:00
Majd Ibrahim 3f845585c5 feat(berater): Bildergalerie und Tests ergänzen 2026-09-29 21:41:52 +00:00
Majd Ibrahim b509adccd9 Beraterbilder hinzugefügt, Styling angepasst 2026-09-29 20:59:52 +00:00
Emirhan3 56e241c387 Merge branch 'feature/Information' into dev 2026-09-29 19:57:30 +00:00
Emirhan3 184599d872 feat(contact): Kontaktformular mit CSV-Speicherung und Mailpit-Mails ergänzt und tests erstellt 2026-09-29 19:52:07 +00:00
Emirhan3 1f5d5dca87 Merge remote-tracking branch 'origin/dev' into feature/Information 2026-09-29 18:10:56 +00:00
Niels Yannick Tietze a56e43c3bb Backend Zeichen zählen + Frontend extra Option für Anfrage-Typ 2026-09-29 17:12:04 +00:00
Niels Yannick Tietze 8746856086 Kontaktformular Neuanfang. Navbar Kontakt im Navbar führt auf das Kontaktformular 2026-09-29 16:52:20 +00:00
Niels Yannick Tietze 6d121aa434 Backend Zeichen zählen + Frontend extra Option für Anfrage-Typ 2026-09-29 16:03:02 +00:00
Majd Ibrahim 598065d740 Design verbessert. 2026-09-29 16:02:23 +00:00
Marvin Gemlin 881ba21630 feat(contact): Spitznamen mit Vornamen-Fallback im Kontakt-Popup anzeigen
Beraterdaten um getrennte Namensfelder ergänzen und den Anzeigenamen für Popup und Kontaktmails zentral bestimmen. Leere Spitznamen fallen auf den Vornamen zurück; Nachname und Kontaktieren-Zusatz entfallen in der Überschrift.

Integrationsdokumentation, API-Beispiele und Tests an den neuen Namensvertrag anpassen.
2026-09-29 15:45:13 +00:00
Majd Ibrahim af88228d3b Merge branch 'dev' into feature/beraterprofile 2026-09-29 15:22:10 +00:00
Majd Ibrahim 659e4ff9ec Design verbessert und Bugs gehoben 2026-09-29 15:15:43 +00:00
Marvin Gemlin fc19fef576 style(contact): Kontakt-Popup an das Design des Buchungsdialogs angleichen
Buttonfarbe, Rundungen, Formularfelder und Fensterdarstellung vereinheitlichen; bestehende Schriftfarben beibehalten.
2026-09-29 15:15:24 +00:00
Majd Ibrahim 94bea79863 Merge branch 'dev' into feature/beraterprofile 2026-09-29 14:45:00 +00:00
Majd Ibrahim 370d54fb39 Beraterkarten sowie Daten erstellt und in index.html eingebunden 2026-09-29 14:39:25 +00:00
Niels Yannick Tietze 35f14a4f5e Start Informationsgespräch 2026-09-29 16:35:08 +02:00
Niels Yannick Tietze 8c0b1a3cb8 Start Informationsgespräch 2026-09-29 16:33:28 +02:00
Marvin Gemlin d79ab2b0ce Merge branch 'dev' into feature/terminbuchung-kontaktfeld 2026-09-29 14:26:24 +00:00
Marvin Gemlin 5553d117d6 feat(contact): Beraterkontakt mit ID-Zuordnung und Kundenvalidierung
Kontakt-Popup mit serverseitigem CSV-Abgleich, Mailpit-Bestätigungen und Weiterleitung zu Microsoft Bookings ergänzen.

Berater über gemeinsame JSON-Daten zuordnen sowie Swagger, Integration, Testseite und automatisierte Tests dokumentieren.
2026-09-29 14:09:20 +00:00
Emirhan3 d9a8317014 fix: Darstellung von Logo, Schrift und Farben 2026-09-29 14:06:27 +00:00
Emirhan3 55e4904482 fix: überflüssige zeile entfernt 2026-09-29 07:44:05 +00:00
Majd Ibrahim 7071d07ea9 Merge pull request 'dev' (#9) from dev into main
Reviewed-on: #9
2026-09-29 00:39:25 +02:00
Marvin Gemlin ae994c29f4 Ernährungsbutton in Nav Bar bearbeitet 2026-09-28 22:34:10 +00:00
Majd Ibrahim b8f4605a70 Merge pull request 'feature/angebote-booking-integration' (#8) from feature/angebote-booking-integration into dev
Reviewed-on: #8
2026-09-29 00:12:13 +02:00
Marvin Gemlin 8fb2044a81 paket seiten angepasst und booking mit telefonvalidierung integriert 2026-09-28 22:07:11 +00:00
Marvin Gemlin 29ef00c6c3 booking komponente bearbeitet und neu integriert 2026-09-28 21:40:58 +00:00
Marvin Gemlin 70636d82f3 Merge branch 'feature/booking-component' into feature/angebote-booking-integration 2026-09-28 21:34:47 +00:00
Majd Ibrahim 8c759c3dcb prototyp für Pakete und Pakete-details 2026-09-28 21:34:47 +00:00
Marvin Gemlin a4bb71f7a7 index.html von dev eingefügt 2026-09-28 19:39:10 +00:00
Majd Ibrahim 00625cd0eb Merge branch 'dev' into feature/angebote-booking-integration 2026-09-28 19:28:42 +00:00
Marvin Gemlin ba438b6db6 Merge branch 'dev' into feature/booking-component 2026-09-28 18:14:25 +00:00
Emirhan Sahin 61d9208578 fix: Navbar-Layout in index.html korrigiert und Skip-Link entfernt 2026-09-28 18:07:19 +00:00
Niels Tietze 5111f84ade Merge pull request 'feature/Darstellung' (#7) from feature/Darstellung into dev
Reviewed-on: #7
2026-09-28 19:26:16 +02:00
Niels Yannick Tietze 937ffac806 Update 5 2026-09-28 19:24:40 +02:00
Niels Yannick Tietze 985139e82f . 2026-09-28 19:19:55 +02:00
Niels Yannick Tietze 15f92f0f55 Update 4 2026-09-28 19:11:12 +02:00
Niels Yannick Tietze 1601716e4f Mir fällt kein neuer Name ein 2026-09-28 19:03:46 +02:00
Marvin Gemlin c1332050d7 Navbar auf Buchungsbestätigungsseite eingebunden. 2026-09-28 16:50:43 +00:00
Marvin Gemlin 6f2b239e57 Telefonnummer bei Buchung übertragen und in Bestätigungsmail ergänzt 2026-09-28 16:48:35 +00:00
Niels Yannick Tietze 02bca699f0 Updates Darstellung 2026-09-28 18:45:48 +02:00
Marvin Gemlin 9bfa8b78b0 Merge branch 'dev' into feature/booking-component 2026-09-28 16:25:29 +00:00
Marvin Gemlin 4cae17a55d Merge pull request 'feat(navbar): fixierte, tastaturbarrierefreie Navigationsleiste' (#4) from feature/navigationsleiste into dev
Reviewed-on: #4
2026-09-28 13:14:10 +02:00
Niels Yannick Tietze 60d3fab9c9 Updates Darstellung 2026-09-28 13:06:58 +02:00
Niels Yannick Tietze 50bf782e97 Landing Page 2026-09-28 11:02:23 +02:00
Marvin Gemlin 0ee772e1e5 überflüssigen ordner entfernt 2026-09-28 07:44:47 +00:00
Majd Ibrahim 6437077342 Merge pull request 'Buchungsbereich als Komponente umgebaut und Bestätigungsseite ergänzt' (#3) from feature/booking-component into feature/angebote-booking-integration
Reviewed-on: #3
2026-09-26 11:54:07 +02:00
Marvin Gemlin 3f82591c2d Buchungsbereich als Komponente umgebaut und Bestätigungsseite ergänzt 2026-09-26 09:21:46 +00:00
Marvin Gemlin 5fb3f51f8f Paketübergabe korrigiert, Bestellübersicht und Bestätigungsmail erweitert 2026-09-25 20:11:16 +00:00
Marvin Gemlin 4abf4aa46c Merge pull request 'packages erweitert und swagger erstellt' (#2) from feature/angebote-booking-integration-detailseiten into feature/angebote-booking-integration
Reviewed-on: #2
2026-09-25 21:34:48 +02:00
Majd Ibrahim e84d8ae6b3 packages erweitert und swagger erstellt 2026-09-25 13:03:04 +00:00
Marvin Gemlin 4ed3ab54e8 Angebotsübersicht und Buchung verbinden, Mailpit einrichten 2026-09-25 11:53:07 +00:00
Majd Ibrahim 762f21dfa1 packagestruktur angepasst 2026-09-25 11:32:18 +00:00
Niels Yannick Tietze f507fb5166 Eigentliche Version 2026-09-25 11:08:39 +02:00
Majd Ibrahim 4447282b76 Merge remote-tracking branch 'origin/feature/paket-buchen-backend' into majds 2026-09-24 16:40:04 +00:00
Majd Ibrahim ef0745e59d US2.1: Datenstruktur, Komponenten-Aufbau, Styling, Testing 2026-09-24 16:36:53 +00:00
Majd Ibrahim 5851668bd8 US2.1: Datenstruktur, Komponenten-Aufbau, Styling, Testing 2026-09-24 16:33:09 +00:00
Niels Yannick Tietze e09fef8c96 feat: Gestaltung 2026-09-24 18:18:07 +02:00
Marvin Gemlin 6bf2e17015 feat: Paketbuchung mit CSV, SMTP und fortlaufenden Buchungsnummern 2026-09-24 16:11:54 +00:00
110 changed files with 30887 additions and 106 deletions

View File

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

View File

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

View File

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

11
.env.example 100644
View File

@ -0,0 +1,11 @@
# Lokal nur einen Mailfänger verwenden. Keine echten Zugangsdaten eintragen.
HOST=127.0.0.1
PORT=3000
SMTP_HOST=127.0.0.1
SMTP_PORT=1025
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM="Tri-Hub <buchung@example.test>"
# Für echtes SMTP erst nach ausdrücklicher Freigabe ändern.
SMTP_ALLOW_EXTERNAL=false

24
.gitignore vendored
View File

@ -1,8 +1,16 @@
node_modules/ node_modules/
dist/ dist/
.env .env
.env.* .env.*
!.env.example !.env.example
.DS_Store .DS_Store
Thumbs.db Thumbs.db
*.log *.log
# Private Buchungsdaten und lokale Testergebnisse
src/server/data/*
!src/server/data/.gitkeep
!src/server/data/contact-requests.csv
!src/server/data/reviews.csv
playwright-report/
test-results/

View File

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

1282
FAQ.html 100644

File diff suppressed because it is too large Load Diff

65
README.md 100644
View File

@ -0,0 +1,65 @@
# 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.
## Beraterkontakt testen
Die [Kontakt-Testseite](http://localhost:5173/contact-test.html?berater-id=relindis-agethen)
öffnet das neue Popup. Kundenprüfung, Mailpit-Versand, Profil-Einbindung und
Checkliste sind in [docs/contact.md](docs/contact.md) dokumentiert.
## 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

@ -0,0 +1,37 @@
<!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>
<link rel="stylesheet" href="/src/styles/navigation.css" />
<link rel="stylesheet" href="/src/styles/packages.css" />
<link rel="stylesheet" href="/src/styles/footer.css" />
</head>
<body class="packages-page">
<div id="navbar-placeholder"></div>
<main class="packages-section" id="main-content" tabindex="-1">
<div class="packages-header">
<div>
<p class="packages-eyebrow">TRI-HUB · ERNÄHRUNG</p>
<h1>Unsere Ernährungs-Pakete</h1>
</div>
<p class="packages-intro">
Wähle das Paket, das zu deinem aktuellen Stand passt. Von
den Grundlagen bis zur Wettkampf-Vorbereitung.
</p>
</div>
<div class="packages-container" id="packages-container"></div>
</main>
<script
type="module"
src="/src/components/navigationsbar/navigation.js"
></script>
<script
type="module"
src="/src/features/angebote/angebote-entry.js"
></script>
<div id="footer-placeholder"></div>
<script type="module" src="/src/components/footer/footer.js"></script>
</body>
</html>

55
berater.html 100644
View File

@ -0,0 +1,55 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Lerne die Ernährungsberaterinnen und Ernährungsberater von Tri-Hub kennen."
/>
<title>Beraterprofil · Tri-Hub</title>
<link rel="stylesheet" href="/src/styles/navigation.css" />
<link rel="stylesheet" href="/src/styles/packages.css" />
<link rel="stylesheet" href="/src/features/berater/berater.css" />
<link rel="stylesheet" href="/src/styles/footer.css" />
</head>
<body class="packages-page advisor-page">
<div id="navbar-placeholder"></div>
<main id="main-content" class="advisor-main">
<section class="advisor-section" aria-labelledby="advisor-heading">
<div class="advisor-container">
<header class="advisor-heading">
<p class="advisor-eyebrow">PERSÖNLICH FÜR DICH DA</p>
<h1 id="advisor-heading">
Lerne deine Ernährungsberater kennen.
</h1>
<p class="advisor-intro">
Hinter einer guten Ernährungsstrategie stehen
Menschen, die zuhören und ihre Erfahrung in deinen
Alltag und dein Training einbringen.
</p>
</header>
<div
class="advisor-grid"
id="advisor-profiles"
aria-live="polite"
>
<p class="advisor-loading">
Beraterprofile werden geladen …
</p>
</div>
</div>
</section>
</main>
<script
type="module"
src="/src/components/navigationsbar/navigation.js"
></script>
<script
type="module"
src="/src/features/berater/berater-entry.js"
></script>
<div id="footer-placeholder"></div>
<script type="module" src="/src/components/footer/footer.js"></script>
</body>
</html>

View File

@ -0,0 +1,161 @@
<!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" />
<link rel="stylesheet" href="/src/styles/navigation.css" />
<title>Buchungsbestätigung – Tri-Hub</title>
<link rel="stylesheet" href="/src/styles/footer.css" />
</head>
<body class="confirmation">
<div id="navbar-placeholder"></div>
<main id="main-content" class="confirmation__main" tabindex="-1">
<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>
<script
type="module"
src="/src/features/booking/booking-confirmation.js"
></script>
<script
type="module"
src="/src/components/navigationsbar/navigation.js"
></script>
<div id="footer-placeholder"></div>
<script type="module" src="/src/components/footer/footer.js"></script>
</body>
</html>

24
booking.html 100644
View File

@ -0,0 +1,24 @@
<!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>
<link rel="stylesheet" href="/src/styles/footer.css" />
</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>
<div id="footer-placeholder"></div>
<script type="module" src="/src/components/footer/footer.js"></script>
</body>
</html>

29
contact-test.html 100644
View File

@ -0,0 +1,29 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Berater kontaktieren – Tri-Hub Testseite</title>
<link rel="stylesheet" href="/src/styles/footer.css" />
</head>
<body>
<main class="contact-test">
<p>TRI-HUB · KONTAKT-DEMO</p>
<h1>Dein direkter Draht zur Beratung</h1>
<p id="advisor-name"></p>
<button id="open-contact" type="button">
Berater kontaktieren
</button>
<p>
Testseite für das spätere Beraterprofil. Nutze eine
E-Mail-Adresse aus einer vorhandenen Testbuchung.
</p>
</main>
<script
type="module"
src="/src/features/contact/contact-entry.js"
></script>
<div id="footer-placeholder"></div>
<script type="module" src="/src/components/footer/footer.js"></script>
</body>
</html>

View File

@ -0,0 +1,36 @@
# Beraterprofile und Bildergalerie testen
Die Berater-Tests laufen auf dem Branch `feature/beraterbilder`. Dieser enthält auch die Profiländerungen aus `feature/beraterprofile`.
## Node-Tests
```sh
npm test
```
Die Node-Suite prüft die Beraterdaten, eindeutige IDs, Kontaktanzeigenamen, vorhandene Porträt- und Aktionsbilder sowie den Navigationslink zur eigenständigen Profilseite.
## Browser-Tests
```sh
npm run test:browser -- src/tests/browser/berater.spec.js
```
Die Browser-Tests prüfen die Profilkarten, den Kontakt-Dialog, die Bildgröße und das manuelle Wechseln der Galerie mit Pfeilen und Positionspunkten. Playwright startet dafür standardmäßig die Testserver auf den Ports 3000 und 5173. Falls diese Ports bereits von den lokalen Projektservern belegt sind, kann Playwright sie wiederverwenden:
```sh
PW_REUSE_EXISTING=1 npm run test:browser -- src/tests/browser/berater.spec.js
```
PowerShell:
```powershell
$env:PW_REUSE_EXISTING = "1"
npm run test:browser -- src/tests/browser/berater.spec.js
```
Beim ersten Browser-Testlauf kann Playwright Chromium benötigen:
```sh
npx playwright install chromium
```

146
docs/booking.md 100644
View File

@ -0,0 +1,146 @@
# Paketbuchung
## Ablauf
Die Angebotsübersicht führt zur Detailseite eines Pakets. Der Buchungsbutton
übergibt die Paket-ID und gegebenenfalls die gewählte Variante als URL-Parameter
an `booking.html`. Bestehende Links mit `#/buchen/:packageId` funktionieren weiterhin.
Booking lädt die Bestellübersicht vom Server. Sie enthält Paket und Variante,
Laufzeit, Leistungen, Abrechnung, Nettopreis, MwSt. und Gesamtbetrag. Die Preise
stammen aus `src/shared/packagesdetails.js`; Preisangaben des Browsers werden
nicht übernommen. Ohne Varianten-ID wird die erste Variante des Pakets gewählt.
Beim Absenden speichert der Server Kundendaten und den vollständigen Bestellstand
in der CSV. Anschließend sendet er eine Bestätigung als Text und HTML mit
Buchungsnummer, Datum, gebuchten Leistungen und Preisen. Änderungen am Katalog
verändern bereits gespeicherte Bestellungen nicht.
Der Zahlungsvorgang ist weiterhin simuliert. Es erfolgt keine Abbuchung und
keine verbindliche Beratungsbuchung. Darauf weisen Formular und E-Mail hin.
## Lokal starten
Voraussetzung ist Node.js 24. Abhängigkeiten mit `npm ci` installieren und die
Werte aus `.env.example` in eine lokale `.env` übernehmen. Eine vorhandene
Konfiguration dabei erhalten.
In separaten Terminals starten:
- `npm run dev:mail`: Mailpit mit SMTP auf Port 1025 und Weboberfläche auf 8025.
- `npm run dev:server`: Buchungs-API auf Port 3000.
- `npm run dev`: Frontend auf Port 5173; `/api` wird an Node weitergeleitet.
Die Angebotsübersicht liegt unter <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`, `email` und optional `phone`.
Die Telefonnummer darf höchstens 50 Zeichen und keine Steuerzeichen enthalten.
Sie wird in der CSV gespeichert und in der Bestätigungsmail ausgegeben.
Alternativ akzeptiert die API das Swagger-Objekt `customer` mit `firstName`,
`lastName`, `email` sowie optional `phone`, `ageGroup` und `notes`. Dabei muss
`acceptedTerms` den Wert `true` haben. Diese Angaben werden ebenfalls gespeichert.
Die Antwort enthält die Buchungsnummer, den Erstellungszeitpunkt, das gebuchte
Paket unter `bookedPackage` und den Versandstatus. Eine neue Buchung mit
SMTP-Annahme erhält HTTP 201, eine Wiederholung HTTP 200. Bei ausstehendem,
fehlgeschlagenem oder unklarem Versand wird HTTP 202 zurückgegeben.
Ungültige Angaben, Pakete und Varianten ergeben HTTP 400; ein unbekanntes Paket
am Detail-Endpunkt HTTP 404. Derselbe Anfrageschlüssel mit anderen Daten ergibt
HTTP 409. JSON-Anfragen sind auf 8 KiB begrenzt. Fehlerantworten enthalten
`error.code`, `error.message` und gegebenenfalls `error.fields`.
## Speicherung und Wiederholungen
Die Buchungen liegen in `src/server/data/bookings.csv`. Neue Buchungsnummern
werden fortlaufend als `TH-000001`, `TH-000002` usw. vergeben. Der höchste
vorhandene Wert bestimmt die nächste Nummer. Die CSV muss deshalb vollständig
erhalten bleiben; ältere UUID-Buchungsnummern werden weiterhin unterstützt.
`bookedPackage` speichert den Bestellstand als JSON, `customer` die optionalen
strukturierten Kundendaten und `acceptedTerms` die zugehörige Zustimmung.
Die optionale Telefonnummer steht zusätzlich in der Spalte `phone`.
CSV-Dateien mit dem früheren Spaltensatz bleiben lesbar und werden beim nächsten
Speichern erweitert. Für alte Buchungen ohne Bestellstand liefert die API
`bookedPackage: null`.
Lesen, Schreiben und Versand werden innerhalb einer Warteschlange abgearbeitet.
Die Datei wird über eine temporäre Datei atomar ersetzt. Unterstützt wird ein
Backend-Prozess auf einem lokalen Dateisystem. Mehrere Serverinstanzen benötigen
einen gemeinsam abgesicherten Speicher. CSV-Zellen werden gegen die Auswertung
als Tabellenformeln geschützt; Dateien erhalten den Zugriffsmodus 0600.
Wiederholte Anfragen mit demselben Schlüssel und denselben Daten liefern die
vorhandene Buchung. Es wird keine zweite Bestellung angelegt. Die Auswahl einer
anderen Variante mit demselben Schlüssel wird abgelehnt.
Das Formular merkt sich Anfrage und Bestellübersicht je Paket und Variante im
`sessionStorage`. Nach einem Verbindungsfehler bleiben die Angaben gesperrt,
damit dieselbe Anfrage erneut geprüft werden kann. Ein neuer Tab oder gelöschter
Sitzungsspeicher liegt außerhalb dieser Absicherung.
## Versandstatus
| Status | Bedeutung |
| ---------- | ------------------------------------------------------------------------------------------------ |
| `pending` | Buchung gespeichert; Versand noch nicht gestartet. Dieselbe Anfrage kann den Versand fortsetzen. |
| `sending` | Versandabsicht gespeichert. Nach einem Prozessabbruch liefert die API `unknown`. |
| `accepted` | SMTP hat die Nachricht angenommen; die Zustellung ist noch nicht bestätigt. |
| `failed` | Versand fehlgeschlagen oder ausdrücklich abgelehnt. |
| `unknown` | SMTP-Annahme oder anschließende Statusspeicherung blieb unklar. |
Bei `failed` und `unknown` wird nicht automatisch erneut versendet. Vor einem
manuellen Neuversand muss der Mailserver anhand der Buchungsnummer geprüft werden.
CSV und SMTP bilden keine gemeinsame Transaktion.
## Tests
`npm test` prüft Validierung, Preisberechnung, Varianten, CSV-Kompatibilität,
Wiederholungen und Versandfehler. `npm run test:browser` prüft den Weg von der
Angebotsübersicht über die Paketdetails bis zur Bestätigung, einschließlich
Premium-Jahresvariante, Tastaturbedienung und schmalem Bildschirm.
Die Tests verwenden die regulären Paketdaten, temporäre CSV-Dateien und einen
simulierten Mailversand. Playwright benötigt Chromium; die Installation erfolgt
mit `npx playwright install --with-deps chromium`. Die Ports 3000 und 5173 müssen
für die automatisch gestarteten Testserver frei sein.
Vor einem Commit außerdem `npm run format:check`, `npm run build` und
`git diff --check` ausführen. Für eine manuelle Mailprüfung eine Buchung mit einer
Adresse unter `example.test` anlegen und die Bestätigung in Mailpit kontrollieren.

View File

@ -0,0 +1,103 @@
# Übergabe an die KI der Beraterprofilseite
Binde das vorhandene Kontakt-Popup in die Kontaktbuttons der Beraterprofile
ein. Übergib ausschließlich die Berater-ID als `advisorId`. Implementiere
kein eigenes Kontaktformular und keine separate E-Mail-Prüfung.
## Relevante Dateien
- `src/shared/berater-daten.json`: gemeinsame Datenquelle für Profile und Kontakt;
Array mit eindeutigen String-IDs, `vorname`, `nachname`, optionalem `spitzname` und `email`. Eigene öffentliche Profilfelder dürfen ergänzt werden.
- `src/shared/berater.js`: `findAdvisor(advisorId)` liefert den passenden Eintrag.
- `src/features/contact/contact-dialog.js`: exportiert `openContactDialog({ advisorId })`.
- `src/features/contact/contact.css`: wird automatisch vom Popup-Modul importiert;
die Schriftdateien liegen unter `public/fonts/`.
- `src/features/contact/contact-entry.js` und `contact-test.html`: Beispielintegration.
- `src/server/services/contact-service.js`: prüft Kunden-E-Mail und Berater-ID,
lädt die Empfängeradresse serverseitig aus der JSON.
- `src/server/swagger.json`: API-Vertrag für `POST /api/contact`.
## Datenvertrag
```json
[
{
"id": "relindis-agethen",
"vorname": "Relindis",
"nachname": "Agethen",
"spitzname": "Lilli",
"email": "relindis.agethen@tri-hub.de"
}
]
```
Die Profilseite und das Popup müssen dieselben IDs verwenden. E-Mail-Adressen
werden explizit gepflegt und nicht aus Namen erzeugt. Sie müssen auf `@tri-hub.de`
enden. Keine vertraulichen Informationen in die JSON schreiben: Sie ist Teil
des Frontends. Nach Änderungen API neu starten und Frontend neu bauen.
## Einzubindender Code
Beispielbutton (ID aus dem jeweiligen Profil):
```html
<button type="button" data-berater-id="relindis-agethen">
Berater kontaktieren
</button>
```
Im JavaScript-Modul der Profilseite, einmalig registrieren:
```js
// Importpfad relativ zu dieser Datei anpassen.
import { openContactDialog } from "./src/features/contact/contact-dialog.js";
document.addEventListener("click", (event) => {
const button = event.target.closest("button[data-berater-id]");
if (!button) return;
openContactDialog({ advisorId: button.dataset.beraterId });
});
```
Bei dynamischer Erzeugung des Buttons die ID aus dem Profil setzen:
```js
button.dataset.beraterId = profile.id;
```
Alternativ direkt am einzelnen Button binden (nicht zusätzlich zur Delegation):
```js
button.addEventListener("click", () => {
openContactDialog({ advisorId: profile.id });
});
```
Falls die Profilseite selbst Daten aus der gemeinsamen Datei benötigt:
```js
// Beispiel für ein Modul direkt unter src/; Pfad ggf. anpassen.
import berater from "./shared/berater-daten.json" with { type: "json" };
const profile = berater.find((entry) => entry.id === advisorId);
// profile.vorname, profile.nachname, profile.spitzname und profile.email sind verfügbar.
```
Das Popup zeigt den getrimmten `spitzname`, falls nicht leer, sonst den
getrimmten `vorname`. `nachname` und das bisherige Feld `name` werden für die
Überschrift nicht verwendet. Es wird kein „kontaktieren“ angehängt.
Das Popup ermittelt den Anzeigenamen selbst und sendet beim Absenden
`{ email, advisorId, reason, simulationAccepted }` an `/api/contact`. Die
Profilseite muss weder Name noch E-Mail an das Popup schicken. Kundenprüfung,
Simulation, Mailpit-Versand und Microsoft-Bookings-Weiterleitung sind bereits
implementiert. Unbekannte IDs sperren das Absenden.
## Testen
Mit laufendem Frontend, API und Mailpit:
`http://localhost:5173/contact-test.html?berater-id=relindis-agethen`.
Für die Kundenadresse eine vorhandene Testbuchung verwenden. Die Prüfung
findet ausschließlich beim Absenden statt. Der Branch ist
`feature/terminbuchung-kontaktfeld`; die Kontaktdateien müssen im Arbeitsstand
der Profilseite vorhanden sein.

154
docs/contact.md 100644
View File

@ -0,0 +1,154 @@
# Beraterkontakt (Simulation)
## Testseite starten
Wie bei der Buchung in drei Terminals starten:
```sh
npm run dev:mail
npm run dev:server
npm run dev
```
Öffne <http://localhost:5173/contact-test.html?berater-id=relindis-agethen>.
Der Button öffnet ein modales Fenster. Gib eine E-Mail aus einer vorhandenen
Buchung in `src/server/data/bookings.csv` ein, wähle einen Kontaktgrund und
bestätige den Simulationshinweis. Beim Klick auf „Anfrage senden & Termin wählen“
prüft der Server die E-Mail und versendet nur bei vorhandener Buchung. Bei Bedarf zuerst
über die bestehende Angebotsseite eine Testbuchung anlegen.
Nach dem Absenden müssen zwei getrennte Nachrichten in
<http://localhost:8025> erscheinen: an die Kundenadresse und beispielsweise
`relindis.agethen@tri-hub.de`. Der Browser wechselt anschließend zur vom Auftrag
vorgegebenen Microsoft-Bookings-Adresse. Es wird dort kein Termin automatisch
gebucht. Die Checkliste spricht von Microsoft Forms; umgesetzt ist der konkret
angegebene Bookings-Link.
## Checkliste
- [x] Kontaktgrund auswählbar: Coaching, Ernährungsplan, Wettkampfverpflegung,
Regeneration, Paketfragen, Termin und Sonstiges.
- [x] Getrennte Eingangsbestätigungen an Kunde und Berater, ausschließlich
simuliert über Mailpit, mit Kontaktgrund und Simulationshinweis.
- [x] Weiterleitung zur vorhandenen Microsoft-Bookings-Adresse nach Annahme
beider Nachrichten durch Mailpit; zusätzlicher Link als Rückfalloption.
- [x] Kontakt nur nach erfolgreichem E-Mail-Abgleich mit `bookings.csv`;
der Server prüft beim tatsächlichen Absenden erneut.
## Einbindung in Beraterprofile
Die Profilseite importiert `openContactDialog` aus
`src/features/contact/contact-dialog.js` und übergibt ausschließlich die ID:
```js
import { openContactDialog } from "./src/features/contact/contact-dialog.js";
button.addEventListener("click", () => {
openContactDialog({ advisorId: profile.id });
});
```
Den Importpfad relativ zur einbindenden Datei anpassen. Das Modul bringt die
Popup-Styles und die lokalen DM-Sans-Schriftdateien mit. Die vollständige
Einbauanleitung für die Profilseite steht in [contact-integration.md](contact-integration.md).
Die gemeinsame Datenquelle `src/shared/berater-daten.json` enthält ein Array
mit eindeutigen String-IDs und den Feldern `id`, `vorname`, `nachname`, `spitzname`, `email`. Die vorhandenen Einträge enthalten die Beraterprofile. Zusätzliche Profilfelder sind möglich. Die Datei
wird im Frontend eingebunden: ausschließlich öffentliche Profildaten eintragen.
`src/shared/berater.js` stellt `findAdvisor(advisorId)` bereit. Popup und Server
verwenden dieselbe Zuordnung. Der Server holt die Empfängeradresse ausschließlich
aus dieser JSON; vom Browser gesendete Namen oder Empfängeradressen werden
nicht übernommen. E-Mail-Adressen müssen gültig sein und auf `@tri-hub.de`
enden. Eine unbekannte ID wird abgelehnt und sperrt das Absenden im Popup.
Die Testseite liest den URL-Parameter `berater-id`, zum Beispiel
`contact-test.html?berater-id=maren-hoffmann`. Ohne Parameter verwendet sie
`relindis-agethen`. Nach Änderungen an der JSON den API-Server neu starten und für
das Deployment das Frontend neu bauen.
Das native `dialog` hält den Tastaturfokus im Popup. Escape und Schließen
bringen ihn zum auslösenden Button zurück. Während einer Anfrage ist Schließen
kurz gesperrt, damit der Versand nicht unbemerkt weiterläuft. Das Fenster ist
auf schmalen Bildschirmen scrollbar. Die E-Mail wird bei jedem Absenden geprüft;
ein separater Prüfbutton ist nicht erforderlich. Es gibt keine zusätzlichen
Laufzeitabhängigkeiten.
## API
Der vollständige OpenAPI-Vertrag steht in `src/server/swagger.json`.
Beide Endpunkte erwarten POST mit `Content-Type: application/json`, maximal
8 KiB. Fehler enthalten `error.code` und `error.message`.
| Endpunkt | Eingabe | Erfolg |
| ----------------------- | --------------------------------------------------- | ---------------------------------------------------------- |
| `/api/contact/validate` | `{ "email": "kunde@example.test" }` | 200: `{ "valid": true, "email": "kunde@example.test" }` |
| `/api/contact` | E-Mail, Berater-ID, Grund, Zustimmung (siehe unten) | 201: `simulated`, `emailStatus: "accepted"`, `redirectUrl` |
```json
{
"email": "kunde@example.test",
"advisorId": "relindis-agethen",
"reason": "coaching",
"simulationAccepted": true
}
```
400 bedeutet ungültige Eingaben, 403 keine passende Buchung, 405 falsche Methode,
413 zu große Anfrage, 415 falscher Medientyp, 503 nicht lesbarer CSV-Speicher oder ungültige Beraterkontaktdaten.
502 bedeutet, dass mindestens eine Mailannahme fehlgeschlagen oder unklar ist;
der Browser leitet dann nicht weiter. 500 bezeichnet einen unerwarteten Fehler.
Die CSV wird ausschließlich gelesen und bleibt unverändert. Der separate
Validierungsendpunkt bleibt für API-Nutzer verfügbar; das Popup verwendet
ausschließlich `/api/contact` mit integrierter Kundenprüfung.
## Grenzen der Simulation
Der E-Mail-Abgleich ignoriert Groß-/Kleinschreibung und äußere Leerzeichen.
Er bestätigt einen Eintrag in der Buchungsdatei, nicht den Besitz des Postfachs.
Die Prüfantwort ist kein Anmeldetoken und gibt keine Buchungsdetails zurück.
Vor einem öffentlichen Produktiveinsatz wären eine Anmeldung oder Bestätigung
per E-Mail sowie ein Schutz vor automatisierten Adressabfragen erforderlich.
Kontaktmails verwenden fest `127.0.0.1:1025`, unabhängig von einer eventuell
externen SMTP-Konfiguration des Buchungsablaufs. Mailpit muss deshalb auf
demselben Host laufen. Kunden- und Berateradresse sind simulierte Empfänger;
es erfolgt kein externer Versand. SMTP-Annahme ist keine reale Zustellbestätigung.
Kontaktanfragen werden nicht dauerhaft gespeichert und haben keinen
Idempotenzschlüssel. Doppelklicks sind während des Versands gesperrt. Bei
Verbindungsabbrüchen oder Teilfehlern kann bereits eine Nachricht vorliegen:
vor manuellem Wiederholen Mailpit prüfen. Es gibt keinen automatischen Neuversand.
## Prüfungen
- `npm test`: API, CSV-Abgleich, erneute Validierung beim Absenden, ungültige
Eingaben, SMTP-Empfänger und Teilfehler; isolierte temporäre CSV-Dateien.
- `npm run test:browser`: vollständiger Ablauf einschließlich abgefangener
externer Weiterleitung, unbekannter Adresse, geänderter E-Mail, Versandfehler,
mobiler Darstellung, Fokus und Escape; zusätzlich bestehende Buchungstests.
- `npm run build` und `git diff --check`.
- Lokaler SMTP-Smoke-Test mit Mailpit: beide Empfänger und Simulationshinweis
in den empfangenen Nachrichten geprüft.
Chromium muss für Playwright installiert sein (`npx playwright install chromium`).
In dieser Arbeitsumgebung liegt der Testbrowser unter `/tmp/trihub-playwright`;
hier mit `PLAYWRIGHT_BROWSERS_PATH=/tmp/trihub-playwright npm run test:browser` starten.
Der globale Formatcheck hat bereits bestehende Abweichungen in `src/main.js`
und `src/components/navigationsbar/README.md`; die Kontaktdateien sind formatiert.
Der bestehende Gesamt-Build meldet außerdem fehlende ältere Schriftdateien aus
dem globalen Stylesheet. Das Kontakt-Popup verwendet eigene lokale Schriftdateien.
### Ergebnis dieser Umsetzung
31 API-/Servicetests und alle vier Kontakt-Browsertests bestanden.
Build, lokale Mailpit-Prüfung und Formatierung der geänderten Dateien bestanden.
Die bestehenden Buchungs-Browsertests sind teilweise nicht mehr an das schon
vorhandene Buchungs-Popup angepasst: Sie suchen Formularfelder, ohne vorher
„Jetzt buchen“ zu klicken. Weitere Bestandsfälle erwarten einen nicht mehr vorhandenen Startseiten-Link
oder eine Seitennavigation, wo bereits ein Popup geöffnet wird. Beim ursprünglichen Gesamtlauf bestanden 7 von 15 Browsertests; die 8 Fehler
betrafen ausschließlich die unveränderten Buchungstests. Die gezielten
Kontakttests werden nach Anpassungen separat ausgeführt.
`git diff --check` ist für die eigenen Änderungen sauber; die zuvor bereits
geänderte `bookings.csv` erzeugt separat CRLF-Whitespace-Hinweise und wurde
von dieser Umsetzung nicht verändert.

View File

@ -0,0 +1,21 @@
# Kontaktformular der Ernährungsseite
Das Formular in `index.html` sendet seine Eingaben als JSON an `POST /api/nutrition-contact`.
Der Server validiert Name, E-Mail, Betreff und Nachricht, speichert die Anfrage in
`src/server/data/contact-requests.csv` und sendet anschließend zwei E-Mails über
Mailpit auf `127.0.0.1:1025`:
- eine Eingangsbestätigung an die eingegebene E-Mail-Adresse;
- beide Nachrichten mit dem Absender `Tri-Hub Ernährung <noreply@tri-hub.de>`;
- die vollständige Kontaktanfrage an die Mailpit-Testadresse
`ernaehrung@tri-hub.de`; die SMTP-Verbindung ist fest auf den lokalen
Mailpit-Server gesetzt, daher wird diese Nachricht nur simuliert und nicht
an den echten Mailserver zugestellt.
Die Mailpit-Weboberfläche ist lokal unter `http://127.0.0.1:8025` erreichbar.
Zum Entwickeln Mailpit mit `npm run dev:mail`, den API-Server mit
`npm run dev:server` und Vite mit `npm run dev` starten.
Die CSV-Datei wird beim ersten erfolgreichen Absenden angelegt. Bei einem
Mailfehler bleibt die Anfrage gespeichert; die API meldet, dass der Versand
nicht vollständig bestätigt werden konnte.

1380
index.html

File diff suppressed because it is too large Load Diff

79
package-lock.json generated
View File

@ -7,7 +7,14 @@
"": { "": {
"name": "ernaehrungsberatung", "name": "ernaehrungsberatung",
"version": "0.1.0", "version": "0.1.0",
"dependencies": {
"csv-parse": "^7.0.2",
"csv-stringify": "^6.8.3",
"libphonenumber-js": "^1.13.14",
"nodemailer": "^10.0.10"
},
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.63.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"vite": "^7.1.7" "vite": "^7.1.7"
}, },
@ -477,6 +484,22 @@
"node": "^22.20 || ^24.12 || >=25" "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": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.63.5", "version": "4.63.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz",
@ -873,6 +896,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/esbuild": {
"version": "0.28.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@ -948,6 +983,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/libphonenumber-js": {
"version": "1.13.14",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.14.tgz",
"integrity": "sha512-llihgCcx0BFLksecLP+x1J+6JDE1GsXS1RN/LoPF6qcwpeQcnjj0lcvZxY8AzbEpYwyZWPZW/nDuqkqzm3amiw==",
"license": "MIT"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.19", "version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
@ -967,6 +1008,15 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -987,6 +1037,35 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/postcss": {
"version": "8.5.28", "version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",

View File

@ -11,10 +11,22 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 5173 --strictPort", "preview": "vite preview --host 0.0.0.0 --port 5173 --strictPort",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check ." "format:check": "prettier --check .",
"dev:server": "node --env-file-if-exists=.env --watch src/server/index.js",
"start:server": "node --env-file-if-exists=.env src/server/index.js",
"test": "node --test src/tests/*.test.js",
"test:browser": "playwright test",
"dev:mail": "mailpit --listen 127.0.0.1:8025 --smtp 127.0.0.1:1025 --disable-version-check"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.63.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"vite": "^7.1.7" "vite": "^7.1.7"
},
"dependencies": {
"csv-parse": "^7.0.2",
"csv-stringify": "^6.8.3",
"libphonenumber-js": "^1.13.14",
"nodemailer": "^10.0.10"
} }
} }

30
paket-details.html 100644
View File

@ -0,0 +1,30 @@
<!doctype html>
<html lang="de">
<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="stylesheet" href="/src/styles/navigation.css" />
<link rel="stylesheet" href="/src/styles/packages.css" />
<link rel="stylesheet" href="/src/styles/footer.css" />
</head>
<body class="packages-page">
<div id="navbar-placeholder"></div>
<main class="packages-section" id="main-content" tabindex="-1">
<a class="packages-back" href="/angebotsuebersicht.html"
>← Zurück zur Angebotsübersicht</a
>
<div id="package-detail-container"></div>
</main>
<script
type="module"
src="/src/components/navigationsbar/navigation.js"
></script>
<script
type="module"
src="/src/features/angebote/paket-details.js"
></script>
<div id="footer-placeholder"></div>
<script type="module" src="/src/components/footer/footer.js"></script>
</body>
</html>

View File

@ -0,0 +1,20 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./src/tests/browser",
workers: 1,
use: { baseURL: "http://127.0.0.1:5173", browserName: "chromium" },
webServer: [
{
command: "node src/tests/helpers/browser-server.js",
url: "http://127.0.0.1:3000/api/angebote",
reuseExistingServer: process.env.PW_REUSE_EXISTING === "1",
gracefulShutdown: { signal: "SIGTERM", timeout: 3000 },
},
{
command: "npm run dev",
url: "http://127.0.0.1:5173/booking.html",
reuseExistingServer: process.env.PW_REUSE_EXISTING === "1",
},
],
});

View File

@ -0,0 +1,93 @@
Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) DMSans-Italic[opsz,wght].ttf: Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@ -0,0 +1,69 @@
<footer class="trihub-footer" aria-label="Fußbereich">
<div class="trihub-footer-inner">
<section class="trihub-footer-card trihub-footer-card--brand">
<a class="trihub-brand" href="/index.html" aria-label="Tri-hub Startseite">
<span class="trihub-logo-box">
<img src="/images/logo.png" alt="Tri-hub" />
</span>
<span>
<span class="trihub-brand-label">Tri-hub</span>
<span class="trihub-brand-title">Triathlon als sportliches Zuhause ab 50</span>
</span>
</a>
<p class="trihub-description">
Tri-hub verbindet persönliche Begleitung, Kleingruppen, Wissen, Camps,
digitale Trainingssteuerung und sportliche Gemeinschaft für Menschen,
die Triathlon ab 50 ernsthaft und passend zu ihrem Alltag entwickeln möchten.
</p>
<div class="trihub-contact">
<div class="trihub-contact-row">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="16" x="2" y="4" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg>
<p>info@tri-hub.de · 06203/1764877</p>
</div>
<div class="trihub-contact-row">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"></path><circle cx="12" cy="10" r="3"></circle></svg>
<p>Tri-hub GmbH, Schloßstraße 11, 68535 Edingen-Neckarhausen</p>
</div>
</div>
<div class="trihub-socials" aria-label="Social Media">
<a class="trihub-social-button" href="https://www.facebook.com/profile.php?id=61569002603339" target="_blank" rel="noopener noreferrer" aria-label="Tri-hub auf Facebook">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/></svg>
</a>
<a class="trihub-social-button" href="https://www.linkedin.com/company/tri-hub-gmbh" target="_blank" rel="noopener noreferrer" aria-label="Tri-hub auf LinkedIn">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/><rect width="4" height="12" x="2" y="9"/><circle cx="4" cy="4" r="2"/></svg>
</a>
</div>
</section>
<section class="trihub-footer-card">
<h2 class="trihub-footer-heading">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-11V5l-8-3-8 3v6c0 7 8 11 8 11Z"></path><path d="m9 12 2 2 4-4"></path></svg>
Hauptbereiche
</h2>
<nav class="trihub-footer-links" aria-label="Hauptbereiche">
<a href="https://tri-hub.de/coaching-1zu1">1:1 Coaching</a>
<a href="https://tri-hub.de/webinare-workshops">Webinare &amp; Workshops</a>
<a href="https://tri-hub.de/camps-schwarzwald">Camps</a>
<a href="https://tri-hub.de/trainingsplaene">Trainingspläne</a>
<a href="https://tri-hub.de/wissen">Wissen</a>
<a href="https://tri-hub.de/community-blog">Community-Blog</a>
<a href="https://tri-hub.de/event-kalender">Event-Kalender</a>
</nav>
</section>
<section class="trihub-footer-card">
<h2 class="trihub-footer-heading">Rechtliches</h2>
<nav class="trihub-footer-links" aria-label="Rechtliche Informationen">
<a href="https://tri-hub.de/kontakt">Kontakt</a>
<a href="https://tri-hub.de/agb">AGB</a>
<a href="https://tri-hub.de/widerruf">Widerrufsbelehrung</a>
<a href="https://tri-hub.de/datenschutz">Datenschutz</a>
<a href="https://tri-hub.de/impressum">Impressum</a>
<a class="trihub-cookie-link" href="#cookie-einstellungen" aria-haspopup="dialog">Cookie-Einstellungen öffnen</a>
</nav>
</section>
</div>
</footer>

View File

@ -0,0 +1,91 @@
import footerHtml from "./footer.html?raw";
/** Fügt das Footer-Fragment am Platzhalter ein. */
export function initFooter(placeholderSelector = "#footer-placeholder") {
const placeholder = document.querySelector(placeholderSelector);
if (!placeholder) {
console.warn("Footer-Platzhalter nicht gefunden:", placeholderSelector);
return;
}
placeholder.outerHTML = `${footerHtml}
<dialog class="cookie-settings-dialog" aria-labelledby="cookie-settings-title">
<div class="cookie-settings-heading">
<div>
<p class="cookie-settings-eyebrow">DATENSCHUTZ &amp; REICHWEITENMESSUNG</p>
<h2 id="cookie-settings-title">Cookie-Einstellungen</h2>
</div>
<button class="cookie-settings-close" type="button" aria-label="Cookie-Einstellungen schließen">×</button>
</div>
<div class="cookie-settings-options">
<label class="cookie-option">
<span class="cookie-option-copy"><strong>Notwendig</strong><span>Halten den Laden am Laufen und sind notwendig für den Betrieb.</span></span>
<input type="checkbox" name="necessary" checked disabled aria-label="Notwendige Cookies immer aktiv">
<span class="cookie-toggle" aria-hidden="true"></span>
</label>
<label class="cookie-option">
<span class="cookie-option-copy"><strong>Komfort</strong><span>Machen dein Erlebnis angenehmer, zum Beispiel durch Terminbuchung, Kundenbewertungen oder externe Videoinhalte.</span></span>
<input type="checkbox" name="comfort">
<span class="cookie-toggle" aria-hidden="true"></span>
</label>
<label class="cookie-option">
<span class="cookie-option-copy"><strong>Analyse</strong><span>Helfen uns, die Nutzung der Website auszuwerten und Inhalte zu verbessern.</span></span>
<input type="checkbox" name="analysis">
<span class="cookie-toggle" aria-hidden="true"></span>
</label>
<label class="cookie-option">
<span class="cookie-option-copy"><strong>Marketing</strong><span>Helfen uns, Angebote zu verbessern und relevante Inhalte zu zeigen.</span></span>
<input type="checkbox" name="marketing">
<span class="cookie-toggle" aria-hidden="true"></span>
</label>
</div>
<div class="cookie-settings-actions">
<button class="cookie-settings-back" type="button">Zurück</button>
<button class="cookie-settings-save" type="button">Einstellungen speichern</button>
</div>
</dialog>`;
const dialog = document.querySelector(".cookie-settings-dialog");
const cookieLink = document.querySelector(".trihub-cookie-link");
const preferenceKey = "triHubCookiePreferences";
const loadPreferences = () => {
try {
return JSON.parse(localStorage.getItem(preferenceKey) || "{}");
} catch {
return {};
}
};
cookieLink?.addEventListener("click", (event) => {
event.preventDefault();
const preferences = loadPreferences();
for (const category of ["comfort", "analysis", "marketing"]) {
const input = dialog.querySelector(`input[name="${category}"]`);
input.checked = preferences[category] === true;
}
dialog.showModal();
});
const closeDialog = () => dialog.close();
dialog.querySelector(".cookie-settings-close").addEventListener("click", closeDialog);
dialog.querySelector(".cookie-settings-back").addEventListener("click", closeDialog);
dialog.querySelector(".cookie-settings-save").addEventListener("click", () => {
const preferences = { necessary: true };
for (const category of ["comfort", "analysis", "marketing"]) {
preferences[category] = dialog.querySelector(`input[name="${category}"]`).checked;
}
try {
localStorage.setItem(preferenceKey, JSON.stringify(preferences));
} catch {
// Der Dialog bleibt auch ohne verfügbaren Speicher bedienbar.
}
closeDialog();
});
dialog.addEventListener("click", (event) => {
if (event.target === dialog) closeDialog();
});
}
document.addEventListener("DOMContentLoaded", () => initFooter());

View File

@ -1,8 +1,10 @@
<a href="#main-content" class="skip-link">Zum Hauptinhalt springen</a>
<header class="navbar" id="site-navbar"> <header class="navbar" id="site-navbar">
<div class="navbar__inner"> <div class="navbar__inner">
<div class="navbar__brand"> <a
href="/index.html#main-content"
class="navbar__brand"
aria-label="Zum Hauptinhalt springen"
>
<img src="/images/logo.png" alt="" class="navbar__logo-img" /> <img src="/images/logo.png" alt="" class="navbar__logo-img" />
<span class="navbar__brand-text"> <span class="navbar__brand-text">
<span class="navbar__brand-title">TRI-HUB</span> <span class="navbar__brand-title">TRI-HUB</span>
@ -10,7 +12,7 @@
>Ernährungsberatung für Triathleten ab 50</span >Ernährungsberatung für Triathleten ab 50</span
> >
</span> </span>
</div> </a>
<button <button
class="navbar__toggle" class="navbar__toggle"
@ -30,29 +32,23 @@
> >
</li> </li>
<li> <li>
<a href="#ernaehrung" class="navbar__link">Ernährung</a> <a href="/index.html" class="navbar__link">Ernährung</a>
</li> </li>
<li> <li>
<a <a href="/berater.html" class="navbar__link"
href="/src/design/tri-hub-ernaehrungsberatung.html"
class="navbar__link"
>Beraterprofil</a >Beraterprofil</a
> >
</li> </li>
<li> <li>
<a <a href="/FAQ.html" class="navbar__link">FAQ</a>
href="/src/design/tri-hub-ernaehrungsberatung.html" </li>
class="navbar__link" <li>
>FAQ</a <a href="/index.html#erfahrungen" class="navbar__link"
>Rezensionen</a
> >
</li> </li>
<li> <li>
<a href="#erfahrungen" class="navbar__link">Rezensionen</a> <a href="/index.html#kontakt" class="navbar__link"
</li>
<li>
<a
href="/src/design/tri-hub-ernaehrungsberatung.html"
class="navbar__link"
>Kontakt</a >Kontakt</a
> >
</li> </li>

View File

@ -0,0 +1,3 @@
import { renderAngebotsuebersicht } from "./angebotsuebersicht.js";
renderAngebotsuebersicht();

View File

@ -0,0 +1,66 @@
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 packageImage = {
"ernaehrung-starter": {
src: "/images/Starter_bild.webp",
alt: "Triathlon-Einsteiger im Gespräch mit ihrem Coach am See",
},
"ernaehrung-standard": {
src: "/images/standard_bild.webp",
alt: "Triathlet im Radtrikot bespricht sein Training mit dem Coach",
},
"ernaehrung-premium": {
src: "/images/premium_bild.webp",
alt: "Triathlon-Athleten planen gemeinsam am Seeufer",
},
}[pkg.id];
const cardHtml = `
<article class="package-card">
<img class="package-image" src="${packageImage.src}" alt="${packageImage.alt}" loading="lazy" />
<div class="package-type-container">
<span class="package-type">${pkg.type}</span>
${highlightHtml}
</div>
<h2 class="package-title">${pkg.name}</h2>
<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} <span aria-hidden="true">→</span></a>
</article>
`;
container.innerHTML += cardHtml;
});
}

View File

@ -0,0 +1,158 @@
import { packagesDetails } from "../../shared/packagesdetails.js";
import { mountBookingPage } from "../booking/booking-page.js";
import { formatMoney } from "../../shared/order-summary.js";
import { paymentNotice } from "../../shared/booking-copy.js";
document.addEventListener("DOMContentLoaded", () => {
const container = document.getElementById("package-detail-container");
if (!container) return;
const params = new URLSearchParams(window.location.search);
const pkg = packagesDetails.find((item) => item.id === params.get("id"));
const requestedVariant = params.get("variant");
const invalidVariant =
requestedVariant &&
!pkg?.variants?.some(
(variant) => variant.variantId === requestedVariant,
);
if (!pkg || invalidVariant) {
container.innerHTML = `
<div class="package-card">
<h1>Paket nicht gefunden</h1>
<p>Bitte wähle ein gültiges Paket und eine passende Variante aus.</p>
<a href="/angebotsuebersicht.html" class="package-btn">Zur Übersicht</a>
</div>`;
return;
}
document.title = `${pkg.type} – Tri-Hub Ernährungsberatung`;
const variantsHtml = pkg.variants?.length
? `<div class="detail-box">
<label for="variant-select">Wähle deine Variante</label>
<select id="variant-select" class="variant-select">
${pkg.variants
.map(
(variant) => `
<option value="${variant.variantId}">
${variant.label} – ${formatMoney(variant.price, pkg.currency)}
</option>`,
)
.join("")}
</select>
</div>`
: "";
container.innerHTML = `
<header class="detail-heading">
<div class="package-type-container">
<span class="package-type">${pkg.type} · <span id="package-duration"></span></span>
${pkg.badge ? `<span class="package-highlight">${pkg.badge}</span>` : ""}
</div>
<h1>${pkg.title}</h1>
<p class="package-desc">${pkg.subtitle}</p>
</header>
<div class="detail-grid">
<div class="detail-content">
<section aria-labelledby="target-heading">
<h2 id="target-heading">Für wen ist dieses Paket ideal?</h2>
<ul class="detail-list">
${pkg.targetGroup.map((item) => `<li>${item}</li>`).join("")}
</ul>
</section>
<section aria-labelledby="features-heading">
<h2 id="features-heading">Das ist im Paket enthalten</h2>
<ul class="detail-list">
${pkg.includedFeatures.map((feature) => `<li>${feature}</li>`).join("")}
</ul>
</section>
<section aria-labelledby="steps-heading">
<h2 id="steps-heading">So läuft deine Begleitung ab</h2>
${pkg.processSteps
.map(
(step) => `
<div class="process-step">
<strong>${step.step}</strong>
<p class="detail-text">${step.text}</p>
</div>`,
)
.join("")}
</section>
</div>
<section class="package-checkout" aria-labelledby="checkout-heading">
<h2 id="checkout-heading">Dein ${pkg.type.toLowerCase().replace(/^./, (letter) => letter.toUpperCase())}-Paket</h2>
<div class="price-box" aria-live="polite">
<span class="price-amount" id="displayed-price"></span>
<span class="price-note">inkl. ${pkg.taxRate} % MwSt. · ${pkg.billingInterval}</span>
<p id="selected-duration" class="price-note"></p>
</div>
${variantsHtml}
<button id="proceed-to-booking-btn" class="package-btn primary-cta" type="button" aria-haspopup="dialog" aria-expanded="false" disabled>
Buchungsformular wird geladen …
</button>
<p class="booking-notice" id="booking-notice"></p>
<div id="booking-root"></div>
</section>
</div>`;
container.querySelector("#booking-notice").textContent = paymentNotice;
const variantSelect = container.querySelector("#variant-select");
if (variantSelect && requestedVariant)
variantSelect.value = requestedVariant;
const bookingBtn = container.querySelector("#proceed-to-booking-btn");
const bookingRoot = container.querySelector("#booking-root");
let cleanupBooking;
let activeDialog;
function prepareBooking() {
cleanupBooking?.();
activeDialog = null;
bookingBtn.disabled = true;
bookingBtn.textContent = "Buchungsformular wird geladen …";
bookingBtn.setAttribute("aria-expanded", "false");
bookingBtn.removeAttribute("aria-controls");
const variant = pkg.variants?.find(
(item) => item.variantId === variantSelect?.value,
);
const duration = variant
? `${variant.durationWeeks} Wochen Begleitung`
: pkg.durationLabel;
container.querySelector("#displayed-price").textContent = formatMoney(
variant?.price ?? pkg.price,
pkg.currency,
);
container.querySelector("#package-duration").textContent = duration;
container.querySelector("#selected-duration").textContent = duration;
cleanupBooking = mountBookingPage(bookingRoot, {
packageId: pkg.id,
variantId: variantSelect?.value,
});
}
// Booking lädt die Paketvorschau asynchron, auch bei einem erneuten Versuch.
const observer = new MutationObserver(() => {
const dialog = bookingRoot.querySelector(".booking__dialog");
if (!dialog || dialog === activeDialog) return;
activeDialog = dialog;
bookingBtn.disabled = false;
bookingBtn.textContent = "Jetzt buchen";
bookingBtn.setAttribute("aria-controls", dialog.id);
dialog.addEventListener("close", () => {
bookingBtn.setAttribute("aria-expanded", "false");
bookingBtn.focus();
});
});
observer.observe(bookingRoot, { childList: true, subtree: true });
prepareBooking();
variantSelect?.addEventListener("change", prepareBooking);
bookingBtn.addEventListener("click", () => {
if (!activeDialog || activeDialog.open) return;
activeDialog.showModal();
bookingBtn.setAttribute("aria-expanded", "true");
const result = activeDialog.querySelector(
".booking__result:not([hidden])",
);
(result || activeDialog.querySelector('[name="name"]')).focus();
});
});

View File

@ -0,0 +1,134 @@
import berater from "../../shared/berater-daten.json";
import "./berater.css";
const container = document.querySelector("#advisor-profiles");
function escapeHtml(value) {
return String(value).replace(
/[&<>"']/g,
(character) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[character],
);
}
function contactName(person) {
return (
(typeof person.spitzname === "string" && person.spitzname.trim()) ||
person.vorname ||
person.name.trim().split(/\s+/)[0]
);
}
if (container) {
if (!Array.isArray(berater) || berater.length === 0) {
container.innerHTML =
'<p class="advisor-loading">Aktuell sind keine Beraterprofile verfügbar.</p>';
} else {
container.innerHTML = berater
.map(
(person) => `
<article class="package-card advisor-card">
<div class="advisor-gallery" data-current-index="0" role="region" aria-roledescription="Karussell" aria-label="Bilder von ${escapeHtml(person.name)}">
<div class="advisor-gallery__frame">
<div class="advisor-gallery__track">
${(person.bilder || [])
.map(
(bild, index) => `
<figure class="advisor-gallery__slide" aria-label="${index + 1} von ${person.bilder.length}: ${escapeHtml(bild.typ)}" aria-hidden="${index !== 0}">
<img class="advisor-gallery__image" src="${escapeHtml(bild.quelle)}" alt="${escapeHtml(person.name)} – ${escapeHtml(bild.typ)}" loading="${index === 0 ? "eager" : "lazy"}" />
</figure>`,
)
.join("")}
</div>
<button class="advisor-gallery__arrow advisor-gallery__arrow--previous" type="button" data-gallery-direction="previous" aria-label="Vorheriges Bild von ${escapeHtml(person.name)}">‹</button>
<button class="advisor-gallery__arrow advisor-gallery__arrow--next" type="button" data-gallery-direction="next" aria-label="Nächstes Bild von ${escapeHtml(person.name)}">›</button>
</div>
<div class="advisor-gallery__pagination" aria-label="Bildposition">
${(person.bilder || [])
.map(
(bild, index) => `
<button class="advisor-gallery__dot" type="button" data-gallery-index="${index}" aria-label="Bild ${index + 1}: ${escapeHtml(bild.typ)}" aria-current="${index === 0}"></button>`,
)
.join("")}
</div>
</div>
<h3 class="package-title">${escapeHtml(person.name)}</h3>
<p class="package-type advisor-role">${escapeHtml(person.titel || "Ernährungsberatung")}</p>
<div class="package-details-box">
<div class="detail-item">
<h4 class="detail-title">Kurzbiografie</h4>
<p class="detail-text">${escapeHtml(person.biographie)}</p>
</div>
<div class="detail-item">
<h4 class="detail-title">Qualifikationen</h4>
<ul class="detail-list">${(person.qualifikationen || []).map((qualification) => `<li>${escapeHtml(qualification)}</li>`).join("")}</ul>
</div>
<div class="detail-item">
<h4 class="detail-title">Schwerpunkte</h4>
<ul class="detail-list">${(person.schwerpunkte || []).map((focus) => `<li>${escapeHtml(focus)}</li>`).join("")}</ul>
</div>
</div>
<button class="package-btn advisor-contact" type="button" data-berater-id="${escapeHtml(person.id)}" aria-haspopup="dialog">${escapeHtml(contactName(person))} kontaktieren</button>
</article>`,
)
.join("");
}
}
document.addEventListener("click", async (event) => {
const galleryControl = event.target.closest(
"button[data-gallery-direction], button[data-gallery-index]",
);
if (galleryControl) {
const gallery = galleryControl.closest(".advisor-gallery");
const slides = [...gallery.querySelectorAll(".advisor-gallery__slide")];
const currentIndex = Number(gallery.dataset.currentIndex || 0);
const requestedIndex = galleryControl.hasAttribute(
"data-gallery-direction",
)
? currentIndex +
(galleryControl.dataset.galleryDirection === "next" ? 1 : -1)
: Number(galleryControl.dataset.galleryIndex);
const nextIndex = (requestedIndex + slides.length) % slides.length;
gallery.dataset.currentIndex = String(nextIndex);
gallery.querySelector(".advisor-gallery__track").style.transform =
`translateX(-${nextIndex * 100}%)`;
slides.forEach((slide, index) => {
slide.setAttribute("aria-hidden", String(index !== nextIndex));
});
gallery.querySelectorAll("[data-gallery-index]").forEach((button) => {
button.setAttribute(
"aria-current",
String(Number(button.dataset.galleryIndex) === nextIndex),
);
});
return;
}
const button = event.target.closest("button[data-berater-id]");
if (!button) return;
try {
const { openContactDialog } = await import(
/* @vite-ignore */ "../contact/contact-dialog.js"
);
openContactDialog({ advisorId: button.dataset.beraterId });
} catch (error) {
console.error(
"Das Kontaktformular ist derzeit nicht verfügbar.",
error,
);
button.insertAdjacentHTML(
"afterend",
'<p class="advisor-contact-error" role="status">Das Kontaktformular ist momentan nicht verfügbar.</p>',
);
button.disabled = true;
}
});

View File

@ -0,0 +1,235 @@
.advisor-page {
color-scheme: dark;
}
.advisor-main {
min-height: 100vh;
padding-top: 96px;
}
.advisor-section {
width: min(1180px, calc(100% - 3rem));
margin: auto;
padding-block: 5rem;
overflow-wrap: anywhere;
}
.advisor-heading {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: 3rem;
align-items: end;
margin-bottom: 3rem;
}
.advisor-eyebrow {
grid-column: 1 / -1;
margin: 0;
color: #53d5cd;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.advisor-heading h1 {
margin: 0.75rem 0 1rem;
font-size: clamp(2rem, 4.5vw, 3.75rem);
font-weight: 600;
line-height: 1.15;
letter-spacing: -0.035em;
}
.advisor-intro {
max-width: 40rem;
margin: 0;
color: #94a3b8;
}
.advisor-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 310px), 1fr));
gap: 1.5rem;
}
.advisor-loading {
grid-column: 1 / -1;
color: #94a3b8;
}
.advisor-card {
min-width: 0;
}
.advisor-card .package-title {
margin-bottom: 0.5rem;
}
.advisor-card .package-type {
margin: 0 0 1rem;
}
.advisor-card .package-details-box {
flex: 1;
padding-block: 1.25rem;
}
.advisor-card .detail-title {
margin-bottom: 0.5rem;
}
.advisor-card .detail-item + .detail-item {
margin-top: 1.25rem;
}
.advisor-card .detail-list {
margin: 0;
padding-left: 1.25rem;
}
.advisor-card .detail-list li::marker {
color: #53d5cd;
}
.advisor-contact {
margin-top: 1.5rem;
}
.advisor-contact-error {
margin: 10px 0 0;
color: #fca5a5;
font-size: 0.82rem;
}
@media (max-width: 760px) {
.advisor-section {
width: calc(100% - 2rem);
padding-block: 3.5rem;
}
.advisor-heading {
grid-template-columns: 1fr;
gap: 1.25rem;
}
.advisor-eyebrow {
grid-column: auto;
}
}
.advisor-card .advisor-role {
margin: 0 0 1rem;
}
.advisor-card .package-details-box {
padding: 1.5rem;
border: 1px solid #f8fafc1f;
border-radius: 1.4rem;
background: #ffffff0b;
}
.advisor-gallery {
margin-bottom: 1.5rem;
}
.advisor-gallery__frame {
position: relative;
padding: 0.7rem;
overflow: hidden;
border: 1px dashed #f8fafc38;
border-radius: 1.5rem;
background: #ffffff05;
}
.advisor-gallery__track {
display: flex;
width: 100%;
height: clamp(240px, 34vw, 390px);
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
.advisor-gallery__slide {
flex: 0 0 100%;
width: 100%;
height: 100%;
margin: 0;
}
.advisor-gallery__image {
display: block;
width: 100%;
height: 100%;
border-radius: 1.1rem;
background: #071720;
object-fit: cover;
object-position: center;
}
.advisor-gallery__arrow {
position: absolute;
top: 50%;
display: grid;
width: 2.75rem;
height: 2.75rem;
padding: 0;
place-items: center;
transform: translateY(-50%);
border: 1px solid #ffffff55;
border-radius: 50%;
color: #fff;
background: #071720bb;
font: inherit;
font-size: 2rem;
line-height: 1;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease;
}
.advisor-gallery__arrow:hover {
border-color: #53d5cd;
background: #071720ee;
}
.advisor-gallery__arrow--previous {
left: 1.2rem;
}
.advisor-gallery__arrow--next {
right: 1.2rem;
}
.advisor-gallery__pagination {
display: flex;
justify-content: center;
gap: 0.55rem;
margin-top: 0.85rem;
}
.advisor-gallery__dot {
width: 0.7rem;
height: 0.7rem;
padding: 0;
border: 1px solid #94a3b8;
border-radius: 50%;
background: #ffffff12;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.advisor-gallery__dot[aria-current="true"] {
transform: scale(1.15);
border-color: #53d5cd;
background: #53d5cd;
}
.advisor-gallery__arrow:focus-visible,
.advisor-gallery__dot:focus-visible {
outline: 3px solid #53d5cd;
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.advisor-gallery__track {
transition: none;
}
}

View File

@ -0,0 +1,54 @@
export class BookingApiError extends Error {
constructor(message, code, fields = {}) {
super(message);
this.code = code;
this.fields = fields;
}
}
async function request(path, options = {}) {
let response;
let body;
try {
response = await fetch(path, {
...options,
signal: options.signal ?? AbortSignal.timeout(45000),
});
body = await response.json();
} catch (error) {
if (options.signal?.aborted) throw error;
throw new BookingApiError(
"Die Verbindung ist unterbrochen oder der Server nicht erreichbar. Deine Angaben bleiben erhalten. Bitte den Status mit derselben Anfrage erneut prüfen.",
"NETWORK_ERROR",
);
}
if (!response.ok) {
throw new BookingApiError(
body.error?.message ||
"Die Anfrage konnte nicht verarbeitet werden.",
body.error?.code,
body.error?.fields,
);
}
return body;
}
export function submitBooking(payload, idempotencyKey) {
return request("/api/bookings", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(payload),
});
}
export function previewBooking(packageId, variantId, signal) {
return request("/api/bookings/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ packageId, variantId }),
signal,
});
}

View File

@ -0,0 +1,288 @@
.confirmation {
--confirmation-accent: #53d5cd;
margin: 0;
min-height: 100vh;
color: #f8fafc;
background:
radial-gradient(ellipse at 5% 20%, #53d5cd14, 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 var(--confirmation-accent);
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: var(--confirmation-accent);
border-radius: 0.9rem;
letter-spacing: -0.08em;
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 #53d5cd70;
border-radius: 50%;
color: var(--confirmation-accent);
}
.confirmation h1 {
margin: 1rem 0 1.5rem;
font-family: inherit;
font-weight: 500;
font-size: clamp(2.5rem, 5vw, 4.5rem);
line-height: 1.12;
letter-spacing: -0.035em;
}
.confirmation h1 em {
color: var(--confirmation-accent);
font-weight: inherit;
}
.confirmation h2 {
margin: 0.5rem 0 1rem;
font-family: inherit;
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 var(--confirmation-accent);
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: #53d5cd40;
background: #53d5cd08;
}
.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: var(--confirmation-accent);
}
.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: var(--confirmation-accent);
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: var(--confirmation-accent);
color: #071a14 !important;
}
.confirmation__link {
border: 1px solid #ffffff20;
}
.confirmation__button:hover {
background: #6de0d9;
}
.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

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

View File

@ -0,0 +1,16 @@
import "../../styles/styles.css";
import { mountBookingPage } from "./booking-page.js";
const root = document.querySelector("#booking-root");
let cleanup;
function render() {
cleanup?.();
const match = location.hash.match(/^#\/buchen\/([a-z0-9-]+)$/);
const params = new URLSearchParams(location.search);
cleanup = mountBookingPage(root, {
packageId: params.get("id") || match?.[1],
variantId: params.get("variant") || undefined,
});
}
window.addEventListener("hashchange", render);
render();

View File

@ -0,0 +1,419 @@
import {
isValidBookingPhone,
phoneError,
} from "../../shared/phone-validation.js";
import { previewBooking, submitBooking } from "./booking-api.js";
import "./booking.css";
import { orderSummaryRows } from "../../shared/order-summary.js";
import { paymentNotice } from "../../shared/booking-copy.js";
// Die Detailseite kann dieselbe Section direkt mounten und später aufräumen.
// Die Zieladresse kann bei der Integration bei Bedarf überschrieben werden.
export function mountBookingPage(
root,
{
packageId,
variantId,
confirmationUrl = root.dataset.confirmationUrl ||
"/booking-confirmation.html",
} = {},
) {
const controller = new AbortController();
const { signal } = controller;
const instanceId = `booking-${crypto.randomUUID()}`;
root.innerHTML = `
<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-error" />
<p class="booking__field-error" id="${instanceId}-booking-phone-error"></p>
</div>
<label class="booking__consent"><input name="consent" type="checkbox" required aria-describedby="${instanceId}-booking-consent-error" /> <span>Ich akzeptiere die <a href="https://www.tri-hub.de/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> (Pflichtfeld).</span></label>
<p class="booking__field-error" id="${instanceId}-booking-consent-error"></p>
<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 consent = form.elements.consent;
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;
consent.disabled = locked;
}
function showError(message, fields = {}) {
errors.textContent = message;
errors.hidden = false;
for (const field of [name, email, phone, consent]) {
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, consent]) {
field.removeAttribute("aria-invalid");
content.querySelector(
`#${instanceId}-booking-${field.name}-error`,
).textContent = "";
}
}
function showResult(body, focus = true) {
if (body.bookedPackage) renderOrder(body.bookedPackage);
const messages = {
accepted:
"Buchung gespeichert. Die Bestätigung wurde vom Mailserver zum Versand angenommen. Die Zustellung ist noch nicht bestätigt.",
failed: "Buchung gespeichert, aber der Bestätigungsversand ist fehlgeschlagen. Bitte nicht erneut buchen. Wende dich mit deiner Buchungsnummer an Tri-Hub.",
pending:
"Buchung gespeichert. Der Bestätigungsversand konnte noch nicht gestartet werden. Du kannst den Versand mit der bestehenden Buchung erneut versuchen.",
unknown:
"Buchung gespeichert. Der Versandstatus der Bestätigung ist unklar. Bitte nicht erneut buchen. Wende dich mit deiner Buchungsnummer an Tri-Hub.",
};
result.textContent = `${messages[body.emailStatus] || messages.unknown} Buchungsnummer: ${body.bookingId}`;
result.hidden = false;
lockFields(true);
finished = body.emailStatus !== "pending";
button.disabled = finished;
button.textContent = finished
? "Buchung gespeichert"
: "Bestätigungsversand erneut versuchen";
if (focus) result.focus();
}
try {
const stored = JSON.parse(sessionStorage.getItem(storageKey));
if (
stored?.key &&
stored.payload?.packageId === selected.packageId
) {
attempt = stored;
if (stored.order) renderOrder(stored.order);
name.value = stored.payload.name;
email.value = stored.payload.email;
phone.value = stored.payload.phone || "";
consent.checked = stored.payload.consent === true;
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 = {};
if (!consent.checked)
fields.consent =
"Bitte die Datenschutzbestimmungen akzeptieren.";
name.value = name.value.trim();
email.value = email.value.trim();
if (
!name.value ||
name.value.length > 120 ||
/[\x00-\x1f\x7f]/.test(name.value)
)
fields.name =
"Bitte deinen Namen eingeben (höchstens 120 Zeichen, ohne Zeilenumbrüche).";
if (!email.validity.valid || email.value.length > 254)
fields.email =
"Bitte eine gültige E-Mail-Adresse eingeben.";
phone.value = phone.value.trim();
if (!isValidBookingPhone(phone.value))
fields.phone = phoneError;
if (Object.keys(fields).length) {
showError(
"Bitte die markierten Angaben prüfen.",
fields,
);
return;
}
const nextAttempt = {
key: crypto.randomUUID(),
order: selected,
payload: {
packageId: selected.packageId,
...(selected.variantId
? { variantId: selected.variantId }
: {}),
consent: consent.checked,
name: name.value,
email: email.value,
...(phone.value ? { phone: phone.value } : {}),
},
};
try {
sessionStorage.setItem(
storageKey,
JSON.stringify(nextAttempt),
);
} catch {
showError(
"Der Browser kann die Anfrage nicht für einen sicheren erneuten Versuch merken. Bitte erlaube den Sitzungsspeicher und versuche es erneut.",
);
return;
}
attempt = nextAttempt;
}
busy = true;
button.disabled = true;
button.textContent = "Buchung wird verarbeitet …";
form.setAttribute("aria-busy", "true");
lockFields(true);
progress.textContent =
"Bitte warten. Deine Buchung wird geprüft und gespeichert.";
try {
const body = await submitBooking(
attempt.payload,
attempt.key,
);
attempt.result = body;
try {
sessionStorage.setItem(
storageKey,
JSON.stringify(attempt),
);
} catch {
/* Der ursprüngliche Schlüssel bleibt erhalten. */
}
if (!signal.aborted) {
showResult(body);
if (
body.saved &&
body.emailStatus === "accepted" &&
confirmationUrl
) {
// Nur weiterleiten, wenn die Bestätigungsseite die
// Serverantwort in diesem Tab auch lesen kann.
try {
sessionStorage.setItem(
"trihub.booking.confirmation",
JSON.stringify(body),
);
} catch {
return; // Die bestehende Bestätigung bleibt sichtbar.
}
window.location.assign(confirmationUrl);
}
}
} catch (error) {
// Nur eindeutige Ablehnungen vor dem Speichern erlauben eine
// Korrektur. Bei Verbindungsfehlern bleiben Daten und Key fest.
if (
[
"INVALID_INPUT",
"UNKNOWN_PACKAGE",
"INVALID_VARIANT",
"INVALID_KEY",
].includes(error.code)
) {
attempt = null;
try {
sessionStorage.removeItem(storageKey);
} catch {
/* Keine Anfrage gespeichert. */
}
lockFields(false);
}
if (!signal.aborted) {
showError(error.message, error.fields);
button.textContent = attempt
? "Status prüfen / erneut versuchen"
: "Paket buchen";
}
} finally {
busy = false;
if (!signal.aborted) {
form.removeAttribute("aria-busy");
progress.textContent = "";
button.disabled = finished;
}
}
},
{ signal },
);
}
load();
return () => {
controller.abort();
root.querySelector(".booking__dialog")?.close();
root.replaceChildren();
};
}

View File

@ -0,0 +1,214 @@
.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: #061619;
background: #53d5cd;
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: #53d5cd;
}
.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: #53d5cd;
border-color: #53d5cd;
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: #53d5cd;
}
.booking .booking__consent {
display: flex;
align-items: flex-start;
gap: 0.6rem;
line-height: 1.5;
}
.booking__consent input[type="checkbox"] {
flex: 0 0 1em;
width: 1em;
height: 1em;
min-height: 0;
margin: 0.25em 0 0;
padding: 0;
}
.booking__consent a,
.booking__consent a:visited {
color: #fff;
font-weight: 700;
text-decoration: underline;
}

View File

@ -0,0 +1,133 @@
import { findAdvisor, advisorDisplayName } from "../../shared/berater.js";
import {
contactReasons,
contactNotice,
contactBookingUrl,
} from "../../shared/contact.js";
import "./contact.css";
// Auf Profilseiten: openContactDialog({ advisorId: profile.id }).
export function openContactDialog({
advisorId,
navigate = (url) => window.location.assign(url),
} = {}) {
const advisor = findAdvisor(advisorId);
const returnFocus = document.activeElement;
const dialog = document.createElement("dialog");
dialog.className = "contact-dialog";
dialog.setAttribute("aria-labelledby", "contact-title");
dialog.innerHTML = `
<button class="contact-close" type="button" aria-label="Kontaktfenster schließen">×</button>
<p class="contact-eyebrow">DEIN NÄCHSTER SCHRITT</p>
<h2 id="contact-title"></h2>
<p class="contact-advisor"></p>
<form class="contact-form">
<div><label for="contact-email">Deine Buchungs-E-Mail</label>
<input id="contact-email" name="email" type="email" autocomplete="email" maxlength="254" required aria-describedby="contact-email-help">
<p id="contact-email-help" class="contact-help">Verwende die E-Mail-Adresse, mit der du dein Paket gebucht hast.</p>
</div>
<div><label for="contact-reason">Grund für den Kontakt</label>
<select id="contact-reason" name="reason" required><option value="">Bitte auswählen</option></select>
</div>
<label class="contact-consent"><input name="simulationAccepted" type="checkbox" required><span></span></label>
<label class="contact-consent"><input name="consent" type="checkbox" required><span>Ich akzeptiere die <a href="https://www.tri-hub.de/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> (Pflichtfeld).</span></label>
<p class="contact-status" role="status" aria-live="polite"></p>
<button class="contact-submit" type="submit" disabled>Anfrage senden & Termin wählen</button>
</form>`;
const advisorName = advisorDisplayName(advisor);
dialog.querySelector("#contact-title").textContent = advisorName
? `${advisorName} kontaktieren`
: "Berater nicht gefunden";
dialog.querySelector(".contact-advisor").textContent = advisor
? "Sende deine Kontaktanfrage."
: "Die Berater-ID fehlt oder ist unbekannt. Bitte öffne das Fenster über ein Beraterprofil.";
dialog.querySelector(".contact-consent span").textContent = contactNotice;
const form = dialog.querySelector("form");
const email = form.elements.email;
const reason = form.elements.reason;
for (const [value, label] of Object.entries(contactReasons)) {
reason.add(new Option(label, value));
}
const status = dialog.querySelector(".contact-status");
const submit = dialog.querySelector(".contact-submit");
const close = dialog.querySelector(".contact-close");
let busy = false;
let sent = false;
function update() {
submit.disabled = busy || sent || !advisor;
close.disabled = busy;
email.disabled = busy || sent;
reason.disabled = busy || sent;
form.elements.consent.disabled = busy || sent;
form.elements.simulationAccepted.disabled = busy || sent;
}
async function post(path, input) {
const response = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const result = await response.json();
if (!response.ok)
throw new Error(
result.error?.message || "Die Anfrage ist fehlgeschlagen.",
);
return result;
}
email.addEventListener("input", () => {
status.textContent = "";
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (submit.disabled || !form.reportValidity()) return;
const input = {
email: email.value,
advisorId,
reason: reason.value,
consent: form.elements.consent.checked,
simulationAccepted: form.elements.simulationAccepted.checked,
};
busy = true;
update();
status.textContent =
"E-Mail wird geprüft und simulierte Anfrage gesendet …";
try {
const result = await post("/api/contact", input);
if (result.redirectUrl !== contactBookingUrl)
throw new Error("Die Weiterleitungsadresse ist ungültig.");
sent = true;
status.textContent =
"Beide Bestätigungen wurden in Mailpit angenommen. Microsoft Bookings wird geöffnet. ";
const link = document.createElement("a");
link.href = contactBookingUrl;
link.textContent = "Weiter zur Terminwahl";
status.append(link);
navigate(contactBookingUrl);
} catch (error) {
status.textContent = `${error.message} Bei einem Verbindungsfehler bitte vor erneutem Senden Mailpit prüfen.`;
} finally {
busy = false;
update();
}
});
close.addEventListener("click", () => dialog.close());
dialog.addEventListener("cancel", (event) => {
if (busy) event.preventDefault();
});
const previousOverflow = document.body.style.overflow;
dialog.addEventListener(
"close",
() => {
document.body.style.overflow = previousOverflow;
dialog.remove();
returnFocus?.focus();
},
{ once: true },
);
document.body.append(dialog);
document.body.style.overflow = "hidden";
update();
dialog.showModal();
email.focus();
return dialog;
}

View File

@ -0,0 +1,14 @@
import { openContactDialog } from "./contact-dialog.js";
import { findAdvisor, advisorDisplayName } from "../../shared/berater.js";
import "./contact-test.css";
const advisorId =
new URLSearchParams(location.search).get("berater-id") ??
"relindis-agethen";
const advisor = findAdvisor(advisorId);
document.querySelector("#advisor-name").textContent = advisor
? `Dein Berater: ${advisorDisplayName(advisor)}`
: "Unbekannte Berater-ID";
document
.querySelector("#open-contact")
.addEventListener("click", () => openContactDialog({ advisorId }));

View File

@ -0,0 +1,73 @@
const form = document.querySelector("#contactForm");
const status = document.querySelector("#contactFormStatus");
const message = form?.elements.message;
const messageHint = document.querySelector("#contactMessageHint");
function updateMessageHint() {
if (!message || !messageHint) return;
const valid = [...message.value.trim()].length >= 10;
messageHint.classList.toggle("is-valid", valid);
messageHint.textContent = valid
? "Mindestens 10 Zeichen erreicht."
: "Mindestens 10 Zeichen erforderlich.";
}
function showErrors(fields = {}) {
for (const input of form.elements) {
if (!input.name) continue;
input.removeAttribute("aria-invalid");
const error = form.querySelector(`[data-error-for="${input.name}"]`);
if (error) error.textContent = fields[input.name] ?? "";
if (fields[input.name]) input.setAttribute("aria-invalid", "true");
}
}
message?.addEventListener("input", updateMessageHint);
updateMessageHint();
form?.addEventListener("submit", async (event) => {
event.preventDefault();
status.textContent = "";
status.classList.remove("is-error");
showErrors();
if (!form.reportValidity()) {
status.textContent = "Bitte prüfe die markierten Eingaben.";
status.classList.add("is-error");
return;
}
const submit = form.querySelector('[type="submit"]');
submit.disabled = true;
submit.textContent = "Wird gesendet …";
try {
const response = await fetch("/api/nutrition-contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...Object.fromEntries(new FormData(form)),
consent: form.elements.consent.checked,
}),
});
const result = await response.json();
if (!response.ok) {
showErrors(result.error?.fields);
throw new Error(
result.error?.message ??
"Die Nachricht konnte nicht gesendet werden.",
);
}
form.reset();
updateMessageHint();
status.textContent =
"Danke für deine Nachricht. Wir melden uns schnellstmöglich bei dir.";
} catch (error) {
status.textContent =
error.message ||
"Der Server ist gerade nicht erreichbar. Bitte versuche es erneut.";
status.classList.add("is-error");
} finally {
submit.disabled = false;
submit.textContent = "Nachricht senden";
}
});

View File

@ -0,0 +1,29 @@
body {
margin: 0;
background: oklch(13% 0.022 223);
color: oklch(96% 0.008 210);
font:
16px/1.5 "DM Sans",
sans-serif;
}
.contact-test {
max-width: 680px;
margin: 12vh auto;
padding: 24px;
}
.contact-test > p {
color: oklch(72% 0.015 214);
}
.contact-test button {
padding: 14px 24px;
border: 0;
border-radius: 6px;
background: white;
color: oklch(20% 0.03 220);
font: inherit;
cursor: pointer;
}
.contact-test button:focus-visible {
outline: 3px solid oklch(70% 0.12 205 / 0.45);
outline-offset: 4px;
}

View File

@ -0,0 +1,155 @@
@font-face {
font-family: "DM Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/dm-sans-latin-400-normal.woff2") format("woff2");
}
@font-face {
font-family: "DM Sans";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/fonts/dm-sans-latin-500-normal.woff2") format("woff2");
}
@font-face {
font-family: "DM Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/dm-sans-latin-700-normal.woff2") format("woff2");
}
.contact-dialog {
--contact-foreground: oklch(96% 0.008 210);
--contact-accent: oklch(70% 0.12 205);
box-sizing: border-box;
width: min(32rem, calc(100vw - 2rem));
max-height: calc(100dvh - 2rem);
margin: auto;
padding: 1.75rem;
overflow-y: auto;
border: 1px solid #324449;
border-radius: 1.25rem;
background: #071316;
color: var(--contact-foreground);
font:
400 16px/1.5 "DM Sans",
sans-serif;
-webkit-font-smoothing: antialiased;
box-shadow: 0 1.5rem 4rem #0006;
}
.contact-dialog::backdrop {
background: rgb(0 0 0 / 65%);
}
.contact-dialog * {
box-sizing: border-box;
}
.contact-dialog h2 {
margin: 8px 32px 8px 0;
font-size: 1.35rem;
line-height: 1.25;
}
.contact-dialog p {
margin: 0;
}
.contact-dialog .contact-eyebrow {
color: var(--contact-accent);
font-size: 12px;
letter-spacing: 0.12em;
}
.contact-advisor,
.contact-help,
.contact-consent {
color: oklch(72% 0.015 214);
}
.contact-form {
margin-top: 32px;
display: grid;
gap: 24px;
}
.contact-form label {
display: block;
margin-bottom: 0.35rem;
font-weight: 600;
}
.contact-form input:not([type="checkbox"]),
.contact-form select {
width: 100%;
min-height: 2.75rem;
padding: 0.65rem 0.8rem;
border: 1px solid #61777d;
border-radius: 0.75rem;
background: #101e22;
color: inherit;
font: inherit;
color-scheme: dark;
}
.contact-dialog button {
min-height: 2.75rem;
padding: 0.65rem 0.8rem;
border: 1px solid transparent;
border-radius: 0.75rem;
font: inherit;
cursor: pointer;
}
.contact-dialog .contact-close {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0;
width: 2.75rem;
height: 2.75rem;
border: 0;
background: transparent;
color: inherit;
font-size: 28px;
}
.contact-submit {
width: 100%;
background: #53d5cd;
color: oklch(20% 0.03 220);
}
.contact-submit:hover:not(:disabled) {
background: #6de0d9;
}
.contact-dialog :disabled {
opacity: 0.7;
cursor: not-allowed;
}
.contact-dialog :focus-visible {
outline: 3px solid #53d5cd;
outline-offset: 4px;
}
.contact-dialog .contact-help {
margin-top: 8px;
font-size: 14px;
}
.contact-form .contact-consent {
display: flex;
align-items: flex-start;
gap: 12px;
margin: 0;
font-size: 14px;
}
.contact-consent input {
flex-shrink: 0;
width: 20px;
height: 20px;
margin: 2px 0 0;
accent-color: var(--contact-accent);
}
.contact-status {
overflow-wrap: anywhere;
}
.contact-status:empty {
display: none;
}
.contact-status a {
color: var(--contact-accent);
}
.contact-consent a {
font-weight: 700;
text-decoration: underline;
}

View File

@ -0,0 +1,137 @@
/**
* Tri-hub FAQ
* Suche, Kategorien, Accordion und Footer-Jahr
*/
document.addEventListener("DOMContentLoaded", () => {
const searchInput = document.getElementById("search");
const chipsContainer = document.getElementById("chips");
const sections = Array.from(document.querySelectorAll(".faq-section"));
const faqItems = Array.from(document.querySelectorAll(".faq-item"));
const emptyMessage = document.getElementById("empty");
const status = document.getElementById("status");
const yearElement = document.getElementById("year");
// Aktuelles Jahr im Footer
if (yearElement) {
yearElement.textContent = new Date().getFullYear();
}
// Aktiver Kategorie-Filter
let activeFilter = "all";
// Text für die Suche vereinheitlichen
function normalizeText(text) {
return text
.toLocaleLowerCase("de")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.trim();
}
// FAQ-Suche und Kategorie-Filter anwenden
function filterFAQs() {
const searchTerm = normalizeText(searchInput?.value || "");
let visibleCount = 0;
sections.forEach((section) => {
const category = section.dataset.cat;
const categoryMatches =
activeFilter === "all" || category === activeFilter;
const items = Array.from(section.querySelectorAll(".faq-item"));
let visibleInSection = 0;
items.forEach((item) => {
const searchableText = normalizeText(item.textContent);
const searchMatches =
searchTerm === "" || searchableText.includes(searchTerm);
const isVisible = categoryMatches && searchMatches;
item.hidden = !isVisible;
if (isVisible) {
visibleInSection++;
visibleCount++;
}
});
// Kategorie nur anzeigen, wenn Fragen passen
section.hidden = visibleInSection === 0;
});
// Hinweis anzeigen, wenn nichts gefunden wurde
if (emptyMessage) {
emptyMessage.classList.toggle("hidden", visibleCount > 0);
}
// Screenreader über das Suchergebnis informieren
if (status) {
status.textContent =
visibleCount === 1
? "Eine Frage gefunden."
: `${visibleCount} Fragen gefunden.`;
}
}
// Kategorie-Buttons initialisieren
if (chipsContainer) {
const chips = Array.from(
chipsContainer.querySelectorAll("[data-filter]"),
);
function updateActiveChip() {
chips.forEach((chip) => {
const isActive = chip.dataset.filter === activeFilter;
chip.classList.toggle("is-active", isActive);
chip.setAttribute("aria-pressed", String(isActive));
});
}
chips.forEach((chip) => {
chip.addEventListener("click", () => {
activeFilter = chip.dataset.filter;
updateActiveChip();
filterFAQs();
});
});
updateActiveChip();
}
// Suche bei jeder Eingabe aktualisieren
if (searchInput) {
searchInput.addEventListener("input", filterFAQs);
}
// Accordion: Immer nur eine Frage gleichzeitig öffnen
faqItems.forEach((item) => {
item.addEventListener("toggle", () => {
const isOpen = item.open;
item.dataset.state = isOpen ? "open" : "closed";
if (!isOpen) return;
faqItems.forEach((otherItem) => {
if (otherItem !== item && otherItem.open) {
otherItem.open = false;
}
});
});
});
// Initialzustand setzen
faqItems.forEach((item) => {
item.dataset.state = item.open ? "open" : "closed";
});
filterFAQs();
});

View File

@ -0,0 +1,49 @@
const form = document.getElementById("reviewForm");
const status = document.getElementById("formStatus");
const button = form.querySelector('button[type="submit"]');
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (button.disabled) return;
const input = {
email: form.elements.email.value.trim(),
title: form.elements.title.value.trim(),
review: form.elements.review.value.trim(),
rating: Number(form.elements.rating.value),
consent: form.elements.consent.checked,
};
for (const [field, max, label] of [
["title", 50, "Titel"],
["review", 500, "Bewertungstext"],
]) {
if (input[field].length < 10 || input[field].length > max) {
status.textContent = `${label}: Bitte 10 bis ${max} Zeichen eingeben.`;
form.elements[field].focus();
return;
}
}
button.disabled = true;
status.textContent = "Buchung wird geprüft und Bewertung gespeichert …";
try {
const response = await fetch("/api/reviews", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const result = await response.json();
if (!response.ok) {
status.textContent =
result.error?.message ||
"Die Bewertung konnte nicht gespeichert werden.";
return;
}
status.textContent = "Danke! Deine Bewertung wurde gespeichert.";
form.reset();
document.dispatchEvent(new Event("review:saved"));
} catch {
status.textContent =
"Keine Bestätigung vom Server erhalten. Bitte versuche es später erneut.";
} finally {
button.disabled = false;
}
});

View File

@ -0,0 +1,139 @@
.review-list {
min-width: 0;
align-content: start;
}
.review-stage {
display: grid;
overflow: hidden;
touch-action: pan-y;
cursor: grab;
user-select: none;
}
.review-stage .review-card {
grid-area: 1 / 1;
min-width: 0;
opacity: 0;
visibility: hidden;
transform: translateX(32px);
transition:
opacity 600ms ease,
transform 600ms cubic-bezier(0.22, 1, 0.36, 1),
visibility 0s 600ms;
pointer-events: none;
overflow-wrap: anywhere;
}
.review-stage .review-card.is-before {
transform: translateX(-32px);
}
.review-stage .review-card.is-active {
opacity: 1;
visibility: visible;
transform: translateX(0);
transition-delay: 0s;
pointer-events: auto;
}
.review-stage .review-card h3 {
font-family: inherit;
margin: 0;
color: var(--white);
font-size: 1.1rem;
line-height: 1.4;
}
.review-stage .review-top {
flex-wrap: wrap;
}
.review-stage .review-card blockquote {
font-family: inherit;
font-size: 1rem;
white-space: pre-wrap;
}
.review-stage:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 4px;
}
.review-carousel {
position: relative;
padding-inline: 3.25rem;
}
.review-arrow {
position: absolute;
z-index: 2;
top: 50%;
display: grid;
width: 2rem;
height: 2.5rem;
padding: 0;
place-items: center;
transform: translateY(-50%);
border: 0;
color: #fff;
background: transparent;
font: inherit;
font-size: 2.5rem;
line-height: 1;
cursor: pointer;
transition: color 0.2s ease;
}
.review-arrow[hidden] {
display: none;
}
.review-arrow:hover {
color: #53d5cd;
}
.review-arrow--previous {
left: 0;
}
.review-arrow--next {
right: 0;
}
.review-arrow:focus-visible {
outline: 3px solid #53d5cd;
outline-offset: 3px;
}
.review-stage:focus:not(:focus-visible) {
outline: none;
box-shadow: none;
}
.review-arrow:focus:not(:focus-visible) {
outline: none;
box-shadow: none;
}
.review-stage.is-dragging {
cursor: grabbing;
}
.review-stage.is-dragging .review-card.is-active {
transition: none;
}
.review-feedback {
color: var(--muted);
font-size: 0.82rem;
}
.review-feedback:empty {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.review-stage .review-card {
transition: none;
transform: none;
}
}
.review-announcement,
.review-instructions {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (max-width: 600px) {
.review-carousel {
padding-inline: 2.25rem;
}
.review-arrow {
width: 1.5rem;
font-size: 2rem;
}
}

View File

@ -0,0 +1,209 @@
import "./review-slider.css";
const slider = document.getElementById("reviewSlider");
const stage = slider.querySelector(".review-stage");
const feedback = slider.querySelector(".review-feedback");
const announcement = slider.querySelector(".review-announcement");
const carousel = document.createElement("div");
carousel.className = "review-carousel";
stage.before(carousel);
carousel.append(stage);
function arrow(direction, symbol, label) {
const button = document.createElement("button");
button.type = "button";
button.className = `review-arrow review-arrow--${direction}`;
button.setAttribute("aria-label", label);
button.textContent = symbol;
button.hidden = true;
button.addEventListener("click", () =>
show(index + (direction === "next" ? 1 : -1), true),
);
carousel.append(button);
return button;
}
const previousArrow = arrow("previous", "‹", "Vorherige Rezension");
const nextArrow = arrow("next", "›", "Nächste Rezension");
const interval = 10_000;
let reviews = [];
let index = 0;
let timer;
let drag = null;
let latestRequest = 0;
function schedule() {
clearTimeout(timer);
if (reviews.length > 1 && !drag && !document.hidden) {
timer = setTimeout(() => show(index + 1), interval);
}
}
function show(next, manual = false) {
if (!reviews.length) return;
const direction = next < index ? -1 : 1;
const previous = stage.children[index];
index = (next + reviews.length) % reviews.length;
const current = stage.children[index];
if (previous !== current) {
current.classList.toggle("is-before", direction < 0);
// Establish the starting position before transitioning into view.
void current.offsetWidth;
previous.classList.toggle("is-before", direction > 0);
}
[...stage.children].forEach((card, number) => {
card.classList.toggle("is-active", number === index);
card.inert = number !== index;
card.setAttribute("aria-hidden", String(number !== index));
});
if (manual)
announcement.textContent = `Rezension ${index + 1} von ${reviews.length}: ${reviews[index].title}`;
schedule();
}
function cardFor(review, number, total) {
const card = document.createElement("article");
card.className = "review-card";
card.setAttribute("aria-roledescription", "Folie");
card.setAttribute("aria-label", `${number + 1} von ${total}`);
const top = document.createElement("div");
top.className = "review-top";
const title = document.createElement("h3");
title.textContent = review.title;
const stars = document.createElement("div");
stars.className = "stars";
stars.setAttribute("aria-label", `${review.rating} von 5 Sternen`);
stars.textContent =
"★".repeat(review.rating) + "☆".repeat(5 - review.rating);
const text = document.createElement("blockquote");
text.textContent = review.review;
top.append(title, stars);
card.append(top, text);
return card;
}
async function refresh(showNewest = false) {
const request = ++latestRequest;
try {
const response = await fetch("/api/reviews", { cache: "no-store" });
if (!response.ok) throw new Error("Laden fehlgeschlagen");
const result = await response.json();
if (request !== latestRequest || drag) return;
const next = result.reviews;
if (
!Array.isArray(next) ||
next.some(
(review) =>
typeof review.title !== "string" ||
typeof review.review !== "string" ||
!Number.isInteger(review.rating) ||
review.rating < 1 ||
review.rating > 5,
)
)
throw new Error("Ungültige Bewertungen");
if (feedback.textContent.startsWith("Bewertungen können"))
feedback.textContent = "";
if (JSON.stringify(next) === JSON.stringify(reviews)) return;
const active = JSON.stringify(reviews[index]);
reviews = next;
if (!reviews.length) {
// Reloading also restores the empty placeholder after external deletion.
stage.replaceChildren(placeholder.cloneNode(true));
previousArrow.hidden = true;
nextArrow.hidden = true;
schedule();
return;
}
index = showNewest
? reviews.length - 1
: Math.max(
0,
reviews.findIndex(
(review) => JSON.stringify(review) === active,
),
);
stage.replaceChildren(
...reviews.map((review, number) =>
cardFor(review, number, reviews.length),
),
);
previousArrow.hidden = reviews.length < 2;
nextArrow.hidden = reviews.length < 2;
void stage.offsetWidth;
show(index);
feedback.textContent = "";
} catch {
if (request === latestRequest)
feedback.textContent =
"Bewertungen können gerade nicht aktualisiert werden. Wir versuchen es erneut.";
}
}
const placeholder = stage.firstElementChild.cloneNode(true);
stage.addEventListener("keydown", (event) => {
if (drag || event.altKey || event.ctrlKey || event.metaKey) return;
const targets = {
ArrowLeft: index - 1,
ArrowRight: index + 1,
Home: 0,
End: reviews.length - 1,
};
if (!(event.key in targets)) return;
event.preventDefault();
show(targets[event.key], true);
});
stage.addEventListener("pointerdown", (event) => {
if (!event.isPrimary || event.button !== 0 || reviews.length < 2 || drag)
return;
drag = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
dx: 0,
dy: 0,
};
clearTimeout(timer);
stage.setPointerCapture(event.pointerId);
stage.classList.add("is-dragging");
});
stage.addEventListener("pointermove", (event) => {
if (!drag || drag.id !== event.pointerId) return;
drag.dx = event.clientX - drag.x;
drag.dy = event.clientY - drag.y;
if (Math.abs(drag.dx) > Math.abs(drag.dy)) {
const offset = Math.max(
-stage.clientWidth / 2,
Math.min(stage.clientWidth / 2, drag.dx),
);
stage.children[index].style.transform = `translateX(${offset}px)`;
}
});
function finishDrag(event) {
if (!drag || drag.id !== event.pointerId) return;
const { dx, dy } = drag;
drag = null;
stage.classList.remove("is-dragging");
stage.children[index]?.style.removeProperty("transform");
if (stage.hasPointerCapture(event.pointerId))
stage.releasePointerCapture(event.pointerId);
if (
event.type === "pointerup" &&
Math.abs(dx) > 50 &&
Math.abs(dx) > Math.abs(dy)
) {
show(index + (dx < 0 ? 1 : -1), true);
} else {
schedule();
}
}
for (const event of ["pointerup", "pointercancel", "lostpointercapture"]) {
stage.addEventListener(event, finishDrag);
}
document.addEventListener("review:saved", () => refresh(true));
document.addEventListener("visibilitychange", () => {
schedule();
if (!document.hidden) refresh();
});
setInterval(() => {
if (!document.hidden) refresh();
}, interval);
refresh();

View File

@ -1,4 +1,5 @@
import "./styles/styles.css"; import "./styles/styles.css";
import "./components/navigationsbar/navigation.js"; import "./components/navigationsbar/navigation.js";
document.querySelector("#year").textContent = new Date().getFullYear(); document.querySelector("#year").textContent = new Date().getFullYear();

View File

@ -0,0 +1,10 @@
bookingId,createdAt,packageId,packageName,name,email,emailStatus,idempotencyKey,requestHash,bookedPackage,customer,acceptedTerms,consent
TH-000001,2026-09-25T11:43:43.074Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,kk,kk@web.de,accepted,497662ce-278c-466f-9505-c2728f8973ca,d5c69de514e6de5552e43f99256a042b59df5bd53db0bd1685f849809a455ef9,,,,true
TH-000002,2026-09-25T19:22:37.801Z,ernaehrung-starter,Für den sicheren Einstieg in die Sporternährung,Marvin,marvin@web.de,accepted,06b9ad79-05c3-4e37-9135-0c3cf1da3f36,7650a36bd436390b5f79dde1ea1e0d8944f1320b29bad927dfd71157c3f4133c,,,,true
TH-000003,2026-09-25T19:52:53.927Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,M,test@web.de,accepted,9446e525-a714-4912-bb10-1906a75cc9cd,a8fbd181361e4de7713fb0c4b5e0bf88cd43acd3e16294e7b84b33d959551458,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""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.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
TH-000004,2026-09-25T19:54:52.744Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,m,test@web.de,accepted,1817271c-343e-4e27-ac60-9d225897ba28,4a9f445acdf5cef94e95d8cdf50e78f95884913799cd2f532aaa37302910a16b,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""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.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",,,true
TH-000005,2026-09-25T20:14:51.874Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,Marvin,m@web.de,accepted,678654e5-7db9-49af-97b0-64fe02257e77,89c188f086a2d72fb77ae52612288610d2f17846fb1a3e62b11e8caa011cf2b4,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""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.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",,,true
TH-000006,2026-09-26T08:44:42.313Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,Marvin,m@web.de,accepted,a0a8c426-fc56-4887-a0e5-42172ff9cfb2,b83cf7b2ef4c42447bd83038c60d2ca565a8b37d24cae128f4fa2214dc43b185,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""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.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
TH-000007,2026-09-26T08:50:50.112Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,k@web.de,accepted,92e12eff-321d-49e5-a9ad-674a7d61f9a7,14b1f6b3909696f8402be66b1de33956b84ca435f58567e57e31e7152b6bff6c,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""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.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
TH-000008,2026-09-26T09:18:17.493Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,marvin@test.de,accepted,c74460a8-0315-4e0b-9d6b-48c854b0f71b,19ebc08e678515ea3a71f9b295ac1fbbf188bf54602a531f4f3bdc53698dcda7,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""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.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
TH-000009,2026-09-26T09:43:16.704Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,marvin,m@email.de,accepted,9c18134f-3a71-428b-8769-fda7d06790c7,eaa9a3f0dfc568c726f1b6fc26d92f8c4cc7df072f9fdd40fbea581661eeef9f,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-24"",""variantLabel"":""24-Wochen-Variante"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""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.""}],""netPrice"":671.43,""taxRate"":19,""taxAmount"":127.57,""grossPrice"":799,""currency"":""EUR""}",,,true
1 bookingId createdAt packageId packageName name email emailStatus idempotencyKey requestHash bookedPackage customer acceptedTerms consent
2 TH-000001 2026-09-25T11:43:43.074Z ernaehrung-standard Für Best Ager mit ersten Erfahrungen und klaren Zielen kk kk@web.de accepted 497662ce-278c-466f-9505-c2728f8973ca d5c69de514e6de5552e43f99256a042b59df5bd53db0bd1685f849809a455ef9 true
3 TH-000002 2026-09-25T19:22:37.801Z ernaehrung-starter Für den sicheren Einstieg in die Sporternährung Marvin marvin@web.de accepted 06b9ad79-05c3-4e37-9135-0c3cf1da3f36 7650a36bd436390b5f79dde1ea1e0d8944f1320b29bad927dfd71157c3f4133c true
4 TH-000003 2026-09-25T19:52:53.927Z ernaehrung-standard Für Best Ager mit ersten Erfahrungen und klaren Zielen M test@web.de accepted 9446e525-a714-4912-bb10-1906a75cc9cd a8fbd181361e4de7713fb0c4b5e0bf88cd43acd3e16294e7b84b33d959551458 {"packageId":"ernaehrung-standard","variantId":null,"variantLabel":null,"type":"STANDARD","title":"Für Best Ager mit ersten Erfahrungen und klaren Zielen","subtitle":"Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf.","summaryForBooking":"Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)","durationWeeks":24,"durationLabel":"24 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","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."}],"netPrice":377.31,"taxRate":19,"taxAmount":71.69,"grossPrice":449,"currency":"EUR"} true
5 TH-000004 2026-09-25T19:54:52.744Z ernaehrung-premium Für maximale Individualität mit klarem Fokus m test@web.de accepted 1817271c-343e-4e27-ac60-9d225897ba28 4a9f445acdf5cef94e95d8cdf50e78f95884913799cd2f532aaa37302910a16b {"packageId":"ernaehrung-premium","variantId":"ernaehrung-premium-52","variantLabel":"52-Wochen-Variante (Jahresbegleitung)","type":"PREMIUM","title":"Für maximale Individualität und intensive 1:1-Begleitung","subtitle":"Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten.","summaryForBooking":"Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)","durationWeeks":52,"durationLabel":"52 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","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."}],"netPrice":1175.63,"taxRate":19,"taxAmount":223.37,"grossPrice":1399,"currency":"EUR"} true
6 TH-000005 2026-09-25T20:14:51.874Z ernaehrung-premium Für maximale Individualität mit klarem Fokus Marvin m@web.de accepted 678654e5-7db9-49af-97b0-64fe02257e77 89c188f086a2d72fb77ae52612288610d2f17846fb1a3e62b11e8caa011cf2b4 {"packageId":"ernaehrung-premium","variantId":"ernaehrung-premium-52","variantLabel":"52-Wochen-Variante (Jahresbegleitung)","type":"PREMIUM","title":"Für maximale Individualität und intensive 1:1-Begleitung","subtitle":"Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten.","summaryForBooking":"Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)","durationWeeks":52,"durationLabel":"52 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","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."}],"netPrice":1175.63,"taxRate":19,"taxAmount":223.37,"grossPrice":1399,"currency":"EUR"} true
7 TH-000006 2026-09-26T08:44:42.313Z ernaehrung-standard Für Best Ager mit ersten Erfahrungen und klaren Zielen Marvin m@web.de accepted a0a8c426-fc56-4887-a0e5-42172ff9cfb2 b83cf7b2ef4c42447bd83038c60d2ca565a8b37d24cae128f4fa2214dc43b185 {"packageId":"ernaehrung-standard","variantId":null,"variantLabel":null,"type":"STANDARD","title":"Für Best Ager mit ersten Erfahrungen und klaren Zielen","subtitle":"Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf.","summaryForBooking":"Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)","durationWeeks":24,"durationLabel":"24 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","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."}],"netPrice":377.31,"taxRate":19,"taxAmount":71.69,"grossPrice":449,"currency":"EUR"} true
8 TH-000007 2026-09-26T08:50:50.112Z ernaehrung-standard Für Best Ager mit ersten Erfahrungen und klaren Zielen m k@web.de accepted 92e12eff-321d-49e5-a9ad-674a7d61f9a7 14b1f6b3909696f8402be66b1de33956b84ca435f58567e57e31e7152b6bff6c {"packageId":"ernaehrung-standard","variantId":null,"variantLabel":null,"type":"STANDARD","title":"Für Best Ager mit ersten Erfahrungen und klaren Zielen","subtitle":"Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf.","summaryForBooking":"Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)","durationWeeks":24,"durationLabel":"24 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","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."}],"netPrice":377.31,"taxRate":19,"taxAmount":71.69,"grossPrice":449,"currency":"EUR"} true
9 TH-000008 2026-09-26T09:18:17.493Z ernaehrung-standard Für Best Ager mit ersten Erfahrungen und klaren Zielen m marvin@test.de accepted c74460a8-0315-4e0b-9d6b-48c854b0f71b 19ebc08e678515ea3a71f9b295ac1fbbf188bf54602a531f4f3bdc53698dcda7 {"packageId":"ernaehrung-standard","variantId":null,"variantLabel":null,"type":"STANDARD","title":"Für Best Ager mit ersten Erfahrungen und klaren Zielen","subtitle":"Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf.","summaryForBooking":"Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)","durationWeeks":24,"durationLabel":"24 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","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."}],"netPrice":377.31,"taxRate":19,"taxAmount":71.69,"grossPrice":449,"currency":"EUR"} true
10 TH-000009 2026-09-26T09:43:16.704Z ernaehrung-premium Für maximale Individualität mit klarem Fokus marvin m@email.de accepted 9c18134f-3a71-428b-8769-fda7d06790c7 eaa9a3f0dfc568c726f1b6fc26d92f8c4cc7df072f9fdd40fbea581661eeef9f {"packageId":"ernaehrung-premium","variantId":"ernaehrung-premium-24","variantLabel":"24-Wochen-Variante","type":"PREMIUM","title":"Für maximale Individualität und intensive 1:1-Begleitung","subtitle":"Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten.","summaryForBooking":"Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)","durationWeeks":24,"durationLabel":"24 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","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."}],"netPrice":671.43,"taxRate":19,"taxAmount":127.57,"grossPrice":799,"currency":"EUR"} true

View File

@ -0,0 +1,13 @@
createdAt,name,email,subject,message,consent
2026-09-29T18:51:35.694Z,Max Muster,test10@web.de,Erstgespräch für eine Ernährungsberatung,"Guten Tag,
ich möchte einen termin vereinbaren, können sie sich bei mir melden.
Mfg
Max Muster",true
2026-09-29T19:27:34.639Z,Test Test,test11@web.de,Erstgespräch Ernährung,"Guten Tag,
können sie sich bei mir melden.
Mfg
Test",true
1 createdAt name email subject message consent
2 2026-09-29T18:51:35.694Z Max Muster test10@web.de Erstgespräch für eine Ernährungsberatung Guten Tag, ich möchte einen termin vereinbaren, können sie sich bei mir melden. Mfg Max Muster true
3 2026-09-29T19:27:34.639Z Test Test test11@web.de Erstgespräch Ernährung Guten Tag, können sie sich bei mir melden. Mfg Test true

View File

@ -0,0 +1,3 @@
createdAt,email,title,review,rating,consent
2026-09-30T12:06:34.079Z,marvin@web.de,Das Premium Paket hat mir sehr geholfen!,Ich hatte eine super Erfahrung mit der Lilli beim Premium Paket. Ich konnte schnell Termine buchen und Lilli konnte immer auf die Sonderwünsche der Wettkampfbegleitung eingehen. Ich hab sie einmal auf eine Allergie aufmerksam gemacht und sie hat meinen Plan umgehend angepasst. Gerne wieder!,5,true
2026-09-30T12:10:41.673Z,test@web.de,"Tolle Beratung, aber etwas zu teuer!",Ich bin insgesamt sehr zufrieden gewesen und konnte meine Ernährung erfolgreich umstellen. Für mich ist der Preis aber etwas zu hoch gewesen.,4,true
1 createdAt email title review rating consent
2 2026-09-30T12:06:34.079Z marvin@web.de Das Premium Paket hat mir sehr geholfen! Ich hatte eine super Erfahrung mit der Lilli beim Premium Paket. Ich konnte schnell Termine buchen und Lilli konnte immer auf die Sonderwünsche der Wettkampfbegleitung eingehen. Ich hab sie einmal auf eine Allergie aufmerksam gemacht und sie hat meinen Plan umgehend angepasst. Gerne wieder! 5 true
3 2026-09-30T12:10:41.673Z test@web.de Tolle Beratung, aber etwas zu teuer! Ich bin insgesamt sehr zufrieden gewesen und konnte meine Ernährung erfolgreich umstellen. Für mich ist der Preis aber etwas zu hoch gewesen. 4 true

168
src/server/index.js 100644
View File

@ -0,0 +1,168 @@
import { createReviewStorage } from "./services/review-storage.js";
import { handleReview } from "./routes/reviews.js";
import { createContactService } from "./services/contact-service.js";
import { createContactEmailService } from "./services/contact-email.js";
import { handleContact } from "./routes/contact.js";
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";
import { handleContactForm } from "./routes/contact-form.js";
import { createContactFormService } from "./services/contact-form-service.js";
import { createContactFormStorage } from "./services/contact-form-storage.js";
import { createContactFormEmailService } from "./services/contact-form-email.js";
export function createApp({
catalog = packages,
storage = createCsvStorage(
fileURLToPath(new URL("./data/bookings.csv", import.meta.url)),
),
reviewStorage = createReviewStorage(
fileURLToPath(new URL("./data/reviews.csv", import.meta.url)),
),
details,
sendConfirmation,
sendContact,
contactFormStorage = createContactFormStorage(
fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)),
),
sendContactForm,
advisorContactStorage = createContactFormStorage(
fileURLToPath(new URL("./data/advisor-contacts.csv", import.meta.url)),
[
"createdAt",
"email",
"advisorId",
"reason",
"simulationAccepted",
"consent",
],
),
} = {}) {
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(),
});
const contact = createContactService({
storage,
requestStorage: advisorContactStorage,
sendContact: sendContact ?? createContactEmailService(),
});
// Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand.
const contactForm = createContactFormService({
storage: contactFormStorage,
sendContactForm: sendContactForm ?? createContactFormEmailService(),
});
return createServer(async (request, response) => {
const path = new URL(request.url, "http://localhost").pathname;
if (path === "/api/reviews") {
return handleReview(request, response, storage, reviewStorage);
}
// Leitet die Ernährungsseite an ihren eigenen Kontaktformular-Endpunkt.
if (path === "/api/nutrition-contact") {
return handleContactForm(request, response, contactForm);
}
if (path === "/api/contact" || path === "/api/contact/validate") {
return handleContact(
request,
response,
contact,
path.endsWith("/validate"),
);
}
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

@ -0,0 +1,81 @@
import { BookingError } from "../services/booking-service.js";
export function json(response, status, body) {
response.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
});
response.end(JSON.stringify(body));
}
export 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

@ -0,0 +1,43 @@
import { BookingError } from "../services/booking-service.js";
import { json, readJson } from "./bookings.js";
// Nimmt HTTP-Anfragen des Ernährungs-Kontaktformulars entgegen und übersetzt Service-Ergebnisse in JSON.
export async function handleContactForm(request, response, service) {
// Das Formular darf Anfragen ausschließlich per POST absenden.
if (request.method !== "POST") {
response.setHeader("Allow", "POST");
return json(response, 405, {
error: {
code: "METHOD_NOT_ALLOWED",
message: "Bitte POST verwenden.",
},
});
}
try {
// Liest JSON, führt den Kontaktablauf aus und antwortet bei Erfolg mit HTTP 201.
const result = await service.submit(await readJson(request));
return json(response, 201, result);
} catch (error) {
// Erwartete Validierungs- und Dienstfehler behalten Status und Fehlercode.
if (error instanceof BookingError) {
return json(response, error.status, {
error: {
code: error.code,
message: error.message,
...(Object.keys(error.fields ?? {}).length
? { fields: error.fields }
: {}),
},
});
}
// Unerwartete Fehler werden protokolliert, ohne Formulardaten offenzulegen.
console.error("Kontaktformular konnte nicht verarbeitet werden.");
return json(response, 500, {
error: {
code: "INTERNAL_ERROR",
message:
"Die Nachricht konnte gerade nicht verarbeitet werden.",
},
});
}
}

View File

@ -0,0 +1,38 @@
import { BookingError } from "../services/booking-service.js";
import { json, readJson } from "./bookings.js";
export async function handleContact(request, response, service, validateOnly) {
if (request.method !== "POST") {
response.setHeader("Allow", "POST");
return json(response, 405, {
error: {
code: "METHOD_NOT_ALLOWED",
message: "Bitte POST verwenden.",
},
});
}
try {
const input = await readJson(request);
const result = await (validateOnly
? service.validate(input)
: service.submit(input));
return json(response, validateOnly ? 200 : 201, result);
} catch (error) {
return json(
response,
error instanceof BookingError ? error.status : 500,
{
error: {
code:
error instanceof BookingError
? error.code
: "INTERNAL_ERROR",
message:
error instanceof BookingError
? error.message
: "Die Kontaktanfrage konnte nicht verarbeitet werden.",
},
},
);
}
}

View File

@ -0,0 +1,114 @@
import { BookingError, emailPattern } from "../services/booking-service.js";
import { json, readJson } from "./bookings.js";
export async function handleReview(request, response, bookings, reviews) {
if (!["GET", "POST"].includes(request.method)) {
response.setHeader("Allow", "GET, POST");
return json(response, 405, {
error: { message: "Bitte GET oder POST verwenden." },
});
}
try {
if (request.method === "GET") {
const records = await reviews.readAll();
return json(response, 200, {
reviews: records
.filter((record) => record.consent === "true")
.map(({ createdAt, title, review, rating }) => ({
createdAt,
title,
review,
rating: Number(rating),
})),
});
}
const input = await readJson(request);
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new BookingError(
400,
"INVALID_INPUT",
"Bitte das Formular ausfüllen.",
);
}
const fields = {};
const email =
typeof input.email === "string"
? input.email.trim().toLowerCase()
: "";
if (email.length > 254 || !emailPattern.test(email)) {
fields.email = "Bitte eine gültige E-Mail-Adresse eingeben.";
}
const record = { email };
for (const [field, max, label] of [
["title", 50, "Titel"],
["review", 500, "Bewertungstext"],
]) {
const value =
typeof input[field] === "string" ? input[field].trim() : "";
if (value.length < 10 || value.length > max) {
fields[field] =
`${label}: Bitte 10 bis ${max} Zeichen eingeben.`;
}
record[field] = value;
}
if (
!Number.isInteger(input.rating) ||
input.rating < 1 ||
input.rating > 5
) {
fields.rating = "Bitte 1 bis 5 Sterne auswählen.";
}
if (input.consent !== true) {
fields.consent = "Bitte die Datenschutzbestimmungen akzeptieren.";
}
if (Object.keys(fields).length) {
throw new BookingError(
400,
"INVALID_INPUT",
Object.values(fields).join(" "),
fields,
);
}
const customers = await bookings.readAll();
if (
!customers.some(
(booking) => booking.email.trim().toLowerCase() === email,
)
) {
throw new BookingError(
403,
"BOOKING_REQUIRED",
"Unter dieser E-Mail-Adresse wurde keine Buchung gefunden. Bitte verwende die E-Mail-Adresse deiner Buchung.",
);
}
await reviews.append({
...record,
rating: input.rating,
consent: true,
createdAt: new Date().toISOString(),
});
return json(response, 201, { saved: true });
} catch (error) {
if (error instanceof BookingError) {
return json(response, error.status, {
error: {
code: error.code,
message: error.message,
fields: error.fields,
},
});
}
return json(response, 503, {
error: {
code:
request.method === "GET"
? "REVIEWS_UNAVAILABLE"
: "REVIEW_NOT_SAVED",
message:
request.method === "GET"
? "Die Bewertungen können gerade nicht geladen werden."
: "Die Bewertung konnte nicht gespeichert werden. Bitte versuche es später erneut.",
},
});
}
}

View File

@ -0,0 +1,370 @@
import {
isValidBookingPhone,
phoneError,
} from "../../shared/phone-validation.js";
import { createHash } from "node:crypto";
import { packagesDetails } from "../../shared/packagesdetails.js";
export class BookingError extends Error {
constructor(status, code, message, fields = {}) {
super(message);
this.status = status;
this.code = code;
this.fields = fields;
}
}
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// Übliche unquotierte E-Mail-Adressen; einzelne Domainlabels maximal 63 Zeichen.
export 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 = {};
if (input.consent !== true)
fields.consent = "Bitte die Datenschutzbestimmungen akzeptieren.";
const result = { consent: true };
for (const [field, max, message] of [
["packageId", 80, "Bitte ein gültiges Paket auswählen."],
["name", 120, "Bitte einen Namen mit höchstens 120 Zeichen eingeben."],
[
"email",
254,
"Bitte eine gültige E-Mail-Adresse eingeben (maximal 254 Zeichen).",
],
]) {
const value = input[field];
if (typeof value !== "string" || !value.trim() || value.length > max) {
fields[field] = message;
} else {
result[field] = value.trim();
}
}
if (
result.packageId &&
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(result.packageId)
) {
fields.packageId = "Die Paket-ID ist ungültig.";
}
if (result.name && /[\x00-\x1f\x7f]/.test(result.name)) {
fields.name =
"Bitte den Namen ohne Zeilenumbrüche oder Steuerzeichen eingeben.";
}
if (
result.email &&
(!emailPattern.test(result.email) ||
result.email.split("@")[0].length > 64)
) {
fields.email = "Bitte eine gültige E-Mail-Adresse eingeben.";
}
const phone = customer ? customer.phone : input.phone;
if (phone !== undefined) {
if (!isValidBookingPhone(phone)) {
fields.phone = phoneError;
} else if (!customer && phone.trim()) {
result.phone = phone.trim();
}
}
if (Object.keys(fields).length) {
throw new BookingError(
400,
"INVALID_INPUT",
"Bitte die markierten Angaben prüfen.",
fields,
);
}
if (input.variantId != null) {
if (
typeof input.variantId !== "string" ||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.variantId) ||
input.variantId.length > 80
) {
throw new BookingError(
400,
"INVALID_VARIANT",
"Bitte eine gültige Variante auswählen.",
);
}
result.variantId = input.variantId;
}
if (customer) {
result.customer = customer;
result.acceptedTerms = true;
}
return result;
}
// Der höchste gespeicherte Wert ist der persistente Zähler. Die bestehende
// Warteschlange verhindert, dass parallele Anfragen dieselbe Nummer erhalten.
function nextBookingId(records) {
let highest = 0n;
for (const record of records) {
const match = /^TH-([0-9]{6,})$/.exec(record.bookingId);
if (match) {
const number = BigInt(match[1]);
if (number > highest) highest = number;
}
}
return `TH-${String(highest + 1n).padStart(6, "0")}`;
}
function responseFor(record, replayed) {
// Nach Prozessabbruch während SMTP bleibt die tatsächliche Annahme unklar.
const emailStatus =
record.emailStatus === "sending" ? "unknown" : record.emailStatus;
return {
status: emailStatus === "accepted" ? (replayed ? 200 : 201) : 202,
body: {
bookingId: record.bookingId,
createdAt: record.createdAt,
packageId: record.packageId,
packageName: record.packageName,
bookedPackage: record.bookedPackage || null,
customerEmail: record.email,
status: "CONFIRMED",
saved: true,
emailStatus,
replayed,
},
};
}
export function createBookingService({
storage,
sendConfirmation,
packages,
details = packagesDetails,
}) {
function getPackage(id) {
const selected =
packages.some((entry) => entry.id === id) &&
details.find((entry) => entry.id === id);
if (!selected)
throw new BookingError(
404,
"UNKNOWN_PACKAGE",
"Dieses Paket ist nicht verfügbar. Bitte wähle ein Paket auf der Angebotsseite aus.",
);
return structuredClone(selected);
}
function preview(input) {
if (
!input ||
typeof input !== "object" ||
Array.isArray(input) ||
typeof input.packageId !== "string"
) {
throw new BookingError(
400,
"INVALID_INPUT",
"Bitte ein gültiges Paket auswählen.",
);
}
let selected;
try {
selected = getPackage(input.packageId);
} catch (error) {
error.status = 400;
throw error;
}
const variants = selected.variants || [];
const variantId = input.variantId ?? variants[0]?.variantId ?? null;
const variant = variants.find((entry) => entry.variantId === variantId);
if (
(variants.length && !variant) ||
(!variants.length &&
variantId !== null &&
variantId !== selected.id)
) {
throw new BookingError(
400,
"INVALID_VARIANT",
"Diese Variante gehört nicht zum ausgewählten Paket.",
);
}
const grossCents = Math.round((variant?.price ?? selected.price) * 100);
const netCents = Math.round(grossCents / (1 + selected.taxRate / 100));
return {
packageId: selected.id,
variantId: variant?.variantId ?? null,
variantLabel: variant?.label ?? null,
type: selected.type,
title: selected.title,
subtitle: selected.subtitle,
summaryForBooking: selected.summaryForBooking,
durationWeeks: variant?.durationWeeks ?? selected.durationWeeks,
durationLabel: variant
? `${variant.durationWeeks} Wochen Begleitung`
: selected.durationLabel,
quantity: 1,
billingInterval: selected.billingInterval,
includedFeatures: selected.includedFeatures,
processSteps: selected.processSteps,
netPrice: netCents / 100,
taxRate: selected.taxRate,
taxAmount: (grossCents - netCents) / 100,
grossPrice: grossCents / 100,
currency: selected.currency,
};
}
// Lesen, Speichern und Versand laufen innerhalb eines Prozesses nacheinander.
let queue = Promise.resolve();
async function processBooking(input, key) {
const data = validateInput(input, key);
const requestHash = createHash("sha256")
.update(JSON.stringify(data))
.digest("hex");
let records;
try {
records = await storage.readAll();
} catch {
throw new BookingError(
503,
"STORAGE_UNAVAILABLE",
"Der Buchungsstatus kann gerade nicht geprüft werden. Bitte mit derselben Anfrage erneut versuchen.",
);
}
let record = records.find((entry) => entry.idempotencyKey === key);
const replayed = Boolean(record);
if (record && record.requestHash !== requestHash) {
throw new BookingError(
409,
"IDEMPOTENCY_CONFLICT",
"Dieser Anfrageschlüssel gehört zu anderen Buchungsdaten. Bitte die ursprünglichen Angaben verwenden.",
);
}
if (record && record.emailStatus !== "pending")
return responseFor(record, true);
if (!record) {
const selected = packages.find(
(entry) => entry.id === data.packageId,
);
if (!selected) {
throw new BookingError(
400,
"UNKNOWN_PACKAGE",
"Das ausgewählte Paket ist nicht verfügbar.",
{ packageId: "Bitte ein verfügbares Paket auswählen." },
);
}
const bookedPackage = preview(data);
record = {
bookingId: nextBookingId(records),
createdAt: new Date().toISOString(),
packageId: selected.id,
packageName: selected.name,
bookedPackage,
customer: data.customer ?? null,
acceptedTerms: data.acceptedTerms ?? null,
consent: data.consent,
name: data.name,
email: data.email,
phone: data.customer?.phone ?? data.phone ?? "",
emailStatus: "pending",
idempotencyKey: key,
requestHash,
};
records.push(record);
try {
await storage.writeAll(records);
} catch {
throw new BookingError(
503,
"BOOKING_NOT_SAVED",
"Die Buchung konnte nicht gespeichert werden. Bitte erneut versuchen.",
);
}
}
// Versandabsicht zuerst persistieren. Nach einem Absturz niemals blind
// noch einmal senden: SMTP und CSV bilden keine gemeinsame Transaktion.
record.emailStatus = "sending";
try {
await storage.writeAll(records);
} catch {
record.emailStatus = "pending";
return responseFor(record, replayed);
}
try {
await sendConfirmation(record);
record.emailStatus = "accepted";
} catch (error) {
record.emailStatus = error.deliveryUnknown ? "unknown" : "failed";
}
try {
await storage.writeAll(records);
} catch {
record.emailStatus = "unknown";
}
return responseFor(record, replayed);
}
return {
getPackage,
preview,
book(input, key) {
const task = queue.then(() => processBooking(input, key));
queue = task.catch(() => {});
return task;
},
};
}

View File

@ -0,0 +1,53 @@
import { advisorDisplayName } from "../../shared/berater.js";
import { createMailTransport } from "./email-service.js";
import { BookingError } from "./booking-service.js";
import { contactNotice } from "../../shared/contact.js";
export function createContactEmailService(makeTransport) {
// Dieser Ablauf bleibt auch bei extern konfiguriertem Buchungs-SMTP lokal.
const { transport, from } = createMailTransport(
{
SMTP_HOST: "127.0.0.1",
SMTP_PORT: "1025",
SMTP_FROM: "Tri-Hub Ernährung <noreply@tri-hub.de>",
},
makeTransport,
);
return async ({ email, advisor, reason }) => {
const results = await Promise.allSettled(
[
{
to: email,
text: `Deine Kontaktanfrage an ${advisorDisplayName(advisor)} ist in der Simulation eingegangen. ${advisorDisplayName(advisor)} wird sich bei dir melden.`,
closing: "Viele Grüße\nTri-Hub Ernährung",
},
{
to: advisor.email,
text: `Hallo ${advisorDisplayName(advisor)}, bitte setze dich mit ${email} in Kontakt.`,
},
].map(async ({ to, text, closing }) => {
const result = await transport.sendMail({
from,
to,
subject:
"Eingangsbestätigung – Tri-Hub Kontakt (Simulation)",
text: `${text}\n\nKontaktgrund: ${reason}\n\n${contactNotice}${closing ? `\n\n${closing}` : ""}`,
});
if (
!result.accepted?.some(
(address) => address.toLowerCase() === to.toLowerCase(),
)
) {
throw new Error("SMTP hat den Empfänger nicht angenommen.");
}
}),
);
if (results.some((result) => result.status === "rejected")) {
throw new BookingError(
502,
"CONTACT_MAIL_FAILED",
"Nicht beide Bestätigungen konnten bestätigt werden. Bitte Mailpit prüfen, bevor du erneut sendest; eine Nachricht kann bereits eingegangen sein.",
);
}
};
}

View File

@ -0,0 +1,83 @@
import { createMailTransport } from "./email-service.js";
import { BookingError } from "./booking-service.js";
// Sichtbarer Empfänger in Mailpit; der Transport darunter bleibt lokal.
const contactAddress = "ernaehrung@tri-hub.de";
// Richtet den SMTP-Transport fest auf Mailpit ein und liefert den E-Mail-Versand zurück.
export function createContactFormEmailService(
env = process.env,
makeTransport,
) {
const { transport, from } = createMailTransport(
{
SMTP_HOST: "127.0.0.1",
SMTP_PORT: "1025",
SMTP_FROM: "Tri-Hub Ernährung <noreply@tri-hub.de>",
},
makeTransport,
);
return async function sendContactForm(data) {
// Erstellt die Eingangsbestätigung für den Absender und die Anfrage für Tri-Hub.
const messages = [
{
to: data.email,
subject: "Wir haben deine Nachricht erhalten – Tri-Hub",
text: [
`Hallo ${data.name},`,
"",
"vielen Dank für deine Nachricht. Wir haben deine Anfrage erhalten und melden uns schnellstmöglich bei dir.",
"",
`Betreff: ${data.subject}`,
"Deine Nachricht:",
data.message,
"",
"Viele Grüße",
"Tri-Hub Ernährung",
].join("\n"),
},
{
to: contactAddress,
subject: `Tri-Hub Kontaktanfrage: ${data.subject}`,
text: [
"Neue Kontaktanfrage über die Ernährungsseite",
"",
`Name: ${data.name}`,
`E-Mail: ${data.email}`,
`Betreff: ${data.subject}`,
"",
"Nachricht:",
data.message,
].join("\n"),
replyTo: data.email,
},
];
// Sendet beide Nachrichten und prüft, dass Mailpit beide Empfänger angenommen hat.
const results = await Promise.allSettled(
messages.map(async (message) => {
const result = await transport.sendMail({ from, ...message });
if (
!result.accepted?.some(
(address) =>
address.toLowerCase() === message.to.toLowerCase(),
)
) {
throw new Error("SMTP hat den Empfänger nicht angenommen.");
}
}),
);
// Ein Fehler bei mindestens einer Nachricht wird an den Service weitergegeben.
if (results.some((result) => result.status === "rejected")) {
throw new BookingError(
502,
"CONTACT_EMAIL_FAILED",
"Deine Nachricht wurde gespeichert, aber die E-Mails konnten nicht vollständig gesendet werden. Bitte versuche es später erneut oder melde dich direkt bei Tri-Hub.",
);
}
return { emailStatus: "accepted" };
};
}
export { contactAddress };

View File

@ -0,0 +1,92 @@
import { BookingError, emailPattern } from "./booking-service.js";
// Prüft und normalisiert die vom Browser übermittelten Kontaktangaben.
function normalizeInput(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new BookingError(
400,
"INVALID_INPUT",
"Bitte alle Pflichtfelder prüfen.",
);
}
// Legt die zulässige Höchstlänge für jedes Formularfeld fest.
const limits = { name: 120, email: 254, subject: 160, message: 5000 };
const fields = {};
const data = { consent: true };
if (input.consent !== true)
fields.consent = "Bitte die Datenschutzbestimmungen akzeptieren.";
for (const [field, max] of Object.entries(limits)) {
const value = input[field];
if (typeof value !== "string") {
fields[field] = "Dieses Feld ist erforderlich.";
continue;
}
const trimmed = value.trim();
if (!trimmed) fields[field] = "Dieses Feld ist erforderlich.";
else if (trimmed.length > max)
fields[field] = `Maximal ${max} Zeichen eingeben.`;
else if (
(field === "message"
? /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/
: /[\x00-\x1f\x7f]/
).test(trimmed)
)
fields[field] = "Bitte entferne ungültige Steuerzeichen.";
else data[field] = trimmed;
}
// Prüft zusätzlich das E-Mail-Format und die Mindestlänge der Nachricht.
if (
data.email &&
(!emailPattern.test(data.email) || data.email.split("@")[0].length > 64)
) {
fields.email = "Bitte eine gültige E-Mail-Adresse eingeben.";
}
if (data.message && [...data.message].length < 10) {
fields.message =
"Deine Nachricht muss mindestens 10 Zeichen enthalten.";
}
// Gibt alle gefundenen Feldfehler gesammelt an die Route zurück.
if (Object.keys(fields).length) {
throw new BookingError(
400,
"INVALID_INPUT",
"Bitte prüfe deine Eingaben.",
fields,
);
}
return data;
}
// Verbindet Validierung, CSV-Speicherung und anschließenden E-Mail-Versand.
export function createContactFormService({ storage, sendContactForm }) {
return {
async submit(input) {
const data = normalizeInput(input);
// Ergänzt einen Zeitstempel, damit die Anfrage zeitlich nachvollziehbar bleibt.
const record = { createdAt: new Date().toISOString(), ...data };
// Speichert vor dem Versand, damit die Anfrage bei SMTP-Problemen erhalten bleibt.
try {
await storage.append(record);
} catch {
throw new BookingError(
503,
"CONTACT_NOT_SAVED",
"Deine Nachricht konnte gerade nicht gespeichert werden. Bitte versuche es erneut.",
);
}
// Sendet die beiden Mails erst nach erfolgreichem Speichern der Anfrage.
try {
await sendContactForm(record);
} catch (error) {
if (error instanceof BookingError) throw error;
throw new BookingError(
502,
"CONTACT_EMAIL_FAILED",
"Deine Nachricht wurde gespeichert, aber die E-Mails konnten nicht vollständig gesendet werden. Bitte versuche es später erneut oder melde dich direkt bei Tri-Hub.",
);
}
return { saved: true, emailStatus: "accepted" };
},
};
}

View File

@ -0,0 +1,106 @@
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";
// Diese Spalten bilden den festen Aufbau der Kontaktanfragen-CSV.
const defaultColumns = [
"createdAt",
"name",
"email",
"subject",
"message",
"consent",
];
// Schützt CSV-Zellen vor Tabellenformeln, wenn die Datei geöffnet wird.
function protect(value) {
const text = String(value ?? "");
return /^(?:'|[\t\r\n]|\s*[=+@-])/u.test(text) ? `'${text}` : text;
}
// Entfernt beim Einlesen den Schutzpräfix und stellt den Eingabewert wieder her.
function restore(value) {
return value.startsWith("'") ? value.slice(1) : value;
}
// Erstellt den CSV-Speicher und hält Schreibvorgänge dieser Instanz in einer Warteschlange.
export function createContactFormStorage(filePath, columns = defaultColumns) {
let queue = Promise.resolve();
// Liest alle Anfragen ein und prüft, ob die Datei den erwarteten CSV-Kopf besitzt.
async function 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 Kontaktdatei");
return parse(content, {
bom: true,
columns(header) {
if (
header.join(",") !== columns.join(",") &&
header.join(",") !==
columns
.filter((column) => column !== "consent")
.join(",")
) {
throw new Error("Unbekanntes Kontakt-CSV-Format");
}
return header;
},
skip_empty_lines: true,
}).map((row) =>
Object.fromEntries(
Object.entries(row).map(([key, value]) => [
key,
key === "consent" ? value === "true" : restore(value),
]),
),
);
}
// Schreibt die vollständige CSV in eine temporäre Datei und ersetzt danach atomar das Original.
async function 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, protect(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();
}
await rename(temporary, filePath);
} finally {
await rm(temporary, { force: true });
}
}
return {
readAll,
// Hängt eine Anfrage nacheinander an, damit parallele Absendevorgänge keine Zeilen verlieren.
append(record) {
const operation = queue.then(async () => {
const records = await readAll();
records.push(record);
await writeAll(records);
});
queue = operation.catch(() => {});
return operation;
},
};
}

View File

@ -0,0 +1,131 @@
import { findAdvisor } from "../../shared/berater.js";
import { BookingError, emailPattern } from "./booking-service.js";
import { contactReasons, contactBookingUrl } from "../../shared/contact.js";
function advisorDetails(advisorId) {
const advisor = findAdvisor(advisorId);
if (!advisor) {
throw new BookingError(
400,
"INVALID_ADVISOR",
"Bitte eine gültige Berater-ID übergeben.",
);
}
if (
typeof advisor.vorname !== "string" ||
!advisor.vorname.trim() ||
typeof advisor.email !== "string" ||
advisor.email.length > 254 ||
!emailPattern.test(advisor.email) ||
advisor.email.split("@")[0].length > 64 ||
!advisor.email.toLowerCase().endsWith("@tri-hub.de")
) {
throw new BookingError(
503,
"ADVISOR_UNAVAILABLE",
"Die Kontaktdaten dieses Beraters sind nicht verfügbar.",
);
}
return { ...advisor };
}
export function createContactService({ storage, requestStorage, sendContact }) {
async function validate(input) {
if (
!input ||
typeof input !== "object" ||
Array.isArray(input) ||
typeof input.email !== "string" ||
input.email.length > 254
) {
throw new BookingError(
400,
"INVALID_EMAIL",
"Bitte eine gültige E-Mail-Adresse eingeben.",
);
}
const email = input.email.trim().toLowerCase();
if (!emailPattern.test(email) || email.split("@")[0].length > 64) {
throw new BookingError(
400,
"INVALID_EMAIL",
"Bitte eine gültige E-Mail-Adresse eingeben.",
);
}
let records;
try {
records = await storage.readAll();
} catch {
throw new BookingError(
503,
"STORAGE_UNAVAILABLE",
"Die Kundendaten können gerade nicht geprüft werden.",
);
}
if (!records.some((row) => row.email?.trim().toLowerCase() === email)) {
throw new BookingError(
403,
"CUSTOMER_NOT_FOUND",
"Zu dieser E-Mail-Adresse liegt keine Buchung vor. Bitte verwende die Adresse deiner Buchung.",
);
}
return { email, valid: true };
}
return {
validate,
async submit(input) {
const { email } = await validate(input);
const advisor = advisorDetails(input.advisorId);
if (
typeof input.reason !== "string" ||
!Object.hasOwn(contactReasons, input.reason)
) {
throw new BookingError(
400,
"INVALID_REASON",
"Bitte einen Kontaktgrund auswählen.",
);
}
if (input.simulationAccepted !== true) {
throw new BookingError(
400,
"SIMULATION_REQUIRED",
"Bitte den Simulationshinweis bestätigen.",
);
}
if (input.consent !== true) {
throw new BookingError(
400,
"CONSENT_REQUIRED",
"Bitte die Datenschutzbestimmungen akzeptieren.",
);
}
try {
await requestStorage.append({
createdAt: new Date().toISOString(),
email,
advisorId: advisor.id,
reason: input.reason,
simulationAccepted: true,
consent: true,
});
} catch {
throw new BookingError(
503,
"CONTACT_NOT_SAVED",
"Die Kontaktanfrage konnte nicht gespeichert werden.",
);
}
await sendContact({
email,
advisor,
reason: contactReasons[input.reason],
});
return {
simulated: true,
emailStatus: "accepted",
redirectUrl: contactBookingUrl,
};
},
};
}

View File

@ -0,0 +1,111 @@
import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
import { dirname } from "node:path";
import { randomUUID } from "node:crypto";
import { parse } from "csv-parse/sync";
import { stringify } from "csv-stringify/sync";
const legacyColumns = [
"bookingId",
"createdAt",
"packageId",
"packageName",
"name",
"email",
"emailStatus",
"idempotencyKey",
"requestHash",
];
const orderColumns = [...legacyColumns, "bookedPackage"];
const customerColumns = [...orderColumns, "customer", "acceptedTerms"];
const consentColumns = [...customerColumns, "consent"];
const phoneColumns = [...customerColumns, "phone"];
export const columns = [...phoneColumns, "consent"];
const jsonColumns = ["bookedPackage", "customer", "acceptedTerms", "consent"];
// Apostroph-Präfix schützt Tabellenprogramme. Ein vorhandenes Apostroph wird
// ebenfalls maskiert, damit Lesen/Schreiben die Originalwerte exakt erhält.
function protectCell(value) {
const text = String(value ?? "");
return /^(?:'|[\t\r\n]|\s*[=+@-])/u.test(text) ? `'${text}` : text;
}
function restoreCell(value) {
return value.startsWith("'") ? value.slice(1) : value;
}
export function createCsvStorage(filePath) {
return {
async readAll() {
let content;
try {
content = await readFile(filePath, "utf8");
} catch (error) {
if (error.code === "ENOENT") return [];
throw error;
}
if (!content.trim()) throw new Error("Leere Buchungsdatei");
return parse(content, {
bom: true,
columns(header) {
if (
header.join(",") !== columns.join(",") &&
header.join(",") !== customerColumns.join(",") &&
header.join(",") !== phoneColumns.join(",") &&
header.join(",") !== consentColumns.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

@ -0,0 +1,132 @@
import nodemailer from "nodemailer";
import { orderSummaryRows } from "../../shared/order-summary.js";
import { paymentNotice } from "../../shared/booking-copy.js";
export function createMailTransport(
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 { transport, from };
}
export function createEmailService(
env = process.env,
makeTransport = nodemailer.createTransport,
) {
const { transport, from } = createMailTransport(env, makeTransport);
return async function sendConfirmation(booking) {
const order = booking.bookedPackage;
const phone = booking.phone || booking.customer?.phone;
const lines = [
`Hallo ${booking.name},`,
"",
"vielen Dank für deine Bestellung bei Tri-Hub. Hier findest du die Zusammenfassung deiner Buchung.",
`Buchungsnummer: ${booking.bookingId}`,
...(booking.createdAt
? [
`Bestelldatum: ${new Intl.DateTimeFormat("de-DE", { dateStyle: "long", timeStyle: "short", timeZone: "Europe/Berlin" }).format(new Date(booking.createdAt))} (Europe/Berlin)`,
]
: []),
`Kunde: ${booking.name}`,
`E-Mail: ${booking.email}`,
...(phone ? [`Telefon: ${phone}`] : []),
...(booking.customer?.notes
? [`Deine Anmerkungen: ${booking.customer.notes}`]
: []),
"",
`Paket: ${order?.title ?? booking.packageName}`,
...(order
? [
order.summaryForBooking,
order.subtitle,
"",
...orderSummaryRows(order).map(
([label, value]) => `${label}: ${value}`,
),
"",
"Enthaltene Leistungen:",
...order.includedFeatures.map(
(feature) => `• ${feature}`,
),
"",
"So geht es weiter:",
...(order.processSteps || []).map(
({ step, text }) => `${step}: ${text}`,
),
]
: []),
"",
paymentNotice,
"",
"Bitte bewahre diese Bestätigung auf und gib bei Rückfragen deine Buchungsnummer an.",
"Dein Tri-Hub-Team",
];
const escapeHtml = (value) =>
String(value).replace(
/[&<>"']/g,
(char) =>
({
"&": "&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

@ -0,0 +1,93 @@
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";
// Diese Spalten bilden den festen Aufbau der Bewertungen-CSV.
const columns = ["createdAt", "email", "title", "review", "rating", "consent"];
// Schützt CSV-Zellen vor Tabellenformeln, wenn die Datei geöffnet wird.
function protect(value) {
const text = String(value ?? "");
return /^(?:'|[\t\r\n]|\s*[=+@-])/u.test(text) ? `'${text}` : text;
}
// Entfernt beim Einlesen den Schutzpräfix und stellt den Eingabewert wieder her.
function restore(value) {
return value.startsWith("'") ? value.slice(1) : value;
}
// Erstellt den CSV-Speicher und hält Schreibvorgänge dieser Instanz in einer Warteschlange.
export function createReviewStorage(filePath) {
let queue = Promise.resolve();
// Liest alle Bewertungen ein und prüft, ob die Datei den erwarteten CSV-Kopf besitzt.
async function 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 Bewertungsdatei");
return parse(content, {
bom: true,
columns(header) {
if (header.join(",") !== columns.join(",")) {
throw new Error("Unbekanntes Bewertungs-CSV-Format");
}
return header;
},
skip_empty_lines: true,
}).map((row) =>
Object.fromEntries(
Object.entries(row).map(([key, value]) => [
key,
restore(value),
]),
),
);
}
// Schreibt die vollständige CSV in eine temporäre Datei und ersetzt danach atomar das Original.
async function 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, protect(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();
}
await rename(temporary, filePath);
} finally {
await rm(temporary, { force: true });
}
}
return {
readAll,
// Hängt eine Bewertung nacheinander an, damit parallele Absendevorgänge keine Zeilen verlieren.
append(record) {
const operation = queue.then(async () => {
const records = await readAll();
records.push(record);
await writeAll(records);
});
queue = operation.catch(() => {});
return operation;
},
};
}

View File

@ -0,0 +1,992 @@
{
"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). Kontakt-Simulation mit CSV-Kundenvalidierung und Mailpit.",
"version": "1.2.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."
}
]
}
},
"/contact/validate": {
"post": {
"tags": ["Contact"],
"summary": "Kunden-E-Mail prüfen",
"description": "Vergleicht normalisierte E-Mail mit bookings.csv. Kein Nachweis des Postfachbesitzes.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["email"],
"properties": {
"email": {
"type": "string",
"format": "email",
"maxLength": 254
}
}
}
}
}
},
"responses": {
"200": {
"description": "Kunde in CSV gefunden",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["valid", "email"],
"properties": {
"valid": {
"type": "boolean",
"enum": [true]
},
"email": {
"type": "string",
"format": "email",
"maxLength": 254
}
}
}
}
}
},
"400": {
"description": "Ungültige Eingabe oder JSON",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContactError"
}
}
}
},
"403": {
"description": "Keine Buchung zur E-Mail",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContactError"
}
}
}
},
"405": {
"description": "Nur POST erlaubt",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContactError"
}
}
}
},
"413": {
"description": "Mehr als 8 KiB",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContactError"
}
}
}
},
"415": {
"description": "Content-Type muss application/json sein",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContactError"
}
}
}
},
"503": {
"description": "CSV nicht lesbar",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContactError"
}
}
}
},
"500": {
"description": "Unerwarteter Fehler",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContactError"
}
}
}
}
}
}
},
"/contact": {
"post": {
"tags": ["Contact"],
"summary": "Beraterkontakt simulieren",
"description": "Vergleicht normalisierte E-Mail mit bookings.csv. Kein Nachweis des Postfachbesitzes. Prüft CSV bei jedem Absenden erneut. Versand ausschließlich an lokales Mailpit (127.0.0.1:1025), zwei getrennte Bestätigungen. Kein automatischer Neuversand, keine persistente Kontaktablage.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"email",
"advisorId",
"reason",
"simulationAccepted",
"consent"
],
"properties": {
"email": {
"type": "string",
"format": "email",
"maxLength": 254
},
"reason": {
"type": "string",
"enum": [
"coaching",
"nutrition",
"competition",
"recovery",
"package",
"appointment",
"other"
]
},
"simulationAccepted": {
"type": "boolean",
"enum": [true]
},
"advisorId": {
"type": "string",
"example": "relindis-agethen",
"description": "ID aus src/shared/berater-daten.json. Name und E-Mail werden serverseitig ausschließlich dort nachgeschlagen."
},
"consent": {
"type": "boolean",
"enum": [true]
}
}
}
}
}
},
"responses": {
"201": {
"description": "Beide simulierten E-Mails von Mailpit angenommen; Weiterleitung möglich",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"simulated",
"emailStatus",
"redirectUrl"
],
"properties": {
"simulated": {
"type": "boolean",
"enum": [true]
},
"emailStatus": {
"type": "string",
"enum": ["accepted"]
},
"redirectUrl": {
"type": "string",
"format": "uri",
"description": "Feste Microsoft-Bookings-Adresse; keine benutzerdefinierten Redirects."
}
}
}
}
}
},
"400": {
"description": "Ungültige Eingabe oder JSON",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContactError"
}
}
}
},
"403": {
"description": "Keine Buchung zur E-Mail",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContactError"
}
}
}
},
"405": {
"description": "Nur POST erlaubt",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContactError"
}
}
}
},
"413": {
"description": "Mehr als 8 KiB",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContactError"
}
}
}
},
"415": {
"description": "Content-Type muss application/json sein",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContactError"
}
}
}
},
"503": {
"description": "CSV nicht lesbar oder Beraterkontaktdaten ungültig",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContactError"
}
}
}
},
"500": {
"description": "Unerwarteter Fehler",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContactError"
}
}
}
},
"502": {
"description": "Mindestens eine Mailannahme fehlgeschlagen oder unklar; vor Wiederholung Mailpit prüfen",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContactError"
}
}
}
}
}
}
}
},
"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", "consent"],
"properties": {
"packageId": {
"type": "string",
"example": "ernaehrung-standard"
},
"variantId": {
"type": "string",
"nullable": true,
"example": "ernaehrung-premium-52"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 120,
"example": "Thomas Müller"
},
"email": {
"type": "string",
"format": "email",
"maxLength": 254,
"example": "thomas.mueller@example.de"
},
"phone": {
"type": "string",
"maxLength": 50,
"description": "Optionale Telefonnummer: deutsche Nummer mit Vorwahl oder internationale Nummer mit Ländervorwahl. Prüfung des Nummernformats, keine Bestätigung der Erreichbarkeit. Bei customer gilt customer.phone.",
"example": "+49 170 1234567"
},
"customer": {
"type": "object",
"required": ["firstName", "lastName", "email"],
"properties": {
"firstName": {
"type": "string",
"example": "Thomas"
},
"lastName": {
"type": "string",
"example": "Müller"
},
"email": {
"type": "string",
"format": "email",
"example": "thomas.mueller@example.de"
},
"phone": {
"type": "string",
"maxLength": 50,
"description": "Optional. Deutsche Nummer mit Vorwahl oder internationale Nummer mit Ländervorwahl; keine Bestätigung der Erreichbarkeit.",
"example": "+49 170 1234567"
},
"ageGroup": {
"type": "string",
"example": "50-59"
},
"notes": {
"type": "string",
"example": "Vorbereitung auf meine erste Mitteldistanz im August."
}
}
},
"acceptedTerms": {
"type": "boolean",
"example": true,
"enum": [true]
},
"consent": {
"type": "boolean",
"enum": [true],
"description": "Zustimmung zu den Datenschutzbestimmungen ist erforderlich."
}
},
"anyOf": [
{
"required": ["customer", "acceptedTerms"]
},
{
"required": ["name", "email"]
}
],
"description": "Kundendaten gemäß customer mit acceptedTerms=true. Bestehende Clients können alternativ name und email sowie optional phone übergeben."
},
"BookingConfirmation": {
"type": "object",
"properties": {
"bookingId": {
"type": "string",
"example": "TH-000001"
},
"status": {
"type": "string",
"example": "CONFIRMED"
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"bookedPackage": {
"allOf": [
{
"$ref": "#/components/schemas/BookingPreviewResponse"
}
],
"nullable": true,
"description": "Gespeicherter Bestellstand; bei älteren Buchungen ohne diese Daten null."
},
"customerEmail": {
"type": "string",
"example": "thomas.mueller@example.de"
},
"packageId": {
"type": "string"
},
"packageName": {
"type": "string"
},
"saved": {
"type": "boolean"
},
"emailStatus": {
"type": "string",
"enum": ["pending", "accepted", "failed", "unknown"],
"description": "accepted bedeutet SMTP-Annahme, keine bestätigte Zustellung."
},
"replayed": {
"type": "boolean"
}
}
},
"ErrorResponse": {
"type": "object",
"required": ["error"],
"properties": {
"error": {
"type": "object",
"required": ["code", "message"],
"properties": {
"code": {
"type": "string",
"example": "INVALID_VARIANT"
},
"message": {
"type": "string"
},
"fields": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"ContactError": {
"type": "object",
"required": ["error"],
"properties": {
"error": {
"type": "object",
"required": ["code", "message"],
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,64 @@
[
{
"id": "relindis-agethen",
"name": "Relindis Agethen (Lilli)",
"email": "relindis.agethen@tri-hub.de",
"titel": "Triathletin und Ernährungsberaterin",
"biographie": "Relindis Agethen, genannt Lilli, ist seit 16 Jahren im Triathlon aktiv und bringt starke sportliche Erfolge mit: Vize-Europameisterin, Deutsche Meisterin und mehrfache Baden-Württembergische Meisterin. Als zertifizierte Ernährungsberaterin für Sportler B-Lizenz ergänzt sie das Team um Leistungsentwicklung und Ernährungswissen.",
"qualifikationen": [
"Zertifizierte Ernährungsberaterin für Sportler (B-Lizenz)",
"Vize-Europameisterin",
"Deutsche Meisterin",
"Mehrfache Baden-Württembergische Meisterin"
],
"schwerpunkte": [
"Ernährung",
"Leistungssport-Perspektive",
"Wettkampforientierte Entwicklung"
],
"bildquelle": "",
"vorname": "Relindis",
"nachname": "Agethen",
"spitzname": "Lilli",
"bilder": [
{
"quelle": "/images/Lilli1.jpeg",
"typ": "Porträt"
},
{
"quelle": "/images/Lilli2.jpeg",
"typ": "Aktionsbild"
}
]
},
{
"id": "maren-hoffmann",
"name": "Maren Hoffmann",
"email": "maren.hoffmann@tri-hub.de",
"titel": "Triathletin und Ernährungsberaterin",
"biographie": "Maren Hoffmann verbindet ihre langjährige Erfahrung im Ausdauersport mit ihrem Wissen aus der Ernährungsberatung. Sie unterstützt Athletinnen und Athleten dabei, Training und Ernährung alltagstauglich aufeinander abzustimmen und sich gezielt auf Wettkämpfe vorzubereiten.",
"qualifikationen": [
"Ernährungsberaterin für Sporternährung",
"Langjährige Erfahrung im Ausdauersport"
],
"schwerpunkte": [
"Ernährung im Ausdauertraining",
"Wettkampfverpflegung",
"Alltagstaugliche Ernährungsroutinen"
],
"bildquelle": "",
"vorname": "Maren",
"nachname": "Hoffmann",
"spitzname": "",
"bilder": [
{
"quelle": "/images/Maren1.jpeg",
"typ": "Porträt"
},
{
"quelle": "/images/Maren2.jpeg",
"typ": "Aktionsbild"
}
]
}
]

View File

@ -0,0 +1,11 @@
import berater from "./berater-daten.json" with { type: "json" };
export function findAdvisor(advisorId) {
return typeof advisorId === "string"
? berater.find((entry) => entry.id === advisorId)
: undefined;
}
export function advisorDisplayName(advisor) {
return advisor?.spitzname?.trim() || advisor?.vorname?.trim() || "";
}

View File

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

View File

@ -0,0 +1,15 @@
export const contactReasons = {
coaching: "Persönliches Coaching und Erstgespräch",
nutrition: "Ernährungsplan und Anpassungen",
competition: "Wettkampfverpflegung und Hydration",
recovery: "Regeneration und Verträglichkeit",
package: "Fragen zum gebuchten Paket",
appointment: "Termin vereinbaren oder ändern",
other: "Sonstiges Anliegen",
};
export const contactNotice =
"Dies ist eine Simulation des Kontaktprozesses. Die Bestätigungen werden nur im lokalen Mailpit gesammelt. Es wird kein Berater tatsächlich kontaktiert. Die Weiterleitung öffnet die externe Microsoft-Bookings-Seite; dort wird hierdurch kein Termin gebucht.";
export const contactBookingUrl =
"https://bookings.cloud.microsoft/bookwithme/user/b055625c7e5b4bf3866230ffa066536b%40Tri-hub.de/meetingtype/qkJP99nqg0a_Na5Z-gY9WQ2?anonymous&ismsaljsauthenabled";

View File

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

View File

@ -0,0 +1,44 @@
export const packages = [
{
id: "ernaehrung-starter",
type: "STARTER",
name: "Für den sicheren Einstieg in die Sporternährung",
summary:
"Für Menschen 50+, die mit Triathlon beginnen und ihre Ernährung an die neue Belastung anpassen möchten.",
fokus: "Grundlagen der Makronährstoffe, Hydration im Training und Gewichtsmanagement.",
anamnese:
"Zu Beginn gehört eine ausführliche gemeinsame Anamnese dazu, damit deine Essgewohnheiten und Ziele sauber eingeordnet werden.",
begleitung:
"Ein individueller Basis-Ernährungsplan und regelmäßige Check-ins im ersten Monat.",
buttonText: "Starter-Paket ansehen",
highlight: null,
},
{
id: "ernaehrung-standard",
type: "STANDARD",
name: "Für Best Ager mit ersten Erfahrungen und klaren Zielen",
summary:
"Für Athletinnen und Athleten, die ihre Ernährung für die Kurzdistanz und im Alltag gezielt weiterentwickeln wollen.",
fokus: "Carb-Loading, Pacing-Strategien für den Magen und Timing der Nahrungsaufnahme.",
anamnese:
"Auch hier startet das Coaching mit einer ausführlichen gemeinsamen Anamnese, damit Trainingsrealität, Erfahrung und Verträglichkeit präzise erfasst werden.",
begleitung:
"Monatliche Anpassung des Plans und enger Austausch bei Magen-Darm-Herausforderungen.",
buttonText: "Standard-Paket ansehen",
highlight: "BESONDERS PASSEND",
},
{
id: "ernaehrung-premium",
type: "PREMIUM",
name: "Für maximale Individualität mit klarem Fokus",
summary:
"Für Menschen, die sich eine besonders enge Coaching-Beziehung und laufende Anpassung ihrer Ernährung wünschen.",
fokus: "Individuelle Stoffwechselanalyse, Mikronährstoff-Optimierung und Raceday-Nutrition.",
anamnese:
"Wie in allen Paketen ist eine ausführliche gemeinsame Anamnese enthalten, damit Betreuung, Regeneration und Zielbild von Beginn an abgestimmt werden.",
begleitung:
"Wöchentliche Anpassungen, direkte Feedback-Schleifen und passgenaue Einkaufslisten.",
buttonText: "Premium-Paket ansehen",
highlight: null,
},
];

View File

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

View File

@ -0,0 +1,14 @@
import { isValidPhoneNumber } from "libphonenumber-js/max";
export const phoneError =
"Bitte eine gültige Telefonnummer mit Vorwahl eingeben, z. B. 0170 1234567 oder +49 170 1234567.";
export function isValidBookingPhone(value) {
if (typeof value !== "string" || value.length > 50) return false;
if (/[\x00-\x1f\x7f]/.test(value)) return false;
const phone = value.trim();
if (!phone) return true;
if (!/^\+?[0-9 ()/.-]+$/.test(phone)) return false;
if (!/^(?:\+|0)/.test(phone)) return false;
return isValidPhoneNumber(phone, "DE");
}

View File

@ -0,0 +1,432 @@
.trihub-footer,
.trihub-footer * {
box-sizing: border-box;
}
.trihub-footer {
padding: 4.75rem 0;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0 0 1.8rem 1.8rem;
background: #06171b;
color: #fff;
font-family: "DM Sans", sans-serif;
}
.trihub-footer-inner {
width: min(100% - 4rem, 1648px);
margin: 0 auto;
display: grid;
grid-template-columns: 1.35fr 1fr 1fr;
gap: 3.5rem;
align-items: stretch;
}
.trihub-footer-card {
min-width: 0;
min-height: 34.75rem;
padding: 2rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1.8rem;
background: rgba(255, 255, 255, 0.05);
}
.trihub-brand {
display: flex;
align-items: center;
gap: 1.4rem;
color: inherit;
text-decoration: none;
}
.trihub-logo-box {
width: 100px;
height: 90px;
padding: 0.5rem 0.75rem;
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 1.1rem;
background: #fff;
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.18);
}
.trihub-logo-box img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.trihub-brand-label,
.trihub-brand-title {
display: block;
}
.trihub-brand-label {
margin: 0 0 0.3rem;
color: rgba(255, 255, 255, 0.46);
font-size: 0.68rem;
letter-spacing: 0.28em;
text-transform: uppercase;
}
.trihub-brand-title {
color: #fff;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
}
.trihub-description {
max-width: 36rem;
margin: 2.3rem 0 0;
color: rgba(255, 255, 255, 0.64);
font-size: 0.875rem;
line-height: 1.75rem;
}
.trihub-contact {
margin-top: 2.1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
color: rgba(255, 255, 255, 0.68);
font-size: 0.875rem;
}
.trihub-contact-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.trihub-contact-row p {
margin: 0;
}
.trihub-contact-row a {
color: inherit;
text-decoration: none;
}
.trihub-contact-row svg {
width: 1rem;
height: 1rem;
margin-top: 0.25rem;
flex-shrink: 0;
color: #ffc21f;
}
.trihub-socials {
margin-top: 2.1rem;
display: flex;
gap: 0.75rem;
}
.trihub-social-button {
width: 3.25rem;
height: 3.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 9999px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.7);
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.trihub-social-button svg {
width: 1.25rem;
height: 1.25rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.trihub-social-button:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.trihub-footer-heading {
margin: 0 0 1.7rem;
display: flex;
font-family: "DM Sans", sans-serif;
align-items: center;
gap: 0.75rem;
color: rgba(255, 255, 255, 0.46);
font-size: 0.8rem;
font-weight: 400;
letter-spacing: 0.28em;
text-transform: uppercase;
}
.trihub-footer-heading svg {
width: 1rem;
height: 1rem;
color: #00c7d5;
}
.trihub-footer-links {
display: flex;
flex-direction: column;
gap: 1rem;
color: rgba(255, 255, 255, 0.7);
font-size: 0.875rem;
}
.trihub-footer-links a {
color: inherit;
text-decoration: none;
transition: color 0.15s ease;
}
.trihub-footer-links a:hover {
color: #fff;
}
.trihub-footer-links .trihub-cookie-link {
margin: 0.7rem 0 0;
color: #00bfd2;
text-decoration: none;
}
.trihub-footer-links .trihub-cookie-link:hover {
color: #fff;
}
@media (max-width: 1000px) {
.trihub-footer-inner {
grid-template-columns: 1fr;
}
.trihub-footer-card {
min-height: auto;
}
}
@media (max-width: 640px) {
.trihub-footer {
padding: 2rem 0;
}
.trihub-footer-inner {
width: min(100% - 2rem, 1648px);
gap: 1rem;
}
.trihub-footer-card {
padding: 1.5rem;
}
.trihub-brand {
align-items: flex-start;
}
}
.cookie-settings-dialog,
.cookie-settings-dialog * {
box-sizing: border-box;
}
.cookie-settings-dialog {
width: min(58rem, calc(100% - 2rem));
max-width: none;
max-height: calc(100% - 2rem);
margin: auto;
padding: clamp(1.25rem, 3vw, 2.1rem);
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 1.4rem;
background: #071217;
color: #f8fafc;
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.55);
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.cookie-settings-dialog::backdrop {
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.cookie-settings-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
margin-bottom: 1.75rem;
}
.cookie-settings-eyebrow {
margin: 0 0 0.45rem;
color: rgba(255, 255, 255, 0.52);
font-size: 0.72rem;
letter-spacing: 0.22em;
}
.cookie-settings-heading h2 {
margin: 0;
font-family: inherit;
color: #fff;
font-size: clamp(1.6rem, 4vw, 2.15rem);
line-height: 1.15;
}
.cookie-settings-close {
flex: 0 0 auto;
padding: 0;
border: 0;
background: transparent;
color: #fff;
font: inherit;
font-size: 2.1rem;
line-height: 1;
cursor: pointer;
}
.cookie-settings-options {
display: grid;
gap: 0.9rem;
}
.cookie-option {
position: relative;
display: flex;
min-height: 5.25rem;
align-items: center;
justify-content: space-between;
gap: 3.5rem;
padding: 1.1rem 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: 1.2rem;
background: rgba(255, 255, 255, 0.065);
cursor: pointer;
}
.cookie-option-copy {
display: grid;
gap: 0.35rem;
}
.cookie-option-copy strong {
color: #fff;
font-size: 1.05rem;
font-weight: 600;
}
.cookie-option-copy > span {
color: rgba(255, 255, 255, 0.66);
font-size: 0.94rem;
line-height: 1.55;
}
.cookie-option input {
position: absolute;
top: 1.15rem;
right: 1.25rem;
z-index: 1;
width: 3.25rem;
height: 1.75rem;
margin: 0;
opacity: 0;
cursor: pointer;
}
.cookie-toggle {
position: absolute;
top: 1.1rem;
right: 1.15rem;
width: 3.25rem;
height: 1.75rem;
border-radius: 999px;
background: #465057;
transition: background-color 0.18s ease;
}
.cookie-toggle::before {
content: "";
position: absolute;
top: 0.2rem;
left: 0.2rem;
width: 1.35rem;
height: 1.35rem;
border-radius: 50%;
background: #fff;
transition: transform 0.18s ease;
}
.cookie-option input:checked + .cookie-toggle {
background: #00bfd2;
}
.cookie-option input:checked + .cookie-toggle::before {
transform: translateX(1.5rem);
}
.cookie-option input:focus-visible + .cookie-toggle,
.cookie-settings-dialog button:focus-visible {
outline: 3px solid #4edbdc;
outline-offset: 3px;
}
.cookie-option input:disabled {
cursor: not-allowed;
}
.cookie-settings-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
.cookie-settings-actions button {
min-height: 3rem;
padding: 0.7rem 1.2rem;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 999px;
background: transparent;
color: #f8fafc;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.cookie-settings-actions .cookie-settings-save {
border-color: #fff;
background: #fff;
color: #102027;
}
@media (max-width: 600px) {
.cookie-settings-dialog {
width: calc(100% - 1.25rem);
padding: 1.2rem;
border-radius: 1.1rem;
}
.cookie-option {
align-items: flex-start;
padding: 1rem 4rem 1rem 1rem;
}
.cookie-option input,
.cookie-toggle {
top: 1rem;
right: 0.85rem;
}
.cookie-settings-actions {
flex-direction: column-reverse;
}
.cookie-settings-actions button {
width: 100%;
}
}

View File

@ -5,7 +5,7 @@
:root { :root {
--nav-bg: #0d0d1a; --nav-bg: #0d0d1a;
--nav-border: rgba(248, 250, 252, 0.08); --nav-border: rgba(248, 250, 252, 0.08);
--nav-green: #10b981; --nav-accent: #53d5cd;
--nav-white: #ffffff; --nav-white: #ffffff;
--nav-light: #f8fafc; --nav-light: #f8fafc;
--nav-muted: #94a3b8; --nav-muted: #94a3b8;
@ -33,7 +33,7 @@
.navbar__inner { .navbar__inner {
width: 100%; width: 100%;
min-height: 76px; min-height: 96px;
padding: 0 20px; padding: 0 20px;
display: flex; display: flex;
align-items: center; align-items: center;
@ -41,18 +41,24 @@
gap: 16px; gap: 16px;
} }
/* ---------- Logo + Markenname (kein Link mehr, nur Anzeige) ---------- */ /* ---------- Logo und Markenname als Sprunglink zum Hauptinhalt ---------- */
.navbar__brand { .navbar__brand {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
flex-shrink: 0; flex-shrink: 0;
color: inherit;
text-decoration: none;
} }
.navbar__logo-img { .navbar__logo-img {
height: 40px; width: 72px;
width: auto; height: 72px;
padding: 5px;
display: block; display: block;
object-fit: contain;
background: #fff;
border-radius: 16px;
} }
.navbar__brand-text { .navbar__brand-text {
@ -117,7 +123,7 @@
} }
.navbar__link--home:hover { .navbar__link--home:hover {
border-color: rgba(16, 185, 129, 0.55); border-color: rgba(83, 213, 205, 0.55);
color: var(--nav-white); color: var(--nav-white);
} }
@ -125,7 +131,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #22d3ee; color: var(--nav-accent);
} }
.navbar__home-icon svg { .navbar__home-icon svg {
@ -138,26 +144,9 @@
stroke-width: 1.5; stroke-width: 1.5;
} }
/* ---------- Kriterium 2: barrierefreie Tastatursteuerung ---------- */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--nav-green);
color: #06130f;
font-weight: 700;
padding: 0.75rem 1rem;
z-index: 1100;
transition: top 0.2s ease;
}
.skip-link:focus {
top: 0;
}
.navbar a:focus-visible, .navbar a:focus-visible,
.navbar button:focus-visible { .navbar button:focus-visible {
outline: 3px solid var(--nav-green); outline: 3px solid var(--nav-accent);
outline-offset: 2px; outline-offset: 2px;
border-radius: 4px; border-radius: 4px;
} }
@ -199,7 +188,7 @@
.navbar__nav { .navbar__nav {
position: absolute; position: absolute;
top: 76px; top: 96px;
left: 0; left: 0;
right: 0; right: 0;
background: var(--nav-bg); background: var(--nav-bg);
@ -232,5 +221,5 @@
/* Platz für die fixierte Navbar */ /* Platz für die fixierte Navbar */
body { body {
padding-top: 76px; padding-top: 96px;
} }

View File

@ -0,0 +1,288 @@
.packages-page {
margin: 0;
min-height: 100vh;
color: #f8fafc;
background:
radial-gradient(circle at 12% 0%, #53d5cd17, transparent 30%),
radial-gradient(circle at 88% 26%, #53d5cd0e, transparent 27%),
linear-gradient(145deg, #0d0d1a, #1a1a2e);
font-family: Inter, system-ui, sans-serif;
line-height: 1.6;
color-scheme: dark;
}
.packages-section,
.packages-section * {
box-sizing: border-box;
}
.packages-section {
width: min(1180px, calc(100% - 3rem));
margin: auto;
padding-block: 5rem;
overflow-wrap: anywhere;
}
.packages-header {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: 3rem;
align-items: end;
margin-bottom: 3rem;
}
.packages-eyebrow,
.package-type {
color: #53d5cd;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.packages-header h1,
.detail-heading h1 {
margin: 0.75rem 0 1rem;
font-size: clamp(2rem, 4.5vw, 3.75rem);
font-weight: 600;
line-height: 1.15;
letter-spacing: -0.035em;
}
.packages-intro,
.package-desc,
.detail-text,
.detail-list,
.price-note,
.booking-notice {
color: #94a3b8;
}
.packages-intro {
margin: 0;
max-width: 40rem;
}
.packages-container {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1.5rem;
}
.package-card,
.package-checkout {
min-width: 0;
padding: 1.75rem;
border: 1px solid #f8fafc1f;
border-radius: 1.4rem;
background: #ffffff0b;
}
.package-card {
display: flex;
flex-direction: column;
}
.package-image {
display: block;
width: 100%;
aspect-ratio: 1.42 / 1;
margin: 0 0 1.5rem;
border: 1px solid #f8fafc1f;
border-radius: 1.1rem;
object-fit: cover;
}
.package-type-container {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.package-highlight {
border-radius: 999px;
padding: 0.25rem 0.65rem;
background: #53d5cd14;
color: #75e2dc;
border: 1px solid #53d5cd33;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.05em;
}
.package-title {
margin: 0 0 1rem;
color: #fff;
font-size: 1.55rem;
font-weight: 600;
line-height: 1.25;
letter-spacing: -0.025em;
}
.package-desc {
margin: 0 0 1.5rem;
}
.package-details-box {
padding-block: 1.5rem;
border-top: 1px solid #f8fafc1f;
flex: 1;
}
.detail-item + .detail-item {
margin-top: 1.25rem;
}
.detail-title {
margin: 0 0 0.5rem;
color: #f8fafc;
font-size: 1rem;
font-weight: 600;
}
.detail-text {
font-size: 0.9rem;
}
.package-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
min-height: 3rem;
padding: 0.8rem 1.1rem;
border: 1px solid #94a3b840;
border-radius: 999px;
background: #ffffff06;
color: #f8fafc;
font: inherit;
font-size: 0.9rem;
font-weight: 600;
text-align: center;
text-decoration: none;
cursor: pointer;
}
.package-btn:hover {
border-color: #53d5cd;
background: #53d5cd14;
}
.package-btn.primary-cta {
background: #53d5cd;
border-color: #53d5cd;
color: #06130f;
}
.package-btn.primary-cta:hover {
background: #75e2dc;
}
.package-btn:disabled {
opacity: 0.65;
cursor: wait;
}
.packages-section :focus-visible {
outline: 3px solid #53d5cd;
outline-offset: 4px;
}
.packages-back {
display: inline-block;
margin-bottom: 2rem;
padding-block: 0.5rem;
color: #75e2dc;
text-decoration: none;
}
.detail-heading {
max-width: 52rem;
margin-bottom: 3rem;
}
.detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
gap: 2.5rem;
align-items: start;
}
.detail-content > section + section {
margin-top: 2.5rem;
}
.detail-content h2,
.package-checkout h2 {
margin: 0 0 1rem;
font-size: 1.25rem;
}
.detail-list {
padding-left: 1.25rem;
}
.detail-list li {
margin-block: 0.65rem;
}
.detail-list li::marker {
color: #53d5cd;
}
.process-step {
margin-top: 1rem;
padding: 1.25rem;
border: 1px solid #f8fafc1f;
border-radius: 1rem;
background: #ffffff06;
}
.process-step strong {
display: block;
margin-bottom: 0.4rem;
}
.process-step p {
margin: 0;
}
.price-box {
margin-bottom: 1.5rem;
}
.price-amount {
display: block;
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 600;
letter-spacing: -0.04em;
color: #75e2dc;
}
.price-note,
.booking-notice {
font-size: 0.85rem;
}
.detail-box {
margin-bottom: 1.5rem;
}
.detail-box label {
display: block;
margin-bottom: 0.5rem;
}
.variant-select {
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 3rem;
padding: 0.65rem;
border: 1px solid #94a3b866;
border-radius: 0.75rem;
color: #f8fafc;
background: #0d0d1a;
font: inherit;
}
/* Die Detailseite zeigt ihre eigene Übersicht; Booking liefert den Dialog. */
.packages-section #booking-root > .booking > h2,
.packages-section #booking-root .booking__package,
.packages-section #booking-root .booking__payment,
.packages-section #booking-root .booking__open {
display: none;
}
.packages-section .booking__dialog {
background: #0d0d1a;
color: #f8fafc;
border-color: #94a3b840;
}
.packages-section .booking__dialog .booking__button:not(.booking__cancel) {
background: #53d5cd;
border-color: #53d5cd;
color: #06130f;
}
.packages-section .booking__dialog :focus-visible {
outline-color: #53d5cd;
}
@media (max-width: 60rem) {
.packages-container {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
}
.packages-header,
.detail-grid {
grid-template-columns: minmax(0, 1fr);
gap: 1.5rem;
}
}
@media (max-width: 30rem) {
.packages-section {
width: calc(100% - 2rem);
padding-block: 2.5rem;
}
.package-card,
.package-checkout {
padding: 1.25rem;
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,83 @@
import test from "node:test";
import assert from "node:assert/strict";
import { access, readFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import berater from "../shared/berater-daten.json" with { type: "json" };
import { advisorDisplayName, findAdvisor } from "../shared/berater.js";
const projectRoot = fileURLToPath(new URL("../../", import.meta.url));
test("Beraterdaten haben eindeutige IDs und vollständige Profilfelder", () => {
assert.ok(berater.length >= 2);
assert.equal(new Set(berater.map(({ id }) => id)).size, berater.length);
for (const person of berater) {
assert.ok(person.id.trim());
assert.ok(person.name.trim());
assert.match(person.email, /^[^@\s]+@tri-hub\.de$/);
assert.ok(person.biographie.trim());
assert.ok(Array.isArray(person.qualifikationen));
assert.ok(Array.isArray(person.schwerpunkte));
assert.ok("bildquelle" in person);
}
});
test("Berater lassen sich per ID finden; unbekannte IDs liefern undefined", () => {
assert.equal(findAdvisor("relindis-agethen")?.spitzname, "Lilli");
assert.equal(findAdvisor("maren-hoffmann")?.vorname, "Maren");
assert.equal(findAdvisor("nicht-vorhanden"), undefined);
assert.equal(findAdvisor(null), undefined);
});
test("Kontaktname bevorzugt Spitzname und fällt auf den Vornamen zurück", () => {
assert.equal(
advisorDisplayName({ spitzname: " Lilli ", vorname: "Relindis" }),
"Lilli",
);
assert.equal(
advisorDisplayName({ spitzname: "", vorname: "Maren" }),
"Maren",
);
assert.equal(
advisorDisplayName({ spitzname: null, vorname: "Maren" }),
"Maren",
);
assert.equal(advisorDisplayName({ spitzname: " ", vorname: " " }), "");
});
test("jedes Profil hat Porträt und Aktionsbild, deren Dateien vorhanden sind", async () => {
for (const person of berater) {
assert.equal(
person.bilder?.length,
2,
`${person.id} muss zwei Bilder haben`,
);
assert.deepEqual(
person.bilder.map(({ typ }) => typ),
["Porträt", "Aktionsbild"],
);
for (const { quelle } of person.bilder) {
assert.match(quelle, /^\/images\//);
await access(join(projectRoot, "public", quelle.slice(1)));
}
}
});
test("Profilseite ist separat verlinkt und die Landingpage rendert keine Beraterkarten", async () => {
const [page, landingPage, navigation] = await Promise.all([
readFile(join(projectRoot, "berater.html"), "utf8"),
readFile(join(projectRoot, "index.html"), "utf8"),
readFile(
join(projectRoot, "src/components/navigationsbar/navigation.html"),
"utf8",
),
]);
assert.match(page, /id="advisor-profiles"/);
assert.match(page, /src="\/src\/features\/berater\/berater-entry\.js"/);
assert.match(navigation, /href="\/berater\.html"[^>]*>Beraterprofil/);
assert.doesNotMatch(landingPage, /id="advisor-profiles"/);
assert.doesNotMatch(landingPage, /berater-entry\.js/);
});

View File

@ -0,0 +1,667 @@
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 = {
consent: true,
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].consent, true);
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,
[],
{},
...[false, "true", undefined].map((consent) => ({ ...input, consent })),
{ ...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 = {
consent: true,
packageId: "ernaehrung-standard",
customer,
acceptedTerms: true,
};
for (const body of [
{ ...request, acceptedTerms: false },
{ ...request, acceptedTerms: "true" },
{ ...request, customer: [] },
{ ...request, customer: { ...customer, firstName: "" } },
{ ...request, customer: { ...customer, email: "ungültig" } },
{ ...request, customer: { ...customer, phone: 123 } },
])
assert.equal((await post(body)).status, 400);
const key = randomUUID();
const result = await post(request, key);
assert.equal(result.status, 201);
assert.equal(result.body.customerEmail, customer.email);
const [record] = await storage.readAll();
assert.equal(record.name, "Thomas Müller");
assert.deepEqual(record.customer, customer);
assert.equal(record.acceptedTerms, true);
assert.deepEqual(sent[0].customer, customer);
assert.equal((await post(request, key)).status, 200);
assert.equal(
(
await post(
{
...request,
customer: { ...customer, notes: "Anderer Auftrag" },
},
key,
)
).status,
409,
);
});
test("Telefonnummern werden in beiden Anfrageformaten geprüft", async (t) => {
const { post, sent } = await setup(t);
for (const phone of [
"123",
"00000000000",
"abc",
"+999 123456789",
"0170 test 1234567",
]) {
for (const data of [
{ ...input, phone },
{
packageId: input.packageId,
consent: true,
acceptedTerms: true,
customer: {
firstName: "Test",
lastName: "Person",
email: input.email,
phone,
},
},
]) {
const result = await post(data);
assert.equal(result.status, 400, phone);
assert.ok(result.body.error.fields.phone);
}
}
assert.equal(sent.length, 0);
for (const phone of [
"",
"0170 1234567",
"+49 170 1234567",
"0049 170 1234567",
"030 123456",
"+44 20 7946 0958",
]) {
const result = await post({ ...input, phone });
assert.equal(result.status, 201, phone);
}
});

View File

@ -0,0 +1,113 @@
import { test, expect } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.goto("/berater.html");
});
test("Navigation öffnet die separate Beraterseite und Profile werden geladen", async ({
page,
}) => {
const profileLink = page.locator('.navbar__link[href="/berater.html"]');
if (!(await profileLink.isVisible())) {
await page.getByRole("button", { name: "Menü öffnen" }).click();
}
await expect(profileLink).toBeVisible();
await expect(
page.getByRole("heading", { name: /Ernährungsberater/ }),
).toBeVisible();
const cards = page.locator(".advisor-card");
await expect(cards).toHaveCount(2);
await expect(cards.nth(0)).toContainText("Relindis Agethen (Lilli)");
await expect(cards.nth(0)).toContainText("Kurzbiografie");
await expect(cards.nth(0)).toContainText("Qualifikationen");
await expect(cards.nth(0)).toContainText("Schwerpunkte");
const roleBackground = await cards
.nth(0)
.locator(".advisor-role")
.evaluate((element) => getComputedStyle(element).backgroundColor);
const detailsBackground = await cards
.nth(0)
.locator(".package-details-box")
.evaluate((element) => getComputedStyle(element).backgroundColor);
expect(roleBackground).toBe("rgba(0, 0, 0, 0)");
expect(detailsBackground).not.toBe("rgba(0, 0, 0, 0)");
await expect(
cards.nth(0).getByRole("button", { name: "Lilli kontaktieren" }),
).toBeVisible();
await expect(
cards.nth(1).getByRole("button", { name: "Maren kontaktieren" }),
).toBeVisible();
await cards
.nth(0)
.getByRole("button", { name: "Lilli kontaktieren" })
.click();
const contactDialog = page.getByRole("dialog");
await expect(contactDialog).toBeVisible();
await expect(contactDialog).toContainText("Lilli");
});
test("Beraterbilder sind präsent und lassen sich mit Karussell-Pfeilen wechseln", async ({
page,
}) => {
const lilli = page
.locator(".advisor-card")
.filter({ hasText: "Relindis Agethen" });
const image = lilli.locator(
'.advisor-gallery__slide[aria-hidden="false"] .advisor-gallery__image',
);
const track = lilli.locator(".advisor-gallery__track");
const previous = lilli.getByRole("button", {
name: "Vorheriges Bild von Relindis Agethen (Lilli)",
});
const next = lilli.getByRole("button", {
name: "Nächstes Bild von Relindis Agethen (Lilli)",
});
const portraitDot = lilli.getByRole("button", { name: "Bild 1: Porträt" });
const actionDot = lilli.getByRole("button", {
name: "Bild 2: Aktionsbild",
});
await expect(image).toBeVisible();
await expect(image).toHaveAttribute("src", "/images/Lilli1.jpeg");
const bounds = await image.boundingBox();
expect(bounds.width).toBeGreaterThan(150);
expect(bounds.height).toBeGreaterThan(150);
await expect(portraitDot).toHaveAttribute("aria-current", "true");
await expect(actionDot).toHaveAttribute("aria-current", "false");
await next.click();
await expect(track).toHaveCSS("transform", /matrix\(1, 0, 0, 1, -/);
await expect(image).toHaveAttribute("src", "/images/Lilli2.jpeg");
await expect(image).toHaveAttribute(
"alt",
"Relindis Agethen (Lilli) – Aktionsbild",
);
await expect(actionDot).toHaveAttribute("aria-current", "true");
await expect(portraitDot).toHaveAttribute("aria-current", "false");
await previous.click();
await expect(image).toHaveAttribute("src", "/images/Lilli1.jpeg");
await expect(portraitDot).toHaveAttribute("aria-current", "true");
await portraitDot.click();
await expect(image).toHaveAttribute("src", "/images/Lilli1.jpeg");
});
test("Maren hat ebenfalls ein umschaltbares Porträt- und Aktionsbild", async ({
page,
}) => {
const maren = page
.locator(".advisor-card")
.filter({ hasText: "Maren Hoffmann" });
const image = maren.locator(
'.advisor-gallery__slide[aria-hidden="false"] .advisor-gallery__image',
);
await expect(image).toHaveAttribute("src", "/images/Maren1.jpeg");
await maren
.getByRole("button", { name: "Nächstes Bild von Maren Hoffmann" })
.click();
await expect(image).toHaveAttribute("src", "/images/Maren2.jpeg");
});

View File

@ -0,0 +1,316 @@
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) {
if (!(await page.locator("dialog[open]").count())) {
await page
.getByRole("button", { name: "Jetzt buchen", exact: true })
.click();
}
await page
.getByLabel("Name (Pflichtfeld)", { exact: true })
.fill("Test Person");
await page
.getByLabel("E-Mail-Adresse (Pflichtfeld)")
.fill("person@example.test");
await page.locator('[name="consent"]').check();
}
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",
);
const opener = page.getByRole("button", {
name: "Jetzt buchen",
exact: true,
});
await opener.focus();
await page.keyboard.press("Enter");
await expect(
page.getByLabel("Name (Pflichtfeld)", { exact: true }),
).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.getByRole("alert")).toBeFocused();
await expect(page.locator('[aria-invalid="true"]')).toHaveCount(3);
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");
await page.keyboard.press("Tab");
await expect(page.locator('[name="consent"]')).toBeFocused();
await page.keyboard.press("Space");
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
const submit = page.getByRole("button", {
name: "Paket buchen",
exact: true,
});
await expect(submit).toBeFocused();
expect(
await submit.evaluate(
(element) => getComputedStyle(element).outlineStyle,
),
).toBe("solid");
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/booking-confirmation\.html$/);
await expect(page.locator("#confirmation-receipt")).toContainText(
/TH-\d{6,}/,
);
await page.reload();
await expect(page.locator("#confirmation-receipt")).toContainText(
/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 page
.getByRole("button", { name: "Jetzt buchen", exact: true })
.click();
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("#confirmation-receipt")).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: "Ernährungswissen entdecken" })
.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.locator("dialog[open]")).toBeVisible();
const details = packagesDetails.find((entry) => entry.id === pkg.id);
await expect(page.locator(".booking__package-name")).toHaveText(
details.title,
);
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);
await expect(page).toHaveURL(/booking-confirmation\.html$/);
const booking = await page.evaluate(() =>
JSON.parse(sessionStorage.getItem("trihub.booking.confirmation")),
);
expect(booking.packageId).toBe(pkg.id);
expect(booking.packageName).toBe(pkg.name);
await expect(page.locator("#confirmation-receipt")).toContainText(
booking.bookingId,
);
await page.goto("/angebotsuebersicht.html");
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.locator("dialog[open]")).toBeVisible();
await expect(page.locator(".booking__totals")).toContainText(
"52 Wochen Begleitung",
);
await expect(page.locator(".booking__totals")).toContainText("1.399,00");
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",
);
await expect(page).toHaveURL(/booking-confirmation\.html$/);
const booking = await page.evaluate(() =>
JSON.parse(sessionStorage.getItem("trihub.booking.confirmation")),
);
expect(booking.bookedPackage.grossPrice).toBe(1399);
expect(booking.bookedPackage.durationWeeks).toBe(52);
await expect(page.locator("#confirmation-receipt")).toContainText(
booking.bookingId,
);
await page.reload();
await expect(page.locator("#confirmation-totals")).toContainText(
"1.399,00",
);
await expect(page.locator("#confirmation-receipt")).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

@ -0,0 +1,105 @@
import { test, expect } from "@playwright/test";
import { randomUUID } from "node:crypto";
import { contactBookingUrl } from "../../shared/contact.js";
async function open(page) {
await page.goto("/contact-test.html?berater-id=maren-hoffmann");
await page
.getByRole("button", { name: "Berater kontaktieren", exact: true })
.click();
}
test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter", async ({
page,
request,
}) => {
const email = `kontakt-${randomUUID()}@example.test`;
const booking = await request.post("/api/bookings", {
headers: { "Idempotency-Key": randomUUID() },
data: {
consent: true,
packageId: "ernaehrung-starter",
name: "Testkunde",
email,
},
});
expect(booking.ok()).toBeTruthy();
await open(page);
await expect(page.getByRole("dialog")).toContainText("Maren");
const submit = page.getByRole("button", { name: "Anfrage senden" });
await expect(
page.getByRole("button", { name: "E-Mail prüfen" }),
).toHaveCount(0);
await expect(submit).toBeEnabled();
await page
.getByLabel("Deine Buchungs-E-Mail")
.fill("unbekannt@example.test");
await page.getByLabel("Grund für den Kontakt").selectOption("nutrition");
await page.locator('[name="simulationAccepted"]').check();
await page.locator('[name="consent"]').check();
await submit.click();
await expect(page.getByRole("status")).toContainText("keine Buchung");
await expect(page.getByRole("dialog")).toBeVisible();
await page.getByLabel("Deine Buchungs-E-Mail").fill(email);
await page.route("https://bookings.cloud.microsoft/**", (route) =>
route.fulfill({ body: "Microsoft Bookings (Test)" }),
);
const sentRequest = page.waitForRequest((request) =>
request.url().endsWith("/api/contact"),
);
await submit.click();
expect((await sentRequest).postDataJSON().advisorId).toBe("maren-hoffmann");
await expect(page).toHaveURL(contactBookingUrl);
});
test("Mobiles Popup: Fokus, Escape, Rückkehr zum Auslöser und keine Überbreite", async ({
page,
}) => {
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
await expect(page.getByLabel("Deine Buchungs-E-Mail")).toBeFocused();
const box = await page.getByRole("dialog").boundingBox();
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(375);
await page.keyboard.press("Shift+Tab");
await expect(
page.getByRole("button", { name: "Kontaktfenster schließen" }),
).toBeFocused();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Berater kontaktieren", exact: true }),
).toBeFocused();
});
test("Versandfehler zeigt Meldung und bleibt im Popup", async ({ page }) => {
await page.route("**/api/contact", (route) =>
route.fulfill({
status: 502,
json: { error: { message: "Mailpit nicht erreichbar." } },
}),
);
await open(page);
await page.getByLabel("Deine Buchungs-E-Mail").fill("kunde@example.test");
await page.getByLabel("Grund für den Kontakt").selectOption("package");
await page.locator('[name="simulationAccepted"]').check();
await page.locator('[name="consent"]').check();
await page.getByRole("button", { name: "Anfrage senden" }).click();
await expect(page.getByRole("status")).toContainText(
"Mailpit nicht erreichbar",
);
await expect(page.getByRole("dialog")).toBeVisible();
});
test("Unbekannte Berater-ID sperrt das Absenden", async ({ page }) => {
await page.goto("/contact-test.html?berater-id=unbekannt");
await page
.getByRole("button", { name: "Berater kontaktieren", exact: true })
.click();
await expect(page.getByRole("dialog")).toContainText(
"Berater-ID fehlt oder ist unbekannt",
);
await expect(
page.getByRole("button", { name: "Anfrage senden" }),
).toBeDisabled();
});

View File

@ -0,0 +1,110 @@
import { test, expect } from "@playwright/test";
const privacyUrl = "https://www.tri-hub.de/datenschutz";
async function expectConsent(form) {
const checkbox = form.locator('[name="consent"]');
await expect(checkbox).not.toBeChecked();
await expect(checkbox).toHaveAttribute("required", "");
const link = form.getByRole("link", {
name: "Datenschutzbestimmungen",
exact: true,
});
await expect(link).toHaveAttribute("href", privacyUrl);
await expect(link).toHaveCSS("font-weight", "700");
await expect(link).toHaveCSS("text-decoration-line", "underline");
return checkbox;
}
test("Landingpage: beide Formulare benötigen Datenschutz; Kontakt speichert Zustimmung", async ({
page,
}) => {
await page.goto("/index.html");
await expectConsent(page.locator("#reviewForm"));
const form = page.locator("#contactForm");
const consent = await expectConsent(form);
await form.locator('[name="name"]').fill("Datenschutz Test");
await form.locator('[name="email"]').fill("privacy@example.test");
await form.locator('[name="subject"]').fill("Eine Testanfrage");
await form
.locator('[name="message"]')
.fill("Eine ausreichend lange Testnachricht.");
await form.locator('[type="submit"]').click();
expect(
await consent.evaluate((element) => element.validity.valueMissing),
).toBe(true);
await consent.check();
const response = page.waitForResponse((response) =>
response.url().endsWith("/api/nutrition-contact"),
);
await form.locator('[type="submit"]').click();
const saved = await response;
expect(saved.status()).toBe(201);
expect(saved.request().postDataJSON().consent).toBe(true);
await expect(consent).not.toBeChecked();
});
test("Buchungsdialog verlangt Datenschutz und übermittelt Zustimmung", async ({
page,
}) => {
await page.goto("/booking.html?id=ernaehrung-starter");
await page
.getByRole("button", { name: "Jetzt buchen", exact: true })
.click();
const form = page.locator("dialog form");
const consent = await expectConsent(form);
await form.locator('[name="name"]').fill("Datenschutz Test");
await form.locator('[name="email"]').fill("privacy@example.test");
await form.locator('[type="submit"]').click();
await expect(page.locator('[id$="booking-consent-error"]')).toContainText(
"Datenschutzbestimmungen akzeptieren",
);
await consent.check();
const response = page.waitForResponse((response) =>
response.url().endsWith("/api/bookings"),
);
await form.locator('[type="submit"]').click();
const saved = await response;
expect(saved.status()).toBe(201);
expect(saved.request().postDataJSON().consent).toBe(true);
});
test("Beraterkontakt hat eine separate verpflichtende Datenschutz-Zustimmung", async ({
page,
}) => {
await page.goto("/contact-test.html?berater-id=maren-hoffmann");
await page
.getByRole("button", { name: "Berater kontaktieren", exact: true })
.click();
await expectConsent(page.locator("dialog form"));
});
for (const width of [375, 1280]) {
test(`Buchungsfenster: Checkbox und erste Textzeile auf gleicher Höhe (${width}px)`, async ({
page,
}) => {
await page.setViewportSize({ width, height: 900 });
await page.goto("/booking.html?id=ernaehrung-starter");
await page
.getByRole("button", { name: "Jetzt buchen", exact: true })
.click();
const label = page.locator(".booking__consent");
const difference = await label.evaluate((element) => {
const checkbox = element
.querySelector("input")
.getBoundingClientRect();
const text = element.querySelector("span");
const firstLineCenter =
text.getBoundingClientRect().top +
parseFloat(getComputedStyle(text).lineHeight) / 2;
return Math.abs(
checkbox.top + checkbox.height / 2 - firstLineCenter,
);
});
expect(difference).toBeLessThanOrEqual(1);
await expect(label.locator("a")).toHaveCSS(
"color",
"rgb(255, 255, 255)",
);
});
}

Some files were not shown because too many files have changed in this diff Show More