Files
Siteforge/src/ContactsPage.tsx
T
nmemmert 111b559865 Add webhook outbound, Contact→Calendar, checklist automation; v1.1.26
- Webhook CRUD (GET/POST/DELETE /api/admin-webhooks, POST .../test) with
   Webhooks drawer in EmailPage; fires contact.new and reply.sent events
- Contact→Calendar: history panel shows checklist episodes within ±30 days
- Checklist automation: saveEdit auto-sets productionStatus (idea→scheduled
  for future dates, idea→published for past); ⚙ Tasks button generates
  Record (−14d) and Edit (−7d) calendar task events for scheduled episodes
- Audit log captures webhook-added, webhook-removed, webhook-test events
- Webhook infrastructure: state, disk persistence, fire-and-forget with
  AbortSignal.timeout(8000), loaded on startup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 08:03:30 -04:00

898 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string>[] {
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 <div className="em-auth-loading">Loading</div>
if (authState === 'needs-password') return (
<div className="em-auth-wrap">
<form className="em-auth-form" onSubmit={handleLogin}>
<h1 className="em-auth-title">Contacts</h1>
<label className="em-auth-label">Admin password<input type="password" className="em-auth-input" value={password} onChange={e => setPassword(e.target.value)} autoFocus /></label>
{authError && <p className="em-auth-error">{authError}</p>}
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>{authBusy ? 'Signing in…' : 'Sign in'}</button>
</form>
</div>
)
if (authState === 'needs-totp') return (
<div className="em-auth-wrap">
<form className="em-auth-form" onSubmit={handleTotp}>
<h1 className="em-auth-title">Two-factor code</h1>
<label className="em-auth-label">Authenticator code<input type="text" className="em-auth-input" inputMode="numeric" pattern="[0-9]*" maxLength={6} value={totp} onChange={e => setTotp(e.target.value)} autoFocus /></label>
{authError && <p className="em-auth-error">{authError}</p>}
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>{authBusy ? 'Verifying…' : 'Verify'}</button>
</form>
</div>
)
return <ContactsClient />
}
// ── Contacts Client ───────────────────────────────────────────────────────────
function ContactsClient() {
const [submissions, setSubmissions] = useState<ContactSubmission[]>([])
const [replyHistory, setReplyHistory] = useState<ReplyHistoryItem[]>([])
const [checklistEpisodes, setChecklistEpisodes] = useState<ChecklistEpisode[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [tagFilter, setTagFilter] = useState('')
const [showArchived, setShowArchived] = useState(false)
// Edit state
const [editingKey, setEditingKey] = useState<string | null>(null)
const [editName, setEditName] = useState('')
const [editNotes, setEditNotes] = useState('')
const [editTags, setEditTags] = useState<string[]>([])
const [editTagInput, setEditTagInput] = useState('')
const [editSaving, setEditSaving] = useState(false)
// Merge state
const [mergePickerKey, setMergePickerKey] = useState<string | null>(null)
const [mergeSearch, setMergeSearch] = useState('')
const [mergeBusy, setMergeBusy] = useState(false)
const [mergeMsg, setMergeMsg] = useState('')
// Drip trigger
const [dripBusyKey, setDripBusyKey] = useState<string | null>(null)
const [dripMsgKey, setDripMsgKey] = useState<string | null>(null)
const [dripMsgText, setDripMsgText] = useState('')
// History state
const [expandedHistoryKey, setExpandedHistoryKey] = useState<string | null>(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<string, string>[]; filename: string } | null>(null)
const importFileRef = useRef<HTMLInputElement>(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<string, ContactSubmission[]>()
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<string, string>()
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<string, number> = { 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<string>()
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<HTMLInputElement>) {
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 <span className="ct-badge ct-badge--manual">manual</span>
if (source === 'inbound-email') return <span className="ct-badge ct-badge--email">email</span>
if (source === 'download') return <span className="ct-badge ct-badge--download">download</span>
return <span className="ct-badge ct-badge--form">form</span>
}
// ── Render ──
return (
<div className="ct-app">
{/* Header */}
<header className="ct-header">
<div className="ct-header-brand">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Contacts
<span className="ct-header-count">{contacts.length}</span>
</div>
<div className="ct-header-actions">
<button type="button" className="em-btn em-btn--secondary em-btn--sm" onClick={() => { setAddOpen(o => !o); setAddError('') }}>
{addOpen ? 'Cancel' : '+ Add Contact'}
</button>
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => exportContactsCSV(filtered)} title="Export visible contacts to CSV">
Export CSV
</button>
<label className="em-btn em-btn--ghost em-btn--sm" style={{ cursor: 'pointer' }}>
Import CSV
<input ref={importFileRef} type="file" accept=".csv,text/csv" style={{ display: 'none' }} onChange={handleFileChange} />
</label>
<Link to="/email" className="em-btn em-btn--ghost em-btn--sm"> Email</Link>
<Link to="/calendar" className="em-btn em-btn--ghost em-btn--sm">Calendar</Link>
<Link to="/admin" className="em-btn em-btn--ghost em-btn--sm"> Admin</Link>
</div>
</header>
{/* Import preview */}
{importPreview && (
<div className="ct-import-banner">
<div className="ct-import-info">
<strong>{importPreview.filename}</strong> {importPreview.rows.length} row{importPreview.rows.length !== 1 ? 's' : ''} found
{importPreview.rows.length > 0 && (
<span className="ct-import-cols"> · columns: {Object.keys(importPreview.rows[0]).join(', ')}</span>
)}
</div>
<div className="ct-import-actions">
<button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={doImport} disabled={importBusy || importPreview.rows.length === 0}>
{importBusy ? 'Importing…' : `Import ${importPreview.rows.length} contacts`}
</button>
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => { setImportPreview(null); setImportMsg('') }}>Cancel</button>
</div>
{importMsg && <p className="ct-import-msg">{importMsg}</p>}
</div>
)}
{importMsg && !importPreview && <div className="ct-flash">{importMsg}</div>}
{flashMsg && <div className="ct-flash">{flashMsg}</div>}
{/* Add contact form */}
{addOpen && (
<div className="ct-add-banner">
<form className="ct-add-form" onSubmit={handleAdd}>
<h3 className="ct-add-title">Add Contact</h3>
<div className="ct-add-row">
<label className="ct-add-label">Name<input className="ct-input" type="text" placeholder="Full name" value={addName} onChange={e => setAddName(e.target.value)} autoFocus /></label>
<label className="ct-add-label">Email<input className="ct-input" type="email" placeholder="email@example.com" value={addEmail} onChange={e => setAddEmail(e.target.value)} /></label>
<label className="ct-add-label">Tags (comma-separated)<input className="ct-input" type="text" placeholder="listener, partner" value={addTags} onChange={e => setAddTags(e.target.value)} /></label>
</div>
<label className="ct-add-label ct-add-label--full">Notes<textarea className="ct-input ct-notes-input" placeholder="Notes (optional)" value={addNotes} onChange={e => setAddNotes(e.target.value)} rows={2} /></label>
{addError && <p className="ct-error">{addError}</p>}
<div className="ct-add-actions">
<button type="submit" className="em-btn em-btn--primary em-btn--sm" disabled={addBusy}>{addBusy ? 'Adding…' : 'Add Contact'}</button>
</div>
</form>
</div>
)}
{/* Toolbar */}
<div className="ct-toolbar">
<input type="search" className="ct-search" placeholder="Search name, email, notes, tags…" value={search} onChange={e => setSearch(e.target.value)} />
<select className="ct-tag-filter" value={tagFilter} onChange={e => setTagFilter(e.target.value)}>
<option value="">All tags</option>
{allTags.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<label className="ct-archived-toggle">
<input type="checkbox" checked={showArchived} onChange={e => setShowArchived(e.target.checked)} />
Show archived
</label>
<span className="ct-count-label">{filtered.length} contact{filtered.length !== 1 ? 's' : ''}</span>
</div>
{/* Contact list */}
<div className="ct-list">
{loading && <p className="ct-empty">Loading contacts</p>}
{!loading && filtered.length === 0 && (
<p className="ct-empty">{search || tagFilter ? 'No contacts match.' : 'No contacts yet.'}</p>
)}
{filtered.map(c => {
const isEditing = editingKey === c.key
const historyOpen = expandedHistoryKey === c.key
const history = historyOpen ? buildHistory(c) : []
const isMergeTarget = mergePickerKey === c.key
return (
<div key={c.mainId} className={`ct-card${c.archived ? ' ct-card--archived' : ''}`}>
<div className="ct-avatar" aria-hidden="true">
{(c.name || c.email || '?').charAt(0).toUpperCase()}
</div>
<div className="ct-card-body">
{isEditing ? (
/* ── Edit mode ── */
<div className="ct-edit-form">
<div className="ct-edit-row">
<label className="ct-edit-label">Name<input className="ct-input" type="text" value={editName} onChange={e => setEditName(e.target.value)} autoFocus /></label>
</div>
<label className="ct-edit-label">
Tags
<div className="ct-tag-editor">
{editTags.map(t => (
<span key={t} className="ct-tag" style={{ background: tagBg(t) }}>
{t}
<button type="button" className="ct-tag-remove" onClick={() => removeEditTag(t)} aria-label={`Remove ${t}`}>×</button>
</span>
))}
<input
className="ct-tag-input"
type="text"
placeholder="Add tag…"
value={editTagInput}
list="ct-tag-suggestions"
onChange={e => setEditTagInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addEditTag(editTagInput) }
else if (e.key === 'Backspace' && !editTagInput && editTags.length) removeEditTag(editTags[editTags.length - 1])
}}
/>
<datalist id="ct-tag-suggestions">
{allTags.filter(t => !editTags.includes(t)).map(t => <option key={t} value={t} />)}
</datalist>
</div>
</label>
<label className="ct-edit-label">Notes<textarea className="ct-input ct-notes-input" rows={3} value={editNotes} onChange={e => setEditNotes(e.target.value)} placeholder="Add a note…" /></label>
{/* Merge picker */}
{isMergeTarget && (
<div className="ct-merge-picker">
<p className="ct-merge-label">Merge another contact into <strong>{c.name || c.email}</strong>:</p>
<input className="ct-input ct-merge-search" type="search" placeholder="Search contacts to merge…" value={mergeSearch} autoFocus onChange={e => setMergeSearch(e.target.value)} />
<div className="ct-merge-list">
{contacts
.filter(other =>
other.key !== c.key &&
(!mergeSearch || other.name.toLowerCase().includes(mergeSearch.toLowerCase()) || other.email.toLowerCase().includes(mergeSearch.toLowerCase()))
)
.slice(0, 20)
.map(other => (
<button key={other.key} type="button" className="ct-merge-option" onClick={() => doMerge(c, other)} disabled={mergeBusy}>
<span className="ct-merge-name">{other.name || <em>No name</em>}</span>
<span className="ct-merge-email">{other.email}</span>
<span className="ct-merge-count">{other.submissionCount} msg{other.submissionCount !== 1 ? 's' : ''}</span>
</button>
))
}
</div>
{mergeMsg && <p className="ct-error">{mergeMsg}</p>}
</div>
)}
<div className="ct-edit-actions">
<button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={() => saveEdit(c)} disabled={editSaving}>{editSaving ? 'Saving…' : 'Save'}</button>
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => { setMergePickerKey(prev => prev === c.key ? null : c.key); setMergeSearch(''); setMergeMsg('') }}>
{isMergeTarget ? 'Cancel merge' : 'Merge with…'}
</button>
{c.email && (
<button type="button" className="em-btn em-btn--ghost em-btn--sm" title="Sync to Resend audience and trigger drip automation" onClick={() => triggerDrip(c)} disabled={dripBusyKey === c.key}>
{dripBusyKey === c.key ? 'Triggering…' : '▶ Drip'}
</button>
)}
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={cancelEdit}>Cancel</button>
</div>
{dripMsgKey === c.key && dripMsgText && (
<p className="ct-drip-msg">{dripMsgText}</p>
)}
</div>
) : (
/* ── Display mode ── */
<>
<div className="ct-card-top">
<span className="ct-card-name">{c.name || <em>No name</em>}</span>
{c.email && <a className="ct-card-email" href={`mailto:${c.email}`}>{c.email}</a>}
{sourceBadge(c.source)}
{c.subscribe && <span className="ct-badge ct-badge--sub">subscriber</span>}
{c.unreplied && <span className="ct-badge ct-badge--unreplied" title="No reply sent yet">needs reply</span>}
{c.bestDeliveryStatus && (
<span className={`ct-delivery-pill ct-delivery-pill--${c.bestDeliveryStatus}`}>
{c.bestDeliveryStatus === 'clicked' ? '🔗 clicked' : c.bestDeliveryStatus === 'opened' ? '👁 opened' : c.bestDeliveryStatus === 'delivered' ? '✓ delivered' : '→ sent'}
</span>
)}
{c.engagementScore >= 3 && <span className="ct-engage-badge" title={`Engagement score: ${c.engagementScore}`}>{'★'.repeat(Math.min(3, Math.floor(c.engagementScore / 3)))}</span>}
{c.submissionCount > 1 && <span className="ct-count-badge">{c.submissionCount}</span>}
</div>
{/* Tags row */}
{c.tags.length > 0 && (
<div className="ct-tags-row">
{c.tags.map(t => (
<span key={t} className="ct-tag" style={{ background: tagBg(t) }}>{t}</span>
))}
</div>
)}
<div className="ct-card-meta-row">
<span className="ct-card-date">
First: {fmtDate(c.firstContactAt)}
{c.lastContactedAt && <> · <span className="ct-last-contacted">Last replied: {fmtShort(c.lastContactedAt)}</span></>}
</span>
</div>
{c.notes && <p className="ct-card-notes">{c.notes}</p>}
{c.message && (
<p className="ct-card-preview">
{c.message.length > 100 ? c.message.slice(0, 100) + '…' : c.message}
</p>
)}
</>
)}
</div>
{/* Actions */}
{!isEditing && (
<div className="ct-card-actions">
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => startEdit(c)}>Edit</button>
<button
type="button"
className={`em-btn em-btn--sm ${historyOpen ? 'em-btn--secondary' : 'em-btn--ghost'}`}
onClick={() => setExpandedHistoryKey(prev => prev === c.key ? null : c.key)}
>
History{c.submissionCount > 1 || replyHistory.some(r => r.toEmail?.trim().toLowerCase() === c.key) ? ` (${c.submissionCount})` : ''}
</button>
<button type="button" className="em-btn em-btn--danger em-btn--sm" onClick={() => deleteContact(c)}>Delete</button>
</div>
)}
{/* Conversation history panel */}
{historyOpen && !isEditing && (() => {
const relatedEps = checklistEpisodes.filter(ep => {
if (!ep.datePublished) return false
const epMs = new Date(ep.datePublished + 'T12:00:00').getTime()
const refMs = new Date(c.latestAt).getTime()
return Math.abs(epMs - refMs) <= 30 * 24 * 60 * 60 * 1000
})
return (
<div className="ct-history-panel">
{history.length === 0 && relatedEps.length === 0 && <p className="ct-history-empty">No conversation history.</p>}
{history.map((item, i) => (
item.kind === 'inbound' ? (
<div key={item.id || i} className="ct-history-item ct-history-item--in">
<div className="ct-history-meta">
<span className="ct-history-who">{item.name}</span>
<span className="ct-history-date">{fmtShort(item.date)}</span>
</div>
<p className="ct-history-body">{item.message || '(no message body)'}</p>
</div>
) : (
<div key={i} className="ct-history-item ct-history-item--out">
<div className="ct-history-meta">
<span className="ct-history-who">You {item.toEmail}</span>
<span className="ct-history-date">{fmtShort(item.date)}</span>
</div>
<div className="ct-history-subject">{item.subject}</div>
<p className="ct-history-body">{item.preview}</p>
</div>
)
))}
{relatedEps.length > 0 && (
<div className="ct-history-ep-section">
<p className="ct-history-ep-label">📅 Episodes near this contact</p>
{relatedEps.map(ep => {
const parts = [ep.series, ep.episodeNumber != null ? `Ep. ${ep.episodeNumber}` : null, ep.title].filter(Boolean)
return (
<div key={ep.id} className="ct-history-item ct-history-item--ep">
<div className="ct-history-meta">
<span className="ct-history-who">{parts.join(' ') || 'Untitled episode'}</span>
<span className="ct-history-date">{ep.datePublished}</span>
</div>
</div>
)
})}
</div>
)}
</div>
)
})()}
</div>
)
})}
</div>
</div>
)
}