Compare commits

..

13 Commits

Author SHA1 Message Date
Emirhan Sahin 599fe51ef2 Merge pull request 'Cookie-Einstellungen angepasst.' (#13) from dev into main
Reviewed-on: #13
2026-09-30 18:20:11 +02:00
Majd Ibrahim 4d95aeb772 Cookie-Einstellungen angepasst. 2026-09-30 16:14:45 +00:00
Emirhan Sahin 857cd86ab2 Merge pull request 'dev' (#12) from dev into main
Reviewed-on: #12
2026-09-30 16:53:07 +02:00
Emirhan Sahin 787e7b61a6 Merge branch 'feature/rezensionen-darstellen' into dev
# Conflicts:
#	.gitignore
2026-09-30 14:33:02 +00:00
Emirhan Sahin 15e5dbe904 feat: Rezensionen mit Navigation und Tests ergänzen 2026-09-30 14:30:49 +00:00
Niels Yannick Tietze 84cf3409de Footer hinzugefügt + Kontakt aufnehmen führt zu Kontaktformular auf index.html 2026-09-30 14:27:50 +00:00
Niels Yannick Tietze ea47960393 Merge branch 'dev' into feature/FAQ 2026-09-30 14:08:19 +00:00
Marvin Gemlin dfa8aeff6c gitignore und reviews.csv bearbeitet 2026-09-30 13:35:14 +00:00
Niels Yannick Tietze f04b45bf16 Max nur eine Frage offen, Farb Korrektur Schrift 2026-09-30 13:32:55 +00:00
Marvin Gemlin 4f308c2a7b feat: Darstellung der Rezensionen implementiert. Minimale Stilanpassungen noch möglich 2026-09-30 13:30:06 +00:00
Niels Yannick Tietze 7c96a97ebf Start FAQ. Kategorien, Fragen und NavBar-Integration 2026-09-30 14:14:54 +02:00
Emirhan Sahin 42c0db48d5 Merge pull request 'Fix: Berater-Popup: Kontaktieren im Titel ergänzen' (#11) from dev into main
Reviewed-on: #11
2026-09-30 09:33:37 +02:00
Majd Ibrahim 7c2a4e99c3 Merge pull request 'dev' (#10) from dev into main
Reviewed-on: #10
2026-09-30 00:10:52 +02:00
40 changed files with 4188 additions and 1730 deletions

1
.gitignore vendored
View File

@ -11,5 +11,6 @@ Thumbs.db
src/server/data/* src/server/data/*
!src/server/data/.gitkeep !src/server/data/.gitkeep
!src/server/data/contact-requests.csv !src/server/data/contact-requests.csv
!src/server/data/reviews.csv
playwright-report/ playwright-report/
test-results/ test-results/

1282
FAQ.html 100644

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,10 @@
<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" />
@ -17,17 +20,35 @@
<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> <div id="footer-placeholder"></div>
<script type="module" src="/src/components/footer/footer.js"></script> <script type="module" src="/src/components/footer/footer.js"></script>
</body> </body>

View File

@ -579,7 +579,7 @@
.rating-input label:hover, .rating-input label:hover,
.rating-input label:hover ~ label, .rating-input label:hover ~ label,
.rating-input input:checked ~ label { .rating-input input:checked ~ label {
color: var(--brand); color: #f7c230;
} }
.consent { .consent {
@ -679,7 +679,7 @@
} }
.stars { .stars {
color: var(--brand); color: #f7c230;
letter-spacing: 0.08em; letter-spacing: 0.08em;
white-space: nowrap; white-space: nowrap;
} }
@ -1017,39 +1017,61 @@
</a> </a>
</div> </div>
<div class="review-list"> <div
<!-- Diese Beispielkarten sind bewusst als Platzhalter gekennzeichnet. --> class="review-list"
<article class="review-card"> id="reviewSlider"
<div class="review-top"> role="region"
<div class="person"> aria-roledescription="Karussell"
<div class="avatar">T</div> aria-label="Kundenrezensionen"
<div> >
<strong>Deine Erfahrung</strong> <div
<small class="review-stage"
>Hier kann eine Kundenstimme aria-live="off"
erscheinen</small tabindex="0"
> role="group"
aria-label="Rezensionen durchblättern"
aria-describedby="reviewInstructions"
>
<article class="review-card is-active">
<div class="review-top">
<div class="person">
<div class="avatar">T</div>
<div>
<strong>Deine Erfahrung</strong>
<small
>Noch keine Rezension
vorhanden</small
>
</div>
</div>
<div
class="stars"
aria-label="Beispiel für eine Sternebewertung"
>
★★★★★
</div> </div>
</div> </div>
<div
class="stars" <blockquote
aria-label="Beispiel für eine Sternebewertung" style="
font-family:
&quot;Inter&quot;, sans-serif;
"
> >
★★★★★ „Teile hier deine Erfahrung mit der
</div> Ernährungsberatung – ehrlich, persönlich und
</div> so, wie du sie erlebt hast.“
</blockquote>
<blockquote </article>
style=" </div>
font-family: &quot;Inter&quot;, sans-serif; <span
" class="review-instructions"
> id="reviewInstructions"
„Teile hier deine Erfahrung mit der >Mit der Maus ziehen oder mit den Pfeiltasten links
Ernährungsberatung – ehrlich, persönlich und so, und rechts durch die Rezensionen wechseln.</span
wie du sie erlebt hast.“ >
</blockquote> <span class="review-announcement" role="status"></span>
</article> <p class="review-feedback" role="status"></p>
<p class="review-note"> <p class="review-note">
Nur Kunden mit einer vorhandenen Buchung können eine Nur Kunden mit einer vorhandenen Buchung können eine
Bewertung abgeben. Deine E-Mail-Adresse wird nicht Bewertung abgeben. Deine E-Mail-Adresse wird nicht
@ -1348,6 +1370,10 @@
type="module" type="module"
src="/src/features/contact/contact-form.js" src="/src/features/contact/contact-form.js"
></script> ></script>
<script
type="module"
src="/src/features/reviews/review-slider.js"
></script>
<script <script
type="module" type="module"
src="/src/features/reviews/review-form.js" src="/src/features/reviews/review-form.js"

View File

@ -62,7 +62,7 @@
<a href="https://tri-hub.de/widerruf">Widerrufsbelehrung</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/datenschutz">Datenschutz</a>
<a href="https://tri-hub.de/impressum">Impressum</a> <a href="https://tri-hub.de/impressum">Impressum</a>
<a class="trihub-cookie-link" href="https://tri-hub.de/">Cookie-Einstellungen öffnen</a> <a class="trihub-cookie-link" href="#cookie-einstellungen" aria-haspopup="dialog">Cookie-Einstellungen öffnen</a>
</nav> </nav>
</section> </section>
</div> </div>

View File

@ -8,8 +8,84 @@ export function initFooter(placeholderSelector = "#footer-placeholder") {
return; return;
} }
placeholder.outerHTML = footerHtml; placeholder.outerHTML = `${footerHtml}
<dialog class="cookie-settings-dialog" aria-labelledby="cookie-settings-title">
<div class="cookie-settings-heading">
<div>
<p class="cookie-settings-eyebrow">DATENSCHUTZ &amp; REICHWEITENMESSUNG</p>
<h2 id="cookie-settings-title">Cookie-Einstellungen</h2>
</div>
<button class="cookie-settings-close" type="button" aria-label="Cookie-Einstellungen schließen">×</button>
</div>
<div class="cookie-settings-options">
<label class="cookie-option">
<span class="cookie-option-copy"><strong>Notwendig</strong><span>Halten den Laden am Laufen und sind notwendig für den Betrieb.</span></span>
<input type="checkbox" name="necessary" checked disabled aria-label="Notwendige Cookies immer aktiv">
<span class="cookie-toggle" aria-hidden="true"></span>
</label>
<label class="cookie-option">
<span class="cookie-option-copy"><strong>Komfort</strong><span>Machen dein Erlebnis angenehmer, zum Beispiel durch Terminbuchung, Kundenbewertungen oder externe Videoinhalte.</span></span>
<input type="checkbox" name="comfort">
<span class="cookie-toggle" aria-hidden="true"></span>
</label>
<label class="cookie-option">
<span class="cookie-option-copy"><strong>Analyse</strong><span>Helfen uns, die Nutzung der Website auszuwerten und Inhalte zu verbessern.</span></span>
<input type="checkbox" name="analysis">
<span class="cookie-toggle" aria-hidden="true"></span>
</label>
<label class="cookie-option">
<span class="cookie-option-copy"><strong>Marketing</strong><span>Helfen uns, Angebote zu verbessern und relevante Inhalte zu zeigen.</span></span>
<input type="checkbox" name="marketing">
<span class="cookie-toggle" aria-hidden="true"></span>
</label>
</div>
<div class="cookie-settings-actions">
<button class="cookie-settings-back" type="button">Zurück</button>
<button class="cookie-settings-save" type="button">Einstellungen speichern</button>
</div>
</dialog>`;
const dialog = document.querySelector(".cookie-settings-dialog");
const cookieLink = document.querySelector(".trihub-cookie-link");
const preferenceKey = "triHubCookiePreferences";
const loadPreferences = () => {
try {
return JSON.parse(localStorage.getItem(preferenceKey) || "{}");
} catch {
return {};
}
};
cookieLink?.addEventListener("click", (event) => {
event.preventDefault();
const preferences = loadPreferences();
for (const category of ["comfort", "analysis", "marketing"]) {
const input = dialog.querySelector(`input[name="${category}"]`);
input.checked = preferences[category] === true;
}
dialog.showModal();
});
const closeDialog = () => dialog.close();
dialog.querySelector(".cookie-settings-close").addEventListener("click", closeDialog);
dialog.querySelector(".cookie-settings-back").addEventListener("click", closeDialog);
dialog.querySelector(".cookie-settings-save").addEventListener("click", () => {
const preferences = { necessary: true };
for (const category of ["comfort", "analysis", "marketing"]) {
preferences[category] = dialog.querySelector(`input[name="${category}"]`).checked;
}
try {
localStorage.setItem(preferenceKey, JSON.stringify(preferences));
} catch {
// Der Dialog bleibt auch ohne verfügbaren Speicher bedienbar.
}
closeDialog();
});
dialog.addEventListener("click", (event) => {
if (event.target === dialog) closeDialog();
});
} }
document.addEventListener("DOMContentLoaded", () => initFooter()); document.addEventListener("DOMContentLoaded", () => initFooter());

View File

@ -35,16 +35,22 @@
<a href="/index.html" class="navbar__link">Ernährung</a> <a href="/index.html" class="navbar__link">Ernährung</a>
</li> </li>
<li> <li>
<a href="/berater.html" class="navbar__link">Beraterprofil</a> <a href="/berater.html" class="navbar__link"
>Beraterprofil</a
>
</li> </li>
<li> <li>
<a href="/index.html" class="navbar__link">FAQ</a> <a href="/FAQ.html" class="navbar__link">FAQ</a>
</li> </li>
<li> <li>
<a href="/index.html#erfahrungen" class="navbar__link">Rezensionen</a> <a href="/index.html#erfahrungen" class="navbar__link"
>Rezensionen</a
>
</li> </li>
<li> <li>
<a href="/index.html#kontakt" class="navbar__link">Kontakt</a> <a href="/index.html#kontakt" class="navbar__link"
>Kontakt</a
>
</li> </li>
<li> <li>
<a <a

View File

@ -0,0 +1,137 @@
/**
* Tri-hub FAQ
* Suche, Kategorien, Accordion und Footer-Jahr
*/
document.addEventListener("DOMContentLoaded", () => {
const searchInput = document.getElementById("search");
const chipsContainer = document.getElementById("chips");
const sections = Array.from(document.querySelectorAll(".faq-section"));
const faqItems = Array.from(document.querySelectorAll(".faq-item"));
const emptyMessage = document.getElementById("empty");
const status = document.getElementById("status");
const yearElement = document.getElementById("year");
// Aktuelles Jahr im Footer
if (yearElement) {
yearElement.textContent = new Date().getFullYear();
}
// Aktiver Kategorie-Filter
let activeFilter = "all";
// Text für die Suche vereinheitlichen
function normalizeText(text) {
return text
.toLocaleLowerCase("de")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.trim();
}
// FAQ-Suche und Kategorie-Filter anwenden
function filterFAQs() {
const searchTerm = normalizeText(searchInput?.value || "");
let visibleCount = 0;
sections.forEach((section) => {
const category = section.dataset.cat;
const categoryMatches =
activeFilter === "all" || category === activeFilter;
const items = Array.from(section.querySelectorAll(".faq-item"));
let visibleInSection = 0;
items.forEach((item) => {
const searchableText = normalizeText(item.textContent);
const searchMatches =
searchTerm === "" || searchableText.includes(searchTerm);
const isVisible = categoryMatches && searchMatches;
item.hidden = !isVisible;
if (isVisible) {
visibleInSection++;
visibleCount++;
}
});
// Kategorie nur anzeigen, wenn Fragen passen
section.hidden = visibleInSection === 0;
});
// Hinweis anzeigen, wenn nichts gefunden wurde
if (emptyMessage) {
emptyMessage.classList.toggle("hidden", visibleCount > 0);
}
// Screenreader über das Suchergebnis informieren
if (status) {
status.textContent =
visibleCount === 1
? "Eine Frage gefunden."
: `${visibleCount} Fragen gefunden.`;
}
}
// Kategorie-Buttons initialisieren
if (chipsContainer) {
const chips = Array.from(
chipsContainer.querySelectorAll("[data-filter]"),
);
function updateActiveChip() {
chips.forEach((chip) => {
const isActive = chip.dataset.filter === activeFilter;
chip.classList.toggle("is-active", isActive);
chip.setAttribute("aria-pressed", String(isActive));
});
}
chips.forEach((chip) => {
chip.addEventListener("click", () => {
activeFilter = chip.dataset.filter;
updateActiveChip();
filterFAQs();
});
});
updateActiveChip();
}
// Suche bei jeder Eingabe aktualisieren
if (searchInput) {
searchInput.addEventListener("input", filterFAQs);
}
// Accordion: Immer nur eine Frage gleichzeitig öffnen
faqItems.forEach((item) => {
item.addEventListener("toggle", () => {
const isOpen = item.open;
item.dataset.state = isOpen ? "open" : "closed";
if (!isOpen) return;
faqItems.forEach((otherItem) => {
if (otherItem !== item && otherItem.open) {
otherItem.open = false;
}
});
});
});
// Initialzustand setzen
faqItems.forEach((item) => {
item.dataset.state = item.open ? "open" : "closed";
});
filterFAQs();
});

View File

@ -39,6 +39,7 @@ form.addEventListener("submit", async (event) => {
} }
status.textContent = "Danke! Deine Bewertung wurde gespeichert."; status.textContent = "Danke! Deine Bewertung wurde gespeichert.";
form.reset(); form.reset();
document.dispatchEvent(new Event("review:saved"));
} catch { } catch {
status.textContent = status.textContent =
"Keine Bestätigung vom Server erhalten. Bitte versuche es später erneut."; "Keine Bestätigung vom Server erhalten. Bitte versuche es später erneut.";

View File

@ -0,0 +1,139 @@
.review-list {
min-width: 0;
align-content: start;
}
.review-stage {
display: grid;
overflow: hidden;
touch-action: pan-y;
cursor: grab;
user-select: none;
}
.review-stage .review-card {
grid-area: 1 / 1;
min-width: 0;
opacity: 0;
visibility: hidden;
transform: translateX(32px);
transition:
opacity 600ms ease,
transform 600ms cubic-bezier(0.22, 1, 0.36, 1),
visibility 0s 600ms;
pointer-events: none;
overflow-wrap: anywhere;
}
.review-stage .review-card.is-before {
transform: translateX(-32px);
}
.review-stage .review-card.is-active {
opacity: 1;
visibility: visible;
transform: translateX(0);
transition-delay: 0s;
pointer-events: auto;
}
.review-stage .review-card h3 {
font-family: inherit;
margin: 0;
color: var(--white);
font-size: 1.1rem;
line-height: 1.4;
}
.review-stage .review-top {
flex-wrap: wrap;
}
.review-stage .review-card blockquote {
font-family: inherit;
font-size: 1rem;
white-space: pre-wrap;
}
.review-stage:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 4px;
}
.review-carousel {
position: relative;
padding-inline: 3.25rem;
}
.review-arrow {
position: absolute;
z-index: 2;
top: 50%;
display: grid;
width: 2rem;
height: 2.5rem;
padding: 0;
place-items: center;
transform: translateY(-50%);
border: 0;
color: #fff;
background: transparent;
font: inherit;
font-size: 2.5rem;
line-height: 1;
cursor: pointer;
transition: color 0.2s ease;
}
.review-arrow[hidden] {
display: none;
}
.review-arrow:hover {
color: #53d5cd;
}
.review-arrow--previous {
left: 0;
}
.review-arrow--next {
right: 0;
}
.review-arrow:focus-visible {
outline: 3px solid #53d5cd;
outline-offset: 3px;
}
.review-stage:focus:not(:focus-visible) {
outline: none;
box-shadow: none;
}
.review-arrow:focus:not(:focus-visible) {
outline: none;
box-shadow: none;
}
.review-stage.is-dragging {
cursor: grabbing;
}
.review-stage.is-dragging .review-card.is-active {
transition: none;
}
.review-feedback {
color: var(--muted);
font-size: 0.82rem;
}
.review-feedback:empty {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.review-stage .review-card {
transition: none;
transform: none;
}
}
.review-announcement,
.review-instructions {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (max-width: 600px) {
.review-carousel {
padding-inline: 2.25rem;
}
.review-arrow {
width: 1.5rem;
font-size: 2rem;
}
}

View File

@ -0,0 +1,209 @@
import "./review-slider.css";
const slider = document.getElementById("reviewSlider");
const stage = slider.querySelector(".review-stage");
const feedback = slider.querySelector(".review-feedback");
const announcement = slider.querySelector(".review-announcement");
const carousel = document.createElement("div");
carousel.className = "review-carousel";
stage.before(carousel);
carousel.append(stage);
function arrow(direction, symbol, label) {
const button = document.createElement("button");
button.type = "button";
button.className = `review-arrow review-arrow--${direction}`;
button.setAttribute("aria-label", label);
button.textContent = symbol;
button.hidden = true;
button.addEventListener("click", () =>
show(index + (direction === "next" ? 1 : -1), true),
);
carousel.append(button);
return button;
}
const previousArrow = arrow("previous", "‹", "Vorherige Rezension");
const nextArrow = arrow("next", "›", "Nächste Rezension");
const interval = 10_000;
let reviews = [];
let index = 0;
let timer;
let drag = null;
let latestRequest = 0;
function schedule() {
clearTimeout(timer);
if (reviews.length > 1 && !drag && !document.hidden) {
timer = setTimeout(() => show(index + 1), interval);
}
}
function show(next, manual = false) {
if (!reviews.length) return;
const direction = next < index ? -1 : 1;
const previous = stage.children[index];
index = (next + reviews.length) % reviews.length;
const current = stage.children[index];
if (previous !== current) {
current.classList.toggle("is-before", direction < 0);
// Establish the starting position before transitioning into view.
void current.offsetWidth;
previous.classList.toggle("is-before", direction > 0);
}
[...stage.children].forEach((card, number) => {
card.classList.toggle("is-active", number === index);
card.inert = number !== index;
card.setAttribute("aria-hidden", String(number !== index));
});
if (manual)
announcement.textContent = `Rezension ${index + 1} von ${reviews.length}: ${reviews[index].title}`;
schedule();
}
function cardFor(review, number, total) {
const card = document.createElement("article");
card.className = "review-card";
card.setAttribute("aria-roledescription", "Folie");
card.setAttribute("aria-label", `${number + 1} von ${total}`);
const top = document.createElement("div");
top.className = "review-top";
const title = document.createElement("h3");
title.textContent = review.title;
const stars = document.createElement("div");
stars.className = "stars";
stars.setAttribute("aria-label", `${review.rating} von 5 Sternen`);
stars.textContent =
"★".repeat(review.rating) + "☆".repeat(5 - review.rating);
const text = document.createElement("blockquote");
text.textContent = review.review;
top.append(title, stars);
card.append(top, text);
return card;
}
async function refresh(showNewest = false) {
const request = ++latestRequest;
try {
const response = await fetch("/api/reviews", { cache: "no-store" });
if (!response.ok) throw new Error("Laden fehlgeschlagen");
const result = await response.json();
if (request !== latestRequest || drag) return;
const next = result.reviews;
if (
!Array.isArray(next) ||
next.some(
(review) =>
typeof review.title !== "string" ||
typeof review.review !== "string" ||
!Number.isInteger(review.rating) ||
review.rating < 1 ||
review.rating > 5,
)
)
throw new Error("Ungültige Bewertungen");
if (feedback.textContent.startsWith("Bewertungen können"))
feedback.textContent = "";
if (JSON.stringify(next) === JSON.stringify(reviews)) return;
const active = JSON.stringify(reviews[index]);
reviews = next;
if (!reviews.length) {
// Reloading also restores the empty placeholder after external deletion.
stage.replaceChildren(placeholder.cloneNode(true));
previousArrow.hidden = true;
nextArrow.hidden = true;
schedule();
return;
}
index = showNewest
? reviews.length - 1
: Math.max(
0,
reviews.findIndex(
(review) => JSON.stringify(review) === active,
),
);
stage.replaceChildren(
...reviews.map((review, number) =>
cardFor(review, number, reviews.length),
),
);
previousArrow.hidden = reviews.length < 2;
nextArrow.hidden = reviews.length < 2;
void stage.offsetWidth;
show(index);
feedback.textContent = "";
} catch {
if (request === latestRequest)
feedback.textContent =
"Bewertungen können gerade nicht aktualisiert werden. Wir versuchen es erneut.";
}
}
const placeholder = stage.firstElementChild.cloneNode(true);
stage.addEventListener("keydown", (event) => {
if (drag || event.altKey || event.ctrlKey || event.metaKey) return;
const targets = {
ArrowLeft: index - 1,
ArrowRight: index + 1,
Home: 0,
End: reviews.length - 1,
};
if (!(event.key in targets)) return;
event.preventDefault();
show(targets[event.key], true);
});
stage.addEventListener("pointerdown", (event) => {
if (!event.isPrimary || event.button !== 0 || reviews.length < 2 || drag)
return;
drag = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
dx: 0,
dy: 0,
};
clearTimeout(timer);
stage.setPointerCapture(event.pointerId);
stage.classList.add("is-dragging");
});
stage.addEventListener("pointermove", (event) => {
if (!drag || drag.id !== event.pointerId) return;
drag.dx = event.clientX - drag.x;
drag.dy = event.clientY - drag.y;
if (Math.abs(drag.dx) > Math.abs(drag.dy)) {
const offset = Math.max(
-stage.clientWidth / 2,
Math.min(stage.clientWidth / 2, drag.dx),
);
stage.children[index].style.transform = `translateX(${offset}px)`;
}
});
function finishDrag(event) {
if (!drag || drag.id !== event.pointerId) return;
const { dx, dy } = drag;
drag = null;
stage.classList.remove("is-dragging");
stage.children[index]?.style.removeProperty("transform");
if (stage.hasPointerCapture(event.pointerId))
stage.releasePointerCapture(event.pointerId);
if (
event.type === "pointerup" &&
Math.abs(dx) > 50 &&
Math.abs(dx) > Math.abs(dy)
) {
show(index + (dx < 0 ? 1 : -1), true);
} else {
schedule();
}
}
for (const event of ["pointerup", "pointercancel", "lostpointercapture"]) {
stage.addEventListener(event, finishDrag);
}
document.addEventListener("review:saved", () => refresh(true));
document.addEventListener("visibilitychange", () => {
schedule();
if (!document.hidden) refresh();
});
setInterval(() => {
if (!document.hidden) refresh();
}, interval);
refresh();

View File

@ -0,0 +1,3 @@
createdAt,email,title,review,rating,consent
2026-09-30T12:06:34.079Z,marvin@web.de,Das Premium Paket hat mir sehr geholfen!,Ich hatte eine super Erfahrung mit der Lilli beim Premium Paket. Ich konnte schnell Termine buchen und Lilli konnte immer auf die Sonderwünsche der Wettkampfbegleitung eingehen. Ich hab sie einmal auf eine Allergie aufmerksam gemacht und sie hat meinen Plan umgehend angepasst. Gerne wieder!,5,true
2026-09-30T12:10:41.673Z,test@web.de,"Tolle Beratung, aber etwas zu teuer!",Ich bin insgesamt sehr zufrieden gewesen und konnte meine Ernährung erfolgreich umstellen. Für mich ist der Preis aber etwas zu hoch gewesen.,4,true
1 createdAt email title review rating consent
2 2026-09-30T12:06:34.079Z marvin@web.de Das Premium Paket hat mir sehr geholfen! Ich hatte eine super Erfahrung mit der Lilli beim Premium Paket. Ich konnte schnell Termine buchen und Lilli konnte immer auf die Sonderwünsche der Wettkampfbegleitung eingehen. Ich hab sie einmal auf eine Allergie aufmerksam gemacht und sie hat meinen Plan umgehend angepasst. Gerne wieder! 5 true
3 2026-09-30T12:10:41.673Z test@web.de Tolle Beratung, aber etwas zu teuer! Ich bin insgesamt sehr zufrieden gewesen und konnte meine Ernährung erfolgreich umstellen. Für mich ist der Preis aber etwas zu hoch gewesen. 4 true

View File

@ -2,13 +2,26 @@ import { BookingError, emailPattern } from "../services/booking-service.js";
import { json, readJson } from "./bookings.js"; import { json, readJson } from "./bookings.js";
export async function handleReview(request, response, bookings, reviews) { export async function handleReview(request, response, bookings, reviews) {
if (request.method !== "POST") { if (!["GET", "POST"].includes(request.method)) {
response.setHeader("Allow", "POST"); response.setHeader("Allow", "GET, POST");
return json(response, 405, { return json(response, 405, {
error: { message: "Bitte POST verwenden." }, error: { message: "Bitte GET oder POST verwenden." },
}); });
} }
try { try {
if (request.method === "GET") {
const records = await reviews.readAll();
return json(response, 200, {
reviews: records
.filter((record) => record.consent === "true")
.map(({ createdAt, title, review, rating }) => ({
createdAt,
title,
review,
rating: Number(rating),
})),
});
}
const input = await readJson(request); const input = await readJson(request);
if (!input || typeof input !== "object" || Array.isArray(input)) { if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new BookingError( throw new BookingError(
@ -87,9 +100,14 @@ export async function handleReview(request, response, bookings, reviews) {
} }
return json(response, 503, { return json(response, 503, {
error: { error: {
code: "REVIEW_NOT_SAVED", code:
request.method === "GET"
? "REVIEWS_UNAVAILABLE"
: "REVIEW_NOT_SAVED",
message: message:
"Die Bewertung konnte nicht gespeichert werden. Bitte versuche es später erneut.", request.method === "GET"
? "Die Bewertungen können gerade nicht geladen werden."
: "Die Bewertung konnte nicht gespeichert werden. Bitte versuche es später erneut.",
}, },
}); });
} }

View File

@ -18,6 +18,7 @@ const legacyColumns = [
const orderColumns = [...legacyColumns, "bookedPackage"]; const orderColumns = [...legacyColumns, "bookedPackage"];
const customerColumns = [...orderColumns, "customer", "acceptedTerms"]; const customerColumns = [...orderColumns, "customer", "acceptedTerms"];
const consentColumns = [...customerColumns, "consent"];
const phoneColumns = [...customerColumns, "phone"]; const phoneColumns = [...customerColumns, "phone"];
export const columns = [...phoneColumns, "consent"]; export const columns = [...phoneColumns, "consent"];
const jsonColumns = ["bookedPackage", "customer", "acceptedTerms", "consent"]; const jsonColumns = ["bookedPackage", "customer", "acceptedTerms", "consent"];
@ -51,6 +52,7 @@ export function createCsvStorage(filePath) {
header.join(",") !== columns.join(",") && header.join(",") !== columns.join(",") &&
header.join(",") !== customerColumns.join(",") && header.join(",") !== customerColumns.join(",") &&
header.join(",") !== phoneColumns.join(",") && header.join(",") !== phoneColumns.join(",") &&
header.join(",") !== consentColumns.join(",") &&
header.join(",") !== legacyColumns.join(",") && header.join(",") !== legacyColumns.join(",") &&
header.join(",") !== orderColumns.join(",") header.join(",") !== orderColumns.join(",")
) { ) {

View File

@ -227,3 +227,206 @@
align-items: flex-start; align-items: flex-start;
} }
} }
.cookie-settings-dialog,
.cookie-settings-dialog * {
box-sizing: border-box;
}
.cookie-settings-dialog {
width: min(58rem, calc(100% - 2rem));
max-width: none;
max-height: calc(100% - 2rem);
margin: auto;
padding: clamp(1.25rem, 3vw, 2.1rem);
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 1.4rem;
background: #071217;
color: #f8fafc;
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.55);
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.cookie-settings-dialog::backdrop {
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.cookie-settings-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
margin-bottom: 1.75rem;
}
.cookie-settings-eyebrow {
margin: 0 0 0.45rem;
color: rgba(255, 255, 255, 0.52);
font-size: 0.72rem;
letter-spacing: 0.22em;
}
.cookie-settings-heading h2 {
margin: 0;
font-family: inherit;
color: #fff;
font-size: clamp(1.6rem, 4vw, 2.15rem);
line-height: 1.15;
}
.cookie-settings-close {
flex: 0 0 auto;
padding: 0;
border: 0;
background: transparent;
color: #fff;
font: inherit;
font-size: 2.1rem;
line-height: 1;
cursor: pointer;
}
.cookie-settings-options {
display: grid;
gap: 0.9rem;
}
.cookie-option {
position: relative;
display: flex;
min-height: 5.25rem;
align-items: center;
justify-content: space-between;
gap: 3.5rem;
padding: 1.1rem 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: 1.2rem;
background: rgba(255, 255, 255, 0.065);
cursor: pointer;
}
.cookie-option-copy {
display: grid;
gap: 0.35rem;
}
.cookie-option-copy strong {
color: #fff;
font-size: 1.05rem;
font-weight: 600;
}
.cookie-option-copy > span {
color: rgba(255, 255, 255, 0.66);
font-size: 0.94rem;
line-height: 1.55;
}
.cookie-option input {
position: absolute;
top: 1.15rem;
right: 1.25rem;
z-index: 1;
width: 3.25rem;
height: 1.75rem;
margin: 0;
opacity: 0;
cursor: pointer;
}
.cookie-toggle {
position: absolute;
top: 1.1rem;
right: 1.15rem;
width: 3.25rem;
height: 1.75rem;
border-radius: 999px;
background: #465057;
transition: background-color 0.18s ease;
}
.cookie-toggle::before {
content: "";
position: absolute;
top: 0.2rem;
left: 0.2rem;
width: 1.35rem;
height: 1.35rem;
border-radius: 50%;
background: #fff;
transition: transform 0.18s ease;
}
.cookie-option input:checked + .cookie-toggle {
background: #00bfd2;
}
.cookie-option input:checked + .cookie-toggle::before {
transform: translateX(1.5rem);
}
.cookie-option input:focus-visible + .cookie-toggle,
.cookie-settings-dialog button:focus-visible {
outline: 3px solid #4edbdc;
outline-offset: 3px;
}
.cookie-option input:disabled {
cursor: not-allowed;
}
.cookie-settings-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
.cookie-settings-actions button {
min-height: 3rem;
padding: 0.7rem 1.2rem;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 999px;
background: transparent;
color: #f8fafc;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.cookie-settings-actions .cookie-settings-save {
border-color: #fff;
background: #fff;
color: #102027;
}
@media (max-width: 600px) {
.cookie-settings-dialog {
width: calc(100% - 1.25rem);
padding: 1.2rem;
border-radius: 1.1rem;
}
.cookie-option {
align-items: flex-start;
padding: 1rem 4rem 1rem 1rem;
}
.cookie-option input,
.cookie-toggle {
top: 1rem;
right: 0.85rem;
}
.cookie-settings-actions {
flex-direction: column-reverse;
}
.cookie-settings-actions button {
width: 100%;
}
}

View File

@ -0,0 +1,217 @@
import { test, expect } from "@playwright/test";
import { randomUUID } from "node:crypto";
const reviews = [
{
createdAt: "2026-01-01",
title: "Hilfreiche Beratung",
review: "Die Beratung hat mir im Training sehr geholfen.",
rating: 4,
},
{
createdAt: "2026-01-02",
title: "Sehr guter Ernährungsplan",
review: "Der Plan lässt sich leicht in meinen Alltag integrieren.",
rating: 5,
},
{
createdAt: "2026-01-03",
title: "Gute Tipps für den Alltag",
review: "Ich konnte viele Tipps direkt im Alltag ausprobieren.",
rating: 3,
},
];
const active = (page) => page.locator("#reviewSlider .review-card.is-active");
async function mock(page, rows = reviews) {
await page.route("**/api/reviews", (route) =>
route.fulfill({ json: { reviews: rows } }),
);
await page.goto("/index.html");
await expect(active(page)).toContainText(
rows[0]?.title || "Deine Erfahrung",
);
}
test("10-Sekunden-Wechsel, Maus-Drag und Tastatur ohne Buttons", async ({
page,
}) => {
await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") });
await page.clock.pauseAt(new Date("2026-01-01T00:00:01Z"));
await mock(page);
const stage = page.locator(".review-stage");
await expect(page.locator("#reviewSlider button")).toHaveCount(0);
const font = await page
.locator("body")
.evaluate((element) => getComputedStyle(element).fontFamily);
await expect(active(page).locator("h3")).toHaveCSS("font-family", font);
await expect(active(page).locator("blockquote")).toHaveCSS(
"font-family",
font,
);
await expect(active(page).locator(".stars")).toHaveAttribute(
"aria-label",
"4 von 5 Sternen",
);
await page.clock.runFor(9999);
await expect(active(page)).toContainText(reviews[0].title);
await page.clock.runFor(1);
await expect(active(page)).toContainText(reviews[1].title);
await stage.scrollIntoViewIfNeeded();
const box = await stage.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + 60);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 - 100, box.y + 60, {
steps: 6,
});
await expect(stage).toHaveClass(/is-dragging/);
await expect(active(page)).toHaveCSS(
"transform",
"matrix(1, 0, 0, 1, -100, 0)",
);
await page.mouse.up();
await expect(active(page)).toContainText(reviews[2].title);
await expect(stage).toBeFocused();
await page.clock.runFor(9999);
await expect(active(page)).toContainText(reviews[2].title);
await page.clock.runFor(1);
await expect(active(page)).toContainText(reviews[0].title);
await page.keyboard.press("ArrowLeft");
await expect(active(page)).toContainText(reviews[2].title);
await page.keyboard.press("Home");
await expect(active(page)).toContainText(reviews[0].title);
await page.keyboard.press("End");
await expect(active(page)).toContainText(reviews[2].title);
await page.keyboard.press("ArrowRight");
await expect(active(page)).toContainText(reviews[0].title);
await page.clock.runFor(10_000);
await expect(active(page)).toContainText(reviews[1].title);
});
test("Kurzes Ziehen springt zurück; Drag nach rechts zeigt vorherige Rezension", async ({
page,
}) => {
await mock(page);
const stage = page.locator(".review-stage");
await stage.scrollIntoViewIfNeeded();
const box = await stage.boundingBox();
for (const distance of [20, 100]) {
await page.mouse.move(box.x + box.width / 2, box.y + 60);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + distance, box.y + 60, {
steps: 5,
});
await page.mouse.up();
await expect(active(page)).toContainText(
reviews[distance === 20 ? 0 : 2].title,
);
await expect(stage).not.toHaveClass(/is-dragging/);
}
});
test("Leere Liste und einzelne Rezension brauchen keine Slider-Steuerung", async ({
page,
}) => {
await mock(page, []);
await expect(page.locator("#reviewSlider button")).toHaveCount(0);
await page.unroute("**/api/reviews");
await mock(page, [reviews[0]]);
await expect(page.locator("#reviewSlider button")).toHaveCount(0);
});
test("Mobile Anzeige bleibt stabil; HTML im Rezensionstext wird nicht ausgeführt", async ({
page,
}) => {
await page.setViewportSize({ width: 375, height: 850 });
await page.emulateMedia({ reducedMotion: "reduce" });
const rows = [
reviews[0],
{
...reviews[1],
review:
'<img src=x onerror="window.reviewInjected=true">' +
"x".repeat(400),
},
];
await mock(page, rows);
const before = await page.locator(".review-stage").boundingBox();
await page.locator(".review-stage").focus();
await page.keyboard.press("ArrowRight");
const after = await page.locator(".review-stage").boundingBox();
expect(after.height).toBe(before.height);
await expect(active(page)).toHaveCSS("transition-duration", "0s");
await expect(active(page)).toContainText("<img src=x");
await expect(active(page).locator("img")).toHaveCount(0);
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
});
test("Neue Rezension erscheint erst nach erfolgreicher Speicherung und bleibt nach Neuladen", async ({
page,
request,
}) => {
const email = `slider-${randomUUID()}@example.test`;
expect(
(
await request.post("/api/bookings", {
headers: { "Idempotency-Key": randomUUID() },
data: {
packageId: "ernaehrung-starter",
name: "Slider Test",
email,
consent: true,
},
})
).status(),
).toBe(201);
await page.goto("/index.html");
const title = `Neue Rezension ${randomUUID().slice(0, 8)}`;
const form = page.locator("#reviewForm");
await form.locator('[name="email"]').fill("unbekannt@example.test");
await form.locator('[name="title"]').fill(title);
await form
.locator('[name="review"]')
.fill("Eine neue und hilfreiche Erfahrung mit der Beratung.");
await form.locator('label[for="star4"]').click();
await form.locator('[name="consent"]').check();
await form.locator('[type="submit"]').click();
await expect(page.locator("#formStatus")).toContainText(
"keine Buchung gefunden",
);
await expect(page.locator("#reviewSlider")).not.toContainText(title);
await form.locator('[name="email"]').fill(email);
await form.locator('[type="submit"]').click();
await expect(page.locator("#formStatus")).toContainText("gespeichert");
await expect(active(page)).toContainText(title);
await expect(page.locator("#reviewSlider")).not.toContainText(email);
await page.reload();
await expect(page.locator("#reviewSlider")).toContainText(title);
});
test("Polling nimmt neue Backend-Rezensionen auf; Lesefehler erhalten vorhandene Karten", async ({
page,
}) => {
await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") });
await page.clock.pauseAt(new Date("2026-01-01T00:00:01Z"));
let rows = [reviews[0]];
let fail = false;
await page.route("**/api/reviews", (route) =>
route.fulfill(
fail ? { status: 503, json: {} } : { json: { reviews: rows } },
),
);
await page.goto("/index.html");
await expect(active(page)).toContainText(reviews[0].title);
rows = reviews;
await page.clock.runFor(10_000);
await expect(page.locator("#reviewSlider .review-card")).toHaveCount(3);
fail = true;
await page.clock.runFor(10_000);
await expect(page.locator(".review-feedback")).toContainText(
"nicht aktualisiert",
);
await expect(page.locator("#reviewSlider .review-card")).toHaveCount(3);
});

View File

@ -59,6 +59,33 @@ test("Kundenbewertung wird dauerhaft als CSV gespeichert; Buchungen bleiben unve
assert.equal(await readFile(bookingFile, "utf8"), before); assert.equal(await readFile(bookingFile, "utf8"), before);
}); });
test("Bewertung funktioniert mit dem Buchungs-CSV-Format ohne Telefonnummer", async (t) => {
const { post, bookingFile, reviews } = await setup(t);
const header =
"bookingId,createdAt,packageId,packageName,name,email,emailStatus,idempotencyKey,requestHash,bookedPackage,customer,acceptedTerms,consent";
const booking = [
"TH-000001",
"2026-09-25T19:22:37.801Z",
"ernaehrung-starter",
"Paket",
"Kunde",
input.email,
"accepted",
"idempotency-key",
"request-hash",
"",
"",
"true",
"true",
].join(",");
await writeFile(bookingFile, `${header}\r\n${booking}\r\n`);
const response = await post();
assert.equal(response.status, 201);
assert.deepEqual(await response.json(), { saved: true });
assert.equal((await reviews.readAll()).length, 1);
});
test("Unbekannte E-Mail, ungültige Felder und fehlende Zustimmung speichern nichts", async (t) => { test("Unbekannte E-Mail, ungültige Felder und fehlende Zustimmung speichern nichts", async (t) => {
const { post, reviews } = await setup(t); const { post, reviews } = await setup(t);
assert.equal( assert.equal(
@ -105,6 +132,55 @@ test("Grenzwerte und alle Sterne sind erlaubt; paralleles Speichern verliert kei
); );
}); });
test("Review-API weist falschen Inhaltstyp, ungültiges JSON und zu große Bodies zurück", async (t) => {
const { url, reviews } = await setup(t);
const send = (body, contentType) =>
fetch(`${url}/api/reviews`, {
method: "POST",
headers: { "Content-Type": contentType },
body,
});
const unsupported = await send(JSON.stringify(input), "text/plain");
assert.equal(unsupported.status, 415);
assert.equal(
(await unsupported.json()).error.code,
"UNSUPPORTED_CONTENT_TYPE",
);
const malformed = await send("{broken", "application/json");
assert.equal(malformed.status, 400);
assert.equal((await malformed.json()).error.code, "INVALID_JSON");
const oversized = await send(" ".repeat(8193), "application/json");
assert.equal(oversized.status, 413);
assert.equal((await oversized.json()).error.code, "BODY_TOO_LARGE");
assert.deepEqual(await reviews.readAll(), []);
});
test("Fehler beim Lesen der Buchungs-CSV speichern keine Bewertung", async (t) => {
let appended = false;
const { post, reviews } = await setup(t, {
storage: {
readAll: async () => {
throw new Error("Booking CSV unreadable");
},
},
reviewStorage: {
append: async () => {
appended = true;
},
readAll: () => reviews.readAll(),
},
});
const response = await post();
assert.equal(response.status, 503);
assert.equal((await response.json()).error.code, "REVIEW_NOT_SAVED");
assert.equal(appended, false);
assert.deepEqual(await reviews.readAll(), []);
});
test("Speicherfehler bestätigt keinen Erfolg; beschädigte CSV bleibt erhalten", async (t) => { test("Speicherfehler bestätigt keinen Erfolg; beschädigte CSV bleibt erhalten", async (t) => {
const { post, file } = await setup(t); const { post, file } = await setup(t);
await writeFile(file, "falscher,header\n1,2\n"); await writeFile(file, "falscher,header\n1,2\n");
@ -126,9 +202,50 @@ test("CSV maskiert Tabellenformeln und bleibt über HTTP privat", async (t) => {
assert.equal((await post({ ...input, title })).status, 201); assert.equal((await post({ ...input, title })).status, 201);
assert.equal((await reviews.readAll())[0].title, title); assert.equal((await reviews.readAll())[0].title, title);
assert.ok((await readFile(file, "utf8")).includes("'=SUM")); assert.ok((await readFile(file, "utf8")).includes("'=SUM"));
assert.equal((await fetch(`${url}/api/reviews`)).status, 405); assert.equal(
(await fetch(`${url}/api/reviews`, { method: "DELETE" })).status,
405,
);
assert.equal( assert.equal(
(await fetch(`${url}/src/server/data/reviews.csv`)).status, (await fetch(`${url}/src/server/data/reviews.csv`)).status,
404, 404,
); );
}); });
test("Öffentliche Rezensionen kommen aus CSV, ohne E-Mail oder Zustimmung", async (t) => {
const { post, url, reviews } = await setup(t);
assert.deepEqual(await (await fetch(`${url}/api/reviews`)).json(), {
reviews: [],
});
await reviews.append({ ...input, consent: false, createdAt: "2026-01-01" });
assert.equal((await post()).status, 201);
const response = await fetch(`${url}/api/reviews`);
assert.equal(response.status, 200);
assert.equal(response.headers.get("cache-control"), "no-store");
const body = await response.json();
assert.equal(body.reviews.length, 1);
const [review] = body.reviews;
assert.deepEqual(Object.keys(review).sort(), [
"createdAt",
"rating",
"review",
"title",
]);
assert.equal(review.title, input.title);
assert.equal(review.review, input.review);
assert.equal(review.rating, 5);
assert.ok(!JSON.stringify(body).includes(input.email));
});
test("Lesefehler bei Rezensionen liefern einen kontrollierten Fehler", async (t) => {
const { url } = await setup(t, {
reviewStorage: {
readAll: async () => {
throw new Error("Disk error");
},
},
});
const response = await fetch(`${url}/api/reviews`);
assert.equal(response.status, 503);
assert.equal((await response.json()).error.code, "REVIEWS_UNAVAILABLE");
});