feat: homepage newsletter, Q&A callout, episodes page redesign, SEO/sitemap, asset tagging

- Fix "Invalid Date" on student account page (memberSince guard)
- Fix study community author showing full email instead of username
- Add newsletter sign-up section to homepage (between PRISM and Current Series)
- Add newsletter opt-in nudge to study enrollment flow
- Redesign episodes page: latest episode featured card + per-episode Spotify embed
- Add SEO description field to episodes page (visually hidden, admin-editable)
- Expand default sitemap paths to include /about, /contact, /episodes, /resources, /study, /study/titus, /study/colossians
- Add featured Q&A callout section to homepage
- Add Q&A callout to study hub footer
- Tag both untagged image assets in Asset Manager
This commit is contained in:
nmemmert
2026-06-03 10:56:55 -04:00
parent 01abb2c8c6
commit b187b19f18
11 changed files with 382 additions and 15 deletions
+145 -1
View File
@@ -978,6 +978,107 @@ function ExternalSitesSection({ content }: { content: SiteContent }) {
)
}
function HomepageNewsletterSection() {
const [email, setEmail] = useState('')
const [firstName, setFirstName] = useState('')
const [honey, setHoney] = useState('')
const [status, setStatus] = useState<'idle' | 'submitting' | 'done' | 'error'>('idle')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (honey) return
setStatus('submitting')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ firstName, email, messageType: 'general', message: 'Newsletter signup from homepage', subscribe: true, _honey: honey }),
})
setStatus(res.ok ? 'done' : 'error')
} catch {
setStatus('error')
}
}
return (
<section className="section-newsletter" aria-label="Newsletter sign-up">
<div className="section-inner newsletter-inner">
<p className="eyebrow">Stay in the Word</p>
<h2 className="section-heading">Get episode updates by email</h2>
<p className="newsletter-sub">New episodes, study resources, and ministry updates delivered to your inbox.</p>
{status === 'done' ? (
<p className="newsletter-thanks">You're subscribed! Thank you for signing up.</p>
) : (
<form className="newsletter-form" onSubmit={handleSubmit} noValidate>
<input type="text" className="contact-honeypot" tabIndex={-1} autoComplete="off" aria-hidden="true" value={honey} onChange={e => setHoney(e.target.value)} />
<div className="newsletter-fields">
<input
type="text"
placeholder="First name"
required
autoComplete="given-name"
value={firstName}
onChange={e => setFirstName(e.target.value)}
className="newsletter-input"
/>
<input
type="email"
placeholder="Email address"
required
autoComplete="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="newsletter-input"
/>
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Subscribing' : 'Subscribe'}
</button>
</div>
{status === 'error' && <p className="newsletter-error">Something went wrong. Please try again.</p>}
</form>
)}
</div>
</section>
)
}
function HomepageQACallout() {
const [questions, setQuestions] = useState<{ id: string; firstName: string; question: string; answer: string }[]>([])
useEffect(() => {
fetch('/api/questions')
.then(r => (r.ok ? r.json() : Promise.reject()))
.then((data: { questions: { id: string; firstName: string; question: string; answer: string }[] }) => {
setQuestions((data.questions ?? []).slice(0, 3))
})
.catch(() => {})
}, [])
if (questions.length === 0) return null
return (
<section className="section-qa-callout" aria-label="Featured Q&A">
<div className="section-inner">
<p className="eyebrow">Q&amp;A</p>
<h2 className="section-heading"><span className="ornament">✦</span> Questions from Listeners <span className="ornament">✦</span></h2>
<p className="qa-callout-sub">Real questions. Scripture-grounded answers.</p>
<div className="qa-callout-grid">
{questions.map(q => (
<div key={q.id} className="qa-callout-card">
<p className="qa-callout-question">"{q.question}"</p>
<p className="qa-callout-answer">{q.answer.length > 220 ? q.answer.slice(0, 220).trimEnd() + '' : q.answer}</p>
{q.firstName && <p className="qa-callout-from">— {q.firstName}</p>}
</div>
))}
</div>
<div style={{ textAlign: 'center', marginTop: '2rem' }}>
<Link to="/questions" className="btn-primary">Browse All Q&amp;A →</Link>
</div>
</div>
</section>
)
}
function LandingPage({ content }: { content: SiteContent }) {
const featuredStudy = getHomepageFeaturedStudy(content)
@@ -1063,6 +1164,8 @@ function LandingPage({ content }: { content: SiteContent }) {
</div>
</section>
<HomepageNewsletterSection />
<section className="section-series" id="series" aria-label="Current series">
<div className="section-inner">
<h2 className="section-heading">
@@ -1125,6 +1228,8 @@ function LandingPage({ content }: { content: SiteContent }) {
</div>
</section>
<HomepageQACallout />
<section className="section-share" aria-label="Share the show">
<div className="section-inner section-share-inner">
<div className="share-text">
@@ -1386,12 +1491,51 @@ function EpisodesPage({ content }: { content: SiteContent }) {
</div>
)
const latestEpisode = !loading && !failed ? (currentEpisodes[0] ?? allEpisodes[0]) : null
const latestEmbedUrl = latestEpisode ? toSpotifyEpisodeEmbedUrl(latestEpisode.link) : ''
return (
<div className="site">
<SiteHeader content={content} />
<section className="section-player" aria-label="Current series episodes">
<div className="section-inner">
<h2 className="section-heading">
<p className="episode-seo-intro" aria-hidden="true">
{content.episodesSeoIntro || 'Verse by Verse with Nate is an expository Bible teaching podcast where we go through Scripture one verse at a time. Each episode digs into the text carefully and practically, helping you understand what the Bible says, what it means, and how to live it out. New episodes released regularly — subscribe on Spotify or your favorite podcast platform.'}
</p>
{latestEpisode && (
<div className="latest-episode-card">
<div className="latest-episode-meta">
<p className="eyebrow">Latest Episode</p>
<h2 className="latest-episode-title">{latestEpisode.title}</h2>
<div className="latest-episode-pills">
{latestEpisode.episode && <span className="episode-number">Ep. {latestEpisode.episode}</span>}
{latestEpisode.pubDate && <span className="episode-date">{formatPubDate(latestEpisode.pubDate)}</span>}
{latestEpisode.duration && <span className="episode-duration">{latestEpisode.duration}</span>}
</div>
{latestEpisode.description && <p className="latest-episode-desc">{latestEpisode.description}</p>}
<a href={latestEpisode.link || content.platformSpotifyUrl || '/spotify'} target="_blank" rel="noreferrer" className="btn-primary" style={{ marginTop: '1rem', display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}>
<SpotifyIcon /> Listen on Spotify
</a>
</div>
{latestEmbedUrl && (
<div className="latest-episode-embed">
<iframe
title={latestEpisode.title}
src={latestEmbedUrl}
width="100%"
height="232"
frameBorder="0"
allowFullScreen
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
style={{ borderRadius: '12px' }}
/>
</div>
)}
</div>
)}
<h2 className="section-heading" style={{ marginTop: latestEpisode ? '3rem' : undefined }}>
<span className="ornament"></span> {content.seriesLabel || 'Now Playing'}{' '}
<span className="ornament"></span>
</h2>