feat(ui): Kopfzeile, Fusszeile und Details mit mehr Leben
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m11s

- Kopfzeile: aktive Seite mit Honig-Unterstrich (auch im Handy-Menue),
  einlaufende Unterstriche beim Hover, beim Scrollen Glas und Schatten;
  weicht beim Runterscrollen aus und ist beim Hochscrollen sofort zurueck
  (steht still, solange Warenkorb oder Menue offen sind)
- Fusszeile mit Wabenraster und einlaufenden Link-Unterstrichen
- Mengensteller: gezeichnete Plus/Minus, taktiles Druecken, tickende Zahl
- Newsletter als eigene Flaeche mit Wabenraster; Erfolg zeichnet ein
  Haekchen in eine Wabe, Fehler werden als Alert angesagt
- 404: Wabe mit einer fehlenden Zelle als Bild fuer die fehlende Seite

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-25 08:05:01 +02:00
1 parent 4ee5bafe20
commit 064ca3ff6d
5 files changed
+188 -25

No files matched your search

+74 -11
View File
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState, useEffect, useRef, type RefObject } from "react";
import { AnimatePresence, motion, useAnimationControls } from "motion/react";
import { useCartStore, cartItemCount, cartTotalCents } from "@/store/cart-store";
@@ -14,6 +15,7 @@ import { QuantityStepper } from "@/components/quantity-stepper";
import { LanguageSwitcher } from "@/components/language-switcher";
import { useI18n } from "@/i18n/i18n-provider";
import { CartFlight, onCartLanded } from "@/components/cart-flight";
import { HexMark } from "@/components/honeycomb";
import type { Dictionary } from "@/i18n/dictionary-type";
const FOCUSABLE_SELECTOR =
@@ -271,9 +273,52 @@ function CartIconButton() {
);
}
/**
* Kopfzeile reagiert aufs Scrollen: ab etwas Abstand zum Seitenanfang
* bekommt sie Schatten und staerkeres Glas; beim Weiterlesen nach unten
* weicht sie aus, beim kleinsten Hochscrollen ist sie sofort wieder da.
* Waehrend Warenkorb oder Menue offen sind, bleibt sie stehen.
*/
function useHeaderScrollState(locked: boolean) {
const [scrolled, setScrolled] = useState(false);
const [hidden, setHidden] = useState(false);
useEffect(() => {
let lastY = window.scrollY;
let frame = 0;
const onScroll = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const y = window.scrollY;
setScrolled(y > 12);
// Kleine Zitterbewegungen (Trackpad) ignorieren.
if (Math.abs(y - lastY) > 6) {
setHidden(y > lastY && y > 240);
lastY = y;
}
});
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", onScroll);
};
}, []);
return { scrolled, hidden: hidden && !locked };
}
function isActivePath(pathname: string, href: string): boolean {
return href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(`${href}/`);
}
export function Header({ companyName }: { companyName: string }) {
const { t } = useI18n();
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
const cartOpen = useCartStore((s) => s.isOpen);
const { scrolled, hidden } = useHeaderScrollState(mobileOpen || cartOpen);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const mobileMenuRef = useRef<HTMLDivElement>(null);
@@ -287,7 +332,15 @@ export function Header({ companyName }: { companyName: string }) {
return (
<>
<header className="sticky top-0 z-40 bg-background/95 backdrop-blur border-b border-ink/10">
<header
className={`sticky top-0 z-40 border-b transition-[transform,background-color,box-shadow,border-color] duration-500 ease-[var(--ease-arrive)] motion-reduce:transition-none ${
hidden ? "-translate-y-full" : "translate-y-0"
} ${
scrolled
? "bg-background/80 backdrop-blur-md border-transparent shadow-[0_10px_30px_-20px_rgba(36,53,43,0.45)]"
: "bg-background/95 backdrop-blur 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
@@ -298,15 +351,21 @@ export function Header({ companyName }: { companyName: string }) {
<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>
))}
{mainNav.map((item) => {
const active = isActivePath(pathname, item.href);
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className={`link-draw pb-1 text-sm whitespace-nowrap transition-colors ${
active ? "text-forest [background-image:linear-gradient(var(--color-honey),var(--color-honey))] [background-size:100%_2px]" : "text-ink/80 hover:text-forest"
}`}
>
{t.nav[item.key]}
</Link>
);
})}
</nav>
<div className="flex items-center gap-2">
@@ -396,8 +455,12 @@ export function Header({ companyName }: { companyName: string }) {
<Link
href={item.href}
onClick={() => setMobileOpen(false)}
className="text-3xl font-serif"
aria-current={isActivePath(pathname, item.href) ? "page" : undefined}
className={`flex items-center gap-3 text-3xl font-serif ${
isActivePath(pathname, item.href) ? "text-honey-light" : ""
}`}
>
{isActivePath(pathname, item.href) ? <HexMark className="h-3 w-3" /> : null}
{t.nav[item.key]}
</Link>
</motion.div>