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
+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