@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.
Files changed (63) hide show
  1. package/dist/cjs/index.js +1 -1
  2. package/dist/cjs/types/src/components/Exotel/CallButton.d.ts +10 -0
  3. package/dist/cjs/types/src/components/Exotel/CallDispositionForm.d.ts +29 -0
  4. package/dist/cjs/types/src/components/Exotel/ExotelPhone.d.ts +10 -0
  5. package/dist/cjs/types/src/components/Exotel/ExotelWrapper.d.ts +14 -0
  6. package/dist/cjs/types/src/components/Exotel/MicrophonePermission.d.ts +6 -0
  7. package/dist/cjs/types/src/components/Exotel/index.d.ts +6 -0
  8. package/dist/cjs/types/src/config/index.d.ts +1 -0
  9. package/dist/cjs/types/src/config/voip.config.d.ts +18 -0
  10. package/dist/cjs/types/src/constants/exotel.constants.d.ts +7 -0
  11. package/dist/cjs/types/src/providers/ExotelProvider.d.ts +79 -0
  12. package/dist/cjs/types/src/providers/VoIPProvider.d.ts +33 -0
  13. package/dist/cjs/types/src/providers/index.d.ts +2 -0
  14. package/dist/cjs/types/src/services/crypto/CryptoService.d.ts +23 -0
  15. package/dist/cjs/types/src/services/exotel/ExotelService.d.ts +47 -0
  16. package/dist/cjs/types/src/services/exotel/api.d.ts +158 -0
  17. package/dist/cjs/types/src/services/exotel/index.d.ts +2 -0
  18. package/dist/cjs/types/src/utils/exotel/formatters.d.ts +8 -0
  19. package/dist/cjs/types/src/utils/exotel/index.d.ts +1 -0
  20. package/dist/esm/index.js +2 -2
  21. package/dist/esm/types/src/components/Exotel/CallButton.d.ts +10 -0
  22. package/dist/esm/types/src/components/Exotel/CallDispositionForm.d.ts +29 -0
  23. package/dist/esm/types/src/components/Exotel/ExotelPhone.d.ts +10 -0
  24. package/dist/esm/types/src/components/Exotel/ExotelWrapper.d.ts +14 -0
  25. package/dist/esm/types/src/components/Exotel/MicrophonePermission.d.ts +6 -0
  26. package/dist/esm/types/src/components/Exotel/index.d.ts +6 -0
  27. package/dist/esm/types/src/config/index.d.ts +1 -0
  28. package/dist/esm/types/src/config/voip.config.d.ts +18 -0
  29. package/dist/esm/types/src/constants/exotel.constants.d.ts +7 -0
  30. package/dist/esm/types/src/providers/ExotelProvider.d.ts +79 -0
  31. package/dist/esm/types/src/providers/VoIPProvider.d.ts +33 -0
  32. package/dist/esm/types/src/providers/index.d.ts +2 -0
  33. package/dist/esm/types/src/services/crypto/CryptoService.d.ts +23 -0
  34. package/dist/esm/types/src/services/exotel/ExotelService.d.ts +47 -0
  35. package/dist/esm/types/src/services/exotel/api.d.ts +158 -0
  36. package/dist/esm/types/src/services/exotel/index.d.ts +2 -0
  37. package/dist/esm/types/src/utils/exotel/formatters.d.ts +8 -0
  38. package/dist/esm/types/src/utils/exotel/index.d.ts +1 -0
  39. package/dist/index.d.ts +357 -3
  40. package/dist/styles.css +337 -47
  41. package/dist/types/exotel-crm-websdk.d.ts +46 -0
  42. package/export.ts +6 -0
  43. package/package.json +4 -1
  44. package/src/components/Exotel/CallButton.tsx +164 -0
  45. package/src/components/Exotel/CallDispositionForm.tsx +213 -0
  46. package/src/components/Exotel/ExotelPhone.tsx +482 -0
  47. package/src/components/Exotel/ExotelWrapper.tsx +80 -0
  48. package/src/components/Exotel/MicrophonePermission.tsx +97 -0
  49. package/src/components/Exotel/index.ts +10 -0
  50. package/src/config/index.ts +1 -0
  51. package/src/config/voip.config.ts +26 -0
  52. package/src/constants/exotel.constants.ts +7 -0
  53. package/src/providers/ExotelProvider.tsx +526 -0
  54. package/src/providers/VoIPProvider.tsx +143 -0
  55. package/src/providers/index.ts +2 -0
  56. package/src/selectors/ResearchStageSelector.tsx +1 -0
  57. package/src/services/crypto/CryptoService.ts +112 -0
  58. package/src/services/exotel/ExotelService.ts +238 -0
  59. package/src/services/exotel/api.ts +319 -0
  60. package/src/services/exotel/index.ts +2 -0
  61. package/src/utils/exotel/formatters.ts +17 -0
  62. package/src/utils/exotel/index.ts +1 -0
  63. package/types/exotel-crm-websdk.d.ts +46 -0
@@ -0,0 +1,7 @@
1
+ export const EXOTEL_CONSTANTS = {
2
+ MODAL_Z_INDEX: 999999,
3
+ MODAL_BACKDROP_Z_INDEX: 999998,
4
+ MODAL_BORDER_RADIUS: '16px',
5
+ DECLINE_TIMEOUT_MS: 2000,
6
+ TIMER_INTERVAL_MS: 1000,
7
+ }
@@ -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
@@ -0,0 +1,2 @@
1
+ export * from './ExotelProvider'
2
+ export * from './VoIPProvider'
@@ -7,6 +7,7 @@ export const ResearchStageSelector = (props: SingleSelectProps) => {
7
7
  <SingleSelect
8
8
  dbValueProps={{
9
9
  valueKey: 'id',
10
+ isInt: true,
10
11
  }}
11
12
  dbLabelProps={{
12
13
  labelKey: 'name',