feat: Darstellung der Rezensionen implementiert. Minimale Stilanpassungen noch möglich

feature/rezensionen-darstellen
Marvin Gemlin 2026-09-30 13:30:06 +00:00
parent 502041fc04
commit 4f308c2a7b
7 changed files with 610 additions and 38 deletions

View File

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

View File

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

View File

@ -0,0 +1,82 @@
.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-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;
}

View File

@ -0,0 +1,187 @@
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 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));
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),
),
);
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.focus({ preventScroll: true });
stage.classList.add("is-dragging");
});
stage.addEventListener("pointermove", (event) => {
if (!drag || drag.id !== event.pointerId) return;
drag.dx = event.clientX - drag.x;
drag.dy = event.clientY - drag.y;
if (Math.abs(drag.dx) > Math.abs(drag.dy)) {
const offset = Math.max(
-stage.clientWidth / 2,
Math.min(stage.clientWidth / 2, drag.dx),
);
stage.children[index].style.transform = `translateX(${offset}px)`;
}
});
function finishDrag(event) {
if (!drag || drag.id !== event.pointerId) return;
const { dx, dy } = drag;
drag = null;
stage.classList.remove("is-dragging");
stage.children[index]?.style.removeProperty("transform");
if (stage.hasPointerCapture(event.pointerId))
stage.releasePointerCapture(event.pointerId);
if (
event.type === "pointerup" &&
Math.abs(dx) > 50 &&
Math.abs(dx) > Math.abs(dy)
) {
show(index + (dx < 0 ? 1 : -1), true);
} else {
schedule();
}
}
for (const event of ["pointerup", "pointercancel", "lostpointercapture"]) {
stage.addEventListener(event, finishDrag);
}
document.addEventListener("review:saved", () => refresh(true));
document.addEventListener("visibilitychange", () => {
schedule();
if (!document.hidden) refresh();
});
setInterval(() => {
if (!document.hidden) refresh();
}, interval);
refresh();

View File

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

View File

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

View File

@ -126,9 +126,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");
});