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:
@@ -36,6 +36,7 @@ export default function ContactForm() {
|
||||
}
|
||||
}, [source, studyName])
|
||||
const [subscribe, setSubscribe] = useState(true)
|
||||
const [notifyOnAnswer, setNotifyOnAnswer] = useState(true)
|
||||
const [honey, setHoney] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
@@ -71,7 +72,7 @@ export default function ContactForm() {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
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) {
|
||||
@@ -135,6 +136,16 @@ export default function ContactForm() {
|
||||
Message
|
||||
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
|
||||
</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">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -595,6 +595,117 @@ export default function QASection() {
|
||||
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) =>
|
||||
`${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)}>
|
||||
{linkCopied ? '✓ Copied' : 'Copy link'}
|
||||
</button>
|
||||
<button type="button" className="qa-share-btn" onClick={() => saveAsImage(question)}>
|
||||
Save as image
|
||||
</button>
|
||||
</div>
|
||||
{(facebookCaptionCopied || linkCopied) && (
|
||||
<p className="qa-share-feedback" role="status" aria-live="polite">
|
||||
|
||||
Reference in New Issue
Block a user