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]>
This commit is contained in:
qcigano
2026-08-03 04:17:04 +02:00
co-authored by Claude Sonnet 5
parent d27a858d8f
commit 6f10e41386
28 changed files with 2338 additions and 334 deletions
+73 -22
View File
@@ -15,6 +15,11 @@ const bestseller = products.filter((p) => p.badges.includes("bestseller")).slice
const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).slice(0, 4);
const topBewertungen = alleBewertungen().slice(0, 3);
const bewertungSchnittGesamt = gesamtDurchschnitt();
const produkteBasis = products.map((p) => ({
slug: p.slug,
name: p.name[lang],
bewertungen: (p.bewertungen ?? []).map((r) => ({ name: r.name, bewertung: r.bewertung, text: r.text?.[lang] ?? "", datum: r.datum ?? "" })),
}));
---
<Layout title="Startsyte" lang={lang} path="/">
<section class="hero">
@@ -89,30 +94,30 @@ const bewertungSchnittGesamt = gesamtDurchschnitt();
</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">
<section class="section-tight" id="kundenstimmen-section" style={topBewertungen.length > 0 ? undefined : "display:none;"}>
<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" id="kundenstimmen-summary">
{bewertungSchnittGesamt !== null && (
<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>
)}
<div class="grid grid-3" id="kundenstimmen-cards">
{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">
@@ -129,3 +134,49 @@ const bewertungSchnittGesamt = gesamtDurchschnitt();
</div>
</section>
</Layout>
<script
define:vars={{
produkteBasis,
localePrefix: "/ch",
basedOnOne: t.reviews.basedOn(1),
basedOnOther: t.reviews.basedOn(2),
toProductText: t.reviews.toProduct,
}}
>
window.__reviewsHomeVars = { produkteBasis, localePrefix, basedOnOne, basedOnOther, toProductText };
</script>
<script>
import { heartsHtml } from "../../scripts/hearts";
import { lokaleBewertungenFuer } from "../../scripts/reviews";
const { produkteBasis, localePrefix, basedOnOne, basedOnOther, toProductText } = (window as any).__reviewsHomeVars;
function basedOnLabel(n: number): string {
return n === 1 ? basedOnOne : basedOnOther.replace(/\d+/, String(n));
}
const alle: any[] = [];
for (const p of produkteBasis) {
for (const r of p.bewertungen) alle.push({ ...r, produktSlug: p.slug, produktName: p.name });
for (const r of lokaleBewertungenFuer(p.slug)) alle.push({ ...r, produktSlug: p.slug, produktName: p.name });
}
alle.sort((a, b) => (b.datum || "").localeCompare(a.datum || ""));
if (alle.length > 0) {
const schnitt = alle.reduce((s, r) => s + r.bewertung, 0) / alle.length;
const summary = document.getElementById("kundenstimmen-summary")!;
summary.innerHTML = heartsHtml(schnitt, 24, { showValue: true, anzahlLabel: basedOnLabel(alle.length) });
const cards = document.getElementById("kundenstimmen-cards")!;
cards.innerHTML = alle
.slice(0, 3)
.map((r) => {
const text = r.text ? `<p class="small">${String(r.text).replace(/</g, "&lt;")}</p>` : "";
return `<div class="card review-card"><div class="review-card-head"><span class="review-name">${String(r.name).replace(/</g, "&lt;")}</span>${heartsHtml(r.bewertung, 14)}</div>${text}<a class="small" href="${localePrefix}/produkt/${r.produktSlug}/">${toProductText} ${r.produktName}</a></div>`;
})
.join("");
document.getElementById("kundenstimmen-section")!.style.display = "";
}
</script>
+147 -23
View File
@@ -1,7 +1,7 @@
---
import Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.astro";
import { products } from "../../../data/products";
import { products, getProduct } from "../../../data/products";
import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui";
import { formatPrice } from "../../../i18n/format";
@@ -9,32 +9,37 @@ import { formatPrice } from "../../../i18n/format";
const lang: Locale = "ch";
const t = useTranslations(lang);
// VORSCHAU-SEITE (Phase 1): So sieht der persönliche Bereich aus, sobald jemand angemeldet ist.
// Es gibt noch kein echtes Login und keine Datenbank — die Bestellungen unten sind reine
// Beispieldaten, damit Aufbau, Status-Anzeige und Bedienung schon jetzt beurteilt und
// weiterentwickelt werden können. In Phase 2 (Cloudflare Worker + D1) werden sie durch die
// echten Daten der angemeldeten Person ersetzt; Layout und Texte bleiben dann bestehen.
const beispielBestellungen = [
// Bestellungen sind weiterhin Beispieldaten (siehe previewNote) — ECHT sind ab jetzt aber
// Anmeldung, Profil (Benutzername/Foto) und Rezensionen, gespeichert im localStorage dieses
// Geräts (siehe scripts/account.ts, scripts/reviews.ts). Jede Bestellposition referenziert
// einen echten Produkt-Slug — der Name kommt live aus den Produktdaten (mehrsprachig, immer
// konsistent mit dem Shop), damit "Rezension schreiben" wirklich zur passenden Seite führt.
const beispielBestellungenRoh = [
{
nummer: "VDB-2026-0184",
datum: "28.07.2026",
status: "versendet" as const,
summe: 47.5,
tracking: "00340434161094015791",
artikel: [
{ name: "\"HasiDog\" im blauen Overall", menge: 1, preis: 25 },
{ name: "Perlenarmband „Himmelblau\"", menge: 1, preis: 14.5 },
{ slug: "hasidog-plushie-blaueroverall", menge: 1 },
{ slug: "perlen-armband-hellblau", menge: 1 },
],
},
{
nummer: "VDB-2026-0157",
datum: "12.07.2026",
status: "abgeschlossen" as const,
summe: 22,
tracking: null,
artikel: [{ name: "Ergonomisches Häkelnadel-Set (9-teilig)", menge: 1, preis: 22 }],
artikel: [{ slug: "haekelnadel-set-ergonomisch", menge: 1 }],
},
];
const beispielBestellungen = beispielBestellungenRoh.map((b) => {
const artikel = b.artikel.map((a) => {
const p = getProduct(a.slug);
return { ...a, name: p?.name[lang] ?? a.slug, preis: p?.preis ?? 0 };
});
return { ...b, artikel, summe: artikel.reduce((s, a) => s + a.preis * a.menge, 0) };
});
const statusLabel = {
bezahlt: t.accountDash.statusBezahlt,
@@ -49,22 +54,26 @@ const statusLabel = {
const zuletztAngesehen = products.slice(0, 3);
const offeneSendungen = beispielBestellungen.filter((b) => b.status === "versendet").length;
---
<Layout title="Mis Konto aagmäldet (Vorschau)" description="Vorschau vom persönliche Kundebereich vo Van's DIY & Bastelbedarf." lang={lang} path="/konto/angemeldet/">
<Layout title="Mis Konto" description="Persönlicher Kundebereich vo Van's DIY & Bastelbedarf." lang={lang} path="/konto/angemeldet/">
<section class="section-tight">
<div class="container">
<p class="todo-note">{t.accountDash.previewNote}</p>
<p class="todo-note" id="not-logged-in-note" style="display:none;">{t.accountDash.notLoggedIn}</p>
<p class="todo-note" id="preview-note">{t.accountDash.previewNote}</p>
</div>
</section>
<section class="section-tight">
<div class="container">
<div class="account-head card">
<div>
<span class="eyebrow">{t.accountDash.eyebrow}</span>
<h1>{t.accountDash.greeting}</h1>
<p class="lead">{t.accountDash.lead}</p>
<div class="account-head-identity">
<span class="account-avatar-lg" id="dash-avatar" aria-hidden="true"></span>
<div>
<span class="eyebrow">{t.accountDash.eyebrow}</span>
<h1 id="dash-greeting">{t.accountDash.greeting("…")}</h1>
<p class="lead">{t.accountDash.lead}</p>
</div>
</div>
<a class="btn btn-outline btn-sm" href="/ch/konto/">{t.accountDash.logout}</a>
<button type="button" class="btn btn-outline btn-sm" id="dash-logout-btn">{t.accountDash.logout}</button>
</div>
<div class="account-stats">
@@ -102,7 +111,14 @@ const offeneSendungen = beispielBestellungen.filter((b) => b.status === "versend
{b.artikel.map((a) => (
<li>
<span>{a.menge}× {a.name}</span>
<span class="order-item-price">{formatPrice(a.preis * a.menge, lang)}</span>
<span class="order-item-right">
<span class="order-item-price">{formatPrice(a.preis * a.menge, lang)}</span>
{/* Zustand (Button vs. "✓ Bewertet") hängt vom localStorage ab — wird per JS
beim Laden gesetzt (siehe Script unten), Grundzustand ist der Button. */}
<span class="order-item-review" data-order-review data-bestellnummer={b.nummer} data-slug={a.slug}>
<a class="small review-link" href={`/ch/produkt/${a.slug}/?bewerten=${b.nummer}`}>{t.accountDash.reviewBtn}</a>
</span>
</span>
</li>
))}
</ul>
@@ -146,15 +162,123 @@ const offeneSendungen = beispielBestellungen.filter((b) => b.status === "versend
<div class="container">
<h2>{t.accountDash.profileTitle}</h2>
<div class="card profile-card">
<div class="profile-photo-row">
<span class="account-avatar-lg" id="profile-avatar" aria-hidden="true"></span>
<div>
<span class="small" style="display:block; margin-bottom:0.4rem;">{t.accountDash.photoLabel}</span>
<label class="btn btn-outline btn-sm profile-photo-btn">
{t.accountDash.photoChange}
<input type="file" accept="image/*" id="profile-photo-input" style="display:none;" />
</label>
<button type="button" class="btn btn-outline btn-sm" id="profile-photo-remove">{t.accountDash.photoRemove}</button>
</div>
</div>
<div class="profile-field">
<label for="profile-username">{t.accountDash.usernameLabel}</label>
<div class="profile-field-row">
<input type="text" id="profile-username" maxlength="40" />
<button type="button" class="btn btn-primary btn-sm" id="profile-username-save">{t.accountDash.usernameSave}</button>
</div>
<span class="small" id="profile-username-status" style="display:none; color: var(--c-accent);">{t.accountDash.usernameSaved}</span>
</div>
<table class="specs">
<tbody>
<tr><th>{t.accountDash.profileName}</th><td>Marie Beispiel</td></tr>
<tr><th>{t.accountDash.profileEmail}</th><td>[email protected]</td></tr>
<tr><th>{t.accountDash.profileEmail}</th><td id="profile-email"></td></tr>
<tr><th>{t.accountDash.profileAddress}</th><td>Musterstraße 1<br />10115 Berlin<br />Deutschland</td></tr>
</tbody>
</table>
<a class="btn btn-outline btn-sm" href="#">{t.accountDash.profileEdit}</a>
</div>
</div>
</section>
</Layout>
<script define:vars={{ loginPath: "/ch/konto/", fallbackName: "Kundin", greetingTemplate: t.accountDash.greeting("{name}"), reviewedBadgeText: t.accountDash.reviewedBadge }}>
window.__accountDashVars = { loginPath, fallbackName, greetingTemplate, reviewedBadgeText };
</script>
<script>
import { getAccount, isLoggedIn, logout, setUsername, setPhoto, hatBestellungBewertet } from "../../../scripts/account";
const { loginPath, fallbackName, greetingTemplate, reviewedBadgeText } = (window as any).__accountDashVars;
// Zugriffsschutz: diese Seite ist nur für angemeldete Kund:innen gedacht. Es gibt noch kein
// echtes Backend, das den Zugriff serverseitig verweigern könnte (Phase 2) — deshalb hier per
// JS geprüft und bei fehlender Anmeldung sofort zur Login-Seite weitergeleitet.
if (!isLoggedIn()) {
const note = document.getElementById("not-logged-in-note");
const preview = document.getElementById("preview-note");
if (note) note.style.display = "block";
if (preview) preview.style.display = "none";
setTimeout(() => { window.location.href = loginPath; }, 900);
} else {
function avatarHtml(username: string, photo: string): { bg: string; text: string } {
if (photo) return { bg: `url(${photo})`, text: "" };
return { bg: "", text: (username || "?").trim().charAt(0).toUpperCase() };
}
function renderProfil() {
const a = getAccount();
const greetingEl = document.getElementById("dash-greeting");
if (greetingEl) greetingEl.textContent = greetingTemplate.replace("{name}", a.username || fallbackName);
const emailEl = document.getElementById("profile-email");
if (emailEl) emailEl.textContent = a.email || "";
const usernameInput = document.getElementById("profile-username") as HTMLInputElement | null;
if (usernameInput && document.activeElement !== usernameInput) usernameInput.value = a.username;
const { bg, text } = avatarHtml(a.username, a.photo);
[document.getElementById("dash-avatar"), document.getElementById("profile-avatar")].forEach((el) => {
if (!el) return;
el.style.backgroundImage = bg;
el.textContent = text;
});
}
renderProfil();
window.addEventListener("account:changed", renderProfil);
// Benutzername ändern
const saveBtn = document.getElementById("profile-username-save");
const usernameInput = document.getElementById("profile-username") as HTMLInputElement | null;
const savedStatus = document.getElementById("profile-username-status");
saveBtn?.addEventListener("click", () => {
if (!usernameInput?.value.trim()) return;
setUsername(usernameInput.value);
if (savedStatus) {
savedStatus.style.display = "inline";
setTimeout(() => { savedStatus.style.display = "none"; }, 2000);
}
});
// Profilfoto hochladen (wird als data:-URL im localStorage abgelegt — reicht für diese
// Demo völlig aus, in Phase 2 würde ein echter Datei-Upload an den Server dahinterstehen)
const photoInput = document.getElementById("profile-photo-input") as HTMLInputElement | null;
photoInput?.addEventListener("change", () => {
const file = photoInput.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setPhoto(String(reader.result));
reader.readAsDataURL(file);
});
document.getElementById("profile-photo-remove")?.addEventListener("click", () => setPhoto(""));
// Logout direkt aus dem Dashboard heraus
document.getElementById("dash-logout-btn")?.addEventListener("click", () => {
logout();
window.location.href = loginPath;
});
// Pro Bestellposition: Button "Rezension schreiben" durch "✓ Bewertet" ersetzen, sobald für
// genau diese Bestellung+Produkt schon eine Rezension existiert (siehe scripts/reviews.ts,
// wird beim Absenden des Formulars auf der Produktseite markiert).
document.querySelectorAll<HTMLElement>("[data-order-review]").forEach((el) => {
const bestellnummer = el.dataset.bestellnummer!;
const slug = el.dataset.slug!;
if (hatBestellungBewertet(bestellnummer, slug)) {
el.innerHTML = "";
const badge = document.createElement("span");
badge.className = "badge review-done-badge";
badge.textContent = reviewedBadgeText;
el.appendChild(badge);
}
});
}
</script>
+22 -6
View File
@@ -17,11 +17,12 @@ const t = useTranslations(lang);
<section class="section-tight">
<div class="container grid grid-2 account-grid">
<form class="frm card">
<form class="frm card" id="login-form">
<h3>{t.account.loginTitle}</h3>
<div><label for="login-email">{t.account.email}</label><input id="login-email" type="email" /></div>
<div><label for="login-email">{t.account.email}</label><input id="login-email" type="email" required /></div>
<div><label for="login-pw">{t.account.password}</label><input id="login-pw" type="password" /></div>
<button class="btn btn-primary btn-block" type="button" disabled>{t.account.loginButton}</button>
<button class="btn btn-primary btn-block" type="submit">{t.account.loginButton}</button>
<p class="small" id="login-error" style="display:none; color: var(--c-sale);">{t.account.loginError}</p>
<a class="small" href="#">{t.account.forgotPassword}</a>
</form>
@@ -31,8 +32,23 @@ const t = useTranslations(lang);
<a class="btn btn-outline btn-block" href="/ch/shop/">{t.account.guestButton}</a>
</div>
</div>
<div class="container" style="margin-top:1.2rem;">
<a class="small" href="/ch/konto/angemeldet/">{t.account.previewLink}</a>
</div>
</section>
</Layout>
<script>
import { login } from "../../../scripts/account";
const form = document.getElementById("login-form") as HTMLFormElement | null;
const emailInput = document.getElementById("login-email") as HTMLInputElement | null;
const error = document.getElementById("login-error");
form?.addEventListener("submit", (e) => {
e.preventDefault();
const email = emailInput?.value.trim() ?? "";
if (!email) {
if (error) error.style.display = "block";
return;
}
login(email);
window.location.href = "/ch/konto/angemeldet/";
});
</script>
+182 -18
View File
@@ -134,27 +134,53 @@ const felder: [string, string | undefined][] = [
<section class="section-tight" id="bewertungen">
<div class="container">
<h2>{t.product.reviewsTitle}</h2>
{bewertungSchnitt !== null ? (
<>
{/* Server-gerenderter Ausgangszustand (funktioniert auch ohne JS) — wird beim Laden per
Skript durch die mit lokal gespeicherten Kundenrezensionen zusammengeführte Liste
ersetzt (siehe scripts/reviews.ts), damit neue Rezensionen sofort überall auftauchen. */}
<div id="review-summary-slot">
{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>
))}
) : (
<p class="small">{t.product.noReviews}</p>
)}
</div>
<div class="review-list" id="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>
</>
) : (
<p class="small">{t.product.noReviews}</p>
)}
))}
</div>
<div class="card review-form-card" id="review-form-card">
<h3>{t.product.writeReviewTitle}</h3>
<p class="small review-order-note" id="review-order-note" style="display:none;"></p>
<p class="small review-login-note" id="review-login-note">{t.product.notLoggedInReview}</p>
<form id="review-form">
<div class="review-form-row">
<span class="review-form-label">{t.product.yourRating}</span>
<div id="review-heart-picker"></div>
</div>
<div id="review-name-field">
<label for="review-name">{t.product.yourName}</label>
<input type="text" id="review-name" maxlength="40" />
</div>
<div>
<label for="review-text">{t.product.yourReview}</label>
<textarea id="review-text" rows="3"></textarea>
</div>
<button class="btn btn-primary" type="submit">{t.product.submitReview}</button>
<p class="small" id="review-error" style="display:none; color: var(--c-sale);">{t.product.reviewNeedsRating}</p>
<p class="small" id="review-confirm" role="status" style="display:none; color: var(--c-accent);">{t.product.reviewSaved}</p>
</form>
</div>
</div>
</section>
@@ -244,9 +270,147 @@ const felder: [string, string | undefined][] = [
// Klick auf ein Miniaturbild zeigt es groß im Hauptbild an (mehrere Produktfotos).
const heroImg = document.getElementById("product-hero-img") as HTMLImageElement | null;
document.querySelectorAll<HTMLImageElement>(".thumb.product-photo").forEach((thumb) => {
const thumbs = [...document.querySelectorAll<HTMLImageElement>(".thumb.product-photo")];
thumbs.forEach((thumb) => {
thumb.addEventListener("click", () => {
if (heroImg && thumb.dataset.full) heroImg.src = thumb.dataset.full;
});
});
// Auch das große Hauptbild lässt sich wischen (Finger auf dem Handy, Maus ziehen am PC) — springt
// dabei einfach zum nächsten/vorherigen Miniaturbild, exakt wie ein Klick auf den Thumbnail.
if (heroImg && thumbs.length > 1) {
let startX = 0, startY = 0;
heroImg.style.touchAction = "pan-y";
heroImg.addEventListener("pointerdown", (e) => { startX = e.clientX; startY = e.clientY; });
heroImg.addEventListener("pointerup", (e) => {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.abs(dx) < 40 || Math.abs(dx) < Math.abs(dy)) return;
const aktuellerIndex = Math.max(0, thumbs.findIndex((th) => th.dataset.full && heroImg.src.endsWith(th.dataset.full)));
const naechster = ((aktuellerIndex + (dx < 0 ? 1 : -1)) % thumbs.length + thumbs.length) % thumbs.length;
heroImg.src = thumbs[naechster].dataset.full!;
});
}
</script>
<script
define:vars={{
slug: product.slug,
serverReviews: (product.bewertungen ?? []).map((r) => ({ name: r.name, bewertung: r.bewertung, text: r.text?.[lang] ?? "", datum: r.datum ?? "" })),
noReviewsText: t.product.noReviews,
reviewCountOne: t.product.reviewCount(1),
reviewCountOther: t.product.reviewCount(2),
loginHintReview: t.product.loginHintReview,
notLoggedInReview: t.product.notLoggedInReview,
reviewFromOrderTemplate: t.product.reviewFromOrder("__NR__"),
reviewNeedsRatingText: t.product.reviewNeedsRating,
reviewSavedText: t.product.reviewSaved,
}}
>
window.__reviewVars = {
slug, serverReviews, noReviewsText, reviewCountOne, reviewCountOther,
loginHintReview, notLoggedInReview, reviewFromOrderTemplate, reviewNeedsRatingText, reviewSavedText,
};
</script>
<script>
import { heartsHtml, heartsPickerHtml, heartsPickerAktualisieren } from "../../../scripts/hearts";
import { bewertungenZusammenfuehren, durchschnitt, fuegeBewertungHinzu, type AnzeigeBewertung } from "../../../scripts/reviews";
import { isLoggedIn, getAccount } from "../../../scripts/account";
const {
slug, serverReviews, noReviewsText, reviewCountOne, reviewCountOther,
loginHintReview, notLoggedInReview, reviewFromOrderTemplate, reviewNeedsRatingText, reviewSavedText,
} = (window as any).__reviewVars;
function reviewCountLabel(n: number): string {
return n === 1 ? reviewCountOne : reviewCountOther.replace(/^\d+/, String(n));
}
const summarySlot = document.getElementById("review-summary-slot")!;
const listSlot = document.getElementById("review-list")!;
function renderReviews() {
const merged: AnzeigeBewertung[] = bewertungenZusammenfuehren(serverReviews, slug);
const schnitt = durchschnitt(merged);
summarySlot.innerHTML =
schnitt === null
? `<p class="small">${noReviewsText}</p>`
: `<div class="review-summary-row">${heartsHtml(schnitt, 26, { showValue: true, anzahlLabel: reviewCountLabel(merged.length) })}</div>`;
listSlot.innerHTML = merged
.map((r) => {
const datum = r.datum ? `<span class="review-date small">${r.datum}</span>` : "";
const text = r.text ? `<p class="small">${r.text.replace(/</g, "&lt;")}</p>` : "";
return `<div class="card review-card"><div class="review-card-head"><span class="review-name">${r.name.replace(/</g, "&lt;")}</span>${heartsHtml(r.bewertung, 14)}${datum}</div>${text}</div>`;
})
.join("");
}
renderReviews();
// Bewertungsformular: eigenes Herzen-Auswahlfeld (kein natives <select>, siehe scripts/hearts.ts)
const picker = document.getElementById("review-heart-picker")!;
picker.innerHTML = heartsPickerHtml(28);
const pickerRoot = picker.querySelector<HTMLElement>("[data-heart-picker]")!;
let gewaehlteBewertung = 0;
pickerRoot.querySelectorAll<HTMLButtonElement>(".heart-pick").forEach((btn) => {
const wert = Number(btn.dataset.wert);
btn.addEventListener("mouseenter", () => heartsPickerAktualisieren(pickerRoot, wert));
btn.addEventListener("mouseleave", () => heartsPickerAktualisieren(pickerRoot, gewaehlteBewertung));
btn.addEventListener("click", () => {
gewaehlteBewertung = wert;
heartsPickerAktualisieren(pickerRoot, gewaehlteBewertung);
});
});
// Name nur abfragen, wenn niemand angemeldet ist — angemeldete Kund:innen bewerten automatisch
// unter ihrem Benutzernamen (siehe scripts/account.ts).
const nameField = document.getElementById("review-name-field")!;
const loginNote = document.getElementById("review-login-note")!;
if (isLoggedIn()) {
nameField.style.display = "none";
loginNote.textContent = `${loginHintReview} ${getAccount().username}`;
} else {
nameField.style.display = "";
loginNote.textContent = notLoggedInReview;
}
// Kommt der Aufruf von einer Bestellung im Kundenkonto ("Rezension schreiben"), steht die
// Bestellnummer als ?bewerten=... in der URL — Hinweistext einblenden, zum Formular scrollen
// und die Nummer beim Absenden mitschicken, damit das Konto sie als "✓ Bewertet" markieren kann.
const bestellnummer = new URLSearchParams(location.search).get("bewerten");
if (bestellnummer) {
const note = document.getElementById("review-order-note")!;
note.textContent = reviewFromOrderTemplate.replace("__NR__", bestellnummer);
note.style.display = "block";
document.getElementById("review-form-card")?.scrollIntoView({ behavior: "smooth", block: "center" });
}
const form = document.getElementById("review-form") as HTMLFormElement;
const errorMsg = document.getElementById("review-error")!;
const confirmMsg = document.getElementById("review-confirm")!;
const nameInput = document.getElementById("review-name") as HTMLInputElement | null;
const textInput = document.getElementById("review-text") as HTMLTextAreaElement;
form.addEventListener("submit", (e) => {
e.preventDefault();
if (gewaehlteBewertung < 1) {
errorMsg.style.display = "block";
return;
}
errorMsg.style.display = "none";
fuegeBewertungHinzu(slug, {
bewertung: gewaehlteBewertung,
text: textInput.value,
name: isLoggedIn() ? undefined : nameInput?.value,
bestellnummer: bestellnummer || undefined,
});
textInput.value = "";
gewaehlteBewertung = 0;
heartsPickerAktualisieren(pickerRoot, 0);
confirmMsg.style.display = "block";
setTimeout(() => { confirmMsg.style.display = "none"; }, 3000);
renderReviews();
});
</script>
+16 -1
View File
@@ -3,7 +3,7 @@ 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 } from "../../../data/products";
import { products, durchschnittsbewertung, anzahlBewertungen } from "../../../data/products";
import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui";
@@ -97,6 +97,7 @@ const t = useTranslations(lang);
{products.map((p) => (
<div
class="product-slot"
data-slug={p.slug}
data-kategorie={p.kategorie}
data-unterkategorie={p.unterkategorie ?? ""}
data-preis={p.preis}
@@ -104,6 +105,7 @@ const t = useTranslations(lang);
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>
@@ -117,6 +119,8 @@ const t = useTranslations(lang);
</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");
@@ -235,5 +239,16 @@ const t = useTranslations(lang);
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>
+73 -22
View File
@@ -15,6 +15,11 @@ const bestseller = products.filter((p) => p.badges.includes("bestseller")).slice
const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).slice(0, 4);
const topBewertungen = alleBewertungen().slice(0, 3);
const bewertungSchnittGesamt = gesamtDurchschnitt();
const produkteBasis = products.map((p) => ({
slug: p.slug,
name: p.name[lang],
bewertungen: (p.bewertungen ?? []).map((r) => ({ name: r.name, bewertung: r.bewertung, text: r.text?.[lang] ?? "", datum: r.datum ?? "" })),
}));
---
<Layout title="Home" lang={lang} path="/">
<section class="hero">
@@ -89,30 +94,30 @@ const bewertungSchnittGesamt = gesamtDurchschnitt();
</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">
<section class="section-tight" id="kundenstimmen-section" style={topBewertungen.length > 0 ? undefined : "display:none;"}>
<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" id="kundenstimmen-summary">
{bewertungSchnittGesamt !== null && (
<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>
)}
<div class="grid grid-3" id="kundenstimmen-cards">
{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">
@@ -129,3 +134,49 @@ const bewertungSchnittGesamt = gesamtDurchschnitt();
</div>
</section>
</Layout>
<script
define:vars={{
produkteBasis,
localePrefix: "/en",
basedOnOne: t.reviews.basedOn(1),
basedOnOther: t.reviews.basedOn(2),
toProductText: t.reviews.toProduct,
}}
>
window.__reviewsHomeVars = { produkteBasis, localePrefix, basedOnOne, basedOnOther, toProductText };
</script>
<script>
import { heartsHtml } from "../../scripts/hearts";
import { lokaleBewertungenFuer } from "../../scripts/reviews";
const { produkteBasis, localePrefix, basedOnOne, basedOnOther, toProductText } = (window as any).__reviewsHomeVars;
function basedOnLabel(n: number): string {
return n === 1 ? basedOnOne : basedOnOther.replace(/\d+/, String(n));
}
const alle: any[] = [];
for (const p of produkteBasis) {
for (const r of p.bewertungen) alle.push({ ...r, produktSlug: p.slug, produktName: p.name });
for (const r of lokaleBewertungenFuer(p.slug)) alle.push({ ...r, produktSlug: p.slug, produktName: p.name });
}
alle.sort((a, b) => (b.datum || "").localeCompare(a.datum || ""));
if (alle.length > 0) {
const schnitt = alle.reduce((s, r) => s + r.bewertung, 0) / alle.length;
const summary = document.getElementById("kundenstimmen-summary")!;
summary.innerHTML = heartsHtml(schnitt, 24, { showValue: true, anzahlLabel: basedOnLabel(alle.length) });
const cards = document.getElementById("kundenstimmen-cards")!;
cards.innerHTML = alle
.slice(0, 3)
.map((r) => {
const text = r.text ? `<p class="small">${String(r.text).replace(/</g, "&lt;")}</p>` : "";
return `<div class="card review-card"><div class="review-card-head"><span class="review-name">${String(r.name).replace(/</g, "&lt;")}</span>${heartsHtml(r.bewertung, 14)}</div>${text}<a class="small" href="${localePrefix}/produkt/${r.produktSlug}/">${toProductText} ${r.produktName}</a></div>`;
})
.join("");
document.getElementById("kundenstimmen-section")!.style.display = "";
}
</script>
+147 -23
View File
@@ -1,7 +1,7 @@
---
import Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.astro";
import { products } from "../../../data/products";
import { products, getProduct } from "../../../data/products";
import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui";
import { formatPrice } from "../../../i18n/format";
@@ -9,32 +9,37 @@ import { formatPrice } from "../../../i18n/format";
const lang: Locale = "en";
const t = useTranslations(lang);
// VORSCHAU-SEITE (Phase 1): So sieht der persönliche Bereich aus, sobald jemand angemeldet ist.
// Es gibt noch kein echtes Login und keine Datenbank — die Bestellungen unten sind reine
// Beispieldaten, damit Aufbau, Status-Anzeige und Bedienung schon jetzt beurteilt und
// weiterentwickelt werden können. In Phase 2 (Cloudflare Worker + D1) werden sie durch die
// echten Daten der angemeldeten Person ersetzt; Layout und Texte bleiben dann bestehen.
const beispielBestellungen = [
// Bestellungen sind weiterhin Beispieldaten (siehe previewNote) — ECHT sind ab jetzt aber
// Anmeldung, Profil (Benutzername/Foto) und Rezensionen, gespeichert im localStorage dieses
// Geräts (siehe scripts/account.ts, scripts/reviews.ts). Jede Bestellposition referenziert
// einen echten Produkt-Slug — der Name kommt live aus den Produktdaten (mehrsprachig, immer
// konsistent mit dem Shop), damit "Rezension schreiben" wirklich zur passenden Seite führt.
const beispielBestellungenRoh = [
{
nummer: "VDB-2026-0184",
datum: "28.07.2026",
status: "versendet" as const,
summe: 47.5,
tracking: "00340434161094015791",
artikel: [
{ name: "\"HasiDog\" im blauen Overall", menge: 1, preis: 25 },
{ name: "Perlenarmband „Himmelblau\"", menge: 1, preis: 14.5 },
{ slug: "hasidog-plushie-blaueroverall", menge: 1 },
{ slug: "perlen-armband-hellblau", menge: 1 },
],
},
{
nummer: "VDB-2026-0157",
datum: "12.07.2026",
status: "abgeschlossen" as const,
summe: 22,
tracking: null,
artikel: [{ name: "Ergonomisches Häkelnadel-Set (9-teilig)", menge: 1, preis: 22 }],
artikel: [{ slug: "haekelnadel-set-ergonomisch", menge: 1 }],
},
];
const beispielBestellungen = beispielBestellungenRoh.map((b) => {
const artikel = b.artikel.map((a) => {
const p = getProduct(a.slug);
return { ...a, name: p?.name[lang] ?? a.slug, preis: p?.preis ?? 0 };
});
return { ...b, artikel, summe: artikel.reduce((s, a) => s + a.preis * a.menge, 0) };
});
const statusLabel = {
bezahlt: t.accountDash.statusBezahlt,
@@ -49,22 +54,26 @@ const statusLabel = {
const zuletztAngesehen = products.slice(0, 3);
const offeneSendungen = beispielBestellungen.filter((b) => b.status === "versendet").length;
---
<Layout title="My account signed in (preview)" description="Preview of the personal customer area at Van's DIY & Bastelbedarf." lang={lang} path="/konto/angemeldet/">
<Layout title="My Account" description="Personal customer area at Van's DIY & Bastelbedarf." lang={lang} path="/konto/angemeldet/">
<section class="section-tight">
<div class="container">
<p class="todo-note">{t.accountDash.previewNote}</p>
<p class="todo-note" id="not-logged-in-note" style="display:none;">{t.accountDash.notLoggedIn}</p>
<p class="todo-note" id="preview-note">{t.accountDash.previewNote}</p>
</div>
</section>
<section class="section-tight">
<div class="container">
<div class="account-head card">
<div>
<span class="eyebrow">{t.accountDash.eyebrow}</span>
<h1>{t.accountDash.greeting}</h1>
<p class="lead">{t.accountDash.lead}</p>
<div class="account-head-identity">
<span class="account-avatar-lg" id="dash-avatar" aria-hidden="true"></span>
<div>
<span class="eyebrow">{t.accountDash.eyebrow}</span>
<h1 id="dash-greeting">{t.accountDash.greeting("…")}</h1>
<p class="lead">{t.accountDash.lead}</p>
</div>
</div>
<a class="btn btn-outline btn-sm" href="/en/konto/">{t.accountDash.logout}</a>
<button type="button" class="btn btn-outline btn-sm" id="dash-logout-btn">{t.accountDash.logout}</button>
</div>
<div class="account-stats">
@@ -102,7 +111,14 @@ const offeneSendungen = beispielBestellungen.filter((b) => b.status === "versend
{b.artikel.map((a) => (
<li>
<span>{a.menge}× {a.name}</span>
<span class="order-item-price">{formatPrice(a.preis * a.menge, lang)}</span>
<span class="order-item-right">
<span class="order-item-price">{formatPrice(a.preis * a.menge, lang)}</span>
{/* Zustand (Button vs. "✓ Bewertet") hängt vom localStorage ab — wird per JS
beim Laden gesetzt (siehe Script unten), Grundzustand ist der Button. */}
<span class="order-item-review" data-order-review data-bestellnummer={b.nummer} data-slug={a.slug}>
<a class="small review-link" href={`/en/produkt/${a.slug}/?bewerten=${b.nummer}`}>{t.accountDash.reviewBtn}</a>
</span>
</span>
</li>
))}
</ul>
@@ -146,15 +162,123 @@ const offeneSendungen = beispielBestellungen.filter((b) => b.status === "versend
<div class="container">
<h2>{t.accountDash.profileTitle}</h2>
<div class="card profile-card">
<div class="profile-photo-row">
<span class="account-avatar-lg" id="profile-avatar" aria-hidden="true"></span>
<div>
<span class="small" style="display:block; margin-bottom:0.4rem;">{t.accountDash.photoLabel}</span>
<label class="btn btn-outline btn-sm profile-photo-btn">
{t.accountDash.photoChange}
<input type="file" accept="image/*" id="profile-photo-input" style="display:none;" />
</label>
<button type="button" class="btn btn-outline btn-sm" id="profile-photo-remove">{t.accountDash.photoRemove}</button>
</div>
</div>
<div class="profile-field">
<label for="profile-username">{t.accountDash.usernameLabel}</label>
<div class="profile-field-row">
<input type="text" id="profile-username" maxlength="40" />
<button type="button" class="btn btn-primary btn-sm" id="profile-username-save">{t.accountDash.usernameSave}</button>
</div>
<span class="small" id="profile-username-status" style="display:none; color: var(--c-accent);">{t.accountDash.usernameSaved}</span>
</div>
<table class="specs">
<tbody>
<tr><th>{t.accountDash.profileName}</th><td>Marie Beispiel</td></tr>
<tr><th>{t.accountDash.profileEmail}</th><td>[email protected]</td></tr>
<tr><th>{t.accountDash.profileEmail}</th><td id="profile-email"></td></tr>
<tr><th>{t.accountDash.profileAddress}</th><td>Musterstraße 1<br />10115 Berlin<br />Deutschland</td></tr>
</tbody>
</table>
<a class="btn btn-outline btn-sm" href="#">{t.accountDash.profileEdit}</a>
</div>
</div>
</section>
</Layout>
<script define:vars={{ loginPath: "/en/konto/", fallbackName: "Customer", greetingTemplate: t.accountDash.greeting("{name}"), reviewedBadgeText: t.accountDash.reviewedBadge }}>
window.__accountDashVars = { loginPath, fallbackName, greetingTemplate, reviewedBadgeText };
</script>
<script>
import { getAccount, isLoggedIn, logout, setUsername, setPhoto, hatBestellungBewertet } from "../../../scripts/account";
const { loginPath, fallbackName, greetingTemplate, reviewedBadgeText } = (window as any).__accountDashVars;
// Zugriffsschutz: diese Seite ist nur für angemeldete Kund:innen gedacht. Es gibt noch kein
// echtes Backend, das den Zugriff serverseitig verweigern könnte (Phase 2) — deshalb hier per
// JS geprüft und bei fehlender Anmeldung sofort zur Login-Seite weitergeleitet.
if (!isLoggedIn()) {
const note = document.getElementById("not-logged-in-note");
const preview = document.getElementById("preview-note");
if (note) note.style.display = "block";
if (preview) preview.style.display = "none";
setTimeout(() => { window.location.href = loginPath; }, 900);
} else {
function avatarHtml(username: string, photo: string): { bg: string; text: string } {
if (photo) return { bg: `url(${photo})`, text: "" };
return { bg: "", text: (username || "?").trim().charAt(0).toUpperCase() };
}
function renderProfil() {
const a = getAccount();
const greetingEl = document.getElementById("dash-greeting");
if (greetingEl) greetingEl.textContent = greetingTemplate.replace("{name}", a.username || fallbackName);
const emailEl = document.getElementById("profile-email");
if (emailEl) emailEl.textContent = a.email || "";
const usernameInput = document.getElementById("profile-username") as HTMLInputElement | null;
if (usernameInput && document.activeElement !== usernameInput) usernameInput.value = a.username;
const { bg, text } = avatarHtml(a.username, a.photo);
[document.getElementById("dash-avatar"), document.getElementById("profile-avatar")].forEach((el) => {
if (!el) return;
el.style.backgroundImage = bg;
el.textContent = text;
});
}
renderProfil();
window.addEventListener("account:changed", renderProfil);
// Benutzername ändern
const saveBtn = document.getElementById("profile-username-save");
const usernameInput = document.getElementById("profile-username") as HTMLInputElement | null;
const savedStatus = document.getElementById("profile-username-status");
saveBtn?.addEventListener("click", () => {
if (!usernameInput?.value.trim()) return;
setUsername(usernameInput.value);
if (savedStatus) {
savedStatus.style.display = "inline";
setTimeout(() => { savedStatus.style.display = "none"; }, 2000);
}
});
// Profilfoto hochladen (wird als data:-URL im localStorage abgelegt — reicht für diese
// Demo völlig aus, in Phase 2 würde ein echter Datei-Upload an den Server dahinterstehen)
const photoInput = document.getElementById("profile-photo-input") as HTMLInputElement | null;
photoInput?.addEventListener("change", () => {
const file = photoInput.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setPhoto(String(reader.result));
reader.readAsDataURL(file);
});
document.getElementById("profile-photo-remove")?.addEventListener("click", () => setPhoto(""));
// Logout direkt aus dem Dashboard heraus
document.getElementById("dash-logout-btn")?.addEventListener("click", () => {
logout();
window.location.href = loginPath;
});
// Pro Bestellposition: Button "Rezension schreiben" durch "✓ Bewertet" ersetzen, sobald für
// genau diese Bestellung+Produkt schon eine Rezension existiert (siehe scripts/reviews.ts,
// wird beim Absenden des Formulars auf der Produktseite markiert).
document.querySelectorAll<HTMLElement>("[data-order-review]").forEach((el) => {
const bestellnummer = el.dataset.bestellnummer!;
const slug = el.dataset.slug!;
if (hatBestellungBewertet(bestellnummer, slug)) {
el.innerHTML = "";
const badge = document.createElement("span");
badge.className = "badge review-done-badge";
badge.textContent = reviewedBadgeText;
el.appendChild(badge);
}
});
}
</script>
+22 -6
View File
@@ -17,11 +17,12 @@ const t = useTranslations(lang);
<section class="section-tight">
<div class="container grid grid-2 account-grid">
<form class="frm card">
<form class="frm card" id="login-form">
<h3>{t.account.loginTitle}</h3>
<div><label for="login-email">{t.account.email}</label><input id="login-email" type="email" /></div>
<div><label for="login-email">{t.account.email}</label><input id="login-email" type="email" required /></div>
<div><label for="login-pw">{t.account.password}</label><input id="login-pw" type="password" /></div>
<button class="btn btn-primary btn-block" type="button" disabled>{t.account.loginButton}</button>
<button class="btn btn-primary btn-block" type="submit">{t.account.loginButton}</button>
<p class="small" id="login-error" style="display:none; color: var(--c-sale);">{t.account.loginError}</p>
<a class="small" href="#">{t.account.forgotPassword}</a>
</form>
@@ -31,8 +32,23 @@ const t = useTranslations(lang);
<a class="btn btn-outline btn-block" href="/en/shop/">{t.account.guestButton}</a>
</div>
</div>
<div class="container" style="margin-top:1.2rem;">
<a class="small" href="/en/konto/angemeldet/">{t.account.previewLink}</a>
</div>
</section>
</Layout>
<script>
import { login } from "../../../scripts/account";
const form = document.getElementById("login-form") as HTMLFormElement | null;
const emailInput = document.getElementById("login-email") as HTMLInputElement | null;
const error = document.getElementById("login-error");
form?.addEventListener("submit", (e) => {
e.preventDefault();
const email = emailInput?.value.trim() ?? "";
if (!email) {
if (error) error.style.display = "block";
return;
}
login(email);
window.location.href = "/en/konto/angemeldet/";
});
</script>
+182 -18
View File
@@ -134,27 +134,53 @@ const felder: [string, string | undefined][] = [
<section class="section-tight" id="bewertungen">
<div class="container">
<h2>{t.product.reviewsTitle}</h2>
{bewertungSchnitt !== null ? (
<>
{/* Server-gerenderter Ausgangszustand (funktioniert auch ohne JS) — wird beim Laden per
Skript durch die mit lokal gespeicherten Kundenrezensionen zusammengeführte Liste
ersetzt (siehe scripts/reviews.ts), damit neue Rezensionen sofort überall auftauchen. */}
<div id="review-summary-slot">
{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>
))}
) : (
<p class="small">{t.product.noReviews}</p>
)}
</div>
<div class="review-list" id="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>
</>
) : (
<p class="small">{t.product.noReviews}</p>
)}
))}
</div>
<div class="card review-form-card" id="review-form-card">
<h3>{t.product.writeReviewTitle}</h3>
<p class="small review-order-note" id="review-order-note" style="display:none;"></p>
<p class="small review-login-note" id="review-login-note">{t.product.notLoggedInReview}</p>
<form id="review-form">
<div class="review-form-row">
<span class="review-form-label">{t.product.yourRating}</span>
<div id="review-heart-picker"></div>
</div>
<div id="review-name-field">
<label for="review-name">{t.product.yourName}</label>
<input type="text" id="review-name" maxlength="40" />
</div>
<div>
<label for="review-text">{t.product.yourReview}</label>
<textarea id="review-text" rows="3"></textarea>
</div>
<button class="btn btn-primary" type="submit">{t.product.submitReview}</button>
<p class="small" id="review-error" style="display:none; color: var(--c-sale);">{t.product.reviewNeedsRating}</p>
<p class="small" id="review-confirm" role="status" style="display:none; color: var(--c-accent);">{t.product.reviewSaved}</p>
</form>
</div>
</div>
</section>
@@ -244,9 +270,147 @@ const felder: [string, string | undefined][] = [
// Klick auf ein Miniaturbild zeigt es groß im Hauptbild an (mehrere Produktfotos).
const heroImg = document.getElementById("product-hero-img") as HTMLImageElement | null;
document.querySelectorAll<HTMLImageElement>(".thumb.product-photo").forEach((thumb) => {
const thumbs = [...document.querySelectorAll<HTMLImageElement>(".thumb.product-photo")];
thumbs.forEach((thumb) => {
thumb.addEventListener("click", () => {
if (heroImg && thumb.dataset.full) heroImg.src = thumb.dataset.full;
});
});
// Auch das große Hauptbild lässt sich wischen (Finger auf dem Handy, Maus ziehen am PC) — springt
// dabei einfach zum nächsten/vorherigen Miniaturbild, exakt wie ein Klick auf den Thumbnail.
if (heroImg && thumbs.length > 1) {
let startX = 0, startY = 0;
heroImg.style.touchAction = "pan-y";
heroImg.addEventListener("pointerdown", (e) => { startX = e.clientX; startY = e.clientY; });
heroImg.addEventListener("pointerup", (e) => {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.abs(dx) < 40 || Math.abs(dx) < Math.abs(dy)) return;
const aktuellerIndex = Math.max(0, thumbs.findIndex((th) => th.dataset.full && heroImg.src.endsWith(th.dataset.full)));
const naechster = ((aktuellerIndex + (dx < 0 ? 1 : -1)) % thumbs.length + thumbs.length) % thumbs.length;
heroImg.src = thumbs[naechster].dataset.full!;
});
}
</script>
<script
define:vars={{
slug: product.slug,
serverReviews: (product.bewertungen ?? []).map((r) => ({ name: r.name, bewertung: r.bewertung, text: r.text?.[lang] ?? "", datum: r.datum ?? "" })),
noReviewsText: t.product.noReviews,
reviewCountOne: t.product.reviewCount(1),
reviewCountOther: t.product.reviewCount(2),
loginHintReview: t.product.loginHintReview,
notLoggedInReview: t.product.notLoggedInReview,
reviewFromOrderTemplate: t.product.reviewFromOrder("__NR__"),
reviewNeedsRatingText: t.product.reviewNeedsRating,
reviewSavedText: t.product.reviewSaved,
}}
>
window.__reviewVars = {
slug, serverReviews, noReviewsText, reviewCountOne, reviewCountOther,
loginHintReview, notLoggedInReview, reviewFromOrderTemplate, reviewNeedsRatingText, reviewSavedText,
};
</script>
<script>
import { heartsHtml, heartsPickerHtml, heartsPickerAktualisieren } from "../../../scripts/hearts";
import { bewertungenZusammenfuehren, durchschnitt, fuegeBewertungHinzu, type AnzeigeBewertung } from "../../../scripts/reviews";
import { isLoggedIn, getAccount } from "../../../scripts/account";
const {
slug, serverReviews, noReviewsText, reviewCountOne, reviewCountOther,
loginHintReview, notLoggedInReview, reviewFromOrderTemplate, reviewNeedsRatingText, reviewSavedText,
} = (window as any).__reviewVars;
function reviewCountLabel(n: number): string {
return n === 1 ? reviewCountOne : reviewCountOther.replace(/^\d+/, String(n));
}
const summarySlot = document.getElementById("review-summary-slot")!;
const listSlot = document.getElementById("review-list")!;
function renderReviews() {
const merged: AnzeigeBewertung[] = bewertungenZusammenfuehren(serverReviews, slug);
const schnitt = durchschnitt(merged);
summarySlot.innerHTML =
schnitt === null
? `<p class="small">${noReviewsText}</p>`
: `<div class="review-summary-row">${heartsHtml(schnitt, 26, { showValue: true, anzahlLabel: reviewCountLabel(merged.length) })}</div>`;
listSlot.innerHTML = merged
.map((r) => {
const datum = r.datum ? `<span class="review-date small">${r.datum}</span>` : "";
const text = r.text ? `<p class="small">${r.text.replace(/</g, "&lt;")}</p>` : "";
return `<div class="card review-card"><div class="review-card-head"><span class="review-name">${r.name.replace(/</g, "&lt;")}</span>${heartsHtml(r.bewertung, 14)}${datum}</div>${text}</div>`;
})
.join("");
}
renderReviews();
// Bewertungsformular: eigenes Herzen-Auswahlfeld (kein natives <select>, siehe scripts/hearts.ts)
const picker = document.getElementById("review-heart-picker")!;
picker.innerHTML = heartsPickerHtml(28);
const pickerRoot = picker.querySelector<HTMLElement>("[data-heart-picker]")!;
let gewaehlteBewertung = 0;
pickerRoot.querySelectorAll<HTMLButtonElement>(".heart-pick").forEach((btn) => {
const wert = Number(btn.dataset.wert);
btn.addEventListener("mouseenter", () => heartsPickerAktualisieren(pickerRoot, wert));
btn.addEventListener("mouseleave", () => heartsPickerAktualisieren(pickerRoot, gewaehlteBewertung));
btn.addEventListener("click", () => {
gewaehlteBewertung = wert;
heartsPickerAktualisieren(pickerRoot, gewaehlteBewertung);
});
});
// Name nur abfragen, wenn niemand angemeldet ist — angemeldete Kund:innen bewerten automatisch
// unter ihrem Benutzernamen (siehe scripts/account.ts).
const nameField = document.getElementById("review-name-field")!;
const loginNote = document.getElementById("review-login-note")!;
if (isLoggedIn()) {
nameField.style.display = "none";
loginNote.textContent = `${loginHintReview} ${getAccount().username}`;
} else {
nameField.style.display = "";
loginNote.textContent = notLoggedInReview;
}
// Kommt der Aufruf von einer Bestellung im Kundenkonto ("Rezension schreiben"), steht die
// Bestellnummer als ?bewerten=... in der URL — Hinweistext einblenden, zum Formular scrollen
// und die Nummer beim Absenden mitschicken, damit das Konto sie als "✓ Bewertet" markieren kann.
const bestellnummer = new URLSearchParams(location.search).get("bewerten");
if (bestellnummer) {
const note = document.getElementById("review-order-note")!;
note.textContent = reviewFromOrderTemplate.replace("__NR__", bestellnummer);
note.style.display = "block";
document.getElementById("review-form-card")?.scrollIntoView({ behavior: "smooth", block: "center" });
}
const form = document.getElementById("review-form") as HTMLFormElement;
const errorMsg = document.getElementById("review-error")!;
const confirmMsg = document.getElementById("review-confirm")!;
const nameInput = document.getElementById("review-name") as HTMLInputElement | null;
const textInput = document.getElementById("review-text") as HTMLTextAreaElement;
form.addEventListener("submit", (e) => {
e.preventDefault();
if (gewaehlteBewertung < 1) {
errorMsg.style.display = "block";
return;
}
errorMsg.style.display = "none";
fuegeBewertungHinzu(slug, {
bewertung: gewaehlteBewertung,
text: textInput.value,
name: isLoggedIn() ? undefined : nameInput?.value,
bestellnummer: bestellnummer || undefined,
});
textInput.value = "";
gewaehlteBewertung = 0;
heartsPickerAktualisieren(pickerRoot, 0);
confirmMsg.style.display = "block";
setTimeout(() => { confirmMsg.style.display = "none"; }, 3000);
renderReviews();
});
</script>
+16 -1
View File
@@ -3,7 +3,7 @@ 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 } from "../../../data/products";
import { products, durchschnittsbewertung, anzahlBewertungen } from "../../../data/products";
import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui";
@@ -97,6 +97,7 @@ const t = useTranslations(lang);
{products.map((p) => (
<div
class="product-slot"
data-slug={p.slug}
data-kategorie={p.kategorie}
data-unterkategorie={p.unterkategorie ?? ""}
data-preis={p.preis}
@@ -104,6 +105,7 @@ const t = useTranslations(lang);
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>
@@ -117,6 +119,8 @@ const t = useTranslations(lang);
</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");
@@ -235,5 +239,16 @@ const t = useTranslations(lang);
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>
+73 -22
View File
@@ -15,6 +15,11 @@ const bestseller = products.filter((p) => p.badges.includes("bestseller")).slice
const sale = products.filter((p) => p.badges.includes("sale") || p.preisAlt).slice(0, 4);
const topBewertungen = alleBewertungen().slice(0, 3);
const bewertungSchnittGesamt = gesamtDurchschnitt();
const produkteBasis = products.map((p) => ({
slug: p.slug,
name: p.name[lang],
bewertungen: (p.bewertungen ?? []).map((r) => ({ name: r.name, bewertung: r.bewertung, text: r.text?.[lang] ?? "", datum: r.datum ?? "" })),
}));
---
<Layout title="Accueil" lang={lang} path="/">
<section class="hero">
@@ -89,30 +94,30 @@ const bewertungSchnittGesamt = gesamtDurchschnitt();
</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">
<section class="section-tight" id="kundenstimmen-section" style={topBewertungen.length > 0 ? undefined : "display:none;"}>
<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" id="kundenstimmen-summary">
{bewertungSchnittGesamt !== null && (
<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>
)}
<div class="grid grid-3" id="kundenstimmen-cards">
{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">
@@ -129,3 +134,49 @@ const bewertungSchnittGesamt = gesamtDurchschnitt();
</div>
</section>
</Layout>
<script
define:vars={{
produkteBasis,
localePrefix: "/fr",
basedOnOne: t.reviews.basedOn(1),
basedOnOther: t.reviews.basedOn(2),
toProductText: t.reviews.toProduct,
}}
>
window.__reviewsHomeVars = { produkteBasis, localePrefix, basedOnOne, basedOnOther, toProductText };
</script>
<script>
import { heartsHtml } from "../../scripts/hearts";
import { lokaleBewertungenFuer } from "../../scripts/reviews";
const { produkteBasis, localePrefix, basedOnOne, basedOnOther, toProductText } = (window as any).__reviewsHomeVars;
function basedOnLabel(n: number): string {
return n === 1 ? basedOnOne : basedOnOther.replace(/\d+/, String(n));
}
const alle: any[] = [];
for (const p of produkteBasis) {
for (const r of p.bewertungen) alle.push({ ...r, produktSlug: p.slug, produktName: p.name });
for (const r of lokaleBewertungenFuer(p.slug)) alle.push({ ...r, produktSlug: p.slug, produktName: p.name });
}
alle.sort((a, b) => (b.datum || "").localeCompare(a.datum || ""));
if (alle.length > 0) {
const schnitt = alle.reduce((s, r) => s + r.bewertung, 0) / alle.length;
const summary = document.getElementById("kundenstimmen-summary")!;
summary.innerHTML = heartsHtml(schnitt, 24, { showValue: true, anzahlLabel: basedOnLabel(alle.length) });
const cards = document.getElementById("kundenstimmen-cards")!;
cards.innerHTML = alle
.slice(0, 3)
.map((r) => {
const text = r.text ? `<p class="small">${String(r.text).replace(/</g, "&lt;")}</p>` : "";
return `<div class="card review-card"><div class="review-card-head"><span class="review-name">${String(r.name).replace(/</g, "&lt;")}</span>${heartsHtml(r.bewertung, 14)}</div>${text}<a class="small" href="${localePrefix}/produkt/${r.produktSlug}/">${toProductText} ${r.produktName}</a></div>`;
})
.join("");
document.getElementById("kundenstimmen-section")!.style.display = "";
}
</script>
+147 -23
View File
@@ -1,7 +1,7 @@
---
import Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.astro";
import { products } from "../../../data/products";
import { products, getProduct } from "../../../data/products";
import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui";
import { formatPrice } from "../../../i18n/format";
@@ -9,32 +9,37 @@ import { formatPrice } from "../../../i18n/format";
const lang: Locale = "fr";
const t = useTranslations(lang);
// VORSCHAU-SEITE (Phase 1): So sieht der persönliche Bereich aus, sobald jemand angemeldet ist.
// Es gibt noch kein echtes Login und keine Datenbank — die Bestellungen unten sind reine
// Beispieldaten, damit Aufbau, Status-Anzeige und Bedienung schon jetzt beurteilt und
// weiterentwickelt werden können. In Phase 2 (Cloudflare Worker + D1) werden sie durch die
// echten Daten der angemeldeten Person ersetzt; Layout und Texte bleiben dann bestehen.
const beispielBestellungen = [
// Bestellungen sind weiterhin Beispieldaten (siehe previewNote) — ECHT sind ab jetzt aber
// Anmeldung, Profil (Benutzername/Foto) und Rezensionen, gespeichert im localStorage dieses
// Geräts (siehe scripts/account.ts, scripts/reviews.ts). Jede Bestellposition referenziert
// einen echten Produkt-Slug — der Name kommt live aus den Produktdaten (mehrsprachig, immer
// konsistent mit dem Shop), damit "Rezension schreiben" wirklich zur passenden Seite führt.
const beispielBestellungenRoh = [
{
nummer: "VDB-2026-0184",
datum: "28.07.2026",
status: "versendet" as const,
summe: 47.5,
tracking: "00340434161094015791",
artikel: [
{ name: "\"HasiDog\" im blauen Overall", menge: 1, preis: 25 },
{ name: "Perlenarmband „Himmelblau\"", menge: 1, preis: 14.5 },
{ slug: "hasidog-plushie-blaueroverall", menge: 1 },
{ slug: "perlen-armband-hellblau", menge: 1 },
],
},
{
nummer: "VDB-2026-0157",
datum: "12.07.2026",
status: "abgeschlossen" as const,
summe: 22,
tracking: null,
artikel: [{ name: "Ergonomisches Häkelnadel-Set (9-teilig)", menge: 1, preis: 22 }],
artikel: [{ slug: "haekelnadel-set-ergonomisch", menge: 1 }],
},
];
const beispielBestellungen = beispielBestellungenRoh.map((b) => {
const artikel = b.artikel.map((a) => {
const p = getProduct(a.slug);
return { ...a, name: p?.name[lang] ?? a.slug, preis: p?.preis ?? 0 };
});
return { ...b, artikel, summe: artikel.reduce((s, a) => s + a.preis * a.menge, 0) };
});
const statusLabel = {
bezahlt: t.accountDash.statusBezahlt,
@@ -49,22 +54,26 @@ const statusLabel = {
const zuletztAngesehen = products.slice(0, 3);
const offeneSendungen = beispielBestellungen.filter((b) => b.status === "versendet").length;
---
<Layout title="Mon compte connecté (aperçu)" description="Aperçu de l'espace client personnel de Van's DIY & Bastelbedarf." lang={lang} path="/konto/angemeldet/">
<Layout title="Mon compte" description="Espace client personnel de Van's DIY & Bastelbedarf." lang={lang} path="/konto/angemeldet/">
<section class="section-tight">
<div class="container">
<p class="todo-note">{t.accountDash.previewNote}</p>
<p class="todo-note" id="not-logged-in-note" style="display:none;">{t.accountDash.notLoggedIn}</p>
<p class="todo-note" id="preview-note">{t.accountDash.previewNote}</p>
</div>
</section>
<section class="section-tight">
<div class="container">
<div class="account-head card">
<div>
<span class="eyebrow">{t.accountDash.eyebrow}</span>
<h1>{t.accountDash.greeting}</h1>
<p class="lead">{t.accountDash.lead}</p>
<div class="account-head-identity">
<span class="account-avatar-lg" id="dash-avatar" aria-hidden="true"></span>
<div>
<span class="eyebrow">{t.accountDash.eyebrow}</span>
<h1 id="dash-greeting">{t.accountDash.greeting("…")}</h1>
<p class="lead">{t.accountDash.lead}</p>
</div>
</div>
<a class="btn btn-outline btn-sm" href="/fr/konto/">{t.accountDash.logout}</a>
<button type="button" class="btn btn-outline btn-sm" id="dash-logout-btn">{t.accountDash.logout}</button>
</div>
<div class="account-stats">
@@ -102,7 +111,14 @@ const offeneSendungen = beispielBestellungen.filter((b) => b.status === "versend
{b.artikel.map((a) => (
<li>
<span>{a.menge}× {a.name}</span>
<span class="order-item-price">{formatPrice(a.preis * a.menge, lang)}</span>
<span class="order-item-right">
<span class="order-item-price">{formatPrice(a.preis * a.menge, lang)}</span>
{/* Zustand (Button vs. "✓ Bewertet") hängt vom localStorage ab — wird per JS
beim Laden gesetzt (siehe Script unten), Grundzustand ist der Button. */}
<span class="order-item-review" data-order-review data-bestellnummer={b.nummer} data-slug={a.slug}>
<a class="small review-link" href={`/fr/produkt/${a.slug}/?bewerten=${b.nummer}`}>{t.accountDash.reviewBtn}</a>
</span>
</span>
</li>
))}
</ul>
@@ -146,15 +162,123 @@ const offeneSendungen = beispielBestellungen.filter((b) => b.status === "versend
<div class="container">
<h2>{t.accountDash.profileTitle}</h2>
<div class="card profile-card">
<div class="profile-photo-row">
<span class="account-avatar-lg" id="profile-avatar" aria-hidden="true"></span>
<div>
<span class="small" style="display:block; margin-bottom:0.4rem;">{t.accountDash.photoLabel}</span>
<label class="btn btn-outline btn-sm profile-photo-btn">
{t.accountDash.photoChange}
<input type="file" accept="image/*" id="profile-photo-input" style="display:none;" />
</label>
<button type="button" class="btn btn-outline btn-sm" id="profile-photo-remove">{t.accountDash.photoRemove}</button>
</div>
</div>
<div class="profile-field">
<label for="profile-username">{t.accountDash.usernameLabel}</label>
<div class="profile-field-row">
<input type="text" id="profile-username" maxlength="40" />
<button type="button" class="btn btn-primary btn-sm" id="profile-username-save">{t.accountDash.usernameSave}</button>
</div>
<span class="small" id="profile-username-status" style="display:none; color: var(--c-accent);">{t.accountDash.usernameSaved}</span>
</div>
<table class="specs">
<tbody>
<tr><th>{t.accountDash.profileName}</th><td>Marie Beispiel</td></tr>
<tr><th>{t.accountDash.profileEmail}</th><td>[email protected]</td></tr>
<tr><th>{t.accountDash.profileEmail}</th><td id="profile-email"></td></tr>
<tr><th>{t.accountDash.profileAddress}</th><td>Musterstraße 1<br />10115 Berlin<br />Deutschland</td></tr>
</tbody>
</table>
<a class="btn btn-outline btn-sm" href="#">{t.accountDash.profileEdit}</a>
</div>
</div>
</section>
</Layout>
<script define:vars={{ loginPath: "/fr/konto/", fallbackName: "Cliente", greetingTemplate: t.accountDash.greeting("{name}"), reviewedBadgeText: t.accountDash.reviewedBadge }}>
window.__accountDashVars = { loginPath, fallbackName, greetingTemplate, reviewedBadgeText };
</script>
<script>
import { getAccount, isLoggedIn, logout, setUsername, setPhoto, hatBestellungBewertet } from "../../../scripts/account";
const { loginPath, fallbackName, greetingTemplate, reviewedBadgeText } = (window as any).__accountDashVars;
// Zugriffsschutz: diese Seite ist nur für angemeldete Kund:innen gedacht. Es gibt noch kein
// echtes Backend, das den Zugriff serverseitig verweigern könnte (Phase 2) — deshalb hier per
// JS geprüft und bei fehlender Anmeldung sofort zur Login-Seite weitergeleitet.
if (!isLoggedIn()) {
const note = document.getElementById("not-logged-in-note");
const preview = document.getElementById("preview-note");
if (note) note.style.display = "block";
if (preview) preview.style.display = "none";
setTimeout(() => { window.location.href = loginPath; }, 900);
} else {
function avatarHtml(username: string, photo: string): { bg: string; text: string } {
if (photo) return { bg: `url(${photo})`, text: "" };
return { bg: "", text: (username || "?").trim().charAt(0).toUpperCase() };
}
function renderProfil() {
const a = getAccount();
const greetingEl = document.getElementById("dash-greeting");
if (greetingEl) greetingEl.textContent = greetingTemplate.replace("{name}", a.username || fallbackName);
const emailEl = document.getElementById("profile-email");
if (emailEl) emailEl.textContent = a.email || "";
const usernameInput = document.getElementById("profile-username") as HTMLInputElement | null;
if (usernameInput && document.activeElement !== usernameInput) usernameInput.value = a.username;
const { bg, text } = avatarHtml(a.username, a.photo);
[document.getElementById("dash-avatar"), document.getElementById("profile-avatar")].forEach((el) => {
if (!el) return;
el.style.backgroundImage = bg;
el.textContent = text;
});
}
renderProfil();
window.addEventListener("account:changed", renderProfil);
// Benutzername ändern
const saveBtn = document.getElementById("profile-username-save");
const usernameInput = document.getElementById("profile-username") as HTMLInputElement | null;
const savedStatus = document.getElementById("profile-username-status");
saveBtn?.addEventListener("click", () => {
if (!usernameInput?.value.trim()) return;
setUsername(usernameInput.value);
if (savedStatus) {
savedStatus.style.display = "inline";
setTimeout(() => { savedStatus.style.display = "none"; }, 2000);
}
});
// Profilfoto hochladen (wird als data:-URL im localStorage abgelegt — reicht für diese
// Demo völlig aus, in Phase 2 würde ein echter Datei-Upload an den Server dahinterstehen)
const photoInput = document.getElementById("profile-photo-input") as HTMLInputElement | null;
photoInput?.addEventListener("change", () => {
const file = photoInput.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setPhoto(String(reader.result));
reader.readAsDataURL(file);
});
document.getElementById("profile-photo-remove")?.addEventListener("click", () => setPhoto(""));
// Logout direkt aus dem Dashboard heraus
document.getElementById("dash-logout-btn")?.addEventListener("click", () => {
logout();
window.location.href = loginPath;
});
// Pro Bestellposition: Button "Rezension schreiben" durch "✓ Bewertet" ersetzen, sobald für
// genau diese Bestellung+Produkt schon eine Rezension existiert (siehe scripts/reviews.ts,
// wird beim Absenden des Formulars auf der Produktseite markiert).
document.querySelectorAll<HTMLElement>("[data-order-review]").forEach((el) => {
const bestellnummer = el.dataset.bestellnummer!;
const slug = el.dataset.slug!;
if (hatBestellungBewertet(bestellnummer, slug)) {
el.innerHTML = "";
const badge = document.createElement("span");
badge.className = "badge review-done-badge";
badge.textContent = reviewedBadgeText;
el.appendChild(badge);
}
});
}
</script>
+22 -6
View File
@@ -17,11 +17,12 @@ const t = useTranslations(lang);
<section class="section-tight">
<div class="container grid grid-2 account-grid">
<form class="frm card">
<form class="frm card" id="login-form">
<h3>{t.account.loginTitle}</h3>
<div><label for="login-email">{t.account.email}</label><input id="login-email" type="email" /></div>
<div><label for="login-email">{t.account.email}</label><input id="login-email" type="email" required /></div>
<div><label for="login-pw">{t.account.password}</label><input id="login-pw" type="password" /></div>
<button class="btn btn-primary btn-block" type="button" disabled>{t.account.loginButton}</button>
<button class="btn btn-primary btn-block" type="submit">{t.account.loginButton}</button>
<p class="small" id="login-error" style="display:none; color: var(--c-sale);">{t.account.loginError}</p>
<a class="small" href="#">{t.account.forgotPassword}</a>
</form>
@@ -31,8 +32,23 @@ const t = useTranslations(lang);
<a class="btn btn-outline btn-block" href="/fr/shop/">{t.account.guestButton}</a>
</div>
</div>
<div class="container" style="margin-top:1.2rem;">
<a class="small" href="/fr/konto/angemeldet/">{t.account.previewLink}</a>
</div>
</section>
</Layout>
<script>
import { login } from "../../../scripts/account";
const form = document.getElementById("login-form") as HTMLFormElement | null;
const emailInput = document.getElementById("login-email") as HTMLInputElement | null;
const error = document.getElementById("login-error");
form?.addEventListener("submit", (e) => {
e.preventDefault();
const email = emailInput?.value.trim() ?? "";
if (!email) {
if (error) error.style.display = "block";
return;
}
login(email);
window.location.href = "/fr/konto/angemeldet/";
});
</script>
+182 -18
View File
@@ -134,27 +134,53 @@ const felder: [string, string | undefined][] = [
<section class="section-tight" id="bewertungen">
<div class="container">
<h2>{t.product.reviewsTitle}</h2>
{bewertungSchnitt !== null ? (
<>
{/* Server-gerenderter Ausgangszustand (funktioniert auch ohne JS) — wird beim Laden per
Skript durch die mit lokal gespeicherten Kundenrezensionen zusammengeführte Liste
ersetzt (siehe scripts/reviews.ts), damit neue Rezensionen sofort überall auftauchen. */}
<div id="review-summary-slot">
{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>
))}
) : (
<p class="small">{t.product.noReviews}</p>
)}
</div>
<div class="review-list" id="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>
</>
) : (
<p class="small">{t.product.noReviews}</p>
)}
))}
</div>
<div class="card review-form-card" id="review-form-card">
<h3>{t.product.writeReviewTitle}</h3>
<p class="small review-order-note" id="review-order-note" style="display:none;"></p>
<p class="small review-login-note" id="review-login-note">{t.product.notLoggedInReview}</p>
<form id="review-form">
<div class="review-form-row">
<span class="review-form-label">{t.product.yourRating}</span>
<div id="review-heart-picker"></div>
</div>
<div id="review-name-field">
<label for="review-name">{t.product.yourName}</label>
<input type="text" id="review-name" maxlength="40" />
</div>
<div>
<label for="review-text">{t.product.yourReview}</label>
<textarea id="review-text" rows="3"></textarea>
</div>
<button class="btn btn-primary" type="submit">{t.product.submitReview}</button>
<p class="small" id="review-error" style="display:none; color: var(--c-sale);">{t.product.reviewNeedsRating}</p>
<p class="small" id="review-confirm" role="status" style="display:none; color: var(--c-accent);">{t.product.reviewSaved}</p>
</form>
</div>
</div>
</section>
@@ -244,9 +270,147 @@ const felder: [string, string | undefined][] = [
// Klick auf ein Miniaturbild zeigt es groß im Hauptbild an (mehrere Produktfotos).
const heroImg = document.getElementById("product-hero-img") as HTMLImageElement | null;
document.querySelectorAll<HTMLImageElement>(".thumb.product-photo").forEach((thumb) => {
const thumbs = [...document.querySelectorAll<HTMLImageElement>(".thumb.product-photo")];
thumbs.forEach((thumb) => {
thumb.addEventListener("click", () => {
if (heroImg && thumb.dataset.full) heroImg.src = thumb.dataset.full;
});
});
// Auch das große Hauptbild lässt sich wischen (Finger auf dem Handy, Maus ziehen am PC) — springt
// dabei einfach zum nächsten/vorherigen Miniaturbild, exakt wie ein Klick auf den Thumbnail.
if (heroImg && thumbs.length > 1) {
let startX = 0, startY = 0;
heroImg.style.touchAction = "pan-y";
heroImg.addEventListener("pointerdown", (e) => { startX = e.clientX; startY = e.clientY; });
heroImg.addEventListener("pointerup", (e) => {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.abs(dx) < 40 || Math.abs(dx) < Math.abs(dy)) return;
const aktuellerIndex = Math.max(0, thumbs.findIndex((th) => th.dataset.full && heroImg.src.endsWith(th.dataset.full)));
const naechster = ((aktuellerIndex + (dx < 0 ? 1 : -1)) % thumbs.length + thumbs.length) % thumbs.length;
heroImg.src = thumbs[naechster].dataset.full!;
});
}
</script>
<script
define:vars={{
slug: product.slug,
serverReviews: (product.bewertungen ?? []).map((r) => ({ name: r.name, bewertung: r.bewertung, text: r.text?.[lang] ?? "", datum: r.datum ?? "" })),
noReviewsText: t.product.noReviews,
reviewCountOne: t.product.reviewCount(1),
reviewCountOther: t.product.reviewCount(2),
loginHintReview: t.product.loginHintReview,
notLoggedInReview: t.product.notLoggedInReview,
reviewFromOrderTemplate: t.product.reviewFromOrder("__NR__"),
reviewNeedsRatingText: t.product.reviewNeedsRating,
reviewSavedText: t.product.reviewSaved,
}}
>
window.__reviewVars = {
slug, serverReviews, noReviewsText, reviewCountOne, reviewCountOther,
loginHintReview, notLoggedInReview, reviewFromOrderTemplate, reviewNeedsRatingText, reviewSavedText,
};
</script>
<script>
import { heartsHtml, heartsPickerHtml, heartsPickerAktualisieren } from "../../../scripts/hearts";
import { bewertungenZusammenfuehren, durchschnitt, fuegeBewertungHinzu, type AnzeigeBewertung } from "../../../scripts/reviews";
import { isLoggedIn, getAccount } from "../../../scripts/account";
const {
slug, serverReviews, noReviewsText, reviewCountOne, reviewCountOther,
loginHintReview, notLoggedInReview, reviewFromOrderTemplate, reviewNeedsRatingText, reviewSavedText,
} = (window as any).__reviewVars;
function reviewCountLabel(n: number): string {
return n === 1 ? reviewCountOne : reviewCountOther.replace(/^\d+/, String(n));
}
const summarySlot = document.getElementById("review-summary-slot")!;
const listSlot = document.getElementById("review-list")!;
function renderReviews() {
const merged: AnzeigeBewertung[] = bewertungenZusammenfuehren(serverReviews, slug);
const schnitt = durchschnitt(merged);
summarySlot.innerHTML =
schnitt === null
? `<p class="small">${noReviewsText}</p>`
: `<div class="review-summary-row">${heartsHtml(schnitt, 26, { showValue: true, anzahlLabel: reviewCountLabel(merged.length) })}</div>`;
listSlot.innerHTML = merged
.map((r) => {
const datum = r.datum ? `<span class="review-date small">${r.datum}</span>` : "";
const text = r.text ? `<p class="small">${r.text.replace(/</g, "&lt;")}</p>` : "";
return `<div class="card review-card"><div class="review-card-head"><span class="review-name">${r.name.replace(/</g, "&lt;")}</span>${heartsHtml(r.bewertung, 14)}${datum}</div>${text}</div>`;
})
.join("");
}
renderReviews();
// Bewertungsformular: eigenes Herzen-Auswahlfeld (kein natives <select>, siehe scripts/hearts.ts)
const picker = document.getElementById("review-heart-picker")!;
picker.innerHTML = heartsPickerHtml(28);
const pickerRoot = picker.querySelector<HTMLElement>("[data-heart-picker]")!;
let gewaehlteBewertung = 0;
pickerRoot.querySelectorAll<HTMLButtonElement>(".heart-pick").forEach((btn) => {
const wert = Number(btn.dataset.wert);
btn.addEventListener("mouseenter", () => heartsPickerAktualisieren(pickerRoot, wert));
btn.addEventListener("mouseleave", () => heartsPickerAktualisieren(pickerRoot, gewaehlteBewertung));
btn.addEventListener("click", () => {
gewaehlteBewertung = wert;
heartsPickerAktualisieren(pickerRoot, gewaehlteBewertung);
});
});
// Name nur abfragen, wenn niemand angemeldet ist — angemeldete Kund:innen bewerten automatisch
// unter ihrem Benutzernamen (siehe scripts/account.ts).
const nameField = document.getElementById("review-name-field")!;
const loginNote = document.getElementById("review-login-note")!;
if (isLoggedIn()) {
nameField.style.display = "none";
loginNote.textContent = `${loginHintReview} ${getAccount().username}`;
} else {
nameField.style.display = "";
loginNote.textContent = notLoggedInReview;
}
// Kommt der Aufruf von einer Bestellung im Kundenkonto ("Rezension schreiben"), steht die
// Bestellnummer als ?bewerten=... in der URL — Hinweistext einblenden, zum Formular scrollen
// und die Nummer beim Absenden mitschicken, damit das Konto sie als "✓ Bewertet" markieren kann.
const bestellnummer = new URLSearchParams(location.search).get("bewerten");
if (bestellnummer) {
const note = document.getElementById("review-order-note")!;
note.textContent = reviewFromOrderTemplate.replace("__NR__", bestellnummer);
note.style.display = "block";
document.getElementById("review-form-card")?.scrollIntoView({ behavior: "smooth", block: "center" });
}
const form = document.getElementById("review-form") as HTMLFormElement;
const errorMsg = document.getElementById("review-error")!;
const confirmMsg = document.getElementById("review-confirm")!;
const nameInput = document.getElementById("review-name") as HTMLInputElement | null;
const textInput = document.getElementById("review-text") as HTMLTextAreaElement;
form.addEventListener("submit", (e) => {
e.preventDefault();
if (gewaehlteBewertung < 1) {
errorMsg.style.display = "block";
return;
}
errorMsg.style.display = "none";
fuegeBewertungHinzu(slug, {
bewertung: gewaehlteBewertung,
text: textInput.value,
name: isLoggedIn() ? undefined : nameInput?.value,
bestellnummer: bestellnummer || undefined,
});
textInput.value = "";
gewaehlteBewertung = 0;
heartsPickerAktualisieren(pickerRoot, 0);
confirmMsg.style.display = "block";
setTimeout(() => { confirmMsg.style.display = "none"; }, 3000);
renderReviews();
});
</script>
+16 -1
View File
@@ -3,7 +3,7 @@ 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 } from "../../../data/products";
import { products, durchschnittsbewertung, anzahlBewertungen } from "../../../data/products";
import type { Locale } from "../../../i18n/config";
import { useTranslations } from "../../../i18n/ui";
@@ -97,6 +97,7 @@ const t = useTranslations(lang);
{products.map((p) => (
<div
class="product-slot"
data-slug={p.slug}
data-kategorie={p.kategorie}
data-unterkategorie={p.unterkategorie ?? ""}
data-preis={p.preis}
@@ -104,6 +105,7 @@ const t = useTranslations(lang);
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>
@@ -117,6 +119,8 @@ const t = useTranslations(lang);
</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");
@@ -235,5 +239,16 @@ const t = useTranslations(lang);
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>
+89 -25
View File
@@ -13,11 +13,22 @@ 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.
// Kundenstimmen-Sektion — erscheint erst automatisch, sobald die erste ECHTE Kundenrezension
// existiert (entweder eine Rezension, die VanVan im Adminbereich hinterlegt hat, ODER eine, die
// ein:e Kund:in direkt auf der Produktseite geschrieben hat, siehe scripts/reviews.ts). Bewusst
// KEINE erfundenen Beispiel-Bewertungen im Code — Fake-Bewertungen sind in Deutschland/der EU
// verboten. Der Server-Stand (topBewertungen) ist nur der Ausgangspunkt; ein Skript unten führt
// ihn beim Laden mit lokal auf dem jeweiligen Gerät gespeicherten Rezensionen zusammen.
const topBewertungen = alleBewertungen().slice(0, 3);
const bewertungSchnittGesamt = gesamtDurchschnitt();
// Basis-Daten aller Produkte fürs Zusammenführen im Browser (Name + vorhandene Server-
// Rezensionen, jeweils schon in der richtigen Sprache) — klein genug, um sie einfach mit ins
// HTML zu geben, statt eine eigene API dafür zu bauen.
const produkteBasis = products.map((p) => ({
slug: p.slug,
name: p.name[lang],
bewertungen: (p.bewertungen ?? []).map((r) => ({ name: r.name, bewertung: r.bewertung, text: r.text?.[lang] ?? "", datum: r.datum ?? "" })),
}));
---
<Layout title="Startseite" lang={lang} path="/">
<section class="hero">
@@ -92,30 +103,34 @@ const bewertungSchnittGesamt = gesamtDurchschnitt();
</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">
{/* Immer im HTML vorhanden (nicht nur bei Server-Rezensionen), aber standardmäßig versteckt —
ein Skript weiter unten führt Server- und lokal gespeicherte Kundenrezensionen zusammen und
blendet die Sektion ein, sobald mindestens eine existiert (auf JEDEM Gerät neu berechnet,
nicht nur auf dem dieser Baubau-Version). */}
<section class="section-tight" id="kundenstimmen-section" style={topBewertungen.length > 0 ? undefined : "display:none;"}>
<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" id="kundenstimmen-summary">
{bewertungSchnittGesamt !== null && (
<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>
)}
<div class="grid grid-3" id="kundenstimmen-cards">
{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">
@@ -132,3 +147,52 @@ const bewertungSchnittGesamt = gesamtDurchschnitt();
</div>
</section>
</Layout>
<script
define:vars={{
produkteBasis,
localePrefix: "",
basedOnOne: t.reviews.basedOn(1),
basedOnOther: t.reviews.basedOn(2),
toProductText: t.reviews.toProduct,
}}
>
window.__reviewsHomeVars = { produkteBasis, localePrefix, basedOnOne, basedOnOther, toProductText };
</script>
<script>
import { heartsHtml } from "../scripts/hearts";
import { lokaleBewertungenFuer } from "../scripts/reviews";
const { produkteBasis, localePrefix, basedOnOne, basedOnOther, toProductText } = (window as any).__reviewsHomeVars;
function basedOnLabel(n: number): string {
return n === 1 ? basedOnOne : basedOnOther.replace(/\d+/, String(n));
}
// Server-Rezensionen (aus den Produktdaten) + lokal auf diesem Gerät gespeicherte
// Kundenrezensionen (siehe scripts/reviews.ts) über ALLE Produkte hinweg zusammenführen —
// genau wie auf der einzelnen Produktseite, nur eben produktübergreifend für die Startseite.
const alle: any[] = [];
for (const p of produkteBasis) {
for (const r of p.bewertungen) alle.push({ ...r, produktSlug: p.slug, produktName: p.name });
for (const r of lokaleBewertungenFuer(p.slug)) alle.push({ ...r, produktSlug: p.slug, produktName: p.name });
}
alle.sort((a, b) => (b.datum || "").localeCompare(a.datum || ""));
if (alle.length > 0) {
const schnitt = alle.reduce((s, r) => s + r.bewertung, 0) / alle.length;
const summary = document.getElementById("kundenstimmen-summary")!;
summary.innerHTML = heartsHtml(schnitt, 24, { showValue: true, anzahlLabel: basedOnLabel(alle.length) });
const cards = document.getElementById("kundenstimmen-cards")!;
cards.innerHTML = alle
.slice(0, 3)
.map((r) => {
const text = r.text ? `<p class="small">${String(r.text).replace(/</g, "&lt;")}</p>` : "";
return `<div class="card review-card"><div class="review-card-head"><span class="review-name">${String(r.name).replace(/</g, "&lt;")}</span>${heartsHtml(r.bewertung, 14)}</div>${text}<a class="small" href="${localePrefix}/produkt/${r.produktSlug}/">${toProductText} ${r.produktName}</a></div>`;
})
.join("");
document.getElementById("kundenstimmen-section")!.style.display = "";
}
</script>
+147 -23
View File
@@ -1,7 +1,7 @@
---
import Layout from "../../layouts/Layout.astro";
import ProductCard from "../../components/ProductCard.astro";
import { products } from "../../data/products";
import { products, getProduct } from "../../data/products";
import type { Locale } from "../../i18n/config";
import { useTranslations } from "../../i18n/ui";
import { formatPrice } from "../../i18n/format";
@@ -9,32 +9,37 @@ import { formatPrice } from "../../i18n/format";
const lang: Locale = "de";
const t = useTranslations(lang);
// VORSCHAU-SEITE (Phase 1): So sieht der persönliche Bereich aus, sobald jemand angemeldet ist.
// Es gibt noch kein echtes Login und keine Datenbank — die Bestellungen unten sind reine
// Beispieldaten, damit Aufbau, Status-Anzeige und Bedienung schon jetzt beurteilt und
// weiterentwickelt werden können. In Phase 2 (Cloudflare Worker + D1) werden sie durch die
// echten Daten der angemeldeten Person ersetzt; Layout und Texte bleiben dann bestehen.
const beispielBestellungen = [
// Bestellungen sind weiterhin Beispieldaten (siehe previewNote) — ECHT sind ab jetzt aber
// Anmeldung, Profil (Benutzername/Foto) und Rezensionen, gespeichert im localStorage dieses
// Geräts (siehe scripts/account.ts, scripts/reviews.ts). Jede Bestellposition referenziert
// einen echten Produkt-Slug — der Name kommt live aus den Produktdaten (mehrsprachig, immer
// konsistent mit dem Shop), damit "Rezension schreiben" wirklich zur passenden Seite führt.
const beispielBestellungenRoh = [
{
nummer: "VDB-2026-0184",
datum: "28.07.2026",
status: "versendet" as const,
summe: 47.5,
tracking: "00340434161094015791",
artikel: [
{ name: "\"HasiDog\" im blauen Overall", menge: 1, preis: 25 },
{ name: "Perlenarmband „Himmelblau\"", menge: 1, preis: 14.5 },
{ slug: "hasidog-plushie-blaueroverall", menge: 1 },
{ slug: "perlen-armband-hellblau", menge: 1 },
],
},
{
nummer: "VDB-2026-0157",
datum: "12.07.2026",
status: "abgeschlossen" as const,
summe: 22,
tracking: null,
artikel: [{ name: "Ergonomisches Häkelnadel-Set (9-teilig)", menge: 1, preis: 22 }],
artikel: [{ slug: "haekelnadel-set-ergonomisch", menge: 1 }],
},
];
const beispielBestellungen = beispielBestellungenRoh.map((b) => {
const artikel = b.artikel.map((a) => {
const p = getProduct(a.slug);
return { ...a, name: p?.name[lang] ?? a.slug, preis: p?.preis ?? 0 };
});
return { ...b, artikel, summe: artikel.reduce((s, a) => s + a.preis * a.menge, 0) };
});
const statusLabel = {
bezahlt: t.accountDash.statusBezahlt,
@@ -49,22 +54,26 @@ const statusLabel = {
const zuletztAngesehen = products.slice(0, 3);
const offeneSendungen = beispielBestellungen.filter((b) => b.status === "versendet").length;
---
<Layout title="Mein Konto angemeldet (Vorschau)" description="Vorschau des persönlichen Kundenbereichs von Van's DIY & Bastelbedarf." lang={lang} path="/konto/angemeldet/">
<Layout title="Mein Konto" description="Persönlicher Kundenbereich von Van's DIY & Bastelbedarf." lang={lang} path="/konto/angemeldet/">
<section class="section-tight">
<div class="container">
<p class="todo-note">{t.accountDash.previewNote}</p>
<p class="todo-note" id="not-logged-in-note" style="display:none;">{t.accountDash.notLoggedIn}</p>
<p class="todo-note" id="preview-note">{t.accountDash.previewNote}</p>
</div>
</section>
<section class="section-tight">
<div class="container">
<div class="account-head card">
<div>
<span class="eyebrow">{t.accountDash.eyebrow}</span>
<h1>{t.accountDash.greeting}</h1>
<p class="lead">{t.accountDash.lead}</p>
<div class="account-head-identity">
<span class="account-avatar-lg" id="dash-avatar" aria-hidden="true"></span>
<div>
<span class="eyebrow">{t.accountDash.eyebrow}</span>
<h1 id="dash-greeting">{t.accountDash.greeting("…")}</h1>
<p class="lead">{t.accountDash.lead}</p>
</div>
</div>
<a class="btn btn-outline btn-sm" href="/konto/">{t.accountDash.logout}</a>
<button type="button" class="btn btn-outline btn-sm" id="dash-logout-btn">{t.accountDash.logout}</button>
</div>
<div class="account-stats">
@@ -102,7 +111,14 @@ const offeneSendungen = beispielBestellungen.filter((b) => b.status === "versend
{b.artikel.map((a) => (
<li>
<span>{a.menge}× {a.name}</span>
<span class="order-item-price">{formatPrice(a.preis * a.menge, lang)}</span>
<span class="order-item-right">
<span class="order-item-price">{formatPrice(a.preis * a.menge, lang)}</span>
{/* Zustand (Button vs. "✓ Bewertet") hängt vom localStorage ab — wird per JS
beim Laden gesetzt (siehe Script unten), Grundzustand ist der Button. */}
<span class="order-item-review" data-order-review data-bestellnummer={b.nummer} data-slug={a.slug}>
<a class="small review-link" href={`/produkt/${a.slug}/?bewerten=${b.nummer}`}>{t.accountDash.reviewBtn}</a>
</span>
</span>
</li>
))}
</ul>
@@ -146,15 +162,123 @@ const offeneSendungen = beispielBestellungen.filter((b) => b.status === "versend
<div class="container">
<h2>{t.accountDash.profileTitle}</h2>
<div class="card profile-card">
<div class="profile-photo-row">
<span class="account-avatar-lg" id="profile-avatar" aria-hidden="true"></span>
<div>
<span class="small" style="display:block; margin-bottom:0.4rem;">{t.accountDash.photoLabel}</span>
<label class="btn btn-outline btn-sm profile-photo-btn">
{t.accountDash.photoChange}
<input type="file" accept="image/*" id="profile-photo-input" style="display:none;" />
</label>
<button type="button" class="btn btn-outline btn-sm" id="profile-photo-remove">{t.accountDash.photoRemove}</button>
</div>
</div>
<div class="profile-field">
<label for="profile-username">{t.accountDash.usernameLabel}</label>
<div class="profile-field-row">
<input type="text" id="profile-username" maxlength="40" />
<button type="button" class="btn btn-primary btn-sm" id="profile-username-save">{t.accountDash.usernameSave}</button>
</div>
<span class="small" id="profile-username-status" style="display:none; color: var(--c-accent);">{t.accountDash.usernameSaved}</span>
</div>
<table class="specs">
<tbody>
<tr><th>{t.accountDash.profileName}</th><td>Marie Beispiel</td></tr>
<tr><th>{t.accountDash.profileEmail}</th><td>[email protected]</td></tr>
<tr><th>{t.accountDash.profileEmail}</th><td id="profile-email"></td></tr>
<tr><th>{t.accountDash.profileAddress}</th><td>Musterstraße 1<br />10115 Berlin<br />Deutschland</td></tr>
</tbody>
</table>
<a class="btn btn-outline btn-sm" href="#">{t.accountDash.profileEdit}</a>
</div>
</div>
</section>
</Layout>
<script define:vars={{ loginPath: "/konto/", fallbackName: "Kundin", greetingTemplate: t.accountDash.greeting("{name}"), reviewedBadgeText: t.accountDash.reviewedBadge }}>
window.__accountDashVars = { loginPath, fallbackName, greetingTemplate, reviewedBadgeText };
</script>
<script>
import { getAccount, isLoggedIn, logout, setUsername, setPhoto, hatBestellungBewertet } from "../../scripts/account";
const { loginPath, fallbackName, greetingTemplate, reviewedBadgeText } = (window as any).__accountDashVars;
// Zugriffsschutz: diese Seite ist nur für angemeldete Kund:innen gedacht. Es gibt noch kein
// echtes Backend, das den Zugriff serverseitig verweigern könnte (Phase 2) — deshalb hier per
// JS geprüft und bei fehlender Anmeldung sofort zur Login-Seite weitergeleitet.
if (!isLoggedIn()) {
const note = document.getElementById("not-logged-in-note");
const preview = document.getElementById("preview-note");
if (note) note.style.display = "block";
if (preview) preview.style.display = "none";
setTimeout(() => { window.location.href = loginPath; }, 900);
} else {
function avatarHtml(username: string, photo: string): { bg: string; text: string } {
if (photo) return { bg: `url(${photo})`, text: "" };
return { bg: "", text: (username || "?").trim().charAt(0).toUpperCase() };
}
function renderProfil() {
const a = getAccount();
const greetingEl = document.getElementById("dash-greeting");
if (greetingEl) greetingEl.textContent = greetingTemplate.replace("{name}", a.username || fallbackName);
const emailEl = document.getElementById("profile-email");
if (emailEl) emailEl.textContent = a.email || "";
const usernameInput = document.getElementById("profile-username") as HTMLInputElement | null;
if (usernameInput && document.activeElement !== usernameInput) usernameInput.value = a.username;
const { bg, text } = avatarHtml(a.username, a.photo);
[document.getElementById("dash-avatar"), document.getElementById("profile-avatar")].forEach((el) => {
if (!el) return;
el.style.backgroundImage = bg;
el.textContent = text;
});
}
renderProfil();
window.addEventListener("account:changed", renderProfil);
// Benutzername ändern
const saveBtn = document.getElementById("profile-username-save");
const usernameInput = document.getElementById("profile-username") as HTMLInputElement | null;
const savedStatus = document.getElementById("profile-username-status");
saveBtn?.addEventListener("click", () => {
if (!usernameInput?.value.trim()) return;
setUsername(usernameInput.value);
if (savedStatus) {
savedStatus.style.display = "inline";
setTimeout(() => { savedStatus.style.display = "none"; }, 2000);
}
});
// Profilfoto hochladen (wird als data:-URL im localStorage abgelegt — reicht für diese
// Demo völlig aus, in Phase 2 würde ein echter Datei-Upload an den Server dahinterstehen)
const photoInput = document.getElementById("profile-photo-input") as HTMLInputElement | null;
photoInput?.addEventListener("change", () => {
const file = photoInput.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setPhoto(String(reader.result));
reader.readAsDataURL(file);
});
document.getElementById("profile-photo-remove")?.addEventListener("click", () => setPhoto(""));
// Logout direkt aus dem Dashboard heraus
document.getElementById("dash-logout-btn")?.addEventListener("click", () => {
logout();
window.location.href = loginPath;
});
// Pro Bestellposition: Button "Rezension schreiben" durch "✓ Bewertet" ersetzen, sobald für
// genau diese Bestellung+Produkt schon eine Rezension existiert (siehe scripts/reviews.ts,
// wird beim Absenden des Formulars auf der Produktseite markiert).
document.querySelectorAll<HTMLElement>("[data-order-review]").forEach((el) => {
const bestellnummer = el.dataset.bestellnummer!;
const slug = el.dataset.slug!;
if (hatBestellungBewertet(bestellnummer, slug)) {
el.innerHTML = "";
const badge = document.createElement("span");
badge.className = "badge review-done-badge";
badge.textContent = reviewedBadgeText;
el.appendChild(badge);
}
});
}
</script>
+22 -8
View File
@@ -17,11 +17,12 @@ const t = useTranslations(lang);
<section class="section-tight">
<div class="container grid grid-2 account-grid">
<form class="frm card">
<form class="frm card" id="login-form">
<h3>{t.account.loginTitle}</h3>
<div><label for="login-email">{t.account.email}</label><input id="login-email" type="email" /></div>
<div><label for="login-email">{t.account.email}</label><input id="login-email" type="email" required /></div>
<div><label for="login-pw">{t.account.password}</label><input id="login-pw" type="password" /></div>
<button class="btn btn-primary btn-block" type="button" disabled>{t.account.loginButton}</button>
<button class="btn btn-primary btn-block" type="submit">{t.account.loginButton}</button>
<p class="small" id="login-error" style="display:none; color: var(--c-sale);">{t.account.loginError}</p>
<a class="small" href="#">{t.account.forgotPassword}</a>
</form>
@@ -31,10 +32,23 @@ const t = useTranslations(lang);
<a class="btn btn-outline btn-block" href="/shop/">{t.account.guestButton}</a>
</div>
</div>
{/* Direkter Weg zur Vorschau des eingeloggten Kundenbereichs — solange es noch kein echtes
Login gibt (Phase 2), wäre diese Ansicht sonst gar nicht erreichbar. */}
<div class="container" style="margin-top:1.2rem;">
<a class="small" href="/konto/angemeldet/">{t.account.previewLink}</a>
</div>
</section>
</Layout>
<script>
import { login } from "../../scripts/account";
const form = document.getElementById("login-form") as HTMLFormElement | null;
const emailInput = document.getElementById("login-email") as HTMLInputElement | null;
const error = document.getElementById("login-error");
form?.addEventListener("submit", (e) => {
e.preventDefault();
const email = emailInput?.value.trim() ?? "";
if (!email) {
if (error) error.style.display = "block";
return;
}
login(email);
window.location.href = "/konto/angemeldet/";
});
</script>
+164 -17
View File
@@ -134,27 +134,53 @@ const felder: [string, string | undefined][] = [
<section class="section-tight" id="bewertungen">
<div class="container">
<h2>{t.product.reviewsTitle}</h2>
{bewertungSchnitt !== null ? (
<>
{/* Server-gerenderter Ausgangszustand (funktioniert auch ohne JS) — wird beim Laden per
Skript durch die mit lokal gespeicherten Kundenrezensionen zusammengeführte Liste
ersetzt (siehe scripts/reviews.ts), damit neue Rezensionen sofort überall auftauchen. */}
<div id="review-summary-slot">
{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>
))}
) : (
<p class="small">{t.product.noReviews}</p>
)}
</div>
<div class="review-list" id="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>
</>
) : (
<p class="small">{t.product.noReviews}</p>
)}
))}
</div>
<div class="card review-form-card" id="review-form-card">
<h3>{t.product.writeReviewTitle}</h3>
<p class="small review-order-note" id="review-order-note" style="display:none;"></p>
<p class="small review-login-note" id="review-login-note">{t.product.notLoggedInReview}</p>
<form id="review-form">
<div class="review-form-row">
<span class="review-form-label">{t.product.yourRating}</span>
<div id="review-heart-picker"></div>
</div>
<div id="review-name-field">
<label for="review-name">{t.product.yourName}</label>
<input type="text" id="review-name" maxlength="40" />
</div>
<div>
<label for="review-text">{t.product.yourReview}</label>
<textarea id="review-text" rows="3"></textarea>
</div>
<button class="btn btn-primary" type="submit">{t.product.submitReview}</button>
<p class="small" id="review-error" style="display:none; color: var(--c-sale);">{t.product.reviewNeedsRating}</p>
<p class="small" id="review-confirm" role="status" style="display:none; color: var(--c-accent);">{t.product.reviewSaved}</p>
</form>
</div>
</div>
</section>
@@ -267,3 +293,124 @@ const felder: [string, string | undefined][] = [
});
}
</script>
<script
define:vars={{
slug: product.slug,
serverReviews: (product.bewertungen ?? []).map((r) => ({ name: r.name, bewertung: r.bewertung, text: r.text?.[lang] ?? "", datum: r.datum ?? "" })),
noReviewsText: t.product.noReviews,
reviewCountOne: t.product.reviewCount(1),
reviewCountOther: t.product.reviewCount(2),
loginHintReview: t.product.loginHintReview,
notLoggedInReview: t.product.notLoggedInReview,
reviewFromOrderTemplate: t.product.reviewFromOrder("__NR__"),
reviewNeedsRatingText: t.product.reviewNeedsRating,
reviewSavedText: t.product.reviewSaved,
}}
>
window.__reviewVars = {
slug, serverReviews, noReviewsText, reviewCountOne, reviewCountOther,
loginHintReview, notLoggedInReview, reviewFromOrderTemplate, reviewNeedsRatingText, reviewSavedText,
};
</script>
<script>
import { heartsHtml, heartsPickerHtml, heartsPickerAktualisieren } from "../../scripts/hearts";
import { bewertungenZusammenfuehren, durchschnitt, fuegeBewertungHinzu, type AnzeigeBewertung } from "../../scripts/reviews";
import { isLoggedIn, getAccount } from "../../scripts/account";
const {
slug, serverReviews, noReviewsText, reviewCountOne, reviewCountOther,
loginHintReview, notLoggedInReview, reviewFromOrderTemplate, reviewNeedsRatingText, reviewSavedText,
} = (window as any).__reviewVars;
function reviewCountLabel(n: number): string {
return n === 1 ? reviewCountOne : reviewCountOther.replace(/^\d+/, String(n));
}
const summarySlot = document.getElementById("review-summary-slot")!;
const listSlot = document.getElementById("review-list")!;
function renderReviews() {
const merged: AnzeigeBewertung[] = bewertungenZusammenfuehren(serverReviews, slug);
const schnitt = durchschnitt(merged);
summarySlot.innerHTML =
schnitt === null
? `<p class="small">${noReviewsText}</p>`
: `<div class="review-summary-row">${heartsHtml(schnitt, 26, { showValue: true, anzahlLabel: reviewCountLabel(merged.length) })}</div>`;
listSlot.innerHTML = merged
.map((r) => {
const datum = r.datum ? `<span class="review-date small">${r.datum}</span>` : "";
const text = r.text ? `<p class="small">${r.text.replace(/</g, "&lt;")}</p>` : "";
return `<div class="card review-card"><div class="review-card-head"><span class="review-name">${r.name.replace(/</g, "&lt;")}</span>${heartsHtml(r.bewertung, 14)}${datum}</div>${text}</div>`;
})
.join("");
}
renderReviews();
// Bewertungsformular: eigenes Herzen-Auswahlfeld (kein natives <select>, siehe scripts/hearts.ts)
const picker = document.getElementById("review-heart-picker")!;
picker.innerHTML = heartsPickerHtml(28);
const pickerRoot = picker.querySelector<HTMLElement>("[data-heart-picker]")!;
let gewaehlteBewertung = 0;
pickerRoot.querySelectorAll<HTMLButtonElement>(".heart-pick").forEach((btn) => {
const wert = Number(btn.dataset.wert);
btn.addEventListener("mouseenter", () => heartsPickerAktualisieren(pickerRoot, wert));
btn.addEventListener("mouseleave", () => heartsPickerAktualisieren(pickerRoot, gewaehlteBewertung));
btn.addEventListener("click", () => {
gewaehlteBewertung = wert;
heartsPickerAktualisieren(pickerRoot, gewaehlteBewertung);
});
});
// Name nur abfragen, wenn niemand angemeldet ist — angemeldete Kund:innen bewerten automatisch
// unter ihrem Benutzernamen (siehe scripts/account.ts).
const nameField = document.getElementById("review-name-field")!;
const loginNote = document.getElementById("review-login-note")!;
if (isLoggedIn()) {
nameField.style.display = "none";
loginNote.textContent = `${loginHintReview} ${getAccount().username}`;
} else {
nameField.style.display = "";
loginNote.textContent = notLoggedInReview;
}
// Kommt der Aufruf von einer Bestellung im Kundenkonto ("Rezension schreiben"), steht die
// Bestellnummer als ?bewerten=... in der URL — Hinweistext einblenden, zum Formular scrollen
// und die Nummer beim Absenden mitschicken, damit das Konto sie als "✓ Bewertet" markieren kann.
const bestellnummer = new URLSearchParams(location.search).get("bewerten");
if (bestellnummer) {
const note = document.getElementById("review-order-note")!;
note.textContent = reviewFromOrderTemplate.replace("__NR__", bestellnummer);
note.style.display = "block";
document.getElementById("review-form-card")?.scrollIntoView({ behavior: "smooth", block: "center" });
}
const form = document.getElementById("review-form") as HTMLFormElement;
const errorMsg = document.getElementById("review-error")!;
const confirmMsg = document.getElementById("review-confirm")!;
const nameInput = document.getElementById("review-name") as HTMLInputElement | null;
const textInput = document.getElementById("review-text") as HTMLTextAreaElement;
form.addEventListener("submit", (e) => {
e.preventDefault();
if (gewaehlteBewertung < 1) {
errorMsg.style.display = "block";
return;
}
errorMsg.style.display = "none";
fuegeBewertungHinzu(slug, {
bewertung: gewaehlteBewertung,
text: textInput.value,
name: isLoggedIn() ? undefined : nameInput?.value,
bestellnummer: bestellnummer || undefined,
});
textInput.value = "";
gewaehlteBewertung = 0;
heartsPickerAktualisieren(pickerRoot, 0);
confirmMsg.style.display = "block";
setTimeout(() => { confirmMsg.style.display = "none"; }, 3000);
renderReviews();
});
</script>
+19 -1
View File
@@ -3,7 +3,7 @@ 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 } from "../../data/products";
import { products, durchschnittsbewertung, anzahlBewertungen } from "../../data/products";
import type { Locale } from "../../i18n/config";
import { useTranslations } from "../../i18n/ui";
@@ -106,6 +106,7 @@ const t = useTranslations(lang);
{products.map((p) => (
<div
class="product-slot"
data-slug={p.slug}
data-kategorie={p.kategorie}
data-unterkategorie={p.unterkategorie ?? ""}
data-preis={p.preis}
@@ -113,6 +114,7 @@ const t = useTranslations(lang);
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>
@@ -126,6 +128,8 @@ const t = useTranslations(lang);
</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");
@@ -244,5 +248,19 @@ const t = useTranslations(lang);
const preset = params.get("kategorie");
if (preset) dropdowns.forEach((d) => d.select(preset));
// Lokal auf diesem Gerät gespeicherte Kundenrezensionen (siehe scripts/reviews.ts) in
// data-bewertung einrechnen, BEVOR zum ersten Mal gefiltert/sortiert wird — sonst würden der
// Mindestbewertung-Filter und die Sortierung "Beste Bewertung" neue Rezensionen ignorieren.
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>