Add episode transcripts, expand chatbot Q&A database to 28 entries, and build scripts
This commit is contained in:
+506
-2
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link, Routes, Route, useNavigate } from 'react-router-dom'
|
||||
import AdminPage from './AdminPage'
|
||||
import './App.css'
|
||||
@@ -103,10 +103,509 @@ function AmazonMusicIcon() {
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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'],
|
||||
leadership: ['elders', 'elder', 'overseer', 'overseers', 'leaders'],
|
||||
grace: ['salvation', 'saved', 'godliness', 'mercy'],
|
||||
quiet: ['prayer', 'devotional', 'reading'],
|
||||
devotional: ['quiet', 'prayer', 'reading'],
|
||||
family: ['skeptic', 'skeptical', 'gospel', 'faith'],
|
||||
gospel: ['grace', 'salvation', 'jesus', 'faith'],
|
||||
}
|
||||
|
||||
type ScoredEntry = { entry: ChatbotEntry; score: 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][a-z]+\s\d+:\d+(?:[-–]\d+)?\b/g) ?? []
|
||||
return [...new Set(matches.map(m => m.toLowerCase()))]
|
||||
}
|
||||
|
||||
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 buildEntryIndex(entry: ChatbotEntry): string[] {
|
||||
return [
|
||||
...tokenize(entry.title),
|
||||
...tokenize(entry.content),
|
||||
...entry.keywords.flatMap(k => tokenize(k)),
|
||||
...extractVerseRefs(`${entry.title} ${entry.content} ${entry.keywords.join(' ')}`),
|
||||
]
|
||||
}
|
||||
|
||||
function getFreshnessBoost(entry: ChatbotEntry): number {
|
||||
if (!entry.updatedAt) return 0
|
||||
const ageDays = (Date.now() - new Date(entry.updatedAt).getTime()) / (1000 * 60 * 60 * 24)
|
||||
if (!Number.isFinite(ageDays)) return 0
|
||||
if (ageDays <= 14) return 1.5
|
||||
if (ageDays <= 45) return 1
|
||||
if (ageDays <= 120) return 0.5
|
||||
return 0
|
||||
}
|
||||
|
||||
function getSourceLabel(entry: ChatbotEntry): string {
|
||||
if (entry.sourceLabel && entry.sourceLabel.trim()) return entry.sourceLabel.trim()
|
||||
return entry.title
|
||||
}
|
||||
|
||||
function scoreEntry(entry: ChatbotEntry, queryTokens: string[]): number {
|
||||
if (queryTokens.length === 0) return 0
|
||||
const expandedTokens = expandTokens(queryTokens)
|
||||
const titleTokens = tokenize(entry.title)
|
||||
const indexedTokens = buildEntryIndex(entry)
|
||||
let score = 0
|
||||
for (const qt of expandedTokens) {
|
||||
if (titleTokens.some(t => t.includes(qt) || qt.includes(t))) score += 3
|
||||
if (indexedTokens.some(t => t.includes(qt) || qt.includes(t))) score += 1
|
||||
}
|
||||
if (entry.priority === true) score += 4
|
||||
score += getFreshnessBoost(entry)
|
||||
return score
|
||||
}
|
||||
|
||||
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 buildEntryExcerpt(entry: ChatbotEntry, queryTokens: string[]): string {
|
||||
const expandedTokens = expandTokens(queryTokens)
|
||||
const sentences = entry.content
|
||||
.split(/(?<=[.!?])\s+(?=[A-Z])/)
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 35 && !/^[A-Z\s]{4,}$/.test(s))
|
||||
|
||||
const scoredSentences = sentences.map(s => ({
|
||||
text: s,
|
||||
score: tokenize(s).filter(t => expandedTokens.some(qt => t.includes(qt) || qt.includes(t))).length,
|
||||
}))
|
||||
|
||||
const topSentences = scoredSentences.some(s => s.score > 0)
|
||||
? scoredSentences.sort((a, b) => b.score - a.score).slice(0, entry.type === 'episode' ? 2 : 1).map(s => s.text)
|
||||
: sentences.slice(0, 1)
|
||||
|
||||
return topSentences.join(' ').slice(0, entry.type === 'episode' ? 320 : 220)
|
||||
}
|
||||
|
||||
function buildFollowUpPrompts(topEntries: ChatbotEntry[], query: string): string[] {
|
||||
const prompts = new Set<string>()
|
||||
const queryLower = query.toLowerCase()
|
||||
for (const entry of topEntries) {
|
||||
if (entry.type === 'episode') prompts.add(`Which episode covers ${getSourceLabel(entry)}?`)
|
||||
if (extractVerseRefs(entry.title).length > 0) prompts.add(`Give me a summary of ${extractVerseRefs(entry.title)[0]}`)
|
||||
if (entry.title.toLowerCase().includes('grace')) prompts.add('What does Nate say about grace?')
|
||||
if (entry.title.toLowerCase().includes('lead')) prompts.add('What makes a faithful leader according to Titus?')
|
||||
}
|
||||
if (!queryLower.includes('episode')) prompts.add('Which episode should I listen to next on this topic?')
|
||||
return [...prompts].slice(0, 2)
|
||||
}
|
||||
|
||||
function buildFallbackReply(): string {
|
||||
return `I don't have enough in Nate's notes to answer that clearly yet.\n\nTry asking about a Bible passage, a Titus episode, prayer, daily Bible reading, or how Nate explains a topic on the podcast.\n\nYou can also submit your question with the contact form below.`
|
||||
}
|
||||
|
||||
function synthesizeReply(scored: ScoredEntry[], query: string, queryTokens: string[]): string {
|
||||
const bestScore = scored[0]?.score ?? 0
|
||||
if (bestScore < 4) return buildFallbackReply()
|
||||
|
||||
const chosen = scored
|
||||
.filter(({ score }, index) => index === 0 || score >= bestScore * 0.65)
|
||||
.slice(0, 3)
|
||||
.map(item => item.entry)
|
||||
|
||||
const shortAnswer = buildEntryExcerpt(chosen[0], queryTokens)
|
||||
const sourceLines = chosen.map(entry => `From ${getSourceLabel(entry)}: ${buildEntryExcerpt(entry, queryTokens)}`)
|
||||
const followUps = buildFollowUpPrompts(chosen, query)
|
||||
|
||||
let reply = `Short answer:\n${shortAnswer}`
|
||||
|
||||
if (sourceLines.length > 0) {
|
||||
reply += `\n\nFrom Nate's notes:\n${sourceLines.join('\n\n')}`
|
||||
}
|
||||
|
||||
if (followUps.length > 0) {
|
||||
reply += `\n\nYou could also ask:\n${followUps.map(prompt => `- ${prompt}`).join('\n')}`
|
||||
}
|
||||
|
||||
return reply
|
||||
}
|
||||
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 ChatBot() {
|
||||
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 [pendingPrompt, setPendingPrompt] = useState<string | null>(null)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const openChat = () => {
|
||||
setOpen(true)
|
||||
if (!loaded) {
|
||||
setLoaded(true)
|
||||
fetch('/api/chatbot-content')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then(data => setEntries(Array.isArray(data) ? data : []))
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const askPrompt = (prompt: string) => {
|
||||
openChat()
|
||||
if (entries.length > 0) {
|
||||
respond(prompt)
|
||||
} else {
|
||||
setPendingPrompt(prompt)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingPrompt !== null && entries.length > 0) {
|
||||
const p = pendingPrompt
|
||||
setPendingPrompt(null)
|
||||
respond(p)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [entries, pendingPrompt])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, open])
|
||||
|
||||
const respond = (query: string) => {
|
||||
const userMsg: ChatMessage = { role: 'user', text: query }
|
||||
setMessages(m => [...m, userMsg])
|
||||
setInput('')
|
||||
setLoading(true)
|
||||
|
||||
setTimeout(() => {
|
||||
const tokens = tokenize(query)
|
||||
const bibleVersionIntent = isBibleVersionQuery(query, tokens)
|
||||
const scored = entries
|
||||
.map(e => {
|
||||
let score = scoreEntry(e, tokens)
|
||||
if (bibleVersionIntent && hasBsbSignal(e)) score += 12
|
||||
if (bibleVersionIntent && e.type === 'episode') score += 2
|
||||
return { entry: e, score }
|
||||
})
|
||||
.filter(x => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
|
||||
let botReply: string
|
||||
if (scored.length > 0) {
|
||||
botReply = synthesizeReply(scored, query, tokens)
|
||||
} else {
|
||||
botReply = buildFallbackReply()
|
||||
}
|
||||
|
||||
setMessages(m => [...m, { role: 'bot', text: botReply }])
|
||||
setLoading(false)
|
||||
}, 400)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const q = input.trim()
|
||||
if (!q) return
|
||||
respond(q)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<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 */}
|
||||
<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 */}
|
||||
{open && (
|
||||
<div className="chatbot-panel" role="dialog" aria-label={BOT_NAME}>
|
||||
<div className="chatbot-header">
|
||||
<span>⛏ {BOT_NAME}</span>
|
||||
<button className="chatbot-close" onClick={() => setOpen(false)} aria-label="Close">✕</button>
|
||||
</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>)}
|
||||
</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}>
|
||||
<input
|
||||
type="text"
|
||||
className="chatbot-input"
|
||||
placeholder="Ask a question…"
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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(false)
|
||||
const [subscribe, setSubscribe] = useState(true)
|
||||
const [honey, setHoney] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
@@ -287,6 +786,7 @@ function LandingPage({ content }: { content: SiteContent }) {
|
||||
<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
|
||||
@@ -498,6 +998,10 @@ function LandingPage({ content }: { content: SiteContent }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Q&A ── */}
|
||||
<QASection />
|
||||
<ChatBot />
|
||||
|
||||
{/* ── CONTACT ── */}
|
||||
<section className="section-contact" id="contact" aria-label="Contact form">
|
||||
<div className="section-inner contact-inner">
|
||||
|
||||
Reference in New Issue
Block a user