Merge branch 'dev' into feature/FAQ

feature/FAQ
Niels Yannick Tietze 2026-09-30 14:08:19 +00:00
commit ea47960393
43 changed files with 4094 additions and 2884 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Berater kontaktieren – Tri-Hub Testseite</title>
<link rel="stylesheet" href="/src/styles/footer.css" />
</head>
<body>
<main class="contact-test">
@ -22,5 +23,7 @@
type="module"
src="/src/features/contact/contact-entry.js"
></script>
<div id="footer-placeholder"></div>
<script type="module" src="/src/components/footer/footer.js"></script>
</body>
</html>

View File

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="/src/styles/navigation.css" />
<link rel="stylesheet" href="/src/styles/footer.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
@ -163,10 +164,26 @@
}
.hero {
position: relative;
isolation: isolate;
padding: 92px 0 82px;
border-bottom: 1px solid var(--line);
}
.hero::before {
content: "";
position: absolute;
inset: 0 0 56px;
z-index: -1;
background:
linear-gradient(
180deg,
rgba(8, 15, 21, 0.72),
rgba(8, 15, 21, 0.84)
),
url("/images/startseiter_hintergrund.jpeg") 38% center / cover no-repeat;
}
.hero-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(330px, 0.8fr);
@ -240,6 +257,7 @@
.hero-card {
position: relative;
transform: translateX(80px);
min-height: 410px;
padding: 38px;
display: flex;
@ -256,8 +274,8 @@
),
linear-gradient(
145deg,
rgba(255, 255, 255, 0.075),
rgba(255, 255, 255, 0.025)
rgba(13, 19, 31, 0.78),
rgba(13, 19, 31, 0.84)
);
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28);
}
@ -274,6 +292,7 @@
box-shadow:
0 0 0 34px rgba(83, 213, 205, 0.035),
0 0 0 68px rgba(83, 213, 205, 0.02);
pointer-events: none;
}
.hero-card-mark {
@ -296,6 +315,11 @@
color: var(--muted);
}
.hero-card > div {
position: relative;
z-index: 1;
}
.section {
padding: 96px 0;
}
@ -375,6 +399,26 @@
font-size: clamp(2.5rem, 5vw, 4.7rem);
}
.knowledge-image-frame {
width: 100%;
transform: translateX(-18px);
margin-top: 28px;
padding: 9px;
overflow: hidden;
border: 1px solid rgba(248, 250, 252, 0.18);
border-radius: 22px;
background: linear-gradient(145deg, #273135, #151d22);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.3);
}
.knowledge-image-frame img {
display: block;
width: 100%;
aspect-ratio: 2.58 / 1;
border-radius: 14px;
object-fit: cover;
}
.knowledge-copy {
color: var(--muted);
line-height: 1.85;
@ -547,6 +591,11 @@
font-size: 0.78rem;
}
.consent a {
font-weight: 700;
text-decoration: underline;
}
.consent input {
margin-top: 4px;
accent-color: var(--brand);
@ -692,6 +741,7 @@
.hero-card {
min-height: 330px;
transform: none;
}
}
@ -879,6 +929,13 @@
<h2 style="font-family: &quot;Inter&quot;, sans-serif">
Wissen,<br />das dich<br />weiterbringt.
</h2>
<div class="knowledge-image-frame">
<img
src="/images/exttraBild.jpeg"
alt="Ergänzendes Bild zur Ernährungsberatung"
loading="lazy"
/>
</div>
</div>
<div>
@ -994,10 +1051,9 @@
</article>
<p class="review-note">
Hinweis: Die Bewertungen in dieser HTML-Demo werden
nicht dauerhaft gespeichert. Für den Live-Betrieb
sollte das Formular an das bestehende Backend oder
CMS von Tri-hub angebunden werden.
Nur Kunden mit einer vorhandenen Buchung können eine
Bewertung abgeben. Deine E-Mail-Adresse wird nicht
öffentlich angezeigt.
</p>
</div>
</div>
@ -1027,19 +1083,36 @@
<form class="review-form" id="reviewForm">
<div class="field" style="margin-top: 0">
<label for="name">Dein Name</label>
<label for="reviewEmail"
>E-Mail deiner Buchung*</label
>
<input
id="name"
name="name"
type="text"
autocomplete="name"
placeholder="z. B. Thomas M."
id="reviewEmail"
name="email"
type="email"
autocomplete="email"
maxlength="254"
placeholder="z. B. thomas@example.de"
required
/>
</div>
<div class="field">
<label>Deine Bewertung</label>
<label for="reviewTitle"
>Titel (10–50 Zeichen)*</label
>
<input
id="reviewTitle"
name="title"
type="text"
minlength="10"
maxlength="50"
required
/>
</div>
<div class="field">
<label>Deine Bewertung*</label>
<div
class="rating-input"
aria-label="Sternebewertung"
@ -1088,10 +1161,14 @@
</div>
<div class="field">
<label for="review">Deine Erfahrung</label>
<label for="review"
>Deine Erfahrung (10–500 Zeichen)*</label
>
<textarea
id="review"
name="review"
minlength="10"
maxlength="500"
placeholder="Was hat dir an der Ernährungsberatung geholfen?"
required
></textarea>
@ -1100,12 +1177,22 @@
<label class="consent">
<input type="checkbox" name="consent" required />
<span>
Ich stimme zu, dass meine Bewertung mit meinem
angegebenen Namen auf dieser Seite
veröffentlicht werden darf.
Ich akzeptiere die
<a
href="https://www.tri-hub.de/datenschutz"
target="_blank"
rel="noopener noreferrer"
>Datenschutzbestimmungen</a
>*: Meine E-Mail-Adresse wird zum Abgleich mit
meiner Buchung und zusammen mit meiner Bewertung
gespeichert. Titel, Bewertungstext und Sterne
dürfen veröffentlicht werden; meine
E-Mail-Adresse bleibt nicht öffentlich.
</span>
</label>
<small>*Pflichtfelder</small>
<button class="button button-primary" type="submit">
Bewertung veröffentlichen
</button>
@ -1217,6 +1304,24 @@
<small>*Pflichtfelder</small>
</div>
<label class="consent"
><input
type="checkbox"
name="consent"
required
/><span
>Ich akzeptiere die
<a
href="https://www.tri-hub.de/datenschutz"
target="_blank"
rel="noopener noreferrer"
>Datenschutzbestimmungen</a
>
(Pflichtfeld).</span
></label
>
<p data-error-for="consent" role="alert"></p>
<button class="button button-primary" type="submit">
Nachricht senden
</button>
@ -1232,42 +1337,20 @@
</section>
</main>
<footer>
<div class="container">
<div>
<strong>tri-hub</strong> · Triathlon als sportliches Zuhause
ab 50.
</div>
<div>Ernährung · Wissen · Erfahrungen</div>
</div>
</footer>
<div id="footer-placeholder"></div>
<script
type="module"
src="/src/components/navigationsbar/navigation.js"
></script>
<script type="module" src="/src/components/footer/footer.js"></script>
<script
type="module"
src="/src/features/contact/contact-form.js"
></script>
<script>
const form = document.getElementById("reviewForm");
const status = document.getElementById("formStatus");
form.addEventListener("submit", function (event) {
event.preventDefault();
const name = form.elements.name.value.trim();
const rating = form.elements.rating.value;
const review = form.elements.review.value.trim();
if (!name || !rating || !review) return;
status.textContent =
"Danke für deine Bewertung. In dieser Demo wurde sie noch nicht dauerhaft gespeichert.";
form.reset();
});
</script>
<script
type="module"
src="/src/features/reviews/review-form.js"
></script>
</body>
</html>

View File

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

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="https://tri-hub.de/">Cookie-Einstellungen öffnen</a>
</nav>
</section>
</div>
</footer>

View File

@ -0,0 +1,15 @@
import footerHtml from "./footer.html?raw";
/** Fügt das Footer-Fragment am Platzhalter ein. */
export function initFooter(placeholderSelector = "#footer-placeholder") {
const placeholder = document.querySelector(placeholderSelector);
if (!placeholder) {
console.warn("Footer-Platzhalter nicht gefunden:", placeholderSelector);
return;
}
placeholder.outerHTML = footerHtml;
}
document.addEventListener("DOMContentLoaded", () => initFooter());

View File

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

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

View File

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

View File

@ -30,6 +30,7 @@ export function openContactDialog({
<select id="contact-reason" name="reason" required><option value="">Bitte auswählen</option></select>
</div>
<label class="contact-consent"><input name="simulationAccepted" type="checkbox" required><span></span></label>
<label class="contact-consent"><input name="consent" type="checkbox" required><span>Ich akzeptiere die <a href="https://www.tri-hub.de/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> (Pflichtfeld).</span></label>
<p class="contact-status" role="status" aria-live="polite"></p>
<button class="contact-submit" type="submit" disabled>Anfrage senden & Termin wählen</button>
</form>`;
@ -57,6 +58,7 @@ export function openContactDialog({
close.disabled = busy;
email.disabled = busy || sent;
reason.disabled = busy || sent;
form.elements.consent.disabled = busy || sent;
form.elements.simulationAccepted.disabled = busy || sent;
}
async function post(path, input) {
@ -82,6 +84,7 @@ export function openContactDialog({
email: email.value,
advisorId,
reason: reason.value,
consent: form.elements.consent.checked,
simulationAccepted: form.elements.simulationAccepted.checked,
};
busy = true;

View File

@ -44,7 +44,10 @@ form?.addEventListener("submit", async (event) => {
const response = await fetch("/api/nutrition-contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.fromEntries(new FormData(form))),
body: JSON.stringify({
...Object.fromEntries(new FormData(form)),
consent: form.elements.consent.checked,
}),
});
const result = await response.json();
if (!response.ok) {

View File

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

View File

@ -0,0 +1,48 @@
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();
} catch {
status.textContent =
"Keine Bestätigung vom Server erhalten. Bitte versuche es später erneut.";
} finally {
button.disabled = false;
}
});

View File

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

1 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

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

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
4
5
6
7
8
9
10
11
12
13

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 { createContactEmailService } from "./services/contact-email.js";
import { handleContact } from "./routes/contact.js";
@ -21,6 +23,9 @@ export function createApp({
storage = createCsvStorage(
fileURLToPath(new URL("./data/bookings.csv", import.meta.url)),
),
reviewStorage = createReviewStorage(
fileURLToPath(new URL("./data/reviews.csv", import.meta.url)),
),
details,
sendConfirmation,
sendContact,
@ -28,6 +33,17 @@ export function createApp({
fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)),
),
sendContactForm,
advisorContactStorage = createContactFormStorage(
fileURLToPath(new URL("./data/advisor-contacts.csv", import.meta.url)),
[
"createdAt",
"email",
"advisorId",
"reason",
"simulationAccepted",
"consent",
],
),
} = {}) {
const ids = new Set();
for (const entry of catalog) {
@ -56,6 +72,7 @@ export function createApp({
});
const contact = createContactService({
storage,
requestStorage: advisorContactStorage,
sendContact: sendContact ?? createContactEmailService(),
});
// Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand.
@ -65,6 +82,9 @@ export function createApp({
});
return createServer(async (request, response) => {
const path = new URL(request.url, "http://localhost").pathname;
if (path === "/api/reviews") {
return handleReview(request, response, storage, reviewStorage);
}
// Leitet die Ernährungsseite an ihren eigenen Kontaktformular-Endpunkt.
if (path === "/api/nutrition-contact") {
return handleContactForm(request, response, contactForm);

View File

@ -0,0 +1,96 @@
import { BookingError, emailPattern } from "../services/booking-service.js";
import { json, readJson } from "./bookings.js";
export async function handleReview(request, response, bookings, reviews) {
if (request.method !== "POST") {
response.setHeader("Allow", "POST");
return json(response, 405, {
error: { message: "Bitte POST verwenden." },
});
}
try {
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: "REVIEW_NOT_SAVED",
message:
"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 result = {};
if (input.consent !== true)
fields.consent = "Bitte die Datenschutzbestimmungen akzeptieren.";
const result = { consent: true };
for (const [field, max, message] of [
["packageId", 80, "Bitte ein gültiges Paket auswählen."],
["name", 120, "Bitte einen Namen mit höchstens 120 Zeichen eingeben."],
@ -315,6 +317,7 @@ export function createBookingService({
bookedPackage,
customer: data.customer ?? null,
acceptedTerms: data.acceptedTerms ?? null,
consent: data.consent,
name: data.name,
email: data.email,
phone: data.customer?.phone ?? data.phone ?? "",

View File

@ -13,7 +13,9 @@ function normalizeInput(input) {
// Legt die zulässige Höchstlänge für jedes Formularfeld fest.
const limits = { name: 120, email: 254, subject: 160, message: 5000 };
const fields = {};
const data = {};
const data = { consent: true };
if (input.consent !== true)
fields.consent = "Bitte die Datenschutzbestimmungen akzeptieren.";
for (const [field, max] of Object.entries(limits)) {
const value = input[field];
if (typeof value !== "string") {

View File

@ -5,7 +5,14 @@ import { parse } from "csv-parse/sync";
import { stringify } from "csv-stringify/sync";
// Diese Spalten bilden den festen Aufbau der Kontaktanfragen-CSV.
const columns = ["createdAt", "name", "email", "subject", "message"];
const defaultColumns = [
"createdAt",
"name",
"email",
"subject",
"message",
"consent",
];
// Schützt CSV-Zellen vor Tabellenformeln, wenn die Datei geöffnet wird.
function protect(value) {
@ -19,7 +26,7 @@ function restore(value) {
}
// Erstellt den CSV-Speicher und hält Schreibvorgänge dieser Instanz in einer Warteschlange.
export function createContactFormStorage(filePath) {
export function createContactFormStorage(filePath, columns = defaultColumns) {
let queue = Promise.resolve();
// Liest alle Anfragen ein und prüft, ob die Datei den erwarteten CSV-Kopf besitzt.
@ -35,7 +42,13 @@ export function createContactFormStorage(filePath) {
return parse(content, {
bom: true,
columns(header) {
if (header.join(",") !== columns.join(",")) {
if (
header.join(",") !== columns.join(",") &&
header.join(",") !==
columns
.filter((column) => column !== "consent")
.join(",")
) {
throw new Error("Unbekanntes Kontakt-CSV-Format");
}
return header;
@ -45,7 +58,7 @@ export function createContactFormStorage(filePath) {
Object.fromEntries(
Object.entries(row).map(([key, value]) => [
key,
restore(value),
key === "consent" ? value === "true" : restore(value),
]),
),
);

View File

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

View File

@ -18,8 +18,9 @@ const legacyColumns = [
const orderColumns = [...legacyColumns, "bookedPackage"];
const customerColumns = [...orderColumns, "customer", "acceptedTerms"];
export const columns = [...customerColumns, "phone"];
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
// ebenfalls maskiert, damit Lesen/Schreiben die Originalwerte exakt erhält.
@ -49,6 +50,7 @@ export function createCsvStorage(filePath) {
if (
header.join(",") !== columns.join(",") &&
header.join(",") !== customerColumns.join(",") &&
header.join(",") !== phoneColumns.join(",") &&
header.join(",") !== legacyColumns.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",
"advisorId",
"reason",
"simulationAccepted"
"simulationAccepted",
"consent"
],
"properties": {
"email": {
@ -387,6 +388,10 @@
"type": "string",
"example": "relindis-agethen",
"description": "ID aus src/shared/berater-daten.json. Name und E-Mail werden serverseitig ausschließlich dort nachgeschlagen."
},
"consent": {
"type": "boolean",
"enum": [true]
}
}
}
@ -809,7 +814,7 @@
},
"BookingCreateRequest": {
"type": "object",
"required": ["packageId"],
"required": ["packageId", "consent"],
"properties": {
"packageId": {
"type": "string",
@ -875,6 +880,11 @@
"type": "boolean",
"example": true,
"enum": [true]
},
"consent": {
"type": "boolean",
"enum": [true],
"description": "Zustimmung zu den Datenschutzbestimmungen ist erforderlich."
}
},
"anyOf": [

View File

@ -0,0 +1,229 @@
.trihub-footer,
.trihub-footer * {
box-sizing: border-box;
}
.trihub-footer {
padding: 4.75rem 0;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0 0 1.8rem 1.8rem;
background: #06171b;
color: #fff;
font-family: "DM Sans", sans-serif;
}
.trihub-footer-inner {
width: min(100% - 4rem, 1648px);
margin: 0 auto;
display: grid;
grid-template-columns: 1.35fr 1fr 1fr;
gap: 3.5rem;
align-items: stretch;
}
.trihub-footer-card {
min-width: 0;
min-height: 34.75rem;
padding: 2rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1.8rem;
background: rgba(255, 255, 255, 0.05);
}
.trihub-brand {
display: flex;
align-items: center;
gap: 1.4rem;
color: inherit;
text-decoration: none;
}
.trihub-logo-box {
width: 100px;
height: 90px;
padding: 0.5rem 0.75rem;
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 1.1rem;
background: #fff;
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.18);
}
.trihub-logo-box img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.trihub-brand-label,
.trihub-brand-title {
display: block;
}
.trihub-brand-label {
margin: 0 0 0.3rem;
color: rgba(255, 255, 255, 0.46);
font-size: 0.68rem;
letter-spacing: 0.28em;
text-transform: uppercase;
}
.trihub-brand-title {
color: #fff;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
}
.trihub-description {
max-width: 36rem;
margin: 2.3rem 0 0;
color: rgba(255, 255, 255, 0.64);
font-size: 0.875rem;
line-height: 1.75rem;
}
.trihub-contact {
margin-top: 2.1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
color: rgba(255, 255, 255, 0.68);
font-size: 0.875rem;
}
.trihub-contact-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.trihub-contact-row p {
margin: 0;
}
.trihub-contact-row a {
color: inherit;
text-decoration: none;
}
.trihub-contact-row svg {
width: 1rem;
height: 1rem;
margin-top: 0.25rem;
flex-shrink: 0;
color: #ffc21f;
}
.trihub-socials {
margin-top: 2.1rem;
display: flex;
gap: 0.75rem;
}
.trihub-social-button {
width: 3.25rem;
height: 3.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 9999px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.7);
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.trihub-social-button svg {
width: 1.25rem;
height: 1.25rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.trihub-social-button:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.trihub-footer-heading {
margin: 0 0 1.7rem;
display: flex;
font-family: "DM Sans", sans-serif;
align-items: center;
gap: 0.75rem;
color: rgba(255, 255, 255, 0.46);
font-size: 0.8rem;
font-weight: 400;
letter-spacing: 0.28em;
text-transform: uppercase;
}
.trihub-footer-heading svg {
width: 1rem;
height: 1rem;
color: #00c7d5;
}
.trihub-footer-links {
display: flex;
flex-direction: column;
gap: 1rem;
color: rgba(255, 255, 255, 0.7);
font-size: 0.875rem;
}
.trihub-footer-links a {
color: inherit;
text-decoration: none;
transition: color 0.15s ease;
}
.trihub-footer-links a:hover {
color: #fff;
}
.trihub-footer-links .trihub-cookie-link {
margin: 0.7rem 0 0;
color: #00bfd2;
text-decoration: none;
}
.trihub-footer-links .trihub-cookie-link:hover {
color: #fff;
}
@media (max-width: 1000px) {
.trihub-footer-inner {
grid-template-columns: 1fr;
}
.trihub-footer-card {
min-height: auto;
}
}
@media (max-width: 640px) {
.trihub-footer {
padding: 2rem 0;
}
.trihub-footer-inner {
width: min(100% - 2rem, 1648px);
gap: 1rem;
}
.trihub-footer-card {
padding: 1.5rem;
}
.trihub-brand {
align-items: flex-start;
}
}

View File

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

View File

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

View File

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

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 booking = await request.post("/api/bookings", {
headers: { "Idempotency-Key": randomUUID() },
data: { packageId: "ernaehrung-starter", name: "Testkunde", email },
data: {
consent: true,
packageId: "ernaehrung-starter",
name: "Testkunde",
email,
},
});
expect(booking.ok()).toBeTruthy();
await open(page);
@ -30,7 +35,8 @@ test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter",
.getByLabel("Deine Buchungs-E-Mail")
.fill("unbekannt@example.test");
await page.getByLabel("Grund für den Kontakt").selectOption("nutrition");
await page.getByRole("checkbox").check();
await page.locator('[name="simulationAccepted"]').check();
await page.locator('[name="consent"]').check();
await submit.click();
await expect(page.getByRole("status")).toContainText("keine Buchung");
await expect(page.getByRole("dialog")).toBeVisible();
@ -76,7 +82,8 @@ test("Versandfehler zeigt Meldung und bleibt im Popup", async ({ page }) => {
await open(page);
await page.getByLabel("Deine Buchungs-E-Mail").fill("kunde@example.test");
await page.getByLabel("Grund für den Kontakt").selectOption("package");
await page.getByRole("checkbox").check();
await page.locator('[name="simulationAccepted"]').check();
await page.locator('[name="consent"]').check();
await page.getByRole("button", { name: "Anfrage senden" }).click();
await expect(page.getByRole("status")).toContainText(
"Mailpit nicht erreichbar",

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,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

@ -8,6 +8,7 @@ import { createContactFormEmailService } from "../server/services/contact-form-e
import { createContactFormStorage } from "../server/services/contact-form-storage.js";
const validInput = {
consent: true,
name: "Test Person",
email: "person@example.test",
subject: "Eine Frage",
@ -57,6 +58,7 @@ test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", asyn
assert.equal(f.sent.length, 1);
assert.deepEqual(
{
consent: f.sent[0].consent,
name: f.sent[0].name,
email: f.sent[0].email,
subject: f.sent[0].subject,
@ -72,6 +74,10 @@ test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", asyn
test("Pflichtfelder, E-Mail, Längen und Mindestlänge werden validiert", async (t) => {
const f = await fixture(t);
const invalidInputs = [
...[false, "true", undefined].map((consent) => ({
...validInput,
consent,
})),
{ ...validInput, name: " " },
{ ...validInput, name: "Test\nBcc: fremd" },
{ ...validInput, name: "x".repeat(121) },
@ -267,3 +273,17 @@ test("Mailservice meldet, wenn Mailpit einen Empfänger ablehnt", async () => {
error.status === 502 && error.code === "CONTACT_EMAIL_FAILED",
);
});
test("Alte Kontakt-CSV bleibt lesbar; neue Zustimmung wird separat gespeichert", async (t) => {
const f = await fixture(t);
await writeFile(
join(f.directory, "contact-requests.csv"),
"createdAt,name,email,subject,message\n2026-01-01,Alt,alt@example.test,Frage,Alte Nachricht\n",
);
assert.equal((await f.post()).status, 201);
const records = await f.storage.readAll();
assert.equal(records.length, 2);
assert.notEqual(records[0].consent, true);
assert.equal(records[0].message, "Alte Nachricht");
assert.equal(records[1].consent, true);
});

View File

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

View File

@ -4,16 +4,34 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { createApp } from "../../server/index.js";
import { createCsvStorage } from "../../server/services/csv-storage.js";
import { createReviewStorage } from "../../server/services/review-storage.js";
import { createContactFormStorage } from "../../server/services/contact-form-storage.js";
import { packages } from "../../shared/packages.js";
const directory = await mkdtemp(join(tmpdir(), "trihub-browser-"));
const server = createApp({
catalog: packages,
storage: createCsvStorage(join(directory, "bookings.csv")),
reviewStorage: createReviewStorage(join(directory, "reviews.csv")),
advisorContactStorage: createContactFormStorage(
join(directory, "advisor-contacts.csv"),
[
"createdAt",
"email",
"advisorId",
"reason",
"simulationAccepted",
"consent",
],
),
contactFormStorage: createContactFormStorage(
join(directory, "contact-requests.csv"),
),
sendContactForm: async () => {},
sendConfirmation: async () => {},
sendContact: async () => {},
});
server.listen(3000, "127.0.0.1");
server.listen(Number(process.env.TEST_API_PORT || 3000), "127.0.0.1");
async function stop() {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));

View File

@ -0,0 +1,134 @@
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("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("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`)).status, 405);
assert.equal(
(await fetch(`${url}/src/server/data/reviews.csv`)).status,
404,
);
});