Files
Siteforge/src/App.tsx
T

1628 lines
57 KiB
TypeScript

import { useState, useEffect } 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 { FacebookIcon, SpotifyIcon, YouTubeIcon, AmazonMusicIcon } from './icons'
import type { SiteContent } from './content'
import { DEFAULTS } from './content'
import './App.css'
const SPOTIFY_SHOW_URL = '/spotify'
const SPOTIFY_EMBED_URL =
'https://open.spotify.com/embed/show/0Gq1TzoJOdReSZ1gYQi8Xl?utm_source=generator&theme=0'
const SPOTIFY_CREATOR_URL = 'https://creators.spotify.com/pod/profile/nmemmert/'
const APPLE_PODCASTS_URL = '/apple'
const YOUTUBE_URL = 'https://www.youtube.com/@blackzebraem5558'
const AMAZON_MUSIC_URL = '/amazon'
const FACEBOOK_URL = 'https://facebook.com/versebyversewithnate'
const CONSENT_KEY = 'vbn_analytics_consent_choice'
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
}
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),
}
}
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),
}
}
return null
}
function AnalyticsConsentBanner() {
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>
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 LatestEpisodesList() {
const [episodes, setEpisodes] = useState<Episode[]>([])
const [loading, setLoading] = useState(true)
const [failed, setFailed] = useState(false)
useEffect(() => {
fetch('/api/episodes')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('fetch failed'))))
.then((data: { episodes: Episode[] }) => {
if (data.episodes.length === 0) setFailed(true)
else setEpisodes(data.episodes)
setLoading(false)
})
.catch(() => {
setFailed(true)
setLoading(false)
})
}, [])
if (loading) {
return (
<div className="episode-list-loading">
{[1, 2, 3].map(i => <div key={i} className="episode-skeleton" aria-hidden="true" />)}
</div>
)
}
if (failed) {
return (
<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>
)
}
return (
<div className="episode-list">
{episodes.map((ep, idx) => (
<a
key={idx}
href={ep.link || SPOTIFY_SHOW_URL}
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>
)
}
function SiteHeader() {
const [menuOpen, setMenuOpen] = useState(false)
return (
<header className="site-header">
<div className="header-inner">
<span className="header-ornament"> &nbsp; &nbsp; </span>
<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="/" end className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Home</NavLink>
<NavLink to="/episodes" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Episodes</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={SPOTIFY_SHOW_URL}
target="_blank"
rel="noreferrer"
className="header-cta"
>
Follow on Spotify
</a>
</nav>
</div>
</header>
)
}
function SiteFooter({ content }: { content: SiteContent }) {
return (
<footer className="site-footer">
<p className="footer-ornament"> &nbsp; &nbsp; </p>
<p className="footer-title">Verse by Verse with Nate</p>
<p className="footer-sub">A Journey Through Scripture</p>
<p className="footer-contact">
Contact:{' '}
<a href="mailto:hello@versebyversewithnate.us">hello@versebyversewithnate.us</a>
</p>
<p className="footer-response">We usually reply within 48 hours.</p>
<nav className="footer-links" aria-label="Footer links">
<a href={SPOTIFY_SHOW_URL} target="_blank" rel="noreferrer">Spotify</a>
<span aria-hidden="true">·</span>
<a href={APPLE_PODCASTS_URL} target="_blank" rel="noreferrer">Apple Podcasts</a>
<span aria-hidden="true">·</span>
<a href={YOUTUBE_URL} target="_blank" rel="noreferrer">YouTube</a>
<span aria-hidden="true">·</span>
<a href={AMAZON_MUSIC_URL} target="_blank" rel="noreferrer">Amazon Music</a>
<span aria-hidden="true">·</span>
<a href={SPOTIFY_CREATOR_URL} target="_blank" rel="noreferrer">Creator Profile</a>
<span aria-hidden="true">·</span>
<a href={FACEBOOK_URL} target="_blank" rel="noreferrer">Facebook</a>
{(content.customLinks ?? []).filter(l => l.placement === 'footer').flatMap(l => [
<span key={`sep-${l.id}`} aria-hidden="true">·</span>,
<a key={l.id} href={l.url} target="_blank" rel="noreferrer">{l.label}</a>,
])}
</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">© 2026 Nate Emmert · Made with faith.</p>
<p className="footer-privacy">
Privacy: with consent, analytics may store masked IP-based location data (country/state/county/city) and returning visitor activity.
</p>
</footer>
)
}
function PlatformButtons({ content }: { content: SiteContent }) {
return (
<div className="platform-buttons">
<a href={SPOTIFY_SHOW_URL} target="_blank" rel="noreferrer" className="platform-btn spotify-btn">
<SpotifyIcon />
Listen on Spotify
</a>
<a href={YOUTUBE_URL} target="_blank" rel="noreferrer" className="platform-btn youtube-btn">
<YouTubeIcon />
YouTube
</a>
<a href={AMAZON_MUSIC_URL} target="_blank" rel="noreferrer" className="platform-btn amazon-btn">
<AmazonMusicIcon />
Amazon Music
</a>
<a href={FACEBOOK_URL} target="_blank" rel="noreferrer" className="platform-btn facebook-btn">
<FacebookIcon />
Facebook
</a>
<a href={APPLE_PODCASTS_URL} target="_blank" rel="noreferrer" className="platform-btn apple-btn">
<img src="/images/apple-podcasts-badge.svg" alt="Listen on Apple Podcasts" className="apple-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 custom-btn">
{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">About Nate</p>
<div className="about-nate-rule" aria-hidden="true" />
<p>{content.aboutNate}</p>
<Link to="/episodes" className="about-nate-cta">Listen Now </Link>
</div>
</div>
<div className="about-text">
<p className="eyebrow">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"
>
Get Printed Copy 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">Get in Touch</p>
<h2>Contact Nate</h2>
<div className="contact-profile">
<img
src={content.contactPhotoUrl || '/images/nate-contact-photo.png'}
alt="Nate"
className="contact-profile-photo"
/>
<div className="contact-profile-meta">
<p className="contact-profile-name">Nate</p>
<p className="contact-profile-role">Bible teacher · Lynchburg, VA</p>
</div>
</div>
<blockquote className="contact-quote">
<p>"I read every message - nothing blesses me more than hearing how God's Word is at work in your life."</p>
</blockquote>
<p className="contact-intro">
Ask a Bible question, share a testimony, or request a topic for a future episode.
</p>
<div className="contact-points" aria-label="Contact response details">
<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>I read every message personally</span>
</div>
<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>Response within 48 hours</span>
</div>
</div>
<div className="contact-scripture">
<p>
"Your word is a lamp to my feet and a light to my path." -{' '}
<span className="contact-scripture-ref">Psalm 119:105</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 || (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">
<Link to="/resources" className="btn-secondary">Back to Downloads</Link>
</div>
</div>
</main>
)
}
function CustomBlocksSection({ content }: { content: SiteContent }) {
return (
<>
{(content.customBlocks ?? []).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 LandingPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader />
{/* ── 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={SPOTIFY_SHOW_URL}
target="_blank"
rel="noreferrer"
className="btn-primary"
>
<SpotifyIcon />
Listen on Spotify
</a>
<Link to="/episodes" className="btn-secondary">Explore Episodes </Link>
</div>
</div>
</section>
<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 || SPOTIFY_SHOW_URL}
target="_blank"
rel="noreferrer"
className="btn-primary"
>
<SpotifyIcon />
Listen to Series
</a>
<Link to="/resources" className="btn-secondary">View Downloads</Link>
</div>
</article>
</div>
</div>
</section>
<section className="section-home-jump" aria-label="Homepage navigation">
<div className="section-inner">
<div className="home-jump-head">
<p className="eyebrow">Where to Next</p>
<h2 className="section-heading">Choose the page you need.</h2>
</div>
<div className="home-jump-grid">
<Link to="/episodes" className="home-jump-card">
<h3>Episodes</h3>
<p>Listen to latest episodes and platform links.</p>
</Link>
<Link to="/resources" className="home-jump-card">
<h3>Downloads</h3>
<p>Main guide, extra downloads, and past study files.</p>
</Link>
<Link to="/questions" className="home-jump-card">
<h3>Q&amp;A</h3>
<p>Browse Bible questions and approved answers.</p>
</Link>
<Link to="/about" className="home-jump-card">
<h3>About</h3>
<p>Learn about Nate and the mission of the show.</p>
</Link>
<Link to="/contact" className="home-jump-card">
<h3>Contact</h3>
<p>Submit a Bible question, testimony, or topic.</p>
</Link>
</div>
</div>
</section>
<SiteFooter content={content} />
<AnalyticsConsentBanner />
</div>
)
}
function EpisodeDetailPage({ content }: { content: SiteContent }) {
const { id } = useParams<{ id: string }>()
const episode = (content.podcastFeaturedLinks ?? []).find(e => e.id === id)
if (!episode) {
return (
<div className="site">
<SiteHeader />
<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 />
</div>
)
}
const questions = episode.discussionQuestions ?? []
return (
<div className="site">
<SiteHeader />
<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 && (
<div className="episode-detail-embed">
<iframe
src={episode.embedUrl}
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 />
</div>
)
}
function EpisodesPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader />
<section className="section-player" aria-label="Podcast episodes">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament">✦</span> Latest Episodes{' '}
<span className="ornament">✦</span>
</h2>
<LatestEpisodesList />
<PlatformButtons content={content} />
</div>
</section>
<PodcastHighlightsSection content={content} />
<SiteFooter content={content} />
<AnalyticsConsentBanner />
</div>
)
}
function ResourcesPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader />
<StudyGuideSection content={content} />
<DownloadLibrarySection content={content} />
<CustomBlocksSection content={content} />
<SiteFooter content={content} />
<AnalyticsConsentBanner />
</div>
)
}
function AboutPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader />
<AboutSection content={content} />
<SiteFooter content={content} />
<AnalyticsConsentBanner />
</div>
)
}
function ContactPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader />
<ContactSection content={content} />
<SiteFooter content={content} />
<AnalyticsConsentBanner />
</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 [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')
})
}, [])
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={content} onSave={onSave} 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() {
return (
<main className="thanks-page" aria-label="Questions and Answers">
<div className="thanks-card" style={{ maxWidth: '1000px', width: '100%' }}>
<QASection />
<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')
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')
})
}, [])
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>
)
}
return <LandingPage content={content} />
}
export default function App() {
const [content, setContent] = useState<SiteContent>(DEFAULTS)
const navigate = useNavigate()
const location = useLocation()
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="/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 />} />
<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>
)
}