Files
Siteforge/src/colossiansStudy.tsx
T
nmemmert 01abb2c8c6 fix
2026-06-02 14:21:03 -04:00

2781 lines
129 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
type Props = { content: SiteContent }
type StudyAuthState = {
checked: boolean
authenticated: boolean
username: string
enrolledStudySlugs?: string[]
displayName?: string
subscribeNewsletter?: boolean
studyRemindersEnabled?: boolean
avatarUrl?: string
}
type StudyAuthStatusResponse = {
authenticated: boolean
username: string
enrolledStudySlugs?: string[]
displayName?: string
subscribeNewsletter?: boolean
studyRemindersEnabled?: boolean
avatarUrl?: string
}
type StudyAccountOverview = {
profile: {
username: string
displayName: string
subscribeNewsletter: boolean
studyRemindersEnabled: boolean
avatarUrl?: string
}
stats: {
noteCount: number
memberSince: string
lastLoginAt: string | null
}
studies: Array<{
slug: string
title: string
status: 'active' | 'planned'
enrolled: boolean
totalLessons: number
completedLessons: number
noteCount: number
}>
}
type StudyNotesMap = Record<string, string>
type StudyProgress = {
completedSectionIds: 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[]
}
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()
}
function getReleaseDateDisplay(section: StudySection): string {
const date = getSectionReleaseDate(section)
if (!date) return ''
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
function getNoteId(studySlug: string, sectionId: string) {
return `${studySlug}--${sectionId}`
}
function getDisplayName(auth: StudyAuthState) {
return auth.displayName?.trim() || auth.username.split('@')[0] || 'student'
}
function formatCommunityDate(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
function CommunityBoard({
study,
auth,
isEnrolled,
sectionFilter,
}: {
study: StudyProgram
auth: StudyAuthState
isEnrolled: boolean
sectionFilter?: string
}) {
const [posts, setPosts] = useState<StudyCommunityPost[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [message, setMessage] = useState('')
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({})
const [replyingTo, setReplyingTo] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [newPost, setNewPost] = useState('')
const [activeSection, setActiveSection] = useState<string>(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) return
setPosts(Array.isArray(data.posts) ? data.posts : [])
} catch (err) {
if (cancelled) return
setError(err instanceof Error ? err.message : 'Unable to load the community right now.')
} finally {
if (cancelled) return
setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [canParticipate, study.slug])
async function submitPost() {
if (!newPost.trim() || !canParticipate) return
setSubmitting(true)
setMessage('')
setError('')
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: newPost }),
})
if (data.post) setPosts(prev => [data.post!, ...prev])
setNewPost('')
setMessage('Posted to the community.')
} catch (err) {
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)
setMessage('Reply posted.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to post your reply.')
} finally {
setSubmitting(false)
}
}
return (
<div className="study-community-board">
{/* Section filter tabs */}
{releasedSections.length > 1 && canParticipate && (
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
<button
type="button"
className={`qa-topic-btn${activeSection === '' ? ' qa-topic-btn--active' : ''}`}
onClick={() => setActiveSection('')}
>All lessons</button>
{releasedSections.map(s => (
<button
key={s.id}
type="button"
className={`qa-topic-btn${activeSection === s.id ? ' qa-topic-btn--active' : ''}`}
onClick={() => setActiveSection(s.id)}
>{s.title}</button>
))}
</div>
)}
{!auth.checked && <p className="study-note-status">Checking your account status...</p>}
{auth.checked && !auth.authenticated && (
<div className="study-auth-box">
<p className="study-auth-why">Sign in to join the study community.</p>
<div className="study-auth-actions">
<Link to="/study/signup" className="btn-primary">Create Account</Link>
<Link to="/study/account" className="btn-secondary">My Account</Link>
</div>
</div>
)}
{auth.checked && auth.authenticated && !isEnrolled && (
<div className="study-auth-box">
<p className="study-auth-why">Enroll in {study.title} to participate in the community.</p>
<div className="study-auth-actions">
<Link to="/study/account" className="btn-primary">Go to My Account</Link>
</div>
</div>
)}
{canParticipate && (
<div style={{ marginBottom: '1.5rem' }}>
{activeSection && (
<p style={{ fontSize: '0.875rem', color: '#7a7060', marginBottom: '0.5rem' }}>
Posting to: <strong style={{ color: '#e0c070' }}>{releasedSections.find(s => s.id === activeSection)?.title ?? 'this lesson'}</strong>
</p>
)}
<textarea
rows={4}
value={newPost}
onChange={e => setNewPost(e.target.value)}
placeholder={activeSection ? `Share a thought about ${releasedSections.find(s => s.id === activeSection)?.title ?? 'this lesson'}...` : 'Share a thought with the community...'}
style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem' }}
/>
<div className="study-auth-actions">
<button type="button" className="btn-primary" disabled={submitting || !newPost.trim()} onClick={submitPost}>
{submitting ? 'Posting...' : 'Post to Community'}
</button>
</div>
</div>
)}
{message && <p className="study-note-status" style={{ marginBottom: '0.75rem' }}>{message}</p>}
{error && <p className="study-note-status" style={{ marginBottom: '0.75rem' }}>{error}</p>}
{canParticipate && (
<div style={{ display: 'grid', gap: '0.85rem' }}>
{loading ? (
<p className="study-detail-copy">Loading community posts...</p>
) : visiblePosts.length === 0 ? (
<p className="study-detail-copy">No posts yet{activeSection ? ' for this lesson' : ''}. Be the first to start the conversation.</p>
) : visiblePosts.map(post => {
const postSection = study.sections.find(s => s.id === post.sectionId)
return (
<div key={post.id} style={{ border: '1px solid #2a2518', borderRadius: '14px', padding: '1.15rem', background: '#11100d' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap', marginBottom: '0.4rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.65rem', flexWrap: 'wrap' }}>
{post.authorAvatarUrl ? (
<img src={post.authorAvatarUrl} alt={`${post.authorName || 'Student'} avatar`} width={28} height={28} style={{ borderRadius: '50%', border: '1px solid #2a2518' }} />
) : (
<div style={{ width: 28, height: 28, borderRadius: '50%', background: '#2a2518', display: 'grid', placeItems: 'center', color: '#f0ead8', fontSize: '0.85rem', fontWeight: 700 }}>
{post.authorName?.slice(0, 1).toUpperCase() || 'S'}
</div>
)}
<strong style={{ color: '#e0c070' }}>{post.authorName || 'Student'}</strong>
{postSection && !activeSection && (
<button
type="button"
className="qa-topic-btn"
style={{ padding: '0.2rem 0.6rem', fontSize: '0.78rem' }}
onClick={() => setActiveSection(postSection.id)}
>{postSection.title}</button>
)}
</div>
<span className="study-note-status">{formatCommunityDate(post.createdAt)}</span>
</div>
<p className="study-detail-copy" style={{ marginTop: 0, whiteSpace: 'pre-wrap' }}>{post.message}</p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '0.75rem' }}>
<button type="button" className="btn-secondary" style={{ fontSize: '0.85rem', padding: '0.35rem 0.85rem' }} onClick={() => setReplyingTo(curr => curr === post.id ? null : post.id)}>
{replyingTo === post.id ? 'Cancel' : `Reply${post.replies?.length ? ` (${post.replies.length})` : ''}`}
</button>
</div>
{replyingTo === post.id && canParticipate && (
<div style={{ marginTop: '0.85rem' }}>
<textarea
rows={3}
value={replyDrafts[post.id] ?? ''}
onChange={e => setReplyDrafts(prev => ({ ...prev, [post.id]: e.target.value }))}
placeholder={`Reply to ${post.authorName || communityDisplayName}...`}
style={{ width: '100%', padding: '0.8rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem' }}
/>
<button type="button" className="btn-primary" style={{ fontSize: '0.9rem' }} disabled={submitting || !(replyDrafts[post.id] ?? '').trim()} onClick={() => submitReply(post.id)}>Post Reply</button>
</div>
)}
{Array.isArray(post.replies) && post.replies.length > 0 && (
<div style={{ marginTop: '1rem', display: 'grid', gap: '0.65rem' }}>
{post.replies.map(reply => (
<div key={reply.id} style={{ borderLeft: '2px solid #2a2518', paddingLeft: '0.85rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.55rem', flexWrap: 'wrap' }}>
{reply.authorAvatarUrl ? (
<img src={reply.authorAvatarUrl} alt={`${reply.authorName || 'Student'} avatar`} width={22} height={22} style={{ borderRadius: '50%', border: '1px solid #2a2518' }} />
) : (
<div style={{ width: 22, height: 22, borderRadius: '50%', background: '#2a2518', display: 'grid', placeItems: 'center', color: '#f0ead8', fontSize: '0.75rem', fontWeight: 700 }}>
{reply.authorName?.slice(0, 1).toUpperCase() || 'S'}
</div>
)}
<strong style={{ color: '#c9a84c', fontSize: '0.95rem' }}>{reply.authorName || 'Student'}</strong>
</div>
<span className="study-note-status">{formatCommunityDate(reply.createdAt)}</span>
</div>
<p className="study-detail-copy" style={{ margin: '0.25rem 0 0', whiteSpace: 'pre-wrap' }}>{reply.message}</p>
</div>
))}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
async function readJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init)
const data = await response.json().catch(() => ({})) as T & { message?: string }
if (!response.ok) {
throw new Error((data as { message?: string }).message ?? 'Request failed')
}
return data as T
}
function normalizeEnrolledStudySlugs(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter(item => typeof item === 'string' && item.trim()).map(item => item.trim().toLowerCase())
}
function isEnrolledInStudy(auth: StudyAuthState, studySlug: string | undefined): boolean {
if (!auth.authenticated || !studySlug) return false
const normalized = studySlug.trim().toLowerCase()
if (!normalized) return false
return normalizeEnrolledStudySlugs(auth.enrolledStudySlugs).includes(normalized)
}
export function StudyLandingPage({ content }: Props) {
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [], displayName: '', subscribeNewsletter: true, studyRemindersEnabled: false, avatarUrl: '' })
const [overview, setOverview] = useState<StudyAccountOverview | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [studyModal, setStudyModal] = useState<'none' | 'enrollments' | 'browseTracks'>('none')
const [enrollBusySlug, setEnrollBusySlug] = useState('')
const [enrollMessage, setEnrollMessage] = useState('')
const studies = getStudies(content)
const activeStudies = studies.filter(study => study.status !== 'planned')
const plannedStudies = studies.filter(study => study.status === 'planned')
const firstActiveStudy = activeStudies[0]
const firstActiveStudyFirstReleasedSection = firstActiveStudy?.sections.find(isSectionReleased)
async function refreshOverview() {
if (!auth.authenticated) return
try {
const dashboard = await readJson<StudyAccountOverview>('/api/study-account/overview')
setOverview(dashboard)
} catch (err) {
console.error(err)
}
}
async function handleEnrollmentToggle(studySlug: string, enrolled: boolean) {
if (!auth.authenticated) {
setEnrollMessage('Sign in to change your enrollments.')
return
}
setEnrollBusySlug(studySlug)
setEnrollMessage('')
try {
const data = await readJson<{ enrolledStudySlugs?: string[]; studyTitle?: string }>(`/api/study-enrollment/${encodeURIComponent(studySlug)}`, {
method: enrolled ? 'DELETE' : 'POST',
})
const slugs = normalizeEnrolledStudySlugs(data.enrolledStudySlugs)
setAuth(prev => ({ ...prev, enrolledStudySlugs: slugs }))
setEnrollMessage(enrolled ? `Unenrolled from ${data.studyTitle ?? studySlug}.` : `Enrolled in ${data.studyTitle ?? studySlug}.`)
await refreshOverview()
} catch (err) {
setEnrollMessage(err instanceof Error ? err.message : 'Unable to update enrollment.')
} finally {
setEnrollBusySlug('')
}
}
function closeStudyModal() {
setStudyModal('none')
setEnrollMessage('')
}
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
setError('')
try {
const authData = await readJson<StudyAuthStatusResponse>('/api/study-auth/status')
if (cancelled) return
setAuth({
checked: true,
authenticated: Boolean(authData.authenticated),
username: authData.username ?? '',
displayName: authData.displayName ?? '',
subscribeNewsletter: authData.subscribeNewsletter !== false,
studyRemindersEnabled: authData.studyRemindersEnabled === true,
avatarUrl: authData.avatarUrl ?? '',
enrolledStudySlugs: normalizeEnrolledStudySlugs(authData.enrolledStudySlugs),
})
if (authData.authenticated) {
const dashboard = await readJson<StudyAccountOverview>('/api/study-account/overview')
if (cancelled) return
setOverview(dashboard)
}
} catch (err) {
if (cancelled) return
setError(err instanceof Error ? err.message : 'Unable to load your study dashboard.')
} finally {
if (cancelled) return
setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [])
const enrolledStudies = overview?.studies.filter(study => study.enrolled) ?? []
const availableStudies = overview?.studies ?? []
const totalEnrolled = enrolledStudies.length
const totalCompletedLessons = enrolledStudies.reduce((sum, study) => sum + study.completedLessons, 0)
const totalLessons = enrolledStudies.reduce((sum, study) => sum + study.totalLessons, 0)
if (loading) {
return (
<main className="study-index-page" aria-label="Study hub">
<section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner">
<p className="eyebrow">Study Dashboard</p>
<h1>Loading your study progress</h1>
<p className="study-course-hero-copy">Hang tight while we gather your enrolled tracks and lesson progress.</p>
</div>
</section>
</main>
)
}
if (auth.authenticated && overview) {
return (
<main className="study-index-page" aria-label="Study dashboard">
<section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner" style={{ alignItems: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
{auth.avatarUrl ? (
<img src={auth.avatarUrl} alt="Your avatar" width={76} height={76} style={{ borderRadius: '50%', border: '1px solid #2a2518' }} />
) : (
<div style={{ width: 76, height: 76, borderRadius: '50%', background: '#2a2518', display: 'grid', placeItems: 'center', color: '#f0ead8', fontWeight: 700, fontSize: '1.75rem' }}>
{auth.displayName?.slice(0, 1).toUpperCase() || auth.username.slice(0, 1).toUpperCase()}
</div>
)}
<div>
<p className="eyebrow">Welcome back</p>
<h1 style={{ marginTop: 0 }}>{auth.displayName || auth.username.split('@')[0]}</h1>
<p className="study-course-hero-copy">Your progress, enrollments, and classroom resources are all in one place.</p>
</div>
</div>
<div className="study-course-meta" style={{ marginTop: '1.5rem', gap: '0.75rem' }}>
<span>{totalEnrolled} enrolled track{totalEnrolled === 1 ? '' : 's'}</span>
<span>{totalCompletedLessons}/{totalLessons} lessons completed</span>
<span>{overview.stats.noteCount} notes saved</span>
{auth.studyRemindersEnabled ? <span>Lesson reminders enabled</span> : <span>Reminders off</span>}
</div>
{error && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{error}</p>}
<div className="study-course-hero-actions" style={{ marginTop: '1.5rem' }}>
<Link to="/study/account" className="btn-primary">Open My Account</Link>
<button type="button" className="btn-secondary" onClick={() => setStudyModal('enrollments')}>View Enrollments</button>
<button type="button" className="btn-secondary" onClick={() => setStudyModal('browseTracks')}>Browse All Tracks</button>
</div>
</div>
</section>
{studyModal !== 'none' && (
<div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(5, 5, 5, 0.86)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1.25rem' }}>
<div style={{ width: '100%', maxWidth: '760px', maxHeight: 'calc(100vh - 2rem)', overflowY: 'auto', background: '#11100d', border: '1px solid #3a3320', borderRadius: '18px', padding: '1.4rem', position: 'relative' }} role="dialog" aria-modal="true" aria-labelledby="study-modal-heading">
<button type="button" onClick={closeStudyModal} style={{ position: 'absolute', top: '1rem', right: '1rem', background: 'transparent', border: 'none', color: '#f0ead8', fontSize: '1.35rem', cursor: 'pointer' }} aria-label="Close study modal">×</button>
<h2 id="study-modal-heading">{studyModal === 'enrollments' ? 'Manage Enrollments' : 'Browse All Tracks'}</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>
{studyModal === 'enrollments'
? 'Review and change the study tracks you are enrolled in. Use the buttons below to join or leave each track.'
: 'See every study track available now. Enroll in a study directly from this popup and start right away.'}
</p>
{auth.authenticated && overview ? (
studyModal === 'enrollments' ? (
enrolledStudies.length > 0 ? (
<div style={{ display: 'grid', gap: '1rem' }}>
{enrolledStudies.map(studyInfo => (
<div key={studyInfo.slug} style={{ padding: '1rem', borderRadius: '12px', background: '#14130f', border: '1px solid #2a2518' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'flex-start' }}>
<div>
<p style={{ margin: 0, fontSize: '0.9rem', color: '#b09e79' }}>{studyInfo.status === 'active' ? 'Active study' : 'Planned study'}</p>
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>
</div>
<button
type="button"
className="btn-admin-remove"
disabled={enrollBusySlug === studyInfo.slug}
onClick={() => handleEnrollmentToggle(studyInfo.slug, true)}
>
{enrollBusySlug === studyInfo.slug ? 'Working…' : 'Leave'}
</button>
</div>
</div>
))}
</div>
) : (
<div style={{ padding: '1rem', background: '#14130f', borderRadius: '12px', border: '1px solid #2a2518' }}>
<p style={{ margin: 0 }}>You are not enrolled in any tracks yet. Use Browse All Tracks to find a study and enroll.</p>
<button type="button" className="btn-primary" style={{ marginTop: '1rem' }} onClick={() => setStudyModal('browseTracks')}>Browse All Tracks</button>
</div>
)
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{availableStudies.map(studyInfo => (
<div key={studyInfo.slug} style={{ padding: '1rem', borderRadius: '12px', background: '#14130f', border: '1px solid #2a2518' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'flex-start' }}>
<div>
<p style={{ margin: 0, fontSize: '0.9rem', color: '#b09e79' }}>{studyInfo.status === 'active' ? 'Active study' : 'Planned study'}</p>
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>
</div>
<button
type="button"
className={studyInfo.enrolled ? 'btn-admin-remove' : 'btn-primary'}
disabled={enrollBusySlug === studyInfo.slug}
onClick={() => handleEnrollmentToggle(studyInfo.slug, studyInfo.enrolled)}
>
{enrollBusySlug === studyInfo.slug ? 'Working…' : studyInfo.enrolled ? 'Leave' : 'Enroll'}
</button>
</div>
</div>
))}
</div>
)
) : auth.authenticated ? (
<p>No study information is available yet. Please refresh the page or visit your account to load your enrollments.</p>
) : (
<div style={{ padding: '1rem', background: '#14130f', borderRadius: '12px', border: '1px solid #2a2518' }}>
<p style={{ margin: 0 }}>Sign in to manage your enrollments and browse tracks from within the app.</p>
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<Link to="/study/signup" className="btn-primary">Sign In</Link>
<button type="button" className="btn-secondary" onClick={closeStudyModal}>Close</button>
</div>
</div>
)}
{enrollMessage && <p className="study-note-status" style={{ marginTop: '1rem' }}>{enrollMessage}</p>}
</div>
</div>
)}
<section id="my-tracks" className="section-study-module" aria-label="Your enrolled tracks" style={{ paddingTop: '1rem' }}>
<div className="section-inner">
<div className="study-module-header">
<p>Your Classroom</p>
<h2>Enrolled Tracks</h2>
</div>
{totalEnrolled === 0 ? (
<article className="study-class-block">
<h2>No enrollments yet</h2>
<p className="study-detail-copy">You are signed in, but you have not enrolled in any study track yet. Browse the available tracks and join the one you want to start.</p>
<button type="button" className="btn-primary" onClick={() => setStudyModal('browseTracks')}>Browse Tracks</button>
</article>
) : (
<div className="study-module-list">
{enrolledStudies.map(study => {
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 className="study-module-row-left">
<p className="study-module-lesson">{study.enrolled ? 'Enrolled' : 'Track'}</p>
<h3>{study.title}</h3>
<p>{studyMeta?.description ?? 'Continue your study with the lessons and notes available in this track.'}</p>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginTop: '0.75rem' }}>
<span style={{ color: '#7a7060', fontSize: '0.9rem' }}>{study.completedLessons}/{study.totalLessons} lessons completed</span>
<span style={{ color: '#7a7060', fontSize: '0.9rem' }}>{study.noteCount} notes</span>
</div>
<div style={{ marginTop: '0.75rem', height: '10px', width: '100%', background: '#27231b', borderRadius: '999px', overflow: 'hidden' }}>
<div style={{ width: `${progress}%`, height: '100%', background: '#e0c070' }} />
</div>
</div>
<div className="study-module-row-right" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<div>
<p className="study-module-focus">Progress</p>
<p>{progress}% complete</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '1rem' }}>
<Link to={`/study/${study.slug}`} className="btn-primary">Continue</Link>
<Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>
<Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>
</div>
</div>
</article>
)
})}
</div>
)}
</div>
</section>
</main>
)
}
return (
<main className="study-index-page" aria-label="Study hub">
<section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner">
<p className="eyebrow">Self-Paced Bible Academy</p>
<h1>Study at Your Own Pace</h1>
<p className="study-course-hero-copy">Pick a study track, move lesson by lesson on your own schedule, and keep personal notes as you grow through each passage.</p>
<div className="study-course-meta">
<span>{studies.length} study tracks</span>
<span>Self-paced flow</span>
<span>Personal notes</span>
<span>Audio + commentary</span>
</div>
<div className="study-course-hero-actions">
{firstActiveStudyFirstReleasedSection && <Link to={`/study/${firstActiveStudy.slug}/${firstActiveStudyFirstReleasedSection.id}`} className="btn-primary">Start Learning</Link>}
<Link to="/study/signup" className="btn-primary">Create Account or Login</Link>
<Link to="/study/account" className="btn-secondary">My Account</Link>
</div>
</div>
</section>
<section className="section-study-hub-flow" aria-label="How self-paced study works">
<div className="section-inner">
<div className="study-module-header">
<p>How It Works</p>
<h2>A Simple Self-Paced Rhythm</h2>
</div>
<div className="study-hub-flow-grid">
<article className="study-hub-flow-card">
<p className="study-module-lesson">Step 1</p>
<h3>Choose Your Track</h3>
<p>Start with any active study and begin at lesson one, or jump back in where you left off.</p>
</article>
<article className="study-hub-flow-card">
<p className="study-module-lesson">Step 2</p>
<h3>Work Each Lesson</h3>
<p>Read the text, listen to audio, review commentary, and process key Greek word notes.</p>
</article>
<article className="study-hub-flow-card">
<p className="study-module-lesson">Step 3</p>
<h3>Save Notes and Continue</h3>
<p>Keep personal notes per lesson and build your own study archive over time.</p>
</article>
</div>
</div>
</section>
<section id="study-tracks" className="section-study-module" aria-label="Available studies">
<div className="section-inner">
<div className="study-module-header">
<p>Available Now</p>
<h2>Current Study Tracks</h2>
</div>
<div className="study-module-list">
{activeStudies.map(study => (
(() => {
const releasedCount = study.sections.filter(isSectionReleased).length
return (
<Link key={study.id} to={`/study/${study.slug}`} className="study-module-row">
<div className="study-module-row-left">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem', flexWrap: 'wrap' }}>
<p className="study-module-lesson">Start Anytime</p>
{study.difficulty && <span style={{ backgroundColor: study.difficulty === 'advanced' ? '#d32f2f' : (study.difficulty === 'intermediate' ? '#f57c00' : '#388e3c'), color: '#fff', padding: '0.25rem 0.75rem', borderRadius: '16px', fontSize: '0.75rem', fontWeight: '600', textTransform: 'capitalize' }}>{study.difficulty}</span>}
</div>
<h3>{study.title}</h3>
<p>{study.description}</p>
{study.estimatedHours && <p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}> {study.estimatedHours} hours</p>}
</div>
<div className="study-module-row-right">
<p className="study-module-focus">Track Snapshot</p>
<p>{releasedCount > 0 ? `${releasedCount} lessons available now` : 'Lessons will be published soon.'}</p>
<p>{study.sections.length > 0 ? 'Estimated pace: every other Monday' : 'Pacing details coming with first lesson release.'}</p>
</div>
</Link>
)
})()
))}
</div>
</div>
</section>
{plannedStudies.length > 0 && (
<section className="section-study-module" aria-label="Upcoming studies">
<div className="section-inner">
<div className="study-module-header">
<p>Coming Soon</p>
<h2>Next Study Tracks</h2>
</div>
<div className="study-module-list">
{plannedStudies.map(study => (
<article key={study.id} className="study-module-row study-module-row--disabled" aria-disabled="true">
<div className="study-module-row-left">
<p className="study-module-lesson">Planned</p>
<h3>{study.title}</h3>
<p>{study.description}</p>
</div>
<div className="study-module-row-right">
<p className="study-module-focus">Status</p>
<p>Preparing lesson structure and media.</p>
</div>
</article>
))}
</div>
</div>
</section>
)}
</main>
)
}
export function StudySignupPage() {
const navigate = useNavigate()
const [mode, setMode] = useState<'signup' | 'login'>('login')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [subscribeNewsletter, setSubscribeNewsletter] = useState(true)
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
const [done, setDone] = useState(false)
const [loggedInAs, setLoggedInAs] = useState('')
useEffect(() => {
readJson<StudyAuthStatusResponse>('/api/study-auth/status')
.then(data => {
if (data.authenticated) {
setDone(true)
setLoggedInAs(data.username ?? '')
}
})
.catch(() => {})
}, [])
const submit = useCallback(async () => {
if (!email.trim() || !password.trim()) {
setMessage('Please enter your email and password.')
return
}
setBusy(true)
setMessage('')
try {
const data = await readJson<{ username: string; enrolledStudySlugs?: string[] }>(`/api/study-auth/${mode}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: email, password, ...(mode === 'signup' ? { subscribe: subscribeNewsletter } : {}) }),
})
setDone(true)
setLoggedInAs(data.username ?? email.trim().toLowerCase())
navigate('/study')
} catch (err) {
setMessage(err instanceof Error ? err.message : 'Something went wrong. Please try again.')
} finally {
setBusy(false)
}
}, [mode, email, password, subscribeNewsletter])
return (
<main className="thanks-page" aria-label={mode === 'signup' ? 'Create account' : 'Sign in'}>
<div className="thanks-card study-signup-page-card">
<p className="eyebrow">Free Student Account</p>
<h1>{mode === 'signup' ? 'Create Your Account' : 'Welcome Back'}</h1>
{done ? (
<>
<p className="study-signup-success">You're signed in as <strong>{loggedInAs}</strong>. Your notes are ready on every lesson.</p>
<div className="study-signup-actions">
<button type="button" className="btn-primary" onClick={() => navigate('/study')}>Go to Study Hub</button>
<Link to="/study/account" className="btn-secondary">My Account</Link>
<Link to="/study" className="btn-secondary">Browse Tracks</Link>
</div>
</>
) : (
<>
<div className="study-signup-form">
<label htmlFor="signup-email">Email</label>
<input
id="signup-email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="your@email.com"
autoComplete="email"
autoFocus
/>
<label htmlFor="signup-password">Password</label>
<input
id="signup-password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="At least 8 characters"
autoComplete={mode === 'signup' ? 'new-password' : 'current-password'}
onKeyDown={e => e.key === 'Enter' && submit()}
/>
{mode === 'signup' && (
<label className="contact-consent" style={{ marginTop: '0.75rem' }}>
<input
type="checkbox"
checked={subscribeNewsletter}
onChange={e => setSubscribeNewsletter(e.target.checked)}
/>
<span>Send me updates from Verse by Verse with Nate. I can unsubscribe anytime.</span>
</label>
)}
{message && <p className="study-signup-error">{message}</p>}
<div className="study-signup-actions">
<button type="button" className="btn-primary" disabled={busy} onClick={submit}>
{busy ? (mode === 'signup' ? 'Creating...' : 'Signing in...') : (mode === 'signup' ? 'Create Account' : 'Sign In')}
</button>
</div>
<div className="study-signup-actions" style={{ marginTop: '1rem' }}>
{mode === 'login' && (
<button type="button" className="btn-secondary" onClick={() => { setMode('signup'); setMessage('') }}>
Don't have an account? Sign up here
</button>
)}
{mode === 'signup' && (
<button type="button" className="btn-secondary" onClick={() => { setMode('login'); setMessage('') }}>
Already have an account? Sign in instead
</button>
)}
</div>
</div>
<div className="study-signup-why-grid" style={{ marginTop: '2rem' }}>
<article className="study-signup-why-card">
<p className="study-signup-why-icon" aria-hidden="true">📝</p>
<h3>Notes on Every Lesson</h3>
<p>Save your own notes per lesson up to 500 notes across all tracks.</p>
</article>
<article className="study-signup-why-card">
<p className="study-signup-why-icon" aria-hidden="true">📖</p>
<h3>Space to Go Deep</h3>
<p>Each note holds ~2,000 words so you can write as much as you need.</p>
</article>
<article className="study-signup-why-card">
<p className="study-signup-why-icon" aria-hidden="true">🔒</p>
<h3>Private to You</h3>
<p>Your notes are tied to your account. Nobody else can read them.</p>
</article>
<article className="study-signup-why-card">
<p className="study-signup-why-icon" aria-hidden="true"></p>
<h3>Free Forever</h3>
<p>Just an email and password. No charge, no ads. Stay signed in 30 days.</p>
</article>
</div>
</>
)}
<div className="study-signup-actions" style={{ marginTop: '1.5rem' }}>
<Link to="/study/account" className="btn-secondary">My Account</Link>
<Link to="/study" className="btn-secondary">Back to Studies</Link>
</div>
</div>
</main>
)
}
export function ColossiansStudyIndexPage({ content }: Props) {
const { studySlug } = useParams<{ studySlug?: string }>()
const studies = getStudies(content)
const study = getStudyBySlug(studies, studySlug)
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
readJson<StudyAuthStatusResponse>('/api/study-auth/status')
.then(data => {
if (cancelled) return
setAuth({
checked: true,
authenticated: Boolean(data.authenticated),
username: data.username ?? '',
enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs),
})
})
.catch(() => {
if (cancelled) return
setAuth({ checked: true, authenticated: false, username: '', enrolledStudySlugs: [] })
})
return () => {
cancelled = true
}
}, [])
if (!study) {
return (
<main className="thanks-page" aria-label="Study not found">
<div className="thanks-card">
<p className="eyebrow">Study Hub</p>
<h1>Study not found</h1>
<p>The study you requested is not available yet.</p>
<Link to="/study" className="btn-primary">Back to Studies</Link>
</div>
</main>
)
}
const sections = study.sections
const chapterSummaries = getChapterSummaries(study)
const releasedSections = sections.filter(isSectionReleased)
const firstReleasedSection = releasedSections[0]
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('')
setEnrolling(true)
try {
const response = await readJson<{ enrolledStudySlugs?: string[]; studyTitle?: string }>(`/api/study-enrollment/${encodeURIComponent(study.slug)}`, {
method: 'POST',
})
setAuth(prev => ({
...prev,
enrolledStudySlugs: normalizeEnrolledStudySlugs(response.enrolledStudySlugs),
}))
setEnrollMessage(response.studyTitle ? `You are enrolled in ${response.studyTitle}.` : 'You are now enrolled.')
} catch (err) {
setEnrollMessage(err instanceof Error ? err.message : 'Unable to enroll right now.')
} finally {
setEnrolling(false)
}
}
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">
<p className="eyebrow">Online Bible Class</p>
<h1>{study.title}</h1>
<p className="study-course-hero-copy">{study.description}</p>
<div className="study-course-meta">
<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>
<div className="study-course-hero-actions">
{enrolled && firstReleasedSection && <Link to={`/study/${study.slug}/${firstReleasedSection.id}`} className="btn-primary">Start Class</Link>}
{!auth.authenticated && <Link to="/study/signup" className="btn-primary">Sign In to Enroll</Link>}
{auth.authenticated && !enrolled && (
<button type="button" className="btn-primary" disabled={enrolling} onClick={enrollInStudy}>
{enrolling ? 'Enrolling...' : 'Enroll in This Study'}
</button>
)}
{auth.authenticated && <Link to="/study/account" className="btn-secondary">My Account</Link>}
{enrolled && <Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>}
{enrolled && <Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>}
<Link to="/study" className="btn-secondary">Back to Studies</Link>
</div>
{enrollMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{enrollMessage}</p>}
</div>
</section>
{auth.checked && auth.authenticated && !enrolled && (
<section className="section-study-module" aria-label="Enrollment required">
<div className="section-inner">
<article className="study-module-row study-module-row--disabled" aria-disabled="true">
<div className="study-module-row-left">
<p className="study-module-lesson">Enrollment Required</p>
<h3>Enroll before entering lessons</h3>
<p>You must enroll in this study before opening lessons or notes.</p>
<div style={{ marginTop: '0.75rem' }}>
<button type="button" className="btn-primary" disabled={enrolling} onClick={enrollInStudy}>
{enrolling ? 'Enrolling...' : `Enroll in ${study.title}`}
</button>
</div>
</div>
</article>
</div>
</section>
)}
{sections.length === 0 && (
<section className="section-study-module">
<div className="section-inner">
<article className="study-module-row study-module-row--disabled" aria-disabled="true">
<div className="study-module-row-left">
<p className="study-module-lesson">Planned</p>
<h3>Lessons are being prepared</h3>
<p>Use the admin Studies editor to add section lessons and publish when ready.</p>
</div>
</article>
</div>
</section>
)}
{sections.length > 0 && (
<section className="section-study-module" aria-label="Course lessons">
<div className="section-inner">
<div className="study-module-header">
<p>Course Lessons</p>
<h2>{study.title}</h2>
</div>
<div className="study-module-list">
{sections.map(section => {
const lessonNumber = getLessonNumber(sections, section.id)
const isNew = isNewLesson(section)
const coming = isComingSoon(section)
const isDisabled = coming || !enrolled
return (
<Link key={section.id} to={isDisabled ? '#' : `/study/${study.slug}/${section.id}`} className={`study-module-row${isDisabled ? ' study-module-row--disabled' : ''}`} onClick={e => isDisabled && e.preventDefault()}>
<div className="study-module-row-left">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<p className="study-module-lesson">Lesson {lessonNumber}</p>
{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>
</div>
<div className="study-module-row-right">
<p className="study-section-reference">{section.reference}</p>
<p className="study-module-focus">Focus question</p>
<p>{getPrimaryQuestion(section)}</p>
</div>
</Link>
)
})}
</div>
</div>
</section>
)}
</main>
)
}
export function ColossiansStudySectionPage({ content }: Props) {
const { studySlug, sectionId } = useParams<{ studySlug?: string; sectionId: string }>()
const studies = getStudies(content)
const study = getStudyBySlug(studies, studySlug)
const sections = study?.sections ?? []
const section = getSectionById(sections, sectionId)
const sectionIndex = sections.findIndex(item => item.id === sectionId)
const lessonNumber = getLessonNumber(sections, sectionId)
const previousSection = sectionIndex > 0 ? sections[sectionIndex - 1] : null
const nextSection = sectionIndex >= 0 && sectionIndex < sections.length - 1 ? sections[sectionIndex + 1] : null
const sectionIsReleased = section ? isSectionReleased(section) : false
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [] })
const [usernameInput, setUsernameInput] = useState('')
const [passwordInput, setPasswordInput] = useState('')
const [authBusy, setAuthBusy] = useState(false)
const [authMessage, setAuthMessage] = useState('')
const [enrollBusy, setEnrollBusy] = useState(false)
const [enrollMessage, setEnrollMessage] = useState('')
const [noteText, setNoteText] = useState('')
const [noteLoading, setNoteLoading] = useState(false)
const [noteSaving, setNoteSaving] = useState(false)
const [noteMessage, setNoteMessage] = useState('')
const [notesModalOpen, setNotesModalOpen] = useState(false)
const [contextNoteMenu, setContextNoteMenu] = useState<{ x: number; y: number; text: string } | null>(null)
const [completedSectionIds, setCompletedSectionIds] = useState<string[]>([])
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(() => {
const trimmed = section?.audioEmbedUrl?.trim() ?? ''
if (!trimmed) return ''
return /^https?:\/\//i.test(trimmed) ? trimmed : ''
}, [section?.audioEmbedUrl])
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'
}, [section, study])
useEffect(() => {
let cancelled = false
readJson<StudyAuthStatusResponse>('/api/study-auth/status')
.then(data => {
if (cancelled) return
setAuth({
checked: true,
authenticated: Boolean(data.authenticated),
username: data.username ?? '',
enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs),
})
})
.catch(() => {
if (cancelled) return
setAuth({ checked: true, authenticated: false, username: '', enrolledStudySlugs: [] })
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!auth.checked || !auth.authenticated || !study || !isEnrolled) return
let cancelled = false
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
})
return () => {
cancelled = true
}
}, [auth.checked, auth.authenticated, study, isEnrolled, currentStudySlug])
useEffect(() => {
setCheckpointAnswers({})
setCheckpointReflection('')
}, [section?.id])
useEffect(() => {
if (!auth.authenticated || !noteId || !isEnrolled) {
setNoteText('')
return
}
let cancelled = false
setNoteLoading(true)
setNoteMessage('')
readJson<{ note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`)
.then(data => {
if (cancelled) return
setNoteText(data.note ?? '')
})
.catch(err => {
if (cancelled) return
setNoteMessage(err instanceof Error ? err.message : 'Unable to load your note.')
})
.finally(() => {
if (cancelled) return
setNoteLoading(false)
})
return () => {
cancelled = true
}
}, [auth.authenticated, noteId, isEnrolled])
async function submitAuth(mode: 'login' | 'signup') {
setAuthBusy(true)
setAuthMessage('')
try {
const payload = { username: usernameInput, password: passwordInput }
const data = await readJson<{ username: string; enrolledStudySlugs?: string[] }>(`/api/study-auth/${mode}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
setAuth({
checked: true,
authenticated: true,
username: data.username ?? usernameInput.trim().toLowerCase(),
enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs),
})
setUsernameInput('')
setPasswordInput('')
setAuthMessage(mode === 'signup' ? 'Account created. You can now save notes for each lesson.' : 'Signed in successfully.')
} catch (err) {
setAuthMessage(err instanceof Error ? err.message : 'Sign-in failed.')
} finally {
setAuthBusy(false)
}
}
async function saveNote() {
if (!noteId || !canSaveNote) return
setNoteSaving(true)
setNoteMessage('')
try {
const data = await readJson<{ ok: boolean; note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note: noteText }),
})
setNoteText(data.note ?? '')
setNoteMessage('Notes saved.')
} catch (err) {
setNoteMessage(err instanceof Error ? err.message : 'Unable to save note.')
} finally {
setNoteSaving(false)
}
}
function closeNotesModal() {
setNotesModalOpen(false)
}
function appendSelectedTextToNotes(selectedText: string) {
if (!selectedText.trim()) return
setNoteText(prev => prev ? `${prev.trim()}\n\n${selectedText.trim()}` : selectedText.trim())
setNotesModalOpen(true)
}
function handleLessonContentContextMenu(e: React.MouseEvent<HTMLDivElement>) {
if (!auth.checked || !auth.authenticated || !isEnrolled || !study || !section) return
const selection = window.getSelection()
const selectedText = selection?.toString().trim() ?? ''
if (!selectedText) {
setContextNoteMenu(null)
return
}
e.preventDefault()
setContextNoteMenu({ x: e.clientX + 4, y: e.clientY + 4, text: selectedText })
}
useEffect(() => {
if (!contextNoteMenu) return
const handleClick = () => setContextNoteMenu(null)
window.addEventListener('mousedown', handleClick)
return () => window.removeEventListener('mousedown', handleClick)
}, [contextNoteMenu])
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('')
setEnrollBusy(true)
try {
const response = await readJson<{ enrolledStudySlugs?: string[]; studyTitle?: string }>(`/api/study-enrollment/${encodeURIComponent(study.slug)}`, { method: 'POST' })
setAuth(prev => ({ ...prev, enrolledStudySlugs: normalizeEnrolledStudySlugs(response.enrolledStudySlugs) }))
setEnrollMessage(response.studyTitle ? `You are enrolled in ${response.studyTitle}.` : 'You are now enrolled.')
} catch (err) {
setEnrollMessage(err instanceof Error ? err.message : 'Unable to enroll right now.')
} finally {
setEnrollBusy(false)
}
}
if (!study || !section) {
return (
<main className="thanks-page" aria-label="Section not found">
<div className="thanks-card">
<p className="eyebrow">Study Hub</p>
<h1>Section not found</h1>
<p>The section you requested is not available yet.</p>
<Link to="/study" className="btn-primary">Back to Studies</Link>
</div>
</main>
)
}
if (!sectionIsReleased) {
const releaseDateDisplay = getReleaseDateDisplay(section)
return (
<main className="thanks-page" aria-label="Lesson not yet available">
<div className="thanks-card">
<p className="eyebrow">{study.title}</p>
<h1>Lesson Coming Soon</h1>
<p>{releaseDateDisplay ? `This lesson will be available on ${releaseDateDisplay}.` : 'This lesson will be available soon.'}</p>
<Link to={`/study/${study.slug}`} className="btn-primary">Back to Lessons</Link>
</div>
</main>
)
}
if (!auth.checked) {
return (
<main className="thanks-page" aria-label="Loading">
<div className="thanks-card">
<p className="eyebrow">{study.title}</p>
<p>Loading lesson...</p>
</div>
</main>
)
}
if (!auth.authenticated) {
return (
<main className="thanks-page" aria-label="Sign in required">
<div className="thanks-card">
<p className="eyebrow">{study.title}</p>
<h1>Sign In to Continue</h1>
<p>Create a free account or sign in to access lessons and save your personal notes.</p>
<div className="study-signup-actions" style={{ marginTop: '1.5rem' }}>
<Link to="/study/signup" className="btn-primary">Create Account or Sign In</Link>
<Link to="/study/account" className="btn-secondary">My Account</Link>
<Link to={`/study/${study.slug}`} className="btn-secondary">Back to Lessons</Link>
</div>
</div>
</main>
)
}
if (!isEnrolled) {
return (
<main className="thanks-page" aria-label="Enrollment required">
<div className="thanks-card">
<p className="eyebrow">{study.title}</p>
<h1>Enrollment Required</h1>
<p>You need to enroll in this study before entering lessons.</p>
<div className="study-signup-actions" style={{ marginTop: '1.5rem' }}>
<button type="button" className="btn-primary" disabled={enrollBusy} onClick={enrollInCurrentStudy}>
{enrollBusy ? 'Enrolling...' : `Enroll in ${study.title}`}
</button>
<Link to="/study/account" className="btn-secondary">My Account</Link>
<Link to={`/study/${study.slug}`} className="btn-secondary">Back to Study</Link>
</div>
{enrollMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{enrollMessage}</p>}
</div>
</main>
)
}
return (
<main className="study-section-page" aria-label={section.title}>
<section className="section-study-classroom" onContextMenu={handleLessonContentContextMenu}>
<div className="section-inner study-classroom-shell">
<div className="study-classroom-main">
<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>
<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>
<div className="study-audio-embed-wrap">
<iframe
src={lessonAudioEmbedUrl}
title={`${section.title} audio`}
width="100%"
height="152"
frameBorder="0"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
/>
</div>
</article>
)}
<article className="study-class-block">
<h2>Scripture Text</h2>
<p className="study-detail-copy study-detail-copy--scripture">{section.passageText || `Add the passage text for ${section.reference} here when you move the guide online.`}</p>
</article>
<article className="study-class-block">
<h2>Instructor Commentary</h2>
<p className="study-detail-copy">{section.commentary}</p>
</article>
<article className="study-class-block">
<h2>Discussion Questions</h2>
<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">Take the Quiz</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">
<article className="study-class-side-block">
<h3>Study Track</h3>
<p>{study.title}</p>
<p>{study.description}</p>
</article>
<article className="study-class-side-block">
<h3>Greek Word Study</h3>
{section.greekNotes.length > 0 ? (
<ul className="study-detail-list">
{section.greekNotes.map((note, index) => (
<li key={index}>{note}</li>
))}
</ul>
) : (
<p className="study-detail-copy">Greek notes will be added for this lesson.</p>
)}
</article>
<article className="study-class-side-block">
<h3>Student Notes</h3>
{!auth.checked && <p className="study-detail-copy">Checking sign-in status...</p>}
{auth.checked && !auth.authenticated && (
<div className="study-auth-box">
<p className="study-auth-why">Sign in to save notes for this lesson.</p>
<label htmlFor="study-email">Email</label>
<input id="study-email" type="email" value={usernameInput} onChange={e => setUsernameInput(e.target.value)} placeholder="your@email.com" autoComplete="email" />
<label htmlFor="study-password">Password</label>
<input id="study-password" type="password" value={passwordInput} onChange={e => setPasswordInput(e.target.value)} placeholder="At least 8 characters" autoComplete="current-password" />
<div className="study-auth-actions">
<button type="button" className="btn-secondary" disabled={authBusy} onClick={() => submitAuth('login')}>Sign In</button>
<button type="button" className="btn-primary" disabled={authBusy} onClick={() => submitAuth('signup')}>Create Account</button>
</div>
</div>
)}
{auth.checked && auth.authenticated && (
<div className="study-notes-box">
<p className="study-notes-user">Signed in as {auth.username}</p>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.5rem' }}>
<button type="button" className="btn-primary" onClick={() => setNotesModalOpen(true)}>Open Notes</button>
<Link to="/study/account" className="btn-secondary">My Account</Link>
</div>
<p style={{ margin: '0.5rem 0 0', color: '#b9b09b', fontSize: '0.9rem' }}>Highlight scripture, commentary, or Greek word text, right click, and add it to your notes.</p>
{noteLoading ? <p style={{ marginTop: '0.75rem' }}>Loading notes</p> : null}
</div>
)}
{(authMessage || noteMessage || progressMessage) && <p className="study-note-status">{authMessage || noteMessage || progressMessage}</p>}
</article>
<article className="study-class-side-block">
<h3>Lesson Navigation</h3>
<div className="study-detail-nav">
{previousSection ? (
<Link to={`/study/${study.slug}/${previousSection.id}`} className="btn-secondary">Previous Lesson</Link>
) : <span className="study-nav-placeholder" />}
{nextSection && isSectionReleased(nextSection) ? (
<Link to={`/study/${study.slug}/${nextSection.id}`} className="btn-secondary">Next Lesson</Link>
) : nextSection ? (
<span className="study-nav-placeholder">Available {getReleaseDateDisplay(nextSection)}</span>
) : <span className="study-nav-placeholder" />}
</div>
</article>
</aside>
{contextNoteMenu && (
<div
style={{
position: 'fixed',
left: contextNoteMenu.x,
top: contextNoteMenu.y,
zIndex: 50,
backgroundColor: '#1f1d19',
border: '1px solid #3f3b2f',
borderRadius: '12px',
padding: '0.75rem',
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
minWidth: '220px',
}}
onMouseDown={e => e.stopPropagation()}
>
<p style={{ margin: '0 0 0.75rem', color: '#ece3c6', fontSize: '0.95rem' }}>Add selected text to notes:</p>
<div style={{ marginBottom: '0.75rem', maxHeight: '100px', overflow: 'auto', color: '#f0ead8', fontSize: '0.9rem' }}>
{contextNoteMenu.text}
</div>
<button
type="button"
className="btn-primary"
onClick={() => {
appendSelectedTextToNotes(contextNoteMenu.text)
setContextNoteMenu(null)
}}
>
Add to Notes
</button>
</div>
)}
{notesModalOpen && (
<div
className="study-modal-overlay"
role="dialog"
aria-modal="true"
aria-label="Lesson notes editor"
onClick={closeNotesModal}
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(12, 11, 10, 0.75)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 40,
padding: '1rem',
}}
>
<div
className="study-modal"
onClick={e => e.stopPropagation()}
style={{
width: 'min(760px, 100%)',
maxHeight: 'min(90vh, 900px)',
overflowY: 'auto',
backgroundColor: '#1b1a16',
border: '1px solid #3c3a31',
borderRadius: '18px',
padding: '1.5rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
<div>
<h2 style={{ margin: 0 }}>Lesson Notes</h2>
<p style={{ margin: '0.5rem 0 0', color: '#b9b09b' }}>Save notes for this lesson and paste selections from the page.</p>
</div>
<button type="button" className="btn-secondary" onClick={closeNotesModal}>Close</button>
</div>
<textarea
rows={12}
value={noteText}
onChange={e => setNoteText(e.target.value)}
placeholder="Write your notes here..."
style={{
width: '100%',
minHeight: '280px',
padding: '1rem',
borderRadius: '16px',
border: '1px solid #3f3b2f',
background: '#14130f',
color: '#f0ead8',
fontSize: '1rem',
lineHeight: '1.6',
}}
/>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', justifyContent: 'flex-end', marginTop: '1rem' }}>
<button type="button" className="btn-primary" onClick={saveNote} disabled={!canSaveNote}>
{noteSaving ? 'Saving...' : 'Save Notes'}
</button>
<button type="button" className="btn-secondary" onClick={closeNotesModal}>Cancel</button>
</div>
{(noteMessage || progressMessage) && <p className="study-note-status" style={{ marginTop: '1rem' }}>{noteMessage || progressMessage}</p>}
</div>
</div>
)}
</div>
</section>
</main>
)
}
export function ColossiansStudyNotesPage({ content }: Props) {
const { studySlug } = useParams<{ studySlug?: string }>()
const studies = getStudies(content)
const study = getStudyBySlug(studies, studySlug)
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [] })
const [notes, setNotes] = useState<StudyNotesMap>({})
const [loading, setLoading] = useState(true)
const [message, setMessage] = useState('')
useEffect(() => {
document.title = study ? `My Notes | ${study.title}` : 'My Study Notes'
}, [study])
useEffect(() => {
let cancelled = false
async function load() {
try {
const authData = await readJson<StudyAuthStatusResponse>('/api/study-auth/status')
if (cancelled) return
if (!authData.authenticated) {
setAuth({ checked: true, authenticated: false, username: '', enrolledStudySlugs: [] })
setLoading(false)
return
}
const enrolledStudySlugs = normalizeEnrolledStudySlugs(authData.enrolledStudySlugs)
setAuth({
checked: true,
authenticated: true,
username: authData.username ?? '',
enrolledStudySlugs,
})
if (!enrolledStudySlugs.includes((study?.slug ?? '').toLowerCase())) {
setLoading(false)
return
}
const notesData = await readJson<{ notes: StudyNotesMap }>('/api/study-notes')
if (cancelled) return
setNotes(notesData.notes ?? {})
} catch (err) {
if (cancelled) return
setMessage(err instanceof Error ? err.message : 'Unable to load notes.')
} finally {
if (cancelled) return
setLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, [])
if (!study) {
return (
<main className="thanks-page" aria-label="Study not found">
<div className="thanks-card">
<p className="eyebrow">Study Hub</p>
<h1>Study not found</h1>
<p>The study you requested is not available yet.</p>
<Link to="/study" className="btn-primary">Back to Studies</Link>
</div>
</main>
)
}
const prefix = `${study.slug}--`
const enrolled = isEnrolledInStudy(auth, study.slug)
const entries = Object.entries(notes)
.filter(([key]) => key.startsWith(prefix))
.map(([key, note]) => {
const sectionId = key.slice(prefix.length)
const section = study.sections.find(item => item.id === sectionId)
return { sectionId, section, note }
})
.filter(item => item.note && item.note.trim())
return (
<main className="study-section-page" aria-label="My study notes">
<section className="section-study-classroom">
<div className="section-inner">
<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>
{!loading && auth.authenticated && <p className="study-detail-summary">Signed in as {auth.username}</p>}
{!loading && auth.authenticated && (
<div className="study-signup-actions" style={{ marginBottom: '1rem' }}>
<Link to="/study/account" className="btn-secondary">My Account</Link>
<Link to={`/study/${study.slug}`} className="btn-secondary">Back to Lessons</Link>
</div>
)}
{loading && <p className="study-detail-copy">Loading your notes...</p>}
{!loading && !auth.authenticated && (
<article className="study-class-block">
<h2>Sign In Required</h2>
<p className="study-detail-copy">Open any lesson and sign in from the Student Notes panel to see your saved notes here.</p>
<Link to="/study/signup" className="btn-primary">Create Account or Sign In</Link>
</article>
)}
{!loading && auth.authenticated && !enrolled && (
<article className="study-class-block">
<h2>Enrollment Required</h2>
<p className="study-detail-copy">You need to enroll in this study before accessing your notes.</p>
<Link to={`/study/${study.slug}`} className="btn-primary">Go to Study Enrollment</Link>
</article>
)}
{!loading && auth.authenticated && enrolled && entries.length === 0 && (
<article className="study-class-block">
<h2>No Notes Yet</h2>
<p className="study-detail-copy">You have not saved notes yet. Open a lesson and use the Student Notes area to start.</p>
{study.sections[0] && <Link to={`/study/${study.slug}/${study.sections[0].id}`} className="btn-primary">Open First Lesson</Link>}
</article>
)}
{!loading && auth.authenticated && enrolled && entries.length > 0 && (
<div className="study-module-list">
{entries.map(({ sectionId, section, note }) => (
<article key={sectionId} className="study-module-row">
<div className="study-module-row-left">
<p className="study-module-lesson">{section ? `Lesson ${getLessonNumber(study.sections, section.id)}` : 'Saved Note'}</p>
<h3>{section?.title ?? sectionId}</h3>
<p>{section ? section.reference : 'Lesson reference unavailable'}</p>
</div>
<div className="study-module-row-right">
<p className="study-module-focus">Your Note</p>
<p className="study-detail-copy study-detail-copy--scripture">{note}</p>
{section && <Link to={`/study/${study.slug}/${section.id}`} className="btn-secondary">Open Lesson</Link>}
</div>
</article>
))}
</div>
)}
{message && <p className="study-note-status">{message}</p>}
</div>
</section>
</main>
)
}
export function StudyAccountPage() {
const navigate = useNavigate()
const location = useLocation()
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [], displayName: '', subscribeNewsletter: true })
const [overview, setOverview] = useState<StudyAccountOverview | null>(null)
const [loadingOverview, setLoadingOverview] = useState(false)
const [displayNameInput, setDisplayNameInput] = useState('')
const [avatarUrlInput, setAvatarUrlInput] = useState('')
const [profileMessage, setProfileMessage] = useState('')
const [profileBusy, setProfileBusy] = useState(false)
const [avatarUploadBusy, setAvatarUploadBusy] = useState(false)
const [avatarUploadMessage, setAvatarUploadMessage] = useState('')
const [subscribeNewsletter, setSubscribeNewsletter] = useState(true)
const [studyRemindersEnabled, setStudyRemindersEnabled] = useState(true)
const [prefMessage, setPrefMessage] = useState('')
const [prefBusy, setPrefBusy] = useState(false)
const [accountModal, setAccountModal] = useState<'none' | 'enrollments' | 'changeEmail' | 'changePassword' | 'profile' | 'preferences' | 'export'>('none')
const [emailCurrentPassword, setEmailCurrentPassword] = useState('')
const [emailNew, setEmailNew] = useState('')
const [emailTokenInput, setEmailTokenInput] = useState('')
const [emailMessage, setEmailMessage] = useState('')
const [emailBusy, setEmailBusy] = useState(false)
const [enrollBusySlug, setEnrollBusySlug] = useState('')
const [enrollMessage, setEnrollMessage] = useState('')
const [exporting, setExporting] = useState(false)
const [exportMessage, setExportMessage] = useState('')
const [pwCurrent, setPwCurrent] = useState('')
const [pwNew, setPwNew] = useState('')
const [pwConfirm, setPwConfirm] = useState('')
const [pwBusy, setPwBusy] = useState(false)
const [pwMessage, setPwMessage] = useState('')
const [pwSuccess, setPwSuccess] = useState(false)
const [signOutBusy, setSignOutBusy] = useState(false)
const [deleteStep, setDeleteStep] = useState<'idle' | 'warn1' | 'warn2'>('idle')
const [deleteConfirmInput, setDeleteConfirmInput] = useState('')
const [deleteBusy, setDeleteBusy] = useState(false)
const [deleteMessage, setDeleteMessage] = useState('')
useEffect(() => {
document.title = 'My Account | Study Hub'
readJson<StudyAuthStatusResponse>('/api/study-auth/status')
.then(data => {
setAuth({
checked: true,
authenticated: Boolean(data.authenticated),
username: data.username ?? '',
displayName: data.displayName ?? '',
subscribeNewsletter: data.subscribeNewsletter !== false,
studyRemindersEnabled: data.studyRemindersEnabled === true,
avatarUrl: data.avatarUrl ?? '',
enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs),
})
})
.catch(() => setAuth({ checked: true, authenticated: false, username: '', enrolledStudySlugs: [], displayName: '', subscribeNewsletter: true }))
}, [])
useEffect(() => {
if (!auth.authenticated) return
setLoadingOverview(true)
readJson<StudyAccountOverview>('/api/study-account/overview')
.then(data => {
setOverview(data)
setDisplayNameInput(data.profile.displayName ?? '')
setAvatarUrlInput(data.profile.avatarUrl ?? '')
setSubscribeNewsletter(data.profile.subscribeNewsletter !== false)
setStudyRemindersEnabled(data.profile.studyRemindersEnabled === true)
})
.catch(() => {})
.finally(() => setLoadingOverview(false))
}, [auth.authenticated])
useEffect(() => {
if (!auth.authenticated) return
const token = new URLSearchParams(location.search).get('verifyEmailToken')
if (!token) return
setEmailTokenInput(token)
setEmailBusy(true)
readJson<{ ok: boolean; username: string }>('/api/study-account/verify-email-change', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
.then(data => {
setEmailMessage('Email address verified and updated successfully.')
setAuth(prev => ({ ...prev, username: data.username }))
navigate('/study/account', { replace: true })
})
.catch(err => setEmailMessage(err instanceof Error ? err.message : 'Unable to verify email token.'))
.finally(() => setEmailBusy(false))
}, [auth.authenticated, location.search, navigate])
async function refreshOverview() {
const data = await readJson<StudyAccountOverview>('/api/study-account/overview')
setOverview(data)
setDisplayNameInput(data.profile.displayName ?? '')
setAvatarUrlInput(data.profile.avatarUrl ?? '')
setSubscribeNewsletter(data.profile.subscribeNewsletter !== false)
setStudyRemindersEnabled(data.profile.studyRemindersEnabled === true)
}
async function handleAvatarUpload(file: File) {
setAvatarUploadBusy(true)
setAvatarUploadMessage('')
try {
if (!file.type.startsWith('image/')) {
throw new Error('Please upload an image file.')
}
const reader = new FileReader()
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () => {
if (typeof reader.result === 'string') resolve(reader.result)
else reject(new Error('Unable to read image file.'))
}
reader.onerror = () => reject(new Error('Failed to read image file.'))
reader.readAsDataURL(file)
})
const result = await readJson<{ ok: boolean; url: string }>('/api/study-account/avatar-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, dataUrl }),
})
setAvatarUrlInput(result.url)
setAvatarUploadMessage('Avatar uploaded successfully. Save profile to keep it.')
} catch (err) {
setAvatarUploadMessage(err instanceof Error ? err.message : 'Unable to upload avatar.')
} finally {
setAvatarUploadBusy(false)
}
}
async function handleProfileSave() {
setProfileBusy(true)
setProfileMessage('')
try {
const data = await readJson<{ ok: boolean; displayName: string; avatarUrl?: string }>('/api/study-account/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName: displayNameInput, avatarUrl: avatarUrlInput.trim() }),
})
setAuth(prev => ({ ...prev, displayName: data.displayName, avatarUrl: data.avatarUrl ?? prev.avatarUrl }))
if (data.avatarUrl) {
setAvatarUrlInput(data.avatarUrl)
}
setProfileMessage('Profile saved.')
await refreshOverview()
} catch (err) {
setProfileMessage(err instanceof Error ? err.message : 'Unable to save profile.')
} finally {
setProfileBusy(false)
}
}
async function handlePreferenceSave() {
setPrefBusy(true)
setPrefMessage('')
try {
const data = await readJson<{ ok: boolean; subscribeNewsletter: boolean; studyRemindersEnabled: boolean }>('/api/study-account/preferences', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscribeNewsletter, studyRemindersEnabled }),
})
setAuth(prev => ({ ...prev, subscribeNewsletter: data.subscribeNewsletter, studyRemindersEnabled: data.studyRemindersEnabled }))
setPrefMessage('Preferences updated.')
await refreshOverview()
} catch (err) {
setPrefMessage(err instanceof Error ? err.message : 'Unable to save preferences.')
} finally {
setPrefBusy(false)
}
}
async function handleRequestEmailChange() {
setEmailBusy(true)
setEmailMessage('')
try {
await readJson<{ ok: boolean }>('/api/study-account/request-email-change', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newEmail: emailNew, currentPassword: emailCurrentPassword }),
})
setEmailCurrentPassword('')
setEmailMessage('Verification email sent. Open the link in that email to complete the change.')
} catch (err) {
setEmailMessage(err instanceof Error ? err.message : 'Unable to request email change.')
} finally {
setEmailBusy(false)
}
}
async function handleVerifyTokenInput() {
setEmailBusy(true)
setEmailMessage('')
try {
const data = await readJson<{ ok: boolean; username: string }>('/api/study-account/verify-email-change', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: emailTokenInput }),
})
setAuth(prev => ({ ...prev, username: data.username }))
setEmailTokenInput('')
setEmailMessage('Email address verified and updated successfully.')
await refreshOverview()
} catch (err) {
setEmailMessage(err instanceof Error ? err.message : 'Unable to verify email token.')
} finally {
setEmailBusy(false)
}
}
async function handleEnrollmentToggle(studySlug: string, enrolled: boolean) {
setEnrollBusySlug(studySlug)
setEnrollMessage('')
try {
const data = await readJson<{ enrolledStudySlugs?: string[]; studyTitle?: string }>(`/api/study-enrollment/${encodeURIComponent(studySlug)}`, {
method: enrolled ? 'DELETE' : 'POST',
})
const slugs = normalizeEnrolledStudySlugs(data.enrolledStudySlugs)
setAuth(prev => ({ ...prev, enrolledStudySlugs: slugs }))
setEnrollMessage(enrolled ? `Unenrolled from ${data.studyTitle ?? studySlug}.` : `Enrolled in ${data.studyTitle ?? studySlug}.`)
await refreshOverview()
} catch (err) {
setEnrollMessage(err instanceof Error ? err.message : 'Unable to update enrollment.')
} finally {
setEnrollBusySlug('')
}
}
async function handleExport() {
setExporting(true)
setExportMessage('')
try {
const res = await fetch('/api/study-account/export-notes')
if (!res.ok) throw new Error('Export failed. Please try again.')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `my-study-notes-${new Date().toISOString().slice(0, 10)}.docx`
a.click()
URL.revokeObjectURL(url)
setExportMessage('Download started.')
} catch (err) {
setExportMessage(err instanceof Error ? err.message : 'Export failed.')
} finally {
setExporting(false)
}
}
async function handleChangePassword() {
setPwMessage('')
setPwSuccess(false)
if (!pwCurrent || !pwNew || !pwConfirm) { setPwMessage('Please fill in all password fields.'); return }
if (pwNew !== pwConfirm) { setPwMessage('New passwords do not match.'); return }
if (pwNew.length < 8) { setPwMessage('New password must be at least 8 characters.'); return }
setPwBusy(true)
try {
await readJson<{ ok: boolean }>('/api/study-account/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: pwCurrent, newPassword: pwNew }),
})
setPwSuccess(true)
setPwCurrent(''); setPwNew(''); setPwConfirm('')
} catch (err) {
setPwMessage(err instanceof Error ? err.message : 'Password change failed.')
} finally {
setPwBusy(false)
}
}
async function handleSignOut() {
setSignOutBusy(true)
try {
await readJson<{ ok: boolean }>('/api/study-auth/logout', { method: 'POST' })
navigate('/study')
} catch {
setSignOutBusy(false)
}
}
async function handleDeleteAccount() {
setDeleteBusy(true)
setDeleteMessage('')
try {
await readJson<{ ok: boolean }>('/api/study-account', { method: 'DELETE' })
navigate('/study')
} catch (err) {
setDeleteMessage(err instanceof Error ? err.message : 'Unable to delete account. Please try again.')
setDeleteBusy(false)
}
}
function closeAccountModal() {
setAccountModal('none')
setEmailMessage('')
setPwMessage('')
setEnrollMessage('')
}
if (!auth.checked) {
return (
<main className="thanks-page" aria-label="Loading">
<div className="thanks-card"><p>Loading...</p></div>
</main>
)
}
if (!auth.authenticated) {
return (
<main className="thanks-page" aria-label="Sign in required">
<div className="thanks-card">
<p className="eyebrow">My Account</p>
<h1>Sign In Required</h1>
<p>Please sign in to manage your account.</p>
<Link to="/study/signup" className="btn-primary" style={{ marginTop: '1rem', display: 'inline-block' }}>Sign In</Link>
</div>
</main>
)
}
const divider = <hr style={{ margin: '0 0 2rem', border: 'none', borderTop: '1px solid #2a2518' }} />
return (
<main className="thanks-page" aria-label="My account">
<div className="thanks-card" style={{ maxWidth: '760px', textAlign: 'left' }}>
<p className="eyebrow">Student Account</p>
<h1>My Account</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
{auth.avatarUrl ? (
<img src={auth.avatarUrl} alt="Your avatar" width={64} height={64} style={{ borderRadius: '50%', border: '1px solid #2a2518' }} />
) : (
<div style={{ width: 64, height: 64, borderRadius: '50%', background: '#2a2518', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#f0ead8', fontWeight: 700, fontSize: '1.5rem' }}>
{auth.displayName?.slice(0, 1).toUpperCase() || auth.username.slice(0, 1).toUpperCase()}
</div>
)}
<div>
<p style={{ margin: 0, fontSize: '0.95rem' }}>Signed in as</p>
<p style={{ margin: '0.25rem 0 0', fontWeight: 700 }}>{auth.displayName || auth.username}</p>
<p style={{ margin: '0.25rem 0 0', fontSize: '0.9rem', color: '#7a7060' }}>{auth.username}</p>
</div>
</div>
{overview?.stats && (
<>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginBottom: '2rem' }}>
<div style={{ flex: '1 1 150px', background: '#1a1a16', borderRadius: '8px', padding: '1rem', border: '1px solid #2a2518' }}>
<p style={{ fontSize: '1.6rem', fontWeight: 700, margin: 0 }}>{overview.stats.noteCount}</p>
<p style={{ margin: 0, fontSize: '0.85rem' }}>Total notes saved</p>
</div>
<div style={{ flex: '1 1 150px', background: '#1a1a16', borderRadius: '8px', padding: '1rem', border: '1px solid #2a2518' }}>
<p style={{ fontSize: '1rem', fontWeight: 600, margin: 0 }}>{new Date(overview.stats.memberSince).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</p>
<p style={{ margin: 0, fontSize: '0.85rem' }}>Member since</p>
</div>
</div>
{divider}
</>
)}
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ marginTop: 0 }}>Quick Actions</h2>
<p style={{ fontSize: '0.92rem' }}>Open the settings you need without scrolling through the whole page.</p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '1rem' }}>
<button type="button" className="btn-primary" onClick={() => setAccountModal('profile')}>Edit Profile</button>
<button type="button" className="btn-primary" onClick={() => setAccountModal('preferences')}>Preferences</button>
<button type="button" className="btn-primary" onClick={() => setAccountModal('export')}>Export Notes</button>
<button type="button" className="btn-primary" onClick={() => setAccountModal('enrollments')}>Manage Enrollments</button>
<button type="button" className="btn-secondary" onClick={() => setAccountModal('changeEmail')}>Change Email</button>
<button type="button" className="btn-secondary" onClick={() => setAccountModal('changePassword')}>Change Password</button>
</div>
</section>
{accountModal !== 'none' && (
<div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(5, 5, 5, 0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1.25rem' }}>
<div style={{ width: '100%', maxWidth: '720px', maxHeight: 'calc(100vh - 2.5rem)', overflowY: 'auto', background: '#11100d', border: '1px solid #3a3320', borderRadius: '18px', padding: '1.5rem', position: 'relative' }} role="dialog" aria-modal="true" aria-labelledby="account-modal-heading">
<button type="button" onClick={closeAccountModal} style={{ position: 'absolute', top: '1rem', right: '1rem', background: 'transparent', border: 'none', color: '#f0ead8', fontSize: '1.35rem', cursor: 'pointer' }} aria-label="Close account settings">×</button>
{accountModal === 'enrollments' && (
<>
<h2 id="account-modal-heading">Manage Enrollments</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>Enroll or leave studies from one place. Your active study list is shown below.</p>
{loadingOverview ? (
<p>Loading study access...</p>
) : overview?.studies?.length ? (
<div style={{ display: 'grid', gap: '1rem' }}>
{overview.studies.map(studyInfo => (
<div key={studyInfo.slug} style={{ padding: '1rem', borderRadius: '12px', background: '#14130f', border: '1px solid #2a2518' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'flex-start' }}>
<div>
<p style={{ margin: 0, fontSize: '0.9rem', color: '#b09e79' }}>{studyInfo.status === 'active' ? 'Active study' : 'Planned study'}</p>
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons} of {studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>
</div>
<button
type="button"
className={studyInfo.enrolled ? 'btn-admin-remove' : 'btn-primary'}
disabled={enrollBusySlug === studyInfo.slug}
onClick={() => handleEnrollmentToggle(studyInfo.slug, studyInfo.enrolled)}
>
{enrollBusySlug === studyInfo.slug ? 'Working…' : studyInfo.enrolled ? 'Leave' : 'Enroll'}
</button>
</div>
</div>
))}
</div>
) : (
<p>No study access information is available right now.</p>
)}
{enrollMessage && <p className="study-note-status" style={{ marginTop: '1rem' }}>{enrollMessage}</p>}
</>
)}
{accountModal === 'changeEmail' && (
<>
<h2 id="account-modal-heading">Change Email</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>Update the email address used for sign in and course notifications.</p>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Current email</label>
<input type="email" value={auth.username} disabled style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#1a1912', color: '#b9b09b', marginBottom: '1rem' }} />
<label style={{ display: 'block', marginBottom: '0.5rem' }}>New email</label>
<input type="email" value={emailNew} onChange={e => setEmailNew(e.target.value)} placeholder="you@example.com" style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem' }} />
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Current password</label>
<input type="password" value={emailCurrentPassword} onChange={e => setEmailCurrentPassword(e.target.value)} placeholder="Current password" style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem' }} />
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
<button type="button" className="btn-primary" disabled={emailBusy || !emailNew || !emailCurrentPassword} onClick={handleRequestEmailChange}>
{emailBusy ? 'Sending…' : 'Send Verification Email'}
</button>
</div>
{emailTokenInput && (
<div style={{ marginTop: '1rem' }}>
<p style={{ margin: '0 0 0.5rem' }}>Have a verification token?</p>
<input type="text" value={emailTokenInput} onChange={e => setEmailTokenInput(e.target.value)} placeholder="Paste token here" style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem' }} />
<button type="button" className="btn-secondary" disabled={emailBusy || !emailTokenInput.trim()} onClick={handleVerifyTokenInput}>Verify Token</button>
</div>
)}
{emailMessage && <p className="study-note-status" style={{ marginTop: '1rem' }}>{emailMessage}</p>}
</>
)}
{accountModal === 'profile' && (
<>
<h2 id="account-modal-heading">Profile Settings</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>Update your display name and avatar in one place.</p>
<label style={{ display: 'block', fontSize: '0.9rem', marginBottom: '0.5rem' }}>Choose an avatar</label>
<p style={{ margin: '0 0 0.85rem', color: '#b9b09b' }}>Pick a preset avatar or upload your own image.</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(92px, 1fr))', gap: '0.75rem', marginBottom: '1rem' }}>
{[
{ key: 'dog', label: 'Dog', emoji: '🐶' },
{ key: 'cat', label: 'Cat', emoji: '🐱' },
{ key: 'rabbit', label: 'Rabbit', emoji: '🐰' },
{ key: 'fox', label: 'Fox', emoji: '🦊' },
{ key: 'panda', label: 'Panda', emoji: '🐼' },
{ key: 'lion', label: 'Lion', emoji: '🦁' },
].map(item => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="88" height="88"><rect width="100%" height="100%" rx="18" ry="18" fill="#11100d"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="56">${item.emoji}</text></svg>`
const url = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
return (
<button key={item.key} type="button" onClick={() => { setAvatarUrlInput(url); setAvatarUploadMessage('Preset avatar selected.') }} style={{ border: avatarUrlInput === url ? '2px solid #e0c070' : '1px solid #2a2518', borderRadius: '14px', padding: 0, background: '#11100d', cursor: 'pointer' }}>
<img src={url} alt={item.label} width={88} height={88} style={{ display: 'block', borderRadius: '14px' }} />
</button>
)
})}
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontSize: '0.9rem', marginBottom: '0.5rem' }}>Upload your own image</label>
<input
type="file"
accept="image/*"
onChange={e => {
const file = e.target.files?.[0]
if (file) {
void handleAvatarUpload(file)
}
}}
style={{ color: '#f0ead8' }}
/>
{avatarUploadBusy && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>Uploading avatar</p>}
{avatarUploadMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{avatarUploadMessage}</p>}
</div>
{(avatarUrlInput || auth.avatarUrl) && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.75rem' }}>
<img src={avatarUrlInput || auth.avatarUrl || ''} alt="Avatar preview" width={48} height={48} style={{ borderRadius: '50%', border: '1px solid #2a2518', objectFit: 'cover' }} />
<p style={{ margin: 0, color: '#b9b09b', fontSize: '0.92rem' }}>Preview of your selected avatar. Save your profile to keep it.</p>
</div>
)}
<label style={{ display: 'block', fontSize: '0.9rem', marginBottom: '0.5rem' }}>Display Name (optional)</label>
<input
type="text"
value={displayNameInput}
onChange={e => setDisplayNameInput(e.target.value)}
placeholder="How your name appears in emails"
style={{ width: '100%', maxWidth: '420px', marginBottom: '0.75rem', padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }}
/>
<div>
<button type="button" className="btn-primary" disabled={profileBusy} onClick={handleProfileSave}>{profileBusy ? 'Saving...' : 'Save Profile'}</button>
</div>
{profileMessage && <p className="study-note-status" style={{ marginTop: '0.5rem' }}>{profileMessage}</p>}
</>
)}
{accountModal === 'preferences' && (
<>
<h2 id="account-modal-heading">Preferences</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>Adjust how you receive study reminders and newsletter updates.</p>
<div style={{ display: 'grid', gap: '0.75rem', marginBottom: '1rem' }}>
<label className="contact-consent" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input type="checkbox" checked={studyRemindersEnabled} onChange={e => setStudyRemindersEnabled(e.target.checked)} />
<span>Send email reminders when a new lesson is released</span>
</label>
<label className="contact-consent" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input type="checkbox" checked={subscribeNewsletter} onChange={e => setSubscribeNewsletter(e.target.checked)} />
<span>Subscribe to newsletter updates</span>
</label>
</div>
<button type="button" className="btn-primary" disabled={prefBusy} onClick={handlePreferenceSave}>{prefBusy ? 'Saving...' : 'Save Preferences'}</button>
{prefMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{prefMessage}</p>}
</>
)}
{accountModal === 'export' && (
<>
<h2 id="account-modal-heading">Export Notes</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>Download all saved notes in a single document.</p>
<button type="button" className="btn-primary" disabled={exporting} onClick={handleExport}>
{exporting ? 'Preparing...' : 'Download Notes (.docx)'}
</button>
{exportMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{exportMessage}</p>}
</>
)}
{accountModal === 'changePassword' && (
<>
<h2 id="account-modal-heading">Change Password</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>Use a strong password to keep your study materials and notes secure.</p>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Current password</label>
<input type="password" value={pwCurrent} onChange={e => setPwCurrent(e.target.value)} placeholder="Current password" style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem' }} />
<label style={{ display: 'block', marginBottom: '0.5rem' }}>New password</label>
<input type="password" value={pwNew} onChange={e => setPwNew(e.target.value)} placeholder="New password" style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem' }} />
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Confirm new password</label>
<input type="password" value={pwConfirm} onChange={e => setPwConfirm(e.target.value)} placeholder="Confirm new password" style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem' }} />
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
<button type="button" className="btn-primary" disabled={pwBusy || !pwCurrent || !pwNew || !pwConfirm} onClick={handleChangePassword}>
{pwBusy ? 'Saving…' : 'Update Password'}
</button>
</div>
{pwMessage && <p className="study-note-status" style={{ marginTop: '1rem' }}>{pwMessage}</p>}
{pwSuccess && <p className="study-note-status" style={{ marginTop: '1rem', color: '#a5d6a7' }}>Password changed successfully.</p>}
</>
)}
</div>
</div>
)}
{divider}
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ marginTop: 0 }}>Sign Out</h2>
<button type="button" className="btn-secondary" disabled={signOutBusy} onClick={handleSignOut}>
{signOutBusy ? 'Signing out...' : 'Sign Out'}
</button>
</section>
{divider}
<section>
<h2 style={{ marginTop: 0, color: '#e57373' }}>Delete Account</h2>
{deleteStep === 'idle' && (
<>
<p style={{ fontSize: '0.9rem' }}>Permanently remove your account and all saved notes. This cannot be undone.</p>
<button type="button" className="btn-admin-remove" onClick={() => setDeleteStep('warn1')}>Delete My Account</button>
</>
)}
{deleteStep === 'warn1' && (
<div style={{ background: '#2d2414', border: '1px solid #6b4b1f', borderRadius: '6px', padding: '1rem', marginBottom: '1rem' }}>
<p style={{ fontWeight: 600 }}>Final warning: export your notes first.</p>
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap' }}>
<button type="button" className="btn-primary" onClick={handleExport} disabled={exporting}>Export Notes First</button>
<button type="button" className="btn-admin-remove" onClick={() => setDeleteStep('warn2')}>Continue to Delete</button>
<button type="button" className="btn-secondary" onClick={() => setDeleteStep('idle')}>Cancel</button>
</div>
</div>
)}
{deleteStep === 'warn2' && (
<div style={{ background: '#2b1111', border: '1px solid #8a3b3b', borderRadius: '6px', padding: '1rem' }}>
<p>Type <strong>DELETE</strong> to confirm permanent deletion:</p>
<input type="text" value={deleteConfirmInput} onChange={e => setDeleteConfirmInput(e.target.value)} placeholder="DELETE" style={{ marginBottom: '0.8rem', padding: '0.5rem', border: '1px solid #8a3b3b', borderRadius: '4px', background: '#140f0f', color: '#f0ead8' }} />
{deleteMessage && <p style={{ color: '#ef9a9a', fontSize: '0.875rem' }}>{deleteMessage}</p>}
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap' }}>
<button type="button" className="btn-admin-remove" disabled={deleteBusy || deleteConfirmInput.trim() !== 'DELETE'} onClick={handleDeleteAccount}>
{deleteBusy ? 'Deleting...' : 'Permanently Delete My Account'}
</button>
<button type="button" className="btn-secondary" onClick={() => { setDeleteStep('idle'); setDeleteConfirmInput('') }}>Cancel</button>
</div>
</div>
)}
</section>
<div style={{ marginTop: '2rem' }}>
<Link to="/study" className="btn-secondary">Back to Studies</Link>
</div>
</div>
</main>
)
}
export function StudyQuizPage({ content }: Props) {
const { studySlug, sectionId } = useParams<{ studySlug: string, sectionId: string }>()
const navigate = useNavigate()
const studies = getStudies(content)
const study = getStudyBySlug(studies, studySlug)
const section = study ? getSectionById(study.sections, sectionId) : undefined
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [], displayName: '', subscribeNewsletter: true, studyRemindersEnabled: false, avatarUrl: '' })
const [answers, setAnswers] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
async function load() {
if (!studySlug || !sectionId) return
setLoading(true)
setError('')
try {
const authData = await readJson<StudyAuthStatusResponse>('/api/study-auth/status')
if (cancelled) return
const enrolledStudySlugs = normalizeEnrolledStudySlugs(authData.enrolledStudySlugs)
setAuth({
checked: true,
authenticated: Boolean(authData.authenticated),
username: authData.username ?? '',
displayName: authData.displayName ?? '',
subscribeNewsletter: authData.subscribeNewsletter !== false,
studyRemindersEnabled: authData.studyRemindersEnabled === true,
avatarUrl: authData.avatarUrl ?? '',
enrolledStudySlugs,
})
if (!authData.authenticated || !enrolledStudySlugs.includes(studySlug?.trim().toLowerCase())) {
return
}
const data = await readJson<{ studySlug: string; sectionId: string; answers: string[] }>(`/api/study-quiz/${encodeURIComponent(studySlug)}/${encodeURIComponent(sectionId)}`)
if (cancelled) return
setAnswers(Array.isArray(data.answers) ? data.answers : [])
} catch (err) {
if (cancelled) return
setError(err instanceof Error ? err.message : 'Unable to load quiz answers.')
} finally {
if (cancelled) return
setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [studySlug, sectionId])
async function saveQuiz() {
if (!studySlug || !sectionId || !study || !section) return
setSaving(true)
setMessage('')
try {
await readJson<{ ok: boolean; answers: string[] }>(`/api/study-quiz/${encodeURIComponent(studySlug)}/${encodeURIComponent(sectionId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers }),
})
setMessage('Quiz answers saved.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to save quiz answers.')
} finally {
setSaving(false)
}
}
if (!study || !section) {
return (
<main className="thanks-page" aria-label="Quiz not found">
<div className="thanks-card">
<p className="eyebrow">Study Quiz</p>
<h1>Quiz not found</h1>
<p>The lesson quiz you requested is not available.</p>
<Link to="/study" className="btn-primary">Back to Studies</Link>
</div>
</main>
)
}
const sectionQuestions = section.studyQuestions ?? []
const canSubmit = sectionQuestions.length > 0
const isEnrolled = auth.authenticated && normalizeEnrolledStudySlugs(auth.enrolledStudySlugs).includes(study.slug)
if (!auth.checked) {
return (
<main className="thanks-page" aria-label="Loading quiz">
<div className="thanks-card"><p>Loading quiz...</p></div>
</main>
)
}
if (!auth.authenticated) {
return (
<main className="thanks-page" aria-label="Sign in required">
<div className="thanks-card">
<p className="eyebrow">Study Quiz</p>
<h1>Sign In Required</h1>
<p>You need to sign in before you can view and save quiz answers.</p>
<Link to="/study/signup" className="btn-primary">Sign In</Link>
</div>
</main>
)
}
if (!isEnrolled) {
return (
<main className="thanks-page" aria-label="Enrollment required">
<div className="thanks-card">
<p className="eyebrow">Study Quiz</p>
<h1>Enroll to Access the Quiz</h1>
<p>Please enroll in {study.title} to open the quiz for this lesson.</p>
<Link to={`/study/${study.slug}`} className="btn-primary">Go to Study</Link>
</div>
</main>
)
}
return (
<main className="study-index-page" aria-label="Study quiz">
<section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner">
<p className="eyebrow">Quiz</p>
<h1>{section.title}</h1>
<p className="study-course-hero-copy">Answer the lesson questions below and save your responses for later review.</p>
<div className="study-course-hero-actions">
<Link to={`/study/${study.slug}/${section.id}`} className="btn-secondary">Back to Lesson</Link>
</div>
</div>
</section>
<section className="section-study-module" style={{ paddingTop: '2rem' }}>
<div className="section-inner">
{loading ? (
<p>Loading quiz...</p>
) : error ? (
<p className="study-note-status">{error}</p>
) : !canSubmit ? (
<article className="study-class-block">
<h2>No Quiz Questions</h2>
<p className="study-detail-copy">This lesson does not have quiz 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>
<textarea
rows={4}
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' }}
/>
</div>
))}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button type="submit" className="btn-primary" disabled={saving}>{saving ? 'Saving...' : 'Save Quiz 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>}
</form>
)}
</div>
</section>
</main>
)
}
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: '' })
useEffect(() => {
fetch('/api/study-auth/status', { credentials: 'include' })
.then(r => r.json())
.then((data: StudyAuthStatusResponse) => {
setAuth({ checked: true, authenticated: data.authenticated, username: data.username ?? '', enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs), displayName: data.displayName ?? '' })
})
.catch(() => setAuth(prev => ({ ...prev, checked: true })))
}, [])
if (!study) {
return (
<main className="study-index-page" aria-label="Community">
<section className="section-study-course-hero">
<div className="section-inner">
<h1>Study not found</h1>
<Link to="/study" className="btn-secondary">Back to Studies</Link>
</div>
</section>
</main>
)
}
const isEnrolled = isEnrolledInStudy(auth, study.slug)
return (
<main className="study-index-page" aria-label="Study community">
<section className="section-study-course-hero" style={{ paddingBottom: '2rem' }}>
<div className="section-inner study-course-hero-inner">
<p className="eyebrow">{study.title}</p>
<h1>Study Community</h1>
<p className="study-course-hero-copy">Talk with other enrolled students. Keep it gracious and on topic.</p>
<div className="study-course-hero-actions">
<Link to={`/study/${study.slug}`} className="btn-secondary">Back to Study</Link>
<Link to={`/contact?source=community&study=${encodeURIComponent(study.title)}`} className="btn-primary">Ask Nate</Link>
</div>
</div>
</section>
<section className="section-inner" style={{ paddingTop: '2rem', paddingBottom: '4rem', maxWidth: '860px' }}>
<CommunityBoard study={study} auth={auth} isEnrolled={isEnrolled} sectionFilter={defaultSectionId} />
</section>
</main>
)
}