Add episode transcripts, expand chatbot Q&A database to 28 entries, and build scripts
This commit is contained in:
+638
-43
@@ -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&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&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&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
@@ -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
@@ -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