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
@@ -134,6 +134,25 @@ collections:
|
||||
- { label: Lieferumfang, name: lieferumfang, widget: string, required: false }
|
||||
- { label: Pflegehinweise, name: pflegehinweise, widget: string, required: false }
|
||||
- { label: Lieferzeit, name: lieferzeit, widget: string, required: false }
|
||||
- label: "🩵 Kundenrezensionen"
|
||||
name: bewertungen
|
||||
widget: list
|
||||
required: false
|
||||
summary: "{{fields.name}} — {{fields.bewertung}}/5"
|
||||
hint: "WICHTIG: Hier nur echte Rückmeldungen deiner Kund:innen eintragen (z.B. per E-Mail/Nachricht erhalten) — erfundene Bewertungen sind in Deutschland und der EU gesetzlich verboten (Gesetz gegen unlauteren Wettbewerb). Ohne Eintrag hier bleibt der Bewertungsbereich beim Produkt einfach leer, das ist völlig normal für neue Produkte."
|
||||
fields:
|
||||
- { label: "Name (z.B. Vorname + Initiale des Nachnamens)", name: name, widget: string }
|
||||
- { label: "Bewertung (1-5 Herzen)", name: bewertung, widget: number, value_type: int, min: 1, max: 5 }
|
||||
- label: "Text der Bewertung (optional)"
|
||||
name: text
|
||||
widget: object
|
||||
required: false
|
||||
fields:
|
||||
- { label: Deutsch, name: de, widget: text, required: false }
|
||||
- { label: Englisch, name: en, widget: text, required: false }
|
||||
- { label: "Schweizerdeutsch", name: ch, widget: text, required: false }
|
||||
- { label: Französisch, name: fr, widget: text, required: false }
|
||||
- { label: "Datum (optional)", name: datum, widget: datetime, format: "YYYY-MM-DD", date_format: "YYYY-MM-DD", time_format: false, required: false }
|
||||
- label: "Interner Code (URL) — nach Veröffentlichung nicht mehr ändern!"
|
||||
name: slug
|
||||
widget: string
|
||||
|
||||
@@ -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