← back to Freddy
components/matches/MatchesTab.tsx
263 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { SkeletonList } from '../Skeleton';
import {
Sparkles, Loader2, RefreshCw, ArrowRight,
Flame, Building2, Landmark, Target,
CheckCircle, Clock, ThumbsUp, Ban,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';
interface Match {
id: string;
cause_id: string;
org_id: string;
granter_id: string | null;
match_score: number;
cause_alignment: number;
public_support: number;
granter_fit: number;
ai_reasoning: string | null;
status: string;
funding_amount: string | null;
cause_title: string;
urgency_score: number;
cause_category: string;
org_name: string;
trust_score: number;
impact_score: number;
granter_name: string | null;
granter_budget: string | null;
created_at: string;
}
const SORT_OPTIONS = [
{ value: 'score_desc', label: 'Best Match' },
{ value: 'date_desc', label: 'Newest First' },
{ value: 'date_asc', label: 'Oldest First' },
{ value: 'status', label: 'Status A-Z' },
];
const SORT_CONFIGS: Record<string, SortConfig> = {
score_desc: { key: 'match_score', direction: 'desc', type: 'number' },
date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
status: { key: 'status', direction: 'asc', type: 'string' },
};
const STATUS_FILTERS = ['all', 'suggested', 'presented', 'accepted', 'funded', 'declined'];
const fadeIn = {
hidden: { opacity: 0, y: 20 },
visible: (i: number) => ({
opacity: 1, y: 0,
transition: { delay: i * 0.05, duration: 0.4 },
}),
};
export default function MatchesTab() {
const { addToast } = useToast();
const [matches, setMatches] = useState<Match[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [statusFilter, setStatusFilter] = useState('all');
const [sortBy, setSortBy] = useState('score_desc');
const sortedMatches = useClientSort(matches, sortBy, SORT_CONFIGS);
const fetchMatches = useCallback(async () => {
try {
const params = new URLSearchParams();
if (statusFilter !== 'all') params.set('status', statusFilter);
const res = await fetch(`/api/matches?${params}`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setMatches(data.matches || []);
}
} catch (err) {
console.error('Failed to fetch matches:', err);
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => { fetchMatches(); }, [fetchMatches]);
const handleGenerate = async () => {
setGenerating(true);
try {
const res = await fetch('/api/matches/generate', {
method: 'POST',
credentials: 'include',
});
if (res.ok) {
await fetchMatches();
addToast('Matches generated', 'success');
} else {
addToast('Match generation failed', 'error');
}
} catch (err) {
console.error('Generate error:', err);
addToast('Match generation failed', 'error');
} finally {
setGenerating(false);
}
};
const statusIcon = (s: string) => {
switch (s) {
case 'funded': return <CheckCircle size={14} style={{ color: '#22c55e' }} />;
case 'accepted': return <ThumbsUp size={14} style={{ color: '#60a5fa' }} />;
case 'declined': return <Ban size={14} style={{ color: '#ef4444' }} />;
default: return <Clock size={14} style={{ color: '#fbbf24' }} />;
}
};
const statusBadge = (s: string) => {
switch (s) {
case 'funded': return 'badge-success';
case 'accepted': return 'badge-blue';
case 'declined': return 'badge-error';
case 'presented': return 'badge-warning';
default: return 'badge-amber';
}
};
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-xl font-bold flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
<Sparkles size={20} style={{ color: '#c084fc' }} />
AI Matches
</h2>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
AI-generated cause-to-organization-to-granter match pipeline
</p>
</div>
<div className="flex items-center gap-2">
<button onClick={handleGenerate} disabled={generating} className="btn btn-primary">
{generating ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />}
{generating ? 'Generating...' : 'Generate Matches'}
</button>
<button onClick={fetchMatches} className="btn btn-secondary btn-sm">
<RefreshCw size={14} />
</button>
</div>
</div>
{/* Status filter */}
<div className="flex items-center gap-2 flex-wrap">
<SortDropdown options={SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
{STATUS_FILTERS.map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className="btn btn-sm"
style={{
backgroundColor: statusFilter === s ? 'rgba(168, 85, 247, 0.2)' : 'var(--color-surface-el)',
color: statusFilter === s ? '#c084fc' : 'var(--color-text-secondary)',
borderColor: statusFilter === s ? 'rgba(168, 85, 247, 0.3)' : 'var(--color-border)',
}}
>
{s === 'all' ? 'All' : s}
</button>
))}
</div>
{loading ? (
<SkeletonList count={4} />
) : (
<div className="space-y-4">
<AnimatePresence>
{sortedMatches.map((m, i) => (
<motion.div key={m.id} custom={i} initial="hidden" animate="visible" variants={fadeIn} className="card p-5">
{/* Pipeline visual */}
<div className="flex items-center gap-3 flex-wrap mb-4">
<div className="flex items-center gap-2 px-3 py-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
<Flame size={14} style={{ color: '#ef4444' }} />
<div>
<div className="text-xs font-semibold" style={{ color: 'var(--color-text)' }}>{m.cause_title}</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{m.cause_category}</div>
</div>
</div>
<ArrowRight size={16} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
<div className="flex items-center gap-2 px-3 py-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
<Building2 size={14} style={{ color: '#22c55e' }} />
<div>
<div className="text-xs font-semibold" style={{ color: 'var(--color-text)' }}>{m.org_name}</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Trust: {((m.trust_score || 0) * 100).toFixed(0)}%</div>
</div>
</div>
{m.granter_name && (
<>
<ArrowRight size={16} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
<div className="flex items-center gap-2 px-3 py-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
<Landmark size={14} style={{ color: '#60a5fa' }} />
<div>
<div className="text-xs font-semibold" style={{ color: 'var(--color-text)' }}>{m.granter_name}</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Funder</div>
</div>
</div>
</>
)}
<div className="ml-auto flex items-center gap-2">
{statusIcon(m.status)}
<span className={`badge ${statusBadge(m.status)}`}>{m.status}</span>
</div>
</div>
{/* Score breakdown */}
<div className="grid grid-cols-4 gap-3 mb-3">
<ScoreBar label="Overall" value={m.match_score} color="#fbbf24" />
<ScoreBar label="Cause Alignment" value={m.cause_alignment} color="#ef4444" />
<ScoreBar label="Public Support" value={m.public_support} color="#22c55e" />
<ScoreBar label="Granter Fit" value={m.granter_fit} color="#60a5fa" />
</div>
{/* AI Reasoning */}
{m.ai_reasoning && (
<div className="p-3 rounded-lg text-xs" style={{ backgroundColor: 'var(--color-surface-el)', color: 'var(--color-text-secondary)', borderLeft: '3px solid #c084fc' }}>
<div className="flex items-center gap-1 mb-1">
<Sparkles size={10} style={{ color: '#c084fc' }} />
<span className="font-semibold" style={{ color: '#c084fc' }}>AI Reasoning</span>
</div>
{m.ai_reasoning}
</div>
)}
</motion.div>
))}
</AnimatePresence>
</div>
)}
{!loading && matches.length === 0 && (
<div className="text-center py-12">
<Sparkles size={32} style={{ color: 'var(--color-text-muted)', margin: '0 auto 12px' }} />
<p style={{ color: 'var(--color-text-muted)' }}>
No matches yet. Add causes and organizations, then click "Generate Matches".
</p>
</div>
)}
</div>
);
}
function ScoreBar({ label, value, color }: { label: string; value: number; color: string }) {
const pct = Math.round((value || 0) * 100);
return (
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span style={{ color: 'var(--color-text-muted)' }}>{label}</span>
<span style={{ color, fontWeight: 700 }}>{pct}%</span>
</div>
<div className="score-bar">
<div className="score-bar-fill" style={{ width: `${pct}%`, background: color }} />
</div>
</div>
);
}