@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.js CHANGED
@@ -37,32 +37,6 @@ const DEFAULT_CONFIG = {
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 @@ function resolveConfig(config) {
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 @@ function resolveConfig(config) {
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 @@ class IframeManager {
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 @@ class IframeManager {
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 @@ class IframeManager {
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 @@ class IframeManager {
508
472
  * Create launcher iframe
509
473
  */
510
474
  async createLauncherIframe() {
475
+ // Guard: skip if launcher iframe already exists
476
+ if (this.iframes.has(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 @@ class IframeManager {
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 @@ class IframeManager {
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(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(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(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 @@ class IframeManager {
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(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(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(IframeType.BACKDROP, {
687
- type: 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 @@ class IframeManager {
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 @@ class IframeManager {
813
791
  iframe.container.style.transform = 'scale(1) translateY(0)';
814
792
  }
815
793
  // Handle mobile scroll lock
816
- if (this.deviceInfo.isMobile && type === IframeType.WIDGET && this.config.mobile.scrollLock) {
794
+ if (this.deviceInfo.isMobile && type === 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 @@ class IframeManager {
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 @@ var MessageType;
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";
@@ -1518,8 +1500,6 @@ class MessageBroker {
1518
1500
  iframeType,
1519
1501
  config: {
1520
1502
  api: this.config.api,
1521
- customization: this.config.customization,
1522
- features: this.config.features,
1523
1503
  },
1524
1504
  };
1525
1505
  const message = createMessage('weld:init', MessageOrigin.PARENT, initPayload);
@@ -2192,7 +2172,7 @@ class StateCoordinator {
2192
2172
  }
2193
2173
  }
2194
2174
 
2195
- var version = "1.0.15";
2175
+ var version = "1.0.17";
2196
2176
  var packageJson = {
2197
2177
  version: version};
2198
2178
 
@@ -2200,6 +2180,16 @@ var packageJson = {
2200
2180
  * Weld SDK - Main Entry Point
2201
2181
  * Public API for the Weld helpdesk widget
2202
2182
  */
2183
+ /**
2184
+ * Module-level singleton registry keyed by widgetId
2185
+ */
2186
+ const sdkRegistry = new Map();
2187
+ /**
2188
+ * SessionStorage key helpers
2189
+ */
2190
+ function openStateKey(widgetId) {
2191
+ return `weld-widget-open-${widgetId}`;
2192
+ }
2203
2193
  /**
2204
2194
  * SDK initialization status
2205
2195
  */
@@ -2222,6 +2212,8 @@ class WeldSDK {
2222
2212
  this.readyResolve = null;
2223
2213
  // Subscription IDs for cleanup
2224
2214
  this.subscriptionIds = [];
2215
+ // Page tracking cleanup
2216
+ this.pageTrackingCleanup = null;
2225
2217
  /**
2226
2218
  * Update user attributes (Intercom-style, with rate limiting)
2227
2219
  * Limited to 20 calls per page load to prevent abuse
@@ -2260,6 +2252,13 @@ class WeldSDK {
2260
2252
  console.log('[Weld SDK] Received message:', event.data.type);
2261
2253
  }
2262
2254
  if (event.data?.type === 'launcher:clicked') {
2255
+ if (this.status !== SDKStatus.READY) {
2256
+ console.log('[Weld SDK] Launcher clicked but SDK not ready yet — waiting...');
2257
+ this.readyPromise?.then(() => {
2258
+ this.handleLauncherClickMessage(event);
2259
+ });
2260
+ return;
2261
+ }
2263
2262
  // Toggle behavior - if widget is open, close it; if closed, open it
2264
2263
  const state = this.stateCoordinator.getState();
2265
2264
  if (state.widget.isOpen) {
@@ -2272,9 +2271,260 @@ class WeldSDK {
2272
2271
  }
2273
2272
  }
2274
2273
  if (event.data?.type === 'weld:close') {
2274
+ if (this.status !== SDKStatus.READY)
2275
+ return;
2275
2276
  console.log('[Weld SDK] Widget close requested');
2276
2277
  this.close();
2277
2278
  }
2279
+ if (event.data?.type === 'weld:unread-count') {
2280
+ const count = event.data.count ?? 0;
2281
+ // Forward to launcher iframe
2282
+ const launcherIframe = this.iframeManager.getIframe(IframeType.LAUNCHER);
2283
+ if (launcherIframe?.element?.contentWindow) {
2284
+ launcherIframe.element.contentWindow.postMessage({
2285
+ type: 'weld:unread-count',
2286
+ count
2287
+ }, '*');
2288
+ }
2289
+ // Update state coordinator for external API consumers
2290
+ this.stateCoordinator.setBadgeCount(count);
2291
+ }
2292
+ if (event.data?.type === 'weld:image:open' && event.data?.url) {
2293
+ this.showImageLightbox(event.data.url);
2294
+ }
2295
+ }
2296
+ /**
2297
+ * Show fullscreen image lightbox on the parent page
2298
+ */
2299
+ showImageLightbox(url) {
2300
+ // Remove existing lightbox if any
2301
+ const existing = document.getElementById('weld-image-lightbox');
2302
+ if (existing)
2303
+ existing.remove();
2304
+ // Zoom / pan state
2305
+ let scale = 1;
2306
+ let translateX = 0;
2307
+ let translateY = 0;
2308
+ let isDragging = false;
2309
+ let dragStartX = 0;
2310
+ let dragStartY = 0;
2311
+ let lastTranslateX = 0;
2312
+ let lastTranslateY = 0;
2313
+ const applyTransform = () => {
2314
+ img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
2315
+ };
2316
+ const resetTransform = () => {
2317
+ scale = 1;
2318
+ translateX = 0;
2319
+ translateY = 0;
2320
+ applyTransform();
2321
+ img.style.cursor = 'zoom-in';
2322
+ };
2323
+ const overlay = document.createElement('div');
2324
+ overlay.id = 'weld-image-lightbox';
2325
+ overlay.style.cssText = `
2326
+ position: fixed;
2327
+ inset: 0;
2328
+ z-index: 2147483647;
2329
+ background: rgba(0, 0, 0, 0.92);
2330
+ display: flex;
2331
+ align-items: center;
2332
+ justify-content: center;
2333
+ padding: 16px;
2334
+ cursor: pointer;
2335
+ overflow: hidden;
2336
+ `;
2337
+ // Close button
2338
+ const closeBtn = document.createElement('button');
2339
+ 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>`;
2340
+ closeBtn.style.cssText = `
2341
+ position: absolute;
2342
+ top: 16px;
2343
+ right: 16px;
2344
+ width: 40px;
2345
+ height: 40px;
2346
+ border-radius: 50%;
2347
+ border: none;
2348
+ background: rgba(255, 255, 255, 0.1);
2349
+ color: white;
2350
+ cursor: pointer;
2351
+ display: flex;
2352
+ align-items: center;
2353
+ justify-content: center;
2354
+ transition: background 0.15s;
2355
+ `;
2356
+ closeBtn.onmouseenter = () => { closeBtn.style.background = 'rgba(255, 255, 255, 0.2)'; };
2357
+ closeBtn.onmouseleave = () => { closeBtn.style.background = 'rgba(255, 255, 255, 0.1)'; };
2358
+ // Download button
2359
+ const downloadBtn = document.createElement('a');
2360
+ downloadBtn.href = url;
2361
+ downloadBtn.download = '';
2362
+ downloadBtn.target = '_blank';
2363
+ downloadBtn.rel = 'noopener noreferrer';
2364
+ 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>`;
2365
+ downloadBtn.style.cssText = `
2366
+ position: absolute;
2367
+ top: 16px;
2368
+ right: 64px;
2369
+ width: 40px;
2370
+ height: 40px;
2371
+ border-radius: 50%;
2372
+ border: none;
2373
+ background: rgba(255, 255, 255, 0.1);
2374
+ color: white;
2375
+ cursor: pointer;
2376
+ display: flex;
2377
+ align-items: center;
2378
+ justify-content: center;
2379
+ transition: background 0.15s;
2380
+ text-decoration: none;
2381
+ `;
2382
+ downloadBtn.onmouseenter = () => { downloadBtn.style.background = 'rgba(255, 255, 255, 0.2)'; };
2383
+ downloadBtn.onmouseleave = () => { downloadBtn.style.background = 'rgba(255, 255, 255, 0.1)'; };
2384
+ // Image
2385
+ const img = document.createElement('img');
2386
+ img.src = url;
2387
+ img.alt = 'Full size';
2388
+ img.draggable = false;
2389
+ img.style.cssText = `
2390
+ max-width: 100%;
2391
+ max-height: 100%;
2392
+ object-fit: contain;
2393
+ border-radius: 8px;
2394
+ cursor: zoom-in;
2395
+ transition: transform 0.2s ease;
2396
+ user-select: none;
2397
+ `;
2398
+ // Click to toggle zoom
2399
+ img.addEventListener('click', (e) => {
2400
+ e.stopPropagation();
2401
+ if (scale === 1) {
2402
+ // Zoom in to 2.5x centered on click position
2403
+ const rect = img.getBoundingClientRect();
2404
+ const clickX = e.clientX - rect.left - rect.width / 2;
2405
+ const clickY = e.clientY - rect.top - rect.height / 2;
2406
+ scale = 2.5;
2407
+ translateX = -clickX * 1.5;
2408
+ translateY = -clickY * 1.5;
2409
+ applyTransform();
2410
+ img.style.cursor = 'zoom-out';
2411
+ }
2412
+ else {
2413
+ // Zoom out - reset
2414
+ resetTransform();
2415
+ }
2416
+ });
2417
+ // Mouse wheel zoom
2418
+ overlay.addEventListener('wheel', (e) => {
2419
+ e.preventDefault();
2420
+ const delta = e.deltaY > 0 ? -0.25 : 0.25;
2421
+ const newScale = Math.min(Math.max(scale + delta, 1), 5);
2422
+ if (newScale === 1) {
2423
+ resetTransform();
2424
+ }
2425
+ else {
2426
+ scale = newScale;
2427
+ applyTransform();
2428
+ img.style.cursor = 'zoom-out';
2429
+ }
2430
+ }, { passive: false });
2431
+ // Drag to pan when zoomed
2432
+ img.addEventListener('mousedown', (e) => {
2433
+ if (scale <= 1)
2434
+ return;
2435
+ e.preventDefault();
2436
+ isDragging = true;
2437
+ dragStartX = e.clientX;
2438
+ dragStartY = e.clientY;
2439
+ lastTranslateX = translateX;
2440
+ lastTranslateY = translateY;
2441
+ img.style.cursor = 'grabbing';
2442
+ img.style.transition = 'none';
2443
+ });
2444
+ window.addEventListener('mousemove', (e) => {
2445
+ if (!isDragging)
2446
+ return;
2447
+ translateX = lastTranslateX + (e.clientX - dragStartX);
2448
+ translateY = lastTranslateY + (e.clientY - dragStartY);
2449
+ applyTransform();
2450
+ });
2451
+ window.addEventListener('mouseup', () => {
2452
+ if (!isDragging)
2453
+ return;
2454
+ isDragging = false;
2455
+ img.style.cursor = scale > 1 ? 'zoom-out' : 'zoom-in';
2456
+ img.style.transition = 'transform 0.2s ease';
2457
+ });
2458
+ // Touch: pinch to zoom + drag to pan
2459
+ let lastTouchDist = 0;
2460
+ let lastTouchScale = 1;
2461
+ overlay.addEventListener('touchstart', (e) => {
2462
+ if (e.touches.length === 2) {
2463
+ e.preventDefault();
2464
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
2465
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
2466
+ lastTouchDist = Math.hypot(dx, dy);
2467
+ lastTouchScale = scale;
2468
+ }
2469
+ else if (e.touches.length === 1 && scale > 1) {
2470
+ isDragging = true;
2471
+ dragStartX = e.touches[0].clientX;
2472
+ dragStartY = e.touches[0].clientY;
2473
+ lastTranslateX = translateX;
2474
+ lastTranslateY = translateY;
2475
+ img.style.transition = 'none';
2476
+ }
2477
+ }, { passive: false });
2478
+ overlay.addEventListener('touchmove', (e) => {
2479
+ if (e.touches.length === 2) {
2480
+ e.preventDefault();
2481
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
2482
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
2483
+ const dist = Math.hypot(dx, dy);
2484
+ scale = Math.min(Math.max(lastTouchScale * (dist / lastTouchDist), 1), 5);
2485
+ if (scale === 1) {
2486
+ translateX = 0;
2487
+ translateY = 0;
2488
+ }
2489
+ applyTransform();
2490
+ }
2491
+ else if (e.touches.length === 1 && isDragging) {
2492
+ e.preventDefault();
2493
+ translateX = lastTranslateX + (e.touches[0].clientX - dragStartX);
2494
+ translateY = lastTranslateY + (e.touches[0].clientY - dragStartY);
2495
+ applyTransform();
2496
+ }
2497
+ }, { passive: false });
2498
+ overlay.addEventListener('touchend', (e) => {
2499
+ if (e.touches.length < 2) {
2500
+ lastTouchDist = 0;
2501
+ }
2502
+ if (e.touches.length === 0) {
2503
+ isDragging = false;
2504
+ img.style.transition = 'transform 0.2s ease';
2505
+ }
2506
+ });
2507
+ const close = () => {
2508
+ document.removeEventListener('keydown', handleKeyDown);
2509
+ overlay.remove();
2510
+ };
2511
+ // Only close on backdrop click when not zoomed (prevent accidental close while panning)
2512
+ overlay.addEventListener('click', (e) => {
2513
+ if (e.target === overlay && scale <= 1)
2514
+ close();
2515
+ });
2516
+ downloadBtn.addEventListener('click', (e) => e.stopPropagation());
2517
+ closeBtn.addEventListener('click', (e) => { e.stopPropagation(); close(); });
2518
+ // Close on Escape
2519
+ const handleKeyDown = (e) => {
2520
+ if (e.key === 'Escape')
2521
+ close();
2522
+ };
2523
+ document.addEventListener('keydown', handleKeyDown);
2524
+ overlay.appendChild(closeBtn);
2525
+ overlay.appendChild(downloadBtn);
2526
+ overlay.appendChild(img);
2527
+ document.body.appendChild(overlay);
2278
2528
  }
2279
2529
  /**
2280
2530
  * Initialize the SDK and render widget
@@ -2300,6 +2550,13 @@ class WeldSDK {
2300
2550
  this.logger.info('WeldSDK ready');
2301
2551
  // Call onReady callback
2302
2552
  this.config.onReady?.();
2553
+ // Start tracking page URL changes
2554
+ this.startPageTracking();
2555
+ // Auto-open if widget was previously open (persisted in sessionStorage)
2556
+ if (this.wasOpen()) {
2557
+ this.logger.info('Restoring previously open widget from sessionStorage');
2558
+ this.open();
2559
+ }
2303
2560
  }
2304
2561
  catch (error) {
2305
2562
  this.status = SDKStatus.ERROR;
@@ -2378,6 +2635,58 @@ class WeldSDK {
2378
2635
  isReady() {
2379
2636
  return this.status === SDKStatus.READY;
2380
2637
  }
2638
+ /**
2639
+ * Update callbacks on an existing instance (used by singleton reuse)
2640
+ */
2641
+ updateCallbacks(config) {
2642
+ if (config.onReady !== undefined)
2643
+ this.config.onReady = config.onReady;
2644
+ if (config.onOpen !== undefined)
2645
+ this.config.onOpen = config.onOpen;
2646
+ if (config.onClose !== undefined)
2647
+ this.config.onClose = config.onClose;
2648
+ if (config.onError !== undefined)
2649
+ this.config.onError = config.onError;
2650
+ if (config.onDestroy !== undefined)
2651
+ this.config.onDestroy = config.onDestroy;
2652
+ if (config.onMinimize !== undefined)
2653
+ this.config.onMinimize = config.onMinimize;
2654
+ if (config.onMaximize !== undefined)
2655
+ this.config.onMaximize = config.onMaximize;
2656
+ }
2657
+ /**
2658
+ * Persist open/closed state to sessionStorage
2659
+ */
2660
+ persistOpenState(isOpen) {
2661
+ try {
2662
+ sessionStorage.setItem(openStateKey(this.config.widgetId), isOpen ? 'true' : 'false');
2663
+ }
2664
+ catch {
2665
+ // sessionStorage might not be available
2666
+ }
2667
+ }
2668
+ /**
2669
+ * Clear persisted state from sessionStorage
2670
+ */
2671
+ clearPersistedState() {
2672
+ try {
2673
+ sessionStorage.removeItem(openStateKey(this.config.widgetId));
2674
+ }
2675
+ catch {
2676
+ // sessionStorage might not be available
2677
+ }
2678
+ }
2679
+ /**
2680
+ * Check if widget was previously open (from sessionStorage)
2681
+ */
2682
+ wasOpen() {
2683
+ try {
2684
+ return sessionStorage.getItem(openStateKey(this.config.widgetId)) === 'true';
2685
+ }
2686
+ catch {
2687
+ return false;
2688
+ }
2689
+ }
2381
2690
  /**
2382
2691
  * Open the widget
2383
2692
  */
@@ -2386,8 +2695,6 @@ class WeldSDK {
2386
2695
  console.log('[Weld SDK] Opening widget...');
2387
2696
  this.stateCoordinator.openWidget();
2388
2697
  this.iframeManager.showIframe(IframeType.WIDGET);
2389
- this.iframeManager.showIframe(IframeType.BACKDROP);
2390
- // Keep launcher visible so user can click it to close the widget
2391
2698
  // Send open message to the widget iframe
2392
2699
  const widgetIframe = this.iframeManager.getIframe(IframeType.WIDGET);
2393
2700
  if (widgetIframe?.element?.contentWindow) {
@@ -2398,6 +2705,7 @@ class WeldSDK {
2398
2705
  if (launcherIframe?.element?.contentWindow) {
2399
2706
  launcherIframe.element.contentWindow.postMessage({ type: 'weld:widget-opened' }, '*');
2400
2707
  }
2708
+ this.persistOpenState(true);
2401
2709
  this.config.onOpen?.();
2402
2710
  }
2403
2711
  /**
@@ -2408,8 +2716,6 @@ class WeldSDK {
2408
2716
  console.log('[Weld SDK] Closing widget...');
2409
2717
  this.stateCoordinator.closeWidget();
2410
2718
  this.iframeManager.hideIframe(IframeType.WIDGET);
2411
- this.iframeManager.hideIframe(IframeType.BACKDROP);
2412
- // Launcher stays visible
2413
2719
  // Send close message to the widget iframe
2414
2720
  const widgetIframe = this.iframeManager.getIframe(IframeType.WIDGET);
2415
2721
  if (widgetIframe?.element?.contentWindow) {
@@ -2420,6 +2726,7 @@ class WeldSDK {
2420
2726
  if (launcherIframe?.element?.contentWindow) {
2421
2727
  launcherIframe.element.contentWindow.postMessage({ type: 'weld:widget-closed' }, '*');
2422
2728
  }
2729
+ this.persistOpenState(false);
2423
2730
  this.config.onClose?.();
2424
2731
  }
2425
2732
  /**
@@ -2608,6 +2915,8 @@ class WeldSDK {
2608
2915
  });
2609
2916
  // Broadcast logout to iframes
2610
2917
  this.messageBroker.broadcast('weld:auth:logout', {});
2918
+ // Clear persisted widget state
2919
+ this.clearPersistedState();
2611
2920
  // Clear any stored session data
2612
2921
  try {
2613
2922
  const prefix = 'weld-';
@@ -2749,6 +3058,63 @@ class WeldSDK {
2749
3058
  this.logger.setLevel('warn');
2750
3059
  this.logger.info('Debug mode disabled');
2751
3060
  }
3061
+ /**
3062
+ * Send a page change message to the widget iframe
3063
+ */
3064
+ sendPageChange(url, title) {
3065
+ const widgetIframe = this.iframeManager.getIframe(IframeType.WIDGET);
3066
+ if (widgetIframe?.element?.contentWindow) {
3067
+ widgetIframe.element.contentWindow.postMessage({
3068
+ type: 'weld:page:change',
3069
+ url,
3070
+ title,
3071
+ timestamp: Date.now(),
3072
+ }, '*');
3073
+ }
3074
+ }
3075
+ /**
3076
+ * Start tracking page URL changes (SPA navigations + popstate)
3077
+ */
3078
+ startPageTracking() {
3079
+ let lastUrl = window.location.href;
3080
+ let debounceTimer = null;
3081
+ const notifyChange = () => {
3082
+ const currentUrl = window.location.href;
3083
+ if (currentUrl !== lastUrl) {
3084
+ lastUrl = currentUrl;
3085
+ this.sendPageChange(currentUrl, document.title);
3086
+ }
3087
+ };
3088
+ const debouncedNotify = () => {
3089
+ if (debounceTimer)
3090
+ clearTimeout(debounceTimer);
3091
+ debounceTimer = setTimeout(notifyChange, 300);
3092
+ };
3093
+ // Send initial page
3094
+ this.sendPageChange(window.location.href, document.title);
3095
+ // Monkey-patch history.pushState and history.replaceState
3096
+ const origPushState = history.pushState.bind(history);
3097
+ const origReplaceState = history.replaceState.bind(history);
3098
+ history.pushState = function (...args) {
3099
+ origPushState(...args);
3100
+ debouncedNotify();
3101
+ };
3102
+ history.replaceState = function (...args) {
3103
+ origReplaceState(...args);
3104
+ debouncedNotify();
3105
+ };
3106
+ // Listen for popstate (browser back/forward)
3107
+ const handlePopstate = () => debouncedNotify();
3108
+ window.addEventListener('popstate', handlePopstate);
3109
+ // Store cleanup
3110
+ this.pageTrackingCleanup = () => {
3111
+ if (debounceTimer)
3112
+ clearTimeout(debounceTimer);
3113
+ window.removeEventListener('popstate', handlePopstate);
3114
+ history.pushState = origPushState;
3115
+ history.replaceState = origReplaceState;
3116
+ };
3117
+ }
2752
3118
  /**
2753
3119
  * Ensure SDK is ready before operation
2754
3120
  */
@@ -2757,11 +3123,26 @@ class WeldSDK {
2757
3123
  throw new Error('SDK not ready. Call init() first.');
2758
3124
  }
2759
3125
  }
3126
+ /**
3127
+ * Detach from the current component lifecycle without destroying the widget.
3128
+ * Use this as a React useEffect cleanup — the widget stays alive across navigations.
3129
+ */
3130
+ detach() {
3131
+ // No-op: widget stays alive in the singleton registry
3132
+ this.logger.debug('WeldSDK detached (no-op, widget stays alive)');
3133
+ }
2760
3134
  /**
2761
3135
  * Destroy SDK and cleanup
2762
3136
  */
2763
3137
  destroy() {
2764
3138
  this.logger.info('Destroying WeldSDK');
3139
+ // Remove from singleton registry
3140
+ sdkRegistry.delete(this.config.widgetId);
3141
+ // Clear persisted state
3142
+ this.clearPersistedState();
3143
+ // Stop page tracking
3144
+ this.pageTrackingCleanup?.();
3145
+ this.pageTrackingCleanup = null;
2765
3146
  // Remove event listener using bound handler
2766
3147
  window.removeEventListener('message', this.boundHandleLauncherClick);
2767
3148
  // Unsubscribe from all message broker subscriptions