@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,482 @@
1
+ import { useState, useEffect, useRef } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+
4
+ import { Button, Typography } from '@campxdev/react-blueprint'
5
+
6
+ import {
7
+ X,
8
+ Mic,
9
+ MicOff,
10
+ Pause,
11
+ Play,
12
+ Volume2,
13
+ VolumeX,
14
+ Phone,
15
+ PhoneOff,
16
+ PhoneIncoming,
17
+ PhoneOutgoing,
18
+ } from 'lucide-react'
19
+
20
+ import { useExotel } from '../../providers/ExotelProvider'
21
+
22
+ import { cancelCall, saveCallDisposition } from '../../services/exotel/api'
23
+
24
+ import { formatDurationTimer } from '../../utils/exotel/formatters'
25
+ import { EXOTEL_CONSTANTS } from '../../constants/exotel.constants'
26
+
27
+ import CallDispositionForm, { type DispositionFormData } from './CallDispositionForm'
28
+
29
+ interface ExotelPhoneProps {
30
+ className?: string
31
+ prospectId?: string
32
+ prospectName?: string
33
+ prospectNumber?: string
34
+ onSaveDisposition?: (data: DispositionFormData) => Promise<any>
35
+ }
36
+
37
+ // CSS for pulsing animation
38
+ const pulseAnimationStyle = `
39
+ @keyframes pulse-ring {
40
+ 0% {
41
+ transform: scale(1);
42
+ opacity: 1;
43
+ }
44
+ 50% {
45
+ transform: scale(1.15);
46
+ opacity: 0.7;
47
+ }
48
+ 100% {
49
+ transform: scale(1);
50
+ opacity: 1;
51
+ }
52
+ }
53
+
54
+ @keyframes pulse-dot {
55
+ 0%, 100% {
56
+ box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.7);
57
+ }
58
+ 50% {
59
+ box-shadow: 0 0 0 20px rgba(249, 115, 22, 0);
60
+ }
61
+ }
62
+
63
+ .animate-pulse-ring {
64
+ animation: pulse-ring 1.5s ease-in-out infinite;
65
+ }
66
+
67
+ .animate-pulse-dot {
68
+ animation: pulse-dot 1.5s ease-in-out infinite;
69
+ }
70
+ `
71
+
72
+ const ExotelPhone = ({ className, prospectId, prospectName, prospectNumber, onSaveDisposition }: ExotelPhoneProps) => {
73
+ const {
74
+ callState,
75
+ dispositionData,
76
+ acceptCall,
77
+ hangupCall,
78
+ rejectCall,
79
+ toggleHold,
80
+ toggleMute,
81
+ toggleSpeaker,
82
+ clearDisposition,
83
+ } = useExotel()
84
+
85
+ const [isAccepting, setIsAccepting] = useState(false)
86
+ const [isDeclining, setIsDeclining] = useState(false)
87
+ const declineTimeoutRef = useRef<NodeJS.Timeout | null>(null)
88
+
89
+ // Inject animation styles
90
+ useEffect(() => {
91
+ const styleId = 'exotel-phone-animations'
92
+ if (!document.getElementById(styleId)) {
93
+ const styleEl = document.createElement('style')
94
+ styleEl.id = styleId
95
+ styleEl.textContent = pulseAnimationStyle
96
+ document.head.appendChild(styleEl)
97
+ }
98
+ }, [])
99
+
100
+ const {
101
+ phase,
102
+ callData,
103
+ isOnHold,
104
+ isMuted,
105
+ isSpeakerOff,
106
+ callDirection,
107
+ callDuration,
108
+ callerName: sdkCallerName,
109
+ callerNumber: sdkCallerNumber,
110
+ callActivityId,
111
+ exotelCallSid,
112
+ prospectUniqueId,
113
+ } = callState
114
+
115
+ // Use props (from Prospect view) as primary, fall back to SDK values (for incoming calls)
116
+ const displayName = prospectName || sdkCallerName || 'Unknown'
117
+ const displayNumber = prospectNumber || sdkCallerNumber || 'No number'
118
+
119
+ const handleAcceptCall = async () => {
120
+ setIsAccepting(true)
121
+ try {
122
+ await acceptCall()
123
+ } catch (error: any) {
124
+ console.error('Accept call error:', error)
125
+ } finally {
126
+ setIsAccepting(false)
127
+ }
128
+ }
129
+
130
+ const handleDeclineCall = async () => {
131
+ setIsDeclining(true)
132
+ // Use rejectCall for incoming/ready_to_call (before accepting)
133
+ // Use hangupCall for connected/calling (after accepting)
134
+ if (phase === 'incoming' || phase === 'ready_to_call') {
135
+ rejectCall()
136
+ } else {
137
+ hangupCall()
138
+ }
139
+
140
+ // Call backend API to update call activity status
141
+ // Use exotelCallSid (from outbound_call API) for outbound calls
142
+ // Use callActivityId as fallback identifier
143
+ const callSidForApi = exotelCallSid || callActivityId
144
+ if (callSidForApi) {
145
+ try {
146
+ const reason = phase === 'connected' ? 'ended' : 'declined'
147
+ await cancelCall({ callSid: callSidForApi, reason })
148
+ } catch (error) {
149
+ console.error('Failed to cancel call in backend:', error)
150
+ }
151
+ }
152
+
153
+ // If the call wasn't connected yet, SDK might not fire 'callEnded' event
154
+ // Set a timeout to force reset the declining state
155
+ // Note: Don't clear disposition here - let the SDK's callEnded event handle it
156
+ declineTimeoutRef.current = setTimeout(() => {
157
+ setIsDeclining(false)
158
+ }, EXOTEL_CONSTANTS.DECLINE_TIMEOUT_MS)
159
+ }
160
+
161
+ // Clear decline timeout when phase changes (meaning SDK handled it)
162
+ useEffect(() => {
163
+ if (phase === 'idle' || phase === 'ended') {
164
+ if (declineTimeoutRef.current) {
165
+ clearTimeout(declineTimeoutRef.current)
166
+ declineTimeoutRef.current = null
167
+ }
168
+ setIsDeclining(false)
169
+ // Note: Don't auto-clear disposition here - let user submit or close the form manually
170
+ }
171
+ }, [phase])
172
+
173
+ // Handle saving disposition to backend
174
+ const handleSaveDisposition = async (data: DispositionFormData) => {
175
+ // If custom handler is provided, use it
176
+ if (onSaveDisposition) {
177
+ return onSaveDisposition(data)
178
+ }
179
+
180
+ // Use the callStatusDisposition from the form (user selected)
181
+ return saveCallDisposition({
182
+ callId: data.callId,
183
+ prospectId: data.prospectId || prospectId || '',
184
+ callStatusDisposition: data.callStatusDisposition,
185
+ dispositionCategory: data.dispositionCategory,
186
+ dispositionNotes: data.remarks || undefined,
187
+ })
188
+ }
189
+
190
+ // Cleanup timeout on unmount
191
+ useEffect(() => {
192
+ return () => {
193
+ if (declineTimeoutRef.current) {
194
+ clearTimeout(declineTimeoutRef.current)
195
+ }
196
+ }
197
+ }, [])
198
+
199
+ // Show disposition form after call ends
200
+ if (dispositionData) {
201
+ // Use prospect name from props, or from dispositionData (preserved from call), or fallback
202
+ const finalCallerName = prospectName || dispositionData.callerName || sdkCallerName || 'Unknown'
203
+
204
+ // Override disposition data with prospect details
205
+ const enrichedDispositionData = {
206
+ ...dispositionData,
207
+ callerName: finalCallerName,
208
+ callerNumber: displayNumber,
209
+ }
210
+
211
+ return (
212
+ <CallDispositionForm
213
+ dispositionData={enrichedDispositionData}
214
+ prospectId={prospectId}
215
+ onClose={clearDisposition}
216
+ onSave={handleSaveDisposition}
217
+ />
218
+ )
219
+ }
220
+
221
+ // Don't show anything if idle or ended
222
+ if (phase === 'idle' || phase === 'ended') {
223
+ return null
224
+ }
225
+
226
+ const isOutbound = callDirection === 'outbound'
227
+ const isInbound = callDirection === 'inbound'
228
+
229
+ // Get avatar color based on call direction
230
+ const avatarBgColor =
231
+ phase === 'connected'
232
+ ? 'bg-green-500'
233
+ : isOutbound
234
+ ? 'bg-blue-500'
235
+ : 'bg-orange-500'
236
+
237
+ // Get status text
238
+ const getStatusText = () => {
239
+ switch (phase) {
240
+ case 'ready_to_call':
241
+ return 'Ready to call...'
242
+ case 'calling':
243
+ return 'Calling...'
244
+ case 'incoming':
245
+ return 'Incoming call...'
246
+ case 'connected':
247
+ return null // Will show timer instead
248
+ default:
249
+ return null
250
+ }
251
+ }
252
+
253
+ // Get connecting text for calling state
254
+ const getConnectingText = () => {
255
+ if (phase === 'calling') {
256
+ return `Connecting to ${displayName}...`
257
+ }
258
+ return null
259
+ }
260
+
261
+ const modalContent = (
262
+ <>
263
+ {/* Backdrop - rendered first, appears behind */}
264
+ <div
265
+ className="fixed inset-0 bg-black/50"
266
+ style={{ zIndex: EXOTEL_CONSTANTS.MODAL_BACKDROP_Z_INDEX }}
267
+ onClick={handleDeclineCall}
268
+ />
269
+
270
+ {/* Modal - rendered second, appears on top */}
271
+ <div
272
+ className={`fixed inset-0 flex items-center justify-center p-4 ${className}`}
273
+ style={{ zIndex: EXOTEL_CONSTANTS.MODAL_Z_INDEX }}
274
+ >
275
+ <div
276
+ className="bg-white shadow-2xl w-full max-w-md p-6 relative"
277
+ style={{ borderRadius: EXOTEL_CONSTANTS.MODAL_BORDER_RADIUS, border: '1px solid #e5e7eb' }}
278
+ onClick={(e) => e.stopPropagation()}
279
+ >
280
+ {/* Close button */}
281
+ <button
282
+ onClick={handleDeclineCall}
283
+ className="absolute top-4 right-4 p-1 hover:bg-gray-100 rounded-full transition-colors"
284
+ >
285
+ <X className="w-5 h-5 text-gray-500" />
286
+ </button>
287
+
288
+ {/* Call type badge */}
289
+ <div className="flex justify-center mb-6">
290
+ <span
291
+ className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium border ${
292
+ isOutbound
293
+ ? 'text-blue-600 border-blue-200 bg-blue-50'
294
+ : 'text-green-600 border-green-200 bg-green-50'
295
+ }`}
296
+ >
297
+ {isOutbound ? (
298
+ <PhoneOutgoing className="w-4 h-4" />
299
+ ) : (
300
+ <PhoneIncoming className="w-4 h-4" />
301
+ )}
302
+ {isOutbound ? 'Outgoing Call' : 'Incoming Call'}
303
+ </span>
304
+ </div>
305
+
306
+ {/* Avatar */}
307
+ <div className="flex justify-center mb-4">
308
+ <div
309
+ className={`w-24 h-24 rounded-full ${avatarBgColor} flex items-center justify-center ${
310
+ phase === 'incoming' || phase === 'ready_to_call'
311
+ ? 'animate-pulse-ring animate-pulse-dot'
312
+ : ''
313
+ }`}
314
+ >
315
+ {isInbound && phase === 'incoming' ? (
316
+ <PhoneIncoming className="w-10 h-10 text-white" />
317
+ ) : (
318
+ <Phone className="w-10 h-10 text-white" />
319
+ )}
320
+ </div>
321
+ </div>
322
+
323
+ {/* Caller info */}
324
+ <div className="text-center mb-4">
325
+ <Typography variant="h3" className="text-xl font-semibold text-gray-900">
326
+ {displayName}
327
+ </Typography>
328
+ {prospectUniqueId && (
329
+ <Typography variant="small" className="text-blue-600 font-medium">ID: {prospectUniqueId}</Typography>
330
+ )}
331
+ <Typography variant="muted">{displayNumber}</Typography>
332
+ </div>
333
+
334
+ {/* Status / Timer */}
335
+ <div className="text-center mb-6">
336
+ {phase === 'connected' ? (
337
+ <Typography variant="p" className="text-green-500 text-lg font-medium">
338
+ {formatDurationTimer(callDuration)}
339
+ </Typography>
340
+ ) : (
341
+ <Typography variant="muted">{getStatusText()}</Typography>
342
+ )}
343
+ </div>
344
+
345
+ {/* Call controls - shown when connected */}
346
+ {phase === 'connected' && (
347
+ <div className="flex justify-center gap-6 mb-6">
348
+ {/* Mute */}
349
+ <div className="text-center">
350
+ <button
351
+ onClick={toggleMute}
352
+ className={`w-12 h-12 rounded-full border-2 flex items-center justify-center transition-colors ${
353
+ isMuted
354
+ ? 'bg-gray-200 border-gray-300'
355
+ : 'bg-white border-gray-200 hover:bg-gray-50'
356
+ }`}
357
+ >
358
+ {isMuted ? (
359
+ <MicOff className="w-5 h-5 text-gray-600" />
360
+ ) : (
361
+ <Mic className="w-5 h-5 text-gray-600" />
362
+ )}
363
+ </button>
364
+ <p className="text-xs text-gray-500 mt-1">
365
+ {isMuted ? 'Unmute' : 'Mute'}
366
+ </p>
367
+ </div>
368
+
369
+ {/* Hold */}
370
+ <div className="text-center">
371
+ <button
372
+ onClick={toggleHold}
373
+ className={`w-12 h-12 rounded-full border-2 flex items-center justify-center transition-colors ${
374
+ isOnHold
375
+ ? 'bg-gray-200 border-gray-300'
376
+ : 'bg-white border-gray-200 hover:bg-gray-50'
377
+ }`}
378
+ >
379
+ {isOnHold ? (
380
+ <Play className="w-5 h-5 text-gray-600" />
381
+ ) : (
382
+ <Pause className="w-5 h-5 text-gray-600" />
383
+ )}
384
+ </button>
385
+ <p className="text-xs text-gray-500 mt-1">
386
+ {isOnHold ? 'Resume' : 'Hold'}
387
+ </p>
388
+ </div>
389
+
390
+ {/* Speaker */}
391
+ <div className="text-center">
392
+ <button
393
+ onClick={toggleSpeaker}
394
+ className={`w-12 h-12 rounded-full border-2 flex items-center justify-center transition-colors ${
395
+ isSpeakerOff
396
+ ? 'bg-gray-200 border-gray-300'
397
+ : 'bg-white border-gray-200 hover:bg-gray-50'
398
+ }`}
399
+ >
400
+ {isSpeakerOff ? (
401
+ <VolumeX className="w-5 h-5 text-gray-600" />
402
+ ) : (
403
+ <Volume2 className="w-5 h-5 text-gray-600" />
404
+ )}
405
+ </button>
406
+ <p className="text-xs text-gray-500 mt-1">
407
+ {isSpeakerOff ? 'Speaker On' : 'Speaker Off'}
408
+ </p>
409
+ </div>
410
+ </div>
411
+ )}
412
+
413
+ {/* Action buttons */}
414
+ <div className="flex justify-center gap-4">
415
+ {/* Ready to call / Incoming - show Accept & Decline */}
416
+ {(phase === 'ready_to_call' || phase === 'incoming') && (
417
+ <>
418
+ <Button
419
+ variant="destructive"
420
+ onClick={handleDeclineCall}
421
+ disabled={isDeclining}
422
+ className="gap-2 px-6"
423
+ >
424
+ <PhoneOff className="w-4 h-4" />
425
+ {isDeclining ? 'Declining...' : 'Decline'}
426
+ </Button>
427
+ <Button
428
+ variant="default"
429
+ onClick={handleAcceptCall}
430
+ disabled={isAccepting || isDeclining}
431
+ style={{ backgroundColor: '#22c55e' }}
432
+ className="gap-2 px-6 hover:bg-green-600"
433
+ >
434
+ <Phone className="w-4 h-4" />
435
+ Accept
436
+ </Button>
437
+ </>
438
+ )}
439
+
440
+ {/* Calling - show End Call only */}
441
+ {phase === 'calling' && (
442
+ <Button
443
+ variant="destructive"
444
+ onClick={handleDeclineCall}
445
+ disabled={isDeclining}
446
+ className="gap-2 px-6"
447
+ >
448
+ <PhoneOff className="w-4 h-4" />
449
+ {isDeclining ? 'Ending...' : 'End Call'}
450
+ </Button>
451
+ )}
452
+
453
+ {/* Connected - show End Call */}
454
+ {phase === 'connected' && (
455
+ <Button
456
+ variant="destructive"
457
+ onClick={handleDeclineCall}
458
+ disabled={isDeclining}
459
+ className="gap-2 px-8"
460
+ >
461
+ <PhoneOff className="w-4 h-4" />
462
+ {isDeclining ? 'Ending...' : 'End Call'}
463
+ </Button>
464
+ )}
465
+ </div>
466
+
467
+ {/* Connecting text */}
468
+ {getConnectingText() && (
469
+ <Typography variant="small" className="text-center text-gray-500 mt-4">
470
+ {getConnectingText()}
471
+ </Typography>
472
+ )}
473
+ </div>
474
+ </div>
475
+ </>
476
+ )
477
+
478
+ // Use portal to render at document body level to escape any parent stacking contexts
479
+ return createPortal(modalContent, document.body)
480
+ }
481
+
482
+ export default ExotelPhone
@@ -0,0 +1,80 @@
1
+ import { ReactNode, useEffect, useState } from 'react'
2
+
3
+ import { ExotelProvider } from '../../providers/ExotelProvider'
4
+ import { getActiveVoiceToken } from '../../services/exotel/api'
5
+ import ExotelPhone from './ExotelPhone'
6
+
7
+ interface ExotelWrapperProps {
8
+ children: ReactNode
9
+ enabled?: boolean
10
+ prospectId?: string
11
+ prospectName?: string
12
+ prospectNumber?: string
13
+ }
14
+
15
+ /**
16
+ * Wrapper component that initializes Exotel VoIP functionality
17
+ * Fetches the access token from backend and provides the Exotel context to children
18
+ */
19
+ const ExotelWrapper = ({
20
+ children,
21
+ enabled = true,
22
+ prospectId,
23
+ prospectName,
24
+ prospectNumber,
25
+ }: ExotelWrapperProps) => {
26
+ const [accessToken, setAccessToken] = useState<string | null>(null)
27
+ const [isLoading, setIsLoading] = useState(true)
28
+ const [error, setError] = useState<string | null>(null)
29
+
30
+ useEffect(() => {
31
+ if (!enabled) {
32
+ setIsLoading(false)
33
+ return
34
+ }
35
+
36
+ const fetchToken = async () => {
37
+ try {
38
+ setIsLoading(true)
39
+ // Fetch token from backend - credentials are managed securely on server
40
+ const tokenData = await getActiveVoiceToken()
41
+ if (tokenData?.token) {
42
+ setAccessToken(tokenData.token)
43
+ } else {
44
+ // No active voice config - Exotel not configured for this tenant
45
+ setError('Voice calling not configured')
46
+ }
47
+ } catch (err: any) {
48
+ console.error('Failed to fetch Exotel token:', err)
49
+ setError(err.message || 'Failed to initialize calling')
50
+ } finally {
51
+ setIsLoading(false)
52
+ }
53
+ }
54
+
55
+ fetchToken()
56
+ }, [enabled])
57
+
58
+ // If Exotel is not enabled or failed to load, just render children
59
+ if (!enabled || error || !accessToken) {
60
+ return <>{children}</>
61
+ }
62
+
63
+ // Render with Exotel provider and floating phone UI
64
+ return (
65
+ <ExotelProvider
66
+ accessToken={accessToken}
67
+ agentUserId="0047"
68
+ autoConnectVOIP={true}
69
+ >
70
+ {children}
71
+ <ExotelPhone
72
+ prospectId={prospectId}
73
+ prospectName={prospectName}
74
+ prospectNumber={prospectNumber}
75
+ />
76
+ </ExotelProvider>
77
+ )
78
+ }
79
+
80
+ export default ExotelWrapper
@@ -0,0 +1,97 @@
1
+ import { useState } from 'react'
2
+ import { Button } from '@campxdev/react-blueprint'
3
+ import { Mic, MicOff, AlertCircle } from 'lucide-react'
4
+ import { useExotel } from '../../providers/ExotelProvider'
5
+
6
+ interface MicrophonePermissionProps {
7
+ onPermissionGranted?: () => void
8
+ onPermissionDenied?: () => void
9
+ }
10
+
11
+ const MicrophonePermission = ({
12
+ onPermissionGranted,
13
+ onPermissionDenied,
14
+ }: MicrophonePermissionProps) => {
15
+ const { permissionStatus, requestMicrophoneAccess } = useExotel()
16
+ const [isRequesting, setIsRequesting] = useState(false)
17
+ const [error, setError] = useState<string | null>(null)
18
+
19
+ const handleRequestPermission = async () => {
20
+ setIsRequesting(true)
21
+ setError(null)
22
+
23
+ try {
24
+ const granted = await requestMicrophoneAccess()
25
+ if (granted) {
26
+ onPermissionGranted?.()
27
+ } else {
28
+ setError('Microphone access was denied')
29
+ onPermissionDenied?.()
30
+ }
31
+ } catch (err: any) {
32
+ setError(err.message || 'Failed to request microphone access')
33
+ onPermissionDenied?.()
34
+ } finally {
35
+ setIsRequesting(false)
36
+ }
37
+ }
38
+
39
+ if (permissionStatus === 'granted') {
40
+ return (
41
+ <div className="flex items-center gap-2 text-green-600">
42
+ <Mic className="w-4 h-4" />
43
+ <span className="text-sm">Microphone access granted</span>
44
+ </div>
45
+ )
46
+ }
47
+
48
+ if (permissionStatus === 'denied') {
49
+ return (
50
+ <div className="p-4 bg-red-50 rounded-lg border border-red-200">
51
+ <div className="flex items-center gap-2 text-red-600 mb-2">
52
+ <MicOff className="w-5 h-5" />
53
+ <span className="font-medium">Microphone Access Denied</span>
54
+ </div>
55
+ <p className="text-sm text-red-600 mb-3">
56
+ To use the calling feature, you need to enable microphone access in
57
+ your browser settings.
58
+ </p>
59
+ <div className="text-xs text-gray-600">
60
+ <p className="font-medium mb-1">How to enable:</p>
61
+ <ol className="list-decimal list-inside space-y-1">
62
+ <li>Click the lock/info icon in your browser's address bar</li>
63
+ <li>Find "Microphone" in the permissions list</li>
64
+ <li>Change the setting to "Allow"</li>
65
+ <li>Refresh the page</li>
66
+ </ol>
67
+ </div>
68
+ </div>
69
+ )
70
+ }
71
+
72
+ return (
73
+ <div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
74
+ <div className="flex items-center gap-2 text-blue-600 mb-2">
75
+ <AlertCircle className="w-5 h-5" />
76
+ <span className="font-medium">Microphone Permission Required</span>
77
+ </div>
78
+ <p className="text-sm text-gray-600 mb-3">
79
+ To make and receive calls, we need access to your microphone. Click the
80
+ button below to grant permission.
81
+ </p>
82
+ <Button
83
+ variant="default"
84
+ size="sm"
85
+ onClick={handleRequestPermission}
86
+ loading={isRequesting}
87
+ className="gap-1.5"
88
+ >
89
+ <Mic className="w-4 h-4" />
90
+ Allow Microphone Access
91
+ </Button>
92
+ {error && <p className="text-red-500 text-xs mt-2">{error}</p>}
93
+ </div>
94
+ )
95
+ }
96
+
97
+ export default MicrophonePermission
@@ -0,0 +1,10 @@
1
+ export { default as ExotelPhone } from './ExotelPhone'
2
+ export { default as CallButton } from './CallButton'
3
+ export { default as CallDispositionForm } from './CallDispositionForm'
4
+ export { default as MicrophonePermission } from './MicrophonePermission'
5
+ export { default as ExotelWrapper } from './ExotelWrapper'
6
+
7
+ export type {
8
+ DispositionFormData,
9
+ ExistingDispositionData,
10
+ } from './CallDispositionForm'
@@ -1,3 +1,4 @@
1
1
  export * from './axios';
2
2
  export * from './createRsbuildSharedConfig';
3
3
  export * from './nonWorkspaceAxios';
4
+ export * from './voip.config';
@@ -0,0 +1,26 @@
1
+
2
+ /**
3
+ * VoIP Configuration Module
4
+ *
5
+ * This module manages VoIP/Exotel configuration for workspaces.
6
+ * Only workspaces listed in VOIP_ENABLED_WORKSPACES will have VoIP functionality.
7
+ */
8
+
9
+ /**
10
+ * List of workspaces that have VoIP/Exotel calling enabled.
11
+ * Add workspace names here to enable VoIP for that workspace.
12
+ */
13
+ export const VOIP_ENABLED_WORKSPACES: string[] = [
14
+ 'admissions-officer-workspace',
15
+ 'tele-counsellor-workspace',
16
+ ]
17
+
18
+ /**
19
+ * Check if VoIP is enabled for a given workspace
20
+ *
21
+ * @param workspaceName - The name of the workspace to check
22
+ * @returns boolean indicating if VoIP is enabled for the workspace
23
+ */
24
+ export function isVoIPEnabledForWorkspace(workspaceName: string): boolean {
25
+ return VOIP_ENABLED_WORKSPACES.includes(workspaceName)
26
+ }