← 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>
  );
}