Implement email tab redesign, archive support, manual Q&A creation, URL linking, and question admin tools

- Add dedicated Emails tab with inbox/archive views and two-pane layout
- Implement archive/unarchive for contact submissions with persisted state
- Add manual question creation endpoint and admin form (non-contact origin)
- Implement URL auto-linking in Q&A answers with safe rendering
- Add question admin tools: search, filter (All/Pending/Approved/Answered/Unanswered), pagination
- Expand admin panel widths to reduce cramping
- Restore and enhance Asset Manager table layout
- Update email reply template with fixed from-address and HTML support
This commit is contained in:
nmemmert
2026-05-07 09:43:16 -04:00
parent bc3d906e64
commit 583a9b7b66
8 changed files with 1420 additions and 36 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"items": [
{
"id": "da3f5f52-f0a3-4a36-9f52-7938b82e899a",
"submissionId": "0c2e3dc4-5ee3-42e9-a81b-172bedc16713",
"toEmail": "hello@versebyversewithnate.us",
"toName": "NoAdmin Signup",
"fromEmail": "hello@versebyversewithnate.us",
"subject": "Thanks for reaching out to Verse by Verse with Nate",
"preview": "Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally. I just wanted to say thank you for your email.\n\n~Nate",
"sentAt": "2026-05-07T12:54:06.683Z"
}
],
"updatedAt": "2026-05-07T12:54:06.685Z"
}
+102
View File
@@ -0,0 +1,102 @@
{
"questions": [
{
"id": "cb_001",
"firstName": "Nate",
"question": "What Bible translation do you use?",
"answer": "In the podcast, I primarily teach from the BSB (Berean Standard Bible). I may compare other translations at times, but BSB is my main teaching text in the Verse by Verse episodes.",
"isApproved": true,
"topic": "Bible Study",
"answeredAt": "2026-05-07T13:36:16.859Z"
},
{
"id": "cb_002",
"firstName": "Nate",
"question": "How do you approach a difficult or confusing Bible passage?",
"answer": "I always start by reading the surrounding context — a confusing verse often makes perfect sense once you zoom out a chapter or two. Then I look at who the author was, who they were writing to, and what was happening historically. A good study Bible or a commentary like Matthew Henry's can be a huge help. Most importantly, pray for understanding before you dive in. The Holy Spirit is your best guide.",
"isApproved": true,
"topic": "Bible Study"
},
{
"id": "cb_003",
"firstName": "Nate",
"question": "How do I stay consistent with daily Bible reading?",
"answer": "Start small and protect that time like an appointment. Even 10 minutes in the morning before checking your phone can be transformative over a year. A reading plan helps a lot — there are great ones on apps like YouVersion that break the whole Bible into daily chunks so you never have to decide what to read. And give yourself grace on the days you miss; guilt is not a good motivator. Just pick back up where you left off.",
"isApproved": true,
"topic": "Bible Study"
},
{
"id": "cb_007",
"firstName": "Nate",
"question": "How do I submit a question to Nate?",
"answer": "You can submit a question using the contact form at the bottom of this page. Select 'Bible Question' as the message type. Nate reads every question personally and answers the best ones on the site's Q&A section or in a future episode. There's no guarantee every question gets a public response, but all are read and appreciated!",
"isApproved": true,
"topic": "Podcast"
},
{
"id": "cb_faith_family_001",
"firstName": "Nate",
"question": "How can I share my faith with skeptical family?",
"answer": "Lead with relationship, not argument. Skeptical family members usually don't need more information — they need to see the faith lived out genuinely. Consistency, love, and patience over time speaks louder than any debate win.\n\nPray specifically and persistently. This is the most underrated strategy. God moves hearts in ways arguments never can.",
"isApproved": true,
"topic": "Faith & Life"
},
{
"id": "cb_blessed_hope_001",
"firstName": "Nate",
"question": "What is the blessed hope?",
"answer": "The blessed hope is a phrase from Titus 2:13 — \"as we await the blessed hope and glorious appearance of our great God and Savior Jesus Christ.\" In Episode 9, Nate breaks down the Greek: makaria elpis. Makaria is the same word used in the Beatitudes — a divinely favored condition. And elpis in the New Testament is not wishful thinking. Biblical hope is a confident expectation of something certain. Not a wish. An anchor.\n\nThe blessed hope is simply this: Jesus is coming back. Not maybe. He is coming back. And the person who really believes that is in a blessed condition even now, living toward a certainty that changes how everything feels today.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_who_is_titus_001",
"firstName": "Nate",
"question": "Who was Titus?",
"answer": "Titus was a Gentile — not Jewish — who came to faith through Paul's ministry. Paul calls him \"my true child in our common faith\" (Titus 1:4). He was one of Paul's closest and most trusted co-workers. In 2 Corinthians 7, Paul describes being comforted by Titus arriving during a difficult season. In chapter 8, Titus helped organize a relief offering for believers in Jerusalem. In Galatians 2, he appears as proof that the gospel is for everyone.\n\nPaul left Titus on the island of Crete with a difficult assignment: appoint elders, correct false teaching, and help a young church find its footing in a challenging culture. The letter of Titus is essentially his briefing.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_deny_works_001",
"firstName": "Nate",
"question": "What does it mean to deny God by your works?",
"answer": "Titus 1:16 says: \"They profess to know God, but by their actions they deny Him. They are detestable, disobedient, and unfit for any good deed.\"\n\nIn Episode 6, Nate explains this using three precise words Paul chose. Detestable — morally repulsive before God, regardless of religious appearance. Disobedient — not persuadable, resistant to truth even when confronted with it. Unfit — a Greek metalworking term for metal tested and found counterfeit. Religious on the surface, but no real substance underneath.\n\nThe warning cuts both ways: a person can know the right words, attend the right services, hold the right opinions — and still live in a way that functionally says God doesn't matter. Theology and life are inseparable. What you actually believe will show itself in how you actually live.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_grace_trains_001",
"firstName": "Nate",
"question": "What does grace actually train us to do?",
"answer": "Titus 2:11-12 says grace \"instructs us to renounce ungodliness and worldly passions, and to live sensible, upright, and godly lives.\"\n\nIn Episode 8, Nate points out that the Greek word for instructs is paideuō — the education of a child. Not classroom instruction but formation. Grace is the subject doing the teaching.\n\nGrace teaches two things: what to say no to (ungodliness and worldly passions) and what to say yes to — sensible (an ordered mind), upright (right in relationship with others), and godly (God genuinely at the center). Inward, outward, and upward.\n\nThe key insight: the pathway to saying no to sin is going deeper into grace, not trying harder. Grace properly understood reorients your desires — it doesn't just change the rules, it changes what you want. Grace is not a hall pass. It's a school you attend every day.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_leadership_titus1_001",
"firstName": "Nate",
"question": "What does Titus 1 teach about church leadership?",
"answer": "Paul's first instruction in Titus was to appoint elders — because a church without mature, qualified leadership is vulnerable. From Episode 4 (Titus 1:5-9):\n\nThe elder's home is exhibit A. Before looking at preaching or public presence, look at the household. If a man cannot lead his own family well, there's no reason to believe he'll lead God's family well. Integrity can't be compartmentalized.\n\nPaul gives five things an elder must NOT be: not self-absorbed, not quick-tempered, not given to drunkenness, not violent, not greedy. And six things he must BE: hospitable, a lover of good, self-controlled, upright, holy, and disciplined in sound doctrine.\n\nThis is not a job description for a CEO. Nate frames it as a picture of Christlike character — authority that flows from below, from a posture of submission and service, not dominance.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_titus347_summary_001",
"firstName": "Nate",
"question": "Give me a summary of Titus 3:4-7",
"answer": "Titus 3:4-7 is what Nate calls 'the gospel in one paragraph' — one of the most concentrated summaries of salvation anywhere in Paul's letters. Episode 12 breaks it down:\n\nAll three persons of the Trinity are present: God the Father is the initiating motivation — He is kind and full of love toward humanity. The Holy Spirit is the agent of new birth and ongoing renewal. Jesus Christ is the one through whom the Spirit is poured out, and verse 7 calls him 'our Savior.'\n\nThe hinge of the passage is verse 5: He saved us not by the righteous deeds we had done, but according to His mercy. Not partially. Not mostly. The basis is His mercy, not our merit — which means salvation rests on something that cannot change.\n\nVerse 7 gives us two results: justified (declared righteous in God's courtroom) and heirs (members of the family with a guaranteed inheritance). You are not just legally acquitted — you belong.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_christians_politics_001",
"firstName": "Nate",
"question": "How should Christians engage with politics and public life?",
"answer": "Titus 3:1-2 is Paul's answer, covered in Episode 10. He tells Titus to remind believers to submit to rulers and authorities, be obedient, be ready for every good work, malign no one, and be peaceable, gentle, and considerate to everyone.\n\nNate's key insight: Paul is not saying agree with every government decision. He's saying your engagement with public life should be marked by something recognizable as different. Submission, readiness to do good, and a peaceable presence are not just civic virtues — they are missionary strategies. The way you navigate public life either opens doors for the gospel or closes them.\n\nThe word for peaceable in Greek is amachous — literally non-combative. Paul doesn't say malign no one except your political opponents. He says no one. This is not weakness. It is the confidence of people who know how the story ends and don't need to win every argument to prove it.",
"isApproved": true,
"topic": "Faith & Life"
}
],
"updatedAt": "2026-05-07T13:36:16.859Z"
}
+370 -3
View File
@@ -53,6 +53,8 @@ const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-stats.json')
const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json') const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json')
const QUESTIONS_FILE = path.join(DATA_DIR, 'questions.json') const QUESTIONS_FILE = path.join(DATA_DIR, 'questions.json')
const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json') const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json')
const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
const BACKUP_DIR = path.join(DATA_DIR, 'backups') const BACKUP_DIR = path.join(DATA_DIR, 'backups')
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads') const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json') const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
@@ -159,11 +161,36 @@ const DEFAULT_PUBLISH_STATE = {
publishedAt: null, publishedAt: null,
} }
const DEFAULT_REPLY_TEMPLATES = [
{
id: 'thanks-for-reaching-out',
label: 'Thank You Reply',
subject: 'Thanks for reaching out to Verse by Verse with Nate',
message: 'Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally.',
},
{
id: 'question-received',
label: 'Question Received',
subject: 'Your Bible question was received',
message: 'Thank you for sending your Bible question.\n\nI have received it, and I appreciate you taking the time to write in.',
},
{
id: 'testimony-thank-you',
label: 'Testimony Thank You',
subject: 'Thank you for sharing your testimony',
message: 'Thank you for sharing what the Lord is doing in your life.\n\nYour message was an encouragement to read.',
},
]
let cachedSiteContent = null let cachedSiteContent = null
let cachedDraftSiteContent = null let cachedDraftSiteContent = null
let publishState = { ...DEFAULT_PUBLISH_STATE } let publishState = { ...DEFAULT_PUBLISH_STATE }
let draftQuestions = null let draftQuestions = null
let draftQuestionsWritePromise = Promise.resolve() let draftQuestionsWritePromise = Promise.resolve()
let replyTemplates = [...DEFAULT_REPLY_TEMPLATES]
let replyTemplatesWritePromise = Promise.resolve()
let replyHistory = []
let replyHistoryWritePromise = Promise.resolve()
async function loadSiteContentFile(filePath) { async function loadSiteContentFile(filePath) {
const raw = await readFile(filePath, 'utf8') const raw = await readFile(filePath, 'utf8')
@@ -331,6 +358,38 @@ let lastBackupStatus = { ok: true, at: null, error: null, file: null }
let lastCachePurgeStatus = { ok: true, at: null, error: null } let lastCachePurgeStatus = { ok: true, at: null, error: null }
let lastDeployHookStatus = { ok: true, at: null, error: null } let lastDeployHookStatus = { ok: true, at: null, error: null }
function sanitizeReplyTemplates(value) {
if (!Array.isArray(value)) return [...DEFAULT_REPLY_TEMPLATES]
const out = value
.filter(item => item && typeof item === 'object')
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
label: typeof item.label === 'string' ? item.label.trim().slice(0, 80) : '',
subject: typeof item.subject === 'string' ? item.subject.trim().slice(0, 180) : '',
message: typeof item.message === 'string' ? item.message.trim().slice(0, 6000) : '',
}))
.filter(item => item.label && item.subject && item.message)
return out.length > 0 ? out : [...DEFAULT_REPLY_TEMPLATES]
}
function sanitizeReplyHistory(value) {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object')
.slice(0, 500)
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
submissionId: typeof item.submissionId === 'string' ? item.submissionId : '',
toEmail: typeof item.toEmail === 'string' ? item.toEmail.trim().slice(0, 320) : '',
toName: typeof item.toName === 'string' ? item.toName.trim().slice(0, 200) : '',
fromEmail: typeof item.fromEmail === 'string' ? item.fromEmail.trim().slice(0, 320) : 'hello@versebyversewithnate.us',
subject: typeof item.subject === 'string' ? item.subject.trim().slice(0, 180) : '',
preview: typeof item.preview === 'string' ? item.preview.trim().slice(0, 500) : '',
sentAt: typeof item.sentAt === 'string' ? item.sentAt : new Date().toISOString(),
}))
}
function normalizeIp(rawIp) { function normalizeIp(rawIp) {
if (!rawIp) return 'unknown' if (!rawIp) return 'unknown'
@@ -403,6 +462,36 @@ function queueContactSubmissionsWrite() {
}) })
} }
function queueReplyTemplatesWrite() {
replyTemplatesWritePromise = replyTemplatesWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
REPLY_TEMPLATES_FILE,
JSON.stringify({ templates: replyTemplates, updatedAt: new Date().toISOString() }, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[reply-templates] failed to write templates:', err)
})
}
function queueReplyHistoryWrite() {
replyHistoryWritePromise = replyHistoryWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
REPLY_HISTORY_FILE,
JSON.stringify({ items: replyHistory, updatedAt: new Date().toISOString() }, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[reply-history] failed to write history:', err)
})
}
function loadContactSubmissionsFromDisk() { function loadContactSubmissionsFromDisk() {
return readFile(CONTACT_SUBMISSIONS_FILE, 'utf8') return readFile(CONTACT_SUBMISSIONS_FILE, 'utf8')
.then(raw => { .then(raw => {
@@ -416,17 +505,76 @@ function loadContactSubmissionsFromDisk() {
}) })
} }
function loadReplyTemplatesFromDisk() {
return readFile(REPLY_TEMPLATES_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
replyTemplates = sanitizeReplyTemplates(parsed?.templates)
})
.catch(() => {
replyTemplates = [...DEFAULT_REPLY_TEMPLATES]
})
}
function loadReplyHistoryFromDisk() {
return readFile(REPLY_HISTORY_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
replyHistory = sanitizeReplyHistory(parsed?.items)
})
.catch(() => {
replyHistory = []
})
}
function normalizeMessageType(value) { function normalizeMessageType(value) {
if (value === 'question' || value === 'testimony' || value === 'topic') return value if (value === 'question' || value === 'testimony' || value === 'topic') return value
return 'general' return 'general'
} }
const USE_RESEND_AUTOMATION_WELCOME = process.env.RESEND_AUTOMATION_WELCOME === 'true' const USE_RESEND_AUTOMATION_WELCOME = process.env.RESEND_AUTOMATION_WELCOME === 'true'
const ADMIN_REPLY_FROM = 'Verse by Verse with Nate <hello@versebyversewithnate.us>'
function shouldSendWelcomeEmail({ subscribe }) { function shouldSendWelcomeEmail({ subscribe }) {
return subscribe === true return subscribe === true
} }
function buildAdminReplyTemplate({ recipientName, message }) {
const safeRecipientName = escapeHtml(recipientName || 'friend')
const safeMessage = escapeHtml(message).replace(/\n/g, '<br/>')
return `
<div style="margin:0;padding:0;background-color:#f5f1e8;font-family:Georgia,serif;color:#201a10;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#f5f1e8;">
<tr>
<td align="center" style="padding:28px 16px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:680px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
<tr>
<td style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>
<h1 style="margin:10px 0 0;color:#f4ead5;font-size:26px;line-height:1.2;">A Personal Reply</h1>
</td>
</tr>
<tr>
<td style="padding:26px 24px 18px;">
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:16px;line-height:1.6;color:#201a10;">Hi ${safeRecipientName},</p>
<div style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeMessage}</div>
<p style="margin:0;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">Grace and peace,<br/>Verse by Verse with Nate</p>
</td>
</tr>
<tr>
<td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:14px 24px;">
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.5;color:#735a2b;">From: hello@versebyversewithnate.us</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
`
}
function addContactSubmission({ name, email, message, messageType, subscribe }) { function addContactSubmission({ name, email, message, messageType, subscribe }) {
const submission = { const submission = {
id: randomUUID(), id: randomUUID(),
@@ -436,6 +584,7 @@ function addContactSubmission({ name, email, message, messageType, subscribe })
message, message,
messageType: normalizeMessageType(messageType), messageType: normalizeMessageType(messageType),
subscribe: subscribe === true, subscribe: subscribe === true,
archived: false,
} }
contactSubmissions.unshift(submission) contactSubmissions.unshift(submission)
@@ -735,6 +884,8 @@ async function createBackupSnapshot(reason = 'scheduled') {
hitStats, hitStats,
visitorStats, visitorStats,
contactSubmissions, contactSubmissions,
replyTemplates,
replyHistory,
} }
try { try {
@@ -841,7 +992,19 @@ function sanitizeLoadedVisitorStats(value) {
function sanitizeLoadedContactSubmissions(value) { function sanitizeLoadedContactSubmissions(value) {
if (!Array.isArray(value)) return [] if (!Array.isArray(value)) return []
return value.slice(0, MAX_CONTACT_SUBMISSIONS) return value
.slice(0, MAX_CONTACT_SUBMISSIONS)
.filter(entry => entry && typeof entry === 'object')
.map(entry => ({
id: typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : randomUUID(),
submittedAt: typeof entry.submittedAt === 'string' ? entry.submittedAt : new Date().toISOString(),
name: typeof entry.name === 'string' ? entry.name.trim().slice(0, 200) : '',
email: typeof entry.email === 'string' ? entry.email.trim().slice(0, 320) : '',
message: typeof entry.message === 'string' ? entry.message.trim().slice(0, 3000) : '',
messageType: normalizeMessageType(entry.messageType),
subscribe: entry.subscribe === true,
archived: entry.archived === true,
}))
} }
async function restoreFromBackup(filename) { async function restoreFromBackup(filename) {
@@ -875,12 +1038,16 @@ async function restoreFromBackup(filename) {
hitStats = sanitizeLoadedHitStats(parsed?.hitStats) hitStats = sanitizeLoadedHitStats(parsed?.hitStats)
visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats) visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats)
contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions) contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions)
replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates)
replyHistory = sanitizeReplyHistory(parsed?.replyHistory)
queueHitStatsWrite() queueHitStatsWrite()
queueVisitorStatsWrite() queueVisitorStatsWrite()
queueContactSubmissionsWrite() queueContactSubmissionsWrite()
queueReplyTemplatesWrite()
queueReplyHistoryWrite()
await Promise.all([hitStatsWritePromise, visitorStatsWritePromise, contactSubmissionsWritePromise]) await Promise.all([hitStatsWritePromise, visitorStatsWritePromise, contactSubmissionsWritePromise, replyTemplatesWritePromise, replyHistoryWritePromise])
await refreshContentCaches() await refreshContentCaches()
await createBackupSnapshot('post-restore') await createBackupSnapshot('post-restore')
} }
@@ -974,7 +1141,8 @@ function loadHitStatsFromDisk() {
const app = express() const app = express()
app.use(express.json({ limit: '10mb' })) app.use(express.json({ limit: '10mb' }))
app.set('trust proxy', true) const trustProxyHops = Number(process.env.TRUST_PROXY_HOPS ?? 1)
app.set('trust proxy', Number.isFinite(trustProxyHops) && trustProxyHops >= 0 ? trustProxyHops : 1)
app.get('/api/admin-content', async (req, res) => { app.get('/api/admin-content', async (req, res) => {
const source = req.query?.source === 'draft' ? 'draft' : 'published' const source = req.query?.source === 'draft' ? 'draft' : 'published'
@@ -1482,6 +1650,152 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
}) })
}) })
app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => {
res.json({ submissions: contactSubmissions.slice(0, 300) })
})
app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
if (typeof id !== 'string' || !id.trim()) {
res.status(400).json({ message: 'Invalid submission id.' })
return
}
const archived = req.body?.archived === true
let found = false
contactSubmissions = contactSubmissions.map(item => {
if (item.id !== id) return item
found = true
return { ...item, archived }
})
if (!found) {
res.status(404).json({ message: 'Submission not found.' })
return
}
queueContactSubmissionsWrite()
res.json({ ok: true, archived })
})
app.get('/api/admin-reply-config', requireAdminAuth, (_req, res) => {
res.json({
fromEmail: 'hello@versebyversewithnate.us',
fromIdentity: ADMIN_REPLY_FROM,
resendApiConfigured: Boolean(process.env.RESEND_API_KEY),
canSendReplies: Boolean(process.env.RESEND_API_KEY),
note: process.env.RESEND_API_KEY
? 'App is configured to attempt sends through Resend. Delivery still depends on Resend sender/domain verification.'
: 'RESEND_API_KEY is missing, so admin replies cannot be sent yet.',
})
})
app.get('/api/admin-contact-reply-templates', requireAdminAuth, (_req, res) => {
res.json({ templates: replyTemplates })
})
app.put('/api/admin-contact-reply-templates', requireAdminAuth, (req, res) => {
const nextTemplates = sanitizeReplyTemplates(req.body?.templates)
replyTemplates = nextTemplates
queueReplyTemplatesWrite()
res.json({ ok: true, templates: replyTemplates })
})
app.get('/api/admin-contact-reply-history', requireAdminAuth, (_req, res) => {
res.json({ items: replyHistory.slice(0, 100) })
})
app.delete('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
if (typeof id !== 'string' || !id.trim()) {
res.status(400).json({ message: 'Invalid submission id.' })
return
}
const startLength = contactSubmissions.length
contactSubmissions = contactSubmissions.filter(item => item.id !== id)
if (contactSubmissions.length === startLength) {
res.status(404).json({ message: 'Submission not found.' })
return
}
queueContactSubmissionsWrite()
res.json({ ok: true })
})
app.post('/api/admin-contact-submissions/:id/reply', requireAdminAuth, async (req, res) => {
try {
if (!process.env.RESEND_API_KEY) {
res.status(503).json({ message: 'RESEND_API_KEY is not configured on the server.' })
return
}
const { id } = req.params
const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : ''
const message = typeof req.body?.message === 'string' ? req.body.message.trim() : ''
if (!id || typeof id !== 'string') {
res.status(400).json({ message: 'Invalid submission id.' })
return
}
if (!subject || subject.length > 180) {
res.status(400).json({ message: 'Subject is required and must be 180 characters or fewer.' })
return
}
if (!message || message.length > 6000) {
res.status(400).json({ message: 'Message is required and must be 6000 characters or fewer.' })
return
}
const submission = contactSubmissions.find(entry => entry.id === id)
if (!submission) {
res.status(404).json({ message: 'Submission not found.' })
return
}
if (!submission.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(submission.email)) {
res.status(400).json({ message: 'Submission does not have a valid email address.' })
return
}
const recipientName = splitName(submission.name).firstName || submission.name || 'friend'
const html = buildAdminReplyTemplate({ recipientName, message })
const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate\nhello@versebyversewithnate.us`
const resend = new Resend(process.env.RESEND_API_KEY)
const { error } = await resend.emails.send({
from: ADMIN_REPLY_FROM,
to: [submission.email],
subject,
replyTo: 'hello@versebyversewithnate.us',
text,
html,
})
if (error) throw error
replyHistory.unshift({
id: randomUUID(),
submissionId: submission.id,
toEmail: submission.email,
toName: submission.name,
fromEmail: 'hello@versebyversewithnate.us',
subject,
preview: message.slice(0, 500),
sentAt: new Date().toISOString(),
})
replyHistory = replyHistory.slice(0, 500)
queueReplyHistoryWrite()
res.json({ ok: true })
} catch (err) {
console.error('[admin-reply] send error:', err)
res.status(500).json({ message: 'Failed to send reply email.' })
}
})
app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => { app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => {
let adminContent = null let adminContent = null
let draftContent = null let draftContent = null
@@ -1507,6 +1821,8 @@ app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => {
hitStats, hitStats,
visitorStats, visitorStats,
contactSubmissions, contactSubmissions,
replyTemplates,
replyHistory,
}) })
}) })
@@ -2095,6 +2411,55 @@ app.get('/api/questions', (_req, res) => {
res.json({ questions: publicQuestions }) res.json({ questions: publicQuestions })
}) })
// Create a manual question (admin)
app.post('/api/admin-questions', requireAdminAuth, (req, res) => {
const firstName = typeof req.body?.firstName === 'string' ? req.body.firstName.trim() : ''
const email = typeof req.body?.email === 'string' ? req.body.email.trim() : ''
const questionText = typeof req.body?.question === 'string' ? req.body.question.trim() : ''
const answerText = typeof req.body?.answer === 'string' ? req.body.answer.trim() : ''
const approveNow = req.body?.approve === true
if (!firstName || firstName.length > 100) {
res.status(400).json({ message: 'First name is required and must be 100 characters or fewer.' })
return
}
if (!questionText || questionText.length < 5 || questionText.length > 3000) {
res.status(400).json({ message: 'Question must be between 5 and 3000 characters.' })
return
}
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
res.status(400).json({ message: 'If provided, email must be a valid email address.' })
return
}
if (answerText.length > 5000) {
res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' })
return
}
ensureDraftQuestions()
const now = new Date().toISOString()
const created = {
id: randomUUID(),
submittedAt: now,
firstName,
email,
question: questionText,
answer: answerText,
answeredAt: answerText ? now : null,
isApproved: approveNow,
approvedAt: approveNow ? now : null,
}
draftQuestions.unshift(created)
draftQuestions = draftQuestions.slice(0, MAX_QUESTIONS)
queueDraftQuestionsWrite()
res.status(201).json({ ok: true, question: created })
})
// Answer a question (admin) // Answer a question (admin)
app.post('/api/admin-questions/:id/answer', requireAdminAuth, (req, res) => { app.post('/api/admin-questions/:id/answer', requireAdminAuth, (req, res) => {
const { id } = req.params const { id } = req.params
@@ -2320,6 +2685,8 @@ Promise.all([
loadHitStatsFromDisk(), loadHitStatsFromDisk(),
loadVisitorStatsFromDisk(), loadVisitorStatsFromDisk(),
loadContactSubmissionsFromDisk(), loadContactSubmissionsFromDisk(),
loadReplyTemplatesFromDisk(),
loadReplyHistoryFromDisk(),
loadQuestionsFromDisk(), loadQuestionsFromDisk(),
loadDraftQuestionsFromDisk(), loadDraftQuestionsFromDisk(),
refreshContentCaches(), refreshContentCaches(),
+13
View File
@@ -174,6 +174,9 @@ function sanitizeCustomLinks(value) {
description: typeof item.description === 'string' ? item.description.trim().slice(0, 4000) : '', description: typeof item.description === 'string' ? item.description.trim().slice(0, 4000) : '',
amazonUrl: sanitizeUrl(item.amazonUrl), amazonUrl: sanitizeUrl(item.amazonUrl),
amazonLabel: typeof item.amazonLabel === 'string' ? item.amazonLabel.trim().slice(0, 120) : '', amazonLabel: typeof item.amazonLabel === 'string' ? item.amazonLabel.trim().slice(0, 120) : '',
tags: Array.isArray(item.tags)
? item.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean).slice(0, 20)
: [],
placement, placement,
} }
}) })
@@ -188,6 +191,9 @@ function sanitizeCustomBlocks(value) {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '', heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '',
body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '', body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '',
page: item?.page === 'homepage' || item?.page === 'start-here' || item?.page === 'episodes' || item?.page === 'downloads' || item?.page === 'about' || item?.page === 'contact' || item?.page === 'questions'
? item.page
: 'downloads',
})) }))
.filter(item => item.heading || item.body) .filter(item => item.heading || item.body)
} }
@@ -235,6 +241,13 @@ function sanitizeArchivedSeries(value) {
studyGuideUrl: sanitizeUrl(item.studyGuideUrl), studyGuideUrl: sanitizeUrl(item.studyGuideUrl),
resourceLinks: sanitizeArchivedSeriesResourceLinks(item.resourceLinks), resourceLinks: sanitizeArchivedSeriesResourceLinks(item.resourceLinks),
notes: sanitizeArchivedSeriesNotes(item.notes), notes: sanitizeArchivedSeriesNotes(item.notes),
episodeRange:
Number.isInteger(item?.episodeRange?.from)
&& Number.isInteger(item?.episodeRange?.to)
&& item.episodeRange.from > 0
&& item.episodeRange.to >= item.episodeRange.from
? { from: item.episodeRange.from, to: item.episodeRange.to }
: undefined,
})) }))
.filter( .filter(
item => item =>
+704 -14
View File
@@ -93,6 +93,52 @@ interface Question {
isApproved: boolean isApproved: boolean
approvedAt: string | null approvedAt: string | null
} }
interface ContactSubmission {
id: string
submittedAt: string
name: string
email: string
message: string
messageType: 'question' | 'testimony' | 'topic' | 'general'
subscribe: boolean
archived?: boolean
}
interface ContactReplyDraft {
submissionId: string
recipientName: string
recipientEmail: string
subject: string
message: string
}
interface ContactReplyTemplate {
id: string
label: string
subject: string
message: string
}
interface ContactReplyHistoryItem {
id: string
submissionId: string
toEmail: string
toName: string
fromEmail: string
subject: string
preview: string
sentAt: string
}
interface ContactReplyConfig {
fromEmail: string
fromIdentity: string
resendApiConfigured: boolean
canSendReplies: boolean
note: string
}
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal' | 'whereToNextCards'> type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal' | 'whereToNextCards'>
type AdminView = type AdminView =
@@ -100,6 +146,7 @@ type AdminView =
| 'current-series' | 'episode-highlights' | 'archived-series' | 'current-series' | 'episode-highlights' | 'archived-series'
| 'downloads' | 'custom-links' | 'content-blocks' | 'downloads' | 'custom-links' | 'content-blocks'
| 'questions' | 'analytics' | 'assets' | 'questions' | 'analytics' | 'assets'
| 'emails'
| 'seo' | 'legal' | 'security' | 'brand' | 'global' | 'seo' | 'legal' | 'security' | 'brand' | 'global'
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'global' type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'global'
@@ -251,8 +298,31 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [assetUploadPending, setAssetUploadPending] = useState(false) const [assetUploadPending, setAssetUploadPending] = useState(false)
const [questions, setQuestions] = useState<Question[]>([]) const [questions, setQuestions] = useState<Question[]>([])
const [contactSubmissions, setContactSubmissions] = useState<ContactSubmission[]>([])
const [contactStatus, setContactStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [contactReplyDraft, setContactReplyDraft] = useState<ContactReplyDraft | null>(null)
const [contactReplyStatus, setContactReplyStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
const [contactReplyMsg, setContactReplyMsg] = useState('')
const [contactReplyTemplates, setContactReplyTemplates] = useState<ContactReplyTemplate[]>([])
const [contactReplyHistory, setContactReplyHistory] = useState<ContactReplyHistoryItem[]>([])
const [contactReplyConfig, setContactReplyConfig] = useState<ContactReplyConfig | null>(null)
const [contactTemplateStatus, setContactTemplateStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [emailMailboxView, setEmailMailboxView] = useState<'inbox' | 'archived'>('inbox')
const [selectedEmailId, setSelectedEmailId] = useState<string | null>(null)
const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({}) const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({})
const [editingQuestionId, setEditingQuestionId] = useState<string | null>(null) const [editingQuestionId, setEditingQuestionId] = useState<string | null>(null)
const [questionSearch, setQuestionSearch] = useState('')
const [questionFilter, setQuestionFilter] = useState<'all' | 'pending' | 'approved' | 'answered' | 'unanswered'>('all')
const [questionPage, setQuestionPage] = useState(0)
const [manualQuestion, setManualQuestion] = useState({
firstName: '',
email: '',
question: '',
answer: '',
approve: false,
})
const [manualQuestionStatus, setManualQuestionStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [manualQuestionMsg, setManualQuestionMsg] = useState('')
const [archiveLinkSelectionBySeries, setArchiveLinkSelectionBySeries] = useState<{ [key: string]: string }>({}) const [archiveLinkSelectionBySeries, setArchiveLinkSelectionBySeries] = useState<{ [key: string]: string }>({})
const [previewOpen, setPreviewOpen] = useState(false) const [previewOpen, setPreviewOpen] = useState(false)
const previewIframeRef = useRef<HTMLIFrameElement>(null) const previewIframeRef = useRef<HTMLIFrameElement>(null)
@@ -312,6 +382,38 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
setQuestions((data as { questions: Question[] }).questions ?? []) setQuestions((data as { questions: Question[] }).questions ?? [])
}) })
.catch(() => {}) .catch(() => {})
fetch('/api/admin-contact-submissions')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load contact submissions'))))
.then(data => {
setContactSubmissions((data as { submissions: ContactSubmission[] }).submissions ?? [])
setContactStatus('ready')
})
.catch(() => {
setContactStatus('error')
})
fetch('/api/admin-contact-reply-templates')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply templates'))))
.then(data => {
setContactReplyTemplates((data as { templates: ContactReplyTemplate[] }).templates ?? [])
})
.catch(() => {})
fetch('/api/admin-contact-reply-history')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply history'))))
.then(data => {
setContactReplyHistory((data as { items: ContactReplyHistoryItem[] }).items ?? [])
})
.catch(() => {})
fetch('/api/admin-reply-config')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply config'))))
.then(data => {
setContactReplyConfig(data as ContactReplyConfig)
})
.catch(() => {})
fetch('/api/admin-stats/backups') fetch('/api/admin-stats/backups')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load backups')))) .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load backups'))))
.then(data => { .then(data => {
@@ -375,6 +477,43 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
setStatsStatus('ready') setStatsStatus('ready')
} }
async function reloadContactSubmissions() {
const r = await fetch('/api/admin-contact-submissions')
if (!r.ok) throw new Error('Could not refresh contact submissions')
const data = await r.json() as { submissions?: ContactSubmission[] }
setContactSubmissions(Array.isArray(data.submissions) ? data.submissions : [])
setContactStatus('ready')
}
useEffect(() => {
const visible = contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true))
if (visible.length === 0) {
setSelectedEmailId(null)
return
}
if (!selectedEmailId || !visible.some(item => item.id === selectedEmailId)) {
setSelectedEmailId(visible[0].id)
}
}, [contactSubmissions, emailMailboxView, selectedEmailId])
useEffect(() => {
setQuestionPage(0)
}, [questionSearch, questionFilter])
async function reloadContactReplyHistory() {
const r = await fetch('/api/admin-contact-reply-history')
if (!r.ok) throw new Error('Could not refresh reply history')
const data = await r.json() as { items?: ContactReplyHistoryItem[] }
setContactReplyHistory(Array.isArray(data.items) ? data.items : [])
}
async function reloadContactReplyTemplates() {
const r = await fetch('/api/admin-contact-reply-templates')
if (!r.ok) throw new Error('Could not refresh reply templates')
const data = await r.json() as { templates?: ContactReplyTemplate[] }
setContactReplyTemplates(Array.isArray(data.templates) ? data.templates : [])
}
async function reloadBackups() { async function reloadBackups() {
const r = await fetch('/api/admin-stats/backups') const r = await fetch('/api/admin-stats/backups')
if (!r.ok) throw new Error('Could not refresh backups') if (!r.ok) throw new Error('Could not refresh backups')
@@ -1237,11 +1376,200 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
} }
} }
async function handleCreateManualQuestion() {
if (!manualQuestion.firstName.trim() || !manualQuestion.question.trim()) {
setManualQuestionStatus('error')
setManualQuestionMsg('First name and question are required.')
return
}
setManualQuestionStatus('saving')
setManualQuestionMsg('')
try {
const res = await fetch('/api/admin-questions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: manualQuestion.firstName,
email: manualQuestion.email,
question: manualQuestion.question,
answer: manualQuestion.answer,
approve: manualQuestion.approve,
}),
})
const data = await res.json().catch(() => ({})) as { question?: Question; message?: string }
if (!res.ok || !data.question) {
throw new Error(data.message ?? 'Failed to add question.')
}
setQuestions(items => [data.question as Question, ...items])
setManualQuestion({ firstName: '', email: '', question: '', answer: '', approve: false })
setManualQuestionStatus('saved')
setManualQuestionMsg('Question added to draft Q&A list.')
} catch (err) {
setManualQuestionStatus('error')
setManualQuestionMsg(err instanceof Error ? err.message : 'Failed to add question.')
}
}
async function handleDeleteContactSubmission(submissionId: string) {
if (!confirm('Delete this contact submission permanently?')) return
try {
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(submissionId)}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete submission')
await reloadContactSubmissions()
await reloadStats()
setMaintenanceMsg('Contact submission deleted.')
} catch {
setMaintenanceMsg('Failed to delete contact submission.')
}
}
async function handleArchiveContactSubmission(submissionId: string, archived: boolean) {
try {
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(submissionId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ archived }),
})
if (!res.ok) throw new Error('Failed to update submission')
await reloadContactSubmissions()
await reloadStats()
setContactReplyMsg(archived ? 'Message archived.' : 'Message moved back to inbox.')
} catch {
setContactReplyMsg('Failed to update archive status.')
}
}
function openContactReplyComposer(submission: ContactSubmission) {
const firstName = submission.name?.trim().split(/\s+/)[0] || 'there'
setContactReplyDraft({
submissionId: submission.id,
recipientName: submission.name,
recipientEmail: submission.email,
subject: 'Thanks for reaching out to Verse by Verse with Nate',
message: `Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally.`,
})
setContactReplyStatus('idle')
setContactReplyMsg(`Composing a reply to ${firstName}.`)
}
function applyContactReplyTemplate(templateId: string) {
if (!contactReplyDraft) return
const template = contactReplyTemplates.find(item => item.id === templateId)
if (!template) return
setContactReplyDraft({
...contactReplyDraft,
subject: template.subject,
message: template.message,
})
setContactReplyMsg(`Applied template: ${template.label}.`)
}
function addContactReplyTemplate() {
setContactReplyTemplates(items => ([
...items,
{
id: Date.now().toString(36),
label: '',
subject: '',
message: '',
},
]))
setContactTemplateStatus('idle')
}
function updateContactReplyTemplate(id: string, field: keyof ContactReplyTemplate, value: string) {
setContactReplyTemplates(items => items.map(item => item.id === id ? { ...item, [field]: value } : item))
setContactTemplateStatus('idle')
}
function removeContactReplyTemplate(id: string) {
setContactReplyTemplates(items => items.filter(item => item.id !== id))
setContactTemplateStatus('idle')
}
async function handleSaveContactReplyTemplates() {
setContactTemplateStatus('saving')
try {
const res = await fetch('/api/admin-contact-reply-templates', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ templates: contactReplyTemplates }),
})
if (!res.ok) throw new Error('Failed to save templates')
await reloadContactReplyTemplates()
setContactTemplateStatus('saved')
setContactReplyMsg('Reply templates saved.')
} catch {
setContactTemplateStatus('error')
setContactReplyMsg('Failed to save reply templates.')
}
}
async function handleSendContactReply() {
if (!contactReplyDraft) return
setContactReplyStatus('sending')
setContactReplyMsg('')
try {
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(contactReplyDraft.submissionId)}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: contactReplyDraft.subject,
message: contactReplyDraft.message,
}),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Failed to send email.')
}
setContactReplyStatus('sent')
setContactReplyMsg(`Reply sent to ${contactReplyDraft.recipientEmail} from hello@versebyversewithnate.us.`)
await reloadContactReplyHistory()
setContactReplyDraft(null)
} catch (err) {
setContactReplyStatus('error')
setContactReplyMsg(err instanceof Error ? err.message : 'Failed to send email.')
}
}
const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources') const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources')
const archivedResourceCount = (form.archivedSeries ?? []).reduce((count, series) => { const archivedResourceCount = (form.archivedSeries ?? []).reduce((count, series) => {
return count + (series.resourceLinks ?? []).length return count + (series.resourceLinks ?? []).length
}, 0) }, 0)
const filteredAdminQuestions = questions.filter(question => {
const search = questionSearch.trim().toLowerCase()
const matchesSearch = !search
|| question.firstName.toLowerCase().includes(search)
|| question.question.toLowerCase().includes(search)
|| question.answer.toLowerCase().includes(search)
const matchesFilter = (
questionFilter === 'all'
|| (questionFilter === 'pending' && !question.isApproved)
|| (questionFilter === 'approved' && question.isApproved)
|| (questionFilter === 'answered' && Boolean(question.answer?.trim()))
|| (questionFilter === 'unanswered' && !question.answer?.trim())
)
return matchesSearch && matchesFilter
})
const QUESTION_PAGE_SIZE = 20
const totalQuestionPages = Math.max(1, Math.ceil(filteredAdminQuestions.length / QUESTION_PAGE_SIZE))
const visibleAdminQuestions = filteredAdminQuestions.slice(
questionPage * QUESTION_PAGE_SIZE,
(questionPage + 1) * QUESTION_PAGE_SIZE,
)
function renderSaveStatus() { function renderSaveStatus() {
return ( return (
<> <>
@@ -1324,8 +1652,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<div className="admin-nav-group"> <div className="admin-nav-group">
<span className="admin-nav-label">Manage</span> <span className="admin-nav-label">Manage</span>
<button type="button" className={`admin-nav-item${adminView === 'questions' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('questions')}>Questions ({questions.length})</button> <button type="button" className={`admin-nav-item${adminView === 'questions' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('questions')}>Questions ({questions.length})</button>
<button type="button" className={`admin-nav-item${adminView === 'emails' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('emails')}>Emails</button>
<button type="button" className={`admin-nav-item${adminView === 'analytics' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('analytics')}>Analytics</button> <button type="button" className={`admin-nav-item${adminView === 'analytics' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('analytics')}>Analytics</button>
<button type="button" className={`admin-nav-item${adminView === 'assets' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('assets')}>Assets</button> <button type="button" className={`admin-nav-item${adminView === 'assets' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('assets')}>Asset Manager</button>
</div> </div>
<div className="admin-nav-group"> <div className="admin-nav-group">
@@ -2018,13 +2347,104 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<section className="admin-panel-section" aria-label="Q&A Management"> <section className="admin-panel-section" aria-label="Q&A Management">
<div className="admin-panel-head"> <div className="admin-panel-head">
<h2>Bible Questions &amp; Answers</h2> <h2>Bible Questions &amp; Answers</h2>
<p>Manage submitted questions, provide answers, and approve for public display.</p> <p>Manage submitted questions, add manual questions, provide answers, and approve for public display.</p>
</div> </div>
<div className="admin-actions admin-actions--maintenance" style={{ alignItems: 'center' }}>
<input
type="text"
value={questionSearch}
onChange={e => setQuestionSearch(e.target.value)}
placeholder="Search by name, question, or answer..."
style={{ minWidth: '320px', maxWidth: '520px', width: '100%' }}
/>
<button type="button" className={`btn-admin-reset${questionFilter === 'all' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('all')}>All</button>
<button type="button" className={`btn-admin-reset${questionFilter === 'pending' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('pending')}>Pending</button>
<button type="button" className={`btn-admin-reset${questionFilter === 'approved' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('approved')}>Approved</button>
<button type="button" className={`btn-admin-reset${questionFilter === 'answered' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('answered')}>Answered</button>
<button type="button" className={`btn-admin-reset${questionFilter === 'unanswered' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('unanswered')}>Unanswered</button>
</div>
<p className="admin-stats-note">Showing {filteredAdminQuestions.length} of {questions.length} questions.</p>
<div className="admin-visits-table-wrap">
<h3>Add Question Manually</h3>
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor="manual-question-first-name">First Name</label>
<input
id="manual-question-first-name"
type="text"
value={manualQuestion.firstName}
onChange={e => setManualQuestion(curr => ({ ...curr, firstName: e.target.value }))}
placeholder="Nate"
/>
</div>
<div className="admin-field">
<label htmlFor="manual-question-email">Email (optional)</label>
<input
id="manual-question-email"
type="email"
value={manualQuestion.email}
onChange={e => setManualQuestion(curr => ({ ...curr, email: e.target.value }))}
placeholder="optional@email.com"
/>
</div>
<div className="admin-field">
<label htmlFor="manual-question-question">Question</label>
<textarea
id="manual-question-question"
rows={3}
value={manualQuestion.question}
onChange={e => setManualQuestion(curr => ({ ...curr, question: e.target.value }))}
placeholder="Type the question you want to add to the Q&A list..."
/>
</div>
<div className="admin-field">
<label htmlFor="manual-question-answer">Answer (optional)</label>
<textarea
id="manual-question-answer"
rows={3}
value={manualQuestion.answer}
onChange={e => setManualQuestion(curr => ({ ...curr, answer: e.target.value }))}
placeholder="Optional: add an answer now"
/>
</div>
<div className="admin-field">
<label htmlFor="manual-question-approve">Approve Immediately</label>
<select
id="manual-question-approve"
value={manualQuestion.approve ? 'yes' : 'no'}
onChange={e => setManualQuestion(curr => ({ ...curr, approve: e.target.value === 'yes' }))}
>
<option value="no">No</option>
<option value="yes">Yes</option>
</select>
</div>
</div>
<button
type="button"
className="btn-admin-save"
onClick={handleCreateManualQuestion}
disabled={manualQuestionStatus === 'saving'}
>
{manualQuestionStatus === 'saving' ? 'Adding…' : 'Add Question'}
</button>
</div>
{manualQuestionMsg && (
<p className={`admin-status ${manualQuestionStatus === 'error' ? 'admin-status--err' : 'admin-status--ok'}`}>
{manualQuestionMsg}
</p>
)}
</div>
{questions.length === 0 ? ( {questions.length === 0 ? (
<p className="admin-stats-note">No questions submitted yet.</p> <p className="admin-stats-note">No questions submitted yet.</p>
) : filteredAdminQuestions.length === 0 ? (
<p className="admin-stats-note">No questions match this filter.</p>
) : ( ) : (
<div className="admin-questions-list"> <div className="admin-questions-list">
{questions.map(question => ( {visibleAdminQuestions.map(question => (
<div key={question.id} className="admin-question-card"> <div key={question.id} className="admin-question-card">
<div className="admin-question-header"> <div className="admin-question-header">
<div> <div>
@@ -2071,6 +2491,28 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
))} ))}
</div> </div>
)} )}
{filteredAdminQuestions.length > QUESTION_PAGE_SIZE && (
<div className="qa-pagination" style={{ marginTop: '1rem', justifyContent: 'flex-start' }}>
<button
type="button"
className="qa-page-btn"
onClick={() => setQuestionPage(p => Math.max(0, p - 1))}
disabled={questionPage === 0}
>
Prev
</button>
<span className="qa-page-info">Page {questionPage + 1} / {totalQuestionPages}</span>
<button
type="button"
className="qa-page-btn"
onClick={() => setQuestionPage(p => Math.min(totalQuestionPages - 1, p + 1))}
disabled={questionPage >= totalQuestionPages - 1}
>
Next
</button>
</div>
)}
</section> </section>
)} )}
@@ -2204,6 +2646,231 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</section> </section>
)} )}
{/* EMAILS */}
{adminView === 'emails' && (
<section className="admin-panel-section" aria-label="Email center">
<div className="admin-panel-head">
<h2>Email Center</h2>
<p>Manage inbound contact emails, archive threads, reply with templates, and review sent history.</p>
</div>
{contactReplyConfig && (
<div className="admin-restore-preview" style={{ marginBottom: '0.9rem' }}>
<h3>Sender Status</h3>
<p><strong>From:</strong> {contactReplyConfig.fromIdentity}</p>
<p><strong>Resend Configured:</strong> {contactReplyConfig.resendApiConfigured ? 'Yes' : 'No'}</p>
<p><strong>Can Send Replies:</strong> {contactReplyConfig.canSendReplies ? 'Yes' : 'No'}</p>
<p>{contactReplyConfig.note}</p>
</div>
)}
<div className="admin-actions admin-actions--maintenance" style={{ marginBottom: '0.75rem' }}>
<button type="button" className={`btn-admin-reset${emailMailboxView === 'inbox' ? ' btn-admin-reset--active' : ''}`} onClick={() => setEmailMailboxView('inbox')}>Inbox</button>
<button type="button" className={`btn-admin-reset${emailMailboxView === 'archived' ? ' btn-admin-reset--active' : ''}`} onClick={() => setEmailMailboxView('archived')}>Archived</button>
</div>
{contactStatus === 'loading' && <p className="admin-stats-note">Loading emails</p>}
{contactStatus === 'error' && <p className="admin-stats-note">Could not load contact submissions.</p>}
{contactStatus === 'ready' && (
<div className="admin-email-layout">
<aside className="admin-email-list">
{contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true)).length === 0 && (
<p className="admin-stats-note">No messages in this mailbox.</p>
)}
{contactSubmissions
.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true))
.map(item => (
<button
key={item.id}
type="button"
className={`admin-email-list-item${selectedEmailId === item.id ? ' admin-email-list-item--active' : ''}`}
onClick={() => setSelectedEmailId(item.id)}
>
<div className="admin-email-list-head">
<strong>{item.name}</strong>
<span>{formatDate(item.submittedAt)}</span>
</div>
<p>{item.message}</p>
</button>
))}
</aside>
<div className="admin-email-detail">
{(() => {
const visible = contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true))
const selected = visible.find(item => item.id === selectedEmailId) ?? null
if (!selected) return <p className="admin-stats-note">Select an email to view details.</p>
return (
<>
<div className="admin-email-meta">
<p><strong>From:</strong> {selected.name} &lt;{selected.email}&gt;</p>
<p><strong>Type:</strong> {selected.messageType}</p>
<p><strong>Subscribed:</strong> {selected.subscribe ? 'Yes' : 'No'}</p>
<p><strong>Received:</strong> {formatDate(selected.submittedAt)}</p>
</div>
<div className="admin-email-body">
<p>{selected.message}</p>
</div>
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-save admin-email-action-btn" onClick={() => openContactReplyComposer(selected)}>Reply</button>
<button
type="button"
className="btn-admin-reset admin-email-action-btn"
onClick={() => handleArchiveContactSubmission(selected.id, !(selected.archived === true))}
>
{selected.archived === true ? 'Move to Inbox' : 'Archive'}
</button>
<button type="button" className="btn-admin-remove admin-email-action-btn" onClick={() => handleDeleteContactSubmission(selected.id)}>Delete</button>
</div>
</>
)
})()}
</div>
</div>
)}
{contactReplyDraft && (
<div className="admin-array-row" style={{ marginTop: '1rem' }}>
<div className="admin-array-fields">
{contactReplyTemplates.length > 0 && (
<div className="admin-field">
<label htmlFor="reply-template">Saved Template</label>
<select id="reply-template" defaultValue="" onChange={e => applyContactReplyTemplate(e.target.value)}>
<option value="">Choose a template</option>
{contactReplyTemplates.map(template => (
<option key={template.id} value={template.id}>{template.label}</option>
))}
</select>
</div>
)}
<div className="admin-field">
<label htmlFor="reply-to">To</label>
<input id="reply-to" type="text" value={`${contactReplyDraft.recipientName} <${contactReplyDraft.recipientEmail}>`} readOnly />
</div>
<div className="admin-field">
<label htmlFor="reply-from">From</label>
<input id="reply-from" type="text" value="hello@versebyversewithnate.us" readOnly />
</div>
<div className="admin-field">
<label htmlFor="reply-subject">Subject</label>
<input
id="reply-subject"
type="text"
value={contactReplyDraft.subject}
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, subject: e.target.value } : draft)}
/>
</div>
<div className="admin-field">
<label htmlFor="reply-message">Message</label>
<textarea
id="reply-message"
rows={8}
value={contactReplyDraft.message}
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, message: e.target.value } : draft)}
/>
<p className="admin-stats-note">This will be wrapped in a professional HTML email template automatically.</p>
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button type="button" className="btn-admin-save" onClick={handleSendContactReply} disabled={contactReplyStatus === 'sending'}>
{contactReplyStatus === 'sending' ? 'Sending…' : 'Send Reply'}
</button>
<button
type="button"
className="btn-admin-reset"
onClick={() => {
setContactReplyDraft(null)
setContactReplyStatus('idle')
setContactReplyMsg('')
}}
>
Cancel
</button>
</div>
</div>
)}
{contactReplyMsg && <p className="admin-stats-note">{contactReplyMsg}</p>}
<details className="admin-collapsible-card">
<summary className="admin-collapsible-summary">
<div>
<strong>Saved Reply Templates</strong>
<p>{contactReplyTemplates.length} template{contactReplyTemplates.length === 1 ? '' : 's'} configured</p>
</div>
<span className="admin-collapsible-hint">Expand to edit</span>
</summary>
<div className="admin-collapsible-body">
{contactReplyTemplates.length === 0 && <p className="admin-stats-note">No saved templates yet.</p>}
{contactReplyTemplates.map(template => (
<details key={template.id} className="admin-collapsible-card admin-collapsible-card--nested">
<summary className="admin-collapsible-summary">
<div>
<strong>{template.label || 'Untitled template'}</strong>
<p>{template.subject || 'No subject set'}</p>
</div>
<span className="admin-collapsible-hint">Expand to edit</span>
</summary>
<div className="admin-collapsible-body">
<div className="admin-array-row admin-array-row--nested">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`reply-template-label-${template.id}`}>Label</label>
<input id={`reply-template-label-${template.id}`} type="text" value={template.label} onChange={e => updateContactReplyTemplate(template.id, 'label', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`reply-template-subject-${template.id}`}>Subject</label>
<input id={`reply-template-subject-${template.id}`} type="text" value={template.subject} onChange={e => updateContactReplyTemplate(template.id, 'subject', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`reply-template-message-${template.id}`}>Message</label>
<textarea id={`reply-template-message-${template.id}`} rows={5} value={template.message} onChange={e => updateContactReplyTemplate(template.id, 'message', e.target.value)} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeContactReplyTemplate(template.id)}>Remove</button>
</div>
</div>
</details>
))}
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-reset" onClick={addContactReplyTemplate}>Add Template</button>
<button type="button" className="btn-admin-save" onClick={handleSaveContactReplyTemplates} disabled={contactTemplateStatus === 'saving'}>
{contactTemplateStatus === 'saving' ? 'Saving…' : 'Save Templates'}
</button>
</div>
</div>
</details>
<div className="admin-visits-table-wrap">
<h3>Reply History</h3>
{contactReplyHistory.length === 0 ? <p className="admin-stats-note">No admin replies have been sent yet.</p> : (
<div className="admin-visits-table-scroll">
<table className="admin-visits-table">
<thead>
<tr><th>Sent</th><th>To</th><th>From</th><th>Subject</th><th>Preview</th></tr>
</thead>
<tbody>
{contactReplyHistory.map(item => (
<tr key={item.id}>
<td>{formatDate(item.sentAt)}</td>
<td>{item.toName} ({item.toEmail})</td>
<td>{item.fromEmail}</td>
<td>{item.subject}</td>
<td>{item.preview}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
)}
{/* ASSETS */} {/* ASSETS */}
{adminView === 'assets' && ( {adminView === 'assets' && (
<section className="admin-panel-section" aria-label="Asset manager"> <section className="admin-panel-section" aria-label="Asset manager">
@@ -2218,13 +2885,31 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</label> </label>
</div> </div>
{assets.length === 0 && <p className="admin-stats-note">No assets uploaded yet.</p>} {assets.length === 0 && <p className="admin-stats-note">No assets uploaded yet.</p>}
<div className="admin-asset-grid"> {assets.length > 0 && (
<div className="admin-visits-table-scroll">
<table className="admin-visits-table admin-assets-table">
<thead>
<tr>
<th>Preview</th>
<th>Filename</th>
<th>Size</th>
<th>Updated</th>
<th>Tags</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{assets.map(asset => ( {assets.map(asset => (
<div key={asset.filename} className="admin-asset-card"> <tr key={asset.filename}>
{isImageAsset(asset.filename) && <img src={asset.url} alt={asset.filename} className="admin-asset-thumb" />} <td>
<div className="admin-asset-meta"> {isImageAsset(asset.filename)
<p className="admin-asset-name">{asset.filename}</p> ? <img src={asset.url} alt={asset.filename} className="admin-asset-thumb" style={{ width: '72px', height: '52px', objectFit: 'cover' }} />
<p className="admin-asset-info">{(asset.sizeBytes / 1024).toFixed(1)} KB · {formatDate(asset.updatedAt)}</p> : <span>PDF/File</span>}
</td>
<td>{asset.filename}</td>
<td>{(asset.sizeBytes / 1024).toFixed(1)} KB</td>
<td>{formatDate(asset.updatedAt)}</td>
<td>
<input <input
type="text" type="text"
className="admin-asset-tags-input" className="admin-asset-tags-input"
@@ -2233,15 +2918,20 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
onChange={e => setAssetTagEdits(t => ({ ...t, [asset.filename]: e.target.value }))} onChange={e => setAssetTagEdits(t => ({ ...t, [asset.filename]: e.target.value }))}
onBlur={() => handleSaveAssetTags(asset.filename)} onBlur={() => handleSaveAssetTags(asset.filename)}
/> />
<div className="admin-asset-actions"> </td>
<button type="button" className="btn-admin-reset" onClick={() => { navigator.clipboard.writeText(asset.url).catch(() => {}) }}>Copy URL</button> <td>
<div className="admin-asset-actions-inline">
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => { navigator.clipboard.writeText(asset.url).catch(() => {}) }}>Copy URL</button>
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteAsset(asset.filename)}>Delete</button> <button type="button" className="btn-admin-remove" onClick={() => handleDeleteAsset(asset.filename)}>Delete</button>
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
</div>
</div>
</div> </div>
</td>
</tr>
))} ))}
</tbody>
</table>
</div> </div>
)}
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
</section> </section>
)} )}
+177 -3
View File
@@ -2333,13 +2333,14 @@
} }
.admin-visits-table-scroll { .admin-visits-table-scroll {
overflow-x: auto; overflow-x: visible;
} }
.admin-visits-table { .admin-visits-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
min-width: 920px; min-width: 0;
table-layout: fixed;
} }
.admin-visits-table th, .admin-visits-table th,
@@ -2350,6 +2351,9 @@
font-family: var(--brand-font-body); font-family: var(--brand-font-body);
color: var(--brand-warm-white); color: var(--brand-warm-white);
font-size: 0.9rem; font-size: 0.9rem;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
} }
.admin-visits-table th { .admin-visits-table th {
@@ -3409,6 +3413,17 @@
line-height: 1.75; line-height: 1.75;
color: var(--brand-warm-white); color: var(--brand-warm-white);
opacity: 0.92; opacity: 0.92;
white-space: pre-wrap;
}
.qa-answer-text a {
color: #e0c070;
text-decoration: underline;
text-underline-offset: 0.18em;
}
.qa-answer-text a:hover {
color: #f0ead8;
} }
.qa-flip-hint { .qa-flip-hint {
@@ -3799,7 +3814,7 @@
} }
.admin-panel-section { .admin-panel-section {
max-width: 740px; max-width: 1240px;
} }
.admin-panel-head { .admin-panel-head {
@@ -3941,6 +3956,165 @@
border-color: rgba(201,168,76,0.4); border-color: rgba(201,168,76,0.4);
} }
.btn-admin-reset--compact {
padding: 0.45rem 0.75rem;
border-radius: 8px;
font-size: 0.78rem;
letter-spacing: 0.04em;
}
.admin-assets-table {
table-layout: auto;
}
.admin-assets-table th,
.admin-assets-table td {
vertical-align: middle;
}
.admin-assets-table th:nth-child(1),
.admin-assets-table td:nth-child(1) {
width: 96px;
}
.admin-assets-table th:nth-child(2),
.admin-assets-table td:nth-child(2) {
min-width: 300px;
}
.admin-assets-table th:nth-child(5),
.admin-assets-table td:nth-child(5) {
min-width: 240px;
}
.admin-assets-table th:nth-child(6),
.admin-assets-table td:nth-child(6) {
min-width: 170px;
}
.admin-asset-thumb {
display: block;
border-radius: 8px;
border: 1px solid rgba(201, 168, 76, 0.25);
background: rgba(255, 255, 255, 0.03);
}
.admin-asset-tags-input {
width: 100%;
min-width: 220px;
background: #111;
border: 1px solid rgba(201, 168, 76, 0.24);
border-radius: 6px;
color: var(--brand-warm-white);
padding: 0.5rem 0.65rem;
}
.admin-asset-actions-inline {
display: flex;
gap: 0.4rem;
flex-wrap: nowrap;
}
.admin-email-layout {
display: grid;
grid-template-columns: minmax(260px, 360px) minmax(0, 1fr);
gap: 1rem;
margin-top: 0.9rem;
}
.admin-email-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 580px;
overflow-y: auto;
padding-right: 0.25rem;
}
.admin-email-list-item {
width: 100%;
text-align: left;
background: #12110d;
border: 1px solid rgba(201, 168, 76, 0.2);
border-radius: 12px;
padding: 0.75rem 0.85rem;
color: var(--brand-warm-white);
cursor: pointer;
}
.admin-email-list-item--active {
border-color: rgba(201, 168, 76, 0.55);
box-shadow: 0 0 0 1px rgba(201, 168, 76, 0.35) inset;
}
.admin-email-list-head {
display: flex;
justify-content: space-between;
gap: 0.6rem;
margin-bottom: 0.4rem;
}
.admin-email-list-head span {
color: var(--brand-muted);
font-size: 0.8rem;
}
.admin-email-list-item p {
margin: 0;
color: rgba(240, 234, 216, 0.78);
font-size: 0.92rem;
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.admin-email-detail {
background: #11100d;
border: 1px solid rgba(201, 168, 76, 0.2);
border-radius: 12px;
padding: 1rem;
}
.admin-email-meta p {
margin: 0 0 0.35rem;
}
.admin-email-body {
margin-top: 0.8rem;
padding: 0.85rem;
border: 1px solid rgba(201, 168, 76, 0.18);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
white-space: pre-wrap;
}
.admin-email-body p {
margin: 0;
line-height: 1.55;
}
.admin-email-action-btn {
border-radius: 10px;
min-width: 112px;
padding: 0.58rem 0.95rem;
white-space: nowrap;
text-transform: none;
letter-spacing: 0.03em;
font-size: 0.9rem;
}
@media (max-width: 980px) {
.admin-email-layout {
grid-template-columns: 1fr;
}
.admin-email-list {
max-height: 280px;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.admin-sidebar { .admin-sidebar {
display: none; display: none;
+3 -2
View File
@@ -1637,11 +1637,12 @@ function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: Sit
) )
} }
function QuestionsPage() { function QuestionsPage({ content }: { content: SiteContent }) {
return ( return (
<main className="thanks-page" aria-label="Questions and Answers"> <main className="thanks-page" aria-label="Questions and Answers">
<div className="thanks-card" style={{ maxWidth: '1000px', width: '100%' }}> <div className="thanks-card" style={{ maxWidth: '1000px', width: '100%' }}>
<QASection /> <QASection />
<CustomBlocksSection content={content} page="questions" />
<div style={{ marginTop: '2rem', textAlign: 'center' }}> <div style={{ marginTop: '2rem', textAlign: 'center' }}>
<Link to="/" className="btn-secondary">Back to Site</Link> <Link to="/" className="btn-secondary">Back to Site</Link>
</div> </div>
@@ -1799,7 +1800,7 @@ export default function App() {
<Route path="/downloads/:id" element={<DownloadDetailPage content={content} />} /> <Route path="/downloads/:id" element={<DownloadDetailPage content={content} />} />
<Route path="/about" element={<AboutPage content={content} />} /> <Route path="/about" element={<AboutPage content={content} />} />
<Route path="/contact" element={<ContactPage content={content} />} /> <Route path="/contact" element={<ContactPage content={content} />} />
<Route path="/questions" element={<QuestionsPage />} /> <Route path="/questions" element={<QuestionsPage content={content} />} />
<Route path="/thanks" element={<ThankYouPage />} /> <Route path="/thanks" element={<ThankYouPage />} />
<Route path="/subscribe" element={<SubscribePage />} /> <Route path="/subscribe" element={<SubscribePage />} />
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} /> <Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
+23 -1
View File
@@ -19,6 +19,28 @@ function tokenizeForRelated(text: string) {
.filter(token => token.length > 3) .filter(token => token.length > 3)
} }
function renderTextWithLinks(text: string) {
const parts = text.split(/(https?:\/\/[^\s]+)/g)
return parts.map((part, index) => {
if (!/^https?:\/\//i.test(part)) {
return <span key={`text-${index}`}>{part}</span>
}
const safeHref = part.replace(/[),.;!?]+$/g, '')
const trailing = part.slice(safeHref.length)
return (
<span key={`link-${index}`}>
<a href={safeHref} target="_blank" rel="noopener noreferrer">
{safeHref}
</a>
{trailing}
</span>
)
})
}
export default function QASection() { export default function QASection() {
const [questions, setQuestions] = useState<PublicQuestion[]>([]) const [questions, setQuestions] = useState<PublicQuestion[]>([])
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
@@ -158,7 +180,7 @@ export default function QASection() {
</div> </div>
<div className="qa-card-face qa-card-back"> <div className="qa-card-face qa-card-back">
<span className="qa-face-label">A</span> <span className="qa-face-label">A</span>
<p className="qa-answer-text">{question.answer}</p> <div className="qa-answer-text">{renderTextWithLinks(question.answer)}</div>
{getRelatedQuestions(question).length > 0 && ( {getRelatedQuestions(question).length > 0 && (
<div className="qa-related-wrap"> <div className="qa-related-wrap">
<p className="qa-related-label">Related questions</p> <p className="qa-related-label">Related questions</p>