@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.umd.js CHANGED
@@ -37,32 +37,6 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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,20 +472,27 @@
508
472
  * Create launcher iframe
509
473
  */
510
474
  async createLauncherIframe() {
475
+ // Guard: skip if launcher iframe already exists
476
+ if (this.iframes.has(exports.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
514
483
  const container = document.createElement('div');
515
484
  container.className = 'weld-launcher-frame';
516
485
  container.setAttribute('data-state', 'visible');
486
+ // Container is larger than the button to allow hover animations (scale, shadow) without clipping
487
+ const launcherPadding = 10;
517
488
  container.style.cssText = `
518
489
  position: fixed;
519
- bottom: ${launcher.position.bottom};
520
- right: ${launcher.position.right};
521
- width: ${launcher.size};
522
- height: ${launcher.size};
490
+ bottom: calc(${launcher.position.bottom} - ${launcherPadding}px);
491
+ right: calc(${launcher.position.right} - ${launcherPadding}px);
492
+ width: calc(${launcher.size} + ${launcherPadding * 2}px);
493
+ height: calc(${launcher.size} + ${launcherPadding * 2}px);
523
494
  z-index: 2147483003;
524
- pointer-events: auto;
495
+ pointer-events: none;
525
496
  display: block;
526
497
  `;
527
498
  // Create iframe
@@ -533,8 +504,12 @@
533
504
  width: 100%;
534
505
  height: 100%;
535
506
  border: none;
536
- background: transparent;
507
+ background: none;
508
+ color-scheme: none;
537
509
  display: block;
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);
538
513
  `;
539
514
  iframe.setAttribute('allow', 'clipboard-write');
540
515
  iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups');
@@ -550,20 +525,36 @@
550
525
  createdAt: Date.now(),
551
526
  });
552
527
  // When DOM loads, notify MessageBroker to send weld:init
528
+ let launcherRetried = false;
553
529
  iframe.onload = () => {
554
530
  const metadata = this.iframes.get(exports.IframeType.LAUNCHER);
555
531
  if (metadata) {
556
532
  this.logger.debug('Launcher iframe DOM loaded');
557
- // Notify MessageBroker that DOM is loaded (triggers weld:init)
558
533
  this.messageBroker?.setIframeDomLoaded(exports.IframeType.LAUNCHER);
559
534
  }
560
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
+ };
561
547
  this.logger.debug('Launcher iframe created');
562
548
  }
563
549
  /**
564
550
  * Create widget iframe
565
551
  */
566
552
  async createWidgetIframe() {
553
+ // Guard: skip if widget iframe already exists
554
+ if (this.iframes.has(exports.IframeType.WIDGET)) {
555
+ this.logger.debug('Widget iframe already exists, skipping creation');
556
+ return;
557
+ }
567
558
  const { iframes } = this.config;
568
559
  const { widget } = iframes;
569
560
  // Create container
@@ -644,54 +635,32 @@
644
635
  createdAt: Date.now(),
645
636
  });
646
637
  // When DOM loads, notify MessageBroker to send weld:init
638
+ let widgetRetried = false;
647
639
  iframe.onload = () => {
648
640
  const metadata = this.iframes.get(exports.IframeType.WIDGET);
649
641
  if (metadata) {
650
642
  this.logger.debug('Widget iframe DOM loaded');
651
- // Notify MessageBroker that DOM is loaded (triggers weld:init)
652
643
  this.messageBroker?.setIframeDomLoaded(exports.IframeType.WIDGET);
653
644
  }
654
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
+ };
655
657
  this.logger.debug('Widget iframe created');
656
658
  }
657
659
  /**
658
- * Create backdrop iframe
660
+ * Create backdrop iframe — disabled, widget stays non-modal so users can interact with the page
659
661
  */
660
662
  async createBackdropIframe() {
661
- if (!this.config.iframes.backdrop?.enabled) {
662
- this.logger.debug('Backdrop disabled, skipping creation');
663
- return;
664
- }
665
- // Create container
666
- const container = document.createElement('div');
667
- container.className = 'weld-backdrop-frame';
668
- container.setAttribute('data-state', 'hidden');
669
- container.style.cssText = `
670
- position: fixed;
671
- top: 0;
672
- left: 0;
673
- right: 0;
674
- bottom: 0;
675
- z-index: 2147483000;
676
- background: transparent;
677
- pointer-events: none;
678
- opacity: 0;
679
- transition: opacity 200ms ease;
680
- `;
681
- this.appContainer?.appendChild(container);
682
- // Store metadata (backdrop doesn't have an iframe, just a div)
683
- // We'll create a minimal "iframe" reference for consistency
684
- const dummyIframe = document.createElement('iframe');
685
- dummyIframe.style.display = 'none';
686
- this.iframes.set(exports.IframeType.BACKDROP, {
687
- type: exports.IframeType.BACKDROP,
688
- element: dummyIframe,
689
- container,
690
- ready: true, // Backdrop is always ready
691
- visible: false,
692
- createdAt: Date.now(),
693
- });
694
- this.logger.debug('Backdrop created');
663
+ this.logger.debug('Backdrop disabled, skipping creation');
695
664
  }
696
665
  /**
697
666
  * Build iframe URL with parameters
@@ -705,12 +674,21 @@
705
674
  url.searchParams.set('device', this.deviceInfo.type);
706
675
  url.searchParams.set('mobile', String(this.deviceInfo.isMobile));
707
676
  url.searchParams.set('parentOrigin', window.location.origin);
677
+ if (this.config.testMode) {
678
+ url.searchParams.set('testMode', 'true');
679
+ }
708
680
  return url.toString();
709
681
  }
710
682
  /**
711
683
  * Setup event listeners
712
684
  */
713
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;
714
692
  // Window resize - use bound handler for proper cleanup
715
693
  window.addEventListener('resize', this.boundHandleResize);
716
694
  // Orientation change - use bound handler for proper cleanup
@@ -813,7 +791,7 @@
813
791
  iframe.container.style.transform = 'scale(1) translateY(0)';
814
792
  }
815
793
  // Handle mobile scroll lock
816
- if (this.deviceInfo.isMobile && type === exports.IframeType.WIDGET && this.config.mobile.scrollLock) {
794
+ if (this.deviceInfo.isMobile && type === exports.IframeType.WIDGET) {
817
795
  document.body.classList.add('weld-mobile-open');
818
796
  }
819
797
  // Hide launcher on mobile when widget is open (full-screen mode)
@@ -912,6 +890,8 @@
912
890
  this.iframes.clear();
913
891
  // Clear messageBroker reference
914
892
  this.messageBroker = null;
893
+ // Reset guard flag
894
+ this.eventListenersBound = false;
915
895
  this.logger.info('IframeManager destroyed');
916
896
  }
917
897
  }
@@ -979,6 +959,8 @@
979
959
  // Events
980
960
  MessageType["EVENT_TRACK"] = "weld:event:track";
981
961
  MessageType["ERROR_REPORT"] = "weld:error:report";
962
+ // Page tracking
963
+ MessageType["PAGE_CHANGE"] = "weld:page:change";
982
964
  // API responses
983
965
  MessageType["API_SUCCESS"] = "weld:api:success";
984
966
  MessageType["API_ERROR"] = "weld:api:error";
@@ -1557,8 +1539,6 @@
1557
1539
  iframeType,
1558
1540
  config: {
1559
1541
  api: this.config.api,
1560
- customization: this.config.customization,
1561
- features: this.config.features,
1562
1542
  },
1563
1543
  };
1564
1544
  const message = createMessage('weld:init', MessageOrigin.PARENT, initPayload);
@@ -2401,7 +2381,7 @@
2401
2381
  }
2402
2382
  }
2403
2383
 
2404
- var version = "1.0.15";
2384
+ var version = "1.0.17";
2405
2385
  var packageJson = {
2406
2386
  version: version};
2407
2387
 
@@ -2409,6 +2389,16 @@
2409
2389
  * Weld SDK - Main Entry Point
2410
2390
  * Public API for the Weld helpdesk widget
2411
2391
  */
2392
+ /**
2393
+ * Module-level singleton registry keyed by widgetId
2394
+ */
2395
+ const sdkRegistry = new Map();
2396
+ /**
2397
+ * SessionStorage key helpers
2398
+ */
2399
+ function openStateKey(widgetId) {
2400
+ return `weld-widget-open-${widgetId}`;
2401
+ }
2412
2402
  /**
2413
2403
  * SDK initialization status
2414
2404
  */
@@ -2431,6 +2421,8 @@
2431
2421
  this.readyResolve = null;
2432
2422
  // Subscription IDs for cleanup
2433
2423
  this.subscriptionIds = [];
2424
+ // Page tracking cleanup
2425
+ this.pageTrackingCleanup = null;
2434
2426
  /**
2435
2427
  * Update user attributes (Intercom-style, with rate limiting)
2436
2428
  * Limited to 20 calls per page load to prevent abuse
@@ -2469,6 +2461,13 @@
2469
2461
  console.log('[Weld SDK] Received message:', event.data.type);
2470
2462
  }
2471
2463
  if (event.data?.type === 'launcher:clicked') {
2464
+ if (this.status !== SDKStatus.READY) {
2465
+ console.log('[Weld SDK] Launcher clicked but SDK not ready yet — waiting...');
2466
+ this.readyPromise?.then(() => {
2467
+ this.handleLauncherClickMessage(event);
2468
+ });
2469
+ return;
2470
+ }
2472
2471
  // Toggle behavior - if widget is open, close it; if closed, open it
2473
2472
  const state = this.stateCoordinator.getState();
2474
2473
  if (state.widget.isOpen) {
@@ -2481,9 +2480,260 @@
2481
2480
  }
2482
2481
  }
2483
2482
  if (event.data?.type === 'weld:close') {
2483
+ if (this.status !== SDKStatus.READY)
2484
+ return;
2484
2485
  console.log('[Weld SDK] Widget close requested');
2485
2486
  this.close();
2486
2487
  }
2488
+ if (event.data?.type === 'weld:unread-count') {
2489
+ const count = event.data.count ?? 0;
2490
+ // Forward to launcher iframe
2491
+ const launcherIframe = this.iframeManager.getIframe(exports.IframeType.LAUNCHER);
2492
+ if (launcherIframe?.element?.contentWindow) {
2493
+ launcherIframe.element.contentWindow.postMessage({
2494
+ type: 'weld:unread-count',
2495
+ count
2496
+ }, '*');
2497
+ }
2498
+ // Update state coordinator for external API consumers
2499
+ this.stateCoordinator.setBadgeCount(count);
2500
+ }
2501
+ if (event.data?.type === 'weld:image:open' && event.data?.url) {
2502
+ this.showImageLightbox(event.data.url);
2503
+ }
2504
+ }
2505
+ /**
2506
+ * Show fullscreen image lightbox on the parent page
2507
+ */
2508
+ showImageLightbox(url) {
2509
+ // Remove existing lightbox if any
2510
+ const existing = document.getElementById('weld-image-lightbox');
2511
+ if (existing)
2512
+ existing.remove();
2513
+ // Zoom / pan state
2514
+ let scale = 1;
2515
+ let translateX = 0;
2516
+ let translateY = 0;
2517
+ let isDragging = false;
2518
+ let dragStartX = 0;
2519
+ let dragStartY = 0;
2520
+ let lastTranslateX = 0;
2521
+ let lastTranslateY = 0;
2522
+ const applyTransform = () => {
2523
+ img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
2524
+ };
2525
+ const resetTransform = () => {
2526
+ scale = 1;
2527
+ translateX = 0;
2528
+ translateY = 0;
2529
+ applyTransform();
2530
+ img.style.cursor = 'zoom-in';
2531
+ };
2532
+ const overlay = document.createElement('div');
2533
+ overlay.id = 'weld-image-lightbox';
2534
+ overlay.style.cssText = `
2535
+ position: fixed;
2536
+ inset: 0;
2537
+ z-index: 2147483647;
2538
+ background: rgba(0, 0, 0, 0.92);
2539
+ display: flex;
2540
+ align-items: center;
2541
+ justify-content: center;
2542
+ padding: 16px;
2543
+ cursor: pointer;
2544
+ overflow: hidden;
2545
+ `;
2546
+ // Close button
2547
+ const closeBtn = document.createElement('button');
2548
+ 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>`;
2549
+ closeBtn.style.cssText = `
2550
+ position: absolute;
2551
+ top: 16px;
2552
+ right: 16px;
2553
+ width: 40px;
2554
+ height: 40px;
2555
+ border-radius: 50%;
2556
+ border: none;
2557
+ background: rgba(255, 255, 255, 0.1);
2558
+ color: white;
2559
+ cursor: pointer;
2560
+ display: flex;
2561
+ align-items: center;
2562
+ justify-content: center;
2563
+ transition: background 0.15s;
2564
+ `;
2565
+ closeBtn.onmouseenter = () => { closeBtn.style.background = 'rgba(255, 255, 255, 0.2)'; };
2566
+ closeBtn.onmouseleave = () => { closeBtn.style.background = 'rgba(255, 255, 255, 0.1)'; };
2567
+ // Download button
2568
+ const downloadBtn = document.createElement('a');
2569
+ downloadBtn.href = url;
2570
+ downloadBtn.download = '';
2571
+ downloadBtn.target = '_blank';
2572
+ downloadBtn.rel = 'noopener noreferrer';
2573
+ 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>`;
2574
+ downloadBtn.style.cssText = `
2575
+ position: absolute;
2576
+ top: 16px;
2577
+ right: 64px;
2578
+ width: 40px;
2579
+ height: 40px;
2580
+ border-radius: 50%;
2581
+ border: none;
2582
+ background: rgba(255, 255, 255, 0.1);
2583
+ color: white;
2584
+ cursor: pointer;
2585
+ display: flex;
2586
+ align-items: center;
2587
+ justify-content: center;
2588
+ transition: background 0.15s;
2589
+ text-decoration: none;
2590
+ `;
2591
+ downloadBtn.onmouseenter = () => { downloadBtn.style.background = 'rgba(255, 255, 255, 0.2)'; };
2592
+ downloadBtn.onmouseleave = () => { downloadBtn.style.background = 'rgba(255, 255, 255, 0.1)'; };
2593
+ // Image
2594
+ const img = document.createElement('img');
2595
+ img.src = url;
2596
+ img.alt = 'Full size';
2597
+ img.draggable = false;
2598
+ img.style.cssText = `
2599
+ max-width: 100%;
2600
+ max-height: 100%;
2601
+ object-fit: contain;
2602
+ border-radius: 8px;
2603
+ cursor: zoom-in;
2604
+ transition: transform 0.2s ease;
2605
+ user-select: none;
2606
+ `;
2607
+ // Click to toggle zoom
2608
+ img.addEventListener('click', (e) => {
2609
+ e.stopPropagation();
2610
+ if (scale === 1) {
2611
+ // Zoom in to 2.5x centered on click position
2612
+ const rect = img.getBoundingClientRect();
2613
+ const clickX = e.clientX - rect.left - rect.width / 2;
2614
+ const clickY = e.clientY - rect.top - rect.height / 2;
2615
+ scale = 2.5;
2616
+ translateX = -clickX * 1.5;
2617
+ translateY = -clickY * 1.5;
2618
+ applyTransform();
2619
+ img.style.cursor = 'zoom-out';
2620
+ }
2621
+ else {
2622
+ // Zoom out - reset
2623
+ resetTransform();
2624
+ }
2625
+ });
2626
+ // Mouse wheel zoom
2627
+ overlay.addEventListener('wheel', (e) => {
2628
+ e.preventDefault();
2629
+ const delta = e.deltaY > 0 ? -0.25 : 0.25;
2630
+ const newScale = Math.min(Math.max(scale + delta, 1), 5);
2631
+ if (newScale === 1) {
2632
+ resetTransform();
2633
+ }
2634
+ else {
2635
+ scale = newScale;
2636
+ applyTransform();
2637
+ img.style.cursor = 'zoom-out';
2638
+ }
2639
+ }, { passive: false });
2640
+ // Drag to pan when zoomed
2641
+ img.addEventListener('mousedown', (e) => {
2642
+ if (scale <= 1)
2643
+ return;
2644
+ e.preventDefault();
2645
+ isDragging = true;
2646
+ dragStartX = e.clientX;
2647
+ dragStartY = e.clientY;
2648
+ lastTranslateX = translateX;
2649
+ lastTranslateY = translateY;
2650
+ img.style.cursor = 'grabbing';
2651
+ img.style.transition = 'none';
2652
+ });
2653
+ window.addEventListener('mousemove', (e) => {
2654
+ if (!isDragging)
2655
+ return;
2656
+ translateX = lastTranslateX + (e.clientX - dragStartX);
2657
+ translateY = lastTranslateY + (e.clientY - dragStartY);
2658
+ applyTransform();
2659
+ });
2660
+ window.addEventListener('mouseup', () => {
2661
+ if (!isDragging)
2662
+ return;
2663
+ isDragging = false;
2664
+ img.style.cursor = scale > 1 ? 'zoom-out' : 'zoom-in';
2665
+ img.style.transition = 'transform 0.2s ease';
2666
+ });
2667
+ // Touch: pinch to zoom + drag to pan
2668
+ let lastTouchDist = 0;
2669
+ let lastTouchScale = 1;
2670
+ overlay.addEventListener('touchstart', (e) => {
2671
+ if (e.touches.length === 2) {
2672
+ e.preventDefault();
2673
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
2674
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
2675
+ lastTouchDist = Math.hypot(dx, dy);
2676
+ lastTouchScale = scale;
2677
+ }
2678
+ else if (e.touches.length === 1 && scale > 1) {
2679
+ isDragging = true;
2680
+ dragStartX = e.touches[0].clientX;
2681
+ dragStartY = e.touches[0].clientY;
2682
+ lastTranslateX = translateX;
2683
+ lastTranslateY = translateY;
2684
+ img.style.transition = 'none';
2685
+ }
2686
+ }, { passive: false });
2687
+ overlay.addEventListener('touchmove', (e) => {
2688
+ if (e.touches.length === 2) {
2689
+ e.preventDefault();
2690
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
2691
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
2692
+ const dist = Math.hypot(dx, dy);
2693
+ scale = Math.min(Math.max(lastTouchScale * (dist / lastTouchDist), 1), 5);
2694
+ if (scale === 1) {
2695
+ translateX = 0;
2696
+ translateY = 0;
2697
+ }
2698
+ applyTransform();
2699
+ }
2700
+ else if (e.touches.length === 1 && isDragging) {
2701
+ e.preventDefault();
2702
+ translateX = lastTranslateX + (e.touches[0].clientX - dragStartX);
2703
+ translateY = lastTranslateY + (e.touches[0].clientY - dragStartY);
2704
+ applyTransform();
2705
+ }
2706
+ }, { passive: false });
2707
+ overlay.addEventListener('touchend', (e) => {
2708
+ if (e.touches.length < 2) {
2709
+ lastTouchDist = 0;
2710
+ }
2711
+ if (e.touches.length === 0) {
2712
+ isDragging = false;
2713
+ img.style.transition = 'transform 0.2s ease';
2714
+ }
2715
+ });
2716
+ const close = () => {
2717
+ document.removeEventListener('keydown', handleKeyDown);
2718
+ overlay.remove();
2719
+ };
2720
+ // Only close on backdrop click when not zoomed (prevent accidental close while panning)
2721
+ overlay.addEventListener('click', (e) => {
2722
+ if (e.target === overlay && scale <= 1)
2723
+ close();
2724
+ });
2725
+ downloadBtn.addEventListener('click', (e) => e.stopPropagation());
2726
+ closeBtn.addEventListener('click', (e) => { e.stopPropagation(); close(); });
2727
+ // Close on Escape
2728
+ const handleKeyDown = (e) => {
2729
+ if (e.key === 'Escape')
2730
+ close();
2731
+ };
2732
+ document.addEventListener('keydown', handleKeyDown);
2733
+ overlay.appendChild(closeBtn);
2734
+ overlay.appendChild(downloadBtn);
2735
+ overlay.appendChild(img);
2736
+ document.body.appendChild(overlay);
2487
2737
  }
2488
2738
  /**
2489
2739
  * Initialize the SDK and render widget
@@ -2509,6 +2759,13 @@
2509
2759
  this.logger.info('WeldSDK ready');
2510
2760
  // Call onReady callback
2511
2761
  this.config.onReady?.();
2762
+ // Start tracking page URL changes
2763
+ this.startPageTracking();
2764
+ // Auto-open if widget was previously open (persisted in sessionStorage)
2765
+ if (this.wasOpen()) {
2766
+ this.logger.info('Restoring previously open widget from sessionStorage');
2767
+ this.open();
2768
+ }
2512
2769
  }
2513
2770
  catch (error) {
2514
2771
  this.status = SDKStatus.ERROR;
@@ -2587,6 +2844,58 @@
2587
2844
  isReady() {
2588
2845
  return this.status === SDKStatus.READY;
2589
2846
  }
2847
+ /**
2848
+ * Update callbacks on an existing instance (used by singleton reuse)
2849
+ */
2850
+ updateCallbacks(config) {
2851
+ if (config.onReady !== undefined)
2852
+ this.config.onReady = config.onReady;
2853
+ if (config.onOpen !== undefined)
2854
+ this.config.onOpen = config.onOpen;
2855
+ if (config.onClose !== undefined)
2856
+ this.config.onClose = config.onClose;
2857
+ if (config.onError !== undefined)
2858
+ this.config.onError = config.onError;
2859
+ if (config.onDestroy !== undefined)
2860
+ this.config.onDestroy = config.onDestroy;
2861
+ if (config.onMinimize !== undefined)
2862
+ this.config.onMinimize = config.onMinimize;
2863
+ if (config.onMaximize !== undefined)
2864
+ this.config.onMaximize = config.onMaximize;
2865
+ }
2866
+ /**
2867
+ * Persist open/closed state to sessionStorage
2868
+ */
2869
+ persistOpenState(isOpen) {
2870
+ try {
2871
+ sessionStorage.setItem(openStateKey(this.config.widgetId), isOpen ? 'true' : 'false');
2872
+ }
2873
+ catch {
2874
+ // sessionStorage might not be available
2875
+ }
2876
+ }
2877
+ /**
2878
+ * Clear persisted state from sessionStorage
2879
+ */
2880
+ clearPersistedState() {
2881
+ try {
2882
+ sessionStorage.removeItem(openStateKey(this.config.widgetId));
2883
+ }
2884
+ catch {
2885
+ // sessionStorage might not be available
2886
+ }
2887
+ }
2888
+ /**
2889
+ * Check if widget was previously open (from sessionStorage)
2890
+ */
2891
+ wasOpen() {
2892
+ try {
2893
+ return sessionStorage.getItem(openStateKey(this.config.widgetId)) === 'true';
2894
+ }
2895
+ catch {
2896
+ return false;
2897
+ }
2898
+ }
2590
2899
  /**
2591
2900
  * Open the widget
2592
2901
  */
@@ -2595,8 +2904,6 @@
2595
2904
  console.log('[Weld SDK] Opening widget...');
2596
2905
  this.stateCoordinator.openWidget();
2597
2906
  this.iframeManager.showIframe(exports.IframeType.WIDGET);
2598
- this.iframeManager.showIframe(exports.IframeType.BACKDROP);
2599
- // Keep launcher visible so user can click it to close the widget
2600
2907
  // Send open message to the widget iframe
2601
2908
  const widgetIframe = this.iframeManager.getIframe(exports.IframeType.WIDGET);
2602
2909
  if (widgetIframe?.element?.contentWindow) {
@@ -2607,6 +2914,7 @@
2607
2914
  if (launcherIframe?.element?.contentWindow) {
2608
2915
  launcherIframe.element.contentWindow.postMessage({ type: 'weld:widget-opened' }, '*');
2609
2916
  }
2917
+ this.persistOpenState(true);
2610
2918
  this.config.onOpen?.();
2611
2919
  }
2612
2920
  /**
@@ -2617,8 +2925,6 @@
2617
2925
  console.log('[Weld SDK] Closing widget...');
2618
2926
  this.stateCoordinator.closeWidget();
2619
2927
  this.iframeManager.hideIframe(exports.IframeType.WIDGET);
2620
- this.iframeManager.hideIframe(exports.IframeType.BACKDROP);
2621
- // Launcher stays visible
2622
2928
  // Send close message to the widget iframe
2623
2929
  const widgetIframe = this.iframeManager.getIframe(exports.IframeType.WIDGET);
2624
2930
  if (widgetIframe?.element?.contentWindow) {
@@ -2629,6 +2935,7 @@
2629
2935
  if (launcherIframe?.element?.contentWindow) {
2630
2936
  launcherIframe.element.contentWindow.postMessage({ type: 'weld:widget-closed' }, '*');
2631
2937
  }
2938
+ this.persistOpenState(false);
2632
2939
  this.config.onClose?.();
2633
2940
  }
2634
2941
  /**
@@ -2817,6 +3124,8 @@
2817
3124
  });
2818
3125
  // Broadcast logout to iframes
2819
3126
  this.messageBroker.broadcast('weld:auth:logout', {});
3127
+ // Clear persisted widget state
3128
+ this.clearPersistedState();
2820
3129
  // Clear any stored session data
2821
3130
  try {
2822
3131
  const prefix = 'weld-';
@@ -2958,6 +3267,63 @@
2958
3267
  this.logger.setLevel('warn');
2959
3268
  this.logger.info('Debug mode disabled');
2960
3269
  }
3270
+ /**
3271
+ * Send a page change message to the widget iframe
3272
+ */
3273
+ sendPageChange(url, title) {
3274
+ const widgetIframe = this.iframeManager.getIframe(exports.IframeType.WIDGET);
3275
+ if (widgetIframe?.element?.contentWindow) {
3276
+ widgetIframe.element.contentWindow.postMessage({
3277
+ type: 'weld:page:change',
3278
+ url,
3279
+ title,
3280
+ timestamp: Date.now(),
3281
+ }, '*');
3282
+ }
3283
+ }
3284
+ /**
3285
+ * Start tracking page URL changes (SPA navigations + popstate)
3286
+ */
3287
+ startPageTracking() {
3288
+ let lastUrl = window.location.href;
3289
+ let debounceTimer = null;
3290
+ const notifyChange = () => {
3291
+ const currentUrl = window.location.href;
3292
+ if (currentUrl !== lastUrl) {
3293
+ lastUrl = currentUrl;
3294
+ this.sendPageChange(currentUrl, document.title);
3295
+ }
3296
+ };
3297
+ const debouncedNotify = () => {
3298
+ if (debounceTimer)
3299
+ clearTimeout(debounceTimer);
3300
+ debounceTimer = setTimeout(notifyChange, 300);
3301
+ };
3302
+ // Send initial page
3303
+ this.sendPageChange(window.location.href, document.title);
3304
+ // Monkey-patch history.pushState and history.replaceState
3305
+ const origPushState = history.pushState.bind(history);
3306
+ const origReplaceState = history.replaceState.bind(history);
3307
+ history.pushState = function (...args) {
3308
+ origPushState(...args);
3309
+ debouncedNotify();
3310
+ };
3311
+ history.replaceState = function (...args) {
3312
+ origReplaceState(...args);
3313
+ debouncedNotify();
3314
+ };
3315
+ // Listen for popstate (browser back/forward)
3316
+ const handlePopstate = () => debouncedNotify();
3317
+ window.addEventListener('popstate', handlePopstate);
3318
+ // Store cleanup
3319
+ this.pageTrackingCleanup = () => {
3320
+ if (debounceTimer)
3321
+ clearTimeout(debounceTimer);
3322
+ window.removeEventListener('popstate', handlePopstate);
3323
+ history.pushState = origPushState;
3324
+ history.replaceState = origReplaceState;
3325
+ };
3326
+ }
2961
3327
  /**
2962
3328
  * Ensure SDK is ready before operation
2963
3329
  */
@@ -2966,11 +3332,26 @@
2966
3332
  throw new Error('SDK not ready. Call init() first.');
2967
3333
  }
2968
3334
  }
3335
+ /**
3336
+ * Detach from the current component lifecycle without destroying the widget.
3337
+ * Use this as a React useEffect cleanup — the widget stays alive across navigations.
3338
+ */
3339
+ detach() {
3340
+ // No-op: widget stays alive in the singleton registry
3341
+ this.logger.debug('WeldSDK detached (no-op, widget stays alive)');
3342
+ }
2969
3343
  /**
2970
3344
  * Destroy SDK and cleanup
2971
3345
  */
2972
3346
  destroy() {
2973
3347
  this.logger.info('Destroying WeldSDK');
3348
+ // Remove from singleton registry
3349
+ sdkRegistry.delete(this.config.widgetId);
3350
+ // Clear persisted state
3351
+ this.clearPersistedState();
3352
+ // Stop page tracking
3353
+ this.pageTrackingCleanup?.();
3354
+ this.pageTrackingCleanup = null;
2974
3355
  // Remove event listener using bound handler
2975
3356
  window.removeEventListener('message', this.boundHandleLauncherClick);
2976
3357
  // Unsubscribe from all message broker subscriptions
@@ -2990,13 +3371,33 @@
2990
3371
  }
2991
3372
  }
2992
3373
  /**
2993
- * Create and initialize WeldSDK instance
3374
+ * Create and initialize WeldSDK instance.
3375
+ * Uses singleton pattern — if an instance for the same widgetId already exists
3376
+ * and is not destroyed, updates callbacks and returns the existing instance.
2994
3377
  */
2995
3378
  async function createWeldSDK(config) {
3379
+ const widgetId = config.widgetId;
3380
+ // Check for existing, non-destroyed instance
3381
+ const existing = sdkRegistry.get(widgetId);
3382
+ if (existing && existing.getStatus() !== 'destroyed') {
3383
+ existing.updateCallbacks(config);
3384
+ return existing;
3385
+ }
2996
3386
  const sdk = new WeldSDK(config);
3387
+ sdkRegistry.set(widgetId, sdk);
2997
3388
  await sdk.init();
2998
3389
  return sdk;
2999
3390
  }
3391
+ /**
3392
+ * Explicitly destroy a WeldSDK instance by widgetId.
3393
+ * Use this for logout or when you need to fully remove the widget.
3394
+ */
3395
+ function destroyWeldSDK(widgetId) {
3396
+ const sdk = sdkRegistry.get(widgetId);
3397
+ if (sdk) {
3398
+ sdk.destroy();
3399
+ }
3400
+ }
3000
3401
 
3001
3402
  exports.DEFAULT_CONFIG = DEFAULT_CONFIG;
3002
3403
  exports.HelpdeskWidget = WeldSDK;
@@ -3015,6 +3416,8 @@
3015
3416
  exports.deepMerge = deepMerge;
3016
3417
  exports.default = WeldSDK;
3017
3418
  exports.defaultLogger = defaultLogger;
3419
+ exports.destroyHelpdeskWidget = destroyWeldSDK;
3420
+ exports.destroyWeldSDK = destroyWeldSDK;
3018
3421
  exports.formatFileSize = formatFileSize;
3019
3422
  exports.getStateValue = getStateValue;
3020
3423
  exports.hasRequiredProperties = hasRequiredProperties;