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
+73
-22
@@ -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, "<")}</p>` : "";
|
||||
return `<div class="card review-card"><div class="review-card-head"><span class="review-name">${String(r.name).replace(/</g, "<")}</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, "<")}</p>` : "";
|
||||
return `<div class="card review-card"><div class="review-card-head"><span class="review-name">${r.name.replace(/</g, "<")}</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>
|
||||
|
||||
@@ -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
@@ -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, "<")}</p>` : "";
|
||||
return `<div class="card review-card"><div class="review-card-head"><span class="review-name">${String(r.name).replace(/</g, "<")}</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, "<")}</p>` : "";
|
||||
return `<div class="card review-card"><div class="review-card-head"><span class="review-name">${r.name.replace(/</g, "<")}</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>
|
||||
|
||||
@@ -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
@@ -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, "<")}</p>` : "";
|
||||
return `<div class="card review-card"><div class="review-card-head"><span class="review-name">${String(r.name).replace(/</g, "<")}</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, "<")}</p>` : "";
|
||||
return `<div class="card review-card"><div class="review-card-head"><span class="review-name">${r.name.replace(/</g, "<")}</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>
|
||||
|
||||
@@ -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
@@ -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, "<")}</p>` : "";
|
||||
return `<div class="card review-card"><div class="review-card-head"><span class="review-name">${String(r.name).replace(/</g, "<")}</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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, "<")}</p>` : "";
|
||||
return `<div class="card review-card"><div class="review-card-head"><span class="review-name">${r.name.replace(/</g, "<")}</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user