Files
Siteforge/src/App.tsx
T

651 lines
24 KiB
TypeScript

import { useState, useEffect } from 'react'
import { Link, Routes, Route, useNavigate } from 'react-router-dom'
import AdminPage from './AdminPage'
import './App.css'
const SPOTIFY_SHOW_URL = 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl'
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 =
'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate'
const YOUTUBE_URL = 'https://www.youtube.com/@blackzebraem5558'
const AMAZON_MUSIC_URL =
'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate'
const FACEBOOK_URL = 'https://facebook.com/versebyversewithnate'
const CONSENT_KEY = 'vbn_analytics_consent_choice'
function FacebookIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.234 2.686.234v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z" />
</svg>
)
}
export interface CustomLink {
id: string
label: string
url: string
placement: 'platforms' | 'footer' | 'resources'
}
export interface CustomBlock {
id: string
heading: string
body: string
}
export interface SiteContent {
eyebrow: string
heroTagline: string
aboutShowHeading: string
aboutShowP1: string
aboutShowP2: string
aboutNate: string
seriesLabel: string
seriesTitle: string
seriesDescription: string
studyGuideTitle: string
studyGuideDescription: string
studyGuideUrl: string
shareHeading: string
shareP: string
customLinks: CustomLink[]
customBlocks: CustomBlock[]
}
export const DEFAULTS: SiteContent = {
eyebrow: 'A Journey Through Scripture',
heroTagline: "Exploring God's Word one verse at a time",
aboutShowHeading: 'Depth. Clarity. Application.',
aboutShowP1:
'Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.',
aboutShowP2:
"Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
aboutNate:
'Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He created Verse by Verse to share the joy of deep Scripture study in a format anyone can follow along with — no seminary required.',
seriesLabel: 'Now Playing',
seriesTitle: 'Study of Titus: Sound Doctrine',
seriesDescription:
"A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
studyGuideTitle: 'Companion Study Guide',
studyGuideDescription:
'Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.',
studyGuideUrl: 'https://a.co/d/01sG2tOJ',
shareHeading: 'Help one more person hear the Word this week.',
shareP: 'Scan the QR code or text the show link to a friend who needs encouragement today.',
customLinks: [],
customBlocks: [],
}
function SpotifyIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />
</svg>
)
}
function YouTubeIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
)
}
function AmazonMusicIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
</svg>
)
}
function ContactForm() {
const navigate = useNavigate()
const [fields, setFields] = useState({ name: '', email: '', message: '', messageType: 'question' })
const [subscribe, setSubscribe] = useState(false)
const [honey, setHoney] = useState('')
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
}
function handleSelectChange(e: React.ChangeEvent<HTMLSelectElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
}
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({ ...fields, subscribe, _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('/thanks')
} catch {
setErrorMsg('Could not connect. Please try again later.')
setStatus('error')
}
}
return (
<form className="contact-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>
Name
<input type="text" name="name" required autoComplete="name" value={fields.name} onChange={handleChange} />
</label>
<label>
Email
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
</label>
<label>
Message Type
<select name="messageType" value={fields.messageType} onChange={handleSelectChange}>
<option value="question">Bible Question</option>
<option value="testimony">Testimony</option>
<option value="topic">Topic Request</option>
<option value="general">General Message</option>
</select>
</label>
<label>
Message
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
</label>
<label className="contact-consent">
<input
type="checkbox"
checked={subscribe}
onChange={e => setSubscribe(e.target.checked)}
/>
<span>Send me updates from Verse by Verse with Nate. I can unsubscribe anytime.</span>
</label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Sending…' : 'Send Message'}
</button>
</form>
)
}
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>
)
}
function LandingPage({ content }: { content: SiteContent }) {
return (
<div className="site">
{/* ── HEADER ── */}
<header className="site-header">
<div className="header-inner">
<span className="header-ornament"> &nbsp; &nbsp; </span>
<nav className="header-nav">
<a href="#listen">Listen</a>
<a href="#about">About</a>
<a href="#series">Series</a>
<a href="#contact">Contact</a>
<a
href={SPOTIFY_SHOW_URL}
target="_blank"
rel="noreferrer"
className="header-cta"
>
Subscribe Free
</a>
</nav>
</div>
</header>
{/* ── 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">
<span className="verse-word">VERSE</span>
<span className="by-label">· by ·</span>
<span className="verse-word">VERSE</span>
</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>
<a href="#listen" className="btn-secondary">
All Platforms
</a>
</div>
</div>
</section>
{/* ── PLAYER ── */}
<section className="section-player" id="listen" aria-label="Podcast player">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament"></span> Latest Episodes{' '}
<span className="ornament"></span>
</h2>
<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>
<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>
</div>
</section>
{/* ── ABOUT ── */}
<section className="section-about" id="about" aria-label="About the show">
<div className="section-inner about-inner">
<div className="about-photo">
<img src="/images/nate-photo.jpeg" alt="Nate Emmert" />
</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" />
<p className="eyebrow">About Nate</p>
<p>{content.aboutNate}</p>
</div>
</div>
</section>
{/* ── SERIES ── */}
<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="/images/titus-cover.png"
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={SPOTIFY_SHOW_URL}
target="_blank"
rel="noreferrer"
className="btn-primary"
>
<SpotifyIcon />
Listen to Series
</a>
</div>
</article>
</div>
</div>
</section>
{/* ── STUDY GUIDE ── */}
<section className="section-guide" aria-label="Companion study guide">
<div className="section-inner guide-inner">
<div className="guide-icon" aria-hidden="true">
<svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="8" y="6" width="36" height="48" rx="3" fill="#c8860a" opacity="0.15" stroke="#c8860a" strokeWidth="1.5"/>
<rect x="14" y="6" width="36" height="48" rx="3" fill="#0f0f0f" stroke="#c8860a" strokeWidth="1.5"/>
<line x1="22" y1="22" x2="42" y2="22" stroke="#c8860a" strokeWidth="1.5" strokeLinecap="round"/>
<line x1="22" y1="30" x2="42" y2="30" stroke="#c8860a" strokeWidth="1.5" strokeLinecap="round" opacity="0.6"/>
<line x1="22" y1="38" x2="34" y2="38" stroke="#c8860a" strokeWidth="1.5" strokeLinecap="round" opacity="0.4"/>
</svg>
</div>
<div className="guide-text">
<p className="eyebrow">Available on Amazon</p>
<h2>{content.studyGuideTitle}</h2>
<p>{content.studyGuideDescription}</p>
<a
href={content.studyGuideUrl}
target="_blank"
rel="noreferrer"
className="btn-primary"
>
Get the Book
</a>
</div>
</div>
</section>
{/* ── SHARE / QR ── */}
<section className="section-qr" aria-label="Share the show">
<div className="section-inner qr-inner">
<div className="qr-text">
<p className="eyebrow">Share the Show</p>
<h2>{content.shareHeading}</h2>
<p>{content.shareP}</p>
<a
href={SPOTIFY_CREATOR_URL}
target="_blank"
rel="noreferrer"
className="btn-secondary"
>
creators.spotify.com/pod/profile/nmemmert
</a>
</div>
<div className="qr-code-wrap">
<div className="qr-frame">
<img
src="/images/qr-code.jpg"
alt="Scan to listen to Verse by Verse with Nate"
className="qr-code"
/>
</div>
<p className="scan-label">SCAN TO LISTEN</p>
</div>
</div>
</section>
{/* ── CONTACT ── */}
<section className="section-contact" id="contact" aria-label="Contact form">
<div className="section-inner contact-inner">
<div className="contact-copy">
<p className="eyebrow">Get in Touch</p>
<h2 className="section-heading">
<span className="ornament"></span> Contact Nate <span className="ornament"></span>
</h2>
<p className="contact-highlight">
Ask a Bible question, share a testimony, or request a topic for a future episode.
</p>
<p>We usually respond within 48 hours.</p>
</div>
<ContactForm />
</div>
</section>
{/* ── CUSTOM RESOURCES ── */}
{(content.customLinks ?? []).filter(l => l.placement === 'resources').length > 0 && (
<section className="section-resources" aria-label="More resources">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament"></span> More Resources{' '}
<span className="ornament"></span>
</h2>
<div className="resources-list">
{(content.customLinks ?? []).filter(l => l.placement === 'resources').map(l => (
<a key={l.id} href={l.url} target="_blank" rel="noreferrer" className="resource-link">
{l.label}
</a>
))}
</div>
</div>
</section>
)}
{/* ── CUSTOM BLOCKS ── */}
{(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>
))}
{/* ── FOOTER ── */}
<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>
<AnalyticsConsentBanner />
</div>
)
}
function ThankYouPage() {
const navigate = useNavigate()
const [secondsLeft, setSecondsLeft] = useState(15)
useEffect(() => {
const timeoutId = window.setTimeout(() => {
navigate('/')
}, 15000)
const intervalId = window.setInterval(() => {
setSecondsLeft(s => (s > 0 ? s - 1 : 0))
}, 1000)
return () => {
window.clearTimeout(timeoutId)
window.clearInterval(intervalId)
}
}, [navigate])
return (
<main className="thanks-page" aria-label="Thank you">
<div className="thanks-card">
<p className="eyebrow">Message Received</p>
<h1>Thank You</h1>
<p>
Your message was sent successfully. We appreciate you reaching out and will get back to you soon.
</p>
<p className="thanks-countdown">Returning to the main site in {secondsLeft} seconds...</p>
<button type="button" className="btn-primary" onClick={() => navigate('/')}>
Go Back Now
</button>
</div>
</main>
)
}
function 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>
)
}
export default function App() {
const [content, setContent] = useState<SiteContent>(DEFAULTS)
useEffect(() => {
fetch('/api/admin-content')
.then(r => (r.ok ? r.json() : null))
.then(data => {
if (data?.siteContent) {
setContent(c => ({ ...c, ...data.siteContent }))
}
})
.catch(() => {})
}, [])
return (
<Routes>
<Route path="/" element={<LandingPage content={content} />} />
<Route path="/thanks" element={<ThankYouPage />} />
<Route path="/admin" element={<AdminPage content={content} onSave={setContent} />} />
<Route
path="/privacy"
element={(
<LegalPage
title="Privacy Policy"
body={[
'We respect your privacy and collect limited data to operate and improve this site.',
'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.',
'Contact form details are used only to respond to your message and ministry communication requests.',
]}
/>
)}
/>
<Route
path="/terms"
element={(
<LegalPage
title="Terms"
body={[
'Content on this site is for informational and ministry purposes.',
'External links are provided for convenience and are subject to third-party policies.',
'By using this site, you agree to lawful use and respectful communication.',
]}
/>
)}
/>
</Routes>
)
}