feat: Darstellung der Rezensionen implementiert. Minimale Stilanpassungen noch möglich
parent
502041fc04
commit
4f308c2a7b
90
index.html
90
index.html
|
|
@ -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:
|
||||||
|
"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
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,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;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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.",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue