Add study completion, checkpoints, lesson announcements, and lesson-thread defaults
This commit is contained in:
@@ -79,6 +79,10 @@ function SortableLessonSection({ section, study, updateStudySection, removeStudy
|
||||
<label htmlFor={`study-section-summary-${study.id}-${section.id}`}>Short Summary</label>
|
||||
<textarea id={`study-section-summary-${study.id}-${section.id}`} rows={3} value={section.summary} placeholder="One to two sentences that summarize the section." onChange={e => updateStudySection(study.id, section.id, 'summary', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-section-announcement-${study.id}-${section.id}`}>Lesson Announcement</label>
|
||||
<textarea id={`study-section-announcement-${study.id}-${section.id}`} rows={3} value={section.announcement ?? ''} placeholder="A short instructor announcement for this lesson." onChange={e => updateStudySection(study.id, section.id, 'announcement', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-section-passage-${study.id}-${section.id}`}>Passage Text</label>
|
||||
<textarea id={`study-section-passage-${study.id}-${section.id}`} rows={4} value={section.passageText} placeholder="Paste the passage text here." onChange={e => updateStudySection(study.id, section.id, 'passageText', e.target.value)} />
|
||||
@@ -99,6 +103,15 @@ function SortableLessonSection({ section, study, updateStudySection, removeStudy
|
||||
<label htmlFor={`study-section-questions-${study.id}-${section.id}`}>Study Questions</label>
|
||||
<textarea id={`study-section-questions-${study.id}-${section.id}`} rows={5} value={(section.studyQuestions ?? []).join('\n')} placeholder="One question per line" onChange={e => updateStudySection(study.id, section.id, 'studyQuestions', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-section-checkpoint-prompt-${study.id}-${section.id}`}>Checkpoint Prompt</label>
|
||||
<input id={`study-section-checkpoint-prompt-${study.id}-${section.id}`} type="text" value={section.checkpointPrompt ?? ''} placeholder="A short prompt for the checkpoint" onChange={e => updateStudySection(study.id, section.id, 'checkpointPrompt', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-section-checkpoint-questions-${study.id}-${section.id}`}>Checkpoint Questions</label>
|
||||
<textarea id={`study-section-checkpoint-questions-${study.id}-${section.id}`} rows={5} value={(section.checkpointQuestions ?? []).join('\n')} placeholder="One checkpoint question per line" onChange={e => updateStudySection(study.id, section.id, 'checkpointQuestions', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
|
||||
<p className="admin-field-help">Optional reflection questions that learners answer before completing the lesson.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removeStudySection(study.id, section.id)}>Remove Lesson</button>
|
||||
</div>
|
||||
|
||||
+198
-6
@@ -46,6 +46,10 @@ type StudyAccountOverview = {
|
||||
|
||||
type StudyNotesMap = Record<string, string>
|
||||
|
||||
type StudyProgress = {
|
||||
completedSectionIds: string[]
|
||||
}
|
||||
|
||||
type StudyCommunityReply = {
|
||||
id: string
|
||||
authorName: string
|
||||
@@ -700,6 +704,8 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
||||
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [] })
|
||||
const [enrolling, setEnrolling] = useState(false)
|
||||
const [enrollMessage, setEnrollMessage] = useState('')
|
||||
const [studyProgress, setStudyProgress] = useState<StudyProgress>({ completedSectionIds: [] })
|
||||
const [progressLoading, setProgressLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -742,6 +748,30 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
||||
const populatedChapterCount = chapterSummaries.filter(chapter => chapter.lessonCount > 0).length
|
||||
const enrolled = isEnrolledInStudy(auth, study.slug)
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth.checked || !auth.authenticated || !study || !enrolled) return
|
||||
let cancelled = false
|
||||
setProgressLoading(true)
|
||||
|
||||
readJson<{ studySlug: string; completedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(study.slug)}`)
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setStudyProgress({ completedSectionIds: Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [] })
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setStudyProgress({ completedSectionIds: [] })
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return
|
||||
setProgressLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [auth.checked, auth.authenticated, study, enrolled])
|
||||
|
||||
async function enrollInStudy() {
|
||||
if (!study) return
|
||||
setEnrollMessage('')
|
||||
@@ -773,6 +803,9 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
||||
<span>{releasedSections.length} lessons available now</span>
|
||||
<span>{chapterSummaries.length} chapters</span>
|
||||
<span>{populatedChapterCount} chapters with lessons</span>
|
||||
{enrolled && !progressLoading ? (
|
||||
<span>{studyProgress.completedSectionIds.length} lessons completed</span>
|
||||
) : null}
|
||||
<span>Text + commentary + discussion</span>
|
||||
<span>Student notes enabled</span>
|
||||
</div>
|
||||
@@ -848,6 +881,9 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
||||
{isNew && <span style={{ backgroundColor: '#4caf50', color: '#fff', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>New</span>}
|
||||
{coming && <span style={{ backgroundColor: '#ffb74d', color: '#333', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Coming {getReleaseDateDisplay(section)}</span>}
|
||||
{!coming && !enrolled && <span style={{ backgroundColor: '#ef5350', color: '#fff', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Enroll to open</span>}
|
||||
{enrolled && 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>
|
||||
)}
|
||||
</div>
|
||||
<h3>{section.title}</h3>
|
||||
<p>{section.summary}</p>
|
||||
@@ -892,6 +928,12 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
const [noteLoading, setNoteLoading] = useState(false)
|
||||
const [noteSaving, setNoteSaving] = useState(false)
|
||||
const [noteMessage, setNoteMessage] = useState('')
|
||||
const [completedSectionIds, setCompletedSectionIds] = useState<string[]>([])
|
||||
const [progressLoading, setProgressLoading] = useState(false)
|
||||
const [progressSaving, setProgressSaving] = useState(false)
|
||||
const [progressMessage, setProgressMessage] = useState('')
|
||||
const [checkpointAnswers, setCheckpointAnswers] = useState<Record<number, string>>({})
|
||||
const [checkpointReflection, setCheckpointReflection] = useState('')
|
||||
|
||||
const canSaveNote = auth.authenticated && !noteSaving && !noteLoading
|
||||
const lessonAudioEmbedUrl = useMemo(() => {
|
||||
@@ -903,6 +945,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
const currentStudySlug = study?.slug ?? 'colossians'
|
||||
const noteId = section?.id ? getNoteId(currentStudySlug, section.id) : ''
|
||||
const isEnrolled = isEnrolledInStudy(auth, currentStudySlug)
|
||||
const lessonCompleted = section?.id ? completedSectionIds.includes(section.id) : false
|
||||
|
||||
useEffect(() => {
|
||||
document.title = section && study ? `${section.title} | ${study.title}` : 'Study'
|
||||
@@ -930,6 +973,36 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth.checked || !auth.authenticated || !study || !isEnrolled) return
|
||||
let cancelled = false
|
||||
setProgressLoading(true)
|
||||
setProgressMessage('')
|
||||
|
||||
readJson<{ studySlug: string; completedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(currentStudySlug)}`)
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setCompletedSectionIds([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return
|
||||
setProgressLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [auth.checked, auth.authenticated, study, isEnrolled, currentStudySlug])
|
||||
|
||||
useEffect(() => {
|
||||
setCheckpointAnswers({})
|
||||
setCheckpointReflection('')
|
||||
}, [section?.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth.authenticated || !noteId || !isEnrolled) {
|
||||
setNoteText('')
|
||||
@@ -1020,6 +1093,70 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function markLessonComplete() {
|
||||
if (!study || !section?.id || progressSaving) return
|
||||
setProgressSaving(true)
|
||||
setProgressMessage('')
|
||||
try {
|
||||
const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
|
||||
setProgressMessage('Lesson marked complete.')
|
||||
} catch (err) {
|
||||
setProgressMessage(err instanceof Error ? err.message : 'Unable to update completion.')
|
||||
} finally {
|
||||
setProgressSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function unmarkLessonCompletion() {
|
||||
if (!study || !section?.id || progressSaving) return
|
||||
setProgressSaving(true)
|
||||
setProgressMessage('')
|
||||
try {
|
||||
const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
|
||||
setProgressMessage('Lesson marked incomplete.')
|
||||
} catch (err) {
|
||||
setProgressMessage(err instanceof Error ? err.message : 'Unable to update completion.')
|
||||
} finally {
|
||||
setProgressSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCheckpoint() {
|
||||
if (!study || !section?.id || progressSaving) return
|
||||
const checkpointQuestions = section.checkpointQuestions ?? []
|
||||
const reflectionRequired = !(checkpointQuestions.length > 0)
|
||||
const allAnswered = checkpointQuestions.every((_, index) => (checkpointAnswers[index] ?? '').trim().length > 0)
|
||||
|
||||
if (reflectionRequired && !checkpointReflection.trim()) {
|
||||
setProgressMessage('Please write a short reflection before submitting the checkpoint.')
|
||||
return
|
||||
}
|
||||
if (!reflectionRequired && !allAnswered) {
|
||||
setProgressMessage('Please answer all checkpoint questions before submitting.')
|
||||
return
|
||||
}
|
||||
|
||||
setProgressSaving(true)
|
||||
setProgressMessage('')
|
||||
try {
|
||||
const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
|
||||
setProgressMessage('Checkpoint submitted and lesson marked complete.')
|
||||
} catch (err) {
|
||||
setProgressMessage(err instanceof Error ? err.message : 'Unable to submit checkpoint.')
|
||||
} finally {
|
||||
setProgressSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function enrollInCurrentStudy() {
|
||||
if (!study) return
|
||||
setEnrollMessage('')
|
||||
@@ -1121,6 +1258,13 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
<p className="study-detail-reference">{section.reference}</p>
|
||||
<p className="study-detail-summary">{section.summary}</p>
|
||||
|
||||
{section.announcement && (
|
||||
<article className="study-class-block" style={{ backgroundColor: '#1c1b17', border: '1px solid #3f3b2f' }}>
|
||||
<h2>Lesson Announcement</h2>
|
||||
<p className="study-detail-copy">{section.announcement}</p>
|
||||
</article>
|
||||
)}
|
||||
|
||||
{lessonAudioEmbedUrl && (
|
||||
<article className="study-class-block">
|
||||
<h2>Lesson Audio</h2>
|
||||
@@ -1156,10 +1300,49 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
))}
|
||||
</ol>
|
||||
<div style={{ marginTop: '1.25rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<Link to={`/study/${study.slug}/community`} className="btn-primary">Go to Community</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>
|
||||
</article>
|
||||
|
||||
{(section.checkpointPrompt || (section.checkpointQuestions && section.checkpointQuestions.length > 0)) && (
|
||||
<article className="study-class-block">
|
||||
<h2>Checkpoint</h2>
|
||||
{section.checkpointPrompt && <p className="study-detail-copy">{section.checkpointPrompt}</p>}
|
||||
{section.checkpointQuestions && section.checkpointQuestions.length > 0 ? (
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
{section.checkpointQuestions.map((question, index) => (
|
||||
<div key={index}>
|
||||
<p style={{ margin: '0 0 0.5rem', fontWeight: 600 }}>{question}</p>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={checkpointAnswers[index] ?? ''}
|
||||
onChange={e => setCheckpointAnswers(prev => ({ ...prev, [index]: e.target.value }))}
|
||||
placeholder="Write your answer here..."
|
||||
style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={checkpointReflection}
|
||||
onChange={e => setCheckpointReflection(e.target.value)}
|
||||
placeholder="Reflect on the prompt above and write your observations here..."
|
||||
style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button type="button" className="btn-primary" onClick={submitCheckpoint} disabled={!isEnrolled || progressSaving}>
|
||||
{progressSaving ? 'Submitting...' : lessonCompleted ? 'Re-submit Checkpoint' : 'Submit Checkpoint'}
|
||||
</button>
|
||||
{lessonCompleted && <span style={{ color: '#a39d8d' }}>Checkpoint completed for this lesson.</span>}
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="study-classroom-sidebar" aria-label="Lesson tools">
|
||||
@@ -1212,13 +1395,21 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
placeholder="Write your lesson notes here..."
|
||||
disabled={noteLoading || noteSaving}
|
||||
/>
|
||||
<div className="study-auth-actions">
|
||||
<div className="study-auth-actions" style={{ flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
<button type="button" className="btn-primary" disabled={!canSaveNote} onClick={saveNote}>{noteSaving ? 'Saving...' : 'Save Notes'}</button>
|
||||
<button type="button" className="btn-secondary" disabled={authBusy} onClick={logoutStudyUser}>Sign Out</button>
|
||||
<button
|
||||
type="button"
|
||||
className={lessonCompleted ? 'btn-secondary' : 'btn-primary'}
|
||||
disabled={!isEnrolled || progressSaving || progressLoading}
|
||||
onClick={lessonCompleted ? unmarkLessonCompletion : markLessonComplete}
|
||||
>
|
||||
{progressSaving ? 'Updating...' : lessonCompleted ? 'Mark Incomplete' : 'Mark Complete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(authMessage || noteMessage) && <p className="study-note-status">{authMessage || noteMessage}</p>}
|
||||
{(authMessage || noteMessage || progressMessage) && <p className="study-note-status">{authMessage || noteMessage || progressMessage}</p>}
|
||||
</article>
|
||||
|
||||
<article className="study-class-side-block">
|
||||
@@ -1698,7 +1889,7 @@ export function StudyAccountPage() {
|
||||
<p style={{ margin: 0, fontWeight: 600 }}>{study.title}</p>
|
||||
<p style={{ margin: '0.35rem 0 0', fontSize: '0.84rem' }}>
|
||||
Status: <strong>{study.enrolled ? 'Enrolled' : 'Not Enrolled'}</strong>
|
||||
{` • Progress: ${study.completedLessons}/${study.totalLessons || 0} lessons with notes`}
|
||||
{` • Progress: ${study.completedLessons}/${study.totalLessons || 0} lessons completed`}
|
||||
{` • Notes: ${study.noteCount}`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1847,8 +2038,10 @@ export function StudyAccountPage() {
|
||||
|
||||
export function StudyCommunityPage({ content }: Props) {
|
||||
const { studySlug } = useParams<{ studySlug: string }>()
|
||||
const location = useLocation()
|
||||
const studies = getStudies(content)
|
||||
const study = getStudyBySlug(studies, studySlug)
|
||||
const defaultSectionId = new URLSearchParams(location.search).get('sectionId') ?? ''
|
||||
|
||||
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [], displayName: '' })
|
||||
|
||||
@@ -1889,9 +2082,8 @@ export function StudyCommunityPage({ content }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-inner" style={{ paddingTop: '2rem', paddingBottom: '4rem', maxWidth: '860px' }}>
|
||||
<CommunityBoard study={study} auth={auth} isEnrolled={isEnrolled} />
|
||||
<CommunityBoard study={study} auth={auth} isEnrolled={isEnrolled} sectionFilter={defaultSectionId} />
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,9 @@ export type ColossiansStudySection = {
|
||||
greekNotes: string[]
|
||||
studyQuestions: string[]
|
||||
focusQuestion?: string
|
||||
announcement?: string
|
||||
checkpointPrompt?: string
|
||||
checkpointQuestions?: string[]
|
||||
releasedAt?: string
|
||||
}
|
||||
|
||||
@@ -25,8 +28,25 @@ function section(
|
||||
studyQuestions: string[],
|
||||
greekNotes: string[] = [],
|
||||
releasedAt: string | undefined = undefined,
|
||||
checkpointPrompt: string | undefined = undefined,
|
||||
checkpointQuestions: string[] = [],
|
||||
): ColossiansStudySection {
|
||||
return { id, chapter, reference, title, audioEmbedUrl, passageText, summary, commentary, studyQuestions, greekNotes, ...(releasedAt ? { releasedAt } : {}) }
|
||||
return {
|
||||
id,
|
||||
chapter,
|
||||
reference,
|
||||
title,
|
||||
audioEmbedUrl,
|
||||
passageText,
|
||||
summary,
|
||||
commentary,
|
||||
studyQuestions,
|
||||
greekNotes,
|
||||
announcement: undefined,
|
||||
checkpointPrompt,
|
||||
checkpointQuestions,
|
||||
...(releasedAt ? { releasedAt } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_COLOSSIANS_STUDY_SECTIONS: ColossiansStudySection[] = [
|
||||
@@ -50,6 +70,8 @@ export const DEFAULT_COLOSSIANS_STUDY_SECTIONS: ColossiansStudySection[] = [
|
||||
'Peace (eirene): wholeness and well-being that flows from reconciliation with God.',
|
||||
],
|
||||
'2026-11-09T00:00:00Z',
|
||||
'What do you want to remember from this greeting as you begin the lesson?',
|
||||
['Why does Paul begin with grace and peace?', 'What tone does this create for the rest of the study?'],
|
||||
),
|
||||
section('1-3-8', 1, '1:3-8', 'Thanksgiving and Report', '', '', 'Paul thanks God for faith, hope, and love, and reports that the gospel is bearing fruit.', 'The gospel is shown to be active and alive. Paul points to evidence of real spiritual growth, not merely religious activity.', ['What signs of gospel growth does Paul mention?', 'How do faith, hope, and love work together here?'], [], '2026-11-23T00:00:00Z'),
|
||||
section('1-9-14', 1, '1:9-14', 'Paul\'s Prayer', '', '', 'Paul prays for spiritual wisdom, endurance, and a life worthy of the Lord.', 'This prayer shows that knowledge and fruitfulness belong together. Paul wants the believers to know God\'s will and walk in a way that reflects it.', ['What does Paul ask God to produce?', 'How does this prayer shape the goals of the study?'], [], '2026-12-07T00:00:00Z'),
|
||||
|
||||
Reference in New Issue
Block a user