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