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

261 lines
15 KiB
TypeScript

"use client"
import { motion, useMotionTemplate, useMotionValue, useSpring } from "motion/react"
import { useRef, useState, type PointerEvent, type ReactNode } from "react"
import { ArrowUpRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { splitList } from "../shared/hooks"
import { TImage } from "../shared/media"
import { Reveal, ease } from "../shared/motion"
import type { LeistungenBentoData, LeistungenKartenData, LeistungenPanelsData } from "./definitions"
import type { BlockProps } from "./types"
import { container, Eyebrow, Heading } from "./ui"
const number = (i: number) => String(i + 1).padStart(2, "0")
// ── Panels ───────────────────────────────────────────────────────────────────
// Bildpaneele nebeneinander; das aktive zieht sich auf (Desktop). Mobil als Karten untereinander.
export function LeistungenPanels({ data }: BlockProps<LeistungenPanelsData>) {
const [active, setActive] = useState(0)
const current = Math.min(active, Math.max(0, data.items.length - 1))
return (
<section className={cn(container, "py-24 md:py-36")}>
<div className="max-w-3xl">
<Eyebrow>{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6" />
</div>
<div className="mt-12 hidden h-[72vh] max-h-[820px] min-h-[520px] gap-3 md:flex">
{data.items.map((item, i) => {
const open = i === current
return (
<article
key={i}
tabIndex={0}
onPointerEnter={() => setActive(i)}
onFocus={() => setActive(i)}
onClick={() => setActive(i)}
style={{ flexBasis: 0 }}
className={cn(
"relative min-w-0 cursor-pointer overflow-hidden rounded-[1.25rem] bg-black text-white outline-none focus-visible:ring-2 focus-visible:ring-t-accent",
"transition-[flex-grow] duration-700 ease-[cubic-bezier(0.77,0,0.175,1)]",
open ? "grow-[5]" : "grow"
)}
>
<TImage
src={item.image}
alt={item.title}
sizes="60vw"
className={cn("transition-transform duration-[1.4s] ease-out", open ? "scale-100" : "scale-110")}
/>
<div aria-hidden className={cn("absolute inset-0 bg-black transition-opacity duration-700", open ? "opacity-20" : "opacity-55")} />
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" />
<span className="absolute top-6 left-6 text-xs tracking-[0.2em] tabular-nums opacity-80">{number(i)}</span>
<span
aria-hidden
className={cn(
"absolute bottom-6 left-6 rotate-180 font-t-display text-2xl whitespace-nowrap transition-opacity duration-300 [writing-mode:vertical-rl]",
open ? "opacity-0" : "opacity-100 delay-300"
)}
>
{item.title}
</span>
<div
className={cn(
"absolute inset-x-0 bottom-0 p-8 transition-[opacity,translate] duration-500 lg:p-10",
open ? "translate-y-0 opacity-100 delay-300" : "pointer-events-none translate-y-6 opacity-0"
)}
>
<h3 className="font-t-display text-[clamp(2rem,3.4vw,3.5rem)] leading-none tracking-[-0.03em]">{item.title}</h3>
<p className="mt-4 max-w-md text-white/80">{item.text}</p>
</div>
</article>
)
})}
</div>
<div className="mt-10 grid gap-3 md:hidden">
{data.items.map((item, i) => (
<Reveal key={i}>
<article className="relative aspect-[4/5] overflow-hidden rounded-[1.25rem] bg-black text-white">
<TImage src={item.image} alt={item.title} sizes="100vw" />
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-6">
<span className="text-xs tracking-[0.2em] opacity-80">{number(i)}</span>
<h3 className="mt-2 font-t-display text-3xl tracking-[-0.02em]">{item.title}</h3>
<p className="mt-2 text-white/80">{item.text}</p>
</div>
</article>
</Reveal>
))}
</div>
</section>
)
}
// ── Bento ────────────────────────────────────────────────────────────────────
// Ein Lichtkegel folgt der Maus über alle Kacheln: Rand und Fläche leuchten in Akzentfarbe auf.
// Die Position steht als --x/--y direkt an jeder Kachel (nicht am Raster), damit nur die Kacheln neu zeichnen.
export function LeistungenBento({ data }: BlockProps<LeistungenBentoData>) {
const cards = useRef<(HTMLDivElement | null)[]>([])
function onMove(event: PointerEvent<HTMLDivElement>) {
for (const card of cards.current) {
if (!card) continue
const rect = card.getBoundingClientRect()
card.style.setProperty("--x", `${event.clientX - rect.left}px`)
card.style.setProperty("--y", `${event.clientY - rect.top}px`)
}
}
return (
<section className={cn(container, "py-24 md:py-36")}>
<div className="grid gap-6 md:grid-cols-12 md:items-end md:gap-6">
<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 onPointerMove={onMove} className="group/grid mt-12 grid gap-3 md:mt-16 md:auto-rows-[minmax(15rem,auto)] md:grid-cols-3">
{data.items.map((item, i) => {
const featured = i === 0
return (
<motion.div
key={i}
ref={(node) => {
cards.current[i] = node
}}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
transition={{ duration: 0.8, ease, delay: (i % 3) * 0.08 }}
className={cn(
"group/card relative rounded-[1.5rem] bg-t-fg/10 p-px",
"[background-image:radial-gradient(380px_circle_at_var(--x,-999px)_var(--y,-999px),var(--t-accent),transparent_45%)]",
featured && "md:col-span-2 md:row-span-2"
)}
>
<div className="relative flex h-full flex-col overflow-hidden rounded-[calc(1.5rem-1px)] bg-t-bg p-7 md:p-8">
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 group-hover/grid:opacity-100 [background:radial-gradient(520px_circle_at_var(--x,-999px)_var(--y,-999px),color-mix(in_oklab,var(--t-accent)_14%,transparent),transparent_45%)]"
/>
{item.image.trim() && (
<div
className={cn(
"relative mb-7 overflow-hidden rounded-[1rem] bg-t-surface",
featured ? "aspect-[16/9] md:aspect-auto md:min-h-[14rem] md:flex-1" : "aspect-[16/10]"
)}
>
<TImage
src={item.image}
alt={item.title}
sizes={featured ? "(min-width: 768px) 60vw, 100vw" : "(min-width: 768px) 30vw, 100vw"}
className="transition-transform duration-700 ease-out group-hover/card:scale-[1.04]"
/>
</div>
)}
<div className="relative mt-auto">
<div className="flex items-center justify-between">
<span className="font-mono text-xs text-t-muted">{number(i)}</span>
<ArrowUpRightIcon className="size-4 text-t-muted transition-transform duration-300 group-hover/card:rotate-45 group-hover/card:text-t-accent" />
</div>
<h3 className={cn("mt-3 font-t-display tracking-[-0.02em]", featured ? "text-3xl md:text-5xl" : "text-2xl")}>{item.title}</h3>
<p className="mt-3 max-w-md leading-relaxed text-t-muted">{item.text}</p>
</div>
</div>
</motion.div>
)
})}
</div>
</section>
)
}
// ── 3D-Karten ────────────────────────────────────────────────────────────────
// Neigt sich zur Maus und wirft einen Glanz (nur mit Maus; auf Touch bleibt die Karte ruhig)
function TiltCard({ children }: { children: ReactNode }) {
const rotateX = useMotionValue(0)
const rotateY = useMotionValue(0)
const glareX = useMotionValue(50)
const glareY = useMotionValue(50)
const springX = useSpring(rotateX, { stiffness: 180, damping: 18 })
const springY = useSpring(rotateY, { stiffness: 180, damping: 18 })
const glare = useMotionTemplate`radial-gradient(circle at ${glareX}% ${glareY}%, color-mix(in oklab, var(--t-fg) 14%, transparent), transparent 55%)`
function onMove(event: PointerEvent<HTMLDivElement>) {
if (event.pointerType !== "mouse") return
const rect = event.currentTarget.getBoundingClientRect()
const px = (event.clientX - rect.left) / rect.width
const py = (event.clientY - rect.top) / rect.height
rotateY.set((px - 0.5) * 12)
rotateX.set((0.5 - py) * 12)
glareX.set(px * 100)
glareY.set(py * 100)
}
return (
<div className="h-full [perspective:1200px]">
<motion.div
onPointerMove={onMove}
onPointerLeave={() => {
rotateX.set(0)
rotateY.set(0)
}}
style={{ rotateX: springX, rotateY: springY }}
className="group relative h-full rounded-[1.75rem] border border-t-fg/10 bg-t-surface p-8 md:p-10"
>
<motion.div aria-hidden style={{ background: glare }} className="pointer-events-none absolute inset-0 rounded-[inherit] opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
<div className="relative">{children}</div>
</motion.div>
</div>
)
}
export function LeistungenKarten({ data }: BlockProps<LeistungenKartenData>) {
return (
<section className={cn(container, "py-24 md:py-36")}>
<div className="max-w-3xl">
<Eyebrow>{data.eyebrow}</Eyebrow>
<Heading text={data.title} className="mt-6" />
</div>
<div className="mt-12 grid gap-4 md:mt-16 md:grid-cols-2">
{data.items.map((item, i) => {
const features = splitList(item.features, "\n")
return (
<Reveal key={i} delay={(i % 2) * 0.08} className="h-full">
<TiltCard>
<div className="flex items-start justify-between gap-6">
<span className="font-t-display text-[5.5rem] leading-[0.8] tracking-[-0.05em] text-t-accent">{number(i)}</span>
<ArrowUpRightIcon className="size-6 text-t-muted transition-transform duration-500 group-hover:rotate-45" />
</div>
<h3 className="mt-10 font-t-display text-3xl tracking-[-0.02em] md:text-4xl">{item.title}</h3>
<p className="mt-3 max-w-sm text-t-muted">{item.text}</p>
{features.length > 0 && (
<ul className="mt-8 grid gap-2.5 border-t border-t-fg/10 pt-6 text-sm">
{features.map((feature, j) => (
<li key={j} className="flex items-center gap-3">
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-t-accent" />
{feature}
</li>
))}
</ul>
)}
</TiltCard>
</Reveal>
)
})}
</div>
</section>
)
}