sovads-sdk 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,31 +5,97 @@ class SovAds {
5
5
  this.components = new Map();
6
6
  this.siteId = null;
7
7
  this.renderObservers = new Map();
8
- this.debugLoggingEnabled = true;
8
+ this.debugLoggingEnabled = false;
9
+ this.adTrackingTokens = new Map();
10
+ this.walletAddress = null;
9
11
  this.config = {
10
12
  apiUrl: typeof window !== 'undefined' && window.location.hostname === 'localhost'
11
13
  ? 'http://localhost:3000'
12
14
  : 'https://ads.sovseas.xyz',
13
15
  debug: false,
16
+ refreshInterval: 0, // No auto-refresh by default
17
+ lazyLoad: true,
18
+ rotationEnabled: true,
19
+ popupMinIntervalMinutes: 30,
20
+ popupSessionMax: 1,
14
21
  ...config
15
22
  };
23
+ this.debugLoggingEnabled = Boolean(this.config.debug);
16
24
  this.fingerprint = this.generateFingerprint();
25
+ // Load persisted wallet address if available
26
+ this.loadPersistedIdentity();
27
+ if (this.config.walletAddress) {
28
+ this.identify(this.config.walletAddress);
29
+ }
17
30
  if (this.config.debug) {
18
31
  console.log('SovAds SDK initialized:', this.config);
19
32
  }
20
33
  }
34
+ /**
35
+ * Identifies the current viewer with a wallet address.
36
+ * This links the device fingerprint to the wallet on the backend.
37
+ */
38
+ identify(walletAddress) {
39
+ if (!walletAddress || typeof walletAddress !== 'string')
40
+ return;
41
+ this.walletAddress = walletAddress.toLowerCase();
42
+ try {
43
+ if (typeof window !== 'undefined' && window.localStorage) {
44
+ localStorage.setItem('sovads_wallet_address', this.walletAddress);
45
+ }
46
+ }
47
+ catch (e) {
48
+ // Ignore storage errors
49
+ }
50
+ if (this.config.debug) {
51
+ console.log('SovAds Identity set:', this.walletAddress);
52
+ }
53
+ }
54
+ loadPersistedIdentity() {
55
+ try {
56
+ if (typeof window !== 'undefined' && window.localStorage) {
57
+ const saved = localStorage.getItem('sovads_wallet_address');
58
+ if (saved) {
59
+ this.walletAddress = saved.toLowerCase();
60
+ }
61
+ }
62
+ }
63
+ catch (e) {
64
+ // Ignore storage errors
65
+ }
66
+ }
21
67
  generateFingerprint() {
22
- const canvas = document.createElement('canvas');
23
- const ctx = canvas.getContext('2d');
24
- ctx?.fillText('SovAds fingerprint', 10, 10);
25
- const fingerprint = [
26
- navigator.userAgent,
27
- navigator.language,
28
- screen.width + 'x' + screen.height,
29
- new Date().getTimezoneOffset(),
30
- canvas.toDataURL()
31
- ].join('|');
32
- return btoa(fingerprint).substring(0, 16);
68
+ const storageKey = 'sovads_fingerprint_v1';
69
+ try {
70
+ if (typeof window !== 'undefined' && window.localStorage) {
71
+ const existing = window.localStorage.getItem(storageKey);
72
+ if (existing) {
73
+ return existing;
74
+ }
75
+ }
76
+ }
77
+ catch {
78
+ // Ignore storage access errors and fall back to generated value.
79
+ }
80
+ const browserParts = [
81
+ typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown-ua',
82
+ typeof navigator !== 'undefined' ? navigator.language : 'unknown-lang',
83
+ typeof screen !== 'undefined' ? `${screen.width}x${screen.height}` : 'unknown-screen',
84
+ String(new Date().getTimezoneOffset()),
85
+ typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
86
+ ? crypto.randomUUID()
87
+ : `${Date.now()}-${Math.random()}`,
88
+ ];
89
+ const value = btoa(browserParts.join('|')).replace(/=+$/g, '');
90
+ try {
91
+ if (typeof window !== 'undefined' && window.localStorage) {
92
+ window.localStorage.setItem(storageKey, value);
93
+ }
94
+ }
95
+ catch {
96
+ // Ignore storage write failures.
97
+ }
98
+ return value;
33
99
  }
34
100
  async detectSiteId() {
35
101
  if (this.siteId) {
@@ -47,6 +113,7 @@ class SovAds {
47
113
  const domain = window.location.hostname;
48
114
  const payload = {
49
115
  domain,
116
+ pathname: window.location.pathname,
50
117
  fingerprint: this.fingerprint,
51
118
  userAgent: navigator.userAgent,
52
119
  pageUrl: window.location.href,
@@ -216,13 +283,16 @@ class SovAds {
216
283
  * Normalize URL - add protocol if missing for localhost
217
284
  */
218
285
  normalizeUrl(url) {
219
- if (!url.includes('://')) {
286
+ const trimmed = url.trim();
287
+ if (!trimmed.includes('://')) {
220
288
  // Allow localhost URLs without protocol for debugging
221
- if (url.startsWith('localhost') || url.startsWith('127.0.0.1')) {
222
- return `http://${url}`;
289
+ if (trimmed.startsWith('localhost') || trimmed.startsWith('127.0.0.1')) {
290
+ return `http://${trimmed}`;
223
291
  }
292
+ // Treat bare domains as https by default.
293
+ return `https://${trimmed}`;
224
294
  }
225
- return url;
295
+ return trimmed;
226
296
  }
227
297
  /**
228
298
  * Validate URL format
@@ -237,6 +307,11 @@ class SovAds {
237
307
  return false;
238
308
  }
239
309
  }
310
+ inferMediaTypeFromUrl(url) {
311
+ const value = (url || '').toLowerCase();
312
+ const videoExts = ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.m3u8'];
313
+ return videoExts.some((ext) => value.includes(ext)) ? 'video' : 'image';
314
+ }
240
315
  /**
241
316
  * Fetch with retry logic
242
317
  */
@@ -263,15 +338,27 @@ class SovAds {
263
338
  }
264
339
  throw lastError || new Error('Fetch failed after retries');
265
340
  }
266
- async loadAd(consumerId) {
341
+ async loadAd(options = {}) {
267
342
  const startTime = Date.now();
268
343
  try {
269
344
  const siteId = await this.detectSiteId();
270
- const params = new URLSearchParams({
271
- siteId,
272
- ...(consumerId && { consumerId })
273
- });
274
- const endpoint = `${this.config.apiUrl}/api/ads?${params}`;
345
+ const url = new URL(`${this.config.apiUrl}/api/ads`);
346
+ url.searchParams.append('siteId', siteId);
347
+ if (options.consumerId || this.config.consumerId) {
348
+ url.searchParams.append('consumerId', (options.consumerId || this.config.consumerId));
349
+ }
350
+ if (options.placement) {
351
+ url.searchParams.append('placement', options.placement);
352
+ }
353
+ if (options.size) {
354
+ url.searchParams.append('size', options.size);
355
+ }
356
+ // Add wallet address for targeting and attribution
357
+ const wallet = options.walletAddress || this.walletAddress;
358
+ if (wallet) {
359
+ url.searchParams.append('wallet', wallet);
360
+ }
361
+ const endpoint = url.toString();
275
362
  const response = await this.fetchWithRetry(endpoint);
276
363
  const duration = Date.now() - startTime;
277
364
  // Log SDK request
@@ -284,7 +371,7 @@ class SovAds {
284
371
  pageUrl: window.location.href,
285
372
  userAgent: navigator.userAgent,
286
373
  fingerprint: this.fingerprint,
287
- requestBody: { siteId, consumerId },
374
+ requestBody: { siteId, ...options },
288
375
  responseStatus: response.status,
289
376
  duration,
290
377
  });
@@ -335,8 +422,13 @@ class SovAds {
335
422
  ...rawAd,
336
423
  bannerUrl: this.normalizeUrl(rawAd.bannerUrl),
337
424
  targetUrl: this.normalizeUrl(rawAd.targetUrl),
338
- mediaType: rawAd.mediaType === 'video' ? 'video' : 'image',
425
+ mediaType: rawAd.mediaType === 'video'
426
+ ? 'video'
427
+ : this.inferMediaTypeFromUrl(this.normalizeUrl(rawAd.bannerUrl)),
339
428
  };
429
+ if (normalizedAd.trackingToken) {
430
+ this.adTrackingTokens.set(normalizedAd.id, normalizedAd.trackingToken);
431
+ }
340
432
  if (this.config.debug) {
341
433
  console.log('Ad loaded:', normalizedAd);
342
434
  }
@@ -370,6 +462,105 @@ class SovAds {
370
462
  return null;
371
463
  }
372
464
  }
465
+ toBase64(bytes) {
466
+ let binary = '';
467
+ for (const b of bytes) {
468
+ binary += String.fromCharCode(b);
469
+ }
470
+ return btoa(binary);
471
+ }
472
+ async signTrackingPayload(payload, timestamp) {
473
+ if (!this.config.apiSecret || typeof crypto === 'undefined' || !crypto.subtle) {
474
+ return null;
475
+ }
476
+ try {
477
+ const encoder = new TextEncoder();
478
+ const key = await crypto.subtle.importKey('raw', encoder.encode(this.config.apiSecret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
479
+ const message = `${timestamp}:${payload}`;
480
+ const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message));
481
+ return this.toBase64(new Uint8Array(signature));
482
+ }
483
+ catch (error) {
484
+ if (this.config.debug) {
485
+ console.error('Failed to sign tracking payload:', error);
486
+ }
487
+ return null;
488
+ }
489
+ }
490
+ async sendTrackingEnvelope(eventPayload, useBeacon) {
491
+ if (eventPayload.trackingToken) {
492
+ const tokenBody = JSON.stringify({
493
+ trackingToken: eventPayload.trackingToken,
494
+ payload: eventPayload,
495
+ });
496
+ const tokenWebhookUrl = `${this.config.apiUrl}/api/webhook/track`;
497
+ try {
498
+ if (useBeacon && navigator.sendBeacon) {
499
+ return navigator.sendBeacon(tokenWebhookUrl, new Blob([tokenBody], { type: 'application/json' }));
500
+ }
501
+ const response = await fetch(tokenWebhookUrl, {
502
+ method: 'POST',
503
+ headers: { 'Content-Type': 'application/json' },
504
+ body: tokenBody,
505
+ keepalive: true,
506
+ });
507
+ return response.ok;
508
+ }
509
+ catch {
510
+ return false;
511
+ }
512
+ }
513
+ if (!this.config.apiKey || !this.config.apiSecret) {
514
+ if (this.config.debug) {
515
+ const devWebhookUrl = `${this.config.apiUrl}/api/webhook/beacon`;
516
+ const body = JSON.stringify(eventPayload);
517
+ try {
518
+ if (useBeacon && navigator.sendBeacon) {
519
+ return navigator.sendBeacon(devWebhookUrl, new Blob([body], { type: 'application/json' }));
520
+ }
521
+ const response = await fetch(devWebhookUrl, {
522
+ method: 'POST',
523
+ headers: { 'Content-Type': 'application/json' },
524
+ body,
525
+ keepalive: true,
526
+ });
527
+ return response.ok;
528
+ }
529
+ catch {
530
+ return false;
531
+ }
532
+ }
533
+ else {
534
+ console.warn('SovAds: Missing apiKey/apiSecret, skipping signed tracking event');
535
+ }
536
+ return false;
537
+ }
538
+ const timestamp = Date.now();
539
+ const payload = JSON.stringify(eventPayload);
540
+ const signature = await this.signTrackingPayload(payload, timestamp);
541
+ if (!signature) {
542
+ return false;
543
+ }
544
+ const envelope = JSON.stringify({
545
+ apiKey: this.config.apiKey,
546
+ siteId: eventPayload.siteId,
547
+ payload,
548
+ signature,
549
+ timestamp,
550
+ });
551
+ const webhookUrl = `${this.config.apiUrl}/api/webhook/track`;
552
+ if (useBeacon && navigator.sendBeacon) {
553
+ const blob = new Blob([envelope], { type: 'application/json' });
554
+ return navigator.sendBeacon(webhookUrl, blob);
555
+ }
556
+ const response = await fetch(webhookUrl, {
557
+ method: 'POST',
558
+ headers: { 'Content-Type': 'application/json' },
559
+ body: envelope,
560
+ keepalive: true,
561
+ });
562
+ return response.ok;
563
+ }
373
564
  /**
374
565
  * Track event with retry logic (internal helper)
375
566
  */
@@ -389,24 +580,15 @@ class SovAds {
389
580
  renderTime: renderInfo?.renderTime ?? Date.now(),
390
581
  timestamp: metadata.timestamp,
391
582
  pageUrl: metadata.pageUrl,
392
- userAgent: metadata.userAgent
583
+ userAgent: metadata.userAgent,
584
+ trackingToken: this.adTrackingTokens.get(adId),
393
585
  };
394
- const webhookUrl = `${this.config.apiUrl}/api/webhook/beacon`;
395
- const response = await fetch(webhookUrl, {
396
- method: 'POST',
397
- headers: {
398
- 'Content-Type': 'application/json'
399
- },
400
- body: JSON.stringify(payload),
401
- keepalive: true // Similar behavior to beacon
402
- });
403
- if (response.ok) {
404
- if (this.config.debug) {
405
- console.log(`SovAds: Tracked ${type} event via fetch (attempt ${attempt})`, payload);
406
- }
586
+ const ok = await this.sendTrackingEnvelope(payload, false);
587
+ if (!ok) {
588
+ throw new Error('Tracking endpoint rejected event');
407
589
  }
408
- else {
409
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
590
+ if (this.config.debug) {
591
+ console.log(`SovAds: Tracked ${type} event via signed fetch (attempt ${attempt})`, payload);
410
592
  }
411
593
  }
412
594
  catch (error) {
@@ -446,35 +628,26 @@ class SovAds {
446
628
  renderTime: renderInfo?.renderTime ?? Date.now(),
447
629
  timestamp: metadata.timestamp,
448
630
  pageUrl: metadata.pageUrl,
449
- userAgent: metadata.userAgent
631
+ userAgent: metadata.userAgent,
632
+ trackingToken: this.adTrackingTokens.get(adId),
633
+ walletAddress: this.walletAddress || undefined
450
634
  };
451
- // Use sendBeacon for reliable delivery (better for tracking impressions)
452
- // Beacon API ensures events are sent even if user navigates away
453
- if (navigator.sendBeacon) {
454
- const blob = new Blob([JSON.stringify(payload)], {
455
- type: 'application/json'
456
- });
457
- // Send to dedicated webhook endpoint for beamer interactions
458
- const webhookUrl = `${this.config.apiUrl}/api/webhook/beacon`;
459
- const sent = navigator.sendBeacon(webhookUrl, blob);
460
- if (!sent) {
461
- // Beacon failed, fallback to fetch with retry
635
+ if (typeof navigator.sendBeacon === 'function') {
636
+ const sent = await this.sendTrackingEnvelope(payload, true);
637
+ if (sent) {
462
638
  if (this.config.debug) {
463
- console.warn(`SovAds: Beacon failed for ${type}, falling back to fetch`);
639
+ console.log(`SovAds: Tracked ${type} event via signed beacon`, {
640
+ payload: { ...payload, fingerprint: payload.fingerprint.substring(0, 8) + '...' }
641
+ });
464
642
  }
465
- await this.trackEventWithRetry(type, adId, campaignId, renderInfo, 1);
466
- }
467
- else if (this.config.debug) {
468
- console.log(`SovAds: Tracked ${type} event via beacon`, {
469
- sent,
470
- payload: { ...payload, fingerprint: payload.fingerprint.substring(0, 8) + '...' }
471
- });
643
+ return;
472
644
  }
473
645
  }
474
- else {
475
- // Fallback to fetch for older browsers
476
- await this.trackEventWithRetry(type, adId, campaignId, renderInfo, 1);
646
+ // Fallback to signed fetch for older browsers and beacon failures
647
+ if (this.config.debug) {
648
+ console.warn(`SovAds: Beacon unavailable/failed for ${type}, falling back to signed fetch`);
477
649
  }
650
+ await this.trackEventWithRetry(type, adId, campaignId, renderInfo, 1);
478
651
  }
479
652
  catch (error) {
480
653
  if (this.config.debug) {
@@ -555,17 +728,22 @@ class SovAds {
555
728
  }
556
729
  // Banner Component
557
730
  export class Banner {
558
- constructor(sovads, containerId) {
731
+ constructor(sovads, containerId, slotConfig = {}) {
559
732
  this.currentAd = null;
560
733
  this.renderStartTime = 0;
561
734
  this.hasTrackedImpression = false;
562
735
  this.isRendering = false;
736
+ this.refreshTimer = null;
737
+ this.lastAdId = null;
738
+ this.retryCount = 0;
739
+ this.maxRetries = 3;
563
740
  this.sovads = sovads;
564
741
  this.containerId = containerId;
742
+ this.slotConfig = slotConfig;
565
743
  }
566
- async render(consumerId) {
744
+ async render(consumerId, forceRefresh = false) {
567
745
  // Prevent concurrent renders
568
- if (this.isRendering) {
746
+ if (this.isRendering && !forceRefresh) {
569
747
  if (this.sovads.getConfig().debug) {
570
748
  console.warn(`Banner render already in progress for ${this.containerId}`);
571
749
  }
@@ -579,8 +757,33 @@ export class Banner {
579
757
  this.isRendering = false;
580
758
  return;
581
759
  }
760
+ // Lazy loading: wait for container to be in viewport
761
+ if (this.sovads.getConfig().lazyLoad && !forceRefresh) {
762
+ const isInViewport = await this.checkViewport(container);
763
+ if (!isInViewport) {
764
+ // Set up intersection observer for lazy loading
765
+ this.setupLazyLoadObserver(container, consumerId);
766
+ this.isRendering = false;
767
+ return;
768
+ }
769
+ }
582
770
  this.renderStartTime = Date.now();
583
- this.currentAd = await this.sovads.loadAd(consumerId);
771
+ this.currentAd = await this.sovads.loadAd({
772
+ consumerId,
773
+ placement: this.slotConfig.placementId || 'banner',
774
+ size: this.slotConfig.size,
775
+ });
776
+ this.hasTrackedImpression = false;
777
+ // Skip if same ad (rotation disabled or same ad returned)
778
+ if (!forceRefresh && this.lastAdId === this.currentAd?.id && this.sovads.getConfig().rotationEnabled) {
779
+ if (this.sovads.getConfig().debug) {
780
+ console.log('Same ad returned, skipping render');
781
+ }
782
+ this.isRendering = false;
783
+ return;
784
+ }
785
+ this.lastAdId = this.currentAd?.id || null;
786
+ this.retryCount = 0; // Reset retry count on success
584
787
  if (!this.currentAd) {
585
788
  container.innerHTML = '<div class="sovads-no-ad">No ads available</div>';
586
789
  this.isRendering = false;
@@ -588,6 +791,7 @@ export class Banner {
588
791
  }
589
792
  // Handle dummy ads for unregistered sites
590
793
  if (this.currentAd.isDummy) {
794
+ container.innerHTML = '';
591
795
  const dummyElement = document.createElement('div');
592
796
  dummyElement.className = 'sovads-banner-dummy';
593
797
  dummyElement.setAttribute('data-ad-id', this.currentAd.id);
@@ -633,16 +837,21 @@ export class Banner {
633
837
  return;
634
838
  }
635
839
  const adElement = document.createElement('div');
840
+ container.innerHTML = '';
636
841
  adElement.className = 'sovads-banner';
637
842
  adElement.setAttribute('data-ad-id', this.currentAd.id);
843
+ const mediaType = this.currentAd.mediaType === 'video' ? 'video' : 'image';
638
844
  adElement.style.cssText = `
639
845
  border: 1px solid #333;
640
846
  border-radius: 8px;
641
847
  overflow: hidden;
642
- cursor: pointer;
848
+ cursor: ${mediaType === 'video' ? 'default' : 'pointer'};
643
849
  transition: transform 0.2s ease;
850
+ max-width: 100%;
851
+ width: 100%;
852
+ box-sizing: border-box;
853
+ opacity: 0;
644
854
  `;
645
- const mediaType = this.currentAd.mediaType === 'video' ? 'video' : 'image';
646
855
  const handleVisibilityTracking = (renderInfo) => {
647
856
  this.sovads.setupRenderObserver(adElement, this.currentAd.id, (isVisible) => {
648
857
  renderInfo.viewportVisible = isVisible;
@@ -653,6 +862,7 @@ export class Banner {
653
862
  });
654
863
  };
655
864
  const handleRenderSuccess = () => {
865
+ adElement.style.opacity = '1';
656
866
  const renderTime = Date.now() - this.renderStartTime;
657
867
  handleVisibilityTracking({
658
868
  rendered: true,
@@ -661,6 +871,7 @@ export class Banner {
661
871
  });
662
872
  };
663
873
  const handleRenderError = () => {
874
+ adElement.style.opacity = '1';
664
875
  if (this.sovads.getConfig().debug) {
665
876
  console.warn(`Failed to load ad media: ${this.currentAd.bannerUrl}`);
666
877
  }
@@ -688,20 +899,19 @@ export class Banner {
688
899
  const img = document.createElement('img');
689
900
  img.src = this.currentAd.bannerUrl;
690
901
  img.alt = this.currentAd.description;
691
- img.style.cssText = 'width: 100%; height: auto; display: block;';
902
+ img.style.cssText = 'width: 100%; height: auto; display: block; max-width: 100%; object-fit: contain;';
692
903
  img.addEventListener('load', handleRenderSuccess, { once: true });
693
904
  img.addEventListener('error', handleRenderError, { once: true });
694
905
  mediaElement = img;
695
906
  }
696
- mediaElement.style.cursor = 'pointer';
697
- // Add click handler
698
- adElement.addEventListener('click', () => {
907
+ mediaElement.style.cursor = mediaType === 'video' ? 'default' : 'pointer';
908
+ mediaElement.style.maxWidth = '100%';
909
+ const handleClickThrough = () => {
699
910
  this.sovads._trackEvent('CLICK', this.currentAd.id, this.currentAd.campaignId, {
700
911
  rendered: true,
701
912
  viewportVisible: true,
702
913
  renderTime: Date.now() - this.renderStartTime
703
914
  });
704
- // Log interaction
705
915
  this.sovads.logInteraction('CLICK', {
706
916
  adId: this.currentAd.id,
707
917
  campaignId: this.currentAd.campaignId,
@@ -709,7 +919,30 @@ export class Banner {
709
919
  metadata: { renderTime: Date.now() - this.renderStartTime },
710
920
  });
711
921
  window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
712
- });
922
+ };
923
+ if (mediaType === 'video') {
924
+ const ctaButton = document.createElement('button');
925
+ ctaButton.type = 'button';
926
+ ctaButton.textContent = 'Learn more';
927
+ ctaButton.style.cssText = `
928
+ width: 100%;
929
+ border: none;
930
+ border-top: 1px solid #333;
931
+ background: #111;
932
+ color: #fff;
933
+ font-size: 12px;
934
+ font-weight: 600;
935
+ padding: 8px 12px;
936
+ cursor: pointer;
937
+ `;
938
+ ctaButton.addEventListener('click', handleClickThrough);
939
+ adElement.appendChild(mediaElement);
940
+ adElement.appendChild(ctaButton);
941
+ }
942
+ else {
943
+ adElement.addEventListener('click', handleClickThrough);
944
+ adElement.appendChild(mediaElement);
945
+ }
713
946
  // Add hover effect
714
947
  adElement.addEventListener('mouseenter', () => {
715
948
  adElement.style.transform = 'scale(1.02)';
@@ -717,13 +950,94 @@ export class Banner {
717
950
  adElement.addEventListener('mouseleave', () => {
718
951
  adElement.style.transform = 'scale(1)';
719
952
  });
720
- adElement.appendChild(mediaElement);
721
953
  container.appendChild(adElement);
954
+ // Set up auto-refresh if enabled
955
+ this.setupAutoRefresh(consumerId);
956
+ }
957
+ catch (error) {
958
+ // Retry logic on error
959
+ if (this.retryCount < this.maxRetries) {
960
+ this.retryCount++;
961
+ if (this.sovads.getConfig().debug) {
962
+ console.warn(`Banner render failed, retrying (${this.retryCount}/${this.maxRetries})...`);
963
+ }
964
+ await new Promise(resolve => setTimeout(resolve, 1000 * this.retryCount)); // Exponential backoff
965
+ this.isRendering = false;
966
+ return this.render(consumerId, true);
967
+ }
968
+ else {
969
+ const container = document.getElementById(this.containerId);
970
+ if (container) {
971
+ container.innerHTML = '<div class="sovads-error" style="padding: 10px; text-align: center; color: #666; font-size: 12px;">Ad temporarily unavailable</div>';
972
+ }
973
+ if (this.sovads.getConfig().debug) {
974
+ console.error('Banner render failed after retries:', error);
975
+ }
976
+ }
722
977
  }
723
978
  finally {
724
979
  this.isRendering = false;
725
980
  }
726
981
  }
982
+ async checkViewport(element) {
983
+ return new Promise((resolve) => {
984
+ if (typeof IntersectionObserver === 'undefined') {
985
+ resolve(true); // Fallback: load immediately if IntersectionObserver not supported
986
+ return;
987
+ }
988
+ const observer = new IntersectionObserver((entries) => {
989
+ entries.forEach((entry) => {
990
+ if (entry.isIntersecting) {
991
+ observer.disconnect();
992
+ resolve(true);
993
+ }
994
+ });
995
+ }, { rootMargin: '50px' } // Start loading 50px before entering viewport
996
+ );
997
+ observer.observe(element);
998
+ // Timeout after 5 seconds - load anyway
999
+ setTimeout(() => {
1000
+ observer.disconnect();
1001
+ resolve(true);
1002
+ }, 5000);
1003
+ });
1004
+ }
1005
+ setupLazyLoadObserver(container, consumerId) {
1006
+ if (typeof IntersectionObserver === 'undefined') {
1007
+ // Fallback: load immediately
1008
+ this.render(consumerId);
1009
+ return;
1010
+ }
1011
+ const observer = new IntersectionObserver((entries) => {
1012
+ entries.forEach((entry) => {
1013
+ if (entry.isIntersecting && !this.isRendering) {
1014
+ observer.disconnect();
1015
+ this.render(consumerId);
1016
+ }
1017
+ });
1018
+ }, { rootMargin: '50px' });
1019
+ observer.observe(container);
1020
+ }
1021
+ setupAutoRefresh(consumerId) {
1022
+ // Clear existing timer
1023
+ if (this.refreshTimer) {
1024
+ clearInterval(this.refreshTimer);
1025
+ }
1026
+ const refreshInterval = this.sovads.getConfig().refreshInterval || 0;
1027
+ if (refreshInterval > 0) {
1028
+ this.refreshTimer = window.setInterval(() => {
1029
+ if (!this.isRendering) {
1030
+ this.render(consumerId, true);
1031
+ }
1032
+ }, refreshInterval * 1000);
1033
+ }
1034
+ }
1035
+ destroy() {
1036
+ if (this.refreshTimer) {
1037
+ clearInterval(this.refreshTimer);
1038
+ this.refreshTimer = null;
1039
+ }
1040
+ }
727
1041
  }
728
1042
  // Popup Component
729
1043
  export class Popup {
@@ -731,8 +1045,40 @@ export class Popup {
731
1045
  this.currentAd = null;
732
1046
  this.popupElement = null;
733
1047
  this.isShowing = false;
1048
+ this.retryCount = 0;
1049
+ this.maxRetries = 3;
1050
+ this.storageKeyLastShown = 'sovads_popup_last_shown';
1051
+ this.storageKeySessionCount = 'sovads_popup_session_count';
734
1052
  this.sovads = sovads;
735
1053
  }
1054
+ canShowByFrequencyCap() {
1055
+ try {
1056
+ const minIntervalMs = (this.sovads.getConfig().popupMinIntervalMinutes || 30) * 60 * 1000;
1057
+ const sessionMax = this.sovads.getConfig().popupSessionMax || 1;
1058
+ const now = Date.now();
1059
+ const lastShown = Number(localStorage.getItem(this.storageKeyLastShown) || 0);
1060
+ const sessionCount = Number(sessionStorage.getItem(this.storageKeySessionCount) || 0);
1061
+ if (sessionCount >= sessionMax)
1062
+ return false;
1063
+ if (lastShown > 0 && now - lastShown < minIntervalMs)
1064
+ return false;
1065
+ return true;
1066
+ }
1067
+ catch {
1068
+ return true;
1069
+ }
1070
+ }
1071
+ markShown() {
1072
+ try {
1073
+ const now = Date.now();
1074
+ const currentSessionCount = Number(sessionStorage.getItem(this.storageKeySessionCount) || 0);
1075
+ localStorage.setItem(this.storageKeyLastShown, String(now));
1076
+ sessionStorage.setItem(this.storageKeySessionCount, String(currentSessionCount + 1));
1077
+ }
1078
+ catch {
1079
+ // Ignore storage access issues.
1080
+ }
1081
+ }
736
1082
  async show(consumerId, delay = 3000) {
737
1083
  // Prevent concurrent shows
738
1084
  if (this.isShowing) {
@@ -741,65 +1087,113 @@ export class Popup {
741
1087
  }
742
1088
  return;
743
1089
  }
744
- this.isShowing = true;
745
- this.currentAd = await this.sovads.loadAd(consumerId);
746
- if (!this.currentAd) {
747
- console.log('No popup ad available');
748
- this.isShowing = false;
1090
+ if (!this.canShowByFrequencyCap()) {
1091
+ if (this.sovads.getConfig().debug) {
1092
+ console.log('Popup skipped due to frequency cap');
1093
+ }
749
1094
  return;
750
1095
  }
751
- // Show popup after delay
752
- setTimeout(() => {
753
- this.renderPopup();
1096
+ this.isShowing = true;
1097
+ try {
1098
+ this.currentAd = await this.sovads.loadAd({
1099
+ consumerId,
1100
+ placement: 'popup',
1101
+ size: window.innerWidth < 640 ? '320x100' : '360x120',
1102
+ });
1103
+ if (!this.currentAd) {
1104
+ if (this.retryCount < this.maxRetries) {
1105
+ this.retryCount++;
1106
+ await new Promise(resolve => setTimeout(resolve, 1000 * this.retryCount));
1107
+ this.isShowing = false;
1108
+ return this.show(consumerId, delay);
1109
+ }
1110
+ if (this.sovads.getConfig().debug) {
1111
+ console.log('No popup ad available after retries');
1112
+ }
1113
+ this.isShowing = false;
1114
+ this.retryCount = 0;
1115
+ return;
1116
+ }
1117
+ this.retryCount = 0; // Reset on success
1118
+ // Show popup after delay
1119
+ setTimeout(() => {
1120
+ this.renderPopup();
1121
+ this.markShown();
1122
+ this.isShowing = false;
1123
+ }, delay);
1124
+ }
1125
+ catch (error) {
1126
+ if (this.sovads.getConfig().debug) {
1127
+ console.error('Error loading popup ad:', error);
1128
+ }
754
1129
  this.isShowing = false;
755
- }, delay);
1130
+ this.retryCount = 0;
1131
+ }
756
1132
  }
757
1133
  renderPopup() {
758
1134
  if (!this.currentAd)
759
1135
  return;
760
1136
  const renderStartTime = Date.now();
761
- // Don't track impressions for dummy ads
762
- if (!this.currentAd.isDummy) {
763
- // Track impression immediately when popup is rendered (not waiting for image load)
764
- // This ensures impression is tracked even if user closes popup quickly
1137
+ let impressionTracked = false;
1138
+ const trackPopupImpression = (rendered, renderTime) => {
1139
+ if (impressionTracked || !this.currentAd || this.currentAd.isDummy)
1140
+ return;
1141
+ impressionTracked = true;
765
1142
  this.sovads._trackEvent('IMPRESSION', this.currentAd.id, this.currentAd.campaignId, {
766
- rendered: true,
1143
+ rendered,
767
1144
  viewportVisible: true,
768
- renderTime: 0 // Will be updated when image loads
1145
+ renderTime,
769
1146
  });
770
- // Log interaction
771
1147
  this.sovads.logInteraction('IMPRESSION', {
772
1148
  adId: this.currentAd.id,
773
1149
  campaignId: this.currentAd.campaignId,
774
1150
  elementType: 'POPUP',
775
- metadata: { renderTime: 0 },
1151
+ metadata: { renderTime, rendered },
776
1152
  });
777
- }
778
- // Create overlay
779
- const overlay = document.createElement('div');
780
- overlay.className = 'sovads-popup-overlay';
781
- overlay.style.cssText = `
1153
+ };
1154
+ // Create non-blocking sticky container
1155
+ const wrapper = document.createElement('div');
1156
+ wrapper.className = 'sovads-popup-overlay';
1157
+ wrapper.style.cssText = `
782
1158
  position: fixed;
783
- top: 0;
784
- left: 0;
785
- width: 100%;
786
- height: 100%;
787
- background: rgba(0, 0, 0, 0.5);
1159
+ right: 16px;
1160
+ bottom: 16px;
1161
+ width: min(360px, calc(100vw - 24px));
788
1162
  z-index: 10000;
789
- display: flex;
790
- align-items: center;
791
- justify-content: center;
792
1163
  `;
793
1164
  // Create popup
794
1165
  this.popupElement = document.createElement('div');
795
1166
  this.popupElement.style.cssText = `
796
1167
  background: white;
797
1168
  border-radius: 12px;
798
- padding: 20px;
799
- max-width: 400px;
1169
+ padding: 14px;
1170
+ max-width: 360px;
800
1171
  position: relative;
801
1172
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
1173
+ opacity: 0;
1174
+ transition: opacity 0.2s ease;
802
1175
  `;
1176
+ // SovAds logo badge in small left corner
1177
+ const logoBadge = document.createElement('div');
1178
+ logoBadge.style.cssText = `
1179
+ position: absolute;
1180
+ top: 8px;
1181
+ left: 12px;
1182
+ width: 24px;
1183
+ height: 24px;
1184
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1185
+ border-radius: 4px;
1186
+ display: flex;
1187
+ align-items: center;
1188
+ justify-content: center;
1189
+ font-size: 10px;
1190
+ font-weight: bold;
1191
+ color: white;
1192
+ z-index: 1;
1193
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
1194
+ `;
1195
+ logoBadge.textContent = 'SA';
1196
+ logoBadge.title = 'SovAds';
803
1197
  // Close button
804
1198
  const closeBtn = document.createElement('button');
805
1199
  closeBtn.innerHTML = '×';
@@ -812,10 +1206,24 @@ export class Popup {
812
1206
  font-size: 24px;
813
1207
  cursor: pointer;
814
1208
  color: #666;
1209
+ z-index: 2;
815
1210
  `;
816
1211
  closeBtn.addEventListener('click', () => {
817
1212
  this.hide();
818
1213
  });
1214
+ // Add "Ad" message text below logo
1215
+ const adLabel = document.createElement('div');
1216
+ adLabel.style.cssText = `
1217
+ position: absolute;
1218
+ top: 36px;
1219
+ left: 12px;
1220
+ font-size: 9px;
1221
+ color: #999;
1222
+ font-weight: 500;
1223
+ text-transform: uppercase;
1224
+ letter-spacing: 0.5px;
1225
+ `;
1226
+ adLabel.textContent = 'Ad';
819
1227
  // Handle dummy ads
820
1228
  if (this.currentAd.isDummy) {
821
1229
  const dummyContent = document.createElement('div');
@@ -844,10 +1252,12 @@ export class Popup {
844
1252
  dummyContent.appendChild(img);
845
1253
  dummyContent.appendChild(message);
846
1254
  dummyContent.appendChild(link);
1255
+ this.popupElement.appendChild(logoBadge);
1256
+ this.popupElement.appendChild(adLabel);
847
1257
  this.popupElement.appendChild(closeBtn);
848
1258
  this.popupElement.appendChild(dummyContent);
849
- overlay.appendChild(this.popupElement);
850
- document.body.appendChild(overlay);
1259
+ wrapper.appendChild(this.popupElement);
1260
+ document.body.appendChild(wrapper);
851
1261
  // Auto close after 10 seconds
852
1262
  setTimeout(() => {
853
1263
  this.hide();
@@ -856,15 +1266,14 @@ export class Popup {
856
1266
  }
857
1267
  const mediaType = this.currentAd.mediaType === 'video' ? 'video' : 'image';
858
1268
  const handleMediaError = () => {
1269
+ if (this.popupElement) {
1270
+ this.popupElement.style.opacity = '1';
1271
+ }
859
1272
  if (this.sovads.getConfig().debug) {
860
1273
  console.warn(`Failed to load popup ad media: ${this.currentAd.bannerUrl}`);
861
1274
  }
862
1275
  const renderTime = Date.now() - renderStartTime;
863
- this.sovads._trackEvent('IMPRESSION', this.currentAd.id, this.currentAd.campaignId, {
864
- rendered: false,
865
- viewportVisible: true,
866
- renderTime
867
- });
1276
+ trackPopupImpression(false, renderTime);
868
1277
  };
869
1278
  let mediaElement;
870
1279
  if (mediaType === 'video') {
@@ -877,7 +1286,11 @@ export class Popup {
877
1286
  video.controls = true;
878
1287
  video.style.cssText = 'width: 100%; height: auto; border-radius: 8px; cursor: pointer;';
879
1288
  video.addEventListener('loadeddata', () => {
1289
+ if (this.popupElement) {
1290
+ this.popupElement.style.opacity = '1';
1291
+ }
880
1292
  const renderTime = Date.now() - renderStartTime;
1293
+ trackPopupImpression(true, renderTime);
881
1294
  if (this.sovads.getConfig().debug) {
882
1295
  console.log(`Popup ad video loaded in ${renderTime}ms`);
883
1296
  }
@@ -891,7 +1304,11 @@ export class Popup {
891
1304
  img.alt = this.currentAd.description;
892
1305
  img.style.cssText = 'width: 100%; height: auto; border-radius: 8px; cursor: pointer;';
893
1306
  img.addEventListener('load', () => {
1307
+ if (this.popupElement) {
1308
+ this.popupElement.style.opacity = '1';
1309
+ }
894
1310
  const renderTime = Date.now() - renderStartTime;
1311
+ trackPopupImpression(true, renderTime);
895
1312
  if (this.sovads.getConfig().debug) {
896
1313
  console.log(`Popup ad image loaded in ${renderTime}ms`);
897
1314
  }
@@ -899,27 +1316,53 @@ export class Popup {
899
1316
  img.addEventListener('error', handleMediaError);
900
1317
  mediaElement = img;
901
1318
  }
902
- mediaElement.style.cursor = 'pointer';
903
- mediaElement.addEventListener('click', () => {
1319
+ const handleClickThrough = () => {
904
1320
  this.sovads._trackEvent('CLICK', this.currentAd.id, this.currentAd.campaignId, {
905
1321
  rendered: true,
906
1322
  viewportVisible: true,
907
1323
  renderTime: Date.now() - renderStartTime
908
1324
  });
909
- // Log interaction
910
1325
  this.sovads.logInteraction('CLICK', {
911
1326
  adId: this.currentAd.id,
912
1327
  campaignId: this.currentAd.campaignId,
913
1328
  elementType: 'POPUP',
914
1329
  metadata: { renderTime: Date.now() - renderStartTime },
915
1330
  });
916
- window.open(this.currentAd.targetUrl, '_blank', 'noopener,noreferrer');
1331
+ window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
917
1332
  this.hide();
918
- });
1333
+ };
1334
+ if (mediaType === 'video') {
1335
+ mediaElement.style.cursor = 'default';
1336
+ }
1337
+ else {
1338
+ mediaElement.style.cursor = 'pointer';
1339
+ mediaElement.addEventListener('click', handleClickThrough);
1340
+ }
1341
+ this.popupElement.appendChild(logoBadge);
1342
+ this.popupElement.appendChild(adLabel);
919
1343
  this.popupElement.appendChild(closeBtn);
920
1344
  this.popupElement.appendChild(mediaElement);
921
- overlay.appendChild(this.popupElement);
922
- document.body.appendChild(overlay);
1345
+ if (mediaType === 'video') {
1346
+ const ctaButton = document.createElement('button');
1347
+ ctaButton.type = 'button';
1348
+ ctaButton.textContent = 'Learn more';
1349
+ ctaButton.style.cssText = `
1350
+ width: 100%;
1351
+ margin-top: 10px;
1352
+ border: none;
1353
+ border-radius: 6px;
1354
+ background: #111;
1355
+ color: #fff;
1356
+ font-size: 12px;
1357
+ font-weight: 600;
1358
+ padding: 10px 12px;
1359
+ cursor: pointer;
1360
+ `;
1361
+ ctaButton.addEventListener('click', handleClickThrough);
1362
+ this.popupElement.appendChild(ctaButton);
1363
+ }
1364
+ wrapper.appendChild(this.popupElement);
1365
+ document.body.appendChild(wrapper);
923
1366
  // Auto close after 10 seconds
924
1367
  setTimeout(() => {
925
1368
  this.hide();
@@ -927,32 +1370,207 @@ export class Popup {
927
1370
  }
928
1371
  hide() {
929
1372
  const overlay = document.querySelector('.sovads-popup-overlay');
930
- if (overlay && overlay.isConnected) {
1373
+ if (overlay) {
931
1374
  try {
932
- overlay.remove();
1375
+ // Check if element is still connected to DOM before removing
1376
+ if (overlay.isConnected) {
1377
+ // Use remove() method which is safer and doesn't require parentNode
1378
+ overlay.remove();
1379
+ }
933
1380
  }
934
1381
  catch (error) {
935
1382
  // Element may have already been removed by React or another process
1383
+ // Silently fail - this is expected in some cases
936
1384
  if (this.sovads.getConfig().debug) {
937
1385
  console.warn('Could not remove popup overlay:', error);
938
1386
  }
939
1387
  }
940
1388
  }
1389
+ this.popupElement = null;
1390
+ this.currentAd = null;
1391
+ }
1392
+ }
1393
+ // BottomBar Component
1394
+ export class BottomBar {
1395
+ constructor(sovads) {
1396
+ this.barElement = null;
1397
+ this.currentAd = null;
1398
+ this.isVisible = false;
1399
+ this.retryCount = 0;
1400
+ this.maxRetries = 3;
1401
+ this.sovads = sovads;
1402
+ }
1403
+ async show(consumerId) {
1404
+ if (this.isVisible) {
1405
+ if (this.sovads.getConfig().debug) {
1406
+ console.warn('BottomBar already visible');
1407
+ }
1408
+ return;
1409
+ }
1410
+ try {
1411
+ this.currentAd = await this.sovads.loadAd({
1412
+ consumerId,
1413
+ placement: 'bottom-bar',
1414
+ size: 'full-width',
1415
+ });
1416
+ if (!this.currentAd) {
1417
+ if (this.retryCount < this.maxRetries) {
1418
+ this.retryCount++;
1419
+ await new Promise(resolve => setTimeout(resolve, 1000 * this.retryCount));
1420
+ return this.show(consumerId);
1421
+ }
1422
+ if (this.sovads.getConfig().debug) {
1423
+ console.log('No bottom‑bar ad available after retries');
1424
+ }
1425
+ this.retryCount = 0;
1426
+ return;
1427
+ }
1428
+ this.retryCount = 0;
1429
+ this.renderBar();
1430
+ this.isVisible = true;
1431
+ }
1432
+ catch (error) {
1433
+ if (this.sovads.getConfig().debug) {
1434
+ console.error('Error loading bottom bar ad:', error);
1435
+ }
1436
+ }
1437
+ }
1438
+ renderBar() {
1439
+ if (!this.currentAd)
1440
+ return;
1441
+ const renderStart = Date.now();
1442
+ let impressionTracked = false;
1443
+ const trackImp = (rendered, renderTime) => {
1444
+ if (impressionTracked || !this.currentAd || this.currentAd.isDummy)
1445
+ return;
1446
+ impressionTracked = true;
1447
+ this.sovads._trackEvent('IMPRESSION', this.currentAd.id, this.currentAd.campaignId, {
1448
+ rendered,
1449
+ viewportVisible: true,
1450
+ renderTime,
1451
+ });
1452
+ };
1453
+ // wrapper fixed bottom
1454
+ const wrapper = document.createElement('div');
1455
+ wrapper.className = 'sovads-bottom-bar';
1456
+ wrapper.style.cssText = `
1457
+ position: fixed;
1458
+ left: 0;
1459
+ bottom: 0;
1460
+ width: 100%;
1461
+ z-index: 10000;
1462
+ display: flex;
1463
+ justify-content: center;
1464
+ background: rgba(255,255,255,0.95);
1465
+ box-shadow: 0 -2px 6px rgba(0,0,0,0.2);
1466
+ `;
1467
+ const bar = document.createElement('div');
1468
+ bar.style.cssText = `
1469
+ max-width: 720px;
1470
+ width: 100%;
1471
+ position: relative;
1472
+ padding: 8px;
1473
+ cursor: pointer;
1474
+ `;
1475
+ // close button
1476
+ const closeBtn = document.createElement('button');
1477
+ closeBtn.innerHTML = '×';
1478
+ closeBtn.style.cssText = `
1479
+ position: absolute;
1480
+ right: 8px;
1481
+ top: 8px;
1482
+ background: none;
1483
+ border: none;
1484
+ font-size: 20px;
1485
+ cursor: pointer;
1486
+ color: #666;
1487
+ `;
1488
+ closeBtn.addEventListener('click', (e) => {
1489
+ e.stopPropagation();
1490
+ this.hide();
1491
+ });
1492
+ // create media element
1493
+ const mediaType = this.currentAd.mediaType === 'video' ? 'video' : 'image';
1494
+ let mediaEl;
1495
+ if (mediaType === 'video') {
1496
+ const video = document.createElement('video');
1497
+ video.src = this.currentAd.bannerUrl;
1498
+ video.muted = true;
1499
+ video.autoplay = true;
1500
+ video.loop = true;
1501
+ video.playsInline = true;
1502
+ video.controls = true;
1503
+ video.style.cssText = 'width:100%;height:auto;';
1504
+ video.addEventListener('loadeddata', () => {
1505
+ const rt = Date.now() - renderStart;
1506
+ trackImp(true, rt);
1507
+ }, { once: true });
1508
+ video.addEventListener('error', () => {
1509
+ const rt = Date.now() - renderStart;
1510
+ trackImp(false, rt);
1511
+ }, { once: true });
1512
+ mediaEl = video;
1513
+ }
1514
+ else {
1515
+ const img = document.createElement('img');
1516
+ img.src = this.currentAd.bannerUrl;
1517
+ img.alt = this.currentAd.description;
1518
+ img.style.cssText = 'width:100%;height:auto;';
1519
+ img.addEventListener('load', () => {
1520
+ const rt = Date.now() - renderStart;
1521
+ trackImp(true, rt);
1522
+ });
1523
+ img.addEventListener('error', () => {
1524
+ const rt = Date.now() - renderStart;
1525
+ trackImp(false, rt);
1526
+ });
1527
+ mediaEl = img;
1528
+ }
1529
+ const handleClick = () => {
1530
+ if (!this.currentAd)
1531
+ return;
1532
+ this.sovads._trackEvent('CLICK', this.currentAd.id, this.currentAd.campaignId, {
1533
+ rendered: true,
1534
+ viewportVisible: true,
1535
+ renderTime: Date.now() - renderStart,
1536
+ });
1537
+ window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
1538
+ this.hide();
1539
+ };
1540
+ bar.appendChild(closeBtn);
1541
+ bar.appendChild(mediaEl);
1542
+ bar.addEventListener('click', handleClick);
1543
+ wrapper.appendChild(bar);
1544
+ document.body.appendChild(wrapper);
1545
+ this.barElement = wrapper;
1546
+ }
1547
+ hide() {
1548
+ if (this.barElement && this.barElement.isConnected) {
1549
+ this.barElement.remove();
1550
+ }
1551
+ this.barElement = null;
1552
+ this.currentAd = null;
1553
+ this.isVisible = false;
941
1554
  }
942
1555
  }
943
1556
  // Sidebar Component
944
1557
  export class Sidebar {
945
- constructor(sovads, containerId) {
1558
+ constructor(sovads, containerId, slotConfig = {}) {
946
1559
  this.currentAd = null;
947
1560
  this.renderStartTime = 0;
948
1561
  this.hasTrackedImpression = false;
949
1562
  this.isRendering = false;
1563
+ this.refreshTimer = null;
1564
+ this.lastAdId = null;
1565
+ this.retryCount = 0;
1566
+ this.maxRetries = 3;
950
1567
  this.sovads = sovads;
951
1568
  this.containerId = containerId;
1569
+ this.slotConfig = slotConfig;
952
1570
  }
953
- async render(consumerId) {
1571
+ async render(consumerId, forceRefresh = false) {
954
1572
  // Prevent concurrent renders
955
- if (this.isRendering) {
1573
+ if (this.isRendering && !forceRefresh) {
956
1574
  if (this.sovads.getConfig().debug) {
957
1575
  console.warn(`Sidebar render already in progress for ${this.containerId}`);
958
1576
  }
@@ -966,8 +1584,32 @@ export class Sidebar {
966
1584
  this.isRendering = false;
967
1585
  return;
968
1586
  }
1587
+ // Lazy loading: wait for container to be in viewport
1588
+ if (this.sovads.getConfig().lazyLoad && !forceRefresh) {
1589
+ const isInViewport = await this.checkViewport(container);
1590
+ if (!isInViewport) {
1591
+ this.setupLazyLoadObserver(container, consumerId);
1592
+ this.isRendering = false;
1593
+ return;
1594
+ }
1595
+ }
969
1596
  this.renderStartTime = Date.now();
970
- this.currentAd = await this.sovads.loadAd(consumerId);
1597
+ this.currentAd = await this.sovads.loadAd({
1598
+ consumerId,
1599
+ placement: this.slotConfig.placementId || 'sidebar',
1600
+ size: this.slotConfig.size,
1601
+ });
1602
+ this.hasTrackedImpression = false;
1603
+ // Skip if same ad (rotation disabled or same ad returned)
1604
+ if (!forceRefresh && this.lastAdId === this.currentAd?.id && this.sovads.getConfig().rotationEnabled) {
1605
+ if (this.sovads.getConfig().debug) {
1606
+ console.log('Same ad returned, skipping render');
1607
+ }
1608
+ this.isRendering = false;
1609
+ return;
1610
+ }
1611
+ this.lastAdId = this.currentAd?.id || null;
1612
+ this.retryCount = 0;
971
1613
  if (!this.currentAd) {
972
1614
  container.innerHTML = '<div class="sovads-no-ad">No ads available</div>';
973
1615
  this.isRendering = false;
@@ -975,6 +1617,7 @@ export class Sidebar {
975
1617
  }
976
1618
  // Handle dummy ads for unregistered sites
977
1619
  if (this.currentAd.isDummy) {
1620
+ container.innerHTML = '';
978
1621
  const dummyElement = document.createElement('div');
979
1622
  dummyElement.className = 'sovads-sidebar-dummy';
980
1623
  dummyElement.setAttribute('data-ad-id', this.currentAd.id);
@@ -1021,18 +1664,20 @@ export class Sidebar {
1021
1664
  return;
1022
1665
  }
1023
1666
  const adElement = document.createElement('div');
1667
+ container.innerHTML = '';
1024
1668
  adElement.className = 'sovads-sidebar';
1025
1669
  adElement.setAttribute('data-ad-id', this.currentAd.id);
1670
+ const mediaType = this.currentAd.mediaType === 'video' ? 'video' : 'image';
1026
1671
  adElement.style.cssText = `
1027
1672
  background: #f8f9fa;
1028
1673
  border: 1px solid #e9ecef;
1029
1674
  border-radius: 8px;
1030
1675
  padding: 15px;
1031
1676
  margin-bottom: 15px;
1032
- cursor: pointer;
1677
+ cursor: ${mediaType === 'video' ? 'default' : 'pointer'};
1033
1678
  transition: all 0.2s ease;
1679
+ opacity: 0;
1034
1680
  `;
1035
- const mediaType = this.currentAd.mediaType === 'video' ? 'video' : 'image';
1036
1681
  const handleVisibilityTracking = (renderInfo) => {
1037
1682
  this.sovads.setupRenderObserver(adElement, this.currentAd.id, (isVisible) => {
1038
1683
  renderInfo.viewportVisible = isVisible;
@@ -1043,6 +1688,7 @@ export class Sidebar {
1043
1688
  });
1044
1689
  };
1045
1690
  const handleRenderSuccess = () => {
1691
+ adElement.style.opacity = '1';
1046
1692
  const renderTime = Date.now() - this.renderStartTime;
1047
1693
  handleVisibilityTracking({
1048
1694
  rendered: true,
@@ -1051,6 +1697,7 @@ export class Sidebar {
1051
1697
  });
1052
1698
  };
1053
1699
  const handleRenderError = () => {
1700
+ adElement.style.opacity = '1';
1054
1701
  if (this.sovads.getConfig().debug) {
1055
1702
  console.warn(`Failed to load sidebar ad media: ${this.currentAd.bannerUrl}`);
1056
1703
  }
@@ -1083,14 +1730,12 @@ export class Sidebar {
1083
1730
  img.addEventListener('error', handleRenderError, { once: true });
1084
1731
  mediaElement = img;
1085
1732
  }
1086
- // Add click handler
1087
- adElement.addEventListener('click', () => {
1733
+ const handleClickThrough = () => {
1088
1734
  this.sovads._trackEvent('CLICK', this.currentAd.id, this.currentAd.campaignId, {
1089
1735
  rendered: true,
1090
1736
  viewportVisible: true,
1091
1737
  renderTime: Date.now() - this.renderStartTime
1092
1738
  });
1093
- // Log interaction
1094
1739
  this.sovads.logInteraction('CLICK', {
1095
1740
  adId: this.currentAd.id,
1096
1741
  campaignId: this.currentAd.campaignId,
@@ -1098,7 +1743,7 @@ export class Sidebar {
1098
1743
  metadata: { renderTime: Date.now() - this.renderStartTime },
1099
1744
  });
1100
1745
  window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
1101
- });
1746
+ };
1102
1747
  // Add hover effect
1103
1748
  adElement.addEventListener('mouseenter', () => {
1104
1749
  adElement.style.background = '#e9ecef';
@@ -1108,17 +1753,116 @@ export class Sidebar {
1108
1753
  adElement.style.background = '#f8f9fa';
1109
1754
  adElement.style.transform = 'translateY(0)';
1110
1755
  });
1111
- mediaElement.style.cursor = 'pointer';
1112
- adElement.appendChild(mediaElement);
1756
+ mediaElement.style.cursor = mediaType === 'video' ? 'default' : 'pointer';
1757
+ if (mediaType === 'video') {
1758
+ const ctaButton = document.createElement('button');
1759
+ ctaButton.type = 'button';
1760
+ ctaButton.textContent = 'Learn more';
1761
+ ctaButton.style.cssText = `
1762
+ width: 100%;
1763
+ border: none;
1764
+ margin-top: 8px;
1765
+ background: #111;
1766
+ color: #fff;
1767
+ font-size: 12px;
1768
+ font-weight: 600;
1769
+ padding: 8px 12px;
1770
+ border-radius: 6px;
1771
+ cursor: pointer;
1772
+ `;
1773
+ ctaButton.addEventListener('click', handleClickThrough);
1774
+ adElement.appendChild(mediaElement);
1775
+ adElement.appendChild(ctaButton);
1776
+ }
1777
+ else {
1778
+ adElement.addEventListener('click', handleClickThrough);
1779
+ adElement.appendChild(mediaElement);
1780
+ }
1113
1781
  container.appendChild(adElement);
1782
+ // Set up auto-refresh if enabled
1783
+ this.setupAutoRefresh(consumerId);
1784
+ }
1785
+ catch (error) {
1786
+ // Retry logic on error
1787
+ if (this.retryCount < this.maxRetries) {
1788
+ this.retryCount++;
1789
+ if (this.sovads.getConfig().debug) {
1790
+ console.warn(`Sidebar render failed, retrying (${this.retryCount}/${this.maxRetries})...`);
1791
+ }
1792
+ await new Promise(resolve => setTimeout(resolve, 1000 * this.retryCount));
1793
+ this.isRendering = false;
1794
+ return this.render(consumerId, true);
1795
+ }
1796
+ else {
1797
+ const container = document.getElementById(this.containerId);
1798
+ if (container) {
1799
+ container.innerHTML = '<div class="sovads-error" style="padding: 10px; text-align: center; color: #666; font-size: 12px;">Ad temporarily unavailable</div>';
1800
+ }
1801
+ if (this.sovads.getConfig().debug) {
1802
+ console.error('Sidebar render failed after retries:', error);
1803
+ }
1804
+ }
1114
1805
  }
1115
1806
  finally {
1116
1807
  this.isRendering = false;
1117
1808
  }
1118
1809
  }
1810
+ async checkViewport(element) {
1811
+ return new Promise((resolve) => {
1812
+ if (typeof IntersectionObserver === 'undefined') {
1813
+ resolve(true);
1814
+ return;
1815
+ }
1816
+ const observer = new IntersectionObserver((entries) => {
1817
+ entries.forEach((entry) => {
1818
+ if (entry.isIntersecting) {
1819
+ observer.disconnect();
1820
+ resolve(true);
1821
+ }
1822
+ });
1823
+ }, { rootMargin: '50px' });
1824
+ observer.observe(element);
1825
+ setTimeout(() => {
1826
+ observer.disconnect();
1827
+ resolve(true);
1828
+ }, 5000);
1829
+ });
1830
+ }
1831
+ setupLazyLoadObserver(container, consumerId) {
1832
+ if (typeof IntersectionObserver === 'undefined') {
1833
+ this.render(consumerId);
1834
+ return;
1835
+ }
1836
+ const observer = new IntersectionObserver((entries) => {
1837
+ entries.forEach((entry) => {
1838
+ if (entry.isIntersecting && !this.isRendering) {
1839
+ observer.disconnect();
1840
+ this.render(consumerId);
1841
+ }
1842
+ });
1843
+ }, { rootMargin: '50px' });
1844
+ observer.observe(container);
1845
+ }
1846
+ setupAutoRefresh(consumerId) {
1847
+ if (this.refreshTimer) {
1848
+ clearInterval(this.refreshTimer);
1849
+ }
1850
+ const refreshInterval = this.sovads.getConfig().refreshInterval || 0;
1851
+ if (refreshInterval > 0) {
1852
+ this.refreshTimer = window.setInterval(() => {
1853
+ if (!this.isRendering) {
1854
+ this.render(consumerId, true);
1855
+ }
1856
+ }, refreshInterval * 1000);
1857
+ }
1858
+ }
1859
+ destroy() {
1860
+ if (this.refreshTimer) {
1861
+ clearInterval(this.refreshTimer);
1862
+ this.refreshTimer = null;
1863
+ }
1864
+ }
1119
1865
  }
1120
- // Export main SovAds class
1121
- export { SovAds };
1122
1866
  // Default export for easy importing
1123
1867
  export default SovAds;
1124
1868
  //# sourceMappingURL=index.js.map