Files
vans-diy-bastelbedarf/src/pages/fr/shop/index.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

255 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 Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.astro";
import HeartRating from "../../../components/HeartRating.astro";
import { categories, subcategoryChipLabel } from "../../../data/categories";
import { products, durchschnittsbewertung, anzahlBewertungen } from "../../../data/products";
import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui";
const lang: Locale = "fr";
const t = useTranslations(lang);
---
<Layout title="Boutique" description="La boutique complète Van's DIY & Bastelbedarf crochet, peluches, bijoux et fournitures créatives." lang={lang} path="/shop/">
<div class="container">
<section class="page-banner" style="background-image:url('/img/banner-shop.webp'); --banner-ratio: 1600 / 729;">
<div class="page-banner-content">
<span class="eyebrow">{t.shop.breadcrumbShop}</span>
<h1>{t.shop.title}</h1>
<p class="lead">{t.shop.lead}</p>
</div>
</section>
</div>
<section class="section-tight">
<div class="container shop-layout">
<aside class="filters card" aria-label={t.shop.filters}>
<h3>{t.shop.filters}</h3>
<div class="filter-group">
<span id="f-kategorie-label">{t.shop.categoryLabel}</span>
<div class="cat-filter" id="cat-filter" data-dropdown>
<input type="hidden" id="f-kategorie" value="" />
<button type="button" class="cat-filter-btn" data-dropdown-btn aria-haspopup="listbox" aria-expanded="false" aria-labelledby="f-kategorie-label">
<span data-dropdown-current>{t.shop.allCategories}</span>
<span class="chevron" aria-hidden="true">▼</span>
</button>
<div class="cat-filter-menu" role="listbox" aria-labelledby="f-kategorie-label">
<button type="button" class="cat-filter-option" data-value="" data-label={t.shop.allCategories} role="option" aria-selected="true">{t.shop.allCategories}</button>
{categories.map((c) => (
c.subcategories && c.subcategories.length > 0 ? (
<div class="cat-filter-group">
<span class="cat-filter-group-label" aria-hidden="true">{c.icon} {c.name[lang]}</span>
<button type="button" class="cat-filter-option" data-value={c.slug} data-label={`${c.icon} ${c.name[lang]}`} role="option" aria-selected="false">{t.shop.allCategories} {c.name[lang]}</button>
{c.subcategories.map((s) => (
<button type="button" class="cat-filter-option cat-filter-sub" data-value={`sub:${s.slug}`} data-label={subcategoryChipLabel(s, lang)} role="option" aria-selected="false">{subcategoryChipLabel(s, lang)}</button>
))}
</div>
) : (
<button type="button" class="cat-filter-option" data-value={c.slug} data-label={`${c.icon} ${c.name[lang]}`} role="option" aria-selected="false">{c.icon} {c.name[lang]}</button>
)
))}
</div>
</div>
</div>
<div class="filter-group">
<label for="f-preis">{t.shop.priceUpTo}</label>
<input id="f-preis" type="range" min="0" max="110" value="110" />
<span class="small" id="f-preis-value">{t.shop.priceUpToValue(110)}</span>
</div>
<div class="filter-group">
<label for="f-verfuegbar">{t.shop.onlyAvailable}</label>
<input id="f-verfuegbar" type="checkbox" />
</div>
<div class="filter-group">
<span id="f-bewertung-label">{t.shop.minRatingLabel}</span>
<div class="cat-filter" id="rating-filter" data-dropdown>
<input type="hidden" id="f-bewertung" value="0" />
<button type="button" class="cat-filter-btn" data-dropdown-btn aria-haspopup="listbox" aria-expanded="false" aria-labelledby="f-bewertung-label">
<span data-dropdown-current>{t.shop.minRatingAny}</span>
<span class="chevron" aria-hidden="true">▼</span>
</button>
<div class="cat-filter-menu" role="listbox" aria-labelledby="f-bewertung-label">
<button type="button" class="cat-filter-option" data-value="0" data-label={t.shop.minRatingAny} role="option" aria-selected="true">{t.shop.minRatingAny}</button>
{[4, 3, 2, 1].map((n) => (
<button type="button" class="cat-filter-option rating-option" data-value={String(n)} role="option" aria-selected="false">
<HeartRating bewertung={n} size={15} />
<span class="rating-plus">+</span>
</button>
))}
</div>
</div>
</div>
<p class="small">{t.shop.filterNote}</p>
</aside>
<div>
<div class="shop-toolbar">
<span class="small" id="result-count">{t.shop.resultCount(products.length)}</span>
<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>
</div>
<div class="grid grid-3" id="product-grid">
{products.map((p) => (
<div
class="product-slot"
data-slug={p.slug}
data-kategorie={p.kategorie}
data-unterkategorie={p.unterkategorie ?? ""}
data-preis={p.preis}
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}
data-basis-anzahl={anzahlBewertungen(p)}
>
<ProductCard product={p} lang={lang} />
</div>
))}
</div>
<p class="small" id="empty-msg" style="display:none;">{t.shop.noResults}</p>
<p class="legal-hint small">{t.common.vatNote} {t.common.allPricesPlusShipping}</p>
</div>
</div>
</section>
</Layout>
<script define:vars={{ productSingular: t.shop.productSingular, productPlural: t.shop.productPlural, unitFirst: lang === "en" }}>
import { lokaleBewertungenFuer } from "../../../scripts/reviews";
const grid = document.getElementById("product-grid");
const slots = Array.from(grid.querySelectorAll(".product-slot"));
const kategorieSel = document.getElementById("f-kategorie");
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");
const currencySymbol = "€";
function formatPrice(v) {
return unitFirst ? `${currencySymbol}${v}` : `${v} ${currencySymbol}`;
}
function apply() {
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;
slots.forEach((slot) => {
const matchKat = !kat
? true
: kat.startsWith("sub:")
? slot.dataset.unterkategorie === kat.slice(4)
: slot.dataset.kategorie === kat;
const matchPreis = Number(slot.dataset.preis) <= maxPreis;
const matchVerfuegbar = !nurVerfuegbar || Number(slot.dataset.bestand) > 0;
const matchBewertung = Number(slot.dataset.bewertung) >= minBewertung;
const show = matchKat && matchPreis && matchVerfuegbar && matchBewertung;
slot.style.display = show ? "" : "none";
if (show) visible++;
});
resultCount.textContent = `${visible} ${visible === 1 ? productSingular : productPlural}`;
emptyMsg.style.display = visible === 0 ? "block" : "none";
const sorted = [...slots].sort((a, b) => {
switch (sortSel.value) {
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));
// Eigene Dropdowns (statt nativer <select>-Felder): Der Browser kann in seiner eigenen
// Auswahlliste weder unsere Kategorie-Struktur noch Verlaufs-Farben darstellen — deshalb hier
// eine eigene, gestaltbare Liste. Ein gemeinsamer Code für BEIDE Dropdowns (Kategorie und
// Mindestbewertung), damit sich beide identisch verhalten und es nur eine Stelle zum Pflegen
// gibt. Verhalten wie beim Sprach-Umschalter im Header.
const dropdowns = Array.from(document.querySelectorAll("[data-dropdown]")).map((root) => {
const btn = root.querySelector("[data-dropdown-btn]");
const current = root.querySelector("[data-dropdown-current]");
const hidden = root.querySelector("input[type=hidden]");
const options = Array.from(root.querySelectorAll(".cat-filter-option"));
function select(value) {
const opt = options.find((o) => o.dataset.value === value);
if (!opt) return;
hidden.value = value;
// Optionen ohne eigenes data-label (z.B. die Herzen-Zeilen) übernehmen ihren kompletten
// Inhalt in den Knopf — so stehen dort dieselben Verlaufs-Herzen wie in der Liste.
if (opt.dataset.label !== undefined) current.textContent = opt.dataset.label;
else current.innerHTML = opt.innerHTML;
options.forEach((o) => o.setAttribute("aria-selected", String(o === opt)));
apply();
}
function close() {
root.classList.remove("open");
btn.setAttribute("aria-expanded", "false");
}
btn.addEventListener("click", (e) => {
e.stopPropagation();
const willOpen = !root.classList.contains("open");
// Immer erst alle anderen schließen, damit nie zwei Listen gleichzeitig offen sind.
document.querySelectorAll("[data-dropdown].open").forEach((other) => {
other.classList.remove("open");
other.querySelector("[data-dropdown-btn]").setAttribute("aria-expanded", "false");
});
if (willOpen) {
root.classList.add("open");
btn.setAttribute("aria-expanded", "true");
}
});
options.forEach((opt) => {
opt.addEventListener("click", () => {
select(opt.dataset.value);
close();
});
});
return { root, close, select, options };
});
document.addEventListener("click", () => dropdowns.forEach((d) => d.close()));
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") dropdowns.forEach((d) => d.close());
});
// Voreinstellung über die Adresszeile (z.B. /shop/?kategorie=diy-plushies), damit Links aus
// der Navigation weiterhin direkt mit gesetztem Filter landen.
const params = new URLSearchParams(location.search);
const preset = params.get("kategorie");
if (preset) dropdowns.forEach((d) => d.select(preset));
slots.forEach((slot) => {
const slug = slot.dataset.slug;
if (!slug) return;
const lokale = lokaleBewertungenFuer(slug);
if (lokale.length === 0) return;
const basisSchnitt = Number(slot.dataset.bewertung || 0);
const basisAnzahl = Number(slot.dataset.basisAnzahl || 0);
const summe = basisSchnitt * basisAnzahl + lokale.reduce((s, r) => s + r.bewertung, 0);
slot.dataset.bewertung = String(summe / (basisAnzahl + lokale.length));
});
apply();
</script>