Publish Container / build (push) Successful in 15m16s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
3682 lines
151 KiB
TypeScript
3682 lines
151 KiB
TypeScript
import { useState, useEffect, useRef, useMemo } from 'react'
|
||
import type { ReactElement } from 'react'
|
||
import { Link, NavLink, Navigate, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||
import AdminPage from './AdminPage'
|
||
import QASection from './components/QASection'
|
||
import ContactForm from './components/ContactForm'
|
||
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyDiscussionHubPage, StudyQuizPage, StudyResetPasswordPage, StudyVerifyEmailPage } from './studyPages'
|
||
import { SpotifyIcon } from './icons'
|
||
import type { SiteContent, StudyProgram, Testimonial, PodcastFeaturedLink } from './content'
|
||
import { DEFAULTS, getAllHighlights } from './content'
|
||
import { usePageMeta } from './hooks/usePageMeta'
|
||
import { useGlobalSearch } from './hooks/useGlobalSearch'
|
||
import { GlobalSearch } from './components/GlobalSearch'
|
||
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
|
||
import { NuggetsHostPortal } from './components/NuggetsHostPortal'
|
||
import { HeaderAccount } from './components/HeaderAccount'
|
||
import { ResourcesHub } from './components/ResourcesHub'
|
||
import { StoreLink } from './components/StoreLink'
|
||
import { BIBLE_BOOKS } from '../shared/bible-books.js'
|
||
import './App.css'
|
||
import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useLinkClickTracking, checkAnalyticsConsentResponse } from './analytics'
|
||
|
||
const CONSENT_KEY = 'vbn_analytics_consent_choice'
|
||
const HEADLINER_WIDGET_ID = 'WI_cmou3b4q7000701p0o9qmmcfj'
|
||
|
||
function ScrollToTop() {
|
||
const { pathname } = useLocation()
|
||
useEffect(() => { window.scrollTo(0, 0) }, [pathname])
|
||
return null
|
||
}
|
||
|
||
function usePageTracking() {
|
||
const location = useLocation()
|
||
const isFirstRun = useRef(true)
|
||
useEffect(() => {
|
||
// The initial hard page load is already counted server-side by the
|
||
// hit-counting middleware in server.js (it sees the raw HTML request).
|
||
// Only client-side route changes (no new HTTP request) need reporting
|
||
// here — otherwise every fresh visit/refresh gets double-counted.
|
||
if (isFirstRun.current) {
|
||
isFirstRun.current = false
|
||
return
|
||
}
|
||
if (localStorage.getItem(CONSENT_KEY) !== 'accepted') return
|
||
const referrer = document.referrer || ''
|
||
const controller = new AbortController()
|
||
const timeout = setTimeout(() => controller.abort(), 5000)
|
||
fetch('/api/analytics/pageview', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: location.pathname, referrer }),
|
||
keepalive: true,
|
||
signal: controller.signal,
|
||
}).then(checkAnalyticsConsentResponse).catch(() => {}).finally(() => clearTimeout(timeout))
|
||
}, [location.pathname])
|
||
}
|
||
|
||
function toSpotifyEpisodeEmbedUrl(url: string | undefined): string {
|
||
if (!url) return ''
|
||
|
||
try {
|
||
const parsed = new URL(url)
|
||
const host = parsed.hostname.toLowerCase()
|
||
const parts = parsed.pathname.split('/').filter(Boolean)
|
||
|
||
if (host === 'open.spotify.com') {
|
||
if (parts[0] === 'embed' && parts[1] === 'episode' && parts[2]) {
|
||
return `https://open.spotify.com/embed/episode/${parts[2]}?utm_source=generator`
|
||
}
|
||
if (parts[0] === 'episode' && parts[1]) {
|
||
return `https://open.spotify.com/embed/episode/${parts[1]}?utm_source=generator`
|
||
}
|
||
}
|
||
} catch {
|
||
return ''
|
||
}
|
||
|
||
return ''
|
||
}
|
||
|
||
function isLikelySpotifyEpisodeUrl(url: string | undefined): boolean {
|
||
if (!url) return false
|
||
if (toSpotifyEpisodeEmbedUrl(url)) return true
|
||
|
||
try {
|
||
const parsed = new URL(url)
|
||
const host = parsed.hostname.toLowerCase()
|
||
const path = parsed.pathname.toLowerCase()
|
||
|
||
if (host === 'creators.spotify.com' && path.includes('/episodes/')) return true
|
||
if (host === 'anchor.fm' && path.includes('/episodes/')) return true
|
||
if (host === 'podcasters.spotify.com' && path.includes('/episodes/')) return true
|
||
} catch {
|
||
return false
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
function getHomepageFeaturedStudy(content: SiteContent): StudyProgram | null {
|
||
const studies = Array.isArray(content.studies) ? content.studies : []
|
||
if (studies.length === 0) return null
|
||
return studies.find(study => study.showOnHomepage === true)
|
||
?? studies.find(study => study.status === 'active')
|
||
?? studies[0]
|
||
}
|
||
|
||
function HeadlinerWidget() {
|
||
const [iframeSrc, setIframeSrc] = useState('')
|
||
const [status, setStatus] = useState<'loading' | 'ready' | 'empty'>('loading')
|
||
|
||
useEffect(() => {
|
||
const getDiscoUrl = () => {
|
||
const canonicalHref = document.querySelector('link[rel="canonical"]')?.getAttribute('href')
|
||
const ogUrl = document.querySelector('meta[property="og:url"]')?.getAttribute('content')
|
||
const base = canonicalHref || ogUrl
|
||
if (!base) return window.location.href
|
||
try {
|
||
const parsed = new URL(base)
|
||
return `${parsed.origin}${window.location.pathname}`
|
||
} catch {
|
||
return window.location.href
|
||
}
|
||
}
|
||
|
||
const discoUrl = getDiscoUrl()
|
||
const discoTitle = document.querySelector('meta[property="og:title"]')?.getAttribute('content') || document.title
|
||
const src = `https://disco.headliner.link/d/web/widget.html?widgetId=${encodeURIComponent(HEADLINER_WIDGET_ID)}&url=${encodeURIComponent(discoUrl)}&title=${encodeURIComponent(discoTitle)}`
|
||
|
||
const sessionId = `SS_siteforge_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
|
||
const query = new URLSearchParams({
|
||
sessionId,
|
||
url: discoUrl,
|
||
widgetId: HEADLINER_WIDGET_ID,
|
||
})
|
||
|
||
fetch(`https://api.headliner.link/api/v1/widget/widget-request?${query.toString()}`)
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('widget request failed'))))
|
||
.then((data: { results?: unknown[] }) => {
|
||
if (Array.isArray(data.results) && data.results.length > 0) {
|
||
setIframeSrc(src)
|
||
setStatus('ready')
|
||
return
|
||
}
|
||
setStatus('empty')
|
||
})
|
||
.catch(() => {
|
||
// If API probing fails, still attempt iframe rendering.
|
||
setIframeSrc(src)
|
||
setStatus('ready')
|
||
})
|
||
}, [])
|
||
|
||
return (
|
||
status === 'empty' ? null : (
|
||
<div className="headliner-widget-wrap" aria-label="Featured listening widget">
|
||
{status === 'ready' && iframeSrc && (
|
||
<iframe
|
||
src={iframeSrc}
|
||
title="Featured listening widget"
|
||
loading="lazy"
|
||
className="headliner-widget-frame"
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
)
|
||
}
|
||
|
||
function DownloadForm({ endpoint, extraBody, buttonText }: { endpoint: string; extraBody?: Record<string, string>; buttonText: string }) {
|
||
const [fields, setFields] = useState({ firstName: '', lastName: '', email: '' })
|
||
const [subscribe, setSubscribe] = useState(true)
|
||
const [honey, setHoney] = useState('')
|
||
const [status, setStatus] = useState<'idle' | 'submitting' | 'error' | 'success'>('idle')
|
||
const [errorMsg, setErrorMsg] = useState('')
|
||
const [downloadUrl, setDownloadUrl] = useState('')
|
||
|
||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
|
||
}
|
||
|
||
async function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
setStatus('submitting')
|
||
setErrorMsg('')
|
||
setDownloadUrl('')
|
||
|
||
try {
|
||
const res = await fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ...extraBody, ...fields, subscribe, _honey: honey }),
|
||
})
|
||
|
||
const data = await res.json().catch(() => ({})) as { message?: string; downloadUrl?: string }
|
||
if (!res.ok || !data.downloadUrl) {
|
||
setErrorMsg(data.message ?? 'Could not process your request. Please try again.')
|
||
setStatus('error')
|
||
return
|
||
}
|
||
|
||
setStatus('success')
|
||
setDownloadUrl(data.downloadUrl)
|
||
window.location.assign(data.downloadUrl)
|
||
} catch {
|
||
setErrorMsg('Could not connect. Please try again later.')
|
||
setStatus('error')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<form className="study-download-form" onSubmit={handleSubmit} noValidate>
|
||
<input
|
||
type="text"
|
||
className="contact-honeypot"
|
||
tabIndex={-1}
|
||
autoComplete="off"
|
||
aria-hidden="true"
|
||
value={honey}
|
||
onChange={e => setHoney(e.target.value)}
|
||
/>
|
||
<div className="study-download-grid">
|
||
<label>
|
||
First Name
|
||
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} />
|
||
</label>
|
||
<label>
|
||
Last Name
|
||
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} />
|
||
</label>
|
||
<label>
|
||
Email
|
||
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
|
||
</label>
|
||
</div>
|
||
<label className="contact-consent">
|
||
<input
|
||
type="checkbox"
|
||
checked={subscribe}
|
||
onChange={e => setSubscribe(e.target.checked)}
|
||
/>
|
||
<span>Subscribe me to updates from Verse by Verse with Nate.</span>
|
||
</label>
|
||
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
|
||
{status === 'success' && (
|
||
<p className="study-download-success">
|
||
Your download should start now.{' '}
|
||
{downloadUrl && <a href={downloadUrl}>Click here if it does not start automatically.</a>}
|
||
</p>
|
||
)}
|
||
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
|
||
{status === 'submitting' ? 'Preparing Download...' : buttonText}
|
||
</button>
|
||
</form>
|
||
)
|
||
}
|
||
|
||
function StudyDownloadForm({ buttonText = 'Download Guide' }: { buttonText?: string }) {
|
||
return <DownloadForm endpoint="/api/study-downloads/titus" buttonText={buttonText} />
|
||
}
|
||
|
||
function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string; buttonText: string }) {
|
||
return <DownloadForm endpoint="/api/resource-download" extraBody={{ resourceId }} buttonText={buttonText} />
|
||
}
|
||
|
||
function buildCustomResourceDownloadId(id: string) {
|
||
return `custom:${id}`
|
||
}
|
||
|
||
function buildCustomDownloadPageId(id: string) {
|
||
return `custom--${id}`
|
||
}
|
||
|
||
interface DownloadPageResource {
|
||
id: string
|
||
label: string
|
||
imageUrl?: string
|
||
summary: string
|
||
tags: string[]
|
||
buttonText: string
|
||
resourceId: string
|
||
amazonUrl?: string
|
||
amazonLabel?: string
|
||
appleBooksUrl?: string
|
||
appleBooksLabel?: string
|
||
isPrimaryGuide?: boolean
|
||
}
|
||
|
||
function resolveDownloadPageResource(content: SiteContent, pageId: string | undefined): DownloadPageResource | null {
|
||
if (!pageId) return null
|
||
|
||
if (pageId === 'primary-guide') {
|
||
return {
|
||
id: 'primary-guide',
|
||
label: content.studyGuideTitle || 'Companion Study Guide',
|
||
imageUrl: content.seriesImageUrl || '/images/titus-cover.png',
|
||
summary: content.studyGuideDescription || 'Complete the short form below and your download will start right away.',
|
||
tags: [],
|
||
buttonText: `Download ${content.studyGuideTitle || 'Guide'}`,
|
||
resourceId: 'primary-guide',
|
||
amazonUrl: content.studyGuideUrl,
|
||
amazonLabel: content.studyGuideAmazonButtonLabel,
|
||
appleBooksUrl: content.studyGuideAppleBooksUrl,
|
||
appleBooksLabel: content.studyGuideAppleBooksLabel,
|
||
isPrimaryGuide: true,
|
||
}
|
||
}
|
||
|
||
// Companion guide of the second series, or one kept on a finished book
|
||
const guideSource = pageId === 'second-guide'
|
||
? { guide: content.secondSeries?.guide, imageUrl: content.secondSeries?.imageUrl, resourceId: 'guide:second' }
|
||
: pageId.startsWith('book-guide--')
|
||
? (() => {
|
||
const book = (content.finishedBooks ?? []).find(item => item.id === pageId.slice('book-guide--'.length))
|
||
return { guide: book?.guide, imageUrl: book?.imageUrl, resourceId: `guide:book:${book?.id ?? ''}` }
|
||
})()
|
||
: null
|
||
if (guideSource) {
|
||
const guide = guideSource.guide
|
||
if (!guide?.title) return null
|
||
return {
|
||
id: pageId,
|
||
label: guide.title,
|
||
imageUrl: guideSource.imageUrl,
|
||
summary: guide.description || 'Complete the short form below and your download will start right away.',
|
||
tags: [],
|
||
buttonText: `Download ${guide.title}`,
|
||
resourceId: guideSource.resourceId,
|
||
amazonUrl: guide.amazonUrl,
|
||
amazonLabel: guide.amazonLabel,
|
||
appleBooksUrl: guide.appleBooksUrl,
|
||
appleBooksLabel: guide.appleBooksLabel,
|
||
}
|
||
}
|
||
|
||
if (pageId.startsWith('custom--')) {
|
||
const customId = pageId.slice('custom--'.length)
|
||
const resource = (content.customLinks ?? []).find(link => link.id === customId && link.placement === 'resources')
|
||
if (!resource) return null
|
||
|
||
return {
|
||
id: pageId,
|
||
label: resource.label,
|
||
imageUrl: resource.imageUrl,
|
||
summary: resource.description || 'Complete the short form below and your download will start right away.',
|
||
tags: resource.tags ?? [],
|
||
buttonText: `Download ${resource.label}`,
|
||
resourceId: buildCustomResourceDownloadId(resource.id),
|
||
amazonUrl: resource.amazonUrl,
|
||
amazonLabel: resource.amazonLabel,
|
||
appleBooksUrl: resource.appleBooksUrl,
|
||
appleBooksLabel: resource.appleBooksLabel,
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
function AnalyticsConsentBanner({ content }: { content: SiteContent }) {
|
||
const [choice, setChoice] = useState<'unknown' | 'accepted' | 'declined'>(() => {
|
||
const saved = localStorage.getItem(CONSENT_KEY)
|
||
if (saved === 'accepted' || saved === 'declined') return saved
|
||
return 'unknown'
|
||
})
|
||
|
||
async function sendChoice(consent: boolean) {
|
||
await fetch('/api/analytics-consent', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ consent }),
|
||
})
|
||
}
|
||
|
||
async function accept() {
|
||
setChoice('accepted')
|
||
localStorage.setItem(CONSENT_KEY, 'accepted')
|
||
try {
|
||
await sendChoice(true)
|
||
} catch {
|
||
// Keep local preference even if network fails.
|
||
}
|
||
}
|
||
|
||
async function decline() {
|
||
setChoice('declined')
|
||
localStorage.setItem(CONSENT_KEY, 'declined')
|
||
try {
|
||
await sendChoice(false)
|
||
} catch {
|
||
// Keep local preference even if network fails.
|
||
}
|
||
}
|
||
|
||
if (choice !== 'unknown') return null
|
||
|
||
return (
|
||
<div className="consent-banner" role="region" aria-label="Analytics consent">
|
||
<p>{content.cookieBannerText || 'We use optional analytics cookies to measure visits and location trends for site improvement.'}</p>
|
||
<div className="consent-actions">
|
||
<button type="button" className="btn-primary" onClick={accept}>Accept</button>
|
||
<button type="button" className="btn-secondary" onClick={decline}>Decline</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SiteSearchBar({ content }: { content: SiteContent }) {
|
||
const { query, setQuery, results } = useGlobalSearch(content)
|
||
return (
|
||
<div className="site-search-bar">
|
||
<div className="site-search-bar-inner">
|
||
<GlobalSearch query={query} setQuery={setQuery} results={results} />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SiteHeader({ content }: { content: SiteContent }) {
|
||
const [menuOpen, setMenuOpen] = useState(false)
|
||
const [episodesOpen, setEpisodesOpen] = useState(false)
|
||
const [studiesOpen, setStudiesOpen] = useState(false)
|
||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||
const studiesDropdownRef = useRef<HTMLDivElement>(null)
|
||
const headerRef = useRef<HTMLElement>(null)
|
||
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
|
||
const location = useLocation()
|
||
const episodesActive = location.pathname.startsWith('/episodes') || location.pathname.startsWith('/finished') || location.pathname.startsWith('/nuggets')
|
||
const studiesActive = location.pathname.startsWith('/study') || location.pathname === '/scripture'
|
||
|
||
// The pinned search bar sits just under the header, whatever height the header is at this screen size
|
||
useEffect(() => {
|
||
const header = headerRef.current
|
||
if (!header) return
|
||
const update = () => document.documentElement.style.setProperty('--site-header-h', `${header.getBoundingClientRect().height}px`)
|
||
update()
|
||
const observer = new ResizeObserver(update)
|
||
observer.observe(header)
|
||
return () => observer.disconnect()
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
function handleOutsideClick(e: MouseEvent) {
|
||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||
setEpisodesOpen(false)
|
||
}
|
||
if (studiesDropdownRef.current && !studiesDropdownRef.current.contains(e.target as Node)) {
|
||
setStudiesOpen(false)
|
||
}
|
||
}
|
||
if (episodesOpen || studiesOpen) document.addEventListener('mousedown', handleOutsideClick)
|
||
return () => document.removeEventListener('mousedown', handleOutsideClick)
|
||
}, [episodesOpen, studiesOpen])
|
||
|
||
return (
|
||
<header className="site-header" ref={headerRef}>
|
||
<div className="header-inner">
|
||
<Link to="/" className="header-logo" aria-label="Home">
|
||
<img src="/book_icon.png" alt="" className="header-logo-img" />
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
className="header-menu-btn"
|
||
aria-expanded={menuOpen}
|
||
aria-controls="site-nav"
|
||
onClick={() => setMenuOpen(open => !open)}
|
||
>
|
||
{menuOpen ? 'Close' : 'Menu'}
|
||
</button>
|
||
<nav id="site-nav" className={`header-nav ${menuOpen ? 'header-nav--open' : ''}`}>
|
||
{/* Episodes dropdown */}
|
||
<div ref={dropdownRef} className={`nav-dropdown${episodesOpen ? ' nav-dropdown--open' : ''}`}>
|
||
<div className="nav-dropdown-trigger">
|
||
<NavLink to="/episodes" className={`nav-dropdown-label${episodesActive ? ' header-nav-link--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Episodes</NavLink>
|
||
<button type="button" className="nav-dropdown-caret" onClick={() => setEpisodesOpen(o => !o)} aria-expanded={episodesOpen} aria-label="Episodes menu">
|
||
<span aria-hidden="true">{episodesOpen ? '▴' : '▾'}</span>
|
||
</button>
|
||
</div>
|
||
<div className="nav-dropdown-menu">
|
||
<div className="nav-dropdown-menu-inner">
|
||
<NavLink to="/episodes" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Current Series</NavLink>
|
||
<NavLink to="/finished" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Finished Books</NavLink>
|
||
<NavLink to="/nuggets" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Nuggets</NavLink>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<NavLink to="/questions" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Q&A</NavLink>
|
||
{/* Studies dropdown */}
|
||
<div ref={studiesDropdownRef} className={`nav-dropdown${studiesOpen ? ' nav-dropdown--open' : ''}`}>
|
||
<div className="nav-dropdown-trigger">
|
||
<NavLink to="/study" className={`nav-dropdown-label${studiesActive ? ' header-nav-link--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Studies</NavLink>
|
||
<button type="button" className="nav-dropdown-caret" onClick={() => setStudiesOpen(o => !o)} aria-expanded={studiesOpen} aria-label="Studies menu">
|
||
<span aria-hidden="true">{studiesOpen ? '▴' : '▾'}</span>
|
||
</button>
|
||
</div>
|
||
<div className="nav-dropdown-menu">
|
||
<div className="nav-dropdown-menu-inner">
|
||
<NavLink to="/study" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>All Studies</NavLink>
|
||
<NavLink to="/study/discussion" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Discussion</NavLink>
|
||
<NavLink to="/scripture" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Scripture Index</NavLink>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<NavLink to="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Resources</NavLink>
|
||
<NavLink to="/about" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>About</NavLink>
|
||
<NavLink to="/contact" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Contact</NavLink>
|
||
<a
|
||
href={spotifyUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="header-cta"
|
||
>
|
||
<SpotifyIcon />
|
||
{content.headerFollowLabel || 'Follow on Spotify'}
|
||
</a>
|
||
</nav>
|
||
<HeaderAccount />
|
||
</div>
|
||
</header>
|
||
)
|
||
}
|
||
|
||
function SiteFooter({ content }: { content: SiteContent }) {
|
||
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
|
||
const appleUrl = content.platformAppleUrl || '/apple'
|
||
const youtubeUrl = content.platformYoutubeUrl || 'https://www.youtube.com/@blackzebraem5558'
|
||
const amazonUrl = content.platformAmazonUrl || '/amazon'
|
||
const creatorUrl = content.platformCreatorUrl || 'https://creators.spotify.com/pod/profile/nmemmert/'
|
||
const facebookUrl = content.platformFacebookUrl || 'https://facebook.com/versebyversewithnate'
|
||
const kofiUrl = content.platformKofiUrl
|
||
const extraFooterLinks = (content.customLinks ?? []).filter(
|
||
l => l.placement === 'footer' || l.placement === 'platforms' || l.placement === 'otherSites',
|
||
)
|
||
|
||
return (
|
||
<footer className="site-footer">
|
||
<p className="footer-ornament">✦ ✦ ✦</p>
|
||
<p className="footer-title">{content.footerTitle || 'Verse by Verse with Nate'}</p>
|
||
<p className="footer-sub">{content.footerSubtitle || 'A Journey Through Scripture'}</p>
|
||
{content.footerEmail && (
|
||
<p className="footer-contact">
|
||
Contact:{' '}
|
||
<a href={`mailto:${content.footerEmail}`}>{content.footerEmail}</a>
|
||
</p>
|
||
)}
|
||
<nav className="footer-links" aria-label="Footer links">
|
||
<a href={spotifyUrl} target="_blank" rel="noreferrer">Spotify</a>
|
||
<span aria-hidden="true">·</span>
|
||
<a href={appleUrl} target="_blank" rel="noreferrer">Apple Podcasts</a>
|
||
<span aria-hidden="true">·</span>
|
||
<a href={youtubeUrl} target="_blank" rel="noreferrer">YouTube</a>
|
||
<span aria-hidden="true">·</span>
|
||
<a href={amazonUrl} target="_blank" rel="noreferrer">Amazon Music</a>
|
||
<span aria-hidden="true">·</span>
|
||
<a href={creatorUrl} target="_blank" rel="noreferrer">Creator Profile</a>
|
||
<span aria-hidden="true">·</span>
|
||
<a href={facebookUrl} target="_blank" rel="noreferrer">Facebook</a>
|
||
{kofiUrl && (
|
||
<>
|
||
<span aria-hidden="true">·</span>
|
||
<a href={kofiUrl} target="_blank" rel="noreferrer">Support on Ko-fi</a>
|
||
</>
|
||
)}
|
||
{extraFooterLinks.length > 0 && (
|
||
<>
|
||
<span aria-hidden="true">·</span>
|
||
<details className="footer-more-links">
|
||
<summary>More Links</summary>
|
||
<div className="footer-more-links-menu">
|
||
{extraFooterLinks.map(link => (
|
||
<a key={link.id} href={link.url} target="_blank" rel="noreferrer">{link.label}</a>
|
||
))}
|
||
</div>
|
||
</details>
|
||
</>
|
||
)}
|
||
</nav>
|
||
<nav className="footer-links footer-links--legal" aria-label="Legal links">
|
||
<Link to="/finished">Finished Books</Link>
|
||
<span aria-hidden="true">·</span>
|
||
<Link to="/subscribe">Newsletter</Link>
|
||
<span aria-hidden="true">·</span>
|
||
<Link to="/privacy">Privacy Policy</Link>
|
||
<span aria-hidden="true">·</span>
|
||
<Link to="/terms">Terms</Link>
|
||
</nav>
|
||
<p className="footer-copy">{content.footerCopyright || '© 2026 Nate Emmert · Made with faith.'}</p>
|
||
{content.footerPrivacyNote && (
|
||
<p className="footer-privacy">{content.footerPrivacyNote}</p>
|
||
)}
|
||
</footer>
|
||
)
|
||
}
|
||
|
||
function PlatformButtons({ content }: { content: SiteContent }) {
|
||
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
|
||
const youtubeUrl = content.platformYoutubeUrl || 'https://www.youtube.com/@blackzebraem5558'
|
||
const amazonUrl = content.platformAmazonUrl || '/amazon'
|
||
const facebookUrl = content.platformFacebookUrl || 'https://facebook.com/versebyversewithnate'
|
||
const appleUrl = content.platformAppleUrl || '/apple'
|
||
|
||
return (
|
||
<div className="platform-buttons">
|
||
<a href={appleUrl} target="_blank" rel="noreferrer" className="platform-btn platform-btn-image">
|
||
<img src="/images/apple-podcasts-badge.svg" alt="Listen on Apple Podcasts" className="platform-badge" />
|
||
</a>
|
||
<a href={spotifyUrl} target="_blank" rel="noreferrer" className="platform-btn platform-btn-image">
|
||
<img src="/images/listen-on-spotify.svg" alt="Listen on Spotify" className="platform-badge" />
|
||
</a>
|
||
<a href={youtubeUrl} target="_blank" rel="noreferrer" className="platform-btn platform-btn-image">
|
||
<img src="/images/watch-on-youtube.svg" alt="Watch on YouTube" className="platform-badge" />
|
||
</a>
|
||
<a href={amazonUrl} target="_blank" rel="noreferrer" className="platform-btn platform-btn-image">
|
||
<img src="/images/listen-on-amazon-music.svg" alt="Listen on Amazon Music" className="platform-badge" />
|
||
</a>
|
||
<a href={facebookUrl} target="_blank" rel="noreferrer" className="platform-btn platform-btn-image">
|
||
<img src="/images/follow-on-facebook.svg" alt="Follow on Facebook" className="platform-badge" />
|
||
</a>
|
||
{(content.customLinks ?? []).filter(l => l.placement === 'platforms').map(l => (
|
||
<a
|
||
key={l.id}
|
||
href={l.url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className={`platform-btn ${l.imageUrl ? 'platform-btn-image' : 'custom-btn'}`}
|
||
aria-label={`Listen on ${l.label}`}
|
||
>
|
||
{l.imageUrl
|
||
? <img src={l.imageUrl} alt={`Listen on ${l.label}`} className="platform-badge" />
|
||
: l.label}
|
||
</a>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function KofiSupportSection({ content }: { content: SiteContent }) {
|
||
const kofiUrl = content.platformKofiUrl
|
||
const qrUrl = content.platformKofiQrUrl
|
||
if (!kofiUrl && !qrUrl) return null
|
||
return (
|
||
<section className="cx-support" aria-label="Support the show">
|
||
<div className="section-inner">
|
||
<div className="cx-support-card">
|
||
<div className="cx-support-copy">
|
||
<p className="eyebrow">Support the Ministry</p>
|
||
<h2>Help Keep the Word Going</h2>
|
||
<p>
|
||
This podcast and its study resources are listener-supported. If God has used this ministry in your life,
|
||
consider buying me a coffee. It goes straight toward keeping the teaching free for everyone.
|
||
</p>
|
||
{kofiUrl && (
|
||
<a href={kofiUrl} target="_blank" rel="noreferrer" className="kofi-btn">
|
||
☕ Support on Ko-fi
|
||
</a>
|
||
)}
|
||
</div>
|
||
{qrUrl && (
|
||
<figure className="cx-support-qr">
|
||
<img src={qrUrl} alt="Ko-fi donation QR code" />
|
||
<figcaption>Scan to give on Ko-fi</figcaption>
|
||
</figure>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function AboutSection({ content }: { content: SiteContent }) {
|
||
const paths = [
|
||
{ to: '/start-here', title: 'New here?', text: 'A short path through the show, one step at a time.' },
|
||
{ to: '/episodes', title: 'Listen', text: 'Every episode, newest first.' },
|
||
{ to: '/study', title: 'Study with us', text: 'Self-paced Bible studies with notes and discussion.' },
|
||
]
|
||
return (
|
||
<section className="ab-section" aria-label="About the show">
|
||
<div className="section-inner">
|
||
<div className="ab-intro">
|
||
<img
|
||
src={content.aboutPhotoUrl || '/images/nate-photo.jpeg'}
|
||
alt="Nate Emmert"
|
||
className="ab-photo"
|
||
/>
|
||
<div className="ab-intro-copy">
|
||
<p className="eyebrow">{content.aboutEyebrow || 'About Nate'}</p>
|
||
<p className="ab-bio">{content.aboutNate}</p>
|
||
<div className="ab-cta-row">
|
||
<Link to="/episodes" className="btn-primary">{content.aboutListenBtnLabel || 'Listen Now ↓'}</Link>
|
||
<Link to="/start-here" className="btn-secondary">New here? Start here</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="ab-show">
|
||
<div className="ab-show-copy">
|
||
{content.aboutShowEyebrow && <p className="eyebrow">{content.aboutShowEyebrow}</p>}
|
||
<h2>{content.aboutShowHeading}</h2>
|
||
<p>{content.aboutShowP1}</p>
|
||
<p>{content.aboutShowP2}</p>
|
||
</div>
|
||
<img
|
||
src={content.aboutVerseArtUrl || '/images/hebrews-4-12-verse-art.png'}
|
||
alt="Hebrews 4:12 verse artwork"
|
||
className="ab-verse-art"
|
||
loading="lazy"
|
||
/>
|
||
</div>
|
||
|
||
<div className="ab-paths">
|
||
{paths.map(p => (
|
||
<Link key={p.to} to={p.to} className="ab-path">
|
||
<span className="ab-path-title">{p.title}</span>
|
||
<span className="ab-path-text">{p.text}</span>
|
||
<span className="ab-path-go">Go →</span>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
|
||
function ContactSection({ content }: { content: SiteContent }) {
|
||
const point = (text: string, icon: ReactElement) => (
|
||
<div className="cx-point">
|
||
{icon}
|
||
<span>{text}</span>
|
||
</div>
|
||
)
|
||
return (
|
||
<section className="cx-section" id="contact" aria-label="Contact form">
|
||
<div className="section-inner cx-layout">
|
||
<aside className="cx-side">
|
||
<div className="cx-profile">
|
||
<img
|
||
src={content.contactPhotoUrl || '/images/nate-contact-photo.png'}
|
||
alt={content.contactName || 'Nate'}
|
||
className="cx-photo"
|
||
/>
|
||
<div>
|
||
<p className="cx-name">{content.contactName || 'Nate'}</p>
|
||
<p className="cx-role">{content.contactRole || 'Bible teacher · Lynchburg, VA'}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{content.contactQuote && <blockquote className="cx-quote">“{content.contactQuote}”</blockquote>}
|
||
{content.contactIntro && <p className="cx-intro">{content.contactIntro}</p>}
|
||
|
||
{(content.contactPoint1 || content.contactPoint2) && (
|
||
<div className="cx-points" aria-label="Contact response details">
|
||
{content.contactPoint1 && point(content.contactPoint1, (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||
</svg>
|
||
))}
|
||
{content.contactPoint2 && point(content.contactPoint2, (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||
<circle cx="12" cy="12" r="10" />
|
||
<polyline points="12 6 12 12 16 14" />
|
||
</svg>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{content.contactVerse && (
|
||
<p className="cx-verse">
|
||
“{content.contactVerse}” <span>{content.contactVerseRef}</span>
|
||
</p>
|
||
)}
|
||
|
||
<Link to="/questions" className="cx-qa-link">Browse answered questions →</Link>
|
||
</aside>
|
||
|
||
<div className="cx-form-card">
|
||
<ContactForm />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function HighlightCard({ link }: { link: PodcastFeaturedLink }) {
|
||
const hasRichContent = !!(
|
||
link.embedUrl
|
||
|| isLikelySpotifyEpisodeUrl(link.url)
|
||
|| (link.discussionQuestions ?? []).length > 0
|
||
|| link.showNotes
|
||
|| link.showNotesSummary
|
||
|| (link.showNotesThemes ?? []).length > 0
|
||
|| (link.showNotesScripture ?? []).length > 0
|
||
|| (link.showNotesQuotes ?? []).length > 0
|
||
|| (link.showNotesResources ?? []).length > 0
|
||
)
|
||
const inner = (
|
||
<>
|
||
<div className="episode-card-meta">
|
||
{link.episodeNumber && <span className="episode-number">Ep. {link.episodeNumber}</span>}
|
||
</div>
|
||
<h3 className="episode-title">{link.title || link.url}</h3>
|
||
{link.summary && <p className="episode-desc">{link.summary}</p>}
|
||
<span className="episode-listen-cta">Listen →</span>
|
||
</>
|
||
)
|
||
if (!hasRichContent && link.url) {
|
||
return <a href={link.url} target="_blank" rel="noreferrer" className="episode-card">{inner}</a>
|
||
}
|
||
return <Link to={`/episodes/${link.id}`} className="episode-card">{inner}</Link>
|
||
}
|
||
|
||
const HIGHLIGHTS_PREVIEW_COUNT = 6
|
||
|
||
// Highlights are grouped by the series they belong to, one tab per series, so a long series
|
||
// never pushes the others down the page. Each tab previews the newest few with a "show all" option.
|
||
function PodcastHighlightsSection({ content }: { content: SiteContent }) {
|
||
const [activeKey, setActiveKey] = useState('')
|
||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||
const links = [...(content.podcastFeaturedLinks ?? [])].reverse()
|
||
if (links.length === 0) return null
|
||
|
||
const laneOf = (link: PodcastFeaturedLink) => (link.lane === 'second' ? 'second' : link.lane === 'general' ? 'general' : 'primary')
|
||
const groups = [
|
||
{ key: 'primary', title: content.seriesTitle, links: links.filter(link => laneOf(link) === 'primary') },
|
||
{ key: 'second', title: content.secondSeries?.title?.trim() || 'Second Series', links: links.filter(link => laneOf(link) === 'second') },
|
||
{ key: 'general', title: 'More Highlights', links: links.filter(link => laneOf(link) === 'general') },
|
||
].filter(group => group.links.length > 0)
|
||
const active = groups.find(group => group.key === activeKey) ?? groups[0]
|
||
const isExpanded = expanded[active.key] === true
|
||
const shown = isExpanded ? active.links : active.links.slice(0, HIGHLIGHTS_PREVIEW_COUNT)
|
||
|
||
return (
|
||
<section className="section-resources" aria-label="Podcast highlights">
|
||
<div className="section-inner">
|
||
<h2 className="section-heading">
|
||
<span className="ornament">✦</span> Podcast Highlights{' '}
|
||
<span className="ornament">✦</span>
|
||
</h2>
|
||
{groups.length > 1 && (
|
||
<div className="highlight-tabs" role="tablist" aria-label="Highlights by series">
|
||
{groups.map(group => (
|
||
<button
|
||
key={group.key}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={group.key === active.key}
|
||
className={`highlight-tab${group.key === active.key ? ' highlight-tab--active' : ''}`}
|
||
onClick={() => setActiveKey(group.key)}
|
||
>
|
||
{group.title} <span className="highlight-tab-count">{group.links.length}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="episode-list">
|
||
{shown.map(link => <HighlightCard key={link.id} link={link} />)}
|
||
</div>
|
||
{active.links.length > HIGHLIGHTS_PREVIEW_COUNT && (
|
||
<div className="highlight-more">
|
||
<button type="button" className="btn-secondary" onClick={() => setExpanded(prev => ({ ...prev, [active.key]: !isExpanded }))}>
|
||
{isExpanded ? 'Show fewer' : `Show all ${active.links.length} highlights`}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function PageBanner({ eyebrow, title, desc }: { eyebrow: string; title: string; desc?: string }) {
|
||
return (
|
||
<section className="section-page-banner" aria-label={title}>
|
||
<div className="section-inner page-banner-inner">
|
||
<p className="eyebrow">{eyebrow}</p>
|
||
<h1 className="page-banner-title">{title}</h1>
|
||
{desc && <p className="page-banner-desc">{desc}</p>}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function DownloadDetailPage({ content }: { content: SiteContent }) {
|
||
const { id } = useParams<{ id: string }>()
|
||
const resource = resolveDownloadPageResource(content, id)
|
||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||
usePageMeta(
|
||
resource ? `${resource.label} | ${siteTitle}` : `Resource | ${siteTitle}`,
|
||
resource?.summary || 'Free study guides and resources from Verse by Verse with Nate.',
|
||
)
|
||
|
||
if (!resource) {
|
||
return (
|
||
<main className="thanks-page" aria-label="Download not found">
|
||
<div className="thanks-card">
|
||
<p className="eyebrow">Resources</p>
|
||
<h1>Download not found</h1>
|
||
<p>The download you requested is not available right now.</p>
|
||
<Link to="/resources" className="btn-primary">Back to Resources</Link>
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="site">
|
||
<SiteHeader content={content} />
|
||
<SiteSearchBar content={content} />
|
||
<main className="dl-page" aria-label={resource.label}>
|
||
<div className="section-inner">
|
||
<Link to="/resources" className="dl-back">← Back to Resources</Link>
|
||
<article className="dl-card">
|
||
{resource.imageUrl && (
|
||
<div className="dl-art">
|
||
<img src={resource.imageUrl} alt={resource.label} />
|
||
</div>
|
||
)}
|
||
<div className="dl-body">
|
||
<p className="eyebrow">Free download</p>
|
||
<h1>{resource.label}</h1>
|
||
{resource.summary && <p className="dl-summary">{resource.summary}</p>}
|
||
{resource.tags.length > 0 && (
|
||
<div className="dl-tags">
|
||
{resource.tags.map(tag => <span key={tag} className="dl-tag">{tag}</span>)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="dl-form">
|
||
{/* The title is already on the page, so the button stays short */}
|
||
{resource.isPrimaryGuide
|
||
? <StudyDownloadForm buttonText="Download now" />
|
||
: <ResourceDownloadForm resourceId={resource.resourceId} buttonText="Download now" />}
|
||
</div>
|
||
|
||
{(resource.amazonUrl || resource.appleBooksUrl) && (
|
||
<div className="dl-actions">
|
||
<span>Or get it from:</span>
|
||
{resource.amazonUrl && (
|
||
<StoreLink source={`page:${resource.id}`} store="amazon" href={resource.amazonUrl} gated={content.gateStoreLinks !== false} className="btn-secondary">
|
||
{resource.amazonLabel || 'Get it on Amazon'}
|
||
</StoreLink>
|
||
)}
|
||
{resource.appleBooksUrl && (
|
||
<StoreLink source={`page:${resource.id}`} store="apple" href={resource.appleBooksUrl} gated={content.gateStoreLinks !== false} className="btn-secondary">
|
||
{resource.appleBooksLabel || 'Get it on Apple Books'}
|
||
</StoreLink>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</article>
|
||
</div>
|
||
</main>
|
||
<SiteFooter content={content} />
|
||
<AnalyticsConsentBanner content={content} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ShareShowButton() {
|
||
const [feedback, setFeedback] = useState('')
|
||
|
||
async function handleShare() {
|
||
const shareUrl = window.location.origin
|
||
const shareTitle = 'Verse by Verse with Nate'
|
||
const shareText = 'Check out Verse by Verse with Nate.'
|
||
try {
|
||
if (navigator.share) {
|
||
await navigator.share({ title: shareTitle, text: shareText, url: shareUrl })
|
||
setFeedback('Shared successfully.')
|
||
return
|
||
}
|
||
if (navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(shareUrl)
|
||
setFeedback('Link copied. Share it with a friend.')
|
||
return
|
||
}
|
||
setFeedback(`Copy this link: ${shareUrl}`)
|
||
} catch {
|
||
setFeedback('Share canceled.')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="share-show-wrap">
|
||
<button type="button" className="btn-primary btn-share-show" onClick={handleShare}>
|
||
Share the Show
|
||
</button>
|
||
{feedback && <p className="share-feedback">{feedback}</p>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function linkifyText(text: string) {
|
||
const urlRegex = /(https?:\/\/[^\s]+)/g
|
||
const parts = text.split(urlRegex)
|
||
return parts.map((part, i) =>
|
||
urlRegex.test(part)
|
||
? <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="custom-block-inline-link">{part}</a>
|
||
: part
|
||
)
|
||
}
|
||
|
||
function CustomBlocksSection({ content, page }: { content: SiteContent; page: string }) {
|
||
const blocks = (content.customBlocks ?? []).filter(block => {
|
||
const blockPage = block.page ?? 'downloads'
|
||
return blockPage === page
|
||
})
|
||
if (blocks.length === 0) return null
|
||
return (
|
||
<>
|
||
{blocks.map(block => (
|
||
<section key={block.id} className="section-custom-block" aria-label={block.heading}>
|
||
<div className="section-inner custom-block-inner">
|
||
<h2>{block.heading}</h2>
|
||
<p>{linkifyText(block.body)}</p>
|
||
{block.linkUrl && (
|
||
<a href={block.linkUrl} target="_blank" rel="noopener noreferrer" className="btn-primary custom-block-link-btn">
|
||
{block.linkLabel || 'Learn More'}
|
||
</a>
|
||
)}
|
||
</div>
|
||
</section>
|
||
))}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function ExternalSitesSection({ content }: { content: SiteContent }) {
|
||
const links = (content.customLinks ?? []).filter(link => link.placement === 'externalSites')
|
||
if (links.length === 0) return null
|
||
|
||
return (
|
||
<section className="section-resources section-external-sites" aria-label="External sites">
|
||
<div className="section-inner">
|
||
<div className="download-library-head">
|
||
<p className="eyebrow">Homepage Links</p>
|
||
<h2 className="section-heading">External sites and ministries worth checking out.</h2>
|
||
</div>
|
||
<div className="resources-list">
|
||
{links.map(link => (
|
||
<a key={link.id} href={link.url} target="_blank" rel="noreferrer" className="resource-link">
|
||
<div className="resource-download-meta">
|
||
{link.imageUrl && <img src={link.imageUrl} alt={link.label} className="resource-link-image" />}
|
||
<span className="resource-link-label">{link.label}</span>
|
||
{link.description && <p className="resource-link-description">{link.description}</p>}
|
||
<span className="resource-link-action">Open external site →</span>
|
||
</div>
|
||
</a>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function HomepageNewsletterSection() {
|
||
const [email, setEmail] = useState('')
|
||
const [firstName, setFirstName] = useState('')
|
||
const [honey, setHoney] = useState('')
|
||
const [status, setStatus] = useState<'idle' | 'submitting' | 'done' | 'error'>('idle')
|
||
|
||
async function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
if (honey) return
|
||
setStatus('submitting')
|
||
try {
|
||
const res = await fetch('/api/contact', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ firstName, email, messageType: 'general', message: 'Newsletter signup from homepage', subscribe: true, _honey: honey }),
|
||
})
|
||
setStatus(res.ok ? 'done' : 'error')
|
||
} catch {
|
||
setStatus('error')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="section-newsletter" aria-label="Newsletter sign-up">
|
||
<div className="section-inner newsletter-inner">
|
||
<p className="eyebrow">Stay in the Word</p>
|
||
<h2 className="section-heading">Get episode updates by email</h2>
|
||
<p className="newsletter-sub">New episodes, study resources, and ministry updates — delivered to your inbox.</p>
|
||
{status === 'done' ? (
|
||
<p className="newsletter-thanks">You're subscribed! Thank you for signing up.</p>
|
||
) : (
|
||
<form className="newsletter-form" onSubmit={handleSubmit} noValidate>
|
||
<input type="text" className="contact-honeypot" tabIndex={-1} autoComplete="off" aria-hidden="true" value={honey} onChange={e => setHoney(e.target.value)} />
|
||
<div className="newsletter-fields">
|
||
<input
|
||
type="text"
|
||
placeholder="First name"
|
||
required
|
||
autoComplete="given-name"
|
||
value={firstName}
|
||
onChange={e => setFirstName(e.target.value)}
|
||
className="newsletter-input"
|
||
/>
|
||
<input
|
||
type="email"
|
||
placeholder="Email address"
|
||
required
|
||
autoComplete="email"
|
||
value={email}
|
||
onChange={e => setEmail(e.target.value)}
|
||
className="newsletter-input"
|
||
/>
|
||
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
|
||
{status === 'submitting' ? 'Subscribing…' : 'Subscribe'}
|
||
</button>
|
||
</div>
|
||
{status === 'error' && <p className="newsletter-error">Something went wrong. Please try again.</p>}
|
||
</form>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
type TflWidgetType = 'audiodevo' | 'abdevotional' | 'resourcead'
|
||
|
||
function TruthForLifeWidget({ type = 'audiodevo' }: { type?: TflWidgetType }) {
|
||
const heading = type === 'resourcead' ? 'Recommended Resources' : 'Daily Devotional'
|
||
const containerId = 'tfl-syndicate-container'
|
||
|
||
useEffect(() => {
|
||
const container = document.getElementById(containerId)
|
||
if (!container) return
|
||
container.innerHTML = ''
|
||
|
||
const existing = document.getElementById('syn_script')
|
||
if (existing) existing.remove()
|
||
|
||
const script = document.createElement('script')
|
||
script.id = 'syn_script'
|
||
script.src = `https://www.truthforlife.org/static/js/responsive/lib/syndicate.js?type=${type}&id=458890`
|
||
script.type = 'text/javascript'
|
||
container.parentElement?.insertBefore(script, container)
|
||
}, [type, containerId])
|
||
|
||
return (
|
||
<section className="section-tfl" aria-label="Truth For Life devotional" style={type === 'resourcead' ? { padding: '2.5rem 0' } : undefined}>
|
||
<div className="section-inner">
|
||
<div className="tfl-label-row" style={type === 'resourcead' ? { marginBottom: '1rem' } : undefined}>
|
||
<h2 className="section-heading" style={{ marginBottom: '0.25rem' }}>
|
||
<span className="ornament">✦</span> {heading} <span className="ornament">✦</span>
|
||
</h2>
|
||
<p className="tfl-attribution" style={{ marginBottom: '1rem' }}>
|
||
<a href="https://www.truthforlife.org" target="_blank" rel="noreferrer" className="tfl-link" style={{ fontSize: '1rem', fontWeight: 600 }}>Truth For Life</a>
|
||
{' '}with Alistair Begg
|
||
</p>
|
||
</div>
|
||
<div className="tfl-widget-wrap">
|
||
<div
|
||
id="tfl-syndicate-container"
|
||
style={{ maxWidth: type === 'resourcead' ? '500px' : '760px', margin: '0 auto' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function HomepageQACallout() {
|
||
const [questions, setQuestions] = useState<{ id: string; firstName: string; question: string; answer: string }[]>([])
|
||
|
||
useEffect(() => {
|
||
fetch('/api/questions')
|
||
.then(r => (r.ok ? r.json() : Promise.reject()))
|
||
.then((data: { questions: { id: string; firstName: string; question: string; answer: string }[] }) => {
|
||
setQuestions((data.questions ?? []).slice(0, 3))
|
||
})
|
||
.catch(() => {})
|
||
}, [])
|
||
|
||
if (questions.length === 0) return null
|
||
|
||
return (
|
||
<section className="section-qa-callout" aria-label="Featured Q&A">
|
||
<div className="section-inner">
|
||
<p className="eyebrow">Q&A</p>
|
||
<h2 className="section-heading"><span className="ornament">✦</span> Questions from Listeners <span className="ornament">✦</span></h2>
|
||
<p className="qa-callout-sub">Real questions. Scripture-grounded answers.</p>
|
||
<div className="qa-callout-grid">
|
||
{questions.map(q => (
|
||
<div key={q.id} className="qa-callout-card">
|
||
<p className="qa-callout-question">"{q.question}"</p>
|
||
<p className="qa-callout-answer">{q.answer.length > 220 ? q.answer.slice(0, 220).trimEnd() + '…' : q.answer}</p>
|
||
{q.firstName && <p className="qa-callout-from">— {q.firstName}</p>}
|
||
<Link to={`/questions#qa-${q.id}`} className="qa-callout-more">Read the full answer →</Link>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div style={{ textAlign: 'center', marginTop: '2rem' }}>
|
||
<Link to="/questions" className="btn-primary">Browse All Q&A →</Link>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function TestimonialsSection({ testimonials }: { testimonials: Testimonial[] }) {
|
||
if (!testimonials || testimonials.length === 0) return null
|
||
return (
|
||
<section className="section-testimonials" aria-label="Listener testimonials">
|
||
<div className="section-inner">
|
||
<h2 className="section-heading">
|
||
<span className="ornament">✦</span> What Listeners Are Saying <span className="ornament">✦</span>
|
||
</h2>
|
||
<div className="testimonials-grid">
|
||
{testimonials.map(t => (
|
||
<blockquote key={t.id} className="testimonial-card">
|
||
<p className="testimonial-quote">"{t.quote}"</p>
|
||
<footer className="testimonial-footer">
|
||
<span className="testimonial-name">— {t.name}</span>
|
||
{t.source && <span className="testimonial-source">{t.source}</span>}
|
||
</footer>
|
||
</blockquote>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function LandingPage({ content }: { content: SiteContent }) {
|
||
const featuredStudy = getHomepageFeaturedStudy(content)
|
||
|
||
return (
|
||
<div className="site">
|
||
<SiteHeader content={content} />
|
||
<SiteSearchBar content={content} />
|
||
<CustomBlocksSection content={content} page="homepage" />
|
||
|
||
{/* ── HERO ── */}
|
||
<section className="hero" aria-label="Show introduction">
|
||
<div className="hero-art">
|
||
<div className="art-glow" aria-hidden="true" />
|
||
<img
|
||
src={content.podcastArtUrl || '/images/podcast-art.jpeg'}
|
||
alt="Verse by Verse with Nate podcast artwork"
|
||
className="podcast-art"
|
||
/>
|
||
</div>
|
||
<div className="hero-text">
|
||
<p className="eyebrow">{content.eyebrow}</p>
|
||
<h1 className="hero-title">Verse by Verse</h1>
|
||
<p className="with-nate">
|
||
<span className="word-with">with </span>Nate
|
||
</p>
|
||
<div className="rule-divider" aria-hidden="true" />
|
||
<p className="tagline">{content.heroTagline}</p>
|
||
<div className="hero-ctas">
|
||
<a
|
||
href={content.platformSpotifyUrl || '/spotify'}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="btn-primary"
|
||
>
|
||
<SpotifyIcon />
|
||
{content.heroBtnSpotify || 'Listen on Spotify'}
|
||
</a>
|
||
<Link to="/episodes" className="btn-secondary">{content.heroBtnEpisodes || 'Explore Episodes ↓'}</Link>
|
||
<Link to="/start-here" className="btn-secondary btn-start-here">{content.heroBtnStartHere || 'New here? Start here →'}</Link>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="section-prism" aria-label="PRISM featured video">
|
||
<div className="section-inner prism-feature">
|
||
<div className="prism-video-shell">
|
||
<video
|
||
className="prism-video"
|
||
controls
|
||
playsInline
|
||
preload="metadata"
|
||
poster="/images/banner.png"
|
||
>
|
||
<source src={content.prismVideoUrl || '/images/PRISM.mp4'} type="video/mp4" />
|
||
Your browser does not support embedded video playback.
|
||
</video>
|
||
</div>
|
||
|
||
<div className="prism-copy">
|
||
<p className="eyebrow">{content.prismEyebrow || 'Featured Video'}</p>
|
||
<h2 className="section-heading prism-heading">
|
||
<span className="ornament">✦</span> {content.prismHeading || 'PRISM'} <span className="ornament">✦</span>
|
||
</h2>
|
||
<p className="prism-description">
|
||
{content.prismIntro || 'What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?'}
|
||
</p>
|
||
<p className="prism-description">
|
||
{content.prismPatternIntro || 'In this bonus episode, I walk you through P.R.I.S.M. — a five-step daily Bible study pattern designed to help you slow down, go deep, and let the Word do its work.'}
|
||
</p>
|
||
<div className="prism-steps" aria-label="P.R.I.S.M. steps">
|
||
<p>{content.prismStep1 || 'P — Pray before you read'}</p>
|
||
<p>{content.prismStep2 || 'R — Read slowly and observe'}</p>
|
||
<p>{content.prismStep3 || 'I — Interpret with context'}</p>
|
||
<p>{content.prismStep4 || 'S — Study and apply specifically'}</p>
|
||
<p>{content.prismStep5 || 'M — Memorize one verse at a time'}</p>
|
||
</div>
|
||
<p className="prism-description">
|
||
{content.prismOutro || "Whether you're brand new to daily Bible reading or you've been at it for years, P.R.I.S.M. gives you a consistent framework for every passage, every day."}
|
||
</p>
|
||
<p className="prism-description prism-emphasis">
|
||
{content.prismClosing || "The goal isn't to get through the text — it's to let the text get through to you."}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<HomepageNewsletterSection />
|
||
|
||
<section className="section-series" id="series" aria-label="Current series">
|
||
<div className="section-inner">
|
||
<h2 className="section-heading">
|
||
<span className="ornament">✦</span> Current Series{' '}
|
||
<span className="ornament">✦</span>
|
||
</h2>
|
||
<div className={`series-grid${content.secondSeries?.title?.trim() ? ' series-grid--two' : ''}`}>
|
||
{[
|
||
{
|
||
key: 'primary',
|
||
imageUrl: content.seriesImageUrl,
|
||
title: content.seriesTitle,
|
||
label: content.seriesLabel,
|
||
description: content.seriesDescription,
|
||
listenUrl: content.seriesListenUrl,
|
||
listenBtnLabel: content.seriesListenBtnLabel,
|
||
studySlug: content.seriesStudySlug,
|
||
},
|
||
...(content.secondSeries?.title?.trim()
|
||
? [{
|
||
key: 'second',
|
||
imageUrl: content.secondSeries.imageUrl,
|
||
title: content.secondSeries.title,
|
||
label: content.secondSeries.label,
|
||
description: content.secondSeries.description,
|
||
listenUrl: content.secondSeries.listenUrl,
|
||
listenBtnLabel: content.secondSeries.listenBtnLabel,
|
||
studySlug: content.secondSeries.studySlug,
|
||
}]
|
||
: []),
|
||
].map(series => (
|
||
<article className="series-card" key={series.key}>
|
||
<img
|
||
src={series.imageUrl}
|
||
alt={series.title}
|
||
className="series-art"
|
||
/>
|
||
<div className="series-info">
|
||
<p className="series-label">{series.label}</p>
|
||
<h3>{series.title}</h3>
|
||
<p>{series.description}</p>
|
||
<a
|
||
href={series.listenUrl || content.platformSpotifyUrl || '/spotify'}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="btn-primary"
|
||
>
|
||
<SpotifyIcon />
|
||
{series.listenBtnLabel || content.seriesListenBtnLabel || 'Listen to Series'}
|
||
</a>
|
||
{series.studySlug
|
||
? <Link to={`/study/${series.studySlug}`} className="btn-secondary">Study Along</Link>
|
||
: <Link to="/resources" className="btn-secondary">{content.seriesDownloadsBtnLabel || 'View Resources'}</Link>}
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<TruthForLifeWidget type="audiodevo" />
|
||
|
||
<ExternalSitesSection content={content} />
|
||
|
||
<section className="section-home-jump" aria-label="Homepage navigation">
|
||
<div className="section-inner">
|
||
<div className="home-jump-head">
|
||
<p className="eyebrow">{content.whereToNextEyebrow || 'Where to Next'}</p>
|
||
<h2 className="section-heading">{content.whereToNextHeading || 'Choose the page you need.'}</h2>
|
||
</div>
|
||
<div className="home-jump-grid">
|
||
{(content.whereToNextCards ?? []).map(card => (
|
||
<Link key={card.id} to={card.path} className="home-jump-card">
|
||
<h3>{card.title}</h3>
|
||
<p>{card.description}</p>
|
||
</Link>
|
||
))}
|
||
{featuredStudy && (
|
||
<Link to={`/study/${featuredStudy.slug || ''}`} className="home-jump-card home-jump-card--featured">
|
||
<div className="home-jump-featured-head">
|
||
<p className="eyebrow">{featuredStudy.homepageEyebrow || 'Study'}</p>
|
||
{featuredStudy.showNewTag && <span className="home-jump-new-tag">{featuredStudy.newTagLabel || 'NEW'}</span>}
|
||
</div>
|
||
<h3>{featuredStudy.title}</h3>
|
||
<p>{featuredStudy.description || 'Explore this study track with lesson notes, commentary, and guided questions.'}</p>
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<HomepageQACallout />
|
||
|
||
<TestimonialsSection testimonials={content.testimonials ?? []} />
|
||
|
||
<section className="section-share" aria-label="Share the show">
|
||
<div className="section-inner section-share-inner">
|
||
<div className="share-text">
|
||
<h2 className="section-heading" style={{ textAlign: 'left', marginBottom: '0.75rem' }}>
|
||
{content.shareHeading || 'Help one more person hear the Word this week.'}
|
||
</h2>
|
||
<p className="share-p">{content.shareP || 'Scan the QR code or text the show link to a friend who needs encouragement today.'}</p>
|
||
<ShareShowButton />
|
||
</div>
|
||
<img src={content.shareQrImageUrl || '/images/qr-code.jpg'} alt="QR code to share the show" className="share-qr" />
|
||
</div>
|
||
</section>
|
||
|
||
<SiteFooter content={content} />
|
||
|
||
<AnalyticsConsentBanner content={content} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function EpisodeDetailPage({ content }: { content: SiteContent }) {
|
||
const { id } = useParams<{ id: string }>()
|
||
const episode = getAllHighlights(content).find(e => e.id === id)
|
||
const [resolvedEmbedUrl, setResolvedEmbedUrl] = useState('')
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
|
||
if (!episode) {
|
||
setResolvedEmbedUrl('')
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}
|
||
|
||
const manualEmbed = episode.embedUrl?.trim() ?? ''
|
||
if (manualEmbed) {
|
||
setResolvedEmbedUrl(manualEmbed)
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}
|
||
|
||
if (!episode.url) {
|
||
setResolvedEmbedUrl('')
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}
|
||
|
||
try {
|
||
const urlObj = new URL(episode.url)
|
||
const host = urlObj.hostname.toLowerCase()
|
||
|
||
if (host === 'open.spotify.com') {
|
||
const fromUrl = toSpotifyEpisodeEmbedUrl(episode.url)
|
||
if (fromUrl) {
|
||
setResolvedEmbedUrl(fromUrl)
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
if (!isLikelySpotifyEpisodeUrl(episode.url)) {
|
||
setResolvedEmbedUrl('')
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}
|
||
|
||
setResolvedEmbedUrl('')
|
||
fetch(`/api/spotify/embed-url?url=${encodeURIComponent(episode.url)}`)
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('embed resolve failed'))))
|
||
.then((data: { embedUrl?: string }) => {
|
||
if (cancelled) return
|
||
setResolvedEmbedUrl(data.embedUrl?.trim() ?? '')
|
||
})
|
||
.catch(() => {
|
||
if (cancelled) return
|
||
setResolvedEmbedUrl('')
|
||
})
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [episode])
|
||
|
||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||
usePageMeta(
|
||
episode ? `${episode.title} | ${siteTitle}` : `Episode | ${siteTitle}`,
|
||
episode?.summary || 'Expository Bible teaching from Verse by Verse with Nate.',
|
||
)
|
||
|
||
if (!episode) {
|
||
return (
|
||
<div className="site">
|
||
<SiteHeader content={content} />
|
||
<SiteSearchBar content={content} />
|
||
<section className="section-player">
|
||
<div className="section-inner" style={{ textAlign: 'center', padding: '4rem 1rem' }}>
|
||
<h2>Episode not found</h2>
|
||
<p>This episode highlight doesn't exist or may have been removed.</p>
|
||
<Link to="/episodes" className="btn-primary" style={{ marginTop: '1.5rem', display: 'inline-block' }}>← Back to Episodes</Link>
|
||
</div>
|
||
</section>
|
||
<SiteFooter content={content} />
|
||
<AnalyticsConsentBanner content={content} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const questions = episode.discussionQuestions ?? []
|
||
|
||
return (
|
||
<div className="site">
|
||
<SiteHeader content={content} />
|
||
<SiteSearchBar content={content} />
|
||
<section className="section-episode-detail" aria-label={episode.title}>
|
||
<div className="section-inner">
|
||
<Link to="/episodes" className="episode-detail-back">← Back to Episodes</Link>
|
||
|
||
{episode.episodeNumber && (
|
||
<p className="episode-detail-number">Episode {episode.episodeNumber}</p>
|
||
)}
|
||
<h1 className="episode-detail-title">{episode.title}</h1>
|
||
{episode.summary && <p className="episode-detail-summary">{episode.summary}</p>}
|
||
|
||
{(episode.embedUrl || episode.url) && (
|
||
<div className="episode-detail-embed">
|
||
{resolvedEmbedUrl ? (
|
||
<EpisodeAudioPlayer src={resolvedEmbedUrl} title={episode.title} size="full" spotifyUrl={episode.url || content.platformSpotifyUrl} />
|
||
) : (
|
||
<div className="episode-embed-skeleton" aria-label="Loading player…" />
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{episode.url && (
|
||
<a href={episode.url} target="_blank" rel="noreferrer" className="btn-primary episode-detail-listen-btn">
|
||
Listen on Podcast Platform →
|
||
</a>
|
||
)}
|
||
|
||
{(episode.showNotesSummary || (episode.showNotesThemes ?? []).length > 0 || (episode.showNotesScripture ?? []).length > 0 || (episode.showNotesQuotes ?? []).length > 0 || (episode.showNotesResources ?? []).length > 0 || episode.showNotes) && (
|
||
<div className="episode-detail-show-notes">
|
||
<h2 className="episode-detail-section-heading">Show Notes</h2>
|
||
{episode.showNotesSummary
|
||
? <p>{episode.showNotesSummary}</p>
|
||
: episode.showNotes && !(episode.showNotesThemes ?? []).length && <p>{episode.showNotes}</p>
|
||
}
|
||
{(episode.showNotesThemes ?? []).length > 0 && (
|
||
<div className="episode-detail-show-notes-section">
|
||
<h3 className="episode-detail-show-notes-heading">Key Themes</h3>
|
||
<ul className="episode-detail-show-notes-list">
|
||
{episode.showNotesThemes!.map((t, i) => <li key={i}>{t}</li>)}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
{(episode.showNotesScripture ?? []).length > 0 && (
|
||
<div className="episode-detail-show-notes-section">
|
||
<h3 className="episode-detail-show-notes-heading">Scripture References</h3>
|
||
<ul className="episode-detail-show-notes-list">
|
||
{episode.showNotesScripture!.map((s, i) => <li key={i}>{s}</li>)}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
{(episode.showNotesQuotes ?? []).length > 0 && (
|
||
<div className="episode-detail-show-notes-section">
|
||
<h3 className="episode-detail-show-notes-heading">Key Quotes</h3>
|
||
{episode.showNotesQuotes!.map((q, i) => (
|
||
<blockquote key={i} className="episode-detail-show-notes-quote">{q}</blockquote>
|
||
))}
|
||
</div>
|
||
)}
|
||
{(episode.showNotesResources ?? []).length > 0 && (
|
||
<div className="episode-detail-show-notes-section">
|
||
<h3 className="episode-detail-show-notes-heading">Resources</h3>
|
||
<ul className="episode-detail-show-notes-list">
|
||
{episode.showNotesResources!.map((r, i) => <li key={i}>{r}</li>)}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{questions.length > 0 && (
|
||
<div className="episode-detail-questions">
|
||
<h2 className="episode-detail-section-heading">Discussion Questions</h2>
|
||
<ol className="episode-detail-questions-list">
|
||
{questions.map((q, i) => (
|
||
<li key={i}>{q}</li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
<SiteFooter content={content} />
|
||
<AnalyticsConsentBanner content={content} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function useEpisodesForBook(season: number) {
|
||
const [episodes, setEpisodes] = useState<{ title: string; episode: string; season: string; duration: string; audioUrl: string; link: string; description: string }[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
useEffect(() => {
|
||
fetch('/api/episodes/all')
|
||
.then(r => r.ok ? r.json() : Promise.reject())
|
||
.then((data: { episodes?: { title: string; episode: string; season: string; duration: string; audioUrl: string; link: string; description: string }[] }) => {
|
||
const filtered = (data.episodes ?? [])
|
||
.filter(ep => ep.season === String(season))
|
||
.sort((a, b) => parseInt(a.episode) - parseInt(b.episode))
|
||
setEpisodes(filtered)
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => setLoading(false))
|
||
}, [season])
|
||
return { episodes, loading }
|
||
}
|
||
|
||
function FinishedBooksPage({ content }: { content: SiteContent }) {
|
||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||
usePageMeta(`Finished Books | ${siteTitle}`, 'Completed series from Verse by Verse with Nate — browse episode playlists for past book studies.')
|
||
const books = content.finishedBooks ?? []
|
||
|
||
return (
|
||
<div className="site">
|
||
<SiteHeader content={content} />
|
||
<SiteSearchBar content={content} />
|
||
<PageBanner eyebrow="Studies" title="Finished Books" desc="Every series we've completed — with a full ordered episode playlist for each book of the Bible." />
|
||
<section className="section-finished-books">
|
||
<div className="section-inner">
|
||
{books.length === 0 ? (
|
||
<div className="finished-books-empty-state">
|
||
<div className="finished-books-empty-icon">📖</div>
|
||
<h2 className="finished-books-empty-heading">Nothing here yet</h2>
|
||
<p className="finished-books-empty-text">
|
||
We're still working through the current series. When a book study wraps up, it'll live here with the full episode playlist. Check back soon.
|
||
</p>
|
||
<Link to="/episodes" className="btn-primary" style={{ display: 'inline-block', marginTop: '1.5rem' }}>
|
||
Listen to Current Series →
|
||
</Link>
|
||
</div>
|
||
) : (
|
||
<div className="finished-books-grid">
|
||
{books.map(book => (
|
||
<Link key={book.id} to={`/finished/${book.id}`} className="finished-book-card">
|
||
{book.imageUrl && (
|
||
<div className="finished-book-card-img-wrap">
|
||
<img src={book.imageUrl} alt={book.title} className="finished-book-card-img" />
|
||
{book.downloadUrl && <span className="finished-book-card-dl-badge" title="Download available">⬇</span>}
|
||
</div>
|
||
)}
|
||
<div className="finished-book-card-body">
|
||
<p className="finished-book-card-season">Season {book.season}</p>
|
||
<h2 className="finished-book-card-title">{book.title}</h2>
|
||
{book.description && <p className="finished-book-card-desc">{book.description}</p>}
|
||
<span className="finished-book-card-cta">View Playlist →</span>
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
<SiteFooter content={content} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const FINISHED_PLAYLIST_PREVIEW_COUNT = 10
|
||
const FINISHED_PLAYLIST_FILTER_THRESHOLD = 12
|
||
|
||
type FinishedBookEpisode = { title: string; episode: string; duration: string; audioUrl: string; description: string }
|
||
|
||
// One section with two tabs instead of two full-length lists stacked on top of each other.
|
||
// "Highlights" are the hand-picked episodes (with notes); "All Episodes" is the complete
|
||
// ordered playlist, which previews the first few, can be filtered, and only loads an audio
|
||
// player for the episode you open.
|
||
function FinishedSeriesEpisodes({ highlights, episodes, loading }: {
|
||
highlights: PodcastFeaturedLink[]
|
||
episodes: FinishedBookEpisode[]
|
||
loading: boolean
|
||
}) {
|
||
const hasHighlights = highlights.length > 0
|
||
const [tab, setTab] = useState<'highlights' | 'all'>(hasHighlights ? 'highlights' : 'all')
|
||
const [highlightsExpanded, setHighlightsExpanded] = useState(false)
|
||
const [playlistExpanded, setPlaylistExpanded] = useState(false)
|
||
const [filter, setFilter] = useState('')
|
||
const [openKey, setOpenKey] = useState<string | null>(null)
|
||
const activeTab = hasHighlights ? tab : 'all'
|
||
|
||
useEffect(() => {
|
||
function onHash() {
|
||
if (window.location.hash === '#episodes') setTab('all')
|
||
}
|
||
onHash()
|
||
window.addEventListener('hashchange', onHash)
|
||
return () => window.removeEventListener('hashchange', onHash)
|
||
}, [])
|
||
|
||
const orderedHighlights = [...highlights].reverse()
|
||
const shownHighlights = highlightsExpanded ? orderedHighlights : orderedHighlights.slice(0, HIGHLIGHTS_PREVIEW_COUNT)
|
||
|
||
const query = filter.trim().toLowerCase()
|
||
const matching = query
|
||
? episodes.filter(ep => `${ep.episode} ${ep.title}`.toLowerCase().includes(query))
|
||
: episodes
|
||
const showAllPlaylist = playlistExpanded || query.length > 0
|
||
const shownEpisodes = showAllPlaylist ? matching : matching.slice(0, FINISHED_PLAYLIST_PREVIEW_COUNT)
|
||
|
||
return (
|
||
<section id="episodes" className="finished-series-episodes" aria-label="Episodes">
|
||
<div className="highlight-tabs" role="tablist" aria-label="Episode views">
|
||
{hasHighlights && (
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={activeTab === 'highlights'}
|
||
className={`highlight-tab${activeTab === 'highlights' ? ' highlight-tab--active' : ''}`}
|
||
onClick={() => setTab('highlights')}
|
||
>
|
||
Highlights <span className="highlight-tab-count">{highlights.length}</span>
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={activeTab === 'all'}
|
||
className={`highlight-tab${activeTab === 'all' ? ' highlight-tab--active' : ''}`}
|
||
onClick={() => setTab('all')}
|
||
>
|
||
All Episodes {!loading && <span className="highlight-tab-count">{episodes.length}</span>}
|
||
</button>
|
||
</div>
|
||
|
||
{activeTab === 'highlights' ? (
|
||
<>
|
||
<div className="episode-list">
|
||
{shownHighlights.map(link => <HighlightCard key={link.id} link={link} />)}
|
||
</div>
|
||
{orderedHighlights.length > HIGHLIGHTS_PREVIEW_COUNT && (
|
||
<div className="highlight-more">
|
||
<button type="button" className="btn-secondary" onClick={() => setHighlightsExpanded(v => !v)}>
|
||
{highlightsExpanded ? 'Show fewer' : `Show all ${orderedHighlights.length} highlights`}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : loading ? (
|
||
<p className="finished-books-empty">Loading episodes…</p>
|
||
) : episodes.length === 0 ? (
|
||
<p className="finished-books-empty">No episodes found for this season.</p>
|
||
) : (
|
||
<>
|
||
{episodes.length > FINISHED_PLAYLIST_FILTER_THRESHOLD && (
|
||
<input
|
||
type="search"
|
||
className="finished-series-filter"
|
||
placeholder="Search episodes by number or title…"
|
||
aria-label="Search episodes"
|
||
value={filter}
|
||
onChange={e => setFilter(e.target.value)}
|
||
/>
|
||
)}
|
||
{matching.length === 0 ? (
|
||
<p className="finished-books-empty">No episodes match “{filter.trim()}”.</p>
|
||
) : (
|
||
<ol className="finished-series-playlist">
|
||
{shownEpisodes.map((ep, i) => {
|
||
const key = ep.audioUrl || `${ep.episode}-${i}`
|
||
const isOpen = openKey === key
|
||
return (
|
||
<li key={key} className={`finished-series-episode${isOpen ? ' finished-series-episode--open' : ''}`}>
|
||
<span className="finished-series-ep-num">{ep.episode || i + 1}</span>
|
||
<div className="finished-series-ep-body">
|
||
<div className="finished-series-ep-row">
|
||
<div>
|
||
<p className="finished-series-ep-title">{ep.title}</p>
|
||
{ep.duration && <p className="finished-series-ep-meta">{ep.duration}</p>}
|
||
</div>
|
||
{ep.audioUrl && (
|
||
<button
|
||
type="button"
|
||
className="finished-series-ep-play"
|
||
aria-expanded={isOpen}
|
||
onClick={() => setOpenKey(isOpen ? null : key)}
|
||
>
|
||
{isOpen ? 'Hide player' : '▶ Play'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{isOpen && ep.audioUrl && <EpisodeAudioPlayer src={ep.audioUrl} title={ep.title} />}
|
||
</div>
|
||
</li>
|
||
)
|
||
})}
|
||
</ol>
|
||
)}
|
||
{!query && matching.length > FINISHED_PLAYLIST_PREVIEW_COUNT && (
|
||
<div className="highlight-more">
|
||
<button type="button" className="btn-secondary" onClick={() => setPlaylistExpanded(v => !v)}>
|
||
{playlistExpanded ? 'Show fewer' : `Show all ${matching.length} episodes`}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function FinishedSeriesPage({ content }: { content: SiteContent }) {
|
||
const { id } = useParams<{ id: string }>()
|
||
const book = (content.finishedBooks ?? []).find(b => b.id === id)
|
||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||
usePageMeta(
|
||
book ? `${book.title} | ${siteTitle}` : `Finished Series | ${siteTitle}`,
|
||
book?.description || 'Episode playlist for a completed series.',
|
||
)
|
||
const { episodes, loading } = useEpisodesForBook(book?.season ?? 0)
|
||
|
||
if (!book) {
|
||
return (
|
||
<div className="site">
|
||
<SiteHeader content={content} />
|
||
<main className="page-inner">
|
||
<p>Series not found. <Link to="/finished">← Back to Finished Books</Link></p>
|
||
</main>
|
||
<SiteFooter content={content} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="site">
|
||
<SiteHeader content={content} />
|
||
<SiteSearchBar content={content} />
|
||
<main className="page-inner">
|
||
<nav className="breadcrumb" aria-label="Breadcrumb">
|
||
<Link to="/finished">Finished Books</Link>
|
||
<span aria-hidden="true"> › </span>
|
||
<span>{book.title}</span>
|
||
</nav>
|
||
<div className="finished-series-hero">
|
||
{book.imageUrl && (
|
||
<div className="finished-series-cd" aria-hidden="true">
|
||
<div className="finished-series-cd-disc">
|
||
<img src={book.imageUrl} alt={book.title} className="finished-series-cd-img" />
|
||
<div className="finished-series-cd-sheen" />
|
||
<div className="finished-series-cd-hole" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<p className="finished-book-card-season">Season {book.season}</p>
|
||
<h1 className="finished-series-title">{book.title}</h1>
|
||
{book.description && <p className="finished-series-desc">{book.description}</p>}
|
||
<div className="finished-series-actions">
|
||
<a href="#episodes" className="finished-series-btn-stream">▶ Stream Episodes</a>
|
||
{book.downloadUrl && (
|
||
<a
|
||
href={book.downloadUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="finished-series-btn-download"
|
||
>
|
||
⬇ Download Album
|
||
</a>
|
||
)}
|
||
{book.studySlug && (
|
||
<Link to={`/study/${book.studySlug}`} className="finished-series-btn-download">
|
||
📖 Study This Book
|
||
</Link>
|
||
)}
|
||
{book.guide?.title && (
|
||
<Link to={`/downloads/book-guide--${book.id}`} className="finished-series-btn-download">
|
||
📄 {book.guide.title}
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{book.downloadQrUrl && book.downloadUrl && (
|
||
<div className="finished-series-qr">
|
||
<a href={book.downloadUrl} target="_blank" rel="noopener noreferrer">
|
||
<img src={book.downloadQrUrl} alt={`QR code to download the ${book.title} album`} className="finished-series-qr-img" />
|
||
</a>
|
||
<p className="finished-series-qr-label">Scan to download the full album</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<FinishedSeriesEpisodes highlights={book.highlights ?? []} episodes={episodes} loading={loading} />
|
||
</main>
|
||
<SiteFooter content={content} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function LatestEpisodePlayer({ content }: { content: SiteContent }) {
|
||
const audioRef = useRef<HTMLAudioElement>(null)
|
||
const [audioUrl, setAudioUrl] = useState('')
|
||
const [title, setTitle] = useState('')
|
||
const [playing, setPlaying] = useState(false)
|
||
const [buffering, setBuffering] = useState(false)
|
||
const [currentTime, setCurrentTime] = useState(0)
|
||
const [duration, setDuration] = useState(0)
|
||
const playTrackedRef = useRef(false)
|
||
const listenStartRef = useRef<number | null>(null)
|
||
|
||
useEffect(() => {
|
||
fetch('/api/episodes')
|
||
.then(r => r.ok ? r.json() : Promise.reject())
|
||
.then((data: { episodes?: { title: string; audioUrl: string }[] }) => {
|
||
const latest = data.episodes?.[0]
|
||
if (latest?.audioUrl) { setAudioUrl(latest.audioUrl); setTitle(latest.title) }
|
||
})
|
||
.catch(() => {})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const audio = audioRef.current
|
||
if (!audio) return
|
||
const onTime = () => setCurrentTime(audio.currentTime)
|
||
const onDur = () => setDuration(audio.duration)
|
||
const onEnded = () => { setPlaying(false); setBuffering(false) }
|
||
const onWait = () => setBuffering(true)
|
||
const onCan = () => setBuffering(false)
|
||
audio.addEventListener('timeupdate', onTime)
|
||
audio.addEventListener('durationchange', onDur)
|
||
audio.addEventListener('loadedmetadata', onDur)
|
||
audio.addEventListener('ended', onEnded)
|
||
audio.addEventListener('waiting', onWait)
|
||
audio.addEventListener('canplay', onCan)
|
||
return () => {
|
||
audio.removeEventListener('timeupdate', onTime)
|
||
audio.removeEventListener('durationchange', onDur)
|
||
audio.removeEventListener('loadedmetadata', onDur)
|
||
audio.removeEventListener('ended', onEnded)
|
||
audio.removeEventListener('waiting', onWait)
|
||
audio.removeEventListener('canplay', onCan)
|
||
}
|
||
}, [audioUrl])
|
||
|
||
function togglePlay() {
|
||
const audio = audioRef.current
|
||
if (!audio) return
|
||
if (playing) {
|
||
audio.pause(); setPlaying(false)
|
||
if (listenStartRef.current !== null) listenStartRef.current = null
|
||
} else {
|
||
audio.play().then(() => {
|
||
setPlaying(true)
|
||
listenStartRef.current = Date.now()
|
||
if (!playTrackedRef.current && title) {
|
||
playTrackedRef.current = true
|
||
fetch('/api/analytics/play', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }) }).then(checkAnalyticsConsentResponse).catch(() => {})
|
||
}
|
||
}).catch(() => {})
|
||
}
|
||
}
|
||
|
||
function skip(sec: number) {
|
||
const audio = audioRef.current
|
||
if (!audio) return
|
||
audio.currentTime = Math.max(0, Math.min(duration, audio.currentTime + sec))
|
||
}
|
||
|
||
function seek(e: React.MouseEvent<HTMLDivElement>) {
|
||
const audio = audioRef.current
|
||
if (!audio || !duration) return
|
||
const rect = e.currentTarget.getBoundingClientRect()
|
||
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||
audio.currentTime = pct * duration
|
||
setCurrentTime(pct * duration)
|
||
}
|
||
|
||
function fmt(s: number) {
|
||
if (!isFinite(s) || s < 0) return '0:00'
|
||
return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`
|
||
}
|
||
|
||
const pct = duration > 0 ? (currentTime / duration) * 100 : 0
|
||
|
||
if (!audioUrl) return <div className="episode-embed-skeleton" aria-label="Loading player…" style={{ height: 152 }} />
|
||
|
||
return (
|
||
<div className="latest-episode-player">
|
||
<audio ref={audioRef} src={audioUrl} preload="metadata" />
|
||
<p className="latest-episode-player__show">Verse by Verse with Nate</p>
|
||
{title && <p className="latest-episode-player__title">{title}</p>}
|
||
<div className="latest-episode-player__play-row">
|
||
<button className="latest-episode-player__skip" onClick={() => skip(-15)} aria-label="Back 15 seconds">
|
||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/><text x="12" y="15" textAnchor="middle" fontSize="5.5" fontFamily="sans-serif" fill="currentColor">15</text></svg>
|
||
<span>back</span>
|
||
</button>
|
||
<button className={`latest-episode-player__play${buffering ? ' latest-episode-player__play--buffering' : ''}`} onClick={togglePlay} aria-label={playing ? 'Pause' : 'Play'}>
|
||
{playing
|
||
? <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
|
||
: <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><polygon points="5,3 19,12 5,21"/></svg>
|
||
}
|
||
</button>
|
||
<button className="latest-episode-player__skip" onClick={() => skip(30)} aria-label="Forward 30 seconds">
|
||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12.01 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/><text x="12" y="15" textAnchor="middle" fontSize="5.5" fontFamily="sans-serif" fill="currentColor">30</text></svg>
|
||
<span>fwd</span>
|
||
</button>
|
||
</div>
|
||
<div className="latest-episode-player__bar" onClick={seek} role="slider" aria-label="Seek" aria-valuenow={Math.round(currentTime)} aria-valuemin={0} aria-valuemax={Math.round(duration)} tabIndex={0} onKeyDown={e => { const a = audioRef.current; if (!a) return; if (e.key === 'ArrowRight') a.currentTime = Math.min(duration, a.currentTime + 10); if (e.key === 'ArrowLeft') a.currentTime = Math.max(0, a.currentTime - 10) }}>
|
||
<div className="latest-episode-player__fill" style={{ width: `${pct}%` }} />
|
||
<div className="latest-episode-player__thumb" style={{ left: `${pct}%` }} />
|
||
</div>
|
||
<div className="latest-episode-player__times">
|
||
<span>{fmt(currentTime)}</span>
|
||
<span>{fmt(duration)}</span>
|
||
</div>
|
||
{content.platformSpotifyUrl && (
|
||
<div className="latest-episode-player__spotify-row">
|
||
<a href={content.platformSpotifyUrl} target="_blank" rel="noreferrer" className="latest-episode-player__spotify-link">
|
||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm4.586 14.424a.623.623 0 01-.857.207c-2.348-1.435-5.304-1.76-8.785-.964a.623.623 0 11-.277-1.215c3.809-.87 7.076-.496 9.712 1.115a.623.623 0 01.207.857zm1.223-2.722a.779.779 0 01-1.072.257c-2.687-1.652-6.785-2.131-9.965-1.166a.78.78 0 01-.973-.519.779.779 0 01.519-.972c3.632-1.102 8.147-.568 11.234 1.328a.779.779 0 01.257 1.072zm.105-2.835C14.692 8.95 9.375 8.775 6.297 9.71a.935.935 0 11-.543-1.79c3.532-1.072 9.404-.865 13.115 1.338a.935.935 0 01-.955 1.609z"/></svg>
|
||
Also on Spotify
|
||
</a>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function EpisodesPage({ content }: { content: SiteContent }) {
|
||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||
usePageMeta(
|
||
`Episodes | ${siteTitle}`,
|
||
content.episodesSeoIntro || 'Browse all episodes of Verse by Verse with Nate — expository Bible teaching, one verse at a time.',
|
||
)
|
||
|
||
return (
|
||
<div className="site">
|
||
<SiteHeader content={content} />
|
||
<SiteSearchBar content={content} />
|
||
<PageBanner eyebrow="Episodes" title="Podcast Episodes" desc={content.episodesSeoIntro || 'Expository Bible teaching — one verse at a time.'} />
|
||
|
||
<section className="section-player" aria-label="Current series episodes">
|
||
<div className="section-inner">
|
||
<p className="episode-seo-intro" aria-hidden="true">
|
||
{content.episodesSeoIntro || 'Verse by Verse with Nate is an expository Bible teaching podcast where we go through Scripture one verse at a time. Each episode digs into the text carefully and practically, helping you understand what the Bible says, what it means, and how to live it out. New episodes released regularly — subscribe on Spotify or your favorite podcast platform.'}
|
||
</p>
|
||
<div className="embed-wrap">
|
||
<LatestEpisodePlayer content={content} />
|
||
</div>
|
||
<PlatformButtons content={content} />
|
||
<HeadlinerWidget />
|
||
</div>
|
||
</section>
|
||
|
||
<HomepageNewsletterSection />
|
||
<PodcastHighlightsSection content={content} />
|
||
<CustomBlocksSection content={content} page="episodes" />
|
||
<SiteFooter content={content} />
|
||
<AnalyticsConsentBanner content={content} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
interface NuggetsEpisode {
|
||
title: string
|
||
pubDate: string
|
||
link: string
|
||
audioUrl: string
|
||
description: string
|
||
duration: string
|
||
episode: string
|
||
season: string
|
||
}
|
||
|
||
function NuggetsPage({ content }: { content: SiteContent }) {
|
||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||
const nuggets = content.nuggets ?? DEFAULTS.nuggets
|
||
usePageMeta(
|
||
`${nuggets.title} | ${siteTitle}`,
|
||
nuggets.description || nuggets.tagline || `${nuggets.title} — a companion show from ${siteTitle}.`,
|
||
)
|
||
|
||
const [episodes, setEpisodes] = useState<NuggetsEpisode[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [configured, setConfigured] = useState(false)
|
||
|
||
useEffect(() => {
|
||
fetch('/api/nuggets/episodes/all')
|
||
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
||
.then((d: { episodes?: NuggetsEpisode[]; configured?: boolean }) => {
|
||
setEpisodes(Array.isArray(d.episodes) ? d.episodes : [])
|
||
setConfigured(d.configured !== false)
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => setLoading(false))
|
||
}, [])
|
||
|
||
const [latest, ...rest] = episodes
|
||
const activeHosts = (nuggets.hosts ?? []).filter(h => !h.archived)
|
||
|
||
return (
|
||
<div className="site">
|
||
<SiteHeader content={content} />
|
||
<SiteSearchBar content={content} />
|
||
<PageBanner eyebrow="Sub-Show" title={nuggets.title} desc={nuggets.tagline} />
|
||
|
||
{(nuggets.podcastArtUrl || nuggets.description || nuggets.spotifyUrl || nuggets.appleUrl) && (
|
||
<section className="section-nuggets-intro" aria-label={`About ${nuggets.title}`}>
|
||
<div className="section-inner nuggets-intro-layout">
|
||
{nuggets.podcastArtUrl && <img src={nuggets.podcastArtUrl} alt={nuggets.title} className="nuggets-podcast-art" />}
|
||
<div className="nuggets-intro-body">
|
||
{nuggets.description && <p className="nuggets-description">{nuggets.description}</p>}
|
||
{(nuggets.spotifyUrl || nuggets.appleUrl) && (
|
||
<div className="nuggets-platform-links">
|
||
{nuggets.spotifyUrl && <a href={nuggets.spotifyUrl} target="_blank" rel="noopener noreferrer" className="btn-primary">Listen on Spotify</a>}
|
||
{nuggets.appleUrl && <a href={nuggets.appleUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary">Listen on Apple Podcasts</a>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
<section className="section-nuggets-body" aria-label={`${nuggets.title} hosts and episodes`}>
|
||
<div className="section-inner">
|
||
{activeHosts.length > 0 && (
|
||
<div className="nuggets-hosts-row-wrap">
|
||
<p className="eyebrow" style={{ marginBottom: '1rem' }}>Meet the Hosts</p>
|
||
<div className="nuggets-hosts-row">
|
||
{activeHosts.map(host => (
|
||
<Link key={host.id} to={`/nuggets/hosts/${host.id}`} className="nuggets-host-pill">
|
||
{host.imageUrl && <img src={host.imageUrl} alt={host.name} className="nuggets-host-pill-img" loading="lazy" />}
|
||
<span className="nuggets-host-pill-name">{host.name}</span>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="nuggets-episodes-full">
|
||
<p className="eyebrow">Episodes</p>
|
||
{loading ? (
|
||
<p className="nuggets-episodes-note">Loading episodes…</p>
|
||
) : !configured ? (
|
||
<p className="nuggets-episodes-note">Episodes are coming soon — check back once the show launches.</p>
|
||
) : episodes.length === 0 ? (
|
||
<p className="nuggets-episodes-note">No episodes published yet.</p>
|
||
) : (
|
||
<>
|
||
<div className="nuggets-latest-episode">
|
||
<p className="nuggets-latest-episode-eyebrow">Latest Episode</p>
|
||
<h2 className="nuggets-latest-episode-title">{latest.title}</h2>
|
||
{latest.description && <p className="nuggets-latest-episode-desc">{latest.description}</p>}
|
||
{latest.audioUrl && <EpisodeAudioPlayer src={latest.audioUrl} title={latest.title} size="full" />}
|
||
</div>
|
||
{rest.length > 0 && (
|
||
<ol className="finished-series-playlist nuggets-episode-list">
|
||
{rest.map((ep, i) => (
|
||
<li key={ep.audioUrl || i} className="finished-series-episode">
|
||
<span className="finished-series-ep-num">{ep.episode || i + 2}</span>
|
||
<div className="finished-series-ep-body">
|
||
<p className="finished-series-ep-title">{ep.title}</p>
|
||
{ep.description && <p className="nuggets-latest-episode-desc" style={{ fontSize: '0.88rem', margin: '3px 0 6px' }}>{ep.description}</p>}
|
||
{ep.duration && <p className="finished-series-ep-meta">{ep.duration}</p>}
|
||
{ep.audioUrl && <EpisodeAudioPlayer src={ep.audioUrl} title={ep.title} />}
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<SiteFooter content={content} />
|
||
<AnalyticsConsentBanner content={content} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function NuggetsHostDetailPage({ content }: { content: SiteContent }) {
|
||
const { hostId } = useParams<{ hostId: string }>()
|
||
const nuggets = content.nuggets ?? DEFAULTS.nuggets
|
||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||
const host = (nuggets.hosts ?? []).find(h => h.id === hostId && !h.archived)
|
||
|
||
usePageMeta(
|
||
host ? `${host.name} | ${nuggets.title} | ${siteTitle}` : `Host | ${nuggets.title} | ${siteTitle}`,
|
||
host?.bio || `Meet ${host?.name}, a host on ${nuggets.title}.`,
|
||
)
|
||
|
||
if (!host) {
|
||
return (
|
||
<div className="site">
|
||
<SiteHeader content={content} />
|
||
<SiteSearchBar content={content} />
|
||
<PageBanner eyebrow="Nuggets" title="Host Not Found" desc="This host profile doesn't exist or has been removed." />
|
||
<section className="section-nuggets-body" style={{ textAlign: 'center', paddingBottom: '4rem' }}>
|
||
<Link to="/nuggets" className="btn-secondary">← 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">← Back to {nuggets.title}</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<SiteFooter content={content} />
|
||
<AnalyticsConsentBanner content={content} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ResourcesPage({ content }: { content: SiteContent }) {
|
||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||
usePageMeta(
|
||
`Resources | ${siteTitle}`,
|
||
'Study guides, sermon notes, and free resources from Verse by Verse with Nate.',
|
||
)
|
||
return (
|
||
<div className="site">
|
||
<SiteHeader content={content} />
|
||
<SiteSearchBar content={content} />
|
||
<PageBanner eyebrow="Resources" title="Study Guides & Resources" desc="Free guides, worksheets, and curated resources to help you go deeper in Scripture." />
|
||
<ResourcesHub content={content} downloadPath={id => `/downloads/${buildCustomDownloadPageId(id)}`} fallback={<TruthForLifeWidget type="resourcead" />} />
|
||
<CustomBlocksSection content={content} page="downloads" />
|
||
<SiteFooter content={content} />
|
||
<AnalyticsConsentBanner content={content} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Canonical book list lives in shared/bible-books.js so the client (Scripture
|
||
// Index book matching, below) and the server (server/routes/bible-passage.js,
|
||
// the BSB passage-fetch API) can't drift out of sync with each other.
|
||
const SI_BOOK_MAP: Record<string, string> = {}
|
||
const SI_BOOK_NAME: Record<string, string> = {}
|
||
const SI_BOOK_ORDER: string[] = []
|
||
for (const b of BIBLE_BOOKS) {
|
||
// Admin's "Bible Book" field strips all whitespace (e.g. "1corinthians"), so
|
||
// index both the spaced and unspaced forms of every name/alias.
|
||
for (const key of [b.name.toLowerCase(), ...(b.aliases ?? [])]) {
|
||
SI_BOOK_MAP[key] = b.id
|
||
SI_BOOK_MAP[key.replace(/\s+/g, '')] = b.id
|
||
}
|
||
SI_BOOK_NAME[b.id] = b.name
|
||
SI_BOOK_ORDER.push(b.id)
|
||
}
|
||
|
||
function parseSIRef(ref: string) {
|
||
// Supports same-chapter ranges ("5:15-20") and chapter-spanning ranges ("5:15-6:2").
|
||
const m = ref.match(/^(.*?)\s+(\d+):(\d+)(?:-(?:(\d+):)?(\d+))?$/)
|
||
if (!m) return null
|
||
const bookId = SI_BOOK_MAP[m[1].toLowerCase().replace(/\.\s*/g, ' ').trim()]
|
||
if (!bookId) return null
|
||
const chapter = parseInt(m[2], 10)
|
||
const endChapter = m[4] ? parseInt(m[4], 10) : chapter
|
||
return {
|
||
bookId,
|
||
chapter,
|
||
endChapter,
|
||
verseStart: parseInt(m[3], 10),
|
||
verseEnd: m[5] ? parseInt(m[5], 10) : parseInt(m[3], 10),
|
||
}
|
||
}
|
||
|
||
type HellaoContent = Array<{ type: string; number?: number; content?: Array<{ text?: string } | string> }>
|
||
|
||
function extractVerseRange(chContent: HellaoContent, verseStart: number, verseEnd: number): string {
|
||
const texts: string[] = []
|
||
for (const item of chContent) {
|
||
if (item.type === 'verse' && item.number != null && item.number >= verseStart && item.number <= verseEnd) {
|
||
const t = (item.content ?? []).map(c => (typeof c === 'string' ? c : ((c as { text?: string }).text ?? ''))).join('').trim()
|
||
if (t) texts.push(t)
|
||
}
|
||
}
|
||
return texts.join(' ')
|
||
}
|
||
|
||
interface ScriptureIndexEntry {
|
||
reference: string
|
||
sourceType: 'study' | 'episode'
|
||
sourceTitle: string
|
||
sectionTitle?: string
|
||
url: string
|
||
}
|
||
|
||
interface ScriptureSource { type: 'study' | 'episode'; label: string; url: string }
|
||
|
||
interface ScriptureRow {
|
||
key: string
|
||
displayRef: string
|
||
bookId: string | null
|
||
chapter: number
|
||
endChapter: number
|
||
verseStart: number
|
||
verseEnd: number
|
||
sources: ScriptureSource[]
|
||
}
|
||
|
||
interface ScriptureBookGroup { bookId: string; bookName: string; rows: ScriptureRow[] }
|
||
|
||
function ScriptureIndexPage({ content }: { content: SiteContent }) {
|
||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||
usePageMeta(
|
||
`Scripture Index | ${siteTitle}`,
|
||
'Every passage covered across our studies and episodes, organized by Scripture reference. Find the lesson or episode for any verse.',
|
||
)
|
||
const [entries, setEntries] = useState<ScriptureIndexEntry[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [query, setQuery] = useState('')
|
||
const [testament, setTestament] = useState<'all' | 'ot' | 'nt'>('all')
|
||
const [sourceFilter, setSourceFilter] = useState<'all' | 'episode' | 'study'>('all')
|
||
const [bookFilter, setBookFilter] = useState<string | null>(null)
|
||
const [toggled, setToggled] = useState<Record<string, boolean>>({})
|
||
const [verseTexts, setVerseTexts] = useState<Record<string, string>>({})
|
||
const [shownVerses, setShownVerses] = useState<Set<string>>(new Set())
|
||
const chapterCache = useRef<Map<string, Promise<HellaoContent>>>(new Map())
|
||
|
||
useEffect(() => {
|
||
fetch('/api/public/scripture-index')
|
||
.then(r => r.json())
|
||
.then(d => { if (Array.isArray(d.entries)) setEntries(d.entries) })
|
||
.catch(() => {})
|
||
.finally(() => setLoading(false))
|
||
}, [])
|
||
|
||
// Merge entries that share the same reference (a study lesson and an episode can both
|
||
// cover the same passage) into one row, then group rows by book in canonical Bible order.
|
||
const { bookGroups, otherRows } = useMemo(() => {
|
||
const rowMap = new Map<string, ScriptureRow>()
|
||
const order: string[] = []
|
||
for (const entry of entries) {
|
||
const parsed = parseSIRef(entry.reference)
|
||
const source: ScriptureSource = {
|
||
type: entry.sourceType,
|
||
url: entry.url,
|
||
label: entry.sourceType === 'study'
|
||
? `${entry.sourceTitle}${entry.sectionTitle ? ` — ${entry.sectionTitle}` : ''}`
|
||
: entry.sourceTitle,
|
||
}
|
||
const key = parsed
|
||
? `${parsed.bookId}-${parsed.chapter}-${parsed.verseStart}-${parsed.endChapter}-${parsed.verseEnd}`
|
||
: `other-${entry.reference.trim().toLowerCase()}`
|
||
let row = rowMap.get(key)
|
||
if (!row) {
|
||
const spansChapters = parsed && parsed.endChapter !== parsed.chapter
|
||
row = {
|
||
key,
|
||
displayRef: parsed
|
||
? spansChapters
|
||
? `${SI_BOOK_NAME[parsed.bookId]} ${parsed.chapter}:${parsed.verseStart}-${parsed.endChapter}:${parsed.verseEnd}`
|
||
: `${SI_BOOK_NAME[parsed.bookId]} ${parsed.chapter}:${parsed.verseStart}${parsed.verseEnd !== parsed.verseStart ? `-${parsed.verseEnd}` : ''}`
|
||
: entry.reference.trim(),
|
||
bookId: parsed?.bookId ?? null,
|
||
chapter: parsed?.chapter ?? 0,
|
||
endChapter: parsed?.endChapter ?? parsed?.chapter ?? 0,
|
||
verseStart: parsed?.verseStart ?? 0,
|
||
verseEnd: parsed?.verseEnd ?? 0,
|
||
sources: [],
|
||
}
|
||
rowMap.set(key, row)
|
||
order.push(key)
|
||
}
|
||
if (!row.sources.some(s => s.url === source.url && s.label === source.label)) row.sources.push(source)
|
||
}
|
||
|
||
const groupsByBook = new Map<string, ScriptureRow[]>()
|
||
const other: ScriptureRow[] = []
|
||
for (const k of order) {
|
||
const row = rowMap.get(k)!
|
||
if (!row.bookId) { other.push(row); continue }
|
||
if (!groupsByBook.has(row.bookId)) groupsByBook.set(row.bookId, [])
|
||
groupsByBook.get(row.bookId)!.push(row)
|
||
}
|
||
const groups: ScriptureBookGroup[] = SI_BOOK_ORDER
|
||
.filter(id => groupsByBook.has(id))
|
||
.map(id => ({
|
||
bookId: id,
|
||
bookName: SI_BOOK_NAME[id],
|
||
rows: groupsByBook.get(id)!.sort((a, b) => a.chapter - b.chapter || a.verseStart - b.verseStart),
|
||
}))
|
||
other.sort((a, b) => a.displayRef.localeCompare(b.displayRef))
|
||
return { bookGroups: groups, otherRows: other }
|
||
}, [entries])
|
||
|
||
// Apply the search box and filters
|
||
const needle = query.trim().toLowerCase()
|
||
const rowMatches = (row: ScriptureRow) => {
|
||
if (sourceFilter !== 'all' && !row.sources.some(src => src.type === sourceFilter)) return false
|
||
if (!needle) return true
|
||
return row.displayRef.toLowerCase().includes(needle) || row.sources.some(src => src.label.toLowerCase().includes(needle))
|
||
}
|
||
const isOldTestament = (bookId: string) => SI_BOOK_ORDER.indexOf(bookId) < 39
|
||
const visibleGroups = bookGroups
|
||
.filter(g => testament === 'all' || (testament === 'ot') === isOldTestament(g.bookId))
|
||
.filter(g => !bookFilter || g.bookId === bookFilter)
|
||
.map(g => ({ ...g, rows: g.rows.filter(rowMatches) }))
|
||
.filter(g => g.rows.length > 0 || (needle && g.bookName.toLowerCase().includes(needle)))
|
||
// A search by book name ("titus") should show the whole book
|
||
.map(g => (needle && g.rows.length === 0
|
||
? { ...g, rows: bookGroups.find(x => x.bookId === g.bookId)!.rows.filter(r => sourceFilter === 'all' || r.sources.some(src => src.type === sourceFilter)) }
|
||
: g))
|
||
const visibleOther = testament === 'all' && !bookFilter ? otherRows.filter(rowMatches) : []
|
||
const totalRows = visibleGroups.reduce((n, g) => n + g.rows.length, 0) + visibleOther.length
|
||
const filtering = Boolean(needle) || testament !== 'all' || sourceFilter !== 'all' || Boolean(bookFilter)
|
||
|
||
// With only a few books everything starts open; a long index starts collapsed. Searching opens matches.
|
||
const isOpen = (id: string) => toggled[id] ?? (filtering || bookGroups.length <= 4)
|
||
const setAll = (open: boolean) => {
|
||
const next: Record<string, boolean> = { other: open }
|
||
for (const g of bookGroups) next[g.bookId] = open
|
||
setToggled(next)
|
||
}
|
||
|
||
const fetchChapter = (bookId: string, chapter: number) => {
|
||
const ck = `${bookId}/${chapter}`
|
||
let promise = chapterCache.current.get(ck)
|
||
if (!promise) {
|
||
promise = fetch(`https://bible.helloao.org/api/BSB/${bookId}/${chapter}.json`)
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('chapter failed'))))
|
||
.then((data: { chapter: { content: HellaoContent } }) => data?.chapter?.content ?? [])
|
||
chapterCache.current.set(ck, promise)
|
||
}
|
||
return promise
|
||
}
|
||
|
||
function toggleVerse(row: ScriptureRow) {
|
||
const open = shownVerses.has(row.key)
|
||
setShownVerses(prev => {
|
||
const next = new Set(prev)
|
||
if (open) next.delete(row.key)
|
||
else next.add(row.key)
|
||
return next
|
||
})
|
||
if (open || verseTexts[row.key] !== undefined || !row.bookId) return
|
||
// Chapter-spanning refs (e.g. "5:15-6:2") preview only the starting chapter's verses
|
||
const verseEnd = row.endChapter !== row.chapter ? Number.MAX_SAFE_INTEGER : row.verseEnd
|
||
fetchChapter(row.bookId, row.chapter)
|
||
.then(chContent => setVerseTexts(prev => ({ ...prev, [row.key]: extractVerseRange(chContent, row.verseStart, verseEnd) || '(Verse text unavailable.)' })))
|
||
.catch(() => setVerseTexts(prev => ({ ...prev, [row.key]: '(Verse text unavailable right now.)' })))
|
||
}
|
||
|
||
function renderRow(row: ScriptureRow) {
|
||
const showing = shownVerses.has(row.key)
|
||
return (
|
||
<li key={row.key} className="si-row">
|
||
<div className="si-row-top">
|
||
<span className="si-ref">{row.displayRef}</span>
|
||
<span className="si-sources">
|
||
{row.sources.map((src, i) => (
|
||
<Link key={i} to={src.url} className={`si-chip si-chip--${src.type}`}>
|
||
<span className="si-chip-kind">{src.type === 'episode' ? 'Episode' : 'Study'}</span>
|
||
<span className="si-chip-label">{src.label}</span>
|
||
</Link>
|
||
))}
|
||
</span>
|
||
{row.bookId && (
|
||
<button type="button" className="si-verse-btn" aria-expanded={showing} onClick={() => toggleVerse(row)}>
|
||
{showing ? 'Hide verse' : 'Show verse'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{showing && (
|
||
<p className="si-verse">{verseTexts[row.key] !== undefined ? `“${verseTexts[row.key]}”` : 'Loading…'}</p>
|
||
)}
|
||
</li>
|
||
)
|
||
}
|
||
|
||
const hasContent = bookGroups.length > 0 || otherRows.length > 0
|
||
const pill = (active: boolean) => `si-pill${active ? ' si-pill--on' : ''}`
|
||
|
||
return (
|
||
<div className="site">
|
||
<SiteHeader content={content} />
|
||
<SiteSearchBar content={content} />
|
||
<PageBanner eyebrow="Reference" title="Scripture Index" desc="Every passage covered across our studies and episodes, organized by Scripture reference. Find the lesson or episode for any verse." />
|
||
<main className="si-main">
|
||
<div className="section-inner">
|
||
{loading ? (
|
||
<p className="si-note">Loading…</p>
|
||
) : !hasContent ? (
|
||
<p className="si-note">No Scripture references are published yet.</p>
|
||
) : (
|
||
<>
|
||
<div className="si-toolbar">
|
||
<input
|
||
type="search"
|
||
className="si-search"
|
||
placeholder="Search a passage or book (e.g. Titus 2)…"
|
||
aria-label="Search the Scripture index"
|
||
value={query}
|
||
onChange={e => setQuery(e.target.value)}
|
||
/>
|
||
<div className="si-filters">
|
||
<div className="si-pills" role="group" aria-label="Testament">
|
||
<button type="button" className={pill(testament === 'all')} onClick={() => { setTestament('all'); setBookFilter(null) }}>All</button>
|
||
<button type="button" className={pill(testament === 'ot')} onClick={() => { setTestament('ot'); setBookFilter(null) }}>Old Testament</button>
|
||
<button type="button" className={pill(testament === 'nt')} onClick={() => { setTestament('nt'); setBookFilter(null) }}>New Testament</button>
|
||
</div>
|
||
<div className="si-pills" role="group" aria-label="Source">
|
||
<button type="button" className={pill(sourceFilter === 'all')} onClick={() => setSourceFilter('all')}>Everything</button>
|
||
<button type="button" className={pill(sourceFilter === 'episode')} onClick={() => setSourceFilter('episode')}>Episodes</button>
|
||
<button type="button" className={pill(sourceFilter === 'study')} onClick={() => setSourceFilter('study')}>Studies</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{bookGroups.length > 1 && (
|
||
<div className="si-books" role="group" aria-label="Books covered">
|
||
{bookGroups.map(g => (
|
||
<button
|
||
key={g.bookId}
|
||
type="button"
|
||
className={pill(bookFilter === g.bookId)}
|
||
onClick={() => setBookFilter(bookFilter === g.bookId ? null : g.bookId)}
|
||
>
|
||
{g.bookName} <span>{g.rows.length}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="si-meta">
|
||
<span>{totalRows} passage{totalRows === 1 ? '' : 's'}{filtering ? ' match' : ''}</span>
|
||
<span className="si-meta-actions">
|
||
{filtering && (
|
||
<button type="button" className="si-link" onClick={() => { setQuery(''); setTestament('all'); setSourceFilter('all'); setBookFilter(null) }}>Clear filters</button>
|
||
)}
|
||
<button type="button" className="si-link" onClick={() => setAll(true)}>Expand all</button>
|
||
<button type="button" className="si-link" onClick={() => setAll(false)}>Collapse all</button>
|
||
</span>
|
||
</div>
|
||
|
||
{totalRows === 0 ? (
|
||
<p className="si-note">Nothing matches that. Try a book name like “Titus”, or clear the filters.</p>
|
||
) : (
|
||
<div className="si-groups">
|
||
{visibleGroups.map(group => {
|
||
const open = isOpen(group.bookId)
|
||
return (
|
||
<section key={group.bookId} className="si-group">
|
||
<button type="button" className="si-group-head" aria-expanded={open} onClick={() => setToggled(t => ({ ...t, [group.bookId]: !open }))}>
|
||
<span className="si-group-name">{group.bookName}</span>
|
||
<span className="si-group-count">{group.rows.length} passage{group.rows.length === 1 ? '' : 's'}</span>
|
||
<span className="si-chev" aria-hidden="true">{open ? '−' : '+'}</span>
|
||
</button>
|
||
{open && <ul className="si-list">{group.rows.map(renderRow)}</ul>}
|
||
</section>
|
||
)
|
||
})}
|
||
{visibleOther.length > 0 && (
|
||
<section className="si-group">
|
||
<button type="button" className="si-group-head" aria-expanded={isOpen('other')} onClick={() => setToggled(t => ({ ...t, other: !isOpen('other') }))}>
|
||
<span className="si-group-name">Other references</span>
|
||
<span className="si-group-count">{visibleOther.length} passage{visibleOther.length === 1 ? '' : 's'}</span>
|
||
<span className="si-chev" aria-hidden="true">{isOpen('other') ? '−' : '+'}</span>
|
||
</button>
|
||
{isOpen('other') && <ul className="si-list">{visibleOther.map(renderRow)}</ul>}
|
||
</section>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</main>
|
||
<SiteFooter content={content} />
|
||
<AnalyticsConsentBanner content={content} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function AboutPage({ content }: { content: SiteContent }) {
|
||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||
usePageMeta(
|
||
`About | ${siteTitle}`,
|
||
content.aboutNate || 'Learn about Verse by Verse with Nate — expository Bible teaching from Nate Emmert.',
|
||
)
|
||
return (
|
||
<div className="site">
|
||
<SiteHeader content={content} />
|
||
<SiteSearchBar content={content} />
|
||
<PageBanner eyebrow="About" title="About the Show" />
|
||
<AboutSection content={content} />
|
||
<TestimonialsSection testimonials={content.testimonials ?? []} />
|
||
<KofiSupportSection content={content} />
|
||
<CustomBlocksSection content={content} page="about" />
|
||
<SiteFooter content={content} />
|
||
<AnalyticsConsentBanner content={content} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ContactPage({ content }: { content: SiteContent }) {
|
||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||
usePageMeta(
|
||
`Contact | ${siteTitle}`,
|
||
'Get in touch with Nate — submit a Bible question, testimony, or topic request.',
|
||
)
|
||
return (
|
||
<div className="site">
|
||
<SiteHeader content={content} />
|
||
<SiteSearchBar content={content} />
|
||
<PageBanner eyebrow={content.contactEyebrow || 'Contact'} title={content.contactHeading || 'Get in Touch'} />
|
||
<ContactSection content={content} />
|
||
<KofiSupportSection content={content} />
|
||
<CustomBlocksSection content={content} page="contact" />
|
||
<SiteFooter content={content} />
|
||
<AnalyticsConsentBanner content={content} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Splits "Step 2: Use Q&A to Go Deeper" into its number and title so the number can be
|
||
// rendered as a badge. Titles without a "Step N:" prefix fall back to the position.
|
||
function splitStepTitle(title: string | undefined, fallbackNumber: number) {
|
||
const text = (title ?? '').trim()
|
||
const match = /^step\s*(\d+)\s*[:.\-–—]\s*(.+)$/i.exec(text)
|
||
return match ? { number: match[1], title: match[2] } : { number: String(fallbackNumber), title: text }
|
||
}
|
||
|
||
function StartHerePage({ content }: { content: SiteContent }) {
|
||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||
usePageMeta(
|
||
`Start Here | ${siteTitle}`,
|
||
content.startHereIntro || 'A beginner\'s guide to Verse by Verse with Nate.',
|
||
)
|
||
const steps = [
|
||
{ icon: '🎧', title: content.startHereStep1Title, body: content.startHereStep1Body, cta: content.startHereStep1Cta, to: '/episodes', primary: false },
|
||
{ icon: '🔎', title: content.startHereStep2Title, body: content.startHereStep2Body, cta: content.startHereStep2Cta, to: '/questions', primary: false },
|
||
{ icon: '✉️', title: content.startHereStep3Title, body: content.startHereStep3Body, cta: content.startHereStep3Cta, to: '/contact', primary: true },
|
||
]
|
||
return (
|
||
<div className="site">
|
||
<SiteHeader content={content} />
|
||
<SiteSearchBar content={content} />
|
||
<main className="start-page" aria-labelledby="start-here-heading">
|
||
<header className="start-hero">
|
||
<p className="eyebrow">Verse by Verse with Nate</p>
|
||
<h1 id="start-here-heading">{content.startHereHeading}</h1>
|
||
{content.startHereIntro && <p className="start-intro">{content.startHereIntro}</p>}
|
||
</header>
|
||
|
||
<ol className="start-steps">
|
||
{steps.map((step, index) => {
|
||
const { number, title } = splitStepTitle(step.title, index + 1)
|
||
return (
|
||
<li className="start-step" key={step.to}>
|
||
<div className="start-step-top">
|
||
<span className="start-step-number" aria-hidden="true">{number}</span>
|
||
<span className="start-step-icon" aria-hidden="true">{step.icon}</span>
|
||
</div>
|
||
<p className="start-step-label">Step {number}</p>
|
||
<h2 className="start-step-title">{title}</h2>
|
||
<p className="start-step-body">{step.body}</p>
|
||
<Link to={step.to} className={`${step.primary ? 'btn-primary' : 'btn-secondary'} start-step-cta`}>
|
||
{step.cta} <span aria-hidden="true">→</span>
|
||
</Link>
|
||
</li>
|
||
)
|
||
})}
|
||
</ol>
|
||
|
||
<p className="start-back"><Link to="/">← Back to site</Link></p>
|
||
</main>
|
||
<SiteFooter content={content} />
|
||
<AnalyticsConsentBanner content={content} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ThankYouPage() {
|
||
const navigate = useNavigate()
|
||
const [secondsLeft, setSecondsLeft] = useState(15)
|
||
|
||
useEffect(() => {
|
||
const timeoutId = window.setTimeout(() => {
|
||
navigate('/')
|
||
}, 15000)
|
||
|
||
const intervalId = window.setInterval(() => {
|
||
setSecondsLeft(s => (s > 0 ? s - 1 : 0))
|
||
}, 1000)
|
||
|
||
return () => {
|
||
window.clearTimeout(timeoutId)
|
||
window.clearInterval(intervalId)
|
||
}
|
||
}, [navigate])
|
||
|
||
return (
|
||
<main className="thanks-page" aria-label="Thank you">
|
||
<div className="thanks-card">
|
||
<p className="eyebrow">Message Received</p>
|
||
<h1>Thank You</h1>
|
||
<p>
|
||
Your message was sent successfully. We appreciate you reaching out and will get back to you soon.
|
||
</p>
|
||
<p className="thanks-countdown">Returning to the main site in {secondsLeft} seconds...</p>
|
||
<button type="button" className="btn-primary" onClick={() => navigate('/')}>
|
||
Go Back Now
|
||
</button>
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function SubscribePage() {
|
||
const navigate = useNavigate()
|
||
const [firstName, setFirstName] = useState('')
|
||
const [lastName, setLastName] = useState('')
|
||
const [email, setEmail] = useState('')
|
||
const [honey, setHoney] = useState('')
|
||
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
|
||
const [errorMsg, setErrorMsg] = useState('')
|
||
|
||
async function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
setStatus('submitting')
|
||
setErrorMsg('')
|
||
|
||
try {
|
||
const res = await fetch('/api/contact', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
firstName,
|
||
lastName,
|
||
email,
|
||
messageType: 'general',
|
||
message: 'Newsletter signup from subscribe page',
|
||
subscribe: true,
|
||
_honey: honey,
|
||
}),
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}))
|
||
setErrorMsg((data as { message?: string }).message ?? 'Something went wrong. Please try again.')
|
||
setStatus('error')
|
||
return
|
||
}
|
||
|
||
navigate('/subscribe/thanks')
|
||
} catch {
|
||
setErrorMsg('Could not connect. Please try again later.')
|
||
setStatus('error')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<main className="thanks-page" aria-label="Subscribe">
|
||
<div className="thanks-card subscribe-card">
|
||
<p className="eyebrow">Verse by Verse with Nate</p>
|
||
<h1>Subscribe for Updates</h1>
|
||
<p>
|
||
Get ministry updates and new episode announcements by email.
|
||
</p>
|
||
|
||
<form className="contact-form subscribe-form" onSubmit={handleSubmit} noValidate>
|
||
<input
|
||
type="text"
|
||
className="contact-honeypot"
|
||
tabIndex={-1}
|
||
autoComplete="off"
|
||
aria-hidden="true"
|
||
value={honey}
|
||
onChange={e => setHoney(e.target.value)}
|
||
/>
|
||
<label>
|
||
First Name
|
||
<input
|
||
type="text"
|
||
name="firstName"
|
||
required
|
||
autoComplete="given-name"
|
||
value={firstName}
|
||
onChange={e => setFirstName(e.target.value)}
|
||
/>
|
||
</label>
|
||
<label>
|
||
Last Name
|
||
<input
|
||
type="text"
|
||
name="lastName"
|
||
required
|
||
autoComplete="family-name"
|
||
value={lastName}
|
||
onChange={e => setLastName(e.target.value)}
|
||
/>
|
||
</label>
|
||
<label>
|
||
Email
|
||
<input
|
||
type="email"
|
||
name="email"
|
||
required
|
||
autoComplete="email"
|
||
value={email}
|
||
onChange={e => setEmail(e.target.value)}
|
||
/>
|
||
</label>
|
||
|
||
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
|
||
|
||
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
|
||
{status === 'submitting' ? 'Subscribing…' : 'Subscribe'}
|
||
</button>
|
||
<Link to="/" className="btn-secondary">Back to Site</Link>
|
||
</form>
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function SubscribeThankYouPage() {
|
||
return (
|
||
<main className="thanks-page" aria-label="Subscription confirmed">
|
||
<div className="thanks-card">
|
||
<p className="eyebrow">Verse by Verse with Nate</p>
|
||
<h1>You are subscribed</h1>
|
||
<p>
|
||
Thank you for subscribing. You will receive future ministry updates and episode announcements.
|
||
</p>
|
||
<Link to="/" className="btn-primary">Back to Site</Link>
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function LegalPage({ title, body }: { title: string; body: string[] }) {
|
||
return (
|
||
<main className="thanks-page" aria-label={title}>
|
||
<div className="thanks-card">
|
||
<p className="eyebrow">Verse by Verse with Nate</p>
|
||
<h1>{title}</h1>
|
||
{body.map((line, idx) => (
|
||
<p key={`${title}-${idx}`}>{line}</p>
|
||
))}
|
||
<Link to="/" className="btn-primary">Back to Site</Link>
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
// Certificate links emailed before v2.10.10 pointed at /study/certificate/:token.
|
||
function LegacyCertificateRedirect() {
|
||
const { token } = useParams<{ token: string }>()
|
||
return <Navigate to={`/certificate/${encodeURIComponent(token ?? '')}`} replace />
|
||
}
|
||
|
||
function PublicCertificatePage() {
|
||
const { token } = useParams<{ token: string }>()
|
||
const [cert, setCert] = useState<{ studyTitle: string; displayName: string; issuedAt: string } | null>(null)
|
||
const [status, setStatus] = useState<'loading' | 'not-found' | 'ready'>('loading')
|
||
|
||
useEffect(() => {
|
||
if (!token) { setStatus('not-found'); return }
|
||
fetch(`/api/public/certificate/${encodeURIComponent(token)}`)
|
||
.then(r => r.ok ? r.json() : Promise.reject())
|
||
.then(data => { setCert(data); setStatus('ready') })
|
||
.catch(() => setStatus('not-found'))
|
||
}, [token])
|
||
|
||
usePageMeta(
|
||
cert ? `Certificate of Completion – ${cert.studyTitle}` : 'Certificate',
|
||
cert ? `${cert.displayName} completed ${cert.studyTitle}` : undefined,
|
||
)
|
||
|
||
if (status === 'loading') {
|
||
return (
|
||
<main className="thanks-page">
|
||
<div className="cert-skeleton-wrap" aria-busy="true" aria-label="Loading certificate">
|
||
<div className="cert-skeleton-card">
|
||
<div className="cert-skeleton-line cert-skeleton-line--short" />
|
||
<div className="cert-skeleton-line cert-skeleton-line--title" />
|
||
<div className="cert-skeleton-line cert-skeleton-line--medium" />
|
||
<div className="cert-skeleton-line cert-skeleton-line--short" />
|
||
</div>
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
if (status === 'not-found' || !cert) {
|
||
return (
|
||
<main className="thanks-page">
|
||
<div className="thanks-card">
|
||
<h1>Certificate Not Found</h1>
|
||
<p>This certificate link is invalid or has been removed.</p>
|
||
<Link to="/" className="btn-primary">Back to Site</Link>
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<main className="thanks-page public-cert-page" aria-label="Certificate of Completion">
|
||
<div className="public-cert-card">
|
||
{/* Corner ornaments */}
|
||
<span className="cert-corner cert-corner--tl" aria-hidden="true">✦</span>
|
||
<span className="cert-corner cert-corner--tr" aria-hidden="true">✦</span>
|
||
<span className="cert-corner cert-corner--bl" aria-hidden="true">✦</span>
|
||
<span className="cert-corner cert-corner--br" aria-hidden="true">✦</span>
|
||
|
||
<div className="cert-inner">
|
||
<p className="cert-issuer">✦ Verse by Verse with Nate ✦</p>
|
||
|
||
<div className="cert-ornament-rule">
|
||
<span className="cert-rule-line" />
|
||
<span className="cert-rule-diamond">◆</span>
|
||
<span className="cert-rule-line" />
|
||
</div>
|
||
|
||
<h1 className="cert-title">Certificate<br />of Completion</h1>
|
||
|
||
<div className="cert-ornament-rule cert-ornament-rule--sm">
|
||
<span className="cert-rule-line" />
|
||
<span className="cert-rule-diamond">◆</span>
|
||
<span className="cert-rule-line" />
|
||
</div>
|
||
|
||
<p className="cert-presented">Presented to</p>
|
||
<p className="cert-name">{cert.displayName}</p>
|
||
|
||
<p className="cert-body-text">
|
||
in recognition of faithful study and completion of
|
||
</p>
|
||
<p className="cert-study">{cert.studyTitle}</p>
|
||
|
||
<div className="cert-seal" aria-hidden="true">
|
||
<span className="cert-seal-ring">
|
||
<span className="cert-seal-inner">✦</span>
|
||
</span>
|
||
</div>
|
||
|
||
<div className="cert-ornament-rule cert-ornament-rule--sm">
|
||
<span className="cert-rule-line" />
|
||
<span className="cert-rule-diamond">◆</span>
|
||
<span className="cert-rule-line" />
|
||
</div>
|
||
|
||
<p className="cert-date">
|
||
Awarded on {new Date(cert.issuedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||
</p>
|
||
|
||
<Link to="/" className="cert-back-link">Visit Verse by Verse with Nate →</Link>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: SiteContent) => void }) {
|
||
const [status, setStatus] = useState<'checking' | 'authenticated' | 'unauthenticated' | 'misconfigured'>('checking')
|
||
const [adminContent, setAdminContent] = useState<SiteContent>(content)
|
||
const [password, setPassword] = useState('')
|
||
const [errorMsg, setErrorMsg] = useState('')
|
||
const [submitting, setSubmitting] = useState(false)
|
||
// TOTP two-step state
|
||
const [totpRequired, setTotpRequired] = useState(false)
|
||
const [pendingToken, setPendingToken] = useState('')
|
||
const [totpCode, setTotpCode] = useState('')
|
||
|
||
useEffect(() => {
|
||
fetch('/api/admin-auth/status')
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Status failed'))))
|
||
.then(data => {
|
||
const next = data as { authenticated?: boolean; configured?: boolean }
|
||
if (next.configured === false) {
|
||
setStatus('misconfigured')
|
||
return
|
||
}
|
||
setStatus(next.authenticated ? 'authenticated' : 'unauthenticated')
|
||
})
|
||
.catch(() => {
|
||
setStatus('unauthenticated')
|
||
})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (status === 'authenticated') return
|
||
setAdminContent(content)
|
||
}, [content, status])
|
||
|
||
useEffect(() => {
|
||
if (status !== 'authenticated') return
|
||
|
||
let cancelled = false
|
||
|
||
fetch('/api/admin-content?source=draft')
|
||
.then(r => (r.ok ? r.json() : null))
|
||
.then(data => {
|
||
if (cancelled) return
|
||
if (data?.siteContent) {
|
||
setAdminContent(c => ({ ...c, ...data.siteContent }))
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [status])
|
||
|
||
function handleAdminSave(next: SiteContent) {
|
||
setAdminContent(next)
|
||
onSave(next)
|
||
}
|
||
|
||
async function handleLogin(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
setSubmitting(true)
|
||
setErrorMsg('')
|
||
try {
|
||
const res = await fetch('/api/admin-auth/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ password }),
|
||
})
|
||
const data = await res.json().catch(() => ({})) as { ok?: boolean; totpRequired?: boolean; pendingToken?: string; message?: string }
|
||
if (!res.ok) {
|
||
setErrorMsg(data.message ?? 'Login failed.')
|
||
setSubmitting(false)
|
||
return
|
||
}
|
||
if (data.totpRequired && data.pendingToken) {
|
||
setPendingToken(data.pendingToken)
|
||
setTotpRequired(true)
|
||
setPassword('')
|
||
setSubmitting(false)
|
||
return
|
||
}
|
||
setStatus('authenticated')
|
||
setPassword('')
|
||
} catch {
|
||
setErrorMsg('Login failed.')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
async function handleTotpVerify(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
setSubmitting(true)
|
||
setErrorMsg('')
|
||
try {
|
||
const res = await fetch('/api/admin-auth/totp-verify', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ pendingToken, code: totpCode }),
|
||
})
|
||
const data = await res.json().catch(() => ({})) as { ok?: boolean; usedRecoveryCode?: boolean; remainingRecoveryCodes?: number; message?: string }
|
||
if (!res.ok) {
|
||
setErrorMsg(data.message ?? 'Invalid code.')
|
||
setSubmitting(false)
|
||
return
|
||
}
|
||
setStatus('authenticated')
|
||
setTotpCode('')
|
||
} catch {
|
||
setErrorMsg('Verification failed.')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
function handleBackToPassword() {
|
||
setTotpRequired(false)
|
||
setPendingToken('')
|
||
setTotpCode('')
|
||
setErrorMsg('')
|
||
}
|
||
|
||
async function handleLogout() {
|
||
try {
|
||
await fetch('/api/admin-auth/logout', { method: 'POST' })
|
||
} finally {
|
||
setStatus('unauthenticated')
|
||
setTotpRequired(false)
|
||
setPendingToken('')
|
||
}
|
||
}
|
||
|
||
if (status === 'authenticated') {
|
||
return <AdminPage content={adminContent} onSave={handleAdminSave} onLogout={handleLogout} />
|
||
}
|
||
|
||
return (
|
||
<main className="admin-auth-page" aria-label="Admin sign in">
|
||
<div className="admin-auth-card">
|
||
<p className="eyebrow">Admin Access</p>
|
||
<h1>{status === 'misconfigured' ? 'Admin Not Configured' : 'Sign in to Admin'}</h1>
|
||
{status === 'checking' && <p className="admin-auth-note">Checking session...</p>}
|
||
{status === 'misconfigured' && (
|
||
<p className="admin-auth-note">Set the ADMIN_PASSWORD environment variable on the server to enable admin login.</p>
|
||
)}
|
||
{status === 'unauthenticated' && !totpRequired && (
|
||
<form className="admin-auth-form" onSubmit={handleLogin}>
|
||
<label>
|
||
Password
|
||
<input
|
||
type="password"
|
||
value={password}
|
||
onChange={e => setPassword(e.target.value)}
|
||
autoComplete="current-password"
|
||
required
|
||
/>
|
||
</label>
|
||
{errorMsg && <p className="admin-auth-error">{errorMsg}</p>}
|
||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||
{submitting ? 'Signing In…' : 'Sign In'}
|
||
</button>
|
||
<Link to="/" className="btn-secondary">Back to Site</Link>
|
||
</form>
|
||
)}
|
||
{status === 'unauthenticated' && totpRequired && (
|
||
<form className="admin-auth-form" onSubmit={handleTotpVerify}>
|
||
<p className="admin-auth-note">Enter the 6-digit code from your authenticator app, or one of your recovery codes.</p>
|
||
<label>
|
||
Code
|
||
<input
|
||
type="text"
|
||
inputMode="numeric"
|
||
value={totpCode}
|
||
onChange={e => setTotpCode(e.target.value)}
|
||
autoComplete="one-time-code"
|
||
placeholder="000000 or XXXX-XXXX-XXXX"
|
||
autoFocus
|
||
required
|
||
/>
|
||
</label>
|
||
{errorMsg && <p className="admin-auth-error">{errorMsg}</p>}
|
||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||
{submitting ? 'Verifying…' : 'Verify'}
|
||
</button>
|
||
<button type="button" className="btn-secondary" onClick={handleBackToPassword}>Back</button>
|
||
</form>
|
||
)}
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function QuestionsPage({ content }: { content: SiteContent }) {
|
||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||
usePageMeta(
|
||
`Q&A | ${siteTitle}`,
|
||
'Real questions answered from Scripture — browse topics, search, and submit your own.',
|
||
)
|
||
|
||
return (
|
||
<div className="site">
|
||
<SiteHeader content={content} />
|
||
<SiteSearchBar content={content} />
|
||
<PageBanner eyebrow="Q&A" title="Questions & Answers" desc="Real questions answered from Scripture — browse by topic or submit your own." />
|
||
<QASection />
|
||
<CustomBlocksSection content={content} page="questions" />
|
||
<SiteFooter content={content} />
|
||
<AnalyticsConsentBanner content={content} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
interface NewsletterIssueSummary {
|
||
id: string
|
||
slug: string
|
||
title: string
|
||
subject: string
|
||
description: string
|
||
sentAt: string | null
|
||
publishedAt: string
|
||
}
|
||
|
||
function NewsletterArchivePage({ content }: { content: SiteContent }) {
|
||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||
usePageMeta(
|
||
`Newsletter | ${siteTitle}`,
|
||
'Read past newsletter issues from Verse by Verse with Nate — episode notes, study excerpts, and Greek word studies.',
|
||
)
|
||
const [issues, setIssues] = useState<NewsletterIssueSummary[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
|
||
useEffect(() => {
|
||
fetch('/api/newsletter/issues')
|
||
.then(r => r.json())
|
||
.then(d => { if (Array.isArray(d.issues)) setIssues(d.issues) })
|
||
.catch(() => {})
|
||
.finally(() => setLoading(false))
|
||
}, [])
|
||
|
||
return (
|
||
<div className="site">
|
||
<SiteHeader content={content} />
|
||
<SiteSearchBar content={content} />
|
||
<PageBanner eyebrow="Newsletter" title="Newsletter Archive" desc="Past issues of the Verse by Verse with Nate newsletter — episode notes, study excerpts, and Greek word studies." />
|
||
<section className="section-resources" aria-label="Newsletter archive">
|
||
<div className="section-inner">
|
||
{loading ? (
|
||
<p className="episode-seo-intro">Loading issues…</p>
|
||
) : issues.length === 0 ? (
|
||
<p className="episode-seo-intro">No newsletter issues published yet.</p>
|
||
) : (
|
||
<div className="resources-list">
|
||
{issues.map(issue => (
|
||
<a key={issue.id} href={`/newsletter/${issue.slug}`} className="resource-download-card">
|
||
<div className="resource-download-header">
|
||
<div className="resource-download-meta">
|
||
<span className="resource-link-label">{issue.title}</span>
|
||
{issue.description && <p className="resource-link-description">{issue.description}</p>}
|
||
{issue.sentAt && (
|
||
<span className="resource-link-tags">
|
||
{new Date(issue.sentAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||
</span>
|
||
)}
|
||
<span className="resource-link-action">Read issue →</span>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
<CustomBlocksSection content={content} page="newsletter" />
|
||
<SiteFooter content={content} />
|
||
<AnalyticsConsentBanner content={content} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PreviewPage() {
|
||
const [content, setContent] = useState<SiteContent | null>(null)
|
||
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||
const [previewView, setPreviewView] = useState<string>('homepage')
|
||
|
||
useEffect(() => {
|
||
fetch('/api/admin-content?source=draft')
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Preview failed'))))
|
||
.then(data => {
|
||
if (data?.siteContent) {
|
||
setContent({ ...DEFAULTS, ...data.siteContent })
|
||
setStatus('ready')
|
||
return
|
||
}
|
||
setStatus('error')
|
||
})
|
||
.catch(() => {
|
||
setStatus('error')
|
||
})
|
||
}, [])
|
||
|
||
// Listen for live content updates + view from the admin parent frame
|
||
useEffect(() => {
|
||
function handleMessage(e: MessageEvent) {
|
||
if (e.origin !== window.location.origin) return
|
||
if (e.data?.type === 'admin-preview-content' && e.data.content) {
|
||
setContent({ ...DEFAULTS, ...e.data.content })
|
||
setStatus('ready')
|
||
if (e.data.view) setPreviewView(e.data.view)
|
||
}
|
||
}
|
||
window.addEventListener('message', handleMessage)
|
||
return () => window.removeEventListener('message', handleMessage)
|
||
}, [])
|
||
|
||
if (status === 'loading') {
|
||
return (
|
||
<main className="thanks-page" aria-label="Draft preview loading">
|
||
<div className="thanks-card">
|
||
<p className="eyebrow">Admin Preview</p>
|
||
<h1>Loading draft preview...</h1>
|
||
<p>Only signed-in admins can view this page.</p>
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
if (status === 'error' || !content) {
|
||
return (
|
||
<main className="thanks-page" aria-label="Draft preview unavailable">
|
||
<div className="thanks-card">
|
||
<p className="eyebrow">Admin Preview</p>
|
||
<h1>Draft preview unavailable</h1>
|
||
<p>Sign into admin and save a draft first.</p>
|
||
<Link to="/admin" className="btn-primary">Back to Admin</Link>
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
if (previewView === 'start-here') return <StartHerePage content={content} />
|
||
if (previewView === 'about') return <AboutPage content={content} />
|
||
if (previewView === 'contact') return <ContactPage content={content} />
|
||
if (previewView === 'current-series' || previewView === 'episode-highlights') {
|
||
return <EpisodesPage content={content} />
|
||
}
|
||
return <LandingPage content={content} />
|
||
}
|
||
|
||
function StudyRouteFrame({ content, child }: { content: SiteContent; child: ReactElement }) {
|
||
return (
|
||
<>
|
||
<SiteHeader content={content} />
|
||
<SiteSearchBar content={content} />
|
||
{child}
|
||
<SiteFooter content={content} />
|
||
</>
|
||
)
|
||
}
|
||
|
||
export default function App() {
|
||
const [content, setContent] = useState<SiteContent>(DEFAULTS)
|
||
const navigate = useNavigate()
|
||
const location = useLocation()
|
||
usePageTracking()
|
||
useScrollDepthTracking()
|
||
useTimeOnPage()
|
||
useUTMCapture()
|
||
useLinkClickTracking()
|
||
|
||
useEffect(() => {
|
||
const TARGET = 'salvation'
|
||
let buffer = ''
|
||
function onKeyDown(e: KeyboardEvent) {
|
||
const tag = (e.target as HTMLElement)?.tagName
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return
|
||
buffer = (buffer + e.key).slice(-TARGET.length)
|
||
if (buffer === TARGET) {
|
||
buffer = ''
|
||
window.location.assign('/salvation')
|
||
}
|
||
}
|
||
document.addEventListener('keydown', onKeyDown)
|
||
return () => document.removeEventListener('keydown', onKeyDown)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
fetch('/api/admin-content')
|
||
.then(r => (r.ok ? r.json() : null))
|
||
.then(data => {
|
||
if (data?.siteContent) {
|
||
setContent(c => ({ ...c, ...data.siteContent }))
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const kofiUrl = content.platformKofiUrl
|
||
if (!kofiUrl) return
|
||
const username = kofiUrl.replace(/\/$/, '').split('/').pop()
|
||
if (!username) return
|
||
if (document.getElementById('kofi-overlay-script')) return
|
||
const script = document.createElement('script')
|
||
script.id = 'kofi-overlay-script'
|
||
script.src = 'https://storage.ko-fi.com/cdn/scripts/overlay-widget.umd.min.js'
|
||
script.onload = () => {
|
||
;(window as unknown as { kofiWidgetOverlay?: { draw: (user: string, opts: Record<string, string>) => void } }).kofiWidgetOverlay?.draw(username, {
|
||
type: 'floating-chat',
|
||
'floating-chat.donateButton.text': 'Support the Show',
|
||
'floating-chat.donateButton.background-color': '#29abe0',
|
||
'floating-chat.donateButton.text-color': '#fff',
|
||
})
|
||
}
|
||
document.body.appendChild(script)
|
||
}, [content.platformKofiUrl])
|
||
|
||
useEffect(() => {
|
||
if (location.pathname !== '/' || !location.hash) return
|
||
|
||
const legacyHashRouteMap: { [key: string]: string } = {
|
||
'#listen': '/episodes',
|
||
'#about': '/about',
|
||
'#qa': '/questions',
|
||
'#contact': '/contact',
|
||
'#series': '/resources',
|
||
'#resources': '/resources',
|
||
'#start-here': '/start-here',
|
||
}
|
||
|
||
const redirectPath = legacyHashRouteMap[location.hash.toLowerCase()]
|
||
if (!redirectPath) return
|
||
|
||
navigate(redirectPath, { replace: true })
|
||
}, [location.hash, location.pathname, navigate])
|
||
|
||
useEffect(() => {
|
||
const seo = content.seo ?? DEFAULTS.seo
|
||
|
||
const ensureMeta = (selector: string, createAttrs: { [key: string]: string }) => {
|
||
const found = document.head.querySelector(selector)
|
||
if (found) return found as HTMLMetaElement
|
||
const meta = document.createElement('meta')
|
||
Object.entries(createAttrs).forEach(([key, value]) => {
|
||
meta.setAttribute(key, value)
|
||
})
|
||
document.head.appendChild(meta)
|
||
return meta
|
||
}
|
||
|
||
const ensureCanonical = () => {
|
||
const found = document.head.querySelector('link[rel="canonical"]')
|
||
if (found) return found as HTMLLinkElement
|
||
const link = document.createElement('link')
|
||
link.setAttribute('rel', 'canonical')
|
||
document.head.appendChild(link)
|
||
return link
|
||
}
|
||
|
||
document.title = seo.title || DEFAULTS.seo.title
|
||
ensureMeta('meta[name="description"]', { name: 'description' }).setAttribute('content', seo.description || DEFAULTS.seo.description)
|
||
ensureMeta('meta[name="robots"]', { name: 'robots' }).setAttribute('content', seo.robotsPolicy || DEFAULTS.seo.robotsPolicy)
|
||
ensureMeta('meta[property="og:title"]', { property: 'og:title' }).setAttribute('content', seo.ogTitle || seo.title || DEFAULTS.seo.ogTitle)
|
||
ensureMeta('meta[property="og:description"]', { property: 'og:description' }).setAttribute('content', seo.ogDescription || seo.description || DEFAULTS.seo.ogDescription)
|
||
ensureMeta('meta[property="og:image"]', { property: 'og:image' }).setAttribute('content', seo.ogImage || DEFAULTS.seo.ogImage)
|
||
ensureMeta('meta[property="og:url"]', { property: 'og:url' }).setAttribute('content', seo.canonicalUrl || DEFAULTS.seo.canonicalUrl)
|
||
ensureCanonical().setAttribute('href', seo.canonicalUrl || DEFAULTS.seo.canonicalUrl)
|
||
}, [content])
|
||
|
||
return (
|
||
<>
|
||
<ScrollToTop />
|
||
<Routes>
|
||
<Route path="/" element={<LandingPage content={content} />} />
|
||
<Route path="/start-here" element={<StartHerePage content={content} />} />
|
||
<Route path="/study" element={<StudyRouteFrame content={content} child={<StudyLandingPage content={content} />} />} />
|
||
<Route path="/study/signup" element={<StudyRouteFrame content={content} child={<StudySignupPage />} />} />
|
||
<Route path="/study/reset-password" element={<StudyRouteFrame content={content} child={<StudyResetPasswordPage />} />} />
|
||
<Route path="/study/verify-email" element={<StudyRouteFrame content={content} child={<StudyVerifyEmailPage />} />} />
|
||
<Route path="/study/account" element={<StudyRouteFrame content={content} child={<StudyAccountPage />} />} />
|
||
<Route path="/study/discussion" element={<StudyRouteFrame content={content} child={<StudyDiscussionHubPage content={content} />} />} />
|
||
<Route path="/study/certificate/:token" element={<LegacyCertificateRedirect />} />
|
||
<Route path="/study/:studySlug" element={<StudyRouteFrame content={content} child={<ColossiansStudyIndexPage content={content} />} />} />
|
||
<Route path="/study/:studySlug/community" element={<StudyRouteFrame content={content} child={<StudyCommunityPage content={content} />} />} />
|
||
<Route path="/study/:studySlug/notes" element={<StudyRouteFrame content={content} child={<ColossiansStudyNotesPage content={content} />} />} />
|
||
<Route path="/study/:studySlug/:sectionId/quiz" element={<StudyRouteFrame content={content} child={<StudyQuizPage content={content} />} />} />
|
||
<Route path="/study/:studySlug/:sectionId" element={<StudyRouteFrame content={content} child={<ColossiansStudySectionPage content={content} />} />} />
|
||
<Route path="/finished" element={<FinishedBooksPage content={content} />} />
|
||
<Route path="/finished/:id" element={<FinishedSeriesPage content={content} />} />
|
||
<Route path="/episodes" element={<EpisodesPage content={content} />} />
|
||
<Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} />
|
||
<Route path="/nuggets" element={<NuggetsPage content={content} />} />
|
||
<Route path="/nuggets/hosts/:hostId" element={<NuggetsHostDetailPage content={content} />} />
|
||
<Route path="/resources" element={<ResourcesPage content={content} />} />
|
||
<Route path="/downloads/:id" element={<DownloadDetailPage content={content} />} />
|
||
<Route path="/about" element={<AboutPage content={content} />} />
|
||
<Route path="/contact" element={<ContactPage content={content} />} />
|
||
<Route path="/questions" element={<QuestionsPage content={content} />} />
|
||
<Route path="/newsletter" element={<NewsletterArchivePage content={content} />} />
|
||
<Route path="/scripture" element={<ScriptureIndexPage content={content} />} />
|
||
<Route path="/thanks" element={<ThankYouPage />} />
|
||
<Route path="/subscribe" element={<SubscribePage />} />
|
||
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
|
||
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
|
||
<Route path="/host" element={<NuggetsHostPortal />} />
|
||
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
|
||
<Route path="/preview" element={<PreviewPage />} />
|
||
<Route
|
||
path="/privacy"
|
||
element={(
|
||
<LegalPage
|
||
title={content.legal?.privacyTitle ?? DEFAULTS.legal.privacyTitle}
|
||
body={content.legal?.privacyBody ?? DEFAULTS.legal.privacyBody}
|
||
/>
|
||
)}
|
||
/>
|
||
<Route
|
||
path="/terms"
|
||
element={(
|
||
<LegalPage
|
||
title={content.legal?.termsTitle ?? DEFAULTS.legal.termsTitle}
|
||
body={content.legal?.termsBody ?? DEFAULTS.legal.termsBody}
|
||
/>
|
||
)}
|
||
/>
|
||
<Route path="*" element={<NotFoundPage />} />
|
||
</Routes>
|
||
<BackToTopButton />
|
||
</>
|
||
)
|
||
}
|
||
|
||
function NotFoundPage() {
|
||
const location = useLocation()
|
||
const [showForm, setShowForm] = useState(false)
|
||
const [form, setForm] = useState({
|
||
firstName: '',
|
||
email: '',
|
||
message: `Hi Nate, I tried visiting "${location.pathname}" and got a 404. You may want to check if this link is broken.`,
|
||
})
|
||
const [submitStatus, setSubmitStatus] = useState<'idle' | 'busy' | 'sent' | 'error'>('idle')
|
||
const [errMsg, setErrMsg] = useState('')
|
||
|
||
useEffect(() => {
|
||
sendEvent('not_found', { path: location.pathname })
|
||
}, [location.pathname])
|
||
|
||
async function handleReport(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
setSubmitStatus('busy')
|
||
try {
|
||
const res = await fetch('/api/contact', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
firstName: form.firstName,
|
||
email: form.email,
|
||
message: form.message,
|
||
messageType: 'question',
|
||
_honey: '',
|
||
}),
|
||
})
|
||
const data = await res.json()
|
||
if (!res.ok) throw new Error(data.message ?? 'Something went wrong.')
|
||
setSubmitStatus('sent')
|
||
} catch (err) {
|
||
setErrMsg(err instanceof Error ? err.message : 'Something went wrong.')
|
||
setSubmitStatus('error')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<main className="thanks-page" aria-label="Page not found">
|
||
<div className="thanks-card">
|
||
<img
|
||
src="/book_icon.png"
|
||
alt=""
|
||
aria-hidden="true"
|
||
style={{ width: '72px', marginBottom: '0.75rem', opacity: 0.55 }}
|
||
/>
|
||
<p className="eyebrow">404</p>
|
||
<h1>Page Not Found</h1>
|
||
<p>
|
||
The path <code style={{ fontSize: '0.95em', opacity: 0.85 }}>{location.pathname}</code>{' '}
|
||
doesn't exist — it may have moved or been removed.
|
||
</p>
|
||
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', flexWrap: 'wrap', marginBottom: '2rem' }}>
|
||
<Link to="/" className="btn-primary">← Back to Home</Link>
|
||
<Link to="/episodes" className="btn-secondary">Browse Episodes</Link>
|
||
</div>
|
||
<div style={{ borderTop: '1px solid rgba(201,168,76,0.15)', paddingTop: '1.5rem' }}>
|
||
{submitStatus !== 'sent' ? (
|
||
<>
|
||
<p style={{ fontSize: '0.9rem', marginBottom: showForm ? '1rem' : 0 }}>
|
||
Think this is a broken link?{' '}
|
||
{!showForm && (
|
||
<button
|
||
onClick={() => setShowForm(true)}
|
||
style={{
|
||
background: 'none', border: 'none', color: 'var(--brand-gold)',
|
||
cursor: 'pointer', fontFamily: 'inherit', fontSize: 'inherit',
|
||
textDecoration: 'underline', padding: 0,
|
||
}}
|
||
>
|
||
Let Nate know.
|
||
</button>
|
||
)}
|
||
</p>
|
||
{showForm && (
|
||
<form className="contact-form" onSubmit={handleReport} style={{ textAlign: 'left' }}>
|
||
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap' }}>
|
||
<label style={{ flex: '1', minWidth: '120px' }}>
|
||
Your name
|
||
<input
|
||
type="text"
|
||
value={form.firstName}
|
||
onChange={e => setForm(f => ({ ...f, firstName: e.target.value }))}
|
||
placeholder="First name"
|
||
required
|
||
maxLength={100}
|
||
/>
|
||
</label>
|
||
<label style={{ flex: '2', minWidth: '180px' }}>
|
||
Email
|
||
<input
|
||
type="email"
|
||
value={form.email}
|
||
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
||
placeholder="you@example.com"
|
||
required
|
||
/>
|
||
</label>
|
||
</div>
|
||
<label>
|
||
Message
|
||
<textarea
|
||
value={form.message}
|
||
onChange={e => setForm(f => ({ ...f, message: e.target.value }))}
|
||
rows={3}
|
||
maxLength={3000}
|
||
required
|
||
/>
|
||
</label>
|
||
<input type="text" name="_honey" style={{ display: 'none' }} aria-hidden="true" tabIndex={-1} />
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||
<button type="submit" className="btn-secondary" disabled={submitStatus === 'busy'}>
|
||
{submitStatus === 'busy' ? 'Sending…' : 'Report Broken Link'}
|
||
</button>
|
||
{submitStatus === 'error' && (
|
||
<span style={{ color: '#e05c5c', fontSize: '0.9rem' }}>{errMsg}</span>
|
||
)}
|
||
</div>
|
||
</form>
|
||
)}
|
||
</>
|
||
) : (
|
||
<p style={{ color: '#6fcf97' }}>Thanks! Nate will look into it.</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
function BackToTopButton() {
|
||
const [visible, setVisible] = useState(false)
|
||
|
||
useEffect(() => {
|
||
const onScroll = () => setVisible(window.scrollY > 320)
|
||
window.addEventListener('scroll', onScroll)
|
||
onScroll()
|
||
return () => window.removeEventListener('scroll', onScroll)
|
||
}, [])
|
||
|
||
if (!visible) return null
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
className="back-to-top"
|
||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||
aria-label="Scroll back to top"
|
||
>
|
||
↑ Top
|
||
</button>
|
||
)
|
||
}
|