import { ref, computed, onMounted, onUnmounted } from 'vue'; import { io, Socket } from 'socket.io-client'; import { useAuth } from './useAuth'; import { toast } from 'vue-sonner'; interface Call { callSid: string; direction: 'inbound' | 'outbound'; fromNumber: string; toNumber: string; status: string; startedAt?: string; duration?: number; } interface CallTranscript { text: string; isFinal: boolean; timestamp: number; } interface AiSuggestion { type: 'response' | 'action' | 'insight'; text: string; data?: any; } // Module-level shared state for global access const socket = ref(null); const isConnected = ref(false); const isOpen = ref(false); const currentCall = ref(null); const incomingCall = ref(null); const transcript = ref([]); const aiSuggestions = ref([]); const callHistory = ref([]); const isInitialized = ref(false); export function useSoftphone() { const auth = useAuth(); // Get token and tenantId from localStorage const getToken = () => { if (typeof window === 'undefined') return null; return localStorage.getItem('token'); }; const getTenantId = () => { if (typeof window === 'undefined') return null; return localStorage.getItem('tenantId'); }; // Computed properties const isInCall = computed(() => currentCall.value !== null); const hasIncomingCall = computed(() => incomingCall.value !== null); const callStatus = computed(() => currentCall.value?.status || 'idle'); /** * Initialize WebSocket connection */ const connect = () => { const token = getToken(); if (socket.value?.connected || !token) { return; } // Use same pattern as useApi to preserve subdomain for multi-tenant const getBackendUrl = () => { if (typeof window !== 'undefined') { const currentHost = window.location.hostname; const protocol = window.location.protocol; return `${protocol}//${currentHost}:3000`; } return 'http://localhost:3000'; }; // Connect to /voice namespace socket.value = io(`${getBackendUrl()}/voice`, { auth: { token: token, }, transports: ['websocket', 'polling'], reconnection: true, reconnectionDelay: 1000, reconnectionDelayMax: 5000, reconnectionAttempts: 5, }); // Connection events socket.value.on('connect', () => { console.log('Softphone WebSocket connected'); isConnected.value = true; }); socket.value.on('disconnect', () => { console.log('Softphone WebSocket disconnected'); isConnected.value = false; }); socket.value.on('connect_error', (error) => { console.error('Softphone connection error:', error); toast.error('Failed to connect to voice service'); }); // Call events socket.value.on('call:incoming', handleIncomingCall); socket.value.on('call:initiated', handleCallInitiated); socket.value.on('call:accepted', handleCallAccepted); socket.value.on('call:rejected', handleCallRejected); socket.value.on('call:ended', handleCallEnded); socket.value.on('call:update', handleCallUpdate); socket.value.on('call:error', handleCallError); socket.value.on('call:state', handleCallState); // AI events socket.value.on('ai:transcript', handleAiTranscript); socket.value.on('ai:suggestion', handleAiSuggestion); socket.value.on('ai:action', handleAiAction); isInitialized.value = true; }; /** * Disconnect WebSocket */ const disconnect = () => { if (socket.value) { socket.value.disconnect(); socket.value = null; isConnected.value = false; isInitialized.value = false; } }; /** * Open softphone dialog */ const open = () => { if (!isInitialized.value) { connect(); } isOpen.value = true; }; /** * Close softphone dialog */ const close = () => { isOpen.value = false; }; /** * Initiate outbound call */ const initiateCall = async (toNumber: string) => { if (!socket.value?.connected) { toast.error('Not connected to voice service'); return; } return new Promise((resolve, reject) => { socket.value!.emit('call:initiate', { toNumber }, (response: any) => { if (response.success) { resolve(response); } else { reject(new Error(response.error)); } }); }); }; /** * Accept incoming call */ const acceptCall = async (callSid: string) => { if (!socket.value?.connected) { toast.error('Not connected to voice service'); return; } return new Promise((resolve, reject) => { socket.value!.emit('call:accept', { callSid }, (response: any) => { if (response.success) { resolve(response); } else { reject(new Error(response.error)); } }); }); }; /** * Reject incoming call */ const rejectCall = async (callSid: string) => { if (!socket.value?.connected) { toast.error('Not connected to voice service'); return; } return new Promise((resolve, reject) => { socket.value!.emit('call:reject', { callSid }, (response: any) => { if (response.success) { resolve(response); } else { reject(new Error(response.error)); } }); }); }; /** * End active call */ const endCall = async (callSid: string) => { if (!socket.value?.connected) { toast.error('Not connected to voice service'); return; } return new Promise((resolve, reject) => { socket.value!.emit('call:end', { callSid }, (response: any) => { if (response.success) { resolve(response); } else { reject(new Error(response.error)); } }); }); }; /** * Send DTMF tone */ const sendDtmf = async (callSid: string, digit: string) => { if (!socket.value?.connected) { return; } return new Promise((resolve, reject) => { socket.value!.emit('call:dtmf', { callSid, digit }, (response: any) => { if (response.success) { resolve(response); } else { reject(new Error(response.error)); } }); }); }; // Event handlers const handleIncomingCall = (data: Call) => { console.log('Incoming call:', data); incomingCall.value = data; isOpen.value = true; toast.info(`Incoming call from ${data.fromNumber}`, { duration: 30000, action: { label: 'Answer', onClick: () => { acceptCall(data.callSid); }, }, }); // Play ringtone playRingtone(); }; const handleCallInitiated = (data: any) => { console.log('Call initiated:', data); currentCall.value = { callSid: data.callSid, direction: 'outbound', fromNumber: '', toNumber: data.toNumber, status: data.status, }; transcript.value = []; aiSuggestions.value = []; }; const handleCallAccepted = (data: any) => { console.log('Call accepted:', data); if (incomingCall.value?.callSid === data.callSid) { currentCall.value = incomingCall.value; if (currentCall.value) { currentCall.value.status = 'in-progress'; } incomingCall.value = null; } stopRingtone(); }; const handleCallRejected = (data: any) => { console.log('Call rejected:', data); if (incomingCall.value?.callSid === data.callSid) { incomingCall.value = null; } stopRingtone(); }; const handleCallEnded = (data: any) => { console.log('Call ended:', data); if (currentCall.value?.callSid === data.callSid) { currentCall.value = null; } if (incomingCall.value?.callSid === data.callSid) { incomingCall.value = null; } stopRingtone(); toast.info('Call ended'); }; const handleCallUpdate = (data: any) => { console.log('Call update:', data); if (currentCall.value?.callSid === data.callSid) { currentCall.value = { ...currentCall.value, ...data }; } }; const handleCallError = (data: any) => { console.error('Call error:', data); toast.error(data.message || 'Call error occurred'); }; const handleCallState = (data: Call) => { console.log('Call state:', data); if (data.status === 'in-progress') { currentCall.value = data; } }; const handleAiTranscript = (data: { transcript: string; isFinal: boolean }) => { console.log('AI transcript:', data); transcript.value.push({ text: data.transcript, isFinal: data.isFinal, timestamp: Date.now(), }); // Keep only last 50 transcript items if (transcript.value.length > 50) { transcript.value = transcript.value.slice(-50); } }; const handleAiSuggestion = (data: AiSuggestion) => { console.log('AI suggestion:', data); aiSuggestions.value.unshift(data); // Keep only last 10 suggestions if (aiSuggestions.value.length > 10) { aiSuggestions.value = aiSuggestions.value.slice(0, 10); } }; const handleAiAction = (data: any) => { console.log('AI action:', data); toast.info(`AI: ${data.action}`); }; // Ringtone management let ringtoneAudio: HTMLAudioElement | null = null; const playRingtone = () => { try { ringtoneAudio = new Audio('/ringtone.mp3'); ringtoneAudio.loop = true; ringtoneAudio.play(); } catch (error) { console.error('Failed to play ringtone:', error); } }; const stopRingtone = () => { if (ringtoneAudio) { ringtoneAudio.pause(); ringtoneAudio = null; } }; // Auto-connect on mount if token is available onMounted(() => { if (getToken() && !isInitialized.value) { connect(); } }); // Cleanup on unmount onUnmounted(() => { stopRingtone(); }); return { // State isOpen, isConnected, isInCall, hasIncomingCall, currentCall, incomingCall, callStatus, transcript, aiSuggestions, callHistory, // Methods open, close, connect, disconnect, initiateCall, acceptCall, rejectCall, endCall, sendDtmf, }; }