Compare commits

...

31 Commits

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

2
.gitignore vendored
View File

@ -10,5 +10,7 @@ Thumbs.db
# 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/reviews.csv
playwright-report/ playwright-report/
test-results/ test-results/

1282
FAQ.html 100644

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@
<title>Ernährungs-Pakete – Tri-Hub</title> <title>Ernährungs-Pakete – 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/styles/footer.css" />
</head> </head>
<body class="packages-page"> <body class="packages-page">
<div id="navbar-placeholder"></div> <div id="navbar-placeholder"></div>
@ -30,5 +31,7 @@
type="module" type="module"
src="/src/features/angebote/angebote-entry.js" src="/src/features/angebote/angebote-entry.js"
></script> ></script>
<div id="footer-placeholder"></div>
<script type="module" src="/src/components/footer/footer.js"></script>
</body> </body>
</html> </html>

View File

@ -3,11 +3,15 @@
<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" />
<link rel="stylesheet" href="/src/styles/footer.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>
@ -16,16 +20,36 @@
<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">
<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> Lerne deine Ernährungsberater kennen.
</h1>
<p class="advisor-intro">
Hinter einer guten Ernährungsstrategie stehen
Menschen, die zuhören und ihre Erfahrung in deinen
Alltag und dein Training einbringen.
</p>
</header> </header>
<div class="advisor-grid" id="advisor-profiles" aria-live="polite"> <div
<p class="advisor-loading">Beraterprofile werden geladen …</p> class="advisor-grid"
id="advisor-profiles"
aria-live="polite"
>
<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
<script type="module" src="/src/features/berater/berater-entry.js"></script> type="module"
src="/src/components/navigationsbar/navigation.js"
></script>
<script
type="module"
src="/src/features/berater/berater-entry.js"
></script>
<div id="footer-placeholder"></div>
<script type="module" src="/src/components/footer/footer.js"></script>
</body> </body>
</html> </html>

View File

@ -6,6 +6,7 @@
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<link rel="stylesheet" href="/src/styles/navigation.css" /> <link rel="stylesheet" href="/src/styles/navigation.css" />
<title>Buchungsbestätigung – Tri-Hub</title> <title>Buchungsbestätigung – Tri-Hub</title>
<link rel="stylesheet" href="/src/styles/footer.css" />
</head> </head>
<body class="confirmation"> <body class="confirmation">
<div id="navbar-placeholder"></div> <div id="navbar-placeholder"></div>
@ -146,9 +147,6 @@
Buchungsbestätigung anzuzeigen.</noscript Buchungsbestätigung anzuzeigen.</noscript
> >
</main> </main>
<footer class="confirmation__footer">
TRI-HUB <span>Dein Weg. Deine Ausdauer. Deine Ernährung.</span>
</footer>
<script <script
type="module" type="module"
src="/src/features/booking/booking-confirmation.js" src="/src/features/booking/booking-confirmation.js"
@ -157,5 +155,7 @@
type="module" type="module"
src="/src/components/navigationsbar/navigation.js" src="/src/components/navigationsbar/navigation.js"
></script> ></script>
<div id="footer-placeholder"></div>
<script type="module" src="/src/components/footer/footer.js"></script>
</body> </body>
</html> </html>

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
<title>Paket buchen – Tri-Hub</title> <title>Paket buchen – Tri-Hub</title>
<link rel="stylesheet" href="/src/styles/footer.css" />
</head> </head>
<body> <body>
<main class="container"> <main class="container">
@ -17,5 +18,7 @@
type="module" type="module"
src="/src/features/booking/booking-entry.js" src="/src/features/booking/booking-entry.js"
></script> ></script>
<div id="footer-placeholder"></div>
<script type="module" src="/src/components/footer/footer.js"></script>
</body> </body>
</html> </html>

View File

@ -4,6 +4,7 @@
<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>
<link rel="stylesheet" href="/src/styles/footer.css" />
</head> </head>
<body> <body>
<main class="contact-test"> <main class="contact-test">
@ -22,5 +23,7 @@
type="module" type="module"
src="/src/features/contact/contact-entry.js" src="/src/features/contact/contact-entry.js"
></script> ></script>
<div id="footer-placeholder"></div>
<script type="module" src="/src/components/footer/footer.js"></script>
</body> </body>
</html> </html>

View File

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

View File

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

View File

@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="stylesheet" href="/src/styles/navigation.css" /> <link rel="stylesheet" href="/src/styles/navigation.css" />
<link rel="stylesheet" href="/src/styles/footer.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
name="description" name="description"
@ -163,10 +164,26 @@
} }
.hero { .hero {
position: relative;
isolation: isolate;
padding: 92px 0 82px; padding: 92px 0 82px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.hero::before {
content: "";
position: absolute;
inset: 0 0 56px;
z-index: -1;
background:
linear-gradient(
180deg,
rgba(8, 15, 21, 0.72),
rgba(8, 15, 21, 0.84)
),
url("/images/startseiter_hintergrund.jpeg") 38% center / cover no-repeat;
}
.hero-grid { .hero-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(330px, 0.8fr); grid-template-columns: minmax(0, 1.2fr) minmax(330px, 0.8fr);
@ -240,6 +257,7 @@
.hero-card { .hero-card {
position: relative; position: relative;
transform: translateX(80px);
min-height: 410px; min-height: 410px;
padding: 38px; padding: 38px;
display: flex; display: flex;
@ -256,8 +274,8 @@
), ),
linear-gradient( linear-gradient(
145deg, 145deg,
rgba(255, 255, 255, 0.075), rgba(13, 19, 31, 0.78),
rgba(255, 255, 255, 0.025) rgba(13, 19, 31, 0.84)
); );
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28); box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28);
} }
@ -274,6 +292,7 @@
box-shadow: box-shadow:
0 0 0 34px rgba(83, 213, 205, 0.035), 0 0 0 34px rgba(83, 213, 205, 0.035),
0 0 0 68px rgba(83, 213, 205, 0.02); 0 0 0 68px rgba(83, 213, 205, 0.02);
pointer-events: none;
} }
.hero-card-mark { .hero-card-mark {
@ -296,6 +315,11 @@
color: var(--muted); color: var(--muted);
} }
.hero-card > div {
position: relative;
z-index: 1;
}
.section { .section {
padding: 96px 0; padding: 96px 0;
} }
@ -375,6 +399,26 @@
font-size: clamp(2.5rem, 5vw, 4.7rem); font-size: clamp(2.5rem, 5vw, 4.7rem);
} }
.knowledge-image-frame {
width: 100%;
transform: translateX(-18px);
margin-top: 28px;
padding: 9px;
overflow: hidden;
border: 1px solid rgba(248, 250, 252, 0.18);
border-radius: 22px;
background: linear-gradient(145deg, #273135, #151d22);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.3);
}
.knowledge-image-frame img {
display: block;
width: 100%;
aspect-ratio: 2.58 / 1;
border-radius: 14px;
object-fit: cover;
}
.knowledge-copy { .knowledge-copy {
color: var(--muted); color: var(--muted);
line-height: 1.85; line-height: 1.85;
@ -470,6 +514,7 @@
} }
input[type="text"], input[type="text"],
input[type="email"],
textarea, textarea,
select { select {
width: 100%; width: 100%;
@ -485,6 +530,7 @@
} }
input[type="text"], input[type="text"],
input[type="email"],
select { select {
height: 48px; height: 48px;
padding: 0 14px; padding: 0 14px;
@ -533,7 +579,7 @@
.rating-input label:hover, .rating-input label:hover,
.rating-input label:hover ~ label, .rating-input label:hover ~ label,
.rating-input input:checked ~ label { .rating-input input:checked ~ label {
color: var(--brand); color: #f7c230;
} }
.consent { .consent {
@ -545,6 +591,11 @@
font-size: 0.78rem; font-size: 0.78rem;
} }
.consent a {
font-weight: 700;
text-decoration: underline;
}
.consent input { .consent input {
margin-top: 4px; margin-top: 4px;
accent-color: var(--brand); accent-color: var(--brand);
@ -557,6 +608,21 @@
cursor: pointer; cursor: pointer;
} }
#contactMessageHint.is-valid {
color: var(--brand);
}
.field-error {
display: block;
min-height: 0;
color: #fca5a5;
font-size: 0.78rem;
}
.form-status.is-error {
color: #fca5a5;
}
.form-status { .form-status {
min-height: 20px; min-height: 20px;
margin-top: 12px; margin-top: 12px;
@ -613,7 +679,7 @@
} }
.stars { .stars {
color: var(--brand); color: #f7c230;
letter-spacing: 0.08em; letter-spacing: 0.08em;
white-space: nowrap; white-space: nowrap;
} }
@ -675,6 +741,7 @@
.hero-card { .hero-card {
min-height: 330px; min-height: 330px;
transform: none;
} }
} }
@ -862,6 +929,13 @@
<h2 style="font-family: &quot;Inter&quot;, sans-serif"> <h2 style="font-family: &quot;Inter&quot;, sans-serif">
Wissen,<br />das dich<br />weiterbringt. Wissen,<br />das dich<br />weiterbringt.
</h2> </h2>
<div class="knowledge-image-frame">
<img
src="/images/exttraBild.jpeg"
alt="Ergänzendes Bild zur Ernährungsberatung"
loading="lazy"
/>
</div>
</div> </div>
<div> <div>
@ -943,44 +1017,65 @@
</a> </a>
</div> </div>
<div class="review-list"> <div
<!-- Diese Beispielkarten sind bewusst als Platzhalter gekennzeichnet. --> class="review-list"
<article class="review-card"> id="reviewSlider"
<div class="review-top"> role="region"
<div class="person"> aria-roledescription="Karussell"
<div class="avatar">T</div> aria-label="Kundenrezensionen"
<div> >
<strong>Deine Erfahrung</strong> <div
<small class="review-stage"
>Hier kann eine Kundenstimme aria-live="off"
erscheinen</small tabindex="0"
> role="group"
aria-label="Rezensionen durchblättern"
aria-describedby="reviewInstructions"
>
<article class="review-card is-active">
<div class="review-top">
<div class="person">
<div class="avatar">T</div>
<div>
<strong>Deine Erfahrung</strong>
<small
>Noch keine Rezension
vorhanden</small
>
</div>
</div>
<div
class="stars"
aria-label="Beispiel für eine Sternebewertung"
>
★★★★★
</div> </div>
</div> </div>
<div
class="stars" <blockquote
aria-label="Beispiel für eine Sternebewertung" style="
font-family:
&quot;Inter&quot;, sans-serif;
"
> >
★★★★★ „Teile hier deine Erfahrung mit der
</div> Ernährungsberatung – ehrlich, persönlich und
</div> so, wie du sie erlebt hast.“
</blockquote>
<blockquote </article>
style=" </div>
font-family: &quot;Inter&quot;, sans-serif; <span
" class="review-instructions"
> id="reviewInstructions"
„Teile hier deine Erfahrung mit der >Mit der Maus ziehen oder mit den Pfeiltasten links
Ernährungsberatung – ehrlich, persönlich und so, und rechts durch die Rezensionen wechseln.</span
wie du sie erlebt hast.“ >
</blockquote> <span class="review-announcement" role="status"></span>
</article> <p class="review-feedback" role="status"></p>
<p class="review-note"> <p class="review-note">
Hinweis: Die Bewertungen in dieser HTML-Demo werden Nur Kunden mit einer vorhandenen Buchung können eine
nicht dauerhaft gespeichert. Für den Live-Betrieb Bewertung abgeben. Deine E-Mail-Adresse wird nicht
sollte das Formular an das bestehende Backend oder öffentlich angezeigt.
CMS von Tri-hub angebunden werden.
</p> </p>
</div> </div>
</div> </div>
@ -1010,19 +1105,36 @@
<form class="review-form" id="reviewForm"> <form class="review-form" id="reviewForm">
<div class="field" style="margin-top: 0"> <div class="field" style="margin-top: 0">
<label for="name">Dein Name</label> <label for="reviewEmail"
>E-Mail deiner Buchung*</label
>
<input <input
id="name" id="reviewEmail"
name="name" name="email"
type="text" type="email"
autocomplete="name" autocomplete="email"
placeholder="z. B. Thomas M." maxlength="254"
placeholder="z. B. thomas@example.de"
required required
/> />
</div> </div>
<div class="field"> <div class="field">
<label>Deine Bewertung</label> <label for="reviewTitle"
>Titel (10–50 Zeichen)*</label
>
<input
id="reviewTitle"
name="title"
type="text"
minlength="10"
maxlength="50"
required
/>
</div>
<div class="field">
<label>Deine Bewertung*</label>
<div <div
class="rating-input" class="rating-input"
aria-label="Sternebewertung" aria-label="Sternebewertung"
@ -1071,10 +1183,14 @@
</div> </div>
<div class="field"> <div class="field">
<label for="review">Deine Erfahrung</label> <label for="review"
>Deine Erfahrung (10–500 Zeichen)*</label
>
<textarea <textarea
id="review" id="review"
name="review" name="review"
minlength="10"
maxlength="500"
placeholder="Was hat dir an der Ernährungsberatung geholfen?" placeholder="Was hat dir an der Ernährungsberatung geholfen?"
required required
></textarea> ></textarea>
@ -1083,12 +1199,22 @@
<label class="consent"> <label class="consent">
<input type="checkbox" name="consent" required /> <input type="checkbox" name="consent" required />
<span> <span>
Ich stimme zu, dass meine Bewertung mit meinem Ich akzeptiere die
angegebenen Namen auf dieser Seite <a
veröffentlicht werden darf. href="https://www.tri-hub.de/datenschutz"
target="_blank"
rel="noopener noreferrer"
>Datenschutzbestimmungen</a
>*: Meine E-Mail-Adresse wird zum Abgleich mit
meiner Buchung und zusammen mit meiner Bewertung
gespeichert. Titel, Bewertungstext und Sterne
dürfen veröffentlicht werden; meine
E-Mail-Adresse bleibt nicht öffentlich.
</span> </span>
</label> </label>
<small>*Pflichtfelder</small>
<button class="button button-primary" type="submit"> <button class="button button-primary" type="submit">
Bewertung veröffentlichen Bewertung veröffentlichen
</button> </button>
@ -1102,40 +1228,155 @@
</form> </form>
</div> </div>
</section> </section>
<!-- Kontaktaufnahme -->
<section class="section" id="kontakt">
<div class="container">
<div class="section-heading">
<div>
<div class="eyebrow">KONTAKT AUFNEHMEN</div>
<h2
style="
font-family: &quot;Inter&quot;, sans-serif;
"
>
Schreib uns.
</h2>
</div>
<p class="section-intro">
Du hast Fragen oder möchtest einen Termin
vereinbaren? Schreib uns einfach. Wir melden uns
schnellstmöglich bei dir.
</p>
</div>
<form class="review-form" id="contactForm">
<div class="field" style="margin-top: 0">
<label for="contactName">Dein Name*</label>
<input
id="contactName"
name="name"
type="text"
autocomplete="name"
placeholder="z. B. Thomas Müller"
maxlength="120"
required
/>
<small
class="field-error"
data-error-for="name"
></small>
</div>
<div class="field">
<label for="contactEmail"
>Deine E-Mail-Adresse*</label
>
<input
id="contactEmail"
name="email"
type="email"
autocomplete="email"
placeholder="z. B. thomas@example.de"
maxlength="254"
required
/>
<small
class="field-error"
data-error-for="email"
></small>
</div>
<div class="field">
<label for="contactSubject">Betreff*</label>
<input
id="contactSubject"
name="subject"
type="text"
placeholder="Worum geht es?"
maxlength="160"
required
/>
<small
class="field-error"
data-error-for="subject"
></small>
</div>
<div class="field">
<label for="message">Deine Nachricht*</label>
<textarea
id="message"
name="message"
placeholder="Wie können wir dir helfen?"
minlength="10"
maxlength="5000"
required
></textarea>
<small id="contactMessageHint"
>Mindestens 10 Zeichen erforderlich.</small
>
<small
class="field-error"
data-error-for="message"
></small>
<br />
<small>*Pflichtfelder</small>
</div>
<label class="consent"
><input
type="checkbox"
name="consent"
required
/><span
>Ich akzeptiere die
<a
href="https://www.tri-hub.de/datenschutz"
target="_blank"
rel="noopener noreferrer"
>Datenschutzbestimmungen</a
>
(Pflichtfeld).</span
></label
>
<p data-error-for="consent" role="alert"></p>
<button class="button button-primary" type="submit">
Nachricht senden
</button>
<div
class="form-status"
id="contactFormStatus"
role="status"
aria-live="polite"
></div>
</form>
</div>
</section>
</main> </main>
<footer> <div id="footer-placeholder"></div>
<div class="container">
<div>
<strong>tri-hub</strong> · Triathlon als sportliches Zuhause
ab 50.
</div>
<div>Ernährung · Wissen · Erfahrungen</div>
</div>
</footer>
<script <script
type="module" type="module"
src="/src/components/navigationsbar/navigation.js" src="/src/components/navigationsbar/navigation.js"
></script> ></script>
<script> <script type="module" src="/src/components/footer/footer.js"></script>
const form = document.getElementById("reviewForm"); <script
const status = document.getElementById("formStatus"); type="module"
src="/src/features/contact/contact-form.js"
form.addEventListener("submit", function (event) { ></script>
event.preventDefault(); <script
type="module"
const name = form.elements.name.value.trim(); src="/src/features/reviews/review-slider.js"
const rating = form.elements.rating.value; ></script>
const review = form.elements.review.value.trim(); <script
type="module"
if (!name || !rating || !review) return; src="/src/features/reviews/review-form.js"
></script>
status.textContent =
"Danke für deine Bewertung. In dieser Demo wurde sie noch nicht dauerhaft gespeichert.";
form.reset();
});
</script>
</body> </body>
</html> </html>

View File

@ -6,6 +6,7 @@
<title>Paket-Details – Tri-Hub Ernährungsberatung</title> <title>Paket-Details – Tri-Hub Ernährungsberatung</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/styles/footer.css" />
</head> </head>
<body class="packages-page"> <body class="packages-page">
<div id="navbar-placeholder"></div> <div id="navbar-placeholder"></div>
@ -23,5 +24,7 @@
type="module" type="module"
src="/src/features/angebote/paket-details.js" src="/src/features/angebote/paket-details.js"
></script> ></script>
<div id="footer-placeholder"></div>
<script type="module" src="/src/components/footer/footer.js"></script>
</body> </body>
</html> </html>

View File

@ -8,13 +8,13 @@ export default defineConfig({
{ {
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: false, 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: false, reuseExistingServer: process.env.PW_REUSE_EXISTING === "1",
}, },
], ],
}); });

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

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

View File

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

View File

@ -35,16 +35,22 @@
<a href="/index.html" class="navbar__link">Ernährung</a> <a href="/index.html" class="navbar__link">Ernährung</a>
</li> </li>
<li> <li>
<a href="/berater.html" class="navbar__link">Beraterprofil</a> <a href="/berater.html" class="navbar__link"
>Beraterprofil</a
>
</li> </li>
<li> <li>
<a href="/index.html" class="navbar__link">FAQ</a> <a href="/FAQ.html" class="navbar__link">FAQ</a>
</li> </li>
<li> <li>
<a href="#erfahrungen" class="navbar__link">Rezensionen</a> <a href="/index.html#erfahrungen" class="navbar__link"
>Rezensionen</a
>
</li> </li>
<li> <li>
<a href="/index.html" class="navbar__link">Kontakt</a> <a href="/index.html#kontakt" class="navbar__link"
>Kontakt</a
>
</li> </li>
<li> <li>
<a <a

View File

@ -15,9 +15,24 @@ export function renderAngebotsuebersicht() {
? `<span class="package-highlight">${pkg.highlight}</span>` ? `<span class="package-highlight">${pkg.highlight}</span>`
: ""; : "";
const cardHtml = ` const packageImage = {
<div class="package-card"> "ernaehrung-starter": {
src: "/images/Starter_bild.webp",
alt: "Triathlon-Einsteiger im Gespräch mit ihrem Coach am See",
},
"ernaehrung-standard": {
src: "/images/standard_bild.webp",
alt: "Triathlet im Radtrikot bespricht sein Training mit dem Coach",
},
"ernaehrung-premium": {
src: "/images/premium_bild.webp",
alt: "Triathlon-Athleten planen gemeinsam am Seeufer",
},
}[pkg.id];
const cardHtml = `
<article class="package-card">
<img class="package-image" src="${packageImage.src}" alt="${packageImage.alt}" loading="lazy" />
<div class="package-type-container"> <div class="package-type-container">
<span class="package-type">${pkg.type}</span> <span class="package-type">${pkg.type}</span>
${highlightHtml} ${highlightHtml}
@ -43,7 +58,7 @@ export function renderAngebotsuebersicht() {
<a href="/paket-details.html?id=${pkg.id}" class="package-btn">${pkg.buttonText} <span aria-hidden="true">→</span></a> <a href="/paket-details.html?id=${pkg.id}" class="package-btn">${pkg.buttonText} <span aria-hidden="true">→</span></a>
</div> </article>
`; `;
container.innerHTML += cardHtml; container.innerHTML += cardHtml;

View File

@ -4,21 +4,60 @@ 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(/[&<>"']/g, (character) => ({ return String(value).replace(
"&": "&amp;", /[&<>"']/g,
"<": "&lt;", (character) =>
">": "&gt;", ({
'"': "&quot;", "&": "&amp;",
"'": "&#039;", "<": "&lt;",
})[character]); ">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[character],
);
}
function contactName(person) {
return (
(typeof person.spitzname === "string" && person.spitzname.trim()) ||
person.vorname ||
person.name.trim().split(/\s+/)[0]
);
} }
if (container) { if (container) {
if (!Array.isArray(berater) || berater.length === 0) { if (!Array.isArray(berater) || berater.length === 0) {
container.innerHTML = '<p class="advisor-loading">Aktuell sind keine Beraterprofile verfügbar.</p>'; container.innerHTML =
'<p class="advisor-loading">Aktuell sind keine Beraterprofile verfügbar.</p>';
} else { } else {
container.innerHTML = berater.map((person) => ` container.innerHTML = berater
.map(
(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__frame">
<div class="advisor-gallery__track">
${(person.bilder || [])
.map(
(bild, index) => `
<figure class="advisor-gallery__slide" aria-label="${index + 1} von ${person.bilder.length}: ${escapeHtml(bild.typ)}" aria-hidden="${index !== 0}">
<img class="advisor-gallery__image" src="${escapeHtml(bild.quelle)}" alt="${escapeHtml(person.name)} – ${escapeHtml(bild.typ)}" loading="${index === 0 ? "eager" : "lazy"}" />
</figure>`,
)
.join("")}
</div>
<button class="advisor-gallery__arrow advisor-gallery__arrow--previous" type="button" data-gallery-direction="previous" aria-label="Vorheriges Bild von ${escapeHtml(person.name)}">‹</button>
<button class="advisor-gallery__arrow advisor-gallery__arrow--next" type="button" data-gallery-direction="next" aria-label="Nächstes Bild von ${escapeHtml(person.name)}">›</button>
</div>
<div class="advisor-gallery__pagination" aria-label="Bildposition">
${(person.bilder || [])
.map(
(bild, index) => `
<button class="advisor-gallery__dot" type="button" data-gallery-index="${index}" aria-label="Bild ${index + 1}: ${escapeHtml(bild.typ)}" aria-current="${index === 0}"></button>`,
)
.join("")}
</div>
</div>
<h3 class="package-title">${escapeHtml(person.name)}</h3> <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">
@ -35,22 +74,61 @@ if (container) {
<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((typeof person.spitzname === "string" && person.spitzname.trim()) || person.vorname || person.name.trim().split(/\s+/)[0])} 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(
"button[data-gallery-direction], button[data-gallery-index]",
);
if (galleryControl) {
const gallery = galleryControl.closest(".advisor-gallery");
const slides = [...gallery.querySelectorAll(".advisor-gallery__slide")];
const currentIndex = Number(gallery.dataset.currentIndex || 0);
const requestedIndex = galleryControl.hasAttribute(
"data-gallery-direction",
)
? currentIndex +
(galleryControl.dataset.galleryDirection === "next" ? 1 : -1)
: Number(galleryControl.dataset.galleryIndex);
const nextIndex = (requestedIndex + slides.length) % slides.length;
gallery.dataset.currentIndex = String(nextIndex);
gallery.querySelector(".advisor-gallery__track").style.transform =
`translateX(-${nextIndex * 100}%)`;
slides.forEach((slide, index) => {
slide.setAttribute("aria-hidden", String(index !== nextIndex));
});
gallery.querySelectorAll("[data-gallery-index]").forEach((button) => {
button.setAttribute(
"aria-current",
String(Number(button.dataset.galleryIndex) === nextIndex),
);
});
return;
}
const button = event.target.closest("button[data-berater-id]"); const button = event.target.closest("button[data-berater-id]");
if (!button) return; if (!button) return;
try { try {
const { openContactDialog } = await import(/* @vite-ignore */ "../contact/contact-dialog.js"); const { openContactDialog } = await import(
/* @vite-ignore */ "../contact/contact-dialog.js"
);
openContactDialog({ advisorId: button.dataset.beraterId }); openContactDialog({ advisorId: button.dataset.beraterId });
} catch (error) { } catch (error) {
console.error("Das Kontaktformular ist derzeit nicht verfügbar.", error); console.error(
button.insertAdjacentHTML("afterend", '<p class="advisor-contact-error" role="status">Das Kontaktformular ist momentan nicht verfügbar.</p>'); "Das Kontaktformular ist derzeit nicht verfügbar.",
error,
);
button.insertAdjacentHTML(
"afterend",
'<p class="advisor-contact-error" role="status">Das Kontaktformular ist momentan nicht verfügbar.</p>',
);
button.disabled = true; button.disabled = true;
} }
}); });

View File

@ -127,3 +127,109 @@
border-radius: 1.4rem; border-radius: 1.4rem;
background: #ffffff0b; background: #ffffff0b;
} }
.advisor-gallery {
margin-bottom: 1.5rem;
}
.advisor-gallery__frame {
position: relative;
padding: 0.7rem;
overflow: hidden;
border: 1px dashed #f8fafc38;
border-radius: 1.5rem;
background: #ffffff05;
}
.advisor-gallery__track {
display: flex;
width: 100%;
height: clamp(240px, 34vw, 390px);
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
.advisor-gallery__slide {
flex: 0 0 100%;
width: 100%;
height: 100%;
margin: 0;
}
.advisor-gallery__image {
display: block;
width: 100%;
height: 100%;
border-radius: 1.1rem;
background: #071720;
object-fit: cover;
object-position: center;
}
.advisor-gallery__arrow {
position: absolute;
top: 50%;
display: grid;
width: 2.75rem;
height: 2.75rem;
padding: 0;
place-items: center;
transform: translateY(-50%);
border: 1px solid #ffffff55;
border-radius: 50%;
color: #fff;
background: #071720bb;
font: inherit;
font-size: 2rem;
line-height: 1;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease;
}
.advisor-gallery__arrow:hover {
border-color: #53d5cd;
background: #071720ee;
}
.advisor-gallery__arrow--previous {
left: 1.2rem;
}
.advisor-gallery__arrow--next {
right: 1.2rem;
}
.advisor-gallery__pagination {
display: flex;
justify-content: center;
gap: 0.55rem;
margin-top: 0.85rem;
}
.advisor-gallery__dot {
width: 0.7rem;
height: 0.7rem;
padding: 0;
border: 1px solid #94a3b8;
border-radius: 50%;
background: #ffffff12;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.advisor-gallery__dot[aria-current="true"] {
transform: scale(1.15);
border-color: #53d5cd;
background: #53d5cd;
}
.advisor-gallery__arrow:focus-visible,
.advisor-gallery__dot:focus-visible {
outline: 3px solid #53d5cd;
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.advisor-gallery__track {
transition: none;
}
}

View File

@ -109,6 +109,8 @@ export function mountBookingPage(
<input id="${instanceId}-booking-phone" name="phone" type="tel" autocomplete="tel" maxlength="50" aria-describedby="${instanceId}-booking-phone-error" /> <input id="${instanceId}-booking-phone" name="phone" type="tel" autocomplete="tel" maxlength="50" aria-describedby="${instanceId}-booking-phone-error" />
<p class="booking__field-error" id="${instanceId}-booking-phone-error"></p> <p class="booking__field-error" id="${instanceId}-booking-phone-error"></p>
</div> </div>
<label class="booking__consent"><input name="consent" type="checkbox" required aria-describedby="${instanceId}-booking-consent-error" /> <span>Ich akzeptiere die <a href="https://www.tri-hub.de/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> (Pflichtfeld).</span></label>
<p class="booking__field-error" id="${instanceId}-booking-consent-error"></p>
<div class="booking__actions"> <div class="booking__actions">
<button class="booking__button booking__cancel" type="button">Abbrechen</button> <button class="booking__button booking__cancel" type="button">Abbrechen</button>
<button class="booking__button" type="submit">Paket buchen</button> <button class="booking__button" type="submit">Paket buchen</button>
@ -192,6 +194,7 @@ export function mountBookingPage(
const errors = form.querySelector(".booking__errors"); const errors = form.querySelector(".booking__errors");
const progress = form.querySelector(".booking__progress"); const progress = form.querySelector(".booking__progress");
const result = content.querySelector(".booking__result"); const result = content.querySelector(".booking__result");
const consent = form.elements.consent;
const storageKey = `trihub.booking.${selected.packageId}${selected.variantId ? `.${selected.variantId}` : ""}`; const storageKey = `trihub.booking.${selected.packageId}${selected.variantId ? `.${selected.variantId}` : ""}`;
let attempt = null; let attempt = null;
let busy = false; let busy = false;
@ -201,11 +204,12 @@ export function mountBookingPage(
name.readOnly = locked; name.readOnly = locked;
email.readOnly = locked; email.readOnly = locked;
phone.readOnly = locked; phone.readOnly = locked;
consent.disabled = locked;
} }
function showError(message, fields = {}) { function showError(message, fields = {}) {
errors.textContent = message; errors.textContent = message;
errors.hidden = false; errors.hidden = false;
for (const field of [name, email, phone]) { for (const field of [name, email, phone, consent]) {
const message = fields[field.name] || ""; const message = fields[field.name] || "";
content.querySelector( content.querySelector(
`#${instanceId}-booking-${field.name}-error`, `#${instanceId}-booking-${field.name}-error`,
@ -216,7 +220,7 @@ export function mountBookingPage(
} }
function clearErrors() { function clearErrors() {
errors.hidden = true; errors.hidden = true;
for (const field of [name, email, phone]) { for (const field of [name, email, phone, consent]) {
field.removeAttribute("aria-invalid"); field.removeAttribute("aria-invalid");
content.querySelector( content.querySelector(
`#${instanceId}-booking-${field.name}-error`, `#${instanceId}-booking-${field.name}-error`,
@ -256,6 +260,7 @@ export function mountBookingPage(
name.value = stored.payload.name; name.value = stored.payload.name;
email.value = stored.payload.email; email.value = stored.payload.email;
phone.value = stored.payload.phone || ""; phone.value = stored.payload.phone || "";
consent.checked = stored.payload.consent === true;
lockFields(true); lockFields(true);
button.textContent = "Status prüfen / erneut versuchen"; button.textContent = "Status prüfen / erneut versuchen";
if (stored.result) showResult(stored.result, false); if (stored.result) showResult(stored.result, false);
@ -275,6 +280,9 @@ export function mountBookingPage(
clearErrors(); clearErrors();
if (!attempt) { if (!attempt) {
const fields = {}; const fields = {};
if (!consent.checked)
fields.consent =
"Bitte die Datenschutzbestimmungen akzeptieren.";
name.value = name.value.trim(); name.value = name.value.trim();
email.value = email.value.trim(); email.value = email.value.trim();
if ( if (
@ -305,6 +313,7 @@ export function mountBookingPage(
...(selected.variantId ...(selected.variantId
? { variantId: selected.variantId } ? { variantId: selected.variantId }
: {}), : {}),
consent: consent.checked,
name: name.value, name: name.value,
email: email.value, email: email.value,
...(phone.value ? { phone: phone.value } : {}), ...(phone.value ? { phone: phone.value } : {}),

View File

@ -190,3 +190,25 @@
.booking__dialog .booking__result:focus { .booking__dialog .booking__result:focus {
outline-color: #53d5cd; outline-color: #53d5cd;
} }
.booking .booking__consent {
display: flex;
align-items: flex-start;
gap: 0.6rem;
line-height: 1.5;
}
.booking__consent input[type="checkbox"] {
flex: 0 0 1em;
width: 1em;
height: 1em;
min-height: 0;
margin: 0.25em 0 0;
padding: 0;
}
.booking__consent a,
.booking__consent a:visited {
color: #fff;
font-weight: 700;
text-decoration: underline;
}

View File

@ -30,11 +30,14 @@ export function openContactDialog({
<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>
<label class="contact-consent"><input name="consent" type="checkbox" required><span>Ich akzeptiere die <a href="https://www.tri-hub.de/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> (Pflichtfeld).</span></label>
<p class="contact-status" role="status" aria-live="polite"></p> <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>`;
dialog.querySelector("#contact-title").textContent = const advisorName = advisorDisplayName(advisor);
advisorDisplayName(advisor) || "Berater nicht gefunden"; dialog.querySelector("#contact-title").textContent = advisorName
? `${advisorName} kontaktieren`
: "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.";
@ -55,6 +58,7 @@ export function openContactDialog({
close.disabled = busy; close.disabled = busy;
email.disabled = busy || sent; email.disabled = busy || sent;
reason.disabled = busy || sent; reason.disabled = busy || sent;
form.elements.consent.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) {
@ -80,6 +84,7 @@ export function openContactDialog({
email: email.value, email: email.value,
advisorId, advisorId,
reason: reason.value, reason: reason.value,
consent: form.elements.consent.checked,
simulationAccepted: form.elements.simulationAccepted.checked, simulationAccepted: form.elements.simulationAccepted.checked,
}; };
busy = true; busy = true;

View File

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

View File

@ -148,3 +148,8 @@
.contact-status a { .contact-status a {
color: var(--contact-accent); color: var(--contact-accent);
} }
.contact-consent a {
font-weight: 700;
text-decoration: underline;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,10 @@
bookingId,createdAt,packageId,packageName,name,email,emailStatus,idempotencyKey,requestHash,bookedPackage,customer,acceptedTerms bookingId,createdAt,packageId,packageName,name,email,emailStatus,idempotencyKey,requestHash,bookedPackage,customer,acceptedTerms,consent
TH-000001,2026-09-25T11:43:43.074Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,kk,kk@web.de,accepted,497662ce-278c-466f-9505-c2728f8973ca,d5c69de514e6de5552e43f99256a042b59df5bd53db0bd1685f849809a455ef9,,, TH-000001,2026-09-25T11:43:43.074Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,kk,kk@web.de,accepted,497662ce-278c-466f-9505-c2728f8973ca,d5c69de514e6de5552e43f99256a042b59df5bd53db0bd1685f849809a455ef9,,,,true
TH-000002,2026-09-25T19:22:37.801Z,ernaehrung-starter,Für den sicheren Einstieg in die Sporternährung,Marvin,marvin@web.de,accepted,06b9ad79-05c3-4e37-9135-0c3cf1da3f36,7650a36bd436390b5f79dde1ea1e0d8944f1320b29bad927dfd71157c3f4133c,,, TH-000002,2026-09-25T19:22:37.801Z,ernaehrung-starter,Für den sicheren Einstieg in die Sporternährung,Marvin,marvin@web.de,accepted,06b9ad79-05c3-4e37-9135-0c3cf1da3f36,7650a36bd436390b5f79dde1ea1e0d8944f1320b29bad927dfd71157c3f4133c,,,,true
TH-000003,2026-09-25T19:52:53.927Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,M,test@web.de,accepted,9446e525-a714-4912-bb10-1906a75cc9cd,a8fbd181361e4de7713fb0c4b5e0bf88cd43acd3e16294e7b84b33d959551458,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",, TH-000003,2026-09-25T19:52:53.927Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,M,test@web.de,accepted,9446e525-a714-4912-bb10-1906a75cc9cd,a8fbd181361e4de7713fb0c4b5e0bf88cd43acd3e16294e7b84b33d959551458,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
TH-000004,2026-09-25T19:54:52.744Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,m,test@web.de,accepted,1817271c-343e-4e27-ac60-9d225897ba28,4a9f445acdf5cef94e95d8cdf50e78f95884913799cd2f532aaa37302910a16b,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",, TH-000004,2026-09-25T19:54:52.744Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,m,test@web.de,accepted,1817271c-343e-4e27-ac60-9d225897ba28,4a9f445acdf5cef94e95d8cdf50e78f95884913799cd2f532aaa37302910a16b,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",,,true
TH-000005,2026-09-25T20:14:51.874Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,Marvin,m@web.de,accepted,678654e5-7db9-49af-97b0-64fe02257e77,89c188f086a2d72fb77ae52612288610d2f17846fb1a3e62b11e8caa011cf2b4,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",, TH-000005,2026-09-25T20:14:51.874Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,Marvin,m@web.de,accepted,678654e5-7db9-49af-97b0-64fe02257e77,89c188f086a2d72fb77ae52612288610d2f17846fb1a3e62b11e8caa011cf2b4,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",,,true
TH-000006,2026-09-26T08:44:42.313Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,Marvin,m@web.de,accepted,a0a8c426-fc56-4887-a0e5-42172ff9cfb2,b83cf7b2ef4c42447bd83038c60d2ca565a8b37d24cae128f4fa2214dc43b185,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",, TH-000006,2026-09-26T08:44:42.313Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,Marvin,m@web.de,accepted,a0a8c426-fc56-4887-a0e5-42172ff9cfb2,b83cf7b2ef4c42447bd83038c60d2ca565a8b37d24cae128f4fa2214dc43b185,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
TH-000007,2026-09-26T08:50:50.112Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,k@web.de,accepted,92e12eff-321d-49e5-a9ad-674a7d61f9a7,14b1f6b3909696f8402be66b1de33956b84ca435f58567e57e31e7152b6bff6c,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",, TH-000007,2026-09-26T08:50:50.112Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,k@web.de,accepted,92e12eff-321d-49e5-a9ad-674a7d61f9a7,14b1f6b3909696f8402be66b1de33956b84ca435f58567e57e31e7152b6bff6c,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
TH-000008,2026-09-26T09:18:17.493Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,marvin@test.de,accepted,c74460a8-0315-4e0b-9d6b-48c854b0f71b,19ebc08e678515ea3a71f9b295ac1fbbf188bf54602a531f4f3bdc53698dcda7,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",, TH-000008,2026-09-26T09:18:17.493Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,marvin@test.de,accepted,c74460a8-0315-4e0b-9d6b-48c854b0f71b,19ebc08e678515ea3a71f9b295ac1fbbf188bf54602a531f4f3bdc53698dcda7,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
TH-000009,2026-09-26T09:43:16.704Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,marvin,m@email.de,accepted,9c18134f-3a71-428b-8769-fda7d06790c7,eaa9a3f0dfc568c726f1b6fc26d92f8c4cc7df072f9fdd40fbea581661eeef9f,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-24"",""variantLabel"":""24-Wochen-Variante"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":671.43,""taxRate"":19,""taxAmount"":127.57,""grossPrice"":799,""currency"":""EUR""}",, TH-000009,2026-09-26T09:43:16.704Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,marvin,m@email.de,accepted,9c18134f-3a71-428b-8769-fda7d06790c7,eaa9a3f0dfc568c726f1b6fc26d92f8c4cc7df072f9fdd40fbea581661eeef9f,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-24"",""variantLabel"":""24-Wochen-Variante"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":671.43,""taxRate"":19,""taxAmount"":127.57,""grossPrice"":799,""currency"":""EUR""}",,,true

1 bookingId createdAt packageId packageName name email emailStatus idempotencyKey requestHash bookedPackage customer acceptedTerms consent
2 TH-000001 2026-09-25T11:43:43.074Z ernaehrung-standard Für Best Ager mit ersten Erfahrungen und klaren Zielen kk kk@web.de accepted 497662ce-278c-466f-9505-c2728f8973ca d5c69de514e6de5552e43f99256a042b59df5bd53db0bd1685f849809a455ef9 true
3 TH-000002 2026-09-25T19:22:37.801Z ernaehrung-starter Für den sicheren Einstieg in die Sporternährung Marvin marvin@web.de accepted 06b9ad79-05c3-4e37-9135-0c3cf1da3f36 7650a36bd436390b5f79dde1ea1e0d8944f1320b29bad927dfd71157c3f4133c true
4 TH-000003 2026-09-25T19:52:53.927Z ernaehrung-standard Für Best Ager mit ersten Erfahrungen und klaren Zielen M test@web.de accepted 9446e525-a714-4912-bb10-1906a75cc9cd a8fbd181361e4de7713fb0c4b5e0bf88cd43acd3e16294e7b84b33d959551458 {"packageId":"ernaehrung-standard","variantId":null,"variantLabel":null,"type":"STANDARD","title":"Für Best Ager mit ersten Erfahrungen und klaren Zielen","subtitle":"Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf.","summaryForBooking":"Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)","durationWeeks":24,"durationLabel":"24 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","includedFeatures":["Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung","Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)","Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag","Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung","Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke","Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)"],"processSteps":[{"step":"1. Tiefen-Anamnese","text":"Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten."},{"step":"2. Periodisierte Planung","text":"Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke."},{"step":"3. Wettkampf-Simulation","text":"Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag."}],"netPrice":377.31,"taxRate":19,"taxAmount":71.69,"grossPrice":449,"currency":"EUR"} true
5 TH-000004 2026-09-25T19:54:52.744Z ernaehrung-premium Für maximale Individualität mit klarem Fokus m test@web.de accepted 1817271c-343e-4e27-ac60-9d225897ba28 4a9f445acdf5cef94e95d8cdf50e78f95884913799cd2f532aaa37302910a16b {"packageId":"ernaehrung-premium","variantId":"ernaehrung-premium-52","variantLabel":"52-Wochen-Variante (Jahresbegleitung)","type":"PREMIUM","title":"Für maximale Individualität und intensive 1:1-Begleitung","subtitle":"Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten.","summaryForBooking":"Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)","durationWeeks":52,"durationLabel":"52 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","includedFeatures":["Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte","Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan","Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50","14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support","Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)","Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub"],"processSteps":[{"step":"1. Ganzheitliches Onboarding","text":"Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur."},{"step":"2. Laufende Steuerung","text":"Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration."},{"step":"3. Punktlandung am Wettkampftag","text":"Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung."}],"netPrice":1175.63,"taxRate":19,"taxAmount":223.37,"grossPrice":1399,"currency":"EUR"} true
6 TH-000005 2026-09-25T20:14:51.874Z ernaehrung-premium Für maximale Individualität mit klarem Fokus Marvin m@web.de accepted 678654e5-7db9-49af-97b0-64fe02257e77 89c188f086a2d72fb77ae52612288610d2f17846fb1a3e62b11e8caa011cf2b4 {"packageId":"ernaehrung-premium","variantId":"ernaehrung-premium-52","variantLabel":"52-Wochen-Variante (Jahresbegleitung)","type":"PREMIUM","title":"Für maximale Individualität und intensive 1:1-Begleitung","subtitle":"Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten.","summaryForBooking":"Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)","durationWeeks":52,"durationLabel":"52 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","includedFeatures":["Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte","Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan","Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50","14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support","Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)","Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub"],"processSteps":[{"step":"1. Ganzheitliches Onboarding","text":"Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur."},{"step":"2. Laufende Steuerung","text":"Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration."},{"step":"3. Punktlandung am Wettkampftag","text":"Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung."}],"netPrice":1175.63,"taxRate":19,"taxAmount":223.37,"grossPrice":1399,"currency":"EUR"} true
7 TH-000006 2026-09-26T08:44:42.313Z ernaehrung-standard Für Best Ager mit ersten Erfahrungen und klaren Zielen Marvin m@web.de accepted a0a8c426-fc56-4887-a0e5-42172ff9cfb2 b83cf7b2ef4c42447bd83038c60d2ca565a8b37d24cae128f4fa2214dc43b185 {"packageId":"ernaehrung-standard","variantId":null,"variantLabel":null,"type":"STANDARD","title":"Für Best Ager mit ersten Erfahrungen und klaren Zielen","subtitle":"Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf.","summaryForBooking":"Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)","durationWeeks":24,"durationLabel":"24 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","includedFeatures":["Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung","Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)","Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag","Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung","Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke","Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)"],"processSteps":[{"step":"1. Tiefen-Anamnese","text":"Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten."},{"step":"2. Periodisierte Planung","text":"Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke."},{"step":"3. Wettkampf-Simulation","text":"Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag."}],"netPrice":377.31,"taxRate":19,"taxAmount":71.69,"grossPrice":449,"currency":"EUR"} true
8 TH-000007 2026-09-26T08:50:50.112Z ernaehrung-standard Für Best Ager mit ersten Erfahrungen und klaren Zielen m k@web.de accepted 92e12eff-321d-49e5-a9ad-674a7d61f9a7 14b1f6b3909696f8402be66b1de33956b84ca435f58567e57e31e7152b6bff6c {"packageId":"ernaehrung-standard","variantId":null,"variantLabel":null,"type":"STANDARD","title":"Für Best Ager mit ersten Erfahrungen und klaren Zielen","subtitle":"Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf.","summaryForBooking":"Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)","durationWeeks":24,"durationLabel":"24 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","includedFeatures":["Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung","Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)","Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag","Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung","Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke","Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)"],"processSteps":[{"step":"1. Tiefen-Anamnese","text":"Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten."},{"step":"2. Periodisierte Planung","text":"Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke."},{"step":"3. Wettkampf-Simulation","text":"Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag."}],"netPrice":377.31,"taxRate":19,"taxAmount":71.69,"grossPrice":449,"currency":"EUR"} true
9 TH-000008 2026-09-26T09:18:17.493Z ernaehrung-standard Für Best Ager mit ersten Erfahrungen und klaren Zielen m marvin@test.de accepted c74460a8-0315-4e0b-9d6b-48c854b0f71b 19ebc08e678515ea3a71f9b295ac1fbbf188bf54602a531f4f3bdc53698dcda7 {"packageId":"ernaehrung-standard","variantId":null,"variantLabel":null,"type":"STANDARD","title":"Für Best Ager mit ersten Erfahrungen und klaren Zielen","subtitle":"Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf.","summaryForBooking":"Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)","durationWeeks":24,"durationLabel":"24 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","includedFeatures":["Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung","Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)","Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag","Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung","Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke","Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)"],"processSteps":[{"step":"1. Tiefen-Anamnese","text":"Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten."},{"step":"2. Periodisierte Planung","text":"Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke."},{"step":"3. Wettkampf-Simulation","text":"Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag."}],"netPrice":377.31,"taxRate":19,"taxAmount":71.69,"grossPrice":449,"currency":"EUR"} true
10 TH-000009 2026-09-26T09:43:16.704Z ernaehrung-premium Für maximale Individualität mit klarem Fokus marvin m@email.de accepted 9c18134f-3a71-428b-8769-fda7d06790c7 eaa9a3f0dfc568c726f1b6fc26d92f8c4cc7df072f9fdd40fbea581661eeef9f {"packageId":"ernaehrung-premium","variantId":"ernaehrung-premium-24","variantLabel":"24-Wochen-Variante","type":"PREMIUM","title":"Für maximale Individualität und intensive 1:1-Begleitung","subtitle":"Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten.","summaryForBooking":"Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)","durationWeeks":24,"durationLabel":"24 Wochen Begleitung","quantity":1,"billingInterval":"einmalig","includedFeatures":["Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte","Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan","Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50","14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support","Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)","Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub"],"processSteps":[{"step":"1. Ganzheitliches Onboarding","text":"Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur."},{"step":"2. Laufende Steuerung","text":"Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration."},{"step":"3. Punktlandung am Wettkampftag","text":"Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung."}],"netPrice":671.43,"taxRate":19,"taxAmount":127.57,"grossPrice":799,"currency":"EUR"} true

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -82,7 +82,9 @@ function validateInput(input, key) {
}; };
} }
const fields = {}; const fields = {};
const result = {}; if (input.consent !== true)
fields.consent = "Bitte die Datenschutzbestimmungen akzeptieren.";
const result = { consent: true };
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."],
@ -315,6 +317,7 @@ export function createBookingService({
bookedPackage, bookedPackage,
customer: data.customer ?? null, customer: data.customer ?? null,
acceptedTerms: data.acceptedTerms ?? null, acceptedTerms: data.acceptedTerms ?? null,
consent: data.consent,
name: data.name, name: data.name,
email: data.email, email: data.email,
phone: data.customer?.phone ?? data.phone ?? "", phone: data.customer?.phone ?? data.phone ?? "",

View File

@ -9,7 +9,7 @@ export function createContactEmailService(makeTransport) {
{ {
SMTP_HOST: "127.0.0.1", SMTP_HOST: "127.0.0.1",
SMTP_PORT: "1025", SMTP_PORT: "1025",
SMTP_FROM: "Tri-Hub Simulation <kontakt@tri-hub.de>", SMTP_FROM: "Tri-Hub Ernährung <noreply@tri-hub.de>",
}, },
makeTransport, makeTransport,
); );
@ -18,19 +18,20 @@ export function createContactEmailService(makeTransport) {
[ [
{ {
to: email, to: email,
text: `Deine Kontaktanfrage an ${advisorDisplayName(advisor)} ist in der Simulation eingegangen.`, text: `Deine Kontaktanfrage an ${advisorDisplayName(advisor)} ist in der Simulation eingegangen. ${advisorDisplayName(advisor)} wird sich bei dir melden.`,
closing: "Viele Grüße\nTri-Hub Ernährung",
}, },
{ {
to: advisor.email, to: advisor.email,
text: `Hallo ${advisorDisplayName(advisor)}, eine simulierte Kontaktanfrage von ${email} ist eingegangen.`, text: `Hallo ${advisorDisplayName(advisor)}, bitte setze dich mit ${email} in Kontakt.`,
}, },
].map(async ({ to, text }) => { ].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}`, text: `${text}\n\nKontaktgrund: ${reason}\n\n${contactNotice}${closing ? `\n\n${closing}` : ""}`,
}); });
if ( if (
!result.accepted?.some( !result.accepted?.some(

View File

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

View File

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

View File

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

View File

@ -29,7 +29,7 @@ function advisorDetails(advisorId) {
return { ...advisor }; return { ...advisor };
} }
export function createContactService({ storage, sendContact }) { export function createContactService({ storage, requestStorage, sendContact }) {
async function validate(input) { async function validate(input) {
if ( if (
!input || !input ||
@ -93,6 +93,29 @@ export function createContactService({ storage, sendContact }) {
"Bitte den Simulationshinweis bestätigen.", "Bitte den Simulationshinweis bestätigen.",
); );
} }
if (input.consent !== true) {
throw new BookingError(
400,
"CONSENT_REQUIRED",
"Bitte die Datenschutzbestimmungen akzeptieren.",
);
}
try {
await requestStorage.append({
createdAt: new Date().toISOString(),
email,
advisorId: advisor.id,
reason: input.reason,
simulationAccepted: true,
consent: true,
});
} catch {
throw new BookingError(
503,
"CONTACT_NOT_SAVED",
"Die Kontaktanfrage konnte nicht gespeichert werden.",
);
}
await sendContact({ await sendContact({
email, email,
advisor, advisor,

View File

@ -18,8 +18,10 @@ const legacyColumns = [
const orderColumns = [...legacyColumns, "bookedPackage"]; const orderColumns = [...legacyColumns, "bookedPackage"];
const customerColumns = [...orderColumns, "customer", "acceptedTerms"]; const customerColumns = [...orderColumns, "customer", "acceptedTerms"];
export const columns = [...customerColumns, "phone"]; const consentColumns = [...customerColumns, "consent"];
const jsonColumns = ["bookedPackage", "customer", "acceptedTerms"]; const phoneColumns = [...customerColumns, "phone"];
export const columns = [...phoneColumns, "consent"];
const jsonColumns = ["bookedPackage", "customer", "acceptedTerms", "consent"];
// Apostroph-Präfix schützt Tabellenprogramme. Ein vorhandenes Apostroph wird // Apostroph-Präfix schützt Tabellenprogramme. Ein vorhandenes Apostroph wird
// ebenfalls maskiert, damit Lesen/Schreiben die Originalwerte exakt erhält. // ebenfalls maskiert, damit Lesen/Schreiben die Originalwerte exakt erhält.
@ -49,6 +51,8 @@ export function createCsvStorage(filePath) {
if ( if (
header.join(",") !== columns.join(",") && header.join(",") !== columns.join(",") &&
header.join(",") !== customerColumns.join(",") && header.join(",") !== customerColumns.join(",") &&
header.join(",") !== phoneColumns.join(",") &&
header.join(",") !== consentColumns.join(",") &&
header.join(",") !== legacyColumns.join(",") && header.join(",") !== legacyColumns.join(",") &&
header.join(",") !== orderColumns.join(",") header.join(",") !== orderColumns.join(",")
) { ) {

View File

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

View File

@ -359,7 +359,8 @@
"email", "email",
"advisorId", "advisorId",
"reason", "reason",
"simulationAccepted" "simulationAccepted",
"consent"
], ],
"properties": { "properties": {
"email": { "email": {
@ -387,6 +388,10 @@
"type": "string", "type": "string",
"example": "relindis-agethen", "example": "relindis-agethen",
"description": "ID aus src/shared/berater-daten.json. Name und E-Mail werden serverseitig ausschließlich dort nachgeschlagen." "description": "ID aus src/shared/berater-daten.json. Name und E-Mail werden serverseitig ausschließlich dort nachgeschlagen."
},
"consent": {
"type": "boolean",
"enum": [true]
} }
} }
} }
@ -809,7 +814,7 @@
}, },
"BookingCreateRequest": { "BookingCreateRequest": {
"type": "object", "type": "object",
"required": ["packageId"], "required": ["packageId", "consent"],
"properties": { "properties": {
"packageId": { "packageId": {
"type": "string", "type": "string",
@ -875,6 +880,11 @@
"type": "boolean", "type": "boolean",
"example": true, "example": true,
"enum": [true] "enum": [true]
},
"consent": {
"type": "boolean",
"enum": [true],
"description": "Zustimmung zu den Datenschutzbestimmungen ist erforderlich."
} }
}, },
"anyOf": [ "anyOf": [

View File

@ -19,7 +19,17 @@
"bildquelle": "", "bildquelle": "",
"vorname": "Relindis", "vorname": "Relindis",
"nachname": "Agethen", "nachname": "Agethen",
"spitzname": "Lilli" "spitzname": "Lilli",
"bilder": [
{
"quelle": "/images/Lilli1.jpeg",
"typ": "Porträt"
},
{
"quelle": "/images/Lilli2.jpeg",
"typ": "Aktionsbild"
}
]
}, },
{ {
"id": "maren-hoffmann", "id": "maren-hoffmann",
@ -39,6 +49,16 @@
"bildquelle": "", "bildquelle": "",
"vorname": "Maren", "vorname": "Maren",
"nachname": "Hoffmann", "nachname": "Hoffmann",
"spitzname": "" "spitzname": "",
"bilder": [
{
"quelle": "/images/Maren1.jpeg",
"typ": "Porträt"
},
{
"quelle": "/images/Maren2.jpeg",
"typ": "Aktionsbild"
}
]
} }
] ]

View File

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

View File

@ -72,6 +72,15 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.package-image {
display: block;
width: 100%;
aspect-ratio: 1.42 / 1;
margin: 0 0 1.5rem;
border: 1px solid #f8fafc1f;
border-radius: 1.1rem;
object-fit: cover;
}
.package-type-container { .package-type-container {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

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

View File

@ -11,6 +11,7 @@ import { createEmailService } from "../server/services/email-service.js";
import { packages } from "../shared/packages.js"; import { packages } from "../shared/packages.js";
const input = { const input = {
consent: true,
packageId: packages[0].id, packageId: packages[0].id,
name: 'Test, "Person"', name: 'Test, "Person"',
email: "person@example.test", email: "person@example.test",
@ -68,6 +69,7 @@ test("Buchung steht vor Versand in CSV; Paketname kommt vom Server", async (t) =
assert.ok(!Number.isNaN(Date.parse(result.body.createdAt))); assert.ok(!Number.isNaN(Date.parse(result.body.createdAt)));
const records = await fixture.storage.readAll(); const records = await fixture.storage.readAll();
assert.equal(records.length, 1); assert.equal(records.length, 1);
assert.equal(records[0].consent, true);
assert.equal(records[0].name, input.name); assert.equal(records[0].name, input.name);
assert.equal(records[0].emailStatus, "accepted"); assert.equal(records[0].emailStatus, "accepted");
assert.equal(fixture.sent.length, 1); assert.equal(fixture.sent.length, 1);
@ -81,6 +83,7 @@ test("Pflichtfelder, Typen, Längen, E-Mail und unbekannte IDs werden abgelehnt"
null, null,
[], [],
{}, {},
...[false, "true", undefined].map((consent) => ({ ...input, consent })),
{ ...input, name: " " }, { ...input, name: " " },
{ ...input, name: 42 }, { ...input, name: 42 },
{ ...input, name: "a".repeat(121) }, { ...input, name: "a".repeat(121) },
@ -583,6 +586,7 @@ test("Swagger-Kundendaten und Zustimmung werden validiert und dauerhaft gespeich
notes: "Vorbereitung auf die Mitteldistanz.", notes: "Vorbereitung auf die Mitteldistanz.",
}; };
const request = { const request = {
consent: true,
packageId: "ernaehrung-standard", packageId: "ernaehrung-standard",
customer, customer,
acceptedTerms: true, acceptedTerms: true,
@ -633,6 +637,7 @@ test("Telefonnummern werden in beiden Anfrageformaten geprüft", async (t) => {
{ ...input, phone }, { ...input, phone },
{ {
packageId: input.packageId, packageId: input.packageId,
consent: true,
acceptedTerms: true, acceptedTerms: true,
customer: { customer: {
firstName: "Test", firstName: "Test",

View File

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

View File

@ -5,12 +5,18 @@ import { packagesDetails } from "../../shared/packagesdetails.js";
const pageUrl = "/booking.html#/buchen/ernaehrung-starter"; const pageUrl = "/booking.html#/buchen/ernaehrung-starter";
async function fill(page) { async function fill(page) {
if (!(await page.locator("dialog[open]").count())) {
await page
.getByRole("button", { name: "Jetzt buchen", exact: true })
.click();
}
await page await page
.getByLabel("Name (Pflichtfeld)", { exact: true }) .getByLabel("Name (Pflichtfeld)", { exact: true })
.fill("Test Person"); .fill("Test Person");
await page await page
.getByLabel("E-Mail-Adresse (Pflichtfeld)") .getByLabel("E-Mail-Adresse (Pflichtfeld)")
.fill("person@example.test"); .fill("person@example.test");
await page.locator('[name="consent"]').check();
} }
test("Tastaturbedienung, Validierung und vollständige Buchung über Vite-Proxy", async ({ test("Tastaturbedienung, Validierung und vollständige Buchung über Vite-Proxy", async ({
@ -26,41 +32,47 @@ test("Tastaturbedienung, Validierung und vollständige Buchung über Vite-Proxy"
await expect(page.locator(".booking__payment-notice")).toContainText( await expect(page.locator(".booking__payment-notice")).toContainText(
"keine Abbuchung", "keine Abbuchung",
); );
await page.keyboard.press("Tab"); const opener = page.getByRole("button", {
await expect( name: "Jetzt buchen",
page.getByRole("link", { name: "Zur Angebotsseite" }), exact: true,
).toBeFocused(); });
await page.keyboard.press("Tab"); await opener.focus();
await page.keyboard.press("Enter");
await expect( await expect(
page.getByLabel("Name (Pflichtfeld)", { exact: true }), page.getByLabel("Name (Pflichtfeld)", { exact: true }),
).toBeFocused(); ).toBeFocused();
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
await expect(page.getByRole("alert")).toBeFocused(); await expect(page.getByRole("alert")).toBeFocused();
await expect(page.locator('[aria-invalid="true"]')).toHaveCount(2); await expect(page.locator('[aria-invalid="true"]')).toHaveCount(3);
await page.keyboard.press("Tab"); await page.keyboard.press("Tab");
await page.keyboard.type("Test Person"); await page.keyboard.type("Test Person");
await page.keyboard.press("Tab"); await page.keyboard.press("Tab");
await page.keyboard.type("person@example.test"); await page.keyboard.type("person@example.test");
await page.keyboard.press("Tab"); await page.keyboard.press("Tab");
const outline = await page await page.keyboard.press("Tab");
.getByRole("button", { name: "Paket buchen", exact: true }) await expect(page.locator('[name="consent"]')).toBeFocused();
.evaluate((element) => getComputedStyle(element).outlineStyle); await page.keyboard.press("Space");
expect(outline).toBe("solid"); await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
const submit = page.getByRole("button", {
name: "Paket buchen",
exact: true,
});
await expect(submit).toBeFocused();
expect(
await submit.evaluate(
(element) => getComputedStyle(element).outlineStyle,
),
).toBe("solid");
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
await expect(page.locator(".booking__result")).toContainText( await expect(page).toHaveURL(/booking-confirmation\.html$/);
/Buchungsnummer: TH-\d{6,}/, await expect(page.locator("#confirmation-receipt")).toContainText(
/TH-\d{6,}/,
); );
await expect(page.locator(".booking__result")).toContainText(
"Zustellung ist noch nicht bestätigt",
);
await expect(
page.getByRole("button", { name: "Buchung gespeichert" }),
).toBeDisabled();
await page.reload(); await page.reload();
await expect(page.locator(".booking__result")).toContainText( await expect(page.locator("#confirmation-receipt")).toContainText(
/Buchungsnummer: TH-\d{6,}/, /TH-\d{6,}/,
); );
}); });
@ -105,6 +117,9 @@ test("Verlorene Antwort: Wiederholung nach Neuladen behält Schlüssel und Buchu
.click(); .click();
await expect(page.getByRole("alert")).toBeVisible(); await expect(page.getByRole("alert")).toBeVisible();
await page.reload(); await page.reload();
await page
.getByRole("button", { name: "Jetzt buchen", exact: true })
.click();
await expect( await expect(
page.getByLabel("Name (Pflichtfeld)", { exact: true }), page.getByLabel("Name (Pflichtfeld)", { exact: true }),
).toHaveValue("Test Person"); ).toHaveValue("Test Person");
@ -114,7 +129,9 @@ test("Verlorene Antwort: Wiederholung nach Neuladen behält Schlüssel und Buchu
await page await page
.getByRole("button", { name: "Status prüfen / erneut versuchen" }) .getByRole("button", { name: "Status prüfen / erneut versuchen" })
.click(); .click();
await expect(page.locator(".booking__result")).toContainText(firstBooking); await expect(page.locator("#confirmation-receipt")).toContainText(
firstBooking,
);
expect(requests).toBe(2); expect(requests).toBe(2);
}); });
@ -186,7 +203,9 @@ test("Vite liefert Serverdateien und Umgebungsdateien nicht aus", async ({
for (const pkg of packages) { for (const pkg of packages) {
test(`Von der Startseite zur Buchung: ${pkg.type}`, async ({ page }) => { test(`Von der Startseite zur Buchung: ${pkg.type}`, async ({ page }) => {
await page.goto("/"); await page.goto("/");
await page.getByRole("link", { name: "Beratung kennenlernen" }).click(); await page
.getByRole("link", { name: "Ernährungswissen entdecken" })
.click();
await expect(page.locator(".package-card")).toHaveCount( await expect(page.locator(".package-card")).toHaveCount(
packages.length, packages.length,
); );
@ -195,13 +214,11 @@ for (const pkg of packages) {
new RegExp(`paket-details\\.html\\?id=${pkg.id}$`), new RegExp(`paket-details\\.html\\?id=${pkg.id}$`),
); );
await page.locator("#proceed-to-booking-btn").click(); await page.locator("#proceed-to-booking-btn").click();
await expect(page).toHaveURL( await expect(page.locator("dialog[open]")).toBeVisible();
new RegExp(`booking\\.html\\?id=${pkg.id}`),
);
const details = packagesDetails.find((entry) => entry.id === pkg.id); const details = packagesDetails.find((entry) => entry.id === pkg.id);
await expect( await expect(page.locator(".booking__package-name")).toHaveText(
page.getByRole("heading", { name: details.title, exact: true }), details.title,
).toBeVisible(); );
await expect(page.locator(".booking__summary")).toHaveText( await expect(page.locator(".booking__summary")).toHaveText(
details.summaryForBooking, details.summaryForBooking,
); );
@ -219,13 +236,16 @@ for (const pkg of packages) {
.click(); .click();
const response = await responsePromise; const response = await responsePromise;
expect(response.status()).toBe(201); expect(response.status()).toBe(201);
const booking = await response.json(); await expect(page).toHaveURL(/booking-confirmation\.html$/);
const booking = await page.evaluate(() =>
JSON.parse(sessionStorage.getItem("trihub.booking.confirmation")),
);
expect(booking.packageId).toBe(pkg.id); expect(booking.packageId).toBe(pkg.id);
expect(booking.packageName).toBe(pkg.name); expect(booking.packageName).toBe(pkg.name);
await expect(page.locator(".booking__result")).toContainText( await expect(page.locator("#confirmation-receipt")).toContainText(
booking.bookingId, booking.bookingId,
); );
await page.getByRole("link", { name: "Zur Angebotsseite" }).click(); await page.goto("/angebotsuebersicht.html");
await expect(page.locator(".package-card")).toHaveCount( await expect(page.locator(".package-card")).toHaveCount(
packages.length, packages.length,
); );
@ -249,14 +269,11 @@ test("Premium-Jahresvariante wird von der Detailseite bis zur Bestätigung über
await page.goto("/paket-details.html?id=ernaehrung-premium"); await page.goto("/paket-details.html?id=ernaehrung-premium");
await page.locator("#variant-select").selectOption("ernaehrung-premium-52"); await page.locator("#variant-select").selectOption("ernaehrung-premium-52");
await page.locator("#proceed-to-booking-btn").click(); await page.locator("#proceed-to-booking-btn").click();
await expect(page).toHaveURL( await expect(page.locator("dialog[open]")).toBeVisible();
/booking\.html\?id=ernaehrung-premium&variant=ernaehrung-premium-52$/,
);
await expect(page.locator(".booking__totals")).toContainText( await expect(page.locator(".booking__totals")).toContainText(
"52 Wochen Begleitung", "52 Wochen Begleitung",
); );
await expect(page.locator(".booking__totals")).toContainText("1.399,00"); await expect(page.locator(".booking__totals")).toContainText("1.399,00");
await page.reload();
await fill(page); await fill(page);
const responsePromise = page.waitForResponse( const responsePromise = page.waitForResponse(
(response) => (response) =>
@ -270,15 +287,20 @@ test("Premium-Jahresvariante wird von der Detailseite bis zur Bestätigung über
expect(response.request().postDataJSON().variantId).toBe( expect(response.request().postDataJSON().variantId).toBe(
"ernaehrung-premium-52", "ernaehrung-premium-52",
); );
const booking = await response.json(); await expect(page).toHaveURL(/booking-confirmation\.html$/);
const booking = await page.evaluate(() =>
JSON.parse(sessionStorage.getItem("trihub.booking.confirmation")),
);
expect(booking.bookedPackage.grossPrice).toBe(1399); expect(booking.bookedPackage.grossPrice).toBe(1399);
expect(booking.bookedPackage.durationWeeks).toBe(52); expect(booking.bookedPackage.durationWeeks).toBe(52);
await expect(page.locator(".booking__result")).toContainText( await expect(page.locator("#confirmation-receipt")).toContainText(
booking.bookingId, booking.bookingId,
); );
await page.reload(); await page.reload();
await expect(page.locator(".booking__totals")).toContainText("1.399,00"); await expect(page.locator("#confirmation-totals")).toContainText(
await expect(page.locator(".booking__result")).toContainText( "1.399,00",
);
await expect(page.locator("#confirmation-receipt")).toContainText(
booking.bookingId, booking.bookingId,
); );
}); });

View File

@ -16,7 +16,12 @@ test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter",
const email = `kontakt-${randomUUID()}@example.test`; const 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: {
consent: true,
packageId: "ernaehrung-starter",
name: "Testkunde",
email,
},
}); });
expect(booking.ok()).toBeTruthy(); expect(booking.ok()).toBeTruthy();
await open(page); await open(page);
@ -30,7 +35,8 @@ test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter",
.getByLabel("Deine Buchungs-E-Mail") .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.locator('[name="simulationAccepted"]').check();
await page.locator('[name="consent"]').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();
@ -76,7 +82,8 @@ test("Versandfehler zeigt Meldung und bleibt im Popup", async ({ page }) => {
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.locator('[name="simulationAccepted"]').check();
await page.locator('[name="consent"]').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",

View File

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

View File

@ -0,0 +1,217 @@
import { test, expect } from "@playwright/test";
import { randomUUID } from "node:crypto";
const reviews = [
{
createdAt: "2026-01-01",
title: "Hilfreiche Beratung",
review: "Die Beratung hat mir im Training sehr geholfen.",
rating: 4,
},
{
createdAt: "2026-01-02",
title: "Sehr guter Ernährungsplan",
review: "Der Plan lässt sich leicht in meinen Alltag integrieren.",
rating: 5,
},
{
createdAt: "2026-01-03",
title: "Gute Tipps für den Alltag",
review: "Ich konnte viele Tipps direkt im Alltag ausprobieren.",
rating: 3,
},
];
const active = (page) => page.locator("#reviewSlider .review-card.is-active");
async function mock(page, rows = reviews) {
await page.route("**/api/reviews", (route) =>
route.fulfill({ json: { reviews: rows } }),
);
await page.goto("/index.html");
await expect(active(page)).toContainText(
rows[0]?.title || "Deine Erfahrung",
);
}
test("10-Sekunden-Wechsel, Maus-Drag und Tastatur ohne Buttons", async ({
page,
}) => {
await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") });
await page.clock.pauseAt(new Date("2026-01-01T00:00:01Z"));
await mock(page);
const stage = page.locator(".review-stage");
await expect(page.locator("#reviewSlider button")).toHaveCount(0);
const font = await page
.locator("body")
.evaluate((element) => getComputedStyle(element).fontFamily);
await expect(active(page).locator("h3")).toHaveCSS("font-family", font);
await expect(active(page).locator("blockquote")).toHaveCSS(
"font-family",
font,
);
await expect(active(page).locator(".stars")).toHaveAttribute(
"aria-label",
"4 von 5 Sternen",
);
await page.clock.runFor(9999);
await expect(active(page)).toContainText(reviews[0].title);
await page.clock.runFor(1);
await expect(active(page)).toContainText(reviews[1].title);
await stage.scrollIntoViewIfNeeded();
const box = await stage.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + 60);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 - 100, box.y + 60, {
steps: 6,
});
await expect(stage).toHaveClass(/is-dragging/);
await expect(active(page)).toHaveCSS(
"transform",
"matrix(1, 0, 0, 1, -100, 0)",
);
await page.mouse.up();
await expect(active(page)).toContainText(reviews[2].title);
await expect(stage).toBeFocused();
await page.clock.runFor(9999);
await expect(active(page)).toContainText(reviews[2].title);
await page.clock.runFor(1);
await expect(active(page)).toContainText(reviews[0].title);
await page.keyboard.press("ArrowLeft");
await expect(active(page)).toContainText(reviews[2].title);
await page.keyboard.press("Home");
await expect(active(page)).toContainText(reviews[0].title);
await page.keyboard.press("End");
await expect(active(page)).toContainText(reviews[2].title);
await page.keyboard.press("ArrowRight");
await expect(active(page)).toContainText(reviews[0].title);
await page.clock.runFor(10_000);
await expect(active(page)).toContainText(reviews[1].title);
});
test("Kurzes Ziehen springt zurück; Drag nach rechts zeigt vorherige Rezension", async ({
page,
}) => {
await mock(page);
const stage = page.locator(".review-stage");
await stage.scrollIntoViewIfNeeded();
const box = await stage.boundingBox();
for (const distance of [20, 100]) {
await page.mouse.move(box.x + box.width / 2, box.y + 60);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + distance, box.y + 60, {
steps: 5,
});
await page.mouse.up();
await expect(active(page)).toContainText(
reviews[distance === 20 ? 0 : 2].title,
);
await expect(stage).not.toHaveClass(/is-dragging/);
}
});
test("Leere Liste und einzelne Rezension brauchen keine Slider-Steuerung", async ({
page,
}) => {
await mock(page, []);
await expect(page.locator("#reviewSlider button")).toHaveCount(0);
await page.unroute("**/api/reviews");
await mock(page, [reviews[0]]);
await expect(page.locator("#reviewSlider button")).toHaveCount(0);
});
test("Mobile Anzeige bleibt stabil; HTML im Rezensionstext wird nicht ausgeführt", async ({
page,
}) => {
await page.setViewportSize({ width: 375, height: 850 });
await page.emulateMedia({ reducedMotion: "reduce" });
const rows = [
reviews[0],
{
...reviews[1],
review:
'<img src=x onerror="window.reviewInjected=true">' +
"x".repeat(400),
},
];
await mock(page, rows);
const before = await page.locator(".review-stage").boundingBox();
await page.locator(".review-stage").focus();
await page.keyboard.press("ArrowRight");
const after = await page.locator(".review-stage").boundingBox();
expect(after.height).toBe(before.height);
await expect(active(page)).toHaveCSS("transition-duration", "0s");
await expect(active(page)).toContainText("<img src=x");
await expect(active(page).locator("img")).toHaveCount(0);
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
});
test("Neue Rezension erscheint erst nach erfolgreicher Speicherung und bleibt nach Neuladen", async ({
page,
request,
}) => {
const email = `slider-${randomUUID()}@example.test`;
expect(
(
await request.post("/api/bookings", {
headers: { "Idempotency-Key": randomUUID() },
data: {
packageId: "ernaehrung-starter",
name: "Slider Test",
email,
consent: true,
},
})
).status(),
).toBe(201);
await page.goto("/index.html");
const title = `Neue Rezension ${randomUUID().slice(0, 8)}`;
const form = page.locator("#reviewForm");
await form.locator('[name="email"]').fill("unbekannt@example.test");
await form.locator('[name="title"]').fill(title);
await form
.locator('[name="review"]')
.fill("Eine neue und hilfreiche Erfahrung mit der Beratung.");
await form.locator('label[for="star4"]').click();
await form.locator('[name="consent"]').check();
await form.locator('[type="submit"]').click();
await expect(page.locator("#formStatus")).toContainText(
"keine Buchung gefunden",
);
await expect(page.locator("#reviewSlider")).not.toContainText(title);
await form.locator('[name="email"]').fill(email);
await form.locator('[type="submit"]').click();
await expect(page.locator("#formStatus")).toContainText("gespeichert");
await expect(active(page)).toContainText(title);
await expect(page.locator("#reviewSlider")).not.toContainText(email);
await page.reload();
await expect(page.locator("#reviewSlider")).toContainText(title);
});
test("Polling nimmt neue Backend-Rezensionen auf; Lesefehler erhalten vorhandene Karten", async ({
page,
}) => {
await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") });
await page.clock.pauseAt(new Date("2026-01-01T00:00:01Z"));
let rows = [reviews[0]];
let fail = false;
await page.route("**/api/reviews", (route) =>
route.fulfill(
fail ? { status: 503, json: {} } : { json: { reviews: rows } },
),
);
await page.goto("/index.html");
await expect(active(page)).toContainText(reviews[0].title);
rows = reviews;
await page.clock.runFor(10_000);
await expect(page.locator("#reviewSlider .review-card")).toHaveCount(3);
fail = true;
await page.clock.runFor(10_000);
await expect(page.locator(".review-feedback")).toContainText(
"nicht aktualisiert",
);
await expect(page.locator("#reviewSlider .review-card")).toHaveCount(3);
});

View File

@ -0,0 +1,51 @@
import { test, expect } from "@playwright/test";
import { randomUUID } from "node:crypto";
test("Rezension prüft Buchung, behält Fehler-Eingaben und bestätigt Speicherung", async ({
page,
request,
}) => {
const email = `review-${randomUUID()}@example.test`;
const booking = await request.post("/api/bookings", {
headers: { "Idempotency-Key": randomUUID() },
data: {
consent: true,
packageId: "ernaehrung-starter",
name: "Testkunde",
email,
},
});
expect(booking.status()).toBe(201);
await page.goto("/index.html#bewertung");
const form = page.locator("#reviewForm");
await form.locator('[name="email"]').fill("unbekannt@example.test");
await form.locator('[name="title"]').fill("Sehr hilfreiche Beratung");
await form
.locator('[name="review"]')
.fill("Die Beratung hilft mir im Alltag und beim Training.");
await form.locator('label[for="star4"]').click();
await form.locator('[name="consent"]').check();
await form
.getByRole("button", { name: "Bewertung veröffentlichen" })
.click();
await expect(page.locator("#formStatus")).toContainText(
"keine Buchung gefunden",
);
await expect(form.locator('[name="title"]')).toHaveValue(
"Sehr hilfreiche Beratung",
);
await form.locator('[name="email"]').fill(email);
const saved = page.waitForResponse(
(response) =>
response.url().endsWith("/api/reviews") &&
response.status() === 201,
);
await form
.getByRole("button", { name: "Bewertung veröffentlichen" })
.click();
expect(await (await saved).json()).toEqual({ saved: true });
await expect(page.locator("#formStatus")).toHaveText(
"Danke! Deine Bewertung wurde gespeichert.",
);
await expect(form.locator('[name="email"]')).toHaveValue("");
});

View File

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

View File

@ -4,6 +4,7 @@ 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 { createContactFormStorage } from "../server/services/contact-form-storage.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";
@ -18,9 +19,21 @@ async function fixture(t, options = {}) {
name: "Testkunde", name: "Testkunde",
}, },
]); ]);
const requests = createContactFormStorage(
join(dir, "advisor-contacts.csv"),
[
"createdAt",
"email",
"advisorId",
"reason",
"simulationAccepted",
"consent",
],
);
const sent = []; const sent = [];
const app = createApp({ const app = createApp({
storage, storage,
advisorContactStorage: requests,
sendConfirmation: async () => {}, sendConfirmation: async () => {},
sendContact: async (data) => sent.push(data), sendContact: async (data) => sent.push(data),
...options, ...options,
@ -40,9 +53,10 @@ async function fixture(t, options = {}) {
}); });
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, requests, base };
} }
const input = { const input = {
consent: true,
email: "kunde@example.test", email: "kunde@example.test",
advisorId: "relindis-agethen", advisorId: "relindis-agethen",
reason: "coaching", reason: "coaching",
@ -74,11 +88,19 @@ test("CSV-Abgleich normalisiert E-Mail; Versand erhält Berater und festen Redir
assert.equal(second.status, 201); assert.equal(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);
const records = await f.requests.readAll();
assert.equal(records.length, 2);
assert.equal(records[0].consent, true);
assert.equal(records[0].advisorId, input.advisorId);
}); });
test("Ungültige und unbekannte Kunden, manipulierte Berater, Gründe und fehlende Zustimmung senden nichts", async (t) => { 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 [
...[false, "true", undefined].map((consent) => [
{ ...input, consent },
400,
]),
[null, 400], [null, 400],
[{ ...input, email: "bad" }, 400], [{ ...input, email: "bad" }, 400],
[{ ...input, email: "unknown@example.test" }, 403], [{ ...input, email: "unknown@example.test" }, 403],
@ -89,6 +111,7 @@ test("Ungültige und unbekannte Kunden, manipulierte Berater, Gründe und fehlen
]) ])
assert.equal((await f.post("/api/contact", data)).status, status); assert.equal((await f.post("/api/contact", data)).status, status);
assert.equal(f.sent.length, 0); assert.equal(f.sent.length, 0);
assert.deepEqual(await f.requests.readAll(), []);
}); });
test("Absenden prüft CSV erneut; Speicherfehler bleiben geschlossen", async (t) => { test("Absenden prüft CSV erneut; Speicherfehler bleiben geschlossen", async (t) => {
@ -187,3 +210,15 @@ test("Anzeigename bevorzugt Spitzname, sonst Vorname, ohne Nachnamen", async ()
"Maren", "Maren",
); );
}); });
test("Berateranfrage wird bei CSV-Schreibfehler nicht versendet", async (t) => {
const f = await fixture(t, {
advisorContactStorage: {
append: async () => {
throw new Error("Disk full");
},
},
});
assert.equal((await f.post("/api/contact", input)).status, 503);
assert.equal(f.sent.length, 0);
});

View File

@ -4,16 +4,34 @@ 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 { createReviewStorage } from "../../server/services/review-storage.js";
import { createContactFormStorage } from "../../server/services/contact-form-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")),
reviewStorage: createReviewStorage(join(directory, "reviews.csv")),
advisorContactStorage: createContactFormStorage(
join(directory, "advisor-contacts.csv"),
[
"createdAt",
"email",
"advisorId",
"reason",
"simulationAccepted",
"consent",
],
),
contactFormStorage: createContactFormStorage(
join(directory, "contact-requests.csv"),
),
sendContactForm: async () => {},
sendConfirmation: async () => {}, sendConfirmation: async () => {},
sendContact: async () => {}, sendContact: async () => {},
}); });
server.listen(3000, "127.0.0.1"); server.listen(Number(process.env.TEST_API_PORT || 3000), "127.0.0.1");
async function stop() { async function stop() {
server.closeAllConnections(); server.closeAllConnections();
await new Promise((resolve) => server.close(resolve)); await new Promise((resolve) => server.close(resolve));

View File

@ -0,0 +1,251 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createApp } from "../server/index.js";
import { createCsvStorage } from "../server/services/csv-storage.js";
import { createReviewStorage } from "../server/services/review-storage.js";
const input = {
email: "kunde@example.test",
title: 'Sehr gut, "hilfreich"',
review: 'Gute Beratung, viele Tipps.\nAuch für den "Alltag".',
rating: 5,
consent: true,
};
async function setup(t, options = {}) {
const directory = await mkdtemp(join(tmpdir(), "trihub-reviews-"));
const bookingFile = join(directory, "bookings.csv");
const file = join(directory, "reviews.csv");
const storage = createCsvStorage(bookingFile);
const reviews = createReviewStorage(file);
await storage.writeAll([{ email: input.email }]);
const app = createApp({ storage, reviewStorage: reviews, ...options });
await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve));
t.after(async () => {
app.closeAllConnections();
await new Promise((resolve) => app.close(resolve));
await rm(directory, { recursive: true, force: true });
});
const url = `http://127.0.0.1:${app.address().port}`;
const post = (body = input) =>
fetch(`${url}/api/reviews`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return { post, reviews, file, bookingFile, url };
}
test("Kundenbewertung wird dauerhaft als CSV gespeichert; Buchungen bleiben unverändert", async (t) => {
const { post, file, bookingFile } = await setup(t);
const before = await readFile(bookingFile, "utf8");
const response = await post({ ...input, email: " KUNDE@EXAMPLE.TEST " });
assert.equal(response.status, 201);
assert.deepEqual(await response.json(), { saved: true });
const rows = await createReviewStorage(file).readAll();
assert.equal(rows.length, 1);
assert.deepEqual(rows[0], {
createdAt: rows[0].createdAt,
email: input.email,
title: input.title,
review: input.review,
rating: "5",
consent: "true",
});
assert.ok(!Number.isNaN(Date.parse(rows[0].createdAt)));
assert.equal(await readFile(bookingFile, "utf8"), before);
});
test("Bewertung funktioniert mit dem Buchungs-CSV-Format ohne Telefonnummer", async (t) => {
const { post, bookingFile, reviews } = await setup(t);
const header =
"bookingId,createdAt,packageId,packageName,name,email,emailStatus,idempotencyKey,requestHash,bookedPackage,customer,acceptedTerms,consent";
const booking = [
"TH-000001",
"2026-09-25T19:22:37.801Z",
"ernaehrung-starter",
"Paket",
"Kunde",
input.email,
"accepted",
"idempotency-key",
"request-hash",
"",
"",
"true",
"true",
].join(",");
await writeFile(bookingFile, `${header}\r\n${booking}\r\n`);
const response = await post();
assert.equal(response.status, 201);
assert.deepEqual(await response.json(), { saved: true });
assert.equal((await reviews.readAll()).length, 1);
});
test("Unbekannte E-Mail, ungültige Felder und fehlende Zustimmung speichern nichts", async (t) => {
const { post, reviews } = await setup(t);
assert.equal(
(await post({ ...input, email: "fremd@example.test" })).status,
403,
);
for (const body of [
null,
[],
{},
...["", "ungueltig", 123].map((email) => ({ ...input, email })),
...["", "a".repeat(9), "a".repeat(51), " ".repeat(10), 123].map(
(title) => ({ ...input, title }),
),
...["", "a".repeat(9), "a".repeat(501), " ".repeat(10), 123].map(
(review) => ({ ...input, review }),
),
...[0, 6, 1.5, "5", null].map((rating) => ({ ...input, rating })),
...[false, "true", undefined].map((consent) => ({ ...input, consent })),
]) {
assert.equal((await post(body)).status, 400, JSON.stringify(body));
}
assert.deepEqual(await reviews.readAll(), []);
});
test("Grenzwerte und alle Sterne sind erlaubt; paralleles Speichern verliert keine Bewertung", async (t) => {
const { post, reviews } = await setup(t);
const responses = await Promise.all(
[1, 2, 3, 4, 5].map((rating) =>
post({
...input,
rating,
title: "t".repeat(rating === 1 ? 10 : 50),
review: "r".repeat(rating === 1 ? 10 : 500),
}),
),
);
assert.ok(responses.every((response) => response.status === 201));
const rows = await reviews.readAll();
assert.equal(rows.length, 5);
assert.deepEqual(
rows.map((row) => Number(row.rating)).sort(),
[1, 2, 3, 4, 5],
);
});
test("Review-API weist falschen Inhaltstyp, ungültiges JSON und zu große Bodies zurück", async (t) => {
const { url, reviews } = await setup(t);
const send = (body, contentType) =>
fetch(`${url}/api/reviews`, {
method: "POST",
headers: { "Content-Type": contentType },
body,
});
const unsupported = await send(JSON.stringify(input), "text/plain");
assert.equal(unsupported.status, 415);
assert.equal(
(await unsupported.json()).error.code,
"UNSUPPORTED_CONTENT_TYPE",
);
const malformed = await send("{broken", "application/json");
assert.equal(malformed.status, 400);
assert.equal((await malformed.json()).error.code, "INVALID_JSON");
const oversized = await send(" ".repeat(8193), "application/json");
assert.equal(oversized.status, 413);
assert.equal((await oversized.json()).error.code, "BODY_TOO_LARGE");
assert.deepEqual(await reviews.readAll(), []);
});
test("Fehler beim Lesen der Buchungs-CSV speichern keine Bewertung", async (t) => {
let appended = false;
const { post, reviews } = await setup(t, {
storage: {
readAll: async () => {
throw new Error("Booking CSV unreadable");
},
},
reviewStorage: {
append: async () => {
appended = true;
},
readAll: () => reviews.readAll(),
},
});
const response = await post();
assert.equal(response.status, 503);
assert.equal((await response.json()).error.code, "REVIEW_NOT_SAVED");
assert.equal(appended, false);
assert.deepEqual(await reviews.readAll(), []);
});
test("Speicherfehler bestätigt keinen Erfolg; beschädigte CSV bleibt erhalten", async (t) => {
const { post, file } = await setup(t);
await writeFile(file, "falscher,header\n1,2\n");
assert.equal((await post()).status, 503);
assert.equal(await readFile(file, "utf8"), "falscher,header\n1,2\n");
const failing = await setup(t, {
reviewStorage: {
append: async () => {
throw new Error("Disk full");
},
},
});
assert.equal((await failing.post()).status, 503);
});
test("CSV maskiert Tabellenformeln und bleibt über HTTP privat", async (t) => {
const { post, reviews, file, url } = await setup(t);
const title = "=SUM(1,2,3)";
assert.equal((await post({ ...input, title })).status, 201);
assert.equal((await reviews.readAll())[0].title, title);
assert.ok((await readFile(file, "utf8")).includes("'=SUM"));
assert.equal(
(await fetch(`${url}/api/reviews`, { method: "DELETE" })).status,
405,
);
assert.equal(
(await fetch(`${url}/src/server/data/reviews.csv`)).status,
404,
);
});
test("Öffentliche Rezensionen kommen aus CSV, ohne E-Mail oder Zustimmung", async (t) => {
const { post, url, reviews } = await setup(t);
assert.deepEqual(await (await fetch(`${url}/api/reviews`)).json(), {
reviews: [],
});
await reviews.append({ ...input, consent: false, createdAt: "2026-01-01" });
assert.equal((await post()).status, 201);
const response = await fetch(`${url}/api/reviews`);
assert.equal(response.status, 200);
assert.equal(response.headers.get("cache-control"), "no-store");
const body = await response.json();
assert.equal(body.reviews.length, 1);
const [review] = body.reviews;
assert.deepEqual(Object.keys(review).sort(), [
"createdAt",
"rating",
"review",
"title",
]);
assert.equal(review.title, input.title);
assert.equal(review.review, input.review);
assert.equal(review.rating, 5);
assert.ok(!JSON.stringify(body).includes(input.email));
});
test("Lesefehler bei Rezensionen liefern einen kontrollierten Fehler", async (t) => {
const { url } = await setup(t, {
reviewStorage: {
readAll: async () => {
throw new Error("Disk error");
},
},
});
const response = await fetch(`${url}/api/reviews`);
assert.equal(response.status, 503);
assert.equal((await response.json()).error.code, "REVIEWS_UNAVAILABLE");
});