Add cross-site improvements across three phases
Phase 1 — Quick wins: - Image lazy-loading on series/resource cards - Newsletter signup added to Episodes page (before highlights) - Per-route meta tags via usePageMeta hook (title, og:title, og:description) - Breadcrumbs on study index, section, and notes pages - SVG completion checkmark badges on study section list - Analytics time-range filter (7d / 30d / 90d) in admin panel Phase 2 — Medium features: - Related episodes on archived series detail pages - Resource library two-tier filter (type + tag chips) - Global search (Fuse.js) moved below sticky header as full-width bar - Q&A anonymous upvoting with localStorage dedup + admin pin/unpin - Study enrollment funnel tracking (firstVisitAt, firstCompletionAt) with funnel chart in analytics Phase 3 — Larger features: - Study section comments (auto-approve for enrolled users, admin moderation panel) - Study completion certificate (canvas render, PNG download, shareable public URL) - Episode script full-text search (mammoth docx extraction, server-side search, admin upload UI) - Reflection questions renamed from Discussion Questions; quiz answers can be shared to section discussion - Public certificate route at /certificate/:token with og meta tags - Comment moderation panel added to admin under Manage > Study Comments Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+215
-24
@@ -8,6 +8,9 @@ import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySect
|
||||
import { SpotifyIcon } from './icons'
|
||||
import type { SiteContent, ArchivedSeries, StudyProgram } from './content'
|
||||
import { DEFAULTS } from './content'
|
||||
import { usePageMeta } from './hooks/usePageMeta'
|
||||
import { useGlobalSearch } from './hooks/useGlobalSearch'
|
||||
import { GlobalSearch } from './components/GlobalSearch'
|
||||
import './App.css'
|
||||
|
||||
const SPOTIFY_EMBED_URL =
|
||||
@@ -459,6 +462,17 @@ function formatPubDate(raw: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function SiteSearchBar({ content }: { content: SiteContent }) {
|
||||
const { query, setQuery, results } = useGlobalSearch(content)
|
||||
return (
|
||||
<div className="site-search-bar">
|
||||
<div className="site-search-bar-inner">
|
||||
<GlobalSearch query={query} setQuery={setQuery} results={results} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SiteHeader({ content }: { content: SiteContent }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
|
||||
@@ -783,39 +797,72 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
|
||||
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
|
||||
const archivedWithResources = (content.archivedSeries ?? []).filter(series => (series.resourceLinks ?? []).length > 0)
|
||||
const [activeTag, setActiveTag] = useState<string | null>(null)
|
||||
const [activeType, setActiveType] = useState<'all' | 'resource' | 'series'>('all')
|
||||
|
||||
if (resources.length === 0 && archivedWithResources.length === 0) return null
|
||||
|
||||
const allTags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean)
|
||||
const filteredResources = activeTag ? resources.filter(r => (r.tags ?? []).includes(activeTag)) : resources
|
||||
|
||||
// Filter resources
|
||||
const filteredResources = resources.filter(r => {
|
||||
const tagOk = !activeTag || (r.tags ?? []).includes(activeTag)
|
||||
const typeOk = activeType === 'all' || activeType === 'resource'
|
||||
return tagOk && typeOk
|
||||
})
|
||||
|
||||
// Filter archived series
|
||||
const filteredArchived = archivedWithResources.filter(() => {
|
||||
return activeType === 'all' || activeType === 'series'
|
||||
})
|
||||
|
||||
const hasTypeFilter = resources.length > 0 && archivedWithResources.length > 0
|
||||
|
||||
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">More guides, worksheets, and past study downloads.</h2>
|
||||
<h2 className="section-heading">Guides, worksheets, and past study downloads.</h2>
|
||||
</div>
|
||||
|
||||
{allTags.length > 0 && (
|
||||
<div className="download-tag-filter">
|
||||
<button
|
||||
className={`download-tag-chip${activeTag === null ? ' download-tag-chip--active' : ''}`}
|
||||
onClick={() => setActiveTag(null)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{allTags.map(tag => (
|
||||
{/* Type + tag filter bar */}
|
||||
<div className="download-filter-bar">
|
||||
{hasTypeFilter && (
|
||||
<div className="download-tag-filter" style={{ marginBottom: allTags.length > 0 ? '0.5rem' : 0 }}>
|
||||
{(['all', 'resource', 'series'] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
className={`download-tag-chip${activeType === t ? ' download-tag-chip--active' : ''}`}
|
||||
onClick={() => setActiveType(t)}
|
||||
>
|
||||
{t === 'all' ? 'All types' : t === 'resource' ? 'Study guides' : 'Past series'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{allTags.length > 0 && (activeType === 'all' || activeType === 'resource') && (
|
||||
<div className="download-tag-filter">
|
||||
<button
|
||||
key={tag}
|
||||
className={`download-tag-chip${activeTag === tag ? ' download-tag-chip--active' : ''}`}
|
||||
onClick={() => setActiveTag(prev => prev === tag ? null : tag)}
|
||||
type="button"
|
||||
className={`download-tag-chip${activeTag === null ? ' download-tag-chip--active' : ''}`}
|
||||
onClick={() => setActiveTag(null)}
|
||||
>
|
||||
{tag}
|
||||
All topics
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filteredResources.length > 0 && (
|
||||
<div className="download-library-group">
|
||||
@@ -824,7 +871,7 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
|
||||
<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" />
|
||||
<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>
|
||||
@@ -841,17 +888,17 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTag !== null && filteredResources.length === 0 && (
|
||||
{activeTag !== null && filteredResources.length === 0 && activeType !== 'series' && (
|
||||
<p className="download-empty-state">No downloads tagged "{activeTag}".</p>
|
||||
)}
|
||||
|
||||
{archivedWithResources.length > 0 && (
|
||||
{filteredArchived.length > 0 && (
|
||||
<div className="download-library-group">
|
||||
<div className="download-library-group-head">
|
||||
<h3>Previous Studies</h3>
|
||||
<p>Downloads from earlier series that you still want available.</p>
|
||||
<p>Downloads from earlier series.</p>
|
||||
</div>
|
||||
{archivedWithResources.map(series => (
|
||||
{filteredArchived.map(series => (
|
||||
<div key={series.id} className="archive-series-resources">
|
||||
<div className="download-library-series-head">
|
||||
<h4>{series.title || 'Archived Study'}</h4>
|
||||
@@ -862,7 +909,7 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
|
||||
<Link key={link.id} to={`/downloads/${buildArchivedDownloadPageId(series.id, link.id)}`} className="resource-download-card resource-download-card--link">
|
||||
<div className="resource-download-header">
|
||||
{series.imageUrl && (
|
||||
<img src={series.imageUrl} alt={series.title || 'Archived study'} className="resource-link-image" />
|
||||
<img src={series.imageUrl} alt={series.title || 'Archived study'} className="resource-link-image" loading="lazy" />
|
||||
)}
|
||||
<div className="resource-download-meta">
|
||||
<span className="resource-link-label">{link.label || series.title || 'Download Resource'}</span>
|
||||
@@ -882,10 +929,43 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
|
||||
)
|
||||
}
|
||||
|
||||
function useEpisodesForSeries(seriesId: string | null, archivedSeries: ArchivedSeries[]) {
|
||||
const [episodes, setEpisodes] = useState<Episode[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!seriesId) return
|
||||
const series = archivedSeries.find(s => s.id === seriesId)
|
||||
if (!series?.episodeRange) return
|
||||
|
||||
setLoading(true)
|
||||
fetch('/api/episodes/all')
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then((data: { episodes?: Episode[] }) => {
|
||||
const { from, to } = series.episodeRange!
|
||||
const filtered = (data.episodes ?? []).filter(ep => {
|
||||
const num = parseInt(ep.episode, 10)
|
||||
return !isNaN(num) && num >= from && num <= to
|
||||
})
|
||||
setEpisodes(filtered)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [seriesId])
|
||||
|
||||
return { episodes, loading }
|
||||
}
|
||||
|
||||
function DownloadDetailPage({ content }: { content: SiteContent }) {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const resource = resolveDownloadPageResource(content, id)
|
||||
|
||||
// Resolve related series for archived resources
|
||||
const archivedSeriesId = id?.startsWith('archived--') ? id.split('--')[1] : null
|
||||
const relatedSeries = archivedSeriesId ? (content.archivedSeries ?? []).find(s => s.id === archivedSeriesId) : null
|
||||
const { episodes: relatedEpisodes, loading: epsLoading } = useEpisodesForSeries(archivedSeriesId, content.archivedSeries ?? [])
|
||||
const spotifyUrl = content.platformSpotifyUrl || '/episodes'
|
||||
|
||||
if (!resource) {
|
||||
return (
|
||||
<main className="thanks-page" aria-label="Download not found">
|
||||
@@ -928,6 +1008,40 @@ function DownloadDetailPage({ content }: { content: SiteContent }) {
|
||||
)}
|
||||
<Link to="/resources" className="btn-secondary">Back to Downloads</Link>
|
||||
</div>
|
||||
|
||||
{relatedSeries && (relatedEpisodes.length > 0 || epsLoading) && (
|
||||
<div className="download-related-episodes">
|
||||
<h2 className="download-related-heading">
|
||||
Episodes from {relatedSeries.title}
|
||||
</h2>
|
||||
{epsLoading ? (
|
||||
<p style={{ color: '#7a7060', fontSize: '0.9rem' }}>Loading episodes…</p>
|
||||
) : (
|
||||
<div className="download-related-list">
|
||||
{relatedEpisodes.slice(0, 10).map((ep, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={ep.link || spotifyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="download-related-ep"
|
||||
>
|
||||
<div className="download-related-ep-meta">
|
||||
{ep.episode && <span className="episode-number">Ep. {ep.episode}</span>}
|
||||
{ep.pubDate && <span className="episode-date">{formatPubDate(ep.pubDate)}</span>}
|
||||
</div>
|
||||
<p className="download-related-ep-title">{ep.title}</p>
|
||||
</a>
|
||||
))}
|
||||
{relatedEpisodes.length > 10 && (
|
||||
<Link to="/episodes" className="btn-secondary" style={{ marginTop: '0.5rem', display: 'inline-block' }}>
|
||||
View all {relatedEpisodes.length} episodes →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
@@ -1175,6 +1289,7 @@ function LandingPage({ content }: { content: SiteContent }) {
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<CustomBlocksSection content={content} page="homepage" />
|
||||
|
||||
{/* ── HERO ── */}
|
||||
@@ -1417,6 +1532,7 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<section className="section-player">
|
||||
<div className="section-inner" style={{ textAlign: 'center', padding: '4rem 1rem' }}>
|
||||
<h2>Episode not found</h2>
|
||||
@@ -1435,6 +1551,7 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<section className="section-episode-detail" aria-label={episode.title}>
|
||||
<div className="section-inner">
|
||||
<Link to="/episodes" className="episode-detail-back">← Back to Episodes</Link>
|
||||
@@ -1494,6 +1611,11 @@ function EpisodesPage({ content }: { content: SiteContent }) {
|
||||
const [allEpisodes, setAllEpisodes] = useState<Episode[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [failed, setFailed] = useState(false)
|
||||
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.',
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/episodes/all')
|
||||
@@ -1589,6 +1711,7 @@ function EpisodesPage({ content }: { content: SiteContent }) {
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
|
||||
<section className="section-player" aria-label="Current series episodes">
|
||||
<div className="section-inner">
|
||||
@@ -1669,6 +1792,7 @@ function EpisodesPage({ content }: { content: SiteContent }) {
|
||||
)
|
||||
})}
|
||||
|
||||
<HomepageNewsletterSection />
|
||||
<PodcastHighlightsSection content={content} />
|
||||
<CustomBlocksSection content={content} page="episodes" />
|
||||
<SiteFooter content={content} />
|
||||
@@ -1678,9 +1802,15 @@ function EpisodesPage({ content }: { content: SiteContent }) {
|
||||
}
|
||||
|
||||
function ResourcesPage({ content }: { content: SiteContent }) {
|
||||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||||
usePageMeta(
|
||||
`Resources & Downloads | ${siteTitle}`,
|
||||
'Study guides, sermon notes, and free resources from Verse by Verse with Nate.',
|
||||
)
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<StudyGuideSection content={content} />
|
||||
<DownloadLibrarySection content={content} />
|
||||
<CustomBlocksSection content={content} page="downloads" />
|
||||
@@ -1692,9 +1822,15 @@ function ResourcesPage({ content }: { content: SiteContent }) {
|
||||
}
|
||||
|
||||
function AboutPage({ content }: { content: SiteContent }) {
|
||||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||||
usePageMeta(
|
||||
`About | ${siteTitle}`,
|
||||
content.aboutNate || 'Learn about Verse by Verse with Nate — expository Bible teaching from Nate Emmert.',
|
||||
)
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<AboutSection content={content} />
|
||||
<CustomBlocksSection content={content} page="about" />
|
||||
<SiteFooter content={content} />
|
||||
@@ -1707,6 +1843,7 @@ function ContactPage({ content }: { content: SiteContent }) {
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<ContactSection content={content} />
|
||||
<CustomBlocksSection content={content} page="contact" />
|
||||
<SiteFooter content={content} />
|
||||
@@ -1925,6 +2062,58 @@ function LegalPage({ title, body }: { title: string; body: string[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function PublicCertificatePage() {
|
||||
const { token } = useParams<{ token: string }>()
|
||||
const [cert, setCert] = useState<{ studyTitle: string; displayName: string; issuedAt: string } | null>(null)
|
||||
const [status, setStatus] = useState<'loading' | 'not-found' | 'ready'>('loading')
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) { setStatus('not-found'); return }
|
||||
fetch(`/api/public/certificate/${encodeURIComponent(token)}`)
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then(data => { setCert(data); setStatus('ready') })
|
||||
.catch(() => setStatus('not-found'))
|
||||
}, [token])
|
||||
|
||||
usePageMeta(
|
||||
cert ? `Certificate of Completion – ${cert.studyTitle}` : 'Certificate',
|
||||
cert ? `${cert.displayName} completed ${cert.studyTitle}` : undefined,
|
||||
)
|
||||
|
||||
if (status === 'loading') {
|
||||
return <main className="thanks-page"><div className="thanks-card"><p>Loading certificate…</p></div></main>
|
||||
}
|
||||
if (status === 'not-found' || !cert) {
|
||||
return (
|
||||
<main className="thanks-page">
|
||||
<div className="thanks-card">
|
||||
<h1>Certificate Not Found</h1>
|
||||
<p>This certificate link is invalid or has been removed.</p>
|
||||
<Link to="/" className="btn-primary">Back to Site</Link>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="thanks-page public-cert-page" aria-label="Certificate of Completion">
|
||||
<div className="thanks-card public-cert-card">
|
||||
<p className="eyebrow">Verse by Verse with Nate</p>
|
||||
<h1 className="public-cert-title">Certificate of Completion</h1>
|
||||
<div className="public-cert-divider" />
|
||||
<p className="public-cert-label">This certifies that</p>
|
||||
<p className="public-cert-name">{cert.displayName}</p>
|
||||
<p className="public-cert-label">has successfully completed</p>
|
||||
<p className="public-cert-study">{cert.studyTitle}</p>
|
||||
<p className="public-cert-date">
|
||||
Awarded on {new Date(cert.issuedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</p>
|
||||
<Link to="/" className="btn-primary" style={{ marginTop: '2rem' }}>Visit Verse by Verse with Nate</Link>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: SiteContent) => void }) {
|
||||
const [status, setStatus] = useState<'checking' | 'authenticated' | 'unauthenticated' | 'misconfigured'>('checking')
|
||||
const [adminContent, setAdminContent] = useState<SiteContent>(content)
|
||||
@@ -2203,6 +2392,7 @@ function StudyRouteFrame({ content, child }: { content: SiteContent; child: Reac
|
||||
return (
|
||||
<>
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
{child}
|
||||
<SiteFooter content={content} />
|
||||
</>
|
||||
@@ -2303,6 +2493,7 @@ export default function App() {
|
||||
<Route path="/thanks" element={<ThankYouPage />} />
|
||||
<Route path="/subscribe" element={<SubscribePage />} />
|
||||
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
|
||||
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
|
||||
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
|
||||
<Route path="/preview" element={<PreviewPage />} />
|
||||
<Route
|
||||
|
||||
Reference in New Issue
Block a user