since3-portal/modules/templates/catalog/blocks/start.tsx
2026-10-11 18:22:32 +02:00

496 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"use client"
import {
motion,
useAnimationFrame,
useInView,
useMotionTemplate,
useMotionValue,
useReducedMotionConfig,
useScroll,
useSpring,
useTransform,
type MotionValue,
} from "motion/react"
import { useEffect, useRef } from "react"
import { cn } from "@/lib/utils"
import { splitList } from "../shared/hooks"
import { TImage } from "../shared/media"
import { ease, wordmarkSize } from "../shared/motion"
import type { HeroAuroraData, HeroEditorialData, HeroKinetikData, HeroKinoData, HeroVollbildData } from "./definitions"
import type { BlockProps } from "./types"
import { Action, container, easeInOut, Eyebrow, Heading, LineLink, linkProps, Magnetic, RevealImage } from "./ui"
// Hervorhebung auf Bildern: Akzent mit Weiß gemischt, damit sie auch bei dunklen Akzenten lesbar bleibt
const emOnImage = "italic text-[color-mix(in_oklab,var(--t-accent)_55%,white)]"
// ── Kino-Fenster ─────────────────────────────────────────────────────────────
// Ein Bildfenster unter der Überschrift öffnet sich beim Scrollen zum Vollbild (Sektion bleibt stehen)
export function HeroKino({ data }: BlockProps<HeroKinoData>) {
const ref = useRef<HTMLElement>(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] })
const progress = useSpring(scrollYProgress, { stiffness: 140, damping: 30, mass: 0.35 })
const inset = useTransform(progress, [0, 0.72], [1, 0])
// Abstände des Fensters als CSS-Variablen (mobil/desktop), skaliert mit dem Fortschritt
const clipPath = useMotionTemplate`inset(calc(var(--it) * ${inset}) calc(var(--ix) * ${inset}) calc(var(--ib) * ${inset}) calc(var(--ix) * ${inset}) round calc(1.75rem * ${inset}))`
const imageScale = useTransform(progress, [0, 0.72], [1.3, 1])
// Kameraschwenk: das kleine Fenster zeigt zuerst den oberen Bildteil, beim Öffnen gleitet das Bild zur Mitte
const imageY = useTransform(progress, [0, 0.72], ["24%", "0%"])
const titleY = useTransform(progress, [0, 0.4], ["0%", "-40%"])
const titleOpacity = useTransform(progress, [0.04, 0.36], [1, 0])
const shade = useTransform(progress, [0.45, 0.8], [0, 1])
const outroOpacity = useTransform(progress, [0.7, 0.88], [0, 1])
const outroY = useTransform(progress, [0.7, 0.88], [48, 0])
const outroEvents = useTransform(progress, (value) => (value > 0.75 ? "auto" : "none"))
const cue = useTransform(progress, [0, 0.06], [1, 0])
return (
<section ref={ref} className="relative h-[250vh]">
<div className="sticky top-0 h-svh overflow-hidden">
<motion.div
data-surface="dark"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1.2, delay: 0.3 }}
style={{ clipPath }}
className="absolute inset-0 bg-black [--ib:7%] [--it:47%] [--ix:5%] md:[--ib:6%] md:[--it:45%] md:[--ix:27%]"
>
<motion.div style={{ y: imageY, scale: imageScale }} className="absolute inset-0">
<TImage src={data.image} eager sizes="100vw" />
</motion.div>
<motion.div style={{ opacity: shade }} className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/35 to-black/10" />
</motion.div>
<motion.div
style={{ y: titleY, opacity: titleOpacity }}
className={cn(container, "relative flex h-[45%] flex-col items-center justify-end pb-7 text-center md:pb-9")}
>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 1, delay: 0.2 }}>
<Eyebrow className="justify-center">{data.eyebrow}</Eyebrow>
</motion.div>
<Heading as="h1" onMount delay={0.25} text={data.title} className="mt-5 text-[clamp(3rem,8.4vw,9.25rem)] leading-[0.88] tracking-[-0.04em]" />
</motion.div>
<motion.div
style={{ opacity: outroOpacity, y: outroY, pointerEvents: outroEvents }}
className={cn(container, "absolute inset-x-0 bottom-0 flex flex-col gap-8 pb-12 text-white md:flex-row md:items-end md:justify-between md:pb-16")}
>
<p className="max-w-2xl font-t-display text-[clamp(1.6rem,3vw,2.75rem)] leading-[1.12] tracking-[-0.02em]">{data.text}</p>
<Action href={data.href} variant="light" className="self-start md:self-auto">
{data.cta}
</Action>
</motion.div>
<motion.div style={{ opacity: cue }} aria-hidden className="absolute right-5 bottom-[calc(7%+1.25rem)] flex items-center gap-3 text-[10px] tracking-[0.3em] text-white/80 uppercase md:right-[calc(27%+1.5rem)] md:bottom-[calc(6%+1.5rem)]">
Scrollen
<span className="relative h-8 w-px overflow-hidden bg-white/25">
<motion.span
className="absolute inset-x-0 top-0 h-1/2 bg-white"
animate={{ y: ["-100%", "200%"] }}
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
/>
</span>
</motion.div>
</div>
</section>
)
}
// ── Vollbild ─────────────────────────────────────────────────────────────────
export function HeroVollbild({ data }: BlockProps<HeroVollbildData>) {
const ref = useRef<HTMLElement>(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end start"] })
const y = useTransform(scrollYProgress, [0, 1], ["0%", "18%"])
const fade = useTransform(scrollYProgress, [0, 0.55], [1, 0])
return (
<section ref={ref} data-surface="dark" className="relative h-svh min-h-[600px] overflow-hidden bg-black text-white">
<motion.div style={{ y }} className="absolute inset-0">
<motion.div initial={{ scale: 1.18 }} animate={{ scale: 1 }} transition={{ duration: 2.6, ease: [0.16, 1, 0.3, 1] }} className="absolute inset-0">
<TImage src={data.image} eager sizes="100vw" />
</motion.div>
</motion.div>
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-black/30" />
<motion.div style={{ opacity: fade }} className={cn(container, "relative flex h-full flex-col justify-end pb-10 md:pb-14")}>
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.9, ease, delay: 0.3 }}>
<Eyebrow className="text-white/75">{data.eyebrow}</Eyebrow>
</motion.div>
<Heading
as="h1"
onMount
delay={0.4}
text={data.title}
emClassName={emOnImage}
className="mt-6 text-[clamp(3.25rem,8.8vw,10rem)] leading-[0.9] tracking-[-0.04em]"
/>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, ease, delay: 0.9 }}
className="mt-10 flex flex-col gap-8 border-t border-white/20 pt-8 md:flex-row md:items-end md:justify-between"
>
<p className="max-w-md text-lg leading-relaxed text-white/80">{data.text}</p>
<div className="flex flex-wrap gap-3">
<Action href={data.href}>{data.cta}</Action>
<Action href={data.hrefSecondary} variant="light">
{data.ctaSecondary}
</Action>
</div>
</motion.div>
</motion.div>
</section>
)
}
// ── Aurora ───────────────────────────────────────────────────────────────────
const auroraBlobs = [
{ style: { top: "-25%", left: "-10%", width: "60vw", height: "60vw", background: "radial-gradient(closest-side, var(--t-accent), transparent)" }, x: ["0%", "14%"], y: ["0%", "8%"], scale: [1, 1.15], duration: 18 },
{ style: { top: "-15%", right: "-15%", width: "55vw", height: "55vw", background: "radial-gradient(closest-side, color-mix(in oklab, var(--t-accent) 50%, #22d3ee), transparent)" }, x: ["0%", "-12%"], y: ["0%", "14%"], scale: [1.1, 0.95], duration: 22 },
{ style: { bottom: "-35%", left: "18%", width: "65vw", height: "65vw", background: "radial-gradient(closest-side, color-mix(in oklab, var(--t-accent) 45%, #f472b6), transparent)" }, x: ["0%", "-8%"], y: ["0%", "-12%"], scale: [0.95, 1.12], duration: 26 },
]
// Langsam ziehende Farbwolken in Akzent-Tönen über einem feinen Raster; auch für andere Bausteine nutzbar
export function AuroraGlow({ className }: { className?: string }) {
return (
<div aria-hidden className={cn("pointer-events-none absolute inset-0 overflow-hidden", className)}>
{auroraBlobs.map((blob, i) => (
<motion.div
key={i}
className="absolute rounded-full opacity-45"
style={blob.style}
animate={{ x: blob.x, y: blob.y, scale: blob.scale }}
transition={{ duration: blob.duration, repeat: Infinity, repeatType: "mirror", ease: "easeInOut" }}
/>
))}
<div className="absolute inset-0 [background-image:linear-gradient(to_right,color-mix(in_oklab,var(--t-fg)_7%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_oklab,var(--t-fg)_7%,transparent)_1px,transparent_1px)] [background-size:64px_64px] [mask-image:radial-gradient(ellipse_at_50%_35%,black_15%,transparent_70%)]" />
</div>
)
}
export function HeroAurora({ data }: BlockProps<HeroAuroraData>) {
const keywords = splitList(data.keywords).slice(0, 8)
return (
<section className="relative overflow-hidden pt-32 pb-12 md:pt-44 md:pb-20">
<AuroraGlow />
<div className={cn(container, "relative flex flex-col items-center text-center")}>
{data.badge.trim() && (
<motion.p
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease, delay: 0.1 }}
className="relative inline-flex items-center gap-2.5 overflow-hidden rounded-full border border-t-fg/15 bg-t-bg/40 px-4 py-1.5 text-xs font-medium backdrop-blur-md"
>
<span aria-hidden className="relative flex size-2">
<span className="absolute inset-0 animate-ping rounded-full bg-t-accent opacity-60" />
<span className="relative size-2 rounded-full bg-t-accent" />
</span>
{data.badge}
<motion.span
aria-hidden
className="absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-t-fg/15 to-transparent"
initial={{ x: "-120%" }}
animate={{ x: "320%" }}
transition={{ duration: 2.4, repeat: Infinity, repeatDelay: 2.5, ease: "easeInOut" }}
/>
</motion.p>
)}
<Heading
as="h1"
onMount
delay={0.2}
text={data.title}
emClassName="bg-gradient-to-r from-t-accent via-[color-mix(in_oklab,var(--t-accent)_50%,#22d3ee)] to-[color-mix(in_oklab,var(--t-accent)_45%,#f472b6)] bg-clip-text text-transparent [-webkit-background-clip:text]"
className="mt-7 max-w-5xl text-[clamp(2.9rem,7vw,7.25rem)] leading-[0.98] tracking-[-0.045em]"
/>
<motion.p
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, ease, delay: 0.6 }}
className="mt-7 max-w-xl text-lg leading-relaxed text-t-muted md:text-xl"
>
{data.text}
</motion.p>
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, ease, delay: 0.75 }}
className="mt-9 flex flex-wrap justify-center gap-3"
>
<Magnetic>
<Action href={data.href}>{data.cta}</Action>
</Magnetic>
<Action href={data.hrefSecondary} variant="outline" className="bg-t-bg/30 backdrop-blur-md">
{data.ctaSecondary}
</Action>
</motion.div>
{keywords.length > 0 && <Orbit words={keywords} />}
</div>
</section>
)
}
// Lichtkugel mit Schlagworten auf einer Ellipsen-Bahn; hinten kleiner und blasser, vorne vor der Kugel
function Orbit({ words }: { words: string[] }) {
const ref = useRef<HTMLDivElement>(null)
const angle = useMotionValue(0)
const width = useMotionValue(0)
const height = useMotionValue(0)
const reduce = useReducedMotionConfig()
const visible = useInView(ref)
useEffect(() => {
const element = ref.current
if (!element) return
const observer = new ResizeObserver(([entry]) => {
width.set(entry.contentRect.width)
height.set(entry.contentRect.height)
})
observer.observe(element)
return () => observer.disconnect()
}, [width, height])
useAnimationFrame((_, delta) => {
if (reduce || !visible) return
angle.set(angle.get() + delta * 0.008)
})
return (
<motion.div
ref={ref}
initial={{ opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 1.4, ease, delay: 0.5 }}
className="relative mt-12 aspect-[4/3] w-full max-w-5xl sm:aspect-[2/1] md:mt-16"
>
<div aria-hidden className="absolute inset-x-[3%] inset-y-[12%] rounded-[50%] border border-t-fg/10" />
<div aria-hidden className="absolute inset-x-[16%] inset-y-[4%] rounded-[50%] border border-dashed border-t-fg/[0.07]" />
<div aria-hidden className="absolute top-1/2 left-1/2 z-[2] aspect-square w-[34%] -translate-x-1/2 -translate-y-1/2 sm:w-[28%]">
<div className="absolute inset-[-40%] rounded-full opacity-60 blur-2xl" style={{ background: "radial-gradient(closest-side, var(--t-accent), transparent)" }} />
<motion.div
className="absolute inset-0 rounded-full"
style={{
background: "radial-gradient(circle at 32% 28%, color-mix(in oklab, white 80%, var(--t-accent)) 0%, var(--t-accent) 38%, color-mix(in oklab, var(--t-accent) 35%, black) 80%)",
boxShadow: "inset -18px -24px 60px rgba(0,0,0,0.35), 0 30px 90px -20px var(--t-accent)",
}}
animate={{ y: [0, -10] }}
transition={{ duration: 3.5, repeat: Infinity, repeatType: "mirror", ease: "easeInOut" }}
/>
</div>
{words.map((word, i) => (
<OrbitChip key={`${word}-${i}`} word={word} angle={angle} width={width} height={height} offset={(360 / words.length) * i} />
))}
</motion.div>
)
}
function OrbitChip({ word, angle, width, height, offset }: {
word: string
angle: MotionValue<number>
width: MotionValue<number>
height: MotionValue<number>
offset: number
}) {
const theta = useTransform(angle, (value) => ((value + offset) * Math.PI) / 180)
const x = useTransform(() => Math.cos(theta.get()) * width.get() * 0.45)
const y = useTransform(() => Math.sin(theta.get()) * height.get() * 0.38)
// 0 = hinten (oben auf der Bahn), 1 = vorne (unten). Gerundet, weil der Server Stilwerte mit weniger
// Nachkommastellen ausgibt – sonst meldet React beim Hydrieren abweichende Attribute.
const depth = useTransform(theta, (value) => (Math.sin(value) + 1) / 2)
const scale = useTransform(depth, (value) => Math.round((0.78 + value * 0.28) * 1000) / 1000)
const opacity = useTransform(depth, (value) => Math.round((0.45 + value * 0.55) * 1000) / 1000)
const zIndex = useTransform(depth, (value) => (value > 0.5 ? 3 : 1))
return (
<motion.span
style={{ x, y, scale, opacity, zIndex }}
className="absolute top-1/2 left-1/2 inline-flex -translate-x-1/2 -translate-y-1/2 items-center gap-2 rounded-full border border-t-fg/15 bg-t-surface/85 px-3.5 py-1.5 text-xs font-medium whitespace-nowrap shadow-[0_10px_30px_-14px_rgba(0,0,0,0.5)] sm:px-4 sm:py-2 sm:text-sm"
>
<span aria-hidden className="size-1.5 rounded-full bg-t-accent" />
{word}
</motion.span>
)
}
// ── Editorial ────────────────────────────────────────────────────────────────
export function HeroEditorial({ data, brand }: BlockProps<HeroEditorialData>) {
const ref = useRef<HTMLElement>(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end start"] })
const imageY = useTransform(scrollYProgress, [0, 1], ["0%", "10%"])
return (
<section ref={ref} className={cn(container, "relative grid gap-10 pt-24 pb-16 md:grid-cols-12 md:gap-6 md:pt-32 md:pb-24")}>
{brand.tagline.trim() && (
<p aria-hidden className="absolute top-32 left-0 hidden rotate-180 text-[10px] tracking-[0.3em] text-t-muted uppercase [writing-mode:vertical-rl] xl:block">
{brand.tagline}
</p>
)}
<div className="flex flex-col md:col-span-7 md:pr-6">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 1, delay: 0.1 }}>
<Eyebrow>{data.eyebrow}</Eyebrow>
</motion.div>
<Heading as="h1" onMount delay={0.15} text={data.title} className="mt-8 text-[clamp(3.25rem,7.8vw,8.75rem)] leading-[0.9] tracking-[-0.035em]" />
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, ease, delay: 0.8 }}
className="mt-auto grid gap-6 pt-12 sm:grid-cols-[1fr_auto] sm:items-end"
>
<p className="max-w-sm text-lg leading-relaxed text-t-muted">{data.text}</p>
<LineLink href={data.href}>{data.cta}</LineLink>
</motion.div>
</div>
<figure className="md:col-span-5">
<div className="relative aspect-[4/5] overflow-hidden bg-t-surface">
<motion.div style={{ y: imageY }} className="absolute inset-x-0 -top-[6%] -bottom-[6%]">
<motion.div initial={{ scale: 1.25 }} animate={{ scale: 1 }} transition={{ duration: 2, ease }} className="absolute inset-0">
<TImage src={data.image} alt={data.caption} eager sizes="(min-width: 768px) 42vw, 100vw" />
</motion.div>
</motion.div>
{/* Jalousien: fünf Streifen in Hintergrundfarbe ziehen nacheinander nach oben weg */}
{[0, 1, 2, 3, 4].map((i) => (
<motion.span
key={i}
aria-hidden
className="absolute inset-y-0 origin-top bg-t-bg"
style={{ left: `${i * 20}%`, width: "20.5%" }}
initial={{ scaleY: 1 }}
animate={{ scaleY: 0 }}
transition={{ duration: 1.1, ease: easeInOut, delay: 0.3 + i * 0.09 }}
/>
))}
</div>
{data.caption.trim() && (
<figcaption className="mt-3 flex justify-between gap-4 text-xs text-t-muted">
<span>{data.caption}</span>
<span>Nº 01</span>
</figcaption>
)}
</figure>
</section>
)
}
// ── Kinetik ──────────────────────────────────────────────────────────────────
const REST = `"wght" 250, "wdth" 75`
// Buchstaben werden zur Maus hin fetter und breiter (variable Schrift mit wght/wdth-Achse, z. B. Mona Sans).
// Ohne Maus wandert ein gedachter Punkt langsam über das Wort.
function PressureWord({ text, className }: { text: string; className?: string }) {
const ref = useRef<HTMLHeadingElement>(null)
const letters = useRef<(HTMLSpanElement | null)[]>([])
const pointer = useRef({ x: -1e4, y: -1e4, targetX: -1e4, targetY: -1e4, active: false })
const reduce = useReducedMotionConfig()
const visible = useInView(ref)
useEffect(() => {
const move = (event: PointerEvent) => {
if (event.pointerType !== "mouse") return
Object.assign(pointer.current, { targetX: event.clientX, targetY: event.clientY, active: true })
}
const leave = () => {
pointer.current.active = false
}
window.addEventListener("pointermove", move)
document.documentElement.addEventListener("pointerleave", leave)
return () => {
window.removeEventListener("pointermove", move)
document.documentElement.removeEventListener("pointerleave", leave)
}
}, [])
useAnimationFrame((time) => {
const element = ref.current
if (!element || !visible) return
const rect = element.getBoundingClientRect()
const p = pointer.current
if (!p.active) {
p.targetX = reduce ? -1e4 : rect.left + rect.width * (0.5 + 0.48 * Math.sin(time / 2200))
p.targetY = rect.top + rect.height / 2
}
p.x += (p.targetX - p.x) * 0.12
p.y += (p.targetY - p.y) * 0.12
const reach = Math.max(rect.width / 2.6, 180)
const boxes = letters.current.map((letter) => letter?.getBoundingClientRect())
boxes.forEach((box, i) => {
const letter = letters.current[i]
if (!box || !letter) return
const distance = Math.hypot(p.x - (box.left + box.width / 2), p.y - (box.top + box.height / 2))
const t = Math.max(0, 1 - distance / reach)
const eased = t * t * (3 - 2 * t)
letter.style.fontVariationSettings = `"wght" ${Math.round(250 + eased * 650)}, "wdth" ${Math.round(75 + eased * 50)}`
})
})
return (
<h1
ref={ref}
aria-label={text}
className={cn("text-center font-t-display leading-[0.82] tracking-[-0.04em] whitespace-nowrap select-none", className)}
style={{ fontSize: wordmarkSize(text, 24) }}
>
{Array.from(text).map((char, i) => (
<span
key={i}
aria-hidden
ref={(node) => {
letters.current[i] = node
}}
style={{ fontVariationSettings: REST }}
>
{char === " " ? " " : char}
</span>
))}
</h1>
)
}
export function HeroKinetik({ data, brand }: BlockProps<HeroKinetikData>) {
const word = data.word.trim() || brand.name
return (
<section className="relative overflow-hidden pt-24 md:pt-28">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1, delay: 0.2 }}
className={cn(container, "flex items-center justify-between gap-6 text-[11px] tracking-[0.25em] text-t-muted uppercase")}
>
<span>{data.eyebrow}</span>
<span className="hidden sm:inline">Scrollen ↓</span>
</motion.div>
<motion.div initial={{ opacity: 0, y: 40 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 1.2, ease, delay: 0.1 }}>
<PressureWord text={word} className="mt-4 md:mt-2" />
</motion.div>
<div className={cn(container, "mt-6 grid gap-8 border-t border-t-fg/15 py-10 md:mt-8 md:grid-cols-12 md:gap-6 md:py-12")}>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, ease, delay: 0.5 }}
className="font-t-display text-[clamp(1.4rem,2.4vw,2.25rem)] leading-[1.15] tracking-[-0.02em] md:col-span-6"
>
{data.text}
</motion.p>
{data.cta.trim() && (
<div className="flex items-end md:col-span-2 md:col-start-8">
<Magnetic strength={0.4}>
<a
{...linkProps(data.href)}
className="grid size-28 place-items-center rounded-full bg-t-accent p-4 text-center text-sm leading-tight font-medium text-t-accent-fg transition-[scale] duration-200 active:scale-95 md:size-32"
>
{data.cta} ↗
</a>
</Magnetic>
</div>
)}
<RevealImage src={data.image} sizes="(min-width: 768px) 33vw, 100vw" className="aspect-[4/3] rounded-[1rem] md:col-span-4 md:col-start-9" />
</div>
</section>
)
}