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 (
)
}
if (authState === 'needs-totp') {
return (
)
}
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 (
{addOpen && (
)}
setSearch(e.target.value)}
/>
{filtered.length} contact{filtered.length !== 1 ? 's' : ''}
{loading &&
Loading contacts…
}
{!loading && filtered.length === 0 && (
{search ? 'No contacts match your search.' : 'No contacts yet. Add one above.'}
)}
{filtered.map(c => (
{(c.name || c.email || '?').charAt(0).toUpperCase()}
{editingId === c.mainId ? (
) : (
<>
{c.name || No name}
{c.email && (
{c.email}
)}
{sourceBadge(c.source)}
{c.subscribe &&
subscriber}
{c.submissionCount > 1 && (
{c.submissionCount}
)}
{c.notes || 'No notes — click Edit to add'}
{fmtDate(c.submittedAt)}
{c.message && (
{c.message.length > 90 ? c.message.slice(0, 90) + '…' : c.message}
)}
>
)}
{editingId !== c.mainId && (
)}
))}
)
}