Files
Siteforge/src/components/QASection.tsx
T
nmemmert 0512d40fc6
Publish Container / docker (push) Has been cancelled
Redesign resources page with PageBanner, featured download, and consistent page headers; v1.1.40
- Replace hero study guide form with PageBanner + featured download card in library
- Add PageBanner component used consistently across all interior pages (Downloads, Episodes, About, Contact, Q&A, Finished Books)
- Remove duplicate headings/eyebrows that conflicted with banner titles on About, Contact, Q&A, Finished Books pages
- Compact download card sizing (padding, border-radius, image size)
- Add Books & Resources tabbed section with fallback to TruthForLife widget
- Fix server sanitizer to pass through 'resources' placement items without URL
- Add featured-badge and featured card styling for primary guide

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 11:03:43 -04:00

1110 lines
46 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
interface PublicQuestion {
id: string
firstName: string
question: string
answer: string
topic?: string
submittedAt?: string
answeredAt?: string
upvotes?: number
pinned?: boolean
}
interface DecoratedQuestion extends PublicQuestion {
_topic: string
_tags: string[]
_helpful: number
_semanticScore: number
}
type SortMode = 'relevance' | 'newest' | 'oldest' | 'helpful'
interface EngagementCounts {
shares: number
related: number
expands: number
}
type EngagementMap = Record<string, EngagementCounts>
const QA_PAGE_SIZE = 8
const ENGAGEMENT_STORAGE_KEY = 'qa-engagement-v1'
const MAX_TAGS_PER_QUESTION = 4
const MAX_TAGS_VISIBLE = 10
const STOP_WORDS = new Set([
'about', 'after', 'again', 'also', 'always', 'among', 'appears', 'around', 'been', 'before', 'being', 'between', 'both', 'could',
'does', 'each', 'every', 'from', 'have', 'into', 'just', 'like', 'many', 'more', 'most', 'much', 'must', 'other', 'over', 'same',
'some', 'such', 'than', 'that', 'their', 'there', 'these', 'they', 'this', 'those', 'through', 'very', 'what', 'when', 'where',
'which', 'while', 'will', 'with', 'would', 'your', 'you', 'the', 'and', 'for', 'are', 'but', 'not', 'too', 'can', 'how', 'why',
'who', 'was', 'were', 'into', 'upon', 'then', 'them', 'ours', 'ourselves', 'himself', 'herself', 'because', 'therefore', 'really',
'simply', 'right', 'still', 'even', 'today', 'episode', 'episodes', 'verse', 'verses',
])
const TAG_LEXICON = [
'bible', 'scripture', 'faith', 'grace', 'hope', 'salvation', 'gospel', 'mercy', 'obedience', 'leadership', 'elders', 'church',
'doctrine', 'discipleship', 'prayer', 'family', 'politics', 'translation', 'reading', 'study', 'titus', 'christian',
]
const SYNONYM_MAP: Record<string, string[]> = {
faith: ['belief', 'trust'],
trust: ['faith', 'belief'],
grace: ['mercy', 'favor'],
mercy: ['grace', 'compassion'],
hope: ['expectation', 'future'],
love: ['charity', 'care'],
sin: ['wrong', 'evil'],
prayer: ['pray'],
pray: ['prayer'],
bible: ['scripture', 'word'],
scripture: ['bible', 'word'],
church: ['fellowship', 'body'],
leadership: ['elder', 'pastor'],
politics: ['government', 'public'],
family: ['home', 'household'],
translation: ['version'],
salvation: ['saved', 'redeemed'],
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function normalizeToken(token: string) {
return token.toLowerCase().replace(/[^a-z0-9]/g, '').trim()
}
function tokenize(text: string) {
return text
.toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ')
.split(/\s+/)
.map(normalizeToken)
.filter(token => token.length > 2)
}
function expandQueryTokens(tokens: string[]) {
const expanded = new Set<string>()
for (const token of tokens) {
expanded.add(token)
const synonyms = SYNONYM_MAP[token] ?? []
for (const synonym of synonyms) expanded.add(synonym)
}
return expanded
}
function renderHighlightedText(text: string, query: string) {
const trimmed = query.trim()
if (!trimmed) return text
const regex = new RegExp(`(${escapeRegExp(trimmed)})`, 'ig')
const parts = text.split(regex)
return parts.map((part, index) => {
if (part.toLowerCase() === trimmed.toLowerCase()) {
return <mark key={`mark-${index}`} className="qa-highlight">{part}</mark>
}
return <span key={`plain-${index}`}>{part}</span>
})
}
function renderTextWithLinks(text: string, highlightQuery = '') {
const BOOK_ID_MAP: Record<string, string> = {
gen: 'GEN', genesis: 'GEN', exo: 'EXO', exodus: 'EXO', lev: 'LEV', leviticus: 'LEV',
num: 'NUM', numbers: 'NUM', deut: 'DEU', deuteronomy: 'DEU', josh: 'JOS', joshua: 'JOS',
judg: 'JDG', judges: 'JDG', ruth: 'RUT', '1 sam': 'SA1', '2 sam': 'SA2',
'1 kgs': 'KI1', '1 kings': 'KI1', '2 kgs': 'KI2', '2 kings': 'KI2',
'1 chr': 'CH1', '1 chron': 'CH1', '1 chronicles': 'CH1', '2 chr': 'CH2', '2 chron': 'CH2', '2 chronicles': 'CH2',
ezra: 'EZR', neh: 'NEH', nehemiah: 'NEH', esth: 'EST', esther: 'EST',
job: 'JOB', ps: 'PSA', psalms: 'PSA', psalm: 'PSA', prov: 'PRO', proverbs: 'PRO',
eccl: 'ECC', ecclesiastes: 'ECC', song: 'SNG', 'song of sol': 'SNG', 'song of solomon': 'SNG',
isa: 'ISA', isaiah: 'ISA', jer: 'JER', jeremiah: 'JER', lam: 'LAM', lamentations: 'LAM',
ezek: 'EZK', ezekiel: 'EZK', dan: 'DAN', daniel: 'DAN', hos: 'HOS', hosea: 'HOS',
joel: 'JOL', amos: 'AMO', obad: 'OBA', obadiah: 'OBA', jonah: 'JNA', mic: 'MIC', micah: 'MIC',
nah: 'NAH', nahum: 'NAH', hab: 'HAB', habakkuk: 'HAB', zeph: 'ZEP', zephaniah: 'ZEP',
hag: 'HAG', haggai: 'HAG', zech: 'ZEC', zechariah: 'ZEC', mal: 'MAL', malachi: 'MAL',
matt: 'MAT', matthew: 'MAT', mark: 'MRK', luke: 'LUK', john: 'JHN', acts: 'ACT',
rom: 'ROM', romans: 'ROM', '1 cor': 'CO1', '1 corinthians': 'CO1', '2 cor': 'CO2', '2 corinthians': 'CO2',
gal: 'GAL', galatians: 'GAL', eph: 'EPH', ephesians: 'EPH', phil: 'PHP', philippians: 'PHP',
col: 'COL', colossians: 'COL', '1 thess': 'TH1', '1 thessalonians': 'TH1', '2 thess': 'TH2', '2 thessalonians': 'TH2',
'1 tim': 'TI1', '1 timothy': 'TI1', '2 tim': 'TI2', '2 timothy': 'TI2',
titus: 'TIT', philem: 'PHM', philemon: 'PHM', heb: 'HEB', hebrews: 'HEB',
jas: 'JAM', james: 'JAM', '1 pet': 'PE1', '1 peter': 'PE1', '2 pet': 'PE2', '2 peter': 'PE2',
'1 john': 'JO1', '2 john': 'JO2', '3 john': 'JO3', jude: 'JDE', rev: 'REV', revelation: 'REV',
}
function parseScriptureRef(refText: string): { bookId: string; chapter: number; verseStart: number; verseEnd: number } | null {
const match = refText.match(/^(.*?)\s+(\d+):(\d+)(?:-(\d+))?$/)
if (!match) return null
const [, bookRaw, chapterStr, verseStartStr, verseEndStr] = match
const bookKey = bookRaw.toLowerCase().replace(/\.\s*/g, ' ').trim()
const bookId = BOOK_ID_MAP[bookKey]
if (!bookId) return null
return {
bookId,
chapter: parseInt(chapterStr, 10),
verseStart: parseInt(verseStartStr, 10),
verseEnd: verseEndStr ? parseInt(verseEndStr, 10) : parseInt(verseStartStr, 10),
}
}
// Split the text by both scripture refs and URLs
const combined = /(https?:\/\/[^\s]+)|\b((?:(?:1|2|3)\s)?(?:Gen(?:esis)?|Exo(?:dus)?|Lev(?:iticus)?|Num(?:bers)?|Deut(?:eronomy)?|Josh(?:ua)?|Judg(?:es)?|Ruth|1\s?Sam|2\s?Sam|1\s?Kgs?|2\s?Kgs?|1\s?Chr(?:on)?|2\s?Chr(?:on)?|Ezra|Neh(?:emiah)?|Esth(?:er)?|Job|Ps(?:alms?)?|Prov(?:erbs)?|Eccl(?:esiastes)?|Song(?:\s?of\s?Sol(?:omon)?)?|Isa(?:iah)?|Jer(?:emiah)?|Lam(?:entations)?|Ezek(?:iel)?|Dan(?:iel)?|Hos(?:ea)?|Joel|Amos|Obad(?:iah)?|Jonah|Mic(?:ah)?|Nah(?:um)?|Hab(?:akkuk)?|Zeph(?:aniah)?|Hag(?:gai)?|Zech(?:ariah)?|Mal(?:achi)?|Matt(?:hew)?|Mark|Luke|John|Acts|Rom(?:ans)?|1\s?Cor(?:inthians)?|2\s?Cor(?:inthians)?|Gal(?:atians)?|Eph(?:esians)?|Phil(?:ippians)?|Col(?:ossians)?|1\s?Thess|2\s?Thess|1\s?Tim(?:othy)?|2\s?Tim(?:othy)?|Titus|Philem(?:on)?|Heb(?:rews)?|Jas(?:mes)?|1\s?Pet(?:er)?|2\s?Pet(?:er)?|1\s?John|2\s?John|3\s?John|Jude|Rev(?:elation)?)\.?\s+\d+:\d+(?:-\d+)?)\b/gi
const parts: string[] = []
let lastIndex = 0
let m: RegExpExecArray | null
// reset lastIndex for combined
combined.lastIndex = 0
while ((m = combined.exec(text)) !== null) {
if (m.index > lastIndex) parts.push(text.slice(lastIndex, m.index))
parts.push(m[0])
lastIndex = m.index + m[0].length
}
if (lastIndex < text.length) parts.push(text.slice(lastIndex))
return parts.map((part, index) => {
if (/^https?:\/\//i.test(part)) {
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>
)
}
const parsed = parseScriptureRef(part.replace(/\.$/, ''))
if (parsed) {
return <ScriptureTooltip key={`scripture-${index}`} refText={part} bookId={parsed.bookId} chapter={parsed.chapter} verseStart={parsed.verseStart} verseEnd={parsed.verseEnd} />
}
return <span key={`text-${index}`}>{renderHighlightedText(part, highlightQuery)}</span>
})
}
interface VerseData {
verse: number
value: string
}
// Maps helloao API book IDs → bible.com book codes
const BIBLE_COM_BOOK_IDS: Record<string, string> = {
GEN: 'GEN', EXO: 'EXO', LEV: 'LEV', NUM: 'NUM', DEU: 'DEU', JOS: 'JOS', JDG: 'JDG', RUT: 'RUT',
SA1: '1SA', SA2: '2SA', KI1: '1KI', KI2: '2KI', CH1: '1CH', CH2: '2CH',
EZR: 'EZR', NEH: 'NEH', EST: 'EST', JOB: 'JOB', PSA: 'PSA', PRO: 'PRO', ECC: 'ECC', SNG: 'SNG',
ISA: 'ISA', JER: 'JER', LAM: 'LAM', EZK: 'EZK', DAN: 'DAN', HOS: 'HOS', JOL: 'JOL', AMO: 'AMO',
OBA: 'OBA', JNA: 'JON', MIC: 'MIC', NAH: 'NAH', HAB: 'HAB', ZEP: 'ZEP', HAG: 'HAG', ZEC: 'ZEC', MAL: 'MAL',
MAT: 'MAT', MRK: 'MRK', LUK: 'LUK', JHN: 'JHN', ACT: 'ACT', ROM: 'ROM',
CO1: '1CO', CO2: '2CO', GAL: 'GAL', EPH: 'EPH', PHP: 'PHP', COL: 'COL',
TH1: '1TH', TH2: '2TH', TI1: '1TI', TI2: '2TI', TIT: 'TIT', PHM: 'PHM', HEB: 'HEB',
JAM: 'JAS', PE1: '1PE', PE2: '2PE', JO1: '1JN', JO2: '2JN', JO3: '3JN', JDE: 'JUD', REV: 'REV',
}
function ScriptureTooltip({ refText, bookId, chapter, verseStart, verseEnd }: {
refText: string
bookId: string
chapter: number
verseStart: number
verseEnd: number
}) {
const [open, setOpen] = useState(false)
const [verses, setVerses] = useState<VerseData[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
const wrapRef = useRef<HTMLSpanElement>(null)
useEffect(() => {
if (!open) return
if (verses.length > 0) return
setLoading(true)
setError(false)
fetch(`https://bible.helloao.org/api/BSB/${bookId}/${chapter}.json`)
.then(r => {
if (!r.ok) throw new Error('Not found')
return r.json() as Promise<{ chapter: { content: Array<{ type: string; number?: number; content?: Array<{ text?: string; poem?: number } | string> }> } }>
})
.then(data => {
const content = data?.chapter?.content ?? []
const found: VerseData[] = []
for (const item of content) {
if (item.type === 'verse' && item.number != null && item.number >= verseStart && item.number <= verseEnd) {
const text = (item.content ?? [])
.map(c => (typeof c === 'string' ? c : (c.text ?? '')))
.join('')
.trim()
if (text) found.push({ verse: item.number, value: text })
}
}
setVerses(found)
setLoading(false)
})
.catch(() => {
setError(true)
setLoading(false)
})
}, [open, bookId, chapter, verseStart, verseEnd, verses.length])
useEffect(() => {
if (!open) return
function handleClickOutside(e: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
return (
<span ref={wrapRef} className="scripture-ref-wrap">
<button
type="button"
className="scripture-ref"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
title={`View ${refText}`}
>
{refText}
</button>
{open && (
<span className="scripture-popup" role="tooltip">
<span className="scripture-popup-header">
<strong>{refText}</strong>
<button type="button" className="scripture-popup-close" onClick={() => setOpen(false)} aria-label="Close"></button>
</span>
{loading && <span className="scripture-popup-body">Loading</span>}
{error && <span className="scripture-popup-body scripture-popup-error">Could not load verse.</span>}
{!loading && !error && verses.length === 0 && <span className="scripture-popup-body">Verse not found.</span>}
{!loading && !error && verses.map(v => (
<span key={v.verse} className="scripture-popup-body">
{verseStart !== verseEnd && <sup>{v.verse} </sup>}{v.value}
</span>
))}
<span className="scripture-popup-footer">
<a
href={`https://www.bible.com/bible/3034/${BIBLE_COM_BOOK_IDS[bookId] ?? bookId}.${chapter}.BSB`}
target="_blank"
rel="noopener noreferrer"
className="scripture-popup-link"
>
Read on Bible.com
</a>
<span className="scripture-popup-attribution">Berean Standard Bible</span>
</span>
</span>
)}
</span>
)
}
// ─── old renderTextWithLinks removed, replaced above ───
function readEngagementFromStorage(): EngagementMap {
try {
const raw = window.localStorage.getItem(ENGAGEMENT_STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw)
return typeof parsed === 'object' && parsed ? (parsed as EngagementMap) : {}
} catch {
return {}
}
}
function truncateAnswer(text: string, limit = 260) {
if (text.length <= limit) return text
const sliced = text.slice(0, limit)
const safeCut = sliced.lastIndexOf(' ')
return `${sliced.slice(0, safeCut > 120 ? safeCut : limit).trim()}...`
}
function semanticScoreForQuestion(question: PublicQuestion, tags: string[], query: string) {
const normalized = query.trim().toLowerCase()
if (!normalized) return 0
const queryTokens = tokenize(normalized)
if (queryTokens.length === 0) return 0
const expanded = expandQueryTokens(queryTokens)
const questionTokens = new Set(tokenize(`${question.question} ${question.answer} ${question.topic ?? ''} ${tags.join(' ')}`))
let score = 0
for (const token of expanded) {
if (questionTokens.has(token)) score += 2
}
const lowerQuestion = question.question.toLowerCase()
const lowerAnswer = question.answer.toLowerCase()
const lowerTopic = (question.topic ?? '').toLowerCase()
if (lowerQuestion.includes(normalized)) score += 6
if (lowerAnswer.includes(normalized)) score += 3
if (lowerTopic.includes(normalized)) score += 2
for (const token of queryTokens) {
if (token.length < 4) continue
if (lowerQuestion.includes(token)) score += 1
if (lowerAnswer.includes(token)) score += 0.5
}
return score
}
function tokenizeForRelated(text: string) {
return tokenize(text).filter(token => token.length > 3 && !STOP_WORDS.has(token))
}
function extractAutoTags(question: PublicQuestion) {
const source = `${question.question} ${question.answer}`.toLowerCase()
const topicToken = normalizeToken(question.topic ?? '')
const lexiconHits = TAG_LEXICON
.filter(tag => source.includes(tag))
.filter(tag => tag !== topicToken)
if (lexiconHits.length > 0) {
return Array.from(new Set(lexiconHits)).slice(0, MAX_TAGS_PER_QUESTION)
}
const questionOnlyTokens = tokenize(question.question)
.filter(token => token.length >= 5 && !STOP_WORDS.has(token) && token !== topicToken)
.slice(0, MAX_TAGS_PER_QUESTION)
return Array.from(new Set(questionOnlyTokens))
}
export default function QASection() {
const [questions, setQuestions] = useState<PublicQuestion[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
const [selectedTag, setSelectedTag] = useState<string | null>(null)
const [focusedQuestionId, setFocusedQuestionId] = useState<string | null>(null)
const [sortMode, setSortMode] = useState<SortMode>('relevance')
const [compactMode, setCompactMode] = useState(false)
const [page, setPage] = useState(0)
const [engagement, setEngagement] = useState<EngagementMap>({})
const [expandedCompactAnswers, setExpandedCompactAnswers] = useState<Record<string, boolean>>({})
const [copiedQuestionId, setCopiedQuestionId] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [votedIds, setVotedIds] = useState<Set<string>>(() => {
try {
const raw = localStorage.getItem('qa-voted-ids-v1')
return new Set(raw ? JSON.parse(raw) as string[] : [])
} catch { return new Set() }
})
const resultsTopRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
fetch('/api/questions')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load questions'))))
.then(data => {
setQuestions((data as { questions: PublicQuestion[] }).questions ?? [])
setLoading(false)
})
.catch(() => {
setLoading(false)
})
}, [])
async function handleUpvote(id: string) {
if (votedIds.has(id)) return
try {
const res = await fetch(`/api/questions/${encodeURIComponent(id)}/upvote`, { method: 'POST' })
if (!res.ok) return
const data = await res.json() as { upvotes: number }
setQuestions(prev => prev.map(q => q.id === id ? { ...q, upvotes: data.upvotes } : q))
setVotedIds(prev => {
const next = new Set(prev)
next.add(id)
localStorage.setItem('qa-voted-ids-v1', JSON.stringify([...next]))
return next
})
} catch { /* ignore */ }
}
useEffect(() => {
setEngagement(readEngagementFromStorage())
}, [])
useEffect(() => {
window.localStorage.setItem(ENGAGEMENT_STORAGE_KEY, JSON.stringify(engagement))
}, [engagement])
const incrementEngagement = useCallback((id: string, metric: keyof EngagementCounts) => {
setEngagement(prev => {
const current = prev[id] ?? { shares: 0, related: 0, expands: 0 }
return {
...prev,
[id]: {
...current,
[metric]: current[metric] + 1,
},
}
})
}, [])
const normalizedSearch = searchQuery.trim().toLowerCase()
const questionSet = useMemo(() => {
return questions.map(question => {
const topic = question.topic?.trim() || 'General'
const tags = extractAutoTags(question)
const counts = engagement[question.id] ?? { shares: 0, related: 0, expands: 0 }
const helpful = counts.shares * 3 + counts.related * 2 + counts.expands
const semantic = semanticScoreForQuestion(question, tags, normalizedSearch)
return {
...question,
_topic: topic,
_tags: tags,
_helpful: helpful,
_semanticScore: semantic,
}
})
}, [engagement, normalizedSearch, questions])
useEffect(() => {
const syncFromHash = () => {
const hash = window.location.hash
if (!hash.startsWith('#qa-')) return
const id = decodeURIComponent(hash.slice(4))
const matched = questionSet.find(item => item.id === id)
if (!matched) return
setFocusedQuestionId(id)
setSelectedTopic(matched._topic)
window.setTimeout(() => {
const element = document.getElementById(`qa-${id}`)
element?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 80)
}
syncFromHash()
window.addEventListener('hashchange', syncFromHash)
return () => window.removeEventListener('hashchange', syncFromHash)
}, [questionSet])
const topicCounts = useMemo(() => {
const counts = new Map<string, number>()
for (const question of questionSet) {
counts.set(question._topic, (counts.get(question._topic) ?? 0) + 1)
}
return Array.from(counts.entries())
.map(([topic, count]) => ({ topic, count }))
.sort((a, b) => b.count - a.count || a.topic.localeCompare(b.topic))
}, [questionSet])
const tagCounts = useMemo(() => {
const counts = new Map<string, number>()
for (const question of questionSet) {
for (const tag of question._tags) {
counts.set(tag, (counts.get(tag) ?? 0) + 1)
}
}
return Array.from(counts.entries())
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))
.slice(0, MAX_TAGS_VISIBLE)
}, [questionSet])
const filteredQuestions = useMemo(() => {
const output = questionSet.filter(question => {
const matchesTopic = !selectedTopic || question._topic === selectedTopic
const matchesTag = !selectedTag || question._tags.includes(selectedTag)
const matchesSearch = !normalizedSearch || question._semanticScore > 0
return matchesTopic && matchesTag && matchesSearch
})
const timestamp = (question: PublicQuestion) => {
const value = question.answeredAt ?? question.submittedAt
return value ? new Date(value).getTime() : 0
}
output.sort((a, b) => {
if (sortMode === 'newest') return timestamp(b) - timestamp(a)
if (sortMode === 'oldest') return timestamp(a) - timestamp(b)
if (sortMode === 'helpful') {
if (b._helpful !== a._helpful) return b._helpful - a._helpful
return timestamp(b) - timestamp(a)
}
if (b._semanticScore !== a._semanticScore) return b._semanticScore - a._semanticScore
return timestamp(b) - timestamp(a)
})
return output
}, [normalizedSearch, questionSet, selectedTag, selectedTopic, sortMode])
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / QA_PAGE_SIZE))
const safePage = Math.min(page, totalPages - 1)
const pageStart = safePage * QA_PAGE_SIZE
const pageEnd = Math.min(pageStart + QA_PAGE_SIZE, filteredQuestions.length)
const pagedQuestions = filteredQuestions.slice(pageStart, pageEnd)
useEffect(() => {
setPage(0)
}, [normalizedSearch, selectedTag, selectedTopic, sortMode])
useEffect(() => {
if (page > totalPages - 1) setPage(totalPages - 1)
}, [page, totalPages])
useEffect(() => {
if (!focusedQuestionId) return
const timer = window.setTimeout(() => setFocusedQuestionId(null), 2200)
return () => window.clearTimeout(timer)
}, [focusedQuestionId])
useEffect(() => {
if (!focusedQuestionId) return
const index = filteredQuestions.findIndex(item => item.id === focusedQuestionId)
if (index === -1) return
const nextPage = Math.floor(index / QA_PAGE_SIZE)
if (nextPage !== safePage) setPage(nextPage)
}, [filteredQuestions, focusedQuestionId, safePage])
const goToPage = (nextPage: number) => {
setPage(nextPage)
window.setTimeout(() => {
resultsTopRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 40)
}
const handleSearch = (value: string) => {
setSearchQuery(value)
}
const handleTopic = (topic: string | null) => {
setSelectedTopic(topic)
}
const handleTag = (tag: string | null) => {
setSelectedTag(tag)
}
const clearFilters = () => {
setSelectedTopic(null)
setSelectedTag(null)
setSearchQuery('')
setPage(0)
}
const buildQuoteCanvas = (question: DecoratedQuestion): HTMLCanvasElement => {
const SIZE = 1080
const PADDING = 72
const canvas = document.createElement('canvas')
canvas.width = SIZE
canvas.height = SIZE
const ctx = canvas.getContext('2d')!
ctx.fillStyle = '#1a1208'
ctx.fillRect(0, 0, SIZE, SIZE)
ctx.fillStyle = '#c9a84c'
ctx.fillRect(0, 0, SIZE, 6)
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
ctx.fillStyle = '#c9a84c'
ctx.font = 'bold 26px Georgia, serif'
ctx.textAlign = 'center'
ctx.fillText('✦ Verse by Verse with Nate ✦', SIZE / 2, y)
y += 50
ctx.fillStyle = '#c9a84c'
ctx.fillRect(PADDING, y, SIZE - PADDING * 2, 1)
y += 36
ctx.fillStyle = '#c9a84c'
ctx.font = 'bold 22px Georgia, serif'
ctx.textAlign = 'left'
ctx.fillText('Q', PADDING, y)
y += 8
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
ctx.fillStyle = '#3a2d14'
ctx.fillRect(PADDING, y, SIZE - PADDING * 2, 1)
y += 36
ctx.fillStyle = '#c9a84c'
ctx.font = 'bold 22px Georgia, serif'
ctx.textAlign = 'left'
ctx.fillText('A', PADDING, y)
y += 8
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) }
ctx.fillStyle = '#8a7040'
ctx.font = '20px Georgia, serif'
ctx.textAlign = 'center'
ctx.fillText(window.location.host + '/questions', SIZE / 2, SIZE - PADDING + 8)
return canvas
}
const saveAsImage = (question: DecoratedQuestion) => {
const canvas = buildQuoteCanvas(question)
const link = document.createElement('a')
link.download = `vbvn-qa-${question.id.slice(0, 8)}.png`
link.href = canvas.toDataURL('image/png')
link.click()
}
const shareImageTo = async (question: DecoratedQuestion, platform: 'x' | 'facebook') => {
const canvas = buildQuoteCanvas(question)
const shareUrl = getSocialUrl(question.id)
const shareText = `Q: ${question.question.slice(0, 120)}${question.question.length > 120 ? '…' : ''}`
// Try Web Share API with file (works on mobile — iOS Safari, Android Chrome)
if (typeof navigator.share === 'function') {
try {
const blob = await new Promise<Blob>((resolve, reject) =>
canvas.toBlob(b => b ? resolve(b) : reject(new Error('Canvas toBlob failed')), 'image/png')
)
const file = new File([blob], `vbvn-qa-${question.id.slice(0, 8)}.png`, { type: 'image/png' })
if (navigator.canShare?.({ files: [file] })) {
await navigator.share({ files: [file], text: shareText, url: shareUrl })
incrementEngagement(question.id, 'shares')
return
}
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return
}
}
// Desktop fallback: download the image, then open the compose window
const link = document.createElement('a')
link.download = `vbvn-qa-${question.id.slice(0, 8)}.png`
link.href = canvas.toDataURL('image/png')
link.click()
if (platform === 'x') {
const tweetUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(shareUrl)}`
window.open(tweetUrl, '_blank', 'width=600,height=400,noopener')
} else {
const fbUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`
window.open(fbUrl, '_blank', 'width=600,height=400,noopener')
}
incrementEngagement(question.id, 'shares')
}
const getShareUrl = (id: string) =>
`${window.location.origin}/questions#qa-${encodeURIComponent(id)}`
const getSocialUrl = (id: string) =>
`${window.location.origin}/questions/share/${encodeURIComponent(id)}`
const shareQuestion = async (id: string) => {
const shareUrl = getShareUrl(id)
try {
await navigator.clipboard.writeText(shareUrl)
incrementEngagement(id, 'shares')
setCopiedQuestionId(id)
window.history.replaceState(null, '', `/questions#qa-${encodeURIComponent(id)}`)
window.setTimeout(() => setCopiedQuestionId(curr => (curr === id ? null : curr)), 1800)
} catch {
window.prompt('Copy this link:', shareUrl)
}
}
const formatDate = (value?: string) => {
if (!value) return null
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return null
return parsed.toLocaleDateString()
}
const toggleCompactAnswer = (id: string) => {
const next = !expandedCompactAnswers[id]
setExpandedCompactAnswers(prev => ({ ...prev, [id]: next }))
if (next) incrementEngagement(id, 'expands')
}
const getRelatedQuestions = (current: DecoratedQuestion) => {
const currentTokens = new Set(tokenizeForRelated(`${current.question} ${current.answer}`))
return questionSet
.filter(candidate => candidate.id !== current.id)
.map(candidate => {
const candidateTokens = tokenizeForRelated(`${candidate.question} ${candidate.answer}`)
const overlap = candidateTokens.filter(token => currentTokens.has(token)).length
const sameTopic = current._topic === candidate._topic
const score = overlap + (sameTopic ? 5 : 0)
return { candidate, score }
})
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 3)
.map(item => item.candidate)
}
const topSuggestedTopic = topicCounts[0]?.topic ?? null
const mostHelpfulQuestion = [...questionSet].sort((a, b) => b._helpful - a._helpful)[0] ?? null
return (
<section id="qa" className="section-qa" aria-label="Questions and answers">
<div className="section-inner">
<div className="qa-submit-cta">
<p className="qa-submit-cta-text">Have a question about Scripture or the podcast?</p>
<Link to="/contact" className="btn-primary">Submit a Question </Link>
</div>
{loading ? (
<div className="qa-skeleton-list" aria-busy="true" aria-label="Loading questions">
{[0, 1, 2].map(i => (
<div key={i} className="qa-skeleton-card">
<div className="qa-skeleton-line qa-skeleton-line--short" />
<div className="qa-skeleton-line" />
<div className="qa-skeleton-line qa-skeleton-line--medium" />
</div>
))}
</div>
) : questions.length === 0 ? (
<div className="qa-no-results">
<p>No questions have been answered yet. <a href="/contact">Submit yours below!</a></p>
</div>
) : (
<>
<div className="qa-toolbar">
<button
type="button"
className={`qa-view-toggle${compactMode ? ' qa-view-toggle--active' : ''}`}
onClick={() => setCompactMode(value => !value)}
>
{compactMode ? 'Compact view: On' : 'Compact view: Off'}
</button>
</div>
<div className="qa-filters">
<div className="qa-topics">
<button
type="button"
className={`qa-topic-btn${selectedTopic === null ? ' qa-topic-btn--active' : ''}`}
onClick={() => handleTopic(null)}
>
All
</button>
{topicCounts.map(({ topic }) => (
<button
type="button"
key={topic}
className={`qa-topic-btn${selectedTopic === topic ? ' qa-topic-btn--active' : ''}`}
onClick={() => handleTopic(topic)}
>
{topic}
</button>
))}
</div>
<div className="qa-tags">
<button
type="button"
className={`qa-tag-btn${selectedTag === null ? ' qa-tag-btn--active' : ''}`}
onClick={() => handleTag(null)}
>
All tags
</button>
{tagCounts.map(({ tag }) => (
<button
type="button"
key={tag}
className={`qa-tag-btn${selectedTag === tag ? ' qa-tag-btn--active' : ''}`}
onClick={() => handleTag(tag)}
>
#{tag}
</button>
))}
</div>
<div className="qa-search">
<label>
<span className="visually-hidden">Search Questions</span>
<input
type="text"
placeholder="Search naturally (example: how to stay consistent in Bible reading)"
value={searchQuery}
onChange={e => handleSearch(e.target.value)}
/>
</label>
</div>
</div>
{(searchQuery || selectedTopic || selectedTag) && (
<div className="qa-filter-chips" aria-label="Active filters">
{searchQuery && (
<button type="button" className="qa-chip" onClick={() => setSearchQuery('')}>
Search: {searchQuery} x
</button>
)}
{selectedTopic && (
<button type="button" className="qa-chip" onClick={() => setSelectedTopic(null)}>
Topic: {selectedTopic} x
</button>
)}
{selectedTag && (
<button type="button" className="qa-chip" onClick={() => setSelectedTag(null)}>
Tag: #{selectedTag} x
</button>
)}
<button type="button" className="qa-chip qa-chip--clear" onClick={clearFilters}>Clear all</button>
</div>
)}
{filteredQuestions.length === 0 ? (
<div className="qa-no-results qa-no-results--smart">
<p>No matches for this search yet.</p>
{topSuggestedTopic && (
<p>
Try browsing <button type="button" className="qa-inline-link" onClick={() => { clearFilters(); setSelectedTopic(topSuggestedTopic) }}>{topSuggestedTopic}</button> questions.
</p>
)}
{mostHelpfulQuestion && (
<p>
Or jump to <button type="button" className="qa-inline-link" onClick={() => {
clearFilters()
setFocusedQuestionId(mostHelpfulQuestion.id)
window.history.replaceState(null, '', `/questions#qa-${encodeURIComponent(mostHelpfulQuestion.id)}`)
}}>{mostHelpfulQuestion.question}</button>.
</p>
)}
<p><a href="/contact">Submit your question</a> and Nate may add it here.</p>
</div>
) : (
<>
<div className="qa-meta-row">
<p className="qa-result-count">
{filteredQuestions.length} result{filteredQuestions.length === 1 ? '' : 's'}
{normalizedSearch && <span> for "{normalizedSearch}"</span>}
{filteredQuestions.length > 0 && <span> · Showing {pageStart + 1}-{pageEnd}</span>}
</p>
<label className="qa-sort-select" htmlFor="qa-sort-mode">
Sort
<select id="qa-sort-mode" value={sortMode} onChange={e => setSortMode(e.target.value as SortMode)}>
<option value="relevance">Best match</option>
<option value="helpful">Most helpful</option>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
</select>
</label>
</div>
<div className="qa-layout">
<aside className="qa-sidebar" aria-label="Question topics">
<p className="qa-sidebar-label">Browse by topic</p>
<button
type="button"
className={`qa-topic-btn qa-topic-btn--full${selectedTopic === null ? ' qa-topic-btn--active' : ''}`}
onClick={() => handleTopic(null)}
>
<span>All topics</span>
<span>{questions.length}</span>
</button>
{topicCounts.map(({ topic, count }) => (
<button
type="button"
key={topic}
className={`qa-topic-btn qa-topic-btn--full${selectedTopic === topic ? ' qa-topic-btn--active' : ''}`}
onClick={() => handleTopic(topic)}
>
<span>{topic}</span>
<span>{count}</span>
</button>
))}
</aside>
<div className={`qa-cards${compactMode ? ' qa-cards--compact' : ''}`}>
<div ref={resultsTopRef} aria-hidden="true" />
{pagedQuestions.map(question => {
const relatedQuestions = getRelatedQuestions(question)
const isFocused = focusedQuestionId === question.id
const isExpandedInCompact = expandedCompactAnswers[question.id] === true
const linkCopied = copiedQuestionId === question.id
const answerText = compactMode && !isExpandedInCompact
? truncateAnswer(question.answer)
: question.answer
return (
<article key={question.id} id={`qa-${question.id}`} className={`qa-card-scene${isFocused ? ' qa-card-scene--focused' : ''}${question.pinned ? ' qa-card-scene--pinned' : ''}`}>
<div className="qa-question-header">
<span className="qa-face-label">Q</span>
<span className="qa-question-main">
<span className="qa-question-text">
{question.pinned && (
<span className="qa-pinned-badge" aria-label="Pinned question">📌 </span>
)}
{renderHighlightedText(question.question, normalizedSearch)}
</span>
<span className="qa-question-meta">
<span className="qa-topic-pill">{question._topic}</span>
{formatDate(question.answeredAt ?? question.submittedAt) && (
<span>{formatDate(question.answeredAt ?? question.submittedAt)}</span>
)}
{question._helpful > 0 && <span>{question._helpful} helpful</span>}
{(question.upvotes ?? 0) > 0 && (
<span>👍 {question.upvotes}</span>
)}
</span>
</span>
</div>
<div className="qa-card-actions">
<button
type="button"
aria-label={votedIds.has(question.id) ? 'Already upvoted' : 'Upvote this question'}
className={`qa-upvote-btn${votedIds.has(question.id) ? ' qa-upvote-btn--voted' : ''}`}
onClick={() => handleUpvote(question.id)}
disabled={votedIds.has(question.id)}
>
<svg viewBox="0 0 16 16" aria-hidden="true" fill="currentColor" width="13" height="13"><path d="M8 2L2 9h4v5h4V9h4z"/></svg>
<span>{votedIds.has(question.id) ? 'Upvoted' : 'Upvote'}{(question.upvotes ?? 0) > 0 ? ` · ${question.upvotes}` : ''}</span>
</button>
<button
type="button"
aria-label="Share on X"
className="qa-social-btn qa-social-btn--x"
onClick={() => shareImageTo(question, 'x')}
>
<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" width="14" height="14"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.746l7.73-8.835L1.254 2.25H8.08l4.253 5.622zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
<span>Share</span>
</button>
<button
type="button"
aria-label="Share on Facebook"
className="qa-social-btn qa-social-btn--fb"
onClick={() => shareImageTo(question, 'facebook')}
>
<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" width="14" height="14"><path d="M24 12.073C24 5.404 18.627 0 12 0S0 5.404 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.236 2.686.236v2.97h-1.513c-1.491 0-1.956.93-1.956 1.884v2.25h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z"/></svg>
<span>Share</span>
</button>
<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 image
</button>
</div>
{linkCopied && (
<p className="qa-share-feedback" role="status" aria-live="polite">
Direct link copied to clipboard.
</p>
)}
<div id={`qa-answer-${question.id}`} className="qa-card-face qa-card-back">
<span className="qa-face-label">A</span>
<div className="qa-answer-text">
{renderTextWithLinks(answerText, normalizedSearch)}
{compactMode && question.answer.length > 260 && (
<button
type="button"
className="qa-read-more-btn"
onClick={() => toggleCompactAnswer(question.id)}
>
{isExpandedInCompact ? 'Show less' : 'Read full answer'}
</button>
)}
</div>
{relatedQuestions.length > 0 && (
<div className="qa-related-wrap">
<p className="qa-related-label">Related questions</p>
<div className="qa-related-list">
{relatedQuestions.map(related => (
<button
key={related.id}
type="button"
className="qa-related-btn"
onClick={() => {
incrementEngagement(question.id, 'related')
setSelectedTopic(related._topic)
setSelectedTag(null)
setSearchQuery('')
setFocusedQuestionId(related.id)
window.history.replaceState(null, '', `/questions#qa-${encodeURIComponent(related.id)}`)
window.setTimeout(() => {
const element = document.getElementById(`qa-${related.id}`)
element?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 30)
}}
>
{related.question}
</button>
))}
</div>
</div>
)}
<p className="qa-answer-byline">Answered by Nate</p>
</div>
</article>
)
})}
{totalPages > 1 && (
<div className="qa-pagination">
<button
type="button"
className="qa-page-btn"
onClick={() => goToPage(Math.max(0, safePage - 1))}
disabled={safePage === 0}
aria-label="Previous questions page"
>
Prev
</button>
<span className="qa-page-info">Page {safePage + 1} / {totalPages}</span>
<button
type="button"
className="qa-page-btn"
onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))}
disabled={safePage >= totalPages - 1}
aria-label="Next questions page"
>
Next
</button>
</div>
)}
</div>
</div>
</>
)}
</>
)}
</div>
</section>
)
}