@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,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;
|