Merge branch 'dev' into feature/FAQ
commit
ea47960393
|
|
@ -6,6 +6,7 @@
|
||||||
<title>Ernährungs-Pakete – Tri-Hub</title>
|
<title>Ernährungs-Pakete – Tri-Hub</title>
|
||||||
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
||||||
<link rel="stylesheet" href="/src/styles/packages.css" />
|
<link rel="stylesheet" href="/src/styles/packages.css" />
|
||||||
|
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="packages-page">
|
<body class="packages-page">
|
||||||
<div id="navbar-placeholder"></div>
|
<div id="navbar-placeholder"></div>
|
||||||
|
|
@ -30,5 +31,7 @@
|
||||||
type="module"
|
type="module"
|
||||||
src="/src/features/angebote/angebote-entry.js"
|
src="/src/features/angebote/angebote-entry.js"
|
||||||
></script>
|
></script>
|
||||||
|
<div id="footer-placeholder"></div>
|
||||||
|
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
38
berater.html
38
berater.html
|
|
@ -3,11 +3,15 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Lerne die Ernährungsberaterinnen und Ernährungsberater von Tri-Hub kennen." />
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Lerne die Ernährungsberaterinnen und Ernährungsberater von Tri-Hub kennen."
|
||||||
|
/>
|
||||||
<title>Beraterprofil · Tri-Hub</title>
|
<title>Beraterprofil · Tri-Hub</title>
|
||||||
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
||||||
<link rel="stylesheet" href="/src/styles/packages.css" />
|
<link rel="stylesheet" href="/src/styles/packages.css" />
|
||||||
<link rel="stylesheet" href="/src/features/berater/berater.css" />
|
<link rel="stylesheet" href="/src/features/berater/berater.css" />
|
||||||
|
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="packages-page advisor-page">
|
<body class="packages-page advisor-page">
|
||||||
<div id="navbar-placeholder"></div>
|
<div id="navbar-placeholder"></div>
|
||||||
|
|
@ -16,16 +20,36 @@
|
||||||
<div class="advisor-container">
|
<div class="advisor-container">
|
||||||
<header class="advisor-heading">
|
<header class="advisor-heading">
|
||||||
<p class="advisor-eyebrow">PERSÖNLICH FÜR DICH DA</p>
|
<p class="advisor-eyebrow">PERSÖNLICH FÜR DICH DA</p>
|
||||||
<h1 id="advisor-heading">Lerne deine Ernährungsberater kennen.</h1>
|
<h1 id="advisor-heading">
|
||||||
<p class="advisor-intro">Hinter einer guten Ernährungsstrategie stehen Menschen, die zuhören und ihre Erfahrung in deinen Alltag und dein Training einbringen.</p>
|
Lerne deine Ernährungsberater kennen.
|
||||||
|
</h1>
|
||||||
|
<p class="advisor-intro">
|
||||||
|
Hinter einer guten Ernährungsstrategie stehen
|
||||||
|
Menschen, die zuhören und ihre Erfahrung in deinen
|
||||||
|
Alltag und dein Training einbringen.
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="advisor-grid" id="advisor-profiles" aria-live="polite">
|
<div
|
||||||
<p class="advisor-loading">Beraterprofile werden geladen …</p>
|
class="advisor-grid"
|
||||||
|
id="advisor-profiles"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<p class="advisor-loading">
|
||||||
|
Beraterprofile werden geladen …
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<script type="module" src="/src/components/navigationsbar/navigation.js"></script>
|
<script
|
||||||
<script type="module" src="/src/features/berater/berater-entry.js"></script>
|
type="module"
|
||||||
|
src="/src/components/navigationsbar/navigation.js"
|
||||||
|
></script>
|
||||||
|
<script
|
||||||
|
type="module"
|
||||||
|
src="/src/features/berater/berater-entry.js"
|
||||||
|
></script>
|
||||||
|
<div id="footer-placeholder"></div>
|
||||||
|
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
<meta name="robots" content="noindex" />
|
<meta name="robots" content="noindex" />
|
||||||
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
||||||
<title>Buchungsbestätigung – Tri-Hub</title>
|
<title>Buchungsbestätigung – Tri-Hub</title>
|
||||||
|
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="confirmation">
|
<body class="confirmation">
|
||||||
<div id="navbar-placeholder"></div>
|
<div id="navbar-placeholder"></div>
|
||||||
|
|
@ -146,9 +147,6 @@
|
||||||
Buchungsbestätigung anzuzeigen.</noscript
|
Buchungsbestätigung anzuzeigen.</noscript
|
||||||
>
|
>
|
||||||
</main>
|
</main>
|
||||||
<footer class="confirmation__footer">
|
|
||||||
TRI-HUB <span>Dein Weg. Deine Ausdauer. Deine Ernährung.</span>
|
|
||||||
</footer>
|
|
||||||
<script
|
<script
|
||||||
type="module"
|
type="module"
|
||||||
src="/src/features/booking/booking-confirmation.js"
|
src="/src/features/booking/booking-confirmation.js"
|
||||||
|
|
@ -157,5 +155,7 @@
|
||||||
type="module"
|
type="module"
|
||||||
src="/src/components/navigationsbar/navigation.js"
|
src="/src/components/navigationsbar/navigation.js"
|
||||||
></script>
|
></script>
|
||||||
|
<div id="footer-placeholder"></div>
|
||||||
|
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="robots" content="noindex" />
|
<meta name="robots" content="noindex" />
|
||||||
<title>Paket buchen – Tri-Hub</title>
|
<title>Paket buchen – Tri-Hub</title>
|
||||||
|
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="container">
|
<main class="container">
|
||||||
|
|
@ -17,5 +18,7 @@
|
||||||
type="module"
|
type="module"
|
||||||
src="/src/features/booking/booking-entry.js"
|
src="/src/features/booking/booking-entry.js"
|
||||||
></script>
|
></script>
|
||||||
|
<div id="footer-placeholder"></div>
|
||||||
|
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Berater kontaktieren – Tri-Hub Testseite</title>
|
<title>Berater kontaktieren – Tri-Hub Testseite</title>
|
||||||
|
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="contact-test">
|
<main class="contact-test">
|
||||||
|
|
@ -22,5 +23,7 @@
|
||||||
type="module"
|
type="module"
|
||||||
src="/src/features/contact/contact-entry.js"
|
src="/src/features/contact/contact-entry.js"
|
||||||
></script>
|
></script>
|
||||||
|
<div id="footer-placeholder"></div>
|
||||||
|
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
173
index.html
173
index.html
|
|
@ -3,6 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
||||||
|
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
|
|
@ -163,10 +164,26 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
padding: 92px 0 82px;
|
padding: 92px 0 82px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 56px;
|
||||||
|
z-index: -1;
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(8, 15, 21, 0.72),
|
||||||
|
rgba(8, 15, 21, 0.84)
|
||||||
|
),
|
||||||
|
url("/images/startseiter_hintergrund.jpeg") 38% center / cover no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-grid {
|
.hero-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.2fr) minmax(330px, 0.8fr);
|
grid-template-columns: minmax(0, 1.2fr) minmax(330px, 0.8fr);
|
||||||
|
|
@ -240,6 +257,7 @@
|
||||||
|
|
||||||
.hero-card {
|
.hero-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
transform: translateX(80px);
|
||||||
min-height: 410px;
|
min-height: 410px;
|
||||||
padding: 38px;
|
padding: 38px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -256,8 +274,8 @@
|
||||||
),
|
),
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
145deg,
|
145deg,
|
||||||
rgba(255, 255, 255, 0.075),
|
rgba(13, 19, 31, 0.78),
|
||||||
rgba(255, 255, 255, 0.025)
|
rgba(13, 19, 31, 0.84)
|
||||||
);
|
);
|
||||||
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28);
|
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28);
|
||||||
}
|
}
|
||||||
|
|
@ -274,6 +292,7 @@
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 34px rgba(83, 213, 205, 0.035),
|
0 0 0 34px rgba(83, 213, 205, 0.035),
|
||||||
0 0 0 68px rgba(83, 213, 205, 0.02);
|
0 0 0 68px rgba(83, 213, 205, 0.02);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-card-mark {
|
.hero-card-mark {
|
||||||
|
|
@ -296,6 +315,11 @@
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-card > div {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
padding: 96px 0;
|
padding: 96px 0;
|
||||||
}
|
}
|
||||||
|
|
@ -375,6 +399,26 @@
|
||||||
font-size: clamp(2.5rem, 5vw, 4.7rem);
|
font-size: clamp(2.5rem, 5vw, 4.7rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.knowledge-image-frame {
|
||||||
|
width: 100%;
|
||||||
|
transform: translateX(-18px);
|
||||||
|
margin-top: 28px;
|
||||||
|
padding: 9px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(248, 250, 252, 0.18);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: linear-gradient(145deg, #273135, #151d22);
|
||||||
|
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-image-frame img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 2.58 / 1;
|
||||||
|
border-radius: 14px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.knowledge-copy {
|
.knowledge-copy {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
line-height: 1.85;
|
line-height: 1.85;
|
||||||
|
|
@ -547,6 +591,11 @@
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.consent a {
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.consent input {
|
.consent input {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
accent-color: var(--brand);
|
accent-color: var(--brand);
|
||||||
|
|
@ -692,6 +741,7 @@
|
||||||
|
|
||||||
.hero-card {
|
.hero-card {
|
||||||
min-height: 330px;
|
min-height: 330px;
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -879,6 +929,13 @@
|
||||||
<h2 style="font-family: "Inter", sans-serif">
|
<h2 style="font-family: "Inter", sans-serif">
|
||||||
Wissen,<br />das dich<br />weiterbringt.
|
Wissen,<br />das dich<br />weiterbringt.
|
||||||
</h2>
|
</h2>
|
||||||
|
<div class="knowledge-image-frame">
|
||||||
|
<img
|
||||||
|
src="/images/exttraBild.jpeg"
|
||||||
|
alt="Ergänzendes Bild zur Ernährungsberatung"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -994,10 +1051,9 @@
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<p class="review-note">
|
<p class="review-note">
|
||||||
Hinweis: Die Bewertungen in dieser HTML-Demo werden
|
Nur Kunden mit einer vorhandenen Buchung können eine
|
||||||
nicht dauerhaft gespeichert. Für den Live-Betrieb
|
Bewertung abgeben. Deine E-Mail-Adresse wird nicht
|
||||||
sollte das Formular an das bestehende Backend oder
|
öffentlich angezeigt.
|
||||||
CMS von Tri-hub angebunden werden.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1027,19 +1083,36 @@
|
||||||
|
|
||||||
<form class="review-form" id="reviewForm">
|
<form class="review-form" id="reviewForm">
|
||||||
<div class="field" style="margin-top: 0">
|
<div class="field" style="margin-top: 0">
|
||||||
<label for="name">Dein Name</label>
|
<label for="reviewEmail"
|
||||||
|
>E-Mail deiner Buchung*</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
id="name"
|
id="reviewEmail"
|
||||||
name="name"
|
name="email"
|
||||||
type="text"
|
type="email"
|
||||||
autocomplete="name"
|
autocomplete="email"
|
||||||
placeholder="z. B. Thomas M."
|
maxlength="254"
|
||||||
|
placeholder="z. B. thomas@example.de"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Deine Bewertung</label>
|
<label for="reviewTitle"
|
||||||
|
>Titel (10–50 Zeichen)*</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="reviewTitle"
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
minlength="10"
|
||||||
|
maxlength="50"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Deine Bewertung*</label>
|
||||||
<div
|
<div
|
||||||
class="rating-input"
|
class="rating-input"
|
||||||
aria-label="Sternebewertung"
|
aria-label="Sternebewertung"
|
||||||
|
|
@ -1088,10 +1161,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="review">Deine Erfahrung</label>
|
<label for="review"
|
||||||
|
>Deine Erfahrung (10–500 Zeichen)*</label
|
||||||
|
>
|
||||||
<textarea
|
<textarea
|
||||||
id="review"
|
id="review"
|
||||||
name="review"
|
name="review"
|
||||||
|
minlength="10"
|
||||||
|
maxlength="500"
|
||||||
placeholder="Was hat dir an der Ernährungsberatung geholfen?"
|
placeholder="Was hat dir an der Ernährungsberatung geholfen?"
|
||||||
required
|
required
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
@ -1100,12 +1177,22 @@
|
||||||
<label class="consent">
|
<label class="consent">
|
||||||
<input type="checkbox" name="consent" required />
|
<input type="checkbox" name="consent" required />
|
||||||
<span>
|
<span>
|
||||||
Ich stimme zu, dass meine Bewertung mit meinem
|
Ich akzeptiere die
|
||||||
angegebenen Namen auf dieser Seite
|
<a
|
||||||
veröffentlicht werden darf.
|
href="https://www.tri-hub.de/datenschutz"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>Datenschutzbestimmungen</a
|
||||||
|
>*: Meine E-Mail-Adresse wird zum Abgleich mit
|
||||||
|
meiner Buchung und zusammen mit meiner Bewertung
|
||||||
|
gespeichert. Titel, Bewertungstext und Sterne
|
||||||
|
dürfen veröffentlicht werden; meine
|
||||||
|
E-Mail-Adresse bleibt nicht öffentlich.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<small>*Pflichtfelder</small>
|
||||||
|
|
||||||
<button class="button button-primary" type="submit">
|
<button class="button button-primary" type="submit">
|
||||||
Bewertung veröffentlichen
|
Bewertung veröffentlichen
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1217,6 +1304,24 @@
|
||||||
<small>*Pflichtfelder</small>
|
<small>*Pflichtfelder</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label class="consent"
|
||||||
|
><input
|
||||||
|
type="checkbox"
|
||||||
|
name="consent"
|
||||||
|
required
|
||||||
|
/><span
|
||||||
|
>Ich akzeptiere die
|
||||||
|
<a
|
||||||
|
href="https://www.tri-hub.de/datenschutz"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>Datenschutzbestimmungen</a
|
||||||
|
>
|
||||||
|
(Pflichtfeld).</span
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
<p data-error-for="consent" role="alert"></p>
|
||||||
|
|
||||||
<button class="button button-primary" type="submit">
|
<button class="button button-primary" type="submit">
|
||||||
Nachricht senden
|
Nachricht senden
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1232,42 +1337,20 @@
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<div id="footer-placeholder"></div>
|
||||||
<div class="container">
|
|
||||||
<div>
|
|
||||||
<strong>tri-hub</strong> · Triathlon als sportliches Zuhause
|
|
||||||
ab 50.
|
|
||||||
</div>
|
|
||||||
<div>Ernährung · Wissen · Erfahrungen</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script
|
<script
|
||||||
type="module"
|
type="module"
|
||||||
src="/src/components/navigationsbar/navigation.js"
|
src="/src/components/navigationsbar/navigation.js"
|
||||||
></script>
|
></script>
|
||||||
|
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||||
<script
|
<script
|
||||||
type="module"
|
type="module"
|
||||||
src="/src/features/contact/contact-form.js"
|
src="/src/features/contact/contact-form.js"
|
||||||
></script>
|
></script>
|
||||||
<script>
|
<script
|
||||||
const form = document.getElementById("reviewForm");
|
type="module"
|
||||||
const status = document.getElementById("formStatus");
|
src="/src/features/reviews/review-form.js"
|
||||||
|
></script>
|
||||||
form.addEventListener("submit", function (event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const name = form.elements.name.value.trim();
|
|
||||||
const rating = form.elements.rating.value;
|
|
||||||
const review = form.elements.review.value.trim();
|
|
||||||
|
|
||||||
if (!name || !rating || !review) return;
|
|
||||||
|
|
||||||
status.textContent =
|
|
||||||
"Danke für deine Bewertung. In dieser Demo wurde sie noch nicht dauerhaft gespeichert.";
|
|
||||||
|
|
||||||
form.reset();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
<title>Paket-Details – Tri-Hub Ernährungsberatung</title>
|
<title>Paket-Details – Tri-Hub Ernährungsberatung</title>
|
||||||
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
<link rel="stylesheet" href="/src/styles/navigation.css" />
|
||||||
<link rel="stylesheet" href="/src/styles/packages.css" />
|
<link rel="stylesheet" href="/src/styles/packages.css" />
|
||||||
|
<link rel="stylesheet" href="/src/styles/footer.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="packages-page">
|
<body class="packages-page">
|
||||||
<div id="navbar-placeholder"></div>
|
<div id="navbar-placeholder"></div>
|
||||||
|
|
@ -23,5 +24,7 @@
|
||||||
type="module"
|
type="module"
|
||||||
src="/src/features/angebote/paket-details.js"
|
src="/src/features/angebote/paket-details.js"
|
||||||
></script>
|
></script>
|
||||||
|
<div id="footer-placeholder"></div>
|
||||||
|
<script type="module" src="/src/components/footer/footer.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 824 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
|
|
@ -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>`
|
? `<span class="package-highlight">${pkg.highlight}</span>`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const cardHtml = `
|
const packageImage = {
|
||||||
<div class="package-card">
|
"ernaehrung-starter": {
|
||||||
|
src: "/images/Starter_bild.webp",
|
||||||
|
alt: "Triathlon-Einsteiger im Gespräch mit ihrem Coach am See",
|
||||||
|
},
|
||||||
|
"ernaehrung-standard": {
|
||||||
|
src: "/images/standard_bild.webp",
|
||||||
|
alt: "Triathlet im Radtrikot bespricht sein Training mit dem Coach",
|
||||||
|
},
|
||||||
|
"ernaehrung-premium": {
|
||||||
|
src: "/images/premium_bild.webp",
|
||||||
|
alt: "Triathlon-Athleten planen gemeinsam am Seeufer",
|
||||||
|
},
|
||||||
|
}[pkg.id];
|
||||||
|
|
||||||
|
const cardHtml = `
|
||||||
|
<article class="package-card">
|
||||||
|
<img class="package-image" src="${packageImage.src}" alt="${packageImage.alt}" loading="lazy" />
|
||||||
<div class="package-type-container">
|
<div class="package-type-container">
|
||||||
<span class="package-type">${pkg.type}</span>
|
<span class="package-type">${pkg.type}</span>
|
||||||
${highlightHtml}
|
${highlightHtml}
|
||||||
|
|
@ -43,7 +58,7 @@ export function renderAngebotsuebersicht() {
|
||||||
|
|
||||||
<a href="/paket-details.html?id=${pkg.id}" class="package-btn">${pkg.buttonText} <span aria-hidden="true">→</span></a>
|
<a href="/paket-details.html?id=${pkg.id}" class="package-btn">${pkg.buttonText} <span aria-hidden="true">→</span></a>
|
||||||
|
|
||||||
</div>
|
</article>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
container.innerHTML += cardHtml;
|
container.innerHTML += cardHtml;
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,8 @@ export function mountBookingPage(
|
||||||
<input id="${instanceId}-booking-phone" name="phone" type="tel" autocomplete="tel" maxlength="50" aria-describedby="${instanceId}-booking-phone-error" />
|
<input id="${instanceId}-booking-phone" name="phone" type="tel" autocomplete="tel" maxlength="50" aria-describedby="${instanceId}-booking-phone-error" />
|
||||||
<p class="booking__field-error" id="${instanceId}-booking-phone-error"></p>
|
<p class="booking__field-error" id="${instanceId}-booking-phone-error"></p>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="booking__consent"><input name="consent" type="checkbox" required aria-describedby="${instanceId}-booking-consent-error" /> <span>Ich akzeptiere die <a href="https://www.tri-hub.de/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> (Pflichtfeld).</span></label>
|
||||||
|
<p class="booking__field-error" id="${instanceId}-booking-consent-error"></p>
|
||||||
<div class="booking__actions">
|
<div class="booking__actions">
|
||||||
<button class="booking__button booking__cancel" type="button">Abbrechen</button>
|
<button class="booking__button booking__cancel" type="button">Abbrechen</button>
|
||||||
<button class="booking__button" type="submit">Paket buchen</button>
|
<button class="booking__button" type="submit">Paket buchen</button>
|
||||||
|
|
@ -192,6 +194,7 @@ export function mountBookingPage(
|
||||||
const errors = form.querySelector(".booking__errors");
|
const errors = form.querySelector(".booking__errors");
|
||||||
const progress = form.querySelector(".booking__progress");
|
const progress = form.querySelector(".booking__progress");
|
||||||
const result = content.querySelector(".booking__result");
|
const result = content.querySelector(".booking__result");
|
||||||
|
const consent = form.elements.consent;
|
||||||
const storageKey = `trihub.booking.${selected.packageId}${selected.variantId ? `.${selected.variantId}` : ""}`;
|
const storageKey = `trihub.booking.${selected.packageId}${selected.variantId ? `.${selected.variantId}` : ""}`;
|
||||||
let attempt = null;
|
let attempt = null;
|
||||||
let busy = false;
|
let busy = false;
|
||||||
|
|
@ -201,11 +204,12 @@ export function mountBookingPage(
|
||||||
name.readOnly = locked;
|
name.readOnly = locked;
|
||||||
email.readOnly = locked;
|
email.readOnly = locked;
|
||||||
phone.readOnly = locked;
|
phone.readOnly = locked;
|
||||||
|
consent.disabled = locked;
|
||||||
}
|
}
|
||||||
function showError(message, fields = {}) {
|
function showError(message, fields = {}) {
|
||||||
errors.textContent = message;
|
errors.textContent = message;
|
||||||
errors.hidden = false;
|
errors.hidden = false;
|
||||||
for (const field of [name, email, phone]) {
|
for (const field of [name, email, phone, consent]) {
|
||||||
const message = fields[field.name] || "";
|
const message = fields[field.name] || "";
|
||||||
content.querySelector(
|
content.querySelector(
|
||||||
`#${instanceId}-booking-${field.name}-error`,
|
`#${instanceId}-booking-${field.name}-error`,
|
||||||
|
|
@ -216,7 +220,7 @@ export function mountBookingPage(
|
||||||
}
|
}
|
||||||
function clearErrors() {
|
function clearErrors() {
|
||||||
errors.hidden = true;
|
errors.hidden = true;
|
||||||
for (const field of [name, email, phone]) {
|
for (const field of [name, email, phone, consent]) {
|
||||||
field.removeAttribute("aria-invalid");
|
field.removeAttribute("aria-invalid");
|
||||||
content.querySelector(
|
content.querySelector(
|
||||||
`#${instanceId}-booking-${field.name}-error`,
|
`#${instanceId}-booking-${field.name}-error`,
|
||||||
|
|
@ -256,6 +260,7 @@ export function mountBookingPage(
|
||||||
name.value = stored.payload.name;
|
name.value = stored.payload.name;
|
||||||
email.value = stored.payload.email;
|
email.value = stored.payload.email;
|
||||||
phone.value = stored.payload.phone || "";
|
phone.value = stored.payload.phone || "";
|
||||||
|
consent.checked = stored.payload.consent === true;
|
||||||
lockFields(true);
|
lockFields(true);
|
||||||
button.textContent = "Status prüfen / erneut versuchen";
|
button.textContent = "Status prüfen / erneut versuchen";
|
||||||
if (stored.result) showResult(stored.result, false);
|
if (stored.result) showResult(stored.result, false);
|
||||||
|
|
@ -275,6 +280,9 @@ export function mountBookingPage(
|
||||||
clearErrors();
|
clearErrors();
|
||||||
if (!attempt) {
|
if (!attempt) {
|
||||||
const fields = {};
|
const fields = {};
|
||||||
|
if (!consent.checked)
|
||||||
|
fields.consent =
|
||||||
|
"Bitte die Datenschutzbestimmungen akzeptieren.";
|
||||||
name.value = name.value.trim();
|
name.value = name.value.trim();
|
||||||
email.value = email.value.trim();
|
email.value = email.value.trim();
|
||||||
if (
|
if (
|
||||||
|
|
@ -305,6 +313,7 @@ export function mountBookingPage(
|
||||||
...(selected.variantId
|
...(selected.variantId
|
||||||
? { variantId: selected.variantId }
|
? { variantId: selected.variantId }
|
||||||
: {}),
|
: {}),
|
||||||
|
consent: consent.checked,
|
||||||
name: name.value,
|
name: name.value,
|
||||||
email: email.value,
|
email: email.value,
|
||||||
...(phone.value ? { phone: phone.value } : {}),
|
...(phone.value ? { phone: phone.value } : {}),
|
||||||
|
|
|
||||||
|
|
@ -190,3 +190,25 @@
|
||||||
.booking__dialog .booking__result:focus {
|
.booking__dialog .booking__result:focus {
|
||||||
outline-color: #53d5cd;
|
outline-color: #53d5cd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.booking .booking__consent {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.6rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.booking__consent input[type="checkbox"] {
|
||||||
|
flex: 0 0 1em;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0.25em 0 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking__consent a,
|
||||||
|
.booking__consent a:visited {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export function openContactDialog({
|
||||||
<select id="contact-reason" name="reason" required><option value="">Bitte auswählen</option></select>
|
<select id="contact-reason" name="reason" required><option value="">Bitte auswählen</option></select>
|
||||||
</div>
|
</div>
|
||||||
<label class="contact-consent"><input name="simulationAccepted" type="checkbox" required><span></span></label>
|
<label class="contact-consent"><input name="simulationAccepted" type="checkbox" required><span></span></label>
|
||||||
|
<label class="contact-consent"><input name="consent" type="checkbox" required><span>Ich akzeptiere die <a href="https://www.tri-hub.de/datenschutz" target="_blank" rel="noopener noreferrer">Datenschutzbestimmungen</a> (Pflichtfeld).</span></label>
|
||||||
<p class="contact-status" role="status" aria-live="polite"></p>
|
<p class="contact-status" role="status" aria-live="polite"></p>
|
||||||
<button class="contact-submit" type="submit" disabled>Anfrage senden & Termin wählen</button>
|
<button class="contact-submit" type="submit" disabled>Anfrage senden & Termin wählen</button>
|
||||||
</form>`;
|
</form>`;
|
||||||
|
|
@ -57,6 +58,7 @@ export function openContactDialog({
|
||||||
close.disabled = busy;
|
close.disabled = busy;
|
||||||
email.disabled = busy || sent;
|
email.disabled = busy || sent;
|
||||||
reason.disabled = busy || sent;
|
reason.disabled = busy || sent;
|
||||||
|
form.elements.consent.disabled = busy || sent;
|
||||||
form.elements.simulationAccepted.disabled = busy || sent;
|
form.elements.simulationAccepted.disabled = busy || sent;
|
||||||
}
|
}
|
||||||
async function post(path, input) {
|
async function post(path, input) {
|
||||||
|
|
@ -82,6 +84,7 @@ export function openContactDialog({
|
||||||
email: email.value,
|
email: email.value,
|
||||||
advisorId,
|
advisorId,
|
||||||
reason: reason.value,
|
reason: reason.value,
|
||||||
|
consent: form.elements.consent.checked,
|
||||||
simulationAccepted: form.elements.simulationAccepted.checked,
|
simulationAccepted: form.elements.simulationAccepted.checked,
|
||||||
};
|
};
|
||||||
busy = true;
|
busy = true;
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,10 @@ form?.addEventListener("submit", async (event) => {
|
||||||
const response = await fetch("/api/nutrition-contact", {
|
const response = await fetch("/api/nutrition-contact", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
body: JSON.stringify({
|
||||||
|
...Object.fromEntries(new FormData(form)),
|
||||||
|
consent: form.elements.consent.checked,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -148,3 +148,8 @@
|
||||||
.contact-status a {
|
.contact-status a {
|
||||||
color: var(--contact-accent);
|
color: var(--contact-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-consent a {
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
bookingId,createdAt,packageId,packageName,name,email,emailStatus,idempotencyKey,requestHash,bookedPackage,customer,acceptedTerms,consent
|
||||||
TH-000001,2026-09-25T11:43:43.074Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,kk,kk@web.de,accepted,497662ce-278c-466f-9505-c2728f8973ca,d5c69de514e6de5552e43f99256a042b59df5bd53db0bd1685f849809a455ef9,,,
|
TH-000001,2026-09-25T11:43:43.074Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,kk,kk@web.de,accepted,497662ce-278c-466f-9505-c2728f8973ca,d5c69de514e6de5552e43f99256a042b59df5bd53db0bd1685f849809a455ef9,,,,true
|
||||||
TH-000002,2026-09-25T19:22:37.801Z,ernaehrung-starter,Für den sicheren Einstieg in die Sporternährung,Marvin,marvin@web.de,accepted,06b9ad79-05c3-4e37-9135-0c3cf1da3f36,7650a36bd436390b5f79dde1ea1e0d8944f1320b29bad927dfd71157c3f4133c,,,
|
TH-000002,2026-09-25T19:22:37.801Z,ernaehrung-starter,Für den sicheren Einstieg in die Sporternährung,Marvin,marvin@web.de,accepted,06b9ad79-05c3-4e37-9135-0c3cf1da3f36,7650a36bd436390b5f79dde1ea1e0d8944f1320b29bad927dfd71157c3f4133c,,,,true
|
||||||
TH-000003,2026-09-25T19:52:53.927Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,M,test@web.de,accepted,9446e525-a714-4912-bb10-1906a75cc9cd,a8fbd181361e4de7713fb0c4b5e0bf88cd43acd3e16294e7b84b33d959551458,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,
|
TH-000003,2026-09-25T19:52:53.927Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,M,test@web.de,accepted,9446e525-a714-4912-bb10-1906a75cc9cd,a8fbd181361e4de7713fb0c4b5e0bf88cd43acd3e16294e7b84b33d959551458,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
|
||||||
TH-000004,2026-09-25T19:54:52.744Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,m,test@web.de,accepted,1817271c-343e-4e27-ac60-9d225897ba28,4a9f445acdf5cef94e95d8cdf50e78f95884913799cd2f532aaa37302910a16b,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",,
|
TH-000004,2026-09-25T19:54:52.744Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,m,test@web.de,accepted,1817271c-343e-4e27-ac60-9d225897ba28,4a9f445acdf5cef94e95d8cdf50e78f95884913799cd2f532aaa37302910a16b,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",,,true
|
||||||
TH-000005,2026-09-25T20:14:51.874Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,Marvin,m@web.de,accepted,678654e5-7db9-49af-97b0-64fe02257e77,89c188f086a2d72fb77ae52612288610d2f17846fb1a3e62b11e8caa011cf2b4,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",,
|
TH-000005,2026-09-25T20:14:51.874Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,Marvin,m@web.de,accepted,678654e5-7db9-49af-97b0-64fe02257e77,89c188f086a2d72fb77ae52612288610d2f17846fb1a3e62b11e8caa011cf2b4,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-52"",""variantLabel"":""52-Wochen-Variante (Jahresbegleitung)"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":52,""durationLabel"":""52 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":1175.63,""taxRate"":19,""taxAmount"":223.37,""grossPrice"":1399,""currency"":""EUR""}",,,true
|
||||||
TH-000006,2026-09-26T08:44:42.313Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,Marvin,m@web.de,accepted,a0a8c426-fc56-4887-a0e5-42172ff9cfb2,b83cf7b2ef4c42447bd83038c60d2ca565a8b37d24cae128f4fa2214dc43b185,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,
|
TH-000006,2026-09-26T08:44:42.313Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,Marvin,m@web.de,accepted,a0a8c426-fc56-4887-a0e5-42172ff9cfb2,b83cf7b2ef4c42447bd83038c60d2ca565a8b37d24cae128f4fa2214dc43b185,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
|
||||||
TH-000007,2026-09-26T08:50:50.112Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,k@web.de,accepted,92e12eff-321d-49e5-a9ad-674a7d61f9a7,14b1f6b3909696f8402be66b1de33956b84ca435f58567e57e31e7152b6bff6c,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,
|
TH-000007,2026-09-26T08:50:50.112Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,k@web.de,accepted,92e12eff-321d-49e5-a9ad-674a7d61f9a7,14b1f6b3909696f8402be66b1de33956b84ca435f58567e57e31e7152b6bff6c,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
|
||||||
TH-000008,2026-09-26T09:18:17.493Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,marvin@test.de,accepted,c74460a8-0315-4e0b-9d6b-48c854b0f71b,19ebc08e678515ea3a71f9b295ac1fbbf188bf54602a531f4f3bdc53698dcda7,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,
|
TH-000008,2026-09-26T09:18:17.493Z,ernaehrung-standard,Für Best Ager mit ersten Erfahrungen und klaren Zielen,m,marvin@test.de,accepted,c74460a8-0315-4e0b-9d6b-48c854b0f71b,19ebc08e678515ea3a71f9b295ac1fbbf188bf54602a531f4f3bdc53698dcda7,"{""packageId"":""ernaehrung-standard"",""variantId"":null,""variantLabel"":null,""type"":""STANDARD"",""title"":""Für Best Ager mit ersten Erfahrungen und klaren Zielen"",""subtitle"":""Gezielte Ernährungssteuerung für Alltag, Belastungsspitzen und deinen nächsten Wettkampf."",""summaryForBooking"":""Standard-Paket Sporternährung (24 Wochen Coaching inkl. Wettkampf-Verpflegungsstrategie)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Umfassende Anamnese inkl. Analyse der bisherigen Trainings- und Wettkampfernährung"",""Dynamischer Ernährungsplan (gekoppelt an deine Trainingsphasen)"",""Spezifisches Carb-Loading- und Pacing-Konzept für den Wettkampftag"",""Monatliche 1:1 Video-Calls (6x 45 Min.) zur Feinjustierung"",""Gezieltes Magen-Training (Gut-Training) für Gels, Riegel und Sportgetränke"",""Support per Messenger bei Fragen im Trainingsalltag (Antwort innerhalb von 48h)""],""processSteps"":[{""step"":""1. Tiefen-Anamnese"",""text"":""Präzise Erfassung von Trainingsrealität, Erfahrung, Belastbarkeit und Verträglichkeiten.""},{""step"":""2. Periodisierte Planung"",""text"":""Abstimmung der Makro- und Mikronährstoffe auf deine konkreten Trainingsblöcke.""},{""step"":""3. Wettkampf-Simulation"",""text"":""Erprobung der Raceday-Strategie im Training für maximale Sicherheit am Starttag.""}],""netPrice"":377.31,""taxRate"":19,""taxAmount"":71.69,""grossPrice"":449,""currency"":""EUR""}",,,true
|
||||||
TH-000009,2026-09-26T09:43:16.704Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,marvin,m@email.de,accepted,9c18134f-3a71-428b-8769-fda7d06790c7,eaa9a3f0dfc568c726f1b6fc26d92f8c4cc7df072f9fdd40fbea581661eeef9f,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-24"",""variantLabel"":""24-Wochen-Variante"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":671.43,""taxRate"":19,""taxAmount"":127.57,""grossPrice"":799,""currency"":""EUR""}",,
|
TH-000009,2026-09-26T09:43:16.704Z,ernaehrung-premium,Für maximale Individualität mit klarem Fokus,marvin,m@email.de,accepted,9c18134f-3a71-428b-8769-fda7d06790c7,eaa9a3f0dfc568c726f1b6fc26d92f8c4cc7df072f9fdd40fbea581661eeef9f,"{""packageId"":""ernaehrung-premium"",""variantId"":""ernaehrung-premium-24"",""variantLabel"":""24-Wochen-Variante"",""type"":""PREMIUM"",""title"":""Für maximale Individualität und intensive 1:1-Begleitung"",""subtitle"":""Enge Coaching-Beziehung, laufende Feinabstimmung und zwei wählbare Laufzeit-Varianten."",""summaryForBooking"":""Premium-Paket Sporternährung (Intensive 1:1-Begleitung inkl. Raceday- & Regenerations-Steuerung)"",""durationWeeks"":24,""durationLabel"":""24 Wochen Begleitung"",""quantity"":1,""billingInterval"":""einmalig"",""includedFeatures"":[""Exklusive 90-Minuten-Start-Anamnese inkl. Auswertung vorhandener Blut-/Laborwerte"",""Wöchentliche Feinabstimmung des Ernährungsplans parallel zum Trainingsplan"",""Individueller Supplement- & Mikronährstoff-Plan für optimale Regeneration ab 50"",""14-tägige 1:1 Video-Calls sowie direkter Priority-Messenger-Support"",""Detailliertes Raceday-Playbook (Stunde-für-Stunde-Plan für vor, während und nach dem Rennen)"",""Direkte Abstimmung mit deinem Triathlon-Trainer bei Tri-Hub""],""processSteps"":[{""step"":""1. Ganzheitliches Onboarding"",""text"":""Gemeinsame Analyse von Gesundheitsdaten, Leistungszielen und Alltagsstruktur.""},{""step"":""2. Laufende Steuerung"",""text"":""Wöchentliche Anpassung an Trainingsumfang, Schlafqualität und Regeneration.""},{""step"":""3. Punktlandung am Wettkampftag"",""text"":""Individuelle Tapering- und Wettkampfernährung für deine persönliche Bestleistung.""}],""netPrice"":671.43,""taxRate"":19,""taxAmount"":127.57,""grossPrice"":799,""currency"":""EUR""}",,,true
|
||||||
|
|
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
createdAt,name,email,subject,message
|
createdAt,name,email,subject,message,consent
|
||||||
2026-09-29T18:51:35.694Z,Max Muster,test10@web.de,Erstgespräch für eine Ernährungsberatung,"Guten Tag,
|
2026-09-29T18:51:35.694Z,Max Muster,test10@web.de,Erstgespräch für eine Ernährungsberatung,"Guten Tag,
|
||||||
|
|
||||||
ich möchte einen termin vereinbaren, können sie sich bei mir melden.
|
ich möchte einen termin vereinbaren, können sie sich bei mir melden.
|
||||||
|
|
||||||
Mfg
|
Mfg
|
||||||
Max Muster"
|
Max Muster",true
|
||||||
2026-09-29T19:27:34.639Z,Test Test,test11@web.de,Erstgespräch Ernährung,"Guten Tag,
|
2026-09-29T19:27:34.639Z,Test Test,test11@web.de,Erstgespräch Ernährung,"Guten Tag,
|
||||||
|
|
||||||
können sie sich bei mir melden.
|
können sie sich bei mir melden.
|
||||||
|
|
||||||
Mfg
|
Mfg
|
||||||
Test"
|
Test",true
|
||||||
|
|
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { createReviewStorage } from "./services/review-storage.js";
|
||||||
|
import { handleReview } from "./routes/reviews.js";
|
||||||
import { createContactService } from "./services/contact-service.js";
|
import { createContactService } from "./services/contact-service.js";
|
||||||
import { createContactEmailService } from "./services/contact-email.js";
|
import { createContactEmailService } from "./services/contact-email.js";
|
||||||
import { handleContact } from "./routes/contact.js";
|
import { handleContact } from "./routes/contact.js";
|
||||||
|
|
@ -21,6 +23,9 @@ export function createApp({
|
||||||
storage = createCsvStorage(
|
storage = createCsvStorage(
|
||||||
fileURLToPath(new URL("./data/bookings.csv", import.meta.url)),
|
fileURLToPath(new URL("./data/bookings.csv", import.meta.url)),
|
||||||
),
|
),
|
||||||
|
reviewStorage = createReviewStorage(
|
||||||
|
fileURLToPath(new URL("./data/reviews.csv", import.meta.url)),
|
||||||
|
),
|
||||||
details,
|
details,
|
||||||
sendConfirmation,
|
sendConfirmation,
|
||||||
sendContact,
|
sendContact,
|
||||||
|
|
@ -28,6 +33,17 @@ export function createApp({
|
||||||
fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)),
|
fileURLToPath(new URL("./data/contact-requests.csv", import.meta.url)),
|
||||||
),
|
),
|
||||||
sendContactForm,
|
sendContactForm,
|
||||||
|
advisorContactStorage = createContactFormStorage(
|
||||||
|
fileURLToPath(new URL("./data/advisor-contacts.csv", import.meta.url)),
|
||||||
|
[
|
||||||
|
"createdAt",
|
||||||
|
"email",
|
||||||
|
"advisorId",
|
||||||
|
"reason",
|
||||||
|
"simulationAccepted",
|
||||||
|
"consent",
|
||||||
|
],
|
||||||
|
),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const ids = new Set();
|
const ids = new Set();
|
||||||
for (const entry of catalog) {
|
for (const entry of catalog) {
|
||||||
|
|
@ -56,6 +72,7 @@ export function createApp({
|
||||||
});
|
});
|
||||||
const contact = createContactService({
|
const contact = createContactService({
|
||||||
storage,
|
storage,
|
||||||
|
requestStorage: advisorContactStorage,
|
||||||
sendContact: sendContact ?? createContactEmailService(),
|
sendContact: sendContact ?? createContactEmailService(),
|
||||||
});
|
});
|
||||||
// Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand.
|
// Registriert den separaten Kontaktformular-Service mit eigener CSV und Mailpit-Versand.
|
||||||
|
|
@ -65,6 +82,9 @@ export function createApp({
|
||||||
});
|
});
|
||||||
return createServer(async (request, response) => {
|
return createServer(async (request, response) => {
|
||||||
const path = new URL(request.url, "http://localhost").pathname;
|
const path = new URL(request.url, "http://localhost").pathname;
|
||||||
|
if (path === "/api/reviews") {
|
||||||
|
return handleReview(request, response, storage, reviewStorage);
|
||||||
|
}
|
||||||
// Leitet die Ernährungsseite an ihren eigenen Kontaktformular-Endpunkt.
|
// Leitet die Ernährungsseite an ihren eigenen Kontaktformular-Endpunkt.
|
||||||
if (path === "/api/nutrition-contact") {
|
if (path === "/api/nutrition-contact") {
|
||||||
return handleContactForm(request, response, contactForm);
|
return handleContactForm(request, response, contactForm);
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -82,7 +82,9 @@ function validateInput(input, key) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const fields = {};
|
const fields = {};
|
||||||
const result = {};
|
if (input.consent !== true)
|
||||||
|
fields.consent = "Bitte die Datenschutzbestimmungen akzeptieren.";
|
||||||
|
const result = { consent: true };
|
||||||
for (const [field, max, message] of [
|
for (const [field, max, message] of [
|
||||||
["packageId", 80, "Bitte ein gültiges Paket auswählen."],
|
["packageId", 80, "Bitte ein gültiges Paket auswählen."],
|
||||||
["name", 120, "Bitte einen Namen mit höchstens 120 Zeichen eingeben."],
|
["name", 120, "Bitte einen Namen mit höchstens 120 Zeichen eingeben."],
|
||||||
|
|
@ -315,6 +317,7 @@ export function createBookingService({
|
||||||
bookedPackage,
|
bookedPackage,
|
||||||
customer: data.customer ?? null,
|
customer: data.customer ?? null,
|
||||||
acceptedTerms: data.acceptedTerms ?? null,
|
acceptedTerms: data.acceptedTerms ?? null,
|
||||||
|
consent: data.consent,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
phone: data.customer?.phone ?? data.phone ?? "",
|
phone: data.customer?.phone ?? data.phone ?? "",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ function normalizeInput(input) {
|
||||||
// Legt die zulässige Höchstlänge für jedes Formularfeld fest.
|
// Legt die zulässige Höchstlänge für jedes Formularfeld fest.
|
||||||
const limits = { name: 120, email: 254, subject: 160, message: 5000 };
|
const limits = { name: 120, email: 254, subject: 160, message: 5000 };
|
||||||
const fields = {};
|
const fields = {};
|
||||||
const data = {};
|
const data = { consent: true };
|
||||||
|
if (input.consent !== true)
|
||||||
|
fields.consent = "Bitte die Datenschutzbestimmungen akzeptieren.";
|
||||||
for (const [field, max] of Object.entries(limits)) {
|
for (const [field, max] of Object.entries(limits)) {
|
||||||
const value = input[field];
|
const value = input[field];
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,14 @@ import { parse } from "csv-parse/sync";
|
||||||
import { stringify } from "csv-stringify/sync";
|
import { stringify } from "csv-stringify/sync";
|
||||||
|
|
||||||
// Diese Spalten bilden den festen Aufbau der Kontaktanfragen-CSV.
|
// Diese Spalten bilden den festen Aufbau der Kontaktanfragen-CSV.
|
||||||
const columns = ["createdAt", "name", "email", "subject", "message"];
|
const defaultColumns = [
|
||||||
|
"createdAt",
|
||||||
|
"name",
|
||||||
|
"email",
|
||||||
|
"subject",
|
||||||
|
"message",
|
||||||
|
"consent",
|
||||||
|
];
|
||||||
|
|
||||||
// Schützt CSV-Zellen vor Tabellenformeln, wenn die Datei geöffnet wird.
|
// Schützt CSV-Zellen vor Tabellenformeln, wenn die Datei geöffnet wird.
|
||||||
function protect(value) {
|
function protect(value) {
|
||||||
|
|
@ -19,7 +26,7 @@ function restore(value) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Erstellt den CSV-Speicher und hält Schreibvorgänge dieser Instanz in einer Warteschlange.
|
// Erstellt den CSV-Speicher und hält Schreibvorgänge dieser Instanz in einer Warteschlange.
|
||||||
export function createContactFormStorage(filePath) {
|
export function createContactFormStorage(filePath, columns = defaultColumns) {
|
||||||
let queue = Promise.resolve();
|
let queue = Promise.resolve();
|
||||||
|
|
||||||
// Liest alle Anfragen ein und prüft, ob die Datei den erwarteten CSV-Kopf besitzt.
|
// Liest alle Anfragen ein und prüft, ob die Datei den erwarteten CSV-Kopf besitzt.
|
||||||
|
|
@ -35,7 +42,13 @@ export function createContactFormStorage(filePath) {
|
||||||
return parse(content, {
|
return parse(content, {
|
||||||
bom: true,
|
bom: true,
|
||||||
columns(header) {
|
columns(header) {
|
||||||
if (header.join(",") !== columns.join(",")) {
|
if (
|
||||||
|
header.join(",") !== columns.join(",") &&
|
||||||
|
header.join(",") !==
|
||||||
|
columns
|
||||||
|
.filter((column) => column !== "consent")
|
||||||
|
.join(",")
|
||||||
|
) {
|
||||||
throw new Error("Unbekanntes Kontakt-CSV-Format");
|
throw new Error("Unbekanntes Kontakt-CSV-Format");
|
||||||
}
|
}
|
||||||
return header;
|
return header;
|
||||||
|
|
@ -45,7 +58,7 @@ export function createContactFormStorage(filePath) {
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
Object.entries(row).map(([key, value]) => [
|
Object.entries(row).map(([key, value]) => [
|
||||||
key,
|
key,
|
||||||
restore(value),
|
key === "consent" ? value === "true" : restore(value),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ function advisorDetails(advisorId) {
|
||||||
return { ...advisor };
|
return { ...advisor };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createContactService({ storage, sendContact }) {
|
export function createContactService({ storage, requestStorage, sendContact }) {
|
||||||
async function validate(input) {
|
async function validate(input) {
|
||||||
if (
|
if (
|
||||||
!input ||
|
!input ||
|
||||||
|
|
@ -93,6 +93,29 @@ export function createContactService({ storage, sendContact }) {
|
||||||
"Bitte den Simulationshinweis bestätigen.",
|
"Bitte den Simulationshinweis bestätigen.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (input.consent !== true) {
|
||||||
|
throw new BookingError(
|
||||||
|
400,
|
||||||
|
"CONSENT_REQUIRED",
|
||||||
|
"Bitte die Datenschutzbestimmungen akzeptieren.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await requestStorage.append({
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
email,
|
||||||
|
advisorId: advisor.id,
|
||||||
|
reason: input.reason,
|
||||||
|
simulationAccepted: true,
|
||||||
|
consent: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new BookingError(
|
||||||
|
503,
|
||||||
|
"CONTACT_NOT_SAVED",
|
||||||
|
"Die Kontaktanfrage konnte nicht gespeichert werden.",
|
||||||
|
);
|
||||||
|
}
|
||||||
await sendContact({
|
await sendContact({
|
||||||
email,
|
email,
|
||||||
advisor,
|
advisor,
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,9 @@ const legacyColumns = [
|
||||||
|
|
||||||
const orderColumns = [...legacyColumns, "bookedPackage"];
|
const orderColumns = [...legacyColumns, "bookedPackage"];
|
||||||
const customerColumns = [...orderColumns, "customer", "acceptedTerms"];
|
const customerColumns = [...orderColumns, "customer", "acceptedTerms"];
|
||||||
export const columns = [...customerColumns, "phone"];
|
const phoneColumns = [...customerColumns, "phone"];
|
||||||
const jsonColumns = ["bookedPackage", "customer", "acceptedTerms"];
|
export const columns = [...phoneColumns, "consent"];
|
||||||
|
const jsonColumns = ["bookedPackage", "customer", "acceptedTerms", "consent"];
|
||||||
|
|
||||||
// Apostroph-Präfix schützt Tabellenprogramme. Ein vorhandenes Apostroph wird
|
// Apostroph-Präfix schützt Tabellenprogramme. Ein vorhandenes Apostroph wird
|
||||||
// ebenfalls maskiert, damit Lesen/Schreiben die Originalwerte exakt erhält.
|
// ebenfalls maskiert, damit Lesen/Schreiben die Originalwerte exakt erhält.
|
||||||
|
|
@ -49,6 +50,7 @@ export function createCsvStorage(filePath) {
|
||||||
if (
|
if (
|
||||||
header.join(",") !== columns.join(",") &&
|
header.join(",") !== columns.join(",") &&
|
||||||
header.join(",") !== customerColumns.join(",") &&
|
header.join(",") !== customerColumns.join(",") &&
|
||||||
|
header.join(",") !== phoneColumns.join(",") &&
|
||||||
header.join(",") !== legacyColumns.join(",") &&
|
header.join(",") !== legacyColumns.join(",") &&
|
||||||
header.join(",") !== orderColumns.join(",")
|
header.join(",") !== orderColumns.join(",")
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -359,7 +359,8 @@
|
||||||
"email",
|
"email",
|
||||||
"advisorId",
|
"advisorId",
|
||||||
"reason",
|
"reason",
|
||||||
"simulationAccepted"
|
"simulationAccepted",
|
||||||
|
"consent"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"email": {
|
"email": {
|
||||||
|
|
@ -387,6 +388,10 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "relindis-agethen",
|
"example": "relindis-agethen",
|
||||||
"description": "ID aus src/shared/berater-daten.json. Name und E-Mail werden serverseitig ausschließlich dort nachgeschlagen."
|
"description": "ID aus src/shared/berater-daten.json. Name und E-Mail werden serverseitig ausschließlich dort nachgeschlagen."
|
||||||
|
},
|
||||||
|
"consent": {
|
||||||
|
"type": "boolean",
|
||||||
|
"enum": [true]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -809,7 +814,7 @@
|
||||||
},
|
},
|
||||||
"BookingCreateRequest": {
|
"BookingCreateRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["packageId"],
|
"required": ["packageId", "consent"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"packageId": {
|
"packageId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
@ -875,6 +880,11 @@
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"example": true,
|
"example": true,
|
||||||
"enum": [true]
|
"enum": [true]
|
||||||
|
},
|
||||||
|
"consent": {
|
||||||
|
"type": "boolean",
|
||||||
|
"enum": [true],
|
||||||
|
"description": "Zustimmung zu den Datenschutzbestimmungen ist erforderlich."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
|
|
|
||||||
|
|
@ -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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
.package-image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1.42 / 1;
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
border: 1px solid #f8fafc1f;
|
||||||
|
border-radius: 1.1rem;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
.package-type-container {
|
.package-type-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { createEmailService } from "../server/services/email-service.js";
|
||||||
import { packages } from "../shared/packages.js";
|
import { packages } from "../shared/packages.js";
|
||||||
|
|
||||||
const input = {
|
const input = {
|
||||||
|
consent: true,
|
||||||
packageId: packages[0].id,
|
packageId: packages[0].id,
|
||||||
name: 'Test, "Person"',
|
name: 'Test, "Person"',
|
||||||
email: "person@example.test",
|
email: "person@example.test",
|
||||||
|
|
@ -68,6 +69,7 @@ test("Buchung steht vor Versand in CSV; Paketname kommt vom Server", async (t) =
|
||||||
assert.ok(!Number.isNaN(Date.parse(result.body.createdAt)));
|
assert.ok(!Number.isNaN(Date.parse(result.body.createdAt)));
|
||||||
const records = await fixture.storage.readAll();
|
const records = await fixture.storage.readAll();
|
||||||
assert.equal(records.length, 1);
|
assert.equal(records.length, 1);
|
||||||
|
assert.equal(records[0].consent, true);
|
||||||
assert.equal(records[0].name, input.name);
|
assert.equal(records[0].name, input.name);
|
||||||
assert.equal(records[0].emailStatus, "accepted");
|
assert.equal(records[0].emailStatus, "accepted");
|
||||||
assert.equal(fixture.sent.length, 1);
|
assert.equal(fixture.sent.length, 1);
|
||||||
|
|
@ -81,6 +83,7 @@ test("Pflichtfelder, Typen, Längen, E-Mail und unbekannte IDs werden abgelehnt"
|
||||||
null,
|
null,
|
||||||
[],
|
[],
|
||||||
{},
|
{},
|
||||||
|
...[false, "true", undefined].map((consent) => ({ ...input, consent })),
|
||||||
{ ...input, name: " " },
|
{ ...input, name: " " },
|
||||||
{ ...input, name: 42 },
|
{ ...input, name: 42 },
|
||||||
{ ...input, name: "a".repeat(121) },
|
{ ...input, name: "a".repeat(121) },
|
||||||
|
|
@ -583,6 +586,7 @@ test("Swagger-Kundendaten und Zustimmung werden validiert und dauerhaft gespeich
|
||||||
notes: "Vorbereitung auf die Mitteldistanz.",
|
notes: "Vorbereitung auf die Mitteldistanz.",
|
||||||
};
|
};
|
||||||
const request = {
|
const request = {
|
||||||
|
consent: true,
|
||||||
packageId: "ernaehrung-standard",
|
packageId: "ernaehrung-standard",
|
||||||
customer,
|
customer,
|
||||||
acceptedTerms: true,
|
acceptedTerms: true,
|
||||||
|
|
@ -633,6 +637,7 @@ test("Telefonnummern werden in beiden Anfrageformaten geprüft", async (t) => {
|
||||||
{ ...input, phone },
|
{ ...input, phone },
|
||||||
{
|
{
|
||||||
packageId: input.packageId,
|
packageId: input.packageId,
|
||||||
|
consent: true,
|
||||||
acceptedTerms: true,
|
acceptedTerms: true,
|
||||||
customer: {
|
customer: {
|
||||||
firstName: "Test",
|
firstName: "Test",
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,18 @@ import { packagesDetails } from "../../shared/packagesdetails.js";
|
||||||
|
|
||||||
const pageUrl = "/booking.html#/buchen/ernaehrung-starter";
|
const pageUrl = "/booking.html#/buchen/ernaehrung-starter";
|
||||||
async function fill(page) {
|
async function fill(page) {
|
||||||
|
if (!(await page.locator("dialog[open]").count())) {
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Jetzt buchen", exact: true })
|
||||||
|
.click();
|
||||||
|
}
|
||||||
await page
|
await page
|
||||||
.getByLabel("Name (Pflichtfeld)", { exact: true })
|
.getByLabel("Name (Pflichtfeld)", { exact: true })
|
||||||
.fill("Test Person");
|
.fill("Test Person");
|
||||||
await page
|
await page
|
||||||
.getByLabel("E-Mail-Adresse (Pflichtfeld)")
|
.getByLabel("E-Mail-Adresse (Pflichtfeld)")
|
||||||
.fill("person@example.test");
|
.fill("person@example.test");
|
||||||
|
await page.locator('[name="consent"]').check();
|
||||||
}
|
}
|
||||||
|
|
||||||
test("Tastaturbedienung, Validierung und vollständige Buchung über Vite-Proxy", async ({
|
test("Tastaturbedienung, Validierung und vollständige Buchung über Vite-Proxy", async ({
|
||||||
|
|
@ -26,41 +32,47 @@ test("Tastaturbedienung, Validierung und vollständige Buchung über Vite-Proxy"
|
||||||
await expect(page.locator(".booking__payment-notice")).toContainText(
|
await expect(page.locator(".booking__payment-notice")).toContainText(
|
||||||
"keine Abbuchung",
|
"keine Abbuchung",
|
||||||
);
|
);
|
||||||
await page.keyboard.press("Tab");
|
const opener = page.getByRole("button", {
|
||||||
await expect(
|
name: "Jetzt buchen",
|
||||||
page.getByRole("link", { name: "Zur Angebotsseite" }),
|
exact: true,
|
||||||
).toBeFocused();
|
});
|
||||||
await page.keyboard.press("Tab");
|
await opener.focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
await expect(
|
await expect(
|
||||||
page.getByLabel("Name (Pflichtfeld)", { exact: true }),
|
page.getByLabel("Name (Pflichtfeld)", { exact: true }),
|
||||||
).toBeFocused();
|
).toBeFocused();
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
await page.keyboard.press("Tab");
|
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
await expect(page.getByRole("alert")).toBeFocused();
|
await expect(page.getByRole("alert")).toBeFocused();
|
||||||
await expect(page.locator('[aria-invalid="true"]')).toHaveCount(2);
|
await expect(page.locator('[aria-invalid="true"]')).toHaveCount(3);
|
||||||
await page.keyboard.press("Tab");
|
await page.keyboard.press("Tab");
|
||||||
await page.keyboard.type("Test Person");
|
await page.keyboard.type("Test Person");
|
||||||
await page.keyboard.press("Tab");
|
await page.keyboard.press("Tab");
|
||||||
await page.keyboard.type("person@example.test");
|
await page.keyboard.type("person@example.test");
|
||||||
await page.keyboard.press("Tab");
|
await page.keyboard.press("Tab");
|
||||||
const outline = await page
|
await page.keyboard.press("Tab");
|
||||||
.getByRole("button", { name: "Paket buchen", exact: true })
|
await expect(page.locator('[name="consent"]')).toBeFocused();
|
||||||
.evaluate((element) => getComputedStyle(element).outlineStyle);
|
await page.keyboard.press("Space");
|
||||||
expect(outline).toBe("solid");
|
await page.keyboard.press("Tab");
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
const submit = page.getByRole("button", {
|
||||||
|
name: "Paket buchen",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(submit).toBeFocused();
|
||||||
|
expect(
|
||||||
|
await submit.evaluate(
|
||||||
|
(element) => getComputedStyle(element).outlineStyle,
|
||||||
|
),
|
||||||
|
).toBe("solid");
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
await expect(page.locator(".booking__result")).toContainText(
|
await expect(page).toHaveURL(/booking-confirmation\.html$/);
|
||||||
/Buchungsnummer: TH-\d{6,}/,
|
await expect(page.locator("#confirmation-receipt")).toContainText(
|
||||||
|
/TH-\d{6,}/,
|
||||||
);
|
);
|
||||||
await expect(page.locator(".booking__result")).toContainText(
|
|
||||||
"Zustellung ist noch nicht bestätigt",
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
page.getByRole("button", { name: "Buchung gespeichert" }),
|
|
||||||
).toBeDisabled();
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.locator(".booking__result")).toContainText(
|
await expect(page.locator("#confirmation-receipt")).toContainText(
|
||||||
/Buchungsnummer: TH-\d{6,}/,
|
/TH-\d{6,}/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -105,6 +117,9 @@ test("Verlorene Antwort: Wiederholung nach Neuladen behält Schlüssel und Buchu
|
||||||
.click();
|
.click();
|
||||||
await expect(page.getByRole("alert")).toBeVisible();
|
await expect(page.getByRole("alert")).toBeVisible();
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Jetzt buchen", exact: true })
|
||||||
|
.click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByLabel("Name (Pflichtfeld)", { exact: true }),
|
page.getByLabel("Name (Pflichtfeld)", { exact: true }),
|
||||||
).toHaveValue("Test Person");
|
).toHaveValue("Test Person");
|
||||||
|
|
@ -114,7 +129,9 @@ test("Verlorene Antwort: Wiederholung nach Neuladen behält Schlüssel und Buchu
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: "Status prüfen / erneut versuchen" })
|
.getByRole("button", { name: "Status prüfen / erneut versuchen" })
|
||||||
.click();
|
.click();
|
||||||
await expect(page.locator(".booking__result")).toContainText(firstBooking);
|
await expect(page.locator("#confirmation-receipt")).toContainText(
|
||||||
|
firstBooking,
|
||||||
|
);
|
||||||
expect(requests).toBe(2);
|
expect(requests).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -186,7 +203,9 @@ test("Vite liefert Serverdateien und Umgebungsdateien nicht aus", async ({
|
||||||
for (const pkg of packages) {
|
for (const pkg of packages) {
|
||||||
test(`Von der Startseite zur Buchung: ${pkg.type}`, async ({ page }) => {
|
test(`Von der Startseite zur Buchung: ${pkg.type}`, async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByRole("link", { name: "Beratung kennenlernen" }).click();
|
await page
|
||||||
|
.getByRole("link", { name: "Ernährungswissen entdecken" })
|
||||||
|
.click();
|
||||||
await expect(page.locator(".package-card")).toHaveCount(
|
await expect(page.locator(".package-card")).toHaveCount(
|
||||||
packages.length,
|
packages.length,
|
||||||
);
|
);
|
||||||
|
|
@ -195,13 +214,11 @@ for (const pkg of packages) {
|
||||||
new RegExp(`paket-details\\.html\\?id=${pkg.id}$`),
|
new RegExp(`paket-details\\.html\\?id=${pkg.id}$`),
|
||||||
);
|
);
|
||||||
await page.locator("#proceed-to-booking-btn").click();
|
await page.locator("#proceed-to-booking-btn").click();
|
||||||
await expect(page).toHaveURL(
|
await expect(page.locator("dialog[open]")).toBeVisible();
|
||||||
new RegExp(`booking\\.html\\?id=${pkg.id}`),
|
|
||||||
);
|
|
||||||
const details = packagesDetails.find((entry) => entry.id === pkg.id);
|
const details = packagesDetails.find((entry) => entry.id === pkg.id);
|
||||||
await expect(
|
await expect(page.locator(".booking__package-name")).toHaveText(
|
||||||
page.getByRole("heading", { name: details.title, exact: true }),
|
details.title,
|
||||||
).toBeVisible();
|
);
|
||||||
await expect(page.locator(".booking__summary")).toHaveText(
|
await expect(page.locator(".booking__summary")).toHaveText(
|
||||||
details.summaryForBooking,
|
details.summaryForBooking,
|
||||||
);
|
);
|
||||||
|
|
@ -219,13 +236,16 @@ for (const pkg of packages) {
|
||||||
.click();
|
.click();
|
||||||
const response = await responsePromise;
|
const response = await responsePromise;
|
||||||
expect(response.status()).toBe(201);
|
expect(response.status()).toBe(201);
|
||||||
const booking = await response.json();
|
await expect(page).toHaveURL(/booking-confirmation\.html$/);
|
||||||
|
const booking = await page.evaluate(() =>
|
||||||
|
JSON.parse(sessionStorage.getItem("trihub.booking.confirmation")),
|
||||||
|
);
|
||||||
expect(booking.packageId).toBe(pkg.id);
|
expect(booking.packageId).toBe(pkg.id);
|
||||||
expect(booking.packageName).toBe(pkg.name);
|
expect(booking.packageName).toBe(pkg.name);
|
||||||
await expect(page.locator(".booking__result")).toContainText(
|
await expect(page.locator("#confirmation-receipt")).toContainText(
|
||||||
booking.bookingId,
|
booking.bookingId,
|
||||||
);
|
);
|
||||||
await page.getByRole("link", { name: "Zur Angebotsseite" }).click();
|
await page.goto("/angebotsuebersicht.html");
|
||||||
await expect(page.locator(".package-card")).toHaveCount(
|
await expect(page.locator(".package-card")).toHaveCount(
|
||||||
packages.length,
|
packages.length,
|
||||||
);
|
);
|
||||||
|
|
@ -249,14 +269,11 @@ test("Premium-Jahresvariante wird von der Detailseite bis zur Bestätigung über
|
||||||
await page.goto("/paket-details.html?id=ernaehrung-premium");
|
await page.goto("/paket-details.html?id=ernaehrung-premium");
|
||||||
await page.locator("#variant-select").selectOption("ernaehrung-premium-52");
|
await page.locator("#variant-select").selectOption("ernaehrung-premium-52");
|
||||||
await page.locator("#proceed-to-booking-btn").click();
|
await page.locator("#proceed-to-booking-btn").click();
|
||||||
await expect(page).toHaveURL(
|
await expect(page.locator("dialog[open]")).toBeVisible();
|
||||||
/booking\.html\?id=ernaehrung-premium&variant=ernaehrung-premium-52$/,
|
|
||||||
);
|
|
||||||
await expect(page.locator(".booking__totals")).toContainText(
|
await expect(page.locator(".booking__totals")).toContainText(
|
||||||
"52 Wochen Begleitung",
|
"52 Wochen Begleitung",
|
||||||
);
|
);
|
||||||
await expect(page.locator(".booking__totals")).toContainText("1.399,00");
|
await expect(page.locator(".booking__totals")).toContainText("1.399,00");
|
||||||
await page.reload();
|
|
||||||
await fill(page);
|
await fill(page);
|
||||||
const responsePromise = page.waitForResponse(
|
const responsePromise = page.waitForResponse(
|
||||||
(response) =>
|
(response) =>
|
||||||
|
|
@ -270,15 +287,20 @@ test("Premium-Jahresvariante wird von der Detailseite bis zur Bestätigung über
|
||||||
expect(response.request().postDataJSON().variantId).toBe(
|
expect(response.request().postDataJSON().variantId).toBe(
|
||||||
"ernaehrung-premium-52",
|
"ernaehrung-premium-52",
|
||||||
);
|
);
|
||||||
const booking = await response.json();
|
await expect(page).toHaveURL(/booking-confirmation\.html$/);
|
||||||
|
const booking = await page.evaluate(() =>
|
||||||
|
JSON.parse(sessionStorage.getItem("trihub.booking.confirmation")),
|
||||||
|
);
|
||||||
expect(booking.bookedPackage.grossPrice).toBe(1399);
|
expect(booking.bookedPackage.grossPrice).toBe(1399);
|
||||||
expect(booking.bookedPackage.durationWeeks).toBe(52);
|
expect(booking.bookedPackage.durationWeeks).toBe(52);
|
||||||
await expect(page.locator(".booking__result")).toContainText(
|
await expect(page.locator("#confirmation-receipt")).toContainText(
|
||||||
booking.bookingId,
|
booking.bookingId,
|
||||||
);
|
);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.locator(".booking__totals")).toContainText("1.399,00");
|
await expect(page.locator("#confirmation-totals")).toContainText(
|
||||||
await expect(page.locator(".booking__result")).toContainText(
|
"1.399,00",
|
||||||
|
);
|
||||||
|
await expect(page.locator("#confirmation-receipt")).toContainText(
|
||||||
booking.bookingId,
|
booking.bookingId,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,12 @@ test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter",
|
||||||
const email = `kontakt-${randomUUID()}@example.test`;
|
const email = `kontakt-${randomUUID()}@example.test`;
|
||||||
const booking = await request.post("/api/bookings", {
|
const booking = await request.post("/api/bookings", {
|
||||||
headers: { "Idempotency-Key": randomUUID() },
|
headers: { "Idempotency-Key": randomUUID() },
|
||||||
data: { packageId: "ernaehrung-starter", name: "Testkunde", email },
|
data: {
|
||||||
|
consent: true,
|
||||||
|
packageId: "ernaehrung-starter",
|
||||||
|
name: "Testkunde",
|
||||||
|
email,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(booking.ok()).toBeTruthy();
|
expect(booking.ok()).toBeTruthy();
|
||||||
await open(page);
|
await open(page);
|
||||||
|
|
@ -30,7 +35,8 @@ test("Popup prüft E-Mail beim Absenden und leitet nur bekannte Kunden weiter",
|
||||||
.getByLabel("Deine Buchungs-E-Mail")
|
.getByLabel("Deine Buchungs-E-Mail")
|
||||||
.fill("unbekannt@example.test");
|
.fill("unbekannt@example.test");
|
||||||
await page.getByLabel("Grund für den Kontakt").selectOption("nutrition");
|
await page.getByLabel("Grund für den Kontakt").selectOption("nutrition");
|
||||||
await page.getByRole("checkbox").check();
|
await page.locator('[name="simulationAccepted"]').check();
|
||||||
|
await page.locator('[name="consent"]').check();
|
||||||
await submit.click();
|
await submit.click();
|
||||||
await expect(page.getByRole("status")).toContainText("keine Buchung");
|
await expect(page.getByRole("status")).toContainText("keine Buchung");
|
||||||
await expect(page.getByRole("dialog")).toBeVisible();
|
await expect(page.getByRole("dialog")).toBeVisible();
|
||||||
|
|
@ -76,7 +82,8 @@ test("Versandfehler zeigt Meldung und bleibt im Popup", async ({ page }) => {
|
||||||
await open(page);
|
await open(page);
|
||||||
await page.getByLabel("Deine Buchungs-E-Mail").fill("kunde@example.test");
|
await page.getByLabel("Deine Buchungs-E-Mail").fill("kunde@example.test");
|
||||||
await page.getByLabel("Grund für den Kontakt").selectOption("package");
|
await page.getByLabel("Grund für den Kontakt").selectOption("package");
|
||||||
await page.getByRole("checkbox").check();
|
await page.locator('[name="simulationAccepted"]').check();
|
||||||
|
await page.locator('[name="consent"]').check();
|
||||||
await page.getByRole("button", { name: "Anfrage senden" }).click();
|
await page.getByRole("button", { name: "Anfrage senden" }).click();
|
||||||
await expect(page.getByRole("status")).toContainText(
|
await expect(page.getByRole("status")).toContainText(
|
||||||
"Mailpit nicht erreichbar",
|
"Mailpit nicht erreichbar",
|
||||||
|
|
|
||||||
|
|
@ -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("");
|
||||||
|
});
|
||||||
|
|
@ -8,6 +8,7 @@ import { createContactFormEmailService } from "../server/services/contact-form-e
|
||||||
import { createContactFormStorage } from "../server/services/contact-form-storage.js";
|
import { createContactFormStorage } from "../server/services/contact-form-storage.js";
|
||||||
|
|
||||||
const validInput = {
|
const validInput = {
|
||||||
|
consent: true,
|
||||||
name: "Test Person",
|
name: "Test Person",
|
||||||
email: "person@example.test",
|
email: "person@example.test",
|
||||||
subject: "Eine Frage",
|
subject: "Eine Frage",
|
||||||
|
|
@ -57,6 +58,7 @@ test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", asyn
|
||||||
assert.equal(f.sent.length, 1);
|
assert.equal(f.sent.length, 1);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
{
|
{
|
||||||
|
consent: f.sent[0].consent,
|
||||||
name: f.sent[0].name,
|
name: f.sent[0].name,
|
||||||
email: f.sent[0].email,
|
email: f.sent[0].email,
|
||||||
subject: f.sent[0].subject,
|
subject: f.sent[0].subject,
|
||||||
|
|
@ -72,6 +74,10 @@ test("gültige Anfrage wird gespeichert und an den Mailversand übergeben", asyn
|
||||||
test("Pflichtfelder, E-Mail, Längen und Mindestlänge werden validiert", async (t) => {
|
test("Pflichtfelder, E-Mail, Längen und Mindestlänge werden validiert", async (t) => {
|
||||||
const f = await fixture(t);
|
const f = await fixture(t);
|
||||||
const invalidInputs = [
|
const invalidInputs = [
|
||||||
|
...[false, "true", undefined].map((consent) => ({
|
||||||
|
...validInput,
|
||||||
|
consent,
|
||||||
|
})),
|
||||||
{ ...validInput, name: " " },
|
{ ...validInput, name: " " },
|
||||||
{ ...validInput, name: "Test\nBcc: fremd" },
|
{ ...validInput, name: "Test\nBcc: fremd" },
|
||||||
{ ...validInput, name: "x".repeat(121) },
|
{ ...validInput, name: "x".repeat(121) },
|
||||||
|
|
@ -267,3 +273,17 @@ test("Mailservice meldet, wenn Mailpit einen Empfänger ablehnt", async () => {
|
||||||
error.status === 502 && error.code === "CONTACT_EMAIL_FAILED",
|
error.status === 502 && error.code === "CONTACT_EMAIL_FAILED",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Alte Kontakt-CSV bleibt lesbar; neue Zustimmung wird separat gespeichert", async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
await writeFile(
|
||||||
|
join(f.directory, "contact-requests.csv"),
|
||||||
|
"createdAt,name,email,subject,message\n2026-01-01,Alt,alt@example.test,Frage,Alte Nachricht\n",
|
||||||
|
);
|
||||||
|
assert.equal((await f.post()).status, 201);
|
||||||
|
const records = await f.storage.readAll();
|
||||||
|
assert.equal(records.length, 2);
|
||||||
|
assert.notEqual(records[0].consent, true);
|
||||||
|
assert.equal(records[0].message, "Alte Nachricht");
|
||||||
|
assert.equal(records[1].consent, true);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createApp } from "../server/index.js";
|
import { createApp } from "../server/index.js";
|
||||||
|
import { createContactFormStorage } from "../server/services/contact-form-storage.js";
|
||||||
import { createCsvStorage } from "../server/services/csv-storage.js";
|
import { createCsvStorage } from "../server/services/csv-storage.js";
|
||||||
import { createContactEmailService } from "../server/services/contact-email.js";
|
import { createContactEmailService } from "../server/services/contact-email.js";
|
||||||
import { contactBookingUrl } from "../shared/contact.js";
|
import { contactBookingUrl } from "../shared/contact.js";
|
||||||
|
|
@ -18,9 +19,21 @@ async function fixture(t, options = {}) {
|
||||||
name: "Testkunde",
|
name: "Testkunde",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
const requests = createContactFormStorage(
|
||||||
|
join(dir, "advisor-contacts.csv"),
|
||||||
|
[
|
||||||
|
"createdAt",
|
||||||
|
"email",
|
||||||
|
"advisorId",
|
||||||
|
"reason",
|
||||||
|
"simulationAccepted",
|
||||||
|
"consent",
|
||||||
|
],
|
||||||
|
);
|
||||||
const sent = [];
|
const sent = [];
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
storage,
|
storage,
|
||||||
|
advisorContactStorage: requests,
|
||||||
sendConfirmation: async () => {},
|
sendConfirmation: async () => {},
|
||||||
sendContact: async (data) => sent.push(data),
|
sendContact: async (data) => sent.push(data),
|
||||||
...options,
|
...options,
|
||||||
|
|
@ -40,9 +53,10 @@ async function fixture(t, options = {}) {
|
||||||
});
|
});
|
||||||
return { status: response.status, body: await response.json() };
|
return { status: response.status, body: await response.json() };
|
||||||
};
|
};
|
||||||
return { post, sent, storage, base };
|
return { post, sent, storage, requests, base };
|
||||||
}
|
}
|
||||||
const input = {
|
const input = {
|
||||||
|
consent: true,
|
||||||
email: "kunde@example.test",
|
email: "kunde@example.test",
|
||||||
advisorId: "relindis-agethen",
|
advisorId: "relindis-agethen",
|
||||||
reason: "coaching",
|
reason: "coaching",
|
||||||
|
|
@ -74,11 +88,19 @@ test("CSV-Abgleich normalisiert E-Mail; Versand erhält Berater und festen Redir
|
||||||
assert.equal(second.status, 201);
|
assert.equal(second.status, 201);
|
||||||
assert.equal(f.sent[1].advisor.email, "maren.hoffmann@tri-hub.de");
|
assert.equal(f.sent[1].advisor.email, "maren.hoffmann@tri-hub.de");
|
||||||
assert.deepEqual(await f.storage.readAll(), before);
|
assert.deepEqual(await f.storage.readAll(), before);
|
||||||
|
const records = await f.requests.readAll();
|
||||||
|
assert.equal(records.length, 2);
|
||||||
|
assert.equal(records[0].consent, true);
|
||||||
|
assert.equal(records[0].advisorId, input.advisorId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Ungültige und unbekannte Kunden, manipulierte Berater, Gründe und fehlende Zustimmung senden nichts", async (t) => {
|
test("Ungültige und unbekannte Kunden, manipulierte Berater, Gründe und fehlende Zustimmung senden nichts", async (t) => {
|
||||||
const f = await fixture(t);
|
const f = await fixture(t);
|
||||||
for (const [data, status] of [
|
for (const [data, status] of [
|
||||||
|
...[false, "true", undefined].map((consent) => [
|
||||||
|
{ ...input, consent },
|
||||||
|
400,
|
||||||
|
]),
|
||||||
[null, 400],
|
[null, 400],
|
||||||
[{ ...input, email: "bad" }, 400],
|
[{ ...input, email: "bad" }, 400],
|
||||||
[{ ...input, email: "unknown@example.test" }, 403],
|
[{ ...input, email: "unknown@example.test" }, 403],
|
||||||
|
|
@ -89,6 +111,7 @@ test("Ungültige und unbekannte Kunden, manipulierte Berater, Gründe und fehlen
|
||||||
])
|
])
|
||||||
assert.equal((await f.post("/api/contact", data)).status, status);
|
assert.equal((await f.post("/api/contact", data)).status, status);
|
||||||
assert.equal(f.sent.length, 0);
|
assert.equal(f.sent.length, 0);
|
||||||
|
assert.deepEqual(await f.requests.readAll(), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Absenden prüft CSV erneut; Speicherfehler bleiben geschlossen", async (t) => {
|
test("Absenden prüft CSV erneut; Speicherfehler bleiben geschlossen", async (t) => {
|
||||||
|
|
@ -187,3 +210,15 @@ test("Anzeigename bevorzugt Spitzname, sonst Vorname, ohne Nachnamen", async ()
|
||||||
"Maren",
|
"Maren",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Berateranfrage wird bei CSV-Schreibfehler nicht versendet", async (t) => {
|
||||||
|
const f = await fixture(t, {
|
||||||
|
advisorContactStorage: {
|
||||||
|
append: async () => {
|
||||||
|
throw new Error("Disk full");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal((await f.post("/api/contact", input)).status, 503);
|
||||||
|
assert.equal(f.sent.length, 0);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,34 @@ import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createApp } from "../../server/index.js";
|
import { createApp } from "../../server/index.js";
|
||||||
import { createCsvStorage } from "../../server/services/csv-storage.js";
|
import { createCsvStorage } from "../../server/services/csv-storage.js";
|
||||||
|
import { createReviewStorage } from "../../server/services/review-storage.js";
|
||||||
|
import { createContactFormStorage } from "../../server/services/contact-form-storage.js";
|
||||||
import { packages } from "../../shared/packages.js";
|
import { packages } from "../../shared/packages.js";
|
||||||
|
|
||||||
const directory = await mkdtemp(join(tmpdir(), "trihub-browser-"));
|
const directory = await mkdtemp(join(tmpdir(), "trihub-browser-"));
|
||||||
const server = createApp({
|
const server = createApp({
|
||||||
catalog: packages,
|
catalog: packages,
|
||||||
storage: createCsvStorage(join(directory, "bookings.csv")),
|
storage: createCsvStorage(join(directory, "bookings.csv")),
|
||||||
|
reviewStorage: createReviewStorage(join(directory, "reviews.csv")),
|
||||||
|
advisorContactStorage: createContactFormStorage(
|
||||||
|
join(directory, "advisor-contacts.csv"),
|
||||||
|
[
|
||||||
|
"createdAt",
|
||||||
|
"email",
|
||||||
|
"advisorId",
|
||||||
|
"reason",
|
||||||
|
"simulationAccepted",
|
||||||
|
"consent",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
contactFormStorage: createContactFormStorage(
|
||||||
|
join(directory, "contact-requests.csv"),
|
||||||
|
),
|
||||||
|
sendContactForm: async () => {},
|
||||||
sendConfirmation: async () => {},
|
sendConfirmation: async () => {},
|
||||||
sendContact: async () => {},
|
sendContact: async () => {},
|
||||||
});
|
});
|
||||||
server.listen(3000, "127.0.0.1");
|
server.listen(Number(process.env.TEST_API_PORT || 3000), "127.0.0.1");
|
||||||
async function stop() {
|
async function stop() {
|
||||||
server.closeAllConnections();
|
server.closeAllConnections();
|
||||||
await new Promise((resolve) => server.close(resolve));
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
|
|
||||||
|
|
@ -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