651 lines
24 KiB
TypeScript
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">✦ ✦ ✦</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">✦ ✦ ✦</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>
|
|
)
|
|
}
|