Home page redesign: latest-episode player beside current series, ways-in cards, finished-books shelf, shortened PRISM, newsletter + share band, quieter 'more from us' group; v2.32.0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-13 07:56:11 -04:00
1 parent d1c84aec6b
commit 729cf94c12
4 files changed
+263 -150

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "siteforge", "name": "siteforge",
"version": "2.31.1", "version": "2.32.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "siteforge", "name": "siteforge",
"version": "2.31.1", "version": "2.32.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1135.0", "@aws-sdk/client-s3": "^3.1135.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "2.31.1", "version": "2.32.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+48
View File
@@ -13825,3 +13825,51 @@ a.study-track-card:hover {
.finished-book-card--featured .finished-book-card-img { aspect-ratio: 16 / 9; height: auto; } .finished-book-card--featured .finished-book-card-img { aspect-ratio: 16 / 9; height: auto; }
} }
.ep-hero-player { margin-bottom: 0.5rem; } .ep-hero-player { margin-bottom: 0.5rem; }
/* ── Home page redesign ── */
.hero-stats { margin: 1.1rem 0 0; font-size: 0.85rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--brand-gold); opacity: 0.85; }
.section-home-now { background: #080808; border-top: 1px solid rgba(201, 168, 76, 0.18); border-bottom: 1px solid rgba(201, 168, 76, 0.18); padding: 3.5rem 0; }
.home-now-grid { max-width: 1100px; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); gap: 2.5rem; align-items: start; }
.home-now-player .eyebrow, .home-now-series .eyebrow { margin-bottom: 0.9rem; }
.home-now-all { display: inline-block; margin-top: 0.9rem; font-size: 0.88rem; color: var(--brand-gold); text-decoration: none; letter-spacing: 0.04em; }
.home-now-series { display: flex; flex-direction: column; gap: 1rem; }
.home-now-series .eyebrow { margin-bottom: 0; }
.home-series-card { display: grid; grid-template-columns: 120px 1fr; gap: 1.25rem; align-items: start; background: #131313; border: 1px solid rgba(201, 168, 76, 0.22); border-radius: 14px; padding: 1.1rem; }
.home-series-art { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 8px; }
.home-series-info h3 { font-family: var(--brand-font-heading); font-size: 1.3rem; color: var(--brand-warm-white); margin: 0 0 0.4rem; }
.home-series-desc { color: var(--brand-muted); font-size: 0.95rem; line-height: 1.55; margin: 0 0 0.9rem; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.home-series-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.section-home-shelf { padding: 3rem 0 2.5rem; background: var(--brand-black); }
.home-shelf-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; margin-bottom: 1.25rem; }
.home-shelf-row { display: flex; gap: 1rem; overflow-x: auto; padding-bottom: 0.75rem; scroll-snap-type: x proximity; scrollbar-width: thin; }
.home-shelf-book { flex: 0 0 130px; scroll-snap-align: start; text-decoration: none; color: #e8d9b5; font-size: 0.88rem; text-align: center; display: flex; flex-direction: column; gap: 0.5rem; }
.home-shelf-book img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 8px; border: 1px solid rgba(201, 168, 76, 0.25); transition: transform 180ms, border-color 180ms; }
.home-shelf-book:hover img { transform: translateY(-3px); border-color: rgba(201, 168, 76, 0.6); }
.prism-steps--chips { display: flex; flex-wrap: wrap; gap: 0.45rem; margin: 1rem 0; }
.prism-steps--chips p { padding: 0.4rem 0.8rem; border-radius: 999px; font-size: 0.82rem; }
.prism-more { background: transparent; border: 0; padding: 0; color: var(--brand-gold); font-family: var(--brand-font-body); font-size: 0.9rem; letter-spacing: 0.04em; cursor: pointer; text-decoration: underline; text-underline-offset: 3px; }
.section-prism { padding: 3.5rem 0; }
.section-home-connect { background: #0c0c08; border-top: 1px solid rgba(201, 168, 76, 0.15); border-bottom: 1px solid rgba(201, 168, 76, 0.15); padding: 3.5rem 0; }
.home-connect-grid { max-width: 1100px; display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
.home-connect-card { background: rgba(26, 26, 21, 0.7); border: 1px solid rgba(201, 168, 76, 0.18); border-radius: 14px; padding: 2rem; }
.home-connect-card--newsletter { text-align: center; }
.home-connect-card--newsletter .newsletter-inner { max-width: none; }
.home-connect-card .section-heading { font-size: 1.5rem; }
.home-connect-card .share-p { margin-bottom: 1.1rem; }
.home-connect-share-row { display: flex; align-items: center; justify-content: space-between; gap: 1.25rem; flex-wrap: wrap; }
.home-connect-share-row .share-qr { width: 110px; height: 110px; }
.home-more .section-qa-callout, .home-more .section-tfl, .home-more .section-external-sites { padding-top: 3rem; padding-bottom: 3rem; }
@media (max-width: 860px) {
.home-now-grid, .home-connect-grid { grid-template-columns: 1fr; gap: 2rem; }
}
@media (max-width: 520px) {
.home-series-card { grid-template-columns: 84px 1fr; gap: 0.9rem; padding: 0.9rem; }
.home-series-info h3 { font-size: 1.1rem; }
.home-connect-card { padding: 1.4rem; }
}
+212 -147
View File
@@ -1052,7 +1052,7 @@ function ExternalSitesSection({ content }: { content: SiteContent }) {
) )
} }
function HomepageNewsletterSection() { function HomepageNewsletterSection({ embedded = false }: { embedded?: boolean }) {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [firstName, setFirstName] = useState('') const [firstName, setFirstName] = useState('')
const [honey, setHoney] = useState('') const [honey, setHoney] = useState('')
@@ -1074,44 +1074,52 @@ function HomepageNewsletterSection() {
} }
} }
const content = (
<>
<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>
)}
</>
)
// `embedded` drops the full-width band so the signup can sit inside another section (homepage connect row).
if (embedded) {
return <div className="home-connect-card home-connect-card--newsletter" aria-label="Newsletter sign-up">{content}</div>
}
return ( return (
<section className="section-newsletter" aria-label="Newsletter sign-up"> <section className="section-newsletter" aria-label="Newsletter sign-up">
<div className="section-inner newsletter-inner"> <div className="section-inner newsletter-inner">{content}</div>
<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> </section>
) )
} }
@@ -1224,6 +1232,44 @@ function TestimonialsSection({ testimonials }: { testimonials: Testimonial[] })
function LandingPage({ content }: { content: SiteContent }) { function LandingPage({ content }: { content: SiteContent }) {
const featuredStudy = getHomepageFeaturedStudy(content) const featuredStudy = getHomepageFeaturedStudy(content)
const { episodes } = useAllEpisodes()
const [prismOpen, setPrismOpen] = useState(false)
const finishedBooks = [...(content.finishedBooks ?? [])].sort((a, b) => b.season - a.season)
const episodesInSeason = (season?: number) => (season ? episodes.filter(ep => ep.season === String(season)).length : 0)
const liveSeries = [
{
key: 'primary',
imageUrl: content.seriesImageUrl,
title: content.seriesTitle,
label: content.seriesLabel,
description: content.seriesDescription,
listenUrl: content.seriesListenUrl,
listenBtnLabel: content.seriesListenBtnLabel,
studySlug: content.seriesStudySlug,
season: content.seriesSeason,
},
...(content.secondSeries?.title?.trim()
? [{
key: 'second',
imageUrl: content.secondSeries.imageUrl,
title: content.secondSeries.title,
label: content.secondSeries.label,
description: content.secondSeries.description,
listenUrl: content.secondSeries.listenUrl,
listenBtnLabel: content.secondSeries.listenBtnLabel,
studySlug: content.secondSeries.studySlug,
season: content.secondSeries.season,
}]
: []),
]
// Fixed "ways in" cards go first; the admin-managed cards follow, skipping any that point to the same page.
const adminCards = content.whereToNextCards ?? []
const fixedCards = [
{ id: 'start', path: '/start-here', title: 'Start here', description: 'New to the show? A short path through the best first episodes.' },
{ id: 'verse', path: '/scripture', title: 'Find a verse', description: 'Look up any passage and jump to the episode or lesson that covers it.' },
].filter(card => !adminCards.some(c => c.path === card.path))
return ( return (
<div className="site"> <div className="site">
@@ -1259,12 +1305,108 @@ function LandingPage({ content }: { content: SiteContent }) {
<SpotifyIcon /> <SpotifyIcon />
{content.heroBtnSpotify || 'Listen on Spotify'} {content.heroBtnSpotify || 'Listen on Spotify'}
</a> </a>
<Link to="/episodes" className="btn-secondary">{content.heroBtnEpisodes || 'Explore Episodes ↓'}</Link>
<Link to="/start-here" className="btn-secondary btn-start-here">{content.heroBtnStartHere || 'New here? Start here →'}</Link> <Link to="/start-here" className="btn-secondary btn-start-here">{content.heroBtnStartHere || 'New here? Start here →'}</Link>
<Link to="/episodes" className="btn-secondary">{content.heroBtnEpisodes || 'Explore Episodes ↓'}</Link>
</div>
{episodes.length > 0 && (
<p className="hero-stats">
{episodes.length} episodes
{finishedBooks.length > 0 && <> · {finishedBooks.length} {finishedBooks.length === 1 ? 'book' : 'books'} completed</>}
</p>
)}
</div>
</section>
{/* ── LISTEN NOW: latest episode + current series ── */}
<section className="section-home-now" id="series" aria-label="Listen now">
<div className="section-inner home-now-grid">
<div className="home-now-player">
<p className="eyebrow">Latest Episode</p>
<LatestEpisodePlayer content={content} />
<Link to="/episodes" className="home-now-all">Browse all episodes →</Link>
</div>
<div className="home-now-series">
<p className="eyebrow">Current Series</p>
{liveSeries.map(series => {
const count = episodesInSeason(series.season)
return (
<article className="home-series-card" key={series.key}>
<img src={series.imageUrl} alt={series.title} className="home-series-art" />
<div className="home-series-info">
<p className="series-label">{series.label}{count > 0 ? ` · ${count} episodes so far` : ''}</p>
<h3>{series.title}</h3>
<p className="home-series-desc">{series.description}</p>
<div className="home-series-actions">
<a
href={series.listenUrl || content.platformSpotifyUrl || '/spotify'}
target="_blank"
rel="noreferrer"
className="btn-primary"
>
<SpotifyIcon />
{series.listenBtnLabel || content.seriesListenBtnLabel || 'Listen to Series'}
</a>
{series.studySlug
? <Link to={`/study/${series.studySlug}`} className="btn-secondary">Study Along</Link>
: <Link to="/resources" className="btn-secondary">{content.seriesDownloadsBtnLabel || 'View Resources'}</Link>}
</div>
</div>
</article>
)
})}
</div> </div>
</div> </div>
</section> </section>
{/* ── WAYS IN ── */}
<section className="section-home-jump" aria-label="Homepage navigation">
<div className="section-inner">
<div className="home-jump-head">
<p className="eyebrow">{content.whereToNextEyebrow || 'Where to Next'}</p>
<h2 className="section-heading">{content.whereToNextHeading || 'Choose the page you need.'}</h2>
</div>
<div className="home-jump-grid">
{[...fixedCards, ...adminCards].map(card => (
<Link key={card.id} to={card.path} className="home-jump-card">
<h3>{card.title}</h3>
<p>{card.description}</p>
</Link>
))}
{featuredStudy && (
<Link to={`/study/${featuredStudy.slug || ''}`} className="home-jump-card home-jump-card--featured">
<div className="home-jump-featured-head">
<p className="eyebrow">{featuredStudy.homepageEyebrow || 'Study'}</p>
{featuredStudy.showNewTag && <span className="home-jump-new-tag">{featuredStudy.newTagLabel || 'NEW'}</span>}
</div>
<h3>{featuredStudy.title}</h3>
<p>{featuredStudy.description || 'Explore this study track with lesson notes, commentary, and guided questions.'}</p>
</Link>
)}
</div>
</div>
</section>
{/* ── FINISHED BOOKS SHELF ── */}
{finishedBooks.length > 0 && (
<section className="section-home-shelf" aria-label="Finished books">
<div className="section-inner">
<div className="home-shelf-head">
<h2 className="section-heading" style={{ textAlign: 'left', marginBottom: 0 }}>Books We've Finished</h2>
<Link to="/finished" className="home-now-all">See all →</Link>
</div>
<div className="home-shelf-row">
{finishedBooks.map(book => (
<Link key={book.id} to={`/finished/${book.id}`} className="home-shelf-book">
{book.imageUrl && <img src={book.imageUrl} alt={book.title} loading="lazy" />}
<span>{book.title}</span>
</Link>
))}
</div>
</div>
</section>
)}
{/* ── PRISM ── */}
<section className="section-prism" aria-label="PRISM featured video"> <section className="section-prism" aria-label="PRISM featured video">
<div className="section-inner prism-feature"> <div className="section-inner prism-feature">
<div className="prism-video-shell"> <div className="prism-video-shell">
@@ -1288,136 +1430,59 @@ function LandingPage({ content }: { content: SiteContent }) {
<p className="prism-description"> <p className="prism-description">
{content.prismIntro || 'What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?'} {content.prismIntro || 'What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?'}
</p> </p>
<p className="prism-description"> <div className="prism-steps prism-steps--chips" aria-label="P.R.I.S.M. steps">
{content.prismPatternIntro || 'In this bonus episode, I walk you through P.R.I.S.M. — a five-step daily Bible study pattern designed to help you slow down, go deep, and let the Word do its work.'}
</p>
<div className="prism-steps" aria-label="P.R.I.S.M. steps">
<p>{content.prismStep1 || 'P — Pray before you read'}</p> <p>{content.prismStep1 || 'P — Pray before you read'}</p>
<p>{content.prismStep2 || 'R — Read slowly and observe'}</p> <p>{content.prismStep2 || 'R — Read slowly and observe'}</p>
<p>{content.prismStep3 || 'I — Interpret with context'}</p> <p>{content.prismStep3 || 'I — Interpret with context'}</p>
<p>{content.prismStep4 || 'S — Study and apply specifically'}</p> <p>{content.prismStep4 || 'S — Study and apply specifically'}</p>
<p>{content.prismStep5 || 'M — Memorize one verse at a time'}</p> <p>{content.prismStep5 || 'M — Memorize one verse at a time'}</p>
</div> </div>
<p className="prism-description"> {prismOpen && (
{content.prismOutro || "Whether you're brand new to daily Bible reading or you've been at it for years, P.R.I.S.M. gives you a consistent framework for every passage, every day."} <>
</p> <p className="prism-description">
<p className="prism-description prism-emphasis"> {content.prismPatternIntro || 'In this bonus episode, I walk you through P.R.I.S.M. — a five-step daily Bible study pattern designed to help you slow down, go deep, and let the Word do its work.'}
{content.prismClosing || "The goal isn't to get through the text — it's to let the text get through to you."} </p>
</p> <p className="prism-description">
</div> {content.prismOutro || "Whether you're brand new to daily Bible reading or you've been at it for years, P.R.I.S.M. gives you a consistent framework for every passage, every day."}
</div> </p>
</section> <p className="prism-description prism-emphasis">
{content.prismClosing || "The goal isn't to get through the text — it's to let the text get through to you."}
<HomepageNewsletterSection /> </p>
</>
<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${content.secondSeries?.title?.trim() ? ' series-grid--two' : ''}`}>
{[
{
key: 'primary',
imageUrl: content.seriesImageUrl,
title: content.seriesTitle,
label: content.seriesLabel,
description: content.seriesDescription,
listenUrl: content.seriesListenUrl,
listenBtnLabel: content.seriesListenBtnLabel,
studySlug: content.seriesStudySlug,
},
...(content.secondSeries?.title?.trim()
? [{
key: 'second',
imageUrl: content.secondSeries.imageUrl,
title: content.secondSeries.title,
label: content.secondSeries.label,
description: content.secondSeries.description,
listenUrl: content.secondSeries.listenUrl,
listenBtnLabel: content.secondSeries.listenBtnLabel,
studySlug: content.secondSeries.studySlug,
}]
: []),
].map(series => (
<article className="series-card" key={series.key}>
<img
src={series.imageUrl}
alt={series.title}
className="series-art"
/>
<div className="series-info">
<p className="series-label">{series.label}</p>
<h3>{series.title}</h3>
<p>{series.description}</p>
<a
href={series.listenUrl || content.platformSpotifyUrl || '/spotify'}
target="_blank"
rel="noreferrer"
className="btn-primary"
>
<SpotifyIcon />
{series.listenBtnLabel || content.seriesListenBtnLabel || 'Listen to Series'}
</a>
{series.studySlug
? <Link to={`/study/${series.studySlug}`} className="btn-secondary">Study Along</Link>
: <Link to="/resources" className="btn-secondary">{content.seriesDownloadsBtnLabel || 'View Resources'}</Link>}
</div>
</article>
))}
</div>
</div>
</section>
<TruthForLifeWidget type="audiodevo" />
<ExternalSitesSection content={content} />
<section className="section-home-jump" aria-label="Homepage navigation">
<div className="section-inner">
<div className="home-jump-head">
<p className="eyebrow">{content.whereToNextEyebrow || 'Where to Next'}</p>
<h2 className="section-heading">{content.whereToNextHeading || 'Choose the page you need.'}</h2>
</div>
<div className="home-jump-grid">
{(content.whereToNextCards ?? []).map(card => (
<Link key={card.id} to={card.path} className="home-jump-card">
<h3>{card.title}</h3>
<p>{card.description}</p>
</Link>
))}
{featuredStudy && (
<Link to={`/study/${featuredStudy.slug || ''}`} className="home-jump-card home-jump-card--featured">
<div className="home-jump-featured-head">
<p className="eyebrow">{featuredStudy.homepageEyebrow || 'Study'}</p>
{featuredStudy.showNewTag && <span className="home-jump-new-tag">{featuredStudy.newTagLabel || 'NEW'}</span>}
</div>
<h3>{featuredStudy.title}</h3>
<p>{featuredStudy.description || 'Explore this study track with lesson notes, commentary, and guided questions.'}</p>
</Link>
)} )}
<button type="button" className="prism-more" aria-expanded={prismOpen} onClick={() => setPrismOpen(v => !v)}>
{prismOpen ? 'Show less' : 'Read more about PRISM'}
</button>
</div> </div>
</div> </div>
</section> </section>
<HomepageQACallout />
<TestimonialsSection testimonials={content.testimonials ?? []} /> <TestimonialsSection testimonials={content.testimonials ?? []} />
<section className="section-share" aria-label="Share the show"> {/* ── CONNECT: newsletter + share ── */}
<div className="section-inner section-share-inner"> <section className="section-home-connect" aria-label="Stay connected">
<div className="share-text"> <div className="section-inner home-connect-grid">
<HomepageNewsletterSection embedded />
<div className="home-connect-card home-connect-card--share">
<h2 className="section-heading" style={{ textAlign: 'left', marginBottom: '0.75rem' }}> <h2 className="section-heading" style={{ textAlign: 'left', marginBottom: '0.75rem' }}>
{content.shareHeading || 'Help one more person hear the Word this week.'} {content.shareHeading || 'Help one more person hear the Word this week.'}
</h2> </h2>
<p className="share-p">{content.shareP || 'Scan the QR code or text the show link to a friend who needs encouragement today.'}</p> <p className="share-p">{content.shareP || 'Scan the QR code or text the show link to a friend who needs encouragement today.'}</p>
<ShareShowButton /> <div className="home-connect-share-row">
<ShareShowButton />
<img src={content.shareQrImageUrl || '/images/qr-code.jpg'} alt="QR code to share the show" className="share-qr" />
</div>
</div> </div>
<img src={content.shareQrImageUrl || '/images/qr-code.jpg'} alt="QR code to share the show" className="share-qr" />
</div> </div>
</section> </section>
{/* ── MORE FROM US ── */}
<div className="home-more">
<HomepageQACallout />
<TruthForLifeWidget type="audiodevo" />
<ExternalSitesSection content={content} />
</div>
<SiteFooter content={content} /> <SiteFooter content={content} />
<AnalyticsConsentBanner content={content} /> <AnalyticsConsentBanner content={content} />