Files
Siteforge/src/studyPages.tsx
T
nmemmertandClaude Sonnet 5.5 753b5c77cf
Publish Container / build (push) Successful in 15m3s
Study guide button downloads the file directly with no name/email form; v2.36.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 09:56:20 -04:00

4464 lines
221 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 DOMPurify from 'dompurify'
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
import type { SiteContent, StudyProgram, StudySection } from './content'
import { getStudyGuideLink } from './content'
import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './legacyColossiansData'
import { usePageMeta } from './hooks/usePageMeta'
import { Breadcrumbs } from './components/Breadcrumbs'
import { StudyCertificate } from './components/StudyCertificate'
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
import { NoteCard } from './components/NoteCard'
import { StudySectionComments } from './components/StudySectionComments'
type Props = { content: SiteContent }
type StudyAuthState = {
checked: boolean
authenticated: boolean
username: string
enrolledStudySlugs?: string[]
displayName?: string
subscribeNewsletter?: boolean
studyRemindersEnabled?: boolean
avatarUrl?: string
totpEnabled?: boolean
twoFaMethod?: 'app' | 'email' | null
totpRecoveryCodesRemaining?: number
emailVerified?: boolean
}
type StudyAuthStatusResponse = {
authenticated: boolean
username: string
enrolledStudySlugs?: string[]
displayName?: string
subscribeNewsletter?: boolean
studyRemindersEnabled?: boolean
avatarUrl?: string
totpEnabled?: boolean
twoFaMethod?: 'app' | 'email' | null
totpRecoveryCodesRemaining?: number
emailVerified?: boolean
}
type StudyAccountOverview = {
profile: {
username: string
displayName: string
subscribeNewsletter: boolean
studyRemindersEnabled: boolean
reminderFrequency?: string
remindersPausedUntil?: string | null
avatarUrl?: string
emailVerified?: boolean
}
stats: {
noteCount: number
noteLimit: number
noteCharLimit: number
memberSince: string
lastLoginAt: string | null
currentStreak: number
longestStreak: number
lastStudiedDate: string | null
}
studies: Array<{
slug: string
title: string
status: 'active' | 'planned' | 'complete'
enrolled: boolean
completed?: boolean
totalLessons: number
completedLessons: number
noteCount: number
}>
certificates: Array<{
studySlug: string
studyTitle: string
issuedAt: string
url: string
}>
}
type StudyNotesMap = Record<string, string>
type StudyProgress = {
completedSectionIds: string[]
bookmarkedSectionIds: string[]
}
type StudyCommunityReply = {
id: string
authorName: string
authorAvatarUrl?: string
message: string
createdAt: string
}
type StudyCommunityPost = {
id: string
studySlug: string
sectionId: string
authorName: string
authorAvatarUrl?: string
message: string
createdAt: string
replies: StudyCommunityReply[]
likeCount?: number
likedByMe?: boolean
pending?: boolean
}
function StudyStatusBadge({ status }: { status: 'active' | 'planned' | 'complete' }) {
const label = status === 'active' ? 'Active' : status === 'complete' ? 'Complete' : 'Planned'
return <span className={`study-status-badge ${status}`}>{label}</span>
}
function getLegacyColossiansStudy(content: SiteContent): StudyProgram {
const legacySections = content.colossiansStudySections?.length ? content.colossiansStudySections : DEFAULT_COLOSSIANS_STUDY_SECTIONS
return {
id: 'study-colossians',
slug: 'colossians',
title: 'Colossians: Rooted in Christ',
description: 'Walk through Colossians in guided lessons with commentary, Greek notes, and discussion prompts.',
status: 'active',
difficulty: 'intermediate',
estimatedHours: 12,
completionBadge: 'Colossians Completion',
numberOfChapters: 4,
sections: legacySections,
}
}
function getStudies(content: SiteContent): StudyProgram[] {
if (Array.isArray(content.studies) && content.studies.length > 0) return content.studies
return [getLegacyColossiansStudy(content)]
}
function getStudyBySlug(studies: StudyProgram[], slug: string | undefined) {
const safeSlug = (slug ?? 'colossians').trim().toLowerCase()
return studies.find(study => study.slug === safeSlug)
}
function getSectionById(sections: StudySection[], sectionId: string | undefined) {
if (!sectionId) return undefined
return sections.find(section => section.id === sectionId)
}
function getLessonNumber(sections: StudySection[], sectionId: string | undefined) {
const index = sections.findIndex(item => item.id === sectionId)
return index >= 0 ? index + 1 : 0
}
function getPrimaryQuestion(section: StudySection) {
if (section.focusQuestion?.trim()) return section.focusQuestion.trim()
return section.studyQuestions.length > 0 ? section.studyQuestions[0] : 'How does this passage shape the way we follow Christ this week?'
}
function getChapterSummaries(study: StudyProgram) {
const highestSectionChapter = study.sections.reduce((max, section) => Math.max(max, section.chapter), 0)
const totalChapters = Math.max(study.numberOfChapters, highestSectionChapter)
return Array.from({ length: totalChapters }, (_, index) => {
const chapter = index + 1
const lessonCount = study.sections.filter(section => section.chapter === chapter).length
return { chapter, lessonCount }
})
}
function getSectionReleaseDate(section: StudySection): Date | null {
const candidateValues = [
section?.releasedAt,
(section as StudySection & { releaseDate?: string }).releaseDate,
(section as StudySection & { availableAt?: string }).availableAt,
(section as StudySection & { publishAt?: string }).publishAt,
]
for (const candidate of candidateValues) {
if (typeof candidate !== 'string' || !candidate.trim()) continue
const parsed = new Date(candidate)
if (!Number.isNaN(parsed.getTime())) return parsed
}
return null
}
function isNewLesson(section: StudySection): boolean {
const releaseDate = getSectionReleaseDate(section)
if (!releaseDate) return false
const now = new Date()
const daysSinceRelease = (now.getTime() - releaseDate.getTime()) / (1000 * 60 * 60 * 24)
return daysSinceRelease >= 0 && daysSinceRelease <= 14
}
function isComingSoon(section: StudySection): boolean {
const releaseDate = getSectionReleaseDate(section)
if (!releaseDate) return false
const now = new Date()
return releaseDate > now
}
function isSectionReleased(section: StudySection): boolean {
const releaseDate = getSectionReleaseDate(section)
if (!releaseDate) return false
return releaseDate <= new Date()
}
// A study is complete when an admin marks it so, or when every lesson has been released.
// Complete studies stay open for enrollment and are fully self-paced.
function getEffectiveStudyStatus(study: StudyProgram): 'active' | 'planned' | 'complete' {
if (study.status === 'planned') return 'planned'
if (study.status === 'complete') return 'complete'
if (study.sections.length > 0 && study.sections.every(isSectionReleased)) return 'complete'
return 'active'
}
// Discussion (community board and lesson comments) is open for every study that has started,
// including finished ones, so students who take a study later can still join in. Planned
// studies are closed until they begin.
function isDiscussionOpen(study: StudyProgram | undefined): boolean {
return Boolean(study) && getEffectiveStudyStatus(study as StudyProgram) !== 'planned'
}
function getNextReleaseLabel(study: StudyProgram): string {
const upcoming = study.sections
.map(getSectionReleaseDate)
.filter((date): date is Date => date !== null && date > new Date())
.sort((a, b) => a.getTime() - b.getTime())[0]
if (!upcoming) return ''
return upcoming.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
}
function getReleaseDateDisplay(section: StudySection): string {
const date = getSectionReleaseDate(section)
if (!date) return ''
const now = new Date()
const diffMs = date.getTime() - now.getTime()
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
if (diffDays === 1) return 'tomorrow'
if (diffDays > 1 && diffDays <= 14) return `in ${diffDays} days`
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
function getNoteId(studySlug: string, sectionId: string) {
return `${studySlug}--${sectionId}`
}
type NoteAnchorId = 'scripture' | 'commentary' | 'questions' | 'general'
const ANCHOR_LABELS: Record<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 {
// Not valid JSON — treat the raw string as legacy plain-text notes.
}
return { general: raw }
}
function getDisplayName(auth: StudyAuthState) {
return auth.displayName?.trim() || auth.username.split('@')[0] || 'student'
}
function formatRelativeTime(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const diffMs = Date.now() - date.getTime()
const diffSec = Math.floor(diffMs / 1000)
if (diffSec < 60) return 'just now'
const diffMin = Math.floor(diffSec / 60)
if (diffMin < 60) return `${diffMin}m ago`
const diffHr = Math.floor(diffMin / 60)
if (diffHr < 24) return `${diffHr}h ago`
const diffDay = Math.floor(diffHr / 24)
if (diffDay < 7) return `${diffDay}d ago`
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
function UserAvatar({ name, url, size = 36 }: { name?: string; url?: string; size?: number }) {
if (url) {
return <img src={url} alt={name || 'avatar'} width={size} height={size} className="community-avatar" />
}
const initial = (name || 'S').slice(0, 1).toUpperCase()
return (
<div className="community-avatar community-avatar--initials" style={{ width: size, height: size, fontSize: size * 0.4 }}>
{initial}
</div>
)
}
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 [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) setPosts(Array.isArray(data.posts) ? data.posts : [])
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'Unable to load the community right now.')
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [canParticipate, study.slug])
async function submitPost() {
if (!newPost.trim() || !canParticipate) return
setSubmitting(true); setError('')
const optimisticId = `pending-${Date.now()}`
const optimisticPost: StudyCommunityPost = {
id: optimisticId,
studySlug: study.slug,
sectionId: activeSection,
authorName: communityDisplayName,
authorAvatarUrl: auth.avatarUrl || undefined,
message: newPost,
createdAt: new Date().toISOString(),
replies: [],
likeCount: 0,
likedByMe: false,
pending: true,
}
setPosts(prev => [optimisticPost, ...prev])
const postedMessage = newPost
setNewPost('')
try {
const data = await readJson<{ post?: StudyCommunityPost }>('/api/study-community/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ studySlug: study.slug, sectionId: activeSection, message: postedMessage }),
})
setPosts(prev => data.post
? prev.map(p => p.id === optimisticId ? { ...data.post!, likeCount: 0, likedByMe: false, pending: false } : p)
: prev.filter(p => p.id !== optimisticId))
} catch (err) {
setPosts(prev => prev.filter(p => p.id !== optimisticId))
setNewPost(postedMessage)
setError(err instanceof Error ? err.message : 'Unable to post right now.')
} finally {
setSubmitting(false)
}
}
async function submitReply(postId: string) {
const reply = replyDrafts[postId]?.trim()
if (!reply || !canParticipate) return
setSubmitting(true); setError('')
try {
const data = await readJson<{ reply?: StudyCommunityReply }>(`/api/study-community/posts/${encodeURIComponent(postId)}/replies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: reply }),
})
if (data.reply) {
setPosts(prev => prev.map(post => post.id === postId ? { ...post, replies: [...(post.replies ?? []), data.reply!] } : post))
}
setReplyDrafts(prev => ({ ...prev, [postId]: '' }))
setReplyingTo(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to post your reply.')
} finally {
setSubmitting(false)
}
}
async function toggleLike(postId: string) {
if (!canParticipate) return
setPosts(prev => prev.map(p => p.id === postId
? { ...p, likedByMe: !p.likedByMe, likeCount: (p.likeCount ?? 0) + (p.likedByMe ? -1 : 1) }
: p))
try {
await readJson(`/api/study-community/posts/${encodeURIComponent(postId)}/like`, {
method: 'POST',
})
} catch {
setPosts(prev => prev.map(p => p.id === postId
? { ...p, likedByMe: !p.likedByMe, likeCount: (p.likeCount ?? 0) + (p.likedByMe ? -1 : 1) }
: p))
}
}
const postCountFor = (sectionId: string) => posts.filter(p => p.sectionId === sectionId).length
const lessonsForFilter = releasedSections.filter(item => postCountFor(item.id) > 0 || item.id === activeSection)
const activeSectionTitle = releasedSections.find(item => item.id === activeSection)?.title
return (
<div className="community-layout">
<div className="community-feed">
{/* Mobile lesson filter */}
{releasedSections.length > 1 && canParticipate && (
<div className="community-filter-select">
<label htmlFor="community-lesson-select">Lesson</label>
<select id="community-lesson-select" value={activeSection} onChange={e => setActiveSection(e.target.value)}>
<option value="">All discussion ({posts.length})</option>
{releasedSections.map(item => (
<option key={item.id} value={item.id}>{item.title}{postCountFor(item.id) > 0 ? ` (${postCountFor(item.id)})` : ''}</option>
))}
</select>
</div>
)}
{!auth.checked && <p className="community-status-msg">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">Sign In</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 className="community-compose-card">
<UserAvatar name={communityDisplayName} url={auth.avatarUrl || undefined} size={40} />
<div className="community-compose-body">
{activeSection && (
<div className="community-compose-tag">
<span>Posting to:</span>
<strong>{releasedSections.find(s => s.id === activeSection)?.title ?? 'this lesson'}</strong>
<button type="button" className="community-compose-tag-clear" onClick={() => setActiveSection('')} aria-label="Clear lesson filter">×</button>
</div>
)}
<textarea
rows={3}
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…'}
className="community-compose-textarea"
/>
<div className="community-compose-footer">
<button type="button" className="btn-primary community-post-btn" disabled={submitting || !newPost.trim()} onClick={submitPost}>
{submitting ? 'Posting…' : 'Post'}
</button>
</div>
</div>
</div>
)}
{error && <p className="community-status-msg community-status-msg--error">{error}</p>}
{canParticipate && (
<div className="community-posts-list">
{loading ? (
<p className="community-status-msg">Loading posts…</p>
) : visiblePosts.length === 0 ? (
<p className="community-status-msg">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)
const isReplying = replyingTo === post.id
return (
<article key={post.id} className={`community-post-card${post.pending ? ' community-post-card--pending' : ''}`}>
<div className="community-post-left">
<UserAvatar name={post.authorName} url={post.authorAvatarUrl} size={40} />
{(post.replies?.length ?? 0) > 0 && <div className="community-thread-line" />}
</div>
<div className="community-post-body">
{/* Header */}
<div className="community-post-header">
<span className="community-post-author">{post.authorName || 'Student'}</span>
<span className="community-post-time" title={post.createdAt}>{formatRelativeTime(post.createdAt)}</span>
{postSection && !activeSection && (
<button type="button" className="community-lesson-chip" onClick={() => setActiveSection(postSection.id)}>{postSection.title}</button>
)}
</div>
{/* Content */}
<p className="community-post-text">{post.message}</p>
{/* Engagement bar */}
<div className="community-engagement-bar">
<button
type="button"
className={`community-action-btn${post.likedByMe ? ' community-action-btn--liked' : ''}`}
onClick={() => toggleLike(post.id)}
aria-label={post.likedByMe ? 'Unlike' : 'Like'}
disabled={post.pending}
>
<svg viewBox="0 0 24 24" className="community-action-icon" aria-hidden="true">
<path d={post.likedByMe
? 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z'
: 'M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z'}
/>
</svg>
{(post.likeCount ?? 0) > 0 && <span>{post.likeCount}</span>}
</button>
<button
type="button"
className={`community-action-btn${isReplying ? ' community-action-btn--active' : ''}`}
onClick={() => setReplyingTo(curr => curr === post.id ? null : post.id)}
aria-label="Reply"
disabled={post.pending}
>
<svg viewBox="0 0 24 24" className="community-action-icon" aria-hidden="true">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" />
</svg>
{(post.replies?.length ?? 0) > 0 && <span>{post.replies!.length}</span>}
</button>
</div>
{/* Reply compose */}
{isReplying && (
<div className="community-reply-compose">
<UserAvatar name={communityDisplayName} url={auth.avatarUrl || undefined} size={30} />
<div style={{ flex: 1 }}>
<textarea
rows={2}
autoFocus
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 || 'this post'}…`}
className="community-compose-textarea"
/>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
<button type="button" className="btn-primary" style={{ fontSize: '0.875rem', padding: '0.35rem 0.9rem' }} disabled={submitting || !(replyDrafts[post.id] ?? '').trim()} onClick={() => submitReply(post.id)}>Reply</button>
<button type="button" className="btn-secondary" style={{ fontSize: '0.875rem', padding: '0.35rem 0.9rem' }} onClick={() => setReplyingTo(null)}>Cancel</button>
</div>
</div>
</div>
)}
{/* Replies */}
{Array.isArray(post.replies) && post.replies.length > 0 && (
<div className="community-replies">
{post.replies.map(reply => (
<div key={reply.id} className="community-reply">
<UserAvatar name={reply.authorName} url={reply.authorAvatarUrl} size={28} />
<div className="community-reply-body">
<div className="community-post-header">
<span className="community-post-author">{reply.authorName || 'Student'}</span>
<span className="community-post-time">{formatRelativeTime(reply.createdAt)}</span>
</div>
<p className="community-post-text">{reply.message}</p>
</div>
</div>
))}
</div>
)}
</div>
</article>
)
})}
</div>
)}
</div>
{canParticipate && (
<aside className="community-aside" aria-label="Browse by lesson">
<section className="community-aside-card">
<h2>Browse by lesson</h2>
<ul className="community-lesson-list">
<li>
<button type="button" className={`community-lesson-item${activeSection === '' ? ' community-lesson-item--active' : ''}`} onClick={() => setActiveSection('')}>
<span>All discussion</span><small>{posts.length}</small>
</button>
</li>
{lessonsForFilter.map(item => (
<li key={item.id}>
<button type="button" className={`community-lesson-item${activeSection === item.id ? ' community-lesson-item--active' : ''}`} onClick={() => setActiveSection(item.id)}>
<span>{item.title}</span><small>{postCountFor(item.id)}</small>
</button>
</li>
))}
</ul>
{activeSection && activeSectionTitle && postCountFor(activeSection) === 0 && (
<p className="community-aside-note">No posts on “{activeSectionTitle}” yet — yours can be the first.</p>
)}
</section>
<section className="community-aside-card">
<h2>Community guidelines</h2>
<ul className="community-guidelines">
<li>Be gracious — assume the best of others.</li>
<li>Stay on topic and close to the text.</li>
<li>Ask honest questions; share what you're learning.</li>
</ul>
</section>
</aside>
)}
</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 => getEffectiveStudyStatus(study) === 'active')
const completeStudies = studies.filter(study => getEffectiveStudyStatus(study) === 'complete')
const plannedStudies = studies.filter(study => getEffectiveStudyStatus(study) === 'planned')
const firstActiveStudy = activeStudies[0] ?? completeStudies[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) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [])
const enrolledStudies = overview?.studies.filter(study => study.enrolled) ?? []
// Finished studies stay in the classroom (with their certificate) even if the student left the enrollment
const classroomStudies = [...(overview?.studies.filter(study => study.enrolled || study.completed) ?? [])]
.sort((a, b) => Number(Boolean(a.completed)) - Number(Boolean(b.completed)))
const completedStudyCount = classroomStudies.filter(study => study.completed).length
const availableStudies = [...(overview?.studies ?? [])].sort((a, b) => Number(b.status === 'active') - Number(a.status === 'active'))
const totalEnrolled = enrolledStudies.length
const totalCompletedLessons = classroomStudies.reduce((sum, study) => sum + study.completedLessons, 0)
const totalLessons = classroomStudies.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>
{completedStudyCount > 0 && <span>{completedStudyCount} completed</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>
<StudyStatusBadge status={studyInfo.status} />
<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>
{studyInfo.completed && <p style={{ margin: '0.4rem 0 0', fontSize: '0.85rem', color: '#6fcf97' }}>✓ Completed — leaving only stops reminders. You keep your lessons, notes and certificate.</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', opacity: !studyInfo.enrolled && studyInfo.status === 'planned' ? 0.7 : 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'flex-start' }}>
<div>
<StudyStatusBadge status={studyInfo.status} />
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
{!studyInfo.enrolled && studyInfo.status === 'planned' ? <p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem', color: '#b09e79' }}>Not open for enrollment yet</p> : <p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>}
{studyInfo.completed && <p style={{ margin: '0.4rem 0 0', fontSize: '0.85rem', color: '#6fcf97' }}>✓ Completed — you keep your lessons, notes and certificate.</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>
) : !studyInfo.enrolled && studyInfo.status === 'planned' ? (
<span style={{ fontSize: '0.85rem', color: '#b09e79' }}>Coming soon</span>
) : (
<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>Your Tracks</h2>
</div>
{classroomStudies.length === 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">
{classroomStudies.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.completed ? '✓ Completed' : 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 className="study-progress-track" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={progress} style={{ marginTop: '0.75rem' }}>
<div className="study-progress-fill" style={{ width: `${progress}%` }} />
</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">{study.completed ? 'Review Lessons' : 'Continue'}</Link>
<Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>
{isDiscussionOpen(studyMeta) && <Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>}
{study.completed && <Link to="/study/account" className="btn-secondary">🏅 Certificate</Link>}
</div>
</div>
</article>
{progress === 100 && (
<StudyCertificate studySlug={study.slug} />
)}
</div>
)
})}
</div>
)}
</div>
</section>
</main>
)
}
const availableCount = activeStudies.length + completeStudies.length
const renderTrackCard = (study: StudyProgram, variant: 'active' | 'complete' | 'planned') => {
const releasedCount = study.sections.filter(isSectionReleased).length
const nextLabel = getNextReleaseLabel(study)
const book = variant === 'complete' ? (content.finishedBooks ?? []).find(item => item.studySlug === study.slug) : undefined
const body = (
<>
<div className="study-track-top">
<span className={`study-track-status study-track-status--${variant}`}>
{variant === 'active' ? 'In progress · start anytime' : variant === 'complete' ? 'Complete · self-paced' : 'Coming soon'}
</span>
{study.difficulty && <span className={`study-difficulty study-difficulty--${study.difficulty}`}>{study.difficulty}</span>}
</div>
<h3 className="study-track-title">{study.title}</h3>
<p className="study-track-desc">{study.description}</p>
<ul className="study-track-facts">
{variant === 'planned' ? (
<li>
{study.startsAt && new Date(study.startsAt).getTime() > Date.now()
? `Starts ${new Date(study.startsAt).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}`
: 'Preparing lesson structure and media'}
</li>
) : (
<>
<li>{variant === 'complete' ? `All ${study.sections.length} lessons available` : releasedCount > 0 ? `${releasedCount} of ${study.sections.length} lessons available` : 'Lessons will be published soon'}</li>
{variant === 'active' && nextLabel && <li>Next lesson: {nextLabel}</li>}
{study.estimatedHours ? <li>About {study.estimatedHours} hours</li> : null}
</>
)}
</ul>
{variant !== 'planned' && <span className="study-track-cta">View lessons →</span>}
</>
)
return (
<div className="study-track-wrap" key={study.id}>
{variant === 'planned'
? <article className="study-track-card study-track-card--planned" aria-disabled="true">{body}</article>
: <Link to={`/study/${study.slug}`} className="study-track-card">{body}</Link>}
{book && (
<p className="study-track-listen">
Just want to listen? <Link to={`/finished/${book.id}`}>Open the audio playlist — no account needed →</Link>
</p>
)}
</div>
)
}
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>Self-paced</span>
<span>Personal notes</span>
<span>Audio + commentary</span>
<span>Free account</span>
</div>
<div className="study-course-hero-actions">
{firstActiveStudyFirstReleasedSection
? <Link to={`/study/${firstActiveStudy.slug}/${firstActiveStudyFirstReleasedSection.id}`} className="btn-primary">Start Learning</Link>
: <a href="#study-tracks" className="btn-primary">Browse Studies</a>}
<Link to="/study/signup" className="btn-secondary">Create Free Account or Sign In</Link>
</div>
<p className="study-hub-count">
{availableCount} {availableCount === 1 ? 'study' : 'studies'} available
{completeStudies.length > 0 ? ` (${completeStudies.length} finished, start anytime)` : ''}
{plannedStudies.length > 0 ? ` · ${plannedStudies.length} coming soon` : ''}
</p>
</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>
<ol className="study-hub-steps">
{[
{ title: 'Choose Your Track', body: 'Start with any active study and begin at lesson one, or jump back in where you left off.' },
{ title: 'Work Each Lesson', body: 'Read the text, listen to audio, review commentary, and process key Greek word notes.' },
{ title: 'Save Notes and Continue', body: 'Keep personal notes per lesson and build your own study archive over time.' },
].map((step, index) => (
<li className="study-hub-step" key={step.title}>
<span className="study-hub-step-number" aria-hidden="true">{index + 1}</span>
<h3>{step.title}</h3>
<p>{step.body}</p>
</li>
))}
</ol>
</div>
</section>
{activeStudies.length > 0 && (
<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-track-grid">{activeStudies.map(study => renderTrackCard(study, 'active'))}</div>
</div>
</section>
)}
{completeStudies.length > 0 && (
<section className="section-study-module" aria-label="Finished studies" id="finished-studies">
<div className="section-inner">
<div className="study-module-header">
<p>Study On Your Own Time</p>
<h2>Finished Studies</h2>
</div>
<div className="study-track-grid">{completeStudies.map(study => renderTrackCard(study, 'complete'))}</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-track-grid">{plannedStudies.map(study => renderTrackCard(study, 'planned'))}</div>
</div>
</section>
)}
<section className="section-study-module" aria-label="Q&A">
<div className="section-inner">
<div className="study-hub-ask">
<p className="eyebrow">Have a question from the Word?</p>
<h2>Ask Nate a Question</h2>
<p>Submit a Bible or theology question and it may be answered in a future episode or the Q&amp;A archive.</p>
<div className="study-hub-ask-actions">
<Link to="/questions" className="btn-primary">Browse Q&amp;A Archive →</Link>
<Link to="/contact" className="btn-secondary">Submit a Question</Link>
</div>
</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' | 'forgot'>('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')
// Forgot-password state
const [forgotSent, setForgotSent] = useState(false)
const [showPassword, setShowPassword] = useState(false)
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, navigate])
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, navigate])
const submitForgot = useCallback(async () => {
if (!email.trim() || !EMAIL_REGEX.test(email.trim())) {
setMessage('Please enter a valid email address.')
return
}
setBusy(true)
setMessage('')
try {
await readJson<{ ok: boolean }>('/api/study-auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: email }),
})
setForgotSent(true)
} catch (err) {
setMessage(err instanceof Error ? err.message : 'Something went wrong. Please try again.')
} finally {
setBusy(false)
}
}, [email])
const showAside = !done && mode !== 'forgot' && !totpPendingToken
return (
<main className="auth-page" aria-label={mode === 'signup' ? 'Create account' : mode === 'forgot' ? 'Reset password' : 'Sign in'}>
<div className={`auth-shell${showAside ? '' : ' auth-shell--single'}`}>
{showAside && (
<aside className="auth-aside" aria-label={mode === 'signup' ? 'Why create an account' : 'Welcome back'}>
{mode === 'signup' ? (
<>
<p className="eyebrow">Free Student Account</p>
<h2 className="auth-aside-title">Make every lesson your own.</h2>
<ul className="auth-benefits">
<li><span aria-hidden="true">📝</span><div><strong>Notes on every lesson</strong><p>Up to 500 notes across all tracks, each holding about 2,000 words.</p></div></li>
<li><span aria-hidden="true">✅</span><div><strong>Track your progress</strong><p>Pick up exactly where you left off, on any device.</p></div></li>
<li><span aria-hidden="true">🔒</span><div><strong>Private to you</strong><p>Your notes are tied to your account. Nobody else can read them.</p></div></li>
<li><span aria-hidden="true">⚡</span><div><strong>Free, no ads</strong><p>Just an email and password. You stay signed in for 30 days.</p></div></li>
</ul>
</>
) : (
<>
<p className="eyebrow">Free Student Account</p>
<h2 className="auth-aside-title">Pick up where you left off.</h2>
<ul className="auth-benefits">
<li><span aria-hidden="true">📝</span><div><strong>Your notes</strong><p>Every lesson note you've saved, waiting for you.</p></div></li>
<li><span aria-hidden="true">✅</span><div><strong>Your progress</strong><p>Resume your studies right where you stopped.</p></div></li>
</ul>
</>
)}
</aside>
)}
<div className="auth-card">
<p className="eyebrow auth-card-eyebrow">{mode === 'signup' ? 'Create account' : mode === 'forgot' ? 'Account recovery' : 'Sign in'}</p>
<h1>{mode === 'signup' ? 'Create Your Account' : mode === 'forgot' ? 'Reset Your Password' : '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>
</>
) : mode === 'forgot' ? (
<div className="study-signup-form">
{forgotSent ? (
<>
<p className="study-signup-success">If that email has an account, a reset link is on its way.</p>
<p style={{ color: '#b09e79', marginBottom: '1.5rem', fontSize: '0.95rem' }}>Check your inbox and follow the link to choose a new password. The link expires in 1 hour.</p>
</>
) : (
<>
<p style={{ color: '#a89a6a', marginBottom: '1.25rem', fontSize: '0.95rem' }}>
Enter the email address on your account and we'll send you a link to reset your password.
</p>
<label htmlFor="forgot-email">Email</label>
<input
id="forgot-email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="your@email.com"
autoComplete="email"
autoFocus
onKeyDown={e => e.key === 'Enter' && submitForgot()}
/>
{message && <p className="study-signup-error">{message}</p>}
<div className="study-signup-actions">
<button type="button" className="btn-primary" disabled={busy} onClick={submitForgot}>
{busy ? 'Sending…' : 'Send Reset Link'}
</button>
</div>
</>
)}
<div className="study-signup-actions" style={{ marginTop: '1rem' }}>
<button type="button" className="btn-secondary" onClick={() => { setMode('login'); setMessage(''); setForgotSent(false) }}>Back to Sign In</button>
</div>
</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>
) : (
<>
<form className="study-signup-form" noValidate onSubmit={e => { e.preventDefault(); void submit() }}>
<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>
<div className="auth-password-wrap">
<input
id="signup-password"
type={showPassword ? 'text' : '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={mode === 'signup' ? 'At least 8 characters' : 'Your password'}
autoComplete={mode === 'signup' ? 'new-password' : 'current-password'}
/>
<button type="button" className="auth-password-toggle" aria-pressed={showPassword} onClick={() => setShowPassword(v => !v)}>
{showPassword ? 'Hide' : 'Show'}
</button>
</div>
{fieldErrors.password && <p className="study-signup-field-error">{fieldErrors.password}</p>}
{mode === 'login' && (
<button type="button" onClick={() => { setMode('forgot'); setMessage(''); setForgotSent(false) }} style={{ background: 'none', border: 'none', color: '#c9a84c', cursor: 'pointer', fontSize: '0.85rem', textDecoration: 'underline', padding: 0, textAlign: 'left', marginTop: '0.5rem', alignSelf: 'flex-start' }}>
Forgot password?
</button>
)}
{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" role="alert">{message}</p>}
<div className="study-signup-actions">
<button type="submit" className="btn-primary" disabled={busy}>
{busy ? (mode === 'signup' ? 'Creating...' : 'Signing in...') : (mode === 'signup' ? 'Create Account' : 'Sign In')}
</button>
</div>
<p className="auth-switch">
{mode === 'login' ? "Don't have an account? " : 'Already have an account? '}
<button type="button" className="auth-link" onClick={() => { setMode(mode === 'login' ? 'signup' : 'login'); setMessage('') }}>
{mode === 'login' ? 'Sign up' : 'Sign in'}
</button>
</p>
</form>
</>
)}
<p className="auth-footer-links">
<Link to="/study">← Back to Studies</Link>
</p>
</div>
</div>
</main>
)
}
export function StudyResetPasswordPage() {
const navigate = useNavigate()
const location = useLocation()
const token = useMemo(() => new URLSearchParams(location.search).get('token') ?? '', [location.search])
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
const [done, setDone] = useState(false)
const submit = useCallback(async () => {
if (!token) { setMessage('This reset link is missing its token.'); return }
if (newPassword.length < 8) { setMessage('New password must be at least 8 characters.'); return }
if (newPassword !== confirmPassword) { setMessage('Passwords do not match.'); return }
setBusy(true)
setMessage('')
try {
await readJson<{ ok: boolean }>('/api/study-auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, newPassword }),
})
setDone(true)
} catch (err) {
setMessage(err instanceof Error ? err.message : 'Unable to reset password. Please request a new link.')
} finally {
setBusy(false)
}
}, [token, newPassword, confirmPassword])
return (
<main className="thanks-page" aria-label="Reset password">
<div className="thanks-card study-signup-page-card">
<p className="eyebrow">Free Student Account</p>
<h1>Reset Your Password</h1>
{!token ? (
<p className="study-signup-error">This link is missing its token. Please use the link from your email, or request a new one.</p>
) : done ? (
<>
<p className="study-signup-success">Your password has been reset.</p>
<p style={{ color: '#b09e79', marginBottom: '1.5rem', fontSize: '0.95rem' }}>You've been signed out of all devices for security. Sign in below with your new password.</p>
<button type="button" className="btn-primary" onClick={() => navigate('/study/signup')}>Sign In</button>
</>
) : (
<div className="study-signup-form">
<label htmlFor="reset-new-password">New Password</label>
<input
id="reset-new-password"
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder="At least 8 characters"
autoComplete="new-password"
autoFocus
/>
<label htmlFor="reset-confirm-password">Confirm New Password</label>
<input
id="reset-confirm-password"
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
placeholder="Re-enter new password"
autoComplete="new-password"
onKeyDown={e => e.key === 'Enter' && submit()}
/>
{message && <p className="study-signup-error">{message}</p>}
<div className="study-signup-actions">
<button type="button" className="btn-primary" disabled={busy} onClick={submit}>
{busy ? 'Resetting…' : 'Reset Password'}
</button>
</div>
</div>
)}
<div className="study-signup-actions" style={{ marginTop: '1.5rem' }}>
<Link to="/study/signup" className="btn-secondary">Back to Sign In</Link>
</div>
</div>
</main>
)
}
export function StudyVerifyEmailPage() {
const location = useLocation()
const token = useMemo(() => new URLSearchParams(location.search).get('token') ?? '', [location.search])
const [status, setStatus] = useState<'checking' | 'ok' | 'error'>(token ? 'checking' : 'error')
const [message, setMessage] = useState(token ? '' : 'This link is missing its token.')
useEffect(() => {
if (!token) return
readJson<{ ok: boolean }>('/api/study-auth/verify-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
.then(() => setStatus('ok'))
.catch(err => {
setStatus('error')
setMessage(err instanceof Error ? err.message : 'Unable to verify this link.')
})
}, [token])
return (
<main className="thanks-page" aria-label="Verify email">
<div className="thanks-card study-signup-page-card">
<p className="eyebrow">Free Student Account</p>
<h1>Verify Your Email</h1>
{status === 'checking' && <p style={{ color: '#b09e79' }}>Verifying…</p>}
{status === 'ok' && (
<>
<p className="study-signup-success">Your email address is verified.</p>
<p style={{ color: '#b09e79', marginBottom: '1.5rem', fontSize: '0.95rem' }}>Thanks for confirming — you're all set.</p>
</>
)}
{status === 'error' && <p className="study-signup-error">{message}</p>}
<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: [], bookmarkedSectionIds: [] })
const [progressLoading, setProgressLoading] = useState(false)
const enrolled = isEnrolledInStudy(auth, study?.slug)
useEffect(() => {
let cancelled = false
readJson<StudyAuthStatusResponse>('/api/study-auth/status')
.then(data => {
if (cancelled) return
setAuth({
checked: true,
authenticated: Boolean(data.authenticated),
username: data.username ?? '',
enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs),
})
})
.catch(() => {
if (cancelled) return
setAuth({ checked: true, authenticated: false, username: '', enrolledStudySlugs: [] })
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!auth.checked || !auth.authenticated || !study || !enrolled) return
let cancelled = false
setProgressLoading(true)
readJson<{ studySlug: string; completedSectionIds: string[]; bookmarkedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(study.slug)}`)
.then(data => {
if (cancelled) return
setStudyProgress({ completedSectionIds: Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [], bookmarkedSectionIds: Array.isArray(data.bookmarkedSectionIds) ? data.bookmarkedSectionIds : [] })
})
.catch(() => {
if (cancelled) return
setStudyProgress({ completedSectionIds: [], bookmarkedSectionIds: [] })
})
.finally(() => {
if (cancelled) return
setProgressLoading(false)
})
return () => {
cancelled = true
}
}, [auth.checked, auth.authenticated, study, enrolled])
usePageMeta(
study ? `${study.title} | Bible Study` : 'Study Not Found',
study?.description,
)
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 studyStatus = getEffectiveStudyStatus(study)
const isSelfPaced = studyStatus === 'complete'
// Resume at the first released lesson the student hasn't finished yet
const resumeSection = releasedSections.find(item => !studyProgress.completedSectionIds.includes(item.id)) ?? firstReleasedSection
const hasStarted = enrolled && studyProgress.completedSectionIds.length > 0
const allLessonsDone = enrolled && !progressLoading && sections.length > 0 && sections.every(item => studyProgress.completedSectionIds.includes(item.id))
const resumeChapter = resumeSection?.chapter ?? chapterSummaries.find(chapter => chapter.lessonCount > 0)?.chapter
const completedCount = sections.filter(item => studyProgress.completedSectionIds.includes(item.id)).length
const chaptersWithLessons = chapterSummaries.filter(chapter => chapter.lessonCount > 0)
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)
}
}
const studyGuide = getStudyGuideLink(content, study)
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} of {sections.length} lessons available</span>
<span>Text + commentary + discussion</span>
<span>Student notes enabled</span>
{isSelfPaced && <span>Finished study — go at your own pace</span>}
</div>
{allLessonsDone && (
<p className="study-completed-banner" role="status">🎉 You've completed this study. Your lessons, notes and certificate are yours to keep.</p>
)}
{enrolled && !progressLoading && sections.length > 0 && (
<div className="study-progress" role="group" aria-label="Your progress">
<div className="study-progress-label">
<span>Your progress</span>
<strong>{completedCount} of {sections.length} lessons completed</strong>
</div>
<div className="study-progress-track" role="progressbar" aria-valuemin={0} aria-valuemax={sections.length} aria-valuenow={completedCount}>
<div className="study-progress-fill" style={{ width: `${Math.round((completedCount / sections.length) * 100)}%` }} />
</div>
</div>
)}
<div className="study-course-hero-actions">
{enrolled && resumeSection && <Link to={`/study/${study.slug}/${resumeSection.id}`} className="btn-primary">{allLessonsDone ? 'Review Lessons' : hasStarted ? 'Resume Where You Left Off' : '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>
)}
{studyGuide && (studyGuide.downloadUrl
? <a href={studyGuide.downloadUrl} target="_blank" rel="noopener noreferrer" download className="btn-secondary">📄 Download Study Guide</a>
: <Link to={`/downloads/${studyGuide.pageId}`} className="btn-secondary">📄 Get the Study Guide</Link>)}
{allLessonsDone && <Link to="/study/account" className="btn-secondary">🏅 My Certificate</Link>}
{enrolled && <Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>}
{enrolled && studyStatus !== 'planned' && <Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>}
{auth.authenticated && <Link to="/study/account" className="btn-secondary">My Account</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>
{chaptersWithLessons.map(({ chapter }) => {
const chapterSections = sections.filter(item => item.chapter === chapter)
const chapterDone = chapterSections.filter(item => studyProgress.completedSectionIds.includes(item.id)).length
return (
<details className="study-chapter" key={chapter} open={chaptersWithLessons.length === 1 || chapter === resumeChapter}>
<summary className="study-chapter-head">
<h3>Chapter {chapter}</h3>
<span>
{enrolled && !progressLoading
? `${chapterDone} of ${chapterSections.length} completed`
: `${chapterSections.length} ${chapterSections.length === 1 ? 'lesson' : 'lessons'}`}
</span>
</summary>
<div className="study-module-list">
{chapterSections.map(section => {
const sectionIdx = sections.indexOf(section)
const lessonNumber = sectionIdx + 1
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 completed = studyProgress.completedSectionIds.includes(section.id)
const lockedByProgress = enrolled && !isSelfPaced && !coming && sectionIdx > 0 && !prevCompleted && !completed
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' : ''}${coming ? ' study-module-row--coming' : ''}${completed && enrolled ? ' study-module-row--done' : ''}`} aria-disabled={isDisabled || undefined} onClick={e => isDisabled && e.preventDefault()}>
<div className="study-module-row-left">
<div className="study-lesson-meta">
<p className="study-module-lesson">Lesson {lessonNumber}</p>
{isNew && <span className="lesson-badge lesson-badge--new">New</span>}
{coming && <span className="lesson-badge lesson-badge--coming">Coming {getReleaseDateDisplay(section)}</span>}
{!coming && !enrolled && <span className="lesson-badge lesson-badge--enroll">Enroll to open</span>}
{lockedByProgress && <span className="lesson-badge lesson-badge--locked">Finish the previous lesson first</span>}
{enrolled && !lockedByProgress && completed && (
<span className="lesson-badge lesson-badge--done">
<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="currentColor" strokeWidth="1.2" />
<path d="M3.5 6.5l2 2 3.5-3.5" stroke="currentColor" 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>
</details>
)
})}
</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 [bookmarkedSectionIds, setBookmarkedSectionIds] = useState<string[]>([])
const [bookmarkSaving, setBookmarkSaving] = useState(false)
const [progressSaving, setProgressSaving] = useState(false)
const [progressMessage, setProgressMessage] = useState('')
const [checkpointAnswers, setCheckpointAnswers] = useState<Record<number, string>>({})
const [quizAnswers, setQuizAnswers] = useState<string[]>([])
const quizAnswersDirtyRef = useRef(false)
const [showCelebration, setShowCelebration] = useState(false)
const [bannerDismissed, setBannerDismissed] = useState(() => localStorage.getItem('study-banner-dismissed-v1') === '1')
const [autoSaveMsg, setAutoSaveMsg] = 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 discussionOpen = isDiscussionOpen(study)
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[]; bookmarkedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(currentStudySlug)}`)
.then(data => {
if (cancelled) return
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
setBookmarkedSectionIds(Array.isArray(data.bookmarkedSectionIds) ? data.bookmarkedSectionIds : [])
})
.catch(() => {
if (cancelled) return
setCompletedSectionIds([])
setBookmarkedSectionIds([])
})
.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)
}, [noteMap, noteId, auth.authenticated, noteLoading])
useEffect(() => {
if (!auth.authenticated || !isEnrolled || !study || !section?.id) {
setQuizAnswers([])
quizAnswersDirtyRef.current = false
return
}
let cancelled = false
readJson<{ answers: string[] }>(`/api/study-quiz/${encodeURIComponent(currentStudySlug)}/${encodeURIComponent(section.id)}`)
.then(data => {
if (cancelled) return
setQuizAnswers(Array.isArray(data.answers) ? data.answers : [])
quizAnswersDirtyRef.current = false
})
.catch(() => { if (!cancelled) setQuizAnswers([]) })
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [auth.authenticated, isEnrolled, currentStudySlug, section?.id])
useEffect(() => {
if (!auth.authenticated || !isEnrolled || !study || !section?.id) return
if (!quizAnswersDirtyRef.current) return
const timer = setTimeout(async () => {
if (!quizAnswersDirtyRef.current) return
quizAnswersDirtyRef.current = false
try {
await readJson<{ ok: boolean; answers: string[] }>(`/api/study-quiz/${encodeURIComponent(currentStudySlug)}/${encodeURIComponent(section.id)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers: quizAnswers }),
})
} catch {
quizAnswersDirtyRef.current = true
}
}, 1500)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [quizAnswers, auth.authenticated, isEnrolled, currentStudySlug, section?.id])
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="auth-page" aria-label="Sign in required">
<div className="auth-shell auth-shell--single">
<div className="auth-card auth-card--center">
<p className="eyebrow">{study.title}</p>
<h1>Sign In to Continue</h1>
<p>Create a free account or sign in to open lessons and save your personal notes.</p>
<div className="study-signup-actions">
<Link to="/study/signup" className="btn-primary">Create Account or Sign In</Link>
</div>
<p className="auth-footer-links"><Link to={`/study/${study.slug}`}>← Back to lessons</Link></p>
</div>
</div>
</main>
)
}
if (!isEnrolled) {
return (
<main className="auth-page" aria-label="Enrollment required">
<div className="auth-shell auth-shell--single">
<div className="auth-card auth-card--center">
<p className="eyebrow">{study.title}</p>
<h1>Enroll to Begin</h1>
<p>Enroll in this study to open its lessons and keep your notes and progress.</p>
<div className="study-signup-actions">
<button type="button" className="btn-primary" disabled={enrollBusy} onClick={enrollInCurrentStudy}>
{enrollBusy ? 'Enrolling...' : `Enroll in ${study.title}`}
</button>
</div>
{enrollMessage && <p className="study-note-status" role="status">{enrollMessage}</p>}
<p className="auth-footer-links"><Link to={`/study/${study.slug}`}>← Back to study</Link></p>
</div>
</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>
{sections.length > 0 && (
<div className="study-lesson-progress-bar" role="progressbar" aria-valuenow={completedSectionIds.length} aria-valuemax={sections.length} aria-label={`${completedSectionIds.length} of ${sections.length} lessons completed`}>
<div className="study-lesson-progress-fill" style={{ width: `${Math.round((completedSectionIds.length / sections.length) * 100)}%` }} />
</div>
)}
<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')}
aria-pressed={openAnchor === 'scripture'}
>{noteMap['scripture']?.trim() ? '✎ Note' : '+ Note'}</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>}
</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')}
aria-pressed={openAnchor === 'commentary'}
>{noteMap['commentary']?.trim() ? '✎ Note' : '+ Note'}</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>
))}
</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')}
aria-pressed={openAnchor === 'questions'}
>{noteMap['questions']?.trim() ? '✎ Note' : '+ Note'}</button>
)}
<h2>Reflection Questions</h2>
<ol className="study-detail-list study-detail-list--questions">
{section.studyQuestions.map((question, index) => (
<li key={index} className="study-question-item">
<p className="study-question-text">{question}</p>
{isEnrolled && (
<textarea
className="study-question-answer"
value={quizAnswers[index] ?? ''}
onChange={e => {
const next = [...quizAnswers]
next[index] = e.target.value
setQuizAnswers(next)
quizAnswersDirtyRef.current = true
}}
placeholder="Write your answer…"
rows={2}
/>
)}
</li>
))}
</ol>
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
{discussionOpen && <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>
</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>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<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>
{isEnrolled && section?.id && (
<button
type="button"
className="btn-secondary"
style={bookmarkedSectionIds.includes(section.id) ? { borderColor: '#c9a84c', color: '#c9a84c' } : {}}
disabled={bookmarkSaving}
title={bookmarkedSectionIds.includes(section.id) ? 'Remove bookmark' : 'Bookmark this lesson'}
onClick={async () => {
if (!study || !section?.id) return
setBookmarkSaving(true)
const isBookmarked = bookmarkedSectionIds.includes(section.id)
try {
const data = await readJson<{ ok: boolean; bookmarkedSectionIds: string[] }>(
`/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}/bookmark`,
{ method: isBookmarked ? 'DELETE' : 'POST' }
)
setBookmarkedSectionIds(Array.isArray(data.bookmarkedSectionIds) ? data.bookmarkedSectionIds : [])
} catch { /* ignore */ }
finally { setBookmarkSaving(false) }
}}
>
{bookmarkSaving ? '…' : bookmarkedSectionIds.includes(section.id) ? '🔖 Bookmarked' : '🔖 Bookmark'}
</button>
)}
</div>
</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>
)
})()}
{discussionOpen && <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">
{/* Lesson navigator */}
<article className="study-class-side-block">
<h3>Lessons <span className="study-lesson-nav-progress">{completedSectionIds.length}/{sections.length}</span></h3>
<nav className="study-lesson-nav" aria-label="All lessons">
{sections.map(s => {
const sNum = getLessonNumber(sections, s.id)
const isDone = completedSectionIds.includes(s.id)
const isCurr = s.id === section.id
const released = isSectionReleased(s)
return (
<Link
key={s.id}
to={released ? `/study/${study.slug}/${s.id}` : '#'}
aria-current={isCurr ? 'page' : undefined}
className={`study-lesson-nav-item${isCurr ? ' study-lesson-nav-item--current' : ''}${isDone ? ' study-lesson-nav-item--done' : ''}${!released ? ' study-lesson-nav-item--locked' : ''}`}
onClick={e => { if (!released) e.preventDefault() }}
>
<span className="study-lesson-nav-icon" aria-hidden="true">
{isDone ? '✓' : isCurr ? String(sNum) : released ? String(sNum) : '○'}
</span>
<span className="study-lesson-nav-label">{s.title}</span>
</Link>
)
})}
</nav>
</article>
{/* Notes panel */}
<article className="study-class-side-block">
<h3>
Your Notes
{savedNoteCount > 0 && <span className="study-notes-count-badge">{savedNoteCount}</span>}
</h3>
{!auth.checked && <p className="study-detail-copy" style={{ fontSize: '0.85rem' }}>Checking sign-in…</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>
{authMessage && <p className="study-note-status" style={{ marginTop: '0.5rem' }}>{authMessage}</p>}
</div>
)}
{auth.checked && auth.authenticated && (
<>
{noteLoading ? (
<p style={{ color: '#7a7060', fontSize: '0.85rem' }}>Loading notes…</p>
) : openAnchor ? (
<NoteCard
anchorLabel={ANCHOR_LABELS[openAnchor] ?? 'Notes'}
html={noteMap[openAnchor] ?? ''}
autoSaveMsg={autoSaveMsg}
onChange={html => updateNote(openAnchor as NoteAnchorId, html)}
onClose={() => setOpenAnchor(null)}
/>
) : (
<p style={{ fontSize: '0.85rem', color: '#5a5040', lineHeight: 1.55 }}>
{savedNoteCount > 0
? `${savedNoteCount} note${savedNoteCount !== 1 ? 's' : ''} saved. Click a section's Note button to edit.`
: 'Click the Note button on Scripture, Commentary, or Reflection Questions to write notes here.'}
</p>
)}
{autoSaveMsg && <p style={{ fontSize: '0.82rem', color: '#7a9c6a', marginTop: '0.35rem' }}>{autoSaveMsg}</p>}
{noteMessage && <p className="study-note-status" style={{ marginTop: '0.35rem' }}>{noteMessage}</p>}
<Link to="/study/account" style={{ display: 'block', fontSize: '0.82rem', color: '#7a7060', marginTop: '0.75rem' }}>My Account →</Link>
</>
)}
</article>
{/* Community shortcut */}
{isEnrolled && discussionOpen && (
<article className="study-class-side-block study-class-side-block--community">
<Link to={`/study/${study.slug}/community?sectionId=${section.id}`} className="study-community-shortcut">
<svg viewBox="0 0 24 24" aria-hidden="true" className="study-community-shortcut-icon"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" /></svg>
<span>Discuss this lesson</span>
<span className="study-community-shortcut-arrow">→</span>
</Link>
</article>
)}
{/* Greek Word Study */}
{section.greekNotes.length > 0 && (
<article className="study-class-side-block">
<h3>Greek Word Study</h3>
<ul className="study-detail-list">
{section.greekNotes.map((note, index) => (
<li key={index}>{note}</li>
))}
</ul>
</article>
)}
{/* Prev / Next navigation */}
<div className="study-lesson-nav-actions">
{previousSection ? (
<Link to={`/study/${study.slug}/${previousSection.id}`} className="btn-secondary">← Previous</Link>
) : <span />}
{nextSection && !isSectionReleased(nextSection) ? (
<span className="study-nav-placeholder" style={{ fontSize: '0.82rem', color: '#5a5440' }}>Next available {getReleaseDateDisplay(nextSection)}</span>
) : nextSection && !lessonCompleted ? (
<span className="study-nav-placeholder" style={{ fontSize: '0.82rem', color: '#5a5440' }}>Complete lesson to continue</span>
) : nextSection ? (
<Link to={`/study/${study.slug}/${nextSection.id}`} className="btn-secondary">Next →</Link>
) : null}
</div>
</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('')
const [noteQuery, setNoteQuery] = 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) setLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, [study?.slug])
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()))
: []
const stripHtml = (html: string) => DOMPurify.sanitize(html, { ALLOWED_TAGS: [] }).replace(/\s+/g, ' ').trim()
const noteText = (raw: string) => Object.values(parseNoteMap(raw)).map(value => stripHtml(value ?? '')).filter(Boolean).join(' · ')
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 queryText = noteQuery.trim().toLowerCase()
const visible = queryText
? sorted.filter(item => `${item.section?.title ?? ''} ${item.section?.reference ?? ''} ${noteText(item.note)}`.toLowerCase().includes(queryText))
: sorted
const active = visible.find(item => item.sectionId === activeNoteTab) ?? visible[0]
return (
<main className="notes-page" aria-label="My study notes">
<div className="notes-shell">
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title, href: `/study/${study.slug}` }, { label: 'My Notes' }]} />
<header className="notes-header">
<div>
<p className="eyebrow">Student Workspace</p>
<h1>My Notes</h1>
<p className="notes-subtitle">
{study.title}
{!loading && auth.authenticated && enrolled && <> · {entries.length} {entries.length === 1 ? 'lesson' : 'lessons'} with notes</>}
</p>
</div>
<div className="notes-header-actions">
<Link to={`/study/${study.slug}`} className="btn-secondary">Back to Lessons</Link>
<Link to="/study/account" className="btn-secondary">My Account</Link>
</div>
</header>
{enrolledStudies.length > 1 && (
<div className="notes-tracks" role="tablist" aria-label="Study tracks">
{enrolledStudies.map(s2 => (
<Link
key={s2.slug}
to={`/study/${s2.slug}/notes`}
className={`notes-track${s2.slug === study.slug ? ' notes-track--active' : ''}`}
aria-current={s2.slug === study.slug ? 'page' : undefined}
>
{s2.title}
</Link>
))}
</div>
)}
{loading && <p className="notes-empty-text">Loading your notes…</p>}
{!loading && !auth.authenticated && (
<div className="notes-empty">
<h2>Sign in to see your notes</h2>
<p>Open any lesson and sign in from the Student Notes panel, and your saved notes will show up here.</p>
<Link to="/study/signup" className="btn-primary">Create Account or Sign In</Link>
</div>
)}
{!loading && auth.authenticated && !enrolled && (
<div className="notes-empty">
<h2>Enroll to use notes</h2>
<p>You need to enroll in this study before you can write and read lesson notes.</p>
<Link to={`/study/${study.slug}`} className="btn-primary">Go to Study Enrollment</Link>
</div>
)}
{!loading && auth.authenticated && enrolled && entries.length === 0 && (
<div className="notes-empty">
<div className="notes-empty-icon" aria-hidden="true">📝</div>
<h2>No notes yet</h2>
<p>Open a lesson and use the Student Notes panel to jot down what stands out. Everything you save collects here, private to you.</p>
{study.sections.find(isSectionReleased) && <Link to={`/study/${study.slug}/${study.sections.find(isSectionReleased)!.id}`} className="btn-primary">Open the First Lesson</Link>}
</div>
)}
{!loading && auth.authenticated && enrolled && entries.length > 0 && (
<div className="notes-layout">
<aside className="notes-list-pane" aria-label="Lessons with notes">
<input
type="search"
className="notes-search"
placeholder="Search your notes…"
aria-label="Search your notes"
value={noteQuery}
onChange={e => setNoteQuery(e.target.value)}
/>
{visible.length === 0 ? (
<p className="notes-empty-text">No notes match “{noteQuery.trim()}”.</p>
) : (
<ul className="notes-list" role="tablist" aria-label="Lesson notes">
{visible.map(({ sectionId, section, note }) => {
const lessonNumber = section ? getLessonNumber(study.sections, section.id) : 0
const isActive = active?.sectionId === sectionId
return (
<li key={sectionId}>
<button
type="button"
role="tab"
aria-selected={isActive}
className={`notes-item${isActive ? ' notes-item--active' : ''}`}
onClick={() => setActiveNoteTab(sectionId)}
>
<span className="notes-item-number" aria-hidden="true">{lessonNumber || '•'}</span>
<span className="notes-item-body">
<strong>{section?.title ?? 'Saved note'}</strong>
<small>{section?.reference ?? ''}</small>
<span className="notes-item-snippet">{noteText(note).slice(0, 110)}</span>
</span>
</button>
</li>
)
})}
</ul>
)}
</aside>
{active && (
<article className="notes-reader" aria-label="Note">
<p className="notes-reader-label">{active.section ? `Lesson ${getLessonNumber(study.sections, active.section.id)}` : 'Saved note'}{active.section?.reference ? ` · ${active.section.reference}` : ''}</p>
<h2>{active.section?.title ?? active.sectionId}</h2>
{(() => {
const anchorMap = parseNoteMap(active.note)
const blocks = Object.entries(anchorMap).filter(([, html]) => html?.trim())
if (blocks.length === 0) return <p className="notes-empty-text">No notes saved for this lesson yet.</p>
return blocks.map(([key, html]) => (
<section className="notes-block" key={key}>
<h3>{ANCHOR_LABELS[key] ?? key}</h3>
<div className="study-note-rich-content" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
</section>
))
})()}
{active.section && (
<div className="notes-reader-actions">
<Link to={`/study/${study.slug}/${active.section.id}`} className="btn-primary">Open Lesson →</Link>
</div>
)}
</article>
)}
</div>
)}
{message && <p className="study-note-status" role="alert">{message}</p>}
</div>
</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 [reminderFrequency, setReminderFrequency] = useState<'immediate' | 'weekly'>('immediate')
const [remindersPausedUntil, setRemindersPausedUntil] = useState<string | null>(null)
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 [certOpen, setCertOpen] = useState<Record<string, boolean>>({})
const [signOutBusy, setSignOutBusy] = useState(false)
const [signOutAllBusy, setSignOutAllBusy] = useState(false)
const [signOutAllMessage, setSignOutAllMessage] = useState('')
const [resendVerifyBusy, setResendVerifyBusy] = useState(false)
const [resendVerifyMessage, setResendVerifyMessage] = useState('')
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,
emailVerified: data.emailVerified === true,
})
})
.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)
setReminderFrequency(data.profile.reminderFrequency === 'weekly' ? 'weekly' : 'immediate')
setRemindersPausedUntil(data.profile.remindersPausedUntil ?? null)
})
.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)
setReminderFrequency(data.profile.reminderFrequency === 'weekly' ? 'weekly' : 'immediate')
setRemindersPausedUntil(data.profile.remindersPausedUntil ?? null)
}
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; reminderFrequency: string; remindersPausedUntil: string | null }>('/api/study-account/preferences', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscribeNewsletter, studyRemindersEnabled, reminderFrequency, remindersPausedUntil }),
})
setAuth(prev => ({ ...prev, subscribeNewsletter: data.subscribeNewsletter, studyRemindersEnabled: data.studyRemindersEnabled }))
setReminderFrequency(data.reminderFrequency === 'weekly' ? 'weekly' : 'immediate')
setRemindersPausedUntil(data.remindersPausedUntil ?? null)
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 handleSignOutAllDevices() {
setSignOutAllBusy(true)
setSignOutAllMessage('')
try {
await readJson<{ ok: boolean }>('/api/study-auth/logout-all', { method: 'POST' })
setSignOutAllMessage('Every other device has been signed out. This device stays signed in.')
} catch (err) {
setSignOutAllMessage(err instanceof Error ? err.message : 'Unable to sign out other devices.')
} finally {
setSignOutAllBusy(false)
}
}
async function handleResendVerification() {
setResendVerifyBusy(true)
setResendVerifyMessage('')
try {
const data = await readJson<{ ok: boolean; alreadyVerified?: boolean }>('/api/study-auth/resend-verification', { method: 'POST' })
setResendVerifyMessage(data.alreadyVerified ? 'Your email is already verified.' : 'Verification email sent. Check your inbox.')
} catch (err) {
setResendVerifyMessage(err instanceof Error ? err.message : 'Unable to send verification email.')
} finally {
setResendVerifyBusy(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="auth-page" aria-label="Loading">
<div className="auth-shell auth-shell--single">
<div className="auth-card auth-card--center"><p>Loading...</p></div>
</div>
</main>
)
}
if (!auth.authenticated) {
return (
<main className="auth-page" aria-label="Sign in required">
<div className="auth-shell auth-shell--single">
<div className="auth-card auth-card--center">
<p className="eyebrow">My Account</p>
<h1>Sign In Required</h1>
<p>Please sign in to manage your account.</p>
<div className="study-signup-actions">
<Link to="/study/signup" className="btn-primary">Sign In</Link>
</div>
</div>
</div>
</main>
)
}
// Finished studies stay listed (with their certificate) even after the student leaves the enrollment
const enrolledProgress = overview?.studies.filter(item => (item.enrolled || item.completed) && item.totalLessons > 0) ?? []
const memberSince = overview?.stats.memberSince && !isNaN(new Date(overview.stats.memberSince).getTime())
? new Date(overview.stats.memberSince).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—'
const initial = (auth.displayName?.slice(0, 1) || auth.username.slice(0, 1)).toUpperCase()
return (
<main className="account-page" aria-label="My account">
<div className="account-shell">
<header className="account-header">
{auth.avatarUrl ? (
<img className="account-avatar" src={auth.avatarUrl} alt="Your avatar" width={84} height={84} />
) : (
<div className="account-avatar account-avatar--initial" aria-hidden="true">{initial}</div>
)}
<div className="account-header-body">
<p className="eyebrow">Student Account</p>
<h1>{auth.displayName || auth.username.split('@')[0]}</h1>
<p className="account-email">
{auth.username}
{auth.emailVerified === false
? <span className="account-chip account-chip--warn">Unverified</span>
: <span className="account-chip account-chip--ok">Verified</span>}
</p>
</div>
<div className="account-header-actions">
<button type="button" className="btn-secondary" onClick={() => setAccountModal('profile')}>Edit Profile</button>
<button type="button" className="btn-secondary" disabled={signOutBusy} onClick={handleSignOut}>
{signOutBusy ? 'Signing out...' : 'Sign Out'}
</button>
</div>
</header>
{auth.emailVerified === false && (
<div className="account-notice" role="status">
<span><strong>Verify your email.</strong> We sent a link to {auth.username} — click it to confirm this address.</span>
<div className="account-notice-actions">
{resendVerifyMessage && <span className="account-notice-msg">{resendVerifyMessage}</span>}
<button type="button" className="btn-secondary" disabled={resendVerifyBusy} onClick={handleResendVerification}>
{resendVerifyBusy ? 'Sending…' : 'Resend Email'}
</button>
</div>
</div>
)}
<div className="account-layout">
<div className="account-main">
{loadingOverview && !overview && <p className="account-muted">Loading your progress…</p>}
{overview?.stats && (
<>
<div className="account-stats">
<div className="account-stat"><strong>{overview.stats.noteCount}</strong><span>Notes saved</span></div>
<div className="account-stat"><strong>{overview.stats.currentStreak ?? 0} 🔥</strong><span>Day streak</span></div>
<div className="account-stat"><strong>{overview.stats.longestStreak ?? 0}</strong><span>Longest streak</span></div>
<div className="account-stat"><strong className="account-stat-date">{memberSince}</strong><span>Member since</span></div>
</div>
<section className="account-card" aria-label="Study progress">
<div className="account-card-head">
<h2>Study Progress</h2>
<button type="button" className="account-link" onClick={() => setAccountModal('enrollments')}>Manage enrollments</button>
</div>
{enrolledProgress.length === 0 ? (
<p className="account-muted">You aren't enrolled in a study yet. <Link to="/study">Browse the studies →</Link></p>
) : enrolledProgress.map(item => {
const pct = Math.round((item.completedLessons / item.totalLessons) * 100)
return (
<div className="account-progress" key={item.slug}>
<div className="account-progress-row">
<Link to={`/study/${item.slug}`}>{item.title}{item.completed && <span className="account-chip account-chip--ok account-chip--inline">Completed</span>}</Link>
<span>{item.completedLessons}/{item.totalLessons} lessons · {pct}%</span>
</div>
<div className="study-progress-track" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={pct}>
<div className="study-progress-fill" style={{ width: `${pct}%` }} />
</div>
</div>
)
})}
</section>
{enrolledProgress.length > 0 && (
<section className="account-card" aria-label="Certificates">
<div className="account-card-head"><h2>Certificates</h2></div>
<p className="account-muted">Finish every lesson in a study to earn a certificate of completion you can view, download and share.</p>
{enrolledProgress.map(item => {
const cert = overview.certificates.find(c => c.studySlug === item.slug)
const ready = !cert && item.completedLessons >= item.totalLessons
return (
<details
className="account-cert-study"
key={item.slug}
open={certOpen[item.slug] ?? ready}
onToggle={e => {
const isOpen = (e.currentTarget as HTMLDetailsElement).open
setCertOpen(prev => (prev[item.slug] === isOpen ? prev : { ...prev, [item.slug]: isOpen }))
}}
>
<summary>
<span className="account-cert-title">{cert ? '🏅' : '🎓'} {item.title}</span>
{cert
? <span className="account-chip account-chip--ok">Earned {new Date(cert.issuedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</span>
: ready
? <span className="account-chip account-chip--warn">Ready to claim</span>
: <span className="account-chip account-chip--muted">{item.completedLessons} of {item.totalLessons} lessons</span>}
</summary>
{cert && (
<div className="account-cert-actions">
<Link to={new URL(cert.url).pathname} target="_blank" rel="noopener noreferrer" className="btn-primary">View certificate</Link>
<button type="button" className="btn-secondary" onClick={() => { navigator.clipboard?.writeText(cert.url).catch(() => {}) }}>Copy link</button>
</div>
)}
<StudyCertificate studySlug={item.slug} onIssued={() => { void refreshOverview() }} />
</details>
)
})}
</section>
)}
{overview.stats.noteLimit > 0 && (
<section className="account-card" aria-label="Note usage">
<div className="account-card-head">
<h2>Note Usage</h2>
<button type="button" className="account-link" onClick={() => setAccountModal('export')}>Export notes</button>
</div>
<div className="account-progress-row">
<span>{overview.stats.noteCount} of {overview.stats.noteLimit} notes</span>
<span>up to {overview.stats.noteCharLimit.toLocaleString()} characters each</span>
</div>
<div className="study-progress-track" role="progressbar" aria-valuemin={0} aria-valuemax={overview.stats.noteLimit} aria-valuenow={overview.stats.noteCount}>
<div className="study-progress-fill" style={{ width: `${Math.min(100, Math.round((overview.stats.noteCount / overview.stats.noteLimit) * 100))}%` }} />
</div>
</section>
)}
</>
)}
</div>
<aside className="account-side">
<section className="account-card" aria-label="Settings">
<div className="account-card-head"><h2>Settings</h2></div>
<ul className="account-menu">
<li><button type="button" onClick={() => setAccountModal('profile')}><span><strong>Profile</strong><small>Name and avatar</small></span><span aria-hidden="true">›</span></button></li>
<li><button type="button" onClick={() => setAccountModal('preferences')}><span><strong>Preferences</strong><small>Email updates and reminders</small></span><span aria-hidden="true">›</span></button></li>
<li><button type="button" onClick={() => setAccountModal('enrollments')}><span><strong>Enrollments</strong><small>Join or leave studies</small></span><span aria-hidden="true">›</span></button></li>
<li><button type="button" onClick={() => setAccountModal('export')}><span><strong>Export notes</strong><small>Download everything you've written</small></span><span aria-hidden="true">›</span></button></li>
</ul>
</section>
<section className="account-card" aria-label="Security">
<div className="account-card-head"><h2>Security</h2></div>
<ul className="account-menu">
<li><button type="button" onClick={() => setAccountModal('changeEmail')}><span><strong>Email address</strong><small>{auth.username}</small></span><span aria-hidden="true">›</span></button></li>
<li><button type="button" onClick={() => setAccountModal('changePassword')}><span><strong>Password</strong><small>Change your password</small></span><span aria-hidden="true">›</span></button></li>
<li>
<button type="button" disabled={twoFaBusy} onClick={() => {
if (auth.totpEnabled) { setAccountModal('disable2fa'); return }
setAccountModal('choose2fa')
}}>
<span>
<strong>Two-factor authentication</strong>
<small>{auth.totpEnabled ? `On — ${auth.twoFaMethod === 'email' ? 'email code' : 'authenticator app'}` : 'Off — recommended'}</small>
</span>
<span className={`account-chip ${auth.totpEnabled ? 'account-chip--ok' : 'account-chip--warn'}`}>{auth.totpEnabled ? 'On' : 'Off'}</span>
</button>
</li>
</ul>
{!auth.totpEnabled && (
<p className="account-callout">
<strong>Recommended:</strong> turn on two-factor authentication to protect your study notes and account.
</p>
)}
{auth.totpEnabled && auth.totpRecoveryCodesRemaining !== undefined && auth.totpRecoveryCodesRemaining <= 2 && (
<p className="account-callout account-callout--danger">
<strong>Warning:</strong> you only have {auth.totpRecoveryCodesRemaining} recovery code{auth.totpRecoveryCodesRemaining === 1 ? '' : 's'} left.{' '}
<button type="button" className="account-link" 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>
</p>
)}
<div className="account-session">
<button type="button" className="btn-secondary" disabled={signOutAllBusy} onClick={handleSignOutAllDevices}>
{signOutAllBusy ? 'Signing out other devices…' : 'Sign out of all other devices'}
</button>
{signOutAllMessage && <p className="study-note-status" role="status">{signOutAllMessage}</p>}
</div>
</section>
<section className="account-card account-card--danger" aria-label="Delete account">
<div className="account-card-head"><h2>Delete Account</h2></div>
{deleteStep === 'idle' && (
<>
<p className="account-muted">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 className="account-danger-step">
<p><strong>Final warning: export your notes first.</strong></p>
<div className="account-danger-actions">
<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 className="account-danger-step">
<p>Type <strong>DELETE</strong> to confirm permanent deletion:</p>
<input type="text" className="account-danger-input" value={deleteConfirmInput} onChange={e => setDeleteConfirmInput(e.target.value)} placeholder="DELETE" aria-label="Type DELETE to confirm" />
{deleteMessage && <p className="study-signup-error" role="alert">{deleteMessage}</p>}
<div className="account-danger-actions">
<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>
</aside>
</div>
{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', opacity: !studyInfo.enrolled && studyInfo.status === 'planned' ? 0.7 : 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'flex-start' }}>
<div>
<StudyStatusBadge status={studyInfo.status} />
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
{!studyInfo.enrolled && studyInfo.status === 'planned' ? <p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem', color: '#b09e79' }}>Not open for enrollment yet</p> : <p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons} of {studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>}
{studyInfo.completed && <p style={{ margin: '0.4rem 0 0', fontSize: '0.85rem', color: '#6fcf97' }}>✓ Completed — leaving only stops reminders. You keep your lessons, notes and certificate.</p>}
</div>
{!studyInfo.enrolled && studyInfo.status === 'planned' ? (
<span style={{ fontSize: '0.85rem', color: '#b09e79' }}>Coming soon</span>
) : (
<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>
{studyRemindersEnabled && (
<div style={{ marginLeft: '2rem', display: 'grid', gap: '0.75rem' }}>
<div>
<p style={{ margin: '0 0 0.4rem', fontSize: '0.875rem', color: '#b9b09b' }}>Reminder frequency</p>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
type="button"
onClick={() => setReminderFrequency('immediate')}
style={{
padding: '0.35rem 0.75rem',
borderRadius: '0.375rem',
border: reminderFrequency === 'immediate' ? '1px solid #d4af7a' : '1px solid #444',
background: reminderFrequency === 'immediate' ? '#2a2218' : 'transparent',
color: reminderFrequency === 'immediate' ? '#d4af7a' : '#b9b09b',
fontSize: '0.875rem',
cursor: 'pointer',
}}
>
Per-lesson
</button>
<button
type="button"
onClick={() => setReminderFrequency('weekly')}
style={{
padding: '0.35rem 0.75rem',
borderRadius: '0.375rem',
border: reminderFrequency === 'weekly' ? '1px solid #d4af7a' : '1px solid #444',
background: reminderFrequency === 'weekly' ? '#2a2218' : 'transparent',
color: reminderFrequency === 'weekly' ? '#d4af7a' : '#b9b09b',
fontSize: '0.875rem',
cursor: 'pointer',
}}
>
Weekly digest
</button>
</div>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.8rem', color: '#7a7060' }}>
{reminderFrequency === 'weekly'
? 'Receive one weekly email summarizing all new lessons.'
: 'Receive an email each time a new lesson is released.'}
</p>
</div>
<div>
<p style={{ margin: '0 0 0.4rem', fontSize: '0.875rem', color: '#b9b09b' }}>
Pause reminders
{remindersPausedUntil && new Date(remindersPausedUntil) > new Date()
? ` · paused until ${new Date(remindersPausedUntil).toLocaleDateString()}`
: ''}
</p>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{[7, 14, 30].map(days => (
<button
key={days}
type="button"
onClick={() => {
const d = new Date()
d.setDate(d.getDate() + days)
setRemindersPausedUntil(d.toISOString())
}}
style={{
padding: '0.35rem 0.75rem',
borderRadius: '0.375rem',
border: '1px solid #444',
background: 'transparent',
color: '#b9b09b',
fontSize: '0.875rem',
cursor: 'pointer',
}}
>
{days} days
</button>
))}
{remindersPausedUntil && new Date(remindersPausedUntil) > new Date() && (
<button
type="button"
onClick={() => setRemindersPausedUntil(null)}
style={{
padding: '0.35rem 0.75rem',
borderRadius: '0.375rem',
border: '1px solid #555',
background: 'transparent',
color: '#7a7060',
fontSize: '0.875rem',
cursor: 'pointer',
}}
>
Clear pause
</button>
)}
</div>
</div>
</div>
)}
<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>
)}
<p className="auth-footer-links"><Link to="/study">← Back to Studies</Link></p>
</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())
const [dirty, setDirty] = useState(false)
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) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [studySlug, sectionId])
useEffect(() => {
if (!dirty) return
function onBeforeUnload(e: BeforeUnloadEvent) { e.preventDefault() }
window.addEventListener('beforeunload', onBeforeUnload)
return () => window.removeEventListener('beforeunload', onBeforeUnload)
}, [dirty])
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
})
}
setDirty(false)
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>
)
}
const answeredCount = sectionQuestions.filter((_, index) => (answers[index] ?? '').trim().length >= 2).length
const canShare = isDiscussionOpen(study)
return (
<main className="quiz-page" aria-label="Reflection questions">
<div className="quiz-shell">
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title, href: `/study/${study.slug}` }, { label: section.title, href: `/study/${study.slug}/${section.id}` }, { label: 'Reflection' }]} />
<header className="quiz-header">
<p className="eyebrow">Reflection Questions</p>
<h1>{section.title}</h1>
{section.reference && <p className="quiz-reference">{section.reference}</p>}
<p className="quiz-intro">Take your time. Your answers are saved privately to your account{canShare ? ', and you can choose to share any of them with the lesson discussion' : ''}.</p>
{canSubmit && !loading && !error && (
<div className="quiz-progress" role="group" aria-label="Answer progress">
<div className="quiz-progress-label">
<span>{answeredCount} of {sectionQuestions.length} answered</span>
<Link to={`/study/${study.slug}/${section.id}`}>← Back to lesson</Link>
</div>
<div className="study-progress-track" role="progressbar" aria-valuemin={0} aria-valuemax={sectionQuestions.length} aria-valuenow={answeredCount}>
<div className="study-progress-fill" style={{ width: `${Math.round((answeredCount / sectionQuestions.length) * 100)}%` }} />
</div>
</div>
)}
</header>
{loading ? (
<p className="notes-empty-text">Loading reflection questions…</p>
) : error ? (
<p className="study-note-status" role="alert">{error}</p>
) : !canSubmit ? (
<div className="notes-empty">
<h2>No reflection questions</h2>
<p>This lesson doesn't have reflection questions yet.</p>
<Link to={`/study/${study.slug}/${section.id}`} className="btn-primary">Back to Lesson</Link>
</div>
) : (
<form className="quiz-form" onSubmit={e => { e.preventDefault(); void saveQuiz() }}>
{sectionQuestions.map((question, index) => {
const answer = answers[index] ?? ''
const hasAnswer = answer.trim().length >= 2
return (
<section key={index} className={`quiz-card${hasAnswer ? ' quiz-card--answered' : ''}`}>
<div className="quiz-card-head">
<span className="quiz-card-number" aria-hidden="true">{index + 1}</span>
<label className="quiz-card-question" htmlFor={`quiz-answer-${index}`}>{question}</label>
</div>
<textarea
id={`quiz-answer-${index}`}
rows={4}
className="quiz-card-textarea"
value={answer}
onChange={e => {
const value = e.target.value
setDirty(true)
setAnswers(prev => {
const next = [...prev]
next[index] = value
return next
})
}}
onInput={e => { const el = e.currentTarget; el.style.height = 'auto'; el.style.height = `${Math.max(el.scrollHeight, 110)}px` }}
placeholder="Write your answer here…"
/>
<div className="quiz-card-foot">
{canShare && hasAnswer ? (
<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 the discussion</span>
: <span className="quiz-share-label">Share this answer with the lesson discussion</span>}
</label>
) : <span />}
<span className="quiz-card-count">{answer.length.toLocaleString()} characters</span>
</div>
</section>
)
})}
<div className="quiz-savebar">
<p className="quiz-savebar-status" role="status" aria-live="polite">
{saving ? 'Saving…' : message ? message : dirty ? 'You have unsaved changes.' : 'All answers saved.'}
</p>
<div className="quiz-savebar-actions">
<button type="button" className="btn-secondary" onClick={() => navigate(`/study/${study.slug}/${section.id}`)}>Back to Lesson</button>
<button type="submit" className="btn-primary" disabled={saving || !dirty && answeredCount === 0}>{saving ? 'Saving…' : 'Save Answers'}</button>
</div>
</div>
</form>
)}
</div>
</main>
)
}
export function StudyDiscussionHubPage({ content }: Props) {
const studies = getStudies(content)
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [] })
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
}
}, [])
usePageMeta('Study Discussion | Bible Study', 'Pick a study track and join the conversation with other students.')
const openDiscussionStudies = studies.filter(study => isDiscussionOpen(study))
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">Study Discussion</p>
<h1>Sign In Required</h1>
<p>Create a free student account to see and join the conversation with other students.</p>
<Link to="/study/signup" className="btn-primary" style={{ marginTop: '1rem', display: 'inline-block' }}>Sign In</Link>
</div>
</main>
)
}
return (
<main className="study-index-page" aria-label="Study discussion">
<section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner">
<p className="eyebrow">Student Community</p>
<h1>Study Discussion</h1>
<p className="study-course-hero-copy">Pick a study track to see what other students are saying, ask questions, and share what God is teaching you.</p>
</div>
</section>
<section className="section-inner" style={{ paddingTop: '1rem', paddingBottom: '4rem' }}>
{openDiscussionStudies.length === 0 ? (
<p className="study-detail-copy">There are no studies open for discussion yet. Check back when the next one starts.</p>
) : (
<div className="study-module-list">
{openDiscussionStudies.map(study => {
const enrolled = isEnrolledInStudy(auth, study.slug)
return (
<article key={study.slug} className="study-module-row">
<div className="study-module-row-left">
<p className="study-module-lesson">{enrolled ? 'Enrolled' : 'Study track'}</p>
<h3>{study.title}</h3>
<p>{study.description || 'Join the discussion for this study track.'}</p>
</div>
<div className="study-module-row-right" style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
<Link to={`/study/${study.slug}/community`} className="btn-primary">
{enrolled ? 'Open Discussion' : 'View Discussion'}
</Link>
</div>
</article>
)
})}
</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 (!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">Study Discussion</p>
<h1>Sign In Required</h1>
<p>Create a free student account to see and join the conversation.</p>
<Link to="/study/signup" className="btn-primary" style={{ marginTop: '1rem', display: 'inline-block' }}>Sign In</Link>
</div>
</main>
)
}
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>
)
}
if (!isDiscussionOpen(study)) {
return (
<main className="auth-page" aria-label="Discussion closed">
<div className="auth-shell auth-shell--single">
<div className="auth-card auth-card--center">
<p className="eyebrow">{study.title}</p>
<h1>Discussion Isn't Open Yet</h1>
<p>Discussion opens once this study has started. Check back when it begins.</p>
<div className="study-signup-actions">
<Link to={`/study/${study.slug}`} className="btn-primary">Back to Study</Link>
</div>
<p className="auth-footer-links"><Link to="/study/discussion">← Study Discussion</Link></p>
</div>
</div>
</main>
)
}
const isEnrolled = isEnrolledInStudy(auth, study.slug)
return (
<main className="community-page" aria-label="Study community">
<div className="community-shell">
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title, href: `/study/${study.slug}` }, { label: 'Community' }]} />
<header className="community-header">
<div>
<p className="eyebrow">{study.title}</p>
<h1>Study Community</h1>
<p className="community-subtitle">Talk with the other students in this study. Keep it gracious and on topic.</p>
</div>
<div className="community-header-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>
</header>
<CommunityBoard study={study} auth={auth} isEnrolled={isEnrolled} sectionFilter={defaultSectionId} />
</div>
</main>
)
}