import { useState, useEffect } 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, ArchivedSeries, StudyProgram } from './content'
import { DEFAULTS } from './content'
import './App.css'
const SPOTIFY_EMBED_URL =
'https://open.spotify.com/embed/show/0Gq1TzoJOdReSZ1gYQi8Xl?utm_source=generator&theme=0'
const CONSENT_KEY = 'vbn_analytics_consent_choice'
const HEADLINER_WIDGET_ID = 'WI_cmou3b4q7000701p0o9qmmcfj'
function usePageTracking() {
const location = useLocation()
useEffect(() => {
if (localStorage.getItem(CONSENT_KEY) !== 'accepted') return
const referrer = document.referrer || ''
fetch('/api/analytics/pageview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: location.pathname, referrer }),
keepalive: true,
}).catch(() => {})
}, [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 StudyDownloadForm({ buttonText = 'Download Guide' }: { 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 [successMsg, setSuccessMsg] = 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('')
setSuccessMsg('')
setDownloadUrl('')
try {
const res = await fetch('/api/study-downloads/titus', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...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')
setSuccessMsg('Your download should start now. If not, use the link below.')
setDownloadUrl(data.downloadUrl)
window.location.assign(data.downloadUrl)
} catch {
setErrorMsg('Could not connect. Please try again later.')
setStatus('error')
}
}
return (
)
}
function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string; 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 [successMsg, setSuccessMsg] = 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('')
setSuccessMsg('')
setDownloadUrl('')
try {
const res = await fetch('/api/resource-download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resourceId, ...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')
setSuccessMsg('Your download should start now. If not, use the link below.')
setDownloadUrl(data.downloadUrl)
window.location.assign(data.downloadUrl)
} catch {
setErrorMsg('Could not connect. Please try again later.')
setStatus('error')
}
}
return (
)
}
function buildCustomResourceDownloadId(id: string) {
return `custom:${id}`
}
function buildArchivedResourceDownloadId(seriesId: string, linkId: string) {
return `archived:${seriesId}:${linkId}`
}
function buildCustomDownloadPageId(id: string) {
return `custom--${id}`
}
function buildArchivedDownloadPageId(seriesId: string, linkId: string) {
return `archived--${seriesId}--${linkId}`
}
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,
}
}
if (pageId.startsWith('archived--')) {
const [, seriesId, linkId] = pageId.split('--')
const series = (content.archivedSeries ?? []).find(item => item.id === seriesId)
const link = (series?.resourceLinks ?? []).find(item => item.id === linkId)
if (!series || !link) return null
return {
id: pageId,
label: link.label || series.title || 'Download Resource',
imageUrl: series.imageUrl,
summary: link.description || series.description || 'Fill out the form below to access this download from a previous study.',
tags: [],
buttonText: `Download ${link.label || series.title || 'Resource'}`,
resourceId: buildArchivedResourceDownloadId(series.id, link.id),
amazonUrl: link.amazonUrl,
amazonLabel: link.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
)
}
interface Episode {
title: string
pubDate: string
link: string
description: string
duration: string
episode: string
}
function formatPubDate(raw: string): string {
try {
return new Date(raw).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
} catch {
return raw
}
}
function SiteHeader({ content }: { content: SiteContent }) {
const [menuOpen, setMenuOpen] = useState(false)
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
return (
setMenuOpen(open => !open)}
>
{menuOpen ? 'Close' : 'Menu'}
`header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Episodes
`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
>
)}
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 ?? []
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 label = <>{link.episodeNumber ? `Ep. ${link.episodeNumber}: ` : ''}{link.title || link.url}>
// Use external link only when there's a url AND no rich detail-page content
if (!hasRichContent && link.url) {
return (
{label}
)
}
// Otherwise always route to the internal detail page
return (
{label}
)
})}
)
}
function DownloadLibrarySection({ content }: { content: SiteContent }) {
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
const archivedWithResources = (content.archivedSeries ?? []).filter(series => (series.resourceLinks ?? []).length > 0)
if (resources.length === 0 && archivedWithResources.length === 0) return null
return (
Download Library
More guides, worksheets, and past study downloads.
Keep the main guide featured at the top, and use this library for every other download you want available on the page.
{resources.length > 0 && (
Current Downloads
Extra files you want people to grab right now.
{resources.map(resource => (
{resource.imageUrl && (
)}
{resource.label}
{resource.description &&
{resource.description}
}
{(resource.tags ?? []).length > 0 && (
{(resource.tags ?? []).join(', ')}
)}
Open download page →
))}
)}
{archivedWithResources.length > 0 && (
Previous Studies
Downloads from earlier series that you still want available.
{archivedWithResources.map(series => (
{series.title || 'Archived Study'}
{series.description &&
{series.description}
}
{(series.resourceLinks ?? []).map(link => (
{series.imageUrl && (
)}
{link.label || series.title || 'Download Resource'}
{link.description &&
{link.description}
}
Open download page →
))}
))}
)}
)
}
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.
These links are shown on the homepage, separate from the footer menu.
)
}
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.
) : (
)}
)
}
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 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('')
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}
}
{resolvedEmbedUrl && (
)}
{episode.url && (
Listen on Podcast Platform →
)}
{episode.showNotes && (
Show Notes
{episode.showNotes}
)}
{questions.length > 0 && (
Discussion Questions
{questions.map((q, i) => (
{q}
))}
)}
)
}
function EpisodesPage({ content }: { content: SiteContent }) {
const [allEpisodes, setAllEpisodes] = useState([])
const [loading, setLoading] = useState(true)
const [failed, setFailed] = useState(false)
useEffect(() => {
fetch('/api/episodes/all')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('fetch failed'))))
.then((data: { episodes: Episode[] }) => {
if (data.episodes.length === 0) setFailed(true)
else setAllEpisodes(data.episodes)
setLoading(false)
})
.catch(() => {
setFailed(true)
setLoading(false)
})
}, [])
const archivedSeries = content.archivedSeries ?? []
const archivedEpisodeNums = new Set(
archivedSeries.flatMap(s => {
if (!s.episodeRange) return []
const nums: number[] = []
for (let i = s.episodeRange.from; i <= s.episodeRange.to; i++) nums.push(i)
return nums
})
)
const currentEpisodes = allEpisodes.filter(ep => {
const num = parseInt(ep.episode, 10)
return isNaN(num) || !archivedEpisodeNums.has(num)
})
function episodesForSeries(series: ArchivedSeries) {
if (!series.episodeRange) return []
const { from, to } = series.episodeRange
return allEpisodes.filter(ep => {
const num = parseInt(ep.episode, 10)
return !isNaN(num) && num >= from && num <= to
})
}
function renderEpisodeCards(episodes: Episode[]) {
return (
)
}
const spotifyFallback = (
)
const loadingSkeleton = (
)
const latestEpisode = !loading && !failed ? (currentEpisodes[0] ?? allEpisodes[0]) : null
const latestEmbedUrl = latestEpisode ? toSpotifyEpisodeEmbedUrl(latestEpisode.link) : ''
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.'}
{latestEpisode && (
Latest Episode
{latestEpisode.title}
{latestEpisode.episode && Ep. {latestEpisode.episode} }
{latestEpisode.pubDate && {formatPubDate(latestEpisode.pubDate)} }
{latestEpisode.duration && {latestEpisode.duration} }
{latestEpisode.description &&
{latestEpisode.description}
}
Listen on Spotify
{latestEmbedUrl && (
)}
)}
✦ {content.seriesLabel || 'Now Playing'}{' '}
✦
{content.seriesTitle &&
{content.seriesTitle}
}
{loading
? loadingSkeleton
: failed || currentEpisodes.length === 0
? spotifyFallback
: renderEpisodeCards(currentEpisodes)}
{archivedSeries.map(series => {
const episodes = episodesForSeries(series)
return (
✦ {series.label || 'Archived Study'}{' '}
✦
{series.title &&
{series.title}
}
{series.description &&
{series.description}
}
{loading
? loadingSkeleton
: episodes.length > 0
? renderEpisodeCards(episodes)
: series.listenUrl
? (
Listen to Full Series →
)
: null}
)
})}
)
}
function ResourcesPage({ content }: { content: SiteContent }) {
return (
)
}
function AboutPage({ content }: { content: SiteContent }) {
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 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 }) {
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 === 'archived-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()
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',
'#archives': '/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 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
)
}