Files
democratic-money/src/components/ProgressSection.tsx
2026-05-16 00:47:44 +00:00

183 lines
7.4 KiB
TypeScript

"use client";
import { motion, useSpring, useTransform, animate } from "framer-motion";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
type Stats = {
raisedUsd: number;
goalUsd: number;
donationCount: number;
uniqueDonors: number;
};
function AnimatedNumber({ value, prefix = "", suffix = "" }: { value: number; prefix?: string; suffix?: string }) {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const ctrl = animate(0, value, {
duration: 1.2,
ease: "easeOut",
onUpdate: (v) => {
el.textContent = prefix + Math.round(v).toLocaleString() + suffix;
},
});
return () => ctrl.stop();
}, [value, prefix, suffix]);
return <span ref={ref}>{prefix}0{suffix}</span>;
}
export function ProgressSection() {
const [stats, setStats] = useState<Stats | null>(null);
useEffect(() => {
let alive = true;
const load = async () => {
try {
const res = await fetch("/api/public/stats", { cache: "no-store" });
if (!res.ok) return;
const data = await res.json();
if (alive) setStats(data);
} catch { /* silent */ }
};
load();
const id = setInterval(load, 30_000);
return () => { alive = false; clearInterval(id); };
}, []);
const goal = stats?.goalUsd ?? 250000;
const pct = stats ? Math.min(100, Math.round((stats.raisedUsd / goal) * 100)) : 0;
const spring = useSpring(0, { stiffness: 80, damping: 18 });
const width = useTransform(spring, (v) => `${v}%`);
useEffect(() => {
spring.set(pct);
}, [pct, spring]);
return (
<section id="meter" className="relative overflow-hidden border-b border-white/10 bg-gradient-to-b from-[#030712] to-[#050b1f] py-10">
{/* Ambient glow */}
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_70%_40%_at_50%_100%,rgba(56,189,248,0.07),transparent)]" />
<div className="relative mx-auto max-w-6xl px-4 text-center sm:px-6 lg:text-left">
<div className="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
<div className="mx-auto max-w-xl lg:mx-0">
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="text-xs uppercase tracking-[0.32em] text-slate-400"
>
Grassroots meter
</motion.p>
<motion.h2
initial={{ opacity: 0, y: 14 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.55 }}
className="mt-3 text-3xl font-bold text-white sm:text-4xl"
>
Momentum you can{" "}
<span className="bg-gradient-to-r from-sky-300 to-indigo-300 bg-clip-text text-sky-200 supports-[(-webkit-background-clip:text)]:text-transparent">
see
</span>
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1, duration: 0.5 }}
className="mt-3 max-w-xl text-slate-400"
>
Every dollar is a vote for whose voice counts. These numbers are the same ones on our public board refreshed on a
short timer so the homepage always feels current.
</motion.p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-sm lg:justify-start">
<Link
href="/donate"
className="inline-flex items-center gap-1.5 font-medium text-sky-300 hover:text-sky-200 transition"
>
Add your gift
</Link>
<span className="hidden text-slate-600 sm:inline" aria-hidden>
|
</span>
<Link href="/raised" className="inline-flex items-center gap-1.5 font-medium text-slate-400 hover:text-white transition">
Open full totals board
</Link>
</div>
</div>
{/* Stat cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
{[
{
label: "Raised",
value: stats?.raisedUsd ?? 0,
display: stats ? `$${stats.raisedUsd.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : "—",
color: "from-sky-500/20 to-transparent",
glow: "border-sky-500/20",
},
{
label: "Donations",
value: stats?.donationCount ?? 0,
display: stats?.donationCount?.toLocaleString() ?? "—",
color: "from-indigo-500/20 to-transparent",
glow: "border-indigo-500/20",
},
{
label: "Supporters",
value: stats?.uniqueDonors ?? 0,
display: stats?.uniqueDonors?.toLocaleString() ?? "—",
color: "from-fuchsia-500/20 to-transparent",
glow: "border-fuchsia-500/20",
},
].map(({ label, display, color, glow }, i) => (
<motion.div
key={label}
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.08, duration: 0.5 }}
whileHover={{ scale: 1.04 }}
className={`relative overflow-hidden rounded-2xl border ${glow} bg-white/5 p-4 ${i === 2 ? "col-span-2 sm:col-span-1" : "col-span-1"}`}
>
<div className={`pointer-events-none absolute inset-0 bg-gradient-to-br ${color}`} />
<p className="relative text-xs uppercase tracking-wide text-slate-400">{label}</p>
<p className="relative mt-2 text-2xl font-bold text-white">{display}</p>
</motion.div>
))}
</div>
</div>
{/* Progress bar */}
<div className="mt-8">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>$0</span>
<span className="font-medium text-slate-300">
Goal: ${goal.toLocaleString()}
</span>
</div>
<div className="relative mt-3 h-5 overflow-hidden rounded-full border border-white/10 bg-black/40">
{/* Glow track */}
<div className="absolute inset-y-0 left-0 w-full bg-gradient-to-r from-sky-900/30 to-indigo-900/20 rounded-full" />
<motion.div
style={{ width }}
className="relative h-full rounded-full bg-gradient-to-r from-sky-400 via-indigo-400 to-fuchsia-400 shadow-[0_0_20px_rgba(56,189,248,0.5)]"
>
{/* Shimmer sweep on bar */}
<div className="absolute inset-0 animate-shimmer rounded-full" />
{/* Leading dot */}
<div className="absolute right-0 top-1/2 -translate-y-1/2 h-5 w-5 rounded-full bg-white shadow-[0_0_12px_rgba(56,189,248,0.9)] -mr-2.5" />
</motion.div>
</div>
<p className="mt-3 text-right text-sm font-semibold text-sky-300">
{stats ? `${pct}% to goal` : "Loading…"}
</p>
</div>
</div>
</section>
);
}