From 4f9bdc7312ac11e5343f00498837c86b57398192 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Tue, 17 Mar 2026 09:14:25 +0530 Subject: [PATCH] feat: add Socket.IO client and useCallEvents hook for live CTI mode --- package-lock.json | 88 +++++++++++++++++- package.json | 1 + src/hooks/use-call-events.ts | 133 ++++++++++++++++++++++++++++ src/lib/socket.ts | 22 +++++ src/pages/call-desk.tsx | 167 +++++++++++++++++++++++++++++------ 5 files changed, 381 insertions(+), 30 deletions(-) create mode 100644 src/hooks/use-call-events.ts create mode 100644 src/lib/socket.ts diff --git a/package-lock.json b/package-lock.json index df40e22..855f731 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "react-dom": "^19.2.3", "react-hotkeys-hook": "^5.2.3", "react-router": "^7.13.0", + "socket.io-client": "^4.8.3", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "tailwindcss-animate": "^1.0.7", @@ -3128,6 +3129,12 @@ "win32" ] }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "http://localhost:4873/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@swc/core": { "version": "1.15.18", "resolved": "http://localhost:4873/@swc/core/-/core-1.15.18.tgz", @@ -4131,7 +4138,6 @@ "version": "4.4.3", "resolved": "http://localhost:4873/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4168,6 +4174,28 @@ "node": ">=8" } }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "http://localhost:4873/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "http://localhost:4873/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.0", "resolved": "http://localhost:4873/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", @@ -5071,7 +5099,6 @@ "version": "2.1.3", "resolved": "http://localhost:4873/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -5655,6 +5682,34 @@ "node": ">=8" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "http://localhost:4873/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "http://localhost:4873/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "http://localhost:4873/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5933,6 +5988,35 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "http://localhost:4873/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "http://localhost:4873/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "http://localhost:4873/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 44c0f23..27c943f 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "react-dom": "^19.2.3", "react-hotkeys-hook": "^5.2.3", "react-router": "^7.13.0", + "socket.io-client": "^4.8.3", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "tailwindcss-animate": "^1.0.7", diff --git a/src/hooks/use-call-events.ts b/src/hooks/use-call-events.ts new file mode 100644 index 0000000..e11b990 --- /dev/null +++ b/src/hooks/use-call-events.ts @@ -0,0 +1,133 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { disconnectSocket, getSocket } from '@/lib/socket'; +import type { CallDisposition } from '@/types/entities'; + +type CallState = 'idle' | 'ringing' | 'active' | 'completed'; + +type EnrichedLead = { + id: string; + firstName: string; + lastName: string; + phone: string; + email?: string; + source?: string; + status?: string; + campaign?: string; + interestedService?: string; + age: number; + aiSummary?: string; + aiSuggestedAction?: string; + recentActivities: { activityType: string; summary: string; occurredAt: string; performedBy: string }[]; +}; + +type IncomingCallEvent = { + callSid: string; + eventType: 'ringing' | 'answered' | 'ended'; + lead: EnrichedLead | null; + callerPhone: string; + agentName: string; + timestamp: string; +}; + +export const useCallEvents = (agentName: string) => { + const [callState, setCallState] = useState('idle'); + const [activeLead, setActiveLead] = useState(null); + const [activeCallSid, setActiveCallSid] = useState(null); + const [callStartTime, setCallStartTime] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const completedTimerRef = useRef(null); + + // Connect to WebSocket and register agent + useEffect(() => { + const socket = getSocket(); + + socket.on('connect', () => { + setIsConnected(true); + socket.emit('agent:register', agentName); + }); + + socket.on('disconnect', () => { + setIsConnected(false); + }); + + socket.on('agent:registered', (data: { agentName: string }) => { + console.log(`Registered as agent: ${data.agentName}`); + }); + + socket.on('call:incoming', (event: IncomingCallEvent) => { + if (event.eventType === 'ringing' || event.eventType === 'answered') { + setActiveCallSid(event.callSid); + setActiveLead(event.lead); + setCallStartTime(event.timestamp); + + if (event.eventType === 'ringing') { + setCallState('ringing'); + // Auto-transition to active after 1.5s (call is answered) + setTimeout(() => setCallState('active'), 1500); + } else { + setCallState('active'); + } + } else if (event.eventType === 'ended') { + // Call ended from Exotel side (e.g. customer hung up) + setCallState('completed'); + completedTimerRef.current = window.setTimeout(() => { + setCallState('idle'); + setActiveLead(null); + setActiveCallSid(null); + setCallStartTime(null); + }, 3000); + } + }); + + socket.on('call:disposition:ack', () => { + // Disposition saved on server + }); + + socket.connect(); + + return () => { + if (completedTimerRef.current) { + clearTimeout(completedTimerRef.current); + } + disconnectSocket(); + }; + }, [agentName]); + + // Send disposition to server + const sendDisposition = useCallback( + (disposition: CallDisposition, notes: string) => { + const socket = getSocket(); + const duration = callStartTime + ? Math.floor((Date.now() - new Date(callStartTime).getTime()) / 1000) + : 0; + + socket.emit('call:disposition', { + callSid: activeCallSid, + leadId: activeLead?.id ?? null, + disposition, + notes, + agentName, + callerPhone: activeLead?.phone ?? '', + startedAt: callStartTime, + duration, + }); + + setCallState('completed'); + completedTimerRef.current = window.setTimeout(() => { + setCallState('idle'); + setActiveLead(null); + setActiveCallSid(null); + setCallStartTime(null); + }, 3000); + }, + [activeCallSid, activeLead, agentName, callStartTime], + ); + + return { + callState, + activeLead, + activeCallSid, + isConnected, + sendDisposition, + }; +}; diff --git a/src/lib/socket.ts b/src/lib/socket.ts new file mode 100644 index 0000000..5813953 --- /dev/null +++ b/src/lib/socket.ts @@ -0,0 +1,22 @@ +import { io, Socket } from 'socket.io-client'; + +const SIDECAR_URL = import.meta.env.VITE_SIDECAR_URL ?? 'http://localhost:4100'; + +let socket: Socket | null = null; + +export const getSocket = (): Socket => { + if (!socket) { + socket = io(`${SIDECAR_URL}/call-events`, { + autoConnect: false, + transports: ['websocket'], + }); + } + return socket; +}; + +export const disconnectSocket = () => { + if (socket) { + socket.disconnect(); + socket = null; + } +}; diff --git a/src/pages/call-desk.tsx b/src/pages/call-desk.tsx index 9717600..1171830 100644 --- a/src/pages/call-desk.tsx +++ b/src/pages/call-desk.tsx @@ -7,9 +7,12 @@ import { DailyStats } from '@/components/call-desk/daily-stats'; import { useAuth } from '@/providers/auth-provider'; import { useData } from '@/providers/data-provider'; import { useLeads } from '@/hooks/use-leads'; -import type { Call, CallDisposition, LeadStatus } from '@/types/entities'; +import { useCallEvents } from '@/hooks/use-call-events'; +import { BadgeWithDot } from '@/components/base/badges/badges'; +import type { Call, CallDisposition, Lead, LeadStatus } from '@/types/entities'; type CallState = 'idle' | 'ringing' | 'active' | 'completed'; +type Mode = 'demo' | 'live'; const isToday = (dateStr: string): boolean => { const d = new Date(dateStr); @@ -35,19 +38,30 @@ export const CallDeskPage = () => { const { calls, leadActivities, campaigns, addCall } = useData(); const { leads, updateLead } = useLeads(); - const [callState, setCallState] = useState('idle'); - const [activeLead, setActiveLead] = useState['leads'][number] | null>(null); + // Mode toggle: demo (mock simulator) vs live (WebSocket) + const [mode, setMode] = useState('demo'); + + // --- Demo mode state --- + const [localCallState, setLocalCallState] = useState('idle'); + const [localActiveLead, setLocalActiveLead] = useState['leads'][number] | null>(null); const [completedDisposition, setCompletedDisposition] = useState(null); const callStartRef = useRef(null); const ringingTimerRef = useRef | null>(null); const completedTimerRef = useRef | null>(null); + // --- Live mode state (WebSocket) --- + const { callState: liveCallState, activeLead: liveLead, isConnected, sendDisposition } = useCallEvents(user.name); + + // Effective state based on mode + const effectiveCallState = mode === 'live' ? liveCallState : localCallState; + const todaysCalls = calls.filter( (c) => c.agentName === user.name && c.startedAt !== null && isToday(c.startedAt), ); + // Demo mode: simulate a call const handleSimulateCall = useCallback(() => { - if (callState !== 'idle') return; + if (localCallState !== 'idle') return; // Prefer leads with aiSummary, fall back to any lead const leadsWithAi = leads.filter((l) => l.aiSummary !== null); @@ -55,19 +69,20 @@ export const CallDeskPage = () => { if (pool.length === 0) return; const randomLead = pool[Math.floor(Math.random() * pool.length)]; - setActiveLead(randomLead); - setCallState('ringing'); + setLocalActiveLead(randomLead); + setLocalCallState('ringing'); setCompletedDisposition(null); ringingTimerRef.current = setTimeout(() => { - setCallState('active'); + setLocalCallState('active'); callStartRef.current = new Date(); }, 1500); - }, [callState, leads]); + }, [localCallState, leads]); - const handleDisposition = useCallback( + // Demo mode: log disposition locally + const handleDemoDisposition = useCallback( (disposition: CallDisposition, notes: string) => { - if (activeLead === null) return; + if (localActiveLead === null) return; const now = new Date(); const startedAt = callStartRef.current ?? now; @@ -78,7 +93,7 @@ export const CallDeskPage = () => { createdAt: startedAt.toISOString(), callDirection: 'INBOUND', callStatus: 'COMPLETED', - callerNumber: activeLead.contactPhone, + callerNumber: localActiveLead.contactPhone, agentName: user.name, startedAt: startedAt.toISOString(), endedAt: now.toISOString(), @@ -88,54 +103,150 @@ export const CallDeskPage = () => { callNotes: notes || null, patientId: null, appointmentId: null, - leadId: activeLead.id, + leadId: localActiveLead.id, leadName: - `${activeLead.contactName?.firstName ?? ''} ${activeLead.contactName?.lastName ?? ''}`.trim() || + `${localActiveLead.contactName?.firstName ?? ''} ${localActiveLead.contactName?.lastName ?? ''}`.trim() || 'Unknown', - leadPhone: activeLead.contactPhone?.[0]?.number ?? undefined, - leadService: activeLead.interestedService ?? undefined, + leadPhone: localActiveLead.contactPhone?.[0]?.number ?? undefined, + leadService: localActiveLead.interestedService ?? undefined, }; addCall(newCall); const newStatus = dispositionToStatus[disposition]; if (newStatus !== undefined) { - updateLead(activeLead.id, { + updateLead(localActiveLead.id, { leadStatus: newStatus, lastContactedAt: now.toISOString(), - contactAttempts: (activeLead.contactAttempts ?? 0) + 1, + contactAttempts: (localActiveLead.contactAttempts ?? 0) + 1, }); } setCompletedDisposition(disposition); - setCallState('completed'); + setLocalCallState('completed'); completedTimerRef.current = setTimeout(() => { - setCallState('idle'); - setActiveLead(null); + setLocalCallState('idle'); + setLocalActiveLead(null); setCompletedDisposition(null); }, 3000); }, - [activeLead, user.name, addCall, updateLead], + [localActiveLead, user.name, addCall, updateLead], ); + // Route disposition to the correct handler based on mode + const handleDisposition = useCallback( + (disposition: CallDisposition, notes: string) => { + if (mode === 'live') { + sendDisposition(disposition, notes); + } else { + handleDemoDisposition(disposition, notes); + } + }, + [mode, sendDisposition, handleDemoDisposition], + ); + + // Build the lead shape for IncomingCallCard — live leads come in EnrichedLead form, map to Lead + const displayLead: Lead | null = (() => { + if (mode === 'live' && liveLead !== null) { + const mapped: Lead = { + id: liveLead.id, + createdAt: liveLead.age ? new Date(Date.now() - liveLead.age * 24 * 60 * 60 * 1000).toISOString() : null, + updatedAt: null, + leadSource: (liveLead.source as Lead['leadSource']) ?? null, + leadStatus: (liveLead.status as Lead['leadStatus']) ?? null, + priority: null, + contactName: { firstName: liveLead.firstName, lastName: liveLead.lastName }, + contactPhone: liveLead.phone ? [{ number: liveLead.phone, callingCode: '' }] : null, + contactEmail: liveLead.email ? [{ address: liveLead.email }] : null, + interestedService: liveLead.interestedService ?? null, + assignedAgent: null, + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmContent: null, + utmTerm: null, + landingPageUrl: null, + referrerUrl: null, + leadScore: null, + spamScore: null, + isSpam: null, + isDuplicate: null, + duplicateOfLeadId: null, + firstContactedAt: null, + lastContactedAt: null, + contactAttempts: null, + convertedAt: null, + aiSummary: liveLead.aiSummary ?? null, + aiSuggestedAction: liveLead.aiSuggestedAction ?? null, + patientId: null, + campaignId: null, + adId: null, + }; + return mapped; + } + return localActiveLead; + })(); + return (
- + {/* Mode toggle bar */} +
+
+ + +
+ + {mode === 'live' && ( + + {isConnected ? 'Connected to call center' : 'Connecting...'} + + )} +
+ + {/* Demo mode simulator button */} + {mode === 'demo' && ( + + )} +