dev #12
|
|
@ -1,15 +1,16 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.log
|
||||
|
||||
# Private Buchungsdaten und lokale Testergebnisse
|
||||
src/server/data/*
|
||||
!src/server/data/.gitkeep
|
||||
!src/server/data/contact-requests.csv
|
||||
playwright-report/
|
||||
test-results/
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.log
|
||||
|
||||
# Private Buchungsdaten und lokale Testergebnisse
|
||||
src/server/data/*
|
||||
!src/server/data/.gitkeep
|
||||
!src/server/data/contact-requests.csv
|
||||
!src/server/data/reviews.csv
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
|
|
|||
130
README.md
130
README.md
|
|
@ -1,65 +1,65 @@
|
|||
# Tri-Hub Ernährungsberatung
|
||||
|
||||
## Im Dev Container starten
|
||||
|
||||
1. Repository klonen und in VS Code „Dev Containers: Reopen in Container“ wählen.
|
||||
Bei einem bereits vorhandenen Container einmal „Dev Containers: Rebuild Container“
|
||||
ausführen, damit Mailpit installiert wird.
|
||||
2. Die Einrichtung installiert automatisch die npm-Abhängigkeiten und erstellt
|
||||
`.env` aus `.env.example`, sofern noch keine `.env` vorhanden ist.
|
||||
3. In drei getrennten Terminals in dieser Reihenfolge starten:
|
||||
|
||||
```bash
|
||||
npm run dev:mail
|
||||
```
|
||||
|
||||
```bash
|
||||
npm run dev:server
|
||||
```
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Startseite: http://localhost:5173 — über „Beratung kennenlernen“ zur
|
||||
Angebotsübersicht und von dort zum Buchungsformular des gewählten Pakets.
|
||||
Direkt: http://localhost:5173/angebotsuebersicht.html
|
||||
Mailansicht: http://localhost:8025. Beide Web-Ports werden vom Dev Container
|
||||
weitergeleitet. Mailpit läuft im selben Container auf SMTP-Port 1025 und fängt
|
||||
Testmails lokal ab, ohne sie extern zu versenden.
|
||||
|
||||
Die Container-Konfiguration installiert Mailpit v1.31.2 aus dem
|
||||
[offiziellen Docker-Image](https://mailpit.axllent.org/docs/install/docker/).
|
||||
Die Binärdatei, lokale Mails, `.env` und Buchungsdaten werden nicht committed.
|
||||
Mailpit verwendet ohne Datenbankpfad einen temporären Speicher; Testmails müssen
|
||||
nach einem Neustart nicht erhalten bleiben.
|
||||
|
||||
Ohne Dev Container: Node.js 24 und Mailpit installieren, `npm ci` ausführen und
|
||||
`.env.example` nach `.env` kopieren, falls `.env` fehlt. Danach dieselben drei
|
||||
Startbefehle verwenden.
|
||||
|
||||
Ohne Mailpit wird die Buchung gespeichert, aber der fehlgeschlagene Mailversand
|
||||
angezeigt. Der Ablauf bleibt eine Simulation ohne Zahlung oder verbindliche
|
||||
Beratung. Buchungen liegen lokal in `src/server/data/bookings.csv`. Diese Datei
|
||||
nicht vorab leer anlegen: Das Backend erzeugt sie bei der ersten Buchung selbst.
|
||||
|
||||
## Beraterkontakt testen
|
||||
|
||||
Die [Kontakt-Testseite](http://localhost:5173/contact-test.html?berater-id=relindis-agethen)
|
||||
öffnet das neue Popup. Kundenprüfung, Mailpit-Versand, Profil-Einbindung und
|
||||
Checkliste sind in [docs/contact.md](docs/contact.md) dokumentiert.
|
||||
|
||||
## Prüfen
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run build
|
||||
npm run test:browser
|
||||
```
|
||||
|
||||
Die Browsertests starten eigene Server auf 3000 und 5173; diese Ports vorher
|
||||
freihalten. Sie verwenden eine temporäre CSV und simulierten Mailversand.
|
||||
Falls Chromium fehlt: `npx playwright install chromium`.
|
||||
|
||||
Der Build enthält Startseite, Angebotsübersicht und Buchungsseite. Für ein
|
||||
Deployment zusätzlich das Node-Backend betreiben und `/api` dorthin weiterleiten.
|
||||
# Tri-Hub Ernährungsberatung
|
||||
|
||||
## Im Dev Container starten
|
||||
|
||||
1. Repository klonen und in VS Code „Dev Containers: Reopen in Container“ wählen.
|
||||
Bei einem bereits vorhandenen Container einmal „Dev Containers: Rebuild Container“
|
||||
ausführen, damit Mailpit installiert wird.
|
||||
2. Die Einrichtung installiert automatisch die npm-Abhängigkeiten und erstellt
|
||||
`.env` aus `.env.example`, sofern noch keine `.env` vorhanden ist.
|
||||
3. In drei getrennten Terminals in dieser Reihenfolge starten:
|
||||
|
||||
```bash
|
||||
npm run dev:mail
|
||||
```
|
||||
|
||||
```bash
|
||||
npm run dev:server
|
||||
```
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Startseite: http://localhost:5173 — über „Beratung kennenlernen“ zur
|
||||
Angebotsübersicht und von dort zum Buchungsformular des gewählten Pakets.
|
||||
Direkt: http://localhost:5173/angebotsuebersicht.html
|
||||
Mailansicht: http://localhost:8025. Beide Web-Ports werden vom Dev Container
|
||||
weitergeleitet. Mailpit läuft im selben Container auf SMTP-Port 1025 und fängt
|
||||
Testmails lokal ab, ohne sie extern zu versenden.
|
||||
|
||||
Die Container-Konfiguration installiert Mailpit v1.31.2 aus dem
|
||||
[offiziellen Docker-Image](https://mailpit.axllent.org/docs/install/docker/).
|
||||
Die Binärdatei, lokale Mails, `.env` und Buchungsdaten werden nicht committed.
|
||||
Mailpit verwendet ohne Datenbankpfad einen temporären Speicher; Testmails müssen
|
||||
nach einem Neustart nicht erhalten bleiben.
|
||||
|
||||
Ohne Dev Container: Node.js 24 und Mailpit installieren, `npm ci` ausführen und
|
||||
`.env.example` nach `.env` kopieren, falls `.env` fehlt. Danach dieselben drei
|
||||
Startbefehle verwenden.
|
||||
|
||||
Ohne Mailpit wird die Buchung gespeichert, aber der fehlgeschlagene Mailversand
|
||||
angezeigt. Der Ablauf bleibt eine Simulation ohne Zahlung oder verbindliche
|
||||
Beratung. Buchungen liegen lokal in `src/server/data/bookings.csv`. Diese Datei
|
||||
nicht vorab leer anlegen: Das Backend erzeugt sie bei der ersten Buchung selbst.
|
||||
|
||||
## Beraterkontakt testen
|
||||
|
||||
Die [Kontakt-Testseite](http://localhost:5173/contact-test.html?berater-id=relindis-agethen)
|
||||
öffnet das neue Popup. Kundenprüfung, Mailpit-Versand, Profil-Einbindung und
|
||||
Checkliste sind in [docs/contact.md](docs/contact.md) dokumentiert.
|
||||
|
||||
## Prüfen
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run build
|
||||
npm run test:browser
|
||||
```
|
||||
|
||||
Die Browsertests starten eigene Server auf 3000 und 5173; diese Ports vorher
|
||||
freihalten. Sie verwenden eine temporäre CSV und simulierten Mailversand.
|
||||
Falls Chromium fehlt: `npx playwright install chromium`.
|
||||
|
||||
Der Build enthält Startseite, Angebotsübersicht und Buchungsseite. Für ein
|
||||
Deployment zusätzlich das Node-Backend betreiben und `/api` dorthin weiterleiten.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<title>Ernährungs-Pakete – Tri-Hub</title>
|
||||
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
||||
<link rel="stylesheet" href="/src/styles/packages.css" />
|
||||
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||
</head>
|
||||
<body class="packages-page">
|
||||
<div id="navbar-placeholder"></div>
|
||||
|
|
@ -30,5 +31,7 @@
|
|||
type="module"
|
||||
src="/src/features/angebote/angebote-entry.js"
|
||||
></script>
|
||||
<div id="footer-placeholder"></div>
|
||||
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
38
berater.html
38
berater.html
|
|
@ -3,11 +3,15 @@
|
|||
<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." />
|
||||
<meta
|
||||
name="description"
|
||||
content="Lerne die Ernährungsberaterinnen und Ernährungsberater von Tri-Hub kennen."
|
||||
/>
|
||||
<title>Beraterprofil · Tri-Hub</title>
|
||||
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
||||
<link rel="stylesheet" href="/src/styles/packages.css" />
|
||||
<link rel="stylesheet" href="/src/features/berater/berater.css" />
|
||||
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||
</head>
|
||||
<body class="packages-page advisor-page">
|
||||
<div id="navbar-placeholder"></div>
|
||||
|
|
@ -16,16 +20,36 @@
|
|||
<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>
|
||||
<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
|
||||
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>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/components/navigationsbar/navigation.js"
|
||||
></script>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/features/berater/berater-entry.js"
|
||||
></script>
|
||||
<div id="footer-placeholder"></div>
|
||||
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<meta name="robots" content="noindex" />
|
||||
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
||||
<title>Buchungsbestätigung – Tri-Hub</title>
|
||||
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||
</head>
|
||||
<body class="confirmation">
|
||||
<div id="navbar-placeholder"></div>
|
||||
|
|
@ -146,9 +147,6 @@
|
|||
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"
|
||||
|
|
@ -157,5 +155,7 @@
|
|||
type="module"
|
||||
src="/src/components/navigationsbar/navigation.js"
|
||||
></script>
|
||||
<div id="footer-placeholder"></div>
|
||||
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>Paket buchen – Tri-Hub</title>
|
||||
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
|
|
@ -17,5 +18,7 @@
|
|||
type="module"
|
||||
src="/src/features/booking/booking-entry.js"
|
||||
></script>
|
||||
<div id="footer-placeholder"></div>
|
||||
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Berater kontaktieren – Tri-Hub Testseite</title>
|
||||
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="contact-test">
|
||||
|
|
@ -22,5 +23,7 @@
|
|||
type="module"
|
||||
src="/src/features/contact/contact-entry.js"
|
||||
></script>
|
||||
<div id="footer-placeholder"></div>
|
||||
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,36 +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
|
||||
```
|
||||
# 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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,103 +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.
|
||||
# Ü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.
|
||||
|
|
|
|||
308
docs/contact.md
308
docs/contact.md
|
|
@ -1,154 +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.
|
||||
# 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.
|
||||
|
|
|
|||
|
|
@ -1,21 +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.
|
||||
# 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.
|
||||
|
|
|
|||
263
index.html
263
index.html
|
|
@ -3,6 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
||||
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
|
|
@ -163,10 +164,26 @@
|
|||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
padding: 92px 0 82px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 0 56px;
|
||||
z-index: -1;
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(8, 15, 21, 0.72),
|
||||
rgba(8, 15, 21, 0.84)
|
||||
),
|
||||
url("/images/startseiter_hintergrund.jpeg") 38% center / cover no-repeat;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(330px, 0.8fr);
|
||||
|
|
@ -240,6 +257,7 @@
|
|||
|
||||
.hero-card {
|
||||
position: relative;
|
||||
transform: translateX(80px);
|
||||
min-height: 410px;
|
||||
padding: 38px;
|
||||
display: flex;
|
||||
|
|
@ -256,8 +274,8 @@
|
|||
),
|
||||
linear-gradient(
|
||||
145deg,
|
||||
rgba(255, 255, 255, 0.075),
|
||||
rgba(255, 255, 255, 0.025)
|
||||
rgba(13, 19, 31, 0.78),
|
||||
rgba(13, 19, 31, 0.84)
|
||||
);
|
||||
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
|
@ -274,6 +292,7 @@
|
|||
box-shadow:
|
||||
0 0 0 34px rgba(83, 213, 205, 0.035),
|
||||
0 0 0 68px rgba(83, 213, 205, 0.02);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-card-mark {
|
||||
|
|
@ -296,6 +315,11 @@
|
|||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hero-card > div {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 96px 0;
|
||||
}
|
||||
|
|
@ -375,6 +399,26 @@
|
|||
font-size: clamp(2.5rem, 5vw, 4.7rem);
|
||||
}
|
||||
|
||||
.knowledge-image-frame {
|
||||
width: 100%;
|
||||
transform: translateX(-18px);
|
||||
margin-top: 28px;
|
||||
padding: 9px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(248, 250, 252, 0.18);
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(145deg, #273135, #151d22);
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.knowledge-image-frame img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 2.58 / 1;
|
||||
border-radius: 14px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.knowledge-copy {
|
||||
color: var(--muted);
|
||||
line-height: 1.85;
|
||||
|
|
@ -535,7 +579,7 @@
|
|||
.rating-input label:hover,
|
||||
.rating-input label:hover ~ label,
|
||||
.rating-input input:checked ~ label {
|
||||
color: var(--brand);
|
||||
color: #f7c230;
|
||||
}
|
||||
|
||||
.consent {
|
||||
|
|
@ -547,6 +591,11 @@
|
|||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.consent a {
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.consent input {
|
||||
margin-top: 4px;
|
||||
accent-color: var(--brand);
|
||||
|
|
@ -630,7 +679,7 @@
|
|||
}
|
||||
|
||||
.stars {
|
||||
color: var(--brand);
|
||||
color: #f7c230;
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
@ -692,6 +741,7 @@
|
|||
|
||||
.hero-card {
|
||||
min-height: 330px;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -879,6 +929,13 @@
|
|||
<h2 style="font-family: "Inter", sans-serif">
|
||||
Wissen,<br />das dich<br />weiterbringt.
|
||||
</h2>
|
||||
<div class="knowledge-image-frame">
|
||||
<img
|
||||
src="/images/exttraBild.jpeg"
|
||||
alt="Ergänzendes Bild zur Ernährungsberatung"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -960,44 +1017,65 @@
|
|||
</a>
|
||||
</div>
|
||||
|
||||
<div class="review-list">
|
||||
<!-- Diese Beispielkarten sind bewusst als Platzhalter gekennzeichnet. -->
|
||||
<article class="review-card">
|
||||
<div class="review-top">
|
||||
<div class="person">
|
||||
<div class="avatar">T</div>
|
||||
<div>
|
||||
<strong>Deine Erfahrung</strong>
|
||||
<small
|
||||
>Hier kann eine Kundenstimme
|
||||
erscheinen</small
|
||||
>
|
||||
<div
|
||||
class="review-list"
|
||||
id="reviewSlider"
|
||||
role="region"
|
||||
aria-roledescription="Karussell"
|
||||
aria-label="Kundenrezensionen"
|
||||
>
|
||||
<div
|
||||
class="review-stage"
|
||||
aria-live="off"
|
||||
tabindex="0"
|
||||
role="group"
|
||||
aria-label="Rezensionen durchblättern"
|
||||
aria-describedby="reviewInstructions"
|
||||
>
|
||||
<article class="review-card is-active">
|
||||
<div class="review-top">
|
||||
<div class="person">
|
||||
<div class="avatar">T</div>
|
||||
<div>
|
||||
<strong>Deine Erfahrung</strong>
|
||||
<small
|
||||
>Noch keine Rezension
|
||||
vorhanden</small
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stars"
|
||||
aria-label="Beispiel für eine Sternebewertung"
|
||||
>
|
||||
★★★★★
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stars"
|
||||
aria-label="Beispiel für eine Sternebewertung"
|
||||
|
||||
<blockquote
|
||||
style="
|
||||
font-family:
|
||||
"Inter", sans-serif;
|
||||
"
|
||||
>
|
||||
★★★★★
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<blockquote
|
||||
style="
|
||||
font-family: "Inter", sans-serif;
|
||||
"
|
||||
>
|
||||
„Teile hier deine Erfahrung mit der
|
||||
Ernährungsberatung – ehrlich, persönlich und so,
|
||||
wie du sie erlebt hast.“
|
||||
</blockquote>
|
||||
</article>
|
||||
|
||||
„Teile hier deine Erfahrung mit der
|
||||
Ernährungsberatung – ehrlich, persönlich und
|
||||
so, wie du sie erlebt hast.“
|
||||
</blockquote>
|
||||
</article>
|
||||
</div>
|
||||
<span
|
||||
class="review-instructions"
|
||||
id="reviewInstructions"
|
||||
>Mit der Maus ziehen oder mit den Pfeiltasten links
|
||||
und rechts durch die Rezensionen wechseln.</span
|
||||
>
|
||||
<span class="review-announcement" role="status"></span>
|
||||
<p class="review-feedback" role="status"></p>
|
||||
<p class="review-note">
|
||||
Hinweis: Die Bewertungen in dieser HTML-Demo werden
|
||||
nicht dauerhaft gespeichert. Für den Live-Betrieb
|
||||
sollte das Formular an das bestehende Backend oder
|
||||
CMS von Tri-hub angebunden werden.
|
||||
Nur Kunden mit einer vorhandenen Buchung können eine
|
||||
Bewertung abgeben. Deine E-Mail-Adresse wird nicht
|
||||
öffentlich angezeigt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1027,19 +1105,36 @@
|
|||
|
||||
<form class="review-form" id="reviewForm">
|
||||
<div class="field" style="margin-top: 0">
|
||||
<label for="name">Dein Name</label>
|
||||
<label for="reviewEmail"
|
||||
>E-Mail deiner Buchung*</label
|
||||
>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autocomplete="name"
|
||||
placeholder="z. B. Thomas M."
|
||||
id="reviewEmail"
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
maxlength="254"
|
||||
placeholder="z. B. thomas@example.de"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Deine Bewertung</label>
|
||||
<label for="reviewTitle"
|
||||
>Titel (10–50 Zeichen)*</label
|
||||
>
|
||||
<input
|
||||
id="reviewTitle"
|
||||
name="title"
|
||||
type="text"
|
||||
minlength="10"
|
||||
maxlength="50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Deine Bewertung*</label>
|
||||
<div
|
||||
class="rating-input"
|
||||
aria-label="Sternebewertung"
|
||||
|
|
@ -1088,10 +1183,14 @@
|
|||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="review">Deine Erfahrung</label>
|
||||
<label for="review"
|
||||
>Deine Erfahrung (10–500 Zeichen)*</label
|
||||
>
|
||||
<textarea
|
||||
id="review"
|
||||
name="review"
|
||||
minlength="10"
|
||||
maxlength="500"
|
||||
placeholder="Was hat dir an der Ernährungsberatung geholfen?"
|
||||
required
|
||||
></textarea>
|
||||
|
|
@ -1100,12 +1199,22 @@
|
|||
<label class="consent">
|
||||
<input type="checkbox" name="consent" required />
|
||||
<span>
|
||||
Ich stimme zu, dass meine Bewertung mit meinem
|
||||
angegebenen Namen auf dieser Seite
|
||||
veröffentlicht werden darf.
|
||||
Ich akzeptiere die
|
||||
<a
|
||||
href="https://www.tri-hub.de/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Datenschutzbestimmungen</a
|
||||
>*: Meine E-Mail-Adresse wird zum Abgleich mit
|
||||
meiner Buchung und zusammen mit meiner Bewertung
|
||||
gespeichert. Titel, Bewertungstext und Sterne
|
||||
dürfen veröffentlicht werden; meine
|
||||
E-Mail-Adresse bleibt nicht öffentlich.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<small>*Pflichtfelder</small>
|
||||
|
||||
<button class="button button-primary" type="submit">
|
||||
Bewertung veröffentlichen
|
||||
</button>
|
||||
|
|
@ -1217,6 +1326,24 @@
|
|||
<small>*Pflichtfelder</small>
|
||||
</div>
|
||||
|
||||
<label class="consent"
|
||||
><input
|
||||
type="checkbox"
|
||||
name="consent"
|
||||
required
|
||||
/><span
|
||||
>Ich akzeptiere die
|
||||
<a
|
||||
href="https://www.tri-hub.de/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Datenschutzbestimmungen</a
|
||||
>
|
||||
(Pflichtfeld).</span
|
||||
></label
|
||||
>
|
||||
<p data-error-for="consent" role="alert"></p>
|
||||
|
||||
<button class="button button-primary" type="submit">
|
||||
Nachricht senden
|
||||
</button>
|
||||
|
|
@ -1232,42 +1359,24 @@
|
|||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<div>
|
||||
<strong>tri-hub</strong> · Triathlon als sportliches Zuhause
|
||||
ab 50.
|
||||
</div>
|
||||
<div>Ernährung · Wissen · Erfahrungen</div>
|
||||
</div>
|
||||
</footer>
|
||||
<div id="footer-placeholder"></div>
|
||||
|
||||
<script
|
||||
type="module"
|
||||
src="/src/components/navigationsbar/navigation.js"
|
||||
></script>
|
||||
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/features/contact/contact-form.js"
|
||||
></script>
|
||||
<script>
|
||||
const form = document.getElementById("reviewForm");
|
||||
const status = document.getElementById("formStatus");
|
||||
|
||||
form.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
const name = form.elements.name.value.trim();
|
||||
const rating = form.elements.rating.value;
|
||||
const review = form.elements.review.value.trim();
|
||||
|
||||
if (!name || !rating || !review) return;
|
||||
|
||||
status.textContent =
|
||||
"Danke für deine Bewertung. In dieser Demo wurde sie noch nicht dauerhaft gespeichert.";
|
||||
|
||||
form.reset();
|
||||
});
|
||||
</script>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/features/reviews/review-slider.js"
|
||||
></script>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/features/reviews/review-form.js"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<title>Paket-Details – Tri-Hub Ernährungsberatung</title>
|
||||
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
||||
<link rel="stylesheet" href="/src/styles/packages.css" />
|
||||
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||
</head>
|
||||
<body class="packages-page">
|
||||
<div id="navbar-placeholder"></div>
|
||||
|
|
@ -23,5 +24,7 @@
|
|||
type="module"
|
||||
src="/src/features/angebote/paket-details.js"
|
||||
></script>
|
||||
<div id="footer-placeholder"></div>
|
||||
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./src/tests/browser",
|
||||
workers: 1,
|
||||
use: { baseURL: "http://127.0.0.1:5173", browserName: "chromium" },
|
||||
webServer: [
|
||||
{
|
||||
command: "node src/tests/helpers/browser-server.js",
|
||||
url: "http://127.0.0.1:3000/api/angebote",
|
||||
reuseExistingServer: process.env.PW_REUSE_EXISTING === "1",
|
||||
gracefulShutdown: { signal: "SIGTERM", timeout: 3000 },
|
||||
},
|
||||
{
|
||||
command: "npm run dev",
|
||||
url: "http://127.0.0.1:5173/booking.html",
|
||||
reuseExistingServer: process.env.PW_REUSE_EXISTING === "1",
|
||||
},
|
||||
],
|
||||
});
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./src/tests/browser",
|
||||
workers: 1,
|
||||
use: { baseURL: "http://127.0.0.1:5173", browserName: "chromium" },
|
||||
webServer: [
|
||||
{
|
||||
command: "node src/tests/helpers/browser-server.js",
|
||||
url: "http://127.0.0.1:3000/api/angebote",
|
||||
reuseExistingServer: process.env.PW_REUSE_EXISTING === "1",
|
||||
gracefulShutdown: { signal: "SIGTERM", timeout: 3000 },
|
||||
},
|
||||
{
|
||||
command: "npm run dev",
|
||||
url: "http://127.0.0.1:5173/booking.html",
|
||||
reuseExistingServer: process.env.PW_REUSE_EXISTING === "1",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,93 +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.
|
||||
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.
|
After Width: | Height: | Size: 131 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 824 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
|
|
@ -0,0 +1,69 @@
|
|||
<footer class="trihub-footer" aria-label="Fußbereich">
|
||||
<div class="trihub-footer-inner">
|
||||
<section class="trihub-footer-card trihub-footer-card--brand">
|
||||
<a class="trihub-brand" href="/index.html" aria-label="Tri-hub Startseite">
|
||||
<span class="trihub-logo-box">
|
||||
<img src="/images/logo.png" alt="Tri-hub" />
|
||||
</span>
|
||||
<span>
|
||||
<span class="trihub-brand-label">Tri-hub</span>
|
||||
<span class="trihub-brand-title">Triathlon als sportliches Zuhause ab 50</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<p class="trihub-description">
|
||||
Tri-hub verbindet persönliche Begleitung, Kleingruppen, Wissen, Camps,
|
||||
digitale Trainingssteuerung und sportliche Gemeinschaft für Menschen,
|
||||
die Triathlon ab 50 ernsthaft und passend zu ihrem Alltag entwickeln möchten.
|
||||
</p>
|
||||
|
||||
<div class="trihub-contact">
|
||||
<div class="trihub-contact-row">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="16" x="2" y="4" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg>
|
||||
<p>info@tri-hub.de · 06203/1764877</p>
|
||||
</div>
|
||||
<div class="trihub-contact-row">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"></path><circle cx="12" cy="10" r="3"></circle></svg>
|
||||
<p>Tri-hub GmbH, Schloßstraße 11, 68535 Edingen-Neckarhausen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trihub-socials" aria-label="Social Media">
|
||||
<a class="trihub-social-button" href="https://www.facebook.com/profile.php?id=61569002603339" target="_blank" rel="noopener noreferrer" aria-label="Tri-hub auf Facebook">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/></svg>
|
||||
</a>
|
||||
<a class="trihub-social-button" href="https://www.linkedin.com/company/tri-hub-gmbh" target="_blank" rel="noopener noreferrer" aria-label="Tri-hub auf LinkedIn">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/><rect width="4" height="12" x="2" y="9"/><circle cx="4" cy="4" r="2"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="trihub-footer-card">
|
||||
<h2 class="trihub-footer-heading">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-11V5l-8-3-8 3v6c0 7 8 11 8 11Z"></path><path d="m9 12 2 2 4-4"></path></svg>
|
||||
Hauptbereiche
|
||||
</h2>
|
||||
<nav class="trihub-footer-links" aria-label="Hauptbereiche">
|
||||
<a href="https://tri-hub.de/coaching-1zu1">1:1 Coaching</a>
|
||||
<a href="https://tri-hub.de/webinare-workshops">Webinare & Workshops</a>
|
||||
<a href="https://tri-hub.de/camps-schwarzwald">Camps</a>
|
||||
<a href="https://tri-hub.de/trainingsplaene">Trainingspläne</a>
|
||||
<a href="https://tri-hub.de/wissen">Wissen</a>
|
||||
<a href="https://tri-hub.de/community-blog">Community-Blog</a>
|
||||
<a href="https://tri-hub.de/event-kalender">Event-Kalender</a>
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
<section class="trihub-footer-card">
|
||||
<h2 class="trihub-footer-heading">Rechtliches</h2>
|
||||
<nav class="trihub-footer-links" aria-label="Rechtliche Informationen">
|
||||
<a href="https://tri-hub.de/kontakt">Kontakt</a>
|
||||
<a href="https://tri-hub.de/agb">AGB</a>
|
||||
<a href="https://tri-hub.de/widerruf">Widerrufsbelehrung</a>
|
||||
<a href="https://tri-hub.de/datenschutz">Datenschutz</a>
|
||||
<a href="https://tri-hub.de/impressum">Impressum</a>
|
||||
<a class="trihub-cookie-link" href="https://tri-hub.de/">Cookie-Einstellungen öffnen</a>
|
||||
</nav>
|
||||
</section>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import footerHtml from "./footer.html?raw";
|
||||
|
||||
/** Fügt das Footer-Fragment am Platzhalter ein. */
|
||||
export function initFooter(placeholderSelector = "#footer-placeholder") {
|
||||
const placeholder = document.querySelector(placeholderSelector);
|
||||
if (!placeholder) {
|
||||
console.warn("Footer-Platzhalter nicht gefunden:", placeholderSelector);
|
||||
return;
|
||||
}
|
||||
|
||||
placeholder.outerHTML = footerHtml;
|
||||
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => initFooter());
|
||||
|
|
@ -35,16 +35,22 @@
|
|||
<a href="/index.html" class="navbar__link">Ernährung</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/berater.html" class="navbar__link">Beraterprofil</a>
|
||||
<a href="/berater.html" class="navbar__link"
|
||||
>Beraterprofil</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/index.html" class="navbar__link">FAQ</a>
|
||||
<a href="/FAQ.html" class="navbar__link">FAQ</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/index.html#erfahrungen" class="navbar__link">Rezensionen</a>
|
||||
<a href="/index.html#erfahrungen" class="navbar__link"
|
||||
>Rezensionen</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/index.html#kontakt" class="navbar__link">Kontakt</a>
|
||||
<a href="/index.html#kontakt" class="navbar__link"
|
||||
>Kontakt</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -15,9 +15,24 @@ export function renderAngebotsuebersicht() {
|
|||
? `<span class="package-highlight">${pkg.highlight}</span>`
|
||||
: "";
|
||||
|
||||
const cardHtml = `
|
||||
<div class="package-card">
|
||||
const packageImage = {
|
||||
"ernaehrung-starter": {
|
||||
src: "/images/Starter_bild.webp",
|
||||
alt: "Triathlon-Einsteiger im Gespräch mit ihrem Coach am See",
|
||||
},
|
||||
"ernaehrung-standard": {
|
||||
src: "/images/standard_bild.webp",
|
||||
alt: "Triathlet im Radtrikot bespricht sein Training mit dem Coach",
|
||||
},
|
||||
"ernaehrung-premium": {
|
||||
src: "/images/premium_bild.webp",
|
||||
alt: "Triathlon-Athleten planen gemeinsam am Seeufer",
|
||||
},
|
||||
}[pkg.id];
|
||||
|
||||
const cardHtml = `
|
||||
<article class="package-card">
|
||||
<img class="package-image" src="${packageImage.src}" alt="${packageImage.alt}" loading="lazy" />
|
||||
<div class="package-type-container">
|
||||
<span class="package-type">${pkg.type}</span>
|
||||
${highlightHtml}
|
||||
|
|
@ -43,7 +58,7 @@ export function renderAngebotsuebersicht() {
|
|||
|
||||
<a href="/paket-details.html?id=${pkg.id}" class="package-btn">${pkg.buttonText} <span aria-hidden="true">→</span></a>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
|
||||
container.innerHTML += cardHtml;
|
||||
|
|
|
|||
|
|
@ -1,134 +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;
|
||||
}
|
||||
});
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,235 +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;
|
||||
}
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,8 @@ export function mountBookingPage(
|
|||
<input id="${instanceId}-booking-phone" name="phone" type="tel" autocomplete="tel" maxlength="50" aria-describedby="${instanceId}-booking-phone-error" />
|
||||
<p class="booking__field-error" id="${instanceId}-booking-phone-error"></p>
|
||||
</div>
|
||||
<label class="booking__consent"><input name="consent" type="checkbox" required aria-describedby="${instanceId}-booking-consent-error" /> <span>Ich akzeptiere die <a href="https://www.tri-hub.de/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> (Pflichtfeld).</span></label>
|
||||
<p class="booking__field-error" id="${instanceId}-booking-consent-error"></p>
|
||||
<div class="booking__actions">
|
||||
<button class="booking__button booking__cancel" type="button">Abbrechen</button>
|
||||
<button class="booking__button" type="submit">Paket buchen</button>
|
||||
|
|
@ -192,6 +194,7 @@ export function mountBookingPage(
|
|||
const errors = form.querySelector(".booking__errors");
|
||||
const progress = form.querySelector(".booking__progress");
|
||||
const result = content.querySelector(".booking__result");
|
||||
const consent = form.elements.consent;
|
||||
const storageKey = `trihub.booking.${selected.packageId}${selected.variantId ? `.${selected.variantId}` : ""}`;
|
||||
let attempt = null;
|
||||
let busy = false;
|
||||
|
|
@ -201,11 +204,12 @@ export function mountBookingPage(
|
|||
name.readOnly = locked;
|
||||
email.readOnly = locked;
|
||||
phone.readOnly = locked;
|
||||
consent.disabled = locked;
|
||||
}
|
||||
function showError(message, fields = {}) {
|
||||
errors.textContent = message;
|
||||
errors.hidden = false;
|
||||
for (const field of [name, email, phone]) {
|
||||
for (const field of [name, email, phone, consent]) {
|
||||
const message = fields[field.name] || "";
|
||||
content.querySelector(
|
||||
`#${instanceId}-booking-${field.name}-error`,
|
||||
|
|
@ -216,7 +220,7 @@ export function mountBookingPage(
|
|||
}
|
||||
function clearErrors() {
|
||||
errors.hidden = true;
|
||||
for (const field of [name, email, phone]) {
|
||||
for (const field of [name, email, phone, consent]) {
|
||||
field.removeAttribute("aria-invalid");
|
||||
content.querySelector(
|
||||
`#${instanceId}-booking-${field.name}-error`,
|
||||
|
|
@ -256,6 +260,7 @@ export function mountBookingPage(
|
|||
name.value = stored.payload.name;
|
||||
email.value = stored.payload.email;
|
||||
phone.value = stored.payload.phone || "";
|
||||
consent.checked = stored.payload.consent === true;
|
||||
lockFields(true);
|
||||
button.textContent = "Status prüfen / erneut versuchen";
|
||||
if (stored.result) showResult(stored.result, false);
|
||||
|
|
@ -275,6 +280,9 @@ export function mountBookingPage(
|
|||
clearErrors();
|
||||
if (!attempt) {
|
||||
const fields = {};
|
||||
if (!consent.checked)
|
||||
fields.consent =
|
||||
"Bitte die Datenschutzbestimmungen akzeptieren.";
|
||||
name.value = name.value.trim();
|
||||
email.value = email.value.trim();
|
||||
if (
|
||||
|
|
@ -305,6 +313,7 @@ export function mountBookingPage(
|
|||
...(selected.variantId
|
||||
? { variantId: selected.variantId }
|
||||
: {}),
|
||||
consent: consent.checked,
|
||||
name: name.value,
|
||||
email: email.value,
|
||||
...(phone.value ? { phone: phone.value } : {}),
|
||||
|
|
|
|||
|
|
@ -190,3 +190,25 @@
|
|||
.booking__dialog .booking__result:focus {
|
||||
outline-color: #53d5cd;
|
||||
}
|
||||
|
||||
.booking .booking__consent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.booking__consent input[type="checkbox"] {
|
||||
flex: 0 0 1em;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
min-height: 0;
|
||||
margin: 0.25em 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.booking__consent a,
|
||||
.booking__consent a:visited {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export function openContactDialog({
|
|||
<select id="contact-reason" name="reason" required><option value="">Bitte auswählen</option></select>
|
||||
</div>
|
||||
<label class="contact-consent"><input name="simulationAccepted" type="checkbox" required><span></span></label>
|
||||
<label class="contact-consent"><input name="consent" type="checkbox" required><span>Ich akzeptiere die <a href="https://www.tri-hub.de/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> (Pflichtfeld).</span></label>
|
||||
<p class="contact-status" role="status" aria-live="polite"></p>
|
||||
<button class="contact-submit" type="submit" disabled>Anfrage senden & Termin wählen</button>
|
||||
</form>`;
|
||||
|
|
@ -57,6 +58,7 @@ export function openContactDialog({
|
|||
close.disabled = busy;
|
||||
email.disabled = busy || sent;
|
||||
reason.disabled = busy || sent;
|
||||
form.elements.consent.disabled = busy || sent;
|
||||
form.elements.simulationAccepted.disabled = busy || sent;
|
||||
}
|
||||
async function post(path, input) {
|
||||
|
|
@ -82,6 +84,7 @@ export function openContactDialog({
|
|||
email: email.value,
|
||||
advisorId,
|
||||
reason: reason.value,
|
||||
consent: form.elements.consent.checked,
|
||||
simulationAccepted: form.elements.simulationAccepted.checked,
|
||||
};
|
||||
busy = true;
|
||||
|
|
|
|||
|
|
@ -1,14 +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 }));
|
||||
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 }));
|
||||
|
|
|
|||
|
|
@ -44,7 +44,10 @@ form?.addEventListener("submit", async (event) => {
|
|||
const response = await fetch("/api/nutrition-contact", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
||||
body: JSON.stringify({
|
||||
...Object.fromEntries(new FormData(form)),
|
||||
consent: form.elements.consent.checked,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -1,29 +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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,3 +148,8 @@
|
|||
.contact-status a {
|
||||
color: var(--contact-accent);
|
||||
}
|
||||
|
||||
.contact-consent a {
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* Tri-hub FAQ
|
||||
* Suche, Kategorien, Accordion und Footer-Jahr
|
||||
*/
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const searchInput = document.getElementById("search");
|
||||
const chipsContainer = document.getElementById("chips");
|
||||
const sections = Array.from(document.querySelectorAll(".faq-section"));
|
||||
const faqItems = Array.from(document.querySelectorAll(".faq-item"));
|
||||
const emptyMessage = document.getElementById("empty");
|
||||
const status = document.getElementById("status");
|
||||
const yearElement = document.getElementById("year");
|
||||
|
||||
// Aktuelles Jahr im Footer
|
||||
if (yearElement) {
|
||||
yearElement.textContent = new Date().getFullYear();
|
||||
}
|
||||
|
||||
// Aktiver Kategorie-Filter
|
||||
let activeFilter = "all";
|
||||
|
||||
// Text für die Suche vereinheitlichen
|
||||
function normalizeText(text) {
|
||||
return text
|
||||
.toLocaleLowerCase("de")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// FAQ-Suche und Kategorie-Filter anwenden
|
||||
function filterFAQs() {
|
||||
const searchTerm = normalizeText(searchInput?.value || "");
|
||||
|
||||
let visibleCount = 0;
|
||||
|
||||
sections.forEach((section) => {
|
||||
const category = section.dataset.cat;
|
||||
const categoryMatches =
|
||||
activeFilter === "all" || category === activeFilter;
|
||||
|
||||
const items = Array.from(section.querySelectorAll(".faq-item"));
|
||||
|
||||
let visibleInSection = 0;
|
||||
|
||||
items.forEach((item) => {
|
||||
const searchableText = normalizeText(item.textContent);
|
||||
|
||||
const searchMatches =
|
||||
searchTerm === "" || searchableText.includes(searchTerm);
|
||||
|
||||
const isVisible = categoryMatches && searchMatches;
|
||||
|
||||
item.hidden = !isVisible;
|
||||
|
||||
if (isVisible) {
|
||||
visibleInSection++;
|
||||
visibleCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// Kategorie nur anzeigen, wenn Fragen passen
|
||||
section.hidden = visibleInSection === 0;
|
||||
});
|
||||
|
||||
// Hinweis anzeigen, wenn nichts gefunden wurde
|
||||
if (emptyMessage) {
|
||||
emptyMessage.classList.toggle("hidden", visibleCount > 0);
|
||||
}
|
||||
|
||||
// Screenreader über das Suchergebnis informieren
|
||||
if (status) {
|
||||
status.textContent =
|
||||
visibleCount === 1
|
||||
? "Eine Frage gefunden."
|
||||
: `${visibleCount} Fragen gefunden.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Kategorie-Buttons initialisieren
|
||||
if (chipsContainer) {
|
||||
const chips = Array.from(
|
||||
chipsContainer.querySelectorAll("[data-filter]"),
|
||||
);
|
||||
|
||||
function updateActiveChip() {
|
||||
chips.forEach((chip) => {
|
||||
const isActive = chip.dataset.filter === activeFilter;
|
||||
|
||||
chip.classList.toggle("is-active", isActive);
|
||||
|
||||
chip.setAttribute("aria-pressed", String(isActive));
|
||||
});
|
||||
}
|
||||
|
||||
chips.forEach((chip) => {
|
||||
chip.addEventListener("click", () => {
|
||||
activeFilter = chip.dataset.filter;
|
||||
|
||||
updateActiveChip();
|
||||
filterFAQs();
|
||||
});
|
||||
});
|
||||
|
||||
updateActiveChip();
|
||||
}
|
||||
|
||||
// Suche bei jeder Eingabe aktualisieren
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener("input", filterFAQs);
|
||||
}
|
||||
|
||||
// Accordion: Immer nur eine Frage gleichzeitig öffnen
|
||||
faqItems.forEach((item) => {
|
||||
item.addEventListener("toggle", () => {
|
||||
const isOpen = item.open;
|
||||
|
||||
item.dataset.state = isOpen ? "open" : "closed";
|
||||
|
||||
if (!isOpen) return;
|
||||
|
||||
faqItems.forEach((otherItem) => {
|
||||
if (otherItem !== item && otherItem.open) {
|
||||
otherItem.open = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Initialzustand setzen
|
||||
faqItems.forEach((item) => {
|
||||
item.dataset.state = item.open ? "open" : "closed";
|
||||
});
|
||||
|
||||
filterFAQs();
|
||||
});
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
const form = document.getElementById("reviewForm");
|
||||
const status = document.getElementById("formStatus");
|
||||
const button = form.querySelector('button[type="submit"]');
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (button.disabled) return;
|
||||
const input = {
|
||||
email: form.elements.email.value.trim(),
|
||||
title: form.elements.title.value.trim(),
|
||||
review: form.elements.review.value.trim(),
|
||||
rating: Number(form.elements.rating.value),
|
||||
consent: form.elements.consent.checked,
|
||||
};
|
||||
for (const [field, max, label] of [
|
||||
["title", 50, "Titel"],
|
||||
["review", 500, "Bewertungstext"],
|
||||
]) {
|
||||
if (input[field].length < 10 || input[field].length > max) {
|
||||
status.textContent = `${label}: Bitte 10 bis ${max} Zeichen eingeben.`;
|
||||
form.elements[field].focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
button.disabled = true;
|
||||
status.textContent = "Buchung wird geprüft und Bewertung gespeichert …";
|
||||
try {
|
||||
const response = await fetch("/api/reviews", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
status.textContent =
|
||||
result.error?.message ||
|
||||
"Die Bewertung konnte nicht gespeichert werden.";
|
||||
return;
|
||||
}
|
||||
status.textContent = "Danke! Deine Bewertung wurde gespeichert.";
|
||||
form.reset();
|
||||
document.dispatchEvent(new Event("review:saved"));
|
||||
} catch {
|
||||
status.textContent =
|
||||
"Keine Bestätigung vom Server erhalten. Bitte versuche es später erneut.";
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
.review-list {
|
||||
min-width: 0;
|
||||
align-content: start;
|
||||
}
|
||||
.review-stage {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
touch-action: pan-y;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
.review-stage .review-card {
|
||||
grid-area: 1 / 1;
|
||||
min-width: 0;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateX(32px);
|
||||
transition:
|
||||
opacity 600ms ease,
|
||||
transform 600ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
visibility 0s 600ms;
|
||||
pointer-events: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.review-stage .review-card.is-before {
|
||||
transform: translateX(-32px);
|
||||
}
|
||||
.review-stage .review-card.is-active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(0);
|
||||
transition-delay: 0s;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.review-stage .review-card h3 {
|
||||
font-family: inherit;
|
||||
margin: 0;
|
||||
color: var(--white);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.review-stage .review-top {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.review-stage .review-card blockquote {
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.review-stage:focus-visible {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
.review-carousel {
|
||||
position: relative;
|
||||
padding-inline: 3.25rem;
|
||||
}
|
||||
.review-arrow {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 50%;
|
||||
display: grid;
|
||||
width: 2rem;
|
||||
height: 2.5rem;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
transform: translateY(-50%);
|
||||
border: 0;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 2.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
.review-arrow[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.review-arrow:hover {
|
||||
color: #53d5cd;
|
||||
}
|
||||
.review-arrow--previous {
|
||||
left: 0;
|
||||
}
|
||||
.review-arrow--next {
|
||||
right: 0;
|
||||
}
|
||||
.review-arrow:focus-visible {
|
||||
outline: 3px solid #53d5cd;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.review-stage:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.review-arrow:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.review-stage.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.review-stage.is-dragging .review-card.is-active {
|
||||
transition: none;
|
||||
}
|
||||
.review-feedback {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.review-feedback:empty {
|
||||
display: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.review-stage .review-card {
|
||||
transition: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.review-announcement,
|
||||
.review-instructions {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.review-carousel {
|
||||
padding-inline: 2.25rem;
|
||||
}
|
||||
.review-arrow {
|
||||
width: 1.5rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
import "./review-slider.css";
|
||||
|
||||
const slider = document.getElementById("reviewSlider");
|
||||
const stage = slider.querySelector(".review-stage");
|
||||
const feedback = slider.querySelector(".review-feedback");
|
||||
const announcement = slider.querySelector(".review-announcement");
|
||||
const carousel = document.createElement("div");
|
||||
carousel.className = "review-carousel";
|
||||
stage.before(carousel);
|
||||
carousel.append(stage);
|
||||
function arrow(direction, symbol, label) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `review-arrow review-arrow--${direction}`;
|
||||
button.setAttribute("aria-label", label);
|
||||
button.textContent = symbol;
|
||||
button.hidden = true;
|
||||
button.addEventListener("click", () =>
|
||||
show(index + (direction === "next" ? 1 : -1), true),
|
||||
);
|
||||
carousel.append(button);
|
||||
return button;
|
||||
}
|
||||
const previousArrow = arrow("previous", "‹", "Vorherige Rezension");
|
||||
const nextArrow = arrow("next", "›", "Nächste Rezension");
|
||||
const interval = 10_000;
|
||||
let reviews = [];
|
||||
let index = 0;
|
||||
let timer;
|
||||
let drag = null;
|
||||
let latestRequest = 0;
|
||||
|
||||
function schedule() {
|
||||
clearTimeout(timer);
|
||||
if (reviews.length > 1 && !drag && !document.hidden) {
|
||||
timer = setTimeout(() => show(index + 1), interval);
|
||||
}
|
||||
}
|
||||
|
||||
function show(next, manual = false) {
|
||||
if (!reviews.length) return;
|
||||
const direction = next < index ? -1 : 1;
|
||||
const previous = stage.children[index];
|
||||
index = (next + reviews.length) % reviews.length;
|
||||
const current = stage.children[index];
|
||||
if (previous !== current) {
|
||||
current.classList.toggle("is-before", direction < 0);
|
||||
// Establish the starting position before transitioning into view.
|
||||
void current.offsetWidth;
|
||||
previous.classList.toggle("is-before", direction > 0);
|
||||
}
|
||||
[...stage.children].forEach((card, number) => {
|
||||
card.classList.toggle("is-active", number === index);
|
||||
card.inert = number !== index;
|
||||
card.setAttribute("aria-hidden", String(number !== index));
|
||||
});
|
||||
if (manual)
|
||||
announcement.textContent = `Rezension ${index + 1} von ${reviews.length}: ${reviews[index].title}`;
|
||||
schedule();
|
||||
}
|
||||
|
||||
function cardFor(review, number, total) {
|
||||
const card = document.createElement("article");
|
||||
card.className = "review-card";
|
||||
card.setAttribute("aria-roledescription", "Folie");
|
||||
card.setAttribute("aria-label", `${number + 1} von ${total}`);
|
||||
const top = document.createElement("div");
|
||||
top.className = "review-top";
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = review.title;
|
||||
const stars = document.createElement("div");
|
||||
stars.className = "stars";
|
||||
stars.setAttribute("aria-label", `${review.rating} von 5 Sternen`);
|
||||
stars.textContent =
|
||||
"★".repeat(review.rating) + "☆".repeat(5 - review.rating);
|
||||
const text = document.createElement("blockquote");
|
||||
text.textContent = review.review;
|
||||
top.append(title, stars);
|
||||
card.append(top, text);
|
||||
return card;
|
||||
}
|
||||
|
||||
async function refresh(showNewest = false) {
|
||||
const request = ++latestRequest;
|
||||
try {
|
||||
const response = await fetch("/api/reviews", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error("Laden fehlgeschlagen");
|
||||
const result = await response.json();
|
||||
if (request !== latestRequest || drag) return;
|
||||
const next = result.reviews;
|
||||
if (
|
||||
!Array.isArray(next) ||
|
||||
next.some(
|
||||
(review) =>
|
||||
typeof review.title !== "string" ||
|
||||
typeof review.review !== "string" ||
|
||||
!Number.isInteger(review.rating) ||
|
||||
review.rating < 1 ||
|
||||
review.rating > 5,
|
||||
)
|
||||
)
|
||||
throw new Error("Ungültige Bewertungen");
|
||||
if (feedback.textContent.startsWith("Bewertungen können"))
|
||||
feedback.textContent = "";
|
||||
if (JSON.stringify(next) === JSON.stringify(reviews)) return;
|
||||
const active = JSON.stringify(reviews[index]);
|
||||
reviews = next;
|
||||
if (!reviews.length) {
|
||||
// Reloading also restores the empty placeholder after external deletion.
|
||||
stage.replaceChildren(placeholder.cloneNode(true));
|
||||
previousArrow.hidden = true;
|
||||
nextArrow.hidden = true;
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
index = showNewest
|
||||
? reviews.length - 1
|
||||
: Math.max(
|
||||
0,
|
||||
reviews.findIndex(
|
||||
(review) => JSON.stringify(review) === active,
|
||||
),
|
||||
);
|
||||
stage.replaceChildren(
|
||||
...reviews.map((review, number) =>
|
||||
cardFor(review, number, reviews.length),
|
||||
),
|
||||
);
|
||||
previousArrow.hidden = reviews.length < 2;
|
||||
nextArrow.hidden = reviews.length < 2;
|
||||
void stage.offsetWidth;
|
||||
show(index);
|
||||
feedback.textContent = "";
|
||||
} catch {
|
||||
if (request === latestRequest)
|
||||
feedback.textContent =
|
||||
"Bewertungen können gerade nicht aktualisiert werden. Wir versuchen es erneut.";
|
||||
}
|
||||
}
|
||||
|
||||
const placeholder = stage.firstElementChild.cloneNode(true);
|
||||
stage.addEventListener("keydown", (event) => {
|
||||
if (drag || event.altKey || event.ctrlKey || event.metaKey) return;
|
||||
const targets = {
|
||||
ArrowLeft: index - 1,
|
||||
ArrowRight: index + 1,
|
||||
Home: 0,
|
||||
End: reviews.length - 1,
|
||||
};
|
||||
if (!(event.key in targets)) return;
|
||||
event.preventDefault();
|
||||
show(targets[event.key], true);
|
||||
});
|
||||
stage.addEventListener("pointerdown", (event) => {
|
||||
if (!event.isPrimary || event.button !== 0 || reviews.length < 2 || drag)
|
||||
return;
|
||||
drag = {
|
||||
id: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
};
|
||||
clearTimeout(timer);
|
||||
stage.setPointerCapture(event.pointerId);
|
||||
stage.classList.add("is-dragging");
|
||||
});
|
||||
stage.addEventListener("pointermove", (event) => {
|
||||
if (!drag || drag.id !== event.pointerId) return;
|
||||
drag.dx = event.clientX - drag.x;
|
||||
drag.dy = event.clientY - drag.y;
|
||||
if (Math.abs(drag.dx) > Math.abs(drag.dy)) {
|
||||
const offset = Math.max(
|
||||
-stage.clientWidth / 2,
|
||||
Math.min(stage.clientWidth / 2, drag.dx),
|
||||
);
|
||||
stage.children[index].style.transform = `translateX(${offset}px)`;
|
||||
}
|
||||
});
|
||||
function finishDrag(event) {
|
||||
if (!drag || drag.id !== event.pointerId) return;
|
||||
const { dx, dy } = drag;
|
||||
drag = null;
|
||||
stage.classList.remove("is-dragging");
|
||||
stage.children[index]?.style.removeProperty("transform");
|
||||
if (stage.hasPointerCapture(event.pointerId))
|
||||
stage.releasePointerCapture(event.pointerId);
|
||||
if (
|
||||
event.type === "pointerup" &&
|
||||
Math.abs(dx) > 50 &&
|
||||
Math.abs(dx) > Math.abs(dy)
|
||||
) {
|
||||
show(index + (dx < 0 ? 1 : -1), true);
|
||||
} else {
|
||||
schedule();
|
||||
}
|
||||
}
|
||||
for (const event of ["pointerup", "pointercancel", "lostpointercapture"]) {
|
||||
stage.addEventListener(event, finishDrag);
|
||||
}
|
||||
document.addEventListener("review:saved", () => refresh(true));
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
schedule();
|
||||
if (!document.hidden) refresh();
|
||||
});
|
||||
setInterval(() => {
|
||||
if (!document.hidden) refresh();
|
||||
}, interval);
|
||||
refresh();
|
||||
|
|
@ -1,10 +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""}",,
|
||||
bookingId,createdAt,packageId,packageName,name,email,emailStatus,idempotencyKey,requestHash,bookedPackage,customer,acceptedTerms,consent
|
||||
TH-000001,2026-09-25T11:43:43.074Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,kk,kk@web.de,accepted,497662ce-278c-466f-9505-c2728f8973ca,d5c69de514e6de5552e43f99256a042b59df5bd53db0bd1685f849809a455ef9,,,,true
|
||||
TH-000002,2026-09-25T19:22:37.801Z,ernaehrung-starter,Für den sicheren Einstieg in die Sporternährung,Marvin,marvin@web.de,accepted,06b9ad79-05c3-4e37-9135-0c3cf1da3f36,7650a36bd436390b5f79dde1ea1e0d8944f1320b29bad927dfd71157c3f4133c,,,,true
|
||||
TH-000003,2026-09-25T19:52:53.927Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,M,test@web.de,accepted,9446e525-a714-4912-bb10-1906a75cc9cd,a8fbd181361e4de7713fb0c4b5e0bf88cd43acd3e16294e7b84b33d959551458,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
|
||||
TH-000004,2026-09-25T19:54:52.744Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,m,test@web.de,accepted,1817271c-343e-4e27-ac60-9d225897ba28,4a9f445acdf5cef94e95d8cdf50e78f95884913799cd2f532aaa37302910a16b,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",,,true
|
||||
TH-000005,2026-09-25T20:14:51.874Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,Marvin,m@web.de,accepted,678654e5-7db9-49af-97b0-64fe02257e77,89c188f086a2d72fb77ae52612288610d2f17846fb1a3e62b11e8caa011cf2b4,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",,,true
|
||||
TH-000006,2026-09-26T08:44:42.313Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,Marvin,m@web.de,accepted,a0a8c426-fc56-4887-a0e5-42172ff9cfb2,b83cf7b2ef4c42447bd83038c60d2ca565a8b37d24cae128f4fa2214dc43b185,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
|
||||
TH-000007,2026-09-26T08:50:50.112Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,k@web.de,accepted,92e12eff-321d-49e5-a9ad-674a7d61f9a7,14b1f6b3909696f8402be66b1de33956b84ca435f58567e57e31e7152b6bff6c,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
|
||||
TH-000008,2026-09-26T09:18:17.493Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,marvin@test.de,accepted,c74460a8-0315-4e0b-9d6b-48c854b0f71b,19ebc08e678515ea3a71f9b295ac1fbbf188bf54602a531f4f3bdc53698dcda7,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
|
||||
TH-000009,2026-09-26T09:43:16.704Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,marvin,m@email.de,accepted,9c18134f-3a71-428b-8769-fda7d06790c7,eaa9a3f0dfc568c726f1b6fc26d92f8c4cc7df072f9fdd40fbea581661eeef9f,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-24"",""variantLabel"":""24-Wochen-Variante"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":671.43,""taxRate"":19,""taxAmount"":127.57,""grossPrice"":799,""currency"":""EUR""}",,,true
|
||||
|
|
|
|||
|
|
|
@ -1,13 +1,13 @@
|
|||
createdAt,name,email,subject,message
|
||||
createdAt,name,email,subject,message,consent
|
||||
2026-09-29T18:51:35.694Z,Max Muster,test10@web.de,Erstgespräch für eine Ernährungsberatung,"Guten Tag,
|
||||
|
||||
ich möchte einen termin vereinbaren, können sie sich bei mir melden.
|
||||
|
||||
Mfg
|
||||
Max Muster"
|
||||
Max Muster",true
|
||||
2026-09-29T19:27:34.639Z,Test Test,test11@web.de,Erstgespräch Ernährung,"Guten Tag,
|
||||
|
||||
können sie sich bei mir melden.
|
||||
|
||||
Mfg
|
||||
Test"
|
||||
Test",true
|
||||
|
|
|
|||
|
|
|
@ -0,0 +1,3 @@
|
|||
createdAt,email,title,review,rating,consent
|
||||
2026-09-30T12:06:34.079Z,marvin@web.de,Das Premium Paket hat mir sehr geholfen!,Ich hatte eine super Erfahrung mit der Lilli beim Premium Paket. Ich konnte schnell Termine buchen und Lilli konnte immer auf die Sonderwünsche der Wettkampfbegleitung eingehen. Ich hab sie einmal auf eine Allergie aufmerksam gemacht und sie hat meinen Plan umgehend angepasst. Gerne wieder!,5,true
|
||||
2026-09-30T12:10:41.673Z,test@web.de,"Tolle Beratung, aber etwas zu teuer!",Ich bin insgesamt sehr zufrieden gewesen und konnte meine Ernährung erfolgreich umstellen. Für mich ist der Preis aber etwas zu hoch gewesen.,4,true
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { createReviewStorage } from "./services/review-storage.js";
|
||||
import { handleReview } from "./routes/reviews.js";
|
||||
import { createContactService } from "./services/contact-service.js";
|
||||
import { createContactEmailService } from "./services/contact-email.js";
|
||||
import { handleContact } from "./routes/contact.js";
|
||||
|
|
@ -21,6 +23,9 @@ export function createApp({
|
|||
storage = createCsvStorage(
|
||||
fileURLToPath(new URL("./data/bookings.csv", import.meta.url)),
|
||||
),
|
||||
reviewStorage = createReviewStorage(
|
||||
fileURLToPath(new URL("./data/reviews.csv", import.meta.url)),
|
||||
),
|
||||
details,
|
||||
sendConfirmation,
|
||||
sendContact,
|
||||
|
|
@ -28,6 +33,17 @@ export function createApp({
|
|||
fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)),
|
||||
),
|
||||
sendContactForm,
|
||||
advisorContactStorage = createContactFormStorage(
|
||||
fileURLToPath(new URL("./data/advisor-contacts.csv", import.meta.url)),
|
||||
[
|
||||
"createdAt",
|
||||
"email",
|
||||
"advisorId",
|
||||
"reason",
|
||||
"simulationAccepted",
|
||||
"consent",
|
||||
],
|
||||
),
|
||||
} = {}) {
|
||||
const ids = new Set();
|
||||
for (const entry of catalog) {
|
||||
|
|
@ -56,6 +72,7 @@ export function createApp({
|
|||
});
|
||||
const contact = createContactService({
|
||||
storage,
|
||||
requestStorage: advisorContactStorage,
|
||||
sendContact: sendContact ?? createContactEmailService(),
|
||||
});
|
||||
// Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand.
|
||||
|
|
@ -65,6 +82,9 @@ export function createApp({
|
|||
});
|
||||
return createServer(async (request, response) => {
|
||||
const path = new URL(request.url, "http://localhost").pathname;
|
||||
if (path === "/api/reviews") {
|
||||
return handleReview(request, response, storage, reviewStorage);
|
||||
}
|
||||
// Leitet die Ernährungsseite an ihren eigenen Kontaktformular-Endpunkt.
|
||||
if (path === "/api/nutrition-contact") {
|
||||
return handleContactForm(request, response, contactForm);
|
||||
|
|
|
|||
|
|
@ -1,81 +1,81 @@
|
|||
import { BookingError } from "../services/booking-service.js";
|
||||
|
||||
export function json(response, status, body) {
|
||||
response.writeHead(status, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
export async function readJson(request) {
|
||||
if (
|
||||
request.headers["content-type"]?.split(";")[0].trim() !==
|
||||
"application/json"
|
||||
) {
|
||||
throw new BookingError(
|
||||
415,
|
||||
"UNSUPPORTED_CONTENT_TYPE",
|
||||
"Bitte JSON senden.",
|
||||
);
|
||||
}
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > 8192)
|
||||
throw new BookingError(
|
||||
413,
|
||||
"BODY_TOO_LARGE",
|
||||
"Die Anfrage ist zu groß.",
|
||||
);
|
||||
chunks.push(chunk);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
} catch {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_JSON",
|
||||
"Die Anfrage enthält kein gültiges JSON.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleBooking(
|
||||
request,
|
||||
response,
|
||||
service,
|
||||
{ preview = false } = {},
|
||||
) {
|
||||
try {
|
||||
const input = await readJson(request);
|
||||
if (preview) return json(response, 200, service.preview(input));
|
||||
const result = await service.book(
|
||||
input,
|
||||
request.headers["idempotency-key"],
|
||||
);
|
||||
json(response, result.status, result.body);
|
||||
} catch (error) {
|
||||
if (error instanceof BookingError) {
|
||||
json(response, error.status, {
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
fields: error.fields,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Keine persönlichen Daten oder SMTP-Zugangsdaten ins Log schreiben.
|
||||
console.error("Buchungsanfrage unerwartet fehlgeschlagen.");
|
||||
json(response, 500, {
|
||||
error: {
|
||||
code: "INTERNAL_ERROR",
|
||||
message:
|
||||
"Der Status ist unklar. Bitte dieselbe Anfrage erneut versuchen.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
import { BookingError } from "../services/booking-service.js";
|
||||
|
||||
export function json(response, status, body) {
|
||||
response.writeHead(status, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
export async function readJson(request) {
|
||||
if (
|
||||
request.headers["content-type"]?.split(";")[0].trim() !==
|
||||
"application/json"
|
||||
) {
|
||||
throw new BookingError(
|
||||
415,
|
||||
"UNSUPPORTED_CONTENT_TYPE",
|
||||
"Bitte JSON senden.",
|
||||
);
|
||||
}
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > 8192)
|
||||
throw new BookingError(
|
||||
413,
|
||||
"BODY_TOO_LARGE",
|
||||
"Die Anfrage ist zu groß.",
|
||||
);
|
||||
chunks.push(chunk);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
} catch {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_JSON",
|
||||
"Die Anfrage enthält kein gültiges JSON.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleBooking(
|
||||
request,
|
||||
response,
|
||||
service,
|
||||
{ preview = false } = {},
|
||||
) {
|
||||
try {
|
||||
const input = await readJson(request);
|
||||
if (preview) return json(response, 200, service.preview(input));
|
||||
const result = await service.book(
|
||||
input,
|
||||
request.headers["idempotency-key"],
|
||||
);
|
||||
json(response, result.status, result.body);
|
||||
} catch (error) {
|
||||
if (error instanceof BookingError) {
|
||||
json(response, error.status, {
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
fields: error.fields,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Keine persönlichen Daten oder SMTP-Zugangsdaten ins Log schreiben.
|
||||
console.error("Buchungsanfrage unerwartet fehlgeschlagen.");
|
||||
json(response, 500, {
|
||||
error: {
|
||||
code: "INTERNAL_ERROR",
|
||||
message:
|
||||
"Der Status ist unklar. Bitte dieselbe Anfrage erneut versuchen.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +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.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
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.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +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.",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
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.",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
import { BookingError, emailPattern } from "../services/booking-service.js";
|
||||
import { json, readJson } from "./bookings.js";
|
||||
|
||||
export async function handleReview(request, response, bookings, reviews) {
|
||||
if (!["GET", "POST"].includes(request.method)) {
|
||||
response.setHeader("Allow", "GET, POST");
|
||||
return json(response, 405, {
|
||||
error: { message: "Bitte GET oder POST verwenden." },
|
||||
});
|
||||
}
|
||||
try {
|
||||
if (request.method === "GET") {
|
||||
const records = await reviews.readAll();
|
||||
return json(response, 200, {
|
||||
reviews: records
|
||||
.filter((record) => record.consent === "true")
|
||||
.map(({ createdAt, title, review, rating }) => ({
|
||||
createdAt,
|
||||
title,
|
||||
review,
|
||||
rating: Number(rating),
|
||||
})),
|
||||
});
|
||||
}
|
||||
const input = await readJson(request);
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_INPUT",
|
||||
"Bitte das Formular ausfüllen.",
|
||||
);
|
||||
}
|
||||
const fields = {};
|
||||
const email =
|
||||
typeof input.email === "string"
|
||||
? input.email.trim().toLowerCase()
|
||||
: "";
|
||||
if (email.length > 254 || !emailPattern.test(email)) {
|
||||
fields.email = "Bitte eine gültige E-Mail-Adresse eingeben.";
|
||||
}
|
||||
const record = { email };
|
||||
for (const [field, max, label] of [
|
||||
["title", 50, "Titel"],
|
||||
["review", 500, "Bewertungstext"],
|
||||
]) {
|
||||
const value =
|
||||
typeof input[field] === "string" ? input[field].trim() : "";
|
||||
if (value.length < 10 || value.length > max) {
|
||||
fields[field] =
|
||||
`${label}: Bitte 10 bis ${max} Zeichen eingeben.`;
|
||||
}
|
||||
record[field] = value;
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(input.rating) ||
|
||||
input.rating < 1 ||
|
||||
input.rating > 5
|
||||
) {
|
||||
fields.rating = "Bitte 1 bis 5 Sterne auswählen.";
|
||||
}
|
||||
if (input.consent !== true) {
|
||||
fields.consent = "Bitte die Datenschutzbestimmungen akzeptieren.";
|
||||
}
|
||||
if (Object.keys(fields).length) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_INPUT",
|
||||
Object.values(fields).join(" "),
|
||||
fields,
|
||||
);
|
||||
}
|
||||
const customers = await bookings.readAll();
|
||||
if (
|
||||
!customers.some(
|
||||
(booking) => booking.email.trim().toLowerCase() === email,
|
||||
)
|
||||
) {
|
||||
throw new BookingError(
|
||||
403,
|
||||
"BOOKING_REQUIRED",
|
||||
"Unter dieser E-Mail-Adresse wurde keine Buchung gefunden. Bitte verwende die E-Mail-Adresse deiner Buchung.",
|
||||
);
|
||||
}
|
||||
await reviews.append({
|
||||
...record,
|
||||
rating: input.rating,
|
||||
consent: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
return json(response, 201, { saved: true });
|
||||
} catch (error) {
|
||||
if (error instanceof BookingError) {
|
||||
return json(response, error.status, {
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
fields: error.fields,
|
||||
},
|
||||
});
|
||||
}
|
||||
return json(response, 503, {
|
||||
error: {
|
||||
code:
|
||||
request.method === "GET"
|
||||
? "REVIEWS_UNAVAILABLE"
|
||||
: "REVIEW_NOT_SAVED",
|
||||
message:
|
||||
request.method === "GET"
|
||||
? "Die Bewertungen können gerade nicht geladen werden."
|
||||
: "Die Bewertung konnte nicht gespeichert werden. Bitte versuche es später erneut.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -82,7 +82,9 @@ function validateInput(input, key) {
|
|||
};
|
||||
}
|
||||
const fields = {};
|
||||
const result = {};
|
||||
if (input.consent !== true)
|
||||
fields.consent = "Bitte die Datenschutzbestimmungen akzeptieren.";
|
||||
const result = { consent: true };
|
||||
for (const [field, max, message] of [
|
||||
["packageId", 80, "Bitte ein gültiges Paket auswählen."],
|
||||
["name", 120, "Bitte einen Namen mit höchstens 120 Zeichen eingeben."],
|
||||
|
|
@ -315,6 +317,7 @@ export function createBookingService({
|
|||
bookedPackage,
|
||||
customer: data.customer ?? null,
|
||||
acceptedTerms: data.acceptedTerms ?? null,
|
||||
consent: data.consent,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
phone: data.customer?.phone ?? data.phone ?? "",
|
||||
|
|
|
|||
|
|
@ -1,53 +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.",
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
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.",
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,83 +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 };
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ function normalizeInput(input) {
|
|||
// 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 = {};
|
||||
const data = { consent: true };
|
||||
if (input.consent !== true)
|
||||
fields.consent = "Bitte die Datenschutzbestimmungen akzeptieren.";
|
||||
for (const [field, max] of Object.entries(limits)) {
|
||||
const value = input[field];
|
||||
if (typeof value !== "string") {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,14 @@ 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"];
|
||||
const defaultColumns = [
|
||||
"createdAt",
|
||||
"name",
|
||||
"email",
|
||||
"subject",
|
||||
"message",
|
||||
"consent",
|
||||
];
|
||||
|
||||
// Schützt CSV-Zellen vor Tabellenformeln, wenn die Datei geöffnet wird.
|
||||
function protect(value) {
|
||||
|
|
@ -19,7 +26,7 @@ function restore(value) {
|
|||
}
|
||||
|
||||
// Erstellt den CSV-Speicher und hält Schreibvorgänge dieser Instanz in einer Warteschlange.
|
||||
export function createContactFormStorage(filePath) {
|
||||
export function createContactFormStorage(filePath, columns = defaultColumns) {
|
||||
let queue = Promise.resolve();
|
||||
|
||||
// Liest alle Anfragen ein und prüft, ob die Datei den erwarteten CSV-Kopf besitzt.
|
||||
|
|
@ -35,7 +42,13 @@ export function createContactFormStorage(filePath) {
|
|||
return parse(content, {
|
||||
bom: true,
|
||||
columns(header) {
|
||||
if (header.join(",") !== columns.join(",")) {
|
||||
if (
|
||||
header.join(",") !== columns.join(",") &&
|
||||
header.join(",") !==
|
||||
columns
|
||||
.filter((column) => column !== "consent")
|
||||
.join(",")
|
||||
) {
|
||||
throw new Error("Unbekanntes Kontakt-CSV-Format");
|
||||
}
|
||||
return header;
|
||||
|
|
@ -45,7 +58,7 @@ export function createContactFormStorage(filePath) {
|
|||
Object.fromEntries(
|
||||
Object.entries(row).map(([key, value]) => [
|
||||
key,
|
||||
restore(value),
|
||||
key === "consent" ? value === "true" : restore(value),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ function advisorDetails(advisorId) {
|
|||
return { ...advisor };
|
||||
}
|
||||
|
||||
export function createContactService({ storage, sendContact }) {
|
||||
export function createContactService({ storage, requestStorage, sendContact }) {
|
||||
async function validate(input) {
|
||||
if (
|
||||
!input ||
|
||||
|
|
@ -93,6 +93,29 @@ export function createContactService({ storage, sendContact }) {
|
|||
"Bitte den Simulationshinweis bestätigen.",
|
||||
);
|
||||
}
|
||||
if (input.consent !== true) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"CONSENT_REQUIRED",
|
||||
"Bitte die Datenschutzbestimmungen akzeptieren.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
await requestStorage.append({
|
||||
createdAt: new Date().toISOString(),
|
||||
email,
|
||||
advisorId: advisor.id,
|
||||
reason: input.reason,
|
||||
simulationAccepted: true,
|
||||
consent: true,
|
||||
});
|
||||
} catch {
|
||||
throw new BookingError(
|
||||
503,
|
||||
"CONTACT_NOT_SAVED",
|
||||
"Die Kontaktanfrage konnte nicht gespeichert werden.",
|
||||
);
|
||||
}
|
||||
await sendContact({
|
||||
email,
|
||||
advisor,
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@ const legacyColumns = [
|
|||
|
||||
const orderColumns = [...legacyColumns, "bookedPackage"];
|
||||
const customerColumns = [...orderColumns, "customer", "acceptedTerms"];
|
||||
export const columns = [...customerColumns, "phone"];
|
||||
const jsonColumns = ["bookedPackage", "customer", "acceptedTerms"];
|
||||
const consentColumns = [...customerColumns, "consent"];
|
||||
const phoneColumns = [...customerColumns, "phone"];
|
||||
export const columns = [...phoneColumns, "consent"];
|
||||
const jsonColumns = ["bookedPackage", "customer", "acceptedTerms", "consent"];
|
||||
|
||||
// Apostroph-Präfix schützt Tabellenprogramme. Ein vorhandenes Apostroph wird
|
||||
// ebenfalls maskiert, damit Lesen/Schreiben die Originalwerte exakt erhält.
|
||||
|
|
@ -49,6 +51,8 @@ export function createCsvStorage(filePath) {
|
|||
if (
|
||||
header.join(",") !== columns.join(",") &&
|
||||
header.join(",") !== customerColumns.join(",") &&
|
||||
header.join(",") !== phoneColumns.join(",") &&
|
||||
header.join(",") !== consentColumns.join(",") &&
|
||||
header.join(",") !== legacyColumns.join(",") &&
|
||||
header.join(",") !== orderColumns.join(",")
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -1,132 +1,132 @@
|
|||
import nodemailer from "nodemailer";
|
||||
import { orderSummaryRows } from "../../shared/order-summary.js";
|
||||
import { paymentNotice } from "../../shared/booking-copy.js";
|
||||
|
||||
export function createMailTransport(
|
||||
env = process.env,
|
||||
makeTransport = nodemailer.createTransport,
|
||||
) {
|
||||
const localHosts = ["127.0.0.1", "localhost", "::1", "mailpit"];
|
||||
const host = env.SMTP_HOST || "127.0.0.1";
|
||||
const local = localHosts.includes(host);
|
||||
if (!local && env.SMTP_ALLOW_EXTERNAL !== "true") {
|
||||
throw new Error(
|
||||
"Externes SMTP ist gesperrt. Erst nach ausdrücklicher Freigabe SMTP_ALLOW_EXTERNAL=true setzen.",
|
||||
);
|
||||
}
|
||||
const port = Number(env.SMTP_PORT || (local ? 1025 : 587));
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
||||
throw new Error("Ungültiger SMTP_PORT");
|
||||
if (Boolean(env.SMTP_USER) !== Boolean(env.SMTP_PASS))
|
||||
throw new Error("SMTP_USER und SMTP_PASS gemeinsam setzen");
|
||||
const transport = makeTransport({
|
||||
host,
|
||||
port,
|
||||
secure: env.SMTP_SECURE === "true",
|
||||
requireTLS: !local,
|
||||
auth: env.SMTP_USER
|
||||
? { user: env.SMTP_USER, pass: env.SMTP_PASS }
|
||||
: undefined,
|
||||
connectionTimeout: 10000,
|
||||
greetingTimeout: 10000,
|
||||
socketTimeout: 20000,
|
||||
disableFileAccess: true,
|
||||
disableUrlAccess: true,
|
||||
});
|
||||
const from = env.SMTP_FROM || "Tri-Hub <buchung@example.test>";
|
||||
return { transport, from };
|
||||
}
|
||||
|
||||
export function createEmailService(
|
||||
env = process.env,
|
||||
makeTransport = nodemailer.createTransport,
|
||||
) {
|
||||
const { transport, from } = createMailTransport(env, makeTransport);
|
||||
return async function sendConfirmation(booking) {
|
||||
const order = booking.bookedPackage;
|
||||
const phone = booking.phone || booking.customer?.phone;
|
||||
const lines = [
|
||||
`Hallo ${booking.name},`,
|
||||
"",
|
||||
"vielen Dank für deine Bestellung bei Tri-Hub. Hier findest du die Zusammenfassung deiner Buchung.",
|
||||
`Buchungsnummer: ${booking.bookingId}`,
|
||||
...(booking.createdAt
|
||||
? [
|
||||
`Bestelldatum: ${new Intl.DateTimeFormat("de-DE", { dateStyle: "long", timeStyle: "short", timeZone: "Europe/Berlin" }).format(new Date(booking.createdAt))} (Europe/Berlin)`,
|
||||
]
|
||||
: []),
|
||||
`Kunde: ${booking.name}`,
|
||||
`E-Mail: ${booking.email}`,
|
||||
...(phone ? [`Telefon: ${phone}`] : []),
|
||||
...(booking.customer?.notes
|
||||
? [`Deine Anmerkungen: ${booking.customer.notes}`]
|
||||
: []),
|
||||
"",
|
||||
`Paket: ${order?.title ?? booking.packageName}`,
|
||||
...(order
|
||||
? [
|
||||
order.summaryForBooking,
|
||||
order.subtitle,
|
||||
"",
|
||||
...orderSummaryRows(order).map(
|
||||
([label, value]) => `${label}: ${value}`,
|
||||
),
|
||||
"",
|
||||
"Enthaltene Leistungen:",
|
||||
...order.includedFeatures.map(
|
||||
(feature) => `• ${feature}`,
|
||||
),
|
||||
"",
|
||||
"So geht es weiter:",
|
||||
...(order.processSteps || []).map(
|
||||
({ step, text }) => `${step}: ${text}`,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
"",
|
||||
paymentNotice,
|
||||
"",
|
||||
"Bitte bewahre diese Bestätigung auf und gib bei Rückfragen deine Buchungsnummer an.",
|
||||
"Dein Tri-Hub-Team",
|
||||
];
|
||||
const escapeHtml = (value) =>
|
||||
String(value).replace(
|
||||
/[&<>"']/g,
|
||||
(char) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[char],
|
||||
);
|
||||
let info;
|
||||
try {
|
||||
info = await transport.sendMail({
|
||||
from,
|
||||
to: { address: booking.email, name: booking.name },
|
||||
subject: `Bestellbestätigung ${booking.bookingId} – Tri-Hub`,
|
||||
text: lines.join("\n"),
|
||||
html: `<html lang="de"><body><h1>Deine Bestellbestätigung</h1>${lines.map((line) => (line ? `<p>${escapeHtml(line)}</p>` : "")).join("")}</body></html>`,
|
||||
});
|
||||
} catch (error) {
|
||||
// Bei Verbindungsabbruch kann SMTP die Nachricht schon angenommen
|
||||
// haben. Nur ausdrückliche Ablehnungen gelten sicher als Fehler.
|
||||
const explicitRejection =
|
||||
["EAUTH", "EENVELOPE", "ECONNECTION", "EDNS"].includes(
|
||||
error.code,
|
||||
) || Number(error.responseCode) >= 400;
|
||||
error.deliveryUnknown = !explicitRejection;
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
!info.accepted?.some(
|
||||
(address) =>
|
||||
address.toLowerCase() === booking.email.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
throw new Error("Empfänger vom Mailserver nicht angenommen");
|
||||
}
|
||||
};
|
||||
}
|
||||
import nodemailer from "nodemailer";
|
||||
import { orderSummaryRows } from "../../shared/order-summary.js";
|
||||
import { paymentNotice } from "../../shared/booking-copy.js";
|
||||
|
||||
export function createMailTransport(
|
||||
env = process.env,
|
||||
makeTransport = nodemailer.createTransport,
|
||||
) {
|
||||
const localHosts = ["127.0.0.1", "localhost", "::1", "mailpit"];
|
||||
const host = env.SMTP_HOST || "127.0.0.1";
|
||||
const local = localHosts.includes(host);
|
||||
if (!local && env.SMTP_ALLOW_EXTERNAL !== "true") {
|
||||
throw new Error(
|
||||
"Externes SMTP ist gesperrt. Erst nach ausdrücklicher Freigabe SMTP_ALLOW_EXTERNAL=true setzen.",
|
||||
);
|
||||
}
|
||||
const port = Number(env.SMTP_PORT || (local ? 1025 : 587));
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
||||
throw new Error("Ungültiger SMTP_PORT");
|
||||
if (Boolean(env.SMTP_USER) !== Boolean(env.SMTP_PASS))
|
||||
throw new Error("SMTP_USER und SMTP_PASS gemeinsam setzen");
|
||||
const transport = makeTransport({
|
||||
host,
|
||||
port,
|
||||
secure: env.SMTP_SECURE === "true",
|
||||
requireTLS: !local,
|
||||
auth: env.SMTP_USER
|
||||
? { user: env.SMTP_USER, pass: env.SMTP_PASS }
|
||||
: undefined,
|
||||
connectionTimeout: 10000,
|
||||
greetingTimeout: 10000,
|
||||
socketTimeout: 20000,
|
||||
disableFileAccess: true,
|
||||
disableUrlAccess: true,
|
||||
});
|
||||
const from = env.SMTP_FROM || "Tri-Hub <buchung@example.test>";
|
||||
return { transport, from };
|
||||
}
|
||||
|
||||
export function createEmailService(
|
||||
env = process.env,
|
||||
makeTransport = nodemailer.createTransport,
|
||||
) {
|
||||
const { transport, from } = createMailTransport(env, makeTransport);
|
||||
return async function sendConfirmation(booking) {
|
||||
const order = booking.bookedPackage;
|
||||
const phone = booking.phone || booking.customer?.phone;
|
||||
const lines = [
|
||||
`Hallo ${booking.name},`,
|
||||
"",
|
||||
"vielen Dank für deine Bestellung bei Tri-Hub. Hier findest du die Zusammenfassung deiner Buchung.",
|
||||
`Buchungsnummer: ${booking.bookingId}`,
|
||||
...(booking.createdAt
|
||||
? [
|
||||
`Bestelldatum: ${new Intl.DateTimeFormat("de-DE", { dateStyle: "long", timeStyle: "short", timeZone: "Europe/Berlin" }).format(new Date(booking.createdAt))} (Europe/Berlin)`,
|
||||
]
|
||||
: []),
|
||||
`Kunde: ${booking.name}`,
|
||||
`E-Mail: ${booking.email}`,
|
||||
...(phone ? [`Telefon: ${phone}`] : []),
|
||||
...(booking.customer?.notes
|
||||
? [`Deine Anmerkungen: ${booking.customer.notes}`]
|
||||
: []),
|
||||
"",
|
||||
`Paket: ${order?.title ?? booking.packageName}`,
|
||||
...(order
|
||||
? [
|
||||
order.summaryForBooking,
|
||||
order.subtitle,
|
||||
"",
|
||||
...orderSummaryRows(order).map(
|
||||
([label, value]) => `${label}: ${value}`,
|
||||
),
|
||||
"",
|
||||
"Enthaltene Leistungen:",
|
||||
...order.includedFeatures.map(
|
||||
(feature) => `• ${feature}`,
|
||||
),
|
||||
"",
|
||||
"So geht es weiter:",
|
||||
...(order.processSteps || []).map(
|
||||
({ step, text }) => `${step}: ${text}`,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
"",
|
||||
paymentNotice,
|
||||
"",
|
||||
"Bitte bewahre diese Bestätigung auf und gib bei Rückfragen deine Buchungsnummer an.",
|
||||
"Dein Tri-Hub-Team",
|
||||
];
|
||||
const escapeHtml = (value) =>
|
||||
String(value).replace(
|
||||
/[&<>"']/g,
|
||||
(char) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[char],
|
||||
);
|
||||
let info;
|
||||
try {
|
||||
info = await transport.sendMail({
|
||||
from,
|
||||
to: { address: booking.email, name: booking.name },
|
||||
subject: `Bestellbestätigung ${booking.bookingId} – Tri-Hub`,
|
||||
text: lines.join("\n"),
|
||||
html: `<html lang="de"><body><h1>Deine Bestellbestätigung</h1>${lines.map((line) => (line ? `<p>${escapeHtml(line)}</p>` : "")).join("")}</body></html>`,
|
||||
});
|
||||
} catch (error) {
|
||||
// Bei Verbindungsabbruch kann SMTP die Nachricht schon angenommen
|
||||
// haben. Nur ausdrückliche Ablehnungen gelten sicher als Fehler.
|
||||
const explicitRejection =
|
||||
["EAUTH", "EENVELOPE", "ECONNECTION", "EDNS"].includes(
|
||||
error.code,
|
||||
) || Number(error.responseCode) >= 400;
|
||||
error.deliveryUnknown = !explicitRejection;
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
!info.accepted?.some(
|
||||
(address) =>
|
||||
address.toLowerCase() === booking.email.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
throw new Error("Empfänger vom Mailserver nicht angenommen");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { stringify } from "csv-stringify/sync";
|
||||
|
||||
// Diese Spalten bilden den festen Aufbau der Bewertungen-CSV.
|
||||
const columns = ["createdAt", "email", "title", "review", "rating", "consent"];
|
||||
|
||||
// Schützt CSV-Zellen vor Tabellenformeln, wenn die Datei geöffnet wird.
|
||||
function protect(value) {
|
||||
const text = String(value ?? "");
|
||||
return /^(?:'|[\t\r\n]|\s*[=+@-])/u.test(text) ? `'${text}` : text;
|
||||
}
|
||||
|
||||
// Entfernt beim Einlesen den Schutzpräfix und stellt den Eingabewert wieder her.
|
||||
function restore(value) {
|
||||
return value.startsWith("'") ? value.slice(1) : value;
|
||||
}
|
||||
|
||||
// Erstellt den CSV-Speicher und hält Schreibvorgänge dieser Instanz in einer Warteschlange.
|
||||
export function createReviewStorage(filePath) {
|
||||
let queue = Promise.resolve();
|
||||
|
||||
// Liest alle Bewertungen ein und prüft, ob die Datei den erwarteten CSV-Kopf besitzt.
|
||||
async function readAll() {
|
||||
let content;
|
||||
try {
|
||||
content = await readFile(filePath, "utf8");
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
if (!content.trim()) throw new Error("Leere Bewertungsdatei");
|
||||
return parse(content, {
|
||||
bom: true,
|
||||
columns(header) {
|
||||
if (header.join(",") !== columns.join(",")) {
|
||||
throw new Error("Unbekanntes Bewertungs-CSV-Format");
|
||||
}
|
||||
return header;
|
||||
},
|
||||
skip_empty_lines: true,
|
||||
}).map((row) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(row).map(([key, value]) => [
|
||||
key,
|
||||
restore(value),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Schreibt die vollständige CSV in eine temporäre Datei und ersetzt danach atomar das Original.
|
||||
async function writeAll(records) {
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
const temporary = `${filePath}.${randomUUID()}.tmp`;
|
||||
const content = stringify(
|
||||
records.map((row) =>
|
||||
Object.fromEntries(
|
||||
columns.map((column) => [column, protect(row[column])]),
|
||||
),
|
||||
),
|
||||
{ header: true, columns, record_delimiter: "\r\n" },
|
||||
);
|
||||
try {
|
||||
const handle = await open(temporary, "wx", 0o600);
|
||||
try {
|
||||
await handle.writeFile(content, "utf8");
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await rename(temporary, filePath);
|
||||
} finally {
|
||||
await rm(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
readAll,
|
||||
// Hängt eine Bewertung nacheinander an, damit parallele Absendevorgänge keine Zeilen verlieren.
|
||||
append(record) {
|
||||
const operation = queue.then(async () => {
|
||||
const records = await readAll();
|
||||
records.push(record);
|
||||
await writeAll(records);
|
||||
});
|
||||
queue = operation.catch(() => {});
|
||||
return operation;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -359,7 +359,8 @@
|
|||
"email",
|
||||
"advisorId",
|
||||
"reason",
|
||||
"simulationAccepted"
|
||||
"simulationAccepted",
|
||||
"consent"
|
||||
],
|
||||
"properties": {
|
||||
"email": {
|
||||
|
|
@ -387,6 +388,10 @@
|
|||
"type": "string",
|
||||
"example": "relindis-agethen",
|
||||
"description": "ID aus src/shared/berater-daten.json. Name und E-Mail werden serverseitig ausschließlich dort nachgeschlagen."
|
||||
},
|
||||
"consent": {
|
||||
"type": "boolean",
|
||||
"enum": [true]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -809,7 +814,7 @@
|
|||
},
|
||||
"BookingCreateRequest": {
|
||||
"type": "object",
|
||||
"required": ["packageId"],
|
||||
"required": ["packageId", "consent"],
|
||||
"properties": {
|
||||
"packageId": {
|
||||
"type": "string",
|
||||
|
|
@ -875,6 +880,11 @@
|
|||
"type": "boolean",
|
||||
"example": true,
|
||||
"enum": [true]
|
||||
},
|
||||
"consent": {
|
||||
"type": "boolean",
|
||||
"enum": [true],
|
||||
"description": "Zustimmung zu den Datenschutzbestimmungen ist erforderlich."
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
|
|
|
|||
|
|
@ -1,64 +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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,11 +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() || "";
|
||||
}
|
||||
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,15 +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";
|
||||
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,229 @@
|
|||
.trihub-footer,
|
||||
.trihub-footer * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.trihub-footer {
|
||||
padding: 4.75rem 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0 0 1.8rem 1.8rem;
|
||||
background: #06171b;
|
||||
color: #fff;
|
||||
font-family: "DM Sans", sans-serif;
|
||||
}
|
||||
|
||||
.trihub-footer-inner {
|
||||
width: min(100% - 4rem, 1648px);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1.35fr 1fr 1fr;
|
||||
gap: 3.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.trihub-footer-card {
|
||||
min-width: 0;
|
||||
min-height: 34.75rem;
|
||||
padding: 2rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 1.8rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.trihub-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.4rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.trihub-logo-box {
|
||||
width: 100px;
|
||||
height: 90px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 1.1rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.trihub-logo-box img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.trihub-brand-label,
|
||||
.trihub-brand-title {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.trihub-brand-label {
|
||||
margin: 0 0 0.3rem;
|
||||
color: rgba(255, 255, 255, 0.46);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.trihub-brand-title {
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.trihub-description {
|
||||
max-width: 36rem;
|
||||
margin: 2.3rem 0 0;
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
.trihub-contact {
|
||||
margin-top: 2.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.trihub-contact-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.trihub-contact-row p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.trihub-contact-row a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.trihub-contact-row svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-top: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
color: #ffc21f;
|
||||
}
|
||||
|
||||
.trihub-socials {
|
||||
margin-top: 2.1rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.trihub-social-button {
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.trihub-social-button svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.trihub-social-button:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.trihub-footer-heading {
|
||||
margin: 0 0 1.7rem;
|
||||
display: flex;
|
||||
font-family: "DM Sans", sans-serif;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.46);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.trihub-footer-heading svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: #00c7d5;
|
||||
}
|
||||
|
||||
.trihub-footer-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.trihub-footer-links a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.trihub-footer-links a:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.trihub-footer-links .trihub-cookie-link {
|
||||
margin: 0.7rem 0 0;
|
||||
color: #00bfd2;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.trihub-footer-links .trihub-cookie-link:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.trihub-footer-inner {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.trihub-footer-card {
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.trihub-footer {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.trihub-footer-inner {
|
||||
width: min(100% - 2rem, 1648px);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.trihub-footer-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.trihub-brand {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
|
@ -72,6 +72,15 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.package-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1.42 / 1;
|
||||
margin: 0 0 1.5rem;
|
||||
border: 1px solid #f8fafc1f;
|
||||
border-radius: 1.1rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
.package-type-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
|
|
@ -1,83 +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/);
|
||||
});
|
||||
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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { createEmailService } from "../server/services/email-service.js";
|
|||
import { packages } from "../shared/packages.js";
|
||||
|
||||
const input = {
|
||||
consent: true,
|
||||
packageId: packages[0].id,
|
||||
name: 'Test, "Person"',
|
||||
email: "person@example.test",
|
||||
|
|
@ -68,6 +69,7 @@ test("Buchung steht vor Versand in CSV; Paketname kommt vom Server", async (t) =
|
|||
assert.ok(!Number.isNaN(Date.parse(result.body.createdAt)));
|
||||
const records = await fixture.storage.readAll();
|
||||
assert.equal(records.length, 1);
|
||||
assert.equal(records[0].consent, true);
|
||||
assert.equal(records[0].name, input.name);
|
||||
assert.equal(records[0].emailStatus, "accepted");
|
||||
assert.equal(fixture.sent.length, 1);
|
||||
|
|
@ -81,6 +83,7 @@ test("Pflichtfelder, Typen, Längen, E-Mail und unbekannte IDs werden abgelehnt"
|
|||
null,
|
||||
[],
|
||||
{},
|
||||
...[false, "true", undefined].map((consent) => ({ ...input, consent })),
|
||||
{ ...input, name: " " },
|
||||
{ ...input, name: 42 },
|
||||
{ ...input, name: "a".repeat(121) },
|
||||
|
|
@ -583,6 +586,7 @@ test("Swagger-Kundendaten und Zustimmung werden validiert und dauerhaft gespeich
|
|||
notes: "Vorbereitung auf die Mitteldistanz.",
|
||||
};
|
||||
const request = {
|
||||
consent: true,
|
||||
packageId: "ernaehrung-standard",
|
||||
customer,
|
||||
acceptedTerms: true,
|
||||
|
|
@ -633,6 +637,7 @@ test("Telefonnummern werden in beiden Anfrageformaten geprüft", async (t) => {
|
|||
{ ...input, phone },
|
||||
{
|
||||
packageId: input.packageId,
|
||||
consent: true,
|
||||
acceptedTerms: true,
|
||||
customer: {
|
||||
firstName: "Test",
|
||||
|
|
|
|||
|
|
@ -1,113 +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");
|
||||
});
|
||||
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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,12 +5,18 @@ import { packagesDetails } from "../../shared/packagesdetails.js";
|
|||
|
||||
const pageUrl = "/booking.html#/buchen/ernaehrung-starter";
|
||||
async function fill(page) {
|
||||
if (!(await page.locator("dialog[open]").count())) {
|
||||
await page
|
||||
.getByRole("button", { name: "Jetzt buchen", exact: true })
|
||||
.click();
|
||||
}
|
||||
await page
|
||||
.getByLabel("Name (Pflichtfeld)", { exact: true })
|
||||
.fill("Test Person");
|
||||
await page
|
||||
.getByLabel("E-Mail-Adresse (Pflichtfeld)")
|
||||
.fill("person@example.test");
|
||||
await page.locator('[name="consent"]').check();
|
||||
}
|
||||
|
||||
test("Tastaturbedienung, Validierung und vollständige Buchung über Vite-Proxy", async ({
|
||||
|
|
@ -26,41 +32,47 @@ test("Tastaturbedienung, Validierung und vollständige Buchung über Vite-Proxy"
|
|||
await expect(page.locator(".booking__payment-notice")).toContainText(
|
||||
"keine Abbuchung",
|
||||
);
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Zur Angebotsseite" }),
|
||||
).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
const opener = page.getByRole("button", {
|
||||
name: "Jetzt buchen",
|
||||
exact: true,
|
||||
});
|
||||
await opener.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(
|
||||
page.getByLabel("Name (Pflichtfeld)", { exact: true }),
|
||||
).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("alert")).toBeFocused();
|
||||
await expect(page.locator('[aria-invalid="true"]')).toHaveCount(2);
|
||||
await expect(page.locator('[aria-invalid="true"]')).toHaveCount(3);
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.type("Test Person");
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.type("person@example.test");
|
||||
await page.keyboard.press("Tab");
|
||||
const outline = await page
|
||||
.getByRole("button", { name: "Paket buchen", exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).outlineStyle);
|
||||
expect(outline).toBe("solid");
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.locator('[name="consent"]')).toBeFocused();
|
||||
await page.keyboard.press("Space");
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Tab");
|
||||
const submit = page.getByRole("button", {
|
||||
name: "Paket buchen",
|
||||
exact: true,
|
||||
});
|
||||
await expect(submit).toBeFocused();
|
||||
expect(
|
||||
await submit.evaluate(
|
||||
(element) => getComputedStyle(element).outlineStyle,
|
||||
),
|
||||
).toBe("solid");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator(".booking__result")).toContainText(
|
||||
/Buchungsnummer: TH-\d{6,}/,
|
||||
await expect(page).toHaveURL(/booking-confirmation\.html$/);
|
||||
await expect(page.locator("#confirmation-receipt")).toContainText(
|
||||
/TH-\d{6,}/,
|
||||
);
|
||||
await expect(page.locator(".booking__result")).toContainText(
|
||||
"Zustellung ist noch nicht bestätigt",
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Buchung gespeichert" }),
|
||||
).toBeDisabled();
|
||||
await page.reload();
|
||||
await expect(page.locator(".booking__result")).toContainText(
|
||||
/Buchungsnummer: TH-\d{6,}/,
|
||||
await expect(page.locator("#confirmation-receipt")).toContainText(
|
||||
/TH-\d{6,}/,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -105,6 +117,9 @@ test("Verlorene Antwort: Wiederholung nach Neuladen behält Schlüssel und Buchu
|
|||
.click();
|
||||
await expect(page.getByRole("alert")).toBeVisible();
|
||||
await page.reload();
|
||||
await page
|
||||
.getByRole("button", { name: "Jetzt buchen", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByLabel("Name (Pflichtfeld)", { exact: true }),
|
||||
).toHaveValue("Test Person");
|
||||
|
|
@ -114,7 +129,9 @@ test("Verlorene Antwort: Wiederholung nach Neuladen behält Schlüssel und Buchu
|
|||
await page
|
||||
.getByRole("button", { name: "Status prüfen / erneut versuchen" })
|
||||
.click();
|
||||
await expect(page.locator(".booking__result")).toContainText(firstBooking);
|
||||
await expect(page.locator("#confirmation-receipt")).toContainText(
|
||||
firstBooking,
|
||||
);
|
||||
expect(requests).toBe(2);
|
||||
});
|
||||
|
||||
|
|
@ -186,7 +203,9 @@ test("Vite liefert Serverdateien und Umgebungsdateien nicht aus", async ({
|
|||
for (const pkg of packages) {
|
||||
test(`Von der Startseite zur Buchung: ${pkg.type}`, async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("link", { name: "Beratung kennenlernen" }).click();
|
||||
await page
|
||||
.getByRole("link", { name: "Ernährungswissen entdecken" })
|
||||
.click();
|
||||
await expect(page.locator(".package-card")).toHaveCount(
|
||||
packages.length,
|
||||
);
|
||||
|
|
@ -195,13 +214,11 @@ for (const pkg of packages) {
|
|||
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}`),
|
||||
);
|
||||
await expect(page.locator("dialog[open]")).toBeVisible();
|
||||
const details = packagesDetails.find((entry) => entry.id === pkg.id);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: details.title, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".booking__package-name")).toHaveText(
|
||||
details.title,
|
||||
);
|
||||
await expect(page.locator(".booking__summary")).toHaveText(
|
||||
details.summaryForBooking,
|
||||
);
|
||||
|
|
@ -219,13 +236,16 @@ for (const pkg of packages) {
|
|||
.click();
|
||||
const response = await responsePromise;
|
||||
expect(response.status()).toBe(201);
|
||||
const booking = await response.json();
|
||||
await expect(page).toHaveURL(/booking-confirmation\.html$/);
|
||||
const booking = await page.evaluate(() =>
|
||||
JSON.parse(sessionStorage.getItem("trihub.booking.confirmation")),
|
||||
);
|
||||
expect(booking.packageId).toBe(pkg.id);
|
||||
expect(booking.packageName).toBe(pkg.name);
|
||||
await expect(page.locator(".booking__result")).toContainText(
|
||||
await expect(page.locator("#confirmation-receipt")).toContainText(
|
||||
booking.bookingId,
|
||||
);
|
||||
await page.getByRole("link", { name: "Zur Angebotsseite" }).click();
|
||||
await page.goto("/angebotsuebersicht.html");
|
||||
await expect(page.locator(".package-card")).toHaveCount(
|
||||
packages.length,
|
||||
);
|
||||
|
|
@ -249,14 +269,11 @@ test("Premium-Jahresvariante wird von der Detailseite bis zur Bestätigung über
|
|||
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("dialog[open]")).toBeVisible();
|
||||
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) =>
|
||||
|
|
@ -270,15 +287,20 @@ test("Premium-Jahresvariante wird von der Detailseite bis zur Bestätigung über
|
|||
expect(response.request().postDataJSON().variantId).toBe(
|
||||
"ernaehrung-premium-52",
|
||||
);
|
||||
const booking = await response.json();
|
||||
await expect(page).toHaveURL(/booking-confirmation\.html$/);
|
||||
const booking = await page.evaluate(() =>
|
||||
JSON.parse(sessionStorage.getItem("trihub.booking.confirmation")),
|
||||
);
|
||||
expect(booking.bookedPackage.grossPrice).toBe(1399);
|
||||
expect(booking.bookedPackage.durationWeeks).toBe(52);
|
||||
await expect(page.locator(".booking__result")).toContainText(
|
||||
await expect(page.locator("#confirmation-receipt")).toContainText(
|
||||
booking.bookingId,
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page.locator(".booking__totals")).toContainText("1.399,00");
|
||||
await expect(page.locator(".booking__result")).toContainText(
|
||||
await expect(page.locator("#confirmation-totals")).toContainText(
|
||||
"1.399,00",
|
||||
);
|
||||
await expect(page.locator("#confirmation-receipt")).toContainText(
|
||||
booking.bookingId,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,7 +16,12 @@ test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter",
|
|||
const email = `kontakt-${randomUUID()}@example.test`;
|
||||
const booking = await request.post("/api/bookings", {
|
||||
headers: { "Idempotency-Key": randomUUID() },
|
||||
data: { packageId: "ernaehrung-starter", name: "Testkunde", email },
|
||||
data: {
|
||||
consent: true,
|
||||
packageId: "ernaehrung-starter",
|
||||
name: "Testkunde",
|
||||
email,
|
||||
},
|
||||
});
|
||||
expect(booking.ok()).toBeTruthy();
|
||||
await open(page);
|
||||
|
|
@ -30,7 +35,8 @@ test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter",
|
|||
.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 page.locator('[name="simulationAccepted"]').check();
|
||||
await page.locator('[name="consent"]').check();
|
||||
await submit.click();
|
||||
await expect(page.getByRole("status")).toContainText("keine Buchung");
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
|
|
@ -76,7 +82,8 @@ test("Versandfehler zeigt Meldung und bleibt im Popup", async ({ page }) => {
|
|||
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.locator('[name="simulationAccepted"]').check();
|
||||
await page.locator('[name="consent"]').check();
|
||||
await page.getByRole("button", { name: "Anfrage senden" }).click();
|
||||
await expect(page.getByRole("status")).toContainText(
|
||||
"Mailpit nicht erreichbar",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const privacyUrl = "https://www.tri-hub.de/datenschutz";
|
||||
|
||||
async function expectConsent(form) {
|
||||
const checkbox = form.locator('[name="consent"]');
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
await expect(checkbox).toHaveAttribute("required", "");
|
||||
const link = form.getByRole("link", {
|
||||
name: "Datenschutzbestimmungen",
|
||||
exact: true,
|
||||
});
|
||||
await expect(link).toHaveAttribute("href", privacyUrl);
|
||||
await expect(link).toHaveCSS("font-weight", "700");
|
||||
await expect(link).toHaveCSS("text-decoration-line", "underline");
|
||||
return checkbox;
|
||||
}
|
||||
|
||||
test("Landingpage: beide Formulare benötigen Datenschutz; Kontakt speichert Zustimmung", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/index.html");
|
||||
await expectConsent(page.locator("#reviewForm"));
|
||||
const form = page.locator("#contactForm");
|
||||
const consent = await expectConsent(form);
|
||||
await form.locator('[name="name"]').fill("Datenschutz Test");
|
||||
await form.locator('[name="email"]').fill("privacy@example.test");
|
||||
await form.locator('[name="subject"]').fill("Eine Testanfrage");
|
||||
await form
|
||||
.locator('[name="message"]')
|
||||
.fill("Eine ausreichend lange Testnachricht.");
|
||||
await form.locator('[type="submit"]').click();
|
||||
expect(
|
||||
await consent.evaluate((element) => element.validity.valueMissing),
|
||||
).toBe(true);
|
||||
await consent.check();
|
||||
const response = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/api/nutrition-contact"),
|
||||
);
|
||||
await form.locator('[type="submit"]').click();
|
||||
const saved = await response;
|
||||
expect(saved.status()).toBe(201);
|
||||
expect(saved.request().postDataJSON().consent).toBe(true);
|
||||
await expect(consent).not.toBeChecked();
|
||||
});
|
||||
|
||||
test("Buchungsdialog verlangt Datenschutz und übermittelt Zustimmung", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/booking.html?id=ernaehrung-starter");
|
||||
await page
|
||||
.getByRole("button", { name: "Jetzt buchen", exact: true })
|
||||
.click();
|
||||
const form = page.locator("dialog form");
|
||||
const consent = await expectConsent(form);
|
||||
await form.locator('[name="name"]').fill("Datenschutz Test");
|
||||
await form.locator('[name="email"]').fill("privacy@example.test");
|
||||
await form.locator('[type="submit"]').click();
|
||||
await expect(page.locator('[id$="booking-consent-error"]')).toContainText(
|
||||
"Datenschutzbestimmungen akzeptieren",
|
||||
);
|
||||
await consent.check();
|
||||
const response = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/api/bookings"),
|
||||
);
|
||||
await form.locator('[type="submit"]').click();
|
||||
const saved = await response;
|
||||
expect(saved.status()).toBe(201);
|
||||
expect(saved.request().postDataJSON().consent).toBe(true);
|
||||
});
|
||||
|
||||
test("Beraterkontakt hat eine separate verpflichtende Datenschutz-Zustimmung", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/contact-test.html?berater-id=maren-hoffmann");
|
||||
await page
|
||||
.getByRole("button", { name: "Berater kontaktieren", exact: true })
|
||||
.click();
|
||||
await expectConsent(page.locator("dialog form"));
|
||||
});
|
||||
|
||||
for (const width of [375, 1280]) {
|
||||
test(`Buchungsfenster: Checkbox und erste Textzeile auf gleicher Höhe (${width}px)`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto("/booking.html?id=ernaehrung-starter");
|
||||
await page
|
||||
.getByRole("button", { name: "Jetzt buchen", exact: true })
|
||||
.click();
|
||||
const label = page.locator(".booking__consent");
|
||||
const difference = await label.evaluate((element) => {
|
||||
const checkbox = element
|
||||
.querySelector("input")
|
||||
.getBoundingClientRect();
|
||||
const text = element.querySelector("span");
|
||||
const firstLineCenter =
|
||||
text.getBoundingClientRect().top +
|
||||
parseFloat(getComputedStyle(text).lineHeight) / 2;
|
||||
return Math.abs(
|
||||
checkbox.top + checkbox.height / 2 - firstLineCenter,
|
||||
);
|
||||
});
|
||||
expect(difference).toBeLessThanOrEqual(1);
|
||||
await expect(label.locator("a")).toHaveCSS(
|
||||
"color",
|
||||
"rgb(255, 255, 255)",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const reviews = [
|
||||
{
|
||||
createdAt: "2026-01-01",
|
||||
title: "Hilfreiche Beratung",
|
||||
review: "Die Beratung hat mir im Training sehr geholfen.",
|
||||
rating: 4,
|
||||
},
|
||||
{
|
||||
createdAt: "2026-01-02",
|
||||
title: "Sehr guter Ernährungsplan",
|
||||
review: "Der Plan lässt sich leicht in meinen Alltag integrieren.",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
createdAt: "2026-01-03",
|
||||
title: "Gute Tipps für den Alltag",
|
||||
review: "Ich konnte viele Tipps direkt im Alltag ausprobieren.",
|
||||
rating: 3,
|
||||
},
|
||||
];
|
||||
const active = (page) => page.locator("#reviewSlider .review-card.is-active");
|
||||
|
||||
async function mock(page, rows = reviews) {
|
||||
await page.route("**/api/reviews", (route) =>
|
||||
route.fulfill({ json: { reviews: rows } }),
|
||||
);
|
||||
await page.goto("/index.html");
|
||||
await expect(active(page)).toContainText(
|
||||
rows[0]?.title || "Deine Erfahrung",
|
||||
);
|
||||
}
|
||||
|
||||
test("10-Sekunden-Wechsel, Maus-Drag und Tastatur ohne Buttons", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") });
|
||||
await page.clock.pauseAt(new Date("2026-01-01T00:00:01Z"));
|
||||
await mock(page);
|
||||
const stage = page.locator(".review-stage");
|
||||
await expect(page.locator("#reviewSlider button")).toHaveCount(0);
|
||||
const font = await page
|
||||
.locator("body")
|
||||
.evaluate((element) => getComputedStyle(element).fontFamily);
|
||||
await expect(active(page).locator("h3")).toHaveCSS("font-family", font);
|
||||
await expect(active(page).locator("blockquote")).toHaveCSS(
|
||||
"font-family",
|
||||
font,
|
||||
);
|
||||
await expect(active(page).locator(".stars")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"4 von 5 Sternen",
|
||||
);
|
||||
await page.clock.runFor(9999);
|
||||
await expect(active(page)).toContainText(reviews[0].title);
|
||||
await page.clock.runFor(1);
|
||||
await expect(active(page)).toContainText(reviews[1].title);
|
||||
await stage.scrollIntoViewIfNeeded();
|
||||
const box = await stage.boundingBox();
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + 60);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 - 100, box.y + 60, {
|
||||
steps: 6,
|
||||
});
|
||||
await expect(stage).toHaveClass(/is-dragging/);
|
||||
await expect(active(page)).toHaveCSS(
|
||||
"transform",
|
||||
"matrix(1, 0, 0, 1, -100, 0)",
|
||||
);
|
||||
await page.mouse.up();
|
||||
await expect(active(page)).toContainText(reviews[2].title);
|
||||
await expect(stage).toBeFocused();
|
||||
await page.clock.runFor(9999);
|
||||
await expect(active(page)).toContainText(reviews[2].title);
|
||||
await page.clock.runFor(1);
|
||||
await expect(active(page)).toContainText(reviews[0].title);
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
await expect(active(page)).toContainText(reviews[2].title);
|
||||
await page.keyboard.press("Home");
|
||||
await expect(active(page)).toContainText(reviews[0].title);
|
||||
await page.keyboard.press("End");
|
||||
await expect(active(page)).toContainText(reviews[2].title);
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(active(page)).toContainText(reviews[0].title);
|
||||
await page.clock.runFor(10_000);
|
||||
await expect(active(page)).toContainText(reviews[1].title);
|
||||
});
|
||||
|
||||
test("Kurzes Ziehen springt zurück; Drag nach rechts zeigt vorherige Rezension", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mock(page);
|
||||
const stage = page.locator(".review-stage");
|
||||
await stage.scrollIntoViewIfNeeded();
|
||||
const box = await stage.boundingBox();
|
||||
for (const distance of [20, 100]) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + 60);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + distance, box.y + 60, {
|
||||
steps: 5,
|
||||
});
|
||||
await page.mouse.up();
|
||||
await expect(active(page)).toContainText(
|
||||
reviews[distance === 20 ? 0 : 2].title,
|
||||
);
|
||||
await expect(stage).not.toHaveClass(/is-dragging/);
|
||||
}
|
||||
});
|
||||
|
||||
test("Leere Liste und einzelne Rezension brauchen keine Slider-Steuerung", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mock(page, []);
|
||||
await expect(page.locator("#reviewSlider button")).toHaveCount(0);
|
||||
await page.unroute("**/api/reviews");
|
||||
await mock(page, [reviews[0]]);
|
||||
await expect(page.locator("#reviewSlider button")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Mobile Anzeige bleibt stabil; HTML im Rezensionstext wird nicht ausgeführt", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 375, height: 850 });
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
const rows = [
|
||||
reviews[0],
|
||||
{
|
||||
...reviews[1],
|
||||
review:
|
||||
'<img src=x onerror="window.reviewInjected=true">' +
|
||||
"x".repeat(400),
|
||||
},
|
||||
];
|
||||
await mock(page, rows);
|
||||
const before = await page.locator(".review-stage").boundingBox();
|
||||
await page.locator(".review-stage").focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
const after = await page.locator(".review-stage").boundingBox();
|
||||
expect(after.height).toBe(before.height);
|
||||
await expect(active(page)).toHaveCSS("transition-duration", "0s");
|
||||
await expect(active(page)).toContainText("<img src=x");
|
||||
await expect(active(page).locator("img")).toHaveCount(0);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("Neue Rezension erscheint erst nach erfolgreicher Speicherung und bleibt nach Neuladen", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const email = `slider-${randomUUID()}@example.test`;
|
||||
expect(
|
||||
(
|
||||
await request.post("/api/bookings", {
|
||||
headers: { "Idempotency-Key": randomUUID() },
|
||||
data: {
|
||||
packageId: "ernaehrung-starter",
|
||||
name: "Slider Test",
|
||||
email,
|
||||
consent: true,
|
||||
},
|
||||
})
|
||||
).status(),
|
||||
).toBe(201);
|
||||
await page.goto("/index.html");
|
||||
const title = `Neue Rezension ${randomUUID().slice(0, 8)}`;
|
||||
const form = page.locator("#reviewForm");
|
||||
await form.locator('[name="email"]').fill("unbekannt@example.test");
|
||||
await form.locator('[name="title"]').fill(title);
|
||||
await form
|
||||
.locator('[name="review"]')
|
||||
.fill("Eine neue und hilfreiche Erfahrung mit der Beratung.");
|
||||
await form.locator('label[for="star4"]').click();
|
||||
await form.locator('[name="consent"]').check();
|
||||
await form.locator('[type="submit"]').click();
|
||||
await expect(page.locator("#formStatus")).toContainText(
|
||||
"keine Buchung gefunden",
|
||||
);
|
||||
await expect(page.locator("#reviewSlider")).not.toContainText(title);
|
||||
await form.locator('[name="email"]').fill(email);
|
||||
await form.locator('[type="submit"]').click();
|
||||
await expect(page.locator("#formStatus")).toContainText("gespeichert");
|
||||
await expect(active(page)).toContainText(title);
|
||||
await expect(page.locator("#reviewSlider")).not.toContainText(email);
|
||||
await page.reload();
|
||||
await expect(page.locator("#reviewSlider")).toContainText(title);
|
||||
});
|
||||
|
||||
test("Polling nimmt neue Backend-Rezensionen auf; Lesefehler erhalten vorhandene Karten", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") });
|
||||
await page.clock.pauseAt(new Date("2026-01-01T00:00:01Z"));
|
||||
let rows = [reviews[0]];
|
||||
let fail = false;
|
||||
await page.route("**/api/reviews", (route) =>
|
||||
route.fulfill(
|
||||
fail ? { status: 503, json: {} } : { json: { reviews: rows } },
|
||||
),
|
||||
);
|
||||
await page.goto("/index.html");
|
||||
await expect(active(page)).toContainText(reviews[0].title);
|
||||
rows = reviews;
|
||||
await page.clock.runFor(10_000);
|
||||
await expect(page.locator("#reviewSlider .review-card")).toHaveCount(3);
|
||||
fail = true;
|
||||
await page.clock.runFor(10_000);
|
||||
await expect(page.locator(".review-feedback")).toContainText(
|
||||
"nicht aktualisiert",
|
||||
);
|
||||
await expect(page.locator("#reviewSlider .review-card")).toHaveCount(3);
|
||||
});
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
test("Rezension prüft Buchung, behält Fehler-Eingaben und bestätigt Speicherung", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const email = `review-${randomUUID()}@example.test`;
|
||||
const booking = await request.post("/api/bookings", {
|
||||
headers: { "Idempotency-Key": randomUUID() },
|
||||
data: {
|
||||
consent: true,
|
||||
packageId: "ernaehrung-starter",
|
||||
name: "Testkunde",
|
||||
email,
|
||||
},
|
||||
});
|
||||
expect(booking.status()).toBe(201);
|
||||
await page.goto("/index.html#bewertung");
|
||||
const form = page.locator("#reviewForm");
|
||||
await form.locator('[name="email"]').fill("unbekannt@example.test");
|
||||
await form.locator('[name="title"]').fill("Sehr hilfreiche Beratung");
|
||||
await form
|
||||
.locator('[name="review"]')
|
||||
.fill("Die Beratung hilft mir im Alltag und beim Training.");
|
||||
await form.locator('label[for="star4"]').click();
|
||||
await form.locator('[name="consent"]').check();
|
||||
await form
|
||||
.getByRole("button", { name: "Bewertung veröffentlichen" })
|
||||
.click();
|
||||
await expect(page.locator("#formStatus")).toContainText(
|
||||
"keine Buchung gefunden",
|
||||
);
|
||||
await expect(form.locator('[name="title"]')).toHaveValue(
|
||||
"Sehr hilfreiche Beratung",
|
||||
);
|
||||
await form.locator('[name="email"]').fill(email);
|
||||
const saved = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith("/api/reviews") &&
|
||||
response.status() === 201,
|
||||
);
|
||||
await form
|
||||
.getByRole("button", { name: "Bewertung veröffentlichen" })
|
||||
.click();
|
||||
expect(await (await saved).json()).toEqual({ saved: true });
|
||||
await expect(page.locator("#formStatus")).toHaveText(
|
||||
"Danke! Deine Bewertung wurde gespeichert.",
|
||||
);
|
||||
await expect(form.locator('[name="email"]')).toHaveValue("");
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import { createContactFormEmailService } from "../server/services/contact-form-e
|
|||
import { createContactFormStorage } from "../server/services/contact-form-storage.js";
|
||||
|
||||
const validInput = {
|
||||
consent: true,
|
||||
name: "Test Person",
|
||||
email: "person@example.test",
|
||||
subject: "Eine Frage",
|
||||
|
|
@ -57,6 +58,7 @@ test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", asyn
|
|||
assert.equal(f.sent.length, 1);
|
||||
assert.deepEqual(
|
||||
{
|
||||
consent: f.sent[0].consent,
|
||||
name: f.sent[0].name,
|
||||
email: f.sent[0].email,
|
||||
subject: f.sent[0].subject,
|
||||
|
|
@ -72,6 +74,10 @@ test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", asyn
|
|||
test("Pflichtfelder, E-Mail, Längen und Mindestlänge werden validiert", async (t) => {
|
||||
const f = await fixture(t);
|
||||
const invalidInputs = [
|
||||
...[false, "true", undefined].map((consent) => ({
|
||||
...validInput,
|
||||
consent,
|
||||
})),
|
||||
{ ...validInput, name: " " },
|
||||
{ ...validInput, name: "Test\nBcc: fremd" },
|
||||
{ ...validInput, name: "x".repeat(121) },
|
||||
|
|
@ -267,3 +273,17 @@ test("Mailservice meldet, wenn Mailpit einen Empfänger ablehnt", async () => {
|
|||
error.status === 502 && error.code === "CONTACT_EMAIL_FAILED",
|
||||
);
|
||||
});
|
||||
|
||||
test("Alte Kontakt-CSV bleibt lesbar; neue Zustimmung wird separat gespeichert", async (t) => {
|
||||
const f = await fixture(t);
|
||||
await writeFile(
|
||||
join(f.directory, "contact-requests.csv"),
|
||||
"createdAt,name,email,subject,message\n2026-01-01,Alt,alt@example.test,Frage,Alte Nachricht\n",
|
||||
);
|
||||
assert.equal((await f.post()).status, 201);
|
||||
const records = await f.storage.readAll();
|
||||
assert.equal(records.length, 2);
|
||||
assert.notEqual(records[0].consent, true);
|
||||
assert.equal(records[0].message, "Alte Nachricht");
|
||||
assert.equal(records[1].consent, true);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createApp } from "../server/index.js";
|
||||
import { createContactFormStorage } from "../server/services/contact-form-storage.js";
|
||||
import { createCsvStorage } from "../server/services/csv-storage.js";
|
||||
import { createContactEmailService } from "../server/services/contact-email.js";
|
||||
import { contactBookingUrl } from "../shared/contact.js";
|
||||
|
|
@ -18,9 +19,21 @@ async function fixture(t, options = {}) {
|
|||
name: "Testkunde",
|
||||
},
|
||||
]);
|
||||
const requests = createContactFormStorage(
|
||||
join(dir, "advisor-contacts.csv"),
|
||||
[
|
||||
"createdAt",
|
||||
"email",
|
||||
"advisorId",
|
||||
"reason",
|
||||
"simulationAccepted",
|
||||
"consent",
|
||||
],
|
||||
);
|
||||
const sent = [];
|
||||
const app = createApp({
|
||||
storage,
|
||||
advisorContactStorage: requests,
|
||||
sendConfirmation: async () => {},
|
||||
sendContact: async (data) => sent.push(data),
|
||||
...options,
|
||||
|
|
@ -40,9 +53,10 @@ async function fixture(t, options = {}) {
|
|||
});
|
||||
return { status: response.status, body: await response.json() };
|
||||
};
|
||||
return { post, sent, storage, base };
|
||||
return { post, sent, storage, requests, base };
|
||||
}
|
||||
const input = {
|
||||
consent: true,
|
||||
email: "kunde@example.test",
|
||||
advisorId: "relindis-agethen",
|
||||
reason: "coaching",
|
||||
|
|
@ -74,11 +88,19 @@ test("CSV-Abgleich normalisiert E-Mail; Versand erhält Berater und festen Redir
|
|||
assert.equal(second.status, 201);
|
||||
assert.equal(f.sent[1].advisor.email, "maren.hoffmann@tri-hub.de");
|
||||
assert.deepEqual(await f.storage.readAll(), before);
|
||||
const records = await f.requests.readAll();
|
||||
assert.equal(records.length, 2);
|
||||
assert.equal(records[0].consent, true);
|
||||
assert.equal(records[0].advisorId, input.advisorId);
|
||||
});
|
||||
|
||||
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 [
|
||||
...[false, "true", undefined].map((consent) => [
|
||||
{ ...input, consent },
|
||||
400,
|
||||
]),
|
||||
[null, 400],
|
||||
[{ ...input, email: "bad" }, 400],
|
||||
[{ ...input, email: "unknown@example.test" }, 403],
|
||||
|
|
@ -89,6 +111,7 @@ test("Ungültige und unbekannte Kunden, manipulierte Berater, Gründe und fehlen
|
|||
])
|
||||
assert.equal((await f.post("/api/contact", data)).status, status);
|
||||
assert.equal(f.sent.length, 0);
|
||||
assert.deepEqual(await f.requests.readAll(), []);
|
||||
});
|
||||
|
||||
test("Absenden prüft CSV erneut; Speicherfehler bleiben geschlossen", async (t) => {
|
||||
|
|
@ -187,3 +210,15 @@ test("Anzeigename bevorzugt Spitzname, sonst Vorname, ohne Nachnamen", async ()
|
|||
"Maren",
|
||||
);
|
||||
});
|
||||
|
||||
test("Berateranfrage wird bei CSV-Schreibfehler nicht versendet", async (t) => {
|
||||
const f = await fixture(t, {
|
||||
advisorContactStorage: {
|
||||
append: async () => {
|
||||
throw new Error("Disk full");
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal((await f.post("/api/contact", input)).status, 503);
|
||||
assert.equal(f.sent.length, 0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,16 +4,34 @@ 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 { createReviewStorage } from "../../server/services/review-storage.js";
|
||||
import { createContactFormStorage } from "../../server/services/contact-form-storage.js";
|
||||
import { packages } from "../../shared/packages.js";
|
||||
|
||||
const directory = await mkdtemp(join(tmpdir(), "trihub-browser-"));
|
||||
const server = createApp({
|
||||
catalog: packages,
|
||||
storage: createCsvStorage(join(directory, "bookings.csv")),
|
||||
reviewStorage: createReviewStorage(join(directory, "reviews.csv")),
|
||||
advisorContactStorage: createContactFormStorage(
|
||||
join(directory, "advisor-contacts.csv"),
|
||||
[
|
||||
"createdAt",
|
||||
"email",
|
||||
"advisorId",
|
||||
"reason",
|
||||
"simulationAccepted",
|
||||
"consent",
|
||||
],
|
||||
),
|
||||
contactFormStorage: createContactFormStorage(
|
||||
join(directory, "contact-requests.csv"),
|
||||
),
|
||||
sendContactForm: async () => {},
|
||||
sendConfirmation: async () => {},
|
||||
sendContact: async () => {},
|
||||
});
|
||||
server.listen(3000, "127.0.0.1");
|
||||
server.listen(Number(process.env.TEST_API_PORT || 3000), "127.0.0.1");
|
||||
async function stop() {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,251 @@
|
|||
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 { createCsvStorage } from "../server/services/csv-storage.js";
|
||||
import { createReviewStorage } from "../server/services/review-storage.js";
|
||||
|
||||
const input = {
|
||||
email: "kunde@example.test",
|
||||
title: 'Sehr gut, "hilfreich"',
|
||||
review: 'Gute Beratung, viele Tipps.\nAuch für den "Alltag".',
|
||||
rating: 5,
|
||||
consent: true,
|
||||
};
|
||||
|
||||
async function setup(t, options = {}) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "trihub-reviews-"));
|
||||
const bookingFile = join(directory, "bookings.csv");
|
||||
const file = join(directory, "reviews.csv");
|
||||
const storage = createCsvStorage(bookingFile);
|
||||
const reviews = createReviewStorage(file);
|
||||
await storage.writeAll([{ email: input.email }]);
|
||||
const app = createApp({ storage, reviewStorage: reviews, ...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 url = `http://127.0.0.1:${app.address().port}`;
|
||||
const post = (body = input) =>
|
||||
fetch(`${url}/api/reviews`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { post, reviews, file, bookingFile, url };
|
||||
}
|
||||
|
||||
test("Kundenbewertung wird dauerhaft als CSV gespeichert; Buchungen bleiben unverändert", async (t) => {
|
||||
const { post, file, bookingFile } = await setup(t);
|
||||
const before = await readFile(bookingFile, "utf8");
|
||||
const response = await post({ ...input, email: " KUNDE@EXAMPLE.TEST " });
|
||||
assert.equal(response.status, 201);
|
||||
assert.deepEqual(await response.json(), { saved: true });
|
||||
const rows = await createReviewStorage(file).readAll();
|
||||
assert.equal(rows.length, 1);
|
||||
assert.deepEqual(rows[0], {
|
||||
createdAt: rows[0].createdAt,
|
||||
email: input.email,
|
||||
title: input.title,
|
||||
review: input.review,
|
||||
rating: "5",
|
||||
consent: "true",
|
||||
});
|
||||
assert.ok(!Number.isNaN(Date.parse(rows[0].createdAt)));
|
||||
assert.equal(await readFile(bookingFile, "utf8"), before);
|
||||
});
|
||||
|
||||
test("Bewertung funktioniert mit dem Buchungs-CSV-Format ohne Telefonnummer", async (t) => {
|
||||
const { post, bookingFile, reviews } = await setup(t);
|
||||
const header =
|
||||
"bookingId,createdAt,packageId,packageName,name,email,emailStatus,idempotencyKey,requestHash,bookedPackage,customer,acceptedTerms,consent";
|
||||
const booking = [
|
||||
"TH-000001",
|
||||
"2026-09-25T19:22:37.801Z",
|
||||
"ernaehrung-starter",
|
||||
"Paket",
|
||||
"Kunde",
|
||||
input.email,
|
||||
"accepted",
|
||||
"idempotency-key",
|
||||
"request-hash",
|
||||
"",
|
||||
"",
|
||||
"true",
|
||||
"true",
|
||||
].join(",");
|
||||
await writeFile(bookingFile, `${header}\r\n${booking}\r\n`);
|
||||
|
||||
const response = await post();
|
||||
assert.equal(response.status, 201);
|
||||
assert.deepEqual(await response.json(), { saved: true });
|
||||
assert.equal((await reviews.readAll()).length, 1);
|
||||
});
|
||||
|
||||
test("Unbekannte E-Mail, ungültige Felder und fehlende Zustimmung speichern nichts", async (t) => {
|
||||
const { post, reviews } = await setup(t);
|
||||
assert.equal(
|
||||
(await post({ ...input, email: "fremd@example.test" })).status,
|
||||
403,
|
||||
);
|
||||
for (const body of [
|
||||
null,
|
||||
[],
|
||||
{},
|
||||
...["", "ungueltig", 123].map((email) => ({ ...input, email })),
|
||||
...["", "a".repeat(9), "a".repeat(51), " ".repeat(10), 123].map(
|
||||
(title) => ({ ...input, title }),
|
||||
),
|
||||
...["", "a".repeat(9), "a".repeat(501), " ".repeat(10), 123].map(
|
||||
(review) => ({ ...input, review }),
|
||||
),
|
||||
...[0, 6, 1.5, "5", null].map((rating) => ({ ...input, rating })),
|
||||
...[false, "true", undefined].map((consent) => ({ ...input, consent })),
|
||||
]) {
|
||||
assert.equal((await post(body)).status, 400, JSON.stringify(body));
|
||||
}
|
||||
assert.deepEqual(await reviews.readAll(), []);
|
||||
});
|
||||
|
||||
test("Grenzwerte und alle Sterne sind erlaubt; paralleles Speichern verliert keine Bewertung", async (t) => {
|
||||
const { post, reviews } = await setup(t);
|
||||
const responses = await Promise.all(
|
||||
[1, 2, 3, 4, 5].map((rating) =>
|
||||
post({
|
||||
...input,
|
||||
rating,
|
||||
title: "t".repeat(rating === 1 ? 10 : 50),
|
||||
review: "r".repeat(rating === 1 ? 10 : 500),
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.ok(responses.every((response) => response.status === 201));
|
||||
const rows = await reviews.readAll();
|
||||
assert.equal(rows.length, 5);
|
||||
assert.deepEqual(
|
||||
rows.map((row) => Number(row.rating)).sort(),
|
||||
[1, 2, 3, 4, 5],
|
||||
);
|
||||
});
|
||||
|
||||
test("Review-API weist falschen Inhaltstyp, ungültiges JSON und zu große Bodies zurück", async (t) => {
|
||||
const { url, reviews } = await setup(t);
|
||||
const send = (body, contentType) =>
|
||||
fetch(`${url}/api/reviews`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
const unsupported = await send(JSON.stringify(input), "text/plain");
|
||||
assert.equal(unsupported.status, 415);
|
||||
assert.equal(
|
||||
(await unsupported.json()).error.code,
|
||||
"UNSUPPORTED_CONTENT_TYPE",
|
||||
);
|
||||
|
||||
const malformed = await send("{broken", "application/json");
|
||||
assert.equal(malformed.status, 400);
|
||||
assert.equal((await malformed.json()).error.code, "INVALID_JSON");
|
||||
|
||||
const oversized = await send(" ".repeat(8193), "application/json");
|
||||
assert.equal(oversized.status, 413);
|
||||
assert.equal((await oversized.json()).error.code, "BODY_TOO_LARGE");
|
||||
assert.deepEqual(await reviews.readAll(), []);
|
||||
});
|
||||
|
||||
test("Fehler beim Lesen der Buchungs-CSV speichern keine Bewertung", async (t) => {
|
||||
let appended = false;
|
||||
const { post, reviews } = await setup(t, {
|
||||
storage: {
|
||||
readAll: async () => {
|
||||
throw new Error("Booking CSV unreadable");
|
||||
},
|
||||
},
|
||||
reviewStorage: {
|
||||
append: async () => {
|
||||
appended = true;
|
||||
},
|
||||
readAll: () => reviews.readAll(),
|
||||
},
|
||||
});
|
||||
|
||||
const response = await post();
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal((await response.json()).error.code, "REVIEW_NOT_SAVED");
|
||||
assert.equal(appended, false);
|
||||
assert.deepEqual(await reviews.readAll(), []);
|
||||
});
|
||||
|
||||
test("Speicherfehler bestätigt keinen Erfolg; beschädigte CSV bleibt erhalten", async (t) => {
|
||||
const { post, file } = await setup(t);
|
||||
await writeFile(file, "falscher,header\n1,2\n");
|
||||
assert.equal((await post()).status, 503);
|
||||
assert.equal(await readFile(file, "utf8"), "falscher,header\n1,2\n");
|
||||
const failing = await setup(t, {
|
||||
reviewStorage: {
|
||||
append: async () => {
|
||||
throw new Error("Disk full");
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal((await failing.post()).status, 503);
|
||||
});
|
||||
|
||||
test("CSV maskiert Tabellenformeln und bleibt über HTTP privat", async (t) => {
|
||||
const { post, reviews, file, url } = await setup(t);
|
||||
const title = "=SUM(1,2,3)";
|
||||
assert.equal((await post({ ...input, title })).status, 201);
|
||||
assert.equal((await reviews.readAll())[0].title, title);
|
||||
assert.ok((await readFile(file, "utf8")).includes("'=SUM"));
|
||||
assert.equal(
|
||||
(await fetch(`${url}/api/reviews`, { method: "DELETE" })).status,
|
||||
405,
|
||||
);
|
||||
assert.equal(
|
||||
(await fetch(`${url}/src/server/data/reviews.csv`)).status,
|
||||
404,
|
||||
);
|
||||
});
|
||||
|
||||
test("Öffentliche Rezensionen kommen aus CSV, ohne E-Mail oder Zustimmung", async (t) => {
|
||||
const { post, url, reviews } = await setup(t);
|
||||
assert.deepEqual(await (await fetch(`${url}/api/reviews`)).json(), {
|
||||
reviews: [],
|
||||
});
|
||||
await reviews.append({ ...input, consent: false, createdAt: "2026-01-01" });
|
||||
assert.equal((await post()).status, 201);
|
||||
const response = await fetch(`${url}/api/reviews`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
const body = await response.json();
|
||||
assert.equal(body.reviews.length, 1);
|
||||
const [review] = body.reviews;
|
||||
assert.deepEqual(Object.keys(review).sort(), [
|
||||
"createdAt",
|
||||
"rating",
|
||||
"review",
|
||||
"title",
|
||||
]);
|
||||
assert.equal(review.title, input.title);
|
||||
assert.equal(review.review, input.review);
|
||||
assert.equal(review.rating, 5);
|
||||
assert.ok(!JSON.stringify(body).includes(input.email));
|
||||
});
|
||||
|
||||
test("Lesefehler bei Rezensionen liefern einen kontrollierten Fehler", async (t) => {
|
||||
const { url } = await setup(t, {
|
||||
reviewStorage: {
|
||||
readAll: async () => {
|
||||
throw new Error("Disk error");
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await fetch(`${url}/api/reviews`);
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal((await response.json()).error.code, "REVIEWS_UNAVAILABLE");
|
||||
});
|
||||
|
|
@ -1,44 +1,44 @@
|
|||
import { defineConfig } from "vite";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
proxy: { "/api": "http://127.0.0.1:3000" },
|
||||
fs: {
|
||||
// Auch Vites direkten Dateizugriff auf das Projekt absichern.
|
||||
deny: [
|
||||
".env",
|
||||
".env.*",
|
||||
"*.{crt,pem}",
|
||||
"**/.git/**",
|
||||
"**/server/**",
|
||||
"**/tests/**",
|
||||
],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
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),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
import { defineConfig } from "vite";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
proxy: { "/api": "http://127.0.0.1:3000" },
|
||||
fs: {
|
||||
// Auch Vites direkten Dateizugriff auf das Projekt absichern.
|
||||
deny: [
|
||||
".env",
|
||||
".env.*",
|
||||
"*.{crt,pem}",
|
||||
"**/.git/**",
|
||||
"**/server/**",
|
||||
"**/tests/**",
|
||||
],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
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