stuff
This commit is contained in:
+738
-111
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user