feat(ui): Kopfzeile, Fusszeile und Details mit mehr Leben
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m11s
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:
1 parent
4ee5bafe20
commit
064ca3ff6d
5 files changed
+181
-18
No files matched your search
@@ -15,6 +15,40 @@ export default async function NotFound() {
|
||||
return (
|
||||
<div className="py-24">
|
||||
<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
|
||||
eyebrow={t.notFoundPage.eyebrow}
|
||||
title={t.notFoundPage.title}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { HoneycombPattern } from "@/components/honeycomb";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { TurnstileWidget } from "@/components/turnstile-widget";
|
||||
@@ -50,8 +52,13 @@ export function NewsletterSignup({ turnstileSiteKey }: { turnstileSiteKey?: stri
|
||||
|
||||
return (
|
||||
<section className="py-24">
|
||||
<Container className="max-w-2xl text-center">
|
||||
<Container className="max-w-4xl">
|
||||
<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
|
||||
eyebrow={t.home.newsletter.eyebrow}
|
||||
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>
|
||||
|
||||
{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">
|
||||
{/* Eingabefeld und Knopf bilden die Zeile, das CAPTCHA steht
|
||||
@@ -92,7 +130,9 @@ export function NewsletterSignup({ turnstileSiteKey }: { turnstileSiteKey?: stri
|
||||
</form>
|
||||
)}
|
||||
{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}
|
||||
|
||||
<p className="mt-6 text-xs text-ink/65">
|
||||
@@ -102,6 +142,8 @@ export function NewsletterSignup({ turnstileSiteKey }: { turnstileSiteKey?: stri
|
||||
</a>
|
||||
{t.home.newsletter.consentAfterLink}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
/**
|
||||
* Gemeinsamer Mengen-Steller (−/Zahl/+), bisher als Inline-Markup nur auf der
|
||||
* Produktdetailseite vorhanden, waehrend Warenkorb-Drawer und Warenkorb-Seite
|
||||
* 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({
|
||||
quantity,
|
||||
@@ -14,30 +21,45 @@ export function QuantityStepper({
|
||||
max?: number;
|
||||
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 (
|
||||
<div className="inline-flex items-stretch border border-ink/20">
|
||||
<div className="inline-flex items-stretch border border-ink/20 bg-background">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(Math.max(1, quantity - 1))}
|
||||
disabled={quantity <= 1}
|
||||
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>
|
||||
<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">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(Math.min(max, quantity + 1))}
|
||||
disabled={quantity >= max}
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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) => (
|
||||
{mainNav.map((item) => {
|
||||
const active = isActivePath(pathname, item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="text-sm text-ink/80 hover:text-forest transition-colors whitespace-nowrap"
|
||||
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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { HoneycombPattern } from "@/components/honeycomb";
|
||||
import type { ReactNode } from "react";
|
||||
import { Container } from "@/components/ui";
|
||||
import { Logomark } from "@/components/logomark";
|
||||
@@ -116,8 +117,9 @@ export async function Footer({
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="bg-forest text-cream mt-24">
|
||||
<Container className="py-16">
|
||||
<footer className="relative overflow-hidden bg-forest text-cream mt-24">
|
||||
<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="col-span-2">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -180,7 +182,7 @@ function FooterColumn({
|
||||
<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">
|
||||
<Link href={link.href} className="link-draw pb-0.5 text-sm text-cream/75 transition-colors hover:text-cream">
|
||||
{labels[link.key]}
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
Reference in new issue
Block a user