285 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|