Add Q&A answered email template to admin Email Templates panel; v1.0.11

- content.ts: add qaAnsweredEmailSubject/Body/CtaLabel/Signoff fields + defaults
- email.js: buildQuestionAnsweredEmailTemplate reads from cachedSiteContent
- AdminPage.tsx: add EmailTemplateCard entry for Q&A answered notification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-22 12:55:07 -04:00
parent d74b7523d3
commit 6444d6e0e3
4 changed files with 33 additions and 5 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "1.0.10", "version": "1.0.11",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+13 -4
View File
@@ -386,11 +386,19 @@ export function buildAdminReplyTemplate({ recipientName, message }) {
} }
export function buildQuestionAnsweredEmailTemplate({ firstName, question, answer, questionUrl }) { export function buildQuestionAnsweredEmailTemplate({ firstName, question, answer, questionUrl }) {
const cfg = state.cachedSiteContent ?? {}
const subject = cfg.qaAnsweredEmailSubject?.trim() || 'Your question has been answered — Verse by Verse with Nate'
const bodyText = cfg.qaAnsweredEmailBody?.trim() || 'Nate has answered your question on Verse by Verse with Nate.'
const ctaLabel = cfg.qaAnsweredEmailCtaLabel?.trim() || 'Read Full Answer →'
const signoff = cfg.qaAnsweredEmailSignoff?.trim() || 'Grace and peace,\nNate'
const safeName = escapeHtml(firstName || 'friend') const safeName = escapeHtml(firstName || 'friend')
const safeQuestion = escapeHtml(question) const safeQuestion = escapeHtml(question)
const safeAnswer = escapeHtml(answer.length > 600 ? answer.slice(0, 597) + '…' : answer).replace(/\n/g, '<br/>') const safeAnswer = escapeHtml(answer.length > 600 ? answer.slice(0, 597) + '…' : answer).replace(/\n/g, '<br/>')
const safeUrl = escapeHtml(questionUrl) const safeUrl = escapeHtml(questionUrl)
const text = `Hi ${firstName || 'friend'},\n\nYour question has been answered on Verse by Verse with Nate.\n\nYour question: ${question}\n\nAnswer: ${answer}\n\nRead it at: ${questionUrl}\n\nGrace and peace,\nNate` const safeBody = escapeHtml(bodyText)
const safeSignoff = escapeHtml(signoff).replace(/\n/g, '<br/>')
const text = `Hi ${firstName || 'friend'},\n\n${bodyText}\n\nYour question: ${question}\n\nAnswer: ${answer}\n\nRead it at: ${questionUrl}\n\n${signoff}`
const html = ` const html = `
<div style="margin:0;padding:0;background-color:#f5f1e8;font-family:Georgia,serif;color:#201a10;"> <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;"> <table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#f5f1e8;">
@@ -406,7 +414,7 @@ export function buildQuestionAnsweredEmailTemplate({ firstName, question, answer
<tr> <tr>
<td style="padding:26px 24px 18px;"> <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 ${safeName},</p> <p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:16px;line-height:1.6;color:#201a10;">Hi ${safeName},</p>
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">Nate has answered your question on Verse by Verse with Nate.</p> <p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeBody}</p>
<div style="background:#f7f2e5;border-left:3px solid #c8860a;padding:14px 18px;margin:0 0 20px;border-radius:0 8px 8px 0;"> <div style="background:#f7f2e5;border-left:3px solid #c8860a;padding:14px 18px;margin:0 0 20px;border-radius:0 8px 8px 0;">
<p style="margin:0 0 6px;font-family:Arial,sans-serif;font-size:13px;letter-spacing:0.06em;text-transform:uppercase;color:#a07830;">Your question</p> <p style="margin:0 0 6px;font-family:Arial,sans-serif;font-size:13px;letter-spacing:0.06em;text-transform:uppercase;color:#a07830;">Your question</p>
<p style="margin:0;font-family:Georgia,serif;font-size:15px;line-height:1.6;color:#201a10;font-style:italic;">${safeQuestion}</p> <p style="margin:0;font-family:Georgia,serif;font-size:15px;line-height:1.6;color:#201a10;font-style:italic;">${safeQuestion}</p>
@@ -415,11 +423,12 @@ export function buildQuestionAnsweredEmailTemplate({ firstName, question, answer
<p style="margin:0 0 6px;font-family:Arial,sans-serif;font-size:13px;letter-spacing:0.06em;text-transform:uppercase;color:#a07830;">Answer</p> <p style="margin:0 0 6px;font-family:Arial,sans-serif;font-size:13px;letter-spacing:0.06em;text-transform:uppercase;color:#a07830;">Answer</p>
<p style="margin:0;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeAnswer}</p> <p style="margin:0;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeAnswer}</p>
</div> </div>
<a href="${safeUrl}" style="display:inline-block;background:#c8860a;color:#ffffff;font-family:Arial,sans-serif;font-size:14px;font-weight:bold;text-decoration:none;padding:12px 24px;border-radius:6px;">Read Full Answer →</a> <a href="${safeUrl}" style="display:inline-block;background:#c8860a;color:#ffffff;font-family:Arial,sans-serif;font-size:14px;font-weight:bold;text-decoration:none;padding:12px 24px;border-radius:6px;">${escapeHtml(ctaLabel)}</a>
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:14px 24px;"> <td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:14px 24px;">
<p style="margin:0 0 12px;font-family:Arial,sans-serif;font-size:13px;line-height:1.6;color:#201a10;">${safeSignoff}</p>
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.5;color:#735a2b;">You received this because you requested a notification when this question was answered.</p> <p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.5;color:#735a2b;">You received this because you requested a notification when this question was answered.</p>
</td> </td>
</tr> </tr>
@@ -429,7 +438,7 @@ export function buildQuestionAnsweredEmailTemplate({ firstName, question, answer
</table> </table>
</div> </div>
` `
return { subject: 'Your question has been answered — Verse by Verse with Nate', text, html } return { subject, text, html }
} }
// ── Transactional email senders ──────────────────────────────────────────── // ── Transactional email senders ────────────────────────────────────────────
+10
View File
@@ -482,6 +482,12 @@ function EmailTemplatesPanel({
fieldKeys={['twoFaOtpEmailSubject','twoFaOtpEmailBody','twoFaOtpEmailExpiry']} fieldKeys={['twoFaOtpEmailSubject','twoFaOtpEmailBody','twoFaOtpEmailExpiry']}
openKey="2fa-otp" /> openKey="2fa-otp" />
<EmailTemplateCard type="transactional" icon="💬" title="Q&amp;A — Question Answered Notification"
trigger={'An admin approves a Q&A question submitted with "Email me when answered" checked'}
autoVars={'Submitter\'s first name, their original question text, the answer (truncated to 600 chars), a direct link to the answer'}
fieldKeys={['qaAnsweredEmailSubject','qaAnsweredEmailBody','qaAnsweredEmailCtaLabel','qaAnsweredEmailSignoff']}
openKey="qa-answered" />
{renderSaveStatus()} {renderSaveStatus()}
</section> </section>
) )
@@ -990,6 +996,10 @@ const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; sect
{ key: 'twoFaOtpEmailSubject', label: 'Subject Line', section: 'email-templates' }, { key: 'twoFaOtpEmailSubject', label: 'Subject Line', section: 'email-templates' },
{ key: 'twoFaOtpEmailBody', label: 'Body Text (shown above the code)', multiline: true, section: 'email-templates' }, { key: 'twoFaOtpEmailBody', label: 'Body Text (shown above the code)', multiline: true, section: 'email-templates' },
{ key: 'twoFaOtpEmailExpiry', label: 'Expiry / Security Note (shown below the code)', multiline: true, section: 'email-templates' }, { key: 'twoFaOtpEmailExpiry', label: 'Expiry / Security Note (shown below the code)', multiline: true, section: 'email-templates' },
{ key: 'qaAnsweredEmailSubject', label: 'Subject Line', section: 'email-templates' },
{ key: 'qaAnsweredEmailBody', label: 'Body Text (paragraph shown below the greeting)', multiline: true, section: 'email-templates' },
{ key: 'qaAnsweredEmailCtaLabel', label: 'Button Label', section: 'email-templates' },
{ key: 'qaAnsweredEmailSignoff', label: 'Signoff', multiline: true, section: 'email-templates' },
] ]
function normalizeStudies(siteContent: SiteContent): StudyProgram[] { function normalizeStudies(siteContent: SiteContent): StudyProgram[] {
+9
View File
@@ -239,6 +239,11 @@ export interface SiteContent {
twoFaOtpEmailSubject: string twoFaOtpEmailSubject: string
twoFaOtpEmailBody: string twoFaOtpEmailBody: string
twoFaOtpEmailExpiry: string twoFaOtpEmailExpiry: string
// ── Q&A Answered Notification ──
qaAnsweredEmailSubject: string
qaAnsweredEmailBody: string
qaAnsweredEmailCtaLabel: string
qaAnsweredEmailSignoff: string
// ── Header ── // ── Header ──
headerFollowLabel: string headerFollowLabel: string
// ── Cookie banner ── // ── Cookie banner ──
@@ -430,6 +435,10 @@ export const DEFAULTS: SiteContent = {
twoFaOtpEmailSubject: 'Your sign-in code — Verse by Verse with Nate', twoFaOtpEmailSubject: 'Your sign-in code — Verse by Verse with Nate',
twoFaOtpEmailBody: 'Your two-factor sign-in code is below. Enter it to complete sign-in.', twoFaOtpEmailBody: 'Your two-factor sign-in code is below. Enter it to complete sign-in.',
twoFaOtpEmailExpiry: 'This code expires in 10 minutes. If you did not request this, you can ignore this message.', twoFaOtpEmailExpiry: 'This code expires in 10 minutes. If you did not request this, you can ignore this message.',
qaAnsweredEmailSubject: 'Your question has been answered — Verse by Verse with Nate',
qaAnsweredEmailBody: 'Nate has answered your question on Verse by Verse with Nate.',
qaAnsweredEmailCtaLabel: 'Read Full Answer →',
qaAnsweredEmailSignoff: 'Grace and peace,\nNate',
headerFollowLabel: 'Follow on Spotify', headerFollowLabel: 'Follow on Spotify',
cookieBannerText: 'We use optional analytics cookies to better understand how people find and engage with this content. By accepting, you allow us to measure how you interact with the site — no personal data is sold or shared.', cookieBannerText: 'We use optional analytics cookies to better understand how people find and engage with this content. By accepting, you allow us to measure how you interact with the site — no personal data is sold or shared.',
customLinks: [], customLinks: [],