0161a0dacd
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1000 lines
42 KiB
TypeScript
1000 lines
42 KiB
TypeScript
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 <hello@versebyversewithnate.us>', label: 'hello@versebyversewithnate.us' },
|
||
{ value: 'Verse by Verse with Nate <nate@versebyversewithnate.us>', 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<string> {
|
||
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<ContactSubmission[]>([])
|
||
const [loadStatus, setLoadStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||
const [mailbox, setMailbox] = useState<'inbox' | 'archived'>('inbox')
|
||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||
const [search, setSearch] = useState('')
|
||
const [localReadIds, setLocalReadIds] = useState<Set<string>>(readIds)
|
||
const [replyDraft, setReplyDraft] = useState<ReplyDraft | null>(null)
|
||
const [replySending, setReplySending] = useState(false)
|
||
const [replyMsg, setReplyMsg] = useState('')
|
||
const [templates, setTemplates] = useState<ReplyTemplate[]>([])
|
||
const [history, setHistory] = useState<ReplyHistoryItem[]>([])
|
||
const [config, setConfig] = useState<ReplyConfig | null>(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<EmailSettings>({ 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<HTMLTextAreaElement>(null)
|
||
const listRef = useRef<HTMLElement>(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 (
|
||
<div className="em-app">
|
||
{/* Header */}
|
||
<header className="em-header">
|
||
<div className="em-header-left">
|
||
<span className="em-header-brand">✉ Email Center</span>
|
||
{unreadCount > 0 && <span className="em-badge em-badge--alert">{unreadCount} new</span>}
|
||
</div>
|
||
<div className="em-header-actions">
|
||
<button type="button" className="em-btn em-btn--primary" onClick={openCompose}>
|
||
Compose
|
||
</button>
|
||
<Link to="/contacts" className="em-btn em-btn--ghost">Contacts</Link>
|
||
<Link to="/calendar" className="em-btn em-btn--ghost">Calendar</Link>
|
||
<Link to="/admin" className="em-btn em-btn--ghost">← Admin</Link>
|
||
<button type="button" className="em-btn em-btn--ghost" onClick={onLogout}>Log Out</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Body */}
|
||
<div className="em-body">
|
||
{/* Sidebar */}
|
||
<aside className="em-sidebar">
|
||
{/* Search */}
|
||
<div className="em-search-wrap">
|
||
<input
|
||
className="em-search"
|
||
type="search"
|
||
placeholder="Search…"
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Mailbox tabs */}
|
||
<div className="em-mailbox-tabs">
|
||
<button
|
||
type="button"
|
||
className={`em-mailbox-tab${mailbox === 'inbox' ? ' em-mailbox-tab--active' : ''}`}
|
||
onClick={() => { setMailbox('inbox'); setSearch('') }}
|
||
>
|
||
Inbox
|
||
{inboxCount > 0 && (
|
||
<span className={`em-tab-badge${unreadCount > 0 ? ' em-tab-badge--unread' : ''}`}>
|
||
{inboxCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`em-mailbox-tab${mailbox === 'archived' ? ' em-mailbox-tab--active' : ''}`}
|
||
onClick={() => { setMailbox('archived'); setSearch('') }}
|
||
>
|
||
Archived
|
||
</button>
|
||
</div>
|
||
|
||
{/* List */}
|
||
<section className="em-list" ref={listRef} aria-label="Messages">
|
||
{loadStatus === 'loading' && <p className="em-list-empty">Loading…</p>}
|
||
{loadStatus === 'error' && <p className="em-list-empty">Could not load messages.</p>}
|
||
{loadStatus === 'ready' && filtered.length === 0 && (
|
||
<p className="em-list-empty">{search ? 'No results.' : 'No messages here.'}</p>
|
||
)}
|
||
{filtered.map(item => {
|
||
const subject = item.source === 'inbound-email' ? extractSubject(item.message) : ''
|
||
const preview = item.source === 'inbound-email' ? extractBodyPreview(item.message) : item.message
|
||
const isUnread = !localReadIds.has(item.id)
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
data-id={item.id}
|
||
type="button"
|
||
className={`em-list-item${selectedId === item.id ? ' em-list-item--active' : ''}${isUnread ? ' em-list-item--unread' : ''}`}
|
||
onClick={() => setSelectedId(item.id)}
|
||
>
|
||
{isUnread && <span className="em-unread-dot" aria-label="Unread" />}
|
||
<div className="em-list-item-head">
|
||
<strong className="em-list-item-name">{item.name}</strong>
|
||
<span className="em-list-item-date">{formatShortDate(item.submittedAt)}</span>
|
||
</div>
|
||
{subject && <div className="em-list-item-subject">{subject}</div>}
|
||
<div className="em-list-item-preview">{preview}</div>
|
||
{item.source === 'inbound-email' && (
|
||
<span className="em-source-badge em-source-badge--inbound">
|
||
{item.inboundTo?.includes('nate@') ? 'nate@' : 'hello@'}
|
||
</span>
|
||
)}
|
||
</button>
|
||
)
|
||
})}
|
||
</section>
|
||
</aside>
|
||
|
||
{/* Detail pane */}
|
||
<main className="em-detail">
|
||
{replyDraft && !replyDraft.submissionId ? (
|
||
/* ── Compose New ── */
|
||
<div className="em-compose-new">
|
||
<div className="em-detail-header">
|
||
<h2 className="em-detail-subject">New Message</h2>
|
||
</div>
|
||
<div className="em-compose-fields">
|
||
<div className="em-compose-row">
|
||
<label className="em-compose-label">To</label>
|
||
<input
|
||
className="em-compose-input"
|
||
type="email"
|
||
placeholder="recipient@example.com"
|
||
value={replyDraft.recipientEmail}
|
||
onChange={e => setReplyDraft({ ...replyDraft, recipientEmail: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="em-compose-row">
|
||
<label className="em-compose-label">Name</label>
|
||
<input
|
||
className="em-compose-input"
|
||
type="text"
|
||
placeholder="Recipient name (optional)"
|
||
value={replyDraft.recipientName}
|
||
onChange={e => setReplyDraft({ ...replyDraft, recipientName: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="em-compose-row">
|
||
<label className="em-compose-label">From</label>
|
||
<select
|
||
className="em-compose-select"
|
||
value={replyDraft.fromAddress}
|
||
onChange={e => setReplyDraft({ ...replyDraft, fromAddress: e.target.value })}
|
||
>
|
||
{REPLY_FROM_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="em-compose-row">
|
||
<label className="em-compose-label">Subject</label>
|
||
<input
|
||
className="em-compose-input"
|
||
type="text"
|
||
value={replyDraft.subject}
|
||
onChange={e => setReplyDraft({ ...replyDraft, subject: e.target.value })}
|
||
/>
|
||
</div>
|
||
{templates.length > 0 && (
|
||
<div className="em-compose-row">
|
||
<label className="em-compose-label">Template</label>
|
||
<select className="em-compose-select" defaultValue="" onChange={e => applyTemplate(e.target.value)}>
|
||
<option value="">Choose a template…</option>
|
||
{templates.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
|
||
</select>
|
||
</div>
|
||
)}
|
||
<textarea
|
||
ref={composeRef}
|
||
className="em-compose-body"
|
||
rows={12}
|
||
placeholder="Type your message…"
|
||
value={replyDraft.message}
|
||
onChange={e => setReplyDraft({ ...replyDraft, message: e.target.value })}
|
||
/>
|
||
<div className="em-compose-signature">
|
||
<span className="em-compose-signature-text">{emailSettings.signature}</span>
|
||
<span className="em-compose-signature-note">auto-appended · <button type="button" className="em-sig-edit-link" onClick={() => { setSettingsSig(emailSettings.signature); setShowSettings(true) }}>edit signature</button></span>
|
||
</div>
|
||
<div className="em-compose-actions">
|
||
<button type="button" className="em-btn em-btn--primary" onClick={handleSend} disabled={replySending}>
|
||
{replySending ? 'Sending…' : 'Send'}
|
||
</button>
|
||
<button type="button" className="em-btn em-btn--ghost" onClick={() => setReplyDraft(null)}>Discard</button>
|
||
</div>
|
||
{replyMsg && <p className="em-status-msg">{replyMsg}</p>}
|
||
</div>
|
||
</div>
|
||
) : !selected ? (
|
||
/* ── Empty state ── */
|
||
<div className="em-empty-state">
|
||
<div className="em-empty-icon">✉</div>
|
||
<p>Select a message to read it.</p>
|
||
<p className="em-empty-hint">↑ ↓ or j / k to navigate · r to reply · e to archive</p>
|
||
</div>
|
||
) : (
|
||
/* ── Message detail ── */
|
||
<>
|
||
<div className="em-detail-header">
|
||
<div className="em-detail-meta-row">
|
||
<div className="em-detail-from">
|
||
<div className="em-detail-avatar">{(selected.name || '?')[0].toUpperCase()}</div>
|
||
<div>
|
||
<div className="em-detail-from-name">{selected.name}</div>
|
||
<div className="em-detail-from-email">{selected.email}</div>
|
||
</div>
|
||
</div>
|
||
<div className="em-detail-meta-right">
|
||
<div className="em-detail-date">{formatDate(selected.submittedAt)}</div>
|
||
{selected.source === 'inbound-email' && selected.inboundTo && (
|
||
<div className="em-detail-to">to: {selected.inboundTo}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="em-detail-subject-row">
|
||
<h2 className="em-detail-subject">
|
||
{selected.source === 'inbound-email'
|
||
? extractSubject(selected.message) || selected.messageType
|
||
: selected.messageType}
|
||
</h2>
|
||
<div className="em-detail-badges">
|
||
{selected.source && (
|
||
<span className={`em-source-badge em-source-badge--${selected.source}`}>
|
||
{selected.source === 'inbound-email' ? 'email' : selected.source === 'contact-form' ? 'contact form' : selected.source}
|
||
</span>
|
||
)}
|
||
{selected.subscribe && <span className="em-source-badge em-source-badge--subscribed">subscribed</span>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="em-detail-body">
|
||
{selected.htmlBody ? (
|
||
<iframe
|
||
srcDoc={selected.htmlBody}
|
||
sandbox="allow-same-origin"
|
||
className="em-detail-iframe"
|
||
onLoad={e => {
|
||
const f = e.currentTarget
|
||
const h = f.contentDocument?.documentElement?.scrollHeight
|
||
if (h) f.style.height = `${h + 24}px`
|
||
}}
|
||
/>
|
||
) : (
|
||
<pre className="em-detail-plain">{
|
||
selected.source === 'inbound-email'
|
||
? selected.message.replace(/^Subject:\s*.+\n+/m, '').trim()
|
||
: selected.message
|
||
}</pre>
|
||
)}
|
||
</div>
|
||
|
||
<div className="em-detail-footer">
|
||
<div className="em-detail-actions">
|
||
<button type="button" className="em-btn em-btn--primary" onClick={() => openReply(selected)}>
|
||
Reply
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="em-btn em-btn--secondary"
|
||
onClick={() => handleArchive(selected.id, !(selected.archived === true))}
|
||
>
|
||
{selected.archived ? 'Move to Inbox' : 'Archive'}
|
||
</button>
|
||
<button type="button" className="em-btn em-btn--danger" onClick={() => handleDelete(selected.id)}>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
{actionMsg && !replyDraft && <p className="em-status-msg">{actionMsg}</p>}
|
||
</div>
|
||
|
||
{/* Inline reply composer */}
|
||
{replyDraft && replyDraft.submissionId === selected.id && (
|
||
<div className="em-reply-composer">
|
||
<div className="em-compose-header">
|
||
<span className="em-compose-header-label">Reply to {replyDraft.recipientName}</span>
|
||
<button type="button" className="em-compose-close" onClick={() => setReplyDraft(null)} aria-label="Close">×</button>
|
||
</div>
|
||
<div className="em-compose-fields">
|
||
<div className="em-compose-row em-compose-row--inline">
|
||
<label className="em-compose-label">From</label>
|
||
<select
|
||
className="em-compose-select"
|
||
value={replyDraft.fromAddress}
|
||
onChange={e => setReplyDraft({ ...replyDraft, fromAddress: e.target.value })}
|
||
>
|
||
{REPLY_FROM_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||
</select>
|
||
{templates.length > 0 && (
|
||
<>
|
||
<label className="em-compose-label">Template</label>
|
||
<select className="em-compose-select" defaultValue="" onChange={e => applyTemplate(e.target.value)}>
|
||
<option value="">Choose…</option>
|
||
{templates.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
|
||
</select>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="em-compose-row">
|
||
<label className="em-compose-label">Subject</label>
|
||
<input
|
||
className="em-compose-input"
|
||
type="text"
|
||
value={replyDraft.subject}
|
||
onChange={e => setReplyDraft({ ...replyDraft, subject: e.target.value })}
|
||
/>
|
||
</div>
|
||
<textarea
|
||
ref={composeRef}
|
||
className="em-compose-body"
|
||
rows={7}
|
||
placeholder="Type your reply…"
|
||
value={replyDraft.message}
|
||
onChange={e => setReplyDraft({ ...replyDraft, message: e.target.value })}
|
||
/>
|
||
<div className="em-compose-signature">
|
||
Grace and peace, · Verse by Verse with Nate
|
||
<span className="em-compose-signature-note">auto-appended</span>
|
||
</div>
|
||
<div className="em-compose-actions">
|
||
<button type="button" className="em-btn em-btn--primary" onClick={handleSend} disabled={replySending}>
|
||
{replySending ? 'Sending…' : 'Send Reply'}
|
||
</button>
|
||
<button type="button" className="em-btn em-btn--ghost" onClick={() => setReplyDraft(null)}>Cancel</button>
|
||
{replyMsg && <span className="em-status-msg em-status-msg--inline">{replyMsg}</span>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</main>
|
||
</div>
|
||
|
||
{/* Templates manager — slide-up drawer */}
|
||
{showTemplatesMgr && (
|
||
<div className="em-drawer-overlay" onClick={e => { if (e.target === e.currentTarget) setShowTemplatesMgr(false) }}>
|
||
<div className="em-drawer">
|
||
<div className="em-drawer-header">
|
||
<h3>Reply Templates</h3>
|
||
<button type="button" className="em-compose-close" onClick={() => setShowTemplatesMgr(false)}>×</button>
|
||
</div>
|
||
<div className="em-drawer-body">
|
||
{templates.length === 0 && <p className="em-list-empty">No templates yet.</p>}
|
||
{templates.map((tpl, i) => (
|
||
<div key={tpl.id} className="em-template-card">
|
||
<input
|
||
className="em-compose-input"
|
||
placeholder="Template label"
|
||
value={tpl.label}
|
||
onChange={e => setTemplates(prev => prev.map((t, j) => j === i ? { ...t, label: e.target.value } : t))}
|
||
/>
|
||
<input
|
||
className="em-compose-input"
|
||
placeholder="Subject (optional)"
|
||
value={tpl.subject}
|
||
onChange={e => setTemplates(prev => prev.map((t, j) => j === i ? { ...t, subject: e.target.value } : t))}
|
||
/>
|
||
<textarea
|
||
className="em-compose-body"
|
||
rows={4}
|
||
placeholder="Message body"
|
||
value={tpl.message}
|
||
onChange={e => setTemplates(prev => prev.map((t, j) => j === i ? { ...t, message: e.target.value } : t))}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="em-btn em-btn--danger em-btn--sm"
|
||
onClick={() => setTemplates(prev => prev.filter((_, j) => j !== i))}
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
))}
|
||
<div className="em-drawer-actions">
|
||
<button
|
||
type="button"
|
||
className="em-btn em-btn--ghost"
|
||
onClick={() => setTemplates(prev => [...prev, { id: Date.now().toString(36), label: '', subject: '', message: '' }])}
|
||
>
|
||
Add Template
|
||
</button>
|
||
<button type="button" className="em-btn em-btn--primary" onClick={handleSaveTemplates} disabled={templateStatus === 'saving'}>
|
||
{templateStatus === 'saving' ? 'Saving…' : templateStatus === 'saved' ? 'Saved ✓' : 'Save Templates'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Sent history drawer */}
|
||
{showHistory && (
|
||
<div className="em-drawer-overlay" onClick={e => { if (e.target === e.currentTarget) setShowHistory(false) }}>
|
||
<div className="em-drawer em-drawer--wide">
|
||
<div className="em-drawer-header">
|
||
<h3>Reply History</h3>
|
||
<button type="button" className="em-compose-close" onClick={() => setShowHistory(false)}>×</button>
|
||
</div>
|
||
<div className="em-drawer-body">
|
||
{history.length === 0 ? (
|
||
<p className="em-list-empty">No replies sent yet.</p>
|
||
) : (
|
||
<div className="em-history-list">
|
||
{history.map(item => (
|
||
<div key={item.id} className="em-history-item">
|
||
<div className="em-history-meta">
|
||
<span className="em-history-date">{formatDate(item.sentAt)}</span>
|
||
<span className="em-history-flow">from {item.fromEmail} → {item.toName} ({item.toEmail})</span>
|
||
</div>
|
||
<div className="em-history-subject">{item.subject}</div>
|
||
<div className="em-history-preview">{item.preview}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Settings drawer */}
|
||
{showSettings && (
|
||
<div className="em-drawer-overlay" onClick={e => { if (e.target === e.currentTarget) setShowSettings(false) }}>
|
||
<div className="em-drawer">
|
||
<div className="em-drawer-header">
|
||
<h3>Email Settings</h3>
|
||
<button type="button" className="em-compose-close" onClick={() => setShowSettings(false)}>×</button>
|
||
</div>
|
||
<div className="em-drawer-body">
|
||
<div className="em-settings-section">
|
||
<label className="em-settings-label">
|
||
Outgoing Signature
|
||
<p className="em-settings-note">Appended to every reply and compose email you send.</p>
|
||
<textarea
|
||
className="em-compose-body em-settings-sig-textarea"
|
||
rows={4}
|
||
value={settingsSig}
|
||
onChange={e => { setSettingsSig(e.target.value); setSettingsSaved(false) }}
|
||
placeholder="Grace and peace, Verse by Verse with Nate"
|
||
/>
|
||
</label>
|
||
</div>
|
||
{config && (
|
||
<div className="em-settings-section">
|
||
<p className="em-settings-label">
|
||
Email Configuration
|
||
<span className={`em-settings-status${config.canSendReplies ? ' em-settings-status--ok' : ' em-settings-status--warn'}`}>
|
||
{config.canSendReplies ? 'Configured' : 'Not configured'}
|
||
</span>
|
||
</p>
|
||
<p className="em-settings-note">{config.note}</p>
|
||
{config.fromEmail && <p className="em-settings-note">Reply-to: {config.fromEmail}</p>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="em-drawer-actions">
|
||
<button
|
||
type="button"
|
||
className="em-btn em-btn--primary"
|
||
disabled={settingsSaving}
|
||
onClick={async () => {
|
||
setSettingsSaving(true)
|
||
try {
|
||
const res = await fetch('/api/admin-email-settings', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ signature: settingsSig }),
|
||
})
|
||
if (res.ok) {
|
||
const data = await res.json() as { settings?: EmailSettings }
|
||
if (data.settings) setEmailSettings(data.settings)
|
||
setSettingsSaved(true)
|
||
}
|
||
} catch { /* silent */ }
|
||
setSettingsSaving(false)
|
||
}}
|
||
>
|
||
{settingsSaving ? 'Saving…' : settingsSaved ? 'Saved ✓' : 'Save Settings'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Footer toolbar */}
|
||
<div className="em-footer-toolbar">
|
||
{config && !config.canSendReplies && (
|
||
<span className="em-footer-warn">⚠ RESEND_API_KEY not configured — sending is disabled</span>
|
||
)}
|
||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setShowTemplatesMgr(true)}>
|
||
Templates
|
||
</button>
|
||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setShowHistory(true)}>
|
||
Sent History
|
||
</button>
|
||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => { setSettingsSig(emailSettings.signature); setShowSettings(true) }}>
|
||
Settings
|
||
</button>
|
||
<span className="em-footer-hint">↑ ↓ navigate · r reply · e archive · Esc close</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Auth shell ───────────────────────────────────────────────────────────────
|
||
|
||
export default function EmailShell() {
|
||
const [authStatus, setAuthStatus] = useState<'checking' | 'authenticated' | 'unauthenticated' | 'misconfigured'>('checking')
|
||
const [password, setPassword] = useState('')
|
||
const [errorMsg, setErrorMsg] = useState('')
|
||
const [submitting, setSubmitting] = useState(false)
|
||
const [totpRequired, setTotpRequired] = useState(false)
|
||
const [pendingToken, setPendingToken] = useState('')
|
||
const [totpCode, setTotpCode] = useState('')
|
||
|
||
useEffect(() => {
|
||
fetch('/api/admin-auth/status')
|
||
.then(r => (r.ok ? r.json() : Promise.reject()))
|
||
.then(data => {
|
||
const d = data as { authenticated?: boolean; configured?: boolean }
|
||
if (d.configured === false) { setAuthStatus('misconfigured'); return }
|
||
setAuthStatus(d.authenticated ? 'authenticated' : 'unauthenticated')
|
||
})
|
||
.catch(() => setAuthStatus('unauthenticated'))
|
||
}, [])
|
||
|
||
async function handleLogin(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
setSubmitting(true)
|
||
setErrorMsg('')
|
||
try {
|
||
const res = await fetch('/api/admin-auth/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ password }),
|
||
})
|
||
const data = await res.json().catch(() => ({})) as { ok?: boolean; totpRequired?: boolean; pendingToken?: string; message?: string }
|
||
if (!res.ok) { setErrorMsg(data.message ?? 'Login failed.'); setSubmitting(false); return }
|
||
if (data.totpRequired && data.pendingToken) {
|
||
setPendingToken(data.pendingToken); setTotpRequired(true); setPassword(''); setSubmitting(false); return
|
||
}
|
||
setAuthStatus('authenticated'); setPassword('')
|
||
} catch {
|
||
setErrorMsg('Login failed.')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
async function handleTotpVerify(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
setSubmitting(true)
|
||
setErrorMsg('')
|
||
try {
|
||
const res = await fetch('/api/admin-auth/totp-verify', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ pendingToken, code: totpCode }),
|
||
})
|
||
if (!res.ok) {
|
||
const d = await res.json().catch(() => ({})) as { message?: string }
|
||
setErrorMsg(d.message ?? 'Invalid code.'); setSubmitting(false); return
|
||
}
|
||
setAuthStatus('authenticated'); setTotpCode('')
|
||
} catch {
|
||
setErrorMsg('Verification failed.')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
async function handleLogout() {
|
||
try { await fetch('/api/admin-auth/logout', { method: 'POST' }) } finally {
|
||
setAuthStatus('unauthenticated'); setTotpRequired(false); setPendingToken('')
|
||
}
|
||
}
|
||
|
||
if (authStatus === 'authenticated') return <EmailClient onLogout={handleLogout} />
|
||
|
||
return (
|
||
<main className="admin-auth-page" aria-label="Email sign in">
|
||
<div className="admin-auth-card">
|
||
<p className="eyebrow">Email Center</p>
|
||
<h1>{authStatus === 'misconfigured' ? 'Not Configured' : 'Sign In'}</h1>
|
||
{authStatus === 'checking' && <p className="admin-auth-note">Checking session…</p>}
|
||
{authStatus === 'misconfigured' && (
|
||
<p className="admin-auth-note">Set the ADMIN_PASSWORD environment variable to enable access.</p>
|
||
)}
|
||
{authStatus === 'unauthenticated' && !totpRequired && (
|
||
<form className="admin-auth-form" onSubmit={handleLogin}>
|
||
<label>Password
|
||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" required />
|
||
</label>
|
||
{errorMsg && <p className="admin-auth-error">{errorMsg}</p>}
|
||
<button type="submit" className="btn-primary" disabled={submitting}>{submitting ? 'Signing In…' : 'Sign In'}</button>
|
||
<Link to="/" className="btn-secondary">Back to Site</Link>
|
||
</form>
|
||
)}
|
||
{authStatus === 'unauthenticated' && totpRequired && (
|
||
<form className="admin-auth-form" onSubmit={handleTotpVerify}>
|
||
<p className="admin-auth-note">Enter the 6-digit code from your authenticator app.</p>
|
||
<label>Code
|
||
<input type="text" inputMode="numeric" value={totpCode} onChange={e => setTotpCode(e.target.value)} autoComplete="one-time-code" placeholder="000000" autoFocus required />
|
||
</label>
|
||
{errorMsg && <p className="admin-auth-error">{errorMsg}</p>}
|
||
<button type="submit" className="btn-primary" disabled={submitting}>{submitting ? 'Verifying…' : 'Verify'}</button>
|
||
<button type="button" className="btn-secondary" onClick={() => { setTotpRequired(false); setPendingToken(''); setTotpCode(''); setErrorMsg('') }}>Back</button>
|
||
</form>
|
||
)}
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|