import { useState, useEffect, useRef } from 'react' import { Link, Routes, Route, useNavigate } from 'react-router-dom' import AdminPage from './AdminPage' import './App.css' const SPOTIFY_SHOW_URL = 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl' const SPOTIFY_EMBED_URL = 'https://open.spotify.com/embed/show/0Gq1TzoJOdReSZ1gYQi8Xl?utm_source=generator&theme=0' const SPOTIFY_CREATOR_URL = 'https://creators.spotify.com/pod/profile/nmemmert/' const APPLE_PODCASTS_URL = 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate' const YOUTUBE_URL = 'https://www.youtube.com/@blackzebraem5558' const AMAZON_MUSIC_URL = 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate' const FACEBOOK_URL = 'https://facebook.com/versebyversewithnate' const CONSENT_KEY = 'vbn_analytics_consent_choice' function FacebookIcon() { return ( ) } export interface CustomLink { id: string label: string url: string placement: 'platforms' | 'footer' | 'resources' } export interface CustomBlock { id: string heading: string body: string } export interface SiteContent { eyebrow: string heroTagline: string aboutShowHeading: string aboutShowP1: string aboutShowP2: string aboutNate: string seriesLabel: string seriesTitle: string seriesDescription: string studyGuideTitle: string studyGuideDescription: string studyGuideUrl: string shareHeading: string shareP: string customLinks: CustomLink[] customBlocks: CustomBlock[] } export const DEFAULTS: SiteContent = { eyebrow: 'A Journey Through Scripture', heroTagline: "Exploring God's Word one verse at a time", aboutShowHeading: 'Depth. Clarity. Application.', aboutShowP1: 'Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.', aboutShowP2: "Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.", aboutNate: 'Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He created Verse by Verse to share the joy of deep Scripture study in a format anyone can follow along with — no seminary required.', seriesLabel: 'Now Playing', seriesTitle: 'Study of Titus: Sound Doctrine', seriesDescription: "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.", studyGuideTitle: 'Companion Study Guide', studyGuideDescription: 'Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.', studyGuideUrl: 'https://a.co/d/01sG2tOJ', shareHeading: 'Help one more person hear the Word this week.', shareP: 'Scan the QR code or text the show link to a friend who needs encouragement today.', customLinks: [], customBlocks: [], } function SpotifyIcon() { return ( ) } function YouTubeIcon() { return ( ) } function AmazonMusicIcon() { return ( ) } interface PublicQuestion { id: string firstName: string question: string answer: string } // ── Chatbot ──────────────────────────────────────────────────────────────── interface ChatbotEntry { id: string type: 'qa' | 'topic' | 'episode' title: string content: string keywords: string[] sourceLabel?: string priority?: boolean createdAt?: string updatedAt?: string } interface ChatMessage { role: 'bot' | 'user' text: string suggestions?: ChatSuggestion[] sources?: string[] } interface ChatSuggestion { label: string prompt?: string response?: ChatResponse } interface ChatResponse { text: string suggestions: ChatSuggestion[] sources?: string[] } type ResponseStyle = 'brief' | 'balanced' | 'deep' const STOP_WORDS = new Set([ 'a','an','the','is','are','was','were','be','been','being','have','has','had','do','does','did', 'will','would','could','should','may','might','shall','can','i','you','he','she','it','we','they', 'me','him','her','us','them','my','your','his','its','our','their','this','that','these','those', 'and','but','or','nor','so','yet','for','of','in','on','at','to','from','with','by','about', 'what','how','why','when','where','who','which','if','then','than','as','do','just','not', ]) const TOKEN_ALIASES: Record = { bible: ['translation', 'version', 'scripture', 'bsb', 'berean'], translation: ['version', 'bsb', 'berean', 'bible'], version: ['translation', 'bsb', 'berean', 'bible'], elders: ['elder', 'elders', 'leadership', 'leaders', 'overseer', 'overseers', 'pastor'], elder: ['elders', 'leadership', 'overseer', 'pastor'], leadership: ['elders', 'elder', 'overseer', 'overseers', 'leaders'], grace: ['salvation', 'saved', 'godliness', 'mercy'], salvation: ['saved', 'grace', 'mercy', 'gospel'], saved: ['salvation', 'grace', 'mercy', 'gospel'], gospel: ['grace', 'salvation', 'jesus', 'faith', 'good news'], hope: ['blessed hope', 'appearing', 'return', 'coming'], appearing: ['return', 'coming', 'hope'], return: ['appearing', 'coming', 'hope'], paul: ['saul', 'apostle paul'], titus: ['titus 1', 'titus 2', 'titus 3'], quiet: ['prayer', 'devotional', 'reading'], devotional: ['quiet', 'prayer', 'reading'], family: ['skeptic', 'skeptical', 'gospel', 'faith'], politics: ['public life', 'authorities', 'rulers', 'government'], public: ['politics', 'government', 'authorities'], } interface ParsedVerseRef { book: string chapter: number startVerse: number endVerse: number } function tokenize(text: string): string[] { return text.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(w => w.length > 2 && !STOP_WORDS.has(w)) } function extractVerseRefs(text: string): string[] { const matches = text.match(/\b(?:[1-3]\s*)?[a-z]+\s+\d+:\d+(?:\s*[-–]\s*\d+)?\b/gi) ?? [] return [...new Set(matches.map(normalizeVerseRef))] } function normalizeVerseRef(ref: string): string { return ref .toLowerCase() .replace(/[–—]/g, '-') .replace(/\s+/g, ' ') .replace(/\s*-\s*/g, '-') .trim() } function parseVerseRef(ref: string): ParsedVerseRef | null { const match = normalizeVerseRef(ref).match(/^((?:[1-3]\s*)?[a-z]+)\s+(\d+):(\d+)(?:-(\d+))?$/) if (!match) return null const [, book, chapter, startVerse, endVerse] = match return { book: book.replace(/\s+/g, ' ').trim(), chapter: Number(chapter), startVerse: Number(startVerse), endVerse: Number(endVerse ?? startVerse), } } function expandTokens(tokens: string[]): string[] { const expanded = new Set(tokens) for (const token of tokens) { const aliases = TOKEN_ALIASES[token] ?? [] for (const alias of aliases) expanded.add(alias) } return [...expanded] } function getSourceLabel(entry: ChatbotEntry): string { if (entry.sourceLabel && entry.sourceLabel.trim()) return entry.sourceLabel.trim() return entry.title } function isBibleVersionQuery(query: string, queryTokens: string[]): boolean { const q = query.toLowerCase() const hasBibleWord = ['bible', 'translation', 'version'].some(k => q.includes(k) || queryTokens.includes(k)) const hasIntentWord = ['use', 'recommend', 'podcast', 'read', 'study'].some(k => q.includes(k) || queryTokens.includes(k)) return hasBibleWord && hasIntentWord } function hasBsbSignal(entry: ChatbotEntry): boolean { const haystack = `${entry.title} ${entry.content} ${entry.keywords.join(' ')}`.toLowerCase() return haystack.includes('berean standard bible') || haystack.includes(' bsb ') } function normalizeExcerpt(text: string): string { return text.toLowerCase().replace(/\s+/g, ' ').trim() } function hasMeaningfulExtraDetail(shortAnswer: string, fullNotes: string): boolean { const normalizedShort = normalizeExcerpt(shortAnswer) const normalizedNotes = normalizeExcerpt(fullNotes) if (!normalizedNotes) return false if (normalizedNotes === normalizedShort) return false if (normalizedNotes.includes(normalizedShort) && normalizedNotes.length - normalizedShort.length < 120) return false return true } type ChatIntent = 'verse-summary' | 'episode-lookup' | 'application' | 'definition' | 'practice' | 'overview' | 'identity' | 'follow-up' | 'general' interface SearchChunk { id: string entryId: string entryType: ChatbotEntry['type'] title: string sourceLabel: string content: string keywords: string[] verseRefs: string[] episodeNumber: number | null topicTags: string[] kind: 'summary' | 'detail' } interface ChatContextState { turnCount: number lastPrompt: string lastIntent: ChatIntent | null lastSubject: string lastVerseRefs: string[] lastEntryIds: string[] lastSourceLabels: string[] lastTopics: string[] lastChunks: SearchChunk[] recentPrompts: string[] recentSubjects: string[] recentEntryIds: string[] } interface IntentAnalysis { type: ChatIntent rawQuery: string searchQuery: string queryTokens: string[] literalTerms: string[] subject: string excludedSubject: string isCorrection: boolean verseRefs: string[] episodeNumber: number | null isFollowUp: boolean } type ScoredChunk = { chunk: SearchChunk; score: number } const EMPTY_CHAT_CONTEXT: ChatContextState = { turnCount: 0, lastPrompt: '', lastIntent: null, lastSubject: '', lastVerseRefs: [], lastEntryIds: [], lastSourceLabels: [], lastTopics: [], lastChunks: [], recentPrompts: [], recentSubjects: [], recentEntryIds: [], } function toTitleCaseLabel(text: string): string { return text .split(' ') .map(part => part ? part.charAt(0).toUpperCase() + part.slice(1) : part) .join(' ') } function buildSourceCitations(chunks: SearchChunk[]): string[] { return [...new Set(chunks.slice(0, 3).map(chunk => { const verse = chunk.verseRefs[0] ? ` (${formatVerseRef(chunk.verseRefs[0])})` : '' return `${chunk.sourceLabel}${verse}` }))] } function formatResponseText(response: ChatResponse): string { if (!response.sources || response.sources.length === 0) return response.text return `${response.text}\n\nSources: ${response.sources.join(' | ')}` } function sanitizeSubject(value: string): string { return value .toLowerCase() .replace(/[^a-z\s'-]/g, ' ') .replace(/\s+/g, ' ') .trim() } function extractIdentitySubject(normalizedQuery: string): string { const match = normalizedQuery.match(/\b(?:who\s+(?:is|was)|tell me about)\s+([a-z][a-z\s'-]{1,40})$/i) if (!match) return '' const cleaned = sanitizeSubject(match[1]) const withoutArticles = cleaned.replace(/^(a|an|the)\s+/, '').trim() return withoutArticles.split(' ').slice(0, 3).join(' ').trim() } function extractCorrectionSubjects(normalizedQuery: string): { rejected: string; expected: string } | null { const match = normalizedQuery.match(/\bthat(?:'s| is)\s+([a-z][a-z\s'-]{0,24})\s+not\s+([a-z][a-z\s'-]{0,24})\b/i) if (!match) return null const rejected = sanitizeSubject(match[1]).split(' ').slice(0, 3).join(' ').trim() const expected = sanitizeSubject(match[2]).split(' ').slice(0, 3).join(' ').trim() if (!rejected || !expected) return null return { rejected, expected } } function escapeRegex(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } function extractEpisodeNumber(text: string): number | null { const match = text.match(/\bepisode\s+(\d+)\b/i) if (!match) return null return Number(match[1]) } function formatVerseRef(ref: string): string { const parsed = parseVerseRef(ref) if (!parsed) return ref const book = parsed.book .split(' ') .map(part => /^\d+$/.test(part) ? part : part.charAt(0).toUpperCase() + part.slice(1)) .join(' ') return parsed.startVerse === parsed.endVerse ? `${book} ${parsed.chapter}:${parsed.startVerse}` : `${book} ${parsed.chapter}:${parsed.startVerse}-${parsed.endVerse}` } function extractSentences(text: string): string[] { return text .split(/(?<=[.!?])\s+(?=[A-Z])/) .map(sentence => sentence.trim()) .filter(Boolean) } function buildRelevantExcerpt(text: string, queryTokens: string[], maxLength: number): string { const expandedTokens = expandTokens(queryTokens) const sentences = extractSentences(text).filter(sentence => sentence.length > 25) if (sentences.length === 0) return text.trim().slice(0, maxLength) const rankedSentences = sentences.map(sentence => ({ sentence, score: tokenize(sentence).filter(token => expandedTokens.some(queryToken => token.includes(queryToken) || queryToken.includes(token))).length, })) const selected = rankedSentences.some(item => item.score > 0) ? rankedSentences.sort((a, b) => b.score - a.score).slice(0, 2).map(item => item.sentence) : rankedSentences.slice(0, 1).map(item => item.sentence) return selected.join(' ').slice(0, maxLength) } function buildTopicTags(entry: ChatbotEntry): string[] { return [...new Set([ ...tokenize(entry.title), ...entry.keywords.flatMap(tokenize), ])].slice(0, 10) } function splitLargeBlock(block: string): string[] { if (block.length <= 550) return [block] const sentences = extractSentences(block) if (sentences.length <= 1) return [block] const chunks: string[] = [] let current = '' for (const sentence of sentences) { const next = current ? `${current} ${sentence}` : sentence if (next.length > 520 && current) { chunks.push(current.trim()) current = sentence continue } current = next } if (current.trim()) chunks.push(current.trim()) return chunks } function injectSemanticBreaks(content: string): string { return content .replace(/([.!?])\s+(?=(WHO|WHAT|WHY|WHERE|WHEN|HOW)\s+[A-Z][A-Z\s'’:-]{4,}\?)/g, '$1\n\n') .replace(/([.!?])\s+(?=(DISCUSSION QUESTIONS|CLOSING|THE \w+|VERSE \d+))/g, '$1\n\n') .replace(/\s+(?=(DISCUSSION QUESTIONS|CLOSING)\b)/g, '\n\n') } function splitContentIntoSections(content: string): string[] { const normalizedContent = injectSemanticBreaks(content) const rawBlocks = normalizedContent .split(/\n\s*\n/) .map(block => block.replace(/\s+/g, ' ').trim()) .filter(Boolean) const mergedBlocks: string[] = [] for (let index = 0; index < rawBlocks.length; index += 1) { const block = rawBlocks[index] const isHeading = /^[A-Z0-9'’:()\-\s]{5,90}$/.test(block) if (isHeading && rawBlocks[index + 1]) { mergedBlocks.push(`${block}. ${rawBlocks[index + 1]}`) index += 1 continue } mergedBlocks.push(block) } return mergedBlocks.flatMap(splitLargeBlock) } function buildSearchChunks(entries: ChatbotEntry[]): SearchChunk[] { return entries.flatMap(entry => { const sourceLabel = getSourceLabel(entry) const verseRefs = extractVerseRefs(`${entry.title} ${entry.content} ${entry.keywords.join(' ')}`) const topicTags = buildTopicTags(entry) const episodeNumber = extractEpisodeNumber(`${entry.title} ${sourceLabel}`) const sections = splitContentIntoSections(entry.content) const chunks: SearchChunk[] = [] const summaryContent = buildRelevantExcerpt(entry.content, topicTags, entry.type === 'episode' ? 340 : 260) chunks.push({ id: `${entry.id}-summary`, entryId: entry.id, entryType: entry.type, title: entry.title, sourceLabel, content: summaryContent, keywords: entry.keywords, verseRefs, episodeNumber, topicTags, kind: 'summary', }) if (sections.length > 1 || entry.content.length > summaryContent.length + 150) { sections.forEach((section, index) => { if (normalizeExcerpt(section) === normalizeExcerpt(summaryContent)) return chunks.push({ id: `${entry.id}-detail-${index}`, entryId: entry.id, entryType: entry.type, title: entry.title, sourceLabel, content: section, keywords: entry.keywords, verseRefs: extractVerseRefs(`${entry.title} ${section} ${entry.keywords.join(' ')}`), episodeNumber, topicTags, kind: 'detail', }) }) } return chunks }) } function buildCharacterTrigrams(text: string): Set { const normalized = normalizeExcerpt(text) const trigrams = new Set() if (normalized.length < 3) return trigrams for (let index = 0; index <= normalized.length - 3; index += 1) { trigrams.add(normalized.slice(index, index + 3)) } return trigrams } function getSemanticSimilarity(left: string, right: string): number { const leftSet = buildCharacterTrigrams(left) const rightSet = buildCharacterTrigrams(right) if (leftSet.size === 0 || rightSet.size === 0) return 0 let intersection = 0 for (const gram of leftSet) { if (rightSet.has(gram)) intersection += 1 } const union = leftSet.size + rightSet.size - intersection return union === 0 ? 0 : intersection / union } function isContextualFollowUp(query: string): boolean { const normalized = query.trim().toLowerCase() return /^(what about|how about|and\b|also\b|that\b|this\b|those\b|these\b|more\b|go deeper\b|expand\b|show me\b|next\b|same\b|what else\b|tell me more\b|his\b|him\b|he\b)/.test(normalized) } function normalizeQuestionShape(text: string): string { return text .toLowerCase() .replace(/\bwho\s+(is|was)\b/g, 'who') .replace(/\bwhat\s+(is|does)\b/g, 'what') .replace(/\s+/g, ' ') .trim() } function extractLiteralTerms(text: string): string[] { const cleaned = text .toLowerCase() .replace(/[–—]/g, '-') .replace(/[^a-z0-9:\-\s']/g, ' ') return [...new Set( cleaned .split(/\s+/) .map(term => term.trim()) .filter(term => term.length >= 2 && !STOP_WORDS.has(term)), )].slice(0, 20) } function normalizeSearchText(text: string): string { return text .toLowerCase() .replace(/[–—]/g, '-') .replace(/[^a-z0-9:\-\s']/g, ' ') .replace(/\s+/g, ' ') .trim() } function cleanSearchPhrase(text: string): string { return normalizeSearchText(text) .replace(/^(can you|please|show me|tell me|what is|what does|who is|who was)\s+/, '') .trim() } function getLiteralSearchScore(indexText: string, titleText: string, analysis: IntentAnalysis): number { const normalizedIndex = normalizeSearchText(indexText) const normalizedTitle = normalizeSearchText(titleText) let score = 0 const phrase = cleanSearchPhrase(analysis.searchQuery) if (phrase.length >= 6) { if (normalizedTitle.includes(phrase)) score += 20 else if (normalizedIndex.includes(phrase)) score += 14 } for (const term of analysis.literalTerms) { if (normalizedTitle.includes(term)) score += 4 else if (normalizedIndex.includes(term)) score += 2.25 } return score } type ConversationCue = 'none' | 'greeting' | 'gratitude' | 'affirmation' | 'deepen' function detectConversationCue(query: string): ConversationCue { const normalized = query.trim().toLowerCase() if (!normalized) return 'none' if (/^(hi|hey|hello|yo|good morning|good afternoon|good evening)\b/.test(normalized)) return 'greeting' if (/\b(thank you|thanks|appreciate it|that helps)\b/.test(normalized)) return 'gratitude' if (/^(yes|yep|yeah|ok|okay|sounds good|right|exactly)\b/.test(normalized)) return 'affirmation' if (/\b(go deeper|deeper|more detail|expand on that|tell me more|go on)\b/.test(normalized)) return 'deepen' return 'none' } function enrichQueryWithContext(query: string, context: ChatContextState): string { const normalized = query.trim().toLowerCase() if (!normalized || !context.lastSubject) return query if (normalized.includes(context.lastSubject)) return query const hasPronounReference = /\b(he|him|his)\b/.test(normalized) const isBridgeFollowUp = /^(what about|how about|and|also)\b/.test(normalized) if (hasPronounReference || isBridgeFollowUp) { return `${query} ${context.lastSubject}`.trim() } return query } function getExcerptMaxChars(style: ResponseStyle, kind: 'normal' | 'deep'): number { if (style === 'brief') return kind === 'deep' ? 260 : 170 if (style === 'deep') return kind === 'deep' ? 520 : 330 return kind === 'deep' ? 380 : 240 } function buildDeeperDiveReply(context: ChatContextState, style: ResponseStyle): ChatResponse | null { if (context.lastChunks.length === 0) return null const primary = context.lastChunks[0] const deeperChunk = context.lastChunks.find(chunk => chunk.kind === 'detail') ?? primary const tokens = tokenize(`${context.lastPrompt} ${context.lastSubject}`) const deeperExcerpt = buildRelevantExcerpt(deeperChunk.content, tokens, getExcerptMaxChars(style, 'deep')) const lead = primary.verseRefs[0] ? `Going deeper in ${formatVerseRef(primary.verseRefs[0])}:` : `Going deeper in ${primary.sourceLabel}:` const suggestions = buildSmartFollowUpPrompts( context.lastChunks, analyzeIntent(context.lastPrompt || primary.title, context), deeperExcerpt, ) return { text: `${lead}\n${deeperExcerpt}`, suggestions, sources: buildSourceCitations(context.lastChunks), } } function buildConversationOnlyReply(cue: ConversationCue, context: ChatContextState, style: ResponseStyle): ChatResponse | null { if (cue === 'none') return null if (cue === 'greeting') { return { text: `Glad you're here. Ask me about a person, passage, or episode and I'll walk it through with you step by step.`, suggestions: INLINE_CHAT_PROMPTS.slice(0, 3).map(prompt => ({ label: prompt, prompt })), } } if (cue === 'gratitude') { const suggestions = context.lastChunks.length > 0 ? buildSmartFollowUpPrompts(context.lastChunks, analyzeIntent(context.lastPrompt || 'go deeper', context), context.lastChunks[0].content) : [ { label: 'Give me a summary of Titus 3:4-7', prompt: 'Give me a summary of Titus 3:4-7' }, { label: 'What does Titus 1 teach about church leadership?', prompt: 'What does Titus 1 teach about church leadership?' }, ] return { text: `Anytime. Want to keep going on this thread or jump to another question?`, suggestions: suggestions.slice(0, 3), sources: context.lastChunks.length > 0 ? buildSourceCitations(context.lastChunks) : undefined, } } if (cue === 'affirmation' || cue === 'deepen') { return buildDeeperDiveReply(context, style) } return null } function analyzeIntent(query: string, context: ChatContextState): IntentAnalysis { const rawQuery = query.trim() const normalized = rawQuery.toLowerCase() const verseRefs = extractVerseRefs(rawQuery) const episodeNumber = extractEpisodeNumber(rawQuery) const wantsSummary = /\b(summary|summarize|overview|explain)\b/.test(normalized) const wantsEpisode = /\bepisode\b|\bpodcast\b|\blisten\b/.test(normalized) || episodeNumber !== null const wantsApplication = /\bapply\b|\bapplication\b|\btoday\b|\blive this out\b|\bpractically\b/.test(normalized) const wantsDefinition = /\bmean\b|\bmeaning\b|\bdefine\b|\bwhat is\b|\bwhat does\b/.test(normalized) const wantsIdentity = /\bwho is\b|\bwho was\b|\btell me about\b/.test(normalized) const wantsPractice = /\bhow can i\b|\bhow do i\b|\bhow should i\b|\bpray\b|\bpractice\b/.test(normalized) const correctionSubjects = extractCorrectionSubjects(normalized) const isFollowUp = isContextualFollowUp(rawQuery) || correctionSubjects !== null const explicitSubject = extractIdentitySubject(normalized) const subject = correctionSubjects?.expected || explicitSubject || (isFollowUp ? context.lastSubject : '') const excludedSubject = correctionSubjects?.rejected ?? '' const isCorrection = correctionSubjects !== null let type: ChatIntent = 'general' if (wantsEpisode) type = 'episode-lookup' else if (verseRefs.length > 0 && wantsSummary) type = 'verse-summary' else if (wantsIdentity || subject) type = 'identity' else if (wantsApplication) type = 'application' else if (wantsDefinition) type = 'definition' else if (wantsPractice) type = 'practice' else if (wantsSummary) type = 'overview' else if (isFollowUp) type = 'follow-up' let searchQuery = rawQuery if (type === 'identity' && subject) { searchQuery = `${searchQuery} ${subject}` } if (verseRefs.length === 0 && context.lastVerseRefs.length > 0 && isFollowUp) { searchQuery += ` ${context.lastVerseRefs[0]}` } if (episodeNumber === null && context.lastSourceLabels.length > 0 && isFollowUp) { searchQuery += ` ${context.lastSourceLabels[0]}` } if (context.lastTopics.length > 0 && isFollowUp) { searchQuery += ` ${context.lastTopics.slice(0, 2).join(' ')}` } if (context.recentSubjects.length > 0 && isFollowUp) { searchQuery += ` ${context.recentSubjects.slice(-2).join(' ')}` } if (context.recentPrompts.length > 0 && isFollowUp) { searchQuery += ` ${context.recentPrompts.slice(-1)[0]}` } return { type, rawQuery, searchQuery, queryTokens: tokenize(searchQuery), literalTerms: extractLiteralTerms(searchQuery), subject, excludedSubject, isCorrection, verseRefs, episodeNumber, isFollowUp, } } function getVerseMatchScoreForRefs(candidateRefs: string[], queryRefs: string[]): number { const parsedQueries = queryRefs.map(parseVerseRef).filter((ref): ref is ParsedVerseRef => ref !== null) if (parsedQueries.length === 0) return 0 const parsedCandidates = candidateRefs.map(parseVerseRef).filter((ref): ref is ParsedVerseRef => ref !== null) let score = 0 for (const queryRef of parsedQueries) { for (const candidateRef of parsedCandidates) { if (candidateRef.book !== queryRef.book) continue if ( candidateRef.chapter === queryRef.chapter && candidateRef.startVerse === queryRef.startVerse && candidateRef.endVerse === queryRef.endVerse ) { score = Math.max(score, 32) continue } if ( candidateRef.chapter === queryRef.chapter && candidateRef.startVerse <= queryRef.startVerse && candidateRef.endVerse >= queryRef.endVerse ) { score = Math.max(score, 24) continue } if ( candidateRef.chapter === queryRef.chapter && queryRef.startVerse <= candidateRef.startVerse && queryRef.endVerse >= candidateRef.endVerse ) { score = Math.max(score, 18) continue } if (candidateRef.chapter === queryRef.chapter) { score = Math.max(score, 9) continue } score = Math.max(score, 2) } } return score } function hasSharedVerseContext(candidateRefs: string[], contextRefs: string[]): boolean { return getVerseMatchScoreForRefs(candidateRefs, contextRefs) > 0 } function scoreChunkForAnalysis(chunk: SearchChunk, analysis: IntentAnalysis, context: ChatContextState): number { const expandedTokens = expandTokens(analysis.queryTokens) const titleTokens = tokenize(chunk.title) const indexTokens = [ ...tokenize(chunk.title), ...tokenize(chunk.content), ...chunk.keywords.flatMap(tokenize), ...chunk.topicTags, ...chunk.verseRefs.flatMap(tokenize), ] let score = 0 const normalizedQuery = normalizeExcerpt(analysis.searchQuery) const normalizedChunk = normalizeExcerpt(`${chunk.title} ${chunk.sourceLabel} ${chunk.content} ${chunk.keywords.join(' ')}`) const normalizedQuestionQuery = normalizeQuestionShape(analysis.searchQuery) const normalizedQuestionTitle = normalizeQuestionShape(chunk.title) const normalizedTitle = chunk.title.toLowerCase() const normalizedContent = chunk.content.toLowerCase() const fullTextIndex = `${chunk.title} ${chunk.sourceLabel} ${chunk.content} ${chunk.keywords.join(' ')} ${chunk.topicTags.join(' ')} ${chunk.verseRefs.join(' ')}` for (const token of expandedTokens) { if (titleTokens.some(titleToken => titleToken === token)) score += 5 else if (titleTokens.some(titleToken => titleToken.includes(token) || token.includes(titleToken))) score += 3 if (indexTokens.some(indexToken => indexToken === token)) score += 1.5 else if (indexTokens.some(indexToken => indexToken.includes(token) || token.includes(indexToken))) score += 0.75 } if (normalizedQuery.length > 8 && normalizedChunk.includes(normalizedQuery)) score += 18 if (normalizedQuestionQuery.length > 5 && normalizedQuestionTitle === normalizedQuestionQuery) score += 28 if (normalizedQuestionQuery.length > 5 && normalizedChunk.includes(normalizedQuestionQuery)) score += 12 score += getLiteralSearchScore(fullTextIndex, chunk.title, analysis) score += getVerseMatchScoreForRefs(chunk.verseRefs, analysis.verseRefs) score += getSemanticSimilarity(analysis.searchQuery, `${chunk.title} ${chunk.content}`) * 14 if (chunk.title.toLowerCase().trim() === analysis.rawQuery.toLowerCase().trim()) score += 40 if (analysis.episodeNumber !== null && chunk.episodeNumber === analysis.episodeNumber) score += 22 if (analysis.type === 'verse-summary') { if (chunk.kind === 'summary') score += 8 if (chunk.verseRefs.length > 0) score += 7 } if (analysis.type === 'episode-lookup') { if (chunk.entryType === 'episode') score += 12 if (chunk.episodeNumber !== null) score += 4 } if (analysis.type === 'application' && /\bapply\b|\bapplication\b|\btoday\b|\bdaily\b|\bcarry this week\b/i.test(chunk.content)) { score += 8 } if (analysis.type === 'definition' && /\bmeans\b|\bgreek\b|\bliterally\b|\bword\b/i.test(chunk.content)) { score += 8 } if (analysis.type === 'identity') { if (/^who\s+(is|was)\b/i.test(chunk.title)) score += 18 if (/\bclosest and most trusted co-workers\b|\btrue child in our common faith\b|\bcame to faith through paul's ministry\b/i.test(chunk.content)) { score += 14 } if (chunk.entryType === 'qa' || chunk.entryType === 'topic') score += 8 if (analysis.subject) { const subjectPattern = new RegExp(`\\b${escapeRegex(analysis.subject)}\\b`, 'i') const identityPattern = new RegExp(`\\b${escapeRegex(analysis.subject)}\\b.{0,30}\\b(is|was)\\b|\\b(is|was)\\b.{0,30}\\b${escapeRegex(analysis.subject)}\\b`, 'i') const titleIsIdentityQuestion = /^who\s+(is|was)\b/i.test(normalizedTitle) if (subjectPattern.test(normalizedTitle)) score += 22 if (subjectPattern.test(normalizedContent)) score += 12 if (identityPattern.test(normalizedContent) || identityPattern.test(normalizedTitle)) score += 14 if (!subjectPattern.test(normalizedTitle) && !subjectPattern.test(normalizedContent)) score -= 20 if (titleIsIdentityQuestion && !subjectPattern.test(normalizedTitle)) score -= 28 } if (analysis.excludedSubject) { const excludedPattern = new RegExp(`\\b${escapeRegex(analysis.excludedSubject)}\\b`, 'i') if (excludedPattern.test(normalizedTitle)) score -= 26 if (excludedPattern.test(normalizedContent)) score -= 12 } } if (analysis.type === 'practice' && /\bpray\b|\bdaily\b|\bconsistent\b|\bpractice\b|\bhow\b/i.test(chunk.content)) { score += 6 } if (analysis.isFollowUp) { if (context.lastEntryIds.includes(chunk.entryId)) score += 7 if (context.recentEntryIds.includes(chunk.entryId)) score += 4 if (hasSharedVerseContext(chunk.verseRefs, context.lastVerseRefs)) score += 6 if (chunk.topicTags.some(tag => context.lastTopics.includes(tag))) score += 4 } if (analysis.verseRefs.length > 0 && chunk.title.toLowerCase().startsWith('give me a summary of') && getVerseMatchScoreForRefs(chunk.verseRefs, analysis.verseRefs) === 0) { score -= 10 } return score } function getTopDistinctChunks(scored: ScoredChunk[], limit: number): SearchChunk[] { const seen = new Set() const chunks: SearchChunk[] = [] for (const item of scored) { if (seen.has(item.chunk.id)) continue seen.add(item.chunk.id) chunks.push(item.chunk) if (chunks.length >= limit) break } return chunks } function buildPromptForChunk(chunk: SearchChunk, analysis: IntentAnalysis): string { if (analysis.type === 'episode-lookup' || chunk.entryType === 'episode') { return `What does ${chunk.sourceLabel} teach?` } if (chunk.verseRefs[0]) { return `Give me a summary of ${formatVerseRef(chunk.verseRefs[0])}` } return chunk.title } function determineConfidence(scored: ScoredChunk[], analysis: IntentAnalysis): 'high' | 'medium' | 'low' { const top = scored[0]?.score ?? 0 const second = scored[1]?.score ?? 0 const margin = top - second const hasExactVerse = scored[0] ? getVerseMatchScoreForRefs(scored[0].chunk.verseRefs, analysis.verseRefs) >= 24 : false if (top >= 42 || (hasExactVerse && margin >= 7)) return 'high' if (top >= 24 && margin >= 4) return 'medium' return 'low' } function buildDetailedNotesReplyFromChunks(chunks: SearchChunk[]): string { const notes = chunks .slice(0, 3) .map(chunk => `From ${chunk.sourceLabel}:\n${chunk.content.trim()}`) .filter(Boolean) return `From Nate's notes:\n${notes.join('\n\n')}` } function buildLowConfidenceReply(scored: ScoredChunk[], analysis: IntentAnalysis): ChatResponse { const choices = getTopDistinctChunks(scored, 3) const topLabels = choices.slice(0, 2).map(chunk => chunk.sourceLabel) const text = topLabels.length === 2 ? `I want to be accurate, so I need one quick clarification. Did you mean "${topLabels[0]}" or "${topLabels[1]}"?` : `I want to be accurate, and I don't have enough confidence to answer yet. Pick the closest direction and I'll continue.` return { text, suggestions: choices.map(chunk => ({ label: chunk.sourceLabel, prompt: buildPromptForChunk(chunk, analysis), })), sources: buildSourceCitations(choices), } } function buildSmartFollowUpPrompts(chunks: SearchChunk[], analysis: IntentAnalysis, shortAnswer: string): ChatSuggestion[] { const prompts = new Set() const primaryChunk = chunks[0] const primaryVerse = primaryChunk?.verseRefs[0] if (primaryVerse && analysis.type !== 'verse-summary') { prompts.add(`Give me a summary of ${formatVerseRef(primaryVerse)}`) } if (primaryChunk?.episodeNumber !== null) { prompts.add(`What are the main takeaways from Episode ${primaryChunk.episodeNumber}?`) } if (primaryVerse) { const parsed = parseVerseRef(primaryVerse) if (parsed) { prompts.add(`How does ${parsed.book.charAt(0).toUpperCase() + parsed.book.slice(1)} ${parsed.chapter} connect to the rest of the chapter?`) } } if (analysis.type !== 'application') prompts.add('How should I apply this today?') if (analysis.type !== 'episode-lookup') prompts.add('Which episode should I listen to next on this topic?') const promptSuggestions = [...prompts] .filter(prompt => prompt.toLowerCase() !== analysis.rawQuery.toLowerCase()) .slice(0, 3) .map(prompt => ({ label: prompt, prompt })) const detailedNotesReply = buildDetailedNotesReplyFromChunks(chunks) const canOfferDetailedNotes = hasMeaningfulExtraDetail(shortAnswer, detailedNotesReply) if (!canOfferDetailedNotes) return promptSuggestions return [ { label: "Show me Nate's notes on this", response: { text: detailedNotesReply, suggestions: promptSuggestions, }, }, ...promptSuggestions, ].slice(0, 3) } function buildSmartFallbackReply(analysis: IntentAnalysis, context: ChatContextState): ChatResponse { const suggestions = new Set() if (analysis.verseRefs.length > 0) { suggestions.add(`Give me a summary of ${formatVerseRef(analysis.verseRefs[0])}`) } if (context.lastVerseRefs[0]) suggestions.add(`How does ${formatVerseRef(context.lastVerseRefs[0])} connect to the rest of the chapter?`) suggestions.add('What does Titus 1 teach about church leadership?') suggestions.add('What does grace train us to do?') suggestions.add('How should I apply this today?') return { text: `I don't have a strong enough match yet to answer that clearly. Try one of these more specific prompts and I'll narrow it down.`, suggestions: [...suggestions].slice(0, 3).map(prompt => ({ label: prompt, prompt })), } } function buildShortAnswerFromChunks(chunks: SearchChunk[], analysis: IntentAnalysis, style: ResponseStyle): string { const primaryChunk = chunks[0] if (!primaryChunk) return '' const baseMax = primaryChunk.entryType === 'episode' ? getExcerptMaxChars(style, 'deep') : getExcerptMaxChars(style, 'normal') const excerpt = buildRelevantExcerpt(primaryChunk.content, analysis.queryTokens, baseMax) const primaryVerse = primaryChunk.verseRefs[0] if (analysis.type === 'episode-lookup' && primaryChunk.episodeNumber !== null) { return `Episode ${primaryChunk.episodeNumber} is the closest match. ${excerpt}` } if (analysis.type === 'verse-summary' && primaryVerse) { return `${formatVerseRef(primaryVerse)} focuses on ${excerpt.charAt(0).toLowerCase() + excerpt.slice(1)}` } if (analysis.type === 'application') { return `Nate's main application is ${excerpt.charAt(0).toLowerCase() + excerpt.slice(1)}` } if (analysis.type === 'definition') { return `Nate explains it this way: ${excerpt}` } return excerpt } function synthesizeSmartReply(scored: ScoredChunk[], analysis: IntentAnalysis, context: ChatContextState, style: ResponseStyle): ChatResponse { if (scored.length === 0) return buildSmartFallbackReply(analysis, context) const confidence = determineConfidence(scored, analysis) if (confidence === 'low') return buildLowConfidenceReply(scored, analysis) const topChunks = getTopDistinctChunks(scored, confidence === 'high' ? 3 : 2) const shortAnswer = buildShortAnswerFromChunks(topChunks, analysis, style) const followUps = buildSmartFollowUpPrompts(topChunks, analysis, shortAnswer) const intro = analysis.isCorrection ? `Thanks for the correction.` : analysis.isFollowUp || context.turnCount > 0 ? `Staying with your thread:` : confidence === 'medium' ? `Closest match I found:` : `Short answer:` const text = `${intro}\n${shortAnswer}` return { text, suggestions: followUps, sources: buildSourceCitations(topChunks), } } function buildNextChatContext(query: string, analysis: IntentAnalysis, scored: ScoredChunk[], previousContext: ChatContextState): ChatContextState { const topChunks = getTopDistinctChunks(scored, 3) if (topChunks.length === 0) return EMPTY_CHAT_CONTEXT const inferredSubject = analysis.subject || extractIdentitySubject(query.toLowerCase()) || contextSubjectFromTitle(topChunks[0]?.title ?? '') return { turnCount: previousContext.turnCount + 1, lastPrompt: query, lastIntent: analysis.type, lastSubject: inferredSubject, lastVerseRefs: [...new Set(topChunks.flatMap(chunk => chunk.verseRefs))].slice(0, 3), lastEntryIds: [...new Set(topChunks.map(chunk => chunk.entryId))], lastSourceLabels: [...new Set(topChunks.map(chunk => chunk.sourceLabel))].slice(0, 3), lastTopics: [...new Set(topChunks.flatMap(chunk => chunk.topicTags))].slice(0, 6), lastChunks: topChunks, recentPrompts: [...previousContext.recentPrompts, query].slice(-5), recentSubjects: [...new Set([...previousContext.recentSubjects, inferredSubject].filter(Boolean))].slice(-5), recentEntryIds: [...new Set([...previousContext.recentEntryIds, ...topChunks.map(chunk => chunk.entryId)])].slice(-10), } } function contextSubjectFromTitle(title: string): string { const normalized = title.toLowerCase().trim() const subject = extractIdentitySubject(normalized) return subject || '' } function isNotesRequest(query: string): boolean { return /\bshow\b.*\bnotes\b|\bnate'?s notes\b|\bfull notes\b/i.test(query) } function isEpisodeFollowUpRequest(query: string): boolean { return /\bwhat episode\b|\bwhich episode\b|\bwhat was that episode\b/i.test(query) } function buildContextualEpisodeReply(context: ChatContextState): ChatResponse | null { const primaryChunk = context.lastChunks[0] if (!primaryChunk || primaryChunk.episodeNumber === null) return null return { text: `This comes from Episode ${primaryChunk.episodeNumber}: ${primaryChunk.sourceLabel}.`, suggestions: buildSmartFollowUpPrompts(context.lastChunks, analyzeIntent(primaryChunk.sourceLabel, context), primaryChunk.content), sources: buildSourceCitations(context.lastChunks), } } const SUGGESTED_PROMPTS = [ 'What Bible translation do you use in the podcast?', 'What does Titus 1 teach about church leadership?', 'How can I stay consistent with daily Bible reading?', 'How do I handle a difficult or confusing passage?', 'How can I share my faith with skeptical family?', 'Give me a summary of Titus 3:4-7', 'What episode talks about grace training us?', 'How do I submit a question to Nate?', ] const INLINE_CHAT_PROMPTS = SUGGESTED_PROMPTS.slice(0, 6) const BOT_NAME = 'The Mine' function openMinePopoutWindow() { const features = [ 'popup=yes', 'width=460', 'height=760', 'resizable=yes', 'scrollbars=yes', ].join(',') window.open('/the-mine', 'the-mine-window', features) } function closeMineWindow() { if (window.opener) { window.close() return } window.location.href = '/' } function ChatBot({ mode = 'embedded' }: { mode?: 'embedded' | 'standalone' }) { const isStandalone = mode === 'standalone' const [open, setOpen] = useState(false) const [messages, setMessages] = useState([ { role: 'bot', text: `Welcome to ${BOT_NAME}. Dig into the Word, nugget by nugget. Ask about the podcast, Bible study, or a passage Nate has covered — or pick a prompt below.` }, ]) const [input, setInput] = useState('') const [entries, setEntries] = useState([]) const [loaded, setLoaded] = useState(false) const [loading, setLoading] = useState(false) const [responseStyle, setResponseStyle] = useState('balanced') const [pendingPrompt, setPendingPrompt] = useState(null) const bottomRef = useRef(null) const lastEntriesSyncRef = useRef(0) const chunkIndexRef = useRef([]) const chatContextRef = useRef(EMPTY_CHAT_CONTEXT) const chatVisible = isStandalone || open const loadEntries = async (force = false) => { const now = Date.now() if (!force && loaded && now - lastEntriesSyncRef.current < 15000) return entries try { const response = await fetch('/api/chatbot-content', { cache: 'no-store' }) if (!response.ok) return entries const data = await response.json() const nextEntries = Array.isArray(data) ? data : [] setEntries(nextEntries) chunkIndexRef.current = buildSearchChunks(nextEntries) setLoaded(true) lastEntriesSyncRef.current = now return nextEntries } catch { return entries } } const openChat = () => { setOpen(true) void loadEntries(true) } useEffect(() => { if (!isStandalone) return setOpen(true) void loadEntries(true) // eslint-disable-next-line react-hooks/exhaustive-deps }, [isStandalone]) const askPrompt = (prompt: string) => { openChat() if (entries.length > 0) { void respond(prompt) } else { setPendingPrompt(prompt) } } useEffect(() => { if (pendingPrompt !== null && entries.length > 0) { const p = pendingPrompt setPendingPrompt(null) void respond(p) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [entries, pendingPrompt]) useEffect(() => { if (chatVisible) bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages, chatVisible]) useEffect(() => { if (!chatVisible) return undefined const intervalId = window.setInterval(() => { void loadEntries(true) }, 30000) return () => window.clearInterval(intervalId) }, [chatVisible]) const handleSuggestion = (suggestion: ChatSuggestion) => { if (suggestion.response) { const response = suggestion.response setMessages(m => [ ...m, { role: 'user', text: suggestion.label }, { role: 'bot', text: formatResponseText(response), suggestions: response.suggestions, sources: response.sources }, ]) return } if (suggestion.prompt) { askPrompt(suggestion.prompt) } } const respond = async (query: string) => { const userMsg: ChatMessage = { role: 'user', text: query } setMessages(m => [...m, userMsg]) setInput('') setLoading(true) const latestEntries = await loadEntries() const latestChunks = chunkIndexRef.current.length > 0 ? chunkIndexRef.current : buildSearchChunks(latestEntries) const activeContext = chatContextRef.current const trimmedQuery = query.trim() const contextualQuery = enrichQueryWithContext(trimmedQuery, activeContext) setTimeout(() => { const cue = detectConversationCue(trimmedQuery) const cueReply = buildConversationOnlyReply(cue, activeContext, responseStyle) if (cueReply) { setMessages(m => [...m, { role: 'bot', text: formatResponseText(cueReply), suggestions: cueReply.suggestions, sources: cueReply.sources }]) setLoading(false) return } if (isNotesRequest(trimmedQuery) && activeContext.lastChunks.length > 0) { const notesReply: ChatResponse = { text: buildDetailedNotesReplyFromChunks(activeContext.lastChunks), suggestions: buildSmartFollowUpPrompts( activeContext.lastChunks, analyzeIntent(activeContext.lastPrompt || trimmedQuery, activeContext), activeContext.lastChunks[0]?.content ?? '', ), sources: buildSourceCitations(activeContext.lastChunks), } setMessages(m => [...m, { role: 'bot', text: formatResponseText(notesReply), suggestions: notesReply.suggestions, sources: notesReply.sources }]) setLoading(false) return } if (isEpisodeFollowUpRequest(trimmedQuery) && activeContext.lastChunks.length > 0) { const episodeReply = buildContextualEpisodeReply(activeContext) if (episodeReply) { setMessages(m => [...m, { role: 'bot', text: formatResponseText(episodeReply), suggestions: episodeReply.suggestions, sources: episodeReply.sources }]) setLoading(false) return } } const analysis = analyzeIntent(contextualQuery, activeContext) const bibleVersionIntent = isBibleVersionQuery(analysis.searchQuery, analysis.queryTokens) const scored = latestChunks .map(chunk => { let score = scoreChunkForAnalysis(chunk, analysis, activeContext) const matchingEntry = latestEntries.find(entry => entry.id === chunk.entryId) if (matchingEntry && bibleVersionIntent && hasBsbSignal(matchingEntry)) score += 12 if (bibleVersionIntent && chunk.entryType === 'episode') score += 2 return { chunk, score } }) .filter(x => x.score > 0) .sort((a, b) => b.score - a.score) let botReply: ChatResponse if (scored.length > 0) { botReply = synthesizeSmartReply(scored, analysis, activeContext, responseStyle) if (determineConfidence(scored, analysis) !== 'low') { chatContextRef.current = buildNextChatContext(contextualQuery, analysis, scored, activeContext) } } else { botReply = buildSmartFallbackReply(analyzeIntent(contextualQuery, activeContext), activeContext) } setMessages(m => [...m, { role: 'bot', text: formatResponseText(botReply), suggestions: botReply.suggestions, sources: botReply.sources }]) setLoading(false) }, 400) } const handleSubmit = (e: React.FormEvent) => { e.preventDefault() const q = input.trim() if (!q) return void respond(q) } return ( <> {!isStandalone && (

Ask {BOT_NAME}

{BOT_NAME}

Your study companion for Verse by Verse with Nate. Ask about Titus, Bible study, prayer, daily reading, or anything Nate has already covered in his notes and episodes.

Have a question about the episode? {BOT_NAME} is here to help you dig deeper.

Ask Nate Directly ↓

Try asking:

{INLINE_CHAT_PROMPTS.map(prompt => ( ))}
)} {/* Floating bubble */} {!isStandalone && ( )} {/* Chat panel */} {chatVisible && (
⛏ {BOT_NAME}
{!isStandalone && ( )} {isStandalone ? ( <> Back to Site ) : ( )}
{messages.map((msg, i) => (
{msg.text.split('\n').map((line, j) =>

{line}

)} {msg.role === 'bot' && msg.suggestions && msg.suggestions.length > 0 && (
{msg.suggestions.map(suggestion => ( ))}
)}
))} {loading && (
)}
{messages.length === 1 && (
{SUGGESTED_PROMPTS.map(p => ( ))}
)}
setInput(e.target.value)} maxLength={300} aria-label="Your question" />
)} ) } function TheMineWindowPage() { return } const QA_PAGE_SIZE = 6 function QASection() { const [questions, setQuestions] = useState([]) const [searchQuery, setSearchQuery] = useState('') const [expanded, setExpanded] = useState<{ [key: string]: boolean }>({}) const [page, setPage] = useState(0) const [loading, setLoading] = useState(true) 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) }) }, []) const filteredQuestions = questions.filter(q => q.question.toLowerCase().includes(searchQuery.toLowerCase()) || q.answer.toLowerCase().includes(searchQuery.toLowerCase()) ) const totalPages = Math.ceil(filteredQuestions.length / QA_PAGE_SIZE) const pagedQuestions = filteredQuestions.slice(page * QA_PAGE_SIZE, (page + 1) * QA_PAGE_SIZE) const toggleExpanded = (id: string) => { setExpanded(e => ({ ...e, [id]: !e[id] })) } // Reset to page 0 when search changes const handleSearch = (value: string) => { setSearchQuery(value) setPage(0) } if (loading || questions.length === 0) return null return (

Questions & Answers

{filteredQuestions.length === 0 ? (

No matching questions found. Submit your question

) : ( <>
{pagedQuestions.map(question => (
toggleExpanded(question.id)} role="button" tabIndex={0} aria-label={expanded[question.id] ? 'Show question' : 'Show answer'} onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && toggleExpanded(question.id)} >
Q

{question.question}

tap to reveal answer
A

{question.answer}

— Answered by Nate
))}
{totalPages > 1 && (
{page + 1} / {totalPages}
)} )}
) } function ContactForm() { const navigate = useNavigate() const [fields, setFields] = useState({ name: '', email: '', message: '', messageType: 'question' }) const [subscribe, setSubscribe] = useState(true) const [honey, setHoney] = useState('') const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle') const [errorMsg, setErrorMsg] = useState('') function handleChange(e: React.ChangeEvent) { setFields(f => ({ ...f, [e.target.name]: e.target.value })) } function handleSelectChange(e: React.ChangeEvent) { setFields(f => ({ ...f, [e.target.name]: e.target.value })) } async function handleSubmit(e: React.FormEvent) { e.preventDefault() setStatus('submitting') setErrorMsg('') try { const res = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...fields, subscribe, _honey: honey }), }) if (!res.ok) { const data = await res.json().catch(() => ({})) setErrorMsg((data as { message?: string }).message ?? 'Something went wrong. Please try again.') setStatus('error') return } navigate('/thanks') } catch { setErrorMsg('Could not connect. Please try again later.') setStatus('error') } } return (
setHoney(e.target.value)} />