import { useState, useEffect, useRef, useMemo } from 'react'
import type { ReactElement } from 'react'
import { Link, NavLink, Navigate, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
import AdminPage from './AdminPage'
import QASection from './components/QASection'
import ContactForm from './components/ContactForm'
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyDiscussionHubPage, StudyQuizPage, StudyResetPasswordPage, StudyVerifyEmailPage } from './studyPages'
import { SpotifyIcon } from './icons'
import type { SiteContent, StudyProgram, Testimonial, PodcastFeaturedLink } from './content'
import { DEFAULTS, getAllHighlights } from './content'
import { usePageMeta } from './hooks/usePageMeta'
import { useGlobalSearch } from './hooks/useGlobalSearch'
import { GlobalSearch } from './components/GlobalSearch'
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
import { NuggetsHostPortal } from './components/NuggetsHostPortal'
import { HeaderAccount } from './components/HeaderAccount'
import { ResourcesHub } from './components/ResourcesHub'
import { StoreLink } from './components/StoreLink'
import { BIBLE_BOOKS } from '../shared/bible-books.js'
import './App.css'
import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useLinkClickTracking, checkAnalyticsConsentResponse } from './analytics'
const CONSENT_KEY = 'vbn_analytics_consent_choice'
const HEADLINER_WIDGET_ID = 'WI_cmou3b4q7000701p0o9qmmcfj'
function ScrollToTop() {
const { pathname } = useLocation()
useEffect(() => { window.scrollTo(0, 0) }, [pathname])
return null
}
function usePageTracking() {
const location = useLocation()
const isFirstRun = useRef(true)
useEffect(() => {
// The initial hard page load is already counted server-side by the
// hit-counting middleware in server.js (it sees the raw HTML request).
// Only client-side route changes (no new HTTP request) need reporting
// here — otherwise every fresh visit/refresh gets double-counted.
if (isFirstRun.current) {
isFirstRun.current = false
return
}
if (localStorage.getItem(CONSENT_KEY) !== 'accepted') return
const referrer = document.referrer || ''
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
fetch('/api/analytics/pageview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: location.pathname, referrer }),
keepalive: true,
signal: controller.signal,
}).then(checkAnalyticsConsentResponse).catch(() => {}).finally(() => clearTimeout(timeout))
}, [location.pathname])
}
function toSpotifyEpisodeEmbedUrl(url: string | undefined): string {
if (!url) return ''
try {
const parsed = new URL(url)
const host = parsed.hostname.toLowerCase()
const parts = parsed.pathname.split('/').filter(Boolean)
if (host === 'open.spotify.com') {
if (parts[0] === 'embed' && parts[1] === 'episode' && parts[2]) {
return `https://open.spotify.com/embed/episode/${parts[2]}?utm_source=generator`
}
if (parts[0] === 'episode' && parts[1]) {
return `https://open.spotify.com/embed/episode/${parts[1]}?utm_source=generator`
}
}
} catch {
return ''
}
return ''
}
function isLikelySpotifyEpisodeUrl(url: string | undefined): boolean {
if (!url) return false
if (toSpotifyEpisodeEmbedUrl(url)) return true
try {
const parsed = new URL(url)
const host = parsed.hostname.toLowerCase()
const path = parsed.pathname.toLowerCase()
if (host === 'creators.spotify.com' && path.includes('/episodes/')) return true
if (host === 'anchor.fm' && path.includes('/episodes/')) return true
if (host === 'podcasters.spotify.com' && path.includes('/episodes/')) return true
} catch {
return false
}
return false
}
function getHomepageFeaturedStudy(content: SiteContent): StudyProgram | null {
const studies = Array.isArray(content.studies) ? content.studies : []
if (studies.length === 0) return null
return studies.find(study => study.showOnHomepage === true)
?? studies.find(study => study.status === 'active')
?? studies[0]
}
function HeadlinerWidget() {
const [iframeSrc, setIframeSrc] = useState('')
const [status, setStatus] = useState<'loading' | 'ready' | 'empty'>('loading')
useEffect(() => {
const getDiscoUrl = () => {
const canonicalHref = document.querySelector('link[rel="canonical"]')?.getAttribute('href')
const ogUrl = document.querySelector('meta[property="og:url"]')?.getAttribute('content')
const base = canonicalHref || ogUrl
if (!base) return window.location.href
try {
const parsed = new URL(base)
return `${parsed.origin}${window.location.pathname}`
} catch {
return window.location.href
}
}
const discoUrl = getDiscoUrl()
const discoTitle = document.querySelector('meta[property="og:title"]')?.getAttribute('content') || document.title
const src = `https://disco.headliner.link/d/web/widget.html?widgetId=${encodeURIComponent(HEADLINER_WIDGET_ID)}&url=${encodeURIComponent(discoUrl)}&title=${encodeURIComponent(discoTitle)}`
const sessionId = `SS_siteforge_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
const query = new URLSearchParams({
sessionId,
url: discoUrl,
widgetId: HEADLINER_WIDGET_ID,
})
fetch(`https://api.headliner.link/api/v1/widget/widget-request?${query.toString()}`)
.then(r => (r.ok ? r.json() : Promise.reject(new Error('widget request failed'))))
.then((data: { results?: unknown[] }) => {
if (Array.isArray(data.results) && data.results.length > 0) {
setIframeSrc(src)
setStatus('ready')
return
}
setStatus('empty')
})
.catch(() => {
// If API probing fails, still attempt iframe rendering.
setIframeSrc(src)
setStatus('ready')
})
}, [])
return (
status === 'empty' ? null : (
{status === 'ready' && iframeSrc && (
)}
)
)
}
function DownloadForm({ endpoint, extraBody, buttonText }: { endpoint: string; extraBody?: Record; buttonText: string }) {
const [fields, setFields] = useState({ firstName: '', lastName: '', email: '' })
const [subscribe, setSubscribe] = useState(true)
const [honey, setHoney] = useState('')
const [status, setStatus] = useState<'idle' | 'submitting' | 'error' | 'success'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [downloadUrl, setDownloadUrl] = useState('')
function handleChange(e: React.ChangeEvent) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setStatus('submitting')
setErrorMsg('')
setDownloadUrl('')
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...extraBody, ...fields, subscribe, _honey: honey }),
})
const data = await res.json().catch(() => ({})) as { message?: string; downloadUrl?: string }
if (!res.ok || !data.downloadUrl) {
setErrorMsg(data.message ?? 'Could not process your request. Please try again.')
setStatus('error')
return
}
setStatus('success')
setDownloadUrl(data.downloadUrl)
window.location.assign(data.downloadUrl)
} catch {
setErrorMsg('Could not connect. Please try again later.')
setStatus('error')
}
}
return (
)
}
function StudyDownloadForm({ buttonText = 'Download Guide' }: { buttonText?: string }) {
return
}
function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string; buttonText: string }) {
return
}
function buildCustomResourceDownloadId(id: string) {
return `custom:${id}`
}
function buildCustomDownloadPageId(id: string) {
return `custom--${id}`
}
interface DownloadPageResource {
id: string
label: string
imageUrl?: string
summary: string
tags: string[]
buttonText: string
resourceId: string
amazonUrl?: string
amazonLabel?: string
appleBooksUrl?: string
appleBooksLabel?: string
isPrimaryGuide?: boolean
}
function resolveDownloadPageResource(content: SiteContent, pageId: string | undefined): DownloadPageResource | null {
if (!pageId) return null
if (pageId === 'primary-guide') {
return {
id: 'primary-guide',
label: content.studyGuideTitle || 'Companion Study Guide',
imageUrl: content.seriesImageUrl || '/images/titus-cover.png',
summary: content.studyGuideDescription || 'Complete the short form below and your download will start right away.',
tags: [],
buttonText: `Download ${content.studyGuideTitle || 'Guide'}`,
resourceId: 'primary-guide',
amazonUrl: content.studyGuideUrl,
amazonLabel: content.studyGuideAmazonButtonLabel,
appleBooksUrl: content.studyGuideAppleBooksUrl,
appleBooksLabel: content.studyGuideAppleBooksLabel,
isPrimaryGuide: true,
}
}
// Companion guide of the second series, or one kept on a finished book
const guideSource = pageId === 'second-guide'
? { guide: content.secondSeries?.guide, imageUrl: content.secondSeries?.imageUrl, resourceId: 'guide:second' }
: pageId.startsWith('book-guide--')
? (() => {
const book = (content.finishedBooks ?? []).find(item => item.id === pageId.slice('book-guide--'.length))
return { guide: book?.guide, imageUrl: book?.imageUrl, resourceId: `guide:book:${book?.id ?? ''}` }
})()
: null
if (guideSource) {
const guide = guideSource.guide
if (!guide?.title) return null
return {
id: pageId,
label: guide.title,
imageUrl: guideSource.imageUrl,
summary: guide.description || 'Complete the short form below and your download will start right away.',
tags: [],
buttonText: `Download ${guide.title}`,
resourceId: guideSource.resourceId,
amazonUrl: guide.amazonUrl,
amazonLabel: guide.amazonLabel,
appleBooksUrl: guide.appleBooksUrl,
appleBooksLabel: guide.appleBooksLabel,
}
}
if (pageId.startsWith('custom--')) {
const customId = pageId.slice('custom--'.length)
const resource = (content.customLinks ?? []).find(link => link.id === customId && link.placement === 'resources')
if (!resource) return null
return {
id: pageId,
label: resource.label,
imageUrl: resource.imageUrl,
summary: resource.description || 'Complete the short form below and your download will start right away.',
tags: resource.tags ?? [],
buttonText: `Download ${resource.label}`,
resourceId: buildCustomResourceDownloadId(resource.id),
amazonUrl: resource.amazonUrl,
amazonLabel: resource.amazonLabel,
appleBooksUrl: resource.appleBooksUrl,
appleBooksLabel: resource.appleBooksLabel,
}
}
return null
}
function AnalyticsConsentBanner({ content }: { content: SiteContent }) {
const [choice, setChoice] = useState<'unknown' | 'accepted' | 'declined'>(() => {
const saved = localStorage.getItem(CONSENT_KEY)
if (saved === 'accepted' || saved === 'declined') return saved
return 'unknown'
})
async function sendChoice(consent: boolean) {
await fetch('/api/analytics-consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ consent }),
})
}
async function accept() {
setChoice('accepted')
localStorage.setItem(CONSENT_KEY, 'accepted')
try {
await sendChoice(true)
} catch {
// Keep local preference even if network fails.
}
}
async function decline() {
setChoice('declined')
localStorage.setItem(CONSENT_KEY, 'declined')
try {
await sendChoice(false)
} catch {
// Keep local preference even if network fails.
}
}
if (choice !== 'unknown') return null
return (
🍪 Help us improve the site
{content.cookieBannerText || 'We use optional analytics cookies to measure visits and location trends for site improvement.'}
Privacy policy
Decline
Accept
)
}
function SiteSearchBar({ content }: { content: SiteContent }) {
const { query, setQuery, results } = useGlobalSearch(content)
return (
)
}
function SiteHeader({ content }: { content: SiteContent }) {
const [menuOpen, setMenuOpen] = useState(false)
const [episodesOpen, setEpisodesOpen] = useState(false)
const [studiesOpen, setStudiesOpen] = useState(false)
const dropdownRef = useRef(null)
const studiesDropdownRef = useRef(null)
const headerRef = useRef(null)
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
const location = useLocation()
const episodesActive = location.pathname.startsWith('/episodes') || location.pathname.startsWith('/finished') || location.pathname.startsWith('/nuggets')
const studiesActive = location.pathname.startsWith('/study') || location.pathname === '/scripture'
// The pinned search bar sits just under the header, whatever height the header is at this screen size
useEffect(() => {
const header = headerRef.current
if (!header) return
const update = () => document.documentElement.style.setProperty('--site-header-h', `${header.getBoundingClientRect().height}px`)
update()
const observer = new ResizeObserver(update)
observer.observe(header)
return () => observer.disconnect()
}, [])
useEffect(() => {
function handleOutsideClick(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setEpisodesOpen(false)
}
if (studiesDropdownRef.current && !studiesDropdownRef.current.contains(e.target as Node)) {
setStudiesOpen(false)
}
}
if (episodesOpen || studiesOpen) document.addEventListener('mousedown', handleOutsideClick)
return () => document.removeEventListener('mousedown', handleOutsideClick)
}, [episodesOpen, studiesOpen])
return (
setMenuOpen(open => !open)}
>
{menuOpen ? 'Close' : 'Menu'}
{/* Episodes dropdown */}
{ if (window.matchMedia('(max-width: 900px)').matches) { e.preventDefault(); setEpisodesOpen(o => !o) } else { setMenuOpen(false); setEpisodesOpen(false) } }}>Episodes
setEpisodesOpen(o => !o)} aria-expanded={episodesOpen} aria-label="Episodes menu">
{episodesOpen ? '▴' : '▾'}
`nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Current Series
`nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Finished Books
`nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Nuggets
`header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Q&A
{/* Studies dropdown */}
{ if (window.matchMedia('(max-width: 900px)').matches) { e.preventDefault(); setStudiesOpen(o => !o) } else { setMenuOpen(false); setStudiesOpen(false) } }}>Studies
setStudiesOpen(o => !o)} aria-expanded={studiesOpen} aria-label="Studies menu">
{studiesOpen ? '▴' : '▾'}
`nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>All Studies
`nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Discussion
`nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Scripture Index
`header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Resources
{/* /salvation is a standalone static page, so it needs a real page load rather than a router link */}
setMenuOpen(false)}>The Gospel
`header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>About
`header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Contact
{content.headerFollowLabel || 'Follow on Spotify'}
)
}
function SiteFooter({ content }: { content: SiteContent }) {
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
const appleUrl = content.platformAppleUrl || '/apple'
const youtubeUrl = content.platformYoutubeUrl || 'https://www.youtube.com/@blackzebraem5558'
const amazonUrl = content.platformAmazonUrl || '/amazon'
const creatorUrl = content.platformCreatorUrl || 'https://creators.spotify.com/pod/profile/nmemmert/'
const facebookUrl = content.platformFacebookUrl || 'https://facebook.com/versebyversewithnate'
const kofiUrl = content.platformKofiUrl
const extraFooterLinks = (content.customLinks ?? []).filter(
l => l.placement === 'footer' || l.placement === 'platforms' || l.placement === 'otherSites',
)
return (
✦ ✦ ✦
{content.footerTitle || 'Verse by Verse with Nate'}
{content.footerSubtitle || 'A Journey Through Scripture'}
{content.footerEmail && (
Contact:{' '}
{content.footerEmail}
)}
Spotify
·
Apple Podcasts
·
YouTube
·
Amazon Music
·
Creator Profile
·
Facebook
{kofiUrl && (
<>
·
Support on Ko-fi
>
)}
{extraFooterLinks.length > 0 && (
<>
·
More Links
>
)}
The Gospel
·
Finished Books
·
Newsletter
·
Privacy Policy
·
Terms
{content.footerCopyright || '© 2026 Nate Emmert · Made with faith.'}
{content.footerPrivacyNote && (
{content.footerPrivacyNote}
)}
)
}
function PlatformButtons({ content }: { content: SiteContent }) {
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
const youtubeUrl = content.platformYoutubeUrl || 'https://www.youtube.com/@blackzebraem5558'
const amazonUrl = content.platformAmazonUrl || '/amazon'
const facebookUrl = content.platformFacebookUrl || 'https://facebook.com/versebyversewithnate'
const appleUrl = content.platformAppleUrl || '/apple'
return (
)
}
function KofiSupportSection({ content }: { content: SiteContent }) {
const kofiUrl = content.platformKofiUrl
const qrUrl = content.platformKofiQrUrl
if (!kofiUrl && !qrUrl) return null
return (
Support the Ministry
Help Keep the Word Going
This podcast and its study resources are listener-supported. If God has used this ministry in your life,
consider buying me a coffee. It goes straight toward keeping the teaching free for everyone.
{kofiUrl && (
☕ Support on Ko-fi
)}
{qrUrl && (
{ const fig = e.currentTarget.parentElement; if (fig) fig.style.display = 'none' }} />
Scan to give on Ko-fi
)}
)
}
function AboutSection({ content }: { content: SiteContent }) {
const paths = [
{ to: '/start-here', title: 'New here?', text: 'A short path through the show, one step at a time.' },
{ to: '/episodes', title: 'Listen', text: 'Every episode, newest first.' },
{ to: '/study', title: 'Study with us', text: 'Self-paced Bible studies with notes and discussion.' },
]
return (
{content.aboutEyebrow || 'About Nate'}
{content.aboutNate}
{content.aboutListenBtnLabel || 'Listen Now ↓'}
New here? Start here
{content.aboutShowEyebrow &&
{content.aboutShowEyebrow}
}
{content.aboutShowHeading}
{content.aboutShowP1}
{content.aboutShowP2}
{paths.map(p => (
{p.title}
{p.text}
Go →
))}
)
}
function ContactSection({ content }: { content: SiteContent }) {
const point = (text: string, icon: ReactElement) => (
{icon}
{text}
)
return (
)
}
function HighlightCard({ link }: { link: PodcastFeaturedLink }) {
const hasRichContent = !!(
link.embedUrl
|| isLikelySpotifyEpisodeUrl(link.url)
|| (link.discussionQuestions ?? []).length > 0
|| link.showNotes
|| link.showNotesSummary
|| (link.showNotesThemes ?? []).length > 0
|| (link.showNotesScripture ?? []).length > 0
|| (link.showNotesQuotes ?? []).length > 0
|| (link.showNotesResources ?? []).length > 0
)
const inner = (
<>
{link.episodeNumber && Ep. {link.episodeNumber} }
{link.title || link.url}
{link.summary && {link.summary}
}
Listen →
>
)
if (!hasRichContent && link.url) {
return {inner}
}
return {inner}
}
const HIGHLIGHTS_PREVIEW_COUNT = 6
// Highlights are grouped by the series they belong to, one tab per series, so a long series
// never pushes the others down the page. Each tab previews the newest few with a "show all" option.
function PodcastHighlightsSection({ content }: { content: SiteContent }) {
const [activeKey, setActiveKey] = useState('')
const [expanded, setExpanded] = useState>({})
const links = [...(content.podcastFeaturedLinks ?? [])].reverse()
if (links.length === 0) return null
const laneOf = (link: PodcastFeaturedLink) => (link.lane === 'second' ? 'second' : link.lane === 'general' ? 'general' : 'primary')
const groups = [
{ key: 'primary', title: content.seriesTitle, links: links.filter(link => laneOf(link) === 'primary') },
{ key: 'second', title: content.secondSeries?.title?.trim() || 'Second Series', links: links.filter(link => laneOf(link) === 'second') },
{ key: 'general', title: 'More Highlights', links: links.filter(link => laneOf(link) === 'general') },
].filter(group => group.links.length > 0)
const active = groups.find(group => group.key === activeKey) ?? groups[0]
const isExpanded = expanded[active.key] === true
const shown = isExpanded ? active.links : active.links.slice(0, HIGHLIGHTS_PREVIEW_COUNT)
return (
✦ Podcast Highlights{' '}
✦
{groups.length > 1 && (
{groups.map(group => (
setActiveKey(group.key)}
>
{group.title} {group.links.length}
))}
)}
{shown.map(link => )}
{active.links.length > HIGHLIGHTS_PREVIEW_COUNT && (
setExpanded(prev => ({ ...prev, [active.key]: !isExpanded }))}>
{isExpanded ? 'Show fewer' : `Show all ${active.links.length} highlights`}
)}
)
}
function PageBanner({ eyebrow, title, desc }: { eyebrow: string; title: string; desc?: string }) {
return (
{eyebrow}
{title}
{desc &&
{desc}
}
)
}
function DownloadDetailPage({ content }: { content: SiteContent }) {
const { id } = useParams<{ id: string }>()
const resource = resolveDownloadPageResource(content, id)
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
resource ? `${resource.label} | ${siteTitle}` : `Resource | ${siteTitle}`,
resource?.summary || 'Free study guides and resources from Verse by Verse with Nate.',
)
if (!resource) {
return (
?
Resources
Download not found
The download you requested is not available right now.
Back to Resources
)
}
return (
← Back to Resources
{resource.imageUrl && (
)}
Free download
{resource.label}
{resource.summary &&
{resource.summary}
}
{resource.tags.length > 0 && (
{resource.tags.map(tag => {tag} )}
)}
{/* The title is already on the page, so the button stays short */}
{resource.isPrimaryGuide
?
: }
{(resource.amazonUrl || resource.appleBooksUrl) && (
Or get it from:
{resource.amazonUrl && (
{resource.amazonLabel || 'Get it on Amazon'}
)}
{resource.appleBooksUrl && (
{resource.appleBooksLabel || 'Get it on Apple Books'}
)}
)}
)
}
function ShareShowButton() {
const [feedback, setFeedback] = useState('')
async function handleShare() {
const shareUrl = window.location.origin
const shareTitle = 'Verse by Verse with Nate'
const shareText = 'Check out Verse by Verse with Nate.'
try {
if (navigator.share) {
await navigator.share({ title: shareTitle, text: shareText, url: shareUrl })
setFeedback('Shared successfully.')
return
}
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(shareUrl)
setFeedback('Link copied. Share it with a friend.')
return
}
setFeedback(`Copy this link: ${shareUrl}`)
} catch {
setFeedback('Share canceled.')
}
}
return (
Share the Show
{feedback &&
{feedback}
}
)
}
function linkifyText(text: string) {
const urlRegex = /(https?:\/\/[^\s]+)/g
const parts = text.split(urlRegex)
return parts.map((part, i) =>
urlRegex.test(part)
? {part}
: part
)
}
function CustomBlocksSection({ content, page }: { content: SiteContent; page: string }) {
const blocks = (content.customBlocks ?? []).filter(block => {
const blockPage = block.page ?? 'downloads'
return blockPage === page
})
if (blocks.length === 0) return null
return (
<>
{blocks.map(block => (
))}
>
)
}
function ExternalSitesSection({ content }: { content: SiteContent }) {
const links = (content.customLinks ?? []).filter(link => link.placement === 'externalSites')
if (links.length === 0) return null
return (
Homepage Links
External sites and ministries worth checking out.
)
}
function HomepageNewsletterSection({ embedded = false }: { embedded?: boolean }) {
const [email, setEmail] = useState('')
const [firstName, setFirstName] = useState('')
const [honey, setHoney] = useState('')
const [status, setStatus] = useState<'idle' | 'submitting' | 'done' | 'error'>('idle')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (honey) return
setStatus('submitting')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ firstName, email, messageType: 'general', message: 'Newsletter signup from homepage', subscribe: true, _honey: honey }),
})
setStatus(res.ok ? 'done' : 'error')
} catch {
setStatus('error')
}
}
const content = (
<>
Stay in the Word
Get episode updates by email
New episodes, study resources, and ministry updates — delivered to your inbox.
{status === 'done' ? (
You're subscribed! Thank you for signing up.
) : (
)}
>
)
// `embedded` drops the full-width band so the signup can sit inside another section (homepage connect row).
if (embedded) {
return {content}
}
return (
)
}
type TflWidgetType = 'audiodevo' | 'abdevotional' | 'resourcead'
function TruthForLifeWidget({ type = 'audiodevo' }: { type?: TflWidgetType }) {
const heading = type === 'resourcead' ? 'Recommended Resources' : 'Daily Devotional'
const containerId = 'tfl-syndicate-container'
useEffect(() => {
const container = document.getElementById(containerId)
if (!container) return
container.innerHTML = ''
const existing = document.getElementById('syn_script')
if (existing) existing.remove()
const script = document.createElement('script')
script.id = 'syn_script'
script.src = `https://www.truthforlife.org/static/js/responsive/lib/syndicate.js?type=${type}&id=458890`
script.type = 'text/javascript'
container.parentElement?.insertBefore(script, container)
}, [type, containerId])
return (
)
}
function HomepageQACallout() {
const [questions, setQuestions] = useState<{ id: string; firstName: string; question: string; answer: string }[]>([])
useEffect(() => {
fetch('/api/questions')
.then(r => (r.ok ? r.json() : Promise.reject()))
.then((data: { questions: { id: string; firstName: string; question: string; answer: string }[] }) => {
setQuestions((data.questions ?? []).slice(0, 3))
})
.catch(() => {})
}, [])
if (questions.length === 0) return null
return (
Q&A
✦ Questions from Listeners ✦
Real questions. Scripture-grounded answers.
{questions.map(q => (
"{q.question}"
{q.answer.length > 220 ? q.answer.slice(0, 220).trimEnd() + '…' : q.answer}
{q.firstName &&
— {q.firstName}
}
Read the full answer →
))}
Browse All Q&A →
)
}
function TestimonialsSection({ testimonials }: { testimonials: Testimonial[] }) {
if (!testimonials || testimonials.length === 0) return null
return (
✦ What Listeners Are Saying ✦
{testimonials.map(t => (
"{t.quote}"
— {t.name}
{t.source && {t.source} }
))}
)
}
function LandingPage({ content }: { content: SiteContent }) {
const featuredStudy = getHomepageFeaturedStudy(content)
const { episodes } = useAllEpisodes()
const [prismOpen, setPrismOpen] = useState(false)
const finishedBooks = [...(content.finishedBooks ?? [])].sort((a, b) => b.season - a.season)
const episodesInSeason = (season?: number) => (season ? episodes.filter(ep => ep.season === String(season)).length : 0)
const liveSeries = [
{
key: 'primary',
imageUrl: content.seriesImageUrl,
title: content.seriesTitle,
label: content.seriesLabel,
description: content.seriesDescription,
listenUrl: content.seriesListenUrl,
listenBtnLabel: content.seriesListenBtnLabel,
studySlug: content.seriesStudySlug,
season: content.seriesSeason,
},
...(content.secondSeries?.title?.trim()
? [{
key: 'second',
imageUrl: content.secondSeries.imageUrl,
title: content.secondSeries.title,
label: content.secondSeries.label,
description: content.secondSeries.description,
listenUrl: content.secondSeries.listenUrl,
listenBtnLabel: content.secondSeries.listenBtnLabel,
studySlug: content.secondSeries.studySlug,
season: content.secondSeries.season,
}]
: []),
]
// Fixed "ways in" cards go first; the admin-managed cards follow, skipping any that point to the same page.
const adminCards = (content.whereToNextCards ?? []).map(c => (c.path === '/resources' && c.title === 'Downloads' ? { ...c, title: 'Resources' } : c))
const fixedCards = [
{ id: 'start', path: '/start-here', title: 'Start here', description: 'New to the show? A short path through the best first episodes.' },
{ id: 'verse', path: '/scripture', title: 'Find a verse', description: 'Look up any passage and jump to the episode or lesson that covers it.' },
].filter(card => !adminCards.some(c => c.path === card.path))
return (
{/* ── HERO ── */}
{content.eyebrow}
Verse by Verse
with Nate
{content.heroTagline}
{episodes.length > 0 && (
{episodes.length} episodes
{finishedBooks.length > 0 && <> · {finishedBooks.length} {finishedBooks.length === 1 ? 'book' : 'books'} completed>}
)}
{/* ── LISTEN NOW: latest episode + current series ── */}
Latest Episode
Browse all episodes →
Current Series
{liveSeries.map(series => {
const count = episodesInSeason(series.season)
return (
{series.label}{count > 0 ? ` · ${count} episodes so far` : ''}
{series.title}
{series.description}
)
})}
{/* ── FEATURED STUDY + WAYS IN ── */}
{featuredStudy && (
{featuredStudy.homepageEyebrow || 'Study'}
{featuredStudy.showNewTag && {featuredStudy.newTagLabel || 'NEW'} }
{featuredStudy.title}
{featuredStudy.description || 'Explore this study track with lesson notes, commentary, and guided questions.'}
Start the study
)}
{content.whereToNextEyebrow || 'Where to Next'}
{content.whereToNextHeading || 'Explore the site'}
{[...fixedCards, ...adminCards].map(card => (
{card.title}
{card.description}
→
))}
{/* ── FINISHED BOOKS SHELF ── */}
{finishedBooks.length > 0 && (
Books We've Finished
See all →
{finishedBooks.map(book => (
{book.imageUrl &&
}
{book.title}
))}
)}
{/* ── PRISM ── */}
Your browser does not support embedded video playback.
{content.prismEyebrow || 'Featured Video'}
✦ {content.prismHeading || 'PRISM'} ✦
{content.prismIntro || 'What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?'}
{content.prismStep1 || 'P — Pray before you read'}
{content.prismStep2 || 'R — Read slowly and observe'}
{content.prismStep3 || 'I — Interpret with context'}
{content.prismStep4 || 'S — Study and apply specifically'}
{content.prismStep5 || 'M — Memorize one verse at a time'}
{prismOpen && (
<>
{content.prismPatternIntro || 'In this bonus episode, I walk you through P.R.I.S.M. — a five-step daily Bible study pattern designed to help you slow down, go deep, and let the Word do its work.'}
{content.prismOutro || "Whether you're brand new to daily Bible reading or you've been at it for years, P.R.I.S.M. gives you a consistent framework for every passage, every day."}
{content.prismClosing || "The goal isn't to get through the text — it's to let the text get through to you."}
>
)}
setPrismOpen(v => !v)}>
{prismOpen ? 'Show less' : 'Read more about PRISM'}
{/* ── CONNECT: newsletter + share ── */}
{content.shareHeading || 'Help one more person hear the Word this week.'}
{content.shareP || 'Scan the QR code or text the show link to a friend who needs encouragement today.'}
{(content.platformKofiUrl || content.platformKofiQrUrl) && (
Support the Ministry
Help keep the Word going
This show is listener-supported. Your gift keeps the teaching free for everyone.
{content.platformKofiUrl && (
☕ Support on Ko-fi
)}
{content.platformKofiQrUrl && (
{ e.currentTarget.style.display = 'none' }} />
)}
)}
{/* ── MORE FROM US ── */}
)
}
function EpisodeDetailPage({ content }: { content: SiteContent }) {
const { id } = useParams<{ id: string }>()
const episode = getAllHighlights(content).find(e => e.id === id)
const [resolvedEmbedUrl, setResolvedEmbedUrl] = useState('')
useEffect(() => {
let cancelled = false
if (!episode) {
setResolvedEmbedUrl('')
return () => {
cancelled = true
}
}
const manualEmbed = episode.embedUrl?.trim() ?? ''
if (manualEmbed) {
setResolvedEmbedUrl(manualEmbed)
return () => {
cancelled = true
}
}
if (!episode.url) {
setResolvedEmbedUrl('')
return () => {
cancelled = true
}
}
try {
const urlObj = new URL(episode.url)
const host = urlObj.hostname.toLowerCase()
if (host === 'open.spotify.com') {
const fromUrl = toSpotifyEpisodeEmbedUrl(episode.url)
if (fromUrl) {
setResolvedEmbedUrl(fromUrl)
return () => {
cancelled = true
}
}
}
} catch {
// ignore
}
if (!isLikelySpotifyEpisodeUrl(episode.url)) {
setResolvedEmbedUrl('')
return () => {
cancelled = true
}
}
setResolvedEmbedUrl('')
fetch(`/api/spotify/embed-url?url=${encodeURIComponent(episode.url)}`)
.then(r => (r.ok ? r.json() : Promise.reject(new Error('embed resolve failed'))))
.then((data: { embedUrl?: string }) => {
if (cancelled) return
setResolvedEmbedUrl(data.embedUrl?.trim() ?? '')
})
.catch(() => {
if (cancelled) return
setResolvedEmbedUrl('')
})
return () => {
cancelled = true
}
}, [episode])
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
episode ? `${episode.title} | ${siteTitle}` : `Episode | ${siteTitle}`,
episode?.summary || 'Expository Bible teaching from Verse by Verse with Nate.',
)
if (!episode) {
return (
Episode not found
This episode highlight doesn't exist or may have been removed.
← Back to Episodes
)
}
const questions = episode.discussionQuestions ?? []
const themes = episode.showNotesThemes ?? []
const scripture = episode.showNotesScripture ?? []
const quotes = episode.showNotesQuotes ?? []
const resources = episode.showNotesResources ?? []
const notesText = episode.showNotesSummary || (!themes.length ? episode.showNotes : '')
const hasMain = Boolean(notesText) || quotes.length > 0 || questions.length > 0
const hasSide = themes.length > 0 || scripture.length > 0 || resources.length > 0
return (
Episodes
›
{episode.episodeNumber ? `Episode ${episode.episodeNumber}` : episode.title}
{episode.episodeNumber &&
Episode {episode.episodeNumber}
}
{episode.title}
{episode.summary &&
{episode.summary}
}
{(episode.embedUrl || episode.url) && (
{resolvedEmbedUrl ? (
) : (
)}
)}
{episode.url && (
Listen on your podcast app →
)}
{(hasMain || hasSide) && (
{hasMain && (
{notesText && (
)}
{quotes.length > 0 && (
Key Quotes
{quotes.map((q, i) =>
{q} )}
)}
{questions.length > 0 && (
Discussion Questions
{questions.map((q, i) => {q} )}
)}
)}
{hasSide && (
)}
)}
)
}
type PodEpisode = { title: string; episode: string; season: string; duration: string; audioUrl: string; link: string; description: string; pubDate: string }
function useAllEpisodes() {
const [episodes, setEpisodes] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/episodes/all')
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { episodes?: PodEpisode[] }) => setEpisodes(data.episodes ?? []))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
return { episodes, loading }
}
function useEpisodesForBook(season: number) {
const { episodes: all, loading } = useAllEpisodes()
const episodes = useMemo(
() => all.filter(ep => ep.season === String(season)).sort((a, b) => parseInt(a.episode) - parseInt(b.episode)),
[all, season],
)
return { episodes, loading }
}
function durationSeconds(d: string) {
if (!d) return 0
if (/^\d+$/.test(d)) return parseInt(d, 10)
const parts = d.split(':').map(Number)
if (parts.some(n => Number.isNaN(n))) return 0
return parts.reduce((acc, n) => acc * 60 + n, 0)
}
function formatRuntime(sec: number) {
if (sec <= 0) return ''
const h = Math.floor(sec / 3600)
const m = Math.round((sec % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
function formatEpisodeDate(pubDate: string) {
const d = new Date(pubDate)
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
// One playlist row: number, title, date/duration, and a Play button that opens the audio player in place.
function EpisodeRow({ ep, label, isOpen, onToggle }: { ep: PodEpisode; label: string | number; isOpen: boolean; onToggle: () => void }) {
const date = formatEpisodeDate(ep.pubDate)
const meta = [date, ep.duration].filter(Boolean).join(' · ')
return (
{label}
{ep.title}
{meta &&
{meta}
}
{ep.audioUrl && (
{isOpen ? 'Hide player' : '▶ Play'}
)}
{isOpen && ep.audioUrl &&
}
)
}
// Maps a finished-book title ("Titus", "Study of 1 John") to its canonical Bible book id.
function bibleBookIdForTitle(title: string): string | null {
const t = title.toLowerCase()
const hits = BIBLE_BOOKS.filter(b => t.includes(b.name.toLowerCase())).sort((a, b) => b.name.length - a.name.length)
return hits[0]?.id ?? null
}
function FinishedBooksPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(`Finished Books | ${siteTitle}`, 'Completed series from Verse by Verse with Nate — browse episode playlists for past book studies.')
const books = content.finishedBooks ?? []
const { episodes } = useAllEpisodes()
const [query, setQuery] = useState('')
const [testament, setTestament] = useState<'all' | 'ot' | 'nt'>('all')
const [sort, setSort] = useState<'recent' | 'canon'>('recent')
const statsBySeason = useMemo(() => {
const map = new Map()
for (const ep of episodes) {
const season = parseInt(ep.season, 10)
if (Number.isNaN(season)) continue
const cur = map.get(season) ?? { count: 0, seconds: 0 }
cur.count += 1
cur.seconds += durationSeconds(ep.duration)
map.set(season, cur)
}
return map
}, [episodes])
const canonIndex = (title: string) => {
const id = bibleBookIdForTitle(title)
const i = id ? BIBLE_BOOKS.findIndex(b => b.id === id) : -1
return i < 0 ? 999 : i
}
const needle = query.trim().toLowerCase()
const filtering = Boolean(needle) || testament !== 'all'
const visible = books
.filter(book => {
if (needle && !`${book.title} ${book.description}`.toLowerCase().includes(needle)) return false
if (testament !== 'all') {
const i = canonIndex(book.title)
if (i === 999 || (testament === 'ot') !== (i < 39)) return false
}
return true
})
.sort((a, b) => sort === 'canon' ? canonIndex(a.title) - canonIndex(b.title) : b.season - a.season)
const showFeatured = !filtering && sort === 'recent' && visible.length > 2
const renderCard = (book: (typeof books)[number], featured: boolean) => {
const stats = statsBySeason.get(book.season)
const facts = [stats ? `${stats.count} episodes` : '', stats ? formatRuntime(stats.seconds) : ''].filter(Boolean).join(' · ')
return (
{book.imageUrl && (
)}
{featured ? `Most recently finished · Season ${book.season}` : `Season ${book.season}`}
{book.title}
{facts &&
{facts}
}
{book.description &&
{book.description}
}
{book.downloadUrl && ⬇ Album }
{book.studySlug && 📖 Study }
{book.guide?.title && 📄 Guide }
View Playlist →
)
}
const pill = (active: boolean) => `si-pill${active ? ' si-pill--on' : ''}`
return (
{books.length === 0 ? (
📖
Nothing here yet
We're still working through the current series. When a book study wraps up, it'll live here with the full episode playlist. Check back soon.
Listen to Current Series →
) : (
<>
{books.length > 3 && (
setQuery(e.target.value)}
/>
setTestament('all')}>All
setTestament('ot')}>Old Testament
setTestament('nt')}>New Testament
setSort('recent')}>Most recent
setSort('canon')}>Bible order
)}
{visible.length === 0 ? (
No finished books match your filters.
) : (
{visible.map((book, i) => renderCard(book, showFeatured && i === 0))}
)}
>
)}
)
}
const FINISHED_PLAYLIST_PREVIEW_COUNT = 10
const FINISHED_PLAYLIST_FILTER_THRESHOLD = 12
type FinishedBookEpisode = PodEpisode
// One section with two tabs instead of two full-length lists stacked on top of each other.
// "Highlights" are the hand-picked episodes (with notes); "All Episodes" is the complete
// ordered playlist, which previews the first few, can be filtered, and only loads an audio
// player for the episode you open.
function FinishedSeriesEpisodes({ highlights, episodes, loading }: {
highlights: PodcastFeaturedLink[]
episodes: FinishedBookEpisode[]
loading: boolean
}) {
const hasHighlights = highlights.length > 0
const [tab, setTab] = useState<'highlights' | 'all'>(hasHighlights ? 'highlights' : 'all')
const [highlightsExpanded, setHighlightsExpanded] = useState(false)
const [playlistExpanded, setPlaylistExpanded] = useState(false)
const [filter, setFilter] = useState('')
const [openKey, setOpenKey] = useState(null)
const activeTab = hasHighlights ? tab : 'all'
useEffect(() => {
function onHash() {
if (window.location.hash === '#episodes') setTab('all')
}
onHash()
window.addEventListener('hashchange', onHash)
return () => window.removeEventListener('hashchange', onHash)
}, [])
const orderedHighlights = [...highlights].reverse()
const shownHighlights = highlightsExpanded ? orderedHighlights : orderedHighlights.slice(0, HIGHLIGHTS_PREVIEW_COUNT)
const query = filter.trim().toLowerCase()
const matching = query
? episodes.filter(ep => `${ep.episode} ${ep.title}`.toLowerCase().includes(query))
: episodes
const showAllPlaylist = playlistExpanded || query.length > 0
const shownEpisodes = showAllPlaylist ? matching : matching.slice(0, FINISHED_PLAYLIST_PREVIEW_COUNT)
return (
{hasHighlights && (
setTab('highlights')}
>
Highlights {highlights.length}
)}
setTab('all')}
>
All Episodes {!loading && {episodes.length} }
{activeTab === 'highlights' ? (
<>
{shownHighlights.map(link => )}
{orderedHighlights.length > HIGHLIGHTS_PREVIEW_COUNT && (
setHighlightsExpanded(v => !v)}>
{highlightsExpanded ? 'Show fewer' : `Show all ${orderedHighlights.length} highlights`}
)}
>
) : loading ? (
Loading episodes…
) : episodes.length === 0 ? (
No episodes found for this season.
) : (
<>
{episodes.length > FINISHED_PLAYLIST_FILTER_THRESHOLD && (
setFilter(e.target.value)}
/>
)}
{matching.length === 0 ? (
No episodes match “{filter.trim()}”.
) : (
{shownEpisodes.map((ep, i) => {
const key = ep.audioUrl || `${ep.episode}-${i}`
return setOpenKey(openKey === key ? null : key)} />
})}
)}
{!query && matching.length > FINISHED_PLAYLIST_PREVIEW_COUNT && (
setPlaylistExpanded(v => !v)}>
{playlistExpanded ? 'Show fewer' : `Show all ${matching.length} episodes`}
)}
>
)}
)
}
function FinishedSeriesPage({ content }: { content: SiteContent }) {
const { id } = useParams<{ id: string }>()
const book = (content.finishedBooks ?? []).find(b => b.id === id)
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
book ? `${book.title} | ${siteTitle}` : `Finished Series | ${siteTitle}`,
book?.description || 'Episode playlist for a completed series.',
)
const { episodes, loading } = useEpisodesForBook(book?.season ?? 0)
if (!book) {
return (
Series not found. ← Back to Finished Books
)
}
return (
{book.imageUrl && (
)}
Finished Books
›
{book.title}
Season {book.season}{!loading && episodes.length > 0 ? ` · ${episodes.length} episodes` : ''}
{book.title}
{book.description &&
{book.description}
}
{book.downloadQrUrl && book.downloadUrl && (
Scan to download the full album
)}
)
}
function LatestEpisodePlayer({ content }: { content: SiteContent }) {
const audioRef = useRef(null)
const [audioUrl, setAudioUrl] = useState('')
const [title, setTitle] = useState('')
const [playing, setPlaying] = useState(false)
const [buffering, setBuffering] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const playTrackedRef = useRef(false)
const listenStartRef = useRef(null)
useEffect(() => {
fetch('/api/episodes')
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { episodes?: { title: string; audioUrl: string }[] }) => {
const latest = data.episodes?.[0]
if (latest?.audioUrl) { setAudioUrl(latest.audioUrl); setTitle(latest.title) }
})
.catch(() => {})
}, [])
useEffect(() => {
const audio = audioRef.current
if (!audio) return
const onTime = () => setCurrentTime(audio.currentTime)
const onDur = () => setDuration(audio.duration)
const onEnded = () => { setPlaying(false); setBuffering(false) }
const onWait = () => setBuffering(true)
const onCan = () => setBuffering(false)
audio.addEventListener('timeupdate', onTime)
audio.addEventListener('durationchange', onDur)
audio.addEventListener('loadedmetadata', onDur)
audio.addEventListener('ended', onEnded)
audio.addEventListener('waiting', onWait)
audio.addEventListener('canplay', onCan)
return () => {
audio.removeEventListener('timeupdate', onTime)
audio.removeEventListener('durationchange', onDur)
audio.removeEventListener('loadedmetadata', onDur)
audio.removeEventListener('ended', onEnded)
audio.removeEventListener('waiting', onWait)
audio.removeEventListener('canplay', onCan)
}
}, [audioUrl])
function togglePlay() {
const audio = audioRef.current
if (!audio) return
if (playing) {
audio.pause(); setPlaying(false)
if (listenStartRef.current !== null) listenStartRef.current = null
} else {
audio.play().then(() => {
setPlaying(true)
listenStartRef.current = Date.now()
if (!playTrackedRef.current && title) {
playTrackedRef.current = true
fetch('/api/analytics/play', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }) }).then(checkAnalyticsConsentResponse).catch(() => {})
}
}).catch(() => {})
}
}
function skip(sec: number) {
const audio = audioRef.current
if (!audio) return
audio.currentTime = Math.max(0, Math.min(duration, audio.currentTime + sec))
}
function seek(e: React.MouseEvent) {
const audio = audioRef.current
if (!audio || !duration) return
const rect = e.currentTarget.getBoundingClientRect()
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
audio.currentTime = pct * duration
setCurrentTime(pct * duration)
}
function fmt(s: number) {
if (!isFinite(s) || s < 0) return '0:00'
return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`
}
const pct = duration > 0 ? (currentTime / duration) * 100 : 0
if (!audioUrl) return
return (
Verse by Verse with Nate
{title &&
{title}
}
skip(-15)} aria-label="Back 15 seconds">
15
back
{playing
?
:
}
skip(30)} aria-label="Forward 30 seconds">
30
fwd
{ const a = audioRef.current; if (!a) return; if (e.key === 'ArrowRight') a.currentTime = Math.min(duration, a.currentTime + 10); if (e.key === 'ArrowLeft') a.currentTime = Math.max(0, a.currentTime - 10) }}>
{fmt(currentTime)}
{fmt(duration)}
{content.platformSpotifyUrl && (
)}
)
}
const EPISODES_PREVIEW_COUNT = 8
function EpisodesPage({ content }: { content: SiteContent }) {
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.',
)
const { episodes, loading } = useAllEpisodes()
const [query, setQuery] = useState('')
const [season, setSeason] = useState('all')
const [expanded, setExpanded] = useState(false)
const [openKey, setOpenKey] = useState(null)
const finishedBooks = [...(content.finishedBooks ?? [])].sort((a, b) => b.season - a.season)
const second = content.secondSeries?.title?.trim() ? content.secondSeries : null
const liveSeries = [
{ key: content.seriesSeason ? String(content.seriesSeason) : '', title: content.seriesTitle, label: second ? (content.seriesLabel || 'Series A') : 'Current series', description: content.seriesDescription, imageUrl: content.seriesImageUrl, season: content.seriesSeason },
...(second ? [{ key: second.season ? String(second.season) : '', title: second.title, label: second.label || 'Series B', description: second.description, imageUrl: second.imageUrl, season: second.season }] : []),
]
const seasonChips = [
...liveSeries.filter(ls => ls.key).map(ls => ({ key: ls.key, label: second ? ls.title : 'Current series' })),
...finishedBooks.map(book => ({ key: String(book.season), label: book.title })),
]
const countFor = (key: string) => episodes.filter(ep => ep.season === key).length
const needle = query.trim().toLowerCase()
const matching = episodes.filter(ep => {
if (season !== 'all' && ep.season !== season) return false
return !needle || `${ep.episode} ${ep.title}`.toLowerCase().includes(needle)
})
const showAll = expanded || needle.length > 0
const shown = showAll ? matching : matching.slice(0, EPISODES_PREVIEW_COUNT)
const activeBook = finishedBooks.find(book => String(book.season) === season)
const pill = (active: boolean) => `si-pill${active ? ' si-pill--on' : ''}`
return (
{content.episodesSeoIntro || 'Verse by Verse with Nate is an expository Bible teaching podcast where we go through Scripture one verse at a time. Each episode digs into the text carefully and practically, helping you understand what the Bible says, what it means, and how to live it out. New episodes released regularly — subscribe on Spotify or your favorite podcast platform.'}
{liveSeries.map((ls, i) => (
{ls.imageUrl &&
}
))}
✦ Browse Episodes ✦
setQuery(e.target.value)}
/>
{seasonChips.length > 0 && (
{ setSeason('all'); setExpanded(false) }}>
All{!loading && {episodes.length} }
{seasonChips.map(chip => (
{ setSeason(chip.key); setExpanded(false) }}>
{chip.label}{!loading && {countFor(chip.key)} }
))}
)}
{activeBook && (
Open the full {activeBook.title} playlist →
)}
{loading ? (
Loading episodes…
) : matching.length === 0 ? (
{needle ? `No episodes match “${query.trim()}”.` : 'No episodes found.'}
) : (
{shown.map((ep, i) => {
const key = ep.audioUrl || `${ep.episode}-${i}`
return setOpenKey(openKey === key ? null : key)} />
})}
)}
{!needle && matching.length > EPISODES_PREVIEW_COUNT && (
setExpanded(v => !v)}>
{expanded ? 'Show fewer' : `Show all ${matching.length} episodes`}
)}
)
}
interface NuggetsEpisode {
title: string
pubDate: string
link: string
audioUrl: string
description: string
duration: string
episode: string
season: string
tags?: string[]
}
function NuggetsPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
const nuggets = content.nuggets ?? DEFAULTS.nuggets
usePageMeta(
`${nuggets.title} | ${siteTitle}`,
nuggets.description || nuggets.tagline || `${nuggets.title} — a companion show from ${siteTitle}.`,
)
const [episodes, setEpisodes] = useState([])
const [loading, setLoading] = useState(true)
const [configured, setConfigured] = useState(false)
useEffect(() => {
fetch('/api/nuggets/episodes/all')
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then((d: { episodes?: NuggetsEpisode[]; configured?: boolean }) => {
setEpisodes(Array.isArray(d.episodes) ? d.episodes : [])
setConfigured(d.configured !== false)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const [query, setQuery] = useState('')
const [shown, setShown] = useState(12)
const [activeTag, setActiveTag] = useState('')
const [latest, ...rest] = episodes
const activeHosts = (nuggets.hosts ?? []).filter(h => !h.archived)
const q = query.trim().toLowerCase()
const allTags = Array.from(new Set(episodes.flatMap(e => e.tags ?? []))).sort((a, b) => a.localeCompare(b))
const filtering = q.length > 0 || activeTag !== ''
const filtered = (filtering ? episodes : rest).filter(e =>
(!q || `${e.title} ${e.description} ${(e.tags ?? []).join(' ')}`.toLowerCase().includes(q)) &&
(!activeTag || (e.tags ?? []).includes(activeTag)),
)
const visible = filtered.slice(0, shown)
const fmtDate = (d: string) => {
const t = new Date(d)
return Number.isNaN(t.getTime()) ? '' : t.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
const listenLinks = (nuggets.spotifyUrl || nuggets.appleUrl) && (
)
return (
{nuggets.podcastArtUrl &&
}
Sub-Show
{nuggets.title}
{nuggets.tagline &&
{nuggets.tagline}
}
{nuggets.description &&
{nuggets.description}
}
{listenLinks}
{loading ? (
Loading episodes…
) : !configured ? (
Episodes are coming soon — check back once the show launches.
) : episodes.length === 0 ? (
No episodes published yet.
) : (
<>
{latest && (
Latest Episode{fmtDate(latest.pubDate) ? ` · ${fmtDate(latest.pubDate)}` : ''}{latest.duration ? ` · ${latest.duration}` : ''}
{latest.title}
{latest.description &&
{latest.description}
}
{latest.audioUrl &&
}
)}
{filtering ? 'Search results' : 'All Episodes'}
{episodes.length > 4 && (
{ setQuery(e.target.value); setShown(12) }}
/>
)}
{allTags.length > 0 && (
{ setActiveTag(''); setShown(12) }}>All
{allTags.map(t => (
{ setActiveTag(activeTag === t ? '' : t); setShown(12) }}>{t}
))}
)}
{filtered.length === 0 ? (
{filtering ? 'No episodes match your search.' : 'More episodes coming soon.'}
) : (
{visible.map((ep, i) => (
{[ep.episode ? `Ep. ${ep.episode}` : '', fmtDate(ep.pubDate), ep.duration].filter(Boolean).join(' · ')}
{ep.title}
{ep.description && {ep.description}
}
{(ep.tags ?? []).length > 0 && (
{(ep.tags ?? []).map(t => { setActiveTag(t); setShown(12) }}>{t} )}
)}
{ep.audioUrl && }
))}
)}
{filtered.length > shown && (
setShown(n => n + 12)}>Show more episodes
)}
>
)}
{activeHosts.length > 0 && (
Meet the Hosts
{activeHosts.map(host => (
{host.imageUrl &&
}
{host.name}
{host.role && {host.role} }
))}
)}
)
}
function NuggetsHostDetailPage({ content }: { content: SiteContent }) {
const { hostId } = useParams<{ hostId: string }>()
const nuggets = content.nuggets ?? DEFAULTS.nuggets
const siteTitle = content.seo?.title || DEFAULTS.seo.title
const host = (nuggets.hosts ?? []).find(h => h.id === hostId && !h.archived)
usePageMeta(
host ? `${host.name} | ${nuggets.title} | ${siteTitle}` : `Host | ${nuggets.title} | ${siteTitle}`,
host?.bio || `Meet ${host?.name}, a host on ${nuggets.title}.`,
)
if (!host) {
return (
)
}
const otherHosts = (nuggets.hosts ?? []).filter(h => !h.archived && h.id !== host.id)
return (
{host.imageUrl &&
}
{nuggets.title}
›
{host.name}
{host.role || 'Host'}
{host.name}
{host.bio && host.bio.split(/\n\n+/).map((para, i) => (
{para.replace(/\n/g, ' ')}
))}
{(nuggets.spotifyUrl || nuggets.appleUrl) && (
Listen to {nuggets.title}
)}
{otherHosts.length > 0 && (
Also on {nuggets.title}
{otherHosts.map(h => (
{h.imageUrl &&
}
{h.name}
{h.role && {h.role} }
))}
)}
← All {nuggets.title} episodes
)
}
function ResourcesPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Resources | ${siteTitle}`,
'Study guides, sermon notes, and free resources from Verse by Verse with Nate.',
)
return (
`/downloads/${buildCustomDownloadPageId(id)}`} fallback={ } />
)
}
// Canonical book list lives in shared/bible-books.js so the client (Scripture
// Index book matching, below) and the server (server/routes/bible-passage.js,
// the BSB passage-fetch API) can't drift out of sync with each other.
const SI_BOOK_MAP: Record = {}
const SI_BOOK_NAME: Record = {}
const SI_BOOK_ORDER: string[] = []
for (const b of BIBLE_BOOKS) {
// Admin's "Bible Book" field strips all whitespace (e.g. "1corinthians"), so
// index both the spaced and unspaced forms of every name/alias.
for (const key of [b.name.toLowerCase(), ...(b.aliases ?? [])]) {
SI_BOOK_MAP[key] = b.id
SI_BOOK_MAP[key.replace(/\s+/g, '')] = b.id
}
SI_BOOK_NAME[b.id] = b.name
SI_BOOK_ORDER.push(b.id)
}
function parseSIRef(ref: string) {
// Supports same-chapter ranges ("5:15-20") and chapter-spanning ranges ("5:15-6:2").
// Tolerate en/em dashes and verse-part suffixes like "10-12a"
const clean = ref.replace(/[\u2013\u2014]/g, '-').replace(/(\d)[a-z]\b/gi, '$1').trim()
const m = clean.match(/^(.*?)\s+(\d+):(\d+)(?:-(?:(\d+):)?(\d+))?$/)
if (!m) return null
const bookId = SI_BOOK_MAP[m[1].toLowerCase().replace(/\.\s*/g, ' ').trim()]
if (!bookId) return null
const chapter = parseInt(m[2], 10)
const endChapter = m[4] ? parseInt(m[4], 10) : chapter
return {
bookId,
chapter,
endChapter,
verseStart: parseInt(m[3], 10),
verseEnd: m[5] ? parseInt(m[5], 10) : parseInt(m[3], 10),
}
}
type HellaoContent = Array<{ type: string; number?: number; content?: Array<{ text?: string } | string> }>
function extractVerseRange(chContent: HellaoContent, verseStart: number, verseEnd: number): string {
const texts: string[] = []
for (const item of chContent) {
if (item.type === 'verse' && item.number != null && item.number >= verseStart && item.number <= verseEnd) {
const t = (item.content ?? []).map(c => (typeof c === 'string' ? c : ((c as { text?: string }).text ?? ''))).join('').trim()
if (t) texts.push(t)
}
}
return texts.join(' ')
}
interface ScriptureIndexEntry {
reference: string
sourceType: 'study' | 'episode'
sourceTitle: string
sectionTitle?: string
url: string
}
interface ScriptureSource { type: 'study' | 'episode'; label: string; url: string }
interface ScriptureRow {
key: string
displayRef: string
bookId: string | null
chapter: number
endChapter: number
verseStart: number
verseEnd: number
sources: ScriptureSource[]
}
interface ScriptureBookGroup { bookId: string; bookName: string; rows: ScriptureRow[] }
function ScriptureIndexPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Scripture Index | ${siteTitle}`,
'Every passage covered across our studies and episodes, organized by Scripture reference. Find the lesson or episode for any verse.',
)
const [entries, setEntries] = useState([])
const [loading, setLoading] = useState(true)
const [query, setQuery] = useState('')
const [testament, setTestament] = useState<'all' | 'ot' | 'nt'>('all')
const [sourceFilter, setSourceFilter] = useState<'all' | 'episode' | 'study'>('all')
const [bookFilter, setBookFilter] = useState(null)
const [toggled, setToggled] = useState>({})
const [verseTexts, setVerseTexts] = useState>({})
const [shownVerses, setShownVerses] = useState>(new Set())
const chapterCache = useRef>>(new Map())
useEffect(() => {
fetch('/api/public/scripture-index')
.then(r => r.json())
.then(d => { if (Array.isArray(d.entries)) setEntries(d.entries) })
.catch(() => {})
.finally(() => setLoading(false))
}, [])
// Merge entries that share the same reference (a study lesson and an episode can both
// cover the same passage) into one row, then group rows by book in canonical Bible order.
const { bookGroups, otherRows, topRows } = useMemo(() => {
const rowMap = new Map()
const order: string[] = []
for (const entry of entries) {
const parsed = parseSIRef(entry.reference)
const source: ScriptureSource = {
type: entry.sourceType,
url: entry.url,
label: entry.sourceType === 'study'
? `${entry.sourceTitle}${entry.sectionTitle ? ` — ${entry.sectionTitle}` : ''}`
: entry.sourceTitle,
}
const key = parsed
? `${parsed.bookId}-${parsed.chapter}-${parsed.verseStart}-${parsed.endChapter}-${parsed.verseEnd}`
: `other-${entry.reference.trim().toLowerCase()}`
let row = rowMap.get(key)
if (!row) {
const spansChapters = parsed && parsed.endChapter !== parsed.chapter
row = {
key,
displayRef: parsed
? spansChapters
? `${SI_BOOK_NAME[parsed.bookId]} ${parsed.chapter}:${parsed.verseStart}-${parsed.endChapter}:${parsed.verseEnd}`
: `${SI_BOOK_NAME[parsed.bookId]} ${parsed.chapter}:${parsed.verseStart}${parsed.verseEnd !== parsed.verseStart ? `-${parsed.verseEnd}` : ''}`
: entry.reference.trim(),
bookId: parsed?.bookId ?? null,
chapter: parsed?.chapter ?? 0,
endChapter: parsed?.endChapter ?? parsed?.chapter ?? 0,
verseStart: parsed?.verseStart ?? 0,
verseEnd: parsed?.verseEnd ?? 0,
sources: [],
}
rowMap.set(key, row)
order.push(key)
}
if (!row.sources.some(s => s.url === source.url && s.label === source.label)) row.sources.push(source)
}
const groupsByBook = new Map()
const other: ScriptureRow[] = []
for (const k of order) {
const row = rowMap.get(k)!
if (!row.bookId) { other.push(row); continue }
if (!groupsByBook.has(row.bookId)) groupsByBook.set(row.bookId, [])
groupsByBook.get(row.bookId)!.push(row)
}
const groups: ScriptureBookGroup[] = SI_BOOK_ORDER
.filter(id => groupsByBook.has(id))
.map(id => ({
bookId: id,
bookName: SI_BOOK_NAME[id],
rows: groupsByBook.get(id)!.sort((a, b) => a.chapter - b.chapter || a.verseStart - b.verseStart),
}))
other.sort((a, b) => a.displayRef.localeCompare(b.displayRef))
// Passages covered by the most lessons/episodes (needs at least two sources to be worth highlighting)
const top = [...rowMap.values()]
.filter(r => r.bookId && r.sources.length >= 2)
.sort((a, b) => b.sources.length - a.sources.length || order.indexOf(a.key) - order.indexOf(b.key))
.slice(0, 6)
return { bookGroups: groups, otherRows: other, topRows: top }
}, [entries])
// Apply the search box and filters
const needle = query.trim().toLowerCase()
const rowMatches = (row: ScriptureRow) => {
if (sourceFilter !== 'all' && !row.sources.some(src => src.type === sourceFilter)) return false
if (!needle) return true
return row.displayRef.toLowerCase().includes(needle) || row.sources.some(src => src.label.toLowerCase().includes(needle))
}
const isOldTestament = (bookId: string) => SI_BOOK_ORDER.indexOf(bookId) < 39
const visibleGroups = bookGroups
.filter(g => testament === 'all' || (testament === 'ot') === isOldTestament(g.bookId))
.filter(g => !bookFilter || g.bookId === bookFilter)
.map(g => ({ ...g, rows: g.rows.filter(rowMatches) }))
.filter(g => g.rows.length > 0 || (needle && g.bookName.toLowerCase().includes(needle)))
// A search by book name ("titus") should show the whole book
.map(g => (needle && g.rows.length === 0
? { ...g, rows: bookGroups.find(x => x.bookId === g.bookId)!.rows.filter(r => sourceFilter === 'all' || r.sources.some(src => src.type === sourceFilter)) }
: g))
const visibleOther = testament === 'all' && !bookFilter ? otherRows.filter(rowMatches) : []
const totalRows = visibleGroups.reduce((n, g) => n + g.rows.length, 0) + visibleOther.length
const filtering = Boolean(needle) || testament !== 'all' || sourceFilter !== 'all' || Boolean(bookFilter)
// With only a few books everything starts open; a long index starts collapsed. Searching opens matches.
const isOpen = (id: string) => toggled[id] ?? (filtering || bookGroups.length <= 4)
const setAll = (open: boolean) => {
const next: Record = { other: open }
for (const g of bookGroups) next[g.bookId] = open
setToggled(next)
}
const fetchChapter = (bookId: string, chapter: number) => {
const ck = `${bookId}/${chapter}`
let promise = chapterCache.current.get(ck)
if (!promise) {
promise = fetch(`https://bible.helloao.org/api/BSB/${bookId}/${chapter}.json`)
.then(r => (r.ok ? r.json() : Promise.reject(new Error('chapter failed'))))
.then((data: { chapter: { content: HellaoContent } }) => data?.chapter?.content ?? [])
chapterCache.current.set(ck, promise)
}
return promise
}
// Verse of the week: the admin's pick if set, otherwise rotate through covered passages by calendar week
const [weekIndex] = useState(() => Math.floor(Date.now() / (7 * 24 * 60 * 60 * 1000)))
const verseOfWeek = useMemo(() => {
const override = content.verseOfTheWeek?.trim()
const parsed = override ? parseSIRef(override) : null
if (parsed) {
const key = `${parsed.bookId}-${parsed.chapter}-${parsed.verseStart}-${parsed.endChapter}-${parsed.verseEnd}`
const existing = bookGroups.flatMap(g => g.rows).find(r => r.key === key)
return existing ?? {
key,
displayRef: `${SI_BOOK_NAME[parsed.bookId]} ${parsed.chapter}:${parsed.verseStart}${parsed.verseEnd !== parsed.verseStart ? `-${parsed.verseEnd}` : ''}`,
bookId: parsed.bookId, chapter: parsed.chapter, endChapter: parsed.endChapter,
verseStart: parsed.verseStart, verseEnd: parsed.verseEnd, sources: [],
} as ScriptureRow
}
const pool = bookGroups.flatMap(g => g.rows)
if (pool.length === 0) return null
return pool[weekIndex % pool.length]
}, [bookGroups, content.verseOfTheWeek, weekIndex])
const [votText, setVotText] = useState(null)
const votKey = verseOfWeek?.key
useEffect(() => {
if (!verseOfWeek || !verseOfWeek.bookId) return
let cancelled = false
const verseEnd = verseOfWeek.endChapter !== verseOfWeek.chapter ? Number.MAX_SAFE_INTEGER : verseOfWeek.verseEnd
fetchChapter(verseOfWeek.bookId, verseOfWeek.chapter)
.then(ch => { if (!cancelled) setVotText(extractVerseRange(ch, verseOfWeek.verseStart, verseEnd) || null) })
.catch(() => { if (!cancelled) setVotText(null) })
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [votKey])
function toggleVerse(row: ScriptureRow) {
const open = shownVerses.has(row.key)
setShownVerses(prev => {
const next = new Set(prev)
if (open) next.delete(row.key)
else next.add(row.key)
return next
})
if (open || verseTexts[row.key] !== undefined || !row.bookId) return
// Chapter-spanning refs (e.g. "5:15-6:2") preview only the starting chapter's verses
const verseEnd = row.endChapter !== row.chapter ? Number.MAX_SAFE_INTEGER : row.verseEnd
fetchChapter(row.bookId, row.chapter)
.then(chContent => setVerseTexts(prev => ({ ...prev, [row.key]: extractVerseRange(chContent, row.verseStart, verseEnd) || '(Verse text unavailable.)' })))
.catch(() => setVerseTexts(prev => ({ ...prev, [row.key]: '(Verse text unavailable right now.)' })))
}
function renderRow(row: ScriptureRow) {
const showing = shownVerses.has(row.key)
return (
{row.displayRef}
{row.sources.map((src, i) => (
{src.type === 'episode' ? 'Episode' : 'Study'}
{src.label}
))}
{row.bookId && (
toggleVerse(row)}>
{showing ? 'Hide verse' : 'Show verse'}
)}
{showing && (
{verseTexts[row.key] !== undefined ? `“${verseTexts[row.key]}”` : 'Loading…'}
)}
)
}
const hasContent = bookGroups.length > 0 || otherRows.length > 0
const pill = (active: boolean) => `si-pill${active ? ' si-pill--on' : ''}`
return (
Scripture Index — {siteTitle}
{loading ? (
Loading…
) : !hasContent ? (
No Scripture references are published yet.
) : (
<>
setQuery(e.target.value)}
/>
{ setTestament('all'); setBookFilter(null) }}>All
{ setTestament('ot'); setBookFilter(null) }}>Old Testament
{ setTestament('nt'); setBookFilter(null) }}>New Testament
setSourceFilter('all')}>Everything
setSourceFilter('episode')}>Episodes
setSourceFilter('study')}>Studies
{!filtering && verseOfWeek && (
Verse of the week
{verseOfWeek.displayRef}
{votText &&
“{votText}”
}
{verseOfWeek.sources.length > 0 && (
{verseOfWeek.sources.slice(0, 3).map((src, i) => (
{src.type === 'episode' ? 'Episode' : 'Study'}
{src.label}
))}
)}
)}
{!filtering && topRows.length > 0 && (
Most-studied passages
{topRows.map(r => (
setQuery(r.displayRef)}>
{r.displayRef}
{r.sources.length} sources
))}
)}
{bookGroups.length > 1 && (
{bookGroups.map(g => (
setBookFilter(bookFilter === g.bookId ? null : g.bookId)}
>
{g.bookName} {g.rows.length}
))}
)}
{totalRows} passage{totalRows === 1 ? '' : 's'}{filtering ? ' match' : ''}
{filtering && (
{ setQuery(''); setTestament('all'); setSourceFilter('all'); setBookFilter(null) }}>Clear filters
)}
setAll(true)}>Expand all
setAll(false)}>Collapse all
{ setAll(true); window.setTimeout(() => window.print(), 150) }}>Print index
{totalRows === 0 ? (
Nothing matches that. Try a book name like “Titus”, or clear the filters.
) : (
{visibleGroups.map(group => {
const open = isOpen(group.bookId)
return (
setToggled(t => ({ ...t, [group.bookId]: !open }))}>
{group.bookName}
{group.rows.length} passage{group.rows.length === 1 ? '' : 's'}
{open ? '−' : '+'}
{open && {group.rows.map(renderRow)} }
)
})}
{visibleOther.length > 0 && (
setToggled(t => ({ ...t, other: !isOpen('other') }))}>
Other references
{visibleOther.length} passage{visibleOther.length === 1 ? '' : 's'}
{isOpen('other') ? '−' : '+'}
{isOpen('other') && {visibleOther.map(renderRow)} }
)}
)}
>
)}
)
}
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 (
)
}
function ContactPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Contact | ${siteTitle}`,
'Get in touch with Nate — submit a Bible question, testimony, or topic request.',
)
return (
)
}
// Splits "Step 2: Use Q&A to Go Deeper" into its number and title so the number can be
// rendered as a badge. Titles without a "Step N:" prefix fall back to the position.
function splitStepTitle(title: string | undefined, fallbackNumber: number) {
const text = (title ?? '').trim()
const match = /^step\s*(\d+)\s*[:.\-–—]\s*(.+)$/i.exec(text)
return match ? { number: match[1], title: match[2] } : { number: String(fallbackNumber), title: text }
}
function StartHerePage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Start Here | ${siteTitle}`,
content.startHereIntro || 'A beginner\'s guide to Verse by Verse with Nate.',
)
const steps = [
{ icon: '🎧', title: content.startHereStep1Title, body: content.startHereStep1Body, cta: content.startHereStep1Cta, to: '/episodes', primary: false },
{ icon: '🔎', title: content.startHereStep2Title, body: content.startHereStep2Body, cta: content.startHereStep2Cta, to: '/questions', primary: false },
{ icon: '✉️', title: content.startHereStep3Title, body: content.startHereStep3Body, cta: content.startHereStep3Cta, to: '/contact', primary: true },
]
return (
Verse by Verse with Nate
{content.startHereHeading}
{content.startHereIntro && {content.startHereIntro}
}
{steps.map((step, index) => {
const { number, title } = splitStepTitle(step.title, index + 1)
return (
{number}
{step.icon}
Step {number}
{title}
{step.body}
{step.cta} →
)
})}
Never heard the gospel, or not sure where you stand with God?
The Gospel, in a Courtroom
God is the Judge, Satan is the accuser, the charges are our sins, and a Substitute has stepped forward. Follow the case from the charge to the pardon, then read the full account of the trial, crucifixion and resurrection of Jesus.
Hear the case →
← Back to site
)
}
function CenteredPageFrame({ content, children, label }: { content: SiteContent; children: React.ReactNode; label: string }) {
return (
)
}
function ThankYouPage({ content }: { content: SiteContent }) {
const navigate = useNavigate()
const [secondsLeft, setSecondsLeft] = useState(15)
useEffect(() => {
const timeoutId = window.setTimeout(() => navigate('/'), 15000)
const intervalId = window.setInterval(() => setSecondsLeft(s => (s > 0 ? s - 1 : 0)), 1000)
return () => {
window.clearTimeout(timeoutId)
window.clearInterval(intervalId)
}
}, [navigate])
return (
✓
Message received
Thank you
Your message was sent. We appreciate you reaching out and will get back to you soon.
Back to home
Browse episodes
Returning to the home page in {secondsLeft}s
)
}
function SubscribePage({ content }: { content: SiteContent }) {
const navigate = useNavigate()
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [email, setEmail] = useState('')
const [honey, setHoney] = useState('')
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setStatus('submitting')
setErrorMsg('')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName,
lastName,
email,
messageType: 'general',
message: 'Newsletter signup from subscribe page',
subscribe: true,
_honey: honey,
}),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setErrorMsg((data as { message?: string }).message ?? 'Something went wrong. Please try again.')
setStatus('error')
return
}
navigate('/subscribe/thanks')
} catch {
setErrorMsg('Could not connect. Please try again later.')
setStatus('error')
}
}
return (
Verse by Verse with Nate
Subscribe for updates
New episodes, study resources and ministry updates, sent to your inbox. No spam, and you can unsubscribe any time.
Prefer to browse first? Read past issues
)
}
function SubscribeThankYouPage({ content }: { content: SiteContent }) {
return (
✓
Verse by Verse with Nate
You’re subscribed
Thank you for subscribing. You’ll receive new episode announcements and ministry updates.
Listen now Start with the latest episode
Join a study Go deeper, one lesson at a time
Past issues Read the newsletter archive
Back to home
)
}
function LegalPage({ content, title, body }: { content: SiteContent; title: string; body: string[] }) {
return (
{body.map((line, idx) => (
{line}
))}
)
}
// Certificate links emailed before v2.10.10 pointed at /study/certificate/:token.
function LegacyCertificateRedirect() {
const { token } = useParams<{ token: string }>()
return
}
function PublicCertificatePage({ content }: { content: SiteContent }) {
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 (
)
}
if (status === 'not-found' || !cert) {
return (
Certificate
Certificate not found
This certificate link is invalid or has been removed.
Back to site
)
}
return (
{/* Corner ornaments */}
✦
✦
✦
✦
✦ Verse by Verse with Nate ✦
◆
Certificate of Completion
◆
Presented to
{cert.displayName}
in recognition of faithful study and completion of
{cert.studyTitle}
✦
◆
Awarded on {new Date(cert.issuedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
Visit Verse by Verse with Nate →
)
}
function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: SiteContent) => void }) {
const [status, setStatus] = useState<'checking' | 'authenticated' | 'unauthenticated' | 'misconfigured'>('checking')
const [adminContent, setAdminContent] = useState(content)
const [password, setPassword] = useState('')
const [errorMsg, setErrorMsg] = useState('')
const [submitting, setSubmitting] = useState(false)
// TOTP two-step state
const [totpRequired, setTotpRequired] = useState(false)
const [pendingToken, setPendingToken] = useState('')
const [totpCode, setTotpCode] = useState('')
useEffect(() => {
fetch('/api/admin-auth/status')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Status failed'))))
.then(data => {
const next = data as { authenticated?: boolean; configured?: boolean }
if (next.configured === false) {
setStatus('misconfigured')
return
}
setStatus(next.authenticated ? 'authenticated' : 'unauthenticated')
})
.catch(() => {
setStatus('unauthenticated')
})
}, [])
useEffect(() => {
if (status === 'authenticated') return
setAdminContent(content)
}, [content, status])
useEffect(() => {
if (status !== 'authenticated') return
let cancelled = false
fetch('/api/admin-content?source=draft')
.then(r => (r.ok ? r.json() : null))
.then(data => {
if (cancelled) return
if (data?.siteContent) {
setAdminContent(c => ({ ...c, ...data.siteContent }))
}
})
.catch(() => {})
return () => {
cancelled = true
}
}, [status])
function handleAdminSave(next: SiteContent) {
setAdminContent(next)
onSave(next)
}
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setSubmitting(true)
setErrorMsg('')
try {
const res = await fetch('/api/admin-auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
const data = await res.json().catch(() => ({})) as { ok?: boolean; totpRequired?: boolean; pendingToken?: string; message?: string }
if (!res.ok) {
setErrorMsg(data.message ?? 'Login failed.')
setSubmitting(false)
return
}
if (data.totpRequired && data.pendingToken) {
setPendingToken(data.pendingToken)
setTotpRequired(true)
setPassword('')
setSubmitting(false)
return
}
setStatus('authenticated')
setPassword('')
} catch {
setErrorMsg('Login failed.')
} finally {
setSubmitting(false)
}
}
async function handleTotpVerify(e: React.FormEvent) {
e.preventDefault()
setSubmitting(true)
setErrorMsg('')
try {
const res = await fetch('/api/admin-auth/totp-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pendingToken, code: totpCode }),
})
const data = await res.json().catch(() => ({})) as { ok?: boolean; usedRecoveryCode?: boolean; remainingRecoveryCodes?: number; message?: string }
if (!res.ok) {
setErrorMsg(data.message ?? 'Invalid code.')
setSubmitting(false)
return
}
setStatus('authenticated')
setTotpCode('')
} catch {
setErrorMsg('Verification failed.')
} finally {
setSubmitting(false)
}
}
function handleBackToPassword() {
setTotpRequired(false)
setPendingToken('')
setTotpCode('')
setErrorMsg('')
}
async function handleLogout() {
try {
await fetch('/api/admin-auth/logout', { method: 'POST' })
} finally {
setStatus('unauthenticated')
setTotpRequired(false)
setPendingToken('')
}
}
if (status === 'authenticated') {
return
}
return (
Admin Access
{status === 'misconfigured' ? 'Admin Not Configured' : 'Sign in to Admin'}
{status === 'checking' &&
Checking session...
}
{status === 'misconfigured' && (
Set the ADMIN_PASSWORD environment variable on the server to enable admin login.
)}
{status === 'unauthenticated' && !totpRequired && (
)}
{status === 'unauthenticated' && totpRequired && (
)}
)
}
function QuestionsPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Q&A | ${siteTitle}`,
'Real questions answered from Scripture — browse topics, search, and submit your own.',
)
return (
)
}
interface NewsletterIssueSummary {
id: string
slug: string
title: string
subject: string
description: string
sentAt: string | null
publishedAt: string
}
function NewsletterArchivePage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Newsletter | ${siteTitle}`,
'Read past newsletter issues from Verse by Verse with Nate — episode notes, study excerpts, and Greek word studies.',
)
const [issues, setIssues] = useState([])
const [loading, setLoading] = useState(true)
const [query, setQuery] = useState('')
useEffect(() => {
fetch('/api/newsletter/issues')
.then(r => r.json())
.then(d => { if (Array.isArray(d.issues)) setIssues(d.issues) })
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const issueDate = (i: NewsletterIssueSummary) => new Date(i.sentAt ?? i.publishedAt)
const fmtDate = (i: NewsletterIssueSummary) =>
issueDate(i).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
const q = query.trim().toLowerCase()
const searching = q.length > 0
const matches = searching
? issues.filter(i => `${i.title} ${i.subject} ${i.description}`.toLowerCase().includes(q))
: issues
const featured = !searching ? issues[0] : undefined
const rest = featured ? matches.slice(1) : matches
const byYear: { year: number; items: NewsletterIssueSummary[] }[] = []
for (const i of rest) {
const year = issueDate(i).getFullYear()
const group = byYear.find(g => g.year === year)
if (group) group.items.push(i)
else byYear.push({ year, items: [i] })
}
return (
Past Issues
{issues.length > 1 && (
setQuery(e.target.value)}
/>
)}
{!loading && searching && matches.length === 0 && (
No issues match “{query.trim()}”.
)}
{!loading && !searching && issues.length <= 1 && (
{issues.length === 0 ? 'No newsletter issues published yet.' : 'More issues will appear here as they’re sent.'}
)}
{byYear.map(group => (
))}
)
}
function PreviewPage() {
const [content, setContent] = useState(null)
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [previewView, setPreviewView] = useState('homepage')
useEffect(() => {
fetch('/api/admin-content?source=draft')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Preview failed'))))
.then(data => {
if (data?.siteContent) {
setContent({ ...DEFAULTS, ...data.siteContent })
setStatus('ready')
return
}
setStatus('error')
})
.catch(() => {
setStatus('error')
})
}, [])
// Listen for live content updates + view from the admin parent frame
useEffect(() => {
function handleMessage(e: MessageEvent) {
if (e.origin !== window.location.origin) return
if (e.data?.type === 'admin-preview-content' && e.data.content) {
setContent({ ...DEFAULTS, ...e.data.content })
setStatus('ready')
if (e.data.view) setPreviewView(e.data.view)
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [])
if (status === 'loading') {
return (
Admin Preview
Loading draft preview…
Only signed-in admins can view this page.
)
}
if (status === 'error' || !content) {
return (
Admin Preview
Draft preview unavailable
Sign into admin and save a draft first.
Back to Admin
)
}
// Admin sends views like "about:bio" or "podcast:highlights"; match on the section prefix
// so every admin section previews the public page it edits.
const section = previewView.split(':')[0]
const view = previewView
if (section === 'start-here') return
if (section === 'about') return
if (section === 'contact') return
if (view === 'podcast:finished-books' || view === 'books') return
if (section === 'podcast') return
if (section === 'nuggets') return
if (view === 'downloads' || view === 'recommended-resources' || view === 'custom-links') return
if (view === 'colossians-study') return } />
if (view === 'questions') return
if (view === 'newsletter-archive' || view === 'broadcast') return
if (view === 'legal') {
return
}
return
}
function StudyRouteFrame({ content, child }: { content: SiteContent; child: ReactElement }) {
return (
<>
{child}
>
)
}
export default function App() {
const [content, setContent] = useState(DEFAULTS)
const navigate = useNavigate()
const location = useLocation()
usePageTracking()
useScrollDepthTracking()
useTimeOnPage()
useUTMCapture()
useLinkClickTracking()
useEffect(() => {
const TARGET = 'salvation'
let buffer = ''
function onKeyDown(e: KeyboardEvent) {
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return
buffer = (buffer + e.key).slice(-TARGET.length)
if (buffer === TARGET) {
buffer = ''
window.location.assign('/salvation')
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [])
useEffect(() => {
fetch('/api/admin-content')
.then(r => (r.ok ? r.json() : null))
.then(data => {
if (data?.siteContent) {
setContent(c => ({ ...c, ...data.siteContent }))
}
})
.catch(() => {})
}, [])
useEffect(() => {
const kofiUrl = content.platformKofiUrl
if (!kofiUrl) return
const username = kofiUrl.replace(/\/$/, '').split('/').pop()
if (!username) return
if (document.getElementById('kofi-overlay-script')) return
const script = document.createElement('script')
script.id = 'kofi-overlay-script'
script.src = 'https://storage.ko-fi.com/cdn/scripts/overlay-widget.umd.min.js'
script.onload = () => {
;(window as unknown as { kofiWidgetOverlay?: { draw: (user: string, opts: Record) => void } }).kofiWidgetOverlay?.draw(username, {
type: 'floating-chat',
'floating-chat.donateButton.text': 'Support the Show',
'floating-chat.donateButton.background-color': '#29abe0',
'floating-chat.donateButton.text-color': '#fff',
})
}
document.body.appendChild(script)
}, [content.platformKofiUrl])
useEffect(() => {
if (location.pathname !== '/' || !location.hash) return
const legacyHashRouteMap: { [key: string]: string } = {
'#listen': '/episodes',
'#about': '/about',
'#qa': '/questions',
'#contact': '/contact',
'#series': '/resources',
'#resources': '/resources',
'#start-here': '/start-here',
}
const redirectPath = legacyHashRouteMap[location.hash.toLowerCase()]
if (!redirectPath) return
navigate(redirectPath, { replace: true })
}, [location.hash, location.pathname, navigate])
useEffect(() => {
const seo = content.seo ?? DEFAULTS.seo
const ensureMeta = (selector: string, createAttrs: { [key: string]: string }) => {
const found = document.head.querySelector(selector)
if (found) return found as HTMLMetaElement
const meta = document.createElement('meta')
Object.entries(createAttrs).forEach(([key, value]) => {
meta.setAttribute(key, value)
})
document.head.appendChild(meta)
return meta
}
const ensureCanonical = () => {
const found = document.head.querySelector('link[rel="canonical"]')
if (found) return found as HTMLLinkElement
const link = document.createElement('link')
link.setAttribute('rel', 'canonical')
document.head.appendChild(link)
return link
}
document.title = seo.title || DEFAULTS.seo.title
ensureMeta('meta[name="description"]', { name: 'description' }).setAttribute('content', seo.description || DEFAULTS.seo.description)
ensureMeta('meta[name="robots"]', { name: 'robots' }).setAttribute('content', seo.robotsPolicy || DEFAULTS.seo.robotsPolicy)
ensureMeta('meta[property="og:title"]', { property: 'og:title' }).setAttribute('content', seo.ogTitle || seo.title || DEFAULTS.seo.ogTitle)
ensureMeta('meta[property="og:description"]', { property: 'og:description' }).setAttribute('content', seo.ogDescription || seo.description || DEFAULTS.seo.ogDescription)
ensureMeta('meta[property="og:image"]', { property: 'og:image' }).setAttribute('content', seo.ogImage || DEFAULTS.seo.ogImage)
ensureMeta('meta[property="og:url"]', { property: 'og:url' }).setAttribute('content', seo.canonicalUrl || DEFAULTS.seo.canonicalUrl)
ensureCanonical().setAttribute('href', seo.canonicalUrl || DEFAULTS.seo.canonicalUrl)
}, [content])
return (
<>
} />
} />
} />} />
} />} />
} />} />
} />} />
} />} />
} />} />
} />
} />} />
} />} />
} />} />
} />} />
} />} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
)}
/>
)}
/>
} />
>
)
}
function NotFoundPage({ content }: { content: SiteContent }) {
const location = useLocation()
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({
firstName: '',
email: '',
message: `Hi Nate, I tried visiting "${location.pathname}" and got a 404. You may want to check if this link is broken.`,
})
const [submitStatus, setSubmitStatus] = useState<'idle' | 'busy' | 'sent' | 'error'>('idle')
const [errMsg, setErrMsg] = useState('')
useEffect(() => {
sendEvent('not_found', { path: location.pathname })
}, [location.pathname])
async function handleReport(e: React.FormEvent) {
e.preventDefault()
setSubmitStatus('busy')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: form.firstName,
email: form.email,
message: form.message,
messageType: 'question',
_honey: '',
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.message ?? 'Something went wrong.')
setSubmitStatus('sent')
} catch (err) {
setErrMsg(err instanceof Error ? err.message : 'Something went wrong.')
setSubmitStatus('error')
}
}
return (
404
Page not found
{location.pathname} doesn’t exist. It may have moved or been removed.
Episodes Listen to the podcast
Bible studies Lessons and quizzes
Q&A Questions and answers
Resources Downloads and guides
← Back to home
{submitStatus !== 'sent' ? (
<>
Think this is a broken link?{' '}
{!showForm && (
setShowForm(true)}
style={{
background: 'none', border: 'none', color: 'var(--brand-gold)',
cursor: 'pointer', fontFamily: 'inherit', fontSize: 'inherit',
textDecoration: 'underline', padding: 0,
}}
>
Let Nate know.
)}
{showForm && (
)}
>
) : (
Thanks! Nate will look into it.
)}
)
}
function BackToTopButton() {
const [visible, setVisible] = useState(false)
useEffect(() => {
const onScroll = () => setVisible(window.scrollY > 320)
window.addEventListener('scroll', onScroll)
onScroll()
return () => window.removeEventListener('scroll', onScroll)
}, [])
if (!visible) return null
return (
window.scrollTo({ top: 0, behavior: 'smooth' })}
aria-label="Scroll back to top"
>
↑ Top
)
}