2570 lines
92 KiB
TypeScript
2570 lines
92 KiB
TypeScript
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
|
||
needsClarification: boolean
|
||
clarificationPrompt: string
|
||
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})\b/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 buildClarificationReply(analysis: IntentAnalysis, context: ChatContextState): ChatResponse {
|
||
const followupSuggestions = context.lastChunks.length > 0
|
||
? buildSmartFollowUpPrompts(
|
||
context.lastChunks,
|
||
analyzeIntent(context.lastPrompt || 'go deeper', context),
|
||
context.lastChunks[0]?.content ?? '',
|
||
)
|
||
: [
|
||
{ label: 'Who was Titus?', prompt: 'Who was Titus?' },
|
||
{ label: 'Give me a summary of Titus 3:4-7', prompt: 'Give me a summary of Titus 3:4-7' },
|
||
]
|
||
|
||
return {
|
||
text: `${analysis.clarificationPrompt}\n\nI want to answer from Nate's notes accurately, so one more detail will help me match the right content.`,
|
||
suggestions: followupSuggestions.slice(0, 3),
|
||
sources: context.lastChunks.length > 0 ? buildSourceCitations(context.lastChunks) : undefined,
|
||
}
|
||
}
|
||
|
||
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 wantsComparison = /\bdifference\b|\bcompare\b|\bvs\.?\b|\bversus\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
|
||
const tokenCount = tokenize(rawQuery).length
|
||
const hasStandalonePronoun = /\b(this|that|it|he|she|they|them|those|these|him|her)\b/.test(normalized)
|
||
|
||
let needsClarification = false
|
||
let clarificationPrompt = ''
|
||
|
||
if (!isFollowUp && hasStandalonePronoun && context.turnCount === 0) {
|
||
needsClarification = true
|
||
clarificationPrompt = 'Can you name the specific person, verse, or episode you mean?'
|
||
} else if (!isFollowUp && tokenCount < 2 && verseRefs.length === 0 && episodeNumber === null && !wantsIdentity) {
|
||
needsClarification = true
|
||
clarificationPrompt = 'Can you make that a bit more specific so I can match Nate\'s notes?'
|
||
} else if (/\bwho\s+(is|was)\b/.test(normalized) && !subject) {
|
||
needsClarification = true
|
||
clarificationPrompt = 'Who would you like to ask about specifically?'
|
||
} else if (wantsComparison && !/\band\b|\bbetween\b/.test(normalized)) {
|
||
needsClarification = true
|
||
clarificationPrompt = 'What two things would you like me to compare?'
|
||
}
|
||
|
||
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'
|
||
else if (wantsComparison) type = 'overview'
|
||
|
||
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,
|
||
needsClarification,
|
||
clarificationPrompt,
|
||
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 don't want to make up an answer. I couldn't find a confident match in Nate's notes yet. Did you mean "${topLabels[0]}" or "${topLabels[1]}"?`
|
||
: `I don't want to make up an answer. I couldn't find this clearly in Nate's current notes yet. You can submit this question directly to Nate below.`
|
||
|
||
const choiceSuggestions = choices.map(chunk => ({
|
||
label: chunk.sourceLabel,
|
||
prompt: buildPromptForChunk(chunk, analysis),
|
||
}))
|
||
|
||
return {
|
||
text,
|
||
suggestions: [
|
||
...choiceSuggestions,
|
||
{
|
||
label: 'How do I submit a question to Nate?',
|
||
prompt: 'How do I submit a question to Nate?',
|
||
},
|
||
].slice(0, 3),
|
||
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 }))
|
||
|
||
// Add context-specific follow-ups based on what was discussed
|
||
const subjectContext = analysis.subject || extractIdentitySubject(analysis.rawQuery.toLowerCase())
|
||
if (subjectContext && !analysis.rawQuery.toLowerCase().includes('apply')) {
|
||
promptSuggestions.unshift({
|
||
label: `How does this apply to my life?`,
|
||
prompt: `Based on what you just said about ${subjectContext}, how should I apply this today?`
|
||
})
|
||
}
|
||
|
||
if (primaryVerse && analysis.type === 'identity') {
|
||
promptSuggestions.unshift({
|
||
label: `What's the historical context?`,
|
||
prompt: `What was the historical and cultural context of ${formatVerseRef(primaryVerse)}?`
|
||
})
|
||
}
|
||
|
||
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 do I submit a question to Nate?')
|
||
|
||
return {
|
||
text: `I don't want to make up an answer. I couldn't find this in Nate's current notes. Please submit this question to Nate using the contact form below.`,
|
||
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 addConfidenceLabel(response: ChatResponse, confidence: 'high' | 'medium' | 'low'): ChatResponse {
|
||
const label = confidence === 'high'
|
||
? '✓ High confidence'
|
||
: confidence === 'medium'
|
||
? '~ Medium confidence'
|
||
: '? Low confidence'
|
||
return {
|
||
...response,
|
||
text: `${response.text}\n\n[${label}]`
|
||
}
|
||
}
|
||
|
||
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 maybeRewriteWithLlm = async (
|
||
question: string,
|
||
draft: ChatResponse,
|
||
contextChunks: SearchChunk[],
|
||
): Promise<ChatResponse> => {
|
||
try {
|
||
const response = await fetch('/api/chatbot-grounded-rewrite', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
question,
|
||
draftAnswer: draft.text,
|
||
sources: draft.sources ?? [],
|
||
contextChunks: contextChunks.slice(0, 4).map(chunk => ({
|
||
title: chunk.title,
|
||
sourceLabel: chunk.sourceLabel,
|
||
content: chunk.content,
|
||
})),
|
||
}),
|
||
})
|
||
|
||
if (!response.ok) return draft
|
||
const payload = await response.json()
|
||
const rewritten = typeof payload?.text === 'string' ? payload.text.trim() : ''
|
||
if (!rewritten) return draft
|
||
|
||
return {
|
||
...draft,
|
||
text: rewritten,
|
||
}
|
||
} catch {
|
||
return draft
|
||
}
|
||
}
|
||
|
||
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(() => {
|
||
void (async () => {
|
||
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)
|
||
if (analysis.needsClarification) {
|
||
const clarificationReply = buildClarificationReply(analysis, activeContext)
|
||
setMessages(m => [...m, {
|
||
role: 'bot',
|
||
text: formatResponseText(clarificationReply),
|
||
suggestions: clarificationReply.suggestions,
|
||
sources: clarificationReply.sources,
|
||
}])
|
||
setLoading(false)
|
||
return
|
||
}
|
||
|
||
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
|
||
let topChunksForRewrite: SearchChunk[] = []
|
||
let shouldTryLlmRewrite = false
|
||
if (scored.length > 0) {
|
||
const confidence = determineConfidence(scored, analysis)
|
||
topChunksForRewrite = getTopDistinctChunks(scored, 3)
|
||
botReply = synthesizeSmartReply(scored, analysis, activeContext, responseStyle)
|
||
if (confidence !== 'low') {
|
||
shouldTryLlmRewrite = true
|
||
chatContextRef.current = buildNextChatContext(contextualQuery, analysis, scored, activeContext)
|
||
botReply = addConfidenceLabel(botReply, confidence)
|
||
}
|
||
} else {
|
||
botReply = buildSmartFallbackReply(analyzeIntent(contextualQuery, activeContext), activeContext)
|
||
}
|
||
|
||
if (shouldTryLlmRewrite) {
|
||
botReply = await maybeRewriteWithLlm(trimmedQuery, botReply, topChunksForRewrite)
|
||
}
|
||
|
||
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">✦ ✦ ✦</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">✦ ✦ ✦</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>
|
||
)
|
||
}
|