"use client"; /** * CASE CARD — distinct per case, photo-led with a tailored data viz. * - Each card leads with its real SECTOR PHOTO (next/image -> WebP/AVIF, lazy, * reserved aspect-ratio so no CLS). The image scales + parallax-drifts toward * the pointer on hover. * - Below the photo, a data visual UNIQUE to the case story: * dual -> CPA-down + ROAS-up twin lines (fashion) * ramp -> a slow-then-steep demo-requests line (SaaS) * bookings -> monthly booking bars climbing toward a capacity target (clinic) * The viz animates in on view (GSAP DrawSVG / CSS bar grow); no two alike. * - 3D tilt that tracks the pointer via a soft spring; lift + accent shadow on * hover; press dip. Transform-only. * Reduced-motion / touch: flat card, static image, viz renders fully drawn. */ import { useEffect, useRef } from "react"; import Image from "next/image"; import { motion, useMotionValue, useSpring, useTransform, useReducedMotion, } from "motion/react"; import { gsap } from "./gsap"; import { SPRING, EASE_OUT } from "./motion"; export type CaseData = { tag: string; image: string; alt: string; problem: string; result: string; how: string; metricNum: string; metricLabel: string; viz: "dual" | "ramp" | "bookings"; series: number[]; series2?: number[]; target?: number; accent: string; }; /* ---- viz geometry helpers (0..100 series mapped into a 240x96 box) ---- */ const VW = 240; const VH = 96; const VP = 10; function toPoints(series: number[]) { const n = series.length; return series.map((v, i) => { const x = VP + (i / (n - 1)) * (VW - VP * 2); const y = VP + (1 - v / 100) * (VH - VP * 2); return { x, y }; }); } const poly = (series: number[]) => toPoints(series) .map((p) => `${p.x},${p.y}`) .join(" "); function CaseViz({ data }: { data: CaseData }) { const accent = data.accent; if (data.viz === "dual") { // CPA (falling, dashed) + ROAS (rising, solid) twin lines return ( CPA ↓ ROAS ↑ ); } if (data.viz === "bookings") { // monthly bookings bars climbing toward a capacity target line const n = data.series.length; const gap = 6; const bw = (VW - VP * 2 - gap * (n - 1)) / n; const ty = VP + (1 - (data.target ?? 90) / 100) * (VH - VP * 2); return ( {/* target / capacity line */} capacity {data.series.map((v, i) => { const h = (v / 100) * (VH - VP * 2); const x = VP + i * (bw + gap); const y = VH - VP - h; return ( ); })} ); } // ramp — slow-then-steep demo-requests line with an area fill const pts = toPoints(data.series); const linePts = poly(data.series); const area = `${linePts} ${VW - VP},${VH - VP} ${VP},${VH - VP}`; return ( ); } export default function CaseCard({ data, index, }: { data: CaseData; index: number; }) { const reduce = useReducedMotion(); const ref = useRef(null); const mx = useMotionValue(0.5); const my = useMotionValue(0.5); const rx = useSpring(useTransform(my, [0, 1], [5, -5]), SPRING.tilt); const ry = useSpring(useTransform(mx, [0, 1], [-6, 6]), SPRING.tilt); // image parallax — drifts opposite the tilt for depth const imgX = useSpring(useTransform(mx, [0, 1], [10, -10]), SPRING.tilt); const imgY = useSpring(useTransform(my, [0, 1], [10, -10]), SPRING.tilt); const onMove = (e: React.PointerEvent) => { if (reduce || e.pointerType !== "mouse") return; const el = ref.current; if (!el) return; const r = el.getBoundingClientRect(); mx.set((e.clientX - r.left) / r.width); my.set((e.clientY - r.top) / r.height); }; const reset = () => { mx.set(0.5); my.set(0.5); }; // animate the viz in on view (lines draw, bars grow) useEffect(() => { const el = ref.current; if (!el || reduce) return; const ctx = gsap.context(() => { const lines = el.querySelectorAll(".case__line, .case__area, .case__target"); gsap.from(lines, { drawSVG: "0%", duration: 1.1, ease: "power2.out", stagger: 0.12, scrollTrigger: { trigger: el, start: "top 82%", once: true }, delay: 0.15 + index * 0.05, }); gsap.from(el.querySelectorAll(".case__rampdot, .case__targetlab, .case__legend"), { autoAlpha: 0, duration: 0.6, ease: "power2.out", scrollTrigger: { trigger: el, start: "top 82%", once: true }, delay: 0.7 + index * 0.05, }); }, el); return () => ctx.revert(); }, [reduce, index]); return ( {/* sector photo — leads the card, parallax/scale on hover */} {data.tag} {String(index + 1).padStart(2, "0")} {/* per-case data visual */} Before {data.problem} {data.result} {data.how} {data.metricNum} {data.metricLabel} ); }
Before {data.problem}
{data.result}
{data.how}