Files
helix-engage/src/components/call-desk/call-widget.tsx

488 lines
19 KiB
TypeScript

import { useState, useEffect, useRef } 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 { useAuth } from '@/providers/auth-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,
reject,
hangup,
toggleMute,
toggleHold,
} = useSip();
const { user } = useAuth();
const [disposition, setDisposition] = useState<CallDisposition | null>(null);
const [notes, setNotes] = useState('');
const [lastDuration, setLastDuration] = useState(0);
const [matchedLead, setMatchedLead] = useState<any>(null);
const [leadActivities, setLeadActivities] = useState<any[]>([]);
const [isSaving, setIsSaving] = useState(false);
const callStartTimeRef = useRef<string | null>(null);
// Capture duration right before call ends
useEffect(() => {
if (callState === 'active' && callDuration > 0) {
setLastDuration(callDuration);
}
}, [callState, callDuration]);
// Track call start time
useEffect(() => {
if (callState === 'active' && !callStartTimeRef.current) {
callStartTimeRef.current = new Date().toISOString();
}
if (callState === 'idle') {
callStartTimeRef.current = null;
}
}, [callState]);
// Look up caller when call becomes active
useEffect(() => {
if (callState === 'ringing-in' && callerNumber && callerNumber !== 'Unknown') {
const lookup = async () => {
try {
const { apiClient } = await import('@/lib/api-client');
const token = apiClient.getStoredToken();
if (!token) return;
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
const res = await fetch(`${API_URL}/api/call/lookup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ phoneNumber: callerNumber }),
});
const data = await res.json();
if (data.matched && data.lead) {
setMatchedLead(data.lead);
setLeadActivities(data.activities ?? []);
}
} catch (err) {
console.warn('Lead lookup failed:', err);
}
};
lookup();
}
}, [callState, callerNumber]);
// Reset state when returning to idle
useEffect(() => {
if (callState === 'idle') {
setDisposition(null);
setNotes('');
setMatchedLead(null);
setLeadActivities([]);
}
}, [callState]);
const handleSaveAndClose = async () => {
if (!disposition) return;
setIsSaving(true);
try {
const { apiClient } = await import('@/lib/api-client');
// 1. Create Call record on platform
await apiClient.graphql(
`mutation CreateCall($data: CallCreateInput!) {
createCall(data: $data) { id }
}`,
{
data: {
callDirection: 'INBOUND',
callStatus: 'COMPLETED',
agentName: user.name,
startedAt: callStartTimeRef.current,
endedAt: new Date().toISOString(),
durationSeconds: callDuration,
disposition,
callNotes: notes || null,
leadId: matchedLead?.id ?? null,
},
},
).catch(err => console.warn('Failed to create call record:', err));
// 2. Update lead status if matched
if (matchedLead?.id) {
const statusMap: Partial<Record<string, string>> = {
APPOINTMENT_BOOKED: 'APPOINTMENT_SET',
FOLLOW_UP_SCHEDULED: 'CONTACTED',
INFO_PROVIDED: 'CONTACTED',
NO_ANSWER: 'CONTACTED',
WRONG_NUMBER: 'LOST',
CALLBACK_REQUESTED: 'CONTACTED',
NOT_INTERESTED: 'LOST',
};
const newStatus = statusMap[disposition];
if (newStatus) {
await apiClient.graphql(
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
updateLead(id: $id, data: $data) { id }
}`,
{
id: matchedLead.id,
data: {
leadStatus: newStatus,
lastContactedAt: new Date().toISOString(),
},
},
).catch(err => console.warn('Failed to update lead:', err));
}
// 3. Create lead activity
await apiClient.graphql(
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
createLeadActivity(data: $data) { id }
}`,
{
data: {
activityType: 'CALL_RECEIVED',
summary: `Inbound call — ${disposition.replace(/_/g, ' ')}`,
occurredAt: new Date().toISOString(),
performedBy: user.name,
channel: 'PHONE',
durationSeconds: callDuration,
leadId: matchedLead.id,
},
},
).catch(err => console.warn('Failed to create activity:', err));
}
} catch (err) {
console.error('Save failed:', err);
}
setIsSaving(false);
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={reject}>
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 info */}
<div>
<span className="text-lg font-bold text-primary">
{matchedLead?.contactName
? `${matchedLead.contactName.firstName ?? ''} ${matchedLead.contactName.lastName ?? ''}`.trim()
: callerNumber ?? 'Unknown'}
</span>
{matchedLead && (
<span className="ml-2 text-sm text-tertiary">{callerNumber}</span>
)}
</div>
{/* AI Summary */}
{matchedLead?.aiSummary && (
<div className="rounded-xl bg-brand-primary p-3">
<div className="mb-1 text-xs font-bold uppercase tracking-wider text-brand-secondary">AI Insight</div>
<p className="text-sm text-primary">{matchedLead.aiSummary}</p>
{matchedLead.aiSuggestedAction && (
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1 text-xs font-semibold text-white">
{matchedLead.aiSuggestedAction}
</span>
)}
</div>
)}
{/* Recent activity */}
{leadActivities.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-tertiary">Recent Activity</div>
{leadActivities.slice(0, 3).map((a: any, i: number) => (
<div key={i} className="text-xs text-quaternary">
{a.activityType?.replace(/_/g, ' ')}: {a.summary}
</div>
))}
</div>
)}
{/* 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 || isSaving}
isLoading={isSaving}
onClick={handleSaveAndClose}
className="w-full"
>
{isSaving ? 'Saving...' : '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;
};