← back to Norma
components/collaborations/CollaborationsTab.tsx
499 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Users,
Building2,
Landmark,
MapPin,
ExternalLink,
Mail,
Phone,
Search,
RefreshCw,
ChevronDown,
ChevronUp,
Sparkles,
MessageSquare,
CheckCircle2,
Clock,
Send,
Globe,
BadgeCheck,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import { useDebounce } from '@/hooks/useDebounce';
import { SkeletonList } from '../Skeleton';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';
const COLLAB_SORT_OPTIONS = [
{ value: 'name_asc', label: 'Name A-Z' },
{ value: 'name_desc', label: 'Name Z-A' },
{ value: 'type', label: 'Type A-Z' },
{ value: 'date_desc', label: 'Newest First' },
{ value: 'date_asc', label: 'Oldest First' },
];
const COLLAB_SORT_CONFIGS: Record<string, SortConfig> = {
name_asc: { key: 'name', direction: 'asc', type: 'string' },
name_desc: { key: 'name', direction: 'desc', type: 'string' },
type: { key: 'collab_type', direction: 'asc', type: 'string' },
date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
};
/* ─── Types ──────────────────────────────────────────────────────────────── */
type CollabType = 'nonprofit' | 'politician' | 'corporation' | 'municipality';
interface Collaboration {
id: string;
collab_type: CollabType;
name: string;
title: string | null;
organization: string | null;
website_url: string | null;
email: string | null;
phone: string | null;
district: string | null;
state: string | null;
city: string | null;
party: string | null;
committees: string[] | null;
focus_areas: string[] | null;
ai_reason: string | null;
ai_relevance: number | null;
ai_talking_points: string[] | null;
status: string;
notes: string | null;
last_contacted: string | null;
outreach_letter: string | null;
created_at: string;
}
/* ─── Sub-tab config ─────────────────────────────────────────────────────── */
const SUB_TABS: Array<{ id: CollabType; label: string; Icon: React.ElementType; color: string }> = [
{ id: 'nonprofit', label: 'Non-Profits', Icon: Users, color: '#10b981' },
{ id: 'politician', label: 'Politicians', Icon: Landmark, color: '#6366f1' },
{ id: 'corporation', label: 'Corporations', Icon: Building2, color: '#f59e0b' },
{ id: 'municipality', label: 'Municipalities', Icon: MapPin, color: '#ec4899' },
];
const STATUS_COLORS: Record<string, string> = {
suggested: '#94a3b8',
contacted: '#34d399',
in_discussion: '#fbbf24',
partnered: '#34d399',
declined: '#f87171',
archived: '#6b7280',
};
/* ─── Helpers ────────────────────────────────────────────────────────────── */
function relevanceBar(score: number | null) {
if (score === null || score === undefined) return null;
const pct = Math.round(score * 100);
const color = pct >= 80 ? '#34d399' : pct >= 60 ? '#fbbf24' : '#f87171';
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ width: 60, height: 6, borderRadius: 3, backgroundColor: 'rgba(255,255,255,0.1)' }}>
<div style={{ width: pct + '%', height: '100%', borderRadius: 3, backgroundColor: color, transition: 'width 0.3s' }} />
</div>
<span style={{ fontSize: 11, color, fontWeight: 600 }}>{pct}%</span>
</div>
);
}
/* ─── Collaboration Card ─────────────────────────────────────────────────── */
function CollabCard({ collab, tabColor }: { collab: Collaboration; tabColor: string }) {
const [expanded, setExpanded] = useState(false);
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
borderRadius: 12,
padding: '16px 20px',
cursor: 'pointer',
}}
onClick={function() { setExpanded(function(e) { return !e; }); }}
>
{/* Header Row */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text)', margin: 0 }}>
{collab.name}
</h3>
{collab.party && (
<span style={{
fontSize: 10, fontWeight: 700, padding: '2px 6px', borderRadius: 4,
backgroundColor: collab.party === 'D' || collab.party === 'Democrat' ? 'rgba(99,102,241,0.2)' : collab.party === 'R' || collab.party === 'Republican' ? 'rgba(239,68,68,0.2)' : 'rgba(156,163,175,0.2)',
color: collab.party === 'D' || collab.party === 'Democrat' ? '#34d399' : collab.party === 'R' || collab.party === 'Republican' ? '#f87171' : '#9ca3af',
}}>
{collab.party}
</span>
)}
</div>
{(collab.title || collab.organization) && (
<p style={{ fontSize: 12, color: 'var(--color-text-muted)', margin: '0 0 4px' }}>
{[collab.title, collab.organization].filter(Boolean).join(' at ')}
</p>
)}
{(collab.state || collab.district || collab.city) && (
<p style={{ fontSize: 11, color: 'var(--color-text-muted)', margin: 0, display: 'flex', alignItems: 'center', gap: 4 }}>
<MapPin size={11} />
{[collab.city, collab.state, collab.district].filter(Boolean).join(', ')}
</p>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
{/* Status badge */}
<span style={{
fontSize: 10, fontWeight: 600, padding: '2px 8px', borderRadius: 10,
backgroundColor: (STATUS_COLORS[collab.status] || '#6b7280') + '22',
color: STATUS_COLORS[collab.status] || '#6b7280',
textTransform: 'capitalize',
}}>
{collab.status.replace('_', ' ')}
</span>
{/* Relevance */}
{relevanceBar(collab.ai_relevance)}
</div>
</div>
{/* Focus Areas */}
{collab.focus_areas && collab.focus_areas.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 8 }}>
{collab.focus_areas.map(function(area, i) {
return (
<span key={i} style={{
fontSize: 10, padding: '2px 8px', borderRadius: 10,
backgroundColor: tabColor + '18',
color: tabColor,
fontWeight: 500,
}}>
{area}
</span>
);
})}
</div>
)}
{/* AI Reason (always visible) */}
{collab.ai_reason && (
<p style={{ fontSize: 12, color: 'var(--color-text-secondary)', margin: '8px 0 0', lineHeight: 1.5 }}>
<Sparkles size={12} style={{ display: 'inline', marginRight: 4, color: tabColor }} />
{collab.ai_reason}
</p>
)}
{/* Expanded Details */}
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
style={{ overflow: 'hidden' }}
>
<div style={{ borderTop: '1px solid var(--color-border)', marginTop: 12, paddingTop: 12 }}>
{/* Talking Points */}
{collab.ai_talking_points && collab.ai_talking_points.length > 0 && (
<div style={{ marginBottom: 12 }}>
<h4 style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
<MessageSquare size={12} style={{ display: 'inline', marginRight: 4 }} />
Talking Points
</h4>
<ul style={{ margin: 0, paddingLeft: 16 }}>
{collab.ai_talking_points.map(function(pt, i) {
return <li key={i} style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>{pt}</li>;
})}
</ul>
</div>
)}
{/* Action Buttons */}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{collab.website_url && (
<a
href={collab.website_url}
target="_blank"
rel="noopener noreferrer"
onClick={function(e) { e.stopPropagation(); }}
style={{
fontSize: 11, fontWeight: 600, padding: '6px 12px', borderRadius: 6,
backgroundColor: tabColor + '22', color: tabColor,
textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4,
}}
>
<Globe size={12} /> Website
</a>
)}
{collab.email && (
<a
href={'mailto:' + collab.email}
onClick={function(e) { e.stopPropagation(); }}
style={{
fontSize: 11, fontWeight: 600, padding: '6px 12px', borderRadius: 6,
backgroundColor: 'rgba(52,211,153,0.15)', color: '#34d399',
textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4,
}}
>
<Mail size={12} /> Email
</a>
)}
{collab.phone && (
<a
href={'tel:' + collab.phone}
onClick={function(e) { e.stopPropagation(); }}
style={{
fontSize: 11, fontWeight: 600, padding: '6px 12px', borderRadius: 6,
backgroundColor: 'rgba(52,211,153,0.15)', color: '#34d399',
textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4,
}}
>
<Phone size={12} /> Call
</a>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Expand indicator */}
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 8 }}>
{expanded ? <ChevronUp size={14} style={{ color: 'var(--color-text-muted)' }} /> : <ChevronDown size={14} style={{ color: 'var(--color-text-muted)' }} />}
</div>
</motion.div>
);
}
/* ─── Main Tab ───────────────────────────────────────────────────────────── */
export default function CollaborationsTab() {
const { addToast } = useToast();
const [activeType, setActiveType] = useState<CollabType>('nonprofit');
const [collabs, setCollabs] = useState<Collaboration[]>([]);
const [loading, setLoading] = useState(false);
const [discovering, setDiscovering] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearch = useDebounce(searchQuery, 300);
const [sortBy, setSortBy] = useState('name_asc');
const [stats, setStats] = useState<Record<CollabType, number>>({
nonprofit: 0, politician: 0, corporation: 0, municipality: 0,
});
const fetchCollabs = useCallback(async function() {
setLoading(true);
try {
const res = await fetch('/api/collaborations?type=' + activeType);
const data = await res.json();
setCollabs(data.collaborations || []);
} catch {
setCollabs([]);
}
setLoading(false);
}, [activeType]);
const fetchStats = useCallback(async function() {
const types: CollabType[] = ['nonprofit', 'politician', 'corporation', 'municipality'];
const counts: Record<string, number> = {};
for (const t of types) {
try {
const res = await fetch('/api/collaborations?type=' + t);
const data = await res.json();
counts[t] = (data.collaborations || []).length;
} catch {
counts[t] = 0;
}
}
setStats(counts as Record<CollabType, number>);
}, []);
useEffect(function() { fetchCollabs(); }, [fetchCollabs]);
useEffect(function() { fetchStats(); }, [fetchStats]);
async function handleDiscover() {
setDiscovering(true);
try {
const res = await fetch('/api/collaborations/discover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: activeType }),
});
const data = await res.json();
if (data.success) {
await fetchCollabs();
await fetchStats();
addToast('New collaborations discovered', 'success');
}
} catch {
addToast('Discovery failed', 'error');
}
setDiscovering(false);
}
const sortedCollabs = useClientSort(collabs, sortBy, COLLAB_SORT_CONFIGS);
const filtered = sortedCollabs.filter(function(c) {
if (!debouncedSearch) return true;
const q = debouncedSearch.toLowerCase();
return (
c.name.toLowerCase().includes(q) ||
(c.organization || '').toLowerCase().includes(q) ||
(c.state || '').toLowerCase().includes(q) ||
(c.ai_reason || '').toLowerCase().includes(q)
);
});
const activeTab = SUB_TABS.find(function(t) { return t.id === activeType; });
const tabColor = activeTab ? activeTab.color : '#34d399';
return (
<div style={{ padding: '24px 28px', maxWidth: 1100, margin: '0 auto' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<h2 style={{ fontSize: 22, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
Collaborations
</h2>
<p style={{ fontSize: 13, color: 'var(--color-text-muted)', margin: '4px 0 0' }}>
Discover and manage partnerships to amplify advocacy efforts
</p>
</div>
<button
onClick={handleDiscover}
disabled={discovering}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 16px', borderRadius: 8, border: 'none',
backgroundColor: tabColor, color: '#fff',
fontSize: 13, fontWeight: 600, cursor: discovering ? 'not-allowed' : 'pointer',
opacity: discovering ? 0.7 : 1,
}}
>
{discovering ? (
<RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
) : (
<Sparkles size={14} />
)}
{discovering ? 'Discovering...' : 'AI Discover'}
</button>
</div>
{/* Sub-tab pills */}
<div style={{ display: 'flex', gap: 8, marginBottom: 20, flexWrap: 'wrap' }}>
{SUB_TABS.map(function(tab) {
const isActive = activeType === tab.id;
const Icon = tab.Icon;
return (
<button
key={tab.id}
onClick={function() { setActiveType(tab.id); }}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 16px', borderRadius: 20, border: 'none',
backgroundColor: isActive ? tab.color + '22' : 'var(--color-surface-el)',
color: isActive ? tab.color : 'var(--color-text-secondary)',
fontSize: 13, fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
<Icon size={14} />
{tab.label}
<span style={{
fontSize: 10, fontWeight: 700, padding: '1px 6px', borderRadius: 10,
backgroundColor: isActive ? tab.color + '33' : 'rgba(255,255,255,0.06)',
color: isActive ? tab.color : 'var(--color-text-muted)',
}}>
{stats[tab.id]}
</span>
</button>
);
})}
</div>
{/* Search */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
backgroundColor: 'var(--color-surface-el)', borderRadius: 8,
padding: '8px 12px', marginBottom: 16,
border: '1px solid var(--color-border)',
}}>
<SortDropdown options={COLLAB_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
<Search size={14} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
<input
type="text"
placeholder={'Search ' + (activeTab ? activeTab.label.toLowerCase() : '') + '...'}
value={searchQuery}
onChange={function(e) { setSearchQuery(e.target.value); }}
aria-label="Search collaborations"
style={{
flex: 1, border: 'none', background: 'transparent',
color: 'var(--color-text)', fontSize: 13, outline: 'none',
}}
/>
</div>
{/* Content */}
{loading ? (
<SkeletonList count={4} />
) : filtered.length === 0 ? (
<div style={{
textAlign: 'center', padding: '48px 20px',
backgroundColor: 'var(--color-surface-el)',
borderRadius: 12, border: '1px solid var(--color-border)',
}}>
<div style={{
width: 48, height: 48, borderRadius: 12,
backgroundColor: tabColor + '15',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 12px',
}}>
{activeTab && <activeTab.Icon size={22} style={{ color: tabColor }} />}
</div>
<h3 style={{ fontSize: 16, fontWeight: 600, color: 'var(--color-text)', margin: '0 0 4px' }}>
No {activeTab ? activeTab.label : 'collaborations'} yet
</h3>
<p style={{ fontSize: 13, color: 'var(--color-text-muted)', margin: '0 0 16px' }}>
Click “AI Discover” to find potential {activeTab ? activeTab.label.toLowerCase() : 'partners'} for your organization
</p>
<button
onClick={handleDiscover}
disabled={discovering}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '10px 20px', borderRadius: 8, border: 'none',
backgroundColor: tabColor, color: '#fff',
fontSize: 13, fontWeight: 600, cursor: 'pointer',
}}
>
<Sparkles size={14} />
Discover {activeTab ? activeTab.label : 'Partners'}
</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<AnimatePresence>
{filtered.map(function(collab) {
return <CollabCard key={collab.id} collab={collab} tabColor={tabColor} />;
})}
</AnimatePresence>
</div>
)}
{/* Spin animation */}
<style>{'\n@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }\n'}</style>
</div>
);
}