@campxdev/campx-web-utils 2.0.14 → 2.0.16

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.
@@ -1,4 +1,4 @@
1
- import { type DispositionFormData } from './CallDispositionForm';
1
+ import { DispositionFormData } from './CallDispositionForm';
2
2
  interface ExotelPhoneProps {
3
3
  className?: string;
4
4
  prospectId?: string;
@@ -6,5 +6,5 @@ interface ExotelPhoneProps {
6
6
  prospectNumber?: string;
7
7
  onSaveDisposition?: (data: DispositionFormData) => Promise<any>;
8
8
  }
9
- declare const ExotelPhone: ({ className, prospectId, prospectName, prospectNumber, onSaveDisposition }: ExotelPhoneProps) => import("react/jsx-runtime").JSX.Element;
9
+ declare const ExotelPhone: ({ className, prospectId, prospectName, prospectNumber, onSaveDisposition, }: ExotelPhoneProps) => import("react/jsx-runtime").JSX.Element;
10
10
  export default ExotelPhone;
@@ -84,6 +84,7 @@ export interface SaveDispositionInput {
84
84
  prospectId?: string;
85
85
  callStatusDisposition?: 'connected' | 'not_connected';
86
86
  dispositionCategory: string;
87
+ prospectStatus?: string;
87
88
  dispositionReason?: string;
88
89
  dispositionNotes?: string;
89
90
  callbackScheduledAt?: string;
package/dist/index.d.ts CHANGED
@@ -305,6 +305,7 @@ interface SaveDispositionInput {
305
305
  prospectId?: string;
306
306
  callStatusDisposition?: 'connected' | 'not_connected';
307
307
  dispositionCategory: string;
308
+ prospectStatus?: string;
308
309
  dispositionReason?: string;
309
310
  dispositionNotes?: string;
310
311
  callbackScheduledAt?: string;
@@ -491,7 +492,7 @@ interface ExotelPhoneProps {
491
492
  prospectNumber?: string;
492
493
  onSaveDisposition?: (data: DispositionFormData) => Promise<any>;
493
494
  }
494
- declare const ExotelPhone: ({ className, prospectId, prospectName, prospectNumber, onSaveDisposition }: ExotelPhoneProps) => react_jsx_runtime.JSX.Element | null;
495
+ declare const ExotelPhone: ({ className, prospectId, prospectName, prospectNumber, onSaveDisposition, }: ExotelPhoneProps) => react_jsx_runtime.JSX.Element | null;
495
496
 
496
497
  interface CallButtonProps {
497
498
  phoneNumber?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@campxdev/campx-web-utils",
3
- "version": "2.0.14",
3
+ "version": "2.0.16",
4
4
  "author": "CampX",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -118,7 +118,41 @@ const CallButton = ({
118
118
  }
119
119
  } catch (err: any) {
120
120
  console.error('Failed to make call:', err)
121
- setError(err.message || 'Failed to make call')
121
+
122
+ // Parse Exotel error response
123
+ let errorMessage = 'Failed to make call'
124
+
125
+ if (err?.Code || err?.Status === 'Failed') {
126
+ // Exotel API error response
127
+ const code = err.Code
128
+
129
+ // Try to parse the Error field if it's a JSON string
130
+ let errorDetails = null
131
+ if (err.Error && typeof err.Error === 'string') {
132
+ try {
133
+ errorDetails = JSON.parse(err.Error)
134
+ } catch (parseErr) {
135
+ // Not JSON, ignore
136
+ }
137
+ }
138
+
139
+ // Get description from parsed error or use default messages
140
+ const description = errorDetails?.response?.error_data?.description
141
+
142
+ if (code === 404) {
143
+ errorMessage = description || 'User device is currently busy or unavailable'
144
+ } else if (code >= 400 && code < 500) {
145
+ errorMessage = description || 'Unable to complete call. Please try again.'
146
+ } else if (code >= 500) {
147
+ errorMessage = description || 'Service temporarily unavailable. Please try again later.'
148
+ } else {
149
+ errorMessage = description || 'Failed to make call'
150
+ }
151
+ } else if (err?.message) {
152
+ errorMessage = err.message
153
+ }
154
+
155
+ setError(errorMessage)
122
156
  } finally {
123
157
  setIsDialing(false)
124
158
  }
@@ -69,7 +69,7 @@ const CallDispositionForm = ({
69
69
  const queryClient = useQueryClient();
70
70
  const isEditMode = !!existingData?.dispositionCategory;
71
71
 
72
- // Simple state management instead of react-hook-form
72
+ // Simple state management
73
73
  const [callStatus, setCallStatus] = useState<CallStatusType | ''>(
74
74
  existingData?.callStatusDisposition || '',
75
75
  );
@@ -1,37 +1,20 @@
1
- import { useState, useEffect, useRef } from 'react'
2
- import { createPortal } from 'react-dom'
1
+ import { Button, Typography } from '@campxdev/react-blueprint';
2
+ import { Mic, MicOff, Pause, Phone, PhoneIncoming, PhoneOff, PhoneOutgoing, Play, Volume2, VolumeX, X } from 'lucide-react';
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { createPortal } from 'react-dom';
3
5
 
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'
6
+ import { EXOTEL_CONSTANTS } from '../../constants/exotel.constants';
7
+ import { useExotel } from '../../providers/ExotelProvider';
8
+ import { cancelCall, saveCallDisposition } from '../../services/exotel/api';
9
+ import { formatDurationTimer } from '../../utils/exotel/formatters';
10
+ import CallDispositionForm, { DispositionFormData } from './CallDispositionForm';
28
11
 
29
12
  interface ExotelPhoneProps {
30
- className?: string
31
- prospectId?: string
32
- prospectName?: string
33
- prospectNumber?: string
34
- onSaveDisposition?: (data: DispositionFormData) => Promise<any>
13
+ className?: string;
14
+ prospectId?: string;
15
+ prospectName?: string;
16
+ prospectNumber?: string;
17
+ onSaveDisposition?: (data: DispositionFormData) => Promise<any>;
35
18
  }
36
19
 
37
20
  // CSS for pulsing animation
@@ -67,9 +50,15 @@ const pulseAnimationStyle = `
67
50
  .animate-pulse-dot {
68
51
  animation: pulse-dot 1.5s ease-in-out infinite;
69
52
  }
70
- `
71
-
72
- const ExotelPhone = ({ className, prospectId, prospectName, prospectNumber, onSaveDisposition }: ExotelPhoneProps) => {
53
+ `;
54
+
55
+ const ExotelPhone = ({
56
+ className,
57
+ prospectId,
58
+ prospectName,
59
+ prospectNumber,
60
+ onSaveDisposition,
61
+ }: ExotelPhoneProps) => {
73
62
  const {
74
63
  callState,
75
64
  dispositionData,
@@ -80,22 +69,22 @@ const ExotelPhone = ({ className, prospectId, prospectName, prospectNumber, onSa
80
69
  toggleMute,
81
70
  toggleSpeaker,
82
71
  clearDisposition,
83
- } = useExotel()
72
+ } = useExotel();
84
73
 
85
- const [isAccepting, setIsAccepting] = useState(false)
86
- const [isDeclining, setIsDeclining] = useState(false)
87
- const declineTimeoutRef = useRef<NodeJS.Timeout | null>(null)
74
+ const [isAccepting, setIsAccepting] = useState(false);
75
+ const [isDeclining, setIsDeclining] = useState(false);
76
+ const declineTimeoutRef = useRef<NodeJS.Timeout | null>(null);
88
77
 
89
78
  // Inject animation styles
90
79
  useEffect(() => {
91
- const styleId = 'exotel-phone-animations'
80
+ const styleId = 'exotel-phone-animations';
92
81
  if (!document.getElementById(styleId)) {
93
- const styleEl = document.createElement('style')
94
- styleEl.id = styleId
95
- styleEl.textContent = pulseAnimationStyle
96
- document.head.appendChild(styleEl)
82
+ const styleEl = document.createElement('style');
83
+ styleEl.id = styleId;
84
+ styleEl.textContent = pulseAnimationStyle;
85
+ document.head.appendChild(styleEl);
97
86
  }
98
- }, [])
87
+ }, []);
99
88
 
100
89
  const {
101
90
  phase,
@@ -110,43 +99,43 @@ const ExotelPhone = ({ className, prospectId, prospectName, prospectNumber, onSa
110
99
  callActivityId,
111
100
  exotelCallSid,
112
101
  prospectUniqueId,
113
- } = callState
102
+ } = callState;
114
103
 
115
104
  // 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'
105
+ const displayName = prospectName || sdkCallerName || 'Unknown';
106
+ const displayNumber = prospectNumber || sdkCallerNumber || 'No number';
118
107
 
119
108
  const handleAcceptCall = async () => {
120
- setIsAccepting(true)
109
+ setIsAccepting(true);
121
110
  try {
122
- await acceptCall()
111
+ await acceptCall();
123
112
  } catch (error: any) {
124
- console.error('Accept call error:', error)
113
+ console.error('Accept call error:', error);
125
114
  } finally {
126
- setIsAccepting(false)
115
+ setIsAccepting(false);
127
116
  }
128
- }
117
+ };
129
118
 
130
119
  const handleDeclineCall = async () => {
131
- setIsDeclining(true)
120
+ setIsDeclining(true);
132
121
  // Use rejectCall for incoming/ready_to_call (before accepting)
133
122
  // Use hangupCall for connected/calling (after accepting)
134
123
  if (phase === 'incoming' || phase === 'ready_to_call') {
135
- rejectCall()
124
+ rejectCall();
136
125
  } else {
137
- hangupCall()
126
+ hangupCall();
138
127
  }
139
128
 
140
129
  // Call backend API to update call activity status
141
130
  // Use exotelCallSid (from outbound_call API) for outbound calls
142
131
  // Use callActivityId as fallback identifier
143
- const callSidForApi = exotelCallSid || callActivityId
132
+ const callSidForApi = exotelCallSid || callActivityId;
144
133
  if (callSidForApi) {
145
134
  try {
146
- const reason = phase === 'connected' ? 'ended' : 'declined'
147
- await cancelCall({ callSid: callSidForApi, reason })
135
+ const reason = phase === 'connected' ? 'ended' : 'declined';
136
+ await cancelCall({ callSid: callSidForApi, reason });
148
137
  } catch (error) {
149
- console.error('Failed to cancel call in backend:', error)
138
+ console.error('Failed to cancel call in backend:', error);
150
139
  }
151
140
  }
152
141
 
@@ -154,27 +143,27 @@ const ExotelPhone = ({ className, prospectId, prospectName, prospectNumber, onSa
154
143
  // Set a timeout to force reset the declining state
155
144
  // Note: Don't clear disposition here - let the SDK's callEnded event handle it
156
145
  declineTimeoutRef.current = setTimeout(() => {
157
- setIsDeclining(false)
158
- }, EXOTEL_CONSTANTS.DECLINE_TIMEOUT_MS)
159
- }
146
+ setIsDeclining(false);
147
+ }, EXOTEL_CONSTANTS.DECLINE_TIMEOUT_MS);
148
+ };
160
149
 
161
150
  // Clear decline timeout when phase changes (meaning SDK handled it)
162
151
  useEffect(() => {
163
152
  if (phase === 'idle' || phase === 'ended') {
164
153
  if (declineTimeoutRef.current) {
165
- clearTimeout(declineTimeoutRef.current)
166
- declineTimeoutRef.current = null
154
+ clearTimeout(declineTimeoutRef.current);
155
+ declineTimeoutRef.current = null;
167
156
  }
168
- setIsDeclining(false)
157
+ setIsDeclining(false);
169
158
  // Note: Don't auto-clear disposition here - let user submit or close the form manually
170
159
  }
171
- }, [phase])
160
+ }, [phase]);
172
161
 
173
162
  // Handle saving disposition to backend
174
163
  const handleSaveDisposition = async (data: DispositionFormData) => {
175
164
  // If custom handler is provided, use it
176
165
  if (onSaveDisposition) {
177
- return onSaveDisposition(data)
166
+ return onSaveDisposition(data);
178
167
  }
179
168
 
180
169
  // Use the callStatusDisposition from the form (user selected)
@@ -184,29 +173,30 @@ const ExotelPhone = ({ className, prospectId, prospectName, prospectNumber, onSa
184
173
  callStatusDisposition: data.callStatusDisposition,
185
174
  dispositionCategory: data.dispositionCategory,
186
175
  dispositionNotes: data.remarks || undefined,
187
- })
188
- }
176
+ });
177
+ };
189
178
 
190
179
  // Cleanup timeout on unmount
191
180
  useEffect(() => {
192
181
  return () => {
193
182
  if (declineTimeoutRef.current) {
194
- clearTimeout(declineTimeoutRef.current)
183
+ clearTimeout(declineTimeoutRef.current);
195
184
  }
196
- }
197
- }, [])
185
+ };
186
+ }, []);
198
187
 
199
188
  // Show disposition form after call ends
200
189
  if (dispositionData) {
201
190
  // Use prospect name from props, or from dispositionData (preserved from call), or fallback
202
- const finalCallerName = prospectName || dispositionData.callerName || sdkCallerName || 'Unknown'
191
+ const finalCallerName =
192
+ prospectName || dispositionData.callerName || sdkCallerName || 'Unknown';
203
193
 
204
194
  // Override disposition data with prospect details
205
195
  const enrichedDispositionData = {
206
196
  ...dispositionData,
207
197
  callerName: finalCallerName,
208
198
  callerNumber: displayNumber,
209
- }
199
+ };
210
200
 
211
201
  return (
212
202
  <CallDispositionForm
@@ -214,17 +204,18 @@ const ExotelPhone = ({ className, prospectId, prospectName, prospectNumber, onSa
214
204
  prospectId={prospectId}
215
205
  onClose={clearDisposition}
216
206
  onSave={handleSaveDisposition}
207
+ existingData={undefined}
217
208
  />
218
- )
209
+ );
219
210
  }
220
211
 
221
212
  // Don't show anything if idle or ended
222
213
  if (phase === 'idle' || phase === 'ended') {
223
- return null
214
+ return null;
224
215
  }
225
216
 
226
- const isOutbound = callDirection === 'outbound'
227
- const isInbound = callDirection === 'inbound'
217
+ const isOutbound = callDirection === 'outbound';
218
+ const isInbound = callDirection === 'inbound';
228
219
 
229
220
  // Get avatar color based on call direction
230
221
  const avatarBgColor =
@@ -232,31 +223,31 @@ const ExotelPhone = ({ className, prospectId, prospectName, prospectNumber, onSa
232
223
  ? 'bg-green-500'
233
224
  : isOutbound
234
225
  ? 'bg-blue-500'
235
- : 'bg-orange-500'
226
+ : 'bg-orange-500';
236
227
 
237
228
  // Get status text
238
229
  const getStatusText = () => {
239
230
  switch (phase) {
240
231
  case 'ready_to_call':
241
- return 'Ready to call...'
232
+ return 'Ready to call...';
242
233
  case 'calling':
243
- return 'Calling...'
234
+ return 'Calling...';
244
235
  case 'incoming':
245
- return 'Incoming call...'
236
+ return 'Incoming call...';
246
237
  case 'connected':
247
- return null // Will show timer instead
238
+ return null; // Will show timer instead
248
239
  default:
249
- return null
240
+ return null;
250
241
  }
251
- }
242
+ };
252
243
 
253
244
  // Get connecting text for calling state
254
245
  const getConnectingText = () => {
255
246
  if (phase === 'calling') {
256
- return `Connecting to ${displayName}...`
247
+ return `Connecting to ${displayName}...`;
257
248
  }
258
- return null
259
- }
249
+ return null;
250
+ };
260
251
 
261
252
  const modalContent = (
262
253
  <>
@@ -273,14 +264,17 @@ const ExotelPhone = ({ className, prospectId, prospectName, prospectNumber, onSa
273
264
  style={{ zIndex: EXOTEL_CONSTANTS.MODAL_Z_INDEX }}
274
265
  >
275
266
  <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' }}
267
+ className="relative w-full max-w-md p-6 bg-white shadow-2xl"
268
+ style={{
269
+ borderRadius: EXOTEL_CONSTANTS.MODAL_BORDER_RADIUS,
270
+ border: '1px solid #e5e7eb',
271
+ }}
278
272
  onClick={(e) => e.stopPropagation()}
279
273
  >
280
274
  {/* Close button */}
281
275
  <button
282
276
  onClick={handleDeclineCall}
283
- className="absolute top-4 right-4 p-1 hover:bg-gray-100 rounded-full transition-colors"
277
+ className="absolute p-1 transition-colors rounded-full top-4 right-4 hover:bg-gray-100"
284
278
  >
285
279
  <X className="w-5 h-5 text-gray-500" />
286
280
  </button>
@@ -321,20 +315,28 @@ const ExotelPhone = ({ className, prospectId, prospectName, prospectNumber, onSa
321
315
  </div>
322
316
 
323
317
  {/* Caller info */}
324
- <div className="text-center mb-4">
325
- <Typography variant="h3" className="text-xl font-semibold text-gray-900">
318
+ <div className="mb-4 text-center">
319
+ <Typography
320
+ variant="h3"
321
+ className="text-xl font-semibold text-gray-900"
322
+ >
326
323
  {displayName}
327
324
  </Typography>
328
325
  {prospectUniqueId && (
329
- <Typography variant="small" className="text-blue-600 font-medium">ID: {prospectUniqueId}</Typography>
326
+ <Typography variant="small" className="font-medium text-blue-600">
327
+ ID: {prospectUniqueId}
328
+ </Typography>
330
329
  )}
331
330
  <Typography variant="muted">{displayNumber}</Typography>
332
331
  </div>
333
332
 
334
333
  {/* Status / Timer */}
335
- <div className="text-center mb-6">
334
+ <div className="mb-6 text-center">
336
335
  {phase === 'connected' ? (
337
- <Typography variant="p" className="text-green-500 text-lg font-medium">
336
+ <Typography
337
+ variant="p"
338
+ className="text-lg font-medium text-green-500"
339
+ >
338
340
  {formatDurationTimer(callDuration)}
339
341
  </Typography>
340
342
  ) : (
@@ -361,7 +363,7 @@ const ExotelPhone = ({ className, prospectId, prospectName, prospectNumber, onSa
361
363
  <Mic className="w-5 h-5 text-gray-600" />
362
364
  )}
363
365
  </button>
364
- <p className="text-xs text-gray-500 mt-1">
366
+ <p className="mt-1 text-xs text-gray-500">
365
367
  {isMuted ? 'Unmute' : 'Mute'}
366
368
  </p>
367
369
  </div>
@@ -382,7 +384,7 @@ const ExotelPhone = ({ className, prospectId, prospectName, prospectNumber, onSa
382
384
  <Pause className="w-5 h-5 text-gray-600" />
383
385
  )}
384
386
  </button>
385
- <p className="text-xs text-gray-500 mt-1">
387
+ <p className="mt-1 text-xs text-gray-500">
386
388
  {isOnHold ? 'Resume' : 'Hold'}
387
389
  </p>
388
390
  </div>
@@ -403,7 +405,7 @@ const ExotelPhone = ({ className, prospectId, prospectName, prospectNumber, onSa
403
405
  <Volume2 className="w-5 h-5 text-gray-600" />
404
406
  )}
405
407
  </button>
406
- <p className="text-xs text-gray-500 mt-1">
408
+ <p className="mt-1 text-xs text-gray-500">
407
409
  {isSpeakerOff ? 'Speaker On' : 'Speaker Off'}
408
410
  </p>
409
411
  </div>
@@ -466,17 +468,20 @@ const ExotelPhone = ({ className, prospectId, prospectName, prospectNumber, onSa
466
468
 
467
469
  {/* Connecting text */}
468
470
  {getConnectingText() && (
469
- <Typography variant="small" className="text-center text-gray-500 mt-4">
471
+ <Typography
472
+ variant="small"
473
+ className="mt-4 text-center text-gray-500"
474
+ >
470
475
  {getConnectingText()}
471
476
  </Typography>
472
477
  )}
473
478
  </div>
474
479
  </div>
475
480
  </>
476
- )
481
+ );
477
482
 
478
483
  // Use portal to render at document body level to escape any parent stacking contexts
479
- return createPortal(modalContent, document.body)
480
- }
484
+ return createPortal(modalContent, document.body);
485
+ };
481
486
 
482
- export default ExotelPhone
487
+ export default ExotelPhone;
@@ -422,8 +422,16 @@ export const ExotelProvider: React.FC<ExotelProviderProps> = ({
422
422
  callDuration: 0,
423
423
  }))
424
424
 
425
- const response = await exotelService.makeCall(phoneNumber) // Use actual number for calling
426
- return response
425
+ try {
426
+ const response = await exotelService.makeCall(phoneNumber) // Use actual number for calling
427
+ return response
428
+ } catch (error: any) {
429
+ // If SDK call fails (4xx/5xx errors from Exotel API), reset state to idle
430
+ // This allows the user to retry the call
431
+ setCallState(initialCallState)
432
+ // Re-throw the error so CallButton can handle it
433
+ throw error
434
+ }
427
435
  },
428
436
  [defaultCallerName],
429
437
  )