mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
281 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|