2276 lines
85 KiB
TypeScript
2276 lines
85 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import type { ReactElement } from 'react'
|
|
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
|
|
import AdminPage from './AdminPage'
|
|
import QASection from './components/QASection'
|
|
import ContactForm from './components/ContactForm'
|
|
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage } from './colossiansStudy'
|
|
import { SpotifyIcon } from './icons'
|
|
import type { SiteContent, ArchivedSeries, StudyProgram } from './content'
|
|
import { DEFAULTS } from './content'
|
|
import './App.css'
|
|
|
|
const SPOTIFY_EMBED_URL =
|
|
'https://open.spotify.com/embed/show/0Gq1TzoJOdReSZ1gYQi8Xl?utm_source=generator&theme=0'
|
|
const CONSENT_KEY = 'vbn_analytics_consent_choice'
|
|
const HEADLINER_WIDGET_ID = 'WI_cmou3b4q7000701p0o9qmmcfj'
|
|
|
|
function usePageTracking() {
|
|
const location = useLocation()
|
|
useEffect(() => {
|
|
if (localStorage.getItem(CONSENT_KEY) !== 'accepted') return
|
|
const referrer = document.referrer || ''
|
|
fetch('/api/analytics/pageview', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ path: location.pathname, referrer }),
|
|
keepalive: true,
|
|
}).catch(() => {})
|
|
}, [location.pathname])
|
|
}
|
|
|
|
function toSpotifyEpisodeEmbedUrl(url: string | undefined): string {
|
|
if (!url) return ''
|
|
|
|
try {
|
|
const parsed = new URL(url)
|
|
const host = parsed.hostname.toLowerCase()
|
|
const parts = parsed.pathname.split('/').filter(Boolean)
|
|
|
|
if (host === 'open.spotify.com') {
|
|
if (parts[0] === 'embed' && parts[1] === 'episode' && parts[2]) {
|
|
return `https://open.spotify.com/embed/episode/${parts[2]}?utm_source=generator`
|
|
}
|
|
if (parts[0] === 'episode' && parts[1]) {
|
|
return `https://open.spotify.com/embed/episode/${parts[1]}?utm_source=generator`
|
|
}
|
|
}
|
|
} catch {
|
|
return ''
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
function isLikelySpotifyEpisodeUrl(url: string | undefined): boolean {
|
|
if (!url) return false
|
|
if (toSpotifyEpisodeEmbedUrl(url)) return true
|
|
|
|
try {
|
|
const parsed = new URL(url)
|
|
const host = parsed.hostname.toLowerCase()
|
|
const path = parsed.pathname.toLowerCase()
|
|
|
|
if (host === 'creators.spotify.com' && path.includes('/episodes/')) return true
|
|
if (host === 'anchor.fm' && path.includes('/episodes/')) return true
|
|
if (host === 'podcasters.spotify.com' && path.includes('/episodes/')) return true
|
|
} catch {
|
|
return false
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
function getHomepageFeaturedStudy(content: SiteContent): StudyProgram | null {
|
|
const studies = Array.isArray(content.studies) ? content.studies : []
|
|
if (studies.length === 0) return null
|
|
return studies.find(study => study.showOnHomepage === true)
|
|
?? studies.find(study => study.status === 'active')
|
|
?? studies[0]
|
|
}
|
|
|
|
function HeadlinerWidget() {
|
|
const [iframeSrc, setIframeSrc] = useState('')
|
|
const [status, setStatus] = useState<'loading' | 'ready' | 'empty'>('loading')
|
|
|
|
useEffect(() => {
|
|
const getDiscoUrl = () => {
|
|
const canonicalHref = document.querySelector('link[rel="canonical"]')?.getAttribute('href')
|
|
const ogUrl = document.querySelector('meta[property="og:url"]')?.getAttribute('content')
|
|
const base = canonicalHref || ogUrl
|
|
if (!base) return window.location.href
|
|
try {
|
|
const parsed = new URL(base)
|
|
return `${parsed.origin}${window.location.pathname}`
|
|
} catch {
|
|
return window.location.href
|
|
}
|
|
}
|
|
|
|
const discoUrl = getDiscoUrl()
|
|
const discoTitle = document.querySelector('meta[property="og:title"]')?.getAttribute('content') || document.title
|
|
const src = `https://disco.headliner.link/d/web/widget.html?widgetId=${encodeURIComponent(HEADLINER_WIDGET_ID)}&url=${encodeURIComponent(discoUrl)}&title=${encodeURIComponent(discoTitle)}`
|
|
|
|
const sessionId = `SS_siteforge_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
|
|
const query = new URLSearchParams({
|
|
sessionId,
|
|
url: discoUrl,
|
|
widgetId: HEADLINER_WIDGET_ID,
|
|
})
|
|
|
|
fetch(`https://api.headliner.link/api/v1/widget/widget-request?${query.toString()}`)
|
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('widget request failed'))))
|
|
.then((data: { results?: unknown[] }) => {
|
|
if (Array.isArray(data.results) && data.results.length > 0) {
|
|
setIframeSrc(src)
|
|
setStatus('ready')
|
|
return
|
|
}
|
|
setStatus('empty')
|
|
})
|
|
.catch(() => {
|
|
// If API probing fails, still attempt iframe rendering.
|
|
setIframeSrc(src)
|
|
setStatus('ready')
|
|
})
|
|
}, [])
|
|
|
|
return (
|
|
status === 'empty' ? null : (
|
|
<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 StudyDownloadForm({ buttonText = 'Download Guide' }: { buttonText?: string }) {
|
|
const [fields, setFields] = useState({ firstName: '', lastName: '', email: '' })
|
|
const [subscribe, setSubscribe] = useState(true)
|
|
const [honey, setHoney] = useState('')
|
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'error' | 'success'>('idle')
|
|
const [errorMsg, setErrorMsg] = useState('')
|
|
const [successMsg, setSuccessMsg] = useState('')
|
|
const [downloadUrl, setDownloadUrl] = useState('')
|
|
|
|
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
|
|
}
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setStatus('submitting')
|
|
setErrorMsg('')
|
|
setSuccessMsg('')
|
|
setDownloadUrl('')
|
|
|
|
try {
|
|
const res = await fetch('/api/study-downloads/titus', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...fields, subscribe, _honey: honey }),
|
|
})
|
|
|
|
const data = await res.json().catch(() => ({})) as { message?: string; downloadUrl?: string }
|
|
if (!res.ok || !data.downloadUrl) {
|
|
setErrorMsg(data.message ?? 'Could not process your request. Please try again.')
|
|
setStatus('error')
|
|
return
|
|
}
|
|
|
|
setStatus('success')
|
|
setSuccessMsg('Your download should start now. If not, use the link below.')
|
|
setDownloadUrl(data.downloadUrl)
|
|
window.location.assign(data.downloadUrl)
|
|
} catch {
|
|
setErrorMsg('Could not connect. Please try again later.')
|
|
setStatus('error')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<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">{successMsg}</p>}
|
|
{status === 'success' && downloadUrl && (
|
|
<p className="study-download-success">
|
|
<a href={downloadUrl}>Click here if your download does not start automatically.</a>
|
|
</p>
|
|
)}
|
|
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
|
|
{status === 'submitting' ? 'Preparing Download...' : buttonText}
|
|
</button>
|
|
</form>
|
|
)
|
|
}
|
|
|
|
function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string; buttonText: string }) {
|
|
const [fields, setFields] = useState({ firstName: '', lastName: '', email: '' })
|
|
const [subscribe, setSubscribe] = useState(true)
|
|
const [honey, setHoney] = useState('')
|
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'error' | 'success'>('idle')
|
|
const [errorMsg, setErrorMsg] = useState('')
|
|
const [successMsg, setSuccessMsg] = useState('')
|
|
const [downloadUrl, setDownloadUrl] = useState('')
|
|
|
|
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
|
|
}
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setStatus('submitting')
|
|
setErrorMsg('')
|
|
setSuccessMsg('')
|
|
setDownloadUrl('')
|
|
|
|
try {
|
|
const res = await fetch('/api/resource-download', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ resourceId, ...fields, subscribe, _honey: honey }),
|
|
})
|
|
|
|
const data = await res.json().catch(() => ({})) as { message?: string; downloadUrl?: string }
|
|
if (!res.ok || !data.downloadUrl) {
|
|
setErrorMsg(data.message ?? 'Could not process your request. Please try again.')
|
|
setStatus('error')
|
|
return
|
|
}
|
|
|
|
setStatus('success')
|
|
setSuccessMsg('Your download should start now. If not, use the link below.')
|
|
setDownloadUrl(data.downloadUrl)
|
|
window.location.assign(data.downloadUrl)
|
|
} catch {
|
|
setErrorMsg('Could not connect. Please try again later.')
|
|
setStatus('error')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<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">{successMsg}</p>}
|
|
{status === 'success' && downloadUrl && (
|
|
<p className="study-download-success">
|
|
<a href={downloadUrl}>Click here if your download does not start automatically.</a>
|
|
</p>
|
|
)}
|
|
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
|
|
{status === 'submitting' ? 'Preparing Download...' : buttonText}
|
|
</button>
|
|
</form>
|
|
)
|
|
}
|
|
|
|
function buildCustomResourceDownloadId(id: string) {
|
|
return `custom:${id}`
|
|
}
|
|
|
|
function buildArchivedResourceDownloadId(seriesId: string, linkId: string) {
|
|
return `archived:${seriesId}:${linkId}`
|
|
}
|
|
|
|
function buildCustomDownloadPageId(id: string) {
|
|
return `custom--${id}`
|
|
}
|
|
|
|
function buildArchivedDownloadPageId(seriesId: string, linkId: string) {
|
|
return `archived--${seriesId}--${linkId}`
|
|
}
|
|
|
|
interface DownloadPageResource {
|
|
id: string
|
|
label: string
|
|
imageUrl?: string
|
|
summary: string
|
|
tags: string[]
|
|
buttonText: string
|
|
resourceId: string
|
|
amazonUrl?: string
|
|
amazonLabel?: string
|
|
}
|
|
|
|
function resolveDownloadPageResource(content: SiteContent, pageId: string | undefined): DownloadPageResource | null {
|
|
if (!pageId) return null
|
|
|
|
if (pageId.startsWith('custom--')) {
|
|
const customId = pageId.slice('custom--'.length)
|
|
const resource = (content.customLinks ?? []).find(link => link.id === customId && link.placement === 'resources')
|
|
if (!resource) return null
|
|
|
|
return {
|
|
id: pageId,
|
|
label: resource.label,
|
|
imageUrl: resource.imageUrl,
|
|
summary: resource.description || 'Complete the short form below and your download will start right away.',
|
|
tags: resource.tags ?? [],
|
|
buttonText: `Download ${resource.label}`,
|
|
resourceId: buildCustomResourceDownloadId(resource.id),
|
|
amazonUrl: resource.amazonUrl,
|
|
amazonLabel: resource.amazonLabel,
|
|
}
|
|
}
|
|
|
|
if (pageId.startsWith('archived--')) {
|
|
const [, seriesId, linkId] = pageId.split('--')
|
|
const series = (content.archivedSeries ?? []).find(item => item.id === seriesId)
|
|
const link = (series?.resourceLinks ?? []).find(item => item.id === linkId)
|
|
if (!series || !link) return null
|
|
|
|
return {
|
|
id: pageId,
|
|
label: link.label || series.title || 'Download Resource',
|
|
imageUrl: series.imageUrl,
|
|
summary: link.description || series.description || 'Fill out the form below to access this download from a previous study.',
|
|
tags: [],
|
|
buttonText: `Download ${link.label || series.title || 'Resource'}`,
|
|
resourceId: buildArchivedResourceDownloadId(series.id, link.id),
|
|
amazonUrl: link.amazonUrl,
|
|
amazonLabel: link.amazonLabel,
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function AnalyticsConsentBanner({ content }: { content: SiteContent }) {
|
|
const [choice, setChoice] = useState<'unknown' | 'accepted' | 'declined'>(() => {
|
|
const saved = localStorage.getItem(CONSENT_KEY)
|
|
if (saved === 'accepted' || saved === 'declined') return saved
|
|
return 'unknown'
|
|
})
|
|
|
|
async function sendChoice(consent: boolean) {
|
|
await fetch('/api/analytics-consent', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ consent }),
|
|
})
|
|
}
|
|
|
|
async function accept() {
|
|
setChoice('accepted')
|
|
localStorage.setItem(CONSENT_KEY, 'accepted')
|
|
try {
|
|
await sendChoice(true)
|
|
} catch {
|
|
// Keep local preference even if network fails.
|
|
}
|
|
}
|
|
|
|
async function decline() {
|
|
setChoice('declined')
|
|
localStorage.setItem(CONSENT_KEY, 'declined')
|
|
try {
|
|
await sendChoice(false)
|
|
} catch {
|
|
// Keep local preference even if network fails.
|
|
}
|
|
}
|
|
|
|
if (choice !== 'unknown') return null
|
|
|
|
return (
|
|
<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>
|
|
)
|
|
}
|
|
|
|
interface Episode {
|
|
title: string
|
|
pubDate: string
|
|
link: string
|
|
description: string
|
|
duration: string
|
|
episode: string
|
|
}
|
|
|
|
function formatPubDate(raw: string): string {
|
|
try {
|
|
return new Date(raw).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
|
} catch {
|
|
return raw
|
|
}
|
|
}
|
|
|
|
function SiteHeader({ content }: { content: SiteContent }) {
|
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
|
|
|
|
return (
|
|
<header className="site-header">
|
|
<div className="header-inner">
|
|
<Link to="/" className="header-logo" aria-label="Home">
|
|
<img src="/book_icon.png" alt="" className="header-logo-img" />
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
className="header-menu-btn"
|
|
aria-expanded={menuOpen}
|
|
aria-controls="site-nav"
|
|
onClick={() => setMenuOpen(open => !open)}
|
|
>
|
|
{menuOpen ? 'Close' : 'Menu'}
|
|
</button>
|
|
<nav id="site-nav" className={`header-nav ${menuOpen ? 'header-nav--open' : ''}`}>
|
|
<NavLink to="/episodes" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Episodes</NavLink>
|
|
<NavLink to="/questions" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Q&A</NavLink>
|
|
<NavLink to="/study" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Studies</NavLink>
|
|
<NavLink to="/study/account" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>My Account</NavLink>
|
|
<NavLink to="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Downloads</NavLink>
|
|
<NavLink to="/about" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>About</NavLink>
|
|
<NavLink to="/contact" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Contact</NavLink>
|
|
<a
|
|
href={spotifyUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="header-cta"
|
|
>
|
|
{content.headerFollowLabel || 'Follow on Spotify'}
|
|
</a>
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
)
|
|
}
|
|
|
|
function SiteFooter({ content }: { content: SiteContent }) {
|
|
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
|
|
const appleUrl = content.platformAppleUrl || '/apple'
|
|
const youtubeUrl = content.platformYoutubeUrl || 'https://www.youtube.com/@blackzebraem5558'
|
|
const amazonUrl = content.platformAmazonUrl || '/amazon'
|
|
const creatorUrl = content.platformCreatorUrl || 'https://creators.spotify.com/pod/profile/nmemmert/'
|
|
const facebookUrl = content.platformFacebookUrl || 'https://facebook.com/versebyversewithnate'
|
|
const extraFooterLinks = (content.customLinks ?? []).filter(
|
|
l => l.placement === 'footer' || l.placement === 'platforms' || l.placement === 'otherSites',
|
|
)
|
|
|
|
return (
|
|
<footer className="site-footer">
|
|
<p className="footer-ornament">✦ ✦ ✦</p>
|
|
<p className="footer-title">{content.footerTitle || 'Verse by Verse with Nate'}</p>
|
|
<p className="footer-sub">{content.footerSubtitle || 'A Journey Through Scripture'}</p>
|
|
{content.footerEmail && (
|
|
<p className="footer-contact">
|
|
Contact:{' '}
|
|
<a href={`mailto:${content.footerEmail}`}>{content.footerEmail}</a>
|
|
</p>
|
|
)}
|
|
<nav className="footer-links" aria-label="Footer links">
|
|
<a href={spotifyUrl} target="_blank" rel="noreferrer">Spotify</a>
|
|
<span aria-hidden="true">·</span>
|
|
<a href={appleUrl} target="_blank" rel="noreferrer">Apple Podcasts</a>
|
|
<span aria-hidden="true">·</span>
|
|
<a href={youtubeUrl} target="_blank" rel="noreferrer">YouTube</a>
|
|
<span aria-hidden="true">·</span>
|
|
<a href={amazonUrl} target="_blank" rel="noreferrer">Amazon Music</a>
|
|
<span aria-hidden="true">·</span>
|
|
<a href={creatorUrl} target="_blank" rel="noreferrer">Creator Profile</a>
|
|
<span aria-hidden="true">·</span>
|
|
<a href={facebookUrl} target="_blank" rel="noreferrer">Facebook</a>
|
|
{extraFooterLinks.length > 0 && (
|
|
<>
|
|
<span aria-hidden="true">·</span>
|
|
<details className="footer-more-links">
|
|
<summary>More Links</summary>
|
|
<div className="footer-more-links-menu">
|
|
{extraFooterLinks.map(link => (
|
|
<a key={link.id} href={link.url} target="_blank" rel="noreferrer">{link.label}</a>
|
|
))}
|
|
</div>
|
|
</details>
|
|
</>
|
|
)}
|
|
</nav>
|
|
<nav className="footer-links footer-links--legal" aria-label="Legal links">
|
|
<Link to="/privacy">Privacy Policy</Link>
|
|
<span aria-hidden="true">·</span>
|
|
<Link to="/terms">Terms</Link>
|
|
</nav>
|
|
<p className="footer-copy">{content.footerCopyright || '© 2026 Nate Emmert · Made with faith.'}</p>
|
|
{content.footerPrivacyNote && (
|
|
<p className="footer-privacy">{content.footerPrivacyNote}</p>
|
|
)}
|
|
</footer>
|
|
)
|
|
}
|
|
|
|
function PlatformButtons({ content }: { content: SiteContent }) {
|
|
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
|
|
const youtubeUrl = content.platformYoutubeUrl || 'https://www.youtube.com/@blackzebraem5558'
|
|
const amazonUrl = content.platformAmazonUrl || '/amazon'
|
|
const facebookUrl = content.platformFacebookUrl || 'https://facebook.com/versebyversewithnate'
|
|
const appleUrl = content.platformAppleUrl || '/apple'
|
|
|
|
return (
|
|
<div className="platform-buttons">
|
|
<a href={appleUrl} target="_blank" rel="noreferrer" className="platform-btn platform-btn-image">
|
|
<img src="/images/apple-podcasts-badge.svg" alt="Listen on Apple Podcasts" className="platform-badge" />
|
|
</a>
|
|
<a href={spotifyUrl} target="_blank" rel="noreferrer" className="platform-btn platform-btn-image">
|
|
<img src="/images/listen-on-spotify.svg" alt="Listen on Spotify" className="platform-badge" />
|
|
</a>
|
|
<a href={youtubeUrl} target="_blank" rel="noreferrer" className="platform-btn platform-btn-image">
|
|
<img src="/images/watch-on-youtube.svg" alt="Watch on YouTube" className="platform-badge" />
|
|
</a>
|
|
<a href={amazonUrl} target="_blank" rel="noreferrer" className="platform-btn platform-btn-image">
|
|
<img src="/images/listen-on-amazon-music.svg" alt="Listen on Amazon Music" className="platform-badge" />
|
|
</a>
|
|
<a href={facebookUrl} target="_blank" rel="noreferrer" className="platform-btn platform-btn-image">
|
|
<img src="/images/follow-on-facebook.svg" alt="Follow on Facebook" className="platform-badge" />
|
|
</a>
|
|
{(content.customLinks ?? []).filter(l => l.placement === 'platforms').map(l => (
|
|
<a
|
|
key={l.id}
|
|
href={l.url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className={`platform-btn ${l.imageUrl ? 'platform-btn-image' : 'custom-btn'}`}
|
|
aria-label={`Listen on ${l.label}`}
|
|
>
|
|
{l.imageUrl
|
|
? <img src={l.imageUrl} alt={`Listen on ${l.label}`} className="platform-badge" />
|
|
: l.label}
|
|
</a>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AboutSection({ content }: { content: SiteContent }) {
|
|
return (
|
|
<section className="section-about" aria-label="About the show">
|
|
<div className="section-inner about-inner">
|
|
<div className="about-photo">
|
|
<img src={content.aboutPhotoUrl || '/images/nate-photo.jpeg'} alt="Nate Emmert" />
|
|
<div className="about-photo-divider" aria-hidden="true" />
|
|
<div className="about-nate-copy">
|
|
<p className="eyebrow">{content.aboutEyebrow || 'About Nate'}</p>
|
|
<div className="about-nate-rule" aria-hidden="true" />
|
|
<p>{content.aboutNate}</p>
|
|
<Link to="/episodes" className="about-nate-cta">{content.aboutListenBtnLabel || 'Listen Now ↓'}</Link>
|
|
</div>
|
|
</div>
|
|
<div className="about-text">
|
|
<p className="eyebrow">{content.aboutShowEyebrow || 'About the Show'}</p>
|
|
<h2>{content.aboutShowHeading}</h2>
|
|
<p>{content.aboutShowP1}</p>
|
|
<p>{content.aboutShowP2}</p>
|
|
<div className="rule-divider" aria-hidden="true" />
|
|
<img
|
|
src={content.aboutVerseArtUrl || '/images/hebrews-4-12-verse-art.png'}
|
|
alt="Hebrews 4:12 verse artwork"
|
|
className="about-verse-art"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function StudyGuideSection({ content }: { content: SiteContent }) {
|
|
return (
|
|
<section className="section-guide" aria-label="Companion study guide">
|
|
<div className="section-inner guide-inner">
|
|
<div className="guide-cover-art">
|
|
<img
|
|
src={content.seriesImageUrl || '/images/titus-cover.png'}
|
|
alt={content.seriesTitle}
|
|
className="guide-cover-img"
|
|
/>
|
|
</div>
|
|
<div className="guide-text">
|
|
<p className="eyebrow">Downloads</p>
|
|
<h1 className="guide-page-title">Study Guides and Downloads</h1>
|
|
<p className="guide-page-intro">Start with the primary guide below, then explore the rest of the download library further down the page.</p>
|
|
<p className="eyebrow">Free Download</p>
|
|
<h2>{content.studyGuideTitle}</h2>
|
|
<p>{content.studyGuideDescription}</p>
|
|
<StudyDownloadForm buttonText={`Download ${content.studyGuideTitle || 'Guide'}`} />
|
|
{content.studyGuideUrl && (
|
|
<div className="guide-actions">
|
|
<a
|
|
href={content.studyGuideUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="btn-secondary"
|
|
>
|
|
{content.studyGuideAmazonButtonLabel || 'Get it on Amazon'}
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function ContactSection({ content }: { content: SiteContent }) {
|
|
return (
|
|
<section className="section-contact" id="contact" aria-label="Contact form">
|
|
<div className="section-inner contact-inner">
|
|
<div className="contact-copy">
|
|
<div className="contact-card">
|
|
<p className="eyebrow">{content.contactEyebrow || 'Get in Touch'}</p>
|
|
<h2>{content.contactHeading || 'Contact Nate'}</h2>
|
|
|
|
<div className="contact-profile">
|
|
<img
|
|
src={content.contactPhotoUrl || '/images/nate-contact-photo.png'}
|
|
alt={content.contactName || 'Nate'}
|
|
className="contact-profile-photo"
|
|
/>
|
|
<div className="contact-profile-meta">
|
|
<p className="contact-profile-name">{content.contactName || 'Nate'}</p>
|
|
<p className="contact-profile-role">{content.contactRole || 'Bible teacher · Lynchburg, VA'}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{content.contactQuote && (
|
|
<blockquote className="contact-quote">
|
|
<p>"{content.contactQuote}"</p>
|
|
</blockquote>
|
|
)}
|
|
|
|
{content.contactIntro && (
|
|
<p className="contact-intro">{content.contactIntro}</p>
|
|
)}
|
|
|
|
<div className="contact-points" aria-label="Contact response details">
|
|
{content.contactPoint1 && (
|
|
<div className="contact-point">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
</svg>
|
|
<span>{content.contactPoint1}</span>
|
|
</div>
|
|
)}
|
|
{content.contactPoint2 && (
|
|
<div className="contact-point">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<polyline points="12 6 12 12 16 14" />
|
|
</svg>
|
|
<span>{content.contactPoint2}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{content.contactVerse && (
|
|
<div className="contact-scripture">
|
|
<p>
|
|
"{content.contactVerse}" -{' '}
|
|
<span className="contact-scripture-ref">{content.contactVerseRef}</span>
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<ContactForm />
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function PodcastHighlightsSection({ content }: { content: SiteContent }) {
|
|
const links = content.podcastFeaturedLinks ?? []
|
|
if (links.length === 0) return null
|
|
|
|
return (
|
|
<section className="section-resources" aria-label="Podcast highlights">
|
|
<div className="section-inner">
|
|
<h2 className="section-heading">
|
|
<span className="ornament">✦</span> Podcast Highlights{' '}
|
|
<span className="ornament">✦</span>
|
|
</h2>
|
|
<div className="resources-list">
|
|
{links.map(link => {
|
|
const hasRichContent = !!(
|
|
link.embedUrl
|
|
|| isLikelySpotifyEpisodeUrl(link.url)
|
|
|| (link.discussionQuestions ?? []).length > 0
|
|
|| link.showNotes
|
|
)
|
|
const label = <>{link.episodeNumber ? `Ep. ${link.episodeNumber}: ` : ''}{link.title || link.url}</>
|
|
// Use external link only when there's a url AND no rich detail-page content
|
|
if (!hasRichContent && link.url) {
|
|
return (
|
|
<a key={link.id} href={link.url} target="_blank" rel="noreferrer" className="resource-link">
|
|
{label}
|
|
</a>
|
|
)
|
|
}
|
|
// Otherwise always route to the internal detail page
|
|
return (
|
|
<Link key={link.id} to={`/episodes/${link.id}`} className="resource-link">
|
|
{label}
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function DownloadLibrarySection({ content }: { content: SiteContent }) {
|
|
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
|
|
const archivedWithResources = (content.archivedSeries ?? []).filter(series => (series.resourceLinks ?? []).length > 0)
|
|
|
|
if (resources.length === 0 && archivedWithResources.length === 0) return null
|
|
|
|
return (
|
|
<section className="section-resources section-download-library" aria-label="Download library">
|
|
<div className="section-inner">
|
|
<div className="download-library-head">
|
|
<p className="eyebrow">Download Library</p>
|
|
<h2 className="section-heading">More guides, worksheets, and past study downloads.</h2>
|
|
<p className="download-library-copy">Keep the main guide featured at the top, and use this library for every other download you want available on the page.</p>
|
|
</div>
|
|
|
|
{resources.length > 0 && (
|
|
<div className="download-library-group">
|
|
<div className="download-library-group-head">
|
|
<h3>Current Downloads</h3>
|
|
<p>Extra files you want people to grab right now.</p>
|
|
</div>
|
|
<div className="resources-list">
|
|
{resources.map(resource => (
|
|
<Link key={resource.id} to={`/downloads/${buildCustomDownloadPageId(resource.id)}`} className="resource-download-card resource-download-card--link">
|
|
<div className="resource-download-header">
|
|
{resource.imageUrl && (
|
|
<img src={resource.imageUrl} alt={resource.label} className="resource-link-image" />
|
|
)}
|
|
<div className="resource-download-meta">
|
|
<span className="resource-link-label">{resource.label}</span>
|
|
{resource.description && <p className="resource-link-description">{resource.description}</p>}
|
|
{(resource.tags ?? []).length > 0 && (
|
|
<div className="resource-link-tags">{(resource.tags ?? []).join(', ')}</div>
|
|
)}
|
|
<span className="resource-link-action">Open download page →</span>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{archivedWithResources.length > 0 && (
|
|
<div className="download-library-group">
|
|
<div className="download-library-group-head">
|
|
<h3>Previous Studies</h3>
|
|
<p>Downloads from earlier series that you still want available.</p>
|
|
</div>
|
|
{archivedWithResources.map(series => (
|
|
<div key={series.id} className="archive-series-resources">
|
|
<div className="download-library-series-head">
|
|
<h4>{series.title || 'Archived Study'}</h4>
|
|
{series.description && <p>{series.description}</p>}
|
|
</div>
|
|
<div className="resources-list">
|
|
{(series.resourceLinks ?? []).map(link => (
|
|
<Link key={link.id} to={`/downloads/${buildArchivedDownloadPageId(series.id, link.id)}`} className="resource-download-card resource-download-card--link">
|
|
<div className="resource-download-header">
|
|
{series.imageUrl && (
|
|
<img src={series.imageUrl} alt={series.title || 'Archived study'} className="resource-link-image" />
|
|
)}
|
|
<div className="resource-download-meta">
|
|
<span className="resource-link-label">{link.label || series.title || 'Download Resource'}</span>
|
|
{link.description && <p className="resource-link-description">{link.description}</p>}
|
|
<span className="resource-link-action">Open download page →</span>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function DownloadDetailPage({ content }: { content: SiteContent }) {
|
|
const { id } = useParams<{ id: string }>()
|
|
const resource = resolveDownloadPageResource(content, id)
|
|
|
|
if (!resource) {
|
|
return (
|
|
<main className="thanks-page" aria-label="Download not found">
|
|
<div className="thanks-card">
|
|
<p className="eyebrow">Downloads</p>
|
|
<h1>Download not found</h1>
|
|
<p>The download you requested is not available right now.</p>
|
|
<Link to="/resources" className="btn-primary">Back to Downloads</Link>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<main className="thanks-page download-detail-page" aria-label={resource.label}>
|
|
<div className="thanks-card download-detail-card">
|
|
<p className="eyebrow">Downloads</p>
|
|
<h1>{resource.label}</h1>
|
|
<p>{resource.summary}</p>
|
|
|
|
{resource.imageUrl && (
|
|
<div className="download-detail-art">
|
|
<img src={resource.imageUrl} alt={resource.label} className="guide-cover-img" />
|
|
</div>
|
|
)}
|
|
|
|
{resource.tags.length > 0 && (
|
|
<div className="resource-link-tags">{resource.tags.join(', ')}</div>
|
|
)}
|
|
|
|
<div className="download-detail-form-wrap">
|
|
<ResourceDownloadForm resourceId={resource.resourceId} buttonText={resource.buttonText} />
|
|
</div>
|
|
|
|
<div className="download-detail-actions">
|
|
{resource.amazonUrl && (
|
|
<a href={resource.amazonUrl} target="_blank" rel="noreferrer" className="btn-secondary">
|
|
{resource.amazonLabel || 'Get it on Amazon'}
|
|
</a>
|
|
)}
|
|
<Link to="/resources" className="btn-secondary">Back to Downloads</Link>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function ShareShowButton() {
|
|
const [feedback, setFeedback] = useState('')
|
|
|
|
async function handleShare() {
|
|
const shareUrl = window.location.origin
|
|
const shareTitle = 'Verse by Verse with Nate'
|
|
const shareText = 'Check out Verse by Verse with Nate.'
|
|
try {
|
|
if (navigator.share) {
|
|
await navigator.share({ title: shareTitle, text: shareText, url: shareUrl })
|
|
setFeedback('Shared successfully.')
|
|
return
|
|
}
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(shareUrl)
|
|
setFeedback('Link copied. Share it with a friend.')
|
|
return
|
|
}
|
|
setFeedback(`Copy this link: ${shareUrl}`)
|
|
} catch {
|
|
setFeedback('Share canceled.')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="share-show-wrap">
|
|
<button type="button" className="btn-primary btn-share-show" onClick={handleShare}>
|
|
Share the Show
|
|
</button>
|
|
{feedback && <p className="share-feedback">{feedback}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CustomBlocksSection({ content, page }: { content: SiteContent; page: string }) {
|
|
const blocks = (content.customBlocks ?? []).filter(block => {
|
|
const blockPage = block.page ?? 'downloads'
|
|
return blockPage === page
|
|
})
|
|
if (blocks.length === 0) return null
|
|
return (
|
|
<>
|
|
{blocks.map(block => (
|
|
<section key={block.id} className="section-custom-block" aria-label={block.heading}>
|
|
<div className="section-inner custom-block-inner">
|
|
<h2>{block.heading}</h2>
|
|
<p>{block.body}</p>
|
|
</div>
|
|
</section>
|
|
))}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function ExternalSitesSection({ content }: { content: SiteContent }) {
|
|
const links = (content.customLinks ?? []).filter(link => link.placement === 'externalSites')
|
|
if (links.length === 0) return null
|
|
|
|
return (
|
|
<section className="section-resources" 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>
|
|
<p className="download-library-copy">These links are shown on the homepage, separate from the footer menu.</p>
|
|
</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">
|
|
<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>
|
|
)
|
|
}
|
|
|
|
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>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div style={{ textAlign: 'center', marginTop: '2rem' }}>
|
|
<Link to="/questions" className="btn-primary">Browse All Q&A →</Link>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function LandingPage({ content }: { content: SiteContent }) {
|
|
const featuredStudy = getHomepageFeaturedStudy(content)
|
|
|
|
return (
|
|
<div className="site">
|
|
<SiteHeader content={content} />
|
|
<CustomBlocksSection content={content} page="homepage" />
|
|
|
|
{/* ── HERO ── */}
|
|
<section className="hero" aria-label="Show introduction">
|
|
<div className="hero-art">
|
|
<div className="art-glow" aria-hidden="true" />
|
|
<img
|
|
src="/images/podcast-art.jpeg"
|
|
alt="Verse by Verse with Nate podcast artwork"
|
|
className="podcast-art"
|
|
/>
|
|
</div>
|
|
<div className="hero-text">
|
|
<p className="eyebrow">{content.eyebrow}</p>
|
|
<h1 className="hero-title">Verse by Verse</h1>
|
|
<p className="with-nate">
|
|
<span className="word-with">with </span>Nate
|
|
</p>
|
|
<div className="rule-divider" aria-hidden="true" />
|
|
<p className="tagline">{content.heroTagline}</p>
|
|
<div className="hero-ctas">
|
|
<a
|
|
href={content.platformSpotifyUrl || '/spotify'}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="btn-primary"
|
|
>
|
|
<SpotifyIcon />
|
|
{content.heroBtnSpotify || 'Listen on Spotify'}
|
|
</a>
|
|
<Link to="/episodes" className="btn-secondary">{content.heroBtnEpisodes || 'Explore Episodes ↓'}</Link>
|
|
<Link to="/start-here" className="btn-secondary btn-start-here">{content.heroBtnStartHere || 'New here? Start here →'}</Link>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="section-prism" aria-label="PRISM featured video">
|
|
<div className="section-inner prism-feature">
|
|
<div className="prism-video-shell">
|
|
<video
|
|
className="prism-video"
|
|
controls
|
|
playsInline
|
|
preload="metadata"
|
|
poster="/images/banner.png"
|
|
>
|
|
<source src={content.prismVideoUrl || '/images/PRISM.mp4'} type="video/mp4" />
|
|
Your browser does not support embedded video playback.
|
|
</video>
|
|
</div>
|
|
|
|
<div className="prism-copy">
|
|
<p className="eyebrow">{content.prismEyebrow || 'Featured Video'}</p>
|
|
<h2 className="section-heading prism-heading">
|
|
<span className="ornament">✦</span> {content.prismHeading || 'PRISM'} <span className="ornament">✦</span>
|
|
</h2>
|
|
<p className="prism-description">
|
|
{content.prismIntro || 'What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?'}
|
|
</p>
|
|
<p className="prism-description">
|
|
{content.prismPatternIntro || 'In this bonus episode, I walk you through P.R.I.S.M. — a five-step daily Bible study pattern designed to help you slow down, go deep, and let the Word do its work.'}
|
|
</p>
|
|
<div className="prism-steps" aria-label="P.R.I.S.M. steps">
|
|
<p>{content.prismStep1 || 'P — Pray before you read'}</p>
|
|
<p>{content.prismStep2 || 'R — Read slowly and observe'}</p>
|
|
<p>{content.prismStep3 || 'I — Interpret with context'}</p>
|
|
<p>{content.prismStep4 || 'S — Study and apply specifically'}</p>
|
|
<p>{content.prismStep5 || 'M — Memorize one verse at a time'}</p>
|
|
</div>
|
|
<p className="prism-description">
|
|
{content.prismOutro || "Whether you're brand new to daily Bible reading or you've been at it for years, P.R.I.S.M. gives you a consistent framework for every passage, every day."}
|
|
</p>
|
|
<p className="prism-description prism-emphasis">
|
|
{content.prismClosing || "The goal isn't to get through the text — it's to let the text get through to you."}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<HomepageNewsletterSection />
|
|
|
|
<section className="section-series" id="series" aria-label="Current series">
|
|
<div className="section-inner">
|
|
<h2 className="section-heading">
|
|
<span className="ornament">✦</span> Current Series{' '}
|
|
<span className="ornament">✦</span>
|
|
</h2>
|
|
<div className="series-grid">
|
|
<article className="series-card">
|
|
<img
|
|
src={content.seriesImageUrl}
|
|
alt={content.seriesTitle}
|
|
className="series-art"
|
|
/>
|
|
<div className="series-info">
|
|
<p className="series-label">{content.seriesLabel}</p>
|
|
<h3>{content.seriesTitle}</h3>
|
|
<p>{content.seriesDescription}</p>
|
|
<a
|
|
href={content.seriesListenUrl || content.platformSpotifyUrl || '/spotify'}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="btn-primary"
|
|
>
|
|
<SpotifyIcon />
|
|
{content.seriesListenBtnLabel || 'Listen to Series'}
|
|
</a>
|
|
<Link to="/resources" className="btn-secondary">{content.seriesDownloadsBtnLabel || 'View Downloads'}</Link>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<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 />
|
|
|
|
<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="/images/qr-code.jpg" alt="QR code to share the show" className="share-qr" />
|
|
</div>
|
|
</section>
|
|
|
|
<SiteFooter content={content} />
|
|
|
|
<AnalyticsConsentBanner content={content} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function EpisodeDetailPage({ content }: { content: SiteContent }) {
|
|
const { id } = useParams<{ id: string }>()
|
|
const episode = (content.podcastFeaturedLinks ?? []).find(e => e.id === id)
|
|
const [resolvedEmbedUrl, setResolvedEmbedUrl] = useState('')
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
if (!episode) {
|
|
setResolvedEmbedUrl('')
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}
|
|
|
|
const manualEmbed = episode.embedUrl?.trim() ?? ''
|
|
if (manualEmbed) {
|
|
setResolvedEmbedUrl(manualEmbed)
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}
|
|
|
|
if (!episode.url) {
|
|
setResolvedEmbedUrl('')
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}
|
|
|
|
try {
|
|
const urlObj = new URL(episode.url)
|
|
const host = urlObj.hostname.toLowerCase()
|
|
|
|
if (host === 'open.spotify.com') {
|
|
const fromUrl = toSpotifyEpisodeEmbedUrl(episode.url)
|
|
if (fromUrl) {
|
|
setResolvedEmbedUrl(fromUrl)
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
if (!isLikelySpotifyEpisodeUrl(episode.url)) {
|
|
setResolvedEmbedUrl('')
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}
|
|
|
|
setResolvedEmbedUrl('')
|
|
fetch(`/api/spotify/embed-url?url=${encodeURIComponent(episode.url)}`)
|
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('embed resolve failed'))))
|
|
.then((data: { embedUrl?: string }) => {
|
|
if (cancelled) return
|
|
setResolvedEmbedUrl(data.embedUrl?.trim() ?? '')
|
|
})
|
|
.catch(() => {
|
|
if (cancelled) return
|
|
setResolvedEmbedUrl('')
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [episode])
|
|
|
|
if (!episode) {
|
|
return (
|
|
<div className="site">
|
|
<SiteHeader 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} />
|
|
<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>}
|
|
|
|
{resolvedEmbedUrl && (
|
|
<div className="episode-detail-embed">
|
|
<iframe
|
|
src={resolvedEmbedUrl}
|
|
title={episode.title}
|
|
width="100%"
|
|
height="152"
|
|
frameBorder="0"
|
|
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
|
loading="lazy"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{episode.url && (
|
|
<a href={episode.url} target="_blank" rel="noreferrer" className="btn-primary episode-detail-listen-btn">
|
|
Listen on Podcast Platform →
|
|
</a>
|
|
)}
|
|
|
|
{episode.showNotes && (
|
|
<div className="episode-detail-show-notes">
|
|
<h2 className="episode-detail-section-heading">Show Notes</h2>
|
|
<p>{episode.showNotes}</p>
|
|
</div>
|
|
)}
|
|
|
|
{questions.length > 0 && (
|
|
<div className="episode-detail-questions">
|
|
<h2 className="episode-detail-section-heading">Discussion Questions</h2>
|
|
<ol className="episode-detail-questions-list">
|
|
{questions.map((q, i) => (
|
|
<li key={i}>{q}</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
<SiteFooter content={content} />
|
|
<AnalyticsConsentBanner content={content} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function EpisodesPage({ content }: { content: SiteContent }) {
|
|
const [allEpisodes, setAllEpisodes] = useState<Episode[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [failed, setFailed] = useState(false)
|
|
|
|
useEffect(() => {
|
|
fetch('/api/episodes/all')
|
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('fetch failed'))))
|
|
.then((data: { episodes: Episode[] }) => {
|
|
if (data.episodes.length === 0) setFailed(true)
|
|
else setAllEpisodes(data.episodes)
|
|
setLoading(false)
|
|
})
|
|
.catch(() => {
|
|
setFailed(true)
|
|
setLoading(false)
|
|
})
|
|
}, [])
|
|
|
|
const archivedSeries = content.archivedSeries ?? []
|
|
|
|
const archivedEpisodeNums = new Set<number>(
|
|
archivedSeries.flatMap(s => {
|
|
if (!s.episodeRange) return []
|
|
const nums: number[] = []
|
|
for (let i = s.episodeRange.from; i <= s.episodeRange.to; i++) nums.push(i)
|
|
return nums
|
|
})
|
|
)
|
|
|
|
const currentEpisodes = allEpisodes.filter(ep => {
|
|
const num = parseInt(ep.episode, 10)
|
|
return isNaN(num) || !archivedEpisodeNums.has(num)
|
|
})
|
|
|
|
function episodesForSeries(series: ArchivedSeries) {
|
|
if (!series.episodeRange) return []
|
|
const { from, to } = series.episodeRange
|
|
return allEpisodes.filter(ep => {
|
|
const num = parseInt(ep.episode, 10)
|
|
return !isNaN(num) && num >= from && num <= to
|
|
})
|
|
}
|
|
|
|
function renderEpisodeCards(episodes: Episode[]) {
|
|
return (
|
|
<div className="episode-list">
|
|
{episodes.map((ep, idx) => (
|
|
<a
|
|
key={idx}
|
|
href={ep.link || content.platformSpotifyUrl || '/spotify'}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="episode-card"
|
|
>
|
|
<div className="episode-card-meta">
|
|
{ep.episode && <span className="episode-number">Ep. {ep.episode}</span>}
|
|
{ep.pubDate && <span className="episode-date">{formatPubDate(ep.pubDate)}</span>}
|
|
{ep.duration && <span className="episode-duration">{ep.duration}</span>}
|
|
</div>
|
|
<h3 className="episode-title">{ep.title}</h3>
|
|
{ep.description && <p className="episode-desc">{ep.description}</p>}
|
|
<span className="episode-listen-cta">
|
|
<SpotifyIcon /> Listen →
|
|
</span>
|
|
</a>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const spotifyFallback = (
|
|
<div className="embed-wrap">
|
|
<iframe
|
|
title="Verse by Verse with Nate"
|
|
src={SPOTIFY_EMBED_URL}
|
|
width="100%"
|
|
height="352"
|
|
frameBorder="0"
|
|
allowFullScreen
|
|
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
|
loading="lazy"
|
|
style={{ borderRadius: '14px' }}
|
|
/>
|
|
</div>
|
|
)
|
|
|
|
const loadingSkeleton = (
|
|
<div className="episode-list-loading">
|
|
{[1, 2, 3].map(i => <div key={i} className="episode-skeleton" aria-hidden="true" />)}
|
|
</div>
|
|
)
|
|
|
|
const latestEpisode = !loading && !failed ? (currentEpisodes[0] ?? allEpisodes[0]) : null
|
|
const latestEmbedUrl = latestEpisode ? toSpotifyEpisodeEmbedUrl(latestEpisode.link) : ''
|
|
|
|
return (
|
|
<div className="site">
|
|
<SiteHeader content={content} />
|
|
|
|
<section className="section-player" aria-label="Current series episodes">
|
|
<div className="section-inner">
|
|
<p className="episode-seo-intro" aria-hidden="true">
|
|
{content.episodesSeoIntro || 'Verse by Verse with Nate is an expository Bible teaching podcast where we go through Scripture one verse at a time. Each episode digs into the text carefully and practically, helping you understand what the Bible says, what it means, and how to live it out. New episodes released regularly — subscribe on Spotify or your favorite podcast platform.'}
|
|
</p>
|
|
{latestEpisode && (
|
|
<div className="latest-episode-card">
|
|
<div className="latest-episode-meta">
|
|
<p className="eyebrow">Latest Episode</p>
|
|
<h2 className="latest-episode-title">{latestEpisode.title}</h2>
|
|
<div className="latest-episode-pills">
|
|
{latestEpisode.episode && <span className="episode-number">Ep. {latestEpisode.episode}</span>}
|
|
{latestEpisode.pubDate && <span className="episode-date">{formatPubDate(latestEpisode.pubDate)}</span>}
|
|
{latestEpisode.duration && <span className="episode-duration">{latestEpisode.duration}</span>}
|
|
</div>
|
|
{latestEpisode.description && <p className="latest-episode-desc">{latestEpisode.description}</p>}
|
|
<a href={latestEpisode.link || content.platformSpotifyUrl || '/spotify'} target="_blank" rel="noreferrer" className="btn-primary" style={{ marginTop: '1rem', display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}>
|
|
<SpotifyIcon /> Listen on Spotify
|
|
</a>
|
|
</div>
|
|
{latestEmbedUrl && (
|
|
<div className="latest-episode-embed">
|
|
<iframe
|
|
title={latestEpisode.title}
|
|
src={latestEmbedUrl}
|
|
width="100%"
|
|
height="232"
|
|
frameBorder="0"
|
|
allowFullScreen
|
|
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
|
loading="lazy"
|
|
style={{ borderRadius: '12px' }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<h2 className="section-heading" style={{ marginTop: latestEpisode ? '3rem' : undefined }}>
|
|
<span className="ornament">✦</span> {content.seriesLabel || 'Now Playing'}{' '}
|
|
<span className="ornament">✦</span>
|
|
</h2>
|
|
{content.seriesTitle && <p className="episode-series-subtitle">{content.seriesTitle}</p>}
|
|
{loading
|
|
? loadingSkeleton
|
|
: failed || currentEpisodes.length === 0
|
|
? spotifyFallback
|
|
: renderEpisodeCards(currentEpisodes)}
|
|
<PlatformButtons content={content} />
|
|
<HeadlinerWidget />
|
|
</div>
|
|
</section>
|
|
|
|
{archivedSeries.map(series => {
|
|
const episodes = episodesForSeries(series)
|
|
return (
|
|
<section key={series.id} className="section-player" aria-label={series.title}>
|
|
<div className="section-inner">
|
|
<h2 className="section-heading">
|
|
<span className="ornament">✦</span> {series.label || 'Archived Study'}{' '}
|
|
<span className="ornament">✦</span>
|
|
</h2>
|
|
{series.title && <p className="episode-series-subtitle">{series.title}</p>}
|
|
{series.description && <p className="episode-series-desc">{series.description}</p>}
|
|
{loading
|
|
? loadingSkeleton
|
|
: episodes.length > 0
|
|
? renderEpisodeCards(episodes)
|
|
: series.listenUrl
|
|
? (
|
|
<a href={series.listenUrl} target="_blank" rel="noreferrer" className="btn-primary" style={{ display: 'inline-block', marginBottom: '1.5rem' }}>
|
|
Listen to Full Series →
|
|
</a>
|
|
)
|
|
: null}
|
|
</div>
|
|
</section>
|
|
)
|
|
})}
|
|
|
|
<PodcastHighlightsSection content={content} />
|
|
<CustomBlocksSection content={content} page="episodes" />
|
|
<SiteFooter content={content} />
|
|
<AnalyticsConsentBanner content={content} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ResourcesPage({ content }: { content: SiteContent }) {
|
|
return (
|
|
<div className="site">
|
|
<SiteHeader content={content} />
|
|
<StudyGuideSection content={content} />
|
|
<DownloadLibrarySection content={content} />
|
|
<CustomBlocksSection content={content} page="downloads" />
|
|
<SiteFooter content={content} />
|
|
<AnalyticsConsentBanner content={content} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AboutPage({ content }: { content: SiteContent }) {
|
|
return (
|
|
<div className="site">
|
|
<SiteHeader content={content} />
|
|
<AboutSection content={content} />
|
|
<CustomBlocksSection content={content} page="about" />
|
|
<SiteFooter content={content} />
|
|
<AnalyticsConsentBanner content={content} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ContactPage({ content }: { content: SiteContent }) {
|
|
return (
|
|
<div className="site">
|
|
<SiteHeader content={content} />
|
|
<ContactSection content={content} />
|
|
<CustomBlocksSection content={content} page="contact" />
|
|
<SiteFooter content={content} />
|
|
<AnalyticsConsentBanner content={content} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StartHerePage({ content }: { content: SiteContent }) {
|
|
return (
|
|
<main className="thanks-page" aria-label="Start Here">
|
|
<div className="thanks-card" style={{ maxWidth: '980px', width: '100%' }}>
|
|
<p className="eyebrow">Verse by Verse with Nate</p>
|
|
<h1>{content.startHereHeading}</h1>
|
|
<p>
|
|
{content.startHereIntro}
|
|
</p>
|
|
|
|
<div className="start-grid" style={{ marginTop: '1.25rem' }}>
|
|
<article className="start-card">
|
|
<h3>{content.startHereStep1Title}</h3>
|
|
<p>{content.startHereStep1Body}</p>
|
|
<Link to="/episodes" className="btn-secondary">{content.startHereStep1Cta}</Link>
|
|
</article>
|
|
<article className="start-card">
|
|
<h3>{content.startHereStep2Title}</h3>
|
|
<p>{content.startHereStep2Body}</p>
|
|
<a href="/#qa" className="btn-secondary">{content.startHereStep2Cta}</a>
|
|
</article>
|
|
<article className="start-card">
|
|
<h3>{content.startHereStep3Title}</h3>
|
|
<p>{content.startHereStep3Body}</p>
|
|
<Link to="/contact" className="btn-primary">{content.startHereStep3Cta}</Link>
|
|
</article>
|
|
</div>
|
|
|
|
<div style={{ marginTop: '1.4rem' }}>
|
|
<Link to="/" className="btn-secondary">Back to Site</Link>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function ThankYouPage() {
|
|
const navigate = useNavigate()
|
|
const [secondsLeft, setSecondsLeft] = useState(15)
|
|
|
|
useEffect(() => {
|
|
const timeoutId = window.setTimeout(() => {
|
|
navigate('/')
|
|
}, 15000)
|
|
|
|
const intervalId = window.setInterval(() => {
|
|
setSecondsLeft(s => (s > 0 ? s - 1 : 0))
|
|
}, 1000)
|
|
|
|
return () => {
|
|
window.clearTimeout(timeoutId)
|
|
window.clearInterval(intervalId)
|
|
}
|
|
}, [navigate])
|
|
|
|
return (
|
|
<main className="thanks-page" aria-label="Thank you">
|
|
<div className="thanks-card">
|
|
<p className="eyebrow">Message Received</p>
|
|
<h1>Thank You</h1>
|
|
<p>
|
|
Your message was sent successfully. We appreciate you reaching out and will get back to you soon.
|
|
</p>
|
|
<p className="thanks-countdown">Returning to the main site in {secondsLeft} seconds...</p>
|
|
<button type="button" className="btn-primary" onClick={() => navigate('/')}>
|
|
Go Back Now
|
|
</button>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function SubscribePage() {
|
|
const navigate = useNavigate()
|
|
const [firstName, setFirstName] = useState('')
|
|
const [lastName, setLastName] = useState('')
|
|
const [email, setEmail] = useState('')
|
|
const [honey, setHoney] = useState('')
|
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
|
|
const [errorMsg, setErrorMsg] = useState('')
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setStatus('submitting')
|
|
setErrorMsg('')
|
|
|
|
try {
|
|
const res = await fetch('/api/contact', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
firstName,
|
|
lastName,
|
|
email,
|
|
messageType: 'general',
|
|
message: 'Newsletter signup from subscribe page',
|
|
subscribe: true,
|
|
_honey: honey,
|
|
}),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
setErrorMsg((data as { message?: string }).message ?? 'Something went wrong. Please try again.')
|
|
setStatus('error')
|
|
return
|
|
}
|
|
|
|
navigate('/subscribe/thanks')
|
|
} catch {
|
|
setErrorMsg('Could not connect. Please try again later.')
|
|
setStatus('error')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<main className="thanks-page" aria-label="Subscribe">
|
|
<div className="thanks-card subscribe-card">
|
|
<p className="eyebrow">Verse by Verse with Nate</p>
|
|
<h1>Subscribe for Updates</h1>
|
|
<p>
|
|
Get ministry updates and new episode announcements by email.
|
|
</p>
|
|
|
|
<form className="contact-form subscribe-form" onSubmit={handleSubmit} noValidate>
|
|
<input
|
|
type="text"
|
|
className="contact-honeypot"
|
|
tabIndex={-1}
|
|
autoComplete="off"
|
|
aria-hidden="true"
|
|
value={honey}
|
|
onChange={e => setHoney(e.target.value)}
|
|
/>
|
|
<label>
|
|
First Name
|
|
<input
|
|
type="text"
|
|
name="firstName"
|
|
required
|
|
autoComplete="given-name"
|
|
value={firstName}
|
|
onChange={e => setFirstName(e.target.value)}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Last Name
|
|
<input
|
|
type="text"
|
|
name="lastName"
|
|
required
|
|
autoComplete="family-name"
|
|
value={lastName}
|
|
onChange={e => setLastName(e.target.value)}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Email
|
|
<input
|
|
type="email"
|
|
name="email"
|
|
required
|
|
autoComplete="email"
|
|
value={email}
|
|
onChange={e => setEmail(e.target.value)}
|
|
/>
|
|
</label>
|
|
|
|
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
|
|
|
|
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
|
|
{status === 'submitting' ? 'Subscribing…' : 'Subscribe'}
|
|
</button>
|
|
<Link to="/" className="btn-secondary">Back to Site</Link>
|
|
</form>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function SubscribeThankYouPage() {
|
|
return (
|
|
<main className="thanks-page" aria-label="Subscription confirmed">
|
|
<div className="thanks-card">
|
|
<p className="eyebrow">Verse by Verse with Nate</p>
|
|
<h1>You are subscribed</h1>
|
|
<p>
|
|
Thank you for subscribing. You will receive future ministry updates and episode announcements.
|
|
</p>
|
|
<Link to="/" className="btn-primary">Back to Site</Link>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function LegalPage({ title, body }: { title: string; body: string[] }) {
|
|
return (
|
|
<main className="thanks-page" aria-label={title}>
|
|
<div className="thanks-card">
|
|
<p className="eyebrow">Verse by Verse with Nate</p>
|
|
<h1>{title}</h1>
|
|
{body.map((line, idx) => (
|
|
<p key={`${title}-${idx}`}>{line}</p>
|
|
))}
|
|
<Link to="/" className="btn-primary">Back to Site</Link>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function 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 }) {
|
|
return (
|
|
<main className="thanks-page" aria-label="Questions and Answers">
|
|
<div className="thanks-card" style={{ maxWidth: '1000px', width: '100%' }}>
|
|
<QASection />
|
|
<CustomBlocksSection content={content} page="questions" />
|
|
<div style={{ marginTop: '2rem', textAlign: 'center' }}>
|
|
<Link to="/" className="btn-secondary">Back to Site</Link>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
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 === 'archived-series' || previewView === 'episode-highlights') {
|
|
return <EpisodesPage content={content} />
|
|
}
|
|
return <LandingPage content={content} />
|
|
}
|
|
|
|
function StudyRouteFrame({ content, child }: { content: SiteContent; child: ReactElement }) {
|
|
return (
|
|
<>
|
|
<SiteHeader content={content} />
|
|
{child}
|
|
<SiteFooter content={content} />
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default function App() {
|
|
const [content, setContent] = useState<SiteContent>(DEFAULTS)
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
usePageTracking()
|
|
|
|
useEffect(() => {
|
|
fetch('/api/admin-content')
|
|
.then(r => (r.ok ? r.json() : null))
|
|
.then(data => {
|
|
if (data?.siteContent) {
|
|
setContent(c => ({ ...c, ...data.siteContent }))
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (location.pathname !== '/' || !location.hash) return
|
|
|
|
const legacyHashRouteMap: { [key: string]: string } = {
|
|
'#listen': '/episodes',
|
|
'#about': '/about',
|
|
'#qa': '/questions',
|
|
'#contact': '/contact',
|
|
'#series': '/resources',
|
|
'#archives': '/resources',
|
|
'#resources': '/resources',
|
|
'#start-here': '/start-here',
|
|
}
|
|
|
|
const redirectPath = legacyHashRouteMap[location.hash.toLowerCase()]
|
|
if (!redirectPath) return
|
|
|
|
navigate(redirectPath, { replace: true })
|
|
}, [location.hash, location.pathname, navigate])
|
|
|
|
useEffect(() => {
|
|
const seo = content.seo ?? DEFAULTS.seo
|
|
|
|
const ensureMeta = (selector: string, createAttrs: { [key: string]: string }) => {
|
|
const found = document.head.querySelector(selector)
|
|
if (found) return found as HTMLMetaElement
|
|
const meta = document.createElement('meta')
|
|
Object.entries(createAttrs).forEach(([key, value]) => {
|
|
meta.setAttribute(key, value)
|
|
})
|
|
document.head.appendChild(meta)
|
|
return meta
|
|
}
|
|
|
|
const ensureCanonical = () => {
|
|
const found = document.head.querySelector('link[rel="canonical"]')
|
|
if (found) return found as HTMLLinkElement
|
|
const link = document.createElement('link')
|
|
link.setAttribute('rel', 'canonical')
|
|
document.head.appendChild(link)
|
|
return link
|
|
}
|
|
|
|
document.title = seo.title || DEFAULTS.seo.title
|
|
ensureMeta('meta[name="description"]', { name: 'description' }).setAttribute('content', seo.description || DEFAULTS.seo.description)
|
|
ensureMeta('meta[name="robots"]', { name: 'robots' }).setAttribute('content', seo.robotsPolicy || DEFAULTS.seo.robotsPolicy)
|
|
ensureMeta('meta[property="og:title"]', { property: 'og:title' }).setAttribute('content', seo.ogTitle || seo.title || DEFAULTS.seo.ogTitle)
|
|
ensureMeta('meta[property="og:description"]', { property: 'og:description' }).setAttribute('content', seo.ogDescription || seo.description || DEFAULTS.seo.ogDescription)
|
|
ensureMeta('meta[property="og:image"]', { property: 'og:image' }).setAttribute('content', seo.ogImage || DEFAULTS.seo.ogImage)
|
|
ensureMeta('meta[property="og:url"]', { property: 'og:url' }).setAttribute('content', seo.canonicalUrl || DEFAULTS.seo.canonicalUrl)
|
|
ensureCanonical().setAttribute('href', seo.canonicalUrl || DEFAULTS.seo.canonicalUrl)
|
|
}, [content])
|
|
|
|
return (
|
|
<>
|
|
<Routes>
|
|
<Route path="/" element={<LandingPage content={content} />} />
|
|
<Route path="/start-here" element={<StartHerePage content={content} />} />
|
|
<Route path="/study" element={<StudyRouteFrame content={content} child={<StudyLandingPage content={content} />} />} />
|
|
<Route path="/studys" element={<StudyRouteFrame content={content} child={<StudyLandingPage content={content} />} />} />
|
|
<Route path="/study/signup" element={<StudyRouteFrame content={content} child={<StudySignupPage />} />} />
|
|
<Route path="/study/account" element={<StudyRouteFrame content={content} child={<StudyAccountPage />} />} />
|
|
<Route path="/study/:studySlug" element={<StudyRouteFrame content={content} child={<ColossiansStudyIndexPage content={content} />} />} />
|
|
<Route path="/study/:studySlug/community" element={<StudyRouteFrame content={content} child={<StudyCommunityPage content={content} />} />} />
|
|
<Route path="/study/:studySlug/notes" element={<StudyRouteFrame content={content} child={<ColossiansStudyNotesPage content={content} />} />} />
|
|
<Route path="/study/:studySlug/:sectionId/quiz" element={<StudyRouteFrame content={content} child={<StudyQuizPage content={content} />} />} />
|
|
<Route path="/study/:studySlug/:sectionId" element={<StudyRouteFrame content={content} child={<ColossiansStudySectionPage content={content} />} />} />
|
|
<Route path="/episodes" element={<EpisodesPage content={content} />} />
|
|
<Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} />
|
|
<Route path="/resources" element={<ResourcesPage content={content} />} />
|
|
<Route path="/downloads/:id" element={<DownloadDetailPage content={content} />} />
|
|
<Route path="/about" element={<AboutPage content={content} />} />
|
|
<Route path="/contact" element={<ContactPage content={content} />} />
|
|
<Route path="/questions" element={<QuestionsPage content={content} />} />
|
|
<Route path="/thanks" element={<ThankYouPage />} />
|
|
<Route path="/subscribe" element={<SubscribePage />} />
|
|
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
|
|
<Route path="/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}
|
|
/>
|
|
)}
|
|
/>
|
|
</Routes>
|
|
<BackToTopButton />
|
|
</>
|
|
)
|
|
}
|
|
|
|
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>
|
|
)
|
|
}
|