Add Democratic fundraising platform.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-05-16 00:47:44 +00:00
parent 89f9b8dc83
commit 391d90a754
141 changed files with 14989 additions and 914 deletions

View File

@@ -1,7 +1,8 @@
"use client";
import { motion, useSpring, useTransform } from "framer-motion";
import { useEffect, useState } from "react";
import { motion, useSpring, useTransform, animate } from "framer-motion";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
type Stats = {
raisedUsd: number;
@@ -10,28 +11,44 @@ type Stats = {
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 () => {
const res = await fetch("/api/public/stats", { cache: "no-store" });
if (!res.ok) return;
const data = await res.json();
if (alive) setStats(data);
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, 30000);
return () => {
alive = false;
clearInterval(id);
};
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(pct, { stiffness: 120, damping: 20 });
const spring = useSpring(0, { stiffness: 80, damping: 18 });
const width = useTransform(spring, (v) => `${v}%`);
useEffect(() => {
@@ -39,44 +56,125 @@ export function ProgressSection() {
}, [pct, spring]);
return (
<section className="border-b border-white/10 bg-gradient-to-b from-[#030712] to-[#050b1f] py-16">
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.32em] text-slate-400">Grassroots meter</p>
<h2 className="mt-3 text-3xl font-semibold text-white sm:text-4xl">Momentum you can see</h2>
<p className="mt-3 max-w-xl text-slate-400">
Every dollar is a choice about whose voice counts. We publish aggregate totals so supporters can
feel the collective liftwithout gamifying human dignity.
</p>
<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>
<div className="grid grid-cols-2 gap-4 text-sm text-slate-200 sm:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<p className="text-xs uppercase tracking-wide text-slate-400">Raised</p>
<p className="mt-2 text-2xl font-semibold text-white">
{stats ? `$${stats.raisedUsd.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : "—"}
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<p className="text-xs uppercase tracking-wide text-slate-400">Donations</p>
<p className="mt-2 text-2xl font-semibold text-white">{stats?.donationCount ?? "—"}</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<p className="text-xs uppercase tracking-wide text-slate-400">Supporters</p>
<p className="mt-2 text-2xl font-semibold text-white">{stats?.uniqueDonors ?? "—"}</p>
</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>
<div className="mt-10">
{/* Progress bar */}
<div className="mt-8">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>$0</span>
<span>
Goal ${stats?.goalUsd?.toLocaleString() ?? "—"} (demo target via PUBLIC_CAMPAIGN_GOAL_USD)
<span className="font-medium text-slate-300">
Goal: ${goal.toLocaleString()}
</span>
</div>
<div className="mt-3 h-4 overflow-hidden rounded-full border border-white/10 bg-black/40">
<motion.div style={{ width }} className="h-full rounded-full bg-gradient-to-r from-sky-400 via-indigo-400 to-fuchsia-400" />
<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>