Add social share buttons and per-question OG share stubs

This commit is contained in:
nmemmert
2026-05-11 11:53:01 -04:00
parent f4adeb2844
commit 7a48130ffb
3 changed files with 1032 additions and 195 deletions
+613 -108
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'
import type { KeyboardEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
interface PublicQuestion {
id: string
@@ -7,24 +6,114 @@ interface PublicQuestion {
question: string
answer: string
topic?: string
submittedAt?: string
answeredAt?: string
}
const QA_PAGE_SIZE = 6
interface DecoratedQuestion extends PublicQuestion {
_topic: string
_tags: string[]
_helpful: number
_semanticScore: number
}
function tokenizeForRelated(text: string) {
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+/)
.filter(token => token.length > 3)
.map(normalizeToken)
.filter(token => token.length > 2)
}
function renderTextWithLinks(text: string) {
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 parts = text.split(/(https?:\/\/[^\s]+)/g)
return parts.map((part, index) => {
if (!/^https?:\/\//i.test(part)) {
return <span key={`text-${index}`}>{part}</span>
return <span key={`text-${index}`}>{renderHighlightedText(part, highlightQuery)}</span>
}
const safeHref = part.replace(/[),.;!?]+$/g, '')
@@ -41,13 +130,93 @@ function renderTextWithLinks(text: string) {
})
}
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 [expanded, setExpanded] = useState<{ [key: string]: boolean }>({})
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 resultsTopRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
fetch('/api/questions')
@@ -61,49 +230,236 @@ export default function QASection() {
})
}, [])
const topics = Array.from(new Set(questions.map(q => q.topic).filter(Boolean))) as string[]
useEffect(() => {
setEngagement(readEngagementFromStorage())
}, [])
const filteredQuestions = questions.filter(q => {
const matchesTopic = !selectedTopic || q.topic === selectedTopic
const matchesSearch =
!searchQuery ||
q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
q.answer.toLowerCase().includes(searchQuery.toLowerCase())
return matchesTopic && matchesSearch
})
useEffect(() => {
window.localStorage.setItem(ENGAGEMENT_STORAGE_KEY, JSON.stringify(engagement))
}, [engagement])
const totalPages = Math.ceil(filteredQuestions.length / QA_PAGE_SIZE)
const pagedQuestions = filteredQuestions.slice(page * QA_PAGE_SIZE, (page + 1) * QA_PAGE_SIZE)
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 toggleExpanded = (id: string) => {
setExpanded(state => ({ ...state, [id]: !state[id] }))
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)
setPage(0)
}
const handleTopic = (topic: string | null) => {
setSelectedTopic(topic)
setPage(0)
}
const handleTag = (tag: string | null) => {
setSelectedTag(tag)
}
const clearFilters = () => {
setSelectedTopic(null)
setSelectedTag(null)
setSearchQuery('')
setPage(0)
}
const getRelatedQuestions = (current: PublicQuestion) => {
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 shareToX = (question: DecoratedQuestion) => {
const url = getSocialUrl(question.id)
const text = question.question.length > 200
? `${question.question.slice(0, 197)}`
: question.question
window.open(
`https://x.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`,
'_blank',
'noopener,noreferrer,width=600,height=420'
)
incrementEngagement(question.id, 'shares')
}
const shareToFacebook = (id: string) => {
const url = getSocialUrl(id)
window.open(
`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
'_blank',
'noopener,noreferrer,width=600,height=500'
)
incrementEngagement(id, 'shares')
}
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 questions
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 = Boolean(current.topic && candidate.topic && current.topic === candidate.topic)
const sameTopic = current._topic === candidate._topic
const score = overlap + (sameTopic ? 5 : 0)
return { candidate, score }
})
@@ -113,6 +469,9 @@ export default function QASection() {
.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">
@@ -121,13 +480,23 @@ export default function QASection() {
</h2>
{loading ? (
<p className="qa-no-results">Loading questions</p>
<p className="qa-no-results">Loading questions...</p>
) : 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
@@ -137,7 +506,7 @@ export default function QASection() {
>
All
</button>
{topics.map(topic => (
{topicCounts.map(({ topic }) => (
<button
type="button"
key={topic}
@@ -148,108 +517,244 @@ export default function QASection() {
</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 questions…"
placeholder="Search naturally (example: how to stay consistent in Bible reading)"
value={searchQuery}
onChange={e => handleSearch(e.target.value)}
/>
</label>
</div>
{(searchQuery || selectedTopic) && (
<div className="qa-filter-actions">
<button type="button" className="qa-clear-btn" onClick={clearFilters}>Clear filters</button>
</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">
<p>No matching questions found. <a href="#contact">Submit your question</a></p>
<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-cards">
{pagedQuestions.map(question => {
const relatedQuestions = getRelatedQuestions(question)
return (
<div key={question.id} className="qa-card-scene">
<div
className={`qa-card-inner ${expanded[question.id] ? 'flipped' : ''}`}
onClick={() => toggleExpanded(question.id)}
role="button"
tabIndex={0}
aria-expanded={!!expanded[question.id]}
aria-label={question.question}
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') toggleExpanded(question.id)
}}
>
<div className="qa-card-face qa-card-front">
<span className="qa-face-label">Q</span>
<p className="qa-question-text">{question.question}</p>
<span className="qa-flip-hint">{expanded[question.id] ? '▲' : '▼'}</span>
</div>
<div className="qa-card-face qa-card-back">
<span className="qa-face-label">A</span>
<div className="qa-answer-text">{renderTextWithLinks(question.answer)}</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={e => {
e.stopPropagation()
handleTopic(null)
handleSearch(related.question)
setExpanded({ [related.id]: true })
}}
>
{related.question}
</button>
))}
</div>
</div>
)}
<p style={{ margin: '0.75rem 0 0', fontSize: '0.8rem', color: '#a89060', fontStyle: 'italic' }}>
Answered by Nate
</p>
</div>
</div>
</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>
{totalPages > 1 && (
<div className="qa-pagination">
<div className="qa-layout">
<aside className="qa-sidebar" aria-label="Question topics">
<p className="qa-sidebar-label">Browse by topic</p>
<button
className="qa-page-btn"
onClick={() => setPage(p => p - 1)}
disabled={page === 0}
aria-label="Previous page"
type="button"
className={`qa-topic-btn qa-topic-btn--full${selectedTopic === null ? ' qa-topic-btn--active' : ''}`}
onClick={() => handleTopic(null)}
>
Prev
</button>
<span className="qa-page-info">
{page + 1} / {totalPages}
</span>
<button
className="qa-page-btn"
onClick={() => setPage(p => p + 1)}
disabled={page >= totalPages - 1}
aria-label="Next page"
>
Next
<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">
<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 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' : ''}`}>
<div className="qa-question-header">
<span className="qa-face-label">Q</span>
<span className="qa-question-main">
<span className="qa-question-text">{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>}
</span>
</span>
</div>
<div className="qa-card-actions">
<button
type="button"
aria-label="Share on X"
className="qa-social-btn qa-social-btn--x"
onClick={() => shareToX(question)}
>
<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={() => shareToFacebook(question.id)}
>
<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)}>
{copiedQuestionId === question.id ? '✓ Copied' : 'Copy link'}
</button>
</div>
<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>
</>
)}
</>