import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' // ── Types ──────────────────────────────────────────────────────────────────── interface ContactSubmission { id: string submittedAt: string name: string email: string message: string messageType: string subscribe: boolean archived?: boolean source?: string inboundTo?: string notes?: string } interface Contact { key: string email: string name: string source: string | undefined inboundTo: string | undefined subscribe: boolean archived: boolean submittedAt: string message: string notes: string submissionCount: number mainId: string allIds: string[] } // ── 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) useEffect(() => { fetch('/api/admin-auth/status', { credentials: 'include' }) .then(r => r.json()) .then((data: { authenticated?: boolean }) => { setAuthState(data.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; requiresTOTP?: boolean; message?: string } if (!res.ok) { setAuthError(data.message ?? 'Invalid password.'); setAuthBusy(false); return } if (data.requiresTOTP) { 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', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: 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 [loading, setLoading] = useState(true) const [search, setSearch] = useState('') const [showArchived, setShowArchived] = useState(false) const [editingId, setEditingId] = useState(null) const [editName, setEditName] = useState('') const [editNotes, setEditNotes] = useState('') const [editSaving, setEditSaving] = useState(false) const [addOpen, setAddOpen] = useState(false) const [addName, setAddName] = useState('') const [addEmail, setAddEmail] = useState('') const [addNotes, setAddNotes] = useState('') const [addBusy, setAddBusy] = useState(false) const [addError, setAddError] = useState('') const reload = useCallback(async () => { try { const res = await fetch('/api/admin-contact-submissions', { credentials: 'include' }) if (res.ok) { const data = await res.json() as { submissions: ContactSubmission[] } setSubmissions(data.submissions ?? []) } } catch { /* silent */ } setLoading(false) }, []) useEffect(() => { reload() }, [reload]) const contacts: Contact[] = useMemo(() => { 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) } 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] return { key: latest.email?.trim().toLowerCase() || latest.id, email: latest.email, name: latest.name, source: latest.source, inboundTo: latest.inboundTo, subscribe: sorted.some(s => s.subscribe), archived: sorted.every(s => s.archived === true), submittedAt: latest.submittedAt, message: latest.message || sorted.find(s => s.message)?.message || '', notes: latest.notes ?? '', submissionCount: sorted.length, mainId: latest.id, allIds: sorted.map(s => s.id), } }) .sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime()) }, [submissions]) const filtered = contacts.filter(c => { if (!showArchived && c.archived) 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) ) }) function startEdit(c: Contact) { setEditingId(c.mainId) setEditName(c.name) setEditNotes(c.notes) } async function saveEdit() { if (!editingId) return setEditSaving(true) await fetch(`/api/admin-contact-submissions/${encodeURIComponent(editingId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ name: editName, notes: editNotes }), }) setEditSaving(false) setEditingId(null) await reload() } 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() } async function handleAdd(e: React.FormEvent) { e.preventDefault() setAddBusy(true) setAddError('') try { 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 }), }) const data = await res.json() as { ok?: boolean; message?: string } if (!res.ok) { setAddError(data.message ?? 'Failed to add contact.'); setAddBusy(false); return } setAddOpen(false) setAddName('') setAddEmail('') setAddNotes('') await reload() } catch { setAddError('Network error.') } setAddBusy(false) } function sourceBadge(source: string | undefined) { if (source === 'manual') return manual if (source === 'inbound-email') return email if (source === 'download') return download return form } function fmtDate(iso: string) { try { return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) } catch { return '—' } } return (
Contacts {contacts.length}
✉ Email Calendar ← Admin
{addOpen && (

Add Contact