import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' // ── Constants ──────────────────────────────────────────────────────────────── const REPLY_FROM_OPTIONS = [ { value: 'Verse by Verse with Nate ', label: 'hello@versebyversewithnate.us' }, { value: 'Verse by Verse with Nate ', label: 'nate@versebyversewithnate.us' }, ] // ── Types ──────────────────────────────────────────────────────────────────── interface ContactSubmission { id: string submittedAt: string name: string email: string message: string messageType: 'question' | 'testimony' | 'topic' | 'general' subscribe: boolean archived?: boolean source?: 'contact-form' | 'download' | 'inbound-email' inboundTo?: string htmlBody?: string | null messageId?: string } interface ReplyDraft { submissionId: string | null recipientName: string recipientEmail: string subject: string message: string fromAddress: string } interface ReplyTemplate { id: string label: string subject: string message: string } interface ReplyHistoryItem { id: string submissionId: string toEmail: string toName: string fromEmail: string subject: string preview: string sentAt: string } interface ReplyConfig { fromEmail: string fromIdentity: string resendApiConfigured: boolean canSendReplies: boolean note: string } interface EmailSettings { signature: string } // ── Helpers ────────────────────────────────────────────────────────────────── function extractSubject(message: string): string { const m = /^Subject:\s*(.+)/m.exec(message ?? '') return m ? m[1].trim() : '' } function extractBodyPreview(message: string): string { const afterSubject = message.replace(/^Subject:\s*.+\n+/m, '').trim() return afterSubject.slice(0, 200) } function formatDate(value: string | null | undefined): string { if (!value) return '—' const d = new Date(value) return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString() } function formatShortDate(value: string | null | undefined): string { if (!value) return '—' const d = new Date(value) if (Number.isNaN(d.getTime())) return '—' const now = new Date() const sameDay = d.toDateString() === now.toDateString() if (sameDay) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) const sameYear = d.getFullYear() === now.getFullYear() if (sameYear) return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) } function readIds(): Set { try { return new Set(JSON.parse(localStorage.getItem('em-read-ids') ?? '[]') as string[]) } catch { return new Set() } } function persistReadId(id: string) { try { const next = [...readIds(), id].slice(-1000) localStorage.setItem('em-read-ids', JSON.stringify(next)) } catch {} } // ── EmailClient ────────────────────────────────────────────────────────────── function EmailClient({ onLogout }: { onLogout: () => void }) { const [submissions, setSubmissions] = useState([]) const [loadStatus, setLoadStatus] = useState<'loading' | 'ready' | 'error'>('loading') const [mailbox, setMailbox] = useState<'inbox' | 'archived'>('inbox') const [selectedId, setSelectedId] = useState(null) const [search, setSearch] = useState('') const [localReadIds, setLocalReadIds] = useState>(readIds) const [replyDraft, setReplyDraft] = useState(null) const [replySending, setReplySending] = useState(false) const [replyMsg, setReplyMsg] = useState('') const [templates, setTemplates] = useState([]) const [history, setHistory] = useState([]) const [config, setConfig] = useState(null) const [templateStatus, setTemplateStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [showTemplatesMgr, setShowTemplatesMgr] = useState(false) const [showHistory, setShowHistory] = useState(false) const [showSettings, setShowSettings] = useState(false) const [emailSettings, setEmailSettings] = useState({ signature: 'Grace and peace,\nVerse by Verse with Nate' }) const [settingsSig, setSettingsSig] = useState('') const [settingsSaving, setSettingsSaving] = useState(false) const [settingsSaved, setSettingsSaved] = useState(false) const [actionMsg, setActionMsg] = useState('') const composeRef = useRef(null) const listRef = useRef(null) // ── Load ── const loadAll = useCallback(async () => { try { const [subRes, tplRes, histRes, cfgRes, settingsRes] = await Promise.all([ fetch('/api/admin-contact-submissions'), fetch('/api/admin-contact-reply-templates'), fetch('/api/admin-contact-reply-history'), fetch('/api/admin-reply-config'), fetch('/api/admin-email-settings'), ]) if (!subRes.ok) throw new Error('submissions failed') const subData = await subRes.json() as { submissions?: ContactSubmission[] } setSubmissions(subData.submissions ?? []) setLoadStatus('ready') if (tplRes.ok) { const d = await tplRes.json() as { templates?: ReplyTemplate[] } setTemplates(d.templates ?? []) } if (histRes.ok) { const d = await histRes.json() as { items?: ReplyHistoryItem[] } setHistory(d.items ?? []) } if (cfgRes.ok) setConfig(await cfgRes.json() as ReplyConfig) if (settingsRes.ok) { const s = await settingsRes.json() as EmailSettings setEmailSettings(s) } } catch { setLoadStatus('error') } }, []) useEffect(() => { loadAll() }, [loadAll]) // 30s poll for new messages useEffect(() => { const id = setInterval(async () => { try { const r = await fetch('/api/admin-contact-submissions') if (!r.ok) return const data = await r.json() as { submissions?: ContactSubmission[] } const fresh = data.submissions ?? [] setSubmissions(prev => { const freshById = new Map(fresh.map(s => [s.id, s])) const existingIds = new Set(prev.map(s => s.id)) const merged = prev.map(s => freshById.get(s.id) ?? s) const newOnes = fresh.filter(s => !existingIds.has(s.id)) return newOnes.length > 0 ? [...newOnes, ...merged] : merged }) } catch {} }, 30_000) return () => clearInterval(id) }, []) // ── Derived ── const filtered = useMemo(() => { const inMailbox = submissions.filter(s => mailbox === 'archived' ? s.archived === true : s.archived !== true) const q = search.trim().toLowerCase() if (!q) return inMailbox return inMailbox.filter(s => { const subj = extractSubject(s.message).toLowerCase() return ( s.name.toLowerCase().includes(q) || s.email.toLowerCase().includes(q) || subj.includes(q) || s.message.toLowerCase().includes(q) ) }) }, [submissions, mailbox, search]) const selected = filtered.find(s => s.id === selectedId) ?? null const inboxCount = submissions.filter(s => s.archived !== true).length const unreadCount = submissions.filter(s => s.archived !== true && !localReadIds.has(s.id)).length // Auto-select first on mailbox switch or after actions useEffect(() => { if (filtered.length === 0) { setSelectedId(null); return } if (!selectedId || !filtered.some(s => s.id === selectedId)) { setSelectedId(filtered[0].id) } }, [filtered, selectedId]) // Mark as read when selected useEffect(() => { if (!selectedId) return if (localReadIds.has(selectedId)) return persistReadId(selectedId) setLocalReadIds(prev => { const n = new Set(prev); n.add(selectedId); return n }) }, [selectedId, localReadIds]) // Auto-scroll selected into view in list useEffect(() => { if (!selectedId || !listRef.current) return const el = listRef.current.querySelector(`[data-id="${selectedId}"]`) as HTMLElement | null el?.scrollIntoView({ block: 'nearest' }) }, [selectedId]) // Focus compose textarea when reply opens useEffect(() => { if (replyDraft) setTimeout(() => composeRef.current?.focus(), 50) }, [replyDraft]) // ── Keyboard nav ── useEffect(() => { function onKey(e: KeyboardEvent) { const tag = (e.target as Element).tagName.toLowerCase() if (tag === 'input' || tag === 'textarea' || tag === 'select') return const idx = filtered.findIndex(s => s.id === selectedId) if (e.key === 'ArrowDown' || e.key === 'j') { e.preventDefault() if (idx < filtered.length - 1) setSelectedId(filtered[idx + 1].id) } else if (e.key === 'ArrowUp' || e.key === 'k') { e.preventDefault() if (idx > 0) setSelectedId(filtered[idx - 1].id) } else if (e.key === 'r' && selected && !replyDraft) { openReply(selected) } else if (e.key === 'Escape' && replyDraft) { setReplyDraft(null) } else if (e.key === 'e' && selected) { handleArchive(selected.id, !(selected.archived === true)) } } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) }, [filtered, selectedId, selected, replyDraft]) // ── Actions ── async function handleArchive(id: string, archive: boolean) { const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ archived: archive }), }) if (!res.ok) { flash('Failed to update.'); return } setSubmissions(prev => prev.map(s => s.id === id ? { ...s, archived: archive } : s)) flash(archive ? 'Archived.' : 'Moved to inbox.') } async function handleDelete(id: string) { if (!confirm('Delete this message permanently?')) return const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(id)}`, { method: 'DELETE' }) if (!res.ok) { flash('Failed to delete.'); return } setSubmissions(prev => prev.filter(s => s.id !== id)) flash('Message deleted.') } function flash(msg: string) { setActionMsg(msg) setTimeout(() => setActionMsg(''), 3000) } function openReply(submission: ContactSubmission) { const firstName = submission.name?.trim().split(/\s+/)[0] || 'there' const subject = extractSubject(submission.message) const inboundTo = submission.inboundTo ?? '' const defaultFrom = inboundTo.includes('nate@') ? REPLY_FROM_OPTIONS[1].value : REPLY_FROM_OPTIONS[0].value setReplyDraft({ submissionId: submission.id, recipientName: submission.name, recipientEmail: submission.email, subject: subject ? `Re: ${subject}` : 'Re: Your message', message: '', fromAddress: defaultFrom, }) setReplyMsg('') flash(`Composing reply to ${firstName}…`) } function openCompose() { setSelectedId(null) setReplyDraft({ submissionId: null, recipientName: '', recipientEmail: '', subject: '', message: '', fromAddress: REPLY_FROM_OPTIONS[0].value, }) setReplyMsg('') } function applyTemplate(templateId: string) { if (!replyDraft) return const tpl = templates.find(t => t.id === templateId) if (!tpl) return setReplyDraft({ ...replyDraft, message: tpl.message }) } async function handleSend() { if (!replyDraft) return setReplySending(true) setReplyMsg('') try { if (replyDraft.submissionId) { // Reply to existing submission const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(replyDraft.submissionId)}/reply`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subject: replyDraft.subject, message: replyDraft.message, fromAddress: replyDraft.fromAddress, }), }) if (!res.ok) { const d = await res.json().catch(() => ({})) as { message?: string } throw new Error(d.message ?? 'Failed to send.') } } else { // Compose new (no existing submission) const res = await fetch('/api/admin-email/compose', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ to: replyDraft.recipientEmail, toName: replyDraft.recipientName, subject: replyDraft.subject, message: replyDraft.message, fromAddress: replyDraft.fromAddress, }), }) if (!res.ok) { const d = await res.json().catch(() => ({})) as { message?: string } throw new Error(d.message ?? 'Failed to send.') } } const sentFrom = replyDraft.fromAddress.match(/<([^>]+)>/)?.[1] ?? replyDraft.fromAddress setReplyMsg(`Sent to ${replyDraft.recipientEmail} from ${sentFrom}.`) setReplyDraft(null) // Refresh history fetch('/api/admin-contact-reply-history') .then(r => r.ok ? r.json() : null) .then(d => { if (d) setHistory((d as { items?: ReplyHistoryItem[] }).items ?? []) }) .catch(() => {}) } catch (err) { setReplyMsg(err instanceof Error ? err.message : 'Failed to send.') } finally { setReplySending(false) } } async function handleSaveTemplates() { setTemplateStatus('saving') try { const res = await fetch('/api/admin-contact-reply-templates', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ templates }), }) if (!res.ok) throw new Error('Failed to save') setTemplateStatus('saved') } catch { setTemplateStatus('error') } } // ── Render ── return (
{/* Header */}
✉ Email Center {unreadCount > 0 && {unreadCount} new}
Contacts Calendar ← Admin
{/* Body */}
{/* Sidebar */} {/* Detail pane */}
{replyDraft && !replyDraft.submissionId ? ( /* ── Compose New ── */

New Message

setReplyDraft({ ...replyDraft, recipientEmail: e.target.value })} />
setReplyDraft({ ...replyDraft, recipientName: e.target.value })} />
setReplyDraft({ ...replyDraft, subject: e.target.value })} />
{templates.length > 0 && (
)}