learn-platform-deploy/Frontend/src/app/pages/LearnerLiveSessionPage.tsx

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>
)
}