@campxdev/campx-web-utils 2.0.13 → 2.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/types/src/components/Exotel/CallButton.d.ts +10 -0
- package/dist/cjs/types/src/components/Exotel/CallDispositionForm.d.ts +29 -0
- package/dist/cjs/types/src/components/Exotel/ExotelPhone.d.ts +10 -0
- package/dist/cjs/types/src/components/Exotel/ExotelWrapper.d.ts +14 -0
- package/dist/cjs/types/src/components/Exotel/MicrophonePermission.d.ts +6 -0
- package/dist/cjs/types/src/components/Exotel/index.d.ts +6 -0
- package/dist/cjs/types/src/config/index.d.ts +1 -0
- package/dist/cjs/types/src/config/voip.config.d.ts +18 -0
- package/dist/cjs/types/src/constants/exotel.constants.d.ts +7 -0
- package/dist/cjs/types/src/providers/ExotelProvider.d.ts +79 -0
- package/dist/cjs/types/src/providers/VoIPProvider.d.ts +33 -0
- package/dist/cjs/types/src/providers/index.d.ts +2 -0
- package/dist/cjs/types/src/services/crypto/CryptoService.d.ts +23 -0
- package/dist/cjs/types/src/services/exotel/ExotelService.d.ts +47 -0
- package/dist/cjs/types/src/services/exotel/api.d.ts +158 -0
- package/dist/cjs/types/src/services/exotel/index.d.ts +2 -0
- package/dist/cjs/types/src/utils/exotel/formatters.d.ts +8 -0
- package/dist/cjs/types/src/utils/exotel/index.d.ts +1 -0
- package/dist/esm/index.js +2 -2
- package/dist/esm/types/src/components/Exotel/CallButton.d.ts +10 -0
- package/dist/esm/types/src/components/Exotel/CallDispositionForm.d.ts +29 -0
- package/dist/esm/types/src/components/Exotel/ExotelPhone.d.ts +10 -0
- package/dist/esm/types/src/components/Exotel/ExotelWrapper.d.ts +14 -0
- package/dist/esm/types/src/components/Exotel/MicrophonePermission.d.ts +6 -0
- package/dist/esm/types/src/components/Exotel/index.d.ts +6 -0
- package/dist/esm/types/src/config/index.d.ts +1 -0
- package/dist/esm/types/src/config/voip.config.d.ts +18 -0
- package/dist/esm/types/src/constants/exotel.constants.d.ts +7 -0
- package/dist/esm/types/src/providers/ExotelProvider.d.ts +79 -0
- package/dist/esm/types/src/providers/VoIPProvider.d.ts +33 -0
- package/dist/esm/types/src/providers/index.d.ts +2 -0
- package/dist/esm/types/src/services/crypto/CryptoService.d.ts +23 -0
- package/dist/esm/types/src/services/exotel/ExotelService.d.ts +47 -0
- package/dist/esm/types/src/services/exotel/api.d.ts +158 -0
- package/dist/esm/types/src/services/exotel/index.d.ts +2 -0
- package/dist/esm/types/src/utils/exotel/formatters.d.ts +8 -0
- package/dist/esm/types/src/utils/exotel/index.d.ts +1 -0
- package/dist/index.d.ts +357 -3
- package/dist/styles.css +337 -47
- package/dist/types/exotel-crm-websdk.d.ts +46 -0
- package/export.ts +6 -0
- package/package.json +4 -1
- package/src/components/Exotel/CallButton.tsx +164 -0
- package/src/components/Exotel/CallDispositionForm.tsx +213 -0
- package/src/components/Exotel/ExotelPhone.tsx +482 -0
- package/src/components/Exotel/ExotelWrapper.tsx +80 -0
- package/src/components/Exotel/MicrophonePermission.tsx +97 -0
- package/src/components/Exotel/index.ts +10 -0
- package/src/config/index.ts +1 -0
- package/src/config/voip.config.ts +26 -0
- package/src/constants/exotel.constants.ts +7 -0
- package/src/providers/ExotelProvider.tsx +526 -0
- package/src/providers/VoIPProvider.tsx +143 -0
- package/src/providers/index.ts +2 -0
- package/src/selectors/ResearchStageSelector.tsx +1 -0
- package/src/services/crypto/CryptoService.ts +112 -0
- package/src/services/exotel/ExotelService.ts +238 -0
- package/src/services/exotel/api.ts +319 -0
- package/src/services/exotel/index.ts +2 -0
- package/src/utils/exotel/formatters.ts +17 -0
- package/src/utils/exotel/index.ts +1 -0
- package/types/exotel-crm-websdk.d.ts +46 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
|
|
2
|
+
import React, {
|
|
3
|
+
createContext,
|
|
4
|
+
ReactNode,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react'
|
|
11
|
+
|
|
12
|
+
import ExotelService, {
|
|
13
|
+
PermissionStatus,
|
|
14
|
+
} from '../services/exotel/ExotelService'
|
|
15
|
+
|
|
16
|
+
import { registerIncomingCall } from '../services/exotel/api'
|
|
17
|
+
|
|
18
|
+
// Global context key for cross-module-federation sharing
|
|
19
|
+
const EXOTEL_CONTEXT_KEY = '__EXOTEL_CONTEXT__'
|
|
20
|
+
|
|
21
|
+
// Get or create a shared context across module federation boundaries
|
|
22
|
+
const getSharedContext = () => {
|
|
23
|
+
if (!(window as any)[EXOTEL_CONTEXT_KEY]) {
|
|
24
|
+
;(window as any)[EXOTEL_CONTEXT_KEY] = createContext<
|
|
25
|
+
ExotelContextType | undefined
|
|
26
|
+
>(undefined)
|
|
27
|
+
}
|
|
28
|
+
return (window as any)[EXOTEL_CONTEXT_KEY] as React.Context<
|
|
29
|
+
ExotelContextType | undefined
|
|
30
|
+
>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CallEventData {
|
|
34
|
+
callId: string
|
|
35
|
+
remoteId: string
|
|
36
|
+
remoteDisplayName: string
|
|
37
|
+
callDirection: string
|
|
38
|
+
callState: string
|
|
39
|
+
callDuration: string
|
|
40
|
+
callStartedTime: string
|
|
41
|
+
callEstablishedTime: string
|
|
42
|
+
callEndedTime: string
|
|
43
|
+
callAnswerTime: string
|
|
44
|
+
callEndReason: string
|
|
45
|
+
sessionId: string
|
|
46
|
+
callFromNumber?: string
|
|
47
|
+
status?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type CallPhase =
|
|
51
|
+
| 'idle'
|
|
52
|
+
| 'ready_to_call' // Outbound: User clicked call, waiting to accept
|
|
53
|
+
| 'calling' // Outbound: Connecting to lead
|
|
54
|
+
| 'incoming' // Inbound: Ringing
|
|
55
|
+
| 'connected' // Call is active
|
|
56
|
+
| 'ended' // Call ended, show disposition
|
|
57
|
+
|
|
58
|
+
export interface CallState {
|
|
59
|
+
phase: CallPhase
|
|
60
|
+
callData: CallEventData | null
|
|
61
|
+
isOnHold: boolean
|
|
62
|
+
isMuted: boolean
|
|
63
|
+
isSpeakerOff: boolean
|
|
64
|
+
callDirection: 'outbound' | 'inbound' | null
|
|
65
|
+
callStartTime: number | null // Timestamp when call connected
|
|
66
|
+
callDuration: number // Duration in seconds
|
|
67
|
+
callerName: string | null
|
|
68
|
+
callerNumber: string | null
|
|
69
|
+
callActivityId: string | null // Database ID from backend call activity record
|
|
70
|
+
exotelCallSid: string | null // CallSid from Exotel outbound_call API response
|
|
71
|
+
prospectUniqueId: number | null // Prospect unique ID number
|
|
72
|
+
wasAccepted: boolean // Track if user clicked Accept (for disposition logic)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface CallDispositionData {
|
|
76
|
+
callId: string // Database ID from backend (for API calls)
|
|
77
|
+
exotelCallId: string // Exotel SIP call ID (for reference)
|
|
78
|
+
callDirection: 'outbound' | 'inbound'
|
|
79
|
+
callDuration: number
|
|
80
|
+
callerName: string | null
|
|
81
|
+
callerNumber: string | null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ExotelContextType {
|
|
85
|
+
isInitialized: boolean
|
|
86
|
+
isConnected: boolean
|
|
87
|
+
callState: CallState
|
|
88
|
+
permissionStatus: PermissionStatus
|
|
89
|
+
dispositionData: CallDispositionData | null
|
|
90
|
+
makeCall: (phoneNumber: string, callerName?: string, callActivityId?: string, maskedNumber?: string) => Promise<any>
|
|
91
|
+
acceptCall: () => Promise<void>
|
|
92
|
+
hangupCall: () => void
|
|
93
|
+
rejectCall: () => void // For declining incoming calls before accepting
|
|
94
|
+
toggleHold: () => void
|
|
95
|
+
toggleMute: () => void
|
|
96
|
+
toggleSpeaker: () => void
|
|
97
|
+
sendDTMF: (digit: string) => void
|
|
98
|
+
registerDevice: () => Promise<void>
|
|
99
|
+
unregisterDevice: () => void
|
|
100
|
+
requestMicrophoneAccess: () => Promise<boolean>
|
|
101
|
+
clearDisposition: () => void
|
|
102
|
+
setCallActivityId: (callActivityId: string) => void // Update callActivityId after record creation
|
|
103
|
+
setExotelCallSid: (callSid: string) => void // Update exotelCallSid from SDK response
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ExotelProviderProps {
|
|
107
|
+
children: ReactNode
|
|
108
|
+
accessToken: string
|
|
109
|
+
agentUserId: string
|
|
110
|
+
autoConnectVOIP?: boolean
|
|
111
|
+
callerName?: string
|
|
112
|
+
callerNumber?: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const initialCallState: CallState = {
|
|
116
|
+
phase: 'idle',
|
|
117
|
+
callData: null,
|
|
118
|
+
isOnHold: false,
|
|
119
|
+
isMuted: false,
|
|
120
|
+
isSpeakerOff: false,
|
|
121
|
+
callDirection: null,
|
|
122
|
+
callStartTime: null,
|
|
123
|
+
callDuration: 0,
|
|
124
|
+
callerName: null,
|
|
125
|
+
callerNumber: null,
|
|
126
|
+
callActivityId: null,
|
|
127
|
+
exotelCallSid: null,
|
|
128
|
+
prospectUniqueId: null,
|
|
129
|
+
wasAccepted: false,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Mask mobile number for display (first 2 and last 2 of actual number visible)
|
|
134
|
+
* Handles country code prefix (+91)
|
|
135
|
+
* e.g., "+919876543210" -> "+9198******10"
|
|
136
|
+
* e.g., "9876543210" -> "98******10"
|
|
137
|
+
*/
|
|
138
|
+
const maskMobile = (mobile: string): string => {
|
|
139
|
+
if (!mobile || mobile.length < 4) return '**********'
|
|
140
|
+
|
|
141
|
+
// Check for country code prefix
|
|
142
|
+
let prefix = ''
|
|
143
|
+
let number = mobile
|
|
144
|
+
|
|
145
|
+
if (mobile.startsWith('+91')) {
|
|
146
|
+
prefix = '+91'
|
|
147
|
+
number = mobile.substring(3)
|
|
148
|
+
} else if (mobile.startsWith('91') && mobile.length > 10) {
|
|
149
|
+
prefix = '91'
|
|
150
|
+
number = mobile.substring(2)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const cleaned = number.replace(/\D/g, '')
|
|
154
|
+
if (cleaned.length < 4) return prefix + '**********'
|
|
155
|
+
|
|
156
|
+
return prefix + cleaned.substring(0, 2) + '*'.repeat(cleaned.length - 4) + cleaned.substring(cleaned.length - 2)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Use shared context for cross-module-federation compatibility
|
|
160
|
+
const ExotelContext = getSharedContext()
|
|
161
|
+
|
|
162
|
+
export const ExotelProvider: React.FC<ExotelProviderProps> = ({
|
|
163
|
+
children,
|
|
164
|
+
accessToken,
|
|
165
|
+
agentUserId,
|
|
166
|
+
autoConnectVOIP = true,
|
|
167
|
+
callerName: defaultCallerName,
|
|
168
|
+
callerNumber: defaultCallerNumber,
|
|
169
|
+
}) => {
|
|
170
|
+
const [isInitialized, setIsInitialized] = useState(false)
|
|
171
|
+
const [isConnected, setIsConnected] = useState(false)
|
|
172
|
+
const [permissionStatus, setPermissionStatus] =
|
|
173
|
+
useState<PermissionStatus>('unknown')
|
|
174
|
+
const [callState, setCallState] = useState<CallState>(initialCallState)
|
|
175
|
+
const [dispositionData, setDispositionData] =
|
|
176
|
+
useState<CallDispositionData | null>(null)
|
|
177
|
+
|
|
178
|
+
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
|
179
|
+
const callStateRef = useRef<CallState>(callState)
|
|
180
|
+
const exotelService = ExotelService.getInstance()
|
|
181
|
+
|
|
182
|
+
// Keep ref in sync with state for use in event handlers
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
callStateRef.current = callState
|
|
185
|
+
}, [callState])
|
|
186
|
+
|
|
187
|
+
// Timer for call duration
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (callState.phase === 'connected' && callState.callStartTime) {
|
|
190
|
+
timerRef.current = setInterval(() => {
|
|
191
|
+
setCallState((prev) => ({
|
|
192
|
+
...prev,
|
|
193
|
+
callDuration: Math.floor((Date.now() - prev.callStartTime!) / 1000),
|
|
194
|
+
}))
|
|
195
|
+
}, 1000)
|
|
196
|
+
} else {
|
|
197
|
+
if (timerRef.current) {
|
|
198
|
+
clearInterval(timerRef.current)
|
|
199
|
+
timerRef.current = null
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return () => {
|
|
204
|
+
if (timerRef.current) {
|
|
205
|
+
clearInterval(timerRef.current)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}, [callState.phase, callState.callStartTime])
|
|
209
|
+
|
|
210
|
+
// Request microphone permission before initializing
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
const requestPermission = async () => {
|
|
213
|
+
try {
|
|
214
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
215
|
+
audio: true,
|
|
216
|
+
})
|
|
217
|
+
stream.getTracks().forEach((track) => track.stop())
|
|
218
|
+
setPermissionStatus('granted')
|
|
219
|
+
} catch (error) {
|
|
220
|
+
console.warn('Microphone permission denied:', error)
|
|
221
|
+
setPermissionStatus('denied')
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (navigator.permissions && navigator.permissions.query) {
|
|
226
|
+
navigator.permissions
|
|
227
|
+
.query({ name: 'microphone' as PermissionName })
|
|
228
|
+
.then((status) => {
|
|
229
|
+
setPermissionStatus(status.state as 'granted' | 'denied' | 'prompt')
|
|
230
|
+
|
|
231
|
+
status.onchange = () => {
|
|
232
|
+
setPermissionStatus(status.state as 'granted' | 'denied' | 'prompt')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (status.state === 'prompt') {
|
|
236
|
+
requestPermission()
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
.catch((error) => {
|
|
240
|
+
console.warn('Could not query permission status:', error)
|
|
241
|
+
requestPermission()
|
|
242
|
+
})
|
|
243
|
+
} else {
|
|
244
|
+
requestPermission()
|
|
245
|
+
}
|
|
246
|
+
}, [])
|
|
247
|
+
|
|
248
|
+
// Initialize the SDK
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (!accessToken || !agentUserId || isInitialized) return
|
|
251
|
+
|
|
252
|
+
const initializeExotel = async () => {
|
|
253
|
+
const success = await exotelService.initialize(
|
|
254
|
+
accessToken,
|
|
255
|
+
agentUserId,
|
|
256
|
+
autoConnectVOIP,
|
|
257
|
+
)
|
|
258
|
+
setIsInitialized(success)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
initializeExotel()
|
|
262
|
+
}, [accessToken, agentUserId, autoConnectVOIP])
|
|
263
|
+
|
|
264
|
+
// Setup event listeners
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
if (!isInitialized) return
|
|
267
|
+
|
|
268
|
+
const handleCallEvent = (event: string, data: CallEventData) => {
|
|
269
|
+
switch (event) {
|
|
270
|
+
case 'incoming': {
|
|
271
|
+
// Check current state to determine if this is a genuine incoming call
|
|
272
|
+
// or just the agent callback for an outbound click-to-call
|
|
273
|
+
const isOutboundCall = callStateRef.current.callDirection === 'outbound'
|
|
274
|
+
|
|
275
|
+
if (isOutboundCall) {
|
|
276
|
+
// For outbound calls (click-to-call), Exotel first calls the agent
|
|
277
|
+
// This triggers 'incoming' event but we should NOT overwrite the outbound call state
|
|
278
|
+
setCallState((prev) => ({
|
|
279
|
+
...prev,
|
|
280
|
+
callData: data,
|
|
281
|
+
isOnHold: false,
|
|
282
|
+
isMuted: false,
|
|
283
|
+
}))
|
|
284
|
+
} else {
|
|
285
|
+
// This is a genuine incoming call - update state
|
|
286
|
+
const incomingNumber = data.callFromNumber || data.remoteId || null
|
|
287
|
+
setCallState((prev) => ({
|
|
288
|
+
...prev,
|
|
289
|
+
phase: 'incoming',
|
|
290
|
+
callData: data,
|
|
291
|
+
callDirection: 'inbound',
|
|
292
|
+
callerName: data.remoteDisplayName || null,
|
|
293
|
+
callerNumber: incomingNumber ? maskMobile(incomingNumber) : null,
|
|
294
|
+
isOnHold: false,
|
|
295
|
+
isMuted: false,
|
|
296
|
+
}))
|
|
297
|
+
|
|
298
|
+
// Register incoming call in the backend and get prospect details
|
|
299
|
+
if (data.callId || data.sessionId) {
|
|
300
|
+
registerIncomingCall({
|
|
301
|
+
callSid: data.callId || data.sessionId,
|
|
302
|
+
fromNumber: data.callFromNumber || data.remoteId || '',
|
|
303
|
+
toNumber: data.remoteDisplayName || '',
|
|
304
|
+
})
|
|
305
|
+
.then((response) => {
|
|
306
|
+
// Update call state with prospect details and call activity ID
|
|
307
|
+
const prospectMobile = response.prospect?.mobile
|
|
308
|
+
setCallState((prev) => ({
|
|
309
|
+
...prev,
|
|
310
|
+
callActivityId: response.data?.id || prev.callActivityId,
|
|
311
|
+
callerName: response.prospect?.name || prev.callerName,
|
|
312
|
+
callerNumber: prospectMobile ? maskMobile(prospectMobile) : prev.callerNumber,
|
|
313
|
+
prospectUniqueId: response.prospect?.uniqueId || prev.prospectUniqueId,
|
|
314
|
+
}))
|
|
315
|
+
})
|
|
316
|
+
.catch((err) => {
|
|
317
|
+
console.error('Failed to register incoming call:', err)
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
break
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
case 'connected':
|
|
325
|
+
setCallState((prev) => ({
|
|
326
|
+
...prev,
|
|
327
|
+
phase: 'connected',
|
|
328
|
+
callData: data,
|
|
329
|
+
callStartTime: Date.now(),
|
|
330
|
+
callDuration: 0,
|
|
331
|
+
}))
|
|
332
|
+
break
|
|
333
|
+
|
|
334
|
+
case 'callEnded':
|
|
335
|
+
setCallState((prev) => {
|
|
336
|
+
// Show disposition form if:
|
|
337
|
+
// 1. User accepted the call (wasAccepted = true), OR
|
|
338
|
+
// 2. Call was connected, OR
|
|
339
|
+
// 3. Call had some duration
|
|
340
|
+
// Do NOT show if user declined before accepting
|
|
341
|
+
const shouldShowDisposition = prev.wasAccepted || prev.phase === 'connected' || prev.callDuration > 0
|
|
342
|
+
|
|
343
|
+
if (shouldShowDisposition) {
|
|
344
|
+
const dispositionDataObj = {
|
|
345
|
+
// Use database callActivityId for API calls, fall back to Exotel callId
|
|
346
|
+
callId: prev.callActivityId || data.callId || prev.callData?.callId || '',
|
|
347
|
+
exotelCallId: data.callId || prev.callData?.callId || '',
|
|
348
|
+
callDirection: prev.callDirection || 'outbound',
|
|
349
|
+
callDuration: prev.callDuration,
|
|
350
|
+
callerName: prev.callerName,
|
|
351
|
+
callerNumber: prev.callerNumber,
|
|
352
|
+
}
|
|
353
|
+
setDispositionData(dispositionDataObj)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
...initialCallState,
|
|
358
|
+
phase: 'ended',
|
|
359
|
+
callData: data,
|
|
360
|
+
}
|
|
361
|
+
})
|
|
362
|
+
break
|
|
363
|
+
|
|
364
|
+
case 'holdtoggle':
|
|
365
|
+
setCallState((prev) => ({
|
|
366
|
+
...prev,
|
|
367
|
+
isOnHold: !prev.isOnHold,
|
|
368
|
+
callData: data,
|
|
369
|
+
}))
|
|
370
|
+
break
|
|
371
|
+
|
|
372
|
+
case 'mutetoggle':
|
|
373
|
+
setCallState((prev) => ({
|
|
374
|
+
...prev,
|
|
375
|
+
isMuted: !prev.isMuted,
|
|
376
|
+
callData: data,
|
|
377
|
+
}))
|
|
378
|
+
break
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const handleRegisterEvent = (state: string) => {
|
|
383
|
+
setIsConnected(state === 'registered')
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const handleSessionEvent = (state: string, data: any) => {
|
|
387
|
+
// Handle session events if needed
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
exotelService.addCallListener(handleCallEvent)
|
|
391
|
+
exotelService.addRegisterListener(handleRegisterEvent)
|
|
392
|
+
exotelService.addSessionListener(handleSessionEvent)
|
|
393
|
+
|
|
394
|
+
return () => {
|
|
395
|
+
exotelService.removeCallListener(handleCallEvent)
|
|
396
|
+
exotelService.removeRegisterListener(handleRegisterEvent)
|
|
397
|
+
exotelService.removeSessionListener(handleSessionEvent)
|
|
398
|
+
}
|
|
399
|
+
}, [isInitialized])
|
|
400
|
+
|
|
401
|
+
// Update permission status when it changes in the service
|
|
402
|
+
useEffect(() => {
|
|
403
|
+
if (isInitialized) {
|
|
404
|
+
setPermissionStatus(exotelService.getPermissionStatus())
|
|
405
|
+
}
|
|
406
|
+
}, [isInitialized])
|
|
407
|
+
|
|
408
|
+
const makeCall = useCallback(
|
|
409
|
+
async (phoneNumber: string, callerName?: string, callActivityId?: string, maskedNumber?: string) => {
|
|
410
|
+
// Set initial state - ready to call (agent needs to accept first)
|
|
411
|
+
// Use masked number for display, actual number for the call
|
|
412
|
+
setCallState((prev) => ({
|
|
413
|
+
...prev,
|
|
414
|
+
phase: 'ready_to_call',
|
|
415
|
+
callDirection: 'outbound',
|
|
416
|
+
callerName: callerName || defaultCallerName || null,
|
|
417
|
+
callerNumber: maskedNumber || phoneNumber, // Show masked number in UI
|
|
418
|
+
callActivityId: callActivityId || null, // Store the database ID for disposition
|
|
419
|
+
isOnHold: false,
|
|
420
|
+
isMuted: false,
|
|
421
|
+
callStartTime: null,
|
|
422
|
+
callDuration: 0,
|
|
423
|
+
}))
|
|
424
|
+
|
|
425
|
+
const response = await exotelService.makeCall(phoneNumber) // Use actual number for calling
|
|
426
|
+
return response
|
|
427
|
+
},
|
|
428
|
+
[defaultCallerName],
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
const acceptCall = useCallback(async () => {
|
|
432
|
+
await exotelService.acceptCall()
|
|
433
|
+
// For outbound calls, accepting means we're now calling the lead
|
|
434
|
+
// Mark wasAccepted = true so we know to show disposition form on end
|
|
435
|
+
setCallState((prev) => {
|
|
436
|
+
if (prev.callDirection === 'outbound') {
|
|
437
|
+
return {
|
|
438
|
+
...prev,
|
|
439
|
+
phase: 'calling',
|
|
440
|
+
wasAccepted: true,
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// For inbound calls, also mark as accepted
|
|
444
|
+
return {
|
|
445
|
+
...prev,
|
|
446
|
+
wasAccepted: true,
|
|
447
|
+
}
|
|
448
|
+
})
|
|
449
|
+
}, [])
|
|
450
|
+
|
|
451
|
+
const hangupCall = useCallback(() => {
|
|
452
|
+
exotelService.hangupCall()
|
|
453
|
+
}, [])
|
|
454
|
+
|
|
455
|
+
const toggleSpeaker = useCallback(() => {
|
|
456
|
+
setCallState((prev) => ({
|
|
457
|
+
...prev,
|
|
458
|
+
isSpeakerOff: !prev.isSpeakerOff,
|
|
459
|
+
}))
|
|
460
|
+
// Note: Actual speaker toggle would need WebRTC audio output device switching
|
|
461
|
+
}, [])
|
|
462
|
+
|
|
463
|
+
const clearDisposition = useCallback(() => {
|
|
464
|
+
setDispositionData(null)
|
|
465
|
+
setCallState(initialCallState)
|
|
466
|
+
}, [])
|
|
467
|
+
|
|
468
|
+
// Update callActivityId after creating the record (for outbound calls)
|
|
469
|
+
const setCallActivityIdFn = useCallback((callActivityId: string) => {
|
|
470
|
+
setCallState((prev) => ({
|
|
471
|
+
...prev,
|
|
472
|
+
callActivityId,
|
|
473
|
+
}))
|
|
474
|
+
}, [])
|
|
475
|
+
|
|
476
|
+
// Update exotelCallSid from SDK response (for outbound calls)
|
|
477
|
+
const setExotelCallSidFn = useCallback((callSid: string) => {
|
|
478
|
+
setCallState((prev) => ({
|
|
479
|
+
...prev,
|
|
480
|
+
exotelCallSid: callSid,
|
|
481
|
+
}))
|
|
482
|
+
}, [])
|
|
483
|
+
|
|
484
|
+
const value: ExotelContextType = {
|
|
485
|
+
isInitialized,
|
|
486
|
+
isConnected,
|
|
487
|
+
callState,
|
|
488
|
+
permissionStatus,
|
|
489
|
+
dispositionData,
|
|
490
|
+
makeCall,
|
|
491
|
+
acceptCall,
|
|
492
|
+
hangupCall,
|
|
493
|
+
rejectCall: () => exotelService.rejectCall(),
|
|
494
|
+
toggleHold: () => exotelService.toggleHold(),
|
|
495
|
+
toggleMute: () => exotelService.toggleMute(),
|
|
496
|
+
toggleSpeaker,
|
|
497
|
+
sendDTMF: (digit: string) => exotelService.sendDTMF(digit),
|
|
498
|
+
registerDevice: () => exotelService.registerDevice(),
|
|
499
|
+
unregisterDevice: () => exotelService.unregisterDevice(),
|
|
500
|
+
requestMicrophoneAccess: () => exotelService.requestMicrophoneAccess(),
|
|
501
|
+
clearDisposition,
|
|
502
|
+
setCallActivityId: setCallActivityIdFn,
|
|
503
|
+
setExotelCallSid: setExotelCallSidFn,
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return (
|
|
507
|
+
<ExotelContext.Provider value={value}>{children}</ExotelContext.Provider>
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export const useExotel = () => {
|
|
512
|
+
const context = useContext(ExotelContext)
|
|
513
|
+
if (context === undefined) {
|
|
514
|
+
throw new Error('useExotel must be used within an ExotelProvider')
|
|
515
|
+
}
|
|
516
|
+
return context
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Safe version of useExotel that returns null when outside the provider
|
|
521
|
+
* Use this in components that may render outside of ExotelProvider
|
|
522
|
+
*/
|
|
523
|
+
export const useExotelSafe = (): ExotelContextType | null => {
|
|
524
|
+
const context = useContext(ExotelContext)
|
|
525
|
+
return context ?? null
|
|
526
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import React, { ReactNode, useCallback, useEffect, useState } from 'react'
|
|
2
|
+
import { ExotelProvider } from './ExotelProvider'
|
|
3
|
+
import { ExotelPhone, DispositionFormData } from '../components/Exotel'
|
|
4
|
+
import { checkVoIPEnabled, getActiveVoiceToken, saveCallDisposition } from '../services/exotel'
|
|
5
|
+
import { isVoIPEnabledForWorkspace } from '../config/voip.config'
|
|
6
|
+
|
|
7
|
+
// Re-export for consumers
|
|
8
|
+
export type { DispositionFormData } from '../components/Exotel'
|
|
9
|
+
|
|
10
|
+
interface VoIPProviderProps {
|
|
11
|
+
children: ReactNode
|
|
12
|
+
workspace: string
|
|
13
|
+
agentUserId?: string // Exotel App User ID from user profile
|
|
14
|
+
prospectId?: string
|
|
15
|
+
prospectName?: string
|
|
16
|
+
prospectNumber?: string
|
|
17
|
+
onSaveDisposition?: (data: DispositionFormData) => Promise<any>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* VoIPProvider - Workspace-level VoIP/Exotel integration
|
|
22
|
+
*
|
|
23
|
+
* This provider checks if VoIP is enabled for the current workspace AND tenant
|
|
24
|
+
* and initializes the Exotel SDK if enabled.
|
|
25
|
+
*
|
|
26
|
+
* VoIP is enabled only when ALL conditions are met:
|
|
27
|
+
* 1. Workspace is in VOIP_ENABLED_WORKSPACES (common-workspace, tele-caller-workspace)
|
|
28
|
+
* 2. Tenant has enableVoice: true in notification config
|
|
29
|
+
* 3. Tenant has at least one enabled voice config (voiceConfigs[].isEnabled: true)
|
|
30
|
+
* 4. Active token is available
|
|
31
|
+
*
|
|
32
|
+
* Features:
|
|
33
|
+
* - Workspace-level VoIP enablement
|
|
34
|
+
* - Tenant-level VoIP configuration check
|
|
35
|
+
* - Automatic token fetching from backend
|
|
36
|
+
* - Microphone permission handling
|
|
37
|
+
* - Floating phone UI for active calls
|
|
38
|
+
*/
|
|
39
|
+
export const VoIPProvider: React.FC<VoIPProviderProps> = ({
|
|
40
|
+
children,
|
|
41
|
+
workspace,
|
|
42
|
+
agentUserId,
|
|
43
|
+
prospectId,
|
|
44
|
+
prospectName,
|
|
45
|
+
prospectNumber,
|
|
46
|
+
onSaveDisposition,
|
|
47
|
+
}) => {
|
|
48
|
+
const [accessToken, setAccessToken] = useState<string | null>(null)
|
|
49
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
50
|
+
const [error, setError] = useState<string | null>(null)
|
|
51
|
+
|
|
52
|
+
const isWorkspaceVoIPEnabled = isVoIPEnabledForWorkspace(workspace)
|
|
53
|
+
const hasAgentUserId = !!agentUserId
|
|
54
|
+
|
|
55
|
+
// Default disposition handler that calls the API
|
|
56
|
+
const handleSaveDisposition = useCallback(async (data: DispositionFormData) => {
|
|
57
|
+
// If custom handler is provided, use it
|
|
58
|
+
if (onSaveDisposition) {
|
|
59
|
+
return onSaveDisposition(data)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Default: call the saveCallDisposition API
|
|
63
|
+
return saveCallDisposition({
|
|
64
|
+
callId: data.callId,
|
|
65
|
+
prospectId: data.prospectId,
|
|
66
|
+
callStatusDisposition: data.callStatusDisposition,
|
|
67
|
+
dispositionCategory: data.dispositionCategory,
|
|
68
|
+
dispositionReason: data.dispositionReason || undefined,
|
|
69
|
+
dispositionNotes: data.remarks || undefined,
|
|
70
|
+
})
|
|
71
|
+
}, [onSaveDisposition])
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
// Skip if workspace doesn't support VoIP or user doesn't have Exotel App User ID
|
|
75
|
+
if (!isWorkspaceVoIPEnabled || !hasAgentUserId) {
|
|
76
|
+
setIsLoading(false)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const initializeVoIP = async () => {
|
|
81
|
+
try {
|
|
82
|
+
setIsLoading(true)
|
|
83
|
+
setError(null)
|
|
84
|
+
|
|
85
|
+
// Step 1: Check if tenant has VoIP enabled in notification config
|
|
86
|
+
const voipStatus = await checkVoIPEnabled()
|
|
87
|
+
|
|
88
|
+
if (!voipStatus.enabled) {
|
|
89
|
+
// VoIP not enabled for this tenant
|
|
90
|
+
setError('Voice calling not enabled for this tenant')
|
|
91
|
+
setIsLoading(false)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Step 2: Fetch the active token
|
|
96
|
+
const tokenData = await getActiveVoiceToken()
|
|
97
|
+
|
|
98
|
+
if (tokenData?.token) {
|
|
99
|
+
setAccessToken(tokenData.token)
|
|
100
|
+
} else {
|
|
101
|
+
// No active token available
|
|
102
|
+
setError('Voice calling token not available')
|
|
103
|
+
}
|
|
104
|
+
} catch (err: any) {
|
|
105
|
+
console.error('Failed to initialize VoIP:', err)
|
|
106
|
+
setError(err.message || 'Failed to initialize calling')
|
|
107
|
+
} finally {
|
|
108
|
+
setIsLoading(false)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
initializeVoIP()
|
|
113
|
+
}, [isWorkspaceVoIPEnabled, hasAgentUserId])
|
|
114
|
+
|
|
115
|
+
// If workspace doesn't support VoIP or user doesn't have Exotel ID, just render children
|
|
116
|
+
if (!isWorkspaceVoIPEnabled || !hasAgentUserId) {
|
|
117
|
+
return <>{children}</>
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// If loading or error or no token, render children without VoIP
|
|
121
|
+
if (isLoading || error || !accessToken) {
|
|
122
|
+
return <>{children}</>
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Render with Exotel provider and floating phone UI
|
|
126
|
+
return (
|
|
127
|
+
<ExotelProvider
|
|
128
|
+
accessToken={accessToken}
|
|
129
|
+
agentUserId={agentUserId}
|
|
130
|
+
autoConnectVOIP={true}
|
|
131
|
+
>
|
|
132
|
+
{children}
|
|
133
|
+
<ExotelPhone
|
|
134
|
+
prospectId={prospectId}
|
|
135
|
+
prospectName={prospectName}
|
|
136
|
+
prospectNumber={prospectNumber}
|
|
137
|
+
onSaveDisposition={handleSaveDisposition}
|
|
138
|
+
/>
|
|
139
|
+
</ExotelProvider>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export default VoIPProvider
|