Resources page redesign: tabs + compact grids; download page redesign with site header, two-column card and a mobile-safe form; v2.26.0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-12 16:19:28 -04:00
1 parent 9268514219
commit d06fcba60b
5 files changed
+406 -468

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "siteforge",
"version": "2.25.2",
"version": "2.26.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "siteforge",
"version": "2.25.2",
"version": "2.26.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.25.2",
"version": "2.26.0",
"type": "module",
"scripts": {
"dev": "vite",
+142 -202
View File
@@ -5768,11 +5768,7 @@ a.study-track-card:hover {
opacity: 0.9;
}
.section-download-library {
background:
radial-gradient(circle at top center, rgba(201, 168, 76, 0.1), transparent 26%),
var(--brand-black);
}
.download-library-head {
max-width: 760px;
@@ -5788,74 +5784,21 @@ a.study-track-card:hover {
margin: 0.85rem auto 0;
}
.download-tag-filter {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
margin: 0 auto 2rem;
max-width: 760px;
}
.download-tag-chip {
background: transparent;
border: 1px solid rgba(201, 168, 76, 0.3);
border-radius: 999px;
color: rgba(255, 255, 255, 0.65);
cursor: pointer;
font-family: var(--brand-font-body);
font-size: 0.88rem;
font-weight: 600;
letter-spacing: 0.03em;
padding: 0.35rem 1rem;
transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
}
.download-tag-chip:hover {
background: rgba(201, 168, 76, 0.1);
border-color: rgba(201, 168, 76, 0.55);
color: var(--brand-warm-white);
}
.download-tag-chip--active {
background: rgba(201, 168, 76, 0.15);
border-color: var(--brand-gold);
color: var(--brand-gold);
}
.download-clear-link {
background: none;
border: none;
cursor: pointer;
font-family: var(--brand-font-body);
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--brand-muted);
padding: 0.35rem 0.5rem;
transition: color 140ms ease;
text-decoration: underline;
text-underline-offset: 3px;
}
.download-clear-link:hover {
color: var(--brand-warm-white);
}
.download-empty-state {
text-align: center;
color: var(--brand-muted);
font-family: var(--brand-font-body);
padding: 2rem 0;
}
.download-filter-bar {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 2rem;
}
/* Related episodes on download detail pages */
.download-related-episodes {
@@ -5987,118 +5930,36 @@ a.study-track-card:hover {
padding: 4rem 0;
}
.books-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.book-card {
display: flex;
gap: 1.25rem;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(201, 168, 76, 0.1);
border-radius: 8px;
padding: 1.25rem;
}
.book-card-cover {
width: 80px;
height: 110px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.book-card-meta {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.book-card-title {
font-family: var(--brand-font-heading);
font-size: 1rem;
font-weight: 700;
color: var(--brand-warm-white);
line-height: 1.3;
}
.book-card-description {
font-family: var(--brand-font-body);
font-size: 0.875rem;
font-weight: 300;
color: var(--brand-muted);
line-height: 1.6;
margin: 0;
}
.book-card-btn {
align-self: flex-start;
font-size: 0.8rem;
padding: 0.4rem 0.9rem;
}
/* ── Recommended Resources section ── */
.recommended-resources-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem;
margin-top: 1rem;
}
.recommended-resource-card {
display: flex;
gap: 1.25rem;
align-items: flex-start;
text-decoration: none;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(201, 168, 76, 0.1);
border-radius: 8px;
padding: 1.25rem;
transition: border-color 0.2s;
}
.recommended-resource-card:hover {
border-color: rgba(201, 168, 76, 0.35);
}
.recommended-resource-image {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
}
.recommended-resource-meta {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.recommended-resource-title {
font-family: var(--brand-font-heading);
font-size: 1rem;
font-weight: 700;
color: var(--brand-warm-white);
}
.recommended-resource-description {
font-family: var(--brand-font-body);
font-size: 0.875rem;
font-weight: 300;
color: var(--brand-muted);
line-height: 1.6;
margin: 0;
}
.recommended-resource-action {
font-size: 0.85rem;
color: var(--brand-gold);
font-weight: 600;
}
/* ── Interior page banner ── */
.section-page-banner {
@@ -6132,18 +5993,9 @@ a.study-track-card:hover {
}
/* ── Featured download card ── */
.resource-download-card--featured {
margin-bottom: 1.5rem;
border-color: rgba(201, 168, 76, 0.35);
background: rgba(201, 168, 76, 0.04);
}
.resource-link-image--featured {
width: 80px !important;
height: 110px !important;
border-radius: 6px !important;
object-fit: cover;
}
.featured-badge {
display: inline-block;
@@ -6161,37 +6013,13 @@ a.study-track-card:hover {
}
/* ── Books + Recommended Resources tabs ── */
.books-resources-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 2rem;
border-bottom: 1px solid rgba(201, 168, 76, 0.15);
padding-bottom: 0;
}
.books-resources-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
padding: 0.6rem 1.1rem;
margin-bottom: -1px;
font-family: var(--brand-font-body);
font-size: 0.92rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: color 160ms ease, border-color 160ms ease;
letter-spacing: 0.02em;
}
.books-resources-tab:hover {
color: rgba(255, 255, 255, 0.85);
}
.books-resources-tab--active {
color: var(--brand-gold);
border-bottom-color: var(--brand-gold);
}
/* ── Study Guide section ── */
.section-guide {
@@ -13834,3 +13662,115 @@ a.study-track-card:hover {
font-size: 1.0625rem;
}
}
/* ── Resources page (tabs + compact grids) ─────────────────────────────── */
.res-hub { padding: 2rem 0 3.5rem; }
.res-tabs { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.res-tab {
font: inherit; font-size: 0.95rem; cursor: pointer; padding: 0.5rem 1.1rem; border-radius: 999px;
color: var(--brand-muted); background: transparent; border: 1px solid var(--brand-border);
transition: border-color 150ms, color 150ms, background 150ms;
}
.res-tab span { opacity: 0.6; margin-left: 0.25rem; font-size: 0.85em; }
.res-tab:hover { border-color: var(--brand-gold-dark); color: var(--brand-warm-white); }
.res-tab--on, .res-tab--on:hover { background: var(--brand-gold); border-color: var(--brand-gold); color: var(--brand-black); }
.res-featured { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; margin-bottom: 1.75rem; }
.res-feature {
display: flex; gap: 1rem; align-items: center; padding: 1rem; text-decoration: none; color: inherit;
background: linear-gradient(135deg, rgba(201, 168, 76, 0.12), rgba(201, 168, 76, 0.03));
border: 1px solid var(--brand-gold-dark); border-radius: 14px; transition: transform 150ms, border-color 150ms;
}
.res-feature:hover { transform: translateY(-2px); border-color: var(--brand-gold); }
.res-feature-img { width: 84px; height: 84px; flex-shrink: 0; object-fit: cover; border-radius: 10px; }
.res-feature-body { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
.res-badge {
align-self: flex-start; font-size: 0.7rem; letter-spacing: 0.1em; text-transform: uppercase; font-weight: 700;
color: var(--brand-black); background: var(--brand-gold); padding: 0.1rem 0.55rem; border-radius: 999px;
}
.res-tags { display: flex; gap: 0.45rem; flex-wrap: wrap; margin-bottom: 1.25rem; }
.res-chip {
font: inherit; font-size: 0.82rem; cursor: pointer; padding: 0.25rem 0.8rem; border-radius: 999px;
color: var(--brand-muted); background: transparent; border: 1px solid var(--brand-border);
}
.res-chip:hover { border-color: var(--brand-gold-dark); color: var(--brand-warm-white); }
.res-chip--on { background: rgba(201, 168, 76, 0.18); border-color: var(--brand-gold-dark); color: var(--brand-gold-light); }
.res-empty { color: var(--brand-muted); padding: 1rem 0; }
.res-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
.res-card, .res-book {
display: flex; gap: 0.9rem; align-items: flex-start; padding: 0.95rem; text-decoration: none; color: inherit;
background: var(--brand-black-3); border: 1px solid var(--brand-border); border-radius: 12px;
transition: border-color 150ms, transform 150ms;
}
.res-card:hover { border-color: var(--brand-gold-dark); transform: translateY(-2px); }
.res-card-img { width: 64px; height: 64px; flex-shrink: 0; object-fit: cover; border-radius: 8px; }
.res-book-cover { width: 76px; flex-shrink: 0; aspect-ratio: 2 / 3; object-fit: cover; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); }
.res-card-body, .res-book-body { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; flex: 1; }
.res-title { font-family: var(--brand-font-heading); font-size: 1.25rem; font-weight: 600; line-height: 1.25; color: var(--brand-warm-white); overflow-wrap: anywhere; }
.res-desc { margin: 0; font-size: 0.92rem; line-height: 1.55; color: var(--brand-muted); }
.res-clamped { display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; }
.res-card-tags { font-size: 0.75rem; color: var(--brand-gold-dark); letter-spacing: 0.03em; }
.res-action { margin-top: 0.15rem; font-size: 0.85rem; color: var(--brand-gold); }
.res-more { align-self: flex-start; font: inherit; font-size: 0.82rem; padding: 0; background: none; border: 0; cursor: pointer; color: var(--brand-gold); text-decoration: underline; }
.res-book-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.4rem; }
.res-btn { font-size: 0.78rem !important; padding: 0.45rem 0.95rem !important; }
@media (max-width: 600px) {
.res-hub { padding: 1.25rem 0 2.5rem; }
.res-tabs { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin-left: -0.25rem; padding: 0 0.25rem 0.25rem; }
.res-tab { flex-shrink: 0; }
.res-grid { grid-template-columns: 1fr; gap: 0.75rem; }
.res-feature { align-items: flex-start; }
.res-feature-img { width: 64px; height: 64px; }
.res-title { font-size: 1.1rem; }
.res-card, .res-book { padding: 0.8rem; gap: 0.75rem; }
.res-card-img { width: 52px; height: 52px; }
.res-book-cover { width: 64px; }
/* Shorter previews on phones; the tag chips above already cover tags */
.res-card .res-clamped, .res-feature .res-clamped { -webkit-line-clamp: 2 !important; }
.res-card-tags { display: none; }
}
/* ── Download page ─────────────────────────────────────────────────────── */
.dl-page { padding: 1.5rem 0 3.5rem; }
.dl-back { display: inline-block; margin-bottom: 1rem; font-size: 0.9rem; color: var(--brand-muted); text-decoration: none; }
.dl-back:hover { color: var(--brand-gold); }
.dl-card {
display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 2rem; align-items: start;
background: var(--brand-black-3); border: 1px solid var(--brand-border); border-radius: 16px; padding: 1.75rem;
}
.dl-art img { width: 100%; max-height: 320px; object-fit: contain; border-radius: 10px; box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); }
.dl-body { min-width: 0; }
.dl-body .eyebrow { margin: 0 0 0.35rem; }
#root > :not(.admin-shell) .dl-card h1 { font-size: clamp(1.7rem, 3.2vw, 2.25rem); line-height: 1.15; margin: 0 0 0.75rem; }
.dl-summary { margin: 0 0 0.9rem; font-size: 1rem; line-height: 1.65; color: var(--brand-muted); white-space: pre-line; }
.dl-tags { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1.1rem; }
.dl-tag { font-size: 0.78rem; padding: 0.15rem 0.7rem; border-radius: 999px; border: 1px solid var(--brand-gold-dark); color: var(--brand-gold-light); }
.dl-form .study-download-form { margin-top: 0.4rem; }
.dl-form .study-download-grid label:nth-child(3) { grid-column: 1 / -1; }
/* The consent line is a sentence, not a field label */
.dl-form .study-download-form label.contact-consent {
flex-direction: row; align-items: flex-start; gap: 0.6rem;
text-transform: none; letter-spacing: normal; font-size: 0.9rem; font-weight: 400; color: var(--brand-muted);
}
.dl-form .study-download-form label.contact-consent input { margin-top: 0.25rem; flex-shrink: 0; }
.dl-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; margin-top: 1.1rem; font-size: 0.88rem; color: var(--brand-muted); }
.dl-actions .btn-secondary { padding: 0.5rem 1.1rem; font-size: 0.8rem; }
@media (max-width: 700px) {
.dl-page { padding: 1rem 0 2.5rem; }
.dl-card { grid-template-columns: minmax(0, 1fr); gap: 1rem; padding: 1.1rem; }
.dl-art { display: flex; justify-content: center; }
.dl-art img { width: auto; max-width: 150px; max-height: 200px; }
.dl-form .study-download-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 0.7rem; }
.dl-form .study-download-form { padding: 0.85rem; }
.dl-form .study-download-form input[type='text'],
.dl-form .study-download-form input[type='email'] { min-width: 0; width: 100%; }
}
@media (max-width: 480px) {
.dl-form .study-download-grid { grid-template-columns: minmax(0, 1fr); }
}
+50 -263
View File
@@ -14,6 +14,7 @@ import { GlobalSearch } from './components/GlobalSearch'
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
import { NuggetsHostPortal } from './components/NuggetsHostPortal'
import { HeaderAccount } from './components/HeaderAccount'
import { ResourcesHub } from './components/ResourcesHub'
import { BIBLE_BOOKS } from '../shared/bible-books.js'
import './App.css'
import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useLinkClickTracking, checkAnalyticsConsentResponse } from './analytics'
@@ -854,131 +855,6 @@ function PageBanner({ eyebrow, title, desc }: { eyebrow: string; title: string;
)
}
function DownloadLibrarySection({ content }: { content: SiteContent }) {
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
const [activeTag, setActiveTag] = useState<string | null>(null)
const hasPrimaryGuide = !!(content.studyGuideTitle)
const allTags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean)
const filteredResources = resources.filter(r => {
return !activeTag || (r.tags ?? []).includes(activeTag)
})
if (!hasPrimaryGuide && !content.secondSeries?.guide?.title && resources.length === 0) return null
return (
<section className="section-resources section-download-library" aria-label="Download library">
<div className="section-inner">
{hasPrimaryGuide && (
<Link to="/downloads/primary-guide" className="resource-download-card resource-download-card--link resource-download-card--featured">
<div className="resource-download-header">
<img
src={content.seriesImageUrl || '/images/titus-cover.png'}
alt={content.seriesTitle}
className="resource-link-image resource-link-image--featured"
loading="lazy"
/>
<div className="resource-download-meta">
<div className="featured-badge">Featured</div>
<span className="resource-link-label">{content.studyGuideTitle}</span>
{content.studyGuideDescription && (
<p className="resource-link-description">{content.studyGuideDescription}</p>
)}
<span className="resource-link-action">Download free guide →</span>
</div>
</div>
</Link>
)}
{content.secondSeries?.guide?.title && (
<Link to="/downloads/second-guide" className="resource-download-card resource-download-card--link resource-download-card--featured">
<div className="resource-download-header">
{content.secondSeries.imageUrl && (
<img
src={content.secondSeries.imageUrl}
alt={content.secondSeries.title}
className="resource-link-image resource-link-image--featured"
loading="lazy"
/>
)}
<div className="resource-download-meta">
<div className="featured-badge">Featured</div>
<span className="resource-link-label">{content.secondSeries.guide.title}</span>
{content.secondSeries.guide.description && (
<p className="resource-link-description">{content.secondSeries.guide.description}</p>
)}
<span className="resource-link-action">Download free guide →</span>
</div>
</div>
</Link>
)}
{resources.length > 0 && (
<>
{allTags.length > 0 && (
<div className="download-filter-bar">
<div className="download-tag-filter">
<button
type="button"
className={`download-tag-chip${activeTag === null ? ' download-tag-chip--active' : ''}`}
onClick={() => setActiveTag(null)}
>
All topics
</button>
{allTags.map(tag => (
<button
key={tag}
type="button"
className={`download-tag-chip${activeTag === tag ? ' download-tag-chip--active' : ''}`}
onClick={() => setActiveTag(prev => prev === tag ? null : tag)}
>
{tag}
</button>
))}
{activeTag !== null && (
<button
type="button"
className="download-clear-link"
onClick={() => setActiveTag(null)}
>
Clear
</button>
)}
</div>
</div>
)}
{filteredResources.length > 0 ? (
<div className="resources-list">
{filteredResources.map(resource => (
<Link key={resource.id} to={`/downloads/${buildCustomDownloadPageId(resource.id)}`} className="resource-download-card resource-download-card--link">
<div className="resource-download-header">
{resource.imageUrl && (
<img src={resource.imageUrl} alt={resource.label} className="resource-link-image" loading="lazy" />
)}
<div className="resource-download-meta">
<span className="resource-link-label">{resource.label}</span>
{resource.description && <p className="resource-link-description">{resource.description}</p>}
{(resource.tags ?? []).length > 0 && (
<div className="resource-link-tags">{(resource.tags ?? []).join(', ')}</div>
)}
<span className="resource-link-action">Open download page →</span>
</div>
</div>
</Link>
))}
</div>
) : (
<p className="download-empty-state">No resources tagged "{activeTag}".</p>
)}
</>
)}
</div>
</section>
)
}
function DownloadDetailPage({ content }: { content: SiteContent }) {
const { id } = useParams<{ id: string }>()
const resource = resolveDownloadPageResource(content, id)
@@ -1002,44 +878,57 @@ function DownloadDetailPage({ content }: { content: SiteContent }) {
}
return (
<main className="thanks-page download-detail-page" aria-label={resource.label}>
<div className="thanks-card download-detail-card">
<p className="eyebrow">Resources</p>
<h1>{resource.label}</h1>
<p>{resource.summary}</p>
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<main className="dl-page" aria-label={resource.label}>
<div className="section-inner">
<Link to="/resources" className="dl-back">← Back to Resources</Link>
<article className="dl-card">
{resource.imageUrl && (
<div className="dl-art">
<img src={resource.imageUrl} alt={resource.label} />
</div>
)}
<div className="dl-body">
<p className="eyebrow">Free download</p>
<h1>{resource.label}</h1>
{resource.summary && <p className="dl-summary">{resource.summary}</p>}
{resource.tags.length > 0 && (
<div className="dl-tags">
{resource.tags.map(tag => <span key={tag} className="dl-tag">{tag}</span>)}
</div>
)}
{resource.imageUrl && (
<div className="download-detail-art">
<img src={resource.imageUrl} alt={resource.label} className="guide-cover-img" />
</div>
)}
<div className="dl-form">
{/* The title is already on the page, so the button stays short */}
{resource.isPrimaryGuide
? <StudyDownloadForm buttonText="Download now" />
: <ResourceDownloadForm resourceId={resource.resourceId} buttonText="Download now" />}
</div>
{resource.tags.length > 0 && (
<div className="resource-link-tags">{resource.tags.join(', ')}</div>
)}
<div className="download-detail-form-wrap">
{resource.isPrimaryGuide
? <StudyDownloadForm buttonText={resource.buttonText} />
: <ResourceDownloadForm resourceId={resource.resourceId} buttonText={resource.buttonText} />}
{(resource.amazonUrl || resource.appleBooksUrl) && (
<div className="dl-actions">
<span>Or get it from:</span>
{resource.amazonUrl && (
<a href={resource.amazonUrl} target="_blank" rel="noreferrer" className="btn-secondary">
{resource.amazonLabel || 'Get it on Amazon'}
</a>
)}
{resource.appleBooksUrl && (
<a href={resource.appleBooksUrl} target="_blank" rel="noreferrer" className="btn-secondary">
{resource.appleBooksLabel || 'Get it on Apple Books'}
</a>
)}
</div>
)}
</div>
</article>
</div>
<div className="download-detail-actions">
{resource.amazonUrl && (
<a href={resource.amazonUrl} target="_blank" rel="noreferrer" className="btn-secondary">
{resource.amazonLabel || 'Get it on Amazon'}
</a>
)}
{resource.appleBooksUrl && (
<a href={resource.appleBooksUrl} target="_blank" rel="noreferrer" className="btn-secondary">
{resource.appleBooksLabel || 'Get it on Apple Books'}
</a>
)}
<Link to="/resources" className="btn-secondary">Back to Resources</Link>
</div>
</div>
</main>
</main>
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
@@ -2341,107 +2230,6 @@ function NuggetsHostDetailPage({ content }: { content: SiteContent }) {
)
}
function BooksAndResourcesSection({ content }: { content: SiteContent }) {
const books = (content.customLinks ?? []).filter(link => link.placement === 'books')
const recommended = (content.customLinks ?? []).filter(link => link.placement === 'recommended')
const [activeTab, setActiveTab] = useState<'books' | 'recommended'>('books')
const hasBooks = books.length > 0
const hasRecommended = recommended.length > 0
const showTabs = hasBooks && hasRecommended
if (!hasBooks && !hasRecommended) return <TruthForLifeWidget type="resourcead" />
const tab = showTabs ? activeTab : (hasBooks ? 'books' : 'recommended')
return (
<section className="section-resources section-books-resources" aria-label="Books and recommended resources">
<div className="section-inner">
{showTabs ? (
<div className="books-resources-tabs">
<button
type="button"
className={`books-resources-tab${tab === 'books' ? ' books-resources-tab--active' : ''}`}
onClick={() => setActiveTab('books')}
>
Reading List
</button>
<button
type="button"
className={`books-resources-tab${tab === 'recommended' ? ' books-resources-tab--active' : ''}`}
onClick={() => setActiveTab('recommended')}
>
Recommended Resources
</button>
</div>
) : (
<div className="download-library-head">
<p className="eyebrow">{tab === 'books' ? 'Reading List' : 'Recommended'}</p>
<h2 className="section-heading">{tab === 'books' ? 'Books worth your time.' : 'Resources worth your time.'}</h2>
</div>
)}
{tab === 'books' && (
<>
{showTabs && (
<div className="download-library-head" style={{ marginBottom: '1.5rem' }}>
<h2 className="section-heading">Books worth your time.</h2>
</div>
)}
<div className="books-list">
{books.map(book => (
<div key={book.id} className="book-card">
{book.imageUrl && (
<img src={book.imageUrl} alt={book.label} className="book-card-cover" loading="lazy" />
)}
<div className="book-card-meta">
<span className="book-card-title">{book.label}</span>
{book.description && <p className="book-card-description">{book.description}</p>}
{(book.amazonUrl || book.url) && (
<a href={book.amazonUrl || book.url} target="_blank" rel="noopener noreferrer" className="btn-primary book-card-btn">
{book.amazonUrl ? (book.amazonLabel || 'View on Amazon') : (book.amazonLabel || 'Learn More')}
</a>
)}
{book.appleBooksUrl && (
<a href={book.appleBooksUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary book-card-btn">
{book.appleBooksLabel || 'Get it on Apple Books'}
</a>
)}
</div>
</div>
))}
</div>
</>
)}
{tab === 'recommended' && (
<>
{showTabs && (
<div className="download-library-head" style={{ marginBottom: '1.5rem' }}>
<h2 className="section-heading">Recommended Resources</h2>
</div>
)}
<div className="recommended-resources-list">
{recommended.map(item => (
<a key={item.id} href={item.url} target="_blank" rel="noopener noreferrer" className="recommended-resource-card">
{item.imageUrl && (
<img src={item.imageUrl} alt={item.label} className="recommended-resource-image" loading="lazy" />
)}
<div className="recommended-resource-meta">
<span className="recommended-resource-title">{item.label}</span>
{item.description && <p className="recommended-resource-description">{item.description}</p>}
<span className="recommended-resource-action">Learn more →</span>
</div>
</a>
))}
</div>
</>
)}
</div>
</section>
)
}
function ResourcesPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
@@ -2453,9 +2241,8 @@ function ResourcesPage({ content }: { content: SiteContent }) {
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Resources" title="Study Guides & Resources" desc="Free guides, worksheets, and curated resources to help you go deeper in Scripture." />
<DownloadLibrarySection content={content} />
<ResourcesHub content={content} downloadPath={id => `/downloads/${buildCustomDownloadPageId(id)}`} fallback={<TruthForLifeWidget type="resourcead" />} />
<CustomBlocksSection content={content} page="downloads" />
<BooksAndResourcesSection content={content} />
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
+211
View File
@@ -0,0 +1,211 @@
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import type { SiteContent } from '../content'
type TabKey = 'downloads' | 'books' | 'recommended'
interface Props {
content: SiteContent
downloadPath: (resourceId: string) => string
// Shown when there are no downloads, books or recommended resources at all
fallback: ReactNode
}
// A description that stays short in the grid; long ones get a Read more toggle
function ClampedText({ text, className, lines = 3, threshold = 150 }: { text: string; className: string; lines?: number; threshold?: number }) {
const [open, setOpen] = useState(false)
const long = text.length > threshold
return (
<>
<p
className={`${className}${long && !open ? ' res-clamped' : ''}`}
style={long && !open ? { WebkitLineClamp: lines } : undefined}
>
{text}
</p>
{long && (
<button type="button" className="res-more" onClick={() => setOpen(o => !o)} aria-expanded={open}>
{open ? 'Show less' : 'Read more'}
</button>
)}
</>
)
}
export function ResourcesHub({ content, downloadPath, fallback }: Props) {
const links = content.customLinks ?? []
const resources = links.filter(l => l.placement === 'resources')
const books = links.filter(l => l.placement === 'books')
const recommended = links.filter(l => l.placement === 'recommended')
const featured: { key: string; to: string; title: string; description: string; image: string; alt: string }[] = []
if (content.studyGuideTitle) {
featured.push({
key: 'primary',
to: '/downloads/primary-guide',
title: content.studyGuideTitle,
description: content.studyGuideDescription ?? '',
image: content.seriesImageUrl || '/images/titus-cover.png',
alt: content.seriesTitle,
})
}
if (content.secondSeries?.guide?.title) {
featured.push({
key: 'second',
to: '/downloads/second-guide',
title: content.secondSeries.guide.title,
description: content.secondSeries.guide.description ?? '',
image: content.secondSeries.imageUrl ?? '',
alt: content.secondSeries.title,
})
}
const counts: Record<TabKey, number> = {
downloads: featured.length + resources.length,
books: books.length,
recommended: recommended.length,
}
const available = (Object.keys(counts) as TabKey[]).filter(k => counts[k] > 0)
const fromHash = (): TabKey | null => {
const h = typeof window !== 'undefined' ? window.location.hash.replace('#', '') : ''
return h === 'downloads' || h === 'books' || h === 'recommended' ? h : null
}
const [picked, setPicked] = useState<TabKey | null>(fromHash)
const [activeTag, setActiveTag] = useState<string | null>(null)
// Links like /resources#books, and the browser's back/forward buttons, switch the tab too
useEffect(() => {
const onHash = () => setPicked(fromHash())
window.addEventListener('hashchange', onHash)
return () => window.removeEventListener('hashchange', onHash)
}, [])
if (available.length === 0) return <>{fallback}</>
const tab: TabKey = picked && counts[picked] > 0 ? picked : available[0]
const choose = (k: TabKey) => {
setPicked(k)
try { window.history.replaceState(null, '', `#${k}`) } catch { /* ignore */ }
}
const tags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean)
const shownResources = resources.filter(r => !activeTag || (r.tags ?? []).includes(activeTag))
const labels: Record<TabKey, string> = { downloads: 'Downloads', books: 'Reading List', recommended: 'Recommended' }
return (
<section className="res-hub" aria-label="Resources">
<div className="section-inner">
{available.length > 1 && (
<div className="res-tabs" role="tablist" aria-label="Resource types">
{available.map(k => (
<button
key={k}
type="button"
role="tab"
id={`res-tab-${k}`}
aria-selected={tab === k}
aria-controls="res-panel"
className={`res-tab${tab === k ? ' res-tab--on' : ''}`}
onClick={() => choose(k)}
>
{labels[k]} <span>{counts[k]}</span>
</button>
))}
</div>
)}
<div id="res-panel" role="tabpanel" aria-labelledby={`res-tab-${tab}`}>
{tab === 'downloads' && (
<>
{featured.length > 0 && (
<div className="res-featured">
{featured.map(f => (
<Link key={f.key} to={f.to} className="res-feature">
{f.image && <img src={f.image} alt={f.alt} className="res-feature-img" loading="lazy" />}
<div className="res-feature-body">
<span className="res-badge">Featured</span>
<span className="res-title">{f.title}</span>
{f.description && <p className="res-desc res-clamped" style={{ WebkitLineClamp: 2 }}>{f.description}</p>}
<span className="res-action">Download free guide →</span>
</div>
</Link>
))}
</div>
)}
{tags.length > 0 && (
<div className="res-tags" role="group" aria-label="Filter by topic">
<button type="button" className={`res-chip${activeTag === null ? ' res-chip--on' : ''}`} onClick={() => setActiveTag(null)}>All topics</button>
{tags.map(t => (
<button key={t} type="button" className={`res-chip${activeTag === t ? ' res-chip--on' : ''}`} onClick={() => setActiveTag(prev => (prev === t ? null : t))}>{t}</button>
))}
</div>
)}
{shownResources.length > 0 ? (
<div className="res-grid">
{shownResources.map(r => (
<Link key={r.id} to={downloadPath(r.id)} className="res-card">
{r.imageUrl && <img src={r.imageUrl} alt={r.label} className="res-card-img" loading="lazy" />}
<div className="res-card-body">
<span className="res-title">{r.label}</span>
{r.description && <p className="res-desc res-clamped" style={{ WebkitLineClamp: 3 }}>{r.description}</p>}
{(r.tags ?? []).length > 0 && <span className="res-card-tags">{(r.tags ?? []).join(' · ')}</span>}
<span className="res-action">Open download →</span>
</div>
</Link>
))}
</div>
) : (
resources.length > 0 && <p className="res-empty">No downloads tagged &quot;{activeTag}&quot;.</p>
)}
</>
)}
{tab === 'books' && (
<div className="res-grid">
{books.map(book => (
<div key={book.id} className="res-book">
{book.imageUrl && <img src={book.imageUrl} alt={book.label} className="res-book-cover" loading="lazy" />}
<div className="res-book-body">
<span className="res-title">{book.label}</span>
{book.description && <ClampedText text={book.description} className="res-desc" lines={3} />}
<div className="res-book-actions">
{(book.amazonUrl || book.url) && (
<a href={book.amazonUrl || book.url} target="_blank" rel="noopener noreferrer" className="btn-primary res-btn">
{book.amazonUrl ? (book.amazonLabel || 'View on Amazon') : (book.amazonLabel || 'Learn More')}
</a>
)}
{book.appleBooksUrl && (
<a href={book.appleBooksUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary res-btn">
{book.appleBooksLabel || 'Apple Books'}
</a>
)}
</div>
</div>
</div>
))}
</div>
)}
{tab === 'recommended' && (
<div className="res-grid">
{recommended.map(item => (
<a key={item.id} href={item.url} target="_blank" rel="noopener noreferrer" className="res-card">
{item.imageUrl && <img src={item.imageUrl} alt={item.label} className="res-card-img" loading="lazy" />}
<div className="res-card-body">
<span className="res-title">{item.label}</span>
{item.description && <p className="res-desc res-clamped" style={{ WebkitLineClamp: 3 }}>{item.description}</p>}
<span className="res-action">Visit →</span>
</div>
</a>
))}
</div>
)}
</div>
</div>
</section>
)
}