import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' // ── Types ──────────────────────────────────────────────────────────────────── interface EmailDeliveryState { status: string lastEventAt: string | null } interface ContactSubmission { id: string submittedAt: string name: string email: string message: string messageType: string subscribe: boolean archived?: boolean starred?: boolean source?: string inboundTo?: string notes?: string tags?: string[] emailStatus?: { welcome: EmailDeliveryState adminNotification: EmailDeliveryState adminReply: EmailDeliveryState } } interface ReplyHistoryItem { id: string submissionId: string toEmail: string toName: string fromEmail: string subject: string preview: string sentAt: string scheduledAt?: string | null } interface Contact { key: string email: string name: string source: string | undefined subscribe: boolean archived: boolean firstContactAt: string // oldest submission date latestAt: string // newest submission date message: string notes: string tags: string[] lastContactedAt: string | null submissionCount: number mainId: string allIds: string[] allSubmissions: ContactSubmission[] bestDeliveryStatus: string | null // opened/clicked/delivered/sent/null unreplied: boolean // has inbound messages, no reply sent engagementScore: number } type ConversationItem = | { kind: 'inbound'; date: string; name: string; message: string; source?: string; id: string } | { kind: 'outbound'; date: string; subject: string; preview: string; toEmail: string } interface ChecklistEpisode { id: string series: string episodeNumber: number | null title: string datePublished: string } // ── Helpers ─────────────────────────────────────────────────────────────────── const TAG_PALETTE = [ '#1a3d2b', '#2b1a3d', '#3d1a1a', '#1a2b3d', '#3d2b1a', '#1a3d3d', '#3d1a3d', '#2b3d1a', '#1a1a3d', '#3d3d1a', ] function tagBg(tag: string): string { let h = 0 for (const c of tag) h = (h * 31 + c.charCodeAt(0)) & 0x7fffffff return TAG_PALETTE[h % TAG_PALETTE.length] } function fmtDate(iso: string | null | undefined) { if (!iso) return '—' try { return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) } catch { return '—' } } function fmtShort(iso: string | null | undefined) { if (!iso) return '' try { const d = new Date(iso) const now = new Date() if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) } catch { return '' } } // ── CSV helpers ─────────────────────────────────────────────────────────────── function csvField(v: string): string { const s = String(v ?? '') return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s } function exportContactsCSV(contacts: Contact[]) { const headers = ['name', 'email', 'notes', 'tags', 'source', 'subscribed', 'first_contact', 'last_contact', 'message_count'] const rows = contacts.map(c => [ c.name, c.email, c.notes, c.tags.join(';'), c.source ?? '', c.subscribe ? 'yes' : 'no', c.firstContactAt.slice(0, 10), c.latestAt.slice(0, 10), String(c.submissionCount), ]) const csv = [headers, ...rows].map(r => r.map(csvField).join(',')).join('\r\n') const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url; a.download = `contacts-${new Date().toISOString().slice(0, 10)}.csv`; a.click() setTimeout(() => URL.revokeObjectURL(url), 5000) } function parseCSVRow(line: string): string[] { const result: string[] = [] let cur = '', inQ = false for (let i = 0; i < line.length; i++) { const ch = line[i] if (ch === '"') { if (inQ && line[i + 1] === '"') { cur += '"'; i++ } else inQ = !inQ } else if (ch === ',' && !inQ) { result.push(cur); cur = '' } else cur += ch } result.push(cur) return result } function parseCSV(text: string): Record[] { const lines = text.split(/\r?\n/).filter(l => l.trim()) if (lines.length < 2) return [] const headers = parseCSVRow(lines[0]).map(h => h.toLowerCase().trim().replace(/\s+/g, '_')) return lines.slice(1) .map(line => { const vals = parseCSVRow(line) return Object.fromEntries(headers.map((h, i) => [h, (vals[i] ?? '').trim()])) }) .filter(r => Object.values(r).some(v => v)) } // ── Auth Shell ──────────────────────────────────────────────────────────────── export default function ContactsShell() { const [authState, setAuthState] = useState<'checking' | 'needs-password' | 'needs-totp' | 'ok'>('checking') const [password, setPassword] = useState('') const [totp, setTotp] = useState('') const [authError, setAuthError] = useState('') const [authBusy, setAuthBusy] = useState(false) const [pendingToken, setPendingToken] = useState('') useEffect(() => { fetch('/api/admin-auth/status', { credentials: 'include' }) .then(r => r.json()) .then((d: { authenticated?: boolean }) => setAuthState(d.authenticated ? 'ok' : 'needs-password')) .catch(() => setAuthState('needs-password')) }, []) async function handleLogin(e: React.FormEvent) { e.preventDefault(); setAuthBusy(true); setAuthError('') try { const res = await fetch('/api/admin-auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), credentials: 'include' }) const data = await res.json() as { ok?: boolean; totpRequired?: boolean; pendingToken?: string; message?: string } if (!res.ok) { setAuthError(data.message ?? 'Invalid password.'); setAuthBusy(false); return } if (data.totpRequired && data.pendingToken) { setPendingToken(data.pendingToken); setAuthState('needs-totp'); setAuthBusy(false); return } setAuthState('ok') } catch { setAuthError('Login failed.') } setAuthBusy(false) } async function handleTotp(e: React.FormEvent) { e.preventDefault(); setAuthBusy(true); setAuthError('') try { const res = await fetch('/api/admin-auth/totp-verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pendingToken, code: totp }), credentials: 'include' }) const data = await res.json() as { ok?: boolean; message?: string } if (!res.ok) { setAuthError(data.message ?? 'Invalid code.'); setAuthBusy(false); return } setAuthState('ok') } catch { setAuthError('Verification failed.') } setAuthBusy(false) } if (authState === 'checking') return
Loading…
if (authState === 'needs-password') return (

Contacts

{authError &&

{authError}

}
) if (authState === 'needs-totp') return (

Two-factor code

{authError &&

{authError}

}
) return } // ── Contacts Client ─────────────────────────────────────────────────────────── function ContactsClient() { const [submissions, setSubmissions] = useState([]) const [replyHistory, setReplyHistory] = useState([]) const [checklistEpisodes, setChecklistEpisodes] = useState([]) const [loading, setLoading] = useState(true) const [search, setSearch] = useState('') const [tagFilter, setTagFilter] = useState('') const [showArchived, setShowArchived] = useState(false) // Edit state const [editingKey, setEditingKey] = useState(null) const [editName, setEditName] = useState('') const [editNotes, setEditNotes] = useState('') const [editTags, setEditTags] = useState([]) const [editTagInput, setEditTagInput] = useState('') const [editSaving, setEditSaving] = useState(false) // Merge state const [mergePickerKey, setMergePickerKey] = useState(null) const [mergeSearch, setMergeSearch] = useState('') const [mergeBusy, setMergeBusy] = useState(false) const [mergeMsg, setMergeMsg] = useState('') // Drip trigger const [dripBusyKey, setDripBusyKey] = useState(null) const [dripMsgKey, setDripMsgKey] = useState(null) const [dripMsgText, setDripMsgText] = useState('') // History state const [expandedHistoryKey, setExpandedHistoryKey] = useState(null) // Add contact const [addOpen, setAddOpen] = useState(false) const [addName, setAddName] = useState('') const [addEmail, setAddEmail] = useState('') const [addNotes, setAddNotes] = useState('') const [addTags, setAddTags] = useState('') const [addBusy, setAddBusy] = useState(false) const [addError, setAddError] = useState('') // CSV import const [importBusy, setImportBusy] = useState(false) const [importMsg, setImportMsg] = useState('') const [importPreview, setImportPreview] = useState<{ rows: Record[]; filename: string } | null>(null) const importFileRef = useRef(null) // Flash const [flashMsg, setFlashMsg] = useState('') const reload = useCallback(async () => { try { const [subRes, histRes, clRes] = await Promise.all([ fetch('/api/admin-contact-submissions', { credentials: 'include' }), fetch('/api/admin-contact-reply-history', { credentials: 'include' }), fetch('/api/admin-podcast-checklist', { credentials: 'include' }), ]) if (subRes.ok) { const d = await subRes.json() as { submissions: ContactSubmission[] } setSubmissions(d.submissions ?? []) } if (histRes.ok) { const d = await histRes.json() as { items: ReplyHistoryItem[] } setReplyHistory(d.items ?? []) } if (clRes.ok) { const d = await clRes.json() as { checklist?: { episodes?: ChecklistEpisode[] } } setChecklistEpisodes(d.checklist?.episodes ?? []) } } catch { /* silent */ } setLoading(false) }, []) useEffect(() => { reload() }, [reload]) // ── Derived contacts ── const contacts: Contact[] = useMemo(() => { // Group by email (lowercased), falling back to id const grouped = new Map() for (const s of submissions) { const key = s.email?.trim().toLowerCase() || s.id const arr = grouped.get(key) ?? [] arr.push(s) grouped.set(key, arr) } // Build last-contacted index from reply history const lastContactedMap = new Map() for (const item of replyHistory) { const email = item.toEmail?.trim().toLowerCase() if (!email) continue const existing = lastContactedMap.get(email) if (!existing || item.sentAt > existing) lastContactedMap.set(email, item.sentAt) } return Array.from(grouped.values()) .map(entries => { const sorted = [...entries].sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime()) const latest = sorted[0] const oldest = sorted[sorted.length - 1] const emailKey = latest.email?.trim().toLowerCase() || latest.id // Tags: prefer entry that has tags, falling back to mainId submission const withTags = sorted.find(s => s.tags && s.tags.length > 0) // Best delivery status: clicked > opened > delivered > sent const STATUS_RANK: Record = { clicked: 4, opened: 3, delivered: 2, sent: 1 } let bestDeliveryStatus: string | null = null let bestRank = -1 for (const s of sorted) { const st = s.emailStatus?.adminReply?.status if (st && STATUS_RANK[st] !== undefined && STATUS_RANK[st] > bestRank) { bestRank = STATUS_RANK[st] bestDeliveryStatus = st } } const repliesForContact = replyHistory.filter(r => r.toEmail?.trim().toLowerCase() === emailKey) const unreplied = sorted.some(s => !s.archived) && repliesForContact.length === 0 const engagementScore = repliesForContact.length * 3 + sorted.filter(s => s.emailStatus?.adminReply?.status === 'clicked').length * 2 + sorted.filter(s => s.emailStatus?.adminReply?.status === 'opened').length * 1 + (sorted.some(s => s.source === 'download') ? 2 : 0) return { key: emailKey, email: latest.email ?? '', name: latest.name ?? '', source: latest.source, subscribe: sorted.some(s => s.subscribe), archived: sorted.every(s => s.archived === true), firstContactAt: oldest.submittedAt, latestAt: latest.submittedAt, message: latest.message || sorted.find(s => s.message)?.message || '', notes: sorted.find(s => s.notes)?.notes ?? '', tags: withTags?.tags ?? latest.tags ?? [], lastContactedAt: lastContactedMap.get(emailKey) ?? null, submissionCount: sorted.length, mainId: latest.id, allIds: sorted.map(s => s.id), allSubmissions: sorted, bestDeliveryStatus, unreplied, engagementScore, } }) .sort((a, b) => new Date(b.latestAt).getTime() - new Date(a.latestAt).getTime()) }, [submissions, replyHistory]) // ── All tags (for filter dropdown + autocomplete) ── const allTags = useMemo(() => { const set = new Set() for (const c of contacts) for (const t of c.tags) set.add(t) return [...set].sort() }, [contacts]) // ── Filtered list ── const filtered = useMemo(() => { return contacts.filter(c => { if (!showArchived && c.archived) return false if (tagFilter && !c.tags.includes(tagFilter)) return false if (!search.trim()) return true const q = search.toLowerCase() return ( c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q) || c.message.toLowerCase().includes(q) || c.notes.toLowerCase().includes(q) || c.tags.some(t => t.toLowerCase().includes(q)) ) }) }, [contacts, showArchived, tagFilter, search]) // ── Edit helpers ── function startEdit(c: Contact) { setEditingKey(c.key) setEditName(c.name) setEditNotes(c.notes) setEditTags([...c.tags]) setEditTagInput('') setMergePickerKey(null) setMergeMsg('') } function cancelEdit() { setEditingKey(null) setMergePickerKey(null) setMergeMsg('') } async function saveEdit(c: Contact) { setEditSaving(true) try { await fetch(`/api/admin-contact-submissions/${encodeURIComponent(c.mainId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ name: editName, notes: editNotes, tags: editTags }), }) setSubmissions(prev => prev.map(s => s.id === c.mainId ? { ...s, name: editName, notes: editNotes, tags: editTags } : (s.email?.trim().toLowerCase() === c.key ? { ...s, name: editName } : s) )) setEditingKey(null) } catch { /* silent */ } setEditSaving(false) } function addEditTag(tag: string) { const t = tag.trim().slice(0, 50) if (!t || editTags.includes(t)) return setEditTags(prev => [...prev, t]) setEditTagInput('') } function removeEditTag(tag: string) { setEditTags(prev => prev.filter(t => t !== tag)) } // ── Delete ── async function deleteContact(c: Contact) { const label = c.name || c.email || 'this contact' const plural = c.submissionCount > 1 ? `all ${c.submissionCount} submissions` : 'submission' if (!confirm(`Delete ${plural} from ${label}?`)) return await Promise.all( c.allIds.map(id => fetch(`/api/admin-contact-submissions/${encodeURIComponent(id)}`, { method: 'DELETE', credentials: 'include' }) ) ) await reload() } // ── Merge ── async function triggerDrip(c: Contact) { setDripBusyKey(c.key); setDripMsgKey(c.key); setDripMsgText('') try { const res = await fetch('/api/admin-contacts/trigger-drip', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: c.email }), }) const d = await res.json() as { ok?: boolean; note?: string; message?: string } setDripMsgText(res.ok ? (d.note ?? 'Drip triggered.') : (d.message ?? 'Failed.')) } catch { setDripMsgText('Network error.') } setDripBusyKey(null) } async function doMerge(keepContact: Contact, mergeContact: Contact) { if (!confirm(`Merge "${mergeContact.name || mergeContact.email}" into "${keepContact.name || keepContact.email}"? All messages from ${mergeContact.email} will be reassigned to ${keepContact.email}.`)) return setMergeBusy(true) try { const res = await fetch('/api/admin-contacts/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ keepEmail: keepContact.email, mergeEmail: mergeContact.email }), }) if (res.ok) { setMergeMsg('') setMergePickerKey(null) setEditingKey(null) flash(`Merged ${mergeContact.email} into ${keepContact.email}.`) await reload() } else { const d = await res.json() as { message?: string } setMergeMsg(d.message ?? 'Merge failed.') } } catch { setMergeMsg('Network error.') } setMergeBusy(false) } // ── Add contact ── async function handleAdd(e: React.FormEvent) { e.preventDefault(); setAddBusy(true); setAddError('') try { const tags = addTags.split(',').map(t => t.trim()).filter(Boolean) const res = await fetch('/api/admin-contact-submissions/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ name: addName, email: addEmail, notes: addNotes, tags }), }) const d = await res.json() as { ok?: boolean; message?: string } if (!res.ok) { setAddError(d.message ?? 'Failed to add.'); setAddBusy(false); return } setAddOpen(false); setAddName(''); setAddEmail(''); setAddNotes(''); setAddTags('') await reload() } catch { setAddError('Network error.') } setAddBusy(false) } // ── CSV import ── function handleFileChange(e: React.ChangeEvent) { const file = e.target.files?.[0] if (!file) return const reader = new FileReader() reader.onload = evt => { const text = evt.target?.result as string const rows = parseCSV(text) setImportPreview({ rows, filename: file.name }) } reader.readAsText(file) if (importFileRef.current) importFileRef.current.value = '' } async function doImport() { if (!importPreview) return setImportBusy(true); setImportMsg('') try { const res = await fetch('/api/admin-contacts/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ rows: importPreview.rows }), }) const d = await res.json() as { ok?: boolean; created?: number; skipped?: number; message?: string } if (!res.ok) { setImportMsg(d.message ?? 'Import failed.'); setImportBusy(false); return } setImportMsg(`Imported ${d.created} contact${d.created !== 1 ? 's' : ''}${d.skipped ? `, skipped ${d.skipped}` : ''}.`) setImportPreview(null) await reload() } catch { setImportMsg('Network error.') } setImportBusy(false) } // ── Flash ── function flash(msg: string) { setFlashMsg(msg) setTimeout(() => setFlashMsg(''), 3000) } // ── Conversation history builder ── function buildHistory(c: Contact): ConversationItem[] { const inbound: ConversationItem[] = c.allSubmissions.map(s => ({ kind: 'inbound', date: s.submittedAt, name: s.name, message: s.source === 'inbound-email' ? s.message.replace(/^Subject:\s*.+\n+/m, '').trim().slice(0, 400) : s.message.slice(0, 400), source: s.source, id: s.id, })) const outbound: ConversationItem[] = replyHistory .filter(r => r.toEmail?.trim().toLowerCase() === c.key) .map(r => ({ kind: 'outbound', date: r.scheduledAt ?? r.sentAt, subject: r.subject, preview: r.preview, toEmail: r.toEmail, })) return [...inbound, ...outbound].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) } // ── Source badge ── function sourceBadge(source: string | undefined) { if (source === 'manual') return manual if (source === 'inbound-email') return email if (source === 'download') return download return form } // ── Render ── return (
{/* Header */}
Contacts {contacts.length}
✉ Email Calendar ← Admin
{/* Import preview */} {importPreview && (
{importPreview.filename} — {importPreview.rows.length} row{importPreview.rows.length !== 1 ? 's' : ''} found {importPreview.rows.length > 0 && ( · columns: {Object.keys(importPreview.rows[0]).join(', ')} )}
{importMsg &&

{importMsg}

}
)} {importMsg && !importPreview &&
{importMsg}
} {flashMsg &&
{flashMsg}
} {/* Add contact form */} {addOpen && (

Add Contact