import { useEffect, useCallback, type PropsWithChildren } from 'react'; import { useAtom, useSetAtom } from 'jotai'; import { sipConnectionStatusAtom, sipCallStateAtom, sipCallerNumberAtom, sipIsMutedAtom, sipIsOnHoldAtom, sipCallDurationAtom, sipCallStartTimeAtom, sipCallUcidAtom, } from '@/state/sip-state'; import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOutboundPending } from '@/state/sip-manager'; import { apiClient } from '@/lib/api-client'; import type { SIPConfig } from '@/types/sip'; // SIP config comes exclusively from the Agent entity (stored on login). // No env var fallback — users without an Agent entity don't connect SIP. const getSipConfig = (): SIPConfig | null => { try { const stored = localStorage.getItem('helix_agent_config'); if (stored) { const config = JSON.parse(stored); if (config.sipUri && config.sipWsServer) { return { displayName: 'Helix Agent', uri: config.sipUri, password: config.sipPassword, wsServer: config.sipWsServer, stunServers: 'stun:stun.l.google.com:19302', }; } } } catch {} return null; }; export const SipProvider = ({ children }: PropsWithChildren) => { const [, setConnectionStatus] = useAtom(sipConnectionStatusAtom); const [callState, setCallState] = useAtom(sipCallStateAtom); const setCallerNumber = useSetAtom(sipCallerNumberAtom); const setCallUcid = useSetAtom(sipCallUcidAtom); const setCallDuration = useSetAtom(sipCallDurationAtom); const setCallStartTime = useSetAtom(sipCallStartTimeAtom); // Register Jotai setters so the singleton SIP manager can update atoms useEffect(() => { registerSipStateUpdater({ setConnectionStatus, setCallState, setCallerNumber, setCallUcid, }); }, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid]); // Auto-connect SIP on mount — only if Agent entity has SIP config useEffect(() => { const config = getSipConfig(); if (config) { connectSip(config); } else { console.log('[SIP] No agent SIP config — skipping connection'); } }, []); // Call duration timer useEffect(() => { if (callState === 'active') { const start = new Date(); setCallStartTime(start); const interval = window.setInterval(() => { setCallDuration(Math.floor((Date.now() - start.getTime()) / 1000)); }, 1000); return () => clearInterval(interval); } setCallDuration(0); setCallStartTime(null); }, [callState, setCallDuration, setCallStartTime]); // Ringtone on incoming call useEffect(() => { if (callState === 'ringing-in') { import('@/lib/ringtone').then(({ startRingtone }) => startRingtone()); } else { import('@/lib/ringtone').then(({ stopRingtone }) => stopRingtone()); } }, [callState]); // No auto-reset — the ActiveCallCard handles post-call flow (disposition → appointment → done) // and resets to idle via the "Back to Worklist" button // Cleanup on unmount + page unload useEffect(() => { const handleUnload = () => disconnectSip(); window.addEventListener('beforeunload', handleUnload); return () => { window.removeEventListener('beforeunload', handleUnload); disconnectSip(); }; }, []); return <>{children}; }; // Hook for components to access SIP actions + state export const useSip = () => { const [connectionStatus] = useAtom(sipConnectionStatusAtom); const [callState, setCallState] = useAtom(sipCallStateAtom); const [callerNumber, setCallerNumber] = useAtom(sipCallerNumberAtom); const [callUcid, setCallUcid] = useAtom(sipCallUcidAtom); const [isMuted, setIsMuted] = useAtom(sipIsMutedAtom); const [isOnHold, setIsOnHold] = useAtom(sipIsOnHoldAtom); const [callDuration] = useAtom(sipCallDurationAtom); const makeCall = useCallback((phoneNumber: string) => { getSipClient()?.call(phoneNumber); setCallerNumber(phoneNumber); }, [setCallerNumber]); // Ozonetel outbound dial — single path for all outbound calls const dialOutbound = useCallback(async (phoneNumber: string): Promise => { console.log(`[DIAL] Outbound dial started: phone=${phoneNumber}`); setCallState('ringing-out'); setCallerNumber(phoneNumber); setOutboundPending(true); const safetyTimeout = setTimeout(() => { console.warn('[DIAL] Safety timeout fired (30s) — clearing outboundPending'); setOutboundPending(false); }, 30000); try { const result = await apiClient.post<{ status: string; ucid?: string }>('/api/ozonetel/dial', { phoneNumber }); console.log('[DIAL] Dial API response:', result); clearTimeout(safetyTimeout); // Store UCID from dial response — SIP bridge doesn't carry X-UCID for outbound if (result?.ucid) { console.log(`[DIAL] Storing UCID from dial response: ${result.ucid}`); setCallUcid(result.ucid); } } catch (err) { console.error('[DIAL] Dial API failed:', err); clearTimeout(safetyTimeout); setOutboundPending(false); setCallState('idle'); setCallerNumber(null); throw new Error('Dial failed'); } }, [setCallState, setCallerNumber, setCallUcid]); const answer = useCallback(() => getSipClient()?.answer(), []); const reject = useCallback(() => getSipClient()?.reject(), []); const hangup = useCallback(() => getSipClient()?.hangup(), []); const toggleMute = useCallback(() => { if (isMuted) { getSipClient()?.unmute(); } else { getSipClient()?.mute(); } setIsMuted(!isMuted); }, [isMuted, setIsMuted]); const toggleHold = useCallback(() => { if (isOnHold) { getSipClient()?.unhold(); } else { getSipClient()?.hold(); } setIsOnHold(!isOnHold); }, [isOnHold, setIsOnHold]); return { connectionStatus, callState, callerNumber, callUcid, isMuted, isOnHold, callDuration, isRegistered: connectionStatus === 'registered', isInCall: ['ringing-in', 'ringing-out', 'active'].includes(callState), ozonetelStatus: 'logged-in' as const, ozonetelError: null as string | null, connect: () => { const c = getSipConfig(); if (c) connectSip(c); }, disconnect: disconnectSip, makeCall, dialOutbound, answer, reject, hangup, toggleMute, toggleHold, }; };