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:
nmemmert
2026-06-09 09:10:29 -04:00
parent 9ad24df626
commit c4645e3475
24 changed files with 2991 additions and 76 deletions
+111 -30
View File
@@ -2,6 +2,10 @@ import { useEffect, useMemo, useState, useCallback } from 'react'
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
import type { SiteContent, StudyProgram, StudySection } from './content'
import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './colossiansStudyData'
import { usePageMeta } from './hooks/usePageMeta'
import { Breadcrumbs } from './components/Breadcrumbs'
import { StudySectionComments } from './components/StudySectionComments'
import { StudyCertificate } from './components/StudyCertificate'
type Props = { content: SiteContent }
@@ -726,7 +730,8 @@ export function StudyLandingPage({ content }: Props) {
const studyMeta = getStudyBySlug(studies, study.slug)
const progress = study.totalLessons > 0 ? Math.round((study.completedLessons / study.totalLessons) * 100) : 0
return (
<article key={study.slug} className="study-module-row">
<div key={study.slug}>
<article className="study-module-row">
<div className="study-module-row-left">
<p className="study-module-lesson">{study.enrolled ? 'Enrolled' : 'Track'}</p>
<h3>{study.title}</h3>
@@ -751,6 +756,10 @@ export function StudyLandingPage({ content }: Props) {
</div>
</div>
</article>
{progress === 100 && (
<StudyCertificate studySlug={study.slug} />
)}
</div>
)
})}
</div>
@@ -1209,10 +1218,16 @@ export function ColossiansStudyIndexPage({ content }: Props) {
}
}
usePageMeta(
`${study.title} | Bible Study`,
study.description,
)
return (
<main className="study-index-page" aria-label={`${study.title} study`}>
<section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner">
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title }]} />
<p className="eyebrow">Online Bible Class</p>
<h1>{study.title}</h1>
<p className="study-course-hero-copy">{study.description}</p>
@@ -1303,7 +1318,13 @@ export function ColossiansStudyIndexPage({ content }: Props) {
{!coming && !enrolled && <span style={{ backgroundColor: '#ef5350', color: '#fff', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Enroll to open</span>}
{lockedByProgress && <span style={{ backgroundColor: '#5a5440', color: '#f0e9cc', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Complete previous lesson</span>}
{enrolled && !lockedByProgress && studyProgress.completedSectionIds.includes(section.id) && (
<span style={{ backgroundColor: '#2196f3', color: '#fff', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Completed</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', backgroundColor: '#1a3a2a', color: '#6fcf97', padding: '0.2rem 0.55rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
<circle cx="6.5" cy="6.5" r="6" stroke="#6fcf97" strokeWidth="1.2" />
<path d="M3.5 6.5l2 2 3.5-3.5" stroke="#6fcf97" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Completed
</span>
)}
</div>
<h3>{section.title}</h3>
@@ -1369,9 +1390,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
const isEnrolled = isEnrolledInStudy(auth, currentStudySlug)
const lessonCompleted = section?.id ? completedSectionIds.includes(section.id) : false
useEffect(() => {
document.title = section && study ? `${section.title} | ${study.title}` : 'Study'
}, [section, study])
usePageMeta(
section && study ? `${section.title} | ${study.title}` : 'Study',
section?.summary,
)
useEffect(() => {
let cancelled = false
@@ -1655,6 +1677,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
<section className="section-study-classroom" onContextMenu={handleLessonContentContextMenu}>
<div className="section-inner study-classroom-shell">
<div className="study-classroom-main">
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title, href: `/study/${study.slug}` }, { label: `Lesson ${lessonNumber}` }]} />
<Link to={`/study/${study.slug}`} className="study-detail-back">Back to {study.title}</Link>
<p className="study-lesson-label">Lesson {lessonNumber} of {sections.length}</p>
<h1>{section.title}</h1>
@@ -1695,14 +1718,17 @@ export function ColossiansStudySectionPage({ content }: Props) {
</article>
<article className="study-class-block">
<h2>Discussion Questions</h2>
<h2>Reflection Questions</h2>
<p className="study-detail-copy" style={{ marginBottom: '0.75rem', color: '#8a7f5a', fontSize: '0.9rem' }}>
Work through these on your own, then share your thoughts in the Discussion below.
</p>
<ol className="study-detail-list">
{section.studyQuestions.map((question, index) => (
<li key={index}>{question}</li>
))}
</ol>
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<Link to={`/study/${study.slug}/${section.id}/quiz`} className="btn-secondary">Discussion Questions</Link>
<Link to={`/study/${study.slug}/${section.id}/quiz`} className="btn-secondary">Write My Answers</Link>
<Link to={`/study/${study.slug}/community?sectionId=${encodeURIComponent(section.id)}`} className="btn-primary">Go to Community</Link>
<Link to={`/contact?source=community&study=${encodeURIComponent(study.title)}`} className="btn-secondary">Ask Nate</Link>
</div>
@@ -1782,6 +1808,14 @@ export function ColossiansStudySectionPage({ content }: Props) {
</article>
)
})()}
<article className="study-class-block">
<StudySectionComments
studySlug={currentStudySlug}
sectionId={section.id}
isEnrolled={isEnrolled}
/>
</article>
</div>
<aside className="study-classroom-sidebar" aria-label="Lesson tools">
@@ -1967,9 +2001,10 @@ export function ColossiansStudyNotesPage({ content }: Props) {
const [message, setMessage] = useState('')
const [activeNoteTab, setActiveNoteTab] = useState('')
useEffect(() => {
document.title = study ? `My Notes | ${study.title}` : 'My Study Notes'
}, [study])
usePageMeta(
study ? `My Notes | ${study.title}` : 'My Study Notes',
study ? `Your personal lesson notes for ${study.title}.` : undefined,
)
useEffect(() => {
let cancelled = false
@@ -2048,6 +2083,7 @@ export function ColossiansStudyNotesPage({ content }: Props) {
<main className="study-section-page" aria-label="My study notes">
<section className="section-study-classroom">
<div className="section-inner">
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title, href: `/study/${study.slug}` }, { label: 'My Notes' }]} />
<Link to={`/study/${study.slug}`} className="study-detail-back">Back to {study.title}</Link>
<p className="study-lesson-label">Student Workspace</p>
<h1>My Lesson Notes</h1>
@@ -2976,6 +3012,8 @@ export function StudyQuizPage({ content }: Props) {
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const [shareToDiscussion, setShareToDiscussion] = useState<Record<number, boolean>>({})
const [sharedIndexes, setSharedIndexes] = useState<Set<number>>(new Set())
useEffect(() => {
let cancelled = false
@@ -3029,7 +3067,37 @@ export function StudyQuizPage({ content }: Props) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers }),
})
setMessage('Answers saved.')
// Post any checked answers to the shared discussion
const toShare = Object.entries(shareToDiscussion)
.filter(([, checked]) => checked)
.map(([idx]) => Number(idx))
.filter(idx => !sharedIndexes.has(idx) && (answers[idx] ?? '').trim().length >= 2)
const newlyShared: number[] = []
for (const idx of toShare) {
const questionText = section.studyQuestions?.[idx] ? `**${section.studyQuestions[idx]}**\n\n` : ''
const text = `${questionText}${answers[idx].trim()}`
try {
const res = await fetch(`/api/study-comments/${encodeURIComponent(studySlug)}/${encodeURIComponent(sectionId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text.slice(0, 2000) }),
})
if (res.ok) newlyShared.push(idx)
} catch { /* ignore individual share failures */ }
}
if (newlyShared.length > 0) {
setSharedIndexes(prev => new Set([...prev, ...newlyShared]))
setShareToDiscussion(prev => {
const next = { ...prev }
newlyShared.forEach(idx => { next[idx] = false })
return next
})
}
setMessage(newlyShared.length > 0 ? `Answers saved and ${newlyShared.length} shared to the discussion.` : 'Answers saved.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to save answers.')
} finally {
@@ -3041,9 +3109,9 @@ export function StudyQuizPage({ content }: Props) {
return (
<main className="thanks-page" aria-label="Discussion questions not found">
<div className="thanks-card">
<p className="eyebrow">Discussion Questions</p>
<p className="eyebrow">Reflection Questions</p>
<h1>Discussion questions not found</h1>
<p>The discussion questions you requested are not available.</p>
<p>The reflection questions you requested are not available.</p>
<Link to="/study" className="btn-primary">Back to Studies</Link>
</div>
</main>
@@ -3056,8 +3124,8 @@ export function StudyQuizPage({ content }: Props) {
if (!auth.checked) {
return (
<main className="thanks-page" aria-label="Loading discussion questions">
<div className="thanks-card"><p>Loading discussion questions...</p></div>
<main className="thanks-page" aria-label="Loading reflection questions">
<div className="thanks-card"><p>Loading reflection questions...</p></div>
</main>
)
}
@@ -3066,7 +3134,7 @@ export function StudyQuizPage({ content }: Props) {
return (
<main className="thanks-page" aria-label="Sign in required">
<div className="thanks-card">
<p className="eyebrow">Discussion Questions</p>
<p className="eyebrow">Reflection Questions</p>
<h1>Sign In Required</h1>
<p>You need to sign in before you can view and save your discussion answers.</p>
<Link to="/study/signup" className="btn-primary">Sign In</Link>
@@ -3079,9 +3147,9 @@ export function StudyQuizPage({ content }: Props) {
return (
<main className="thanks-page" aria-label="Enrollment required">
<div className="thanks-card">
<p className="eyebrow">Discussion Questions</p>
<h1>Enroll to Access Discussion Questions</h1>
<p>Please enroll in {study.title} to open the discussion questions for this lesson.</p>
<p className="eyebrow">Reflection Questions</p>
<h1>Enroll to Access Reflection Questions</h1>
<p>Please enroll in {study.title} to open the reflection questions for this lesson.</p>
<Link to={`/study/${study.slug}`} className="btn-primary">Go to Study</Link>
</div>
</main>
@@ -3092,9 +3160,9 @@ export function StudyQuizPage({ content }: Props) {
<main className="study-index-page" aria-label="Discussion questions">
<section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner">
<p className="eyebrow">Discussion Questions</p>
<p className="eyebrow">Reflection Questions</p>
<h1>{section.title}</h1>
<p className="study-course-hero-copy">Work through the discussion questions below and save your responses for later review.</p>
<p className="study-course-hero-copy">Write out your answers below — they're saved privately. You can also choose to share individual answers to the lesson discussion.</p>
<div className="study-course-hero-actions">
<Link to={`/study/${study.slug}/${section.id}`} className="btn-secondary">Back to Lesson</Link>
</div>
@@ -3104,34 +3172,47 @@ export function StudyQuizPage({ content }: Props) {
<section className="section-study-module" style={{ paddingTop: '2rem' }}>
<div className="section-inner">
{loading ? (
<p>Loading discussion questions...</p>
<p>Loading reflection questions...</p>
) : error ? (
<p className="study-note-status">{error}</p>
) : !canSubmit ? (
<article className="study-class-block">
<h2>No Discussion Questions</h2>
<p className="study-detail-copy">This lesson does not have discussion questions configured yet.</p>
<h2>No Reflection Questions</h2>
<p className="study-detail-copy">This lesson does not have reflection questions configured yet.</p>
</article>
) : (
<form onSubmit={e => { e.preventDefault(); saveQuiz() }}>
{sectionQuestions.map((question, index) => (
<div key={index} style={{ marginBottom: '1.25rem' }}>
<p style={{ margin: '0 0 0.5rem', fontWeight: 600 }}>{`${index + 1}. ${question}`}</p>
<div key={index} className="quiz-question-block">
<p className="quiz-question-label">{`${index + 1}. ${question}`}</p>
<textarea
rows={4}
className="quiz-question-textarea"
value={answers[index] ?? ''}
onChange={e => setAnswers(prev => {
const next = [...prev]
next[index] = e.target.value
return next
})}
placeholder="Your answer..."
style={{ width: '100%', padding: '0.9rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8' }}
placeholder="Write your answer here…"
/>
{(answers[index] ?? '').trim().length >= 2 && (
<label className="quiz-share-toggle">
<input
type="checkbox"
checked={shareToDiscussion[index] ?? false}
disabled={sharedIndexes.has(index)}
onChange={e => setShareToDiscussion(prev => ({ ...prev, [index]: e.target.checked }))}
/>
{sharedIndexes.has(index)
? <span className="quiz-share-label quiz-share-label--done"> Shared to discussion</span>
: <span className="quiz-share-label">Share this answer to the lesson discussion</span>}
</label>
)}
</div>
))}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button type="submit" className="btn-primary" disabled={saving}>{saving ? 'Saving...' : 'Save Answers'}</button>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '0.5rem' }}>
<button type="submit" className="btn-primary" disabled={saving}>{saving ? 'Saving…' : 'Save Answers'}</button>
<button type="button" className="btn-secondary" onClick={() => navigate(`/study/${study.slug}/${section.id}`)}>Back to Lesson</button>
</div>
{message && <p className="study-note-status" style={{ marginTop: '1rem' }}>{message}</p>}