From 26b9d93f32e13dbc9c44c3678b2900a44de2d230 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Thu, 19 Mar 2026 21:29:26 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20outbound=20call=20UI=20=E2=80=94=20imme?= =?UTF-8?q?diate=20call=20card,=20auto-answer=20SIP=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ClickToCallButton sets callState='ringing-out' immediately on click - ActiveCallCard shows "Calling..." state for outbound - SIP manager auto-answers incoming SIP when outbound is pending (Kookoo bridge) - CallPrepCard shows lead context while dialing - On error, resets state cleanly Flow: Click Call → UI shows call card → Kookoo dials customer → customer answers → SIP bridges → auto-answer → active call → disposition Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/call-desk/active-call-card.tsx | 28 ++++++++++++++++++- .../call-desk/click-to-call-button.tsx | 20 +++++++++++-- src/state/sip-manager.ts | 21 ++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx index b0d1b86..7da9236 100644 --- a/src/components/call-desk/active-call-card.tsx +++ b/src/components/call-desk/active-call-card.tsx @@ -105,7 +105,33 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => { setCallerNumber(null); }; - // Ringing state + // Outbound ringing — agent initiated the call + if (callState === 'ringing-out') { + return ( +
+
+
+
+
+ +
+
+
+

Calling...

+

{fullName || phoneDisplay}

+ {fullName &&

{phoneDisplay}

} +
+
+
+ +
+
+ ); + } + + // Inbound ringing if (callState === 'ringing-in') { return (
diff --git a/src/components/call-desk/click-to-call-button.tsx b/src/components/call-desk/click-to-call-button.tsx index 0532a6f..7be395f 100644 --- a/src/components/call-desk/click-to-call-button.tsx +++ b/src/components/call-desk/click-to-call-button.tsx @@ -1,7 +1,10 @@ import { useState } from 'react'; import { Phone01 } from '@untitledui/icons'; +import { useSetAtom } from 'jotai'; import { Button } from '@/components/base/buttons/button'; import { useSip } from '@/providers/sip-provider'; +import { sipCallStateAtom, sipCallerNumberAtom } from '@/state/sip-state'; +import { setOutboundPending } from '@/state/sip-manager'; import { apiClient } from '@/lib/api-client'; import { notify } from '@/lib/toast'; @@ -15,14 +18,25 @@ interface ClickToCallButtonProps { export const ClickToCallButton = ({ phoneNumber, leadId, label, size = 'sm' }: ClickToCallButtonProps) => { const { isRegistered, isInCall } = useSip(); const [dialing, setDialing] = useState(false); + const setCallState = useSetAtom(sipCallStateAtom); + const setCallerNumber = useSetAtom(sipCallerNumberAtom); const handleDial = async () => { setDialing(true); + + // Immediately show the call UI and mark outbound pending for auto-answer + setCallState('ringing-out'); + setCallerNumber(phoneNumber); + setOutboundPending(true); + try { await apiClient.post('/api/ozonetel/dial', { phoneNumber, leadId }); - notify.success('Dialing', `Calling ${phoneNumber}...`); } catch { - // apiClient.post already toasts the error + // API error — reset call state + setCallState('idle'); + setCallerNumber(null); + setOutboundPending(false); + notify.error('Dial Failed', 'Could not place the call'); } finally { setDialing(false); } @@ -37,7 +51,7 @@ export const ClickToCallButton = ({ phoneNumber, leadId, label, size = 'sm' }: C isDisabled={!isRegistered || isInCall || !phoneNumber || dialing} isLoading={dialing} > - {dialing ? 'Dialing...' : (label ?? 'Call')} + {label ?? 'Call'} ); }; diff --git a/src/state/sip-manager.ts b/src/state/sip-manager.ts index a38b145..e3f3214 100644 --- a/src/state/sip-manager.ts +++ b/src/state/sip-manager.ts @@ -4,6 +4,7 @@ import type { SIPConfig, ConnectionStatus, CallState } from '@/types/sip'; // Singleton SIP client — survives React StrictMode remounts let sipClient: SIPClient | null = null; let connected = false; +let outboundPending = false; type StateUpdater = { setConnectionStatus: (status: ConnectionStatus) => void; @@ -17,6 +18,14 @@ export function registerSipStateUpdater(updater: StateUpdater) { stateUpdater = updater; } +export function setOutboundPending(pending: boolean) { + outboundPending = pending; +} + +export function isOutboundPending(): boolean { + return outboundPending; +} + export function connectSip(config: SIPConfig): void { if (connected || sipClient?.isRegistered() || sipClient?.isConnected()) { return; @@ -38,6 +47,17 @@ export function connectSip(config: SIPConfig): void { config, (status) => stateUpdater?.setConnectionStatus(status), (state, number) => { + // Auto-answer SIP when it's a bridge from our outbound Kookoo call + if (state === 'ringing-in' && outboundPending) { + outboundPending = false; + // Auto-answer after a brief delay to let SIP negotiate + setTimeout(() => { + sipClient?.answer(); + stateUpdater?.setCallState('active'); + }, 500); + return; + } + stateUpdater?.setCallState(state); if (number !== undefined) stateUpdater?.setCallerNumber(number ?? null); }, @@ -50,6 +70,7 @@ export function disconnectSip(): void { sipClient?.disconnect(); sipClient = null; connected = false; + outboundPending = false; stateUpdater?.setConnectionStatus('disconnected'); }