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
+1
View File
@@ -491,6 +491,7 @@ const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; sect
{ key: 'contactPoint2', label: 'Contact — Point 2', section: 'contact' },
{ key: 'contactVerse', label: 'Contact — Scripture Text', section: 'contact' },
{ key: 'contactVerseRef', label: 'Contact — Scripture Reference', section: 'contact' },
{ key: 'episodesSeoIntro', label: 'Episodes Page — SEO Description (hidden from visitors, indexed by search engines)', multiline: true, section: 'series' },
{ key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")', section: 'series' },
{ key: 'seriesTitle', label: 'Series Title', section: 'series' },
{ key: 'seriesDescription', label: 'Series Description', multiline: true, section: 'series' },
+176
View File
@@ -403,6 +403,62 @@
padding: 5rem 0;
}
.episode-seo-intro {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.latest-episode-card {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2.5rem;
align-items: center;
background: #111110;
border: 1px solid rgba(201, 168, 76, 0.2);
border-radius: 12px;
padding: 2rem;
margin-bottom: 1rem;
}
.latest-episode-title {
font-size: 1.4rem;
font-weight: 700;
color: #f0e9cc;
margin: 0.5rem 0 0.75rem;
line-height: 1.3;
}
.latest-episode-pills {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.latest-episode-desc {
font-size: 0.9rem;
color: #8a7f5a;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.latest-episode-embed {
width: 100%;
}
@media (max-width: 700px) {
.latest-episode-card {
grid-template-columns: 1fr;
}
}
.episode-series-subtitle {
text-align: center;
color: var(--brand-gold);
@@ -735,6 +791,71 @@
opacity: 0.9;
}
/* ── Homepage Newsletter ── */
.section-newsletter {
background: #0c0c08;
border-top: 1px solid rgba(201, 168, 76, 0.15);
border-bottom: 1px solid rgba(201, 168, 76, 0.15);
padding: 4rem 0;
text-align: center;
}
.newsletter-inner {
max-width: 600px;
margin: 0 auto;
}
.newsletter-sub {
color: #a89a6a;
font-size: 1rem;
margin-bottom: 2rem;
}
.newsletter-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: center;
}
.newsletter-fields {
display: flex;
gap: 0.75rem;
width: 100%;
flex-wrap: wrap;
justify-content: center;
}
.newsletter-input {
flex: 1 1 180px;
padding: 0.7rem 1rem;
border-radius: 6px;
border: 1px solid rgba(201, 168, 76, 0.3);
background: #1a1a12;
color: #f0e9cc;
font-size: 0.95rem;
outline: none;
}
.newsletter-input::placeholder {
color: #5a5440;
}
.newsletter-input:focus {
border-color: rgba(201, 168, 76, 0.7);
}
.newsletter-thanks {
color: #c9a84c;
font-size: 1.05rem;
font-weight: 600;
}
.newsletter-error {
color: #e05c5c;
font-size: 0.9rem;
}
/* ── Series ── */
.section-series {
background: #080808;
@@ -935,6 +1056,61 @@
font-size: 0.8rem;
}
/* ── Homepage Q&A Callout ── */
.section-qa-callout {
background: #080808;
border-top: 1px solid rgba(201, 168, 76, 0.18);
border-bottom: 1px solid rgba(201, 168, 76, 0.18);
padding: 5rem 0;
text-align: center;
}
.qa-callout-sub {
color: #8a7f5a;
font-size: 1rem;
margin: -0.5rem 0 2rem;
}
.qa-callout-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1.25rem;
max-width: 960px;
margin: 0 auto;
text-align: left;
}
.qa-callout-card {
background: #111110;
border: 1px solid rgba(201, 168, 76, 0.15);
border-radius: 10px;
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.qa-callout-question {
font-size: 0.95rem;
font-weight: 600;
color: #c9a84c;
margin: 0;
line-height: 1.5;
}
.qa-callout-answer {
font-size: 0.875rem;
color: #a89a6a;
line-height: 1.65;
margin: 0;
}
.qa-callout-from {
font-size: 0.8rem;
color: #5a5440;
margin: 0;
}
/* ── Home Jump Section ── */
.section-share {
background: #080808;
+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>
+33 -1
View File
@@ -463,6 +463,8 @@ export function StudyLandingPage({ content }: Props) {
const [studyModal, setStudyModal] = useState<'none' | 'enrollments' | 'browseTracks'>('none')
const [enrollBusySlug, setEnrollBusySlug] = useState('')
const [enrollMessage, setEnrollMessage] = useState('')
const [showNewsletterNudge, setShowNewsletterNudge] = useState(false)
const [newsletterNudgeDone, setNewsletterNudgeDone] = useState(false)
const studies = getStudies(content)
const activeStudies = studies.filter(study => study.status !== 'planned')
@@ -495,6 +497,7 @@ export function StudyLandingPage({ content }: Props) {
const slugs = normalizeEnrolledStudySlugs(data.enrolledStudySlugs)
setAuth(prev => ({ ...prev, enrolledStudySlugs: slugs }))
setEnrollMessage(enrolled ? `Unenrolled from ${data.studyTitle ?? studySlug}.` : `Enrolled in ${data.studyTitle ?? studySlug}.`)
if (!enrolled && !auth.subscribeNewsletter) setShowNewsletterNudge(true)
await refreshOverview()
} catch (err) {
setEnrollMessage(err instanceof Error ? err.message : 'Unable to update enrollment.')
@@ -678,6 +681,22 @@ export function StudyLandingPage({ content }: Props) {
</div>
)}
{enrollMessage && <p className="study-note-status" style={{ marginTop: '1rem' }}>{enrollMessage}</p>}
{showNewsletterNudge && !newsletterNudgeDone && (
<div className="study-newsletter-nudge" style={{ marginTop: '1rem', background: '#1a1a12', border: '1px solid rgba(201,168,76,0.25)', borderRadius: '8px', padding: '1rem', fontSize: '0.9rem' }}>
<p style={{ margin: '0 0 0.5rem' }}>Want episode updates and study announcements by email?</p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button type="button" className="btn-primary" style={{ fontSize: '0.85rem', padding: '0.4rem 1rem' }} onClick={async () => {
try {
await fetch('/api/study-account/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subscribeNewsletter: true }) })
setAuth(prev => ({ ...prev, subscribeNewsletter: true }))
} catch {}
setNewsletterNudgeDone(true)
setShowNewsletterNudge(false)
}}>Yes, subscribe me</button>
<button type="button" className="btn-secondary" style={{ fontSize: '0.85rem', padding: '0.4rem 1rem' }} onClick={() => setShowNewsletterNudge(false)}>No thanks</button>
</div>
</div>
)}
</div>
</div>
)}
@@ -844,6 +863,19 @@ export function StudyLandingPage({ content }: Props) {
</section>
)}
<section className="section-study-module" aria-label="Q&A">
<div className="section-inner" style={{ textAlign: 'center', padding: '3rem 1rem' }}>
<p className="eyebrow">Have a question from the Word?</p>
<h2 style={{ fontSize: '1.5rem', fontWeight: 700, margin: '0.5rem 0 0.75rem', color: '#f0e9cc' }}>Ask Nate a Question</h2>
<p style={{ color: '#8a7f5a', maxWidth: '480px', margin: '0 auto 1.5rem', fontSize: '0.95rem' }}>
Submit a Bible or theology question and it may be answered in a future episode or the Q&amp;A archive.
</p>
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', flexWrap: 'wrap' }}>
<a href="/questions" className="btn-primary">Browse Q&amp;A Archive </a>
<a href="/questions#ask" className="btn-secondary">Submit a Question</a>
</div>
</div>
</section>
</main>
)
@@ -2299,7 +2331,7 @@ export function StudyAccountPage() {
<p style={{ margin: 0, fontSize: '0.85rem' }}>Total notes saved</p>
</div>
<div style={{ flex: '1 1 150px', background: '#1a1a16', borderRadius: '8px', padding: '1rem', border: '1px solid #2a2518' }}>
<p style={{ fontSize: '1rem', fontWeight: 600, margin: 0 }}>{new Date(overview.stats.memberSince).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</p>
<p style={{ fontSize: '1rem', fontWeight: 600, margin: 0 }}>{overview.stats.memberSince && !isNaN(new Date(overview.stats.memberSince).getTime()) ? new Date(overview.stats.memberSince).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</p>
<p style={{ margin: 0, fontSize: '0.85rem' }}>Member since</p>
</div>
</div>
+3
View File
@@ -235,6 +235,8 @@ export interface SiteContent {
podcastFeaturedLinks: PodcastFeaturedLink[]
seo: SeoSettings
legal: LegalSettings
// ── Episodes page ──
episodesSeoIntro: string
}
export const DEFAULTS: SiteContent = {
@@ -411,6 +413,7 @@ export const DEFAULTS: SiteContent = {
},
],
podcastFeaturedLinks: [],
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.',
seo: {
title: 'Verse by Verse with Nate',
description: "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",