Compare commits
12
Commits
03f46ae683
...
82ce312898
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82ce312898 | ||
|
|
b48a81a00c | ||
|
|
b04a8b30cd | ||
|
|
f3fdd32943 | ||
|
|
ee707fad10 | ||
|
|
e82e8222f8 | ||
|
|
04a572bb6e | ||
|
|
448e3eec2b | ||
|
|
95aecf739f | ||
|
|
729cf94c12 | ||
|
|
d1c84aec6b | ||
|
|
f1577e9d8a |
No files matched your search
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "2.30.0",
|
||||
"version": "2.35.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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’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 “Download Study Guide” button to this study’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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
Reference in new issue
Block a user