155 lines
5.5 KiB
TypeScript
155 lines
5.5 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { Link, useParams } from 'react-router'
|
|
import { ArrowLeft, CalendarClock, ExternalLink, Lock } from 'lucide-react'
|
|
import { ApiError } from '../api/client'
|
|
import {
|
|
fetchLearnerLiveSession,
|
|
joinLearnerLiveSession,
|
|
type LearnerLiveSessionDetail,
|
|
} from '../learner-live-courses'
|
|
|
|
type LoadState = 'loading' | 'ready' | 'error' | 'not-found'
|
|
|
|
function formatDateTime(value?: number, timezone?: string): string {
|
|
if (!value) return 'TBA'
|
|
try {
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
timeZone: timezone,
|
|
}).format(new Date(value))
|
|
} catch {
|
|
return new Date(value).toLocaleString()
|
|
}
|
|
}
|
|
|
|
export function LearnerLiveSessionPage() {
|
|
const { id = '', sessionId = '' } = useParams()
|
|
const [detail, setDetail] = useState<LearnerLiveSessionDetail | null>(null)
|
|
const [state, setState] = useState<LoadState>('loading')
|
|
const [error, setError] = useState('')
|
|
const [joinError, setJoinError] = useState('')
|
|
const [joining, setJoining] = useState(false)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
const load = async () => {
|
|
try {
|
|
setState('loading')
|
|
const result = await fetchLearnerLiveSession(id, sessionId)
|
|
if (cancelled) return
|
|
if (!result.session.id || !result.course.id) {
|
|
setState('not-found')
|
|
return
|
|
}
|
|
setDetail(result)
|
|
setState('ready')
|
|
} catch (err) {
|
|
if (cancelled) return
|
|
if (err instanceof ApiError && err.status === 404) {
|
|
setState('not-found')
|
|
return
|
|
}
|
|
setError(err instanceof Error ? err.message : 'Unable to load live session')
|
|
setState('error')
|
|
}
|
|
}
|
|
|
|
void load()
|
|
return () => { cancelled = true }
|
|
}, [id, sessionId])
|
|
|
|
const canJoin = useMemo(() => Boolean(detail?.access.allowed), [detail])
|
|
|
|
const handleJoin = async () => {
|
|
if (!detail) return
|
|
setJoinError('')
|
|
setJoining(true)
|
|
try {
|
|
const result = await joinLearnerLiveSession(detail.course.id, detail.session.id)
|
|
if (!result.joinUrl) {
|
|
throw new Error('Join URL is not available yet')
|
|
}
|
|
window.open(result.joinUrl, '_blank', 'noopener,noreferrer')
|
|
} catch (err) {
|
|
setJoinError(err instanceof Error ? err.message : 'Failed to join session')
|
|
} finally {
|
|
setJoining(false)
|
|
}
|
|
}
|
|
|
|
if (state === 'loading') {
|
|
return <p className="text-sm text-slate-600">Loading session details...</p>
|
|
}
|
|
|
|
if (state === 'not-found') {
|
|
return (
|
|
<div>
|
|
<h1 className="text-3xl font-semibold text-slate-900">Session not found</h1>
|
|
<p className="mt-2 text-sm text-slate-600">This session may not be available for your learner account.</p>
|
|
<Link to="/learn/live" className="mt-5 inline-flex items-center gap-2 text-sm font-semibold text-sky-700">
|
|
<ArrowLeft size={16} />
|
|
Back to live sessions
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (state === 'error') {
|
|
return <p className="text-sm text-red-600">{error}</p>
|
|
}
|
|
|
|
return (
|
|
<article data-testid="learner-live-session-detail-page">
|
|
<Link data-testid="learner-live-session-back" to="/learn/live" className="mb-6 inline-flex items-center gap-2 text-sm font-semibold text-slate-600 hover:text-slate-900">
|
|
<ArrowLeft size={16} />
|
|
Learner live sessions
|
|
</Link>
|
|
|
|
<header className="rounded-3xl border border-slate-200 bg-slate-50 p-6 md:p-8">
|
|
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">{detail?.course.title}</p>
|
|
<h1 className="mt-2 text-3xl font-semibold leading-tight tracking-tight text-slate-900 md:text-5xl">{detail?.session.title}</h1>
|
|
<p className="mt-4 inline-flex items-center gap-2 text-sm text-slate-600">
|
|
<CalendarClock size={15} />
|
|
{formatDateTime(detail?.session.startsAt, detail?.session.timezone || detail?.cohort.timezone)}
|
|
</p>
|
|
</header>
|
|
|
|
{!canJoin && (
|
|
<section className="mt-6 rounded-2xl border border-amber-200 bg-amber-50 p-4">
|
|
<p className="inline-flex items-center gap-2 text-sm font-semibold text-amber-900">
|
|
<Lock size={16} />
|
|
Session Locked
|
|
</p>
|
|
<p className="mt-2 text-sm text-amber-800">{detail?.access.message || 'Access to this session is currently restricted.'}</p>
|
|
</section>
|
|
)}
|
|
|
|
<section className="mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-6">
|
|
<p className="text-sm text-slate-600">Cohort: <span className="font-semibold text-slate-800">{detail?.cohort.title}</span></p>
|
|
|
|
{canJoin ? (
|
|
<button
|
|
data-testid="learner-live-session-join"
|
|
type="button"
|
|
onClick={() => { void handleJoin() }}
|
|
disabled={joining}
|
|
className="mt-4 inline-flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-800 hover:border-emerald-500 hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
<ExternalLink size={16} />
|
|
{joining ? 'Joining...' : 'Join Session'}
|
|
</button>
|
|
) : (
|
|
<p className="mt-4 text-sm text-slate-500">Join is enabled when enrollment and entitlement checks pass.</p>
|
|
)}
|
|
|
|
{joinError && <p className="mt-3 text-sm text-red-600">{joinError}</p>}
|
|
</section>
|
|
</article>
|
|
)
|
|
}
|