1801 lines
78 KiB
TypeScript
1801 lines
78 KiB
TypeScript
import { useEffect, useMemo, useState, useCallback } from 'react'
|
|
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
|
|
import type { SiteContent, StudyProgram, StudySection } from './content'
|
|
import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './colossiansStudyData'
|
|
|
|
type Props = { content: SiteContent }
|
|
|
|
type StudyAuthState = {
|
|
checked: boolean
|
|
authenticated: boolean
|
|
username: string
|
|
enrolledStudySlugs?: string[]
|
|
displayName?: string
|
|
subscribeNewsletter?: boolean
|
|
}
|
|
|
|
type StudyAuthStatusResponse = {
|
|
authenticated: boolean
|
|
username: string
|
|
enrolledStudySlugs?: string[]
|
|
displayName?: string
|
|
subscribeNewsletter?: boolean
|
|
}
|
|
|
|
type StudyAccountOverview = {
|
|
profile: {
|
|
username: string
|
|
displayName: string
|
|
subscribeNewsletter: boolean
|
|
}
|
|
stats: {
|
|
noteCount: number
|
|
memberSince: string
|
|
lastLoginAt: string | null
|
|
}
|
|
studies: Array<{
|
|
slug: string
|
|
title: string
|
|
status: 'active' | 'planned'
|
|
enrolled: boolean
|
|
totalLessons: number
|
|
completedLessons: number
|
|
noteCount: number
|
|
}>
|
|
}
|
|
|
|
type StudyNotesMap = Record<string, string>
|
|
|
|
type StudyCommunityReply = {
|
|
id: string
|
|
authorName: string
|
|
message: string
|
|
createdAt: string
|
|
}
|
|
|
|
type StudyCommunityPost = {
|
|
id: string
|
|
studySlug: string
|
|
sectionId: string
|
|
authorName: string
|
|
message: string
|
|
createdAt: string
|
|
replies: StudyCommunityReply[]
|
|
}
|
|
|
|
function getLegacyColossiansStudy(content: SiteContent): StudyProgram {
|
|
const legacySections = content.colossiansStudySections?.length ? content.colossiansStudySections : DEFAULT_COLOSSIANS_STUDY_SECTIONS
|
|
return {
|
|
id: 'study-colossians',
|
|
slug: 'colossians',
|
|
title: 'Colossians: Rooted in Christ',
|
|
description: 'Walk through Colossians in guided lessons with commentary, Greek notes, and discussion prompts.',
|
|
status: 'active',
|
|
difficulty: 'intermediate',
|
|
estimatedHours: 12,
|
|
completionBadge: 'Colossians Completion',
|
|
numberOfChapters: 4,
|
|
sections: legacySections,
|
|
}
|
|
}
|
|
|
|
function getStudies(content: SiteContent): StudyProgram[] {
|
|
if (Array.isArray(content.studies) && content.studies.length > 0) return content.studies
|
|
return [getLegacyColossiansStudy(content)]
|
|
}
|
|
|
|
function getStudyBySlug(studies: StudyProgram[], slug: string | undefined) {
|
|
const safeSlug = (slug ?? 'colossians').trim().toLowerCase()
|
|
return studies.find(study => study.slug === safeSlug)
|
|
}
|
|
|
|
function getSectionById(sections: StudySection[], sectionId: string | undefined) {
|
|
if (!sectionId) return undefined
|
|
return sections.find(section => section.id === sectionId)
|
|
}
|
|
|
|
function getLessonNumber(sections: StudySection[], sectionId: string | undefined) {
|
|
const index = sections.findIndex(item => item.id === sectionId)
|
|
return index >= 0 ? index + 1 : 0
|
|
}
|
|
|
|
function getPrimaryQuestion(section: StudySection) {
|
|
if (section.focusQuestion?.trim()) return section.focusQuestion.trim()
|
|
return section.studyQuestions.length > 0 ? section.studyQuestions[0] : 'How does this passage shape the way we follow Christ this week?'
|
|
}
|
|
|
|
function getChapterSummaries(study: StudyProgram) {
|
|
const highestSectionChapter = study.sections.reduce((max, section) => Math.max(max, section.chapter), 0)
|
|
const totalChapters = Math.max(study.numberOfChapters, highestSectionChapter)
|
|
return Array.from({ length: totalChapters }, (_, index) => {
|
|
const chapter = index + 1
|
|
const lessonCount = study.sections.filter(section => section.chapter === chapter).length
|
|
return { chapter, lessonCount }
|
|
})
|
|
}
|
|
|
|
function getSectionReleaseDate(section: StudySection): Date | null {
|
|
const candidateValues = [
|
|
section?.releasedAt,
|
|
(section as StudySection & { releaseDate?: string }).releaseDate,
|
|
(section as StudySection & { availableAt?: string }).availableAt,
|
|
(section as StudySection & { publishAt?: string }).publishAt,
|
|
]
|
|
|
|
for (const candidate of candidateValues) {
|
|
if (typeof candidate !== 'string' || !candidate.trim()) continue
|
|
const parsed = new Date(candidate)
|
|
if (!Number.isNaN(parsed.getTime())) return parsed
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function isNewLesson(section: StudySection): boolean {
|
|
const releaseDate = getSectionReleaseDate(section)
|
|
if (!releaseDate) return false
|
|
const now = new Date()
|
|
const daysSinceRelease = (now.getTime() - releaseDate.getTime()) / (1000 * 60 * 60 * 24)
|
|
return daysSinceRelease >= 0 && daysSinceRelease <= 14
|
|
}
|
|
|
|
function isComingSoon(section: StudySection): boolean {
|
|
const releaseDate = getSectionReleaseDate(section)
|
|
if (!releaseDate) return false
|
|
const now = new Date()
|
|
return releaseDate > now
|
|
}
|
|
|
|
function isSectionReleased(section: StudySection): boolean {
|
|
const releaseDate = getSectionReleaseDate(section)
|
|
if (!releaseDate) return false
|
|
return releaseDate <= new Date()
|
|
}
|
|
|
|
function getReleaseDateDisplay(section: StudySection): string {
|
|
const date = getSectionReleaseDate(section)
|
|
if (!date) return ''
|
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
|
}
|
|
|
|
function getNoteId(studySlug: string, sectionId: string) {
|
|
return `${studySlug}--${sectionId}`
|
|
}
|
|
|
|
function getDisplayName(auth: StudyAuthState) {
|
|
return auth.displayName?.trim() || auth.username.split('@')[0] || 'student'
|
|
}
|
|
|
|
function formatCommunityDate(value: string) {
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.getTime())) return ''
|
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
|
}
|
|
|
|
function StudyCommunityWidget({
|
|
study,
|
|
section,
|
|
auth,
|
|
isEnrolled,
|
|
}: {
|
|
study: StudyProgram
|
|
section: StudySection
|
|
auth: StudyAuthState
|
|
isEnrolled: boolean
|
|
}) {
|
|
const [posts, setPosts] = useState<StudyCommunityPost[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
const [message, setMessage] = useState('')
|
|
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({})
|
|
const [replyingTo, setReplyingTo] = useState<string | null>(null)
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [newPost, setNewPost] = useState('')
|
|
|
|
const canParticipate = auth.authenticated && isEnrolled
|
|
const communityDisplayName = getDisplayName(auth)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
async function load() {
|
|
if (!canParticipate) {
|
|
setPosts([])
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
setError('')
|
|
try {
|
|
const data = await readJson<{ posts?: StudyCommunityPost[] }>('/api/study-community?' + new URLSearchParams({ studySlug: study.slug }).toString())
|
|
if (cancelled) return
|
|
setPosts(Array.isArray(data.posts) ? data.posts : [])
|
|
} catch (err) {
|
|
if (cancelled) return
|
|
setError(err instanceof Error ? err.message : 'Unable to load the community right now.')
|
|
} finally {
|
|
if (cancelled) return
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
void load()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [canParticipate, study.slug])
|
|
|
|
async function submitPost() {
|
|
if (!newPost.trim() || !canParticipate) return
|
|
setSubmitting(true)
|
|
setMessage('')
|
|
setError('')
|
|
try {
|
|
const data = await readJson<{ post?: StudyCommunityPost }>('/api/study-community/posts', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ studySlug: study.slug, sectionId: section.id, message: newPost }),
|
|
})
|
|
if (data.post) setPosts(prev => [data.post!, ...prev])
|
|
setNewPost('')
|
|
setMessage('Posted to the community.')
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Unable to post right now.')
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
async function submitReply(postId: string) {
|
|
const reply = replyDrafts[postId]?.trim()
|
|
if (!reply || !canParticipate) return
|
|
setSubmitting(true)
|
|
setError('')
|
|
try {
|
|
const data = await readJson<{ reply?: StudyCommunityReply }>(`/api/study-community/posts/${encodeURIComponent(postId)}/replies`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ message: reply }),
|
|
})
|
|
if (data.reply) {
|
|
setPosts(prev => prev.map(post => post.id === postId ? { ...post, replies: [...(post.replies ?? []), data.reply!] } : post))
|
|
}
|
|
setReplyDrafts(prev => ({ ...prev, [postId]: '' }))
|
|
setReplyingTo(null)
|
|
setMessage('Reply posted.')
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Unable to post your reply.')
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<article className="study-class-block" aria-label="Study community">
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
|
|
<div>
|
|
<h2>Study Community</h2>
|
|
<p className="study-detail-copy">Talk with other enrolled students about {study.title}. Keep it gracious and on topic.</p>
|
|
</div>
|
|
<Link to="/contact" className="btn-secondary">Ask Nate</Link>
|
|
</div>
|
|
|
|
{!auth.checked && <p className="study-note-status" style={{ marginTop: '1rem' }}>Checking your account status...</p>}
|
|
{auth.checked && !auth.authenticated && (
|
|
<div className="study-auth-box" style={{ marginTop: '1rem' }}>
|
|
<p className="study-auth-why">Sign in to join the study community.</p>
|
|
<div className="study-auth-actions">
|
|
<Link to="/study/signup" className="btn-primary">Create Account</Link>
|
|
<Link to="/study/account" className="btn-secondary">My Account</Link>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{auth.checked && auth.authenticated && !isEnrolled && (
|
|
<div className="study-auth-box" style={{ marginTop: '1rem' }}>
|
|
<p className="study-auth-why">Enroll in this study to participate in the community.</p>
|
|
<div className="study-auth-actions">
|
|
<Link to="/study/account" className="btn-primary">Go to My Account</Link>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{canParticipate && (
|
|
<div style={{ marginTop: '1rem' }}>
|
|
<textarea
|
|
rows={4}
|
|
value={newPost}
|
|
onChange={e => setNewPost(e.target.value)}
|
|
placeholder={`Share a thought about ${section.title}...`}
|
|
style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem' }}
|
|
/>
|
|
<div className="study-auth-actions">
|
|
<button type="button" className="btn-primary" disabled={submitting || !newPost.trim()} onClick={submitPost}>
|
|
{submitting ? 'Posting...' : 'Post to Community'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{message && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{message}</p>}
|
|
{error && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{error}</p>}
|
|
|
|
{canParticipate && (
|
|
<div style={{ marginTop: '1rem', display: 'grid', gap: '0.75rem' }}>
|
|
{loading ? (
|
|
<p className="study-detail-copy">Loading community posts...</p>
|
|
) : posts.length === 0 ? (
|
|
<p className="study-detail-copy">No one has posted here yet. Be the first to start the conversation.</p>
|
|
) : posts.map(post => (
|
|
<div key={post.id} style={{ border: '1px solid #2a2518', borderRadius: '14px', padding: '1rem', background: '#11100d' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap', marginBottom: '0.5rem' }}>
|
|
<strong style={{ color: '#e0c070' }}>{post.authorName || 'Student'}</strong>
|
|
<span className="study-note-status">{formatCommunityDate(post.createdAt)}</span>
|
|
</div>
|
|
<p className="study-detail-copy" style={{ marginTop: 0, whiteSpace: 'pre-wrap' }}>{post.message}</p>
|
|
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '0.75rem' }}>
|
|
<button type="button" className="btn-secondary" onClick={() => setReplyingTo(curr => curr === post.id ? null : post.id)}>{replyingTo === post.id ? 'Cancel Reply' : 'Reply'}</button>
|
|
</div>
|
|
{replyingTo === post.id && canParticipate && (
|
|
<div style={{ marginTop: '0.85rem' }}>
|
|
<textarea
|
|
rows={3}
|
|
value={replyDrafts[post.id] ?? ''}
|
|
onChange={e => setReplyDrafts(prev => ({ ...prev, [post.id]: e.target.value }))}
|
|
placeholder={`Reply to ${post.authorName || communityDisplayName}...`}
|
|
style={{ width: '100%', padding: '0.8rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem' }}
|
|
/>
|
|
<button type="button" className="btn-primary" disabled={submitting || !(replyDrafts[post.id] ?? '').trim()} onClick={() => submitReply(post.id)}>Post Reply</button>
|
|
</div>
|
|
)}
|
|
{Array.isArray(post.replies) && post.replies.length > 0 && (
|
|
<div style={{ marginTop: '1rem', display: 'grid', gap: '0.65rem' }}>
|
|
{post.replies.map(reply => (
|
|
<div key={reply.id} style={{ borderLeft: '2px solid #2a2518', paddingLeft: '0.85rem' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
|
|
<strong style={{ color: '#c9a84c', fontSize: '0.95rem' }}>{reply.authorName || 'Student'}</strong>
|
|
<span className="study-note-status">{formatCommunityDate(reply.createdAt)}</span>
|
|
</div>
|
|
<p className="study-detail-copy" style={{ margin: '0.25rem 0 0', whiteSpace: 'pre-wrap' }}>{reply.message}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</article>
|
|
)
|
|
}
|
|
|
|
async function readJson<T>(url: string, init?: RequestInit): Promise<T> {
|
|
const response = await fetch(url, init)
|
|
const data = await response.json().catch(() => ({})) as T & { message?: string }
|
|
if (!response.ok) {
|
|
throw new Error((data as { message?: string }).message ?? 'Request failed')
|
|
}
|
|
return data as T
|
|
}
|
|
|
|
function normalizeEnrolledStudySlugs(value: unknown): string[] {
|
|
if (!Array.isArray(value)) return []
|
|
return value.filter(item => typeof item === 'string' && item.trim()).map(item => item.trim().toLowerCase())
|
|
}
|
|
|
|
function isEnrolledInStudy(auth: StudyAuthState, studySlug: string | undefined): boolean {
|
|
if (!auth.authenticated || !studySlug) return false
|
|
const normalized = studySlug.trim().toLowerCase()
|
|
if (!normalized) return false
|
|
return normalizeEnrolledStudySlugs(auth.enrolledStudySlugs).includes(normalized)
|
|
}
|
|
|
|
export function StudyLandingPage({ content }: Props) {
|
|
const 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)
|
|
|
|
return (
|
|
<main className="study-index-page" aria-label="Study hub">
|
|
<section className="section-study-course-hero">
|
|
<div className="section-inner study-course-hero-inner">
|
|
<p className="eyebrow">Self-Paced Bible Academy</p>
|
|
<h1>Study at Your Own Pace</h1>
|
|
<p className="study-course-hero-copy">Pick a study track, move lesson by lesson on your own schedule, and keep personal notes as you grow through each passage.</p>
|
|
<div className="study-course-meta">
|
|
<span>{studies.length} study tracks</span>
|
|
<span>Self-paced flow</span>
|
|
<span>Personal notes</span>
|
|
<span>Audio + commentary</span>
|
|
</div>
|
|
<div className="study-course-hero-actions">
|
|
{firstActiveStudyFirstReleasedSection && <Link to={`/study/${firstActiveStudy.slug}/${firstActiveStudyFirstReleasedSection.id}`} className="btn-primary">Start Learning</Link>}
|
|
<Link to="#study-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>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="section-study-hub-flow" aria-label="How self-paced study works">
|
|
<div className="section-inner">
|
|
<div className="study-module-header">
|
|
<p>How It Works</p>
|
|
<h2>A Simple Self-Paced Rhythm</h2>
|
|
</div>
|
|
<div className="study-hub-flow-grid">
|
|
<article className="study-hub-flow-card">
|
|
<p className="study-module-lesson">Step 1</p>
|
|
<h3>Choose Your Track</h3>
|
|
<p>Start with any active study and begin at lesson one, or jump back in where you left off.</p>
|
|
</article>
|
|
<article className="study-hub-flow-card">
|
|
<p className="study-module-lesson">Step 2</p>
|
|
<h3>Work Each Lesson</h3>
|
|
<p>Read the text, listen to audio, review commentary, and process key Greek word notes.</p>
|
|
</article>
|
|
<article className="study-hub-flow-card">
|
|
<p className="study-module-lesson">Step 3</p>
|
|
<h3>Save Notes and Continue</h3>
|
|
<p>Keep personal notes per lesson and build your own study archive over time.</p>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="study-tracks" className="section-study-module" aria-label="Available studies">
|
|
<div className="section-inner">
|
|
<div className="study-module-header">
|
|
<p>Available Now</p>
|
|
<h2>Current Study Tracks</h2>
|
|
</div>
|
|
<div className="study-module-list">
|
|
{activeStudies.map(study => (
|
|
(() => {
|
|
const releasedCount = study.sections.filter(isSectionReleased).length
|
|
return (
|
|
<Link key={study.id} to={`/study/${study.slug}`} className="study-module-row">
|
|
<div className="study-module-row-left">
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem', flexWrap: 'wrap' }}>
|
|
<p className="study-module-lesson">Start Anytime</p>
|
|
{study.difficulty && <span style={{ backgroundColor: study.difficulty === 'advanced' ? '#d32f2f' : (study.difficulty === 'intermediate' ? '#f57c00' : '#388e3c'), color: '#fff', padding: '0.25rem 0.75rem', borderRadius: '16px', fontSize: '0.75rem', fontWeight: '600', textTransform: 'capitalize' }}>{study.difficulty}</span>}
|
|
</div>
|
|
<h3>{study.title}</h3>
|
|
<p>{study.description}</p>
|
|
{study.estimatedHours && <p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>⏱ {study.estimatedHours} hours</p>}
|
|
</div>
|
|
<div className="study-module-row-right">
|
|
<p className="study-module-focus">Track Snapshot</p>
|
|
<p>{releasedCount > 0 ? `${releasedCount} lessons available now` : 'Lessons will be published soon.'}</p>
|
|
<p>{study.sections.length > 0 ? 'Estimated pace: every other Monday' : 'Pacing details coming with first lesson release.'}</p>
|
|
</div>
|
|
</Link>
|
|
)
|
|
})()
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{plannedStudies.length > 0 && (
|
|
<section className="section-study-module" aria-label="Upcoming studies">
|
|
<div className="section-inner">
|
|
<div className="study-module-header">
|
|
<p>Coming Soon</p>
|
|
<h2>Next Study Tracks</h2>
|
|
</div>
|
|
<div className="study-module-list">
|
|
{plannedStudies.map(study => (
|
|
<article key={study.id} className="study-module-row study-module-row--disabled" aria-disabled="true">
|
|
<div className="study-module-row-left">
|
|
<p className="study-module-lesson">Planned</p>
|
|
<h3>{study.title}</h3>
|
|
<p>{study.description}</p>
|
|
</div>
|
|
<div className="study-module-row-right">
|
|
<p className="study-module-focus">Status</p>
|
|
<p>Preparing lesson structure and media.</p>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
|
|
</main>
|
|
)
|
|
}
|
|
|
|
export function StudySignupPage() {
|
|
const navigate = useNavigate()
|
|
const [mode, setMode] = useState<'signup' | 'login'>('signup')
|
|
const [email, setEmail] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [subscribeNewsletter, setSubscribeNewsletter] = useState(true)
|
|
const [busy, setBusy] = useState(false)
|
|
const [message, setMessage] = useState('')
|
|
const [done, setDone] = useState(false)
|
|
const [loggedInAs, setLoggedInAs] = useState('')
|
|
|
|
useEffect(() => {
|
|
readJson<StudyAuthStatusResponse>('/api/study-auth/status')
|
|
.then(data => {
|
|
if (data.authenticated) {
|
|
setDone(true)
|
|
setLoggedInAs(data.username ?? '')
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
}, [])
|
|
|
|
const submit = useCallback(async () => {
|
|
if (!email.trim() || !password.trim()) {
|
|
setMessage('Please enter your email and password.')
|
|
return
|
|
}
|
|
setBusy(true)
|
|
setMessage('')
|
|
try {
|
|
const data = await readJson<{ username: string; enrolledStudySlugs?: string[] }>(`/api/study-auth/${mode}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username: email, password, ...(mode === 'signup' ? { subscribe: subscribeNewsletter } : {}) }),
|
|
})
|
|
setDone(true)
|
|
setLoggedInAs(data.username ?? email.trim().toLowerCase())
|
|
} catch (err) {
|
|
setMessage(err instanceof Error ? err.message : 'Something went wrong. Please try again.')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}, [mode, email, password, subscribeNewsletter])
|
|
|
|
return (
|
|
<main className="thanks-page" aria-label={mode === 'signup' ? 'Create account' : 'Sign in'}>
|
|
<div className="thanks-card study-signup-page-card">
|
|
<p className="eyebrow">Free Student Account</p>
|
|
<h1>{mode === 'signup' ? 'Create Your Account' : 'Welcome Back'}</h1>
|
|
|
|
{done ? (
|
|
<>
|
|
<p className="study-signup-success">You're signed in as <strong>{loggedInAs}</strong>. Your notes are ready on every lesson.</p>
|
|
<div className="study-signup-actions">
|
|
<button type="button" className="btn-primary" onClick={() => navigate('/study')}>Go to Study Hub</button>
|
|
<Link to="/study/account" className="btn-secondary">My Account</Link>
|
|
<Link to="/study" className="btn-secondary">Browse Tracks</Link>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="study-signup-form">
|
|
<label htmlFor="signup-email">Email</label>
|
|
<input
|
|
id="signup-email"
|
|
type="email"
|
|
value={email}
|
|
onChange={e => setEmail(e.target.value)}
|
|
placeholder="your@email.com"
|
|
autoComplete="email"
|
|
autoFocus
|
|
/>
|
|
<label htmlFor="signup-password">Password</label>
|
|
<input
|
|
id="signup-password"
|
|
type="password"
|
|
value={password}
|
|
onChange={e => setPassword(e.target.value)}
|
|
placeholder="At least 8 characters"
|
|
autoComplete={mode === 'signup' ? 'new-password' : 'current-password'}
|
|
onKeyDown={e => e.key === 'Enter' && submit()}
|
|
/>
|
|
{mode === 'signup' && (
|
|
<label className="contact-consent" style={{ marginTop: '0.75rem' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={subscribeNewsletter}
|
|
onChange={e => setSubscribeNewsletter(e.target.checked)}
|
|
/>
|
|
<span>Send me updates from Verse by Verse with Nate. I can unsubscribe anytime.</span>
|
|
</label>
|
|
)}
|
|
{message && <p className="study-signup-error">{message}</p>}
|
|
<div className="study-signup-actions">
|
|
<button type="button" className="btn-primary" disabled={busy} onClick={submit}>
|
|
{busy ? (mode === 'signup' ? 'Creating...' : 'Signing in...') : (mode === 'signup' ? 'Create Account' : 'Sign In')}
|
|
</button>
|
|
</div>
|
|
<p className="study-signup-switch">
|
|
{mode === 'signup'
|
|
? <><span>Already have an account? </span><button type="button" className="study-signup-toggle" onClick={() => { setMode('login'); setMessage('') }}>Sign in instead</button></>
|
|
: <><span>New here? </span><button type="button" className="study-signup-toggle" onClick={() => { setMode('signup'); setMessage('') }}>Create a free account</button></>
|
|
}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="study-signup-why-grid" style={{ marginTop: '2rem' }}>
|
|
<article className="study-signup-why-card">
|
|
<p className="study-signup-why-icon" aria-hidden="true">📝</p>
|
|
<h3>Notes on Every Lesson</h3>
|
|
<p>Save your own notes per lesson — up to 500 notes across all tracks.</p>
|
|
</article>
|
|
<article className="study-signup-why-card">
|
|
<p className="study-signup-why-icon" aria-hidden="true">📖</p>
|
|
<h3>Space to Go Deep</h3>
|
|
<p>Each note holds ~2,000 words so you can write as much as you need.</p>
|
|
</article>
|
|
<article className="study-signup-why-card">
|
|
<p className="study-signup-why-icon" aria-hidden="true">🔒</p>
|
|
<h3>Private to You</h3>
|
|
<p>Your notes are tied to your account. Nobody else can read them.</p>
|
|
</article>
|
|
<article className="study-signup-why-card">
|
|
<p className="study-signup-why-icon" aria-hidden="true">⚡</p>
|
|
<h3>Free Forever</h3>
|
|
<p>Just an email and password. No charge, no ads. Stay signed in 30 days.</p>
|
|
</article>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="study-signup-actions" style={{ marginTop: '1.5rem' }}>
|
|
<Link to="/study/account" className="btn-secondary">My Account</Link>
|
|
<Link to="/study" className="btn-secondary">Back to Studies</Link>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
export function ColossiansStudyIndexPage({ content }: Props) {
|
|
const { studySlug } = useParams<{ studySlug?: string }>()
|
|
const studies = getStudies(content)
|
|
const study = getStudyBySlug(studies, studySlug)
|
|
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [] })
|
|
const [enrolling, setEnrolling] = useState(false)
|
|
const [enrollMessage, setEnrollMessage] = useState('')
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
readJson<StudyAuthStatusResponse>('/api/study-auth/status')
|
|
.then(data => {
|
|
if (cancelled) return
|
|
setAuth({
|
|
checked: true,
|
|
authenticated: Boolean(data.authenticated),
|
|
username: data.username ?? '',
|
|
enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs),
|
|
})
|
|
})
|
|
.catch(() => {
|
|
if (cancelled) return
|
|
setAuth({ checked: true, authenticated: false, username: '', enrolledStudySlugs: [] })
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
if (!study) {
|
|
return (
|
|
<main className="thanks-page" aria-label="Study not found">
|
|
<div className="thanks-card">
|
|
<p className="eyebrow">Study Hub</p>
|
|
<h1>Study not found</h1>
|
|
<p>The study you requested is not available yet.</p>
|
|
<Link to="/study" className="btn-primary">Back to Studies</Link>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
const sections = study.sections
|
|
const chapterSummaries = getChapterSummaries(study)
|
|
const releasedSections = sections.filter(isSectionReleased)
|
|
const firstReleasedSection = releasedSections[0]
|
|
const populatedChapterCount = chapterSummaries.filter(chapter => chapter.lessonCount > 0).length
|
|
const enrolled = isEnrolledInStudy(auth, study.slug)
|
|
|
|
async function enrollInStudy() {
|
|
if (!study) return
|
|
setEnrollMessage('')
|
|
setEnrolling(true)
|
|
try {
|
|
const response = await readJson<{ enrolledStudySlugs?: string[]; studyTitle?: string }>(`/api/study-enrollment/${encodeURIComponent(study.slug)}`, {
|
|
method: 'POST',
|
|
})
|
|
setAuth(prev => ({
|
|
...prev,
|
|
enrolledStudySlugs: normalizeEnrolledStudySlugs(response.enrolledStudySlugs),
|
|
}))
|
|
setEnrollMessage(response.studyTitle ? `You are enrolled in ${response.studyTitle}.` : 'You are now enrolled.')
|
|
} catch (err) {
|
|
setEnrollMessage(err instanceof Error ? err.message : 'Unable to enroll right now.')
|
|
} finally {
|
|
setEnrolling(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<main className="study-index-page" aria-label={`${study.title} study`}>
|
|
<section className="section-study-course-hero">
|
|
<div className="section-inner study-course-hero-inner">
|
|
<p className="eyebrow">Online Bible Class</p>
|
|
<h1>{study.title}</h1>
|
|
<p className="study-course-hero-copy">{study.description}</p>
|
|
<div className="study-course-meta">
|
|
<span>{releasedSections.length} lessons available now</span>
|
|
<span>{chapterSummaries.length} chapters</span>
|
|
<span>{populatedChapterCount} chapters with lessons</span>
|
|
<span>Text + commentary + discussion</span>
|
|
<span>Student notes enabled</span>
|
|
</div>
|
|
<div className="study-course-hero-actions">
|
|
{enrolled && firstReleasedSection && <Link to={`/study/${study.slug}/${firstReleasedSection.id}`} className="btn-primary">Start Class</Link>}
|
|
{!auth.authenticated && <Link to="/study/signup" className="btn-primary">Sign In to Enroll</Link>}
|
|
{auth.authenticated && !enrolled && (
|
|
<button type="button" className="btn-primary" disabled={enrolling} onClick={enrollInStudy}>
|
|
{enrolling ? 'Enrolling...' : 'Enroll in This Study'}
|
|
</button>
|
|
)}
|
|
{auth.authenticated && <Link to="/study/account" className="btn-secondary">My Account</Link>}
|
|
{enrolled && <Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>}
|
|
<Link to="/study" className="btn-secondary">Back to Studies</Link>
|
|
</div>
|
|
{enrollMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{enrollMessage}</p>}
|
|
</div>
|
|
</section>
|
|
|
|
{auth.checked && auth.authenticated && !enrolled && (
|
|
<section className="section-study-module" aria-label="Enrollment required">
|
|
<div className="section-inner">
|
|
<article className="study-module-row study-module-row--disabled" aria-disabled="true">
|
|
<div className="study-module-row-left">
|
|
<p className="study-module-lesson">Enrollment Required</p>
|
|
<h3>Enroll before entering lessons</h3>
|
|
<p>You must enroll in this study before opening lessons or notes.</p>
|
|
<div style={{ marginTop: '0.75rem' }}>
|
|
<button type="button" className="btn-primary" disabled={enrolling} onClick={enrollInStudy}>
|
|
{enrolling ? 'Enrolling...' : `Enroll in ${study.title}`}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{sections.length === 0 && (
|
|
<section className="section-study-module">
|
|
<div className="section-inner">
|
|
<article className="study-module-row study-module-row--disabled" aria-disabled="true">
|
|
<div className="study-module-row-left">
|
|
<p className="study-module-lesson">Planned</p>
|
|
<h3>Lessons are being prepared</h3>
|
|
<p>Use the admin Studies editor to add section lessons and publish when ready.</p>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{sections.length > 0 && (
|
|
<section className="section-study-module" aria-label="Course lessons">
|
|
<div className="section-inner">
|
|
<div className="study-module-header">
|
|
<p>Course Lessons</p>
|
|
<h2>{study.title}</h2>
|
|
</div>
|
|
<div className="study-module-list">
|
|
{sections.map(section => {
|
|
const lessonNumber = getLessonNumber(sections, section.id)
|
|
const isNew = isNewLesson(section)
|
|
const coming = isComingSoon(section)
|
|
const isDisabled = coming || !enrolled
|
|
|
|
return (
|
|
<Link key={section.id} to={isDisabled ? '#' : `/study/${study.slug}/${section.id}`} className={`study-module-row${isDisabled ? ' study-module-row--disabled' : ''}`} onClick={e => isDisabled && e.preventDefault()}>
|
|
<div className="study-module-row-left">
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
|
<p className="study-module-lesson">Lesson {lessonNumber}</p>
|
|
{isNew && <span style={{ backgroundColor: '#4caf50', color: '#fff', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>New</span>}
|
|
{coming && <span style={{ backgroundColor: '#ffb74d', color: '#333', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Coming {getReleaseDateDisplay(section)}</span>}
|
|
{!coming && !enrolled && <span style={{ backgroundColor: '#ef5350', color: '#fff', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Enroll to open</span>}
|
|
</div>
|
|
<h3>{section.title}</h3>
|
|
<p>{section.summary}</p>
|
|
</div>
|
|
<div className="study-module-row-right">
|
|
<p className="study-section-reference">{section.reference}</p>
|
|
<p className="study-module-focus">Focus question</p>
|
|
<p>{getPrimaryQuestion(section)}</p>
|
|
</div>
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
</main>
|
|
)
|
|
}
|
|
|
|
export function ColossiansStudySectionPage({ content }: Props) {
|
|
const { studySlug, sectionId } = useParams<{ studySlug?: string; sectionId: string }>()
|
|
const studies = getStudies(content)
|
|
const study = getStudyBySlug(studies, studySlug)
|
|
const sections = study?.sections ?? []
|
|
const section = getSectionById(sections, sectionId)
|
|
const sectionIndex = sections.findIndex(item => item.id === sectionId)
|
|
const lessonNumber = getLessonNumber(sections, sectionId)
|
|
const previousSection = sectionIndex > 0 ? sections[sectionIndex - 1] : null
|
|
const nextSection = sectionIndex >= 0 && sectionIndex < sections.length - 1 ? sections[sectionIndex + 1] : null
|
|
const sectionIsReleased = section ? isSectionReleased(section) : false
|
|
|
|
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [] })
|
|
const [usernameInput, setUsernameInput] = useState('')
|
|
const [passwordInput, setPasswordInput] = useState('')
|
|
const [authBusy, setAuthBusy] = useState(false)
|
|
const [authMessage, setAuthMessage] = useState('')
|
|
const [enrollBusy, setEnrollBusy] = useState(false)
|
|
const [enrollMessage, setEnrollMessage] = useState('')
|
|
|
|
const [noteText, setNoteText] = useState('')
|
|
const [noteLoading, setNoteLoading] = useState(false)
|
|
const [noteSaving, setNoteSaving] = useState(false)
|
|
const [noteMessage, setNoteMessage] = useState('')
|
|
|
|
const canSaveNote = auth.authenticated && !noteSaving && !noteLoading
|
|
const lessonAudioEmbedUrl = useMemo(() => {
|
|
const trimmed = section?.audioEmbedUrl?.trim() ?? ''
|
|
if (!trimmed) return ''
|
|
return /^https?:\/\//i.test(trimmed) ? trimmed : ''
|
|
}, [section?.audioEmbedUrl])
|
|
|
|
const currentStudySlug = study?.slug ?? 'colossians'
|
|
const noteId = section?.id ? getNoteId(currentStudySlug, section.id) : ''
|
|
const isEnrolled = isEnrolledInStudy(auth, currentStudySlug)
|
|
|
|
useEffect(() => {
|
|
document.title = section && study ? `${section.title} | ${study.title}` : 'Study'
|
|
}, [section, study])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
readJson<StudyAuthStatusResponse>('/api/study-auth/status')
|
|
.then(data => {
|
|
if (cancelled) return
|
|
setAuth({
|
|
checked: true,
|
|
authenticated: Boolean(data.authenticated),
|
|
username: data.username ?? '',
|
|
enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs),
|
|
})
|
|
})
|
|
.catch(() => {
|
|
if (cancelled) return
|
|
setAuth({ checked: true, authenticated: false, username: '', enrolledStudySlugs: [] })
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!auth.authenticated || !noteId || !isEnrolled) {
|
|
setNoteText('')
|
|
return
|
|
}
|
|
|
|
let cancelled = false
|
|
setNoteLoading(true)
|
|
setNoteMessage('')
|
|
|
|
readJson<{ note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`)
|
|
.then(data => {
|
|
if (cancelled) return
|
|
setNoteText(data.note ?? '')
|
|
})
|
|
.catch(err => {
|
|
if (cancelled) return
|
|
setNoteMessage(err instanceof Error ? err.message : 'Unable to load your note.')
|
|
})
|
|
.finally(() => {
|
|
if (cancelled) return
|
|
setNoteLoading(false)
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [auth.authenticated, noteId, isEnrolled])
|
|
|
|
async function submitAuth(mode: 'login' | 'signup') {
|
|
setAuthBusy(true)
|
|
setAuthMessage('')
|
|
|
|
try {
|
|
const payload = { username: usernameInput, password: passwordInput }
|
|
const data = await readJson<{ username: string; enrolledStudySlugs?: string[] }>(`/api/study-auth/${mode}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
setAuth({
|
|
checked: true,
|
|
authenticated: true,
|
|
username: data.username ?? usernameInput.trim().toLowerCase(),
|
|
enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs),
|
|
})
|
|
setUsernameInput('')
|
|
setPasswordInput('')
|
|
setAuthMessage(mode === 'signup' ? 'Account created. You can now save notes for each lesson.' : 'Signed in successfully.')
|
|
} catch (err) {
|
|
setAuthMessage(err instanceof Error ? err.message : 'Sign-in failed.')
|
|
} finally {
|
|
setAuthBusy(false)
|
|
}
|
|
}
|
|
|
|
async function logoutStudyUser() {
|
|
setAuthBusy(true)
|
|
setAuthMessage('')
|
|
try {
|
|
await readJson<{ ok: boolean }>('/api/study-auth/logout', { method: 'POST' })
|
|
setAuth({ checked: true, authenticated: false, username: '', enrolledStudySlugs: [] })
|
|
setNoteText('')
|
|
setAuthMessage('Signed out.')
|
|
} catch (err) {
|
|
setAuthMessage(err instanceof Error ? err.message : 'Unable to sign out.')
|
|
} finally {
|
|
setAuthBusy(false)
|
|
}
|
|
}
|
|
|
|
async function saveNote() {
|
|
if (!noteId || !canSaveNote) return
|
|
setNoteSaving(true)
|
|
setNoteMessage('')
|
|
try {
|
|
const data = await readJson<{ ok: boolean; note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ note: noteText }),
|
|
})
|
|
setNoteText(data.note ?? '')
|
|
setNoteMessage('Notes saved.')
|
|
} catch (err) {
|
|
setNoteMessage(err instanceof Error ? err.message : 'Unable to save note.')
|
|
} finally {
|
|
setNoteSaving(false)
|
|
}
|
|
}
|
|
|
|
async function enrollInCurrentStudy() {
|
|
if (!study) return
|
|
setEnrollMessage('')
|
|
setEnrollBusy(true)
|
|
try {
|
|
const response = await readJson<{ enrolledStudySlugs?: string[]; studyTitle?: string }>(`/api/study-enrollment/${encodeURIComponent(study.slug)}`, { method: 'POST' })
|
|
setAuth(prev => ({ ...prev, enrolledStudySlugs: normalizeEnrolledStudySlugs(response.enrolledStudySlugs) }))
|
|
setEnrollMessage(response.studyTitle ? `You are enrolled in ${response.studyTitle}.` : 'You are now enrolled.')
|
|
} catch (err) {
|
|
setEnrollMessage(err instanceof Error ? err.message : 'Unable to enroll right now.')
|
|
} finally {
|
|
setEnrollBusy(false)
|
|
}
|
|
}
|
|
|
|
if (!study || !section) {
|
|
return (
|
|
<main className="thanks-page" aria-label="Section not found">
|
|
<div className="thanks-card">
|
|
<p className="eyebrow">Study Hub</p>
|
|
<h1>Section not found</h1>
|
|
<p>The section you requested is not available yet.</p>
|
|
<Link to="/study" className="btn-primary">Back to Studies</Link>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
if (!sectionIsReleased) {
|
|
const releaseDateDisplay = getReleaseDateDisplay(section)
|
|
return (
|
|
<main className="thanks-page" aria-label="Lesson not yet available">
|
|
<div className="thanks-card">
|
|
<p className="eyebrow">{study.title}</p>
|
|
<h1>Lesson Coming Soon</h1>
|
|
<p>{releaseDateDisplay ? `This lesson will be available on ${releaseDateDisplay}.` : 'This lesson will be available soon.'}</p>
|
|
<Link to={`/study/${study.slug}`} className="btn-primary">Back to Lessons</Link>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
if (!auth.checked) {
|
|
return (
|
|
<main className="thanks-page" aria-label="Loading">
|
|
<div className="thanks-card">
|
|
<p className="eyebrow">{study.title}</p>
|
|
<p>Loading lesson...</p>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
if (!auth.authenticated) {
|
|
return (
|
|
<main className="thanks-page" aria-label="Sign in required">
|
|
<div className="thanks-card">
|
|
<p className="eyebrow">{study.title}</p>
|
|
<h1>Sign In to Continue</h1>
|
|
<p>Create a free account or sign in to access lessons and save your personal notes.</p>
|
|
<div className="study-signup-actions" style={{ marginTop: '1.5rem' }}>
|
|
<Link to="/study/signup" className="btn-primary">Create Account or Sign In</Link>
|
|
<Link to="/study/account" className="btn-secondary">My Account</Link>
|
|
<Link to={`/study/${study.slug}`} className="btn-secondary">Back to Lessons</Link>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
if (!isEnrolled) {
|
|
return (
|
|
<main className="thanks-page" aria-label="Enrollment required">
|
|
<div className="thanks-card">
|
|
<p className="eyebrow">{study.title}</p>
|
|
<h1>Enrollment Required</h1>
|
|
<p>You need to enroll in this study before entering lessons.</p>
|
|
<div className="study-signup-actions" style={{ marginTop: '1.5rem' }}>
|
|
<button type="button" className="btn-primary" disabled={enrollBusy} onClick={enrollInCurrentStudy}>
|
|
{enrollBusy ? 'Enrolling...' : `Enroll in ${study.title}`}
|
|
</button>
|
|
<Link to="/study/account" className="btn-secondary">My Account</Link>
|
|
<Link to={`/study/${study.slug}`} className="btn-secondary">Back to Study</Link>
|
|
</div>
|
|
{enrollMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{enrollMessage}</p>}
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<main className="study-section-page" aria-label={section.title}>
|
|
<section className="section-study-classroom">
|
|
<div className="section-inner study-classroom-shell">
|
|
<div className="study-classroom-main">
|
|
<Link to={`/study/${study.slug}`} className="study-detail-back">Back to {study.title}</Link>
|
|
<p className="study-lesson-label">Lesson {lessonNumber} of {sections.length}</p>
|
|
<h1>{section.title}</h1>
|
|
<p className="study-detail-reference">{section.reference}</p>
|
|
<p className="study-detail-summary">{section.summary}</p>
|
|
|
|
{lessonAudioEmbedUrl && (
|
|
<article className="study-class-block">
|
|
<h2>Lesson Audio</h2>
|
|
<div className="study-audio-embed-wrap">
|
|
<iframe
|
|
src={lessonAudioEmbedUrl}
|
|
title={`${section.title} audio`}
|
|
width="100%"
|
|
height="152"
|
|
frameBorder="0"
|
|
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
|
loading="lazy"
|
|
/>
|
|
</div>
|
|
</article>
|
|
)}
|
|
|
|
<article className="study-class-block">
|
|
<h2>Scripture Text</h2>
|
|
<p className="study-detail-copy study-detail-copy--scripture">{section.passageText || `Add the passage text for ${section.reference} here when you move the guide online.`}</p>
|
|
</article>
|
|
|
|
<article className="study-class-block">
|
|
<h2>Instructor Commentary</h2>
|
|
<p className="study-detail-copy">{section.commentary}</p>
|
|
</article>
|
|
|
|
<article className="study-class-block">
|
|
<h2>Discussion Questions</h2>
|
|
<ol className="study-detail-list">
|
|
{section.studyQuestions.map((question, index) => (
|
|
<li key={index}>{question}</li>
|
|
))}
|
|
</ol>
|
|
</article>
|
|
|
|
<StudyCommunityWidget study={study} section={section} auth={auth} isEnrolled={isEnrolled} />
|
|
</div>
|
|
|
|
<aside className="study-classroom-sidebar" aria-label="Lesson tools">
|
|
<article className="study-class-side-block">
|
|
<h3>Study Track</h3>
|
|
<p>{study.title}</p>
|
|
<p>{study.description}</p>
|
|
</article>
|
|
|
|
<article className="study-class-side-block">
|
|
<h3>Greek Word Study</h3>
|
|
{section.greekNotes.length > 0 ? (
|
|
<ul className="study-detail-list">
|
|
{section.greekNotes.map((note, index) => (
|
|
<li key={index}>{note}</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p className="study-detail-copy">Greek notes will be added for this lesson.</p>
|
|
)}
|
|
</article>
|
|
|
|
<article className="study-class-side-block">
|
|
<h3>Student Notes</h3>
|
|
{!auth.checked && <p className="study-detail-copy">Checking sign-in status...</p>}
|
|
{auth.checked && !auth.authenticated && (
|
|
<div className="study-auth-box">
|
|
<p className="study-auth-why">Sign in to save notes for this lesson.</p>
|
|
<label htmlFor="study-email">Email</label>
|
|
<input id="study-email" type="email" value={usernameInput} onChange={e => setUsernameInput(e.target.value)} placeholder="your@email.com" autoComplete="email" />
|
|
<label htmlFor="study-password">Password</label>
|
|
<input id="study-password" type="password" value={passwordInput} onChange={e => setPasswordInput(e.target.value)} placeholder="At least 8 characters" autoComplete="current-password" />
|
|
<div className="study-auth-actions">
|
|
<button type="button" className="btn-secondary" disabled={authBusy} onClick={() => submitAuth('login')}>Sign In</button>
|
|
<button type="button" className="btn-primary" disabled={authBusy} onClick={() => submitAuth('signup')}>Create Account</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{auth.checked && auth.authenticated && (
|
|
<div className="study-notes-box">
|
|
<p className="study-notes-user">Signed in as {auth.username}</p>
|
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.5rem' }}>
|
|
<Link to={`/study/${study.slug}/notes`} className="btn-secondary">Open My Notes</Link>
|
|
<Link to="/study/account" className="btn-secondary">My Account</Link>
|
|
</div>
|
|
<textarea
|
|
rows={8}
|
|
value={noteText}
|
|
onChange={e => setNoteText(e.target.value)}
|
|
placeholder="Write your lesson notes here..."
|
|
disabled={noteLoading || noteSaving}
|
|
/>
|
|
<div className="study-auth-actions">
|
|
<button type="button" className="btn-primary" disabled={!canSaveNote} onClick={saveNote}>{noteSaving ? 'Saving...' : 'Save Notes'}</button>
|
|
<button type="button" className="btn-secondary" disabled={authBusy} onClick={logoutStudyUser}>Sign Out</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{(authMessage || noteMessage) && <p className="study-note-status">{authMessage || noteMessage}</p>}
|
|
</article>
|
|
|
|
<article className="study-class-side-block">
|
|
<h3>Lesson Navigation</h3>
|
|
<div className="study-detail-nav">
|
|
{previousSection ? (
|
|
<Link to={`/study/${study.slug}/${previousSection.id}`} className="btn-secondary">Previous Lesson</Link>
|
|
) : <span className="study-nav-placeholder" />}
|
|
{nextSection && isSectionReleased(nextSection) ? (
|
|
<Link to={`/study/${study.slug}/${nextSection.id}`} className="btn-secondary">Next Lesson</Link>
|
|
) : nextSection ? (
|
|
<span className="study-nav-placeholder">Available {getReleaseDateDisplay(nextSection)}</span>
|
|
) : <span className="study-nav-placeholder" />}
|
|
</div>
|
|
</article>
|
|
</aside>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
export function ColossiansStudyNotesPage({ content }: Props) {
|
|
const { studySlug } = useParams<{ studySlug?: string }>()
|
|
const studies = getStudies(content)
|
|
const study = getStudyBySlug(studies, studySlug)
|
|
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [] })
|
|
const [notes, setNotes] = useState<StudyNotesMap>({})
|
|
const [loading, setLoading] = useState(true)
|
|
const [message, setMessage] = useState('')
|
|
|
|
useEffect(() => {
|
|
document.title = study ? `My Notes | ${study.title}` : 'My Study Notes'
|
|
}, [study])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
async function load() {
|
|
try {
|
|
const authData = await readJson<StudyAuthStatusResponse>('/api/study-auth/status')
|
|
if (cancelled) return
|
|
|
|
if (!authData.authenticated) {
|
|
setAuth({ checked: true, authenticated: false, username: '', enrolledStudySlugs: [] })
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
const enrolledStudySlugs = normalizeEnrolledStudySlugs(authData.enrolledStudySlugs)
|
|
setAuth({
|
|
checked: true,
|
|
authenticated: true,
|
|
username: authData.username ?? '',
|
|
enrolledStudySlugs,
|
|
})
|
|
|
|
if (!enrolledStudySlugs.includes((study?.slug ?? '').toLowerCase())) {
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
const notesData = await readJson<{ notes: StudyNotesMap }>('/api/study-notes')
|
|
if (cancelled) return
|
|
setNotes(notesData.notes ?? {})
|
|
} catch (err) {
|
|
if (cancelled) return
|
|
setMessage(err instanceof Error ? err.message : 'Unable to load notes.')
|
|
} finally {
|
|
if (cancelled) return
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
load()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
if (!study) {
|
|
return (
|
|
<main className="thanks-page" aria-label="Study not found">
|
|
<div className="thanks-card">
|
|
<p className="eyebrow">Study Hub</p>
|
|
<h1>Study not found</h1>
|
|
<p>The study you requested is not available yet.</p>
|
|
<Link to="/study" className="btn-primary">Back to Studies</Link>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
const prefix = `${study.slug}--`
|
|
const enrolled = isEnrolledInStudy(auth, study.slug)
|
|
const entries = Object.entries(notes)
|
|
.filter(([key]) => key.startsWith(prefix))
|
|
.map(([key, note]) => {
|
|
const sectionId = key.slice(prefix.length)
|
|
const section = study.sections.find(item => item.id === sectionId)
|
|
return { sectionId, section, note }
|
|
})
|
|
.filter(item => item.note && item.note.trim())
|
|
|
|
return (
|
|
<main className="study-section-page" aria-label="My study notes">
|
|
<section className="section-study-classroom">
|
|
<div className="section-inner">
|
|
<Link to={`/study/${study.slug}`} className="study-detail-back">Back to {study.title}</Link>
|
|
<p className="study-lesson-label">Student Workspace</p>
|
|
<h1>My Lesson Notes</h1>
|
|
{!loading && auth.authenticated && <p className="study-detail-summary">Signed in as {auth.username}</p>}
|
|
{!loading && auth.authenticated && (
|
|
<div className="study-signup-actions" style={{ marginBottom: '1rem' }}>
|
|
<Link to="/study/account" className="btn-secondary">My Account</Link>
|
|
<Link to={`/study/${study.slug}`} className="btn-secondary">Back to Lessons</Link>
|
|
</div>
|
|
)}
|
|
|
|
{loading && <p className="study-detail-copy">Loading your notes...</p>}
|
|
{!loading && !auth.authenticated && (
|
|
<article className="study-class-block">
|
|
<h2>Sign In Required</h2>
|
|
<p className="study-detail-copy">Open any lesson and sign in from the Student Notes panel to see your saved notes here.</p>
|
|
<Link to="/study/signup" className="btn-primary">Create Account or Sign In</Link>
|
|
</article>
|
|
)}
|
|
{!loading && auth.authenticated && !enrolled && (
|
|
<article className="study-class-block">
|
|
<h2>Enrollment Required</h2>
|
|
<p className="study-detail-copy">You need to enroll in this study before accessing your notes.</p>
|
|
<Link to={`/study/${study.slug}`} className="btn-primary">Go to Study Enrollment</Link>
|
|
</article>
|
|
)}
|
|
{!loading && auth.authenticated && enrolled && entries.length === 0 && (
|
|
<article className="study-class-block">
|
|
<h2>No Notes Yet</h2>
|
|
<p className="study-detail-copy">You have not saved notes yet. Open a lesson and use the Student Notes area to start.</p>
|
|
{study.sections[0] && <Link to={`/study/${study.slug}/${study.sections[0].id}`} className="btn-primary">Open First Lesson</Link>}
|
|
</article>
|
|
)}
|
|
{!loading && auth.authenticated && enrolled && entries.length > 0 && (
|
|
<div className="study-module-list">
|
|
{entries.map(({ sectionId, section, note }) => (
|
|
<article key={sectionId} className="study-module-row">
|
|
<div className="study-module-row-left">
|
|
<p className="study-module-lesson">{section ? `Lesson ${getLessonNumber(study.sections, section.id)}` : 'Saved Note'}</p>
|
|
<h3>{section?.title ?? sectionId}</h3>
|
|
<p>{section ? section.reference : 'Lesson reference unavailable'}</p>
|
|
</div>
|
|
<div className="study-module-row-right">
|
|
<p className="study-module-focus">Your Note</p>
|
|
<p className="study-detail-copy study-detail-copy--scripture">{note}</p>
|
|
{section && <Link to={`/study/${study.slug}/${section.id}`} className="btn-secondary">Open Lesson</Link>}
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
{message && <p className="study-note-status">{message}</p>}
|
|
</div>
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
export function StudyAccountPage() {
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
|
|
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [], displayName: '', subscribeNewsletter: true })
|
|
const [overview, setOverview] = useState<StudyAccountOverview | null>(null)
|
|
const [loadingOverview, setLoadingOverview] = useState(false)
|
|
|
|
const [displayNameInput, setDisplayNameInput] = useState('')
|
|
const [profileMessage, setProfileMessage] = useState('')
|
|
const [profileBusy, setProfileBusy] = useState(false)
|
|
|
|
const [subscribeNewsletter, setSubscribeNewsletter] = useState(true)
|
|
const [prefMessage, setPrefMessage] = useState('')
|
|
const [prefBusy, setPrefBusy] = useState(false)
|
|
|
|
const [emailCurrentPassword, setEmailCurrentPassword] = useState('')
|
|
const [emailNew, setEmailNew] = useState('')
|
|
const [emailTokenInput, setEmailTokenInput] = useState('')
|
|
const [emailMessage, setEmailMessage] = useState('')
|
|
const [emailBusy, setEmailBusy] = useState(false)
|
|
|
|
const [enrollBusySlug, setEnrollBusySlug] = useState('')
|
|
const [enrollMessage, setEnrollMessage] = useState('')
|
|
|
|
const [exporting, setExporting] = useState(false)
|
|
const [exportMessage, setExportMessage] = useState('')
|
|
|
|
const [pwCurrent, setPwCurrent] = useState('')
|
|
const [pwNew, setPwNew] = useState('')
|
|
const [pwConfirm, setPwConfirm] = useState('')
|
|
const [pwBusy, setPwBusy] = useState(false)
|
|
const [pwMessage, setPwMessage] = useState('')
|
|
const [pwSuccess, setPwSuccess] = useState(false)
|
|
|
|
const [signOutBusy, setSignOutBusy] = useState(false)
|
|
|
|
const [deleteStep, setDeleteStep] = useState<'idle' | 'warn1' | 'warn2'>('idle')
|
|
const [deleteConfirmInput, setDeleteConfirmInput] = useState('')
|
|
const [deleteBusy, setDeleteBusy] = useState(false)
|
|
const [deleteMessage, setDeleteMessage] = useState('')
|
|
|
|
useEffect(() => {
|
|
document.title = 'My Account | Study Hub'
|
|
readJson<StudyAuthStatusResponse>('/api/study-auth/status')
|
|
.then(data => {
|
|
setAuth({
|
|
checked: true,
|
|
authenticated: Boolean(data.authenticated),
|
|
username: data.username ?? '',
|
|
displayName: data.displayName ?? '',
|
|
subscribeNewsletter: data.subscribeNewsletter !== false,
|
|
enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs),
|
|
})
|
|
})
|
|
.catch(() => setAuth({ checked: true, authenticated: false, username: '', enrolledStudySlugs: [], displayName: '', subscribeNewsletter: true }))
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!auth.authenticated) return
|
|
setLoadingOverview(true)
|
|
readJson<StudyAccountOverview>('/api/study-account/overview')
|
|
.then(data => {
|
|
setOverview(data)
|
|
setDisplayNameInput(data.profile.displayName ?? '')
|
|
setSubscribeNewsletter(data.profile.subscribeNewsletter !== false)
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => setLoadingOverview(false))
|
|
}, [auth.authenticated])
|
|
|
|
useEffect(() => {
|
|
if (!auth.authenticated) return
|
|
const token = new URLSearchParams(location.search).get('verifyEmailToken')
|
|
if (!token) return
|
|
setEmailTokenInput(token)
|
|
setEmailBusy(true)
|
|
readJson<{ ok: boolean; username: string }>('/api/study-account/verify-email-change', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ token }),
|
|
})
|
|
.then(data => {
|
|
setEmailMessage('Email address verified and updated successfully.')
|
|
setAuth(prev => ({ ...prev, username: data.username }))
|
|
navigate('/study/account', { replace: true })
|
|
})
|
|
.catch(err => setEmailMessage(err instanceof Error ? err.message : 'Unable to verify email token.'))
|
|
.finally(() => setEmailBusy(false))
|
|
}, [auth.authenticated, location.search, navigate])
|
|
|
|
async function refreshOverview() {
|
|
const data = await readJson<StudyAccountOverview>('/api/study-account/overview')
|
|
setOverview(data)
|
|
setDisplayNameInput(data.profile.displayName ?? '')
|
|
setSubscribeNewsletter(data.profile.subscribeNewsletter !== false)
|
|
}
|
|
|
|
async function handleProfileSave() {
|
|
setProfileBusy(true)
|
|
setProfileMessage('')
|
|
try {
|
|
const data = await readJson<{ ok: boolean; displayName: string }>('/api/study-account/profile', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ displayName: displayNameInput }),
|
|
})
|
|
setAuth(prev => ({ ...prev, displayName: data.displayName }))
|
|
setProfileMessage('Profile saved.')
|
|
await refreshOverview()
|
|
} catch (err) {
|
|
setProfileMessage(err instanceof Error ? err.message : 'Unable to save profile.')
|
|
} finally {
|
|
setProfileBusy(false)
|
|
}
|
|
}
|
|
|
|
async function handlePreferenceSave() {
|
|
setPrefBusy(true)
|
|
setPrefMessage('')
|
|
try {
|
|
const data = await readJson<{ ok: boolean; subscribeNewsletter: boolean }>('/api/study-account/preferences', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ subscribeNewsletter }),
|
|
})
|
|
setAuth(prev => ({ ...prev, subscribeNewsletter: data.subscribeNewsletter }))
|
|
setPrefMessage('Preferences updated.')
|
|
await refreshOverview()
|
|
} catch (err) {
|
|
setPrefMessage(err instanceof Error ? err.message : 'Unable to save preferences.')
|
|
} finally {
|
|
setPrefBusy(false)
|
|
}
|
|
}
|
|
|
|
async function handleRequestEmailChange() {
|
|
setEmailBusy(true)
|
|
setEmailMessage('')
|
|
try {
|
|
await readJson<{ ok: boolean }>('/api/study-account/request-email-change', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ newEmail: emailNew, currentPassword: emailCurrentPassword }),
|
|
})
|
|
setEmailCurrentPassword('')
|
|
setEmailMessage('Verification email sent. Open the link in that email to complete the change.')
|
|
} catch (err) {
|
|
setEmailMessage(err instanceof Error ? err.message : 'Unable to request email change.')
|
|
} finally {
|
|
setEmailBusy(false)
|
|
}
|
|
}
|
|
|
|
async function handleVerifyTokenInput() {
|
|
setEmailBusy(true)
|
|
setEmailMessage('')
|
|
try {
|
|
const data = await readJson<{ ok: boolean; username: string }>('/api/study-account/verify-email-change', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ token: emailTokenInput }),
|
|
})
|
|
setAuth(prev => ({ ...prev, username: data.username }))
|
|
setEmailTokenInput('')
|
|
setEmailMessage('Email address verified and updated successfully.')
|
|
await refreshOverview()
|
|
} catch (err) {
|
|
setEmailMessage(err instanceof Error ? err.message : 'Unable to verify email token.')
|
|
} finally {
|
|
setEmailBusy(false)
|
|
}
|
|
}
|
|
|
|
async function handleEnrollmentToggle(studySlug: string, enrolled: boolean) {
|
|
setEnrollBusySlug(studySlug)
|
|
setEnrollMessage('')
|
|
try {
|
|
const data = await readJson<{ enrolledStudySlugs?: string[]; studyTitle?: string }>(`/api/study-enrollment/${encodeURIComponent(studySlug)}`, {
|
|
method: enrolled ? 'DELETE' : 'POST',
|
|
})
|
|
const slugs = normalizeEnrolledStudySlugs(data.enrolledStudySlugs)
|
|
setAuth(prev => ({ ...prev, enrolledStudySlugs: slugs }))
|
|
setEnrollMessage(enrolled ? `Unenrolled from ${data.studyTitle ?? studySlug}.` : `Enrolled in ${data.studyTitle ?? studySlug}.`)
|
|
await refreshOverview()
|
|
} catch (err) {
|
|
setEnrollMessage(err instanceof Error ? err.message : 'Unable to update enrollment.')
|
|
} finally {
|
|
setEnrollBusySlug('')
|
|
}
|
|
}
|
|
|
|
async function handleExport() {
|
|
setExporting(true)
|
|
setExportMessage('')
|
|
try {
|
|
const res = await fetch('/api/study-account/export-notes')
|
|
if (!res.ok) throw new Error('Export failed. Please try again.')
|
|
const blob = await res.blob()
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `my-study-notes-${new Date().toISOString().slice(0, 10)}.docx`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
setExportMessage('Download started.')
|
|
} catch (err) {
|
|
setExportMessage(err instanceof Error ? err.message : 'Export failed.')
|
|
} finally {
|
|
setExporting(false)
|
|
}
|
|
}
|
|
|
|
async function handleChangePassword() {
|
|
setPwMessage('')
|
|
setPwSuccess(false)
|
|
if (!pwCurrent || !pwNew || !pwConfirm) { setPwMessage('Please fill in all password fields.'); return }
|
|
if (pwNew !== pwConfirm) { setPwMessage('New passwords do not match.'); return }
|
|
if (pwNew.length < 8) { setPwMessage('New password must be at least 8 characters.'); return }
|
|
setPwBusy(true)
|
|
try {
|
|
await readJson<{ ok: boolean }>('/api/study-account/change-password', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ currentPassword: pwCurrent, newPassword: pwNew }),
|
|
})
|
|
setPwSuccess(true)
|
|
setPwCurrent(''); setPwNew(''); setPwConfirm('')
|
|
} catch (err) {
|
|
setPwMessage(err instanceof Error ? err.message : 'Password change failed.')
|
|
} finally {
|
|
setPwBusy(false)
|
|
}
|
|
}
|
|
|
|
async function handleSignOut() {
|
|
setSignOutBusy(true)
|
|
try {
|
|
await readJson<{ ok: boolean }>('/api/study-auth/logout', { method: 'POST' })
|
|
navigate('/study')
|
|
} catch {
|
|
setSignOutBusy(false)
|
|
}
|
|
}
|
|
|
|
async function handleDeleteAccount() {
|
|
setDeleteBusy(true)
|
|
setDeleteMessage('')
|
|
try {
|
|
await readJson<{ ok: boolean }>('/api/study-account', { method: 'DELETE' })
|
|
navigate('/study')
|
|
} catch (err) {
|
|
setDeleteMessage(err instanceof Error ? err.message : 'Unable to delete account. Please try again.')
|
|
setDeleteBusy(false)
|
|
}
|
|
}
|
|
|
|
if (!auth.checked) {
|
|
return (
|
|
<main className="thanks-page" aria-label="Loading">
|
|
<div className="thanks-card"><p>Loading...</p></div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
if (!auth.authenticated) {
|
|
return (
|
|
<main className="thanks-page" aria-label="Sign in required">
|
|
<div className="thanks-card">
|
|
<p className="eyebrow">My Account</p>
|
|
<h1>Sign In Required</h1>
|
|
<p>Please sign in to manage your account.</p>
|
|
<Link to="/study/signup" className="btn-primary" style={{ marginTop: '1rem', display: 'inline-block' }}>Sign In</Link>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
const divider = <hr style={{ margin: '0 0 2rem', border: 'none', borderTop: '1px solid #2a2518' }} />
|
|
|
|
return (
|
|
<main className="thanks-page" aria-label="My account">
|
|
<div className="thanks-card" style={{ maxWidth: '760px', textAlign: 'left' }}>
|
|
<p className="eyebrow">Student Account</p>
|
|
<h1>My Account</h1>
|
|
<p style={{ marginBottom: '1.5rem' }}>Signed in as <strong>{auth.username}</strong></p>
|
|
|
|
{overview?.stats && (
|
|
<>
|
|
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginBottom: '2rem' }}>
|
|
<div style={{ flex: '1 1 150px', background: '#1a1a16', borderRadius: '8px', padding: '1rem', border: '1px solid #2a2518' }}>
|
|
<p style={{ fontSize: '1.6rem', fontWeight: 700, margin: 0 }}>{overview.stats.noteCount}</p>
|
|
<p style={{ margin: 0, fontSize: '0.85rem' }}>Total notes saved</p>
|
|
</div>
|
|
<div style={{ flex: '1 1 150px', background: '#1a1a16', borderRadius: '8px', padding: '1rem', border: '1px solid #2a2518' }}>
|
|
<p style={{ fontSize: '1rem', fontWeight: 600, margin: 0 }}>{new Date(overview.stats.memberSince).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</p>
|
|
<p style={{ margin: 0, fontSize: '0.85rem' }}>Member since</p>
|
|
</div>
|
|
</div>
|
|
{divider}
|
|
</>
|
|
)}
|
|
|
|
<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 with notes`}
|
|
{` • 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>
|
|
</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}
|
|
|
|
<section style={{ marginBottom: '2rem' }}>
|
|
<h2 style={{ marginTop: 0 }}>Sign Out</h2>
|
|
<button type="button" className="btn-secondary" disabled={signOutBusy} onClick={handleSignOut}>
|
|
{signOutBusy ? 'Signing out...' : 'Sign Out'}
|
|
</button>
|
|
</section>
|
|
|
|
{divider}
|
|
|
|
<section>
|
|
<h2 style={{ marginTop: 0, color: '#e57373' }}>Delete Account</h2>
|
|
{deleteStep === 'idle' && (
|
|
<>
|
|
<p style={{ fontSize: '0.9rem' }}>Permanently remove your account and all saved notes. This cannot be undone.</p>
|
|
<button type="button" className="btn-admin-remove" onClick={() => setDeleteStep('warn1')}>Delete My Account</button>
|
|
</>
|
|
)}
|
|
{deleteStep === 'warn1' && (
|
|
<div style={{ background: '#2d2414', border: '1px solid #6b4b1f', borderRadius: '6px', padding: '1rem', marginBottom: '1rem' }}>
|
|
<p style={{ fontWeight: 600 }}>Final warning: export your notes first.</p>
|
|
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap' }}>
|
|
<button type="button" className="btn-primary" onClick={handleExport} disabled={exporting}>Export Notes First</button>
|
|
<button type="button" className="btn-admin-remove" onClick={() => setDeleteStep('warn2')}>Continue to Delete</button>
|
|
<button type="button" className="btn-secondary" onClick={() => setDeleteStep('idle')}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{deleteStep === 'warn2' && (
|
|
<div style={{ background: '#2b1111', border: '1px solid #8a3b3b', borderRadius: '6px', padding: '1rem' }}>
|
|
<p>Type <strong>DELETE</strong> to confirm permanent deletion:</p>
|
|
<input type="text" value={deleteConfirmInput} onChange={e => setDeleteConfirmInput(e.target.value)} placeholder="DELETE" style={{ marginBottom: '0.8rem', padding: '0.5rem', border: '1px solid #8a3b3b', borderRadius: '4px', background: '#140f0f', color: '#f0ead8' }} />
|
|
{deleteMessage && <p style={{ color: '#ef9a9a', fontSize: '0.875rem' }}>{deleteMessage}</p>}
|
|
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap' }}>
|
|
<button type="button" className="btn-admin-remove" disabled={deleteBusy || deleteConfirmInput.trim() !== 'DELETE'} onClick={handleDeleteAccount}>
|
|
{deleteBusy ? 'Deleting...' : 'Permanently Delete My Account'}
|
|
</button>
|
|
<button type="button" className="btn-secondary" onClick={() => { setDeleteStep('idle'); setDeleteConfirmInput('') }}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<div style={{ marginTop: '2rem' }}>
|
|
<Link to="/study" className="btn-secondary">Back to Studies</Link>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|