Kundenrezensionen jetzt vollständig durch Kund:innen selbst, nicht mehr über Admin

Bisher musste VanVan Bewertungen manuell im CMS eintragen. Jetzt können
angemeldete Kund:innen Rezensionen direkt auf der Produktseite abgeben
(inkl. Herzen-Sternebewertung und Text). Die Durchschnittsbewertung wird
automatisch berechnet und überall live eingebunden: Produktkarten
(shop-weit), Produktdetailseite, Shop-Filter/Sortierung nach Bewertung
und die Kundenstimmen-Sektion auf der Startseite.

Neu dazu: ein Kontobereich fürs Login (Demo-Login per E-Mail), Profilfoto-
Upload, wählbarer Benutzername, und ein Header-Kontomenü, das nur bei
Anmeldung erscheint. Jede Bestellung im Konto-Dashboard hat einen
"Rezension schreiben"-Link, der direkt zur Produktseite führt und dort
per Bestellnummer verknüpft wird; im Profil ist dann sofort sichtbar,
welche Bestellungen schon bewertet wurden.

Technisch als reines Frontend/localStorage-System umgesetzt (kein Backend
in dieser Phase), analog zum bestehenden Warenkorb-Muster. Admin-CMS-Feld
für manuelle Bewertungen entfernt, da Rezensionen jetzt ausschließlich von
Kund:innen kommen (auch aus rechtlichen Gründen: keine Fake-Bewertungen).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
qcigano
2026-08-03 04:17:04 +02:00
co-authored by Claude Sonnet 5
parent d27a858d8f
commit 6f10e41386
28 changed files with 2338 additions and 334 deletions
+51 -4
View File
@@ -26,7 +26,7 @@ const badgeLabel: Record<string, string> = {
const name = product.name[lang];
const desc = product.beschreibung[lang];
---
<a href={`${localePrefix(lang)}/produkt/${product.slug}/`} class="card product-card">
<a href={`${localePrefix(lang)}/produkt/${product.slug}/`} class="card product-card" data-slug={product.slug}>
<div class="product-media" data-carousel>
{product.bilder && product.bilder.length > 0 ? (
<>
@@ -59,9 +59,23 @@ const desc = product.beschreibung[lang];
</div>
</div>
<h3>{name}</h3>
{bewertungSchnitt !== null && (
<HeartRating bewertung={bewertungSchnitt} size={13} anzahlLabel={`(${bewertungAnzahl})`} />
)}
{/* Immer gerendert (auch ohne Server-Bewertungen), aber standardmäßig versteckt — ein
gemeinsames Skript (siehe unten) blendet die Zeile automatisch ein, sobald auf diesem
Gerät lokal gespeicherte Kundenrezensionen für dieses Produkt dazukommen (siehe
scripts/reviews.ts), egal auf welcher Seite die Karte gerade angezeigt wird. */}
<span
class="card-rating"
data-rating-slot
data-basis-schnitt={bewertungSchnitt ?? 0}
data-basis-anzahl={bewertungAnzahl}
data-count-one={t.product.reviewCount(1)}
data-count-other={t.product.reviewCount(2)}
style={bewertungSchnitt === null ? "display:none;" : undefined}
>
{bewertungSchnitt !== null && (
<HeartRating bewertung={bewertungSchnitt} size={13} anzahlLabel={`(${bewertungAnzahl})`} />
)}
</span>
<p class="small">{desc.slice(0, 78)}{desc.length > 78 ? "…" : ""}</p>
<div class="price-row">
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
@@ -146,6 +160,39 @@ const desc = product.beschreibung[lang];
}, true);
</script>
<script>
// Bewertungszeile auf JEDER Produktkarte der Seite (Shop-Grid, Kategorie-Seiten, Startseite,
// "Das könnte dir auch gefallen" ...) live mit den lokal auf diesem Gerät gespeicherten
// Kundenrezensionen zusammenführen (siehe scripts/reviews.ts) — Astro rendert die Karte selbst
// nur mit dem Bau-Zeit-Stand (VanVans Admin-Daten), neue Rezensionen von Kund:innen kommen erst
// im Browser dazu. Ein einziges, delegiertes Skript für alle Karten (Astro dedupliziert dieses
// <script> automatisch, egal wie oft ProductCard auf der Seite eingebunden wird).
import { heartsHtml } from "../scripts/hearts";
import { lokaleBewertungenFuer } from "../scripts/reviews";
function reviewCountLabel(slot: HTMLElement, n: number): string {
if (n === 1) return slot.dataset.countOne!;
return slot.dataset.countOther!.replace(/^\d+/, String(n));
}
document.querySelectorAll<HTMLElement>("[data-rating-slot]").forEach((slot) => {
const card = slot.closest<HTMLElement>(".product-card");
const slug = card?.dataset.slug;
if (!slug) return;
const lokale = lokaleBewertungenFuer(slug);
if (lokale.length === 0) return; // Bau-Zeit-Stand reicht bereits, nichts zu ergänzen
const basisSchnitt = Number(slot.dataset.basisSchnitt || 0);
const basisAnzahl = Number(slot.dataset.basisAnzahl || 0);
const summe = basisSchnitt * basisAnzahl + lokale.reduce((s, r) => s + r.bewertung, 0);
const anzahl = basisAnzahl + lokale.length;
const schnitt = summe / anzahl;
slot.innerHTML = heartsHtml(schnitt, 13, { anzahlLabel: `(${anzahl})` });
slot.style.display = "";
});
</script>
<style>
.product-card { display: flex; flex-direction: column; gap: 0.6rem; padding: 1rem; }
.product-media { position: relative; isolation: isolate; touch-action: pan-y; }