Merge branch 'dev' into feature/bildliche_darsteullung

feature/bildliche_darsteullung
Majd Ibrahim 2026-09-30 13:42:00 +00:00
commit e7269a819a
36 changed files with 1233 additions and 119 deletions

View File

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

View File

@ -8,6 +8,7 @@
<link rel="stylesheet" href="/src/styles/navigation.css" /> <link rel="stylesheet" href="/src/styles/navigation.css" />
<link rel="stylesheet" href="/src/styles/packages.css" /> <link rel="stylesheet" href="/src/styles/packages.css" />
<link rel="stylesheet" href="/src/features/berater/berater.css" /> <link rel="stylesheet" href="/src/features/berater/berater.css" />
<link rel="stylesheet" href="/src/styles/footer.css" />
</head> </head>
<body class="packages-page advisor-page"> <body class="packages-page advisor-page">
<div id="navbar-placeholder"></div> <div id="navbar-placeholder"></div>
@ -27,5 +28,7 @@
</main> </main>
<script type="module" src="/src/components/navigationsbar/navigation.js"></script> <script type="module" src="/src/components/navigationsbar/navigation.js"></script>
<script type="module" src="/src/features/berater/berater-entry.js"></script> <script type="module" src="/src/features/berater/berater-entry.js"></script>
<div id="footer-placeholder"></div>
<script type="module" src="/src/components/footer/footer.js"></script>
</body> </body>
</html> </html>

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="stylesheet" href="/src/styles/navigation.css" /> <link rel="stylesheet" href="/src/styles/navigation.css" />
<link rel="stylesheet" href="/src/styles/footer.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
name="description" name="description"
@ -590,6 +591,11 @@
font-size: 0.78rem; font-size: 0.78rem;
} }
.consent a {
font-weight: 700;
text-decoration: underline;
}
.consent input { .consent input {
margin-top: 4px; margin-top: 4px;
accent-color: var(--brand); accent-color: var(--brand);
@ -1045,10 +1051,9 @@
</article> </article>
<p class="review-note"> <p class="review-note">
Hinweis: Die Bewertungen in dieser HTML-Demo werden Nur Kunden mit einer vorhandenen Buchung können eine
nicht dauerhaft gespeichert. Für den Live-Betrieb Bewertung abgeben. Deine E-Mail-Adresse wird nicht
sollte das Formular an das bestehende Backend oder öffentlich angezeigt.
CMS von Tri-hub angebunden werden.
</p> </p>
</div> </div>
</div> </div>
@ -1078,19 +1083,36 @@
<form class="review-form" id="reviewForm"> <form class="review-form" id="reviewForm">
<div class="field" style="margin-top: 0"> <div class="field" style="margin-top: 0">
<label for="name">Dein Name</label> <label for="reviewEmail"
>E-Mail deiner Buchung*</label
>
<input <input
id="name" id="reviewEmail"
name="name" name="email"
type="text" type="email"
autocomplete="name" autocomplete="email"
placeholder="z. B. Thomas M." maxlength="254"
placeholder="z. B. thomas@example.de"
required required
/> />
</div> </div>
<div class="field"> <div class="field">
<label>Deine Bewertung</label> <label for="reviewTitle"
>Titel (10–50 Zeichen)*</label
>
<input
id="reviewTitle"
name="title"
type="text"
minlength="10"
maxlength="50"
required
/>
</div>
<div class="field">
<label>Deine Bewertung*</label>
<div <div
class="rating-input" class="rating-input"
aria-label="Sternebewertung" aria-label="Sternebewertung"
@ -1139,10 +1161,14 @@
</div> </div>
<div class="field"> <div class="field">
<label for="review">Deine Erfahrung</label> <label for="review"
>Deine Erfahrung (10–500 Zeichen)*</label
>
<textarea <textarea
id="review" id="review"
name="review" name="review"
minlength="10"
maxlength="500"
placeholder="Was hat dir an der Ernährungsberatung geholfen?" placeholder="Was hat dir an der Ernährungsberatung geholfen?"
required required
></textarea> ></textarea>
@ -1151,12 +1177,22 @@
<label class="consent"> <label class="consent">
<input type="checkbox" name="consent" required /> <input type="checkbox" name="consent" required />
<span> <span>
Ich stimme zu, dass meine Bewertung mit meinem Ich akzeptiere die
angegebenen Namen auf dieser Seite <a
veröffentlicht werden darf. href="https://www.tri-hub.de/datenschutz"
target="_blank"
rel="noopener noreferrer"
>Datenschutzbestimmungen</a
>*: Meine E-Mail-Adresse wird zum Abgleich mit
meiner Buchung und zusammen mit meiner Bewertung
gespeichert. Titel, Bewertungstext und Sterne
dürfen veröffentlicht werden; meine
E-Mail-Adresse bleibt nicht öffentlich.
</span> </span>
</label> </label>
<small>*Pflichtfelder</small>
<button class="button button-primary" type="submit"> <button class="button button-primary" type="submit">
Bewertung veröffentlichen Bewertung veröffentlichen
</button> </button>
@ -1268,6 +1304,24 @@
<small>*Pflichtfelder</small> <small>*Pflichtfelder</small>
</div> </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"> <button class="button button-primary" type="submit">
Nachricht senden Nachricht senden
</button> </button>
@ -1283,42 +1337,20 @@
</section> </section>
</main> </main>
<footer> <div id="footer-placeholder"></div>
<div class="container">
<div>
<strong>tri-hub</strong> · Triathlon als sportliches Zuhause
ab 50.
</div>
<div>Ernährung · Wissen · Erfahrungen</div>
</div>
</footer>
<script <script
type="module" type="module"
src="/src/components/navigationsbar/navigation.js" src="/src/components/navigationsbar/navigation.js"
></script> ></script>
<script type="module" src="/src/components/footer/footer.js"></script>
<script <script
type="module" type="module"
src="/src/features/contact/contact-form.js" src="/src/features/contact/contact-form.js"
></script> ></script>
<script> <script
const form = document.getElementById("reviewForm"); type="module"
const status = document.getElementById("formStatus"); src="/src/features/reviews/review-form.js"
></script>
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>
</body> </body>
</html> </html>

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -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, 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. ich möchte einen termin vereinbaren, können sie sich bei mir melden.
Mfg Mfg
Max Muster" Max Muster",true
2026-09-29T19:27:34.639Z,Test Test,test11@web.de,Erstgespräch Ernährung,"Guten Tag, 2026-09-29T19:27:34.639Z,Test Test,test11@web.de,Erstgespräch Ernährung,"Guten Tag,
können sie sich bei mir melden. können sie sich bei mir melden.
Mfg 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 { createContactService } from "./services/contact-service.js";
import { createContactEmailService } from "./services/contact-email.js"; import { createContactEmailService } from "./services/contact-email.js";
import { handleContact } from "./routes/contact.js"; import { handleContact } from "./routes/contact.js";
@ -21,6 +23,9 @@ export function createApp({
storage = createCsvStorage( storage = createCsvStorage(
fileURLToPath(new URL("./data/bookings.csv", import.meta.url)), fileURLToPath(new URL("./data/bookings.csv", import.meta.url)),
), ),
reviewStorage = createReviewStorage(
fileURLToPath(new URL("./data/reviews.csv", import.meta.url)),
),
details, details,
sendConfirmation, sendConfirmation,
sendContact, sendContact,
@ -28,6 +33,17 @@ export function createApp({
fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)), fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)),
), ),
sendContactForm, sendContactForm,
advisorContactStorage = createContactFormStorage(
fileURLToPath(new URL("./data/advisor-contacts.csv", import.meta.url)),
[
"createdAt",
"email",
"advisorId",
"reason",
"simulationAccepted",
"consent",
],
),
} = {}) { } = {}) {
const ids = new Set(); const ids = new Set();
for (const entry of catalog) { for (const entry of catalog) {
@ -56,6 +72,7 @@ export function createApp({
}); });
const contact = createContactService({ const contact = createContactService({
storage, storage,
requestStorage: advisorContactStorage,
sendContact: sendContact ?? createContactEmailService(), sendContact: sendContact ?? createContactEmailService(),
}); });
// Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand. // Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand.
@ -65,6 +82,9 @@ export function createApp({
}); });
return createServer(async (request, response) => { return createServer(async (request, response) => {
const path = new URL(request.url, "http://localhost").pathname; const path = new URL(request.url, "http://localhost").pathname;
if (path === "/api/reviews") {
return handleReview(request, response, storage, reviewStorage);
}
// Leitet die Ernährungsseite an ihren eigenen Kontaktformular-Endpunkt. // Leitet die Ernährungsseite an ihren eigenen Kontaktformular-Endpunkt.
if (path === "/api/nutrition-contact") { if (path === "/api/nutrition-contact") {
return handleContactForm(request, response, contactForm); return handleContactForm(request, response, contactForm);

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 fields = {};
const result = {}; if (input.consent !== true)
fields.consent = "Bitte die Datenschutzbestimmungen akzeptieren.";
const result = { consent: true };
for (const [field, max, message] of [ for (const [field, max, message] of [
["packageId", 80, "Bitte ein gültiges Paket auswählen."], ["packageId", 80, "Bitte ein gültiges Paket auswählen."],
["name", 120, "Bitte einen Namen mit höchstens 120 Zeichen eingeben."], ["name", 120, "Bitte einen Namen mit höchstens 120 Zeichen eingeben."],
@ -315,6 +317,7 @@ export function createBookingService({
bookedPackage, bookedPackage,
customer: data.customer ?? null, customer: data.customer ?? null,
acceptedTerms: data.acceptedTerms ?? null, acceptedTerms: data.acceptedTerms ?? null,
consent: data.consent,
name: data.name, name: data.name,
email: data.email, email: data.email,
phone: data.customer?.phone ?? data.phone ?? "", phone: data.customer?.phone ?? data.phone ?? "",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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"; import { createContactFormStorage } from "../server/services/contact-form-storage.js";
const validInput = { const validInput = {
consent: true,
name: "Test Person", name: "Test Person",
email: "person@example.test", email: "person@example.test",
subject: "Eine Frage", 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.equal(f.sent.length, 1);
assert.deepEqual( assert.deepEqual(
{ {
consent: f.sent[0].consent,
name: f.sent[0].name, name: f.sent[0].name,
email: f.sent[0].email, email: f.sent[0].email,
subject: f.sent[0].subject, subject: f.sent[0].subject,
@ -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) => { test("Pflichtfelder, E-Mail, Längen und Mindestlänge werden validiert", async (t) => {
const f = await fixture(t); const f = await fixture(t);
const invalidInputs = [ const invalidInputs = [
...[false, "true", undefined].map((consent) => ({
...validInput,
consent,
})),
{ ...validInput, name: " " }, { ...validInput, name: " " },
{ ...validInput, name: "Test\nBcc: fremd" }, { ...validInput, name: "Test\nBcc: fremd" },
{ ...validInput, name: "x".repeat(121) }, { ...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", error.status === 502 && error.code === "CONTACT_EMAIL_FAILED",
); );
}); });
test("Alte Kontakt-CSV bleibt lesbar; neue Zustimmung wird separat gespeichert", async (t) => {
const f = await fixture(t);
await writeFile(
join(f.directory, "contact-requests.csv"),
"createdAt,name,email,subject,message\n2026-01-01,Alt,alt@example.test,Frage,Alte Nachricht\n",
);
assert.equal((await f.post()).status, 201);
const records = await f.storage.readAll();
assert.equal(records.length, 2);
assert.notEqual(records[0].consent, true);
assert.equal(records[0].message, "Alte Nachricht");
assert.equal(records[1].consent, true);
});

View File

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

View File

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

View File

@ -0,0 +1,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,
);
});