@convirza/dialer-sdk 1.0.0

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/README.md ADDED
@@ -0,0 +1,1841 @@
1
+ # @convirza/dialer-sdk
2
+
3
+ Production-ready embeddable WebRTC SIP dialer widget. Auto-configures from OAuth session, handles token refresh, cross-tab logout detection. Includes call parking, call history persistence, audio device management, call quality monitoring.
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ - [Installation](#installation)
10
+ - [Quick Start](#quick-start)
11
+ - [Global Singleton API](#option-a--global-singleton-api-recommended)
12
+ - [Standalone Helpers](#option-b--standalone-helper-functions)
13
+ - [Web Component](#option-c--web-component-convirza-dialer)
14
+ - [Authentication Flow](#authentication-flow)
15
+ - [How It Works](#how-it-works)
16
+ - [Token Refresh](#token-refresh)
17
+ - [Logout Detection](#logout-detection)
18
+ - [API Reference](#api-reference)
19
+ - [convirzaDialer (Singleton)](#convirzadialer-singleton-dialerapi)
20
+ - [SipAdapter (Headless SIP)](#sipadapter)
21
+ - [AudioDeviceManager](#audiodevicemanager)
22
+ - [CallQualityMonitor](#callqualitymonitor)
23
+ - [PhoneNumbersAPI](#phonenumbersapi)
24
+ - [CallHistoryAPI](#callhistoryapi)
25
+ - [Web Component Reference](#web-component-reference)
26
+ - [HTML Attributes](#web-component-attributes)
27
+ - [Public Methods](#web-component-public-methods)
28
+ - [Events](#web-component-events)
29
+ - [Feature Guides](#feature-guides)
30
+ - [Call Parking](#call-parking)
31
+ - [Call History Persistence](#call-history-persistence)
32
+ - [Audio Device Management](#audio-device-management-1)
33
+ - [Call Quality Monitoring](#call-quality-monitoring-1)
34
+ - [Theming](#theming)
35
+ - [Types](#types)
36
+ - [Browser Support](#browser-support)
37
+
38
+ ---
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ npm install @convirza/dialer-sdk
44
+ ```
45
+
46
+ **Peer dependency** — SIP.js must be loaded separately (UMD global `SIP` or `sip`):
47
+
48
+ ```html
49
+ <script src="https://cdn.jsdelivr.net/npm/sip.js@0.13.8/dist/sip.js"></script>
50
+ ```
51
+
52
+ Or install via npm and bundle it yourself:
53
+
54
+ ```bash
55
+ npm install sip.js@^0.13.8
56
+ ```
57
+
58
+ ---
59
+
60
+ ## Quick Start
61
+
62
+ ### Prerequisites
63
+
64
+ User must be logged into your app with `access_token` + `refresh_token` stored in `localStorage`.
65
+
66
+ ### Option A — Global singleton API (recommended)
67
+
68
+ Best for single-page apps where the dialer is always present.
69
+
70
+ ```js
71
+ import { convirzaDialer } from '@convirza/dialer-sdk';
72
+
73
+ // Get tokens from user session (set during login)
74
+ const accessToken = localStorage.getItem('access_token');
75
+ const refreshToken = localStorage.getItem('refresh_token');
76
+
77
+ // Initialize widget (auto-fetches SIP config from OAuth session endpoint)
78
+ convirzaDialer.init({
79
+ access_token: accessToken,
80
+ refresh_token: refreshToken,
81
+ oauth_endpoint: 'https://stag-5-oauth.convirza.com/oauth/internal',
82
+ api_url: 'https://stag-5-dialer-apis.convirza.com',
83
+ dark_theme: true,
84
+ primaryColor: '#6366f1',
85
+ brandName: 'Acme Corp',
86
+ });
87
+
88
+ // Listen for token refresh (only fired if access_token expires)
89
+ window.addEventListener('dialer-token-refreshed', (e) => {
90
+ localStorage.setItem('access_token', e.detail.access_token);
91
+ localStorage.setItem('refresh_token', e.detail.refresh_token);
92
+ console.log('Dialer tokens refreshed');
93
+ });
94
+
95
+ // Handle auth failure (refresh_token expired)
96
+ window.addEventListener('dialer-auth-failed', (e) => {
97
+ console.error('Dialer auth failed:', e.detail.error);
98
+ localStorage.clear();
99
+ window.location.href = '/login';
100
+ });
101
+
102
+ // Cross-tab logout detection (auto-destroys widget)
103
+ // Storage event fires when another tab removes access_token
104
+ window.addEventListener('storage', (e) => {
105
+ if (e.key === 'access_token' && !e.newValue) {
106
+ convirzaDialer.destroy();
107
+ }
108
+ });
109
+
110
+ // Place a call
111
+ convirzaDialer.call('+15551234567');
112
+
113
+ // Control active call
114
+ convirzaDialer.mute(true);
115
+ convirzaDialer.hold(true);
116
+
117
+ // End the active call
118
+ convirzaDialer.endCall();
119
+
120
+ // Clean up on logout
121
+ convirzaDialer.destroy();
122
+ ```
123
+
124
+ ### Option B — Standalone helper functions
125
+
126
+ Same as singleton, but exposed as named exports for tree-shaking.
127
+
128
+ ```js
129
+ import { initDialer, destroyDialer, isDialerInitialized } from '@convirza/dialer-sdk';
130
+
131
+ const accessToken = localStorage.getItem('access_token');
132
+ const refreshToken = localStorage.getItem('refresh_token');
133
+
134
+ initDialer({
135
+ access_token: accessToken,
136
+ refresh_token: refreshToken,
137
+ api_url: 'https://stag-5-dialer-apis.convirza.com',
138
+ });
139
+
140
+ if (isDialerInitialized()) {
141
+ destroyDialer();
142
+ }
143
+ ```
144
+
145
+ ### Option C — Web Component (`<convirza-dialer>`)
146
+
147
+ Embed the dialer widget as a custom HTML element.
148
+
149
+ ```html
150
+ <script type="module">
151
+ import '@convirza/dialer-sdk';
152
+
153
+ const accessToken = localStorage.getItem('access_token');
154
+ const refreshToken = localStorage.getItem('refresh_token');
155
+
156
+ const dialer = document.createElement('convirza-dialer');
157
+ dialer.setAttribute('access-token', accessToken);
158
+ dialer.setAttribute('refresh-token', refreshToken);
159
+ dialer.setAttribute('auto-configure', 'true');
160
+ dialer.setAttribute('oauth-endpoint', 'https://stag-5-oauth.convirza.com/oauth/internal');
161
+ dialer.setAttribute('theme', 'dark');
162
+ dialer.setAttribute('api-url', 'https://stag-5-dialer-apis.convirza.com');
163
+
164
+ // Listen for token refresh (only fires if access_token expires)
165
+ dialer.addEventListener('token-refreshed', (e) => {
166
+ localStorage.setItem('access_token', e.detail.access_token);
167
+ localStorage.setItem('refresh_token', e.detail.refresh_token);
168
+ });
169
+
170
+ // Handle auth failure
171
+ dialer.addEventListener('auth-failed', (e) => {
172
+ console.error('Auth failed:', e.detail.error);
173
+ window.location.href = '/login';
174
+ });
175
+
176
+ document.body.appendChild(dialer);
177
+ </script>
178
+ ```
179
+
180
+ **Attributes:**
181
+
182
+ - `access-token` — Access token from user login (used first)
183
+ - `refresh-token` — Refresh token (fallback if access_token expires)
184
+ - `auto-configure="true"` — Enables auto-config flow
185
+ - `oauth-endpoint` — OAuth base URL (default: `https://stag-5-oauth.convirza.com/oauth/internal`)
186
+ - `api-url` — API base URL for park slots, call history
187
+
188
+ **Events:**
189
+
190
+ - `token-refreshed` — New tokens received (only if access_token expired)
191
+ - `auth-failed` — Both tokens failed (redirect to login)
192
+
193
+ ---
194
+
195
+ ## Authentication Flow
196
+
197
+ ### How It Works
198
+
199
+ Widget auto-configures SIP credentials from OAuth session data. No manual SIP configuration needed.
200
+
201
+ **1. User logs into main app**
202
+
203
+ Your app authenticates user → receives `access_token` + `refresh_token`:
204
+
205
+ ```javascript
206
+ // Your login handler
207
+ const response = await fetch('https://stag-5-oauth.convirza.com/oauth/internal/token', {
208
+ method: 'POST',
209
+ headers: { 'Content-Type': 'application/json' },
210
+ body: JSON.stringify({ email, password }),
211
+ });
212
+
213
+ const { access_token, refresh_token } = response.data;
214
+
215
+ localStorage.setItem('access_token', access_token);
216
+ localStorage.setItem('refresh_token', refresh_token);
217
+ ```
218
+
219
+ **2. Initialize dialer with tokens**
220
+
221
+ ```javascript
222
+ convirzaDialer.init({
223
+ access_token: localStorage.getItem('access_token'),
224
+ refresh_token: localStorage.getItem('refresh_token'),
225
+ oauth_endpoint: 'https://stag-5-oauth.convirza.com/oauth/internal',
226
+ });
227
+ ```
228
+
229
+ **3. Widget fetches SIP config**
230
+
231
+ Widget calls `GET /oauth/internal/session` with `access_token` → gets user's dialer config:
232
+
233
+ ```json
234
+ {
235
+ "user": {
236
+ "user_id": 123,
237
+ "dialer_config": {
238
+ "domains": [
239
+ {
240
+ "sip_domain": "prod-registration",
241
+ "sip_proxy_1": "sip.example.com",
242
+ "extensions": [{ "extension": "1001" }]
243
+ }
244
+ ]
245
+ }
246
+ }
247
+ }
248
+ ```
249
+
250
+ **4. Widget auto-configures SIP credentials**
251
+
252
+ Internally sets:
253
+
254
+ - SIP username: `1001` (from extension)
255
+ - SIP password: `123@prod-registration` (user_id @ sip_domain)
256
+ - SIP domain: `prod-registration`
257
+ - WebSocket server: `wss://sip.example.com:8443`
258
+
259
+ **5. Widget connects to SIP server**
260
+
261
+ SIP registration happens automatically. Widget ready to make calls.
262
+
263
+ ### Token Refresh
264
+
265
+ **Automatic refresh on expiry:**
266
+
267
+ 1. Widget tries `GET /session` with `access_token`
268
+ 2. If 401 Unauthorized → `access_token` expired
269
+ 3. Widget calls `POST /refresh-token` with `refresh_token`
270
+ 4. Gets new `access_token` + `refresh_token`
271
+ 5. Widget emits `dialer-token-refreshed` event
272
+ 6. Your app updates localStorage
273
+
274
+ ```javascript
275
+ window.addEventListener('dialer-token-refreshed', (e) => {
276
+ localStorage.setItem('access_token', e.detail.access_token);
277
+ localStorage.setItem('refresh_token', e.detail.refresh_token);
278
+ });
279
+ ```
280
+
281
+ **If refresh_token also expired:**
282
+
283
+ Widget emits `dialer-auth-failed` → redirect user to login.
284
+
285
+ ### Logout Detection
286
+
287
+ **Same-tab logout:**
288
+
289
+ ```javascript
290
+ // Your logout handler
291
+ function logout() {
292
+ localStorage.removeItem('access_token');
293
+ localStorage.removeItem('refresh_token');
294
+ convirzaDialer.destroy(); // Widget destroyed
295
+ window.location.href = '/login';
296
+ }
297
+ ```
298
+
299
+ **Cross-tab logout:**
300
+
301
+ Widget automatically destroys when `access_token` removed in another tab:
302
+
303
+ ```javascript
304
+ // Storage event listener (built into widget)
305
+ window.addEventListener('storage', (e) => {
306
+ if (e.key === 'access_token' && !e.newValue) {
307
+ convirzaDialer.destroy(); // Auto-destroyed
308
+ }
309
+ });
310
+ ```
311
+
312
+ User logs out in Tab A → widget destroyed in Tab B automatically.
313
+
314
+ ### Backend Requirements
315
+
316
+ Widget expects these OAuth endpoints:
317
+
318
+ **Session endpoint:**
319
+
320
+ ```
321
+ GET /oauth/internal/session
322
+ Authorization: Bearer {access_token}
323
+
324
+ Returns: { user: { dialer_config: {...} } }
325
+ ```
326
+
327
+ **Refresh endpoint:**
328
+
329
+ ```
330
+ POST /oauth/internal/refresh-token
331
+ Body: { refresh_token: "..." }
332
+
333
+ Returns: { access_token, refresh_token, user: { dialer_config: {...} } }
334
+ ```
335
+
336
+ ---
337
+
338
+ ## API Reference
339
+
340
+ ### `convirzaDialer` (singleton `DialerAPI`)
341
+
342
+ The default export is a singleton that manages the `<convirza-dialer>` web component and provides a simple imperative API.
343
+
344
+ #### `convirzaDialer.init(config)`
345
+
346
+ Initialize the dialer and inject the widget into the page. **Must be called before any other method.**
347
+
348
+ ```ts
349
+ convirzaDialer.init(config: DialerInitConfig): void
350
+ ```
351
+
352
+ | Field | Type | Required | Description |
353
+ | ---------------- | --------- | -------- | -------------------------------------------- |
354
+ | `access_token` | `string` | Yes | Access token from user login session |
355
+ | `refresh_token` | `string` | Yes | Refresh token (used if access_token expires) |
356
+ | `oauth_endpoint` | `string` | No | OAuth base URL (default: internal) |
357
+ | `api_url` | `string` | No | API base URL for park slots, call history |
358
+ | `brandName` | `string` | No | Brand name shown in widget header |
359
+ | `brandLogo` | `string` | No | URL of brand logo image |
360
+ | `dark_theme` | `boolean` | No | Use dark theme (default `false`) |
361
+ | `showPopup` | `boolean` | No | Start with widget expanded |
362
+ | `primaryColor` | `string` | No | CSS color for primary UI elements (hex/rgb) |
363
+ | `accentColor` | `string` | No | CSS color for accent elements |
364
+
365
+ OAuth response provides SIP credentials automatically. User never sees SIP details.
366
+
367
+ **Example:**
368
+
369
+ ```js
370
+ convirzaDialer.init({
371
+ oauth_endpoint: 'https://api.example.com/oauth/token',
372
+ username: 'user@example.com',
373
+ password: 'userpassword',
374
+ dark_theme: true,
375
+ primaryColor: '#6366f1',
376
+ brandName: 'Acme Corp',
377
+ brandLogo: 'https://example.com/logo.png',
378
+ });
379
+ ```
380
+
381
+ #### `convirzaDialer.refresh()`
382
+
383
+ Re-initialize the widget using the same config. Useful after credential rotation.
384
+
385
+ ```ts
386
+ convirzaDialer.refresh(): void
387
+ ```
388
+
389
+ #### `convirzaDialer.call(phoneNumber?)`
390
+
391
+ Place an outbound call. Opens the widget popup if collapsed.
392
+
393
+ ```ts
394
+ convirzaDialer.call(phoneNumber?: string): void
395
+ ```
396
+
397
+ **Parameters:**
398
+
399
+ - `phoneNumber` (optional): E.164 phone number (e.g., `+15551234567`) or SIP extension. If omitted, just opens the widget.
400
+
401
+ **Example:**
402
+
403
+ ```js
404
+ // Dial number immediately
405
+ convirzaDialer.call('+15551234567');
406
+
407
+ // Open widget without dialing
408
+ convirzaDialer.call();
409
+ ```
410
+
411
+ #### `convirzaDialer.endCall()`
412
+
413
+ Hang up the active call. Throws if there is no active call.
414
+
415
+ ```ts
416
+ convirzaDialer.endCall(): void
417
+ ```
418
+
419
+ **Example:**
420
+
421
+ ```js
422
+ if (convirzaDialer.getActiveCallID()) {
423
+ convirzaDialer.endCall();
424
+ }
425
+ ```
426
+
427
+ #### `convirzaDialer.mute(enable?)`
428
+
429
+ Mute or unmute the microphone. Toggles if `enable` is omitted.
430
+
431
+ ```ts
432
+ convirzaDialer.mute(enable?: boolean): boolean // returns current mute state
433
+ ```
434
+
435
+ **Parameters:**
436
+
437
+ - `enable` (optional): `true` to mute, `false` to unmute. Omit to toggle.
438
+
439
+ **Returns:** Current mute state (`true` = muted).
440
+
441
+ **Example:**
442
+
443
+ ```js
444
+ // Toggle mute
445
+ const isMuted = convirzaDialer.mute();
446
+
447
+ // Explicitly mute
448
+ convirzaDialer.mute(true);
449
+
450
+ // Explicitly unmute
451
+ convirzaDialer.mute(false);
452
+ ```
453
+
454
+ #### `convirzaDialer.hold(enable?)`
455
+
456
+ Place the call on hold or take it off hold.
457
+
458
+ ```ts
459
+ convirzaDialer.hold(enable?: boolean): boolean
460
+ ```
461
+
462
+ **Parameters:**
463
+
464
+ - `enable` (optional): `true` to hold, `false` to unhold. Omit to toggle.
465
+
466
+ **Returns:** Current hold state (`true` = on hold).
467
+
468
+ **Example:**
469
+
470
+ ```js
471
+ // Toggle hold
472
+ const isOnHold = convirzaDialer.hold();
473
+
474
+ // Explicitly hold
475
+ convirzaDialer.hold(true);
476
+
477
+ // Explicitly resume
478
+ convirzaDialer.hold(false);
479
+ ```
480
+
481
+ #### `convirzaDialer.record(enable?)`
482
+
483
+ Start or stop server-side recording. Sends SIP INFO message to backend.
484
+
485
+ ```ts
486
+ convirzaDialer.record(enable?: boolean): boolean // returns current recording state
487
+ ```
488
+
489
+ **Parameters:**
490
+
491
+ - `enable` (optional): `true` to start recording, `false` to stop. Omit to toggle.
492
+
493
+ **Returns:** Current recording state (`true` = recording).
494
+
495
+ **Note:** Server must support `application/x-recording-command` SIP INFO messages.
496
+
497
+ **Example:**
498
+
499
+ ```js
500
+ // Start recording
501
+ convirzaDialer.record(true);
502
+
503
+ // Stop recording
504
+ convirzaDialer.record(false);
505
+ ```
506
+
507
+ #### `convirzaDialer.transfer(target)`
508
+
509
+ Blind-transfer the active call to another extension or phone number.
510
+
511
+ ```ts
512
+ convirzaDialer.transfer(target: string): void
513
+ ```
514
+
515
+ **Parameters:**
516
+
517
+ - `target`: SIP URI (e.g., `sip:1002@pbx.example.com`) or bare extension (e.g., `1002`).
518
+
519
+ **Example:**
520
+
521
+ ```js
522
+ // Transfer to extension
523
+ convirzaDialer.transfer('1002');
524
+
525
+ // Transfer to external number
526
+ convirzaDialer.transfer('+15559876543');
527
+ ```
528
+
529
+ #### `convirzaDialer.showPopup()`
530
+
531
+ Expand the dialer widget.
532
+
533
+ ```ts
534
+ convirzaDialer.showPopup(): void
535
+ ```
536
+
537
+ #### `convirzaDialer.hidePopup()`
538
+
539
+ Collapse the dialer widget to floating action button (FAB).
540
+
541
+ ```ts
542
+ convirzaDialer.hidePopup(): void
543
+ ```
544
+
545
+ #### `convirzaDialer.getActiveCallID()`
546
+
547
+ Returns the ID of the active call, or `null` if idle.
548
+
549
+ ```ts
550
+ convirzaDialer.getActiveCallID(): string | null
551
+ ```
552
+
553
+ **Example:**
554
+
555
+ ```js
556
+ const callId = convirzaDialer.getActiveCallID();
557
+ if (callId) {
558
+ console.log('Active call:', callId);
559
+ }
560
+ ```
561
+
562
+ #### `convirzaDialer.callList(limit?)`
563
+
564
+ Returns the in-session call history (most recent first).
565
+
566
+ ```ts
567
+ convirzaDialer.callList(limit?: number): CallListItem[]
568
+ ```
569
+
570
+ **Parameters:**
571
+
572
+ - `limit` (optional): Max number of calls to return.
573
+
574
+ **Returns:** Array of `CallListItem`:
575
+
576
+ ```ts
577
+ interface CallListItem {
578
+ id: string;
579
+ phoneNumber: string;
580
+ timestamp: number; // Unix timestamp (ms)
581
+ duration?: number; // Call duration in milliseconds
582
+ direction: 'inbound' | 'outbound';
583
+ status: 'answered' | 'missed' | 'voicemail';
584
+ }
585
+ ```
586
+
587
+ **Example:**
588
+
589
+ ```js
590
+ const recent = convirzaDialer.callList(10);
591
+ recent.forEach((call) => {
592
+ console.log(`${call.direction} call to ${call.phoneNumber}: ${call.status}`);
593
+ });
594
+ ```
595
+
596
+ #### `convirzaDialer.destroy()`
597
+
598
+ Remove the widget from DOM and clean up all event listeners. Call on logout or page unload.
599
+
600
+ ```ts
601
+ convirzaDialer.destroy(): void
602
+ ```
603
+
604
+ **Example:**
605
+
606
+ ```js
607
+ // On user logout
608
+ function logout() {
609
+ convirzaDialer.destroy();
610
+ // ... other cleanup
611
+ }
612
+ ```
613
+
614
+ #### Read-only properties
615
+
616
+ | Property | Type | Description |
617
+ | ------------------ | --------- | ----------------------- |
618
+ | `isMutedState` | `boolean` | Current mute state |
619
+ | `isRecordingState` | `boolean` | Current recording state |
620
+
621
+ ---
622
+
623
+ ### Helper functions
624
+
625
+ #### `initDialer(config)`
626
+
627
+ Initialize the global dialer. Warns and no-ops if called a second time.
628
+
629
+ ```ts
630
+ import { initDialer } from '@convirza/dialer-sdk';
631
+ initDialer(config: DialerInitConfig): void
632
+ ```
633
+
634
+ #### `destroyDialer()`
635
+
636
+ Destroy the global dialer. Warns if not initialized.
637
+
638
+ ```ts
639
+ import { destroyDialer } from '@convirza/dialer-sdk';
640
+ destroyDialer(): void
641
+ ```
642
+
643
+ #### `isDialerInitialized()`
644
+
645
+ Returns `true` if the global dialer has been initialized.
646
+
647
+ ```ts
648
+ import { isDialerInitialized } from '@convirza/dialer-sdk';
649
+ isDialerInitialized(): boolean
650
+ ```
651
+
652
+ ---
653
+
654
+ ### `SipAdapter`
655
+
656
+ Low-level SIP client for headless / custom-UI integrations. Requires `sip.js` as a UMD global (`window.SIP` or `window.sip`).
657
+
658
+ #### Constructor
659
+
660
+ ```ts
661
+ new SipAdapter(config: SipConfig, callbacks?: SipCallbacks)
662
+ ```
663
+
664
+ **`SipConfig`**
665
+
666
+ | Field | Type | Required | Description |
667
+ | -------------- | ---------------- | -------- | ------------------------------------------------- |
668
+ | `sipUsername` | `string` | Yes | SIP username |
669
+ | `sipPassword` | `string` | Yes | SIP password |
670
+ | `sipDomain` | `string` | Yes | SIP registrar domain |
671
+ | `wsServers` | `string[]` | Yes | WebSocket proxy URL(s) |
672
+ | `displayName` | `string` | No | Caller display name |
673
+ | `iceServers` | `RTCIceServer[]` | No | Override STUN/TURN servers (default: Google STUN) |
674
+ | `microphoneId` | `string` | No | Preferred microphone device ID |
675
+ | `speakerId` | `string` | No | Preferred speaker device ID |
676
+ | `apiUrl` | `string` | No | Backend API URL (required for call parking) |
677
+ | `authToken` | `string` | No | Bearer token (required for authenticated APIs) |
678
+
679
+ **`SipCallbacks`**
680
+
681
+ | Callback | Signature | When it fires |
682
+ | ------------------------ | ---------------------------------------------------- | --------------------------------------------------------- |
683
+ | `onRegistered` | `() => void` | SIP registration succeeded |
684
+ | `onUnregistered` | `() => void` | SIP unregistered (intentional or expired) |
685
+ | `onRegistrationFailed` | `(cause?: string) => void` | Registration failed (e.g., 403 wrong password) |
686
+ | `onTransportError` | `(error: Error) => void` | WebSocket transport error |
687
+ | `onConnecting` | `() => void` | Transport connecting (before registration) |
688
+ | `onCallRinging` | `() => void` | Outbound call is ringing (received 180 Ringing) |
689
+ | `onCallAnswered` | `() => void` | Call connected (received 200 OK) |
690
+ | `onCallEnded` | `(reason?: string) => void` | Call ended by any party (BYE, CANCEL, timeout) |
691
+ | `onCallFailed` | `(error: Error) => void` | Call setup or media failure (e.g., no WebRTC permissions) |
692
+ | `onIncomingCall` | `(phoneNumber: string, callerName?: string) => void` | Inbound INVITE received |
693
+ | `onRemoteHold` | `(isOnHold: boolean) => void` | Remote party placed call on hold |
694
+ | `onQualityChange` | `(metrics: CallQualityMetrics) => void` | Periodic RTC quality update (every 2s) |
695
+ | `onRecordingStateChange` | `(recording: boolean) => void` | Server recording started/stopped (via SIP INFO) |
696
+ | `onTransferSucceeded` | `() => void` | Blind transfer (REFER) accepted by server |
697
+ | `onTransferFailed` | `(reason: string) => void` | Blind transfer rejected by server |
698
+ | `onDevicesChanged` | `(devices: AudioDevice[]) => void` | Audio device list changed (mic/speaker plugged in/out) |
699
+ | `onError` | `(error: Error) => void` | General unhandled error |
700
+
701
+ #### Methods
702
+
703
+ ##### `sip.connect()`
704
+
705
+ Connect to the WebSocket transport and register with the SIP server. **Must be called first.**
706
+
707
+ ```ts
708
+ await sip.connect(): Promise<void>
709
+ ```
710
+
711
+ **Example:**
712
+
713
+ ```js
714
+ await sip.connect();
715
+ console.log('Registered with SIP server');
716
+ ```
717
+
718
+ **Errors:**
719
+
720
+ - Throws if SIP.js not loaded
721
+ - Throws if WebSocket connection fails
722
+ - Fires `onRegistrationFailed` on auth failure
723
+
724
+ ##### `sip.disconnect()`
725
+
726
+ End any active call, unregister, and close the WebSocket transport.
727
+
728
+ ```ts
729
+ await sip.disconnect(): Promise<void>
730
+ ```
731
+
732
+ **Example:**
733
+
734
+ ```js
735
+ await sip.disconnect();
736
+ console.log('Disconnected from SIP server');
737
+ ```
738
+
739
+ ##### `sip.call(phoneNumber, options?)`
740
+
741
+ Place an outbound call.
742
+
743
+ ```ts
744
+ await sip.call(phoneNumber: string, options?: {
745
+ callerId?: string; // P-Asserted-Identity number (E.164)
746
+ callerIdName?: string; // P-Asserted-Identity display name
747
+ }): Promise<void>
748
+ ```
749
+
750
+ **Parameters:**
751
+
752
+ - `phoneNumber`: E.164 phone number or SIP extension
753
+ - `options.callerId`: Override outbound caller ID (if supported by server)
754
+ - `options.callerIdName`: Caller display name
755
+
756
+ **Example:**
757
+
758
+ ```js
759
+ // Simple call
760
+ await sip.call('+15551234567');
761
+
762
+ // With custom caller ID
763
+ await sip.call('+15551234567', {
764
+ callerId: '+15559876543',
765
+ callerIdName: 'Support Line',
766
+ });
767
+ ```
768
+
769
+ **Fires:**
770
+
771
+ - `onCallRinging` when remote party rings
772
+ - `onCallAnswered` when call connects
773
+ - `onCallFailed` on error
774
+
775
+ ##### `sip.endCall()`
776
+
777
+ Hang up the current call (sends BYE or CANCEL as appropriate).
778
+
779
+ ```ts
780
+ await sip.endCall(): Promise<void>
781
+ ```
782
+
783
+ **Example:**
784
+
785
+ ```js
786
+ if (sip.hasActiveCall) {
787
+ await sip.endCall();
788
+ }
789
+ ```
790
+
791
+ ##### `sip.answerCall()`
792
+
793
+ Answer an incoming call. **Must be called after `onIncomingCall` fires.**
794
+
795
+ ```ts
796
+ await sip.answerCall(): Promise<void>
797
+ ```
798
+
799
+ **Example:**
800
+
801
+ ```js
802
+ const sip = new SipAdapter(config, {
803
+ onIncomingCall: (number, name) => {
804
+ console.log(`Incoming call from ${name} <${number}>`);
805
+ sip.answerCall(); // Auto-answer
806
+ },
807
+ });
808
+ ```
809
+
810
+ ##### `sip.rejectCall()`
811
+
812
+ Reject an incoming call (sends 486 Busy Here).
813
+
814
+ ```ts
815
+ await sip.rejectCall(): Promise<void>
816
+ ```
817
+
818
+ **Example:**
819
+
820
+ ```js
821
+ const sip = new SipAdapter(config, {
822
+ onIncomingCall: (number) => {
823
+ if (number === '+15551111111') {
824
+ sip.rejectCall(); // Block this caller
825
+ }
826
+ },
827
+ });
828
+ ```
829
+
830
+ ##### `sip.mute(enable?)`
831
+
832
+ Mute/unmute the local microphone track. Toggles if `enable` is omitted.
833
+
834
+ ```ts
835
+ sip.mute(enable?: boolean): boolean // returns new mute state
836
+ ```
837
+
838
+ **Example:**
839
+
840
+ ```js
841
+ // Toggle mute
842
+ const isMuted = sip.mute();
843
+
844
+ // Explicitly mute
845
+ sip.mute(true);
846
+ ```
847
+
848
+ ##### `sip.hold(enable?)`
849
+
850
+ Put the call on hold (`true`) or take it off hold (`false`). Defaults to `true`.
851
+
852
+ ```ts
853
+ await sip.hold(enable?: boolean): Promise<void>
854
+ ```
855
+
856
+ **Example:**
857
+
858
+ ```js
859
+ // Put on hold
860
+ await sip.hold(true);
861
+
862
+ // Resume
863
+ await sip.hold(false);
864
+ ```
865
+
866
+ **Note:** Uses SIP re-INVITE with `sendrecv`/`sendonly` SDP attributes.
867
+
868
+ ##### `sip.sendDTMF(digit)`
869
+
870
+ Send a DTMF digit via SIP INFO (RFC 2833 alternative).
871
+
872
+ ```ts
873
+ sip.sendDTMF(digit: string): void // digit: '0'-'9', '*', '#'
874
+ ```
875
+
876
+ **Example:**
877
+
878
+ ```js
879
+ // Send IVR menu choice
880
+ sip.sendDTMF('1');
881
+
882
+ // Send PIN
883
+ '1234'.split('').forEach((d) => sip.sendDTMF(d));
884
+ ```
885
+
886
+ **Note:** Uses `application/dtmf-relay` content type.
887
+
888
+ ##### `sip.startRecording()`
889
+
890
+ Signal the server to start recording (sends SIP INFO with `application/x-recording-command: start`).
891
+
892
+ ```ts
893
+ sip.startRecording(): void
894
+ ```
895
+
896
+ **Example:**
897
+
898
+ ```js
899
+ sip.startRecording();
900
+ console.log('Recording started');
901
+ ```
902
+
903
+ ##### `sip.stopRecording()`
904
+
905
+ Signal the server to stop recording.
906
+
907
+ ```ts
908
+ sip.stopRecording(): void
909
+ ```
910
+
911
+ ##### `sip.park(parkExtension)`
912
+
913
+ Park the call at a valet park slot via the Convirza REST API. Requires `apiUrl` and `authToken` in config.
914
+
915
+ ```ts
916
+ await sip.park(parkExtension: string): Promise<void>
917
+ ```
918
+
919
+ **Parameters:**
920
+
921
+ - `parkExtension`: Park slot extension (e.g., `'700'`)
922
+
923
+ **Example:**
924
+
925
+ ```js
926
+ // Park call at slot 700
927
+ await sip.park('700');
928
+ ```
929
+
930
+ **See:** [Call Parking Guide](#call-parking) for full details.
931
+
932
+ ##### `sip.blindTransfer(target)`
933
+
934
+ Blind-transfer the call via SIP REFER. `target` can be a full SIP URI or a bare extension.
935
+
936
+ ```ts
937
+ await sip.blindTransfer(target: string): Promise<void>
938
+ ```
939
+
940
+ **Parameters:**
941
+
942
+ - `target`: SIP URI (e.g., `sip:1002@pbx.example.com`) or extension (e.g., `1002`)
943
+
944
+ **Example:**
945
+
946
+ ```js
947
+ // Transfer to extension
948
+ await sip.blindTransfer('1002');
949
+
950
+ // Transfer to external number
951
+ await sip.blindTransfer('sip:+15559876543@pbx.example.com');
952
+ ```
953
+
954
+ **Fires:**
955
+
956
+ - `onTransferSucceeded` on 202 Accepted
957
+ - `onTransferFailed` on rejection
958
+
959
+ ##### `sip.setMicrophone(deviceId)`
960
+
961
+ Hot-swap the active microphone without interrupting the call.
962
+
963
+ ```ts
964
+ await sip.setMicrophone(deviceId: string): Promise<void>
965
+ ```
966
+
967
+ **Parameters:**
968
+
969
+ - `deviceId`: MediaDeviceInfo device ID
970
+
971
+ **Example:**
972
+
973
+ ```js
974
+ const devices = await sip.getAudioDevices();
975
+ const builtInMic = devices.microphones.find((d) => d.label.includes('Built-in'));
976
+ if (builtInMic) {
977
+ await sip.setMicrophone(builtInMic.deviceId);
978
+ }
979
+ ```
980
+
981
+ **Note:** Uses `replaceTrack()` on the sender — no interruption to call.
982
+
983
+ ##### `sip.setSpeaker(deviceId)`
984
+
985
+ Switch the audio output device.
986
+
987
+ ```ts
988
+ await sip.setSpeaker(deviceId: string): Promise<void>
989
+ ```
990
+
991
+ **Parameters:**
992
+
993
+ - `deviceId`: MediaDeviceInfo device ID
994
+
995
+ **Example:**
996
+
997
+ ```js
998
+ const devices = await sip.getAudioDevices();
999
+ const headphones = devices.speakers.find((d) => d.label.includes('Headphones'));
1000
+ if (headphones) {
1001
+ await sip.setSpeaker(headphones.deviceId);
1002
+ }
1003
+ ```
1004
+
1005
+ **Browser support:** Chrome/Edge only (`setSinkId` API). Silently no-ops in Firefox/Safari.
1006
+
1007
+ ##### `sip.getAudioDevices()`
1008
+
1009
+ Enumerate available microphones and speakers.
1010
+
1011
+ ```ts
1012
+ await sip.getAudioDevices(): Promise<{
1013
+ microphones: Array<{ deviceId: string; label: string }>;
1014
+ speakers: Array<{ deviceId: string; label: string }>;
1015
+ }>
1016
+ ```
1017
+
1018
+ **Example:**
1019
+
1020
+ ```js
1021
+ const { microphones, speakers } = await sip.getAudioDevices();
1022
+ console.log(
1023
+ 'Mics:',
1024
+ microphones.map((m) => m.label)
1025
+ );
1026
+ console.log(
1027
+ 'Speakers:',
1028
+ speakers.map((s) => s.label)
1029
+ );
1030
+ ```
1031
+
1032
+ **Note:** Requires microphone permission. Labels are generic until permission granted.
1033
+
1034
+ ##### `sip.getCurrentDevices()`
1035
+
1036
+ Return the currently active microphone and speaker device IDs.
1037
+
1038
+ ```ts
1039
+ sip.getCurrentDevices(): { microphoneId: string | null; speakerId: string | null }
1040
+ ```
1041
+
1042
+ **Example:**
1043
+
1044
+ ```js
1045
+ const { microphoneId, speakerId } = sip.getCurrentDevices();
1046
+ console.log('Active mic:', microphoneId);
1047
+ console.log('Active speaker:', speakerId);
1048
+ ```
1049
+
1050
+ ##### `sip.getCallQuality()`
1051
+
1052
+ Return the last WebRTC quality metrics sample, or `null` if no call is active.
1053
+
1054
+ ```ts
1055
+ sip.getCallQuality(): CallQualityMetrics | null
1056
+ ```
1057
+
1058
+ **Returns:**
1059
+
1060
+ ```ts
1061
+ interface CallQualityMetrics {
1062
+ bitrate: number; // kbps
1063
+ packetLoss: number; // percentage (0-100)
1064
+ jitter: number; // ms
1065
+ latency: number; // ms (round-trip time)
1066
+ audioLevel: number; // 0–1 (remote audio volume)
1067
+ quality: 'excellent' | 'good' | 'fair' | 'poor';
1068
+ }
1069
+ ```
1070
+
1071
+ **Example:**
1072
+
1073
+ ```js
1074
+ const metrics = sip.getCallQuality();
1075
+ if (metrics && metrics.quality === 'poor') {
1076
+ console.warn('Poor call quality:', metrics.packetLoss + '% packet loss');
1077
+ }
1078
+ ```
1079
+
1080
+ ##### `sip.getCallUuid()`
1081
+
1082
+ Return the active call UUID (FreeSWITCH channel UUID or SIP Call-ID). Useful for server-side operations.
1083
+
1084
+ ```ts
1085
+ sip.getCallUuid(): string | null
1086
+ ```
1087
+
1088
+ **Example:**
1089
+
1090
+ ```js
1091
+ const uuid = sip.getCallUuid();
1092
+ console.log('Call UUID for backend lookup:', uuid);
1093
+ ```
1094
+
1095
+ ##### `sip.reconnect()`
1096
+
1097
+ Trigger an immediate reconnection attempt (resets backoff counter). Useful on the browser `online` event.
1098
+
1099
+ ```ts
1100
+ sip.reconnect(): void
1101
+ ```
1102
+
1103
+ **Example:**
1104
+
1105
+ ```js
1106
+ window.addEventListener('online', () => {
1107
+ sip.reconnect(); // Force reconnect after network restored
1108
+ });
1109
+ ```
1110
+
1111
+ ##### Read-only properties
1112
+
1113
+ | Property | Type | Description |
1114
+ | --------------- | --------- | ------------------------------------------------------------ |
1115
+ | `isConnected` | `boolean` | Whether the WebSocket transport is up |
1116
+ | `hasActiveCall` | `boolean` | Whether a SIP session is in progress (any state except idle) |
1117
+
1118
+ ---
1119
+
1120
+ ### `AudioDeviceManager`
1121
+
1122
+ Standalone audio device manager. Used internally by `SipAdapter` but also exportable for custom UIs.
1123
+
1124
+ ```ts
1125
+ import { AudioDeviceManager } from '@convirza/dialer-sdk';
1126
+
1127
+ const adm = new AudioDeviceManager({
1128
+ onDevicesChanged: (devices) => console.log(devices),
1129
+ onMicrophoneChanged: (id) => console.log('Mic changed:', id),
1130
+ onSpeakerChanged: (id) => console.log('Speaker changed:', id),
1131
+ });
1132
+
1133
+ const devices = await adm.enumerateDevices();
1134
+ const mics = adm.getMicrophones();
1135
+ const spk = adm.getSpeakers();
1136
+
1137
+ adm.savePreferences({ microphoneId: mics[0].deviceId, speakerId: spk[0].deviceId });
1138
+ const prefs = adm.loadPreferences();
1139
+
1140
+ await adm.applyAudioOutputDevice(audioElement, spk[0].deviceId);
1141
+
1142
+ adm.destroy();
1143
+ ```
1144
+
1145
+ | Method | Returns | Description |
1146
+ | -------------------------------------- | --------------------------------- | -------------------------------------------- |
1147
+ | `enumerateDevices()` | `Promise<AudioDevice[]>` | Refresh and return all audio devices |
1148
+ | `refreshDevices()` | `Promise<AudioDevice[]>` | Alias for `enumerateDevices()` |
1149
+ | `getMicrophones()` | `AudioDevice[]` | Cached microphone list |
1150
+ | `getSpeakers()` | `AudioDevice[]` | Cached speaker list |
1151
+ | `savePreferences(prefs)` | `void` | Persist device preferences to `localStorage` |
1152
+ | `loadPreferences()` | `DevicePreferences` | Load persisted preferences |
1153
+ | `applyAudioOutputDevice(el, deviceId)` | `Promise<void>` | Set `setSinkId` on an `<audio>` element |
1154
+ | `getAudioConstraints(microphoneId)` | `Promise<MediaStreamConstraints>` | Build `getUserMedia` constraints |
1155
+ | `destroy()` | `void` | Remove `devicechange` listener |
1156
+
1157
+ ---
1158
+
1159
+ ### `CallQualityMonitor`
1160
+
1161
+ Periodically samples WebRTC stats and emits quality metrics.
1162
+
1163
+ ```ts
1164
+ import { CallQualityMonitor } from '@convirza/dialer-sdk';
1165
+
1166
+ const monitor = new CallQualityMonitor();
1167
+ monitor.setOnQualityChange((metrics) => {
1168
+ console.log(metrics.quality, metrics.packetLoss, metrics.latency);
1169
+ });
1170
+
1171
+ monitor.start(peerConnection, 2000); // sample every 2 s
1172
+ // ...
1173
+ const last = monitor.getLastMetrics();
1174
+ monitor.stop();
1175
+ ```
1176
+
1177
+ | Method | Description |
1178
+ | ------------------------ | ------------------------------------------ |
1179
+ | `start(pc, interval?)` | Start sampling (default 2000 ms) |
1180
+ | `stop()` | Stop sampling and clear state |
1181
+ | `setOnQualityChange(cb)` | Register quality callback |
1182
+ | `getLastMetrics()` | Return last `CallQualityMetrics` or `null` |
1183
+
1184
+ ---
1185
+
1186
+ ### `PhoneNumbersAPI`
1187
+
1188
+ REST client for fetching provisioned phone numbers from the Convirza backend.
1189
+
1190
+ ```ts
1191
+ import { PhoneNumbersAPI } from '@convirza/dialer-sdk';
1192
+
1193
+ const api = new PhoneNumbersAPI('https://api.convirza.com', 'Bearer-token');
1194
+ api.setAuthToken('new-token');
1195
+
1196
+ const result = await api.fetchOrderedNumbers({
1197
+ domainId: 123,
1198
+ limit: 50,
1199
+ status: 'provisioned',
1200
+ });
1201
+ // result: { items: OrderedPhoneNumber[], total: number }
1202
+ ```
1203
+
1204
+ | Method | Description |
1205
+ | ----------------------------- | -------------------------------------------- |
1206
+ | `setAuthToken(token)` | Update the bearer token |
1207
+ | `fetchOrderedNumbers(params)` | Fetch provisioned phone numbers for a domain |
1208
+
1209
+ ---
1210
+
1211
+ ### `CallHistoryAPI`
1212
+
1213
+ REST client for persisting and querying call history.
1214
+
1215
+ ```ts
1216
+ import { CallHistoryAPI } from '@convirza/dialer-sdk';
1217
+
1218
+ const api = new CallHistoryAPI('https://api.convirza.com', 'Bearer-token');
1219
+
1220
+ const history = await api.fetchHistory({
1221
+ userId: 'user-123',
1222
+ sipDomain: 'sip.example.com',
1223
+ limit: 50,
1224
+ offset: 0,
1225
+ });
1226
+ // history: { calls: CallRecord[], total: number, hasMore: boolean }
1227
+
1228
+ const saved = await api.saveCall({
1229
+ callId: 'abc-123',
1230
+ userId: 'user-123',
1231
+ sipDomain: 'sip.example.com',
1232
+ callerIdNumber: '+15551234567',
1233
+ phoneNumber: '+19876543210',
1234
+ direction: 'outbound',
1235
+ disposition: 'answered',
1236
+ startedAt: Date.now(),
1237
+ durationSeconds: 42,
1238
+ createdAt: Date.now(),
1239
+ });
1240
+
1241
+ const updated = await api.updateCall(saved.callId, { disposition: 'missed' });
1242
+ ```
1243
+
1244
+ | Method | Description |
1245
+ | ----------------------------- | -------------------------------- |
1246
+ | `setAuthToken(token)` | Update the bearer token |
1247
+ | `fetchHistory(params)` | Fetch paginated call history |
1248
+ | `saveCall(record)` | Create a new call history record |
1249
+ | `updateCall(callId, updates)` | Patch an existing call record |
1250
+
1251
+ ---
1252
+
1253
+ ## Web Component Reference
1254
+
1255
+ ### Web Component Attributes
1256
+
1257
+ The `<convirza-dialer>` custom element accepts the following HTML attributes:
1258
+
1259
+ | Attribute | Type | Required | Description |
1260
+ | -------------------- | -------------------------------------------------------------- | -------- | ------------------------------------------------ |
1261
+ | `access-token` | `string` | Yes\* | Access token from user login |
1262
+ | `refresh-token` | `string` | Yes\* | Refresh token (fallback if access_token expires) |
1263
+ | `auto-configure` | `'true' \| 'false'` | Yes\* | Enable auto-config from OAuth session |
1264
+ | `oauth-endpoint` | `string` | No | OAuth base URL (default: stag-5 internal) |
1265
+ | `api-url` | `string` | No | API base URL for park/history |
1266
+ | `brand-name` | `string` | No | Brand name in header |
1267
+ | `brand-logo` | `string` | No | URL for brand logo |
1268
+ | `theme` | `'light' \| 'dark'` | No | UI theme (default: dark) |
1269
+ | `primary-color` | `string` | No | Primary CSS color (hex/rgb) |
1270
+ | `accent-color` | `string` | No | Accent CSS color |
1271
+ | `position` | `'top-left' \| 'top-right' \| 'bottom-left' \| 'bottom-right'` | No | Widget corner position (default: bottom-right) |
1272
+ | `state` | `'collapsed' \| 'expanded'` | No | Initial state (default: collapsed) |
1273
+ | `z-index` | `number` | No | CSS z-index of the widget |
1274
+ | `park-poll-interval` | `number` | No | Park slots polling interval in ms (default 5000) |
1275
+
1276
+ **Required only for auto-configure mode.** Widget fetches SIP credentials from OAuth session endpoint automatically.
1277
+
1278
+ ### Web Component Public Methods
1279
+
1280
+ All methods can be called on the element reference.
1281
+
1282
+ #### `element.placeCall(phoneNumber, options?)`
1283
+
1284
+ Place an outbound call and return a `CallSession` handle.
1285
+
1286
+ ```ts
1287
+ placeCall(phoneNumber: string, options?: CallOptions): CallSession
1288
+ ```
1289
+
1290
+ **Parameters:**
1291
+
1292
+ - `phoneNumber`: E.164 phone number or SIP extension
1293
+ - `options.callerId`: Override caller ID
1294
+ - `options.displayName`: Override caller display name
1295
+
1296
+ **Returns:** `CallSession` object:
1297
+
1298
+ ```ts
1299
+ interface CallSession {
1300
+ phoneNumber: string;
1301
+ status: CallStatus; // Current call status
1302
+ duration: number; // Current duration in ms
1303
+ onAnswered(callback: () => void): void;
1304
+ onEnded(callback: (duration: number) => void): void;
1305
+ end(): void;
1306
+ }
1307
+ ```
1308
+
1309
+ **Example:**
1310
+
1311
+ ```js
1312
+ const el = document.querySelector('convirza-dialer');
1313
+ const session = el.placeCall('+15551234567');
1314
+
1315
+ session.onAnswered(() => {
1316
+ console.log('Call connected');
1317
+ });
1318
+
1319
+ session.onEnded((duration) => {
1320
+ console.log('Call ended after', duration, 'ms');
1321
+ });
1322
+
1323
+ // Hang up programmatically
1324
+ setTimeout(() => session.end(), 30000); // Hang up after 30s
1325
+ ```
1326
+
1327
+ #### `element.endCall()`
1328
+
1329
+ Hang up the active call.
1330
+
1331
+ ```ts
1332
+ endCall(): void
1333
+ ```
1334
+
1335
+ **Example:**
1336
+
1337
+ ```js
1338
+ element.endCall();
1339
+ ```
1340
+
1341
+ #### `element.mute(enable?)`
1342
+
1343
+ Mute/unmute microphone. Toggles if `enable` omitted.
1344
+
1345
+ ```ts
1346
+ mute(enable?: boolean): boolean // returns new mute state
1347
+ ```
1348
+
1349
+ #### `element.hold(enable?)`
1350
+
1351
+ Put call on hold. Toggles if `enable` omitted.
1352
+
1353
+ ```ts
1354
+ hold(enable?: boolean): boolean // returns new hold state
1355
+ ```
1356
+
1357
+ #### `element.sendDTMF(digit)`
1358
+
1359
+ Send DTMF digit via SIP INFO.
1360
+
1361
+ ```ts
1362
+ sendDTMF(digit: string): void // '0'-'9', '*', '#'
1363
+ ```
1364
+
1365
+ #### `element.startRecording()` / `element.stopRecording()`
1366
+
1367
+ Start/stop server-side recording.
1368
+
1369
+ ```ts
1370
+ startRecording(): void
1371
+ stopRecording(): void
1372
+ ```
1373
+
1374
+ #### `element.open()`
1375
+
1376
+ Expand the widget.
1377
+
1378
+ ```ts
1379
+ open(): void
1380
+ ```
1381
+
1382
+ #### `element.close()`
1383
+
1384
+ Collapse the widget to FAB.
1385
+
1386
+ ```ts
1387
+ close(): void
1388
+ ```
1389
+
1390
+ #### `element.toggle()`
1391
+
1392
+ Toggle widget open/closed.
1393
+
1394
+ ```ts
1395
+ toggle(): void
1396
+ ```
1397
+
1398
+ #### `element.setTheme(options)`
1399
+
1400
+ Dynamically update theme.
1401
+
1402
+ ```ts
1403
+ setTheme(options: {
1404
+ theme?: 'dark' | 'light';
1405
+ primaryColor?: string;
1406
+ accentColor?: string;
1407
+ brandName?: string;
1408
+ }): void
1409
+ ```
1410
+
1411
+ **Example:**
1412
+
1413
+ ```js
1414
+ element.setTheme({
1415
+ theme: 'dark',
1416
+ primaryColor: '#6366f1',
1417
+ brandName: 'Acme Corp',
1418
+ });
1419
+ ```
1420
+
1421
+ #### `element.clearCallHistoryCache()`
1422
+
1423
+ Clear IndexedDB call history cache and reload from backend.
1424
+
1425
+ ```ts
1426
+ async clearCallHistoryCache(): Promise<void>
1427
+ ```
1428
+
1429
+ **Example:**
1430
+
1431
+ ```js
1432
+ await element.clearCallHistoryCache();
1433
+ ```
1434
+
1435
+ ### Web Component Events
1436
+
1437
+ The element dispatches the following `CustomEvent`s:
1438
+
1439
+ #### Authentication Events
1440
+
1441
+ | Event | `detail` | Description |
1442
+ | ------------------ | --------------------------------------------------------------- | ------------------------------------------ |
1443
+ | `token-refreshed` | `{ access_token: string, refresh_token: string, user: object }` | New tokens received (access_token expired) |
1444
+ | `auth-failed` | `{ error: string }` | Auth failed (both tokens expired/invalid) |
1445
+ | `sip-registered` | `{}` | SIP registration successful |
1446
+ | `sip-unregistered` | `{}` | SIP registration lost |
1447
+
1448
+ #### Call Events
1449
+
1450
+ | Event | `detail` | Description |
1451
+ | ------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------ |
1452
+ | `call-started` | `{ phoneNumber: string, callerId?: string, direction?: 'inbound'\|'outbound' }` | Outbound call initiated or inbound call answered |
1453
+ | `call-ended` | `{ phoneNumber: string, duration: number, reason?: string }` | Call ended (duration in ms) |
1454
+ | `call-answered` | `{}` | Call connected (200 OK received) |
1455
+ | `state-change` | `{ oldState: CallStatus, newState: CallStatus, metadata?: CallMetadata }` | Call state transition |
1456
+ | `sip-call-mute` | `{ muted: boolean }` | Mute state changed |
1457
+ | `sip-call-hold` | `{ onHold: boolean }` | Hold state changed |
1458
+ | `dtmf-sent` | `{ digit: string }` | DTMF digit sent |
1459
+ | `call-transferred` | `{}` | Blind transfer succeeded |
1460
+
1461
+ #### Widget Events
1462
+
1463
+ | Event | `detail` | Description |
1464
+ | ----------------- | -------------------------------- | -------------------- |
1465
+ | `widget-opened` | `{}` | Widget expanded |
1466
+ | `widget-closed` | `{}` | Widget collapsed |
1467
+ | `dialer-error` | `{ error: string }` | Unhandled error |
1468
+ | `history-updated` | `{ history: CallHistoryItem[] }` | Call history updated |
1469
+
1470
+ **Example:**
1471
+
1472
+ ```js
1473
+ // Auth events
1474
+ element.addEventListener('token-refreshed', (e) => {
1475
+ localStorage.setItem('access_token', e.detail.access_token);
1476
+ localStorage.setItem('refresh_token', e.detail.refresh_token);
1477
+ });
1478
+
1479
+ element.addEventListener('auth-failed', (e) => {
1480
+ console.error('Auth failed:', e.detail.error);
1481
+ window.location.href = '/login';
1482
+ });
1483
+
1484
+ // Call events
1485
+ element.addEventListener('call-started', (e) => {
1486
+ console.log('Call started:', e.detail.phoneNumber);
1487
+ });
1488
+
1489
+ element.addEventListener('call-ended', (e) => {
1490
+ console.log('Call ended after', e.detail.duration / 1000, 'seconds');
1491
+ });
1492
+ ```
1493
+
1494
+ ---
1495
+
1496
+ ## Feature Guides
1497
+
1498
+ ### Call Parking
1499
+
1500
+ Call parking allows transferring an active call to a shared "parking lot" where any user in the domain can retrieve it.
1501
+
1502
+ #### How It Works
1503
+
1504
+ 1. User clicks park slot (e.g., `700`)
1505
+ 2. Widget sends valet park request to backend API
1506
+ 3. Backend uses FreeSWITCH ESL to park call (no SIP REFER)
1507
+ 4. Server responds with success
1508
+ 5. Widget ends local session (call now owned by server)
1509
+ 6. Widget polls backend API to sync park slot occupancy
1510
+ 7. Other users see parked call and can retrieve by dialing park extension
1511
+
1512
+ #### Configuration
1513
+
1514
+ **HTML Attributes:**
1515
+
1516
+ ```html
1517
+ <convirza-dialer
1518
+ api-url="https://stag-5-dialer-apis.convirza.com"
1519
+ auth-token="Bearer xyz"
1520
+ park-poll-interval="5000"
1521
+ ></convirza-dialer>
1522
+ ```
1523
+
1524
+ - `api-url`: Base API URL — park endpoint is `{api-url}/v3/park-slots`
1525
+ - `auth-token`: JWT or API key for backend auth
1526
+ - `park-poll-interval`: Poll interval in ms (default: 5000)
1527
+
1528
+ #### Backend API
1529
+
1530
+ **Required endpoint:**
1531
+
1532
+ ```
1533
+ GET /v3/park-slots?domain={sipDomain}
1534
+ Returns: [
1535
+ {
1536
+ extension: "700",
1537
+ label: "Park 1",
1538
+ occupied: true,
1539
+ parkedCall: {
1540
+ phoneNumber: "+12485794891",
1541
+ parkedBy: "1001",
1542
+ parkedAt: 1719234567890
1543
+ }
1544
+ },
1545
+ ...
1546
+ ]
1547
+
1548
+ POST /v3/park-call
1549
+ Body: { extension: "700", callUuid: "abc-123", sipDomain: "pbx.example.com" }
1550
+ Returns: { success: true }
1551
+ ```
1552
+
1553
+ #### Events
1554
+
1555
+ **`call-parked`** — fired when park succeeds
1556
+
1557
+ ```js
1558
+ element.addEventListener('call-parked', (e) => {
1559
+ // e.detail: { parkExtension, phoneNumber, parkedBy }
1560
+ });
1561
+ ```
1562
+
1563
+ **`park-slots-updated`** — fired on successful API fetch
1564
+
1565
+ ```js
1566
+ element.addEventListener('park-slots-updated', (e) => {
1567
+ // e.detail: { slots: ParkAccount[] }
1568
+ });
1569
+ ```
1570
+
1571
+ **`call-park-failed`** — fired if park fails
1572
+
1573
+ ```js
1574
+ element.addEventListener('call-park-failed', (e) => {
1575
+ // e.detail: { error: string }
1576
+ });
1577
+ ```
1578
+
1579
+ ### Call History Persistence
1580
+
1581
+ Call history is saved to **two tiers**:
1582
+
1583
+ 1. **IndexedDB** (local cache) — instant load, offline support
1584
+ 2. **Backend API** (source of truth) — multi-device sync, compliance
1585
+
1586
+ Widget saves to IndexedDB immediately (non-blocking), then syncs to backend async.
1587
+
1588
+ #### Data Flow
1589
+
1590
+ **On call end:**
1591
+
1592
+ 1. Widget generates UUID `callId`
1593
+ 2. Saves to IndexedDB (instant)
1594
+ 3. POSTs to `/api/call-history` (async, retries on failure)
1595
+ 4. Reloads history from IndexedDB to update UI
1596
+
1597
+ **On SIP registration:**
1598
+
1599
+ 1. Initialize `CallHistoryService`
1600
+ 2. Load from IndexedDB (fast)
1601
+ 3. Background-sync from backend (replaces IndexedDB cache)
1602
+
1603
+ **On page reload:**
1604
+
1605
+ - IndexedDB persists → history visible immediately
1606
+ - Backend sync refreshes on reconnect
1607
+
1608
+ #### Configuration
1609
+
1610
+ **HTML Attributes:**
1611
+
1612
+ ```html
1613
+ <convirza-dialer api-url="https://api.example.com" auth-token="Bearer xyz"></convirza-dialer>
1614
+ ```
1615
+
1616
+ - `api-url`: Base API URL — history endpoint = `{api-url}/api/call-history`
1617
+ - `auth-token`: JWT or API key for backend auth
1618
+
1619
+ **If `api-url` not set:**
1620
+
1621
+ - IndexedDB-only mode (no backend sync)
1622
+ - History persists locally but not across devices
1623
+
1624
+ #### Backend API
1625
+
1626
+ **Required endpoints:**
1627
+
1628
+ ```
1629
+ GET /api/call-history?userId={id}&sipDomain={domain}&limit=100&offset=0
1630
+ POST /api/call-history
1631
+ PATCH /api/call-history/{callId}
1632
+ ```
1633
+
1634
+ **Database schema (PostgreSQL):**
1635
+
1636
+ ```sql
1637
+ CREATE TABLE call_history (
1638
+ id BIGSERIAL PRIMARY KEY,
1639
+ call_id VARCHAR(100) UNIQUE NOT NULL,
1640
+ user_id VARCHAR(50) NOT NULL,
1641
+ sip_domain VARCHAR(100) NOT NULL,
1642
+ phone_number VARCHAR(50) NOT NULL,
1643
+ direction VARCHAR(10) NOT NULL CHECK (direction IN ('inbound', 'outbound')),
1644
+ status VARCHAR(20) NOT NULL CHECK (status IN ('answered', 'missed', 'rejected', 'failed')),
1645
+ started_at TIMESTAMPTZ NOT NULL,
1646
+ ended_at TIMESTAMPTZ,
1647
+ duration_seconds INTEGER DEFAULT 0,
1648
+ recording_url VARCHAR(500),
1649
+ created_at TIMESTAMPTZ DEFAULT NOW(),
1650
+ INDEX idx_user_domain_started (user_id, sip_domain, started_at DESC),
1651
+ INDEX idx_call_id (call_id)
1652
+ );
1653
+ ```
1654
+
1655
+ See `docs/CALL_HISTORY_API.md` for full spec, Node.js examples, security notes.
1656
+
1657
+ #### Events
1658
+
1659
+ **`history-updated`** — fired when history changes
1660
+
1661
+ ```js
1662
+ element.addEventListener('history-updated', (e) => {
1663
+ // e.detail: { history: CallHistoryItem[] }
1664
+ });
1665
+ ```
1666
+
1667
+ ### Audio Device Management
1668
+
1669
+ Microphone and speaker selection with hot-swapping during calls.
1670
+
1671
+ #### Usage
1672
+
1673
+ ```js
1674
+ const devices = await sip.getAudioDevices();
1675
+ // { microphones: [...], speakers: [...] }
1676
+
1677
+ // Set device
1678
+ await sip.setMicrophone(deviceId);
1679
+ await sip.setSpeaker(deviceId);
1680
+
1681
+ // Preferences saved to localStorage: convirza_audio_preferences
1682
+ ```
1683
+
1684
+ **Browser support:**
1685
+
1686
+ - Microphone switching: Chrome, Firefox, Safari, Edge
1687
+ - Speaker switching (`setSinkId`): Chrome, Edge only (silently no-ops in Firefox/Safari)
1688
+
1689
+ ### Call Quality Monitoring
1690
+
1691
+ Polls `RTCPeerConnection.getStats()` every 2s and emits quality metrics.
1692
+
1693
+ **Metrics:**
1694
+
1695
+ - **Bitrate** (kbps)
1696
+ - **Packet loss** (%)
1697
+ - **Jitter** (ms)
1698
+ - **Latency** (ms, round-trip)
1699
+ - **Audio level** (0–1)
1700
+ - **Quality** (`excellent` | `good` | `fair` | `poor`)
1701
+
1702
+ **Usage:**
1703
+
1704
+ ```js
1705
+ const sip = new SipAdapter(config, {
1706
+ onQualityChange: (metrics) => {
1707
+ if (metrics.quality === 'poor') {
1708
+ console.warn('Poor call quality:', metrics);
1709
+ }
1710
+ },
1711
+ });
1712
+
1713
+ // Or poll manually
1714
+ const metrics = sip.getCallQuality();
1715
+ console.log('Packet loss:', metrics.packetLoss + '%');
1716
+ ```
1717
+
1718
+ ### Theming
1719
+
1720
+ #### HTML Attributes (static)
1721
+
1722
+ ```html
1723
+ <convirza-dialer
1724
+ theme="dark"
1725
+ primary-color="#6366f1"
1726
+ accent-color="#22c55e"
1727
+ brand-name="Convirza"
1728
+ brand-logo="https://..."
1729
+ ></convirza-dialer>
1730
+ ```
1731
+
1732
+ #### JavaScript `setTheme()` (dynamic)
1733
+
1734
+ ```js
1735
+ element.setTheme({
1736
+ theme: 'dark',
1737
+ primaryColor: '#6366f1',
1738
+ accentColor: '#10b981',
1739
+ brandName: 'Acme Corp',
1740
+ });
1741
+ ```
1742
+
1743
+ #### Direct `setAttribute()`
1744
+
1745
+ ```js
1746
+ element.setAttribute('primary-color', '#6366f1');
1747
+ ```
1748
+
1749
+ CSS vars injected at runtime via `style.setProperty('--primary-color', ...)`.
1750
+
1751
+ ---
1752
+
1753
+ ## Types
1754
+
1755
+ ```ts
1756
+ // Widget position
1757
+ type WidgetPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
1758
+
1759
+ // Call lifecycle state
1760
+ type CallStatus = 'idle' | 'dialing' | 'ringing' | 'connected' | 'ended' | 'error';
1761
+
1762
+ // Widget open/closed state
1763
+ type WidgetState = 'collapsed' | 'expanded';
1764
+
1765
+ // UI theme
1766
+ type ThemeMode = 'light' | 'dark';
1767
+
1768
+ // Call direction and outcome
1769
+ type CallDirection = 'inbound' | 'outbound';
1770
+ type CallDisposition = 'answered' | 'missed' | 'rejected' | 'failed';
1771
+
1772
+ // Events emitted by ConvirzaDialer class
1773
+ interface DialerEvents {
1774
+ onOpen?: () => void;
1775
+ onClose?: () => void;
1776
+ onCallStarted?: (phoneNumber: string) => void;
1777
+ onCallEnded?: (phoneNumber: string, duration: number) => void;
1778
+ onStateChange?: (oldState: CallStatus, newState: CallStatus, metadata?: CallMetadata) => void;
1779
+ }
1780
+
1781
+ // Returned by placeCall()
1782
+ interface CallSession {
1783
+ phoneNumber: string;
1784
+ status: CallStatus;
1785
+ duration: number;
1786
+ onAnswered(callback: () => void): void;
1787
+ onEnded(callback: (duration: number) => void): void;
1788
+ end(): void;
1789
+ }
1790
+ ```
1791
+
1792
+ ---
1793
+
1794
+ ## Browser Support
1795
+
1796
+ Requires a browser with WebRTC (`RTCPeerConnection`) and WebSocket support.
1797
+
1798
+ - **Chrome** 74+
1799
+ - **Firefox** 69+
1800
+ - **Safari** 14.1+
1801
+ - **Edge** 79+
1802
+
1803
+ **Feature availability:**
1804
+
1805
+ - **Speaker selection** (`setSinkId`): Chrome/Edge only
1806
+ - **Microphone hot-swap**: All browsers
1807
+ - **WebRTC stats**: All browsers
1808
+
1809
+ ---
1810
+
1811
+ ## Project Structure
1812
+
1813
+ ```
1814
+ convirza-dialer-1/
1815
+ ├── packages/web-sdk/
1816
+ │ ├── src/ # Source code (TypeScript)
1817
+ │ │ ├── ui/ # Web component
1818
+ │ │ ├── sip/ # SIP adapter
1819
+ │ │ ├── api/ # REST clients
1820
+ │ │ ├── media/ # Audio device management
1821
+ │ │ ├── constants/ # Configuration
1822
+ │ │ └── types/ # TypeScript types
1823
+ │ ├── test/ # Unit tests
1824
+ │ ├── dist/ # Built output (published)
1825
+ │ │ ├── index.esm.js # ES modules
1826
+ │ │ ├── index.cjs.js # CommonJS
1827
+ │ │ ├── index.umd.js # UMD (browser)
1828
+ │ │ └── types/ # TypeScript declarations
1829
+ │ └── package.json # Package metadata
1830
+ └── demo/ # Demo files (root level, not published)
1831
+ ├── demo.html # Interactive demo
1832
+ ├── sip.bundle.js # Bundled SIP.js
1833
+ └── README.md # Demo instructions
1834
+ ```
1835
+
1836
+ **For development:** Work in `src/` directory.
1837
+ **For users:** Import from `dist/` (handled automatically by package.json).
1838
+
1839
+ ## License
1840
+
1841
+ PROPRIETARY — Copyright Convirza. All rights reserved.