agency-web/app/components/CaseCard.tsx

125 lines
4 KiB
TypeScript

"use client";
/**
* CASE CARD — replaces the rejected repetitive photos with a coded, animated
* data visual unique per case:
* - 3D tilt that tracks the pointer (Motion springs, transform-only).
* - An animated bars/sparkline "result chart" drawn in CSS + SVG, growing
* into view (no two cards look alike: different bars, colours, labels).
* - A glare/sheen that follows the cursor across the surface.
* Reduced-motion / touch: flat card, bars still grow on view via CSS.
*/
import { useRef } from "react";
import {
motion,
useMotionValue,
useSpring,
useTransform,
useReducedMotion,
} from "motion/react";
export type CaseData = {
tag: string;
problem: string;
result: string;
how: string;
metricNum: string;
metricLabel: string;
bars: number[]; // 0..100 heights — unique per case
accent: string; // brand accent for this card
};
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], [7, -7]), { stiffness: 200, damping: 20 });
const ry = useSpring(useTransform(mx, [0, 1], [-9, 9]), { stiffness: 200, damping: 20 });
const glare = useTransform(
[mx, my],
([gx, gy]: number[]) =>
`radial-gradient(circle at ${gx * 100}% ${gy * 100}%, rgba(255,255,255,0.14), transparent 45%)`
);
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);
};
return (
<motion.article
ref={ref}
className="case"
onPointerMove={onMove}
onPointerLeave={reset}
style={reduce ? undefined : { rotateX: rx, rotateY: ry, transformPerspective: 1000 }}
initial={reduce ? { opacity: 1 } : { opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "0px 0px -12% 0px" }}
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
>
<div className="case__inner" style={{ ["--case-accent" as string]: data.accent }}>
<div className="case__head">
<span className="case__no">{String(index + 1).padStart(2, "0")}</span>
<span className="case__tag">{data.tag}</span>
</div>
{/* coded data visual — unique bars per case */}
<div className="case__viz" aria-hidden="true">
<div className="case__bars">
{data.bars.map((h, i) => (
<span
key={i}
className="case__bar"
style={{
["--h" as string]: `${h}%`,
["--d" as string]: `${i * 70}ms`,
}}
/>
))}
</div>
<svg className="case__spark" viewBox="0 0 120 40" preserveAspectRatio="none">
<polyline
points="0,34 24,30 48,22 72,18 96,8 120,4"
fill="none"
stroke="var(--case-accent)"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</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>
{!reduce && (
<motion.span
className="case__glare"
aria-hidden="true"
style={{ background: glare }}
/>
)}
</div>
</motion.article>
);
}