← back to Grant
components/collaborations/CollaborationsTab.tsx
732 lines
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { SkeletonList } from '../Skeleton';
import {
Users,
Building2,
Landmark,
MapPin,
Search,
Sparkles,
Loader2,
ChevronDown,
ChevronUp,
ExternalLink,
Mail,
Phone,
Globe,
MessageSquare,
X,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import { useAuth } from '../AuthProvider';
import { useDebounce } from '@/hooks/useDebounce';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';
const COLLAB_SORT_OPTIONS = [
{ value: 'name_asc', label: 'Name A-Z' },
{ value: 'type', label: 'Type A-Z' },
{ value: 'status', label: 'Status A-Z' },
{ value: 'date_desc', label: 'Newest First' },
];
const COLLAB_SORT_CONFIGS: Record<string, SortConfig> = {
name_asc: { key: 'name', direction: 'asc', type: 'string' },
type: { key: 'collab_type', direction: 'asc', type: 'string' },
status: { key: 'status', direction: 'asc', type: 'string' },
date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
};
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface Collaboration {
id: string;
collab_type: string;
name: string;
title: string | null;
organization: string | null;
website_url: string | null;
email: string | null;
phone: string | null;
district: string | null;
state: string | null;
ai_reason: string | null;
ai_relevance: number | null;
ai_talking_points: string[];
status: string;
notes: string | null;
last_contacted: string | null;
created_at: string;
}
type TypeFilter = 'all' | 'nonprofit' | 'politician' | 'corporation' | 'municipality';
type StatusFilter = 'all' | 'suggested' | 'contacted' | 'in_discussion' | 'partnered';
const TYPE_OPTIONS: { id: TypeFilter; label: string; Icon: React.ElementType }[] = [
{ id: 'all', label: 'All', Icon: Users },
{ id: 'nonprofit', label: 'Nonprofits', Icon: Users },
{ id: 'politician', label: 'Politicians', Icon: Landmark },
{ id: 'corporation', label: 'Corporations', Icon: Building2 },
{ id: 'municipality', label: 'Municipalities', Icon: MapPin },
];
const STATUS_OPTIONS: { id: StatusFilter; label: string }[] = [
{ id: 'all', label: 'All Status' },
{ id: 'suggested', label: 'Suggested' },
{ id: 'contacted', label: 'Contacted' },
{ id: 'in_discussion', label: 'In Discussion' },
{ id: 'partnered', label: 'Partnered' },
];
const TYPE_COLORS: Record<string, { bg: string; text: string; border: string }> = {
nonprofit: { bg: 'rgba(5,150,105,0.15)', text: '#34d399', border: 'rgba(5,150,105,0.3)' },
politician: { bg: 'rgba(59,130,246,0.15)', text: '#60a5fa', border: 'rgba(59,130,246,0.3)' },
corporation: { bg: 'rgba(245,158,11,0.15)', text: '#fbbf24', border: 'rgba(245,158,11,0.3)' },
municipality: { bg: 'rgba(168,85,247,0.15)', text: '#c084fc', border: 'rgba(168,85,247,0.3)' },
};
const STATUS_COLORS: Record<string, { bg: string; text: string; border: string }> = {
suggested: { bg: 'rgba(59,130,246,0.15)', text: '#60a5fa', border: 'rgba(59,130,246,0.3)' },
contacted: { bg: 'rgba(245,158,11,0.15)', text: '#fbbf24', border: 'rgba(245,158,11,0.3)' },
in_discussion: { bg: 'rgba(168,85,247,0.15)', text: '#c084fc', border: 'rgba(168,85,247,0.3)' },
partnered: { bg: 'rgba(34,197,94,0.15)', text: '#22c55e', border: 'rgba(34,197,94,0.3)' },
declined: { bg: 'rgba(239,68,68,0.15)', text: '#ef4444', border: 'rgba(239,68,68,0.3)' },
archived: { bg: 'rgba(107,127,117,0.15)', text: '#6b7f75', border: 'rgba(107,127,117,0.3)' },
};
/* ─── Main Component ─────────────────────────────────────────────────────── */
export default function CollaborationsTab() {
const { addToast } = useToast();
const { showAdmin } = useAuth();
const [collabs, setCollabs] = useState<Collaboration[]>([]);
const [typeCounts, setTypeCounts] = useState<Record<string, number>>({});
const [loading, setLoading] = useState(true);
const [discovering, setDiscovering] = useState(false);
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [outreachModal, setOutreachModal] = useState<{ name: string; org: string; type: string } | null>(null);
const [sortBy, setSortBy] = useState('name_asc');
const sortedCollabs = useClientSort(collabs, sortBy, COLLAB_SORT_CONFIGS);
const fetchCollabs = useCallback(async () => {
try {
const params = new URLSearchParams();
if (typeFilter !== 'all') params.set('collab_type', typeFilter);
if (statusFilter !== 'all') params.set('status', statusFilter);
if (debouncedSearch) params.set('search', debouncedSearch);
const res = await fetch(`/api/collaborations?${params}`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setCollabs(data.rows || []);
setTypeCounts(data.typeCounts || {});
}
} catch {
// fetch error handled by loading state
} finally {
setLoading(false);
}
}, [typeFilter, statusFilter, debouncedSearch]);
useEffect(() => {
fetchCollabs();
}, [fetchCollabs]);
const handleDiscover = async () => {
setDiscovering(true);
try {
const res = await fetch('/api/collaborations/discover', {
method: 'POST',
credentials: 'include',
});
if (res.ok) {
await fetchCollabs();
addToast('Collaborators discovered', 'success');
} else {
addToast('Discovery failed', 'error');
}
} catch {
addToast('Discovery failed', 'error');
} finally {
setDiscovering(false);
}
};
const handleStatusChange = async (id: string, newStatus: string) => {
try {
await fetch('/api/collaborations', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ id, status: newStatus }),
});
await fetchCollabs();
addToast(`Status updated to ${newStatus.replace(/_/g, ' ')}`, 'success');
} catch {
addToast('Status update failed', 'error');
}
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-lg font-bold" style={{ color: 'var(--color-text)' }}>
Collaborations
</h2>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
Build partnerships across sectors to amplify your impact.
</p>
</div>
{showAdmin && (
<button
className="btn btn-secondary"
onClick={handleDiscover}
disabled={discovering}
>
{discovering ? (
<Loader2 size={15} className="animate-spin" />
) : (
<Sparkles size={15} />
)}
{discovering ? 'Discovering...' : 'AI Discover Collaborators'}
</button>
)}
</div>
{/* Type Filter Pills */}
<div className="flex gap-2 flex-wrap">
{TYPE_OPTIONS.map(({ id, label, Icon }) => {
const count = typeCounts[id] ?? 0;
const isActive = typeFilter === id;
return (
<button
key={id}
className="btn btn-sm"
onClick={() => setTypeFilter(id)}
style={{
backgroundColor: isActive ? 'rgba(5,150,105,0.12)' : 'var(--color-surface-el)',
color: isActive ? '#34d399' : 'var(--color-text-secondary)',
border: isActive
? '1px solid rgba(5,150,105,0.3)'
: '1px solid var(--color-border)',
}}
>
<Icon size={13} />
{label}
{count > 0 && <span className="ml-1 text-xs" style={{ opacity: 0.7 }}>({count})</span>}
</button>
);
})}
</div>
{/* Sort + Status Filter + Search */}
<div className="flex items-center gap-3 flex-wrap">
<SortDropdown options={COLLAB_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
<div className="relative flex-1 min-w-[200px]">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2"
style={{ color: 'var(--color-text-muted)' }}
/>
<input
type="text"
className="input"
placeholder="Search collaborators..."
aria-label="Search"
style={{ paddingLeft: '2.25rem' }}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select
className="input"
style={{ width: 'auto' }}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
aria-label="Filter by status"
>
{STATUS_OPTIONS.map(({ id, label }) => (
<option key={id} value={id}>{label}</option>
))}
</select>
</div>
{/* Cards */}
{loading ? (
<SkeletonList count={4} />
) : sortedCollabs.length === 0 ? (
<div className="card">
<div className="flex flex-col items-center justify-center py-16 text-center">
<div
className="w-14 h-14 rounded-xl flex items-center justify-center mb-4"
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
}}
>
<Users size={24} style={{ color: 'var(--color-text-muted)' }} />
</div>
<h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>
No collaborators found
</h3>
<p className="text-sm max-w-md" style={{ color: 'var(--color-text-muted)' }}>
Click "AI Discover Collaborators" to find potential partners for your mission.
</p>
</div>
</div>
) : (
<div className="space-y-3">
{sortedCollabs.map((c) => {
const isExpanded = expandedId === c.id;
const typeStyle = TYPE_COLORS[c.collab_type] || TYPE_COLORS.nonprofit;
const statusStyle = STATUS_COLORS[c.status] || STATUS_COLORS.suggested;
const relevancePct = c.ai_relevance != null ? Math.round(c.ai_relevance * 100) : null;
return (
<div key={c.id} className="card" style={{ padding: 0 }}>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
{c.name}
</h3>
<span
className="badge"
style={{
backgroundColor: typeStyle.bg,
color: typeStyle.text,
border: `1px solid ${typeStyle.border}`,
}}
>
{c.collab_type}
</span>
<span
className="badge"
style={{
backgroundColor: statusStyle.bg,
color: statusStyle.text,
border: `1px solid ${statusStyle.border}`,
}}
>
{c.status.replace(/_/g, ' ')}
</span>
</div>
<div className="flex items-center gap-3 text-xs flex-wrap" style={{ color: 'var(--color-text-muted)' }}>
{c.title && <span>{c.title}</span>}
{c.organization && (
<span className="font-medium" style={{ color: 'var(--color-text-secondary)' }}>
{c.organization}
</span>
)}
{c.district && <span>{c.district}</span>}
{c.state && <span>{c.state}</span>}
</div>
{c.ai_reason && (
<p className="text-xs mt-2 leading-relaxed" style={{ color: 'var(--color-text-secondary)' }}>
{c.ai_reason}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{/* Relevance score */}
{relevancePct != null && (
<div className="text-center">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center text-xs font-bold"
style={{
backgroundColor: relevancePct >= 70 ? 'rgba(34,197,94,0.15)' : 'rgba(245,158,11,0.15)',
color: relevancePct >= 70 ? '#22c55e' : '#f59e0b',
border: `1px solid ${relevancePct >= 70 ? 'rgba(34,197,94,0.3)' : 'rgba(245,158,11,0.3)'}`,
}}
>
{relevancePct}%
</div>
</div>
)}
<button
className="btn btn-ghost btn-sm"
onClick={() => setExpandedId(isExpanded ? null : c.id)}
aria-label={isExpanded ? `Collapse ${c.name}` : `Expand ${c.name}`}
aria-expanded={isExpanded}
>
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</div>
</div>
</div>
{/* Expanded */}
{isExpanded && (
<div
className="px-4 pb-4 space-y-3"
style={{ borderTop: '1px solid var(--color-border)' }}
>
{/* Contact info */}
<div className="flex gap-3 flex-wrap pt-3 text-xs" style={{ color: 'var(--color-text-muted)' }}>
{c.email && (
<a href={`mailto:${c.email}`} className="flex items-center gap-1 hover:underline" style={{ color: '#60a5fa' }}>
<Mail size={12} />
{c.email}
</a>
)}
{c.phone && (
<span className="flex items-center gap-1">
<Phone size={12} />
{c.phone}
</span>
)}
{c.website_url && (
<a href={c.website_url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:underline" style={{ color: '#60a5fa' }}>
<Globe size={12} />
Website
</a>
)}
</div>
{/* Talking Points */}
{c.ai_talking_points && c.ai_talking_points.length > 0 && (
<div>
<h4 className="text-xs font-semibold mb-1 flex items-center gap-1" style={{ color: '#34d399' }}>
<Sparkles size={12} />
Talking Points
</h4>
<ul className="space-y-1">
{c.ai_talking_points.map((point, i) => (
<li
key={i}
className="text-xs leading-relaxed pl-3 relative"
style={{ color: 'var(--color-text-secondary)' }}
>
<span
className="absolute left-0 top-1.5 w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: '#34d399' }}
/>
{point}
</li>
))}
</ul>
</div>
)}
{/* Relevance bar */}
{relevancePct != null && (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
AI Relevance
</span>
<span className="text-xs font-bold" style={{ color: relevancePct >= 70 ? '#34d399' : '#fbbf24' }}>
{relevancePct}%
</span>
</div>
<div className="h-1.5 rounded-full overflow-hidden" style={{ backgroundColor: 'var(--color-surface-el)' }}>
<div
className="h-full rounded-full"
style={{
width: `${relevancePct}%`,
backgroundColor: relevancePct >= 70 ? '#34d399' : '#fbbf24',
}}
/>
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-2 flex-wrap pt-1">
<button
className="btn btn-primary btn-sm"
onClick={() =>
setOutreachModal({
name: c.name,
org: c.organization || '',
type: c.collab_type,
})
}
>
<MessageSquare size={14} />
Draft Outreach
</button>
<select
className="input"
style={{ width: 'auto', padding: '0.25rem 0.5rem', fontSize: '0.8125rem' }}
value={c.status}
onChange={(e) => handleStatusChange(c.id, e.target.value)}
aria-label={`Status for ${c.name}`}
>
<option value="suggested">Suggested</option>
<option value="contacted">Contacted</option>
<option value="in_discussion">In Discussion</option>
<option value="partnered">Partnered</option>
<option value="declined">Declined</option>
<option value="archived">Archived</option>
</select>
</div>
</div>
)}
</div>
);
})}
</div>
)}
{/* Outreach Modal */}
{outreachModal && (
<OutreachGenerateModal
targetName={outreachModal.name}
targetOrg={outreachModal.org}
targetType={outreachModal.type === 'politician' ? 'official' : outreachModal.type}
onClose={() => setOutreachModal(null)}
/>
)}
</div>
);
}
/* ─── Outreach Generate Modal ────────────────────────────────────────────── */
function OutreachGenerateModal({
targetName,
targetOrg,
targetType,
onClose,
}: {
targetName: string;
targetOrg: string;
targetType: string;
onClose: () => void;
}) {
const { addToast } = useToast();
const { canUseAI } = useAuth();
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
// Store previously focused element and focus the modal on mount
useEffect(() => {
previousFocusRef.current = document.activeElement as HTMLElement;
modalRef.current?.focus();
return () => {
previousFocusRef.current?.focus();
};
}, []);
// Escape to close + focus trapping
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key === 'Tab' && modalRef.current) {
const focusable = modalRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
}, [onClose]);
const [templateType, setTemplateType] = useState('introduction');
const [context, setContext] = useState('');
const [generating, setGenerating] = useState(false);
const [result, setResult] = useState<{ subject: string; body_html: string; body_text: string } | null>(null);
const [copied, setCopied] = useState(false);
const handleGenerate = async () => {
setGenerating(true);
try {
const res = await fetch('/api/outreach/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
target_name: targetName,
target_org: targetOrg,
target_type: targetType,
template_type: templateType,
context,
}),
});
if (res.ok) {
const data = await res.json();
setResult(data);
addToast('Outreach email generated', 'success');
} else {
addToast('Generation failed', 'error');
}
} catch {
addToast('Generation failed', 'error');
} finally {
setGenerating(false);
}
};
const handleCopy = async () => {
if (!result) return;
try {
await navigator.clipboard.writeText(result.body_text);
} catch {
const ta = document.createElement('textarea');
ta.value = result.body_text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
addToast('Copied to clipboard', 'success');
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="outreach-modal-title"
onKeyDown={handleKeyDown}
>
<div
ref={modalRef}
className="card w-full max-w-2xl max-h-[90vh] overflow-auto"
style={{ backgroundColor: 'var(--color-surface)' }}
onClick={(e) => e.stopPropagation()}
tabIndex={-1}
>
<div className="flex items-center justify-between mb-4">
<div>
<h3 id="outreach-modal-title" className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
Draft Outreach
</h3>
<p className="text-xs mt-0.5" style={{ color: 'var(--color-text-muted)' }}>
To: {targetName} {targetOrg ? `(${targetOrg})` : ''}
</p>
</div>
<button className="btn btn-ghost btn-sm" onClick={onClose} aria-label="Close dialog">
<X size={16} />
</button>
</div>
{!result ? (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
Email Type
</label>
<select
className="input"
value={templateType}
onChange={(e) => setTemplateType(e.target.value)}
>
<option value="introduction">Introduction</option>
<option value="meeting_request">Meeting Request</option>
<option value="follow_up">Follow Up</option>
<option value="thank_you">Thank You</option>
<option value="donation_ask">Donation Ask</option>
</select>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
Additional Context (optional)
</label>
<textarea
className="input"
rows={3}
placeholder="Any specific talking points or context..."
value={context}
onChange={(e) => setContext(e.target.value)}
style={{ resize: 'vertical' }}
/>
</div>
{canUseAI ? (
<button
className="btn btn-primary w-full"
onClick={handleGenerate}
disabled={generating}
>
{generating ? <Loader2 size={15} className="animate-spin" /> : <Sparkles size={15} />}
{generating ? 'Generating...' : 'Generate Email'}
</button>
) : (
<p className="text-xs text-center" style={{ color: 'var(--color-text-muted)' }}>
AI email drafting isn’t available for this account.
</p>
)}
</div>
) : (
<div className="space-y-3">
<div
className="p-3 rounded-lg"
style={{ backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}
>
<h4 className="text-xs font-semibold mb-1" style={{ color: 'var(--color-text-secondary)' }}>
Subject
</h4>
<p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
{result.subject}
</p>
</div>
<div
className="p-4 rounded-lg"
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-secondary)',
fontSize: '0.8125rem',
lineHeight: '1.6',
}}
>
{result.body_text.split('\n').map((line, i) => (
<p key={i} style={{ marginBottom: line.trim() ? '0.5rem' : '0.25rem' }}>
{line || '\u00A0'}
</p>
))}
</div>
<div className="flex gap-2">
<button className="btn btn-secondary flex-1" onClick={handleCopy}>
<Mail size={14} />
{copied ? 'Copied!' : 'Copy to Clipboard'}
</button>
<button
className="btn btn-primary flex-1"
onClick={() => {
window.open(
`mailto:?subject=${encodeURIComponent(result.subject)}&body=${encodeURIComponent(result.body_text)}`,
'_blank',
);
}}
>
<ExternalLink size={14} />
Open in Email
</button>
</div>
<button className="btn btn-ghost btn-sm w-full" onClick={() => setResult(null)}>
Generate Another
</button>
</div>
)}
</div>
</div>
);
}