@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.esm.js CHANGED
@@ -31,32 +31,6 @@ const DEFAULT_CONFIG = {
31
31
  closeOnClick: true,
32
32
  },
33
33
  },
34
- customization: {
35
- primaryColor: '#000000',
36
- accentColor: '#3b82f6',
37
- backgroundColor: '#ffffff',
38
- textColor: '#111827',
39
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
40
- fontSize: '14px',
41
- borderRadius: '12px',
42
- },
43
- features: {
44
- attachments: true,
45
- reactions: true,
46
- typing: true,
47
- readReceipts: true,
48
- offlineMode: false,
49
- fileUpload: true,
50
- imageUpload: true,
51
- voiceMessages: false,
52
- videoMessages: false,
53
- },
54
- mobile: {
55
- fullScreen: true,
56
- scrollLock: true,
57
- keyboardHandling: 'auto',
58
- safeAreaInsets: true,
59
- },
60
34
  auth: {
61
35
  enabled: true,
62
36
  mode: 'anonymous',
@@ -100,6 +74,7 @@ function resolveConfig(config) {
100
74
  validateConfig(config);
101
75
  return {
102
76
  widgetId: config.widgetId,
77
+ testMode: config.testMode,
103
78
  api: {
104
79
  ...DEFAULT_CONFIG.api,
105
80
  widgetId: config.widgetId,
@@ -129,18 +104,6 @@ function resolveConfig(config) {
129
104
  ...config.iframes?.backdrop,
130
105
  },
131
106
  },
132
- customization: {
133
- ...DEFAULT_CONFIG.customization,
134
- ...config.customization,
135
- },
136
- features: {
137
- ...DEFAULT_CONFIG.features,
138
- ...config.features,
139
- },
140
- mobile: {
141
- ...DEFAULT_CONFIG.mobile,
142
- ...config.mobile,
143
- },
144
107
  auth: {
145
108
  ...DEFAULT_CONFIG.auth,
146
109
  ...config.auth,
@@ -383,6 +346,8 @@ class IframeManager {
383
346
  this.modalContainer = null;
384
347
  this.styleElement = null;
385
348
  this.messageBroker = null;
349
+ // Guard flag to prevent double-binding event listeners
350
+ this.eventListenersBound = false;
386
351
  this.config = config;
387
352
  this.logger = new Logger(config.logging);
388
353
  this.deviceInfo = detectDevice();
@@ -422,18 +387,27 @@ class IframeManager {
422
387
  }
423
388
  /**
424
389
  * Create root container structure
390
+ * Reuses existing container if it has the same widgetId (singleton behavior)
425
391
  */
426
392
  createRootContainer() {
427
- // Check if already exists
428
- let existingContainer = document.getElementById('weld-container');
393
+ const existingContainer = document.getElementById('weld-container');
429
394
  if (existingContainer) {
430
- this.logger.warn('Weld container already exists, removing old instance');
395
+ // Reuse if same widgetId
396
+ if (existingContainer.getAttribute('data-widget-id') === this.config.widgetId) {
397
+ this.logger.debug('Reusing existing root container');
398
+ this.rootContainer = existingContainer;
399
+ this.appContainer = existingContainer.querySelector('.weld-app');
400
+ this.modalContainer = document.getElementById('weld-modal-container');
401
+ return;
402
+ }
403
+ this.logger.warn('Weld container already exists with different widgetId, removing old instance');
431
404
  existingContainer.remove();
432
405
  }
433
406
  // Create root container
434
407
  this.rootContainer = document.createElement('div');
435
408
  this.rootContainer.id = 'weld-container';
436
409
  this.rootContainer.className = 'weld-namespace';
410
+ this.rootContainer.setAttribute('data-widget-id', this.config.widgetId);
437
411
  // Create app container
438
412
  this.appContainer = document.createElement('div');
439
413
  this.appContainer.className = 'weld-app';
@@ -468,17 +442,7 @@ class IframeManager {
468
442
  * Generate CSS for containers
469
443
  */
470
444
  generateCSS() {
471
- const { customization } = this.config;
472
445
  return `
473
- /* Weld Container */
474
- #weld-container {
475
- --weld-color-primary: ${customization.primaryColor};
476
- --weld-color-accent: ${customization.accentColor};
477
- --weld-font-family: ${customization.fontFamily};
478
- --weld-font-size-base: ${customization.fontSize};
479
- --weld-radius-xl: ${customization.borderRadius};
480
- }
481
-
482
446
  /* Import main stylesheet */
483
447
  @import url('/styles/index.css');
484
448
 
@@ -502,20 +466,27 @@ class IframeManager {
502
466
  * Create launcher iframe
503
467
  */
504
468
  async createLauncherIframe() {
469
+ // Guard: skip if launcher iframe already exists
470
+ if (this.iframes.has(IframeType.LAUNCHER)) {
471
+ this.logger.debug('Launcher iframe already exists, skipping creation');
472
+ return;
473
+ }
505
474
  const { iframes } = this.config;
506
475
  const { launcher } = iframes;
507
476
  // Create container
508
477
  const container = document.createElement('div');
509
478
  container.className = 'weld-launcher-frame';
510
479
  container.setAttribute('data-state', 'visible');
480
+ // Container is larger than the button to allow hover animations (scale, shadow) without clipping
481
+ const launcherPadding = 10;
511
482
  container.style.cssText = `
512
483
  position: fixed;
513
- bottom: ${launcher.position.bottom};
514
- right: ${launcher.position.right};
515
- width: ${launcher.size};
516
- height: ${launcher.size};
484
+ bottom: calc(${launcher.position.bottom} - ${launcherPadding}px);
485
+ right: calc(${launcher.position.right} - ${launcherPadding}px);
486
+ width: calc(${launcher.size} + ${launcherPadding * 2}px);
487
+ height: calc(${launcher.size} + ${launcherPadding * 2}px);
517
488
  z-index: 2147483003;
518
- pointer-events: auto;
489
+ pointer-events: none;
519
490
  display: block;
520
491
  `;
521
492
  // Create iframe
@@ -527,8 +498,12 @@ class IframeManager {
527
498
  width: 100%;
528
499
  height: 100%;
529
500
  border: none;
530
- background: transparent;
501
+ background: none;
502
+ color-scheme: none;
531
503
  display: block;
504
+ pointer-events: auto;
505
+ border-radius: 50%;
506
+ filter: drop-shadow(rgba(9, 14, 21, 0.54) 0px 1px 6px) drop-shadow(rgba(9, 14, 21, 0.9) 0px 2px 32px);
532
507
  `;
533
508
  iframe.setAttribute('allow', 'clipboard-write');
534
509
  iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups');
@@ -544,20 +519,36 @@ class IframeManager {
544
519
  createdAt: Date.now(),
545
520
  });
546
521
  // When DOM loads, notify MessageBroker to send weld:init
522
+ let launcherRetried = false;
547
523
  iframe.onload = () => {
548
524
  const metadata = this.iframes.get(IframeType.LAUNCHER);
549
525
  if (metadata) {
550
526
  this.logger.debug('Launcher iframe DOM loaded');
551
- // Notify MessageBroker that DOM is loaded (triggers weld:init)
552
527
  this.messageBroker?.setIframeDomLoaded(IframeType.LAUNCHER);
553
528
  }
554
529
  };
530
+ iframe.onerror = () => {
531
+ this.logger.error('Launcher iframe failed to load');
532
+ if (!launcherRetried) {
533
+ launcherRetried = true;
534
+ this.logger.info('Retrying launcher iframe load...');
535
+ setTimeout(() => { iframe.src = this.buildIframeUrl(launcher.url); }, 3000);
536
+ }
537
+ else {
538
+ this.config.onError?.(new Error('Failed to load widget launcher'));
539
+ }
540
+ };
555
541
  this.logger.debug('Launcher iframe created');
556
542
  }
557
543
  /**
558
544
  * Create widget iframe
559
545
  */
560
546
  async createWidgetIframe() {
547
+ // Guard: skip if widget iframe already exists
548
+ if (this.iframes.has(IframeType.WIDGET)) {
549
+ this.logger.debug('Widget iframe already exists, skipping creation');
550
+ return;
551
+ }
561
552
  const { iframes } = this.config;
562
553
  const { widget } = iframes;
563
554
  // Create container
@@ -638,54 +629,32 @@ class IframeManager {
638
629
  createdAt: Date.now(),
639
630
  });
640
631
  // When DOM loads, notify MessageBroker to send weld:init
632
+ let widgetRetried = false;
641
633
  iframe.onload = () => {
642
634
  const metadata = this.iframes.get(IframeType.WIDGET);
643
635
  if (metadata) {
644
636
  this.logger.debug('Widget iframe DOM loaded');
645
- // Notify MessageBroker that DOM is loaded (triggers weld:init)
646
637
  this.messageBroker?.setIframeDomLoaded(IframeType.WIDGET);
647
638
  }
648
639
  };
640
+ iframe.onerror = () => {
641
+ this.logger.error('Widget iframe failed to load');
642
+ if (!widgetRetried) {
643
+ widgetRetried = true;
644
+ this.logger.info('Retrying widget iframe load...');
645
+ setTimeout(() => { iframe.src = this.buildIframeUrl(widget.url); }, 3000);
646
+ }
647
+ else {
648
+ this.config.onError?.(new Error('Failed to load widget'));
649
+ }
650
+ };
649
651
  this.logger.debug('Widget iframe created');
650
652
  }
651
653
  /**
652
- * Create backdrop iframe
654
+ * Create backdrop iframe — disabled, widget stays non-modal so users can interact with the page
653
655
  */
654
656
  async createBackdropIframe() {
655
- if (!this.config.iframes.backdrop?.enabled) {
656
- this.logger.debug('Backdrop disabled, skipping creation');
657
- return;
658
- }
659
- // Create container
660
- const container = document.createElement('div');
661
- container.className = 'weld-backdrop-frame';
662
- container.setAttribute('data-state', 'hidden');
663
- container.style.cssText = `
664
- position: fixed;
665
- top: 0;
666
- left: 0;
667
- right: 0;
668
- bottom: 0;
669
- z-index: 2147483000;
670
- background: transparent;
671
- pointer-events: none;
672
- opacity: 0;
673
- transition: opacity 200ms ease;
674
- `;
675
- this.appContainer?.appendChild(container);
676
- // Store metadata (backdrop doesn't have an iframe, just a div)
677
- // We'll create a minimal "iframe" reference for consistency
678
- const dummyIframe = document.createElement('iframe');
679
- dummyIframe.style.display = 'none';
680
- this.iframes.set(IframeType.BACKDROP, {
681
- type: IframeType.BACKDROP,
682
- element: dummyIframe,
683
- container,
684
- ready: true, // Backdrop is always ready
685
- visible: false,
686
- createdAt: Date.now(),
687
- });
688
- this.logger.debug('Backdrop created');
657
+ this.logger.debug('Backdrop disabled, skipping creation');
689
658
  }
690
659
  /**
691
660
  * Build iframe URL with parameters
@@ -699,12 +668,21 @@ class IframeManager {
699
668
  url.searchParams.set('device', this.deviceInfo.type);
700
669
  url.searchParams.set('mobile', String(this.deviceInfo.isMobile));
701
670
  url.searchParams.set('parentOrigin', window.location.origin);
671
+ if (this.config.testMode) {
672
+ url.searchParams.set('testMode', 'true');
673
+ }
702
674
  return url.toString();
703
675
  }
704
676
  /**
705
677
  * Setup event listeners
706
678
  */
707
679
  setupEventListeners() {
680
+ // Guard: prevent double-binding
681
+ if (this.eventListenersBound) {
682
+ this.logger.debug('Event listeners already bound, skipping');
683
+ return;
684
+ }
685
+ this.eventListenersBound = true;
708
686
  // Window resize - use bound handler for proper cleanup
709
687
  window.addEventListener('resize', this.boundHandleResize);
710
688
  // Orientation change - use bound handler for proper cleanup
@@ -807,7 +785,7 @@ class IframeManager {
807
785
  iframe.container.style.transform = 'scale(1) translateY(0)';
808
786
  }
809
787
  // Handle mobile scroll lock
810
- if (this.deviceInfo.isMobile && type === IframeType.WIDGET && this.config.mobile.scrollLock) {
788
+ if (this.deviceInfo.isMobile && type === IframeType.WIDGET) {
811
789
  document.body.classList.add('weld-mobile-open');
812
790
  }
813
791
  // Hide launcher on mobile when widget is open (full-screen mode)
@@ -906,6 +884,8 @@ class IframeManager {
906
884
  this.iframes.clear();
907
885
  // Clear messageBroker reference
908
886
  this.messageBroker = null;
887
+ // Reset guard flag
888
+ this.eventListenersBound = false;
909
889
  this.logger.info('IframeManager destroyed');
910
890
  }
911
891
  }
@@ -973,6 +953,8 @@ var MessageType;
973
953
  // Events
974
954
  MessageType["EVENT_TRACK"] = "weld:event:track";
975
955
  MessageType["ERROR_REPORT"] = "weld:error:report";
956
+ // Page tracking
957
+ MessageType["PAGE_CHANGE"] = "weld:page:change";
976
958
  // API responses
977
959
  MessageType["API_SUCCESS"] = "weld:api:success";
978
960
  MessageType["API_ERROR"] = "weld:api:error";
@@ -1551,8 +1533,6 @@ class MessageBroker {
1551
1533
  iframeType,
1552
1534
  config: {
1553
1535
  api: this.config.api,
1554
- customization: this.config.customization,
1555
- features: this.config.features,
1556
1536
  },
1557
1537
  };
1558
1538
  const message = createMessage('weld:init', MessageOrigin.PARENT, initPayload);
@@ -2395,7 +2375,7 @@ class StateCoordinator {
2395
2375
  }
2396
2376
  }
2397
2377
 
2398
- var version = "1.0.15";
2378
+ var version = "1.0.17";
2399
2379
  var packageJson = {
2400
2380
  version: version};
2401
2381
 
@@ -2403,6 +2383,16 @@ var packageJson = {
2403
2383
  * Weld SDK - Main Entry Point
2404
2384
  * Public API for the Weld helpdesk widget
2405
2385
  */
2386
+ /**
2387
+ * Module-level singleton registry keyed by widgetId
2388
+ */
2389
+ const sdkRegistry = new Map();
2390
+ /**
2391
+ * SessionStorage key helpers
2392
+ */
2393
+ function openStateKey(widgetId) {
2394
+ return `weld-widget-open-${widgetId}`;
2395
+ }
2406
2396
  /**
2407
2397
  * SDK initialization status
2408
2398
  */
@@ -2425,6 +2415,8 @@ class WeldSDK {
2425
2415
  this.readyResolve = null;
2426
2416
  // Subscription IDs for cleanup
2427
2417
  this.subscriptionIds = [];
2418
+ // Page tracking cleanup
2419
+ this.pageTrackingCleanup = null;
2428
2420
  /**
2429
2421
  * Update user attributes (Intercom-style, with rate limiting)
2430
2422
  * Limited to 20 calls per page load to prevent abuse
@@ -2463,6 +2455,13 @@ class WeldSDK {
2463
2455
  console.log('[Weld SDK] Received message:', event.data.type);
2464
2456
  }
2465
2457
  if (event.data?.type === 'launcher:clicked') {
2458
+ if (this.status !== SDKStatus.READY) {
2459
+ console.log('[Weld SDK] Launcher clicked but SDK not ready yet — waiting...');
2460
+ this.readyPromise?.then(() => {
2461
+ this.handleLauncherClickMessage(event);
2462
+ });
2463
+ return;
2464
+ }
2466
2465
  // Toggle behavior - if widget is open, close it; if closed, open it
2467
2466
  const state = this.stateCoordinator.getState();
2468
2467
  if (state.widget.isOpen) {
@@ -2475,9 +2474,260 @@ class WeldSDK {
2475
2474
  }
2476
2475
  }
2477
2476
  if (event.data?.type === 'weld:close') {
2477
+ if (this.status !== SDKStatus.READY)
2478
+ return;
2478
2479
  console.log('[Weld SDK] Widget close requested');
2479
2480
  this.close();
2480
2481
  }
2482
+ if (event.data?.type === 'weld:unread-count') {
2483
+ const count = event.data.count ?? 0;
2484
+ // Forward to launcher iframe
2485
+ const launcherIframe = this.iframeManager.getIframe(IframeType.LAUNCHER);
2486
+ if (launcherIframe?.element?.contentWindow) {
2487
+ launcherIframe.element.contentWindow.postMessage({
2488
+ type: 'weld:unread-count',
2489
+ count
2490
+ }, '*');
2491
+ }
2492
+ // Update state coordinator for external API consumers
2493
+ this.stateCoordinator.setBadgeCount(count);
2494
+ }
2495
+ if (event.data?.type === 'weld:image:open' && event.data?.url) {
2496
+ this.showImageLightbox(event.data.url);
2497
+ }
2498
+ }
2499
+ /**
2500
+ * Show fullscreen image lightbox on the parent page
2501
+ */
2502
+ showImageLightbox(url) {
2503
+ // Remove existing lightbox if any
2504
+ const existing = document.getElementById('weld-image-lightbox');
2505
+ if (existing)
2506
+ existing.remove();
2507
+ // Zoom / pan state
2508
+ let scale = 1;
2509
+ let translateX = 0;
2510
+ let translateY = 0;
2511
+ let isDragging = false;
2512
+ let dragStartX = 0;
2513
+ let dragStartY = 0;
2514
+ let lastTranslateX = 0;
2515
+ let lastTranslateY = 0;
2516
+ const applyTransform = () => {
2517
+ img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
2518
+ };
2519
+ const resetTransform = () => {
2520
+ scale = 1;
2521
+ translateX = 0;
2522
+ translateY = 0;
2523
+ applyTransform();
2524
+ img.style.cursor = 'zoom-in';
2525
+ };
2526
+ const overlay = document.createElement('div');
2527
+ overlay.id = 'weld-image-lightbox';
2528
+ overlay.style.cssText = `
2529
+ position: fixed;
2530
+ inset: 0;
2531
+ z-index: 2147483647;
2532
+ background: rgba(0, 0, 0, 0.92);
2533
+ display: flex;
2534
+ align-items: center;
2535
+ justify-content: center;
2536
+ padding: 16px;
2537
+ cursor: pointer;
2538
+ overflow: hidden;
2539
+ `;
2540
+ // Close button
2541
+ const closeBtn = document.createElement('button');
2542
+ 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>`;
2543
+ closeBtn.style.cssText = `
2544
+ position: absolute;
2545
+ top: 16px;
2546
+ right: 16px;
2547
+ width: 40px;
2548
+ height: 40px;
2549
+ border-radius: 50%;
2550
+ border: none;
2551
+ background: rgba(255, 255, 255, 0.1);
2552
+ color: white;
2553
+ cursor: pointer;
2554
+ display: flex;
2555
+ align-items: center;
2556
+ justify-content: center;
2557
+ transition: background 0.15s;
2558
+ `;
2559
+ closeBtn.onmouseenter = () => { closeBtn.style.background = 'rgba(255, 255, 255, 0.2)'; };
2560
+ closeBtn.onmouseleave = () => { closeBtn.style.background = 'rgba(255, 255, 255, 0.1)'; };
2561
+ // Download button
2562
+ const downloadBtn = document.createElement('a');
2563
+ downloadBtn.href = url;
2564
+ downloadBtn.download = '';
2565
+ downloadBtn.target = '_blank';
2566
+ downloadBtn.rel = 'noopener noreferrer';
2567
+ 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>`;
2568
+ downloadBtn.style.cssText = `
2569
+ position: absolute;
2570
+ top: 16px;
2571
+ right: 64px;
2572
+ width: 40px;
2573
+ height: 40px;
2574
+ border-radius: 50%;
2575
+ border: none;
2576
+ background: rgba(255, 255, 255, 0.1);
2577
+ color: white;
2578
+ cursor: pointer;
2579
+ display: flex;
2580
+ align-items: center;
2581
+ justify-content: center;
2582
+ transition: background 0.15s;
2583
+ text-decoration: none;
2584
+ `;
2585
+ downloadBtn.onmouseenter = () => { downloadBtn.style.background = 'rgba(255, 255, 255, 0.2)'; };
2586
+ downloadBtn.onmouseleave = () => { downloadBtn.style.background = 'rgba(255, 255, 255, 0.1)'; };
2587
+ // Image
2588
+ const img = document.createElement('img');
2589
+ img.src = url;
2590
+ img.alt = 'Full size';
2591
+ img.draggable = false;
2592
+ img.style.cssText = `
2593
+ max-width: 100%;
2594
+ max-height: 100%;
2595
+ object-fit: contain;
2596
+ border-radius: 8px;
2597
+ cursor: zoom-in;
2598
+ transition: transform 0.2s ease;
2599
+ user-select: none;
2600
+ `;
2601
+ // Click to toggle zoom
2602
+ img.addEventListener('click', (e) => {
2603
+ e.stopPropagation();
2604
+ if (scale === 1) {
2605
+ // Zoom in to 2.5x centered on click position
2606
+ const rect = img.getBoundingClientRect();
2607
+ const clickX = e.clientX - rect.left - rect.width / 2;
2608
+ const clickY = e.clientY - rect.top - rect.height / 2;
2609
+ scale = 2.5;
2610
+ translateX = -clickX * 1.5;
2611
+ translateY = -clickY * 1.5;
2612
+ applyTransform();
2613
+ img.style.cursor = 'zoom-out';
2614
+ }
2615
+ else {
2616
+ // Zoom out - reset
2617
+ resetTransform();
2618
+ }
2619
+ });
2620
+ // Mouse wheel zoom
2621
+ overlay.addEventListener('wheel', (e) => {
2622
+ e.preventDefault();
2623
+ const delta = e.deltaY > 0 ? -0.25 : 0.25;
2624
+ const newScale = Math.min(Math.max(scale + delta, 1), 5);
2625
+ if (newScale === 1) {
2626
+ resetTransform();
2627
+ }
2628
+ else {
2629
+ scale = newScale;
2630
+ applyTransform();
2631
+ img.style.cursor = 'zoom-out';
2632
+ }
2633
+ }, { passive: false });
2634
+ // Drag to pan when zoomed
2635
+ img.addEventListener('mousedown', (e) => {
2636
+ if (scale <= 1)
2637
+ return;
2638
+ e.preventDefault();
2639
+ isDragging = true;
2640
+ dragStartX = e.clientX;
2641
+ dragStartY = e.clientY;
2642
+ lastTranslateX = translateX;
2643
+ lastTranslateY = translateY;
2644
+ img.style.cursor = 'grabbing';
2645
+ img.style.transition = 'none';
2646
+ });
2647
+ window.addEventListener('mousemove', (e) => {
2648
+ if (!isDragging)
2649
+ return;
2650
+ translateX = lastTranslateX + (e.clientX - dragStartX);
2651
+ translateY = lastTranslateY + (e.clientY - dragStartY);
2652
+ applyTransform();
2653
+ });
2654
+ window.addEventListener('mouseup', () => {
2655
+ if (!isDragging)
2656
+ return;
2657
+ isDragging = false;
2658
+ img.style.cursor = scale > 1 ? 'zoom-out' : 'zoom-in';
2659
+ img.style.transition = 'transform 0.2s ease';
2660
+ });
2661
+ // Touch: pinch to zoom + drag to pan
2662
+ let lastTouchDist = 0;
2663
+ let lastTouchScale = 1;
2664
+ overlay.addEventListener('touchstart', (e) => {
2665
+ if (e.touches.length === 2) {
2666
+ e.preventDefault();
2667
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
2668
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
2669
+ lastTouchDist = Math.hypot(dx, dy);
2670
+ lastTouchScale = scale;
2671
+ }
2672
+ else if (e.touches.length === 1 && scale > 1) {
2673
+ isDragging = true;
2674
+ dragStartX = e.touches[0].clientX;
2675
+ dragStartY = e.touches[0].clientY;
2676
+ lastTranslateX = translateX;
2677
+ lastTranslateY = translateY;
2678
+ img.style.transition = 'none';
2679
+ }
2680
+ }, { passive: false });
2681
+ overlay.addEventListener('touchmove', (e) => {
2682
+ if (e.touches.length === 2) {
2683
+ e.preventDefault();
2684
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
2685
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
2686
+ const dist = Math.hypot(dx, dy);
2687
+ scale = Math.min(Math.max(lastTouchScale * (dist / lastTouchDist), 1), 5);
2688
+ if (scale === 1) {
2689
+ translateX = 0;
2690
+ translateY = 0;
2691
+ }
2692
+ applyTransform();
2693
+ }
2694
+ else if (e.touches.length === 1 && isDragging) {
2695
+ e.preventDefault();
2696
+ translateX = lastTranslateX + (e.touches[0].clientX - dragStartX);
2697
+ translateY = lastTranslateY + (e.touches[0].clientY - dragStartY);
2698
+ applyTransform();
2699
+ }
2700
+ }, { passive: false });
2701
+ overlay.addEventListener('touchend', (e) => {
2702
+ if (e.touches.length < 2) {
2703
+ lastTouchDist = 0;
2704
+ }
2705
+ if (e.touches.length === 0) {
2706
+ isDragging = false;
2707
+ img.style.transition = 'transform 0.2s ease';
2708
+ }
2709
+ });
2710
+ const close = () => {
2711
+ document.removeEventListener('keydown', handleKeyDown);
2712
+ overlay.remove();
2713
+ };
2714
+ // Only close on backdrop click when not zoomed (prevent accidental close while panning)
2715
+ overlay.addEventListener('click', (e) => {
2716
+ if (e.target === overlay && scale <= 1)
2717
+ close();
2718
+ });
2719
+ downloadBtn.addEventListener('click', (e) => e.stopPropagation());
2720
+ closeBtn.addEventListener('click', (e) => { e.stopPropagation(); close(); });
2721
+ // Close on Escape
2722
+ const handleKeyDown = (e) => {
2723
+ if (e.key === 'Escape')
2724
+ close();
2725
+ };
2726
+ document.addEventListener('keydown', handleKeyDown);
2727
+ overlay.appendChild(closeBtn);
2728
+ overlay.appendChild(downloadBtn);
2729
+ overlay.appendChild(img);
2730
+ document.body.appendChild(overlay);
2481
2731
  }
2482
2732
  /**
2483
2733
  * Initialize the SDK and render widget
@@ -2503,6 +2753,13 @@ class WeldSDK {
2503
2753
  this.logger.info('WeldSDK ready');
2504
2754
  // Call onReady callback
2505
2755
  this.config.onReady?.();
2756
+ // Start tracking page URL changes
2757
+ this.startPageTracking();
2758
+ // Auto-open if widget was previously open (persisted in sessionStorage)
2759
+ if (this.wasOpen()) {
2760
+ this.logger.info('Restoring previously open widget from sessionStorage');
2761
+ this.open();
2762
+ }
2506
2763
  }
2507
2764
  catch (error) {
2508
2765
  this.status = SDKStatus.ERROR;
@@ -2581,6 +2838,58 @@ class WeldSDK {
2581
2838
  isReady() {
2582
2839
  return this.status === SDKStatus.READY;
2583
2840
  }
2841
+ /**
2842
+ * Update callbacks on an existing instance (used by singleton reuse)
2843
+ */
2844
+ updateCallbacks(config) {
2845
+ if (config.onReady !== undefined)
2846
+ this.config.onReady = config.onReady;
2847
+ if (config.onOpen !== undefined)
2848
+ this.config.onOpen = config.onOpen;
2849
+ if (config.onClose !== undefined)
2850
+ this.config.onClose = config.onClose;
2851
+ if (config.onError !== undefined)
2852
+ this.config.onError = config.onError;
2853
+ if (config.onDestroy !== undefined)
2854
+ this.config.onDestroy = config.onDestroy;
2855
+ if (config.onMinimize !== undefined)
2856
+ this.config.onMinimize = config.onMinimize;
2857
+ if (config.onMaximize !== undefined)
2858
+ this.config.onMaximize = config.onMaximize;
2859
+ }
2860
+ /**
2861
+ * Persist open/closed state to sessionStorage
2862
+ */
2863
+ persistOpenState(isOpen) {
2864
+ try {
2865
+ sessionStorage.setItem(openStateKey(this.config.widgetId), isOpen ? 'true' : 'false');
2866
+ }
2867
+ catch {
2868
+ // sessionStorage might not be available
2869
+ }
2870
+ }
2871
+ /**
2872
+ * Clear persisted state from sessionStorage
2873
+ */
2874
+ clearPersistedState() {
2875
+ try {
2876
+ sessionStorage.removeItem(openStateKey(this.config.widgetId));
2877
+ }
2878
+ catch {
2879
+ // sessionStorage might not be available
2880
+ }
2881
+ }
2882
+ /**
2883
+ * Check if widget was previously open (from sessionStorage)
2884
+ */
2885
+ wasOpen() {
2886
+ try {
2887
+ return sessionStorage.getItem(openStateKey(this.config.widgetId)) === 'true';
2888
+ }
2889
+ catch {
2890
+ return false;
2891
+ }
2892
+ }
2584
2893
  /**
2585
2894
  * Open the widget
2586
2895
  */
@@ -2589,8 +2898,6 @@ class WeldSDK {
2589
2898
  console.log('[Weld SDK] Opening widget...');
2590
2899
  this.stateCoordinator.openWidget();
2591
2900
  this.iframeManager.showIframe(IframeType.WIDGET);
2592
- this.iframeManager.showIframe(IframeType.BACKDROP);
2593
- // Keep launcher visible so user can click it to close the widget
2594
2901
  // Send open message to the widget iframe
2595
2902
  const widgetIframe = this.iframeManager.getIframe(IframeType.WIDGET);
2596
2903
  if (widgetIframe?.element?.contentWindow) {
@@ -2601,6 +2908,7 @@ class WeldSDK {
2601
2908
  if (launcherIframe?.element?.contentWindow) {
2602
2909
  launcherIframe.element.contentWindow.postMessage({ type: 'weld:widget-opened' }, '*');
2603
2910
  }
2911
+ this.persistOpenState(true);
2604
2912
  this.config.onOpen?.();
2605
2913
  }
2606
2914
  /**
@@ -2611,8 +2919,6 @@ class WeldSDK {
2611
2919
  console.log('[Weld SDK] Closing widget...');
2612
2920
  this.stateCoordinator.closeWidget();
2613
2921
  this.iframeManager.hideIframe(IframeType.WIDGET);
2614
- this.iframeManager.hideIframe(IframeType.BACKDROP);
2615
- // Launcher stays visible
2616
2922
  // Send close message to the widget iframe
2617
2923
  const widgetIframe = this.iframeManager.getIframe(IframeType.WIDGET);
2618
2924
  if (widgetIframe?.element?.contentWindow) {
@@ -2623,6 +2929,7 @@ class WeldSDK {
2623
2929
  if (launcherIframe?.element?.contentWindow) {
2624
2930
  launcherIframe.element.contentWindow.postMessage({ type: 'weld:widget-closed' }, '*');
2625
2931
  }
2932
+ this.persistOpenState(false);
2626
2933
  this.config.onClose?.();
2627
2934
  }
2628
2935
  /**
@@ -2811,6 +3118,8 @@ class WeldSDK {
2811
3118
  });
2812
3119
  // Broadcast logout to iframes
2813
3120
  this.messageBroker.broadcast('weld:auth:logout', {});
3121
+ // Clear persisted widget state
3122
+ this.clearPersistedState();
2814
3123
  // Clear any stored session data
2815
3124
  try {
2816
3125
  const prefix = 'weld-';
@@ -2952,6 +3261,63 @@ class WeldSDK {
2952
3261
  this.logger.setLevel('warn');
2953
3262
  this.logger.info('Debug mode disabled');
2954
3263
  }
3264
+ /**
3265
+ * Send a page change message to the widget iframe
3266
+ */
3267
+ sendPageChange(url, title) {
3268
+ const widgetIframe = this.iframeManager.getIframe(IframeType.WIDGET);
3269
+ if (widgetIframe?.element?.contentWindow) {
3270
+ widgetIframe.element.contentWindow.postMessage({
3271
+ type: 'weld:page:change',
3272
+ url,
3273
+ title,
3274
+ timestamp: Date.now(),
3275
+ }, '*');
3276
+ }
3277
+ }
3278
+ /**
3279
+ * Start tracking page URL changes (SPA navigations + popstate)
3280
+ */
3281
+ startPageTracking() {
3282
+ let lastUrl = window.location.href;
3283
+ let debounceTimer = null;
3284
+ const notifyChange = () => {
3285
+ const currentUrl = window.location.href;
3286
+ if (currentUrl !== lastUrl) {
3287
+ lastUrl = currentUrl;
3288
+ this.sendPageChange(currentUrl, document.title);
3289
+ }
3290
+ };
3291
+ const debouncedNotify = () => {
3292
+ if (debounceTimer)
3293
+ clearTimeout(debounceTimer);
3294
+ debounceTimer = setTimeout(notifyChange, 300);
3295
+ };
3296
+ // Send initial page
3297
+ this.sendPageChange(window.location.href, document.title);
3298
+ // Monkey-patch history.pushState and history.replaceState
3299
+ const origPushState = history.pushState.bind(history);
3300
+ const origReplaceState = history.replaceState.bind(history);
3301
+ history.pushState = function (...args) {
3302
+ origPushState(...args);
3303
+ debouncedNotify();
3304
+ };
3305
+ history.replaceState = function (...args) {
3306
+ origReplaceState(...args);
3307
+ debouncedNotify();
3308
+ };
3309
+ // Listen for popstate (browser back/forward)
3310
+ const handlePopstate = () => debouncedNotify();
3311
+ window.addEventListener('popstate', handlePopstate);
3312
+ // Store cleanup
3313
+ this.pageTrackingCleanup = () => {
3314
+ if (debounceTimer)
3315
+ clearTimeout(debounceTimer);
3316
+ window.removeEventListener('popstate', handlePopstate);
3317
+ history.pushState = origPushState;
3318
+ history.replaceState = origReplaceState;
3319
+ };
3320
+ }
2955
3321
  /**
2956
3322
  * Ensure SDK is ready before operation
2957
3323
  */
@@ -2960,11 +3326,26 @@ class WeldSDK {
2960
3326
  throw new Error('SDK not ready. Call init() first.');
2961
3327
  }
2962
3328
  }
3329
+ /**
3330
+ * Detach from the current component lifecycle without destroying the widget.
3331
+ * Use this as a React useEffect cleanup — the widget stays alive across navigations.
3332
+ */
3333
+ detach() {
3334
+ // No-op: widget stays alive in the singleton registry
3335
+ this.logger.debug('WeldSDK detached (no-op, widget stays alive)');
3336
+ }
2963
3337
  /**
2964
3338
  * Destroy SDK and cleanup
2965
3339
  */
2966
3340
  destroy() {
2967
3341
  this.logger.info('Destroying WeldSDK');
3342
+ // Remove from singleton registry
3343
+ sdkRegistry.delete(this.config.widgetId);
3344
+ // Clear persisted state
3345
+ this.clearPersistedState();
3346
+ // Stop page tracking
3347
+ this.pageTrackingCleanup?.();
3348
+ this.pageTrackingCleanup = null;
2968
3349
  // Remove event listener using bound handler
2969
3350
  window.removeEventListener('message', this.boundHandleLauncherClick);
2970
3351
  // Unsubscribe from all message broker subscriptions
@@ -2984,13 +3365,33 @@ class WeldSDK {
2984
3365
  }
2985
3366
  }
2986
3367
  /**
2987
- * Create and initialize WeldSDK instance
3368
+ * Create and initialize WeldSDK instance.
3369
+ * Uses singleton pattern — if an instance for the same widgetId already exists
3370
+ * and is not destroyed, updates callbacks and returns the existing instance.
2988
3371
  */
2989
3372
  async function createWeldSDK(config) {
3373
+ const widgetId = config.widgetId;
3374
+ // Check for existing, non-destroyed instance
3375
+ const existing = sdkRegistry.get(widgetId);
3376
+ if (existing && existing.getStatus() !== 'destroyed') {
3377
+ existing.updateCallbacks(config);
3378
+ return existing;
3379
+ }
2990
3380
  const sdk = new WeldSDK(config);
3381
+ sdkRegistry.set(widgetId, sdk);
2991
3382
  await sdk.init();
2992
3383
  return sdk;
2993
3384
  }
3385
+ /**
3386
+ * Explicitly destroy a WeldSDK instance by widgetId.
3387
+ * Use this for logout or when you need to fully remove the widget.
3388
+ */
3389
+ function destroyWeldSDK(widgetId) {
3390
+ const sdk = sdkRegistry.get(widgetId);
3391
+ if (sdk) {
3392
+ sdk.destroy();
3393
+ }
3394
+ }
2994
3395
 
2995
- export { DEFAULT_CONFIG, WeldSDK as HelpdeskWidget, IframeManager, IframeType, LogLevel, Logger, MessageBroker, RateLimiter, SecurityManager, StateCoordinator, TokenValidator, WeldSDK, createInitialState, createMessage, createWeldSDK, deepClone, deepMerge, WeldSDK as default, defaultLogger, formatFileSize, getStateValue, hasRequiredProperties, createWeldSDK as initHelpdeskWidget, isBaseMessage, isInRange, isPayloadMessage, isPlainObject, isValidApiKey, isValidArrayLength, isValidColor, isValidEmail, isValidFileSize, isValidFileType, isValidLength, isValidMessageText, isValidUrl, isValidWorkspaceId, resolveConfig, sanitizeHtml, sanitizeInput, setStateValue, validateConfig };
3396
+ export { DEFAULT_CONFIG, WeldSDK as HelpdeskWidget, IframeManager, IframeType, LogLevel, Logger, MessageBroker, RateLimiter, SecurityManager, StateCoordinator, TokenValidator, WeldSDK, createInitialState, createMessage, createWeldSDK, deepClone, deepMerge, WeldSDK as default, defaultLogger, destroyWeldSDK as destroyHelpdeskWidget, destroyWeldSDK, formatFileSize, getStateValue, hasRequiredProperties, createWeldSDK as initHelpdeskWidget, isBaseMessage, isInRange, isPayloadMessage, isPlainObject, isValidApiKey, isValidArrayLength, isValidColor, isValidEmail, isValidFileSize, isValidFileType, isValidLength, isValidMessageText, isValidUrl, isValidWorkspaceId, resolveConfig, sanitizeHtml, sanitizeInput, setStateValue, validateConfig };
2996
3397
  //# sourceMappingURL=index.esm.js.map