@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/react.esm.js CHANGED
@@ -33,32 +33,6 @@ const DEFAULT_CONFIG = {
33
33
  closeOnClick: true,
34
34
  },
35
35
  },
36
- customization: {
37
- primaryColor: '#000000',
38
- accentColor: '#3b82f6',
39
- backgroundColor: '#ffffff',
40
- textColor: '#111827',
41
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
42
- fontSize: '14px',
43
- borderRadius: '12px',
44
- },
45
- features: {
46
- attachments: true,
47
- reactions: true,
48
- typing: true,
49
- readReceipts: true,
50
- offlineMode: false,
51
- fileUpload: true,
52
- imageUpload: true,
53
- voiceMessages: false,
54
- videoMessages: false,
55
- },
56
- mobile: {
57
- fullScreen: true,
58
- scrollLock: true,
59
- keyboardHandling: 'auto',
60
- safeAreaInsets: true,
61
- },
62
36
  auth: {
63
37
  enabled: true,
64
38
  mode: 'anonymous',
@@ -102,6 +76,7 @@ function resolveConfig(config) {
102
76
  validateConfig(config);
103
77
  return {
104
78
  widgetId: config.widgetId,
79
+ testMode: config.testMode,
105
80
  api: {
106
81
  ...DEFAULT_CONFIG.api,
107
82
  widgetId: config.widgetId,
@@ -131,18 +106,6 @@ function resolveConfig(config) {
131
106
  ...config.iframes?.backdrop,
132
107
  },
133
108
  },
134
- customization: {
135
- ...DEFAULT_CONFIG.customization,
136
- ...config.customization,
137
- },
138
- features: {
139
- ...DEFAULT_CONFIG.features,
140
- ...config.features,
141
- },
142
- mobile: {
143
- ...DEFAULT_CONFIG.mobile,
144
- ...config.mobile,
145
- },
146
109
  auth: {
147
110
  ...DEFAULT_CONFIG.auth,
148
111
  ...config.auth,
@@ -385,6 +348,8 @@ class IframeManager {
385
348
  this.modalContainer = null;
386
349
  this.styleElement = null;
387
350
  this.messageBroker = null;
351
+ // Guard flag to prevent double-binding event listeners
352
+ this.eventListenersBound = false;
388
353
  this.config = config;
389
354
  this.logger = new Logger(config.logging);
390
355
  this.deviceInfo = detectDevice();
@@ -424,18 +389,27 @@ class IframeManager {
424
389
  }
425
390
  /**
426
391
  * Create root container structure
392
+ * Reuses existing container if it has the same widgetId (singleton behavior)
427
393
  */
428
394
  createRootContainer() {
429
- // Check if already exists
430
- let existingContainer = document.getElementById('weld-container');
395
+ const existingContainer = document.getElementById('weld-container');
431
396
  if (existingContainer) {
432
- this.logger.warn('Weld container already exists, removing old instance');
397
+ // Reuse if same widgetId
398
+ if (existingContainer.getAttribute('data-widget-id') === this.config.widgetId) {
399
+ this.logger.debug('Reusing existing root container');
400
+ this.rootContainer = existingContainer;
401
+ this.appContainer = existingContainer.querySelector('.weld-app');
402
+ this.modalContainer = document.getElementById('weld-modal-container');
403
+ return;
404
+ }
405
+ this.logger.warn('Weld container already exists with different widgetId, removing old instance');
433
406
  existingContainer.remove();
434
407
  }
435
408
  // Create root container
436
409
  this.rootContainer = document.createElement('div');
437
410
  this.rootContainer.id = 'weld-container';
438
411
  this.rootContainer.className = 'weld-namespace';
412
+ this.rootContainer.setAttribute('data-widget-id', this.config.widgetId);
439
413
  // Create app container
440
414
  this.appContainer = document.createElement('div');
441
415
  this.appContainer.className = 'weld-app';
@@ -470,17 +444,7 @@ class IframeManager {
470
444
  * Generate CSS for containers
471
445
  */
472
446
  generateCSS() {
473
- const { customization } = this.config;
474
447
  return `
475
- /* Weld Container */
476
- #weld-container {
477
- --weld-color-primary: ${customization.primaryColor};
478
- --weld-color-accent: ${customization.accentColor};
479
- --weld-font-family: ${customization.fontFamily};
480
- --weld-font-size-base: ${customization.fontSize};
481
- --weld-radius-xl: ${customization.borderRadius};
482
- }
483
-
484
448
  /* Import main stylesheet */
485
449
  @import url('/styles/index.css');
486
450
 
@@ -504,20 +468,27 @@ class IframeManager {
504
468
  * Create launcher iframe
505
469
  */
506
470
  async createLauncherIframe() {
471
+ // Guard: skip if launcher iframe already exists
472
+ if (this.iframes.has(IframeType.LAUNCHER)) {
473
+ this.logger.debug('Launcher iframe already exists, skipping creation');
474
+ return;
475
+ }
507
476
  const { iframes } = this.config;
508
477
  const { launcher } = iframes;
509
478
  // Create container
510
479
  const container = document.createElement('div');
511
480
  container.className = 'weld-launcher-frame';
512
481
  container.setAttribute('data-state', 'visible');
482
+ // Container is larger than the button to allow hover animations (scale, shadow) without clipping
483
+ const launcherPadding = 10;
513
484
  container.style.cssText = `
514
485
  position: fixed;
515
- bottom: ${launcher.position.bottom};
516
- right: ${launcher.position.right};
517
- width: ${launcher.size};
518
- height: ${launcher.size};
486
+ bottom: calc(${launcher.position.bottom} - ${launcherPadding}px);
487
+ right: calc(${launcher.position.right} - ${launcherPadding}px);
488
+ width: calc(${launcher.size} + ${launcherPadding * 2}px);
489
+ height: calc(${launcher.size} + ${launcherPadding * 2}px);
519
490
  z-index: 2147483003;
520
- pointer-events: auto;
491
+ pointer-events: none;
521
492
  display: block;
522
493
  `;
523
494
  // Create iframe
@@ -529,8 +500,12 @@ class IframeManager {
529
500
  width: 100%;
530
501
  height: 100%;
531
502
  border: none;
532
- background: transparent;
503
+ background: none;
504
+ color-scheme: none;
533
505
  display: block;
506
+ pointer-events: auto;
507
+ border-radius: 50%;
508
+ filter: drop-shadow(rgba(9, 14, 21, 0.54) 0px 1px 6px) drop-shadow(rgba(9, 14, 21, 0.9) 0px 2px 32px);
534
509
  `;
535
510
  iframe.setAttribute('allow', 'clipboard-write');
536
511
  iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups');
@@ -546,20 +521,36 @@ class IframeManager {
546
521
  createdAt: Date.now(),
547
522
  });
548
523
  // When DOM loads, notify MessageBroker to send weld:init
524
+ let launcherRetried = false;
549
525
  iframe.onload = () => {
550
526
  const metadata = this.iframes.get(IframeType.LAUNCHER);
551
527
  if (metadata) {
552
528
  this.logger.debug('Launcher iframe DOM loaded');
553
- // Notify MessageBroker that DOM is loaded (triggers weld:init)
554
529
  this.messageBroker?.setIframeDomLoaded(IframeType.LAUNCHER);
555
530
  }
556
531
  };
532
+ iframe.onerror = () => {
533
+ this.logger.error('Launcher iframe failed to load');
534
+ if (!launcherRetried) {
535
+ launcherRetried = true;
536
+ this.logger.info('Retrying launcher iframe load...');
537
+ setTimeout(() => { iframe.src = this.buildIframeUrl(launcher.url); }, 3000);
538
+ }
539
+ else {
540
+ this.config.onError?.(new Error('Failed to load widget launcher'));
541
+ }
542
+ };
557
543
  this.logger.debug('Launcher iframe created');
558
544
  }
559
545
  /**
560
546
  * Create widget iframe
561
547
  */
562
548
  async createWidgetIframe() {
549
+ // Guard: skip if widget iframe already exists
550
+ if (this.iframes.has(IframeType.WIDGET)) {
551
+ this.logger.debug('Widget iframe already exists, skipping creation');
552
+ return;
553
+ }
563
554
  const { iframes } = this.config;
564
555
  const { widget } = iframes;
565
556
  // Create container
@@ -640,54 +631,32 @@ class IframeManager {
640
631
  createdAt: Date.now(),
641
632
  });
642
633
  // When DOM loads, notify MessageBroker to send weld:init
634
+ let widgetRetried = false;
643
635
  iframe.onload = () => {
644
636
  const metadata = this.iframes.get(IframeType.WIDGET);
645
637
  if (metadata) {
646
638
  this.logger.debug('Widget iframe DOM loaded');
647
- // Notify MessageBroker that DOM is loaded (triggers weld:init)
648
639
  this.messageBroker?.setIframeDomLoaded(IframeType.WIDGET);
649
640
  }
650
641
  };
642
+ iframe.onerror = () => {
643
+ this.logger.error('Widget iframe failed to load');
644
+ if (!widgetRetried) {
645
+ widgetRetried = true;
646
+ this.logger.info('Retrying widget iframe load...');
647
+ setTimeout(() => { iframe.src = this.buildIframeUrl(widget.url); }, 3000);
648
+ }
649
+ else {
650
+ this.config.onError?.(new Error('Failed to load widget'));
651
+ }
652
+ };
651
653
  this.logger.debug('Widget iframe created');
652
654
  }
653
655
  /**
654
- * Create backdrop iframe
656
+ * Create backdrop iframe — disabled, widget stays non-modal so users can interact with the page
655
657
  */
656
658
  async createBackdropIframe() {
657
- if (!this.config.iframes.backdrop?.enabled) {
658
- this.logger.debug('Backdrop disabled, skipping creation');
659
- return;
660
- }
661
- // Create container
662
- const container = document.createElement('div');
663
- container.className = 'weld-backdrop-frame';
664
- container.setAttribute('data-state', 'hidden');
665
- container.style.cssText = `
666
- position: fixed;
667
- top: 0;
668
- left: 0;
669
- right: 0;
670
- bottom: 0;
671
- z-index: 2147483000;
672
- background: transparent;
673
- pointer-events: none;
674
- opacity: 0;
675
- transition: opacity 200ms ease;
676
- `;
677
- this.appContainer?.appendChild(container);
678
- // Store metadata (backdrop doesn't have an iframe, just a div)
679
- // We'll create a minimal "iframe" reference for consistency
680
- const dummyIframe = document.createElement('iframe');
681
- dummyIframe.style.display = 'none';
682
- this.iframes.set(IframeType.BACKDROP, {
683
- type: IframeType.BACKDROP,
684
- element: dummyIframe,
685
- container,
686
- ready: true, // Backdrop is always ready
687
- visible: false,
688
- createdAt: Date.now(),
689
- });
690
- this.logger.debug('Backdrop created');
659
+ this.logger.debug('Backdrop disabled, skipping creation');
691
660
  }
692
661
  /**
693
662
  * Build iframe URL with parameters
@@ -701,12 +670,21 @@ class IframeManager {
701
670
  url.searchParams.set('device', this.deviceInfo.type);
702
671
  url.searchParams.set('mobile', String(this.deviceInfo.isMobile));
703
672
  url.searchParams.set('parentOrigin', window.location.origin);
673
+ if (this.config.testMode) {
674
+ url.searchParams.set('testMode', 'true');
675
+ }
704
676
  return url.toString();
705
677
  }
706
678
  /**
707
679
  * Setup event listeners
708
680
  */
709
681
  setupEventListeners() {
682
+ // Guard: prevent double-binding
683
+ if (this.eventListenersBound) {
684
+ this.logger.debug('Event listeners already bound, skipping');
685
+ return;
686
+ }
687
+ this.eventListenersBound = true;
710
688
  // Window resize - use bound handler for proper cleanup
711
689
  window.addEventListener('resize', this.boundHandleResize);
712
690
  // Orientation change - use bound handler for proper cleanup
@@ -809,7 +787,7 @@ class IframeManager {
809
787
  iframe.container.style.transform = 'scale(1) translateY(0)';
810
788
  }
811
789
  // Handle mobile scroll lock
812
- if (this.deviceInfo.isMobile && type === IframeType.WIDGET && this.config.mobile.scrollLock) {
790
+ if (this.deviceInfo.isMobile && type === IframeType.WIDGET) {
813
791
  document.body.classList.add('weld-mobile-open');
814
792
  }
815
793
  // Hide launcher on mobile when widget is open (full-screen mode)
@@ -908,6 +886,8 @@ class IframeManager {
908
886
  this.iframes.clear();
909
887
  // Clear messageBroker reference
910
888
  this.messageBroker = null;
889
+ // Reset guard flag
890
+ this.eventListenersBound = false;
911
891
  this.logger.info('IframeManager destroyed');
912
892
  }
913
893
  }
@@ -975,6 +955,8 @@ var MessageType;
975
955
  // Events
976
956
  MessageType["EVENT_TRACK"] = "weld:event:track";
977
957
  MessageType["ERROR_REPORT"] = "weld:error:report";
958
+ // Page tracking
959
+ MessageType["PAGE_CHANGE"] = "weld:page:change";
978
960
  // API responses
979
961
  MessageType["API_SUCCESS"] = "weld:api:success";
980
962
  MessageType["API_ERROR"] = "weld:api:error";
@@ -1514,8 +1496,6 @@ class MessageBroker {
1514
1496
  iframeType,
1515
1497
  config: {
1516
1498
  api: this.config.api,
1517
- customization: this.config.customization,
1518
- features: this.config.features,
1519
1499
  },
1520
1500
  };
1521
1501
  const message = createMessage('weld:init', MessageOrigin.PARENT, initPayload);
@@ -2188,7 +2168,7 @@ class StateCoordinator {
2188
2168
  }
2189
2169
  }
2190
2170
 
2191
- var version = "1.0.15";
2171
+ var version = "1.0.17";
2192
2172
  var packageJson = {
2193
2173
  version: version};
2194
2174
 
@@ -2196,6 +2176,16 @@ var packageJson = {
2196
2176
  * Weld SDK - Main Entry Point
2197
2177
  * Public API for the Weld helpdesk widget
2198
2178
  */
2179
+ /**
2180
+ * Module-level singleton registry keyed by widgetId
2181
+ */
2182
+ const sdkRegistry = new Map();
2183
+ /**
2184
+ * SessionStorage key helpers
2185
+ */
2186
+ function openStateKey(widgetId) {
2187
+ return `weld-widget-open-${widgetId}`;
2188
+ }
2199
2189
  /**
2200
2190
  * SDK initialization status
2201
2191
  */
@@ -2218,6 +2208,8 @@ class WeldSDK {
2218
2208
  this.readyResolve = null;
2219
2209
  // Subscription IDs for cleanup
2220
2210
  this.subscriptionIds = [];
2211
+ // Page tracking cleanup
2212
+ this.pageTrackingCleanup = null;
2221
2213
  /**
2222
2214
  * Update user attributes (Intercom-style, with rate limiting)
2223
2215
  * Limited to 20 calls per page load to prevent abuse
@@ -2256,6 +2248,13 @@ class WeldSDK {
2256
2248
  console.log('[Weld SDK] Received message:', event.data.type);
2257
2249
  }
2258
2250
  if (event.data?.type === 'launcher:clicked') {
2251
+ if (this.status !== SDKStatus.READY) {
2252
+ console.log('[Weld SDK] Launcher clicked but SDK not ready yet — waiting...');
2253
+ this.readyPromise?.then(() => {
2254
+ this.handleLauncherClickMessage(event);
2255
+ });
2256
+ return;
2257
+ }
2259
2258
  // Toggle behavior - if widget is open, close it; if closed, open it
2260
2259
  const state = this.stateCoordinator.getState();
2261
2260
  if (state.widget.isOpen) {
@@ -2268,9 +2267,260 @@ class WeldSDK {
2268
2267
  }
2269
2268
  }
2270
2269
  if (event.data?.type === 'weld:close') {
2270
+ if (this.status !== SDKStatus.READY)
2271
+ return;
2271
2272
  console.log('[Weld SDK] Widget close requested');
2272
2273
  this.close();
2273
2274
  }
2275
+ if (event.data?.type === 'weld:unread-count') {
2276
+ const count = event.data.count ?? 0;
2277
+ // Forward to launcher iframe
2278
+ const launcherIframe = this.iframeManager.getIframe(IframeType.LAUNCHER);
2279
+ if (launcherIframe?.element?.contentWindow) {
2280
+ launcherIframe.element.contentWindow.postMessage({
2281
+ type: 'weld:unread-count',
2282
+ count
2283
+ }, '*');
2284
+ }
2285
+ // Update state coordinator for external API consumers
2286
+ this.stateCoordinator.setBadgeCount(count);
2287
+ }
2288
+ if (event.data?.type === 'weld:image:open' && event.data?.url) {
2289
+ this.showImageLightbox(event.data.url);
2290
+ }
2291
+ }
2292
+ /**
2293
+ * Show fullscreen image lightbox on the parent page
2294
+ */
2295
+ showImageLightbox(url) {
2296
+ // Remove existing lightbox if any
2297
+ const existing = document.getElementById('weld-image-lightbox');
2298
+ if (existing)
2299
+ existing.remove();
2300
+ // Zoom / pan state
2301
+ let scale = 1;
2302
+ let translateX = 0;
2303
+ let translateY = 0;
2304
+ let isDragging = false;
2305
+ let dragStartX = 0;
2306
+ let dragStartY = 0;
2307
+ let lastTranslateX = 0;
2308
+ let lastTranslateY = 0;
2309
+ const applyTransform = () => {
2310
+ img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
2311
+ };
2312
+ const resetTransform = () => {
2313
+ scale = 1;
2314
+ translateX = 0;
2315
+ translateY = 0;
2316
+ applyTransform();
2317
+ img.style.cursor = 'zoom-in';
2318
+ };
2319
+ const overlay = document.createElement('div');
2320
+ overlay.id = 'weld-image-lightbox';
2321
+ overlay.style.cssText = `
2322
+ position: fixed;
2323
+ inset: 0;
2324
+ z-index: 2147483647;
2325
+ background: rgba(0, 0, 0, 0.92);
2326
+ display: flex;
2327
+ align-items: center;
2328
+ justify-content: center;
2329
+ padding: 16px;
2330
+ cursor: pointer;
2331
+ overflow: hidden;
2332
+ `;
2333
+ // Close button
2334
+ const closeBtn = document.createElement('button');
2335
+ 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>`;
2336
+ closeBtn.style.cssText = `
2337
+ position: absolute;
2338
+ top: 16px;
2339
+ right: 16px;
2340
+ width: 40px;
2341
+ height: 40px;
2342
+ border-radius: 50%;
2343
+ border: none;
2344
+ background: rgba(255, 255, 255, 0.1);
2345
+ color: white;
2346
+ cursor: pointer;
2347
+ display: flex;
2348
+ align-items: center;
2349
+ justify-content: center;
2350
+ transition: background 0.15s;
2351
+ `;
2352
+ closeBtn.onmouseenter = () => { closeBtn.style.background = 'rgba(255, 255, 255, 0.2)'; };
2353
+ closeBtn.onmouseleave = () => { closeBtn.style.background = 'rgba(255, 255, 255, 0.1)'; };
2354
+ // Download button
2355
+ const downloadBtn = document.createElement('a');
2356
+ downloadBtn.href = url;
2357
+ downloadBtn.download = '';
2358
+ downloadBtn.target = '_blank';
2359
+ downloadBtn.rel = 'noopener noreferrer';
2360
+ 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>`;
2361
+ downloadBtn.style.cssText = `
2362
+ position: absolute;
2363
+ top: 16px;
2364
+ right: 64px;
2365
+ width: 40px;
2366
+ height: 40px;
2367
+ border-radius: 50%;
2368
+ border: none;
2369
+ background: rgba(255, 255, 255, 0.1);
2370
+ color: white;
2371
+ cursor: pointer;
2372
+ display: flex;
2373
+ align-items: center;
2374
+ justify-content: center;
2375
+ transition: background 0.15s;
2376
+ text-decoration: none;
2377
+ `;
2378
+ downloadBtn.onmouseenter = () => { downloadBtn.style.background = 'rgba(255, 255, 255, 0.2)'; };
2379
+ downloadBtn.onmouseleave = () => { downloadBtn.style.background = 'rgba(255, 255, 255, 0.1)'; };
2380
+ // Image
2381
+ const img = document.createElement('img');
2382
+ img.src = url;
2383
+ img.alt = 'Full size';
2384
+ img.draggable = false;
2385
+ img.style.cssText = `
2386
+ max-width: 100%;
2387
+ max-height: 100%;
2388
+ object-fit: contain;
2389
+ border-radius: 8px;
2390
+ cursor: zoom-in;
2391
+ transition: transform 0.2s ease;
2392
+ user-select: none;
2393
+ `;
2394
+ // Click to toggle zoom
2395
+ img.addEventListener('click', (e) => {
2396
+ e.stopPropagation();
2397
+ if (scale === 1) {
2398
+ // Zoom in to 2.5x centered on click position
2399
+ const rect = img.getBoundingClientRect();
2400
+ const clickX = e.clientX - rect.left - rect.width / 2;
2401
+ const clickY = e.clientY - rect.top - rect.height / 2;
2402
+ scale = 2.5;
2403
+ translateX = -clickX * 1.5;
2404
+ translateY = -clickY * 1.5;
2405
+ applyTransform();
2406
+ img.style.cursor = 'zoom-out';
2407
+ }
2408
+ else {
2409
+ // Zoom out - reset
2410
+ resetTransform();
2411
+ }
2412
+ });
2413
+ // Mouse wheel zoom
2414
+ overlay.addEventListener('wheel', (e) => {
2415
+ e.preventDefault();
2416
+ const delta = e.deltaY > 0 ? -0.25 : 0.25;
2417
+ const newScale = Math.min(Math.max(scale + delta, 1), 5);
2418
+ if (newScale === 1) {
2419
+ resetTransform();
2420
+ }
2421
+ else {
2422
+ scale = newScale;
2423
+ applyTransform();
2424
+ img.style.cursor = 'zoom-out';
2425
+ }
2426
+ }, { passive: false });
2427
+ // Drag to pan when zoomed
2428
+ img.addEventListener('mousedown', (e) => {
2429
+ if (scale <= 1)
2430
+ return;
2431
+ e.preventDefault();
2432
+ isDragging = true;
2433
+ dragStartX = e.clientX;
2434
+ dragStartY = e.clientY;
2435
+ lastTranslateX = translateX;
2436
+ lastTranslateY = translateY;
2437
+ img.style.cursor = 'grabbing';
2438
+ img.style.transition = 'none';
2439
+ });
2440
+ window.addEventListener('mousemove', (e) => {
2441
+ if (!isDragging)
2442
+ return;
2443
+ translateX = lastTranslateX + (e.clientX - dragStartX);
2444
+ translateY = lastTranslateY + (e.clientY - dragStartY);
2445
+ applyTransform();
2446
+ });
2447
+ window.addEventListener('mouseup', () => {
2448
+ if (!isDragging)
2449
+ return;
2450
+ isDragging = false;
2451
+ img.style.cursor = scale > 1 ? 'zoom-out' : 'zoom-in';
2452
+ img.style.transition = 'transform 0.2s ease';
2453
+ });
2454
+ // Touch: pinch to zoom + drag to pan
2455
+ let lastTouchDist = 0;
2456
+ let lastTouchScale = 1;
2457
+ overlay.addEventListener('touchstart', (e) => {
2458
+ if (e.touches.length === 2) {
2459
+ e.preventDefault();
2460
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
2461
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
2462
+ lastTouchDist = Math.hypot(dx, dy);
2463
+ lastTouchScale = scale;
2464
+ }
2465
+ else if (e.touches.length === 1 && scale > 1) {
2466
+ isDragging = true;
2467
+ dragStartX = e.touches[0].clientX;
2468
+ dragStartY = e.touches[0].clientY;
2469
+ lastTranslateX = translateX;
2470
+ lastTranslateY = translateY;
2471
+ img.style.transition = 'none';
2472
+ }
2473
+ }, { passive: false });
2474
+ overlay.addEventListener('touchmove', (e) => {
2475
+ if (e.touches.length === 2) {
2476
+ e.preventDefault();
2477
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
2478
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
2479
+ const dist = Math.hypot(dx, dy);
2480
+ scale = Math.min(Math.max(lastTouchScale * (dist / lastTouchDist), 1), 5);
2481
+ if (scale === 1) {
2482
+ translateX = 0;
2483
+ translateY = 0;
2484
+ }
2485
+ applyTransform();
2486
+ }
2487
+ else if (e.touches.length === 1 && isDragging) {
2488
+ e.preventDefault();
2489
+ translateX = lastTranslateX + (e.touches[0].clientX - dragStartX);
2490
+ translateY = lastTranslateY + (e.touches[0].clientY - dragStartY);
2491
+ applyTransform();
2492
+ }
2493
+ }, { passive: false });
2494
+ overlay.addEventListener('touchend', (e) => {
2495
+ if (e.touches.length < 2) {
2496
+ lastTouchDist = 0;
2497
+ }
2498
+ if (e.touches.length === 0) {
2499
+ isDragging = false;
2500
+ img.style.transition = 'transform 0.2s ease';
2501
+ }
2502
+ });
2503
+ const close = () => {
2504
+ document.removeEventListener('keydown', handleKeyDown);
2505
+ overlay.remove();
2506
+ };
2507
+ // Only close on backdrop click when not zoomed (prevent accidental close while panning)
2508
+ overlay.addEventListener('click', (e) => {
2509
+ if (e.target === overlay && scale <= 1)
2510
+ close();
2511
+ });
2512
+ downloadBtn.addEventListener('click', (e) => e.stopPropagation());
2513
+ closeBtn.addEventListener('click', (e) => { e.stopPropagation(); close(); });
2514
+ // Close on Escape
2515
+ const handleKeyDown = (e) => {
2516
+ if (e.key === 'Escape')
2517
+ close();
2518
+ };
2519
+ document.addEventListener('keydown', handleKeyDown);
2520
+ overlay.appendChild(closeBtn);
2521
+ overlay.appendChild(downloadBtn);
2522
+ overlay.appendChild(img);
2523
+ document.body.appendChild(overlay);
2274
2524
  }
2275
2525
  /**
2276
2526
  * Initialize the SDK and render widget
@@ -2296,6 +2546,13 @@ class WeldSDK {
2296
2546
  this.logger.info('WeldSDK ready');
2297
2547
  // Call onReady callback
2298
2548
  this.config.onReady?.();
2549
+ // Start tracking page URL changes
2550
+ this.startPageTracking();
2551
+ // Auto-open if widget was previously open (persisted in sessionStorage)
2552
+ if (this.wasOpen()) {
2553
+ this.logger.info('Restoring previously open widget from sessionStorage');
2554
+ this.open();
2555
+ }
2299
2556
  }
2300
2557
  catch (error) {
2301
2558
  this.status = SDKStatus.ERROR;
@@ -2374,6 +2631,58 @@ class WeldSDK {
2374
2631
  isReady() {
2375
2632
  return this.status === SDKStatus.READY;
2376
2633
  }
2634
+ /**
2635
+ * Update callbacks on an existing instance (used by singleton reuse)
2636
+ */
2637
+ updateCallbacks(config) {
2638
+ if (config.onReady !== undefined)
2639
+ this.config.onReady = config.onReady;
2640
+ if (config.onOpen !== undefined)
2641
+ this.config.onOpen = config.onOpen;
2642
+ if (config.onClose !== undefined)
2643
+ this.config.onClose = config.onClose;
2644
+ if (config.onError !== undefined)
2645
+ this.config.onError = config.onError;
2646
+ if (config.onDestroy !== undefined)
2647
+ this.config.onDestroy = config.onDestroy;
2648
+ if (config.onMinimize !== undefined)
2649
+ this.config.onMinimize = config.onMinimize;
2650
+ if (config.onMaximize !== undefined)
2651
+ this.config.onMaximize = config.onMaximize;
2652
+ }
2653
+ /**
2654
+ * Persist open/closed state to sessionStorage
2655
+ */
2656
+ persistOpenState(isOpen) {
2657
+ try {
2658
+ sessionStorage.setItem(openStateKey(this.config.widgetId), isOpen ? 'true' : 'false');
2659
+ }
2660
+ catch {
2661
+ // sessionStorage might not be available
2662
+ }
2663
+ }
2664
+ /**
2665
+ * Clear persisted state from sessionStorage
2666
+ */
2667
+ clearPersistedState() {
2668
+ try {
2669
+ sessionStorage.removeItem(openStateKey(this.config.widgetId));
2670
+ }
2671
+ catch {
2672
+ // sessionStorage might not be available
2673
+ }
2674
+ }
2675
+ /**
2676
+ * Check if widget was previously open (from sessionStorage)
2677
+ */
2678
+ wasOpen() {
2679
+ try {
2680
+ return sessionStorage.getItem(openStateKey(this.config.widgetId)) === 'true';
2681
+ }
2682
+ catch {
2683
+ return false;
2684
+ }
2685
+ }
2377
2686
  /**
2378
2687
  * Open the widget
2379
2688
  */
@@ -2382,8 +2691,6 @@ class WeldSDK {
2382
2691
  console.log('[Weld SDK] Opening widget...');
2383
2692
  this.stateCoordinator.openWidget();
2384
2693
  this.iframeManager.showIframe(IframeType.WIDGET);
2385
- this.iframeManager.showIframe(IframeType.BACKDROP);
2386
- // Keep launcher visible so user can click it to close the widget
2387
2694
  // Send open message to the widget iframe
2388
2695
  const widgetIframe = this.iframeManager.getIframe(IframeType.WIDGET);
2389
2696
  if (widgetIframe?.element?.contentWindow) {
@@ -2394,6 +2701,7 @@ class WeldSDK {
2394
2701
  if (launcherIframe?.element?.contentWindow) {
2395
2702
  launcherIframe.element.contentWindow.postMessage({ type: 'weld:widget-opened' }, '*');
2396
2703
  }
2704
+ this.persistOpenState(true);
2397
2705
  this.config.onOpen?.();
2398
2706
  }
2399
2707
  /**
@@ -2404,8 +2712,6 @@ class WeldSDK {
2404
2712
  console.log('[Weld SDK] Closing widget...');
2405
2713
  this.stateCoordinator.closeWidget();
2406
2714
  this.iframeManager.hideIframe(IframeType.WIDGET);
2407
- this.iframeManager.hideIframe(IframeType.BACKDROP);
2408
- // Launcher stays visible
2409
2715
  // Send close message to the widget iframe
2410
2716
  const widgetIframe = this.iframeManager.getIframe(IframeType.WIDGET);
2411
2717
  if (widgetIframe?.element?.contentWindow) {
@@ -2416,6 +2722,7 @@ class WeldSDK {
2416
2722
  if (launcherIframe?.element?.contentWindow) {
2417
2723
  launcherIframe.element.contentWindow.postMessage({ type: 'weld:widget-closed' }, '*');
2418
2724
  }
2725
+ this.persistOpenState(false);
2419
2726
  this.config.onClose?.();
2420
2727
  }
2421
2728
  /**
@@ -2604,6 +2911,8 @@ class WeldSDK {
2604
2911
  });
2605
2912
  // Broadcast logout to iframes
2606
2913
  this.messageBroker.broadcast('weld:auth:logout', {});
2914
+ // Clear persisted widget state
2915
+ this.clearPersistedState();
2607
2916
  // Clear any stored session data
2608
2917
  try {
2609
2918
  const prefix = 'weld-';
@@ -2745,6 +3054,63 @@ class WeldSDK {
2745
3054
  this.logger.setLevel('warn');
2746
3055
  this.logger.info('Debug mode disabled');
2747
3056
  }
3057
+ /**
3058
+ * Send a page change message to the widget iframe
3059
+ */
3060
+ sendPageChange(url, title) {
3061
+ const widgetIframe = this.iframeManager.getIframe(IframeType.WIDGET);
3062
+ if (widgetIframe?.element?.contentWindow) {
3063
+ widgetIframe.element.contentWindow.postMessage({
3064
+ type: 'weld:page:change',
3065
+ url,
3066
+ title,
3067
+ timestamp: Date.now(),
3068
+ }, '*');
3069
+ }
3070
+ }
3071
+ /**
3072
+ * Start tracking page URL changes (SPA navigations + popstate)
3073
+ */
3074
+ startPageTracking() {
3075
+ let lastUrl = window.location.href;
3076
+ let debounceTimer = null;
3077
+ const notifyChange = () => {
3078
+ const currentUrl = window.location.href;
3079
+ if (currentUrl !== lastUrl) {
3080
+ lastUrl = currentUrl;
3081
+ this.sendPageChange(currentUrl, document.title);
3082
+ }
3083
+ };
3084
+ const debouncedNotify = () => {
3085
+ if (debounceTimer)
3086
+ clearTimeout(debounceTimer);
3087
+ debounceTimer = setTimeout(notifyChange, 300);
3088
+ };
3089
+ // Send initial page
3090
+ this.sendPageChange(window.location.href, document.title);
3091
+ // Monkey-patch history.pushState and history.replaceState
3092
+ const origPushState = history.pushState.bind(history);
3093
+ const origReplaceState = history.replaceState.bind(history);
3094
+ history.pushState = function (...args) {
3095
+ origPushState(...args);
3096
+ debouncedNotify();
3097
+ };
3098
+ history.replaceState = function (...args) {
3099
+ origReplaceState(...args);
3100
+ debouncedNotify();
3101
+ };
3102
+ // Listen for popstate (browser back/forward)
3103
+ const handlePopstate = () => debouncedNotify();
3104
+ window.addEventListener('popstate', handlePopstate);
3105
+ // Store cleanup
3106
+ this.pageTrackingCleanup = () => {
3107
+ if (debounceTimer)
3108
+ clearTimeout(debounceTimer);
3109
+ window.removeEventListener('popstate', handlePopstate);
3110
+ history.pushState = origPushState;
3111
+ history.replaceState = origReplaceState;
3112
+ };
3113
+ }
2748
3114
  /**
2749
3115
  * Ensure SDK is ready before operation
2750
3116
  */
@@ -2753,11 +3119,26 @@ class WeldSDK {
2753
3119
  throw new Error('SDK not ready. Call init() first.');
2754
3120
  }
2755
3121
  }
3122
+ /**
3123
+ * Detach from the current component lifecycle without destroying the widget.
3124
+ * Use this as a React useEffect cleanup — the widget stays alive across navigations.
3125
+ */
3126
+ detach() {
3127
+ // No-op: widget stays alive in the singleton registry
3128
+ this.logger.debug('WeldSDK detached (no-op, widget stays alive)');
3129
+ }
2756
3130
  /**
2757
3131
  * Destroy SDK and cleanup
2758
3132
  */
2759
3133
  destroy() {
2760
3134
  this.logger.info('Destroying WeldSDK');
3135
+ // Remove from singleton registry
3136
+ sdkRegistry.delete(this.config.widgetId);
3137
+ // Clear persisted state
3138
+ this.clearPersistedState();
3139
+ // Stop page tracking
3140
+ this.pageTrackingCleanup?.();
3141
+ this.pageTrackingCleanup = null;
2761
3142
  // Remove event listener using bound handler
2762
3143
  window.removeEventListener('message', this.boundHandleLauncherClick);
2763
3144
  // Unsubscribe from all message broker subscriptions