Merge branch 'feature/rezensionen-darstellen' into dev
# Conflicts: # .gitignorepull/12/head
commit
787e7b61a6
|
|
@ -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/
|
||||||
|
|
|
||||||
90
index.html
90
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,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:
|
||||||
|
"Inter", 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: "Inter", 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"
|
||||||
|
|
|
||||||
|
|
@ -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(",")
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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