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

+34
View File
@@ -15,6 +15,40 @@ export default async function NotFound() {
return ( return (
<div className="py-24"> <div className="py-24">
<Container className="max-w-xl text-center"> <Container className="max-w-xl text-center">
{/* Eine Wabe, in der genau eine Zelle fehlt — diese Seite. */}
<svg viewBox="0 0 120 104" className="mx-auto mb-10 h-28 w-32" fill="none" aria-hidden="true">
{/* Exakte Wabengeometrie: Spaltenabstand sqrt(3)*r, Zeilenabstand 1,5*r,
jede zweite Zeile um eine halbe Spalte versetzt. */}
{[
[0.5, 0],
[1.5, 0],
[0, 1],
[1, 1],
[2, 1],
[0.5, 2],
[1.5, 2],
].map(([col, row], i) => {
const r = 17;
const cx = 60 + (col - 1) * Math.sqrt(3) * (r + 1);
const cy = 52 + (row - 1) * 1.5 * (r + 1);
const points = Array.from({ length: 6 }, (_, k) => {
const a = (Math.PI / 3) * k - Math.PI / 2;
return `${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`;
}).join(" ");
const missing = i === 4;
return (
<polygon
key={i}
points={points}
stroke={missing ? "var(--color-honey-dark)" : "var(--color-honey)"}
strokeWidth="1.5"
strokeDasharray={missing ? "3 3" : undefined}
fill={missing ? "none" : i % 3 === 0 ? "var(--color-honey)" : "color-mix(in oklab, var(--color-honey) 35%, transparent)"}
className={missing ? "motion-pulse animate-pulse" : undefined}
/>
);
})}
</svg>
<SectionHeading <SectionHeading
eyebrow={t.notFoundPage.eyebrow} eyebrow={t.notFoundPage.eyebrow}
title={t.notFoundPage.title} title={t.notFoundPage.title}
+45 -3
View File
@@ -1,6 +1,8 @@
"use client"; "use client";
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { motion } from "motion/react";
import { HoneycombPattern } from "@/components/honeycomb";
import { Container, SectionHeading, Button } from "@/components/ui"; import { Container, SectionHeading, Button } from "@/components/ui";
import { ScrollReveal } from "@/components/scroll-reveal"; import { ScrollReveal } from "@/components/scroll-reveal";
import { TurnstileWidget } from "@/components/turnstile-widget"; import { TurnstileWidget } from "@/components/turnstile-widget";
@@ -50,8 +52,13 @@ export function NewsletterSignup({ turnstileSiteKey }: { turnstileSiteKey?: stri
return ( return (
<section className="py-24"> <section className="py-24">
<Container className="max-w-2xl text-center"> <Container className="max-w-4xl">
<ScrollReveal> <ScrollReveal>
{/* Eigene Flaeche mit Wabenraster: die Anmeldung ist ein Angebot,
kein Nachsatz unter der Seite. */}
<div className="relative overflow-hidden border border-honey/30 bg-honey/[0.06] px-6 py-14 text-center sm:px-14">
<HoneycombPattern className="text-honey/25" size={24} />
<div className="relative mx-auto max-w-2xl">
<SectionHeading <SectionHeading
eyebrow={t.home.newsletter.eyebrow} eyebrow={t.home.newsletter.eyebrow}
title={t.home.newsletter.title} title={t.home.newsletter.title}
@@ -61,7 +68,38 @@ export function NewsletterSignup({ turnstileSiteKey }: { turnstileSiteKey?: stri
<p className="mt-4 text-ink/70">{t.home.newsletter.description}</p> <p className="mt-4 text-ink/70">{t.home.newsletter.description}</p>
{status === "success" ? ( {status === "success" ? (
<p className="mt-8 text-forest">{t.home.newsletter.successMessage}</p> // Bestaetigung als kleine Zeremonie: das Haekchen zeichnet sich
// in der Wabe, dann erscheint der Text.
<div className="mt-8 flex flex-col items-center" role="status">
<svg viewBox="0 0 24 26" className="h-14 w-14" fill="none" aria-hidden="true">
<motion.polygon
points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7"
fill="var(--color-honey)"
initial={{ scale: 0.4, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 300, damping: 16 }}
style={{ transformOrigin: "center" }}
/>
<motion.path
d="M7.5 13.2l3 3 6-6.4"
stroke="var(--color-cream)"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.25 }}
/>
</svg>
<motion.p
className="mt-4 font-serif text-2xl text-forest"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.45 }}
>
{t.home.newsletter.successMessage}
</motion.p>
</div>
) : ( ) : (
<form onSubmit={handleSubmit} className="mt-8 max-w-lg mx-auto"> <form onSubmit={handleSubmit} className="mt-8 max-w-lg mx-auto">
{/* Eingabefeld und Knopf bilden die Zeile, das CAPTCHA steht {/* Eingabefeld und Knopf bilden die Zeile, das CAPTCHA steht
@@ -92,7 +130,9 @@ export function NewsletterSignup({ turnstileSiteKey }: { turnstileSiteKey?: stri
</form> </form>
)} )}
{status === "error" ? ( {status === "error" ? (
<p className="mt-4 text-sm text-red-700">{t.home.newsletter.errorMessage}</p> <p className="mt-4 text-sm text-red-700" role="alert">
{t.home.newsletter.errorMessage}
</p>
) : null} ) : null}
<p className="mt-6 text-xs text-ink/65"> <p className="mt-6 text-xs text-ink/65">
@@ -102,6 +142,8 @@ export function NewsletterSignup({ turnstileSiteKey }: { turnstileSiteKey?: stri
</a> </a>
{t.home.newsletter.consentAfterLink} {t.home.newsletter.consentAfterLink}
</p> </p>
</div>
</div>
</ScrollReveal> </ScrollReveal>
</Container> </Container>
</section> </section>
+30 -8
View File
@@ -1,7 +1,14 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
/** /**
* Gemeinsamer Mengen-Steller (−/Zahl/+), bisher als Inline-Markup nur auf der * Gemeinsamer Mengen-Steller (−/Zahl/+), bisher als Inline-Markup nur auf der
* Produktdetailseite vorhanden, waehrend Warenkorb-Drawer und Warenkorb-Seite * Produktdetailseite vorhanden, waehrend Warenkorb-Drawer und Warenkorb-Seite
* noch ein natives <select> nutzten — vereinheitlicht das Bedienelement. * noch ein natives <select> nutzten — vereinheitlicht das Bedienelement.
*
* Die Zahl "tickt" bei jeder Aenderung, die Knoepfe geben beim Druecken nach
* — die Menge fuehlt sich an wie ein Zaehlwerk statt wie ein Textfeld.
*/ */
export function QuantityStepper({ export function QuantityStepper({
quantity, quantity,
@@ -14,30 +21,45 @@ export function QuantityStepper({
max?: number; max?: number;
size?: "sm" | "md"; size?: "sm" | "md";
}) { }) {
const buttonSize = size === "sm" ? "w-8 h-8 text-base" : "w-10 h-10 text-lg"; const buttonSize = size === "sm" ? "w-8 h-8" : "w-10 h-10";
const buttonClass = `${buttonSize} flex items-center justify-center text-ink/70 transition-[color,background-color,transform] duration-200 hover:bg-honey/10 hover:text-forest active:scale-90 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-ink/70 disabled:active:scale-100`;
return ( return (
<div className="inline-flex items-stretch border border-ink/20"> <div className="inline-flex items-stretch border border-ink/20 bg-background">
<button <button
type="button" type="button"
onClick={() => onChange(Math.max(1, quantity - 1))} onClick={() => onChange(Math.max(1, quantity - 1))}
disabled={quantity <= 1} disabled={quantity <= 1}
aria-label="Menge verringern" aria-label="Menge verringern"
className={`${buttonSize} flex items-center justify-center text-ink/70 hover:text-forest disabled:opacity-30 disabled:hover:text-ink/70`} className={buttonClass}
> >
− <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M5 12h14" strokeLinecap="round" />
</svg>
</button> </button>
<span className="w-8 flex items-center justify-center text-sm" aria-live="polite"> <span className="relative w-8 overflow-hidden flex items-center justify-center text-sm tabular-nums" aria-live="polite">
{quantity} <AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={quantity}
initial={{ y: -12, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 12, opacity: 0 }}
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
>
{quantity}
</motion.span>
</AnimatePresence>
</span> </span>
<button <button
type="button" type="button"
onClick={() => onChange(Math.min(max, quantity + 1))} onClick={() => onChange(Math.min(max, quantity + 1))}
disabled={quantity >= max} disabled={quantity >= max}
aria-label="Menge erhöhen" aria-label="Menge erhöhen"
className={`${buttonSize} flex items-center justify-center text-ink/70 hover:text-forest disabled:opacity-30 disabled:hover:text-ink/70`} className={buttonClass}
> >
+ <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M5 12h14M12 5v14" strokeLinecap="round" />
</svg>
</button> </button>
</div> </div>
); );
+74 -11
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState, useEffect, useRef, type RefObject } from "react"; import { useState, useEffect, useRef, type RefObject } from "react";
import { AnimatePresence, motion, useAnimationControls } from "motion/react"; import { AnimatePresence, motion, useAnimationControls } from "motion/react";
import { useCartStore, cartItemCount, cartTotalCents } from "@/store/cart-store"; 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 { LanguageSwitcher } from "@/components/language-switcher";
import { useI18n } from "@/i18n/i18n-provider"; import { useI18n } from "@/i18n/i18n-provider";
import { CartFlight, onCartLanded } from "@/components/cart-flight"; import { CartFlight, onCartLanded } from "@/components/cart-flight";
import { HexMark } from "@/components/honeycomb";
import type { Dictionary } from "@/i18n/dictionary-type"; import type { Dictionary } from "@/i18n/dictionary-type";
const FOCUSABLE_SELECTOR = 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 }) { export function Header({ companyName }: { companyName: string }) {
const { t } = useI18n(); const { t } = useI18n();
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const cartOpen = useCartStore((s) => s.isOpen);
const { scrolled, hidden } = useHeaderScrollState(mobileOpen || cartOpen);
const menuButtonRef = useRef<HTMLButtonElement>(null); const menuButtonRef = useRef<HTMLButtonElement>(null);
const mobileMenuRef = useRef<HTMLDivElement>(null); const mobileMenuRef = useRef<HTMLDivElement>(null);
@@ -287,7 +332,15 @@ export function Header({ companyName }: { companyName: string }) {
return ( 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): Eigener, breiterer Wrapper statt der gemeinsamen <Container> (max-w-6xl):
Logo + 7-Punkte-Navigation + CTA-Button + Icons brauchen mehr Raum als die 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} /> <Logo t={t} companyName={companyName} />
<nav aria-label="Hauptnavigation" className="hidden xl:flex items-center gap-3 2xl:gap-6"> <nav aria-label="Hauptnavigation" className="hidden xl:flex items-center gap-3 2xl:gap-6">
{mainNav.map((item) => ( {mainNav.map((item) => {
<Link const active = isActivePath(pathname, item.href);
key={item.href} return (
href={item.href} <Link
className="text-sm text-ink/80 hover:text-forest transition-colors whitespace-nowrap" key={item.href}
> href={item.href}
{t.nav[item.key]} aria-current={active ? "page" : undefined}
</Link> 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> </nav>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -396,8 +455,12 @@ export function Header({ companyName }: { companyName: string }) {
<Link <Link
href={item.href} href={item.href}
onClick={() => setMobileOpen(false)} 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]} {t.nav[item.key]}
</Link> </Link>
</motion.div> </motion.div>
+5 -3
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { HoneycombPattern } from "@/components/honeycomb";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Container } from "@/components/ui"; import { Container } from "@/components/ui";
import { Logomark } from "@/components/logomark"; import { Logomark } from "@/components/logomark";
@@ -116,8 +117,9 @@ export async function Footer({
}; };
return ( return (
<footer className="bg-forest text-cream mt-24"> <footer className="relative overflow-hidden bg-forest text-cream mt-24">
<Container className="py-16"> <HoneycombPattern className="text-honey-light/[0.06]" size={26} fade="right" />
<Container className="relative py-16">
<div className="grid grid-cols-2 md:grid-cols-5 gap-10"> <div className="grid grid-cols-2 md:grid-cols-5 gap-10">
<div className="col-span-2"> <div className="col-span-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -180,7 +182,7 @@ function FooterColumn({
<ul className="space-y-2"> <ul className="space-y-2">
{links.map((link) => ( {links.map((link) => (
<li key={link.key}> <li key={link.key}>
<Link href={link.href} className="text-sm text-cream/75 hover:text-cream"> <Link href={link.href} className="link-draw pb-0.5 text-sm text-cream/75 transition-colors hover:text-cream">
{labels[link.key]} {labels[link.key]}
</Link> </Link>
</li> </li>