agency-web/app/components/Scoreboard.tsx

258 lines
9.2 KiB
TypeScript

"use client";
/**
* METRICS scoreboard.
* - The four headline metrics count up on view (Motion) and are now the focus:
* each cell is an interactive tile with a hover lift, an accent edge that
* wipes in, and a tiny per-metric sparkline that animates.
* - The previously-meaningless decorative bars are replaced with a MEANINGFUL,
* LABELLED growth trend: a 6-month "client revenue" area+line chart with real
* month ticks, a value axis, and interactive data points that enlarge + show
* a tooltip on hover/focus. It communicates the compounding-growth story
* instead of being filler.
* Reduced-motion: numbers rest at final value, chart renders fully drawn, no
* count-up, no draw animation.
*/
import { useEffect, useRef, useState } from "react";
import { gsap } from "./gsap";
import CountUp from "./CountUp";
import { metrics } from "../content";
/* 6-month sample "client revenue generated" trend (index, $M). Compounding
curve that mirrors the "$40M+ generated / 3.8x ROAS" headline story. */
const TREND = [
{ m: "Jan", v: 2.1 },
{ m: "Feb", v: 3.4 },
{ m: "Mar", v: 5.0 },
{ m: "Apr", v: 7.8 },
{ m: "May", v: 11.2 },
{ m: "Jun", v: 16.4 },
];
// tiny per-metric sparkline shapes (each different, matching the metric's story)
const SPARKS: string[] = [
"0,18 20,16 40,12 60,11 80,6 100,2", // revenue — steady climb
"0,16 20,14 40,15 60,9 80,7 100,3", // ROAS — climbing with a dip
"0,19 20,17 40,12 60,10 80,5 100,1", // organic — strong ramp
"0,8 20,7 40,8 60,6 80,7 100,5", // retention — high + stable
];
const W = 560;
const H = 200;
const PAD = { l: 38, r: 16, t: 18, b: 30 };
const maxV = Math.max(...TREND.map((d) => d.v));
function pt(i: number, v: number) {
const x = PAD.l + (i / (TREND.length - 1)) * (W - PAD.l - PAD.r);
const y = PAD.t + (1 - v / maxV) * (H - PAD.t - PAD.b);
return { x, y };
}
export default function Scoreboard() {
const root = useRef<HTMLElement>(null);
const [hover, setHover] = useState<number | null>(null);
const pts = TREND.map((d, i) => pt(i, d.v));
const linePath = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" ");
const areaPath = `${linePath} L${pts[pts.length - 1].x},${H - PAD.b} L${pts[0].x},${H - PAD.b} Z`;
// y-axis gridlines at 0 / 50% / 100% of max
const yTicks = [0, 0.5, 1].map((f) => ({
f,
y: PAD.t + (1 - f) * (H - PAD.t - PAD.b),
label: `$${(maxV * f).toFixed(0)}M`,
}));
useEffect(() => {
const el = root.current;
if (!el) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
const ctx = gsap.context(() => {
const tl = gsap.timeline({
scrollTrigger: { trigger: ".score__chart", start: "top 82%", once: true },
});
// gridlines fade, the area wipes up, the line draws itself, points pop in,
// and each metric sparkline draws — a coherent "data resolving" moment.
tl.from(".score__grid line", { autoAlpha: 0, duration: 0.5, stagger: 0.08 })
.from(".score__area", { autoAlpha: 0, yPercent: 8, duration: 0.8, ease: "power2.out" }, "-=0.2")
.from(".score__line", { drawSVG: "0%", duration: 1.4, ease: "power2.inOut" }, "-=0.7")
.from(
".score__pt",
{ scale: 0, transformOrigin: "center", duration: 0.5, ease: "back.out(2.2)", stagger: 0.08 },
"-=0.6"
)
.from(
".score__mlabel",
{ autoAlpha: 0, y: 6, duration: 0.4, stagger: 0.06 },
"-=0.8"
);
gsap.from(".score__spark polyline", {
drawSVG: "0%",
duration: 1,
ease: "power2.out",
stagger: 0.12,
scrollTrigger: { trigger: ".score__grid-stats", start: "top 85%", once: true },
});
}, el);
return () => ctx.revert();
}, []);
return (
<section ref={root} data-invert className="score frame" aria-labelledby="score-h">
<div className="wrap">
<header className="sec-head sec-head--center">
<p className="kicker">
<span className="kicker__dot" />
The scoreboard sample data
</p>
<h2 id="score-h" className="display sec-head__title">
Numbers we report on
</h2>
</header>
{/* four headline metrics — the focus; interactive tiles with sparklines */}
<dl className="score__grid score__grid-stats">
{metrics.map((m, i) => (
<div className="score__cell" key={m.label}>
<dt className="sr-only">{m.label}</dt>
<dd
className={`score__num display ${"accent" in m && m.accent ? "is-accent" : ""}`}
>
<CountUp
to={m.value}
prefix={"prefix" in m ? m.prefix : ""}
suffix={m.suffix}
decimals={"decimals" in m ? m.decimals : 0}
/>
</dd>
<p className="score__lab" aria-hidden="true">
{m.label}
</p>
<svg
className="score__spark"
viewBox="0 0 100 20"
preserveAspectRatio="none"
aria-hidden="true"
>
<polyline
points={SPARKS[i]}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
))}
</dl>
{/* meaningful labelled growth trend — replaces the decorative bars */}
<figure className="score__chart">
<figcaption className="score__chart-cap">
Client revenue generated cumulative, sample 6-month engagement
</figcaption>
<svg
className="score__trend"
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label="Cumulative client revenue rising from $2.1M in January to $16.4M in June (sample data)."
>
<defs>
<linearGradient id="scoreLine" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#2563eb" />
<stop offset="55%" stopColor="#7c3aed" />
<stop offset="100%" stopColor="#059669" />
</linearGradient>
<linearGradient id="scoreArea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.28" />
<stop offset="100%" stopColor="#7c3aed" stopOpacity="0" />
</linearGradient>
</defs>
{/* y gridlines + value labels */}
<g className="score__grid">
{yTicks.map((t) => (
<line key={t.f} x1={PAD.l} y1={t.y} x2={W - PAD.r} y2={t.y} />
))}
</g>
<g className="score__ylabels" aria-hidden="true">
{yTicks.map((t) => (
<text key={t.f} x={PAD.l - 8} y={t.y + 4} textAnchor="end">
{t.label}
</text>
))}
</g>
<path className="score__area" d={areaPath} fill="url(#scoreArea)" />
<path
className="score__line"
d={linePath}
fill="none"
stroke="url(#scoreLine)"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* interactive data points + month labels */}
{TREND.map((d, i) => {
const p = pts[i];
const on = hover === i;
return (
<g
key={d.m}
className="score__ptg"
onMouseEnter={() => setHover(i)}
onMouseLeave={() => setHover(null)}
onFocus={() => setHover(i)}
onBlur={() => setHover(null)}
tabIndex={0}
role="img"
aria-label={`${d.m}: $${d.v}M`}
>
{/* invisible larger hit area */}
<rect
x={p.x - 22}
y={PAD.t}
width={44}
height={H - PAD.t - PAD.b}
fill="transparent"
/>
<circle
className="score__pt"
cx={p.x}
cy={p.y}
r={on ? 7 : 4.5}
/>
<text className="score__mlabel" x={p.x} y={H - 10} textAnchor="middle">
{d.m}
</text>
{on && (
<g className="score__tip">
<rect
x={p.x - 26}
y={p.y - 34}
width={52}
height={22}
rx={6}
/>
<text x={p.x} y={p.y - 19} textAnchor="middle">
${d.v}M
</text>
</g>
)}
</g>
);
})}
</svg>
</figure>
<p className="score__foot">Sample data your numbers, reported monthly.</p>
</div>
</section>
);
}