Add episode transcripts, expand chatbot Q&A database to 28 entries, and build scripts

This commit is contained in:
nmemmert
2026-04-13 10:29:06 -04:00
parent 6e8b360398
commit 063f5d2a0e
31 changed files with 2779 additions and 134 deletions
+638 -43
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import type { SiteContent, CustomLink, CustomBlock } from './App'
import { DEFAULTS } from './App'
@@ -54,21 +54,23 @@ interface AdminStats {
visitorStats: { ok: boolean; at: string | null; error: string | null }
backups: { ok: boolean; at: string | null; error: string | null; file: string | null }
}
bibleQuestions: Array<{
id: string
submittedAt: string
name: string
email: string
message: string
messageType: 'question' | 'testimony' | 'topic' | 'general'
subscribe: boolean
}>
contactTotals: {
totalSubmissions: number
totalQuestions: number
}
}
interface Question {
id: string
submittedAt: string
firstName: string
email: string
question: string
answer: string
answeredAt: string | null
isApproved: boolean
approvedAt: string | null
}
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks'>
const FIELDS: { key: StringField; label: string; multiline?: boolean }[] = [
@@ -92,7 +94,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [form, setForm] = useState<SiteContent>(content)
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [adminTab, setAdminTab] = useState<'content' | 'stats'>('content')
const [adminTab, setAdminTab] = useState<'content' | 'stats' | 'questions' | 'chatbot'>('content')
const [contentTab, setContentTab] = useState<'main' | 'custom'>('main')
const [stats, setStats] = useState<AdminStats | null>(null)
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
@@ -101,6 +103,40 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [selectedBackup, setSelectedBackup] = useState('')
const [selectedBackupPreview, setSelectedBackupPreview] = useState<BackupPreview | null>(null)
const [questions, setQuestions] = useState<Question[]>([])
const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({})
const [editingQuestionId, setEditingQuestionId] = useState<string | null>(null)
// Chatbot knowledge base
interface ChatbotEntry {
id: string
type: 'qa' | 'topic' | 'episode'
title: string
content: string
sourceLabel?: string
priority?: boolean
keywords: string[]
createdAt: string
updatedAt: string
}
const [chatbotEntries, setChatbotEntries] = useState<ChatbotEntry[]>([])
const [chatbotDraft, setChatbotDraft] = useState<ChatbotEntry | null>(null)
const chatbotEditorRef = useRef<HTMLDivElement>(null)
const [chatbotSaving, setChatbotSaving] = useState(false)
const [chatbotMsg, setChatbotMsg] = useState('')
const [quickNoteMode, setQuickNoteMode] = useState<'new' | 'append'>('new')
const [quickNoteType, setQuickNoteType] = useState<ChatbotEntry['type']>('topic')
const [quickNoteTitle, setQuickNoteTitle] = useState('')
const [quickNoteContent, setQuickNoteContent] = useState('')
const [quickNoteKeywords, setQuickNoteKeywords] = useState('')
const [quickNoteTargetId, setQuickNoteTargetId] = useState('')
const [quickNoteSourceLabel, setQuickNoteSourceLabel] = useState('')
const [quickNotePriority, setQuickNotePriority] = useState(false)
useEffect(() => {
if (chatbotDraft !== null) {
setTimeout(() => chatbotEditorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50)
}
}, [chatbotDraft !== null]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
fetch('/api/admin-stats')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load stats'))))
@@ -112,6 +148,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
setStatsStatus('error')
})
fetch('/api/admin-questions')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load questions'))))
.then(data => {
setQuestions((data as { questions: Question[] }).questions ?? [])
})
.catch(() => {})
fetch('/api/admin-stats/backups')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load backups'))))
.then(data => {
@@ -356,6 +398,59 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}
}
async function handleAnswerQuestion(questionId: string, answer: string) {
if (!answer.trim()) return
try {
const res = await fetch(`/api/admin-questions/${questionId}/answer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answer: answer.trim() }),
})
if (!res.ok) throw new Error('Failed to answer question')
setQuestions(qs =>
qs.map(q =>
q.id === questionId
? { ...q, answer: answer.trim(), answeredAt: new Date().toISOString() }
: q
)
)
setEditingQuestionId(null)
setAnsweredQuestions(a => ({ ...a, [questionId]: '' }))
} catch {
alert('Failed to save answer')
}
}
async function handleApproveQuestion(questionId: string, approved: boolean) {
try {
const res = await fetch(`/api/admin-questions/${questionId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved }),
})
if (!res.ok) throw new Error('Failed to update question')
setQuestions(qs =>
qs.map(q =>
q.id === questionId
? { ...q, isApproved: approved, approvedAt: approved ? new Date().toISOString() : null }
: q
)
)
} catch {
alert('Failed to update question')
}
}
async function handleDeleteQuestion(questionId: string) {
if (!confirm('Delete this question permanently?')) return
try {
const res = await fetch(`/api/admin-questions/${questionId}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete question')
setQuestions(qs => qs.filter(q => q.id !== questionId))
} catch {
alert('Failed to delete question')
}
}
return (
<div className="admin-page">
<div className="admin-header">
@@ -390,6 +485,32 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
>
Site Stats
</button>
<button
type="button"
role="tab"
aria-selected={adminTab === 'questions'}
className={`admin-tab ${adminTab === 'questions' ? 'admin-tab--active' : ''}`}
onClick={() => setAdminTab('questions')}
>
Questions ({questions.length})
</button>
<button
type="button"
role="tab"
aria-selected={adminTab === 'chatbot'}
className={`admin-tab ${adminTab === 'chatbot' ? 'admin-tab--active' : ''}`}
onClick={() => {
setAdminTab('chatbot')
if (chatbotEntries.length === 0) {
fetch('/api/admin/chatbot-content', { credentials: 'include' })
.then(r => r.ok ? r.json() : [])
.then(data => setChatbotEntries(Array.isArray(data) ? data : []))
.catch(() => {})
}
}}
>
Chatbot ({chatbotEntries.length})
</button>
</div>
{adminTab === 'stats' && (
@@ -570,8 +691,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</div>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Bible Questions Inbox</h2>
<p>Questions submitted from the contact form emails.</p>
<h2>Contact Summary</h2>
<p>Submission totals from the contact form.</p>
</div>
<div className="admin-stats-grid">
@@ -585,36 +706,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</article>
</div>
<div className="admin-visits-table-wrap">
<h3>Recent Bible Questions</h3>
{stats.bibleQuestions.length === 0 ? (
<p className="admin-stats-note">No Bible questions yet.</p>
) : (
<div className="admin-visits-table-scroll">
<table className="admin-visits-table">
<thead>
<tr>
<th>Submitted</th>
<th>Name</th>
<th>Email</th>
<th>Question</th>
</tr>
</thead>
<tbody>
{stats.bibleQuestions.map(item => (
<tr key={item.id}>
<td>{formatDate(item.submittedAt)}</td>
<td>{item.name}</td>
<td>{item.email}</td>
<td>{item.message}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Data Management</h2>
<p>Export, backup, or retain only recent analytics data.</p>
@@ -849,7 +940,511 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<p className="admin-status admin-status--err"> {errorMsg}</p>
)}
</form>
)}
{adminTab === 'questions' && (
<section className="admin-questions" aria-label="Q&A Management">
<div className="admin-stats-head">
<h2>Bible Questions & Answers</h2>
<p>Manage submitted questions, provide answers, and approve for public display.</p>
</div>
{questions.length === 0 ? (
<p className="admin-stats-note">No questions submitted yet.</p>
) : (
<div className="admin-questions-list">
{questions.map(question => (
<div key={question.id} className="admin-question-card">
<div className="admin-question-header">
<div>
<p className="admin-question-meta">
<strong>{question.firstName}</strong> {formatDate(question.submittedAt)}
</p>
<p className="admin-question-text"><strong>Q:</strong> {question.question}</p>
</div>
<div className="admin-question-status">
<span className={`admin-badge ${question.isApproved ? 'admin-badge--approved' : 'admin-badge--pending'}`}>
{question.isApproved ? 'Approved' : 'Pending'}
</span>
{question.answer && (
<span className="admin-badge admin-badge--answered">Answered</span>
)}
</div>
</div>
{question.answer && (
<div className="admin-question-answer">
<p><strong>A:</strong> {question.answer}</p>
</div>
)}
{editingQuestionId === question.id ? (
<div className="admin-question-editor">
<textarea
value={answeredQuestions[question.id] ?? question.answer ?? ''}
onChange={e => setAnsweredQuestions(a => ({ ...a, [question.id]: e.target.value }))}
rows={4}
placeholder="Type your answer here..."
/>
<div className="admin-question-editor-actions">
<button
type="button"
className="btn-admin-save"
onClick={() => handleAnswerQuestion(question.id, answeredQuestions[question.id] ?? '')}
>
Save Answer
</button>
<button
type="button"
className="btn-admin-reset"
onClick={() => setEditingQuestionId(null)}
>
Cancel
</button>
</div>
</div>
) : (
<div className="admin-question-actions">
<button
type="button"
className="btn-admin-reset"
onClick={() => {
setEditingQuestionId(question.id)
setAnsweredQuestions(a => ({ ...a, [question.id]: question.answer ?? '' }))
}}
>
{question.answer ? 'Edit Answer' : 'Add Answer'}
</button>
<button
type="button"
className={`btn-admin-${question.isApproved ? 'remove' : 'reset'}`}
onClick={() => handleApproveQuestion(question.id, !question.isApproved)}
>
{question.isApproved ? 'Unapprove' : 'Approve'}
</button>
<button
type="button"
className="btn-admin-remove"
onClick={() => handleDeleteQuestion(question.id)}
>
Delete
</button>
</div>
)}
</div>
))}
</div>
)}
</section>
)}
{adminTab === 'chatbot' && (() => {
const emptyDraft = (): ChatbotEntry => ({
id: '',
type: 'qa',
title: '',
content: '',
sourceLabel: '',
priority: false,
keywords: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
const extractVerseRefs = (text: 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.trim()))].slice(0, 8)
}
const deriveKeywords = (text: string, existing: string[] = []) => {
const words = text
.toLowerCase()
.replace(/[^a-z0-9:\s-]/g, ' ')
.split(/\s+/)
.filter(word => word.length > 3)
.filter(word => !['that', 'this', 'with', 'from', 'have', 'your', 'about', 'into', 'because'].includes(word))
return [...new Set([...existing, ...extractVerseRefs(text).map(v => v.toLowerCase()), ...words])].slice(0, 16)
}
const deriveTitleFromNotes = (text: string) => {
const firstMeaningfulLine = text.split(/\n+/).map(line => line.trim()).find(Boolean) ?? ''
if (firstMeaningfulLine.length > 0 && firstMeaningfulLine.length <= 80) return firstMeaningfulLine
const verse = extractVerseRefs(text)[0]
if (verse) return `${verse} Notes`
return 'New Notes'
}
const populateQuickNoteFromRaw = () => {
if (!quickNoteContent.trim()) return
if (!quickNoteTitle.trim()) setQuickNoteTitle(deriveTitleFromNotes(quickNoteContent))
if (!quickNoteSourceLabel.trim()) {
const verse = extractVerseRefs(quickNoteContent)[0]
if (verse) setQuickNoteSourceLabel(verse)
}
if (!quickNoteKeywords.trim()) {
setQuickNoteKeywords(deriveKeywords(quickNoteContent).join(', '))
}
}
const saveChatbot = (entries: ChatbotEntry[]) => {
setChatbotSaving(true)
fetch('/api/admin/chatbot-content', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entries),
})
.then(r => r.ok ? r.json() : Promise.reject(new Error('Save failed')))
.then(() => {
setChatbotEntries(entries)
setChatbotMsg('Saved!')
setTimeout(() => setChatbotMsg(''), 2500)
})
.catch(() => setChatbotMsg('Error saving — try again.'))
.finally(() => setChatbotSaving(false))
}
const deleteEntry = (id: string) => {
const next = chatbotEntries.filter(e => e.id !== id)
saveChatbot(next)
}
const parseKeywords = (raw: string) => raw
.split(',')
.map(k => k.trim().toLowerCase())
.filter(Boolean)
const saveQuickNote = () => {
const content = quickNoteContent.trim()
if (!content) return
const keywordList = parseKeywords(quickNoteKeywords)
if (quickNoteMode === 'append' && quickNoteTargetId) {
const existing = chatbotEntries.find(e => e.id === quickNoteTargetId)
if (!existing) return
const next = chatbotEntries.map(e => {
if (e.id !== quickNoteTargetId) return e
return {
...e,
content: `${e.content.trim()}\n\n${content}`,
keywords: [...new Set([...e.keywords, ...keywordList])],
priority: quickNotePriority || e.priority,
sourceLabel: quickNoteSourceLabel.trim() || e.sourceLabel || '',
updatedAt: new Date().toISOString(),
}
})
saveChatbot(next)
setQuickNoteContent('')
setQuickNoteKeywords('')
setQuickNoteSourceLabel('')
setQuickNotePriority(false)
return
}
const title = quickNoteTitle.trim() || deriveTitleFromNotes(quickNoteContent)
if (!title) return
const entry: ChatbotEntry = {
id: crypto.randomUUID(),
type: quickNoteType,
title,
content,
sourceLabel: quickNoteSourceLabel.trim(),
priority: quickNotePriority,
keywords: keywordList.length > 0 ? keywordList : deriveKeywords(`${title} ${content}`),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
saveChatbot([...chatbotEntries, entry])
setQuickNoteTitle('')
setQuickNoteContent('')
setQuickNoteKeywords('')
setQuickNoteSourceLabel('')
setQuickNotePriority(false)
}
const saveEntry = (draft: ChatbotEntry) => {
const isNew = !draft.id
const entry: ChatbotEntry = {
...draft,
id: draft.id || crypto.randomUUID(),
sourceLabel: typeof draft.sourceLabel === 'string' ? draft.sourceLabel.trim() : '',
priority: draft.priority === true,
keywords: typeof (draft.keywords as unknown) === 'string'
? (draft.keywords as unknown as string).split(',').map(k => k.trim().toLowerCase()).filter(Boolean)
: (Array.isArray(draft.keywords) ? draft.keywords : []).filter(Boolean),
updatedAt: new Date().toISOString(),
createdAt: draft.createdAt || new Date().toISOString(),
}
const next = isNew
? [...chatbotEntries, entry]
: chatbotEntries.map(e => e.id === entry.id ? entry : e)
saveChatbot(next)
setChatbotDraft(null)
}
const TYPE_LABELS: Record<ChatbotEntry['type'], string> = { qa: 'Q&A', topic: 'Topic', episode: 'Episode' }
const sortedChatbotEntries = [...chatbotEntries].sort((a, b) => {
if ((a.priority === true) !== (b.priority === true)) return a.priority === true ? -1 : 1
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
})
return (
<section className="admin-chatbot" aria-label="Chatbot Knowledge Base">
<div className="admin-stats-head">
<h2>Chatbot Knowledge Base</h2>
<p className="admin-stats-sub">
Entries the floating chatbot uses to answer visitor questions. Add Q&amp;A pairs, topic summaries, or episode notes. The chatbot fuzzy-matches visitor questions against these entries.
</p>
</div>
{chatbotMsg && <p className="admin-save-msg">{chatbotMsg}</p>}
<div className="admin-chatbot-actions">
<button
type="button"
className="btn-admin-primary"
onClick={() => setChatbotDraft(emptyDraft())}
disabled={!!chatbotDraft}
>
+ Add Entry
</button>
</div>
<div className="admin-chatbot-editor">
<h3>Quick Add Notes</h3>
<label>
Save Mode
<select
value={quickNoteMode}
onChange={e => setQuickNoteMode(e.target.value as 'new' | 'append')}
>
<option value="new">Create New Entry</option>
<option value="append">Append to Existing Entry</option>
</select>
</label>
{quickNoteMode === 'append' ? (
<label>
Append To
<select
value={quickNoteTargetId}
onChange={e => setQuickNoteTargetId(e.target.value)}
>
<option value="">Select an entry...</option>
{chatbotEntries.map(entry => (
<option key={entry.id} value={entry.id}>{entry.title}</option>
))}
</select>
</label>
) : (
<>
<label>
Type
<select
value={quickNoteType}
onChange={e => setQuickNoteType(e.target.value as ChatbotEntry['type'])}
>
<option value="qa">Q&amp;A</option>
<option value="topic">Topic Summary</option>
<option value="episode">Episode Notes</option>
</select>
</label>
<label>
Title
<input
type="text"
value={quickNoteTitle}
onChange={e => setQuickNoteTitle(e.target.value)}
placeholder="e.g. Titus 2:11-12 Grace Trains Us"
/>
</label>
</>
)}
<label>
Source Label <span className="admin-field-hint">(optional)</span>
<input
type="text"
value={quickNoteSourceLabel}
onChange={e => setQuickNoteSourceLabel(e.target.value)}
placeholder="e.g. Episode 8, Titus 2:11-12"
/>
</label>
<label>
Notes
<textarea
rows={5}
value={quickNoteContent}
onChange={e => setQuickNoteContent(e.target.value)}
placeholder="Paste your latest notes here..."
/>
</label>
<label>
Keywords <span className="admin-field-hint">(comma-separated, optional)</span>
<input
type="text"
value={quickNoteKeywords}
onChange={e => setQuickNoteKeywords(e.target.value)}
placeholder="e.g. titus, grace, discipleship"
/>
</label>
<label>
<input
type="checkbox"
checked={quickNotePriority}
onChange={e => setQuickNotePriority(e.target.checked)}
/>{' '}
Pin this as a priority answer
</label>
<div className="admin-chatbot-editor-btns">
<button
type="button"
className="btn-admin-reset"
onClick={populateQuickNoteFromRaw}
>
Suggest Title & Keywords
</button>
<button
type="button"
className="btn-admin-primary"
disabled={
chatbotSaving ||
!quickNoteContent.trim() ||
(quickNoteMode === 'new' && !quickNoteTitle.trim()) ||
(quickNoteMode === 'append' && !quickNoteTargetId)
}
onClick={saveQuickNote}
>
{chatbotSaving ? 'Saving…' : quickNoteMode === 'append' ? 'Append Notes' : 'Add Note Entry'}
</button>
</div>
</div>
{chatbotDraft && (
<div className="admin-chatbot-editor" ref={chatbotEditorRef}>
<h3>{chatbotDraft.id ? 'Edit Entry' : 'New Entry'}</h3>
<label>
Type
<select
value={chatbotDraft.type}
onChange={e => setChatbotDraft({ ...chatbotDraft, type: e.target.value as ChatbotEntry['type'] })}
>
<option value="qa">Q&amp;A</option>
<option value="topic">Topic Summary</option>
<option value="episode">Episode Notes</option>
</select>
</label>
<label>
{chatbotDraft.type === 'qa' ? 'Question' : 'Title'}
<input
type="text"
value={chatbotDraft.title}
onChange={e => setChatbotDraft({ ...chatbotDraft, title: e.target.value })}
placeholder={chatbotDraft.type === 'qa' ? 'e.g. What Bible translation do you use?' : 'e.g. The Sermon on the Mount'}
/>
</label>
<label>
{chatbotDraft.type === 'qa' ? 'Answer' : 'Content / Summary'}
<textarea
rows={5}
value={chatbotDraft.content}
onChange={e => setChatbotDraft({ ...chatbotDraft, content: e.target.value })}
placeholder="Write the answer or summary here..."
/>
</label>
<label>
Source Label <span className="admin-field-hint">(optional)</span>
<input
type="text"
value={chatbotDraft.sourceLabel ?? ''}
onChange={e => setChatbotDraft({ ...chatbotDraft, sourceLabel: e.target.value })}
placeholder="e.g. Episode 12, Titus 3:4-7"
/>
</label>
<label>
Keywords <span className="admin-field-hint">(comma-separated, optional)</span>
<input
type="text"
value={Array.isArray(chatbotDraft.keywords) ? chatbotDraft.keywords.join(', ') : chatbotDraft.keywords}
onChange={e => setChatbotDraft({ ...chatbotDraft, keywords: e.target.value as unknown as string[] })}
placeholder="e.g. bible, translation, ESV, study"
/>
</label>
<label>
<input
type="checkbox"
checked={chatbotDraft.priority === true}
onChange={e => setChatbotDraft({ ...chatbotDraft, priority: e.target.checked })}
/>{' '}
Pin this as a priority answer
</label>
<div className="admin-chatbot-editor-btns">
<button
type="button"
className="btn-admin-primary"
disabled={!chatbotDraft.title.trim() || !chatbotDraft.content.trim() || chatbotSaving}
onClick={() => saveEntry(chatbotDraft)}
>
{chatbotSaving ? 'Saving…' : 'Save Entry'}
</button>
<button type="button" className="btn-admin-reset" onClick={() => setChatbotDraft(null)}>
Cancel
</button>
</div>
</div>
)}
{chatbotEntries.length === 0 ? (
<p className="admin-empty-msg">No entries yet. Add your first one above.</p>
) : (
<div className="admin-chatbot-list">
{sortedChatbotEntries.map(entry => (
<div key={entry.id} className="admin-chatbot-card">
<div className="admin-chatbot-card-head">
<span className={`admin-badge admin-badge--${entry.type}`}>{TYPE_LABELS[entry.type]}</span>
<span className="admin-chatbot-title">{entry.title}</span>
{entry.priority === true && <span className="admin-badge admin-badge--topic">Priority</span>}
</div>
{entry.sourceLabel && <p className="admin-chatbot-keywords">Source: {entry.sourceLabel}</p>}
<p className="admin-chatbot-preview">{entry.content.slice(0, 180)}{entry.content.length > 180 ? '…' : ''}</p>
{entry.keywords.length > 0 && (
<p className="admin-chatbot-keywords">🏷 {entry.keywords.join(', ')}</p>
)}
<div className="admin-question-actions">
<button
type="button"
className="btn-admin-reset"
onClick={() => setChatbotDraft({ ...entry, keywords: entry.keywords })}
disabled={!!chatbotDraft}
>
Edit
</button>
<button
type="button"
className="btn-admin-remove"
onClick={() => deleteEntry(entry.id)}
disabled={chatbotSaving}
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</section>
)
})()}
</div>
</div>
)
+778 -1
View File
@@ -511,6 +511,92 @@
}
/* ── Contact ── */
.section-chatbot-feature {
padding: 5rem 0;
background:
radial-gradient(circle at top left, rgba(200, 134, 10, 0.14), transparent 38%),
linear-gradient(180deg, rgba(22, 18, 10, 0.96), rgba(11, 11, 11, 0.98));
border-top: 1px solid rgba(200, 134, 10, 0.18);
border-bottom: 1px solid rgba(200, 134, 10, 0.18);
}
.chatbot-feature-inner {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(300px, 0.85fr);
gap: 2rem;
align-items: stretch;
}
.chatbot-feature-copy,
.chatbot-feature-card {
background: rgba(18, 18, 18, 0.84);
border: 1px solid rgba(200, 134, 10, 0.2);
border-radius: 18px;
padding: 1.7rem;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
}
.chatbot-feature-copy .section-heading {
text-align: left;
margin-bottom: 1rem;
}
.chatbot-feature-lead,
.chatbot-feature-sub,
.chatbot-feature-kicker {
font-family: 'Barlow Condensed', sans-serif;
margin: 0;
color: #d9c9a0;
}
.chatbot-feature-lead {
font-size: clamp(1.2rem, 2vw, 1.55rem);
line-height: 1.45;
color: #f0e6d0;
text-wrap: balance;
}
.chatbot-feature-sub {
margin-top: 0.85rem;
font-size: 1.02rem;
line-height: 1.65;
color: #ab9568;
}
.chatbot-feature-actions {
margin-top: 1.35rem;
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
}
.chatbot-feature-card {
display: flex;
flex-direction: column;
justify-content: center;
}
.chatbot-feature-kicker {
font-size: 0.82rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #c8860a;
margin-bottom: 1rem;
}
.chatbot-feature-prompts {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.chatbot-prompt-btn--feature {
font-size: 0.86rem;
padding: 0.55rem 0.9rem;
text-align: left;
color: #efd8a1;
}
.section-contact {
background: #090909;
border-top: 1px solid rgba(200, 134, 10, 0.18);
@@ -1083,11 +1169,21 @@
.admin-top-tabs {
display: flex;
gap: 0.65rem;
flex-wrap: wrap;
gap: 0.65rem;
margin-bottom: 1.1rem;
}
.admin-top-tabs .admin-tab {
flex: 1 1 180px;
min-width: 180px;
max-width: 100%;
text-align: center;
white-space: normal;
line-height: 1.2;
overflow-wrap: anywhere;
}
.admin-stats {
background: #0d0d0d;
border: 1px solid rgba(200, 134, 10, 0.22);
@@ -1679,6 +1775,19 @@
text-align: center;
}
.chatbot-feature-inner {
grid-template-columns: 1fr;
}
.chatbot-feature-copy .section-heading {
text-align: center;
}
.chatbot-feature-actions,
.chatbot-feature-prompts {
justify-content: center;
}
.contact-copy .section-heading {
text-align: center;
}
@@ -1703,6 +1812,7 @@
.section-series,
.section-guide,
.section-qr,
.section-chatbot-feature,
.section-contact {
padding: 3.5rem 0;
}
@@ -1722,4 +1832,671 @@
.header-nav a:not(.header-cta) {
display: none;
}
.admin-top-tabs .admin-tab {
flex-basis: 100%;
min-width: 0;
}
}
/* ── Q&A Section ── */
.section-qa {
background: rgba(200, 134, 10, 0.05);
border-top: 1px solid rgba(200, 134, 10, 0.15);
}
.qa-search {
margin-bottom: 2.5rem;
display: flex;
gap: 1rem;
align-items: flex-end;
}
.qa-search label {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.qa-search label span {
font-size: 0.95rem;
font-weight: 600;
color: #c8860a;
}
.qa-search input {
padding: 0.75rem 1rem;
background: rgba(30, 30, 30, 0.8);
border: 1px solid rgba(200, 134, 10, 0.3);
color: #f0e6d0;
font-family: 'Barlow', sans-serif;
border-radius: 0.375rem;
transition: border-color 0.2s ease;
}
.qa-search input:focus {
outline: none;
border-color: #c8860a;
}
.qa-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.75rem;
}
/* ── 3D flip card ── */
.qa-card-scene {
perspective: 1000px;
height: 260px;
}
.qa-card-inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.6s cubic-bezier(0.45, 0, 0.55, 1);
cursor: pointer;
border-radius: 0.5rem;
}
.qa-card-inner.flipped {
transform: rotateY(180deg);
}
.qa-card-face {
position: absolute;
inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
border-radius: 0.5rem;
padding: 1.75rem;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 0.75rem;
border: 1px solid rgba(200, 134, 10, 0.25);
transition: border-color 0.2s ease;
}
.qa-card-inner:hover .qa-card-face {
border-color: rgba(200, 134, 10, 0.55);
}
.qa-card-front {
background: rgba(35, 30, 20, 0.85);
}
.qa-card-back {
background: rgba(20, 30, 20, 0.92);
transform: rotateY(180deg);
overflow-y: auto;
}
.qa-face-label {
font-size: 2rem;
font-weight: 700;
color: #c8860a;
line-height: 1;
flex-shrink: 0;
}
.qa-question-text {
margin: 0;
font-size: 1rem;
line-height: 1.55;
color: #e8d8b0;
flex: 1;
}
.qa-answer-text {
margin: 0;
font-size: 0.95rem;
line-height: 1.6;
color: #c8d8b0;
flex: 1;
overflow-y: auto;
}
.qa-flip-hint {
font-size: 0.75rem;
color: #a89060;
margin-top: auto;
opacity: 0.75;
font-style: italic;
}
.qa-no-results {
text-align: center;
padding: 2rem;
color: #a89060;
}
.qa-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1.25rem;
margin-top: 2rem;
}
.qa-page-btn {
background: none;
border: 1px solid rgba(200, 134, 10, 0.4);
color: #c8860a;
padding: 0.45rem 1.1rem;
border-radius: 0.35rem;
cursor: pointer;
font-size: 0.95rem;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.qa-page-btn:hover:not(:disabled) {
background: rgba(200, 134, 10, 0.12);
border-color: #c8860a;
}
.qa-page-btn:disabled {
opacity: 0.3;
cursor: default;
}
.qa-page-info {
font-size: 0.9rem;
color: #a89060;
min-width: 4rem;
text-align: center;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Admin Q&A Styles ── */
.admin-questions {
padding: 2rem;
}
.admin-questions-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.admin-question-card {
background: rgba(50, 50, 50, 0.5);
border: 1px solid var(--border-color, #444);
border-radius: 0.375rem;
padding: 1.5rem;
transition: background 0.2s ease;
}
.admin-question-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.admin-question-meta {
font-size: 0.85rem;
color: #999;
margin: 0 0 0.5rem 0;
}
.admin-question-text {
margin: 0;
line-height: 1.5;
color: #f0e6d0;
}
.admin-question-status {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.admin-badge {
display: inline-block;
font-size: 0.75rem;
font-weight: 600;
padding: 0.35rem 0.75rem;
border-radius: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.admin-badge--pending {
background: rgba(255, 152, 0, 0.15);
color: #gf8c00;
}
.admin-badge--approved {
background: rgba(76, 175, 80, 0.15);
color: #4caf50;
}
.admin-badge--answered {
background: rgba(200, 134, 10, 0.15);
color: #c8860a;
}
.admin-question-answer {
background: rgba(30, 30, 30, 0.8);
padding: 1rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
border-left: 3px solid #c8860a;
}
.admin-question-answer p {
margin: 0;
line-height: 1.6;
color: #e8d4c0;
}
.admin-question-editor {
margin-top: 1rem;
padding: 1rem;
background: rgba(30, 30, 30, 0.6);
border-radius: 0.25rem;
}
.admin-question-editor textarea {
width: 100%;
padding: 0.75rem;
background: rgba(10, 10, 10, 0.8);
border: 1px solid rgba(200, 134, 10, 0.3);
color: #f0e6d0;
font-family: 'Barlow', monospace;
border-radius: 0.25rem;
resize: vertical;
}
.admin-question-editor textarea:focus {
outline: none;
border-color: #c8860a;
}
.admin-question-editor-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.admin-question-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.admin-question-actions button {
padding: 0.5rem 1rem;
font-size: 0.85rem;
border-radius: 0.25rem;
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
/* ══════════════════════════════════════════════════════════
FLOATING CHATBOT
══════════════════════════════════════════════════════════ */
/* Bubble trigger */
.chatbot-bubble {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 1000;
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
background: #c8860a;
color: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.45);
transition: background 0.2s, transform 0.2s;
}
.chatbot-bubble:hover {
background: #e8a91a;
transform: scale(1.08);
}
.chatbot-bubble--open {
background: #555;
}
/* Panel */
.chatbot-panel {
position: fixed;
bottom: 5.5rem;
right: 1.5rem;
z-index: 999;
width: min(360px, calc(100vw - 2rem));
max-height: min(520px, calc(100vh - 8rem));
display: flex;
flex-direction: column;
background: #1a1a1a;
border: 1px solid rgba(200, 134, 10, 0.35);
border-radius: 0.75rem;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
overflow: hidden;
animation: chatSlideUp 0.22s ease;
}
@keyframes chatSlideUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* Header */
.chatbot-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1rem;
background: rgba(200, 134, 10, 0.12);
border-bottom: 1px solid rgba(200, 134, 10, 0.25);
font-size: 0.9rem;
font-weight: 600;
color: #e8c87a;
}
.chatbot-close {
background: none;
border: none;
color: #a89060;
font-size: 1rem;
cursor: pointer;
line-height: 1;
padding: 0 0.25rem;
transition: color 0.15s;
}
.chatbot-close:hover { color: #e8c87a; }
/* Message list */
.chatbot-messages {
flex: 1;
overflow-y: auto;
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.chatbot-msg {
max-width: 85%;
padding: 0.6rem 0.85rem;
border-radius: 0.65rem;
font-size: 0.88rem;
line-height: 1.5;
}
.chatbot-msg p {
margin: 0;
}
.chatbot-msg p + p {
margin-top: 0.4rem;
}
.chatbot-msg--bot {
background: rgba(200, 134, 10, 0.12);
border: 1px solid rgba(200, 134, 10, 0.2);
color: #e8d8b0;
align-self: flex-start;
border-bottom-left-radius: 0.15rem;
}
.chatbot-msg--user {
background: rgba(80, 80, 80, 0.45);
color: #ddd;
align-self: flex-end;
border-bottom-right-radius: 0.15rem;
}
/* Typing dots */
.chatbot-msg--typing {
display: flex;
gap: 0.3rem;
align-items: center;
padding: 0.75rem 1rem;
}
.chatbot-msg--typing span {
width: 7px;
height: 7px;
border-radius: 50%;
background: #c8860a;
animation: typingDot 1.2s infinite;
}
.chatbot-msg--typing span:nth-child(2) { animation-delay: 0.2s; }
.chatbot-msg--typing span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typingDot {
0%, 80%, 100% { opacity: 0.2; transform: scale(0.85); }
40% { opacity: 1; transform: scale(1.1); }
}
/* Suggested prompts */
.chatbot-prompts {
padding: 0 0.75rem 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.chatbot-prompt-btn {
background: rgba(200, 134, 10, 0.1);
border: 1px solid rgba(200, 134, 10, 0.3);
color: #c8860a;
border-radius: 1rem;
padding: 0.3rem 0.75rem;
font-size: 0.78rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.chatbot-prompt-btn:hover {
background: rgba(200, 134, 10, 0.22);
border-color: #c8860a;
}
/* Input row */
.chatbot-form {
display: flex;
align-items: center;
border-top: 1px solid rgba(200, 134, 10, 0.2);
padding: 0.6rem 0.75rem;
gap: 0.5rem;
}
.chatbot-input {
flex: 1;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(200, 134, 10, 0.25);
border-radius: 0.4rem;
color: #e8d8b0;
padding: 0.45rem 0.75rem;
font-size: 0.88rem;
outline: none;
transition: border-color 0.2s;
}
.chatbot-input::placeholder { color: #7a6a50; }
.chatbot-input:focus { border-color: #c8860a; }
.chatbot-send {
background: #c8860a;
border: none;
border-radius: 0.4rem;
color: #fff;
width: 2.2rem;
height: 2.2rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s;
}
.chatbot-send:hover:not(:disabled) { background: #e8a91a; }
.chatbot-send:disabled { opacity: 0.35; cursor: default; }
/* ══════════════════════════════════════════════════════════
ADMIN — CHATBOT TAB
══════════════════════════════════════════════════════════ */
.admin-chatbot {
padding: 2rem;
}
.admin-chatbot-actions {
margin: 1.25rem 0;
}
.btn-admin-primary {
background: #c8860a;
color: #fff;
border: none;
border-radius: 0.35rem;
padding: 0.55rem 1.25rem;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s;
}
.btn-admin-primary:hover:not(:disabled) { background: #e8a91a; }
.btn-admin-primary:disabled { opacity: 0.4; cursor: default; }
.admin-chatbot-editor {
background: rgba(50, 50, 50, 0.5);
border: 1px solid rgba(200, 134, 10, 0.25);
border-radius: 0.5rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.admin-chatbot-editor h3 {
margin: 0 0 0.5rem;
color: #c8860a;
font-size: 1rem;
}
.admin-chatbot-editor label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.88rem;
color: #b0a080;
}
.admin-chatbot-editor input,
.admin-chatbot-editor select,
.admin-chatbot-editor textarea {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(200, 134, 10, 0.3);
border-radius: 0.35rem;
color: #e8d8b0;
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s;
font-family: inherit;
}
.admin-chatbot-editor input:focus,
.admin-chatbot-editor select:focus,
.admin-chatbot-editor textarea:focus {
border-color: #c8860a;
}
.admin-chatbot-editor textarea {
resize: vertical;
min-height: 100px;
}
.admin-field-hint {
font-size: 0.78rem;
color: #7a6a50;
}
.admin-chatbot-editor-btns {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.admin-chatbot-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.admin-chatbot-card {
background: rgba(50, 50, 50, 0.45);
border: 1px solid rgba(200, 134, 10, 0.18);
border-radius: 0.45rem;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.admin-chatbot-card-head {
display: flex;
align-items: center;
gap: 0.65rem;
flex-wrap: wrap;
}
.admin-chatbot-title {
font-size: 0.95rem;
color: #e8d8b0;
font-weight: 500;
}
.admin-badge--topic { background: rgba(30,100,180,0.3); color: #88aaff; border: 1px solid rgba(100,160,255,0.3); }
.admin-badge--episode { background: rgba(100,60,180,0.3); color: #cc99ff; border: 1px solid rgba(160,100,255,0.3); }
.admin-chatbot-preview {
font-size: 0.85rem;
color: #9a8a6a;
margin: 0;
line-height: 1.5;
}
.admin-chatbot-keywords {
font-size: 0.78rem;
color: #7a6a50;
margin: 0;
}
.admin-empty-msg {
color: #7a6a50;
font-style: italic;
padding: 1rem 0;
}
+506 -2
View File
@@ -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">