@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,112 @@
1
+ import { syncScrypt } from 'scrypt-js'
2
+
3
+ class CryptoService {
4
+ private static instance: CryptoService
5
+ private readonly algorithm = 'aes-256-cbc'
6
+ private secretKey: Uint8Array | null = null
7
+ private initialized = false
8
+
9
+ private constructor() {
10
+ // Lazy initialization - don't run heavy crypto operations at module load time
11
+ }
12
+
13
+ private ensureInitialized(): void {
14
+ if (this.initialized) return
15
+
16
+ try {
17
+ const key = '431fef99-47dc-4d2a-9175-2e8f19b20225'
18
+ if (key) {
19
+ // Same as backend: crypto.scryptSync(key, 'salt', 32)
20
+ // scrypt-js with N=16384, r=8, p=1 matches Node.js defaults
21
+ const keyBuffer = new TextEncoder().encode(key)
22
+ const saltBuffer = new TextEncoder().encode('salt')
23
+ this.secretKey = syncScrypt(keyBuffer, saltBuffer, 16384, 8, 1, 32)
24
+ }
25
+ this.initialized = true
26
+ } catch (error) {
27
+ console.error('CryptoService initialization failed:', error)
28
+ this.initialized = true // Prevent repeated attempts
29
+ }
30
+ }
31
+
32
+ static getInstance(): CryptoService {
33
+ if (!CryptoService.instance) {
34
+ CryptoService.instance = new CryptoService()
35
+ }
36
+ return CryptoService.instance
37
+ }
38
+
39
+ /**
40
+ * Decrypt data using AES-256-CBC (same as backend)
41
+ * Format: "iv_hex:encrypted_hex"
42
+ */
43
+ decryptData(encryptedData: string): string {
44
+ this.ensureInitialized()
45
+
46
+ if (!encryptedData || !this.secretKey) {
47
+ console.error('Missing encrypted data or secret key')
48
+ return ''
49
+ }
50
+
51
+ try {
52
+ const [ivHex, encrypted] = encryptedData.split(':')
53
+ if (!ivHex || !encrypted) {
54
+ console.error('Invalid encrypted data format')
55
+ return ''
56
+ }
57
+
58
+ // Use require for crypto to avoid bundler issues with top-level imports
59
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
60
+ const crypto = require('crypto')
61
+
62
+ const iv = Buffer.from(ivHex, 'hex')
63
+ const decipher = crypto.createDecipheriv(
64
+ this.algorithm,
65
+ Buffer.from(this.secretKey),
66
+ iv,
67
+ )
68
+
69
+ let decrypted = decipher.update(encrypted, 'hex', 'utf8')
70
+ decrypted += decipher.final('utf8')
71
+
72
+ return decrypted
73
+ } catch (error) {
74
+ console.error('Decryption failed:', error)
75
+ return ''
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Mask mobile number for display (first 2 and last 2 of actual number visible)
81
+ * Handles country code prefix (+91)
82
+ * e.g., "+919876543210" -> "+9198******10"
83
+ * e.g., "9876543210" -> "98******10"
84
+ */
85
+ maskMobile(mobile: string): string {
86
+ if (!mobile || mobile.length < 4) return '**********'
87
+
88
+ // Check for country code prefix
89
+ let prefix = ''
90
+ let number = mobile
91
+
92
+ if (mobile.startsWith('+91')) {
93
+ prefix = '+91'
94
+ number = mobile.substring(3)
95
+ } else if (mobile.startsWith('91') && mobile.length > 10) {
96
+ prefix = '91'
97
+ number = mobile.substring(2)
98
+ }
99
+
100
+ const cleaned = number.replace(/\D/g, '')
101
+ if (cleaned.length < 4) return prefix + '**********'
102
+
103
+ return (
104
+ prefix +
105
+ cleaned.substring(0, 2) +
106
+ '*'.repeat(cleaned.length - 4) +
107
+ cleaned.substring(cleaned.length - 2)
108
+ )
109
+ }
110
+ }
111
+
112
+ export default CryptoService.getInstance()
@@ -0,0 +1,238 @@
1
+ import ExotelCRMWebSDK, {
2
+ CallEventData,
3
+ ExotelWebPhoneSDK,
4
+ } from '@exotel-npm-dev/exotel-ip-calling-crm-websdk';
5
+
6
+ export type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unknown';
7
+
8
+ class ExotelService {
9
+ private static instance: ExotelService;
10
+ private exotelSDK: ExotelCRMWebSDK | null = null;
11
+ private webPhone: ExotelWebPhoneSDK | null = null;
12
+ private callListeners: Set<(event: string, data: CallEventData) => void> =
13
+ new Set();
14
+ private registerListeners: Set<(state: string) => void> = new Set();
15
+ private sessionListeners: Set<(state: string, data: any) => void> = new Set();
16
+ private permissionStatus: PermissionStatus = 'unknown';
17
+
18
+ public static getInstance(): ExotelService {
19
+ if (!ExotelService.instance) {
20
+ ExotelService.instance = new ExotelService();
21
+ }
22
+ return ExotelService.instance;
23
+ }
24
+
25
+ /**
26
+ * Initialize the Exotel SDK
27
+ * @param accessToken - The access token for authentication
28
+ * @param agentUserId - The user ID of the agent
29
+ * @param autoConnectVOIP - Whether to automatically connect VOIP (default: true)
30
+ * @returns boolean indicating success
31
+ */
32
+ public async initialize(
33
+ accessToken: string,
34
+ agentUserId: string,
35
+ autoConnectVOIP = true,
36
+ ): Promise<boolean> {
37
+ try {
38
+ await this.checkAudioPermissions();
39
+
40
+ this.exotelSDK = new ExotelCRMWebSDK(
41
+ accessToken,
42
+ agentUserId,
43
+ autoConnectVOIP,
44
+ );
45
+
46
+ const phone = await this.exotelSDK.Initialize(
47
+ this.handleCallEvent,
48
+ this.handleRegisterEvent,
49
+ this.handleSessionEvent,
50
+ );
51
+
52
+ this.webPhone = phone || null;
53
+
54
+ return !!this.webPhone;
55
+ } catch (error) {
56
+ console.error('Failed to initialize Exotel SDK:', error);
57
+ return false;
58
+ }
59
+ }
60
+
61
+ public async checkAudioPermissions(): Promise<boolean> {
62
+ try {
63
+ const devices = await navigator.mediaDevices.enumerateDevices();
64
+ const hasAudioDevice = devices.some(
65
+ (device) => device.kind === 'audioinput',
66
+ );
67
+
68
+ if (!hasAudioDevice) {
69
+ console.error('No audio input devices found');
70
+ this.permissionStatus = 'denied';
71
+ return false;
72
+ }
73
+
74
+ if (navigator.permissions && navigator.permissions.query) {
75
+ try {
76
+ const permissionStatus = await navigator.permissions.query({
77
+ name: 'microphone' as PermissionName,
78
+ });
79
+ this.permissionStatus = permissionStatus.state as
80
+ | 'granted'
81
+ | 'denied'
82
+ | 'prompt';
83
+
84
+ permissionStatus.onchange = () => {
85
+ this.permissionStatus = permissionStatus.state as
86
+ | 'granted'
87
+ | 'denied'
88
+ | 'prompt';
89
+ };
90
+
91
+ return permissionStatus.state === 'granted';
92
+ } catch (error) {
93
+ console.warn('Could not query permission status:', error);
94
+ }
95
+ }
96
+
97
+ return true;
98
+ } catch (error) {
99
+ console.error('Error checking audio permissions:', error);
100
+ return false;
101
+ }
102
+ }
103
+
104
+ public async requestMicrophoneAccess(): Promise<boolean> {
105
+ try {
106
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
107
+ stream.getTracks().forEach((track) => track.stop());
108
+ this.permissionStatus = 'granted';
109
+ return true;
110
+ } catch (error) {
111
+ console.error('Failed to get microphone access:', error);
112
+ this.permissionStatus = 'denied';
113
+ return false;
114
+ }
115
+ }
116
+
117
+ public makeCall(phoneNumber: string): Promise<any> {
118
+ return new Promise((resolve, reject) => {
119
+ if (!this.webPhone) {
120
+ reject(new Error('Web phone not initialized'));
121
+ return;
122
+ }
123
+
124
+ this.webPhone.MakeCall(phoneNumber, (status, data) => {
125
+ if (status === 'success') {
126
+ resolve(data);
127
+ } else {
128
+ reject(data);
129
+ }
130
+ });
131
+ });
132
+ }
133
+
134
+ public async acceptCall(): Promise<void> {
135
+ if (!this.webPhone) {
136
+ throw new Error('Web phone not initialized');
137
+ }
138
+
139
+ if (this.permissionStatus !== 'granted') {
140
+ const granted = await this.requestMicrophoneAccess();
141
+ if (!granted) {
142
+ throw new Error('Microphone access denied');
143
+ }
144
+ }
145
+
146
+ this.webPhone.AcceptCall();
147
+ }
148
+
149
+ public hangupCall(): void {
150
+ this.webPhone?.HangupCall();
151
+ }
152
+
153
+ /**
154
+ * Reject an incoming call before accepting it.
155
+ * Note: Exotel SDK only has HangupCall - no separate RejectCall method.
156
+ * HangupCall internally calls webrtcSIPPhone.rejectCall() -> phone.sipHangUp()
157
+ */
158
+ public rejectCall(): void {
159
+ this.webPhone?.HangupCall();
160
+ }
161
+
162
+ public toggleHold(): void {
163
+ this.webPhone?.ToggleHold();
164
+ }
165
+
166
+ public toggleMute(): void {
167
+ this.webPhone?.ToggleMute();
168
+ }
169
+
170
+ public sendDTMF(digit: string): void {
171
+ this.webPhone?.SendDTMF(digit);
172
+ }
173
+
174
+ public async registerDevice(): Promise<void> {
175
+ if (this.permissionStatus !== 'granted') {
176
+ const granted = await this.requestMicrophoneAccess();
177
+ if (!granted) {
178
+ throw new Error('Microphone access denied');
179
+ }
180
+ }
181
+
182
+ this.webPhone?.RegisterDevice();
183
+ }
184
+
185
+ public unregisterDevice(): void {
186
+ this.webPhone?.UnRegisterDevice();
187
+ }
188
+
189
+ public getPermissionStatus(): PermissionStatus {
190
+ return this.permissionStatus;
191
+ }
192
+
193
+ private handleCallEvent = (event: string, callData: CallEventData): void => {
194
+ this.callListeners.forEach((listener) => listener(event, callData));
195
+ };
196
+
197
+ private handleRegisterEvent = (state: string): void => {
198
+ this.registerListeners.forEach((listener) => listener(state));
199
+ };
200
+
201
+ private handleSessionEvent = (state: string, data: any): void => {
202
+ this.sessionListeners.forEach((listener) => listener(state, data));
203
+ };
204
+
205
+ public addCallListener(
206
+ listener: (event: string, data: CallEventData) => void,
207
+ ): void {
208
+ this.callListeners.add(listener);
209
+ }
210
+
211
+ public removeCallListener(
212
+ listener: (event: string, data: CallEventData) => void,
213
+ ): void {
214
+ this.callListeners.delete(listener);
215
+ }
216
+
217
+ public addRegisterListener(listener: (state: string) => void): void {
218
+ this.registerListeners.add(listener);
219
+ }
220
+
221
+ public removeRegisterListener(listener: (state: string) => void): void {
222
+ this.registerListeners.delete(listener);
223
+ }
224
+
225
+ public addSessionListener(
226
+ listener: (state: string, data: any) => void,
227
+ ): void {
228
+ this.sessionListeners.add(listener);
229
+ }
230
+
231
+ public removeSessionListener(
232
+ listener: (state: string, data: any) => void,
233
+ ): void {
234
+ this.sessionListeners.delete(listener);
235
+ }
236
+ }
237
+
238
+ export default ExotelService;
@@ -0,0 +1,319 @@
1
+ import { axios } from '../../config/axios'
2
+ import { workspace } from '../../utils/constants'
3
+
4
+ /**
5
+ * Voice config from notification-configs API
6
+ */
7
+ export interface VoiceConfig {
8
+ id: string
9
+ isEnabled: boolean
10
+ name: string
11
+ channelType: string
12
+ EXOTEL?: {
13
+ appId: string
14
+ appSecret: string
15
+ token?: string
16
+ tokenExpiresAt?: string
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Notification config response structure
22
+ */
23
+ export interface NotificationConfigResponse {
24
+ config: {
25
+ id: string
26
+ tenantId: string
27
+ institutionUniqueId: number
28
+ enableVoice?: boolean
29
+ voiceConfigs?: VoiceConfig[]
30
+ // ... other fields not relevant to VoIP
31
+ }
32
+ options: {
33
+ voiceChannels?: string[]
34
+ // ... other fields
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Check if VoIP is enabled for the current tenant by fetching notification config.
40
+ * Returns true only if:
41
+ * - enableVoice is true
42
+ * - voiceConfigs has at least one enabled config
43
+ */
44
+ export const checkVoIPEnabled = async (): Promise<{
45
+ enabled: boolean
46
+ hasValidToken: boolean
47
+ }> => {
48
+ try {
49
+ const { data } = await axios.get<NotificationConfigResponse>(
50
+ '/notifications/notification-configs',
51
+ )
52
+
53
+ const config = data?.config
54
+
55
+ // Check if voice is enabled at tenant level
56
+ if (!config?.enableVoice) {
57
+ return { enabled: false, hasValidToken: false }
58
+ }
59
+
60
+ // Check if there's at least one enabled voice config
61
+ const enabledVoiceConfig = config.voiceConfigs?.find(
62
+ (vc) => vc.isEnabled === true,
63
+ )
64
+
65
+ if (!enabledVoiceConfig) {
66
+ return { enabled: false, hasValidToken: false }
67
+ }
68
+
69
+ // Check if token exists and is not expired
70
+ const hasValidToken = !!(
71
+ enabledVoiceConfig.EXOTEL?.token &&
72
+ enabledVoiceConfig.EXOTEL?.tokenExpiresAt &&
73
+ new Date(enabledVoiceConfig.EXOTEL.tokenExpiresAt) > new Date()
74
+ )
75
+
76
+ return { enabled: true, hasValidToken }
77
+ } catch (error) {
78
+ console.error('Failed to check VoIP config:', error)
79
+ return { enabled: false, hasValidToken: false }
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Get the active Exotel voice token from the backend.
85
+ * The backend manages credentials securely and auto-refreshes tokens.
86
+ */
87
+ export const getActiveVoiceToken = async (): Promise<{
88
+ token: string
89
+ expiresAt: string
90
+ } | null> => {
91
+ try {
92
+ const { data } = await axios.get(
93
+ '/notifications/notification-configs/voice-config/active-token',
94
+ )
95
+ return data
96
+ } catch (error) {
97
+ console.error('Failed to fetch Exotel token:', error)
98
+ return null
99
+ }
100
+ }
101
+
102
+ /**
103
+ * @deprecated Use getActiveVoiceToken instead.
104
+ * This function is kept for backwards compatibility but will be removed.
105
+ */
106
+ export const createExotelAppToken = async () => {
107
+ const tokenData = await getActiveVoiceToken()
108
+ if (!tokenData) {
109
+ throw new Error('No active voice configuration found')
110
+ }
111
+ return { Data: tokenData.token }
112
+ }
113
+
114
+ // ============ Call Activity APIs ============
115
+
116
+ export interface CallActivity {
117
+ id: string
118
+ callSid: string
119
+ prospectId: string
120
+ direction: 'inbound' | 'outbound'
121
+ status: string
122
+ durationSeconds?: number
123
+ createdAt: string
124
+ startTime?: string
125
+ endTime?: string
126
+ fromNumber?: string
127
+ toNumber?: string
128
+ userId?: number
129
+ dispositionCategory?: string
130
+ dispositionReason?: string
131
+ dispositionNotes?: string
132
+ callStatusDisposition?: 'connected' | 'not_connected'
133
+ recordingUrl?: string
134
+ }
135
+
136
+ export interface InitiateCallInput {
137
+ prospectId: string
138
+ toNumber: string
139
+ fromNumber?: string
140
+ userId?: number
141
+ callSid?: string // Exotel CallSid from SDK response
142
+ }
143
+
144
+ export interface SaveDispositionInput {
145
+ callId: string
146
+ prospectId?: string
147
+ callStatusDisposition?: 'connected' | 'not_connected'
148
+ dispositionCategory: string
149
+ dispositionReason?: string
150
+ dispositionNotes?: string
151
+ callbackScheduledAt?: string
152
+ }
153
+
154
+ export interface CancelCallInput {
155
+ callSid: string
156
+ reason?: string // 'declined' | 'cancelled' | 'missed' | 'timeout'
157
+ }
158
+
159
+ export interface CallActivityResponse {
160
+ success: boolean
161
+ message: string
162
+ data: CallActivity
163
+ }
164
+
165
+ export interface RegisterIncomingCallInput {
166
+ callSid: string
167
+ fromNumber: string
168
+ toNumber?: string
169
+ prospectId?: string
170
+ userId?: number
171
+ }
172
+
173
+ export interface RegisterIncomingCallResponse {
174
+ success: boolean
175
+ message: string
176
+ data: CallActivity
177
+ prospect?: {
178
+ id: string
179
+ uniqueId?: number
180
+ prospectId?: string // Formatted ID like PRSP-000001
181
+ name: string
182
+ mobile: string
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Initiate an outbound call - creates call activity record
188
+ */
189
+ export const initiateCallActivity = async (
190
+ input: InitiateCallInput,
191
+ ): Promise<{ success: boolean; message: string; data: CallActivity }> => {
192
+ const endpoint =
193
+ workspace === 'common-workspace'
194
+ ? `/paymentx/call-activities`
195
+ : `/call-activities`
196
+
197
+ const { data } = await axios.post(`${endpoint}/initiate`, input)
198
+ return data
199
+ }
200
+
201
+ /**
202
+ * Register an incoming call - creates call activity record
203
+ */
204
+ export const registerIncomingCall = async (
205
+ input: RegisterIncomingCallInput,
206
+ ): Promise<RegisterIncomingCallResponse> => {
207
+ const endpoint =
208
+ workspace === 'common-workspace'
209
+ ? `/paymentx/call-activities`
210
+ : `/call-activities`
211
+
212
+ const { data } = await axios.post(`${endpoint}/register-incoming`, input)
213
+ return data
214
+ }
215
+
216
+ /**
217
+ * Fetch call history for a prospect
218
+ */
219
+ export const fetchCallHistory = async (
220
+ prospectId: string,
221
+ ): Promise<CallActivity[]> => {
222
+ const endpoint =
223
+ workspace === 'common-workspace'
224
+ ? `/paymentx/call-activities`
225
+ : `/call-activities`
226
+
227
+ const { data } = await axios.get(`${endpoint}/by-prospect`, {
228
+ params: { prospectId },
229
+ })
230
+ return data
231
+ }
232
+
233
+ /**
234
+ * Get call activity by ID
235
+ */
236
+ export const getCallActivityById = async (
237
+ id: string,
238
+ ): Promise<CallActivity> => {
239
+ const endpoint =
240
+ workspace === 'common-workspace'
241
+ ? `/paymentx/call-activities`
242
+ : `/call-activities`
243
+
244
+ const { data } = await axios.get(`${endpoint}/${id}`)
245
+ return data
246
+ }
247
+
248
+ /**
249
+ * Get call activity by CallSid
250
+ */
251
+ export const getCallActivityByCallSid = async (
252
+ callSid: string,
253
+ ): Promise<CallActivity> => {
254
+ const endpoint =
255
+ workspace === 'common-workspace'
256
+ ? `/paymentx/call-activities`
257
+ : `/call-activities`
258
+
259
+ const { data } = await axios.get(`${endpoint}/by-call-sid`, {
260
+ params: { callSid },
261
+ })
262
+ return data
263
+ }
264
+
265
+ /**
266
+ * Get call count for a prospect
267
+ */
268
+ export const getCallCountByProspect = async (
269
+ prospectId: string,
270
+ ): Promise<number> => {
271
+ const endpoint =
272
+ workspace === 'common-workspace'
273
+ ? `/paymentx/call-activities`
274
+ : `/call-activities`
275
+
276
+ const { data } = await axios.get(`${endpoint}/count-by-prospect`, {
277
+ params: { prospectId },
278
+ })
279
+ return data?.count || 0
280
+ }
281
+
282
+ /**
283
+ * Save call disposition
284
+ */
285
+ export const saveCallDisposition = async (
286
+ input: SaveDispositionInput,
287
+ ): Promise<CallActivity> => {
288
+ const endpoint =
289
+ workspace === 'common-workspace'
290
+ ? `/paymentx/call-activities`
291
+ : `/call-activities`
292
+
293
+ const requestBody = {
294
+ callStatusDisposition: input.callStatusDisposition,
295
+ dispositionCategory: input.dispositionCategory,
296
+ dispositionNotes: input.dispositionNotes,
297
+ callbackScheduledAt: input.callbackScheduledAt,
298
+ };
299
+
300
+ const { data } = await axios.post(`${endpoint}/${input.callId}/disposition`, requestBody)
301
+ return data
302
+ }
303
+
304
+ /**
305
+ * Cancel/decline a call before it was connected
306
+ * Updates the call activity status to CANCELED
307
+ */
308
+ export const cancelCall = async (
309
+ input: CancelCallInput,
310
+ ): Promise<{ success: boolean; message: string }> => {
311
+ const endpoint =
312
+ workspace === 'common-workspace'
313
+ ? `/paymentx/call-activities`
314
+ : `/call-activities`
315
+
316
+ const { data } = await axios.post(`${endpoint}/cancel`, input)
317
+ return data
318
+ }
319
+
@@ -0,0 +1,2 @@
1
+ export * from './ExotelService'
2
+ export * from './api'
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Format duration in MM:SS format (e.g., "02:30")
3
+ */
4
+ export const formatDurationTimer = (seconds: number): string => {
5
+ const mins = Math.floor(seconds / 60)
6
+ const secs = seconds % 60
7
+ return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
8
+ }
9
+
10
+ /**
11
+ * Format duration in verbose format (e.g., "2m 30s")
12
+ */
13
+ export const formatDurationVerbose = (seconds: number): string => {
14
+ const mins = Math.floor(seconds / 60)
15
+ const secs = seconds % 60
16
+ return `${mins}m ${secs}s`
17
+ }
@@ -0,0 +1 @@
1
+ export * from './formatters'