183 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|