@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/angular.js CHANGED
@@ -95,32 +95,6 @@ const DEFAULT_CONFIG = {
95
95
  closeOnClick: true,
96
96
  },
97
97
  },
98
- customization: {
99
- primaryColor: '#000000',
100
- accentColor: '#3b82f6',
101
- backgroundColor: '#ffffff',
102
- textColor: '#111827',
103
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
104
- fontSize: '14px',
105
- borderRadius: '12px',
106
- },
107
- features: {
108
- attachments: true,
109
- reactions: true,
110
- typing: true,
111
- readReceipts: true,
112
- offlineMode: false,
113
- fileUpload: true,
114
- imageUpload: true,
115
- voiceMessages: false,
116
- videoMessages: false,
117
- },
118
- mobile: {
119
- fullScreen: true,
120
- scrollLock: true,
121
- keyboardHandling: 'auto',
122
- safeAreaInsets: true,
123
- },
124
98
  auth: {
125
99
  enabled: true,
126
100
  mode: 'anonymous',
@@ -164,6 +138,7 @@ function resolveConfig(config) {
164
138
  validateConfig(config);
165
139
  return {
166
140
  widgetId: config.widgetId,
141
+ testMode: config.testMode,
167
142
  api: {
168
143
  ...DEFAULT_CONFIG.api,
169
144
  widgetId: config.widgetId,
@@ -193,18 +168,6 @@ function resolveConfig(config) {
193
168
  ...config.iframes?.backdrop,
194
169
  },
195
170
  },
196
- customization: {
197
- ...DEFAULT_CONFIG.customization,
198
- ...config.customization,
199
- },
200
- features: {
201
- ...DEFAULT_CONFIG.features,
202
- ...config.features,
203
- },
204
- mobile: {
205
- ...DEFAULT_CONFIG.mobile,
206
- ...config.mobile,
207
- },
208
171
  auth: {
209
172
  ...DEFAULT_CONFIG.auth,
210
173
  ...config.auth,
@@ -447,6 +410,8 @@ class IframeManager {
447
410
  this.modalContainer = null;
448
411
  this.styleElement = null;
449
412
  this.messageBroker = null;
413
+ // Guard flag to prevent double-binding event listeners
414
+ this.eventListenersBound = false;
450
415
  this.config = config;
451
416
  this.logger = new Logger(config.logging);
452
417
  this.deviceInfo = detectDevice();
@@ -486,18 +451,27 @@ class IframeManager {
486
451
  }
487
452
  /**
488
453
  * Create root container structure
454
+ * Reuses existing container if it has the same widgetId (singleton behavior)
489
455
  */
490
456
  createRootContainer() {
491
- // Check if already exists
492
- let existingContainer = document.getElementById('weld-container');
457
+ const existingContainer = document.getElementById('weld-container');
493
458
  if (existingContainer) {
494
- this.logger.warn('Weld container already exists, removing old instance');
459
+ // Reuse if same widgetId
460
+ if (existingContainer.getAttribute('data-widget-id') === this.config.widgetId) {
461
+ this.logger.debug('Reusing existing root container');
462
+ this.rootContainer = existingContainer;
463
+ this.appContainer = existingContainer.querySelector('.weld-app');
464
+ this.modalContainer = document.getElementById('weld-modal-container');
465
+ return;
466
+ }
467
+ this.logger.warn('Weld container already exists with different widgetId, removing old instance');
495
468
  existingContainer.remove();
496
469
  }
497
470
  // Create root container
498
471
  this.rootContainer = document.createElement('div');
499
472
  this.rootContainer.id = 'weld-container';
500
473
  this.rootContainer.className = 'weld-namespace';
474
+ this.rootContainer.setAttribute('data-widget-id', this.config.widgetId);
501
475
  // Create app container
502
476
  this.appContainer = document.createElement('div');
503
477
  this.appContainer.className = 'weld-app';
@@ -532,17 +506,7 @@ class IframeManager {
532
506
  * Generate CSS for containers
533
507
  */
534
508
  generateCSS() {
535
- const { customization } = this.config;
536
509
  return `
537
- /* Weld Container */
538
- #weld-container {
539
- --weld-color-primary: ${customization.primaryColor};
540
- --weld-color-accent: ${customization.accentColor};
541
- --weld-font-family: ${customization.fontFamily};
542
- --weld-font-size-base: ${customization.fontSize};
543
- --weld-radius-xl: ${customization.borderRadius};
544
- }
545
-
546
510
  /* Import main stylesheet */
547
511
  @import url('/styles/index.css');
548
512
 
@@ -566,6 +530,11 @@ class IframeManager {
566
530
  * Create launcher iframe
567
531
  */
568
532
  async createLauncherIframe() {
533
+ // Guard: skip if launcher iframe already exists
534
+ if (this.iframes.has(IframeType.LAUNCHER)) {
535
+ this.logger.debug('Launcher iframe already exists, skipping creation');
536
+ return;
537
+ }
569
538
  const { iframes } = this.config;
570
539
  const { launcher } = iframes;
571
540
  // Create container
@@ -593,9 +562,12 @@ class IframeManager {
593
562
  width: 100%;
594
563
  height: 100%;
595
564
  border: none;
596
- background: transparent;
565
+ background: none;
566
+ color-scheme: none;
597
567
  display: block;
598
568
  pointer-events: auto;
569
+ border-radius: 50%;
570
+ filter: drop-shadow(rgba(9, 14, 21, 0.54) 0px 1px 6px) drop-shadow(rgba(9, 14, 21, 0.9) 0px 2px 32px);
599
571
  `;
600
572
  iframe.setAttribute('allow', 'clipboard-write');
601
573
  iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups');
@@ -611,20 +583,36 @@ class IframeManager {
611
583
  createdAt: Date.now(),
612
584
  });
613
585
  // When DOM loads, notify MessageBroker to send weld:init
586
+ let launcherRetried = false;
614
587
  iframe.onload = () => {
615
588
  const metadata = this.iframes.get(IframeType.LAUNCHER);
616
589
  if (metadata) {
617
590
  this.logger.debug('Launcher iframe DOM loaded');
618
- // Notify MessageBroker that DOM is loaded (triggers weld:init)
619
591
  this.messageBroker?.setIframeDomLoaded(IframeType.LAUNCHER);
620
592
  }
621
593
  };
594
+ iframe.onerror = () => {
595
+ this.logger.error('Launcher iframe failed to load');
596
+ if (!launcherRetried) {
597
+ launcherRetried = true;
598
+ this.logger.info('Retrying launcher iframe load...');
599
+ setTimeout(() => { iframe.src = this.buildIframeUrl(launcher.url); }, 3000);
600
+ }
601
+ else {
602
+ this.config.onError?.(new Error('Failed to load widget launcher'));
603
+ }
604
+ };
622
605
  this.logger.debug('Launcher iframe created');
623
606
  }
624
607
  /**
625
608
  * Create widget iframe
626
609
  */
627
610
  async createWidgetIframe() {
611
+ // Guard: skip if widget iframe already exists
612
+ if (this.iframes.has(IframeType.WIDGET)) {
613
+ this.logger.debug('Widget iframe already exists, skipping creation');
614
+ return;
615
+ }
628
616
  const { iframes } = this.config;
629
617
  const { widget } = iframes;
630
618
  // Create container
@@ -705,14 +693,25 @@ class IframeManager {
705
693
  createdAt: Date.now(),
706
694
  });
707
695
  // When DOM loads, notify MessageBroker to send weld:init
696
+ let widgetRetried = false;
708
697
  iframe.onload = () => {
709
698
  const metadata = this.iframes.get(IframeType.WIDGET);
710
699
  if (metadata) {
711
700
  this.logger.debug('Widget iframe DOM loaded');
712
- // Notify MessageBroker that DOM is loaded (triggers weld:init)
713
701
  this.messageBroker?.setIframeDomLoaded(IframeType.WIDGET);
714
702
  }
715
703
  };
704
+ iframe.onerror = () => {
705
+ this.logger.error('Widget iframe failed to load');
706
+ if (!widgetRetried) {
707
+ widgetRetried = true;
708
+ this.logger.info('Retrying widget iframe load...');
709
+ setTimeout(() => { iframe.src = this.buildIframeUrl(widget.url); }, 3000);
710
+ }
711
+ else {
712
+ this.config.onError?.(new Error('Failed to load widget'));
713
+ }
714
+ };
716
715
  this.logger.debug('Widget iframe created');
717
716
  }
718
717
  /**
@@ -733,12 +732,21 @@ class IframeManager {
733
732
  url.searchParams.set('device', this.deviceInfo.type);
734
733
  url.searchParams.set('mobile', String(this.deviceInfo.isMobile));
735
734
  url.searchParams.set('parentOrigin', window.location.origin);
735
+ if (this.config.testMode) {
736
+ url.searchParams.set('testMode', 'true');
737
+ }
736
738
  return url.toString();
737
739
  }
738
740
  /**
739
741
  * Setup event listeners
740
742
  */
741
743
  setupEventListeners() {
744
+ // Guard: prevent double-binding
745
+ if (this.eventListenersBound) {
746
+ this.logger.debug('Event listeners already bound, skipping');
747
+ return;
748
+ }
749
+ this.eventListenersBound = true;
742
750
  // Window resize - use bound handler for proper cleanup
743
751
  window.addEventListener('resize', this.boundHandleResize);
744
752
  // Orientation change - use bound handler for proper cleanup
@@ -841,7 +849,7 @@ class IframeManager {
841
849
  iframe.container.style.transform = 'scale(1) translateY(0)';
842
850
  }
843
851
  // Handle mobile scroll lock
844
- if (this.deviceInfo.isMobile && type === IframeType.WIDGET && this.config.mobile.scrollLock) {
852
+ if (this.deviceInfo.isMobile && type === IframeType.WIDGET) {
845
853
  document.body.classList.add('weld-mobile-open');
846
854
  }
847
855
  // Hide launcher on mobile when widget is open (full-screen mode)
@@ -940,6 +948,8 @@ class IframeManager {
940
948
  this.iframes.clear();
941
949
  // Clear messageBroker reference
942
950
  this.messageBroker = null;
951
+ // Reset guard flag
952
+ this.eventListenersBound = false;
943
953
  this.logger.info('IframeManager destroyed');
944
954
  }
945
955
  }
@@ -1007,6 +1017,8 @@ var MessageType;
1007
1017
  // Events
1008
1018
  MessageType["EVENT_TRACK"] = "weld:event:track";
1009
1019
  MessageType["ERROR_REPORT"] = "weld:error:report";
1020
+ // Page tracking
1021
+ MessageType["PAGE_CHANGE"] = "weld:page:change";
1010
1022
  // API responses
1011
1023
  MessageType["API_SUCCESS"] = "weld:api:success";
1012
1024
  MessageType["API_ERROR"] = "weld:api:error";
@@ -1546,8 +1558,6 @@ class MessageBroker {
1546
1558
  iframeType,
1547
1559
  config: {
1548
1560
  api: this.config.api,
1549
- customization: this.config.customization,
1550
- features: this.config.features,
1551
1561
  },
1552
1562
  };
1553
1563
  const message = createMessage('weld:init', MessageOrigin.PARENT, initPayload);
@@ -2220,7 +2230,7 @@ class StateCoordinator {
2220
2230
  }
2221
2231
  }
2222
2232
 
2223
- var version = "1.0.16";
2233
+ var version = "1.0.17";
2224
2234
  var packageJson = {
2225
2235
  version: version};
2226
2236
 
@@ -2228,6 +2238,16 @@ var packageJson = {
2228
2238
  * Weld SDK - Main Entry Point
2229
2239
  * Public API for the Weld helpdesk widget
2230
2240
  */
2241
+ /**
2242
+ * Module-level singleton registry keyed by widgetId
2243
+ */
2244
+ const sdkRegistry = new Map();
2245
+ /**
2246
+ * SessionStorage key helpers
2247
+ */
2248
+ function openStateKey(widgetId) {
2249
+ return `weld-widget-open-${widgetId}`;
2250
+ }
2231
2251
  /**
2232
2252
  * SDK initialization status
2233
2253
  */
@@ -2250,6 +2270,8 @@ class WeldSDK {
2250
2270
  this.readyResolve = null;
2251
2271
  // Subscription IDs for cleanup
2252
2272
  this.subscriptionIds = [];
2273
+ // Page tracking cleanup
2274
+ this.pageTrackingCleanup = null;
2253
2275
  /**
2254
2276
  * Update user attributes (Intercom-style, with rate limiting)
2255
2277
  * Limited to 20 calls per page load to prevent abuse
@@ -2288,6 +2310,13 @@ class WeldSDK {
2288
2310
  console.log('[Weld SDK] Received message:', event.data.type);
2289
2311
  }
2290
2312
  if (event.data?.type === 'launcher:clicked') {
2313
+ if (this.status !== SDKStatus.READY) {
2314
+ console.log('[Weld SDK] Launcher clicked but SDK not ready yet — waiting...');
2315
+ this.readyPromise?.then(() => {
2316
+ this.handleLauncherClickMessage(event);
2317
+ });
2318
+ return;
2319
+ }
2291
2320
  // Toggle behavior - if widget is open, close it; if closed, open it
2292
2321
  const state = this.stateCoordinator.getState();
2293
2322
  if (state.widget.isOpen) {
@@ -2300,9 +2329,24 @@ class WeldSDK {
2300
2329
  }
2301
2330
  }
2302
2331
  if (event.data?.type === 'weld:close') {
2332
+ if (this.status !== SDKStatus.READY)
2333
+ return;
2303
2334
  console.log('[Weld SDK] Widget close requested');
2304
2335
  this.close();
2305
2336
  }
2337
+ if (event.data?.type === 'weld:unread-count') {
2338
+ const count = event.data.count ?? 0;
2339
+ // Forward to launcher iframe
2340
+ const launcherIframe = this.iframeManager.getIframe(IframeType.LAUNCHER);
2341
+ if (launcherIframe?.element?.contentWindow) {
2342
+ launcherIframe.element.contentWindow.postMessage({
2343
+ type: 'weld:unread-count',
2344
+ count
2345
+ }, '*');
2346
+ }
2347
+ // Update state coordinator for external API consumers
2348
+ this.stateCoordinator.setBadgeCount(count);
2349
+ }
2306
2350
  if (event.data?.type === 'weld:image:open' && event.data?.url) {
2307
2351
  this.showImageLightbox(event.data.url);
2308
2352
  }
@@ -2564,6 +2608,13 @@ class WeldSDK {
2564
2608
  this.logger.info('WeldSDK ready');
2565
2609
  // Call onReady callback
2566
2610
  this.config.onReady?.();
2611
+ // Start tracking page URL changes
2612
+ this.startPageTracking();
2613
+ // Auto-open if widget was previously open (persisted in sessionStorage)
2614
+ if (this.wasOpen()) {
2615
+ this.logger.info('Restoring previously open widget from sessionStorage');
2616
+ this.open();
2617
+ }
2567
2618
  }
2568
2619
  catch (error) {
2569
2620
  this.status = SDKStatus.ERROR;
@@ -2642,6 +2693,58 @@ class WeldSDK {
2642
2693
  isReady() {
2643
2694
  return this.status === SDKStatus.READY;
2644
2695
  }
2696
+ /**
2697
+ * Update callbacks on an existing instance (used by singleton reuse)
2698
+ */
2699
+ updateCallbacks(config) {
2700
+ if (config.onReady !== undefined)
2701
+ this.config.onReady = config.onReady;
2702
+ if (config.onOpen !== undefined)
2703
+ this.config.onOpen = config.onOpen;
2704
+ if (config.onClose !== undefined)
2705
+ this.config.onClose = config.onClose;
2706
+ if (config.onError !== undefined)
2707
+ this.config.onError = config.onError;
2708
+ if (config.onDestroy !== undefined)
2709
+ this.config.onDestroy = config.onDestroy;
2710
+ if (config.onMinimize !== undefined)
2711
+ this.config.onMinimize = config.onMinimize;
2712
+ if (config.onMaximize !== undefined)
2713
+ this.config.onMaximize = config.onMaximize;
2714
+ }
2715
+ /**
2716
+ * Persist open/closed state to sessionStorage
2717
+ */
2718
+ persistOpenState(isOpen) {
2719
+ try {
2720
+ sessionStorage.setItem(openStateKey(this.config.widgetId), isOpen ? 'true' : 'false');
2721
+ }
2722
+ catch {
2723
+ // sessionStorage might not be available
2724
+ }
2725
+ }
2726
+ /**
2727
+ * Clear persisted state from sessionStorage
2728
+ */
2729
+ clearPersistedState() {
2730
+ try {
2731
+ sessionStorage.removeItem(openStateKey(this.config.widgetId));
2732
+ }
2733
+ catch {
2734
+ // sessionStorage might not be available
2735
+ }
2736
+ }
2737
+ /**
2738
+ * Check if widget was previously open (from sessionStorage)
2739
+ */
2740
+ wasOpen() {
2741
+ try {
2742
+ return sessionStorage.getItem(openStateKey(this.config.widgetId)) === 'true';
2743
+ }
2744
+ catch {
2745
+ return false;
2746
+ }
2747
+ }
2645
2748
  /**
2646
2749
  * Open the widget
2647
2750
  */
@@ -2660,6 +2763,7 @@ class WeldSDK {
2660
2763
  if (launcherIframe?.element?.contentWindow) {
2661
2764
  launcherIframe.element.contentWindow.postMessage({ type: 'weld:widget-opened' }, '*');
2662
2765
  }
2766
+ this.persistOpenState(true);
2663
2767
  this.config.onOpen?.();
2664
2768
  }
2665
2769
  /**
@@ -2680,6 +2784,7 @@ class WeldSDK {
2680
2784
  if (launcherIframe?.element?.contentWindow) {
2681
2785
  launcherIframe.element.contentWindow.postMessage({ type: 'weld:widget-closed' }, '*');
2682
2786
  }
2787
+ this.persistOpenState(false);
2683
2788
  this.config.onClose?.();
2684
2789
  }
2685
2790
  /**
@@ -2868,6 +2973,8 @@ class WeldSDK {
2868
2973
  });
2869
2974
  // Broadcast logout to iframes
2870
2975
  this.messageBroker.broadcast('weld:auth:logout', {});
2976
+ // Clear persisted widget state
2977
+ this.clearPersistedState();
2871
2978
  // Clear any stored session data
2872
2979
  try {
2873
2980
  const prefix = 'weld-';
@@ -3009,6 +3116,63 @@ class WeldSDK {
3009
3116
  this.logger.setLevel('warn');
3010
3117
  this.logger.info('Debug mode disabled');
3011
3118
  }
3119
+ /**
3120
+ * Send a page change message to the widget iframe
3121
+ */
3122
+ sendPageChange(url, title) {
3123
+ const widgetIframe = this.iframeManager.getIframe(IframeType.WIDGET);
3124
+ if (widgetIframe?.element?.contentWindow) {
3125
+ widgetIframe.element.contentWindow.postMessage({
3126
+ type: 'weld:page:change',
3127
+ url,
3128
+ title,
3129
+ timestamp: Date.now(),
3130
+ }, '*');
3131
+ }
3132
+ }
3133
+ /**
3134
+ * Start tracking page URL changes (SPA navigations + popstate)
3135
+ */
3136
+ startPageTracking() {
3137
+ let lastUrl = window.location.href;
3138
+ let debounceTimer = null;
3139
+ const notifyChange = () => {
3140
+ const currentUrl = window.location.href;
3141
+ if (currentUrl !== lastUrl) {
3142
+ lastUrl = currentUrl;
3143
+ this.sendPageChange(currentUrl, document.title);
3144
+ }
3145
+ };
3146
+ const debouncedNotify = () => {
3147
+ if (debounceTimer)
3148
+ clearTimeout(debounceTimer);
3149
+ debounceTimer = setTimeout(notifyChange, 300);
3150
+ };
3151
+ // Send initial page
3152
+ this.sendPageChange(window.location.href, document.title);
3153
+ // Monkey-patch history.pushState and history.replaceState
3154
+ const origPushState = history.pushState.bind(history);
3155
+ const origReplaceState = history.replaceState.bind(history);
3156
+ history.pushState = function (...args) {
3157
+ origPushState(...args);
3158
+ debouncedNotify();
3159
+ };
3160
+ history.replaceState = function (...args) {
3161
+ origReplaceState(...args);
3162
+ debouncedNotify();
3163
+ };
3164
+ // Listen for popstate (browser back/forward)
3165
+ const handlePopstate = () => debouncedNotify();
3166
+ window.addEventListener('popstate', handlePopstate);
3167
+ // Store cleanup
3168
+ this.pageTrackingCleanup = () => {
3169
+ if (debounceTimer)
3170
+ clearTimeout(debounceTimer);
3171
+ window.removeEventListener('popstate', handlePopstate);
3172
+ history.pushState = origPushState;
3173
+ history.replaceState = origReplaceState;
3174
+ };
3175
+ }
3012
3176
  /**
3013
3177
  * Ensure SDK is ready before operation
3014
3178
  */
@@ -3017,11 +3181,26 @@ class WeldSDK {
3017
3181
  throw new Error('SDK not ready. Call init() first.');
3018
3182
  }
3019
3183
  }
3184
+ /**
3185
+ * Detach from the current component lifecycle without destroying the widget.
3186
+ * Use this as a React useEffect cleanup — the widget stays alive across navigations.
3187
+ */
3188
+ detach() {
3189
+ // No-op: widget stays alive in the singleton registry
3190
+ this.logger.debug('WeldSDK detached (no-op, widget stays alive)');
3191
+ }
3020
3192
  /**
3021
3193
  * Destroy SDK and cleanup
3022
3194
  */
3023
3195
  destroy() {
3024
3196
  this.logger.info('Destroying WeldSDK');
3197
+ // Remove from singleton registry
3198
+ sdkRegistry.delete(this.config.widgetId);
3199
+ // Clear persisted state
3200
+ this.clearPersistedState();
3201
+ // Stop page tracking
3202
+ this.pageTrackingCleanup?.();
3203
+ this.pageTrackingCleanup = null;
3025
3204
  // Remove event listener using bound handler
3026
3205
  window.removeEventListener('message', this.boundHandleLauncherClick);
3027
3206
  // Unsubscribe from all message broker subscriptions