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
+6
-19
@@ -134,25 +134,12 @@ collections:
|
||||
- { label: Lieferumfang, name: lieferumfang, widget: string, required: false }
|
||||
- { label: Pflegehinweise, name: pflegehinweise, widget: string, required: false }
|
||||
- { label: Lieferzeit, name: lieferzeit, widget: string, required: false }
|
||||
- label: "🩵 Kundenrezensionen"
|
||||
name: bewertungen
|
||||
widget: list
|
||||
required: false
|
||||
summary: "{{fields.name}} — {{fields.bewertung}}/5"
|
||||
hint: "WICHTIG: Hier nur echte Rückmeldungen deiner Kund:innen eintragen (z.B. per E-Mail/Nachricht erhalten) — erfundene Bewertungen sind in Deutschland und der EU gesetzlich verboten (Gesetz gegen unlauteren Wettbewerb). Ohne Eintrag hier bleibt der Bewertungsbereich beim Produkt einfach leer, das ist völlig normal für neue Produkte."
|
||||
fields:
|
||||
- { label: "Name (z.B. Vorname + Initiale des Nachnamens)", name: name, widget: string }
|
||||
- { label: "Bewertung (1-5 Herzen)", name: bewertung, widget: number, value_type: int, min: 1, max: 5 }
|
||||
- label: "Text der Bewertung (optional)"
|
||||
name: text
|
||||
widget: object
|
||||
required: false
|
||||
fields:
|
||||
- { label: Deutsch, name: de, widget: text, required: false }
|
||||
- { label: Englisch, name: en, widget: text, required: false }
|
||||
- { label: "Schweizerdeutsch", name: ch, widget: text, required: false }
|
||||
- { label: Französisch, name: fr, widget: text, required: false }
|
||||
- { label: "Datum (optional)", name: datum, widget: datetime, format: "YYYY-MM-DD", date_format: "YYYY-MM-DD", time_format: false, required: false }
|
||||
# Kein "Bewertungen"-Feld mehr hier: Kund:innen schreiben ihre Rezensionen jetzt direkt auf
|
||||
# der Produktseite selbst (Herzen + Text), die Durchschnittsbewertung wird automatisch
|
||||
# berechnet und überall auf der Seite live angezeigt (Produktkarten, Filter, Startseite).
|
||||
# VanVan muss dafür nichts mehr im Adminbereich eintragen. Das Datenfeld "bewertungen" bleibt
|
||||
# im Hintergrund als technische Grundlage bestehen (src/data/products.ts), ist hier aber
|
||||
# bewusst nicht mehr als Formularfeld sichtbar.
|
||||
- label: "Interner Code (URL) — nach Veröffentlichung nicht mehr ändern!"
|
||||
name: slug
|
||||
widget: string
|
||||
|
||||
@@ -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>
|
||||
{/* 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">
|
||||
{/* 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", () => {
|
||||
|
||||
@@ -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,16 +94,17 @@ const bewertungSchnittGesamt = gesamtDurchschnitt();
|
||||
</section>
|
||||
)}
|
||||
|
||||
{topBewertungen.length > 0 && bewertungSchnittGesamt !== null && (
|
||||
<section class="section-tight">
|
||||
<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">
|
||||
<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">
|
||||
<div class="grid grid-3" id="kundenstimmen-cards">
|
||||
{topBewertungen.map((r) => (
|
||||
<div class="card review-card">
|
||||
<div class="review-card-head">
|
||||
@@ -112,7 +118,6 @@ const bewertungSchnittGesamt = gesamtDurchschnitt();
|
||||
</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 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>{t.accountDash.greeting}</h1>
|
||||
<h1 id="dash-greeting">{t.accountDash.greeting("…")}</h1>
|
||||
<p class="lead">{t.accountDash.lead}</p>
|
||||
</div>
|
||||
<a class="btn btn-outline btn-sm" href="/ch/konto/">{t.accountDash.logout}</a>
|
||||
</div>
|
||||
<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-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,13 +134,20 @@ const felder: [string, string | undefined][] = [
|
||||
<section class="section-tight" id="bewertungen">
|
||||
<div class="container">
|
||||
<h2>{t.product.reviewsTitle}</h2>
|
||||
{/* 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) => (
|
||||
) : (
|
||||
<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>
|
||||
@@ -151,10 +158,29 @@ const felder: [string, string | undefined][] = [
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p class="small">{t.product.noReviews}</p>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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,16 +94,17 @@ const bewertungSchnittGesamt = gesamtDurchschnitt();
|
||||
</section>
|
||||
)}
|
||||
|
||||
{topBewertungen.length > 0 && bewertungSchnittGesamt !== null && (
|
||||
<section class="section-tight">
|
||||
<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">
|
||||
<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">
|
||||
<div class="grid grid-3" id="kundenstimmen-cards">
|
||||
{topBewertungen.map((r) => (
|
||||
<div class="card review-card">
|
||||
<div class="review-card-head">
|
||||
@@ -112,7 +118,6 @@ const bewertungSchnittGesamt = gesamtDurchschnitt();
|
||||
</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 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>{t.accountDash.greeting}</h1>
|
||||
<h1 id="dash-greeting">{t.accountDash.greeting("…")}</h1>
|
||||
<p class="lead">{t.accountDash.lead}</p>
|
||||
</div>
|
||||
<a class="btn btn-outline btn-sm" href="/en/konto/">{t.accountDash.logout}</a>
|
||||
</div>
|
||||
<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-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,13 +134,20 @@ const felder: [string, string | undefined][] = [
|
||||
<section class="section-tight" id="bewertungen">
|
||||
<div class="container">
|
||||
<h2>{t.product.reviewsTitle}</h2>
|
||||
{/* 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) => (
|
||||
) : (
|
||||
<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>
|
||||
@@ -151,10 +158,29 @@ const felder: [string, string | undefined][] = [
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p class="small">{t.product.noReviews}</p>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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,16 +94,17 @@ const bewertungSchnittGesamt = gesamtDurchschnitt();
|
||||
</section>
|
||||
)}
|
||||
|
||||
{topBewertungen.length > 0 && bewertungSchnittGesamt !== null && (
|
||||
<section class="section-tight">
|
||||
<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">
|
||||
<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">
|
||||
<div class="grid grid-3" id="kundenstimmen-cards">
|
||||
{topBewertungen.map((r) => (
|
||||
<div class="card review-card">
|
||||
<div class="review-card-head">
|
||||
@@ -112,7 +118,6 @@ const bewertungSchnittGesamt = gesamtDurchschnitt();
|
||||
</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 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>{t.accountDash.greeting}</h1>
|
||||
<h1 id="dash-greeting">{t.accountDash.greeting("…")}</h1>
|
||||
<p class="lead">{t.accountDash.lead}</p>
|
||||
</div>
|
||||
<a class="btn btn-outline btn-sm" href="/fr/konto/">{t.accountDash.logout}</a>
|
||||
</div>
|
||||
<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-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,13 +134,20 @@ const felder: [string, string | undefined][] = [
|
||||
<section class="section-tight" id="bewertungen">
|
||||
<div class="container">
|
||||
<h2>{t.product.reviewsTitle}</h2>
|
||||
{/* 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) => (
|
||||
) : (
|
||||
<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>
|
||||
@@ -151,10 +158,29 @@ const felder: [string, string | undefined][] = [
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p class="small">{t.product.noReviews}</p>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
+72
-8
@@ -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,16 +103,21 @@ const bewertungSchnittGesamt = gesamtDurchschnitt();
|
||||
</section>
|
||||
)}
|
||||
|
||||
{topBewertungen.length > 0 && bewertungSchnittGesamt !== null && (
|
||||
<section class="section-tight">
|
||||
{/* 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">
|
||||
<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">
|
||||
<div class="grid grid-3" id="kundenstimmen-cards">
|
||||
{topBewertungen.map((r) => (
|
||||
<div class="card review-card">
|
||||
<div class="review-card-head">
|
||||
@@ -115,7 +131,6 @@ const bewertungSchnittGesamt = gesamtDurchschnitt();
|
||||
</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 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>{t.accountDash.greeting}</h1>
|
||||
<h1 id="dash-greeting">{t.accountDash.greeting("…")}</h1>
|
||||
<p class="lead">{t.accountDash.lead}</p>
|
||||
</div>
|
||||
<a class="btn btn-outline btn-sm" href="/konto/">{t.accountDash.logout}</a>
|
||||
</div>
|
||||
<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-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>
|
||||
|
||||
@@ -134,13 +134,20 @@ const felder: [string, string | undefined][] = [
|
||||
<section class="section-tight" id="bewertungen">
|
||||
<div class="container">
|
||||
<h2>{t.product.reviewsTitle}</h2>
|
||||
{/* 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) => (
|
||||
) : (
|
||||
<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>
|
||||
@@ -151,10 +158,29 @@ const felder: [string, string | undefined][] = [
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p class="small">{t.product.noReviews}</p>
|
||||
)}
|
||||
|
||||
<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