From f07d8fecfb44a19b64f4506aa52476d8cc69b8da Mon Sep 17 00:00:00 2001 From: nmemmert Date: Sun, 12 Apr 2026 13:25:59 -0400 Subject: [PATCH] Add Bible Questions inbox from contact submissions --- README.md | 1 + server.js | 95 +++++++++++++++++++++++++++++++++++++++++++++-- src/AdminPage.tsx | 59 +++++++++++++++++++++++++++++ src/App.css | 3 +- src/App.tsx | 15 +++++++- 5 files changed, 168 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d77eba6..db51d0f 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ Persistent admin saves: - Click **Save project** to apply updates instantly. - Saved edits are written to `data/admin-content.json` through the API server. - Built-in stats in `/admin` include page hits plus visitor details (IP, country/state/county/city, returning visitors, and recent visitor log). +- Site Stats in `/admin` includes a Bible Questions inbox sourced from contact form submissions marked as Bible Question. - Analytics cookies are consent-based. Visitors can accept or decline tracking from the site banner. - Admin now includes maintenance actions: **Export JSON**, **Backup Now**, **Prune Old Data**, and **Clear Analytics**. - Admin also supports restoring from a backup snapshot from `/admin`. diff --git a/server.js b/server.js index ee7cf12..b388b79 100644 --- a/server.js +++ b/server.js @@ -28,6 +28,7 @@ const DATA_DIR = path.join(__dirname, 'data') const DATA_FILE = path.join(DATA_DIR, 'admin-content.json') const HIT_STATS_FILE = path.join(DATA_DIR, 'hit-stats.json') const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-stats.json') +const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json') const BACKUP_DIR = path.join(DATA_DIR, 'backups') const DIST_DIR = path.join(__dirname, 'dist') const INDEX_FILE = path.join(DIST_DIR, 'index.html') @@ -61,8 +62,12 @@ const EMPTY_VISITOR_STATS = { geoCacheByIp: {}, } +const MAX_CONTACT_SUBMISSIONS = 5000 + let visitorStats = { ...EMPTY_VISITOR_STATS } let visitorStatsWritePromise = Promise.resolve() +let contactSubmissions = [] +let contactSubmissionsWritePromise = Promise.resolve() let lastVisitorStatsWrite = { ok: true, at: null, error: null } let lastHitStatsWrite = { ok: true, at: null, error: null } let lastBackupStatus = { ok: true, at: null, error: null, file: null } @@ -160,6 +165,59 @@ function queueVisitorStatsWrite() { }) } +function queueContactSubmissionsWrite() { + contactSubmissionsWritePromise = contactSubmissionsWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + CONTACT_SUBMISSIONS_FILE, + JSON.stringify({ + submissions: contactSubmissions, + updatedAt: new Date().toISOString(), + }, null, 2), + 'utf8', + ) + }) + .catch(err => { + console.error('[contact] failed to write submissions:', err) + }) +} + +function loadContactSubmissionsFromDisk() { + return readFile(CONTACT_SUBMISSIONS_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + contactSubmissions = Array.isArray(parsed?.submissions) + ? parsed.submissions.slice(0, MAX_CONTACT_SUBMISSIONS) + : [] + }) + .catch(() => { + contactSubmissions = [] + }) +} + +function normalizeMessageType(value) { + if (value === 'question' || value === 'testimony' || value === 'topic') return value + return 'general' +} + +function addContactSubmission({ name, email, message, messageType, subscribe }) { + const submission = { + id: randomUUID(), + submittedAt: new Date().toISOString(), + name, + email, + message, + messageType: normalizeMessageType(messageType), + subscribe: subscribe === true, + } + + contactSubmissions.unshift(submission) + contactSubmissions = contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS) + queueContactSubmissionsWrite() + return submission +} + function sanitizeUserAgent(userAgent) { if (!userAgent || typeof userAgent !== 'string') return 'unknown' return userAgent.trim().slice(0, 300) || 'unknown' @@ -397,6 +455,7 @@ async function createBackupSnapshot(reason = 'scheduled') { adminContent: null, hitStats, visitorStats, + contactSubmissions, } try { @@ -494,6 +553,11 @@ function sanitizeLoadedVisitorStats(value) { } } +function sanitizeLoadedContactSubmissions(value) { + if (!Array.isArray(value)) return [] + return value.slice(0, MAX_CONTACT_SUBMISSIONS) +} + async function restoreFromBackup(filename) { if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) { throw new Error('Invalid backup filename') @@ -512,11 +576,13 @@ async function restoreFromBackup(filename) { hitStats = sanitizeLoadedHitStats(parsed?.hitStats) visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats) + contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions) queueHitStatsWrite() queueVisitorStatsWrite() + queueContactSubmissionsWrite() - await Promise.all([hitStatsWritePromise, visitorStatsWritePromise]) + await Promise.all([hitStatsWritePromise, visitorStatsWritePromise, contactSubmissionsWritePromise]) await createBackupSnapshot('post-restore') } @@ -655,6 +721,9 @@ app.get('/api/admin-stats', (_req, res) => { .map(([pathKey, hits]) => ({ path: pathKey, hits })) const recentVisitorRows = visitorStats.recentVisits.slice(0, 100) + const bibleQuestions = contactSubmissions + .filter(entry => normalizeMessageType(entry?.messageType) === 'question') + .slice(0, 100) res.json({ totalHits: hitStats.totalHits, @@ -680,6 +749,11 @@ app.get('/api/admin-stats', (_req, res) => { visitorStats: lastVisitorStatsWrite, backups: lastBackupStatus, }, + bibleQuestions, + contactTotals: { + totalSubmissions: contactSubmissions.length, + totalQuestions: contactSubmissions.filter(entry => normalizeMessageType(entry?.messageType) === 'question').length, + }, }) }) @@ -697,6 +771,7 @@ app.get('/api/admin-stats/export', async (_req, res) => { adminContent, hitStats, visitorStats, + contactSubmissions, }) }) @@ -784,7 +859,7 @@ function contactRateLimit(req, res, next) { app.post('/api/contact', contactRateLimit, async (req, res) => { try { - const { name, email, message, subscribe, _honey } = req.body ?? {} + const { name, email, message, messageType, subscribe, _honey } = req.body ?? {} // Honeypot — silently discard if filled by a bot if (_honey) { @@ -814,11 +889,20 @@ app.post('/api/contact', contactRateLimit, async (req, res) => { const trimmedName = name.trim() const trimmedEmail = email.trim() const trimmedMessage = message.trim() + const normalizedMessageType = normalizeMessageType(messageType) const submittedAt = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short', }) + addContactSubmission({ + name: trimmedName, + email: trimmedEmail, + message: trimmedMessage, + messageType: normalizedMessageType, + subscribe, + }) + const resend = new Resend(process.env.RESEND_API_KEY) if (subscribe === true) { @@ -860,6 +944,7 @@ app.post('/api/contact', contactRateLimit, async (req, res) => { subject: `Verse by Verse contact form: ${trimmedName}`, text: `New contact form submission\n\n` + + `Message Type: ${normalizedMessageType}\n` + `Name: ${trimmedName}\n` + `Email: ${trimmedEmail}\n` + `Submitted: ${submittedAt}\n\n` + @@ -875,6 +960,10 @@ app.post('/api/contact', contactRateLimit, async (req, res) => { `

A new message was sent from the website contact form. Reply directly to this email to respond to ${escapeHtml(trimmedName)}.

` + `` + `` + + `` + + `` + + `` + + `` + `` + `` + `` + @@ -916,7 +1005,7 @@ app.use(async (_req, res) => { }) const PORT = Number(process.env.PORT ?? 4173) -Promise.all([loadHitStatsFromDisk(), loadVisitorStatsFromDisk()]) +Promise.all([loadHitStatsFromDisk(), loadVisitorStatsFromDisk(), loadContactSubmissionsFromDisk()]) .catch(err => { console.error('[stats] failed to load persisted stats:', err) }) diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index f4bda35..665f934 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -53,6 +53,19 @@ interface AdminStats { visitorStats: { ok: boolean; at: string | null; error: string | null } backups: { ok: boolean; at: string | null; error: string | null; file: string | null } } + bibleQuestions: Array<{ + id: string + submittedAt: string + name: string + email: string + message: string + messageType: 'question' | 'testimony' | 'topic' | 'general' + subscribe: boolean + }> + contactTotals: { + totalSubmissions: number + totalQuestions: number + } } type StringField = Exclude @@ -550,6 +563,52 @@ export default function AdminPage({ content, onSave }: Props) { )} +
+

Bible Questions Inbox

+

Questions submitted from the contact form emails.

+
+ +
+
+

Total Contact Messages

+

{stats.contactTotals.totalSubmissions.toLocaleString()}

+
+
+

Total Bible Questions

+

{stats.contactTotals.totalQuestions.toLocaleString()}

+
+
+ +
+

Recent Bible Questions

+ {stats.bibleQuestions.length === 0 ? ( +

No Bible questions yet.

+ ) : ( +
+
Type${escapeHtml(normalizedMessageType)}
Name${escapeHtml(trimmedName)}
+ + + + + + + + + + {stats.bibleQuestions.map(item => ( + + + + + + + ))} + +
SubmittedNameEmailQuestion
{formatDate(item.submittedAt)}{item.name}{item.email}{item.message}
+ + )} + +

Data Management

Export, backup, or retain only recent analytics data.

diff --git a/src/App.css b/src/App.css index a834bf6..f818245 100644 --- a/src/App.css +++ b/src/App.css @@ -553,7 +553,8 @@ } .contact-form input, -.contact-form textarea { +.contact-form textarea, +.contact-form select { background: #0c0c0c; border: 1px solid rgba(200, 134, 10, 0.25); border-radius: 8px; diff --git a/src/App.tsx b/src/App.tsx index ec14d21..7aa42cd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -105,7 +105,7 @@ function AmazonMusicIcon() { function ContactForm() { const navigate = useNavigate() - const [fields, setFields] = useState({ name: '', email: '', message: '' }) + const [fields, setFields] = useState({ name: '', email: '', message: '', messageType: 'question' }) const [subscribe, setSubscribe] = useState(false) const [honey, setHoney] = useState('') const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle') @@ -115,6 +115,10 @@ function ContactForm() { setFields(f => ({ ...f, [e.target.name]: e.target.value })) } + function handleSelectChange(e: React.ChangeEvent) { + setFields(f => ({ ...f, [e.target.name]: e.target.value })) + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault() setStatus('submitting') @@ -157,6 +161,15 @@ function ContactForm() { Email +