feat: outbound call UI — immediate call card, auto-answer SIP bridge

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 21:29:26 +05:30
parent 1d395a8c36
commit 26b9d93f32
3 changed files with 65 additions and 4 deletions

View File

@@ -105,7 +105,33 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
setCallerNumber(null); setCallerNumber(null);
}; };
// Ringing state // Outbound ringing — agent initiated the call
if (callState === 'ringing-out') {
return (
<div className="rounded-xl bg-brand-primary p-4">
<div className="flex items-center gap-3">
<div className="relative">
<div className="absolute inset-0 animate-pulse rounded-full bg-brand-solid opacity-20" />
<div className="relative flex size-10 items-center justify-center rounded-full bg-brand-solid">
<FontAwesomeIcon icon={faPhone} className="size-4 text-white" />
</div>
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold uppercase tracking-wider text-brand-secondary">Calling...</p>
<p className="text-lg font-bold text-primary">{fullName || phoneDisplay}</p>
{fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>}
</div>
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>
Cancel
</Button>
</div>
</div>
);
}
// Inbound ringing
if (callState === 'ringing-in') { if (callState === 'ringing-in') {
return ( return (
<div className="rounded-xl bg-brand-primary p-4"> <div className="rounded-xl bg-brand-primary p-4">

View File

@@ -1,7 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { Phone01 } from '@untitledui/icons'; import { Phone01 } from '@untitledui/icons';
import { useSetAtom } from 'jotai';
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { useSip } from '@/providers/sip-provider'; 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 { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
@@ -15,14 +18,25 @@ interface ClickToCallButtonProps {
export const ClickToCallButton = ({ phoneNumber, leadId, label, size = 'sm' }: ClickToCallButtonProps) => { export const ClickToCallButton = ({ phoneNumber, leadId, label, size = 'sm' }: ClickToCallButtonProps) => {
const { isRegistered, isInCall } = useSip(); const { isRegistered, isInCall } = useSip();
const [dialing, setDialing] = useState(false); const [dialing, setDialing] = useState(false);
const setCallState = useSetAtom(sipCallStateAtom);
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const handleDial = async () => { const handleDial = async () => {
setDialing(true); setDialing(true);
// Immediately show the call UI and mark outbound pending for auto-answer
setCallState('ringing-out');
setCallerNumber(phoneNumber);
setOutboundPending(true);
try { try {
await apiClient.post('/api/ozonetel/dial', { phoneNumber, leadId }); await apiClient.post('/api/ozonetel/dial', { phoneNumber, leadId });
notify.success('Dialing', `Calling ${phoneNumber}...`);
} catch { } 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 { } finally {
setDialing(false); setDialing(false);
} }
@@ -37,7 +51,7 @@ export const ClickToCallButton = ({ phoneNumber, leadId, label, size = 'sm' }: C
isDisabled={!isRegistered || isInCall || !phoneNumber || dialing} isDisabled={!isRegistered || isInCall || !phoneNumber || dialing}
isLoading={dialing} isLoading={dialing}
> >
{dialing ? 'Dialing...' : (label ?? 'Call')} {label ?? 'Call'}
</Button> </Button>
); );
}; };

View File

@@ -4,6 +4,7 @@ import type { SIPConfig, ConnectionStatus, CallState } from '@/types/sip';
// Singleton SIP client — survives React StrictMode remounts // Singleton SIP client — survives React StrictMode remounts
let sipClient: SIPClient | null = null; let sipClient: SIPClient | null = null;
let connected = false; let connected = false;
let outboundPending = false;
type StateUpdater = { type StateUpdater = {
setConnectionStatus: (status: ConnectionStatus) => void; setConnectionStatus: (status: ConnectionStatus) => void;
@@ -17,6 +18,14 @@ export function registerSipStateUpdater(updater: StateUpdater) {
stateUpdater = updater; stateUpdater = updater;
} }
export function setOutboundPending(pending: boolean) {
outboundPending = pending;
}
export function isOutboundPending(): boolean {
return outboundPending;
}
export function connectSip(config: SIPConfig): void { export function connectSip(config: SIPConfig): void {
if (connected || sipClient?.isRegistered() || sipClient?.isConnected()) { if (connected || sipClient?.isRegistered() || sipClient?.isConnected()) {
return; return;
@@ -38,6 +47,17 @@ export function connectSip(config: SIPConfig): void {
config, config,
(status) => stateUpdater?.setConnectionStatus(status), (status) => stateUpdater?.setConnectionStatus(status),
(state, number) => { (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); stateUpdater?.setCallState(state);
if (number !== undefined) stateUpdater?.setCallerNumber(number ?? null); if (number !== undefined) stateUpdater?.setCallerNumber(number ?? null);
}, },
@@ -50,6 +70,7 @@ export function disconnectSip(): void {
sipClient?.disconnect(); sipClient?.disconnect();
sipClient = null; sipClient = null;
connected = false; connected = false;
outboundPending = false;
stateUpdater?.setConnectionStatus('disconnected'); stateUpdater?.setConnectionStatus('disconnected');
} }