feat: Rezensionen mit Navigation und Tests ergänzen
parent
dfa8aeff6c
commit
15e5dbe904
|
|
@ -51,6 +51,53 @@
|
||||||
outline: 2px solid var(--brand);
|
outline: 2px solid var(--brand);
|
||||||
outline-offset: 4px;
|
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 {
|
.review-stage.is-dragging {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
@ -80,3 +127,13 @@
|
||||||
clip-path: inset(50%);
|
clip-path: inset(50%);
|
||||||
white-space: nowrap;
|
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 stage = slider.querySelector(".review-stage");
|
||||||
const feedback = slider.querySelector(".review-feedback");
|
const feedback = slider.querySelector(".review-feedback");
|
||||||
const announcement = slider.querySelector(".review-announcement");
|
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;
|
const interval = 10_000;
|
||||||
let reviews = [];
|
let reviews = [];
|
||||||
let index = 0;
|
let index = 0;
|
||||||
|
|
@ -89,6 +108,8 @@ async function refresh(showNewest = false) {
|
||||||
if (!reviews.length) {
|
if (!reviews.length) {
|
||||||
// Reloading also restores the empty placeholder after external deletion.
|
// Reloading also restores the empty placeholder after external deletion.
|
||||||
stage.replaceChildren(placeholder.cloneNode(true));
|
stage.replaceChildren(placeholder.cloneNode(true));
|
||||||
|
previousArrow.hidden = true;
|
||||||
|
nextArrow.hidden = true;
|
||||||
schedule();
|
schedule();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -105,6 +126,8 @@ async function refresh(showNewest = false) {
|
||||||
cardFor(review, number, reviews.length),
|
cardFor(review, number, reviews.length),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
previousArrow.hidden = reviews.length < 2;
|
||||||
|
nextArrow.hidden = reviews.length < 2;
|
||||||
void stage.offsetWidth;
|
void stage.offsetWidth;
|
||||||
show(index);
|
show(index);
|
||||||
feedback.textContent = "";
|
feedback.textContent = "";
|
||||||
|
|
@ -140,7 +163,6 @@ stage.addEventListener("pointerdown", (event) => {
|
||||||
};
|
};
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
stage.setPointerCapture(event.pointerId);
|
stage.setPointerCapture(event.pointerId);
|
||||||
stage.focus({ preventScroll: true });
|
|
||||||
stage.classList.add("is-dragging");
|
stage.classList.add("is-dragging");
|
||||||
});
|
});
|
||||||
stage.addEventListener("pointermove", (event) => {
|
stage.addEventListener("pointermove", (event) => {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ const legacyColumns = [
|
||||||
|
|
||||||
const orderColumns = [...legacyColumns, "bookedPackage"];
|
const orderColumns = [...legacyColumns, "bookedPackage"];
|
||||||
const customerColumns = [...orderColumns, "customer", "acceptedTerms"];
|
const customerColumns = [...orderColumns, "customer", "acceptedTerms"];
|
||||||
|
const consentColumns = [...customerColumns, "consent"];
|
||||||
const phoneColumns = [...customerColumns, "phone"];
|
const phoneColumns = [...customerColumns, "phone"];
|
||||||
export const columns = [...phoneColumns, "consent"];
|
export const columns = [...phoneColumns, "consent"];
|
||||||
const jsonColumns = ["bookedPackage", "customer", "acceptedTerms", "consent"];
|
const jsonColumns = ["bookedPackage", "customer", "acceptedTerms", "consent"];
|
||||||
|
|
@ -51,6 +52,7 @@ export function createCsvStorage(filePath) {
|
||||||
header.join(",") !== columns.join(",") &&
|
header.join(",") !== columns.join(",") &&
|
||||||
header.join(",") !== customerColumns.join(",") &&
|
header.join(",") !== customerColumns.join(",") &&
|
||||||
header.join(",") !== phoneColumns.join(",") &&
|
header.join(",") !== phoneColumns.join(",") &&
|
||||||
|
header.join(",") !== consentColumns.join(",") &&
|
||||||
header.join(",") !== legacyColumns.join(",") &&
|
header.join(",") !== legacyColumns.join(",") &&
|
||||||
header.join(",") !== orderColumns.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);
|
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) => {
|
test("Unbekannte E-Mail, ungültige Felder und fehlende Zustimmung speichern nichts", async (t) => {
|
||||||
const { post, reviews } = await setup(t);
|
const { post, reviews } = await setup(t);
|
||||||
assert.equal(
|
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) => {
|
test("Speicherfehler bestätigt keinen Erfolg; beschädigte CSV bleibt erhalten", async (t) => {
|
||||||
const { post, file } = await setup(t);
|
const { post, file } = await setup(t);
|
||||||
await writeFile(file, "falscher,header\n1,2\n");
|
await writeFile(file, "falscher,header\n1,2\n");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue