diff --git a/src/features/reviews/review-slider.css b/src/features/reviews/review-slider.css index c307a1a..2228729 100644 --- a/src/features/reviews/review-slider.css +++ b/src/features/reviews/review-slider.css @@ -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; + } +} diff --git a/src/features/reviews/review-slider.js b/src/features/reviews/review-slider.js index f7f3dcc..d0b8423 100644 --- a/src/features/reviews/review-slider.js +++ b/src/features/reviews/review-slider.js @@ -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) => { diff --git a/src/server/services/csv-storage.js b/src/server/services/csv-storage.js index 2656660..74b709d 100644 --- a/src/server/services/csv-storage.js +++ b/src/server/services/csv-storage.js @@ -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(",") ) { diff --git a/src/tests/reviews.test.js b/src/tests/reviews.test.js index 0f02501..7170edb 100644 --- a/src/tests/reviews.test.js +++ b/src/tests/reviews.test.js @@ -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");