feat: Wabenhain Honig-Shop Anwendung
Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei: Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich (Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung, PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma- Treiber-Adapter, Unit-/Komponenten-/E2E-Tests.
This commit is contained in:
1 parent
5c76fcc266
commit
c7927f2883
240 files changed
+26096
-137
No files matched your search
@@ -0,0 +1,600 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect, useRef, type RefObject } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCartStore, cartItemCount, cartTotalCents } from "@/store/cart-store";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { mainNav, footerNav } from "@/lib/site-config";
|
||||
import { Container, LinkButton } from "@/components/ui";
|
||||
import { Logomark } from "@/components/logomark";
|
||||
import { CartItemThumbnail } from "@/components/cart-item-thumbnail";
|
||||
import { QuantityStepper } from "@/components/quantity-stepper";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/**
|
||||
* Haelt den Tab-Fokus innerhalb eines geoeffneten Overlays (Cart-Drawer,
|
||||
* mobiles Menue) — sonst koennte man sich per Tab zu Links "hinter" dem
|
||||
* abgedunkelten Hintergrund durchtabben, die dort unsichtbar, aber technisch
|
||||
* weiterhin fokussierbar sind.
|
||||
*/
|
||||
function useFocusTrap(active: boolean, containerRef: RefObject<HTMLElement | null>) {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
// Fokus beim Oeffnen aktiv in den Container verschieben — sonst bleibt er
|
||||
// auf dem jetzt visuell durch den Hintergrund verdeckten Ausloeser-Button
|
||||
// stehen, und Tastaturnutzer:innen muessten sich erst durch den Rest der
|
||||
// (fuer sie unsichtbaren) Seite tabben, bevor sie ueberhaupt im Overlay
|
||||
// ankommen. Der Container selbst (tabindex=-1), nicht z.B. der
|
||||
// Schliessen-Button, damit eine Screenreader-Ansage nicht sofort mit
|
||||
// "Schliessen" beginnt.
|
||||
containerRef.current?.focus();
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const container = containerRef.current;
|
||||
if (event.key !== "Tab" || !container) return;
|
||||
const focusable = Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||
(el) => el.offsetParent !== null
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [active, containerRef]);
|
||||
}
|
||||
|
||||
/** Schliesst ein Overlay (Suche, Cart-Drawer, mobiles Menue) per Escape-Taste. */
|
||||
function useEscapeKey(active: boolean, onEscape: () => void) {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onEscape();
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [active, onEscape]);
|
||||
}
|
||||
|
||||
function Logo({ t, companyName }: { t: Dictionary; companyName: string }) {
|
||||
return (
|
||||
<Link href="/" className="flex items-center gap-3 group">
|
||||
<Logomark className="h-9 w-9 flex-shrink-0" />
|
||||
<span className="flex flex-col leading-none">
|
||||
<span className="text-2xl tracking-[0.2em] text-forest font-serif">
|
||||
{companyName}
|
||||
</span>
|
||||
<span className="hidden xl:block text-[10px] tracking-[0.3em] uppercase text-honey-dark mt-1 whitespace-nowrap">
|
||||
{t.footer.tagline}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
interface SearchSuggestion {
|
||||
slug: string;
|
||||
name: string;
|
||||
price: string;
|
||||
}
|
||||
|
||||
function SearchButton() {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([]);
|
||||
const toggleButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length < 2) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => {
|
||||
fetch(`/api/shop/search-suggestions?q=${encodeURIComponent(trimmed)}`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data: { suggestions?: SearchSuggestion[] }) => setSuggestions(data.suggestions ?? []))
|
||||
.catch(() => {});
|
||||
}, 200);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
controller.abort();
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
function closeSearch() {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
setSuggestions([]);
|
||||
toggleButtonRef.current?.focus();
|
||||
}
|
||||
|
||||
useEscapeKey(open, closeSearch);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.form
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 180, opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
action="/shop"
|
||||
method="GET"
|
||||
className="overflow-visible"
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
placeholder={t.nav.searchPlaceholder}
|
||||
className="w-full border-b border-forest bg-transparent px-2 py-1 text-sm focus:outline-none"
|
||||
/>
|
||||
{suggestions.length > 0 ? (
|
||||
<ul className="absolute top-full left-0 mt-2 w-72 bg-background border border-ink/10 shadow-[0_16px_32px_-16px_rgba(36,53,43,0.25)] z-50">
|
||||
{suggestions.map((s) => (
|
||||
<li key={s.slug}>
|
||||
<Link
|
||||
href={`/honig/${s.slug}`}
|
||||
onClick={closeSearch}
|
||||
className="flex items-center justify-between gap-4 px-4 py-2.5 text-sm text-ink hover:bg-cream/60"
|
||||
>
|
||||
<span>{s.name}</span>
|
||||
<span className="text-ink/60 whitespace-nowrap">{s.price}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</motion.form>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<button
|
||||
ref={toggleButtonRef}
|
||||
type="button"
|
||||
onClick={() => (open ? closeSearch() : setOpen(true))}
|
||||
aria-label={t.nav.openSearch}
|
||||
className="flex items-center justify-center w-10 h-10 text-forest flex-shrink-0"
|
||||
>
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountIconButton() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Link
|
||||
href="/konto"
|
||||
aria-label={t.nav.account}
|
||||
className="flex items-center justify-center w-10 h-10 text-forest"
|
||||
>
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 20c1.5-4 5-6 8-6s6.5 2 8 6" />
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function CartIconButton() {
|
||||
const { t } = useI18n();
|
||||
const items = useCartStore((s) => s.items);
|
||||
const openCart = useCartStore((s) => s.openCart);
|
||||
const count = cartItemCount(items);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={openCart}
|
||||
aria-label={t.nav.openCart(count)}
|
||||
className="relative flex items-center justify-center w-10 h-10 text-forest"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M6 6h15l-1.5 9h-12z" />
|
||||
<path d="M6 6 5 3H2" />
|
||||
<circle cx="9" cy="20" r="1" />
|
||||
<circle cx="18" cy="20" r="1" />
|
||||
</svg>
|
||||
{count > 0 ? (
|
||||
<span className="absolute -top-1 -right-1 bg-honey text-cream text-[10px] w-4 h-4 rounded-full flex items-center justify-center">
|
||||
{count}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header({ companyName }: { companyName: string }) {
|
||||
const { t } = useI18n();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function closeMobileMenu() {
|
||||
setMobileOpen(false);
|
||||
menuButtonRef.current?.focus();
|
||||
}
|
||||
|
||||
useEscapeKey(mobileOpen, closeMobileMenu);
|
||||
useFocusTrap(mobileOpen, mobileMenuRef);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-40 bg-background/95 backdrop-blur border-b border-ink/10">
|
||||
{/*
|
||||
Eigener, breiterer Wrapper statt der gemeinsamen <Container> (max-w-6xl):
|
||||
Logo + 7-Punkte-Navigation + CTA-Button + Icons brauchen mehr Raum als die
|
||||
1152px-Inhaltsspalte der restlichen Seite hergibt — mit Container stiessen
|
||||
Navigation und Button ab 1280px Breite ohne jeden Abstand aneinander.
|
||||
*/}
|
||||
<div className="mx-auto flex h-20 w-full max-w-[100rem] items-center justify-between px-6 2xl:px-10">
|
||||
<Logo t={t} companyName={companyName} />
|
||||
|
||||
<nav aria-label="Hauptnavigation" className="hidden xl:flex items-center gap-3 2xl:gap-6">
|
||||
{mainNav.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="text-sm text-ink/80 hover:text-forest transition-colors whitespace-nowrap"
|
||||
>
|
||||
{t.nav[item.key]}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden md:block">
|
||||
<LinkButton href="/shop" variant="primary" className="whitespace-nowrap">
|
||||
{t.nav.discoverHoney}
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="hidden xl:flex items-center gap-1">
|
||||
<LanguageSwitcher />
|
||||
<SearchButton />
|
||||
<AccountIconButton />
|
||||
</div>
|
||||
<CartIconButton />
|
||||
<button
|
||||
ref={menuButtonRef}
|
||||
aria-label={t.nav.openMenu}
|
||||
className="xl:hidden flex items-center justify-center w-10 h-10 text-forest"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/*
|
||||
Bewusst AUSSERHALB von <header>: backdrop-blur auf dem Header erzeugt
|
||||
einen eigenen CSS-Containing-Block fuer position:fixed-Nachfahren
|
||||
(wie transform/filter). Ein "fixed inset-0" Overlay INNERHALB des
|
||||
Headers wuerde sich dadurch relativ zum Header statt zum Viewport
|
||||
positionieren und den Seiteninhalt durchscheinen lassen, statt ihn
|
||||
vollflaechig abzudecken.
|
||||
*/}
|
||||
<AnimatePresence>
|
||||
{mobileOpen ? (
|
||||
<motion.div
|
||||
ref={mobileMenuRef}
|
||||
tabIndex={-1}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="fixed inset-0 z-50 bg-forest text-cream flex flex-col outline-none"
|
||||
>
|
||||
<Container className="flex items-center justify-between h-20">
|
||||
<span className="flex items-center gap-3">
|
||||
<Logomark className="h-8 w-8" variant="inverted" />
|
||||
<span className="text-xl tracking-[0.2em] font-serif">
|
||||
{companyName}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<LanguageSwitcher className="!text-cream/70" />
|
||||
<button
|
||||
aria-label={t.nav.closeMenu}
|
||||
className="w-10 h-10 flex items-center justify-center"
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
<Container>
|
||||
<form action="/shop" method="GET">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder={t.nav.searchPlaceholder}
|
||||
className="w-full border-b border-cream/40 bg-transparent py-2 text-lg placeholder:text-cream/50 focus:outline-none"
|
||||
/>
|
||||
</form>
|
||||
</Container>
|
||||
<nav aria-label="Mobile Hauptnavigation" className="flex-1 flex flex-col items-start justify-center gap-6 px-10">
|
||||
{mainNav.map((item, i) => (
|
||||
<motion.div
|
||||
key={item.href}
|
||||
initial={{ opacity: 0, x: -16 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.05 * i, duration: 0.3 }}
|
||||
>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="text-3xl font-serif"
|
||||
>
|
||||
{t.nav[item.key]}
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -16 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.05 * mainNav.length, duration: 0.3 }}
|
||||
>
|
||||
<Link
|
||||
href="/konto"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="text-3xl font-serif"
|
||||
>
|
||||
{t.nav.account}
|
||||
</Link>
|
||||
</motion.div>
|
||||
</nav>
|
||||
<Container className="pb-10">
|
||||
<LinkButton
|
||||
href="/shop"
|
||||
variant="secondary"
|
||||
className="!border-cream !text-cream hover:!bg-cream hover:!text-forest w-full"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{t.nav.discoverHoney}
|
||||
</LinkButton>
|
||||
</Container>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CartDrawer() {
|
||||
const { t } = useI18n();
|
||||
const isOpen = useCartStore((s) => s.isOpen);
|
||||
const closeCart = useCartStore((s) => s.closeCart);
|
||||
const items = useCartStore((s) => s.items);
|
||||
const triggerRef = useRef<Element | null>(null);
|
||||
|
||||
// CartIconButton (der Ausloeser) und CartDrawer sind ueber den Zustand-Store
|
||||
// entkoppelt, nicht ueber Props verbunden — deshalb wird beim Oeffnen einfach
|
||||
// gemerkt, welches Element gerade fokussiert war (typischerweise der
|
||||
// angeklickte Button), um den Fokus beim Schliessen dorthin zurueckzugeben,
|
||||
// statt ihn auf <body> verpuffen zu lassen.
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
triggerRef.current = document.activeElement;
|
||||
} else {
|
||||
if (triggerRef.current instanceof HTMLElement) {
|
||||
triggerRef.current.focus();
|
||||
}
|
||||
triggerRef.current = null;
|
||||
}
|
||||
}, [isOpen]);
|
||||
const setQuantity = useCartStore((s) => s.setQuantity);
|
||||
const removeItem = useCartStore((s) => s.removeItem);
|
||||
const total = cartTotalCents(items);
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEscapeKey(isOpen, closeCart);
|
||||
useFocusTrap(isOpen, asideRef);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen ? (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-ink/40 z-50"
|
||||
onClick={closeCart}
|
||||
/>
|
||||
<motion.aside
|
||||
ref={asideRef}
|
||||
tabIndex={-1}
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ type: "spring", damping: 28, stiffness: 260 }}
|
||||
className="fixed right-0 top-0 h-full w-full sm:w-[420px] bg-background z-50 flex flex-col shadow-xl outline-none"
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 h-20 border-b border-ink/10">
|
||||
<h2 className="text-xl font-serif text-forest">{t.cart.title}</h2>
|
||||
<button aria-label={t.cart.close} onClick={closeCart} className="w-9 h-9">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-ink/70 mt-10 text-center">
|
||||
{t.cart.empty}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-ink/10">
|
||||
{items.map((item) => (
|
||||
<li key={item.productId} className="py-4 flex gap-4">
|
||||
<CartItemThumbnail image={item.image} className="w-16 h-16 flex-shrink-0 rounded-sm" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-ink">{item.name}</p>
|
||||
<p className="text-xs text-ink/65">{item.weightGrams} g</p>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<QuantityStepper
|
||||
quantity={item.quantity}
|
||||
onChange={(quantity) => setQuantity(item.productId, quantity)}
|
||||
size="sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeItem(item.productId)}
|
||||
className="text-xs text-ink/65 hover:text-ink underline"
|
||||
>
|
||||
{t.cart.remove}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-ink">
|
||||
{formatPrice(item.priceCents * item.quantity)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="border-t border-ink/10 p-6 space-y-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{t.cart.subtotal}</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
</div>
|
||||
<LinkButton href="/checkout" onClick={closeCart} className="w-full">
|
||||
{t.cart.checkout}
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
href="/warenkorb"
|
||||
variant="secondary"
|
||||
onClick={closeCart}
|
||||
className="w-full"
|
||||
>
|
||||
{t.cart.viewCart}
|
||||
</LinkButton>
|
||||
</div>
|
||||
) : null}
|
||||
</motion.aside>
|
||||
</>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export function Footer({
|
||||
companyInfo,
|
||||
}: {
|
||||
companyInfo: { name: string; email: string; address: { street: string; zip: string; city: string } };
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const wabenhainLabels: Record<(typeof footerNav.wabenhain)[number]["key"], string> = {
|
||||
aboutUs: t.footer.aboutUs,
|
||||
origin: t.nav.origin,
|
||||
harvests: t.nav.harvests,
|
||||
contact: t.nav.contact,
|
||||
};
|
||||
const einkaufenLabels: Record<(typeof footerNav.einkaufen)[number]["key"], string> = {
|
||||
shop: t.nav.shop,
|
||||
shipping: t.footer.shipping,
|
||||
pickup: t.footer.pickup,
|
||||
businessCustomers: t.footer.businessCustomers,
|
||||
trackOrder: t.footer.trackOrder,
|
||||
};
|
||||
const rechtlichesLabels: Record<(typeof footerNav.rechtliches)[number]["key"], string> = {
|
||||
impressum: t.legalTitles.impressum,
|
||||
datenschutz: t.legalTitles.datenschutz,
|
||||
agb: t.legalTitles.agb,
|
||||
widerruf: t.legalTitles.widerruf,
|
||||
versandZahlung: t.legalTitles.versandZahlung,
|
||||
cookieRichtlinie: t.legalTitles.cookieRichtlinie,
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="bg-forest text-cream mt-24">
|
||||
<Container className="py-16">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-10">
|
||||
<div className="col-span-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Logomark className="h-9 w-9" variant="inverted" />
|
||||
<span className="text-2xl tracking-[0.2em] font-serif">
|
||||
{companyInfo.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-cream/70 text-sm max-w-xs">{t.footer.tagline}</p>
|
||||
<p className="mt-6 text-cream/60 text-sm">
|
||||
{companyInfo.address.street}
|
||||
<br />
|
||||
{companyInfo.address.zip} {companyInfo.address.city}
|
||||
<br />
|
||||
{companyInfo.email}
|
||||
</p>
|
||||
</div>
|
||||
<FooterColumn title={t.footer.columnWabenhain} links={footerNav.wabenhain} labels={wabenhainLabels} />
|
||||
<FooterColumn title={t.footer.columnShopping} links={footerNav.einkaufen} labels={einkaufenLabels} />
|
||||
<FooterColumn title={t.footer.columnLegal} links={footerNav.rechtliches} labels={rechtlichesLabels} />
|
||||
</div>
|
||||
<div className="mt-16 pt-8 border-t border-cream/15 flex flex-col md:flex-row justify-between gap-4 text-xs text-cream/70">
|
||||
<span>© {new Date().getFullYear()} Wabenhain</span>
|
||||
<span>{t.footer.bottomLine}</span>
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterColumn({
|
||||
title,
|
||||
links,
|
||||
labels,
|
||||
}: {
|
||||
title: string;
|
||||
links: readonly { href: string; key: string }[];
|
||||
labels: Record<string, string>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="tracking-wide-caps text-xs text-honey-light mb-4">{title}</p>
|
||||
<ul className="space-y-2">
|
||||
{links.map((link) => (
|
||||
<li key={link.key}>
|
||||
<Link href={link.href} className="text-sm text-cream/75 hover:text-cream">
|
||||
{labels[link.key]}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user