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

281 lines
15 KiB
TypeScript

"use client"
import { animate, AnimatePresence, motion, useAnimationFrame, useInView, useMotionValue, useReducedMotionConfig, wrap } from "motion/react"
import { useEffect, useRef, useState } from "react"
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { TImage } from "../shared/media"
import { Counter, Marquee, Reveal, ease } from "../shared/motion"
import type { LogosData, StimmenWandData, StimmenZitatData, TeamData, ZahlenData } from "./definitions"
import type { BlockProps } from "./types"
import { container, easeInOut, Eyebrow, Heading, InkImage } from "./ui"
// ── Kennzahlen ───────────────────────────────────────────────────────────────
export function Zahlen({ data }: BlockProps<ZahlenData>) {
const header = data.eyebrow.trim() || data.title.trim()
return (
<section className={cn(container, "py-20 md:py-28")}>
{header && (
<div className="mb-14 grid gap-6 md:mb-20 md:grid-cols-12">
<div className="md:col-span-3">
<Eyebrow>{data.eyebrow}</Eyebrow>
</div>
<Heading text={data.title} className="text-[clamp(2.25rem,4.5vw,4.5rem)] md:col-span-9" />
</div>
)}
<div className="grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-4">
{data.items.map((item, i) => (
<div key={i}>
<motion.span
aria-hidden
className="block h-px origin-left bg-t-fg/20"
initial={{ scaleX: 0 }}
whileInView={{ scaleX: 1 }}
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
transition={{ duration: 1.2, ease: easeInOut, delay: i * 0.1 }}
/>
<p className="mt-6 flex items-start gap-x-1.5 font-t-display text-[clamp(2.75rem,5.5vw,5.5rem)] leading-none tracking-[-0.04em] whitespace-nowrap">
<Counter value={item.value} />
{/* Zusatz (+, %, Mrd. €) klein und hochgestellt, damit lange Zusätze nicht umbrechen */}
{item.suffix.trim() && <span className="mt-[0.1em] text-[0.42em] tracking-normal text-t-accent">{item.suffix.trim()}</span>}
</p>
<p className="mt-3 text-sm text-t-muted">{item.label}</p>
</div>
))}
</div>
</section>
)
}
// ── Logo-Laufband ────────────────────────────────────────────────────────────
export function Logos({ data }: BlockProps<LogosData>) {
return (
<section className="py-14 md:py-20">
{data.title.trim() && <p className={cn(container, "mb-10 text-center text-sm text-t-muted")}>{data.title}</p>}
<div className="[mask-image:linear-gradient(90deg,transparent,black_12%,black_88%,transparent)]">
<Marquee duration={36}>
{data.items.map((item, i) =>
item.logo.trim() ? (
<span key={i} className="relative mx-8 block h-10 w-36 opacity-60 grayscale transition-opacity hover:opacity-100 md:mx-12">
<TImage src={item.logo} alt={item.name} sizes="144px" className="object-contain" />
</span>
) : (
<span key={i} className="mx-8 font-t-display text-2xl tracking-[-0.02em] whitespace-nowrap text-t-fg/45 md:mx-12 md:text-3xl">
{item.name}
</span>
)
)}
</Marquee>
</div>
</section>
)
}
// ── Großes Zitat ─────────────────────────────────────────────────────────────
const ROTATE_MS = 7000
// Wechselt alle 7 Sekunden; über dem Bereich hält es an. Die Leiste unter dem Zitat zeigt die Restzeit.
export function StimmenZitat({ data }: BlockProps<StimmenZitatData>) {
const [index, setIndex] = useState(0)
const [paused, setPaused] = useState(false)
const count = data.items.length
const current = count > 0 ? index % count : 0
useEffect(() => {
if (count < 2 || paused) return
const timer = setTimeout(() => setIndex((i) => (i + 1) % count), ROTATE_MS)
return () => clearTimeout(timer)
}, [current, count, paused])
if (count === 0) return null
const item = data.items[current]
return (
<section onPointerEnter={() => setPaused(true)} onPointerLeave={() => setPaused(false)} className={cn(container, "py-24 md:py-40")}>
<div className="mx-auto max-w-5xl text-center">
<Eyebrow className="justify-center">{data.eyebrow}</Eyebrow>
<span aria-hidden className="mt-10 block h-[0.5em] font-t-display text-[9rem] leading-none text-t-accent md:text-[12rem]">
“
</span>
<div className="grid min-h-[19rem] items-center md:min-h-[16rem]">
<AnimatePresence mode="wait" initial={false}>
<motion.figure
key={current}
initial={{ opacity: 0, y: 24, filter: "blur(8px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -16, filter: "blur(8px)" }}
transition={{ duration: 0.6, ease }}
>
<blockquote className="font-t-display text-[clamp(1.6rem,3.4vw,3rem)] leading-[1.18] tracking-[-0.015em] text-balance">{item.quote}</blockquote>
<figcaption className="mt-8 text-sm">
<span className="font-medium">{item.author}</span>
{item.role.trim() && <span className="text-t-muted"> · {item.role}</span>}
</figcaption>
</motion.figure>
</AnimatePresence>
</div>
{count > 1 && (
<div className="mt-12 flex items-center justify-center gap-4">
<button
type="button"
aria-label="Vorherige Stimme"
onClick={() => setIndex((current - 1 + count) % count)}
className="grid size-11 cursor-pointer place-items-center rounded-full border border-t-fg/15 transition-[border-color,scale] duration-200 hover:border-t-fg/50 active:scale-95"
>
<ArrowLeftIcon className="size-4" />
</button>
<div className="flex gap-2">
{data.items.map((_, i) => (
<button
key={i}
type="button"
aria-label={`Stimme ${i + 1}`}
aria-current={i === current}
onClick={() => setIndex(i)}
className="relative h-1 w-10 cursor-pointer overflow-hidden rounded-full bg-t-fg/15"
>
{i === current && (
<motion.span
key={`${current}-${paused}`}
className="absolute inset-0 origin-left bg-t-accent"
initial={{ scaleX: paused ? 1 : 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: paused ? 0 : ROTATE_MS / 1000, ease: "linear" }}
/>
)}
</button>
))}
</div>
<button
type="button"
aria-label="Nächste Stimme"
onClick={() => setIndex((current + 1) % count)}
className="grid size-11 cursor-pointer place-items-center rounded-full border border-t-fg/15 transition-[border-color,scale] duration-200 hover:border-t-fg/50 active:scale-95"
>
<ArrowRightIcon className="size-4" />
</button>
</div>
)}
</div>
</section>
)
}
// ── Stimmen-Wand ─────────────────────────────────────────────────────────────
type Testimonial = StimmenWandData["items"][number]
function TestimonialCard({ item, hidden }: { item: Testimonial; hidden: boolean }) {
return (
<figure aria-hidden={hidden || undefined} className="mb-4 rounded-[1.5rem] border border-t-fg/10 bg-t-fg/[0.03] p-6 md:p-7">
<blockquote className="leading-relaxed">„{item.quote}“</blockquote>
<figcaption className="mt-6 flex items-center gap-3">
{item.image.trim() && (
<span className="relative size-10 shrink-0 overflow-hidden rounded-full bg-t-surface">
<TImage src={item.image} sizes="40px" />
</span>
)}
<span className="text-sm">
<span className="block font-medium">{item.author}</span>
<span className="text-t-muted">{item.role}</span>
</span>
</figcaption>
</figure>
)
}
// Endlose Spalte: Inhalt doppelt, verschoben um die halbe Höhe. Beim Darüberfahren bremst sie weich ab.
function LoopColumn({ items, speed, reverse, className }: { items: Testimonial[]; speed: number; reverse?: boolean; className?: string }) {
const ref = useRef<HTMLDivElement>(null)
const inner = useRef<HTMLDivElement>(null)
const y = useMotionValue(0)
const rate = useMotionValue(1)
const visible = useInView(ref)
const reduce = useReducedMotionConfig()
useAnimationFrame((_, delta) => {
const element = inner.current
if (!element || !visible || reduce) return
const half = element.offsetHeight / 2
if (half <= 0) return
y.set(wrap(-half, 0, y.get() + (reverse ? 1 : -1) * speed * (delta / 1000) * rate.get()))
})
return (
<div
ref={ref}
onPointerEnter={() => animate(rate, 0, { duration: 0.6, ease: "easeOut" })}
onPointerLeave={() => animate(rate, 1, { duration: 0.8, ease: "easeInOut" })}
className={cn("h-full overflow-hidden", className)}
>
<motion.div ref={inner} style={{ y }}>
{[0, 1].map((copy) => items.map((item, i) => <TestimonialCard key={`${copy}-${i}`} item={item} hidden={copy === 1} />))}
</motion.div>
</div>
)
}
export function StimmenWand({ data }: BlockProps<StimmenWandData>) {
const split = (columns: number) => Array.from({ length: columns }, (_, c) => data.items.filter((_, i) => i % columns === c))
return (
<section className="overflow-hidden py-24 md:py-36">
<div className={cn(container, "max-w-3xl text-center")}>
<Eyebrow className="justify-center">{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6" />
</div>
<div className={cn(container, "mt-14 md:mt-16")}>
<div className="h-[620px] [mask-image:linear-gradient(transparent,black_14%,black_86%,transparent)]">
<LoopColumn items={data.items} speed={26} className="md:hidden" />
<div className="hidden h-full grid-cols-2 gap-4 md:grid lg:hidden">
{split(2).map((items, c) => (
<LoopColumn key={c} items={items} speed={26 + c * 6} reverse={c % 2 === 1} />
))}
</div>
<div className="hidden h-full grid-cols-3 gap-4 lg:grid">
{split(3).map((items, c) => (
<LoopColumn key={c} items={items} speed={24 + c * 5} reverse={c % 2 === 1} />
))}
</div>
</div>
</div>
</section>
)
}
// ── Team ─────────────────────────────────────────────────────────────────────
export function Team({ data }: BlockProps<TeamData>) {
return (
<section className={cn(container, "py-24 md:py-36")}>
<div className="grid gap-6 md:grid-cols-12 md:items-end">
<div className="md:col-span-7">
<Eyebrow>{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6" />
</div>
{data.text.trim() && (
<Reveal className="md:col-span-4 md:col-start-9">
<p className="text-lg leading-relaxed text-t-muted">{data.text}</p>
</Reveal>
)}
</div>
<div className="mt-12 grid grid-cols-2 gap-x-3 gap-y-10 md:mt-16 md:grid-cols-4 md:gap-x-5">
{data.members.map((member, i) => (
<Reveal key={i} delay={(i % 4) * 0.07} className={cn(i % 2 === 1 && "md:mt-16")}>
<figure>
<InkImage src={member.image} alt={member.name} sizes="(min-width: 768px) 25vw, 50vw" className="aspect-[3/4] rounded-[1rem]" />
<figcaption className="mt-4">
<p className="font-t-display text-xl tracking-[-0.01em] md:text-2xl">{member.name}</p>
<p className="mt-1 text-sm text-t-muted">{member.role}</p>
</figcaption>
</figure>
</Reveal>
))}
</div>
</section>
)
}