Add study completion, checkpoints, lesson announcements, and lesson-thread defaults
This commit is contained in:
+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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user