Compare commits
58 Commits
feature/an
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
7c2a4e99c3 | |
|
|
b6ba795cf4 | |
|
|
531583b690 | |
|
|
3f845585c5 | |
|
|
b509adccd9 | |
|
|
56e241c387 | |
|
|
184599d872 | |
|
|
1f5d5dca87 | |
|
|
a56e43c3bb | |
|
|
8746856086 | |
|
|
6d121aa434 | |
|
|
598065d740 | |
|
|
881ba21630 | |
|
|
af88228d3b | |
|
|
659e4ff9ec | |
|
|
fc19fef576 | |
|
|
94bea79863 | |
|
|
370d54fb39 | |
|
|
35f14a4f5e | |
|
|
8c0b1a3cb8 | |
|
|
d79ab2b0ce | |
|
|
5553d117d6 | |
|
|
d9a8317014 | |
|
|
55e4904482 | |
|
|
7071d07ea9 | |
|
|
ae994c29f4 | |
|
|
b8f4605a70 | |
|
|
8fb2044a81 | |
|
|
29ef00c6c3 | |
|
|
70636d82f3 | |
|
|
8c759c3dcb | |
|
|
a4bb71f7a7 | |
|
|
00625cd0eb | |
|
|
ba438b6db6 | |
|
|
61d9208578 | |
|
|
5111f84ade | |
|
|
937ffac806 | |
|
|
985139e82f | |
|
|
15f92f0f55 | |
|
|
1601716e4f | |
|
|
c1332050d7 | |
|
|
6f2b239e57 | |
|
|
02bca699f0 | |
|
|
9bfa8b78b0 | |
|
|
4cae17a55d | |
|
|
48fdb2ecc1 | |
|
|
60d3fab9c9 | |
|
|
50bf782e97 | |
|
|
0ee772e1e5 | |
|
|
33e844423b | |
|
|
7059b41bfe | |
|
|
6437077342 | |
|
|
3f82591c2d | |
|
|
5fb3f51f8f | |
|
|
4abf4aa46c | |
|
|
f507fb5166 | |
|
|
e09fef8c96 | |
|
|
1c9ccfb794 |
|
|
@ -1,7 +1,6 @@
|
|||
# Lokal nur einen Mailfänger verwenden. Keine echten Zugangsdaten eintragen.
|
||||
HOST=127.0.0.1
|
||||
PORT=3000
|
||||
BOOKING_DEMO=true
|
||||
SMTP_HOST=127.0.0.1
|
||||
SMTP_PORT=1025
|
||||
SMTP_SECURE=false
|
||||
|
|
|
|||
|
|
@ -10,5 +10,6 @@ Thumbs.db
|
|||
# Private Buchungsdaten und lokale Testergebnisse
|
||||
src/server/data/*
|
||||
!src/server/data/.gitkeep
|
||||
!src/server/data/contact-requests.csv
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@ 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
|
||||
|
|
|
|||
|
|
@ -4,13 +4,28 @@
|
|||
<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" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="container"><a href="/">Zur Startseite</a></header>
|
||||
<main class="packages-section" id="angebotsuebersicht-section">
|
||||
<div class="packages-header"><h1>Unsere Ernährungs-Pakete</h1></div>
|
||||
<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"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<!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" />
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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>
|
||||
</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>
|
||||
<footer class="confirmation__footer">
|
||||
TRI-HUB <span>Dein Weg. Deine Ausdauer. Deine Ernährung.</span>
|
||||
</footer>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/features/booking/booking-confirmation.js"
|
||||
></script>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/components/navigationsbar/navigation.js"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -7,7 +7,12 @@
|
|||
<title>Paket buchen – Tri-Hub</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="booking-root" class="container"></main>
|
||||
<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"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
<!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>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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
|
||||
```
|
||||
433
docs/booking.md
433
docs/booking.md
|
|
@ -1,357 +1,146 @@
|
|||
# US2.4 – Paket buchen
|
||||
# Paketbuchung
|
||||
|
||||
## Stand und Zusammenarbeit
|
||||
## Ablauf
|
||||
|
||||
Entwicklung auf `feature/paket-buchen-backend`, späteres PR-Ziel: `dev`.
|
||||
Zu Beginn waren HEAD und frisch abgerufenes `origin/dev` identisch. Kein Merge,
|
||||
Commit oder Push wurde durchgeführt. Es gab keinen Router, keine Paketdaten und
|
||||
kein Backend. Die Implementierung verwendet die vereinbarte Startstruktur:
|
||||
Backend unter `src/server/`, gemeinsame Paketdaten unter `src/shared/` und
|
||||
Tests unter `src/tests/`. Die früheren Doppelordner im Projektwurzelverzeichnis
|
||||
wurden in diese vorhandenen Ordner überführt.
|
||||
Die Angebotsübersicht führt zur Detailseite eines Pakets. Der Buchungsbutton
|
||||
übergibt die Paket-ID und gegebenenfalls die gewählte Variante als URL-Parameter
|
||||
an `booking.html`. Bestehende Links mit `#/buchen/:packageId` funktionieren weiterhin.
|
||||
|
||||
## So funktioniert der Ablauf
|
||||
Booking lädt die Bestellübersicht vom Server. Sie enthält Paket und Variante,
|
||||
Laufzeit, Leistungen, Abrechnung, Nettopreis, MwSt. und Gesamtbetrag. Die Preise
|
||||
stammen aus `src/shared/packagesdetails.js`; Preisangaben des Browsers werden
|
||||
nicht übernommen. Ohne Varianten-ID wird die erste Variante des Pakets gewählt.
|
||||
|
||||
1. Der Browser lädt die verfügbaren Pakete über `GET /api/angebote`.
|
||||
2. Das Formular sendet Paket-ID, Name und E-Mail an `POST /api/bookings`.
|
||||
Es merkt sich dafür einen zufälligen Anfrageschlüssel (Idempotency-Key).
|
||||
3. Der Server prüft die Angaben und liest den Paketnamen aus `src/shared/packages.js`.
|
||||
4. Er speichert Buchungsnummer, Zeitpunkt, Angaben und Schlüssel in der CSV.
|
||||
5. Erst danach übergibt er die Bestätigung an SMTP und aktualisiert den Versandstatus.
|
||||
6. Bei einem erneuten Versuch mit denselben Daten und demselben Schlüssel gibt
|
||||
er die vorhandene Buchung zurück. Es entsteht keine zweite Bestellung.
|
||||
Beim Absenden speichert der Server Kundendaten und den vollständigen Bestellstand
|
||||
in der CSV. Anschließend sendet er eine Bestätigung als Text und HTML mit
|
||||
Buchungsnummer, Datum, gebuchten Leistungen und Preisen. Änderungen am Katalog
|
||||
verändern bereits gespeicherte Bestellungen nicht.
|
||||
|
||||
Der Zahlungsvorgang ist weiterhin simuliert. Es erfolgt keine Abbuchung und
|
||||
keine verbindliche Beratungsbuchung. Darauf weisen Formular und E-Mail hin.
|
||||
|
||||
## Lokal starten
|
||||
|
||||
Node.js 24 ist im vorhandenen Dev Container vorgesehen. Alle Befehle laufen im
|
||||
Projektverzeichnis. Vite und Backend benötigen jeweils ein eigenes Terminal.
|
||||
Voraussetzung ist Node.js 24. Abhängigkeiten mit `npm ci` installieren und die
|
||||
Werte aus `.env.example` in eine lokale `.env` übernehmen. Eine vorhandene
|
||||
Konfiguration dabei erhalten.
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
cp .env.example .env
|
||||
```
|
||||
In separaten Terminals starten:
|
||||
|
||||
Eine vorhandene `.env` vorher prüfen und nicht überschreiben. Die Beispielwerte
|
||||
aktivieren das klar gekennzeichnete Testpaket und verwenden nur einen Mailfänger.
|
||||
Ohne `.env` bleibt der Demo-Modus ausgeschaltet; die drei regulären Pakete bleiben verfügbar.
|
||||
- `npm run dev:mail`: Mailpit mit SMTP auf Port 1025 und Weboberfläche auf 8025.
|
||||
- `npm run dev:server`: Buchungs-API auf Port 3000.
|
||||
- `npm run dev`: Frontend auf Port 5173; `/api` wird an Node weitergeleitet.
|
||||
|
||||
Mailpit ist über `.devcontainer/Dockerfile` bereits Bestandteil neuer Dev Container.
|
||||
Bestehende Container einmal neu bauen. Außerhalb davon einmalig
|
||||
[Mailpit installieren](https://mailpit.axllent.org/docs/install/).
|
||||
Es läuft als einzelne Binärdatei auch direkt im Dev Container. Kein Docker im
|
||||
Container erforderlich. Mailpit ohne Weiterleitung an echte SMTP-Server starten:
|
||||
Die Angebotsübersicht liegt unter <http://localhost:5173/angebotsuebersicht.html>,
|
||||
der Mailfänger unter <http://localhost:8025>. Mailpit ist im Dev Container enthalten.
|
||||
|
||||
```bash
|
||||
npm run dev:mail
|
||||
```
|
||||
|
||||
In Terminal 2 das Backend starten:
|
||||
|
||||
```bash
|
||||
npm run dev:server
|
||||
```
|
||||
|
||||
In Terminal 3 das Frontend starten:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Öffnen: <http://localhost:5173/booking.html#/buchen/demo-booking>.
|
||||
Mailfänger: <http://localhost:8025>. Im Dev Container sind die Ports 8025 und 5173
|
||||
für die Weiterleitung vorkonfiguriert.
|
||||
Port 3000 muss für den Browser nicht weitergeleitet werden: Vite vermittelt `/api`.
|
||||
Diese Beschreibung setzt voraus, dass Mailpit im selben Container läuft.
|
||||
|
||||
`npm run start:server` startet das Backend ohne automatischen Neustart.
|
||||
Bestehende Befehle `dev`, `build`, `preview`, `format` und `format:check` bleiben erhalten.
|
||||
`npm run preview` zeigt ausschließlich den gebauten Frontend-Stand. Für eine
|
||||
vollständige Buchung im Deployment muss der Webserver `/api` separat an Node
|
||||
weiterleiten; der Vite-Entwicklungsproxy ist kein Produktionsserver.
|
||||
`npm run build` erzeugt die Seiten in `dist/`, einschließlich der Paketdetailseite.
|
||||
Für ein Deployment muss der Webserver `/api` an den Node-Server weiterleiten.
|
||||
`npm run start:server` startet die API ohne automatischen Neustart.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Alle SMTP-Einstellungen werden ausschließlich in Node geladen. Niemals mit einem
|
||||
`VITE_`-Präfix versehen: Solche Variablen könnten im Browser landen.
|
||||
| Variable | Standardwert | Bedeutung |
|
||||
| ------------------------ | -------------------------------- | --------------------------------------------------- |
|
||||
| `HOST` | `127.0.0.1` | Bind-Adresse der API |
|
||||
| `PORT` | `3000` | API-Port; bei Änderung auch den Vite-Proxy anpassen |
|
||||
| `SMTP_HOST` | `127.0.0.1` | SMTP-Server |
|
||||
| `SMTP_PORT` | `1025` lokal, sonst `587` | SMTP-Port |
|
||||
| `SMTP_SECURE` | `false` | Direktes TLS aktivieren |
|
||||
| `SMTP_USER`, `SMTP_PASS` | leer | SMTP-Zugangsdaten; beide gemeinsam setzen |
|
||||
| `SMTP_FROM` | `Tri-Hub <buchung@example.test>` | Absender |
|
||||
| `SMTP_ALLOW_EXTERNAL` | `false` | Versand über externe SMTP-Server aktivieren |
|
||||
|
||||
| Variable | Lokaler Beispielwert | Bedeutung |
|
||||
| ------------------------- | -------------------------------- | --------------------------------------------------------------------- |
|
||||
| `HOST` | `127.0.0.1` | Bind-Adresse des Backends |
|
||||
| `PORT` | `3000` | Backend-Port; bei Änderung auch Vite-Proxy anpassen |
|
||||
| `BOOKING_DEMO` | `true` | Fügt ausschließlich lokal das Testpaket hinzu; in Produktion gesperrt |
|
||||
| `SMTP_HOST` | `127.0.0.1` | Mailfänger im selben Container |
|
||||
| `SMTP_PORT` | `1025` | SMTP-Port des Mailfängers |
|
||||
| `SMTP_SECURE` | `false` | Bei echtem SMTP mit direktem TLS üblicherweise `true` auf 465 |
|
||||
| `SMTP_USER` / `SMTP_PASS` | leer | Beide gemeinsam setzen, falls Authentifizierung benötigt wird |
|
||||
| `SMTP_FROM` | `Tri-Hub <buchung@example.test>` | Absender; später abgestimmten echten Absender verwenden |
|
||||
| `SMTP_ALLOW_EXTERNAL` | `false` | Externes SMTP gesperrt; erst nach ausdrücklicher Freigabe aktivieren |
|
||||
Externe SMTP-Verbindungen erfordern TLS. Zugangsdaten gehören ausschließlich
|
||||
in die Serverkonfiguration und dürfen kein `VITE_`-Präfix erhalten.
|
||||
|
||||
Für externe SMTP-Hosts wird TLS verlangt; Zertifikatsprüfung bleibt aktiv.
|
||||
Für STARTTLS (typischerweise Port 587) bleibt `SMTP_SECURE=false`.
|
||||
Die `.env.example` enthält nur Testwerte und keine Geheimnisse.
|
||||
[SMTP-Optionen bei Nodemailer](https://nodemailer.com/smtp).
|
||||
## API
|
||||
|
||||
## Paketdaten und spätere Router-Anbindung
|
||||
Der vollständige Vertrag steht in `src/server/swagger.json`.
|
||||
|
||||
Person 3 pflegt `packages` in **`src/shared/packages.js`**.
|
||||
Das Array enthält die drei Angebote Starter, Standard und Premium. Übersicht und
|
||||
Buchung verwenden gemeinsam `id`, `name` und `summary`; zusätzliche Felder
|
||||
beschreiben die Darstellung der Angebotskarten.
|
||||
| Endpunkt | Funktion |
|
||||
| ---------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `GET /api/angebote` | Bestehende Kurzübersicht mit `id`, `name` und `summary` |
|
||||
| `GET /api/packages` | Kurzübersicht im Swagger-Format |
|
||||
| `GET /api/packages/{id}` | Paketdetails einschließlich Varianten und Leistungen |
|
||||
| `POST /api/bookings/preview` | Bestellübersicht für `packageId` und optionale `variantId`; speichert keine Buchung |
|
||||
| `POST /api/bookings` | Buchung speichern und Bestätigung versenden |
|
||||
|
||||
```js
|
||||
// Nur Schema-Beispiel, kein verbindliches Angebot:
|
||||
{ id: "stabile-paket-id", name: "Abgestimmter Paketname", summary: "Kurze Beschreibung" }
|
||||
```
|
||||
POST-Anfragen benötigen `Content-Type: application/json`. Beim Buchen ist
|
||||
zusätzlich ein `Idempotency-Key` als UUID v4 erforderlich. Das Formular sendet
|
||||
`packageId`, gegebenenfalls `variantId`, `name`, `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.
|
||||
|
||||
IDs: eindeutig, maximal 80 Zeichen, Kleinbuchstaben/Ziffern mit einzelnen
|
||||
Bindestrichen. `name` und `summary` sind nicht leere Strings. IDs nach Freigabe
|
||||
nicht für andere Angebote wiederverwenden. Das Backend prüft den Vertrag beim Start.
|
||||
Das separate `demoPackages`-Array in derselben Datei enthält nur `demo-booking`,
|
||||
explizit als Test markiert. Es wird mit `BOOKING_DEMO=true` zugeschaltet.
|
||||
Keine Preise wurden erfunden. Sobald verbindliche Preisfelder vereinbart sind,
|
||||
müssen Anzeige und serverseitiger Buchungssnapshot gemeinsam ergänzt werden.
|
||||
Angaben wie `packageName` oder `price` aus API-Anfragen werden nicht übernommen.
|
||||
Alternativ akzeptiert die API das Swagger-Objekt `customer` mit `firstName`,
|
||||
`lastName`, `email` sowie optional `phone`, `ageGroup` und `notes`. Dabei muss
|
||||
`acceptedTerms` den Wert `true` haben. Diese Angaben werden ebenfalls gespeichert.
|
||||
|
||||
Die Angebotsseite kann vorerst auf die eigenständige Ansicht verlinken:
|
||||
Die Antwort enthält die Buchungsnummer, den Erstellungszeitpunkt, das gebuchte
|
||||
Paket unter `bookedPackage` und den Versandstatus. Eine neue Buchung mit
|
||||
SMTP-Annahme erhält HTTP 201, eine Wiederholung HTTP 200. Bei ausstehendem,
|
||||
fehlgeschlagenem oder unklarem Versand wird HTTP 202 zurückgegeben.
|
||||
|
||||
```js
|
||||
link.href = `/booking.html#/buchen/${encodeURIComponent(paket.id)}`;
|
||||
```
|
||||
Ungültige Angaben, Pakete und Varianten ergeben HTTP 400; ein unbekanntes Paket
|
||||
am Detail-Endpunkt HTTP 404. Derselbe Anfrageschlüssel mit anderen Daten ergibt
|
||||
HTTP 409. JSON-Anfragen sind auf 8 KiB begrenzt. Fehlerantworten enthalten
|
||||
`error.code`, `error.message` und gegebenenfalls `error.fields`.
|
||||
|
||||
Es wurde kein Router in die gemeinsame Startseite eingebaut. Wenn Person 2 den
|
||||
Router ergänzt, kann sie bei `/#/buchen/:packageId` diese Funktion aufrufen:
|
||||
## Speicherung und Wiederholungen
|
||||
|
||||
```js
|
||||
import { mountBookingPage } from "./features/booking/booking-page.js";
|
||||
const cleanup = mountBookingPage(container, { packageId });
|
||||
// Beim Verlassen der Route:
|
||||
cleanup();
|
||||
```
|
||||
Die Buchungen liegen in `src/server/data/bookings.csv`. Neue Buchungsnummern
|
||||
werden fortlaufend als `TH-000001`, `TH-000002` usw. vergeben. Der höchste
|
||||
vorhandene Wert bestimmt die nächste Nummer. Die CSV muss deshalb vollständig
|
||||
erhalten bleiben; ältere UUID-Buchungsnummern werden weiterhin unterstützt.
|
||||
|
||||
Das Modul bringt seine begrenzten `.booking`-Styles mit. Farben und Schrift
|
||||
orientieren sich am vorhandenen Basis-CSS. Gemeinsame Designvariablen und eine
|
||||
Button-Komponente gibt es bisher nicht. Layout und Navigation bleiben Aufgabe
|
||||
der anderen Teammitglieder. „Zur Angebotsseite“ führt zu `/angebotsuebersicht.html`.
|
||||
`bookedPackage` speichert den Bestellstand als JSON, `customer` die optionalen
|
||||
strukturierten Kundendaten und `acceptedTerms` die zugehörige Zustimmung.
|
||||
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`.
|
||||
|
||||
## API-Beispiele
|
||||
Lesen, Schreiben und Versand werden innerhalb einer Warteschlange abgearbeitet.
|
||||
Die Datei wird über eine temporäre Datei atomar ersetzt. Unterstützt wird ein
|
||||
Backend-Prozess auf einem lokalen Dateisystem. Mehrere Serverinstanzen benötigen
|
||||
einen gemeinsam abgesicherten Speicher. CSV-Zellen werden gegen die Auswertung
|
||||
als Tabellenformeln geschützt; Dateien erhalten den Zugriffsmodus 0600.
|
||||
|
||||
`GET /api/angebote` liefert `{ "packages": [...] }` mit `id`, `name`, `summary`
|
||||
und `testOnly`. Keine personenbezogenen Daten oder SMTP-Einstellungen.
|
||||
Wiederholte Anfragen mit demselben Schlüssel und denselben Daten liefern die
|
||||
vorhandene Buchung. Es wird keine zweite Bestellung angelegt. Die Auswahl einer
|
||||
anderen Variante mit demselben Schlüssel wird abgelehnt.
|
||||
|
||||
Für `POST /api/bookings` sind `Content-Type: application/json` und ein
|
||||
`Idempotency-Key` als UUID v4 erforderlich. Ein Schlüssel gehört genau zu einer
|
||||
Buchung. Bei Wiederholung denselben Schlüssel und dieselben Angaben verwenden.
|
||||
Das Formular merkt sich Anfrage und Bestellübersicht je Paket und Variante im
|
||||
`sessionStorage`. Nach einem Verbindungsfehler bleiben die Angaben gesperrt,
|
||||
damit dieselbe Anfrage erneut geprüft werden kann. Ein neuer Tab oder gelöschter
|
||||
Sitzungsspeicher liegt außerhalb dieser Absicherung.
|
||||
|
||||
```http
|
||||
POST /api/bookings
|
||||
Content-Type: application/json
|
||||
Idempotency-Key: 36c45d7f-585f-47e9-bf5c-7ea1b7a162ed
|
||||
## Versandstatus
|
||||
|
||||
{"packageId":"demo-booking","name":"Test Person","email":"person@example.test"}
|
||||
```
|
||||
| Status | Bedeutung |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `pending` | Buchung gespeichert; Versand noch nicht gestartet. Dieselbe Anfrage kann den Versand fortsetzen. |
|
||||
| `sending` | Versandabsicht gespeichert. Nach einem Prozessabbruch liefert die API `unknown`. |
|
||||
| `accepted` | SMTP hat die Nachricht angenommen; die Zustellung ist noch nicht bestätigt. |
|
||||
| `failed` | Versand fehlgeschlagen oder ausdrücklich abgelehnt. |
|
||||
| `unknown` | SMTP-Annahme oder anschließende Statusspeicherung blieb unklar. |
|
||||
|
||||
Neue Buchung mit SMTP-Annahme: HTTP `201`; gespeicherte Wiederholung: `200`.
|
||||
Bei `failed` und `unknown` wird nicht automatisch erneut versendet. Vor einem
|
||||
manuellen Neuversand muss der Mailserver anhand der Buchungsnummer geprüft werden.
|
||||
CSV und SMTP bilden keine gemeinsame Transaktion.
|
||||
|
||||
```json
|
||||
{
|
||||
"bookingId": "TH-000001",
|
||||
"createdAt": "2026-09-24T12:00:00.000Z",
|
||||
"packageId": "demo-booking",
|
||||
"packageName": "1:1-Ernährungsberatung",
|
||||
"saved": true,
|
||||
"emailStatus": "accepted",
|
||||
"replayed": false
|
||||
}
|
||||
```
|
||||
## Tests
|
||||
|
||||
Gespeichert, aber E-Mail nicht bestätigt: HTTP `202` mit derselben Struktur und
|
||||
`emailStatus: "failed"`, `"pending"` oder `"unknown"`. Das bedeutet ausdrücklich
|
||||
nicht, dass die E-Mail zugestellt wurde. Die Buchungsnummer bleibt gültig.
|
||||
`npm test` prüft Validierung, Preisberechnung, Varianten, CSV-Kompatibilität,
|
||||
Wiederholungen und Versandfehler. `npm run test:browser` prüft den Weg von der
|
||||
Angebotsübersicht über die Paketdetails bis zur Bestätigung, einschließlich
|
||||
Premium-Jahresvariante, Tastaturbedienung und schmalem Bildschirm.
|
||||
|
||||
| Status/Code | Bedeutung und Verhalten |
|
||||
| ------------------------------ | ----------------------------------------------------------------------- |
|
||||
| `400 INVALID_INPUT` | Angaben korrigieren; Feldhinweise stehen in `error.fields` |
|
||||
| `400 UNKNOWN_PACKAGE` | Paket nicht verfügbar; verfügbares Paket auswählen |
|
||||
| `400 INVALID_KEY` | UUID-v4-Anfrageschlüssel fehlt oder ist ungültig |
|
||||
| `400 INVALID_JSON` | Anfrage ist kein gültiges JSON |
|
||||
| `409 IDEMPOTENCY_CONFLICT` | Derselbe Schlüssel wurde mit anderen Angaben verwendet |
|
||||
| `413 BODY_TOO_LARGE` | Mehr als 8 KiB Anfrageinhalt |
|
||||
| `415 UNSUPPORTED_CONTENT_TYPE` | JSON-Content-Type erforderlich |
|
||||
| `503 BOOKING_NOT_SAVED` | Speicherung fehlgeschlagen; mit gleichem Schlüssel wiederholen |
|
||||
| `503 STORAGE_UNAVAILABLE` | Vorhandener Status nicht lesbar; mit gleichem Schlüssel wiederholen |
|
||||
| `500 INTERNAL_ERROR` | Status unklar; keine neue Buchung erzeugen, gleiche Anfrage wiederholen |
|
||||
Die Tests verwenden die regulären Paketdaten, temporäre CSV-Dateien und einen
|
||||
simulierten Mailversand. Playwright benötigt Chromium; die Installation erfolgt
|
||||
mit `npx playwright install --with-deps chromium`. Die Ports 3000 und 5173 müssen
|
||||
für die automatisch gestarteten Testserver frei sein.
|
||||
|
||||
Die E-Mail-Prüfung unterstützt übliche unquotierte ASCII-Adressen. Internationale
|
||||
Domains können in Punycode angegeben werden; SMTPUTF8-Adressen sind nicht Teil
|
||||
dieses Sprints. Eine Formatprüfung beweist nicht, dass das Postfach existiert.
|
||||
|
||||
Beispiel für einen Validierungsfehler:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "INVALID_INPUT",
|
||||
"message": "Bitte die markierten Angaben prüfen.",
|
||||
"fields": { "email": "Bitte eine gültige E-Mail-Adresse eingeben." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Simulation und spätere Zahlung
|
||||
|
||||
Das Demonstrationspaket heißt in der Oberfläche „1:1-Ernährungsberatung“.
|
||||
Die ID `demo-booking` und die Freigabe über `BOOKING_DEMO` bleiben unverändert;
|
||||
es wurden keine verbindlichen Angebote, Leistungsumfänge oder Preise ergänzt.
|
||||
Ein gemeinsamer Hinweis aus `src/shared/booking-copy.js` erklärt vor dem Absenden
|
||||
und in der E-Mail, dass der Ablauf simuliert ist: Der Zahlungsvorgang wird
|
||||
übersprungen, es wird nichts abgebucht und keine verbindliche Beratung gebucht.
|
||||
Bei vollständiger Integration kann der Zahlungsschritt ergänzt werden. Dafür
|
||||
müssen tatsächliche Zahlungsbestätigung und Buchungsstatus serverseitig verbunden
|
||||
werden; das Entfernen des Hinweises allein aktiviert keine Zahlung.
|
||||
|
||||
## Fortlaufende Buchungsnummern
|
||||
|
||||
Neue Buchungen erhalten `TH-000001`, `TH-000002` usw. Dieselbe Nummer steht in der
|
||||
Antwort, CSV und E-Mail. Sie wird innerhalb der Warteschlange aus dem höchsten
|
||||
vorhandenen TH-Wert berechnet und mit der Buchung gespeichert. Ein Neustart setzt
|
||||
sie nicht zurück; Wiederholungen behalten dieselbe Nummer. Sechs Stellen sind
|
||||
die Mindestbreite, nach `TH-999999` folgt `TH-1000000`. Es gibt keinen Jahresreset.
|
||||
|
||||
Bestehende UUID-Buchungen und gespeicherte Browserbestätigungen behalten ihre
|
||||
bisherige Nummer, damit bereits ausgegebene Referenzen gültig bleiben. Nur neue
|
||||
Buchungen verwenden das neue Format. Der technische Idempotency-Key bleibt eine
|
||||
zufällige UUID; die lesbare Buchungsnummer ist kein Geheimnis oder Zugriffstoken.
|
||||
|
||||
Der Zähler setzt voraus, dass die CSV vollständig erhalten bleibt. Löschen von
|
||||
Zeilen, Entfernen der Datei oder Zurückspielen eines älteren Backups kann Nummern
|
||||
wiederverwendbar machen. Vor einer Archivierungs-/Löschfunktion ist ein separat
|
||||
persistierter, transaktionaler Zähler nötig. Weiterhin nur ein Backend-Prozess.
|
||||
|
||||
## CSV, Wiederholungen und Fehlergrenzen
|
||||
|
||||
Pfad: `src/server/data/bookings.csv`. Spalten: `bookingId`, `createdAt`, `packageId`,
|
||||
`packageName`, `name`, `email`, `emailStatus`, `idempotencyKey`, `requestHash`.
|
||||
`requestHash` verknüpft den Schlüssel mit den normalisierten Anfragedaten, ohne
|
||||
sie in einer zweiten Datenquelle zu duplizieren. Bereits gespeicherte Buchungen
|
||||
können auch nach dem Entfernen eines Pakets weiterhin wiederholt abgefragt werden.
|
||||
|
||||
CSV-Bibliotheken behandeln Kommas, Anführungszeichen und Zeilenumbrüche. Zellen,
|
||||
die wie Tabellenformeln beginnen, erhalten ein Apostroph. Bereits führende
|
||||
Apostrophe werden ebenfalls maskiert, damit internes Lesen die Originalwerte
|
||||
wiederherstellen kann. Persönliche Namen dürfen in der API keine Steuerzeichen
|
||||
enthalten; die Speicherschicht unterstützt Zeilenumbrüche für andere CSV-Felder.
|
||||
Dateien werden mit Zugriffsmodus `0600` geschrieben.
|
||||
|
||||
Eine Warteschlange führt komplette Buchungen nacheinander aus. Schreiben erfolgt
|
||||
über eine temporäre Datei und atomisches Umbenennen. **Nur ein laufender
|
||||
Backend-Prozess auf einem lokalen Dateisystem wird unterstützt.** Keine Cluster,
|
||||
mehreren Containerinstanzen, manuelle Dateiedits während des Betriebs oder
|
||||
Netzlaufwerke. Die gesamte CSV wird bei Änderungen gelesen/neu geschrieben;
|
||||
geeignet für den ersten Sprint mit kleinem Volumen. Es gibt keine verteilte
|
||||
Transaktion oder vollständige Garantie gegen Hardware-/Stromausfall.
|
||||
|
||||
Versandzustände:
|
||||
|
||||
- `pending`: Buchung gespeichert, Versand noch nicht gestartet. Derselbe Schlüssel
|
||||
darf den Versand nach einem Schreibfehler fortsetzen.
|
||||
- `sending`: Versandabsicht dauerhaft gespeichert. Bei Prozessabbruch zeigt die
|
||||
API diesen Zustand als `unknown`; kein automatischer Neuversand.
|
||||
- `accepted`: SMTP hat den Empfänger und die Nachricht angenommen.
|
||||
- `failed`: Der Versand ist fehlgeschlagen bzw. wurde ausdrücklich abgelehnt.
|
||||
- `unknown`: Verbindung oder Statusspeicherung brach zu einem unklaren Zeitpunkt ab.
|
||||
|
||||
Für `failed` und `unknown` gibt es bewusst keinen automatischen erneuten Versand.
|
||||
Erst CSV und Mailserver/Mailfänger anhand der Buchungsnummer prüfen. Eine
|
||||
administrative Wiederholungsfunktion ist offen; keinesfalls eine neue Buchung
|
||||
anlegen oder `sending` blind zurücksetzen. SMTP und CSV können nicht gemeinsam
|
||||
atomar abgeschlossen werden, weshalb ein exakt einmaliger Versand nicht garantiert
|
||||
werden kann. Ein Verbindungsabbruch nach SMTP-Annahme kann doppelte Mails verursachen,
|
||||
wenn später ohne Prüfung manuell erneut gesendet wird.
|
||||
|
||||
Das Formular speichert Schlüssel und Angaben je Paket in `sessionStorage`, also
|
||||
für den aktuellen Browser-Tab, auch über Neuladen hinweg. Bei unklarem Ergebnis
|
||||
bleiben die Angaben schreibgeschützt und können mit demselben Schlüssel erneut
|
||||
geprüft werden. Nach gespeicherter Buchung bleibt das Formular gesperrt. Ein
|
||||
bewusst neuer Auftrag benötigt einen neuen Tab bzw. eine neue Sitzung. Das
|
||||
Schließen des Tabs, gelöschter Sitzungsspeicher, ein anderes Gerät oder ein neuer
|
||||
Schlüssel liegen außerhalb der Doppelbuchungsabsicherung. Bei unklarem Ausgang
|
||||
zuerst den bestehenden Status prüfen. Es gibt keine globale Erkennung anhand
|
||||
von Name/E-Mail, weil zwei bewusst getrennte Buchungen möglich sein müssen.
|
||||
|
||||
`.gitignore` schließt Buchungsdaten einschließlich temporärer Dateien und `.env`
|
||||
aus. Das Backend liefert ausschließlich API-Antworten aus. Vite blockiert
|
||||
Serververzeichnis und Umgebungsdateien auch beim direkten Dateizugriff. Im
|
||||
Deployment nur `dist/` öffentlich bereitstellen, nie den gesamten Projektordner.
|
||||
|
||||
## Prüfen
|
||||
|
||||
Automatisierte Tests erzeugen temporäre CSV-Dateien und simulieren E-Mail-Versand.
|
||||
Sie schreiben nicht in `src/server/data/bookings.csv` und kontaktieren kein echtes SMTP.
|
||||
Vor Browsertests lokale Server auf 3000/5173 stoppen; Playwright startet eigene
|
||||
Testserver und übernimmt keine bereits laufenden Instanzen.
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npx playwright install --with-deps chromium
|
||||
npm run test:browser
|
||||
npm run format:check
|
||||
npm run build
|
||||
```
|
||||
|
||||
Die einmalige Chromium-Installation benötigt Netzwerk und unter Linux eventuell
|
||||
Systempakete. Der Dev Container wurde dafür nicht global umkonfiguriert.
|
||||
|
||||
Manuelle Abnahme im lokalen Mailfänger:
|
||||
|
||||
1. Demo-Modus, Mailpit, Backend und Vite wie oben starten.
|
||||
2. Testpaket mit `person@example.test` buchen. Buchungsnummer notieren.
|
||||
3. CSV im Editor öffnen: genau eine passende Zeile, Paket, Zeitpunkt und Versandstatus prüfen.
|
||||
4. In Mailpit die Nachricht öffnen: Empfänger, Paket und Buchungsnummer vergleichen.
|
||||
5. Seite neu laden: gleiche Buchungsnummer, keine neue CSV-Zeile und keine zweite Mail.
|
||||
6. Mit Tab/Shift+Tab und Enter bedienen, Feldfehler und sichtbaren Fokus prüfen.
|
||||
Auf 320 Pixel Breite und mit Browser-Zoom prüfen; ergänzend Screenreader verwenden.
|
||||
7. Für den Versandfehler einen neuen Test in neuer Sitzung bei gestopptem Mailpit
|
||||
ausführen: Buchung bleibt gespeichert, Anzeige meldet keine erfolgreiche Zustellung.
|
||||
|
||||
**Das Akzeptanzkriterium „E-Mail zugestellt“ ist mit SMTP-Annahme oder Mailpit allein
|
||||
noch nicht für einen echten Empfänger nachgewiesen.** Erst nach ausdrücklicher
|
||||
Freigabe echtes SMTP konfigurieren und an eine vereinbarte Testadresse senden.
|
||||
Dann im tatsächlichen Empfängerpostfach (auch Spam) Eingang, Buchungsnummer und
|
||||
Paket kontrollieren und Zeitpunkt sowie Ergebnis im Sprint-Nachweis festhalten.
|
||||
Es gibt bisher keine automatische Bounce-/Zustellverfolgung.
|
||||
|
||||
## Durchgeführte Prüfungen
|
||||
|
||||
- 17 Backend-Tests: Validierung, CSV-Sonderzeichen/Formelschutz, parallele
|
||||
Anfragen, Wiederholung mit neuer Serviceinstanz, Speicher- und SMTP-Fehler.
|
||||
- 6 Chromium-Prüfungen: Tastaturbedienung/Fokus, 320-Pixel-Ansicht, verlorene
|
||||
Antwort mit Neuladen, Ladezustand, Versandfehler und private Dateipfade.
|
||||
- Lokaler SMTP-Test mit Mailpit und temporärer CSV: Nachricht tatsächlich im
|
||||
Mailfänger empfangen, Paket/Buchungsnummer geprüft, wiederholte Anfrage ohne
|
||||
zusätzliche CSV-Zeile oder E-Mail. Ausschließlich `example.test`-Empfänger.
|
||||
- Projektweite Formatprüfung, Produktionsbuild und Git-Whitespace-Prüfung.
|
||||
|
||||
Die automatisierten Prüfungen ersetzen keine Screenreader-Abnahme oder Prüfung
|
||||
in weiteren Browsern. Keine echte E-Mail-Zustellung wurde getestet. Zum Ausführen
|
||||
der Browserprüfungen wurden Chromium und die benötigten Systembibliotheken in
|
||||
dieser Arbeitsumgebung installiert; die Dev-Container-Konfigurationsdatei wurde
|
||||
nicht verändert. Der Mailpit-Test verwendete eine temporär heruntergeladene
|
||||
Binärdatei außerhalb des Repositorys.
|
||||
|
||||
## Offene Punkte und gemeinsame Dateien
|
||||
|
||||
Die Angebotsübersicht ist als eigene HTML-Seite angebunden. Offen: Anschluss an einen gemeinsamen Router, abschließendes
|
||||
Teamdesign, reale Zustellprüfung und Screenreader-Abnahme. Vor öffentlichem
|
||||
Betrieb sind außerdem Betriebsfragen wie Zugriff auf CSV/Backups, Aufbewahrung
|
||||
und Schutz des öffentlichen Buchungsendpunkts vor automatisiertem Massenversand
|
||||
zu klären. Keine Zahlungen, Benutzerkonten oder Datenbank implementiert.
|
||||
|
||||
Gemeinsame Änderungen: `package.json` und Lockdatei (Abhängigkeiten/Startbefehle),
|
||||
`src/main.js` (bestehenden falschen CSS-Import korrigiert), `.gitignore` und
|
||||
`.prettierignore` (private Daten/Testergebnisse ausschließen). Neu hinzugefügt:
|
||||
`vite.config.js` (Proxy, Dateizugriffsschutz, zwei HTML-Einstiegspunkte),
|
||||
`src/shared/packages.js` (gemeinsamer Paketvertrag), `.env.example`, `booking.html`
|
||||
und `playwright.config.js`. Die Dev-Container-Konfiguration installiert Mailpit und leitet dessen Web-Port weiter.
|
||||
|
||||
Technische Referenzen: [CSV-Parser](https://csv.js.org/parse/api/sync/),
|
||||
[Playwright-Testserver](https://playwright.dev/docs/test-webserver).
|
||||
Vor einem Commit außerdem `npm run format:check`, `npm run build` und
|
||||
`git diff --check` ausführen. Für eine manuelle Mailprüfung eine Buchung mit einer
|
||||
Adresse unter `example.test` anlegen und die Bestätigung in Mailpit kontrollieren.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
1275
index.html
1275
index.html
File diff suppressed because it is too large
Load Diff
|
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"csv-parse": "^7.0.2",
|
||||
"csv-stringify": "^6.8.3",
|
||||
"libphonenumber-js": "^1.13.14",
|
||||
"nodemailer": "^10.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -982,6 +983,12 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libphonenumber-js": {
|
||||
"version": "1.13.14",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.14.tgz",
|
||||
"integrity": "sha512-llihgCcx0BFLksecLP+x1J+6JDE1GsXS1RN/LoPF6qcwpeQcnjj0lcvZxY8AzbEpYwyZWPZW/nDuqkqzm3amiw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.19",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
"dependencies": {
|
||||
"csv-parse": "^7.0.2",
|
||||
"csv-stringify": "^6.8.3",
|
||||
"libphonenumber-js": "^1.13.14",
|
||||
"nodemailer": "^10.0.10"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,27 @@
|
|||
<!doctype html>
|
||||
<html lang="de" class="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Paket-Details – Tri-Hub Ernährungsberatung</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Playfair+Display:wght@600;700&display=swap"
|
||||
rel="stylesheet">
|
||||
<link rel="stylesheet" href="/src/styles/packages.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="container">
|
||||
<a href="angebotsuebersicht.html">← Zurück zur Angebotsübersicht</a>
|
||||
</header>
|
||||
|
||||
<main class="packages-section">
|
||||
<div id="package-detail-container" class="detail-page-container">
|
||||
<!-- Wird dynamisch durch paket-details.js gefüllt -->
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/src/features/angebote/paket-details.js"></script>
|
||||
</body>
|
||||
|
||||
<html 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" />
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ export default defineConfig({
|
|||
{
|
||||
command: "node src/tests/helpers/browser-server.js",
|
||||
url: "http://127.0.0.1:3000/api/angebote",
|
||||
reuseExistingServer: false,
|
||||
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: false,
|
||||
reuseExistingServer: process.env.PW_REUSE_EXISTING === "1",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: 63 KiB |
|
|
@ -0,0 +1,14 @@
|
|||
# Navigation einbinden
|
||||
|
||||
Füge an der gewünschten Stelle im HTML einen Platzhalter ein und binde CSS
|
||||
und JavaScript separat ein:
|
||||
|
||||
```html
|
||||
<div id="navbar-placeholder"></div>
|
||||
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
||||
<script type="module" src="/src/components/navigationsbar/navigation.js"></script>
|
||||
```
|
||||
|
||||
Das Script lädt `navigation.html` und initialisiert die Navigation automatisch,
|
||||
`initNavbar()` muss nicht zusätzlich aufgerufen werden. Die Stylesheet-Datei wird
|
||||
nicht vom Script geladen und muss deshalb separat eingebunden werden.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
|
|
@ -0,0 +1,74 @@
|
|||
<header class="navbar" id="site-navbar">
|
||||
<div class="navbar__inner">
|
||||
<a
|
||||
href="/index.html#main-content"
|
||||
class="navbar__brand"
|
||||
aria-label="Zum Hauptinhalt springen"
|
||||
>
|
||||
<img src="/images/logo.png" alt="" class="navbar__logo-img" />
|
||||
<span class="navbar__brand-text">
|
||||
<span class="navbar__brand-title">TRI-HUB</span>
|
||||
<span class="navbar__brand-tagline"
|
||||
>Ernährungsberatung für Triathleten ab 50</span
|
||||
>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
class="navbar__toggle"
|
||||
type="button"
|
||||
aria-controls="navbar-menu"
|
||||
aria-expanded="false"
|
||||
aria-label="Menü öffnen"
|
||||
>
|
||||
<span class="navbar__toggle-icon" aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
<nav class="navbar__nav" aria-label="Hauptnavigation">
|
||||
<ul id="navbar-menu" class="navbar__menu">
|
||||
<li>
|
||||
<a href="/angebotsuebersicht.html" class="navbar__link"
|
||||
>Pakete</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/index.html" class="navbar__link">Ernährung</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/berater.html" class="navbar__link">Beraterprofil</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/index.html" class="navbar__link">FAQ</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/index.html#erfahrungen" class="navbar__link">Rezensionen</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/index.html#kontakt" class="navbar__link">Kontakt</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://tri-hub.de/"
|
||||
class="navbar__link navbar__link--home"
|
||||
>
|
||||
<span class="navbar__home-icon" aria-hidden="true">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 10L12 3L20 10V20a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V10Z M9 21V14H15V21"
|
||||
stroke="currentColor"
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
Startseite
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import navigationHtml from "./navigation.html?raw";
|
||||
|
||||
/**
|
||||
* Bindet das mit Vite gebündelte Navbar-Fragment (navigation.html) ein und
|
||||
* ersetzt damit den Platzhalter im Header der jeweiligen Seite.
|
||||
* Aufruf z. B. in main.js: initNavbar('#navbar-placeholder')
|
||||
*/
|
||||
export function initNavbar(placeholderSelector = "#navbar-placeholder") {
|
||||
const placeholder = document.querySelector(placeholderSelector);
|
||||
if (!placeholder) {
|
||||
console.warn("Navbar-Platzhalter nicht gefunden:", placeholderSelector);
|
||||
return;
|
||||
}
|
||||
|
||||
placeholder.outerHTML = navigationHtml; // Platzhalter durch echtes Navbar-Markup ersetzen
|
||||
setupBehavior();
|
||||
markCurrentPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Richtet alle interaktiven Funktionen der Navbar ein:
|
||||
* - Mobiles Menü öffnen/schließen per Klick
|
||||
* - Automatischer Fokus auf ersten Link, wenn per Tastatur geöffnet
|
||||
* - Escape schließt das Menü und gibt den Fokus zurück
|
||||
* - Menü schließt sich, wenn der Fokus die Navbar per Tab verlässt
|
||||
* - Klick außerhalb der Navbar schließt das Menü (Maus/Touch)
|
||||
*/
|
||||
export function setupBehavior() {
|
||||
const navbar = document.querySelector(".navbar");
|
||||
const toggle = document.querySelector(".navbar__toggle");
|
||||
const nav = document.querySelector(".navbar__nav");
|
||||
const menu = document.querySelector(".navbar__menu");
|
||||
if (!navbar || !toggle || !nav || !menu) return;
|
||||
|
||||
let openedViaKeyboard = false;
|
||||
|
||||
toggle.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
openedViaKeyboard = true;
|
||||
}
|
||||
});
|
||||
|
||||
toggle.addEventListener("click", () => {
|
||||
const isOpen = nav.classList.toggle("is-open");
|
||||
toggle.setAttribute("aria-expanded", String(isOpen));
|
||||
toggle.setAttribute(
|
||||
"aria-label",
|
||||
isOpen ? "Menü schließen" : "Menü öffnen",
|
||||
);
|
||||
|
||||
if (isOpen && openedViaKeyboard) {
|
||||
const firstLink = menu.querySelector(".navbar__link");
|
||||
if (firstLink) firstLink.focus();
|
||||
}
|
||||
openedViaKeyboard = false;
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && nav.classList.contains("is-open")) {
|
||||
nav.classList.remove("is-open");
|
||||
toggle.setAttribute("aria-expanded", "false");
|
||||
toggle.setAttribute("aria-label", "Menü öffnen");
|
||||
toggle.focus();
|
||||
}
|
||||
});
|
||||
|
||||
navbar.addEventListener("focusout", (e) => {
|
||||
const leavingNavbar = !navbar.contains(e.relatedTarget);
|
||||
if (leavingNavbar && nav.classList.contains("is-open")) {
|
||||
nav.classList.remove("is-open");
|
||||
toggle.setAttribute("aria-expanded", "false");
|
||||
toggle.setAttribute("aria-label", "Menü öffnen");
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!navbar.contains(e.target) && nav.classList.contains("is-open")) {
|
||||
nav.classList.remove("is-open");
|
||||
toggle.setAttribute("aria-expanded", "false");
|
||||
toggle.setAttribute("aria-label", "Menü öffnen");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Markiert den Menüpunkt der aktuell geöffneten Seite mit aria-current="page",
|
||||
* damit er optisch hervorgehoben wird (siehe .navbar__link[aria-current="page"] in der CSS).
|
||||
*/
|
||||
export function markCurrentPage() {
|
||||
const links = document.querySelectorAll(".navbar__link");
|
||||
const current = window.location.pathname.replace(/\/index\.html$/, "/");
|
||||
links.forEach((link) => {
|
||||
if (link.getAttribute("href") === current) {
|
||||
link.setAttribute("aria-current", "page");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => initNavbar());
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import "../../styles/styles.css";
|
||||
import "../../styles/packages.css";
|
||||
import { renderAngebotsuebersicht } from "./angebotsuebersicht.js";
|
||||
|
||||
renderAngebotsuebersicht();
|
||||
|
|
|
|||
|
|
@ -1,21 +1,16 @@
|
|||
import { packages } from "../../shared/packages.js";
|
||||
|
||||
export function renderAngebotsuebersicht() {
|
||||
// Wir suchen den Container, der in der angebotsuebersicht.html definiert wurde
|
||||
const container = document.getElementById("packages-container");
|
||||
|
||||
// Sicherheitscheck: Wenn der Container nicht da ist, brechen wir ab
|
||||
if (!container) {
|
||||
console.warn("Container für Angebotsübersicht nicht gefunden.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Container leeren (falls die Funktion mehrfach aufgerufen wird)
|
||||
container.innerHTML = "";
|
||||
|
||||
// Durch jedes Paket iterieren und das HTML generieren
|
||||
packages.forEach((pkg) => {
|
||||
// Highlight-Badge nur anzeigen, wenn es eins gibt
|
||||
const highlightHtml = pkg.highlight
|
||||
? `<span class="package-highlight">${pkg.highlight}</span>`
|
||||
: "";
|
||||
|
|
@ -28,7 +23,7 @@ export function renderAngebotsuebersicht() {
|
|||
${highlightHtml}
|
||||
</div>
|
||||
|
||||
<h3 class="package-title">${pkg.name}</h3>
|
||||
<h2 class="package-title">${pkg.name}</h2>
|
||||
<p class="package-desc">${pkg.summary}</p>
|
||||
|
||||
<div class="package-details-box">
|
||||
|
|
@ -46,13 +41,11 @@ export function renderAngebotsuebersicht() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HIER ist der wichtige Link für deinen Kollegen: -->
|
||||
<a href="paket-details.html?id=${pkg.id}" class="package-btn">${pkg.buttonText}</a>
|
||||
<a href="/paket-details.html?id=${pkg.id}" class="package-btn">${pkg.buttonText} <span aria-hidden="true">→</span></a>
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Das generierte HTML dem Container hinzufügen
|
||||
container.innerHTML += cardHtml;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,132 +1,158 @@
|
|||
// src/features/angebote/paket-details.js
|
||||
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", async () => {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const container = document.getElementById("package-detail-container");
|
||||
if (!container) return;
|
||||
|
||||
// 1. ID aus der URL lesen (z.B. ?id=ernaehrung-standard)
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const packageId = params.get("id");
|
||||
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,
|
||||
);
|
||||
|
||||
// 2. Passendes Paket in packagesdetails.js suchen
|
||||
// (Später kann hier alternativ: await fetch(`/api/packages/${packageId}`) genutzt werden)
|
||||
const pkg = packagesDetails.find((item) => item.id === packageId);
|
||||
|
||||
if (!pkg) {
|
||||
if (!pkg || invalidVariant) {
|
||||
container.innerHTML = `
|
||||
<div class="package-card">
|
||||
<h2>Paket nicht gefunden</h2>
|
||||
<p>Bitte wähle ein gültiges Angebot aus der Übersicht.</p>
|
||||
<a href="angebotsuebersicht.html" class="package-btn">Zur Übersicht</a>
|
||||
</div>
|
||||
`;
|
||||
<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;
|
||||
}
|
||||
|
||||
// 3. Optional: Varianten-Auswahl rendern (für das Premium-Paket)
|
||||
const variantsHtml = pkg.variants
|
||||
document.title = `${pkg.type} – Tri-Hub Ernährungsberatung`;
|
||||
const variantsHtml = pkg.variants?.length
|
||||
? `<div class="detail-box">
|
||||
<h3>Wähle deine Variante</h3>
|
||||
<select id="variant-select" class="variant-select">
|
||||
${pkg.variants.map((v) => `<option value="${v.variantId}" data-price="${v.price}">${v.label} –${v.price.toFixed(2)} €</option>`).join("")}
|
||||
</select>
|
||||
</div>`
|
||||
<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>`
|
||||
: "";
|
||||
|
||||
// 4. Detailseite im Tri-Hub-Stil rendern
|
||||
container.innerHTML = `
|
||||
<div class="package-card detail-view">
|
||||
<div class="package-type-container">
|
||||
<span class="package-type">${pkg.type} · ${pkg.durationLabel}</span>
|
||||
<span class="package-highlight">${pkg.badge}</span>
|
||||
</div>
|
||||
|
||||
<h1 class="package-title">${pkg.title}</h1>
|
||||
<p class="package-desc">${pkg.subtitle}</p>
|
||||
|
||||
<div class="price-box">
|
||||
<span class="price-amount" id="displayed-price">${pkg.price.toFixed(2)} €</span>
|
||||
<span class="price-note">inkl. ${pkg.taxRate}% MwSt. (${pkg.billingInterval})</span>
|
||||
</div>
|
||||
|
||||
${variantsHtml}
|
||||
|
||||
<div class="package-details-box">
|
||||
<div class="detail-item">
|
||||
<h3 class="detail-title">Für wen ist dieses Paket ideal?</h3>
|
||||
<ul class="detail-list">
|
||||
${pkg.targetGroup.map((item) => `<li>${item}</li>`).join("")}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<h3 class="detail-title">Das ist im Paket enthalten</h3>
|
||||
<ul class="detail-list">
|
||||
${pkg.includedFeatures.map((feat) => `<li>${feat}</li>`).join("")}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<h3 class="detail-title">So läuft deine Begleitung ab</h3>
|
||||
${pkg.processSteps
|
||||
.map(
|
||||
(s) => `
|
||||
<div class="process-step">
|
||||
<strong>${s.step}:</strong> <span class="detail-text">${s.text}</span>
|
||||
<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>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</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>`;
|
||||
|
||||
<button id="proceed-to-booking-btn" class="package-btn primary-cta">
|
||||
${pkg.ctaButtonText}
|
||||
</button>
|
||||
</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;
|
||||
|
||||
// 5. Preis aktualisieren, falls eine andere Variante (24 vs. 52 Wochen) gewählt wird
|
||||
const variantSelect = document.getElementById("variant-select");
|
||||
const displayedPrice = document.getElementById("displayed-price");
|
||||
if (variantSelect && displayedPrice) {
|
||||
variantSelect.addEventListener("change", (e) => {
|
||||
const selectedOption = e.target.selectedOptions[0];
|
||||
const newPrice = parseFloat(
|
||||
selectedOption.getAttribute("data-price"),
|
||||
);
|
||||
displayedPrice.textContent = `${newPrice.toFixed(2)} €`;
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Klick auf "Jetzt buchen" -> Daten für bookingService bereitstellen & weiterleiten
|
||||
const bookingBtn = document.getElementById("proceed-to-booking-btn");
|
||||
// Booking lädt die Paketvorschau asynchron, auch bei einem erneuten Versuch.
|
||||
const observer = new MutationObserver(() => {
|
||||
const dialog = bookingRoot.querySelector(".booking__dialog");
|
||||
if (!dialog || dialog === activeDialog) return;
|
||||
activeDialog = dialog;
|
||||
bookingBtn.disabled = false;
|
||||
bookingBtn.textContent = "Jetzt buchen";
|
||||
bookingBtn.setAttribute("aria-controls", dialog.id);
|
||||
dialog.addEventListener("close", () => {
|
||||
bookingBtn.setAttribute("aria-expanded", "false");
|
||||
bookingBtn.focus();
|
||||
});
|
||||
});
|
||||
observer.observe(bookingRoot, { childList: true, subtree: true });
|
||||
prepareBooking();
|
||||
variantSelect?.addEventListener("change", prepareBooking);
|
||||
|
||||
bookingBtn.addEventListener("click", () => {
|
||||
const selectedVariantId = variantSelect ? variantSelect.value : pkg.id;
|
||||
const selectedVariant = pkg.variants
|
||||
? pkg.variants.find((v) => v.variantId === selectedVariantId)
|
||||
: null;
|
||||
|
||||
const bookingPayload = {
|
||||
packageId: pkg.id,
|
||||
variantId: selectedVariantId,
|
||||
title: pkg.title,
|
||||
type: pkg.type,
|
||||
durationWeeks: selectedVariant
|
||||
? selectedVariant.durationWeeks
|
||||
: pkg.durationWeeks,
|
||||
price: selectedVariant ? selectedVariant.price : pkg.price,
|
||||
currency: pkg.currency,
|
||||
taxRate: pkg.taxRate,
|
||||
summaryForBooking: pkg.summaryForBooking,
|
||||
};
|
||||
|
||||
// Im sessionStorage zwischenspeichern + per URL an die Buchungsseite übergeben
|
||||
sessionStorage.setItem(
|
||||
"selectedPackageForBooking",
|
||||
JSON.stringify(bookingPayload),
|
||||
if (!activeDialog || activeDialog.open) return;
|
||||
activeDialog.showModal();
|
||||
bookingBtn.setAttribute("aria-expanded", "true");
|
||||
const result = activeDialog.querySelector(
|
||||
".booking__result:not([hidden])",
|
||||
);
|
||||
window.location.href = `booking.html?id=${pkg.id}&variant=${selectedVariantId}`;
|
||||
(result || activeDialog.querySelector('[name="name"]')).focus();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[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;
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -33,10 +33,6 @@ async function request(path, options = {}) {
|
|||
return body;
|
||||
}
|
||||
|
||||
export function loadPackages(signal) {
|
||||
return request("/api/angebote", { signal });
|
||||
}
|
||||
|
||||
export function submitBooking(payload, idempotencyKey) {
|
||||
return request("/api/bookings", {
|
||||
method: "POST",
|
||||
|
|
@ -47,3 +43,12 @@ export function submitBooking(payload, idempotencyKey) {
|
|||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function previewBooking(packageId, variantId, signal) {
|
||||
return request("/api/bookings/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ packageId, variantId }),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -5,9 +5,12 @@ const root = document.querySelector("#booking-root");
|
|||
let cleanup;
|
||||
function render() {
|
||||
cleanup?.();
|
||||
// Eigenständige Testseite, bis Person 2 den gemeinsamen Router bereitstellt.
|
||||
const match = location.hash.match(/^#\/buchen\/([a-z0-9-]+)$/);
|
||||
cleanup = mountBookingPage(root, { packageId: match?.[1] });
|
||||
const params = new URLSearchParams(location.search);
|
||||
cleanup = mountBookingPage(root, {
|
||||
packageId: params.get("id") || match?.[1],
|
||||
variantId: params.get("variant") || undefined,
|
||||
});
|
||||
}
|
||||
window.addEventListener("hashchange", render);
|
||||
render();
|
||||
|
|
|
|||
|
|
@ -1,19 +1,32 @@
|
|||
import { loadPackages, submitBooking } from "./booking-api.js";
|
||||
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";
|
||||
|
||||
// Kann später vom gemeinsamen Router aufgerufen werden. cleanup entfernt
|
||||
// Listener und bricht das Laden beim Verlassen der Ansicht ab.
|
||||
export function mountBookingPage(root, { packageId } = {}) {
|
||||
// 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 = `
|
||||
<div class="booking">
|
||||
<a href="/angebotsuebersicht.html">Zur Angebotsseite</a>
|
||||
<h1>Paket buchen</h1>
|
||||
<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>
|
||||
</div>`;
|
||||
</section>`;
|
||||
const intro = root.querySelector(".booking__intro");
|
||||
const content = root.querySelector(".booking__content");
|
||||
|
||||
|
|
@ -24,20 +37,22 @@ export function mountBookingPage(root, { packageId } = {}) {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
const data = await loadPackages(signal);
|
||||
const selected = await previewBooking(packageId, variantId, signal);
|
||||
if (signal.aborted) return;
|
||||
const selected = data.packages.find(
|
||||
(entry) => entry.id === packageId,
|
||||
);
|
||||
if (!selected) {
|
||||
intro.textContent =
|
||||
"Dieses Paket ist nicht verfügbar. Bitte wähle ein Paket auf der Angebotsseite aus.";
|
||||
return;
|
||||
}
|
||||
intro.remove();
|
||||
renderForm(selected);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
if (
|
||||
[
|
||||
"UNKNOWN_PACKAGE",
|
||||
"INVALID_VARIANT",
|
||||
"INVALID_INPUT",
|
||||
].includes(error.code)
|
||||
) {
|
||||
intro.textContent = error.message;
|
||||
return;
|
||||
}
|
||||
intro.textContent =
|
||||
"Das Paket konnte nicht geladen werden. Bitte prüfe deine Verbindung und versuche es erneut.";
|
||||
const retry = document.createElement("button");
|
||||
|
|
@ -61,43 +76,123 @@ export function mountBookingPage(root, { packageId } = {}) {
|
|||
<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="booking-payment-heading">
|
||||
<h2 id="booking-payment-heading">Hinweis zum Buchungsablauf</h2>
|
||||
<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>
|
||||
<p>Alle Felder sind Pflichtfelder.</p>
|
||||
<div class="booking__errors" role="alert" tabindex="-1" hidden></div>
|
||||
<div class="booking__field">
|
||||
<label for="booking-name">Name (Pflichtfeld)</label>
|
||||
<input id="booking-name" name="name" type="text" autocomplete="name" required maxlength="120" aria-describedby="booking-name-error" />
|
||||
<p class="booking__field-error" id="booking-name-error"></p>
|
||||
<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="booking-email">E-Mail-Adresse (Pflichtfeld)</label>
|
||||
<input id="booking-email" name="email" type="email" autocomplete="email" inputmode="email" required maxlength="254" aria-describedby="booking-email-hint booking-email-error" />
|
||||
<p id="booking-email-hint">An diese Adresse senden wir deine Bestätigung.</p>
|
||||
<p class="booking__field-error" id="booking-email-error"></p>
|
||||
<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>
|
||||
<div class="booking__actions">
|
||||
<button class="booking__button booking__cancel" type="button">Abbrechen</button>
|
||||
<button class="booking__button" type="submit">Paket buchen</button>
|
||||
</div>
|
||||
<button class="booking__button" type="submit">Paket buchen</button>
|
||||
<p class="booking__progress" role="status"></p>
|
||||
</form>
|
||||
<div class="booking__result" role="status" tabindex="-1" hidden></div>`;
|
||||
content.querySelector(".booking__package-name").textContent =
|
||||
selected.name;
|
||||
content.querySelector(".booking__summary").textContent =
|
||||
selected.summary;
|
||||
<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 button = form.querySelector("button");
|
||||
const phone = form.elements.namedItem("phone");
|
||||
const button = form.querySelector('button[type="submit"]');
|
||||
const errors = form.querySelector(".booking__errors");
|
||||
const progress = form.querySelector(".booking__progress");
|
||||
const result = content.querySelector(".booking__result");
|
||||
const storageKey = `trihub.booking.${selected.id}`;
|
||||
const storageKey = `trihub.booking.${selected.packageId}${selected.variantId ? `.${selected.variantId}` : ""}`;
|
||||
let attempt = null;
|
||||
let busy = false;
|
||||
let finished = false;
|
||||
|
|
@ -105,14 +200,15 @@ export function mountBookingPage(root, { packageId } = {}) {
|
|||
function lockFields(locked) {
|
||||
name.readOnly = locked;
|
||||
email.readOnly = locked;
|
||||
phone.readOnly = locked;
|
||||
}
|
||||
function showError(message, fields = {}) {
|
||||
errors.textContent = message;
|
||||
errors.hidden = false;
|
||||
for (const field of [name, email]) {
|
||||
for (const field of [name, email, phone]) {
|
||||
const message = fields[field.name] || "";
|
||||
content.querySelector(
|
||||
`#booking-${field.name}-error`,
|
||||
`#${instanceId}-booking-${field.name}-error`,
|
||||
).textContent = message;
|
||||
field.setAttribute("aria-invalid", String(Boolean(message)));
|
||||
}
|
||||
|
|
@ -120,14 +216,15 @@ export function mountBookingPage(root, { packageId } = {}) {
|
|||
}
|
||||
function clearErrors() {
|
||||
errors.hidden = true;
|
||||
for (const field of [name, email]) {
|
||||
for (const field of [name, email, phone]) {
|
||||
field.removeAttribute("aria-invalid");
|
||||
content.querySelector(
|
||||
`#booking-${field.name}-error`,
|
||||
`#${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.",
|
||||
|
|
@ -150,10 +247,15 @@ export function mountBookingPage(root, { packageId } = {}) {
|
|||
|
||||
try {
|
||||
const stored = JSON.parse(sessionStorage.getItem(storageKey));
|
||||
if (stored?.key && stored.payload?.packageId === selected.id) {
|
||||
if (
|
||||
stored?.key &&
|
||||
stored.payload?.packageId === selected.packageId
|
||||
) {
|
||||
attempt = stored;
|
||||
if (stored.order) renderOrder(stored.order);
|
||||
name.value = stored.payload.name;
|
||||
email.value = stored.payload.email;
|
||||
phone.value = stored.payload.phone || "";
|
||||
lockFields(true);
|
||||
button.textContent = "Status prüfen / erneut versuchen";
|
||||
if (stored.result) showResult(stored.result, false);
|
||||
|
|
@ -185,6 +287,9 @@ export function mountBookingPage(root, { packageId } = {}) {
|
|||
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.",
|
||||
|
|
@ -194,10 +299,15 @@ export function mountBookingPage(root, { packageId } = {}) {
|
|||
}
|
||||
const nextAttempt = {
|
||||
key: crypto.randomUUID(),
|
||||
order: selected,
|
||||
payload: {
|
||||
packageId: selected.id,
|
||||
packageId: selected.packageId,
|
||||
...(selected.variantId
|
||||
? { variantId: selected.variantId }
|
||||
: {}),
|
||||
name: name.value,
|
||||
email: email.value,
|
||||
...(phone.value ? { phone: phone.value } : {}),
|
||||
},
|
||||
};
|
||||
try {
|
||||
|
|
@ -234,7 +344,26 @@ export function mountBookingPage(root, { packageId } = {}) {
|
|||
} catch {
|
||||
/* Der ursprüngliche Schlüssel bleibt erhalten. */
|
||||
}
|
||||
if (!signal.aborted) showResult(body);
|
||||
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.
|
||||
|
|
@ -242,6 +371,7 @@ export function mountBookingPage(root, { packageId } = {}) {
|
|||
[
|
||||
"INVALID_INPUT",
|
||||
"UNKNOWN_PACKAGE",
|
||||
"INVALID_VARIANT",
|
||||
"INVALID_KEY",
|
||||
].includes(error.code)
|
||||
) {
|
||||
|
|
@ -272,5 +402,9 @@ export function mountBookingPage(root, { packageId } = {}) {
|
|||
);
|
||||
}
|
||||
load();
|
||||
return () => controller.abort();
|
||||
return () => {
|
||||
controller.abort();
|
||||
root.querySelector(".booking__dialog")?.close();
|
||||
root.replaceChildren();
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,8 +53,8 @@
|
|||
border-style: dashed;
|
||||
}
|
||||
.booking__button {
|
||||
color: #f7faf7;
|
||||
background: #176448;
|
||||
color: #061619;
|
||||
background: #53d5cd;
|
||||
cursor: pointer;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
|
@ -69,7 +69,7 @@
|
|||
outline-offset: 4px;
|
||||
}
|
||||
.booking__button:focus-visible {
|
||||
outline-color: #18352b;
|
||||
outline-color: #53d5cd;
|
||||
}
|
||||
.booking__errors,
|
||||
.booking__field-error {
|
||||
|
|
@ -94,3 +94,99 @@
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
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>
|
||||
<p class="contact-status" role="status" aria-live="polite"></p>
|
||||
<button class="contact-submit" type="submit" disabled>Anfrage senden & Termin wählen</button>
|
||||
</form>`;
|
||||
dialog.querySelector("#contact-title").textContent =
|
||||
advisorDisplayName(advisor) || "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.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,
|
||||
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;
|
||||
}
|
||||
|
|
@ -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 }));
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
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))),
|
||||
});
|
||||
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";
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
@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);
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import "./styles/styles.css";
|
||||
import "./components/navigationsbar/navigation.js";
|
||||
|
||||
|
||||
document.querySelector("#year").textContent = new Date().getFullYear();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
bookingId,createdAt,packageId,packageName,name,email,emailStatus,idempotencyKey,requestHash,bookedPackage,customer,acceptedTerms
|
||||
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,,,
|
||||
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,,,
|
||||
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""}",,
|
||||
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""}",,
|
||||
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""}",,
|
||||
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""}",,
|
||||
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""}",,
|
||||
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""}",,
|
||||
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""}",,
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
createdAt,name,email,subject,message
|
||||
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"
|
||||
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"
|
||||
|
|
|
@ -1,17 +1,33 @@
|
|||
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, demoPackages } from "../shared/packages.js";
|
||||
import { createBookingService } from "./services/booking-service.js";
|
||||
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)),
|
||||
),
|
||||
details,
|
||||
sendConfirmation,
|
||||
sendContact,
|
||||
contactFormStorage = createContactFormStorage(
|
||||
fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)),
|
||||
),
|
||||
sendContactForm,
|
||||
} = {}) {
|
||||
const ids = new Set();
|
||||
for (const entry of catalog) {
|
||||
|
|
@ -34,22 +50,72 @@ export function createApp({
|
|||
}
|
||||
const service = createBookingService({
|
||||
storage,
|
||||
details,
|
||||
packages: catalog,
|
||||
sendConfirmation: sendConfirmation ?? createEmailService(),
|
||||
});
|
||||
const contact = createContactService({
|
||||
storage,
|
||||
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;
|
||||
// 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, testOnly }) => ({
|
||||
packages: catalog.map(({ id, name, summary }) => ({
|
||||
id,
|
||||
name,
|
||||
summary,
|
||||
testOnly: Boolean(testOnly),
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (path === "/api/bookings") {
|
||||
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, {
|
||||
|
|
@ -59,7 +125,9 @@ export function createApp({
|
|||
},
|
||||
});
|
||||
}
|
||||
return handleBooking(request, response, service);
|
||||
return handleBooking(request, response, service, {
|
||||
preview: path.endsWith("/preview"),
|
||||
});
|
||||
}
|
||||
// Ausschließlich API; keine Auslieferung von CSV oder Serverdateien.
|
||||
return json(response, 404, {
|
||||
|
|
@ -72,16 +140,9 @@ if (
|
|||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
const demo = process.env.BOOKING_DEMO === "true";
|
||||
if (demo && process.env.NODE_ENV === "production")
|
||||
throw new Error("Demo-Modus ist in Produktion gesperrt.");
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const server = createApp({
|
||||
catalog: demo ? [...packages, ...demoPackages] : packages,
|
||||
});
|
||||
const server = createApp();
|
||||
server.listen(port, process.env.HOST || "127.0.0.1", () => {
|
||||
console.log(
|
||||
`Buchungs-API auf Port ${port}${demo ? " (Testpaket aktiviert)" : ""}`,
|
||||
);
|
||||
console.log(`Buchungs-API auf Port ${port}`);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export function json(response, status, body) {
|
|||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
async function readJson(request) {
|
||||
export async function readJson(request) {
|
||||
if (
|
||||
request.headers["content-type"]?.split(";")[0].trim() !==
|
||||
"application/json"
|
||||
|
|
@ -43,9 +43,15 @@ async function readJson(request) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function handleBooking(request, response, service) {
|
||||
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"],
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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.",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,9 @@
|
|||
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 = {}) {
|
||||
|
|
@ -12,7 +17,7 @@ export class BookingError extends Error {
|
|||
const uuidPattern =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
// Übliche unquotierte E-Mail-Adressen; einzelne Domainlabels maximal 63 Zeichen.
|
||||
const emailPattern =
|
||||
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) {
|
||||
|
|
@ -30,6 +35,52 @@ function validateInput(input, key) {
|
|||
"Erwartet wird ein JSON-Objekt.",
|
||||
);
|
||||
}
|
||||
let customer;
|
||||
if (Object.hasOwn(input, "customer")) {
|
||||
if (
|
||||
!input.customer ||
|
||||
typeof input.customer !== "object" ||
|
||||
Array.isArray(input.customer) ||
|
||||
input.acceptedTerms !== true
|
||||
) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_INPUT",
|
||||
"Kundendaten und Zustimmung zu den Bedingungen sind erforderlich.",
|
||||
);
|
||||
}
|
||||
customer = {};
|
||||
for (const [field, max, required] of [
|
||||
["firstName", 60, true],
|
||||
["lastName", 60, true],
|
||||
["email", 254, true],
|
||||
["phone", 50, false],
|
||||
["ageGroup", 40, false],
|
||||
["notes", 2000, false],
|
||||
]) {
|
||||
const value = input.customer[field];
|
||||
if (value === undefined && !required) continue;
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value.length > max ||
|
||||
(required && !value.trim()) ||
|
||||
/[\x00-\x1f\x7f]/.test(value)
|
||||
) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_INPUT",
|
||||
"Bitte die Kundendaten prüfen.",
|
||||
{ [field]: "Ungültige Angabe." },
|
||||
);
|
||||
}
|
||||
customer[field] = value.trim();
|
||||
}
|
||||
input = {
|
||||
...input,
|
||||
name: `${customer.firstName} ${customer.lastName}`,
|
||||
email: customer.email,
|
||||
};
|
||||
}
|
||||
const fields = {};
|
||||
const result = {};
|
||||
for (const [field, max, message] of [
|
||||
|
|
@ -65,6 +116,14 @@ function validateInput(input, key) {
|
|||
) {
|
||||
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,
|
||||
|
|
@ -73,6 +132,24 @@ function validateInput(input, key) {
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
@ -101,6 +178,9 @@ function responseFor(record, replayed) {
|
|||
createdAt: record.createdAt,
|
||||
packageId: record.packageId,
|
||||
packageName: record.packageName,
|
||||
bookedPackage: record.bookedPackage || null,
|
||||
customerEmail: record.email,
|
||||
status: "CONFIRMED",
|
||||
saved: true,
|
||||
emailStatus,
|
||||
replayed,
|
||||
|
|
@ -108,8 +188,85 @@ function responseFor(record, replayed) {
|
|||
};
|
||||
}
|
||||
|
||||
export function createBookingService({ storage, sendConfirmation, packages }) {
|
||||
// Eine Warteschlange umfasst Lesen, Schreiben UND Versand. Nur ein Prozess!
|
||||
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);
|
||||
|
|
@ -149,13 +306,18 @@ export function createBookingService({ storage, sendConfirmation, packages }) {
|
|||
{ packageId: "Bitte ein verfügbares Paket auswählen." },
|
||||
);
|
||||
}
|
||||
const bookedPackage = preview(data);
|
||||
record = {
|
||||
bookingId: nextBookingId(records),
|
||||
createdAt: new Date().toISOString(),
|
||||
packageId: selected.id,
|
||||
packageName: selected.name,
|
||||
bookedPackage,
|
||||
customer: data.customer ?? null,
|
||||
acceptedTerms: data.acceptedTerms ?? null,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
phone: data.customer?.phone ?? data.phone ?? "",
|
||||
emailStatus: "pending",
|
||||
idempotencyKey: key,
|
||||
requestHash,
|
||||
|
|
@ -194,6 +356,8 @@ export function createBookingService({ storage, sendConfirmation, packages }) {
|
|||
return responseFor(record, replayed);
|
||||
}
|
||||
return {
|
||||
getPackage,
|
||||
preview,
|
||||
book(input, key) {
|
||||
const task = queue.then(() => processBooking(input, key));
|
||||
queue = task.catch(() => {});
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
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 = {};
|
||||
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" };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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 Kontaktanfragen-CSV.
|
||||
const columns = ["createdAt", "name", "email", "subject", "message"];
|
||||
|
||||
// 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) {
|
||||
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(",")) {
|
||||
throw new Error("Unbekanntes Kontakt-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 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
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, 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.",
|
||||
);
|
||||
}
|
||||
await sendContact({
|
||||
email,
|
||||
advisor,
|
||||
reason: contactReasons[input.reason],
|
||||
});
|
||||
return {
|
||||
simulated: true,
|
||||
emailStatus: "accepted",
|
||||
redirectUrl: contactBookingUrl,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
|
|||
import { parse } from "csv-parse/sync";
|
||||
import { stringify } from "csv-stringify/sync";
|
||||
|
||||
export const columns = [
|
||||
const legacyColumns = [
|
||||
"bookingId",
|
||||
"createdAt",
|
||||
"packageId",
|
||||
|
|
@ -16,6 +16,11 @@ export const columns = [
|
|||
"requestHash",
|
||||
];
|
||||
|
||||
const orderColumns = [...legacyColumns, "bookedPackage"];
|
||||
const customerColumns = [...orderColumns, "customer", "acceptedTerms"];
|
||||
export const columns = [...customerColumns, "phone"];
|
||||
const jsonColumns = ["bookedPackage", "customer", "acceptedTerms"];
|
||||
|
||||
// Apostroph-Präfix schützt Tabellenprogramme. Ein vorhandenes Apostroph wird
|
||||
// ebenfalls maskiert, damit Lesen/Schreiben die Originalwerte exakt erhält.
|
||||
function protectCell(value) {
|
||||
|
|
@ -41,7 +46,12 @@ export function createCsvStorage(filePath) {
|
|||
return parse(content, {
|
||||
bom: true,
|
||||
columns(header) {
|
||||
if (header.join(",") !== columns.join(",")) {
|
||||
if (
|
||||
header.join(",") !== columns.join(",") &&
|
||||
header.join(",") !== customerColumns.join(",") &&
|
||||
header.join(",") !== legacyColumns.join(",") &&
|
||||
header.join(",") !== orderColumns.join(",")
|
||||
) {
|
||||
throw new Error("Unbekanntes CSV-Format");
|
||||
}
|
||||
return header;
|
||||
|
|
@ -51,7 +61,11 @@ export function createCsvStorage(filePath) {
|
|||
Object.fromEntries(
|
||||
Object.entries(row).map(([key, value]) => [
|
||||
key,
|
||||
restoreCell(value),
|
||||
jsonColumns.includes(key)
|
||||
? value
|
||||
? JSON.parse(restoreCell(value))
|
||||
: null
|
||||
: restoreCell(value),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
|
@ -64,7 +78,12 @@ export function createCsvStorage(filePath) {
|
|||
Object.fromEntries(
|
||||
columns.map((column) => [
|
||||
column,
|
||||
protectCell(row[column]),
|
||||
protectCell(
|
||||
jsonColumns.includes(column) &&
|
||||
row[column] != null
|
||||
? JSON.stringify(row[column])
|
||||
: row[column],
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import nodemailer from "nodemailer";
|
||||
import { orderSummaryRows } from "../../shared/order-summary.js";
|
||||
import { paymentNotice } from "../../shared/booking-copy.js";
|
||||
|
||||
export function createEmailService(
|
||||
export function createMailTransport(
|
||||
env = process.env,
|
||||
makeTransport = nodemailer.createTransport,
|
||||
) {
|
||||
|
|
@ -33,24 +34,81 @@ export function createEmailService(
|
|||
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) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[char],
|
||||
);
|
||||
let info;
|
||||
try {
|
||||
info = await transport.sendMail({
|
||||
from,
|
||||
to: { address: booking.email, name: booking.name },
|
||||
subject: `Buchungsbestätigung ${booking.bookingId}`,
|
||||
text: [
|
||||
`Hallo ${booking.name},`,
|
||||
"",
|
||||
"vielen Dank für deine Buchung. Wir haben deine Angaben erhalten.",
|
||||
`Buchungsnummer: ${booking.bookingId}`,
|
||||
`Paket: ${booking.packageName}`,
|
||||
"",
|
||||
paymentNotice,
|
||||
"",
|
||||
"Dein Tri-Hub-Team",
|
||||
].join("\n"),
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "Tri-Hub Ernährungsberatung & Booking API",
|
||||
"description": "API-Spezifikation für die Angebotsübersicht (US2.1), die Paket-Detailansicht und den Buchungsservice (US2.4 Paket buchen).",
|
||||
"version": "1.0.0"
|
||||
"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": [
|
||||
{
|
||||
|
|
@ -78,7 +78,7 @@
|
|||
"/bookings/preview": {
|
||||
"post": {
|
||||
"summary": "Preis- und Leistungsübersicht für den Bestellprozess berechnen",
|
||||
"description": "Wird vom bookingService aufgerufen, um vor Abschluss der Buchung die relevanten Bestelldaten (Nettopreis, MwSt., Bruttopreis, gewählte Variante) validiert anzuzeigen.",
|
||||
"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,
|
||||
|
|
@ -117,7 +117,7 @@
|
|||
"/bookings": {
|
||||
"post": {
|
||||
"summary": "Paket verbindlich buchen (US2.4)",
|
||||
"description": "Nimmt die ausgewählten Paketdetails und die Kundendaten entgegen, speichert die Buchung im bookingService und gibt eine Bestellbestätigung zurück.",
|
||||
"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,
|
||||
|
|
@ -149,6 +149,360 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
],
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -222,7 +576,8 @@
|
|||
"price": {
|
||||
"type": "number",
|
||||
"format": "float",
|
||||
"example": 799.0
|
||||
"example": 799.0,
|
||||
"description": "Bruttopreis der Variante inklusive MwSt."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -270,7 +625,8 @@
|
|||
"price": {
|
||||
"type": "number",
|
||||
"format": "float",
|
||||
"example": 449.0
|
||||
"example": 449.0,
|
||||
"description": "Bruttopreis inklusive der angegebenen MwSt."
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
|
|
@ -351,7 +707,8 @@
|
|||
},
|
||||
"variantId": {
|
||||
"type": "string",
|
||||
"example": "ernaehrung-premium-52"
|
||||
"example": "ernaehrung-premium-52",
|
||||
"nullable": true
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
|
|
@ -382,12 +739,77 @@
|
|||
"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", "customer", "acceptedTerms"],
|
||||
"required": ["packageId"],
|
||||
"properties": {
|
||||
"packageId": {
|
||||
"type": "string",
|
||||
|
|
@ -396,7 +818,25 @@
|
|||
"variantId": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"example": "ernaehrung-standard"
|
||||
"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",
|
||||
|
|
@ -417,6 +857,8 @@
|
|||
},
|
||||
"phone": {
|
||||
"type": "string",
|
||||
"maxLength": 50,
|
||||
"description": "Optional. Deutsche Nummer mit Vorwahl oder internationale Nummer mit Ländervorwahl; keine Bestätigung der Erreichbarkeit.",
|
||||
"example": "+49 170 1234567"
|
||||
},
|
||||
"ageGroup": {
|
||||
|
|
@ -431,16 +873,26 @@
|
|||
},
|
||||
"acceptedTerms": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
"example": true,
|
||||
"enum": [true]
|
||||
}
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
{
|
||||
"required": ["customer", "acceptedTerms"]
|
||||
},
|
||||
{
|
||||
"required": ["name", "email"]
|
||||
}
|
||||
],
|
||||
"description": "Kundendaten gemäß customer mit acceptedTerms=true. Bestehende Clients können alternativ name und email sowie optional phone übergeben."
|
||||
},
|
||||
"BookingConfirmation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bookingId": {
|
||||
"type": "string",
|
||||
"example": "BK-2026-84920"
|
||||
"example": "TH-000001"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
|
|
@ -451,28 +903,77 @@
|
|||
"format": "date-time"
|
||||
},
|
||||
"bookedPackage": {
|
||||
"$ref": "#/components/schemas/BookingPreviewResponse"
|
||||
"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"
|
||||
},
|
||||
"confirmationMessage": {
|
||||
"packageId": {
|
||||
"type": "string"
|
||||
},
|
||||
"packageName": {
|
||||
"type": "string"
|
||||
},
|
||||
"saved": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"emailStatus": {
|
||||
"type": "string",
|
||||
"example": "Vielen Dank für deine Buchung! Deine Bestellbestätigung wurde per E-Mail versendet."
|
||||
"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": "string",
|
||||
"example": "Paket nicht gefunden"
|
||||
},
|
||||
"code": {
|
||||
"type": "integer",
|
||||
"example": 404
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -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() || "";
|
||||
}
|
||||
|
|
@ -1,4 +1,2 @@
|
|||
// Gemeinsamer Hinweis für Buchungsseite und Bestätigungs-E-Mail.
|
||||
// Bei der späteren Integration echter Zahlungen zusammen mit dem Ablauf ändern.
|
||||
export const paymentNotice =
|
||||
"Dieser Buchungsablauf ist eine Simulation. Der Zahlungsvorgang wird übersprungen: Es erfolgt keine Abbuchung und keine verbindliche Beratungsbuchung. Bei der vollständigen Integration des Bestellprozesses kann die Zahlung ergänzt werden.";
|
||||
"Dieser Buchungsablauf ist eine Simulation. Der Zahlungsvorgang wird übersprungen: Es erfolgt keine Abbuchung und keine verbindliche Beratungsbuchung.";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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)],
|
||||
];
|
||||
}
|
||||
|
|
@ -42,13 +42,3 @@ export const packages = [
|
|||
highlight: null,
|
||||
},
|
||||
];
|
||||
|
||||
// Nur für lokale Demonstrationen und isolierte Tests.
|
||||
export const demoPackages = [
|
||||
{
|
||||
id: "demo-booking",
|
||||
name: "1:1-Ernährungsberatung",
|
||||
summary: "Testpaket für den simulierten Buchungsablauf.",
|
||||
testOnly: true,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
/* Google Fonts: Inter für Fließtext, Playfair Display für Akzente (falls später gebraucht) */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=Inter:wght@400;500;600;700&display=swap");
|
||||
|
||||
/* Zentrale Design-Werte der Navbar, passend zum TriHub-Farbschema */
|
||||
:root {
|
||||
--nav-bg: #0d0d1a;
|
||||
--nav-border: rgba(248, 250, 252, 0.08);
|
||||
--nav-accent: #53d5cd;
|
||||
--nav-white: #ffffff;
|
||||
--nav-light: #f8fafc;
|
||||
--nav-muted: #94a3b8;
|
||||
}
|
||||
|
||||
/* Verhindert, dass Padding die Breite von Elementen zusätzlich vergrößert */
|
||||
.navbar,
|
||||
.navbar *,
|
||||
.navbar *::before,
|
||||
.navbar *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ---------- Kriterium 1: durchgehend fixiert am oberen Bildschirmrand ---------- */
|
||||
.navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
border-bottom: 1px solid var(--nav-border);
|
||||
background: var(--nav-bg);
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.navbar__inner {
|
||||
width: 100%;
|
||||
min-height: 96px;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ---------- Logo und Markenname als Sprunglink zum Hauptinhalt ---------- */
|
||||
.navbar__brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navbar__logo-img {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
padding: 5px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.navbar__brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.navbar__brand-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--nav-muted);
|
||||
}
|
||||
|
||||
.navbar__brand-tagline {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--nav-muted);
|
||||
}
|
||||
|
||||
/* ---------- Menüpunkte (rechts) ---------- */
|
||||
.navbar__menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: var(--nav-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navbar__link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
padding: 0.35rem 0.1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.navbar__link:hover,
|
||||
.navbar__link[aria-current="page"] {
|
||||
color: var(--nav-white);
|
||||
}
|
||||
|
||||
/* ---------- "Startseite"-Button mit Haus-Icon, ganz rechts ---------- */
|
||||
.navbar__link--home {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 18px 6px 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: 999px;
|
||||
color: var(--nav-light);
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
.navbar__link--home:hover {
|
||||
border-color: rgba(83, 213, 205, 0.55);
|
||||
color: var(--nav-white);
|
||||
}
|
||||
|
||||
.navbar__home-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--nav-accent);
|
||||
}
|
||||
|
||||
.navbar__home-icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Linienstärke des Haus-Icons zentral hier steuern statt im SVG-Markup */
|
||||
.navbar__home-icon svg path {
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.navbar a:focus-visible,
|
||||
.navbar button:focus-visible {
|
||||
outline: 3px solid var(--nav-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ---------- Mobiler Hamburger-Button ---------- */
|
||||
.navbar__toggle {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.navbar__toggle-icon,
|
||||
.navbar__toggle-icon::before,
|
||||
.navbar__toggle-icon::after {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 2px;
|
||||
background: var(--nav-white);
|
||||
position: relative;
|
||||
}
|
||||
.navbar__toggle-icon::before,
|
||||
.navbar__toggle-icon::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
}
|
||||
.navbar__toggle-icon::before {
|
||||
top: -7px;
|
||||
}
|
||||
.navbar__toggle-icon::after {
|
||||
top: 7px;
|
||||
}
|
||||
|
||||
@media (max-width: 1300px) {
|
||||
.navbar__toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.navbar__nav {
|
||||
position: absolute;
|
||||
top: 96px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--nav-bg);
|
||||
border-bottom: 1px solid var(--nav-border);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar__nav.is-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.navbar__menu {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 1rem 20px;
|
||||
gap: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.navbar__inner {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.navbar__brand-tagline {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Platz für die fixierte Navbar */
|
||||
body {
|
||||
padding-top: 96px;
|
||||
}
|
||||
|
|
@ -1,123 +1,279 @@
|
|||
:root {
|
||||
--bg-dark: #0d0d1a;
|
||||
--bg-card: #1a1a2e;
|
||||
--accent-teal: #10b981;
|
||||
--text-white: #ffffff;
|
||||
--text-lightgray: #f8fafc;
|
||||
--text-muted: #94a3b8;
|
||||
|
||||
--font-display: "Playfair Display", serif;
|
||||
--font-body: "Inter", system-ui, sans-serif;
|
||||
.packages-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 {
|
||||
background-color: var(--bg-dark);
|
||||
padding: 4rem 2rem;
|
||||
font-family: var(--font-body);
|
||||
color: var(--text-lightgray);
|
||||
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(auto-fit, minmax(min(100%, 300px), 1fr));
|
||||
gap: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
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 {
|
||||
background-color: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.package-type-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.package-type {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.package-highlight {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: var(--accent-teal);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.65rem;
|
||||
background: #53d5cd14;
|
||||
color: #75e2dc;
|
||||
border: 1px solid #53d5cd33;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.package-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.75rem;
|
||||
color: var(--text-white);
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.package-desc {
|
||||
color: var(--text-lightgray);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.package-details-box {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-grow: 1; /* Schiebt den Button immer ganz nach unten */
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.detail-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
color: var(--text-white);
|
||||
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;
|
||||
}
|
||||
|
||||
.detail-text {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
.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;
|
||||
}
|
||||
|
||||
.package-btn {
|
||||
display: block;
|
||||
text-align: center;
|
||||
background-color: transparent;
|
||||
color: var(--text-white);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.package-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-color: var(--accent-teal);
|
||||
color: var(--accent-teal);
|
||||
.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
|
|
@ -24,7 +24,6 @@ test("API liefert für jedes angezeigte Angebot dieselbe ID, Namen und Beschreib
|
|||
id,
|
||||
name,
|
||||
summary,
|
||||
testOnly: false,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -8,10 +8,10 @@ import { createApp } from "../server/index.js";
|
|||
import { createCsvStorage } from "../server/services/csv-storage.js";
|
||||
import { createBookingService } from "../server/services/booking-service.js";
|
||||
import { createEmailService } from "../server/services/email-service.js";
|
||||
import { demoPackages } from "../shared/packages.js";
|
||||
import { packages } from "../shared/packages.js";
|
||||
|
||||
const input = {
|
||||
packageId: demoPackages[0].id,
|
||||
packageId: packages[0].id,
|
||||
name: 'Test, "Person"',
|
||||
email: "person@example.test",
|
||||
};
|
||||
|
|
@ -27,7 +27,7 @@ async function setup(t, options = {}) {
|
|||
sent.push({ ...booking });
|
||||
});
|
||||
const dependencies = {
|
||||
catalog: demoPackages,
|
||||
catalog: packages,
|
||||
storage,
|
||||
sendConfirmation,
|
||||
...options,
|
||||
|
|
@ -63,7 +63,7 @@ test("Buchung steht vor Versand in CSV; Paketname kommt vom Server", async (t) =
|
|||
});
|
||||
assert.equal(result.status, 201);
|
||||
assert.equal(result.body.emailStatus, "accepted");
|
||||
assert.equal(result.body.packageName, demoPackages[0].name);
|
||||
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();
|
||||
|
|
@ -230,7 +230,7 @@ test("Ausstehender Versand kann nach Schreibfehler sicher fortgesetzt werden", a
|
|||
let writes = 0;
|
||||
let sends = 0;
|
||||
const service = createBookingService({
|
||||
packages: demoPackages,
|
||||
packages: packages,
|
||||
storage: {
|
||||
readAll: () => storage.readAll(),
|
||||
writeAll: async (rows) => {
|
||||
|
|
@ -257,7 +257,7 @@ test("Status-Schreibfehler nach SMTP bleibt unklar und löst keinen Doppelversan
|
|||
let writes = 0;
|
||||
let sends = 0;
|
||||
const service = createBookingService({
|
||||
packages: demoPackages,
|
||||
packages: packages,
|
||||
storage: {
|
||||
readAll: () => storage.readAll(),
|
||||
writeAll: async (rows) => {
|
||||
|
|
@ -274,7 +274,7 @@ test("Status-Schreibfehler nach SMTP bleibt unklar und löst keinen Doppelversan
|
|||
assert.equal((await storage.readAll())[0].emailStatus, "sending");
|
||||
const restarted = createBookingService({
|
||||
storage,
|
||||
packages: demoPackages,
|
||||
packages: packages,
|
||||
sendConfirmation: async () => {
|
||||
sends++;
|
||||
},
|
||||
|
|
@ -297,10 +297,10 @@ test("SMTP-Nachricht enthält Buchungsnummer und Paket; externe Hosts sind stand
|
|||
await send({
|
||||
...input,
|
||||
bookingId: "TH-000001",
|
||||
packageName: demoPackages[0].name,
|
||||
packageName: packages[0].name,
|
||||
});
|
||||
assert.ok(message.text.includes("TH-000001"));
|
||||
assert.ok(message.text.includes(demoPackages[0].name));
|
||||
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"));
|
||||
|
|
@ -368,16 +368,19 @@ test("HTTP-Vertrag: JSON, Größenlimit, Methoden und private Dateien", async (t
|
|||
413,
|
||||
);
|
||||
const catalog = await (await fetch(`${url}/api/angebote`)).json();
|
||||
assert.deepEqual(catalog.packages, demoPackages);
|
||||
assert.equal((await fetch(`${url}/api/packages`)).status, 404);
|
||||
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],
|
||||
[{ ...demoPackages[0], id: 123 }],
|
||||
[{ ...demoPackages[0], id: undefined }],
|
||||
[demoPackages[0], demoPackages[0]],
|
||||
[{ ...packages[0], id: 123 }],
|
||||
[{ ...packages[0], id: undefined }],
|
||||
[packages[0], packages[0]],
|
||||
]) {
|
||||
assert.throws(
|
||||
() => createApp({ catalog, sendConfirmation: async () => {} }),
|
||||
|
|
@ -393,7 +396,7 @@ test("Fortlaufende Nummern bleiben nach Neustart erhalten und Wiederholungen ver
|
|||
assert.equal((await post(input, key)).body.bookingId, "TH-000001");
|
||||
const restarted = createBookingService({
|
||||
storage,
|
||||
packages: demoPackages,
|
||||
packages: packages,
|
||||
sendConfirmation: async () => {},
|
||||
});
|
||||
assert.equal(
|
||||
|
|
@ -425,3 +428,235 @@ test("Nummernvergabe verwendet den höchsten CSV-Wert und wächst über sechs St
|
|||
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("<img src=x>"));
|
||||
assert.ok(!message.html.includes("<img src=x>"));
|
||||
});
|
||||
|
||||
test("Swagger-Kundendaten und Zustimmung werden validiert und dauerhaft gespeichert", async (t) => {
|
||||
const { post, storage, sent } = await setup(t, { catalog: packages });
|
||||
const customer = {
|
||||
firstName: "Thomas",
|
||||
lastName: "Müller",
|
||||
email: "thomas@example.test",
|
||||
phone: "+49 170 1234567",
|
||||
ageGroup: "50-59",
|
||||
notes: "Vorbereitung auf die Mitteldistanz.",
|
||||
};
|
||||
const request = {
|
||||
packageId: "ernaehrung-standard",
|
||||
customer,
|
||||
acceptedTerms: true,
|
||||
};
|
||||
for (const body of [
|
||||
{ ...request, acceptedTerms: false },
|
||||
{ ...request, acceptedTerms: "true" },
|
||||
{ ...request, customer: [] },
|
||||
{ ...request, customer: { ...customer, firstName: "" } },
|
||||
{ ...request, customer: { ...customer, email: "ungültig" } },
|
||||
{ ...request, customer: { ...customer, phone: 123 } },
|
||||
])
|
||||
assert.equal((await post(body)).status, 400);
|
||||
const key = randomUUID();
|
||||
const result = await post(request, key);
|
||||
assert.equal(result.status, 201);
|
||||
assert.equal(result.body.customerEmail, customer.email);
|
||||
const [record] = await storage.readAll();
|
||||
assert.equal(record.name, "Thomas Müller");
|
||||
assert.deepEqual(record.customer, customer);
|
||||
assert.equal(record.acceptedTerms, true);
|
||||
assert.deepEqual(sent[0].customer, customer);
|
||||
assert.equal((await post(request, key)).status, 200);
|
||||
assert.equal(
|
||||
(
|
||||
await post(
|
||||
{
|
||||
...request,
|
||||
customer: { ...customer, notes: "Anderer Auftrag" },
|
||||
},
|
||||
key,
|
||||
)
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
});
|
||||
|
||||
test("Telefonnummern werden in beiden Anfrageformaten geprüft", async (t) => {
|
||||
const { post, sent } = await setup(t);
|
||||
for (const phone of [
|
||||
"123",
|
||||
"00000000000",
|
||||
"abc",
|
||||
"+999 123456789",
|
||||
"0170 test 1234567",
|
||||
]) {
|
||||
for (const data of [
|
||||
{ ...input, phone },
|
||||
{
|
||||
packageId: input.packageId,
|
||||
acceptedTerms: true,
|
||||
customer: {
|
||||
firstName: "Test",
|
||||
lastName: "Person",
|
||||
email: input.email,
|
||||
phone,
|
||||
},
|
||||
},
|
||||
]) {
|
||||
const result = await post(data);
|
||||
assert.equal(result.status, 400, phone);
|
||||
assert.ok(result.body.error.fields.phone);
|
||||
}
|
||||
}
|
||||
assert.equal(sent.length, 0);
|
||||
for (const phone of [
|
||||
"",
|
||||
"0170 1234567",
|
||||
"+49 170 1234567",
|
||||
"0049 170 1234567",
|
||||
"030 123456",
|
||||
"+44 20 7946 0958",
|
||||
]) {
|
||||
const result = await post({ ...input, phone });
|
||||
assert.equal(result.status, 201, phone);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
|
||||
import { packages } from "../../shared/packages.js";
|
||||
import { packagesDetails } from "../../shared/packagesdetails.js";
|
||||
|
||||
const pageUrl = "/booking.html#/buchen/demo-booking";
|
||||
const pageUrl = "/booking.html#/buchen/ernaehrung-starter";
|
||||
async function fill(page) {
|
||||
await page
|
||||
.getByLabel("Name (Pflichtfeld)", { exact: true })
|
||||
|
|
@ -17,7 +18,7 @@ test("Tastaturbedienung, Validierung und vollständige Buchung über Vite-Proxy"
|
|||
}) => {
|
||||
await page.goto(pageUrl);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "1:1-Ernährungsberatung" }),
|
||||
page.getByRole("heading", { name: packagesDetails[0].title }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".booking__payment-notice")).toContainText(
|
||||
"Zahlungsvorgang wird übersprungen",
|
||||
|
|
@ -191,12 +192,22 @@ for (const pkg of packages) {
|
|||
);
|
||||
await page.getByRole("link", { name: pkg.buttonText }).click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`booking\\.html#/buchen/${pkg.id}$`),
|
||||
new RegExp(`paket-details\\.html\\?id=${pkg.id}$`),
|
||||
);
|
||||
await page.locator("#proceed-to-booking-btn").click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`booking\\.html\\?id=${pkg.id}`),
|
||||
);
|
||||
const details = packagesDetails.find((entry) => entry.id === pkg.id);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: pkg.name, exact: true }),
|
||||
page.getByRole("heading", { name: details.title, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".booking__summary")).toHaveText(pkg.summary);
|
||||
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) =>
|
||||
|
|
@ -231,3 +242,53 @@ test("Angebotsübersicht passt auf ein schmales Display", async ({ page }) => {
|
|||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("Premium-Jahresvariante wird von der Detailseite bis zur Bestätigung übernommen", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/paket-details.html?id=ernaehrung-premium");
|
||||
await page.locator("#variant-select").selectOption("ernaehrung-premium-52");
|
||||
await page.locator("#proceed-to-booking-btn").click();
|
||||
await expect(page).toHaveURL(
|
||||
/booking\.html\?id=ernaehrung-premium&variant=ernaehrung-premium-52$/,
|
||||
);
|
||||
await expect(page.locator(".booking__totals")).toContainText(
|
||||
"52 Wochen Begleitung",
|
||||
);
|
||||
await expect(page.locator(".booking__totals")).toContainText("1.399,00");
|
||||
await page.reload();
|
||||
await fill(page);
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith("/api/bookings") &&
|
||||
response.request().method() === "POST",
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "Paket buchen", exact: true })
|
||||
.click();
|
||||
const response = await responsePromise;
|
||||
expect(response.request().postDataJSON().variantId).toBe(
|
||||
"ernaehrung-premium-52",
|
||||
);
|
||||
const booking = await response.json();
|
||||
expect(booking.bookedPackage.grossPrice).toBe(1399);
|
||||
expect(booking.bookedPackage.durationWeeks).toBe(52);
|
||||
await expect(page.locator(".booking__result")).toContainText(
|
||||
booking.bookingId,
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page.locator(".booking__totals")).toContainText("1.399,00");
|
||||
await expect(page.locator(".booking__result")).toContainText(
|
||||
booking.bookingId,
|
||||
);
|
||||
});
|
||||
|
||||
test("Paketfremde Variante zeigt kein Buchungsformular", async ({ page }) => {
|
||||
await page.goto(
|
||||
"/booking.html?id=ernaehrung-starter&variant=ernaehrung-premium-52",
|
||||
);
|
||||
await expect(
|
||||
page.getByText("Diese Variante gehört nicht zum ausgewählten Paket."),
|
||||
).toBeVisible();
|
||||
await expect(page.locator("form")).toHaveCount(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
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: { 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.getByRole("checkbox").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.getByRole("checkbox").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();
|
||||
});
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
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 { createApp } from "../server/index.js";
|
||||
import { createContactFormEmailService } from "../server/services/contact-form-email.js";
|
||||
import { createContactFormStorage } from "../server/services/contact-form-storage.js";
|
||||
|
||||
const validInput = {
|
||||
name: "Test Person",
|
||||
email: "person@example.test",
|
||||
subject: "Eine Frage",
|
||||
message: "Das ist eine ausreichend lange Nachricht.",
|
||||
};
|
||||
|
||||
// Startet die API mit temporärer Kontakt-CSV und einem ersetzbaren Mailversand.
|
||||
async function fixture(t, options = {}) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "trihub-contact-form-"));
|
||||
const storage =
|
||||
options.storage ??
|
||||
createContactFormStorage(join(directory, "contact-requests.csv"));
|
||||
const sent = [];
|
||||
const app = createApp({
|
||||
sendConfirmation: async () => {},
|
||||
sendContact: async () => {},
|
||||
contactFormStorage: storage,
|
||||
sendContactForm: async (data) => sent.push(data),
|
||||
...options,
|
||||
});
|
||||
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 base = `http://127.0.0.1:${app.address().port}`;
|
||||
async function post(input = validInput, headers = {}) {
|
||||
const response = await fetch(`${base}/api/nutrition-contact`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return { status: response.status, body: await response.json() };
|
||||
}
|
||||
return { app, base, post, sent, storage, directory };
|
||||
}
|
||||
|
||||
// Prüft den erfolgreichen Ablauf von HTTP-Anfrage über CSV bis zur Mail-Abhängigkeit.
|
||||
test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", async (t) => {
|
||||
const f = await fixture(t);
|
||||
const result = await f.post();
|
||||
|
||||
assert.equal(result.status, 201);
|
||||
assert.deepEqual(result.body, { saved: true, emailStatus: "accepted" });
|
||||
assert.equal(f.sent.length, 1);
|
||||
assert.deepEqual(
|
||||
{
|
||||
name: f.sent[0].name,
|
||||
email: f.sent[0].email,
|
||||
subject: f.sent[0].subject,
|
||||
message: f.sent[0].message,
|
||||
},
|
||||
validInput,
|
||||
);
|
||||
assert.ok(!Number.isNaN(Date.parse(f.sent[0].createdAt)));
|
||||
assert.deepEqual(await f.storage.readAll(), f.sent);
|
||||
});
|
||||
|
||||
// Stellt sicher, dass fehlerhafte Pflichtfelder nicht gespeichert oder versendet werden.
|
||||
test("Pflichtfelder, E-Mail, Längen und Mindestlänge werden validiert", async (t) => {
|
||||
const f = await fixture(t);
|
||||
const invalidInputs = [
|
||||
{ ...validInput, name: " " },
|
||||
{ ...validInput, name: "Test\nBcc: fremd" },
|
||||
{ ...validInput, name: "x".repeat(121) },
|
||||
{ ...validInput, email: "keine-adresse" },
|
||||
{ ...validInput, email: "x".repeat(255) },
|
||||
{ ...validInput, subject: "x".repeat(161) },
|
||||
{ ...validInput, message: "zu kurz" },
|
||||
{ ...validInput, message: "x".repeat(5001) },
|
||||
{ ...validInput, message: "Nachricht\u0000ungültig" },
|
||||
{ ...validInput, message: undefined },
|
||||
null,
|
||||
[],
|
||||
];
|
||||
|
||||
for (const input of invalidInputs) {
|
||||
const result = await f.post(input);
|
||||
assert.equal(result.status, 400, JSON.stringify(input));
|
||||
}
|
||||
assert.deepEqual(await f.storage.readAll(), []);
|
||||
assert.equal(f.sent.length, 0);
|
||||
});
|
||||
|
||||
// Prüft die Begrenzungen und JSON-Fehlerbehandlung des HTTP-Endpunkts.
|
||||
test("HTTP-Route lehnt falsche Methode, Inhaltstyp, JSON und große Bodies ab", async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal((await fetch(`${f.base}/api/nutrition-contact`)).status, 405);
|
||||
|
||||
const wrongType = await fetch(`${f.base}/api/nutrition-contact`, {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
assert.equal(wrongType.status, 415);
|
||||
|
||||
const invalidJson = await fetch(`${f.base}/api/nutrition-contact`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{",
|
||||
});
|
||||
assert.equal(invalidJson.status, 400);
|
||||
|
||||
const tooLarge = await fetch(`${f.base}/api/nutrition-contact`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "x".repeat(9000) }),
|
||||
});
|
||||
assert.equal(tooLarge.status, 413);
|
||||
assert.deepEqual(await f.storage.readAll(), []);
|
||||
});
|
||||
|
||||
// Ein Speicherfehler muss verhindern, dass anschließend E-Mails abgeschickt werden.
|
||||
test("Speicherfehler melden 503 und starten keinen Mailversand", async (t) => {
|
||||
let sendCount = 0;
|
||||
const f = await fixture(t, {
|
||||
storage: {
|
||||
async append() {
|
||||
throw new Error("Datenträger nicht verfügbar");
|
||||
},
|
||||
},
|
||||
sendContactForm: async () => sendCount++,
|
||||
});
|
||||
const result = await f.post();
|
||||
|
||||
assert.equal(result.status, 503);
|
||||
assert.equal(result.body.error.code, "CONTACT_NOT_SAVED");
|
||||
assert.equal(sendCount, 0);
|
||||
});
|
||||
|
||||
// Nach einem SMTP-Fehler bleibt die bereits gespeicherte Anfrage erhalten.
|
||||
test("Mailfehler melden 502, erhalten aber die gespeicherte Anfrage", async (t) => {
|
||||
const f = await fixture(t, {
|
||||
sendContactForm: async () => {
|
||||
throw new Error("Mailpit nicht erreichbar");
|
||||
},
|
||||
});
|
||||
const result = await f.post();
|
||||
|
||||
assert.equal(result.status, 502);
|
||||
assert.equal(result.body.error.code, "CONTACT_EMAIL_FAILED");
|
||||
assert.equal(result.body.error.message.includes("gespeichert"), true);
|
||||
assert.equal((await f.storage.readAll()).length, 1);
|
||||
});
|
||||
|
||||
// Prüft CSV-Rundlauf, Formelschutz und gleichzeitiges Anhängen mehrerer Anfragen.
|
||||
test("CSV erhält Sonderzeichen und verliert bei parallelen Anhängen keine Zeilen", async (t) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "trihub-contact-csv-"));
|
||||
const file = join(directory, "contact-requests.csv");
|
||||
const storage = createContactFormStorage(file);
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const names = [
|
||||
'=HYPERLINK("evil")',
|
||||
'Test, "Person"',
|
||||
"Zeile 1\nZeile 2",
|
||||
"'Original",
|
||||
...Array.from({ length: 8 }, (_, index) => `Person ${index}`),
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
names.map((name, index) =>
|
||||
storage.append({
|
||||
createdAt: `2026-01-01T00:00:0${index}.000Z`,
|
||||
name,
|
||||
email: `person${index}@example.test`,
|
||||
subject: "CSV Test",
|
||||
message: "Eine Nachricht mit mehr als zehn Zeichen.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const records = await storage.readAll();
|
||||
assert.equal(records.length, names.length);
|
||||
assert.deepEqual(
|
||||
records.map((record) => record.name).sort(),
|
||||
[...names].sort(),
|
||||
);
|
||||
const csv = await readFile(file, "utf8");
|
||||
assert.match(csv, /'=HYPERLINK/);
|
||||
assert.match(csv, /"Test, ""Person"""/);
|
||||
});
|
||||
|
||||
// Beschädigte CSV-Kopfzeilen sollen fehlschlagen und unangetastet bleiben.
|
||||
test("CSV mit unerwartetem Kopf wird nicht überschrieben", async (t) => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), "trihub-contact-csv-invalid-"),
|
||||
);
|
||||
const file = join(directory, "contact-requests.csv");
|
||||
const storage = createContactFormStorage(file);
|
||||
const corrupt = "wrong,header\n1,2\n";
|
||||
await writeFile(file, corrupt);
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
|
||||
await assert.rejects(
|
||||
storage.append({ ...validInput, createdAt: "2026-01-01" }),
|
||||
);
|
||||
assert.equal(await readFile(file, "utf8"), corrupt);
|
||||
});
|
||||
|
||||
// Verifiziert die zwei Mailvorlagen und dass der Transport fest bei Mailpit bleibt.
|
||||
test("Mailservice verwendet Mailpit und sendet Bestätigung sowie Tri-Hub-Anfrage", async () => {
|
||||
const config = [];
|
||||
const messages = [];
|
||||
const sendContactForm = createContactFormEmailService(
|
||||
{},
|
||||
(transportConfig) => {
|
||||
config.push(transportConfig);
|
||||
return {
|
||||
async sendMail(message) {
|
||||
messages.push(message);
|
||||
return { accepted: [message.to] };
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
await sendContactForm({
|
||||
...validInput,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
assert.equal(config[0].host, "127.0.0.1");
|
||||
assert.equal(config[0].port, 1025);
|
||||
assert.deepEqual(
|
||||
messages.map((message) => message.to).sort(),
|
||||
[validInput.email, "ernaehrung@tri-hub.de"].sort(),
|
||||
);
|
||||
assert.ok(
|
||||
messages.every(
|
||||
(message) =>
|
||||
message.from === "Tri-Hub Ernährung <noreply@tri-hub.de>",
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
messages.every((message) => message.text.includes(validInput.message)),
|
||||
);
|
||||
const request = messages.find(
|
||||
(message) => message.to === "ernaehrung@tri-hub.de",
|
||||
);
|
||||
assert.equal(request.replyTo, validInput.email);
|
||||
});
|
||||
|
||||
// SMTP-Ablehnung wird als Fehler sichtbar, statt fälschlich Erfolg zu melden.
|
||||
test("Mailservice meldet, wenn Mailpit einen Empfänger ablehnt", async () => {
|
||||
const sendContactForm = createContactFormEmailService({}, () => ({
|
||||
async sendMail(message) {
|
||||
return {
|
||||
accepted: message.to === validInput.email ? [message.to] : [],
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
await assert.rejects(
|
||||
sendContactForm(validInput),
|
||||
(error) =>
|
||||
error.status === 502 && error.code === "CONTACT_EMAIL_FAILED",
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createApp } from "../server/index.js";
|
||||
import { createCsvStorage } from "../server/services/csv-storage.js";
|
||||
import { createContactEmailService } from "../server/services/contact-email.js";
|
||||
import { contactBookingUrl } from "../shared/contact.js";
|
||||
|
||||
async function fixture(t, options = {}) {
|
||||
const dir = await mkdtemp(join(tmpdir(), "trihub-contact-"));
|
||||
const storage = createCsvStorage(join(dir, "bookings.csv"));
|
||||
await storage.writeAll([
|
||||
{
|
||||
bookingId: "TH-000001",
|
||||
email: "kunde@example.test",
|
||||
name: "Testkunde",
|
||||
},
|
||||
]);
|
||||
const sent = [];
|
||||
const app = createApp({
|
||||
storage,
|
||||
sendConfirmation: async () => {},
|
||||
sendContact: async (data) => sent.push(data),
|
||||
...options,
|
||||
});
|
||||
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(dir, { recursive: true, force: true });
|
||||
});
|
||||
const base = `http://127.0.0.1:${app.address().port}`;
|
||||
const post = async (path, input) => {
|
||||
const response = await fetch(base + path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return { status: response.status, body: await response.json() };
|
||||
};
|
||||
return { post, sent, storage, base };
|
||||
}
|
||||
const input = {
|
||||
email: "kunde@example.test",
|
||||
advisorId: "relindis-agethen",
|
||||
reason: "coaching",
|
||||
simulationAccepted: true,
|
||||
};
|
||||
|
||||
test("CSV-Abgleich normalisiert E-Mail; Versand erhält Berater und festen Redirect", async (t) => {
|
||||
const f = await fixture(t);
|
||||
const before = await f.storage.readAll();
|
||||
const validation = await f.post("/api/contact/validate", {
|
||||
email: " KUNDE@example.test ",
|
||||
});
|
||||
assert.equal(validation.status, 200);
|
||||
assert.equal(validation.body.email, input.email);
|
||||
const result = await f.post("/api/contact", {
|
||||
...input,
|
||||
advisor: "Manipuliert",
|
||||
advisorEmail: "falsch@example.test",
|
||||
});
|
||||
assert.equal(result.status, 201);
|
||||
assert.equal(result.body.redirectUrl, contactBookingUrl);
|
||||
assert.equal(result.body.simulated, true);
|
||||
assert.equal(f.sent[0].advisor.email, "relindis.agethen@tri-hub.de");
|
||||
assert.equal(f.sent[0].advisor.vorname, "Relindis");
|
||||
const second = await f.post("/api/contact", {
|
||||
...input,
|
||||
advisorId: "maren-hoffmann",
|
||||
});
|
||||
assert.equal(second.status, 201);
|
||||
assert.equal(f.sent[1].advisor.email, "maren.hoffmann@tri-hub.de");
|
||||
assert.deepEqual(await f.storage.readAll(), before);
|
||||
});
|
||||
|
||||
test("Ungültige und unbekannte Kunden, manipulierte Berater, Gründe und fehlende Zustimmung senden nichts", async (t) => {
|
||||
const f = await fixture(t);
|
||||
for (const [data, status] of [
|
||||
[null, 400],
|
||||
[{ ...input, email: "bad" }, 400],
|
||||
[{ ...input, email: "unknown@example.test" }, 403],
|
||||
[{ ...input, advisorId: "unbekannt" }, 400],
|
||||
[{ ...input, advisorId: null }, 400],
|
||||
[{ ...input, reason: "__proto__" }, 400],
|
||||
[{ ...input, simulationAccepted: false }, 400],
|
||||
])
|
||||
assert.equal((await f.post("/api/contact", data)).status, status);
|
||||
assert.equal(f.sent.length, 0);
|
||||
});
|
||||
|
||||
test("Absenden prüft CSV erneut; Speicherfehler bleiben geschlossen", async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal((await f.post("/api/contact/validate", input)).status, 200);
|
||||
await f.storage.writeAll([]);
|
||||
assert.equal((await f.post("/api/contact", input)).status, 403);
|
||||
const broken = await fixture(t, {
|
||||
storage: {
|
||||
readAll() {
|
||||
throw new Error("unavailable");
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal((await broken.post("/api/contact", input)).status, 503);
|
||||
assert.equal(broken.sent.length, 0);
|
||||
});
|
||||
|
||||
test("HTTP-Vertrag weist falsche Methode, ungültiges JSON und große Requests zurück", async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal((await fetch(f.base + "/api/contact")).status, 405);
|
||||
assert.equal(
|
||||
(await fetch(f.base + "/api/contact", { method: "POST", body: "x" }))
|
||||
.status,
|
||||
415,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await fetch(f.base + "/api/contact", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{",
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.post("/api/contact", { email: "x".repeat(9000) })).status,
|
||||
413,
|
||||
);
|
||||
});
|
||||
|
||||
test("Zwei getrennte simulierte Bestätigungen; Teilfehler verhindern Erfolg", async (t) => {
|
||||
const messages = [];
|
||||
const sender = createContactEmailService((config) => {
|
||||
assert.equal(config.host, "127.0.0.1");
|
||||
assert.equal(config.port, 1025);
|
||||
return {
|
||||
async sendMail(message) {
|
||||
messages.push(message);
|
||||
return { accepted: [message.to] };
|
||||
},
|
||||
};
|
||||
});
|
||||
const f = await fixture(t, { sendContact: sender });
|
||||
assert.equal((await f.post("/api/contact", input)).status, 201);
|
||||
assert.deepEqual(
|
||||
messages.map((m) => m.to),
|
||||
[input.email, "relindis.agethen@tri-hub.de"],
|
||||
);
|
||||
for (const message of messages) {
|
||||
assert.match(message.text, /Simulation/);
|
||||
assert.match(message.text, /Persönliches Coaching/);
|
||||
}
|
||||
const failing = createContactEmailService(() => ({
|
||||
async sendMail(message) {
|
||||
return { accepted: message.to === input.email ? [message.to] : [] };
|
||||
},
|
||||
}));
|
||||
const failure = await fixture(t, { sendContact: failing });
|
||||
const result = await failure.post("/api/contact", input);
|
||||
assert.equal(result.status, 502);
|
||||
assert.equal(result.body.redirectUrl, undefined);
|
||||
});
|
||||
|
||||
test("Anzeigename bevorzugt Spitzname, sonst Vorname, ohne Nachnamen", async () => {
|
||||
const { advisorDisplayName } = await import("../shared/berater.js");
|
||||
assert.equal(
|
||||
advisorDisplayName({
|
||||
spitzname: " Lilli ",
|
||||
vorname: "Relindis",
|
||||
nachname: "Agethen",
|
||||
}),
|
||||
"Lilli",
|
||||
);
|
||||
assert.equal(
|
||||
advisorDisplayName({
|
||||
spitzname: " ",
|
||||
vorname: "Maren",
|
||||
nachname: "Hoffmann",
|
||||
}),
|
||||
"Maren",
|
||||
);
|
||||
assert.equal(
|
||||
advisorDisplayName({ vorname: "Maren", nachname: "Hoffmann" }),
|
||||
"Maren",
|
||||
);
|
||||
});
|
||||
|
|
@ -4,13 +4,14 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { createApp } from "../../server/index.js";
|
||||
import { createCsvStorage } from "../../server/services/csv-storage.js";
|
||||
import { packages, demoPackages } from "../../shared/packages.js";
|
||||
import { packages } from "../../shared/packages.js";
|
||||
|
||||
const directory = await mkdtemp(join(tmpdir(), "trihub-browser-"));
|
||||
const server = createApp({
|
||||
catalog: [...packages, ...demoPackages],
|
||||
catalog: packages,
|
||||
storage: createCsvStorage(join(directory, "bookings.csv")),
|
||||
sendConfirmation: async () => {},
|
||||
sendContact: async () => {},
|
||||
});
|
||||
server.listen(3000, "127.0.0.1");
|
||||
async function stop() {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { packages, demoPackages } from "../shared/packages.js";
|
||||
import { packages } from "../shared/packages.js";
|
||||
import { createApp } from "../server/index.js";
|
||||
|
||||
test("Angebote erfüllen den gemeinsamen Vertrag von Übersicht und Buchung", () => {
|
||||
assert.ok(packages.length > 0);
|
||||
assert.doesNotThrow(() =>
|
||||
createApp({
|
||||
catalog: [...packages, ...demoPackages],
|
||||
catalog: packages,
|
||||
sendConfirmation: async () => {},
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
|
|
@ -19,13 +19,25 @@ export default defineConfig({
|
|||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
contactTest: fileURLToPath(
|
||||
new URL("./contact-test.html", import.meta.url),
|
||||
),
|
||||
angebote: fileURLToPath(
|
||||
new URL("./angebotsuebersicht.html", import.meta.url),
|
||||
),
|
||||
details: fileURLToPath(
|
||||
new URL("./paket-details.html", import.meta.url),
|
||||
),
|
||||
main: fileURLToPath(new URL("./index.html", import.meta.url)),
|
||||
bookingConfirmation: fileURLToPath(
|
||||
new URL("./booking-confirmation.html", import.meta.url),
|
||||
),
|
||||
booking: fileURLToPath(
|
||||
new URL("./booking.html", import.meta.url),
|
||||
),
|
||||
berater: fileURLToPath(
|
||||
new URL("./berater.html", import.meta.url),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue