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]>
258 lines
12 KiB
Plaintext
258 lines
12 KiB
Plaintext
---
|
||
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>
|