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:
+111
-30
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user