Add contact form inbox to admin portal; v2.0.25

- New Inbox view in the Audience nav group shows all contact form submissions
- Filter by Unread / All / Archived with a search bar for name, email, or message
- Click any message to open a detail pane with full text, metadata, and a Reply via Email link
- Archive / Unarchive button to clear messages without deleting them
- Unread badge on the Inbox nav item; dashboard card now links directly to Inbox
- Added PATCH /api/admin-contact-submissions/:id to persist archived state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-09-17 10:20:49 -04:00
1 parent 8ff01f8e48
commit 590885f6b1
3 files changed
+162 -1

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "2.0.24",
"version": "2.0.25",
"type": "module",
"scripts": {
"dev": "vite",
+15
View File
@@ -358,6 +358,21 @@ export function register(app) {
res.json({ submissions: state.contactSubmissions.slice(0, 300) })
})
app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
const { archived } = req.body ?? {}
if (typeof archived !== 'boolean') {
res.status(400).json({ message: 'archived must be a boolean.' }); return
}
const idx = state.contactSubmissions.findIndex(s => s.id === id)
if (idx === -1) {
res.status(404).json({ message: 'Submission not found.' }); return
}
state.contactSubmissions[idx] = { ...state.contactSubmissions[idx], archived }
queueContactSubmissionsWrite()
res.json({ ok: true, submission: state.contactSubmissions[idx] })
})
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
const seen = new Set()
const subscribers = state.contactSubmissions
+146
View File
@@ -668,6 +668,7 @@ type AdminView =
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss'
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history'
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
| 'inbox'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
| 'seo' | 'legal' | 'security' | 'brand' | 'global' | 'webhooks'
@@ -737,6 +738,7 @@ const ADMIN_NAV: NavGroup[] = [
{
group: 'Audience',
items: [
{ kind: 'leaf', view: 'inbox', label: 'Inbox' },
{ kind: 'leaf', view: 'questions', label: 'Questions' },
{ kind: 'leaf', view: 'study-comments', label: 'Study Comments' },
{ kind: 'leaf', view: 'subscribers', label: 'Subscribers' },
@@ -1568,6 +1570,10 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const [navSearch, setNavSearch] = useState('')
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
const [inboxFilter, setInboxFilter] = useState<'unread' | 'all' | 'archived'>('unread')
const [inboxSearch, setInboxSearch] = useState('')
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(null)
const [archivingId, setArchivingId] = useState<string | null>(null)
const [subscriberSearch, setSubscriberSearch] = useState('')
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
// Study comments moderation state
@@ -2545,12 +2551,31 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
if (view === 'questions' && unansweredCount > 0) {
return { count: unansweredCount, neutral: false }
}
if (view === 'inbox' && unreadEmailCount > 0) {
return { count: unreadEmailCount, neutral: false }
}
if (view === 'subscribers' && subscribers.length > 0) {
return { count: subscribers.length, neutral: true }
}
return null
}
async function handleArchiveSubmission(id: string, archived: boolean) {
setArchivingId(id)
try {
const res = await fetch(`/api/admin-contact-submissions/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ archived }),
})
if (!res.ok) return
setContactSubmissions(prev => prev.map(s => s.id === id ? { ...s, archived } : s))
if (archived && selectedSubmissionId === id) setSelectedSubmissionId(null)
} finally {
setArchivingId(null)
}
}
function renderAdminNavItem(view: AdminView, label: string) {
const badge = getAdminViewBadge(view)
@@ -3319,6 +3344,7 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
<div className="admin-dashboard-card-value">{unreadEmailCount}</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('inbox')}>View Inbox →</button>}
</div>
<div className="admin-dashboard-card">
<div className="admin-dashboard-card-value">{thisWeekReal.toLocaleString()}</div>
@@ -3430,6 +3456,126 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
)
})()}
{/* INBOX */}
{adminView === 'inbox' && (() => {
const messageTypeLabel: Record<string, string> = {
question: 'Bible Question',
community: 'Community Question',
testimony: 'Testimony',
topic: 'Topic Request',
general: 'General Message',
}
const filtered = contactSubmissions.filter(s => {
if (inboxFilter === 'unread') return !s.archived
if (inboxFilter === 'archived') return s.archived === true
return true
}).filter(s => {
if (!inboxSearch.trim()) return true
const q = inboxSearch.toLowerCase()
return s.name.toLowerCase().includes(q) || s.email.toLowerCase().includes(q) || s.message.toLowerCase().includes(q)
})
const selected = selectedSubmissionId ? contactSubmissions.find(s => s.id === selectedSubmissionId) ?? null : null
return (
<section className="admin-panel-section" aria-label="Contact Form Inbox">
<div className="admin-panel-head">
<h2>Inbox</h2>
<p>{unreadEmailCount} unread · {contactSubmissions.filter(s => s.archived).length} archived · {contactSubmissions.length} total</p>
</div>
<div className="admin-actions admin-actions--maintenance" style={{ alignItems: 'center' }}>
<input
type="search"
value={inboxSearch}
onChange={e => setInboxSearch(e.target.value)}
placeholder="Search by name, email, or message…"
style={{ minWidth: '240px', maxWidth: '420px', width: '100%' }}
/>
<button type="button" className={`btn-admin-reset${inboxFilter === 'unread' ? ' btn-admin-reset--active' : ''}`} onClick={() => setInboxFilter('unread')}>Unread</button>
<button type="button" className={`btn-admin-reset${inboxFilter === 'all' ? ' btn-admin-reset--active' : ''}`} onClick={() => setInboxFilter('all')}>All</button>
<button type="button" className={`btn-admin-reset${inboxFilter === 'archived' ? ' btn-admin-reset--active' : ''}`} onClick={() => setInboxFilter('archived')}>Archived</button>
</div>
{contactSubmissions.length === 0 ? (
<p className="admin-stats-note">No messages yet.</p>
) : filtered.length === 0 ? (
<p className="admin-stats-note">No messages match this filter.</p>
) : (
<div style={{ display: 'grid', gridTemplateColumns: selected ? '1fr 1fr' : '1fr', gap: '1rem', alignItems: 'start' }}>
<div className="admin-questions-list" style={{ gap: '0.5rem' }}>
{filtered.map(sub => (
<div
key={sub.id}
className={`admin-question-card${selectedSubmissionId === sub.id ? ' admin-question-card--selected' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => setSelectedSubmissionId(prev => prev === sub.id ? null : sub.id)}
>
<div className="admin-question-header">
<div style={{ flex: 1, minWidth: 0 }}>
<p className="admin-question-meta">
<strong>{sub.name}</strong>
{' · '}
<a href={`mailto:${sub.email}`} onClick={e => e.stopPropagation()}>{sub.email}</a>
{' · '}
{formatDate(sub.submittedAt)}
</p>
<p className="admin-question-text" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{sub.message}
</p>
</div>
<div className="admin-question-status" style={{ flexShrink: 0 }}>
<span className="admin-badge admin-badge--pending" style={{ textTransform: 'capitalize' }}>
{messageTypeLabel[sub.messageType] ?? sub.messageType}
</span>
{sub.archived && <span className="admin-badge" style={{ background: 'var(--clr-surface-2)' }}>Archived</span>}
</div>
</div>
</div>
))}
</div>
{selected && (
<div className="admin-visits-table-wrap" style={{ position: 'sticky', top: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem', marginBottom: '0.75rem' }}>
<div>
<p style={{ fontWeight: 600, margin: 0 }}>{selected.name}</p>
<p style={{ margin: '0.2rem 0 0', fontSize: '0.875rem', opacity: 0.7 }}>
<a href={`mailto:${selected.email}`}>{selected.email}</a>
{' · '}
{formatDate(selected.submittedAt)}
</p>
<p style={{ margin: '0.25rem 0 0', fontSize: '0.8rem', opacity: 0.6 }}>
{messageTypeLabel[selected.messageType] ?? selected.messageType}
{selected.subscribe ? ' · subscribed' : ''}
</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
<button
type="button"
className="btn-admin-reset"
disabled={archivingId === selected.id}
onClick={() => handleArchiveSubmission(selected.id, !selected.archived)}
>
{archivingId === selected.id ? '…' : selected.archived ? 'Unarchive' : 'Archive'}
</button>
<button type="button" className="btn-admin-reset" onClick={() => setSelectedSubmissionId(null)}>✕</button>
</div>
</div>
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6, borderTop: '1px solid var(--clr-border)', paddingTop: '0.75rem' }}>
{selected.message}
</div>
<div style={{ marginTop: '1rem' }}>
<a href={`mailto:${selected.email}?subject=Re: Your message`} className="btn-admin-save" style={{ display: 'inline-block', textDecoration: 'none' }}>
Reply via Email
</a>
</div>
</div>
)}
</div>
)}
</section>
)
})()}
{/* SUBSCRIBERS */}
{adminView === 'subscribers' && (() => {
const filteredSubs = subscribers.filter(s =>