@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,164 @@
1
+ import { useState } from 'react'
2
+ import { Button } from '@campxdev/react-blueprint'
3
+ import { Phone, MicOff } from 'lucide-react'
4
+ import { useExotelSafe } from '../../providers/ExotelProvider'
5
+ import ExotelService from '../../services/exotel/ExotelService'
6
+ import { initiateCallActivity } from '../../services/exotel/api'
7
+ import CryptoService from '../../services/crypto/CryptoService'
8
+
9
+ interface CallButtonProps {
10
+ phoneNumber?: string
11
+ encryptedPhoneNumber?: string
12
+ callerName?: string
13
+ prospectId?: string
14
+ className?: string
15
+ disabled?: boolean
16
+ }
17
+
18
+ const CallButton = ({
19
+ phoneNumber,
20
+ encryptedPhoneNumber,
21
+ callerName,
22
+ prospectId,
23
+ className,
24
+ disabled,
25
+ }: CallButtonProps) => {
26
+ const exotel = useExotelSafe()
27
+ const [isDialing, setIsDialing] = useState(false)
28
+ const [error, setError] = useState<string | null>(null)
29
+
30
+ // If Exotel is not available (not within provider), don't render the button
31
+ if (!exotel) {
32
+ return null
33
+ }
34
+
35
+ const {
36
+ makeCall,
37
+ isConnected,
38
+ callState,
39
+ permissionStatus,
40
+ requestMicrophoneAccess,
41
+ setCallActivityId,
42
+ setExotelCallSid,
43
+ } = exotel
44
+
45
+ const handleCall = async () => {
46
+ // Allow calling from 'idle' or 'ended' states
47
+ if (!isConnected || (callState.phase !== 'idle' && callState.phase !== 'ended')) return
48
+
49
+ setError(null)
50
+
51
+ // Decrypt phone number if encrypted version is provided
52
+ let actualPhoneNumber = phoneNumber
53
+ if (encryptedPhoneNumber && !actualPhoneNumber) {
54
+ try {
55
+ actualPhoneNumber = CryptoService.decryptData(encryptedPhoneNumber)
56
+ if (!actualPhoneNumber) {
57
+ setError('Failed to decrypt phone number')
58
+ return
59
+ }
60
+ } catch (err) {
61
+ console.error('Decryption error:', err)
62
+ setError('Failed to decrypt phone number')
63
+ return
64
+ }
65
+ }
66
+
67
+ if (!actualPhoneNumber) {
68
+ setError('No phone number available')
69
+ return
70
+ }
71
+
72
+ // Create masked number for display in call popup
73
+ const maskedNumber = CryptoService.maskMobile(actualPhoneNumber)
74
+
75
+ // Check microphone permissions first
76
+ const hasPermission =
77
+ await ExotelService.getInstance().checkAudioPermissions()
78
+ if (!hasPermission) {
79
+ const granted = await requestMicrophoneAccess()
80
+ if (!granted) {
81
+ setError('Microphone access is required to make calls')
82
+ return
83
+ }
84
+ }
85
+
86
+ try {
87
+ setIsDialing(true)
88
+
89
+ // Step 1: Initiate call via SDK - this triggers Exotel's outbound_call API
90
+ // SDK returns response containing Data.CallSid
91
+ const sdkResponse = await makeCall(actualPhoneNumber, callerName, undefined, maskedNumber)
92
+
93
+ // Step 2: Extract CallSid from SDK response and store it immediately
94
+ // This is needed for cancel API if user declines before initiateCallActivity completes
95
+ const exotelCallSid = sdkResponse?.Data?.CallSid
96
+ if (exotelCallSid) {
97
+ setExotelCallSid(exotelCallSid)
98
+ }
99
+
100
+ // Step 3: Create call activity record in backend WITH the CallSid
101
+ // This allows webhook data to be matched with the call activity
102
+ if (prospectId && exotelCallSid) {
103
+ try {
104
+ const response = await initiateCallActivity({
105
+ prospectId,
106
+ toNumber: actualPhoneNumber,
107
+ callSid: exotelCallSid, // Pass CallSid when creating the record
108
+ })
109
+ // Update call state with the database ID for disposition
110
+ const callActivityId = response.data?.id
111
+ if (callActivityId) {
112
+ setCallActivityId(callActivityId)
113
+ }
114
+ } catch (apiError) {
115
+ console.error('[CallButton] Failed to create call activity record:', apiError)
116
+ // Continue even if record creation fails - call is already initiated
117
+ }
118
+ }
119
+ } catch (err: any) {
120
+ console.error('Failed to make call:', err)
121
+ setError(err.message || 'Failed to make call')
122
+ } finally {
123
+ setIsDialing(false)
124
+ }
125
+ }
126
+
127
+ // 'idle' and 'ended' both mean no active call - button should be enabled
128
+ const isCallInProgress = callState.phase !== 'idle' && callState.phase !== 'ended'
129
+
130
+ const hasPhoneNumber = phoneNumber || encryptedPhoneNumber
131
+ const isDisabled =
132
+ disabled || !hasPhoneNumber || !isConnected || isCallInProgress || isDialing
133
+
134
+ const getButtonTitle = () => {
135
+ if (!isConnected) return 'VoIP not connected'
136
+ if (isCallInProgress) return 'Call in progress'
137
+ if (!hasPhoneNumber) return 'No phone number'
138
+ if (permissionStatus === 'denied') return 'Microphone access denied'
139
+ return `Call ${callerName || 'prospect'}`
140
+ }
141
+
142
+ return (
143
+ <div className={className}>
144
+ <Button
145
+ variant="outline"
146
+ size="sm"
147
+ onClick={handleCall}
148
+ disabled={isDisabled}
149
+ title={getButtonTitle()}
150
+ className="gap-1.5"
151
+ >
152
+ {permissionStatus === 'denied' ? (
153
+ <MicOff className="w-4 h-4" />
154
+ ) : (
155
+ <Phone className="w-4 h-4" />
156
+ )}
157
+ {isDialing ? 'Calling...' : 'Call'}
158
+ </Button>
159
+ {error && <p className="text-red-500 text-xs mt-1">{error}</p>}
160
+ </div>
161
+ )
162
+ }
163
+
164
+ export default CallButton
@@ -0,0 +1,213 @@
1
+ import { Button, Dialog, RadioGroup, SingleSelect, Textarea, Typography } from '@campxdev/react-blueprint';
2
+ import { useState } from 'react';
3
+ import { useMutation, useQueryClient } from 'react-query';
4
+
5
+ import type { CallDispositionData } from '../../providers/ExotelProvider';
6
+ import { formatDurationVerbose } from '../../utils/exotel/formatters';
7
+
8
+ type CallStatusType = 'connected' | 'not_connected';
9
+
10
+ const CALL_STATUS_OPTIONS = [
11
+ { label: 'Connected', value: 'connected' },
12
+ { label: 'Not Connected', value: 'not_connected' },
13
+ ];
14
+
15
+ const CONNECTED_DISPOSITIONS = [
16
+ { label: 'Interested', value: 'interested' },
17
+ { label: 'Not Interested', value: 'not_interested' },
18
+ { label: 'Not Sure', value: 'not_sure' },
19
+ { label: 'Not Eligible', value: 'not_eligible' },
20
+ { label: 'Wrong Number', value: 'wrong_number' },
21
+ { label: 'Campus Visit', value: 'campus_visit' },
22
+ { label: 'Call Back', value: 'call_back' },
23
+ { label: 'After JEE Mains', value: 'after_jee_mains' },
24
+ { label: 'After IPE', value: 'after_ipe' },
25
+ { label: 'After EAPCET', value: 'after_eapcet' },
26
+ ];
27
+
28
+ const NOT_CONNECTED_DISPOSITIONS = [
29
+ { label: 'Not Answering', value: 'not_answering' },
30
+ { label: 'Not Connected', value: 'not_connected' },
31
+ { label: 'Fail to Call', value: 'fail_to_call' },
32
+ ];
33
+
34
+ export interface DispositionFormData {
35
+ prospectId?: string;
36
+ callId: string;
37
+ channel: string;
38
+ direction: string;
39
+ duration: number;
40
+ callStatusDisposition: CallStatusType;
41
+ dispositionReason?: string;
42
+ dispositionCategory: string;
43
+ remarks: string;
44
+ callerName: string;
45
+ callerNumber: string;
46
+ }
47
+
48
+ export interface ExistingDispositionData {
49
+ callStatusDisposition?: CallStatusType;
50
+ dispositionCategory?: string;
51
+ dispositionNotes?: string;
52
+ }
53
+
54
+ interface CallDispositionFormProps {
55
+ dispositionData: CallDispositionData;
56
+ prospectId?: string;
57
+ onClose: () => void;
58
+ onSave: (data: DispositionFormData) => Promise<any>;
59
+ existingData?: ExistingDispositionData;
60
+ }
61
+
62
+ const CallDispositionForm = ({
63
+ dispositionData,
64
+ prospectId,
65
+ onClose,
66
+ onSave,
67
+ existingData,
68
+ }: CallDispositionFormProps) => {
69
+ const queryClient = useQueryClient();
70
+ const isEditMode = !!existingData?.dispositionCategory;
71
+
72
+ // Simple state management instead of react-hook-form
73
+ const [callStatus, setCallStatus] = useState<CallStatusType | ''>(
74
+ existingData?.callStatusDisposition || '',
75
+ );
76
+ const [dispositionCategory, setDispositionCategory] = useState<string>(
77
+ existingData?.dispositionCategory || '',
78
+ );
79
+ const [remarks, setRemarks] = useState<string>(
80
+ existingData?.dispositionNotes || '',
81
+ );
82
+
83
+ const { mutate, isLoading } = useMutation(
84
+ (data: DispositionFormData) => onSave(data),
85
+ {
86
+ onSuccess: () => {
87
+ queryClient.invalidateQueries('getOneProspect');
88
+ queryClient.invalidateQueries('callHistory');
89
+ onClose();
90
+ },
91
+ },
92
+ );
93
+
94
+ const handleCallStatusChange = (value: string) => {
95
+ setCallStatus(value as CallStatusType);
96
+ // Reset disposition when call status changes
97
+ setDispositionCategory('');
98
+ };
99
+
100
+ const handleDispositionChange = (value: string) => {
101
+ setDispositionCategory(value);
102
+ };
103
+
104
+ const handleSubmit = () => {
105
+ if (!callStatus || !dispositionCategory) {
106
+ return;
107
+ }
108
+
109
+ const submitData: DispositionFormData = {
110
+ prospectId,
111
+ callId: dispositionData.callId,
112
+ channel: 'phone',
113
+ direction: dispositionData.callDirection,
114
+ duration: dispositionData.callDuration,
115
+ callStatusDisposition: callStatus,
116
+ dispositionCategory: dispositionCategory,
117
+ remarks: remarks || '',
118
+ callerName: dispositionData.callerName || '',
119
+ callerNumber: dispositionData.callerNumber || '',
120
+ };
121
+
122
+ mutate(submitData);
123
+ };
124
+
125
+ const dispositionOptions =
126
+ callStatus === 'connected'
127
+ ? CONNECTED_DISPOSITIONS
128
+ : callStatus === 'not_connected'
129
+ ? NOT_CONNECTED_DISPOSITIONS
130
+ : [];
131
+
132
+ return (
133
+ <Dialog
134
+ open={true}
135
+ onClose={onClose}
136
+ title={isEditMode ? 'Edit Disposition' : 'Call Disposition'}
137
+ maxWidth="md"
138
+ content={() => (
139
+ <div className="flex flex-col gap-4 p-4">
140
+ <Typography variant="muted">
141
+ {isEditMode
142
+ ? 'Update the disposition details'
143
+ : `Add details about your call with ${dispositionData.callerName || 'Unknown'}`}
144
+ </Typography>
145
+
146
+ {/* Call Duration */}
147
+ <div className="flex items-center justify-between p-3 bg-gray-100 rounded-lg">
148
+ <Typography variant="small" className="text-gray-600">
149
+ Call Duration
150
+ </Typography>
151
+ <Typography variant="small" className="font-semibold">
152
+ {formatDurationVerbose(dispositionData.callDuration)}
153
+ </Typography>
154
+ </div>
155
+
156
+ {/* Call Status */}
157
+ <RadioGroup
158
+ name="callStatusDisposition"
159
+ label="Call Status"
160
+ required
161
+ options={CALL_STATUS_OPTIONS}
162
+ direction="row"
163
+ value={callStatus}
164
+ onChange={handleCallStatusChange}
165
+ />
166
+
167
+ {/* Call Disposition */}
168
+ {callStatus && (
169
+ <SingleSelect
170
+ key={callStatus}
171
+ name="dispositionCategory"
172
+ label="Call Disposition"
173
+ required
174
+ fullWidth
175
+ placeholder="Search disposition..."
176
+ options={dispositionOptions}
177
+ value={dispositionCategory}
178
+ onChange={handleDispositionChange}
179
+ />
180
+ )}
181
+
182
+ {/* Remarks */}
183
+ <Textarea
184
+ name="remarks"
185
+ label="Remarks"
186
+ placeholder="Add any additional notes about this call..."
187
+ rows={3}
188
+ fullWidth
189
+ value={remarks}
190
+ onChange={(e) => setRemarks(e.target.value)}
191
+ className="break-all"
192
+ />
193
+
194
+ {/* Action Buttons */}
195
+ <div className="flex justify-start gap-2 pt-4 border-t">
196
+ <Button
197
+ onClick={handleSubmit}
198
+ disabled={!callStatus || !dispositionCategory}
199
+ loading={isLoading}
200
+ >
201
+ {isEditMode ? 'Update Disposition' : 'Save Disposition'}
202
+ </Button>
203
+ <Button variant="outline" onClick={onClose}>
204
+ Cancel
205
+ </Button>
206
+ </div>
207
+ </div>
208
+ )}
209
+ />
210
+ );
211
+ };
212
+
213
+ export default CallDispositionForm;