Files
vans-diy-bastelbedarf/src/layouts/Layout.astro
T
qciganoandClaude Sonnet 5 25055d9345 Konto-Dropdown: Menüpunkte sauber ausgerichtet statt schiefem Emoji-Versatz
"Mein Konto" und "Abmelden" starteten je nach Breite des Emojis (👤 vs 🚪)
an leicht unterschiedlichen Stellen. Jetzt echtes Flex-Layout mit fester
Icon-Spalte (1.2em, zentriert) — beide Labels stehen exakt untereinander.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-03 17:52:32 +02:00

494 lines
20 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;
/** Interne Vorschau-/Demoseiten (z.B. /demo-abo/) sollen nie von Suchmaschinen indexiert
* werden — setzt zusätzlich zum Sitemap-Ausschluss noch ein robots-Meta. */
noindex?: boolean;
}
const {
title,
description = "Van's DIY & Bastelbedarf handgemachte Häkelwerke, Plushies, Schmuck und Bastelzubehör mit Liebe gefertigt.",
lang = defaultLocale,
path = "/",
legalOnly = false,
noindex = 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} />
{noindex && <meta name="robots" content="noindex, nofollow" />}
<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"><span class="account-dropdown-icon" aria-hidden="true">👤</span>{t.accountMenu.myAccount}</a>
<button type="button" id="account-logout-btn" role="menuitem"><span class="account-dropdown-icon" aria-hidden="true">🚪</span>{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, anzeigeName } 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 = anzeigeName(a) || "Konto";
if (avatar) {
if (a.photo) {
avatar.style.backgroundImage = `url(${a.photo})`;
avatar.textContent = "";
} else {
avatar.style.backgroundImage = "";
avatar.textContent = (anzeigeName(a) || "?").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");
});
// Öffnet zusätzlich automatisch beim Drüberfahren mit der Maus (Desktop) — kein Klick mehr
// nötig. Kurze Schließverzögerung beim Verlassen, damit der Wechsel von Button zu Menü nicht
// sofort wieder zuklappt. Klick bleibt als Fallback für Touch-Geräte/Tastatur erhalten.
let accountCloseTimer: ReturnType<typeof setTimeout> | null = null;
menu?.addEventListener("mouseenter", () => {
if (accountCloseTimer) { clearTimeout(accountCloseTimer); accountCloseTimer = null; }
menu.classList.add("open");
menuBtn?.setAttribute("aria-expanded", "true");
});
menu?.addEventListener("mouseleave", () => {
accountCloseTimer = setTimeout(() => {
menu.classList.remove("open");
menuBtn?.setAttribute("aria-expanded", "false");
}, 200);
});
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>