import { useState, useEffect, useRef } from 'react'
import type { ReactElement } from 'react'
import { Link, NavLink, 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, StudyQuizPage } from './colossiansStudy'
import { SpotifyIcon } from './icons'
import type { SiteContent, StudyProgram, Testimonial } from './content'
import { DEFAULTS } from './content'
import { usePageMeta } from './hooks/usePageMeta'
import { useGlobalSearch } from './hooks/useGlobalSearch'
import { GlobalSearch } from './components/GlobalSearch'
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
import './App.css'
import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useOutboundLinkTracking } 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()
useEffect(() => {
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,
}).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
}
function resolveDownloadPageResource(content: SiteContent, pageId: string | undefined): DownloadPageResource | null {
if (!pageId) return null
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,
}
}
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 dropdownRef = useRef(null)
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
const location = useLocation()
const episodesActive = location.pathname.startsWith('/episodes') || location.pathname.startsWith('/finished')
useEffect(() => {
function handleOutsideClick(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setEpisodesOpen(false)
}
}
if (episodesOpen) document.addEventListener('mousedown', handleOutsideClick)
return () => document.removeEventListener('mousedown', handleOutsideClick)
}, [episodesOpen])
return (
setMenuOpen(open => !open)}
>
{menuOpen ? 'Close' : 'Menu'}
{/* Episodes dropdown */}
setEpisodesOpen(o => !o)}
aria-expanded={episodesOpen}
>
Episodes {episodesOpen ? '▴' : '▾'}
{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
)}
`header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Q&A
`header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Studies
`header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>My Account
`header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Downloads
`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 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
{extraFooterLinks.length > 0 && (
<>
·
More Links
>
)}
Finished Books
·
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 AboutSection({ content }: { content: SiteContent }) {
return (
{content.aboutEyebrow || 'About Nate'}
{content.aboutNate}
{content.aboutListenBtnLabel || 'Listen Now ↓'}
{content.aboutShowEyebrow || 'About the Show'}
{content.aboutShowHeading}
{content.aboutShowP1}
{content.aboutShowP2}
)
}
function StudyGuideSection({ content }: { content: SiteContent }) {
return (
Downloads
Study Guides and Downloads
Start with the primary guide below, then explore the rest of the download library further down the page.
Free Download
{content.studyGuideTitle}
{content.studyGuideDescription}
{content.studyGuideUrl && (
)}
)
}
function ContactSection({ content }: { content: SiteContent }) {
return (
)
}
function PodcastHighlightsSection({ content }: { content: SiteContent }) {
const links = [...(content.podcastFeaturedLinks ?? [])].reverse()
if (links.length === 0) return null
return (
✦ Podcast Highlights{' '}
✦
{links.map(link => {
const hasRichContent = !!(
link.embedUrl
|| isLikelySpotifyEpisodeUrl(link.url)
|| (link.discussionQuestions ?? []).length > 0
|| link.showNotes
)
const dest = (!hasRichContent && link.url)
? { external: link.url }
: { internal: `/episodes/${link.id}` }
const inner = (
<>
{link.episodeNumber && Ep. {link.episodeNumber} }
{link.title || link.url}
{link.summary &&
{link.summary}
}
Listen →
>
)
if ('external' in dest) {
return (
{inner}
)
}
return (
{inner}
)
})}
)
}
function DownloadLibrarySection({ content }: { content: SiteContent }) {
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
const [activeTag, setActiveTag] = useState(null)
if (resources.length === 0) return null
const allTags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean)
const filteredResources = resources.filter(r => {
return !activeTag || (r.tags ?? []).includes(activeTag)
})
return (
Download Library
Guides, worksheets, and study downloads.
{allTags.length > 0 && (
setActiveTag(null)}
>
All topics
{allTags.map(tag => (
setActiveTag(prev => prev === tag ? null : tag)}
>
{tag}
))}
{activeTag !== null && (
setActiveTag(null)}
>
Clear
)}
)}
{filteredResources.length > 0 ? (
{filteredResources.map(resource => (
{resource.imageUrl && (
)}
{resource.label}
{resource.description &&
{resource.description}
}
{(resource.tags ?? []).length > 0 && (
{(resource.tags ?? []).join(', ')}
)}
Open download page →
))}
) : (
No downloads tagged "{activeTag}".
)}
)
}
function DownloadDetailPage({ content }: { content: SiteContent }) {
const { id } = useParams<{ id: string }>()
const resource = resolveDownloadPageResource(content, id)
if (!resource) {
return (
Downloads
Download not found
The download you requested is not available right now.
Back to Downloads
)
}
return (
Downloads
{resource.label}
{resource.summary}
{resource.imageUrl && (
)}
{resource.tags.length > 0 && (
{resource.tags.join(', ')}
)}
)
}
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 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 => (
{block.heading}
{block.body}
))}
>
)
}
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}
}
))}
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."}
{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 = (content.podcastFeaturedLinks ?? []).find(e => e.id === id)
const [resolvedEmbedUrl, setResolvedEmbedUrl] = useState('')
const [audioUrl, setAudioUrl] = useState('')
// Fetch the direct MP3 URL from the RSS-backed episodes list, matching by title
useEffect(() => {
if (!episode) return
fetch('/api/episodes')
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { episodes?: { title: string; audioUrl: string }[] }) => {
const match = (data.episodes ?? []).find(e =>
e.title?.trim().toLowerCase() === episode.title?.trim().toLowerCase()
)
if (match?.audioUrl) setAudioUrl(match.audioUrl)
})
.catch(() => {})
}, [episode])
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])
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 || audioUrl) && (
{audioUrl ? (
) : resolvedEmbedUrl ? (
) : (
)}
)}
{episode.url && (
Listen on Podcast Platform →
)}
{episode.showNotes && (
Show Notes
{episode.showNotes}
)}
{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 (
✦ Finished Books ✦
Every series we've completed — with a full ordered episode playlist for each book of the Bible.
{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 &&
}
Season {book.season}
{book.title}
{book.description &&
{book.description}
}
View Playlist →
))}
)}
)
}
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}
}
Episode Playlist
{loading ? (
Loading episodes…
) : episodes.length === 0 ? (
No episodes found for this season.
) : (
{episodes.map((ep, i) => (
{ep.episode || i + 1}
{ep.title}
{ep.duration &&
{ep.duration}
}
{ep.audioUrl && (
)}
))}
)}
)
}
function LatestEpisodePlayer({ content }: { content: SiteContent }) {
const [audioUrl, setAudioUrl] = useState('')
const [title, setTitle] = useState('')
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(() => {})
}, [])
if (!audioUrl) return
return
}
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.'}
)
}
function ResourcesPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Resources & Downloads | ${siteTitle}`,
'Study guides, sermon notes, and free resources from Verse by Verse with Nate.',
)
return (
)
}
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 }) {
return (
)
}
function StartHerePage({ content }: { content: SiteContent }) {
return (
Verse by Verse with Nate
{content.startHereHeading}
{content.startHereIntro}
{content.startHereStep1Title}
{content.startHereStep1Body}
{content.startHereStep1Cta}
{content.startHereStep2Title}
{content.startHereStep2Body}
{content.startHereStep2Cta}
{content.startHereStep3Title}
{content.startHereStep3Body}
{content.startHereStep3Cta}
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
)
}
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 (
)
}
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()
useOutboundLinkTracking()
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(() => {
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()
useEffect(() => {
sendEvent('not_found', { path: location.pathname })
}, [location.pathname])
return (
Page not found
The page {location.pathname} doesn't exist.
← Back to home
)
}
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
)
}