@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,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'
|
package/src/config/index.ts
CHANGED
|
@@ -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
|
+
}
|