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
+364 -86
View File
@@ -3268,21 +3268,73 @@
/* ── Q&A Section ── */
.section-qa {
background: rgba(201, 168, 76, 0.05);
border-top: 1px solid rgba(201, 168, 76, 0.15);
background:
radial-gradient(90% 120% at 100% 0%, rgba(201, 168, 76, 0.12) 0%, rgba(201, 168, 76, 0.02) 50%, rgba(16, 15, 12, 0) 100%),
linear-gradient(180deg, rgba(20, 18, 14, 0.96), rgba(13, 12, 10, 0.97));
border-top: 1px solid rgba(201, 168, 76, 0.2);
}
.qa-filters {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 2rem;
gap: 0.85rem;
margin-bottom: 1rem;
}
.qa-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 0.7rem;
}
.qa-view-toggle {
border: 1px solid rgba(201, 168, 76, 0.34);
background: rgba(201, 168, 76, 0.05);
color: #cfba8d;
border-radius: 999px;
padding: 0.3rem 0.72rem;
font-size: 0.76rem;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
}
.qa-view-toggle--active {
background: rgba(201, 168, 76, 0.16);
color: #f4ddb0;
}
.qa-topics {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.15rem;
}
.qa-tags {
display: flex;
flex-wrap: nowrap;
gap: 0.45rem;
overflow-x: auto;
padding-bottom: 0.1rem;
}
.qa-tag-btn {
border: 1px solid rgba(201, 168, 76, 0.26);
background: rgba(201, 168, 76, 0.04);
color: #bfa982;
border-radius: 999px;
padding: 0.28rem 0.62rem;
font-size: 0.75rem;
cursor: pointer;
white-space: nowrap;
}
.qa-tag-btn--active {
background: rgba(201, 168, 76, 0.18);
color: #f0d9ab;
border-color: rgba(201, 168, 76, 0.5);
}
.qa-topics::-webkit-scrollbar {
@@ -3297,11 +3349,11 @@
.qa-topic-btn {
background: none;
border: 1px solid rgba(201, 168, 76, 0.35);
color: var(--brand-muted);
padding: 0.35rem 0.9rem;
border-radius: 2rem;
color: #cab483;
padding: 0.42rem 0.92rem;
border-radius: 999px;
cursor: pointer;
font-size: 0.875rem;
font-size: 0.85rem;
font-family: 'Barlow', sans-serif;
transition: background 0.18s, border-color 0.18s, color 0.18s;
}
@@ -3313,16 +3365,22 @@
}
.qa-topic-btn--active {
background: rgba(201, 168, 76, 0.18);
background: rgba(201, 168, 76, 0.2);
border-color: var(--brand-gold);
color: var(--brand-gold);
color: #f2ddb0;
font-weight: 600;
}
.qa-search {
.qa-topic-btn--full {
width: 100%;
display: flex;
gap: 1rem;
align-items: flex-end;
justify-content: space-between;
align-items: center;
border-radius: 0.6rem;
}
.qa-search {
display: block;
}
.qa-search label {
@@ -3345,18 +3403,20 @@
}
.qa-search input {
padding: 0.75rem 1rem;
background: rgba(30, 30, 30, 0.8);
border: 1px solid rgba(201, 168, 76, 0.3);
color: var(--brand-warm-white);
width: 100%;
padding: 0.78rem 1rem;
background: rgba(22, 21, 17, 0.95);
border: 1px solid rgba(201, 168, 76, 0.33);
color: #f7f0df;
font-family: 'Barlow', sans-serif;
border-radius: 0.375rem;
transition: border-color 0.2s ease;
border-radius: 0.55rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.qa-search input:focus {
outline: none;
border-color: var(--brand-gold);
box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.18);
}
.qa-filter-actions {
@@ -3364,6 +3424,28 @@
justify-content: flex-end;
}
.qa-filter-chips {
margin: 0 0 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.qa-chip {
border: 1px solid rgba(201, 168, 76, 0.26);
background: rgba(201, 168, 76, 0.06);
color: #d2bf95;
border-radius: 999px;
padding: 0.24rem 0.6rem;
font-size: 0.74rem;
cursor: pointer;
}
.qa-chip--clear {
color: #ead6ac;
border-color: rgba(201, 168, 76, 0.5);
}
.qa-clear-btn {
background: transparent;
border: 1px solid rgba(201, 168, 76, 0.38);
@@ -3381,14 +3463,72 @@
.qa-cards {
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.qa-meta-row {
margin: 0 0 0.9rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
/* ── Accordion card ── */
.qa-result-count {
margin: 0;
color: #d7c59d;
font-size: 0.92rem;
}
.qa-sort-select {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.82rem;
color: #b8a37a;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.qa-sort-select select {
border: 1px solid rgba(201, 168, 76, 0.35);
background: rgba(24, 22, 18, 0.95);
color: #f1e4c2;
border-radius: 0.45rem;
padding: 0.33rem 0.58rem;
}
.qa-layout {
display: grid;
grid-template-columns: minmax(210px, 250px) minmax(0, 1fr);
gap: 1rem;
align-items: flex-start;
}
.qa-sidebar {
border: 1px solid rgba(201, 168, 76, 0.26);
background: linear-gradient(180deg, rgba(39, 34, 24, 0.76), rgba(25, 22, 17, 0.9));
border-radius: 0.75rem;
padding: 0.8rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
position: sticky;
top: 94px;
}
.qa-sidebar-label {
margin: 0 0 0.1rem;
color: #ad9971;
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.qa-card-scene {
border: 1px solid rgba(201, 168, 76, 0.25);
border-radius: 0.5rem;
background: rgba(35, 30, 20, 0.85);
border-radius: 0.68rem;
background: linear-gradient(180deg, rgba(32, 28, 20, 0.96), rgba(20, 19, 15, 0.98));
transition: border-color 0.2s ease;
overflow: hidden;
}
@@ -3397,56 +3537,87 @@
border-color: rgba(201, 168, 76, 0.55);
}
.qa-card-inner {
.qa-card-scene--focused {
border-color: rgba(201, 168, 76, 0.72);
box-shadow: 0 0 0 1px rgba(201, 168, 76, 0.24);
}
.qa-question-header {
width: 100%;
cursor: pointer;
display: flex;
align-items: flex-start;
gap: 0.8rem;
padding: 1rem 1rem 0.9rem;
}
.qa-card-face {
padding: 1.25rem 1.5rem;
}
.qa-card-front {
display: flex;
align-items: center;
gap: 1rem;
user-select: none;
padding: 0.25rem 1rem 1rem;
}
.qa-card-back {
border-top: 1px solid rgba(201, 168, 76, 0.2);
background: rgba(20, 28, 20, 0.7);
display: none;
}
.qa-card-inner.flipped .qa-card-back {
display: block;
border-top: 1px solid rgba(201, 168, 76, 0.16);
background: rgba(17, 24, 17, 0.58);
display: grid;
grid-template-columns: auto 1fr;
column-gap: 0.8rem;
align-items: flex-start;
text-align: left;
}
.qa-face-label {
font-size: 1.3rem;
font-size: 1.15rem;
font-weight: 700;
color: var(--brand-gold);
flex-shrink: 0;
width: 1.75rem;
flex: 0 0 1.4rem;
}
.qa-question-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.42rem;
}
.qa-question-text {
margin: 0;
font-size: 1.14rem;
line-height: 1.6;
color: var(--brand-warm-white);
font-size: 1.06rem;
line-height: 1.5;
color: #f6efe0;
font-weight: 600;
flex: 1;
}
.qa-highlight {
background: rgba(201, 168, 76, 0.26);
color: #f7e9c8;
padding: 0.02em 0.18em;
border-radius: 0.24em;
}
.qa-question-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
color: #bca67f;
font-size: 0.78rem;
}
.qa-topic-pill {
border: 1px solid rgba(201, 168, 76, 0.35);
border-radius: 999px;
padding: 0.16rem 0.5rem;
color: #e8d0a0;
}
.qa-answer-text {
margin: 0;
font-size: 1.12rem;
line-height: 1.75;
color: var(--brand-warm-white);
font-size: 1.01rem;
line-height: 1.58;
color: #efe7d4;
opacity: 0.92;
white-space: pre-wrap;
text-align: left;
justify-self: stretch;
}
.qa-answer-text a {
@@ -3459,12 +3630,76 @@
color: #f0ead8;
}
.qa-flip-hint {
font-size: 0.75rem;
color: var(--brand-muted);
margin-left: auto;
flex-shrink: 0;
font-style: italic;
.qa-read-more-btn {
margin-top: 0.55rem;
border: 1px solid rgba(201, 168, 76, 0.35);
background: rgba(201, 168, 76, 0.07);
color: #d8c395;
border-radius: 999px;
padding: 0.2rem 0.58rem;
font-size: 0.74rem;
cursor: pointer;
}
.qa-card-actions {
display: flex;
align-items: center;
gap: 0.45rem;
justify-content: flex-end;
padding: 0 1rem 0.65rem;
}
.qa-social-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border-radius: 999px;
padding: 0.28rem 0.62rem;
font-size: 0.73rem;
font-family: inherit;
letter-spacing: 0.04em;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.qa-social-btn--x {
background: rgba(255,255,255,0.06);
border-color: rgba(255,255,255,0.15);
color: #e7e7e7;
}
.qa-social-btn--x:hover {
background: rgba(255,255,255,0.12);
border-color: rgba(255,255,255,0.28);
}
.qa-social-btn--fb {
background: rgba(24,119,242,0.12);
border-color: rgba(24,119,242,0.32);
color: #7eaaff;
}
.qa-social-btn--fb:hover {
background: rgba(24,119,242,0.22);
border-color: rgba(24,119,242,0.5);
}
.qa-share-btn {
border: 1px solid rgba(201, 168, 76, 0.35);
background: rgba(201, 168, 76, 0.06);
color: #d6be8f;
border-radius: 999px;
padding: 0.28rem 0.66rem;
font-size: 0.73rem;
font-family: inherit;
text-transform: uppercase;
letter-spacing: 0.06em;
cursor: pointer;
}
.qa-share-btn:hover {
background: rgba(201, 168, 76, 0.14);
}
.qa-no-results {
@@ -3473,6 +3708,26 @@
color: var(--brand-muted);
}
.qa-no-results--smart {
text-align: left;
border: 1px solid rgba(201, 168, 76, 0.24);
border-radius: 0.7rem;
background: rgba(23, 22, 17, 0.82);
}
.qa-no-results--smart p {
margin: 0 0 0.6rem;
}
.qa-inline-link {
border: 0;
background: transparent;
color: #e7cf9c;
text-decoration: underline;
cursor: pointer;
padding: 0;
}
.qa-pagination {
display: flex;
align-items: center;
@@ -3509,18 +3764,23 @@
text-align: center;
}
.qa-answer-byline {
grid-column: 2;
margin: 0.55rem 0 0;
font-size: 0.8rem;
color: #a89060;
font-style: italic;
text-align: left;
}
@media (max-width: 768px) {
.qa-filters {
gap: 0.65rem;
margin-bottom: 1.25rem;
margin-bottom: 1rem;
}
.qa-topics {
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 0.2rem;
-webkit-overflow-scrolling: touch;
gap: 0.4rem;
}
.qa-topic-btn {
@@ -3535,28 +3795,43 @@
font-size: 0.95rem;
}
.qa-card-face {
padding: 1rem 0.9rem;
}
.qa-card-front {
.qa-meta-row {
flex-direction: column;
align-items: flex-start;
gap: 0.65rem;
gap: 0.5rem;
}
.qa-face-label {
width: 1.2rem;
font-size: 1.1rem;
line-height: 1;
.qa-layout {
grid-template-columns: 1fr;
}
.qa-sidebar {
position: static;
padding: 0.65rem;
max-height: 220px;
overflow-y: auto;
}
.qa-question-header {
padding: 0.86rem 0.82rem;
gap: 0.62rem;
}
.qa-card-face {
padding: 0.15rem 0.82rem 0.86rem;
}
.qa-card-actions {
padding: 0 0.82rem 0.62rem;
}
.qa-question-text {
font-size: 1rem;
line-height: 1.5;
font-size: 0.97rem;
line-height: 1.45;
}
.qa-answer-text {
font-size: 0.98rem;
font-size: 0.95rem;
line-height: 1.6;
}
@@ -4321,15 +4596,17 @@
/* Q&A related question buttons */
.qa-related-wrap {
margin-top: 1rem;
border-top: 1px solid rgba(201, 168, 76, 0.18);
padding-top: 0.85rem;
margin-top: 0.7rem;
border-top: 1px solid rgba(201, 168, 76, 0.12);
padding-top: 0.6rem;
grid-column: 2;
text-align: left;
}
.qa-related-label {
margin: 0 0 0.5rem;
color: #b9a783;
font-size: 0.88rem;
color: #a9956c;
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
@@ -4338,20 +4615,21 @@
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
justify-content: flex-start;
}
.qa-related-btn {
border: 1px solid rgba(201, 168, 76, 0.35);
background: rgba(201, 168, 76, 0.08);
color: #d9be86;
border: 1px solid rgba(201, 168, 76, 0.22);
background: rgba(201, 168, 76, 0.03);
color: #bca57b;
border-radius: 999px;
padding: 0.3rem 0.75rem;
font-size: 0.82rem;
padding: 0.22rem 0.62rem;
font-size: 0.74rem;
cursor: pointer;
}
.qa-related-btn:hover {
background: rgba(201, 168, 76, 0.16);
background: rgba(201, 168, 76, 0.09);
}
@media (max-width: 900px) {
+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>
</>
)}
</>