Files
vans-diy-bastelbedarf/src/components/ProductCard.astro
T
qciganoandClaude Sonnet 5 6f10e41386 Kundenrezensionen jetzt vollständig durch Kund:innen selbst, nicht mehr über Admin
Bisher musste VanVan Bewertungen manuell im CMS eintragen. Jetzt können
angemeldete Kund:innen Rezensionen direkt auf der Produktseite abgeben
(inkl. Herzen-Sternebewertung und Text). Die Durchschnittsbewertung wird
automatisch berechnet und überall live eingebunden: Produktkarten
(shop-weit), Produktdetailseite, Shop-Filter/Sortierung nach Bewertung
und die Kundenstimmen-Sektion auf der Startseite.

Neu dazu: ein Kontobereich fürs Login (Demo-Login per E-Mail), Profilfoto-
Upload, wählbarer Benutzername, und ein Header-Kontomenü, das nur bei
Anmeldung erscheint. Jede Bestellung im Konto-Dashboard hat einen
"Rezension schreiben"-Link, der direkt zur Produktseite führt und dort
per Bestellnummer verknüpft wird; im Profil ist dann sofort sichtbar,
welche Bestellungen schon bewertet wurden.

Technisch als reines Frontend/localStorage-System umgesetzt (kein Backend
in dieser Phase), analog zum bestehenden Warenkorb-Muster. Admin-CMS-Feld
für manuelle Bewertungen entfernt, da Rezensionen jetzt ausschließlich von
Kund:innen kommen (auch aus rechtlichen Gründen: keine Fake-Bewertungen).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-03 04:17:04 +02:00

258 lines
12 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
import type { Product } from "../data/products";
import type { Locale } from "../i18n/config";
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;
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,
bestseller: t.badge.bestseller,
sale: t.badge.sale,
handgemacht: t.badge.handgemacht,
};
const name = product.name[lang];
const desc = product.beschreibung[lang];
---
<a href={`${localePrefix(lang)}/produkt/${product.slug}/`} class="card product-card" data-slug={product.slug}>
<div class="product-media" data-carousel>
{product.bilder && product.bilder.length > 0 ? (
<>
{product.bilder.map((bild, i) => (
<img class="product-photo carousel-img" src={bild} alt={name} loading="lazy" data-idx={i} style={i === 0 ? undefined : "display:none;"} />
))}
{product.bilder.length > 1 && (
<>
<button type="button" class="carousel-btn carousel-prev" data-dir="-1" aria-label={t.common.prevPhoto}></button>
<button type="button" class="carousel-btn carousel-next" data-dir="1" aria-label={t.common.nextPhoto}></button>
<div class="carousel-dots">
{product.bilder.map((_, i) => (
<span class={`carousel-dot ${i === 0 ? "is-active" : ""}`} data-idx={i}></span>
))}
</div>
</>
)}
</>
) : (
<div class="img-placeholder" role="img" aria-label={`${t.common.photoSoon}: ${name}`}>
{t.common.photoSoon}<br /><span class="small">{name}</span>
</div>
)}
<div class="product-badges">
{product.badges.map((b) => (
<span class={`badge ${b === "sale" ? "badge-sale" : ""}`}>{badgeLabel[b]}</span>
))}
{product.bestand === 0 && <span class="badge badge-sold-out">{t.badge.ausverkauft}</span>}
{discountBadge && <span class="badge badge-rabatt">{discountBadge}</span>}
</div>
</div>
<h3>{name}</h3>
{/* Immer gerendert (auch ohne Server-Bewertungen), aber standardmäßig versteckt — ein
gemeinsames Skript (siehe unten) blendet die Zeile automatisch ein, sobald auf diesem
Gerät lokal gespeicherte Kundenrezensionen für dieses Produkt dazukommen (siehe
scripts/reviews.ts), egal auf welcher Seite die Karte gerade angezeigt wird. */}
<span
class="card-rating"
data-rating-slot
data-basis-schnitt={bewertungSchnitt ?? 0}
data-basis-anzahl={bewertungAnzahl}
data-count-one={t.product.reviewCount(1)}
data-count-other={t.product.reviewCount(2)}
style={bewertungSchnitt === null ? "display:none;" : undefined}
>
{bewertungSchnitt !== null && (
<HeartRating bewertung={bewertungSchnitt} size={13} anzahlLabel={`(${bewertungAnzahl})`} />
)}
</span>
<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>}
{showOldAsPreis && <span class="price-old">{formatPrice(product.preis, lang)}</span>}
<span class="price">{formatPrice(finalPrice, lang)}</span>
</div>
</a>
<script>
// Mini-Bildergalerie direkt auf der Karte (Shop-Grid, Charakter-Kacheln, "Weitere Modelle" ...):
// sobald ein Produkt mehr als ein Foto hat, kann man hier sofort mit / durchblättern, ohne
// erst die Produktseite öffnen zu müssen. EIN einziger, delegierter Klick-Listener für alle
// Karten der Seite (Astro dedupliziert dieses <script> automatisch, egal wie oft ProductCard
// auf der Seite eingebunden wird — kein Mehrfach-Registrieren). preventDefault/stopPropagation
// ist nötig, weil die ganze Karte selbst ein <a>-Link ist — sonst würde ein Klick auf den Pfeil
// gleichzeitig zur Produktseite weiterspringen.
function karussellWechseln(media: Element, nextIdx: number) {
const imgs = [...media.querySelectorAll<HTMLElement>(".carousel-img")];
const dots = [...media.querySelectorAll<HTMLElement>(".carousel-dot")];
if (imgs.length === 0) return;
const clamped = ((nextIdx % imgs.length) + imgs.length) % imgs.length;
imgs.forEach((img, i) => { img.style.display = i === clamped ? "" : "none"; });
dots.forEach((dot, i) => dot.classList.toggle("is-active", i === clamped));
}
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
const control = target.closest<HTMLElement>(".carousel-btn, .carousel-dot");
if (!control) return;
const media = control.closest("[data-carousel]");
if (!media) return;
e.preventDefault();
e.stopPropagation();
const imgs = [...media.querySelectorAll<HTMLElement>(".carousel-img")];
const current = Math.max(0, imgs.findIndex((img) => img.style.display !== "none"));
if (control.classList.contains("carousel-dot")) {
karussellWechseln(media, Number(control.dataset.idx));
} else {
karussellWechseln(media, current + Number(control.dataset.dir));
}
});
// Wischen/Swipen (Finger auf dem Handy, Maus ziehen am PC) — überall auf der Seite, wo eine
// Produktkarte mit mehreren Fotos vorkommt. Pointer Events statt separater Touch-/Maus-Logik,
// damit EIN Code sowohl Finger als auch Maus abdeckt. Ein "Swipe" zählt erst ab einer gewissen
// Mindeststrecke (SWIPE_SCHWELLE) und nur, wenn die Bewegung überwiegend horizontal war — sonst
// würde normales Scrollen der Seite (vertikal) versehentlich als Wisch-Geste missverstanden.
const SWIPE_SCHWELLE = 40;
let wischStart: { media: Element; x: number; y: number } | null = null;
let letzterWischZeitpunkt = 0;
document.addEventListener("pointerdown", (e) => {
const media = (e.target as HTMLElement).closest("[data-carousel]");
if (!media) return;
if (media.querySelectorAll(".carousel-img").length <= 1) return;
wischStart = { media, x: e.clientX, y: e.clientY };
});
document.addEventListener("pointerup", (e) => {
if (!wischStart) return;
const { media, x, y } = wischStart;
wischStart = null;
const dx = e.clientX - x;
const dy = e.clientY - y;
if (Math.abs(dx) < SWIPE_SCHWELLE || Math.abs(dx) < Math.abs(dy)) return;
letzterWischZeitpunkt = Date.now();
const imgs = [...media.querySelectorAll<HTMLElement>(".carousel-img")];
const current = Math.max(0, imgs.findIndex((img) => img.style.display !== "none"));
karussellWechseln(media, current + (dx < 0 ? 1 : -1));
});
document.addEventListener("pointercancel", () => { wischStart = null; });
// Direkt nach einem echten Wisch darf der Klick NICHT mehr zur Produktseite weiterspringen
// (sonst landet man ungewollt auf der Produktseite, nur weil man das Foto durchblättern
// wollte). Capture-Phase, damit das VOR dem normalen Link-Klick des <a class="product-card">
// greift. Ein kurzes Zeitfenster (300ms) reicht, da Klick direkt nach dem Loslassen kommt.
document.addEventListener("click", (e) => {
if (Date.now() - letzterWischZeitpunkt > 300) return;
const link = (e.target as HTMLElement).closest("a.product-card");
if (link) { e.preventDefault(); e.stopPropagation(); }
}, true);
</script>
<script>
// Bewertungszeile auf JEDER Produktkarte der Seite (Shop-Grid, Kategorie-Seiten, Startseite,
// "Das könnte dir auch gefallen" ...) live mit den lokal auf diesem Gerät gespeicherten
// Kundenrezensionen zusammenführen (siehe scripts/reviews.ts) — Astro rendert die Karte selbst
// nur mit dem Bau-Zeit-Stand (VanVans Admin-Daten), neue Rezensionen von Kund:innen kommen erst
// im Browser dazu. Ein einziges, delegiertes Skript für alle Karten (Astro dedupliziert dieses
// <script> automatisch, egal wie oft ProductCard auf der Seite eingebunden wird).
import { heartsHtml } from "../scripts/hearts";
import { lokaleBewertungenFuer } from "../scripts/reviews";
function reviewCountLabel(slot: HTMLElement, n: number): string {
if (n === 1) return slot.dataset.countOne!;
return slot.dataset.countOther!.replace(/^\d+/, String(n));
}
document.querySelectorAll<HTMLElement>("[data-rating-slot]").forEach((slot) => {
const card = slot.closest<HTMLElement>(".product-card");
const slug = card?.dataset.slug;
if (!slug) return;
const lokale = lokaleBewertungenFuer(slug);
if (lokale.length === 0) return; // Bau-Zeit-Stand reicht bereits, nichts zu ergänzen
const basisSchnitt = Number(slot.dataset.basisSchnitt || 0);
const basisAnzahl = Number(slot.dataset.basisAnzahl || 0);
const summe = basisSchnitt * basisAnzahl + lokale.reduce((s, r) => s + r.bewertung, 0);
const anzahl = basisAnzahl + lokale.length;
const schnitt = summe / anzahl;
slot.innerHTML = heartsHtml(schnitt, 13, { anzahlLabel: `(${anzahl})` });
slot.style.display = "";
});
</script>
<style>
.product-card { display: flex; flex-direction: column; gap: 0.6rem; padding: 1rem; }
.product-media { position: relative; isolation: isolate; touch-action: pan-y; }
.product-media .img-placeholder { aspect-ratio: 1 / 1; min-height: 0; }
.product-badges { position: absolute; top: 0.6rem; left: 0.6rem; display: flex; gap: 0.4rem; flex-wrap: wrap; z-index: 2; }
/* Mini-Karussell: Pfeile links/rechts direkt auf der Karte, sobald ein Produkt mehr als ein
Foto hat. Dezent, aber immer sichtbar (nicht nur bei Hover) — sonst wären sie auf
Touch-Geräten (Handy/Tablet, kein Hover) gar nicht erreichbar. */
.carousel-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid rgba(244, 241, 247, 0.18);
background: rgba(10, 9, 16, 0.55);
color: #fff;
font-size: 1.15rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
backdrop-filter: blur(3px);
transition: background 0.2s var(--ease), border-color 0.2s var(--ease), transform 0.2s var(--ease);
}
.carousel-btn:hover { background: rgba(10, 9, 16, 0.85); border-color: var(--c-accent); }
.carousel-prev { left: 0.5rem; }
.carousel-next { right: 0.5rem; }
.carousel-prev:hover { transform: translateY(-50%) translateX(-2px); }
.carousel-next:hover { transform: translateY(-50%) translateX(2px); }
.carousel-dots {
position: absolute;
bottom: 0.55rem;
left: 0;
right: 0;
z-index: 2;
display: flex;
justify-content: center;
gap: 0.35rem;
}
.carousel-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.45);
border: 1px solid rgba(10, 9, 16, 0.4);
cursor: pointer;
transition: background 0.2s var(--ease), transform 0.2s var(--ease);
}
.carousel-dot.is-active { background: var(--c-accent); transform: scale(1.25); }
.product-card h3 { font-size: 1.05rem; margin: 0.2rem 0 0; transition: color 0.2s var(--ease); }
.product-card:hover h3 { color: var(--c-accent); }
.product-card p { margin: 0; }
.price-row { margin-top: auto; display: flex; align-items: baseline; gap: 0.6rem; }
.price { color: var(--c-accent); font-weight: 700; font-size: 1.15rem; }
.price-old { color: var(--c-text-muted); text-decoration: line-through; font-size: 0.9rem; }
.badge-rabatt { background: #ff5f7e; color: #fff; border-color: #ff5f7e; }
</style>