Files
Siteforge/src/App.tsx
T

2431 lines
87 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.234 2.686.234v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z" />
</svg>
)
}
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 (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />
</svg>
)
}
function YouTubeIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
)
}
function AmazonMusicIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
</svg>
)
}
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<string, string[]> = {
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<string> {
const normalized = normalizeExcerpt(text)
const trigrams = new Set<string>()
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<string>()
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<string>()
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<string>()
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<ChatMessage[]>([
{ 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<ChatbotEntry[]>([])
const [loaded, setLoaded] = useState(false)
const [loading, setLoading] = useState(false)
const [responseStyle, setResponseStyle] = useState<ResponseStyle>('balanced')
const [pendingPrompt, setPendingPrompt] = useState<string | null>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const lastEntriesSyncRef = useRef(0)
const chunkIndexRef = useRef<SearchChunk[]>([])
const chatContextRef = useRef<ChatContextState>(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 && (
<section id="the-mine" className="section-chatbot-feature" aria-label={`Ask ${BOT_NAME}`}>
<div className="section-inner chatbot-feature-inner">
<div className="chatbot-feature-copy">
<p className="eyebrow">Ask {BOT_NAME}</p>
<h2 className="section-heading">
<span className="ornament"></span> {BOT_NAME} <span className="ornament"></span>
</h2>
<p className="chatbot-feature-lead">
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.
</p>
<p className="chatbot-feature-sub">
Have a question about the episode? {BOT_NAME} is here to help you dig deeper.
</p>
<div className="chatbot-feature-actions">
<button type="button" className="btn-primary" onClick={openChat}>
Open {BOT_NAME}
</button>
<button type="button" className="btn-secondary" onClick={openMinePopoutWindow}>
Pop Out {BOT_NAME}
</button>
<a href="#contact" className="btn-secondary">
Ask Nate Directly
</a>
</div>
</div>
<div className="chatbot-feature-card">
<p className="chatbot-feature-kicker">Try asking:</p>
<div className="chatbot-feature-prompts">
{INLINE_CHAT_PROMPTS.map(prompt => (
<button key={prompt} className="chatbot-prompt-btn chatbot-prompt-btn--feature" onClick={() => askPrompt(prompt)}>
{prompt}
</button>
))}
</div>
</div>
</div>
</section>
)}
{/* Floating bubble */}
{!isStandalone && (
<button
className={`chatbot-bubble ${open ? 'chatbot-bubble--open' : ''}`}
onClick={() => open ? setOpen(false) : openChat()}
aria-label={open ? `Close ${BOT_NAME}` : `Open ${BOT_NAME}`}
>
{open ? (
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
) : (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></svg>
)}
</button>
)}
{/* Chat panel */}
{chatVisible && (
<div className={isStandalone ? 'chatbot-page' : ''}>
<div className={`chatbot-panel ${isStandalone ? 'chatbot-panel--standalone' : ''}`} role="dialog" aria-label={BOT_NAME}>
<div className="chatbot-header">
<span> {BOT_NAME}</span>
<div className="chatbot-header-actions">
{!isStandalone && (
<button type="button" className="chatbot-header-btn" onClick={openMinePopoutWindow} aria-label={`Open ${BOT_NAME} in a new window`}>
Pop Out
</button>
)}
{isStandalone ? (
<>
<Link to="/" className="chatbot-header-btn chatbot-header-btn--link">Back to Site</Link>
<button type="button" className="chatbot-close" onClick={closeMineWindow} aria-label="Close window"></button>
</>
) : (
<button className="chatbot-close" onClick={() => setOpen(false)} aria-label="Close"></button>
)}
</div>
</div>
<div className="chatbot-messages">
{messages.map((msg, i) => (
<div key={i} className={`chatbot-msg chatbot-msg--${msg.role}`}>
{msg.text.split('\n').map((line, j) => <p key={j}>{line}</p>)}
{msg.role === 'bot' && msg.suggestions && msg.suggestions.length > 0 && (
<div className="chatbot-msg-suggestions" aria-label="Related follow-up questions">
{msg.suggestions.map(suggestion => (
<button
key={`${i}-${suggestion.label}`}
type="button"
className="chatbot-prompt-btn chatbot-prompt-btn--inline"
onClick={() => handleSuggestion(suggestion)}
>
{suggestion.label}
</button>
))}
</div>
)}
</div>
))}
{loading && (
<div className="chatbot-msg chatbot-msg--bot chatbot-msg--typing">
<span /><span /><span />
</div>
)}
<div ref={bottomRef} />
</div>
{messages.length === 1 && (
<div className="chatbot-prompts">
{SUGGESTED_PROMPTS.map(p => (
<button key={p} className="chatbot-prompt-btn" onClick={() => askPrompt(p)}>{p}</button>
))}
</div>
)}
<form className="chatbot-form" onSubmit={handleSubmit}>
<select
className="chatbot-style-select"
value={responseStyle}
onChange={e => setResponseStyle(e.target.value as ResponseStyle)}
aria-label="Response style"
>
<option value="brief">Brief</option>
<option value="balanced">Balanced</option>
<option value="deep">Deep</option>
</select>
<input
type="text"
className="chatbot-input"
placeholder={`Ask a question (${toTitleCaseLabel(responseStyle)} mode)…`}
value={input}
onChange={e => setInput(e.target.value)}
maxLength={300}
aria-label="Your question"
/>
<button type="submit" className="chatbot-send" disabled={!input.trim() || loading} aria-label="Send">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</form>
</div>
</div>
)}
</>
)
}
function TheMineWindowPage() {
return <ChatBot mode="standalone" />
}
const QA_PAGE_SIZE = 6
function QASection() {
const [questions, setQuestions] = useState<PublicQuestion[]>([])
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 (
<section className="section-qa" aria-label="Questions and answers">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament"></span> Questions & Answers <span className="ornament"></span>
</h2>
<div className="qa-search">
<label>
<span>Search Questions</span>
<input
type="text"
placeholder="Search by topic..."
value={searchQuery}
onChange={e => handleSearch(e.target.value)}
/>
</label>
</div>
{filteredQuestions.length === 0 ? (
<div className="qa-no-results">
<p>No matching questions found. <a href="#contact">Submit your question</a></p>
</div>
) : (
<>
<div className="qa-cards">
{pagedQuestions.map(question => (
<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-label={expanded[question.id] ? 'Show question' : 'Show answer'}
onKeyDown={e => (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">tap to reveal answer</span>
</div>
<div className="qa-card-face qa-card-back">
<span className="qa-face-label">A</span>
<p className="qa-answer-text">{question.answer}</p>
<span className="qa-flip-hint"> Answered by Nate</span>
</div>
</div>
</div>
))}
</div>
{totalPages > 1 && (
<div className="qa-pagination">
<button
className="qa-page-btn"
onClick={() => setPage(p => p - 1)}
disabled={page === 0}
aria-label="Previous page"
>
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
</button>
</div>
)}
</>
)}
</div>
</section>
)
}
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<HTMLInputElement | HTMLTextAreaElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
}
function handleSelectChange(e: React.ChangeEvent<HTMLSelectElement>) {
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 (
<form className="contact-form" onSubmit={handleSubmit} noValidate>
<input
type="text"
className="contact-honeypot"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
value={honey}
onChange={e => setHoney(e.target.value)}
/>
<label>
Name
<input type="text" name="name" required autoComplete="name" value={fields.name} onChange={handleChange} />
</label>
<label>
Email
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
</label>
<label>
Message Type
<select name="messageType" value={fields.messageType} onChange={handleSelectChange}>
<option value="question">Bible Question</option>
<option value="testimony">Testimony</option>
<option value="topic">Topic Request</option>
<option value="general">General Message</option>
</select>
</label>
<label>
Message
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
</label>
<label className="contact-consent">
<input
type="checkbox"
checked={subscribe}
onChange={e => setSubscribe(e.target.checked)}
/>
<span>Send me updates from Verse by Verse with Nate. I can unsubscribe anytime.</span>
</label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Sending…' : 'Send Message'}
</button>
</form>
)
}
function AnalyticsConsentBanner() {
const [choice, setChoice] = useState<'unknown' | 'accepted' | 'declined'>(() => {
const saved = localStorage.getItem(CONSENT_KEY)
if (saved === 'accepted' || saved === 'declined') return saved
return 'unknown'
})
async function sendChoice(consent: boolean) {
await fetch('/api/analytics-consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ consent }),
})
}
async function accept() {
setChoice('accepted')
localStorage.setItem(CONSENT_KEY, 'accepted')
try {
await sendChoice(true)
} catch {
// Keep local preference even if network fails.
}
}
async function decline() {
setChoice('declined')
localStorage.setItem(CONSENT_KEY, 'declined')
try {
await sendChoice(false)
} catch {
// Keep local preference even if network fails.
}
}
if (choice !== 'unknown') return null
return (
<div className="consent-banner" role="region" aria-label="Analytics consent">
<p>
We use optional analytics cookies to measure visits and location trends for site improvement.
</p>
<div className="consent-actions">
<button type="button" className="btn-primary" onClick={accept}>Accept</button>
<button type="button" className="btn-secondary" onClick={decline}>Decline</button>
</div>
</div>
)
}
function ShareShowButton() {
const [feedback, setFeedback] = useState('')
async function handleShare() {
const shareUrl = window.location.origin
const shareTitle = 'Verse by Verse with Nate'
const shareText = 'Check out Verse by Verse with Nate.'
try {
if (navigator.share) {
await navigator.share({ title: shareTitle, text: shareText, url: shareUrl })
setFeedback('Shared successfully.')
return
}
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(shareUrl)
setFeedback('Link copied. Share it with a friend.')
return
}
setFeedback(`Copy this link: ${shareUrl}`)
} catch {
setFeedback('Share canceled.')
}
}
return (
<div className="share-show-wrap">
<button type="button" className="btn-primary btn-share-show" onClick={handleShare}>
Share the Show
</button>
{feedback && <p className="share-feedback">{feedback}</p>}
</div>
)
}
function LandingPage({ content }: { content: SiteContent }) {
return (
<div className="site">
{/* ── HEADER ── */}
<header className="site-header">
<div className="header-inner">
<span className="header-ornament"> &nbsp; &nbsp; </span>
<nav className="header-nav">
<a href="#listen">Listen</a>
<a href="#about">About</a>
<a href="#the-mine">The Mine</a>
<a href="#series">Series</a>
<a href="#contact">Contact</a>
<a
href={SPOTIFY_SHOW_URL}
target="_blank"
rel="noreferrer"
className="header-cta"
>
Subscribe Free
</a>
</nav>
</div>
</header>
{/* ── HERO ── */}
<section className="hero" aria-label="Show introduction">
<div className="hero-art">
<div className="art-glow" aria-hidden="true" />
<img
src="/images/podcast-art.jpeg"
alt="Verse by Verse with Nate podcast artwork"
className="podcast-art"
/>
</div>
<div className="hero-text">
<p className="eyebrow">{content.eyebrow}</p>
<h1 className="hero-title">
<span className="verse-word">VERSE</span>
<span className="by-label">· by ·</span>
<span className="verse-word">VERSE</span>
</h1>
<p className="with-nate">
<span className="word-with">with </span>Nate
</p>
<div className="rule-divider" aria-hidden="true" />
<p className="tagline">{content.heroTagline}</p>
<div className="hero-ctas">
<a
href={SPOTIFY_SHOW_URL}
target="_blank"
rel="noreferrer"
className="btn-primary"
>
<SpotifyIcon />
Listen on Spotify
</a>
<a href="#listen" className="btn-secondary">
All Platforms
</a>
</div>
</div>
</section>
{/* ── PLAYER ── */}
<section className="section-player" id="listen" aria-label="Podcast player">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament"></span> Latest Episodes{' '}
<span className="ornament"></span>
</h2>
<div className="embed-wrap">
<iframe
title="Verse by Verse with Nate"
src={SPOTIFY_EMBED_URL}
width="100%"
height="352"
frameBorder="0"
allowFullScreen
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
style={{ borderRadius: '14px' }}
/>
</div>
<div className="platform-buttons">
<a href={SPOTIFY_SHOW_URL} target="_blank" rel="noreferrer" className="platform-btn spotify-btn">
<SpotifyIcon />
Listen on Spotify
</a>
<a href={YOUTUBE_URL} target="_blank" rel="noreferrer" className="platform-btn youtube-btn">
<YouTubeIcon />
YouTube
</a>
<a href={AMAZON_MUSIC_URL} target="_blank" rel="noreferrer" className="platform-btn amazon-btn">
<AmazonMusicIcon />
Amazon Music
</a>
<a href={FACEBOOK_URL} target="_blank" rel="noreferrer" className="platform-btn facebook-btn">
<FacebookIcon />
Facebook
</a>
<a href={APPLE_PODCASTS_URL} target="_blank" rel="noreferrer" className="platform-btn apple-btn">
<img src="/images/apple-podcasts-badge.svg" alt="Listen on Apple Podcasts" className="apple-badge" />
</a>
{(content.customLinks ?? []).filter(l => l.placement === 'platforms').map(l => (
<a key={l.id} href={l.url} target="_blank" rel="noreferrer" className="platform-btn custom-btn">
{l.label}
</a>
))}
</div>
</div>
</section>
{/* ── ABOUT ── */}
<section className="section-about" id="about" aria-label="About the show">
<div className="section-inner about-inner">
<div className="about-photo">
<img src="/images/nate-photo.jpeg" alt="Nate Emmert" />
</div>
<div className="about-text">
<p className="eyebrow">About the Show</p>
<h2>{content.aboutShowHeading}</h2>
<p>{content.aboutShowP1}</p>
<p>{content.aboutShowP2}</p>
<div className="rule-divider" aria-hidden="true" />
<p className="eyebrow">About Nate</p>
<p>{content.aboutNate}</p>
</div>
</div>
</section>
{/* ── SERIES ── */}
<section className="section-series" id="series" aria-label="Current series">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament"></span> Current Series{' '}
<span className="ornament"></span>
</h2>
<div className="series-grid">
<article className="series-card">
<img
src="/images/titus-cover.png"
alt={content.seriesTitle}
className="series-art"
/>
<div className="series-info">
<p className="series-label">{content.seriesLabel}</p>
<h3>{content.seriesTitle}</h3>
<p>{content.seriesDescription}</p>
<a
href={SPOTIFY_SHOW_URL}
target="_blank"
rel="noreferrer"
className="btn-primary"
>
<SpotifyIcon />
Listen to Series
</a>
</div>
</article>
</div>
</div>
</section>
{/* ── STUDY GUIDE ── */}
<section className="section-guide" aria-label="Companion study guide">
<div className="section-inner guide-inner">
<div className="guide-icon" aria-hidden="true">
<svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="8" y="6" width="36" height="48" rx="3" fill="#c8860a" opacity="0.15" stroke="#c8860a" strokeWidth="1.5"/>
<rect x="14" y="6" width="36" height="48" rx="3" fill="#0f0f0f" stroke="#c8860a" strokeWidth="1.5"/>
<line x1="22" y1="22" x2="42" y2="22" stroke="#c8860a" strokeWidth="1.5" strokeLinecap="round"/>
<line x1="22" y1="30" x2="42" y2="30" stroke="#c8860a" strokeWidth="1.5" strokeLinecap="round" opacity="0.6"/>
<line x1="22" y1="38" x2="34" y2="38" stroke="#c8860a" strokeWidth="1.5" strokeLinecap="round" opacity="0.4"/>
</svg>
</div>
<div className="guide-text">
<p className="eyebrow">Available on Amazon</p>
<h2>{content.studyGuideTitle}</h2>
<p>{content.studyGuideDescription}</p>
<a
href={content.studyGuideUrl}
target="_blank"
rel="noreferrer"
className="btn-primary"
>
Get the Book
</a>
</div>
</div>
</section>
{/* ── SHARE / QR ── */}
<section className="section-qr" aria-label="Share the show">
<div className="section-inner qr-inner">
<div className="qr-text">
<p className="eyebrow">Share the Show</p>
<h2>{content.shareHeading}</h2>
<p>{content.shareP}</p>
<a
href={SPOTIFY_CREATOR_URL}
target="_blank"
rel="noreferrer"
className="btn-secondary"
>
creators.spotify.com/pod/profile/nmemmert
</a>
<ShareShowButton />
</div>
<div className="qr-code-wrap">
<div className="qr-frame">
<img
src="/images/qr-code.jpg"
alt="Scan to listen to Verse by Verse with Nate"
className="qr-code"
/>
</div>
<p className="scan-label">SCAN TO LISTEN</p>
</div>
</div>
</section>
{/* ── Q&A ── */}
<QASection />
<ChatBot />
{/* ── CONTACT ── */}
<section className="section-contact" id="contact" aria-label="Contact form">
<div className="section-inner contact-inner">
<div className="contact-copy">
<p className="eyebrow">Get in Touch</p>
<h2 className="section-heading">
<span className="ornament"></span> Contact Nate <span className="ornament"></span>
</h2>
<p className="contact-highlight">
Ask a Bible question, share a testimony, or request a topic for a future episode.
</p>
<p>We usually respond within 48 hours.</p>
</div>
<ContactForm />
</div>
</section>
{/* ── CUSTOM RESOURCES ── */}
{(content.customLinks ?? []).filter(l => l.placement === 'resources').length > 0 && (
<section className="section-resources" aria-label="More resources">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament"></span> More Resources{' '}
<span className="ornament"></span>
</h2>
<div className="resources-list">
{(content.customLinks ?? []).filter(l => l.placement === 'resources').map(l => (
<a key={l.id} href={l.url} target="_blank" rel="noreferrer" className="resource-link">
{l.label}
</a>
))}
</div>
</div>
</section>
)}
{/* ── CUSTOM BLOCKS ── */}
{(content.customBlocks ?? []).map(block => (
<section key={block.id} className="section-custom-block" aria-label={block.heading}>
<div className="section-inner custom-block-inner">
<h2>{block.heading}</h2>
<p>{block.body}</p>
</div>
</section>
))}
{/* ── FOOTER ── */}
<footer className="site-footer">
<p className="footer-ornament"> &nbsp; &nbsp; </p>
<p className="footer-title">Verse by Verse with Nate</p>
<p className="footer-sub">A Journey Through Scripture</p>
<p className="footer-contact">
Contact:{' '}
<a href="mailto:hello@versebyversewithnate.us">hello@versebyversewithnate.us</a>
</p>
<p className="footer-response">We usually reply within 48 hours.</p>
<nav className="footer-links" aria-label="Footer links">
<a href={SPOTIFY_SHOW_URL} target="_blank" rel="noreferrer">Spotify</a>
<span aria-hidden="true">·</span>
<a href={APPLE_PODCASTS_URL} target="_blank" rel="noreferrer">Apple Podcasts</a>
<span aria-hidden="true">·</span>
<a href={YOUTUBE_URL} target="_blank" rel="noreferrer">YouTube</a>
<span aria-hidden="true">·</span>
<a href={AMAZON_MUSIC_URL} target="_blank" rel="noreferrer">Amazon Music</a>
<span aria-hidden="true">·</span>
<a href={SPOTIFY_CREATOR_URL} target="_blank" rel="noreferrer">Creator Profile</a>
<span aria-hidden="true">·</span>
<a href={FACEBOOK_URL} target="_blank" rel="noreferrer">Facebook</a>
{(content.customLinks ?? []).filter(l => l.placement === 'footer').flatMap(l => [
<span key={`sep-${l.id}`} aria-hidden="true">·</span>,
<a key={l.id} href={l.url} target="_blank" rel="noreferrer">{l.label}</a>,
])}
</nav>
<nav className="footer-links footer-links--legal" aria-label="Legal links">
<Link to="/privacy">Privacy Policy</Link>
<span aria-hidden="true">·</span>
<Link to="/terms">Terms</Link>
</nav>
<p className="footer-copy">© 2026 Nate Emmert · Made with faith.</p>
<p className="footer-privacy">
Privacy: with consent, analytics may store masked IP-based location data (country/state/county/city) and returning visitor activity.
</p>
</footer>
<AnalyticsConsentBanner />
</div>
)
}
function ThankYouPage() {
const navigate = useNavigate()
const [secondsLeft, setSecondsLeft] = useState(15)
useEffect(() => {
const timeoutId = window.setTimeout(() => {
navigate('/')
}, 15000)
const intervalId = window.setInterval(() => {
setSecondsLeft(s => (s > 0 ? s - 1 : 0))
}, 1000)
return () => {
window.clearTimeout(timeoutId)
window.clearInterval(intervalId)
}
}, [navigate])
return (
<main className="thanks-page" aria-label="Thank you">
<div className="thanks-card">
<p className="eyebrow">Message Received</p>
<h1>Thank You</h1>
<p>
Your message was sent successfully. We appreciate you reaching out and will get back to you soon.
</p>
<p className="thanks-countdown">Returning to the main site in {secondsLeft} seconds...</p>
<button type="button" className="btn-primary" onClick={() => navigate('/')}>
Go Back Now
</button>
</div>
</main>
)
}
function SubscribePage() {
const navigate = useNavigate()
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [honey, setHoney] = useState('')
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
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({
name,
email,
messageType: 'general',
message: 'Newsletter signup from subscribe page',
subscribe: true,
_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('/subscribe/thanks')
} catch {
setErrorMsg('Could not connect. Please try again later.')
setStatus('error')
}
}
return (
<main className="thanks-page" aria-label="Subscribe">
<div className="thanks-card subscribe-card">
<p className="eyebrow">Verse by Verse with Nate</p>
<h1>Subscribe for Updates</h1>
<p>
Get ministry updates and new episode announcements by email.
</p>
<form className="contact-form subscribe-form" onSubmit={handleSubmit} noValidate>
<input
type="text"
className="contact-honeypot"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
value={honey}
onChange={e => setHoney(e.target.value)}
/>
<label>
Name
<input
type="text"
name="name"
required
autoComplete="name"
value={name}
onChange={e => setName(e.target.value)}
/>
</label>
<label>
Email
<input
type="email"
name="email"
required
autoComplete="email"
value={email}
onChange={e => setEmail(e.target.value)}
/>
</label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Subscribing…' : 'Subscribe'}
</button>
<Link to="/" className="btn-secondary">Back to Site</Link>
</form>
</div>
</main>
)
}
function SubscribeThankYouPage() {
return (
<main className="thanks-page" aria-label="Subscription confirmed">
<div className="thanks-card">
<p className="eyebrow">Verse by Verse with Nate</p>
<h1>You are subscribed</h1>
<p>
Thank you for subscribing. You will receive future ministry updates and episode announcements.
</p>
<Link to="/" className="btn-primary">Back to Site</Link>
</div>
</main>
)
}
function LegalPage({ title, body }: { title: string; body: string[] }) {
return (
<main className="thanks-page" aria-label={title}>
<div className="thanks-card">
<p className="eyebrow">Verse by Verse with Nate</p>
<h1>{title}</h1>
{body.map((line, idx) => (
<p key={`${title}-${idx}`}>{line}</p>
))}
<Link to="/" className="btn-primary">Back to Site</Link>
</div>
</main>
)
}
function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: SiteContent) => void }) {
const [status, setStatus] = useState<'checking' | 'authenticated' | 'unauthenticated' | 'misconfigured'>('checking')
const [password, setPassword] = useState('')
const [errorMsg, setErrorMsg] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
fetch('/api/admin-auth/status')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Status failed'))))
.then(data => {
const next = data as { authenticated?: boolean; configured?: boolean }
if (next.configured === false) {
setStatus('misconfigured')
return
}
setStatus(next.authenticated ? 'authenticated' : 'unauthenticated')
})
.catch(() => {
setStatus('unauthenticated')
})
}, [])
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setSubmitting(true)
setErrorMsg('')
try {
const res = await fetch('/api/admin-auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setErrorMsg((data as { message?: string }).message ?? 'Login failed.')
setSubmitting(false)
return
}
setStatus('authenticated')
setPassword('')
} catch {
setErrorMsg('Login failed.')
} finally {
setSubmitting(false)
}
}
async function handleLogout() {
try {
await fetch('/api/admin-auth/logout', { method: 'POST' })
} finally {
setStatus('unauthenticated')
}
}
if (status === 'authenticated') {
return <AdminPage content={content} onSave={onSave} onLogout={handleLogout} />
}
return (
<main className="admin-auth-page" aria-label="Admin sign in">
<div className="admin-auth-card">
<p className="eyebrow">Admin Access</p>
<h1>{status === 'misconfigured' ? 'Admin Not Configured' : 'Sign in to Admin'}</h1>
{status === 'checking' && <p className="admin-auth-note">Checking session...</p>}
{status === 'misconfigured' && (
<p className="admin-auth-note">Set the ADMIN_PASSWORD environment variable on the server to enable admin login.</p>
)}
{status === 'unauthenticated' && (
<form className="admin-auth-form" onSubmit={handleLogin}>
<label>
Password
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</label>
{errorMsg && <p className="admin-auth-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Signing In…' : 'Sign In'}
</button>
<Link to="/" className="btn-secondary">Back to Site</Link>
</form>
)}
</div>
</main>
)
}
export default function App() {
const [content, setContent] = useState<SiteContent>(DEFAULTS)
useEffect(() => {
fetch('/api/admin-content')
.then(r => (r.ok ? r.json() : null))
.then(data => {
if (data?.siteContent) {
setContent(c => ({ ...c, ...data.siteContent }))
}
})
.catch(() => {})
}, [])
return (
<Routes>
<Route path="/" element={<LandingPage content={content} />} />
<Route path="/the-mine" element={<TheMineWindowPage />} />
<Route path="/thanks" element={<ThankYouPage />} />
<Route path="/subscribe" element={<SubscribePage />} />
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
<Route
path="/privacy"
element={(
<LegalPage
title="Privacy Policy"
body={[
'We respect your privacy and collect limited data to operate and improve this site.',
'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.',
'Contact form details are used only to respond to your message and ministry communication requests.',
]}
/>
)}
/>
<Route
path="/terms"
element={(
<LegalPage
title="Terms"
body={[
'Content on this site is for informational and ministry purposes.',
'External links are provided for convenience and are subject to third-party policies.',
'By using this site, you agree to lawful use and respectful communication.',
]}
/>
)}
/>
</Routes>
)
}