← back to Grant
components/outreach/OutreachTab.tsx
634 lines
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { SkeletonList } from '../Skeleton';
import {
Megaphone,
Mail,
FileText,
Heart,
ArrowRight,
Send,
Plus,
Sparkles,
Loader2,
X,
Copy,
ExternalLink,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import { useAuth } from '../AuthProvider';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';
const OUTREACH_SORT_OPTIONS = [
{ value: 'date_desc', label: 'Newest First' },
{ value: 'date_asc', label: 'Oldest First' },
{ value: 'type', label: 'Type A-Z' },
];
const OUTREACH_SORT_CONFIGS: Record<string, SortConfig> = {
date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
type: { key: 'template_type', direction: 'asc', type: 'string' },
};
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface OutreachTemplate {
id: string;
template_type: string;
target_type: string | null;
title: string;
subject: string | null;
body_html: string;
body_text: string | null;
is_ai_generated: boolean;
version: number;
created_at: string;
}
const TEMPLATE_TYPES: { Icon: React.ElementType; label: string; desc: string; type: string }[] = [
{ Icon: Mail, label: 'Meeting Request', desc: 'Request a meeting with a funder or partner', type: 'meeting_request' },
{ Icon: FileText, label: 'One-Pager', desc: 'Generate a concise organization summary', type: 'one_pager' },
{ Icon: Heart, label: 'Thank You', desc: 'Send gratitude to donors and supporters', type: 'thank_you' },
{ Icon: ArrowRight, label: 'Follow Up', desc: 'Follow up on prior conversations', type: 'follow_up' },
{ Icon: Send, label: 'Introduction', desc: 'Introduce your organization to a new contact', type: 'introduction' },
{ Icon: Megaphone, label: 'Donation Ask', desc: 'Request financial support from donors', type: 'donation_ask' },
];
const TYPE_LABELS: Record<string, string> = {
meeting_request: 'Meeting Request',
one_pager: 'One-Pager',
thank_you: 'Thank You',
follow_up: 'Follow Up',
introduction: 'Introduction',
donation_ask: 'Donation Ask',
};
const TARGET_LABELS: Record<string, string> = {
official: 'Official',
corporation: 'Corporation',
nonprofit: 'Nonprofit',
donor: 'Donor',
general: 'General',
};
const TYPE_BADGE_COLORS: Record<string, string> = {
meeting_request: '#3b82f6',
one_pager: '#8b5cf6',
thank_you: '#ec4899',
follow_up: '#f59e0b',
introduction: '#059669',
donation_ask: '#ef4444',
};
function formatDate(d: string): string {
return new Date(d).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
/* ─── Main Component ─────────────────────────────────────────────────────── */
export default function OutreachTab() {
const [templates, setTemplates] = useState<OutreachTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [showGenerateModal, setShowGenerateModal] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [sortBy, setSortBy] = useState('date_desc');
const sortedTemplates = useClientSort(templates, sortBy, OUTREACH_SORT_CONFIGS);
const fetchTemplates = useCallback(async () => {
try {
const res = await fetch('/api/outreach', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setTemplates(data.rows || []);
}
} catch {
// fetch error handled by loading state
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchTemplates();
}, [fetchTemplates]);
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)' }}>
Outreach Tools
</h2>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
AI-generated templates and tools for effective donor and partner outreach.
</p>
</div>
<div className="flex items-center gap-2">
<SortDropdown options={OUTREACH_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
<button className="btn btn-primary" onClick={() => setShowGenerateModal('introduction')}>
<Plus size={15} />
New Template
</button>
</div>
</div>
{/* Template Type Grid */}
<div>
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--color-text)' }}>
Generate from Template
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{TEMPLATE_TYPES.map(({ Icon, label, desc, type }) => (
<button
key={type}
className="flex items-start gap-3 p-4 rounded-lg text-left w-full"
style={{
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
transition: 'border-color 0.15s, background-color 0.15s',
cursor: 'pointer',
}}
onClick={() => setShowGenerateModal(type)}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'rgba(5,150,105,0.4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)';
}}
>
<div
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{
backgroundColor: 'rgba(5,150,105,0.12)',
border: '1px solid rgba(5,150,105,0.2)',
}}
>
<Icon size={16} style={{ color: '#34d399' }} />
</div>
<div>
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
{label}
</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--color-text-muted)' }}>
{desc}
</div>
</div>
</button>
))}
</div>
</div>
{/* Saved Templates */}
<div>
<div className="flex items-center gap-2 mb-3">
<FileText size={16} style={{ color: 'var(--color-primary)' }} />
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
Saved Templates ({templates.length})
</h3>
</div>
{loading ? (
<SkeletonList count={3} />
) : sortedTemplates.length === 0 ? (
<div className="card">
<div className="flex flex-col items-center justify-center py-12 text-center">
<div
className="w-12 h-12 rounded-xl flex items-center justify-center mb-3"
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
}}
>
<Megaphone size={20} style={{ color: 'var(--color-text-muted)' }} />
</div>
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
No saved templates yet
</p>
<p className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
Generated outreach templates will be saved here for reuse
</p>
</div>
</div>
) : (
<div className="space-y-3">
{sortedTemplates.map((t) => {
const isExpanded = expandedId === t.id;
const badgeColor = TYPE_BADGE_COLORS[t.template_type] || '#059669';
const plainText = t.body_text || stripHtml(t.body_html);
return (
<div key={t.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">
<h4 className="text-sm font-semibold truncate" style={{ color: 'var(--color-text)' }}>
{t.title}
</h4>
<span
className="badge"
style={{
backgroundColor: `${badgeColor}20`,
color: badgeColor,
border: `1px solid ${badgeColor}40`,
}}
>
{TYPE_LABELS[t.template_type] || t.template_type}
</span>
{t.target_type && (
<span className="badge badge-info">
{TARGET_LABELS[t.target_type] || t.target_type}
</span>
)}
{t.is_ai_generated && (
<Sparkles size={12} style={{ color: '#34d399' }} />
)}
</div>
{t.subject && (
<p className="text-xs mb-1" style={{ color: 'var(--color-text-secondary)' }}>
Subject: {t.subject}
</p>
)}
{!isExpanded && (
<p className="text-xs truncate" style={{ color: 'var(--color-text-muted)' }}>
{plainText.substring(0, 150)}...
</p>
)}
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
{formatDate(t.created_at)}
</span>
</div>
<button
className="btn btn-ghost btn-sm shrink-0"
onClick={() => setExpandedId(isExpanded ? null : t.id)}
aria-label={isExpanded ? `Collapse ${t.title}` : `Expand ${t.title}`}
aria-expanded={isExpanded}
>
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</div>
</div>
{isExpanded && (
<div
className="px-4 pb-4 space-y-3"
style={{ borderTop: '1px solid var(--color-border)' }}
>
<div
className="p-4 rounded-lg mt-3"
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-secondary)',
fontSize: '0.8125rem',
lineHeight: '1.6',
}}
>
{plainText.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 btn-sm flex-1"
onClick={async () => {
try {
await navigator.clipboard.writeText(plainText);
} catch {
const ta = document.createElement('textarea');
ta.value = plainText;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
}}
>
<Copy size={14} />
Copy
</button>
<button
className="btn btn-primary btn-sm flex-1"
onClick={() => {
window.open(
`mailto:?subject=${encodeURIComponent(t.subject || '')}&body=${encodeURIComponent(plainText)}`,
'_blank',
);
}}
>
<ExternalLink size={14} />
Open in Email
</button>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Generate Modal */}
{showGenerateModal && (
<GenerateTemplateModal
defaultType={showGenerateModal}
onClose={() => setShowGenerateModal(null)}
onCreated={fetchTemplates}
/>
)}
</div>
);
}
/* ─── Generate Template Modal ────────────────────────────────────────────── */
function GenerateTemplateModal({
defaultType,
onClose,
onCreated,
}: {
defaultType: string;
onClose: () => void;
onCreated: () => 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(defaultType);
const [targetType, setTargetType] = useState('general');
const [targetName, setTargetName] = useState('');
const [targetOrg, setTargetOrg] = useState('');
const [context, setContext] = useState('');
const [generating, setGenerating] = useState(false);
const [result, setResult] = useState<{ subject: 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 || undefined,
target_org: targetOrg || undefined,
target_type: targetType,
template_type: templateType,
context: context || undefined,
}),
});
if (res.ok) {
const data = await res.json();
setResult({ subject: data.subject, body_text: data.body_text });
onCreated();
addToast('Template 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="generate-template-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">
<h3 id="generate-template-modal-title" className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
Generate Outreach Template
</h3>
<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 className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
Template Type
</label>
<select
className="input"
value={templateType}
onChange={(e) => setTemplateType(e.target.value)}
>
{Object.entries(TYPE_LABELS).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
Target Type
</label>
<select
className="input"
value={targetType}
onChange={(e) => setTargetType(e.target.value)}
>
{Object.entries(TARGET_LABELS).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
Contact Name
</label>
<input
className="input"
placeholder="John Smith"
value={targetName}
onChange={(e) => setTargetName(e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
Organization
</label>
<input
className="input"
placeholder="Ford Foundation"
value={targetOrg}
onChange={(e) => setTargetOrg(e.target.value)}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
Context (optional)
</label>
<textarea
className="input"
rows={3}
placeholder="Any context about the relationship, recent events, specific ask..."
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 Template'}
</button>
) : (
<p className="text-xs text-center" style={{ color: 'var(--color-text-muted)' }}>
AI generation 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}>
<Copy 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>
);
}