Files
Siteforge/src/App.tsx
T
nmemmertandClaude Sonnet 5 3d3c7175a1
Publish Container / build (push) Successful in 16m31s
Add a study-picker discussion hub and require sign-in to view it; v2.2.0
The "Discussion" nav link previously jumped straight into the featured
study's community board. Adds /study/discussion as a proper landing page
that lists every study track so students pick which one to discuss, then
links into /study/:slug/community from there. Both pages now require an
authenticated session to view at all (previously the per-study community
page rendered its shell to logged-out visitors, only gating the post feed
itself).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 10:19:20 -04:00

3421 lines
137 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, 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 } from './content'
import { DEFAULTS } from './content'
import { usePageMeta } from './hooks/usePageMeta'
import { useGlobalSearch } from './hooks/useGlobalSearch'
import { GlobalSearch } from './components/GlobalSearch'
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
import { NuggetsHostPortal } from './components/NuggetsHostPortal'
import { BIBLE_BOOKS } from '../shared/bible-books.js'
import './App.css'
import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useLinkClickTracking } from './analytics'
const CONSENT_KEY = 'vbn_analytics_consent_choice'
const HEADLINER_WIDGET_ID = 'WI_cmou3b4q7000701p0o9qmmcfj'
function ScrollToTop() {
const { pathname } = useLocation()
useEffect(() => { window.scrollTo(0, 0) }, [pathname])
return null
}
function usePageTracking() {
const location = useLocation()
useEffect(() => {
if (localStorage.getItem(CONSENT_KEY) !== 'accepted') return
const referrer = document.referrer || ''
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
fetch('/api/analytics/pageview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: location.pathname, referrer }),
keepalive: true,
signal: controller.signal,
}).catch(() => {}).finally(() => clearTimeout(timeout))
}, [location.pathname])
}
function toSpotifyEpisodeEmbedUrl(url: string | undefined): string {
if (!url) return ''
try {
const parsed = new URL(url)
const host = parsed.hostname.toLowerCase()
const parts = parsed.pathname.split('/').filter(Boolean)
if (host === 'open.spotify.com') {
if (parts[0] === 'embed' && parts[1] === 'episode' && parts[2]) {
return `https://open.spotify.com/embed/episode/${parts[2]}?utm_source=generator`
}
if (parts[0] === 'episode' && parts[1]) {
return `https://open.spotify.com/embed/episode/${parts[1]}?utm_source=generator`
}
}
} catch {
return ''
}
return ''
}
function isLikelySpotifyEpisodeUrl(url: string | undefined): boolean {
if (!url) return false
if (toSpotifyEpisodeEmbedUrl(url)) return true
try {
const parsed = new URL(url)
const host = parsed.hostname.toLowerCase()
const path = parsed.pathname.toLowerCase()
if (host === 'creators.spotify.com' && path.includes('/episodes/')) return true
if (host === 'anchor.fm' && path.includes('/episodes/')) return true
if (host === 'podcasters.spotify.com' && path.includes('/episodes/')) return true
} catch {
return false
}
return false
}
function getHomepageFeaturedStudy(content: SiteContent): StudyProgram | null {
const studies = Array.isArray(content.studies) ? content.studies : []
if (studies.length === 0) return null
return studies.find(study => study.showOnHomepage === true)
?? studies.find(study => study.status === 'active')
?? studies[0]
}
function HeadlinerWidget() {
const [iframeSrc, setIframeSrc] = useState('')
const [status, setStatus] = useState<'loading' | 'ready' | 'empty'>('loading')
useEffect(() => {
const getDiscoUrl = () => {
const canonicalHref = document.querySelector('link[rel="canonical"]')?.getAttribute('href')
const ogUrl = document.querySelector('meta[property="og:url"]')?.getAttribute('content')
const base = canonicalHref || ogUrl
if (!base) return window.location.href
try {
const parsed = new URL(base)
return `${parsed.origin}${window.location.pathname}`
} catch {
return window.location.href
}
}
const discoUrl = getDiscoUrl()
const discoTitle = document.querySelector('meta[property="og:title"]')?.getAttribute('content') || document.title
const src = `https://disco.headliner.link/d/web/widget.html?widgetId=${encodeURIComponent(HEADLINER_WIDGET_ID)}&url=${encodeURIComponent(discoUrl)}&title=${encodeURIComponent(discoTitle)}`
const sessionId = `SS_siteforge_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
const query = new URLSearchParams({
sessionId,
url: discoUrl,
widgetId: HEADLINER_WIDGET_ID,
})
fetch(`https://api.headliner.link/api/v1/widget/widget-request?${query.toString()}`)
.then(r => (r.ok ? r.json() : Promise.reject(new Error('widget request failed'))))
.then((data: { results?: unknown[] }) => {
if (Array.isArray(data.results) && data.results.length > 0) {
setIframeSrc(src)
setStatus('ready')
return
}
setStatus('empty')
})
.catch(() => {
// If API probing fails, still attempt iframe rendering.
setIframeSrc(src)
setStatus('ready')
})
}, [])
return (
status === 'empty' ? null : (
<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,
}
}
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 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'
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">
<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="/study/account" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>My Account</NavLink>
<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"
>
{content.headerFollowLabel || 'Follow on Spotify'}
</a>
</nav>
</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="section-kofi-support" aria-label="Support the show">
<div className="section-inner kofi-support-inner">
<p className="eyebrow">Support the Ministry</p>
<h2>Help Keep the Word Going</h2>
<p className="kofi-support-desc">
This podcast and study resources are listener-supported. If God has used this ministry in your life,
consider buying me a coffee — it goes directly toward keeping the teaching free for everyone.
</p>
{qrUrl && (
<div className="kofi-qr-wrap">
<img src={qrUrl} alt="Ko-fi donation QR code" className="kofi-qr-img" />
<p className="kofi-qr-caption">Scan to give on Ko-fi</p>
</div>
)}
{kofiUrl && (
<a href={kofiUrl} target="_blank" rel="noreferrer" className="kofi-btn">
☕ Support on Ko-fi
</a>
)}
</div>
</section>
)
}
function AboutSection({ content }: { content: SiteContent }) {
return (
<section className="section-about" aria-label="About the show">
<div className="section-inner about-inner">
<div className="about-photo">
<img src={content.aboutPhotoUrl || '/images/nate-photo.jpeg'} alt="Nate Emmert" />
<div className="about-photo-divider" aria-hidden="true" />
<div className="about-nate-copy">
<p className="eyebrow">{content.aboutEyebrow || 'About Nate'}</p>
<div className="about-nate-rule" aria-hidden="true" />
<p>{content.aboutNate}</p>
<Link to="/episodes" className="about-nate-cta">{content.aboutListenBtnLabel || 'Listen Now ↓'}</Link>
</div>
</div>
<div className="about-text">
{content.aboutShowEyebrow && <p className="eyebrow">{content.aboutShowEyebrow}</p>}
<h2>{content.aboutShowHeading}</h2>
<p>{content.aboutShowP1}</p>
<p>{content.aboutShowP2}</p>
<div className="rule-divider" aria-hidden="true" />
<img
src={content.aboutVerseArtUrl || '/images/hebrews-4-12-verse-art.png'}
alt="Hebrews 4:12 verse artwork"
className="about-verse-art"
/>
</div>
</div>
</section>
)
}
function ContactSection({ content }: { content: SiteContent }) {
return (
<section className="section-contact" id="contact" aria-label="Contact form">
<div className="section-inner contact-inner">
<div className="contact-copy">
<div className="contact-card">
<div className="contact-profile">
<img
src={content.contactPhotoUrl || '/images/nate-contact-photo.png'}
alt={content.contactName || 'Nate'}
className="contact-profile-photo"
/>
<div className="contact-profile-meta">
<p className="contact-profile-name">{content.contactName || 'Nate'}</p>
<p className="contact-profile-role">{content.contactRole || 'Bible teacher · Lynchburg, VA'}</p>
</div>
</div>
{content.contactQuote && (
<blockquote className="contact-quote">
<p>"{content.contactQuote}"</p>
</blockquote>
)}
{content.contactIntro && (
<p className="contact-intro">{content.contactIntro}</p>
)}
<div className="contact-points" aria-label="Contact response details">
{content.contactPoint1 && (
<div className="contact-point">
<svg width="15" height="15" 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>
<span>{content.contactPoint1}</span>
</div>
)}
{content.contactPoint2 && (
<div className="contact-point">
<svg width="15" height="15" 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>
<span>{content.contactPoint2}</span>
</div>
)}
</div>
{content.contactVerse && (
<div className="contact-scripture">
<p>
"{content.contactVerse}" -{' '}
<span className="contact-scripture-ref">{content.contactVerseRef}</span>
</p>
</div>
)}
</div>
</div>
<ContactForm />
</div>
</section>
)
}
function PodcastHighlightsSection({ content }: { content: SiteContent }) {
const links = [...(content.podcastFeaturedLinks ?? [])].reverse()
if (links.length === 0) return null
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>
<div className="episode-list">
{links.map(link => {
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 dest = (!hasRichContent && link.url)
? { external: link.url }
: { internal: `/episodes/${link.id}` }
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 ('external' in dest) {
return (
<a key={link.id} href={dest.external} target="_blank" rel="noreferrer" className="episode-card">
{inner}
</a>
)
}
return (
<Link key={link.id} to={dest.internal} className="episode-card">
{inner}
</Link>
)
})}
</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 DownloadLibrarySection({ content }: { content: SiteContent }) {
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
const [activeTag, setActiveTag] = useState<string | null>(null)
const hasPrimaryGuide = !!(content.studyGuideTitle)
const allTags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean)
const filteredResources = resources.filter(r => {
return !activeTag || (r.tags ?? []).includes(activeTag)
})
if (!hasPrimaryGuide && resources.length === 0) return null
return (
<section className="section-resources section-download-library" aria-label="Download library">
<div className="section-inner">
{hasPrimaryGuide && (
<Link to="/downloads/primary-guide" className="resource-download-card resource-download-card--link resource-download-card--featured">
<div className="resource-download-header">
{(content.seriesImageUrl || '/images/titus-cover.png') && (
<img
src={content.seriesImageUrl || '/images/titus-cover.png'}
alt={content.seriesTitle}
className="resource-link-image resource-link-image--featured"
loading="lazy"
/>
)}
<div className="resource-download-meta">
<div className="featured-badge">Featured</div>
<span className="resource-link-label">{content.studyGuideTitle}</span>
{content.studyGuideDescription && (
<p className="resource-link-description">{content.studyGuideDescription}</p>
)}
<span className="resource-link-action">Download free guide →</span>
</div>
</div>
</Link>
)}
{resources.length > 0 && (
<>
{allTags.length > 0 && (
<div className="download-filter-bar">
<div className="download-tag-filter">
<button
type="button"
className={`download-tag-chip${activeTag === null ? ' download-tag-chip--active' : ''}`}
onClick={() => setActiveTag(null)}
>
All topics
</button>
{allTags.map(tag => (
<button
key={tag}
type="button"
className={`download-tag-chip${activeTag === tag ? ' download-tag-chip--active' : ''}`}
onClick={() => setActiveTag(prev => prev === tag ? null : tag)}
>
{tag}
</button>
))}
{activeTag !== null && (
<button
type="button"
className="download-clear-link"
onClick={() => setActiveTag(null)}
>
Clear
</button>
)}
</div>
</div>
)}
{filteredResources.length > 0 ? (
<div className="resources-list">
{filteredResources.map(resource => (
<Link key={resource.id} to={`/downloads/${buildCustomDownloadPageId(resource.id)}`} className="resource-download-card resource-download-card--link">
<div className="resource-download-header">
{resource.imageUrl && (
<img src={resource.imageUrl} alt={resource.label} className="resource-link-image" loading="lazy" />
)}
<div className="resource-download-meta">
<span className="resource-link-label">{resource.label}</span>
{resource.description && <p className="resource-link-description">{resource.description}</p>}
{(resource.tags ?? []).length > 0 && (
<div className="resource-link-tags">{(resource.tags ?? []).join(', ')}</div>
)}
<span className="resource-link-action">Open download page →</span>
</div>
</div>
</Link>
))}
</div>
) : (
<p className="download-empty-state">No resources tagged "{activeTag}".</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 (
<main className="thanks-page download-detail-page" aria-label={resource.label}>
<div className="thanks-card download-detail-card">
<p className="eyebrow">Resources</p>
<h1>{resource.label}</h1>
<p>{resource.summary}</p>
{resource.imageUrl && (
<div className="download-detail-art">
<img src={resource.imageUrl} alt={resource.label} className="guide-cover-img" />
</div>
)}
{resource.tags.length > 0 && (
<div className="resource-link-tags">{resource.tags.join(', ')}</div>
)}
<div className="download-detail-form-wrap">
{resource.isPrimaryGuide
? <StudyDownloadForm buttonText={resource.buttonText} />
: <ResourceDownloadForm resourceId={resource.resourceId} buttonText={resource.buttonText} />}
</div>
<div className="download-detail-actions">
{resource.amazonUrl && (
<a href={resource.amazonUrl} target="_blank" rel="noreferrer" className="btn-secondary">
{resource.amazonLabel || 'Get it on Amazon'}
</a>
)}
{resource.appleBooksUrl && (
<a href={resource.appleBooksUrl} target="_blank" rel="noreferrer" className="btn-secondary">
{resource.appleBooksLabel || 'Get it on Apple Books'}
</a>
)}
<Link to="/resources" className="btn-secondary">Back to Resources</Link>
</div>
</div>
</main>
)
}
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>}
</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">
<article className="series-card">
<img
src={content.seriesImageUrl}
alt={content.seriesTitle}
className="series-art"
/>
<div className="series-info">
<p className="series-label">{content.seriesLabel}</p>
<h3>{content.seriesTitle}</h3>
<p>{content.seriesDescription}</p>
<a
href={content.seriesListenUrl || content.platformSpotifyUrl || '/spotify'}
target="_blank"
rel="noreferrer"
className="btn-primary"
>
<SpotifyIcon />
{content.seriesListenBtnLabel || 'Listen to Series'}
</a>
<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 = (content.podcastFeaturedLinks ?? []).find(e => e.id === id)
const [resolvedEmbedUrl, setResolvedEmbedUrl] = useState('')
useEffect(() => {
let cancelled = false
if (!episode) {
setResolvedEmbedUrl('')
return () => {
cancelled = true
}
}
const manualEmbed = episode.embedUrl?.trim() ?? ''
if (manualEmbed) {
setResolvedEmbedUrl(manualEmbed)
return () => {
cancelled = true
}
}
if (!episode.url) {
setResolvedEmbedUrl('')
return () => {
cancelled = true
}
}
try {
const urlObj = new URL(episode.url)
const host = urlObj.hostname.toLowerCase()
if (host === 'open.spotify.com') {
const fromUrl = toSpotifyEpisodeEmbedUrl(episode.url)
if (fromUrl) {
setResolvedEmbedUrl(fromUrl)
return () => {
cancelled = true
}
}
}
} catch {
// ignore
}
if (!isLikelySpotifyEpisodeUrl(episode.url)) {
setResolvedEmbedUrl('')
return () => {
cancelled = true
}
}
setResolvedEmbedUrl('')
fetch(`/api/spotify/embed-url?url=${encodeURIComponent(episode.url)}`)
.then(r => (r.ok ? r.json() : Promise.reject(new Error('embed resolve failed'))))
.then((data: { embedUrl?: string }) => {
if (cancelled) return
setResolvedEmbedUrl(data.embedUrl?.trim() ?? '')
})
.catch(() => {
if (cancelled) return
setResolvedEmbedUrl('')
})
return () => {
cancelled = true
}
}, [episode])
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>
)
}
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="#playlist" 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>
)}
</div>
{book.downloadQrUrl && book.downloadUrl && (
<div className="finished-series-qr">
<a href={book.downloadUrl} target="_blank" rel="noopener noreferrer">
<img src={book.downloadQrUrl} alt="Scan to download" className="finished-series-qr-img" />
</a>
<p className="finished-series-qr-label">Scan to download</p>
</div>
)}
</div>
</div>
<h2 id="playlist" className="finished-series-playlist-heading">Episode Playlist</h2>
{loading ? (
<p className="finished-books-empty">Loading episodes…</p>
) : episodes.length === 0 ? (
<p className="finished-books-empty">No episodes found for this season.</p>
) : (
<ol className="finished-series-playlist">
{episodes.map((ep, i) => (
<li key={ep.audioUrl || i} className="finished-series-episode">
<span className="finished-series-ep-num">{ep.episode || i + 1}</span>
<div className="finished-series-ep-body">
<p className="finished-series-ep-title">{ep.title}</p>
{ep.duration && <p className="finished-series-ep-meta">{ep.duration}</p>}
{ep.audioUrl && (
<EpisodeAudioPlayer src={ep.audioUrl} title={ep.title} />
)}
</div>
</li>
))}
</ol>
)}
</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 }) }).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 BooksAndResourcesSection({ content }: { content: SiteContent }) {
const books = (content.customLinks ?? []).filter(link => link.placement === 'books')
const recommended = (content.customLinks ?? []).filter(link => link.placement === 'recommended')
const [activeTab, setActiveTab] = useState<'books' | 'recommended'>('books')
const hasBooks = books.length > 0
const hasRecommended = recommended.length > 0
const showTabs = hasBooks && hasRecommended
if (!hasBooks && !hasRecommended) return <TruthForLifeWidget type="resourcead" />
const tab = showTabs ? activeTab : (hasBooks ? 'books' : 'recommended')
return (
<section className="section-resources section-books-resources" aria-label="Books and recommended resources">
<div className="section-inner">
{showTabs ? (
<div className="books-resources-tabs">
<button
type="button"
className={`books-resources-tab${tab === 'books' ? ' books-resources-tab--active' : ''}`}
onClick={() => setActiveTab('books')}
>
Reading List
</button>
<button
type="button"
className={`books-resources-tab${tab === 'recommended' ? ' books-resources-tab--active' : ''}`}
onClick={() => setActiveTab('recommended')}
>
Recommended Resources
</button>
</div>
) : (
<div className="download-library-head">
<p className="eyebrow">{tab === 'books' ? 'Reading List' : 'Recommended'}</p>
<h2 className="section-heading">{tab === 'books' ? 'Books worth your time.' : 'Resources worth your time.'}</h2>
</div>
)}
{tab === 'books' && (
<>
{showTabs && (
<div className="download-library-head" style={{ marginBottom: '1.5rem' }}>
<h2 className="section-heading">Books worth your time.</h2>
</div>
)}
<div className="books-list">
{books.map(book => (
<div key={book.id} className="book-card">
{book.imageUrl && (
<img src={book.imageUrl} alt={book.label} className="book-card-cover" loading="lazy" />
)}
<div className="book-card-meta">
<span className="book-card-title">{book.label}</span>
{book.description && <p className="book-card-description">{book.description}</p>}
{(book.amazonUrl || book.url) && (
<a href={book.amazonUrl || book.url} target="_blank" rel="noopener noreferrer" className="btn-primary book-card-btn">
{book.amazonUrl ? (book.amazonLabel || 'View on Amazon') : (book.amazonLabel || 'Learn More')}
</a>
)}
{book.appleBooksUrl && (
<a href={book.appleBooksUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary book-card-btn">
{book.appleBooksLabel || 'Get it on Apple Books'}
</a>
)}
</div>
</div>
))}
</div>
</>
)}
{tab === 'recommended' && (
<>
{showTabs && (
<div className="download-library-head" style={{ marginBottom: '1.5rem' }}>
<h2 className="section-heading">Recommended Resources</h2>
</div>
)}
<div className="recommended-resources-list">
{recommended.map(item => (
<a key={item.id} href={item.url} target="_blank" rel="noopener noreferrer" className="recommended-resource-card">
{item.imageUrl && (
<img src={item.imageUrl} alt={item.label} className="recommended-resource-image" loading="lazy" />
)}
<div className="recommended-resource-meta">
<span className="recommended-resource-title">{item.label}</span>
{item.description && <p className="recommended-resource-description">{item.description}</p>}
<span className="recommended-resource-action">Learn more →</span>
</div>
</a>
))}
</div>
</>
)}
</div>
</section>
)
}
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." />
<DownloadLibrarySection content={content} />
<CustomBlocksSection content={content} page="downloads" />
<BooksAndResourcesSection content={content} />
<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 [verseTexts, setVerseTexts] = useState<Record<string, string>>({})
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])
useEffect(() => {
if (bookGroups.length === 0) return
// Group rows by chapter to minimize API calls
const chapterMap = new Map<string, Array<{ key: string; verseStart: number; verseEnd: number }>>()
for (const group of bookGroups) {
for (const row of group.rows) {
const ck = `${group.bookId}/${row.chapter}`
if (!chapterMap.has(ck)) chapterMap.set(ck, [])
// Chapter-spanning refs (e.g. "5:15-6:2") preview only the starting chapter's
// verses, since extractVerseRange operates on a single fetched chapter.
const verseEnd = row.endChapter !== row.chapter ? Number.MAX_SAFE_INTEGER : row.verseEnd
chapterMap.get(ck)!.push({ key: row.key, verseStart: row.verseStart, verseEnd })
}
}
for (const [ck, rows] of chapterMap) {
const [bookId, chapter] = ck.split('/')
fetch(`https://bible.helloao.org/api/BSB/${bookId}/${chapter}.json`)
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { chapter: { content: HellaoContent } }) => {
const chContent = data?.chapter?.content ?? []
const updates: Record<string, string> = {}
for (const r of rows) {
const text = extractVerseRange(chContent, r.verseStart, r.verseEnd)
if (text) updates[r.key] = text
}
setVerseTexts(prev => ({ ...prev, ...updates }))
})
.catch(() => {})
}
}, [bookGroups])
const hasContent = bookGroups.length > 0 || otherRows.length > 0
function renderRow(row: ScriptureRow) {
return (
<div key={row.key} className="scripture-entry-row">
<span className="scripture-entry-ref">{row.displayRef}</span>
<span className="scripture-entry-body">
{verseTexts[row.key] && <span className="scripture-entry-verse">"{verseTexts[row.key]}"</span>}
<span className="scripture-entry-sources">
{row.sources.map((s, i) => (
<Link key={i} to={s.url} className={`scripture-source-chip scripture-source-chip--${s.type}`}>
<span className="scripture-source-kind">{s.type === 'episode' ? 'Episode' : 'Study'}</span>
{s.label}
</Link>
))}
</span>
</span>
</div>
)
}
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="scripture-index-main page-width">
{loading ? (
<p className="scripture-index-note">Loading…</p>
) : !hasContent ? (
<p className="scripture-index-note">No Scripture references are published yet.</p>
) : (
<>
{bookGroups.map(group => (
<section key={group.bookId} className="scripture-study-group">
<h2 className="scripture-study-title">{group.bookName}</h2>
<div className="scripture-entry-table">
{group.rows.map(renderRow)}
</div>
</section>
))}
{otherRows.length > 0 && (
<section className="scripture-study-group">
<h2 className="scripture-study-title">Other References</h2>
<div className="scripture-entry-table">
{otherRows.map(renderRow)}
</div>
</section>
)}
</>
)}
</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} />
<KofiSupportSection content={content} />
<TestimonialsSection testimonials={content.testimonials ?? []} />
<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>
)
}
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.',
)
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<main className="thanks-page" aria-label="Start Here">
<div className="thanks-card" style={{ maxWidth: '980px', width: '100%' }}>
<p className="eyebrow">Verse by Verse with Nate</p>
<h1>{content.startHereHeading}</h1>
<p>
{content.startHereIntro}
</p>
<div className="start-grid" style={{ marginTop: '1.25rem' }}>
<article className="start-card">
<h3>{content.startHereStep1Title}</h3>
<p>{content.startHereStep1Body}</p>
<Link to="/episodes" className="btn-secondary">{content.startHereStep1Cta}</Link>
</article>
<article className="start-card">
<h3>{content.startHereStep2Title}</h3>
<p>{content.startHereStep2Body}</p>
<Link to="/questions" className="btn-secondary">{content.startHereStep2Cta}</Link>
</article>
<article className="start-card">
<h3>{content.startHereStep3Title}</h3>
<p>{content.startHereStep3Body}</p>
<Link to="/contact" className="btn-primary">{content.startHereStep3Cta}</Link>
</article>
</div>
<div style={{ marginTop: '1.4rem' }}>
<Link to="/" className="btn-secondary">Back to Site</Link>
</div>
</div>
</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>
)
}
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>
)
}
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 any).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/: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="/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>
)
}