import { useEffect, useMemo, useRef, useState, useCallback } from 'react' import DOMPurify from 'dompurify' import { Link, useLocation, useNavigate, useParams } from 'react-router-dom' import type { SiteContent, StudyProgram, StudySection } from './content' import { getStudyGuideLink } from './content' import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './legacyColossiansData' import { usePageMeta } from './hooks/usePageMeta' import { Breadcrumbs } from './components/Breadcrumbs' import { StudyCertificate } from './components/StudyCertificate' import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer' import { NoteCard } from './components/NoteCard' import { StudySectionComments } from './components/StudySectionComments' type Props = { content: SiteContent } type StudyAuthState = { checked: boolean authenticated: boolean username: string enrolledStudySlugs?: string[] displayName?: string subscribeNewsletter?: boolean studyRemindersEnabled?: boolean avatarUrl?: string totpEnabled?: boolean twoFaMethod?: 'app' | 'email' | null totpRecoveryCodesRemaining?: number emailVerified?: boolean } type StudyAuthStatusResponse = { authenticated: boolean username: string enrolledStudySlugs?: string[] displayName?: string subscribeNewsletter?: boolean studyRemindersEnabled?: boolean avatarUrl?: string totpEnabled?: boolean twoFaMethod?: 'app' | 'email' | null totpRecoveryCodesRemaining?: number emailVerified?: boolean } type StudyAccountOverview = { profile: { username: string displayName: string subscribeNewsletter: boolean studyRemindersEnabled: boolean reminderFrequency?: string remindersPausedUntil?: string | null avatarUrl?: string emailVerified?: boolean } stats: { noteCount: number noteLimit: number noteCharLimit: number memberSince: string lastLoginAt: string | null currentStreak: number longestStreak: number lastStudiedDate: string | null } studies: Array<{ slug: string title: string status: 'active' | 'planned' | 'complete' enrolled: boolean completed?: boolean totalLessons: number completedLessons: number noteCount: number }> certificates: Array<{ studySlug: string studyTitle: string issuedAt: string url: string }> } type StudyNotesMap = Record type StudyProgress = { completedSectionIds: string[] bookmarkedSectionIds: string[] } type StudyCommunityReply = { id: string authorName: string authorAvatarUrl?: string message: string createdAt: string } type StudyCommunityPost = { id: string studySlug: string sectionId: string authorName: string authorAvatarUrl?: string message: string createdAt: string replies: StudyCommunityReply[] likeCount?: number likedByMe?: boolean pending?: boolean } function StudyStatusBadge({ status }: { status: 'active' | 'planned' | 'complete' }) { const label = status === 'active' ? 'Active' : status === 'complete' ? 'Complete' : 'Planned' return {label} } function getLegacyColossiansStudy(content: SiteContent): StudyProgram { const legacySections = content.colossiansStudySections?.length ? content.colossiansStudySections : DEFAULT_COLOSSIANS_STUDY_SECTIONS return { id: 'study-colossians', slug: 'colossians', title: 'Colossians: Rooted in Christ', description: 'Walk through Colossians in guided lessons with commentary, Greek notes, and discussion prompts.', status: 'active', difficulty: 'intermediate', estimatedHours: 12, completionBadge: 'Colossians Completion', numberOfChapters: 4, sections: legacySections, } } function getStudies(content: SiteContent): StudyProgram[] { if (Array.isArray(content.studies) && content.studies.length > 0) return content.studies return [getLegacyColossiansStudy(content)] } function getStudyBySlug(studies: StudyProgram[], slug: string | undefined) { const safeSlug = (slug ?? 'colossians').trim().toLowerCase() return studies.find(study => study.slug === safeSlug) } function getSectionById(sections: StudySection[], sectionId: string | undefined) { if (!sectionId) return undefined return sections.find(section => section.id === sectionId) } function getLessonNumber(sections: StudySection[], sectionId: string | undefined) { const index = sections.findIndex(item => item.id === sectionId) return index >= 0 ? index + 1 : 0 } function getPrimaryQuestion(section: StudySection) { if (section.focusQuestion?.trim()) return section.focusQuestion.trim() return section.studyQuestions.length > 0 ? section.studyQuestions[0] : 'How does this passage shape the way we follow Christ this week?' } function getChapterSummaries(study: StudyProgram) { const highestSectionChapter = study.sections.reduce((max, section) => Math.max(max, section.chapter), 0) const totalChapters = Math.max(study.numberOfChapters, highestSectionChapter) return Array.from({ length: totalChapters }, (_, index) => { const chapter = index + 1 const lessonCount = study.sections.filter(section => section.chapter === chapter).length return { chapter, lessonCount } }) } function getSectionReleaseDate(section: StudySection): Date | null { const candidateValues = [ section?.releasedAt, (section as StudySection & { releaseDate?: string }).releaseDate, (section as StudySection & { availableAt?: string }).availableAt, (section as StudySection & { publishAt?: string }).publishAt, ] for (const candidate of candidateValues) { if (typeof candidate !== 'string' || !candidate.trim()) continue const parsed = new Date(candidate) if (!Number.isNaN(parsed.getTime())) return parsed } return null } function isNewLesson(section: StudySection): boolean { const releaseDate = getSectionReleaseDate(section) if (!releaseDate) return false const now = new Date() const daysSinceRelease = (now.getTime() - releaseDate.getTime()) / (1000 * 60 * 60 * 24) return daysSinceRelease >= 0 && daysSinceRelease <= 14 } function isComingSoon(section: StudySection): boolean { const releaseDate = getSectionReleaseDate(section) if (!releaseDate) return false const now = new Date() return releaseDate > now } function isSectionReleased(section: StudySection): boolean { const releaseDate = getSectionReleaseDate(section) if (!releaseDate) return false return releaseDate <= new Date() } // A study is complete when an admin marks it so, or when every lesson has been released. // Complete studies stay open for enrollment and are fully self-paced. function getEffectiveStudyStatus(study: StudyProgram): 'active' | 'planned' | 'complete' { if (study.status === 'planned') return 'planned' if (study.status === 'complete') return 'complete' if (study.sections.length > 0 && study.sections.every(isSectionReleased)) return 'complete' return 'active' } // Discussion (community board and lesson comments) is open for every study that has started, // including finished ones, so students who take a study later can still join in. Planned // studies are closed until they begin. function isDiscussionOpen(study: StudyProgram | undefined): boolean { return Boolean(study) && getEffectiveStudyStatus(study as StudyProgram) !== 'planned' } function getNextReleaseLabel(study: StudyProgram): string { const upcoming = study.sections .map(getSectionReleaseDate) .filter((date): date is Date => date !== null && date > new Date()) .sort((a, b) => a.getTime() - b.getTime())[0] if (!upcoming) return '' return upcoming.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) } function getReleaseDateDisplay(section: StudySection): string { const date = getSectionReleaseDate(section) if (!date) return '' const now = new Date() const diffMs = date.getTime() - now.getTime() const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)) if (diffDays === 1) return 'tomorrow' if (diffDays > 1 && diffDays <= 14) return `in ${diffDays} days` return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) } function getNoteId(studySlug: string, sectionId: string) { return `${studySlug}--${sectionId}` } type NoteAnchorId = 'scripture' | 'commentary' | 'questions' | 'general' const ANCHOR_LABELS: Record = { scripture: 'Scripture Text', commentary: 'Commentary', questions: 'Reflection Questions', general: 'General Notes', } function parseNoteMap(raw: string): Record { if (!raw?.trim()) return {} try { const parsed = JSON.parse(raw) if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed as Record } } catch { // Not valid JSON — treat the raw string as legacy plain-text notes. } return { general: raw } } function getDisplayName(auth: StudyAuthState) { return auth.displayName?.trim() || auth.username.split('@')[0] || 'student' } function formatRelativeTime(value: string) { const date = new Date(value) if (Number.isNaN(date.getTime())) return '' const diffMs = Date.now() - date.getTime() const diffSec = Math.floor(diffMs / 1000) if (diffSec < 60) return 'just now' const diffMin = Math.floor(diffSec / 60) if (diffMin < 60) return `${diffMin}m ago` const diffHr = Math.floor(diffMin / 60) if (diffHr < 24) return `${diffHr}h ago` const diffDay = Math.floor(diffHr / 24) if (diffDay < 7) return `${diffDay}d ago` return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) } function UserAvatar({ name, url, size = 36 }: { name?: string; url?: string; size?: number }) { if (url) { return {name } const initial = (name || 'S').slice(0, 1).toUpperCase() return (
{initial}
) } function CommunityBoard({ study, auth, isEnrolled, sectionFilter, }: { study: StudyProgram auth: StudyAuthState isEnrolled: boolean sectionFilter?: string }) { const [posts, setPosts] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [replyDrafts, setReplyDrafts] = useState>({}) const [replyingTo, setReplyingTo] = useState(null) const [submitting, setSubmitting] = useState(false) const [newPost, setNewPost] = useState('') const [activeSection, setActiveSection] = useState(sectionFilter ?? '') const canParticipate = auth.authenticated && isEnrolled const communityDisplayName = getDisplayName(auth) const releasedSections = study.sections.filter(isSectionReleased) const visiblePosts = activeSection ? posts.filter(p => p.sectionId === activeSection) : posts useEffect(() => { let cancelled = false async function load() { if (!canParticipate) { setPosts([]); setLoading(false); return } setLoading(true); setError('') try { const data = await readJson<{ posts?: StudyCommunityPost[] }>('/api/study-community?' + new URLSearchParams({ studySlug: study.slug }).toString()) if (!cancelled) setPosts(Array.isArray(data.posts) ? data.posts : []) } catch (err) { if (!cancelled) setError(err instanceof Error ? err.message : 'Unable to load the community right now.') } finally { if (!cancelled) setLoading(false) } } void load() return () => { cancelled = true } }, [canParticipate, study.slug]) async function submitPost() { if (!newPost.trim() || !canParticipate) return setSubmitting(true); setError('') const optimisticId = `pending-${Date.now()}` const optimisticPost: StudyCommunityPost = { id: optimisticId, studySlug: study.slug, sectionId: activeSection, authorName: communityDisplayName, authorAvatarUrl: auth.avatarUrl || undefined, message: newPost, createdAt: new Date().toISOString(), replies: [], likeCount: 0, likedByMe: false, pending: true, } setPosts(prev => [optimisticPost, ...prev]) const postedMessage = newPost setNewPost('') try { const data = await readJson<{ post?: StudyCommunityPost }>('/api/study-community/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ studySlug: study.slug, sectionId: activeSection, message: postedMessage }), }) setPosts(prev => data.post ? prev.map(p => p.id === optimisticId ? { ...data.post!, likeCount: 0, likedByMe: false, pending: false } : p) : prev.filter(p => p.id !== optimisticId)) } catch (err) { setPosts(prev => prev.filter(p => p.id !== optimisticId)) setNewPost(postedMessage) setError(err instanceof Error ? err.message : 'Unable to post right now.') } finally { setSubmitting(false) } } async function submitReply(postId: string) { const reply = replyDrafts[postId]?.trim() if (!reply || !canParticipate) return setSubmitting(true); setError('') try { const data = await readJson<{ reply?: StudyCommunityReply }>(`/api/study-community/posts/${encodeURIComponent(postId)}/replies`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: reply }), }) if (data.reply) { setPosts(prev => prev.map(post => post.id === postId ? { ...post, replies: [...(post.replies ?? []), data.reply!] } : post)) } setReplyDrafts(prev => ({ ...prev, [postId]: '' })) setReplyingTo(null) } catch (err) { setError(err instanceof Error ? err.message : 'Unable to post your reply.') } finally { setSubmitting(false) } } async function toggleLike(postId: string) { if (!canParticipate) return setPosts(prev => prev.map(p => p.id === postId ? { ...p, likedByMe: !p.likedByMe, likeCount: (p.likeCount ?? 0) + (p.likedByMe ? -1 : 1) } : p)) try { await readJson(`/api/study-community/posts/${encodeURIComponent(postId)}/like`, { method: 'POST', }) } catch { setPosts(prev => prev.map(p => p.id === postId ? { ...p, likedByMe: !p.likedByMe, likeCount: (p.likeCount ?? 0) + (p.likedByMe ? -1 : 1) } : p)) } } const postCountFor = (sectionId: string) => posts.filter(p => p.sectionId === sectionId).length const lessonsForFilter = releasedSections.filter(item => postCountFor(item.id) > 0 || item.id === activeSection) const activeSectionTitle = releasedSections.find(item => item.id === activeSection)?.title return (
{/* Mobile lesson filter */} {releasedSections.length > 1 && canParticipate && (
)} {!auth.checked &&

Checking your account status...

} {auth.checked && !auth.authenticated && (

Sign in to join the study community.

Create Account Sign In
)} {auth.checked && auth.authenticated && !isEnrolled && (

Enroll in {study.title} to participate in the community.

Go to My Account
)} {canParticipate && (
{activeSection && (
Posting to: {releasedSections.find(s => s.id === activeSection)?.title ?? 'this lesson'}
)}