Redesign resources page with PageBanner, featured download, and consistent page headers; v1.1.40
Publish Container / docker (push) Has been cancelled

- Replace hero study guide form with PageBanner + featured download card in library
- Add PageBanner component used consistently across all interior pages (Downloads, Episodes, About, Contact, Q&A, Finished Books)
- Remove duplicate headings/eyebrows that conflicted with banner titles on About, Contact, Q&A, Finished Books pages
- Compact download card sizing (padding, border-radius, image size)
- Add Books & Resources tabbed section with fallback to TruthForLife widget
- Fix server sanitizer to pass through 'resources' placement items without URL
- Add featured-badge and featured card styling for primary guide

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-08-26 11:03:43 -04:00
parent 0fdf7f0f50
commit 0512d40fc6
5 changed files with 307 additions and 181 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "1.1.39",
"version": "1.1.40",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -186,7 +186,7 @@ function sanitizeCustomLinks(value) {
placement,
}
})
.filter(item => item.label && (item.url || item.amazonUrl || item.placement === 'books' || item.placement === 'recommended'))
.filter(item => item.label && (item.url || item.amazonUrl || item.placement === 'books' || item.placement === 'recommended' || item.placement === 'resources'))
}
function sanitizeCustomBlocks(value) {
+102 -12
View File
@@ -4119,11 +4119,11 @@
.resource-download-card {
background: #111;
border: 1px solid rgba(201, 168, 76, 0.18);
border-radius: 18px;
padding: 1.5rem;
border-radius: 14px;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
gap: 0.75rem;
text-decoration: none;
color: inherit;
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
@@ -4172,9 +4172,9 @@
}
.resource-link-image {
width: 80px;
height: 80px;
border-radius: 14px;
width: 64px;
height: 64px;
border-radius: 10px;
object-fit: cover;
border: 1px solid rgba(201, 168, 76, 0.2);
flex-shrink: 0;
@@ -4480,13 +4480,10 @@
/* ── Recommended Resources section ── */
.recommended-resources-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem;
margin-top: 1rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.recommended-resource-card {
@@ -4541,6 +4538,99 @@
font-weight: 600;
}
/* ── Interior page banner ── */
.section-page-banner {
padding: 3.5rem 0 2rem;
border-bottom: 1px solid rgba(201, 168, 76, 0.12);
}
.page-banner-inner {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.page-banner-title {
font-family: var(--brand-font-heading);
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 700;
color: var(--brand-warm-white);
margin: 0.15rem 0 0;
line-height: 1.15;
}
.page-banner-desc {
font-family: var(--brand-font-body);
font-size: 1.05rem;
font-weight: 300;
color: var(--brand-muted);
max-width: 52ch;
margin: 0.25rem 0 0;
line-height: 1.6;
}
/* ── 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;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--brand-gold);
background: rgba(201, 168, 76, 0.12);
border: 1px solid rgba(201, 168, 76, 0.3);
border-radius: 4px;
padding: 0.15rem 0.5rem;
margin-bottom: 0.25rem;
align-self: flex-start;
}
/* ── 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 {
background: #0d0d0d;
+203 -163
View File
@@ -267,11 +267,27 @@ interface DownloadPageResource {
resourceId: string
amazonUrl?: string
amazonLabel?: string
isPrimaryGuide?: boolean
}
function resolveDownloadPageResource(content: SiteContent, pageId: string | undefined): DownloadPageResource | null {
if (!pageId) return null
if (pageId === 'primary-guide') {
return {
id: 'primary-guide',
label: content.studyGuideTitle || 'Companion Study Guide',
imageUrl: content.seriesImageUrl || '/images/titus-cover.png',
summary: content.studyGuideDescription || 'Complete the short form below and your download will start right away.',
tags: [],
buttonText: `Download ${content.studyGuideTitle || 'Guide'}`,
resourceId: 'primary-guide',
amazonUrl: content.studyGuideUrl,
amazonLabel: content.studyGuideAmazonButtonLabel,
isPrimaryGuide: true,
}
}
if (pageId.startsWith('custom--')) {
const customId = pageId.slice('custom--'.length)
const resource = (content.customLinks ?? []).find(link => link.id === customId && link.placement === 'resources')
@@ -543,7 +559,6 @@ function AboutSection({ content }: { content: SiteContent }) {
</div>
</div>
<div className="about-text">
<p className="eyebrow">{content.aboutShowEyebrow || 'About the Show'}</p>
<h2>{content.aboutShowHeading}</h2>
<p>{content.aboutShowP1}</p>
<p>{content.aboutShowP2}</p>
@@ -559,42 +574,6 @@ function AboutSection({ content }: { content: SiteContent }) {
)
}
function StudyGuideSection({ content }: { content: SiteContent }) {
return (
<section className="section-guide" aria-label="Companion study guide">
<div className="section-inner guide-inner">
<div className="guide-cover-art">
<img
src={content.seriesImageUrl || '/images/titus-cover.png'}
alt={content.seriesTitle}
className="guide-cover-img"
/>
</div>
<div className="guide-text">
<p className="eyebrow">Downloads</p>
<h1 className="guide-page-title">Study Guides and Downloads</h1>
<p className="guide-page-intro">Start with the primary guide below, then explore the rest of the download library further down the page.</p>
<p className="eyebrow">Free Download</p>
<h2>{content.studyGuideTitle}</h2>
<p>{content.studyGuideDescription}</p>
<StudyDownloadForm buttonText={`Download ${content.studyGuideTitle || 'Guide'}`} />
{content.studyGuideUrl && (
<div className="guide-actions">
<a
href={content.studyGuideUrl}
target="_blank"
rel="noreferrer"
className="btn-secondary"
>
{content.studyGuideAmazonButtonLabel || 'Get it on Amazon'}
</a>
</div>
)}
</div>
</div>
</section>
)
}
function ContactSection({ content }: { content: SiteContent }) {
return (
@@ -602,9 +581,6 @@ function ContactSection({ content }: { content: SiteContent }) {
<div className="section-inner contact-inner">
<div className="contact-copy">
<div className="contact-card">
<p className="eyebrow">{content.contactEyebrow || 'Get in Touch'}</p>
<h2>{content.contactHeading || 'Contact Nate'}</h2>
<div className="contact-profile">
<img
src={content.contactPhotoUrl || '/images/nate-contact-photo.png'}
@@ -716,83 +692,116 @@ function PodcastHighlightsSection({ content }: { content: SiteContent }) {
)
}
function PageBanner({ eyebrow, title, desc }: { eyebrow: string; title: string; desc?: string }) {
return (
<section className="section-page-banner" aria-label={title}>
<div className="section-inner page-banner-inner">
<p className="eyebrow">{eyebrow}</p>
<h1 className="page-banner-title">{title}</h1>
{desc && <p className="page-banner-desc">{desc}</p>}
</div>
</section>
)
}
function DownloadLibrarySection({ content }: { content: SiteContent }) {
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
const [activeTag, setActiveTag] = useState<string | null>(null)
if (resources.length === 0) return 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 && resources.length === 0) return null
return (
<section className="section-resources section-download-library" aria-label="Download library">
<div className="section-inner">
<div className="download-library-head">
<p className="eyebrow">Download Library</p>
<h2 className="section-heading">Guides, worksheets, and study downloads.</h2>
</div>
{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>
{hasPrimaryGuide && (
<Link to="/downloads/primary-guide" className="resource-download-card resource-download-card--link resource-download-card--featured">
<div className="resource-download-header">
{(content.seriesImageUrl || '/images/titus-cover.png') && (
<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>
</div>
</Link>
)}
{filteredResources.length > 0 ? (
<div className="download-library-group">
<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>
{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" />
)}
<span className="resource-link-action">Open download page </span>
<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>
</div>
</Link>
))}
</div>
</div>
) : (
<p className="download-empty-state">No downloads tagged "{activeTag}".</p>
</Link>
))}
</div>
) : (
<p className="download-empty-state">No downloads tagged "{activeTag}".</p>
)}
</>
)}
</div>
</section>
@@ -834,7 +843,9 @@ function DownloadDetailPage({ content }: { content: SiteContent }) {
)}
<div className="download-detail-form-wrap">
<ResourceDownloadForm resourceId={resource.resourceId} buttonText={resource.buttonText} />
{resource.isPrimaryGuide
? <StudyDownloadForm buttonText={resource.buttonText} />
: <ResourceDownloadForm resourceId={resource.resourceId} buttonText={resource.buttonText} />}
</div>
<div className="download-detail-actions">
@@ -1466,16 +1477,9 @@ function FinishedBooksPage({ content }: { content: SiteContent }) {
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Studies" title="Finished Books" desc="Every series we've completed — with a full ordered episode playlist for each book of the Bible." />
<section className="section-finished-books">
<div className="section-inner">
<div className="finished-books-header">
<h1 className="section-heading">
<span className="ornament"></span> Finished Books <span className="ornament"></span>
</h1>
<p className="finished-books-intro">
Every series we've completed with a full ordered episode playlist for each book of the Bible.
</p>
</div>
{books.length === 0 ? (
<div className="finished-books-empty-state">
<div className="finished-books-empty-icon">📖</div>
@@ -1715,6 +1719,7 @@ function EpisodesPage({ content }: { content: SiteContent }) {
<div className="site">
<SiteHeader content={content} />
<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">
@@ -1738,64 +1743,97 @@ function EpisodesPage({ content }: { content: SiteContent }) {
)
}
function RecommendedResourcesSection({ content }: { content: SiteContent }) {
const recommended = (content.customLinks ?? []).filter(link => link.placement === 'recommended')
if (recommended.length === 0) return <TruthForLifeWidget type="resourcead" />
return (
<section className="section-tfl" aria-label="Recommended resources">
<div className="section-inner">
<div className="tfl-label-row">
<h2 className="section-heading" style={{ marginBottom: '1rem' }}>
<span className="ornament"></span> Recommended Resources <span className="ornament"></span>
</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 BooksSectionSection({ content }: { content: SiteContent }) {
function BooksAndResourcesSection({ content }: { content: SiteContent }) {
const books = (content.customLinks ?? []).filter(link => link.placement === 'books')
if (books.length === 0) return null
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" aria-label="Recommended books">
<section className="section-resources section-books-resources" aria-label="Books and recommended resources">
<div className="section-inner">
<div className="download-library-head">
<p className="eyebrow">Reading List</p>
<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>
)}
{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>
)}
</div>
</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>
)
@@ -1811,11 +1849,10 @@ function ResourcesPage({ content }: { content: SiteContent }) {
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<StudyGuideSection content={content} />
<PageBanner eyebrow="Downloads" title="Study Guides & Resources" desc="Free guides, worksheets, and curated resources to help you go deeper in Scripture." />
<DownloadLibrarySection content={content} />
<BooksSectionSection content={content} />
<CustomBlocksSection content={content} page="downloads" />
<RecommendedResourcesSection content={content} />
<BooksAndResourcesSection content={content} />
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
@@ -1832,6 +1869,7 @@ function AboutPage({ content }: { content: SiteContent }) {
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="About" title="About the Show" />
<AboutSection content={content} />
<TestimonialsSection testimonials={content.testimonials ?? []} />
<CustomBlocksSection content={content} page="about" />
@@ -1846,6 +1884,7 @@ function ContactPage({ content }: { content: SiteContent }) {
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Contact" title={content.contactHeading || 'Get in Touch'} />
<ContactSection content={content} />
<CustomBlocksSection content={content} page="contact" />
<SiteFooter content={content} />
@@ -2367,6 +2406,7 @@ function QuestionsPage({ content }: { content: SiteContent }) {
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Q&A" title="Questions & Answers" desc="Real questions answered from Scripture — browse by topic or submit your own." />
<QASection />
<CustomBlocksSection content={content} page="questions" />
<SiteFooter content={content} />
-4
View File
@@ -783,10 +783,6 @@ export default function QASection() {
return (
<section id="qa" className="section-qa" aria-label="Questions and answers">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament"></span> Questions & Answers <span className="ornament"></span>
</h2>
<div className="qa-submit-cta">
<p className="qa-submit-cta-text">Have a question about Scripture or the podcast?</p>
<Link to="/contact" className="btn-primary">Submit a Question </Link>