Merge branch 'dev' into feature/FAQ
commit
ea47960393
|
|
@ -6,6 +6,7 @@
|
|||
<title>Ernährungs-Pakete – Tri-Hub</title>
|
||||
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
||||
<link rel="stylesheet" href="/src/styles/packages.css" />
|
||||
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||
</head>
|
||||
<body class="packages-page">
|
||||
<div id="navbar-placeholder"></div>
|
||||
|
|
@ -30,5 +31,7 @@
|
|||
type="module"
|
||||
src="/src/features/angebote/angebote-entry.js"
|
||||
></script>
|
||||
<div id="footer-placeholder"></div>
|
||||
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
86
berater.html
86
berater.html
|
|
@ -1,31 +1,55 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Lerne die Ernährungsberaterinnen und Ernährungsberater von Tri-Hub kennen." />
|
||||
<title>Beraterprofil · Tri-Hub</title>
|
||||
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
||||
<link rel="stylesheet" href="/src/styles/packages.css" />
|
||||
<link rel="stylesheet" href="/src/features/berater/berater.css" />
|
||||
</head>
|
||||
<body class="packages-page advisor-page">
|
||||
<div id="navbar-placeholder"></div>
|
||||
<main id="main-content" class="advisor-main">
|
||||
<section class="advisor-section" aria-labelledby="advisor-heading">
|
||||
<div class="advisor-container">
|
||||
<header class="advisor-heading">
|
||||
<p class="advisor-eyebrow">PERSÖNLICH FÜR DICH DA</p>
|
||||
<h1 id="advisor-heading">Lerne deine Ernährungsberater kennen.</h1>
|
||||
<p class="advisor-intro">Hinter einer guten Ernährungsstrategie stehen Menschen, die zuhören und ihre Erfahrung in deinen Alltag und dein Training einbringen.</p>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Lerne die Ernährungsberaterinnen und Ernährungsberater von Tri-Hub kennen."
|
||||
/>
|
||||
<title>Beraterprofil · Tri-Hub</title>
|
||||
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
||||
<link rel="stylesheet" href="/src/styles/packages.css" />
|
||||
<link rel="stylesheet" href="/src/features/berater/berater.css" />
|
||||
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||
</head>
|
||||
<body class="packages-page advisor-page">
|
||||
<div id="navbar-placeholder"></div>
|
||||
<main id="main-content" class="advisor-main">
|
||||
<section class="advisor-section" aria-labelledby="advisor-heading">
|
||||
<div class="advisor-container">
|
||||
<header class="advisor-heading">
|
||||
<p class="advisor-eyebrow">PERSÖNLICH FÜR DICH DA</p>
|
||||
<h1 id="advisor-heading">
|
||||
Lerne deine Ernährungsberater kennen.
|
||||
</h1>
|
||||
<p class="advisor-intro">
|
||||
Hinter einer guten Ernährungsstrategie stehen
|
||||
Menschen, die zuhören und ihre Erfahrung in deinen
|
||||
Alltag und dein Training einbringen.
|
||||
</p>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<meta name="robots" content="noindex" />
|
||||
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
||||
<title>Buchungsbestätigung – Tri-Hub</title>
|
||||
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||
</head>
|
||||
<body class="confirmation">
|
||||
<div id="navbar-placeholder"></div>
|
||||
|
|
@ -146,9 +147,6 @@
|
|||
Buchungsbestätigung anzuzeigen.</noscript
|
||||
>
|
||||
</main>
|
||||
<footer class="confirmation__footer">
|
||||
TRI-HUB <span>Dein Weg. Deine Ausdauer. Deine Ernährung.</span>
|
||||
</footer>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/features/booking/booking-confirmation.js"
|
||||
|
|
@ -157,5 +155,7 @@
|
|||
type="module"
|
||||
src="/src/components/navigationsbar/navigation.js"
|
||||
></script>
|
||||
<div id="footer-placeholder"></div>
|
||||
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>Paket buchen – Tri-Hub</title>
|
||||
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
|
|
@ -17,5 +18,7 @@
|
|||
type="module"
|
||||
src="/src/features/booking/booking-entry.js"
|
||||
></script>
|
||||
<div id="footer-placeholder"></div>
|
||||
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,29 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Berater kontaktieren – Tri-Hub Testseite</title>
|
||||
</head>
|
||||
<body>
|
||||
<main class="contact-test">
|
||||
<p>TRI-HUB · KONTAKT-DEMO</p>
|
||||
<h1>Dein direkter Draht zur Beratung</h1>
|
||||
<p id="advisor-name"></p>
|
||||
<button id="open-contact" type="button">
|
||||
Berater kontaktieren
|
||||
</button>
|
||||
<p>
|
||||
Testseite für das spätere Beraterprofil. Nutze eine
|
||||
E-Mail-Adresse aus einer vorhandenen Testbuchung.
|
||||
</p>
|
||||
</main>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/features/contact/contact-entry.js"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Berater kontaktieren – Tri-Hub Testseite</title>
|
||||
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="contact-test">
|
||||
<p>TRI-HUB · KONTAKT-DEMO</p>
|
||||
<h1>Dein direkter Draht zur Beratung</h1>
|
||||
<p id="advisor-name"></p>
|
||||
<button id="open-contact" type="button">
|
||||
Berater kontaktieren
|
||||
</button>
|
||||
<p>
|
||||
Testseite für das spätere Beraterprofil. Nutze eine
|
||||
E-Mail-Adresse aus einer vorhandenen Testbuchung.
|
||||
</p>
|
||||
</main>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/features/contact/contact-entry.js"
|
||||
></script>
|
||||
<div id="footer-placeholder"></div>
|
||||
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
173
index.html
173
index.html
|
|
@ -3,6 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
||||
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
|
|
@ -163,10 +164,26 @@
|
|||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
padding: 92px 0 82px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 0 56px;
|
||||
z-index: -1;
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(8, 15, 21, 0.72),
|
||||
rgba(8, 15, 21, 0.84)
|
||||
),
|
||||
url("/images/startseiter_hintergrund.jpeg") 38% center / cover no-repeat;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(330px, 0.8fr);
|
||||
|
|
@ -240,6 +257,7 @@
|
|||
|
||||
.hero-card {
|
||||
position: relative;
|
||||
transform: translateX(80px);
|
||||
min-height: 410px;
|
||||
padding: 38px;
|
||||
display: flex;
|
||||
|
|
@ -256,8 +274,8 @@
|
|||
),
|
||||
linear-gradient(
|
||||
145deg,
|
||||
rgba(255, 255, 255, 0.075),
|
||||
rgba(255, 255, 255, 0.025)
|
||||
rgba(13, 19, 31, 0.78),
|
||||
rgba(13, 19, 31, 0.84)
|
||||
);
|
||||
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
|
@ -274,6 +292,7 @@
|
|||
box-shadow:
|
||||
0 0 0 34px rgba(83, 213, 205, 0.035),
|
||||
0 0 0 68px rgba(83, 213, 205, 0.02);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-card-mark {
|
||||
|
|
@ -296,6 +315,11 @@
|
|||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hero-card > div {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 96px 0;
|
||||
}
|
||||
|
|
@ -375,6 +399,26 @@
|
|||
font-size: clamp(2.5rem, 5vw, 4.7rem);
|
||||
}
|
||||
|
||||
.knowledge-image-frame {
|
||||
width: 100%;
|
||||
transform: translateX(-18px);
|
||||
margin-top: 28px;
|
||||
padding: 9px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(248, 250, 252, 0.18);
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(145deg, #273135, #151d22);
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.knowledge-image-frame img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 2.58 / 1;
|
||||
border-radius: 14px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.knowledge-copy {
|
||||
color: var(--muted);
|
||||
line-height: 1.85;
|
||||
|
|
@ -547,6 +591,11 @@
|
|||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.consent a {
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.consent input {
|
||||
margin-top: 4px;
|
||||
accent-color: var(--brand);
|
||||
|
|
@ -692,6 +741,7 @@
|
|||
|
||||
.hero-card {
|
||||
min-height: 330px;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -879,6 +929,13 @@
|
|||
<h2 style="font-family: "Inter", sans-serif">
|
||||
Wissen,<br />das dich<br />weiterbringt.
|
||||
</h2>
|
||||
<div class="knowledge-image-frame">
|
||||
<img
|
||||
src="/images/exttraBild.jpeg"
|
||||
alt="Ergänzendes Bild zur Ernährungsberatung"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -994,10 +1051,9 @@
|
|||
</article>
|
||||
|
||||
<p class="review-note">
|
||||
Hinweis: Die Bewertungen in dieser HTML-Demo werden
|
||||
nicht dauerhaft gespeichert. Für den Live-Betrieb
|
||||
sollte das Formular an das bestehende Backend oder
|
||||
CMS von Tri-hub angebunden werden.
|
||||
Nur Kunden mit einer vorhandenen Buchung können eine
|
||||
Bewertung abgeben. Deine E-Mail-Adresse wird nicht
|
||||
öffentlich angezeigt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1027,19 +1083,36 @@
|
|||
|
||||
<form class="review-form" id="reviewForm">
|
||||
<div class="field" style="margin-top: 0">
|
||||
<label for="name">Dein Name</label>
|
||||
<label for="reviewEmail"
|
||||
>E-Mail deiner Buchung*</label
|
||||
>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autocomplete="name"
|
||||
placeholder="z. B. Thomas M."
|
||||
id="reviewEmail"
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
maxlength="254"
|
||||
placeholder="z. B. thomas@example.de"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Deine Bewertung</label>
|
||||
<label for="reviewTitle"
|
||||
>Titel (10–50 Zeichen)*</label
|
||||
>
|
||||
<input
|
||||
id="reviewTitle"
|
||||
name="title"
|
||||
type="text"
|
||||
minlength="10"
|
||||
maxlength="50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Deine Bewertung*</label>
|
||||
<div
|
||||
class="rating-input"
|
||||
aria-label="Sternebewertung"
|
||||
|
|
@ -1088,10 +1161,14 @@
|
|||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="review">Deine Erfahrung</label>
|
||||
<label for="review"
|
||||
>Deine Erfahrung (10–500 Zeichen)*</label
|
||||
>
|
||||
<textarea
|
||||
id="review"
|
||||
name="review"
|
||||
minlength="10"
|
||||
maxlength="500"
|
||||
placeholder="Was hat dir an der Ernährungsberatung geholfen?"
|
||||
required
|
||||
></textarea>
|
||||
|
|
@ -1100,12 +1177,22 @@
|
|||
<label class="consent">
|
||||
<input type="checkbox" name="consent" required />
|
||||
<span>
|
||||
Ich stimme zu, dass meine Bewertung mit meinem
|
||||
angegebenen Namen auf dieser Seite
|
||||
veröffentlicht werden darf.
|
||||
Ich akzeptiere die
|
||||
<a
|
||||
href="https://www.tri-hub.de/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Datenschutzbestimmungen</a
|
||||
>*: Meine E-Mail-Adresse wird zum Abgleich mit
|
||||
meiner Buchung und zusammen mit meiner Bewertung
|
||||
gespeichert. Titel, Bewertungstext und Sterne
|
||||
dürfen veröffentlicht werden; meine
|
||||
E-Mail-Adresse bleibt nicht öffentlich.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<small>*Pflichtfelder</small>
|
||||
|
||||
<button class="button button-primary" type="submit">
|
||||
Bewertung veröffentlichen
|
||||
</button>
|
||||
|
|
@ -1217,6 +1304,24 @@
|
|||
<small>*Pflichtfelder</small>
|
||||
</div>
|
||||
|
||||
<label class="consent"
|
||||
><input
|
||||
type="checkbox"
|
||||
name="consent"
|
||||
required
|
||||
/><span
|
||||
>Ich akzeptiere die
|
||||
<a
|
||||
href="https://www.tri-hub.de/datenschutz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Datenschutzbestimmungen</a
|
||||
>
|
||||
(Pflichtfeld).</span
|
||||
></label
|
||||
>
|
||||
<p data-error-for="consent" role="alert"></p>
|
||||
|
||||
<button class="button button-primary" type="submit">
|
||||
Nachricht senden
|
||||
</button>
|
||||
|
|
@ -1232,42 +1337,20 @@
|
|||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<div>
|
||||
<strong>tri-hub</strong> · Triathlon als sportliches Zuhause
|
||||
ab 50.
|
||||
</div>
|
||||
<div>Ernährung · Wissen · Erfahrungen</div>
|
||||
</div>
|
||||
</footer>
|
||||
<div id="footer-placeholder"></div>
|
||||
|
||||
<script
|
||||
type="module"
|
||||
src="/src/components/navigationsbar/navigation.js"
|
||||
></script>
|
||||
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/features/contact/contact-form.js"
|
||||
></script>
|
||||
<script>
|
||||
const form = document.getElementById("reviewForm");
|
||||
const status = document.getElementById("formStatus");
|
||||
|
||||
form.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
const name = form.elements.name.value.trim();
|
||||
const rating = form.elements.rating.value;
|
||||
const review = form.elements.review.value.trim();
|
||||
|
||||
if (!name || !rating || !review) return;
|
||||
|
||||
status.textContent =
|
||||
"Danke für deine Bewertung. In dieser Demo wurde sie noch nicht dauerhaft gespeichert.";
|
||||
|
||||
form.reset();
|
||||
});
|
||||
</script>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/features/reviews/review-form.js"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<title>Paket-Details – Tri-Hub Ernährungsberatung</title>
|
||||
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
||||
<link rel="stylesheet" href="/src/styles/packages.css" />
|
||||
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||
</head>
|
||||
<body class="packages-page">
|
||||
<div id="navbar-placeholder"></div>
|
||||
|
|
@ -23,5 +24,7 @@
|
|||
type="module"
|
||||
src="/src/features/angebote/paket-details.js"
|
||||
></script>
|
||||
<div id="footer-placeholder"></div>
|
||||
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 824 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
|
|
@ -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 & 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>
|
||||
|
|
@ -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());
|
||||
|
|
@ -15,9 +15,24 @@ export function renderAngebotsuebersicht() {
|
|||
? `<span class="package-highlight">${pkg.highlight}</span>`
|
||||
: "";
|
||||
|
||||
const cardHtml = `
|
||||
<div class="package-card">
|
||||
const packageImage = {
|
||||
"ernaehrung-starter": {
|
||||
src: "/images/Starter_bild.webp",
|
||||
alt: "Triathlon-Einsteiger im Gespräch mit ihrem Coach am See",
|
||||
},
|
||||
"ernaehrung-standard": {
|
||||
src: "/images/standard_bild.webp",
|
||||
alt: "Triathlet im Radtrikot bespricht sein Training mit dem Coach",
|
||||
},
|
||||
"ernaehrung-premium": {
|
||||
src: "/images/premium_bild.webp",
|
||||
alt: "Triathlon-Athleten planen gemeinsam am Seeufer",
|
||||
},
|
||||
}[pkg.id];
|
||||
|
||||
const cardHtml = `
|
||||
<article class="package-card">
|
||||
<img class="package-image" src="${packageImage.src}" alt="${packageImage.alt}" loading="lazy" />
|
||||
<div class="package-type-container">
|
||||
<span class="package-type">${pkg.type}</span>
|
||||
${highlightHtml}
|
||||
|
|
@ -43,7 +58,7 @@ export function renderAngebotsuebersicht() {
|
|||
|
||||
<a href="/paket-details.html?id=${pkg.id}" class="package-btn">${pkg.buttonText} <span aria-hidden="true">→</span></a>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
|
||||
container.innerHTML += cardHtml;
|
||||
|
|
|
|||
|
|
@ -109,6 +109,8 @@ export function mountBookingPage(
|
|||
<input id="${instanceId}-booking-phone" name="phone" type="tel" autocomplete="tel" maxlength="50" aria-describedby="${instanceId}-booking-phone-error" />
|
||||
<p class="booking__field-error" id="${instanceId}-booking-phone-error"></p>
|
||||
</div>
|
||||
<label class="booking__consent"><input name="consent" type="checkbox" required aria-describedby="${instanceId}-booking-consent-error" /> <span>Ich akzeptiere die <a href="https://www.tri-hub.de/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> (Pflichtfeld).</span></label>
|
||||
<p class="booking__field-error" id="${instanceId}-booking-consent-error"></p>
|
||||
<div class="booking__actions">
|
||||
<button class="booking__button booking__cancel" type="button">Abbrechen</button>
|
||||
<button class="booking__button" type="submit">Paket buchen</button>
|
||||
|
|
@ -192,6 +194,7 @@ export function mountBookingPage(
|
|||
const errors = form.querySelector(".booking__errors");
|
||||
const progress = form.querySelector(".booking__progress");
|
||||
const result = content.querySelector(".booking__result");
|
||||
const consent = form.elements.consent;
|
||||
const storageKey = `trihub.booking.${selected.packageId}${selected.variantId ? `.${selected.variantId}` : ""}`;
|
||||
let attempt = null;
|
||||
let busy = false;
|
||||
|
|
@ -201,11 +204,12 @@ export function mountBookingPage(
|
|||
name.readOnly = locked;
|
||||
email.readOnly = locked;
|
||||
phone.readOnly = locked;
|
||||
consent.disabled = locked;
|
||||
}
|
||||
function showError(message, fields = {}) {
|
||||
errors.textContent = message;
|
||||
errors.hidden = false;
|
||||
for (const field of [name, email, phone]) {
|
||||
for (const field of [name, email, phone, consent]) {
|
||||
const message = fields[field.name] || "";
|
||||
content.querySelector(
|
||||
`#${instanceId}-booking-${field.name}-error`,
|
||||
|
|
@ -216,7 +220,7 @@ export function mountBookingPage(
|
|||
}
|
||||
function clearErrors() {
|
||||
errors.hidden = true;
|
||||
for (const field of [name, email, phone]) {
|
||||
for (const field of [name, email, phone, consent]) {
|
||||
field.removeAttribute("aria-invalid");
|
||||
content.querySelector(
|
||||
`#${instanceId}-booking-${field.name}-error`,
|
||||
|
|
@ -256,6 +260,7 @@ export function mountBookingPage(
|
|||
name.value = stored.payload.name;
|
||||
email.value = stored.payload.email;
|
||||
phone.value = stored.payload.phone || "";
|
||||
consent.checked = stored.payload.consent === true;
|
||||
lockFields(true);
|
||||
button.textContent = "Status prüfen / erneut versuchen";
|
||||
if (stored.result) showResult(stored.result, false);
|
||||
|
|
@ -275,6 +280,9 @@ export function mountBookingPage(
|
|||
clearErrors();
|
||||
if (!attempt) {
|
||||
const fields = {};
|
||||
if (!consent.checked)
|
||||
fields.consent =
|
||||
"Bitte die Datenschutzbestimmungen akzeptieren.";
|
||||
name.value = name.value.trim();
|
||||
email.value = email.value.trim();
|
||||
if (
|
||||
|
|
@ -305,6 +313,7 @@ export function mountBookingPage(
|
|||
...(selected.variantId
|
||||
? { variantId: selected.variantId }
|
||||
: {}),
|
||||
consent: consent.checked,
|
||||
name: name.value,
|
||||
email: email.value,
|
||||
...(phone.value ? { phone: phone.value } : {}),
|
||||
|
|
|
|||
|
|
@ -190,3 +190,25 @@
|
|||
.booking__dialog .booking__result:focus {
|
||||
outline-color: #53d5cd;
|
||||
}
|
||||
|
||||
.booking .booking__consent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.booking__consent input[type="checkbox"] {
|
||||
flex: 0 0 1em;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
min-height: 0;
|
||||
margin: 0.25em 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.booking__consent a,
|
||||
.booking__consent a:visited {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,130 +1,133 @@
|
|||
import { findAdvisor, advisorDisplayName } from "../../shared/berater.js";
|
||||
import {
|
||||
contactReasons,
|
||||
contactNotice,
|
||||
contactBookingUrl,
|
||||
} from "../../shared/contact.js";
|
||||
import "./contact.css";
|
||||
|
||||
// Auf Profilseiten: openContactDialog({ advisorId: profile.id }).
|
||||
export function openContactDialog({
|
||||
advisorId,
|
||||
navigate = (url) => window.location.assign(url),
|
||||
} = {}) {
|
||||
const advisor = findAdvisor(advisorId);
|
||||
const returnFocus = document.activeElement;
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.className = "contact-dialog";
|
||||
dialog.setAttribute("aria-labelledby", "contact-title");
|
||||
dialog.innerHTML = `
|
||||
<button class="contact-close" type="button" aria-label="Kontaktfenster schließen">×</button>
|
||||
<p class="contact-eyebrow">DEIN NÄCHSTER SCHRITT</p>
|
||||
<h2 id="contact-title"></h2>
|
||||
<p class="contact-advisor"></p>
|
||||
<form class="contact-form">
|
||||
<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">
|
||||
<p id="contact-email-help" class="contact-help">Verwende die E-Mail-Adresse, mit der du dein Paket gebucht hast.</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<label class="contact-consent"><input name="simulationAccepted" type="checkbox" required><span></span></label>
|
||||
<p class="contact-status" role="status" aria-live="polite"></p>
|
||||
<button class="contact-submit" type="submit" disabled>Anfrage senden & Termin wählen</button>
|
||||
</form>`;
|
||||
const advisorName = advisorDisplayName(advisor);
|
||||
dialog.querySelector("#contact-title").textContent = advisorName
|
||||
? `${advisorName} kontaktieren`
|
||||
: "Berater nicht gefunden";
|
||||
dialog.querySelector(".contact-advisor").textContent = advisor
|
||||
? "Sende deine Kontaktanfrage."
|
||||
: "Die Berater-ID fehlt oder ist unbekannt. Bitte öffne das Fenster über ein Beraterprofil.";
|
||||
dialog.querySelector(".contact-consent span").textContent = contactNotice;
|
||||
const form = dialog.querySelector("form");
|
||||
const email = form.elements.email;
|
||||
const reason = form.elements.reason;
|
||||
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 close = dialog.querySelector(".contact-close");
|
||||
let busy = false;
|
||||
let sent = false;
|
||||
function update() {
|
||||
submit.disabled = busy || sent || !advisor;
|
||||
close.disabled = busy;
|
||||
email.disabled = busy || sent;
|
||||
reason.disabled = busy || sent;
|
||||
form.elements.simulationAccepted.disabled = busy || sent;
|
||||
}
|
||||
async function post(path, input) {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
result.error?.message || "Die Anfrage ist fehlgeschlagen.",
|
||||
);
|
||||
return result;
|
||||
}
|
||||
email.addEventListener("input", () => {
|
||||
status.textContent = "";
|
||||
});
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (submit.disabled || !form.reportValidity()) return;
|
||||
const input = {
|
||||
email: email.value,
|
||||
advisorId,
|
||||
reason: reason.value,
|
||||
simulationAccepted: form.elements.simulationAccepted.checked,
|
||||
};
|
||||
busy = true;
|
||||
update();
|
||||
status.textContent =
|
||||
"E-Mail wird geprüft und simulierte Anfrage gesendet …";
|
||||
try {
|
||||
const result = await post("/api/contact", input);
|
||||
if (result.redirectUrl !== contactBookingUrl)
|
||||
throw new Error("Die Weiterleitungsadresse ist ungültig.");
|
||||
sent = true;
|
||||
status.textContent =
|
||||
"Beide Bestätigungen wurden in Mailpit angenommen. Microsoft Bookings wird geöffnet. ";
|
||||
const link = document.createElement("a");
|
||||
link.href = contactBookingUrl;
|
||||
link.textContent = "Weiter zur Terminwahl";
|
||||
status.append(link);
|
||||
navigate(contactBookingUrl);
|
||||
} catch (error) {
|
||||
status.textContent = `${error.message} Bei einem Verbindungsfehler bitte vor erneutem Senden Mailpit prüfen.`;
|
||||
} finally {
|
||||
busy = false;
|
||||
update();
|
||||
}
|
||||
});
|
||||
close.addEventListener("click", () => dialog.close());
|
||||
dialog.addEventListener("cancel", (event) => {
|
||||
if (busy) event.preventDefault();
|
||||
});
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
dialog.addEventListener(
|
||||
"close",
|
||||
() => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
dialog.remove();
|
||||
returnFocus?.focus();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
document.body.append(dialog);
|
||||
document.body.style.overflow = "hidden";
|
||||
update();
|
||||
dialog.showModal();
|
||||
email.focus();
|
||||
return dialog;
|
||||
}
|
||||
import { findAdvisor, advisorDisplayName } from "../../shared/berater.js";
|
||||
import {
|
||||
contactReasons,
|
||||
contactNotice,
|
||||
contactBookingUrl,
|
||||
} from "../../shared/contact.js";
|
||||
import "./contact.css";
|
||||
|
||||
// Auf Profilseiten: openContactDialog({ advisorId: profile.id }).
|
||||
export function openContactDialog({
|
||||
advisorId,
|
||||
navigate = (url) => window.location.assign(url),
|
||||
} = {}) {
|
||||
const advisor = findAdvisor(advisorId);
|
||||
const returnFocus = document.activeElement;
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.className = "contact-dialog";
|
||||
dialog.setAttribute("aria-labelledby", "contact-title");
|
||||
dialog.innerHTML = `
|
||||
<button class="contact-close" type="button" aria-label="Kontaktfenster schließen">×</button>
|
||||
<p class="contact-eyebrow">DEIN NÄCHSTER SCHRITT</p>
|
||||
<h2 id="contact-title"></h2>
|
||||
<p class="contact-advisor"></p>
|
||||
<form class="contact-form">
|
||||
<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">
|
||||
<p id="contact-email-help" class="contact-help">Verwende die E-Mail-Adresse, mit der du dein Paket gebucht hast.</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<label class="contact-consent"><input name="simulationAccepted" type="checkbox" required><span></span></label>
|
||||
<label class="contact-consent"><input name="consent" type="checkbox" required><span>Ich akzeptiere die <a href="https://www.tri-hub.de/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> (Pflichtfeld).</span></label>
|
||||
<p class="contact-status" role="status" aria-live="polite"></p>
|
||||
<button class="contact-submit" type="submit" disabled>Anfrage senden & Termin wählen</button>
|
||||
</form>`;
|
||||
const advisorName = advisorDisplayName(advisor);
|
||||
dialog.querySelector("#contact-title").textContent = advisorName
|
||||
? `${advisorName} kontaktieren`
|
||||
: "Berater nicht gefunden";
|
||||
dialog.querySelector(".contact-advisor").textContent = advisor
|
||||
? "Sende deine Kontaktanfrage."
|
||||
: "Die Berater-ID fehlt oder ist unbekannt. Bitte öffne das Fenster über ein Beraterprofil.";
|
||||
dialog.querySelector(".contact-consent span").textContent = contactNotice;
|
||||
const form = dialog.querySelector("form");
|
||||
const email = form.elements.email;
|
||||
const reason = form.elements.reason;
|
||||
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 close = dialog.querySelector(".contact-close");
|
||||
let busy = false;
|
||||
let sent = false;
|
||||
function update() {
|
||||
submit.disabled = busy || sent || !advisor;
|
||||
close.disabled = busy;
|
||||
email.disabled = busy || sent;
|
||||
reason.disabled = busy || sent;
|
||||
form.elements.consent.disabled = busy || sent;
|
||||
form.elements.simulationAccepted.disabled = busy || sent;
|
||||
}
|
||||
async function post(path, input) {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
result.error?.message || "Die Anfrage ist fehlgeschlagen.",
|
||||
);
|
||||
return result;
|
||||
}
|
||||
email.addEventListener("input", () => {
|
||||
status.textContent = "";
|
||||
});
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (submit.disabled || !form.reportValidity()) return;
|
||||
const input = {
|
||||
email: email.value,
|
||||
advisorId,
|
||||
reason: reason.value,
|
||||
consent: form.elements.consent.checked,
|
||||
simulationAccepted: form.elements.simulationAccepted.checked,
|
||||
};
|
||||
busy = true;
|
||||
update();
|
||||
status.textContent =
|
||||
"E-Mail wird geprüft und simulierte Anfrage gesendet …";
|
||||
try {
|
||||
const result = await post("/api/contact", input);
|
||||
if (result.redirectUrl !== contactBookingUrl)
|
||||
throw new Error("Die Weiterleitungsadresse ist ungültig.");
|
||||
sent = true;
|
||||
status.textContent =
|
||||
"Beide Bestätigungen wurden in Mailpit angenommen. Microsoft Bookings wird geöffnet. ";
|
||||
const link = document.createElement("a");
|
||||
link.href = contactBookingUrl;
|
||||
link.textContent = "Weiter zur Terminwahl";
|
||||
status.append(link);
|
||||
navigate(contactBookingUrl);
|
||||
} catch (error) {
|
||||
status.textContent = `${error.message} Bei einem Verbindungsfehler bitte vor erneutem Senden Mailpit prüfen.`;
|
||||
} finally {
|
||||
busy = false;
|
||||
update();
|
||||
}
|
||||
});
|
||||
close.addEventListener("click", () => dialog.close());
|
||||
dialog.addEventListener("cancel", (event) => {
|
||||
if (busy) event.preventDefault();
|
||||
});
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
dialog.addEventListener(
|
||||
"close",
|
||||
() => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
dialog.remove();
|
||||
returnFocus?.focus();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
document.body.append(dialog);
|
||||
document.body.style.overflow = "hidden";
|
||||
update();
|
||||
dialog.showModal();
|
||||
email.focus();
|
||||
return dialog;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,70 +1,73 @@
|
|||
const form = document.querySelector("#contactForm");
|
||||
const status = document.querySelector("#contactFormStatus");
|
||||
const message = form?.elements.message;
|
||||
const messageHint = document.querySelector("#contactMessageHint");
|
||||
|
||||
function updateMessageHint() {
|
||||
if (!message || !messageHint) return;
|
||||
const valid = [...message.value.trim()].length >= 10;
|
||||
messageHint.classList.toggle("is-valid", valid);
|
||||
messageHint.textContent = valid
|
||||
? "Mindestens 10 Zeichen erreicht."
|
||||
: "Mindestens 10 Zeichen erforderlich.";
|
||||
}
|
||||
|
||||
function showErrors(fields = {}) {
|
||||
for (const input of form.elements) {
|
||||
if (!input.name) continue;
|
||||
input.removeAttribute("aria-invalid");
|
||||
const error = form.querySelector(`[data-error-for="${input.name}"]`);
|
||||
if (error) error.textContent = fields[input.name] ?? "";
|
||||
if (fields[input.name]) input.setAttribute("aria-invalid", "true");
|
||||
}
|
||||
}
|
||||
|
||||
message?.addEventListener("input", updateMessageHint);
|
||||
updateMessageHint();
|
||||
|
||||
form?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
status.textContent = "";
|
||||
status.classList.remove("is-error");
|
||||
showErrors();
|
||||
|
||||
if (!form.reportValidity()) {
|
||||
status.textContent = "Bitte prüfe die markierten Eingaben.";
|
||||
status.classList.add("is-error");
|
||||
return;
|
||||
}
|
||||
|
||||
const submit = form.querySelector('[type="submit"]');
|
||||
submit.disabled = true;
|
||||
submit.textContent = "Wird gesendet …";
|
||||
try {
|
||||
const response = await fetch("/api/nutrition-contact", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
showErrors(result.error?.fields);
|
||||
throw new Error(
|
||||
result.error?.message ??
|
||||
"Die Nachricht konnte nicht gesendet werden.",
|
||||
);
|
||||
}
|
||||
form.reset();
|
||||
updateMessageHint();
|
||||
status.textContent =
|
||||
"Danke für deine Nachricht. Wir melden uns schnellstmöglich bei dir.";
|
||||
} catch (error) {
|
||||
status.textContent =
|
||||
error.message ||
|
||||
"Der Server ist gerade nicht erreichbar. Bitte versuche es erneut.";
|
||||
status.classList.add("is-error");
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
submit.textContent = "Nachricht senden";
|
||||
}
|
||||
});
|
||||
const form = document.querySelector("#contactForm");
|
||||
const status = document.querySelector("#contactFormStatus");
|
||||
const message = form?.elements.message;
|
||||
const messageHint = document.querySelector("#contactMessageHint");
|
||||
|
||||
function updateMessageHint() {
|
||||
if (!message || !messageHint) return;
|
||||
const valid = [...message.value.trim()].length >= 10;
|
||||
messageHint.classList.toggle("is-valid", valid);
|
||||
messageHint.textContent = valid
|
||||
? "Mindestens 10 Zeichen erreicht."
|
||||
: "Mindestens 10 Zeichen erforderlich.";
|
||||
}
|
||||
|
||||
function showErrors(fields = {}) {
|
||||
for (const input of form.elements) {
|
||||
if (!input.name) continue;
|
||||
input.removeAttribute("aria-invalid");
|
||||
const error = form.querySelector(`[data-error-for="${input.name}"]`);
|
||||
if (error) error.textContent = fields[input.name] ?? "";
|
||||
if (fields[input.name]) input.setAttribute("aria-invalid", "true");
|
||||
}
|
||||
}
|
||||
|
||||
message?.addEventListener("input", updateMessageHint);
|
||||
updateMessageHint();
|
||||
|
||||
form?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
status.textContent = "";
|
||||
status.classList.remove("is-error");
|
||||
showErrors();
|
||||
|
||||
if (!form.reportValidity()) {
|
||||
status.textContent = "Bitte prüfe die markierten Eingaben.";
|
||||
status.classList.add("is-error");
|
||||
return;
|
||||
}
|
||||
|
||||
const submit = form.querySelector('[type="submit"]');
|
||||
submit.disabled = true;
|
||||
submit.textContent = "Wird gesendet …";
|
||||
try {
|
||||
const response = await fetch("/api/nutrition-contact", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...Object.fromEntries(new FormData(form)),
|
||||
consent: form.elements.consent.checked,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
showErrors(result.error?.fields);
|
||||
throw new Error(
|
||||
result.error?.message ??
|
||||
"Die Nachricht konnte nicht gesendet werden.",
|
||||
);
|
||||
}
|
||||
form.reset();
|
||||
updateMessageHint();
|
||||
status.textContent =
|
||||
"Danke für deine Nachricht. Wir melden uns schnellstmöglich bei dir.";
|
||||
} catch (error) {
|
||||
status.textContent =
|
||||
error.message ||
|
||||
"Der Server ist gerade nicht erreichbar. Bitte versuche es erneut.";
|
||||
status.classList.add("is-error");
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
submit.textContent = "Nachricht senden";
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,150 +1,155 @@
|
|||
@font-face {
|
||||
font-family: "DM Sans";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/fonts/dm-sans-latin-400-normal.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "DM Sans";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/fonts/dm-sans-latin-500-normal.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "DM Sans";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/fonts/dm-sans-latin-700-normal.woff2") format("woff2");
|
||||
}
|
||||
.contact-dialog {
|
||||
--contact-foreground: oklch(96% 0.008 210);
|
||||
--contact-accent: oklch(70% 0.12 205);
|
||||
box-sizing: border-box;
|
||||
width: min(32rem, calc(100vw - 2rem));
|
||||
max-height: calc(100dvh - 2rem);
|
||||
margin: auto;
|
||||
padding: 1.75rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #324449;
|
||||
border-radius: 1.25rem;
|
||||
background: #071316;
|
||||
color: var(--contact-foreground);
|
||||
font:
|
||||
400 16px/1.5 "DM Sans",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
box-shadow: 0 1.5rem 4rem #0006;
|
||||
}
|
||||
.contact-dialog::backdrop {
|
||||
background: rgb(0 0 0 / 65%);
|
||||
}
|
||||
.contact-dialog * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.contact-dialog h2 {
|
||||
margin: 8px 32px 8px 0;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.contact-dialog p {
|
||||
margin: 0;
|
||||
}
|
||||
.contact-dialog .contact-eyebrow {
|
||||
color: var(--contact-accent);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.contact-advisor,
|
||||
.contact-help,
|
||||
.contact-consent {
|
||||
color: oklch(72% 0.015 214);
|
||||
}
|
||||
.contact-form {
|
||||
margin-top: 32px;
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
.contact-form label {
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.contact-form input:not([type="checkbox"]),
|
||||
.contact-form select {
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.65rem 0.8rem;
|
||||
border: 1px solid #61777d;
|
||||
border-radius: 0.75rem;
|
||||
background: #101e22;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
color-scheme: dark;
|
||||
}
|
||||
.contact-dialog button {
|
||||
min-height: 2.75rem;
|
||||
padding: 0.65rem 0.8rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.75rem;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.contact-dialog .contact-close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
padding: 0;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 28px;
|
||||
}
|
||||
.contact-submit {
|
||||
width: 100%;
|
||||
background: #53d5cd;
|
||||
color: oklch(20% 0.03 220);
|
||||
}
|
||||
.contact-submit:hover:not(:disabled) {
|
||||
background: #6de0d9;
|
||||
}
|
||||
|
||||
.contact-dialog :disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.contact-dialog :focus-visible {
|
||||
outline: 3px solid #53d5cd;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
.contact-dialog .contact-help {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.contact-form .contact-consent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.contact-consent input {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 2px 0 0;
|
||||
accent-color: var(--contact-accent);
|
||||
}
|
||||
.contact-status {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.contact-status:empty {
|
||||
display: none;
|
||||
}
|
||||
.contact-status a {
|
||||
color: var(--contact-accent);
|
||||
}
|
||||
@font-face {
|
||||
font-family: "DM Sans";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/fonts/dm-sans-latin-400-normal.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "DM Sans";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/fonts/dm-sans-latin-500-normal.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "DM Sans";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/fonts/dm-sans-latin-700-normal.woff2") format("woff2");
|
||||
}
|
||||
.contact-dialog {
|
||||
--contact-foreground: oklch(96% 0.008 210);
|
||||
--contact-accent: oklch(70% 0.12 205);
|
||||
box-sizing: border-box;
|
||||
width: min(32rem, calc(100vw - 2rem));
|
||||
max-height: calc(100dvh - 2rem);
|
||||
margin: auto;
|
||||
padding: 1.75rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #324449;
|
||||
border-radius: 1.25rem;
|
||||
background: #071316;
|
||||
color: var(--contact-foreground);
|
||||
font:
|
||||
400 16px/1.5 "DM Sans",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
box-shadow: 0 1.5rem 4rem #0006;
|
||||
}
|
||||
.contact-dialog::backdrop {
|
||||
background: rgb(0 0 0 / 65%);
|
||||
}
|
||||
.contact-dialog * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.contact-dialog h2 {
|
||||
margin: 8px 32px 8px 0;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.contact-dialog p {
|
||||
margin: 0;
|
||||
}
|
||||
.contact-dialog .contact-eyebrow {
|
||||
color: var(--contact-accent);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.contact-advisor,
|
||||
.contact-help,
|
||||
.contact-consent {
|
||||
color: oklch(72% 0.015 214);
|
||||
}
|
||||
.contact-form {
|
||||
margin-top: 32px;
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
.contact-form label {
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.contact-form input:not([type="checkbox"]),
|
||||
.contact-form select {
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.65rem 0.8rem;
|
||||
border: 1px solid #61777d;
|
||||
border-radius: 0.75rem;
|
||||
background: #101e22;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
color-scheme: dark;
|
||||
}
|
||||
.contact-dialog button {
|
||||
min-height: 2.75rem;
|
||||
padding: 0.65rem 0.8rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.75rem;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.contact-dialog .contact-close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
padding: 0;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 28px;
|
||||
}
|
||||
.contact-submit {
|
||||
width: 100%;
|
||||
background: #53d5cd;
|
||||
color: oklch(20% 0.03 220);
|
||||
}
|
||||
.contact-submit:hover:not(:disabled) {
|
||||
background: #6de0d9;
|
||||
}
|
||||
|
||||
.contact-dialog :disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.contact-dialog :focus-visible {
|
||||
outline: 3px solid #53d5cd;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
.contact-dialog .contact-help {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.contact-form .contact-consent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.contact-consent input {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 2px 0 0;
|
||||
accent-color: var(--contact-accent);
|
||||
}
|
||||
.contact-status {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.contact-status:empty {
|
||||
display: none;
|
||||
}
|
||||
.contact-status a {
|
||||
color: var(--contact-accent);
|
||||
}
|
||||
|
||||
.contact-consent a {
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
bookingId,createdAt,packageId,packageName,name,email,emailStatus,idempotencyKey,requestHash,bookedPackage,customer,acceptedTerms
|
||||
TH-000001,2026-09-25T11:43:43.074Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,kk,kk@web.de,accepted,497662ce-278c-466f-9505-c2728f8973ca,d5c69de514e6de5552e43f99256a042b59df5bd53db0bd1685f849809a455ef9,,,
|
||||
TH-000002,2026-09-25T19:22:37.801Z,ernaehrung-starter,Für den sicheren Einstieg in die Sporternährung,Marvin,marvin@web.de,accepted,06b9ad79-05c3-4e37-9135-0c3cf1da3f36,7650a36bd436390b5f79dde1ea1e0d8944f1320b29bad927dfd71157c3f4133c,,,
|
||||
TH-000003,2026-09-25T19:52:53.927Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,M,test@web.de,accepted,9446e525-a714-4912-bb10-1906a75cc9cd,a8fbd181361e4de7713fb0c4b5e0bf88cd43acd3e16294e7b84b33d959551458,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,
|
||||
TH-000004,2026-09-25T19:54:52.744Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,m,test@web.de,accepted,1817271c-343e-4e27-ac60-9d225897ba28,4a9f445acdf5cef94e95d8cdf50e78f95884913799cd2f532aaa37302910a16b,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",,
|
||||
TH-000005,2026-09-25T20:14:51.874Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,Marvin,m@web.de,accepted,678654e5-7db9-49af-97b0-64fe02257e77,89c188f086a2d72fb77ae52612288610d2f17846fb1a3e62b11e8caa011cf2b4,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",,
|
||||
TH-000006,2026-09-26T08:44:42.313Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,Marvin,m@web.de,accepted,a0a8c426-fc56-4887-a0e5-42172ff9cfb2,b83cf7b2ef4c42447bd83038c60d2ca565a8b37d24cae128f4fa2214dc43b185,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,
|
||||
TH-000007,2026-09-26T08:50:50.112Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,k@web.de,accepted,92e12eff-321d-49e5-a9ad-674a7d61f9a7,14b1f6b3909696f8402be66b1de33956b84ca435f58567e57e31e7152b6bff6c,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,
|
||||
TH-000008,2026-09-26T09:18:17.493Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,marvin@test.de,accepted,c74460a8-0315-4e0b-9d6b-48c854b0f71b,19ebc08e678515ea3a71f9b295ac1fbbf188bf54602a531f4f3bdc53698dcda7,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,
|
||||
TH-000009,2026-09-26T09:43:16.704Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,marvin,m@email.de,accepted,9c18134f-3a71-428b-8769-fda7d06790c7,eaa9a3f0dfc568c726f1b6fc26d92f8c4cc7df072f9fdd40fbea581661eeef9f,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-24"",""variantLabel"":""24-Wochen-Variante"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":671.43,""taxRate"":19,""taxAmount"":127.57,""grossPrice"":799,""currency"":""EUR""}",,
|
||||
bookingId,createdAt,packageId,packageName,name,email,emailStatus,idempotencyKey,requestHash,bookedPackage,customer,acceptedTerms,consent
|
||||
TH-000001,2026-09-25T11:43:43.074Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,kk,kk@web.de,accepted,497662ce-278c-466f-9505-c2728f8973ca,d5c69de514e6de5552e43f99256a042b59df5bd53db0bd1685f849809a455ef9,,,,true
|
||||
TH-000002,2026-09-25T19:22:37.801Z,ernaehrung-starter,Für den sicheren Einstieg in die Sporternährung,Marvin,marvin@web.de,accepted,06b9ad79-05c3-4e37-9135-0c3cf1da3f36,7650a36bd436390b5f79dde1ea1e0d8944f1320b29bad927dfd71157c3f4133c,,,,true
|
||||
TH-000003,2026-09-25T19:52:53.927Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,M,test@web.de,accepted,9446e525-a714-4912-bb10-1906a75cc9cd,a8fbd181361e4de7713fb0c4b5e0bf88cd43acd3e16294e7b84b33d959551458,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
|
||||
TH-000004,2026-09-25T19:54:52.744Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,m,test@web.de,accepted,1817271c-343e-4e27-ac60-9d225897ba28,4a9f445acdf5cef94e95d8cdf50e78f95884913799cd2f532aaa37302910a16b,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",,,true
|
||||
TH-000005,2026-09-25T20:14:51.874Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,Marvin,m@web.de,accepted,678654e5-7db9-49af-97b0-64fe02257e77,89c188f086a2d72fb77ae52612288610d2f17846fb1a3e62b11e8caa011cf2b4,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",,,true
|
||||
TH-000006,2026-09-26T08:44:42.313Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,Marvin,m@web.de,accepted,a0a8c426-fc56-4887-a0e5-42172ff9cfb2,b83cf7b2ef4c42447bd83038c60d2ca565a8b37d24cae128f4fa2214dc43b185,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
|
||||
TH-000007,2026-09-26T08:50:50.112Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,k@web.de,accepted,92e12eff-321d-49e5-a9ad-674a7d61f9a7,14b1f6b3909696f8402be66b1de33956b84ca435f58567e57e31e7152b6bff6c,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
|
||||
TH-000008,2026-09-26T09:18:17.493Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,marvin@test.de,accepted,c74460a8-0315-4e0b-9d6b-48c854b0f71b,19ebc08e678515ea3a71f9b295ac1fbbf188bf54602a531f4f3bdc53698dcda7,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
|
||||
TH-000009,2026-09-26T09:43:16.704Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,marvin,m@email.de,accepted,9c18134f-3a71-428b-8769-fda7d06790c7,eaa9a3f0dfc568c726f1b6fc26d92f8c4cc7df072f9fdd40fbea581661eeef9f,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-24"",""variantLabel"":""24-Wochen-Variante"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":671.43,""taxRate"":19,""taxAmount"":127.57,""grossPrice"":799,""currency"":""EUR""}",,,true
|
||||
|
|
|
|||
|
|
|
@ -1,13 +1,13 @@
|
|||
createdAt,name,email,subject,message
|
||||
createdAt,name,email,subject,message,consent
|
||||
2026-09-29T18:51:35.694Z,Max Muster,test10@web.de,Erstgespräch für eine Ernährungsberatung,"Guten Tag,
|
||||
|
||||
ich möchte einen termin vereinbaren, können sie sich bei mir melden.
|
||||
|
||||
Mfg
|
||||
Max Muster"
|
||||
Max Muster",true
|
||||
2026-09-29T19:27:34.639Z,Test Test,test11@web.de,Erstgespräch Ernährung,"Guten Tag,
|
||||
|
||||
können sie sich bei mir melden.
|
||||
|
||||
Mfg
|
||||
Test"
|
||||
Test",true
|
||||
|
|
|
|||
|
|
|
@ -1,148 +1,168 @@
|
|||
import { createContactService } from "./services/contact-service.js";
|
||||
import { createContactEmailService } from "./services/contact-email.js";
|
||||
import { handleContact } from "./routes/contact.js";
|
||||
import { createServer } from "node:http";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { packages } from "../shared/packages.js";
|
||||
import {
|
||||
BookingError,
|
||||
createBookingService,
|
||||
} from "./services/booking-service.js";
|
||||
import { createCsvStorage } from "./services/csv-storage.js";
|
||||
import { createEmailService } from "./services/email-service.js";
|
||||
import { handleBooking, json } from "./routes/bookings.js";
|
||||
import { handleContactForm } from "./routes/contact-form.js";
|
||||
import { createContactFormService } from "./services/contact-form-service.js";
|
||||
import { createContactFormStorage } from "./services/contact-form-storage.js";
|
||||
import { createContactFormEmailService } from "./services/contact-form-email.js";
|
||||
|
||||
export function createApp({
|
||||
catalog = packages,
|
||||
storage = createCsvStorage(
|
||||
fileURLToPath(new URL("./data/bookings.csv", import.meta.url)),
|
||||
),
|
||||
details,
|
||||
sendConfirmation,
|
||||
sendContact,
|
||||
contactFormStorage = createContactFormStorage(
|
||||
fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)),
|
||||
),
|
||||
sendContactForm,
|
||||
} = {}) {
|
||||
const ids = new Set();
|
||||
for (const entry of catalog) {
|
||||
if (
|
||||
!entry ||
|
||||
typeof entry.id !== "string" ||
|
||||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id) ||
|
||||
entry.id.length > 80 ||
|
||||
ids.has(entry.id) ||
|
||||
typeof entry.name !== "string" ||
|
||||
!entry.name.trim() ||
|
||||
typeof entry.summary !== "string" ||
|
||||
!entry.summary.trim()
|
||||
) {
|
||||
throw new Error(
|
||||
"Paketdaten erfüllen den Vertrag in src/shared/packages.js nicht.",
|
||||
);
|
||||
}
|
||||
ids.add(entry.id);
|
||||
}
|
||||
const service = createBookingService({
|
||||
storage,
|
||||
details,
|
||||
packages: catalog,
|
||||
sendConfirmation: sendConfirmation ?? createEmailService(),
|
||||
});
|
||||
const contact = createContactService({
|
||||
storage,
|
||||
sendContact: sendContact ?? createContactEmailService(),
|
||||
});
|
||||
// Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand.
|
||||
const contactForm = createContactFormService({
|
||||
storage: contactFormStorage,
|
||||
sendContactForm: sendContactForm ?? createContactFormEmailService(),
|
||||
});
|
||||
return createServer(async (request, response) => {
|
||||
const path = new URL(request.url, "http://localhost").pathname;
|
||||
// Leitet die Ernährungsseite an ihren eigenen Kontaktformular-Endpunkt.
|
||||
if (path === "/api/nutrition-contact") {
|
||||
return handleContactForm(request, response, contactForm);
|
||||
}
|
||||
if (path === "/api/contact" || path === "/api/contact/validate") {
|
||||
return handleContact(
|
||||
request,
|
||||
response,
|
||||
contact,
|
||||
path.endsWith("/validate"),
|
||||
);
|
||||
}
|
||||
if (path === "/api/angebote" && request.method === "GET") {
|
||||
return json(response, 200, {
|
||||
packages: catalog.map(({ id, name, summary }) => ({
|
||||
id,
|
||||
name,
|
||||
summary,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (path === "/api/packages" && request.method === "GET") {
|
||||
return json(
|
||||
response,
|
||||
200,
|
||||
catalog.map(({ name, summary, ...entry }) => ({
|
||||
...entry,
|
||||
title: name,
|
||||
description: summary,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if (path.startsWith("/api/packages/") && request.method === "GET") {
|
||||
try {
|
||||
return json(
|
||||
response,
|
||||
200,
|
||||
service.getPackage(path.slice("/api/packages/".length)),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof BookingError)) throw error;
|
||||
return json(response, error.status, {
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
fields: error.fields,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if (path === "/api/bookings" || path === "/api/bookings/preview") {
|
||||
if (request.method !== "POST") {
|
||||
response.setHeader("Allow", "POST");
|
||||
return json(response, 405, {
|
||||
error: {
|
||||
code: "METHOD_NOT_ALLOWED",
|
||||
message: "Bitte POST verwenden.",
|
||||
},
|
||||
});
|
||||
}
|
||||
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}`);
|
||||
});
|
||||
}
|
||||
import { createReviewStorage } from "./services/review-storage.js";
|
||||
import { handleReview } from "./routes/reviews.js";
|
||||
import { createContactService } from "./services/contact-service.js";
|
||||
import { createContactEmailService } from "./services/contact-email.js";
|
||||
import { handleContact } from "./routes/contact.js";
|
||||
import { createServer } from "node:http";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { packages } from "../shared/packages.js";
|
||||
import {
|
||||
BookingError,
|
||||
createBookingService,
|
||||
} from "./services/booking-service.js";
|
||||
import { createCsvStorage } from "./services/csv-storage.js";
|
||||
import { createEmailService } from "./services/email-service.js";
|
||||
import { handleBooking, json } from "./routes/bookings.js";
|
||||
import { handleContactForm } from "./routes/contact-form.js";
|
||||
import { createContactFormService } from "./services/contact-form-service.js";
|
||||
import { createContactFormStorage } from "./services/contact-form-storage.js";
|
||||
import { createContactFormEmailService } from "./services/contact-form-email.js";
|
||||
|
||||
export function createApp({
|
||||
catalog = packages,
|
||||
storage = createCsvStorage(
|
||||
fileURLToPath(new URL("./data/bookings.csv", import.meta.url)),
|
||||
),
|
||||
reviewStorage = createReviewStorage(
|
||||
fileURLToPath(new URL("./data/reviews.csv", import.meta.url)),
|
||||
),
|
||||
details,
|
||||
sendConfirmation,
|
||||
sendContact,
|
||||
contactFormStorage = createContactFormStorage(
|
||||
fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)),
|
||||
),
|
||||
sendContactForm,
|
||||
advisorContactStorage = createContactFormStorage(
|
||||
fileURLToPath(new URL("./data/advisor-contacts.csv", import.meta.url)),
|
||||
[
|
||||
"createdAt",
|
||||
"email",
|
||||
"advisorId",
|
||||
"reason",
|
||||
"simulationAccepted",
|
||||
"consent",
|
||||
],
|
||||
),
|
||||
} = {}) {
|
||||
const ids = new Set();
|
||||
for (const entry of catalog) {
|
||||
if (
|
||||
!entry ||
|
||||
typeof entry.id !== "string" ||
|
||||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id) ||
|
||||
entry.id.length > 80 ||
|
||||
ids.has(entry.id) ||
|
||||
typeof entry.name !== "string" ||
|
||||
!entry.name.trim() ||
|
||||
typeof entry.summary !== "string" ||
|
||||
!entry.summary.trim()
|
||||
) {
|
||||
throw new Error(
|
||||
"Paketdaten erfüllen den Vertrag in src/shared/packages.js nicht.",
|
||||
);
|
||||
}
|
||||
ids.add(entry.id);
|
||||
}
|
||||
const service = createBookingService({
|
||||
storage,
|
||||
details,
|
||||
packages: catalog,
|
||||
sendConfirmation: sendConfirmation ?? createEmailService(),
|
||||
});
|
||||
const contact = createContactService({
|
||||
storage,
|
||||
requestStorage: advisorContactStorage,
|
||||
sendContact: sendContact ?? createContactEmailService(),
|
||||
});
|
||||
// Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand.
|
||||
const contactForm = createContactFormService({
|
||||
storage: contactFormStorage,
|
||||
sendContactForm: sendContactForm ?? createContactFormEmailService(),
|
||||
});
|
||||
return createServer(async (request, response) => {
|
||||
const path = new URL(request.url, "http://localhost").pathname;
|
||||
if (path === "/api/reviews") {
|
||||
return handleReview(request, response, storage, reviewStorage);
|
||||
}
|
||||
// Leitet die Ernährungsseite an ihren eigenen Kontaktformular-Endpunkt.
|
||||
if (path === "/api/nutrition-contact") {
|
||||
return handleContactForm(request, response, contactForm);
|
||||
}
|
||||
if (path === "/api/contact" || path === "/api/contact/validate") {
|
||||
return handleContact(
|
||||
request,
|
||||
response,
|
||||
contact,
|
||||
path.endsWith("/validate"),
|
||||
);
|
||||
}
|
||||
if (path === "/api/angebote" && request.method === "GET") {
|
||||
return json(response, 200, {
|
||||
packages: catalog.map(({ id, name, summary }) => ({
|
||||
id,
|
||||
name,
|
||||
summary,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (path === "/api/packages" && request.method === "GET") {
|
||||
return json(
|
||||
response,
|
||||
200,
|
||||
catalog.map(({ name, summary, ...entry }) => ({
|
||||
...entry,
|
||||
title: name,
|
||||
description: summary,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if (path.startsWith("/api/packages/") && request.method === "GET") {
|
||||
try {
|
||||
return json(
|
||||
response,
|
||||
200,
|
||||
service.getPackage(path.slice("/api/packages/".length)),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof BookingError)) throw error;
|
||||
return json(response, error.status, {
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
fields: error.fields,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if (path === "/api/bookings" || path === "/api/bookings/preview") {
|
||||
if (request.method !== "POST") {
|
||||
response.setHeader("Allow", "POST");
|
||||
return json(response, 405, {
|
||||
error: {
|
||||
code: "METHOD_NOT_ALLOWED",
|
||||
message: "Bitte POST verwenden.",
|
||||
},
|
||||
});
|
||||
}
|
||||
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}`);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,367 +1,370 @@
|
|||
import {
|
||||
isValidBookingPhone,
|
||||
phoneError,
|
||||
} from "../../shared/phone-validation.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { packagesDetails } from "../../shared/packagesdetails.js";
|
||||
|
||||
export class BookingError extends Error {
|
||||
constructor(status, code, message, fields = {}) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.fields = fields;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
// Übliche unquotierte E-Mail-Adressen; einzelne Domainlabels maximal 63 Zeichen.
|
||||
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;
|
||||
|
||||
function validateInput(input, key) {
|
||||
if (!uuidPattern.test(key ?? "")) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_KEY",
|
||||
"Ein gültiger Idempotency-Key (UUID v4) ist erforderlich.",
|
||||
);
|
||||
}
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_INPUT",
|
||||
"Erwartet wird ein JSON-Objekt.",
|
||||
);
|
||||
}
|
||||
let customer;
|
||||
if (Object.hasOwn(input, "customer")) {
|
||||
if (
|
||||
!input.customer ||
|
||||
typeof input.customer !== "object" ||
|
||||
Array.isArray(input.customer) ||
|
||||
input.acceptedTerms !== true
|
||||
) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_INPUT",
|
||||
"Kundendaten und Zustimmung zu den Bedingungen sind erforderlich.",
|
||||
);
|
||||
}
|
||||
customer = {};
|
||||
for (const [field, max, required] of [
|
||||
["firstName", 60, true],
|
||||
["lastName", 60, true],
|
||||
["email", 254, true],
|
||||
["phone", 50, false],
|
||||
["ageGroup", 40, false],
|
||||
["notes", 2000, false],
|
||||
]) {
|
||||
const value = input.customer[field];
|
||||
if (value === undefined && !required) continue;
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value.length > max ||
|
||||
(required && !value.trim()) ||
|
||||
/[\x00-\x1f\x7f]/.test(value)
|
||||
) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_INPUT",
|
||||
"Bitte die Kundendaten prüfen.",
|
||||
{ [field]: "Ungültige Angabe." },
|
||||
);
|
||||
}
|
||||
customer[field] = value.trim();
|
||||
}
|
||||
input = {
|
||||
...input,
|
||||
name: `${customer.firstName} ${customer.lastName}`,
|
||||
email: customer.email,
|
||||
};
|
||||
}
|
||||
const fields = {};
|
||||
const result = {};
|
||||
for (const [field, max, message] of [
|
||||
["packageId", 80, "Bitte ein gültiges Paket auswählen."],
|
||||
["name", 120, "Bitte einen Namen mit höchstens 120 Zeichen eingeben."],
|
||||
[
|
||||
"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;
|
||||
} else {
|
||||
result[field] = value.trim();
|
||||
}
|
||||
}
|
||||
if (
|
||||
result.packageId &&
|
||||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(result.packageId)
|
||||
) {
|
||||
fields.packageId = "Die Paket-ID ist ungültig.";
|
||||
}
|
||||
if (result.name && /[\x00-\x1f\x7f]/.test(result.name)) {
|
||||
fields.name =
|
||||
"Bitte den Namen ohne Zeilenumbrüche oder Steuerzeichen eingeben.";
|
||||
}
|
||||
if (
|
||||
result.email &&
|
||||
(!emailPattern.test(result.email) ||
|
||||
result.email.split("@")[0].length > 64)
|
||||
) {
|
||||
fields.email = "Bitte eine gültige E-Mail-Adresse eingeben.";
|
||||
}
|
||||
const phone = customer ? customer.phone : input.phone;
|
||||
if (phone !== undefined) {
|
||||
if (!isValidBookingPhone(phone)) {
|
||||
fields.phone = phoneError;
|
||||
} else if (!customer && phone.trim()) {
|
||||
result.phone = phone.trim();
|
||||
}
|
||||
}
|
||||
if (Object.keys(fields).length) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_INPUT",
|
||||
"Bitte die markierten Angaben prüfen.",
|
||||
fields,
|
||||
);
|
||||
}
|
||||
if (input.variantId != null) {
|
||||
if (
|
||||
typeof input.variantId !== "string" ||
|
||||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.variantId) ||
|
||||
input.variantId.length > 80
|
||||
) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_VARIANT",
|
||||
"Bitte eine gültige Variante auswählen.",
|
||||
);
|
||||
}
|
||||
result.variantId = input.variantId;
|
||||
}
|
||||
if (customer) {
|
||||
result.customer = customer;
|
||||
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) {
|
||||
let highest = 0n;
|
||||
for (const record of records) {
|
||||
const match = /^TH-([0-9]{6,})$/.exec(record.bookingId);
|
||||
if (match) {
|
||||
const number = BigInt(match[1]);
|
||||
if (number > highest) highest = number;
|
||||
}
|
||||
}
|
||||
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 =
|
||||
record.emailStatus === "sending" ? "unknown" : record.emailStatus;
|
||||
return {
|
||||
status: emailStatus === "accepted" ? (replayed ? 200 : 201) : 202,
|
||||
body: {
|
||||
bookingId: record.bookingId,
|
||||
createdAt: record.createdAt,
|
||||
packageId: record.packageId,
|
||||
packageName: record.packageName,
|
||||
bookedPackage: record.bookedPackage || null,
|
||||
customerEmail: record.email,
|
||||
status: "CONFIRMED",
|
||||
saved: true,
|
||||
emailStatus,
|
||||
replayed,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createBookingService({
|
||||
storage,
|
||||
sendConfirmation,
|
||||
packages,
|
||||
details = packagesDetails,
|
||||
}) {
|
||||
function getPackage(id) {
|
||||
const selected =
|
||||
packages.some((entry) => entry.id === id) &&
|
||||
details.find((entry) => entry.id === id);
|
||||
if (!selected)
|
||||
throw new BookingError(
|
||||
404,
|
||||
"UNKNOWN_PACKAGE",
|
||||
"Dieses Paket ist nicht verfügbar. Bitte wähle ein Paket auf der Angebotsseite aus.",
|
||||
);
|
||||
return structuredClone(selected);
|
||||
}
|
||||
function preview(input) {
|
||||
if (
|
||||
!input ||
|
||||
typeof input !== "object" ||
|
||||
Array.isArray(input) ||
|
||||
typeof input.packageId !== "string"
|
||||
) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_INPUT",
|
||||
"Bitte ein gültiges Paket auswählen.",
|
||||
);
|
||||
}
|
||||
let selected;
|
||||
try {
|
||||
selected = getPackage(input.packageId);
|
||||
} catch (error) {
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
const variants = selected.variants || [];
|
||||
const variantId = input.variantId ?? variants[0]?.variantId ?? null;
|
||||
const variant = variants.find((entry) => entry.variantId === variantId);
|
||||
if (
|
||||
(variants.length && !variant) ||
|
||||
(!variants.length &&
|
||||
variantId !== null &&
|
||||
variantId !== selected.id)
|
||||
) {
|
||||
throw new BookingError(
|
||||
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 {
|
||||
packageId: selected.id,
|
||||
variantId: variant?.variantId ?? null,
|
||||
variantLabel: variant?.label ?? null,
|
||||
type: selected.type,
|
||||
title: selected.title,
|
||||
subtitle: selected.subtitle,
|
||||
summaryForBooking: selected.summaryForBooking,
|
||||
durationWeeks: variant?.durationWeeks ?? selected.durationWeeks,
|
||||
durationLabel: variant
|
||||
? `${variant.durationWeeks} Wochen Begleitung`
|
||||
: selected.durationLabel,
|
||||
quantity: 1,
|
||||
billingInterval: selected.billingInterval,
|
||||
includedFeatures: selected.includedFeatures,
|
||||
processSteps: selected.processSteps,
|
||||
netPrice: netCents / 100,
|
||||
taxRate: selected.taxRate,
|
||||
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) {
|
||||
const data = validateInput(input, key);
|
||||
const requestHash = createHash("sha256")
|
||||
.update(JSON.stringify(data))
|
||||
.digest("hex");
|
||||
let records;
|
||||
try {
|
||||
records = await storage.readAll();
|
||||
} catch {
|
||||
throw new BookingError(
|
||||
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) {
|
||||
throw new BookingError(
|
||||
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) {
|
||||
const selected = packages.find(
|
||||
(entry) => entry.id === data.packageId,
|
||||
);
|
||||
if (!selected) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"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),
|
||||
createdAt: new Date().toISOString(),
|
||||
packageId: selected.id,
|
||||
packageName: selected.name,
|
||||
bookedPackage,
|
||||
customer: data.customer ?? null,
|
||||
acceptedTerms: data.acceptedTerms ?? null,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
phone: data.customer?.phone ?? data.phone ?? "",
|
||||
emailStatus: "pending",
|
||||
idempotencyKey: key,
|
||||
requestHash,
|
||||
};
|
||||
records.push(record);
|
||||
try {
|
||||
await storage.writeAll(records);
|
||||
} catch {
|
||||
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 {
|
||||
await storage.writeAll(records);
|
||||
} catch {
|
||||
record.emailStatus = "pending";
|
||||
return responseFor(record, replayed);
|
||||
}
|
||||
try {
|
||||
await sendConfirmation(record);
|
||||
record.emailStatus = "accepted";
|
||||
} catch (error) {
|
||||
record.emailStatus = error.deliveryUnknown ? "unknown" : "failed";
|
||||
}
|
||||
try {
|
||||
await storage.writeAll(records);
|
||||
} catch {
|
||||
record.emailStatus = "unknown";
|
||||
}
|
||||
return responseFor(record, replayed);
|
||||
}
|
||||
return {
|
||||
getPackage,
|
||||
preview,
|
||||
book(input, key) {
|
||||
const task = queue.then(() => processBooking(input, key));
|
||||
queue = task.catch(() => {});
|
||||
return task;
|
||||
},
|
||||
};
|
||||
}
|
||||
import {
|
||||
isValidBookingPhone,
|
||||
phoneError,
|
||||
} from "../../shared/phone-validation.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { packagesDetails } from "../../shared/packagesdetails.js";
|
||||
|
||||
export class BookingError extends Error {
|
||||
constructor(status, code, message, fields = {}) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.fields = fields;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
// Übliche unquotierte E-Mail-Adressen; einzelne Domainlabels maximal 63 Zeichen.
|
||||
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;
|
||||
|
||||
function validateInput(input, key) {
|
||||
if (!uuidPattern.test(key ?? "")) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_KEY",
|
||||
"Ein gültiger Idempotency-Key (UUID v4) ist erforderlich.",
|
||||
);
|
||||
}
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_INPUT",
|
||||
"Erwartet wird ein JSON-Objekt.",
|
||||
);
|
||||
}
|
||||
let customer;
|
||||
if (Object.hasOwn(input, "customer")) {
|
||||
if (
|
||||
!input.customer ||
|
||||
typeof input.customer !== "object" ||
|
||||
Array.isArray(input.customer) ||
|
||||
input.acceptedTerms !== true
|
||||
) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_INPUT",
|
||||
"Kundendaten und Zustimmung zu den Bedingungen sind erforderlich.",
|
||||
);
|
||||
}
|
||||
customer = {};
|
||||
for (const [field, max, required] of [
|
||||
["firstName", 60, true],
|
||||
["lastName", 60, true],
|
||||
["email", 254, true],
|
||||
["phone", 50, false],
|
||||
["ageGroup", 40, false],
|
||||
["notes", 2000, false],
|
||||
]) {
|
||||
const value = input.customer[field];
|
||||
if (value === undefined && !required) continue;
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value.length > max ||
|
||||
(required && !value.trim()) ||
|
||||
/[\x00-\x1f\x7f]/.test(value)
|
||||
) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_INPUT",
|
||||
"Bitte die Kundendaten prüfen.",
|
||||
{ [field]: "Ungültige Angabe." },
|
||||
);
|
||||
}
|
||||
customer[field] = value.trim();
|
||||
}
|
||||
input = {
|
||||
...input,
|
||||
name: `${customer.firstName} ${customer.lastName}`,
|
||||
email: customer.email,
|
||||
};
|
||||
}
|
||||
const fields = {};
|
||||
if (input.consent !== true)
|
||||
fields.consent = "Bitte die Datenschutzbestimmungen akzeptieren.";
|
||||
const result = { consent: true };
|
||||
for (const [field, max, message] of [
|
||||
["packageId", 80, "Bitte ein gültiges Paket auswählen."],
|
||||
["name", 120, "Bitte einen Namen mit höchstens 120 Zeichen eingeben."],
|
||||
[
|
||||
"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;
|
||||
} else {
|
||||
result[field] = value.trim();
|
||||
}
|
||||
}
|
||||
if (
|
||||
result.packageId &&
|
||||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(result.packageId)
|
||||
) {
|
||||
fields.packageId = "Die Paket-ID ist ungültig.";
|
||||
}
|
||||
if (result.name && /[\x00-\x1f\x7f]/.test(result.name)) {
|
||||
fields.name =
|
||||
"Bitte den Namen ohne Zeilenumbrüche oder Steuerzeichen eingeben.";
|
||||
}
|
||||
if (
|
||||
result.email &&
|
||||
(!emailPattern.test(result.email) ||
|
||||
result.email.split("@")[0].length > 64)
|
||||
) {
|
||||
fields.email = "Bitte eine gültige E-Mail-Adresse eingeben.";
|
||||
}
|
||||
const phone = customer ? customer.phone : input.phone;
|
||||
if (phone !== undefined) {
|
||||
if (!isValidBookingPhone(phone)) {
|
||||
fields.phone = phoneError;
|
||||
} else if (!customer && phone.trim()) {
|
||||
result.phone = phone.trim();
|
||||
}
|
||||
}
|
||||
if (Object.keys(fields).length) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_INPUT",
|
||||
"Bitte die markierten Angaben prüfen.",
|
||||
fields,
|
||||
);
|
||||
}
|
||||
if (input.variantId != null) {
|
||||
if (
|
||||
typeof input.variantId !== "string" ||
|
||||
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.variantId) ||
|
||||
input.variantId.length > 80
|
||||
) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_VARIANT",
|
||||
"Bitte eine gültige Variante auswählen.",
|
||||
);
|
||||
}
|
||||
result.variantId = input.variantId;
|
||||
}
|
||||
if (customer) {
|
||||
result.customer = customer;
|
||||
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) {
|
||||
let highest = 0n;
|
||||
for (const record of records) {
|
||||
const match = /^TH-([0-9]{6,})$/.exec(record.bookingId);
|
||||
if (match) {
|
||||
const number = BigInt(match[1]);
|
||||
if (number > highest) highest = number;
|
||||
}
|
||||
}
|
||||
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 =
|
||||
record.emailStatus === "sending" ? "unknown" : record.emailStatus;
|
||||
return {
|
||||
status: emailStatus === "accepted" ? (replayed ? 200 : 201) : 202,
|
||||
body: {
|
||||
bookingId: record.bookingId,
|
||||
createdAt: record.createdAt,
|
||||
packageId: record.packageId,
|
||||
packageName: record.packageName,
|
||||
bookedPackage: record.bookedPackage || null,
|
||||
customerEmail: record.email,
|
||||
status: "CONFIRMED",
|
||||
saved: true,
|
||||
emailStatus,
|
||||
replayed,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createBookingService({
|
||||
storage,
|
||||
sendConfirmation,
|
||||
packages,
|
||||
details = packagesDetails,
|
||||
}) {
|
||||
function getPackage(id) {
|
||||
const selected =
|
||||
packages.some((entry) => entry.id === id) &&
|
||||
details.find((entry) => entry.id === id);
|
||||
if (!selected)
|
||||
throw new BookingError(
|
||||
404,
|
||||
"UNKNOWN_PACKAGE",
|
||||
"Dieses Paket ist nicht verfügbar. Bitte wähle ein Paket auf der Angebotsseite aus.",
|
||||
);
|
||||
return structuredClone(selected);
|
||||
}
|
||||
function preview(input) {
|
||||
if (
|
||||
!input ||
|
||||
typeof input !== "object" ||
|
||||
Array.isArray(input) ||
|
||||
typeof input.packageId !== "string"
|
||||
) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_INPUT",
|
||||
"Bitte ein gültiges Paket auswählen.",
|
||||
);
|
||||
}
|
||||
let selected;
|
||||
try {
|
||||
selected = getPackage(input.packageId);
|
||||
} catch (error) {
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
const variants = selected.variants || [];
|
||||
const variantId = input.variantId ?? variants[0]?.variantId ?? null;
|
||||
const variant = variants.find((entry) => entry.variantId === variantId);
|
||||
if (
|
||||
(variants.length && !variant) ||
|
||||
(!variants.length &&
|
||||
variantId !== null &&
|
||||
variantId !== selected.id)
|
||||
) {
|
||||
throw new BookingError(
|
||||
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 {
|
||||
packageId: selected.id,
|
||||
variantId: variant?.variantId ?? null,
|
||||
variantLabel: variant?.label ?? null,
|
||||
type: selected.type,
|
||||
title: selected.title,
|
||||
subtitle: selected.subtitle,
|
||||
summaryForBooking: selected.summaryForBooking,
|
||||
durationWeeks: variant?.durationWeeks ?? selected.durationWeeks,
|
||||
durationLabel: variant
|
||||
? `${variant.durationWeeks} Wochen Begleitung`
|
||||
: selected.durationLabel,
|
||||
quantity: 1,
|
||||
billingInterval: selected.billingInterval,
|
||||
includedFeatures: selected.includedFeatures,
|
||||
processSteps: selected.processSteps,
|
||||
netPrice: netCents / 100,
|
||||
taxRate: selected.taxRate,
|
||||
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) {
|
||||
const data = validateInput(input, key);
|
||||
const requestHash = createHash("sha256")
|
||||
.update(JSON.stringify(data))
|
||||
.digest("hex");
|
||||
let records;
|
||||
try {
|
||||
records = await storage.readAll();
|
||||
} catch {
|
||||
throw new BookingError(
|
||||
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) {
|
||||
throw new BookingError(
|
||||
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) {
|
||||
const selected = packages.find(
|
||||
(entry) => entry.id === data.packageId,
|
||||
);
|
||||
if (!selected) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"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),
|
||||
createdAt: new Date().toISOString(),
|
||||
packageId: selected.id,
|
||||
packageName: selected.name,
|
||||
bookedPackage,
|
||||
customer: data.customer ?? null,
|
||||
acceptedTerms: data.acceptedTerms ?? null,
|
||||
consent: data.consent,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
phone: data.customer?.phone ?? data.phone ?? "",
|
||||
emailStatus: "pending",
|
||||
idempotencyKey: key,
|
||||
requestHash,
|
||||
};
|
||||
records.push(record);
|
||||
try {
|
||||
await storage.writeAll(records);
|
||||
} catch {
|
||||
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 {
|
||||
await storage.writeAll(records);
|
||||
} catch {
|
||||
record.emailStatus = "pending";
|
||||
return responseFor(record, replayed);
|
||||
}
|
||||
try {
|
||||
await sendConfirmation(record);
|
||||
record.emailStatus = "accepted";
|
||||
} catch (error) {
|
||||
record.emailStatus = error.deliveryUnknown ? "unknown" : "failed";
|
||||
}
|
||||
try {
|
||||
await storage.writeAll(records);
|
||||
} catch {
|
||||
record.emailStatus = "unknown";
|
||||
}
|
||||
return responseFor(record, replayed);
|
||||
}
|
||||
return {
|
||||
getPackage,
|
||||
preview,
|
||||
book(input, key) {
|
||||
const task = queue.then(() => processBooking(input, key));
|
||||
queue = task.catch(() => {});
|
||||
return task;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,90 +1,92 @@
|
|||
import { BookingError, emailPattern } from "./booking-service.js";
|
||||
|
||||
// Prüft und normalisiert die vom Browser übermittelten Kontaktangaben.
|
||||
function normalizeInput(input) {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_INPUT",
|
||||
"Bitte alle Pflichtfelder prüfen.",
|
||||
);
|
||||
}
|
||||
|
||||
// Legt die zulässige Höchstlänge für jedes Formularfeld fest.
|
||||
const limits = { name: 120, email: 254, subject: 160, message: 5000 };
|
||||
const fields = {};
|
||||
const data = {};
|
||||
for (const [field, max] of Object.entries(limits)) {
|
||||
const value = input[field];
|
||||
if (typeof value !== "string") {
|
||||
fields[field] = "Dieses Feld ist erforderlich.";
|
||||
continue;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) fields[field] = "Dieses Feld ist erforderlich.";
|
||||
else if (trimmed.length > max)
|
||||
fields[field] = `Maximal ${max} Zeichen eingeben.`;
|
||||
else if (
|
||||
(field === "message"
|
||||
? /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/
|
||||
: /[\x00-\x1f\x7f]/
|
||||
).test(trimmed)
|
||||
)
|
||||
fields[field] = "Bitte entferne ungültige Steuerzeichen.";
|
||||
else data[field] = trimmed;
|
||||
}
|
||||
// Prüft zusätzlich das E-Mail-Format und die Mindestlänge der Nachricht.
|
||||
if (
|
||||
data.email &&
|
||||
(!emailPattern.test(data.email) || data.email.split("@")[0].length > 64)
|
||||
) {
|
||||
fields.email = "Bitte eine gültige E-Mail-Adresse eingeben.";
|
||||
}
|
||||
if (data.message && [...data.message].length < 10) {
|
||||
fields.message =
|
||||
"Deine Nachricht muss mindestens 10 Zeichen enthalten.";
|
||||
}
|
||||
// Gibt alle gefundenen Feldfehler gesammelt an die Route zurück.
|
||||
if (Object.keys(fields).length) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_INPUT",
|
||||
"Bitte prüfe deine Eingaben.",
|
||||
fields,
|
||||
);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Verbindet Validierung, CSV-Speicherung und anschließenden E-Mail-Versand.
|
||||
export function createContactFormService({ storage, sendContactForm }) {
|
||||
return {
|
||||
async submit(input) {
|
||||
const data = normalizeInput(input);
|
||||
// Ergänzt einen Zeitstempel, damit die Anfrage zeitlich nachvollziehbar bleibt.
|
||||
const record = { createdAt: new Date().toISOString(), ...data };
|
||||
// Speichert vor dem Versand, damit die Anfrage bei SMTP-Problemen erhalten bleibt.
|
||||
try {
|
||||
await storage.append(record);
|
||||
} catch {
|
||||
throw new BookingError(
|
||||
503,
|
||||
"CONTACT_NOT_SAVED",
|
||||
"Deine Nachricht konnte gerade nicht gespeichert werden. Bitte versuche es erneut.",
|
||||
);
|
||||
}
|
||||
// Sendet die beiden Mails erst nach erfolgreichem Speichern der Anfrage.
|
||||
try {
|
||||
await sendContactForm(record);
|
||||
} catch (error) {
|
||||
if (error instanceof BookingError) throw error;
|
||||
throw new BookingError(
|
||||
502,
|
||||
"CONTACT_EMAIL_FAILED",
|
||||
"Deine Nachricht wurde gespeichert, aber die E-Mails konnten nicht vollständig gesendet werden. Bitte versuche es später erneut oder melde dich direkt bei Tri-Hub.",
|
||||
);
|
||||
}
|
||||
return { saved: true, emailStatus: "accepted" };
|
||||
},
|
||||
};
|
||||
}
|
||||
import { BookingError, emailPattern } from "./booking-service.js";
|
||||
|
||||
// Prüft und normalisiert die vom Browser übermittelten Kontaktangaben.
|
||||
function normalizeInput(input) {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_INPUT",
|
||||
"Bitte alle Pflichtfelder prüfen.",
|
||||
);
|
||||
}
|
||||
|
||||
// Legt die zulässige Höchstlänge für jedes Formularfeld fest.
|
||||
const limits = { name: 120, email: 254, subject: 160, message: 5000 };
|
||||
const fields = {};
|
||||
const data = { consent: true };
|
||||
if (input.consent !== true)
|
||||
fields.consent = "Bitte die Datenschutzbestimmungen akzeptieren.";
|
||||
for (const [field, max] of Object.entries(limits)) {
|
||||
const value = input[field];
|
||||
if (typeof value !== "string") {
|
||||
fields[field] = "Dieses Feld ist erforderlich.";
|
||||
continue;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) fields[field] = "Dieses Feld ist erforderlich.";
|
||||
else if (trimmed.length > max)
|
||||
fields[field] = `Maximal ${max} Zeichen eingeben.`;
|
||||
else if (
|
||||
(field === "message"
|
||||
? /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/
|
||||
: /[\x00-\x1f\x7f]/
|
||||
).test(trimmed)
|
||||
)
|
||||
fields[field] = "Bitte entferne ungültige Steuerzeichen.";
|
||||
else data[field] = trimmed;
|
||||
}
|
||||
// Prüft zusätzlich das E-Mail-Format und die Mindestlänge der Nachricht.
|
||||
if (
|
||||
data.email &&
|
||||
(!emailPattern.test(data.email) || data.email.split("@")[0].length > 64)
|
||||
) {
|
||||
fields.email = "Bitte eine gültige E-Mail-Adresse eingeben.";
|
||||
}
|
||||
if (data.message && [...data.message].length < 10) {
|
||||
fields.message =
|
||||
"Deine Nachricht muss mindestens 10 Zeichen enthalten.";
|
||||
}
|
||||
// Gibt alle gefundenen Feldfehler gesammelt an die Route zurück.
|
||||
if (Object.keys(fields).length) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_INPUT",
|
||||
"Bitte prüfe deine Eingaben.",
|
||||
fields,
|
||||
);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Verbindet Validierung, CSV-Speicherung und anschließenden E-Mail-Versand.
|
||||
export function createContactFormService({ storage, sendContactForm }) {
|
||||
return {
|
||||
async submit(input) {
|
||||
const data = normalizeInput(input);
|
||||
// Ergänzt einen Zeitstempel, damit die Anfrage zeitlich nachvollziehbar bleibt.
|
||||
const record = { createdAt: new Date().toISOString(), ...data };
|
||||
// Speichert vor dem Versand, damit die Anfrage bei SMTP-Problemen erhalten bleibt.
|
||||
try {
|
||||
await storage.append(record);
|
||||
} catch {
|
||||
throw new BookingError(
|
||||
503,
|
||||
"CONTACT_NOT_SAVED",
|
||||
"Deine Nachricht konnte gerade nicht gespeichert werden. Bitte versuche es erneut.",
|
||||
);
|
||||
}
|
||||
// Sendet die beiden Mails erst nach erfolgreichem Speichern der Anfrage.
|
||||
try {
|
||||
await sendContactForm(record);
|
||||
} catch (error) {
|
||||
if (error instanceof BookingError) throw error;
|
||||
throw new BookingError(
|
||||
502,
|
||||
"CONTACT_EMAIL_FAILED",
|
||||
"Deine Nachricht wurde gespeichert, aber die E-Mails konnten nicht vollständig gesendet werden. Bitte versuche es später erneut oder melde dich direkt bei Tri-Hub.",
|
||||
);
|
||||
}
|
||||
return { saved: true, emailStatus: "accepted" };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,93 +1,106 @@
|
|||
import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { stringify } from "csv-stringify/sync";
|
||||
|
||||
// Diese Spalten bilden den festen Aufbau der Kontaktanfragen-CSV.
|
||||
const columns = ["createdAt", "name", "email", "subject", "message"];
|
||||
|
||||
// Schützt CSV-Zellen vor Tabellenformeln, wenn die Datei geöffnet wird.
|
||||
function protect(value) {
|
||||
const text = String(value ?? "");
|
||||
return /^(?:'|[\t\r\n]|\s*[=+@-])/u.test(text) ? `'${text}` : text;
|
||||
}
|
||||
|
||||
// Entfernt beim Einlesen den Schutzpräfix und stellt den Eingabewert wieder her.
|
||||
function restore(value) {
|
||||
return value.startsWith("'") ? value.slice(1) : value;
|
||||
}
|
||||
|
||||
// Erstellt den CSV-Speicher und hält Schreibvorgänge dieser Instanz in einer Warteschlange.
|
||||
export function createContactFormStorage(filePath) {
|
||||
let queue = Promise.resolve();
|
||||
|
||||
// Liest alle Anfragen ein und prüft, ob die Datei den erwarteten CSV-Kopf besitzt.
|
||||
async function readAll() {
|
||||
let content;
|
||||
try {
|
||||
content = await readFile(filePath, "utf8");
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
if (!content.trim()) throw new Error("Leere Kontaktdatei");
|
||||
return parse(content, {
|
||||
bom: true,
|
||||
columns(header) {
|
||||
if (header.join(",") !== columns.join(",")) {
|
||||
throw new Error("Unbekanntes Kontakt-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 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { stringify } from "csv-stringify/sync";
|
||||
|
||||
// Diese Spalten bilden den festen Aufbau der Kontaktanfragen-CSV.
|
||||
const defaultColumns = [
|
||||
"createdAt",
|
||||
"name",
|
||||
"email",
|
||||
"subject",
|
||||
"message",
|
||||
"consent",
|
||||
];
|
||||
|
||||
// Schützt CSV-Zellen vor Tabellenformeln, wenn die Datei geöffnet wird.
|
||||
function protect(value) {
|
||||
const text = String(value ?? "");
|
||||
return /^(?:'|[\t\r\n]|\s*[=+@-])/u.test(text) ? `'${text}` : text;
|
||||
}
|
||||
|
||||
// Entfernt beim Einlesen den Schutzpräfix und stellt den Eingabewert wieder her.
|
||||
function restore(value) {
|
||||
return value.startsWith("'") ? value.slice(1) : value;
|
||||
}
|
||||
|
||||
// Erstellt den CSV-Speicher und hält Schreibvorgänge dieser Instanz in einer Warteschlange.
|
||||
export function createContactFormStorage(filePath, columns = defaultColumns) {
|
||||
let queue = Promise.resolve();
|
||||
|
||||
// Liest alle Anfragen ein und prüft, ob die Datei den erwarteten CSV-Kopf besitzt.
|
||||
async function readAll() {
|
||||
let content;
|
||||
try {
|
||||
content = await readFile(filePath, "utf8");
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
if (!content.trim()) throw new Error("Leere Kontaktdatei");
|
||||
return parse(content, {
|
||||
bom: true,
|
||||
columns(header) {
|
||||
if (
|
||||
header.join(",") !== columns.join(",") &&
|
||||
header.join(",") !==
|
||||
columns
|
||||
.filter((column) => column !== "consent")
|
||||
.join(",")
|
||||
) {
|
||||
throw new Error("Unbekanntes Kontakt-CSV-Format");
|
||||
}
|
||||
return header;
|
||||
},
|
||||
skip_empty_lines: true,
|
||||
}).map((row) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(row).map(([key, value]) => [
|
||||
key,
|
||||
key === "consent" ? value === "true" : restore(value),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Schreibt die vollständige CSV in eine temporäre Datei und ersetzt danach atomar das Original.
|
||||
async function writeAll(records) {
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
const temporary = `${filePath}.${randomUUID()}.tmp`;
|
||||
const content = stringify(
|
||||
records.map((row) =>
|
||||
Object.fromEntries(
|
||||
columns.map((column) => [column, protect(row[column])]),
|
||||
),
|
||||
),
|
||||
{ header: true, columns, record_delimiter: "\r\n" },
|
||||
);
|
||||
try {
|
||||
const handle = await open(temporary, "wx", 0o600);
|
||||
try {
|
||||
await handle.writeFile(content, "utf8");
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await rename(temporary, filePath);
|
||||
} finally {
|
||||
await rm(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
readAll,
|
||||
// Hängt eine Anfrage nacheinander an, damit parallele Absendevorgänge keine Zeilen verlieren.
|
||||
append(record) {
|
||||
const operation = queue.then(async () => {
|
||||
const records = await readAll();
|
||||
records.push(record);
|
||||
await writeAll(records);
|
||||
});
|
||||
queue = operation.catch(() => {});
|
||||
return operation;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,108 +1,131 @@
|
|||
import { findAdvisor } from "../../shared/berater.js";
|
||||
import { BookingError, emailPattern } from "./booking-service.js";
|
||||
import { contactReasons, contactBookingUrl } from "../../shared/contact.js";
|
||||
|
||||
function advisorDetails(advisorId) {
|
||||
const advisor = findAdvisor(advisorId);
|
||||
if (!advisor) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_ADVISOR",
|
||||
"Bitte eine gültige Berater-ID übergeben.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof advisor.vorname !== "string" ||
|
||||
!advisor.vorname.trim() ||
|
||||
typeof advisor.email !== "string" ||
|
||||
advisor.email.length > 254 ||
|
||||
!emailPattern.test(advisor.email) ||
|
||||
advisor.email.split("@")[0].length > 64 ||
|
||||
!advisor.email.toLowerCase().endsWith("@tri-hub.de")
|
||||
) {
|
||||
throw new BookingError(
|
||||
503,
|
||||
"ADVISOR_UNAVAILABLE",
|
||||
"Die Kontaktdaten dieses Beraters sind nicht verfügbar.",
|
||||
);
|
||||
}
|
||||
return { ...advisor };
|
||||
}
|
||||
|
||||
export function createContactService({ storage, sendContact }) {
|
||||
async function validate(input) {
|
||||
if (
|
||||
!input ||
|
||||
typeof input !== "object" ||
|
||||
Array.isArray(input) ||
|
||||
typeof input.email !== "string" ||
|
||||
input.email.length > 254
|
||||
) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_EMAIL",
|
||||
"Bitte eine gültige E-Mail-Adresse eingeben.",
|
||||
);
|
||||
}
|
||||
const email = input.email.trim().toLowerCase();
|
||||
if (!emailPattern.test(email) || email.split("@")[0].length > 64) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_EMAIL",
|
||||
"Bitte eine gültige E-Mail-Adresse eingeben.",
|
||||
);
|
||||
}
|
||||
let records;
|
||||
try {
|
||||
records = await storage.readAll();
|
||||
} catch {
|
||||
throw new BookingError(
|
||||
503,
|
||||
"STORAGE_UNAVAILABLE",
|
||||
"Die Kundendaten können gerade nicht geprüft werden.",
|
||||
);
|
||||
}
|
||||
if (!records.some((row) => row.email?.trim().toLowerCase() === email)) {
|
||||
throw new BookingError(
|
||||
403,
|
||||
"CUSTOMER_NOT_FOUND",
|
||||
"Zu dieser E-Mail-Adresse liegt keine Buchung vor. Bitte verwende die Adresse deiner Buchung.",
|
||||
);
|
||||
}
|
||||
return { email, valid: true };
|
||||
}
|
||||
return {
|
||||
validate,
|
||||
async submit(input) {
|
||||
const { email } = await validate(input);
|
||||
const advisor = advisorDetails(input.advisorId);
|
||||
if (
|
||||
typeof input.reason !== "string" ||
|
||||
!Object.hasOwn(contactReasons, input.reason)
|
||||
) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_REASON",
|
||||
"Bitte einen Kontaktgrund auswählen.",
|
||||
);
|
||||
}
|
||||
if (input.simulationAccepted !== true) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"SIMULATION_REQUIRED",
|
||||
"Bitte den Simulationshinweis bestätigen.",
|
||||
);
|
||||
}
|
||||
await sendContact({
|
||||
email,
|
||||
advisor,
|
||||
reason: contactReasons[input.reason],
|
||||
});
|
||||
return {
|
||||
simulated: true,
|
||||
emailStatus: "accepted",
|
||||
redirectUrl: contactBookingUrl,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
import { findAdvisor } from "../../shared/berater.js";
|
||||
import { BookingError, emailPattern } from "./booking-service.js";
|
||||
import { contactReasons, contactBookingUrl } from "../../shared/contact.js";
|
||||
|
||||
function advisorDetails(advisorId) {
|
||||
const advisor = findAdvisor(advisorId);
|
||||
if (!advisor) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_ADVISOR",
|
||||
"Bitte eine gültige Berater-ID übergeben.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof advisor.vorname !== "string" ||
|
||||
!advisor.vorname.trim() ||
|
||||
typeof advisor.email !== "string" ||
|
||||
advisor.email.length > 254 ||
|
||||
!emailPattern.test(advisor.email) ||
|
||||
advisor.email.split("@")[0].length > 64 ||
|
||||
!advisor.email.toLowerCase().endsWith("@tri-hub.de")
|
||||
) {
|
||||
throw new BookingError(
|
||||
503,
|
||||
"ADVISOR_UNAVAILABLE",
|
||||
"Die Kontaktdaten dieses Beraters sind nicht verfügbar.",
|
||||
);
|
||||
}
|
||||
return { ...advisor };
|
||||
}
|
||||
|
||||
export function createContactService({ storage, requestStorage, sendContact }) {
|
||||
async function validate(input) {
|
||||
if (
|
||||
!input ||
|
||||
typeof input !== "object" ||
|
||||
Array.isArray(input) ||
|
||||
typeof input.email !== "string" ||
|
||||
input.email.length > 254
|
||||
) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_EMAIL",
|
||||
"Bitte eine gültige E-Mail-Adresse eingeben.",
|
||||
);
|
||||
}
|
||||
const email = input.email.trim().toLowerCase();
|
||||
if (!emailPattern.test(email) || email.split("@")[0].length > 64) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_EMAIL",
|
||||
"Bitte eine gültige E-Mail-Adresse eingeben.",
|
||||
);
|
||||
}
|
||||
let records;
|
||||
try {
|
||||
records = await storage.readAll();
|
||||
} catch {
|
||||
throw new BookingError(
|
||||
503,
|
||||
"STORAGE_UNAVAILABLE",
|
||||
"Die Kundendaten können gerade nicht geprüft werden.",
|
||||
);
|
||||
}
|
||||
if (!records.some((row) => row.email?.trim().toLowerCase() === email)) {
|
||||
throw new BookingError(
|
||||
403,
|
||||
"CUSTOMER_NOT_FOUND",
|
||||
"Zu dieser E-Mail-Adresse liegt keine Buchung vor. Bitte verwende die Adresse deiner Buchung.",
|
||||
);
|
||||
}
|
||||
return { email, valid: true };
|
||||
}
|
||||
return {
|
||||
validate,
|
||||
async submit(input) {
|
||||
const { email } = await validate(input);
|
||||
const advisor = advisorDetails(input.advisorId);
|
||||
if (
|
||||
typeof input.reason !== "string" ||
|
||||
!Object.hasOwn(contactReasons, input.reason)
|
||||
) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"INVALID_REASON",
|
||||
"Bitte einen Kontaktgrund auswählen.",
|
||||
);
|
||||
}
|
||||
if (input.simulationAccepted !== true) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"SIMULATION_REQUIRED",
|
||||
"Bitte den Simulationshinweis bestätigen.",
|
||||
);
|
||||
}
|
||||
if (input.consent !== true) {
|
||||
throw new BookingError(
|
||||
400,
|
||||
"CONSENT_REQUIRED",
|
||||
"Bitte die Datenschutzbestimmungen akzeptieren.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
await requestStorage.append({
|
||||
createdAt: new Date().toISOString(),
|
||||
email,
|
||||
advisorId: advisor.id,
|
||||
reason: input.reason,
|
||||
simulationAccepted: true,
|
||||
consent: true,
|
||||
});
|
||||
} catch {
|
||||
throw new BookingError(
|
||||
503,
|
||||
"CONTACT_NOT_SAVED",
|
||||
"Die Kontaktanfrage konnte nicht gespeichert werden.",
|
||||
);
|
||||
}
|
||||
await sendContact({
|
||||
email,
|
||||
advisor,
|
||||
reason: contactReasons[input.reason],
|
||||
});
|
||||
return {
|
||||
simulated: true,
|
||||
emailStatus: "accepted",
|
||||
redirectUrl: contactBookingUrl,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@ const legacyColumns = [
|
|||
|
||||
const orderColumns = [...legacyColumns, "bookedPackage"];
|
||||
const customerColumns = [...orderColumns, "customer", "acceptedTerms"];
|
||||
export const columns = [...customerColumns, "phone"];
|
||||
const jsonColumns = ["bookedPackage", "customer", "acceptedTerms"];
|
||||
const phoneColumns = [...customerColumns, "phone"];
|
||||
export const columns = [...phoneColumns, "consent"];
|
||||
const jsonColumns = ["bookedPackage", "customer", "acceptedTerms", "consent"];
|
||||
|
||||
// Apostroph-Präfix schützt Tabellenprogramme. Ein vorhandenes Apostroph wird
|
||||
// ebenfalls maskiert, damit Lesen/Schreiben die Originalwerte exakt erhält.
|
||||
|
|
@ -49,6 +50,7 @@ export function createCsvStorage(filePath) {
|
|||
if (
|
||||
header.join(",") !== columns.join(",") &&
|
||||
header.join(",") !== customerColumns.join(",") &&
|
||||
header.join(",") !== phoneColumns.join(",") &&
|
||||
header.join(",") !== legacyColumns.join(",") &&
|
||||
header.join(",") !== orderColumns.join(",")
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -72,6 +72,15 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.package-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1.42 / 1;
|
||||
margin: 0 0 1.5rem;
|
||||
border: 1px solid #f8fafc1f;
|
||||
border-radius: 1.1rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
.package-type-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { createEmailService } from "../server/services/email-service.js";
|
|||
import { packages } from "../shared/packages.js";
|
||||
|
||||
const input = {
|
||||
consent: true,
|
||||
packageId: packages[0].id,
|
||||
name: 'Test, "Person"',
|
||||
email: "person@example.test",
|
||||
|
|
@ -68,6 +69,7 @@ test("Buchung steht vor Versand in CSV; Paketname kommt vom Server", async (t) =
|
|||
assert.ok(!Number.isNaN(Date.parse(result.body.createdAt)));
|
||||
const records = await fixture.storage.readAll();
|
||||
assert.equal(records.length, 1);
|
||||
assert.equal(records[0].consent, true);
|
||||
assert.equal(records[0].name, input.name);
|
||||
assert.equal(records[0].emailStatus, "accepted");
|
||||
assert.equal(fixture.sent.length, 1);
|
||||
|
|
@ -81,6 +83,7 @@ test("Pflichtfelder, Typen, Längen, E-Mail und unbekannte IDs werden abgelehnt"
|
|||
null,
|
||||
[],
|
||||
{},
|
||||
...[false, "true", undefined].map((consent) => ({ ...input, consent })),
|
||||
{ ...input, name: " " },
|
||||
{ ...input, name: 42 },
|
||||
{ ...input, name: "a".repeat(121) },
|
||||
|
|
@ -583,6 +586,7 @@ test("Swagger-Kundendaten und Zustimmung werden validiert und dauerhaft gespeich
|
|||
notes: "Vorbereitung auf die Mitteldistanz.",
|
||||
};
|
||||
const request = {
|
||||
consent: true,
|
||||
packageId: "ernaehrung-standard",
|
||||
customer,
|
||||
acceptedTerms: true,
|
||||
|
|
@ -633,6 +637,7 @@ test("Telefonnummern werden in beiden Anfrageformaten geprüft", async (t) => {
|
|||
{ ...input, phone },
|
||||
{
|
||||
packageId: input.packageId,
|
||||
consent: true,
|
||||
acceptedTerms: true,
|
||||
customer: {
|
||||
firstName: "Test",
|
||||
|
|
|
|||
|
|
@ -5,12 +5,18 @@ import { packagesDetails } from "../../shared/packagesdetails.js";
|
|||
|
||||
const pageUrl = "/booking.html#/buchen/ernaehrung-starter";
|
||||
async function fill(page) {
|
||||
if (!(await page.locator("dialog[open]").count())) {
|
||||
await page
|
||||
.getByRole("button", { name: "Jetzt buchen", exact: true })
|
||||
.click();
|
||||
}
|
||||
await page
|
||||
.getByLabel("Name (Pflichtfeld)", { exact: true })
|
||||
.fill("Test Person");
|
||||
await page
|
||||
.getByLabel("E-Mail-Adresse (Pflichtfeld)")
|
||||
.fill("person@example.test");
|
||||
await page.locator('[name="consent"]').check();
|
||||
}
|
||||
|
||||
test("Tastaturbedienung, Validierung und vollständige Buchung über Vite-Proxy", async ({
|
||||
|
|
@ -26,41 +32,47 @@ test("Tastaturbedienung, Validierung und vollständige Buchung über Vite-Proxy"
|
|||
await expect(page.locator(".booking__payment-notice")).toContainText(
|
||||
"keine Abbuchung",
|
||||
);
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Zur Angebotsseite" }),
|
||||
).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
const opener = page.getByRole("button", {
|
||||
name: "Jetzt buchen",
|
||||
exact: true,
|
||||
});
|
||||
await opener.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(
|
||||
page.getByLabel("Name (Pflichtfeld)", { exact: true }),
|
||||
).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("alert")).toBeFocused();
|
||||
await expect(page.locator('[aria-invalid="true"]')).toHaveCount(2);
|
||||
await expect(page.locator('[aria-invalid="true"]')).toHaveCount(3);
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.type("Test Person");
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.type("person@example.test");
|
||||
await page.keyboard.press("Tab");
|
||||
const outline = await page
|
||||
.getByRole("button", { name: "Paket buchen", exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).outlineStyle);
|
||||
expect(outline).toBe("solid");
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.locator('[name="consent"]')).toBeFocused();
|
||||
await page.keyboard.press("Space");
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Tab");
|
||||
const submit = page.getByRole("button", {
|
||||
name: "Paket buchen",
|
||||
exact: true,
|
||||
});
|
||||
await expect(submit).toBeFocused();
|
||||
expect(
|
||||
await submit.evaluate(
|
||||
(element) => getComputedStyle(element).outlineStyle,
|
||||
),
|
||||
).toBe("solid");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator(".booking__result")).toContainText(
|
||||
/Buchungsnummer: TH-\d{6,}/,
|
||||
await expect(page).toHaveURL(/booking-confirmation\.html$/);
|
||||
await expect(page.locator("#confirmation-receipt")).toContainText(
|
||||
/TH-\d{6,}/,
|
||||
);
|
||||
await expect(page.locator(".booking__result")).toContainText(
|
||||
"Zustellung ist noch nicht bestätigt",
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Buchung gespeichert" }),
|
||||
).toBeDisabled();
|
||||
await page.reload();
|
||||
await expect(page.locator(".booking__result")).toContainText(
|
||||
/Buchungsnummer: TH-\d{6,}/,
|
||||
await expect(page.locator("#confirmation-receipt")).toContainText(
|
||||
/TH-\d{6,}/,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -105,6 +117,9 @@ test("Verlorene Antwort: Wiederholung nach Neuladen behält Schlüssel und Buchu
|
|||
.click();
|
||||
await expect(page.getByRole("alert")).toBeVisible();
|
||||
await page.reload();
|
||||
await page
|
||||
.getByRole("button", { name: "Jetzt buchen", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByLabel("Name (Pflichtfeld)", { exact: true }),
|
||||
).toHaveValue("Test Person");
|
||||
|
|
@ -114,7 +129,9 @@ test("Verlorene Antwort: Wiederholung nach Neuladen behält Schlüssel und Buchu
|
|||
await page
|
||||
.getByRole("button", { name: "Status prüfen / erneut versuchen" })
|
||||
.click();
|
||||
await expect(page.locator(".booking__result")).toContainText(firstBooking);
|
||||
await expect(page.locator("#confirmation-receipt")).toContainText(
|
||||
firstBooking,
|
||||
);
|
||||
expect(requests).toBe(2);
|
||||
});
|
||||
|
||||
|
|
@ -186,7 +203,9 @@ test("Vite liefert Serverdateien und Umgebungsdateien nicht aus", async ({
|
|||
for (const pkg of packages) {
|
||||
test(`Von der Startseite zur Buchung: ${pkg.type}`, async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("link", { name: "Beratung kennenlernen" }).click();
|
||||
await page
|
||||
.getByRole("link", { name: "Ernährungswissen entdecken" })
|
||||
.click();
|
||||
await expect(page.locator(".package-card")).toHaveCount(
|
||||
packages.length,
|
||||
);
|
||||
|
|
@ -195,13 +214,11 @@ for (const pkg of packages) {
|
|||
new RegExp(`paket-details\\.html\\?id=${pkg.id}$`),
|
||||
);
|
||||
await page.locator("#proceed-to-booking-btn").click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`booking\\.html\\?id=${pkg.id}`),
|
||||
);
|
||||
await expect(page.locator("dialog[open]")).toBeVisible();
|
||||
const details = packagesDetails.find((entry) => entry.id === pkg.id);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: details.title, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".booking__package-name")).toHaveText(
|
||||
details.title,
|
||||
);
|
||||
await expect(page.locator(".booking__summary")).toHaveText(
|
||||
details.summaryForBooking,
|
||||
);
|
||||
|
|
@ -219,13 +236,16 @@ for (const pkg of packages) {
|
|||
.click();
|
||||
const response = await responsePromise;
|
||||
expect(response.status()).toBe(201);
|
||||
const booking = await response.json();
|
||||
await expect(page).toHaveURL(/booking-confirmation\.html$/);
|
||||
const booking = await page.evaluate(() =>
|
||||
JSON.parse(sessionStorage.getItem("trihub.booking.confirmation")),
|
||||
);
|
||||
expect(booking.packageId).toBe(pkg.id);
|
||||
expect(booking.packageName).toBe(pkg.name);
|
||||
await expect(page.locator(".booking__result")).toContainText(
|
||||
await expect(page.locator("#confirmation-receipt")).toContainText(
|
||||
booking.bookingId,
|
||||
);
|
||||
await page.getByRole("link", { name: "Zur Angebotsseite" }).click();
|
||||
await page.goto("/angebotsuebersicht.html");
|
||||
await expect(page.locator(".package-card")).toHaveCount(
|
||||
packages.length,
|
||||
);
|
||||
|
|
@ -249,14 +269,11 @@ test("Premium-Jahresvariante wird von der Detailseite bis zur Bestätigung über
|
|||
await page.goto("/paket-details.html?id=ernaehrung-premium");
|
||||
await page.locator("#variant-select").selectOption("ernaehrung-premium-52");
|
||||
await page.locator("#proceed-to-booking-btn").click();
|
||||
await expect(page).toHaveURL(
|
||||
/booking\.html\?id=ernaehrung-premium&variant=ernaehrung-premium-52$/,
|
||||
);
|
||||
await expect(page.locator("dialog[open]")).toBeVisible();
|
||||
await expect(page.locator(".booking__totals")).toContainText(
|
||||
"52 Wochen Begleitung",
|
||||
);
|
||||
await expect(page.locator(".booking__totals")).toContainText("1.399,00");
|
||||
await page.reload();
|
||||
await fill(page);
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
|
|
@ -270,15 +287,20 @@ test("Premium-Jahresvariante wird von der Detailseite bis zur Bestätigung über
|
|||
expect(response.request().postDataJSON().variantId).toBe(
|
||||
"ernaehrung-premium-52",
|
||||
);
|
||||
const booking = await response.json();
|
||||
await expect(page).toHaveURL(/booking-confirmation\.html$/);
|
||||
const booking = await page.evaluate(() =>
|
||||
JSON.parse(sessionStorage.getItem("trihub.booking.confirmation")),
|
||||
);
|
||||
expect(booking.bookedPackage.grossPrice).toBe(1399);
|
||||
expect(booking.bookedPackage.durationWeeks).toBe(52);
|
||||
await expect(page.locator(".booking__result")).toContainText(
|
||||
await expect(page.locator("#confirmation-receipt")).toContainText(
|
||||
booking.bookingId,
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page.locator(".booking__totals")).toContainText("1.399,00");
|
||||
await expect(page.locator(".booking__result")).toContainText(
|
||||
await expect(page.locator("#confirmation-totals")).toContainText(
|
||||
"1.399,00",
|
||||
);
|
||||
await expect(page.locator("#confirmation-receipt")).toContainText(
|
||||
booking.bookingId,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,98 +1,105 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { contactBookingUrl } from "../../shared/contact.js";
|
||||
|
||||
async function open(page) {
|
||||
await page.goto("/contact-test.html?berater-id=maren-hoffmann");
|
||||
await page
|
||||
.getByRole("button", { name: "Berater kontaktieren", exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const email = `kontakt-${randomUUID()}@example.test`;
|
||||
const booking = await request.post("/api/bookings", {
|
||||
headers: { "Idempotency-Key": randomUUID() },
|
||||
data: { packageId: "ernaehrung-starter", name: "Testkunde", email },
|
||||
});
|
||||
expect(booking.ok()).toBeTruthy();
|
||||
await open(page);
|
||||
await expect(page.getByRole("dialog")).toContainText("Maren");
|
||||
const submit = page.getByRole("button", { name: "Anfrage senden" });
|
||||
await expect(
|
||||
page.getByRole("button", { name: "E-Mail prüfen" }),
|
||||
).toHaveCount(0);
|
||||
await expect(submit).toBeEnabled();
|
||||
await page
|
||||
.getByLabel("Deine Buchungs-E-Mail")
|
||||
.fill("unbekannt@example.test");
|
||||
await page.getByLabel("Grund für den Kontakt").selectOption("nutrition");
|
||||
await page.getByRole("checkbox").check();
|
||||
await submit.click();
|
||||
await expect(page.getByRole("status")).toContainText("keine Buchung");
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.getByLabel("Deine Buchungs-E-Mail").fill(email);
|
||||
await page.route("https://bookings.cloud.microsoft/**", (route) =>
|
||||
route.fulfill({ body: "Microsoft Bookings (Test)" }),
|
||||
);
|
||||
const sentRequest = page.waitForRequest((request) =>
|
||||
request.url().endsWith("/api/contact"),
|
||||
);
|
||||
await submit.click();
|
||||
expect((await sentRequest).postDataJSON().advisorId).toBe("maren-hoffmann");
|
||||
await expect(page).toHaveURL(contactBookingUrl);
|
||||
});
|
||||
|
||||
test("Mobiles Popup: Fokus, Escape, Rückkehr zum Auslöser und keine Überbreite", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await open(page);
|
||||
await expect(page.getByLabel("Deine Buchungs-E-Mail")).toBeFocused();
|
||||
const box = await page.getByRole("dialog").boundingBox();
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(375);
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Kontaktfenster schließen" }),
|
||||
).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Berater kontaktieren", exact: true }),
|
||||
).toBeFocused();
|
||||
});
|
||||
|
||||
test("Versandfehler zeigt Meldung und bleibt im Popup", async ({ page }) => {
|
||||
await page.route("**/api/contact", (route) =>
|
||||
route.fulfill({
|
||||
status: 502,
|
||||
json: { error: { message: "Mailpit nicht erreichbar." } },
|
||||
}),
|
||||
);
|
||||
await open(page);
|
||||
await page.getByLabel("Deine Buchungs-E-Mail").fill("kunde@example.test");
|
||||
await page.getByLabel("Grund für den Kontakt").selectOption("package");
|
||||
await page.getByRole("checkbox").check();
|
||||
await page.getByRole("button", { name: "Anfrage senden" }).click();
|
||||
await expect(page.getByRole("status")).toContainText(
|
||||
"Mailpit nicht erreichbar",
|
||||
);
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Unbekannte Berater-ID sperrt das Absenden", async ({ page }) => {
|
||||
await page.goto("/contact-test.html?berater-id=unbekannt");
|
||||
await page
|
||||
.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();
|
||||
});
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { contactBookingUrl } from "../../shared/contact.js";
|
||||
|
||||
async function open(page) {
|
||||
await page.goto("/contact-test.html?berater-id=maren-hoffmann");
|
||||
await page
|
||||
.getByRole("button", { name: "Berater kontaktieren", exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const email = `kontakt-${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.ok()).toBeTruthy();
|
||||
await open(page);
|
||||
await expect(page.getByRole("dialog")).toContainText("Maren");
|
||||
const submit = page.getByRole("button", { name: "Anfrage senden" });
|
||||
await expect(
|
||||
page.getByRole("button", { name: "E-Mail prüfen" }),
|
||||
).toHaveCount(0);
|
||||
await expect(submit).toBeEnabled();
|
||||
await page
|
||||
.getByLabel("Deine Buchungs-E-Mail")
|
||||
.fill("unbekannt@example.test");
|
||||
await page.getByLabel("Grund für den Kontakt").selectOption("nutrition");
|
||||
await page.locator('[name="simulationAccepted"]').check();
|
||||
await page.locator('[name="consent"]').check();
|
||||
await submit.click();
|
||||
await expect(page.getByRole("status")).toContainText("keine Buchung");
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.getByLabel("Deine Buchungs-E-Mail").fill(email);
|
||||
await page.route("https://bookings.cloud.microsoft/**", (route) =>
|
||||
route.fulfill({ body: "Microsoft Bookings (Test)" }),
|
||||
);
|
||||
const sentRequest = page.waitForRequest((request) =>
|
||||
request.url().endsWith("/api/contact"),
|
||||
);
|
||||
await submit.click();
|
||||
expect((await sentRequest).postDataJSON().advisorId).toBe("maren-hoffmann");
|
||||
await expect(page).toHaveURL(contactBookingUrl);
|
||||
});
|
||||
|
||||
test("Mobiles Popup: Fokus, Escape, Rückkehr zum Auslöser und keine Überbreite", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await open(page);
|
||||
await expect(page.getByLabel("Deine Buchungs-E-Mail")).toBeFocused();
|
||||
const box = await page.getByRole("dialog").boundingBox();
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(375);
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Kontaktfenster schließen" }),
|
||||
).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Berater kontaktieren", exact: true }),
|
||||
).toBeFocused();
|
||||
});
|
||||
|
||||
test("Versandfehler zeigt Meldung und bleibt im Popup", async ({ page }) => {
|
||||
await page.route("**/api/contact", (route) =>
|
||||
route.fulfill({
|
||||
status: 502,
|
||||
json: { error: { message: "Mailpit nicht erreichbar." } },
|
||||
}),
|
||||
);
|
||||
await open(page);
|
||||
await page.getByLabel("Deine Buchungs-E-Mail").fill("kunde@example.test");
|
||||
await page.getByLabel("Grund für den Kontakt").selectOption("package");
|
||||
await page.locator('[name="simulationAccepted"]').check();
|
||||
await page.locator('[name="consent"]').check();
|
||||
await page.getByRole("button", { name: "Anfrage senden" }).click();
|
||||
await expect(page.getByRole("status")).toContainText(
|
||||
"Mailpit nicht erreichbar",
|
||||
);
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Unbekannte Berater-ID sperrt das Absenden", async ({ page }) => {
|
||||
await page.goto("/contact-test.html?berater-id=unbekannt");
|
||||
await page
|
||||
.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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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("");
|
||||
});
|
||||
|
|
@ -1,269 +1,289 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createApp } from "../server/index.js";
|
||||
import { createContactFormEmailService } from "../server/services/contact-form-email.js";
|
||||
import { createContactFormStorage } from "../server/services/contact-form-storage.js";
|
||||
|
||||
const validInput = {
|
||||
name: "Test Person",
|
||||
email: "person@example.test",
|
||||
subject: "Eine Frage",
|
||||
message: "Das ist eine ausreichend lange Nachricht.",
|
||||
};
|
||||
|
||||
// Startet die API mit temporärer Kontakt-CSV und einem ersetzbaren Mailversand.
|
||||
async function fixture(t, options = {}) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "trihub-contact-form-"));
|
||||
const storage =
|
||||
options.storage ??
|
||||
createContactFormStorage(join(directory, "contact-requests.csv"));
|
||||
const sent = [];
|
||||
const app = createApp({
|
||||
sendConfirmation: async () => {},
|
||||
sendContact: async () => {},
|
||||
contactFormStorage: storage,
|
||||
sendContactForm: async (data) => sent.push(data),
|
||||
...options,
|
||||
});
|
||||
await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve));
|
||||
t.after(async () => {
|
||||
app.closeAllConnections();
|
||||
await new Promise((resolve) => app.close(resolve));
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const base = `http://127.0.0.1:${app.address().port}`;
|
||||
async function post(input = validInput, headers = {}) {
|
||||
const response = await fetch(`${base}/api/nutrition-contact`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return { status: response.status, body: await response.json() };
|
||||
}
|
||||
return { app, base, post, sent, storage, directory };
|
||||
}
|
||||
|
||||
// Prüft den erfolgreichen Ablauf von HTTP-Anfrage über CSV bis zur Mail-Abhängigkeit.
|
||||
test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", async (t) => {
|
||||
const f = await fixture(t);
|
||||
const result = await f.post();
|
||||
|
||||
assert.equal(result.status, 201);
|
||||
assert.deepEqual(result.body, { saved: true, emailStatus: "accepted" });
|
||||
assert.equal(f.sent.length, 1);
|
||||
assert.deepEqual(
|
||||
{
|
||||
name: f.sent[0].name,
|
||||
email: f.sent[0].email,
|
||||
subject: f.sent[0].subject,
|
||||
message: f.sent[0].message,
|
||||
},
|
||||
validInput,
|
||||
);
|
||||
assert.ok(!Number.isNaN(Date.parse(f.sent[0].createdAt)));
|
||||
assert.deepEqual(await f.storage.readAll(), f.sent);
|
||||
});
|
||||
|
||||
// Stellt sicher, dass fehlerhafte Pflichtfelder nicht gespeichert oder versendet werden.
|
||||
test("Pflichtfelder, E-Mail, Längen und Mindestlänge werden validiert", async (t) => {
|
||||
const f = await fixture(t);
|
||||
const invalidInputs = [
|
||||
{ ...validInput, name: " " },
|
||||
{ ...validInput, name: "Test\nBcc: fremd" },
|
||||
{ ...validInput, name: "x".repeat(121) },
|
||||
{ ...validInput, email: "keine-adresse" },
|
||||
{ ...validInput, email: "x".repeat(255) },
|
||||
{ ...validInput, subject: "x".repeat(161) },
|
||||
{ ...validInput, message: "zu kurz" },
|
||||
{ ...validInput, message: "x".repeat(5001) },
|
||||
{ ...validInput, message: "Nachricht\u0000ungültig" },
|
||||
{ ...validInput, message: undefined },
|
||||
null,
|
||||
[],
|
||||
];
|
||||
|
||||
for (const input of invalidInputs) {
|
||||
const result = await f.post(input);
|
||||
assert.equal(result.status, 400, JSON.stringify(input));
|
||||
}
|
||||
assert.deepEqual(await f.storage.readAll(), []);
|
||||
assert.equal(f.sent.length, 0);
|
||||
});
|
||||
|
||||
// Prüft die Begrenzungen und JSON-Fehlerbehandlung des HTTP-Endpunkts.
|
||||
test("HTTP-Route lehnt falsche Methode, Inhaltstyp, JSON und große Bodies ab", async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal((await fetch(`${f.base}/api/nutrition-contact`)).status, 405);
|
||||
|
||||
const wrongType = await fetch(`${f.base}/api/nutrition-contact`, {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
assert.equal(wrongType.status, 415);
|
||||
|
||||
const invalidJson = await fetch(`${f.base}/api/nutrition-contact`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{",
|
||||
});
|
||||
assert.equal(invalidJson.status, 400);
|
||||
|
||||
const tooLarge = await fetch(`${f.base}/api/nutrition-contact`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "x".repeat(9000) }),
|
||||
});
|
||||
assert.equal(tooLarge.status, 413);
|
||||
assert.deepEqual(await f.storage.readAll(), []);
|
||||
});
|
||||
|
||||
// Ein Speicherfehler muss verhindern, dass anschließend E-Mails abgeschickt werden.
|
||||
test("Speicherfehler melden 503 und starten keinen Mailversand", async (t) => {
|
||||
let sendCount = 0;
|
||||
const f = await fixture(t, {
|
||||
storage: {
|
||||
async append() {
|
||||
throw new Error("Datenträger nicht verfügbar");
|
||||
},
|
||||
},
|
||||
sendContactForm: async () => sendCount++,
|
||||
});
|
||||
const result = await f.post();
|
||||
|
||||
assert.equal(result.status, 503);
|
||||
assert.equal(result.body.error.code, "CONTACT_NOT_SAVED");
|
||||
assert.equal(sendCount, 0);
|
||||
});
|
||||
|
||||
// Nach einem SMTP-Fehler bleibt die bereits gespeicherte Anfrage erhalten.
|
||||
test("Mailfehler melden 502, erhalten aber die gespeicherte Anfrage", async (t) => {
|
||||
const f = await fixture(t, {
|
||||
sendContactForm: async () => {
|
||||
throw new Error("Mailpit nicht erreichbar");
|
||||
},
|
||||
});
|
||||
const result = await f.post();
|
||||
|
||||
assert.equal(result.status, 502);
|
||||
assert.equal(result.body.error.code, "CONTACT_EMAIL_FAILED");
|
||||
assert.equal(result.body.error.message.includes("gespeichert"), true);
|
||||
assert.equal((await f.storage.readAll()).length, 1);
|
||||
});
|
||||
|
||||
// Prüft CSV-Rundlauf, Formelschutz und gleichzeitiges Anhängen mehrerer Anfragen.
|
||||
test("CSV erhält Sonderzeichen und verliert bei parallelen Anhängen keine Zeilen", async (t) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "trihub-contact-csv-"));
|
||||
const file = join(directory, "contact-requests.csv");
|
||||
const storage = createContactFormStorage(file);
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const names = [
|
||||
'=HYPERLINK("evil")',
|
||||
'Test, "Person"',
|
||||
"Zeile 1\nZeile 2",
|
||||
"'Original",
|
||||
...Array.from({ length: 8 }, (_, index) => `Person ${index}`),
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
names.map((name, index) =>
|
||||
storage.append({
|
||||
createdAt: `2026-01-01T00:00:0${index}.000Z`,
|
||||
name,
|
||||
email: `person${index}@example.test`,
|
||||
subject: "CSV Test",
|
||||
message: "Eine Nachricht mit mehr als zehn Zeichen.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const records = await storage.readAll();
|
||||
assert.equal(records.length, names.length);
|
||||
assert.deepEqual(
|
||||
records.map((record) => record.name).sort(),
|
||||
[...names].sort(),
|
||||
);
|
||||
const csv = await readFile(file, "utf8");
|
||||
assert.match(csv, /'=HYPERLINK/);
|
||||
assert.match(csv, /"Test, ""Person"""/);
|
||||
});
|
||||
|
||||
// Beschädigte CSV-Kopfzeilen sollen fehlschlagen und unangetastet bleiben.
|
||||
test("CSV mit unerwartetem Kopf wird nicht überschrieben", async (t) => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), "trihub-contact-csv-invalid-"),
|
||||
);
|
||||
const file = join(directory, "contact-requests.csv");
|
||||
const storage = createContactFormStorage(file);
|
||||
const corrupt = "wrong,header\n1,2\n";
|
||||
await writeFile(file, corrupt);
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
|
||||
await assert.rejects(
|
||||
storage.append({ ...validInput, createdAt: "2026-01-01" }),
|
||||
);
|
||||
assert.equal(await readFile(file, "utf8"), corrupt);
|
||||
});
|
||||
|
||||
// Verifiziert die zwei Mailvorlagen und dass der Transport fest bei Mailpit bleibt.
|
||||
test("Mailservice verwendet Mailpit und sendet Bestätigung sowie Tri-Hub-Anfrage", async () => {
|
||||
const config = [];
|
||||
const messages = [];
|
||||
const sendContactForm = createContactFormEmailService(
|
||||
{},
|
||||
(transportConfig) => {
|
||||
config.push(transportConfig);
|
||||
return {
|
||||
async sendMail(message) {
|
||||
messages.push(message);
|
||||
return { accepted: [message.to] };
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
await sendContactForm({
|
||||
...validInput,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
assert.equal(config[0].host, "127.0.0.1");
|
||||
assert.equal(config[0].port, 1025);
|
||||
assert.deepEqual(
|
||||
messages.map((message) => message.to).sort(),
|
||||
[validInput.email, "ernaehrung@tri-hub.de"].sort(),
|
||||
);
|
||||
assert.ok(
|
||||
messages.every(
|
||||
(message) =>
|
||||
message.from === "Tri-Hub Ernährung <noreply@tri-hub.de>",
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
messages.every((message) => message.text.includes(validInput.message)),
|
||||
);
|
||||
const request = messages.find(
|
||||
(message) => message.to === "ernaehrung@tri-hub.de",
|
||||
);
|
||||
assert.equal(request.replyTo, validInput.email);
|
||||
});
|
||||
|
||||
// SMTP-Ablehnung wird als Fehler sichtbar, statt fälschlich Erfolg zu melden.
|
||||
test("Mailservice meldet, wenn Mailpit einen Empfänger ablehnt", async () => {
|
||||
const sendContactForm = createContactFormEmailService({}, () => ({
|
||||
async sendMail(message) {
|
||||
return {
|
||||
accepted: message.to === validInput.email ? [message.to] : [],
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
await assert.rejects(
|
||||
sendContactForm(validInput),
|
||||
(error) =>
|
||||
error.status === 502 && error.code === "CONTACT_EMAIL_FAILED",
|
||||
);
|
||||
});
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createApp } from "../server/index.js";
|
||||
import { createContactFormEmailService } from "../server/services/contact-form-email.js";
|
||||
import { createContactFormStorage } from "../server/services/contact-form-storage.js";
|
||||
|
||||
const validInput = {
|
||||
consent: true,
|
||||
name: "Test Person",
|
||||
email: "person@example.test",
|
||||
subject: "Eine Frage",
|
||||
message: "Das ist eine ausreichend lange Nachricht.",
|
||||
};
|
||||
|
||||
// Startet die API mit temporärer Kontakt-CSV und einem ersetzbaren Mailversand.
|
||||
async function fixture(t, options = {}) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "trihub-contact-form-"));
|
||||
const storage =
|
||||
options.storage ??
|
||||
createContactFormStorage(join(directory, "contact-requests.csv"));
|
||||
const sent = [];
|
||||
const app = createApp({
|
||||
sendConfirmation: async () => {},
|
||||
sendContact: async () => {},
|
||||
contactFormStorage: storage,
|
||||
sendContactForm: async (data) => sent.push(data),
|
||||
...options,
|
||||
});
|
||||
await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve));
|
||||
t.after(async () => {
|
||||
app.closeAllConnections();
|
||||
await new Promise((resolve) => app.close(resolve));
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const base = `http://127.0.0.1:${app.address().port}`;
|
||||
async function post(input = validInput, headers = {}) {
|
||||
const response = await fetch(`${base}/api/nutrition-contact`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return { status: response.status, body: await response.json() };
|
||||
}
|
||||
return { app, base, post, sent, storage, directory };
|
||||
}
|
||||
|
||||
// Prüft den erfolgreichen Ablauf von HTTP-Anfrage über CSV bis zur Mail-Abhängigkeit.
|
||||
test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", async (t) => {
|
||||
const f = await fixture(t);
|
||||
const result = await f.post();
|
||||
|
||||
assert.equal(result.status, 201);
|
||||
assert.deepEqual(result.body, { saved: true, emailStatus: "accepted" });
|
||||
assert.equal(f.sent.length, 1);
|
||||
assert.deepEqual(
|
||||
{
|
||||
consent: f.sent[0].consent,
|
||||
name: f.sent[0].name,
|
||||
email: f.sent[0].email,
|
||||
subject: f.sent[0].subject,
|
||||
message: f.sent[0].message,
|
||||
},
|
||||
validInput,
|
||||
);
|
||||
assert.ok(!Number.isNaN(Date.parse(f.sent[0].createdAt)));
|
||||
assert.deepEqual(await f.storage.readAll(), f.sent);
|
||||
});
|
||||
|
||||
// Stellt sicher, dass fehlerhafte Pflichtfelder nicht gespeichert oder versendet werden.
|
||||
test("Pflichtfelder, E-Mail, Längen und Mindestlänge werden validiert", async (t) => {
|
||||
const f = await fixture(t);
|
||||
const invalidInputs = [
|
||||
...[false, "true", undefined].map((consent) => ({
|
||||
...validInput,
|
||||
consent,
|
||||
})),
|
||||
{ ...validInput, name: " " },
|
||||
{ ...validInput, name: "Test\nBcc: fremd" },
|
||||
{ ...validInput, name: "x".repeat(121) },
|
||||
{ ...validInput, email: "keine-adresse" },
|
||||
{ ...validInput, email: "x".repeat(255) },
|
||||
{ ...validInput, subject: "x".repeat(161) },
|
||||
{ ...validInput, message: "zu kurz" },
|
||||
{ ...validInput, message: "x".repeat(5001) },
|
||||
{ ...validInput, message: "Nachricht\u0000ungültig" },
|
||||
{ ...validInput, message: undefined },
|
||||
null,
|
||||
[],
|
||||
];
|
||||
|
||||
for (const input of invalidInputs) {
|
||||
const result = await f.post(input);
|
||||
assert.equal(result.status, 400, JSON.stringify(input));
|
||||
}
|
||||
assert.deepEqual(await f.storage.readAll(), []);
|
||||
assert.equal(f.sent.length, 0);
|
||||
});
|
||||
|
||||
// Prüft die Begrenzungen und JSON-Fehlerbehandlung des HTTP-Endpunkts.
|
||||
test("HTTP-Route lehnt falsche Methode, Inhaltstyp, JSON und große Bodies ab", async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal((await fetch(`${f.base}/api/nutrition-contact`)).status, 405);
|
||||
|
||||
const wrongType = await fetch(`${f.base}/api/nutrition-contact`, {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
assert.equal(wrongType.status, 415);
|
||||
|
||||
const invalidJson = await fetch(`${f.base}/api/nutrition-contact`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{",
|
||||
});
|
||||
assert.equal(invalidJson.status, 400);
|
||||
|
||||
const tooLarge = await fetch(`${f.base}/api/nutrition-contact`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "x".repeat(9000) }),
|
||||
});
|
||||
assert.equal(tooLarge.status, 413);
|
||||
assert.deepEqual(await f.storage.readAll(), []);
|
||||
});
|
||||
|
||||
// Ein Speicherfehler muss verhindern, dass anschließend E-Mails abgeschickt werden.
|
||||
test("Speicherfehler melden 503 und starten keinen Mailversand", async (t) => {
|
||||
let sendCount = 0;
|
||||
const f = await fixture(t, {
|
||||
storage: {
|
||||
async append() {
|
||||
throw new Error("Datenträger nicht verfügbar");
|
||||
},
|
||||
},
|
||||
sendContactForm: async () => sendCount++,
|
||||
});
|
||||
const result = await f.post();
|
||||
|
||||
assert.equal(result.status, 503);
|
||||
assert.equal(result.body.error.code, "CONTACT_NOT_SAVED");
|
||||
assert.equal(sendCount, 0);
|
||||
});
|
||||
|
||||
// Nach einem SMTP-Fehler bleibt die bereits gespeicherte Anfrage erhalten.
|
||||
test("Mailfehler melden 502, erhalten aber die gespeicherte Anfrage", async (t) => {
|
||||
const f = await fixture(t, {
|
||||
sendContactForm: async () => {
|
||||
throw new Error("Mailpit nicht erreichbar");
|
||||
},
|
||||
});
|
||||
const result = await f.post();
|
||||
|
||||
assert.equal(result.status, 502);
|
||||
assert.equal(result.body.error.code, "CONTACT_EMAIL_FAILED");
|
||||
assert.equal(result.body.error.message.includes("gespeichert"), true);
|
||||
assert.equal((await f.storage.readAll()).length, 1);
|
||||
});
|
||||
|
||||
// Prüft CSV-Rundlauf, Formelschutz und gleichzeitiges Anhängen mehrerer Anfragen.
|
||||
test("CSV erhält Sonderzeichen und verliert bei parallelen Anhängen keine Zeilen", async (t) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "trihub-contact-csv-"));
|
||||
const file = join(directory, "contact-requests.csv");
|
||||
const storage = createContactFormStorage(file);
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const names = [
|
||||
'=HYPERLINK("evil")',
|
||||
'Test, "Person"',
|
||||
"Zeile 1\nZeile 2",
|
||||
"'Original",
|
||||
...Array.from({ length: 8 }, (_, index) => `Person ${index}`),
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
names.map((name, index) =>
|
||||
storage.append({
|
||||
createdAt: `2026-01-01T00:00:0${index}.000Z`,
|
||||
name,
|
||||
email: `person${index}@example.test`,
|
||||
subject: "CSV Test",
|
||||
message: "Eine Nachricht mit mehr als zehn Zeichen.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const records = await storage.readAll();
|
||||
assert.equal(records.length, names.length);
|
||||
assert.deepEqual(
|
||||
records.map((record) => record.name).sort(),
|
||||
[...names].sort(),
|
||||
);
|
||||
const csv = await readFile(file, "utf8");
|
||||
assert.match(csv, /'=HYPERLINK/);
|
||||
assert.match(csv, /"Test, ""Person"""/);
|
||||
});
|
||||
|
||||
// Beschädigte CSV-Kopfzeilen sollen fehlschlagen und unangetastet bleiben.
|
||||
test("CSV mit unerwartetem Kopf wird nicht überschrieben", async (t) => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), "trihub-contact-csv-invalid-"),
|
||||
);
|
||||
const file = join(directory, "contact-requests.csv");
|
||||
const storage = createContactFormStorage(file);
|
||||
const corrupt = "wrong,header\n1,2\n";
|
||||
await writeFile(file, corrupt);
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
|
||||
await assert.rejects(
|
||||
storage.append({ ...validInput, createdAt: "2026-01-01" }),
|
||||
);
|
||||
assert.equal(await readFile(file, "utf8"), corrupt);
|
||||
});
|
||||
|
||||
// Verifiziert die zwei Mailvorlagen und dass der Transport fest bei Mailpit bleibt.
|
||||
test("Mailservice verwendet Mailpit und sendet Bestätigung sowie Tri-Hub-Anfrage", async () => {
|
||||
const config = [];
|
||||
const messages = [];
|
||||
const sendContactForm = createContactFormEmailService(
|
||||
{},
|
||||
(transportConfig) => {
|
||||
config.push(transportConfig);
|
||||
return {
|
||||
async sendMail(message) {
|
||||
messages.push(message);
|
||||
return { accepted: [message.to] };
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
await sendContactForm({
|
||||
...validInput,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
assert.equal(config[0].host, "127.0.0.1");
|
||||
assert.equal(config[0].port, 1025);
|
||||
assert.deepEqual(
|
||||
messages.map((message) => message.to).sort(),
|
||||
[validInput.email, "ernaehrung@tri-hub.de"].sort(),
|
||||
);
|
||||
assert.ok(
|
||||
messages.every(
|
||||
(message) =>
|
||||
message.from === "Tri-Hub Ernährung <noreply@tri-hub.de>",
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
messages.every((message) => message.text.includes(validInput.message)),
|
||||
);
|
||||
const request = messages.find(
|
||||
(message) => message.to === "ernaehrung@tri-hub.de",
|
||||
);
|
||||
assert.equal(request.replyTo, validInput.email);
|
||||
});
|
||||
|
||||
// SMTP-Ablehnung wird als Fehler sichtbar, statt fälschlich Erfolg zu melden.
|
||||
test("Mailservice meldet, wenn Mailpit einen Empfänger ablehnt", async () => {
|
||||
const sendContactForm = createContactFormEmailService({}, () => ({
|
||||
async sendMail(message) {
|
||||
return {
|
||||
accepted: message.to === validInput.email ? [message.to] : [],
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
await assert.rejects(
|
||||
sendContactForm(validInput),
|
||||
(error) =>
|
||||
error.status === 502 && error.code === "CONTACT_EMAIL_FAILED",
|
||||
);
|
||||
});
|
||||
|
||||
test("Alte Kontakt-CSV bleibt lesbar; neue Zustimmung wird separat gespeichert", async (t) => {
|
||||
const f = await fixture(t);
|
||||
await writeFile(
|
||||
join(f.directory, "contact-requests.csv"),
|
||||
"createdAt,name,email,subject,message\n2026-01-01,Alt,alt@example.test,Frage,Alte Nachricht\n",
|
||||
);
|
||||
assert.equal((await f.post()).status, 201);
|
||||
const records = await f.storage.readAll();
|
||||
assert.equal(records.length, 2);
|
||||
assert.notEqual(records[0].consent, true);
|
||||
assert.equal(records[0].message, "Alte Nachricht");
|
||||
assert.equal(records[1].consent, true);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,189 +1,224 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm } 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 { 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-"));
|
||||
const storage = createCsvStorage(join(dir, "bookings.csv"));
|
||||
await storage.writeAll([
|
||||
{
|
||||
bookingId: "TH-000001",
|
||||
email: "kunde@example.test",
|
||||
name: "Testkunde",
|
||||
},
|
||||
]);
|
||||
const sent = [];
|
||||
const app = createApp({
|
||||
storage,
|
||||
sendConfirmation: async () => {},
|
||||
sendContact: async (data) => sent.push(data),
|
||||
...options,
|
||||
});
|
||||
await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve));
|
||||
t.after(async () => {
|
||||
app.closeAllConnections();
|
||||
await new Promise((resolve) => app.close(resolve));
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
const base = `http://127.0.0.1:${app.address().port}`;
|
||||
const post = async (path, input) => {
|
||||
const response = await fetch(base + path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return { status: response.status, body: await response.json() };
|
||||
};
|
||||
return { post, sent, storage, base };
|
||||
}
|
||||
const input = {
|
||||
email: "kunde@example.test",
|
||||
advisorId: "relindis-agethen",
|
||||
reason: "coaching",
|
||||
simulationAccepted: true,
|
||||
};
|
||||
|
||||
test("CSV-Abgleich normalisiert E-Mail; Versand erhält Berater und festen Redirect", async (t) => {
|
||||
const f = await fixture(t);
|
||||
const before = await f.storage.readAll();
|
||||
const validation = await f.post("/api/contact/validate", {
|
||||
email: " KUNDE@example.test ",
|
||||
});
|
||||
assert.equal(validation.status, 200);
|
||||
assert.equal(validation.body.email, input.email);
|
||||
const result = await f.post("/api/contact", {
|
||||
...input,
|
||||
advisor: "Manipuliert",
|
||||
advisorEmail: "falsch@example.test",
|
||||
});
|
||||
assert.equal(result.status, 201);
|
||||
assert.equal(result.body.redirectUrl, contactBookingUrl);
|
||||
assert.equal(result.body.simulated, true);
|
||||
assert.equal(f.sent[0].advisor.email, "relindis.agethen@tri-hub.de");
|
||||
assert.equal(f.sent[0].advisor.vorname, "Relindis");
|
||||
const second = await f.post("/api/contact", {
|
||||
...input,
|
||||
advisorId: "maren-hoffmann",
|
||||
});
|
||||
assert.equal(second.status, 201);
|
||||
assert.equal(f.sent[1].advisor.email, "maren.hoffmann@tri-hub.de");
|
||||
assert.deepEqual(await f.storage.readAll(), before);
|
||||
});
|
||||
|
||||
test("Ungültige und unbekannte Kunden, manipulierte Berater, Gründe und fehlende Zustimmung senden nichts", async (t) => {
|
||||
const f = await fixture(t);
|
||||
for (const [data, status] of [
|
||||
[null, 400],
|
||||
[{ ...input, email: "bad" }, 400],
|
||||
[{ ...input, email: "unknown@example.test" }, 403],
|
||||
[{ ...input, advisorId: "unbekannt" }, 400],
|
||||
[{ ...input, advisorId: null }, 400],
|
||||
[{ ...input, reason: "__proto__" }, 400],
|
||||
[{ ...input, simulationAccepted: false }, 400],
|
||||
])
|
||||
assert.equal((await f.post("/api/contact", data)).status, status);
|
||||
assert.equal(f.sent.length, 0);
|
||||
});
|
||||
|
||||
test("Absenden prüft CSV erneut; Speicherfehler bleiben geschlossen", async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal((await f.post("/api/contact/validate", input)).status, 200);
|
||||
await f.storage.writeAll([]);
|
||||
assert.equal((await f.post("/api/contact", input)).status, 403);
|
||||
const broken = await fixture(t, {
|
||||
storage: {
|
||||
readAll() {
|
||||
throw new Error("unavailable");
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal((await broken.post("/api/contact", input)).status, 503);
|
||||
assert.equal(broken.sent.length, 0);
|
||||
});
|
||||
|
||||
test("HTTP-Vertrag weist falsche Methode, ungültiges JSON und große Requests zurück", async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal((await fetch(f.base + "/api/contact")).status, 405);
|
||||
assert.equal(
|
||||
(await fetch(f.base + "/api/contact", { method: "POST", body: "x" }))
|
||||
.status,
|
||||
415,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await fetch(f.base + "/api/contact", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{",
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.post("/api/contact", { email: "x".repeat(9000) })).status,
|
||||
413,
|
||||
);
|
||||
});
|
||||
|
||||
test("Zwei getrennte simulierte Bestätigungen; Teilfehler verhindern Erfolg", async (t) => {
|
||||
const messages = [];
|
||||
const sender = createContactEmailService((config) => {
|
||||
assert.equal(config.host, "127.0.0.1");
|
||||
assert.equal(config.port, 1025);
|
||||
return {
|
||||
async sendMail(message) {
|
||||
messages.push(message);
|
||||
return { accepted: [message.to] };
|
||||
},
|
||||
};
|
||||
});
|
||||
const f = await fixture(t, { sendContact: sender });
|
||||
assert.equal((await f.post("/api/contact", input)).status, 201);
|
||||
assert.deepEqual(
|
||||
messages.map((m) => m.to),
|
||||
[input.email, "relindis.agethen@tri-hub.de"],
|
||||
);
|
||||
for (const message of messages) {
|
||||
assert.match(message.text, /Simulation/);
|
||||
assert.match(message.text, /Persönliches Coaching/);
|
||||
}
|
||||
const failing = createContactEmailService(() => ({
|
||||
async sendMail(message) {
|
||||
return { accepted: message.to === input.email ? [message.to] : [] };
|
||||
},
|
||||
}));
|
||||
const failure = await fixture(t, { sendContact: failing });
|
||||
const result = await failure.post("/api/contact", input);
|
||||
assert.equal(result.status, 502);
|
||||
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",
|
||||
);
|
||||
});
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createApp } from "../server/index.js";
|
||||
import { createContactFormStorage } from "../server/services/contact-form-storage.js";
|
||||
import { createCsvStorage } from "../server/services/csv-storage.js";
|
||||
import { createContactEmailService } from "../server/services/contact-email.js";
|
||||
import { contactBookingUrl } from "../shared/contact.js";
|
||||
|
||||
async function fixture(t, options = {}) {
|
||||
const dir = await mkdtemp(join(tmpdir(), "trihub-contact-"));
|
||||
const storage = createCsvStorage(join(dir, "bookings.csv"));
|
||||
await storage.writeAll([
|
||||
{
|
||||
bookingId: "TH-000001",
|
||||
email: "kunde@example.test",
|
||||
name: "Testkunde",
|
||||
},
|
||||
]);
|
||||
const requests = createContactFormStorage(
|
||||
join(dir, "advisor-contacts.csv"),
|
||||
[
|
||||
"createdAt",
|
||||
"email",
|
||||
"advisorId",
|
||||
"reason",
|
||||
"simulationAccepted",
|
||||
"consent",
|
||||
],
|
||||
);
|
||||
const sent = [];
|
||||
const app = createApp({
|
||||
storage,
|
||||
advisorContactStorage: requests,
|
||||
sendConfirmation: async () => {},
|
||||
sendContact: async (data) => sent.push(data),
|
||||
...options,
|
||||
});
|
||||
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(dir, { recursive: true, force: true });
|
||||
});
|
||||
const base = `http://127.0.0.1:${app.address().port}`;
|
||||
const post = async (path, input) => {
|
||||
const response = await fetch(base + path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return { status: response.status, body: await response.json() };
|
||||
};
|
||||
return { post, sent, storage, requests, base };
|
||||
}
|
||||
const input = {
|
||||
consent: true,
|
||||
email: "kunde@example.test",
|
||||
advisorId: "relindis-agethen",
|
||||
reason: "coaching",
|
||||
simulationAccepted: true,
|
||||
};
|
||||
|
||||
test("CSV-Abgleich normalisiert E-Mail; Versand erhält Berater und festen Redirect", async (t) => {
|
||||
const f = await fixture(t);
|
||||
const before = await f.storage.readAll();
|
||||
const validation = await f.post("/api/contact/validate", {
|
||||
email: " KUNDE@example.test ",
|
||||
});
|
||||
assert.equal(validation.status, 200);
|
||||
assert.equal(validation.body.email, input.email);
|
||||
const result = await f.post("/api/contact", {
|
||||
...input,
|
||||
advisor: "Manipuliert",
|
||||
advisorEmail: "falsch@example.test",
|
||||
});
|
||||
assert.equal(result.status, 201);
|
||||
assert.equal(result.body.redirectUrl, contactBookingUrl);
|
||||
assert.equal(result.body.simulated, true);
|
||||
assert.equal(f.sent[0].advisor.email, "relindis.agethen@tri-hub.de");
|
||||
assert.equal(f.sent[0].advisor.vorname, "Relindis");
|
||||
const second = await f.post("/api/contact", {
|
||||
...input,
|
||||
advisorId: "maren-hoffmann",
|
||||
});
|
||||
assert.equal(second.status, 201);
|
||||
assert.equal(f.sent[1].advisor.email, "maren.hoffmann@tri-hub.de");
|
||||
assert.deepEqual(await f.storage.readAll(), before);
|
||||
const records = await f.requests.readAll();
|
||||
assert.equal(records.length, 2);
|
||||
assert.equal(records[0].consent, true);
|
||||
assert.equal(records[0].advisorId, input.advisorId);
|
||||
});
|
||||
|
||||
test("Ungültige und unbekannte Kunden, manipulierte Berater, Gründe und fehlende Zustimmung senden nichts", async (t) => {
|
||||
const f = await fixture(t);
|
||||
for (const [data, status] of [
|
||||
...[false, "true", undefined].map((consent) => [
|
||||
{ ...input, consent },
|
||||
400,
|
||||
]),
|
||||
[null, 400],
|
||||
[{ ...input, email: "bad" }, 400],
|
||||
[{ ...input, email: "unknown@example.test" }, 403],
|
||||
[{ ...input, advisorId: "unbekannt" }, 400],
|
||||
[{ ...input, advisorId: null }, 400],
|
||||
[{ ...input, reason: "__proto__" }, 400],
|
||||
[{ ...input, simulationAccepted: false }, 400],
|
||||
])
|
||||
assert.equal((await f.post("/api/contact", data)).status, status);
|
||||
assert.equal(f.sent.length, 0);
|
||||
assert.deepEqual(await f.requests.readAll(), []);
|
||||
});
|
||||
|
||||
test("Absenden prüft CSV erneut; Speicherfehler bleiben geschlossen", async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal((await f.post("/api/contact/validate", input)).status, 200);
|
||||
await f.storage.writeAll([]);
|
||||
assert.equal((await f.post("/api/contact", input)).status, 403);
|
||||
const broken = await fixture(t, {
|
||||
storage: {
|
||||
readAll() {
|
||||
throw new Error("unavailable");
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal((await broken.post("/api/contact", input)).status, 503);
|
||||
assert.equal(broken.sent.length, 0);
|
||||
});
|
||||
|
||||
test("HTTP-Vertrag weist falsche Methode, ungültiges JSON und große Requests zurück", async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal((await fetch(f.base + "/api/contact")).status, 405);
|
||||
assert.equal(
|
||||
(await fetch(f.base + "/api/contact", { method: "POST", body: "x" }))
|
||||
.status,
|
||||
415,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await fetch(f.base + "/api/contact", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{",
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.post("/api/contact", { email: "x".repeat(9000) })).status,
|
||||
413,
|
||||
);
|
||||
});
|
||||
|
||||
test("Zwei getrennte simulierte Bestätigungen; Teilfehler verhindern Erfolg", async (t) => {
|
||||
const messages = [];
|
||||
const sender = createContactEmailService((config) => {
|
||||
assert.equal(config.host, "127.0.0.1");
|
||||
assert.equal(config.port, 1025);
|
||||
return {
|
||||
async sendMail(message) {
|
||||
messages.push(message);
|
||||
return { accepted: [message.to] };
|
||||
},
|
||||
};
|
||||
});
|
||||
const f = await fixture(t, { sendContact: sender });
|
||||
assert.equal((await f.post("/api/contact", input)).status, 201);
|
||||
assert.deepEqual(
|
||||
messages.map((m) => m.to),
|
||||
[input.email, "relindis.agethen@tri-hub.de"],
|
||||
);
|
||||
for (const message of messages) {
|
||||
assert.match(message.text, /Simulation/);
|
||||
assert.match(message.text, /Persönliches Coaching/);
|
||||
}
|
||||
const failing = createContactEmailService(() => ({
|
||||
async sendMail(message) {
|
||||
return { accepted: message.to === input.email ? [message.to] : [] };
|
||||
},
|
||||
}));
|
||||
const failure = await fixture(t, { sendContact: failing });
|
||||
const result = await failure.post("/api/contact", input);
|
||||
assert.equal(result.status, 502);
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,24 +1,42 @@
|
|||
// Ausschließlich Testserver: temporäre CSV und simulierter Versand.
|
||||
import { mkdtemp, rm } 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 { packages } from "../../shared/packages.js";
|
||||
|
||||
const directory = await mkdtemp(join(tmpdir(), "trihub-browser-"));
|
||||
const server = createApp({
|
||||
catalog: packages,
|
||||
storage: createCsvStorage(join(directory, "bookings.csv")),
|
||||
sendConfirmation: async () => {},
|
||||
sendContact: async () => {},
|
||||
});
|
||||
server.listen(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);
|
||||
// Ausschließlich Testserver: temporäre CSV und simulierter Versand.
|
||||
import { mkdtemp, rm } 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";
|
||||
import { createContactFormStorage } from "../../server/services/contact-form-storage.js";
|
||||
import { packages } from "../../shared/packages.js";
|
||||
|
||||
const directory = await mkdtemp(join(tmpdir(), "trihub-browser-"));
|
||||
const server = createApp({
|
||||
catalog: packages,
|
||||
storage: createCsvStorage(join(directory, "bookings.csv")),
|
||||
reviewStorage: createReviewStorage(join(directory, "reviews.csv")),
|
||||
advisorContactStorage: createContactFormStorage(
|
||||
join(directory, "advisor-contacts.csv"),
|
||||
[
|
||||
"createdAt",
|
||||
"email",
|
||||
"advisorId",
|
||||
"reason",
|
||||
"simulationAccepted",
|
||||
"consent",
|
||||
],
|
||||
),
|
||||
contactFormStorage: createContactFormStorage(
|
||||
join(directory, "contact-requests.csv"),
|
||||
),
|
||||
sendContactForm: async () => {},
|
||||
sendConfirmation: async () => {},
|
||||
sendContact: async () => {},
|
||||
});
|
||||
server.listen(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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue