Files
Anelia-Wolf/src/components/services/ServiceHero.tsx
T
JUSNandClaude Opus 5 a37e48e4ef Messeseite: „EN“ führt zur englischen Messeseite, nicht zur Startseite
Auf her-career.anelia-wolf.com liegt die Seite unter „/“. usePathname()
meldet dort „/“, und counterpart() leitete daraus folgerichtig die
englische STARTSEITE ab – wer am Stand gescannt und dann auf „EN“ getippt
hat, landete auf anelia-wolf.com/en/. Der Umschalter fragt jetzt den
hreflang-Verweis im Kopf des Dokuments: Der hängt an der Seite, nicht an
der Adresse, unter der sie ausgeliefert wird.

Am selben Wurzelproblem: Der Sprachwechsel ist wegen der zwei
Wurzel-Layouts ohnehin ein neues Dokument. Den macht jetzt der Browser
statt router.push(), und über die Sprachgrenze wird nicht mehr vorgeladen
– auf der Subdomain scheiterte der Vorabruf an CORS, mehrfach pro
Seitenaufbau.

Dazu zwei Sachen am Kopf der Messeseite: Das Monogramm steht hinter dem
Schlusspunkt der Begrüßung, 0,783 em hoch (die Oberlänge der Playfair
Display, im Browser gemessen) und auf der Grundlinie – und dafür nicht
mehr in der Kopfzeile, wo es sonst zweimal auf einem Bildschirm stünde.
Es steht dort dekorativ, sonst wandert der Text seines <title> über
SplitText ins aria-label der Überschrift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 21:17:09 +02:00

189 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Container } from "@/components/layout/Container";
import { Folio } from "@/components/layout/Folio";
import { GRID } from "@/components/layout/Section";
import { Lines } from "@/components/motion/Lines";
import { Reveal } from "@/components/motion/Reveal";
import type { Cta, FactItem, Headline } from "@/content/home";
import { Button } from "@/components/ui/Button";
import { Facts } from "@/components/ui/Facts";
import { Portrait } from "@/components/ui/Portrait";
/** The slice of `coaching` / `trainings` / `keynotes` the hero needs (structural). */
export interface ServiceHeroContent {
/** „01“ / „02“ / „03“ – mono, cols 1–2. */
numeral: string;
masthead: { center: string };
eyebrow: string;
h1: Headline;
/** The italic word may also carry `.gold-text` (keynotes – the page's one gradient word). */
h1GoldEm?: boolean;
/** Fließtext unter der Headline. Ohne Angabe trägt die Headline den Hero allein. */
subline?: string;
/** Haupt-Knopf. Ohne Angabe trägt der Hero nur Headline und Subline. */
cta?: Cta;
/** Fakten-Stapel oben rechts. Ohne Angabe (und ohne Foto) entfällt die Spalte. */
facts?: readonly FactItem[];
/** No fraction here – the page's own section counter is the only counter (see <Folio/>). */
folio: { next?: { label: string; href: string } };
/** Photo alt / caption (keynote stage photo). */
alt?: string;
caption?: string;
}
export interface ServiceHeroProps {
content: ServiceHeroContent;
/** Stage photo (keynotes) – `null`/undefined = no photo. */
photo?: string | null;
/**
* Steht in derselben Spalte direkt unter Headline, Subline und Knopf.
* Auf der Messeseite hängt hier der Gutschein: Wer den Code gescannt hat,
* soll den Nachlass sehen, ohne erst an einer Sektionsüberschrift vorbei
* zu scrollen.
*/
children?: ReactNode;
/**
* Steht als letztes Zeichen in der H1, hinter dem Schlusspunkt. Auf der Messeseite
* hängt hier das Monogramm – die Begrüßung endet mit der Marke, zu der sie gehört.
*/
h1Mark?: ReactNode;
className?: string;
}
/**
* Service-page hero (ink, ≥ 60svh): mono numeral, H1 with one italic word,
* subline, the page's primary CTA, facts stack top-right, folio with the next
* section. Header monogram only – no hero draw.
*
* Ohne `subline` steht die Headline allein: sie erbt dann den Platz, den der
* Fließtext gehalten hat – eine Stufe größer und über die Spalten 3–11 statt
* 3–9. So trägt die Messeseite ihren Hero mit einem einzigen Satz.
*/
export function ServiceHero({ content, photo, children, h1Mark, className }: ServiceHeroProps) {
/* `masthead` und `eyebrow` stehen weiter in den Inhaltsdateien, werden aber seit
dem 06.09.2026 nicht mehr gerendert (Kundenwunsch): die Kopfzeile trug dieselben
Angaben doppelt, und über der H1 stand faktisch die Überschrift ein zweites Mal. */
const { numeral, masthead, h1, h1GoldEm, subline, cta, facts, folio, alt, caption } = content;
const hasPhoto = Boolean(photo);
/* Die Messeseite trägt weder Fakten noch Foto – dann gibt es die rechte Spalte
nicht, statt eine leere Reveal-Hülle in die Spalten 10–12 zu stellen. */
const hasAside = hasPhoto || Boolean(facts?.length);
/* Ohne Fließtext steht die Headline allein im Hero – dann bekommt sie dessen
Platz, statt dass der Hero um die fehlende Zeile zusammenfällt. Der Slot
darunter ändert daran nichts: Auf der Messeseite ist die große Begrüßung
ausdrücklich gewollt, der Gutschein hängt sich unter sie. */
const solo = !subline;
return (
<section
data-theme="dark"
className={cn(
"relative flex min-h-[60svh] flex-col bg-transparent pt-[calc(var(--header-h,7.5rem)+1.5rem)] text-fg",
className,
)}
>
<Container className="flex flex-1 flex-col">
<div className={cn(GRID, "flex-1 gap-y-12 py-14 lg:py-20")}>
<Reveal
play="load"
delay={0.3}
as="p"
className="hidden font-mono text-xs tracking-[0.06em] tabular-nums text-fg-muted lg:col-span-2 lg:block lg:pt-1"
>
{numeral}
</Reveal>
<div
className={cn(
"col-span-12 flex flex-col lg:col-start-3",
hasPhoto ? "lg:col-span-5" : solo ? "lg:col-span-9" : "lg:col-span-7",
)}
>
<Lines
as="h1"
play="load"
delay={0.35}
className={cn(
"max-w-[14ch] font-display font-normal leading-[0.96] tracking-[-0.015em] text-ivory [text-wrap:balance]",
solo ? "text-[clamp(3.25rem,1.6rem+7vw,9rem)]" : "text-[clamp(2.75rem,1.4rem+5.4vw,7rem)]",
)}
>
{h1.before}
{h1.em && <em className={cn("italic", h1GoldEm && "gold-text")}>{h1.em}</em>}
{h1.after}
{h1Mark && (
/* Auf der Grundlinie, hinter dem Punkt, 0,783 em hoch: Das ist die
Oberlänge der Playfair Display (im Browser an „h“ und „b“ gemessen;
die Versalhöhe liegt bei 0,719 em) – die Marke wird damit genau so
hoch, wie die Schrift dieser Zeile maximal wird, und keinen Punkt
höher. Die viewBox der Marke sitzt eng auf der Zeichnung, deshalb
ist Kastenhöhe gleich sichtbare Höhe.
Zwischen Punkt und Marke steht KEIN Leerzeichen im Markup – der
Abstand kommt aus `ml`, damit an dieser Stelle keine Umbruchstelle
entsteht und die Marke nicht allein in eine letzte Zeile rutscht.
Dekorativ (`aria-hidden`): Die Marke steht im Kopf schon mit Namen.
In der Überschrift stünde er ein zweites Mal – und zwar mitten im
Satz, den ein Screenreader vorliest. */
<span
aria-hidden="true"
className="ml-[0.22em] inline-block align-baseline [&_svg]:h-[0.783em] [&_svg]:w-auto"
>
{h1Mark}
</span>
)}
</Lines>
{subline && (
<Reveal play="load" delay={1.0} as="p" className="mt-10 max-w-[40ch] font-sans text-xl font-light leading-[1.45] text-fg-muted">
{subline}
</Reveal>
)}
{cta && (
<Reveal play="load" delay={1.2} className="mt-8">
<Button {...cta} />
</Reveal>
)}
{children && (
<Reveal play="load" delay={1.0} className="mt-12 lg:mt-14">
{children}
</Reveal>
)}
</div>
{hasAside && (
<div
className={cn(
"col-span-12 flex flex-col gap-10",
hasPhoto ? "lg:col-span-5 lg:col-start-8" : "lg:col-span-3 lg:col-start-10",
)}
>
{facts && facts.length > 0 && (
<Reveal play="load" delay={0.6} className={cn(hasPhoto && "lg:self-end")}>
<Facts items={facts} />
</Reveal>
)}
{photo && (
<Portrait
src={photo}
alt={alt ?? masthead.center}
aspect="16/10"
caption={caption}
priority
frame={false}
sizes="(min-width: 1024px) 40vw, 100vw"
/>
)}
</div>
)}
</div>
<Folio next={folio.next} play="load" delay={1.3} />
</Container>
</section>
);
}
export default ServiceHero;