agency-web/app/components/CaseCard.tsx

285 lines
8.9 KiB
TypeScript

"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 (
<svg className="case__chart" viewBox={`0 0 ${VW} ${VH}`} aria-hidden="true">
<polyline
className="case__line case__line--muted"
points={poly(data.series)}
fill="none"
stroke="#9a9aac"
strokeWidth="2"
strokeDasharray="4 4"
strokeLinecap="round"
/>
<polyline
className="case__line case__line--accent"
points={poly(data.series2 ?? data.series)}
fill="none"
stroke={accent}
strokeWidth="2.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<g className="case__legend" fill="currentColor">
<text x={VP} y={VH - 1}>CPA </text>
<text x={VW - VP} y={VH - 1} textAnchor="end" fill={accent}>ROAS </text>
</g>
</svg>
);
}
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 (
<svg className="case__chart" viewBox={`0 0 ${VW} ${VH}`} aria-hidden="true">
{/* target / capacity line */}
<line
className="case__target"
x1={VP}
y1={ty}
x2={VW - VP}
y2={ty}
stroke={accent}
strokeWidth="1.5"
strokeDasharray="3 4"
/>
<text className="case__targetlab" x={VW - VP} y={ty - 4} textAnchor="end" fill={accent}>
capacity
</text>
{data.series.map((v, i) => {
const h = (v / 100) * (VH - VP * 2);
const x = VP + i * (bw + gap);
const y = VH - VP - h;
return (
<rect
key={i}
className="case__bar2"
x={x}
y={y}
width={bw}
height={h}
rx={2}
fill={accent}
style={{ ["--d" as string]: `${i * 80}ms` }}
/>
);
})}
</svg>
);
}
// 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 (
<svg className="case__chart" viewBox={`0 0 ${VW} ${VH}`} aria-hidden="true">
<defs>
<linearGradient id={`rampFill-${accent.slice(1)}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={accent} stopOpacity="0.32" />
<stop offset="100%" stopColor={accent} stopOpacity="0" />
</linearGradient>
</defs>
<polygon className="case__area" points={area} fill={`url(#rampFill-${accent.slice(1)})`} />
<polyline
className="case__line case__line--accent"
points={linePts}
fill="none"
stroke={accent}
strokeWidth="2.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle className="case__rampdot" cx={pts[pts.length - 1].x} cy={pts[pts.length - 1].y} r="3.5" fill={accent} />
</svg>
);
}
export default function CaseCard({
data,
index,
}: {
data: CaseData;
index: number;
}) {
const reduce = useReducedMotion();
const ref = useRef<HTMLDivElement>(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 (
<motion.article
ref={ref}
className="case"
onPointerMove={onMove}
onPointerLeave={reset}
style={
reduce
? undefined
: { rotateX: rx, rotateY: ry, transformPerspective: 1100 }
}
initial={reduce ? { opacity: 1 } : { opacity: 0, y: 44, filter: "blur(4px)" }}
whileInView={{ opacity: 1, y: 0, filter: "blur(0px)" }}
whileTap={reduce ? undefined : { scale: 0.985 }}
viewport={{ once: true, margin: "0px 0px -12% 0px" }}
transition={{ duration: 0.85, delay: index * 0.08, ease: EASE_OUT }}
>
<div
className="case__inner"
style={{ ["--case-accent" as string]: data.accent }}
>
{/* sector photo — leads the card, parallax/scale on hover */}
<div className="case__photo">
<motion.div
className="case__photo-inner"
style={reduce ? undefined : { x: imgX, y: imgY }}
>
<Image
src={data.image}
alt={data.alt}
width={900}
height={672}
sizes="(max-width: 980px) 90vw, 30vw"
loading="lazy"
className="case__img"
/>
</motion.div>
<span className="case__photo-tag">{data.tag}</span>
<span className="case__photo-no" aria-hidden="true">
{String(index + 1).padStart(2, "0")}
</span>
</div>
{/* per-case data visual */}
<div className="case__viz" aria-hidden="true">
<CaseViz data={data} />
</div>
<div className="case__body">
<p className="case__problem">
<span className="case__k">Before</span>
{data.problem}
</p>
<p className="case__result display">{data.result}</p>
<p className="case__how">{data.how}</p>
</div>
<div className="case__metric">
<span className="case__metric-num display">{data.metricNum}</span>
<span className="case__metric-lab">{data.metricLabel}</span>
</div>
</div>
</motion.article>
);
}