111b559865
- 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>
898 lines
40 KiB
TypeScript
898 lines
40 KiB
TypeScript
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>
|
||
)
|
||
}
|