Neu: Kundenrezensionen mit Herzen in Lila-Babyblau, ueberall im Shop integriert

Neue HeartRating-Komponente (Herzen statt Sterne, Lila-Babyblau-Verlauf wie
der Rest der Markenoptik) + komplettes Rezensions-System:

- Produktkarten: Bewertungszeile (Herzen + Anzahl) unter dem Titel, nur wenn
  Bewertungen vorhanden sind.
- Produktseite: volle "Kundenrezensionen"-Sektion mit Durchschnitt und
  einzelnen Rezensionen (Name, Herzen, Datum, Text) bzw. "Noch keine
  Bewertungen"-Hinweis.
- Shop-Filter: neuer "Mindestbewertung"-Filter im Filter-Sidebar + neue
  Sortierung "Beste Bewertung".
- Startseite: "Was unsere Kund:innen sagen"-Sektion mit Gesamtbewertung und
  den 3 neuesten Rezensionen - erscheint automatisch, sobald die erste echte
  Bewertung existiert.
- Adminbereich: neues Feld "Bewertungen" pro Produkt (Name/Herzen/Text/Datum),
  mit deutlichem Hinweis, dass hier nur echte Kundenrueckmeldungen eingetragen
  werden duerfen - erfundene Bewertungen sind in Deutschland/der EU gesetzlich
  verboten (UWG). Deshalb bewusst OHNE Beispiel-Bewertungen im Code: alle
  Bereiche starten leer und blenden sich automatisch ein, sobald VanVan die
  erste echte Rezension eintraegt.

Mit temporaeren Testdaten end-to-end durchgetestet (Herzen-Darstellung,
Rezensions-Section, Filter, Startseiten-Sektion) und vor dem Commit wieder
aus den echten Produktdaten entfernt. Gilt fuer alle 4 Sprachen.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
qcigano
2026-08-03 03:15:00 +02:00
co-authored by Claude Sonnet 5
parent 1add9a42a1
commit b384938b92
18 changed files with 534 additions and 24 deletions
+66
View File
@@ -0,0 +1,66 @@
---
// Herzen statt Sterne für Kundenbewertungen — passend zum Marken-Herz 🩵 und dem
// Lila-Babyblau-Verlauf, der schon überall auf der Seite als Signature-Look verwendet wird
// (Verlaufsränder der Karten, Sprach-Umschalter, Story-Boxen). Volle Herzen: Verlaufsfüllung,
// halbe Herzen: Verlaufsfüllung bei halber Deckkraft, leere Herzen: nur Umriss.
interface Props {
bewertung: number; // 05, Nachkommastellen erlaubt (z.B. 4.5)
size?: number;
showValue?: boolean;
anzahl?: number;
anzahlLabel?: string; // z.B. "(12)" oder "12 Bewertungen" — fertig formatiert übergeben
}
const { bewertung, size = 16, showValue = false, anzahl, anzahlLabel } = Astro.props;
const gradId = `hr-${Math.random().toString(36).slice(2, 9)}`;
const herzen = Array.from({ length: 5 }, (_, i) => {
const diff = bewertung - i;
if (diff >= 0.75) return "full";
if (diff >= 0.25) return "half";
return "empty";
});
const HERZ_PFAD =
"M12 21s-7.5-4.9-10.2-9.3C.2 8.7 1.4 5 5 4.1c2.1-.5 4 1.4 5 2.9.6-.9 1.3-1.7 2.2-2.2 1.9-1.1 4.4-.7 5.8 1 1.9 2.3 1.5 5.6-.6 8.6C14.9 17.5 12 21 12 21z";
---
<span class="heart-rating" role="img" aria-label={`${bewertung.toFixed(1)} von 5 Herzen${anzahlLabel ? `, ${anzahlLabel}` : ""}`}>
<svg width="0" height="0" style="position:absolute;" aria-hidden="true">
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="var(--c-purple-glow)"></stop>
<stop offset="100%" stop-color="var(--c-accent)"></stop>
</linearGradient>
</defs>
</svg>
<span class="heart-icons" aria-hidden="true">
{herzen.map((zustand) => (
<svg
class={`heart-icon heart-${zustand}`}
width={size}
height={size}
viewBox="0 0 24 24"
>
<path
d={HERZ_PFAD}
fill={zustand === "empty" ? "none" : `url(#${gradId})`}
fill-opacity={zustand === "half" ? "0.5" : "1"}
stroke={zustand === "empty" ? "var(--c-text-muted)" : `url(#${gradId})`}
stroke-width="1.4"
stroke-linejoin="round"
></path>
</svg>
))}
</span>
{showValue && <span class="heart-value">{bewertung.toFixed(1)}</span>}
{anzahlLabel && <span class="heart-count small">{anzahlLabel}</span>}
</span>
<style>
.heart-rating { display: inline-flex; align-items: center; gap: 0.4em; }
.heart-icons { display: inline-flex; gap: 0.15em; }
.heart-icon { display: block; flex-shrink: 0; }
.heart-value { font-weight: 700; color: var(--c-text); font-size: 0.92em; }
.heart-count { color: var(--c-text-muted); }
</style>
+7
View File
@@ -5,6 +5,8 @@ import { localePrefix } from "../i18n/config";
import { useTranslations } from "../i18n/ui";
import { formatPrice } from "../i18n/format";
import { effektiverPreis, rabattLabel, hatAktivenRabatt } from "../data/rabatt";
import { durchschnittsbewertung, anzahlBewertungen } from "../data/products";
import HeartRating from "./HeartRating.astro";
interface Props { product: Product; lang: Locale }
const { product, lang } = Astro.props;
@@ -12,6 +14,8 @@ const t = useTranslations(lang);
const finalPrice = effektiverPreis(product);
const discountBadge = rabattLabel(product);
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
const bewertungSchnitt = durchschnittsbewertung(product);
const bewertungAnzahl = anzahlBewertungen(product);
const badgeLabel: Record<string, string> = {
neu: t.badge.neu,
@@ -55,6 +59,9 @@ const desc = product.beschreibung[lang];
</div>
</div>
<h3>{name}</h3>
{bewertungSchnitt !== null && (
<HeartRating bewertung={bewertungSchnitt} size={13} anzahlLabel={`(${bewertungAnzahl})`} />
)}
<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>}