feat: Rezensionen mit Navigation und Tests ergänzen

feature/rezensionen-darstellen
Emirhan Sahin 2026-09-30 14:30:49 +00:00
parent dfa8aeff6c
commit 15e5dbe904
4 changed files with 158 additions and 1 deletions

View File

@ -51,6 +51,53 @@
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;
}
@ -80,3 +127,13 @@
clip-path: inset(50%);
white-space: nowrap;
}
@media (max-width: 600px) {
.review-carousel {
padding-inline: 2.25rem;
}
.review-arrow {
width: 1.5rem;
font-size: 2rem;
}
}

View File

@ -4,6 +4,25 @@ 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;
@ -89,6 +108,8 @@ async function refresh(showNewest = false) {
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;
}
@ -105,6 +126,8 @@ async function refresh(showNewest = false) {
cardFor(review, number, reviews.length),
),
);
previousArrow.hidden = reviews.length < 2;
nextArrow.hidden = reviews.length < 2;
void stage.offsetWidth;
show(index);
feedback.textContent = "";
@ -140,7 +163,6 @@ stage.addEventListener("pointerdown", (event) => {
};
clearTimeout(timer);
stage.setPointerCapture(event.pointerId);
stage.focus({ preventScroll: true });
stage.classList.add("is-dragging");
});
stage.addEventListener("pointermove", (event) => {

View File

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

View File

@ -59,6 +59,33 @@ test("Kundenbewertung wird dauerhaft als CSV gespeichert; Buchungen bleiben unve
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) => {
const { post, reviews } = await setup(t);
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) => {
const { post, file } = await setup(t);
await writeFile(file, "falscher,header\n1,2\n");