← back to Freddy
components/solicitor/SolicitorTab.tsx
426 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { SkeletonList } from '../Skeleton';
import {
Send, Sparkles, Loader2, Search, Users, Mail,
FileText, User, Building2, Phone, Linkedin,
Star, MessageSquare, X, Copy, Check,
AlertTriangle, RefreshCw,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import { useDebounce } from '@/hooks/useDebounce';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';
interface Contact {
id: string;
contact_type: string;
name: string;
title: string | null;
organization: string | null;
email: string | null;
phone: string | null;
linkedin_url: string | null;
city: string | null;
state: string | null;
source: string;
relevance_score: number;
ai_pitch: string | null;
solicited: boolean;
solicited_at: string | null;
response: string | null;
notes: string | null;
created_at: string;
}
const SORT_OPTIONS = [
{ value: 'date_desc', label: 'Newest First' },
{ value: 'date_asc', label: 'Oldest First' },
{ value: 'relevance_desc', label: 'Most Relevant' },
{ value: 'name_asc', label: 'Name A-Z' },
{ value: 'response', label: 'Response Status' },
];
const SORT_CONFIGS: Record<string, SortConfig> = {
date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
relevance_desc: { key: 'relevance_score', direction: 'desc', type: 'number' },
name_asc: { key: 'name', direction: 'asc', type: 'string' },
response: { key: 'response', direction: 'asc', type: 'string' },
};
const CONTACT_TYPES = ['all', 'nonprofit_leader', 'grant_officer', 'board_member', 'program_director', 'government', 'media'];
const RESPONSE_STATUSES = ['none', 'interested', 'meeting_set', 'signed', 'declined'];
const fadeIn = {
hidden: { opacity: 0, y: 20 },
visible: (i: number) => ({
opacity: 1, y: 0,
transition: { delay: i * 0.04, duration: 0.35 },
}),
};
export default function SolicitorTab() {
const { addToast } = useToast();
const [contacts, setContacts] = useState<Contact[]>([]);
const [loading, setLoading] = useState(true);
const [discovering, setDiscovering] = useState(false);
const [typeFilter, setTypeFilter] = useState('all');
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearch = useDebounce(searchTerm, 300);
const [letterContact, setLetterContact] = useState<Contact | null>(null);
const [generatedLetter, setGeneratedLetter] = useState('');
const [generatingLetter, setGeneratingLetter] = useState(false);
const [letterType, setLetterType] = useState('nonprofit_pitch');
const [copied, setCopied] = useState(false);
const [sortBy, setSortBy] = useState('relevance_desc');
const sortedContacts = useClientSort(contacts, sortBy, SORT_CONFIGS);
const fetchContacts = useCallback(async () => {
try {
const params = new URLSearchParams();
if (typeFilter !== 'all') params.set('type', typeFilter);
const res = await fetch(`/api/contacts?${params}`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setContacts(data.contacts || []);
}
} catch (err) {
console.error('Failed to fetch contacts:', err);
} finally {
setLoading(false);
}
}, [typeFilter]);
useEffect(() => { fetchContacts(); }, [fetchContacts]);
/* ── Escape key handler ───────────────────────────────────────── */
useEffect(() => {
if (!letterContact) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { setLetterContact(null); setGeneratedLetter(''); }
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [letterContact]);
const handleDiscover = async () => {
setDiscovering(true);
try {
const res = await fetch('/api/contacts/discover', {
method: 'POST',
credentials: 'include',
});
if (res.ok) {
await fetchContacts();
addToast('Contacts discovered', 'success');
} else {
addToast('Discovery failed', 'error');
}
} catch (err) {
console.error('Discover error:', err);
addToast('Discovery failed', 'error');
} finally {
setDiscovering(false);
}
};
const handleGenerateLetter = async (contact: Contact, type: string) => {
setLetterContact(contact);
setLetterType(type);
setGeneratingLetter(true);
setGeneratedLetter('');
try {
const res = await fetch('/api/contacts/generate-letter', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ contact_id: contact.id, letter_type: type }),
});
if (res.ok) {
const data = await res.json();
setGeneratedLetter(data.letter || '');
addToast('Letter generated', 'success');
} else {
addToast('Letter generation failed', 'error');
}
} catch (err) {
console.error('Generate letter error:', err);
addToast('Letter generation failed', 'error');
} finally {
setGeneratingLetter(false);
}
};
const handleUpdateResponse = async (contactId: string, response: string) => {
try {
await fetch(`/api/contacts`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ id: contactId, response, solicited: true }),
});
await fetchContacts();
addToast('Response updated', 'success');
} catch (err) {
console.error('Update error:', err);
addToast('Update failed', 'error');
}
};
const copyToClipboard = async () => {
await navigator.clipboard.writeText(generatedLetter);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
addToast('Letter copied to clipboard', 'success');
};
const filtered = sortedContacts.filter(c =>
!debouncedSearch || c.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
(c.organization && c.organization.toLowerCase().includes(debouncedSearch.toLowerCase()))
);
const responseColor = (r: string | null) => {
switch (r) {
case 'interested': return 'badge-blue';
case 'meeting_set': return 'badge-warning';
case 'signed': return 'badge-success';
case 'declined': return 'badge-error';
default: return 'badge-amber';
}
};
const typeLabel = (t: string) => t.replace('_', ' ');
return (
<div className="p-6 space-y-6">
{/* Header */}
<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)' }}>
<Send size={20} style={{ color: '#d97706' }} />
Solicitor
</h2>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
Contact harvesting and outreach letter generation for both parties
</p>
</div>
<div className="flex items-center gap-2">
<button onClick={handleDiscover} disabled={discovering} className="btn btn-primary">
{discovering ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />}
{discovering ? 'Discovering...' : 'AI Discover Contacts'}
</button>
<button onClick={fetchContacts} className="btn btn-secondary btn-sm">
<RefreshCw size={14} />
</button>
</div>
</div>
{/* Fee Structure Banner */}
<div className="card p-4" style={{ borderColor: 'rgba(217, 119, 6, 0.3)', background: 'linear-gradient(135deg, rgba(217, 119, 6, 0.05), rgba(251, 191, 36, 0.02))' }}>
<div className="flex items-start gap-3">
<AlertTriangle size={18} style={{ color: '#fbbf24', flexShrink: 0, marginTop: 2 }} />
<div>
<h4 className="text-sm font-semibold mb-2" style={{ color: '#fbbf24' }}>Fee Structure</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
<div>
<span className="font-bold" style={{ color: 'var(--color-text)' }}>Introduction Fee: $750 flat</span>
<br />Paid by requesting party at time of introduction
</div>
<div>
<span className="font-bold" style={{ color: 'var(--color-text)' }}>Success Fee: 5% of grant amount</span>
<br />Paid by non-profit from grant funds, only if awarded
</div>
<div>
<span className="font-bold" style={{ color: 'var(--color-text)' }}>No upfront costs for granters</span>
<br />Granters explore the platform at no charge
</div>
</div>
</div>
</div>
</div>
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 max-w-xs">
<Search size={14} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} />
<input className="input pl-8" placeholder="Search contacts..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} />
</div>
<SortDropdown options={SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
</div>
{/* Contact type pills */}
<div className="flex items-center gap-2 flex-wrap">
{CONTACT_TYPES.map((t) => (
<button
key={t}
onClick={() => setTypeFilter(t)}
className="btn btn-sm"
style={{
backgroundColor: typeFilter === t ? 'rgba(217, 119, 6, 0.2)' : 'var(--color-surface-el)',
color: typeFilter === t ? '#fbbf24' : 'var(--color-text-secondary)',
borderColor: typeFilter === t ? 'rgba(217, 119, 6, 0.3)' : 'var(--color-border)',
}}
>
{t === 'all' ? 'All' : typeLabel(t)}
</button>
))}
</div>
{/* Contacts list */}
{loading ? (
<SkeletonList count={4} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<AnimatePresence>
{filtered.map((contact, i) => (
<motion.div key={contact.id} custom={i} initial="hidden" animate="visible" variants={fadeIn} className="card p-4 flex flex-col gap-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full flex items-center justify-center" style={{ backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}>
<User size={18} style={{ color: 'var(--color-text-muted)' }} />
</div>
<div>
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>{contact.name}</h3>
{contact.title && <p className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>{contact.title}</p>}
{contact.organization && (
<p className="text-xs flex items-center gap-1" style={{ color: 'var(--color-text-muted)' }}>
<Building2 size={10} /> {contact.organization}
</p>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1">
<span className={`badge ${responseColor(contact.response)} text-xs`}>
{contact.response || 'none'}
</span>
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{typeLabel(contact.contact_type)}</span>
</div>
</div>
{/* Contact info */}
<div className="flex items-center gap-3 flex-wrap text-xs" style={{ color: 'var(--color-text-muted)' }}>
{contact.email && (
<span className="flex items-center gap-1"><Mail size={10} /> {contact.email}</span>
)}
{contact.phone && (
<span className="flex items-center gap-1"><Phone size={10} /> {contact.phone}</span>
)}
{contact.linkedin_url && (
<a href={contact.linkedin_url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1" style={{ color: '#60a5fa' }}>
<Linkedin size={10} /> LinkedIn
</a>
)}
</div>
{/* Relevance score */}
<div className="flex items-center gap-2">
<Star size={12} style={{ color: '#fbbf24' }} />
<div className="flex-1">
<div className="score-bar">
<div className="score-bar-fill" style={{ width: `${(contact.relevance_score || 0) * 100}%` }} />
</div>
</div>
<span className="text-xs font-bold" style={{ color: '#fbbf24' }}>{((contact.relevance_score || 0) * 100).toFixed(0)}%</span>
</div>
{/* AI pitch */}
{contact.ai_pitch && (
<p className="text-xs p-2 rounded" style={{ backgroundColor: 'var(--color-surface-el)', color: 'var(--color-text-secondary)', borderLeft: '2px solid #c084fc' }}>
{contact.ai_pitch}
</p>
)}
{/* Actions */}
<div className="flex items-center gap-2 flex-wrap pt-1">
<button onClick={() => handleGenerateLetter(contact, 'nonprofit_pitch')} className="btn btn-sm btn-secondary">
<FileText size={12} /> NP Pitch
</button>
<button onClick={() => handleGenerateLetter(contact, 'granter_pitch')} className="btn btn-sm btn-secondary">
<FileText size={12} /> Granter Pitch
</button>
<button onClick={() => handleGenerateLetter(contact, 'introduction')} className="btn btn-sm btn-secondary">
<MessageSquare size={12} /> Intro
</button>
<button onClick={() => handleGenerateLetter(contact, 'follow_up')} className="btn btn-sm btn-secondary">
<Send size={12} /> Follow-Up
</button>
{/* Response status dropdown */}
<select
className="input btn-sm"
style={{ width: 'auto', fontSize: '0.75rem', padding: '2px 6px' }}
value={contact.response || 'none'}
onChange={(e) => handleUpdateResponse(contact.id, e.target.value)}
>
{RESPONSE_STATUSES.map(s => (
<option key={s} value={s}>{s === 'none' ? 'No Response' : s.replace('_', ' ')}</option>
))}
</select>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
)}
{!loading && filtered.length === 0 && (
<div className="text-center py-12">
<Users size={32} style={{ color: 'var(--color-text-muted)', margin: '0 auto 12px' }} />
<p style={{ color: 'var(--color-text-muted)' }}>
No contacts yet. Click "AI Discover Contacts" to find nonprofit leaders and grant officers.
</p>
</div>
)}
{/* Letter Generation Modal */}
{letterContact && (
<div className="modal-overlay" onClick={() => { setLetterContact(null); setGeneratedLetter(''); }}>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="modal-content"
style={{ maxWidth: '40rem' }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
Generated Letter
</h3>
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
To: {letterContact.name} — Type: {letterType.replace('_', ' ')}
</p>
</div>
<button onClick={() => { setLetterContact(null); setGeneratedLetter(''); }} className="btn btn-ghost btn-sm"><X size={16} /></button>
</div>
{generatingLetter ? (
<div className="flex items-center justify-center py-12 gap-2">
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--color-primary)' }} />
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>Generating personalized letter...</span>
</div>
) : generatedLetter ? (
<div className="space-y-3">
<div className="letter-preview">{generatedLetter}</div>
<div className="flex justify-end gap-2">
<button onClick={copyToClipboard} className="btn btn-secondary">
{copied ? <Check size={14} style={{ color: '#22c55e' }} /> : <Copy size={14} />}
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
) : (
<p className="text-sm text-center py-8" style={{ color: 'var(--color-text-muted)' }}>
No letter generated. Try again.
</p>
)}
</motion.div>
</div>
)}
</div>
);
}