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>}
+42
View File
@@ -11,6 +11,18 @@ export type Badge = "neu" | "bestseller" | "sale" | "handgemacht";
type LocalizedText = Record<Locale, string>;
// Eine einzelne Kundenrezension zu einem Produkt — von VanVan im Adminbereich gepflegt (echte
// Kunden-Rückmeldungen, KEINE automatisch erzeugten/erfundenen Bewertungen: Fake-Bewertungen sind
// in Deutschland/der EU seit der Omnibus-Richtlinie ausdrücklich verboten). Deshalb startet jedes
// Produkt ohne Bewertungen — die Anzeige blendet sich überall automatisch ein, sobald die erste
// echte Rezension eingetragen wird, und bleibt sonst dezent (kein "0 Bewertungen"-Ballast).
export interface Bewertung {
name: string;
bewertung: number; // 15 (Herzen statt Sterne, siehe HeartRating.astro)
text?: LocalizedText;
datum?: string; // ISO-Datum, z.B. "2026-08-03"
}
export interface Product {
slug: string;
name: LocalizedText;
@@ -47,6 +59,7 @@ export interface Product {
bis?: string; // ISO-Datum, z.B. "2026-12-31" — Rabatt gilt nur bis einschließlich diesem Tag
mengenrabatt?: { abMenge: number; prozent: number }[];
};
bewertungen?: Bewertung[];
}
const modules = import.meta.glob("/src/content/products/*.json", { eager: true }) as Record<
@@ -73,3 +86,32 @@ export function productsByUnterkategorie(kategorie: string, unterkategorie: stri
export function saleProducts(): Product[] {
return products.filter((p) => p.badges.includes("sale") || p.preisAlt);
}
// Rezensions-Helfer — überall genutzt, wo eine Herzen-Bewertung angezeigt wird (Produktkarte,
// Produktseite, Shop-Filter/Sortierung, Startseiten-Kundenstimmen), damit die Rechenlogik nur an
// einer Stelle steht.
export function durchschnittsbewertung(product: Product): number | null {
const b = product.bewertungen;
if (!b || b.length === 0) return null;
return b.reduce((sum, r) => sum + r.bewertung, 0) / b.length;
}
export function anzahlBewertungen(product: Product): number {
return product.bewertungen?.length ?? 0;
}
// Alle Rezensionen über alle Produkte hinweg, neueste zuerst — Grundlage für die
// Kundenstimmen-Sektion auf der Startseite.
export function alleBewertungen(): (Bewertung & { produktSlug: string; produktName: LocalizedText })[] {
return products
.flatMap((p) => (p.bewertungen ?? []).map((b) => ({ ...b, produktSlug: p.slug, produktName: p.name })))
.sort((a, b) => (b.datum ?? "").localeCompare(a.datum ?? ""));
}
// Gesamt-Durchschnitt über alle Produkte hinweg — für die Kundenstimmen-Sektion auf der
// Startseite (erscheint erst automatisch, sobald mindestens eine echte Bewertung existiert).
export function gesamtDurchschnitt(): number | null {
const alle = alleBewertungen();
if (alle.length === 0) return null;
return alle.reduce((sum, r) => sum + r.bewertung, 0) / alle.length;
}
+36 -4
View File
@@ -36,16 +36,24 @@ export const ui = {
recommendations: "Das könnte dir auch gefallen",
todoFields: "GPSR-Hinweise, Warnhinweise, Hersteller/Herstelleranschrift und Produktvideos werden ergänzt, sobald echte Produktdaten von VanVan vorliegen (Phase 2, Adminbereich).",
photoNote: "echte Fotos ergänzt VanVan später",
reviewsTitle: "Kundenrezensionen", noReviews: "Noch keine Bewertungen für dieses Produkt — sei die/der Erste!",
reviewCount: (n: number) => `${n} Bewertung${n === 1 ? "" : "en"}`,
},
shop: {
title: "Alle Produkte", lead: "Stöbere durch alle handgemachten Stücke filtere und sortiere nach deinen Wünschen.",
filters: "Filter", allCategories: "Alle Kategorien", priceUpTo: "Preis bis", priceUpToValue: (v: number) => `bis ${v}`,
onlyAvailable: "Nur verfügbare Artikel", filterNote: "Material- und Farbfilter folgen, sobald mehr Produkte im Sortiment sind.",
sortNew: "Neu", sortBestseller: "Bestseller", sortPriceAsc: "Preis aufsteigend", sortPriceDesc: "Preis absteigend",
sortNew: "Neu", sortBestseller: "Bestseller", sortPriceAsc: "Preis aufsteigend", sortPriceDesc: "Preis absteigend", sortRating: "Beste Bewertung",
resultCount: (n: number) => `${n} Produkt${n === 1 ? "" : "e"}`, noResults: "Keine Produkte gefunden bitte Filter anpassen.",
breadcrumbShop: "Shop", categoryEmpty: "In dieser Kategorie sind aktuell noch keine Produkte hinterlegt schau bald wieder vorbei.",
categoryLabel: "Kategorie", productSingular: "Produkt", productPlural: "Produkte", sortLabel: "Sortierung", subcategoriesLabel: "Unterkategorien", mainCategoryLabel: "Hauptkategorie", subcategoryEmpty: "Für diesen Bereich stellt VanVan bald die ersten Produkte ein schau bald wieder vorbei.", moreAbout: "Mehr über", moreVariants: "Weitere Modelle",
moreAboutCharacterLead: "Wenn du mehr über mich erfahren möchtest, dann klicke",
minRatingLabel: "Mindestbewertung", minRatingAny: "Alle Bewertungen",
},
reviews: {
heading: "Was unsere Kund:innen sagen", lead: "Echte Rückmeldungen zu unseren handgemachten Stücken.",
basedOn: (n: number) => `basierend auf ${n} Bewertung${n === 1 ? "" : "en"}`,
toProduct: "Zum Produkt →",
},
sale: {
eyebrow: "% Sale", title: "Aktuelle Angebote", lead: "Zeitlich begrenzte Rabatte auf ausgewählte handgemachte Stücke.",
@@ -226,16 +234,24 @@ export const ui = {
recommendations: "You might also like",
todoFields: "GPSR notices, warnings, manufacturer details and product videos will be added once VanVan provides real product data (Phase 2, admin area).",
photoNote: "real photos will be added by VanVan later",
reviewsTitle: "Customer reviews", noReviews: "No reviews yet for this product — be the first!",
reviewCount: (n: number) => `${n} review${n === 1 ? "" : "s"}`,
},
shop: {
title: "All products", lead: "Browse all handmade pieces filter and sort to your liking.",
filters: "Filters", allCategories: "All categories", priceUpTo: "Price up to", priceUpToValue: (v: number) => `up to €${v}`,
onlyAvailable: "In-stock items only", filterNote: "Material and color filters will follow once more products are in the shop.",
sortNew: "New", sortBestseller: "Bestseller", sortPriceAsc: "Price: low to high", sortPriceDesc: "Price: high to low",
sortNew: "New", sortBestseller: "Bestseller", sortPriceAsc: "Price: low to high", sortPriceDesc: "Price: high to low", sortRating: "Top rated",
resultCount: (n: number) => `${n} product${n === 1 ? "" : "s"}`, noResults: "No products found please adjust your filters.",
breadcrumbShop: "Shop", categoryEmpty: "No products in this category yet check back soon.",
categoryLabel: "Category", productSingular: "product", productPlural: "products", sortLabel: "Sort by", subcategoriesLabel: "Subcategories", mainCategoryLabel: "Main category", subcategoryEmpty: "VanVan will be adding the first products here soon check back shortly.", moreAbout: "More about", moreVariants: "More designs",
moreAboutCharacterLead: "If you'd like to learn more about me, click",
minRatingLabel: "Minimum rating", minRatingAny: "All ratings",
},
reviews: {
heading: "What our customers say", lead: "Real feedback on our handmade pieces.",
basedOn: (n: number) => `based on ${n} review${n === 1 ? "" : "s"}`,
toProduct: "View product →",
},
sale: {
eyebrow: "% Sale", title: "Current offers", lead: "Time-limited discounts on selected handmade pieces.",
@@ -416,16 +432,24 @@ export const ui = {
recommendations: "Das chönnt dir au gfalle",
todoFields: "GPSR-Hinwys, Warnhinwys, Hersteller/Herstelleradrässe und Produktvideo werded ergänzt, sobald echti Produktdate vo VanVan da sind (Phase 2, Adminbereich).",
photoNote: "echti Fotis macht VanVan später drby",
reviewsTitle: "Kundebewärtige", noReviews: "No kei Bewärtige für das Produkt — sei du dr Erschti!",
reviewCount: (n: number) => `${n} Bewärtig${n === 1 ? "" : "e"}`,
},
shop: {
title: "Alli Produkt", lead: "Lueg dr alli vo Hang gmachte Sächeli a filtere und sortiere, wie's dir passt.",
filters: "Filter", allCategories: "Alli Kategorie", priceUpTo: "Pris bis", priceUpToValue: (v: number) => `bis ${v}`,
onlyAvailable: "Nur wo grad a Lager isch", filterNote: "Material- und Farbfilter chöme, sobald meh Produkt im Sortiment sind.",
sortNew: "Neu", sortBestseller: "Bestseller", sortPriceAsc: "Pris ufsteigend", sortPriceDesc: "Pris absteigend",
sortNew: "Neu", sortBestseller: "Bestseller", sortPriceAsc: "Pris ufsteigend", sortPriceDesc: "Pris absteigend", sortRating: "Beschti Bewärtig",
resultCount: (n: number) => `${n} Produkt`, noResults: "Kei Produkt gfunde bitte Filter apasse.",
breadcrumbShop: "Shop", categoryEmpty: "In däre Kategorie sind grad no kei Produkt hinterlegt lueg spöter nomal verbi.",
categoryLabel: "Kategorie", productSingular: "Produkt", productPlural: "Produkt", sortLabel: "Sortiere", subcategoriesLabel: "Unterkategorie", mainCategoryLabel: "Hauptkategorie", subcategoryEmpty: "Für das da stellt VanVan bald die erschte Produkt i lueg bald wieder verbi.", moreAbout: "Meh über", moreVariants: "Witeri Modell",
moreAboutCharacterLead: "Wenn du meh über mich erfahre witsch, dänn klick",
minRatingLabel: "Mindestbewärtig", minRatingAny: "Alli Bewärtige",
},
reviews: {
heading: "Das säge eusi Kunde", lead: "Echti Rückmäldige zu eusne vo Hang gmachte Sächeli.",
basedOn: (n: number) => `basierend uf ${n} Bewärtig${n === 1 ? "" : "e"}`,
toProduct: "Zum Produkt →",
},
sale: {
eyebrow: "% Aktion", title: "Aktuelli Aktion", lead: "Zytlich begrenzti Rabatt uf uswählti, vo Hang gmachti Sächeli.",
@@ -606,16 +630,24 @@ export const ui = {
recommendations: "Cela pourrait aussi vous plaire",
todoFields: "Les mentions GPSR, avertissements, coordonnées du fabricant et vidéos produit seront ajoutés dès que VanVan fournira les données réelles (phase 2, espace admin).",
photoNote: "VanVan ajoutera de vraies photos plus tard",
reviewsTitle: "Avis clients", noReviews: "Pas encore d'avis pour ce produit — soyez le/la premier(ère) !",
reviewCount: (n: number) => `${n} avis`,
},
shop: {
title: "Tous les produits", lead: "Parcourez toutes les pièces faites main filtrez et triez selon vos envies.",
filters: "Filtres", allCategories: "Toutes les catégories", priceUpTo: "Prix jusqu'à", priceUpToValue: (v: number) => `jusqu'à ${v}`,
onlyAvailable: "Articles disponibles uniquement", filterNote: "Les filtres matière et couleur arriveront dès que l'assortiment sera plus large.",
sortNew: "Nouveautés", sortBestseller: "Best-sellers", sortPriceAsc: "Prix croissant", sortPriceDesc: "Prix décroissant",
sortNew: "Nouveautés", sortBestseller: "Best-sellers", sortPriceAsc: "Prix croissant", sortPriceDesc: "Prix décroissant", sortRating: "Mieux notés",
resultCount: (n: number) => `${n} produit${n === 1 ? "" : "s"}`, noResults: "Aucun produit trouvé veuillez ajuster les filtres.",
breadcrumbShop: "Boutique", categoryEmpty: "Aucun produit dans cette catégorie pour le moment repassez bientôt.",
categoryLabel: "Catégorie", productSingular: "produit", productPlural: "produits", sortLabel: "Trier par", subcategoriesLabel: "Sous-catégories", mainCategoryLabel: "Catégorie principale", subcategoryEmpty: "VanVan ajoutera bientôt les premiers produits ici repassez bientôt.", moreAbout: "En savoir plus sur", moreVariants: "Autres modèles",
moreAboutCharacterLead: "Si tu veux en savoir plus sur moi, clique",
minRatingLabel: "Note minimale", minRatingAny: "Toutes les notes",
},
reviews: {
heading: "Ce que disent nos client(e)s", lead: "De vrais retours sur nos pièces faites main.",
basedOn: (n: number) => `basé sur ${n} avis`,
toProduct: "Voir le produit →",
},
sale: {
eyebrow: "% Soldes", title: "Offres actuelles", lead: "Réductions limitées dans le temps sur une sélection de pièces faites main.",
+31 -1
View File
@@ -1,15 +1,20 @@
---
import Layout from "../../layouts/Layout.astro";
import ProductCard from "../../components/ProductCard.astro";
import HeartRating from "../../components/HeartRating.astro";
import { categories } from "../../data/categories";
import { products } from "../../data/products";
import { products, alleBewertungen, gesamtDurchschnitt } from "../../data/products";
import { useTranslations } from "../../i18n/ui";
import type { Locale } from "../../i18n/config";
const lang: Locale = "ch";
const t = useTranslations(lang);
const neuheiten = products.filter((p) => p.badges.includes("neu")).slice(0, 4);
const bestseller = products.filter((p) => p.badges.includes("bestseller")).slice(0, 4);
const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).slice(0, 4);
const topBewertungen = alleBewertungen().slice(0, 3);
const bewertungSchnittGesamt = gesamtDurchschnitt();
---
<Layout title="Startsyte" lang={lang} path="/">
<section class="hero">
@@ -84,6 +89,31 @@ const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).sli
</section>
)}
{topBewertungen.length > 0 && bewertungSchnittGesamt !== null && (
<section class="section-tight">
<div class="container">
<span class="eyebrow">🩵 {t.reviews.heading}</span>
<h2>{t.reviews.heading}</h2>
<p class="lead">{t.reviews.lead}</p>
<div class="review-summary-row">
<HeartRating bewertung={bewertungSchnittGesamt} size={24} showValue={true} anzahlLabel={t.reviews.basedOn(alleBewertungen().length)} />
</div>
<div class="grid grid-3">
{topBewertungen.map((r) => (
<div class="card review-card">
<div class="review-card-head">
<span class="review-name">{r.name}</span>
<HeartRating bewertung={r.bewertung} size={14} />
</div>
{r.text && <p class="small">{r.text[lang]}</p>}
<a class="small" href={`/ch/produkt/${r.produktSlug}/`}>{t.reviews.toProduct} {r.produktName[lang]}</a>
</div>
))}
</div>
</div>
</section>
)}
<section class="section">
<div class="container grid grid-2 creator-section">
<div class="portrait-frame creator-photo">
+36 -1
View File
@@ -1,7 +1,8 @@
---
import Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.astro";
import { products, getProduct, productsByCategory } from "../../../data/products";
import HeartRating from "../../../components/HeartRating.astro";
import { products, getProduct, productsByCategory, durchschnittsbewertung, anzahlBewertungen } from "../../../data/products";
import { getCategory } from "../../../data/categories";
import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui";
@@ -27,6 +28,8 @@ const finalPrice = effektiverPreis(product);
const discountBadge = rabattLabel(product);
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
const mengenrabatt = product.rabatt?.mengenrabatt ?? [];
const bewertungSchnitt = durchschnittsbewertung(product);
const bewertungAnzahl = anzahlBewertungen(product);
const felder: [string, string | undefined][] = [
[t.product.articleNo, product.artikelnummer],
@@ -71,6 +74,11 @@ const felder: [string, string | undefined][] = [
{product.bestand === 0 && <span class="badge badge-sold-out">{t.badge.ausverkauft}</span>}
</div>
<h1>{name}</h1>
{bewertungSchnitt !== null && (
<a href="#bewertungen" class="review-summary-link">
<HeartRating bewertung={bewertungSchnitt} size={16} showValue={true} anzahlLabel={t.product.reviewCount(bewertungAnzahl)} />
</a>
)}
<div class="price-row-detail">
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
{showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>}
@@ -123,6 +131,33 @@ const felder: [string, string | undefined][] = [
</div>
</section>
<section class="section-tight" id="bewertungen">
<div class="container">
<h2>{t.product.reviewsTitle}</h2>
{bewertungSchnitt !== null ? (
<>
<div class="review-summary-row">
<HeartRating bewertung={bewertungSchnitt} size={26} showValue={true} anzahlLabel={t.product.reviewCount(bewertungAnzahl)} />
</div>
<div class="review-list">
{product.bewertungen!.map((r) => (
<div class="card review-card">
<div class="review-card-head">
<span class="review-name">{r.name}</span>
<HeartRating bewertung={r.bewertung} size={14} />
{r.datum && <span class="review-date small">{r.datum}</span>}
</div>
{r.text && <p class="small">{r.text[lang]}</p>}
</div>
))}
</div>
</>
) : (
<p class="small">{t.product.noReviews}</p>
)}
</div>
</section>
{empfehlungen.length > 0 && (
<section class="section-tight">
<div class="container">
+19 -3
View File
@@ -2,7 +2,7 @@
import Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.astro";
import { categories, subcategoryChipLabel } from "../../../data/categories";
import { products } from "../../../data/products";
import { products, durchschnittsbewertung } from "../../../data/products";
import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui";
@@ -59,6 +59,16 @@ const t = useTranslations(lang);
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
<input id="f-verfuegbar" type="checkbox" />
</div>
<div class="filter-group">
<label for="f-bewertung">{t.shop.minRatingLabel}</label>
<select id="f-bewertung">
<option value="0">{t.shop.minRatingAny}</option>
<option value="4">🩵🩵🩵🩵 +</option>
<option value="3">🩵🩵🩵 +</option>
<option value="2">🩵🩵 +</option>
<option value="1">🩵 +</option>
</select>
</div>
<p class="small">{t.shop.filterNote}</p>
</aside>
@@ -68,6 +78,7 @@ const t = useTranslations(lang);
<select id="sort" aria-label={t.shop.sortLabel}>
<option value="neu">{t.shop.sortNew}</option>
<option value="bestseller">{t.shop.sortBestseller}</option>
<option value="bewertung">{t.shop.sortRating}</option>
<option value="preis-auf">{t.shop.sortPriceAsc}</option>
<option value="preis-ab">{t.shop.sortPriceDesc}</option>
</select>
@@ -82,6 +93,7 @@ const t = useTranslations(lang);
data-bestand={p.bestand}
data-neu={p.badges.includes("neu") ? 1 : 0}
data-bestseller={p.badges.includes("bestseller") ? 1 : 0}
data-bewertung={durchschnittsbewertung(p) ?? 0}
>
<ProductCard product={p} lang={lang} />
</div>
@@ -101,6 +113,7 @@ const t = useTranslations(lang);
const preisRange = document.getElementById("f-preis");
const preisValue = document.getElementById("f-preis-value");
const verfuegbarChk = document.getElementById("f-verfuegbar");
const bewertungSel = document.getElementById("f-bewertung");
const sortSel = document.getElementById("sort");
const resultCount = document.getElementById("result-count");
const emptyMsg = document.getElementById("empty-msg");
@@ -114,6 +127,7 @@ const t = useTranslations(lang);
const kat = kategorieSel.value;
const maxPreis = Number(preisRange.value);
const nurVerfuegbar = verfuegbarChk.checked;
const minBewertung = Number(bewertungSel.value);
preisValue.textContent = formatPrice(maxPreis);
let visible = 0;
@@ -125,7 +139,8 @@ const t = useTranslations(lang);
: slot.dataset.kategorie === kat;
const matchPreis = Number(slot.dataset.preis) <= maxPreis;
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0;
const show = matchKat && matchPreis && matchVerfuegbar;
const matchBewertung = Number(slot.dataset.bewertung) >= minBewertung;
const show = matchKat && matchPreis && matchVerfuegbar && matchBewertung;
slot.style.display = show ? "" : "none";
if (show) visible++;
});
@@ -138,13 +153,14 @@ const t = useTranslations(lang);
case "preis-auf": return Number(a.dataset.preis) - Number(b.dataset.preis);
case "preis-ab": return Number(b.dataset.preis) - Number(a.dataset.preis);
case "bestseller": return Number(b.dataset.bestseller) - Number(a.dataset.bestseller);
case "bewertung": return Number(b.dataset.bewertung) - Number(a.dataset.bewertung);
default: return Number(b.dataset.neu) - Number(a.dataset.neu);
}
});
sorted.forEach((el) => grid.appendChild(el));
}
[preisRange, verfuegbarChk, sortSel].forEach((el) => el.addEventListener("input", apply));
[preisRange, verfuegbarChk, bewertungSel, sortSel].forEach((el) => el.addEventListener("input", apply));
const catFilter = document.getElementById("cat-filter");
const catFilterBtn = document.getElementById("cat-filter-btn");
+31 -1
View File
@@ -1,15 +1,20 @@
---
import Layout from "../../layouts/Layout.astro";
import ProductCard from "../../components/ProductCard.astro";
import HeartRating from "../../components/HeartRating.astro";
import { categories } from "../../data/categories";
import { products } from "../../data/products";
import { products, alleBewertungen, gesamtDurchschnitt } from "../../data/products";
import { useTranslations } from "../../i18n/ui";
import type { Locale } from "../../i18n/config";
const lang: Locale = "en";
const t = useTranslations(lang);
const neuheiten = products.filter((p) => p.badges.includes("neu")).slice(0, 4);
const bestseller = products.filter((p) => p.badges.includes("bestseller")).slice(0, 4);
const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).slice(0, 4);
const topBewertungen = alleBewertungen().slice(0, 3);
const bewertungSchnittGesamt = gesamtDurchschnitt();
---
<Layout title="Home" lang={lang} path="/">
<section class="hero">
@@ -84,6 +89,31 @@ const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).sli
</section>
)}
{topBewertungen.length > 0 && bewertungSchnittGesamt !== null && (
<section class="section-tight">
<div class="container">
<span class="eyebrow">🩵 {t.reviews.heading}</span>
<h2>{t.reviews.heading}</h2>
<p class="lead">{t.reviews.lead}</p>
<div class="review-summary-row">
<HeartRating bewertung={bewertungSchnittGesamt} size={24} showValue={true} anzahlLabel={t.reviews.basedOn(alleBewertungen().length)} />
</div>
<div class="grid grid-3">
{topBewertungen.map((r) => (
<div class="card review-card">
<div class="review-card-head">
<span class="review-name">{r.name}</span>
<HeartRating bewertung={r.bewertung} size={14} />
</div>
{r.text && <p class="small">{r.text[lang]}</p>}
<a class="small" href={`/en/produkt/${r.produktSlug}/`}>{t.reviews.toProduct} {r.produktName[lang]}</a>
</div>
))}
</div>
</div>
</section>
)}
<section class="section">
<div class="container grid grid-2 creator-section">
<div class="portrait-frame creator-photo">
+36 -1
View File
@@ -1,7 +1,8 @@
---
import Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.astro";
import { products, getProduct, productsByCategory } from "../../../data/products";
import HeartRating from "../../../components/HeartRating.astro";
import { products, getProduct, productsByCategory, durchschnittsbewertung, anzahlBewertungen } from "../../../data/products";
import { getCategory } from "../../../data/categories";
import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui";
@@ -27,6 +28,8 @@ const finalPrice = effektiverPreis(product);
const discountBadge = rabattLabel(product);
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
const mengenrabatt = product.rabatt?.mengenrabatt ?? [];
const bewertungSchnitt = durchschnittsbewertung(product);
const bewertungAnzahl = anzahlBewertungen(product);
const felder: [string, string | undefined][] = [
[t.product.articleNo, product.artikelnummer],
@@ -71,6 +74,11 @@ const felder: [string, string | undefined][] = [
{product.bestand === 0 && <span class="badge badge-sold-out">{t.badge.ausverkauft}</span>}
</div>
<h1>{name}</h1>
{bewertungSchnitt !== null && (
<a href="#bewertungen" class="review-summary-link">
<HeartRating bewertung={bewertungSchnitt} size={16} showValue={true} anzahlLabel={t.product.reviewCount(bewertungAnzahl)} />
</a>
)}
<div class="price-row-detail">
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
{showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>}
@@ -123,6 +131,33 @@ const felder: [string, string | undefined][] = [
</div>
</section>
<section class="section-tight" id="bewertungen">
<div class="container">
<h2>{t.product.reviewsTitle}</h2>
{bewertungSchnitt !== null ? (
<>
<div class="review-summary-row">
<HeartRating bewertung={bewertungSchnitt} size={26} showValue={true} anzahlLabel={t.product.reviewCount(bewertungAnzahl)} />
</div>
<div class="review-list">
{product.bewertungen!.map((r) => (
<div class="card review-card">
<div class="review-card-head">
<span class="review-name">{r.name}</span>
<HeartRating bewertung={r.bewertung} size={14} />
{r.datum && <span class="review-date small">{r.datum}</span>}
</div>
{r.text && <p class="small">{r.text[lang]}</p>}
</div>
))}
</div>
</>
) : (
<p class="small">{t.product.noReviews}</p>
)}
</div>
</section>
{empfehlungen.length > 0 && (
<section class="section-tight">
<div class="container">
+19 -3
View File
@@ -2,7 +2,7 @@
import Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.astro";
import { categories, subcategoryChipLabel } from "../../../data/categories";
import { products } from "../../../data/products";
import { products, durchschnittsbewertung } from "../../../data/products";
import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui";
@@ -59,6 +59,16 @@ const t = useTranslations(lang);
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
<input id="f-verfuegbar" type="checkbox" />
</div>
<div class="filter-group">
<label for="f-bewertung">{t.shop.minRatingLabel}</label>
<select id="f-bewertung">
<option value="0">{t.shop.minRatingAny}</option>
<option value="4">🩵🩵🩵🩵 +</option>
<option value="3">🩵🩵🩵 +</option>
<option value="2">🩵🩵 +</option>
<option value="1">🩵 +</option>
</select>
</div>
<p class="small">{t.shop.filterNote}</p>
</aside>
@@ -68,6 +78,7 @@ const t = useTranslations(lang);
<select id="sort" aria-label={t.shop.sortLabel}>
<option value="neu">{t.shop.sortNew}</option>
<option value="bestseller">{t.shop.sortBestseller}</option>
<option value="bewertung">{t.shop.sortRating}</option>
<option value="preis-auf">{t.shop.sortPriceAsc}</option>
<option value="preis-ab">{t.shop.sortPriceDesc}</option>
</select>
@@ -82,6 +93,7 @@ const t = useTranslations(lang);
data-bestand={p.bestand}
data-neu={p.badges.includes("neu") ? 1 : 0}
data-bestseller={p.badges.includes("bestseller") ? 1 : 0}
data-bewertung={durchschnittsbewertung(p) ?? 0}
>
<ProductCard product={p} lang={lang} />
</div>
@@ -101,6 +113,7 @@ const t = useTranslations(lang);
const preisRange = document.getElementById("f-preis");
const preisValue = document.getElementById("f-preis-value");
const verfuegbarChk = document.getElementById("f-verfuegbar");
const bewertungSel = document.getElementById("f-bewertung");
const sortSel = document.getElementById("sort");
const resultCount = document.getElementById("result-count");
const emptyMsg = document.getElementById("empty-msg");
@@ -114,6 +127,7 @@ const t = useTranslations(lang);
const kat = kategorieSel.value;
const maxPreis = Number(preisRange.value);
const nurVerfuegbar = verfuegbarChk.checked;
const minBewertung = Number(bewertungSel.value);
preisValue.textContent = formatPrice(maxPreis);
let visible = 0;
@@ -125,7 +139,8 @@ const t = useTranslations(lang);
: slot.dataset.kategorie === kat;
const matchPreis = Number(slot.dataset.preis) <= maxPreis;
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0;
const show = matchKat && matchPreis && matchVerfuegbar;
const matchBewertung = Number(slot.dataset.bewertung) >= minBewertung;
const show = matchKat && matchPreis && matchVerfuegbar && matchBewertung;
slot.style.display = show ? "" : "none";
if (show) visible++;
});
@@ -138,13 +153,14 @@ const t = useTranslations(lang);
case "preis-auf": return Number(a.dataset.preis) - Number(b.dataset.preis);
case "preis-ab": return Number(b.dataset.preis) - Number(a.dataset.preis);
case "bestseller": return Number(b.dataset.bestseller) - Number(a.dataset.bestseller);
case "bewertung": return Number(b.dataset.bewertung) - Number(a.dataset.bewertung);
default: return Number(b.dataset.neu) - Number(a.dataset.neu);
}
});
sorted.forEach((el) => grid.appendChild(el));
}
[preisRange, verfuegbarChk, sortSel].forEach((el) => el.addEventListener("input", apply));
[preisRange, verfuegbarChk, bewertungSel, sortSel].forEach((el) => el.addEventListener("input", apply));
const catFilter = document.getElementById("cat-filter");
const catFilterBtn = document.getElementById("cat-filter-btn");
+31 -1
View File
@@ -1,15 +1,20 @@
---
import Layout from "../../layouts/Layout.astro";
import ProductCard from "../../components/ProductCard.astro";
import HeartRating from "../../components/HeartRating.astro";
import { categories } from "../../data/categories";
import { products } from "../../data/products";
import { products, alleBewertungen, gesamtDurchschnitt } from "../../data/products";
import { useTranslations } from "../../i18n/ui";
import type { Locale } from "../../i18n/config";
const lang: Locale = "fr";
const t = useTranslations(lang);
const neuheiten = products.filter((p) => p.badges.includes("neu")).slice(0, 4);
const bestseller = products.filter((p) => p.badges.includes("bestseller")).slice(0, 4);
const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).slice(0, 4);
const topBewertungen = alleBewertungen().slice(0, 3);
const bewertungSchnittGesamt = gesamtDurchschnitt();
---
<Layout title="Accueil" lang={lang} path="/">
<section class="hero">
@@ -84,6 +89,31 @@ const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).sli
</section>
)}
{topBewertungen.length > 0 && bewertungSchnittGesamt !== null && (
<section class="section-tight">
<div class="container">
<span class="eyebrow">🩵 {t.reviews.heading}</span>
<h2>{t.reviews.heading}</h2>
<p class="lead">{t.reviews.lead}</p>
<div class="review-summary-row">
<HeartRating bewertung={bewertungSchnittGesamt} size={24} showValue={true} anzahlLabel={t.reviews.basedOn(alleBewertungen().length)} />
</div>
<div class="grid grid-3">
{topBewertungen.map((r) => (
<div class="card review-card">
<div class="review-card-head">
<span class="review-name">{r.name}</span>
<HeartRating bewertung={r.bewertung} size={14} />
</div>
{r.text && <p class="small">{r.text[lang]}</p>}
<a class="small" href={`/fr/produkt/${r.produktSlug}/`}>{t.reviews.toProduct} {r.produktName[lang]}</a>
</div>
))}
</div>
</div>
</section>
)}
<section class="section">
<div class="container grid grid-2 creator-section">
<div class="portrait-frame creator-photo">
+36 -1
View File
@@ -1,7 +1,8 @@
---
import Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.astro";
import { products, getProduct, productsByCategory } from "../../../data/products";
import HeartRating from "../../../components/HeartRating.astro";
import { products, getProduct, productsByCategory, durchschnittsbewertung, anzahlBewertungen } from "../../../data/products";
import { getCategory } from "../../../data/categories";
import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui";
@@ -27,6 +28,8 @@ const finalPrice = effektiverPreis(product);
const discountBadge = rabattLabel(product);
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
const mengenrabatt = product.rabatt?.mengenrabatt ?? [];
const bewertungSchnitt = durchschnittsbewertung(product);
const bewertungAnzahl = anzahlBewertungen(product);
const felder: [string, string | undefined][] = [
[t.product.articleNo, product.artikelnummer],
@@ -71,6 +74,11 @@ const felder: [string, string | undefined][] = [
{product.bestand === 0 && <span class="badge badge-sold-out">{t.badge.ausverkauft}</span>}
</div>
<h1>{name}</h1>
{bewertungSchnitt !== null && (
<a href="#bewertungen" class="review-summary-link">
<HeartRating bewertung={bewertungSchnitt} size={16} showValue={true} anzahlLabel={t.product.reviewCount(bewertungAnzahl)} />
</a>
)}
<div class="price-row-detail">
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
{showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>}
@@ -123,6 +131,33 @@ const felder: [string, string | undefined][] = [
</div>
</section>
<section class="section-tight" id="bewertungen">
<div class="container">
<h2>{t.product.reviewsTitle}</h2>
{bewertungSchnitt !== null ? (
<>
<div class="review-summary-row">
<HeartRating bewertung={bewertungSchnitt} size={26} showValue={true} anzahlLabel={t.product.reviewCount(bewertungAnzahl)} />
</div>
<div class="review-list">
{product.bewertungen!.map((r) => (
<div class="card review-card">
<div class="review-card-head">
<span class="review-name">{r.name}</span>
<HeartRating bewertung={r.bewertung} size={14} />
{r.datum && <span class="review-date small">{r.datum}</span>}
</div>
{r.text && <p class="small">{r.text[lang]}</p>}
</div>
))}
</div>
</>
) : (
<p class="small">{t.product.noReviews}</p>
)}
</div>
</section>
{empfehlungen.length > 0 && (
<section class="section-tight">
<div class="container">
+19 -3
View File
@@ -2,7 +2,7 @@
import Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.astro";
import { categories, subcategoryChipLabel } from "../../../data/categories";
import { products } from "../../../data/products";
import { products, durchschnittsbewertung } from "../../../data/products";
import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui";
@@ -59,6 +59,16 @@ const t = useTranslations(lang);
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
<input id="f-verfuegbar" type="checkbox" />
</div>
<div class="filter-group">
<label for="f-bewertung">{t.shop.minRatingLabel}</label>
<select id="f-bewertung">
<option value="0">{t.shop.minRatingAny}</option>
<option value="4">🩵🩵🩵🩵 +</option>
<option value="3">🩵🩵🩵 +</option>
<option value="2">🩵🩵 +</option>
<option value="1">🩵 +</option>
</select>
</div>
<p class="small">{t.shop.filterNote}</p>
</aside>
@@ -68,6 +78,7 @@ const t = useTranslations(lang);
<select id="sort" aria-label={t.shop.sortLabel}>
<option value="neu">{t.shop.sortNew}</option>
<option value="bestseller">{t.shop.sortBestseller}</option>
<option value="bewertung">{t.shop.sortRating}</option>
<option value="preis-auf">{t.shop.sortPriceAsc}</option>
<option value="preis-ab">{t.shop.sortPriceDesc}</option>
</select>
@@ -82,6 +93,7 @@ const t = useTranslations(lang);
data-bestand={p.bestand}
data-neu={p.badges.includes("neu") ? 1 : 0}
data-bestseller={p.badges.includes("bestseller") ? 1 : 0}
data-bewertung={durchschnittsbewertung(p) ?? 0}
>
<ProductCard product={p} lang={lang} />
</div>
@@ -101,6 +113,7 @@ const t = useTranslations(lang);
const preisRange = document.getElementById("f-preis");
const preisValue = document.getElementById("f-preis-value");
const verfuegbarChk = document.getElementById("f-verfuegbar");
const bewertungSel = document.getElementById("f-bewertung");
const sortSel = document.getElementById("sort");
const resultCount = document.getElementById("result-count");
const emptyMsg = document.getElementById("empty-msg");
@@ -114,6 +127,7 @@ const t = useTranslations(lang);
const kat = kategorieSel.value;
const maxPreis = Number(preisRange.value);
const nurVerfuegbar = verfuegbarChk.checked;
const minBewertung = Number(bewertungSel.value);
preisValue.textContent = formatPrice(maxPreis);
let visible = 0;
@@ -125,7 +139,8 @@ const t = useTranslations(lang);
: slot.dataset.kategorie === kat;
const matchPreis = Number(slot.dataset.preis) <= maxPreis;
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0;
const show = matchKat && matchPreis && matchVerfuegbar;
const matchBewertung = Number(slot.dataset.bewertung) >= minBewertung;
const show = matchKat && matchPreis && matchVerfuegbar && matchBewertung;
slot.style.display = show ? "" : "none";
if (show) visible++;
});
@@ -138,13 +153,14 @@ const t = useTranslations(lang);
case "preis-auf": return Number(a.dataset.preis) - Number(b.dataset.preis);
case "preis-ab": return Number(b.dataset.preis) - Number(a.dataset.preis);
case "bestseller": return Number(b.dataset.bestseller) - Number(a.dataset.bestseller);
case "bewertung": return Number(b.dataset.bewertung) - Number(a.dataset.bewertung);
default: return Number(b.dataset.neu) - Number(a.dataset.neu);
}
});
sorted.forEach((el) => grid.appendChild(el));
}
[preisRange, verfuegbarChk, sortSel].forEach((el) => el.addEventListener("input", apply));
[preisRange, verfuegbarChk, bewertungSel, sortSel].forEach((el) => el.addEventListener("input", apply));
const catFilter = document.getElementById("cat-filter");
const catFilterBtn = document.getElementById("cat-filter-btn");
+34 -1
View File
@@ -1,15 +1,23 @@
---
import Layout from "../layouts/Layout.astro";
import ProductCard from "../components/ProductCard.astro";
import HeartRating from "../components/HeartRating.astro";
import { categories } from "../data/categories";
import { products } from "../data/products";
import { products, alleBewertungen, gesamtDurchschnitt } from "../data/products";
import { useTranslations } from "../i18n/ui";
import type { Locale } from "../i18n/config";
const lang: Locale = "de";
const t = useTranslations(lang);
const neuheiten = products.filter((p) => p.badges.includes("neu")).slice(0, 4);
const bestseller = products.filter((p) => p.badges.includes("bestseller")).slice(0, 4);
const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).slice(0, 4);
// Kundenstimmen-Sektion — erscheint erst automatisch, sobald VanVan im Adminbereich die erste
// ECHTE Rezension einträgt (siehe Bewertung-Feld bei den Produkten). Bewusst KEINE erfundenen
// Beispiel-Bewertungen im Code, da Fake-Bewertungen in Deutschland/der EU verboten sind.
const topBewertungen = alleBewertungen().slice(0, 3);
const bewertungSchnittGesamt = gesamtDurchschnitt();
---
<Layout title="Startseite" lang={lang} path="/">
<section class="hero">
@@ -84,6 +92,31 @@ const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).sli
</section>
)}
{topBewertungen.length > 0 && bewertungSchnittGesamt !== null && (
<section class="section-tight">
<div class="container">
<span class="eyebrow">🩵 {t.reviews.heading}</span>
<h2>{t.reviews.heading}</h2>
<p class="lead">{t.reviews.lead}</p>
<div class="review-summary-row">
<HeartRating bewertung={bewertungSchnittGesamt} size={24} showValue={true} anzahlLabel={t.reviews.basedOn(alleBewertungen().length)} />
</div>
<div class="grid grid-3">
{topBewertungen.map((r) => (
<div class="card review-card">
<div class="review-card-head">
<span class="review-name">{r.name}</span>
<HeartRating bewertung={r.bewertung} size={14} />
</div>
{r.text && <p class="small">{r.text[lang]}</p>}
<a class="small" href={`/produkt/${r.produktSlug}/`}>{t.reviews.toProduct} {r.produktName[lang]}</a>
</div>
))}
</div>
</div>
</section>
)}
<section class="section">
<div class="container grid grid-2 creator-section">
<div class="portrait-frame creator-photo">
+36 -1
View File
@@ -1,7 +1,8 @@
---
import Layout from "../../layouts/Layout.astro";
import ProductCard from "../../components/ProductCard.astro";
import { products, getProduct, productsByCategory } from "../../data/products";
import HeartRating from "../../components/HeartRating.astro";
import { products, getProduct, productsByCategory, durchschnittsbewertung, anzahlBewertungen } from "../../data/products";
import { getCategory } from "../../data/categories";
import type { Locale } from "../../i18n/config";
import { useTranslations } from "../../i18n/ui";
@@ -27,6 +28,8 @@ const finalPrice = effektiverPreis(product);
const discountBadge = rabattLabel(product);
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
const mengenrabatt = product.rabatt?.mengenrabatt ?? [];
const bewertungSchnitt = durchschnittsbewertung(product);
const bewertungAnzahl = anzahlBewertungen(product);
const felder: [string, string | undefined][] = [
[t.product.articleNo, product.artikelnummer],
@@ -71,6 +74,11 @@ const felder: [string, string | undefined][] = [
{product.bestand === 0 && <span class="badge badge-sold-out">{t.badge.ausverkauft}</span>}
</div>
<h1>{name}</h1>
{bewertungSchnitt !== null && (
<a href="#bewertungen" class="review-summary-link">
<HeartRating bewertung={bewertungSchnitt} size={16} showValue={true} anzahlLabel={t.product.reviewCount(bewertungAnzahl)} />
</a>
)}
<div class="price-row-detail">
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
{showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>}
@@ -123,6 +131,33 @@ const felder: [string, string | undefined][] = [
</div>
</section>
<section class="section-tight" id="bewertungen">
<div class="container">
<h2>{t.product.reviewsTitle}</h2>
{bewertungSchnitt !== null ? (
<>
<div class="review-summary-row">
<HeartRating bewertung={bewertungSchnitt} size={26} showValue={true} anzahlLabel={t.product.reviewCount(bewertungAnzahl)} />
</div>
<div class="review-list">
{product.bewertungen!.map((r) => (
<div class="card review-card">
<div class="review-card-head">
<span class="review-name">{r.name}</span>
<HeartRating bewertung={r.bewertung} size={14} />
{r.datum && <span class="review-date small">{r.datum}</span>}
</div>
{r.text && <p class="small">{r.text[lang]}</p>}
</div>
))}
</div>
</>
) : (
<p class="small">{t.product.noReviews}</p>
)}
</div>
</section>
{empfehlungen.length > 0 && (
<section class="section-tight">
<div class="container">
+19 -3
View File
@@ -2,7 +2,7 @@
import Layout from "../../layouts/Layout.astro";
import ProductCard from "../../components/ProductCard.astro";
import { categories, subcategoryChipLabel } from "../../data/categories";
import { products } from "../../data/products";
import { products, durchschnittsbewertung } from "../../data/products";
import type { Locale } from "../../i18n/config";
import { useTranslations } from "../../i18n/ui";
@@ -64,6 +64,16 @@ const t = useTranslations(lang);
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
<input id="f-verfuegbar" type="checkbox" />
</div>
<div class="filter-group">
<label for="f-bewertung">{t.shop.minRatingLabel}</label>
<select id="f-bewertung">
<option value="0">{t.shop.minRatingAny}</option>
<option value="4">🩵🩵🩵🩵 +</option>
<option value="3">🩵🩵🩵 +</option>
<option value="2">🩵🩵 +</option>
<option value="1">🩵 +</option>
</select>
</div>
<p class="small">{t.shop.filterNote}</p>
</aside>
@@ -73,6 +83,7 @@ const t = useTranslations(lang);
<select id="sort" aria-label={t.shop.sortLabel}>
<option value="neu">{t.shop.sortNew}</option>
<option value="bestseller">{t.shop.sortBestseller}</option>
<option value="bewertung">{t.shop.sortRating}</option>
<option value="preis-auf">{t.shop.sortPriceAsc}</option>
<option value="preis-ab">{t.shop.sortPriceDesc}</option>
</select>
@@ -87,6 +98,7 @@ const t = useTranslations(lang);
data-bestand={p.bestand}
data-neu={p.badges.includes("neu") ? 1 : 0}
data-bestseller={p.badges.includes("bestseller") ? 1 : 0}
data-bewertung={durchschnittsbewertung(p) ?? 0}
>
<ProductCard product={p} lang={lang} />
</div>
@@ -106,6 +118,7 @@ const t = useTranslations(lang);
const preisRange = document.getElementById("f-preis");
const preisValue = document.getElementById("f-preis-value");
const verfuegbarChk = document.getElementById("f-verfuegbar");
const bewertungSel = document.getElementById("f-bewertung");
const sortSel = document.getElementById("sort");
const resultCount = document.getElementById("result-count");
const emptyMsg = document.getElementById("empty-msg");
@@ -119,6 +132,7 @@ const t = useTranslations(lang);
const kat = kategorieSel.value;
const maxPreis = Number(preisRange.value);
const nurVerfuegbar = verfuegbarChk.checked;
const minBewertung = Number(bewertungSel.value);
preisValue.textContent = formatPrice(maxPreis);
let visible = 0;
@@ -130,7 +144,8 @@ const t = useTranslations(lang);
: slot.dataset.kategorie === kat;
const matchPreis = Number(slot.dataset.preis) <= maxPreis;
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0;
const show = matchKat && matchPreis && matchVerfuegbar;
const matchBewertung = Number(slot.dataset.bewertung) >= minBewertung;
const show = matchKat && matchPreis && matchVerfuegbar && matchBewertung;
slot.style.display = show ? "" : "none";
if (show) visible++;
});
@@ -143,13 +158,14 @@ const t = useTranslations(lang);
case "preis-auf": return Number(a.dataset.preis) - Number(b.dataset.preis);
case "preis-ab": return Number(b.dataset.preis) - Number(a.dataset.preis);
case "bestseller": return Number(b.dataset.bestseller) - Number(a.dataset.bestseller);
case "bewertung": return Number(b.dataset.bewertung) - Number(a.dataset.bewertung);
default: return Number(b.dataset.neu) - Number(a.dataset.neu);
}
});
sorted.forEach((el) => grid.appendChild(el));
}
[preisRange, verfuegbarChk, sortSel].forEach((el) => el.addEventListener("input", apply));
[preisRange, verfuegbarChk, bewertungSel, sortSel].forEach((el) => el.addEventListener("input", apply));
// Eigenes Kategorie-Dropdown (statt nativem <select>): Klick auf den Button öffnet/schließt
// die Liste, Klick auf eine Zeile setzt den Filter, aktualisiert die Anzeige-Beschriftung und
+17
View File
@@ -1125,6 +1125,23 @@ a:focus-visible, button:focus-visible {
}
.thumb.product-photo:hover { box-shadow: 0 0 0 3px rgba(10, 9, 16, 0.6), 0 0 0 4px var(--c-accent); }
.badge-row { display: flex; gap: 0.5rem; margin-bottom: 0.8rem; flex-wrap: wrap; }
/* Kundenrezensionen — Herzen in Lila-Babyblau statt Sterne (siehe HeartRating.astro), damit
Bewertungen sich erkennbar in die restliche Seite einfügen statt wie ein fremdes Bewertungs-
Widget zu wirken. */
.review-summary-link { display: inline-flex; text-decoration: none; margin: 0.3rem 0 0.9rem; }
.review-summary-link:hover .heart-value { color: var(--c-accent); }
.review-summary-row { margin-bottom: 1.6rem; }
.review-list { display: flex; flex-direction: column; gap: 1rem; }
.review-card { gap: 0.5rem; display: flex; flex-direction: column; }
.review-card-head { display: flex; align-items: center; gap: 0.8rem; flex-wrap: wrap; }
.review-name { font-weight: 700; }
.review-date { color: var(--c-text-muted); margin-left: auto; }
.review-card p { color: var(--c-text-muted); }
/* Bewertungszeile auf der Produktkarte — dicht unter dem Titel, kein zusätzlicher Abstand nötig,
da .product-card schon einen einheitlichen gap zwischen allen Kindern hat. */
.product-card .heart-rating { margin-top: -0.2rem; }
.price-row-detail { display: flex; align-items: baseline; gap: 0.8rem; margin-bottom: 0.2rem; flex-wrap: wrap; }
.price-detail { font-size: 1.8rem; font-weight: 700; color: var(--c-accent); }
/* Durchgestrichener alter Preis bei Rabatt — global, damit das auch auf der Produktseite gilt