@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/react.js CHANGED
@@ -37,32 +37,6 @@ const DEFAULT_CONFIG = {
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 @@ function resolveConfig(config) {
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 @@ function resolveConfig(config) {
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 @@ class IframeManager {
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 @@ class IframeManager {
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 @@ class IframeManager {
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 @@ class IframeManager {
508
472
  * Create launcher iframe
509
473
  */
510
474
  async createLauncherIframe() {
475
+ // Guard: skip if launcher iframe already exists
476
+ if (this.iframes.has(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 @@ class IframeManager {
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 @@ class IframeManager {
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(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(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(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 @@ class IframeManager {
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(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(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 @@ class IframeManager {
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 @@ class IframeManager {
783
791
  iframe.container.style.transform = 'scale(1) translateY(0)';
784
792
  }
785
793
  // Handle mobile scroll lock
786
- if (this.deviceInfo.isMobile && type === IframeType.WIDGET && this.config.mobile.scrollLock) {
794
+ if (this.deviceInfo.isMobile && type === 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 @@ class IframeManager {
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 @@ var MessageType;
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";
@@ -1488,8 +1500,6 @@ class MessageBroker {
1488
1500
  iframeType,
1489
1501
  config: {
1490
1502
  api: this.config.api,
1491
- customization: this.config.customization,
1492
- features: this.config.features,
1493
1503
  },
1494
1504
  };
1495
1505
  const message = createMessage('weld:init', MessageOrigin.PARENT, initPayload);
@@ -2162,7 +2172,7 @@ class StateCoordinator {
2162
2172
  }
2163
2173
  }
2164
2174
 
2165
- var version = "1.0.16";
2175
+ var version = "1.0.17";
2166
2176
  var packageJson = {
2167
2177
  version: version};
2168
2178
 
@@ -2170,6 +2180,16 @@ var packageJson = {
2170
2180
  * Weld SDK - Main Entry Point
2171
2181
  * Public API for the Weld helpdesk widget
2172
2182
  */
2183
+ /**
2184
+ * Module-level singleton registry keyed by widgetId
2185
+ */
2186
+ const sdkRegistry = new Map();
2187
+ /**
2188
+ * SessionStorage key helpers
2189
+ */
2190
+ function openStateKey(widgetId) {
2191
+ return `weld-widget-open-${widgetId}`;
2192
+ }
2173
2193
  /**
2174
2194
  * SDK initialization status
2175
2195
  */
@@ -2192,6 +2212,8 @@ class WeldSDK {
2192
2212
  this.readyResolve = null;
2193
2213
  // Subscription IDs for cleanup
2194
2214
  this.subscriptionIds = [];
2215
+ // Page tracking cleanup
2216
+ this.pageTrackingCleanup = null;
2195
2217
  /**
2196
2218
  * Update user attributes (Intercom-style, with rate limiting)
2197
2219
  * Limited to 20 calls per page load to prevent abuse
@@ -2230,6 +2252,13 @@ class WeldSDK {
2230
2252
  console.log('[Weld SDK] Received message:', event.data.type);
2231
2253
  }
2232
2254
  if (event.data?.type === 'launcher:clicked') {
2255
+ if (this.status !== SDKStatus.READY) {
2256
+ console.log('[Weld SDK] Launcher clicked but SDK not ready yet — waiting...');
2257
+ this.readyPromise?.then(() => {
2258
+ this.handleLauncherClickMessage(event);
2259
+ });
2260
+ return;
2261
+ }
2233
2262
  // Toggle behavior - if widget is open, close it; if closed, open it
2234
2263
  const state = this.stateCoordinator.getState();
2235
2264
  if (state.widget.isOpen) {
@@ -2242,9 +2271,24 @@ class WeldSDK {
2242
2271
  }
2243
2272
  }
2244
2273
  if (event.data?.type === 'weld:close') {
2274
+ if (this.status !== SDKStatus.READY)
2275
+ return;
2245
2276
  console.log('[Weld SDK] Widget close requested');
2246
2277
  this.close();
2247
2278
  }
2279
+ if (event.data?.type === 'weld:unread-count') {
2280
+ const count = event.data.count ?? 0;
2281
+ // Forward to launcher iframe
2282
+ const launcherIframe = this.iframeManager.getIframe(IframeType.LAUNCHER);
2283
+ if (launcherIframe?.element?.contentWindow) {
2284
+ launcherIframe.element.contentWindow.postMessage({
2285
+ type: 'weld:unread-count',
2286
+ count
2287
+ }, '*');
2288
+ }
2289
+ // Update state coordinator for external API consumers
2290
+ this.stateCoordinator.setBadgeCount(count);
2291
+ }
2248
2292
  if (event.data?.type === 'weld:image:open' && event.data?.url) {
2249
2293
  this.showImageLightbox(event.data.url);
2250
2294
  }
@@ -2506,6 +2550,13 @@ class WeldSDK {
2506
2550
  this.logger.info('WeldSDK ready');
2507
2551
  // Call onReady callback
2508
2552
  this.config.onReady?.();
2553
+ // Start tracking page URL changes
2554
+ this.startPageTracking();
2555
+ // Auto-open if widget was previously open (persisted in sessionStorage)
2556
+ if (this.wasOpen()) {
2557
+ this.logger.info('Restoring previously open widget from sessionStorage');
2558
+ this.open();
2559
+ }
2509
2560
  }
2510
2561
  catch (error) {
2511
2562
  this.status = SDKStatus.ERROR;
@@ -2584,6 +2635,58 @@ class WeldSDK {
2584
2635
  isReady() {
2585
2636
  return this.status === SDKStatus.READY;
2586
2637
  }
2638
+ /**
2639
+ * Update callbacks on an existing instance (used by singleton reuse)
2640
+ */
2641
+ updateCallbacks(config) {
2642
+ if (config.onReady !== undefined)
2643
+ this.config.onReady = config.onReady;
2644
+ if (config.onOpen !== undefined)
2645
+ this.config.onOpen = config.onOpen;
2646
+ if (config.onClose !== undefined)
2647
+ this.config.onClose = config.onClose;
2648
+ if (config.onError !== undefined)
2649
+ this.config.onError = config.onError;
2650
+ if (config.onDestroy !== undefined)
2651
+ this.config.onDestroy = config.onDestroy;
2652
+ if (config.onMinimize !== undefined)
2653
+ this.config.onMinimize = config.onMinimize;
2654
+ if (config.onMaximize !== undefined)
2655
+ this.config.onMaximize = config.onMaximize;
2656
+ }
2657
+ /**
2658
+ * Persist open/closed state to sessionStorage
2659
+ */
2660
+ persistOpenState(isOpen) {
2661
+ try {
2662
+ sessionStorage.setItem(openStateKey(this.config.widgetId), isOpen ? 'true' : 'false');
2663
+ }
2664
+ catch {
2665
+ // sessionStorage might not be available
2666
+ }
2667
+ }
2668
+ /**
2669
+ * Clear persisted state from sessionStorage
2670
+ */
2671
+ clearPersistedState() {
2672
+ try {
2673
+ sessionStorage.removeItem(openStateKey(this.config.widgetId));
2674
+ }
2675
+ catch {
2676
+ // sessionStorage might not be available
2677
+ }
2678
+ }
2679
+ /**
2680
+ * Check if widget was previously open (from sessionStorage)
2681
+ */
2682
+ wasOpen() {
2683
+ try {
2684
+ return sessionStorage.getItem(openStateKey(this.config.widgetId)) === 'true';
2685
+ }
2686
+ catch {
2687
+ return false;
2688
+ }
2689
+ }
2587
2690
  /**
2588
2691
  * Open the widget
2589
2692
  */
@@ -2602,6 +2705,7 @@ class WeldSDK {
2602
2705
  if (launcherIframe?.element?.contentWindow) {
2603
2706
  launcherIframe.element.contentWindow.postMessage({ type: 'weld:widget-opened' }, '*');
2604
2707
  }
2708
+ this.persistOpenState(true);
2605
2709
  this.config.onOpen?.();
2606
2710
  }
2607
2711
  /**
@@ -2622,6 +2726,7 @@ class WeldSDK {
2622
2726
  if (launcherIframe?.element?.contentWindow) {
2623
2727
  launcherIframe.element.contentWindow.postMessage({ type: 'weld:widget-closed' }, '*');
2624
2728
  }
2729
+ this.persistOpenState(false);
2625
2730
  this.config.onClose?.();
2626
2731
  }
2627
2732
  /**
@@ -2810,6 +2915,8 @@ class WeldSDK {
2810
2915
  });
2811
2916
  // Broadcast logout to iframes
2812
2917
  this.messageBroker.broadcast('weld:auth:logout', {});
2918
+ // Clear persisted widget state
2919
+ this.clearPersistedState();
2813
2920
  // Clear any stored session data
2814
2921
  try {
2815
2922
  const prefix = 'weld-';
@@ -2951,6 +3058,63 @@ class WeldSDK {
2951
3058
  this.logger.setLevel('warn');
2952
3059
  this.logger.info('Debug mode disabled');
2953
3060
  }
3061
+ /**
3062
+ * Send a page change message to the widget iframe
3063
+ */
3064
+ sendPageChange(url, title) {
3065
+ const widgetIframe = this.iframeManager.getIframe(IframeType.WIDGET);
3066
+ if (widgetIframe?.element?.contentWindow) {
3067
+ widgetIframe.element.contentWindow.postMessage({
3068
+ type: 'weld:page:change',
3069
+ url,
3070
+ title,
3071
+ timestamp: Date.now(),
3072
+ }, '*');
3073
+ }
3074
+ }
3075
+ /**
3076
+ * Start tracking page URL changes (SPA navigations + popstate)
3077
+ */
3078
+ startPageTracking() {
3079
+ let lastUrl = window.location.href;
3080
+ let debounceTimer = null;
3081
+ const notifyChange = () => {
3082
+ const currentUrl = window.location.href;
3083
+ if (currentUrl !== lastUrl) {
3084
+ lastUrl = currentUrl;
3085
+ this.sendPageChange(currentUrl, document.title);
3086
+ }
3087
+ };
3088
+ const debouncedNotify = () => {
3089
+ if (debounceTimer)
3090
+ clearTimeout(debounceTimer);
3091
+ debounceTimer = setTimeout(notifyChange, 300);
3092
+ };
3093
+ // Send initial page
3094
+ this.sendPageChange(window.location.href, document.title);
3095
+ // Monkey-patch history.pushState and history.replaceState
3096
+ const origPushState = history.pushState.bind(history);
3097
+ const origReplaceState = history.replaceState.bind(history);
3098
+ history.pushState = function (...args) {
3099
+ origPushState(...args);
3100
+ debouncedNotify();
3101
+ };
3102
+ history.replaceState = function (...args) {
3103
+ origReplaceState(...args);
3104
+ debouncedNotify();
3105
+ };
3106
+ // Listen for popstate (browser back/forward)
3107
+ const handlePopstate = () => debouncedNotify();
3108
+ window.addEventListener('popstate', handlePopstate);
3109
+ // Store cleanup
3110
+ this.pageTrackingCleanup = () => {
3111
+ if (debounceTimer)
3112
+ clearTimeout(debounceTimer);
3113
+ window.removeEventListener('popstate', handlePopstate);
3114
+ history.pushState = origPushState;
3115
+ history.replaceState = origReplaceState;
3116
+ };
3117
+ }
2954
3118
  /**
2955
3119
  * Ensure SDK is ready before operation
2956
3120
  */
@@ -2959,11 +3123,26 @@ class WeldSDK {
2959
3123
  throw new Error('SDK not ready. Call init() first.');
2960
3124
  }
2961
3125
  }
3126
+ /**
3127
+ * Detach from the current component lifecycle without destroying the widget.
3128
+ * Use this as a React useEffect cleanup — the widget stays alive across navigations.
3129
+ */
3130
+ detach() {
3131
+ // No-op: widget stays alive in the singleton registry
3132
+ this.logger.debug('WeldSDK detached (no-op, widget stays alive)');
3133
+ }
2962
3134
  /**
2963
3135
  * Destroy SDK and cleanup
2964
3136
  */
2965
3137
  destroy() {
2966
3138
  this.logger.info('Destroying WeldSDK');
3139
+ // Remove from singleton registry
3140
+ sdkRegistry.delete(this.config.widgetId);
3141
+ // Clear persisted state
3142
+ this.clearPersistedState();
3143
+ // Stop page tracking
3144
+ this.pageTrackingCleanup?.();
3145
+ this.pageTrackingCleanup = null;
2967
3146
  // Remove event listener using bound handler
2968
3147
  window.removeEventListener('message', this.boundHandleLauncherClick);
2969
3148
  // Unsubscribe from all message broker subscriptions