Beta: admin assets, resource download forms, and resource page redesign
This commit is contained in:
+142
-561
@@ -1,6 +1,11 @@
|
||||
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'
|
||||
@@ -13,546 +18,6 @@ const AMAZON_MUSIC_URL = '/amazon'
|
||||
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 ArchivedSeriesResourceLink {
|
||||
id: string
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface ArchivedSeriesNote {
|
||||
id: string
|
||||
heading: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface ArchivedSeries {
|
||||
id: string
|
||||
label: string
|
||||
title: string
|
||||
description: string
|
||||
imageUrl: string
|
||||
listenUrl: string
|
||||
studyGuideTitle: string
|
||||
studyGuideDescription: string
|
||||
studyGuideUrl: string
|
||||
resourceLinks: ArchivedSeriesResourceLink[]
|
||||
notes: ArchivedSeriesNote[]
|
||||
}
|
||||
|
||||
export interface RedirectRule {
|
||||
id: string
|
||||
path: string
|
||||
target: string
|
||||
statusCode: 301 | 302
|
||||
}
|
||||
|
||||
export interface PodcastFeaturedLink {
|
||||
id: string
|
||||
title: string
|
||||
episodeNumber?: string
|
||||
summary: string
|
||||
url: string
|
||||
embedUrl?: string
|
||||
showNotes?: string
|
||||
discussionQuestions?: string[]
|
||||
}
|
||||
|
||||
export interface SeoSettings {
|
||||
title: string
|
||||
description: string
|
||||
ogTitle: string
|
||||
ogDescription: string
|
||||
ogImage: string
|
||||
canonicalUrl: string
|
||||
robotsPolicy: string
|
||||
sitemapPaths: string[]
|
||||
}
|
||||
|
||||
export interface LegalSettings {
|
||||
privacyTitle: string
|
||||
privacyBody: string[]
|
||||
termsTitle: string
|
||||
termsBody: string[]
|
||||
}
|
||||
|
||||
export interface SiteContent {
|
||||
eyebrow: string
|
||||
heroTagline: string
|
||||
startHereHeading: string
|
||||
startHereIntro: string
|
||||
startHereStep1Title: string
|
||||
startHereStep1Body: string
|
||||
startHereStep1Cta: string
|
||||
startHereStep2Title: string
|
||||
startHereStep2Body: string
|
||||
startHereStep2Cta: string
|
||||
startHereStep3Title: string
|
||||
startHereStep3Body: string
|
||||
startHereStep3Cta: string
|
||||
aboutShowHeading: string
|
||||
aboutShowP1: string
|
||||
aboutShowP2: string
|
||||
aboutNate: string
|
||||
seriesLabel: string
|
||||
seriesTitle: string
|
||||
seriesDescription: string
|
||||
seriesImageUrl: string
|
||||
seriesListenUrl: string
|
||||
studyGuideTitle: string
|
||||
studyGuideDescription: string
|
||||
studyGuideUrl: string
|
||||
shareHeading: string
|
||||
shareP: string
|
||||
customLinks: CustomLink[]
|
||||
customBlocks: CustomBlock[]
|
||||
archivedSeries: ArchivedSeries[]
|
||||
redirects: RedirectRule[]
|
||||
podcastFeaturedLinks: PodcastFeaturedLink[]
|
||||
seo: SeoSettings
|
||||
legal: LegalSettings
|
||||
}
|
||||
|
||||
export const DEFAULTS: SiteContent = {
|
||||
eyebrow: 'A Journey Through Scripture',
|
||||
heroTagline: "Exploring God's Word one verse at a time",
|
||||
startHereHeading: 'New Here? Start Here',
|
||||
startHereIntro:
|
||||
'If this is your first visit, this path helps you get grounded quickly and make the most of the site.',
|
||||
startHereStep1Title: 'Step 1: Listen to 3 Starter Episodes',
|
||||
startHereStep1Body:
|
||||
'Start with the newest episode, one from the beginning of the current study, and one from the middle.',
|
||||
startHereStep1Cta: 'Open Episodes',
|
||||
startHereStep2Title: 'Step 2: Use Q&A to Go Deeper',
|
||||
startHereStep2Body:
|
||||
'Browse by topic or search key words to find concise biblical answers and related follow-up questions.',
|
||||
startHereStep2Cta: 'Go to Q&A',
|
||||
startHereStep3Title: 'Step 3: Ask Nate Directly',
|
||||
startHereStep3Body:
|
||||
'Use the contact form to submit your Bible question for future Q&A or an upcoming episode.',
|
||||
startHereStep3Cta: 'Submit a Question',
|
||||
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's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
|
||||
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.",
|
||||
seriesImageUrl: '/images/titus-cover.png',
|
||||
seriesListenUrl: SPOTIFY_SHOW_URL,
|
||||
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: [],
|
||||
archivedSeries: [],
|
||||
redirects: [
|
||||
{
|
||||
id: 'spotify',
|
||||
path: '/spotify',
|
||||
target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl',
|
||||
statusCode: 301,
|
||||
},
|
||||
{
|
||||
id: 'apple',
|
||||
path: '/apple',
|
||||
target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate',
|
||||
statusCode: 301,
|
||||
},
|
||||
{
|
||||
id: 'amazon',
|
||||
path: '/amazon',
|
||||
target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate',
|
||||
statusCode: 301,
|
||||
},
|
||||
],
|
||||
podcastFeaturedLinks: [],
|
||||
seo: {
|
||||
title: 'Verse by Verse with Nate',
|
||||
description: "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
|
||||
ogTitle: 'Verse by Verse with Nate',
|
||||
ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.',
|
||||
ogImage: '/images/podcast-art.jpeg',
|
||||
canonicalUrl: 'https://versebyversewithnate.us/',
|
||||
robotsPolicy: 'index,follow',
|
||||
sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'],
|
||||
},
|
||||
legal: {
|
||||
privacyTitle: 'Privacy Policy',
|
||||
privacyBody: [
|
||||
'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.',
|
||||
],
|
||||
termsTitle: 'Terms',
|
||||
termsBody: [
|
||||
'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.',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
interface PublicQuestion {
|
||||
id: string
|
||||
firstName: string
|
||||
question: string
|
||||
answer: string
|
||||
topic?: string
|
||||
}
|
||||
|
||||
|
||||
const QA_PAGE_SIZE = 6
|
||||
|
||||
function QASection() {
|
||||
const [questions, setQuestions] = useState<PublicQuestion[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<{ [key: string]: boolean }>({})
|
||||
const [page, setPage] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/questions')
|
||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load questions'))))
|
||||
.then(data => {
|
||||
setQuestions((data as { questions: PublicQuestion[] }).questions ?? [])
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const topics = Array.from(new Set(questions.map(q => q.topic).filter(Boolean))) as string[]
|
||||
|
||||
const filteredQuestions = questions.filter(q => {
|
||||
const matchesTopic = !selectedTopic || q.topic === selectedTopic
|
||||
const matchesSearch = !searchQuery ||
|
||||
q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
q.answer.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
return matchesTopic && matchesSearch
|
||||
})
|
||||
|
||||
const totalPages = Math.ceil(filteredQuestions.length / QA_PAGE_SIZE)
|
||||
const pagedQuestions = filteredQuestions.slice(page * QA_PAGE_SIZE, (page + 1) * QA_PAGE_SIZE)
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpanded(e => ({ ...e, [id]: !e[id] }))
|
||||
}
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchQuery(value)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const handleTopic = (topic: string | null) => {
|
||||
setSelectedTopic(topic)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const tokenizeForRelated = (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(token => token.length > 3)
|
||||
|
||||
const getRelatedQuestions = (current: PublicQuestion) => {
|
||||
const currentTokens = new Set(tokenizeForRelated(`${current.question} ${current.answer}`))
|
||||
|
||||
const related = questions
|
||||
.filter(candidate => candidate.id !== current.id)
|
||||
.map(candidate => {
|
||||
const candidateTokens = tokenizeForRelated(`${candidate.question} ${candidate.answer}`)
|
||||
const overlap = candidateTokens.filter(token => currentTokens.has(token)).length
|
||||
const sameTopic = Boolean(current.topic && candidate.topic && current.topic === candidate.topic)
|
||||
const score = overlap + (sameTopic ? 5 : 0)
|
||||
return { candidate, score }
|
||||
})
|
||||
.filter(item => item.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 3)
|
||||
.map(item => item.candidate)
|
||||
|
||||
return related
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="qa" className="section-qa" aria-label="Questions and answers">
|
||||
<div className="section-inner">
|
||||
<h2 className="section-heading">
|
||||
<span className="ornament">✦</span> Questions & Answers <span className="ornament">✦</span>
|
||||
</h2>
|
||||
|
||||
{loading ? (
|
||||
<p className="qa-no-results">Loading questions…</p>
|
||||
) : questions.length === 0 ? (
|
||||
<div className="qa-no-results">
|
||||
<p>No questions have been answered yet. <a href="#contact">Submit yours below!</a></p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="qa-filters">
|
||||
<div className="qa-topics">
|
||||
<button
|
||||
className={`qa-topic-btn${selectedTopic === null ? ' qa-topic-btn--active' : ''}`}
|
||||
onClick={() => handleTopic(null)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{topics.map(topic => (
|
||||
<button
|
||||
key={topic}
|
||||
className={`qa-topic-btn${selectedTopic === topic ? ' qa-topic-btn--active' : ''}`}
|
||||
onClick={() => handleTopic(topic)}
|
||||
>
|
||||
{topic}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="qa-search">
|
||||
<label>
|
||||
<span className="visually-hidden">Search Questions</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search questions…"
|
||||
value={searchQuery}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredQuestions.length === 0 ? (
|
||||
<div className="qa-no-results">
|
||||
<p>No matching questions found. <a href="#contact">Submit your question</a></p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="qa-cards">
|
||||
{pagedQuestions.map(question => (
|
||||
<div key={question.id} className="qa-card-scene">
|
||||
<div
|
||||
className={`qa-card-inner ${expanded[question.id] ? 'flipped' : ''}`}
|
||||
onClick={() => toggleExpanded(question.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={!!expanded[question.id]}
|
||||
aria-label={question.question}
|
||||
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && toggleExpanded(question.id)}
|
||||
>
|
||||
<div className="qa-card-face qa-card-front">
|
||||
<span className="qa-face-label">Q</span>
|
||||
<p className="qa-question-text">{question.question}</p>
|
||||
<span className="qa-flip-hint">{expanded[question.id] ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
<div className="qa-card-face qa-card-back">
|
||||
<span className="qa-face-label">A</span>
|
||||
<p className="qa-answer-text">{question.answer}</p>
|
||||
{getRelatedQuestions(question).length > 0 && (
|
||||
<div className="qa-related-wrap">
|
||||
<p className="qa-related-label">Related questions</p>
|
||||
<div className="qa-related-list">
|
||||
{getRelatedQuestions(question).map(related => (
|
||||
<button
|
||||
key={related.id}
|
||||
type="button"
|
||||
className="qa-related-btn"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleTopic(null)
|
||||
handleSearch(related.question)
|
||||
setExpanded({ [related.id]: true })
|
||||
}}
|
||||
>
|
||||
{related.question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p style={{ margin: '0.75rem 0 0', fontSize: '0.8rem', color: '#a89060', fontStyle: 'italic' }}>— Answered by Nate</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="qa-pagination">
|
||||
<button
|
||||
className="qa-page-btn"
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
disabled={page === 0}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
<span className="qa-page-info">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
className="qa-page-btn"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= totalPages - 1}
|
||||
aria-label="Next page"
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ContactForm() {
|
||||
const navigate = useNavigate()
|
||||
const [fields, setFields] = useState({ firstName: '', lastName: '', email: '', message: '', messageType: 'question' })
|
||||
const [subscribe, setSubscribe] = useState(true)
|
||||
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>
|
||||
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>
|
||||
<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 StudyDownloadForm() {
|
||||
const [fields, setFields] = useState({ firstName: '', lastName: '', email: '' })
|
||||
const [subscribe, setSubscribe] = useState(true)
|
||||
@@ -636,6 +101,89 @@ function StudyDownloadForm() {
|
||||
)
|
||||
}
|
||||
|
||||
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('')
|
||||
|
||||
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('')
|
||||
|
||||
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.')
|
||||
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>}
|
||||
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
|
||||
{status === 'submitting' ? 'Preparing Download...' : buttonText}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function AnalyticsConsentBanner() {
|
||||
const [choice, setChoice] = useState<'unknown' | 'accepted' | 'declined'>(() => {
|
||||
const saved = localStorage.getItem(CONSENT_KEY)
|
||||
@@ -776,11 +324,6 @@ function LatestEpisodesList() {
|
||||
|
||||
function SiteHeader() {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
setMenuOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
return (
|
||||
<header className="site-header">
|
||||
@@ -796,11 +339,11 @@ function SiteHeader() {
|
||||
{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' : ''}`}>Home</NavLink>
|
||||
<NavLink to="/episodes" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`}>Episodes</NavLink>
|
||||
<NavLink to="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`}>Resources</NavLink>
|
||||
<NavLink to="/about" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`}>About</NavLink>
|
||||
<NavLink to="/contact" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`}>Contact</NavLink>
|
||||
<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)}>Resources</NavLink>
|
||||
<NavLink to="/about" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>About</NavLink>
|
||||
<NavLink to="/contact" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Contact</NavLink>
|
||||
<a
|
||||
href={SPOTIFY_SHOW_URL}
|
||||
target="_blank"
|
||||
@@ -892,7 +435,7 @@ function AboutSection({ content }: { content: SiteContent }) {
|
||||
<section className="section-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" />
|
||||
<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>
|
||||
@@ -908,7 +451,7 @@ function AboutSection({ content }: { content: SiteContent }) {
|
||||
<p>{content.aboutShowP2}</p>
|
||||
<div className="rule-divider" aria-hidden="true" />
|
||||
<img
|
||||
src="/images/hebrews-4-12-verse-art.png"
|
||||
src={content.aboutVerseArtUrl || '/images/hebrews-4-12-verse-art.png'}
|
||||
alt="Hebrews 4:12 verse artwork"
|
||||
className="about-verse-art"
|
||||
/>
|
||||
@@ -952,7 +495,7 @@ function StudyGuideSection({ content }: { content: SiteContent }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ContactSection() {
|
||||
function ContactSection({ content }: { content: SiteContent }) {
|
||||
return (
|
||||
<section className="section-contact" id="contact" aria-label="Contact form">
|
||||
<div className="section-inner contact-inner">
|
||||
@@ -963,7 +506,7 @@ function ContactSection() {
|
||||
|
||||
<div className="contact-profile">
|
||||
<img
|
||||
src="/images/nate-contact-photo.png"
|
||||
src={content.contactPhotoUrl || '/images/nate-contact-photo.png'}
|
||||
alt="Nate"
|
||||
className="contact-profile-photo"
|
||||
/>
|
||||
@@ -1059,10 +602,21 @@ function CustomResourcesSection({ content }: { content: SiteContent }) {
|
||||
<span className="ornament">✦</span>
|
||||
</h2>
|
||||
<div className="resources-list">
|
||||
{resources.map(l => (
|
||||
<a key={l.id} href={l.url} target="_blank" rel="noreferrer" className="resource-link">
|
||||
{l.label} →
|
||||
</a>
|
||||
{resources.map(resource => (
|
||||
<article key={resource.id} className="resource-download-card">
|
||||
<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.tags ?? []).length > 0 && (
|
||||
<div className="resource-link-tags">{(resource.tags ?? []).join(', ')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ResourceDownloadForm resourceId={resource.id} buttonText={`Download ${resource.label}`} />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1349,7 +903,7 @@ function ContactPage({ content }: { content: SiteContent }) {
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader />
|
||||
<ContactSection />
|
||||
<ContactSection content={content} />
|
||||
<SiteFooter content={content} />
|
||||
<AnalyticsConsentBanner />
|
||||
</div>
|
||||
@@ -1789,8 +1343,9 @@ export default function App() {
|
||||
}, [content])
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage content={content} />} />
|
||||
<>
|
||||
<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} />} />
|
||||
@@ -1822,5 +1377,31 @@ export default function App() {
|
||||
)}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user