From 590885f6b1719b8f836dfc4ec992d6f6ad47ced0 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 17 Sep 2026 10:20:49 -0400 Subject: [PATCH] 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 --- package.json | 2 +- server/routes/contact.js | 15 ++++ src/AdminPage.tsx | 146 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 9679a4d..dd72867 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.0.24", + "version": "2.0.25", "type": "module", "scripts": { "dev": "vite", diff --git a/server/routes/contact.js b/server/routes/contact.js index fa8ee87..3876d3f 100644 --- a/server/routes/contact.js +++ b/server/routes/contact.js @@ -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 diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index e9f4a99..0a073c7 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -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([]) + const [inboxFilter, setInboxFilter] = useState<'unread' | 'all' | 'archived'>('unread') + const [inboxSearch, setInboxSearch] = useState('') + const [selectedSubmissionId, setSelectedSubmissionId] = useState(null) + const [archivingId, setArchivingId] = useState(null) const [subscriberSearch, setSubscriberSearch] = useState('') const [downloadStats, setDownloadStats] = useState>({}) // Study comments moderation state @@ -2545,12 +2551,31 @@ async function handleAssetUpload(event: ChangeEvent) { 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) {
{unreadEmailCount}
Inbox Messages
{contactSubmissions.filter(s => s.archived).length} archived · {contactSubmissions.length} total
+ {unreadEmailCount > 0 && }
{thisWeekReal.toLocaleString()}
@@ -3430,6 +3456,126 @@ async function handleAssetUpload(event: ChangeEvent) { ) })()} + {/* INBOX */} + {adminView === 'inbox' && (() => { + const messageTypeLabel: Record = { + 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 ( +
+
+

Inbox

+

{unreadEmailCount} unread · {contactSubmissions.filter(s => s.archived).length} archived · {contactSubmissions.length} total

+
+ +
+ setInboxSearch(e.target.value)} + placeholder="Search by name, email, or message…" + style={{ minWidth: '240px', maxWidth: '420px', width: '100%' }} + /> + + + +
+ + {contactSubmissions.length === 0 ? ( +

No messages yet.

+ ) : filtered.length === 0 ? ( +

No messages match this filter.

+ ) : ( +
+
+ {filtered.map(sub => ( +
setSelectedSubmissionId(prev => prev === sub.id ? null : sub.id)} + > +
+
+

+ {sub.name} + {' · '} + e.stopPropagation()}>{sub.email} + {' · '} + {formatDate(sub.submittedAt)} +

+

+ {sub.message} +

+
+
+ + {messageTypeLabel[sub.messageType] ?? sub.messageType} + + {sub.archived && Archived} +
+
+
+ ))} +
+ + {selected && ( +
+
+
+

{selected.name}

+

+ {selected.email} + {' · '} + {formatDate(selected.submittedAt)} +

+

+ {messageTypeLabel[selected.messageType] ?? selected.messageType} + {selected.subscribe ? ' · subscribed' : ''} +

+
+
+ + +
+
+
+ {selected.message} +
+ +
+ )} +
+ )} +
+ ) + })()} + {/* SUBSCRIBERS */} {adminView === 'subscribers' && (() => { const filteredSubs = subscribers.filter(s =>