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
+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>