Compare commits

...
12 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 82ce312898 Studies: add a Download Study Guide button to the study page, automatic from the linked series/book or chosen per study in admin; v2.35.0
Publish Container / build (push) Successful in 18m26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 08:16:44 -04:00
nmemmertandClaude Sonnet 5.5 b48a81a00c Home page: replace Where to Next card grid with a featured-study card plus an Explore list; v2.34.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 08:13:57 -04:00
nmemmertandClaude Sonnet 5.5 b04a8b30cd Stronger hover on gold buttons: brighter fill, lift and glow; v2.33.2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 08:10:17 -04:00
nmemmertandClaude Sonnet 5.5 f3fdd32943 Give non-gold buttons and pills a soft gold tint instead of an empty outline; v2.33.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 08:08:53 -04:00
nmemmertandClaude Sonnet 5.5 ee707fad10 Analytics consent banner redesign: compact corner card with title, privacy link and equal-weight Accept/Decline; v2.33.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 08:01:33 -04:00
nmemmertandClaude Sonnet 5.5 e82e8222f8 Home page: rename Downloads card and series button to Resources; v2.32.4
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 08:00:29 -04:00
nmemmertandClaude Sonnet 5.5 04a572bb6e Home page: center all text in the newsletter, share and Ko-fi cards; v2.32.3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 07:59:24 -04:00
nmemmertandClaude Sonnet 5.5 448e3eec2b Home page: center the QR codes in the share and Ko-fi cards; v2.32.2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 07:58:36 -04:00
nmemmertandClaude Sonnet 5.5 95aecf739f Home page: add Ko-fi as a third card beside newsletter and share; v2.32.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 07:58:17 -04:00
nmemmertandClaude Sonnet 5.5 729cf94c12 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>
2026-10-13 07:56:11 -04:00
nmemmertandClaude Sonnet 5.5 d1c84aec6b Episodes page: show both series lanes (A and B) in the hero with lane-aware series chips; v2.31.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 07:52:22 -04:00
nmemmertandClaude Sonnet 5.5 f1577e9d8a Episodes + Finished Books redesign: current-series hero, searchable episode browser with series chips, shared episode rows; finished-books library with search, testament/sort filters, featured card, episode counts and badges; v2.31.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 07:47:56 -04:00
10 changed files with 780 additions and 313 deletions

No files matched your search

+2 -2
View File
@@ -42,7 +42,7 @@
"seriesImageUrl": "/images/titus-cover.png",
"seriesListenUrl": "/spotify",
"seriesListenBtnLabel": "Listen to Series",
"seriesDownloadsBtnLabel": "View Downloads",
"seriesDownloadsBtnLabel": "View Resources",
"studyGuideTitle": "Companion Study Guide",
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
"studyGuideDownloadUrl": "",
@@ -1030,7 +1030,7 @@
},
{
"id": "downloads",
"title": "Downloads",
"title": "Resources",
"description": "Main guide, extra downloads, and past study files.",
"path": "/resources"
},
+2 -2
View File
@@ -43,7 +43,7 @@
"seriesImageUrl": "/images/titus-cover.png",
"seriesListenUrl": "/spotify",
"seriesListenBtnLabel": "Listen to Series",
"seriesDownloadsBtnLabel": "View Downloads",
"seriesDownloadsBtnLabel": "View Resources",
"studyGuideTitle": "Companion Study Guide",
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
"studyGuideDownloadUrl": "",
@@ -1084,7 +1084,7 @@
},
{
"id": "downloads",
"title": "Downloads",
"title": "Resources",
"description": "Main guide, extra downloads, and past study files.",
"path": "/resources"
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "siteforge",
"version": "2.30.0",
"version": "2.35.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "siteforge",
"version": "2.30.0",
"version": "2.35.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.1135.0",
"@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "2.30.0",
"version": "2.35.0",
"type": "module",
"scripts": {
"dev": "vite",
+1
View File
@@ -344,6 +344,7 @@ function sanitizeStudies(value) {
showOnHomepage: item.showOnHomepage === true,
showNewTag: item.showNewTag === true,
newTagLabel: typeof item.newTagLabel === 'string' ? item.newTagLabel.trim().slice(0, 24) : '',
guideSource: typeof item.guideSource === 'string' && /^(none|primary|second|book:[A-Za-z0-9_-]{1,80})$/.test(item.guideSource.trim()) ? item.guideSource.trim() : '',
status: item.status === 'planned' ? 'planned' : (item.status === 'complete' ? 'complete' : 'active'),
lane: item.lane === 'A' || item.lane === 'B' ? item.lane : '',
startsAt: sanitizeIsoDate(item.startsAt),
+13
View File
@@ -6013,6 +6013,19 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
<label htmlFor={`study-chapters-${activeStudy.id}`}>Number of Chapters</label>
<input id={`study-chapters-${activeStudy.id}`} type="number" min="1" max="999" value={activeStudy.numberOfChapters} placeholder="4" onChange={e => updateStudyProgram(activeStudy.id, 'numberOfChapters', Number(e.target.value) || 1)} />
</div>
<div className="admin-field">
<label htmlFor={`study-guide-source-${activeStudy.id}`}>Study Guide Download</label>
<select id={`study-guide-source-${activeStudy.id}`} value={activeStudy.guideSource ?? ''} onChange={e => updateStudyProgram(activeStudy.id, 'guideSource', e.target.value)}>
<option value="">Automatic (guide of the series or book linked to this study)</option>
<option value="none">None — don&rsquo;t show a guide button</option>
{form.studyGuideTitle && <option value="primary">Current series guide: {form.studyGuideTitle}</option>}
{form.secondSeries?.guide?.title && <option value="second">Second series guide: {form.secondSeries.guide.title}</option>}
{(form.finishedBooks ?? []).filter(b => b.guide?.title).map(b => (
<option key={b.id} value={`book:${b.id}`}>{b.title}: {b.guide?.title}</option>
))}
</select>
<p className="admin-field-help">Adds a &ldquo;Download Study Guide&rdquo; button to this study&rsquo;s page. Visitors enter their name and email to get the file.</p>
</div>
<div className="admin-field" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<input id={`study-home-feature-${activeStudy.id}`} type="checkbox" checked={activeStudy.showOnHomepage === true} onChange={e => updateStudyProgram(activeStudy.id, 'showOnHomepage', e.target.checked)} />
<label htmlFor={`study-home-feature-${activeStudy.id}`}>Feature this study card on homepage</label>
+217 -99
View File
@@ -1567,70 +1567,6 @@
padding: 4.5rem 0;
}
.home-jump-head {
text-align: center;
margin-bottom: 1.25rem;
}
.home-jump-head .section-heading {
margin-bottom: 0;
}
.home-jump-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.home-jump-card {
display: block;
text-decoration: none;
background: linear-gradient(180deg, rgba(20, 20, 18, 0.95), rgba(11, 11, 10, 0.95));
border: 1px solid rgba(201, 168, 76, 0.2);
border-radius: 14px;
padding: 1.1rem 1.15rem;
transition: transform 180ms, border-color 180ms, background 180ms;
}
.home-jump-card:hover {
transform: translateY(-2px);
border-color: rgba(201, 168, 76, 0.45);
background: linear-gradient(180deg, rgba(28, 27, 23, 0.98), rgba(14, 14, 12, 0.98));
}
.home-jump-card h3 {
margin: 0 0 0.35rem;
font-family: var(--brand-font-heading);
color: var(--brand-warm-white);
font-size: 1.28rem;
}
.home-jump-card p {
margin: 0;
font-family: var(--brand-font-body);
color: var(--brand-muted);
line-height: 1.55;
font-size: 0.97rem;
}
.home-jump-card--featured {
border-color: rgba(201, 168, 76, 0.38);
background: linear-gradient(180deg, rgba(201, 168, 76, 0.1), rgba(12, 12, 11, 0.96));
}
.home-jump-card--featured:hover {
border-color: rgba(201, 168, 76, 0.55);
background: linear-gradient(180deg, rgba(201, 168, 76, 0.16), rgba(14, 14, 12, 0.98));
}
.home-jump-featured-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.45rem;
}
.home-jump-new-tag {
display: inline-block;
padding: 0.12rem 0.48rem;
@@ -6705,48 +6641,61 @@ a.study-track-card:hover {
.consent-banner {
position: fixed;
left: 1rem;
right: 1rem;
bottom: 1rem;
left: 1.25rem;
bottom: 1.25rem;
width: min(380px, calc(100vw - 2.5rem));
z-index: 220;
background: rgba(10, 10, 8, 0.96);
border: 1px solid rgba(201, 168, 76, 0.45);
border-radius: 12px;
padding: 0.9rem 1rem;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
background: rgba(14, 14, 11, 0.97);
border: 1px solid rgba(201, 168, 76, 0.4);
border-radius: 14px;
padding: 1.1rem 1.2rem 1.15rem;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.55);
font-family: var(--brand-font-body);
}
.consent-banner p {
margin: 0;
color: var(--brand-warm-white);
font-family: var(--brand-font-body);
font-size: 0.92rem;
.consent-banner p { margin: 0; }
.consent-title {
font-family: var(--brand-font-heading);
font-size: 1.15rem;
color: #e8d9b5;
margin-bottom: 0.4rem !important;
}
.consent-text {
color: var(--brand-muted);
font-size: 0.88rem;
line-height: 1.5;
}
.consent-more { margin-top: 0.4rem !important; font-size: 0.82rem; }
.consent-more a { color: var(--brand-gold); text-underline-offset: 3px; }
.consent-actions {
margin-top: 0.75rem;
display: flex;
margin-top: 0.9rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
}
.consent-actions .btn-primary,
.consent-actions .btn-secondary {
font-size: 1rem;
padding: 0.75rem 1.4rem;
min-width: 140px;
justify-content: center;
/* Equal weight on both choices: same size, same shape, only the fill differs */
.consent-btn {
font-family: var(--brand-font-body);
font-size: 0.92rem;
font-weight: 600;
letter-spacing: 0.04em;
padding: 0.65rem 1rem;
border-radius: 999px;
border: 1px solid var(--brand-gold);
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.consent-btn--accept { background: var(--brand-gold); color: var(--brand-black); }
.consent-btn--decline { background: transparent; color: var(--brand-gold); }
.consent-btn:hover { background: rgba(201, 168, 76, 0.85); color: var(--brand-black); }
.consent-actions .btn-secondary {
color: var(--brand-gold);
border-color: rgba(201, 168, 76, 0.55);
background: transparent;
}
.consent-actions .btn-secondary:hover {
color: var(--brand-black);
background: var(--brand-gold);
border-color: var(--brand-gold);
@media (max-width: 520px) {
.consent-banner { left: 0.75rem; right: 0.75rem; bottom: 0.75rem; width: auto; padding: 1rem; }
}
.admin-form {
@@ -7632,10 +7581,6 @@ a.study-track-card:hover {
flex-direction: column;
}
.home-jump-grid {
grid-template-columns: 1fr;
}
@@ -13786,3 +13731,176 @@ a.study-track-card:hover {
.si-verse-btn { margin-left: 0; }
}
/* ── Episodes hub & Finished Books library ── */
.ep-hero { max-width: 1000px; display: flex; flex-direction: column; gap: 1.5rem; }
.ep-hero-series { display: block; }
.ep-hero-series--two { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; }
.ep-hero-series-card { display: grid; grid-template-columns: 220px 1fr; gap: 1.5rem 2rem; align-items: center; }
.ep-hero-series--two .ep-hero-series-card { grid-template-columns: 110px 1fr; gap: 1rem; align-items: start; padding: 1rem; background: rgba(26, 26, 21, 0.7); border: 1px solid rgba(201, 168, 76, 0.18); border-radius: 12px; }
.ep-hero-art { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 12px; border: 1px solid rgba(201, 168, 76, 0.25); }
.ep-hero-browse { display: inline-block; margin-top: 0.6rem; font-size: 0.85rem; color: var(--brand-gold); text-decoration: none; letter-spacing: 0.04em; }
.ep-hero-title { font-family: var(--brand-font-heading); font-size: 1.7rem; color: #e8d9b5; margin: 0.2rem 0 0.6rem; }
.ep-hero-desc { color: var(--brand-muted); line-height: 1.6; margin: 0; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
.section-episode-browser { padding: 3rem 0 2rem; }
.ep-season-chips { margin: 0 0 1.25rem; }
.ep-season-link { margin: -0.5rem 0 1rem; font-size: 0.9rem; }
.ep-season-link a { color: var(--brand-gold); text-decoration: none; }
.section-episode-headliner { padding: 0 0 3rem; }
.finished-books-toolbar { display: flex; flex-direction: column; gap: 0.9rem; margin-bottom: 0.5rem; }
.finished-books-toolbar .finished-series-filter { margin: 0; }
.finished-book-card-facts { font-size: 0.82rem; color: var(--brand-gold); opacity: 0.85; margin: 0; }
.finished-book-card-badges { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.6rem; }
.finished-book-badge { font-size: 0.72rem; letter-spacing: 0.04em; padding: 0.15rem 0.6rem; border: 1px solid rgba(201, 168, 76, 0.3); border-radius: 999px; color: var(--brand-muted); }
.finished-book-card--featured { grid-column: 1 / -1; flex-direction: row; }
.finished-book-card--featured .finished-book-card-img-wrap { flex: 0 0 280px; }
.finished-book-card--featured .finished-book-card-img { height: 100%; aspect-ratio: auto; }
.finished-book-card--featured .finished-book-card-body { justify-content: center; padding: 1.5rem 1.8rem; }
.finished-book-card--featured .finished-book-card-title { font-size: 1.8rem; }
@media (max-width: 640px) {
.ep-hero-series--two { grid-template-columns: 1fr; }
.ep-hero-series-card, .ep-hero-series--two .ep-hero-series-card { grid-template-columns: 96px 1fr; gap: 1rem; }
.ep-hero-title { font-size: 1.3rem; }
.ep-hero-desc { -webkit-line-clamp: 3; font-size: 0.9rem; }
.ep-season-chips { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 0.2rem; }
.finished-book-card--featured { flex-direction: column; }
.finished-book-card--featured .finished-book-card-img-wrap { flex-basis: auto; }
.finished-book-card--featured .finished-book-card-img { aspect-ratio: 16 / 9; height: auto; }
}
.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: 1200px; display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 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 { text-align: center; }
.home-connect-card .section-heading { 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; flex-direction: column; align-items: center; gap: 1.1rem; }
.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 { 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; }
}
/* ── Non-gold buttons and pills get a soft gold tint instead of an empty outline ── */
.btn-secondary,
.finished-series-ep-play,
.highlight-tab,
.si-pill,
.consent-btn--decline {
background: rgba(201, 168, 76, 0.14);
border-color: rgba(201, 168, 76, 0.5);
color: #ecdfb8;
}
.btn-secondary:hover,
.finished-series-ep-play:hover,
.finished-series-episode--open .finished-series-ep-play,
.highlight-tab:hover,
.si-pill:hover,
.consent-btn--decline:hover {
background: rgba(201, 168, 76, 0.26);
border-color: var(--brand-gold);
color: #fff4d0;
}
/* Selected pills stay solid gold */
.highlight-tab--active,
.si-pill--on,
.si-pill--on:hover { background: var(--brand-gold); border-color: var(--brand-gold); color: var(--brand-black); }
.consent-actions .btn-secondary { background: rgba(201, 168, 76, 0.14); color: #ecdfb8; border-color: rgba(201, 168, 76, 0.5); }
/* ── Gold button hover: clearly brighter, lifted, with a glow ── */
.btn-primary,
.consent-btn--accept {
transition: background 160ms, box-shadow 160ms, transform 160ms, opacity 200ms;
}
.btn-primary:not(:disabled):hover,
.consent-btn--accept:hover {
opacity: 1;
background: #f0d276;
color: #1a1608;
transform: translateY(-3px);
box-shadow: 0 8px 22px rgba(201, 168, 76, 0.55), 0 0 0 3px rgba(240, 210, 118, 0.28);
}
.btn-primary:not(:disabled):active,
.consent-btn--accept:active {
transform: translateY(0);
background: #c9a84c;
box-shadow: 0 2px 6px rgba(201, 168, 76, 0.4);
}
.btn-primary:focus-visible,
.consent-btn:focus-visible { outline: 2px solid #f0d276; outline-offset: 3px; }
/* ── Home: featured study + ways in ── */
.section-home-jump { padding: 3.5rem 0; }
.home-ways { max-width: 1100px; display: grid; grid-template-columns: 1.25fr 1fr; gap: 2rem; align-items: stretch; }
.home-ways--list-only { grid-template-columns: minmax(0, 560px); justify-content: center; }
.home-feature {
display: flex; flex-direction: column; justify-content: flex-end; min-height: 320px;
padding: 2.2rem; border-radius: 16px; text-decoration: none; color: inherit;
border: 1px solid rgba(201, 168, 76, 0.4);
background: radial-gradient(circle at 85% 15%, rgba(201, 168, 76, 0.35), transparent 55%), linear-gradient(160deg, #1f1b0e, #0f0e08);
transition: border-color 200ms, transform 200ms;
}
.home-feature:hover { border-color: rgba(201, 168, 76, 0.75); transform: translateY(-2px); }
.home-feature:hover .home-feature-btn { background: #f0d276; transform: translateY(-3px); box-shadow: 0 8px 22px rgba(201, 168, 76, 0.55), 0 0 0 3px rgba(240, 210, 118, 0.28); }
.home-feature-head { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.8rem; }
.home-feature-eyebrow { font-size: 0.75rem; letter-spacing: 0.22em; text-transform: uppercase; color: var(--brand-gold); }
.home-feature h3 { font-family: var(--brand-font-heading); font-size: clamp(1.6rem, 3vw, 2.1rem); margin: 0 0 0.6rem; color: #f4e9c6; }
.home-feature p { color: var(--brand-muted); margin: 0 0 1.4rem; line-height: 1.55; font-size: 0.97rem; }
.home-feature-btn { align-self: flex-start; margin-top: auto; }
.home-ways-list { display: flex; flex-direction: column; }
.home-ways-eyebrow { margin: 0; font-size: 0.75rem; letter-spacing: 0.22em; text-transform: uppercase; color: var(--brand-gold); }
.home-ways-heading { font-family: var(--brand-font-heading); font-size: 1.5rem; font-weight: 600; color: #e8d9b5; margin: 0.25rem 0 0.6rem; }
.home-ways-link {
display: flex; justify-content: space-between; align-items: center; gap: 1rem;
padding: 0.8rem 0.2rem; border-bottom: 1px solid rgba(201, 168, 76, 0.16);
text-decoration: none; color: #e8d9b5; transition: padding 180ms, color 180ms;
}
.home-ways-link strong { display: block; font-weight: 600; }
.home-ways-link small { display: block; color: var(--brand-muted); font-size: 0.82rem; margin-top: 0.1rem; line-height: 1.4; }
.home-ways-link:hover { padding-left: 0.7rem; color: #fff4d0; }
.home-ways-arrow { color: var(--brand-gold); flex-shrink: 0; }
@media (max-width: 800px) {
.home-ways { grid-template-columns: 1fr; }
.home-feature { min-height: 260px; padding: 1.6rem; }
}
+499 -207
View File
@@ -395,10 +395,12 @@ function AnalyticsConsentBanner({ content }: { content: SiteContent }) {
return (
<div className="consent-banner" role="region" aria-label="Analytics consent">
<p>{content.cookieBannerText || 'We use optional analytics cookies to measure visits and location trends for site improvement.'}</p>
<p className="consent-title"><span aria-hidden="true">🍪</span> Help us improve the site</p>
<p className="consent-text">{content.cookieBannerText || 'We use optional analytics cookies to measure visits and location trends for site improvement.'}</p>
<p className="consent-more"><Link to="/privacy">Privacy policy</Link></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>
<button type="button" className="consent-btn consent-btn--decline" onClick={decline}>Decline</button>
<button type="button" className="consent-btn consent-btn--accept" onClick={accept}>Accept</button>
</div>
</div>
)
@@ -1052,7 +1054,7 @@ function ExternalSitesSection({ content }: { content: SiteContent }) {
)
}
function HomepageNewsletterSection() {
function HomepageNewsletterSection({ embedded = false }: { embedded?: boolean }) {
const [email, setEmail] = useState('')
const [firstName, setFirstName] = useState('')
const [honey, setHoney] = useState('')
@@ -1074,44 +1076,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 (
<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>
<div className="section-inner newsletter-inner">{content}</div>
</section>
)
}
@@ -1224,6 +1234,44 @@ function TestimonialsSection({ testimonials }: { testimonials: Testimonial[] })
function LandingPage({ content }: { content: SiteContent }) {
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 ?? []).map(c => (c.path === '/resources' && c.title === 'Downloads' ? { ...c, title: 'Resources' } : c))
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 (
<div className="site">
@@ -1259,12 +1307,110 @@ function LandingPage({ content }: { content: SiteContent }) {
<SpotifyIcon />
{content.heroBtnSpotify || 'Listen on Spotify'}
</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="/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 Downloads' ? 'View Resources' : (content.seriesDownloadsBtnLabel || 'View Resources')}</Link>}
</div>
</div>
</article>
)
})}
</div>
</div>
</section>
{/* ── FEATURED STUDY + WAYS IN ── */}
<section className="section-home-jump" aria-label="Homepage navigation">
<div className={`section-inner home-ways${featuredStudy ? '' : ' home-ways--list-only'}`}>
{featuredStudy && (
<Link to={`/study/${featuredStudy.slug || ''}`} className="home-feature">
<div className="home-feature-head">
<span className="home-feature-eyebrow">{featuredStudy.homepageEyebrow || 'Study'}</span>
{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>
<span className="btn-primary home-feature-btn">Start the study</span>
</Link>
)}
<div className="home-ways-list">
<p className="home-ways-eyebrow">{content.whereToNextEyebrow || 'Where to Next'}</p>
<h2 className="home-ways-heading">{content.whereToNextHeading || 'Explore the site'}</h2>
{[...fixedCards, ...adminCards].map(card => (
<Link key={card.id} to={card.path} className="home-ways-link">
<span>
<strong>{card.title}</strong>
<small>{card.description}</small>
</span>
<span className="home-ways-arrow" aria-hidden="true">→</span>
</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">
<div className="section-inner prism-feature">
<div className="prism-video-shell">
@@ -1288,136 +1434,74 @@ function LandingPage({ content }: { content: SiteContent }) {
<p className="prism-description">
{content.prismIntro || 'What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?'}
</p>
<p className="prism-description">
{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">
<div className="prism-steps prism-steps--chips" aria-label="P.R.I.S.M. steps">
<p>{content.prismStep1 || 'P — Pray before you read'}</p>
<p>{content.prismStep2 || 'R — Read slowly and observe'}</p>
<p>{content.prismStep3 || 'I — Interpret with context'}</p>
<p>{content.prismStep4 || 'S — Study and apply specifically'}</p>
<p>{content.prismStep5 || 'M — Memorize one verse at a time'}</p>
</div>
<p className="prism-description">
{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 prism-emphasis">
{content.prismClosing || "The goal isn't to get through the text — it's to let the text get through to you."}
</p>
</div>
</div>
</section>
<HomepageNewsletterSection />
<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>
{prismOpen && (
<>
<p className="prism-description">
{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>
<p className="prism-description">
{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 prism-emphasis">
{content.prismClosing || "The goal isn't to get through the text — it's to let the text get through to you."}
</p>
</>
)}
<button type="button" className="prism-more" aria-expanded={prismOpen} onClick={() => setPrismOpen(v => !v)}>
{prismOpen ? 'Show less' : 'Read more about PRISM'}
</button>
</div>
</div>
</section>
<HomepageQACallout />
<TestimonialsSection testimonials={content.testimonials ?? []} />
<section className="section-share" aria-label="Share the show">
<div className="section-inner section-share-inner">
<div className="share-text">
<h2 className="section-heading" style={{ textAlign: 'left', marginBottom: '0.75rem' }}>
{/* ── CONNECT: newsletter + share ── */}
<section className="section-home-connect" aria-label="Stay connected">
<div className="section-inner home-connect-grid">
<HomepageNewsletterSection embedded />
<div className="home-connect-card home-connect-card--share">
<h2 className="section-heading" style={{ marginBottom: '0.75rem' }}>
{content.shareHeading || 'Help one more person hear the Word this week.'}
</h2>
<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>
<img src={content.shareQrImageUrl || '/images/qr-code.jpg'} alt="QR code to share the show" className="share-qr" />
{(content.platformKofiUrl || content.platformKofiQrUrl) && (
<div className="home-connect-card home-connect-card--kofi">
<p className="eyebrow">Support the Ministry</p>
<h2 className="section-heading" style={{ marginBottom: '0.75rem' }}>Help keep the Word going</h2>
<p className="share-p">This show is listener-supported. Your gift keeps the teaching free for everyone.</p>
<div className="home-connect-share-row">
{content.platformKofiUrl && (
<a href={content.platformKofiUrl} target="_blank" rel="noreferrer" className="kofi-btn">☕ Support on Ko-fi</a>
)}
{content.platformKofiQrUrl && (
<img src={content.platformKofiQrUrl} alt="Ko-fi donation QR code" className="share-qr" onError={e => { e.currentTarget.style.display = 'none' }} />
)}
</div>
</div>
)}
</div>
</section>
{/* ── MORE FROM US ── */}
<div className="home-more">
<HomepageQACallout />
<TruthForLifeWidget type="audiodevo" />
<ExternalSitesSection content={content} />
</div>
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
@@ -1612,28 +1696,150 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
)
}
function useEpisodesForBook(season: number) {
const [episodes, setEpisodes] = useState<{ title: string; episode: string; season: string; duration: string; audioUrl: string; link: string; description: string }[]>([])
type PodEpisode = { title: string; episode: string; season: string; duration: string; audioUrl: string; link: string; description: string; pubDate: string }
function useAllEpisodes() {
const [episodes, setEpisodes] = useState<PodEpisode[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/episodes/all')
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { episodes?: { title: string; episode: string; season: string; duration: string; audioUrl: string; link: string; description: string }[] }) => {
const filtered = (data.episodes ?? [])
.filter(ep => ep.season === String(season))
.sort((a, b) => parseInt(a.episode) - parseInt(b.episode))
setEpisodes(filtered)
})
.then((data: { episodes?: PodEpisode[] }) => setEpisodes(data.episodes ?? []))
.catch(() => {})
.finally(() => setLoading(false))
}, [season])
}, [])
return { episodes, loading }
}
function useEpisodesForBook(season: number) {
const { episodes: all, loading } = useAllEpisodes()
const episodes = useMemo(
() => all.filter(ep => ep.season === String(season)).sort((a, b) => parseInt(a.episode) - parseInt(b.episode)),
[all, season],
)
return { episodes, loading }
}
function durationSeconds(d: string) {
if (!d) return 0
if (/^\d+$/.test(d)) return parseInt(d, 10)
const parts = d.split(':').map(Number)
if (parts.some(n => Number.isNaN(n))) return 0
return parts.reduce((acc, n) => acc * 60 + n, 0)
}
function formatRuntime(sec: number) {
if (sec <= 0) return ''
const h = Math.floor(sec / 3600)
const m = Math.round((sec % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
function formatEpisodeDate(pubDate: string) {
const d = new Date(pubDate)
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
// One playlist row: number, title, date/duration, and a Play button that opens the audio player in place.
function EpisodeRow({ ep, label, isOpen, onToggle }: { ep: PodEpisode; label: string | number; isOpen: boolean; onToggle: () => void }) {
const date = formatEpisodeDate(ep.pubDate)
const meta = [date, ep.duration].filter(Boolean).join(' · ')
return (
<li className={`finished-series-episode${isOpen ? ' finished-series-episode--open' : ''}`}>
<span className="finished-series-ep-num">{label}</span>
<div className="finished-series-ep-body">
<div className="finished-series-ep-row">
<div>
<p className="finished-series-ep-title">{ep.title}</p>
{meta && <p className="finished-series-ep-meta">{meta}</p>}
</div>
{ep.audioUrl && (
<button type="button" className="finished-series-ep-play" aria-expanded={isOpen} onClick={onToggle}>
{isOpen ? 'Hide player' : '▶ Play'}
</button>
)}
</div>
{isOpen && ep.audioUrl && <EpisodeAudioPlayer src={ep.audioUrl} title={ep.title} />}
</div>
</li>
)
}
// Maps a finished-book title ("Titus", "Study of 1 John") to its canonical Bible book id.
function bibleBookIdForTitle(title: string): string | null {
const t = title.toLowerCase()
const hits = BIBLE_BOOKS.filter(b => t.includes(b.name.toLowerCase())).sort((a, b) => b.name.length - a.name.length)
return hits[0]?.id ?? null
}
function FinishedBooksPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(`Finished Books | ${siteTitle}`, 'Completed series from Verse by Verse with Nate — browse episode playlists for past book studies.')
const books = content.finishedBooks ?? []
const { episodes } = useAllEpisodes()
const [query, setQuery] = useState('')
const [testament, setTestament] = useState<'all' | 'ot' | 'nt'>('all')
const [sort, setSort] = useState<'recent' | 'canon'>('recent')
const statsBySeason = useMemo(() => {
const map = new Map<number, { count: number; seconds: number }>()
for (const ep of episodes) {
const season = parseInt(ep.season, 10)
if (Number.isNaN(season)) continue
const cur = map.get(season) ?? { count: 0, seconds: 0 }
cur.count += 1
cur.seconds += durationSeconds(ep.duration)
map.set(season, cur)
}
return map
}, [episodes])
const canonIndex = (title: string) => {
const id = bibleBookIdForTitle(title)
const i = id ? BIBLE_BOOKS.findIndex(b => b.id === id) : -1
return i < 0 ? 999 : i
}
const needle = query.trim().toLowerCase()
const filtering = Boolean(needle) || testament !== 'all'
const visible = books
.filter(book => {
if (needle && !`${book.title} ${book.description}`.toLowerCase().includes(needle)) return false
if (testament !== 'all') {
const i = canonIndex(book.title)
if (i === 999 || (testament === 'ot') !== (i < 39)) return false
}
return true
})
.sort((a, b) => sort === 'canon' ? canonIndex(a.title) - canonIndex(b.title) : b.season - a.season)
const showFeatured = !filtering && sort === 'recent' && visible.length > 2
const renderCard = (book: (typeof books)[number], featured: boolean) => {
const stats = statsBySeason.get(book.season)
const facts = [stats ? `${stats.count} episodes` : '', stats ? formatRuntime(stats.seconds) : ''].filter(Boolean).join(' · ')
return (
<Link key={book.id} to={`/finished/${book.id}`} className={`finished-book-card${featured ? ' finished-book-card--featured' : ''}`}>
{book.imageUrl && (
<div className="finished-book-card-img-wrap">
<img src={book.imageUrl} alt={book.title} className="finished-book-card-img" loading="lazy" />
</div>
)}
<div className="finished-book-card-body">
<p className="finished-book-card-season">{featured ? `Most recently finished · Season ${book.season}` : `Season ${book.season}`}</p>
<h2 className="finished-book-card-title">{book.title}</h2>
{facts && <p className="finished-book-card-facts">{facts}</p>}
{book.description && <p className="finished-book-card-desc">{book.description}</p>}
<div className="finished-book-card-badges">
{book.downloadUrl && <span className="finished-book-badge">⬇ Album</span>}
{book.studySlug && <span className="finished-book-badge">📖 Study</span>}
{book.guide?.title && <span className="finished-book-badge">📄 Guide</span>}
</div>
<span className="finished-book-card-cta">View Playlist →</span>
</div>
</Link>
)
}
const pill = (active: boolean) => `si-pill${active ? ' si-pill--on' : ''}`
return (
<div className="site">
@@ -1654,24 +1860,38 @@ function FinishedBooksPage({ content }: { content: SiteContent }) {
</Link>
</div>
) : (
<div className="finished-books-grid">
{books.map(book => (
<Link key={book.id} to={`/finished/${book.id}`} className="finished-book-card">
{book.imageUrl && (
<div className="finished-book-card-img-wrap">
<img src={book.imageUrl} alt={book.title} className="finished-book-card-img" />
{book.downloadUrl && <span className="finished-book-card-dl-badge" title="Download available">⬇</span>}
<>
{books.length > 3 && (
<div className="finished-books-toolbar">
<input
type="search"
className="finished-series-filter"
placeholder="Search finished books…"
aria-label="Search finished books"
value={query}
onChange={e => setQuery(e.target.value)}
/>
<div className="si-filters">
<div className="si-pills" role="group" aria-label="Testament">
<button type="button" className={pill(testament === 'all')} onClick={() => setTestament('all')}>All</button>
<button type="button" className={pill(testament === 'ot')} onClick={() => setTestament('ot')}>Old Testament</button>
<button type="button" className={pill(testament === 'nt')} onClick={() => setTestament('nt')}>New Testament</button>
</div>
<div className="si-pills" role="group" aria-label="Sort order">
<button type="button" className={pill(sort === 'recent')} onClick={() => setSort('recent')}>Most recent</button>
<button type="button" className={pill(sort === 'canon')} onClick={() => setSort('canon')}>Bible order</button>
</div>
)}
<div className="finished-book-card-body">
<p className="finished-book-card-season">Season {book.season}</p>
<h2 className="finished-book-card-title">{book.title}</h2>
{book.description && <p className="finished-book-card-desc">{book.description}</p>}
<span className="finished-book-card-cta">View Playlist →</span>
</div>
</Link>
))}
</div>
</div>
)}
{visible.length === 0 ? (
<p className="finished-books-empty">No finished books match your filters.</p>
) : (
<div className="finished-books-grid">
{visible.map((book, i) => renderCard(book, showFeatured && i === 0))}
</div>
)}
</>
)}
</div>
</section>
@@ -1683,7 +1903,7 @@ function FinishedBooksPage({ content }: { content: SiteContent }) {
const FINISHED_PLAYLIST_PREVIEW_COUNT = 10
const FINISHED_PLAYLIST_FILTER_THRESHOLD = 12
type FinishedBookEpisode = { title: string; episode: string; duration: string; audioUrl: string; description: string }
type FinishedBookEpisode = PodEpisode
// One section with two tabs instead of two full-length lists stacked on top of each other.
// "Highlights" are the hand-picked episodes (with notes); "All Episodes" is the complete
@@ -1781,31 +2001,7 @@ function FinishedSeriesEpisodes({ highlights, episodes, loading }: {
<ol className="finished-series-playlist">
{shownEpisodes.map((ep, i) => {
const key = ep.audioUrl || `${ep.episode}-${i}`
const isOpen = openKey === key
return (
<li key={key} className={`finished-series-episode${isOpen ? ' finished-series-episode--open' : ''}`}>
<span className="finished-series-ep-num">{ep.episode || i + 1}</span>
<div className="finished-series-ep-body">
<div className="finished-series-ep-row">
<div>
<p className="finished-series-ep-title">{ep.title}</p>
{ep.duration && <p className="finished-series-ep-meta">{ep.duration}</p>}
</div>
{ep.audioUrl && (
<button
type="button"
className="finished-series-ep-play"
aria-expanded={isOpen}
onClick={() => setOpenKey(isOpen ? null : key)}
>
{isOpen ? 'Hide player' : '▶ Play'}
</button>
)}
</div>
{isOpen && ep.audioUrl && <EpisodeAudioPlayer src={ep.audioUrl} title={ep.title} />}
</div>
</li>
)
return <EpisodeRow key={key} ep={ep} label={ep.episode || i + 1} isOpen={openKey === key} onToggle={() => setOpenKey(openKey === key ? null : key)} />
})}
</ol>
)}
@@ -2036,12 +2232,41 @@ function LatestEpisodePlayer({ content }: { content: SiteContent }) {
)
}
const EPISODES_PREVIEW_COUNT = 8
function EpisodesPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Episodes | ${siteTitle}`,
content.episodesSeoIntro || 'Browse all episodes of Verse by Verse with Nate — expository Bible teaching, one verse at a time.',
)
const { episodes, loading } = useAllEpisodes()
const [query, setQuery] = useState('')
const [season, setSeason] = useState('all')
const [expanded, setExpanded] = useState(false)
const [openKey, setOpenKey] = useState<string | null>(null)
const finishedBooks = [...(content.finishedBooks ?? [])].sort((a, b) => b.season - a.season)
const second = content.secondSeries?.title?.trim() ? content.secondSeries : null
const liveSeries = [
{ key: content.seriesSeason ? String(content.seriesSeason) : '', title: content.seriesTitle, label: second ? (content.seriesLabel || 'Series A') : 'Current series', description: content.seriesDescription, imageUrl: content.seriesImageUrl, season: content.seriesSeason },
...(second ? [{ key: second.season ? String(second.season) : '', title: second.title, label: second.label || 'Series B', description: second.description, imageUrl: second.imageUrl, season: second.season }] : []),
]
const seasonChips = [
...liveSeries.filter(ls => ls.key).map(ls => ({ key: ls.key, label: second ? ls.title : 'Current series' })),
...finishedBooks.map(book => ({ key: String(book.season), label: book.title })),
]
const countFor = (key: string) => episodes.filter(ep => ep.season === key).length
const needle = query.trim().toLowerCase()
const matching = episodes.filter(ep => {
if (season !== 'all' && ep.season !== season) return false
return !needle || `${ep.episode} ${ep.title}`.toLowerCase().includes(needle)
})
const showAll = expanded || needle.length > 0
const shown = showAll ? matching : matching.slice(0, EPISODES_PREVIEW_COUNT)
const activeBook = finishedBooks.find(book => String(book.season) === season)
const pill = (active: boolean) => `si-pill${active ? ' si-pill--on' : ''}`
return (
<div className="site">
@@ -2049,22 +2274,89 @@ function EpisodesPage({ content }: { content: SiteContent }) {
<SiteSearchBar content={content} />
<PageBanner eyebrow="Episodes" title="Podcast Episodes" desc={content.episodesSeoIntro || 'Expository Bible teaching — one verse at a time.'} />
<section className="section-player" aria-label="Current series episodes">
<div className="section-inner">
<section className="section-player" aria-label="Current series">
<div className="section-inner ep-hero">
<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>
<div className="embed-wrap">
<div className={`ep-hero-series${second ? ' ep-hero-series--two' : ''}`}>
{liveSeries.map((ls, i) => (
<div key={i} className="ep-hero-series-card">
{ls.imageUrl && <img src={ls.imageUrl} alt={ls.title} className="ep-hero-art" />}
<div className="ep-hero-body">
<p className="finished-book-card-season">{ls.label}{ls.season ? ` · Season ${ls.season}` : ''}</p>
<h2 className="ep-hero-title">{ls.title}</h2>
{ls.description && <p className="ep-hero-desc">{ls.description}</p>}
{second && ls.key && (
<a href="#browse-episodes" className="ep-hero-browse" onClick={() => { setSeason(ls.key); setExpanded(false) }}>Browse these episodes ↓</a>
)}
</div>
</div>
))}
</div>
<div className="ep-hero-player">
<LatestEpisodePlayer content={content} />
</div>
</div>
<div className="section-inner">
<PlatformButtons content={content} />
<HeadlinerWidget />
</div>
</section>
<HomepageNewsletterSection />
<section id="browse-episodes" className="section-episode-browser" aria-label="Browse episodes">
<div className="section-inner">
<h2 className="section-heading"><span className="ornament">✦</span> Browse Episodes <span className="ornament">✦</span></h2>
<input
type="search"
className="finished-series-filter"
placeholder="Search episodes by number or title…"
aria-label="Search episodes"
value={query}
onChange={e => setQuery(e.target.value)}
/>
{seasonChips.length > 0 && (
<div className="si-pills ep-season-chips" role="group" aria-label="Filter by series">
<button type="button" className={pill(season === 'all')} onClick={() => { setSeason('all'); setExpanded(false) }}>
All{!loading && <span>{episodes.length}</span>}
</button>
{seasonChips.map(chip => (
<button key={chip.key} type="button" className={pill(season === chip.key)} onClick={() => { setSeason(chip.key); setExpanded(false) }}>
{chip.label}{!loading && <span>{countFor(chip.key)}</span>}
</button>
))}
</div>
)}
{activeBook && (
<p className="ep-season-link"><Link to={`/finished/${activeBook.id}`}>Open the full {activeBook.title} playlist →</Link></p>
)}
{loading ? (
<p className="finished-books-empty">Loading episodes…</p>
) : matching.length === 0 ? (
<p className="finished-books-empty">{needle ? `No episodes match “${query.trim()}”.` : 'No episodes found.'}</p>
) : (
<ol className="finished-series-playlist">
{shown.map((ep, i) => {
const key = ep.audioUrl || `${ep.episode}-${i}`
return <EpisodeRow key={key} ep={ep} label={ep.episode || '•'} isOpen={openKey === key} onToggle={() => setOpenKey(openKey === key ? null : key)} />
})}
</ol>
)}
{!needle && matching.length > EPISODES_PREVIEW_COUNT && (
<div className="highlight-more">
<button type="button" className="btn-secondary" onClick={() => setExpanded(v => !v)}>
{expanded ? 'Show fewer' : `Show all ${matching.length} episodes`}
</button>
</div>
)}
</div>
</section>
<PodcastHighlightsSection content={content} />
<CustomBlocksSection content={content} page="episodes" />
<HomepageNewsletterSection />
<section className="section-episode-headliner" aria-label="Podcast notifications">
<div className="section-inner"><HeadlinerWidget /></div>
</section>
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
+39
View File
@@ -14,6 +14,9 @@ export interface StudyProgram {
showOnHomepage?: boolean
showNewTag?: boolean
newTagLabel?: string
// Which companion guide the study offers for download: '' = automatic (the guide of the series or
// finished book linked to this study), 'none', 'primary', 'second', or 'book:<finished book id>'
guideSource?: string
// 'complete' = finished and self-paced (still open for enrollment). A study whose lessons
// have all been released is treated as complete even if left as 'active'.
status: 'active' | 'planned' | 'complete'
@@ -700,3 +703,39 @@ export function getAllHighlights(content: Pick<SiteContent, 'podcastFeaturedLink
...(content.finishedBooks ?? []).flatMap(book => book.highlights ?? []),
]
}
export interface StudyGuideLink {
// Page id under /downloads/ (primary-guide, second-guide, book-guide--<id>)
pageId: string
title: string
}
// The companion guide a study offers, either chosen explicitly in admin or found through the
// series / finished book that is linked to the study.
export function getStudyGuideLink(
content: Pick<SiteContent, 'seriesStudySlug' | 'studyGuideTitle' | 'studyGuideUrl' | 'studyGuideDownloadUrl' | 'studyGuideAppleBooksUrl' | 'secondSeries' | 'finishedBooks'>,
study: Pick<StudyProgram, 'slug' | 'guideSource'>,
): StudyGuideLink | null {
const source = study.guideSource ?? ''
if (source === 'none') return null
const primary = (): StudyGuideLink | null =>
content.studyGuideTitle && (content.studyGuideUrl || content.studyGuideDownloadUrl || content.studyGuideAppleBooksUrl)
? { pageId: 'primary-guide', title: content.studyGuideTitle }
: null
const second = (): StudyGuideLink | null =>
content.secondSeries?.guide?.title ? { pageId: 'second-guide', title: content.secondSeries.guide.title } : null
const book = (id: string): StudyGuideLink | null => {
const found = (content.finishedBooks ?? []).find(b => b.id === id)
return found?.guide?.title ? { pageId: `book-guide--${found.id}`, title: found.guide.title } : null
}
if (source === 'primary') return primary()
if (source === 'second') return second()
if (source.startsWith('book:')) return book(source.slice('book:'.length))
if (content.seriesStudySlug && content.seriesStudySlug === study.slug) return primary()
if (content.secondSeries?.studySlug && content.secondSeries.studySlug === study.slug) return second()
const linkedBook = (content.finishedBooks ?? []).find(b => b.studySlug === study.slug && b.guide?.title)
return linkedBook ? book(linkedBook.id) : null
}
+4
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import DOMPurify from 'dompurify'
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
import type { SiteContent, StudyProgram, StudySection } from './content'
import { getStudyGuideLink } from './content'
import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './legacyColossiansData'
import { usePageMeta } from './hooks/usePageMeta'
import { Breadcrumbs } from './components/Breadcrumbs'
@@ -1704,6 +1705,8 @@ export function ColossiansStudyIndexPage({ content }: Props) {
}
}
const studyGuide = getStudyGuideLink(content, study)
return (
<main className="study-index-page" aria-label={`${study.title} study`}>
<section className="section-study-course-hero">
@@ -1740,6 +1743,7 @@ export function ColossiansStudyIndexPage({ content }: Props) {
{enrolling ? 'Enrolling...' : 'Enroll in This Study'}
</button>
)}
{studyGuide && <Link to={`/downloads/${studyGuide.pageId}`} className="btn-secondary">📄 Download Study Guide</Link>}
{allLessonsDone && <Link to="/study/account" className="btn-secondary">🏅 My Certificate</Link>}
{enrolled && <Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>}
{enrolled && studyStatus !== 'planned' && <Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>}