@weldsuite/helpdesk-widget-sdk 1.0.15 → 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,20 +470,27 @@ 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
512
481
  const container = document.createElement('div');
513
482
  container.className = 'weld-launcher-frame';
514
483
  container.setAttribute('data-state', 'visible');
484
+ // Container is larger than the button to allow hover animations (scale, shadow) without clipping
485
+ const launcherPadding = 10;
515
486
  container.style.cssText = `
516
487
  position: fixed;
517
- bottom: ${launcher.position.bottom};
518
- right: ${launcher.position.right};
519
- width: ${launcher.size};
520
- height: ${launcher.size};
488
+ bottom: calc(${launcher.position.bottom} - ${launcherPadding}px);
489
+ right: calc(${launcher.position.right} - ${launcherPadding}px);
490
+ width: calc(${launcher.size} + ${launcherPadding * 2}px);
491
+ height: calc(${launcher.size} + ${launcherPadding * 2}px);
521
492
  z-index: 2147483003;
522
- pointer-events: auto;
493
+ pointer-events: none;
523
494
  display: block;
524
495
  `;
525
496
  // Create iframe
@@ -531,8 +502,12 @@ class IframeManager {
531
502
  width: 100%;
532
503
  height: 100%;
533
504
  border: none;
534
- background: transparent;
505
+ background: none;
506
+ color-scheme: none;
535
507
  display: block;
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);
536
511
  `;
537
512
  iframe.setAttribute('allow', 'clipboard-write');
538
513
  iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups');
@@ -548,20 +523,36 @@ class IframeManager {
548
523
  createdAt: Date.now(),
549
524
  });
550
525
  // When DOM loads, notify MessageBroker to send weld:init
526
+ let launcherRetried = false;
551
527
  iframe.onload = () => {
552
528
  const metadata = this.iframes.get(exports.IframeType.LAUNCHER);
553
529
  if (metadata) {
554
530
  this.logger.debug('Launcher iframe DOM loaded');
555
- // Notify MessageBroker that DOM is loaded (triggers weld:init)
556
531
  this.messageBroker?.setIframeDomLoaded(exports.IframeType.LAUNCHER);
557
532
  }
558
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
+ };
559
545
  this.logger.debug('Launcher iframe created');
560
546
  }
561
547
  /**
562
548
  * Create widget iframe
563
549
  */
564
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
+ }
565
556
  const { iframes } = this.config;
566
557
  const { widget } = iframes;
567
558
  // Create container
@@ -642,54 +633,32 @@ class IframeManager {
642
633
  createdAt: Date.now(),
643
634
  });
644
635
  // When DOM loads, notify MessageBroker to send weld:init
636
+ let widgetRetried = false;
645
637
  iframe.onload = () => {
646
638
  const metadata = this.iframes.get(exports.IframeType.WIDGET);
647
639
  if (metadata) {
648
640
  this.logger.debug('Widget iframe DOM loaded');
649
- // Notify MessageBroker that DOM is loaded (triggers weld:init)
650
641
  this.messageBroker?.setIframeDomLoaded(exports.IframeType.WIDGET);
651
642
  }
652
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
+ };
653
655
  this.logger.debug('Widget iframe created');
654
656
  }
655
657
  /**
656
- * Create backdrop iframe
658
+ * Create backdrop iframe — disabled, widget stays non-modal so users can interact with the page
657
659
  */
658
660
  async createBackdropIframe() {
659
- if (!this.config.iframes.backdrop?.enabled) {
660
- this.logger.debug('Backdrop disabled, skipping creation');
661
- return;
662
- }
663
- // Create container
664
- const container = document.createElement('div');
665
- container.className = 'weld-backdrop-frame';
666
- container.setAttribute('data-state', 'hidden');
667
- container.style.cssText = `
668
- position: fixed;
669
- top: 0;
670
- left: 0;
671
- right: 0;
672
- bottom: 0;
673
- z-index: 2147483000;
674
- background: transparent;
675
- pointer-events: none;
676
- opacity: 0;
677
- transition: opacity 200ms ease;
678
- `;
679
- this.appContainer?.appendChild(container);
680
- // Store metadata (backdrop doesn't have an iframe, just a div)
681
- // We'll create a minimal "iframe" reference for consistency
682
- const dummyIframe = document.createElement('iframe');
683
- dummyIframe.style.display = 'none';
684
- this.iframes.set(exports.IframeType.BACKDROP, {
685
- type: exports.IframeType.BACKDROP,
686
- element: dummyIframe,
687
- container,
688
- ready: true, // Backdrop is always ready
689
- visible: false,
690
- createdAt: Date.now(),
691
- });
692
- this.logger.debug('Backdrop created');
661
+ this.logger.debug('Backdrop disabled, skipping creation');
693
662
  }
694
663
  /**
695
664
  * Build iframe URL with parameters
@@ -703,12 +672,21 @@ class IframeManager {
703
672
  url.searchParams.set('device', this.deviceInfo.type);
704
673
  url.searchParams.set('mobile', String(this.deviceInfo.isMobile));
705
674
  url.searchParams.set('parentOrigin', window.location.origin);
675
+ if (this.config.testMode) {
676
+ url.searchParams.set('testMode', 'true');
677
+ }
706
678
  return url.toString();
707
679
  }
708
680
  /**
709
681
  * Setup event listeners
710
682
  */
711
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;
712
690
  // Window resize - use bound handler for proper cleanup
713
691
  window.addEventListener('resize', this.boundHandleResize);
714
692
  // Orientation change - use bound handler for proper cleanup
@@ -811,7 +789,7 @@ class IframeManager {
811
789
  iframe.container.style.transform = 'scale(1) translateY(0)';
812
790
  }
813
791
  // Handle mobile scroll lock
814
- if (this.deviceInfo.isMobile && type === exports.IframeType.WIDGET && this.config.mobile.scrollLock) {
792
+ if (this.deviceInfo.isMobile && type === exports.IframeType.WIDGET) {
815
793
  document.body.classList.add('weld-mobile-open');
816
794
  }
817
795
  // Hide launcher on mobile when widget is open (full-screen mode)
@@ -910,6 +888,8 @@ class IframeManager {
910
888
  this.iframes.clear();
911
889
  // Clear messageBroker reference
912
890
  this.messageBroker = null;
891
+ // Reset guard flag
892
+ this.eventListenersBound = false;
913
893
  this.logger.info('IframeManager destroyed');
914
894
  }
915
895
  }
@@ -977,6 +957,8 @@ var MessageType;
977
957
  // Events
978
958
  MessageType["EVENT_TRACK"] = "weld:event:track";
979
959
  MessageType["ERROR_REPORT"] = "weld:error:report";
960
+ // Page tracking
961
+ MessageType["PAGE_CHANGE"] = "weld:page:change";
980
962
  // API responses
981
963
  MessageType["API_SUCCESS"] = "weld:api:success";
982
964
  MessageType["API_ERROR"] = "weld:api:error";
@@ -1555,8 +1537,6 @@ class MessageBroker {
1555
1537
  iframeType,
1556
1538
  config: {
1557
1539
  api: this.config.api,
1558
- customization: this.config.customization,
1559
- features: this.config.features,
1560
1540
  },
1561
1541
  };
1562
1542
  const message = createMessage('weld:init', MessageOrigin.PARENT, initPayload);
@@ -2399,7 +2379,7 @@ class StateCoordinator {
2399
2379
  }
2400
2380
  }
2401
2381
 
2402
- var version = "1.0.15";
2382
+ var version = "1.0.17";
2403
2383
  var packageJson = {
2404
2384
  version: version};
2405
2385
 
@@ -2407,6 +2387,16 @@ var packageJson = {
2407
2387
  * Weld SDK - Main Entry Point
2408
2388
  * Public API for the Weld helpdesk widget
2409
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
+ }
2410
2400
  /**
2411
2401
  * SDK initialization status
2412
2402
  */
@@ -2429,6 +2419,8 @@ class WeldSDK {
2429
2419
  this.readyResolve = null;
2430
2420
  // Subscription IDs for cleanup
2431
2421
  this.subscriptionIds = [];
2422
+ // Page tracking cleanup
2423
+ this.pageTrackingCleanup = null;
2432
2424
  /**
2433
2425
  * Update user attributes (Intercom-style, with rate limiting)
2434
2426
  * Limited to 20 calls per page load to prevent abuse
@@ -2467,6 +2459,13 @@ class WeldSDK {
2467
2459
  console.log('[Weld SDK] Received message:', event.data.type);
2468
2460
  }
2469
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
+ }
2470
2469
  // Toggle behavior - if widget is open, close it; if closed, open it
2471
2470
  const state = this.stateCoordinator.getState();
2472
2471
  if (state.widget.isOpen) {
@@ -2479,9 +2478,260 @@ class WeldSDK {
2479
2478
  }
2480
2479
  }
2481
2480
  if (event.data?.type === 'weld:close') {
2481
+ if (this.status !== SDKStatus.READY)
2482
+ return;
2482
2483
  console.log('[Weld SDK] Widget close requested');
2483
2484
  this.close();
2484
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
+ }
2499
+ if (event.data?.type === 'weld:image:open' && event.data?.url) {
2500
+ this.showImageLightbox(event.data.url);
2501
+ }
2502
+ }
2503
+ /**
2504
+ * Show fullscreen image lightbox on the parent page
2505
+ */
2506
+ showImageLightbox(url) {
2507
+ // Remove existing lightbox if any
2508
+ const existing = document.getElementById('weld-image-lightbox');
2509
+ if (existing)
2510
+ existing.remove();
2511
+ // Zoom / pan state
2512
+ let scale = 1;
2513
+ let translateX = 0;
2514
+ let translateY = 0;
2515
+ let isDragging = false;
2516
+ let dragStartX = 0;
2517
+ let dragStartY = 0;
2518
+ let lastTranslateX = 0;
2519
+ let lastTranslateY = 0;
2520
+ const applyTransform = () => {
2521
+ img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
2522
+ };
2523
+ const resetTransform = () => {
2524
+ scale = 1;
2525
+ translateX = 0;
2526
+ translateY = 0;
2527
+ applyTransform();
2528
+ img.style.cursor = 'zoom-in';
2529
+ };
2530
+ const overlay = document.createElement('div');
2531
+ overlay.id = 'weld-image-lightbox';
2532
+ overlay.style.cssText = `
2533
+ position: fixed;
2534
+ inset: 0;
2535
+ z-index: 2147483647;
2536
+ background: rgba(0, 0, 0, 0.92);
2537
+ display: flex;
2538
+ align-items: center;
2539
+ justify-content: center;
2540
+ padding: 16px;
2541
+ cursor: pointer;
2542
+ overflow: hidden;
2543
+ `;
2544
+ // Close button
2545
+ const closeBtn = document.createElement('button');
2546
+ closeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`;
2547
+ closeBtn.style.cssText = `
2548
+ position: absolute;
2549
+ top: 16px;
2550
+ right: 16px;
2551
+ width: 40px;
2552
+ height: 40px;
2553
+ border-radius: 50%;
2554
+ border: none;
2555
+ background: rgba(255, 255, 255, 0.1);
2556
+ color: white;
2557
+ cursor: pointer;
2558
+ display: flex;
2559
+ align-items: center;
2560
+ justify-content: center;
2561
+ transition: background 0.15s;
2562
+ `;
2563
+ closeBtn.onmouseenter = () => { closeBtn.style.background = 'rgba(255, 255, 255, 0.2)'; };
2564
+ closeBtn.onmouseleave = () => { closeBtn.style.background = 'rgba(255, 255, 255, 0.1)'; };
2565
+ // Download button
2566
+ const downloadBtn = document.createElement('a');
2567
+ downloadBtn.href = url;
2568
+ downloadBtn.download = '';
2569
+ downloadBtn.target = '_blank';
2570
+ downloadBtn.rel = 'noopener noreferrer';
2571
+ downloadBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`;
2572
+ downloadBtn.style.cssText = `
2573
+ position: absolute;
2574
+ top: 16px;
2575
+ right: 64px;
2576
+ width: 40px;
2577
+ height: 40px;
2578
+ border-radius: 50%;
2579
+ border: none;
2580
+ background: rgba(255, 255, 255, 0.1);
2581
+ color: white;
2582
+ cursor: pointer;
2583
+ display: flex;
2584
+ align-items: center;
2585
+ justify-content: center;
2586
+ transition: background 0.15s;
2587
+ text-decoration: none;
2588
+ `;
2589
+ downloadBtn.onmouseenter = () => { downloadBtn.style.background = 'rgba(255, 255, 255, 0.2)'; };
2590
+ downloadBtn.onmouseleave = () => { downloadBtn.style.background = 'rgba(255, 255, 255, 0.1)'; };
2591
+ // Image
2592
+ const img = document.createElement('img');
2593
+ img.src = url;
2594
+ img.alt = 'Full size';
2595
+ img.draggable = false;
2596
+ img.style.cssText = `
2597
+ max-width: 100%;
2598
+ max-height: 100%;
2599
+ object-fit: contain;
2600
+ border-radius: 8px;
2601
+ cursor: zoom-in;
2602
+ transition: transform 0.2s ease;
2603
+ user-select: none;
2604
+ `;
2605
+ // Click to toggle zoom
2606
+ img.addEventListener('click', (e) => {
2607
+ e.stopPropagation();
2608
+ if (scale === 1) {
2609
+ // Zoom in to 2.5x centered on click position
2610
+ const rect = img.getBoundingClientRect();
2611
+ const clickX = e.clientX - rect.left - rect.width / 2;
2612
+ const clickY = e.clientY - rect.top - rect.height / 2;
2613
+ scale = 2.5;
2614
+ translateX = -clickX * 1.5;
2615
+ translateY = -clickY * 1.5;
2616
+ applyTransform();
2617
+ img.style.cursor = 'zoom-out';
2618
+ }
2619
+ else {
2620
+ // Zoom out - reset
2621
+ resetTransform();
2622
+ }
2623
+ });
2624
+ // Mouse wheel zoom
2625
+ overlay.addEventListener('wheel', (e) => {
2626
+ e.preventDefault();
2627
+ const delta = e.deltaY > 0 ? -0.25 : 0.25;
2628
+ const newScale = Math.min(Math.max(scale + delta, 1), 5);
2629
+ if (newScale === 1) {
2630
+ resetTransform();
2631
+ }
2632
+ else {
2633
+ scale = newScale;
2634
+ applyTransform();
2635
+ img.style.cursor = 'zoom-out';
2636
+ }
2637
+ }, { passive: false });
2638
+ // Drag to pan when zoomed
2639
+ img.addEventListener('mousedown', (e) => {
2640
+ if (scale <= 1)
2641
+ return;
2642
+ e.preventDefault();
2643
+ isDragging = true;
2644
+ dragStartX = e.clientX;
2645
+ dragStartY = e.clientY;
2646
+ lastTranslateX = translateX;
2647
+ lastTranslateY = translateY;
2648
+ img.style.cursor = 'grabbing';
2649
+ img.style.transition = 'none';
2650
+ });
2651
+ window.addEventListener('mousemove', (e) => {
2652
+ if (!isDragging)
2653
+ return;
2654
+ translateX = lastTranslateX + (e.clientX - dragStartX);
2655
+ translateY = lastTranslateY + (e.clientY - dragStartY);
2656
+ applyTransform();
2657
+ });
2658
+ window.addEventListener('mouseup', () => {
2659
+ if (!isDragging)
2660
+ return;
2661
+ isDragging = false;
2662
+ img.style.cursor = scale > 1 ? 'zoom-out' : 'zoom-in';
2663
+ img.style.transition = 'transform 0.2s ease';
2664
+ });
2665
+ // Touch: pinch to zoom + drag to pan
2666
+ let lastTouchDist = 0;
2667
+ let lastTouchScale = 1;
2668
+ overlay.addEventListener('touchstart', (e) => {
2669
+ if (e.touches.length === 2) {
2670
+ e.preventDefault();
2671
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
2672
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
2673
+ lastTouchDist = Math.hypot(dx, dy);
2674
+ lastTouchScale = scale;
2675
+ }
2676
+ else if (e.touches.length === 1 && scale > 1) {
2677
+ isDragging = true;
2678
+ dragStartX = e.touches[0].clientX;
2679
+ dragStartY = e.touches[0].clientY;
2680
+ lastTranslateX = translateX;
2681
+ lastTranslateY = translateY;
2682
+ img.style.transition = 'none';
2683
+ }
2684
+ }, { passive: false });
2685
+ overlay.addEventListener('touchmove', (e) => {
2686
+ if (e.touches.length === 2) {
2687
+ e.preventDefault();
2688
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
2689
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
2690
+ const dist = Math.hypot(dx, dy);
2691
+ scale = Math.min(Math.max(lastTouchScale * (dist / lastTouchDist), 1), 5);
2692
+ if (scale === 1) {
2693
+ translateX = 0;
2694
+ translateY = 0;
2695
+ }
2696
+ applyTransform();
2697
+ }
2698
+ else if (e.touches.length === 1 && isDragging) {
2699
+ e.preventDefault();
2700
+ translateX = lastTranslateX + (e.touches[0].clientX - dragStartX);
2701
+ translateY = lastTranslateY + (e.touches[0].clientY - dragStartY);
2702
+ applyTransform();
2703
+ }
2704
+ }, { passive: false });
2705
+ overlay.addEventListener('touchend', (e) => {
2706
+ if (e.touches.length < 2) {
2707
+ lastTouchDist = 0;
2708
+ }
2709
+ if (e.touches.length === 0) {
2710
+ isDragging = false;
2711
+ img.style.transition = 'transform 0.2s ease';
2712
+ }
2713
+ });
2714
+ const close = () => {
2715
+ document.removeEventListener('keydown', handleKeyDown);
2716
+ overlay.remove();
2717
+ };
2718
+ // Only close on backdrop click when not zoomed (prevent accidental close while panning)
2719
+ overlay.addEventListener('click', (e) => {
2720
+ if (e.target === overlay && scale <= 1)
2721
+ close();
2722
+ });
2723
+ downloadBtn.addEventListener('click', (e) => e.stopPropagation());
2724
+ closeBtn.addEventListener('click', (e) => { e.stopPropagation(); close(); });
2725
+ // Close on Escape
2726
+ const handleKeyDown = (e) => {
2727
+ if (e.key === 'Escape')
2728
+ close();
2729
+ };
2730
+ document.addEventListener('keydown', handleKeyDown);
2731
+ overlay.appendChild(closeBtn);
2732
+ overlay.appendChild(downloadBtn);
2733
+ overlay.appendChild(img);
2734
+ document.body.appendChild(overlay);
2485
2735
  }
2486
2736
  /**
2487
2737
  * Initialize the SDK and render widget
@@ -2507,6 +2757,13 @@ class WeldSDK {
2507
2757
  this.logger.info('WeldSDK ready');
2508
2758
  // Call onReady callback
2509
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
+ }
2510
2767
  }
2511
2768
  catch (error) {
2512
2769
  this.status = SDKStatus.ERROR;
@@ -2585,6 +2842,58 @@ class WeldSDK {
2585
2842
  isReady() {
2586
2843
  return this.status === SDKStatus.READY;
2587
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
+ }
2588
2897
  /**
2589
2898
  * Open the widget
2590
2899
  */
@@ -2593,8 +2902,6 @@ class WeldSDK {
2593
2902
  console.log('[Weld SDK] Opening widget...');
2594
2903
  this.stateCoordinator.openWidget();
2595
2904
  this.iframeManager.showIframe(exports.IframeType.WIDGET);
2596
- this.iframeManager.showIframe(exports.IframeType.BACKDROP);
2597
- // Keep launcher visible so user can click it to close the widget
2598
2905
  // Send open message to the widget iframe
2599
2906
  const widgetIframe = this.iframeManager.getIframe(exports.IframeType.WIDGET);
2600
2907
  if (widgetIframe?.element?.contentWindow) {
@@ -2605,6 +2912,7 @@ class WeldSDK {
2605
2912
  if (launcherIframe?.element?.contentWindow) {
2606
2913
  launcherIframe.element.contentWindow.postMessage({ type: 'weld:widget-opened' }, '*');
2607
2914
  }
2915
+ this.persistOpenState(true);
2608
2916
  this.config.onOpen?.();
2609
2917
  }
2610
2918
  /**
@@ -2615,8 +2923,6 @@ class WeldSDK {
2615
2923
  console.log('[Weld SDK] Closing widget...');
2616
2924
  this.stateCoordinator.closeWidget();
2617
2925
  this.iframeManager.hideIframe(exports.IframeType.WIDGET);
2618
- this.iframeManager.hideIframe(exports.IframeType.BACKDROP);
2619
- // Launcher stays visible
2620
2926
  // Send close message to the widget iframe
2621
2927
  const widgetIframe = this.iframeManager.getIframe(exports.IframeType.WIDGET);
2622
2928
  if (widgetIframe?.element?.contentWindow) {
@@ -2627,6 +2933,7 @@ class WeldSDK {
2627
2933
  if (launcherIframe?.element?.contentWindow) {
2628
2934
  launcherIframe.element.contentWindow.postMessage({ type: 'weld:widget-closed' }, '*');
2629
2935
  }
2936
+ this.persistOpenState(false);
2630
2937
  this.config.onClose?.();
2631
2938
  }
2632
2939
  /**
@@ -2815,6 +3122,8 @@ class WeldSDK {
2815
3122
  });
2816
3123
  // Broadcast logout to iframes
2817
3124
  this.messageBroker.broadcast('weld:auth:logout', {});
3125
+ // Clear persisted widget state
3126
+ this.clearPersistedState();
2818
3127
  // Clear any stored session data
2819
3128
  try {
2820
3129
  const prefix = 'weld-';
@@ -2956,6 +3265,63 @@ class WeldSDK {
2956
3265
  this.logger.setLevel('warn');
2957
3266
  this.logger.info('Debug mode disabled');
2958
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
+ }
2959
3325
  /**
2960
3326
  * Ensure SDK is ready before operation
2961
3327
  */
@@ -2964,11 +3330,26 @@ class WeldSDK {
2964
3330
  throw new Error('SDK not ready. Call init() first.');
2965
3331
  }
2966
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
+ }
2967
3341
  /**
2968
3342
  * Destroy SDK and cleanup
2969
3343
  */
2970
3344
  destroy() {
2971
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;
2972
3353
  // Remove event listener using bound handler
2973
3354
  window.removeEventListener('message', this.boundHandleLauncherClick);
2974
3355
  // Unsubscribe from all message broker subscriptions
@@ -2988,13 +3369,33 @@ class WeldSDK {
2988
3369
  }
2989
3370
  }
2990
3371
  /**
2991
- * 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.
2992
3375
  */
2993
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
+ }
2994
3384
  const sdk = new WeldSDK(config);
3385
+ sdkRegistry.set(widgetId, sdk);
2995
3386
  await sdk.init();
2996
3387
  return sdk;
2997
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
+ }
2998
3399
 
2999
3400
  exports.DEFAULT_CONFIG = DEFAULT_CONFIG;
3000
3401
  exports.HelpdeskWidget = WeldSDK;
@@ -3013,6 +3414,8 @@ exports.deepClone = deepClone;
3013
3414
  exports.deepMerge = deepMerge;
3014
3415
  exports.default = WeldSDK;
3015
3416
  exports.defaultLogger = defaultLogger;
3417
+ exports.destroyHelpdeskWidget = destroyWeldSDK;
3418
+ exports.destroyWeldSDK = destroyWeldSDK;
3016
3419
  exports.formatFileSize = formatFileSize;
3017
3420
  exports.getStateValue = getStateValue;
3018
3421
  exports.hasRequiredProperties = hasRequiredProperties;