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 (
{content.cookieBannerText || 'We use optional analytics cookies to measure visits and location trends for site improvement.'}
Accept
Decline
)
}
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 */}
{ 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 */}
{ 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
`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
>
)}
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 && (
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() {
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')
}
}
return (
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.
) : (
)}
)
}
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)
return (
{/* ── HERO ── */}
{content.eyebrow}
Verse by Verse
with Nate
{content.heroTagline}
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.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.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'}
{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."}
✦ Current Series{' '}
✦
{[
{
key: 'primary',
imageUrl: content.seriesImageUrl,
title: content.seriesTitle,
label: content.seriesLabel,
description: content.seriesDescription,
listenUrl: content.seriesListenUrl,
listenBtnLabel: content.seriesListenBtnLabel,
studySlug: content.seriesStudySlug,
},
...(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,
}]
: []),
].map(series => (
))}
{content.whereToNextEyebrow || 'Where to Next'}
{content.whereToNextHeading || 'Choose the page you need.'}
{(content.whereToNextCards ?? []).map(card => (
{card.title}
{card.description}
))}
{featuredStudy && (
{featuredStudy.homepageEyebrow || 'Study'}
{featuredStudy.showNewTag &&
{featuredStudy.newTagLabel || 'NEW'} }
{featuredStudy.title}
{featuredStudy.description || 'Explore this study track with lesson notes, commentary, and guided questions.'}
)}
{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.'}
)
}
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 ?? []
return (
← Back to Episodes
{episode.episodeNumber && (
Episode {episode.episodeNumber}
)}
{episode.title}
{episode.summary &&
{episode.summary}
}
{(episode.embedUrl || episode.url) && (
{resolvedEmbedUrl ? (
) : (
)}
)}
{episode.url && (
Listen on Podcast Platform →
)}
{(episode.showNotesSummary || (episode.showNotesThemes ?? []).length > 0 || (episode.showNotesScripture ?? []).length > 0 || (episode.showNotesQuotes ?? []).length > 0 || (episode.showNotesResources ?? []).length > 0 || episode.showNotes) && (
Show Notes
{episode.showNotesSummary
?
{episode.showNotesSummary}
: episode.showNotes && !(episode.showNotesThemes ?? []).length &&
{episode.showNotes}
}
{(episode.showNotesThemes ?? []).length > 0 && (
Key Themes
{episode.showNotesThemes!.map((t, i) => {t} )}
)}
{(episode.showNotesScripture ?? []).length > 0 && (
Scripture References
{episode.showNotesScripture!.map((s, i) => {s} )}
)}
{(episode.showNotesQuotes ?? []).length > 0 && (
Key Quotes
{episode.showNotesQuotes!.map((q, i) => (
{q}
))}
)}
{(episode.showNotesResources ?? []).length > 0 && (
Resources
{episode.showNotesResources!.map((r, i) => {r} )}
)}
)}
{questions.length > 0 && (
Discussion Questions
{questions.map((q, i) => (
{q}
))}
)}
)
}
function useEpisodesForBook(season: number) {
const [episodes, setEpisodes] = useState<{ title: string; episode: string; season: string; duration: string; audioUrl: string; link: string; description: string }[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/episodes/all')
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { episodes?: { title: string; episode: string; season: string; duration: string; audioUrl: string; link: string; description: string }[] }) => {
const filtered = (data.episodes ?? [])
.filter(ep => ep.season === String(season))
.sort((a, b) => parseInt(a.episode) - parseInt(b.episode))
setEpisodes(filtered)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [season])
return { episodes, loading }
}
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 ?? []
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.map(book => (
{book.imageUrl && (
{book.downloadUrl &&
⬇ }
)}
Season {book.season}
{book.title}
{book.description &&
{book.description}
}
View Playlist →
))}
)}
)
}
const FINISHED_PLAYLIST_PREVIEW_COUNT = 10
const FINISHED_PLAYLIST_FILTER_THRESHOLD = 12
type FinishedBookEpisode = { title: string; episode: string; duration: string; audioUrl: string; description: string }
// 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}`
const isOpen = openKey === key
return (
{ep.episode || i + 1}
{ep.title}
{ep.duration &&
{ep.duration}
}
{ep.audioUrl && (
setOpenKey(isOpen ? null : key)}
>
{isOpen ? 'Hide player' : '▶ Play'}
)}
{isOpen && ep.audioUrl &&
}
)
})}
)}
{!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 (
Finished Books
›
{book.title}
{book.imageUrl && (
)}
Season {book.season}
{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 && (
)}
)
}
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.',
)
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.'}
)
}
interface NuggetsEpisode {
title: string
pubDate: string
link: string
audioUrl: string
description: string
duration: string
episode: string
season: 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 [latest, ...rest] = episodes
const activeHosts = (nuggets.hosts ?? []).filter(h => !h.archived)
return (
{(nuggets.podcastArtUrl || nuggets.description || nuggets.spotifyUrl || nuggets.appleUrl) && (
{nuggets.podcastArtUrl &&
}
{nuggets.description &&
{nuggets.description}
}
{(nuggets.spotifyUrl || nuggets.appleUrl) && (
)}
)}
{activeHosts.length > 0 && (
Meet the Hosts
{activeHosts.map(host => (
{host.imageUrl &&
}
{host.name}
))}
)}
Episodes
{loading ? (
Loading episodes…
) : !configured ? (
Episodes are coming soon — check back once the show launches.
) : episodes.length === 0 ? (
No episodes published yet.
) : (
<>
Latest Episode
{latest.title}
{latest.description &&
{latest.description}
}
{latest.audioUrl &&
}
{rest.length > 0 && (
{rest.map((ep, i) => (
{ep.episode || i + 2}
{ep.title}
{ep.description &&
{ep.description}
}
{ep.duration &&
{ep.duration}
}
{ep.audioUrl &&
}
))}
)}
>
)}
)
}
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 (
)
}
return (
{host.imageUrl && (
)}
{host.bio && host.bio.split(/\n\n+/).map((para, i) => (
{para.replace(/\n/g, ' ')}
))}
{(nuggets.spotifyUrl || nuggets.appleUrl) && (
Listen to {nuggets.title}
)}
← Back to {nuggets.title}
)
}
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").
const m = ref.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 } = 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))
return { bookGroups: groups, otherRows: other }
}, [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
}
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 (
{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
{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
{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} →
)
})}
← Back to site
)
}
function ThankYouPage() {
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 successfully. We appreciate you reaching out and will get back to you soon.
Returning to the main site in {secondsLeft} seconds...
navigate('/')}>
Go Back Now
)
}
function SubscribePage() {
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
Get ministry updates and new episode announcements by email.
)
}
function SubscribeThankYouPage() {
return (
Verse by Verse with Nate
You are subscribed
Thank you for subscribing. You will receive future ministry updates and episode announcements.
Back to Site
)
}
function LegalPage({ title, body }: { title: string; body: string[] }) {
return (
Verse by Verse with Nate
{title}
{body.map((line, idx) => (
{line}
))}
Back to Site
)
}
// Certificate links emailed before v2.10.10 pointed at /study/certificate/:token.
function LegacyCertificateRedirect() {
const { token } = useParams<{ token: string }>()
return
}
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 (
)
}
if (status === 'not-found' || !cert) {
return (
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)
useEffect(() => {
fetch('/api/newsletter/issues')
.then(r => r.json())
.then(d => { if (Array.isArray(d.issues)) setIssues(d.issues) })
.catch(() => {})
.finally(() => setLoading(false))
}, [])
return (
{loading ? (
Loading issues…
) : issues.length === 0 ? (
No newsletter issues published yet.
) : (
)}
)
}
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
)
}
if (previewView === 'start-here') return
if (previewView === 'about') return
if (previewView === 'contact') return
if (previewView === 'current-series' || previewView === 'episode-highlights') {
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() {
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
The path {location.pathname}{' '}
doesn't exist — it may have moved or been removed.
← Back to Home
Browse Episodes
{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
)
}