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.
|
- Click **Save project** to apply updates instantly.
|
||||||
- Saved edits are written to `data/admin-content.json` through the API server.
|
- 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).
|
- 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.
|
- 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 now includes maintenance actions: **Export JSON**, **Backup Now**, **Prune Old Data**, and **Clear Analytics**.
|
||||||
- Admin also supports restoring from a backup snapshot from `/admin`.
|
- 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 DATA_FILE = path.join(DATA_DIR, 'admin-content.json')
|
||||||
const HIT_STATS_FILE = path.join(DATA_DIR, 'hit-stats.json')
|
const HIT_STATS_FILE = path.join(DATA_DIR, 'hit-stats.json')
|
||||||
const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-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 BACKUP_DIR = path.join(DATA_DIR, 'backups')
|
||||||
const DIST_DIR = path.join(__dirname, 'dist')
|
const DIST_DIR = path.join(__dirname, 'dist')
|
||||||
const INDEX_FILE = path.join(DIST_DIR, 'index.html')
|
const INDEX_FILE = path.join(DIST_DIR, 'index.html')
|
||||||
@@ -61,8 +62,12 @@ const EMPTY_VISITOR_STATS = {
|
|||||||
geoCacheByIp: {},
|
geoCacheByIp: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_CONTACT_SUBMISSIONS = 5000
|
||||||
|
|
||||||
let visitorStats = { ...EMPTY_VISITOR_STATS }
|
let visitorStats = { ...EMPTY_VISITOR_STATS }
|
||||||
let visitorStatsWritePromise = Promise.resolve()
|
let visitorStatsWritePromise = Promise.resolve()
|
||||||
|
let contactSubmissions = []
|
||||||
|
let contactSubmissionsWritePromise = Promise.resolve()
|
||||||
let lastVisitorStatsWrite = { ok: true, at: null, error: null }
|
let lastVisitorStatsWrite = { ok: true, at: null, error: null }
|
||||||
let lastHitStatsWrite = { ok: true, at: null, error: null }
|
let lastHitStatsWrite = { ok: true, at: null, error: null }
|
||||||
let lastBackupStatus = { ok: true, at: null, error: null, file: 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) {
|
function sanitizeUserAgent(userAgent) {
|
||||||
if (!userAgent || typeof userAgent !== 'string') return 'unknown'
|
if (!userAgent || typeof userAgent !== 'string') return 'unknown'
|
||||||
return userAgent.trim().slice(0, 300) || 'unknown'
|
return userAgent.trim().slice(0, 300) || 'unknown'
|
||||||
@@ -397,6 +455,7 @@ async function createBackupSnapshot(reason = 'scheduled') {
|
|||||||
adminContent: null,
|
adminContent: null,
|
||||||
hitStats,
|
hitStats,
|
||||||
visitorStats,
|
visitorStats,
|
||||||
|
contactSubmissions,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
async function restoreFromBackup(filename) {
|
||||||
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) {
|
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) {
|
||||||
throw new Error('Invalid backup filename')
|
throw new Error('Invalid backup filename')
|
||||||
@@ -512,11 +576,13 @@ async function restoreFromBackup(filename) {
|
|||||||
|
|
||||||
hitStats = sanitizeLoadedHitStats(parsed?.hitStats)
|
hitStats = sanitizeLoadedHitStats(parsed?.hitStats)
|
||||||
visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats)
|
visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats)
|
||||||
|
contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions)
|
||||||
|
|
||||||
queueHitStatsWrite()
|
queueHitStatsWrite()
|
||||||
queueVisitorStatsWrite()
|
queueVisitorStatsWrite()
|
||||||
|
queueContactSubmissionsWrite()
|
||||||
|
|
||||||
await Promise.all([hitStatsWritePromise, visitorStatsWritePromise])
|
await Promise.all([hitStatsWritePromise, visitorStatsWritePromise, contactSubmissionsWritePromise])
|
||||||
await createBackupSnapshot('post-restore')
|
await createBackupSnapshot('post-restore')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,6 +721,9 @@ app.get('/api/admin-stats', (_req, res) => {
|
|||||||
.map(([pathKey, hits]) => ({ path: pathKey, hits }))
|
.map(([pathKey, hits]) => ({ path: pathKey, hits }))
|
||||||
|
|
||||||
const recentVisitorRows = visitorStats.recentVisits.slice(0, 100)
|
const recentVisitorRows = visitorStats.recentVisits.slice(0, 100)
|
||||||
|
const bibleQuestions = contactSubmissions
|
||||||
|
.filter(entry => normalizeMessageType(entry?.messageType) === 'question')
|
||||||
|
.slice(0, 100)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
totalHits: hitStats.totalHits,
|
totalHits: hitStats.totalHits,
|
||||||
@@ -680,6 +749,11 @@ app.get('/api/admin-stats', (_req, res) => {
|
|||||||
visitorStats: lastVisitorStatsWrite,
|
visitorStats: lastVisitorStatsWrite,
|
||||||
backups: lastBackupStatus,
|
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,
|
adminContent,
|
||||||
hitStats,
|
hitStats,
|
||||||
visitorStats,
|
visitorStats,
|
||||||
|
contactSubmissions,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -784,7 +859,7 @@ function contactRateLimit(req, res, next) {
|
|||||||
|
|
||||||
app.post('/api/contact', contactRateLimit, async (req, res) => {
|
app.post('/api/contact', contactRateLimit, async (req, res) => {
|
||||||
try {
|
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
|
// Honeypot — silently discard if filled by a bot
|
||||||
if (_honey) {
|
if (_honey) {
|
||||||
@@ -814,11 +889,20 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
|
|||||||
const trimmedName = name.trim()
|
const trimmedName = name.trim()
|
||||||
const trimmedEmail = email.trim()
|
const trimmedEmail = email.trim()
|
||||||
const trimmedMessage = message.trim()
|
const trimmedMessage = message.trim()
|
||||||
|
const normalizedMessageType = normalizeMessageType(messageType)
|
||||||
const submittedAt = new Date().toLocaleString('en-US', {
|
const submittedAt = new Date().toLocaleString('en-US', {
|
||||||
dateStyle: 'medium',
|
dateStyle: 'medium',
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
addContactSubmission({
|
||||||
|
name: trimmedName,
|
||||||
|
email: trimmedEmail,
|
||||||
|
message: trimmedMessage,
|
||||||
|
messageType: normalizedMessageType,
|
||||||
|
subscribe,
|
||||||
|
})
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
|
|
||||||
if (subscribe === true) {
|
if (subscribe === true) {
|
||||||
@@ -860,6 +944,7 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
|
|||||||
subject: `Verse by Verse contact form: ${trimmedName}`,
|
subject: `Verse by Verse contact form: ${trimmedName}`,
|
||||||
text:
|
text:
|
||||||
`New contact form submission\n\n` +
|
`New contact form submission\n\n` +
|
||||||
|
`Message Type: ${normalizedMessageType}\n` +
|
||||||
`Name: ${trimmedName}\n` +
|
`Name: ${trimmedName}\n` +
|
||||||
`Email: ${trimmedEmail}\n` +
|
`Email: ${trimmedEmail}\n` +
|
||||||
`Submitted: ${submittedAt}\n\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>` +
|
`<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;">` +
|
`<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:20px;">` +
|
||||||
`<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;">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="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>` +
|
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(trimmedName)}</td>` +
|
||||||
`</tr>` +
|
`</tr>` +
|
||||||
@@ -916,7 +1005,7 @@ app.use(async (_req, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT ?? 4173)
|
const PORT = Number(process.env.PORT ?? 4173)
|
||||||
Promise.all([loadHitStatsFromDisk(), loadVisitorStatsFromDisk()])
|
Promise.all([loadHitStatsFromDisk(), loadVisitorStatsFromDisk(), loadContactSubmissionsFromDisk()])
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('[stats] failed to load persisted stats:', 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 }
|
visitorStats: { ok: boolean; at: string | null; error: string | null }
|
||||||
backups: { ok: boolean; at: string | null; error: string | null; file: 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'>
|
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks'>
|
||||||
@@ -550,6 +563,52 @@ export default function AdminPage({ content, onSave }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="admin-stats-head admin-stats-head--visitors">
|
||||||
<h2>Data Management</h2>
|
<h2>Data Management</h2>
|
||||||
<p>Export, backup, or retain only recent analytics data.</p>
|
<p>Export, backup, or retain only recent analytics data.</p>
|
||||||
|
|||||||
+2
-1
@@ -553,7 +553,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.contact-form input,
|
.contact-form input,
|
||||||
.contact-form textarea {
|
.contact-form textarea,
|
||||||
|
.contact-form select {
|
||||||
background: #0c0c0c;
|
background: #0c0c0c;
|
||||||
border: 1px solid rgba(200, 134, 10, 0.25);
|
border: 1px solid rgba(200, 134, 10, 0.25);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
+14
-1
@@ -105,7 +105,7 @@ function AmazonMusicIcon() {
|
|||||||
|
|
||||||
function ContactForm() {
|
function ContactForm() {
|
||||||
const navigate = useNavigate()
|
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 [subscribe, setSubscribe] = useState(false)
|
||||||
const [honey, setHoney] = useState('')
|
const [honey, setHoney] = useState('')
|
||||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
|
||||||
@@ -115,6 +115,10 @@ function ContactForm() {
|
|||||||
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
|
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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setStatus('submitting')
|
setStatus('submitting')
|
||||||
@@ -157,6 +161,15 @@ function ContactForm() {
|
|||||||
Email
|
Email
|
||||||
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
|
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
|
||||||
</label>
|
</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>
|
<label>
|
||||||
Message
|
Message
|
||||||
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
|
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
|
||||||
|
|||||||
Reference in New Issue
Block a user