Add /email route as standalone web email client; v1.1.11

Extracts the email inbox from /admin and builds it as a full-viewport
email client at /email with the same admin auth. Key improvements over
the old embedded panel:

- Full-height split-pane layout (sidebar list + detail pane)
- Subject line extracted and shown separately for inbound emails
- Inline reply composer inside the detail pane (no more page-jump)
- Compose-new button for outbound messages to arbitrary addresses
- Search/filter across name, email, subject, and body
- Unread dot indicators with localStorage tracking (marks read on open)
- Keyboard navigation: ↑/↓ or j/k to move, r to reply, e to archive, Esc to close
- Source badges (contact-form / inbound-email) on list items
- Sent history and reply templates accessible via footer drawers
- 30-second polling for new messages
- New POST /api/admin-email/compose server endpoint for outbound sends
- Dashboard card "Open Inbox" now links to /email

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-28 09:34:39 -04:00
parent e75a6d0e15
commit 7670b44d27
6 changed files with 1794 additions and 510 deletions
+3 -509
View File
@@ -11,12 +11,6 @@ import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
import { useAutosave } from './hooks/useAutosave'
import QRCode from 'qrcode'
// Addresses an admin may send a reply from — must stay in sync with ADMIN_REPLY_FROM_OPTIONS in server/config.js.
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' },
]
interface SortableLessonSectionProps {
section: ColossiansStudySection
study: StudyProgram
@@ -657,33 +651,6 @@ interface ContactSubmission {
htmlBody?: string | null
}
interface ContactReplyDraft {
submissionId: string
recipientName: string
recipientEmail: string
subject: string
message: string
fromAddress: string
}
interface ContactReplyTemplate {
id: string
label: string
subject: string
message: string
}
interface ContactReplyHistoryItem {
id: string
submissionId: string
toEmail: string
toName: string
fromEmail: string
subject: string
preview: string
sentAt: string
}
interface Subscriber {
name: string
email: string
@@ -691,14 +658,6 @@ interface Subscriber {
source: 'contact-form' | 'download'
}
interface ContactReplyConfig {
fromEmail: string
fromIdentity: string
resendApiConfigured: boolean
canSendReplies: boolean
note: string
}
type ChecklistPhase = 'pre' | 'post'
interface PodcastChecklistTask {
@@ -729,7 +688,7 @@ type AdminView =
| 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist'
| 'downloads' | 'custom-links' | 'content-blocks'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
| 'emails' | 'subscribers' | 'contacts' | 'study-users' | 'email-templates'
| 'subscribers' | 'contacts' | 'study-users' | 'email-templates'
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
interface AdminSectionLink {
@@ -766,7 +725,6 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi
options: [
{ value: 'questions', label: 'Questions' },
{ value: 'study-comments', label: 'Study Comments' },
{ value: 'emails', label: 'Emails' },
{ value: 'contacts', label: 'Contacts' },
{ value: 'subscribers', label: 'Subscribers' },
{ value: 'study-users', label: 'Study Users' },
@@ -1155,16 +1113,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [questions, setQuestions] = useState<Question[]>([])
const [contactSubmissions, setContactSubmissions] = useState<ContactSubmission[]>([])
const [contactStatus, setContactStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [contactReplyDraft, setContactReplyDraft] = useState<ContactReplyDraft | null>(null)
const [contactReplyStatus, setContactReplyStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
const [contactReplyMsg, setContactReplyMsg] = useState('')
const [contactReplyTemplates, setContactReplyTemplates] = useState<ContactReplyTemplate[]>([])
const [contactReplyHistory, setContactReplyHistory] = useState<ContactReplyHistoryItem[]>([])
const [contactReplyConfig, setContactReplyConfig] = useState<ContactReplyConfig | null>(null)
const [contactTemplateStatus, setContactTemplateStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [emailMailboxView, setEmailMailboxView] = useState<'inbox' | 'archived'>('inbox')
const [selectedEmailId, setSelectedEmailId] = useState<string | null>(null)
const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({})
const [editingQuestionId, setEditingQuestionId] = useState<string | null>(null)
const [questionSearch, setQuestionSearch] = useState('')
@@ -1386,30 +1334,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load contact submissions'))))
.then(data => {
setContactSubmissions((data as { submissions: ContactSubmission[] }).submissions ?? [])
setContactStatus('ready')
})
.catch(() => {
setContactStatus('error')
})
fetch('/api/admin-contact-reply-templates')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply templates'))))
.then(data => {
setContactReplyTemplates((data as { templates: ContactReplyTemplate[] }).templates ?? [])
})
.catch(() => {})
fetch('/api/admin-contact-reply-history')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply history'))))
.then(data => {
setContactReplyHistory((data as { items: ContactReplyHistoryItem[] }).items ?? [])
})
.catch(() => {})
fetch('/api/admin-reply-config')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply config'))))
.then(data => {
setContactReplyConfig(data as ContactReplyConfig)
})
.catch(() => {})
@@ -1501,58 +1425,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
if (!r.ok) throw new Error('Could not refresh contact submissions')
const data = await r.json() as { submissions?: ContactSubmission[] }
setContactSubmissions(Array.isArray(data.submissions) ? data.submissions : [])
setContactStatus('ready')
}
// Poll for new messages every 30 seconds — prepend new ones and refresh fields (e.g. archived) on existing ones
useEffect(() => {
const interval = 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 = Array.isArray(data.submissions) ? data.submissions : []
setContactSubmissions(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 { /* silent — don't disrupt the UI */ }
}, 30_000)
return () => clearInterval(interval)
}, [])
useEffect(() => {
const visible = contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true))
if (visible.length === 0) {
setSelectedEmailId(null)
return
}
if (!selectedEmailId || !visible.some(item => item.id === selectedEmailId)) {
setSelectedEmailId(visible[0].id)
}
}, [contactSubmissions, emailMailboxView, selectedEmailId])
useEffect(() => {
setQuestionPage(0)
}, [questionSearch, questionFilter])
async function reloadContactReplyHistory() {
const r = await fetch('/api/admin-contact-reply-history')
if (!r.ok) throw new Error('Could not refresh reply history')
const data = await r.json() as { items?: ContactReplyHistoryItem[] }
setContactReplyHistory(Array.isArray(data.items) ? data.items : [])
}
async function reloadContactReplyTemplates() {
const r = await fetch('/api/admin-contact-reply-templates')
if (!r.ok) throw new Error('Could not refresh reply templates')
const data = await r.json() as { templates?: ContactReplyTemplate[] }
setContactReplyTemplates(Array.isArray(data.templates) ? data.templates : [])
}
async function reloadBackups() {
const r = await fetch('/api/admin-stats/backups')
if (!r.ok) throw new Error('Could not refresh backups')
@@ -2331,9 +2209,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
if (view === 'questions' && unansweredCount > 0) {
return { count: unansweredCount, neutral: false }
}
if (view === 'emails' && unreadEmailCount > 0) {
return { count: unreadEmailCount, neutral: false }
}
if (view === 'contacts' && contactSubmissions.length > 0) {
return { count: contactSubmissions.length, neutral: true }
}
@@ -2837,147 +2712,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}
}
async function handleDeleteContactSubmission(submissionId: string) {
if (!confirm('Delete this contact submission permanently?')) return
try {
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(submissionId)}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete submission')
await reloadContactSubmissions()
await reloadStats()
setMaintenanceMsg('Contact submission deleted.')
} catch {
setMaintenanceMsg('Failed to delete contact submission.')
}
}
async function handleArchiveContactSubmission(submissionId: string, archived: boolean) {
try {
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(submissionId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ archived }),
})
if (!res.ok) throw new Error('Failed to update submission')
await reloadContactSubmissions()
await reloadStats()
setContactReplyMsg(archived ? 'Message archived.' : 'Message moved back to inbox.')
} catch {
setContactReplyMsg('Failed to update archive status.')
}
}
function openContactReplyComposer(submission: ContactSubmission) {
const firstName = submission.name?.trim().split(/\s+/)[0] || 'there'
const isInbound = submission.source === 'inbound-email'
// For inbound emails, extract the original subject from the stored message
const subjectLine = isInbound
? (() => {
const match = /^Subject: (.+)/m.exec(submission.message ?? '')
return match ? `Re: ${match[1].trim()}` : 'Re: Your message'
})()
: 'Thanks for reaching out to Verse by Verse with Nate'
// Default reply-from to whichever address the email was sent to
const inboundTo = submission.inboundTo ?? ''
const defaultFrom = inboundTo.includes('nate@')
? REPLY_FROM_OPTIONS[1].value
: REPLY_FROM_OPTIONS[0].value
setContactReplyDraft({
submissionId: submission.id,
recipientName: submission.name,
recipientEmail: submission.email,
subject: subjectLine,
message: '',
fromAddress: defaultFrom,
})
setContactReplyStatus('idle')
setContactReplyMsg(`Composing a reply to ${firstName}.`)
}
function applyContactReplyTemplate(templateId: string) {
if (!contactReplyDraft) return
const template = contactReplyTemplates.find(item => item.id === templateId)
if (!template) return
setContactReplyDraft({
...contactReplyDraft,
message: template.message,
})
setContactReplyMsg(`Applied template: ${template.label}.`)
}
function addContactReplyTemplate() {
setContactReplyTemplates(items => ([
...items,
{
id: Date.now().toString(36),
label: '',
subject: '',
message: '',
},
]))
setContactTemplateStatus('idle')
}
function updateContactReplyTemplate(id: string, field: keyof ContactReplyTemplate, value: string) {
setContactReplyTemplates(items => items.map(item => item.id === id ? { ...item, [field]: value } : item))
setContactTemplateStatus('idle')
}
function removeContactReplyTemplate(id: string) {
setContactReplyTemplates(items => items.filter(item => item.id !== id))
setContactTemplateStatus('idle')
}
async function handleSaveContactReplyTemplates() {
setContactTemplateStatus('saving')
try {
const res = await fetch('/api/admin-contact-reply-templates', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ templates: contactReplyTemplates }),
})
if (!res.ok) throw new Error('Failed to save templates')
await reloadContactReplyTemplates()
setContactTemplateStatus('saved')
setContactReplyMsg('Reply templates saved.')
} catch {
setContactTemplateStatus('error')
setContactReplyMsg('Failed to save reply templates.')
}
}
async function handleSendContactReply() {
if (!contactReplyDraft) return
setContactReplyStatus('sending')
setContactReplyMsg('')
try {
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(contactReplyDraft.submissionId)}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: contactReplyDraft.subject,
message: contactReplyDraft.message,
fromAddress: contactReplyDraft.fromAddress,
}),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Failed to send email.')
}
setContactReplyStatus('sent')
const sentFrom = contactReplyDraft.fromAddress.match(/<([^>]+)>/)?.[1] ?? contactReplyDraft.fromAddress
setContactReplyMsg(`Reply sent to ${contactReplyDraft.recipientEmail} from ${sentFrom}.`)
await reloadContactReplyHistory()
setContactReplyDraft(null)
} catch (err) {
setContactReplyStatus('error')
setContactReplyMsg(err instanceof Error ? err.message : 'Failed to send email.')
}
}
const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources')
const filteredAdminQuestions = questions.filter(question => {
@@ -3259,9 +2993,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</div>
<div className={`admin-dashboard-card${unreadEmailCount > 0 ? ' admin-dashboard-card--alert' : ''}`}>
<div className="admin-dashboard-card-value">{unreadEmailCount}</div>
<div className="admin-dashboard-card-label">Unread Emails</div>
<div className="admin-dashboard-card-label">Inbox Messages</div>
<div className="admin-dashboard-card-sub">{contactSubmissions.filter(s => s.archived).length} archived · {contactSubmissions.length} total</div>
{unreadEmailCount > 0 && <button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('emails')}>Open Inbox </button>}
<Link to="/email" className="admin-dashboard-card-action" style={{ textDecoration: 'none' }}>Open Inbox </Link>
</div>
<div className="admin-dashboard-card">
<div className="admin-dashboard-card-value">{thisWeekReal.toLocaleString()}</div>
@@ -5241,246 +4975,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
/>
)}
{/* EMAILS */}
{adminView === 'emails' && (
<section className="admin-panel-section" aria-label="Email center">
<div className="admin-panel-head">
<h2>Email Center</h2>
<p>Manage inbound contact emails, archive threads, reply with templates, and review sent history.</p>
</div>
{contactReplyConfig && (
<div className="admin-restore-preview" style={{ marginBottom: '0.9rem' }}>
<h3>Sender Status</h3>
<p><strong>From:</strong> {contactReplyConfig.fromIdentity}</p>
<p><strong>Resend Configured:</strong> {contactReplyConfig.resendApiConfigured ? 'Yes' : 'No'}</p>
<p><strong>Can Send Replies:</strong> {contactReplyConfig.canSendReplies ? 'Yes' : 'No'}</p>
<p>{contactReplyConfig.note}</p>
</div>
)}
<div className="admin-actions admin-actions--maintenance" style={{ marginBottom: '0.75rem' }}>
<button type="button" className={`btn-admin-reset${emailMailboxView === 'inbox' ? ' btn-admin-reset--active' : ''}`} onClick={() => setEmailMailboxView('inbox')}>Inbox</button>
<button type="button" className={`btn-admin-reset${emailMailboxView === 'archived' ? ' btn-admin-reset--active' : ''}`} onClick={() => setEmailMailboxView('archived')}>Archived</button>
</div>
{contactStatus === 'loading' && <p className="admin-stats-note">Loading emails</p>}
{contactStatus === 'error' && <p className="admin-stats-note">Could not load contact submissions.</p>}
{contactStatus === 'ready' && (
<div className="admin-email-layout">
<aside className="admin-email-list">
{contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true)).length === 0 && (
<p className="admin-stats-note">No messages in this mailbox.</p>
)}
{contactSubmissions
.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true))
.map(item => (
<button
key={item.id}
type="button"
className={`admin-email-list-item${selectedEmailId === item.id ? ' admin-email-list-item--active' : ''}`}
onClick={() => setSelectedEmailId(item.id)}
>
<div className="admin-email-list-head">
<strong>{item.name}</strong>
<span>{formatDate(item.submittedAt)}</span>
</div>
<p>{item.message}</p>
</button>
))}
</aside>
<div className="admin-email-detail">
{(() => {
const visible = contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true))
const selected = visible.find(item => item.id === selectedEmailId) ?? null
if (!selected) return <p className="admin-stats-note">Select an email to view details.</p>
return (
<>
<div className="admin-email-meta">
<p><strong>From:</strong> {selected.name} &lt;{selected.email}&gt;</p>
<p><strong>Type:</strong> {selected.messageType}</p>
<p><strong>Subscribed:</strong> {selected.subscribe ? 'Yes' : 'No'}</p>
<p><strong>Received:</strong> {formatDate(selected.submittedAt)}</p>
</div>
<div className="admin-email-body">
{selected.htmlBody ? (
<iframe
srcDoc={selected.htmlBody}
sandbox="allow-same-origin"
style={{ width: '100%', minHeight: '320px', border: 'none', background: '#fff', borderRadius: '4px' }}
onLoad={e => {
const iframe = e.currentTarget
const h = iframe.contentDocument?.documentElement?.scrollHeight
if (h) iframe.style.height = `${h}px`
}}
/>
) : (
<p style={{ whiteSpace: 'pre-wrap' }}>{selected.message}</p>
)}
</div>
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-save admin-email-action-btn" onClick={() => openContactReplyComposer(selected)}>Reply</button>
<button
type="button"
className="btn-admin-reset admin-email-action-btn"
onClick={() => handleArchiveContactSubmission(selected.id, !(selected.archived === true))}
>
{selected.archived === true ? 'Move to Inbox' : 'Archive'}
</button>
<button type="button" className="btn-admin-remove admin-email-action-btn" onClick={() => handleDeleteContactSubmission(selected.id)}>Delete</button>
</div>
</>
)
})()}
</div>
</div>
)}
{contactReplyDraft && (
<div className="admin-array-row" style={{ marginTop: '1rem' }}>
<div className="admin-array-fields">
{contactReplyTemplates.length > 0 && (
<div className="admin-field">
<label htmlFor="reply-template">Saved Template</label>
<select id="reply-template" defaultValue="" onChange={e => applyContactReplyTemplate(e.target.value)}>
<option value="">Choose a template</option>
{contactReplyTemplates.map(template => (
<option key={template.id} value={template.id}>{template.label}</option>
))}
</select>
</div>
)}
<div className="admin-field">
<label htmlFor="reply-to">To</label>
<input id="reply-to" type="text" value={`${contactReplyDraft.recipientName} <${contactReplyDraft.recipientEmail}>`} readOnly />
</div>
<div className="admin-field">
<label htmlFor="reply-from">From</label>
<select
id="reply-from"
value={contactReplyDraft.fromAddress}
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, fromAddress: e.target.value } : draft)}
>
{REPLY_FROM_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div className="admin-field">
<label htmlFor="reply-subject">Subject</label>
<input
id="reply-subject"
type="text"
value={contactReplyDraft.subject}
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, subject: e.target.value } : draft)}
/>
</div>
<div className="admin-field">
<label htmlFor="reply-message">Message</label>
<textarea
id="reply-message"
rows={8}
placeholder="Type your reply here…"
value={contactReplyDraft.message}
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, message: e.target.value } : draft)}
/>
<div className="admin-stats-note" style={{ borderLeft: '3px solid #ccc', paddingLeft: '0.6rem', marginTop: '0.4rem', color: '#666', fontStyle: 'italic', whiteSpace: 'pre-line' }}>
{'Grace and peace,\nVerse by Verse with Nate'}
<span style={{ display: 'block', marginTop: '0.25rem', fontSize: '0.75em', fontStyle: 'normal', color: '#999' }}>Your signature appended automatically</span>
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button type="button" className="btn-admin-save" onClick={handleSendContactReply} disabled={contactReplyStatus === 'sending'}>
{contactReplyStatus === 'sending' ? 'Sending…' : 'Send Reply'}
</button>
<button
type="button"
className="btn-admin-reset"
onClick={() => {
setContactReplyDraft(null)
setContactReplyStatus('idle')
setContactReplyMsg('')
}}
>
Cancel
</button>
</div>
</div>
)}
{contactReplyMsg && <p className="admin-stats-note">{contactReplyMsg}</p>}
<AdminCollapsibleCard
title="Saved Reply Templates"
subtitle={`${contactReplyTemplates.length} template${contactReplyTemplates.length === 1 ? '' : 's'} configured`}
>
{contactReplyTemplates.length === 0 && <p className="admin-stats-note">No saved templates yet.</p>}
{contactReplyTemplates.map(template => (
<AdminCollapsibleCard
key={template.id}
title={template.label || 'Untitled template'}
subtitle={template.subject || 'No subject set'}
className="admin-collapsible-card--nested"
>
<div className="admin-array-row admin-array-row--nested">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`reply-template-label-${template.id}`}>Label</label>
<input id={`reply-template-label-${template.id}`} type="text" value={template.label} onChange={e => updateContactReplyTemplate(template.id, 'label', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`reply-template-subject-${template.id}`}>Subject</label>
<input id={`reply-template-subject-${template.id}`} type="text" value={template.subject} onChange={e => updateContactReplyTemplate(template.id, 'subject', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`reply-template-message-${template.id}`}>Message</label>
<textarea id={`reply-template-message-${template.id}`} rows={5} value={template.message} onChange={e => updateContactReplyTemplate(template.id, 'message', e.target.value)} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeContactReplyTemplate(template.id)}>Remove</button>
</div>
</AdminCollapsibleCard>
))}
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-reset" onClick={addContactReplyTemplate}>Add Template</button>
<button type="button" className="btn-admin-save" onClick={handleSaveContactReplyTemplates} disabled={contactTemplateStatus === 'saving'}>
{contactTemplateStatus === 'saving' ? 'Saving…' : 'Save Templates'}
</button>
</div>
</AdminCollapsibleCard>
<div className="admin-visits-table-wrap">
<h3>Reply History</h3>
{contactReplyHistory.length === 0 ? <p className="admin-stats-note">No admin replies have been sent yet.</p> : (
<div className="admin-visits-table-scroll">
<table className="admin-visits-table">
<thead>
<tr><th>Sent</th><th>To</th><th>From</th><th>Subject</th><th>Preview</th></tr>
</thead>
<tbody>
{contactReplyHistory.map(item => (
<tr key={item.id}>
<td>{formatDate(item.sentAt)}</td>
<td>{item.toName} ({item.toEmail})</td>
<td>{item.fromEmail}</td>
<td>{item.subject}</td>
<td>{item.preview}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
)}
{/* ASSETS */}
{adminView === 'assets' && (
<section className="admin-panel-section" aria-label="Asset manager">
+809
View File
@@ -9253,3 +9253,812 @@
line-height: 1.65;
margin: 0;
}
/* ── Email Client (/email) ──────────────────────────────────────────────── */
.em-app {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
overflow: hidden;
background: #0a0a08;
color: #f0ead8;
font-family: Georgia, serif;
}
.em-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.6rem 1.1rem;
border-bottom: 1px solid rgba(201, 168, 76, 0.22);
background: #0d0d0a;
flex-shrink: 0;
}
.em-header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.em-header-brand {
font-family: Georgia, serif;
font-size: 1rem;
font-weight: 600;
color: #e0c070;
letter-spacing: 0.02em;
}
.em-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.em-body {
display: flex;
flex: 1;
overflow: hidden;
}
/* Sidebar */
.em-sidebar {
width: 300px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid rgba(201, 168, 76, 0.18);
overflow: hidden;
background: #0b0b09;
}
.em-search-wrap {
padding: 0.55rem 0.65rem;
border-bottom: 1px solid rgba(201, 168, 76, 0.14);
}
.em-search {
width: 100%;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(201, 168, 76, 0.22);
border-radius: 8px;
padding: 0.45rem 0.65rem;
color: #f0ead8;
font-family: Georgia, serif;
font-size: 0.88rem;
outline: none;
box-sizing: border-box;
}
.em-search:focus {
border-color: rgba(201, 168, 76, 0.5);
}
.em-search::placeholder {
color: rgba(240, 234, 216, 0.35);
}
.em-mailbox-tabs {
display: flex;
border-bottom: 1px solid rgba(201, 168, 76, 0.14);
}
.em-mailbox-tab {
flex: 1;
background: none;
border: none;
padding: 0.55rem 0.5rem;
color: rgba(240, 234, 216, 0.6);
font-family: Georgia, serif;
font-size: 0.85rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
}
.em-mailbox-tab--active {
color: #e0c070;
border-bottom-color: #e0c070;
}
.em-tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.3rem;
height: 1.3rem;
padding: 0 0.3rem;
border-radius: 999px;
font-size: 0.72rem;
font-family: system-ui, sans-serif;
background: rgba(201, 168, 76, 0.18);
color: rgba(240, 234, 216, 0.7);
}
.em-tab-badge--unread {
background: rgba(201, 168, 76, 0.35);
color: #e0c070;
}
/* Message list */
.em-list {
flex: 1;
overflow-y: auto;
padding: 0.4rem 0.35rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.em-list-empty {
padding: 1.25rem 0.75rem;
color: rgba(240, 234, 216, 0.45);
font-size: 0.88rem;
text-align: center;
margin: 0;
}
.em-list-item {
position: relative;
width: 100%;
text-align: left;
background: rgba(255,255,255,0.018);
border: 1px solid rgba(201, 168, 76, 0.12);
border-radius: 8px;
padding: 0.6rem 0.7rem 0.6rem 1rem;
cursor: pointer;
color: #f0ead8;
transition: border-color 0.12s, background 0.12s;
}
.em-list-item:hover {
background: rgba(255,255,255,0.04);
border-color: rgba(201, 168, 76, 0.25);
}
.em-list-item--active {
background: rgba(201, 168, 76, 0.08);
border-color: rgba(201, 168, 76, 0.45);
}
.em-list-item--unread .em-list-item-name {
color: #e0c070;
}
.em-unread-dot {
position: absolute;
left: 0.32rem;
top: 50%;
transform: translateY(-50%);
width: 5px;
height: 5px;
border-radius: 50%;
background: #c9a84c;
}
.em-list-item-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.4rem;
margin-bottom: 0.18rem;
}
.em-list-item-name {
font-size: 0.88rem;
font-weight: 600;
color: rgba(240,234,216,0.9);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.em-list-item-date {
font-size: 0.74rem;
color: rgba(240, 234, 216, 0.4);
white-space: nowrap;
flex-shrink: 0;
font-family: system-ui, sans-serif;
}
.em-list-item-subject {
font-size: 0.83rem;
color: rgba(240, 234, 216, 0.8);
margin-bottom: 0.14rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.em-list-item-preview {
font-size: 0.78rem;
color: rgba(240, 234, 216, 0.45);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.em-source-badge {
display: inline-block;
font-size: 0.68rem;
font-family: system-ui, sans-serif;
padding: 0.12rem 0.4rem;
border-radius: 4px;
margin-top: 0.3rem;
letter-spacing: 0.04em;
}
.em-source-badge--inbound {
background: rgba(3, 105, 161, 0.25);
color: #7dd3fc;
}
.em-source-badge--contact-form {
background: rgba(22, 101, 52, 0.25);
color: #86efac;
}
.em-source-badge--download {
background: rgba(109, 40, 217, 0.2);
color: #c4b5fd;
}
.em-source-badge--subscribed {
background: rgba(201, 168, 76, 0.15);
color: #e0c070;
}
/* Detail pane */
.em-detail {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.em-empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
color: rgba(240, 234, 216, 0.35);
}
.em-empty-icon {
font-size: 2.5rem;
opacity: 0.5;
}
.em-empty-state p {
margin: 0;
font-size: 0.95rem;
}
.em-empty-hint {
font-size: 0.78rem !important;
font-family: system-ui, sans-serif;
opacity: 0.6;
}
.em-detail-header {
padding: 0.9rem 1.15rem 0.75rem;
border-bottom: 1px solid rgba(201, 168, 76, 0.16);
flex-shrink: 0;
background: #0d0d0a;
}
.em-detail-meta-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.55rem;
}
.em-detail-from {
display: flex;
align-items: center;
gap: 0.65rem;
}
.em-detail-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(201, 168, 76, 0.22);
color: #e0c070;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.95rem;
font-weight: 700;
flex-shrink: 0;
}
.em-detail-from-name {
font-weight: 600;
font-size: 0.95rem;
color: #f0ead8;
}
.em-detail-from-email {
font-size: 0.8rem;
color: rgba(240, 234, 216, 0.5);
font-family: system-ui, sans-serif;
}
.em-detail-meta-right {
text-align: right;
flex-shrink: 0;
}
.em-detail-date {
font-size: 0.8rem;
color: rgba(240, 234, 216, 0.5);
font-family: system-ui, sans-serif;
}
.em-detail-to {
font-size: 0.75rem;
color: rgba(240, 234, 216, 0.4);
font-family: system-ui, sans-serif;
margin-top: 0.2rem;
}
.em-detail-subject-row {
display: flex;
align-items: center;
gap: 0.65rem;
flex-wrap: wrap;
}
.em-detail-subject {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
color: #f0ead8;
}
.em-detail-badges {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.em-detail-body {
flex: 1;
overflow-y: auto;
padding: 1rem 1.15rem;
}
.em-detail-iframe {
width: 100%;
border: none;
background: #fff;
border-radius: 6px;
display: block;
min-height: 200px;
}
.em-detail-plain {
margin: 0;
white-space: pre-wrap;
font-family: Georgia, serif;
font-size: 0.93rem;
line-height: 1.7;
color: rgba(240, 234, 216, 0.85);
}
.em-detail-footer {
padding: 0.65rem 1.15rem;
border-top: 1px solid rgba(201, 168, 76, 0.16);
flex-shrink: 0;
background: #0d0d0a;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.em-detail-actions {
display: flex;
gap: 0.4rem;
}
/* Reply / Compose */
.em-reply-composer {
border-top: 1px solid rgba(201, 168, 76, 0.25);
background: #0d0d0a;
flex-shrink: 0;
max-height: 55vh;
overflow-y: auto;
}
.em-compose-new {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.em-compose-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1.1rem 0.4rem;
border-bottom: 1px solid rgba(201, 168, 76, 0.14);
}
.em-compose-header-label {
font-size: 0.9rem;
color: #e0c070;
font-weight: 600;
}
.em-compose-close {
background: none;
border: none;
color: rgba(240, 234, 216, 0.5);
font-size: 1.3rem;
cursor: pointer;
padding: 0 0.25rem;
line-height: 1;
}
.em-compose-close:hover {
color: #f0ead8;
}
.em-compose-fields {
padding: 0.7rem 1.1rem 0.9rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.em-compose-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.em-compose-row--inline {
flex-wrap: nowrap;
}
.em-compose-label {
font-size: 0.8rem;
color: rgba(240, 234, 216, 0.5);
white-space: nowrap;
min-width: 48px;
font-family: system-ui, sans-serif;
}
.em-compose-input {
flex: 1;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(201, 168, 76, 0.2);
border-radius: 6px;
padding: 0.38rem 0.6rem;
color: #f0ead8;
font-family: Georgia, serif;
font-size: 0.88rem;
outline: none;
min-width: 0;
box-sizing: border-box;
}
.em-compose-input:focus {
border-color: rgba(201, 168, 76, 0.45);
}
.em-compose-select {
flex: 1;
background: #14130f;
border: 1px solid rgba(201, 168, 76, 0.2);
border-radius: 6px;
padding: 0.38rem 0.55rem;
color: #f0ead8;
font-family: Georgia, serif;
font-size: 0.85rem;
outline: none;
min-width: 0;
}
.em-compose-body {
width: 100%;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(201, 168, 76, 0.2);
border-radius: 8px;
padding: 0.6rem 0.75rem;
color: #f0ead8;
font-family: Georgia, serif;
font-size: 0.9rem;
line-height: 1.65;
resize: vertical;
outline: none;
box-sizing: border-box;
margin-top: 0.2rem;
}
.em-compose-body:focus {
border-color: rgba(201, 168, 76, 0.45);
}
.em-compose-signature {
font-size: 0.78rem;
color: rgba(240, 234, 216, 0.35);
font-style: italic;
padding-left: 0.5rem;
border-left: 2px solid rgba(201, 168, 76, 0.2);
display: flex;
gap: 0.6rem;
align-items: center;
}
.em-compose-signature-note {
font-size: 0.72rem;
font-style: normal;
font-family: system-ui, sans-serif;
opacity: 0.65;
}
.em-compose-actions {
display: flex;
gap: 0.4rem;
align-items: center;
flex-wrap: wrap;
margin-top: 0.2rem;
}
/* Buttons */
.em-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
font-family: Georgia, serif;
font-size: 0.85rem;
padding: 0.42rem 0.9rem;
border-radius: 7px;
border: 1px solid transparent;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
transition: opacity 0.12s;
}
.em-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.em-btn--primary {
background: #c9a84c;
color: #0a0a08;
font-weight: 700;
border-color: #e0c070;
}
.em-btn--primary:hover:not(:disabled) { opacity: 0.88; }
.em-btn--secondary {
background: rgba(201, 168, 76, 0.12);
color: #e0c070;
border-color: rgba(201, 168, 76, 0.3);
}
.em-btn--secondary:hover { background: rgba(201, 168, 76, 0.2); }
.em-btn--ghost {
background: rgba(255,255,255,0.06);
color: rgba(240, 234, 216, 0.7);
border-color: rgba(255,255,255,0.1);
}
.em-btn--ghost:hover { background: rgba(255,255,255,0.1); color: #f0ead8; }
.em-btn--danger {
background: rgba(220, 38, 38, 0.15);
color: #fca5a5;
border-color: rgba(220, 38, 38, 0.3);
}
.em-btn--danger:hover { background: rgba(220, 38, 38, 0.25); }
.em-btn--sm {
font-size: 0.78rem;
padding: 0.28rem 0.65rem;
}
/* Badges */
.em-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
font-family: system-ui, sans-serif;
}
.em-badge--alert {
background: rgba(201, 168, 76, 0.25);
color: #e0c070;
}
/* Status message */
.em-status-msg {
margin: 0;
font-size: 0.82rem;
color: rgba(240, 234, 216, 0.6);
font-family: system-ui, sans-serif;
}
.em-status-msg--inline {
display: inline;
}
/* Drawers */
.em-drawer-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.55);
z-index: 200;
display: flex;
align-items: flex-end;
justify-content: center;
}
.em-drawer {
background: #111009;
border: 1px solid rgba(201, 168, 76, 0.22);
border-radius: 14px 14px 0 0;
width: 100%;
max-width: 640px;
max-height: 70vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.em-drawer--wide { max-width: 820px; }
.em-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.8rem 1.1rem;
border-bottom: 1px solid rgba(201, 168, 76, 0.18);
flex-shrink: 0;
}
.em-drawer-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #e0c070;
}
.em-drawer-body {
flex: 1;
overflow-y: auto;
padding: 0.9rem 1.1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.em-drawer-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
padding-top: 0.5rem;
}
/* Template cards */
.em-template-card {
display: flex;
flex-direction: column;
gap: 0.4rem;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(201, 168, 76, 0.14);
border-radius: 8px;
padding: 0.75rem;
}
/* History */
.em-history-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.em-history-item {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(201, 168, 76, 0.12);
border-radius: 8px;
padding: 0.65rem 0.8rem;
}
.em-history-meta {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.2rem;
}
.em-history-date {
font-size: 0.77rem;
color: rgba(240, 234, 216, 0.4);
font-family: system-ui, sans-serif;
}
.em-history-flow {
font-size: 0.77rem;
color: rgba(240, 234, 216, 0.45);
font-family: system-ui, sans-serif;
}
.em-history-subject {
font-size: 0.88rem;
font-weight: 600;
color: #f0ead8;
margin-bottom: 0.2rem;
}
.em-history-preview {
font-size: 0.82rem;
color: rgba(240, 234, 216, 0.55);
}
/* Footer toolbar */
.em-footer-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.9rem;
border-top: 1px solid rgba(201, 168, 76, 0.14);
background: #0b0b09;
flex-shrink: 0;
flex-wrap: wrap;
}
.em-footer-hint {
margin-left: auto;
font-size: 0.72rem;
font-family: system-ui, sans-serif;
color: rgba(240, 234, 216, 0.28);
}
.em-footer-warn {
font-size: 0.78rem;
font-family: system-ui, sans-serif;
color: #fca5a5;
}
/* Responsive */
@media (max-width: 700px) {
.em-sidebar {
width: 100%;
max-height: 42vh;
border-right: none;
border-bottom: 1px solid rgba(201, 168, 76, 0.18);
}
.em-body {
flex-direction: column;
}
.em-footer-hint { display: none; }
}
+2
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
import type { ReactElement } from 'react'
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
import AdminPage from './AdminPage'
import EmailShell from './EmailPage'
import QASection from './components/QASection'
import ContactForm from './components/ContactForm'
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage } from './colossiansStudy'
@@ -2494,6 +2495,7 @@ export default function App() {
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
<Route path="/email" element={<EmailShell />} />
<Route path="/preview" element={<PreviewPage />} />
<Route
path="/privacy"
+916
View File
@@ -0,0 +1,916 @@
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
}
// ── 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 [actionMsg, setActionMsg] = useState('')
const composeRef = useRef<HTMLTextAreaElement>(null)
const listRef = useRef<HTMLElement>(null)
// ── Load ──
const loadAll = useCallback(async () => {
try {
const [subRes, tplRes, histRes, cfgRes] = 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'),
])
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)
} 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="/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">
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'}
</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>
)}
{/* 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>
<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>
)
}