321 lines
14 KiB
TypeScript
321 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||
import { cn } from "@/lib/utils";
|
||
import { flags } from "@/lib/site";
|
||
import { gsap, MM } from "@/lib/gsap";
|
||
import { splitPlaceholders } from "@/lib/legal";
|
||
import { hero as heroDe, type HomeContent } from "@/content/home";
|
||
import { Section } from "@/components/layout/Section";
|
||
import { Folio } from "@/components/layout/Folio";
|
||
import { Lines } from "@/components/motion/Lines";
|
||
import { Reveal } from "@/components/motion/Reveal";
|
||
import { Door } from "@/components/motion/Door";
|
||
import { Magnet } from "@/components/motion/Magnet";
|
||
import { DrawMark } from "@/components/brand/DrawMark";
|
||
import { Button } from "@/components/ui/Button";
|
||
import { PlaceholderText } from "@/components/ui/Facts";
|
||
|
||
export interface HeroProps {
|
||
/** `<BrandMark idPrefix="hero" handoff/>` – Server Component, rendered by page.tsx. */
|
||
mark: ReactNode;
|
||
/** `getPhotos().hero` – `null` renders the documented variant B (monogram is the image). */
|
||
photo: string | null;
|
||
/** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
|
||
content?: HomeContent["hero"];
|
||
}
|
||
|
||
/* Load choreography (spec §6.1), seconds from mount. Masthead hairlines 0 / 0.15 are
|
||
Masthead's own; the folio hairline waits for the neck stroke (draw 0.7 + handoff 1.2–1.4). */
|
||
const T = { lines: 0.3, door: 0.5, draw: 0.7, cta: 1.2, folio: 2.0 } as const;
|
||
|
||
/** Fallback length of the handoff hairline until it is measured (px). */
|
||
const HANDOFF = 40;
|
||
|
||
const MONO_11 = "font-mono text-[11px] tracking-[0.06em]";
|
||
|
||
/**
|
||
* Handoff x for `<Rule origin={x}/>`, measured from the folio's left edge.
|
||
* DrawMark writes `--handoff-x` on its wrapper (fallback WolfMark); a client
|
||
* SVG with `path#handoff` is measured at the path's end point.
|
||
*/
|
||
function measureHandoffX(markWrap: HTMLElement, folioEl: HTMLElement): number | null {
|
||
const folioLeft = folioEl.getBoundingClientRect().left;
|
||
const drawWrap = markWrap.querySelector<HTMLElement>("[style*='--handoff-x']");
|
||
if (drawWrap) {
|
||
const v = parseFloat(drawWrap.style.getPropertyValue("--handoff-x"));
|
||
if (!Number.isNaN(v)) return drawWrap.getBoundingClientRect().left + v - folioLeft;
|
||
}
|
||
const path = markWrap.querySelector<SVGPathElement>("path.wolfmark-handoff");
|
||
const ctm = path?.ownerSVGElement?.getScreenCTM();
|
||
if (path && ctm) {
|
||
const end = path.getPointAtLength(path.getTotalLength());
|
||
const sp = new DOMPoint(end.x, end.y).matrixTransform(ctm);
|
||
return sp.x - folioLeft;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Samples the mark's outline in SVG user space – once. The geometry never
|
||
* changes, only the CTM does, so a resize re-projects these points instead of
|
||
* walking `getPointAtLength` again (~500 forced geometry calls per event).
|
||
*/
|
||
function sampleMarkPoints(markWrap: HTMLElement): { x: number; y: number }[] {
|
||
const svg = markWrap.querySelector("svg");
|
||
if (!svg) return [];
|
||
const pts: { x: number; y: number }[] = [];
|
||
svg.querySelectorAll<SVGPathElement>("path").forEach((path) => {
|
||
let len = 0;
|
||
try {
|
||
len = path.getTotalLength();
|
||
} catch {
|
||
return;
|
||
}
|
||
if (!len) return;
|
||
for (let i = 0; i <= 60; i++) {
|
||
const p = path.getPointAtLength((len * i) / 60);
|
||
pts.push({ x: p.x, y: p.y });
|
||
}
|
||
});
|
||
return pts;
|
||
}
|
||
|
||
/**
|
||
* Screen y of the mark's lowest painted point – the V vertex the handoff
|
||
* hairline hangs from. The SVG box bottom is ~5 % below it (viewBox padding),
|
||
* so a fixed length always stopped short of the folio rule.
|
||
*/
|
||
function markBottomY(markWrap: HTMLElement, pts: { x: number; y: number }[]): number | null {
|
||
const svg = markWrap.querySelector("svg");
|
||
const ctm = svg?.getScreenCTM();
|
||
if (!svg || !ctm || !pts.length) return null;
|
||
let maxY = -Infinity;
|
||
for (const p of pts) {
|
||
const y = new DOMPoint(p.x, p.y).matrixTransform(ctm).y;
|
||
if (y > maxY) maxY = y;
|
||
}
|
||
return Number.isFinite(maxY) ? maxY : null;
|
||
}
|
||
|
||
/**
|
||
* Cover (ink, ≥ 100svh). Rows: (leer) · 1fr · H1 · Subline + CTA · Folio.
|
||
* Die Mastkopf-Zeile wurde am 06.09.2026 auf Kundenwunsch gestrichen; die erste
|
||
* Rasterzeile bleibt leer, damit die übrigen Zeilenzuweisungen unverändert gelten.
|
||
* Variant A (photo): portrait cols 8–12 bleeding to the page edge with scrims,
|
||
* monogram 28vh in cols 6–8 overlapping its left edge, neck handoff into the
|
||
* folio hairline, which draws outward from that x. Variant B (no photo):
|
||
* monogram 56vh cols 7–12, right-aligned, same handoff. Mobile: masthead,
|
||
* monogram 14svh, H1, CTAs, portrait 3:4 (≤ 48svh) – no folio row (it costs
|
||
* 69 px the phone cover does not have; Positionierung carries „01 / 06“).
|
||
*/
|
||
export function Hero({ mark, photo, content: hero = heroDe }: HeroProps) {
|
||
const root = useRef<HTMLDivElement>(null);
|
||
const h1 = useRef<HTMLDivElement>(null);
|
||
const markWrap = useRef<HTMLDivElement>(null);
|
||
const folio = useRef<HTMLDivElement>(null);
|
||
const [origin, setOrigin] = useState<number | null>(null);
|
||
const [handoffLength, setHandoffLength] = useState(HANDOFF);
|
||
const hasPhoto = Boolean(photo);
|
||
|
||
/* Handoff x → folio rule origin, and the exact gap from the mark's lowest
|
||
point down to that rule, so the neck stroke actually reaches the hairline
|
||
instead of ending as a stub ~25 px above it (desktop only; on mobile the
|
||
mark sits above the H1). */
|
||
useLayoutEffect(() => {
|
||
let pts: { x: number; y: number }[] | null = null;
|
||
const measure = () => {
|
||
const m = markWrap.current;
|
||
const f = folio.current;
|
||
if (!m || !f || !window.matchMedia("(min-width: 1024px)").matches) return;
|
||
const x = measureHandoffX(m, f);
|
||
if (x !== null && x > 0) setOrigin(Math.round(x));
|
||
if (!pts) pts = sampleMarkPoints(m);
|
||
const bottom = markBottomY(m, pts);
|
||
if (bottom !== null) {
|
||
const gap = f.getBoundingClientRect().top - bottom;
|
||
setHandoffLength(Math.round(Math.min(72, Math.max(16, gap))));
|
||
}
|
||
};
|
||
measure();
|
||
/* One measurement per frame: a window drag fires `resize` dozens of times a second
|
||
and each pass forces layout for the whole monogram. */
|
||
let raf = 0;
|
||
const onResize = () => {
|
||
if (raf) return;
|
||
raf = requestAnimationFrame(() => {
|
||
raf = 0;
|
||
measure();
|
||
});
|
||
};
|
||
window.addEventListener("resize", onResize);
|
||
return () => {
|
||
window.removeEventListener("resize", onResize);
|
||
if (raf) cancelAnimationFrame(raf);
|
||
};
|
||
}, []);
|
||
|
||
/* Scroll: headline drifts up 6 % over the hero (desktop, no reduced motion). */
|
||
useLayoutEffect(() => {
|
||
const el = root.current;
|
||
const head = h1.current;
|
||
if (!el || !head) return;
|
||
const ctx = gsap.context(() => {
|
||
const mm = gsap.matchMedia();
|
||
mm.add({ desktop: MM.desktop, ok: MM.ok }, (c) => {
|
||
if (!c.conditions?.desktop || !c.conditions?.ok) return;
|
||
gsap.to(head, {
|
||
yPercent: -6,
|
||
ease: "none",
|
||
scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: 0.6 },
|
||
});
|
||
});
|
||
}, el);
|
||
return () => ctx.revert();
|
||
}, []);
|
||
|
||
const captionHasPlaceholder = splitPlaceholders(hero.caption).some((s) => s.placeholder);
|
||
const showCaption = !captionHasPlaceholder || flags.showPlaceholders;
|
||
|
||
return (
|
||
<Section
|
||
id={hero.id}
|
||
theme="dark"
|
||
className="grid min-h-svh pt-[calc(var(--header-h,7.5rem)+0.75rem)] pb-0 lg:pt-[calc(var(--header-h,7.5rem)+0.5rem)] [&>div]:grid"
|
||
>
|
||
{/* From lg the FOURTH row (subline + CTA) is the flexible one, not the second:
|
||
the slack has to sit between the CTA block and the folio, otherwise a
|
||
shorter CTA row pushes the headline down into the monogram. */}
|
||
<div
|
||
ref={root}
|
||
className="col-span-12 grid grid-cols-12 grid-rows-[auto_auto_auto_auto_auto_auto] gap-x-[clamp(16px,2vw,32px)] lg:grid-rows-[auto_auto_auto_1fr_auto]"
|
||
>
|
||
|
||
{/* Portrait – variant A. Desktop: cols 8–12, rows 2–4, bleeds to the page edge. */}
|
||
{photo && (
|
||
<div className="relative z-0 col-span-12 row-start-5 mt-10 lg:col-span-5 lg:col-start-8 lg:row-start-2 lg:row-end-5 lg:mt-0 lg:-mr-[clamp(20px,5vw,80px)]">
|
||
<div className="relative h-full">
|
||
<Door
|
||
src={photo}
|
||
alt={hero.alt}
|
||
width={1600}
|
||
height={2000}
|
||
sizes="(min-width: 1024px) 45vw, 100vw"
|
||
priority
|
||
parallax
|
||
play="load"
|
||
delay={T.door}
|
||
className="max-h-[48svh] w-full lg:h-full lg:max-h-none"
|
||
imgClassName="[filter:sepia(.12)_saturate(.9)_contrast(1.02)]"
|
||
/>
|
||
{/* Scrims: top third + left edge (headline legibility). */}
|
||
<div
|
||
aria-hidden="true"
|
||
className="pointer-events-none absolute inset-0 bg-[linear-gradient(var(--ink)_0%,transparent_45%)]"
|
||
/>
|
||
<div
|
||
aria-hidden="true"
|
||
className="pointer-events-none absolute inset-0 hidden bg-[linear-gradient(90deg,var(--ink)_0%,rgba(15,14,12,0.72)_30%,transparent_60%)] lg:block"
|
||
/>
|
||
{showCaption && (
|
||
<p className={cn(MONO_11, "absolute right-4 bottom-4 text-ivory/80")}>
|
||
<PlaceholderText text={hero.caption} />
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Monogram: bottom-aligned to the folio hairline; the neck hands off into it. */}
|
||
<div
|
||
ref={markWrap}
|
||
className={cn(
|
||
"relative z-[1] col-span-12 row-start-2 mt-6 h-[14svh] text-ivory lg:row-start-2 lg:row-end-5 lg:mt-0 lg:mb-4 lg:self-end",
|
||
"max-lg:[&_span[aria-hidden=true]]:hidden",
|
||
/* WolfMark's 7-unit stroke is ~18 px at hero size – far from the
|
||
spec's hairline mark. Overridden here, not in WolfMark.tsx. */
|
||
"[&_.wolfmark-v]:[stroke-width:3] [&_.wolfmark-wolf]:[stroke-width:2.6]",
|
||
hasPhoto
|
||
? "lg:col-span-3 lg:col-start-6 lg:h-[28vh] lg:justify-self-start"
|
||
: "lg:col-span-6 lg:col-start-7 lg:h-[min(45vh,24vw)] lg:justify-self-end",
|
||
)}
|
||
>
|
||
<DrawMark
|
||
play="load"
|
||
sheen
|
||
handoff
|
||
handoffLength={handoffLength}
|
||
delay={T.draw}
|
||
className="block h-full [&_svg]:h-full [&_svg]:w-auto"
|
||
>
|
||
{mark}
|
||
</DrawMark>
|
||
</div>
|
||
|
||
{/* H1 */}
|
||
<div
|
||
ref={h1}
|
||
className="relative z-[2] col-span-12 row-start-3 mt-6 lg:col-span-8 lg:col-start-1 lg:row-start-3 lg:mt-0 lg:self-end"
|
||
>
|
||
{/* The H1 is capped by viewport HEIGHT as well as width: `26vh - 108px`
|
||
is neutral at 1440×900 (the cover's reference frame) and pulls the
|
||
three Bodoni lines back on 800/768 px laptops, where the vw term
|
||
alone grows faster than the fold shrinks. */}
|
||
<Lines
|
||
as="h1"
|
||
play="load"
|
||
delay={T.lines}
|
||
stagger={0.09}
|
||
className="max-w-[12ch] font-display text-[clamp(3rem,min(1.4rem_+_7.4vw,26vh_-_108px),8rem)] font-normal leading-[0.94] tracking-[-0.015em] text-ivory [text-wrap:balance]"
|
||
>
|
||
{hero.h1.before}
|
||
<em className="italic">{hero.h1.em}</em>
|
||
{hero.h1.after}
|
||
</Lines>
|
||
</div>
|
||
|
||
{/* Subline + CTA. Vertical rhythm from lg is viewport-relative so the cover
|
||
keeps fitting `min-h-svh` at 768–900 px viewport heights; the row runs
|
||
cols 1–8 below 1440 so button + text link stay on one line there too. At
|
||
1440×900 – the cover's reference frame – the original 24/32 px rhythm is
|
||
restored, which also keeps the headline clear of the monogram. */}
|
||
<div className="relative z-[2] col-span-12 row-start-4 mt-6 pb-2 lg:col-span-6 lg:col-start-1 lg:row-start-4 lg:mt-[2.2vh] lg:pb-[3vh] wide:mt-6 wide:pb-8">
|
||
<Reveal play="load" delay={T.cta} as="p" className="max-w-[38ch] font-sans text-[clamp(1.125rem,min(1.1rem_+_0.6vw,3.1vh),1.5rem)] font-light leading-[1.45] text-fg-muted wide:max-w-[46ch]">
|
||
{hero.subline}
|
||
</Reveal>
|
||
<Reveal play="load" delay={T.cta + 0.1} className="mt-6 flex flex-wrap items-start gap-x-8 gap-y-4 lg:mt-[2.2vh] wide:mt-6">
|
||
<Magnet>
|
||
{/* The hover suffix („( 30 Min · kostenfrei )“) must NOT reserve its
|
||
width in flow – it added ~190 px of empty outline and pushed the
|
||
text link onto its own line. It grows out of the masked cell on
|
||
hover/focus instead (spec §6.1: button + text link, one row). */}
|
||
<Button
|
||
href={hero.cta.href}
|
||
label={hero.cta.label}
|
||
suffix={hero.cta.suffix}
|
||
className="[&>span[aria-hidden]]:max-w-0 [&>span[aria-hidden]]:transition-[max-width] [&>span[aria-hidden]]:duration-[350ms] [&>span[aria-hidden]]:ease-[var(--ease-out-quart)] hover:[&>span[aria-hidden]]:max-w-[16rem] focus-visible:[&>span[aria-hidden]]:max-w-[16rem] motion-reduce:[&>span[aria-hidden]]:transition-none"
|
||
/>
|
||
</Magnet>
|
||
</Reveal>
|
||
</div>
|
||
|
||
{/* Folio: hairline draws outward from the handoff x, „01 / 06“ · „Positionierung ↓“.
|
||
Hidden below lg – the folio row costs 69 px that the phone cover does not have,
|
||
and Positionierung directly below carries its own „01 / 06“ mobile counter. */}
|
||
<div ref={folio} className="col-span-12 row-start-6 mt-6 hidden lg:block lg:row-start-5 lg:mt-0">
|
||
<Folio
|
||
current={hero.folio.current}
|
||
total={hero.folio.total}
|
||
next={{ label: hero.folio.next.label, href: `/${hero.folio.next.href}` }}
|
||
origin={origin ?? "left"}
|
||
play="load"
|
||
delay={T.folio}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
export default Hero;
|