feat: add Socket.IO client and useCallEvents hook for live CTI mode

This commit is contained in:
2026-03-17 09:14:25 +05:30
parent 45bae9c1c0
commit 4f9bdc7312
5 changed files with 381 additions and 30 deletions

View File

@@ -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<CallState>('idle');
const [activeLead, setActiveLead] = useState<EnrichedLead | null>(null);
const [activeCallSid, setActiveCallSid] = useState<string | null>(null);
const [callStartTime, setCallStartTime] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false);
const completedTimerRef = useRef<number | null>(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,
};
};

22
src/lib/socket.ts Normal file
View File

@@ -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;
}
};

View File

@@ -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<CallState>('idle');
const [activeLead, setActiveLead] = useState<ReturnType<typeof useLeads>['leads'][number] | null>(null);
// Mode toggle: demo (mock simulator) vs live (WebSocket)
const [mode, setMode] = useState<Mode>('demo');
// --- Demo mode state ---
const [localCallState, setLocalCallState] = useState<CallState>('idle');
const [localActiveLead, setLocalActiveLead] = useState<ReturnType<typeof useLeads>['leads'][number] | null>(null);
const [completedDisposition, setCompletedDisposition] = useState<CallDisposition | null>(null);
const callStartRef = useRef<Date | null>(null);
const ringingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const completedTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="flex flex-1 flex-col">
<TopBar title="Call Desk" subtitle={`${user.name} \u00B7 Global Hospital`} />
<div className="flex flex-1 overflow-hidden">
<div className="flex-1 space-y-5 overflow-y-auto p-7">
<CallSimulator
onSimulate={handleSimulateCall}
isCallActive={callState !== 'idle'}
/>
{/* Mode toggle bar */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2 rounded-xl border border-secondary bg-primary p-1">
<button
type="button"
onClick={() => setMode('demo')}
className={`rounded-lg px-4 py-1.5 text-sm font-semibold transition duration-100 ease-linear ${
mode === 'demo'
? 'bg-brand-solid text-white'
: 'text-secondary hover:text-primary'
}`}
>
Demo Mode
</button>
<button
type="button"
onClick={() => setMode('live')}
className={`rounded-lg px-4 py-1.5 text-sm font-semibold transition duration-100 ease-linear ${
mode === 'live'
? 'bg-brand-solid text-white'
: 'text-secondary hover:text-primary'
}`}
>
Live Mode
</button>
</div>
{mode === 'live' && (
<BadgeWithDot
color={isConnected ? 'success' : 'gray'}
size="md"
type="pill-color"
>
{isConnected ? 'Connected to call center' : 'Connecting...'}
</BadgeWithDot>
)}
</div>
{/* Demo mode simulator button */}
{mode === 'demo' && (
<CallSimulator
onSimulate={handleSimulateCall}
isCallActive={localCallState !== 'idle'}
/>
)}
<IncomingCallCard
callState={callState}
lead={activeLead}
callState={effectiveCallState}
lead={displayLead}
activities={leadActivities}
campaigns={campaigns}
onDisposition={handleDisposition}
completedDisposition={completedDisposition}
completedDisposition={mode === 'demo' ? completedDisposition : null}
/>
<CallLog calls={todaysCalls} />
</div>