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
@@ -26,7 +26,7 @@ const badgeLabel: Record<string, string> = {
|
||||
const name = product.name[lang];
|
||||
const desc = product.beschreibung[lang];
|
||||
---
|
||||
<a href={`${localePrefix(lang)}/produkt/${product.slug}/`} class="card product-card">
|
||||
<a href={`${localePrefix(lang)}/produkt/${product.slug}/`} class="card product-card" data-slug={product.slug}>
|
||||
<div class="product-media" data-carousel>
|
||||
{product.bilder && product.bilder.length > 0 ? (
|
||||
<>
|
||||
@@ -59,9 +59,23 @@ const desc = product.beschreibung[lang];
|
||||
</div>
|
||||
</div>
|
||||
<h3>{name}</h3>
|
||||
{bewertungSchnitt !== null && (
|
||||
<HeartRating bewertung={bewertungSchnitt} size={13} anzahlLabel={`(${bewertungAnzahl})`} />
|
||||
)}
|
||||
{/* Immer gerendert (auch ohne Server-Bewertungen), aber standardmäßig versteckt — ein
|
||||
gemeinsames Skript (siehe unten) blendet die Zeile automatisch ein, sobald auf diesem
|
||||
Gerät lokal gespeicherte Kundenrezensionen für dieses Produkt dazukommen (siehe
|
||||
scripts/reviews.ts), egal auf welcher Seite die Karte gerade angezeigt wird. */}
|
||||
<span
|
||||
class="card-rating"
|
||||
data-rating-slot
|
||||
data-basis-schnitt={bewertungSchnitt ?? 0}
|
||||
data-basis-anzahl={bewertungAnzahl}
|
||||
data-count-one={t.product.reviewCount(1)}
|
||||
data-count-other={t.product.reviewCount(2)}
|
||||
style={bewertungSchnitt === null ? "display:none;" : undefined}
|
||||
>
|
||||
{bewertungSchnitt !== null && (
|
||||
<HeartRating bewertung={bewertungSchnitt} size={13} anzahlLabel={`(${bewertungAnzahl})`} />
|
||||
)}
|
||||
</span>
|
||||
<p class="small">{desc.slice(0, 78)}{desc.length > 78 ? "…" : ""}</p>
|
||||
<div class="price-row">
|
||||
{product.preisAlt && <span class="price-old">{formatPrice(product.preisAlt, lang)}</span>}
|
||||
@@ -146,6 +160,39 @@ const desc = product.beschreibung[lang];
|
||||
}, true);
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// Bewertungszeile auf JEDER Produktkarte der Seite (Shop-Grid, Kategorie-Seiten, Startseite,
|
||||
// "Das könnte dir auch gefallen" ...) live mit den lokal auf diesem Gerät gespeicherten
|
||||
// Kundenrezensionen zusammenführen (siehe scripts/reviews.ts) — Astro rendert die Karte selbst
|
||||
// nur mit dem Bau-Zeit-Stand (VanVans Admin-Daten), neue Rezensionen von Kund:innen kommen erst
|
||||
// im Browser dazu. Ein einziges, delegiertes Skript für alle Karten (Astro dedupliziert dieses
|
||||
// <script> automatisch, egal wie oft ProductCard auf der Seite eingebunden wird).
|
||||
import { heartsHtml } from "../scripts/hearts";
|
||||
import { lokaleBewertungenFuer } from "../scripts/reviews";
|
||||
|
||||
function reviewCountLabel(slot: HTMLElement, n: number): string {
|
||||
if (n === 1) return slot.dataset.countOne!;
|
||||
return slot.dataset.countOther!.replace(/^\d+/, String(n));
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLElement>("[data-rating-slot]").forEach((slot) => {
|
||||
const card = slot.closest<HTMLElement>(".product-card");
|
||||
const slug = card?.dataset.slug;
|
||||
if (!slug) return;
|
||||
const lokale = lokaleBewertungenFuer(slug);
|
||||
if (lokale.length === 0) return; // Bau-Zeit-Stand reicht bereits, nichts zu ergänzen
|
||||
|
||||
const basisSchnitt = Number(slot.dataset.basisSchnitt || 0);
|
||||
const basisAnzahl = Number(slot.dataset.basisAnzahl || 0);
|
||||
const summe = basisSchnitt * basisAnzahl + lokale.reduce((s, r) => s + r.bewertung, 0);
|
||||
const anzahl = basisAnzahl + lokale.length;
|
||||
const schnitt = summe / anzahl;
|
||||
|
||||
slot.innerHTML = heartsHtml(schnitt, 13, { anzahlLabel: `(${anzahl})` });
|
||||
slot.style.display = "";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.product-card { display: flex; flex-direction: column; gap: 0.6rem; padding: 1rem; }
|
||||
.product-media { position: relative; isolation: isolate; touch-action: pan-y; }
|
||||
|
||||
+56
-20
@@ -8,6 +8,7 @@ import type { Locale } from "./config";
|
||||
export const ui = {
|
||||
de: {
|
||||
nav: { shop: "Shop", sale: "Sale", creative: "Über die Kreative", faq: "FAQ", contact: "Kontakt", account: "Mein Konto", login: "Anmelden" },
|
||||
accountMenu: { myAccount: "Mein Konto", logout: "Abmelden" },
|
||||
topbar: "Versandkostenfrei ab 75 € innerhalb DE/AT/CH/LU",
|
||||
footer: {
|
||||
tagline: "Handgemacht mit Liebe – Kreativität zum Verschenken und Selbstbehalten.",
|
||||
@@ -38,6 +39,10 @@ export const ui = {
|
||||
photoNote: "echte Fotos ergänzt VanVan später",
|
||||
reviewsTitle: "Kundenrezensionen", noReviews: "Noch keine Bewertungen für dieses Produkt — sei die/der Erste!",
|
||||
reviewCount: (n: number) => `${n} Bewertung${n === 1 ? "" : "en"}`,
|
||||
writeReviewTitle: "🩵 Deine Rezension", yourRating: "Deine Bewertung", yourReview: "Was hat dir gefallen? (optional)",
|
||||
yourName: "Dein Name", submitReview: "Rezension veröffentlichen", reviewSaved: "Danke für deine Rezension! ✓",
|
||||
reviewNeedsRating: "Bitte wähle 1–5 Herzen aus.", reviewFromOrder: (nr: string) => `Du bewertest deine Bestellung ${nr}.`,
|
||||
loginHintReview: "Angemeldet als", notLoggedInReview: "Du bist nicht angemeldet — deine Rezension wird mit dem eingegebenen Namen veröffentlicht.",
|
||||
},
|
||||
shop: {
|
||||
title: "Alle Produkte", lead: "Stöbere durch alle handgemachten Stücke – filtere und sortiere nach deinen Wünschen.",
|
||||
@@ -198,15 +203,16 @@ export const ui = {
|
||||
},
|
||||
account: {
|
||||
eyebrow: "Mein Konto", title: "Anmelden oder als Gast bestellen",
|
||||
todoNote: "Phase 1 – reine Oberfläche ohne echtes Login/Registrierung. Bestellhistorie, Sendungsverfolgung, Wunschliste und \"Zuletzt angesehen\" folgen in Phase 2 mit echter Datenbank-Anbindung.",
|
||||
loginTitle: "Anmelden", email: "E-Mail", password: "Passwort", loginButton: "Anmelden (folgt in Phase 2)", forgotPassword: "Passwort vergessen?",
|
||||
todoNote: "Phase 1 – Demo-Anmeldung ohne echte Passwortprüfung: Jede E-Mail-Adresse funktioniert, das Passwort-Feld ist reine Vorschau. Die Bestellungen im Konto sind Beispieldaten. Eine echte Anmeldung mit Datenbank kommt in Phase 2.",
|
||||
loginTitle: "Anmelden", email: "E-Mail", password: "Passwort", loginButton: "Anmelden", forgotPassword: "Passwort vergessen?",
|
||||
newTitle: "Neu hier?", newText: "Lege ein Konto an, um Bestellhistorie, Wunschliste und Sendungsverfolgung an einem Ort zu haben – oder bestelle einfach als Gast, ganz ohne Konto.",
|
||||
guestButton: "Als Gast weiter einkaufen",
|
||||
previewLink: "👁️ Vorschau: So sieht der Bereich für angemeldete Kund:innen aus",
|
||||
loginError: "Bitte gib eine E-Mail-Adresse ein.",
|
||||
},
|
||||
accountDash: {
|
||||
eyebrow: "Mein Konto", greeting: "Hallo Marie 🩵", lead: "Schön, dass du wieder da bist. Hier findest du alle deine Bestellungen, deine Wunschliste und deine Daten auf einen Blick.",
|
||||
previewNote: "VORSCHAU-ANSICHT (Phase 1): So sieht der persönliche Bereich aus, sobald ein:e Kund:in angemeldet ist. Die hier gezeigten Bestellungen und Daten sind reine Beispieldaten zur Gestaltung — es gibt noch kein echtes Login und keine echte Datenbank. Beides kommt in Phase 2 (Cloudflare Worker + D1), danach zeigt diese Seite die echten Daten der jeweils angemeldeten Person.",
|
||||
eyebrow: "Mein Konto", greeting: (name: string) => `Hallo ${name} 🩵`, lead: "Schön, dass du wieder da bist. Hier findest du alle deine Bestellungen, deine Wunschliste und deine Daten auf einen Blick.",
|
||||
previewNote: "Phase 1: Deine Anmeldung, dein Profil und deine Rezensionen werden schon jetzt echt auf diesem Gerät gespeichert. Nur die Bestellungen unten sind Beispieldaten zur Gestaltung — echte Bestellungen kommen in Phase 2 mit Zahlungsanbindung und Datenbank (Cloudflare Worker + D1).",
|
||||
notLoggedIn: "Du bist nicht angemeldet — du wirst zur Anmeldung weitergeleitet …",
|
||||
logout: "Abmelden",
|
||||
statOrders: "Bestellungen", statWishlist: "Wunschliste", statOpen: "Offene Sendungen",
|
||||
ordersTitle: "📦 Meine Bestellungen",
|
||||
@@ -216,10 +222,14 @@ export const ui = {
|
||||
wishlistTitle: "🩵 Meine Wunschliste", wishlistEmpty: "Deine Wunschliste ist noch leer — stöbere im Shop und speichere deine Lieblingsstücke mit dem Herz-Symbol.",
|
||||
recentTitle: "🕘 Zuletzt angesehen",
|
||||
profileTitle: "👤 Meine Daten", profileName: "Name", profileEmail: "E-Mail", profileAddress: "Lieferadresse", profileEdit: "Daten bearbeiten",
|
||||
reviewBtn: "🩵 Rezension schreiben", reviewedBadge: "✓ Bewertet",
|
||||
usernameLabel: "Benutzername", usernameSave: "Speichern", usernameSaved: "Gespeichert ✓",
|
||||
photoLabel: "Profilfoto", photoChange: "Foto ändern", photoRemove: "Entfernen",
|
||||
},
|
||||
},
|
||||
en: {
|
||||
nav: { shop: "Shop", sale: "Sale", creative: "About the Maker", faq: "FAQ", contact: "Contact", account: "My Account", login: "Sign in" },
|
||||
accountMenu: { myAccount: "My Account", logout: "Sign out" },
|
||||
topbar: "Free shipping from €75 within DE/AT/CH/LU",
|
||||
footer: {
|
||||
tagline: "Handmade with love – creativity to gift and to keep.",
|
||||
@@ -250,6 +260,10 @@ export const ui = {
|
||||
photoNote: "real photos will be added by VanVan later",
|
||||
reviewsTitle: "Customer reviews", noReviews: "No reviews yet for this product — be the first!",
|
||||
reviewCount: (n: number) => `${n} review${n === 1 ? "" : "s"}`,
|
||||
writeReviewTitle: "🩵 Your review", yourRating: "Your rating", yourReview: "What did you like? (optional)",
|
||||
yourName: "Your name", submitReview: "Publish review", reviewSaved: "Thanks for your review! ✓",
|
||||
reviewNeedsRating: "Please choose 1–5 hearts.", reviewFromOrder: (nr: string) => `You're reviewing your order ${nr}.`,
|
||||
loginHintReview: "Signed in as", notLoggedInReview: "You're not signed in — your review will be published under the name you enter.",
|
||||
},
|
||||
shop: {
|
||||
title: "All products", lead: "Browse all handmade pieces – filter and sort to your liking.",
|
||||
@@ -410,15 +424,16 @@ export const ui = {
|
||||
},
|
||||
account: {
|
||||
eyebrow: "My Account", title: "Sign in or check out as a guest",
|
||||
todoNote: "Phase 1 – interface only, no real login/registration yet. Order history, tracking, wishlist and \"recently viewed\" follow in Phase 2 with a real database connection.",
|
||||
loginTitle: "Sign in", email: "Email", password: "Password", loginButton: "Sign in (coming in Phase 2)", forgotPassword: "Forgot password?",
|
||||
todoNote: "Phase 1 – demo sign-in without real password checking: any email address works, the password field is a preview only. Orders shown in the account are example data. A real login with a database is coming in Phase 2.",
|
||||
loginTitle: "Sign in", email: "Email", password: "Password", loginButton: "Sign in", forgotPassword: "Forgot password?",
|
||||
newTitle: "New here?", newText: "Create an account to keep order history, wishlist and tracking in one place – or simply check out as a guest, no account needed.",
|
||||
guestButton: "Continue shopping as guest",
|
||||
previewLink: "👁️ Preview: what signed-in customers see",
|
||||
loginError: "Please enter an email address.",
|
||||
},
|
||||
accountDash: {
|
||||
eyebrow: "My Account", greeting: "Hello Marie 🩵", lead: "Good to see you again. Here you'll find all your orders, your wishlist and your details at a glance.",
|
||||
previewNote: "PREVIEW (Phase 1): This is what the personal area looks like once a customer is signed in. The orders and details shown here are example data for design purposes only — there is no real login or database yet. Both are coming in Phase 2 (Cloudflare Worker + D1), after which this page will show the signed-in person's real data.",
|
||||
eyebrow: "My Account", greeting: (name: string) => `Hello ${name} 🩵`, lead: "Good to see you again. Here you'll find all your orders, your wishlist and your details at a glance.",
|
||||
previewNote: "Phase 1: your sign-in, your profile and your reviews are already genuinely saved on this device. Only the orders below are example data for design purposes — real orders arrive in Phase 2 with payment integration and a database (Cloudflare Worker + D1).",
|
||||
notLoggedIn: "You're not signed in — redirecting you to sign in …",
|
||||
logout: "Sign out",
|
||||
statOrders: "Orders", statWishlist: "Wishlist", statOpen: "Open shipments",
|
||||
ordersTitle: "📦 My orders",
|
||||
@@ -428,10 +443,14 @@ export const ui = {
|
||||
wishlistTitle: "🩵 My wishlist", wishlistEmpty: "Your wishlist is still empty — browse the shop and save your favourites with the heart icon.",
|
||||
recentTitle: "🕘 Recently viewed",
|
||||
profileTitle: "👤 My details", profileName: "Name", profileEmail: "Email", profileAddress: "Delivery address", profileEdit: "Edit details",
|
||||
reviewBtn: "🩵 Write a review", reviewedBadge: "✓ Reviewed",
|
||||
usernameLabel: "Username", usernameSave: "Save", usernameSaved: "Saved ✓",
|
||||
photoLabel: "Profile photo", photoChange: "Change photo", photoRemove: "Remove",
|
||||
},
|
||||
},
|
||||
ch: {
|
||||
nav: { shop: "Shop", sale: "Aktion", creative: "Über d'Kreativi", faq: "FAQ", contact: "Kontakt", account: "Mis Konto", login: "Aamälde" },
|
||||
accountMenu: { myAccount: "Mis Konto", logout: "Abmälde" },
|
||||
topbar: "Gratisversand ab 75 € innerhalb DE/AT/CH/LU",
|
||||
footer: {
|
||||
tagline: "Vo Hang gmacht mit Härz – Sächeli zum Verschänke und Sälber Bhalte.",
|
||||
@@ -462,6 +481,10 @@ export const ui = {
|
||||
photoNote: "echti Fotis macht VanVan später drby",
|
||||
reviewsTitle: "Kundebewärtige", noReviews: "No kei Bewärtige für das Produkt — sei du dr Erschti!",
|
||||
reviewCount: (n: number) => `${n} Bewärtig${n === 1 ? "" : "e"}`,
|
||||
writeReviewTitle: "🩵 Dini Bewärtig", yourRating: "Dini Bewärtig", yourReview: "Was hat dir gfalle? (optional)",
|
||||
yourName: "Din Name", submitReview: "Bewärtig veröffentliche", reviewSaved: "Danke für dini Bewärtig! ✓",
|
||||
reviewNeedsRating: "Bitte wähl 1–5 Herzli us.", reviewFromOrder: (nr: string) => `Du bewärtisch dini Bstellig ${nr}.`,
|
||||
loginHintReview: "Aagmäldet als", notLoggedInReview: "Du bisch nid aagmäldet — dini Bewärtig wird mit em iigäbene Name veröffentlicht.",
|
||||
},
|
||||
shop: {
|
||||
title: "Alli Produkt", lead: "Lueg dr alli vo Hang gmachte Sächeli a – filtere und sortiere, wie's dir passt.",
|
||||
@@ -622,15 +645,16 @@ export const ui = {
|
||||
},
|
||||
account: {
|
||||
eyebrow: "Mis Konto", title: "Aamälde oder als Gast bstelle",
|
||||
todoNote: "Phase 1 – nume d'Oberflächi ohni echts Login/Registrierig. Bstellhistorie, Sändigsverfolgig, Wunschlischte und \"Zletscht aagluegt\" chöme i Phase 2.",
|
||||
loginTitle: "Aamälde", email: "E-Mail", password: "Passwort", loginButton: "Aamälde (chunnt i Phase 2)", forgotPassword: "Passwort vergässe?",
|
||||
todoNote: "Phase 1 – Demo-Aamäldig ohni echti Passwortprüefig: jedi E-Mail-Adrässe funktioniert, s'Passwort-Fäld isch nume Vorschau. D'Bstellige im Konto sind Bispieldate. E echti Aamäldig mit Datebank chunnt i Phase 2.",
|
||||
loginTitle: "Aamälde", email: "E-Mail", password: "Passwort", loginButton: "Aamälde", forgotPassword: "Passwort vergässe?",
|
||||
newTitle: "Neu da?", newText: "Leg es Konto a, für Bstellhistorie, Wunschlischte und Sändigsverfolgig a einem Ort z'ha – oder bstell eifach als Gast, ganz ohni Konto.",
|
||||
guestButton: "Als Gast wyter yichaufe",
|
||||
previewLink: "👁️ Vorschau: so gseht dr Bereich für aagmäldeti Kund:inne us",
|
||||
loginError: "Bitte gib e E-Mail-Adrässe i.",
|
||||
},
|
||||
accountDash: {
|
||||
eyebrow: "Mis Konto", greeting: "Hoi Marie 🩵", lead: "Schön, bisch wieder da. Da findsch alli dini Bstellige, dini Wunschlischte und dini Date uf ein Blick.",
|
||||
previewNote: "VORSCHAU (Phase 1): So gseht dr persönlich Bereich us, sobald öpper aagmäldet isch. D'Bstellige und Date wo da zeigt werde, sind reini Bispieldate für d'Gstaltig — es git no kei echts Login und kei echti Datebank. Beides chunnt i Phase 2 (Cloudflare Worker + D1), denn zeigt die Syte d'echte Date vo dr aagmäldete Person.",
|
||||
eyebrow: "Mis Konto", greeting: (name: string) => `Hoi ${name} 🩵`, lead: "Schön, bisch wieder da. Da findsch alli dini Bstellige, dini Wunschlischte und dini Date uf ein Blick.",
|
||||
previewNote: "Phase 1: dini Aamäldig, dis Profil und dini Bewärtige werde scho jetzt echt uf däm Gerät gspeicheret. Nume d'Bstellige unde sind Bispieldate für d'Gstaltig — echti Bstellige chöme i Phase 2 mit Zahligsaabindig und Datebank (Cloudflare Worker + D1).",
|
||||
notLoggedIn: "Du bisch nid aagmäldet — du wirsch zur Aamäldig wytergleitet …",
|
||||
logout: "Abmälde",
|
||||
statOrders: "Bstellige", statWishlist: "Wunschlischte", statOpen: "Offni Sändige",
|
||||
ordersTitle: "📦 Mini Bstellige",
|
||||
@@ -640,10 +664,14 @@ export const ui = {
|
||||
wishlistTitle: "🩵 Mini Wunschlischte", wishlistEmpty: "Dini Wunschlischte isch no leer — lueg im Shop verbi und spicher dini Lieblingssächeli mit em Härz-Symbol.",
|
||||
recentTitle: "🕘 Zletscht aagluegt",
|
||||
profileTitle: "👤 Mini Date", profileName: "Name", profileEmail: "E-Mail", profileAddress: "Lieferadrässe", profileEdit: "Date bearbeite",
|
||||
reviewBtn: "🩵 Bewärtig schrybe", reviewedBadge: "✓ Bewärtet",
|
||||
usernameLabel: "Benutzername", usernameSave: "Spichere", usernameSaved: "Gspicheret ✓",
|
||||
photoLabel: "Profilfoto", photoChange: "Foto ändere", photoRemove: "Entferne",
|
||||
},
|
||||
},
|
||||
fr: {
|
||||
nav: { shop: "Boutique", sale: "Soldes", creative: "À propos de la créatrice", faq: "FAQ", contact: "Contact", account: "Mon compte", login: "Se connecter" },
|
||||
accountMenu: { myAccount: "Mon compte", logout: "Se déconnecter" },
|
||||
topbar: "Livraison gratuite dès 75 € en DE/AT/CH/LU",
|
||||
footer: {
|
||||
tagline: "Fait main avec amour – de la créativité à offrir ou à garder.",
|
||||
@@ -674,6 +702,10 @@ export const ui = {
|
||||
photoNote: "VanVan ajoutera de vraies photos plus tard",
|
||||
reviewsTitle: "Avis clients", noReviews: "Pas encore d'avis pour ce produit — soyez le/la premier(ère) !",
|
||||
reviewCount: (n: number) => `${n} avis`,
|
||||
writeReviewTitle: "🩵 Votre avis", yourRating: "Votre note", yourReview: "Qu'avez-vous aimé ? (facultatif)",
|
||||
yourName: "Votre nom", submitReview: "Publier l'avis", reviewSaved: "Merci pour votre avis ! ✓",
|
||||
reviewNeedsRating: "Merci de choisir 1 à 5 cœurs.", reviewFromOrder: (nr: string) => `Vous évaluez votre commande ${nr}.`,
|
||||
loginHintReview: "Connecté(e) en tant que", notLoggedInReview: "Vous n'êtes pas connecté(e) — votre avis sera publié sous le nom que vous indiquez.",
|
||||
},
|
||||
shop: {
|
||||
title: "Tous les produits", lead: "Parcourez toutes les pièces faites main – filtrez et triez selon vos envies.",
|
||||
@@ -834,15 +866,16 @@ export const ui = {
|
||||
},
|
||||
account: {
|
||||
eyebrow: "Mon compte", title: "Se connecter ou commander en tant qu'invité",
|
||||
todoNote: "Phase 1 – interface uniquement, pas encore de vraie connexion/inscription. L'historique des commandes, le suivi, la liste de souhaits et \"vu récemment\" suivront en phase 2.",
|
||||
loginTitle: "Se connecter", email: "E-mail", password: "Mot de passe", loginButton: "Se connecter (disponible en phase 2)", forgotPassword: "Mot de passe oublié ?",
|
||||
todoNote: "Phase 1 – connexion de démonstration sans vérification réelle du mot de passe : n'importe quelle adresse e-mail fonctionne, le champ mot de passe n'est qu'un aperçu. Les commandes affichées dans le compte sont des exemples. Une vraie connexion avec base de données arrivera en phase 2.",
|
||||
loginTitle: "Se connecter", email: "E-mail", password: "Mot de passe", loginButton: "Se connecter", forgotPassword: "Mot de passe oublié ?",
|
||||
newTitle: "Nouveau ici ?", newText: "Créez un compte pour retrouver l'historique des commandes, la liste de souhaits et le suivi au même endroit – ou commandez simplement en tant qu'invité, sans compte.",
|
||||
guestButton: "Continuer mes achats en tant qu'invité",
|
||||
previewLink: "👁️ Aperçu : l'espace vu par les client(e)s connecté(e)s",
|
||||
loginError: "Veuillez saisir une adresse e-mail.",
|
||||
},
|
||||
accountDash: {
|
||||
eyebrow: "Mon compte", greeting: "Bonjour Marie 🩵", lead: "Ravie de vous revoir. Retrouvez ici toutes vos commandes, votre liste de souhaits et vos données en un coup d'œil.",
|
||||
previewNote: "APERÇU (phase 1) : voici à quoi ressemble l'espace personnel une fois le/la client(e) connecté(e). Les commandes et données affichées ici sont des exemples destinés à la conception — il n'existe encore ni connexion réelle ni base de données. Les deux arriveront en phase 2 (Cloudflare Worker + D1) ; cette page affichera alors les données réelles de la personne connectée.",
|
||||
eyebrow: "Mon compte", greeting: (name: string) => `Bonjour ${name} 🩵`, lead: "Ravie de vous revoir. Retrouvez ici toutes vos commandes, votre liste de souhaits et vos données en un coup d'œil.",
|
||||
previewNote: "Phase 1 : votre connexion, votre profil et vos avis sont déjà réellement enregistrés sur cet appareil. Seules les commandes ci-dessous sont des exemples destinés à la conception — les vraies commandes arriveront en phase 2 avec le paiement et une base de données (Cloudflare Worker + D1).",
|
||||
notLoggedIn: "Vous n'êtes pas connecté(e) — redirection vers la connexion …",
|
||||
logout: "Se déconnecter",
|
||||
statOrders: "Commandes", statWishlist: "Liste de souhaits", statOpen: "Envois en cours",
|
||||
ordersTitle: "📦 Mes commandes",
|
||||
@@ -852,6 +885,9 @@ export const ui = {
|
||||
wishlistTitle: "🩵 Ma liste de souhaits", wishlistEmpty: "Votre liste de souhaits est encore vide — parcourez la boutique et enregistrez vos coups de cœur avec l'icône en forme de cœur.",
|
||||
recentTitle: "🕘 Vus récemment",
|
||||
profileTitle: "👤 Mes données", profileName: "Nom", profileEmail: "E-mail", profileAddress: "Adresse de livraison", profileEdit: "Modifier mes données",
|
||||
reviewBtn: "🩵 Écrire un avis", reviewedBadge: "✓ Avis publié",
|
||||
usernameLabel: "Nom d'utilisateur", usernameSave: "Enregistrer", usernameSaved: "Enregistré ✓",
|
||||
photoLabel: "Photo de profil", photoChange: "Changer la photo", photoRemove: "Supprimer",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -78,12 +78,30 @@ const p = (href: string) => `${localePrefix(lang)}${href}`;
|
||||
<span class="install-label">{t.common.installAppShort}</span>
|
||||
</button>
|
||||
<LanguageSwitcher lang={lang} path={path} legalOnly={legalOnly} />
|
||||
<a href={p("/konto/")} class="account-btn">
|
||||
<span class="account-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5Zm0 2c-4.4 0-9 2.2-9 5v2h18v-2c0-2.8-4.6-5-9-5Z"/></svg>
|
||||
</span>
|
||||
<span class="account-label">{t.nav.login}</span>
|
||||
</a>
|
||||
{/* Zwei Zustände nebeneinander im HTML, per JS umgeschaltet (siehe Script unten) — ohne
|
||||
eingebautes Login (Phase 1: nur clientseitig im localStorage, siehe scripts/account.ts)
|
||||
weiß der Server beim Rendern nie, ob jemand "angemeldet" ist. Standardmäßig ist nur der
|
||||
Anmelden-Link sichtbar; sobald JS geladen hat und ein lokales Konto findet, blendet es
|
||||
auf das Konto-Menü um. */}
|
||||
<div class="account-area" id="account-area">
|
||||
<a href={p("/konto/")} class="account-btn" id="account-login-btn">
|
||||
<span class="account-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5Zm0 2c-4.4 0-9 2.2-9 5v2h18v-2c0-2.8-4.6-5-9-5Z"/></svg>
|
||||
</span>
|
||||
<span class="account-label">{t.nav.login}</span>
|
||||
</a>
|
||||
<div class="account-menu" id="account-menu" style="display:none;">
|
||||
<button type="button" class="account-menu-btn" id="account-menu-btn" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="account-avatar" id="account-avatar" aria-hidden="true"></span>
|
||||
<span class="account-username" id="account-username-label"></span>
|
||||
<span class="chevron" aria-hidden="true">▼</span>
|
||||
</button>
|
||||
<div class="account-dropdown" id="account-dropdown" role="menu">
|
||||
<a href={p("/konto/angemeldet/")} role="menuitem">👤 {t.accountMenu.myAccount}</a>
|
||||
<button type="button" id="account-logout-btn" role="menuitem">🚪 {t.accountMenu.logout}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href={p("/warenkorb/")} class="icon-link cart-link" aria-label={t.nav.account}>
|
||||
🧺 <span class="cart-count" id="cart-count">0</span>
|
||||
</a>
|
||||
@@ -158,6 +176,8 @@ const p = (href: string) => `${localePrefix(lang)}${href}`;
|
||||
|
||||
<script>
|
||||
import { cartCount } from "../scripts/cart";
|
||||
import { getAccount, logout } from "../scripts/account";
|
||||
|
||||
function updateCount() {
|
||||
const el = document.getElementById("cart-count");
|
||||
if (el) el.textContent = String(cartCount());
|
||||
@@ -166,6 +186,58 @@ const p = (href: string) => `${localePrefix(lang)}${href}`;
|
||||
window.addEventListener("cart:changed", updateCount);
|
||||
window.addEventListener("storage", updateCount);
|
||||
|
||||
// Konto-Bereich der Kopfzeile: "Anmelden"-Link vs. Konto-Menü, je nachdem ob auf diesem
|
||||
// Gerät gerade ein (Demo-)Konto angemeldet ist (siehe scripts/account.ts). Läuft auf JEDER
|
||||
// Seite, weil Layout.astro von allen Seiten eingebunden wird — daher überall konsistent.
|
||||
const loginBtn = document.getElementById("account-login-btn");
|
||||
const menu = document.getElementById("account-menu");
|
||||
const menuBtn = document.getElementById("account-menu-btn");
|
||||
const dropdown = document.getElementById("account-dropdown");
|
||||
const avatar = document.getElementById("account-avatar");
|
||||
const usernameLabel = document.getElementById("account-username-label");
|
||||
const logoutBtn = document.getElementById("account-logout-btn");
|
||||
|
||||
function renderAccountArea() {
|
||||
const a = getAccount();
|
||||
if (loginBtn) loginBtn.style.display = a.loggedIn ? "none" : "";
|
||||
if (menu) menu.style.display = a.loggedIn ? "flex" : "none";
|
||||
if (!a.loggedIn) return;
|
||||
if (usernameLabel) usernameLabel.textContent = a.username || "Konto";
|
||||
if (avatar) {
|
||||
if (a.photo) {
|
||||
avatar.style.backgroundImage = `url(${a.photo})`;
|
||||
avatar.textContent = "";
|
||||
} else {
|
||||
avatar.style.backgroundImage = "";
|
||||
avatar.textContent = (a.username || "?").trim().charAt(0).toUpperCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
renderAccountArea();
|
||||
window.addEventListener("account:changed", renderAccountArea);
|
||||
window.addEventListener("storage", renderAccountArea);
|
||||
|
||||
menuBtn?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const open = menu?.classList.toggle("open");
|
||||
menuBtn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
});
|
||||
document.addEventListener("click", () => {
|
||||
menu?.classList.remove("open");
|
||||
menuBtn?.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
menu?.classList.remove("open");
|
||||
menuBtn?.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
});
|
||||
logoutBtn?.addEventListener("click", () => {
|
||||
logout();
|
||||
menu?.classList.remove("open");
|
||||
menuBtn?.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
const hamburger = document.getElementById("hamburger");
|
||||
const mobileNav = document.getElementById("mobile-nav");
|
||||
hamburger?.addEventListener("click", () => {
|
||||
|
||||
+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>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Rein clientseitiges "Konto" (Phase 1, kein echtes Login/Backend) — genau wie der Warenkorb
|
||||
// (siehe cart.ts) im localStorage gespeichert. Ermöglicht schon jetzt eine echte, durchgängige
|
||||
// Demo: anmelden, Profil pflegen, Bestellungen bewerten, Kopfzeile reagiert überall darauf.
|
||||
// TODO Phase 2: durch echtes Login (Cloudflare Worker + D1, siehe Tech-Stack-Entscheidung im
|
||||
// Projekt-Vault) ersetzen — Struktur/Feldnamen bewusst schon so gewählt, dass sich eine echte
|
||||
// Session später 1:1 einsetzen lässt, ohne Aufrufstellen in der ganzen Seite anpassen zu müssen.
|
||||
|
||||
export interface Account {
|
||||
loggedIn: boolean;
|
||||
username: string;
|
||||
email: string;
|
||||
/** Profilfoto als data:-URL (im Browser aus der hochgeladenen Datei erzeugt). */
|
||||
photo: string;
|
||||
/** "<bestellnummer>:<produkt-slug>"-Paare, zu denen schon eine Rezension abgegeben wurde. */
|
||||
bewerteteBestellungen: string[];
|
||||
}
|
||||
|
||||
const KEY = "vandiy_account_v1";
|
||||
|
||||
function leeresKonto(): Account {
|
||||
return { loggedIn: false, username: "", email: "", photo: "", bewerteteBestellungen: [] };
|
||||
}
|
||||
|
||||
export function getAccount(): Account {
|
||||
if (typeof localStorage === "undefined") return leeresKonto();
|
||||
try {
|
||||
return { ...leeresKonto(), ...JSON.parse(localStorage.getItem(KEY) || "{}") };
|
||||
} catch {
|
||||
return leeresKonto();
|
||||
}
|
||||
}
|
||||
|
||||
function saveAccount(a: Account) {
|
||||
localStorage.setItem(KEY, JSON.stringify(a));
|
||||
window.dispatchEvent(new CustomEvent("account:changed", { detail: a }));
|
||||
}
|
||||
|
||||
export function isLoggedIn(): boolean {
|
||||
return getAccount().loggedIn;
|
||||
}
|
||||
|
||||
/** Demo-Anmeldung: es gibt noch keine echte Passwortprüfung (Phase 2) — jede E-Mail meldet an,
|
||||
* der Benutzername wird beim ersten Mal aus dem E-Mail-Namen abgeleitet, bleibt danach aber
|
||||
* erhalten (spätere Logins überschreiben einen schon gewählten Benutzernamen nicht). */
|
||||
export function login(email: string): Account {
|
||||
const bestehend = getAccount();
|
||||
const account: Account = {
|
||||
...bestehend,
|
||||
loggedIn: true,
|
||||
email: email.trim(),
|
||||
username: bestehend.username || email.split("@")[0] || "Kundin",
|
||||
};
|
||||
saveAccount(account);
|
||||
return account;
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
saveAccount({ ...getAccount(), loggedIn: false });
|
||||
}
|
||||
|
||||
export function setUsername(username: string) {
|
||||
saveAccount({ ...getAccount(), username: username.trim() });
|
||||
}
|
||||
|
||||
export function setPhoto(dataUrl: string) {
|
||||
saveAccount({ ...getAccount(), photo: dataUrl });
|
||||
}
|
||||
|
||||
export function bestellSchluessel(bestellnummer: string, produktSlug: string): string {
|
||||
return `${bestellnummer}:${produktSlug}`;
|
||||
}
|
||||
|
||||
export function hatBestellungBewertet(bestellnummer: string, produktSlug: string): boolean {
|
||||
return getAccount().bewerteteBestellungen.includes(bestellSchluessel(bestellnummer, produktSlug));
|
||||
}
|
||||
|
||||
export function markiereBestellungBewertet(bestellnummer: string, produktSlug: string) {
|
||||
const a = getAccount();
|
||||
const schluessel = bestellSchluessel(bestellnummer, produktSlug);
|
||||
if (!a.bewerteteBestellungen.includes(schluessel)) {
|
||||
saveAccount({ ...a, bewerteteBestellungen: [...a.bewerteteBestellungen, schluessel] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Client-seitiges Gegenstück zu components/HeartRating.astro. Astro-Komponenten können nicht im
|
||||
// Browser laufen (sie werden nur beim Bauen der Seite zu HTML) — überall dort, wo sich Herzen
|
||||
// nach dem Laden der Seite per JavaScript ändern müssen (neue Kundenrezension, Login-Zustand,
|
||||
// zusammengeführte Bewertungen), wird stattdessen diese Funktion benutzt. Bewusst dieselbe
|
||||
// Optik/Logik wie HeartRating.astro (Lila-Babyblau-Verlauf, gleicher Herz-Pfad) — bei
|
||||
// Design-Änderungen an einer Stelle IMMER auch die andere anpassen.
|
||||
|
||||
let zaehler = 0;
|
||||
|
||||
const HERZ_PFAD =
|
||||
"M12 21s-7.5-4.9-10.2-9.3C.2 8.7 1.4 5 5 4.1c2.1-.5 4 1.4 5 2.9.6-.9 1.3-1.7 2.2-2.2 1.9-1.1 4.4-.7 5.8 1 1.9 2.3 1.5 5.6-.6 8.6C14.9 17.5 12 21 12 21z";
|
||||
|
||||
/** Spiegelt die Props von HeartRating.astro (showValue/anzahlLabel optional). */
|
||||
export function heartsHtml(bewertung: number, size = 16, opts: { showValue?: boolean; anzahlLabel?: string } = {}): string {
|
||||
zaehler += 1;
|
||||
const gradId = `hrc-${Date.now().toString(36)}-${zaehler}`;
|
||||
const herzen = Array.from({ length: 5 }, (_, i) => {
|
||||
const diff = bewertung - i;
|
||||
if (diff >= 0.75) return "full";
|
||||
if (diff >= 0.25) return "half";
|
||||
return "empty";
|
||||
});
|
||||
const svgs = herzen
|
||||
.map((zustand) => {
|
||||
const fill = zustand === "empty" ? "none" : `url(#${gradId})`;
|
||||
const stroke = zustand === "empty" ? "var(--c-text-muted)" : `url(#${gradId})`;
|
||||
const opacity = zustand === "half" ? "0.5" : "1";
|
||||
return `<svg class="heart-icon heart-${zustand}" width="${size}" height="${size}" viewBox="0 0 24 24"><path d="${HERZ_PFAD}" fill="${fill}" fill-opacity="${opacity}" stroke="${stroke}" stroke-width="1.4" stroke-linejoin="round"></path></svg>`;
|
||||
})
|
||||
.join("");
|
||||
const valueHtml = opts.showValue ? `<span class="heart-value">${bewertung.toFixed(1)}</span>` : "";
|
||||
const countHtml = opts.anzahlLabel ? `<span class="heart-count small">${opts.anzahlLabel}</span>` : "";
|
||||
return `<span class="heart-rating" role="img" aria-label="${bewertung.toFixed(1)} von 5 Herzen${opts.anzahlLabel ? `, ${opts.anzahlLabel}` : ""}">
|
||||
<svg width="0" height="0" style="position:absolute;" aria-hidden="true"><defs><linearGradient id="${gradId}" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="var(--c-purple-glow)"></stop><stop offset="55%" stop-color="#8aa8e6"></stop><stop offset="100%" stop-color="var(--c-accent)"></stop></linearGradient></defs></svg>
|
||||
<span class="heart-icons">${svgs}</span>${valueHtml}${countHtml}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
/** Klickbare Herzen-Auswahl fürs Bewertungsformular (1-5, volle Herzen, kein Halb-Zustand). */
|
||||
export function heartsPickerHtml(size = 28): string {
|
||||
const gradId = `hrc-picker-${Date.now().toString(36)}-${++zaehler}`;
|
||||
const svgs = Array.from({ length: 5 }, (_, i) => {
|
||||
const wert = i + 1;
|
||||
return `<button type="button" class="heart-pick" data-wert="${wert}" aria-label="${wert} von 5 Herzen"><svg width="${size}" height="${size}" viewBox="0 0 24 24"><path d="${HERZ_PFAD}" fill="none" stroke="var(--c-text-muted)" stroke-width="1.4" stroke-linejoin="round"></path></svg></button>`;
|
||||
}).join("");
|
||||
return `<span class="heart-picker" data-heart-picker data-grad="${gradId}">
|
||||
<svg width="0" height="0" style="position:absolute;" aria-hidden="true"><defs><linearGradient id="${gradId}" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="var(--c-purple-glow)"></stop><stop offset="55%" stop-color="#8aa8e6"></stop><stop offset="100%" stop-color="var(--c-accent)"></stop></linearGradient></defs></svg>
|
||||
${svgs}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
/** Setzt Fill/Stroke der Picker-Herzen passend zum gewählten Wert (aufgerufen bei Hover+Klick). */
|
||||
export function heartsPickerAktualisieren(picker: HTMLElement, wert: number) {
|
||||
const gradId = picker.dataset.grad;
|
||||
const buttons = Array.from(picker.querySelectorAll<HTMLButtonElement>(".heart-pick"));
|
||||
buttons.forEach((btn) => {
|
||||
const btnWert = Number(btn.dataset.wert);
|
||||
const path = btn.querySelector("path")!;
|
||||
const aktiv = btnWert <= wert;
|
||||
path.setAttribute("fill", aktiv ? `url(#${gradId})` : "none");
|
||||
path.setAttribute("stroke", aktiv ? `url(#${gradId})` : "var(--c-text-muted)");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Rein clientseitige Kundenrezensionen (Phase 1, kein Backend) — im localStorage gespeichert,
|
||||
// genau wie Warenkorb (cart.ts) und Konto (account.ts). Kund:innen schreiben ihre Rezension
|
||||
// direkt auf der Produktseite; sie wird sofort auf dieser Seite UND (per gemeinsamem Skript,
|
||||
// siehe reviews-live.ts) auf Produktkarten, im Shop-Filter und auf der Startseite berücksichtigt.
|
||||
// TODO Phase 2: durch echte, für alle Besucher:innen sichtbare Rezensionen aus einer Datenbank
|
||||
// ersetzen (Cloudflare Worker + D1) — bis dahin sieht jede Rezension nur die Person, die sie auf
|
||||
// ihrem eigenen Gerät/Browser geschrieben hat, wichtig das VanVan gegenüber ehrlich zu kommunizieren.
|
||||
|
||||
import { getAccount, markiereBestellungBewertet } from "./account";
|
||||
|
||||
export interface LokaleBewertung {
|
||||
name: string;
|
||||
bewertung: number; // 1–5
|
||||
text: string;
|
||||
datum: string; // ISO-Datum
|
||||
bestellnummer?: string;
|
||||
}
|
||||
|
||||
/** Einheitliche Anzeige-Form, egal ob die Rezension von VanVan (Admin/Seed-Daten, mehrsprachig)
|
||||
* oder von einer/einem Kund:in (lokal, eine Sprache) stammt — Produktkarten, Produktseite,
|
||||
* Startseite rechnen nur noch mit dieser Form. */
|
||||
export interface AnzeigeBewertung {
|
||||
name: string;
|
||||
bewertung: number;
|
||||
text?: string;
|
||||
datum?: string;
|
||||
}
|
||||
|
||||
const KEY = "vandiy_reviews_v1";
|
||||
|
||||
function alle(): Record<string, LokaleBewertung[]> {
|
||||
if (typeof localStorage === "undefined") return {};
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEY) || "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function speichereAlle(map: Record<string, LokaleBewertung[]>) {
|
||||
localStorage.setItem(KEY, JSON.stringify(map));
|
||||
window.dispatchEvent(new CustomEvent("reviews:changed", { detail: map }));
|
||||
}
|
||||
|
||||
export function lokaleBewertungenFuer(slug: string): LokaleBewertung[] {
|
||||
return alle()[slug] ?? [];
|
||||
}
|
||||
|
||||
/** Slugs aller Produkte, zu denen auf diesem Gerät schon eine Rezension geschrieben wurde —
|
||||
* Grundlage für die Startseiten-Kundenstimmen, wenn (noch) keine Admin-Rezensionen existieren. */
|
||||
export function alleLokalenSlugs(): string[] {
|
||||
return Object.entries(alle())
|
||||
.filter(([, liste]) => liste.length > 0)
|
||||
.map(([slug]) => slug);
|
||||
}
|
||||
|
||||
export function fuegeBewertungHinzu(
|
||||
slug: string,
|
||||
eintrag: { bewertung: number; text: string; bestellnummer?: string; name?: string }
|
||||
): LokaleBewertung {
|
||||
const account = getAccount();
|
||||
const neu: LokaleBewertung = {
|
||||
name: eintrag.name?.trim() || account.username || "Anonym",
|
||||
bewertung: Math.min(5, Math.max(1, Math.round(eintrag.bewertung))),
|
||||
text: eintrag.text.trim(),
|
||||
datum: new Date().toISOString().slice(0, 10),
|
||||
bestellnummer: eintrag.bestellnummer,
|
||||
};
|
||||
const map = alle();
|
||||
map[slug] = [neu, ...(map[slug] ?? [])];
|
||||
speichereAlle(map);
|
||||
if (eintrag.bestellnummer) markiereBestellungBewertet(eintrag.bestellnummer, slug);
|
||||
return neu;
|
||||
}
|
||||
|
||||
/** Führt die vom Server mitgelieferten (Admin/Seed-)Bewertungen mit den lokal auf diesem Gerät
|
||||
* gespeicherten zusammen — neueste zuerst. `serverText` bekommt schon die richtige Sprache
|
||||
* übergeben (Produkt-JSON ist mehrsprachig, lokale Kundentexte nicht). */
|
||||
export function bewertungenZusammenfuehren(
|
||||
serverBewertungen: { name: string; bewertung: number; text?: string; datum?: string }[],
|
||||
slug: string
|
||||
): AnzeigeBewertung[] {
|
||||
const lokal: AnzeigeBewertung[] = lokaleBewertungenFuer(slug).map((b) => ({
|
||||
name: b.name,
|
||||
bewertung: b.bewertung,
|
||||
text: b.text,
|
||||
datum: b.datum,
|
||||
}));
|
||||
const server: AnzeigeBewertung[] = serverBewertungen.map((b) => ({ ...b }));
|
||||
return [...lokal, ...server].sort((a, b) => (b.datum ?? "").localeCompare(a.datum ?? ""));
|
||||
}
|
||||
|
||||
export function durchschnitt(bewertungen: { bewertung: number }[]): number | null {
|
||||
if (bewertungen.length === 0) return null;
|
||||
return bewertungen.reduce((sum, b) => sum + b.bewertung, 0) / bewertungen.length;
|
||||
}
|
||||
+144
-1
@@ -550,6 +550,84 @@ a:focus-visible, button:focus-visible {
|
||||
.account-btn { padding: 0.5em; }
|
||||
}
|
||||
|
||||
/* Konto-Menü in der Kopfzeile — ersetzt den "Anmelden"-Button sobald auf diesem Gerät ein
|
||||
(Demo-)Konto angemeldet ist (siehe Layout.astro-Script + scripts/account.ts). Gleicher
|
||||
Verlaufsrand-Knopf-Look wie account-btn, damit der Wechsel nicht wie ein Bruch wirkt. */
|
||||
.account-area { position: relative; }
|
||||
.account-menu { position: relative; display: none; align-items: center; }
|
||||
.account-menu-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
padding: 0.4em 0.9em 0.4em 0.4em;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, rgba(95, 211, 236, 0.16), rgba(46, 27, 71, 0.4));
|
||||
border: 1px solid rgba(95, 211, 236, 0.3);
|
||||
color: var(--c-text);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s var(--ease), transform 0.2s var(--ease), box-shadow 0.2s var(--ease);
|
||||
}
|
||||
.account-menu-btn:hover {
|
||||
border-color: var(--c-accent);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 20px -8px rgba(95, 211, 236, 0.4);
|
||||
}
|
||||
.account-avatar {
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(155deg, var(--c-accent), var(--c-purple));
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--c-black);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.account-menu-btn .chevron { font-size: 0.6rem; opacity: 0.7; transition: transform 0.2s var(--ease); }
|
||||
.account-menu.open .account-menu-btn .chevron { transform: rotate(180deg); }
|
||||
.account-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.6rem);
|
||||
right: 0;
|
||||
min-width: 180px;
|
||||
background: var(--c-anthracite-light);
|
||||
border: 1px solid rgba(244, 241, 247, 0.1);
|
||||
border-radius: var(--radius-m);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 0.4rem;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
z-index: 60;
|
||||
}
|
||||
.account-menu.open .account-dropdown { display: flex; }
|
||||
.account-dropdown a,
|
||||
.account-dropdown button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--c-text);
|
||||
padding: 0.6em 0.7em;
|
||||
border-radius: var(--radius-s);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s var(--ease);
|
||||
}
|
||||
.account-dropdown a:hover,
|
||||
.account-dropdown button:hover { background: color-mix(in srgb, var(--c-accent) 12%, transparent); }
|
||||
@media (max-width: 860px) {
|
||||
.account-menu-btn .account-username { display: none; }
|
||||
.account-menu-btn { padding: 0.4em; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Geteilte Seiten-Layouts (von allen Sprachversionen genutzt,
|
||||
damit DE/EN/CH/FR immer exakt gleich aussehen)
|
||||
@@ -1163,8 +1241,27 @@ a:focus-visible, button:focus-visible {
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.account-head-identity { display: flex; align-items: center; gap: 1rem; }
|
||||
.account-head h1 { margin: 0.2rem 0 0.5rem; }
|
||||
.account-head .lead { margin: 0; max-width: 52ch; }
|
||||
/* Großer Avatar (Kopfzeile des Dashboards + Profil-Bereich) — gleiches Prinzip wie der kleine
|
||||
Avatar im Header-Konto-Menü (Foto als Hintergrundbild, sonst erster Buchstabe des Namens). */
|
||||
.account-avatar-lg {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(155deg, var(--c-accent), var(--c-purple));
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--c-black);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (max-width: 500px) { .account-head-identity { flex-wrap: wrap; } }
|
||||
.account-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-top: 1.2rem; }
|
||||
.account-stat { text-align: center; display: flex; flex-direction: column; gap: 0.25rem; padding: 1.2rem 1rem; }
|
||||
.account-stat-value { font-family: var(--font-head); font-size: 1.9rem; font-weight: 700; color: var(--c-accent); line-height: 1; }
|
||||
@@ -1182,14 +1279,24 @@ a:focus-visible, button:focus-visible {
|
||||
.order-items { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.order-items li { display: flex; justify-content: space-between; gap: 1rem; font-size: 0.95rem; color: var(--c-text-muted); }
|
||||
.order-item-price { color: var(--c-text); white-space: nowrap; }
|
||||
.order-item-right { display: flex; align-items: center; gap: 0.9rem; flex-wrap: wrap; }
|
||||
.review-link { color: var(--c-accent); font-weight: 600; white-space: nowrap; }
|
||||
.review-link:hover { text-decoration: underline; }
|
||||
.review-done-badge { background: rgba(126, 200, 160, 0.28); border-color: rgba(126, 200, 160, 0.5); color: #cfeee0; white-space: nowrap; }
|
||||
.order-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; border-top: 1px dashed rgba(244, 241, 247, 0.14); padding-top: 0.9rem; }
|
||||
.order-total { font-size: 1.05rem; }
|
||||
.order-total strong { color: var(--c-accent); }
|
||||
.order-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.order-tracking-no { margin: 0; color: var(--c-text-muted); }
|
||||
.order-tracking-no code { font-size: 0.9em; letter-spacing: 0.02em; }
|
||||
.profile-card { display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem; }
|
||||
.profile-card { display: flex; flex-direction: column; align-items: stretch; gap: 1.3rem; }
|
||||
.profile-card .specs { margin-top: 0; width: 100%; }
|
||||
.profile-photo-row { display: flex; align-items: center; gap: 1.1rem; }
|
||||
.profile-photo-btn { display: inline-block; cursor: pointer; }
|
||||
.profile-field { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.profile-field label { font-size: 0.85rem; color: var(--c-text-muted); }
|
||||
.profile-field-row { display: flex; gap: 0.6rem; }
|
||||
.profile-field-row input { flex: 1; }
|
||||
|
||||
/* Kundenrezensionen — Herzen in Lila-Babyblau statt Sterne (siehe HeartRating.astro), damit
|
||||
Bewertungen sich erkennbar in die restliche Seite einfügen statt wie ein fremdes Bewertungs-
|
||||
@@ -1203,6 +1310,42 @@ a:focus-visible, button:focus-visible {
|
||||
.review-name { font-weight: 700; }
|
||||
.review-date { color: var(--c-text-muted); margin-left: auto; }
|
||||
.review-card p { color: var(--c-text-muted); }
|
||||
|
||||
/* Bewertungsformular auf der Produktseite — eigene Karte unter der Rezensionsliste, gleicher
|
||||
.frm-Look (Formularfelder) wie Kontakt-/Login-Formulare der Seite. */
|
||||
.review-form-card { margin-top: 1.6rem; }
|
||||
.review-form-card h3 { margin-top: 0; }
|
||||
.review-form-card form { display: flex; flex-direction: column; gap: 1rem; margin-top: 1rem; }
|
||||
.review-form-card label { display: block; font-size: 0.85rem; color: var(--c-text-muted); margin-bottom: 0.4rem; }
|
||||
.review-form-card input[type="text"],
|
||||
.review-form-card textarea {
|
||||
width: 100%;
|
||||
background: var(--c-anthracite);
|
||||
color: var(--c-text);
|
||||
border: 1px solid rgba(244, 241, 247, 0.15);
|
||||
border-radius: var(--radius-s);
|
||||
padding: 0.6em 0.8em;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
resize: vertical;
|
||||
}
|
||||
.review-form-row { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.review-form-label { font-size: 0.85rem; color: var(--c-text-muted); }
|
||||
.review-login-note, .review-order-note { color: var(--c-text-muted); }
|
||||
.review-order-note { color: var(--c-accent); font-weight: 600; }
|
||||
|
||||
/* Klickbare Herzen-Auswahl im Formular — größer als die Anzeige-Herzen, deutlich als Steuerelement
|
||||
erkennbar (Hand-Cursor, sanftes Aufskalieren bei Hover). */
|
||||
.heart-picker { display: inline-flex; gap: 0.2em; }
|
||||
.heart-pick {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.1em;
|
||||
cursor: pointer;
|
||||
line-height: 0;
|
||||
transition: transform 0.15s var(--ease);
|
||||
}
|
||||
.heart-pick:hover { transform: scale(1.15); }
|
||||
/* Bewertungszeile auf der Produktkarte — dicht unter dem Titel, kein zusätzlicher Abstand nötig,
|
||||
da .product-card schon einen einheitlichen gap zwischen allen Kindern hat. */
|
||||
.product-card .heart-rating { margin-top: -0.2rem; }
|
||||
|
||||
Reference in New Issue
Block a user