Files
Siteforge/src/App.tsx
T
nmemmert 1e24eaa9af Replace Spotify show embed on /episodes with custom player; v1.1.6
LatestEpisodePlayer fetches the most recent episode from /api/episodes
(Anchor RSS) and renders it with the centered cinematic layout. The
Spotify show iframe is removed; platform buttons and HeadlinerWidget
remain below as before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 14:36:41 -04:00

2470 lines
93 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 } from 'react'
import type { ReactElement } from 'react'
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
import AdminPage from './AdminPage'
import QASection from './components/QASection'
import ContactForm from './components/ContactForm'
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage } from './colossiansStudy'
import { SpotifyIcon } from './icons'
import type { SiteContent, StudyProgram, Testimonial } from './content'
import { DEFAULTS } from './content'
import { usePageMeta } from './hooks/usePageMeta'
import { useGlobalSearch } from './hooks/useGlobalSearch'
import { GlobalSearch } from './components/GlobalSearch'
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
import './App.css'
import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useOutboundLinkTracking } from './analytics'
const CONSENT_KEY = 'vbn_analytics_consent_choice'
const HEADLINER_WIDGET_ID = 'WI_cmou3b4q7000701p0o9qmmcfj'
function ScrollToTop() {
const { pathname } = useLocation()
useEffect(() => { window.scrollTo(0, 0) }, [pathname])
return null
}
function usePageTracking() {
const location = useLocation()
useEffect(() => {
if (localStorage.getItem(CONSENT_KEY) !== 'accepted') return
const referrer = document.referrer || ''
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
fetch('/api/analytics/pageview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: location.pathname, referrer }),
keepalive: true,
signal: controller.signal,
}).catch(() => {}).finally(() => clearTimeout(timeout))
}, [location.pathname])
}
function toSpotifyEpisodeEmbedUrl(url: string | undefined): string {
if (!url) return ''
try {
const parsed = new URL(url)
const host = parsed.hostname.toLowerCase()
const parts = parsed.pathname.split('/').filter(Boolean)
if (host === 'open.spotify.com') {
if (parts[0] === 'embed' && parts[1] === 'episode' && parts[2]) {
return `https://open.spotify.com/embed/episode/${parts[2]}?utm_source=generator`
}
if (parts[0] === 'episode' && parts[1]) {
return `https://open.spotify.com/embed/episode/${parts[1]}?utm_source=generator`
}
}
} catch {
return ''
}
return ''
}
function isLikelySpotifyEpisodeUrl(url: string | undefined): boolean {
if (!url) return false
if (toSpotifyEpisodeEmbedUrl(url)) return true
try {
const parsed = new URL(url)
const host = parsed.hostname.toLowerCase()
const path = parsed.pathname.toLowerCase()
if (host === 'creators.spotify.com' && path.includes('/episodes/')) return true
if (host === 'anchor.fm' && path.includes('/episodes/')) return true
if (host === 'podcasters.spotify.com' && path.includes('/episodes/')) return true
} catch {
return false
}
return false
}
function getHomepageFeaturedStudy(content: SiteContent): StudyProgram | null {
const studies = Array.isArray(content.studies) ? content.studies : []
if (studies.length === 0) return null
return studies.find(study => study.showOnHomepage === true)
?? studies.find(study => study.status === 'active')
?? studies[0]
}
function HeadlinerWidget() {
const [iframeSrc, setIframeSrc] = useState('')
const [status, setStatus] = useState<'loading' | 'ready' | 'empty'>('loading')
useEffect(() => {
const getDiscoUrl = () => {
const canonicalHref = document.querySelector('link[rel="canonical"]')?.getAttribute('href')
const ogUrl = document.querySelector('meta[property="og:url"]')?.getAttribute('content')
const base = canonicalHref || ogUrl
if (!base) return window.location.href
try {
const parsed = new URL(base)
return `${parsed.origin}${window.location.pathname}`
} catch {
return window.location.href
}
}
const discoUrl = getDiscoUrl()
const discoTitle = document.querySelector('meta[property="og:title"]')?.getAttribute('content') || document.title
const src = `https://disco.headliner.link/d/web/widget.html?widgetId=${encodeURIComponent(HEADLINER_WIDGET_ID)}&url=${encodeURIComponent(discoUrl)}&title=${encodeURIComponent(discoTitle)}`
const sessionId = `SS_siteforge_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
const query = new URLSearchParams({
sessionId,
url: discoUrl,
widgetId: HEADLINER_WIDGET_ID,
})
fetch(`https://api.headliner.link/api/v1/widget/widget-request?${query.toString()}`)
.then(r => (r.ok ? r.json() : Promise.reject(new Error('widget request failed'))))
.then((data: { results?: unknown[] }) => {
if (Array.isArray(data.results) && data.results.length > 0) {
setIframeSrc(src)
setStatus('ready')
return
}
setStatus('empty')
})
.catch(() => {
// If API probing fails, still attempt iframe rendering.
setIframeSrc(src)
setStatus('ready')
})
}, [])
return (
status === 'empty' ? null : (
<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
}
function resolveDownloadPageResource(content: SiteContent, pageId: string | undefined): DownloadPageResource | null {
if (!pageId) return null
if (pageId.startsWith('custom--')) {
const customId = pageId.slice('custom--'.length)
const resource = (content.customLinks ?? []).find(link => link.id === customId && link.placement === 'resources')
if (!resource) return null
return {
id: pageId,
label: resource.label,
imageUrl: resource.imageUrl,
summary: resource.description || 'Complete the short form below and your download will start right away.',
tags: resource.tags ?? [],
buttonText: `Download ${resource.label}`,
resourceId: buildCustomResourceDownloadId(resource.id),
amazonUrl: resource.amazonUrl,
amazonLabel: resource.amazonLabel,
}
}
return null
}
function AnalyticsConsentBanner({ content }: { content: SiteContent }) {
const [choice, setChoice] = useState<'unknown' | 'accepted' | 'declined'>(() => {
const saved = localStorage.getItem(CONSENT_KEY)
if (saved === 'accepted' || saved === 'declined') return saved
return 'unknown'
})
async function sendChoice(consent: boolean) {
await fetch('/api/analytics-consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ consent }),
})
}
async function accept() {
setChoice('accepted')
localStorage.setItem(CONSENT_KEY, 'accepted')
try {
await sendChoice(true)
} catch {
// Keep local preference even if network fails.
}
}
async function decline() {
setChoice('declined')
localStorage.setItem(CONSENT_KEY, 'declined')
try {
await sendChoice(false)
} catch {
// Keep local preference even if network fails.
}
}
if (choice !== 'unknown') return null
return (
<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 dropdownRef = useRef<HTMLDivElement>(null)
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
const location = useLocation()
const episodesActive = location.pathname.startsWith('/episodes') || location.pathname.startsWith('/finished')
useEffect(() => {
function handleOutsideClick(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setEpisodesOpen(false)
}
}
if (episodesOpen) document.addEventListener('mousedown', handleOutsideClick)
return () => document.removeEventListener('mousedown', handleOutsideClick)
}, [episodesOpen])
return (
<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' : ''}`}>
<button
type="button"
className={`nav-dropdown-trigger${episodesActive ? ' header-nav-link--active' : ''}`}
onClick={() => setEpisodesOpen(o => !o)}
aria-expanded={episodesOpen}
>
Episodes <span className="nav-dropdown-arrow" aria-hidden="true">{episodesOpen ? '▴' : '▾'}</span>
</button>
{episodesOpen && (
<div className="nav-dropdown-menu">
<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>
</div>
)}
</div>
<NavLink to="/questions" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Q&amp;A</NavLink>
<NavLink to="/study" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Studies</NavLink>
<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)}>Downloads</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 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>
{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="/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 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">
<p className="eyebrow">{content.aboutShowEyebrow || 'About the Show'}</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 StudyGuideSection({ content }: { content: SiteContent }) {
return (
<section className="section-guide" aria-label="Companion study guide">
<div className="section-inner guide-inner">
<div className="guide-cover-art">
<img
src={content.seriesImageUrl || '/images/titus-cover.png'}
alt={content.seriesTitle}
className="guide-cover-img"
/>
</div>
<div className="guide-text">
<p className="eyebrow">Downloads</p>
<h1 className="guide-page-title">Study Guides and Downloads</h1>
<p className="guide-page-intro">Start with the primary guide below, then explore the rest of the download library further down the page.</p>
<p className="eyebrow">Free Download</p>
<h2>{content.studyGuideTitle}</h2>
<p>{content.studyGuideDescription}</p>
<StudyDownloadForm buttonText={`Download ${content.studyGuideTitle || 'Guide'}`} />
{content.studyGuideUrl && (
<div className="guide-actions">
<a
href={content.studyGuideUrl}
target="_blank"
rel="noreferrer"
className="btn-secondary"
>
{content.studyGuideAmazonButtonLabel || 'Get it on Amazon'}
</a>
</div>
)}
</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">
<p className="eyebrow">{content.contactEyebrow || 'Get in Touch'}</p>
<h2>{content.contactHeading || 'Contact Nate'}</h2>
<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
)
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 DownloadLibrarySection({ content }: { content: SiteContent }) {
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
const [activeTag, setActiveTag] = useState<string | null>(null)
if (resources.length === 0) return null
const allTags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean)
const filteredResources = resources.filter(r => {
return !activeTag || (r.tags ?? []).includes(activeTag)
})
return (
<section className="section-resources section-download-library" aria-label="Download library">
<div className="section-inner">
<div className="download-library-head">
<p className="eyebrow">Download Library</p>
<h2 className="section-heading">Guides, worksheets, and study downloads.</h2>
</div>
{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="download-library-group">
<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>
</div>
) : (
<p className="download-empty-state">No downloads tagged "{activeTag}".</p>
)}
</div>
</section>
)
}
function DownloadDetailPage({ content }: { content: SiteContent }) {
const { id } = useParams<{ id: string }>()
const resource = resolveDownloadPageResource(content, id)
if (!resource) {
return (
<main className="thanks-page" aria-label="Download not found">
<div className="thanks-card">
<p className="eyebrow">Downloads</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 Downloads</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">Downloads</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">
<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>
)}
<Link to="/resources" className="btn-secondary">Back to Downloads</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 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>{block.body}</p>
</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="/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 Downloads'}</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('')
const [audioUrl, setAudioUrl] = useState('')
// Fetch the direct MP3 URL from the RSS-backed episodes list, matching by title
useEffect(() => {
if (!episode) return
fetch('/api/episodes')
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { episodes?: { title: string; audioUrl: string }[] }) => {
const match = (data.episodes ?? []).find(e =>
e.title?.trim().toLowerCase() === episode.title?.trim().toLowerCase()
)
if (match?.audioUrl) setAudioUrl(match.audioUrl)
})
.catch(() => {})
}, [episode])
useEffect(() => {
let cancelled = false
if (!episode) {
setResolvedEmbedUrl('')
return () => {
cancelled = true
}
}
const manualEmbed = episode.embedUrl?.trim() ?? ''
if (manualEmbed) {
setResolvedEmbedUrl(manualEmbed)
return () => {
cancelled = true
}
}
if (!episode.url) {
setResolvedEmbedUrl('')
return () => {
cancelled = true
}
}
try {
const urlObj = new URL(episode.url)
const host = urlObj.hostname.toLowerCase()
if (host === 'open.spotify.com') {
const fromUrl = toSpotifyEpisodeEmbedUrl(episode.url)
if (fromUrl) {
setResolvedEmbedUrl(fromUrl)
return () => {
cancelled = true
}
}
}
} catch {
// ignore
}
if (!isLikelySpotifyEpisodeUrl(episode.url)) {
setResolvedEmbedUrl('')
return () => {
cancelled = true
}
}
setResolvedEmbedUrl('')
fetch(`/api/spotify/embed-url?url=${encodeURIComponent(episode.url)}`)
.then(r => (r.ok ? r.json() : Promise.reject(new Error('embed resolve failed'))))
.then((data: { embedUrl?: string }) => {
if (cancelled) return
setResolvedEmbedUrl(data.embedUrl?.trim() ?? '')
})
.catch(() => {
if (cancelled) return
setResolvedEmbedUrl('')
})
return () => {
cancelled = true
}
}, [episode])
if (!episode) {
return (
<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 || audioUrl) && (
<div className="episode-detail-embed">
{audioUrl ? (
<EpisodeAudioPlayer src={audioUrl} title={episode.title} size="full" spotifyUrl={episode.url || content.platformSpotifyUrl} />
) : 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.showNotes && (
<div className="episode-detail-show-notes">
<h2 className="episode-detail-section-heading">Show Notes</h2>
<p>{episode.showNotes}</p>
</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} />
<section className="section-finished-books">
<div className="section-inner">
<div className="finished-books-header">
<h1 className="section-heading">
<span className="ornament"></span> Finished Books <span className="ornament"></span>
</h1>
<p className="finished-books-intro">
Every series we've completed — with a full ordered episode playlist for each book of the Bible.
</p>
</div>
{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 && <img src={book.imageUrl} alt={book.title} className="finished-book-card-img" />}
<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 && <img src={book.imageUrl} alt={book.title} className="finished-series-hero-img" />}
<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>
</div>
<h2 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 [audioUrl, setAudioUrl] = useState('')
const [title, setTitle] = useState('')
useEffect(() => {
fetch('/api/episodes')
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { episodes?: { title: string; audioUrl: string }[] }) => {
const latest = data.episodes?.[0]
if (latest?.audioUrl) {
setAudioUrl(latest.audioUrl)
setTitle(latest.title)
}
})
.catch(() => {})
}, [])
if (!audioUrl) return <div className="episode-embed-skeleton" aria-label="Loading player…" style={{ height: 152 }} />
return <EpisodeAudioPlayer src={audioUrl} title={title} size="full" spotifyUrl={content.platformSpotifyUrl} />
}
function EpisodesPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Episodes | ${siteTitle}`,
content.episodesSeoIntro || 'Browse all episodes of Verse by Verse with Nate expository Bible teaching, one verse at a time.',
)
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<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>
)
}
function ResourcesPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Resources & Downloads | ${siteTitle}`,
'Study guides, sermon notes, and free resources from Verse by Verse with Nate.',
)
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<StudyGuideSection content={content} />
<DownloadLibrarySection content={content} />
<CustomBlocksSection content={content} page="downloads" />
<TruthForLifeWidget type="resourcead" />
<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} />
<AboutSection content={content} />
<TestimonialsSection testimonials={content.testimonials ?? []} />
<CustomBlocksSection content={content} page="about" />
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
function ContactPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<ContactSection content={content} />
<CustomBlocksSection content={content} page="contact" />
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
function StartHerePage({ content }: { content: SiteContent }) {
return (
<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>
<a href="/#qa" className="btn-secondary">{content.startHereStep2Cta}</a>
</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>
)
}
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} />
<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()
useOutboundLinkTracking()
useEffect(() => {
const TARGET = 'salvation'
let buffer = ''
function onKeyDown(e: KeyboardEvent) {
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return
buffer = (buffer + e.key).slice(-TARGET.length)
if (buffer === TARGET) {
buffer = ''
window.location.assign('/salvation')
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [])
useEffect(() => {
fetch('/api/admin-content')
.then(r => (r.ok ? r.json() : null))
.then(data => {
if (data?.siteContent) {
setContent(c => ({ ...c, ...data.siteContent }))
}
})
.catch(() => {})
}, [])
useEffect(() => {
if (location.pathname !== '/' || !location.hash) return
const legacyHashRouteMap: { [key: string]: string } = {
'#listen': '/episodes',
'#about': '/about',
'#qa': '/questions',
'#contact': '/contact',
'#series': '/resources',
'#resources': '/resources',
'#start-here': '/start-here',
}
const redirectPath = legacyHashRouteMap[location.hash.toLowerCase()]
if (!redirectPath) return
navigate(redirectPath, { replace: true })
}, [location.hash, location.pathname, navigate])
useEffect(() => {
const seo = content.seo ?? DEFAULTS.seo
const ensureMeta = (selector: string, createAttrs: { [key: string]: string }) => {
const found = document.head.querySelector(selector)
if (found) return found as HTMLMetaElement
const meta = document.createElement('meta')
Object.entries(createAttrs).forEach(([key, value]) => {
meta.setAttribute(key, value)
})
document.head.appendChild(meta)
return meta
}
const ensureCanonical = () => {
const found = document.head.querySelector('link[rel="canonical"]')
if (found) return found as HTMLLinkElement
const link = document.createElement('link')
link.setAttribute('rel', 'canonical')
document.head.appendChild(link)
return link
}
document.title = seo.title || DEFAULTS.seo.title
ensureMeta('meta[name="description"]', { name: 'description' }).setAttribute('content', seo.description || DEFAULTS.seo.description)
ensureMeta('meta[name="robots"]', { name: 'robots' }).setAttribute('content', seo.robotsPolicy || DEFAULTS.seo.robotsPolicy)
ensureMeta('meta[property="og:title"]', { property: 'og:title' }).setAttribute('content', seo.ogTitle || seo.title || DEFAULTS.seo.ogTitle)
ensureMeta('meta[property="og:description"]', { property: 'og:description' }).setAttribute('content', seo.ogDescription || seo.description || DEFAULTS.seo.ogDescription)
ensureMeta('meta[property="og:image"]', { property: 'og:image' }).setAttribute('content', seo.ogImage || DEFAULTS.seo.ogImage)
ensureMeta('meta[property="og:url"]', { property: 'og:url' }).setAttribute('content', seo.canonicalUrl || DEFAULTS.seo.canonicalUrl)
ensureCanonical().setAttribute('href', seo.canonicalUrl || DEFAULTS.seo.canonicalUrl)
}, [content])
return (
<>
<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="/studys" element={<StudyRouteFrame content={content} child={<StudyLandingPage content={content} />} />} />
<Route path="/study/signup" element={<StudyRouteFrame content={content} child={<StudySignupPage />} />} />
<Route path="/study/account" element={<StudyRouteFrame content={content} child={<StudyAccountPage />} />} />
<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="/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="/thanks" element={<ThankYouPage />} />
<Route path="/subscribe" element={<SubscribePage />} />
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
<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()
useEffect(() => {
sendEvent('not_found', { path: location.pathname })
}, [location.pathname])
return (
<main style={{ padding: '4rem 2rem', textAlign: 'center' }}>
<h1>Page not found</h1>
<p>The page <code>{location.pathname}</code> doesn't exist.</p>
<Link to="/" style={{ marginTop: '1rem', display: 'inline-block' }}> Back to home</Link>
</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>
)
}