@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.
@@ -93,32 +93,6 @@ const DEFAULT_CONFIG = {
93
93
  closeOnClick: true,
94
94
  },
95
95
  },
96
- customization: {
97
- primaryColor: '#000000',
98
- accentColor: '#3b82f6',
99
- backgroundColor: '#ffffff',
100
- textColor: '#111827',
101
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
102
- fontSize: '14px',
103
- borderRadius: '12px',
104
- },
105
- features: {
106
- attachments: true,
107
- reactions: true,
108
- typing: true,
109
- readReceipts: true,
110
- offlineMode: false,
111
- fileUpload: true,
112
- imageUpload: true,
113
- voiceMessages: false,
114
- videoMessages: false,
115
- },
116
- mobile: {
117
- fullScreen: true,
118
- scrollLock: true,
119
- keyboardHandling: 'auto',
120
- safeAreaInsets: true,
121
- },
122
96
  auth: {
123
97
  enabled: true,
124
98
  mode: 'anonymous',
@@ -162,6 +136,7 @@ function resolveConfig(config) {
162
136
  validateConfig(config);
163
137
  return {
164
138
  widgetId: config.widgetId,
139
+ testMode: config.testMode,
165
140
  api: {
166
141
  ...DEFAULT_CONFIG.api,
167
142
  widgetId: config.widgetId,
@@ -191,18 +166,6 @@ function resolveConfig(config) {
191
166
  ...config.iframes?.backdrop,
192
167
  },
193
168
  },
194
- customization: {
195
- ...DEFAULT_CONFIG.customization,
196
- ...config.customization,
197
- },
198
- features: {
199
- ...DEFAULT_CONFIG.features,
200
- ...config.features,
201
- },
202
- mobile: {
203
- ...DEFAULT_CONFIG.mobile,
204
- ...config.mobile,
205
- },
206
169
  auth: {
207
170
  ...DEFAULT_CONFIG.auth,
208
171
  ...config.auth,
@@ -445,6 +408,8 @@ class IframeManager {
445
408
  this.modalContainer = null;
446
409
  this.styleElement = null;
447
410
  this.messageBroker = null;
411
+ // Guard flag to prevent double-binding event listeners
412
+ this.eventListenersBound = false;
448
413
  this.config = config;
449
414
  this.logger = new Logger(config.logging);
450
415
  this.deviceInfo = detectDevice();
@@ -484,18 +449,27 @@ class IframeManager {
484
449
  }
485
450
  /**
486
451
  * Create root container structure
452
+ * Reuses existing container if it has the same widgetId (singleton behavior)
487
453
  */
488
454
  createRootContainer() {
489
- // Check if already exists
490
- let existingContainer = document.getElementById('weld-container');
455
+ const existingContainer = document.getElementById('weld-container');
491
456
  if (existingContainer) {
492
- this.logger.warn('Weld container already exists, removing old instance');
457
+ // Reuse if same widgetId
458
+ if (existingContainer.getAttribute('data-widget-id') === this.config.widgetId) {
459
+ this.logger.debug('Reusing existing root container');
460
+ this.rootContainer = existingContainer;
461
+ this.appContainer = existingContainer.querySelector('.weld-app');
462
+ this.modalContainer = document.getElementById('weld-modal-container');
463
+ return;
464
+ }
465
+ this.logger.warn('Weld container already exists with different widgetId, removing old instance');
493
466
  existingContainer.remove();
494
467
  }
495
468
  // Create root container
496
469
  this.rootContainer = document.createElement('div');
497
470
  this.rootContainer.id = 'weld-container';
498
471
  this.rootContainer.className = 'weld-namespace';
472
+ this.rootContainer.setAttribute('data-widget-id', this.config.widgetId);
499
473
  // Create app container
500
474
  this.appContainer = document.createElement('div');
501
475
  this.appContainer.className = 'weld-app';
@@ -530,17 +504,7 @@ class IframeManager {
530
504
  * Generate CSS for containers
531
505
  */
532
506
  generateCSS() {
533
- const { customization } = this.config;
534
507
  return `
535
- /* Weld Container */
536
- #weld-container {
537
- --weld-color-primary: ${customization.primaryColor};
538
- --weld-color-accent: ${customization.accentColor};
539
- --weld-font-family: ${customization.fontFamily};
540
- --weld-font-size-base: ${customization.fontSize};
541
- --weld-radius-xl: ${customization.borderRadius};
542
- }
543
-
544
508
  /* Import main stylesheet */
545
509
  @import url('/styles/index.css');
546
510
 
@@ -564,20 +528,27 @@ class IframeManager {
564
528
  * Create launcher iframe
565
529
  */
566
530
  async createLauncherIframe() {
531
+ // Guard: skip if launcher iframe already exists
532
+ if (this.iframes.has(IframeType.LAUNCHER)) {
533
+ this.logger.debug('Launcher iframe already exists, skipping creation');
534
+ return;
535
+ }
567
536
  const { iframes } = this.config;
568
537
  const { launcher } = iframes;
569
538
  // Create container
570
539
  const container = document.createElement('div');
571
540
  container.className = 'weld-launcher-frame';
572
541
  container.setAttribute('data-state', 'visible');
542
+ // Container is larger than the button to allow hover animations (scale, shadow) without clipping
543
+ const launcherPadding = 10;
573
544
  container.style.cssText = `
574
545
  position: fixed;
575
- bottom: ${launcher.position.bottom};
576
- right: ${launcher.position.right};
577
- width: ${launcher.size};
578
- height: ${launcher.size};
546
+ bottom: calc(${launcher.position.bottom} - ${launcherPadding}px);
547
+ right: calc(${launcher.position.right} - ${launcherPadding}px);
548
+ width: calc(${launcher.size} + ${launcherPadding * 2}px);
549
+ height: calc(${launcher.size} + ${launcherPadding * 2}px);
579
550
  z-index: 2147483003;
580
- pointer-events: auto;
551
+ pointer-events: none;
581
552
  display: block;
582
553
  `;
583
554
  // Create iframe
@@ -589,8 +560,12 @@ class IframeManager {
589
560
  width: 100%;
590
561
  height: 100%;
591
562
  border: none;
592
- background: transparent;
563
+ background: none;
564
+ color-scheme: none;
593
565
  display: block;
566
+ pointer-events: auto;
567
+ border-radius: 50%;
568
+ filter: drop-shadow(rgba(9, 14, 21, 0.54) 0px 1px 6px) drop-shadow(rgba(9, 14, 21, 0.9) 0px 2px 32px);
594
569
  `;
595
570
  iframe.setAttribute('allow', 'clipboard-write');
596
571
  iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups');
@@ -606,20 +581,36 @@ class IframeManager {
606
581
  createdAt: Date.now(),
607
582
  });
608
583
  // When DOM loads, notify MessageBroker to send weld:init
584
+ let launcherRetried = false;
609
585
  iframe.onload = () => {
610
586
  const metadata = this.iframes.get(IframeType.LAUNCHER);
611
587
  if (metadata) {
612
588
  this.logger.debug('Launcher iframe DOM loaded');
613
- // Notify MessageBroker that DOM is loaded (triggers weld:init)
614
589
  this.messageBroker?.setIframeDomLoaded(IframeType.LAUNCHER);
615
590
  }
616
591
  };
592
+ iframe.onerror = () => {
593
+ this.logger.error('Launcher iframe failed to load');
594
+ if (!launcherRetried) {
595
+ launcherRetried = true;
596
+ this.logger.info('Retrying launcher iframe load...');
597
+ setTimeout(() => { iframe.src = this.buildIframeUrl(launcher.url); }, 3000);
598
+ }
599
+ else {
600
+ this.config.onError?.(new Error('Failed to load widget launcher'));
601
+ }
602
+ };
617
603
  this.logger.debug('Launcher iframe created');
618
604
  }
619
605
  /**
620
606
  * Create widget iframe
621
607
  */
622
608
  async createWidgetIframe() {
609
+ // Guard: skip if widget iframe already exists
610
+ if (this.iframes.has(IframeType.WIDGET)) {
611
+ this.logger.debug('Widget iframe already exists, skipping creation');
612
+ return;
613
+ }
623
614
  const { iframes } = this.config;
624
615
  const { widget } = iframes;
625
616
  // Create container
@@ -700,54 +691,32 @@ class IframeManager {
700
691
  createdAt: Date.now(),
701
692
  });
702
693
  // When DOM loads, notify MessageBroker to send weld:init
694
+ let widgetRetried = false;
703
695
  iframe.onload = () => {
704
696
  const metadata = this.iframes.get(IframeType.WIDGET);
705
697
  if (metadata) {
706
698
  this.logger.debug('Widget iframe DOM loaded');
707
- // Notify MessageBroker that DOM is loaded (triggers weld:init)
708
699
  this.messageBroker?.setIframeDomLoaded(IframeType.WIDGET);
709
700
  }
710
701
  };
702
+ iframe.onerror = () => {
703
+ this.logger.error('Widget iframe failed to load');
704
+ if (!widgetRetried) {
705
+ widgetRetried = true;
706
+ this.logger.info('Retrying widget iframe load...');
707
+ setTimeout(() => { iframe.src = this.buildIframeUrl(widget.url); }, 3000);
708
+ }
709
+ else {
710
+ this.config.onError?.(new Error('Failed to load widget'));
711
+ }
712
+ };
711
713
  this.logger.debug('Widget iframe created');
712
714
  }
713
715
  /**
714
- * Create backdrop iframe
716
+ * Create backdrop iframe — disabled, widget stays non-modal so users can interact with the page
715
717
  */
716
718
  async createBackdropIframe() {
717
- if (!this.config.iframes.backdrop?.enabled) {
718
- this.logger.debug('Backdrop disabled, skipping creation');
719
- return;
720
- }
721
- // Create container
722
- const container = document.createElement('div');
723
- container.className = 'weld-backdrop-frame';
724
- container.setAttribute('data-state', 'hidden');
725
- container.style.cssText = `
726
- position: fixed;
727
- top: 0;
728
- left: 0;
729
- right: 0;
730
- bottom: 0;
731
- z-index: 2147483000;
732
- background: transparent;
733
- pointer-events: none;
734
- opacity: 0;
735
- transition: opacity 200ms ease;
736
- `;
737
- this.appContainer?.appendChild(container);
738
- // Store metadata (backdrop doesn't have an iframe, just a div)
739
- // We'll create a minimal "iframe" reference for consistency
740
- const dummyIframe = document.createElement('iframe');
741
- dummyIframe.style.display = 'none';
742
- this.iframes.set(IframeType.BACKDROP, {
743
- type: IframeType.BACKDROP,
744
- element: dummyIframe,
745
- container,
746
- ready: true, // Backdrop is always ready
747
- visible: false,
748
- createdAt: Date.now(),
749
- });
750
- this.logger.debug('Backdrop created');
719
+ this.logger.debug('Backdrop disabled, skipping creation');
751
720
  }
752
721
  /**
753
722
  * Build iframe URL with parameters
@@ -761,12 +730,21 @@ class IframeManager {
761
730
  url.searchParams.set('device', this.deviceInfo.type);
762
731
  url.searchParams.set('mobile', String(this.deviceInfo.isMobile));
763
732
  url.searchParams.set('parentOrigin', window.location.origin);
733
+ if (this.config.testMode) {
734
+ url.searchParams.set('testMode', 'true');
735
+ }
764
736
  return url.toString();
765
737
  }
766
738
  /**
767
739
  * Setup event listeners
768
740
  */
769
741
  setupEventListeners() {
742
+ // Guard: prevent double-binding
743
+ if (this.eventListenersBound) {
744
+ this.logger.debug('Event listeners already bound, skipping');
745
+ return;
746
+ }
747
+ this.eventListenersBound = true;
770
748
  // Window resize - use bound handler for proper cleanup
771
749
  window.addEventListener('resize', this.boundHandleResize);
772
750
  // Orientation change - use bound handler for proper cleanup
@@ -869,7 +847,7 @@ class IframeManager {
869
847
  iframe.container.style.transform = 'scale(1) translateY(0)';
870
848
  }
871
849
  // Handle mobile scroll lock
872
- if (this.deviceInfo.isMobile && type === IframeType.WIDGET && this.config.mobile.scrollLock) {
850
+ if (this.deviceInfo.isMobile && type === IframeType.WIDGET) {
873
851
  document.body.classList.add('weld-mobile-open');
874
852
  }
875
853
  // Hide launcher on mobile when widget is open (full-screen mode)
@@ -968,6 +946,8 @@ class IframeManager {
968
946
  this.iframes.clear();
969
947
  // Clear messageBroker reference
970
948
  this.messageBroker = null;
949
+ // Reset guard flag
950
+ this.eventListenersBound = false;
971
951
  this.logger.info('IframeManager destroyed');
972
952
  }
973
953
  }
@@ -1035,6 +1015,8 @@ var MessageType;
1035
1015
  // Events
1036
1016
  MessageType["EVENT_TRACK"] = "weld:event:track";
1037
1017
  MessageType["ERROR_REPORT"] = "weld:error:report";
1018
+ // Page tracking
1019
+ MessageType["PAGE_CHANGE"] = "weld:page:change";
1038
1020
  // API responses
1039
1021
  MessageType["API_SUCCESS"] = "weld:api:success";
1040
1022
  MessageType["API_ERROR"] = "weld:api:error";
@@ -1574,8 +1556,6 @@ class MessageBroker {
1574
1556
  iframeType,
1575
1557
  config: {
1576
1558
  api: this.config.api,
1577
- customization: this.config.customization,
1578
- features: this.config.features,
1579
1559
  },
1580
1560
  };
1581
1561
  const message = createMessage('weld:init', MessageOrigin.PARENT, initPayload);
@@ -2248,7 +2228,7 @@ class StateCoordinator {
2248
2228
  }
2249
2229
  }
2250
2230
 
2251
- var version = "1.0.15";
2231
+ var version = "1.0.17";
2252
2232
  var packageJson = {
2253
2233
  version: version};
2254
2234
 
@@ -2256,6 +2236,16 @@ var packageJson = {
2256
2236
  * Weld SDK - Main Entry Point
2257
2237
  * Public API for the Weld helpdesk widget
2258
2238
  */
2239
+ /**
2240
+ * Module-level singleton registry keyed by widgetId
2241
+ */
2242
+ const sdkRegistry = new Map();
2243
+ /**
2244
+ * SessionStorage key helpers
2245
+ */
2246
+ function openStateKey(widgetId) {
2247
+ return `weld-widget-open-${widgetId}`;
2248
+ }
2259
2249
  /**
2260
2250
  * SDK initialization status
2261
2251
  */
@@ -2278,6 +2268,8 @@ class WeldSDK {
2278
2268
  this.readyResolve = null;
2279
2269
  // Subscription IDs for cleanup
2280
2270
  this.subscriptionIds = [];
2271
+ // Page tracking cleanup
2272
+ this.pageTrackingCleanup = null;
2281
2273
  /**
2282
2274
  * Update user attributes (Intercom-style, with rate limiting)
2283
2275
  * Limited to 20 calls per page load to prevent abuse
@@ -2316,6 +2308,13 @@ class WeldSDK {
2316
2308
  console.log('[Weld SDK] Received message:', event.data.type);
2317
2309
  }
2318
2310
  if (event.data?.type === 'launcher:clicked') {
2311
+ if (this.status !== SDKStatus.READY) {
2312
+ console.log('[Weld SDK] Launcher clicked but SDK not ready yet — waiting...');
2313
+ this.readyPromise?.then(() => {
2314
+ this.handleLauncherClickMessage(event);
2315
+ });
2316
+ return;
2317
+ }
2319
2318
  // Toggle behavior - if widget is open, close it; if closed, open it
2320
2319
  const state = this.stateCoordinator.getState();
2321
2320
  if (state.widget.isOpen) {
@@ -2328,9 +2327,260 @@ class WeldSDK {
2328
2327
  }
2329
2328
  }
2330
2329
  if (event.data?.type === 'weld:close') {
2330
+ if (this.status !== SDKStatus.READY)
2331
+ return;
2331
2332
  console.log('[Weld SDK] Widget close requested');
2332
2333
  this.close();
2333
2334
  }
2335
+ if (event.data?.type === 'weld:unread-count') {
2336
+ const count = event.data.count ?? 0;
2337
+ // Forward to launcher iframe
2338
+ const launcherIframe = this.iframeManager.getIframe(IframeType.LAUNCHER);
2339
+ if (launcherIframe?.element?.contentWindow) {
2340
+ launcherIframe.element.contentWindow.postMessage({
2341
+ type: 'weld:unread-count',
2342
+ count
2343
+ }, '*');
2344
+ }
2345
+ // Update state coordinator for external API consumers
2346
+ this.stateCoordinator.setBadgeCount(count);
2347
+ }
2348
+ if (event.data?.type === 'weld:image:open' && event.data?.url) {
2349
+ this.showImageLightbox(event.data.url);
2350
+ }
2351
+ }
2352
+ /**
2353
+ * Show fullscreen image lightbox on the parent page
2354
+ */
2355
+ showImageLightbox(url) {
2356
+ // Remove existing lightbox if any
2357
+ const existing = document.getElementById('weld-image-lightbox');
2358
+ if (existing)
2359
+ existing.remove();
2360
+ // Zoom / pan state
2361
+ let scale = 1;
2362
+ let translateX = 0;
2363
+ let translateY = 0;
2364
+ let isDragging = false;
2365
+ let dragStartX = 0;
2366
+ let dragStartY = 0;
2367
+ let lastTranslateX = 0;
2368
+ let lastTranslateY = 0;
2369
+ const applyTransform = () => {
2370
+ img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
2371
+ };
2372
+ const resetTransform = () => {
2373
+ scale = 1;
2374
+ translateX = 0;
2375
+ translateY = 0;
2376
+ applyTransform();
2377
+ img.style.cursor = 'zoom-in';
2378
+ };
2379
+ const overlay = document.createElement('div');
2380
+ overlay.id = 'weld-image-lightbox';
2381
+ overlay.style.cssText = `
2382
+ position: fixed;
2383
+ inset: 0;
2384
+ z-index: 2147483647;
2385
+ background: rgba(0, 0, 0, 0.92);
2386
+ display: flex;
2387
+ align-items: center;
2388
+ justify-content: center;
2389
+ padding: 16px;
2390
+ cursor: pointer;
2391
+ overflow: hidden;
2392
+ `;
2393
+ // Close button
2394
+ const closeBtn = document.createElement('button');
2395
+ 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>`;
2396
+ closeBtn.style.cssText = `
2397
+ position: absolute;
2398
+ top: 16px;
2399
+ right: 16px;
2400
+ width: 40px;
2401
+ height: 40px;
2402
+ border-radius: 50%;
2403
+ border: none;
2404
+ background: rgba(255, 255, 255, 0.1);
2405
+ color: white;
2406
+ cursor: pointer;
2407
+ display: flex;
2408
+ align-items: center;
2409
+ justify-content: center;
2410
+ transition: background 0.15s;
2411
+ `;
2412
+ closeBtn.onmouseenter = () => { closeBtn.style.background = 'rgba(255, 255, 255, 0.2)'; };
2413
+ closeBtn.onmouseleave = () => { closeBtn.style.background = 'rgba(255, 255, 255, 0.1)'; };
2414
+ // Download button
2415
+ const downloadBtn = document.createElement('a');
2416
+ downloadBtn.href = url;
2417
+ downloadBtn.download = '';
2418
+ downloadBtn.target = '_blank';
2419
+ downloadBtn.rel = 'noopener noreferrer';
2420
+ 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>`;
2421
+ downloadBtn.style.cssText = `
2422
+ position: absolute;
2423
+ top: 16px;
2424
+ right: 64px;
2425
+ width: 40px;
2426
+ height: 40px;
2427
+ border-radius: 50%;
2428
+ border: none;
2429
+ background: rgba(255, 255, 255, 0.1);
2430
+ color: white;
2431
+ cursor: pointer;
2432
+ display: flex;
2433
+ align-items: center;
2434
+ justify-content: center;
2435
+ transition: background 0.15s;
2436
+ text-decoration: none;
2437
+ `;
2438
+ downloadBtn.onmouseenter = () => { downloadBtn.style.background = 'rgba(255, 255, 255, 0.2)'; };
2439
+ downloadBtn.onmouseleave = () => { downloadBtn.style.background = 'rgba(255, 255, 255, 0.1)'; };
2440
+ // Image
2441
+ const img = document.createElement('img');
2442
+ img.src = url;
2443
+ img.alt = 'Full size';
2444
+ img.draggable = false;
2445
+ img.style.cssText = `
2446
+ max-width: 100%;
2447
+ max-height: 100%;
2448
+ object-fit: contain;
2449
+ border-radius: 8px;
2450
+ cursor: zoom-in;
2451
+ transition: transform 0.2s ease;
2452
+ user-select: none;
2453
+ `;
2454
+ // Click to toggle zoom
2455
+ img.addEventListener('click', (e) => {
2456
+ e.stopPropagation();
2457
+ if (scale === 1) {
2458
+ // Zoom in to 2.5x centered on click position
2459
+ const rect = img.getBoundingClientRect();
2460
+ const clickX = e.clientX - rect.left - rect.width / 2;
2461
+ const clickY = e.clientY - rect.top - rect.height / 2;
2462
+ scale = 2.5;
2463
+ translateX = -clickX * 1.5;
2464
+ translateY = -clickY * 1.5;
2465
+ applyTransform();
2466
+ img.style.cursor = 'zoom-out';
2467
+ }
2468
+ else {
2469
+ // Zoom out - reset
2470
+ resetTransform();
2471
+ }
2472
+ });
2473
+ // Mouse wheel zoom
2474
+ overlay.addEventListener('wheel', (e) => {
2475
+ e.preventDefault();
2476
+ const delta = e.deltaY > 0 ? -0.25 : 0.25;
2477
+ const newScale = Math.min(Math.max(scale + delta, 1), 5);
2478
+ if (newScale === 1) {
2479
+ resetTransform();
2480
+ }
2481
+ else {
2482
+ scale = newScale;
2483
+ applyTransform();
2484
+ img.style.cursor = 'zoom-out';
2485
+ }
2486
+ }, { passive: false });
2487
+ // Drag to pan when zoomed
2488
+ img.addEventListener('mousedown', (e) => {
2489
+ if (scale <= 1)
2490
+ return;
2491
+ e.preventDefault();
2492
+ isDragging = true;
2493
+ dragStartX = e.clientX;
2494
+ dragStartY = e.clientY;
2495
+ lastTranslateX = translateX;
2496
+ lastTranslateY = translateY;
2497
+ img.style.cursor = 'grabbing';
2498
+ img.style.transition = 'none';
2499
+ });
2500
+ window.addEventListener('mousemove', (e) => {
2501
+ if (!isDragging)
2502
+ return;
2503
+ translateX = lastTranslateX + (e.clientX - dragStartX);
2504
+ translateY = lastTranslateY + (e.clientY - dragStartY);
2505
+ applyTransform();
2506
+ });
2507
+ window.addEventListener('mouseup', () => {
2508
+ if (!isDragging)
2509
+ return;
2510
+ isDragging = false;
2511
+ img.style.cursor = scale > 1 ? 'zoom-out' : 'zoom-in';
2512
+ img.style.transition = 'transform 0.2s ease';
2513
+ });
2514
+ // Touch: pinch to zoom + drag to pan
2515
+ let lastTouchDist = 0;
2516
+ let lastTouchScale = 1;
2517
+ overlay.addEventListener('touchstart', (e) => {
2518
+ if (e.touches.length === 2) {
2519
+ e.preventDefault();
2520
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
2521
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
2522
+ lastTouchDist = Math.hypot(dx, dy);
2523
+ lastTouchScale = scale;
2524
+ }
2525
+ else if (e.touches.length === 1 && scale > 1) {
2526
+ isDragging = true;
2527
+ dragStartX = e.touches[0].clientX;
2528
+ dragStartY = e.touches[0].clientY;
2529
+ lastTranslateX = translateX;
2530
+ lastTranslateY = translateY;
2531
+ img.style.transition = 'none';
2532
+ }
2533
+ }, { passive: false });
2534
+ overlay.addEventListener('touchmove', (e) => {
2535
+ if (e.touches.length === 2) {
2536
+ e.preventDefault();
2537
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
2538
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
2539
+ const dist = Math.hypot(dx, dy);
2540
+ scale = Math.min(Math.max(lastTouchScale * (dist / lastTouchDist), 1), 5);
2541
+ if (scale === 1) {
2542
+ translateX = 0;
2543
+ translateY = 0;
2544
+ }
2545
+ applyTransform();
2546
+ }
2547
+ else if (e.touches.length === 1 && isDragging) {
2548
+ e.preventDefault();
2549
+ translateX = lastTranslateX + (e.touches[0].clientX - dragStartX);
2550
+ translateY = lastTranslateY + (e.touches[0].clientY - dragStartY);
2551
+ applyTransform();
2552
+ }
2553
+ }, { passive: false });
2554
+ overlay.addEventListener('touchend', (e) => {
2555
+ if (e.touches.length < 2) {
2556
+ lastTouchDist = 0;
2557
+ }
2558
+ if (e.touches.length === 0) {
2559
+ isDragging = false;
2560
+ img.style.transition = 'transform 0.2s ease';
2561
+ }
2562
+ });
2563
+ const close = () => {
2564
+ document.removeEventListener('keydown', handleKeyDown);
2565
+ overlay.remove();
2566
+ };
2567
+ // Only close on backdrop click when not zoomed (prevent accidental close while panning)
2568
+ overlay.addEventListener('click', (e) => {
2569
+ if (e.target === overlay && scale <= 1)
2570
+ close();
2571
+ });
2572
+ downloadBtn.addEventListener('click', (e) => e.stopPropagation());
2573
+ closeBtn.addEventListener('click', (e) => { e.stopPropagation(); close(); });
2574
+ // Close on Escape
2575
+ const handleKeyDown = (e) => {
2576
+ if (e.key === 'Escape')
2577
+ close();
2578
+ };
2579
+ document.addEventListener('keydown', handleKeyDown);
2580
+ overlay.appendChild(closeBtn);
2581
+ overlay.appendChild(downloadBtn);
2582
+ overlay.appendChild(img);
2583
+ document.body.appendChild(overlay);
2334
2584
  }
2335
2585
  /**
2336
2586
  * Initialize the SDK and render widget
@@ -2356,6 +2606,13 @@ class WeldSDK {
2356
2606
  this.logger.info('WeldSDK ready');
2357
2607
  // Call onReady callback
2358
2608
  this.config.onReady?.();
2609
+ // Start tracking page URL changes
2610
+ this.startPageTracking();
2611
+ // Auto-open if widget was previously open (persisted in sessionStorage)
2612
+ if (this.wasOpen()) {
2613
+ this.logger.info('Restoring previously open widget from sessionStorage');
2614
+ this.open();
2615
+ }
2359
2616
  }
2360
2617
  catch (error) {
2361
2618
  this.status = SDKStatus.ERROR;
@@ -2434,6 +2691,58 @@ class WeldSDK {
2434
2691
  isReady() {
2435
2692
  return this.status === SDKStatus.READY;
2436
2693
  }
2694
+ /**
2695
+ * Update callbacks on an existing instance (used by singleton reuse)
2696
+ */
2697
+ updateCallbacks(config) {
2698
+ if (config.onReady !== undefined)
2699
+ this.config.onReady = config.onReady;
2700
+ if (config.onOpen !== undefined)
2701
+ this.config.onOpen = config.onOpen;
2702
+ if (config.onClose !== undefined)
2703
+ this.config.onClose = config.onClose;
2704
+ if (config.onError !== undefined)
2705
+ this.config.onError = config.onError;
2706
+ if (config.onDestroy !== undefined)
2707
+ this.config.onDestroy = config.onDestroy;
2708
+ if (config.onMinimize !== undefined)
2709
+ this.config.onMinimize = config.onMinimize;
2710
+ if (config.onMaximize !== undefined)
2711
+ this.config.onMaximize = config.onMaximize;
2712
+ }
2713
+ /**
2714
+ * Persist open/closed state to sessionStorage
2715
+ */
2716
+ persistOpenState(isOpen) {
2717
+ try {
2718
+ sessionStorage.setItem(openStateKey(this.config.widgetId), isOpen ? 'true' : 'false');
2719
+ }
2720
+ catch {
2721
+ // sessionStorage might not be available
2722
+ }
2723
+ }
2724
+ /**
2725
+ * Clear persisted state from sessionStorage
2726
+ */
2727
+ clearPersistedState() {
2728
+ try {
2729
+ sessionStorage.removeItem(openStateKey(this.config.widgetId));
2730
+ }
2731
+ catch {
2732
+ // sessionStorage might not be available
2733
+ }
2734
+ }
2735
+ /**
2736
+ * Check if widget was previously open (from sessionStorage)
2737
+ */
2738
+ wasOpen() {
2739
+ try {
2740
+ return sessionStorage.getItem(openStateKey(this.config.widgetId)) === 'true';
2741
+ }
2742
+ catch {
2743
+ return false;
2744
+ }
2745
+ }
2437
2746
  /**
2438
2747
  * Open the widget
2439
2748
  */
@@ -2442,8 +2751,6 @@ class WeldSDK {
2442
2751
  console.log('[Weld SDK] Opening widget...');
2443
2752
  this.stateCoordinator.openWidget();
2444
2753
  this.iframeManager.showIframe(IframeType.WIDGET);
2445
- this.iframeManager.showIframe(IframeType.BACKDROP);
2446
- // Keep launcher visible so user can click it to close the widget
2447
2754
  // Send open message to the widget iframe
2448
2755
  const widgetIframe = this.iframeManager.getIframe(IframeType.WIDGET);
2449
2756
  if (widgetIframe?.element?.contentWindow) {
@@ -2454,6 +2761,7 @@ class WeldSDK {
2454
2761
  if (launcherIframe?.element?.contentWindow) {
2455
2762
  launcherIframe.element.contentWindow.postMessage({ type: 'weld:widget-opened' }, '*');
2456
2763
  }
2764
+ this.persistOpenState(true);
2457
2765
  this.config.onOpen?.();
2458
2766
  }
2459
2767
  /**
@@ -2464,8 +2772,6 @@ class WeldSDK {
2464
2772
  console.log('[Weld SDK] Closing widget...');
2465
2773
  this.stateCoordinator.closeWidget();
2466
2774
  this.iframeManager.hideIframe(IframeType.WIDGET);
2467
- this.iframeManager.hideIframe(IframeType.BACKDROP);
2468
- // Launcher stays visible
2469
2775
  // Send close message to the widget iframe
2470
2776
  const widgetIframe = this.iframeManager.getIframe(IframeType.WIDGET);
2471
2777
  if (widgetIframe?.element?.contentWindow) {
@@ -2476,6 +2782,7 @@ class WeldSDK {
2476
2782
  if (launcherIframe?.element?.contentWindow) {
2477
2783
  launcherIframe.element.contentWindow.postMessage({ type: 'weld:widget-closed' }, '*');
2478
2784
  }
2785
+ this.persistOpenState(false);
2479
2786
  this.config.onClose?.();
2480
2787
  }
2481
2788
  /**
@@ -2664,6 +2971,8 @@ class WeldSDK {
2664
2971
  });
2665
2972
  // Broadcast logout to iframes
2666
2973
  this.messageBroker.broadcast('weld:auth:logout', {});
2974
+ // Clear persisted widget state
2975
+ this.clearPersistedState();
2667
2976
  // Clear any stored session data
2668
2977
  try {
2669
2978
  const prefix = 'weld-';
@@ -2805,6 +3114,63 @@ class WeldSDK {
2805
3114
  this.logger.setLevel('warn');
2806
3115
  this.logger.info('Debug mode disabled');
2807
3116
  }
3117
+ /**
3118
+ * Send a page change message to the widget iframe
3119
+ */
3120
+ sendPageChange(url, title) {
3121
+ const widgetIframe = this.iframeManager.getIframe(IframeType.WIDGET);
3122
+ if (widgetIframe?.element?.contentWindow) {
3123
+ widgetIframe.element.contentWindow.postMessage({
3124
+ type: 'weld:page:change',
3125
+ url,
3126
+ title,
3127
+ timestamp: Date.now(),
3128
+ }, '*');
3129
+ }
3130
+ }
3131
+ /**
3132
+ * Start tracking page URL changes (SPA navigations + popstate)
3133
+ */
3134
+ startPageTracking() {
3135
+ let lastUrl = window.location.href;
3136
+ let debounceTimer = null;
3137
+ const notifyChange = () => {
3138
+ const currentUrl = window.location.href;
3139
+ if (currentUrl !== lastUrl) {
3140
+ lastUrl = currentUrl;
3141
+ this.sendPageChange(currentUrl, document.title);
3142
+ }
3143
+ };
3144
+ const debouncedNotify = () => {
3145
+ if (debounceTimer)
3146
+ clearTimeout(debounceTimer);
3147
+ debounceTimer = setTimeout(notifyChange, 300);
3148
+ };
3149
+ // Send initial page
3150
+ this.sendPageChange(window.location.href, document.title);
3151
+ // Monkey-patch history.pushState and history.replaceState
3152
+ const origPushState = history.pushState.bind(history);
3153
+ const origReplaceState = history.replaceState.bind(history);
3154
+ history.pushState = function (...args) {
3155
+ origPushState(...args);
3156
+ debouncedNotify();
3157
+ };
3158
+ history.replaceState = function (...args) {
3159
+ origReplaceState(...args);
3160
+ debouncedNotify();
3161
+ };
3162
+ // Listen for popstate (browser back/forward)
3163
+ const handlePopstate = () => debouncedNotify();
3164
+ window.addEventListener('popstate', handlePopstate);
3165
+ // Store cleanup
3166
+ this.pageTrackingCleanup = () => {
3167
+ if (debounceTimer)
3168
+ clearTimeout(debounceTimer);
3169
+ window.removeEventListener('popstate', handlePopstate);
3170
+ history.pushState = origPushState;
3171
+ history.replaceState = origReplaceState;
3172
+ };
3173
+ }
2808
3174
  /**
2809
3175
  * Ensure SDK is ready before operation
2810
3176
  */
@@ -2813,11 +3179,26 @@ class WeldSDK {
2813
3179
  throw new Error('SDK not ready. Call init() first.');
2814
3180
  }
2815
3181
  }
3182
+ /**
3183
+ * Detach from the current component lifecycle without destroying the widget.
3184
+ * Use this as a React useEffect cleanup — the widget stays alive across navigations.
3185
+ */
3186
+ detach() {
3187
+ // No-op: widget stays alive in the singleton registry
3188
+ this.logger.debug('WeldSDK detached (no-op, widget stays alive)');
3189
+ }
2816
3190
  /**
2817
3191
  * Destroy SDK and cleanup
2818
3192
  */
2819
3193
  destroy() {
2820
3194
  this.logger.info('Destroying WeldSDK');
3195
+ // Remove from singleton registry
3196
+ sdkRegistry.delete(this.config.widgetId);
3197
+ // Clear persisted state
3198
+ this.clearPersistedState();
3199
+ // Stop page tracking
3200
+ this.pageTrackingCleanup?.();
3201
+ this.pageTrackingCleanup = null;
2821
3202
  // Remove event listener using bound handler
2822
3203
  window.removeEventListener('message', this.boundHandleLauncherClick);
2823
3204
  // Unsubscribe from all message broker subscriptions