Spaces:
Sleeping
Sleeping
| import { useState, useEffect, useRef } from 'react'; | |
| import { useParams, useNavigate } from 'react-router-dom'; | |
| import { Button } from '../ui/button'; | |
| import { Badge } from '../ui/badge'; | |
| import { Textarea } from '../ui/textarea'; | |
| import { | |
| AlertDialog, AlertDialogAction, AlertDialogCancel, | |
| AlertDialogContent, AlertDialogDescription, AlertDialogFooter, | |
| AlertDialogHeader, AlertDialogTitle, | |
| } from '../ui/alert-dialog'; | |
| import { | |
| Video, VideoOff, Mic, MicOff, PhoneOff, | |
| MessageSquare, ClipboardEdit, Save, Wifi, | |
| AlertTriangle, Clock, LogOut, | |
| } from 'lucide-react'; | |
| import { toast } from 'sonner'; | |
| import { useAuth } from '../../lib/authContext'; | |
| import { useWebRTC } from '../../hooks/useWebRTC'; | |
| import { useLanguage } from '../../lib/languageContext'; | |
| import { getApiBaseUrl } from '../../lib/api/config'; | |
| interface LiveSessionProps { | |
| sessionId?: string; | |
| onNavigate?: (page: string) => void; | |
| } | |
| export function LiveSession({ sessionId: propSessionId, onNavigate }: LiveSessionProps) { | |
| const { sessionId: paramSessionId } = useParams(); | |
| const navigate = useNavigate(); | |
| const sessionId = propSessionId || paramSessionId || 'unknown'; | |
| const { user } = useAuth(); | |
| const { language = 'ar' } = useLanguage?.() ?? {}; | |
| const [isVideoOn, setIsVideoOn] = useState(true); | |
| const [isAudioOn, setIsAudioOn] = useState(true); | |
| const [sessionTime, setSessionTime] = useState(0); | |
| const [newMessage, setNewMessage] = useState(''); | |
| const [showChat, setShowChat] = useState(false); | |
| const [showNotes, setShowNotes] = useState(false); | |
| const [sessionNotes, setSessionNotes] = useState(''); | |
| const [isSavingNotes, setIsSavingNotes] = useState(false); | |
| const [lastSavedNotes, setLastSavedNotes] = useState(''); | |
| const [sessionData, setSessionData] = useState<any>(null); | |
| const [isEndingSession, setIsEndingSession] = useState(false); | |
| const [endSessionError, setEndSessionError] = useState<string | null>(null); | |
| const [showEndConfirm, setShowEndConfirm] = useState(false); | |
| const joinRecordedRef = useRef(false); | |
| const localVideoRef = useRef<HTMLVideoElement>(null); | |
| const remoteVideoRef = useRef<HTMLVideoElement>(null); | |
| const { localStream, remoteStream, isConnected, isCameraAvailable, messages, sendChatMessage, toggleVideo, toggleAudio, connectionQuality } = useWebRTC( | |
| sessionId, | |
| user?.id || 'anonymous' | |
| ); | |
| // Fetch session data | |
| useEffect(() => { | |
| const fetchSession = async () => { | |
| if (sessionId === 'unknown' || !user) return; | |
| try { | |
| const token = localStorage.getItem('authToken'); | |
| const rolePath = user.role === 'sheikh' ? 'sheikh' : 'student'; | |
| const response = await fetch(`${getApiBaseUrl()}/api/${rolePath}/sessions/${sessionId}`, { | |
| headers: { 'Authorization': `Bearer ${token}` } | |
| }); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| setSessionData(data); | |
| if (data.sessionNotes) setSessionNotes(data.sessionNotes); | |
| const status = data.sessionStatus || data.status; | |
| if (status === 'MISSED' || status === 'CANCELLED') { | |
| if (user.role === 'student') { | |
| // Send student to session report so they can leave a review | |
| navigate(`/session-report/${sessionId}?mode=review`); | |
| } else { | |
| navigate('/sheikh/sessions'); | |
| } | |
| return; | |
| } | |
| // If the session hasn't started yet AND we somehow landed here without | |
| // a join in progress, go back to waiting room. But don't redirect if | |
| // the join was just recorded (status may lag behind by one poll cycle). | |
| if ((status === 'SCHEDULED' || status === 'PENDING') && !joinRecordedRef.current) { | |
| navigate(`/waiting-room/${sessionId}`, { replace: true }); | |
| return; | |
| } | |
| if (status === 'COMPLETED' && user.role === 'student') { | |
| navigate(`/session-report/${sessionId}?mode=review`); | |
| return; | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Failed to fetch session data:', error); | |
| } | |
| }; | |
| fetchSession(); | |
| const interval = setInterval(fetchSession, 5000); // Poll status every 5 seconds | |
| return () => clearInterval(interval); | |
| }, [sessionId, user, navigate, onNavigate, language]); | |
| // Record join for BOTH sheikh and student on mount — this sets joinedAt timestamps | |
| // and transitions status to ON_GOING once both have joined. | |
| useEffect(() => { | |
| if (sessionId === 'unknown' || !user || joinRecordedRef.current) return; | |
| joinRecordedRef.current = true; | |
| const recordJoin = async () => { | |
| try { | |
| const token = localStorage.getItem('authToken'); | |
| // Sheikh uses /api/sheikh/sessions/{id}/join | |
| // Student uses /api/student/sessions/{id}/join | |
| const rolePath = user.role === 'sheikh' ? 'sheikh' : 'student'; | |
| const response = await fetch(`${getApiBaseUrl()}/api/${rolePath}/sessions/${sessionId}/join`, { | |
| method: 'POST', | |
| headers: { 'Authorization': `Bearer ${token}` } | |
| }); | |
| if (response.ok) { | |
| console.log(`[${user.role}] join recorded for session ${sessionId}`); | |
| } else { | |
| console.error('Failed to record join:', await response.text()); | |
| } | |
| } catch (error) { | |
| console.error('Network error recording join:', error); | |
| } | |
| }; | |
| recordJoin(); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [sessionId, user?.role]); | |
| // Bind local stream to video element | |
| useEffect(() => { | |
| if (localVideoRef.current && localStream) { | |
| localVideoRef.current.srcObject = localStream; | |
| } | |
| }, [localStream]); | |
| // Bind remote stream to video element | |
| useEffect(() => { | |
| if (remoteVideoRef.current && remoteStream) { | |
| remoteVideoRef.current.srcObject = remoteStream; | |
| } | |
| }, [remoteStream]); | |
| // Session timer | |
| useEffect(() => { | |
| const timer = setInterval(() => setSessionTime(prev => prev + 1), 1000); | |
| return () => clearInterval(timer); | |
| }, []); | |
| // Auto-save notes every 60s (sheikh only) | |
| useEffect(() => { | |
| if (user?.role !== 'sheikh') return; | |
| const timer = setInterval(() => handleSaveNotes(true), 60000); | |
| return () => clearInterval(timer); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [sessionNotes, lastSavedNotes, sessionId]); | |
| const formatTime = (seconds: number) => { | |
| const h = Math.floor(seconds / 3600); | |
| const m = Math.floor((seconds % 3600) / 60); | |
| const s = seconds % 60; | |
| return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; | |
| }; | |
| const handleSendMessage = () => { | |
| if (newMessage.trim()) { | |
| sendChatMessage(newMessage); | |
| setNewMessage(''); | |
| } | |
| }; | |
| const handleSaveNotes = async (isAutoSave = false) => { | |
| if (user?.role !== 'sheikh' || sessionNotes === lastSavedNotes) return; | |
| if (!isAutoSave) setIsSavingNotes(true); | |
| try { | |
| const token = localStorage.getItem('authToken'); | |
| const response = await fetch(`${getApiBaseUrl()}/api/sheikh/sessions/${sessionId}/notes`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, | |
| body: JSON.stringify({ notes: sessionNotes }) | |
| }); | |
| if (!response.ok) throw new Error('Failed to save notes'); | |
| setLastSavedNotes(sessionNotes); | |
| if (!isAutoSave) toast.success('Notes saved successfully!'); | |
| } catch (error) { | |
| console.error('Failed to save notes:', error); | |
| if (!isAutoSave) toast.error('Failed to save notes. Please try again.'); | |
| } finally { | |
| if (!isAutoSave) setIsSavingNotes(false); | |
| } | |
| }; | |
| // ── Sheikh: confirm dialog → POST /end → navigate to sessions list ────────── | |
| const handleSheikhEndSession = async () => { | |
| setIsEndingSession(true); | |
| setEndSessionError(null); | |
| try { | |
| const token = localStorage.getItem('authToken'); | |
| const response = await fetch(`${getApiBaseUrl()}/api/sheikh/sessions/${sessionId}/end`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, | |
| body: JSON.stringify({ notes: sessionNotes }), | |
| }); | |
| if (!response.ok) { | |
| const msg = await response.text().catch(() => 'Failed to end session'); | |
| setEndSessionError(msg || 'Failed to end session. Please try again.'); | |
| return; | |
| } | |
| // Record sheikh leave timestamp (best-effort) | |
| await fetch(`${getApiBaseUrl()}/api/sheikh/sessions/${sessionId}/leave`, { | |
| method: 'POST', | |
| headers: { 'Authorization': `Bearer ${token}` }, | |
| }).catch(() => {}); | |
| if (onNavigate) onNavigate('my-sessions'); | |
| else navigate('/sheikh/sessions'); | |
| } catch (error) { | |
| console.error('Failed to end session:', error); | |
| setEndSessionError('Network error. Please try again.'); | |
| } finally { | |
| setIsEndingSession(false); | |
| setShowEndConfirm(false); | |
| } | |
| }; | |
| // ── Student: record leave → navigate to waiting screen (NOT review page) ─── | |
| const handleStudentLeave = async () => { | |
| try { | |
| const token = localStorage.getItem('authToken'); | |
| await fetch(`${getApiBaseUrl()}/api/student/sessions/${sessionId}/leave`, { | |
| method: 'POST', | |
| headers: { 'Authorization': `Bearer ${token}` }, | |
| }); | |
| } catch (error) { | |
| console.error('Failed to record leave (best-effort):', error); | |
| } | |
| if (onNavigate) onNavigate('session-waiting'); | |
| else navigate(`/session-waiting/${sessionId}`); | |
| }; | |
| const getConnectionStatusColor = () => { | |
| switch (connectionQuality) { | |
| case 'good': return 'text-emerald-500'; | |
| case 'fair': return 'text-yellow-500'; | |
| case 'poor': return 'text-red-500'; | |
| default: return 'text-gray-500'; | |
| } | |
| }; | |
| const getConnectionStatusLabel = () => { | |
| if (!isConnected) return 'Connecting to Server...'; | |
| switch (connectionQuality) { | |
| case 'good': return 'Excellent'; | |
| case 'fair': return 'Stable'; | |
| case 'poor': return 'Poor Connection'; | |
| default: return 'Waiting for Peer...'; | |
| } | |
| }; | |
| return ( | |
| <div className="h-screen bg-gray-900 flex flex-col"> | |
| {/* Camera/Mic Denied Warning */} | |
| {!isCameraAvailable && ( | |
| <div className="bg-yellow-500 text-yellow-950 text-sm font-medium px-4 py-2 flex items-center gap-2"> | |
| <AlertTriangle className="h-4 w-4 shrink-0" /> | |
| Camera and microphone access was denied. The other participant cannot see or hear you. Please allow access in your browser settings and refresh. | |
| </div> | |
| )} | |
| {/* Header */} | |
| <div className="bg-gray-800 border-b border-gray-700 px-6 py-3"> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <h2 className="text-white">{sessionData?.sessionDescription || 'Ongoing Session'}</h2> | |
| <p className="text-gray-400">with {user?.role === 'sheikh' ? sessionData?.studentName : sessionData?.sheikhName}</p> | |
| </div> | |
| <div className="flex items-center gap-6"> | |
| <div className="flex items-center gap-2"> | |
| <div className={`flex items-center gap-1.5 px-3 py-1 rounded-full bg-gray-700/50 border border-gray-600 ${getConnectionStatusColor()}`}> | |
| {connectionQuality === 'poor' ? <AlertTriangle className="h-3.5 w-3.5" /> : <Wifi className="h-3.5 w-3.5" />} | |
| <span className="text-xs font-medium">{getConnectionStatusLabel()}</span> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2 text-white border-l border-gray-700 pl-6"> | |
| <Clock className="h-4 w-4 text-emerald-500" /> | |
| <span className="font-mono">{formatTime(sessionTime)}</span> | |
| </div> | |
| <Badge variant="destructive" className="animate-pulse px-3">LIVE</Badge> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Main Content */} | |
| <div className="flex-1 flex overflow-hidden"> | |
| {/* Video Area */} | |
| <div className="flex-1 relative bg-black"> | |
| {/* Remote Video */} | |
| <div className="absolute inset-0 flex items-center justify-center"> | |
| {remoteStream ? ( | |
| <video ref={remoteVideoRef} autoPlay playsInline className="w-full h-full object-cover" /> | |
| ) : ( | |
| <div className="w-full h-full bg-gradient-to-br from-gray-800 to-gray-900 flex items-center justify-center"> | |
| <div className="text-center"> | |
| <div className="w-32 h-32 rounded-full bg-emerald-600 flex items-center justify-center text-white text-4xl mx-auto mb-4"> | |
| {(user?.role === 'sheikh' ? (sessionData?.studentName || 'Student') : (sessionData?.sheikhName || 'Sheikh')).split(' ').map((n: string) => n[0]).join('')} | |
| </div> | |
| <p className="text-white text-xl">{user?.role === 'sheikh' ? sessionData?.studentName : sessionData?.sheikhName}</p> | |
| <p className="text-gray-400 mt-2">Waiting for remote peer...</p> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Local Video (PiP) */} | |
| <div className="absolute top-4 right-4 w-48 h-36 bg-black rounded-lg shadow-lg border-2 border-white overflow-hidden"> | |
| <video | |
| ref={localVideoRef} | |
| autoPlay | |
| playsInline | |
| muted | |
| className={`w-full h-full object-cover ${!isVideoOn ? 'hidden' : ''}`} | |
| /> | |
| {!isVideoOn && ( | |
| <div className="w-full h-full flex items-center justify-center bg-gray-800"> | |
| <div className="text-center"> | |
| <div className="w-12 h-12 rounded-full bg-blue-600 flex items-center justify-center text-white mx-auto mb-1"> | |
| {user?.name?.[0] || 'U'} | |
| </div> | |
| <p className="text-white text-xs">You (Video Off)</p> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* Chat Sidebar */} | |
| {showChat && ( | |
| <div className="w-80 bg-gray-800 border-l border-gray-700 flex flex-col"> | |
| <div className="p-4 border-b border-gray-700"> | |
| <h3 className="text-white">Chat</h3> | |
| </div> | |
| <div className="flex-1 overflow-y-auto p-4 space-y-3"> | |
| {messages.map((msg, index) => ( | |
| <div key={index} className={`flex flex-col ${msg.sender === 'You' ? 'items-end' : 'items-start'}`}> | |
| <div className={`max-w-[80%] rounded-lg p-3 ${msg.sender === 'You' ? 'bg-emerald-600' : 'bg-gray-700'}`}> | |
| <p className="text-white text-sm">{msg.message}</p> | |
| </div> | |
| <p className="text-gray-400 text-xs mt-1">{msg.sender} • {msg.time}</p> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="p-4 border-t border-gray-700"> | |
| <div className="flex gap-2"> | |
| <Textarea | |
| value={newMessage} | |
| onChange={(e) => setNewMessage(e.target.value)} | |
| onKeyPress={(e) => e.key === 'Enter' && !e.shiftKey && (e.preventDefault(), handleSendMessage())} | |
| placeholder="Type a message..." | |
| rows={2} | |
| className="bg-gray-700 border-gray-600 text-white" | |
| /> | |
| <Button onClick={handleSendMessage} size="sm">Send</Button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Sheikh Notes Sidebar */} | |
| {user?.role === 'sheikh' && showNotes && ( | |
| <div className="w-96 bg-gray-800 border-l border-gray-700 flex flex-col"> | |
| <div className="p-4 border-b border-gray-700 flex justify-between items-center"> | |
| <h3 className="text-white flex items-center gap-2"> | |
| <ClipboardEdit className="h-5 w-5 text-emerald-500" /> | |
| Session Notes | |
| </h3> | |
| <Badge variant="outline" className="text-gray-400 border-gray-600">Private</Badge> | |
| </div> | |
| <div className="flex-1 p-4 flex flex-col gap-4"> | |
| <div className="bg-gray-700/50 p-3 rounded-lg border border-gray-600"> | |
| <p className="text-sm text-gray-300"> | |
| These notes will be saved to the session record after the call ends. The student will be able to see them in their session details. | |
| </p> | |
| </div> | |
| <Textarea | |
| value={sessionNotes} | |
| onChange={(e) => setSessionNotes(e.target.value)} | |
| placeholder="Write your observations, tajweed corrections, or assignments for the student here..." | |
| className="flex-1 bg-gray-700 border-gray-600 text-white resize-none" | |
| /> | |
| </div> | |
| <div className="p-4 border-t border-gray-700"> | |
| <Button | |
| onClick={() => handleSaveNotes(false)} | |
| className="w-full bg-emerald-600 hover:bg-emerald-700 text-white" | |
| disabled={isSavingNotes || sessionNotes.trim() === ''} | |
| > | |
| {isSavingNotes ? ( | |
| <span className="flex items-center gap-2"> | |
| <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" /> | |
| Saving... | |
| </span> | |
| ) : ( | |
| <span className="flex items-center gap-2"> | |
| <Save className="h-4 w-4" /> | |
| Save Notes | |
| </span> | |
| )} | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Controls */} | |
| <div className="bg-gray-800 border-t border-gray-700 px-6 py-4"> | |
| <div className="flex items-center justify-center gap-4"> | |
| {/* Video Toggle */} | |
| <Button | |
| variant={isVideoOn ? 'outline' : 'destructive'} | |
| size="lg" | |
| className={isVideoOn ? 'bg-gray-700 border-gray-600 hover:bg-gray-600 text-white' : ''} | |
| onClick={() => { setIsVideoOn(!isVideoOn); toggleVideo(); }} | |
| > | |
| {isVideoOn ? <Video className="h-5 w-5" /> : <VideoOff className="h-5 w-5" />} | |
| </Button> | |
| {/* Audio Toggle */} | |
| <Button | |
| variant={isAudioOn ? 'outline' : 'destructive'} | |
| size="lg" | |
| className={isAudioOn ? 'bg-gray-700 border-gray-600 hover:bg-gray-600 text-white' : ''} | |
| onClick={() => { setIsAudioOn(!isAudioOn); toggleAudio(); }} | |
| > | |
| {isAudioOn ? <Mic className="h-5 w-5" /> : <MicOff className="h-5 w-5" />} | |
| </Button> | |
| {/* Chat Toggle */} | |
| <Button | |
| variant={showChat ? 'default' : 'outline'} | |
| size="lg" | |
| className={showChat ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-gray-700 border-gray-600 hover:bg-gray-600 text-white'} | |
| onClick={() => { setShowChat(!showChat); if (!showChat) setShowNotes(false); }} | |
| > | |
| <MessageSquare className="h-5 w-5" /> | |
| </Button> | |
| {/* Sheikh Notes Toggle */} | |
| {user?.role === 'sheikh' && ( | |
| <Button | |
| variant={showNotes ? 'default' : 'outline'} | |
| size="lg" | |
| className={showNotes ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-gray-700 border-gray-600 hover:bg-gray-600 text-white'} | |
| onClick={() => { setShowNotes(!showNotes); if (!showNotes) setShowChat(false); }} | |
| > | |
| <ClipboardEdit className="h-5 w-5" /> | |
| </Button> | |
| )} | |
| {/* Sheikh: End Session button + confirmation dialog */} | |
| {user?.role === 'sheikh' && ( | |
| <> | |
| <Button | |
| variant="destructive" | |
| size="lg" | |
| disabled={isEndingSession} | |
| onClick={() => setShowEndConfirm(true)} | |
| > | |
| <PhoneOff className="h-5 w-5 mr-2" /> | |
| {isEndingSession ? 'Ending...' : 'End Session'} | |
| </Button> | |
| <AlertDialog open={showEndConfirm} onOpenChange={setShowEndConfirm}> | |
| <AlertDialogContent> | |
| <AlertDialogHeader> | |
| <AlertDialogTitle>End this session?</AlertDialogTitle> | |
| <AlertDialogDescription> | |
| This will mark the session as completed, save your notes, and release payment to you. The student will be redirected to the session report. | |
| </AlertDialogDescription> | |
| </AlertDialogHeader> | |
| {endSessionError && ( | |
| <p className="text-sm text-red-600 px-1">{endSessionError}</p> | |
| )} | |
| <AlertDialogFooter> | |
| <AlertDialogCancel disabled={isEndingSession}>Cancel</AlertDialogCancel> | |
| <AlertDialogAction | |
| onClick={handleSheikhEndSession} | |
| disabled={isEndingSession} | |
| className="bg-red-600 hover:bg-red-700" | |
| > | |
| {isEndingSession ? 'Ending...' : 'Yes, End Session'} | |
| </AlertDialogAction> | |
| </AlertDialogFooter> | |
| </AlertDialogContent> | |
| </AlertDialog> | |
| </> | |
| )} | |
| {/* Student: Leave Call button */} | |
| {user?.role !== 'sheikh' && ( | |
| <Button variant="outline" size="lg" className="bg-gray-700 border-gray-600 hover:bg-gray-600 text-white" onClick={handleStudentLeave}> | |
| <LogOut className="h-5 w-5 mr-2" /> | |
| Leave Call | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |