Add Democratic fundraising platform.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 lift—without 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>
|
||||
|
||||
Reference in New Issue
Block a user