Initial commit: Van's DIY & Bastelbedarf Astro shop

This commit is contained in:
qcigano
2026-08-02 13:22:55 +02:00
commit 2e340f7a8a
92 changed files with 11022 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
---
import { locales, localeMeta, type Locale } from "../i18n/config";
interface Props {
lang: Locale;
/** Sprachneutraler Pfad der aktuellen Seite, z.B. "/shop/" oder "/produkt/foo/". */
path: string;
/** Seite existiert nur auf Deutsch (z.B. Rechtstexte) — alle Optionen führen zur DE-Version,
* damit der Umschalter niemals auf eine 404-Seite verlinkt. */
legalOnly?: boolean;
}
const { lang, path, legalOnly = false } = Astro.props;
function hrefFor(target: Locale) {
if (legalOnly) return path; // immer die deutsche Original-URL
const prefix = target === "de" ? "" : `/${target}`;
return `${prefix}${path}` || "/";
}
function globeStyle(l: Locale) {
const m = localeMeta[l];
return `--globe-color:${m.color}; --globe-glow:${m.glow}; --flag-1:${m.flag[0]}; --flag-2:${m.flag[1]}; --flag-3:${m.flag[2]}`;
}
---
<div class="lang-switch" id="lang-switch">
<button
type="button"
class="lang-switch-btn"
id="lang-switch-btn"
aria-haspopup="true"
aria-expanded="false"
style={globeStyle(lang)}
>
<span class="lang-globe" aria-hidden="true">
<span class="lang-globe-rotor">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">
<circle cx="12" cy="12" r="8.5" />
<ellipse cx="12" cy="12" rx="3.6" ry="8.5" />
<path d="M3.7 9.5h16.6M3.7 14.5h16.6" />
</svg>
</span>
</span>
<span class="lang-label">{localeMeta[lang].label}</span>
<span class="chevron">▼</span>
</button>
<div class="lang-switch-menu" role="menu">
{locales.map((l) => (
<a
href={hrefFor(l)}
role="menuitem"
aria-current={l === lang ? "true" : "false"}
title={legalOnly && l !== "de" ? "Nur auf Deutsch verfügbar / Only available in German" : undefined}
style={globeStyle(l)}
>
<span class="lang-globe" aria-hidden="true">
<span class="lang-globe-rotor">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">
<circle cx="12" cy="12" r="8.5" />
<ellipse cx="12" cy="12" rx="3.6" ry="8.5" />
<path d="M3.7 9.5h16.6M3.7 14.5h16.6" />
</svg>
</span>
</span>
<span class="lang-label">{localeMeta[l].label}</span>
</a>
))}
</div>
</div>
<script>
// Jede Instanz für sich initialisieren (Desktop-Header + evtl. Mobile-Nav-Kopie).
document.querySelectorAll(".lang-switch").forEach((el) => {
const btn = el.querySelector(".lang-switch-btn");
btn?.addEventListener("click", (e) => {
e.stopPropagation();
document.querySelectorAll(".lang-switch.open").forEach((other) => {
if (other !== el) other.classList.remove("open");
});
el.classList.toggle("open");
btn.setAttribute("aria-expanded", el.classList.contains("open") ? "true" : "false");
});
});
document.addEventListener("click", () => {
document.querySelectorAll(".lang-switch.open").forEach((el) => el.classList.remove("open"));
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
document.querySelectorAll(".lang-switch.open").forEach((el) => el.classList.remove("open"));
}
});
</script>
+52
View File
@@ -0,0 +1,52 @@
---
import type { Product } from "../data/products";
import type { Locale } from "../i18n/config";
import { localePrefix } from "../i18n/config";
import { useTranslations } from "../i18n/ui";
import { formatPrice } from "../i18n/format";
interface Props { product: Product; lang: Locale }
const { product, lang } = Astro.props;
const t = useTranslations(lang);
const badgeLabel: Record<string, string> = {
neu: t.badge.neu,
bestseller: t.badge.bestseller,
sale: t.badge.sale,
handgemacht: t.badge.handgemacht,
};
const name = product.name[lang];
const desc = product.beschreibung[lang];
---
<a href={`${localePrefix(lang)}/produkt/${product.slug}/`} class="card product-card">
<div class="product-media">
<div class="img-placeholder" role="img" aria-label={`${t.common.photoSoon}: ${name}`}>
{t.common.photoSoon}<br /><span class="small">{name}</span>
</div>
<div class="product-badges">
{product.badges.map((b) => (
<span class={`badge ${b === "sale" ? "badge-sale" : ""}`}>{badgeLabel[b]}</span>
))}
{product.bestand === 0 && <span class="badge badge-sold-out">{t.badge.ausverkauft}</span>}
</div>
</div>
<h3>{name}</h3>
<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>}
<span class="price">{formatPrice(product.preis, lang)}</span>
</div>
</a>
<style>
.product-card { display: flex; flex-direction: column; gap: 0.6rem; padding: 1rem; }
.product-media { position: relative; }
.product-media .img-placeholder { aspect-ratio: 1 / 1; min-height: 0; }
.product-badges { position: absolute; top: 0.6rem; left: 0.6rem; display: flex; gap: 0.4rem; flex-wrap: wrap; }
.product-card h3 { font-size: 1.05rem; margin: 0.2rem 0 0; transition: color 0.2s var(--ease); }
.product-card:hover h3 { color: var(--c-accent); }
.product-card p { margin: 0; }
.price-row { margin-top: auto; display: flex; align-items: baseline; gap: 0.6rem; }
.price { color: var(--c-accent); font-weight: 700; font-size: 1.15rem; }
.price-old { color: var(--c-text-muted); text-decoration: line-through; font-size: 0.9rem; }
</style>