This commit is contained in:
nmemmert
2026-06-02 14:02:00 -04:00
parent b18f4e88fa
commit 5b448aef0f
12 changed files with 1466 additions and 184 deletions
+738 -111
View File
@@ -12,6 +12,8 @@ type StudyAuthState = {
enrolledStudySlugs?: string[]
displayName?: string
subscribeNewsletter?: boolean
studyRemindersEnabled?: boolean
avatarUrl?: string
}
type StudyAuthStatusResponse = {
@@ -20,6 +22,8 @@ type StudyAuthStatusResponse = {
enrolledStudySlugs?: string[]
displayName?: string
subscribeNewsletter?: boolean
studyRemindersEnabled?: boolean
avatarUrl?: string
}
type StudyAccountOverview = {
@@ -27,6 +31,8 @@ type StudyAccountOverview = {
username: string
displayName: string
subscribeNewsletter: boolean
studyRemindersEnabled: boolean
avatarUrl?: string
}
stats: {
noteCount: number
@@ -53,6 +59,7 @@ type StudyProgress = {
type StudyCommunityReply = {
id: string
authorName: string
authorAvatarUrl?: string
message: string
createdAt: string
}
@@ -62,6 +69,7 @@ type StudyCommunityPost = {
studySlug: string
sectionId: string
authorName: string
authorAvatarUrl?: string
message: string
createdAt: string
replies: StudyCommunityReply[]
@@ -358,6 +366,13 @@ function CommunityBoard({
<div key={post.id} style={{ border: '1px solid #2a2518', borderRadius: '14px', padding: '1.15rem', background: '#11100d' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap', marginBottom: '0.4rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.65rem', flexWrap: 'wrap' }}>
{post.authorAvatarUrl ? (
<img src={post.authorAvatarUrl} alt={`${post.authorName || 'Student'} avatar`} width={28} height={28} style={{ borderRadius: '50%', border: '1px solid #2a2518' }} />
) : (
<div style={{ width: 28, height: 28, borderRadius: '50%', background: '#2a2518', display: 'grid', placeItems: 'center', color: '#f0ead8', fontSize: '0.85rem', fontWeight: 700 }}>
{post.authorName?.slice(0, 1).toUpperCase() || 'S'}
</div>
)}
<strong style={{ color: '#e0c070' }}>{post.authorName || 'Student'}</strong>
{postSection && !activeSection && (
<button
@@ -393,7 +408,16 @@ function CommunityBoard({
{post.replies.map(reply => (
<div key={reply.id} style={{ borderLeft: '2px solid #2a2518', paddingLeft: '0.85rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<strong style={{ color: '#c9a84c', fontSize: '0.95rem' }}>{reply.authorName || 'Student'}</strong>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.55rem', flexWrap: 'wrap' }}>
{reply.authorAvatarUrl ? (
<img src={reply.authorAvatarUrl} alt={`${reply.authorName || 'Student'} avatar`} width={22} height={22} style={{ borderRadius: '50%', border: '1px solid #2a2518' }} />
) : (
<div style={{ width: 22, height: 22, borderRadius: '50%', background: '#2a2518', display: 'grid', placeItems: 'center', color: '#f0ead8', fontSize: '0.75rem', fontWeight: 700 }}>
{reply.authorName?.slice(0, 1).toUpperCase() || 'S'}
</div>
)}
<strong style={{ color: '#c9a84c', fontSize: '0.95rem' }}>{reply.authorName || 'Student'}</strong>
</div>
<span className="study-note-status">{formatCommunityDate(reply.createdAt)}</span>
</div>
<p className="study-detail-copy" style={{ margin: '0.25rem 0 0', whiteSpace: 'pre-wrap' }}>{reply.message}</p>
@@ -432,12 +456,287 @@ function isEnrolledInStudy(auth: StudyAuthState, studySlug: string | undefined):
}
export function StudyLandingPage({ content }: Props) {
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [], displayName: '', subscribeNewsletter: true, studyRemindersEnabled: false, avatarUrl: '' })
const [overview, setOverview] = useState<StudyAccountOverview | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [studyModal, setStudyModal] = useState<'none' | 'enrollments' | 'browseTracks'>('none')
const [enrollBusySlug, setEnrollBusySlug] = useState('')
const [enrollMessage, setEnrollMessage] = useState('')
const studies = getStudies(content)
const activeStudies = studies.filter(study => study.status !== 'planned')
const plannedStudies = studies.filter(study => study.status === 'planned')
const firstActiveStudy = activeStudies[0]
const firstActiveStudyFirstReleasedSection = firstActiveStudy?.sections.find(isSectionReleased)
async function refreshOverview() {
if (!auth.authenticated) return
try {
const dashboard = await readJson<StudyAccountOverview>('/api/study-account/overview')
setOverview(dashboard)
} catch (err) {
console.error(err)
}
}
async function handleEnrollmentToggle(studySlug: string, enrolled: boolean) {
if (!auth.authenticated) {
setEnrollMessage('Sign in to change your enrollments.')
return
}
setEnrollBusySlug(studySlug)
setEnrollMessage('')
try {
const data = await readJson<{ enrolledStudySlugs?: string[]; studyTitle?: string }>(`/api/study-enrollment/${encodeURIComponent(studySlug)}`, {
method: enrolled ? 'DELETE' : 'POST',
})
const slugs = normalizeEnrolledStudySlugs(data.enrolledStudySlugs)
setAuth(prev => ({ ...prev, enrolledStudySlugs: slugs }))
setEnrollMessage(enrolled ? `Unenrolled from ${data.studyTitle ?? studySlug}.` : `Enrolled in ${data.studyTitle ?? studySlug}.`)
await refreshOverview()
} catch (err) {
setEnrollMessage(err instanceof Error ? err.message : 'Unable to update enrollment.')
} finally {
setEnrollBusySlug('')
}
}
function closeStudyModal() {
setStudyModal('none')
setEnrollMessage('')
}
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
setError('')
try {
const authData = await readJson<StudyAuthStatusResponse>('/api/study-auth/status')
if (cancelled) return
setAuth({
checked: true,
authenticated: Boolean(authData.authenticated),
username: authData.username ?? '',
displayName: authData.displayName ?? '',
subscribeNewsletter: authData.subscribeNewsletter !== false,
studyRemindersEnabled: authData.studyRemindersEnabled === true,
avatarUrl: authData.avatarUrl ?? '',
enrolledStudySlugs: normalizeEnrolledStudySlugs(authData.enrolledStudySlugs),
})
if (authData.authenticated) {
const dashboard = await readJson<StudyAccountOverview>('/api/study-account/overview')
if (cancelled) return
setOverview(dashboard)
}
} catch (err) {
if (cancelled) return
setError(err instanceof Error ? err.message : 'Unable to load your study dashboard.')
} finally {
if (cancelled) return
setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [])
const enrolledStudies = overview?.studies.filter(study => study.enrolled) ?? []
const availableStudies = overview?.studies ?? []
const totalEnrolled = enrolledStudies.length
const totalCompletedLessons = enrolledStudies.reduce((sum, study) => sum + study.completedLessons, 0)
const totalLessons = enrolledStudies.reduce((sum, study) => sum + study.totalLessons, 0)
if (loading) {
return (
<main className="study-index-page" aria-label="Study hub">
<section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner">
<p className="eyebrow">Study Dashboard</p>
<h1>Loading your study progress</h1>
<p className="study-course-hero-copy">Hang tight while we gather your enrolled tracks and lesson progress.</p>
</div>
</section>
</main>
)
}
if (auth.authenticated && overview) {
return (
<main className="study-index-page" aria-label="Study dashboard">
<section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner" style={{ alignItems: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
{auth.avatarUrl ? (
<img src={auth.avatarUrl} alt="Your avatar" width={76} height={76} style={{ borderRadius: '50%', border: '1px solid #2a2518' }} />
) : (
<div style={{ width: 76, height: 76, borderRadius: '50%', background: '#2a2518', display: 'grid', placeItems: 'center', color: '#f0ead8', fontWeight: 700, fontSize: '1.75rem' }}>
{auth.displayName?.slice(0, 1).toUpperCase() || auth.username.slice(0, 1).toUpperCase()}
</div>
)}
<div>
<p className="eyebrow">Welcome back</p>
<h1 style={{ marginTop: 0 }}>{auth.displayName || auth.username.split('@')[0]}</h1>
<p className="study-course-hero-copy">Your progress, enrollments, and classroom resources are all in one place.</p>
</div>
</div>
<div className="study-course-meta" style={{ marginTop: '1.5rem', gap: '0.75rem' }}>
<span>{totalEnrolled} enrolled track{totalEnrolled === 1 ? '' : 's'}</span>
<span>{totalCompletedLessons}/{totalLessons} lessons completed</span>
<span>{overview.stats.noteCount} notes saved</span>
{auth.studyRemindersEnabled ? <span>Lesson reminders enabled</span> : <span>Reminders off</span>}
</div>
{error && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{error}</p>}
<div className="study-course-hero-actions" style={{ marginTop: '1.5rem' }}>
<Link to="/study/account" className="btn-primary">Open My Account</Link>
<button type="button" className="btn-secondary" onClick={() => setStudyModal('enrollments')}>View Enrollments</button>
<button type="button" className="btn-secondary" onClick={() => setStudyModal('browseTracks')}>Browse All Tracks</button>
</div>
</div>
</section>
{studyModal !== 'none' && (
<div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(5, 5, 5, 0.86)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1.25rem' }}>
<div style={{ width: '100%', maxWidth: '760px', maxHeight: 'calc(100vh - 2rem)', overflowY: 'auto', background: '#11100d', border: '1px solid #3a3320', borderRadius: '18px', padding: '1.4rem', position: 'relative' }} role="dialog" aria-modal="true" aria-labelledby="study-modal-heading">
<button type="button" onClick={closeStudyModal} style={{ position: 'absolute', top: '1rem', right: '1rem', background: 'transparent', border: 'none', color: '#f0ead8', fontSize: '1.35rem', cursor: 'pointer' }} aria-label="Close study modal">×</button>
<h2 id="study-modal-heading">{studyModal === 'enrollments' ? 'Manage Enrollments' : 'Browse All Tracks'}</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>
{studyModal === 'enrollments'
? 'Review and change the study tracks you are enrolled in. Use the buttons below to join or leave each track.'
: 'See every study track available now. Enroll in a study directly from this popup and start right away.'}
</p>
{auth.authenticated && overview ? (
studyModal === 'enrollments' ? (
enrolledStudies.length > 0 ? (
<div style={{ display: 'grid', gap: '1rem' }}>
{enrolledStudies.map(studyInfo => (
<div key={studyInfo.slug} style={{ padding: '1rem', borderRadius: '12px', background: '#14130f', border: '1px solid #2a2518' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'flex-start' }}>
<div>
<p style={{ margin: 0, fontSize: '0.9rem', color: '#b09e79' }}>{studyInfo.status === 'active' ? 'Active study' : 'Planned study'}</p>
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>
</div>
<button
type="button"
className="btn-admin-remove"
disabled={enrollBusySlug === studyInfo.slug}
onClick={() => handleEnrollmentToggle(studyInfo.slug, true)}
>
{enrollBusySlug === studyInfo.slug ? 'Working…' : 'Leave'}
</button>
</div>
</div>
))}
</div>
) : (
<div style={{ padding: '1rem', background: '#14130f', borderRadius: '12px', border: '1px solid #2a2518' }}>
<p style={{ margin: 0 }}>You are not enrolled in any tracks yet. Use Browse All Tracks to find a study and enroll.</p>
<button type="button" className="btn-primary" style={{ marginTop: '1rem' }} onClick={() => setStudyModal('browseTracks')}>Browse All Tracks</button>
</div>
)
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{availableStudies.map(studyInfo => (
<div key={studyInfo.slug} style={{ padding: '1rem', borderRadius: '12px', background: '#14130f', border: '1px solid #2a2518' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'flex-start' }}>
<div>
<p style={{ margin: 0, fontSize: '0.9rem', color: '#b09e79' }}>{studyInfo.status === 'active' ? 'Active study' : 'Planned study'}</p>
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>
</div>
<button
type="button"
className={studyInfo.enrolled ? 'btn-admin-remove' : 'btn-primary'}
disabled={enrollBusySlug === studyInfo.slug}
onClick={() => handleEnrollmentToggle(studyInfo.slug, studyInfo.enrolled)}
>
{enrollBusySlug === studyInfo.slug ? 'Working…' : studyInfo.enrolled ? 'Leave' : 'Enroll'}
</button>
</div>
</div>
))}
</div>
)
) : auth.authenticated ? (
<p>No study information is available yet. Please refresh the page or visit your account to load your enrollments.</p>
) : (
<div style={{ padding: '1rem', background: '#14130f', borderRadius: '12px', border: '1px solid #2a2518' }}>
<p style={{ margin: 0 }}>Sign in to manage your enrollments and browse tracks from within the app.</p>
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<Link to="/study/signup" className="btn-primary">Sign In</Link>
<button type="button" className="btn-secondary" onClick={closeStudyModal}>Close</button>
</div>
</div>
)}
{enrollMessage && <p className="study-note-status" style={{ marginTop: '1rem' }}>{enrollMessage}</p>}
</div>
</div>
)}
<section id="my-tracks" className="section-study-module" aria-label="Your enrolled tracks" style={{ paddingTop: '1rem' }}>
<div className="section-inner">
<div className="study-module-header">
<p>Your Classroom</p>
<h2>Enrolled Tracks</h2>
</div>
{totalEnrolled === 0 ? (
<article className="study-class-block">
<h2>No enrollments yet</h2>
<p className="study-detail-copy">You are signed in, but you have not enrolled in any study track yet. Browse the available tracks and join the one you want to start.</p>
<button type="button" className="btn-primary" onClick={() => setStudyModal('browseTracks')}>Browse Tracks</button>
</article>
) : (
<div className="study-module-list">
{enrolledStudies.map(study => {
const studyMeta = getStudyBySlug(studies, study.slug)
const progress = study.totalLessons > 0 ? Math.round((study.completedLessons / study.totalLessons) * 100) : 0
return (
<article key={study.slug} className="study-module-row">
<div className="study-module-row-left">
<p className="study-module-lesson">{study.enrolled ? 'Enrolled' : 'Track'}</p>
<h3>{study.title}</h3>
<p>{studyMeta?.description ?? 'Continue your study with the lessons and notes available in this track.'}</p>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginTop: '0.75rem' }}>
<span style={{ color: '#7a7060', fontSize: '0.9rem' }}>{study.completedLessons}/{study.totalLessons} lessons completed</span>
<span style={{ color: '#7a7060', fontSize: '0.9rem' }}>{study.noteCount} notes</span>
</div>
<div style={{ marginTop: '0.75rem', height: '10px', width: '100%', background: '#27231b', borderRadius: '999px', overflow: 'hidden' }}>
<div style={{ width: `${progress}%`, height: '100%', background: '#e0c070' }} />
</div>
</div>
<div className="study-module-row-right" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<div>
<p className="study-module-focus">Progress</p>
<p>{progress}% complete</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '1rem' }}>
<Link to={`/study/${study.slug}`} className="btn-primary">Continue</Link>
<Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>
<Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>
</div>
</div>
</article>
)
})}
</div>
)}
</div>
</section>
</main>
)
}
return (
<main className="study-index-page" aria-label="Study hub">
<section className="section-study-course-hero">
@@ -453,7 +752,6 @@ export function StudyLandingPage({ content }: Props) {
</div>
<div className="study-course-hero-actions">
{firstActiveStudyFirstReleasedSection && <Link to={`/study/${firstActiveStudy.slug}/${firstActiveStudyFirstReleasedSection.id}`} className="btn-primary">Start Learning</Link>}
<Link to="#study-tracks" className="btn-secondary">Browse Tracks</Link>
<Link to="/study/signup" className="btn-primary">Create Account or Login</Link>
<Link to="/study/account" className="btn-secondary">My Account</Link>
</div>
@@ -588,6 +886,7 @@ export function StudySignupPage() {
})
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 {
@@ -1299,7 +1598,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
<li key={index}>{question}</li>
))}
</ol>
<div style={{ marginTop: '1.25rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<Link to={`/study/${study.slug}/${section.id}/quiz`} className="btn-secondary">Take the Quiz</Link>
<Link to={`/study/${study.slug}/community?sectionId=${encodeURIComponent(section.id)}`} className="btn-primary">Go to Community</Link>
<Link to={`/contact?source=community&study=${encodeURIComponent(study.title)}`} className="btn-secondary">Ask Nate</Link>
</div>
@@ -1585,12 +1885,17 @@ export function StudyAccountPage() {
const [loadingOverview, setLoadingOverview] = useState(false)
const [displayNameInput, setDisplayNameInput] = useState('')
const [avatarUrlInput, setAvatarUrlInput] = useState('')
const [profileMessage, setProfileMessage] = useState('')
const [profileBusy, setProfileBusy] = useState(false)
const [avatarUploadBusy, setAvatarUploadBusy] = useState(false)
const [avatarUploadMessage, setAvatarUploadMessage] = useState('')
const [subscribeNewsletter, setSubscribeNewsletter] = useState(true)
const [studyRemindersEnabled, setStudyRemindersEnabled] = useState(true)
const [prefMessage, setPrefMessage] = useState('')
const [prefBusy, setPrefBusy] = useState(false)
const [accountModal, setAccountModal] = useState<'none' | 'enrollments' | 'changeEmail' | 'changePassword' | 'profile' | 'preferences' | 'export'>('none')
const [emailCurrentPassword, setEmailCurrentPassword] = useState('')
const [emailNew, setEmailNew] = useState('')
@@ -1628,6 +1933,8 @@ export function StudyAccountPage() {
username: data.username ?? '',
displayName: data.displayName ?? '',
subscribeNewsletter: data.subscribeNewsletter !== false,
studyRemindersEnabled: data.studyRemindersEnabled === true,
avatarUrl: data.avatarUrl ?? '',
enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs),
})
})
@@ -1641,7 +1948,9 @@ export function StudyAccountPage() {
.then(data => {
setOverview(data)
setDisplayNameInput(data.profile.displayName ?? '')
setAvatarUrlInput(data.profile.avatarUrl ?? '')
setSubscribeNewsletter(data.profile.subscribeNewsletter !== false)
setStudyRemindersEnabled(data.profile.studyRemindersEnabled === true)
})
.catch(() => {})
.finally(() => setLoadingOverview(false))
@@ -1671,19 +1980,55 @@ export function StudyAccountPage() {
const data = await readJson<StudyAccountOverview>('/api/study-account/overview')
setOverview(data)
setDisplayNameInput(data.profile.displayName ?? '')
setAvatarUrlInput(data.profile.avatarUrl ?? '')
setSubscribeNewsletter(data.profile.subscribeNewsletter !== false)
setStudyRemindersEnabled(data.profile.studyRemindersEnabled === true)
}
async function handleAvatarUpload(file: File) {
setAvatarUploadBusy(true)
setAvatarUploadMessage('')
try {
if (!file.type.startsWith('image/')) {
throw new Error('Please upload an image file.')
}
const reader = new FileReader()
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () => {
if (typeof reader.result === 'string') resolve(reader.result)
else reject(new Error('Unable to read image file.'))
}
reader.onerror = () => reject(new Error('Failed to read image file.'))
reader.readAsDataURL(file)
})
const result = await readJson<{ ok: boolean; url: string }>('/api/study-account/avatar-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, dataUrl }),
})
setAvatarUrlInput(result.url)
setAvatarUploadMessage('Avatar uploaded successfully. Save profile to keep it.')
} catch (err) {
setAvatarUploadMessage(err instanceof Error ? err.message : 'Unable to upload avatar.')
} finally {
setAvatarUploadBusy(false)
}
}
async function handleProfileSave() {
setProfileBusy(true)
setProfileMessage('')
try {
const data = await readJson<{ ok: boolean; displayName: string }>('/api/study-account/profile', {
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 }),
body: JSON.stringify({ displayName: displayNameInput, avatarUrl: avatarUrlInput.trim() }),
})
setAuth(prev => ({ ...prev, displayName: data.displayName }))
setAuth(prev => ({ ...prev, displayName: data.displayName, avatarUrl: data.avatarUrl ?? prev.avatarUrl }))
if (data.avatarUrl) {
setAvatarUrlInput(data.avatarUrl)
}
setProfileMessage('Profile saved.')
await refreshOverview()
} catch (err) {
@@ -1697,12 +2042,12 @@ export function StudyAccountPage() {
setPrefBusy(true)
setPrefMessage('')
try {
const data = await readJson<{ ok: boolean; subscribeNewsletter: boolean }>('/api/study-account/preferences', {
const data = await readJson<{ ok: boolean; subscribeNewsletter: boolean; studyRemindersEnabled: boolean }>('/api/study-account/preferences', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscribeNewsletter }),
body: JSON.stringify({ subscribeNewsletter, studyRemindersEnabled }),
})
setAuth(prev => ({ ...prev, subscribeNewsletter: data.subscribeNewsletter }))
setAuth(prev => ({ ...prev, subscribeNewsletter: data.subscribeNewsletter, studyRemindersEnabled: data.studyRemindersEnabled }))
setPrefMessage('Preferences updated.')
await refreshOverview()
} catch (err) {
@@ -1833,6 +2178,13 @@ export function StudyAccountPage() {
}
}
function closeAccountModal() {
setAccountModal('none')
setEmailMessage('')
setPwMessage('')
setEnrollMessage('')
}
if (!auth.checked) {
return (
<main className="thanks-page" aria-label="Loading">
@@ -1861,7 +2213,20 @@ export function StudyAccountPage() {
<div className="thanks-card" style={{ maxWidth: '760px', textAlign: 'left' }}>
<p className="eyebrow">Student Account</p>
<h1>My Account</h1>
<p style={{ marginBottom: '1.5rem' }}>Signed in as <strong>{auth.username}</strong></p>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
{auth.avatarUrl ? (
<img src={auth.avatarUrl} alt="Your avatar" width={64} height={64} style={{ borderRadius: '50%', border: '1px solid #2a2518' }} />
) : (
<div style={{ width: 64, height: 64, borderRadius: '50%', background: '#2a2518', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#f0ead8', fontWeight: 700, fontSize: '1.5rem' }}>
{auth.displayName?.slice(0, 1).toUpperCase() || auth.username.slice(0, 1).toUpperCase()}
</div>
)}
<div>
<p style={{ margin: 0, fontSize: '0.95rem' }}>Signed in as</p>
<p style={{ margin: '0.25rem 0 0', fontWeight: 700 }}>{auth.displayName || auth.username}</p>
<p style={{ margin: '0.25rem 0 0', fontSize: '0.9rem', color: '#7a7060' }}>{auth.username}</p>
</div>
</div>
{overview?.stats && (
<>
@@ -1880,109 +2245,191 @@ export function StudyAccountPage() {
)}
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ marginTop: 0 }}>Enrollment Manager</h2>
<p style={{ fontSize: '0.92rem' }}>Enroll or unenroll by study and track your progress.</p>
{loadingOverview && <p>Loading studies...</p>}
{!loadingOverview && overview?.studies?.map(study => (
<div key={study.slug} style={{ border: '1px solid #2a2518', background: '#151511', borderRadius: '8px', padding: '0.9rem', marginBottom: '0.75rem', display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
<div>
<p style={{ margin: 0, fontWeight: 600 }}>{study.title}</p>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.84rem' }}>
Status: <strong>{study.enrolled ? 'Enrolled' : 'Not Enrolled'}</strong>
{` • Progress: ${study.completedLessons}/${study.totalLessons || 0} lessons completed`}
{` • Notes: ${study.noteCount}`}
</p>
</div>
<button
type="button"
className={study.enrolled ? 'btn-secondary' : 'btn-primary'}
disabled={study.status === 'planned' || enrollBusySlug === study.slug}
onClick={() => handleEnrollmentToggle(study.slug, study.enrolled)}
>
{enrollBusySlug === study.slug ? 'Updating...' : (study.enrolled ? 'Unenroll' : 'Enroll')}
</button>
<h2 style={{ marginTop: 0 }}>Quick Actions</h2>
<p style={{ fontSize: '0.92rem' }}>Open the settings you need without scrolling through the whole page.</p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '1rem' }}>
<button type="button" className="btn-primary" onClick={() => setAccountModal('profile')}>Edit Profile</button>
<button type="button" className="btn-primary" onClick={() => setAccountModal('preferences')}>Preferences</button>
<button type="button" className="btn-primary" onClick={() => setAccountModal('export')}>Export Notes</button>
<button type="button" className="btn-primary" onClick={() => setAccountModal('enrollments')}>Manage Enrollments</button>
<button type="button" className="btn-secondary" onClick={() => setAccountModal('changeEmail')}>Change Email</button>
<button type="button" className="btn-secondary" onClick={() => setAccountModal('changePassword')}>Change Password</button>
</div>
</section>
{accountModal !== 'none' && (
<div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(5, 5, 5, 0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1.25rem' }}>
<div style={{ width: '100%', maxWidth: '720px', maxHeight: 'calc(100vh - 2.5rem)', overflowY: 'auto', background: '#11100d', border: '1px solid #3a3320', borderRadius: '18px', padding: '1.5rem', position: 'relative' }} role="dialog" aria-modal="true" aria-labelledby="account-modal-heading">
<button type="button" onClick={closeAccountModal} style={{ position: 'absolute', top: '1rem', right: '1rem', background: 'transparent', border: 'none', color: '#f0ead8', fontSize: '1.35rem', cursor: 'pointer' }} aria-label="Close account settings">×</button>
{accountModal === 'enrollments' && (
<>
<h2 id="account-modal-heading">Manage Enrollments</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>Enroll or leave studies from one place. Your active study list is shown below.</p>
{loadingOverview ? (
<p>Loading study access...</p>
) : overview?.studies?.length ? (
<div style={{ display: 'grid', gap: '1rem' }}>
{overview.studies.map(studyInfo => (
<div key={studyInfo.slug} style={{ padding: '1rem', borderRadius: '12px', background: '#14130f', border: '1px solid #2a2518' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'flex-start' }}>
<div>
<p style={{ margin: 0, fontSize: '0.9rem', color: '#b09e79' }}>{studyInfo.status === 'active' ? 'Active study' : 'Planned study'}</p>
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons} of {studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>
</div>
<button
type="button"
className={studyInfo.enrolled ? 'btn-admin-remove' : 'btn-primary'}
disabled={enrollBusySlug === studyInfo.slug}
onClick={() => handleEnrollmentToggle(studyInfo.slug, studyInfo.enrolled)}
>
{enrollBusySlug === studyInfo.slug ? 'Working…' : studyInfo.enrolled ? 'Leave' : 'Enroll'}
</button>
</div>
</div>
))}
</div>
) : (
<p>No study access information is available right now.</p>
)}
{enrollMessage && <p className="study-note-status" style={{ marginTop: '1rem' }}>{enrollMessage}</p>}
</>
)}
{accountModal === 'changeEmail' && (
<>
<h2 id="account-modal-heading">Change Email</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>Update the email address used for sign in and course notifications.</p>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Current email</label>
<input type="email" value={auth.username} disabled style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#1a1912', color: '#b9b09b', marginBottom: '1rem' }} />
<label style={{ display: 'block', marginBottom: '0.5rem' }}>New email</label>
<input type="email" value={emailNew} onChange={e => setEmailNew(e.target.value)} placeholder="you@example.com" style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem' }} />
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Current password</label>
<input type="password" value={emailCurrentPassword} onChange={e => setEmailCurrentPassword(e.target.value)} placeholder="Current password" style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem' }} />
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
<button type="button" className="btn-primary" disabled={emailBusy || !emailNew || !emailCurrentPassword} onClick={handleRequestEmailChange}>
{emailBusy ? 'Sending…' : 'Send Verification Email'}
</button>
</div>
{emailTokenInput && (
<div style={{ marginTop: '1rem' }}>
<p style={{ margin: '0 0 0.5rem' }}>Have a verification token?</p>
<input type="text" value={emailTokenInput} onChange={e => setEmailTokenInput(e.target.value)} placeholder="Paste token here" style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem' }} />
<button type="button" className="btn-secondary" disabled={emailBusy || !emailTokenInput.trim()} onClick={handleVerifyTokenInput}>Verify Token</button>
</div>
)}
{emailMessage && <p className="study-note-status" style={{ marginTop: '1rem' }}>{emailMessage}</p>}
</>
)}
{accountModal === 'profile' && (
<>
<h2 id="account-modal-heading">Profile Settings</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>Update your display name and avatar in one place.</p>
<label style={{ display: 'block', fontSize: '0.9rem', marginBottom: '0.5rem' }}>Choose an avatar</label>
<p style={{ margin: '0 0 0.85rem', color: '#b9b09b' }}>Pick a preset avatar or upload your own image.</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(92px, 1fr))', gap: '0.75rem', marginBottom: '1rem' }}>
{[
{ key: 'dog', label: 'Dog', emoji: '🐶' },
{ key: 'cat', label: 'Cat', emoji: '🐱' },
{ key: 'rabbit', label: 'Rabbit', emoji: '🐰' },
{ key: 'fox', label: 'Fox', emoji: '🦊' },
{ key: 'panda', label: 'Panda', emoji: '🐼' },
{ key: 'lion', label: 'Lion', emoji: '🦁' },
].map(item => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="88" height="88"><rect width="100%" height="100%" rx="18" ry="18" fill="#11100d"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="56">${item.emoji}</text></svg>`
const url = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
return (
<button key={item.key} type="button" onClick={() => { setAvatarUrlInput(url); setAvatarUploadMessage('Preset avatar selected.') }} style={{ border: avatarUrlInput === url ? '2px solid #e0c070' : '1px solid #2a2518', borderRadius: '14px', padding: 0, background: '#11100d', cursor: 'pointer' }}>
<img src={url} alt={item.label} width={88} height={88} style={{ display: 'block', borderRadius: '14px' }} />
</button>
)
})}
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontSize: '0.9rem', marginBottom: '0.5rem' }}>Upload your own image</label>
<input
type="file"
accept="image/*"
onChange={e => {
const file = e.target.files?.[0]
if (file) {
void handleAvatarUpload(file)
}
}}
style={{ color: '#f0ead8' }}
/>
{avatarUploadBusy && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>Uploading avatar</p>}
{avatarUploadMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{avatarUploadMessage}</p>}
</div>
{(avatarUrlInput || auth.avatarUrl) && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.75rem' }}>
<img src={avatarUrlInput || auth.avatarUrl || ''} alt="Avatar preview" width={48} height={48} style={{ borderRadius: '50%', border: '1px solid #2a2518', objectFit: 'cover' }} />
<p style={{ margin: 0, color: '#b9b09b', fontSize: '0.92rem' }}>Preview of your selected avatar. Save your profile to keep it.</p>
</div>
)}
<label style={{ display: 'block', fontSize: '0.9rem', marginBottom: '0.5rem' }}>Display Name (optional)</label>
<input
type="text"
value={displayNameInput}
onChange={e => setDisplayNameInput(e.target.value)}
placeholder="How your name appears in emails"
style={{ width: '100%', maxWidth: '420px', marginBottom: '0.75rem', padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }}
/>
<div>
<button type="button" className="btn-primary" disabled={profileBusy} onClick={handleProfileSave}>{profileBusy ? 'Saving...' : 'Save Profile'}</button>
</div>
{profileMessage && <p className="study-note-status" style={{ marginTop: '0.5rem' }}>{profileMessage}</p>}
</>
)}
{accountModal === 'preferences' && (
<>
<h2 id="account-modal-heading">Preferences</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>Adjust how you receive study reminders and newsletter updates.</p>
<div style={{ display: 'grid', gap: '0.75rem', marginBottom: '1rem' }}>
<label className="contact-consent" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input type="checkbox" checked={studyRemindersEnabled} onChange={e => setStudyRemindersEnabled(e.target.checked)} />
<span>Send email reminders when a new lesson is released</span>
</label>
<label className="contact-consent" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input type="checkbox" checked={subscribeNewsletter} onChange={e => setSubscribeNewsletter(e.target.checked)} />
<span>Subscribe to newsletter updates</span>
</label>
</div>
<button type="button" className="btn-primary" disabled={prefBusy} onClick={handlePreferenceSave}>{prefBusy ? 'Saving...' : 'Save Preferences'}</button>
{prefMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{prefMessage}</p>}
</>
)}
{accountModal === 'export' && (
<>
<h2 id="account-modal-heading">Export Notes</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>Download all saved notes in a single document.</p>
<button type="button" className="btn-primary" disabled={exporting} onClick={handleExport}>
{exporting ? 'Preparing...' : 'Download Notes (.docx)'}
</button>
{exportMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{exportMessage}</p>}
</>
)}
{accountModal === 'changePassword' && (
<>
<h2 id="account-modal-heading">Change Password</h2>
<p style={{ marginTop: '0.5rem', marginBottom: '1rem', color: '#b9b09b' }}>Use a strong password to keep your study materials and notes secure.</p>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Current password</label>
<input type="password" value={pwCurrent} onChange={e => setPwCurrent(e.target.value)} placeholder="Current password" style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem' }} />
<label style={{ display: 'block', marginBottom: '0.5rem' }}>New password</label>
<input type="password" value={pwNew} onChange={e => setPwNew(e.target.value)} placeholder="New password" style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem' }} />
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Confirm new password</label>
<input type="password" value={pwConfirm} onChange={e => setPwConfirm(e.target.value)} placeholder="Confirm new password" style={{ width: '100%', padding: '0.75rem', borderRadius: '8px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '1rem' }} />
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
<button type="button" className="btn-primary" disabled={pwBusy || !pwCurrent || !pwNew || !pwConfirm} onClick={handleChangePassword}>
{pwBusy ? 'Saving…' : 'Update Password'}
</button>
</div>
{pwMessage && <p className="study-note-status" style={{ marginTop: '1rem' }}>{pwMessage}</p>}
{pwSuccess && <p className="study-note-status" style={{ marginTop: '1rem', color: '#a5d6a7' }}>Password changed successfully.</p>}
</>
)}
</div>
))}
{enrollMessage && <p className="study-note-status">{enrollMessage}</p>}
</section>
{divider}
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ marginTop: 0 }}>Profile Settings</h2>
<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 Display Name'}</button>
</div>
{profileMessage && <p className="study-note-status" style={{ marginTop: '0.5rem' }}>{profileMessage}</p>}
<div style={{ marginTop: '1.5rem' }}>
<label className="contact-consent">
<input type="checkbox" checked={subscribeNewsletter} onChange={e => setSubscribeNewsletter(e.target.checked)} />
<span>Subscribe to newsletter updates</span>
</label>
<button type="button" className="btn-secondary" disabled={prefBusy} onClick={handlePreferenceSave} style={{ marginTop: '0.5rem' }}>
{prefBusy ? 'Saving...' : 'Save Preferences'}
</button>
{prefMessage && <p className="study-note-status" style={{ marginTop: '0.5rem' }}>{prefMessage}</p>}
</div>
</section>
{divider}
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ marginTop: 0 }}>Change Email (Verification Required)</h2>
<p style={{ fontSize: '0.9rem' }}>Enter your new email and current password. We will send a verification link to the new email.</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', maxWidth: '420px' }}>
<input type="email" value={emailNew} onChange={e => setEmailNew(e.target.value)} placeholder="new@email.com" style={{ padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }} />
<input type="password" value={emailCurrentPassword} onChange={e => setEmailCurrentPassword(e.target.value)} placeholder="Current password" style={{ padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }} />
</div>
<button type="button" className="btn-primary" disabled={emailBusy} onClick={handleRequestEmailChange} style={{ marginTop: '0.65rem' }}>
{emailBusy ? 'Sending...' : 'Send Verification Email'}
</button>
<p style={{ marginTop: '1rem', fontSize: '0.85rem' }}>Have a token? Paste it here to verify manually.</p>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<input type="text" value={emailTokenInput} onChange={e => setEmailTokenInput(e.target.value)} placeholder="Verification token" style={{ minWidth: '260px', padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }} />
<button type="button" className="btn-secondary" disabled={emailBusy || !emailTokenInput.trim()} onClick={handleVerifyTokenInput}>
{emailBusy ? 'Verifying...' : 'Verify Token'}
</button>
</div>
{emailMessage && <p className="study-note-status" style={{ marginTop: '0.5rem' }}>{emailMessage}</p>}
</section>
{divider}
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ marginTop: 0 }}>Export My Notes</h2>
<p style={{ fontSize: '0.9rem' }}>Download all your saved lesson notes as a Word document (.docx).</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.5rem' }}>{exportMessage}</p>}
</section>
{divider}
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ marginTop: 0 }}>Change Password</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem', maxWidth: '420px' }}>
<input type="password" value={pwCurrent} onChange={e => setPwCurrent(e.target.value)} placeholder="Current password" autoComplete="current-password" style={{ padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }} />
<input type="password" value={pwNew} onChange={e => setPwNew(e.target.value)} placeholder="New password" autoComplete="new-password" style={{ padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }} />
<input type="password" value={pwConfirm} onChange={e => setPwConfirm(e.target.value)} placeholder="Confirm new password" autoComplete="new-password" onKeyDown={e => e.key === 'Enter' && handleChangePassword()} style={{ padding: '0.5rem', border: '1px solid #3a3320', borderRadius: '4px', background: '#14130f', color: '#f0ead8' }} />
</div>
{pwMessage && <p style={{ color: '#e57373', fontSize: '0.875rem', marginTop: '0.5rem' }}>{pwMessage}</p>}
{pwSuccess && <p style={{ color: '#81c784', fontSize: '0.875rem', marginTop: '0.5rem' }}>Password updated successfully.</p>}
<button type="button" className="btn-primary" disabled={pwBusy} onClick={handleChangePassword} style={{ marginTop: '0.75rem' }}>
{pwBusy ? 'Updating...' : 'Update Password'}
</button>
</section>
)}
{divider}
@@ -2036,6 +2483,186 @@ export function StudyAccountPage() {
)
}
export function StudyQuizPage({ content }: Props) {
const { studySlug, sectionId } = useParams<{ studySlug: string, sectionId: string }>()
const navigate = useNavigate()
const studies = getStudies(content)
const study = getStudyBySlug(studies, studySlug)
const section = study ? getSectionById(study.sections, sectionId) : undefined
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [], displayName: '', subscribeNewsletter: true, studyRemindersEnabled: false, avatarUrl: '' })
const [answers, setAnswers] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
async function load() {
if (!studySlug || !sectionId) return
setLoading(true)
setError('')
try {
const authData = await readJson<StudyAuthStatusResponse>('/api/study-auth/status')
if (cancelled) return
const enrolledStudySlugs = normalizeEnrolledStudySlugs(authData.enrolledStudySlugs)
setAuth({
checked: true,
authenticated: Boolean(authData.authenticated),
username: authData.username ?? '',
displayName: authData.displayName ?? '',
subscribeNewsletter: authData.subscribeNewsletter !== false,
studyRemindersEnabled: authData.studyRemindersEnabled === true,
avatarUrl: authData.avatarUrl ?? '',
enrolledStudySlugs,
})
if (!authData.authenticated || !enrolledStudySlugs.includes(studySlug?.trim().toLowerCase())) {
return
}
const data = await readJson<{ studySlug: string; sectionId: string; answers: string[] }>(`/api/study-quiz/${encodeURIComponent(studySlug)}/${encodeURIComponent(sectionId)}`)
if (cancelled) return
setAnswers(Array.isArray(data.answers) ? data.answers : [])
} catch (err) {
if (cancelled) return
setError(err instanceof Error ? err.message : 'Unable to load quiz answers.')
} finally {
if (cancelled) return
setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [studySlug, sectionId])
async function saveQuiz() {
if (!studySlug || !sectionId || !study || !section) return
setSaving(true)
setMessage('')
try {
await readJson<{ ok: boolean; answers: string[] }>(`/api/study-quiz/${encodeURIComponent(studySlug)}/${encodeURIComponent(sectionId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers }),
})
setMessage('Quiz answers saved.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to save quiz answers.')
} finally {
setSaving(false)
}
}
if (!study || !section) {
return (
<main className="thanks-page" aria-label="Quiz not found">
<div className="thanks-card">
<p className="eyebrow">Study Quiz</p>
<h1>Quiz not found</h1>
<p>The lesson quiz you requested is not available.</p>
<Link to="/study" className="btn-primary">Back to Studies</Link>
</div>
</main>
)
}
const sectionQuestions = section.studyQuestions ?? []
const canSubmit = sectionQuestions.length > 0
const isEnrolled = auth.authenticated && normalizeEnrolledStudySlugs(auth.enrolledStudySlugs).includes(study.slug)
if (!auth.checked) {
return (
<main className="thanks-page" aria-label="Loading quiz">
<div className="thanks-card"><p>Loading quiz...</p></div>
</main>
)
}
if (!auth.authenticated) {
return (
<main className="thanks-page" aria-label="Sign in required">
<div className="thanks-card">
<p className="eyebrow">Study Quiz</p>
<h1>Sign In Required</h1>
<p>You need to sign in before you can view and save quiz answers.</p>
<Link to="/study/signup" className="btn-primary">Sign In</Link>
</div>
</main>
)
}
if (!isEnrolled) {
return (
<main className="thanks-page" aria-label="Enrollment required">
<div className="thanks-card">
<p className="eyebrow">Study Quiz</p>
<h1>Enroll to Access the Quiz</h1>
<p>Please enroll in {study.title} to open the quiz for this lesson.</p>
<Link to={`/study/${study.slug}`} className="btn-primary">Go to Study</Link>
</div>
</main>
)
}
return (
<main className="study-index-page" aria-label="Study quiz">
<section className="section-study-course-hero">
<div className="section-inner study-course-hero-inner">
<p className="eyebrow">Quiz</p>
<h1>{section.title}</h1>
<p className="study-course-hero-copy">Answer the lesson questions below and save your responses for later review.</p>
<div className="study-course-hero-actions">
<Link to={`/study/${study.slug}/${section.id}`} className="btn-secondary">Back to Lesson</Link>
</div>
</div>
</section>
<section className="section-study-module" style={{ paddingTop: '2rem' }}>
<div className="section-inner">
{loading ? (
<p>Loading quiz...</p>
) : error ? (
<p className="study-note-status">{error}</p>
) : !canSubmit ? (
<article className="study-class-block">
<h2>No Quiz Questions</h2>
<p className="study-detail-copy">This lesson does not have quiz questions configured yet.</p>
</article>
) : (
<form onSubmit={e => { e.preventDefault(); saveQuiz() }}>
{sectionQuestions.map((question, index) => (
<div key={index} style={{ marginBottom: '1.25rem' }}>
<p style={{ margin: '0 0 0.5rem', fontWeight: 600 }}>{`${index + 1}. ${question}`}</p>
<textarea
rows={4}
value={answers[index] ?? ''}
onChange={e => setAnswers(prev => {
const next = [...prev]
next[index] = e.target.value
return next
})}
placeholder="Your answer..."
style={{ width: '100%', padding: '0.9rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8' }}
/>
</div>
))}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button type="submit" className="btn-primary" disabled={saving}>{saving ? 'Saving...' : 'Save Quiz Answers'}</button>
<button type="button" className="btn-secondary" onClick={() => navigate(`/study/${study.slug}/${section.id}`)}>Back to Lesson</button>
</div>
{message && <p className="study-note-status" style={{ marginTop: '1rem' }}>{message}</p>}
</form>
)}
</div>
</section>
</main>
)
}
export function StudyCommunityPage({ content }: Props) {
const { studySlug } = useParams<{ studySlug: string }>()
const location = useLocation()