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:
co-authored by
Claude Sonnet 5
parent
d27a858d8f
commit
6f10e41386
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user