import { useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { api } from '@/lib/api' import { hasWebsiteScope, scopedQueryKey } from '@/lib/queryKeys' import { useAppStore } from '@/store/useAppStore' import { SafeHtml } from '@/components/SafeHtml' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' import { CheckCircle2, Send } from 'lucide-react' const ACTIVE_SESSION_KEY = 'irt-student-active-session' type SessionResponse = { session_id: string wp_user_id: string website_id: number tryout_id: string start_time: string end_time: string | null expires_at: string | null is_completed: boolean total_benar: number NM: number | null NN: number | null } type NextItemResponse = { status: 'item' | 'completed' item_id?: number stem?: string options?: Record slot?: number level?: string display_level?: string generated_by?: string source_snapshot_question_id?: number | null reason?: string items_answered?: number } function formatRemaining(seconds: number | null) { if (seconds === null) return '-' const clamped = Math.max(0, seconds) const minutes = Math.floor(clamped / 60) const rest = clamped % 60 return `${minutes}:${String(rest).padStart(2, '0')}` } function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : 'Request failed.' } export default function StudentSession() { const { sessionId } = useParams<{ sessionId: string }>() const { websiteId } = useAppStore() const queryClient = useQueryClient() const navigate = useNavigate() const [selectedResponse, setSelectedResponse] = useState('') const [now, setNow] = useState(0) const itemStartedAtRef = useRef(0) const sessionKey = scopedQueryKey(websiteId, 'student-session', sessionId) const nextItemKey = scopedQueryKey(websiteId, 'student-next-item', sessionId) const sessionQuery = useQuery({ queryKey: sessionKey, queryFn: async () => { const res = await api.get(`/session/${sessionId}`) return res.data }, enabled: hasWebsiteScope(websiteId) && Boolean(sessionId), }) const nextItemQuery = useQuery({ queryKey: nextItemKey, queryFn: async () => { const res = await api.get(`/session/${sessionId}/next_item`) return res.data }, enabled: hasWebsiteScope(websiteId) && Boolean(sessionId) && !sessionQuery.data?.is_completed, }) const submitMutation = useMutation({ mutationFn: async () => { if (!nextItemQuery.data?.item_id) throw new Error('No active item.') const startedAt = itemStartedAtRef.current || Date.now() const timeSpent = Math.max(0, Math.round((Date.now() - startedAt) / 1000)) await api.post(`/session/${sessionId}/submit_answer`, { item_id: nextItemQuery.data.item_id, response: selectedResponse, time_spent: timeSpent, }) }, onSuccess: () => { setSelectedResponse('') itemStartedAtRef.current = Date.now() queryClient.invalidateQueries({ queryKey: nextItemKey }) queryClient.invalidateQueries({ queryKey: sessionKey }) }, }) const completeMutation = useMutation({ mutationFn: async () => { const res = await api.post(`/session/${sessionId}/complete`, { end_time: new Date().toISOString(), user_answers: [], }) return res.data }, onSuccess: () => { localStorage.removeItem(ACTIVE_SESSION_KEY) queryClient.invalidateQueries({ queryKey: sessionKey }) navigate(`/student/result/${sessionId}`) }, }) const expiresAt = sessionQuery.data?.expires_at useEffect(() => { if (!expiresAt) { return } const timer = window.setInterval(() => setNow(Date.now()), 1000) return () => window.clearInterval(timer) }, [expiresAt]) useEffect(() => { if (sessionId && !sessionQuery.data?.is_completed) { localStorage.setItem(ACTIVE_SESSION_KEY, sessionId) } }, [sessionId, sessionQuery.data?.is_completed]) useEffect(() => { if (nextItemQuery.data?.item_id) { itemStartedAtRef.current = Date.now() } }, [nextItemQuery.data?.item_id]) const options = useMemo(() => Object.entries(nextItemQuery.data?.options || {}), [nextItemQuery.data?.options]) const remainingSeconds = useMemo(() => { if (!expiresAt || !now) return null return Math.ceil((new Date(expiresAt).getTime() - now) / 1000) }, [expiresAt, now]) if (!hasWebsiteScope(websiteId)) { return
Select a website to load the session.
} if (sessionQuery.isLoading || nextItemQuery.isLoading) { return } if (sessionQuery.isError || !sessionQuery.data) { return ( Session failed to load Check the active website and session ID. ) } if (sessionQuery.data.is_completed) { return ( Session Completed {sessionQuery.data.session_id} ) } const nextItem = nextItemQuery.data return (

Tryout Session

{sessionQuery.data.tryout_id} ยท Student {sessionQuery.data.wp_user_id}

Answered {nextItem?.items_answered ?? 0} {formatRemaining(remainingSeconds)}
{(submitMutation.isError || completeMutation.isError || nextItemQuery.isError) && ( Session action failed {getErrorMessage(submitMutation.error || completeMutation.error || nextItemQuery.error)} )} {nextItem?.status === 'completed' ? ( Ready to Complete {nextItem.reason || 'No more questions are available for this session.'} ) : ( Question {nextItem?.slot ?? '-'} {nextItem?.display_level || nextItem?.level || 'item'}
{options.map(([key, value]) => ( ))}
)}
) }