Publish Container / build (push) Successful in 15m3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
4464 lines
221 KiB
TypeScript
4464 lines
221 KiB
TypeScript
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&A archive.</p>
|
||
<div className="study-hub-ask-actions">
|
||
<Link to="/questions" className="btn-primary">Browse Q&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>
|
||
)
|
||
}
|