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
+19
View File
@@ -134,6 +134,25 @@ collections:
- { label: Lieferumfang, name: lieferumfang, widget: string, required: false } - { label: Lieferumfang, name: lieferumfang, widget: string, required: false }
- { label: Pflegehinweise, name: pflegehinweise, widget: string, required: false } - { label: Pflegehinweise, name: pflegehinweise, widget: string, required: false }
- { label: Lieferzeit, name: lieferzeit, 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!" - label: "Interner Code (URL) — nach Veröffentlichung nicht mehr ändern!"
name: slug name: slug
widget: string widget: string
+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 { useTranslations } from "../i18n/ui";
import { formatPrice } from "../i18n/format"; import { formatPrice } from "../i18n/format";
import { effektiverPreis, rabattLabel, hatAktivenRabatt } from "../data/rabatt"; import { effektiverPreis, rabattLabel, hatAktivenRabatt } from "../data/rabatt";
import { durchschnittsbewertung, anzahlBewertungen } from "../data/products";
import HeartRating from "./HeartRating.astro";
interface Props { product: Product; lang: Locale } interface Props { product: Product; lang: Locale }
const { product, lang } = Astro.props; const { product, lang } = Astro.props;
@@ -12,6 +14,8 @@ const t = useTranslations(lang);
const finalPrice = effektiverPreis(product); const finalPrice = effektiverPreis(product);
const discountBadge = rabattLabel(product); const discountBadge = rabattLabel(product);
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product); const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
const bewertungSchnitt = durchschnittsbewertung(product);
const bewertungAnzahl = anzahlBewertungen(product);
const badgeLabel: Record<string, string> = { const badgeLabel: Record<string, string> = {
neu: t.badge.neu, neu: t.badge.neu,
@@ -55,6 +59,9 @@ const desc = product.beschreibung[lang];
</div> </div>
</div> </div>
<h3>{name}</h3> <h3>{name}</h3>
{bewertungSchnitt !== null && (
<HeartRating bewertung={bewertungSchnitt} size={13} anzahlLabel={`(${bewertungAnzahl})`} />
)}
<p class="small">{desc.slice(0, 78)}{desc.length > 78 ? "…" : ""}</p> <p class="small">{desc.slice(0, 78)}{desc.length > 78 ? "…" : ""}</p>
<div class="price-row"> <div class="price-row">
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>} {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>; 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 { export interface Product {
slug: string; slug: string;
name: LocalizedText; 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 bis?: string; // ISO-Datum, z.B. "2026-12-31" — Rabatt gilt nur bis einschließlich diesem Tag
mengenrabatt?: { abMenge: number; prozent: number }[]; mengenrabatt?: { abMenge: number; prozent: number }[];
}; };
bewertungen?: Bewertung[];
} }
const modules = import.meta.glob("/src/content/products/*.json", { eager: true }) as Record< 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[] { export function saleProducts(): Product[] {
return products.filter((p) => p.badges.includes("sale") || p.preisAlt); 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", 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).", 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", 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: { shop: {
title: "Alle Produkte", lead: "Stöbere durch alle handgemachten Stücke filtere und sortiere nach deinen Wünschen.", 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}`, 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.", 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.", 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.", 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", 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", 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: { sale: {
eyebrow: "% Sale", title: "Aktuelle Angebote", lead: "Zeitlich begrenzte Rabatte auf ausgewählte handgemachte Stücke.", 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", 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).", 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", 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: { shop: {
title: "All products", lead: "Browse all handmade pieces filter and sort to your liking.", 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}`, 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.", 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.", 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.", 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", 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", 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: { sale: {
eyebrow: "% Sale", title: "Current offers", lead: "Time-limited discounts on selected handmade pieces.", 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", 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).", 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", 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: { shop: {
title: "Alli Produkt", lead: "Lueg dr alli vo Hang gmachte Sächeli a filtere und sortiere, wie's dir passt.", 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}`, 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.", 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.", 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.", 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", 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", 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: { sale: {
eyebrow: "% Aktion", title: "Aktuelli Aktion", lead: "Zytlich begrenzti Rabatt uf uswählti, vo Hang gmachti Sächeli.", 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", 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).", 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", 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: { shop: {
title: "Tous les produits", lead: "Parcourez toutes les pièces faites main filtrez et triez selon vos envies.", 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}`, 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.", 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.", 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.", 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", 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", 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: { sale: {
eyebrow: "% Soldes", title: "Offres actuelles", lead: "Réductions limitées dans le temps sur une sélection de pièces faites main.", 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 Layout from "../../layouts/Layout.astro";
import ProductCard from "../../components/ProductCard.astro"; import ProductCard from "../../components/ProductCard.astro";
import HeartRating from "../../components/HeartRating.astro";
import { categories } from "../../data/categories"; 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"; import type { Locale } from "../../i18n/config";
const lang: Locale = "ch"; const lang: Locale = "ch";
const t = useTranslations(lang);
const neuheiten = products.filter((p) => p.badges.includes("neu")).slice(0, 4); 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 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 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="/"> <Layout title="Startsyte" lang={lang} path="/">
<section class="hero"> <section class="hero">
@@ -84,6 +89,31 @@ const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).sli
</section> </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"> <section class="section">
<div class="container grid grid-2 creator-section"> <div class="container grid grid-2 creator-section">
<div class="portrait-frame creator-photo"> <div class="portrait-frame creator-photo">
+36 -1
View File
@@ -1,7 +1,8 @@
--- ---
import Layout from "../../../layouts/Layout.astro"; import Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.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 { getCategory } from "../../../data/categories";
import type { Locale } from "../../../i18n/config"; import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui"; import { useTranslations } from "../../../i18n/ui";
@@ -27,6 +28,8 @@ const finalPrice = effektiverPreis(product);
const discountBadge = rabattLabel(product); const discountBadge = rabattLabel(product);
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product); const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
const mengenrabatt = product.rabatt?.mengenrabatt ?? []; const mengenrabatt = product.rabatt?.mengenrabatt ?? [];
const bewertungSchnitt = durchschnittsbewertung(product);
const bewertungAnzahl = anzahlBewertungen(product);
const felder: [string, string | undefined][] = [ const felder: [string, string | undefined][] = [
[t.product.articleNo, product.artikelnummer], [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>} {product.bestand === 0 && <span class="badge badge-sold-out">{t.badge.ausverkauft}</span>}
</div> </div>
<h1>{name}</h1> <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"> <div class="price-row-detail">
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>} {product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
{showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>} {showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>}
@@ -123,6 +131,33 @@ const felder: [string, string | undefined][] = [
</div> </div>
</section> </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 && ( {empfehlungen.length > 0 && (
<section class="section-tight"> <section class="section-tight">
<div class="container"> <div class="container">
+19 -3
View File
@@ -2,7 +2,7 @@
import Layout from "../../../layouts/Layout.astro"; import Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.astro"; import ProductCard from "../../../components/ProductCard.astro";
import { categories, subcategoryChipLabel } from "../../../data/categories"; import { categories, subcategoryChipLabel } from "../../../data/categories";
import { products } from "../../../data/products"; import { products, durchschnittsbewertung } from "../../../data/products";
import type { Locale } from "../../../i18n/config"; import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui"; import { useTranslations } from "../../../i18n/ui";
@@ -59,6 +59,16 @@ const t = useTranslations(lang);
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label> <label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
<input id="f-verfuegbar" type="checkbox" /> <input id="f-verfuegbar" type="checkbox" />
</div> </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> <p class="small">{t.shop.filterNote}</p>
</aside> </aside>
@@ -68,6 +78,7 @@ const t = useTranslations(lang);
<select id="sort" aria-label={t.shop.sortLabel}> <select id="sort" aria-label={t.shop.sortLabel}>
<option value="neu">{t.shop.sortNew}</option> <option value="neu">{t.shop.sortNew}</option>
<option value="bestseller">{t.shop.sortBestseller}</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-auf">{t.shop.sortPriceAsc}</option>
<option value="preis-ab">{t.shop.sortPriceDesc}</option> <option value="preis-ab">{t.shop.sortPriceDesc}</option>
</select> </select>
@@ -82,6 +93,7 @@ const t = useTranslations(lang);
data-bestand={p.bestand} data-bestand={p.bestand}
data-neu={p.badges.includes("neu") ? 1 : 0} data-neu={p.badges.includes("neu") ? 1 : 0}
data-bestseller={p.badges.includes("bestseller") ? 1 : 0} data-bestseller={p.badges.includes("bestseller") ? 1 : 0}
data-bewertung={durchschnittsbewertung(p) ?? 0}
> >
<ProductCard product={p} lang={lang} /> <ProductCard product={p} lang={lang} />
</div> </div>
@@ -101,6 +113,7 @@ const t = useTranslations(lang);
const preisRange = document.getElementById("f-preis"); const preisRange = document.getElementById("f-preis");
const preisValue = document.getElementById("f-preis-value"); const preisValue = document.getElementById("f-preis-value");
const verfuegbarChk = document.getElementById("f-verfuegbar"); const verfuegbarChk = document.getElementById("f-verfuegbar");
const bewertungSel = document.getElementById("f-bewertung");
const sortSel = document.getElementById("sort"); const sortSel = document.getElementById("sort");
const resultCount = document.getElementById("result-count"); const resultCount = document.getElementById("result-count");
const emptyMsg = document.getElementById("empty-msg"); const emptyMsg = document.getElementById("empty-msg");
@@ -114,6 +127,7 @@ const t = useTranslations(lang);
const kat = kategorieSel.value; const kat = kategorieSel.value;
const maxPreis = Number(preisRange.value); const maxPreis = Number(preisRange.value);
const nurVerfuegbar = verfuegbarChk.checked; const nurVerfuegbar = verfuegbarChk.checked;
const minBewertung = Number(bewertungSel.value);
preisValue.textContent = formatPrice(maxPreis); preisValue.textContent = formatPrice(maxPreis);
let visible = 0; let visible = 0;
@@ -125,7 +139,8 @@ const t = useTranslations(lang);
: slot.dataset.kategorie === kat; : slot.dataset.kategorie === kat;
const matchPreis = Number(slot.dataset.preis) <= maxPreis; const matchPreis = Number(slot.dataset.preis) <= maxPreis;
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0; 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"; slot.style.display = show ? "" : "none";
if (show) visible++; 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-auf": return Number(a.dataset.preis) - Number(b.dataset.preis);
case "preis-ab": return Number(b.dataset.preis) - Number(a.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 "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); default: return Number(b.dataset.neu) - Number(a.dataset.neu);
} }
}); });
sorted.forEach((el) => grid.appendChild(el)); 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 catFilter = document.getElementById("cat-filter");
const catFilterBtn = document.getElementById("cat-filter-btn"); const catFilterBtn = document.getElementById("cat-filter-btn");
+31 -1
View File
@@ -1,15 +1,20 @@
--- ---
import Layout from "../../layouts/Layout.astro"; import Layout from "../../layouts/Layout.astro";
import ProductCard from "../../components/ProductCard.astro"; import ProductCard from "../../components/ProductCard.astro";
import HeartRating from "../../components/HeartRating.astro";
import { categories } from "../../data/categories"; 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"; import type { Locale } from "../../i18n/config";
const lang: Locale = "en"; const lang: Locale = "en";
const t = useTranslations(lang);
const neuheiten = products.filter((p) => p.badges.includes("neu")).slice(0, 4); 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 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 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="/"> <Layout title="Home" lang={lang} path="/">
<section class="hero"> <section class="hero">
@@ -84,6 +89,31 @@ const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).sli
</section> </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"> <section class="section">
<div class="container grid grid-2 creator-section"> <div class="container grid grid-2 creator-section">
<div class="portrait-frame creator-photo"> <div class="portrait-frame creator-photo">
+36 -1
View File
@@ -1,7 +1,8 @@
--- ---
import Layout from "../../../layouts/Layout.astro"; import Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.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 { getCategory } from "../../../data/categories";
import type { Locale } from "../../../i18n/config"; import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui"; import { useTranslations } from "../../../i18n/ui";
@@ -27,6 +28,8 @@ const finalPrice = effektiverPreis(product);
const discountBadge = rabattLabel(product); const discountBadge = rabattLabel(product);
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product); const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
const mengenrabatt = product.rabatt?.mengenrabatt ?? []; const mengenrabatt = product.rabatt?.mengenrabatt ?? [];
const bewertungSchnitt = durchschnittsbewertung(product);
const bewertungAnzahl = anzahlBewertungen(product);
const felder: [string, string | undefined][] = [ const felder: [string, string | undefined][] = [
[t.product.articleNo, product.artikelnummer], [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>} {product.bestand === 0 && <span class="badge badge-sold-out">{t.badge.ausverkauft}</span>}
</div> </div>
<h1>{name}</h1> <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"> <div class="price-row-detail">
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>} {product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
{showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>} {showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>}
@@ -123,6 +131,33 @@ const felder: [string, string | undefined][] = [
</div> </div>
</section> </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 && ( {empfehlungen.length > 0 && (
<section class="section-tight"> <section class="section-tight">
<div class="container"> <div class="container">
+19 -3
View File
@@ -2,7 +2,7 @@
import Layout from "../../../layouts/Layout.astro"; import Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.astro"; import ProductCard from "../../../components/ProductCard.astro";
import { categories, subcategoryChipLabel } from "../../../data/categories"; import { categories, subcategoryChipLabel } from "../../../data/categories";
import { products } from "../../../data/products"; import { products, durchschnittsbewertung } from "../../../data/products";
import type { Locale } from "../../../i18n/config"; import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui"; import { useTranslations } from "../../../i18n/ui";
@@ -59,6 +59,16 @@ const t = useTranslations(lang);
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label> <label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
<input id="f-verfuegbar" type="checkbox" /> <input id="f-verfuegbar" type="checkbox" />
</div> </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> <p class="small">{t.shop.filterNote}</p>
</aside> </aside>
@@ -68,6 +78,7 @@ const t = useTranslations(lang);
<select id="sort" aria-label={t.shop.sortLabel}> <select id="sort" aria-label={t.shop.sortLabel}>
<option value="neu">{t.shop.sortNew}</option> <option value="neu">{t.shop.sortNew}</option>
<option value="bestseller">{t.shop.sortBestseller}</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-auf">{t.shop.sortPriceAsc}</option>
<option value="preis-ab">{t.shop.sortPriceDesc}</option> <option value="preis-ab">{t.shop.sortPriceDesc}</option>
</select> </select>
@@ -82,6 +93,7 @@ const t = useTranslations(lang);
data-bestand={p.bestand} data-bestand={p.bestand}
data-neu={p.badges.includes("neu") ? 1 : 0} data-neu={p.badges.includes("neu") ? 1 : 0}
data-bestseller={p.badges.includes("bestseller") ? 1 : 0} data-bestseller={p.badges.includes("bestseller") ? 1 : 0}
data-bewertung={durchschnittsbewertung(p) ?? 0}
> >
<ProductCard product={p} lang={lang} /> <ProductCard product={p} lang={lang} />
</div> </div>
@@ -101,6 +113,7 @@ const t = useTranslations(lang);
const preisRange = document.getElementById("f-preis"); const preisRange = document.getElementById("f-preis");
const preisValue = document.getElementById("f-preis-value"); const preisValue = document.getElementById("f-preis-value");
const verfuegbarChk = document.getElementById("f-verfuegbar"); const verfuegbarChk = document.getElementById("f-verfuegbar");
const bewertungSel = document.getElementById("f-bewertung");
const sortSel = document.getElementById("sort"); const sortSel = document.getElementById("sort");
const resultCount = document.getElementById("result-count"); const resultCount = document.getElementById("result-count");
const emptyMsg = document.getElementById("empty-msg"); const emptyMsg = document.getElementById("empty-msg");
@@ -114,6 +127,7 @@ const t = useTranslations(lang);
const kat = kategorieSel.value; const kat = kategorieSel.value;
const maxPreis = Number(preisRange.value); const maxPreis = Number(preisRange.value);
const nurVerfuegbar = verfuegbarChk.checked; const nurVerfuegbar = verfuegbarChk.checked;
const minBewertung = Number(bewertungSel.value);
preisValue.textContent = formatPrice(maxPreis); preisValue.textContent = formatPrice(maxPreis);
let visible = 0; let visible = 0;
@@ -125,7 +139,8 @@ const t = useTranslations(lang);
: slot.dataset.kategorie === kat; : slot.dataset.kategorie === kat;
const matchPreis = Number(slot.dataset.preis) <= maxPreis; const matchPreis = Number(slot.dataset.preis) <= maxPreis;
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0; 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"; slot.style.display = show ? "" : "none";
if (show) visible++; 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-auf": return Number(a.dataset.preis) - Number(b.dataset.preis);
case "preis-ab": return Number(b.dataset.preis) - Number(a.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 "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); default: return Number(b.dataset.neu) - Number(a.dataset.neu);
} }
}); });
sorted.forEach((el) => grid.appendChild(el)); 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 catFilter = document.getElementById("cat-filter");
const catFilterBtn = document.getElementById("cat-filter-btn"); const catFilterBtn = document.getElementById("cat-filter-btn");
+31 -1
View File
@@ -1,15 +1,20 @@
--- ---
import Layout from "../../layouts/Layout.astro"; import Layout from "../../layouts/Layout.astro";
import ProductCard from "../../components/ProductCard.astro"; import ProductCard from "../../components/ProductCard.astro";
import HeartRating from "../../components/HeartRating.astro";
import { categories } from "../../data/categories"; 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"; import type { Locale } from "../../i18n/config";
const lang: Locale = "fr"; const lang: Locale = "fr";
const t = useTranslations(lang);
const neuheiten = products.filter((p) => p.badges.includes("neu")).slice(0, 4); 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 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 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="/"> <Layout title="Accueil" lang={lang} path="/">
<section class="hero"> <section class="hero">
@@ -84,6 +89,31 @@ const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).sli
</section> </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"> <section class="section">
<div class="container grid grid-2 creator-section"> <div class="container grid grid-2 creator-section">
<div class="portrait-frame creator-photo"> <div class="portrait-frame creator-photo">
+36 -1
View File
@@ -1,7 +1,8 @@
--- ---
import Layout from "../../../layouts/Layout.astro"; import Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.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 { getCategory } from "../../../data/categories";
import type { Locale } from "../../../i18n/config"; import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui"; import { useTranslations } from "../../../i18n/ui";
@@ -27,6 +28,8 @@ const finalPrice = effektiverPreis(product);
const discountBadge = rabattLabel(product); const discountBadge = rabattLabel(product);
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product); const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
const mengenrabatt = product.rabatt?.mengenrabatt ?? []; const mengenrabatt = product.rabatt?.mengenrabatt ?? [];
const bewertungSchnitt = durchschnittsbewertung(product);
const bewertungAnzahl = anzahlBewertungen(product);
const felder: [string, string | undefined][] = [ const felder: [string, string | undefined][] = [
[t.product.articleNo, product.artikelnummer], [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>} {product.bestand === 0 && <span class="badge badge-sold-out">{t.badge.ausverkauft}</span>}
</div> </div>
<h1>{name}</h1> <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"> <div class="price-row-detail">
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>} {product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
{showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>} {showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>}
@@ -123,6 +131,33 @@ const felder: [string, string | undefined][] = [
</div> </div>
</section> </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 && ( {empfehlungen.length > 0 && (
<section class="section-tight"> <section class="section-tight">
<div class="container"> <div class="container">
+19 -3
View File
@@ -2,7 +2,7 @@
import Layout from "../../../layouts/Layout.astro"; import Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.astro"; import ProductCard from "../../../components/ProductCard.astro";
import { categories, subcategoryChipLabel } from "../../../data/categories"; import { categories, subcategoryChipLabel } from "../../../data/categories";
import { products } from "../../../data/products"; import { products, durchschnittsbewertung } from "../../../data/products";
import type { Locale } from "../../../i18n/config"; import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui"; import { useTranslations } from "../../../i18n/ui";
@@ -59,6 +59,16 @@ const t = useTranslations(lang);
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label> <label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
<input id="f-verfuegbar" type="checkbox" /> <input id="f-verfuegbar" type="checkbox" />
</div> </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> <p class="small">{t.shop.filterNote}</p>
</aside> </aside>
@@ -68,6 +78,7 @@ const t = useTranslations(lang);
<select id="sort" aria-label={t.shop.sortLabel}> <select id="sort" aria-label={t.shop.sortLabel}>
<option value="neu">{t.shop.sortNew}</option> <option value="neu">{t.shop.sortNew}</option>
<option value="bestseller">{t.shop.sortBestseller}</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-auf">{t.shop.sortPriceAsc}</option>
<option value="preis-ab">{t.shop.sortPriceDesc}</option> <option value="preis-ab">{t.shop.sortPriceDesc}</option>
</select> </select>
@@ -82,6 +93,7 @@ const t = useTranslations(lang);
data-bestand={p.bestand} data-bestand={p.bestand}
data-neu={p.badges.includes("neu") ? 1 : 0} data-neu={p.badges.includes("neu") ? 1 : 0}
data-bestseller={p.badges.includes("bestseller") ? 1 : 0} data-bestseller={p.badges.includes("bestseller") ? 1 : 0}
data-bewertung={durchschnittsbewertung(p) ?? 0}
> >
<ProductCard product={p} lang={lang} /> <ProductCard product={p} lang={lang} />
</div> </div>
@@ -101,6 +113,7 @@ const t = useTranslations(lang);
const preisRange = document.getElementById("f-preis"); const preisRange = document.getElementById("f-preis");
const preisValue = document.getElementById("f-preis-value"); const preisValue = document.getElementById("f-preis-value");
const verfuegbarChk = document.getElementById("f-verfuegbar"); const verfuegbarChk = document.getElementById("f-verfuegbar");
const bewertungSel = document.getElementById("f-bewertung");
const sortSel = document.getElementById("sort"); const sortSel = document.getElementById("sort");
const resultCount = document.getElementById("result-count"); const resultCount = document.getElementById("result-count");
const emptyMsg = document.getElementById("empty-msg"); const emptyMsg = document.getElementById("empty-msg");
@@ -114,6 +127,7 @@ const t = useTranslations(lang);
const kat = kategorieSel.value; const kat = kategorieSel.value;
const maxPreis = Number(preisRange.value); const maxPreis = Number(preisRange.value);
const nurVerfuegbar = verfuegbarChk.checked; const nurVerfuegbar = verfuegbarChk.checked;
const minBewertung = Number(bewertungSel.value);
preisValue.textContent = formatPrice(maxPreis); preisValue.textContent = formatPrice(maxPreis);
let visible = 0; let visible = 0;
@@ -125,7 +139,8 @@ const t = useTranslations(lang);
: slot.dataset.kategorie === kat; : slot.dataset.kategorie === kat;
const matchPreis = Number(slot.dataset.preis) <= maxPreis; const matchPreis = Number(slot.dataset.preis) <= maxPreis;
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0; 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"; slot.style.display = show ? "" : "none";
if (show) visible++; 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-auf": return Number(a.dataset.preis) - Number(b.dataset.preis);
case "preis-ab": return Number(b.dataset.preis) - Number(a.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 "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); default: return Number(b.dataset.neu) - Number(a.dataset.neu);
} }
}); });
sorted.forEach((el) => grid.appendChild(el)); 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 catFilter = document.getElementById("cat-filter");
const catFilterBtn = document.getElementById("cat-filter-btn"); const catFilterBtn = document.getElementById("cat-filter-btn");
+34 -1
View File
@@ -1,15 +1,23 @@
--- ---
import Layout from "../layouts/Layout.astro"; import Layout from "../layouts/Layout.astro";
import ProductCard from "../components/ProductCard.astro"; import ProductCard from "../components/ProductCard.astro";
import HeartRating from "../components/HeartRating.astro";
import { categories } from "../data/categories"; 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"; import type { Locale } from "../i18n/config";
const lang: Locale = "de"; const lang: Locale = "de";
const t = useTranslations(lang);
const neuheiten = products.filter((p) => p.badges.includes("neu")).slice(0, 4); 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 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 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="/"> <Layout title="Startseite" lang={lang} path="/">
<section class="hero"> <section class="hero">
@@ -84,6 +92,31 @@ const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).sli
</section> </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"> <section class="section">
<div class="container grid grid-2 creator-section"> <div class="container grid grid-2 creator-section">
<div class="portrait-frame creator-photo"> <div class="portrait-frame creator-photo">
+36 -1
View File
@@ -1,7 +1,8 @@
--- ---
import Layout from "../../layouts/Layout.astro"; import Layout from "../../layouts/Layout.astro";
import ProductCard from "../../components/ProductCard.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 { getCategory } from "../../data/categories";
import type { Locale } from "../../i18n/config"; import type { Locale } from "../../i18n/config";
import { useTranslations } from "../../i18n/ui"; import { useTranslations } from "../../i18n/ui";
@@ -27,6 +28,8 @@ const finalPrice = effektiverPreis(product);
const discountBadge = rabattLabel(product); const discountBadge = rabattLabel(product);
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product); const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
const mengenrabatt = product.rabatt?.mengenrabatt ?? []; const mengenrabatt = product.rabatt?.mengenrabatt ?? [];
const bewertungSchnitt = durchschnittsbewertung(product);
const bewertungAnzahl = anzahlBewertungen(product);
const felder: [string, string | undefined][] = [ const felder: [string, string | undefined][] = [
[t.product.articleNo, product.artikelnummer], [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>} {product.bestand === 0 && <span class="badge badge-sold-out">{t.badge.ausverkauft}</span>}
</div> </div>
<h1>{name}</h1> <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"> <div class="price-row-detail">
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>} {product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
{showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>} {showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>}
@@ -123,6 +131,33 @@ const felder: [string, string | undefined][] = [
</div> </div>
</section> </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 && ( {empfehlungen.length > 0 && (
<section class="section-tight"> <section class="section-tight">
<div class="container"> <div class="container">
+19 -3
View File
@@ -2,7 +2,7 @@
import Layout from "../../layouts/Layout.astro"; import Layout from "../../layouts/Layout.astro";
import ProductCard from "../../components/ProductCard.astro"; import ProductCard from "../../components/ProductCard.astro";
import { categories, subcategoryChipLabel } from "../../data/categories"; import { categories, subcategoryChipLabel } from "../../data/categories";
import { products } from "../../data/products"; import { products, durchschnittsbewertung } from "../../data/products";
import type { Locale } from "../../i18n/config"; import type { Locale } from "../../i18n/config";
import { useTranslations } from "../../i18n/ui"; import { useTranslations } from "../../i18n/ui";
@@ -64,6 +64,16 @@ const t = useTranslations(lang);
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label> <label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
<input id="f-verfuegbar" type="checkbox" /> <input id="f-verfuegbar" type="checkbox" />
</div> </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> <p class="small">{t.shop.filterNote}</p>
</aside> </aside>
@@ -73,6 +83,7 @@ const t = useTranslations(lang);
<select id="sort" aria-label={t.shop.sortLabel}> <select id="sort" aria-label={t.shop.sortLabel}>
<option value="neu">{t.shop.sortNew}</option> <option value="neu">{t.shop.sortNew}</option>
<option value="bestseller">{t.shop.sortBestseller}</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-auf">{t.shop.sortPriceAsc}</option>
<option value="preis-ab">{t.shop.sortPriceDesc}</option> <option value="preis-ab">{t.shop.sortPriceDesc}</option>
</select> </select>
@@ -87,6 +98,7 @@ const t = useTranslations(lang);
data-bestand={p.bestand} data-bestand={p.bestand}
data-neu={p.badges.includes("neu") ? 1 : 0} data-neu={p.badges.includes("neu") ? 1 : 0}
data-bestseller={p.badges.includes("bestseller") ? 1 : 0} data-bestseller={p.badges.includes("bestseller") ? 1 : 0}
data-bewertung={durchschnittsbewertung(p) ?? 0}
> >
<ProductCard product={p} lang={lang} /> <ProductCard product={p} lang={lang} />
</div> </div>
@@ -106,6 +118,7 @@ const t = useTranslations(lang);
const preisRange = document.getElementById("f-preis"); const preisRange = document.getElementById("f-preis");
const preisValue = document.getElementById("f-preis-value"); const preisValue = document.getElementById("f-preis-value");
const verfuegbarChk = document.getElementById("f-verfuegbar"); const verfuegbarChk = document.getElementById("f-verfuegbar");
const bewertungSel = document.getElementById("f-bewertung");
const sortSel = document.getElementById("sort"); const sortSel = document.getElementById("sort");
const resultCount = document.getElementById("result-count"); const resultCount = document.getElementById("result-count");
const emptyMsg = document.getElementById("empty-msg"); const emptyMsg = document.getElementById("empty-msg");
@@ -119,6 +132,7 @@ const t = useTranslations(lang);
const kat = kategorieSel.value; const kat = kategorieSel.value;
const maxPreis = Number(preisRange.value); const maxPreis = Number(preisRange.value);
const nurVerfuegbar = verfuegbarChk.checked; const nurVerfuegbar = verfuegbarChk.checked;
const minBewertung = Number(bewertungSel.value);
preisValue.textContent = formatPrice(maxPreis); preisValue.textContent = formatPrice(maxPreis);
let visible = 0; let visible = 0;
@@ -130,7 +144,8 @@ const t = useTranslations(lang);
: slot.dataset.kategorie === kat; : slot.dataset.kategorie === kat;
const matchPreis = Number(slot.dataset.preis) <= maxPreis; const matchPreis = Number(slot.dataset.preis) <= maxPreis;
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0; 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"; slot.style.display = show ? "" : "none";
if (show) visible++; 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-auf": return Number(a.dataset.preis) - Number(b.dataset.preis);
case "preis-ab": return Number(b.dataset.preis) - Number(a.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 "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); default: return Number(b.dataset.neu) - Number(a.dataset.neu);
} }
}); });
sorted.forEach((el) => grid.appendChild(el)); 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 // 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 // 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); } .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; } .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-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); } .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 /* Durchgestrichener alter Preis bei Rabatt — global, damit das auch auf der Produktseite gilt