Files
vans-diy-bastelbedarf/src/layouts/Layout.astro
T
qciganoandClaude Sonnet 5 6f10e41386 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]>
2026-08-03 04:17:04 +02:00

472 lines
19 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
import "../styles/global.css";
import { categories } from "../data/categories";
import { defaultLocale, localeMeta, localePrefix, type Locale } from "../i18n/config";
import { useTranslations } from "../i18n/ui";
import LanguageSwitcher from "../components/LanguageSwitcher.astro";
interface Props {
title: string;
description?: string;
lang?: Locale;
/** Sprachneutraler Pfad der aktuellen Seite (ohne Sprach-Prefix), z.B. "/shop/". Für den Sprachumschalter. */
path?: string;
/** Seite existiert nur auf Deutsch (Rechtstexte) — siehe LanguageSwitcher. */
legalOnly?: boolean;
}
const {
title,
description = "Van's DIY & Bastelbedarf handgemachte Häkelwerke, Plushies, Schmuck und Bastelzubehör mit Liebe gefertigt.",
lang = defaultLocale,
path = "/",
legalOnly = false,
} = Astro.props;
const t = useTranslations(lang);
const p = (href: string) => `${localePrefix(lang)}${href}`;
---
<!doctype html>
<html lang={localeMeta[lang].htmlLang}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Eigenes Logo als Icon — überall (Browser-Tab, Lesezeichen, Installation als App/PWA auf
PC & Handy) statt eines automatisch generierten Buchstaben-Icons. -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" href="/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon-16x16.png" sizes="16x16" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>{title} · Van's DIY & Bastelbedarf</title>
<meta name="description" content={description} />
<link rel="canonical" href={new URL(Astro.url.pathname, Astro.site ?? "https://vans-diy-bastelbedarf.de").toString()} />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Van's DIY & Bastelbedarf" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta name="theme-color" content="#0a0910" />
<!-- TODO Phase 2: Fonts self-hosten statt Google-Fonts-CDN (Performance/DSGVO) -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],300..700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
:root { --font-head-override: "Fraunces", serif; }
</style>
</head>
<body>
<div class="topbar">🩵 {t.topbar}</div>
<header class="site-header">
<div class="container header-inner">
<a href={p("/")} class="brand">
<img src="/logo.png" alt="Van's DIY & Bastelbedarf Logo" width="48" height="48" />
<span>Van's DIY &amp; Bastelbedarf</span>
</a>
<nav class="main-nav" aria-label="Hauptnavigation">
<a href={p("/shop/")}>{t.nav.shop}</a>
<a href={p("/sale/")}>{t.nav.sale}</a>
<a href={p("/ueber-die-kreative/")}>{t.nav.creative}</a>
<a href={p("/faq/")}>{t.nav.faq}</a>
<a href={p("/kontakt/")}>{t.nav.contact}</a>
</nav>
<div class="header-actions">
<button type="button" id="install-btn" class="install-btn" style="display:none;" title={t.common.installApp}>
<span class="install-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 3a1 1 0 0 1 1 1v9.6l3-3a1 1 0 1 1 1.4 1.4l-4.7 4.7a1 1 0 0 1-1.4 0L6.6 12a1 1 0 1 1 1.4-1.4l3 3V4a1 1 0 0 1 1-1Zm-7 15a1 1 0 0 1 1-1h12a1 1 0 1 1 0 2H6a1 1 0 0 1-1-1Z"/></svg>
</span>
<span class="install-label">{t.common.installAppShort}</span>
</button>
<LanguageSwitcher lang={lang} path={path} legalOnly={legalOnly} />
{/* 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>
<button class="hamburger" id="hamburger" aria-label="Menü" aria-expanded="false">☰</button>
</div>
</div>
<nav class="mobile-nav" id="mobile-nav" aria-label="Mobile Navigation">
<a href={p("/shop/")}>{t.nav.shop}</a>
<a href={p("/sale/")}>{t.nav.sale}</a>
<a href={p("/ueber-die-kreative/")}>{t.nav.creative}</a>
<a href={p("/faq/")}>{t.nav.faq}</a>
<a href={p("/kontakt/")}>{t.nav.contact}</a>
<a href={p("/konto/")}>{t.nav.account}</a>
<span class="mobile-nav-divider">{t.shop.categoryLabel}</span>
{categories.map((c) => <a href={p(`/shop/${c.slug}/`)}>{c.icon} {c.name[lang]}</a>)}
</nav>
</header>
<nav class="sub-nav" aria-label="Kategorien-Schnellzugriff">
<div class="container sub-nav-inner">
{categories.map((c) => (
<a href={p(`/shop/${c.slug}/`)}>{c.name[lang]}</a>
))}
</div>
</nav>
<main>
<slot />
</main>
<footer class="site-footer">
<div class="container footer-grid">
<div>
<a href={p("/")} class="brand">
<img src="/logo.png" alt="Van's DIY & Bastelbedarf Logo" width="36" height="36" />
<span>Van's DIY &amp; Bastelbedarf</span>
</a>
<p class="small">{t.footer.tagline}</p>
</div>
<div>
<h3>{t.footer.shopHeading}</h3>
<ul class="footer-list">
{categories.map((c) => <li><a href={p(`/shop/${c.slug}/`)}>{c.name[lang]}</a></li>)}
</ul>
</div>
<div>
<h3>{t.footer.serviceHeading}</h3>
<ul class="footer-list">
<li><a href={p("/versand-zahlung/")}>{t.footer.shipping}</a></li>
<li><a href={p("/faq/")}>{t.nav.faq}</a></li>
<li><a href={p("/kontakt/")}>{t.nav.contact}</a></li>
<li><a href={p("/konto/")}>{t.footer.account}</a></li>
</ul>
</div>
<div>
<h3>{t.footer.legalHeading}</h3>
<ul class="footer-list">
<li><a href="/impressum/">Impressum</a></li>
<li><a href="/datenschutz/">Datenschutz</a></li>
<li><a href="/agb/">AGB</a></li>
<li><a href="/widerruf/">Widerrufsbelehrung</a></li>
<li><a href="/muster-widerrufsformular/">Muster-Widerrufsformular</a></li>
</ul>
{lang !== "de" && <p class="small legal-note-footer" title={t.footer.legalNoteTitle}>{t.footer.legalNote}</p>}
</div>
</div>
<div class="container">
<hr class="divider" />
<p class="small">© {new Date().getFullYear()} Van's DIY & Bastelbedarf · {t.footer.copyright}</p>
</div>
</footer>
<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());
}
updateCount();
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", () => {
const open = mobileNav?.classList.toggle("open");
hamburger.setAttribute("aria-expanded", String(!!open));
});
// "Auf dem Gerät installieren" — zeigt einen echten Button in der Kopfzeile, sobald der
// Browser eine Installation anbietet (Chrome/Edge auf PC & Handy), statt sich darauf zu
// verlassen, dass jemand das winzige Icon im Browser selbst findet. Läuft bereits als
// installierte App? Dann gibt es sowieso nichts mehr zu installieren — Button bleibt versteckt.
const installBtn = document.getElementById("install-btn");
const laeuftBereitsAlsApp = window.matchMedia("(display-mode: standalone)").matches;
let installPromptEvent = null;
if (!laeuftBereitsAlsApp) {
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
installPromptEvent = e;
if (installBtn) installBtn.style.display = "flex";
});
}
installBtn?.addEventListener("click", async () => {
if (!installPromptEvent) return;
installBtn.style.display = "none";
installPromptEvent.prompt();
await installPromptEvent.userChoice;
installPromptEvent = null;
});
window.addEventListener("appinstalled", () => {
if (installBtn) installBtn.style.display = "none";
});
// Service Worker: rein "network-first" (versucht IMMER zuerst das echte Netzwerk, Cache
// dient nur als Rückfalllösung, wenn wirklich keine Verbindung besteht) — kein Risiko auf
// veraltete Inhalte im Adminbereich, macht die Seite aber gleichzeitig zu einer "echten" App
// (Installations-Voraussetzung in den meisten Browsern) und funktioniert notfalls auch kurz
// offline für bereits besuchte Seiten.
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {});
});
}
</script>
<style is:global>
body { font-family: var(--font-body); }
h1, h2, h3, h4 { font-family: var(--font-head-override, var(--font-head)); }
.topbar {
background: linear-gradient(90deg, var(--c-purple), var(--c-purple-deep));
color: var(--c-text);
text-align: center;
font-size: 0.9rem;
padding: 0.6rem 1rem;
letter-spacing: 0.01em;
}
.site-header {
position: sticky;
top: 0;
z-index: 40;
background: rgba(10, 9, 16, 0.85);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(244, 241, 247, 0.06);
}
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-block: 1rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.6rem;
font-family: var(--font-head);
font-weight: 600;
font-size: 1.18rem;
}
.brand img { border-radius: 50%; }
.main-nav { display: flex; gap: 1.7rem; flex-shrink: 0; }
.main-nav a {
font-size: 1.02rem;
color: var(--c-text-muted);
white-space: nowrap;
transition: color 0.2s var(--ease);
}
.main-nav a:hover { color: var(--c-accent); }
.header-actions { display: flex; align-items: center; gap: 0.8rem; }
.icon-link { font-size: 1.25rem; position: relative; display: inline-flex; align-items: center; gap: 0.3rem; }
/* "Auf dem Gerät installieren"-Button — nur sichtbar, wenn der Browser das wirklich anbietet
(per JS über das beforeinstallprompt-Event ein-/ausgeblendet, siehe Script unten). Damit
muss niemand erst das kleine, leicht zu übersehende Installations-Icon im Browser selbst
suchen — der Button ist direkt in der Kopfzeile sichtbar. */
.install-btn {
display: flex;
align-items: center;
gap: 0.45em;
padding: 0.5em 0.9em 0.5em 0.7em;
border-radius: 999px;
background: linear-gradient(135deg, rgba(155, 127, 214, 0.22), rgba(46, 27, 71, 0.4));
border: 1px solid rgba(155, 127, 214, 0.4);
color: var(--c-text);
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition: border-color 0.2s var(--ease), transform 0.2s var(--ease), box-shadow 0.2s var(--ease);
}
.install-btn:hover {
border-color: var(--c-purple-glow);
transform: translateY(-1px);
box-shadow: 0 8px 20px -8px rgba(155, 127, 214, 0.5);
}
.install-icon { display: flex; }
@media (max-width: 640px) { .install-btn .install-label { display: none; } .install-btn { padding: 0.5em; } }
.cart-count {
background: var(--c-accent);
color: #08181c;
font-size: 0.7rem;
font-weight: 700;
border-radius: 999px;
min-width: 1.2em;
padding: 0 0.35em;
text-align: center;
}
.hamburger {
display: none;
background: none;
border: none;
color: var(--c-text);
font-size: 1.4rem;
cursor: pointer;
}
.mobile-nav {
display: none;
flex-direction: column;
padding: 0.5rem 1.5rem 1.2rem;
gap: 0.9rem;
border-top: 1px solid rgba(244,241,247,0.06);
}
.mobile-nav.open { display: flex; }
.mobile-nav-divider {
margin-top: 0.4rem;
padding-top: 0.9rem;
border-top: 1px solid rgba(244, 241, 247, 0.08);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--c-accent);
}
@media (max-width: 1100px) {
.main-nav { display: none; }
.hamburger { display: inline-block; }
}
/* Auf schmalen Screens (Handy) reicht der Platz nicht mehr für Logo+Markenname+
Sprachumschalter-Text+Konto-Icon+Warenkorb+Menü-Button nebeneinander in voller Größe —
ohne diese Anpassung würde der Menü-Button (☰) rechts aus dem Bildschirm rausgeschoben
und wäre gar nicht mehr anklickbar. Zeigt auf schmalen Screens nur noch die Icons/Kürzel. */
@media (max-width: 480px) {
.brand img { width: 38px; height: 38px; }
.brand span { font-size: 0.98rem; }
.lang-switch-btn { padding: 0.35em 0.55em; }
.lang-switch-btn .lang-label,
.lang-switch-btn .chevron { display: none; }
}
/* Zweite Navigationsleiste: Schnellzugriff auf die 5 Hauptkategorien, direkt unter der
Hauptnav — gleicher Designstil (dunkler Anthrazit-Streifen, Akzent-Unterstrich bei Hover). */
.sub-nav {
background: var(--c-anthracite);
border-bottom: 1px solid rgba(244, 241, 247, 0.06);
}
.sub-nav-inner {
display: flex;
gap: clamp(1.1rem, 3vw, 2rem);
overflow-x: auto;
padding-block: 0.75rem;
scrollbar-width: none;
}
.sub-nav-inner::-webkit-scrollbar { display: none; }
.sub-nav-inner a {
position: relative;
font-size: 0.92rem;
font-weight: 600;
color: var(--c-text-muted);
white-space: nowrap;
padding-bottom: 3px;
transition: color 0.2s var(--ease);
}
.sub-nav-inner a::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 2px;
background: var(--c-accent);
transform: scaleX(0);
transform-origin: left;
transition: transform 0.2s var(--ease);
}
.sub-nav-inner a:hover { color: var(--c-accent); }
.sub-nav-inner a:hover::after { transform: scaleX(1); }
@media (max-width: 860px) { .sub-nav { display: none; } }
.site-footer {
background: var(--c-anthracite);
margin-top: 4rem;
padding-top: 3rem;
padding-bottom: 2rem;
}
.footer-grid {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr 1fr;
gap: 2rem;
margin-bottom: 1rem;
}
.footer-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.55rem; }
.footer-list a { color: var(--c-text-muted); font-size: 0.9rem; }
.footer-list a:hover { color: var(--c-accent); }
.legal-note-footer { margin-top: 0.8rem; font-size: 0.78rem; line-height: 1.5; }
@media (max-width: 760px) { .footer-grid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 480px) { .footer-grid { grid-template-columns: 1fr; } }
</style>
</body>
</html>