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