Compare commits
13 Commits
feature/bi
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
599fe51ef2 | |
|
|
4d95aeb772 | |
|
|
857cd86ab2 | |
|
|
787e7b61a6 | |
|
|
15e5dbe904 | |
|
|
84cf3409de | |
|
|
ea47960393 | |
|
|
dfa8aeff6c | |
|
|
f04b45bf16 | |
|
|
4f308c2a7b | |
|
|
7c96a97ebf | |
|
|
42c0db48d5 | |
|
|
7c2a4e99c3 |
|
|
@ -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/
|
||||||
|
|
|
||||||
35
berater.html
35
berater.html
|
|
@ -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>
|
||||||
|
|
|
||||||
48
index.html
48
index.html
|
|
@ -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,17 +1017,30 @@
|
||||||
</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"
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="Karussell"
|
||||||
|
aria-label="Kundenrezensionen"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="review-stage"
|
||||||
|
aria-live="off"
|
||||||
|
tabindex="0"
|
||||||
|
role="group"
|
||||||
|
aria-label="Rezensionen durchblättern"
|
||||||
|
aria-describedby="reviewInstructions"
|
||||||
|
>
|
||||||
|
<article class="review-card is-active">
|
||||||
<div class="review-top">
|
<div class="review-top">
|
||||||
<div class="person">
|
<div class="person">
|
||||||
<div class="avatar">T</div>
|
<div class="avatar">T</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Deine Erfahrung</strong>
|
<strong>Deine Erfahrung</strong>
|
||||||
<small
|
<small
|
||||||
>Hier kann eine Kundenstimme
|
>Noch keine Rezension
|
||||||
erscheinen</small
|
vorhanden</small
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1041,15 +1054,24 @@
|
||||||
|
|
||||||
<blockquote
|
<blockquote
|
||||||
style="
|
style="
|
||||||
font-family: "Inter", sans-serif;
|
font-family:
|
||||||
|
"Inter", sans-serif;
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
„Teile hier deine Erfahrung mit der
|
„Teile hier deine Erfahrung mit der
|
||||||
Ernährungsberatung – ehrlich, persönlich und so,
|
Ernährungsberatung – ehrlich, persönlich und
|
||||||
wie du sie erlebt hast.“
|
so, wie du sie erlebt hast.“
|
||||||
</blockquote>
|
</blockquote>
|
||||||
</article>
|
</article>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="review-instructions"
|
||||||
|
id="reviewInstructions"
|
||||||
|
>Mit der Maus ziehen oder mit den Pfeiltasten links
|
||||||
|
und rechts durch die Rezensionen wechseln.</span
|
||||||
|
>
|
||||||
|
<span class="review-announcement" role="status"></span>
|
||||||
|
<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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 & 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());
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
|
@ -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.";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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
|
||||||
|
|
|
@ -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.",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(",")
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue