Max nur eine Frage offen, Farb Korrektur Schrift

feature/FAQ
Niels Yannick Tietze 2026-09-30 13:32:55 +00:00
parent 7c96a97ebf
commit f04b45bf16
41 changed files with 4611 additions and 4562 deletions

30
.gitignore vendored
View File

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

128
FAQ.html
View File

@ -23,6 +23,15 @@
summary::-webkit-details-marker { summary::-webkit-details-marker {
display: none; display: none;
} }
:root {
--eyebrow-color: #53d5cd;
}
.eyebrow {
color: var(--eyebrow-color);
}
li::marker {
color: var(--eyebrow-color);
}
</style> </style>
<script type="application/ld+json"> <script type="application/ld+json">
{ {
@ -191,12 +200,11 @@
</head> </head>
<body class="min-h-screen"> <body class="min-h-screen">
<div id="navbar-placeholder"></div> <div id="navbar-placeholder"></div>
<main> <main>
<section class="container py-20"> <section class="container py-20">
<div class="mx-auto max-w-4xl"> <div class="mx-auto max-w-4xl">
<p class="eyebrow mb-5 text-[var(--accent-warm)]"> <p class="eyebrow mb-5">Ernährungsberatung · FAQ</p>
Ernährungsberatung · FAQ
</p>
<h1 <h1
class="mb-6 font-display text-4xl leading-tight sm:text-5xl lg:text-7xl" class="mb-6 font-display text-4xl leading-tight sm:text-5xl lg:text-7xl"
> >
@ -276,9 +284,7 @@
aria-labelledby="h-grundlagen" aria-labelledby="h-grundlagen"
> >
<div class="mb-5"> <div class="mb-5">
<p class="eyebrow mb-3 text-[var(--accent-warm)]"> <p class="eyebrow mb-3">3 Fragen</p>
3 Fragen
</p>
<h2 <h2
id="h-grundlagen" id="h-grundlagen"
class="font-display text-2xl sm:text-3xl" class="font-display text-2xl sm:text-3xl"
@ -431,9 +437,7 @@
aria-labelledby="h-ablauf" aria-labelledby="h-ablauf"
> >
<div class="mb-5"> <div class="mb-5">
<p class="eyebrow mb-3 text-[var(--accent-warm)]"> <p class="eyebrow mb-3">5 Fragen</p>
5 Fragen
</p>
<h2 <h2
id="h-ablauf" id="h-ablauf"
class="font-display text-2xl sm:text-3xl" class="font-display text-2xl sm:text-3xl"
@ -515,9 +519,7 @@
<div <div
class="space-y-3 border-t border-white/10 pt-4 leading-relaxed text-white/70" class="space-y-3 border-t border-white/10 pt-4 leading-relaxed text-white/70"
> >
<ul <ul class="list-disc pl-5 space-y-2">
class="list-disc pl-5 space-y-2 marker:text-[var(--accent-warm)]"
>
<li> <li>
ein Ernährungsprotokoll der ein Ernährungsprotokoll der
letzten 3 bis 7 Tage (falls letzten 3 bis 7 Tage (falls
@ -676,9 +678,7 @@
aria-labelledby="h-training" aria-labelledby="h-training"
> >
<div class="mb-5"> <div class="mb-5">
<p class="eyebrow mb-3 text-[var(--accent-warm)]"> <p class="eyebrow mb-3">4 Fragen</p>
4 Fragen
</p>
<h2 <h2
id="h-training" id="h-training"
class="font-display text-2xl sm:text-3xl" class="font-display text-2xl sm:text-3xl"
@ -717,9 +717,7 @@
<div <div
class="space-y-3 border-t border-white/10 pt-4 leading-relaxed text-white/70" class="space-y-3 border-t border-white/10 pt-4 leading-relaxed text-white/70"
> >
<ul <ul class="list-disc pl-5 space-y-2">
class="list-disc pl-5 space-y-2 marker:text-[var(--accent-warm)]"
>
<li> <li>
<strong>Vorher:</strong> leicht <strong>Vorher:</strong> leicht
verdauliche Kohlenhydrate, wenig verdauliche Kohlenhydrate, wenig
@ -884,9 +882,7 @@
aria-labelledby="h-gesundheit" aria-labelledby="h-gesundheit"
> >
<div class="mb-5"> <div class="mb-5">
<p class="eyebrow mb-3 text-[var(--accent-warm)]"> <p class="eyebrow mb-3">4 Fragen</p>
4 Fragen
</p>
<h2 <h2
id="h-gesundheit" id="h-gesundheit"
class="font-display text-2xl sm:text-3xl" class="font-display text-2xl sm:text-3xl"
@ -1077,9 +1073,7 @@
aria-labelledby="h-gewicht" aria-labelledby="h-gewicht"
> >
<div class="mb-5"> <div class="mb-5">
<p class="eyebrow mb-3 text-[var(--accent-warm)]"> <p class="eyebrow mb-3">3 Fragen</p>
3 Fragen
</p>
<h2 <h2
id="h-gewicht" id="h-gewicht"
class="font-display text-2xl sm:text-3xl" class="font-display text-2xl sm:text-3xl"
@ -1234,9 +1228,7 @@
<div <div
class="card-premium mx-auto max-w-4xl rounded-[1.8rem] p-8 text-center sm:p-12" class="card-premium mx-auto max-w-4xl rounded-[1.8rem] p-8 text-center sm:p-12"
> >
<p class="eyebrow mb-4 text-[var(--accent-warm)]"> <p class="eyebrow mb-4">Noch Fragen?</p>
Noch Fragen?
</p>
<h2 class="mb-4 font-display text-3xl sm:text-4xl"> <h2 class="mb-4 font-display text-3xl sm:text-4xl">
Deine Frage war nicht dabei? Deine Frage war nicht dabei?
</h2> </h2>
@ -1281,86 +1273,6 @@
</div> </div>
</footer> </footer>
<script> <script src="/src/features/faq/faq.js" defer></script>
(function () {
var items = [].slice.call(
document.querySelectorAll(".faq-item"),
);
var sections = [].slice.call(
document.querySelectorAll(".faq-section"),
);
var chips = [].slice.call(document.querySelectorAll(".chip"));
var search = document.getElementById("search");
var empty = document.getElementById("empty");
var status = document.getElementById("status");
var filter = "all";
var ON = [
"bg-primary",
"text-primary-foreground",
"border-transparent",
];
var OFF = [
"bg-white/5",
"text-white/70",
"border-white/10",
"hover:bg-white/10",
];
document.getElementById("year").textContent =
new Date().getFullYear();
items.forEach(function (d) {
d.addEventListener("toggle", function () {
d.dataset.state = d.open ? "open" : "closed";
});
});
function paintChips() {
chips.forEach(function (c) {
var active = c.dataset.filter === filter;
ON.forEach(function (k) {
c.classList.toggle(k, active);
});
OFF.forEach(function (k) {
c.classList.toggle(k, !active);
});
c.setAttribute("aria-pressed", active);
});
}
function apply() {
var q = search.value.trim().toLowerCase(),
shown = 0;
sections.forEach(function (s) {
var visible = 0;
s.querySelectorAll(".faq-item").forEach(function (d) {
var ok =
(filter === "all" ||
s.dataset.cat === filter) &&
(!q ||
d.textContent.toLowerCase().indexOf(q) >
-1);
d.classList.toggle("hidden", !ok);
if (ok) visible++;
});
s.classList.toggle("hidden", visible === 0);
shown += visible;
});
empty.classList.toggle("hidden", shown > 0);
status.textContent = shown + " Fragen angezeigt";
}
chips.forEach(function (c) {
c.addEventListener("click", function () {
filter = c.dataset.filter;
paintChips();
apply();
});
});
search.addEventListener("input", apply);
paintChips();
apply();
})();
</script>
</body> </body>
</html> </html>

130
README.md
View File

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

View File

@ -1,31 +1,31 @@
<!doctype html> <!doctype html>
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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> <title>Beraterprofil · Tri-Hub</title>
<link rel="stylesheet" href="/src/styles/navigation.css" /> <link rel="stylesheet" href="/src/styles/navigation.css" />
<link rel="stylesheet" href="/src/styles/packages.css" /> <link rel="stylesheet" href="/src/styles/packages.css" />
<link rel="stylesheet" href="/src/features/berater/berater.css" /> <link rel="stylesheet" href="/src/features/berater/berater.css" />
</head> </head>
<body class="packages-page advisor-page"> <body class="packages-page advisor-page">
<div id="navbar-placeholder"></div> <div id="navbar-placeholder"></div>
<main id="main-content" class="advisor-main"> <main id="main-content" class="advisor-main">
<section class="advisor-section" aria-labelledby="advisor-heading"> <section class="advisor-section" aria-labelledby="advisor-heading">
<div class="advisor-container"> <div class="advisor-container">
<header class="advisor-heading"> <header class="advisor-heading">
<p class="advisor-eyebrow">PERSÖNLICH FÜR DICH DA</p> <p class="advisor-eyebrow">PERSÖNLICH FÜR DICH DA</p>
<h1 id="advisor-heading">Lerne deine Ernährungsberater kennen.</h1> <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> <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> </header>
<div class="advisor-grid" id="advisor-profiles" aria-live="polite"> <div class="advisor-grid" id="advisor-profiles" aria-live="polite">
<p class="advisor-loading">Beraterprofile werden geladen …</p> <p class="advisor-loading">Beraterprofile werden geladen …</p>
</div> </div>
</div> </div>
</section> </section>
</main> </main>
<script type="module" src="/src/components/navigationsbar/navigation.js"></script> <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/features/berater/berater-entry.js"></script>
</body> </body>
</html> </html>

View File

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

View File

@ -1,36 +1,36 @@
# Beraterprofile und Bildergalerie testen # Beraterprofile und Bildergalerie testen
Die Berater-Tests laufen auf dem Branch `feature/beraterbilder`. Dieser enthält auch die Profiländerungen aus `feature/beraterprofile`. Die Berater-Tests laufen auf dem Branch `feature/beraterbilder`. Dieser enthält auch die Profiländerungen aus `feature/beraterprofile`.
## Node-Tests ## Node-Tests
```sh ```sh
npm test npm test
``` ```
Die Node-Suite prüft die Beraterdaten, eindeutige IDs, Kontaktanzeigenamen, vorhandene Porträt- und Aktionsbilder sowie den Navigationslink zur eigenständigen Profilseite. Die Node-Suite prüft die Beraterdaten, eindeutige IDs, Kontaktanzeigenamen, vorhandene Porträt- und Aktionsbilder sowie den Navigationslink zur eigenständigen Profilseite.
## Browser-Tests ## Browser-Tests
```sh ```sh
npm run test:browser -- src/tests/browser/berater.spec.js 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: 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 ```sh
PW_REUSE_EXISTING=1 npm run test:browser -- src/tests/browser/berater.spec.js PW_REUSE_EXISTING=1 npm run test:browser -- src/tests/browser/berater.spec.js
``` ```
PowerShell: PowerShell:
```powershell ```powershell
$env:PW_REUSE_EXISTING = "1" $env:PW_REUSE_EXISTING = "1"
npm run test:browser -- src/tests/browser/berater.spec.js npm run test:browser -- src/tests/browser/berater.spec.js
``` ```
Beim ersten Browser-Testlauf kann Playwright Chromium benötigen: Beim ersten Browser-Testlauf kann Playwright Chromium benötigen:
```sh ```sh
npx playwright install chromium npx playwright install chromium
``` ```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,148 +1,148 @@
import { createContactService } from "./services/contact-service.js"; import { createContactService } from "./services/contact-service.js";
import { createContactEmailService } from "./services/contact-email.js"; import { createContactEmailService } from "./services/contact-email.js";
import { handleContact } from "./routes/contact.js"; import { handleContact } from "./routes/contact.js";
import { createServer } from "node:http"; import { createServer } from "node:http";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { packages } from "../shared/packages.js"; import { packages } from "../shared/packages.js";
import { import {
BookingError, BookingError,
createBookingService, createBookingService,
} from "./services/booking-service.js"; } from "./services/booking-service.js";
import { createCsvStorage } from "./services/csv-storage.js"; import { createCsvStorage } from "./services/csv-storage.js";
import { createEmailService } from "./services/email-service.js"; import { createEmailService } from "./services/email-service.js";
import { handleBooking, json } from "./routes/bookings.js"; import { handleBooking, json } from "./routes/bookings.js";
import { handleContactForm } from "./routes/contact-form.js"; import { handleContactForm } from "./routes/contact-form.js";
import { createContactFormService } from "./services/contact-form-service.js"; import { createContactFormService } from "./services/contact-form-service.js";
import { createContactFormStorage } from "./services/contact-form-storage.js"; import { createContactFormStorage } from "./services/contact-form-storage.js";
import { createContactFormEmailService } from "./services/contact-form-email.js"; import { createContactFormEmailService } from "./services/contact-form-email.js";
export function createApp({ export function createApp({
catalog = packages, catalog = packages,
storage = createCsvStorage( storage = createCsvStorage(
fileURLToPath(new URL("./data/bookings.csv", import.meta.url)), fileURLToPath(new URL("./data/bookings.csv", import.meta.url)),
), ),
details, details,
sendConfirmation, sendConfirmation,
sendContact, sendContact,
contactFormStorage = createContactFormStorage( contactFormStorage = createContactFormStorage(
fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)), fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)),
), ),
sendContactForm, sendContactForm,
} = {}) { } = {}) {
const ids = new Set(); const ids = new Set();
for (const entry of catalog) { for (const entry of catalog) {
if ( if (
!entry || !entry ||
typeof entry.id !== "string" || typeof entry.id !== "string" ||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id) || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id) ||
entry.id.length > 80 || entry.id.length > 80 ||
ids.has(entry.id) || ids.has(entry.id) ||
typeof entry.name !== "string" || typeof entry.name !== "string" ||
!entry.name.trim() || !entry.name.trim() ||
typeof entry.summary !== "string" || typeof entry.summary !== "string" ||
!entry.summary.trim() !entry.summary.trim()
) { ) {
throw new Error( throw new Error(
"Paketdaten erfüllen den Vertrag in src/shared/packages.js nicht.", "Paketdaten erfüllen den Vertrag in src/shared/packages.js nicht.",
); );
} }
ids.add(entry.id); ids.add(entry.id);
} }
const service = createBookingService({ const service = createBookingService({
storage, storage,
details, details,
packages: catalog, packages: catalog,
sendConfirmation: sendConfirmation ?? createEmailService(), sendConfirmation: sendConfirmation ?? createEmailService(),
}); });
const contact = createContactService({ const contact = createContactService({
storage, storage,
sendContact: sendContact ?? createContactEmailService(), sendContact: sendContact ?? createContactEmailService(),
}); });
// Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand. // Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand.
const contactForm = createContactFormService({ const contactForm = createContactFormService({
storage: contactFormStorage, storage: contactFormStorage,
sendContactForm: sendContactForm ?? createContactFormEmailService(), sendContactForm: sendContactForm ?? createContactFormEmailService(),
}); });
return createServer(async (request, response) => { return createServer(async (request, response) => {
const path = new URL(request.url, "http://localhost").pathname; const path = new URL(request.url, "http://localhost").pathname;
// Leitet die Ernährungsseite an ihren eigenen Kontaktformular-Endpunkt. // Leitet die Ernährungsseite an ihren eigenen Kontaktformular-Endpunkt.
if (path === "/api/nutrition-contact") { if (path === "/api/nutrition-contact") {
return handleContactForm(request, response, contactForm); return handleContactForm(request, response, contactForm);
} }
if (path === "/api/contact" || path === "/api/contact/validate") { if (path === "/api/contact" || path === "/api/contact/validate") {
return handleContact( return handleContact(
request, request,
response, response,
contact, contact,
path.endsWith("/validate"), path.endsWith("/validate"),
); );
} }
if (path === "/api/angebote" && request.method === "GET") { if (path === "/api/angebote" && request.method === "GET") {
return json(response, 200, { return json(response, 200, {
packages: catalog.map(({ id, name, summary }) => ({ packages: catalog.map(({ id, name, summary }) => ({
id, id,
name, name,
summary, summary,
})), })),
}); });
} }
if (path === "/api/packages" && request.method === "GET") { if (path === "/api/packages" && request.method === "GET") {
return json( return json(
response, response,
200, 200,
catalog.map(({ name, summary, ...entry }) => ({ catalog.map(({ name, summary, ...entry }) => ({
...entry, ...entry,
title: name, title: name,
description: summary, description: summary,
})), })),
); );
} }
if (path.startsWith("/api/packages/") && request.method === "GET") { if (path.startsWith("/api/packages/") && request.method === "GET") {
try { try {
return json( return json(
response, response,
200, 200,
service.getPackage(path.slice("/api/packages/".length)), service.getPackage(path.slice("/api/packages/".length)),
); );
} catch (error) { } catch (error) {
if (!(error instanceof BookingError)) throw error; if (!(error instanceof BookingError)) throw error;
return json(response, error.status, { return json(response, error.status, {
error: { error: {
code: error.code, code: error.code,
message: error.message, message: error.message,
fields: error.fields, fields: error.fields,
}, },
}); });
} }
} }
if (path === "/api/bookings" || path === "/api/bookings/preview") { if (path === "/api/bookings" || path === "/api/bookings/preview") {
if (request.method !== "POST") { if (request.method !== "POST") {
response.setHeader("Allow", "POST"); response.setHeader("Allow", "POST");
return json(response, 405, { return json(response, 405, {
error: { error: {
code: "METHOD_NOT_ALLOWED", code: "METHOD_NOT_ALLOWED",
message: "Bitte POST verwenden.", message: "Bitte POST verwenden.",
}, },
}); });
} }
return handleBooking(request, response, service, { return handleBooking(request, response, service, {
preview: path.endsWith("/preview"), preview: path.endsWith("/preview"),
}); });
} }
// Ausschließlich API; keine Auslieferung von CSV oder Serverdateien. // Ausschließlich API; keine Auslieferung von CSV oder Serverdateien.
return json(response, 404, { return json(response, 404, {
error: { code: "NOT_FOUND", message: "Adresse nicht gefunden." }, error: { code: "NOT_FOUND", message: "Adresse nicht gefunden." },
}); });
}); });
} }
if ( if (
process.argv[1] && process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href import.meta.url === pathToFileURL(process.argv[1]).href
) { ) {
const port = Number(process.env.PORT || 3000); const port = Number(process.env.PORT || 3000);
const server = createApp(); const server = createApp();
server.listen(port, process.env.HOST || "127.0.0.1", () => { server.listen(port, process.env.HOST || "127.0.0.1", () => {
console.log(`Buchungs-API auf Port ${port}`); console.log(`Buchungs-API auf Port ${port}`);
}); });
} }

View File

@ -1,81 +1,81 @@
import { BookingError } from "../services/booking-service.js"; import { BookingError } from "../services/booking-service.js";
export function json(response, status, body) { export function json(response, status, body) {
response.writeHead(status, { response.writeHead(status, {
"Content-Type": "application/json; charset=utf-8", "Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store", "Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
}); });
response.end(JSON.stringify(body)); response.end(JSON.stringify(body));
} }
export async function readJson(request) { export async function readJson(request) {
if ( if (
request.headers["content-type"]?.split(";")[0].trim() !== request.headers["content-type"]?.split(";")[0].trim() !==
"application/json" "application/json"
) { ) {
throw new BookingError( throw new BookingError(
415, 415,
"UNSUPPORTED_CONTENT_TYPE", "UNSUPPORTED_CONTENT_TYPE",
"Bitte JSON senden.", "Bitte JSON senden.",
); );
} }
let size = 0; let size = 0;
const chunks = []; const chunks = [];
for await (const chunk of request) { for await (const chunk of request) {
size += chunk.length; size += chunk.length;
if (size > 8192) if (size > 8192)
throw new BookingError( throw new BookingError(
413, 413,
"BODY_TOO_LARGE", "BODY_TOO_LARGE",
"Die Anfrage ist zu groß.", "Die Anfrage ist zu groß.",
); );
chunks.push(chunk); chunks.push(chunk);
} }
try { try {
return JSON.parse(Buffer.concat(chunks).toString("utf8")); return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch { } catch {
throw new BookingError( throw new BookingError(
400, 400,
"INVALID_JSON", "INVALID_JSON",
"Die Anfrage enthält kein gültiges JSON.", "Die Anfrage enthält kein gültiges JSON.",
); );
} }
} }
export async function handleBooking( export async function handleBooking(
request, request,
response, response,
service, service,
{ preview = false } = {}, { preview = false } = {},
) { ) {
try { try {
const input = await readJson(request); const input = await readJson(request);
if (preview) return json(response, 200, service.preview(input)); if (preview) return json(response, 200, service.preview(input));
const result = await service.book( const result = await service.book(
input, input,
request.headers["idempotency-key"], request.headers["idempotency-key"],
); );
json(response, result.status, result.body); json(response, result.status, result.body);
} catch (error) { } catch (error) {
if (error instanceof BookingError) { if (error instanceof BookingError) {
json(response, error.status, { json(response, error.status, {
error: { error: {
code: error.code, code: error.code,
message: error.message, message: error.message,
fields: error.fields, fields: error.fields,
}, },
}); });
} else { } else {
// Keine persönlichen Daten oder SMTP-Zugangsdaten ins Log schreiben. // Keine persönlichen Daten oder SMTP-Zugangsdaten ins Log schreiben.
console.error("Buchungsanfrage unerwartet fehlgeschlagen."); console.error("Buchungsanfrage unerwartet fehlgeschlagen.");
json(response, 500, { json(response, 500, {
error: { error: {
code: "INTERNAL_ERROR", code: "INTERNAL_ERROR",
message: message:
"Der Status ist unklar. Bitte dieselbe Anfrage erneut versuchen.", "Der Status ist unklar. Bitte dieselbe Anfrage erneut versuchen.",
}, },
}); });
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,132 +1,132 @@
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { orderSummaryRows } from "../../shared/order-summary.js"; import { orderSummaryRows } from "../../shared/order-summary.js";
import { paymentNotice } from "../../shared/booking-copy.js"; import { paymentNotice } from "../../shared/booking-copy.js";
export function createMailTransport( export function createMailTransport(
env = process.env, env = process.env,
makeTransport = nodemailer.createTransport, makeTransport = nodemailer.createTransport,
) { ) {
const localHosts = ["127.0.0.1", "localhost", "::1", "mailpit"]; const localHosts = ["127.0.0.1", "localhost", "::1", "mailpit"];
const host = env.SMTP_HOST || "127.0.0.1"; const host = env.SMTP_HOST || "127.0.0.1";
const local = localHosts.includes(host); const local = localHosts.includes(host);
if (!local && env.SMTP_ALLOW_EXTERNAL !== "true") { if (!local && env.SMTP_ALLOW_EXTERNAL !== "true") {
throw new Error( throw new Error(
"Externes SMTP ist gesperrt. Erst nach ausdrücklicher Freigabe SMTP_ALLOW_EXTERNAL=true setzen.", "Externes SMTP ist gesperrt. Erst nach ausdrücklicher Freigabe SMTP_ALLOW_EXTERNAL=true setzen.",
); );
} }
const port = Number(env.SMTP_PORT || (local ? 1025 : 587)); const port = Number(env.SMTP_PORT || (local ? 1025 : 587));
if (!Number.isInteger(port) || port < 1 || port > 65535) if (!Number.isInteger(port) || port < 1 || port > 65535)
throw new Error("Ungültiger SMTP_PORT"); throw new Error("Ungültiger SMTP_PORT");
if (Boolean(env.SMTP_USER) !== Boolean(env.SMTP_PASS)) if (Boolean(env.SMTP_USER) !== Boolean(env.SMTP_PASS))
throw new Error("SMTP_USER und SMTP_PASS gemeinsam setzen"); throw new Error("SMTP_USER und SMTP_PASS gemeinsam setzen");
const transport = makeTransport({ const transport = makeTransport({
host, host,
port, port,
secure: env.SMTP_SECURE === "true", secure: env.SMTP_SECURE === "true",
requireTLS: !local, requireTLS: !local,
auth: env.SMTP_USER auth: env.SMTP_USER
? { user: env.SMTP_USER, pass: env.SMTP_PASS } ? { user: env.SMTP_USER, pass: env.SMTP_PASS }
: undefined, : undefined,
connectionTimeout: 10000, connectionTimeout: 10000,
greetingTimeout: 10000, greetingTimeout: 10000,
socketTimeout: 20000, socketTimeout: 20000,
disableFileAccess: true, disableFileAccess: true,
disableUrlAccess: true, disableUrlAccess: true,
}); });
const from = env.SMTP_FROM || "Tri-Hub <buchung@example.test>"; const from = env.SMTP_FROM || "Tri-Hub <buchung@example.test>";
return { transport, from }; return { transport, from };
} }
export function createEmailService( export function createEmailService(
env = process.env, env = process.env,
makeTransport = nodemailer.createTransport, makeTransport = nodemailer.createTransport,
) { ) {
const { transport, from } = createMailTransport(env, makeTransport); const { transport, from } = createMailTransport(env, makeTransport);
return async function sendConfirmation(booking) { return async function sendConfirmation(booking) {
const order = booking.bookedPackage; const order = booking.bookedPackage;
const phone = booking.phone || booking.customer?.phone; const phone = booking.phone || booking.customer?.phone;
const lines = [ const lines = [
`Hallo ${booking.name},`, `Hallo ${booking.name},`,
"", "",
"vielen Dank für deine Bestellung bei Tri-Hub. Hier findest du die Zusammenfassung deiner Buchung.", "vielen Dank für deine Bestellung bei Tri-Hub. Hier findest du die Zusammenfassung deiner Buchung.",
`Buchungsnummer: ${booking.bookingId}`, `Buchungsnummer: ${booking.bookingId}`,
...(booking.createdAt ...(booking.createdAt
? [ ? [
`Bestelldatum: ${new Intl.DateTimeFormat("de-DE", { dateStyle: "long", timeStyle: "short", timeZone: "Europe/Berlin" }).format(new Date(booking.createdAt))} (Europe/Berlin)`, `Bestelldatum: ${new Intl.DateTimeFormat("de-DE", { dateStyle: "long", timeStyle: "short", timeZone: "Europe/Berlin" }).format(new Date(booking.createdAt))} (Europe/Berlin)`,
] ]
: []), : []),
`Kunde: ${booking.name}`, `Kunde: ${booking.name}`,
`E-Mail: ${booking.email}`, `E-Mail: ${booking.email}`,
...(phone ? [`Telefon: ${phone}`] : []), ...(phone ? [`Telefon: ${phone}`] : []),
...(booking.customer?.notes ...(booking.customer?.notes
? [`Deine Anmerkungen: ${booking.customer.notes}`] ? [`Deine Anmerkungen: ${booking.customer.notes}`]
: []), : []),
"", "",
`Paket: ${order?.title ?? booking.packageName}`, `Paket: ${order?.title ?? booking.packageName}`,
...(order ...(order
? [ ? [
order.summaryForBooking, order.summaryForBooking,
order.subtitle, order.subtitle,
"", "",
...orderSummaryRows(order).map( ...orderSummaryRows(order).map(
([label, value]) => `${label}: ${value}`, ([label, value]) => `${label}: ${value}`,
), ),
"", "",
"Enthaltene Leistungen:", "Enthaltene Leistungen:",
...order.includedFeatures.map( ...order.includedFeatures.map(
(feature) => `• ${feature}`, (feature) => `• ${feature}`,
), ),
"", "",
"So geht es weiter:", "So geht es weiter:",
...(order.processSteps || []).map( ...(order.processSteps || []).map(
({ step, text }) => `${step}: ${text}`, ({ step, text }) => `${step}: ${text}`,
), ),
] ]
: []), : []),
"", "",
paymentNotice, paymentNotice,
"", "",
"Bitte bewahre diese Bestätigung auf und gib bei Rückfragen deine Buchungsnummer an.", "Bitte bewahre diese Bestätigung auf und gib bei Rückfragen deine Buchungsnummer an.",
"Dein Tri-Hub-Team", "Dein Tri-Hub-Team",
]; ];
const escapeHtml = (value) => const escapeHtml = (value) =>
String(value).replace( String(value).replace(
/[&<>"']/g, /[&<>"']/g,
(char) => (char) =>
({ ({
"&": "&amp;", "&": "&amp;",
"<": "&lt;", "<": "&lt;",
">": "&gt;", ">": "&gt;",
'"': "&quot;", '"': "&quot;",
"'": "&#39;", "'": "&#39;",
})[char], })[char],
); );
let info; let info;
try { try {
info = await transport.sendMail({ info = await transport.sendMail({
from, from,
to: { address: booking.email, name: booking.name }, to: { address: booking.email, name: booking.name },
subject: `Bestellbestätigung ${booking.bookingId} – Tri-Hub`, subject: `Bestellbestätigung ${booking.bookingId} – Tri-Hub`,
text: lines.join("\n"), 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>`, html: `<html lang="de"><body><h1>Deine Bestellbestätigung</h1>${lines.map((line) => (line ? `<p>${escapeHtml(line)}</p>` : "")).join("")}</body></html>`,
}); });
} catch (error) { } catch (error) {
// Bei Verbindungsabbruch kann SMTP die Nachricht schon angenommen // Bei Verbindungsabbruch kann SMTP die Nachricht schon angenommen
// haben. Nur ausdrückliche Ablehnungen gelten sicher als Fehler. // haben. Nur ausdrückliche Ablehnungen gelten sicher als Fehler.
const explicitRejection = const explicitRejection =
["EAUTH", "EENVELOPE", "ECONNECTION", "EDNS"].includes( ["EAUTH", "EENVELOPE", "ECONNECTION", "EDNS"].includes(
error.code, error.code,
) || Number(error.responseCode) >= 400; ) || Number(error.responseCode) >= 400;
error.deliveryUnknown = !explicitRejection; error.deliveryUnknown = !explicitRejection;
throw error; throw error;
} }
if ( if (
!info.accepted?.some( !info.accepted?.some(
(address) => (address) =>
address.toLowerCase() === booking.email.toLowerCase(), address.toLowerCase() === booking.email.toLowerCase(),
) )
) { ) {
throw new Error("Empfänger vom Mailserver nicht angenommen"); throw new Error("Empfänger vom Mailserver nicht angenommen");
} }
}; };
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,64 +1,64 @@
[ [
{ {
"id": "relindis-agethen", "id": "relindis-agethen",
"name": "Relindis Agethen (Lilli)", "name": "Relindis Agethen (Lilli)",
"email": "relindis.agethen@tri-hub.de", "email": "relindis.agethen@tri-hub.de",
"titel": "Triathletin und Ernährungsberaterin", "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.", "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": [ "qualifikationen": [
"Zertifizierte Ernährungsberaterin für Sportler (B-Lizenz)", "Zertifizierte Ernährungsberaterin für Sportler (B-Lizenz)",
"Vize-Europameisterin", "Vize-Europameisterin",
"Deutsche Meisterin", "Deutsche Meisterin",
"Mehrfache Baden-Württembergische Meisterin" "Mehrfache Baden-Württembergische Meisterin"
], ],
"schwerpunkte": [ "schwerpunkte": [
"Ernährung", "Ernährung",
"Leistungssport-Perspektive", "Leistungssport-Perspektive",
"Wettkampforientierte Entwicklung" "Wettkampforientierte Entwicklung"
], ],
"bildquelle": "", "bildquelle": "",
"vorname": "Relindis", "vorname": "Relindis",
"nachname": "Agethen", "nachname": "Agethen",
"spitzname": "Lilli", "spitzname": "Lilli",
"bilder": [ "bilder": [
{ {
"quelle": "/images/Lilli1.jpeg", "quelle": "/images/Lilli1.jpeg",
"typ": "Porträt" "typ": "Porträt"
}, },
{ {
"quelle": "/images/Lilli2.jpeg", "quelle": "/images/Lilli2.jpeg",
"typ": "Aktionsbild" "typ": "Aktionsbild"
} }
] ]
}, },
{ {
"id": "maren-hoffmann", "id": "maren-hoffmann",
"name": "Maren Hoffmann", "name": "Maren Hoffmann",
"email": "maren.hoffmann@tri-hub.de", "email": "maren.hoffmann@tri-hub.de",
"titel": "Triathletin und Ernährungsberaterin", "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.", "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": [ "qualifikationen": [
"Ernährungsberaterin für Sporternährung", "Ernährungsberaterin für Sporternährung",
"Langjährige Erfahrung im Ausdauersport" "Langjährige Erfahrung im Ausdauersport"
], ],
"schwerpunkte": [ "schwerpunkte": [
"Ernährung im Ausdauertraining", "Ernährung im Ausdauertraining",
"Wettkampfverpflegung", "Wettkampfverpflegung",
"Alltagstaugliche Ernährungsroutinen" "Alltagstaugliche Ernährungsroutinen"
], ],
"bildquelle": "", "bildquelle": "",
"vorname": "Maren", "vorname": "Maren",
"nachname": "Hoffmann", "nachname": "Hoffmann",
"spitzname": "", "spitzname": "",
"bilder": [ "bilder": [
{ {
"quelle": "/images/Maren1.jpeg", "quelle": "/images/Maren1.jpeg",
"typ": "Porträt" "typ": "Porträt"
}, },
{ {
"quelle": "/images/Maren2.jpeg", "quelle": "/images/Maren2.jpeg",
"typ": "Aktionsbild" "typ": "Aktionsbild"
} }
] ]
} }
] ]

View File

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

View File

@ -1,15 +1,15 @@
export const contactReasons = { export const contactReasons = {
coaching: "Persönliches Coaching und Erstgespräch", coaching: "Persönliches Coaching und Erstgespräch",
nutrition: "Ernährungsplan und Anpassungen", nutrition: "Ernährungsplan und Anpassungen",
competition: "Wettkampfverpflegung und Hydration", competition: "Wettkampfverpflegung und Hydration",
recovery: "Regeneration und Verträglichkeit", recovery: "Regeneration und Verträglichkeit",
package: "Fragen zum gebuchten Paket", package: "Fragen zum gebuchten Paket",
appointment: "Termin vereinbaren oder ändern", appointment: "Termin vereinbaren oder ändern",
other: "Sonstiges Anliegen", other: "Sonstiges Anliegen",
}; };
export const contactNotice = 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."; "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 = export const contactBookingUrl =
"https://bookings.cloud.microsoft/bookwithme/user/b055625c7e5b4bf3866230ffa066536b%40Tri-hub.de/meetingtype/qkJP99nqg0a_Na5Z-gY9WQ2?anonymous&ismsaljsauthenabled"; "https://bookings.cloud.microsoft/bookwithme/user/b055625c7e5b4bf3866230ffa066536b%40Tri-hub.de/meetingtype/qkJP99nqg0a_Na5Z-gY9WQ2?anonymous&ismsaljsauthenabled";

View File

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

View File

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

View File

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

View File

@ -1,269 +1,269 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createApp } from "../server/index.js"; import { createApp } from "../server/index.js";
import { createContactFormEmailService } from "../server/services/contact-form-email.js"; import { createContactFormEmailService } from "../server/services/contact-form-email.js";
import { createContactFormStorage } from "../server/services/contact-form-storage.js"; import { createContactFormStorage } from "../server/services/contact-form-storage.js";
const validInput = { const validInput = {
name: "Test Person", name: "Test Person",
email: "person@example.test", email: "person@example.test",
subject: "Eine Frage", subject: "Eine Frage",
message: "Das ist eine ausreichend lange Nachricht.", message: "Das ist eine ausreichend lange Nachricht.",
}; };
// Startet die API mit temporärer Kontakt-CSV und einem ersetzbaren Mailversand. // Startet die API mit temporärer Kontakt-CSV und einem ersetzbaren Mailversand.
async function fixture(t, options = {}) { async function fixture(t, options = {}) {
const directory = await mkdtemp(join(tmpdir(), "trihub-contact-form-")); const directory = await mkdtemp(join(tmpdir(), "trihub-contact-form-"));
const storage = const storage =
options.storage ?? options.storage ??
createContactFormStorage(join(directory, "contact-requests.csv")); createContactFormStorage(join(directory, "contact-requests.csv"));
const sent = []; const sent = [];
const app = createApp({ const app = createApp({
sendConfirmation: async () => {}, sendConfirmation: async () => {},
sendContact: async () => {}, sendContact: async () => {},
contactFormStorage: storage, contactFormStorage: storage,
sendContactForm: async (data) => sent.push(data), sendContactForm: async (data) => sent.push(data),
...options, ...options,
}); });
await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve)); await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve));
t.after(async () => { t.after(async () => {
app.closeAllConnections(); app.closeAllConnections();
await new Promise((resolve) => app.close(resolve)); await new Promise((resolve) => app.close(resolve));
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
}); });
const base = `http://127.0.0.1:${app.address().port}`; const base = `http://127.0.0.1:${app.address().port}`;
async function post(input = validInput, headers = {}) { async function post(input = validInput, headers = {}) {
const response = await fetch(`${base}/api/nutrition-contact`, { const response = await fetch(`${base}/api/nutrition-contact`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", ...headers }, headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
return { status: response.status, body: await response.json() }; return { status: response.status, body: await response.json() };
} }
return { app, base, post, sent, storage, directory }; return { app, base, post, sent, storage, directory };
} }
// Prüft den erfolgreichen Ablauf von HTTP-Anfrage über CSV bis zur Mail-Abhängigkeit. // Prüft den erfolgreichen Ablauf von HTTP-Anfrage über CSV bis zur Mail-Abhängigkeit.
test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", async (t) => { test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", async (t) => {
const f = await fixture(t); const f = await fixture(t);
const result = await f.post(); const result = await f.post();
assert.equal(result.status, 201); assert.equal(result.status, 201);
assert.deepEqual(result.body, { saved: true, emailStatus: "accepted" }); assert.deepEqual(result.body, { saved: true, emailStatus: "accepted" });
assert.equal(f.sent.length, 1); assert.equal(f.sent.length, 1);
assert.deepEqual( assert.deepEqual(
{ {
name: f.sent[0].name, name: f.sent[0].name,
email: f.sent[0].email, email: f.sent[0].email,
subject: f.sent[0].subject, subject: f.sent[0].subject,
message: f.sent[0].message, message: f.sent[0].message,
}, },
validInput, validInput,
); );
assert.ok(!Number.isNaN(Date.parse(f.sent[0].createdAt))); assert.ok(!Number.isNaN(Date.parse(f.sent[0].createdAt)));
assert.deepEqual(await f.storage.readAll(), f.sent); assert.deepEqual(await f.storage.readAll(), f.sent);
}); });
// Stellt sicher, dass fehlerhafte Pflichtfelder nicht gespeichert oder versendet werden. // Stellt sicher, dass fehlerhafte Pflichtfelder nicht gespeichert oder versendet werden.
test("Pflichtfelder, E-Mail, Längen und Mindestlänge werden validiert", async (t) => { test("Pflichtfelder, E-Mail, Längen und Mindestlänge werden validiert", async (t) => {
const f = await fixture(t); const f = await fixture(t);
const invalidInputs = [ const invalidInputs = [
{ ...validInput, name: " " }, { ...validInput, name: " " },
{ ...validInput, name: "Test\nBcc: fremd" }, { ...validInput, name: "Test\nBcc: fremd" },
{ ...validInput, name: "x".repeat(121) }, { ...validInput, name: "x".repeat(121) },
{ ...validInput, email: "keine-adresse" }, { ...validInput, email: "keine-adresse" },
{ ...validInput, email: "x".repeat(255) }, { ...validInput, email: "x".repeat(255) },
{ ...validInput, subject: "x".repeat(161) }, { ...validInput, subject: "x".repeat(161) },
{ ...validInput, message: "zu kurz" }, { ...validInput, message: "zu kurz" },
{ ...validInput, message: "x".repeat(5001) }, { ...validInput, message: "x".repeat(5001) },
{ ...validInput, message: "Nachricht\u0000ungültig" }, { ...validInput, message: "Nachricht\u0000ungültig" },
{ ...validInput, message: undefined }, { ...validInput, message: undefined },
null, null,
[], [],
]; ];
for (const input of invalidInputs) { for (const input of invalidInputs) {
const result = await f.post(input); const result = await f.post(input);
assert.equal(result.status, 400, JSON.stringify(input)); assert.equal(result.status, 400, JSON.stringify(input));
} }
assert.deepEqual(await f.storage.readAll(), []); assert.deepEqual(await f.storage.readAll(), []);
assert.equal(f.sent.length, 0); assert.equal(f.sent.length, 0);
}); });
// Prüft die Begrenzungen und JSON-Fehlerbehandlung des HTTP-Endpunkts. // Prüft die Begrenzungen und JSON-Fehlerbehandlung des HTTP-Endpunkts.
test("HTTP-Route lehnt falsche Methode, Inhaltstyp, JSON und große Bodies ab", async (t) => { test("HTTP-Route lehnt falsche Methode, Inhaltstyp, JSON und große Bodies ab", async (t) => {
const f = await fixture(t); const f = await fixture(t);
assert.equal((await fetch(`${f.base}/api/nutrition-contact`)).status, 405); assert.equal((await fetch(`${f.base}/api/nutrition-contact`)).status, 405);
const wrongType = await fetch(`${f.base}/api/nutrition-contact`, { const wrongType = await fetch(`${f.base}/api/nutrition-contact`, {
method: "POST", method: "POST",
body: "{}", body: "{}",
}); });
assert.equal(wrongType.status, 415); assert.equal(wrongType.status, 415);
const invalidJson = await fetch(`${f.base}/api/nutrition-contact`, { const invalidJson = await fetch(`${f.base}/api/nutrition-contact`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: "{", body: "{",
}); });
assert.equal(invalidJson.status, 400); assert.equal(invalidJson.status, 400);
const tooLarge = await fetch(`${f.base}/api/nutrition-contact`, { const tooLarge = await fetch(`${f.base}/api/nutrition-contact`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "x".repeat(9000) }), body: JSON.stringify({ email: "x".repeat(9000) }),
}); });
assert.equal(tooLarge.status, 413); assert.equal(tooLarge.status, 413);
assert.deepEqual(await f.storage.readAll(), []); assert.deepEqual(await f.storage.readAll(), []);
}); });
// Ein Speicherfehler muss verhindern, dass anschließend E-Mails abgeschickt werden. // Ein Speicherfehler muss verhindern, dass anschließend E-Mails abgeschickt werden.
test("Speicherfehler melden 503 und starten keinen Mailversand", async (t) => { test("Speicherfehler melden 503 und starten keinen Mailversand", async (t) => {
let sendCount = 0; let sendCount = 0;
const f = await fixture(t, { const f = await fixture(t, {
storage: { storage: {
async append() { async append() {
throw new Error("Datenträger nicht verfügbar"); throw new Error("Datenträger nicht verfügbar");
}, },
}, },
sendContactForm: async () => sendCount++, sendContactForm: async () => sendCount++,
}); });
const result = await f.post(); const result = await f.post();
assert.equal(result.status, 503); assert.equal(result.status, 503);
assert.equal(result.body.error.code, "CONTACT_NOT_SAVED"); assert.equal(result.body.error.code, "CONTACT_NOT_SAVED");
assert.equal(sendCount, 0); assert.equal(sendCount, 0);
}); });
// Nach einem SMTP-Fehler bleibt die bereits gespeicherte Anfrage erhalten. // Nach einem SMTP-Fehler bleibt die bereits gespeicherte Anfrage erhalten.
test("Mailfehler melden 502, erhalten aber die gespeicherte Anfrage", async (t) => { test("Mailfehler melden 502, erhalten aber die gespeicherte Anfrage", async (t) => {
const f = await fixture(t, { const f = await fixture(t, {
sendContactForm: async () => { sendContactForm: async () => {
throw new Error("Mailpit nicht erreichbar"); throw new Error("Mailpit nicht erreichbar");
}, },
}); });
const result = await f.post(); const result = await f.post();
assert.equal(result.status, 502); assert.equal(result.status, 502);
assert.equal(result.body.error.code, "CONTACT_EMAIL_FAILED"); assert.equal(result.body.error.code, "CONTACT_EMAIL_FAILED");
assert.equal(result.body.error.message.includes("gespeichert"), true); assert.equal(result.body.error.message.includes("gespeichert"), true);
assert.equal((await f.storage.readAll()).length, 1); assert.equal((await f.storage.readAll()).length, 1);
}); });
// Prüft CSV-Rundlauf, Formelschutz und gleichzeitiges Anhängen mehrerer Anfragen. // Prüft CSV-Rundlauf, Formelschutz und gleichzeitiges Anhängen mehrerer Anfragen.
test("CSV erhält Sonderzeichen und verliert bei parallelen Anhängen keine Zeilen", async (t) => { test("CSV erhält Sonderzeichen und verliert bei parallelen Anhängen keine Zeilen", async (t) => {
const directory = await mkdtemp(join(tmpdir(), "trihub-contact-csv-")); const directory = await mkdtemp(join(tmpdir(), "trihub-contact-csv-"));
const file = join(directory, "contact-requests.csv"); const file = join(directory, "contact-requests.csv");
const storage = createContactFormStorage(file); const storage = createContactFormStorage(file);
t.after(() => rm(directory, { recursive: true, force: true })); t.after(() => rm(directory, { recursive: true, force: true }));
const names = [ const names = [
'=HYPERLINK("evil")', '=HYPERLINK("evil")',
'Test, "Person"', 'Test, "Person"',
"Zeile 1\nZeile 2", "Zeile 1\nZeile 2",
"'Original", "'Original",
...Array.from({ length: 8 }, (_, index) => `Person ${index}`), ...Array.from({ length: 8 }, (_, index) => `Person ${index}`),
]; ];
await Promise.all( await Promise.all(
names.map((name, index) => names.map((name, index) =>
storage.append({ storage.append({
createdAt: `2026-01-01T00:00:0${index}.000Z`, createdAt: `2026-01-01T00:00:0${index}.000Z`,
name, name,
email: `person${index}@example.test`, email: `person${index}@example.test`,
subject: "CSV Test", subject: "CSV Test",
message: "Eine Nachricht mit mehr als zehn Zeichen.", message: "Eine Nachricht mit mehr als zehn Zeichen.",
}), }),
), ),
); );
const records = await storage.readAll(); const records = await storage.readAll();
assert.equal(records.length, names.length); assert.equal(records.length, names.length);
assert.deepEqual( assert.deepEqual(
records.map((record) => record.name).sort(), records.map((record) => record.name).sort(),
[...names].sort(), [...names].sort(),
); );
const csv = await readFile(file, "utf8"); const csv = await readFile(file, "utf8");
assert.match(csv, /'=HYPERLINK/); assert.match(csv, /'=HYPERLINK/);
assert.match(csv, /"Test, ""Person"""/); assert.match(csv, /"Test, ""Person"""/);
}); });
// Beschädigte CSV-Kopfzeilen sollen fehlschlagen und unangetastet bleiben. // Beschädigte CSV-Kopfzeilen sollen fehlschlagen und unangetastet bleiben.
test("CSV mit unerwartetem Kopf wird nicht überschrieben", async (t) => { test("CSV mit unerwartetem Kopf wird nicht überschrieben", async (t) => {
const directory = await mkdtemp( const directory = await mkdtemp(
join(tmpdir(), "trihub-contact-csv-invalid-"), join(tmpdir(), "trihub-contact-csv-invalid-"),
); );
const file = join(directory, "contact-requests.csv"); const file = join(directory, "contact-requests.csv");
const storage = createContactFormStorage(file); const storage = createContactFormStorage(file);
const corrupt = "wrong,header\n1,2\n"; const corrupt = "wrong,header\n1,2\n";
await writeFile(file, corrupt); await writeFile(file, corrupt);
t.after(() => rm(directory, { recursive: true, force: true })); t.after(() => rm(directory, { recursive: true, force: true }));
await assert.rejects( await assert.rejects(
storage.append({ ...validInput, createdAt: "2026-01-01" }), storage.append({ ...validInput, createdAt: "2026-01-01" }),
); );
assert.equal(await readFile(file, "utf8"), corrupt); assert.equal(await readFile(file, "utf8"), corrupt);
}); });
// Verifiziert die zwei Mailvorlagen und dass der Transport fest bei Mailpit bleibt. // Verifiziert die zwei Mailvorlagen und dass der Transport fest bei Mailpit bleibt.
test("Mailservice verwendet Mailpit und sendet Bestätigung sowie Tri-Hub-Anfrage", async () => { test("Mailservice verwendet Mailpit und sendet Bestätigung sowie Tri-Hub-Anfrage", async () => {
const config = []; const config = [];
const messages = []; const messages = [];
const sendContactForm = createContactFormEmailService( const sendContactForm = createContactFormEmailService(
{}, {},
(transportConfig) => { (transportConfig) => {
config.push(transportConfig); config.push(transportConfig);
return { return {
async sendMail(message) { async sendMail(message) {
messages.push(message); messages.push(message);
return { accepted: [message.to] }; return { accepted: [message.to] };
}, },
}; };
}, },
); );
await sendContactForm({ await sendContactForm({
...validInput, ...validInput,
createdAt: "2026-01-01T00:00:00.000Z", createdAt: "2026-01-01T00:00:00.000Z",
}); });
assert.equal(config[0].host, "127.0.0.1"); assert.equal(config[0].host, "127.0.0.1");
assert.equal(config[0].port, 1025); assert.equal(config[0].port, 1025);
assert.deepEqual( assert.deepEqual(
messages.map((message) => message.to).sort(), messages.map((message) => message.to).sort(),
[validInput.email, "ernaehrung@tri-hub.de"].sort(), [validInput.email, "ernaehrung@tri-hub.de"].sort(),
); );
assert.ok( assert.ok(
messages.every( messages.every(
(message) => (message) =>
message.from === "Tri-Hub Ernährung <noreply@tri-hub.de>", message.from === "Tri-Hub Ernährung <noreply@tri-hub.de>",
), ),
); );
assert.ok( assert.ok(
messages.every((message) => message.text.includes(validInput.message)), messages.every((message) => message.text.includes(validInput.message)),
); );
const request = messages.find( const request = messages.find(
(message) => message.to === "ernaehrung@tri-hub.de", (message) => message.to === "ernaehrung@tri-hub.de",
); );
assert.equal(request.replyTo, validInput.email); assert.equal(request.replyTo, validInput.email);
}); });
// SMTP-Ablehnung wird als Fehler sichtbar, statt fälschlich Erfolg zu melden. // SMTP-Ablehnung wird als Fehler sichtbar, statt fälschlich Erfolg zu melden.
test("Mailservice meldet, wenn Mailpit einen Empfänger ablehnt", async () => { test("Mailservice meldet, wenn Mailpit einen Empfänger ablehnt", async () => {
const sendContactForm = createContactFormEmailService({}, () => ({ const sendContactForm = createContactFormEmailService({}, () => ({
async sendMail(message) { async sendMail(message) {
return { return {
accepted: message.to === validInput.email ? [message.to] : [], accepted: message.to === validInput.email ? [message.to] : [],
}; };
}, },
})); }));
await assert.rejects( await assert.rejects(
sendContactForm(validInput), sendContactForm(validInput),
(error) => (error) =>
error.status === 502 && error.code === "CONTACT_EMAIL_FAILED", error.status === 502 && error.code === "CONTACT_EMAIL_FAILED",
); );
}); });

View File

@ -1,189 +1,189 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises"; import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createApp } from "../server/index.js"; import { createApp } from "../server/index.js";
import { createCsvStorage } from "../server/services/csv-storage.js"; import { createCsvStorage } from "../server/services/csv-storage.js";
import { createContactEmailService } from "../server/services/contact-email.js"; import { createContactEmailService } from "../server/services/contact-email.js";
import { contactBookingUrl } from "../shared/contact.js"; import { contactBookingUrl } from "../shared/contact.js";
async function fixture(t, options = {}) { async function fixture(t, options = {}) {
const dir = await mkdtemp(join(tmpdir(), "trihub-contact-")); const dir = await mkdtemp(join(tmpdir(), "trihub-contact-"));
const storage = createCsvStorage(join(dir, "bookings.csv")); const storage = createCsvStorage(join(dir, "bookings.csv"));
await storage.writeAll([ await storage.writeAll([
{ {
bookingId: "TH-000001", bookingId: "TH-000001",
email: "kunde@example.test", email: "kunde@example.test",
name: "Testkunde", name: "Testkunde",
}, },
]); ]);
const sent = []; const sent = [];
const app = createApp({ const app = createApp({
storage, storage,
sendConfirmation: async () => {}, sendConfirmation: async () => {},
sendContact: async (data) => sent.push(data), sendContact: async (data) => sent.push(data),
...options, ...options,
}); });
await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve)); await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve));
t.after(async () => { t.after(async () => {
app.closeAllConnections(); app.closeAllConnections();
await new Promise((resolve) => app.close(resolve)); await new Promise((resolve) => app.close(resolve));
await rm(dir, { recursive: true, force: true }); await rm(dir, { recursive: true, force: true });
}); });
const base = `http://127.0.0.1:${app.address().port}`; const base = `http://127.0.0.1:${app.address().port}`;
const post = async (path, input) => { const post = async (path, input) => {
const response = await fetch(base + path, { const response = await fetch(base + path, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
return { status: response.status, body: await response.json() }; return { status: response.status, body: await response.json() };
}; };
return { post, sent, storage, base }; return { post, sent, storage, base };
} }
const input = { const input = {
email: "kunde@example.test", email: "kunde@example.test",
advisorId: "relindis-agethen", advisorId: "relindis-agethen",
reason: "coaching", reason: "coaching",
simulationAccepted: true, simulationAccepted: true,
}; };
test("CSV-Abgleich normalisiert E-Mail; Versand erhält Berater und festen Redirect", async (t) => { test("CSV-Abgleich normalisiert E-Mail; Versand erhält Berater und festen Redirect", async (t) => {
const f = await fixture(t); const f = await fixture(t);
const before = await f.storage.readAll(); const before = await f.storage.readAll();
const validation = await f.post("/api/contact/validate", { const validation = await f.post("/api/contact/validate", {
email: " KUNDE@example.test ", email: " KUNDE@example.test ",
}); });
assert.equal(validation.status, 200); assert.equal(validation.status, 200);
assert.equal(validation.body.email, input.email); assert.equal(validation.body.email, input.email);
const result = await f.post("/api/contact", { const result = await f.post("/api/contact", {
...input, ...input,
advisor: "Manipuliert", advisor: "Manipuliert",
advisorEmail: "falsch@example.test", advisorEmail: "falsch@example.test",
}); });
assert.equal(result.status, 201); assert.equal(result.status, 201);
assert.equal(result.body.redirectUrl, contactBookingUrl); assert.equal(result.body.redirectUrl, contactBookingUrl);
assert.equal(result.body.simulated, true); assert.equal(result.body.simulated, true);
assert.equal(f.sent[0].advisor.email, "relindis.agethen@tri-hub.de"); assert.equal(f.sent[0].advisor.email, "relindis.agethen@tri-hub.de");
assert.equal(f.sent[0].advisor.vorname, "Relindis"); assert.equal(f.sent[0].advisor.vorname, "Relindis");
const second = await f.post("/api/contact", { const second = await f.post("/api/contact", {
...input, ...input,
advisorId: "maren-hoffmann", advisorId: "maren-hoffmann",
}); });
assert.equal(second.status, 201); assert.equal(second.status, 201);
assert.equal(f.sent[1].advisor.email, "maren.hoffmann@tri-hub.de"); assert.equal(f.sent[1].advisor.email, "maren.hoffmann@tri-hub.de");
assert.deepEqual(await f.storage.readAll(), before); assert.deepEqual(await f.storage.readAll(), before);
}); });
test("Ungültige und unbekannte Kunden, manipulierte Berater, Gründe und fehlende Zustimmung senden nichts", async (t) => { test("Ungültige und unbekannte Kunden, manipulierte Berater, Gründe und fehlende Zustimmung senden nichts", async (t) => {
const f = await fixture(t); const f = await fixture(t);
for (const [data, status] of [ for (const [data, status] of [
[null, 400], [null, 400],
[{ ...input, email: "bad" }, 400], [{ ...input, email: "bad" }, 400],
[{ ...input, email: "unknown@example.test" }, 403], [{ ...input, email: "unknown@example.test" }, 403],
[{ ...input, advisorId: "unbekannt" }, 400], [{ ...input, advisorId: "unbekannt" }, 400],
[{ ...input, advisorId: null }, 400], [{ ...input, advisorId: null }, 400],
[{ ...input, reason: "__proto__" }, 400], [{ ...input, reason: "__proto__" }, 400],
[{ ...input, simulationAccepted: false }, 400], [{ ...input, simulationAccepted: false }, 400],
]) ])
assert.equal((await f.post("/api/contact", data)).status, status); assert.equal((await f.post("/api/contact", data)).status, status);
assert.equal(f.sent.length, 0); assert.equal(f.sent.length, 0);
}); });
test("Absenden prüft CSV erneut; Speicherfehler bleiben geschlossen", async (t) => { test("Absenden prüft CSV erneut; Speicherfehler bleiben geschlossen", async (t) => {
const f = await fixture(t); const f = await fixture(t);
assert.equal((await f.post("/api/contact/validate", input)).status, 200); assert.equal((await f.post("/api/contact/validate", input)).status, 200);
await f.storage.writeAll([]); await f.storage.writeAll([]);
assert.equal((await f.post("/api/contact", input)).status, 403); assert.equal((await f.post("/api/contact", input)).status, 403);
const broken = await fixture(t, { const broken = await fixture(t, {
storage: { storage: {
readAll() { readAll() {
throw new Error("unavailable"); throw new Error("unavailable");
}, },
}, },
}); });
assert.equal((await broken.post("/api/contact", input)).status, 503); assert.equal((await broken.post("/api/contact", input)).status, 503);
assert.equal(broken.sent.length, 0); assert.equal(broken.sent.length, 0);
}); });
test("HTTP-Vertrag weist falsche Methode, ungültiges JSON und große Requests zurück", async (t) => { test("HTTP-Vertrag weist falsche Methode, ungültiges JSON und große Requests zurück", async (t) => {
const f = await fixture(t); const f = await fixture(t);
assert.equal((await fetch(f.base + "/api/contact")).status, 405); assert.equal((await fetch(f.base + "/api/contact")).status, 405);
assert.equal( assert.equal(
(await fetch(f.base + "/api/contact", { method: "POST", body: "x" })) (await fetch(f.base + "/api/contact", { method: "POST", body: "x" }))
.status, .status,
415, 415,
); );
assert.equal( assert.equal(
( (
await fetch(f.base + "/api/contact", { await fetch(f.base + "/api/contact", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: "{", body: "{",
}) })
).status, ).status,
400, 400,
); );
assert.equal( assert.equal(
(await f.post("/api/contact", { email: "x".repeat(9000) })).status, (await f.post("/api/contact", { email: "x".repeat(9000) })).status,
413, 413,
); );
}); });
test("Zwei getrennte simulierte Bestätigungen; Teilfehler verhindern Erfolg", async (t) => { test("Zwei getrennte simulierte Bestätigungen; Teilfehler verhindern Erfolg", async (t) => {
const messages = []; const messages = [];
const sender = createContactEmailService((config) => { const sender = createContactEmailService((config) => {
assert.equal(config.host, "127.0.0.1"); assert.equal(config.host, "127.0.0.1");
assert.equal(config.port, 1025); assert.equal(config.port, 1025);
return { return {
async sendMail(message) { async sendMail(message) {
messages.push(message); messages.push(message);
return { accepted: [message.to] }; return { accepted: [message.to] };
}, },
}; };
}); });
const f = await fixture(t, { sendContact: sender }); const f = await fixture(t, { sendContact: sender });
assert.equal((await f.post("/api/contact", input)).status, 201); assert.equal((await f.post("/api/contact", input)).status, 201);
assert.deepEqual( assert.deepEqual(
messages.map((m) => m.to), messages.map((m) => m.to),
[input.email, "relindis.agethen@tri-hub.de"], [input.email, "relindis.agethen@tri-hub.de"],
); );
for (const message of messages) { for (const message of messages) {
assert.match(message.text, /Simulation/); assert.match(message.text, /Simulation/);
assert.match(message.text, /Persönliches Coaching/); assert.match(message.text, /Persönliches Coaching/);
} }
const failing = createContactEmailService(() => ({ const failing = createContactEmailService(() => ({
async sendMail(message) { async sendMail(message) {
return { accepted: message.to === input.email ? [message.to] : [] }; return { accepted: message.to === input.email ? [message.to] : [] };
}, },
})); }));
const failure = await fixture(t, { sendContact: failing }); const failure = await fixture(t, { sendContact: failing });
const result = await failure.post("/api/contact", input); const result = await failure.post("/api/contact", input);
assert.equal(result.status, 502); assert.equal(result.status, 502);
assert.equal(result.body.redirectUrl, undefined); assert.equal(result.body.redirectUrl, undefined);
}); });
test("Anzeigename bevorzugt Spitzname, sonst Vorname, ohne Nachnamen", async () => { test("Anzeigename bevorzugt Spitzname, sonst Vorname, ohne Nachnamen", async () => {
const { advisorDisplayName } = await import("../shared/berater.js"); const { advisorDisplayName } = await import("../shared/berater.js");
assert.equal( assert.equal(
advisorDisplayName({ advisorDisplayName({
spitzname: " Lilli ", spitzname: " Lilli ",
vorname: "Relindis", vorname: "Relindis",
nachname: "Agethen", nachname: "Agethen",
}), }),
"Lilli", "Lilli",
); );
assert.equal( assert.equal(
advisorDisplayName({ advisorDisplayName({
spitzname: " ", spitzname: " ",
vorname: "Maren", vorname: "Maren",
nachname: "Hoffmann", nachname: "Hoffmann",
}), }),
"Maren", "Maren",
); );
assert.equal( assert.equal(
advisorDisplayName({ vorname: "Maren", nachname: "Hoffmann" }), advisorDisplayName({ vorname: "Maren", nachname: "Hoffmann" }),
"Maren", "Maren",
); );
}); });

View File

@ -1,24 +1,24 @@
// Ausschließlich Testserver: temporäre CSV und simulierter Versand. // Ausschließlich Testserver: temporäre CSV und simulierter Versand.
import { mkdtemp, rm } from "node:fs/promises"; import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createApp } from "../../server/index.js"; import { createApp } from "../../server/index.js";
import { createCsvStorage } from "../../server/services/csv-storage.js"; import { createCsvStorage } from "../../server/services/csv-storage.js";
import { packages } from "../../shared/packages.js"; import { packages } from "../../shared/packages.js";
const directory = await mkdtemp(join(tmpdir(), "trihub-browser-")); const directory = await mkdtemp(join(tmpdir(), "trihub-browser-"));
const server = createApp({ const server = createApp({
catalog: packages, catalog: packages,
storage: createCsvStorage(join(directory, "bookings.csv")), storage: createCsvStorage(join(directory, "bookings.csv")),
sendConfirmation: async () => {}, sendConfirmation: async () => {},
sendContact: async () => {}, sendContact: async () => {},
}); });
server.listen(3000, "127.0.0.1"); server.listen(3000, "127.0.0.1");
async function stop() { async function stop() {
server.closeAllConnections(); server.closeAllConnections();
await new Promise((resolve) => server.close(resolve)); await new Promise((resolve) => server.close(resolve));
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
process.exit(0); process.exit(0);
} }
process.on("SIGTERM", stop); process.on("SIGTERM", stop);
process.on("SIGINT", stop); process.on("SIGINT", stop);

View File

@ -1,44 +1,44 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
export default defineConfig({ export default defineConfig({
server: { server: {
proxy: { "/api": "http://127.0.0.1:3000" }, proxy: { "/api": "http://127.0.0.1:3000" },
fs: { fs: {
// Auch Vites direkten Dateizugriff auf das Projekt absichern. // Auch Vites direkten Dateizugriff auf das Projekt absichern.
deny: [ deny: [
".env", ".env",
".env.*", ".env.*",
"*.{crt,pem}", "*.{crt,pem}",
"**/.git/**", "**/.git/**",
"**/server/**", "**/server/**",
"**/tests/**", "**/tests/**",
], ],
}, },
}, },
build: { build: {
rollupOptions: { rollupOptions: {
input: { input: {
contactTest: fileURLToPath( contactTest: fileURLToPath(
new URL("./contact-test.html", import.meta.url), new URL("./contact-test.html", import.meta.url),
), ),
angebote: fileURLToPath( angebote: fileURLToPath(
new URL("./angebotsuebersicht.html", import.meta.url), new URL("./angebotsuebersicht.html", import.meta.url),
), ),
details: fileURLToPath( details: fileURLToPath(
new URL("./paket-details.html", import.meta.url), new URL("./paket-details.html", import.meta.url),
), ),
main: fileURLToPath(new URL("./index.html", import.meta.url)), main: fileURLToPath(new URL("./index.html", import.meta.url)),
bookingConfirmation: fileURLToPath( bookingConfirmation: fileURLToPath(
new URL("./booking-confirmation.html", import.meta.url), new URL("./booking-confirmation.html", import.meta.url),
), ),
booking: fileURLToPath( booking: fileURLToPath(
new URL("./booking.html", import.meta.url), new URL("./booking.html", import.meta.url),
), ),
berater: fileURLToPath( berater: fileURLToPath(
new URL("./berater.html", import.meta.url), new URL("./berater.html", import.meta.url),
), ),
}, },
}, },
}, },
}); });