feat: Rezensionen mit Navigation und Tests ergänzen
parent
dfa8aeff6c
commit
15e5dbe904
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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(",")
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue