Add notify-on-answer email and save-as-image for Q&A; v1.0.10
- Contact form: notifyOnAnswer checkbox (shown for Bible Questions only) - Contact route: stores notifyOnAnswer flag on question record - Email: buildQuestionAnsweredEmailTemplate for branded notification - Questions route: sends notification email on approve when notifyOnAnswer + email + answer are set - Q&A section: Canvas API "Save as image" button generates 1080x1080 PNG quote card Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.9",
|
"version": "1.0.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -385,6 +385,53 @@ export function buildAdminReplyTemplate({ recipientName, message }) {
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildQuestionAnsweredEmailTemplate({ firstName, question, answer, questionUrl }) {
|
||||||
|
const safeName = escapeHtml(firstName || 'friend')
|
||||||
|
const safeQuestion = escapeHtml(question)
|
||||||
|
const safeAnswer = escapeHtml(answer.length > 600 ? answer.slice(0, 597) + '…' : answer).replace(/\n/g, '<br/>')
|
||||||
|
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 html = `
|
||||||
|
<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;">Your Question Was Answered</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 ${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>
|
||||||
|
<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;font-family:Georgia,serif;font-size:15px;line-height:1.6;color:#201a10;font-style:italic;">${safeQuestion}</p>
|
||||||
|
</div>
|
||||||
|
<div style="margin:0 0 24px;">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</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;">You received this because you requested a notification when this question was answered.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
return { subject: 'Your question has been answered — Verse by Verse with Nate', text, html }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Transactional email senders ────────────────────────────────────────────
|
// ── Transactional email senders ────────────────────────────────────────────
|
||||||
|
|
||||||
export async function sendStudyWelcomeEmail(email, displayName) {
|
export async function sendStudyWelcomeEmail(email, displayName) {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ function contactRateLimit(req, res, next) {
|
|||||||
export function register(app) {
|
export function register(app) {
|
||||||
app.post('/api/contact', contactRateLimit, async (req, res) => {
|
app.post('/api/contact', contactRateLimit, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { firstName, lastName, email, message, messageType, subscribe, _honey } = req.body ?? {}
|
const { firstName, lastName, email, message, messageType, subscribe, notifyOnAnswer, _honey } = req.body ?? {}
|
||||||
|
|
||||||
if (_honey) { res.json({ ok: true }); return }
|
if (_honey) { res.json({ ok: true }); return }
|
||||||
|
|
||||||
@@ -164,6 +164,7 @@ export function register(app) {
|
|||||||
answeredAt: null,
|
answeredAt: null,
|
||||||
isApproved: false,
|
isApproved: false,
|
||||||
approvedAt: null,
|
approvedAt: null,
|
||||||
|
notifyOnAnswer: notifyOnAnswer === true,
|
||||||
}
|
}
|
||||||
state.questions.unshift(question)
|
state.questions.unshift(question)
|
||||||
state.questions = state.questions.slice(0, MAX_QUESTIONS)
|
state.questions = state.questions.slice(0, MAX_QUESTIONS)
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { requireAdminAuth } from '../auth.js'
|
|||||||
import { MAX_QUESTIONS } from '../config.js'
|
import { MAX_QUESTIONS } from '../config.js'
|
||||||
import { state } from '../state.js'
|
import { state } from '../state.js'
|
||||||
import { queueQuestionsWrite, queueDraftQuestionsWrite } from '../data.js'
|
import { queueQuestionsWrite, queueDraftQuestionsWrite } from '../data.js'
|
||||||
|
import { buildQuestionAnsweredEmailTemplate, getResendFromAddress, getResendReplyToAddress, sendResendEmailWithRetry, getCanonicalBaseUrl } from '../email.js'
|
||||||
|
import { buildAbsoluteUrl } from '../helpers.js'
|
||||||
|
import { Resend } from 'resend'
|
||||||
|
|
||||||
function ensureDraftQuestions() {
|
function ensureDraftQuestions() {
|
||||||
if (state.draftQuestions !== null) return
|
if (state.draftQuestions !== null) return
|
||||||
@@ -103,6 +106,29 @@ export function register(app) {
|
|||||||
question.approvedAt = approved === true ? new Date().toISOString() : null
|
question.approvedAt = approved === true ? new Date().toISOString() : null
|
||||||
queueDraftQuestionsWrite()
|
queueDraftQuestionsWrite()
|
||||||
|
|
||||||
|
if (approved === true && question.notifyOnAnswer && question.email && question.answer) {
|
||||||
|
try {
|
||||||
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
|
const from = getResendFromAddress()
|
||||||
|
const replyTo = getResendReplyToAddress()
|
||||||
|
const baseUrl = getCanonicalBaseUrl()
|
||||||
|
const questionUrl = buildAbsoluteUrl(baseUrl, `/questions#qa-${question.id}`)
|
||||||
|
const template = buildQuestionAnsweredEmailTemplate({
|
||||||
|
firstName: question.firstName,
|
||||||
|
question: question.question,
|
||||||
|
answer: question.answer,
|
||||||
|
questionUrl,
|
||||||
|
})
|
||||||
|
await sendResendEmailWithRetry({
|
||||||
|
resend,
|
||||||
|
payload: { from, replyTo, to: question.email, subject: template.subject, text: template.text, html: template.html },
|
||||||
|
context: `notify-on-answer:${question.id}`,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[notify-on-answer] Failed to send notification email:', err?.message ?? err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ ok: true, question })
|
res.json({ ok: true, question })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export default function ContactForm() {
|
|||||||
}
|
}
|
||||||
}, [source, studyName])
|
}, [source, studyName])
|
||||||
const [subscribe, setSubscribe] = useState(true)
|
const [subscribe, setSubscribe] = useState(true)
|
||||||
|
const [notifyOnAnswer, setNotifyOnAnswer] = useState(true)
|
||||||
const [honey, setHoney] = useState('')
|
const [honey, setHoney] = useState('')
|
||||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
|
||||||
const [errorMsg, setErrorMsg] = useState('')
|
const [errorMsg, setErrorMsg] = useState('')
|
||||||
@@ -71,7 +72,7 @@ export default function ContactForm() {
|
|||||||
const res = await fetch('/api/contact', {
|
const res = await fetch('/api/contact', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ...fields, subscribe, _honey: honey }),
|
body: JSON.stringify({ ...fields, subscribe, notifyOnAnswer: fields.messageType === 'question' ? notifyOnAnswer : false, _honey: honey }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -135,6 +136,16 @@ export default function ContactForm() {
|
|||||||
Message
|
Message
|
||||||
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
|
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
|
||||||
</label>
|
</label>
|
||||||
|
{fields.messageType === 'question' && (
|
||||||
|
<label className="contact-consent">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notifyOnAnswer}
|
||||||
|
onChange={e => setNotifyOnAnswer(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Email me when this question is answered.</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<label className="contact-consent">
|
<label className="contact-consent">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -595,6 +595,117 @@ export default function QASection() {
|
|||||||
setPage(0)
|
setPage(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saveAsImage = (question: DecoratedQuestion) => {
|
||||||
|
const SIZE = 1080
|
||||||
|
const PADDING = 72
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = SIZE
|
||||||
|
canvas.height = SIZE
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
|
||||||
|
// Background
|
||||||
|
ctx.fillStyle = '#1a1208'
|
||||||
|
ctx.fillRect(0, 0, SIZE, SIZE)
|
||||||
|
|
||||||
|
// Gold top border
|
||||||
|
ctx.fillStyle = '#c9a84c'
|
||||||
|
ctx.fillRect(0, 0, SIZE, 6)
|
||||||
|
|
||||||
|
// Helper: word-wrap text
|
||||||
|
function wrapText(text: string, maxWidth: number, font: string): string[] {
|
||||||
|
ctx.font = font
|
||||||
|
const words = text.split(' ')
|
||||||
|
const lines: string[] = []
|
||||||
|
let line = ''
|
||||||
|
for (const word of words) {
|
||||||
|
const test = line ? `${line} ${word}` : word
|
||||||
|
if (ctx.measureText(test).width > maxWidth && line) {
|
||||||
|
lines.push(line)
|
||||||
|
line = word
|
||||||
|
} else {
|
||||||
|
line = test
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line) lines.push(line)
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
let y = PADDING + 40
|
||||||
|
|
||||||
|
// Ornament header
|
||||||
|
ctx.fillStyle = '#c9a84c'
|
||||||
|
ctx.font = 'bold 26px Georgia, serif'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText('✦ Verse by Verse with Nate ✦', SIZE / 2, y)
|
||||||
|
y += 50
|
||||||
|
|
||||||
|
// Divider
|
||||||
|
ctx.fillStyle = '#c9a84c'
|
||||||
|
ctx.fillRect(PADDING, y, SIZE - PADDING * 2, 1)
|
||||||
|
y += 36
|
||||||
|
|
||||||
|
// Question label
|
||||||
|
ctx.fillStyle = '#c9a84c'
|
||||||
|
ctx.font = 'bold 22px Georgia, serif'
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
ctx.fillText('Q', PADDING, y)
|
||||||
|
y += 8
|
||||||
|
|
||||||
|
// Question text
|
||||||
|
const qFont = 'italic 32px Georgia, serif'
|
||||||
|
const qLines = wrapText(`"${question.question}"`, SIZE - PADDING * 2 - 20, qFont)
|
||||||
|
ctx.fillStyle = '#f5ead2'
|
||||||
|
ctx.font = qFont
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
const qLineH = 44
|
||||||
|
for (const line of qLines.slice(0, 5)) {
|
||||||
|
y += qLineH
|
||||||
|
ctx.fillText(line, PADDING + 20, y)
|
||||||
|
}
|
||||||
|
y += 40
|
||||||
|
|
||||||
|
// Divider
|
||||||
|
ctx.fillStyle = '#3a2d14'
|
||||||
|
ctx.fillRect(PADDING, y, SIZE - PADDING * 2, 1)
|
||||||
|
y += 36
|
||||||
|
|
||||||
|
// Answer label
|
||||||
|
ctx.fillStyle = '#c9a84c'
|
||||||
|
ctx.font = 'bold 22px Georgia, serif'
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
ctx.fillText('A', PADDING, y)
|
||||||
|
y += 8
|
||||||
|
|
||||||
|
// Answer text (truncated to fit)
|
||||||
|
const aFont = '28px Georgia, serif'
|
||||||
|
const maxAHeight = SIZE - y - PADDING - 60
|
||||||
|
const aLineH = 40
|
||||||
|
const maxALines = Math.floor(maxAHeight / aLineH)
|
||||||
|
const rawAnswer = question.answer.replace(/\*\*/g, '').replace(/\*/g, '').replace(/<[^>]+>/g, '')
|
||||||
|
const aLines = wrapText(rawAnswer, SIZE - PADDING * 2 - 20, aFont).slice(0, maxALines)
|
||||||
|
if (aLines.length > 0 && rawAnswer.split(' ').length > aLines.join(' ').split(' ').length) {
|
||||||
|
aLines[aLines.length - 1] = aLines[aLines.length - 1].replace(/\s*\w+$/, '…')
|
||||||
|
}
|
||||||
|
ctx.fillStyle = '#e8d9b5'
|
||||||
|
ctx.font = aFont
|
||||||
|
for (const line of aLines) {
|
||||||
|
y += aLineH
|
||||||
|
ctx.fillText(line, PADDING + 20, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL footer
|
||||||
|
ctx.fillStyle = '#8a7040'
|
||||||
|
ctx.font = '20px Georgia, serif'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText(window.location.host + '/questions', SIZE / 2, SIZE - PADDING + 8)
|
||||||
|
|
||||||
|
// Download
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.download = `vbvn-qa-${question.id.slice(0, 8)}.png`
|
||||||
|
link.href = canvas.toDataURL('image/png')
|
||||||
|
link.click()
|
||||||
|
}
|
||||||
|
|
||||||
const getShareUrl = (id: string) =>
|
const getShareUrl = (id: string) =>
|
||||||
`${window.location.origin}/questions#qa-${encodeURIComponent(id)}`
|
`${window.location.origin}/questions#qa-${encodeURIComponent(id)}`
|
||||||
|
|
||||||
@@ -929,6 +1040,9 @@ export default function QASection() {
|
|||||||
<button type="button" className="qa-share-btn" onClick={() => shareQuestion(question.id)}>
|
<button type="button" className="qa-share-btn" onClick={() => shareQuestion(question.id)}>
|
||||||
{linkCopied ? '✓ Copied' : 'Copy link'}
|
{linkCopied ? '✓ Copied' : 'Copy link'}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="qa-share-btn" onClick={() => saveAsImage(question)}>
|
||||||
|
Save as image
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{(facebookCaptionCopied || linkCopied) && (
|
{(facebookCaptionCopied || linkCopied) && (
|
||||||
<p className="qa-share-feedback" role="status" aria-live="polite">
|
<p className="qa-share-feedback" role="status" aria-live="polite">
|
||||||
|
|||||||
Reference in New Issue
Block a user