Merge branch 'dev' into feature/FAQ

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

View File

@ -6,6 +6,7 @@
<title>Ernährungs-Pakete – Tri-Hub</title> <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

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

View File

@ -6,6 +6,7 @@
<meta name="robots" content="noindex" /> <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

@ -1,26 +1,29 @@
<!doctype html> <!doctype html>
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Berater kontaktieren – Tri-Hub Testseite</title> <title>Berater kontaktieren – Tri-Hub Testseite</title>
</head> <link rel="stylesheet" href="/src/styles/footer.css" />
<body> </head>
<main class="contact-test"> <body>
<p>TRI-HUB · KONTAKT-DEMO</p> <main class="contact-test">
<h1>Dein direkter Draht zur Beratung</h1> <p>TRI-HUB · KONTAKT-DEMO</p>
<p id="advisor-name"></p> <h1>Dein direkter Draht zur Beratung</h1>
<button id="open-contact" type="button"> <p id="advisor-name"></p>
Berater kontaktieren <button id="open-contact" type="button">
</button> Berater kontaktieren
<p> </button>
Testseite für das spätere Beraterprofil. Nutze eine <p>
E-Mail-Adresse aus einer vorhandenen Testbuchung. Testseite für das spätere Beraterprofil. Nutze eine
</p> E-Mail-Adresse aus einer vorhandenen Testbuchung.
</main> </p>
<script </main>
type="module" <script
src="/src/features/contact/contact-entry.js" type="module"
></script> src="/src/features/contact/contact-entry.js"
</body> ></script>
</html> <div id="footer-placeholder"></div>
<script type="module" src="/src/components/footer/footer.js"></script>
</body>
</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"
@ -163,10 +164,26 @@
} }
.hero { .hero {
position: relative;
isolation: isolate;
padding: 92px 0 82px; padding: 92px 0 82px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.hero::before {
content: "";
position: absolute;
inset: 0 0 56px;
z-index: -1;
background:
linear-gradient(
180deg,
rgba(8, 15, 21, 0.72),
rgba(8, 15, 21, 0.84)
),
url("/images/startseiter_hintergrund.jpeg") 38% center / cover no-repeat;
}
.hero-grid { .hero-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(330px, 0.8fr); grid-template-columns: minmax(0, 1.2fr) minmax(330px, 0.8fr);
@ -240,6 +257,7 @@
.hero-card { .hero-card {
position: relative; position: relative;
transform: translateX(80px);
min-height: 410px; min-height: 410px;
padding: 38px; padding: 38px;
display: flex; display: flex;
@ -256,8 +274,8 @@
), ),
linear-gradient( linear-gradient(
145deg, 145deg,
rgba(255, 255, 255, 0.075), rgba(13, 19, 31, 0.78),
rgba(255, 255, 255, 0.025) rgba(13, 19, 31, 0.84)
); );
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28); box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28);
} }
@ -274,6 +292,7 @@
box-shadow: box-shadow:
0 0 0 34px rgba(83, 213, 205, 0.035), 0 0 0 34px rgba(83, 213, 205, 0.035),
0 0 0 68px rgba(83, 213, 205, 0.02); 0 0 0 68px rgba(83, 213, 205, 0.02);
pointer-events: none;
} }
.hero-card-mark { .hero-card-mark {
@ -296,6 +315,11 @@
color: var(--muted); color: var(--muted);
} }
.hero-card > div {
position: relative;
z-index: 1;
}
.section { .section {
padding: 96px 0; padding: 96px 0;
} }
@ -375,6 +399,26 @@
font-size: clamp(2.5rem, 5vw, 4.7rem); font-size: clamp(2.5rem, 5vw, 4.7rem);
} }
.knowledge-image-frame {
width: 100%;
transform: translateX(-18px);
margin-top: 28px;
padding: 9px;
overflow: hidden;
border: 1px solid rgba(248, 250, 252, 0.18);
border-radius: 22px;
background: linear-gradient(145deg, #273135, #151d22);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.3);
}
.knowledge-image-frame img {
display: block;
width: 100%;
aspect-ratio: 2.58 / 1;
border-radius: 14px;
object-fit: cover;
}
.knowledge-copy { .knowledge-copy {
color: var(--muted); color: var(--muted);
line-height: 1.85; line-height: 1.85;
@ -547,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);
@ -692,6 +741,7 @@
.hero-card { .hero-card {
min-height: 330px; min-height: 330px;
transform: none;
} }
} }
@ -879,6 +929,13 @@
<h2 style="font-family: &quot;Inter&quot;, sans-serif"> <h2 style="font-family: &quot;Inter&quot;, sans-serif">
Wissen,<br />das dich<br />weiterbringt. Wissen,<br />das dich<br />weiterbringt.
</h2> </h2>
<div class="knowledge-image-frame">
<img
src="/images/exttraBild.jpeg"
alt="Ergänzendes Bild zur Ernährungsberatung"
loading="lazy"
/>
</div>
</div> </div>
<div> <div>
@ -994,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>
@ -1027,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"
@ -1088,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>
@ -1100,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>
@ -1217,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>
@ -1232,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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -1,150 +1,155 @@
@font-face { @font-face {
font-family: "DM Sans"; font-family: "DM Sans";
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url("/fonts/dm-sans-latin-400-normal.woff2") format("woff2"); src: url("/fonts/dm-sans-latin-400-normal.woff2") format("woff2");
} }
@font-face { @font-face {
font-family: "DM Sans"; font-family: "DM Sans";
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
font-display: swap; font-display: swap;
src: url("/fonts/dm-sans-latin-500-normal.woff2") format("woff2"); src: url("/fonts/dm-sans-latin-500-normal.woff2") format("woff2");
} }
@font-face { @font-face {
font-family: "DM Sans"; font-family: "DM Sans";
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
font-display: swap; font-display: swap;
src: url("/fonts/dm-sans-latin-700-normal.woff2") format("woff2"); src: url("/fonts/dm-sans-latin-700-normal.woff2") format("woff2");
} }
.contact-dialog { .contact-dialog {
--contact-foreground: oklch(96% 0.008 210); --contact-foreground: oklch(96% 0.008 210);
--contact-accent: oklch(70% 0.12 205); --contact-accent: oklch(70% 0.12 205);
box-sizing: border-box; box-sizing: border-box;
width: min(32rem, calc(100vw - 2rem)); width: min(32rem, calc(100vw - 2rem));
max-height: calc(100dvh - 2rem); max-height: calc(100dvh - 2rem);
margin: auto; margin: auto;
padding: 1.75rem; padding: 1.75rem;
overflow-y: auto; overflow-y: auto;
border: 1px solid #324449; border: 1px solid #324449;
border-radius: 1.25rem; border-radius: 1.25rem;
background: #071316; background: #071316;
color: var(--contact-foreground); color: var(--contact-foreground);
font: font:
400 16px/1.5 "DM Sans", 400 16px/1.5 "DM Sans",
sans-serif; sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
box-shadow: 0 1.5rem 4rem #0006; box-shadow: 0 1.5rem 4rem #0006;
} }
.contact-dialog::backdrop { .contact-dialog::backdrop {
background: rgb(0 0 0 / 65%); background: rgb(0 0 0 / 65%);
} }
.contact-dialog * { .contact-dialog * {
box-sizing: border-box; box-sizing: border-box;
} }
.contact-dialog h2 { .contact-dialog h2 {
margin: 8px 32px 8px 0; margin: 8px 32px 8px 0;
font-size: 1.35rem; font-size: 1.35rem;
line-height: 1.25; line-height: 1.25;
} }
.contact-dialog p { .contact-dialog p {
margin: 0; margin: 0;
} }
.contact-dialog .contact-eyebrow { .contact-dialog .contact-eyebrow {
color: var(--contact-accent); color: var(--contact-accent);
font-size: 12px; font-size: 12px;
letter-spacing: 0.12em; letter-spacing: 0.12em;
} }
.contact-advisor, .contact-advisor,
.contact-help, .contact-help,
.contact-consent { .contact-consent {
color: oklch(72% 0.015 214); color: oklch(72% 0.015 214);
} }
.contact-form { .contact-form {
margin-top: 32px; margin-top: 32px;
display: grid; display: grid;
gap: 24px; gap: 24px;
} }
.contact-form label { .contact-form label {
display: block; display: block;
margin-bottom: 0.35rem; margin-bottom: 0.35rem;
font-weight: 600; font-weight: 600;
} }
.contact-form input:not([type="checkbox"]), .contact-form input:not([type="checkbox"]),
.contact-form select { .contact-form select {
width: 100%; width: 100%;
min-height: 2.75rem; min-height: 2.75rem;
padding: 0.65rem 0.8rem; padding: 0.65rem 0.8rem;
border: 1px solid #61777d; border: 1px solid #61777d;
border-radius: 0.75rem; border-radius: 0.75rem;
background: #101e22; background: #101e22;
color: inherit; color: inherit;
font: inherit; font: inherit;
color-scheme: dark; color-scheme: dark;
} }
.contact-dialog button { .contact-dialog button {
min-height: 2.75rem; min-height: 2.75rem;
padding: 0.65rem 0.8rem; padding: 0.65rem 0.8rem;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 0.75rem; border-radius: 0.75rem;
font: inherit; font: inherit;
cursor: pointer; cursor: pointer;
} }
.contact-dialog .contact-close { .contact-dialog .contact-close {
position: absolute; position: absolute;
top: 0.5rem; top: 0.5rem;
right: 0.5rem; right: 0.5rem;
padding: 0; padding: 0;
width: 2.75rem; width: 2.75rem;
height: 2.75rem; height: 2.75rem;
border: 0; border: 0;
background: transparent; background: transparent;
color: inherit; color: inherit;
font-size: 28px; font-size: 28px;
} }
.contact-submit { .contact-submit {
width: 100%; width: 100%;
background: #53d5cd; background: #53d5cd;
color: oklch(20% 0.03 220); color: oklch(20% 0.03 220);
} }
.contact-submit:hover:not(:disabled) { .contact-submit:hover:not(:disabled) {
background: #6de0d9; background: #6de0d9;
} }
.contact-dialog :disabled { .contact-dialog :disabled {
opacity: 0.7; opacity: 0.7;
cursor: not-allowed; cursor: not-allowed;
} }
.contact-dialog :focus-visible { .contact-dialog :focus-visible {
outline: 3px solid #53d5cd; outline: 3px solid #53d5cd;
outline-offset: 4px; outline-offset: 4px;
} }
.contact-dialog .contact-help { .contact-dialog .contact-help {
margin-top: 8px; margin-top: 8px;
font-size: 14px; font-size: 14px;
} }
.contact-form .contact-consent { .contact-form .contact-consent {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: 12px;
margin: 0; margin: 0;
font-size: 14px; font-size: 14px;
} }
.contact-consent input { .contact-consent input {
flex-shrink: 0; flex-shrink: 0;
width: 20px; width: 20px;
height: 20px; height: 20px;
margin: 2px 0 0; margin: 2px 0 0;
accent-color: var(--contact-accent); accent-color: var(--contact-accent);
} }
.contact-status { .contact-status {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.contact-status:empty { .contact-status:empty {
display: none; display: none;
} }
.contact-status a { .contact-status a {
color: var(--contact-accent); color: var(--contact-accent);
} }
.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,148 +1,168 @@
import { createContactService } from "./services/contact-service.js"; import { createReviewStorage } from "./services/review-storage.js";
import { createContactEmailService } from "./services/contact-email.js"; import { handleReview } from "./routes/reviews.js";
import { handleContact } from "./routes/contact.js"; import { createContactService } from "./services/contact-service.js";
import { createServer } from "node:http"; import { createContactEmailService } from "./services/contact-email.js";
import { fileURLToPath, pathToFileURL } from "node:url"; import { handleContact } from "./routes/contact.js";
import { packages } from "../shared/packages.js"; import { createServer } from "node:http";
import { import { fileURLToPath, pathToFileURL } from "node:url";
BookingError, import { packages } from "../shared/packages.js";
createBookingService, import {
} from "./services/booking-service.js"; BookingError,
import { createCsvStorage } from "./services/csv-storage.js"; createBookingService,
import { createEmailService } from "./services/email-service.js"; } from "./services/booking-service.js";
import { handleBooking, json } from "./routes/bookings.js"; import { createCsvStorage } from "./services/csv-storage.js";
import { handleContactForm } from "./routes/contact-form.js"; import { createEmailService } from "./services/email-service.js";
import { createContactFormService } from "./services/contact-form-service.js"; import { handleBooking, json } from "./routes/bookings.js";
import { createContactFormStorage } from "./services/contact-form-storage.js"; import { handleContactForm } from "./routes/contact-form.js";
import { createContactFormEmailService } from "./services/contact-form-email.js"; import { createContactFormService } from "./services/contact-form-service.js";
import { createContactFormStorage } from "./services/contact-form-storage.js";
export function createApp({ import { createContactFormEmailService } from "./services/contact-form-email.js";
catalog = packages,
storage = createCsvStorage( export function createApp({
fileURLToPath(new URL("./data/bookings.csv", import.meta.url)), catalog = packages,
), storage = createCsvStorage(
details, fileURLToPath(new URL("./data/bookings.csv", import.meta.url)),
sendConfirmation, ),
sendContact, reviewStorage = createReviewStorage(
contactFormStorage = createContactFormStorage( fileURLToPath(new URL("./data/reviews.csv", import.meta.url)),
fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)), ),
), details,
sendContactForm, sendConfirmation,
} = {}) { sendContact,
const ids = new Set(); contactFormStorage = createContactFormStorage(
for (const entry of catalog) { fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)),
if ( ),
!entry || sendContactForm,
typeof entry.id !== "string" || advisorContactStorage = createContactFormStorage(
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id) || fileURLToPath(new URL("./data/advisor-contacts.csv", import.meta.url)),
entry.id.length > 80 || [
ids.has(entry.id) || "createdAt",
typeof entry.name !== "string" || "email",
!entry.name.trim() || "advisorId",
typeof entry.summary !== "string" || "reason",
!entry.summary.trim() "simulationAccepted",
) { "consent",
throw new Error( ],
"Paketdaten erfüllen den Vertrag in src/shared/packages.js nicht.", ),
); } = {}) {
} const ids = new Set();
ids.add(entry.id); for (const entry of catalog) {
} if (
const service = createBookingService({ !entry ||
storage, typeof entry.id !== "string" ||
details, !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id) ||
packages: catalog, entry.id.length > 80 ||
sendConfirmation: sendConfirmation ?? createEmailService(), ids.has(entry.id) ||
}); typeof entry.name !== "string" ||
const contact = createContactService({ !entry.name.trim() ||
storage, typeof entry.summary !== "string" ||
sendContact: sendContact ?? createContactEmailService(), !entry.summary.trim()
}); ) {
// Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand. throw new Error(
const contactForm = createContactFormService({ "Paketdaten erfüllen den Vertrag in src/shared/packages.js nicht.",
storage: contactFormStorage, );
sendContactForm: sendContactForm ?? createContactFormEmailService(), }
}); ids.add(entry.id);
return createServer(async (request, response) => { }
const path = new URL(request.url, "http://localhost").pathname; const service = createBookingService({
// Leitet die Ernährungsseite an ihren eigenen Kontaktformular-Endpunkt. storage,
if (path === "/api/nutrition-contact") { details,
return handleContactForm(request, response, contactForm); packages: catalog,
} sendConfirmation: sendConfirmation ?? createEmailService(),
if (path === "/api/contact" || path === "/api/contact/validate") { });
return handleContact( const contact = createContactService({
request, storage,
response, requestStorage: advisorContactStorage,
contact, sendContact: sendContact ?? createContactEmailService(),
path.endsWith("/validate"), });
); // Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand.
} const contactForm = createContactFormService({
if (path === "/api/angebote" && request.method === "GET") { storage: contactFormStorage,
return json(response, 200, { sendContactForm: sendContactForm ?? createContactFormEmailService(),
packages: catalog.map(({ id, name, summary }) => ({ });
id, return createServer(async (request, response) => {
name, const path = new URL(request.url, "http://localhost").pathname;
summary, if (path === "/api/reviews") {
})), return handleReview(request, response, storage, reviewStorage);
}); }
} // Leitet die Ernährungsseite an ihren eigenen Kontaktformular-Endpunkt.
if (path === "/api/packages" && request.method === "GET") { if (path === "/api/nutrition-contact") {
return json( return handleContactForm(request, response, contactForm);
response, }
200, if (path === "/api/contact" || path === "/api/contact/validate") {
catalog.map(({ name, summary, ...entry }) => ({ return handleContact(
...entry, request,
title: name, response,
description: summary, contact,
})), path.endsWith("/validate"),
); );
} }
if (path.startsWith("/api/packages/") && request.method === "GET") { if (path === "/api/angebote" && request.method === "GET") {
try { return json(response, 200, {
return json( packages: catalog.map(({ id, name, summary }) => ({
response, id,
200, name,
service.getPackage(path.slice("/api/packages/".length)), summary,
); })),
} catch (error) { });
if (!(error instanceof BookingError)) throw error; }
return json(response, error.status, { if (path === "/api/packages" && request.method === "GET") {
error: { return json(
code: error.code, response,
message: error.message, 200,
fields: error.fields, catalog.map(({ name, summary, ...entry }) => ({
}, ...entry,
}); title: name,
} description: summary,
} })),
if (path === "/api/bookings" || path === "/api/bookings/preview") { );
if (request.method !== "POST") { }
response.setHeader("Allow", "POST"); if (path.startsWith("/api/packages/") && request.method === "GET") {
return json(response, 405, { try {
error: { return json(
code: "METHOD_NOT_ALLOWED", response,
message: "Bitte POST verwenden.", 200,
}, service.getPackage(path.slice("/api/packages/".length)),
}); );
} } catch (error) {
return handleBooking(request, response, service, { if (!(error instanceof BookingError)) throw error;
preview: path.endsWith("/preview"), return json(response, error.status, {
}); error: {
} code: error.code,
// Ausschließlich API; keine Auslieferung von CSV oder Serverdateien. message: error.message,
return json(response, 404, { fields: error.fields,
error: { code: "NOT_FOUND", message: "Adresse nicht gefunden." }, },
}); });
}); }
} }
if (path === "/api/bookings" || path === "/api/bookings/preview") {
if ( if (request.method !== "POST") {
process.argv[1] && response.setHeader("Allow", "POST");
import.meta.url === pathToFileURL(process.argv[1]).href return json(response, 405, {
) { error: {
const port = Number(process.env.PORT || 3000); code: "METHOD_NOT_ALLOWED",
const server = createApp(); message: "Bitte POST verwenden.",
server.listen(port, process.env.HOST || "127.0.0.1", () => { },
console.log(`Buchungs-API auf Port ${port}`); });
}); }
} return handleBooking(request, response, service, {
preview: path.endsWith("/preview"),
});
}
// Ausschließlich API; keine Auslieferung von CSV oder Serverdateien.
return json(response, 404, {
error: { code: "NOT_FOUND", message: "Adresse nicht gefunden." },
});
});
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
const port = Number(process.env.PORT || 3000);
const server = createApp();
server.listen(port, process.env.HOST || "127.0.0.1", () => {
console.log(`Buchungs-API auf Port ${port}`);
});
}

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

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

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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

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

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

View File

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

@ -1,24 +1,42 @@
// Ausschließlich Testserver: temporäre CSV und simulierter Versand. // Ausschließlich Testserver: temporäre CSV und simulierter Versand.
import { mkdtemp, rm } from "node:fs/promises"; import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createApp } from "../../server/index.js"; import { createApp } from "../../server/index.js";
import { createCsvStorage } from "../../server/services/csv-storage.js"; import { createCsvStorage } from "../../server/services/csv-storage.js";
import { packages } from "../../shared/packages.js"; import { createReviewStorage } from "../../server/services/review-storage.js";
import { createContactFormStorage } from "../../server/services/contact-form-storage.js";
const directory = await mkdtemp(join(tmpdir(), "trihub-browser-")); import { packages } from "../../shared/packages.js";
const server = createApp({
catalog: packages, const directory = await mkdtemp(join(tmpdir(), "trihub-browser-"));
storage: createCsvStorage(join(directory, "bookings.csv")), const server = createApp({
sendConfirmation: async () => {}, catalog: packages,
sendContact: async () => {}, storage: createCsvStorage(join(directory, "bookings.csv")),
}); reviewStorage: createReviewStorage(join(directory, "reviews.csv")),
server.listen(3000, "127.0.0.1"); advisorContactStorage: createContactFormStorage(
async function stop() { join(directory, "advisor-contacts.csv"),
server.closeAllConnections(); [
await new Promise((resolve) => server.close(resolve)); "createdAt",
await rm(directory, { recursive: true, force: true }); "email",
process.exit(0); "advisorId",
} "reason",
process.on("SIGTERM", stop); "simulationAccepted",
process.on("SIGINT", stop); "consent",
],
),
contactFormStorage: createContactFormStorage(
join(directory, "contact-requests.csv"),
),
sendContactForm: async () => {},
sendConfirmation: async () => {},
sendContact: async () => {},
});
server.listen(Number(process.env.TEST_API_PORT || 3000), "127.0.0.1");
async function stop() {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
await rm(directory, { recursive: true, force: true });
process.exit(0);
}
process.on("SIGTERM", stop);
process.on("SIGINT", stop);

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,
);
});