Files
Siteforge/src/App.tsx
T
2026-10-12 17:07:28 -04:00

3682 lines
151 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 : (
<div className="headliner-widget-wrap" aria-label="Featured listening widget">
{status === 'ready' && iframeSrc && (
<iframe
src={iframeSrc}
title="Featured listening widget"
loading="lazy"
className="headliner-widget-frame"
/>
)}
</div>
)
)
}
function DownloadForm({ endpoint, extraBody, buttonText }: { endpoint: string; extraBody?: Record<string, 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 [downloadUrl, setDownloadUrl] = useState('')
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
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 (
<form className="study-download-form" onSubmit={handleSubmit} noValidate>
<input
type="text"
className="contact-honeypot"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
value={honey}
onChange={e => setHoney(e.target.value)}
/>
<div className="study-download-grid">
<label>
First Name
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} />
</label>
<label>
Last Name
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} />
</label>
<label>
Email
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
</label>
</div>
<label className="contact-consent">
<input
type="checkbox"
checked={subscribe}
onChange={e => setSubscribe(e.target.checked)}
/>
<span>Subscribe me to updates from Verse by Verse with Nate.</span>
</label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
{status === 'success' && (
<p className="study-download-success">
Your download should start now.{' '}
{downloadUrl && <a href={downloadUrl}>Click here if it does not start automatically.</a>}
</p>
)}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Preparing Download...' : buttonText}
</button>
</form>
)
}
function StudyDownloadForm({ buttonText = 'Download Guide' }: { buttonText?: string }) {
return <DownloadForm endpoint="/api/study-downloads/titus" buttonText={buttonText} />
}
function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string; buttonText: string }) {
return <DownloadForm endpoint="/api/resource-download" extraBody={{ resourceId }} buttonText={buttonText} />
}
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 (
<div className="consent-banner" role="region" aria-label="Analytics consent">
<p>{content.cookieBannerText || 'We use optional analytics cookies to measure visits and location trends for site improvement.'}</p>
<div className="consent-actions">
<button type="button" className="btn-primary" onClick={accept}>Accept</button>
<button type="button" className="btn-secondary" onClick={decline}>Decline</button>
</div>
</div>
)
}
function SiteSearchBar({ content }: { content: SiteContent }) {
const { query, setQuery, results } = useGlobalSearch(content)
return (
<div className="site-search-bar">
<div className="site-search-bar-inner">
<GlobalSearch query={query} setQuery={setQuery} results={results} />
</div>
</div>
)
}
function SiteHeader({ content }: { content: SiteContent }) {
const [menuOpen, setMenuOpen] = useState(false)
const [episodesOpen, setEpisodesOpen] = useState(false)
const [studiesOpen, setStudiesOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const studiesDropdownRef = useRef<HTMLDivElement>(null)
const headerRef = useRef<HTMLElement>(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 (
<header className="site-header" ref={headerRef}>
<div className="header-inner">
<Link to="/" className="header-logo" aria-label="Home">
<img src="/book_icon.png" alt="" className="header-logo-img" />
</Link>
<button
type="button"
className="header-menu-btn"
aria-expanded={menuOpen}
aria-controls="site-nav"
onClick={() => setMenuOpen(open => !open)}
>
{menuOpen ? 'Close' : 'Menu'}
</button>
<nav id="site-nav" className={`header-nav ${menuOpen ? 'header-nav--open' : ''}`}>
{/* Episodes dropdown */}
<div ref={dropdownRef} className={`nav-dropdown${episodesOpen ? ' nav-dropdown--open' : ''}`}>
<div className="nav-dropdown-trigger">
<NavLink to="/episodes" className={`nav-dropdown-label${episodesActive ? ' header-nav-link--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Episodes</NavLink>
<button type="button" className="nav-dropdown-caret" onClick={() => setEpisodesOpen(o => !o)} aria-expanded={episodesOpen} aria-label="Episodes menu">
<span aria-hidden="true">{episodesOpen ? '▴' : '▾'}</span>
</button>
</div>
<div className="nav-dropdown-menu">
<div className="nav-dropdown-menu-inner">
<NavLink to="/episodes" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Current Series</NavLink>
<NavLink to="/finished" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Finished Books</NavLink>
<NavLink to="/nuggets" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Nuggets</NavLink>
</div>
</div>
</div>
<NavLink to="/questions" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Q&amp;A</NavLink>
{/* Studies dropdown */}
<div ref={studiesDropdownRef} className={`nav-dropdown${studiesOpen ? ' nav-dropdown--open' : ''}`}>
<div className="nav-dropdown-trigger">
<NavLink to="/study" className={`nav-dropdown-label${studiesActive ? ' header-nav-link--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Studies</NavLink>
<button type="button" className="nav-dropdown-caret" onClick={() => setStudiesOpen(o => !o)} aria-expanded={studiesOpen} aria-label="Studies menu">
<span aria-hidden="true">{studiesOpen ? '▴' : '▾'}</span>
</button>
</div>
<div className="nav-dropdown-menu">
<div className="nav-dropdown-menu-inner">
<NavLink to="/study" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>All Studies</NavLink>
<NavLink to="/study/discussion" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Discussion</NavLink>
<NavLink to="/scripture" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Scripture Index</NavLink>
</div>
</div>
</div>
<NavLink to="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Resources</NavLink>
<NavLink to="/about" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>About</NavLink>
<NavLink to="/contact" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Contact</NavLink>
<a
href={spotifyUrl}
target="_blank"
rel="noreferrer"
className="header-cta"
>
<SpotifyIcon />
{content.headerFollowLabel || 'Follow on Spotify'}
</a>
</nav>
<HeaderAccount />
</div>
</header>
)
}
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 (
<footer className="site-footer">
<p className="footer-ornament">✦ &nbsp; ✦ &nbsp; ✦</p>
<p className="footer-title">{content.footerTitle || 'Verse by Verse with Nate'}</p>
<p className="footer-sub">{content.footerSubtitle || 'A Journey Through Scripture'}</p>
{content.footerEmail && (
<p className="footer-contact">
Contact:{' '}
<a href={`mailto:${content.footerEmail}`}>{content.footerEmail}</a>
</p>
)}
<nav className="footer-links" aria-label="Footer links">
<a href={spotifyUrl} target="_blank" rel="noreferrer">Spotify</a>
<span aria-hidden="true">·</span>
<a href={appleUrl} target="_blank" rel="noreferrer">Apple Podcasts</a>
<span aria-hidden="true">·</span>
<a href={youtubeUrl} target="_blank" rel="noreferrer">YouTube</a>
<span aria-hidden="true">·</span>
<a href={amazonUrl} target="_blank" rel="noreferrer">Amazon Music</a>
<span aria-hidden="true">·</span>
<a href={creatorUrl} target="_blank" rel="noreferrer">Creator Profile</a>
<span aria-hidden="true">·</span>
<a href={facebookUrl} target="_blank" rel="noreferrer">Facebook</a>
{kofiUrl && (
<>
<span aria-hidden="true">·</span>
<a href={kofiUrl} target="_blank" rel="noreferrer">Support on Ko-fi</a>
</>
)}
{extraFooterLinks.length > 0 && (
<>
<span aria-hidden="true">·</span>
<details className="footer-more-links">
<summary>More Links</summary>
<div className="footer-more-links-menu">
{extraFooterLinks.map(link => (
<a key={link.id} href={link.url} target="_blank" rel="noreferrer">{link.label}</a>
))}
</div>
</details>
</>
)}
</nav>
<nav className="footer-links footer-links--legal" aria-label="Legal links">
<Link to="/finished">Finished Books</Link>
<span aria-hidden="true">·</span>
<Link to="/subscribe">Newsletter</Link>
<span aria-hidden="true">·</span>
<Link to="/privacy">Privacy Policy</Link>
<span aria-hidden="true">·</span>
<Link to="/terms">Terms</Link>
</nav>
<p className="footer-copy">{content.footerCopyright || '© 2026 Nate Emmert · Made with faith.'}</p>
{content.footerPrivacyNote && (
<p className="footer-privacy">{content.footerPrivacyNote}</p>
)}
</footer>
)
}
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 (
<div className="platform-buttons">
<a href={appleUrl} target="_blank" rel="noreferrer" className="platform-btn platform-btn-image">
<img src="/images/apple-podcasts-badge.svg" alt="Listen on Apple Podcasts" className="platform-badge" />
</a>
<a href={spotifyUrl} target="_blank" rel="noreferrer" className="platform-btn platform-btn-image">
<img src="/images/listen-on-spotify.svg" alt="Listen on Spotify" className="platform-badge" />
</a>
<a href={youtubeUrl} target="_blank" rel="noreferrer" className="platform-btn platform-btn-image">
<img src="/images/watch-on-youtube.svg" alt="Watch on YouTube" className="platform-badge" />
</a>
<a href={amazonUrl} target="_blank" rel="noreferrer" className="platform-btn platform-btn-image">
<img src="/images/listen-on-amazon-music.svg" alt="Listen on Amazon Music" className="platform-badge" />
</a>
<a href={facebookUrl} target="_blank" rel="noreferrer" className="platform-btn platform-btn-image">
<img src="/images/follow-on-facebook.svg" alt="Follow on Facebook" className="platform-badge" />
</a>
{(content.customLinks ?? []).filter(l => l.placement === 'platforms').map(l => (
<a
key={l.id}
href={l.url}
target="_blank"
rel="noreferrer"
className={`platform-btn ${l.imageUrl ? 'platform-btn-image' : 'custom-btn'}`}
aria-label={`Listen on ${l.label}`}
>
{l.imageUrl
? <img src={l.imageUrl} alt={`Listen on ${l.label}`} className="platform-badge" />
: l.label}
</a>
))}
</div>
)
}
function KofiSupportSection({ content }: { content: SiteContent }) {
const kofiUrl = content.platformKofiUrl
const qrUrl = content.platformKofiQrUrl
if (!kofiUrl && !qrUrl) return null
return (
<section className="cx-support" aria-label="Support the show">
<div className="section-inner">
<div className="cx-support-card">
<div className="cx-support-copy">
<p className="eyebrow">Support the Ministry</p>
<h2>Help Keep the Word Going</h2>
<p>
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.
</p>
{kofiUrl && (
<a href={kofiUrl} target="_blank" rel="noreferrer" className="kofi-btn">
☕ Support on Ko-fi
</a>
)}
</div>
{qrUrl && (
<figure className="cx-support-qr">
<img src={qrUrl} alt="Ko-fi donation QR code" />
<figcaption>Scan to give on Ko-fi</figcaption>
</figure>
)}
</div>
</div>
</section>
)
}
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 (
<section className="ab-section" aria-label="About the show">
<div className="section-inner">
<div className="ab-intro">
<img
src={content.aboutPhotoUrl || '/images/nate-photo.jpeg'}
alt="Nate Emmert"
className="ab-photo"
/>
<div className="ab-intro-copy">
<p className="eyebrow">{content.aboutEyebrow || 'About Nate'}</p>
<p className="ab-bio">{content.aboutNate}</p>
<div className="ab-cta-row">
<Link to="/episodes" className="btn-primary">{content.aboutListenBtnLabel || 'Listen Now ↓'}</Link>
<Link to="/start-here" className="btn-secondary">New here? Start here</Link>
</div>
</div>
</div>
<div className="ab-show">
<div className="ab-show-copy">
{content.aboutShowEyebrow && <p className="eyebrow">{content.aboutShowEyebrow}</p>}
<h2>{content.aboutShowHeading}</h2>
<p>{content.aboutShowP1}</p>
<p>{content.aboutShowP2}</p>
</div>
<img
src={content.aboutVerseArtUrl || '/images/hebrews-4-12-verse-art.png'}
alt="Hebrews 4:12 verse artwork"
className="ab-verse-art"
loading="lazy"
/>
</div>
<div className="ab-paths">
{paths.map(p => (
<Link key={p.to} to={p.to} className="ab-path">
<span className="ab-path-title">{p.title}</span>
<span className="ab-path-text">{p.text}</span>
<span className="ab-path-go">Go →</span>
</Link>
))}
</div>
</div>
</section>
)
}
function ContactSection({ content }: { content: SiteContent }) {
const point = (text: string, icon: ReactElement) => (
<div className="cx-point">
{icon}
<span>{text}</span>
</div>
)
return (
<section className="cx-section" id="contact" aria-label="Contact form">
<div className="section-inner cx-layout">
<aside className="cx-side">
<div className="cx-profile">
<img
src={content.contactPhotoUrl || '/images/nate-contact-photo.png'}
alt={content.contactName || 'Nate'}
className="cx-photo"
/>
<div>
<p className="cx-name">{content.contactName || 'Nate'}</p>
<p className="cx-role">{content.contactRole || 'Bible teacher · Lynchburg, VA'}</p>
</div>
</div>
{content.contactQuote && <blockquote className="cx-quote">&ldquo;{content.contactQuote}&rdquo;</blockquote>}
{content.contactIntro && <p className="cx-intro">{content.contactIntro}</p>}
{(content.contactPoint1 || content.contactPoint2) && (
<div className="cx-points" aria-label="Contact response details">
{content.contactPoint1 && point(content.contactPoint1, (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
))}
{content.contactPoint2 && point(content.contactPoint2, (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
))}
</div>
)}
{content.contactVerse && (
<p className="cx-verse">
&ldquo;{content.contactVerse}&rdquo; <span>{content.contactVerseRef}</span>
</p>
)}
<Link to="/questions" className="cx-qa-link">Browse answered questions →</Link>
</aside>
<div className="cx-form-card">
<ContactForm />
</div>
</div>
</section>
)
}
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 = (
<>
<div className="episode-card-meta">
{link.episodeNumber && <span className="episode-number">Ep. {link.episodeNumber}</span>}
</div>
<h3 className="episode-title">{link.title || link.url}</h3>
{link.summary && <p className="episode-desc">{link.summary}</p>}
<span className="episode-listen-cta">Listen →</span>
</>
)
if (!hasRichContent && link.url) {
return <a href={link.url} target="_blank" rel="noreferrer" className="episode-card">{inner}</a>
}
return <Link to={`/episodes/${link.id}`} className="episode-card">{inner}</Link>
}
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<Record<string, boolean>>({})
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 (
<section className="section-resources" aria-label="Podcast highlights">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament">✦</span> Podcast Highlights{' '}
<span className="ornament">✦</span>
</h2>
{groups.length > 1 && (
<div className="highlight-tabs" role="tablist" aria-label="Highlights by series">
{groups.map(group => (
<button
key={group.key}
type="button"
role="tab"
aria-selected={group.key === active.key}
className={`highlight-tab${group.key === active.key ? ' highlight-tab--active' : ''}`}
onClick={() => setActiveKey(group.key)}
>
{group.title} <span className="highlight-tab-count">{group.links.length}</span>
</button>
))}
</div>
)}
<div className="episode-list">
{shown.map(link => <HighlightCard key={link.id} link={link} />)}
</div>
{active.links.length > HIGHLIGHTS_PREVIEW_COUNT && (
<div className="highlight-more">
<button type="button" className="btn-secondary" onClick={() => setExpanded(prev => ({ ...prev, [active.key]: !isExpanded }))}>
{isExpanded ? 'Show fewer' : `Show all ${active.links.length} highlights`}
</button>
</div>
)}
</div>
</section>
)
}
function PageBanner({ eyebrow, title, desc }: { eyebrow: string; title: string; desc?: string }) {
return (
<section className="section-page-banner" aria-label={title}>
<div className="section-inner page-banner-inner">
<p className="eyebrow">{eyebrow}</p>
<h1 className="page-banner-title">{title}</h1>
{desc && <p className="page-banner-desc">{desc}</p>}
</div>
</section>
)
}
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 (
<main className="thanks-page" aria-label="Download not found">
<div className="thanks-card">
<p className="eyebrow">Resources</p>
<h1>Download not found</h1>
<p>The download you requested is not available right now.</p>
<Link to="/resources" className="btn-primary">Back to Resources</Link>
</div>
</main>
)
}
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<main className="dl-page" aria-label={resource.label}>
<div className="section-inner">
<Link to="/resources" className="dl-back">← Back to Resources</Link>
<article className="dl-card">
{resource.imageUrl && (
<div className="dl-art">
<img src={resource.imageUrl} alt={resource.label} />
</div>
)}
<div className="dl-body">
<p className="eyebrow">Free download</p>
<h1>{resource.label}</h1>
{resource.summary && <p className="dl-summary">{resource.summary}</p>}
{resource.tags.length > 0 && (
<div className="dl-tags">
{resource.tags.map(tag => <span key={tag} className="dl-tag">{tag}</span>)}
</div>
)}
<div className="dl-form">
{/* The title is already on the page, so the button stays short */}
{resource.isPrimaryGuide
? <StudyDownloadForm buttonText="Download now" />
: <ResourceDownloadForm resourceId={resource.resourceId} buttonText="Download now" />}
</div>
{(resource.amazonUrl || resource.appleBooksUrl) && (
<div className="dl-actions">
<span>Or get it from:</span>
{resource.amazonUrl && (
<StoreLink source={`page:${resource.id}`} store="amazon" href={resource.amazonUrl} gated={content.gateStoreLinks !== false} className="btn-secondary">
{resource.amazonLabel || 'Get it on Amazon'}
</StoreLink>
)}
{resource.appleBooksUrl && (
<StoreLink source={`page:${resource.id}`} store="apple" href={resource.appleBooksUrl} gated={content.gateStoreLinks !== false} className="btn-secondary">
{resource.appleBooksLabel || 'Get it on Apple Books'}
</StoreLink>
)}
</div>
)}
</div>
</article>
</div>
</main>
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
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 (
<div className="share-show-wrap">
<button type="button" className="btn-primary btn-share-show" onClick={handleShare}>
Share the Show
</button>
{feedback && <p className="share-feedback">{feedback}</p>}
</div>
)
}
function linkifyText(text: string) {
const urlRegex = /(https?:\/\/[^\s]+)/g
const parts = text.split(urlRegex)
return parts.map((part, i) =>
urlRegex.test(part)
? <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="custom-block-inline-link">{part}</a>
: 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 => (
<section key={block.id} className="section-custom-block" aria-label={block.heading}>
<div className="section-inner custom-block-inner">
<h2>{block.heading}</h2>
<p>{linkifyText(block.body)}</p>
{block.linkUrl && (
<a href={block.linkUrl} target="_blank" rel="noopener noreferrer" className="btn-primary custom-block-link-btn">
{block.linkLabel || 'Learn More'}
</a>
)}
</div>
</section>
))}
</>
)
}
function ExternalSitesSection({ content }: { content: SiteContent }) {
const links = (content.customLinks ?? []).filter(link => link.placement === 'externalSites')
if (links.length === 0) return null
return (
<section className="section-resources section-external-sites" aria-label="External sites">
<div className="section-inner">
<div className="download-library-head">
<p className="eyebrow">Homepage Links</p>
<h2 className="section-heading">External sites and ministries worth checking out.</h2>
</div>
<div className="resources-list">
{links.map(link => (
<a key={link.id} href={link.url} target="_blank" rel="noreferrer" className="resource-link">
<div className="resource-download-meta">
{link.imageUrl && <img src={link.imageUrl} alt={link.label} className="resource-link-image" />}
<span className="resource-link-label">{link.label}</span>
{link.description && <p className="resource-link-description">{link.description}</p>}
<span className="resource-link-action">Open external site →</span>
</div>
</a>
))}
</div>
</div>
</section>
)
}
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 (
<section className="section-newsletter" aria-label="Newsletter sign-up">
<div className="section-inner newsletter-inner">
<p className="eyebrow">Stay in the Word</p>
<h2 className="section-heading">Get episode updates by email</h2>
<p className="newsletter-sub">New episodes, study resources, and ministry updates — delivered to your inbox.</p>
{status === 'done' ? (
<p className="newsletter-thanks">You're subscribed! Thank you for signing up.</p>
) : (
<form className="newsletter-form" onSubmit={handleSubmit} noValidate>
<input type="text" className="contact-honeypot" tabIndex={-1} autoComplete="off" aria-hidden="true" value={honey} onChange={e => setHoney(e.target.value)} />
<div className="newsletter-fields">
<input
type="text"
placeholder="First name"
required
autoComplete="given-name"
value={firstName}
onChange={e => setFirstName(e.target.value)}
className="newsletter-input"
/>
<input
type="email"
placeholder="Email address"
required
autoComplete="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="newsletter-input"
/>
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Subscribing…' : 'Subscribe'}
</button>
</div>
{status === 'error' && <p className="newsletter-error">Something went wrong. Please try again.</p>}
</form>
)}
</div>
</section>
)
}
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 (
<section className="section-tfl" aria-label="Truth For Life devotional" style={type === 'resourcead' ? { padding: '2.5rem 0' } : undefined}>
<div className="section-inner">
<div className="tfl-label-row" style={type === 'resourcead' ? { marginBottom: '1rem' } : undefined}>
<h2 className="section-heading" style={{ marginBottom: '0.25rem' }}>
<span className="ornament">✦</span> {heading} <span className="ornament">✦</span>
</h2>
<p className="tfl-attribution" style={{ marginBottom: '1rem' }}>
<a href="https://www.truthforlife.org" target="_blank" rel="noreferrer" className="tfl-link" style={{ fontSize: '1rem', fontWeight: 600 }}>Truth For Life</a>
{' '}with Alistair Begg
</p>
</div>
<div className="tfl-widget-wrap">
<div
id="tfl-syndicate-container"
style={{ maxWidth: type === 'resourcead' ? '500px' : '760px', margin: '0 auto' }}
/>
</div>
</div>
</section>
)
}
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 (
<section className="section-qa-callout" aria-label="Featured Q&A">
<div className="section-inner">
<p className="eyebrow">Q&amp;A</p>
<h2 className="section-heading"><span className="ornament">✦</span> Questions from Listeners <span className="ornament">✦</span></h2>
<p className="qa-callout-sub">Real questions. Scripture-grounded answers.</p>
<div className="qa-callout-grid">
{questions.map(q => (
<div key={q.id} className="qa-callout-card">
<p className="qa-callout-question">"{q.question}"</p>
<p className="qa-callout-answer">{q.answer.length > 220 ? q.answer.slice(0, 220).trimEnd() + '…' : q.answer}</p>
{q.firstName && <p className="qa-callout-from">— {q.firstName}</p>}
<Link to={`/questions#qa-${q.id}`} className="qa-callout-more">Read the full answer →</Link>
</div>
))}
</div>
<div style={{ textAlign: 'center', marginTop: '2rem' }}>
<Link to="/questions" className="btn-primary">Browse All Q&amp;A →</Link>
</div>
</div>
</section>
)
}
function TestimonialsSection({ testimonials }: { testimonials: Testimonial[] }) {
if (!testimonials || testimonials.length === 0) return null
return (
<section className="section-testimonials" aria-label="Listener testimonials">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament">✦</span> What Listeners Are Saying <span className="ornament">✦</span>
</h2>
<div className="testimonials-grid">
{testimonials.map(t => (
<blockquote key={t.id} className="testimonial-card">
<p className="testimonial-quote">"{t.quote}"</p>
<footer className="testimonial-footer">
<span className="testimonial-name">— {t.name}</span>
{t.source && <span className="testimonial-source">{t.source}</span>}
</footer>
</blockquote>
))}
</div>
</div>
</section>
)
}
function LandingPage({ content }: { content: SiteContent }) {
const featuredStudy = getHomepageFeaturedStudy(content)
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<CustomBlocksSection content={content} page="homepage" />
{/* ── HERO ── */}
<section className="hero" aria-label="Show introduction">
<div className="hero-art">
<div className="art-glow" aria-hidden="true" />
<img
src={content.podcastArtUrl || '/images/podcast-art.jpeg'}
alt="Verse by Verse with Nate podcast artwork"
className="podcast-art"
/>
</div>
<div className="hero-text">
<p className="eyebrow">{content.eyebrow}</p>
<h1 className="hero-title">Verse by Verse</h1>
<p className="with-nate">
<span className="word-with">with </span>Nate
</p>
<div className="rule-divider" aria-hidden="true" />
<p className="tagline">{content.heroTagline}</p>
<div className="hero-ctas">
<a
href={content.platformSpotifyUrl || '/spotify'}
target="_blank"
rel="noreferrer"
className="btn-primary"
>
<SpotifyIcon />
{content.heroBtnSpotify || 'Listen on Spotify'}
</a>
<Link to="/episodes" className="btn-secondary">{content.heroBtnEpisodes || 'Explore Episodes ↓'}</Link>
<Link to="/start-here" className="btn-secondary btn-start-here">{content.heroBtnStartHere || 'New here? Start here →'}</Link>
</div>
</div>
</section>
<section className="section-prism" aria-label="PRISM featured video">
<div className="section-inner prism-feature">
<div className="prism-video-shell">
<video
className="prism-video"
controls
playsInline
preload="metadata"
poster="/images/banner.png"
>
<source src={content.prismVideoUrl || '/images/PRISM.mp4'} type="video/mp4" />
Your browser does not support embedded video playback.
</video>
</div>
<div className="prism-copy">
<p className="eyebrow">{content.prismEyebrow || 'Featured Video'}</p>
<h2 className="section-heading prism-heading">
<span className="ornament">✦</span> {content.prismHeading || 'PRISM'} <span className="ornament">✦</span>
</h2>
<p className="prism-description">
{content.prismIntro || 'What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?'}
</p>
<p className="prism-description">
{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.'}
</p>
<div className="prism-steps" aria-label="P.R.I.S.M. steps">
<p>{content.prismStep1 || 'P — Pray before you read'}</p>
<p>{content.prismStep2 || 'R — Read slowly and observe'}</p>
<p>{content.prismStep3 || 'I — Interpret with context'}</p>
<p>{content.prismStep4 || 'S — Study and apply specifically'}</p>
<p>{content.prismStep5 || 'M — Memorize one verse at a time'}</p>
</div>
<p className="prism-description">
{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."}
</p>
<p className="prism-description prism-emphasis">
{content.prismClosing || "The goal isn't to get through the text — it's to let the text get through to you."}
</p>
</div>
</div>
</section>
<HomepageNewsletterSection />
<section className="section-series" id="series" aria-label="Current series">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament">✦</span> Current Series{' '}
<span className="ornament">✦</span>
</h2>
<div className={`series-grid${content.secondSeries?.title?.trim() ? ' series-grid--two' : ''}`}>
{[
{
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 => (
<article className="series-card" key={series.key}>
<img
src={series.imageUrl}
alt={series.title}
className="series-art"
/>
<div className="series-info">
<p className="series-label">{series.label}</p>
<h3>{series.title}</h3>
<p>{series.description}</p>
<a
href={series.listenUrl || content.platformSpotifyUrl || '/spotify'}
target="_blank"
rel="noreferrer"
className="btn-primary"
>
<SpotifyIcon />
{series.listenBtnLabel || content.seriesListenBtnLabel || 'Listen to Series'}
</a>
{series.studySlug
? <Link to={`/study/${series.studySlug}`} className="btn-secondary">Study Along</Link>
: <Link to="/resources" className="btn-secondary">{content.seriesDownloadsBtnLabel || 'View Resources'}</Link>}
</div>
</article>
))}
</div>
</div>
</section>
<TruthForLifeWidget type="audiodevo" />
<ExternalSitesSection content={content} />
<section className="section-home-jump" aria-label="Homepage navigation">
<div className="section-inner">
<div className="home-jump-head">
<p className="eyebrow">{content.whereToNextEyebrow || 'Where to Next'}</p>
<h2 className="section-heading">{content.whereToNextHeading || 'Choose the page you need.'}</h2>
</div>
<div className="home-jump-grid">
{(content.whereToNextCards ?? []).map(card => (
<Link key={card.id} to={card.path} className="home-jump-card">
<h3>{card.title}</h3>
<p>{card.description}</p>
</Link>
))}
{featuredStudy && (
<Link to={`/study/${featuredStudy.slug || ''}`} className="home-jump-card home-jump-card--featured">
<div className="home-jump-featured-head">
<p className="eyebrow">{featuredStudy.homepageEyebrow || 'Study'}</p>
{featuredStudy.showNewTag && <span className="home-jump-new-tag">{featuredStudy.newTagLabel || 'NEW'}</span>}
</div>
<h3>{featuredStudy.title}</h3>
<p>{featuredStudy.description || 'Explore this study track with lesson notes, commentary, and guided questions.'}</p>
</Link>
)}
</div>
</div>
</section>
<HomepageQACallout />
<TestimonialsSection testimonials={content.testimonials ?? []} />
<section className="section-share" aria-label="Share the show">
<div className="section-inner section-share-inner">
<div className="share-text">
<h2 className="section-heading" style={{ textAlign: 'left', marginBottom: '0.75rem' }}>
{content.shareHeading || 'Help one more person hear the Word this week.'}
</h2>
<p className="share-p">{content.shareP || 'Scan the QR code or text the show link to a friend who needs encouragement today.'}</p>
<ShareShowButton />
</div>
<img src={content.shareQrImageUrl || '/images/qr-code.jpg'} alt="QR code to share the show" className="share-qr" />
</div>
</section>
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
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 (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<section className="section-player">
<div className="section-inner" style={{ textAlign: 'center', padding: '4rem 1rem' }}>
<h2>Episode not found</h2>
<p>This episode highlight doesn't exist or may have been removed.</p>
<Link to="/episodes" className="btn-primary" style={{ marginTop: '1.5rem', display: 'inline-block' }}>← Back to Episodes</Link>
</div>
</section>
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
const questions = episode.discussionQuestions ?? []
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<section className="section-episode-detail" aria-label={episode.title}>
<div className="section-inner">
<Link to="/episodes" className="episode-detail-back">← Back to Episodes</Link>
{episode.episodeNumber && (
<p className="episode-detail-number">Episode {episode.episodeNumber}</p>
)}
<h1 className="episode-detail-title">{episode.title}</h1>
{episode.summary && <p className="episode-detail-summary">{episode.summary}</p>}
{(episode.embedUrl || episode.url) && (
<div className="episode-detail-embed">
{resolvedEmbedUrl ? (
<EpisodeAudioPlayer src={resolvedEmbedUrl} title={episode.title} size="full" spotifyUrl={episode.url || content.platformSpotifyUrl} />
) : (
<div className="episode-embed-skeleton" aria-label="Loading player…" />
)}
</div>
)}
{episode.url && (
<a href={episode.url} target="_blank" rel="noreferrer" className="btn-primary episode-detail-listen-btn">
Listen on Podcast Platform →
</a>
)}
{(episode.showNotesSummary || (episode.showNotesThemes ?? []).length > 0 || (episode.showNotesScripture ?? []).length > 0 || (episode.showNotesQuotes ?? []).length > 0 || (episode.showNotesResources ?? []).length > 0 || episode.showNotes) && (
<div className="episode-detail-show-notes">
<h2 className="episode-detail-section-heading">Show Notes</h2>
{episode.showNotesSummary
? <p>{episode.showNotesSummary}</p>
: episode.showNotes && !(episode.showNotesThemes ?? []).length && <p>{episode.showNotes}</p>
}
{(episode.showNotesThemes ?? []).length > 0 && (
<div className="episode-detail-show-notes-section">
<h3 className="episode-detail-show-notes-heading">Key Themes</h3>
<ul className="episode-detail-show-notes-list">
{episode.showNotesThemes!.map((t, i) => <li key={i}>{t}</li>)}
</ul>
</div>
)}
{(episode.showNotesScripture ?? []).length > 0 && (
<div className="episode-detail-show-notes-section">
<h3 className="episode-detail-show-notes-heading">Scripture References</h3>
<ul className="episode-detail-show-notes-list">
{episode.showNotesScripture!.map((s, i) => <li key={i}>{s}</li>)}
</ul>
</div>
)}
{(episode.showNotesQuotes ?? []).length > 0 && (
<div className="episode-detail-show-notes-section">
<h3 className="episode-detail-show-notes-heading">Key Quotes</h3>
{episode.showNotesQuotes!.map((q, i) => (
<blockquote key={i} className="episode-detail-show-notes-quote">{q}</blockquote>
))}
</div>
)}
{(episode.showNotesResources ?? []).length > 0 && (
<div className="episode-detail-show-notes-section">
<h3 className="episode-detail-show-notes-heading">Resources</h3>
<ul className="episode-detail-show-notes-list">
{episode.showNotesResources!.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
</div>
)}
{questions.length > 0 && (
<div className="episode-detail-questions">
<h2 className="episode-detail-section-heading">Discussion Questions</h2>
<ol className="episode-detail-questions-list">
{questions.map((q, i) => (
<li key={i}>{q}</li>
))}
</ol>
</div>
)}
</div>
</section>
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
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 (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Studies" title="Finished Books" desc="Every series we've completed — with a full ordered episode playlist for each book of the Bible." />
<section className="section-finished-books">
<div className="section-inner">
{books.length === 0 ? (
<div className="finished-books-empty-state">
<div className="finished-books-empty-icon">📖</div>
<h2 className="finished-books-empty-heading">Nothing here yet</h2>
<p className="finished-books-empty-text">
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.
</p>
<Link to="/episodes" className="btn-primary" style={{ display: 'inline-block', marginTop: '1.5rem' }}>
Listen to Current Series →
</Link>
</div>
) : (
<div className="finished-books-grid">
{books.map(book => (
<Link key={book.id} to={`/finished/${book.id}`} className="finished-book-card">
{book.imageUrl && (
<div className="finished-book-card-img-wrap">
<img src={book.imageUrl} alt={book.title} className="finished-book-card-img" />
{book.downloadUrl && <span className="finished-book-card-dl-badge" title="Download available">⬇</span>}
</div>
)}
<div className="finished-book-card-body">
<p className="finished-book-card-season">Season {book.season}</p>
<h2 className="finished-book-card-title">{book.title}</h2>
{book.description && <p className="finished-book-card-desc">{book.description}</p>}
<span className="finished-book-card-cta">View Playlist →</span>
</div>
</Link>
))}
</div>
)}
</div>
</section>
<SiteFooter content={content} />
</div>
)
}
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<string | null>(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 (
<section id="episodes" className="finished-series-episodes" aria-label="Episodes">
<div className="highlight-tabs" role="tablist" aria-label="Episode views">
{hasHighlights && (
<button
type="button"
role="tab"
aria-selected={activeTab === 'highlights'}
className={`highlight-tab${activeTab === 'highlights' ? ' highlight-tab--active' : ''}`}
onClick={() => setTab('highlights')}
>
Highlights <span className="highlight-tab-count">{highlights.length}</span>
</button>
)}
<button
type="button"
role="tab"
aria-selected={activeTab === 'all'}
className={`highlight-tab${activeTab === 'all' ? ' highlight-tab--active' : ''}`}
onClick={() => setTab('all')}
>
All Episodes {!loading && <span className="highlight-tab-count">{episodes.length}</span>}
</button>
</div>
{activeTab === 'highlights' ? (
<>
<div className="episode-list">
{shownHighlights.map(link => <HighlightCard key={link.id} link={link} />)}
</div>
{orderedHighlights.length > HIGHLIGHTS_PREVIEW_COUNT && (
<div className="highlight-more">
<button type="button" className="btn-secondary" onClick={() => setHighlightsExpanded(v => !v)}>
{highlightsExpanded ? 'Show fewer' : `Show all ${orderedHighlights.length} highlights`}
</button>
</div>
)}
</>
) : loading ? (
<p className="finished-books-empty">Loading episodes…</p>
) : episodes.length === 0 ? (
<p className="finished-books-empty">No episodes found for this season.</p>
) : (
<>
{episodes.length > FINISHED_PLAYLIST_FILTER_THRESHOLD && (
<input
type="search"
className="finished-series-filter"
placeholder="Search episodes by number or title…"
aria-label="Search episodes"
value={filter}
onChange={e => setFilter(e.target.value)}
/>
)}
{matching.length === 0 ? (
<p className="finished-books-empty">No episodes match “{filter.trim()}”.</p>
) : (
<ol className="finished-series-playlist">
{shownEpisodes.map((ep, i) => {
const key = ep.audioUrl || `${ep.episode}-${i}`
const isOpen = openKey === key
return (
<li key={key} className={`finished-series-episode${isOpen ? ' finished-series-episode--open' : ''}`}>
<span className="finished-series-ep-num">{ep.episode || i + 1}</span>
<div className="finished-series-ep-body">
<div className="finished-series-ep-row">
<div>
<p className="finished-series-ep-title">{ep.title}</p>
{ep.duration && <p className="finished-series-ep-meta">{ep.duration}</p>}
</div>
{ep.audioUrl && (
<button
type="button"
className="finished-series-ep-play"
aria-expanded={isOpen}
onClick={() => setOpenKey(isOpen ? null : key)}
>
{isOpen ? 'Hide player' : '▶ Play'}
</button>
)}
</div>
{isOpen && ep.audioUrl && <EpisodeAudioPlayer src={ep.audioUrl} title={ep.title} />}
</div>
</li>
)
})}
</ol>
)}
{!query && matching.length > FINISHED_PLAYLIST_PREVIEW_COUNT && (
<div className="highlight-more">
<button type="button" className="btn-secondary" onClick={() => setPlaylistExpanded(v => !v)}>
{playlistExpanded ? 'Show fewer' : `Show all ${matching.length} episodes`}
</button>
</div>
)}
</>
)}
</section>
)
}
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 (
<div className="site">
<SiteHeader content={content} />
<main className="page-inner">
<p>Series not found. <Link to="/finished">← Back to Finished Books</Link></p>
</main>
<SiteFooter content={content} />
</div>
)
}
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<main className="page-inner">
<nav className="breadcrumb" aria-label="Breadcrumb">
<Link to="/finished">Finished Books</Link>
<span aria-hidden="true"> › </span>
<span>{book.title}</span>
</nav>
<div className="finished-series-hero">
{book.imageUrl && (
<div className="finished-series-cd" aria-hidden="true">
<div className="finished-series-cd-disc">
<img src={book.imageUrl} alt={book.title} className="finished-series-cd-img" />
<div className="finished-series-cd-sheen" />
<div className="finished-series-cd-hole" />
</div>
</div>
)}
<div>
<p className="finished-book-card-season">Season {book.season}</p>
<h1 className="finished-series-title">{book.title}</h1>
{book.description && <p className="finished-series-desc">{book.description}</p>}
<div className="finished-series-actions">
<a href="#episodes" className="finished-series-btn-stream">▶ Stream Episodes</a>
{book.downloadUrl && (
<a
href={book.downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="finished-series-btn-download"
>
⬇ Download Album
</a>
)}
{book.studySlug && (
<Link to={`/study/${book.studySlug}`} className="finished-series-btn-download">
📖 Study This Book
</Link>
)}
{book.guide?.title && (
<Link to={`/downloads/book-guide--${book.id}`} className="finished-series-btn-download">
📄 {book.guide.title}
</Link>
)}
</div>
</div>
{book.downloadQrUrl && book.downloadUrl && (
<div className="finished-series-qr">
<a href={book.downloadUrl} target="_blank" rel="noopener noreferrer">
<img src={book.downloadQrUrl} alt={`QR code to download the ${book.title} album`} className="finished-series-qr-img" />
</a>
<p className="finished-series-qr-label">Scan to download the full album</p>
</div>
)}
</div>
<FinishedSeriesEpisodes highlights={book.highlights ?? []} episodes={episodes} loading={loading} />
</main>
<SiteFooter content={content} />
</div>
)
}
function LatestEpisodePlayer({ content }: { content: SiteContent }) {
const audioRef = useRef<HTMLAudioElement>(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<number | null>(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<HTMLDivElement>) {
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 <div className="episode-embed-skeleton" aria-label="Loading player…" style={{ height: 152 }} />
return (
<div className="latest-episode-player">
<audio ref={audioRef} src={audioUrl} preload="metadata" />
<p className="latest-episode-player__show">Verse by Verse with Nate</p>
{title && <p className="latest-episode-player__title">{title}</p>}
<div className="latest-episode-player__play-row">
<button className="latest-episode-player__skip" onClick={() => skip(-15)} aria-label="Back 15 seconds">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/><text x="12" y="15" textAnchor="middle" fontSize="5.5" fontFamily="sans-serif" fill="currentColor">15</text></svg>
<span>back</span>
</button>
<button className={`latest-episode-player__play${buffering ? ' latest-episode-player__play--buffering' : ''}`} onClick={togglePlay} aria-label={playing ? 'Pause' : 'Play'}>
{playing
? <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
: <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><polygon points="5,3 19,12 5,21"/></svg>
}
</button>
<button className="latest-episode-player__skip" onClick={() => skip(30)} aria-label="Forward 30 seconds">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12.01 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/><text x="12" y="15" textAnchor="middle" fontSize="5.5" fontFamily="sans-serif" fill="currentColor">30</text></svg>
<span>fwd</span>
</button>
</div>
<div className="latest-episode-player__bar" onClick={seek} role="slider" aria-label="Seek" aria-valuenow={Math.round(currentTime)} aria-valuemin={0} aria-valuemax={Math.round(duration)} tabIndex={0} onKeyDown={e => { 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) }}>
<div className="latest-episode-player__fill" style={{ width: `${pct}%` }} />
<div className="latest-episode-player__thumb" style={{ left: `${pct}%` }} />
</div>
<div className="latest-episode-player__times">
<span>{fmt(currentTime)}</span>
<span>{fmt(duration)}</span>
</div>
{content.platformSpotifyUrl && (
<div className="latest-episode-player__spotify-row">
<a href={content.platformSpotifyUrl} target="_blank" rel="noreferrer" className="latest-episode-player__spotify-link">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm4.586 14.424a.623.623 0 01-.857.207c-2.348-1.435-5.304-1.76-8.785-.964a.623.623 0 11-.277-1.215c3.809-.87 7.076-.496 9.712 1.115a.623.623 0 01.207.857zm1.223-2.722a.779.779 0 01-1.072.257c-2.687-1.652-6.785-2.131-9.965-1.166a.78.78 0 01-.973-.519.779.779 0 01.519-.972c3.632-1.102 8.147-.568 11.234 1.328a.779.779 0 01.257 1.072zm.105-2.835C14.692 8.95 9.375 8.775 6.297 9.71a.935.935 0 11-.543-1.79c3.532-1.072 9.404-.865 13.115 1.338a.935.935 0 01-.955 1.609z"/></svg>
Also on Spotify
</a>
</div>
)}
</div>
)
}
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 (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Episodes" title="Podcast Episodes" desc={content.episodesSeoIntro || 'Expository Bible teaching — one verse at a time.'} />
<section className="section-player" aria-label="Current series episodes">
<div className="section-inner">
<p className="episode-seo-intro" aria-hidden="true">
{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.'}
</p>
<div className="embed-wrap">
<LatestEpisodePlayer content={content} />
</div>
<PlatformButtons content={content} />
<HeadlinerWidget />
</div>
</section>
<HomepageNewsletterSection />
<PodcastHighlightsSection content={content} />
<CustomBlocksSection content={content} page="episodes" />
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
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<NuggetsEpisode[]>([])
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 (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Sub-Show" title={nuggets.title} desc={nuggets.tagline} />
{(nuggets.podcastArtUrl || nuggets.description || nuggets.spotifyUrl || nuggets.appleUrl) && (
<section className="section-nuggets-intro" aria-label={`About ${nuggets.title}`}>
<div className="section-inner nuggets-intro-layout">
{nuggets.podcastArtUrl && <img src={nuggets.podcastArtUrl} alt={nuggets.title} className="nuggets-podcast-art" />}
<div className="nuggets-intro-body">
{nuggets.description && <p className="nuggets-description">{nuggets.description}</p>}
{(nuggets.spotifyUrl || nuggets.appleUrl) && (
<div className="nuggets-platform-links">
{nuggets.spotifyUrl && <a href={nuggets.spotifyUrl} target="_blank" rel="noopener noreferrer" className="btn-primary">Listen on Spotify</a>}
{nuggets.appleUrl && <a href={nuggets.appleUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary">Listen on Apple Podcasts</a>}
</div>
)}
</div>
</div>
</section>
)}
<section className="section-nuggets-body" aria-label={`${nuggets.title} hosts and episodes`}>
<div className="section-inner">
{activeHosts.length > 0 && (
<div className="nuggets-hosts-row-wrap">
<p className="eyebrow" style={{ marginBottom: '1rem' }}>Meet the Hosts</p>
<div className="nuggets-hosts-row">
{activeHosts.map(host => (
<Link key={host.id} to={`/nuggets/hosts/${host.id}`} className="nuggets-host-pill">
{host.imageUrl && <img src={host.imageUrl} alt={host.name} className="nuggets-host-pill-img" loading="lazy" />}
<span className="nuggets-host-pill-name">{host.name}</span>
</Link>
))}
</div>
</div>
)}
<div className="nuggets-episodes-full">
<p className="eyebrow">Episodes</p>
{loading ? (
<p className="nuggets-episodes-note">Loading episodes…</p>
) : !configured ? (
<p className="nuggets-episodes-note">Episodes are coming soon — check back once the show launches.</p>
) : episodes.length === 0 ? (
<p className="nuggets-episodes-note">No episodes published yet.</p>
) : (
<>
<div className="nuggets-latest-episode">
<p className="nuggets-latest-episode-eyebrow">Latest Episode</p>
<h2 className="nuggets-latest-episode-title">{latest.title}</h2>
{latest.description && <p className="nuggets-latest-episode-desc">{latest.description}</p>}
{latest.audioUrl && <EpisodeAudioPlayer src={latest.audioUrl} title={latest.title} size="full" />}
</div>
{rest.length > 0 && (
<ol className="finished-series-playlist nuggets-episode-list">
{rest.map((ep, i) => (
<li key={ep.audioUrl || i} className="finished-series-episode">
<span className="finished-series-ep-num">{ep.episode || i + 2}</span>
<div className="finished-series-ep-body">
<p className="finished-series-ep-title">{ep.title}</p>
{ep.description && <p className="nuggets-latest-episode-desc" style={{ fontSize: '0.88rem', margin: '3px 0 6px' }}>{ep.description}</p>}
{ep.duration && <p className="finished-series-ep-meta">{ep.duration}</p>}
{ep.audioUrl && <EpisodeAudioPlayer src={ep.audioUrl} title={ep.title} />}
</div>
</li>
))}
</ol>
)}
</>
)}
</div>
</div>
</section>
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
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 (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Nuggets" title="Host Not Found" desc="This host profile doesn't exist or has been removed." />
<section className="section-nuggets-body" style={{ textAlign: 'center', paddingBottom: '4rem' }}>
<Link to="/nuggets" className="btn-secondary">&larr; Back to Nuggets</Link>
</section>
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow={nuggets.title} title={host.name} desc={host.role} />
<section className="section-nuggets-body" aria-label={`${host.name} profile`}>
<div className="section-inner nuggets-host-detail-layout">
<div className="nuggets-host-detail-photo-col">
{host.imageUrl && (
<img src={host.imageUrl} alt={host.name} className="nuggets-host-detail-photo" />
)}
</div>
<div className="nuggets-host-detail-body">
{host.bio && host.bio.split(/\n\n+/).map((para, i) => (
<p key={i} className="nuggets-host-detail-bio">{para.replace(/\n/g, ' ')}</p>
))}
{(nuggets.spotifyUrl || nuggets.appleUrl) && (
<div className="nuggets-host-detail-listen">
<p className="eyebrow" style={{ marginBottom: '0.75rem' }}>Listen to {nuggets.title}</p>
<div className="nuggets-platform-links">
{nuggets.spotifyUrl && <a href={nuggets.spotifyUrl} target="_blank" rel="noopener noreferrer" className="btn-primary">Listen on Spotify</a>}
{nuggets.appleUrl && <a href={nuggets.appleUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary">Listen on Apple Podcasts</a>}
</div>
</div>
)}
<div style={{ marginTop: '2rem' }}>
<Link to="/nuggets" className="nuggets-host-detail-back">&larr; Back to {nuggets.title}</Link>
</div>
</div>
</div>
</section>
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
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 (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Resources" title="Study Guides & Resources" desc="Free guides, worksheets, and curated resources to help you go deeper in Scripture." />
<ResourcesHub content={content} downloadPath={id => `/downloads/${buildCustomDownloadPageId(id)}`} fallback={<TruthForLifeWidget type="resourcead" />} />
<CustomBlocksSection content={content} page="downloads" />
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
// 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<string, string> = {}
const SI_BOOK_NAME: Record<string, string> = {}
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<ScriptureIndexEntry[]>([])
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<string | null>(null)
const [toggled, setToggled] = useState<Record<string, boolean>>({})
const [verseTexts, setVerseTexts] = useState<Record<string, string>>({})
const [shownVerses, setShownVerses] = useState<Set<string>>(new Set())
const chapterCache = useRef<Map<string, Promise<HellaoContent>>>(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<string, ScriptureRow>()
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<string, ScriptureRow[]>()
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<string, boolean> = { 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 (
<li key={row.key} className="si-row">
<div className="si-row-top">
<span className="si-ref">{row.displayRef}</span>
<span className="si-sources">
{row.sources.map((src, i) => (
<Link key={i} to={src.url} className={`si-chip si-chip--${src.type}`}>
<span className="si-chip-kind">{src.type === 'episode' ? 'Episode' : 'Study'}</span>
<span className="si-chip-label">{src.label}</span>
</Link>
))}
</span>
{row.bookId && (
<button type="button" className="si-verse-btn" aria-expanded={showing} onClick={() => toggleVerse(row)}>
{showing ? 'Hide verse' : 'Show verse'}
</button>
)}
</div>
{showing && (
<p className="si-verse">{verseTexts[row.key] !== undefined ? `“${verseTexts[row.key]}”` : 'Loading…'}</p>
)}
</li>
)
}
const hasContent = bookGroups.length > 0 || otherRows.length > 0
const pill = (active: boolean) => `si-pill${active ? ' si-pill--on' : ''}`
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Reference" title="Scripture Index" desc="Every passage covered across our studies and episodes, organized by Scripture reference. Find the lesson or episode for any verse." />
<main className="si-main">
<div className="section-inner">
{loading ? (
<p className="si-note">Loading…</p>
) : !hasContent ? (
<p className="si-note">No Scripture references are published yet.</p>
) : (
<>
<div className="si-toolbar">
<input
type="search"
className="si-search"
placeholder="Search a passage or book (e.g. Titus 2)…"
aria-label="Search the Scripture index"
value={query}
onChange={e => setQuery(e.target.value)}
/>
<div className="si-filters">
<div className="si-pills" role="group" aria-label="Testament">
<button type="button" className={pill(testament === 'all')} onClick={() => { setTestament('all'); setBookFilter(null) }}>All</button>
<button type="button" className={pill(testament === 'ot')} onClick={() => { setTestament('ot'); setBookFilter(null) }}>Old Testament</button>
<button type="button" className={pill(testament === 'nt')} onClick={() => { setTestament('nt'); setBookFilter(null) }}>New Testament</button>
</div>
<div className="si-pills" role="group" aria-label="Source">
<button type="button" className={pill(sourceFilter === 'all')} onClick={() => setSourceFilter('all')}>Everything</button>
<button type="button" className={pill(sourceFilter === 'episode')} onClick={() => setSourceFilter('episode')}>Episodes</button>
<button type="button" className={pill(sourceFilter === 'study')} onClick={() => setSourceFilter('study')}>Studies</button>
</div>
</div>
</div>
{bookGroups.length > 1 && (
<div className="si-books" role="group" aria-label="Books covered">
{bookGroups.map(g => (
<button
key={g.bookId}
type="button"
className={pill(bookFilter === g.bookId)}
onClick={() => setBookFilter(bookFilter === g.bookId ? null : g.bookId)}
>
{g.bookName} <span>{g.rows.length}</span>
</button>
))}
</div>
)}
<div className="si-meta">
<span>{totalRows} passage{totalRows === 1 ? '' : 's'}{filtering ? ' match' : ''}</span>
<span className="si-meta-actions">
{filtering && (
<button type="button" className="si-link" onClick={() => { setQuery(''); setTestament('all'); setSourceFilter('all'); setBookFilter(null) }}>Clear filters</button>
)}
<button type="button" className="si-link" onClick={() => setAll(true)}>Expand all</button>
<button type="button" className="si-link" onClick={() => setAll(false)}>Collapse all</button>
</span>
</div>
{totalRows === 0 ? (
<p className="si-note">Nothing matches that. Try a book name like “Titus”, or clear the filters.</p>
) : (
<div className="si-groups">
{visibleGroups.map(group => {
const open = isOpen(group.bookId)
return (
<section key={group.bookId} className="si-group">
<button type="button" className="si-group-head" aria-expanded={open} onClick={() => setToggled(t => ({ ...t, [group.bookId]: !open }))}>
<span className="si-group-name">{group.bookName}</span>
<span className="si-group-count">{group.rows.length} passage{group.rows.length === 1 ? '' : 's'}</span>
<span className="si-chev" aria-hidden="true">{open ? '−' : '+'}</span>
</button>
{open && <ul className="si-list">{group.rows.map(renderRow)}</ul>}
</section>
)
})}
{visibleOther.length > 0 && (
<section className="si-group">
<button type="button" className="si-group-head" aria-expanded={isOpen('other')} onClick={() => setToggled(t => ({ ...t, other: !isOpen('other') }))}>
<span className="si-group-name">Other references</span>
<span className="si-group-count">{visibleOther.length} passage{visibleOther.length === 1 ? '' : 's'}</span>
<span className="si-chev" aria-hidden="true">{isOpen('other') ? '−' : '+'}</span>
</button>
{isOpen('other') && <ul className="si-list">{visibleOther.map(renderRow)}</ul>}
</section>
)}
</div>
)}
</>
)}
</div>
</main>
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
function AboutPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`About | ${siteTitle}`,
content.aboutNate || 'Learn about Verse by Verse with Nate — expository Bible teaching from Nate Emmert.',
)
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="About" title="About the Show" />
<AboutSection content={content} />
<TestimonialsSection testimonials={content.testimonials ?? []} />
<KofiSupportSection content={content} />
<CustomBlocksSection content={content} page="about" />
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
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 (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow={content.contactEyebrow || 'Contact'} title={content.contactHeading || 'Get in Touch'} />
<ContactSection content={content} />
<KofiSupportSection content={content} />
<CustomBlocksSection content={content} page="contact" />
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
// 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 (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<main className="start-page" aria-labelledby="start-here-heading">
<header className="start-hero">
<p className="eyebrow">Verse by Verse with Nate</p>
<h1 id="start-here-heading">{content.startHereHeading}</h1>
{content.startHereIntro && <p className="start-intro">{content.startHereIntro}</p>}
</header>
<ol className="start-steps">
{steps.map((step, index) => {
const { number, title } = splitStepTitle(step.title, index + 1)
return (
<li className="start-step" key={step.to}>
<div className="start-step-top">
<span className="start-step-number" aria-hidden="true">{number}</span>
<span className="start-step-icon" aria-hidden="true">{step.icon}</span>
</div>
<p className="start-step-label">Step {number}</p>
<h2 className="start-step-title">{title}</h2>
<p className="start-step-body">{step.body}</p>
<Link to={step.to} className={`${step.primary ? 'btn-primary' : 'btn-secondary'} start-step-cta`}>
{step.cta} <span aria-hidden="true">→</span>
</Link>
</li>
)
})}
</ol>
<p className="start-back"><Link to="/">← Back to site</Link></p>
</main>
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
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 (
<main className="thanks-page" aria-label="Thank you">
<div className="thanks-card">
<p className="eyebrow">Message Received</p>
<h1>Thank You</h1>
<p>
Your message was sent successfully. We appreciate you reaching out and will get back to you soon.
</p>
<p className="thanks-countdown">Returning to the main site in {secondsLeft} seconds...</p>
<button type="button" className="btn-primary" onClick={() => navigate('/')}>
Go Back Now
</button>
</div>
</main>
)
}
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 (
<main className="thanks-page" aria-label="Subscribe">
<div className="thanks-card subscribe-card">
<p className="eyebrow">Verse by Verse with Nate</p>
<h1>Subscribe for Updates</h1>
<p>
Get ministry updates and new episode announcements by email.
</p>
<form className="contact-form subscribe-form" onSubmit={handleSubmit} noValidate>
<input
type="text"
className="contact-honeypot"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
value={honey}
onChange={e => setHoney(e.target.value)}
/>
<label>
First Name
<input
type="text"
name="firstName"
required
autoComplete="given-name"
value={firstName}
onChange={e => setFirstName(e.target.value)}
/>
</label>
<label>
Last Name
<input
type="text"
name="lastName"
required
autoComplete="family-name"
value={lastName}
onChange={e => setLastName(e.target.value)}
/>
</label>
<label>
Email
<input
type="email"
name="email"
required
autoComplete="email"
value={email}
onChange={e => setEmail(e.target.value)}
/>
</label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Subscribing…' : 'Subscribe'}
</button>
<Link to="/" className="btn-secondary">Back to Site</Link>
</form>
</div>
</main>
)
}
function SubscribeThankYouPage() {
return (
<main className="thanks-page" aria-label="Subscription confirmed">
<div className="thanks-card">
<p className="eyebrow">Verse by Verse with Nate</p>
<h1>You are subscribed</h1>
<p>
Thank you for subscribing. You will receive future ministry updates and episode announcements.
</p>
<Link to="/" className="btn-primary">Back to Site</Link>
</div>
</main>
)
}
function LegalPage({ title, body }: { title: string; body: string[] }) {
return (
<main className="thanks-page" aria-label={title}>
<div className="thanks-card">
<p className="eyebrow">Verse by Verse with Nate</p>
<h1>{title}</h1>
{body.map((line, idx) => (
<p key={`${title}-${idx}`}>{line}</p>
))}
<Link to="/" className="btn-primary">Back to Site</Link>
</div>
</main>
)
}
// Certificate links emailed before v2.10.10 pointed at /study/certificate/:token.
function LegacyCertificateRedirect() {
const { token } = useParams<{ token: string }>()
return <Navigate to={`/certificate/${encodeURIComponent(token ?? '')}`} replace />
}
function PublicCertificatePage() {
const { token } = useParams<{ token: string }>()
const [cert, setCert] = useState<{ studyTitle: string; displayName: string; issuedAt: string } | null>(null)
const [status, setStatus] = useState<'loading' | 'not-found' | 'ready'>('loading')
useEffect(() => {
if (!token) { setStatus('not-found'); return }
fetch(`/api/public/certificate/${encodeURIComponent(token)}`)
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => { setCert(data); setStatus('ready') })
.catch(() => setStatus('not-found'))
}, [token])
usePageMeta(
cert ? `Certificate of Completion – ${cert.studyTitle}` : 'Certificate',
cert ? `${cert.displayName} completed ${cert.studyTitle}` : undefined,
)
if (status === 'loading') {
return (
<main className="thanks-page">
<div className="cert-skeleton-wrap" aria-busy="true" aria-label="Loading certificate">
<div className="cert-skeleton-card">
<div className="cert-skeleton-line cert-skeleton-line--short" />
<div className="cert-skeleton-line cert-skeleton-line--title" />
<div className="cert-skeleton-line cert-skeleton-line--medium" />
<div className="cert-skeleton-line cert-skeleton-line--short" />
</div>
</div>
</main>
)
}
if (status === 'not-found' || !cert) {
return (
<main className="thanks-page">
<div className="thanks-card">
<h1>Certificate Not Found</h1>
<p>This certificate link is invalid or has been removed.</p>
<Link to="/" className="btn-primary">Back to Site</Link>
</div>
</main>
)
}
return (
<main className="thanks-page public-cert-page" aria-label="Certificate of Completion">
<div className="public-cert-card">
{/* Corner ornaments */}
<span className="cert-corner cert-corner--tl" aria-hidden="true">✦</span>
<span className="cert-corner cert-corner--tr" aria-hidden="true">✦</span>
<span className="cert-corner cert-corner--bl" aria-hidden="true">✦</span>
<span className="cert-corner cert-corner--br" aria-hidden="true">✦</span>
<div className="cert-inner">
<p className="cert-issuer">✦ &nbsp; Verse by Verse with Nate &nbsp; ✦</p>
<div className="cert-ornament-rule">
<span className="cert-rule-line" />
<span className="cert-rule-diamond">◆</span>
<span className="cert-rule-line" />
</div>
<h1 className="cert-title">Certificate<br />of Completion</h1>
<div className="cert-ornament-rule cert-ornament-rule--sm">
<span className="cert-rule-line" />
<span className="cert-rule-diamond">◆</span>
<span className="cert-rule-line" />
</div>
<p className="cert-presented">Presented to</p>
<p className="cert-name">{cert.displayName}</p>
<p className="cert-body-text">
in recognition of faithful study and completion of
</p>
<p className="cert-study">{cert.studyTitle}</p>
<div className="cert-seal" aria-hidden="true">
<span className="cert-seal-ring">
<span className="cert-seal-inner">✦</span>
</span>
</div>
<div className="cert-ornament-rule cert-ornament-rule--sm">
<span className="cert-rule-line" />
<span className="cert-rule-diamond">◆</span>
<span className="cert-rule-line" />
</div>
<p className="cert-date">
Awarded on {new Date(cert.issuedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
</p>
<Link to="/" className="cert-back-link">Visit Verse by Verse with Nate →</Link>
</div>
</div>
</main>
)
}
function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: SiteContent) => void }) {
const [status, setStatus] = useState<'checking' | 'authenticated' | 'unauthenticated' | 'misconfigured'>('checking')
const [adminContent, setAdminContent] = useState<SiteContent>(content)
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 <AdminPage content={adminContent} onSave={handleAdminSave} onLogout={handleLogout} />
}
return (
<main className="admin-auth-page" aria-label="Admin sign in">
<div className="admin-auth-card">
<p className="eyebrow">Admin Access</p>
<h1>{status === 'misconfigured' ? 'Admin Not Configured' : 'Sign in to Admin'}</h1>
{status === 'checking' && <p className="admin-auth-note">Checking session...</p>}
{status === 'misconfigured' && (
<p className="admin-auth-note">Set the ADMIN_PASSWORD environment variable on the server to enable admin login.</p>
)}
{status === 'unauthenticated' && !totpRequired && (
<form className="admin-auth-form" onSubmit={handleLogin}>
<label>
Password
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</label>
{errorMsg && <p className="admin-auth-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Signing In…' : 'Sign In'}
</button>
<Link to="/" className="btn-secondary">Back to Site</Link>
</form>
)}
{status === 'unauthenticated' && totpRequired && (
<form className="admin-auth-form" onSubmit={handleTotpVerify}>
<p className="admin-auth-note">Enter the 6-digit code from your authenticator app, or one of your recovery codes.</p>
<label>
Code
<input
type="text"
inputMode="numeric"
value={totpCode}
onChange={e => setTotpCode(e.target.value)}
autoComplete="one-time-code"
placeholder="000000 or XXXX-XXXX-XXXX"
autoFocus
required
/>
</label>
{errorMsg && <p className="admin-auth-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Verifying…' : 'Verify'}
</button>
<button type="button" className="btn-secondary" onClick={handleBackToPassword}>Back</button>
</form>
)}
</div>
</main>
)
}
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 (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Q&A" title="Questions & Answers" desc="Real questions answered from Scripture — browse by topic or submit your own." />
<QASection />
<CustomBlocksSection content={content} page="questions" />
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
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<NewsletterIssueSummary[]>([])
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 (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Newsletter" title="Newsletter Archive" desc="Past issues of the Verse by Verse with Nate newsletter — episode notes, study excerpts, and Greek word studies." />
<section className="section-resources" aria-label="Newsletter archive">
<div className="section-inner">
{loading ? (
<p className="episode-seo-intro">Loading issues…</p>
) : issues.length === 0 ? (
<p className="episode-seo-intro">No newsletter issues published yet.</p>
) : (
<div className="resources-list">
{issues.map(issue => (
<a key={issue.id} href={`/newsletter/${issue.slug}`} className="resource-download-card">
<div className="resource-download-header">
<div className="resource-download-meta">
<span className="resource-link-label">{issue.title}</span>
{issue.description && <p className="resource-link-description">{issue.description}</p>}
{issue.sentAt && (
<span className="resource-link-tags">
{new Date(issue.sentAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
</span>
)}
<span className="resource-link-action">Read issue →</span>
</div>
</div>
</a>
))}
</div>
)}
</div>
</section>
<CustomBlocksSection content={content} page="newsletter" />
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
function PreviewPage() {
const [content, setContent] = useState<SiteContent | null>(null)
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [previewView, setPreviewView] = useState<string>('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 (
<main className="thanks-page" aria-label="Draft preview loading">
<div className="thanks-card">
<p className="eyebrow">Admin Preview</p>
<h1>Loading draft preview...</h1>
<p>Only signed-in admins can view this page.</p>
</div>
</main>
)
}
if (status === 'error' || !content) {
return (
<main className="thanks-page" aria-label="Draft preview unavailable">
<div className="thanks-card">
<p className="eyebrow">Admin Preview</p>
<h1>Draft preview unavailable</h1>
<p>Sign into admin and save a draft first.</p>
<Link to="/admin" className="btn-primary">Back to Admin</Link>
</div>
</main>
)
}
if (previewView === 'start-here') return <StartHerePage content={content} />
if (previewView === 'about') return <AboutPage content={content} />
if (previewView === 'contact') return <ContactPage content={content} />
if (previewView === 'current-series' || previewView === 'episode-highlights') {
return <EpisodesPage content={content} />
}
return <LandingPage content={content} />
}
function StudyRouteFrame({ content, child }: { content: SiteContent; child: ReactElement }) {
return (
<>
<SiteHeader content={content} />
<SiteSearchBar content={content} />
{child}
<SiteFooter content={content} />
</>
)
}
export default function App() {
const [content, setContent] = useState<SiteContent>(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<string, string>) => 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 (
<>
<ScrollToTop />
<Routes>
<Route path="/" element={<LandingPage content={content} />} />
<Route path="/start-here" element={<StartHerePage content={content} />} />
<Route path="/study" element={<StudyRouteFrame content={content} child={<StudyLandingPage content={content} />} />} />
<Route path="/study/signup" element={<StudyRouteFrame content={content} child={<StudySignupPage />} />} />
<Route path="/study/reset-password" element={<StudyRouteFrame content={content} child={<StudyResetPasswordPage />} />} />
<Route path="/study/verify-email" element={<StudyRouteFrame content={content} child={<StudyVerifyEmailPage />} />} />
<Route path="/study/account" element={<StudyRouteFrame content={content} child={<StudyAccountPage />} />} />
<Route path="/study/discussion" element={<StudyRouteFrame content={content} child={<StudyDiscussionHubPage content={content} />} />} />
<Route path="/study/certificate/:token" element={<LegacyCertificateRedirect />} />
<Route path="/study/:studySlug" element={<StudyRouteFrame content={content} child={<ColossiansStudyIndexPage content={content} />} />} />
<Route path="/study/:studySlug/community" element={<StudyRouteFrame content={content} child={<StudyCommunityPage content={content} />} />} />
<Route path="/study/:studySlug/notes" element={<StudyRouteFrame content={content} child={<ColossiansStudyNotesPage content={content} />} />} />
<Route path="/study/:studySlug/:sectionId/quiz" element={<StudyRouteFrame content={content} child={<StudyQuizPage content={content} />} />} />
<Route path="/study/:studySlug/:sectionId" element={<StudyRouteFrame content={content} child={<ColossiansStudySectionPage content={content} />} />} />
<Route path="/finished" element={<FinishedBooksPage content={content} />} />
<Route path="/finished/:id" element={<FinishedSeriesPage content={content} />} />
<Route path="/episodes" element={<EpisodesPage content={content} />} />
<Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} />
<Route path="/nuggets" element={<NuggetsPage content={content} />} />
<Route path="/nuggets/hosts/:hostId" element={<NuggetsHostDetailPage content={content} />} />
<Route path="/resources" element={<ResourcesPage content={content} />} />
<Route path="/downloads/:id" element={<DownloadDetailPage content={content} />} />
<Route path="/about" element={<AboutPage content={content} />} />
<Route path="/contact" element={<ContactPage content={content} />} />
<Route path="/questions" element={<QuestionsPage content={content} />} />
<Route path="/newsletter" element={<NewsletterArchivePage content={content} />} />
<Route path="/scripture" element={<ScriptureIndexPage content={content} />} />
<Route path="/thanks" element={<ThankYouPage />} />
<Route path="/subscribe" element={<SubscribePage />} />
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
<Route path="/host" element={<NuggetsHostPortal />} />
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
<Route path="/preview" element={<PreviewPage />} />
<Route
path="/privacy"
element={(
<LegalPage
title={content.legal?.privacyTitle ?? DEFAULTS.legal.privacyTitle}
body={content.legal?.privacyBody ?? DEFAULTS.legal.privacyBody}
/>
)}
/>
<Route
path="/terms"
element={(
<LegalPage
title={content.legal?.termsTitle ?? DEFAULTS.legal.termsTitle}
body={content.legal?.termsBody ?? DEFAULTS.legal.termsBody}
/>
)}
/>
<Route path="*" element={<NotFoundPage />} />
</Routes>
<BackToTopButton />
</>
)
}
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 (
<main className="thanks-page" aria-label="Page not found">
<div className="thanks-card">
<img
src="/book_icon.png"
alt=""
aria-hidden="true"
style={{ width: '72px', marginBottom: '0.75rem', opacity: 0.55 }}
/>
<p className="eyebrow">404</p>
<h1>Page Not Found</h1>
<p>
The path <code style={{ fontSize: '0.95em', opacity: 0.85 }}>{location.pathname}</code>{' '}
doesn't exist — it may have moved or been removed.
</p>
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', flexWrap: 'wrap', marginBottom: '2rem' }}>
<Link to="/" className="btn-primary">← Back to Home</Link>
<Link to="/episodes" className="btn-secondary">Browse Episodes</Link>
</div>
<div style={{ borderTop: '1px solid rgba(201,168,76,0.15)', paddingTop: '1.5rem' }}>
{submitStatus !== 'sent' ? (
<>
<p style={{ fontSize: '0.9rem', marginBottom: showForm ? '1rem' : 0 }}>
Think this is a broken link?{' '}
{!showForm && (
<button
onClick={() => setShowForm(true)}
style={{
background: 'none', border: 'none', color: 'var(--brand-gold)',
cursor: 'pointer', fontFamily: 'inherit', fontSize: 'inherit',
textDecoration: 'underline', padding: 0,
}}
>
Let Nate know.
</button>
)}
</p>
{showForm && (
<form className="contact-form" onSubmit={handleReport} style={{ textAlign: 'left' }}>
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap' }}>
<label style={{ flex: '1', minWidth: '120px' }}>
Your name
<input
type="text"
value={form.firstName}
onChange={e => setForm(f => ({ ...f, firstName: e.target.value }))}
placeholder="First name"
required
maxLength={100}
/>
</label>
<label style={{ flex: '2', minWidth: '180px' }}>
Email
<input
type="email"
value={form.email}
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
placeholder="you@example.com"
required
/>
</label>
</div>
<label>
Message
<textarea
value={form.message}
onChange={e => setForm(f => ({ ...f, message: e.target.value }))}
rows={3}
maxLength={3000}
required
/>
</label>
<input type="text" name="_honey" style={{ display: 'none' }} aria-hidden="true" tabIndex={-1} />
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<button type="submit" className="btn-secondary" disabled={submitStatus === 'busy'}>
{submitStatus === 'busy' ? 'Sending…' : 'Report Broken Link'}
</button>
{submitStatus === 'error' && (
<span style={{ color: '#e05c5c', fontSize: '0.9rem' }}>{errMsg}</span>
)}
</div>
</form>
)}
</>
) : (
<p style={{ color: '#6fcf97' }}>Thanks! Nate will look into it.</p>
)}
</div>
</div>
</main>
)
}
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 (
<button
type="button"
className="back-to-top"
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
aria-label="Scroll back to top"
>
↑ Top
</button>
)
}