@weldsuite/helpdesk-widget-sdk 1.0.16 → 1.0.17

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/index.umd.js CHANGED
@@ -37,32 +37,6 @@
37
37
  closeOnClick: true,
38
38
  },
39
39
  },
40
- customization: {
41
- primaryColor: '#000000',
42
- accentColor: '#3b82f6',
43
- backgroundColor: '#ffffff',
44
- textColor: '#111827',
45
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
46
- fontSize: '14px',
47
- borderRadius: '12px',
48
- },
49
- features: {
50
- attachments: true,
51
- reactions: true,
52
- typing: true,
53
- readReceipts: true,
54
- offlineMode: false,
55
- fileUpload: true,
56
- imageUpload: true,
57
- voiceMessages: false,
58
- videoMessages: false,
59
- },
60
- mobile: {
61
- fullScreen: true,
62
- scrollLock: true,
63
- keyboardHandling: 'auto',
64
- safeAreaInsets: true,
65
- },
66
40
  auth: {
67
41
  enabled: true,
68
42
  mode: 'anonymous',
@@ -106,6 +80,7 @@
106
80
  validateConfig(config);
107
81
  return {
108
82
  widgetId: config.widgetId,
83
+ testMode: config.testMode,
109
84
  api: {
110
85
  ...DEFAULT_CONFIG.api,
111
86
  widgetId: config.widgetId,
@@ -135,18 +110,6 @@
135
110
  ...config.iframes?.backdrop,
136
111
  },
137
112
  },
138
- customization: {
139
- ...DEFAULT_CONFIG.customization,
140
- ...config.customization,
141
- },
142
- features: {
143
- ...DEFAULT_CONFIG.features,
144
- ...config.features,
145
- },
146
- mobile: {
147
- ...DEFAULT_CONFIG.mobile,
148
- ...config.mobile,
149
- },
150
113
  auth: {
151
114
  ...DEFAULT_CONFIG.auth,
152
115
  ...config.auth,
@@ -389,6 +352,8 @@
389
352
  this.modalContainer = null;
390
353
  this.styleElement = null;
391
354
  this.messageBroker = null;
355
+ // Guard flag to prevent double-binding event listeners
356
+ this.eventListenersBound = false;
392
357
  this.config = config;
393
358
  this.logger = new Logger(config.logging);
394
359
  this.deviceInfo = detectDevice();
@@ -428,18 +393,27 @@
428
393
  }
429
394
  /**
430
395
  * Create root container structure
396
+ * Reuses existing container if it has the same widgetId (singleton behavior)
431
397
  */
432
398
  createRootContainer() {
433
- // Check if already exists
434
- let existingContainer = document.getElementById('weld-container');
399
+ const existingContainer = document.getElementById('weld-container');
435
400
  if (existingContainer) {
436
- this.logger.warn('Weld container already exists, removing old instance');
401
+ // Reuse if same widgetId
402
+ if (existingContainer.getAttribute('data-widget-id') === this.config.widgetId) {
403
+ this.logger.debug('Reusing existing root container');
404
+ this.rootContainer = existingContainer;
405
+ this.appContainer = existingContainer.querySelector('.weld-app');
406
+ this.modalContainer = document.getElementById('weld-modal-container');
407
+ return;
408
+ }
409
+ this.logger.warn('Weld container already exists with different widgetId, removing old instance');
437
410
  existingContainer.remove();
438
411
  }
439
412
  // Create root container
440
413
  this.rootContainer = document.createElement('div');
441
414
  this.rootContainer.id = 'weld-container';
442
415
  this.rootContainer.className = 'weld-namespace';
416
+ this.rootContainer.setAttribute('data-widget-id', this.config.widgetId);
443
417
  // Create app container
444
418
  this.appContainer = document.createElement('div');
445
419
  this.appContainer.className = 'weld-app';
@@ -474,17 +448,7 @@
474
448
  * Generate CSS for containers
475
449
  */
476
450
  generateCSS() {
477
- const { customization } = this.config;
478
451
  return `
479
- /* Weld Container */
480
- #weld-container {
481
- --weld-color-primary: ${customization.primaryColor};
482
- --weld-color-accent: ${customization.accentColor};
483
- --weld-font-family: ${customization.fontFamily};
484
- --weld-font-size-base: ${customization.fontSize};
485
- --weld-radius-xl: ${customization.borderRadius};
486
- }
487
-
488
452
  /* Import main stylesheet */
489
453
  @import url('/styles/index.css');
490
454
 
@@ -508,6 +472,11 @@
508
472
  * Create launcher iframe
509
473
  */
510
474
  async createLauncherIframe() {
475
+ // Guard: skip if launcher iframe already exists
476
+ if (this.iframes.has(exports.IframeType.LAUNCHER)) {
477
+ this.logger.debug('Launcher iframe already exists, skipping creation');
478
+ return;
479
+ }
511
480
  const { iframes } = this.config;
512
481
  const { launcher } = iframes;
513
482
  // Create container
@@ -535,9 +504,12 @@
535
504
  width: 100%;
536
505
  height: 100%;
537
506
  border: none;
538
- background: transparent;
507
+ background: none;
508
+ color-scheme: none;
539
509
  display: block;
540
510
  pointer-events: auto;
511
+ border-radius: 50%;
512
+ filter: drop-shadow(rgba(9, 14, 21, 0.54) 0px 1px 6px) drop-shadow(rgba(9, 14, 21, 0.9) 0px 2px 32px);
541
513
  `;
542
514
  iframe.setAttribute('allow', 'clipboard-write');
543
515
  iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups');
@@ -553,20 +525,36 @@
553
525
  createdAt: Date.now(),
554
526
  });
555
527
  // When DOM loads, notify MessageBroker to send weld:init
528
+ let launcherRetried = false;
556
529
  iframe.onload = () => {
557
530
  const metadata = this.iframes.get(exports.IframeType.LAUNCHER);
558
531
  if (metadata) {
559
532
  this.logger.debug('Launcher iframe DOM loaded');
560
- // Notify MessageBroker that DOM is loaded (triggers weld:init)
561
533
  this.messageBroker?.setIframeDomLoaded(exports.IframeType.LAUNCHER);
562
534
  }
563
535
  };
536
+ iframe.onerror = () => {
537
+ this.logger.error('Launcher iframe failed to load');
538
+ if (!launcherRetried) {
539
+ launcherRetried = true;
540
+ this.logger.info('Retrying launcher iframe load...');
541
+ setTimeout(() => { iframe.src = this.buildIframeUrl(launcher.url); }, 3000);
542
+ }
543
+ else {
544
+ this.config.onError?.(new Error('Failed to load widget launcher'));
545
+ }
546
+ };
564
547
  this.logger.debug('Launcher iframe created');
565
548
  }
566
549
  /**
567
550
  * Create widget iframe
568
551
  */
569
552
  async createWidgetIframe() {
553
+ // Guard: skip if widget iframe already exists
554
+ if (this.iframes.has(exports.IframeType.WIDGET)) {
555
+ this.logger.debug('Widget iframe already exists, skipping creation');
556
+ return;
557
+ }
570
558
  const { iframes } = this.config;
571
559
  const { widget } = iframes;
572
560
  // Create container
@@ -647,14 +635,25 @@
647
635
  createdAt: Date.now(),
648
636
  });
649
637
  // When DOM loads, notify MessageBroker to send weld:init
638
+ let widgetRetried = false;
650
639
  iframe.onload = () => {
651
640
  const metadata = this.iframes.get(exports.IframeType.WIDGET);
652
641
  if (metadata) {
653
642
  this.logger.debug('Widget iframe DOM loaded');
654
- // Notify MessageBroker that DOM is loaded (triggers weld:init)
655
643
  this.messageBroker?.setIframeDomLoaded(exports.IframeType.WIDGET);
656
644
  }
657
645
  };
646
+ iframe.onerror = () => {
647
+ this.logger.error('Widget iframe failed to load');
648
+ if (!widgetRetried) {
649
+ widgetRetried = true;
650
+ this.logger.info('Retrying widget iframe load...');
651
+ setTimeout(() => { iframe.src = this.buildIframeUrl(widget.url); }, 3000);
652
+ }
653
+ else {
654
+ this.config.onError?.(new Error('Failed to load widget'));
655
+ }
656
+ };
658
657
  this.logger.debug('Widget iframe created');
659
658
  }
660
659
  /**
@@ -675,12 +674,21 @@
675
674
  url.searchParams.set('device', this.deviceInfo.type);
676
675
  url.searchParams.set('mobile', String(this.deviceInfo.isMobile));
677
676
  url.searchParams.set('parentOrigin', window.location.origin);
677
+ if (this.config.testMode) {
678
+ url.searchParams.set('testMode', 'true');
679
+ }
678
680
  return url.toString();
679
681
  }
680
682
  /**
681
683
  * Setup event listeners
682
684
  */
683
685
  setupEventListeners() {
686
+ // Guard: prevent double-binding
687
+ if (this.eventListenersBound) {
688
+ this.logger.debug('Event listeners already bound, skipping');
689
+ return;
690
+ }
691
+ this.eventListenersBound = true;
684
692
  // Window resize - use bound handler for proper cleanup
685
693
  window.addEventListener('resize', this.boundHandleResize);
686
694
  // Orientation change - use bound handler for proper cleanup
@@ -783,7 +791,7 @@
783
791
  iframe.container.style.transform = 'scale(1) translateY(0)';
784
792
  }
785
793
  // Handle mobile scroll lock
786
- if (this.deviceInfo.isMobile && type === exports.IframeType.WIDGET && this.config.mobile.scrollLock) {
794
+ if (this.deviceInfo.isMobile && type === exports.IframeType.WIDGET) {
787
795
  document.body.classList.add('weld-mobile-open');
788
796
  }
789
797
  // Hide launcher on mobile when widget is open (full-screen mode)
@@ -882,6 +890,8 @@
882
890
  this.iframes.clear();
883
891
  // Clear messageBroker reference
884
892
  this.messageBroker = null;
893
+ // Reset guard flag
894
+ this.eventListenersBound = false;
885
895
  this.logger.info('IframeManager destroyed');
886
896
  }
887
897
  }
@@ -949,6 +959,8 @@
949
959
  // Events
950
960
  MessageType["EVENT_TRACK"] = "weld:event:track";
951
961
  MessageType["ERROR_REPORT"] = "weld:error:report";
962
+ // Page tracking
963
+ MessageType["PAGE_CHANGE"] = "weld:page:change";
952
964
  // API responses
953
965
  MessageType["API_SUCCESS"] = "weld:api:success";
954
966
  MessageType["API_ERROR"] = "weld:api:error";
@@ -1527,8 +1539,6 @@
1527
1539
  iframeType,
1528
1540
  config: {
1529
1541
  api: this.config.api,
1530
- customization: this.config.customization,
1531
- features: this.config.features,
1532
1542
  },
1533
1543
  };
1534
1544
  const message = createMessage('weld:init', MessageOrigin.PARENT, initPayload);
@@ -2371,7 +2381,7 @@
2371
2381
  }
2372
2382
  }
2373
2383
 
2374
- var version = "1.0.16";
2384
+ var version = "1.0.17";
2375
2385
  var packageJson = {
2376
2386
  version: version};
2377
2387
 
@@ -2379,6 +2389,16 @@
2379
2389
  * Weld SDK - Main Entry Point
2380
2390
  * Public API for the Weld helpdesk widget
2381
2391
  */
2392
+ /**
2393
+ * Module-level singleton registry keyed by widgetId
2394
+ */
2395
+ const sdkRegistry = new Map();
2396
+ /**
2397
+ * SessionStorage key helpers
2398
+ */
2399
+ function openStateKey(widgetId) {
2400
+ return `weld-widget-open-${widgetId}`;
2401
+ }
2382
2402
  /**
2383
2403
  * SDK initialization status
2384
2404
  */
@@ -2401,6 +2421,8 @@
2401
2421
  this.readyResolve = null;
2402
2422
  // Subscription IDs for cleanup
2403
2423
  this.subscriptionIds = [];
2424
+ // Page tracking cleanup
2425
+ this.pageTrackingCleanup = null;
2404
2426
  /**
2405
2427
  * Update user attributes (Intercom-style, with rate limiting)
2406
2428
  * Limited to 20 calls per page load to prevent abuse
@@ -2439,6 +2461,13 @@
2439
2461
  console.log('[Weld SDK] Received message:', event.data.type);
2440
2462
  }
2441
2463
  if (event.data?.type === 'launcher:clicked') {
2464
+ if (this.status !== SDKStatus.READY) {
2465
+ console.log('[Weld SDK] Launcher clicked but SDK not ready yet — waiting...');
2466
+ this.readyPromise?.then(() => {
2467
+ this.handleLauncherClickMessage(event);
2468
+ });
2469
+ return;
2470
+ }
2442
2471
  // Toggle behavior - if widget is open, close it; if closed, open it
2443
2472
  const state = this.stateCoordinator.getState();
2444
2473
  if (state.widget.isOpen) {
@@ -2451,9 +2480,24 @@
2451
2480
  }
2452
2481
  }
2453
2482
  if (event.data?.type === 'weld:close') {
2483
+ if (this.status !== SDKStatus.READY)
2484
+ return;
2454
2485
  console.log('[Weld SDK] Widget close requested');
2455
2486
  this.close();
2456
2487
  }
2488
+ if (event.data?.type === 'weld:unread-count') {
2489
+ const count = event.data.count ?? 0;
2490
+ // Forward to launcher iframe
2491
+ const launcherIframe = this.iframeManager.getIframe(exports.IframeType.LAUNCHER);
2492
+ if (launcherIframe?.element?.contentWindow) {
2493
+ launcherIframe.element.contentWindow.postMessage({
2494
+ type: 'weld:unread-count',
2495
+ count
2496
+ }, '*');
2497
+ }
2498
+ // Update state coordinator for external API consumers
2499
+ this.stateCoordinator.setBadgeCount(count);
2500
+ }
2457
2501
  if (event.data?.type === 'weld:image:open' && event.data?.url) {
2458
2502
  this.showImageLightbox(event.data.url);
2459
2503
  }
@@ -2715,6 +2759,13 @@
2715
2759
  this.logger.info('WeldSDK ready');
2716
2760
  // Call onReady callback
2717
2761
  this.config.onReady?.();
2762
+ // Start tracking page URL changes
2763
+ this.startPageTracking();
2764
+ // Auto-open if widget was previously open (persisted in sessionStorage)
2765
+ if (this.wasOpen()) {
2766
+ this.logger.info('Restoring previously open widget from sessionStorage');
2767
+ this.open();
2768
+ }
2718
2769
  }
2719
2770
  catch (error) {
2720
2771
  this.status = SDKStatus.ERROR;
@@ -2793,6 +2844,58 @@
2793
2844
  isReady() {
2794
2845
  return this.status === SDKStatus.READY;
2795
2846
  }
2847
+ /**
2848
+ * Update callbacks on an existing instance (used by singleton reuse)
2849
+ */
2850
+ updateCallbacks(config) {
2851
+ if (config.onReady !== undefined)
2852
+ this.config.onReady = config.onReady;
2853
+ if (config.onOpen !== undefined)
2854
+ this.config.onOpen = config.onOpen;
2855
+ if (config.onClose !== undefined)
2856
+ this.config.onClose = config.onClose;
2857
+ if (config.onError !== undefined)
2858
+ this.config.onError = config.onError;
2859
+ if (config.onDestroy !== undefined)
2860
+ this.config.onDestroy = config.onDestroy;
2861
+ if (config.onMinimize !== undefined)
2862
+ this.config.onMinimize = config.onMinimize;
2863
+ if (config.onMaximize !== undefined)
2864
+ this.config.onMaximize = config.onMaximize;
2865
+ }
2866
+ /**
2867
+ * Persist open/closed state to sessionStorage
2868
+ */
2869
+ persistOpenState(isOpen) {
2870
+ try {
2871
+ sessionStorage.setItem(openStateKey(this.config.widgetId), isOpen ? 'true' : 'false');
2872
+ }
2873
+ catch {
2874
+ // sessionStorage might not be available
2875
+ }
2876
+ }
2877
+ /**
2878
+ * Clear persisted state from sessionStorage
2879
+ */
2880
+ clearPersistedState() {
2881
+ try {
2882
+ sessionStorage.removeItem(openStateKey(this.config.widgetId));
2883
+ }
2884
+ catch {
2885
+ // sessionStorage might not be available
2886
+ }
2887
+ }
2888
+ /**
2889
+ * Check if widget was previously open (from sessionStorage)
2890
+ */
2891
+ wasOpen() {
2892
+ try {
2893
+ return sessionStorage.getItem(openStateKey(this.config.widgetId)) === 'true';
2894
+ }
2895
+ catch {
2896
+ return false;
2897
+ }
2898
+ }
2796
2899
  /**
2797
2900
  * Open the widget
2798
2901
  */
@@ -2811,6 +2914,7 @@
2811
2914
  if (launcherIframe?.element?.contentWindow) {
2812
2915
  launcherIframe.element.contentWindow.postMessage({ type: 'weld:widget-opened' }, '*');
2813
2916
  }
2917
+ this.persistOpenState(true);
2814
2918
  this.config.onOpen?.();
2815
2919
  }
2816
2920
  /**
@@ -2831,6 +2935,7 @@
2831
2935
  if (launcherIframe?.element?.contentWindow) {
2832
2936
  launcherIframe.element.contentWindow.postMessage({ type: 'weld:widget-closed' }, '*');
2833
2937
  }
2938
+ this.persistOpenState(false);
2834
2939
  this.config.onClose?.();
2835
2940
  }
2836
2941
  /**
@@ -3019,6 +3124,8 @@
3019
3124
  });
3020
3125
  // Broadcast logout to iframes
3021
3126
  this.messageBroker.broadcast('weld:auth:logout', {});
3127
+ // Clear persisted widget state
3128
+ this.clearPersistedState();
3022
3129
  // Clear any stored session data
3023
3130
  try {
3024
3131
  const prefix = 'weld-';
@@ -3160,6 +3267,63 @@
3160
3267
  this.logger.setLevel('warn');
3161
3268
  this.logger.info('Debug mode disabled');
3162
3269
  }
3270
+ /**
3271
+ * Send a page change message to the widget iframe
3272
+ */
3273
+ sendPageChange(url, title) {
3274
+ const widgetIframe = this.iframeManager.getIframe(exports.IframeType.WIDGET);
3275
+ if (widgetIframe?.element?.contentWindow) {
3276
+ widgetIframe.element.contentWindow.postMessage({
3277
+ type: 'weld:page:change',
3278
+ url,
3279
+ title,
3280
+ timestamp: Date.now(),
3281
+ }, '*');
3282
+ }
3283
+ }
3284
+ /**
3285
+ * Start tracking page URL changes (SPA navigations + popstate)
3286
+ */
3287
+ startPageTracking() {
3288
+ let lastUrl = window.location.href;
3289
+ let debounceTimer = null;
3290
+ const notifyChange = () => {
3291
+ const currentUrl = window.location.href;
3292
+ if (currentUrl !== lastUrl) {
3293
+ lastUrl = currentUrl;
3294
+ this.sendPageChange(currentUrl, document.title);
3295
+ }
3296
+ };
3297
+ const debouncedNotify = () => {
3298
+ if (debounceTimer)
3299
+ clearTimeout(debounceTimer);
3300
+ debounceTimer = setTimeout(notifyChange, 300);
3301
+ };
3302
+ // Send initial page
3303
+ this.sendPageChange(window.location.href, document.title);
3304
+ // Monkey-patch history.pushState and history.replaceState
3305
+ const origPushState = history.pushState.bind(history);
3306
+ const origReplaceState = history.replaceState.bind(history);
3307
+ history.pushState = function (...args) {
3308
+ origPushState(...args);
3309
+ debouncedNotify();
3310
+ };
3311
+ history.replaceState = function (...args) {
3312
+ origReplaceState(...args);
3313
+ debouncedNotify();
3314
+ };
3315
+ // Listen for popstate (browser back/forward)
3316
+ const handlePopstate = () => debouncedNotify();
3317
+ window.addEventListener('popstate', handlePopstate);
3318
+ // Store cleanup
3319
+ this.pageTrackingCleanup = () => {
3320
+ if (debounceTimer)
3321
+ clearTimeout(debounceTimer);
3322
+ window.removeEventListener('popstate', handlePopstate);
3323
+ history.pushState = origPushState;
3324
+ history.replaceState = origReplaceState;
3325
+ };
3326
+ }
3163
3327
  /**
3164
3328
  * Ensure SDK is ready before operation
3165
3329
  */
@@ -3168,11 +3332,26 @@
3168
3332
  throw new Error('SDK not ready. Call init() first.');
3169
3333
  }
3170
3334
  }
3335
+ /**
3336
+ * Detach from the current component lifecycle without destroying the widget.
3337
+ * Use this as a React useEffect cleanup — the widget stays alive across navigations.
3338
+ */
3339
+ detach() {
3340
+ // No-op: widget stays alive in the singleton registry
3341
+ this.logger.debug('WeldSDK detached (no-op, widget stays alive)');
3342
+ }
3171
3343
  /**
3172
3344
  * Destroy SDK and cleanup
3173
3345
  */
3174
3346
  destroy() {
3175
3347
  this.logger.info('Destroying WeldSDK');
3348
+ // Remove from singleton registry
3349
+ sdkRegistry.delete(this.config.widgetId);
3350
+ // Clear persisted state
3351
+ this.clearPersistedState();
3352
+ // Stop page tracking
3353
+ this.pageTrackingCleanup?.();
3354
+ this.pageTrackingCleanup = null;
3176
3355
  // Remove event listener using bound handler
3177
3356
  window.removeEventListener('message', this.boundHandleLauncherClick);
3178
3357
  // Unsubscribe from all message broker subscriptions
@@ -3192,13 +3371,33 @@
3192
3371
  }
3193
3372
  }
3194
3373
  /**
3195
- * Create and initialize WeldSDK instance
3374
+ * Create and initialize WeldSDK instance.
3375
+ * Uses singleton pattern — if an instance for the same widgetId already exists
3376
+ * and is not destroyed, updates callbacks and returns the existing instance.
3196
3377
  */
3197
3378
  async function createWeldSDK(config) {
3379
+ const widgetId = config.widgetId;
3380
+ // Check for existing, non-destroyed instance
3381
+ const existing = sdkRegistry.get(widgetId);
3382
+ if (existing && existing.getStatus() !== 'destroyed') {
3383
+ existing.updateCallbacks(config);
3384
+ return existing;
3385
+ }
3198
3386
  const sdk = new WeldSDK(config);
3387
+ sdkRegistry.set(widgetId, sdk);
3199
3388
  await sdk.init();
3200
3389
  return sdk;
3201
3390
  }
3391
+ /**
3392
+ * Explicitly destroy a WeldSDK instance by widgetId.
3393
+ * Use this for logout or when you need to fully remove the widget.
3394
+ */
3395
+ function destroyWeldSDK(widgetId) {
3396
+ const sdk = sdkRegistry.get(widgetId);
3397
+ if (sdk) {
3398
+ sdk.destroy();
3399
+ }
3400
+ }
3202
3401
 
3203
3402
  exports.DEFAULT_CONFIG = DEFAULT_CONFIG;
3204
3403
  exports.HelpdeskWidget = WeldSDK;
@@ -3217,6 +3416,8 @@
3217
3416
  exports.deepMerge = deepMerge;
3218
3417
  exports.default = WeldSDK;
3219
3418
  exports.defaultLogger = defaultLogger;
3419
+ exports.destroyHelpdeskWidget = destroyWeldSDK;
3420
+ exports.destroyWeldSDK = destroyWeldSDK;
3220
3421
  exports.formatFileSize = formatFileSize;
3221
3422
  exports.getStateValue = getStateValue;
3222
3423
  exports.hasRequiredProperties = hasRequiredProperties;