mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: add floating call widget with SIP controls, click-to-call, and SIP provider
Introduce a shared SipProvider context so all components use the same SIP connection. Add a floating CallWidget (idle pill, ringing, active with disposition, ended states) visible for CC agents on every page. Add a ClickToCallButton for the worklist. Wire SIP status badge and worklist into the call-desk page. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
327
src/components/call-desk/call-widget.tsx
Normal file
327
src/components/call-desk/call-widget.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Phone01,
|
||||||
|
PhoneIncoming01,
|
||||||
|
PhoneOutgoing01,
|
||||||
|
PhoneHangUp,
|
||||||
|
PhoneX,
|
||||||
|
MicrophoneOff01,
|
||||||
|
Microphone01,
|
||||||
|
PauseCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Save01,
|
||||||
|
} from '@untitledui/icons';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
|
import { useSip } from '@/providers/sip-provider';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
import type { CallDisposition } from '@/types/entities';
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number): string => {
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0');
|
||||||
|
const s = (seconds % 60).toString().padStart(2, '0');
|
||||||
|
return `${m}:${s}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusDotColor: Record<string, string> = {
|
||||||
|
registered: 'bg-success-500',
|
||||||
|
connecting: 'bg-warning-500',
|
||||||
|
disconnected: 'bg-quaternary',
|
||||||
|
error: 'bg-error-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
registered: 'Ready',
|
||||||
|
connecting: 'Connecting...',
|
||||||
|
disconnected: 'Offline',
|
||||||
|
error: 'Error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispositionOptions: Array<{
|
||||||
|
value: CallDisposition;
|
||||||
|
label: string;
|
||||||
|
activeClass: string;
|
||||||
|
defaultClass: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
value: 'APPOINTMENT_BOOKED',
|
||||||
|
label: 'Appt Booked',
|
||||||
|
activeClass: 'bg-success-solid text-white ring-transparent',
|
||||||
|
defaultClass: 'bg-success-primary text-success-primary border-success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'FOLLOW_UP_SCHEDULED',
|
||||||
|
label: 'Follow-up',
|
||||||
|
activeClass: 'bg-brand-solid text-white ring-transparent',
|
||||||
|
defaultClass: 'bg-brand-primary text-brand-secondary border-brand',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'INFO_PROVIDED',
|
||||||
|
label: 'Info Given',
|
||||||
|
activeClass: 'bg-utility-blue-light-600 text-white ring-transparent',
|
||||||
|
defaultClass: 'bg-utility-blue-light-50 text-utility-blue-light-700 border-utility-blue-light-200',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'NO_ANSWER',
|
||||||
|
label: 'No Answer',
|
||||||
|
activeClass: 'bg-warning-solid text-white ring-transparent',
|
||||||
|
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'WRONG_NUMBER',
|
||||||
|
label: 'Wrong #',
|
||||||
|
activeClass: 'bg-secondary-solid text-white ring-transparent',
|
||||||
|
defaultClass: 'bg-secondary text-secondary border-secondary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'CALLBACK_REQUESTED',
|
||||||
|
label: 'Not Interested',
|
||||||
|
activeClass: 'bg-error-solid text-white ring-transparent',
|
||||||
|
defaultClass: 'bg-error-primary text-error-primary border-error',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CallWidget = () => {
|
||||||
|
const {
|
||||||
|
connectionStatus,
|
||||||
|
callState,
|
||||||
|
callerNumber,
|
||||||
|
isMuted,
|
||||||
|
isOnHold,
|
||||||
|
callDuration,
|
||||||
|
answer,
|
||||||
|
hangup,
|
||||||
|
toggleMute,
|
||||||
|
toggleHold,
|
||||||
|
} = useSip();
|
||||||
|
|
||||||
|
const [disposition, setDisposition] = useState<CallDisposition | null>(null);
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [lastDuration, setLastDuration] = useState(0);
|
||||||
|
|
||||||
|
// Capture duration right before call ends so we can display it in the ended state
|
||||||
|
useEffect(() => {
|
||||||
|
if (callState === 'active' && callDuration > 0) {
|
||||||
|
setLastDuration(callDuration);
|
||||||
|
}
|
||||||
|
}, [callState, callDuration]);
|
||||||
|
|
||||||
|
// Reset disposition/notes when returning to idle
|
||||||
|
useEffect(() => {
|
||||||
|
if (callState === 'idle') {
|
||||||
|
setDisposition(null);
|
||||||
|
setNotes('');
|
||||||
|
}
|
||||||
|
}, [callState]);
|
||||||
|
|
||||||
|
const handleSaveAndClose = () => {
|
||||||
|
// TODO: Wire to BFF to create Call record + update Lead
|
||||||
|
hangup();
|
||||||
|
setDisposition(null);
|
||||||
|
setNotes('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const dotColor = statusDotColor[connectionStatus] ?? 'bg-quaternary';
|
||||||
|
const label = statusLabel[connectionStatus] ?? connectionStatus;
|
||||||
|
|
||||||
|
// Idle: collapsed pill
|
||||||
|
if (callState === 'idle') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'fixed bottom-6 right-6 z-50',
|
||||||
|
'inline-flex items-center gap-2 rounded-full border border-secondary bg-primary px-4 py-2.5 shadow-lg',
|
||||||
|
'transition-all duration-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cx('size-2.5 shrink-0 rounded-full', dotColor)} />
|
||||||
|
<span className="text-sm font-semibold text-secondary">{label}</span>
|
||||||
|
<span className="text-sm text-tertiary">Helix Phone</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ringing inbound
|
||||||
|
if (callState === 'ringing-in') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'fixed bottom-6 right-6 z-50 w-80',
|
||||||
|
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
|
||||||
|
'transition-all duration-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 animate-ping rounded-full bg-brand-solid opacity-20" />
|
||||||
|
<div className="relative animate-bounce">
|
||||||
|
<PhoneIncoming01 className="size-10 text-fg-brand-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
|
||||||
|
Incoming Call
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button size="md" color="primary" iconLeading={Phone01} onClick={answer}>
|
||||||
|
Answer
|
||||||
|
</Button>
|
||||||
|
<Button size="md" color="primary-destructive" iconLeading={PhoneX} onClick={hangup}>
|
||||||
|
Decline
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ringing outbound
|
||||||
|
if (callState === 'ringing-out') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'fixed bottom-6 right-6 z-50 w-80',
|
||||||
|
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
|
||||||
|
'transition-all duration-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PhoneOutgoing01 className="size-10 animate-pulse text-fg-brand-primary" />
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
|
||||||
|
Calling...
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="md" color="primary-destructive" iconLeading={PhoneHangUp} onClick={hangup}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active call (full widget)
|
||||||
|
if (callState === 'active') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'fixed bottom-6 right-6 z-50 w-80',
|
||||||
|
'flex flex-col gap-4 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
|
||||||
|
'transition-all duration-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Phone01 className="size-4 text-fg-success-primary" />
|
||||||
|
<span className="text-sm font-semibold text-primary">Active Call</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-sm font-bold tabular-nums text-brand-secondary">
|
||||||
|
{formatDuration(callDuration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Caller number */}
|
||||||
|
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
|
||||||
|
|
||||||
|
{/* Call controls */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color={isMuted ? 'primary' : 'secondary'}
|
||||||
|
iconLeading={isMuted ? MicrophoneOff01 : Microphone01}
|
||||||
|
onClick={toggleMute}
|
||||||
|
>
|
||||||
|
{isMuted ? 'Unmute' : 'Mute'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color={isOnHold ? 'primary' : 'secondary'}
|
||||||
|
iconLeading={PauseCircle}
|
||||||
|
onClick={toggleHold}
|
||||||
|
>
|
||||||
|
{isOnHold ? 'Resume' : 'Hold'}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" color="primary-destructive" iconLeading={PhoneHangUp} onClick={hangup}>
|
||||||
|
End
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-secondary" />
|
||||||
|
|
||||||
|
{/* Disposition */}
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-secondary">Disposition</span>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{dispositionOptions.map((opt) => {
|
||||||
|
const isSelected = disposition === opt.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDisposition(opt.value)}
|
||||||
|
className={cx(
|
||||||
|
'cursor-pointer rounded-lg border px-2.5 py-1.5 text-xs font-semibold transition duration-100 ease-linear',
|
||||||
|
isSelected ? cx(opt.activeClass, 'ring-2 ring-brand') : opt.defaultClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextArea
|
||||||
|
placeholder="Add notes..."
|
||||||
|
value={notes}
|
||||||
|
onChange={(value) => setNotes(value)}
|
||||||
|
rows={2}
|
||||||
|
textAreaClassName="text-xs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
iconLeading={Save01}
|
||||||
|
isDisabled={disposition === null}
|
||||||
|
onClick={handleSaveAndClose}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Save & Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ended / Failed
|
||||||
|
if (callState === 'ended' || callState === 'failed') {
|
||||||
|
const isEnded = callState === 'ended';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'fixed bottom-6 right-6 z-50 w-80',
|
||||||
|
'flex flex-col items-center gap-2 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
|
||||||
|
'transition-all duration-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckCircle
|
||||||
|
className={cx('size-8', isEnded ? 'text-fg-success-primary' : 'text-fg-error-primary')}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-semibold text-primary">
|
||||||
|
{isEnded ? 'Call Ended' : 'Call Failed'}
|
||||||
|
{lastDuration > 0 && ` \u00B7 ${formatDuration(lastDuration)}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-tertiary">auto-closing...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
25
src/components/call-desk/click-to-call-button.tsx
Normal file
25
src/components/call-desk/click-to-call-button.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Phone01 } from '@untitledui/icons';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { useSip } from '@/providers/sip-provider';
|
||||||
|
|
||||||
|
interface ClickToCallButtonProps {
|
||||||
|
phoneNumber: string;
|
||||||
|
label?: string;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClickToCallButton = ({ phoneNumber, label, size = 'sm' }: ClickToCallButtonProps) => {
|
||||||
|
const { makeCall, isRegistered, isInCall } = useSip();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
color="primary"
|
||||||
|
iconLeading={Phone01}
|
||||||
|
onClick={() => makeCall(phoneNumber)}
|
||||||
|
isDisabled={!isRegistered || isInCall || phoneNumber === ''}
|
||||||
|
>
|
||||||
|
{label ?? 'Call'}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from 'react';
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from 'react-router';
|
||||||
import { Sidebar } from "./sidebar";
|
import { Sidebar } from './sidebar';
|
||||||
|
import { SipProvider } from '@/providers/sip-provider';
|
||||||
|
import { CallWidget } from '@/components/call-desk/call-widget';
|
||||||
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -8,11 +11,15 @@ interface AppShellProps {
|
|||||||
|
|
||||||
export const AppShell = ({ children }: AppShellProps) => {
|
export const AppShell = ({ children }: AppShellProps) => {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
const { isCCAgent } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-primary">
|
<SipProvider>
|
||||||
<Sidebar activeUrl={pathname} />
|
<div className="flex min-h-screen bg-primary">
|
||||||
<main className="flex flex-1 flex-col overflow-auto">{children}</main>
|
<Sidebar activeUrl={pathname} />
|
||||||
</div>
|
<main className="flex flex-1 flex-col overflow-auto">{children}</main>
|
||||||
|
{isCCAgent && <CallWidget />}
|
||||||
|
</div>
|
||||||
|
</SipProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import { useState, useCallback, useRef } from 'react';
|
|||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
import { CallSimulator } from '@/components/call-desk/call-simulator';
|
import { CallSimulator } from '@/components/call-desk/call-simulator';
|
||||||
import { IncomingCallCard } from '@/components/call-desk/incoming-call-card';
|
import { IncomingCallCard } from '@/components/call-desk/incoming-call-card';
|
||||||
|
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||||
import { CallLog } from '@/components/call-desk/call-log';
|
import { CallLog } from '@/components/call-desk/call-log';
|
||||||
import { DailyStats } from '@/components/call-desk/daily-stats';
|
import { DailyStats } from '@/components/call-desk/daily-stats';
|
||||||
import { useAuth } from '@/providers/auth-provider';
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
import { useData } from '@/providers/data-provider';
|
import { useData } from '@/providers/data-provider';
|
||||||
import { useLeads } from '@/hooks/use-leads';
|
import { useLeads } from '@/hooks/use-leads';
|
||||||
import { useCallEvents } from '@/hooks/use-call-events';
|
import { useCallEvents } from '@/hooks/use-call-events';
|
||||||
|
import { useSip } from '@/providers/sip-provider';
|
||||||
import { BadgeWithDot } from '@/components/base/badges/badges';
|
import { BadgeWithDot } from '@/components/base/badges/badges';
|
||||||
|
import { formatPhone } from '@/lib/format';
|
||||||
import type { Call, CallDisposition, Lead, LeadStatus } from '@/types/entities';
|
import type { Call, CallDisposition, Lead, LeadStatus } from '@/types/entities';
|
||||||
|
|
||||||
type CallState = 'idle' | 'ringing' | 'active' | 'completed';
|
type CallState = 'idle' | 'ringing' | 'active' | 'completed';
|
||||||
@@ -49,6 +52,9 @@ export const CallDeskPage = () => {
|
|||||||
const ringingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const ringingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const completedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const completedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// --- SIP phone state ---
|
||||||
|
const { connectionStatus, isRegistered } = useSip();
|
||||||
|
|
||||||
// --- Live mode state (WebSocket) ---
|
// --- Live mode state (WebSocket) ---
|
||||||
const { callState: liveCallState, activeLead: liveLead, isConnected, sendDisposition } = useCallEvents(user.name);
|
const { callState: liveCallState, activeLead: liveLead, isConnected, sendDisposition } = useCallEvents(user.name);
|
||||||
|
|
||||||
@@ -221,15 +227,25 @@ export const CallDeskPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === 'live' && (
|
<div className="flex items-center gap-2">
|
||||||
<BadgeWithDot
|
<BadgeWithDot
|
||||||
color={isConnected ? 'success' : 'gray'}
|
color={isRegistered ? 'success' : connectionStatus === 'connecting' ? 'warning' : 'gray'}
|
||||||
size="md"
|
size="md"
|
||||||
type="pill-color"
|
type="pill-color"
|
||||||
>
|
>
|
||||||
{isConnected ? 'Connected to call center' : 'Connecting...'}
|
{isRegistered ? 'Phone Ready' : `Phone: ${connectionStatus}`}
|
||||||
</BadgeWithDot>
|
</BadgeWithDot>
|
||||||
)}
|
|
||||||
|
{mode === 'live' && (
|
||||||
|
<BadgeWithDot
|
||||||
|
color={isConnected ? 'success' : 'gray'}
|
||||||
|
size="md"
|
||||||
|
type="pill-color"
|
||||||
|
>
|
||||||
|
{isConnected ? 'Connected to call center' : 'Connecting...'}
|
||||||
|
</BadgeWithDot>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Demo mode simulator button */}
|
{/* Demo mode simulator button */}
|
||||||
@@ -240,6 +256,48 @@ export const CallDeskPage = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Worklist: leads with click-to-call */}
|
||||||
|
{mode === 'live' && leads.length > 0 && (
|
||||||
|
<div className="rounded-2xl border border-secondary bg-primary">
|
||||||
|
<div className="border-b border-secondary px-5 py-3">
|
||||||
|
<h3 className="text-sm font-bold text-primary">Worklist</h3>
|
||||||
|
<p className="text-xs text-tertiary">Click to start an outbound call</p>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-secondary">
|
||||||
|
{leads.slice(0, 10).map((lead) => {
|
||||||
|
const firstName = lead.contactName?.firstName ?? '';
|
||||||
|
const lastName = lead.contactName?.lastName ?? '';
|
||||||
|
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
|
||||||
|
const phone = lead.contactPhone?.[0];
|
||||||
|
const phoneDisplay = phone ? formatPhone(phone) : 'No phone';
|
||||||
|
const phoneNumber = phone?.number ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={lead.id}
|
||||||
|
className="flex items-center justify-between gap-3 px-5 py-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="text-sm font-semibold text-primary">
|
||||||
|
{fullName}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-sm text-tertiary">
|
||||||
|
{phoneDisplay}
|
||||||
|
</span>
|
||||||
|
{lead.interestedService !== null && (
|
||||||
|
<span className="ml-2 text-xs text-quaternary">
|
||||||
|
{lead.interestedService}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ClickToCallButton phoneNumber={phoneNumber} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<IncomingCallCard
|
<IncomingCallCard
|
||||||
callState={effectiveCallState}
|
callState={effectiveCallState}
|
||||||
lead={displayLead}
|
lead={displayLead}
|
||||||
|
|||||||
26
src/providers/sip-provider.tsx
Normal file
26
src/providers/sip-provider.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { createContext, useContext, useEffect, type PropsWithChildren } from 'react';
|
||||||
|
import { useSipPhone } from '@/hooks/use-sip-phone';
|
||||||
|
|
||||||
|
type SipContextType = ReturnType<typeof useSipPhone>;
|
||||||
|
|
||||||
|
const SipContext = createContext<SipContextType | null>(null);
|
||||||
|
|
||||||
|
export const SipProvider = ({ children }: PropsWithChildren) => {
|
||||||
|
const sipPhone = useSipPhone();
|
||||||
|
|
||||||
|
// Auto-connect on mount
|
||||||
|
useEffect(() => {
|
||||||
|
sipPhone.connect();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <SipContext.Provider value={sipPhone}>{children}</SipContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSip = (): SipContextType => {
|
||||||
|
const ctx = useContext(SipContext);
|
||||||
|
if (ctx === null) {
|
||||||
|
throw new Error('useSip must be used within a SipProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user