diff --git a/public/admin/config.yml b/public/admin/config.yml index 200d028..243aed1 100644 --- a/public/admin/config.yml +++ b/public/admin/config.yml @@ -134,6 +134,25 @@ collections: - { label: Lieferumfang, name: lieferumfang, widget: string, required: false } - { label: Pflegehinweise, name: pflegehinweise, widget: string, required: false } - { label: Lieferzeit, name: lieferzeit, widget: string, required: false } + - label: "🩵 Kundenrezensionen" + name: bewertungen + widget: list + required: false + summary: "{{fields.name}} — {{fields.bewertung}}/5" + hint: "WICHTIG: Hier nur echte Rückmeldungen deiner Kund:innen eintragen (z.B. per E-Mail/Nachricht erhalten) — erfundene Bewertungen sind in Deutschland und der EU gesetzlich verboten (Gesetz gegen unlauteren Wettbewerb). Ohne Eintrag hier bleibt der Bewertungsbereich beim Produkt einfach leer, das ist völlig normal für neue Produkte." + fields: + - { label: "Name (z.B. Vorname + Initiale des Nachnamens)", name: name, widget: string } + - { label: "Bewertung (1-5 Herzen)", name: bewertung, widget: number, value_type: int, min: 1, max: 5 } + - label: "Text der Bewertung (optional)" + name: text + widget: object + required: false + fields: + - { label: Deutsch, name: de, widget: text, required: false } + - { label: Englisch, name: en, widget: text, required: false } + - { label: "Schweizerdeutsch", name: ch, widget: text, required: false } + - { label: Französisch, name: fr, widget: text, required: false } + - { label: "Datum (optional)", name: datum, widget: datetime, format: "YYYY-MM-DD", date_format: "YYYY-MM-DD", time_format: false, required: false } - label: "Interner Code (URL) — nach Veröffentlichung nicht mehr ändern!" name: slug widget: string diff --git a/src/components/HeartRating.astro b/src/components/HeartRating.astro new file mode 100644 index 0000000..7e4c39a --- /dev/null +++ b/src/components/HeartRating.astro @@ -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; // 0–5, 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"; +--- + + + + + {showValue && {bewertung.toFixed(1)}} + {anzahlLabel && {anzahlLabel}} + + + diff --git a/src/components/ProductCard.astro b/src/components/ProductCard.astro index 0b6949d..092ad28 100644 --- a/src/components/ProductCard.astro +++ b/src/components/ProductCard.astro @@ -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 = { neu: t.badge.neu, @@ -55,6 +59,9 @@ const desc = product.beschreibung[lang];

{name}

+ {bewertungSchnitt !== null && ( + + )}

{desc.slice(0, 78)}{desc.length > 78 ? "…" : ""}

{product.preisAlt && {formatPrice(product.preisAlt, lang)}} diff --git a/src/data/products.ts b/src/data/products.ts index a429920..3fe98f7 100644 --- a/src/data/products.ts +++ b/src/data/products.ts @@ -11,6 +11,18 @@ export type Badge = "neu" | "bestseller" | "sale" | "handgemacht"; type LocalizedText = Record; +// 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; // 1–5 (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; +} diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts index dc1e9bf..7ae75ca 100644 --- a/src/i18n/ui.ts +++ b/src/i18n/ui.ts @@ -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.", diff --git a/src/pages/ch/index.astro b/src/pages/ch/index.astro index 8277ab3..8b8d81e 100644 --- a/src/pages/ch/index.astro +++ b/src/pages/ch/index.astro @@ -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(); ---
@@ -84,6 +89,31 @@ const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).sli
)} + {topBewertungen.length > 0 && bewertungSchnittGesamt !== null && ( +
+
+ 🩵 {t.reviews.heading} +

{t.reviews.heading}

+

{t.reviews.lead}

+
+ +
+
+ {topBewertungen.map((r) => ( +
+
+ {r.name} + +
+ {r.text &&

{r.text[lang]}

} + {t.reviews.toProduct} {r.produktName[lang]} +
+ ))} +
+
+
+ )} +
diff --git a/src/pages/ch/produkt/[slug].astro b/src/pages/ch/produkt/[slug].astro index dec077e..f344147 100644 --- a/src/pages/ch/produkt/[slug].astro +++ b/src/pages/ch/produkt/[slug].astro @@ -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 && {t.badge.ausverkauft}}

{name}

+ {bewertungSchnitt !== null && ( + + + + )}
{product.preisAlt && {formatPrice(product.preisAlt, lang)}} {showOldAsPreis && {formatPrice(product.preis, lang)}} @@ -123,6 +131,33 @@ const felder: [string, string | undefined][] = [
+
+
+

{t.product.reviewsTitle}

+ {bewertungSchnitt !== null ? ( + <> +
+ +
+
+ {product.bewertungen!.map((r) => ( +
+
+ {r.name} + + {r.datum && {r.datum}} +
+ {r.text &&

{r.text[lang]}

} +
+ ))} +
+ + ) : ( +

{t.product.noReviews}

+ )} +
+
+ {empfehlungen.length > 0 && (
diff --git a/src/pages/ch/shop/index.astro b/src/pages/ch/shop/index.astro index 8eb3969..d67c124 100644 --- a/src/pages/ch/shop/index.astro +++ b/src/pages/ch/shop/index.astro @@ -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);
+
+ + +

{t.shop.filterNote}

@@ -68,6 +78,7 @@ const t = useTranslations(lang); @@ -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} >
@@ -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"); diff --git a/src/pages/en/index.astro b/src/pages/en/index.astro index dd371b0..47583e9 100644 --- a/src/pages/en/index.astro +++ b/src/pages/en/index.astro @@ -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(); ---
@@ -84,6 +89,31 @@ const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).sli
)} + {topBewertungen.length > 0 && bewertungSchnittGesamt !== null && ( +
+
+ 🩵 {t.reviews.heading} +

{t.reviews.heading}

+

{t.reviews.lead}

+
+ +
+
+ {topBewertungen.map((r) => ( +
+
+ {r.name} + +
+ {r.text &&

{r.text[lang]}

} + {t.reviews.toProduct} {r.produktName[lang]} +
+ ))} +
+
+
+ )} +
diff --git a/src/pages/en/produkt/[slug].astro b/src/pages/en/produkt/[slug].astro index 9a12e78..67f8d64 100644 --- a/src/pages/en/produkt/[slug].astro +++ b/src/pages/en/produkt/[slug].astro @@ -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 && {t.badge.ausverkauft}}

{name}

+ {bewertungSchnitt !== null && ( + + + + )}
{product.preisAlt && {formatPrice(product.preisAlt, lang)}} {showOldAsPreis && {formatPrice(product.preis, lang)}} @@ -123,6 +131,33 @@ const felder: [string, string | undefined][] = [
+
+
+

{t.product.reviewsTitle}

+ {bewertungSchnitt !== null ? ( + <> +
+ +
+
+ {product.bewertungen!.map((r) => ( +
+
+ {r.name} + + {r.datum && {r.datum}} +
+ {r.text &&

{r.text[lang]}

} +
+ ))} +
+ + ) : ( +

{t.product.noReviews}

+ )} +
+
+ {empfehlungen.length > 0 && (
diff --git a/src/pages/en/shop/index.astro b/src/pages/en/shop/index.astro index c8a4db3..f664860 100644 --- a/src/pages/en/shop/index.astro +++ b/src/pages/en/shop/index.astro @@ -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);
+
+ + +

{t.shop.filterNote}

@@ -68,6 +78,7 @@ const t = useTranslations(lang); @@ -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} > @@ -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"); diff --git a/src/pages/fr/index.astro b/src/pages/fr/index.astro index 30f2ef5..eba405e 100644 --- a/src/pages/fr/index.astro +++ b/src/pages/fr/index.astro @@ -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(); ---
@@ -84,6 +89,31 @@ const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).sli
)} + {topBewertungen.length > 0 && bewertungSchnittGesamt !== null && ( +
+
+ 🩵 {t.reviews.heading} +

{t.reviews.heading}

+

{t.reviews.lead}

+
+ +
+
+ {topBewertungen.map((r) => ( +
+
+ {r.name} + +
+ {r.text &&

{r.text[lang]}

} + {t.reviews.toProduct} {r.produktName[lang]} +
+ ))} +
+
+
+ )} +
diff --git a/src/pages/fr/produkt/[slug].astro b/src/pages/fr/produkt/[slug].astro index 97f2bcb..dea1669 100644 --- a/src/pages/fr/produkt/[slug].astro +++ b/src/pages/fr/produkt/[slug].astro @@ -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 && {t.badge.ausverkauft}}

{name}

+ {bewertungSchnitt !== null && ( + + + + )}
{product.preisAlt && {formatPrice(product.preisAlt, lang)}} {showOldAsPreis && {formatPrice(product.preis, lang)}} @@ -123,6 +131,33 @@ const felder: [string, string | undefined][] = [
+
+
+

{t.product.reviewsTitle}

+ {bewertungSchnitt !== null ? ( + <> +
+ +
+
+ {product.bewertungen!.map((r) => ( +
+
+ {r.name} + + {r.datum && {r.datum}} +
+ {r.text &&

{r.text[lang]}

} +
+ ))} +
+ + ) : ( +

{t.product.noReviews}

+ )} +
+
+ {empfehlungen.length > 0 && (
diff --git a/src/pages/fr/shop/index.astro b/src/pages/fr/shop/index.astro index 4be4536..8c9c63e 100644 --- a/src/pages/fr/shop/index.astro +++ b/src/pages/fr/shop/index.astro @@ -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);
+
+ + +

{t.shop.filterNote}

@@ -68,6 +78,7 @@ const t = useTranslations(lang); @@ -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} > @@ -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"); diff --git a/src/pages/index.astro b/src/pages/index.astro index ea3eeed..f6a37a8 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -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(); ---
@@ -84,6 +92,31 @@ const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).sli
)} + {topBewertungen.length > 0 && bewertungSchnittGesamt !== null && ( +
+
+ 🩵 {t.reviews.heading} +

{t.reviews.heading}

+

{t.reviews.lead}

+
+ +
+
+ {topBewertungen.map((r) => ( +
+
+ {r.name} + +
+ {r.text &&

{r.text[lang]}

} + {t.reviews.toProduct} {r.produktName[lang]} +
+ ))} +
+
+
+ )} +
diff --git a/src/pages/produkt/[slug].astro b/src/pages/produkt/[slug].astro index 9646abf..6bbebb1 100644 --- a/src/pages/produkt/[slug].astro +++ b/src/pages/produkt/[slug].astro @@ -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 && {t.badge.ausverkauft}}

{name}

+ {bewertungSchnitt !== null && ( + + + + )}
{product.preisAlt && {formatPrice(product.preisAlt, lang)}} {showOldAsPreis && {formatPrice(product.preis, lang)}} @@ -123,6 +131,33 @@ const felder: [string, string | undefined][] = [
+
+
+

{t.product.reviewsTitle}

+ {bewertungSchnitt !== null ? ( + <> +
+ +
+
+ {product.bewertungen!.map((r) => ( +
+
+ {r.name} + + {r.datum && {r.datum}} +
+ {r.text &&

{r.text[lang]}

} +
+ ))} +
+ + ) : ( +

{t.product.noReviews}

+ )} +
+
+ {empfehlungen.length > 0 && (
diff --git a/src/pages/shop/index.astro b/src/pages/shop/index.astro index 5e4020a..f33ea8a 100644 --- a/src/pages/shop/index.astro +++ b/src/pages/shop/index.astro @@ -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);
+
+ + +

{t.shop.filterNote}

@@ -73,6 +83,7 @@ const t = useTranslations(lang); @@ -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} > @@ -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