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:
co-authored by
Claude Sonnet 5
parent
1add9a42a1
commit
b384938b92
@@ -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";
|
||||
---
|
||||
|
||||
<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>
|
||||
@@ -5,6 +5,8 @@ import { localePrefix } from "../i18n/config";
|
||||
import { useTranslations } from "../i18n/ui";
|
||||
import { formatPrice } from "../i18n/format";
|
||||
import { effektiverPreis, rabattLabel, hatAktivenRabatt } from "../data/rabatt";
|
||||
import { durchschnittsbewertung, anzahlBewertungen } from "../data/products";
|
||||
import HeartRating from "./HeartRating.astro";
|
||||
|
||||
interface Props { product: Product; lang: Locale }
|
||||
const { product, lang } = Astro.props;
|
||||
@@ -12,6 +14,8 @@ const t = useTranslations(lang);
|
||||
const finalPrice = effektiverPreis(product);
|
||||
const discountBadge = rabattLabel(product);
|
||||
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
|
||||
const bewertungSchnitt = durchschnittsbewertung(product);
|
||||
const bewertungAnzahl = anzahlBewertungen(product);
|
||||
|
||||
const badgeLabel: Record<string, string> = {
|
||||
neu: t.badge.neu,
|
||||
@@ -55,6 +59,9 @@ const desc = product.beschreibung[lang];
|
||||
</div>
|
||||
</div>
|
||||
<h3>{name}</h3>
|
||||
{bewertungSchnitt !== null && (
|
||||
<HeartRating bewertung={bewertungSchnitt} size={13} anzahlLabel={`(${bewertungAnzahl})`} />
|
||||
)}
|
||||
<p class="small">{desc.slice(0, 78)}{desc.length > 78 ? "…" : ""}</p>
|
||||
<div class="price-row">
|
||||
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
|
||||
|
||||
@@ -11,6 +11,18 @@ export type Badge = "neu" | "bestseller" | "sale" | "handgemacht";
|
||||
|
||||
type LocalizedText = Record<Locale, string>;
|
||||
|
||||
// Eine einzelne Kundenrezension zu einem Produkt — von VanVan im Adminbereich gepflegt (echte
|
||||
// Kunden-Rückmeldungen, KEINE automatisch erzeugten/erfundenen Bewertungen: Fake-Bewertungen sind
|
||||
// in Deutschland/der EU seit der Omnibus-Richtlinie ausdrücklich verboten). Deshalb startet jedes
|
||||
// Produkt ohne Bewertungen — die Anzeige blendet sich überall automatisch ein, sobald die erste
|
||||
// echte Rezension eingetragen wird, und bleibt sonst dezent (kein "0 Bewertungen"-Ballast).
|
||||
export interface Bewertung {
|
||||
name: string;
|
||||
bewertung: number; // 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;
|
||||
}
|
||||
|
||||
+36
-4
@@ -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.",
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import ProductCard from "../../components/ProductCard.astro";
|
||||
import HeartRating from "../../components/HeartRating.astro";
|
||||
import { categories } from "../../data/categories";
|
||||
import { products } from "../../data/products";
|
||||
import { products, alleBewertungen, gesamtDurchschnitt } from "../../data/products";
|
||||
import { useTranslations } from "../../i18n/ui";
|
||||
import type { Locale } from "../../i18n/config";
|
||||
|
||||
const lang: Locale = "ch";
|
||||
const t = useTranslations(lang);
|
||||
|
||||
const neuheiten = products.filter((p) => p.badges.includes("neu")).slice(0, 4);
|
||||
const bestseller = products.filter((p) => p.badges.includes("bestseller")).slice(0, 4);
|
||||
const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).slice(0, 4);
|
||||
const topBewertungen = alleBewertungen().slice(0, 3);
|
||||
const bewertungSchnittGesamt = gesamtDurchschnitt();
|
||||
---
|
||||
<Layout title="Startsyte" lang={lang} path="/">
|
||||
<section class="hero">
|
||||
@@ -84,6 +89,31 @@ const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).sli
|
||||
</section>
|
||||
)}
|
||||
|
||||
{topBewertungen.length > 0 && bewertungSchnittGesamt !== null && (
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<span class="eyebrow">🩵 {t.reviews.heading}</span>
|
||||
<h2>{t.reviews.heading}</h2>
|
||||
<p class="lead">{t.reviews.lead}</p>
|
||||
<div class="review-summary-row">
|
||||
<HeartRating bewertung={bewertungSchnittGesamt} size={24} showValue={true} anzahlLabel={t.reviews.basedOn(alleBewertungen().length)} />
|
||||
</div>
|
||||
<div class="grid grid-3">
|
||||
{topBewertungen.map((r) => (
|
||||
<div class="card review-card">
|
||||
<div class="review-card-head">
|
||||
<span class="review-name">{r.name}</span>
|
||||
<HeartRating bewertung={r.bewertung} size={14} />
|
||||
</div>
|
||||
{r.text && <p class="small">{r.text[lang]}</p>}
|
||||
<a class="small" href={`/ch/produkt/${r.produktSlug}/`}>{t.reviews.toProduct} {r.produktName[lang]}</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section class="section">
|
||||
<div class="container grid grid-2 creator-section">
|
||||
<div class="portrait-frame creator-photo">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
import Layout from "../../../layouts/Layout.astro";
|
||||
import ProductCard from "../../../components/ProductCard.astro";
|
||||
import { products, getProduct, productsByCategory } from "../../../data/products";
|
||||
import HeartRating from "../../../components/HeartRating.astro";
|
||||
import { products, getProduct, productsByCategory, durchschnittsbewertung, anzahlBewertungen } from "../../../data/products";
|
||||
import { getCategory } from "../../../data/categories";
|
||||
import type { Locale } from "../../../i18n/config";
|
||||
import { useTranslations } from "../../../i18n/ui";
|
||||
@@ -27,6 +28,8 @@ const finalPrice = effektiverPreis(product);
|
||||
const discountBadge = rabattLabel(product);
|
||||
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
|
||||
const mengenrabatt = product.rabatt?.mengenrabatt ?? [];
|
||||
const bewertungSchnitt = durchschnittsbewertung(product);
|
||||
const bewertungAnzahl = anzahlBewertungen(product);
|
||||
|
||||
const felder: [string, string | undefined][] = [
|
||||
[t.product.articleNo, product.artikelnummer],
|
||||
@@ -71,6 +74,11 @@ const felder: [string, string | undefined][] = [
|
||||
{product.bestand === 0 && <span class="badge badge-sold-out">{t.badge.ausverkauft}</span>}
|
||||
</div>
|
||||
<h1>{name}</h1>
|
||||
{bewertungSchnitt !== null && (
|
||||
<a href="#bewertungen" class="review-summary-link">
|
||||
<HeartRating bewertung={bewertungSchnitt} size={16} showValue={true} anzahlLabel={t.product.reviewCount(bewertungAnzahl)} />
|
||||
</a>
|
||||
)}
|
||||
<div class="price-row-detail">
|
||||
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
|
||||
{showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>}
|
||||
@@ -123,6 +131,33 @@ const felder: [string, string | undefined][] = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight" id="bewertungen">
|
||||
<div class="container">
|
||||
<h2>{t.product.reviewsTitle}</h2>
|
||||
{bewertungSchnitt !== null ? (
|
||||
<>
|
||||
<div class="review-summary-row">
|
||||
<HeartRating bewertung={bewertungSchnitt} size={26} showValue={true} anzahlLabel={t.product.reviewCount(bewertungAnzahl)} />
|
||||
</div>
|
||||
<div class="review-list">
|
||||
{product.bewertungen!.map((r) => (
|
||||
<div class="card review-card">
|
||||
<div class="review-card-head">
|
||||
<span class="review-name">{r.name}</span>
|
||||
<HeartRating bewertung={r.bewertung} size={14} />
|
||||
{r.datum && <span class="review-date small">{r.datum}</span>}
|
||||
</div>
|
||||
{r.text && <p class="small">{r.text[lang]}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p class="small">{t.product.noReviews}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{empfehlungen.length > 0 && (
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Layout from "../../../layouts/Layout.astro";
|
||||
import ProductCard from "../../../components/ProductCard.astro";
|
||||
import { categories, subcategoryChipLabel } from "../../../data/categories";
|
||||
import { products } from "../../../data/products";
|
||||
import { products, durchschnittsbewertung } from "../../../data/products";
|
||||
import type { Locale } from "../../../i18n/config";
|
||||
import { useTranslations } from "../../../i18n/ui";
|
||||
|
||||
@@ -59,6 +59,16 @@ const t = useTranslations(lang);
|
||||
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
|
||||
<input id="f-verfuegbar" type="checkbox" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="f-bewertung">{t.shop.minRatingLabel}</label>
|
||||
<select id="f-bewertung">
|
||||
<option value="0">{t.shop.minRatingAny}</option>
|
||||
<option value="4">🩵🩵🩵🩵 +</option>
|
||||
<option value="3">🩵🩵🩵 +</option>
|
||||
<option value="2">🩵🩵 +</option>
|
||||
<option value="1">🩵 +</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="small">{t.shop.filterNote}</p>
|
||||
</aside>
|
||||
|
||||
@@ -68,6 +78,7 @@ const t = useTranslations(lang);
|
||||
<select id="sort" aria-label={t.shop.sortLabel}>
|
||||
<option value="neu">{t.shop.sortNew}</option>
|
||||
<option value="bestseller">{t.shop.sortBestseller}</option>
|
||||
<option value="bewertung">{t.shop.sortRating}</option>
|
||||
<option value="preis-auf">{t.shop.sortPriceAsc}</option>
|
||||
<option value="preis-ab">{t.shop.sortPriceDesc}</option>
|
||||
</select>
|
||||
@@ -82,6 +93,7 @@ const t = useTranslations(lang);
|
||||
data-bestand={p.bestand}
|
||||
data-neu={p.badges.includes("neu") ? 1 : 0}
|
||||
data-bestseller={p.badges.includes("bestseller") ? 1 : 0}
|
||||
data-bewertung={durchschnittsbewertung(p) ?? 0}
|
||||
>
|
||||
<ProductCard product={p} lang={lang} />
|
||||
</div>
|
||||
@@ -101,6 +113,7 @@ const t = useTranslations(lang);
|
||||
const preisRange = document.getElementById("f-preis");
|
||||
const preisValue = document.getElementById("f-preis-value");
|
||||
const verfuegbarChk = document.getElementById("f-verfuegbar");
|
||||
const bewertungSel = document.getElementById("f-bewertung");
|
||||
const sortSel = document.getElementById("sort");
|
||||
const resultCount = document.getElementById("result-count");
|
||||
const emptyMsg = document.getElementById("empty-msg");
|
||||
@@ -114,6 +127,7 @@ const t = useTranslations(lang);
|
||||
const kat = kategorieSel.value;
|
||||
const maxPreis = Number(preisRange.value);
|
||||
const nurVerfuegbar = verfuegbarChk.checked;
|
||||
const minBewertung = Number(bewertungSel.value);
|
||||
preisValue.textContent = formatPrice(maxPreis);
|
||||
|
||||
let visible = 0;
|
||||
@@ -125,7 +139,8 @@ const t = useTranslations(lang);
|
||||
: slot.dataset.kategorie === kat;
|
||||
const matchPreis = Number(slot.dataset.preis) <= maxPreis;
|
||||
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0;
|
||||
const show = matchKat && matchPreis && matchVerfuegbar;
|
||||
const matchBewertung = Number(slot.dataset.bewertung) >= minBewertung;
|
||||
const show = matchKat && matchPreis && matchVerfuegbar && matchBewertung;
|
||||
slot.style.display = show ? "" : "none";
|
||||
if (show) visible++;
|
||||
});
|
||||
@@ -138,13 +153,14 @@ const t = useTranslations(lang);
|
||||
case "preis-auf": return Number(a.dataset.preis) - Number(b.dataset.preis);
|
||||
case "preis-ab": return Number(b.dataset.preis) - Number(a.dataset.preis);
|
||||
case "bestseller": return Number(b.dataset.bestseller) - Number(a.dataset.bestseller);
|
||||
case "bewertung": return Number(b.dataset.bewertung) - Number(a.dataset.bewertung);
|
||||
default: return Number(b.dataset.neu) - Number(a.dataset.neu);
|
||||
}
|
||||
});
|
||||
sorted.forEach((el) => grid.appendChild(el));
|
||||
}
|
||||
|
||||
[preisRange, verfuegbarChk, sortSel].forEach((el) => el.addEventListener("input", apply));
|
||||
[preisRange, verfuegbarChk, bewertungSel, sortSel].forEach((el) => el.addEventListener("input", apply));
|
||||
|
||||
const catFilter = document.getElementById("cat-filter");
|
||||
const catFilterBtn = document.getElementById("cat-filter-btn");
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import ProductCard from "../../components/ProductCard.astro";
|
||||
import HeartRating from "../../components/HeartRating.astro";
|
||||
import { categories } from "../../data/categories";
|
||||
import { products } from "../../data/products";
|
||||
import { products, alleBewertungen, gesamtDurchschnitt } from "../../data/products";
|
||||
import { useTranslations } from "../../i18n/ui";
|
||||
import type { Locale } from "../../i18n/config";
|
||||
|
||||
const lang: Locale = "en";
|
||||
const t = useTranslations(lang);
|
||||
|
||||
const neuheiten = products.filter((p) => p.badges.includes("neu")).slice(0, 4);
|
||||
const bestseller = products.filter((p) => p.badges.includes("bestseller")).slice(0, 4);
|
||||
const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).slice(0, 4);
|
||||
const topBewertungen = alleBewertungen().slice(0, 3);
|
||||
const bewertungSchnittGesamt = gesamtDurchschnitt();
|
||||
---
|
||||
<Layout title="Home" lang={lang} path="/">
|
||||
<section class="hero">
|
||||
@@ -84,6 +89,31 @@ const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).sli
|
||||
</section>
|
||||
)}
|
||||
|
||||
{topBewertungen.length > 0 && bewertungSchnittGesamt !== null && (
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<span class="eyebrow">🩵 {t.reviews.heading}</span>
|
||||
<h2>{t.reviews.heading}</h2>
|
||||
<p class="lead">{t.reviews.lead}</p>
|
||||
<div class="review-summary-row">
|
||||
<HeartRating bewertung={bewertungSchnittGesamt} size={24} showValue={true} anzahlLabel={t.reviews.basedOn(alleBewertungen().length)} />
|
||||
</div>
|
||||
<div class="grid grid-3">
|
||||
{topBewertungen.map((r) => (
|
||||
<div class="card review-card">
|
||||
<div class="review-card-head">
|
||||
<span class="review-name">{r.name}</span>
|
||||
<HeartRating bewertung={r.bewertung} size={14} />
|
||||
</div>
|
||||
{r.text && <p class="small">{r.text[lang]}</p>}
|
||||
<a class="small" href={`/en/produkt/${r.produktSlug}/`}>{t.reviews.toProduct} {r.produktName[lang]}</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section class="section">
|
||||
<div class="container grid grid-2 creator-section">
|
||||
<div class="portrait-frame creator-photo">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
import Layout from "../../../layouts/Layout.astro";
|
||||
import ProductCard from "../../../components/ProductCard.astro";
|
||||
import { products, getProduct, productsByCategory } from "../../../data/products";
|
||||
import HeartRating from "../../../components/HeartRating.astro";
|
||||
import { products, getProduct, productsByCategory, durchschnittsbewertung, anzahlBewertungen } from "../../../data/products";
|
||||
import { getCategory } from "../../../data/categories";
|
||||
import type { Locale } from "../../../i18n/config";
|
||||
import { useTranslations } from "../../../i18n/ui";
|
||||
@@ -27,6 +28,8 @@ const finalPrice = effektiverPreis(product);
|
||||
const discountBadge = rabattLabel(product);
|
||||
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
|
||||
const mengenrabatt = product.rabatt?.mengenrabatt ?? [];
|
||||
const bewertungSchnitt = durchschnittsbewertung(product);
|
||||
const bewertungAnzahl = anzahlBewertungen(product);
|
||||
|
||||
const felder: [string, string | undefined][] = [
|
||||
[t.product.articleNo, product.artikelnummer],
|
||||
@@ -71,6 +74,11 @@ const felder: [string, string | undefined][] = [
|
||||
{product.bestand === 0 && <span class="badge badge-sold-out">{t.badge.ausverkauft}</span>}
|
||||
</div>
|
||||
<h1>{name}</h1>
|
||||
{bewertungSchnitt !== null && (
|
||||
<a href="#bewertungen" class="review-summary-link">
|
||||
<HeartRating bewertung={bewertungSchnitt} size={16} showValue={true} anzahlLabel={t.product.reviewCount(bewertungAnzahl)} />
|
||||
</a>
|
||||
)}
|
||||
<div class="price-row-detail">
|
||||
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
|
||||
{showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>}
|
||||
@@ -123,6 +131,33 @@ const felder: [string, string | undefined][] = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight" id="bewertungen">
|
||||
<div class="container">
|
||||
<h2>{t.product.reviewsTitle}</h2>
|
||||
{bewertungSchnitt !== null ? (
|
||||
<>
|
||||
<div class="review-summary-row">
|
||||
<HeartRating bewertung={bewertungSchnitt} size={26} showValue={true} anzahlLabel={t.product.reviewCount(bewertungAnzahl)} />
|
||||
</div>
|
||||
<div class="review-list">
|
||||
{product.bewertungen!.map((r) => (
|
||||
<div class="card review-card">
|
||||
<div class="review-card-head">
|
||||
<span class="review-name">{r.name}</span>
|
||||
<HeartRating bewertung={r.bewertung} size={14} />
|
||||
{r.datum && <span class="review-date small">{r.datum}</span>}
|
||||
</div>
|
||||
{r.text && <p class="small">{r.text[lang]}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p class="small">{t.product.noReviews}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{empfehlungen.length > 0 && (
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Layout from "../../../layouts/Layout.astro";
|
||||
import ProductCard from "../../../components/ProductCard.astro";
|
||||
import { categories, subcategoryChipLabel } from "../../../data/categories";
|
||||
import { products } from "../../../data/products";
|
||||
import { products, durchschnittsbewertung } from "../../../data/products";
|
||||
import type { Locale } from "../../../i18n/config";
|
||||
import { useTranslations } from "../../../i18n/ui";
|
||||
|
||||
@@ -59,6 +59,16 @@ const t = useTranslations(lang);
|
||||
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
|
||||
<input id="f-verfuegbar" type="checkbox" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="f-bewertung">{t.shop.minRatingLabel}</label>
|
||||
<select id="f-bewertung">
|
||||
<option value="0">{t.shop.minRatingAny}</option>
|
||||
<option value="4">🩵🩵🩵🩵 +</option>
|
||||
<option value="3">🩵🩵🩵 +</option>
|
||||
<option value="2">🩵🩵 +</option>
|
||||
<option value="1">🩵 +</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="small">{t.shop.filterNote}</p>
|
||||
</aside>
|
||||
|
||||
@@ -68,6 +78,7 @@ const t = useTranslations(lang);
|
||||
<select id="sort" aria-label={t.shop.sortLabel}>
|
||||
<option value="neu">{t.shop.sortNew}</option>
|
||||
<option value="bestseller">{t.shop.sortBestseller}</option>
|
||||
<option value="bewertung">{t.shop.sortRating}</option>
|
||||
<option value="preis-auf">{t.shop.sortPriceAsc}</option>
|
||||
<option value="preis-ab">{t.shop.sortPriceDesc}</option>
|
||||
</select>
|
||||
@@ -82,6 +93,7 @@ const t = useTranslations(lang);
|
||||
data-bestand={p.bestand}
|
||||
data-neu={p.badges.includes("neu") ? 1 : 0}
|
||||
data-bestseller={p.badges.includes("bestseller") ? 1 : 0}
|
||||
data-bewertung={durchschnittsbewertung(p) ?? 0}
|
||||
>
|
||||
<ProductCard product={p} lang={lang} />
|
||||
</div>
|
||||
@@ -101,6 +113,7 @@ const t = useTranslations(lang);
|
||||
const preisRange = document.getElementById("f-preis");
|
||||
const preisValue = document.getElementById("f-preis-value");
|
||||
const verfuegbarChk = document.getElementById("f-verfuegbar");
|
||||
const bewertungSel = document.getElementById("f-bewertung");
|
||||
const sortSel = document.getElementById("sort");
|
||||
const resultCount = document.getElementById("result-count");
|
||||
const emptyMsg = document.getElementById("empty-msg");
|
||||
@@ -114,6 +127,7 @@ const t = useTranslations(lang);
|
||||
const kat = kategorieSel.value;
|
||||
const maxPreis = Number(preisRange.value);
|
||||
const nurVerfuegbar = verfuegbarChk.checked;
|
||||
const minBewertung = Number(bewertungSel.value);
|
||||
preisValue.textContent = formatPrice(maxPreis);
|
||||
|
||||
let visible = 0;
|
||||
@@ -125,7 +139,8 @@ const t = useTranslations(lang);
|
||||
: slot.dataset.kategorie === kat;
|
||||
const matchPreis = Number(slot.dataset.preis) <= maxPreis;
|
||||
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0;
|
||||
const show = matchKat && matchPreis && matchVerfuegbar;
|
||||
const matchBewertung = Number(slot.dataset.bewertung) >= minBewertung;
|
||||
const show = matchKat && matchPreis && matchVerfuegbar && matchBewertung;
|
||||
slot.style.display = show ? "" : "none";
|
||||
if (show) visible++;
|
||||
});
|
||||
@@ -138,13 +153,14 @@ const t = useTranslations(lang);
|
||||
case "preis-auf": return Number(a.dataset.preis) - Number(b.dataset.preis);
|
||||
case "preis-ab": return Number(b.dataset.preis) - Number(a.dataset.preis);
|
||||
case "bestseller": return Number(b.dataset.bestseller) - Number(a.dataset.bestseller);
|
||||
case "bewertung": return Number(b.dataset.bewertung) - Number(a.dataset.bewertung);
|
||||
default: return Number(b.dataset.neu) - Number(a.dataset.neu);
|
||||
}
|
||||
});
|
||||
sorted.forEach((el) => grid.appendChild(el));
|
||||
}
|
||||
|
||||
[preisRange, verfuegbarChk, sortSel].forEach((el) => el.addEventListener("input", apply));
|
||||
[preisRange, verfuegbarChk, bewertungSel, sortSel].forEach((el) => el.addEventListener("input", apply));
|
||||
|
||||
const catFilter = document.getElementById("cat-filter");
|
||||
const catFilterBtn = document.getElementById("cat-filter-btn");
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import ProductCard from "../../components/ProductCard.astro";
|
||||
import HeartRating from "../../components/HeartRating.astro";
|
||||
import { categories } from "../../data/categories";
|
||||
import { products } from "../../data/products";
|
||||
import { products, alleBewertungen, gesamtDurchschnitt } from "../../data/products";
|
||||
import { useTranslations } from "../../i18n/ui";
|
||||
import type { Locale } from "../../i18n/config";
|
||||
|
||||
const lang: Locale = "fr";
|
||||
const t = useTranslations(lang);
|
||||
|
||||
const neuheiten = products.filter((p) => p.badges.includes("neu")).slice(0, 4);
|
||||
const bestseller = products.filter((p) => p.badges.includes("bestseller")).slice(0, 4);
|
||||
const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).slice(0, 4);
|
||||
const topBewertungen = alleBewertungen().slice(0, 3);
|
||||
const bewertungSchnittGesamt = gesamtDurchschnitt();
|
||||
---
|
||||
<Layout title="Accueil" lang={lang} path="/">
|
||||
<section class="hero">
|
||||
@@ -84,6 +89,31 @@ const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).sli
|
||||
</section>
|
||||
)}
|
||||
|
||||
{topBewertungen.length > 0 && bewertungSchnittGesamt !== null && (
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<span class="eyebrow">🩵 {t.reviews.heading}</span>
|
||||
<h2>{t.reviews.heading}</h2>
|
||||
<p class="lead">{t.reviews.lead}</p>
|
||||
<div class="review-summary-row">
|
||||
<HeartRating bewertung={bewertungSchnittGesamt} size={24} showValue={true} anzahlLabel={t.reviews.basedOn(alleBewertungen().length)} />
|
||||
</div>
|
||||
<div class="grid grid-3">
|
||||
{topBewertungen.map((r) => (
|
||||
<div class="card review-card">
|
||||
<div class="review-card-head">
|
||||
<span class="review-name">{r.name}</span>
|
||||
<HeartRating bewertung={r.bewertung} size={14} />
|
||||
</div>
|
||||
{r.text && <p class="small">{r.text[lang]}</p>}
|
||||
<a class="small" href={`/fr/produkt/${r.produktSlug}/`}>{t.reviews.toProduct} {r.produktName[lang]}</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section class="section">
|
||||
<div class="container grid grid-2 creator-section">
|
||||
<div class="portrait-frame creator-photo">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
import Layout from "../../../layouts/Layout.astro";
|
||||
import ProductCard from "../../../components/ProductCard.astro";
|
||||
import { products, getProduct, productsByCategory } from "../../../data/products";
|
||||
import HeartRating from "../../../components/HeartRating.astro";
|
||||
import { products, getProduct, productsByCategory, durchschnittsbewertung, anzahlBewertungen } from "../../../data/products";
|
||||
import { getCategory } from "../../../data/categories";
|
||||
import type { Locale } from "../../../i18n/config";
|
||||
import { useTranslations } from "../../../i18n/ui";
|
||||
@@ -27,6 +28,8 @@ const finalPrice = effektiverPreis(product);
|
||||
const discountBadge = rabattLabel(product);
|
||||
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
|
||||
const mengenrabatt = product.rabatt?.mengenrabatt ?? [];
|
||||
const bewertungSchnitt = durchschnittsbewertung(product);
|
||||
const bewertungAnzahl = anzahlBewertungen(product);
|
||||
|
||||
const felder: [string, string | undefined][] = [
|
||||
[t.product.articleNo, product.artikelnummer],
|
||||
@@ -71,6 +74,11 @@ const felder: [string, string | undefined][] = [
|
||||
{product.bestand === 0 && <span class="badge badge-sold-out">{t.badge.ausverkauft}</span>}
|
||||
</div>
|
||||
<h1>{name}</h1>
|
||||
{bewertungSchnitt !== null && (
|
||||
<a href="#bewertungen" class="review-summary-link">
|
||||
<HeartRating bewertung={bewertungSchnitt} size={16} showValue={true} anzahlLabel={t.product.reviewCount(bewertungAnzahl)} />
|
||||
</a>
|
||||
)}
|
||||
<div class="price-row-detail">
|
||||
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
|
||||
{showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>}
|
||||
@@ -123,6 +131,33 @@ const felder: [string, string | undefined][] = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight" id="bewertungen">
|
||||
<div class="container">
|
||||
<h2>{t.product.reviewsTitle}</h2>
|
||||
{bewertungSchnitt !== null ? (
|
||||
<>
|
||||
<div class="review-summary-row">
|
||||
<HeartRating bewertung={bewertungSchnitt} size={26} showValue={true} anzahlLabel={t.product.reviewCount(bewertungAnzahl)} />
|
||||
</div>
|
||||
<div class="review-list">
|
||||
{product.bewertungen!.map((r) => (
|
||||
<div class="card review-card">
|
||||
<div class="review-card-head">
|
||||
<span class="review-name">{r.name}</span>
|
||||
<HeartRating bewertung={r.bewertung} size={14} />
|
||||
{r.datum && <span class="review-date small">{r.datum}</span>}
|
||||
</div>
|
||||
{r.text && <p class="small">{r.text[lang]}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p class="small">{t.product.noReviews}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{empfehlungen.length > 0 && (
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Layout from "../../../layouts/Layout.astro";
|
||||
import ProductCard from "../../../components/ProductCard.astro";
|
||||
import { categories, subcategoryChipLabel } from "../../../data/categories";
|
||||
import { products } from "../../../data/products";
|
||||
import { products, durchschnittsbewertung } from "../../../data/products";
|
||||
import type { Locale } from "../../../i18n/config";
|
||||
import { useTranslations } from "../../../i18n/ui";
|
||||
|
||||
@@ -59,6 +59,16 @@ const t = useTranslations(lang);
|
||||
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
|
||||
<input id="f-verfuegbar" type="checkbox" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="f-bewertung">{t.shop.minRatingLabel}</label>
|
||||
<select id="f-bewertung">
|
||||
<option value="0">{t.shop.minRatingAny}</option>
|
||||
<option value="4">🩵🩵🩵🩵 +</option>
|
||||
<option value="3">🩵🩵🩵 +</option>
|
||||
<option value="2">🩵🩵 +</option>
|
||||
<option value="1">🩵 +</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="small">{t.shop.filterNote}</p>
|
||||
</aside>
|
||||
|
||||
@@ -68,6 +78,7 @@ const t = useTranslations(lang);
|
||||
<select id="sort" aria-label={t.shop.sortLabel}>
|
||||
<option value="neu">{t.shop.sortNew}</option>
|
||||
<option value="bestseller">{t.shop.sortBestseller}</option>
|
||||
<option value="bewertung">{t.shop.sortRating}</option>
|
||||
<option value="preis-auf">{t.shop.sortPriceAsc}</option>
|
||||
<option value="preis-ab">{t.shop.sortPriceDesc}</option>
|
||||
</select>
|
||||
@@ -82,6 +93,7 @@ const t = useTranslations(lang);
|
||||
data-bestand={p.bestand}
|
||||
data-neu={p.badges.includes("neu") ? 1 : 0}
|
||||
data-bestseller={p.badges.includes("bestseller") ? 1 : 0}
|
||||
data-bewertung={durchschnittsbewertung(p) ?? 0}
|
||||
>
|
||||
<ProductCard product={p} lang={lang} />
|
||||
</div>
|
||||
@@ -101,6 +113,7 @@ const t = useTranslations(lang);
|
||||
const preisRange = document.getElementById("f-preis");
|
||||
const preisValue = document.getElementById("f-preis-value");
|
||||
const verfuegbarChk = document.getElementById("f-verfuegbar");
|
||||
const bewertungSel = document.getElementById("f-bewertung");
|
||||
const sortSel = document.getElementById("sort");
|
||||
const resultCount = document.getElementById("result-count");
|
||||
const emptyMsg = document.getElementById("empty-msg");
|
||||
@@ -114,6 +127,7 @@ const t = useTranslations(lang);
|
||||
const kat = kategorieSel.value;
|
||||
const maxPreis = Number(preisRange.value);
|
||||
const nurVerfuegbar = verfuegbarChk.checked;
|
||||
const minBewertung = Number(bewertungSel.value);
|
||||
preisValue.textContent = formatPrice(maxPreis);
|
||||
|
||||
let visible = 0;
|
||||
@@ -125,7 +139,8 @@ const t = useTranslations(lang);
|
||||
: slot.dataset.kategorie === kat;
|
||||
const matchPreis = Number(slot.dataset.preis) <= maxPreis;
|
||||
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0;
|
||||
const show = matchKat && matchPreis && matchVerfuegbar;
|
||||
const matchBewertung = Number(slot.dataset.bewertung) >= minBewertung;
|
||||
const show = matchKat && matchPreis && matchVerfuegbar && matchBewertung;
|
||||
slot.style.display = show ? "" : "none";
|
||||
if (show) visible++;
|
||||
});
|
||||
@@ -138,13 +153,14 @@ const t = useTranslations(lang);
|
||||
case "preis-auf": return Number(a.dataset.preis) - Number(b.dataset.preis);
|
||||
case "preis-ab": return Number(b.dataset.preis) - Number(a.dataset.preis);
|
||||
case "bestseller": return Number(b.dataset.bestseller) - Number(a.dataset.bestseller);
|
||||
case "bewertung": return Number(b.dataset.bewertung) - Number(a.dataset.bewertung);
|
||||
default: return Number(b.dataset.neu) - Number(a.dataset.neu);
|
||||
}
|
||||
});
|
||||
sorted.forEach((el) => grid.appendChild(el));
|
||||
}
|
||||
|
||||
[preisRange, verfuegbarChk, sortSel].forEach((el) => el.addEventListener("input", apply));
|
||||
[preisRange, verfuegbarChk, bewertungSel, sortSel].forEach((el) => el.addEventListener("input", apply));
|
||||
|
||||
const catFilter = document.getElementById("cat-filter");
|
||||
const catFilterBtn = document.getElementById("cat-filter-btn");
|
||||
|
||||
+34
-1
@@ -1,15 +1,23 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import ProductCard from "../components/ProductCard.astro";
|
||||
import HeartRating from "../components/HeartRating.astro";
|
||||
import { categories } from "../data/categories";
|
||||
import { products } from "../data/products";
|
||||
import { products, alleBewertungen, gesamtDurchschnitt } from "../data/products";
|
||||
import { useTranslations } from "../i18n/ui";
|
||||
import type { Locale } from "../i18n/config";
|
||||
|
||||
const lang: Locale = "de";
|
||||
const t = useTranslations(lang);
|
||||
|
||||
const neuheiten = products.filter((p) => p.badges.includes("neu")).slice(0, 4);
|
||||
const bestseller = products.filter((p) => p.badges.includes("bestseller")).slice(0, 4);
|
||||
const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).slice(0, 4);
|
||||
// Kundenstimmen-Sektion — erscheint erst automatisch, sobald VanVan im Adminbereich die erste
|
||||
// ECHTE Rezension einträgt (siehe Bewertung-Feld bei den Produkten). Bewusst KEINE erfundenen
|
||||
// Beispiel-Bewertungen im Code, da Fake-Bewertungen in Deutschland/der EU verboten sind.
|
||||
const topBewertungen = alleBewertungen().slice(0, 3);
|
||||
const bewertungSchnittGesamt = gesamtDurchschnitt();
|
||||
---
|
||||
<Layout title="Startseite" lang={lang} path="/">
|
||||
<section class="hero">
|
||||
@@ -84,6 +92,31 @@ const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).sli
|
||||
</section>
|
||||
)}
|
||||
|
||||
{topBewertungen.length > 0 && bewertungSchnittGesamt !== null && (
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
<span class="eyebrow">🩵 {t.reviews.heading}</span>
|
||||
<h2>{t.reviews.heading}</h2>
|
||||
<p class="lead">{t.reviews.lead}</p>
|
||||
<div class="review-summary-row">
|
||||
<HeartRating bewertung={bewertungSchnittGesamt} size={24} showValue={true} anzahlLabel={t.reviews.basedOn(alleBewertungen().length)} />
|
||||
</div>
|
||||
<div class="grid grid-3">
|
||||
{topBewertungen.map((r) => (
|
||||
<div class="card review-card">
|
||||
<div class="review-card-head">
|
||||
<span class="review-name">{r.name}</span>
|
||||
<HeartRating bewertung={r.bewertung} size={14} />
|
||||
</div>
|
||||
{r.text && <p class="small">{r.text[lang]}</p>}
|
||||
<a class="small" href={`/produkt/${r.produktSlug}/`}>{t.reviews.toProduct} {r.produktName[lang]}</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section class="section">
|
||||
<div class="container grid grid-2 creator-section">
|
||||
<div class="portrait-frame creator-photo">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import ProductCard from "../../components/ProductCard.astro";
|
||||
import { products, getProduct, productsByCategory } from "../../data/products";
|
||||
import HeartRating from "../../components/HeartRating.astro";
|
||||
import { products, getProduct, productsByCategory, durchschnittsbewertung, anzahlBewertungen } from "../../data/products";
|
||||
import { getCategory } from "../../data/categories";
|
||||
import type { Locale } from "../../i18n/config";
|
||||
import { useTranslations } from "../../i18n/ui";
|
||||
@@ -27,6 +28,8 @@ const finalPrice = effektiverPreis(product);
|
||||
const discountBadge = rabattLabel(product);
|
||||
const showOldAsPreis = !product.preisAlt && hatAktivenRabatt(product);
|
||||
const mengenrabatt = product.rabatt?.mengenrabatt ?? [];
|
||||
const bewertungSchnitt = durchschnittsbewertung(product);
|
||||
const bewertungAnzahl = anzahlBewertungen(product);
|
||||
|
||||
const felder: [string, string | undefined][] = [
|
||||
[t.product.articleNo, product.artikelnummer],
|
||||
@@ -71,6 +74,11 @@ const felder: [string, string | undefined][] = [
|
||||
{product.bestand === 0 && <span class="badge badge-sold-out">{t.badge.ausverkauft}</span>}
|
||||
</div>
|
||||
<h1>{name}</h1>
|
||||
{bewertungSchnitt !== null && (
|
||||
<a href="#bewertungen" class="review-summary-link">
|
||||
<HeartRating bewertung={bewertungSchnitt} size={16} showValue={true} anzahlLabel={t.product.reviewCount(bewertungAnzahl)} />
|
||||
</a>
|
||||
)}
|
||||
<div class="price-row-detail">
|
||||
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
|
||||
{showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>}
|
||||
@@ -123,6 +131,33 @@ const felder: [string, string | undefined][] = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-tight" id="bewertungen">
|
||||
<div class="container">
|
||||
<h2>{t.product.reviewsTitle}</h2>
|
||||
{bewertungSchnitt !== null ? (
|
||||
<>
|
||||
<div class="review-summary-row">
|
||||
<HeartRating bewertung={bewertungSchnitt} size={26} showValue={true} anzahlLabel={t.product.reviewCount(bewertungAnzahl)} />
|
||||
</div>
|
||||
<div class="review-list">
|
||||
{product.bewertungen!.map((r) => (
|
||||
<div class="card review-card">
|
||||
<div class="review-card-head">
|
||||
<span class="review-name">{r.name}</span>
|
||||
<HeartRating bewertung={r.bewertung} size={14} />
|
||||
{r.datum && <span class="review-date small">{r.datum}</span>}
|
||||
</div>
|
||||
{r.text && <p class="small">{r.text[lang]}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p class="small">{t.product.noReviews}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{empfehlungen.length > 0 && (
|
||||
<section class="section-tight">
|
||||
<div class="container">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import ProductCard from "../../components/ProductCard.astro";
|
||||
import { categories, subcategoryChipLabel } from "../../data/categories";
|
||||
import { products } from "../../data/products";
|
||||
import { products, durchschnittsbewertung } from "../../data/products";
|
||||
import type { Locale } from "../../i18n/config";
|
||||
import { useTranslations } from "../../i18n/ui";
|
||||
|
||||
@@ -64,6 +64,16 @@ const t = useTranslations(lang);
|
||||
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
|
||||
<input id="f-verfuegbar" type="checkbox" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="f-bewertung">{t.shop.minRatingLabel}</label>
|
||||
<select id="f-bewertung">
|
||||
<option value="0">{t.shop.minRatingAny}</option>
|
||||
<option value="4">🩵🩵🩵🩵 +</option>
|
||||
<option value="3">🩵🩵🩵 +</option>
|
||||
<option value="2">🩵🩵 +</option>
|
||||
<option value="1">🩵 +</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="small">{t.shop.filterNote}</p>
|
||||
</aside>
|
||||
|
||||
@@ -73,6 +83,7 @@ const t = useTranslations(lang);
|
||||
<select id="sort" aria-label={t.shop.sortLabel}>
|
||||
<option value="neu">{t.shop.sortNew}</option>
|
||||
<option value="bestseller">{t.shop.sortBestseller}</option>
|
||||
<option value="bewertung">{t.shop.sortRating}</option>
|
||||
<option value="preis-auf">{t.shop.sortPriceAsc}</option>
|
||||
<option value="preis-ab">{t.shop.sortPriceDesc}</option>
|
||||
</select>
|
||||
@@ -87,6 +98,7 @@ const t = useTranslations(lang);
|
||||
data-bestand={p.bestand}
|
||||
data-neu={p.badges.includes("neu") ? 1 : 0}
|
||||
data-bestseller={p.badges.includes("bestseller") ? 1 : 0}
|
||||
data-bewertung={durchschnittsbewertung(p) ?? 0}
|
||||
>
|
||||
<ProductCard product={p} lang={lang} />
|
||||
</div>
|
||||
@@ -106,6 +118,7 @@ const t = useTranslations(lang);
|
||||
const preisRange = document.getElementById("f-preis");
|
||||
const preisValue = document.getElementById("f-preis-value");
|
||||
const verfuegbarChk = document.getElementById("f-verfuegbar");
|
||||
const bewertungSel = document.getElementById("f-bewertung");
|
||||
const sortSel = document.getElementById("sort");
|
||||
const resultCount = document.getElementById("result-count");
|
||||
const emptyMsg = document.getElementById("empty-msg");
|
||||
@@ -119,6 +132,7 @@ const t = useTranslations(lang);
|
||||
const kat = kategorieSel.value;
|
||||
const maxPreis = Number(preisRange.value);
|
||||
const nurVerfuegbar = verfuegbarChk.checked;
|
||||
const minBewertung = Number(bewertungSel.value);
|
||||
preisValue.textContent = formatPrice(maxPreis);
|
||||
|
||||
let visible = 0;
|
||||
@@ -130,7 +144,8 @@ const t = useTranslations(lang);
|
||||
: slot.dataset.kategorie === kat;
|
||||
const matchPreis = Number(slot.dataset.preis) <= maxPreis;
|
||||
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0;
|
||||
const show = matchKat && matchPreis && matchVerfuegbar;
|
||||
const matchBewertung = Number(slot.dataset.bewertung) >= minBewertung;
|
||||
const show = matchKat && matchPreis && matchVerfuegbar && matchBewertung;
|
||||
slot.style.display = show ? "" : "none";
|
||||
if (show) visible++;
|
||||
});
|
||||
@@ -143,13 +158,14 @@ const t = useTranslations(lang);
|
||||
case "preis-auf": return Number(a.dataset.preis) - Number(b.dataset.preis);
|
||||
case "preis-ab": return Number(b.dataset.preis) - Number(a.dataset.preis);
|
||||
case "bestseller": return Number(b.dataset.bestseller) - Number(a.dataset.bestseller);
|
||||
case "bewertung": return Number(b.dataset.bewertung) - Number(a.dataset.bewertung);
|
||||
default: return Number(b.dataset.neu) - Number(a.dataset.neu);
|
||||
}
|
||||
});
|
||||
sorted.forEach((el) => grid.appendChild(el));
|
||||
}
|
||||
|
||||
[preisRange, verfuegbarChk, sortSel].forEach((el) => el.addEventListener("input", apply));
|
||||
[preisRange, verfuegbarChk, bewertungSel, sortSel].forEach((el) => el.addEventListener("input", apply));
|
||||
|
||||
// Eigenes Kategorie-Dropdown (statt nativem <select>): Klick auf den Button öffnet/schließt
|
||||
// die Liste, Klick auf eine Zeile setzt den Filter, aktualisiert die Anzeige-Beschriftung und
|
||||
|
||||
@@ -1125,6 +1125,23 @@ a:focus-visible, button:focus-visible {
|
||||
}
|
||||
.thumb.product-photo:hover { box-shadow: 0 0 0 3px rgba(10, 9, 16, 0.6), 0 0 0 4px var(--c-accent); }
|
||||
.badge-row { display: flex; gap: 0.5rem; margin-bottom: 0.8rem; flex-wrap: wrap; }
|
||||
|
||||
/* Kundenrezensionen — Herzen in Lila-Babyblau statt Sterne (siehe HeartRating.astro), damit
|
||||
Bewertungen sich erkennbar in die restliche Seite einfügen statt wie ein fremdes Bewertungs-
|
||||
Widget zu wirken. */
|
||||
.review-summary-link { display: inline-flex; text-decoration: none; margin: 0.3rem 0 0.9rem; }
|
||||
.review-summary-link:hover .heart-value { color: var(--c-accent); }
|
||||
.review-summary-row { margin-bottom: 1.6rem; }
|
||||
.review-list { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.review-card { gap: 0.5rem; display: flex; flex-direction: column; }
|
||||
.review-card-head { display: flex; align-items: center; gap: 0.8rem; flex-wrap: wrap; }
|
||||
.review-name { font-weight: 700; }
|
||||
.review-date { color: var(--c-text-muted); margin-left: auto; }
|
||||
.review-card p { color: var(--c-text-muted); }
|
||||
/* Bewertungszeile auf der Produktkarte — dicht unter dem Titel, kein zusätzlicher Abstand nötig,
|
||||
da .product-card schon einen einheitlichen gap zwischen allen Kindern hat. */
|
||||
.product-card .heart-rating { margin-top: -0.2rem; }
|
||||
|
||||
.price-row-detail { display: flex; align-items: baseline; gap: 0.8rem; margin-bottom: 0.2rem; flex-wrap: wrap; }
|
||||
.price-detail { font-size: 1.8rem; font-weight: 700; color: var(--c-accent); }
|
||||
/* Durchgestrichener alter Preis bei Rabatt — global, damit das auch auf der Produktseite gilt
|
||||
|
||||
Reference in New Issue
Block a user