← back to Freddy
components/pulse/PulseBoard.tsx
507 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { SkeletonStats, SkeletonList } from '../Skeleton';
import {
BarChart3, Flame, Building2, Landmark, Sparkles, DollarSign,
TrendingUp, TrendingDown, ArrowRight, RefreshCw, Zap,
Users, Target, ArrowUpRight, ArrowDownRight, Minus,
} from 'lucide-react';
interface PulseData {
stats: {
total_causes: number;
total_votes: number;
registered_orgs: number;
active_granters: number;
total_matches: number;
funds_matched: number;
revenue_earned: number;
pending_fees: number;
};
top_causes: Array<{
id: string;
title: string;
urgency_score: number;
total_votes: number;
total_supporters: number;
category: string;
tags: string[];
trending_rank: number | null;
}>;
top_orgs: Array<{
id: string;
name: string;
category: string;
impact_score: number;
trust_score: number;
total_votes: number;
total_funding_received: string;
status: string;
}>;
recent_matches: Array<{
id: string;
match_score: number;
status: string;
created_at: string;
cause_title: string;
org_name: string;
granter_name: string | null;
}>;
funding_rounds: Array<{
id: string;
title: string;
total_pool: string;
allocated: string;
remaining: string;
status: string;
}>;
}
const fadeIn = {
hidden: { opacity: 0, y: 20 },
visible: (i: number) => ({
opacity: 1,
y: 0,
transition: { delay: i * 0.05, duration: 0.4, ease: 'easeOut' as const },
}),
};
export default function PulseBoard() {
const [data, setData] = useState<PulseData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const fetchData = useCallback(async () => {
try {
const res = await fetch('/api/pulse', { credentials: 'include' });
if (!res.ok) throw new Error('Failed to fetch');
const json = await res.json();
setData(json);
setError('');
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, [fetchData]);
if (loading) {
return (
<div className="p-6" style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<SkeletonStats count={4} />
<SkeletonList count={3} />
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="card p-4 text-center">
<p style={{ color: 'var(--color-error)' }}>Error: {error}</p>
<button className="btn btn-primary mt-3" onClick={fetchData}>Retry</button>
</div>
</div>
);
}
if (!data) return null;
const stats = [
{ label: 'Active Causes', value: data.stats.total_causes, Icon: Flame, color: '#ef4444' },
{ label: 'Total Votes', value: data.stats.total_votes.toLocaleString(), Icon: Users, color: '#fbbf24' },
{ label: 'Registered Orgs', value: data.stats.registered_orgs, Icon: Building2, color: '#22c55e' },
{ label: 'Active Granters', value: data.stats.active_granters, Icon: Landmark, color: '#60a5fa' },
{ label: 'AI Matches', value: data.stats.total_matches, Icon: Sparkles, color: '#c084fc' },
{ label: 'Funds Matched', value: `$${(data.stats.funds_matched || 0).toLocaleString()}`, Icon: DollarSign, color: '#d97706' },
{ label: 'Revenue Earned', value: `$${(data.stats.revenue_earned || 0).toLocaleString()}`, Icon: TrendingUp, color: '#22c55e' },
{ label: 'Pending Fees', value: `$${(data.stats.pending_fees || 0).toLocaleString()}`, Icon: Target, color: '#f59e0b' },
];
return (
<div className="p-6 space-y-6">
{/* Hero Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between"
>
<div>
<h2 className="text-xl font-bold flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
<Zap size={20} style={{ color: '#fbbf24' }} />
Pulse Board
</h2>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
Real-time democratic funding dashboard
</p>
</div>
<button onClick={fetchData} className="btn btn-secondary btn-sm">
<RefreshCw size={14} />
Refresh
</button>
</motion.div>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-3">
{stats.map((stat, i) => (
<motion.div
key={stat.label}
custom={i}
initial="hidden"
animate="visible"
variants={fadeIn}
className="card p-4"
>
<div className="flex items-center gap-2 mb-2">
<stat.Icon size={16} style={{ color: stat.color }} />
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{stat.label}</span>
</div>
<div className="text-xl font-bold" style={{ color: 'var(--color-text)' }}>
{stat.value}
</div>
</motion.div>
))}
</div>
{/* Funding Pipeline Visual */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="card p-5"
>
<h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--color-text-secondary)' }}>
FUNDING PIPELINE
</h3>
<div className="flex items-center justify-between gap-2 flex-wrap">
<PipelineStage
icon={<Flame size={20} />}
label="Causes"
count={data.stats.total_causes}
color="#ef4444"
subtitle="Active issues"
/>
<ArrowRight size={20} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
<PipelineStage
icon={<Users size={20} />}
label="Votes"
count={data.stats.total_votes}
color="#fbbf24"
subtitle="Public support"
/>
<ArrowRight size={20} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
<PipelineStage
icon={<Building2 size={20} />}
label="Organizations"
count={data.stats.registered_orgs}
color="#22c55e"
subtitle="Non-profits"
/>
<ArrowRight size={20} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
<PipelineStage
icon={<Sparkles size={20} />}
label="Matches"
count={data.stats.total_matches}
color="#c084fc"
subtitle="AI-generated"
/>
<ArrowRight size={20} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
<PipelineStage
icon={<DollarSign size={20} />}
label="Funded"
count={`$${(data.stats.funds_matched || 0).toLocaleString()}`}
color="#d97706"
subtitle="Matched funds"
/>
</div>
</motion.div>
{/* Two Column: Cause Ticker + Top Orgs */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Cause Ticker */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4 }}
className="card p-5"
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold flex items-center gap-2" style={{ color: 'var(--color-text-secondary)' }}>
<Flame size={14} style={{ color: '#ef4444' }} />
TRENDING CAUSES
</h3>
<span className="badge badge-amber">LIVE</span>
</div>
<div className="space-y-2">
<AnimatePresence>
{data.top_causes.map((cause, i) => (
<motion.div
key={cause.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.05 * i }}
className="flex items-center gap-3 p-3 rounded-lg"
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
}}
>
<div className="flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold"
style={{
backgroundColor: i < 3 ? 'rgba(217, 119, 6, 0.2)' : 'rgba(113, 113, 122, 0.2)',
color: i < 3 ? '#fbbf24' : 'var(--color-text-muted)',
}}
>
{i + 1}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate" style={{ color: 'var(--color-text)' }}>
{cause.title}
</div>
<div className="flex items-center gap-3 mt-1">
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
{cause.total_votes} votes
</span>
<div className="urgency-gauge flex-1 max-w-[80px]">
<div
className="urgency-gauge-fill"
style={{
width: `${Math.round(cause.urgency_score * 100)}%`,
background: cause.urgency_score > 0.7
? 'linear-gradient(90deg, #d97706, #ef4444)'
: cause.urgency_score > 0.4
? 'linear-gradient(90deg, #22c55e, #d97706)'
: '#22c55e',
}}
/>
</div>
</div>
</div>
<div className="text-right">
{cause.urgency_score > 0.7 ? (
<ArrowUpRight size={16} style={{ color: '#ef4444' }} />
) : cause.urgency_score > 0.4 ? (
<Minus size={16} style={{ color: 'var(--color-text-muted)' }} />
) : (
<ArrowDownRight size={16} style={{ color: '#22c55e' }} />
)}
</div>
</motion.div>
))}
</AnimatePresence>
{data.top_causes.length === 0 && (
<p className="text-sm text-center py-4" style={{ color: 'var(--color-text-muted)' }}>
No causes yet. Use the Causes tab to discover or add causes.
</p>
)}
</div>
</motion.div>
{/* Top Organizations Leaderboard */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4 }}
className="card p-5"
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold flex items-center gap-2" style={{ color: 'var(--color-text-secondary)' }}>
<Target size={14} style={{ color: '#22c55e' }} />
TOP ORGANIZATIONS
</h3>
<span className="badge badge-success">Leaderboard</span>
</div>
<div className="space-y-2">
{data.top_orgs.map((org, i) => (
<motion.div
key={org.id}
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.05 * i }}
className="flex items-center gap-3 p-3 rounded-lg"
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
}}
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg text-sm font-bold"
style={{
backgroundColor: i === 0 ? 'rgba(217, 119, 6, 0.2)' : i === 1 ? 'rgba(168, 162, 158, 0.2)' : i === 2 ? 'rgba(180, 83, 9, 0.2)' : 'rgba(113, 113, 122, 0.1)',
color: i === 0 ? '#fbbf24' : i === 1 ? '#a8a29e' : i === 2 ? '#d97706' : 'var(--color-text-muted)',
}}
>
#{i + 1}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate" style={{ color: 'var(--color-text)' }}>
{org.name}
</div>
<div className="flex items-center gap-3 mt-1">
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
{org.total_votes} votes
</span>
<span className="text-xs" style={{ color: '#22c55e' }}>
Impact: {((org.impact_score || 0) * 100).toFixed(0)}%
</span>
<span className="text-xs" style={{ color: '#60a5fa' }}>
Trust: {((org.trust_score || 0) * 100).toFixed(0)}%
</span>
</div>
</div>
<div className="badge badge-amber text-xs">
{org.category || 'other'}
</div>
</motion.div>
))}
{data.top_orgs.length === 0 && (
<p className="text-sm text-center py-4" style={{ color: 'var(--color-text-muted)' }}>
No organizations yet. Register non-profits in the Organizations tab.
</p>
)}
</div>
</motion.div>
</div>
{/* Recent Matches + Active Funding */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Matches */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="card p-5"
>
<h3 className="text-sm font-semibold flex items-center gap-2 mb-4" style={{ color: 'var(--color-text-secondary)' }}>
<Sparkles size={14} style={{ color: '#c084fc' }} />
RECENT AI MATCHES
</h3>
<div className="space-y-2">
{data.recent_matches.map((match) => (
<div
key={match.id}
className="flex items-center gap-3 p-3 rounded-lg"
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
}}
>
<div className="flex-1 min-w-0">
<div className="text-sm truncate" style={{ color: 'var(--color-text)' }}>
{match.cause_title}
</div>
<div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
{match.org_name} {match.granter_name ? `via ${match.granter_name}` : ''}
</div>
</div>
<div className="text-right">
<div className="text-sm font-bold" style={{ color: '#fbbf24' }}>
{(match.match_score * 100).toFixed(0)}%
</div>
<span className={`badge ${match.status === 'funded' ? 'badge-success' : match.status === 'accepted' ? 'badge-blue' : 'badge-amber'} text-xs mt-1`}>
{match.status}
</span>
</div>
</div>
))}
{data.recent_matches.length === 0 && (
<p className="text-sm text-center py-4" style={{ color: 'var(--color-text-muted)' }}>
No matches yet. Generate matches in the Matches tab.
</p>
)}
</div>
</motion.div>
{/* Active Funding Rounds */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="card p-5"
>
<h3 className="text-sm font-semibold flex items-center gap-2 mb-4" style={{ color: 'var(--color-text-secondary)' }}>
<DollarSign size={14} style={{ color: '#d97706' }} />
FUNDING ROUNDS
</h3>
<div className="space-y-2">
{data.funding_rounds.map((round) => {
const total = parseFloat(round.total_pool) || 1;
const allocated = parseFloat(round.allocated) || 0;
const pct = Math.min(100, (allocated / total) * 100);
return (
<div
key={round.id}
className="p-3 rounded-lg"
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
}}
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium truncate" style={{ color: 'var(--color-text)' }}>
{round.title}
</span>
<span className={`badge ${round.status === 'open' ? 'badge-success' : round.status === 'completed' ? 'badge-blue' : 'badge-warning'} text-xs`}>
{round.status}
</span>
</div>
<div className="flex items-center justify-between text-xs mb-1" style={{ color: 'var(--color-text-muted)' }}>
<span>${allocated.toLocaleString()} / ${total.toLocaleString()}</span>
<span>{pct.toFixed(0)}%</span>
</div>
<div className="urgency-gauge">
<div
className="urgency-gauge-fill"
style={{
width: `${pct}%`,
background: 'linear-gradient(90deg, #d97706, #fbbf24)',
}}
/>
</div>
</div>
);
})}
{data.funding_rounds.length === 0 && (
<p className="text-sm text-center py-4" style={{ color: 'var(--color-text-muted)' }}>
No funding rounds yet. Create one in the Funding tab.
</p>
)}
</div>
</motion.div>
</div>
</div>
);
}
function PipelineStage({
icon,
label,
count,
color,
subtitle,
}: {
icon: React.ReactNode;
label: string;
count: number | string;
color: string;
subtitle: string;
}) {
return (
<div className="flex flex-col items-center gap-1 px-3 py-2 rounded-lg"
style={{ backgroundColor: 'var(--color-surface-el)', minWidth: 100 }}
>
<div style={{ color }}>{icon}</div>
<div className="text-lg font-bold" style={{ color: 'var(--color-text)' }}>{count}</div>
<div className="text-xs font-semibold" style={{ color }}>{label}</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{subtitle}</div>
</div>
);
}