Files
Siteforge/src/colossiansStudy.tsx
T
nmemmert 9e602e29ec Fetch BSB passage text from Bolls.life API in study lesson view; v1.1.35
Adds /api/bible-passage server route that proxies the Berean Standard Bible
from Bolls.life per-verse, with in-memory caching. Study lesson scripture
block now displays live BSB text instead of static passageText. Adds
bibleBook field to StudyProgram so admins can set the book name explicitly
when the study slug doesn't match (e.g. a study titled "ephesians-part-2").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-10 10:12:25 -04:00

3563 lines
177 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, useRef, useState, useCallback } from 'react'
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
import type { SiteContent, StudyProgram, StudySection } from './content'
import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './colossiansStudyData'
import { usePageMeta } from './hooks/usePageMeta'
import { Breadcrumbs } from './components/Breadcrumbs'
import { StudyCertificate } from './components/StudyCertificate'
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
import { NoteCard } from './components/NoteCard'
import { StudySectionComments } from './components/StudySectionComments'
type Props = { content: SiteContent }
type StudyAuthState = {
checked: boolean
authenticated: boolean
username: string
enrolledStudySlugs?: string[]
displayName?: string
subscribeNewsletter?: boolean
studyRemindersEnabled?: boolean
avatarUrl?: string
totpEnabled?: boolean
twoFaMethod?: 'app' | 'email' | null
totpRecoveryCodesRemaining?: number
}
type StudyAuthStatusResponse = {
authenticated: boolean
username: string
enrolledStudySlugs?: string[]
displayName?: string
subscribeNewsletter?: boolean
studyRemindersEnabled?: boolean
avatarUrl?: string
totpEnabled?: boolean
twoFaMethod?: 'app' | 'email' | null
totpRecoveryCodesRemaining?: number
}
type StudyAccountOverview = {
profile: {
username: string
displayName: string
subscribeNewsletter: boolean
studyRemindersEnabled: boolean
avatarUrl?: string
}
stats: {
noteCount: number
memberSince: string
lastLoginAt: string | null
currentStreak: number
longestStreak: number
lastStudiedDate: 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[]
pending?: boolean
}
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 ''
const now = new Date()
const diffMs = date.getTime() - now.getTime()
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
if (diffDays === 1) return 'tomorrow'
if (diffDays > 1 && diffDays <= 14) return `in ${diffDays} days`
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
function getNoteId(studySlug: string, sectionId: string) {
return `${studySlug}--${sectionId}`
}
const NOTE_ANCHORS = [
{ id: 'scripture', label: 'Scripture Text' },
{ id: 'commentary', label: 'Commentary' },
{ id: 'questions', label: 'Reflection Questions' },
] as const
type NoteAnchorId = typeof NOTE_ANCHORS[number]['id'] | 'general'
const ANCHOR_LABELS: Record<string, string> = {
scripture: 'Scripture Text',
commentary: 'Commentary',
questions: 'Reflection Questions',
general: 'General Notes',
}
function parseNoteMap(raw: string): Record<string, string> {
if (!raw?.trim()) return {}
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, string>
}
} catch {}
return { general: raw }
}
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('')
const optimisticId = `pending-${Date.now()}`
const optimisticPost: StudyCommunityPost = {
id: optimisticId,
studySlug: study.slug,
sectionId: activeSection,
authorName: communityDisplayName,
authorAvatarUrl: auth.avatarUrl || undefined,
message: newPost,
createdAt: new Date().toISOString(),
replies: [],
pending: true,
}
setPosts(prev => [optimisticPost, ...prev])
const postedMessage = newPost
setNewPost('')
try {
const data = await readJson<{ post?: StudyCommunityPost }>('/api/study-community/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ studySlug: study.slug, sectionId: activeSection, message: postedMessage }),
})
setPosts(prev => data.post
? prev.map(p => p.id === optimisticId ? { ...data.post!, pending: false } : p)
: prev.filter(p => p.id !== optimisticId))
setMessage('')
} catch (err) {
setPosts(prev => prev.filter(p => p.id !== optimisticId))
setNewPost(postedMessage)
setError(err instanceof Error ? err.message : 'Unable to post right now.')
} finally {
setSubmitting(false)
}
}
async function submitReply(postId: string) {
const reply = replyDrafts[postId]?.trim()
if (!reply || !canParticipate) return
setSubmitting(true)
setError('')
try {
const data = await readJson<{ reply?: StudyCommunityReply }>(`/api/study-community/posts/${encodeURIComponent(postId)}/replies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: reply }),
})
if (data.reply) {
setPosts(prev => prev.map(post => post.id === postId ? { ...post, replies: [...(post.replies ?? []), data.reply!] } : post))
}
setReplyDrafts(prev => ({ ...prev, [postId]: '' }))
setReplyingTo(null)
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)}
onInput={e => {
const el = e.currentTarget
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}}
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', resize: 'none', overflow: 'hidden', boxSizing: 'border-box' }}
/>
<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', opacity: post.pending ? 0.65 : 1, transition: 'opacity 300ms' }}>
<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 }))}
onInput={e => {
const el = e.currentTarget
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}}
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', resize: 'none', overflow: 'hidden' }}
/>
<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 [showNewsletterNudge, setShowNewsletterNudge] = useState(false)
const [confirmLeaveSlug, setConfirmLeaveSlug] = useState<string | null>(null)
const [newsletterNudgeDone, setNewsletterNudgeDone] = useState(false)
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}.`)
if (!enrolled && !auth.subscribeNewsletter) setShowNewsletterNudge(true)
await refreshOverview()
} catch (err) {
setEnrollMessage(err instanceof Error ? err.message : 'Unable to update enrollment.')
} finally {
setEnrollBusySlug('')
}
}
function closeStudyModal() {
setStudyModal('none')
setEnrollMessage('')
}
useEffect(() => {
if (studyModal === 'none') return
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') closeStudyModal()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [studyModal])
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>
{confirmLeaveSlug === studyInfo.slug ? (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.85rem', color: '#b9b09b' }}>Are you sure?</span>
<button
type="button"
className="btn-admin-remove"
disabled={enrollBusySlug === studyInfo.slug}
onClick={() => { setConfirmLeaveSlug(null); handleEnrollmentToggle(studyInfo.slug, true) }}
>{enrollBusySlug === studyInfo.slug ? 'Working…' : 'Yes, leave'}</button>
<button type="button" className="btn-secondary" style={{ fontSize: '0.85rem', padding: '0.3rem 0.75rem' }} onClick={() => setConfirmLeaveSlug(null)}>Cancel</button>
</div>
) : (
<button
type="button"
className="btn-admin-remove"
disabled={enrollBusySlug === studyInfo.slug}
onClick={() => setConfirmLeaveSlug(studyInfo.slug)}
>
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>
{studyInfo.enrolled && confirmLeaveSlug === studyInfo.slug ? (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.85rem', color: '#b9b09b' }}>Are you sure?</span>
<button
type="button"
className="btn-admin-remove"
disabled={enrollBusySlug === studyInfo.slug}
onClick={() => { setConfirmLeaveSlug(null); handleEnrollmentToggle(studyInfo.slug, true) }}
>{enrollBusySlug === studyInfo.slug ? 'Working…' : 'Yes, leave'}</button>
<button type="button" className="btn-secondary" style={{ fontSize: '0.85rem', padding: '0.3rem 0.75rem' }} onClick={() => setConfirmLeaveSlug(null)}>Cancel</button>
</div>
) : (
<button
type="button"
className={studyInfo.enrolled ? 'btn-admin-remove' : 'btn-primary'}
disabled={enrollBusySlug === studyInfo.slug}
onClick={() => studyInfo.enrolled ? setConfirmLeaveSlug(studyInfo.slug) : handleEnrollmentToggle(studyInfo.slug, false)}
>
{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>}
{showNewsletterNudge && !newsletterNudgeDone && (
<div className="study-newsletter-nudge" style={{ marginTop: '1rem', background: '#1a1a12', border: '1px solid rgba(201,168,76,0.25)', borderRadius: '8px', padding: '1rem', fontSize: '0.9rem' }}>
<p style={{ margin: '0 0 0.5rem' }}>Want episode updates and study announcements by email?</p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button type="button" className="btn-primary" style={{ fontSize: '0.85rem', padding: '0.4rem 1rem' }} onClick={async () => {
try {
await fetch('/api/study-account/preferences', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subscribeNewsletter: true, studyRemindersEnabled: auth.studyRemindersEnabled === true }) })
setAuth(prev => ({ ...prev, subscribeNewsletter: true }))
} catch {
// Silently continue — preference will sync on next account load
}
setNewsletterNudgeDone(true)
setShowNewsletterNudge(false)
}}>Yes, subscribe me</button>
<button type="button" className="btn-secondary" style={{ fontSize: '0.85rem', padding: '0.4rem 1rem' }} onClick={() => setShowNewsletterNudge(false)}>No thanks</button>
</div>
</div>
)}
</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 (
<div key={study.slug}>
<article className="study-module-row">
<div className="study-module-row-left">
<p className="study-module-lesson">{study.enrolled ? 'Enrolled' : 'Track'}</p>
<h3>{study.title}</h3>
<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>
{progress === 100 && (
<StudyCertificate studySlug={study.slug} />
)}
</div>
)
})}
</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>
)}
<section className="section-study-module" aria-label="Q&A">
<div className="section-inner" style={{ textAlign: 'center', padding: '3rem 1rem' }}>
<p className="eyebrow">Have a question from the Word?</p>
<h2 style={{ fontSize: '1.5rem', fontWeight: 700, margin: '0.5rem 0 0.75rem', color: '#f0e9cc' }}>Ask Nate a Question</h2>
<p style={{ color: '#8a7f5a', maxWidth: '480px', margin: '0 auto 1.5rem', fontSize: '0.95rem' }}>
Submit a Bible or theology question and it may be answered in a future episode or the Q&amp;A archive.
</p>
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', flexWrap: 'wrap' }}>
<a href="/questions" className="btn-primary">Browse Q&amp;A Archive </a>
<a href="/contact" className="btn-secondary">Submit a Question</a>
</div>
</div>
</section>
</main>
)
}
const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/
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('')
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({})
// 2FA state
const [totpPendingToken, setTotpPendingToken] = useState('')
const [totpCode, setTotpCode] = useState('')
const [totpMethod, setTotpMethod] = useState<'app' | 'email' | null>(null)
const [resendStatus, setResendStatus] = useState<'idle' | 'sending' | 'sent'>('idle')
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
}
if (!EMAIL_REGEX.test(email.trim())) {
setMessage('Please enter a valid email address.')
return
}
setBusy(true)
setMessage('')
try {
const data = await readJson<{ username?: string; enrolledStudySlugs?: string[]; totpRequired?: boolean; pendingToken?: string }>(`/api/study-auth/${mode}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: email, password, ...(mode === 'signup' ? { subscribe: subscribeNewsletter } : {}) }),
})
if (data.totpRequired && data.pendingToken) {
setTotpPendingToken(data.pendingToken)
setTotpMethod((data as { method?: 'app' | 'email' }).method ?? 'app')
return
}
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])
const submitTotp = useCallback(async () => {
if (!totpCode.trim()) { setMessage('Please enter your 6-digit code.'); return }
setBusy(true)
setMessage('')
try {
const data = await readJson<{ ok?: boolean; username?: string; usedRecoveryCode?: boolean }>('/api/study-auth/totp-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pendingToken: totpPendingToken, code: totpCode }),
})
setDone(true)
setLoggedInAs(data.username ?? email.trim().toLowerCase())
navigate('/study')
} catch (err) {
setMessage(err instanceof Error ? err.message : 'Invalid code. Please try again.')
} finally {
setBusy(false)
}
}, [totpPendingToken, totpCode, email])
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>.</p>
<p style={{ color: '#b09e79', marginBottom: '1.5rem', fontSize: '0.95rem' }}>Head to the study hub to enroll in a track and begin your first lesson.</p>
<div className="study-signup-actions" style={{ flexDirection: 'column', alignItems: 'stretch', gap: '0.75rem' }}>
<button type="button" className="btn-primary" style={{ justifyContent: 'center' }} onClick={() => navigate('/study')}>Browse & Enroll in a Study →</button>
<Link to="/study/account" className="btn-secondary" style={{ justifyContent: 'center' }}>My Account</Link>
</div>
</>
) : totpPendingToken ? (
<div className="study-signup-form">
{totpMethod === 'email' ? (
<p style={{ color: '#a89a6a', marginBottom: '1.25rem', fontSize: '0.95rem' }}>
📧 A 6-digit code was sent to your email address. Enter it below to sign in.
</p>
) : (
<p style={{ color: '#a89a6a', marginBottom: '1.25rem', fontSize: '0.95rem' }}>
🔐 Open your authenticator app and enter the 6-digit code below.
</p>
)}
<label htmlFor="totp-code">{totpMethod === 'email' ? 'Email Code' : 'Authenticator Code'}</label>
<input
id="totp-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={totpCode}
onChange={e => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
autoFocus
onKeyDown={e => e.key === 'Enter' && submitTotp()}
style={{ letterSpacing: '0.3em', fontSize: '1.4rem', textAlign: 'center' }}
/>
{message && <p className="study-signup-error">{message}</p>}
<div className="study-signup-actions">
<button type="button" className="btn-primary" disabled={busy} onClick={submitTotp}>
{busy ? 'Verifying' : 'Verify'}
</button>
<button type="button" className="btn-secondary" onClick={() => { setTotpPendingToken(''); setMessage(''); setTotpCode('') }}>Back</button>
</div>
{totpMethod === 'email' && (
<p style={{ fontSize: '0.82rem', color: '#5a5440', marginTop: '1rem' }}>
Didn't get it?{' '}
<button type="button" disabled={resendStatus !== 'idle'} style={{ background: 'none', border: 'none', color: resendStatus === 'sent' ? '#7abf7a' : '#c9a84c', cursor: resendStatus === 'idle' ? 'pointer' : 'default', fontSize: 'inherit', textDecoration: 'underline', padding: 0 }} onClick={async () => {
setResendStatus('sending')
try {
await fetch('/api/study-auth/email-otp-resend', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pendingToken: totpPendingToken }) })
setResendStatus('sent')
setTimeout(() => setResendStatus('idle'), 30000)
} catch { setResendStatus('idle') }
}}>
{resendStatus === 'sending' ? 'Sending…' : resendStatus === 'sent' ? 'Code sent ✓' : 'Resend code'}
</button>
</p>
)}
{totpMethod === 'app' && (
<p style={{ fontSize: '0.8rem', color: '#5a5440', marginTop: '1rem' }}>Lost access to your app? Enter one of your recovery codes instead.</p>
)}
</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); if (fieldErrors.email) setFieldErrors(prev => ({ ...prev, email: undefined })) }}
onBlur={() => {
if (email.trim() && !EMAIL_REGEX.test(email.trim())) {
setFieldErrors(prev => ({ ...prev, email: 'Please enter a valid email address.' }))
} else {
setFieldErrors(prev => ({ ...prev, email: undefined }))
}
}}
placeholder="your@email.com"
autoComplete="email"
autoFocus
/>
{fieldErrors.email && <p className="study-signup-field-error">{fieldErrors.email}</p>}
<label htmlFor="signup-password">Password</label>
<input
id="signup-password"
type="password"
value={password}
onChange={e => { setPassword(e.target.value); if (fieldErrors.password) setFieldErrors(prev => ({ ...prev, password: undefined })) }}
onBlur={() => {
if (mode === 'signup' && password && password.length < 8) {
setFieldErrors(prev => ({ ...prev, password: 'Password must be at least 8 characters.' }))
} else {
setFieldErrors(prev => ({ ...prev, password: undefined }))
}
}}
placeholder="At least 8 characters"
autoComplete={mode === 'signup' ? 'new-password' : 'current-password'}
onKeyDown={e => e.key === 'Enter' && submit()}
/>
{fieldErrors.password && <p className="study-signup-field-error">{fieldErrors.password}</p>}
{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)
}
}
usePageMeta(
`${study.title} | Bible Study`,
study.description,
)
return (
<main className="study-index-page" aria-label={`${study.title} study`}>
<section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner">
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title }]} />
<p className="eyebrow">Online Bible Class</p>
<h1>{study.title}</h1>
<p className="study-course-hero-copy">{study.description}</p>
<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, sectionIdx) => {
const lessonNumber = getLessonNumber(sections, section.id)
const isNew = isNewLesson(section)
const coming = isComingSoon(section)
const prevSection = sectionIdx > 0 ? sections[sectionIdx - 1] : null
const prevCompleted = !prevSection || studyProgress.completedSectionIds.includes(prevSection.id)
const lockedByProgress = enrolled && !coming && sectionIdx > 0 && !prevCompleted && !studyProgress.completedSectionIds.includes(section.id)
const isDisabled = coming || !enrolled || lockedByProgress
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>}
{lockedByProgress && <span style={{ backgroundColor: '#5a5440', color: '#f0e9cc', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Complete previous lesson</span>}
{enrolled && !lockedByProgress && studyProgress.completedSectionIds.includes(section.id) && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', backgroundColor: '#1a3a2a', color: '#6fcf97', padding: '0.2rem 0.55rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
<circle cx="6.5" cy="6.5" r="6" stroke="#6fcf97" strokeWidth="1.2" />
<path d="M3.5 6.5l2 2 3.5-3.5" stroke="#6fcf97" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Completed
</span>
)}
</div>
<h3>{section.title}</h3>
<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 [noteMap, setNoteMap] = useState<Record<string, string>>({})
const [openAnchor, setOpenAnchor] = useState<NoteAnchorId | null>(null)
const [noteLoading, setNoteLoading] = useState(false)
const [noteMessage, setNoteMessage] = useState('')
const noteMapDirtyRef = useRef(false)
const [contextNoteMenu, setContextNoteMenu] = useState<{ x: number; y: number; text: string; anchor: NoteAnchorId } | null>(null)
const [completedSectionIds, setCompletedSectionIds] = useState<string[]>([])
const [progressSaving, setProgressSaving] = useState(false)
const [progressMessage, setProgressMessage] = useState('')
const [checkpointAnswers, setCheckpointAnswers] = useState<Record<number, string>>({})
const [showCelebration, setShowCelebration] = useState(false)
const [bannerDismissed, setBannerDismissed] = useState(() => localStorage.getItem('study-banner-dismissed-v1') === '1')
const [autoSaveMsg, setAutoSaveMsg] = useState('')
const [_checkpointReflection, _setCheckpointReflection] = useState('')
const [bsbPassage, setBsbPassage] = useState<string | null>(null)
const [bsbPassageLoading, setBsbPassageLoading] = useState(false)
const savedNoteCount = Object.values(noteMap).filter(v => v?.trim()).length
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
usePageMeta(
section && study ? `${section.title} | ${study.title}` : 'Study',
section?.summary,
)
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 (!section?.reference || !currentStudySlug) return
let cancelled = false
setBsbPassage(null)
setBsbPassageLoading(true)
const bookKey = (study?.bibleBook?.trim() || currentStudySlug).toLowerCase()
const params = new URLSearchParams({ book: bookKey, reference: section.reference })
fetch(`/api/bible-passage?${params}`)
.then(r => r.json() as Promise<{ text?: string }>)
.then(data => { if (!cancelled && data.text) setBsbPassage(data.text) })
.catch(() => {})
.finally(() => { if (!cancelled) setBsbPassageLoading(false) })
return () => { cancelled = true }
}, [section?.id, section?.reference, currentStudySlug, study?.bibleBook])
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({})
}, [section?.id])
useEffect(() => {
if (!auth.authenticated || !noteId || !isEnrolled) {
setNoteMap({})
noteMapDirtyRef.current = false
return
}
let cancelled = false
setNoteLoading(true)
setNoteMessage('')
readJson<{ note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`)
.then(data => {
if (cancelled) return
setNoteMap(parseNoteMap(data.note ?? ''))
noteMapDirtyRef.current = false
})
.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)
}
}
function updateNote(anchor: NoteAnchorId, html: string) {
noteMapDirtyRef.current = true
setNoteMap(prev => ({ ...prev, [anchor]: html }))
}
useEffect(() => {
if (!openAnchor) return
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setOpenAnchor(null)
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [openAnchor])
useEffect(() => {
if (!auth.authenticated || !noteId || noteLoading) return
if (!noteMapDirtyRef.current) return
const timer = setTimeout(async () => {
if (!noteMapDirtyRef.current) return
noteMapDirtyRef.current = false
try {
await readJson<{ ok: boolean }>(`/api/study-notes/${encodeURIComponent(noteId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note: JSON.stringify(noteMap) }),
})
setAutoSaveMsg('Auto-saved.')
setTimeout(() => setAutoSaveMsg(''), 3000)
} catch {
noteMapDirtyRef.current = true
}
}, 2000)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [noteMap, noteId, auth.authenticated, noteLoading])
useEffect(() => {
if (!showCelebration) return
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setShowCelebration(false)
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [showCelebration])
function appendSelectedTextToNotes(selectedText: string, anchor: NoteAnchorId = 'general') {
if (!selectedText.trim()) return
const existing = noteMap[anchor] ?? ''
const appended = existing
? `${existing}<p>${selectedText.trim()}</p>`
: `<p>${selectedText.trim()}</p>`
updateNote(anchor, appended)
setOpenAnchor(anchor)
}
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()
const anchorEl = (e.target as Element).closest('[data-note-anchor]')
const anchor = (anchorEl?.getAttribute('data-note-anchor') ?? 'general') as NoteAnchorId
setContextNoteMenu({ x: e.clientX + 4, y: e.clientY + 4, text: selectedText, anchor })
}
useEffect(() => {
if (!contextNoteMenu) return
const handleClick = () => setContextNoteMenu(null)
window.addEventListener('mousedown', handleClick)
return () => window.removeEventListener('mousedown', handleClick)
}, [contextNoteMenu])
function checkAndShowCelebration(newIds: string[], wasAlreadyComplete: boolean) {
if (wasAlreadyComplete) return
if (sections.length > 0 && newIds.length >= sections.length) {
setShowCelebration(true)
}
}
async function submitCheckpoint() {
if (!study || !section?.id || progressSaving) return
const checkpointQuestions = section.checkpointQuestions ?? []
const allAnswered = checkpointQuestions.every((_, index) => (checkpointAnswers[index] ?? '').trim().length > 0)
if (!allAnswered) {
setProgressMessage('Please answer all checkpoint questions to mark this lesson complete.')
return
}
setProgressSaving(true)
setProgressMessage('')
try {
const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`, {
method: 'POST',
})
const newIds = Array.isArray(data.completedSectionIds) ? data.completedSectionIds : []
setCompletedSectionIds(newIds)
setProgressMessage('')
checkAndShowCelebration(newIds, lessonCompleted)
} catch (err) {
setProgressMessage(err instanceof Error ? err.message : 'Unable to save. Please try again.')
} 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.announcement && !bannerDismissed && (
<div className="study-lesson-announcement-banner" role="alert">
<span className="study-lesson-announcement-icon" aria-hidden="true">📢</span>
<div className="study-lesson-announcement-body">
<strong className="study-lesson-announcement-label">Announcement</strong>
<p className="study-lesson-announcement-text">{section.announcement}</p>
</div>
<button
type="button"
className="study-lesson-announcement-close"
aria-label="Dismiss announcement"
onClick={() => {
setBannerDismissed(true)
localStorage.setItem('study-banner-dismissed-v1', '1')
}}
>×</button>
</div>
)}
{showCelebration && study && (
<div className="study-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="celebrate-heading">
<div className="study-modal-box" style={{ textAlign: 'center', maxWidth: '480px' }}>
<p style={{ fontSize: '2.5rem', margin: '0 0 0.5rem' }}>🎉</p>
<h2 id="celebrate-heading" style={{ margin: '0 0 0.75rem' }}>You finished {study.title}!</h2>
<p style={{ color: '#b09e79', marginBottom: '1.5rem' }}>Your certificate of completion is ready on the study dashboard.</p>
<div className="study-auth-actions" style={{ justifyContent: 'center' }}>
<Link to="/study/account" className="btn-primary" onClick={() => setShowCelebration(false)}>View Certificate</Link>
<button type="button" className="btn-secondary" onClick={() => setShowCelebration(false)}>Continue</button>
</div>
</div>
</div>
)}
<section className="section-study-classroom" onContextMenu={handleLessonContentContextMenu}>
<div className="section-inner study-classroom-shell">
<div className="study-classroom-main">
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title, href: `/study/${study.slug}` }, { label: `Lesson ${lessonNumber}` }]} />
<Link to={`/study/${study.slug}`} className="study-detail-back">Back to {study.title}</Link>
<p className="study-lesson-label">Lesson {lessonNumber} of {sections.length}</p>
<h1>{section.title}</h1>
<p className="study-detail-reference">{section.reference}</p>
<p className="study-detail-summary">{section.summary}</p>
{lessonAudioEmbedUrl && (
<article className="study-class-block">
<h2>Lesson Audio</h2>
{lessonAudioEmbedUrl.includes('spotify.com') ? (
<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>
) : (
<EpisodeAudioPlayer src={lessonAudioEmbedUrl} title={section.title} size="compact" spotifyUrl={content.platformSpotifyUrl} />
)}
</article>
)}
<article className="study-class-block study-class-block--anchored" data-note-anchor="scripture">
{isEnrolled && (
<button
type="button"
className={`note-anchor-btn${noteMap['scripture']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'scripture' ? ' note-anchor-btn--open' : ''}`}
onClick={() => setOpenAnchor(curr => curr === 'scripture' ? null : 'scripture')}
title={noteMap['scripture']?.trim() ? 'Edit scripture note' : 'Add note to Scripture'}
aria-pressed={openAnchor === 'scripture'}
>✎</button>
)}
<h2>Scripture Text</h2>
{bsbPassageLoading
? <p className="study-detail-copy" style={{ opacity: 0.5 }}>Loading passage…</p>
: (bsbPassage ?? section.passageText)
? (bsbPassage ?? section.passageText).split('\n\n').map((para, i) => (
<p key={i} className="study-detail-copy study-detail-copy--scripture">
{para.split('\n').map((line, j, arr) => j < arr.length - 1 ? <>{line}<br /></> : line)}
</p>
))
: <p className="study-detail-copy" style={{ opacity: 0.5 }}>Passage text not available.</p>
}
{bsbPassage && <p style={{ fontSize: '0.78rem', color: '#5a5440', marginTop: '0.5rem' }}>Berean Standard Bible (BSB)</p>}
{isEnrolled && openAnchor === 'scripture' && (
<NoteCard
anchorLabel="Scripture Text"
html={noteMap['scripture'] ?? ''}
autoSaveMsg={autoSaveMsg}
onChange={html => updateNote('scripture', html)}
onClose={() => setOpenAnchor(null)}
/>
)}
</article>
<article className="study-class-block study-class-block--anchored" data-note-anchor="commentary">
{isEnrolled && (
<button
type="button"
className={`note-anchor-btn${noteMap['commentary']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'commentary' ? ' note-anchor-btn--open' : ''}`}
onClick={() => setOpenAnchor(curr => curr === 'commentary' ? null : 'commentary')}
title={noteMap['commentary']?.trim() ? 'Edit commentary note' : 'Add note to Commentary'}
aria-pressed={openAnchor === 'commentary'}
>✎</button>
)}
<h2>Instructor Commentary</h2>
{section.commentary.split(/\n{2,}/).map((para, i) => (
<p key={i} className="study-detail-copy">
{para.split('\n').map((line, j, arr) => (
j < arr.length - 1 ? <>{line}<br /></> : line
))}
</p>
))}
{isEnrolled && openAnchor === 'commentary' && (
<NoteCard
anchorLabel="Commentary"
html={noteMap['commentary'] ?? ''}
autoSaveMsg={autoSaveMsg}
onChange={html => updateNote('commentary', html)}
onClose={() => setOpenAnchor(null)}
/>
)}
</article>
<article className="study-class-block study-class-block--anchored" data-note-anchor="questions">
{isEnrolled && (
<button
type="button"
className={`note-anchor-btn${noteMap['questions']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'questions' ? ' note-anchor-btn--open' : ''}`}
onClick={() => setOpenAnchor(curr => curr === 'questions' ? null : 'questions')}
title={noteMap['questions']?.trim() ? 'Edit reflection note' : 'Add note to Reflection Questions'}
aria-pressed={openAnchor === 'questions'}
>✎</button>
)}
<h2>Reflection Questions</h2>
<p className="study-detail-copy" style={{ marginBottom: '0.75rem', color: '#8a7f5a', fontSize: '0.9rem' }}>
Work through these on your own, then share your thoughts in the Discussion below.
</p>
<ol className="study-detail-list">
{section.studyQuestions.map((question, index) => (
<li key={index}>{question}</li>
))}
</ol>
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<Link to={`/study/${study.slug}/${section.id}/quiz`} className="btn-secondary">Write My Answers</Link>
<Link to={`/study/${study.slug}/community`} className="btn-primary">Go to Community</Link>
<Link to={`/contact?source=community&study=${encodeURIComponent(study.title)}`} className="btn-secondary">Ask Nate</Link>
</div>
{isEnrolled && openAnchor === 'questions' && (
<NoteCard
anchorLabel="Reflection Questions"
html={noteMap['questions'] ?? ''}
autoSaveMsg={autoSaveMsg}
onChange={html => updateNote('questions', html)}
onClose={() => setOpenAnchor(null)}
/>
)}
</article>
{isEnrolled && !(section.checkpointQuestions && section.checkpointQuestions.length > 0) && (
<article className="study-class-block" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '1rem' }}>
<div>
<h2 style={{ margin: 0 }}>Lesson Progress</h2>
<p style={{ margin: '0.25rem 0 0', fontSize: '0.85rem', color: '#5a5440' }}>No checkpoint questions for this lesson.</p>
</div>
<button
type="button"
className={lessonCompleted ? 'btn-secondary' : 'btn-primary'}
style={lessonCompleted ? { borderColor: '#2196f3', color: '#2196f3' } : {}}
disabled={progressSaving}
onClick={async () => {
if (!study || !section?.id) return
setProgressSaving(true)
const wasComplete = lessonCompleted
try {
const method = wasComplete ? 'DELETE' : 'POST'
const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(
`/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`,
{ method }
)
const newIds = Array.isArray(data.completedSectionIds) ? data.completedSectionIds : []
setCompletedSectionIds(newIds)
if (!wasComplete) checkAndShowCelebration(newIds, false)
} catch { /* ignore */ }
finally { setProgressSaving(false) }
}}
>
{progressSaving ? '' : lessonCompleted ? ' Completed' : 'Mark as Completed'}
</button>
</article>
)}
{(section.checkpointQuestions && section.checkpointQuestions.length > 0) && (() => {
const allAnswered = section.checkpointQuestions!.every((_, i) => (checkpointAnswers[i] ?? '').trim().length > 0)
return (
<article className="study-class-block">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '1rem' }}>
<h2 style={{ margin: 0 }}>Checkpoint</h2>
{lessonCompleted && (
<span style={{ background: '#1565c0', color: '#fff', padding: '0.2rem 0.65rem', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 600 }}>
✓ Completed
</span>
)}
</div>
{section.checkpointPrompt && <p className="study-detail-copy">{section.checkpointPrompt}</p>}
<div style={{ display: 'grid', gap: '1rem', marginTop: section.checkpointPrompt ? '1rem' : undefined }}>
{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%', boxSizing: 'border-box', resize: 'vertical', padding: '0.85rem 1rem', borderRadius: '12px', border: `1px solid ${(checkpointAnswers[index] ?? '').trim() ? '#2a4a2a' : '#2a2518'}`, background: '#14130f', color: '#f0ead8' }}
/>
</div>
))}
</div>
<div style={{ marginTop: '1.25rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'center' }}>
<button
type="button"
className="btn-primary"
onClick={submitCheckpoint}
disabled={!isEnrolled || progressSaving || !allAnswered}
>
{progressSaving ? 'Saving' : lessonCompleted ? 'Update Answers' : 'Mark as Completed'}
</button>
{!allAnswered && (
<span style={{ fontSize: '0.85rem', color: '#5a5440' }}>Answer all questions to mark this lesson complete.</span>
)}
{progressMessage && <span style={{ fontSize: '0.85rem', color: '#e07a7a' }}>{progressMessage}</span>}
</div>
</article>
)
})()}
<article className="study-class-block">
<h2>Lesson Discussion</h2>
<p className="study-detail-copy" style={{ fontSize: '0.88rem', color: '#7a7060', margin: '0 0 1rem' }}>
Share a thought or reaction with other students in this lesson.{' '}
<Link to={`/study/${study.slug}/community`} style={{ color: '#e0b840' }}>See all community posts →</Link>
</p>
<StudySectionComments studySlug={currentStudySlug} sectionId={section.id} isEnrolled={isEnrolled} />
</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>
<Link to="/study/account" className="btn-secondary" style={{ marginBottom: '0.5rem', display: 'inline-block' }}>My Account</Link>
{noteLoading ? (
<p style={{ color: '#7a7060', fontSize: '0.85rem', marginTop: '0.5rem' }}>Loading notes…</p>
) : (
<>
{savedNoteCount > 0 && (
<p style={{ fontSize: '0.82rem', color: '#7a9c6a', marginBottom: '0.5rem' }}>
{savedNoteCount} note{savedNoteCount !== 1 ? 's' : ''} saved ✓
</p>
)}
<div className="note-anchor-list">
{NOTE_ANCHORS.map(({ id, label }) => {
const hasNote = !!(noteMap[id]?.trim())
return (
<button
key={id}
type="button"
className={`note-anchor-list-item${hasNote ? ' note-anchor-list-item--has' : ''}${openAnchor === id ? ' note-anchor-list-item--open' : ''}`}
onClick={() => setOpenAnchor(curr => curr === id ? null : id as NoteAnchorId)}
>
<span>{label}</span>
{hasNote && <span className="note-anchor-dot" aria-hidden="true">●</span>}
</button>
)
})}
</div>
<p style={{ margin: '0.5rem 0 0', color: '#7a7060', fontSize: '0.82rem' }}>Click a section above to add notes. Right-click lesson text to append a selection.</p>
</>
)}
</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) ? (
<span className="study-nav-placeholder">Available {getReleaseDateDisplay(nextSection)}</span>
) : nextSection && !lessonCompleted ? (
<span className="study-nav-placeholder" title="Complete this lesson's checkpoint to unlock the next one">🔒 Complete checkpoint first</span>
) : nextSection ? (
<Link to={`/study/${study.slug}/${nextSection.id}`} className="btn-secondary">Next Lesson</Link>
) : <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.35rem', color: '#ece3c6', fontSize: '0.95rem' }}>Add to <strong style={{ color: '#c9a84c' }}>{ANCHOR_LABELS[contextNoteMenu.anchor] ?? 'Notes'}</strong>:</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, contextNoteMenu.anchor)
setContextNoteMenu(null)
}}
>
Add to Notes
</button>
</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('')
const [activeNoteTab, setActiveNoteTab] = useState('')
usePageMeta(
study ? `My Notes | ${study.title}` : 'My Study Notes',
study ? `Your personal lesson notes for ${study.title}.` : undefined,
)
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())
const enrolledStudies = !loading && auth.authenticated
? getStudies(content).filter(s => (auth.enrolledStudySlugs ?? []).includes(s.slug.toLowerCase()))
: []
return (
<main className="study-section-page" aria-label="My study notes">
<section className="section-study-classroom">
<div className="section-inner">
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title, href: `/study/${study.slug}` }, { label: 'My Notes' }]} />
<Link to={`/study/${study.slug}`} className="study-detail-back">Back to {study.title}</Link>
<p className="study-lesson-label">Student Workspace</p>
<h1>My Lesson Notes</h1>
{!loading && auth.authenticated && <p className="study-detail-summary">Signed in as {auth.username}</p>}
{enrolledStudies.length > 1 && (
<div className="study-track-switcher">
<p className="study-lesson-label">Switch Track</p>
<div className="study-track-switcher-tabs">
{enrolledStudies.map(s => (
<Link
key={s.slug}
to={`/study/${s.slug}/notes`}
className={`study-track-tab${s.slug === study.slug ? ' study-track-tab--active' : ''}`}
>
{s.title}
</Link>
))}
</div>
</div>
)}
{!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 && (() => {
const sorted = [...entries].sort((a, b) => {
const aNum = a.section ? getLessonNumber(study.sections, a.section.id) : 0
const bNum = b.section ? getLessonNumber(study.sections, b.section.id) : 0
return aNum - bNum
})
const active = sorted.find(item => item.sectionId === activeNoteTab) ?? sorted[0]
return (
<div className="study-notebook">
<div className="study-notebook-tabs" role="tablist" aria-label="Lesson notes">
{sorted.map(({ sectionId, section }) => (
<button
key={sectionId}
type="button"
role="tab"
aria-selected={active.sectionId === sectionId}
className={`study-notebook-tab${active.sectionId === sectionId ? ' study-notebook-tab--active' : ''}`}
onClick={() => setActiveNoteTab(sectionId)}
>
{section ? `Lesson ${getLessonNumber(study.sections, section.id)}` : 'Saved Note'}
</button>
))}
</div>
<article className="study-notebook-page">
<p className="study-module-lesson">{active.section ? `Lesson ${getLessonNumber(study.sections, active.section.id)}` : 'Saved Note'}</p>
<h3>{active.section?.title ?? active.sectionId}</h3>
<p className="study-detail-reference">{active.section ? active.section.reference : 'Lesson reference unavailable'}</p>
{(() => {
const anchorMap = parseNoteMap(active.note)
const hasContent = Object.values(anchorMap).some(v => v?.trim())
if (!hasContent) return <p className="study-detail-copy">No notes saved for this lesson yet.</p>
return Object.entries(anchorMap).map(([key, html]) => (
html?.trim() ? (
<div key={key} style={{ marginBottom: '1.25rem' }}>
<p className="study-module-focus" style={{ marginBottom: '0.5rem' }}>{ANCHOR_LABELS[key] ?? key}</p>
<div className="study-note-rich-content" dangerouslySetInnerHTML={{ __html: html }} />
</div>
) : null
))
})()}
{active.section && <Link to={`/study/${study.slug}/${active.section.id}`} className="btn-secondary">Open Lesson</Link>}
</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' | 'choose2fa' | 'setup2fa' | 'setup2fa-email' | 'disable2fa' | 'recoveryCodes'>('none')
// 2FA state
const [twoFaQr, setTwoFaQr] = useState('')
const [twoFaSecret, setTwoFaSecret] = useState('')
const [twoFaCode, setTwoFaCode] = useState('')
const [twoFaMessage, setTwoFaMessage] = useState('')
const [twoFaBusy, setTwoFaBusy] = useState(false)
const [twoFaRecoveryCodes, setTwoFaRecoveryCodes] = useState<string[]>([])
const [disablePassword, setDisablePassword] = useState('')
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),
totpEnabled: data.totpEnabled === true,
twoFaMethod: data.twoFaMethod ?? null,
totpRecoveryCodesRemaining: data.totpRecoveryCodesRemaining ?? 0,
})
})
.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('')
}
useEffect(() => {
if (accountModal === 'none') return
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') closeAccountModal()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [accountModal])
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: '1.5rem' }}>
<div style={{ flex: '1 1 130px', 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', color: '#7a7060' }}>Notes saved</p>
</div>
<div style={{ flex: '1 1 130px', background: '#1a1a16', borderRadius: '8px', padding: '1rem', border: '1px solid #2a2518' }}>
<p style={{ fontSize: '1.6rem', fontWeight: 700, margin: 0 }}>{overview.stats.currentStreak ?? 0} 🔥</p>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#7a7060' }}>Day streak</p>
</div>
<div style={{ flex: '1 1 130px', background: '#1a1a16', borderRadius: '8px', padding: '1rem', border: '1px solid #2a2518' }}>
<p style={{ fontSize: '1.6rem', fontWeight: 700, margin: 0 }}>{overview.stats.longestStreak ?? 0}</p>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#7a7060' }}>Longest streak</p>
</div>
<div style={{ flex: '1 1 130px', background: '#1a1a16', borderRadius: '8px', padding: '1rem', border: '1px solid #2a2518' }}>
<p style={{ fontSize: '1rem', fontWeight: 600, margin: 0 }}>{overview.stats.memberSince && !isNaN(new Date(overview.stats.memberSince).getTime()) ? new Date(overview.stats.memberSince).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</p>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#7a7060' }}>Member since</p>
</div>
</div>
{overview.studies.filter(s => s.enrolled && s.totalLessons > 0).length > 0 && (
<div style={{ marginBottom: '1.5rem' }}>
<p style={{ margin: '0 0 0.75rem', fontSize: '0.85rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: '#7a7060' }}>Study Progress</p>
{overview.studies.filter(s => s.enrolled && s.totalLessons > 0).map(s => {
const pct = Math.round((s.completedLessons / s.totalLessons) * 100)
return (
<div key={s.slug} style={{ marginBottom: '0.85rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.88rem', marginBottom: '0.3rem' }}>
<span>{s.title}</span>
<span style={{ color: '#7a7060' }}>{s.completedLessons}/{s.totalLessons} lessons · {pct}%</span>
</div>
<div style={{ height: '6px', background: '#2a2518', borderRadius: '3px', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: pct === 100 ? '#4a9a4a' : '#c9a84c', borderRadius: '3px', transition: 'width 0.4s ease' }} />
</div>
</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>
<button type="button" className={auth.totpEnabled ? 'btn-secondary' : 'btn-primary'} onClick={() => {
if (auth.totpEnabled) { setAccountModal('disable2fa'); return }
setAccountModal('choose2fa')
}} disabled={twoFaBusy}>
{auth.totpEnabled
? `2FA: ${auth.twoFaMethod === 'email' ? 'Email ✓' : 'App ✓'}`
: '🔐 Enable Two-Factor Auth'}
</button>
</div>
{!auth.totpEnabled && (
<div style={{ marginTop: '1rem', background: 'rgba(201,168,76,0.07)', border: '1px solid rgba(201,168,76,0.2)', borderRadius: '8px', padding: '0.75rem 1rem', fontSize: '0.85rem', color: '#a89a6a' }}>
<strong style={{ color: '#c9a84c' }}>Recommended:</strong> Enable two-factor authentication to protect your study notes and account.
</div>
)}
{auth.totpEnabled && auth.totpRecoveryCodesRemaining !== undefined && auth.totpRecoveryCodesRemaining <= 2 && (
<div style={{ marginTop: '1rem', background: 'rgba(224,92,92,0.07)', border: '1px solid rgba(224,92,92,0.2)', borderRadius: '8px', padding: '0.75rem 1rem', fontSize: '0.85rem', color: '#e07a7a' }}>
<strong>Warning:</strong> You only have {auth.totpRecoveryCodesRemaining} recovery code{auth.totpRecoveryCodesRemaining === 1 ? '' : 's'} left.{' '}
<button type="button" style={{ background: 'none', border: 'none', color: '#c9a84c', cursor: 'pointer', textDecoration: 'underline', fontSize: 'inherit' }} onClick={async () => {
try {
const data = await readJson<{ ok: boolean; recoveryCodes: string[] }>('/api/study-auth/totp-regen-recovery', { method: 'POST' })
setTwoFaRecoveryCodes(data.recoveryCodes)
setAccountModal('recoveryCodes')
} catch { /* ignore */ }
}}>Generate new codes →</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 === 'choose2fa' && (
<>
<h2 id="account-modal-heading">Choose Your 2FA Method</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1.5rem', color: '#b9b09b' }}>
Pick how you'd like to verify your identity each time you sign in.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<button type="button" style={{ textAlign: 'left', background: '#1a1a12', border: '1px solid rgba(201,168,76,0.25)', borderRadius: '10px', padding: '1rem 1.25rem', cursor: 'pointer', color: '#f0e9cc' }} onClick={async () => {
setTwoFaBusy(true); setTwoFaMessage('')
try {
const data = await readJson<{ qrDataUrl: string; secret: string }>('/api/study-auth/totp-setup-init', { method: 'POST' })
if (!data.qrDataUrl || !data.secret) throw new Error('Setup response missing QR data.')
setTwoFaQr(data.qrDataUrl)
setTwoFaSecret(data.secret)
setTwoFaCode('')
setTwoFaMessage('')
setAccountModal('setup2fa')
} catch (err) { setTwoFaMessage(err instanceof Error ? err.message : 'Could not start setup. Please try again.') }
finally { setTwoFaBusy(false) }
}}>
<p style={{ fontWeight: 700, margin: '0 0 0.25rem' }}>📱 Authenticator App</p>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#8a7f5a' }}>Use Google Authenticator, Authy, 1Password, or any TOTP app. Works offline. More secure.</p>
</button>
<button type="button" style={{ textAlign: 'left', background: '#1a1a12', border: '1px solid rgba(201,168,76,0.25)', borderRadius: '10px', padding: '1rem 1.25rem', cursor: 'pointer', color: '#f0e9cc' }} onClick={async () => {
setTwoFaBusy(true); setTwoFaMessage('')
try {
await readJson('/api/study-auth/2fa-setup-email', { method: 'POST' })
setTwoFaCode(''); setTwoFaMessage('')
setAccountModal('setup2fa-email')
} catch { setTwoFaMessage('Could not send code. Please try again.') }
finally { setTwoFaBusy(false) }
}}>
<p style={{ fontWeight: 700, margin: '0 0 0.25rem' }}>📧 Email Code</p>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#8a7f5a' }}>A one-time code sent to your email each time you sign in. Easier to set up.</p>
</button>
</div>
{twoFaMessage && <p style={{ color: '#e07a7a', marginTop: '0.75rem' }}>{twoFaMessage}</p>}
</>
)}
{accountModal === 'setup2fa-email' && (
<>
<h2 id="account-modal-heading">Verify Your Email for 2FA</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1.25rem', color: '#b9b09b' }}>
We sent a 6-digit code to your email. Enter it below to activate email-based two-factor authentication.
</p>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Verification Code</label>
<input
type="text" inputMode="numeric" autoComplete="one-time-code"
value={twoFaCode}
onChange={e => setTwoFaCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
autoFocus
style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem', letterSpacing: '0.3em', fontSize: '1.3rem', textAlign: 'center' }}
/>
{twoFaMessage && <p style={{ color: '#e07a7a', marginBottom: '0.5rem' }}>{twoFaMessage}</p>}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'center' }}>
<button type="button" className="btn-primary" disabled={twoFaBusy || twoFaCode.length < 6} onClick={async () => {
setTwoFaBusy(true); setTwoFaMessage('')
try {
await readJson('/api/study-auth/2fa-setup-email-confirm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: twoFaCode }) })
setAuth(prev => ({ ...prev, totpEnabled: true, twoFaMethod: 'email', totpRecoveryCodesRemaining: 0 }))
closeAccountModal()
} catch (err) { setTwoFaMessage(err instanceof Error ? err.message : 'Invalid code.') }
finally { setTwoFaBusy(false) }
}}>{twoFaBusy ? 'Activating' : 'Activate Email 2FA'}</button>
<button type="button" style={{ background: 'none', border: 'none', color: '#c9a84c', cursor: 'pointer', fontSize: '0.85rem', textDecoration: 'underline' }} onClick={async () => {
try { await readJson('/api/study-auth/2fa-setup-email', { method: 'POST' }) } catch { /* ignore */ }
}}>Resend code</button>
</div>
</>
)}
{accountModal === 'setup2fa' && (
<>
<h2 id="account-modal-heading">Enable Two-Factor Authentication</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1.25rem', color: '#b9b09b' }}>
Scan the QR code below with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code to confirm.
</p>
{twoFaQr ? (
<div style={{ textAlign: 'center', marginBottom: '1.25rem' }}>
<img
src={twoFaQr}
alt="2FA QR code"
style={{ display: 'block', margin: '0 auto 0.75rem', borderRadius: '8px', background: '#fff', padding: '0.5rem' }}
width={180} height={180}
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
<p style={{ fontSize: '0.8rem', color: '#5a5440', margin: 0 }}>Can't scan? Enter this key manually:</p>
</div>
) : (
<p style={{ fontSize: '0.85rem', color: '#8a7f5a', marginBottom: '0.5rem' }}>Enter this key manually in your authenticator app:</p>
)}
{twoFaSecret && (
<div style={{ background: '#0a0a08', border: '1px solid rgba(201,168,76,0.2)', borderRadius: '8px', padding: '0.75rem 1rem', marginBottom: '1.25rem', textAlign: 'center', wordBreak: 'break-all' }}>
<code style={{ color: '#c9a84c', fontSize: '0.95rem', letterSpacing: '0.1em' }}>{twoFaSecret}</code>
</div>
)}
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Verification Code</label>
<input
type="text" inputMode="numeric" autoComplete="one-time-code"
value={twoFaCode}
onChange={e => setTwoFaCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem', letterSpacing: '0.3em', fontSize: '1.3rem', textAlign: 'center' }}
/>
{twoFaMessage && <p style={{ color: '#e07a7a', marginBottom: '0.5rem' }}>{twoFaMessage}</p>}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button type="button" className="btn-primary" disabled={twoFaBusy || twoFaCode.length < 6} onClick={async () => {
setTwoFaBusy(true); setTwoFaMessage('')
try {
const data = await readJson<{ ok: boolean; recoveryCodes: string[] }>('/api/study-auth/totp-setup-confirm', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: twoFaCode }),
})
setTwoFaRecoveryCodes(data.recoveryCodes)
setAuth(prev => ({ ...prev, totpEnabled: true, totpRecoveryCodesRemaining: data.recoveryCodes.length }))
setAccountModal('recoveryCodes')
} catch (err) {
setTwoFaMessage(err instanceof Error ? err.message : 'Code incorrect. Try again.')
} finally { setTwoFaBusy(false) }
}}>{twoFaBusy ? 'Verifying…' : 'Activate 2FA'}</button>
</div>
</>
)}
{accountModal === 'recoveryCodes' && (
<>
<h2 id="account-modal-heading">Save Your Recovery Codes</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1.25rem', color: '#b9b09b' }}>
Store these codes somewhere safe. Each one can be used once if you ever lose access to your authenticator app. <strong style={{ color: '#f0ead8' }}>You won't be able to see these again.</strong>
</p>
<div style={{ background: '#0a0a08', border: '1px solid rgba(201,168,76,0.2)', borderRadius: '8px', padding: '1rem', marginBottom: '1.25rem', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
{twoFaRecoveryCodes.map(code => (
<code key={code} style={{ color: '#c9a84c', fontSize: '0.9rem', letterSpacing: '0.1em' }}>{code}</code>
))}
</div>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button type="button" className="btn-primary" onClick={() => {
navigator.clipboard?.writeText(twoFaRecoveryCodes.join('\n')).catch(() => {})
}}>Copy All</button>
<button type="button" className="btn-secondary" onClick={closeAccountModal}>Done</button>
</div>
</>
)}
{accountModal === 'disable2fa' && (
<>
<h2 id="account-modal-heading">Disable Two-Factor Authentication</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1.25rem', color: '#b9b09b' }}>
Enter your password to confirm. This will remove {auth.twoFaMethod === 'email' ? 'email code' : 'authenticator app'} 2FA from your account.
</p>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Current Password</label>
<input
type="password" autoComplete="current-password"
value={disablePassword}
onChange={e => setDisablePassword(e.target.value)}
placeholder="Your password"
style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem' }}
/>
{twoFaMessage && <p style={{ color: '#e07a7a', marginBottom: '0.5rem' }}>{twoFaMessage}</p>}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button type="button" className="btn-admin-remove" disabled={twoFaBusy || !disablePassword} onClick={async () => {
setTwoFaBusy(true); setTwoFaMessage('')
try {
await readJson('/api/study-auth/totp-disable', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: disablePassword }),
})
setAuth(prev => ({ ...prev, totpEnabled: false, totpRecoveryCodesRemaining: 0 }))
setDisablePassword('')
closeAccountModal()
} catch (err) {
setTwoFaMessage(err instanceof Error ? err.message : 'Could not disable 2FA.')
} finally { setTwoFaBusy(false) }
}}>{twoFaBusy ? 'Disabling' : 'Disable 2FA'}</button>
<button type="button" className="btn-secondary" onClick={closeAccountModal}>Cancel</button>
</div>
</>
)}
{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('')
const [shareToDiscussion, setShareToDiscussion] = useState<Record<number, boolean>>({})
const [sharedIndexes, setSharedIndexes] = useState<Set<number>>(new Set())
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 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 }),
})
// Post any checked answers to the shared discussion
const toShare = Object.entries(shareToDiscussion)
.filter(([, checked]) => checked)
.map(([idx]) => Number(idx))
.filter(idx => !sharedIndexes.has(idx) && (answers[idx] ?? '').trim().length >= 2)
const newlyShared: number[] = []
for (const idx of toShare) {
const questionText = section.studyQuestions?.[idx] ? `**${section.studyQuestions[idx]}**\n\n` : ''
const text = `${questionText}${answers[idx].trim()}`
try {
const res = await fetch('/api/study-community/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ studySlug, sectionId, message: text.slice(0, 2000) }),
})
if (res.ok) newlyShared.push(idx)
} catch { /* ignore individual share failures */ }
}
if (newlyShared.length > 0) {
setSharedIndexes(prev => new Set([...prev, ...newlyShared]))
setShareToDiscussion(prev => {
const next = { ...prev }
newlyShared.forEach(idx => { next[idx] = false })
return next
})
}
setMessage(newlyShared.length > 0 ? `Answers saved and ${newlyShared.length} shared to the discussion.` : 'Answers saved.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to save answers.')
} finally {
setSaving(false)
}
}
if (!study || !section) {
return (
<main className="thanks-page" aria-label="Discussion questions not found">
<div className="thanks-card">
<p className="eyebrow">Reflection Questions</p>
<h1>Discussion questions not found</h1>
<p>The reflection questions you requested are 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 reflection questions">
<div className="thanks-card"><p>Loading reflection questions...</p></div>
</main>
)
}
if (!auth.authenticated) {
return (
<main className="thanks-page" aria-label="Sign in required">
<div className="thanks-card">
<p className="eyebrow">Reflection Questions</p>
<h1>Sign In Required</h1>
<p>You need to sign in before you can view and save your discussion answers.</p>
<Link to="/study/signup" className="btn-primary">Sign In</Link>
</div>
</main>
)
}
if (!isEnrolled) {
return (
<main className="thanks-page" aria-label="Enrollment required">
<div className="thanks-card">
<p className="eyebrow">Reflection Questions</p>
<h1>Enroll to Access Reflection Questions</h1>
<p>Please enroll in {study.title} to open the reflection questions for this lesson.</p>
<Link to={`/study/${study.slug}`} className="btn-primary">Go to Study</Link>
</div>
</main>
)
}
return (
<main className="study-index-page" aria-label="Discussion questions">
<section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner">
<p className="eyebrow">Reflection Questions</p>
<h1>{section.title}</h1>
<p className="study-course-hero-copy">Write out your answers below — they're saved privately. You can also choose to share individual answers to the lesson discussion.</p>
<div className="study-course-hero-actions">
<Link to={`/study/${study.slug}/${section.id}`} className="btn-secondary">Back to Lesson</Link>
</div>
</div>
</section>
<section className="section-study-module" style={{ paddingTop: '2rem' }}>
<div className="section-inner">
{loading ? (
<p>Loading reflection questions...</p>
) : error ? (
<p className="study-note-status">{error}</p>
) : !canSubmit ? (
<article className="study-class-block">
<h2>No Reflection Questions</h2>
<p className="study-detail-copy">This lesson does not have reflection questions configured yet.</p>
</article>
) : (
<form onSubmit={e => { e.preventDefault(); saveQuiz() }}>
{sectionQuestions.map((question, index) => (
<div key={index} className="quiz-question-block">
<p className="quiz-question-label">{`${index + 1}. ${question}`}</p>
<textarea
rows={4}
className="quiz-question-textarea"
value={answers[index] ?? ''}
onChange={e => setAnswers(prev => {
const next = [...prev]
next[index] = e.target.value
return next
})}
placeholder="Write your answer here…"
/>
{(answers[index] ?? '').trim().length >= 2 && (
<label className="quiz-share-toggle">
<input
type="checkbox"
checked={shareToDiscussion[index] ?? false}
disabled={sharedIndexes.has(index)}
onChange={e => setShareToDiscussion(prev => ({ ...prev, [index]: e.target.checked }))}
/>
{sharedIndexes.has(index)
? <span className="quiz-share-label quiz-share-label--done"> Shared to discussion</span>
: <span className="quiz-share-label">Share this answer to the lesson discussion</span>}
</label>
)}
</div>
))}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '0.5rem' }}>
<button type="submit" className="btn-primary" disabled={saving}>{saving ? 'Saving…' : 'Save Answers'}</button>
<button type="button" className="btn-secondary" onClick={() => navigate(`/study/${study.slug}/${section.id}`)}>Back to Lesson</button>
</div>
{message && <p className="study-note-status" style={{ marginTop: '1rem' }}>{message}</p>}
</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>
)
}