Add Bible Questions inbox from contact submissions
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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) => {
|
||||
`<p style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.6;color:#57452b;">A new message was sent from the website contact form. Reply directly to this email to respond to <strong>${escapeHtml(trimmedName)}</strong>.</p>` +
|
||||
`<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:20px;">` +
|
||||
`<tr>` +
|
||||
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Type</td>` +
|
||||
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(normalizedMessageType)}</td>` +
|
||||
`</tr>` +
|
||||
`<tr>` +
|
||||
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Name</td>` +
|
||||
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(trimmedName)}</td>` +
|
||||
`</tr>` +
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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<keyof SiteContent, 'customLinks' | 'customBlocks'>
|
||||
@@ -550,6 +563,52 @@ export default function AdminPage({ content, onSave }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Bible Questions Inbox</h2>
|
||||
<p>Questions submitted from the contact form emails.</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-stats-grid">
|
||||
<article>
|
||||
<h3>Total Contact Messages</h3>
|
||||
<p>{stats.contactTotals.totalSubmissions.toLocaleString()}</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Total Bible Questions</h3>
|
||||
<p>{stats.contactTotals.totalQuestions.toLocaleString()}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="admin-visits-table-wrap">
|
||||
<h3>Recent Bible Questions</h3>
|
||||
{stats.bibleQuestions.length === 0 ? (
|
||||
<p className="admin-stats-note">No Bible questions yet.</p>
|
||||
) : (
|
||||
<div className="admin-visits-table-scroll">
|
||||
<table className="admin-visits-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Submitted</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Question</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.bibleQuestions.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td>{formatDate(item.submittedAt)}</td>
|
||||
<td>{item.name}</td>
|
||||
<td>{item.email}</td>
|
||||
<td>{item.message}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Data Management</h2>
|
||||
<p>Export, backup, or retain only recent analytics data.</p>
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+14
-1
@@ -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<HTMLSelectElement>) {
|
||||
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
|
||||
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
|
||||
</label>
|
||||
<label>
|
||||
Message Type
|
||||
<select name="messageType" value={fields.messageType} onChange={handleSelectChange}>
|
||||
<option value="question">Bible Question</option>
|
||||
<option value="testimony">Testimony</option>
|
||||
<option value="topic">Topic Request</option>
|
||||
<option value="general">General Message</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Message
|
||||
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
|
||||
|
||||
Reference in New Issue
Block a user