Add cross-site improvements across three phases

Phase 1 — Quick wins:
- Image lazy-loading on series/resource cards
- Newsletter signup added to Episodes page (before highlights)
- Per-route meta tags via usePageMeta hook (title, og:title, og:description)
- Breadcrumbs on study index, section, and notes pages
- SVG completion checkmark badges on study section list
- Analytics time-range filter (7d / 30d / 90d) in admin panel

Phase 2 — Medium features:
- Related episodes on archived series detail pages
- Resource library two-tier filter (type + tag chips)
- Global search (Fuse.js) moved below sticky header as full-width bar
- Q&A anonymous upvoting with localStorage dedup + admin pin/unpin
- Study enrollment funnel tracking (firstVisitAt, firstCompletionAt) with funnel chart in analytics

Phase 3 — Larger features:
- Study section comments (auto-approve for enrolled users, admin moderation panel)
- Study completion certificate (canvas render, PNG download, shareable public URL)
- Episode script full-text search (mammoth docx extraction, server-side search, admin upload UI)
- Reflection questions renamed from Discussion Questions; quiz answers can be shared to section discussion
- Public certificate route at /certificate/:token with og meta tags
- Comment moderation panel added to admin under Manage > Study Comments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-06-09 09:10:29 -04:00
parent 9ad24df626
commit c4645e3475
24 changed files with 2991 additions and 76 deletions
+287 -5
View File
@@ -553,6 +553,7 @@ export interface AdminStats {
displayName: string
enrolledStudies: Array<{ slug: string; title: string }>
}>
funnel?: { signups: number; firstVisit: number; firstCompletion: number }
}
}
@@ -587,6 +588,8 @@ interface Question {
answeredAt: string | null
isApproved: boolean
approvedAt: string | null
upvotes?: number
pinned?: boolean
}
interface ContactSubmission {
@@ -670,7 +673,7 @@ type AdminView =
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
| 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series'
| 'downloads' | 'custom-links' | 'content-blocks'
| 'questions' | 'analytics' | 'assets' | 'colossians-study'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study'
| 'emails' | 'subscribers' | 'contacts' | 'study-users' | 'email-templates'
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
@@ -707,6 +710,7 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi
group: 'Manage',
options: [
{ value: 'questions', label: 'Questions' },
{ value: 'study-comments', label: 'Study Comments' },
{ value: 'emails', label: 'Emails' },
{ value: 'contacts', label: 'Contacts' },
{ value: 'subscribers', label: 'Subscribers' },
@@ -774,7 +778,7 @@ const ADMIN_SECTION_LINKS: Partial<Record<AdminView, AdminSectionLink[]>> = {
],
}
type PodcastTab = 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series'
type PodcastTab = 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series' | 'episode-scripts'
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'prism' | 'global' | 'email-templates'
@@ -1019,6 +1023,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [podcastTab, setPodcastTab] = useState<PodcastTab>('current-series')
const [stats, setStats] = useState<AdminStats | null>(null)
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [statsRange, setStatsRange] = useState<'7d' | '30d' | '90d'>('30d')
const [maintenanceMsg, setMaintenanceMsg] = useState('')
const [opsMsg, setOpsMsg] = useState('')
const [backupFiles, setBackupFiles] = useState<BackupPreview[]>([])
@@ -1063,6 +1068,23 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [subscriberSearch, setSubscriberSearch] = useState('')
const [contactSearch, setContactSearch] = useState('')
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
// Study comments moderation state
interface AdminComment { id: string; studySlug: string; sectionId: string; displayName: string; text: string; createdAt: string; isApproved: boolean; approvedAt: string | null }
const [studyComments, setStudyComments] = useState<AdminComment[]>([])
const [commentsLoading, setCommentsLoading] = useState(false)
const [commentsLoaded, setCommentsLoaded] = useState(false)
const [commentFilter, setCommentFilter] = useState<'all' | 'pending' | 'approved'>('all')
const [commentSearch, setCommentSearch] = useState('')
// Episode scripts state
const [scriptsList, setScriptsList] = useState<Array<{ episodeNumber: string; title: string; filename: string; uploadedAt: string | null; wordCount: number }>>([])
const [scriptsLoading, setScriptsLoading] = useState(false)
const [scriptUploadEpisode, setScriptUploadEpisode] = useState('')
const [scriptUploadTitle, setScriptUploadTitle] = useState('')
const [scriptUploadFile, setScriptUploadFile] = useState<File | null>(null)
const [scriptUploadBusy, setScriptUploadBusy] = useState(false)
const [scriptUploadMsg, setScriptUploadMsg] = useState('')
const [podcastChecklist, setPodcastChecklist] = useState<PodcastChecklistData>({ tasks: [], episodes: [] })
const [podcastChecklistStatus, setPodcastChecklistStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [podcastChecklistMsg, setPodcastChecklistMsg] = useState('')
@@ -1206,7 +1228,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}, [])
useEffect(() => {
fetch('/api/admin-stats')
fetch(`/api/admin-stats?range=${statsRange}`)
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load stats'))))
.then(data => {
setStats(data as AdminStats)
@@ -1329,8 +1351,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
})
}, [selectedBackup])
async function reloadStats() {
const r = await fetch('/api/admin-stats')
async function reloadStats(range?: '7d' | '30d' | '90d') {
const r = await fetch(`/api/admin-stats?range=${range ?? statsRange}`)
if (!r.ok) throw new Error('Could not refresh stats')
const data = await r.json()
setStats(data as AdminStats)
@@ -2607,6 +2629,99 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}
}
async function handlePinQuestion(questionId: string, pin: boolean) {
try {
const method = pin ? 'POST' : 'DELETE'
const res = await fetch(`/api/admin-questions/${questionId}/pin`, { method })
if (!res.ok) throw new Error('Failed to update pin')
const data = await res.json().catch(() => ({})) as { question?: Question }
if (data.question) {
setQuestions(items => items.map(q => q.id === questionId ? { ...q, pinned: data.question!.pinned } : q))
}
} catch (err) {
console.error(err)
}
}
async function loadStudyComments() {
setCommentsLoading(true)
try {
const res = await fetch('/api/admin-study-comments')
const data = await res.json()
setStudyComments(Array.isArray(data.comments) ? data.comments : [])
setCommentsLoaded(true)
} catch { /* ignore */ }
finally { setCommentsLoading(false) }
}
async function handleApproveComment(commentId: string) {
try {
const res = await fetch(`/api/admin-study-comments/${commentId}/approve`, { method: 'POST' })
if (!res.ok) return
setStudyComments(prev => prev.map(c => c.id === commentId ? { ...c, isApproved: true, approvedAt: new Date().toISOString() } : c))
} catch { /* ignore */ }
}
async function handleDeleteComment(commentId: string) {
if (!confirm('Delete this comment permanently?')) return
try {
const res = await fetch(`/api/admin-study-comments/${commentId}`, { method: 'DELETE' })
if (!res.ok) return
setStudyComments(prev => prev.filter(c => c.id !== commentId))
} catch { /* ignore */ }
}
async function loadScriptsList() {
setScriptsLoading(true)
try {
const res = await fetch('/api/admin-episode-scripts')
const data = await res.json()
setScriptsList(Array.isArray(data.scripts) ? data.scripts : [])
} catch { /* ignore */ }
finally { setScriptsLoading(false) }
}
async function handleScriptUpload() {
if (!scriptUploadEpisode.trim() || !/^\d+$/.test(scriptUploadEpisode.trim())) {
setScriptUploadMsg('Enter a valid episode number.'); return
}
if (!scriptUploadFile) { setScriptUploadMsg('Select a .docx file.'); return }
setScriptUploadBusy(true)
setScriptUploadMsg('')
try {
const reader = new FileReader()
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = e => resolve(e.target?.result as string)
reader.onerror = reject
reader.readAsDataURL(scriptUploadFile)
})
const res = await fetch(`/api/admin-episode-scripts/${encodeURIComponent(scriptUploadEpisode.trim())}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dataUrl, filename: scriptUploadFile.name, title: scriptUploadTitle.trim() || `Episode ${scriptUploadEpisode.trim()}` }),
})
const data = await res.json()
if (!res.ok) { setScriptUploadMsg(data.message ?? 'Upload failed.'); return }
setScriptUploadMsg(`✓ Episode ${scriptUploadEpisode} uploaded — ${data.wordCount?.toLocaleString()} words indexed.`)
setScriptUploadEpisode('')
setScriptUploadTitle('')
setScriptUploadFile(null)
loadScriptsList()
} catch {
setScriptUploadMsg('Upload failed. Please try again.')
} finally {
setScriptUploadBusy(false)
}
}
async function handleDeleteScript(episodeNumber: string) {
if (!confirm(`Remove script for Episode ${episodeNumber}?`)) return
try {
await fetch(`/api/admin-episode-scripts/${encodeURIComponent(episodeNumber)}`, { method: 'DELETE' })
setScriptsList(prev => prev.filter(s => s.episodeNumber !== episodeNumber))
} catch { alert('Failed to delete script.') }
}
async function handleDeleteQuestion(questionId: string) {
if (!confirm('Delete this question permanently?')) return
try {
@@ -3640,6 +3755,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<button type="button" className={`admin-tab${podcastTab === 'episode-highlights' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-highlights')}>Ep. Highlights</button>
<button type="button" className={`admin-tab${podcastTab === 'podcast-checklist' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('podcast-checklist')}>Production Checklist</button>
<button type="button" className={`admin-tab${podcastTab === 'archived-series' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('archived-series')}>Archived Series</button>
<button type="button" className={`admin-tab${podcastTab === 'episode-scripts' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-scripts')}>Episode Scripts</button>
</div>
</section>
)}
@@ -4646,6 +4762,163 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</section>
)}
{/* EPISODE SCRIPTS */}
{(adminView === 'podcast' && podcastTab === 'episode-scripts') && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Episode Scripts</h2>
<p>Upload a Word (.docx) script for each episode to make the full content searchable from the site&apos;s global search bar.</p>
</div>
{/* Upload form */}
<div className="admin-script-upload-form">
<div className="admin-field">
<label htmlFor="script-episode-num">Episode Number</label>
<input
id="script-episode-num"
type="number"
min="1"
placeholder="e.g. 42"
value={scriptUploadEpisode}
onChange={e => setScriptUploadEpisode(e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor="script-episode-title">Episode Title <span style={{ color: '#5a5440', fontWeight: 400 }}>(optional will be pulled from search results)</span></label>
<input
id="script-episode-title"
type="text"
placeholder="e.g. Colossians 1:15 — The Image of God"
value={scriptUploadTitle}
onChange={e => setScriptUploadTitle(e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor="script-file">Word Document (.docx)</label>
<input
id="script-file"
type="file"
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
onChange={e => setScriptUploadFile(e.target.files?.[0] ?? null)}
/>
</div>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<button type="button" className="btn-admin-save" onClick={handleScriptUpload} disabled={scriptUploadBusy}>
{scriptUploadBusy ? 'Uploading…' : 'Upload Script'}
</button>
{scriptUploadMsg && <span style={{ fontSize: '0.85rem', color: scriptUploadMsg.startsWith('✓') ? '#6fcf97' : '#e07a7a' }}>{scriptUploadMsg}</span>}
</div>
</div>
{/* Indexed scripts list */}
<div className="admin-panel-head" style={{ marginTop: '2rem' }}>
<h3 style={{ margin: 0 }}>Indexed Scripts</h3>
<button type="button" className="btn-admin-reset" onClick={loadScriptsList} disabled={scriptsLoading} style={{ marginLeft: 'auto' }}>
{scriptsLoading ? 'Loading…' : 'Refresh'}
</button>
</div>
{scriptsList.length === 0 ? (
<p style={{ color: '#5a5440', fontSize: '0.9rem', marginTop: '0.75rem' }}>
No scripts uploaded yet.{' '}
<button type="button" className="btn-admin-reset" style={{ display: 'inline', padding: '0.1rem 0.5rem' }} onClick={loadScriptsList}>Load list</button>
</p>
) : (
<div className="admin-script-list">
{scriptsList.map(script => (
<div key={script.episodeNumber} className="admin-script-row">
<div className="admin-script-row-info">
<span className="admin-script-ep">Ep. {script.episodeNumber}</span>
<span className="admin-script-title">{script.title}</span>
<span className="admin-script-meta">{script.wordCount.toLocaleString()} words · {script.filename}</span>
</div>
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteScript(script.episodeNumber)}>Remove</button>
</div>
))}
</div>
)}
</section>
)}
{/* STUDY COMMENTS */}
{adminView === 'study-comments' && (() => {
const filtered = studyComments.filter(c => {
if (commentFilter === 'pending') return !c.isApproved
if (commentFilter === 'approved') return c.isApproved
return true
}).filter(c => {
if (!commentSearch.trim()) return true
const q = commentSearch.toLowerCase()
return c.displayName.toLowerCase().includes(q) || c.text.toLowerCase().includes(q) || c.studySlug.includes(q) || c.sectionId.includes(q)
})
return (
<section className="admin-panel-section" aria-label="Study Comment Moderation">
<div className="admin-panel-head">
<h2>Study Comments</h2>
<p>Review, approve, and delete comments left on study section pages.</p>
</div>
<div className="admin-actions admin-actions--maintenance" style={{ alignItems: 'center' }}>
<input
type="text"
value={commentSearch}
onChange={e => setCommentSearch(e.target.value)}
placeholder="Search by name, text, or section…"
style={{ minWidth: '280px', maxWidth: '480px', width: '100%' }}
/>
<button type="button" className={`btn-admin-reset${commentFilter === 'all' ? ' btn-admin-reset--active' : ''}`} onClick={() => setCommentFilter('all')}>All</button>
<button type="button" className={`btn-admin-reset${commentFilter === 'pending' ? ' btn-admin-reset--active' : ''}`} onClick={() => setCommentFilter('pending')}>Pending</button>
<button type="button" className={`btn-admin-reset${commentFilter === 'approved' ? ' btn-admin-reset--active' : ''}`} onClick={() => setCommentFilter('approved')}>Approved</button>
<button type="button" className="btn-admin-reset" onClick={loadStudyComments} disabled={commentsLoading} style={{ marginLeft: 'auto' }}>
{commentsLoading ? 'Loading…' : 'Refresh'}
</button>
</div>
{!commentsLoaded ? (
<div style={{ marginTop: '1.5rem' }}>
<button type="button" className="btn-admin-reset" onClick={loadStudyComments} disabled={commentsLoading}>
{commentsLoading ? 'Loading…' : 'Load Comments'}
</button>
</div>
) : filtered.length === 0 ? (
<p className="admin-stats-note" style={{ marginTop: '1rem' }}>
{commentFilter === 'pending' ? 'No pending comments.' : commentFilter === 'approved' ? 'No approved comments.' : 'No comments yet.'}
</p>
) : (
<>
<p className="admin-stats-note">Showing {filtered.length} of {studyComments.length} comments.</p>
<div className="admin-comment-list">
{filtered.map(comment => (
<div key={comment.id} className={`admin-comment-card${comment.isApproved ? '' : ' admin-comment-card--pending'}`}>
<div className="admin-comment-card-header">
<div className="admin-comment-card-meta">
<span className="admin-comment-author">{comment.displayName}</span>
<span className="admin-comment-location">{comment.studySlug} {comment.sectionId}</span>
<span className="admin-comment-date">{new Date(comment.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</span>
</div>
{!comment.isApproved && (
<span className="admin-comment-badge admin-comment-badge--pending">Pending</span>
)}
{comment.isApproved && (
<span className="admin-comment-badge admin-comment-badge--approved">Approved</span>
)}
</div>
<p className="admin-comment-text">{comment.text}</p>
<div className="admin-comment-actions">
{!comment.isApproved && (
<button type="button" className="btn-admin-save" onClick={() => handleApproveComment(comment.id)}>Approve</button>
)}
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteComment(comment.id)}>Delete</button>
</div>
</div>
))}
</div>
</>
)}
</section>
)
})()}
{/* EMAIL TEMPLATES */}
{adminView === 'email-templates' && (
<EmailTemplatesPanel form={form} handleChange={handleChange} renderSaveStatus={renderSaveStatus} />
@@ -4815,6 +5088,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<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-${question.pinned ? 'remove' : 'reset'}`} onClick={() => handlePinQuestion(question.id, !question.pinned)}>
{question.pinned ? '📌 Unpin' : '📌 Pin'}
</button>
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteQuestion(question.id)}>Delete</button>
</div>
)}
@@ -4855,6 +5131,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<AnalyticsPanel
stats={stats}
statsStatus={statsStatus}
statsRange={statsRange}
onRangeChange={async (range) => {
setStatsRange(range)
setStatsStatus('loading')
try { await reloadStats(range) } catch { setStatsStatus('error') }
}}
opsStatus={opsStatus}
formatDate={formatDate}
maskIp={maskIp}
+734
View File
@@ -26,8 +26,131 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
/* ── Global Search ── */
/* Search bar strip below the sticky header */
.site-search-bar {
background: #13120d;
border-bottom: 1px solid #28241a;
position: sticky;
top: 72px; /* sits flush below the header */
z-index: 99;
}
.site-search-bar-inner {
max-width: 860px;
margin: 0 auto;
padding: 0.5rem 1.5rem;
}
.global-search {
position: relative;
width: 100%;
}
.global-search-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.global-search-icon {
position: absolute;
left: 0.65rem;
color: #7a7060;
pointer-events: none;
display: flex;
align-items: center;
}
.global-search-input {
width: 100%;
padding: 0.4rem 2rem 0.4rem 2.1rem;
background: rgba(255,255,255,0.05);
border: 1px solid #2a2518;
border-radius: 20px;
color: #f0ead8;
font-size: 0.82rem;
font-family: var(--brand-font-body);
outline: none;
transition: border-color 0.15s, background 0.15s;
}
.global-search-input::placeholder { color: #5a5440; }
.global-search-input:focus {
border-color: rgba(201,168,76,0.5);
background: rgba(255,255,255,0.08);
}
.global-search-input::-webkit-search-cancel-button { display: none; }
.global-search-clear {
position: absolute;
right: 0.55rem;
background: none;
border: none;
color: #7a7060;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
padding: 0 0.2rem;
}
.global-search-clear:hover { color: #f0ead8; }
.global-search-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
min-width: 320px;
background: #18170f;
border: 1px solid #3a3320;
border-radius: 14px;
box-shadow: 0 12px 40px rgba(0,0,0,0.55);
z-index: 200;
overflow: hidden;
}
.global-search-result {
width: 100%;
display: flex;
align-items: flex-start;
gap: 0.7rem;
padding: 0.7rem 1rem;
background: none;
border: none;
border-bottom: 1px solid #25231a;
cursor: pointer;
text-align: left;
color: inherit;
transition: background 0.1s;
}
.global-search-result:last-of-type { border-bottom: none; }
.global-search-result:hover,
.global-search-result--active { background: rgba(201,168,76,0.08); }
.global-search-result-icon { font-size: 1rem; flex-shrink: 0; margin-top: 1px; }
.global-search-result-body { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
.global-search-result-title {
color: #f0ead8;
font-size: 0.88rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.global-search-result-meta { display: flex; align-items: center; gap: 0; }
.global-search-result-type {
font-size: 0.72rem;
color: #c9a84c;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.global-search-result-sub { font-size: 0.72rem; color: #7a7060; }
.global-search-result-snippet {
font-size: 0.72rem;
color: #6a6050;
line-height: 1.45;
margin-top: 0.15rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.global-search-empty { padding: 0.85rem 1rem; color: #7a7060; font-size: 0.85rem; margin: 0; }
.global-search-hint { padding: 0.4rem 1rem; color: #3a3320; font-size: 0.7rem; margin: 0; border-top: 1px solid #25231a; }
.header-logo {
display: flex;
align-items: center;
@@ -1661,6 +1784,35 @@
align-items: start;
}
.study-breadcrumbs {
margin-bottom: 0.75rem;
}
.study-breadcrumbs-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0;
font-size: 0.8rem;
}
.study-breadcrumbs-link {
color: #7a7060;
text-decoration: none;
transition: color 0.15s;
}
.study-breadcrumbs-link:hover {
color: var(--brand-gold);
}
.study-breadcrumbs-current {
color: #b9b09b;
}
.study-breadcrumbs-sep {
color: #3a3320;
padding: 0 0.25rem;
}
.study-detail-back {
display: inline-block;
margin-bottom: 0.9rem;
@@ -1736,6 +1888,319 @@
margin-top: 0.5rem;
}
/* ── Reflection Questions (quiz page) ───────────────────────────────────── */
.quiz-question-block {
margin-bottom: 1.5rem;
}
.quiz-question-label {
margin: 0 0 0.5rem;
font-weight: 600;
color: var(--brand-warm-white);
line-height: 1.5;
}
.quiz-question-textarea {
width: 100%;
box-sizing: border-box;
padding: 0.9rem;
border-radius: 12px;
border: 1px solid #2a2518;
background: #14130f;
color: #f0ead8;
font-family: var(--brand-font-body);
font-size: 0.95rem;
resize: vertical;
}
.quiz-question-textarea:focus {
outline: none;
border-color: #c8952a;
}
.quiz-share-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
cursor: pointer;
user-select: none;
}
.quiz-share-toggle input[type="checkbox"] {
accent-color: #e0b840;
width: 15px;
height: 15px;
flex-shrink: 0;
}
.quiz-share-label {
font-size: 0.82rem;
color: #8a7f5a;
}
.quiz-share-label--done {
color: #6fcf97;
}
/* ── Study Section Comments ──────────────────────────────────────────────── */
.study-comments {
margin-top: 0.5rem;
}
.study-comments-heading {
font-family: var(--brand-font-header);
font-size: 1.15rem;
color: var(--brand-gold);
margin: 0 0 1rem;
}
.study-comments-loading,
.study-comments-empty,
.study-comments-enroll-note {
font-family: var(--brand-font-body);
color: #7a7060;
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.study-comments-enroll-note a {
color: var(--brand-gold);
text-decoration: underline;
}
.study-comments-list {
list-style: none;
margin: 0 0 1.25rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.study-comment {
background: #14130f;
border: 1px solid #2a2518;
border-radius: 10px;
padding: 0.85rem 1rem;
position: relative;
}
.study-comment-header {
display: flex;
gap: 0.75rem;
align-items: baseline;
margin-bottom: 0.4rem;
}
.study-comment-author {
font-family: var(--brand-font-header);
font-size: 0.9rem;
color: #e0c88a;
}
.study-comment-date {
font-family: var(--brand-font-body);
font-size: 0.78rem;
color: #5a5040;
}
.study-comment-text {
font-family: var(--brand-font-body);
font-size: 0.9rem;
color: #c8bfa8;
margin: 0;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.study-comment-delete {
margin-top: 0.5rem;
background: none;
border: none;
cursor: pointer;
font-size: 0.78rem;
color: #5a5040;
padding: 0;
text-decoration: underline;
}
.study-comment-delete:hover { color: #e07a7a; }
.study-comment-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.study-comment-form-label {
font-family: var(--brand-font-body);
font-size: 0.85rem;
color: #8a7f5a;
}
.study-comment-textarea {
width: 100%;
box-sizing: border-box;
background: #14130f;
border: 1px solid #2a2518;
border-radius: 10px;
color: #f0ead8;
font-family: var(--brand-font-body);
font-size: 0.9rem;
padding: 0.75rem 1rem;
resize: vertical;
}
.study-comment-textarea:focus {
outline: none;
border-color: #c8952a;
}
.study-comment-form-footer {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.study-comment-char-count {
font-size: 0.78rem;
color: #5a5040;
margin-right: auto;
}
.study-comment-error {
font-size: 0.85rem;
color: #e07a7a;
}
.study-comment-success {
font-size: 0.85rem;
color: #6fcf97;
}
.study-comment-submit {
background: var(--brand-gold);
color: #14100a;
border: none;
border-radius: 8px;
padding: 0.5rem 1.25rem;
font-family: var(--brand-font-header);
font-size: 0.85rem;
cursor: pointer;
font-weight: 700;
}
.study-comment-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Study Certificate Panel ──────────────────────────────────────────────── */
.study-cert-panel {
margin-top: 1.5rem;
background: #14130f;
border: 1px solid #2a2518;
border-radius: 14px;
padding: 1.5rem;
}
.study-cert-heading {
font-family: var(--brand-font-header);
font-size: 1.1rem;
color: var(--brand-gold);
margin: 0 0 1rem;
}
.study-cert-loading {
font-size: 0.9rem;
color: #7a7060;
}
.study-cert-progress-label {
font-size: 0.9rem;
color: #8a7f5a;
margin: 0 0 0.5rem;
}
.study-cert-progress-bar-wrap {
background: #27231b;
border-radius: 999px;
height: 10px;
overflow: hidden;
margin-bottom: 0.75rem;
}
.study-cert-progress-bar {
height: 100%;
background: #e0c070;
border-radius: 999px;
}
.study-cert-note {
font-size: 0.85rem;
color: #5a5040;
margin: 0;
}
.study-cert-eligible {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.study-cert-congrats {
font-size: 0.95rem;
color: #c8bfa8;
margin: 0;
}
.study-cert-error {
font-size: 0.85rem;
color: #e07a7a;
}
.study-cert-btn {
background: var(--brand-gold);
color: #14100a;
border: none;
border-radius: 8px;
padding: 0.6rem 1.4rem;
font-family: var(--brand-font-header);
font-weight: 700;
font-size: 0.9rem;
cursor: pointer;
}
.study-cert-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.study-cert-btn--secondary {
background: transparent;
color: var(--brand-gold);
border: 1px solid var(--brand-gold);
}
.study-cert-issued {
display: flex;
flex-direction: column;
gap: 1rem;
}
.study-cert-issued-label {
font-size: 0.85rem;
color: #7a7060;
margin: 0;
}
.study-cert-canvas {
width: 100%;
max-width: 900px;
height: auto;
border-radius: 8px;
}
.study-cert-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
/* ── Public Certificate Page ──────────────────────────────────────────────── */
.public-cert-page .thanks-card {
max-width: 540px;
}
.public-cert-title {
font-size: 1.8rem;
color: var(--brand-gold);
margin: 0.5rem 0;
}
.public-cert-divider {
width: 100%;
height: 1px;
background: #2a2518;
margin: 1rem 0;
}
.public-cert-label {
font-size: 0.9rem;
color: #7a7060;
margin: 0.25rem 0;
}
.public-cert-name {
font-family: var(--brand-font-header);
font-size: 1.6rem;
color: var(--brand-warm-white);
margin: 0.25rem 0 0.75rem;
}
.public-cert-study {
font-family: var(--brand-font-header);
font-size: 1.2rem;
color: #e0c88a;
margin: 0.25rem 0 0.75rem;
}
.public-cert-date {
font-size: 0.85rem;
color: #7a7060;
margin: 0;
}
.study-track-switcher {
margin: 1rem 0;
}
@@ -2853,6 +3318,61 @@
padding: 2rem 0;
}
.download-filter-bar {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 2rem;
}
/* Related episodes on download detail pages */
.download-related-episodes {
margin-top: 2.5rem;
padding-top: 2rem;
border-top: 1px solid #2a2518;
text-align: left;
}
.download-related-heading {
font-size: 1.1rem;
font-weight: 700;
color: #f0e9cc;
margin: 0 0 1rem;
}
.download-related-list {
display: grid;
gap: 0.6rem;
}
.download-related-ep {
display: block;
padding: 0.7rem 0.9rem;
border: 1px solid #2a2518;
border-radius: 10px;
text-decoration: none;
background: #11100d;
transition: border-color 0.15s, background 0.15s;
}
.download-related-ep:hover {
border-color: rgba(201,168,76,0.4);
background: rgba(201,168,76,0.05);
}
.download-related-ep-meta {
display: flex;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.download-related-ep-title {
margin: 0;
font-size: 0.9rem;
color: #f0ead8;
line-height: 1.4;
}
.download-library-group {
background: rgba(17, 17, 17, 0.82);
border: 1px solid rgba(201, 168, 76, 0.14);
@@ -3461,6 +3981,59 @@
font-size: 1rem;
}
/* Enrollment Funnel */
.admin-funnel {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.admin-funnel-step {
display: flex;
align-items: center;
gap: 1rem;
}
.admin-funnel-bar-wrap {
flex: 1;
background: #2a2620;
border-radius: 4px;
height: 20px;
overflow: hidden;
}
.admin-funnel-bar {
height: 100%;
background: linear-gradient(90deg, #e0b840, #c8952a);
border-radius: 4px;
transition: width 0.4s ease;
min-width: 4px;
}
.admin-funnel-label {
display: flex;
gap: 0.75rem;
align-items: center;
min-width: 260px;
}
.admin-funnel-step-name {
font-family: var(--brand-font-body);
color: var(--brand-warm-white);
font-size: 0.9rem;
flex: 1;
}
.admin-funnel-count {
font-family: var(--brand-font-header);
color: #e0b840;
font-size: 1rem;
min-width: 40px;
text-align: right;
}
.admin-funnel-pct {
font-family: var(--brand-font-body);
color: #8a7f5a;
font-size: 0.85rem;
min-width: 36px;
text-align: right;
}
.admin-stats-lists {
margin-top: 1rem;
display: grid;
@@ -4527,6 +5100,10 @@
position: relative;
}
.site-search-bar-inner {
padding: 0.4rem 1rem;
}
.header-menu-btn {
display: inline-flex;
margin-left: auto;
@@ -4971,6 +5548,45 @@
cursor: pointer;
}
.qa-card-scene--pinned {
border-color: rgba(201, 168, 76, 0.4) !important;
box-shadow: 0 0 0 1px rgba(201,168,76,0.15);
}
.qa-pinned-badge {
font-size: 0.85em;
margin-right: 0.15rem;
}
.qa-upvote-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border-radius: 999px;
padding: 0.28rem 0.65rem;
font-size: 0.73rem;
font-family: var(--brand-font-body);
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
border: 1px solid rgba(201,168,76,0.3);
background: transparent;
color: #c9a84c;
transition: background 0.15s, border-color 0.15s;
}
.qa-upvote-btn:hover:not(:disabled) {
background: rgba(201,168,76,0.12);
border-color: #c9a84c;
}
.qa-upvote-btn--voted {
background: rgba(201,168,76,0.1);
border-color: rgba(201,168,76,0.5);
color: #a08030;
cursor: default;
}
.qa-card-actions {
display: flex;
align-items: center;
@@ -5725,6 +6341,124 @@
margin: 0;
}
/* Study comments moderation panel */
.admin-comment-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 0.75rem;
}
.admin-comment-card {
background: #14130f;
border: 1px solid #2a2518;
border-radius: 10px;
padding: 1rem 1.25rem;
}
.admin-comment-card--pending {
border-color: #5a3a1a;
background: #160f08;
}
.admin-comment-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.admin-comment-card-meta {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.75rem;
align-items: baseline;
}
.admin-comment-author {
font-family: var(--brand-font-header);
font-size: 0.9rem;
color: var(--brand-warm-white);
}
.admin-comment-location {
font-size: 0.78rem;
color: #8a7f5a;
}
.admin-comment-date {
font-size: 0.75rem;
color: #5a5040;
}
.admin-comment-badge {
font-size: 0.72rem;
font-weight: 700;
padding: 0.2rem 0.55rem;
border-radius: 4px;
white-space: nowrap;
flex-shrink: 0;
}
.admin-comment-badge--pending {
background: #3a2010;
color: #e0903a;
}
.admin-comment-badge--approved {
background: #1a3a2a;
color: #6fcf97;
}
.admin-comment-text {
font-size: 0.9rem;
color: #c8bfa8;
margin: 0 0 0.75rem;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.admin-comment-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Episode scripts admin panel */
.admin-script-upload-form {
display: grid;
gap: 1rem;
max-width: 560px;
margin-bottom: 1rem;
}
.admin-script-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
}
.admin-script-row {
display: flex;
align-items: center;
gap: 1rem;
background: #14130f;
border: 1px solid #2a2518;
border-radius: 8px;
padding: 0.65rem 1rem;
}
.admin-script-row-info {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: baseline;
}
.admin-script-ep {
font-family: var(--brand-font-header);
font-size: 0.85rem;
color: var(--brand-gold);
min-width: 48px;
}
.admin-script-title {
font-size: 0.9rem;
color: var(--brand-warm-white);
}
.admin-script-meta {
font-size: 0.78rem;
color: #5a5040;
width: 100%;
}
.admin-sidebar-backdrop {
display: none;
}
+215 -24
View File
@@ -8,6 +8,9 @@ import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySect
import { SpotifyIcon } from './icons'
import type { SiteContent, ArchivedSeries, StudyProgram } from './content'
import { DEFAULTS } from './content'
import { usePageMeta } from './hooks/usePageMeta'
import { useGlobalSearch } from './hooks/useGlobalSearch'
import { GlobalSearch } from './components/GlobalSearch'
import './App.css'
const SPOTIFY_EMBED_URL =
@@ -459,6 +462,17 @@ function formatPubDate(raw: string): string {
}
}
function SiteSearchBar({ content }: { content: SiteContent }) {
const { query, setQuery, results } = useGlobalSearch(content)
return (
<div className="site-search-bar">
<div className="site-search-bar-inner">
<GlobalSearch query={query} setQuery={setQuery} results={results} />
</div>
</div>
)
}
function SiteHeader({ content }: { content: SiteContent }) {
const [menuOpen, setMenuOpen] = useState(false)
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
@@ -783,39 +797,72 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
const archivedWithResources = (content.archivedSeries ?? []).filter(series => (series.resourceLinks ?? []).length > 0)
const [activeTag, setActiveTag] = useState<string | null>(null)
const [activeType, setActiveType] = useState<'all' | 'resource' | 'series'>('all')
if (resources.length === 0 && archivedWithResources.length === 0) return null
const allTags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean)
const filteredResources = activeTag ? resources.filter(r => (r.tags ?? []).includes(activeTag)) : resources
// Filter resources
const filteredResources = resources.filter(r => {
const tagOk = !activeTag || (r.tags ?? []).includes(activeTag)
const typeOk = activeType === 'all' || activeType === 'resource'
return tagOk && typeOk
})
// Filter archived series
const filteredArchived = archivedWithResources.filter(() => {
return activeType === 'all' || activeType === 'series'
})
const hasTypeFilter = resources.length > 0 && archivedWithResources.length > 0
return (
<section className="section-resources section-download-library" aria-label="Download library">
<div className="section-inner">
<div className="download-library-head">
<p className="eyebrow">Download Library</p>
<h2 className="section-heading">More guides, worksheets, and past study downloads.</h2>
<h2 className="section-heading">Guides, worksheets, and past study downloads.</h2>
</div>
{allTags.length > 0 && (
<div className="download-tag-filter">
<button
className={`download-tag-chip${activeTag === null ? ' download-tag-chip--active' : ''}`}
onClick={() => setActiveTag(null)}
>
All
</button>
{allTags.map(tag => (
{/* Type + tag filter bar */}
<div className="download-filter-bar">
{hasTypeFilter && (
<div className="download-tag-filter" style={{ marginBottom: allTags.length > 0 ? '0.5rem' : 0 }}>
{(['all', 'resource', 'series'] as const).map(t => (
<button
key={t}
type="button"
className={`download-tag-chip${activeType === t ? ' download-tag-chip--active' : ''}`}
onClick={() => setActiveType(t)}
>
{t === 'all' ? 'All types' : t === 'resource' ? 'Study guides' : 'Past series'}
</button>
))}
</div>
)}
{allTags.length > 0 && (activeType === 'all' || activeType === 'resource') && (
<div className="download-tag-filter">
<button
key={tag}
className={`download-tag-chip${activeTag === tag ? ' download-tag-chip--active' : ''}`}
onClick={() => setActiveTag(prev => prev === tag ? null : tag)}
type="button"
className={`download-tag-chip${activeTag === null ? ' download-tag-chip--active' : ''}`}
onClick={() => setActiveTag(null)}
>
{tag}
All topics
</button>
))}
</div>
)}
{allTags.map(tag => (
<button
key={tag}
type="button"
className={`download-tag-chip${activeTag === tag ? ' download-tag-chip--active' : ''}`}
onClick={() => setActiveTag(prev => prev === tag ? null : tag)}
>
{tag}
</button>
))}
</div>
)}
</div>
{filteredResources.length > 0 && (
<div className="download-library-group">
@@ -824,7 +871,7 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
<Link key={resource.id} to={`/downloads/${buildCustomDownloadPageId(resource.id)}`} className="resource-download-card resource-download-card--link">
<div className="resource-download-header">
{resource.imageUrl && (
<img src={resource.imageUrl} alt={resource.label} className="resource-link-image" />
<img src={resource.imageUrl} alt={resource.label} className="resource-link-image" loading="lazy" />
)}
<div className="resource-download-meta">
<span className="resource-link-label">{resource.label}</span>
@@ -841,17 +888,17 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
</div>
)}
{activeTag !== null && filteredResources.length === 0 && (
{activeTag !== null && filteredResources.length === 0 && activeType !== 'series' && (
<p className="download-empty-state">No downloads tagged "{activeTag}".</p>
)}
{archivedWithResources.length > 0 && (
{filteredArchived.length > 0 && (
<div className="download-library-group">
<div className="download-library-group-head">
<h3>Previous Studies</h3>
<p>Downloads from earlier series that you still want available.</p>
<p>Downloads from earlier series.</p>
</div>
{archivedWithResources.map(series => (
{filteredArchived.map(series => (
<div key={series.id} className="archive-series-resources">
<div className="download-library-series-head">
<h4>{series.title || 'Archived Study'}</h4>
@@ -862,7 +909,7 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
<Link key={link.id} to={`/downloads/${buildArchivedDownloadPageId(series.id, link.id)}`} className="resource-download-card resource-download-card--link">
<div className="resource-download-header">
{series.imageUrl && (
<img src={series.imageUrl} alt={series.title || 'Archived study'} className="resource-link-image" />
<img src={series.imageUrl} alt={series.title || 'Archived study'} className="resource-link-image" loading="lazy" />
)}
<div className="resource-download-meta">
<span className="resource-link-label">{link.label || series.title || 'Download Resource'}</span>
@@ -882,10 +929,43 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
)
}
function useEpisodesForSeries(seriesId: string | null, archivedSeries: ArchivedSeries[]) {
const [episodes, setEpisodes] = useState<Episode[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!seriesId) return
const series = archivedSeries.find(s => s.id === seriesId)
if (!series?.episodeRange) return
setLoading(true)
fetch('/api/episodes/all')
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { episodes?: Episode[] }) => {
const { from, to } = series.episodeRange!
const filtered = (data.episodes ?? []).filter(ep => {
const num = parseInt(ep.episode, 10)
return !isNaN(num) && num >= from && num <= to
})
setEpisodes(filtered)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [seriesId])
return { episodes, loading }
}
function DownloadDetailPage({ content }: { content: SiteContent }) {
const { id } = useParams<{ id: string }>()
const resource = resolveDownloadPageResource(content, id)
// Resolve related series for archived resources
const archivedSeriesId = id?.startsWith('archived--') ? id.split('--')[1] : null
const relatedSeries = archivedSeriesId ? (content.archivedSeries ?? []).find(s => s.id === archivedSeriesId) : null
const { episodes: relatedEpisodes, loading: epsLoading } = useEpisodesForSeries(archivedSeriesId, content.archivedSeries ?? [])
const spotifyUrl = content.platformSpotifyUrl || '/episodes'
if (!resource) {
return (
<main className="thanks-page" aria-label="Download not found">
@@ -928,6 +1008,40 @@ function DownloadDetailPage({ content }: { content: SiteContent }) {
)}
<Link to="/resources" className="btn-secondary">Back to Downloads</Link>
</div>
{relatedSeries && (relatedEpisodes.length > 0 || epsLoading) && (
<div className="download-related-episodes">
<h2 className="download-related-heading">
Episodes from {relatedSeries.title}
</h2>
{epsLoading ? (
<p style={{ color: '#7a7060', fontSize: '0.9rem' }}>Loading episodes</p>
) : (
<div className="download-related-list">
{relatedEpisodes.slice(0, 10).map((ep, idx) => (
<a
key={idx}
href={ep.link || spotifyUrl}
target="_blank"
rel="noreferrer"
className="download-related-ep"
>
<div className="download-related-ep-meta">
{ep.episode && <span className="episode-number">Ep. {ep.episode}</span>}
{ep.pubDate && <span className="episode-date">{formatPubDate(ep.pubDate)}</span>}
</div>
<p className="download-related-ep-title">{ep.title}</p>
</a>
))}
{relatedEpisodes.length > 10 && (
<Link to="/episodes" className="btn-secondary" style={{ marginTop: '0.5rem', display: 'inline-block' }}>
View all {relatedEpisodes.length} episodes
</Link>
)}
</div>
)}
</div>
)}
</div>
</main>
)
@@ -1175,6 +1289,7 @@ function LandingPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<CustomBlocksSection content={content} page="homepage" />
{/* ── HERO ── */}
@@ -1417,6 +1532,7 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<section className="section-player">
<div className="section-inner" style={{ textAlign: 'center', padding: '4rem 1rem' }}>
<h2>Episode not found</h2>
@@ -1435,6 +1551,7 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<section className="section-episode-detail" aria-label={episode.title}>
<div className="section-inner">
<Link to="/episodes" className="episode-detail-back">← Back to Episodes</Link>
@@ -1494,6 +1611,11 @@ function EpisodesPage({ content }: { content: SiteContent }) {
const [allEpisodes, setAllEpisodes] = useState<Episode[]>([])
const [loading, setLoading] = useState(true)
const [failed, setFailed] = useState(false)
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Episodes | ${siteTitle}`,
content.episodesSeoIntro || 'Browse all episodes of Verse by Verse with Nate — expository Bible teaching, one verse at a time.',
)
useEffect(() => {
fetch('/api/episodes/all')
@@ -1589,6 +1711,7 @@ function EpisodesPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<section className="section-player" aria-label="Current series episodes">
<div className="section-inner">
@@ -1669,6 +1792,7 @@ function EpisodesPage({ content }: { content: SiteContent }) {
)
})}
<HomepageNewsletterSection />
<PodcastHighlightsSection content={content} />
<CustomBlocksSection content={content} page="episodes" />
<SiteFooter content={content} />
@@ -1678,9 +1802,15 @@ function EpisodesPage({ content }: { content: SiteContent }) {
}
function ResourcesPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Resources & Downloads | ${siteTitle}`,
'Study guides, sermon notes, and free resources from Verse by Verse with Nate.',
)
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<StudyGuideSection content={content} />
<DownloadLibrarySection content={content} />
<CustomBlocksSection content={content} page="downloads" />
@@ -1692,9 +1822,15 @@ function ResourcesPage({ content }: { content: SiteContent }) {
}
function AboutPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`About | ${siteTitle}`,
content.aboutNate || 'Learn about Verse by Verse with Nate — expository Bible teaching from Nate Emmert.',
)
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<AboutSection content={content} />
<CustomBlocksSection content={content} page="about" />
<SiteFooter content={content} />
@@ -1707,6 +1843,7 @@ function ContactPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<ContactSection content={content} />
<CustomBlocksSection content={content} page="contact" />
<SiteFooter content={content} />
@@ -1925,6 +2062,58 @@ function LegalPage({ title, body }: { title: string; body: string[] }) {
)
}
function PublicCertificatePage() {
const { token } = useParams<{ token: string }>()
const [cert, setCert] = useState<{ studyTitle: string; displayName: string; issuedAt: string } | null>(null)
const [status, setStatus] = useState<'loading' | 'not-found' | 'ready'>('loading')
useEffect(() => {
if (!token) { setStatus('not-found'); return }
fetch(`/api/public/certificate/${encodeURIComponent(token)}`)
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => { setCert(data); setStatus('ready') })
.catch(() => setStatus('not-found'))
}, [token])
usePageMeta(
cert ? `Certificate of Completion ${cert.studyTitle}` : 'Certificate',
cert ? `${cert.displayName} completed ${cert.studyTitle}` : undefined,
)
if (status === 'loading') {
return <main className="thanks-page"><div className="thanks-card"><p>Loading certificate…</p></div></main>
}
if (status === 'not-found' || !cert) {
return (
<main className="thanks-page">
<div className="thanks-card">
<h1>Certificate Not Found</h1>
<p>This certificate link is invalid or has been removed.</p>
<Link to="/" className="btn-primary">Back to Site</Link>
</div>
</main>
)
}
return (
<main className="thanks-page public-cert-page" aria-label="Certificate of Completion">
<div className="thanks-card public-cert-card">
<p className="eyebrow">Verse by Verse with Nate</p>
<h1 className="public-cert-title">Certificate of Completion</h1>
<div className="public-cert-divider" />
<p className="public-cert-label">This certifies that</p>
<p className="public-cert-name">{cert.displayName}</p>
<p className="public-cert-label">has successfully completed</p>
<p className="public-cert-study">{cert.studyTitle}</p>
<p className="public-cert-date">
Awarded on {new Date(cert.issuedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
</p>
<Link to="/" className="btn-primary" style={{ marginTop: '2rem' }}>Visit Verse by Verse with Nate</Link>
</div>
</main>
)
}
function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: SiteContent) => void }) {
const [status, setStatus] = useState<'checking' | 'authenticated' | 'unauthenticated' | 'misconfigured'>('checking')
const [adminContent, setAdminContent] = useState<SiteContent>(content)
@@ -2203,6 +2392,7 @@ function StudyRouteFrame({ content, child }: { content: SiteContent; child: Reac
return (
<>
<SiteHeader content={content} />
<SiteSearchBar content={content} />
{child}
<SiteFooter content={content} />
</>
@@ -2303,6 +2493,7 @@ export default function App() {
<Route path="/thanks" element={<ThankYouPage />} />
<Route path="/subscribe" element={<SubscribePage />} />
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
<Route path="/preview" element={<PreviewPage />} />
<Route
+111 -30
View File
@@ -2,6 +2,10 @@ import { useEffect, useMemo, useState, useCallback } from 'react'
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
import type { SiteContent, StudyProgram, StudySection } from './content'
import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './colossiansStudyData'
import { usePageMeta } from './hooks/usePageMeta'
import { Breadcrumbs } from './components/Breadcrumbs'
import { StudySectionComments } from './components/StudySectionComments'
import { StudyCertificate } from './components/StudyCertificate'
type Props = { content: SiteContent }
@@ -726,7 +730,8 @@ export function StudyLandingPage({ content }: Props) {
const studyMeta = getStudyBySlug(studies, study.slug)
const progress = study.totalLessons > 0 ? Math.round((study.completedLessons / study.totalLessons) * 100) : 0
return (
<article key={study.slug} className="study-module-row">
<div key={study.slug}>
<article className="study-module-row">
<div className="study-module-row-left">
<p className="study-module-lesson">{study.enrolled ? 'Enrolled' : 'Track'}</p>
<h3>{study.title}</h3>
@@ -751,6 +756,10 @@ export function StudyLandingPage({ content }: Props) {
</div>
</div>
</article>
{progress === 100 && (
<StudyCertificate studySlug={study.slug} />
)}
</div>
)
})}
</div>
@@ -1209,10 +1218,16 @@ export function ColossiansStudyIndexPage({ content }: Props) {
}
}
usePageMeta(
`${study.title} | Bible Study`,
study.description,
)
return (
<main className="study-index-page" aria-label={`${study.title} study`}>
<section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner">
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title }]} />
<p className="eyebrow">Online Bible Class</p>
<h1>{study.title}</h1>
<p className="study-course-hero-copy">{study.description}</p>
@@ -1303,7 +1318,13 @@ export function ColossiansStudyIndexPage({ content }: Props) {
{!coming && !enrolled && <span style={{ backgroundColor: '#ef5350', color: '#fff', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Enroll to open</span>}
{lockedByProgress && <span style={{ backgroundColor: '#5a5440', color: '#f0e9cc', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Complete previous lesson</span>}
{enrolled && !lockedByProgress && studyProgress.completedSectionIds.includes(section.id) && (
<span style={{ backgroundColor: '#2196f3', color: '#fff', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Completed</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', backgroundColor: '#1a3a2a', color: '#6fcf97', padding: '0.2rem 0.55rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
<circle cx="6.5" cy="6.5" r="6" stroke="#6fcf97" strokeWidth="1.2" />
<path d="M3.5 6.5l2 2 3.5-3.5" stroke="#6fcf97" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Completed
</span>
)}
</div>
<h3>{section.title}</h3>
@@ -1369,9 +1390,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
const isEnrolled = isEnrolledInStudy(auth, currentStudySlug)
const lessonCompleted = section?.id ? completedSectionIds.includes(section.id) : false
useEffect(() => {
document.title = section && study ? `${section.title} | ${study.title}` : 'Study'
}, [section, study])
usePageMeta(
section && study ? `${section.title} | ${study.title}` : 'Study',
section?.summary,
)
useEffect(() => {
let cancelled = false
@@ -1655,6 +1677,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
<section className="section-study-classroom" onContextMenu={handleLessonContentContextMenu}>
<div className="section-inner study-classroom-shell">
<div className="study-classroom-main">
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title, href: `/study/${study.slug}` }, { label: `Lesson ${lessonNumber}` }]} />
<Link to={`/study/${study.slug}`} className="study-detail-back">Back to {study.title}</Link>
<p className="study-lesson-label">Lesson {lessonNumber} of {sections.length}</p>
<h1>{section.title}</h1>
@@ -1695,14 +1718,17 @@ export function ColossiansStudySectionPage({ content }: Props) {
</article>
<article className="study-class-block">
<h2>Discussion Questions</h2>
<h2>Reflection Questions</h2>
<p className="study-detail-copy" style={{ marginBottom: '0.75rem', color: '#8a7f5a', fontSize: '0.9rem' }}>
Work through these on your own, then share your thoughts in the Discussion below.
</p>
<ol className="study-detail-list">
{section.studyQuestions.map((question, index) => (
<li key={index}>{question}</li>
))}
</ol>
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<Link to={`/study/${study.slug}/${section.id}/quiz`} className="btn-secondary">Discussion Questions</Link>
<Link to={`/study/${study.slug}/${section.id}/quiz`} className="btn-secondary">Write My Answers</Link>
<Link to={`/study/${study.slug}/community?sectionId=${encodeURIComponent(section.id)}`} className="btn-primary">Go to Community</Link>
<Link to={`/contact?source=community&study=${encodeURIComponent(study.title)}`} className="btn-secondary">Ask Nate</Link>
</div>
@@ -1782,6 +1808,14 @@ export function ColossiansStudySectionPage({ content }: Props) {
</article>
)
})()}
<article className="study-class-block">
<StudySectionComments
studySlug={currentStudySlug}
sectionId={section.id}
isEnrolled={isEnrolled}
/>
</article>
</div>
<aside className="study-classroom-sidebar" aria-label="Lesson tools">
@@ -1967,9 +2001,10 @@ export function ColossiansStudyNotesPage({ content }: Props) {
const [message, setMessage] = useState('')
const [activeNoteTab, setActiveNoteTab] = useState('')
useEffect(() => {
document.title = study ? `My Notes | ${study.title}` : 'My Study Notes'
}, [study])
usePageMeta(
study ? `My Notes | ${study.title}` : 'My Study Notes',
study ? `Your personal lesson notes for ${study.title}.` : undefined,
)
useEffect(() => {
let cancelled = false
@@ -2048,6 +2083,7 @@ export function ColossiansStudyNotesPage({ content }: Props) {
<main className="study-section-page" aria-label="My study notes">
<section className="section-study-classroom">
<div className="section-inner">
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title, href: `/study/${study.slug}` }, { label: 'My Notes' }]} />
<Link to={`/study/${study.slug}`} className="study-detail-back">Back to {study.title}</Link>
<p className="study-lesson-label">Student Workspace</p>
<h1>My Lesson Notes</h1>
@@ -2976,6 +3012,8 @@ export function StudyQuizPage({ content }: Props) {
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const [shareToDiscussion, setShareToDiscussion] = useState<Record<number, boolean>>({})
const [sharedIndexes, setSharedIndexes] = useState<Set<number>>(new Set())
useEffect(() => {
let cancelled = false
@@ -3029,7 +3067,37 @@ export function StudyQuizPage({ content }: Props) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers }),
})
setMessage('Answers saved.')
// Post any checked answers to the shared discussion
const toShare = Object.entries(shareToDiscussion)
.filter(([, checked]) => checked)
.map(([idx]) => Number(idx))
.filter(idx => !sharedIndexes.has(idx) && (answers[idx] ?? '').trim().length >= 2)
const newlyShared: number[] = []
for (const idx of toShare) {
const questionText = section.studyQuestions?.[idx] ? `**${section.studyQuestions[idx]}**\n\n` : ''
const text = `${questionText}${answers[idx].trim()}`
try {
const res = await fetch(`/api/study-comments/${encodeURIComponent(studySlug)}/${encodeURIComponent(sectionId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text.slice(0, 2000) }),
})
if (res.ok) newlyShared.push(idx)
} catch { /* ignore individual share failures */ }
}
if (newlyShared.length > 0) {
setSharedIndexes(prev => new Set([...prev, ...newlyShared]))
setShareToDiscussion(prev => {
const next = { ...prev }
newlyShared.forEach(idx => { next[idx] = false })
return next
})
}
setMessage(newlyShared.length > 0 ? `Answers saved and ${newlyShared.length} shared to the discussion.` : 'Answers saved.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to save answers.')
} finally {
@@ -3041,9 +3109,9 @@ export function StudyQuizPage({ content }: Props) {
return (
<main className="thanks-page" aria-label="Discussion questions not found">
<div className="thanks-card">
<p className="eyebrow">Discussion Questions</p>
<p className="eyebrow">Reflection Questions</p>
<h1>Discussion questions not found</h1>
<p>The discussion questions you requested are not available.</p>
<p>The reflection questions you requested are not available.</p>
<Link to="/study" className="btn-primary">Back to Studies</Link>
</div>
</main>
@@ -3056,8 +3124,8 @@ export function StudyQuizPage({ content }: Props) {
if (!auth.checked) {
return (
<main className="thanks-page" aria-label="Loading discussion questions">
<div className="thanks-card"><p>Loading discussion questions...</p></div>
<main className="thanks-page" aria-label="Loading reflection questions">
<div className="thanks-card"><p>Loading reflection questions...</p></div>
</main>
)
}
@@ -3066,7 +3134,7 @@ export function StudyQuizPage({ content }: Props) {
return (
<main className="thanks-page" aria-label="Sign in required">
<div className="thanks-card">
<p className="eyebrow">Discussion Questions</p>
<p className="eyebrow">Reflection Questions</p>
<h1>Sign In Required</h1>
<p>You need to sign in before you can view and save your discussion answers.</p>
<Link to="/study/signup" className="btn-primary">Sign In</Link>
@@ -3079,9 +3147,9 @@ export function StudyQuizPage({ content }: Props) {
return (
<main className="thanks-page" aria-label="Enrollment required">
<div className="thanks-card">
<p className="eyebrow">Discussion Questions</p>
<h1>Enroll to Access Discussion Questions</h1>
<p>Please enroll in {study.title} to open the discussion questions for this lesson.</p>
<p className="eyebrow">Reflection Questions</p>
<h1>Enroll to Access Reflection Questions</h1>
<p>Please enroll in {study.title} to open the reflection questions for this lesson.</p>
<Link to={`/study/${study.slug}`} className="btn-primary">Go to Study</Link>
</div>
</main>
@@ -3092,9 +3160,9 @@ export function StudyQuizPage({ content }: Props) {
<main className="study-index-page" aria-label="Discussion questions">
<section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner">
<p className="eyebrow">Discussion Questions</p>
<p className="eyebrow">Reflection Questions</p>
<h1>{section.title}</h1>
<p className="study-course-hero-copy">Work through the discussion questions below and save your responses for later review.</p>
<p className="study-course-hero-copy">Write out your answers below — they're saved privately. You can also choose to share individual answers to the lesson discussion.</p>
<div className="study-course-hero-actions">
<Link to={`/study/${study.slug}/${section.id}`} className="btn-secondary">Back to Lesson</Link>
</div>
@@ -3104,34 +3172,47 @@ export function StudyQuizPage({ content }: Props) {
<section className="section-study-module" style={{ paddingTop: '2rem' }}>
<div className="section-inner">
{loading ? (
<p>Loading discussion questions...</p>
<p>Loading reflection questions...</p>
) : error ? (
<p className="study-note-status">{error}</p>
) : !canSubmit ? (
<article className="study-class-block">
<h2>No Discussion Questions</h2>
<p className="study-detail-copy">This lesson does not have discussion questions configured yet.</p>
<h2>No Reflection Questions</h2>
<p className="study-detail-copy">This lesson does not have reflection questions configured yet.</p>
</article>
) : (
<form onSubmit={e => { e.preventDefault(); saveQuiz() }}>
{sectionQuestions.map((question, index) => (
<div key={index} style={{ marginBottom: '1.25rem' }}>
<p style={{ margin: '0 0 0.5rem', fontWeight: 600 }}>{`${index + 1}. ${question}`}</p>
<div key={index} className="quiz-question-block">
<p className="quiz-question-label">{`${index + 1}. ${question}`}</p>
<textarea
rows={4}
className="quiz-question-textarea"
value={answers[index] ?? ''}
onChange={e => setAnswers(prev => {
const next = [...prev]
next[index] = e.target.value
return next
})}
placeholder="Your answer..."
style={{ width: '100%', padding: '0.9rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8' }}
placeholder="Write your answer here…"
/>
{(answers[index] ?? '').trim().length >= 2 && (
<label className="quiz-share-toggle">
<input
type="checkbox"
checked={shareToDiscussion[index] ?? false}
disabled={sharedIndexes.has(index)}
onChange={e => setShareToDiscussion(prev => ({ ...prev, [index]: e.target.checked }))}
/>
{sharedIndexes.has(index)
? <span className="quiz-share-label quiz-share-label--done"> Shared to discussion</span>
: <span className="quiz-share-label">Share this answer to the lesson discussion</span>}
</label>
)}
</div>
))}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button type="submit" className="btn-primary" disabled={saving}>{saving ? 'Saving...' : 'Save Answers'}</button>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '0.5rem' }}>
<button type="submit" className="btn-primary" disabled={saving}>{saving ? 'Saving…' : 'Save Answers'}</button>
<button type="button" className="btn-secondary" onClick={() => navigate(`/study/${study.slug}/${section.id}`)}>Back to Lesson</button>
</div>
{message && <p className="study-note-status" style={{ marginTop: '1rem' }}>{message}</p>}
+72 -4
View File
@@ -30,6 +30,8 @@ ChartJS.register(
interface Props {
stats: AdminStats | null
statsStatus: 'loading' | 'error' | 'ready'
statsRange?: '7d' | '30d' | '90d'
onRangeChange?: (range: '7d' | '30d' | '90d') => void
opsStatus: { buildCommit: string | null; buildNumber: string | null; deployedAt: string | null; cachePurge: { ok: boolean; at: string | null; error: string | null }; deployHook: { ok: boolean; at: string | null; error: string | null } } | null
formatDate: (date: string | null) => string
maskIp: (ip: string) => string
@@ -51,6 +53,8 @@ interface Props {
export function AnalyticsPanel({
stats,
statsStatus,
statsRange = '30d',
onRangeChange,
opsStatus,
formatDate,
maskIp,
@@ -224,6 +228,9 @@ export function AnalyticsPanel({
},
}
const rangeTotalHits = (stats as AdminStats & { rangeTotalHits?: number }).rangeTotalHits ?? totalHits
const rangeLabel = (stats as AdminStats & { rangeLabel?: string }).rangeLabel ?? statsRange
return (
<section className="admin-panel-section" aria-label="Analytics">
<div className="admin-panel-head">
@@ -231,22 +238,51 @@ export function AnalyticsPanel({
<p>Real-time insights into site traffic, bot detection, visitor behavior, and data management.</p>
</div>
{/* Time-range tabs */}
{onRangeChange && (
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
{(['7d', '30d', '90d'] as const).map(r => (
<button
key={r}
type="button"
onClick={() => onRangeChange(r)}
style={{
padding: '0.35rem 1rem',
borderRadius: '20px',
border: `1px solid ${statsRange === r ? '#c9a84c' : '#3a3320'}`,
background: statsRange === r ? 'rgba(201,168,76,0.15)' : 'transparent',
color: statsRange === r ? '#c9a84c' : '#7a7060',
cursor: 'pointer',
fontSize: '0.85rem',
fontWeight: statsRange === r ? 600 : 400,
transition: 'all 0.15s',
}}
>
{r === '7d' ? 'Last 7 days' : r === '30d' ? 'Last 30 days' : 'Last 90 days'}
</button>
))}
<span style={{ color: '#5a5440', fontSize: '0.8rem', alignSelf: 'center', marginLeft: '0.25rem' }}>
Showing: {rangeLabel}
</span>
</div>
)}
{/* KPI Grid */}
<div className="admin-stats-grid">
<article>
<h3>Total Hits</h3>
<p>{stats.totalHits.toLocaleString()}</p>
<small>All page requests</small>
<p>{rangeTotalHits.toLocaleString()}</p>
<small>{rangeLabel} all requests</small>
</article>
<article>
<h3>Real Traffic</h3>
<p>{realHits.toLocaleString()}</p>
<small>{totalHits > 0 ? ((realHits / totalHits) * 100).toFixed(1) : '0.0'}% of total</small>
<small>{rangeTotalHits > 0 ? ((realHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small>
</article>
<article>
<h3>Bot Traffic</h3>
<p>{botHits.toLocaleString()}</p>
<small>{totalHits > 0 ? ((botHits / totalHits) * 100).toFixed(1) : '0.0'}% of total</small>
<small>{rangeTotalHits > 0 ? ((botHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small>
</article>
<article>
<h3>Last 30 Days</h3>
@@ -496,6 +532,38 @@ export function AnalyticsPanel({
<article><h3>Total Bible Questions</h3><p>{stats.contactTotals.totalQuestions.toLocaleString()}</p></article>
</div>
{/* Study Enrollment Funnel */}
{stats.studyEnrollment?.funnel && (
<>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Study Enrollment Funnel</h2>
<p>How many users progress from signup first visit first section completed.</p>
</div>
<div className="admin-funnel">
{(() => {
const { signups, firstVisit, firstCompletion } = stats.studyEnrollment.funnel!
const steps = [
{ label: 'Signed Up', count: signups, pct: 100 },
{ label: 'Visited a Study', count: firstVisit, pct: signups > 0 ? Math.round((firstVisit / signups) * 100) : 0 },
{ label: 'Completed a Section', count: firstCompletion, pct: signups > 0 ? Math.round((firstCompletion / signups) * 100) : 0 },
]
return steps.map((step, i) => (
<div key={i} className="admin-funnel-step">
<div className="admin-funnel-bar-wrap">
<div className="admin-funnel-bar" style={{ width: `${step.pct}%` }} />
</div>
<div className="admin-funnel-label">
<span className="admin-funnel-step-name">{step.label}</span>
<span className="admin-funnel-count">{step.count.toLocaleString()}</span>
<span className="admin-funnel-pct">{step.pct}%</span>
</div>
</div>
))
})()}
</div>
</>
)}
{/* Deployment & Cache Status */}
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Deployment &amp; Cache Status</h2>
+36
View File
@@ -0,0 +1,36 @@
import { Link } from 'react-router-dom'
export interface BreadcrumbItem {
label: string
href?: string
}
export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
if (items.length === 0) return null
return (
<nav aria-label="Breadcrumb" className="study-breadcrumbs">
<ol className="study-breadcrumbs-list">
{items.map((item, idx) => {
const isLast = idx === items.length - 1
return (
<li key={idx} className="study-breadcrumbs-item">
{!isLast && item.href ? (
<Link to={item.href} className="study-breadcrumbs-link">
{item.label}
</Link>
) : (
<span className="study-breadcrumbs-current" aria-current={isLast ? 'page' : undefined}>
{item.label}
</span>
)}
{!isLast && (
<span className="study-breadcrumbs-sep" aria-hidden="true"> </span>
)}
</li>
)
})}
</ol>
</nav>
)
}
+155
View File
@@ -0,0 +1,155 @@
import { useRef, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import type { SearchResult, SearchResultType } from '../hooks/useGlobalSearch'
const TYPE_LABEL: Record<SearchResultType, string> = {
episode: 'Episode',
question: 'Q&A',
resource: 'Download',
series: 'Series',
}
const TYPE_ICON: Record<SearchResultType, string> = {
episode: '🎙',
question: '❓',
resource: '📄',
series: '📚',
}
interface Props {
query: string
setQuery: (q: string) => void
results: SearchResult[]
}
export function GlobalSearch({ query, setQuery, results }: Props) {
const [open, setOpen] = useState(false)
const [activeIdx, setActiveIdx] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
const showDropdown = open && (results.length > 0 || query.trim().length >= 2)
// Reset active index when results change
useEffect(() => { setActiveIdx(-1) }, [results])
// Close on outside click
useEffect(() => {
if (!showDropdown) return
function handleClick(e: MouseEvent) {
if (
!inputRef.current?.contains(e.target as Node) &&
!dropdownRef.current?.contains(e.target as Node)
) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [showDropdown])
function handleSelect(result: SearchResult) {
setOpen(false)
setQuery('')
if (result.type === 'episode' && result.href.startsWith('http')) {
window.open(result.href, '_blank', 'noreferrer')
} else {
navigate(result.href)
}
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (!showDropdown) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIdx(i => Math.min(i + 1, results.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIdx(i => Math.max(i - 1, 0))
} else if (e.key === 'Enter' && activeIdx >= 0) {
e.preventDefault()
handleSelect(results[activeIdx])
} else if (e.key === 'Escape') {
setOpen(false)
inputRef.current?.blur()
}
}
return (
<div className="global-search" role="search">
<div className="global-search-input-wrap">
<span className="global-search-icon" aria-hidden="true">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<circle cx="6.5" cy="6.5" r="5" stroke="currentColor" strokeWidth="1.5" />
<path d="M10.5 10.5L14 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</span>
<input
ref={inputRef}
type="search"
className="global-search-input"
placeholder="Search episodes, Q&A, resources…"
value={query}
autoComplete="off"
aria-label="Search site"
aria-expanded={showDropdown}
aria-autocomplete="list"
onChange={e => { setQuery(e.target.value); setOpen(true) }}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
/>
{query && (
<button
type="button"
className="global-search-clear"
aria-label="Clear search"
onClick={() => { setQuery(''); setOpen(false); inputRef.current?.focus() }}
>×</button>
)}
</div>
{showDropdown && (
<div
ref={dropdownRef}
className="global-search-dropdown"
role="listbox"
aria-label="Search results"
>
{results.length === 0 ? (
<p className="global-search-empty">No results for "{query}"</p>
) : results.map((result, idx) => (
<button
key={result.id}
type="button"
role="option"
aria-selected={idx === activeIdx}
className={`global-search-result${idx === activeIdx ? ' global-search-result--active' : ''}`}
onClick={() => handleSelect(result)}
onMouseEnter={() => setActiveIdx(idx)}
>
<span className="global-search-result-icon" aria-hidden="true">
{TYPE_ICON[result.type]}
</span>
<span className="global-search-result-body">
<span className="global-search-result-title">{result.title}</span>
<span className="global-search-result-meta">
<span className="global-search-result-type">{TYPE_LABEL[result.type]}</span>
{result.subtitle && result.subtitle !== TYPE_LABEL[result.type] && (
<span className="global-search-result-sub"> · {result.subtitle}</span>
)}
</span>
{result.scriptSnippet && (
<span className="global-search-result-snippet">{result.scriptSnippet}</span>
)}
</span>
</button>
))}
{results.length > 0 && (
<p className="global-search-hint"> navigate · Enter select · Esc close</p>
)}
</div>
)}
</div>
)
}
+44 -2
View File
@@ -8,6 +8,8 @@ interface PublicQuestion {
topic?: string
submittedAt?: string
answeredAt?: string
upvotes?: number
pinned?: boolean
}
interface DecoratedQuestion extends PublicQuestion {
@@ -390,6 +392,12 @@ export default function QASection() {
const [copiedQuestionId, setCopiedQuestionId] = useState<string | null>(null)
const [copiedFacebookCaptionId, setCopiedFacebookCaptionId] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [votedIds, setVotedIds] = useState<Set<string>>(() => {
try {
const raw = localStorage.getItem('qa-voted-ids-v1')
return new Set(raw ? JSON.parse(raw) as string[] : [])
} catch { return new Set() }
})
const resultsTopRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
@@ -404,6 +412,22 @@ export default function QASection() {
})
}, [])
async function handleUpvote(id: string) {
if (votedIds.has(id)) return
try {
const res = await fetch(`/api/questions/${encodeURIComponent(id)}/upvote`, { method: 'POST' })
if (!res.ok) return
const data = await res.json() as { upvotes: number }
setQuestions(prev => prev.map(q => q.id === id ? { ...q, upvotes: data.upvotes } : q))
setVotedIds(prev => {
const next = new Set(prev)
next.add(id)
localStorage.setItem('qa-voted-ids-v1', JSON.stringify([...next]))
return next
})
} catch { /* ignore */ }
}
useEffect(() => {
setEngagement(readEngagementFromStorage())
}, [])
@@ -839,22 +863,40 @@ export default function QASection() {
: question.answer
return (
<article key={question.id} id={`qa-${question.id}`} className={`qa-card-scene${isFocused ? ' qa-card-scene--focused' : ''}`}>
<article key={question.id} id={`qa-${question.id}`} className={`qa-card-scene${isFocused ? ' qa-card-scene--focused' : ''}${question.pinned ? ' qa-card-scene--pinned' : ''}`}>
<div className="qa-question-header">
<span className="qa-face-label">Q</span>
<span className="qa-question-main">
<span className="qa-question-text">{renderHighlightedText(question.question, normalizedSearch)}</span>
<span className="qa-question-text">
{question.pinned && (
<span className="qa-pinned-badge" aria-label="Pinned question">📌 </span>
)}
{renderHighlightedText(question.question, normalizedSearch)}
</span>
<span className="qa-question-meta">
<span className="qa-topic-pill">{question._topic}</span>
{formatDate(question.answeredAt ?? question.submittedAt) && (
<span>{formatDate(question.answeredAt ?? question.submittedAt)}</span>
)}
{question._helpful > 0 && <span>{question._helpful} helpful</span>}
{(question.upvotes ?? 0) > 0 && (
<span>👍 {question.upvotes}</span>
)}
</span>
</span>
</div>
<div className="qa-card-actions">
<button
type="button"
aria-label={votedIds.has(question.id) ? 'Already upvoted' : 'Upvote this question'}
className={`qa-upvote-btn${votedIds.has(question.id) ? ' qa-upvote-btn--voted' : ''}`}
onClick={() => handleUpvote(question.id)}
disabled={votedIds.has(question.id)}
>
<svg viewBox="0 0 16 16" aria-hidden="true" fill="currentColor" width="13" height="13"><path d="M8 2L2 9h4v5h4V9h4z"/></svg>
<span>{votedIds.has(question.id) ? 'Upvoted' : 'Upvote'}{(question.upvotes ?? 0) > 0 ? ` · ${question.upvotes}` : ''}</span>
</button>
<button
type="button"
aria-label="Share on X"
+250
View File
@@ -0,0 +1,250 @@
import { useEffect, useRef, useState } from 'react'
interface CertificateStatus {
eligible: boolean
studyTitle: string
completedCount: number
totalCount: number
token: string | null
issuedAt: string | null
}
interface Props {
studySlug: string
}
function formatDate(iso: string | null) {
if (!iso) return ''
try {
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
} catch {
return ''
}
}
function drawCertificate(
canvas: HTMLCanvasElement,
studyTitle: string,
displayName: string,
issuedAt: string,
) {
const W = 900
const H = 630
canvas.width = W
canvas.height = H
const ctx = canvas.getContext('2d')!
// Background
ctx.fillStyle = '#1a1710'
ctx.fillRect(0, 0, W, H)
// Gold border (outer)
ctx.strokeStyle = '#c8952a'
ctx.lineWidth = 8
ctx.strokeRect(16, 16, W - 32, H - 32)
// Gold border (inner)
ctx.strokeStyle = '#e0b840'
ctx.lineWidth = 2
ctx.strokeRect(28, 28, W - 56, H - 56)
// Header label
ctx.fillStyle = '#8a7f5a'
ctx.font = '700 15px Georgia, serif'
ctx.textAlign = 'center'
ctx.fillText('VERSE BY VERSE WITH NATE', W / 2, 80)
// "Certificate of Completion"
ctx.fillStyle = '#e0b840'
ctx.font = 'italic 700 38px Georgia, serif'
ctx.fillText('Certificate of Completion', W / 2, 145)
// Decorative line
ctx.strokeStyle = '#c8952a'
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.moveTo(W / 2 - 200, 162)
ctx.lineTo(W / 2 + 200, 162)
ctx.stroke()
// "This certifies that"
ctx.fillStyle = '#b8a87a'
ctx.font = '16px Georgia, serif'
ctx.fillText('This certifies that', W / 2, 210)
// Name
ctx.fillStyle = '#f5f0e8'
ctx.font = 'italic 700 44px Georgia, serif'
ctx.fillText(displayName, W / 2, 272)
// Underline the name
const nameWidth = ctx.measureText(displayName).width
ctx.strokeStyle = '#e0b840'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(W / 2 - nameWidth / 2, 282)
ctx.lineTo(W / 2 + nameWidth / 2, 282)
ctx.stroke()
// "has successfully completed"
ctx.fillStyle = '#b8a87a'
ctx.font = '16px Georgia, serif'
ctx.fillText('has successfully completed', W / 2, 322)
// Study title
ctx.fillStyle = '#f5f0e8'
ctx.font = '700 28px Georgia, serif'
ctx.fillText(studyTitle, W / 2, 372)
// Date line
ctx.fillStyle = '#8a7f5a'
ctx.font = '14px Georgia, serif'
ctx.fillText(`Awarded on ${formatDate(issuedAt)}`, W / 2, 430)
// Decorative flourish bottom
ctx.strokeStyle = '#c8952a'
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.moveTo(W / 2 - 180, 460)
ctx.lineTo(W / 2 + 180, 460)
ctx.stroke()
// Footer
ctx.fillStyle = '#5a5040'
ctx.font = '12px Georgia, serif'
ctx.fillText('versebyversewithnate.us', W / 2, 490)
}
export function StudyCertificate({ studySlug }: Props) {
const [status, setStatus] = useState<CertificateStatus | null>(null)
const [loading, setLoading] = useState(true)
const [issuing, setIssuing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [displayName, setDisplayName] = useState('')
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
fetch(`/api/study-certificate/${studySlug}`)
.then(r => r.json())
.then(data => {
setStatus(data)
// Try to get display name from account
return fetch('/api/study-account')
})
.then(r => r.json())
.then(data => {
if (data?.displayName) setDisplayName(data.displayName)
else if (data?.username) setDisplayName(data.username)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [studySlug])
useEffect(() => {
if (status?.token && canvasRef.current) {
drawCertificate(
canvasRef.current,
status.studyTitle,
displayName || 'Student',
status.issuedAt ?? new Date().toISOString(),
)
}
}, [status, displayName])
async function handleIssueCertificate() {
setIssuing(true)
setError(null)
try {
const res = await fetch(`/api/study-certificate/${studySlug}`, { method: 'POST' })
const data = await res.json()
if (!res.ok) { setError(data.message ?? 'Failed to issue certificate.'); return }
setStatus(prev => prev ? { ...prev, token: data.token, issuedAt: data.issuedAt } : prev)
} catch {
setError('Network error. Please try again.')
} finally {
setIssuing(false)
}
}
function handleDownload() {
if (!canvasRef.current) return
const link = document.createElement('a')
link.download = `certificate-${studySlug}.png`
link.href = canvasRef.current.toDataURL('image/png')
link.click()
}
function handleShare() {
if (!status?.token) return
const url = `${window.location.origin}/certificate/${status.token}`
if (navigator.share) {
navigator.share({ title: `Certificate ${status.studyTitle}`, url }).catch(() => {})
} else {
navigator.clipboard.writeText(url).then(() => alert('Certificate link copied!')).catch(() => {})
}
}
if (loading) return <p className="study-cert-loading">Checking completion status</p>
if (!status) return null
const progressPct = status.totalCount > 0
? Math.round((status.completedCount / status.totalCount) * 100)
: 0
return (
<div className="study-cert-panel">
<h3 className="study-cert-heading">Certificate of Completion</h3>
{!status.eligible && (
<div className="study-cert-progress">
<p className="study-cert-progress-label">
{status.completedCount} of {status.totalCount} sections completed ({progressPct}%)
</p>
<div className="study-cert-progress-bar-wrap">
<div
className="study-cert-progress-bar"
style={{ width: `${progressPct}%` }}
/>
</div>
<p className="study-cert-note">
Complete all sections to earn your certificate.
</p>
</div>
)}
{status.eligible && !status.token && (
<div className="study-cert-eligible">
<p className="study-cert-congrats">
🎉 Congratulations! You've completed <strong>{status.studyTitle}</strong>.
</p>
{error && <p className="study-cert-error">{error}</p>}
<button
type="button"
className="study-cert-btn"
onClick={handleIssueCertificate}
disabled={issuing}
>
{issuing ? 'Generating' : 'Generate My Certificate'}
</button>
</div>
)}
{status.token && (
<div className="study-cert-issued">
<p className="study-cert-issued-label">
Issued on {formatDate(status.issuedAt)}
</p>
<canvas ref={canvasRef} className="study-cert-canvas" aria-label="Certificate of completion" />
<div className="study-cert-actions">
<button type="button" className="study-cert-btn" onClick={handleDownload}>
Download PNG
</button>
<button type="button" className="study-cert-btn study-cert-btn--secondary" onClick={handleShare}>
Share Link
</button>
</div>
</div>
)}
</div>
)
}
+150
View File
@@ -0,0 +1,150 @@
import { useEffect, useRef, useState } from 'react'
interface Comment {
id: string
displayName: string
text: string
createdAt: string
}
interface Props {
studySlug: string
sectionId: string
isEnrolled: boolean
}
function formatDate(iso: string) {
try {
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
} catch {
return ''
}
}
export function StudySectionComments({ studySlug, sectionId, isEnrolled }: Props) {
const [comments, setComments] = useState<Comment[]>([])
const [loading, setLoading] = useState(true)
const [text, setText] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const textRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
setLoading(true)
fetch(`/api/study-comments/${studySlug}/${sectionId}`)
.then(r => r.json())
.then(data => setComments(Array.isArray(data.comments) ? data.comments : []))
.catch(() => setComments([]))
.finally(() => setLoading(false))
}, [studySlug, sectionId])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!text.trim()) return
setSubmitting(true)
setError(null)
setSuccess(false)
try {
const res = await fetch(`/api/study-comments/${studySlug}/${sectionId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text.trim() }),
})
const data = await res.json()
if (!res.ok) {
setError(data.message ?? 'Failed to post comment.')
return
}
setComments(prev => [...prev, data.comment])
setText('')
setSuccess(true)
setTimeout(() => setSuccess(false), 3000)
} catch {
setError('Network error. Please try again.')
} finally {
setSubmitting(false)
}
}
async function handleDelete(commentId: string) {
setDeletingId(commentId)
try {
await fetch(`/api/study-comments/${commentId}`, { method: 'DELETE' })
setComments(prev => prev.filter(c => c.id !== commentId))
} catch {
// ignore
} finally {
setDeletingId(null)
}
}
return (
<section className="study-comments">
<h3 className="study-comments-heading">Share Your Thoughts</h3>
{loading ? (
<p className="study-comments-loading">Loading comments</p>
) : comments.length === 0 ? (
<p className="study-comments-empty">No comments yet. Be the first to share a thought!</p>
) : (
<ul className="study-comments-list">
{comments.map(comment => (
<li key={comment.id} className="study-comment">
<div className="study-comment-header">
<span className="study-comment-author">{comment.displayName}</span>
<span className="study-comment-date">{formatDate(comment.createdAt)}</span>
</div>
<p className="study-comment-text">{comment.text}</p>
<button
type="button"
className="study-comment-delete"
onClick={() => handleDelete(comment.id)}
disabled={deletingId === comment.id}
aria-label="Delete comment"
>
{deletingId === comment.id ? 'Deleting…' : 'Delete'}
</button>
</li>
))}
</ul>
)}
{isEnrolled ? (
<form className="study-comment-form" onSubmit={handleSubmit}>
<label htmlFor="study-comment-input" className="study-comment-form-label">
Leave a comment
</label>
<textarea
id="study-comment-input"
ref={textRef}
className="study-comment-textarea"
value={text}
onChange={e => setText(e.target.value)}
placeholder="Share a thought, question, or encouragement…"
rows={3}
maxLength={2000}
disabled={submitting}
/>
<div className="study-comment-form-footer">
<span className="study-comment-char-count">{text.length}/2000</span>
{error && <span className="study-comment-error">{error}</span>}
{success && <span className="study-comment-success">Comment posted!</span>}
<button
type="submit"
className="study-comment-submit"
disabled={submitting || !text.trim()}
>
{submitting ? 'Posting…' : 'Post Comment'}
</button>
</div>
</form>
) : (
<p className="study-comments-enroll-note">
<a href="/study">Enroll in this study</a> to join the discussion.
</p>
)}
</section>
)
}
+165
View File
@@ -0,0 +1,165 @@
import { useMemo, useState, useEffect, useRef } from 'react'
import Fuse from 'fuse.js'
import type { SiteContent } from '../content'
export type SearchResultType = 'episode' | 'question' | 'resource' | 'series'
export interface SearchResult {
id: string
type: SearchResultType
title: string
subtitle?: string
description?: string
href: string
/** Snippet from script text — only present on script-matched episode results */
scriptSnippet?: string
}
interface PublicQuestion {
id: string
firstName?: string
question: string
answer?: string
topic?: string
}
interface ScriptSearchResult {
episodeNumber: string
title: string
filename: string
snippet: string
score: number
}
export function useGlobalSearch(content: SiteContent) {
const [query, setQuery] = useState('')
const [questions, setQuestions] = useState<PublicQuestion[]>([])
const [episodeItems, setEpisodeItems] = useState<SearchResult[]>([])
const [scriptResults, setScriptResults] = useState<SearchResult[]>([])
const scriptDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Fetch questions once on mount
useEffect(() => {
fetch('/api/questions')
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { questions?: PublicQuestion[] }) => {
setQuestions(Array.isArray(data.questions) ? data.questions : [])
})
.catch(() => {})
}, [])
// Fetch full episode list once on mount (for title/description matching)
useEffect(() => {
fetch('/api/episodes/all')
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { episodes?: Array<{ title: string; link: string; description?: string; episode?: string }> }) => {
const eps: SearchResult[] = (data.episodes ?? []).map(ep => ({
id: `episode-${ep.episode ?? ep.title}`,
type: 'episode' as const,
title: ep.title,
subtitle: ep.episode ? `Episode ${ep.episode}` : 'Episode',
description: ep.description,
href: ep.link || '/episodes',
}))
setEpisodeItems(eps)
})
.catch(() => {})
}, [])
// Debounced server-side script search — runs whenever query changes
useEffect(() => {
if (scriptDebounceRef.current) clearTimeout(scriptDebounceRef.current)
if (!query.trim() || query.trim().length < 2) {
setScriptResults([])
return
}
scriptDebounceRef.current = setTimeout(() => {
fetch(`/api/episode-scripts/search?q=${encodeURIComponent(query.trim())}`)
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { results?: ScriptSearchResult[] }) => {
const results = (data.results ?? []).map(r => ({
id: `script-${r.episodeNumber}`,
type: 'episode' as const,
title: r.title,
subtitle: `Episode ${r.episodeNumber} · Script`,
description: r.snippet,
scriptSnippet: r.snippet,
href: `/episodes`,
}))
setScriptResults(results)
})
.catch(() => setScriptResults([]))
}, 200)
}, [query])
// Build the Fuse corpus: episodes + resources + series + Q&A
const staticItems: SearchResult[] = useMemo(() => {
const results: SearchResult[] = []
for (const link of content.customLinks ?? []) {
if (link.placement === 'resources' || link.placement === 'otherSites' || link.placement === 'externalSites') {
results.push({
id: `resource-${link.id}`,
type: 'resource',
title: link.label,
subtitle: link.placement === 'resources' ? 'Download' : 'Resource',
description: link.description,
href: `/downloads/${link.id}`,
})
}
}
for (const series of content.archivedSeries ?? []) {
results.push({
id: `series-${series.id}`,
type: 'series',
title: series.title,
subtitle: series.label || 'Archived Series',
description: series.description,
href: `/episodes#${series.id}`,
})
}
for (const q of questions) {
results.push({
id: `qa-${q.id}`,
type: 'question',
title: q.question,
subtitle: q.topic ?? 'Q&A',
description: q.answer ? q.answer.slice(0, 120) : undefined,
href: `/questions`,
})
}
return results
}, [content, questions])
const allFuseItems = useMemo(() => [...episodeItems, ...staticItems], [episodeItems, staticItems])
const fuse = useMemo(() => new Fuse(allFuseItems, {
keys: [
{ name: 'title', weight: 2 },
{ name: 'subtitle', weight: 0.5 },
{ name: 'description', weight: 1 },
],
threshold: 0.35,
minMatchCharLength: 2,
includeScore: true,
}), [allFuseItems])
const results = useMemo(() => {
const q = query.trim()
if (!q || q.length < 2) return []
const fuseMatches = fuse.search(q).slice(0, 6).map(r => r.item)
// Merge script results — deduplicate by episode id (prefer script match if already in fuse)
const fuseIds = new Set(fuseMatches.map(r => r.id))
const dedupedScripts = scriptResults.filter(r => !fuseIds.has(r.id))
// Interleave: fuse results first, then script-only hits, total cap 8
return [...fuseMatches, ...dedupedScripts].slice(0, 8)
}, [fuse, query, scriptResults])
return { query, setQuery, results }
}
+38
View File
@@ -0,0 +1,38 @@
import { useEffect } from 'react'
/**
* Sets page <title> and og meta tags for each route.
* Uses direct DOM mutation — no react-helmet required.
*/
export function usePageMeta(
title: string,
description?: string,
imageUrl?: string,
) {
useEffect(() => {
if (title) document.title = title
function setMeta(property: string, content: string | undefined, attr: 'property' | 'name' = 'property') {
if (!content) return
let el = document.querySelector<HTMLMetaElement>(`meta[${attr}="${property}"]`)
if (!el) {
el = document.createElement('meta')
el.setAttribute(attr, property)
document.head.appendChild(el)
}
el.setAttribute('content', content)
}
setMeta('og:title', title)
setMeta('twitter:title', title, 'name')
if (description) {
setMeta('og:description', description)
setMeta('description', description, 'name')
setMeta('twitter:description', description, 'name')
}
if (imageUrl) {
setMeta('og:image', imageUrl)
setMeta('twitter:image', imageUrl, 'name')
}
}, [title, description, imageUrl])
}