sovads-sdk 1.0.8 → 1.1.0
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/README.md +232 -96
- package/dist/index.d.ts +483 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2025 -81
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// SovAds SDK - Modular Ad Network Integration
|
|
2
2
|
// Usage: import { SovAds, Banner, Popup, Sidebar } from '@sovads/sdk'
|
|
3
|
+
/** Runtime SDK version. Kept in sync with `sdk/package.json#version`.
|
|
4
|
+
* Sent as `X-SovAds-SDK-Version` on signed tracking requests and exported
|
|
5
|
+
* so host pages can log / gate on it. */
|
|
6
|
+
export const SDK_VERSION = '1.1.0';
|
|
3
7
|
export class SovAds {
|
|
4
8
|
constructor(config = {}) {
|
|
5
9
|
this.components = new Map();
|
|
@@ -8,10 +12,20 @@ export class SovAds {
|
|
|
8
12
|
this.debugLoggingEnabled = false;
|
|
9
13
|
this.adTrackingTokens = new Map();
|
|
10
14
|
this.walletAddress = null;
|
|
15
|
+
this.unitListeners = new Map();
|
|
16
|
+
/** Subscribers notified whenever the viewer's wallet identity becomes known
|
|
17
|
+
* or changes. Used by `renderAttachedCtas` to lazy-mount CTAs once the host
|
|
18
|
+
* page connects a wallet. */
|
|
19
|
+
this.identityListeners = new Set();
|
|
11
20
|
this.config = {
|
|
12
21
|
apiUrl: 'https://ads.sovseas.xyz',
|
|
13
22
|
debug: false,
|
|
14
|
-
|
|
23
|
+
// Phase 6: refresh is OFF by default. Auto-rotating banners on a
|
|
24
|
+
// 30-second cadence is the single biggest source of "click? what
|
|
25
|
+
// click?" disputes \u2014 the ad that recorded the impression isn't the
|
|
26
|
+
// ad the viewer eventually clicked. Publishers who actually want
|
|
27
|
+
// rotation can still opt in by setting this explicitly.
|
|
28
|
+
refreshInterval: 0,
|
|
15
29
|
lazyLoad: true,
|
|
16
30
|
rotationEnabled: true,
|
|
17
31
|
popupMinIntervalMinutes: 30,
|
|
@@ -36,7 +50,9 @@ export class SovAds {
|
|
|
36
50
|
identify(walletAddress) {
|
|
37
51
|
if (!walletAddress || typeof walletAddress !== 'string')
|
|
38
52
|
return;
|
|
39
|
-
|
|
53
|
+
const next = walletAddress.toLowerCase();
|
|
54
|
+
const changed = next !== this.walletAddress;
|
|
55
|
+
this.walletAddress = next;
|
|
40
56
|
try {
|
|
41
57
|
if (typeof window !== 'undefined' && window.localStorage) {
|
|
42
58
|
localStorage.setItem('sovads_wallet_address', this.walletAddress);
|
|
@@ -48,6 +64,34 @@ export class SovAds {
|
|
|
48
64
|
if (this.config.debug) {
|
|
49
65
|
console.log('SovAds Identity set:', this.walletAddress);
|
|
50
66
|
}
|
|
67
|
+
if (changed) {
|
|
68
|
+
this.notifyIdentityListeners();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Subscribe to wallet-identity changes. Fires once immediately if a wallet
|
|
73
|
+
* is already known, then on every subsequent `identify()` call that changes
|
|
74
|
+
* the address. Returns an unsubscribe function.
|
|
75
|
+
*/
|
|
76
|
+
onIdentify(cb) {
|
|
77
|
+
this.identityListeners.add(cb);
|
|
78
|
+
// Fire synchronously if we already have an address \u2014 lets callers treat
|
|
79
|
+
// "already connected" and "connects later" the same way.
|
|
80
|
+
if (this.walletAddress) {
|
|
81
|
+
try {
|
|
82
|
+
cb(this.walletAddress);
|
|
83
|
+
}
|
|
84
|
+
catch { /* swallow */ }
|
|
85
|
+
}
|
|
86
|
+
return () => { this.identityListeners.delete(cb); };
|
|
87
|
+
}
|
|
88
|
+
notifyIdentityListeners() {
|
|
89
|
+
for (const cb of this.identityListeners) {
|
|
90
|
+
try {
|
|
91
|
+
cb(this.walletAddress);
|
|
92
|
+
}
|
|
93
|
+
catch { /* swallow */ }
|
|
94
|
+
}
|
|
51
95
|
}
|
|
52
96
|
loadPersistedIdentity() {
|
|
53
97
|
try {
|
|
@@ -307,6 +351,12 @@ export class SovAds {
|
|
|
307
351
|
}
|
|
308
352
|
inferMediaTypeFromUrl(url) {
|
|
309
353
|
const value = (url || '').toLowerCase();
|
|
354
|
+
// Streaming URLs (YouTube/Vimeo/TikTok) are rendered via iframe by the
|
|
355
|
+
// Banner/Popup renderers \u2014 treat them as 'video' here so downstream code
|
|
356
|
+
// knows not to try a hover/click handler, but the actual <iframe> swap
|
|
357
|
+
// happens at render time via toStreamingEmbed().
|
|
358
|
+
if (toStreamingEmbed(value))
|
|
359
|
+
return 'video';
|
|
310
360
|
const videoExts = ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.m3u8'];
|
|
311
361
|
return videoExts.some((ext) => value.includes(ext)) ? 'video' : 'image';
|
|
312
362
|
}
|
|
@@ -356,6 +406,11 @@ export class SovAds {
|
|
|
356
406
|
if (wallet) {
|
|
357
407
|
url.searchParams.append('wallet', wallet);
|
|
358
408
|
}
|
|
409
|
+
// Opt-in: ask for attached CTA tasks + serve out-of-budget banners
|
|
410
|
+
// (the viewer can still earn via attached CTAs through the points fallback).
|
|
411
|
+
if (options.attached) {
|
|
412
|
+
url.searchParams.append('attached', '1');
|
|
413
|
+
}
|
|
359
414
|
const endpoint = url.toString();
|
|
360
415
|
const response = await this.fetchWithRetry(endpoint);
|
|
361
416
|
const duration = Date.now() - startTime;
|
|
@@ -553,7 +608,11 @@ export class SovAds {
|
|
|
553
608
|
}
|
|
554
609
|
const response = await fetch(webhookUrl, {
|
|
555
610
|
method: 'POST',
|
|
556
|
-
headers: {
|
|
611
|
+
headers: {
|
|
612
|
+
'Content-Type': 'application/json',
|
|
613
|
+
'Cache-Control': 'no-cache',
|
|
614
|
+
'X-SovAds-SDK-Version': SDK_VERSION,
|
|
615
|
+
},
|
|
557
616
|
body: envelope,
|
|
558
617
|
keepalive: true,
|
|
559
618
|
});
|
|
@@ -675,6 +734,89 @@ export class SovAds {
|
|
|
675
734
|
getConfig() {
|
|
676
735
|
return this.config;
|
|
677
736
|
}
|
|
737
|
+
/**
|
|
738
|
+
* Submit a CTA-task completion (POLL / VISIT_URL / SIGN_MESSAGE) on behalf
|
|
739
|
+
* of the current viewer. Uses plain fetch (no retry) to avoid double-submitting
|
|
740
|
+
* an idempotent task; rate-limit/dedupe is enforced server-side.
|
|
741
|
+
*/
|
|
742
|
+
async submitTaskCompletion(params) {
|
|
743
|
+
try {
|
|
744
|
+
const endpoint = `${this.config.apiUrl}/api/tasks/complete`;
|
|
745
|
+
const body = {
|
|
746
|
+
taskId: params.taskId,
|
|
747
|
+
wallet: this.walletAddress || undefined,
|
|
748
|
+
fingerprint: this.fingerprint,
|
|
749
|
+
proof: params.proof || {},
|
|
750
|
+
};
|
|
751
|
+
const response = await fetch(endpoint, {
|
|
752
|
+
method: 'POST',
|
|
753
|
+
headers: { 'Content-Type': 'application/json' },
|
|
754
|
+
body: JSON.stringify(body),
|
|
755
|
+
});
|
|
756
|
+
let data = null;
|
|
757
|
+
try {
|
|
758
|
+
data = (await response.json());
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
// server returned non-JSON; treat as error
|
|
762
|
+
}
|
|
763
|
+
return {
|
|
764
|
+
ok: response.ok,
|
|
765
|
+
status: response.status,
|
|
766
|
+
awarded: data?.awarded,
|
|
767
|
+
error: data?.error,
|
|
768
|
+
data,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
catch (err) {
|
|
772
|
+
return {
|
|
773
|
+
ok: false,
|
|
774
|
+
status: 0,
|
|
775
|
+
error: err instanceof Error ? err.message : 'submit failed',
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Public accessor for the current wallet address (read-only).
|
|
781
|
+
* CTA renderers use this to suppress wallet-bound rewards on anonymous viewers.
|
|
782
|
+
*/
|
|
783
|
+
getWalletAddress() {
|
|
784
|
+
return this.walletAddress;
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Fetch this viewer's completion / eligibility status for every active task
|
|
788
|
+
* of a campaign. Used by the attached-CTA panel to mark already-completed
|
|
789
|
+
* tasks with a \u2713 badge after the wallet connects. Returns a Map keyed by
|
|
790
|
+
* taskId so callers can do O(1) lookups; tasks missing from the map are
|
|
791
|
+
* assumed eligible.
|
|
792
|
+
*/
|
|
793
|
+
async fetchTaskStatuses(campaignId) {
|
|
794
|
+
const out = new Map();
|
|
795
|
+
if (!campaignId)
|
|
796
|
+
return out;
|
|
797
|
+
try {
|
|
798
|
+
const url = new URL(`${this.config.apiUrl}/api/tasks/status`);
|
|
799
|
+
url.searchParams.set('campaignId', campaignId);
|
|
800
|
+
if (this.walletAddress)
|
|
801
|
+
url.searchParams.set('wallet', this.walletAddress);
|
|
802
|
+
if (this.fingerprint)
|
|
803
|
+
url.searchParams.set('fingerprint', this.fingerprint);
|
|
804
|
+
const response = await fetch(url.toString(), { method: 'GET' });
|
|
805
|
+
if (!response.ok)
|
|
806
|
+
return out;
|
|
807
|
+
const json = (await response.json());
|
|
808
|
+
const tasks = Array.isArray(json?.tasks) ? json.tasks : [];
|
|
809
|
+
for (const t of tasks) {
|
|
810
|
+
if (t && typeof t.id === 'string')
|
|
811
|
+
out.set(t.id, t);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
catch (err) {
|
|
815
|
+
if (this.config.debug)
|
|
816
|
+
console.warn('[SovAds] fetchTaskStatuses failed', err);
|
|
817
|
+
}
|
|
818
|
+
return out;
|
|
819
|
+
}
|
|
678
820
|
/**
|
|
679
821
|
* Log interaction (public method for components)
|
|
680
822
|
*/
|
|
@@ -722,7 +864,927 @@ export class SovAds {
|
|
|
722
864
|
destroy() {
|
|
723
865
|
this.renderObservers.forEach((observer) => observer.disconnect());
|
|
724
866
|
this.renderObservers.clear();
|
|
867
|
+
this.unitListeners.forEach((entry) => {
|
|
868
|
+
window.removeEventListener('message', entry.listener);
|
|
869
|
+
try {
|
|
870
|
+
entry.iframe.remove();
|
|
871
|
+
}
|
|
872
|
+
catch { /* noop */ }
|
|
873
|
+
});
|
|
874
|
+
this.unitListeners.clear();
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Mount a standalone unit iframe (BANNER / POLL / FEEDBACK / SURVEY) into
|
|
878
|
+
* `containerId`. Forwards lifecycle/interaction events from the iframe
|
|
879
|
+
* (via postMessage protocol) to the supplied `onEvent` callback.
|
|
880
|
+
*
|
|
881
|
+
* Returns an object with `unmount()` for cleanup.
|
|
882
|
+
*/
|
|
883
|
+
mountUnit(containerId, options) {
|
|
884
|
+
const container = document.getElementById(containerId);
|
|
885
|
+
if (!container) {
|
|
886
|
+
if (this.config.debug)
|
|
887
|
+
console.error(`SovAds.mountUnit: container #${containerId} not found`);
|
|
888
|
+
return { slotId: '', unmount: () => { } };
|
|
889
|
+
}
|
|
890
|
+
const slotId = options.slotId || `sa-${Math.random().toString(36).slice(2, 10)}`;
|
|
891
|
+
const apiBase = this.config.apiUrl;
|
|
892
|
+
const params = new URLSearchParams();
|
|
893
|
+
params.set('slotId', slotId);
|
|
894
|
+
if (this.siteId)
|
|
895
|
+
params.set('siteId', this.siteId);
|
|
896
|
+
if (options.kind)
|
|
897
|
+
params.set('kind', options.kind);
|
|
898
|
+
if (options.location)
|
|
899
|
+
params.set('location', options.location);
|
|
900
|
+
if (options.placement)
|
|
901
|
+
params.set('placement', options.placement);
|
|
902
|
+
if (options.size)
|
|
903
|
+
params.set('size', options.size);
|
|
904
|
+
const wallet = options.wallet || this.walletAddress;
|
|
905
|
+
if (wallet)
|
|
906
|
+
params.set('wallet', wallet);
|
|
907
|
+
// If siteId wasn't set yet, detect lazily and rebuild the URL once.
|
|
908
|
+
const buildSrc = (sid) => {
|
|
909
|
+
const p = new URLSearchParams(params);
|
|
910
|
+
p.set('siteId', sid);
|
|
911
|
+
return `${apiBase}/r/unit?${p.toString()}`;
|
|
912
|
+
};
|
|
913
|
+
const iframe = document.createElement('iframe');
|
|
914
|
+
iframe.setAttribute('title', 'SovAds Unit');
|
|
915
|
+
iframe.setAttribute('loading', 'lazy');
|
|
916
|
+
// Sandboxed: allow scripts + same-origin (for fetch to apiBase via CORS) +
|
|
917
|
+
// popups for banner link clicks. No top-navigation, no forms, no plugins.
|
|
918
|
+
iframe.setAttribute('sandbox', 'allow-scripts allow-popups allow-popups-to-escape-sandbox');
|
|
919
|
+
iframe.style.width = '100%';
|
|
920
|
+
iframe.style.border = '0';
|
|
921
|
+
iframe.style.display = 'block';
|
|
922
|
+
iframe.style.minHeight = options.minHeight || '120px';
|
|
923
|
+
iframe.style.background = 'transparent';
|
|
924
|
+
container.appendChild(iframe);
|
|
925
|
+
void this.detectSiteId().then((sid) => {
|
|
926
|
+
iframe.src = buildSrc(sid);
|
|
927
|
+
});
|
|
928
|
+
// Phase 6 \u2014 derive the expected iframe origin once so the postMessage
|
|
929
|
+
// listener can reject events from any other window. Without this check
|
|
930
|
+
// any same-tab iframe could forge { source: 'sovads-unit', \u2026 } messages
|
|
931
|
+
// and trigger our onEvent handler / iframe resize. We tolerate a parse
|
|
932
|
+
// failure (e.g. relative apiUrl) by skipping the check rather than
|
|
933
|
+
// breaking publishers who configure unusual API URLs.
|
|
934
|
+
let expectedOrigin = null;
|
|
935
|
+
try {
|
|
936
|
+
expectedOrigin = new URL(apiBase, typeof window !== 'undefined' ? window.location.href : undefined).origin;
|
|
937
|
+
}
|
|
938
|
+
catch {
|
|
939
|
+
expectedOrigin = null;
|
|
940
|
+
}
|
|
941
|
+
const listener = (ev) => {
|
|
942
|
+
// Origin check: only trust messages from the iframe we mounted.
|
|
943
|
+
if (expectedOrigin && ev.origin !== expectedOrigin)
|
|
944
|
+
return;
|
|
945
|
+
const data = ev.data;
|
|
946
|
+
if (!data || typeof data !== 'object')
|
|
947
|
+
return;
|
|
948
|
+
if (data.source !== 'sovads-unit')
|
|
949
|
+
return;
|
|
950
|
+
if (data.slotId !== slotId)
|
|
951
|
+
return;
|
|
952
|
+
const type = String(data.type);
|
|
953
|
+
const payload = (data.payload || {});
|
|
954
|
+
// Auto-resize iframe in response to RESIZE messages
|
|
955
|
+
if (type === 'RESIZE' && typeof payload.height === 'number') {
|
|
956
|
+
iframe.style.height = `${payload.height}px`;
|
|
957
|
+
}
|
|
958
|
+
try {
|
|
959
|
+
options.onEvent?.({ type: type, payload, slotId });
|
|
960
|
+
}
|
|
961
|
+
catch (e) {
|
|
962
|
+
if (this.config.debug)
|
|
963
|
+
console.error('SovAds.mountUnit onEvent threw', e);
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
window.addEventListener('message', listener);
|
|
967
|
+
this.unitListeners.set(slotId, { listener, iframe });
|
|
968
|
+
const unmount = () => {
|
|
969
|
+
window.removeEventListener('message', listener);
|
|
970
|
+
try {
|
|
971
|
+
iframe.remove();
|
|
972
|
+
}
|
|
973
|
+
catch { /* noop */ }
|
|
974
|
+
this.unitListeners.delete(slotId);
|
|
975
|
+
};
|
|
976
|
+
return { slotId, unmount };
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
export function toStreamingEmbed(url) {
|
|
980
|
+
if (!url)
|
|
981
|
+
return null;
|
|
982
|
+
const trimmed = url.trim();
|
|
983
|
+
if (!trimmed)
|
|
984
|
+
return null;
|
|
985
|
+
// YouTube: youtu.be/{id}, youtube.com/watch?v={id}, youtube.com/shorts/{id},
|
|
986
|
+
// youtube.com/embed/{id}.
|
|
987
|
+
const yt = trimmed.match(/(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|shorts\/|embed\/|v\/))([A-Za-z0-9_-]{6,})/i);
|
|
988
|
+
if (yt && yt[1]) {
|
|
989
|
+
return {
|
|
990
|
+
provider: 'youtube',
|
|
991
|
+
// playsinline + modestbranding + rel=0 keeps the embed quiet and clean;
|
|
992
|
+
// no autoplay by default (browsers block autoplay-with-sound anyway).
|
|
993
|
+
embedUrl: `https://www.youtube.com/embed/${yt[1]}?rel=0&modestbranding=1&playsinline=1`,
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
// Vimeo: vimeo.com/{id} or player.vimeo.com/video/{id}.
|
|
997
|
+
const vm = trimmed.match(/vimeo\.com\/(?:video\/)?(\d+)/i);
|
|
998
|
+
if (vm && vm[1]) {
|
|
999
|
+
return {
|
|
1000
|
+
provider: 'vimeo',
|
|
1001
|
+
embedUrl: `https://player.vimeo.com/video/${vm[1]}?byline=0&portrait=0&title=0`,
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
// TikTok: tiktok.com/@user/video/{id}.
|
|
1005
|
+
const tt = trimmed.match(/tiktok\.com\/(?:@[^/]+\/)?video\/(\d+)/i);
|
|
1006
|
+
if (tt && tt[1]) {
|
|
1007
|
+
return {
|
|
1008
|
+
provider: 'tiktok',
|
|
1009
|
+
embedUrl: `https://www.tiktok.com/embed/v2/${tt[1]}`,
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
return null;
|
|
1013
|
+
}
|
|
1014
|
+
/** Build a sandboxed `<iframe>` for a streaming embed URL. Shared by Banner
|
|
1015
|
+
* and Popup so both surfaces behave identically. */
|
|
1016
|
+
export function buildStreamingIframe(embed, alt) {
|
|
1017
|
+
const iframe = document.createElement('iframe');
|
|
1018
|
+
iframe.src = embed.embedUrl;
|
|
1019
|
+
iframe.title = alt || 'Sponsored video';
|
|
1020
|
+
iframe.setAttribute('frameborder', '0');
|
|
1021
|
+
iframe.setAttribute('allow', 'accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share');
|
|
1022
|
+
iframe.allowFullscreen = true;
|
|
1023
|
+
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
|
|
1024
|
+
iframe.style.cssText =
|
|
1025
|
+
'width:100%;aspect-ratio:16/9;height:auto;display:block;border:0;background:#000;';
|
|
1026
|
+
iframe.dataset.sovadsProvider = embed.provider;
|
|
1027
|
+
return iframe;
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Build the media element for an ad. Single source of truth for the
|
|
1031
|
+
* image / video / streaming-iframe switch that used to be duplicated across
|
|
1032
|
+
* Banner, Sidebar, Popup and BottomBar.
|
|
1033
|
+
*
|
|
1034
|
+
* The caller is responsible for:
|
|
1035
|
+
* - Attaching `load` / `loadeddata` / `error` listeners (for impression timing).
|
|
1036
|
+
* - Mounting the returned `element` into the DOM.
|
|
1037
|
+
* - Adding a click handler — either on the element (when `clickable=true`)
|
|
1038
|
+
* or on an external "Learn more" button (always, when false).
|
|
1039
|
+
*/
|
|
1040
|
+
export function mountMedia(opts) {
|
|
1041
|
+
const { ad, style } = opts;
|
|
1042
|
+
const streamingEmbed = toStreamingEmbed(ad.bannerUrl);
|
|
1043
|
+
if (streamingEmbed) {
|
|
1044
|
+
const iframe = buildStreamingIframe(streamingEmbed, ad.description);
|
|
1045
|
+
if (style)
|
|
1046
|
+
iframe.style.cssText = style;
|
|
1047
|
+
return { element: iframe, kind: 'streaming', clickable: false };
|
|
725
1048
|
}
|
|
1049
|
+
const mediaType = ad.mediaType === 'video' ? 'video' : 'image';
|
|
1050
|
+
if (mediaType === 'video') {
|
|
1051
|
+
const video = document.createElement('video');
|
|
1052
|
+
video.src = ad.bannerUrl;
|
|
1053
|
+
video.muted = true;
|
|
1054
|
+
video.autoplay = true;
|
|
1055
|
+
video.loop = true;
|
|
1056
|
+
video.playsInline = true;
|
|
1057
|
+
video.controls = true;
|
|
1058
|
+
video.style.cssText = style || 'width:100%;height:auto;display:block;border-radius:4px;';
|
|
1059
|
+
return { element: video, kind: 'video', clickable: false };
|
|
1060
|
+
}
|
|
1061
|
+
const img = document.createElement('img');
|
|
1062
|
+
img.src = ad.bannerUrl;
|
|
1063
|
+
img.alt = ad.description || 'Sponsored';
|
|
1064
|
+
img.style.cssText = style || 'width:100%;height:auto;display:block;max-width:100%;object-fit:contain;';
|
|
1065
|
+
return { element: img, kind: 'image', clickable: true };
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Build a compact "Sponsored" disclosure badge.
|
|
1069
|
+
*
|
|
1070
|
+
* Phase 0 only EXPORTS the helper — components do not mount it yet. Phase 2
|
|
1071
|
+
* wires it into every render path, opt-out via `new SovAds({ disclosureLabel: false })`.
|
|
1072
|
+
*
|
|
1073
|
+
* The returned span uses `aria-label="Advertisement"` (the FTC-recommended
|
|
1074
|
+
* explicit term) and is sized to remain legible (11px min, 1.0 contrast on
|
|
1075
|
+
* standard backgrounds). Position is the caller's responsibility.
|
|
1076
|
+
*/
|
|
1077
|
+
export function buildDisclosureBadge(opts) {
|
|
1078
|
+
const label = opts?.label ?? 'Sponsored';
|
|
1079
|
+
const variant = opts?.variant ?? 'dark';
|
|
1080
|
+
const badge = document.createElement('span');
|
|
1081
|
+
badge.className = 'sovads-disclosure';
|
|
1082
|
+
badge.setAttribute('role', 'note');
|
|
1083
|
+
badge.setAttribute('aria-label', 'Advertisement');
|
|
1084
|
+
// Phase 5: badge fg/bg pull from CSS variables when defined; otherwise
|
|
1085
|
+
// fall back to today's defaults so existing publishers see no change.
|
|
1086
|
+
const bg = variant === 'dark'
|
|
1087
|
+
? 'var(--sovads-disclosure-bg-dark, rgba(255,255,255,0.92))'
|
|
1088
|
+
: 'var(--sovads-disclosure-bg-light, rgba(0,0,0,0.62))';
|
|
1089
|
+
const fg = variant === 'dark'
|
|
1090
|
+
? 'var(--sovads-accent, #2D2D2D)'
|
|
1091
|
+
: 'var(--sovads-on-accent-strong, #FFFFFF)';
|
|
1092
|
+
badge.style.cssText =
|
|
1093
|
+
`display:inline-flex;align-items:center;gap:4px;` +
|
|
1094
|
+
`font-size:11px;font-weight:600;line-height:1;` +
|
|
1095
|
+
`padding:3px 6px;border-radius:3px;letter-spacing:0.02em;` +
|
|
1096
|
+
`background:${bg};color:${fg};` +
|
|
1097
|
+
`font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;`;
|
|
1098
|
+
badge.textContent = opts?.advertiser ? `${label} · ${opts.advertiser}` : label;
|
|
1099
|
+
return badge;
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Phase 2 \u2014 resolve the effective disclosure setting from a 3-level cascade:
|
|
1103
|
+
* slot-override \u2192 SovAdsConfig.disclosureLabel \u2192 default (true \u2192 'Sponsored').
|
|
1104
|
+
*
|
|
1105
|
+
* Returns the resolved label string, or `null` if disclosure is explicitly
|
|
1106
|
+
* disabled (which callers should treat as "do not render"). Centralised here
|
|
1107
|
+
* so every component reads the rule the same way.
|
|
1108
|
+
*/
|
|
1109
|
+
export function resolveDisclosureLabel(slotOverride, configValue) {
|
|
1110
|
+
const layered = slotOverride !== undefined ? slotOverride : configValue;
|
|
1111
|
+
if (layered === false)
|
|
1112
|
+
return null;
|
|
1113
|
+
if (typeof layered === 'string' && layered.trim().length > 0)
|
|
1114
|
+
return layered;
|
|
1115
|
+
return 'Sponsored';
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Phase 2 \u2014 small helper that builds AND positions a disclosure badge over
|
|
1119
|
+
* the top-left of an ad surface (absolute positioning). Caller must ensure
|
|
1120
|
+
* the parent has `position: relative` (or another non-static positioning
|
|
1121
|
+
* context). Returns `null` when disclosure is disabled \u2014 caller should
|
|
1122
|
+
* handle that as "do not append".
|
|
1123
|
+
*/
|
|
1124
|
+
export function buildPositionedDisclosure(opts) {
|
|
1125
|
+
const label = resolveDisclosureLabel(opts.slotOverride, opts.configValue);
|
|
1126
|
+
if (label === null)
|
|
1127
|
+
return null;
|
|
1128
|
+
const badge = buildDisclosureBadge({
|
|
1129
|
+
label,
|
|
1130
|
+
advertiser: opts.advertiser,
|
|
1131
|
+
variant: opts.variant,
|
|
1132
|
+
});
|
|
1133
|
+
const pos = opts.position ?? 'top-left';
|
|
1134
|
+
badge.style.position = 'absolute';
|
|
1135
|
+
badge.style.top = '6px';
|
|
1136
|
+
if (pos === 'top-left')
|
|
1137
|
+
badge.style.left = '6px';
|
|
1138
|
+
else
|
|
1139
|
+
badge.style.right = '6px';
|
|
1140
|
+
badge.style.zIndex = '2';
|
|
1141
|
+
return badge;
|
|
1142
|
+
}
|
|
1143
|
+
// ============================================================================
|
|
1144
|
+
// Phase 3 \u2014 CLS (Cumulative Layout Shift) reservation.
|
|
1145
|
+
//
|
|
1146
|
+
// Today every component sets `container.style.display = 'none'` before the
|
|
1147
|
+
// async ad fetch and only shows the box on `<img>` load. That guarantees
|
|
1148
|
+
// CLS: the page content below the slot is pulled up while the ad fetches
|
|
1149
|
+
// and gets shoved back down when the image decodes. Lighthouse penalises
|
|
1150
|
+
// this hard (it's the dominant CLS source for most ad-supported pages).
|
|
1151
|
+
//
|
|
1152
|
+
// Phase 3 fix: when the publisher tells us the slot size (e.g. '300x250',
|
|
1153
|
+
// '728x90'), we reserve the exact aspect-ratio box on the container BEFORE
|
|
1154
|
+
// the fetch. Layout stays put; media fades in. No size known \u2192 we keep
|
|
1155
|
+
// today's hide-then-show behaviour for backcompat.
|
|
1156
|
+
// ============================================================================
|
|
1157
|
+
/**
|
|
1158
|
+
* Parse an IAB-style size string ('300x250', '728x90', '160x600', etc.) into
|
|
1159
|
+
* a {width, height} pair. Returns null when the string is malformed so the
|
|
1160
|
+
* caller falls back to legacy behaviour rather than throwing.
|
|
1161
|
+
*/
|
|
1162
|
+
export function parseAdSize(size) {
|
|
1163
|
+
if (!size || typeof size !== 'string')
|
|
1164
|
+
return null;
|
|
1165
|
+
const m = size.trim().toLowerCase().match(/^(\d{1,5})\s*x\s*(\d{1,5})$/);
|
|
1166
|
+
if (!m)
|
|
1167
|
+
return null;
|
|
1168
|
+
const width = Number.parseInt(m[1], 10);
|
|
1169
|
+
const height = Number.parseInt(m[2], 10);
|
|
1170
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width === 0 || height === 0)
|
|
1171
|
+
return null;
|
|
1172
|
+
return { width, height };
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Reserve a CLS-safe box on the slot container before the ad fetch starts.
|
|
1176
|
+
* Sets `aspect-ratio` so the browser knows the box's intrinsic shape and
|
|
1177
|
+
* `max-width` so the slot never grows past the IAB size on large viewports.
|
|
1178
|
+
* The container stays visible (no `display: none`) so the page below it
|
|
1179
|
+
* keeps its final position.
|
|
1180
|
+
*
|
|
1181
|
+
* Returns true when reservation was applied. The caller should skip the
|
|
1182
|
+
* legacy hide-then-show dance only when this returns true.
|
|
1183
|
+
*/
|
|
1184
|
+
export function reserveAdSlot(container, size) {
|
|
1185
|
+
const parsed = parseAdSize(size);
|
|
1186
|
+
if (!parsed)
|
|
1187
|
+
return false;
|
|
1188
|
+
// aspect-ratio is supported in every browser shipped since 2021. The
|
|
1189
|
+
// `width: 100%` + `max-width` combo lets the slot fluidly shrink on
|
|
1190
|
+
// narrow viewports while never exceeding its IAB declared width.
|
|
1191
|
+
container.style.aspectRatio = `${parsed.width} / ${parsed.height}`;
|
|
1192
|
+
container.style.width = '100%';
|
|
1193
|
+
container.style.maxWidth = `${parsed.width}px`;
|
|
1194
|
+
// A subtle neutral placeholder background so the reserved box is visible
|
|
1195
|
+
// to debug + matches what most ad networks show. Uses CSS variables so
|
|
1196
|
+
// Phase 5 theming will override automatically.
|
|
1197
|
+
if (!container.style.backgroundColor) {
|
|
1198
|
+
container.style.backgroundColor = 'var(--sovads-placeholder-bg, transparent)';
|
|
1199
|
+
}
|
|
1200
|
+
return true;
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Phase 7 \u2014 returns true when the user / OS prefers reduced motion. Used
|
|
1204
|
+
* by hover-scale and translate animations so we don't trigger vestibular
|
|
1205
|
+
* discomfort for users who've asked the system to dial back animation.
|
|
1206
|
+
* Falls back to `false` (= motion allowed) when matchMedia isn't available
|
|
1207
|
+
* so server-side rendering / older browsers see the same animation as today.
|
|
1208
|
+
*/
|
|
1209
|
+
export function prefersReducedMotion() {
|
|
1210
|
+
try {
|
|
1211
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
1212
|
+
return false;
|
|
1213
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
1214
|
+
}
|
|
1215
|
+
catch {
|
|
1216
|
+
return false;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Mount the attached-CTA panel for a given surface. Thin wrapper around
|
|
1221
|
+
* `renderAttachedCtas` that components can call without re-implementing the
|
|
1222
|
+
* try/catch + debug-log boilerplate. Phase 1 routes every component through
|
|
1223
|
+
* this helper.
|
|
1224
|
+
*
|
|
1225
|
+
* The `surface` arg is passed through to `onCtaComplete` callers via the
|
|
1226
|
+
* existing AttachedCtaCompleteEvent (no new field today) and used as a hint
|
|
1227
|
+
* for layout — POPUP / NATIVE / SIDEBAR stack vertically, BOTTOM_BAR may
|
|
1228
|
+
* render inline (decided at the call site).
|
|
1229
|
+
*/
|
|
1230
|
+
export function mountCtaPanel(opts) {
|
|
1231
|
+
// Wrap the host's onComplete so we can fire a CTA_COMPLETE interaction
|
|
1232
|
+
// event tagged with the originating surface. Backward-compat: existing
|
|
1233
|
+
// analytics consumers ignore unknown elementType values.
|
|
1234
|
+
const wrappedOnComplete = (ev) => {
|
|
1235
|
+
try {
|
|
1236
|
+
opts.sovads?.logInteraction('CTA_COMPLETE', {
|
|
1237
|
+
adId: undefined,
|
|
1238
|
+
campaignId: ev.campaignId,
|
|
1239
|
+
taskId: ev.taskId,
|
|
1240
|
+
kind: ev.kind,
|
|
1241
|
+
elementType: opts.surface,
|
|
1242
|
+
ok: ev.ok,
|
|
1243
|
+
status: ev.status,
|
|
1244
|
+
awarded: ev.awarded,
|
|
1245
|
+
error: ev.error,
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
catch {
|
|
1249
|
+
/* analytics best-effort */
|
|
1250
|
+
}
|
|
1251
|
+
try {
|
|
1252
|
+
opts.onComplete?.(ev);
|
|
1253
|
+
}
|
|
1254
|
+
catch { /* host handler threw */ }
|
|
1255
|
+
};
|
|
1256
|
+
try {
|
|
1257
|
+
renderAttachedCtas({
|
|
1258
|
+
container: opts.container,
|
|
1259
|
+
sovads: opts.sovads,
|
|
1260
|
+
tasks: opts.tasks,
|
|
1261
|
+
campaignId: opts.campaignId,
|
|
1262
|
+
bannerClickActive: opts.bannerClickActive,
|
|
1263
|
+
onComplete: wrappedOnComplete,
|
|
1264
|
+
preview: opts.preview,
|
|
1265
|
+
layout: opts.layout,
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
catch (e) {
|
|
1269
|
+
if (opts.sovads?.getConfig().debug) {
|
|
1270
|
+
console.error(`[SovAds] mountCtaPanel(${opts.surface}) failed`, e);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
// ============================================================================
|
|
1275
|
+
// Mounts a compact CTA panel beneath a banner whenever the server returns
|
|
1276
|
+
// `attachedTasks` (only happens when the slot was opened with `attached: true`).
|
|
1277
|
+
//
|
|
1278
|
+
// Supported task kinds:
|
|
1279
|
+
// - VISIT_URL \u2192 single "primary" button; opens url, then submits after dwell
|
|
1280
|
+
// - SIGN_MESSAGE \u2192 single "primary" button; emits onCtaComplete with
|
|
1281
|
+
// needsSignature so the host page can sign via its own wallet
|
|
1282
|
+
// (the SDK does not currently hold a signer)
|
|
1283
|
+
// - POLL \u2192 stacked option buttons (one click = one submit)
|
|
1284
|
+
//
|
|
1285
|
+
// When `bannerClickActive=false` (campaign out of token budget), the reward
|
|
1286
|
+
// badge swaps "+N G$" \u2192 "+N pts*" to reflect the points-fallback that
|
|
1287
|
+
// /api/tasks/complete will apply.
|
|
1288
|
+
// ============================================================================
|
|
1289
|
+
/**
|
|
1290
|
+
* Public renderer for the attached-CTA panel.
|
|
1291
|
+
*
|
|
1292
|
+
* Two modes:
|
|
1293
|
+
* - Live (default): mounts the panel with real click handlers; clicking
|
|
1294
|
+
* POLL/VISIT_URL/SIGN_MESSAGE submits via `sovads.submitTaskCompletion`.
|
|
1295
|
+
* - Preview: pass `preview: true` (and omit `sovads`). Renders the same DOM
|
|
1296
|
+
* but disables click handlers and submission — used by the create-campaign
|
|
1297
|
+
* page and the advertiser review queue so the advertiser sees the exact
|
|
1298
|
+
* button the viewer will see, with no risk of side-effects.
|
|
1299
|
+
*/
|
|
1300
|
+
export function renderAttachedCtas(opts) {
|
|
1301
|
+
const { container, sovads, tasks, campaignId, bannerClickActive, onComplete, preview } = opts;
|
|
1302
|
+
const requestedLayout = opts.layout ?? 'stack';
|
|
1303
|
+
// 'auto' resolves at render time: 2 tasks side-by-side, otherwise stack.
|
|
1304
|
+
// 1 task in a row would look identical to stack; 3+ tasks side-by-side in
|
|
1305
|
+
// a 300px-wide Banner would each end up ~90px wide with a truncated label,
|
|
1306
|
+
// so we cap auto-inline at exactly 2.
|
|
1307
|
+
const layout = requestedLayout === 'auto'
|
|
1308
|
+
? (tasks.length === 2 ? 'inline' : 'stack')
|
|
1309
|
+
: requestedLayout;
|
|
1310
|
+
if (!tasks.length)
|
|
1311
|
+
return;
|
|
1312
|
+
if (!preview && !sovads) {
|
|
1313
|
+
// Live mode requires a real SovAds instance.
|
|
1314
|
+
console.error('[SovAds] renderAttachedCtas: `sovads` is required when `preview` is not true');
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
const panel = document.createElement('div');
|
|
1318
|
+
panel.className = 'sovads-cta-panel';
|
|
1319
|
+
panel.setAttribute('data-campaign-id', campaignId);
|
|
1320
|
+
panel.setAttribute('data-layout', layout);
|
|
1321
|
+
// Record what the caller asked for separately from the resolved value so
|
|
1322
|
+
// host pages can style/debug the auto-switch independently.
|
|
1323
|
+
if (requestedLayout === 'auto')
|
|
1324
|
+
panel.setAttribute('data-layout-requested', 'auto');
|
|
1325
|
+
if (preview)
|
|
1326
|
+
panel.setAttribute('data-preview', '1');
|
|
1327
|
+
// No card chrome \u2014 the buttons themselves carry all the visual weight.
|
|
1328
|
+
// Tight spacing so the banner + CTAs read as one cohesive component:
|
|
1329
|
+
// 2px gap to the banner above, 4px between stacked CTA buttons.
|
|
1330
|
+
// In 'inline' layout the panel becomes a horizontal row of equal-width
|
|
1331
|
+
// items so it can sit next to the media element (used by BottomBar).
|
|
1332
|
+
panel.style.cssText = layout === 'inline'
|
|
1333
|
+
? `
|
|
1334
|
+
margin: 0;
|
|
1335
|
+
color: var(--sovads-accent, #2D2D2D);
|
|
1336
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
1337
|
+
font-size: 13px;
|
|
1338
|
+
display: flex;
|
|
1339
|
+
flex-direction: row;
|
|
1340
|
+
align-items: stretch;
|
|
1341
|
+
gap: 6px;
|
|
1342
|
+
box-sizing: border-box;
|
|
1343
|
+
width: 100%;
|
|
1344
|
+
`
|
|
1345
|
+
: `
|
|
1346
|
+
margin-top: 2px;
|
|
1347
|
+
color: var(--sovads-accent, #2D2D2D);
|
|
1348
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
1349
|
+
font-size: 13px;
|
|
1350
|
+
display: flex;
|
|
1351
|
+
flex-direction: column;
|
|
1352
|
+
gap: 4px;
|
|
1353
|
+
box-sizing: border-box;
|
|
1354
|
+
width: 100%;
|
|
1355
|
+
`;
|
|
1356
|
+
container.appendChild(panel);
|
|
1357
|
+
const renderBudgetNotice = () => {
|
|
1358
|
+
if (bannerClickActive)
|
|
1359
|
+
return;
|
|
1360
|
+
const notice = document.createElement('div');
|
|
1361
|
+
notice.textContent = 'Earn SovPoints by completing a quick task below.';
|
|
1362
|
+
notice.style.cssText =
|
|
1363
|
+
'font-size:11px;color:#8a6d3b;background:#fff8e1;border:1px solid #ffe082;border-radius:6px;padding:6px 8px;';
|
|
1364
|
+
panel.appendChild(notice);
|
|
1365
|
+
};
|
|
1366
|
+
// Renders the list of CTA buttons into the panel. `statusByTask` is keyed
|
|
1367
|
+
// by taskId; tasks present in the map with `completionsUsed > 0` (or with a
|
|
1368
|
+
// verified/paid completion) are rendered as a disabled button with a \u2713
|
|
1369
|
+
// corner badge. Missing entries are assumed eligible.
|
|
1370
|
+
const mountTasks = (statusByTask) => {
|
|
1371
|
+
panel.innerHTML = '';
|
|
1372
|
+
renderBudgetNotice();
|
|
1373
|
+
for (const task of tasks) {
|
|
1374
|
+
const status = statusByTask.get(task.id);
|
|
1375
|
+
const done = isTaskDone(status);
|
|
1376
|
+
const row = buildTaskRow(task, {
|
|
1377
|
+
sovads,
|
|
1378
|
+
bannerClickActive,
|
|
1379
|
+
onComplete,
|
|
1380
|
+
preview: !!preview,
|
|
1381
|
+
gsLogoUrl: resolveGsLogoUrl(sovads),
|
|
1382
|
+
done,
|
|
1383
|
+
});
|
|
1384
|
+
// In inline layout the row needs to flex so multiple tasks share width
|
|
1385
|
+
// equally. In stack mode (default) we leave width alone so the row
|
|
1386
|
+
// grows to fill the column \u2014 byte-identical to today.
|
|
1387
|
+
if (layout === 'inline') {
|
|
1388
|
+
row.style.flex = '1 1 0';
|
|
1389
|
+
row.style.minWidth = '0';
|
|
1390
|
+
}
|
|
1391
|
+
panel.appendChild(row);
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
// Preview mode (advertiser-side rendering) never gates on wallet \u2014 the
|
|
1395
|
+
// advertiser must always see the live button layout.
|
|
1396
|
+
if (preview) {
|
|
1397
|
+
mountTasks(new Map());
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
const wallet = sovads.getWalletAddress();
|
|
1401
|
+
if (!wallet) {
|
|
1402
|
+
// Lazy-load: no wallet yet \u2192 show a compact connect-wallet hint and
|
|
1403
|
+
// subscribe to identity changes. When the host page calls
|
|
1404
|
+
// `sovads.identify(addr)` we fetch completion status and swap in the
|
|
1405
|
+
// real CTA buttons.
|
|
1406
|
+
const placeholder = document.createElement('div');
|
|
1407
|
+
placeholder.style.cssText =
|
|
1408
|
+
'display:flex;align-items:center;justify-content:center;gap:6px;font-size:12px;font-weight:600;' +
|
|
1409
|
+
'color:var(--sovads-text-muted, #666);' +
|
|
1410
|
+
'background:var(--sovads-surface, #FAFAF8);' +
|
|
1411
|
+
'border:1px dashed var(--sovads-border-soft, #E5E5E5);' +
|
|
1412
|
+
'border-radius:6px;padding:10px 12px;';
|
|
1413
|
+
placeholder.innerHTML =
|
|
1414
|
+
'<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:#ffb020;"></span>' +
|
|
1415
|
+
'Connect your wallet to unlock rewards';
|
|
1416
|
+
panel.appendChild(placeholder);
|
|
1417
|
+
let unsub = null;
|
|
1418
|
+
unsub = sovads.onIdentify(async (next) => {
|
|
1419
|
+
if (!next)
|
|
1420
|
+
return;
|
|
1421
|
+
if (unsub) {
|
|
1422
|
+
unsub();
|
|
1423
|
+
unsub = null;
|
|
1424
|
+
}
|
|
1425
|
+
const statusByTask = await sovads.fetchTaskStatuses(campaignId);
|
|
1426
|
+
mountTasks(statusByTask);
|
|
1427
|
+
});
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
// Wallet already known on first render \u2014 render an immediate skeleton-free
|
|
1431
|
+
// pass then patch with completion status once it arrives. We render the
|
|
1432
|
+
// buttons first so the panel doesn't flash empty space; done-badges appear
|
|
1433
|
+
// a tick later when the status fetch resolves.
|
|
1434
|
+
mountTasks(new Map());
|
|
1435
|
+
sovads.fetchTaskStatuses(campaignId).then((statusByTask) => {
|
|
1436
|
+
if (statusByTask.size === 0)
|
|
1437
|
+
return;
|
|
1438
|
+
mountTasks(statusByTask);
|
|
1439
|
+
}).catch(() => { });
|
|
1440
|
+
}
|
|
1441
|
+
/** A task counts as "done" for badge purposes when the viewer has any
|
|
1442
|
+
* successful (verified/paid) completion OR has hit `maxPerWallet`. We use
|
|
1443
|
+
* the status response's `eligibility.completionsUsed` plus the completions
|
|
1444
|
+
* array (looking for non-failed records) so a single click immediately
|
|
1445
|
+
* reflects as done on the next render. */
|
|
1446
|
+
function isTaskDone(status) {
|
|
1447
|
+
if (!status)
|
|
1448
|
+
return false;
|
|
1449
|
+
const used = status.eligibility?.completionsUsed ?? 0;
|
|
1450
|
+
if (used > 0)
|
|
1451
|
+
return true;
|
|
1452
|
+
const successful = (status.completions ?? []).some((c) => c && c.status !== 'failed' && c.status !== 'rejected');
|
|
1453
|
+
return successful;
|
|
1454
|
+
}
|
|
1455
|
+
/** Build an absolute URL to the G$ logo asset served from /public.
|
|
1456
|
+
* Falls back to a relative path so the asset still works when the SDK is
|
|
1457
|
+
* used inside the sovads frontend itself (preview mode, no instance). */
|
|
1458
|
+
function resolveGsLogoUrl(sovads) {
|
|
1459
|
+
try {
|
|
1460
|
+
const apiBase = sovads?.getConfig()?.apiUrl;
|
|
1461
|
+
if (apiBase)
|
|
1462
|
+
return `${apiBase.replace(/\/$/, '')}/6961.png`;
|
|
1463
|
+
}
|
|
1464
|
+
catch {
|
|
1465
|
+
/* ignore */
|
|
1466
|
+
}
|
|
1467
|
+
return '/6961.png';
|
|
1468
|
+
}
|
|
1469
|
+
/** Returns the combined reward amount as a single number. Points and G$ are
|
|
1470
|
+
* 1:1 in this system (G$ falls back to points when the campaign budget is
|
|
1471
|
+
* exhausted), so we display them as one value with the G$ icon. */
|
|
1472
|
+
function totalReward(task) {
|
|
1473
|
+
return (task.rewardPoints || 0) + (task.rewardGs || 0);
|
|
1474
|
+
}
|
|
1475
|
+
function buildTaskRow(task, ctx) {
|
|
1476
|
+
const row = document.createElement('div');
|
|
1477
|
+
// `position:relative` so the absolute \u2713 corner badge anchors to the row.
|
|
1478
|
+
row.style.cssText = 'position:relative;display:flex;flex-direction:column;gap:2px;';
|
|
1479
|
+
const reward = totalReward(task);
|
|
1480
|
+
const status = document.createElement('div');
|
|
1481
|
+
status.style.cssText = 'font-size:11px;color:#666;min-height:0;';
|
|
1482
|
+
// Phase 7: status text changes mid-submit ('Submitting\u2026', 'Thanks! +N',
|
|
1483
|
+
// 'Submit failed', etc.). Mark the node as a polite live region so screen
|
|
1484
|
+
// readers announce updates without stealing focus.
|
|
1485
|
+
status.setAttribute('role', 'status');
|
|
1486
|
+
status.setAttribute('aria-live', 'polite');
|
|
1487
|
+
status.setAttribute('aria-atomic', 'true');
|
|
1488
|
+
const setStatus = (text, tone = 'info') => {
|
|
1489
|
+
status.textContent = text;
|
|
1490
|
+
status.style.color = tone === 'ok' ? '#1f7a3a' : tone === 'err' ? '#a02020' : '#666';
|
|
1491
|
+
};
|
|
1492
|
+
const submit = async (proof, button) => {
|
|
1493
|
+
if (!ctx.sovads)
|
|
1494
|
+
return;
|
|
1495
|
+
if (button) {
|
|
1496
|
+
button.disabled = true;
|
|
1497
|
+
button.style.opacity = '0.6';
|
|
1498
|
+
button.style.cursor = 'wait';
|
|
1499
|
+
}
|
|
1500
|
+
setStatus('Submitting\u2026');
|
|
1501
|
+
const result = await ctx.sovads.submitTaskCompletion({ taskId: task.id, proof });
|
|
1502
|
+
if (result.ok) {
|
|
1503
|
+
const aw = result.awarded;
|
|
1504
|
+
const total = aw ? (aw.points || 0) + (aw.gs || 0) : reward;
|
|
1505
|
+
setStatus(`Thanks! +${total}`, 'ok');
|
|
1506
|
+
}
|
|
1507
|
+
else {
|
|
1508
|
+
setStatus(result.error || `Submit failed (${result.status})`, 'err');
|
|
1509
|
+
if (button) {
|
|
1510
|
+
button.disabled = false;
|
|
1511
|
+
button.style.opacity = '1';
|
|
1512
|
+
button.style.cursor = 'pointer';
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
try {
|
|
1516
|
+
ctx.onComplete?.({
|
|
1517
|
+
taskId: task.id,
|
|
1518
|
+
campaignId: task.campaignId,
|
|
1519
|
+
kind: task.kind,
|
|
1520
|
+
ok: result.ok,
|
|
1521
|
+
status: result.status,
|
|
1522
|
+
awarded: result.awarded,
|
|
1523
|
+
error: result.error,
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
catch {
|
|
1527
|
+
/* host handler threw - swallow */
|
|
1528
|
+
}
|
|
1529
|
+
};
|
|
1530
|
+
// In preview mode buttons render but do nothing on click. We keep the status
|
|
1531
|
+
// placeholder so the live and preview layouts match pixel-for-pixel.
|
|
1532
|
+
const wireClick = (btn, handler) => {
|
|
1533
|
+
if (ctx.preview || ctx.done) {
|
|
1534
|
+
btn.style.cursor = 'default';
|
|
1535
|
+
btn.setAttribute('aria-disabled', 'true');
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
btn.addEventListener('click', handler);
|
|
1539
|
+
};
|
|
1540
|
+
// Apply the "already completed" visual treatment: faded button + \u2713
|
|
1541
|
+
// corner badge anchored to the row. We intentionally keep the original
|
|
1542
|
+
// label so the viewer remembers what they did, just dimmed.
|
|
1543
|
+
const applyDoneStyling = (btn) => {
|
|
1544
|
+
if (!ctx.done)
|
|
1545
|
+
return;
|
|
1546
|
+
btn.disabled = true;
|
|
1547
|
+
btn.style.opacity = '0.55';
|
|
1548
|
+
btn.style.cursor = 'default';
|
|
1549
|
+
btn.style.pointerEvents = 'none';
|
|
1550
|
+
};
|
|
1551
|
+
if (task.kind === 'VISIT_URL') {
|
|
1552
|
+
const btn = makeButton(task.buttonLabel || task.label || 'Visit link', 'primary', {
|
|
1553
|
+
reward,
|
|
1554
|
+
gsLogoUrl: ctx.gsLogoUrl,
|
|
1555
|
+
});
|
|
1556
|
+
wireClick(btn, async () => {
|
|
1557
|
+
const target = task.url;
|
|
1558
|
+
if (target) {
|
|
1559
|
+
try {
|
|
1560
|
+
window.open(target, '_blank', 'noopener,noreferrer');
|
|
1561
|
+
}
|
|
1562
|
+
catch {
|
|
1563
|
+
/* popup blocked - submission still proceeds */
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
const dwell = Math.max(0, task.minDwellMs ?? 3000);
|
|
1567
|
+
if (dwell > 0) {
|
|
1568
|
+
setStatus(`Keep the tab open for ${Math.round(dwell / 1000)}s\u2026`);
|
|
1569
|
+
await new Promise((r) => setTimeout(r, dwell));
|
|
1570
|
+
}
|
|
1571
|
+
await submit({ dwellMs: dwell }, btn);
|
|
1572
|
+
});
|
|
1573
|
+
applyDoneStyling(btn);
|
|
1574
|
+
row.appendChild(btn);
|
|
1575
|
+
}
|
|
1576
|
+
else if (task.kind === 'SIGN_MESSAGE') {
|
|
1577
|
+
const btn = makeButton(task.buttonLabel || task.label || 'Sign to claim', 'primary', {
|
|
1578
|
+
reward,
|
|
1579
|
+
gsLogoUrl: ctx.gsLogoUrl,
|
|
1580
|
+
});
|
|
1581
|
+
wireClick(btn, async () => {
|
|
1582
|
+
setStatus('Awaiting signature from your wallet\u2026');
|
|
1583
|
+
btn.disabled = true;
|
|
1584
|
+
btn.style.opacity = '0.6';
|
|
1585
|
+
btn.style.cursor = 'wait';
|
|
1586
|
+
try {
|
|
1587
|
+
ctx.onComplete?.({
|
|
1588
|
+
taskId: task.id,
|
|
1589
|
+
campaignId: task.campaignId,
|
|
1590
|
+
kind: task.kind,
|
|
1591
|
+
ok: false,
|
|
1592
|
+
status: 0,
|
|
1593
|
+
needsSignature: { message: task.signMessage || task.label },
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
catch {
|
|
1597
|
+
/* host handler threw - swallow */
|
|
1598
|
+
}
|
|
1599
|
+
});
|
|
1600
|
+
applyDoneStyling(btn);
|
|
1601
|
+
row.appendChild(btn);
|
|
1602
|
+
}
|
|
1603
|
+
else if (task.kind === 'POLL') {
|
|
1604
|
+
const optionsList = task.options ?? [];
|
|
1605
|
+
if (optionsList.length === 0) {
|
|
1606
|
+
setStatus('Poll has no options.', 'err');
|
|
1607
|
+
row.appendChild(status);
|
|
1608
|
+
return row;
|
|
1609
|
+
}
|
|
1610
|
+
// Reward chip floats above the option row (we can't stamp +N on every
|
|
1611
|
+
// option button without it reading like each pick rewards multiple times).
|
|
1612
|
+
const rewardChip = makeRewardChip(reward, ctx.gsLogoUrl);
|
|
1613
|
+
const rewardWrap = document.createElement('div');
|
|
1614
|
+
rewardWrap.style.cssText = 'display:flex;justify-content:flex-end;margin-bottom:2px;';
|
|
1615
|
+
rewardWrap.appendChild(rewardChip);
|
|
1616
|
+
row.appendChild(rewardWrap);
|
|
1617
|
+
// Layout heuristic: 2 short labels (\u2264 18 chars) \u2192 side-by-side pair so the
|
|
1618
|
+
// viewer reads them as a real binary choice. Otherwise stack vertically
|
|
1619
|
+
// and emphasise the first option as primary, the rest as secondary so the
|
|
1620
|
+
// group reads as a single decision tree, not a wall of equal buttons.
|
|
1621
|
+
const allShort = optionsList.every((o) => (o.label || '').length <= 18);
|
|
1622
|
+
const horizontal = optionsList.length === 2 && allShort;
|
|
1623
|
+
const optionsWrap = document.createElement('div');
|
|
1624
|
+
optionsWrap.style.cssText = horizontal
|
|
1625
|
+
? 'display:flex;flex-direction:row;gap:4px;align-items:stretch;'
|
|
1626
|
+
: 'display:flex;flex-direction:column;gap:3px;';
|
|
1627
|
+
optionsList.forEach((opt, idx) => {
|
|
1628
|
+
const variant = horizontal
|
|
1629
|
+
? 'secondary'
|
|
1630
|
+
: idx === 0
|
|
1631
|
+
? 'primary'
|
|
1632
|
+
: 'secondary';
|
|
1633
|
+
const optBtn = makeButton(opt.label, variant);
|
|
1634
|
+
if (horizontal) {
|
|
1635
|
+
optBtn.style.flex = '1 1 0';
|
|
1636
|
+
optBtn.style.minWidth = '0';
|
|
1637
|
+
optBtn.style.whiteSpace = 'normal';
|
|
1638
|
+
}
|
|
1639
|
+
wireClick(optBtn, async () => {
|
|
1640
|
+
for (const child of Array.from(optionsWrap.children)) {
|
|
1641
|
+
;
|
|
1642
|
+
child.disabled = true;
|
|
1643
|
+
child.style.opacity = '0.6';
|
|
1644
|
+
child.style.cursor = 'default';
|
|
1645
|
+
}
|
|
1646
|
+
// Phase 5: themable selected-option swatch.
|
|
1647
|
+
optBtn.style.background = 'var(--sovads-accent, #2D2D2D)';
|
|
1648
|
+
optBtn.style.color = 'var(--sovads-on-accent, #F5F3F0)';
|
|
1649
|
+
optBtn.style.opacity = '1';
|
|
1650
|
+
await submit({ answer: opt.id });
|
|
1651
|
+
});
|
|
1652
|
+
applyDoneStyling(optBtn);
|
|
1653
|
+
optionsWrap.appendChild(optBtn);
|
|
1654
|
+
});
|
|
1655
|
+
row.appendChild(optionsWrap);
|
|
1656
|
+
}
|
|
1657
|
+
// ✓ corner badge when the viewer has already completed this task. Anchored
|
|
1658
|
+
// to the row corner so it sits over the button(s) regardless of layout
|
|
1659
|
+
// (single button, side-by-side poll, or vertical poll).
|
|
1660
|
+
if (ctx.done) {
|
|
1661
|
+
const badge = document.createElement('span');
|
|
1662
|
+
badge.title = 'You\u2019ve already completed this';
|
|
1663
|
+
badge.setAttribute('aria-label', 'completed');
|
|
1664
|
+
badge.textContent = '\u2713';
|
|
1665
|
+
badge.style.cssText =
|
|
1666
|
+
'position:absolute;top:-6px;right:-6px;z-index:2;' +
|
|
1667
|
+
'display:inline-flex;align-items:center;justify-content:center;' +
|
|
1668
|
+
'width:20px;height:20px;border-radius:50%;' +
|
|
1669
|
+
'background:var(--sovads-success, #22c55e);color:#fff;font-size:12px;font-weight:800;line-height:1;' +
|
|
1670
|
+
'border:2px solid var(--sovads-surface, #FAFAF8);box-shadow:0 1px 2px rgba(0,0,0,0.15);' +
|
|
1671
|
+
'pointer-events:none;';
|
|
1672
|
+
row.appendChild(badge);
|
|
1673
|
+
setStatus('Already completed', 'ok');
|
|
1674
|
+
}
|
|
1675
|
+
row.appendChild(status);
|
|
1676
|
+
return row;
|
|
1677
|
+
}
|
|
1678
|
+
function makeRewardChip(amount, gsLogoUrl) {
|
|
1679
|
+
const chip = document.createElement('span');
|
|
1680
|
+
chip.style.cssText =
|
|
1681
|
+
'display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:700;' +
|
|
1682
|
+
'color:var(--sovads-accent, #2D2D2D);background:var(--sovads-on-accent, #F5F3F0);' +
|
|
1683
|
+
'border:1px solid var(--sovads-accent, #2D2D2D);' +
|
|
1684
|
+
'padding:3px 8px;border-radius:999px;line-height:1;';
|
|
1685
|
+
const num = document.createElement('span');
|
|
1686
|
+
num.textContent = `+${amount}`;
|
|
1687
|
+
chip.appendChild(num);
|
|
1688
|
+
const img = document.createElement('img');
|
|
1689
|
+
img.src = gsLogoUrl;
|
|
1690
|
+
img.alt = 'G$';
|
|
1691
|
+
img.width = 14;
|
|
1692
|
+
img.height = 14;
|
|
1693
|
+
img.style.cssText = 'width:14px;height:14px;object-fit:contain;display:block;';
|
|
1694
|
+
chip.appendChild(img);
|
|
1695
|
+
return chip;
|
|
1696
|
+
}
|
|
1697
|
+
function makeButton(label, variant, reward) {
|
|
1698
|
+
const btn = document.createElement('button');
|
|
1699
|
+
btn.type = 'button';
|
|
1700
|
+
const isPrimary = variant === 'primary';
|
|
1701
|
+
btn.style.cssText = `
|
|
1702
|
+
display: ${reward ? 'flex' : 'inline-flex'};
|
|
1703
|
+
align-items: center;
|
|
1704
|
+
justify-content: ${reward ? 'space-between' : 'center'};
|
|
1705
|
+
gap: 8px;
|
|
1706
|
+
border: 1px solid var(--sovads-accent, #2D2D2D);
|
|
1707
|
+
border-radius: 6px;
|
|
1708
|
+
padding: 10px 12px;
|
|
1709
|
+
font-size: 13px;
|
|
1710
|
+
font-weight: 600;
|
|
1711
|
+
line-height: 1.2;
|
|
1712
|
+
cursor: pointer;
|
|
1713
|
+
transition: background 0.15s ease, color 0.15s ease, transform 0.05s ease;
|
|
1714
|
+
width: 100%;
|
|
1715
|
+
box-sizing: border-box;
|
|
1716
|
+
text-align: ${reward ? 'left' : 'center'};
|
|
1717
|
+
background: ${isPrimary ? 'var(--sovads-accent, #2D2D2D)' : 'var(--sovads-surface, #FAFAF8)'};
|
|
1718
|
+
color: ${isPrimary ? 'var(--sovads-on-accent, #F5F3F0)' : 'var(--sovads-accent, #2D2D2D)'};
|
|
1719
|
+
`;
|
|
1720
|
+
if (reward) {
|
|
1721
|
+
const labelEl = document.createElement('span');
|
|
1722
|
+
labelEl.textContent = label;
|
|
1723
|
+
labelEl.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
|
|
1724
|
+
btn.appendChild(labelEl);
|
|
1725
|
+
const chip = document.createElement('span');
|
|
1726
|
+
// Inverted chip vs the button surface so the reward always pops.
|
|
1727
|
+
const chipBg = isPrimary
|
|
1728
|
+
? 'var(--sovads-on-accent, #F5F3F0)'
|
|
1729
|
+
: 'var(--sovads-accent, #2D2D2D)';
|
|
1730
|
+
const chipFg = isPrimary
|
|
1731
|
+
? 'var(--sovads-accent, #2D2D2D)'
|
|
1732
|
+
: 'var(--sovads-on-accent, #F5F3F0)';
|
|
1733
|
+
chip.style.cssText = `
|
|
1734
|
+
display:inline-flex;align-items:center;gap:4px;
|
|
1735
|
+
font-size:11px;font-weight:700;
|
|
1736
|
+
background:${chipBg};color:${chipFg};
|
|
1737
|
+
padding:3px 8px;border-radius:999px;line-height:1;
|
|
1738
|
+
flex-shrink:0;
|
|
1739
|
+
`;
|
|
1740
|
+
const num = document.createElement('span');
|
|
1741
|
+
num.textContent = `+${reward.reward}`;
|
|
1742
|
+
chip.appendChild(num);
|
|
1743
|
+
const img = document.createElement('img');
|
|
1744
|
+
img.src = reward.gsLogoUrl;
|
|
1745
|
+
img.alt = 'G$';
|
|
1746
|
+
img.width = 14;
|
|
1747
|
+
img.height = 14;
|
|
1748
|
+
img.style.cssText = 'width:14px;height:14px;object-fit:contain;display:block;';
|
|
1749
|
+
chip.appendChild(img);
|
|
1750
|
+
btn.appendChild(chip);
|
|
1751
|
+
}
|
|
1752
|
+
else {
|
|
1753
|
+
btn.textContent = label;
|
|
1754
|
+
}
|
|
1755
|
+
// Lightweight hover/active feedback so it visibly behaves like a button.
|
|
1756
|
+
// Phase 5: themable hover swatches (caller can override via
|
|
1757
|
+
// --sovads-accent-hover / --sovads-surface-hover).
|
|
1758
|
+
btn.addEventListener('mouseenter', () => {
|
|
1759
|
+
if (btn.disabled)
|
|
1760
|
+
return;
|
|
1761
|
+
btn.style.background = isPrimary
|
|
1762
|
+
? 'var(--sovads-accent-hover, #1A1A1A)'
|
|
1763
|
+
: 'var(--sovads-surface-hover, #EFEDE7)';
|
|
1764
|
+
});
|
|
1765
|
+
btn.addEventListener('mouseleave', () => {
|
|
1766
|
+
if (btn.disabled)
|
|
1767
|
+
return;
|
|
1768
|
+
btn.style.background = isPrimary
|
|
1769
|
+
? 'var(--sovads-accent, #2D2D2D)'
|
|
1770
|
+
: 'var(--sovads-surface, #FAFAF8)';
|
|
1771
|
+
});
|
|
1772
|
+
btn.addEventListener('mousedown', () => {
|
|
1773
|
+
if (btn.disabled)
|
|
1774
|
+
return;
|
|
1775
|
+
// Phase 7: skip the press animation entirely when the user prefers
|
|
1776
|
+
// reduced motion. Hover background change still happens (it's not a
|
|
1777
|
+
// movement) so the button keeps its hover affordance.
|
|
1778
|
+
if (prefersReducedMotion())
|
|
1779
|
+
return;
|
|
1780
|
+
btn.style.transform = 'translateY(1px)';
|
|
1781
|
+
});
|
|
1782
|
+
btn.addEventListener('mouseup', () => {
|
|
1783
|
+
if (prefersReducedMotion())
|
|
1784
|
+
return;
|
|
1785
|
+
btn.style.transform = 'translateY(0)';
|
|
1786
|
+
});
|
|
1787
|
+
return btn;
|
|
726
1788
|
}
|
|
727
1789
|
// Banner Component
|
|
728
1790
|
export class Banner {
|
|
@@ -732,6 +1794,7 @@ export class Banner {
|
|
|
732
1794
|
this.hasTrackedImpression = false;
|
|
733
1795
|
this.isRendering = false;
|
|
734
1796
|
this.refreshTimer = null;
|
|
1797
|
+
this.lazyLoadObserver = null;
|
|
735
1798
|
this.lastAdId = null;
|
|
736
1799
|
this.retryCount = 0;
|
|
737
1800
|
this.maxRetries = 3;
|
|
@@ -755,8 +1818,15 @@ export class Banner {
|
|
|
755
1818
|
this.isRendering = false;
|
|
756
1819
|
return;
|
|
757
1820
|
}
|
|
758
|
-
//
|
|
759
|
-
|
|
1821
|
+
// Phase 3: when the publisher declared a slot size, reserve the
|
|
1822
|
+
// CLS-safe aspect-ratio box BEFORE the fetch so the page layout
|
|
1823
|
+
// doesn't jump when the ad eventually loads. When no size is given
|
|
1824
|
+
// we fall back to the legacy hide-then-show behaviour for backcompat.
|
|
1825
|
+
const sizeReserved = reserveAdSlot(container, this.slotConfig.size);
|
|
1826
|
+
if (!sizeReserved) {
|
|
1827
|
+
// Legacy: hide until media loads.
|
|
1828
|
+
container.style.display = 'none';
|
|
1829
|
+
}
|
|
760
1830
|
// Lazy loading: wait for container to be in viewport
|
|
761
1831
|
if (this.sovads.getConfig().lazyLoad && !forceRefresh) {
|
|
762
1832
|
const isInViewport = await this.checkViewport(container);
|
|
@@ -772,6 +1842,7 @@ export class Banner {
|
|
|
772
1842
|
consumerId,
|
|
773
1843
|
placement: this.slotConfig.placementId || 'banner',
|
|
774
1844
|
size: this.slotConfig.size,
|
|
1845
|
+
attached: this.slotConfig.attached === true,
|
|
775
1846
|
});
|
|
776
1847
|
this.hasTrackedImpression = false;
|
|
777
1848
|
// Skip if same ad (rotation disabled or same ad returned)
|
|
@@ -825,11 +1896,15 @@ export class Banner {
|
|
|
825
1896
|
window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
|
|
826
1897
|
});
|
|
827
1898
|
dummyElement.addEventListener('mouseenter', () => {
|
|
828
|
-
|
|
1899
|
+
// Phase 7: keep the bg change (informative), drop the scale
|
|
1900
|
+
// (movement) when the user prefers reduced motion.
|
|
1901
|
+
if (!prefersReducedMotion())
|
|
1902
|
+
dummyElement.style.transform = 'scale(1.02)';
|
|
829
1903
|
dummyElement.style.background = '#f0f0f0';
|
|
830
1904
|
});
|
|
831
1905
|
dummyElement.addEventListener('mouseleave', () => {
|
|
832
|
-
|
|
1906
|
+
if (!prefersReducedMotion())
|
|
1907
|
+
dummyElement.style.transform = 'scale(1)';
|
|
833
1908
|
dummyElement.style.background = '#f9f9f9';
|
|
834
1909
|
});
|
|
835
1910
|
container.appendChild(dummyElement);
|
|
@@ -840,20 +1915,37 @@ export class Banner {
|
|
|
840
1915
|
container.innerHTML = '';
|
|
841
1916
|
adElement.className = 'sovads-banner';
|
|
842
1917
|
adElement.setAttribute('data-ad-id', this.currentAd.id);
|
|
1918
|
+
// Phase 7: announce the unit as an advertisement to AT users so it
|
|
1919
|
+
// can be navigated to / skipped past with rotor + landmark shortcuts.
|
|
1920
|
+
adElement.setAttribute('role', 'region');
|
|
1921
|
+
adElement.setAttribute('aria-label', 'Advertisement');
|
|
843
1922
|
const mediaType = this.currentAd.mediaType === 'video' ? 'video' : 'image';
|
|
1923
|
+
// Phase 2: resolve click-target + disclosure once for this render pass.
|
|
1924
|
+
// Default for Banner stays 'media' = today's behaviour (backcompat).
|
|
1925
|
+
// Video / streaming embeds always render an explicit Learn-more button
|
|
1926
|
+
// regardless of clickTarget, because the iframe / <video> controls
|
|
1927
|
+
// intercept pointer events.
|
|
1928
|
+
const slotClickTarget = this.slotConfig.clickTarget ?? 'media';
|
|
1929
|
+
const useButtonCta = slotClickTarget === 'button' || mediaType === 'video';
|
|
844
1930
|
adElement.style.cssText = `
|
|
1931
|
+
position: relative;
|
|
845
1932
|
border: 1px solid #333;
|
|
846
1933
|
border-radius: 8px;
|
|
847
1934
|
overflow: hidden;
|
|
848
|
-
cursor: ${
|
|
1935
|
+
cursor: ${useButtonCta ? 'default' : 'pointer'};
|
|
849
1936
|
transition: transform 0.2s ease;
|
|
850
1937
|
max-width: 100%;
|
|
851
1938
|
width: 100%;
|
|
852
1939
|
box-sizing: border-box;
|
|
853
1940
|
opacity: 0;
|
|
854
1941
|
`;
|
|
855
|
-
//
|
|
856
|
-
|
|
1942
|
+
// Phase 3: hide-until-loaded ONLY when we didn't reserve a CLS-safe
|
|
1943
|
+
// box up front. When `sizeReserved` is true the container is already
|
|
1944
|
+
// showing a placeholder of the right shape; hiding it now would
|
|
1945
|
+
// re-introduce the layout shift we just prevented.
|
|
1946
|
+
if (!sizeReserved) {
|
|
1947
|
+
container.style.display = 'none';
|
|
1948
|
+
}
|
|
857
1949
|
const handleVisibilityTracking = (renderInfo) => {
|
|
858
1950
|
this.sovads.setupRenderObserver(adElement, this.currentAd.id, (isVisible) => {
|
|
859
1951
|
renderInfo.viewportVisible = isVisible;
|
|
@@ -885,7 +1977,15 @@ export class Banner {
|
|
|
885
1977
|
});
|
|
886
1978
|
};
|
|
887
1979
|
let mediaElement;
|
|
888
|
-
|
|
1980
|
+
const streamingEmbed = toStreamingEmbed(this.currentAd.bannerUrl);
|
|
1981
|
+
if (streamingEmbed) {
|
|
1982
|
+
// Streaming platform (YouTube/Vimeo/TikTok) — sandboxed iframe.
|
|
1983
|
+
const iframe = buildStreamingIframe(streamingEmbed, this.currentAd.description);
|
|
1984
|
+
iframe.addEventListener('load', handleRenderSuccess, { once: true });
|
|
1985
|
+
iframe.addEventListener('error', handleRenderError, { once: true });
|
|
1986
|
+
mediaElement = iframe;
|
|
1987
|
+
}
|
|
1988
|
+
else if (mediaType === 'video') {
|
|
889
1989
|
const video = document.createElement('video');
|
|
890
1990
|
video.src = this.currentAd.bannerUrl;
|
|
891
1991
|
video.muted = true;
|
|
@@ -907,9 +2007,21 @@ export class Banner {
|
|
|
907
2007
|
img.addEventListener('error', handleRenderError, { once: true });
|
|
908
2008
|
mediaElement = img;
|
|
909
2009
|
}
|
|
910
|
-
|
|
2010
|
+
// Streaming iframes can't be made clickable as a whole — the iframe
|
|
2011
|
+
// intercepts pointer events for its own player UI. Video <video> tags
|
|
2012
|
+
// get an external "Learn more" button below. Only plain images get the
|
|
2013
|
+
// banner-as-link cursor.
|
|
2014
|
+
mediaElement.style.cursor = mediaType === 'video' || streamingEmbed ? 'default' : 'pointer';
|
|
911
2015
|
mediaElement.style.maxWidth = '100%';
|
|
912
2016
|
const handleClickThrough = () => {
|
|
2017
|
+
// When the campaign is out of token budget, banner click-through is
|
|
2018
|
+
// suppressed — viewers earn via the attached CTAs instead.
|
|
2019
|
+
if (this.currentAd.bannerClickActive === false) {
|
|
2020
|
+
if (this.sovads.getConfig().debug) {
|
|
2021
|
+
console.log('[SovAds] banner click suppressed (budget exhausted, attached CTAs active)');
|
|
2022
|
+
}
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
913
2025
|
this.sovads._trackEvent('CLICK', this.currentAd.id, this.currentAd.campaignId, {
|
|
914
2026
|
rendered: true,
|
|
915
2027
|
viewportVisible: true,
|
|
@@ -923,7 +2035,11 @@ export class Banner {
|
|
|
923
2035
|
});
|
|
924
2036
|
window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
|
|
925
2037
|
};
|
|
926
|
-
if (
|
|
2038
|
+
if (useButtonCta) {
|
|
2039
|
+
// Explicit "Learn more" button is the only click target. Used for:
|
|
2040
|
+
// - video / streaming embeds (player intercepts pointer events)
|
|
2041
|
+
// - slots configured with `clickTarget: 'button'` (Phase 2 opt-in
|
|
2042
|
+
// for higher-quality click traffic).
|
|
927
2043
|
const ctaButton = document.createElement('button');
|
|
928
2044
|
ctaButton.type = 'button';
|
|
929
2045
|
ctaButton.textContent = 'Learn more';
|
|
@@ -943,17 +2059,61 @@ export class Banner {
|
|
|
943
2059
|
adElement.appendChild(ctaButton);
|
|
944
2060
|
}
|
|
945
2061
|
else {
|
|
2062
|
+
// Legacy: whole element is the click target. Backwards compatible.
|
|
946
2063
|
adElement.addEventListener('click', handleClickThrough);
|
|
947
2064
|
adElement.appendChild(mediaElement);
|
|
948
2065
|
}
|
|
949
|
-
//
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
2066
|
+
// Phase 2: mount the Sponsored disclosure on every render unless
|
|
2067
|
+
// explicitly suppressed by config or slot override.
|
|
2068
|
+
const disclosure = buildPositionedDisclosure({
|
|
2069
|
+
slotOverride: this.slotConfig.disclosureLabel,
|
|
2070
|
+
configValue: this.sovads.getConfig().disclosureLabel,
|
|
2071
|
+
advertiser: this.sovads.getConfig().advertiserName,
|
|
2072
|
+
variant: 'dark',
|
|
2073
|
+
position: 'top-left',
|
|
955
2074
|
});
|
|
2075
|
+
if (disclosure)
|
|
2076
|
+
adElement.appendChild(disclosure);
|
|
2077
|
+
// Add hover effect
|
|
2078
|
+
// Only animate the whole element when the whole element is the click
|
|
2079
|
+
// target. With an explicit button the user's pointer is over the button,
|
|
2080
|
+
// not the media, so the scale would be misleading. Phase 7: also skip
|
|
2081
|
+
// the animation entirely when the user has asked the OS for reduced
|
|
2082
|
+
// motion \u2014 the click affordance is already in the cursor.
|
|
2083
|
+
if (!useButtonCta && !prefersReducedMotion()) {
|
|
2084
|
+
adElement.addEventListener('mouseenter', () => {
|
|
2085
|
+
adElement.style.transform = 'scale(1.02)';
|
|
2086
|
+
});
|
|
2087
|
+
adElement.addEventListener('mouseleave', () => {
|
|
2088
|
+
adElement.style.transform = 'scale(1)';
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
956
2091
|
container.appendChild(adElement);
|
|
2092
|
+
// Mount attached CTAs under the banner when the slot opted in and the
|
|
2093
|
+
// server returned at least one. When bannerClickActive=false this is the
|
|
2094
|
+
// only way the viewer can earn from this impression.
|
|
2095
|
+
if (this.slotConfig.attached === true &&
|
|
2096
|
+
Array.isArray(this.currentAd.attachedTasks) &&
|
|
2097
|
+
this.currentAd.attachedTasks.length > 0) {
|
|
2098
|
+
try {
|
|
2099
|
+
renderAttachedCtas({
|
|
2100
|
+
container,
|
|
2101
|
+
sovads: this.sovads,
|
|
2102
|
+
tasks: this.currentAd.attachedTasks,
|
|
2103
|
+
campaignId: this.currentAd.campaignId,
|
|
2104
|
+
bannerClickActive: this.currentAd.bannerClickActive !== false,
|
|
2105
|
+
onComplete: this.slotConfig.onCtaComplete,
|
|
2106
|
+
// 2 tasks → horizontal row (saves the second line under a thin
|
|
2107
|
+
// banner); 1 or 3+ tasks → stack as before.
|
|
2108
|
+
layout: 'auto',
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2111
|
+
catch (e) {
|
|
2112
|
+
if (this.sovads.getConfig().debug) {
|
|
2113
|
+
console.error('[SovAds] renderAttachedCtas failed', e);
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
957
2117
|
// Set up auto-refresh if enabled
|
|
958
2118
|
this.setupAutoRefresh(consumerId);
|
|
959
2119
|
}
|
|
@@ -1011,14 +2171,21 @@ export class Banner {
|
|
|
1011
2171
|
this.render(consumerId);
|
|
1012
2172
|
return;
|
|
1013
2173
|
}
|
|
2174
|
+
// Disconnect any previous observer before creating a new one
|
|
2175
|
+
if (this.lazyLoadObserver) {
|
|
2176
|
+
this.lazyLoadObserver.disconnect();
|
|
2177
|
+
this.lazyLoadObserver = null;
|
|
2178
|
+
}
|
|
1014
2179
|
const observer = new IntersectionObserver((entries) => {
|
|
1015
2180
|
entries.forEach((entry) => {
|
|
1016
2181
|
if (entry.isIntersecting && !this.isRendering) {
|
|
1017
2182
|
observer.disconnect();
|
|
2183
|
+
this.lazyLoadObserver = null;
|
|
1018
2184
|
this.render(consumerId);
|
|
1019
2185
|
}
|
|
1020
2186
|
});
|
|
1021
2187
|
}, { rootMargin: '50px' });
|
|
2188
|
+
this.lazyLoadObserver = observer;
|
|
1022
2189
|
observer.observe(container);
|
|
1023
2190
|
}
|
|
1024
2191
|
setupAutoRefresh(consumerId) {
|
|
@@ -1040,9 +2207,12 @@ export class Banner {
|
|
|
1040
2207
|
clearInterval(this.refreshTimer);
|
|
1041
2208
|
this.refreshTimer = null;
|
|
1042
2209
|
}
|
|
2210
|
+
if (this.lazyLoadObserver) {
|
|
2211
|
+
this.lazyLoadObserver.disconnect();
|
|
2212
|
+
this.lazyLoadObserver = null;
|
|
2213
|
+
}
|
|
1043
2214
|
}
|
|
1044
2215
|
}
|
|
1045
|
-
// Popup Component
|
|
1046
2216
|
export class Popup {
|
|
1047
2217
|
constructor(sovads) {
|
|
1048
2218
|
this.currentAd = null;
|
|
@@ -1052,6 +2222,13 @@ export class Popup {
|
|
|
1052
2222
|
this.maxRetries = 3;
|
|
1053
2223
|
this.storageKeyLastShown = 'sovads_popup_last_shown';
|
|
1054
2224
|
this.storageKeySessionCount = 'sovads_popup_session_count';
|
|
2225
|
+
/** Phase 1: remembered across the show \u2192 renderPopup boundary so the CTA
|
|
2226
|
+
* mount has access to the original opts without changing renderPopup's
|
|
2227
|
+
* signature (kept private to preserve subclass compatibility). */
|
|
2228
|
+
this.currentOpts = {};
|
|
2229
|
+
/** Phase 7: keyboard escape hatch. Bound once per show() so we can
|
|
2230
|
+
* removeEventListener on hide() and avoid listener leaks. */
|
|
2231
|
+
this.escHandler = null;
|
|
1055
2232
|
this.sovads = sovads;
|
|
1056
2233
|
}
|
|
1057
2234
|
canShowByFrequencyCap() {
|
|
@@ -1082,7 +2259,27 @@ export class Popup {
|
|
|
1082
2259
|
// Ignore storage access issues.
|
|
1083
2260
|
}
|
|
1084
2261
|
}
|
|
1085
|
-
|
|
2262
|
+
/**
|
|
2263
|
+
* Show the popup. Two call shapes (both supported \u2014 backwards compatible):
|
|
2264
|
+
*
|
|
2265
|
+
* popup.show() // defaults
|
|
2266
|
+
* popup.show('consumer-id', 3000) // legacy positional
|
|
2267
|
+
* popup.show({ consumerId, delay, attached, onCtaComplete }) // recommended
|
|
2268
|
+
*/
|
|
2269
|
+
async show(consumerIdOrOpts, delay) {
|
|
2270
|
+
// Normalise the two call shapes into a single opts object. Old positional
|
|
2271
|
+
// calls take precedence over delay defaults to preserve today's semantics.
|
|
2272
|
+
let opts;
|
|
2273
|
+
if (typeof consumerIdOrOpts === 'string' || consumerIdOrOpts === undefined) {
|
|
2274
|
+
opts = {
|
|
2275
|
+
consumerId: consumerIdOrOpts,
|
|
2276
|
+
delay: delay ?? 3000,
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
else {
|
|
2280
|
+
opts = { delay: 3000, ...consumerIdOrOpts };
|
|
2281
|
+
}
|
|
2282
|
+
this.currentOpts = opts;
|
|
1086
2283
|
// Prevent concurrent shows
|
|
1087
2284
|
if (this.isShowing) {
|
|
1088
2285
|
if (this.sovads.getConfig().debug) {
|
|
@@ -1099,16 +2296,17 @@ export class Popup {
|
|
|
1099
2296
|
this.isShowing = true;
|
|
1100
2297
|
try {
|
|
1101
2298
|
this.currentAd = await this.sovads.loadAd({
|
|
1102
|
-
consumerId,
|
|
2299
|
+
consumerId: opts.consumerId,
|
|
1103
2300
|
placement: 'popup',
|
|
1104
2301
|
size: window.innerWidth < 640 ? '320x100' : '360x120',
|
|
2302
|
+
attached: opts.attached === true,
|
|
1105
2303
|
});
|
|
1106
2304
|
if (!this.currentAd) {
|
|
1107
2305
|
if (this.retryCount < this.maxRetries) {
|
|
1108
2306
|
this.retryCount++;
|
|
1109
2307
|
await new Promise(resolve => setTimeout(resolve, 1000 * this.retryCount));
|
|
1110
2308
|
this.isShowing = false;
|
|
1111
|
-
return this.show(
|
|
2309
|
+
return this.show(opts);
|
|
1112
2310
|
}
|
|
1113
2311
|
if (this.sovads.getConfig().debug) {
|
|
1114
2312
|
console.log('No popup ad available after retries');
|
|
@@ -1123,7 +2321,7 @@ export class Popup {
|
|
|
1123
2321
|
this.renderPopup();
|
|
1124
2322
|
this.markShown();
|
|
1125
2323
|
this.isShowing = false;
|
|
1126
|
-
}, delay);
|
|
2324
|
+
}, opts.delay ?? 3000);
|
|
1127
2325
|
}
|
|
1128
2326
|
catch (error) {
|
|
1129
2327
|
if (this.sovads.getConfig().debug) {
|
|
@@ -1157,6 +2355,13 @@ export class Popup {
|
|
|
1157
2355
|
// Create non-blocking sticky container
|
|
1158
2356
|
const wrapper = document.createElement('div');
|
|
1159
2357
|
wrapper.className = 'sovads-popup-overlay';
|
|
2358
|
+
// Phase 7: dialog semantics for AT. `aria-modal=false` because the
|
|
2359
|
+
// popup deliberately does NOT block page interaction — viewers should
|
|
2360
|
+
// be able to keep reading and tab past it. Focus is not trapped here
|
|
2361
|
+
// for the same reason.
|
|
2362
|
+
wrapper.setAttribute('role', 'dialog');
|
|
2363
|
+
wrapper.setAttribute('aria-modal', 'false');
|
|
2364
|
+
wrapper.setAttribute('aria-label', 'Advertisement');
|
|
1160
2365
|
wrapper.style.cssText = `
|
|
1161
2366
|
position: fixed;
|
|
1162
2367
|
right: 16px;
|
|
@@ -1214,7 +2419,12 @@ export class Popup {
|
|
|
1214
2419
|
closeBtn.addEventListener('click', () => {
|
|
1215
2420
|
this.hide();
|
|
1216
2421
|
});
|
|
1217
|
-
// Add "Ad" message text below logo
|
|
2422
|
+
// Add "Ad" message text below logo. Phase 2: label text is configurable
|
|
2423
|
+
// via SovAdsConfig.disclosureLabel / show({ disclosureLabel }). Passing
|
|
2424
|
+
// `false` hides this badge entirely. (advertiserName isn't relevant here
|
|
2425
|
+
// — this is the legacy inline label; the SDK-wide advertiser hint only
|
|
2426
|
+
// shows up via buildPositionedDisclosure, used by Banner/Sidebar.)
|
|
2427
|
+
const popupDisclosureLabel = resolveDisclosureLabel(this.currentOpts.disclosureLabel, this.sovads.getConfig().disclosureLabel);
|
|
1218
2428
|
const adLabel = document.createElement('div');
|
|
1219
2429
|
adLabel.style.cssText = `
|
|
1220
2430
|
position: absolute;
|
|
@@ -1226,7 +2436,13 @@ export class Popup {
|
|
|
1226
2436
|
text-transform: uppercase;
|
|
1227
2437
|
letter-spacing: 0.5px;
|
|
1228
2438
|
`;
|
|
1229
|
-
|
|
2439
|
+
if (popupDisclosureLabel) {
|
|
2440
|
+
adLabel.textContent = popupDisclosureLabel;
|
|
2441
|
+
}
|
|
2442
|
+
else {
|
|
2443
|
+
// Suppressed by config \u2014 detach so it never enters the DOM.
|
|
2444
|
+
adLabel.style.display = 'none';
|
|
2445
|
+
}
|
|
1230
2446
|
// Handle dummy ads
|
|
1231
2447
|
if (this.currentAd.isDummy) {
|
|
1232
2448
|
const dummyContent = document.createElement('div');
|
|
@@ -1261,6 +2477,7 @@ export class Popup {
|
|
|
1261
2477
|
this.popupElement.appendChild(dummyContent);
|
|
1262
2478
|
wrapper.appendChild(this.popupElement);
|
|
1263
2479
|
document.body.appendChild(wrapper);
|
|
2480
|
+
this.bindEscHandler();
|
|
1264
2481
|
// Auto close after 10 seconds
|
|
1265
2482
|
setTimeout(() => {
|
|
1266
2483
|
this.hide();
|
|
@@ -1279,7 +2496,20 @@ export class Popup {
|
|
|
1279
2496
|
trackPopupImpression(false, renderTime);
|
|
1280
2497
|
};
|
|
1281
2498
|
let mediaElement;
|
|
1282
|
-
|
|
2499
|
+
const streamingEmbed = toStreamingEmbed(this.currentAd.bannerUrl);
|
|
2500
|
+
if (streamingEmbed) {
|
|
2501
|
+
const iframe = buildStreamingIframe(streamingEmbed, this.currentAd.description);
|
|
2502
|
+
iframe.style.borderRadius = '8px';
|
|
2503
|
+
iframe.addEventListener('load', () => {
|
|
2504
|
+
if (this.popupElement)
|
|
2505
|
+
this.popupElement.style.opacity = '1';
|
|
2506
|
+
const renderTime = Date.now() - renderStartTime;
|
|
2507
|
+
trackPopupImpression(true, renderTime);
|
|
2508
|
+
}, { once: true });
|
|
2509
|
+
iframe.addEventListener('error', handleMediaError, { once: true });
|
|
2510
|
+
mediaElement = iframe;
|
|
2511
|
+
}
|
|
2512
|
+
else if (mediaType === 'video') {
|
|
1283
2513
|
const video = document.createElement('video');
|
|
1284
2514
|
video.src = this.currentAd.bannerUrl;
|
|
1285
2515
|
video.muted = true;
|
|
@@ -1334,7 +2564,11 @@ export class Popup {
|
|
|
1334
2564
|
window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
|
|
1335
2565
|
this.hide();
|
|
1336
2566
|
};
|
|
1337
|
-
|
|
2567
|
+
// Phase 2: caller can force a button click target instead of the
|
|
2568
|
+
// legacy click-the-whole-image behaviour.
|
|
2569
|
+
const popupClickTarget = this.currentOpts.clickTarget ?? 'media';
|
|
2570
|
+
const useButtonCta = popupClickTarget === 'button' || mediaType === 'video' || !!streamingEmbed;
|
|
2571
|
+
if (useButtonCta) {
|
|
1338
2572
|
mediaElement.style.cursor = 'default';
|
|
1339
2573
|
}
|
|
1340
2574
|
else {
|
|
@@ -1345,7 +2579,7 @@ export class Popup {
|
|
|
1345
2579
|
this.popupElement.appendChild(adLabel);
|
|
1346
2580
|
this.popupElement.appendChild(closeBtn);
|
|
1347
2581
|
this.popupElement.appendChild(mediaElement);
|
|
1348
|
-
if (
|
|
2582
|
+
if (useButtonCta) {
|
|
1349
2583
|
const ctaButton = document.createElement('button');
|
|
1350
2584
|
ctaButton.type = 'button';
|
|
1351
2585
|
ctaButton.textContent = 'Learn more';
|
|
@@ -1364,14 +2598,66 @@ export class Popup {
|
|
|
1364
2598
|
ctaButton.addEventListener('click', handleClickThrough);
|
|
1365
2599
|
this.popupElement.appendChild(ctaButton);
|
|
1366
2600
|
}
|
|
2601
|
+
// Phase 1: mount attached CTAs inside the popup card when opted in.
|
|
2602
|
+
if (this.currentOpts.attached === true &&
|
|
2603
|
+
Array.isArray(this.currentAd.attachedTasks) &&
|
|
2604
|
+
this.currentAd.attachedTasks.length > 0) {
|
|
2605
|
+
const ctaSlot = document.createElement('div');
|
|
2606
|
+
ctaSlot.style.cssText = 'margin-top:10px;';
|
|
2607
|
+
this.popupElement.appendChild(ctaSlot);
|
|
2608
|
+
mountCtaPanel({
|
|
2609
|
+
container: ctaSlot,
|
|
2610
|
+
sovads: this.sovads,
|
|
2611
|
+
surface: 'POPUP',
|
|
2612
|
+
tasks: this.currentAd.attachedTasks,
|
|
2613
|
+
campaignId: this.currentAd.campaignId,
|
|
2614
|
+
bannerClickActive: this.currentAd.bannerClickActive !== false,
|
|
2615
|
+
onComplete: this.currentOpts.onCtaComplete,
|
|
2616
|
+
// 2 tasks → horizontal row to keep the popup compact; otherwise
|
|
2617
|
+
// stack so 3+ tasks don't crush their labels.
|
|
2618
|
+
layout: 'auto',
|
|
2619
|
+
});
|
|
2620
|
+
}
|
|
1367
2621
|
wrapper.appendChild(this.popupElement);
|
|
1368
2622
|
document.body.appendChild(wrapper);
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
2623
|
+
this.bindEscHandler();
|
|
2624
|
+
// Auto close after 10 seconds \u2014 but only when there are no CTAs to
|
|
2625
|
+
// complete. If the viewer might be mid-interaction with an attached task
|
|
2626
|
+
// (typing, signing, waiting for dwell), keep the card open until they
|
|
2627
|
+
// dismiss it manually.
|
|
2628
|
+
const hasCtas = this.currentOpts.attached === true &&
|
|
2629
|
+
Array.isArray(this.currentAd?.attachedTasks) &&
|
|
2630
|
+
(this.currentAd?.attachedTasks?.length ?? 0) > 0;
|
|
2631
|
+
if (!hasCtas) {
|
|
2632
|
+
setTimeout(() => {
|
|
2633
|
+
this.hide();
|
|
2634
|
+
}, 10000);
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
/** Phase 7: keyboard escape hatch. The popup is a non-modal sticky card,
|
|
2638
|
+
* so we don't trap focus — but pressing Esc anywhere should dismiss it.
|
|
2639
|
+
* Bound on each renderPopup() so re-shows install a fresh handler, and
|
|
2640
|
+
* always paired with removeEventListener in hide(). */
|
|
2641
|
+
bindEscHandler() {
|
|
2642
|
+
if (typeof document === 'undefined')
|
|
2643
|
+
return;
|
|
2644
|
+
if (this.escHandler) {
|
|
2645
|
+
document.removeEventListener('keydown', this.escHandler);
|
|
2646
|
+
}
|
|
2647
|
+
this.escHandler = (ev) => {
|
|
2648
|
+
if (ev.key === 'Escape' || ev.key === 'Esc') {
|
|
2649
|
+
this.hide();
|
|
2650
|
+
}
|
|
2651
|
+
};
|
|
2652
|
+
document.addEventListener('keydown', this.escHandler);
|
|
1373
2653
|
}
|
|
1374
2654
|
hide() {
|
|
2655
|
+
// Phase 7: detach the Esc key listener before tearing down the DOM so
|
|
2656
|
+
// we don't leak handlers across show → hide cycles.
|
|
2657
|
+
if (this.escHandler) {
|
|
2658
|
+
document.removeEventListener('keydown', this.escHandler);
|
|
2659
|
+
this.escHandler = null;
|
|
2660
|
+
}
|
|
1375
2661
|
const overlay = document.querySelector('.sovads-popup-overlay');
|
|
1376
2662
|
if (overlay) {
|
|
1377
2663
|
try {
|
|
@@ -1393,7 +2679,6 @@ export class Popup {
|
|
|
1393
2679
|
this.currentAd = null;
|
|
1394
2680
|
}
|
|
1395
2681
|
}
|
|
1396
|
-
// BottomBar Component
|
|
1397
2682
|
export class BottomBar {
|
|
1398
2683
|
constructor(sovads) {
|
|
1399
2684
|
this.barElement = null;
|
|
@@ -1401,9 +2686,30 @@ export class BottomBar {
|
|
|
1401
2686
|
this.isVisible = false;
|
|
1402
2687
|
this.retryCount = 0;
|
|
1403
2688
|
this.maxRetries = 3;
|
|
2689
|
+
/** Phase 1: remembered across show \u2192 renderBar so the CTA mount has
|
|
2690
|
+
* access to the original opts. */
|
|
2691
|
+
this.currentOpts = {};
|
|
2692
|
+
/** Phase 7: keyboard escape hatch. Bound when the bar is appended to
|
|
2693
|
+
* the DOM, removed in hide() to avoid listener leaks. */
|
|
2694
|
+
this.escHandler = null;
|
|
1404
2695
|
this.sovads = sovads;
|
|
1405
2696
|
}
|
|
1406
|
-
|
|
2697
|
+
/**
|
|
2698
|
+
* Show the bottom bar. Two call shapes (both supported \u2014 backwards compatible):
|
|
2699
|
+
*
|
|
2700
|
+
* bottomBar.show()
|
|
2701
|
+
* bottomBar.show('consumer-id') // legacy positional
|
|
2702
|
+
* bottomBar.show({ consumerId, attached, onCtaComplete }) // recommended
|
|
2703
|
+
*/
|
|
2704
|
+
async show(consumerIdOrOpts) {
|
|
2705
|
+
let opts;
|
|
2706
|
+
if (typeof consumerIdOrOpts === 'string' || consumerIdOrOpts === undefined) {
|
|
2707
|
+
opts = { consumerId: consumerIdOrOpts };
|
|
2708
|
+
}
|
|
2709
|
+
else {
|
|
2710
|
+
opts = { ...consumerIdOrOpts };
|
|
2711
|
+
}
|
|
2712
|
+
this.currentOpts = opts;
|
|
1407
2713
|
if (this.isVisible) {
|
|
1408
2714
|
if (this.sovads.getConfig().debug) {
|
|
1409
2715
|
console.warn('BottomBar already visible');
|
|
@@ -1412,15 +2718,16 @@ export class BottomBar {
|
|
|
1412
2718
|
}
|
|
1413
2719
|
try {
|
|
1414
2720
|
this.currentAd = await this.sovads.loadAd({
|
|
1415
|
-
consumerId,
|
|
2721
|
+
consumerId: opts.consumerId,
|
|
1416
2722
|
placement: 'bottom-bar',
|
|
1417
2723
|
size: 'full-width',
|
|
2724
|
+
attached: opts.attached === true,
|
|
1418
2725
|
});
|
|
1419
2726
|
if (!this.currentAd) {
|
|
1420
2727
|
if (this.retryCount < this.maxRetries) {
|
|
1421
2728
|
this.retryCount++;
|
|
1422
2729
|
await new Promise(resolve => setTimeout(resolve, 1000 * this.retryCount));
|
|
1423
|
-
return this.show(
|
|
2730
|
+
return this.show(opts);
|
|
1424
2731
|
}
|
|
1425
2732
|
if (this.sovads.getConfig().debug) {
|
|
1426
2733
|
console.log('No bottom‑bar ad available after retries');
|
|
@@ -1456,6 +2763,10 @@ export class BottomBar {
|
|
|
1456
2763
|
// wrapper fixed bottom
|
|
1457
2764
|
const wrapper = document.createElement('div');
|
|
1458
2765
|
wrapper.className = 'sovads-bottom-bar';
|
|
2766
|
+
// Phase 7: region landmark + label so AT users can jump straight to /
|
|
2767
|
+
// past the bar with rotor navigation.
|
|
2768
|
+
wrapper.setAttribute('role', 'region');
|
|
2769
|
+
wrapper.setAttribute('aria-label', 'Advertisement');
|
|
1459
2770
|
wrapper.style.cssText = `
|
|
1460
2771
|
position: fixed;
|
|
1461
2772
|
left: 0;
|
|
@@ -1541,13 +2852,113 @@ export class BottomBar {
|
|
|
1541
2852
|
this.hide();
|
|
1542
2853
|
};
|
|
1543
2854
|
bar.appendChild(closeBtn);
|
|
1544
|
-
|
|
1545
|
-
|
|
2855
|
+
// Phase 2: resolve click-target + disclosure once.
|
|
2856
|
+
// BottomBar default stays 'media' (bar-wide click) for full backwards
|
|
2857
|
+
// compatibility. Publishers worried about accidental clicks should set
|
|
2858
|
+
// `{ clickTarget: 'button' }` in the show() options, which renders an
|
|
2859
|
+
// explicit "Learn more" button inside the bar instead.
|
|
2860
|
+
const barClickTarget = this.currentOpts.clickTarget ?? 'media';
|
|
2861
|
+
const useButtonCta = barClickTarget === 'button' || mediaType === 'video';
|
|
2862
|
+
// Mount disclosure once \u2014 same badge regardless of CTA layout.
|
|
2863
|
+
// We anchor it inside `bar` because `wrapper` is the fullscreen rail.
|
|
2864
|
+
const disclosure = buildPositionedDisclosure({
|
|
2865
|
+
slotOverride: this.currentOpts.disclosureLabel,
|
|
2866
|
+
configValue: this.sovads.getConfig().disclosureLabel,
|
|
2867
|
+
advertiser: this.sovads.getConfig().advertiserName,
|
|
2868
|
+
variant: 'light',
|
|
2869
|
+
position: 'top-left',
|
|
2870
|
+
});
|
|
2871
|
+
if (disclosure)
|
|
2872
|
+
bar.appendChild(disclosure);
|
|
2873
|
+
// Phase 1: when CTAs are requested + returned, lay media + CTA panel in a
|
|
2874
|
+
// horizontal row. Media keeps its own click target (so the existing
|
|
2875
|
+
// banner-click path still works); CTA buttons get their own click handlers
|
|
2876
|
+
// and we suppress the bar-wide click handler so taps on a poll option
|
|
2877
|
+
// don't double-fire as a banner click + redirect.
|
|
2878
|
+
const hasCtas = this.currentOpts.attached === true &&
|
|
2879
|
+
Array.isArray(this.currentAd.attachedTasks) &&
|
|
2880
|
+
this.currentAd.attachedTasks.length > 0;
|
|
2881
|
+
if (hasCtas) {
|
|
2882
|
+
const row = document.createElement('div');
|
|
2883
|
+
row.style.cssText = 'display:flex;flex-direction:row;gap:12px;align-items:center;width:100%;';
|
|
2884
|
+
const mediaCol = document.createElement('div');
|
|
2885
|
+
mediaCol.style.cssText = `flex:1 1 auto;min-width:0;cursor:${useButtonCta ? 'default' : 'pointer'};`;
|
|
2886
|
+
mediaCol.appendChild(mediaEl);
|
|
2887
|
+
// Phase 2: only the media column is clickable when clickTarget='media'.
|
|
2888
|
+
// With 'button', media is silent and viewers must use the inline CTA
|
|
2889
|
+
// panel (which has its own per-task buttons).
|
|
2890
|
+
if (!useButtonCta) {
|
|
2891
|
+
mediaCol.addEventListener('click', handleClick);
|
|
2892
|
+
}
|
|
2893
|
+
row.appendChild(mediaCol);
|
|
2894
|
+
const ctaCol = document.createElement('div');
|
|
2895
|
+
ctaCol.style.cssText = 'flex:0 0 auto;min-width:160px;max-width:50%;';
|
|
2896
|
+
// Stop propagation so taps on CTA buttons don't bubble into the bar's
|
|
2897
|
+
// legacy click handler (kept off in this branch, but defence-in-depth).
|
|
2898
|
+
ctaCol.addEventListener('click', (e) => e.stopPropagation());
|
|
2899
|
+
row.appendChild(ctaCol);
|
|
2900
|
+
bar.appendChild(row);
|
|
2901
|
+
mountCtaPanel({
|
|
2902
|
+
container: ctaCol,
|
|
2903
|
+
sovads: this.sovads,
|
|
2904
|
+
surface: 'BOTTOM_BAR',
|
|
2905
|
+
tasks: this.currentAd.attachedTasks,
|
|
2906
|
+
campaignId: this.currentAd.campaignId,
|
|
2907
|
+
bannerClickActive: this.currentAd.bannerClickActive !== false,
|
|
2908
|
+
onComplete: this.currentOpts.onCtaComplete,
|
|
2909
|
+
layout: 'inline',
|
|
2910
|
+
});
|
|
2911
|
+
}
|
|
2912
|
+
else if (useButtonCta) {
|
|
2913
|
+
// No attached tasks but caller wants an explicit click target. Render
|
|
2914
|
+
// the media + a "Learn more" pill underneath, same as Banner/Popup.
|
|
2915
|
+
bar.style.cursor = 'default';
|
|
2916
|
+
bar.appendChild(mediaEl);
|
|
2917
|
+
const ctaButton = document.createElement('button');
|
|
2918
|
+
ctaButton.type = 'button';
|
|
2919
|
+
ctaButton.textContent = 'Learn more';
|
|
2920
|
+
ctaButton.style.cssText = `
|
|
2921
|
+
display:block;
|
|
2922
|
+
margin: 8px auto 0;
|
|
2923
|
+
border: none;
|
|
2924
|
+
border-radius: 6px;
|
|
2925
|
+
background: #111;
|
|
2926
|
+
color: #fff;
|
|
2927
|
+
font-size: 12px;
|
|
2928
|
+
font-weight: 600;
|
|
2929
|
+
padding: 8px 16px;
|
|
2930
|
+
cursor: pointer;
|
|
2931
|
+
`;
|
|
2932
|
+
ctaButton.addEventListener('click', (e) => {
|
|
2933
|
+
e.stopPropagation();
|
|
2934
|
+
handleClick();
|
|
2935
|
+
});
|
|
2936
|
+
bar.appendChild(ctaButton);
|
|
2937
|
+
}
|
|
2938
|
+
else {
|
|
2939
|
+
bar.appendChild(mediaEl);
|
|
2940
|
+
bar.addEventListener('click', handleClick);
|
|
2941
|
+
}
|
|
1546
2942
|
wrapper.appendChild(bar);
|
|
1547
2943
|
document.body.appendChild(wrapper);
|
|
1548
2944
|
this.barElement = wrapper;
|
|
2945
|
+
// Phase 7: dismiss on Esc. The bar is non-modal so we don't trap focus,
|
|
2946
|
+
// but the keyboard still needs an explicit escape hatch.
|
|
2947
|
+
if (typeof document !== 'undefined') {
|
|
2948
|
+
if (this.escHandler)
|
|
2949
|
+
document.removeEventListener('keydown', this.escHandler);
|
|
2950
|
+
this.escHandler = (ev) => {
|
|
2951
|
+
if (ev.key === 'Escape' || ev.key === 'Esc')
|
|
2952
|
+
this.hide();
|
|
2953
|
+
};
|
|
2954
|
+
document.addEventListener('keydown', this.escHandler);
|
|
2955
|
+
}
|
|
1549
2956
|
}
|
|
1550
2957
|
hide() {
|
|
2958
|
+
if (this.escHandler) {
|
|
2959
|
+
document.removeEventListener('keydown', this.escHandler);
|
|
2960
|
+
this.escHandler = null;
|
|
2961
|
+
}
|
|
1551
2962
|
if (this.barElement && this.barElement.isConnected) {
|
|
1552
2963
|
this.barElement.remove();
|
|
1553
2964
|
}
|
|
@@ -1564,6 +2975,7 @@ export class Sidebar {
|
|
|
1564
2975
|
this.hasTrackedImpression = false;
|
|
1565
2976
|
this.isRendering = false;
|
|
1566
2977
|
this.refreshTimer = null;
|
|
2978
|
+
this.lazyLoadObserver = null;
|
|
1567
2979
|
this.lastAdId = null;
|
|
1568
2980
|
this.retryCount = 0;
|
|
1569
2981
|
this.maxRetries = 3;
|
|
@@ -1587,6 +2999,11 @@ export class Sidebar {
|
|
|
1587
2999
|
this.isRendering = false;
|
|
1588
3000
|
return;
|
|
1589
3001
|
}
|
|
3002
|
+
// Phase 3: CLS reservation. See Banner.render() for the rationale.
|
|
3003
|
+
// Sidebars are usually fixed-width but variable-height, so reserving
|
|
3004
|
+
// the IAB aspect ratio still prevents the column re-flow that today
|
|
3005
|
+
// happens when the ad image loads.
|
|
3006
|
+
reserveAdSlot(container, this.slotConfig.size);
|
|
1590
3007
|
// Lazy loading: wait for container to be in viewport
|
|
1591
3008
|
if (this.sovads.getConfig().lazyLoad && !forceRefresh) {
|
|
1592
3009
|
const isInViewport = await this.checkViewport(container);
|
|
@@ -1601,6 +3018,7 @@ export class Sidebar {
|
|
|
1601
3018
|
consumerId,
|
|
1602
3019
|
placement: this.slotConfig.placementId || 'sidebar',
|
|
1603
3020
|
size: this.slotConfig.size,
|
|
3021
|
+
attached: this.slotConfig.attached === true,
|
|
1604
3022
|
});
|
|
1605
3023
|
this.hasTrackedImpression = false;
|
|
1606
3024
|
// Skip if same ad (rotation disabled or same ad returned)
|
|
@@ -1656,11 +3074,13 @@ export class Sidebar {
|
|
|
1656
3074
|
});
|
|
1657
3075
|
dummyElement.addEventListener('mouseenter', () => {
|
|
1658
3076
|
dummyElement.style.background = '#f0f0f0';
|
|
1659
|
-
|
|
3077
|
+
if (!prefersReducedMotion())
|
|
3078
|
+
dummyElement.style.transform = 'translateY(-2px)';
|
|
1660
3079
|
});
|
|
1661
3080
|
dummyElement.addEventListener('mouseleave', () => {
|
|
1662
3081
|
dummyElement.style.background = '#f9f9f9';
|
|
1663
|
-
|
|
3082
|
+
if (!prefersReducedMotion())
|
|
3083
|
+
dummyElement.style.transform = 'translateY(0)';
|
|
1664
3084
|
});
|
|
1665
3085
|
container.appendChild(dummyElement);
|
|
1666
3086
|
this.isRendering = false;
|
|
@@ -1670,14 +3090,21 @@ export class Sidebar {
|
|
|
1670
3090
|
container.innerHTML = '';
|
|
1671
3091
|
adElement.className = 'sovads-sidebar';
|
|
1672
3092
|
adElement.setAttribute('data-ad-id', this.currentAd.id);
|
|
3093
|
+
// Phase 7: a11y — see Banner.render() for rationale.
|
|
3094
|
+
adElement.setAttribute('role', 'region');
|
|
3095
|
+
adElement.setAttribute('aria-label', 'Advertisement');
|
|
1673
3096
|
const mediaType = this.currentAd.mediaType === 'video' ? 'video' : 'image';
|
|
3097
|
+
// Phase 2: same click-target / disclosure pattern as Banner.
|
|
3098
|
+
const slotClickTarget = this.slotConfig.clickTarget ?? 'media';
|
|
3099
|
+
const useButtonCta = slotClickTarget === 'button' || mediaType === 'video';
|
|
1674
3100
|
adElement.style.cssText = `
|
|
3101
|
+
position: relative;
|
|
1675
3102
|
background: #f8f9fa;
|
|
1676
3103
|
border: 1px solid #e9ecef;
|
|
1677
3104
|
border-radius: 8px;
|
|
1678
3105
|
padding: 15px;
|
|
1679
3106
|
margin-bottom: 15px;
|
|
1680
|
-
cursor: ${
|
|
3107
|
+
cursor: ${useButtonCta ? 'default' : 'pointer'};
|
|
1681
3108
|
transition: all 0.2s ease;
|
|
1682
3109
|
opacity: 0;
|
|
1683
3110
|
`;
|
|
@@ -1747,17 +3174,20 @@ export class Sidebar {
|
|
|
1747
3174
|
});
|
|
1748
3175
|
window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
|
|
1749
3176
|
};
|
|
1750
|
-
// Add hover effect
|
|
3177
|
+
// Add hover effect. Phase 7: bg change always fires; translateY only
|
|
3178
|
+
// when motion is allowed.
|
|
1751
3179
|
adElement.addEventListener('mouseenter', () => {
|
|
1752
3180
|
adElement.style.background = '#e9ecef';
|
|
1753
|
-
|
|
3181
|
+
if (!prefersReducedMotion())
|
|
3182
|
+
adElement.style.transform = 'translateY(-2px)';
|
|
1754
3183
|
});
|
|
1755
3184
|
adElement.addEventListener('mouseleave', () => {
|
|
1756
3185
|
adElement.style.background = '#f8f9fa';
|
|
1757
|
-
|
|
3186
|
+
if (!prefersReducedMotion())
|
|
3187
|
+
adElement.style.transform = 'translateY(0)';
|
|
1758
3188
|
});
|
|
1759
|
-
mediaElement.style.cursor =
|
|
1760
|
-
if (
|
|
3189
|
+
mediaElement.style.cursor = useButtonCta ? 'default' : 'pointer';
|
|
3190
|
+
if (useButtonCta) {
|
|
1761
3191
|
const ctaButton = document.createElement('button');
|
|
1762
3192
|
ctaButton.type = 'button';
|
|
1763
3193
|
ctaButton.textContent = 'Learn more';
|
|
@@ -1781,7 +3211,33 @@ export class Sidebar {
|
|
|
1781
3211
|
adElement.addEventListener('click', handleClickThrough);
|
|
1782
3212
|
adElement.appendChild(mediaElement);
|
|
1783
3213
|
}
|
|
3214
|
+
// Phase 2: mount disclosure badge after media so it stacks on top.
|
|
3215
|
+
const disclosure = buildPositionedDisclosure({
|
|
3216
|
+
slotOverride: this.slotConfig.disclosureLabel,
|
|
3217
|
+
configValue: this.sovads.getConfig().disclosureLabel,
|
|
3218
|
+
advertiser: this.sovads.getConfig().advertiserName,
|
|
3219
|
+
variant: 'light',
|
|
3220
|
+
position: 'top-left',
|
|
3221
|
+
});
|
|
3222
|
+
if (disclosure)
|
|
3223
|
+
adElement.appendChild(disclosure);
|
|
1784
3224
|
container.appendChild(adElement);
|
|
3225
|
+
// Phase 1: mount attached CTAs under the sidebar ad when the slot opted
|
|
3226
|
+
// in and the server returned at least one task. Same semantics as Banner.
|
|
3227
|
+
if (this.slotConfig.attached === true &&
|
|
3228
|
+
Array.isArray(this.currentAd.attachedTasks) &&
|
|
3229
|
+
this.currentAd.attachedTasks.length > 0) {
|
|
3230
|
+
mountCtaPanel({
|
|
3231
|
+
container,
|
|
3232
|
+
sovads: this.sovads,
|
|
3233
|
+
surface: 'SIDEBAR',
|
|
3234
|
+
tasks: this.currentAd.attachedTasks,
|
|
3235
|
+
campaignId: this.currentAd.campaignId,
|
|
3236
|
+
bannerClickActive: this.currentAd.bannerClickActive !== false,
|
|
3237
|
+
onComplete: this.slotConfig.onCtaComplete,
|
|
3238
|
+
layout: 'stack',
|
|
3239
|
+
});
|
|
3240
|
+
}
|
|
1785
3241
|
// Set up auto-refresh if enabled
|
|
1786
3242
|
this.setupAutoRefresh(consumerId);
|
|
1787
3243
|
}
|
|
@@ -1836,14 +3292,20 @@ export class Sidebar {
|
|
|
1836
3292
|
this.render(consumerId);
|
|
1837
3293
|
return;
|
|
1838
3294
|
}
|
|
3295
|
+
if (this.lazyLoadObserver) {
|
|
3296
|
+
this.lazyLoadObserver.disconnect();
|
|
3297
|
+
this.lazyLoadObserver = null;
|
|
3298
|
+
}
|
|
1839
3299
|
const observer = new IntersectionObserver((entries) => {
|
|
1840
3300
|
entries.forEach((entry) => {
|
|
1841
3301
|
if (entry.isIntersecting && !this.isRendering) {
|
|
1842
3302
|
observer.disconnect();
|
|
3303
|
+
this.lazyLoadObserver = null;
|
|
1843
3304
|
this.render(consumerId);
|
|
1844
3305
|
}
|
|
1845
3306
|
});
|
|
1846
3307
|
}, { rootMargin: '50px' });
|
|
3308
|
+
this.lazyLoadObserver = observer;
|
|
1847
3309
|
observer.observe(container);
|
|
1848
3310
|
}
|
|
1849
3311
|
setupAutoRefresh(consumerId) {
|
|
@@ -1864,33 +3326,147 @@ export class Sidebar {
|
|
|
1864
3326
|
clearInterval(this.refreshTimer);
|
|
1865
3327
|
this.refreshTimer = null;
|
|
1866
3328
|
}
|
|
3329
|
+
if (this.lazyLoadObserver) {
|
|
3330
|
+
this.lazyLoadObserver.disconnect();
|
|
3331
|
+
this.lazyLoadObserver = null;
|
|
3332
|
+
}
|
|
1867
3333
|
}
|
|
1868
3334
|
}
|
|
1869
|
-
// Default export for easy importing
|
|
1870
|
-
// Overlay Component
|
|
1871
3335
|
export class Overlay {
|
|
1872
3336
|
constructor(sovads) {
|
|
1873
3337
|
this.currentAd = null;
|
|
1874
3338
|
this.overlayElement = null;
|
|
3339
|
+
this.isShowing = false;
|
|
3340
|
+
this.currentOpts = {};
|
|
3341
|
+
this.escHandler = null;
|
|
3342
|
+
this.previousBodyOverflow = '';
|
|
3343
|
+
// Subclasses (Interstitial) override these to get independent caps.
|
|
3344
|
+
this.storageKeyLastShown = 'sovads_overlay_last_shown';
|
|
3345
|
+
this.storageKeySessionCount = 'sovads_overlay_session_count';
|
|
3346
|
+
this.placement = 'overlay';
|
|
1875
3347
|
this.sovads = sovads;
|
|
1876
3348
|
}
|
|
1877
|
-
|
|
3349
|
+
canShowByFrequencyCap() {
|
|
3350
|
+
try {
|
|
3351
|
+
// Reuse Popup's tuning knobs \u2014 publishers shouldn't have to think about
|
|
3352
|
+
// a second set of dials for the overlay surface.
|
|
3353
|
+
const minIntervalMs = (this.sovads.getConfig().popupMinIntervalMinutes || 30) * 60 * 1000;
|
|
3354
|
+
const sessionMax = this.sovads.getConfig().popupSessionMax || 1;
|
|
3355
|
+
const now = Date.now();
|
|
3356
|
+
const lastShown = Number(localStorage.getItem(this.storageKeyLastShown) || 0);
|
|
3357
|
+
const sessionCount = Number(sessionStorage.getItem(this.storageKeySessionCount) || 0);
|
|
3358
|
+
if (sessionCount >= sessionMax)
|
|
3359
|
+
return false;
|
|
3360
|
+
if (lastShown > 0 && now - lastShown < minIntervalMs)
|
|
3361
|
+
return false;
|
|
3362
|
+
return true;
|
|
3363
|
+
}
|
|
3364
|
+
catch {
|
|
3365
|
+
return true;
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
markShown() {
|
|
3369
|
+
try {
|
|
3370
|
+
const now = Date.now();
|
|
3371
|
+
const currentSessionCount = Number(sessionStorage.getItem(this.storageKeySessionCount) || 0);
|
|
3372
|
+
localStorage.setItem(this.storageKeyLastShown, String(now));
|
|
3373
|
+
sessionStorage.setItem(this.storageKeySessionCount, String(currentSessionCount + 1));
|
|
3374
|
+
}
|
|
3375
|
+
catch {
|
|
3376
|
+
// Ignore storage failures (private mode, quota, etc).
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
/**
|
|
3380
|
+
* Show the overlay. Two call shapes (both supported \u2014 backwards compatible):
|
|
3381
|
+
*
|
|
3382
|
+
* overlay.show() // defaults
|
|
3383
|
+
* overlay.show('consumer-id') // legacy positional
|
|
3384
|
+
* overlay.show({ consumerId, attached, onCtaComplete, ... })
|
|
3385
|
+
*/
|
|
3386
|
+
async show(consumerIdOrOpts) {
|
|
3387
|
+
let opts;
|
|
3388
|
+
if (typeof consumerIdOrOpts === 'string' || consumerIdOrOpts === undefined) {
|
|
3389
|
+
opts = { consumerId: consumerIdOrOpts };
|
|
3390
|
+
}
|
|
3391
|
+
else {
|
|
3392
|
+
opts = { ...consumerIdOrOpts };
|
|
3393
|
+
}
|
|
3394
|
+
this.currentOpts = opts;
|
|
3395
|
+
if (this.isShowing) {
|
|
3396
|
+
if (this.sovads.getConfig().debug) {
|
|
3397
|
+
console.warn('Overlay show already in progress');
|
|
3398
|
+
}
|
|
3399
|
+
return;
|
|
3400
|
+
}
|
|
3401
|
+
if (!this.canShowByFrequencyCap()) {
|
|
3402
|
+
if (this.sovads.getConfig().debug) {
|
|
3403
|
+
console.log('Overlay skipped due to frequency cap');
|
|
3404
|
+
}
|
|
3405
|
+
return;
|
|
3406
|
+
}
|
|
3407
|
+
this.isShowing = true;
|
|
3408
|
+
try {
|
|
3409
|
+
this.currentAd = await this.sovads.loadAd({
|
|
3410
|
+
consumerId: opts.consumerId,
|
|
3411
|
+
placement: this.placement,
|
|
3412
|
+
attached: opts.attached === true,
|
|
3413
|
+
});
|
|
3414
|
+
if (!this.currentAd) {
|
|
3415
|
+
this.isShowing = false;
|
|
3416
|
+
return;
|
|
3417
|
+
}
|
|
3418
|
+
this.renderOverlay();
|
|
3419
|
+
this.markShown();
|
|
3420
|
+
}
|
|
3421
|
+
catch (err) {
|
|
3422
|
+
if (this.sovads.getConfig().debug) {
|
|
3423
|
+
console.error('Overlay show failed:', err);
|
|
3424
|
+
}
|
|
3425
|
+
this.isShowing = false;
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
renderOverlay() {
|
|
1878
3429
|
if (!this.currentAd)
|
|
1879
3430
|
return;
|
|
3431
|
+
const renderStart = Date.now();
|
|
3432
|
+
let impressionTracked = false;
|
|
3433
|
+
const trackImp = (rendered) => {
|
|
3434
|
+
if (impressionTracked || !this.currentAd || this.currentAd.isDummy)
|
|
3435
|
+
return;
|
|
3436
|
+
impressionTracked = true;
|
|
3437
|
+
this.sovads._trackEvent('IMPRESSION', this.currentAd.id, this.currentAd.campaignId, {
|
|
3438
|
+
rendered,
|
|
3439
|
+
viewportVisible: true,
|
|
3440
|
+
renderTime: Date.now() - renderStart,
|
|
3441
|
+
});
|
|
3442
|
+
this.sovads.logInteraction('IMPRESSION', {
|
|
3443
|
+
adId: this.currentAd.id,
|
|
3444
|
+
campaignId: this.currentAd.campaignId,
|
|
3445
|
+
elementType: 'OVERLAY',
|
|
3446
|
+
metadata: { renderTime: Date.now() - renderStart },
|
|
3447
|
+
});
|
|
3448
|
+
};
|
|
1880
3449
|
const wrapper = document.createElement('div');
|
|
3450
|
+
wrapper.className = 'sovads-overlay';
|
|
3451
|
+
wrapper.setAttribute('role', 'dialog');
|
|
3452
|
+
wrapper.setAttribute('aria-modal', 'true');
|
|
3453
|
+
wrapper.setAttribute('aria-label', 'Advertisement');
|
|
1881
3454
|
wrapper.style.cssText = `
|
|
1882
|
-
position: fixed;
|
|
3455
|
+
position: fixed; inset: 0;
|
|
1883
3456
|
background: rgba(0,0,0,0.5); z-index: 10001;
|
|
1884
3457
|
display: flex; align-items: center; justify-content: center;
|
|
1885
3458
|
opacity: 0; transition: opacity 0.3s ease;
|
|
1886
3459
|
`;
|
|
1887
|
-
const
|
|
1888
|
-
|
|
1889
|
-
position: relative; max-width: 90%; max-height:
|
|
3460
|
+
const card = document.createElement('div');
|
|
3461
|
+
card.style.cssText = `
|
|
3462
|
+
position: relative; max-width: 90%; max-height: 90vh;
|
|
1890
3463
|
background: white; border-radius: 12px; overflow: hidden;
|
|
1891
3464
|
box-shadow: 0 20px 40px rgba(0,0,0,0.4);
|
|
3465
|
+
display: flex; flex-direction: column;
|
|
1892
3466
|
`;
|
|
1893
3467
|
const closeBtn = document.createElement('button');
|
|
3468
|
+
closeBtn.type = 'button';
|
|
3469
|
+
closeBtn.setAttribute('aria-label', 'Close advertisement');
|
|
1894
3470
|
closeBtn.innerHTML = '×';
|
|
1895
3471
|
closeBtn.style.cssText = `
|
|
1896
3472
|
position: absolute; top: 10px; right: 10px;
|
|
@@ -1898,74 +3474,442 @@ export class Overlay {
|
|
|
1898
3474
|
width: 30px; height: 30px; border-radius: 15px;
|
|
1899
3475
|
cursor: pointer; z-index: 11; font-size: 20px;
|
|
1900
3476
|
`;
|
|
1901
|
-
closeBtn.
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
3477
|
+
closeBtn.addEventListener('click', (e) => {
|
|
3478
|
+
e.stopPropagation();
|
|
3479
|
+
this.hide();
|
|
3480
|
+
});
|
|
3481
|
+
// Phase 2: resolve clickTarget + disclosure.
|
|
3482
|
+
const mediaType = this.currentAd.mediaType === 'video' ? 'video' : 'image';
|
|
3483
|
+
const clickTarget = this.currentOpts.clickTarget ?? 'media';
|
|
3484
|
+
const useButtonCta = clickTarget === 'button' || mediaType === 'video';
|
|
3485
|
+
let mediaEl;
|
|
3486
|
+
if (mediaType === 'video') {
|
|
3487
|
+
const video = document.createElement('video');
|
|
3488
|
+
video.src = this.currentAd.bannerUrl;
|
|
3489
|
+
video.muted = true;
|
|
3490
|
+
video.autoplay = true;
|
|
3491
|
+
video.loop = true;
|
|
3492
|
+
video.playsInline = true;
|
|
3493
|
+
video.controls = true;
|
|
3494
|
+
video.style.cssText = 'display:block; max-width:100%; height:auto;';
|
|
3495
|
+
video.addEventListener('loadeddata', () => {
|
|
3496
|
+
wrapper.style.opacity = '1';
|
|
3497
|
+
trackImp(true);
|
|
3498
|
+
}, { once: true });
|
|
3499
|
+
video.addEventListener('error', () => trackImp(false), { once: true });
|
|
3500
|
+
mediaEl = video;
|
|
3501
|
+
}
|
|
3502
|
+
else {
|
|
3503
|
+
const img = document.createElement('img');
|
|
3504
|
+
img.src = this.currentAd.bannerUrl;
|
|
3505
|
+
img.alt = this.currentAd.description;
|
|
3506
|
+
img.style.cssText = 'display:block; max-width:100%; height:auto;';
|
|
3507
|
+
img.addEventListener('load', () => {
|
|
3508
|
+
wrapper.style.opacity = '1';
|
|
3509
|
+
trackImp(true);
|
|
3510
|
+
}, { once: true });
|
|
3511
|
+
img.addEventListener('error', () => trackImp(false), { once: true });
|
|
3512
|
+
mediaEl = img;
|
|
3513
|
+
}
|
|
3514
|
+
const handleClick = () => {
|
|
3515
|
+
if (!this.currentAd)
|
|
3516
|
+
return;
|
|
3517
|
+
this.sovads._trackEvent('CLICK', this.currentAd.id, this.currentAd.campaignId, {
|
|
3518
|
+
rendered: true,
|
|
3519
|
+
viewportVisible: true,
|
|
3520
|
+
renderTime: Date.now() - renderStart,
|
|
3521
|
+
});
|
|
3522
|
+
this.sovads.logInteraction('CLICK', {
|
|
3523
|
+
adId: this.currentAd.id,
|
|
3524
|
+
campaignId: this.currentAd.campaignId,
|
|
3525
|
+
elementType: 'OVERLAY',
|
|
3526
|
+
metadata: { renderTime: Date.now() - renderStart },
|
|
3527
|
+
});
|
|
3528
|
+
window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
|
|
3529
|
+
this.hide();
|
|
1913
3530
|
};
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
3531
|
+
if (useButtonCta) {
|
|
3532
|
+
mediaEl.style.cursor = 'default';
|
|
3533
|
+
}
|
|
3534
|
+
else {
|
|
3535
|
+
mediaEl.style.cursor = 'pointer';
|
|
3536
|
+
mediaEl.addEventListener('click', handleClick);
|
|
3537
|
+
}
|
|
3538
|
+
card.appendChild(closeBtn);
|
|
3539
|
+
card.appendChild(mediaEl);
|
|
3540
|
+
if (useButtonCta) {
|
|
3541
|
+
const ctaButton = document.createElement('button');
|
|
3542
|
+
ctaButton.type = 'button';
|
|
3543
|
+
ctaButton.textContent = 'Learn more';
|
|
3544
|
+
ctaButton.style.cssText = `
|
|
3545
|
+
display:block;
|
|
3546
|
+
width: calc(100% - 24px);
|
|
3547
|
+
margin: 12px;
|
|
3548
|
+
border: none;
|
|
3549
|
+
border-radius: 6px;
|
|
3550
|
+
background: #111;
|
|
3551
|
+
color: #fff;
|
|
3552
|
+
font-size: 13px;
|
|
3553
|
+
font-weight: 600;
|
|
3554
|
+
padding: 10px 16px;
|
|
3555
|
+
cursor: pointer;
|
|
3556
|
+
`;
|
|
3557
|
+
ctaButton.addEventListener('click', (e) => {
|
|
3558
|
+
e.stopPropagation();
|
|
3559
|
+
handleClick();
|
|
3560
|
+
});
|
|
3561
|
+
card.appendChild(ctaButton);
|
|
3562
|
+
}
|
|
3563
|
+
// Phase 1: attached CTA panel.
|
|
3564
|
+
if (this.currentOpts.attached === true &&
|
|
3565
|
+
Array.isArray(this.currentAd.attachedTasks) &&
|
|
3566
|
+
this.currentAd.attachedTasks.length > 0) {
|
|
3567
|
+
const ctaSlot = document.createElement('div');
|
|
3568
|
+
ctaSlot.style.cssText = 'padding: 0 12px 12px;';
|
|
3569
|
+
ctaSlot.addEventListener('click', (e) => e.stopPropagation());
|
|
3570
|
+
card.appendChild(ctaSlot);
|
|
3571
|
+
mountCtaPanel({
|
|
3572
|
+
container: ctaSlot,
|
|
3573
|
+
sovads: this.sovads,
|
|
3574
|
+
surface: 'OVERLAY',
|
|
3575
|
+
tasks: this.currentAd.attachedTasks,
|
|
3576
|
+
campaignId: this.currentAd.campaignId,
|
|
3577
|
+
bannerClickActive: this.currentAd.bannerClickActive !== false,
|
|
3578
|
+
onComplete: this.currentOpts.onCtaComplete,
|
|
3579
|
+
layout: 'stack',
|
|
3580
|
+
});
|
|
3581
|
+
}
|
|
3582
|
+
// Phase 2: disclosure badge anchored over the card.
|
|
3583
|
+
const disclosure = buildPositionedDisclosure({
|
|
3584
|
+
slotOverride: this.currentOpts.disclosureLabel,
|
|
3585
|
+
configValue: this.sovads.getConfig().disclosureLabel,
|
|
3586
|
+
advertiser: this.sovads.getConfig().advertiserName,
|
|
3587
|
+
variant: 'light',
|
|
3588
|
+
position: 'top-left',
|
|
3589
|
+
});
|
|
3590
|
+
if (disclosure)
|
|
3591
|
+
card.appendChild(disclosure);
|
|
3592
|
+
wrapper.appendChild(card);
|
|
3593
|
+
// Backdrop click dismiss \u2014 only when the click was on the wrapper itself
|
|
3594
|
+
// (not bubbled from the card). Default on; opt-out via dismissOnBackdrop:false.
|
|
3595
|
+
const dismissOnBackdrop = this.currentOpts.dismissOnBackdrop !== false;
|
|
3596
|
+
if (dismissOnBackdrop) {
|
|
3597
|
+
wrapper.addEventListener('click', (e) => {
|
|
3598
|
+
if (e.target === wrapper)
|
|
3599
|
+
this.hide();
|
|
3600
|
+
});
|
|
3601
|
+
}
|
|
3602
|
+
// ESC dismiss \u2014 default on. We keep the handler reference so hide() can
|
|
3603
|
+
// detach it cleanly (no listener leaks across multiple show/hide cycles).
|
|
3604
|
+
const dismissOnEscape = this.currentOpts.dismissOnEscape !== false;
|
|
3605
|
+
if (dismissOnEscape) {
|
|
3606
|
+
this.escHandler = (e) => {
|
|
3607
|
+
if (e.key === 'Escape')
|
|
3608
|
+
this.hide();
|
|
3609
|
+
};
|
|
3610
|
+
window.addEventListener('keydown', this.escHandler);
|
|
3611
|
+
}
|
|
3612
|
+
// Scroll lock \u2014 stash the previous overflow so hide() can restore it
|
|
3613
|
+
// even when another script has changed body.style.overflow in the meantime.
|
|
3614
|
+
this.previousBodyOverflow = document.body.style.overflow;
|
|
3615
|
+
document.body.style.overflow = 'hidden';
|
|
1917
3616
|
document.body.appendChild(wrapper);
|
|
1918
3617
|
this.overlayElement = wrapper;
|
|
1919
3618
|
}
|
|
3619
|
+
hide() {
|
|
3620
|
+
if (this.overlayElement && this.overlayElement.isConnected) {
|
|
3621
|
+
try {
|
|
3622
|
+
this.overlayElement.remove();
|
|
3623
|
+
}
|
|
3624
|
+
catch { /* swallow */ }
|
|
3625
|
+
}
|
|
3626
|
+
this.overlayElement = null;
|
|
3627
|
+
if (this.escHandler) {
|
|
3628
|
+
window.removeEventListener('keydown', this.escHandler);
|
|
3629
|
+
this.escHandler = null;
|
|
3630
|
+
}
|
|
3631
|
+
// Restore scroll. Only touch body.style.overflow when we actually set it,
|
|
3632
|
+
// so we don't clobber a publisher's own scroll-lock for an unrelated modal.
|
|
3633
|
+
document.body.style.overflow = this.previousBodyOverflow;
|
|
3634
|
+
this.isShowing = false;
|
|
3635
|
+
}
|
|
1920
3636
|
}
|
|
1921
|
-
// Interstitial Component (Full page ad before content)
|
|
3637
|
+
// Interstitial Component (Full page ad before content). Same machinery as
|
|
3638
|
+
// Overlay but with its own frequency-cap storage so the two surfaces don't
|
|
3639
|
+
// share a counter.
|
|
1922
3640
|
export class Interstitial extends Overlay {
|
|
3641
|
+
constructor() {
|
|
3642
|
+
super(...arguments);
|
|
3643
|
+
this.storageKeyLastShown = 'sovads_interstitial_last_shown';
|
|
3644
|
+
this.storageKeySessionCount = 'sovads_interstitial_session_count';
|
|
3645
|
+
this.placement = 'interstitial';
|
|
3646
|
+
}
|
|
1923
3647
|
}
|
|
1924
|
-
// NativeCard Component
|
|
1925
3648
|
export class NativeCard {
|
|
1926
3649
|
constructor(sovads, containerId) {
|
|
1927
3650
|
this.currentAd = null;
|
|
1928
3651
|
this.sovads = sovads;
|
|
1929
3652
|
this.containerId = containerId;
|
|
1930
3653
|
}
|
|
1931
|
-
|
|
3654
|
+
/**
|
|
3655
|
+
* Render the native card. Two call shapes (backwards compatible):
|
|
3656
|
+
*
|
|
3657
|
+
* nativeCard.render()
|
|
3658
|
+
* nativeCard.render('consumer-id') // legacy positional
|
|
3659
|
+
* nativeCard.render({ consumerId, attached, onCtaComplete }) // recommended
|
|
3660
|
+
*/
|
|
3661
|
+
async render(consumerIdOrOpts) {
|
|
3662
|
+
let opts;
|
|
3663
|
+
if (typeof consumerIdOrOpts === 'string' || consumerIdOrOpts === undefined) {
|
|
3664
|
+
opts = { consumerId: consumerIdOrOpts };
|
|
3665
|
+
}
|
|
3666
|
+
else {
|
|
3667
|
+
opts = { ...consumerIdOrOpts };
|
|
3668
|
+
}
|
|
1932
3669
|
const container = document.getElementById(this.containerId);
|
|
1933
3670
|
if (!container)
|
|
1934
3671
|
return;
|
|
1935
3672
|
container.style.display = 'none';
|
|
1936
3673
|
this.currentAd = await this.sovads.loadAd({
|
|
1937
|
-
consumerId,
|
|
3674
|
+
consumerId: opts.consumerId,
|
|
1938
3675
|
placement: 'native',
|
|
3676
|
+
attached: opts.attached === true,
|
|
1939
3677
|
});
|
|
1940
3678
|
if (!this.currentAd)
|
|
1941
3679
|
return;
|
|
3680
|
+
// Phase 2: resolve disclosure + click-target once for this render pass.
|
|
3681
|
+
// NativeCard renders the disclosure as an inline text line (not the
|
|
3682
|
+
// floating badge), so we don't pass `advertiserName` here — the badge
|
|
3683
|
+
// helper handles that. The headline already names the campaign anyway.
|
|
3684
|
+
const nativeDisclosureLabel = resolveDisclosureLabel(opts.disclosureLabel, this.sovads.getConfig().disclosureLabel);
|
|
3685
|
+
const nativeClickTarget = opts.clickTarget ?? 'media';
|
|
3686
|
+
const useButtonCta = nativeClickTarget === 'button';
|
|
1942
3687
|
const card = document.createElement('div');
|
|
1943
3688
|
card.style.cssText = `
|
|
1944
3689
|
display: flex; gap: 16px; padding: 16px;
|
|
1945
3690
|
background: white; border: 1px solid #eee; border-radius: 12px;
|
|
1946
|
-
cursor: pointer; position: relative;
|
|
3691
|
+
cursor: ${useButtonCta ? 'default' : 'pointer'}; position: relative;
|
|
1947
3692
|
`;
|
|
3693
|
+
// Phase 7: a11y landmark.
|
|
3694
|
+
card.setAttribute('role', 'region');
|
|
3695
|
+
card.setAttribute('aria-label', 'Advertisement');
|
|
1948
3696
|
const img = document.createElement('img');
|
|
1949
3697
|
img.src = this.currentAd.bannerUrl;
|
|
1950
3698
|
img.style.cssText = 'width: 80px; height: 80px; object-fit: cover; border-radius: 8px;';
|
|
1951
3699
|
const content = document.createElement('div');
|
|
1952
|
-
content.
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
3700
|
+
content.style.cssText = 'flex:1 1 auto;min-width:0;';
|
|
3701
|
+
// Phase 1 nit: only append ellipsis when actually truncated.
|
|
3702
|
+
const rawDesc = this.currentAd.description || '';
|
|
3703
|
+
const headline = rawDesc.length > 40 ? `${rawDesc.slice(0, 40)}\u2026` : rawDesc;
|
|
3704
|
+
const headlineEl = document.createElement('div');
|
|
3705
|
+
headlineEl.style.cssText = 'font-weight: bold; font-size: 16px; margin-bottom: 4px;';
|
|
3706
|
+
headlineEl.textContent = headline;
|
|
3707
|
+
content.appendChild(headlineEl);
|
|
3708
|
+
// Disclosure: configurable text, or omitted entirely when disclosureLabel:false.
|
|
3709
|
+
if (nativeDisclosureLabel) {
|
|
3710
|
+
const discEl = document.createElement('div');
|
|
3711
|
+
discEl.style.cssText = 'font-size: 12px; color: #666;';
|
|
3712
|
+
discEl.textContent = nativeDisclosureLabel;
|
|
3713
|
+
content.appendChild(discEl);
|
|
3714
|
+
}
|
|
3715
|
+
// Phase 6: gate the IMPRESSION on actual viewport visibility, not just
|
|
3716
|
+
// image decode. Before this fix, NativeCards placed below-the-fold fired
|
|
3717
|
+
// a paid IMPRESSION the moment the browser decoded the image \u2014 even
|
|
3718
|
+
// when the viewer never scrolled to see them. We now wait until the
|
|
3719
|
+
// observer reports the card is on screen, and we only fire once.
|
|
3720
|
+
let nativeImpressionFired = false;
|
|
1956
3721
|
img.onload = () => {
|
|
1957
3722
|
container.style.display = 'block';
|
|
1958
|
-
this.sovads.
|
|
3723
|
+
this.sovads.setupRenderObserver(card, this.currentAd.id, (isVisible) => {
|
|
3724
|
+
if (!isVisible || nativeImpressionFired)
|
|
3725
|
+
return;
|
|
3726
|
+
nativeImpressionFired = true;
|
|
3727
|
+
this.sovads._trackEvent('IMPRESSION', this.currentAd.id, this.currentAd.campaignId);
|
|
3728
|
+
this.sovads.logInteraction('IMPRESSION', {
|
|
3729
|
+
adId: this.currentAd.id,
|
|
3730
|
+
campaignId: this.currentAd.campaignId,
|
|
3731
|
+
elementType: 'NATIVE',
|
|
3732
|
+
});
|
|
3733
|
+
});
|
|
1959
3734
|
};
|
|
1960
|
-
|
|
3735
|
+
const handleCardClick = () => {
|
|
1961
3736
|
this.sovads._trackEvent('CLICK', this.currentAd.id, this.currentAd.campaignId);
|
|
1962
|
-
|
|
3737
|
+
this.sovads.logInteraction('CLICK', {
|
|
3738
|
+
adId: this.currentAd.id,
|
|
3739
|
+
campaignId: this.currentAd.campaignId,
|
|
3740
|
+
elementType: 'NATIVE',
|
|
3741
|
+
});
|
|
3742
|
+
window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
|
|
1963
3743
|
};
|
|
3744
|
+
if (useButtonCta) {
|
|
3745
|
+
// 'button' mode: explicit "Learn more" pill, card itself is silent.
|
|
3746
|
+
const ctaButton = document.createElement('button');
|
|
3747
|
+
ctaButton.type = 'button';
|
|
3748
|
+
ctaButton.textContent = 'Learn more \u2192';
|
|
3749
|
+
ctaButton.style.cssText = `
|
|
3750
|
+
margin-top: 6px;
|
|
3751
|
+
border: none;
|
|
3752
|
+
border-radius: 6px;
|
|
3753
|
+
background: #111;
|
|
3754
|
+
color: #fff;
|
|
3755
|
+
font-size: 12px;
|
|
3756
|
+
font-weight: 600;
|
|
3757
|
+
padding: 6px 12px;
|
|
3758
|
+
cursor: pointer;
|
|
3759
|
+
align-self: flex-start;
|
|
3760
|
+
`;
|
|
3761
|
+
ctaButton.addEventListener('click', (e) => {
|
|
3762
|
+
e.stopPropagation();
|
|
3763
|
+
handleCardClick();
|
|
3764
|
+
});
|
|
3765
|
+
content.appendChild(ctaButton);
|
|
3766
|
+
}
|
|
3767
|
+
else {
|
|
3768
|
+
card.onclick = handleCardClick;
|
|
3769
|
+
}
|
|
1964
3770
|
card.appendChild(img);
|
|
1965
3771
|
card.appendChild(content);
|
|
1966
3772
|
container.innerHTML = '';
|
|
1967
3773
|
container.appendChild(card);
|
|
3774
|
+
// Phase 1: mount attached CTAs underneath the card body.
|
|
3775
|
+
if (opts.attached === true &&
|
|
3776
|
+
Array.isArray(this.currentAd.attachedTasks) &&
|
|
3777
|
+
this.currentAd.attachedTasks.length > 0) {
|
|
3778
|
+
const ctaSlot = document.createElement('div');
|
|
3779
|
+
ctaSlot.style.cssText = 'margin-top:8px;';
|
|
3780
|
+
// CTA buttons must not bubble into card.onclick (which would open the ad).
|
|
3781
|
+
ctaSlot.addEventListener('click', (e) => e.stopPropagation());
|
|
3782
|
+
container.appendChild(ctaSlot);
|
|
3783
|
+
mountCtaPanel({
|
|
3784
|
+
container: ctaSlot,
|
|
3785
|
+
sovads: this.sovads,
|
|
3786
|
+
surface: 'NATIVE',
|
|
3787
|
+
tasks: this.currentAd.attachedTasks,
|
|
3788
|
+
campaignId: this.currentAd.campaignId,
|
|
3789
|
+
bannerClickActive: this.currentAd.bannerClickActive !== false,
|
|
3790
|
+
onComplete: opts.onCtaComplete,
|
|
3791
|
+
// 2 tasks → horizontal row (native cards are narrow but tall enough
|
|
3792
|
+
// for a single CTA row); 1 or 3+ stay stacked.
|
|
3793
|
+
layout: 'auto',
|
|
3794
|
+
});
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3798
|
+
export class CtaUnit {
|
|
3799
|
+
constructor(sovads, containerId) {
|
|
3800
|
+
this.currentAd = null;
|
|
3801
|
+
this.isRendering = false;
|
|
3802
|
+
this.sovads = sovads;
|
|
3803
|
+
this.containerId = containerId;
|
|
3804
|
+
}
|
|
3805
|
+
async render(consumerIdOrOpts) {
|
|
3806
|
+
let opts;
|
|
3807
|
+
if (typeof consumerIdOrOpts === 'string' || consumerIdOrOpts === undefined) {
|
|
3808
|
+
opts = { consumerId: consumerIdOrOpts };
|
|
3809
|
+
}
|
|
3810
|
+
else {
|
|
3811
|
+
opts = { ...consumerIdOrOpts };
|
|
3812
|
+
}
|
|
3813
|
+
if (this.isRendering) {
|
|
3814
|
+
if (this.sovads.getConfig().debug) {
|
|
3815
|
+
console.warn(`CtaUnit render already in progress for ${this.containerId}`);
|
|
3816
|
+
}
|
|
3817
|
+
return;
|
|
3818
|
+
}
|
|
3819
|
+
const container = document.getElementById(this.containerId);
|
|
3820
|
+
if (!container) {
|
|
3821
|
+
console.error(`Container with id "${this.containerId}" not found`);
|
|
3822
|
+
return;
|
|
3823
|
+
}
|
|
3824
|
+
this.isRendering = true;
|
|
3825
|
+
container.style.display = 'none';
|
|
3826
|
+
try {
|
|
3827
|
+
this.currentAd = await this.sovads.loadAd({
|
|
3828
|
+
consumerId: opts.consumerId,
|
|
3829
|
+
placement: 'cta',
|
|
3830
|
+
attached: true,
|
|
3831
|
+
});
|
|
3832
|
+
if (!this.currentAd) {
|
|
3833
|
+
container.style.display = 'none';
|
|
3834
|
+
return;
|
|
3835
|
+
}
|
|
3836
|
+
// No-tasks \u2192 nothing to render. Hide the slot entirely so the publisher
|
|
3837
|
+
// page doesn't reserve empty space.
|
|
3838
|
+
const tasks = this.currentAd.attachedTasks;
|
|
3839
|
+
if (!Array.isArray(tasks) || tasks.length === 0) {
|
|
3840
|
+
container.style.display = 'none';
|
|
3841
|
+
return;
|
|
3842
|
+
}
|
|
3843
|
+
container.innerHTML = '';
|
|
3844
|
+
container.style.display = 'block';
|
|
3845
|
+
// Fire a single IMPRESSION on render so advertiser dashboards see the slot
|
|
3846
|
+
// even though no banner media was shown.
|
|
3847
|
+
this.sovads._trackEvent('IMPRESSION', this.currentAd.id, this.currentAd.campaignId, {
|
|
3848
|
+
rendered: true,
|
|
3849
|
+
viewportVisible: true,
|
|
3850
|
+
renderTime: 0,
|
|
3851
|
+
});
|
|
3852
|
+
mountCtaPanel({
|
|
3853
|
+
container,
|
|
3854
|
+
sovads: this.sovads,
|
|
3855
|
+
surface: 'NATIVE',
|
|
3856
|
+
tasks,
|
|
3857
|
+
campaignId: this.currentAd.campaignId,
|
|
3858
|
+
bannerClickActive: this.currentAd.bannerClickActive !== false,
|
|
3859
|
+
onComplete: opts.onCtaComplete,
|
|
3860
|
+
layout: opts.layout ?? 'stack',
|
|
3861
|
+
});
|
|
3862
|
+
}
|
|
3863
|
+
catch (e) {
|
|
3864
|
+
if (this.sovads.getConfig().debug) {
|
|
3865
|
+
console.error('CtaUnit render failed:', e);
|
|
3866
|
+
}
|
|
3867
|
+
}
|
|
3868
|
+
finally {
|
|
3869
|
+
this.isRendering = false;
|
|
3870
|
+
}
|
|
1968
3871
|
}
|
|
1969
3872
|
}
|
|
3873
|
+
// ============================================================================
|
|
3874
|
+
// GoodDollar reward icon
|
|
3875
|
+
// ----------------------------------------------------------------------------
|
|
3876
|
+
// Inlined as a base64 data URI so the SDK is asset-self-contained: any
|
|
3877
|
+
// consumer (publisher page, advertiser preview, e2e test) gets the icon
|
|
3878
|
+
// without an extra HTTP request or hosting concern.
|
|
3879
|
+
// Source: sdk/assets/g-dollar.png (64x64 PNG, ~2.9 KB).
|
|
3880
|
+
// ============================================================================
|
|
3881
|
+
export const GOOD_DOLLAR_ICON_DATA_URI = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAMAUExURUxpcQKy/wCw/3u//+D//wCv/zg4ygOx/wGx/3/m/zqm/wCx/wCw/wCL+QOx/wKy/yef5gCu/3zc3AKy/QCu/6ra/wCv////9f///+v5/wSx/f/+9vz+9///967k92HT+QGx/ZXc9vn/80XC+P//8///9fr/+DO99wCu/bvs9ff7/+f///3/+vX++i+9+HbS9vv++FvJ9Ija+Mbw+UvJ+Mb0/DHB+6Pm90rG9///4lvI9F/O+a3j9f//9FzK9wCw/0nF92jR+FDL+lvO+v///0zK+wCw/ITX+HLP84bc91vM9mzS+K3o+MPr9X7Z9l/Q+gGv/T/F+FjP+fz9+HbU9zrB+UfC9aLi92jN9vb59DXB+k/H9KPq8bTo91nI9bDm9GnS+v3++ETD92DK8////0zJ+v7/94vf/IDX9ZXd9kjD+Yza95zi9T7D+UDE+j3C92bM9GzO9XvT9HLW+ark+HXU9ZD/AJ7i91rP+kTE9m7S95Hh+U3F9v//8ZLe+Nr09Krn9Zvg82PP+QCw/Sy99zbA+fD7927P8f3983vS9IDY9sHr95bg8lbO+Yrc+LTq+GDI81TN+U3G+IXY9HfW+Sy69kbD8kTG+tTy8kvE9NDu+GDP+Te/92PK81DE9v//8mzS+onW9r3v+GbR+XvR9inD+ym9+1HJ+2LL8uL/92jP83/V9X/X93zX+F3Q+oLZ9tb18VXH9bHs91jM9UrC+P//9/n/+VfI9hW7/DrC+oHV9X3V+Iza+Nrz90fF+FLL+gKx/wOx/wOx/gCx/wSx/wOy/gOy/wCw/wG3/wC1/wCy/wCv/wu//x/E/wGy/gS7/wC4/wC0/wGw/wCu/wK6/wCw/gm6/wW8/wKy/wy9/yzD/ynC/wCz/xq39jXD/gqz+gWy/RTB/xHB/wa7/yfH/w20/CfD/zO99RK+/yPI/w6//x7A/xvB/wm9/x+9/znF/hq//w60+x/D/xjA/zS/9y7G/yLB/xnH/zjC+wm3/xm9/xu39xjC/xG6/w7J/yq/+5C2f9YAAADAdFJOUwD9+wQB+wH+/AID/fwC/f4E/gT9+wP9AgME/iAmImYI/QYH7x0bOPX+TAgFCzX3nCnTmEXcUPph4QnGwm4Zyv3c2ODGDuz8mqB406dyP6jG/PH8TaD42GXMMvbZCVzIVLVF6LIT5y2YpIv3mmDt7fCprKqweagBddnzvo3LE4RPNWrU//7zQ7czgnNkW/J9e8/335Ox78v1PNV4y+255hS1q1LEl/z50aMktZrUx+11OMVF7OlgMMz70L2hxDLo2h8hnHYAAAcoSURBVFjDpVdneNPmFj52bOuzazsOOLLj2OAQNwHiQJhJgLJnS1uXPUvZULr3HnQBnXd23dve23Fv97z79oclWZZsg2MHZzgJkEEhEFYobdPS9uknJU4kxWmSp+8PP34knaNPZ74vQC9km/CPq3Ts5dcfv9h+5Ej7oeP5l48tdXqFW9A/hGdKFs0pPFcdj3BMJROt5Kq4UPW5J3Z/XHxX/y50+O2T/jK5iWdIlZmlgxTFBqnGTDMimXjr2fsmAZh0v2SfATBhan3ioNZM0GGaovwiKJpmaEKlzeLrp04QH+oDGhN4rqjjRyLCnxYBRMbqbveATpPeXg/w/E0tJIr6+wSLyIobFwPY0tnnQPmyoxEU8P8iAog7elU5DOltPwS2XX3SSvj7BWFNznH1DkSRs2BijTroHwCCvpqZS3P0cvs8TfGSsC/gHxCCKLxktfwMtnXOiWFE+QcK5J+YK42kRuedlXD7BwFUMb3cpJHUz7KkOjAYB0Ft8jYw9NhPO2EN+geFoPXEtFQY9DrXNaj//AUw6ED3OYkZN18mdA6GAW6tQelMKEKFkFqNzIJ3gszCOBjpvmusuaXzI7Lhyjqj8gOCgVp6qI+LxVqSp2Mhzm02h+vOHj58uOPx7lRRxvorxe7W26fEzMr6p1n3SP5U4QNP/XXB2PvWjmmKx/7xZEFBcck/n30s9bGUKvSCTSMcYNQxK6ssAXY+X/dmaW5nkOyuO/6+tkD45/xv3NzzjPHYKGxuyNmcUCteHyXIr6Y+jA1MBsvwPIvgYwTkeGHbs7yj51WUumKzEIU9Z7MUJVDrYOp25oBONBXznIeLLdueWxZyS48ayJrsxHf3NauV/XbJ5PVgycFZ9haPWv9Irh3Agl+VWxZDAVb6oLb5Oez505hKljuatdZtAAsOz7btsx//5vyZMfc85IF1UDCb9wUZSnoEc2icF1yFDC0LAD2/6SHIsIPz/jXJcIQ0ZnENrfNWQklZzKdMNs0UuqD0HCkLQa2Kv9ZmsMGDH3x7EKkcFIXLScu07VgR89HKSqPJczfA2GqVPIHGtx+GIn3uh7y1scuCrnWQSd7N1vbqN1S9EMaFzLIQqPhVGk0G7PxW1l6U0Zym1mlVaBzkc5nSuFDqA8MgD5xrjI091ySQNwtbdT1ciMjKkOCe8OAB+/5+lDo/hbQSVMq/97HjcJGrlV5SxR4AE4wua0hFhvV9d6Ab589/J/MQrDoE7ZWy2lDXvIOr5t2/VXUdIDi/9X9PD0th2itlYcnkoCimHY5UybKjPv0WDIdR35CUeDlgbfr/aOn4nSItO4qubIQjYdlXqZMLsIMNbUYxMMGspi/mWoZ3Ii9jSIZ9Fa900F4ZZaQOWnbYLfDImSzBAYX+M16+/eFamQOK+RIOcbICRYn3cAxcYzihaIjo5zsX7No7ftd4jO3P4OlZtEJWNkIQcRr9sv5YW6TT5awNDaXFwXT6ZHK/gGTyp6dwenOXR2hZGi9APpMpvURXjfHgQlrU7BOzyxjVIrTIeuYzvAA3tJHSqsmsyselrJI6oFDT3fhBz70zHLKZroqPG6GxwELZ/BZLWdlMjtDL5boMWHlCG6SjqWBTjcaOTyBPd+k1ldIFEhaaqfSUrJ0pB//aXI3GAAt+sDpS1cwOJY89CtkZsKtV3aud8UCROkXxratBjz1ofne0QY0IIkCo3GSo41HQ62HjmksclHKg2KSZrXWH5+3B2cIudLB+9qlEOJzFNFScefUlsHgN3lny1verQv8uAs2ikz3HMsfnFYA+G0aDzmYC78rrNv3hcOHM25/WgAF7vTNplLeztnkfrq7VPWOdYLaWAJ6fznv2YtIk7F7vg8WXjRAYlAU8d/6ooCCdYx0vloquIxCOjzZ6t2wp2Tg71PZ7D76V18kALMKCWDqrFbHygaRO/Bm3fudqEy9UNt67KT8/f1NH3G2snnf3FmE1mSyiE+f9y0NaxVBmreJqE5Yr3xUbhgszDBM+ODRAI+6rrdsnvN65Gt9Y9mJrxK3cnyg2xWYSCeKEr1P8jHU4CILIpKM0TfiY0/V/+uP0VdNXLG+riRgdvQhHar2DwX5ri4xgMF30ApFhHiPEkZjA9KJw3QQDc+S+KA7eKRhmIh3/k1AcgWQd0A6eZN3RwzUzchb+KpqHiWb57gQajAO3nGhi1uqa6B8M1W3AVFcv1zoFS8LGAcYBk+3fFCgJf97cpb+tGCDd1yZmPjlaKTk068A1cMHhsaVRXiOg/KqjXH+SJyhInrlQ1IfoWiyILrZPa4o1G1tuXGxPL7q6ZR/Z1ykINJL/+oq+ZZ9EeKrNRAAzg1QD0LRQ1dpIov7qSZBOsSmlb0czz8xAKhbL3ij+YTulb/Pk6/qVvl3i+19YfH+PxXcVx0RF8R2v/r5w93MlA9Xvovy/YQeW/4cE+X9RkP/PuPBoNGXf1evpnwH9wKyz1N0ryQAAAABJRU5ErkJggg==';
|
|
3882
|
+
/**
|
|
3883
|
+
* Build a compact reward badge ("Earn <icon> <amount>") that ad surfaces
|
|
3884
|
+
* overlay to surface the viewer-reward. Returns an HTMLElement; caller is
|
|
3885
|
+
* responsible for absolute positioning over the creative.
|
|
3886
|
+
*/
|
|
3887
|
+
export function buildRewardBadge(opts) {
|
|
3888
|
+
const amount = opts?.amount ?? '0.5';
|
|
3889
|
+
const variant = opts?.variant ?? 'dark';
|
|
3890
|
+
const bg = variant === 'dark' ? '#2D2D2D' : 'rgba(255,255,255,0.95)';
|
|
3891
|
+
const fg = variant === 'dark' ? '#FFFFFF' : '#2D2D2D';
|
|
3892
|
+
const span = document.createElement('span');
|
|
3893
|
+
span.className = 'sovads-reward-badge';
|
|
3894
|
+
span.setAttribute('role', 'note');
|
|
3895
|
+
span.setAttribute('aria-label', `Earn ${amount} GoodDollar`);
|
|
3896
|
+
span.style.cssText =
|
|
3897
|
+
`display:inline-flex;align-items:center;gap:4px;` +
|
|
3898
|
+
`padding:2px 6px 2px 4px;border-radius:999px;` +
|
|
3899
|
+
`font-size:11px;font-weight:800;line-height:1;letter-spacing:0.02em;` +
|
|
3900
|
+
`background:${bg};color:${fg};` +
|
|
3901
|
+
`font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;`;
|
|
3902
|
+
const icon = document.createElement('img');
|
|
3903
|
+
icon.src = GOOD_DOLLAR_ICON_DATA_URI;
|
|
3904
|
+
icon.alt = '';
|
|
3905
|
+
icon.width = 12;
|
|
3906
|
+
icon.height = 12;
|
|
3907
|
+
icon.style.cssText = 'display:block;border-radius:50%;flex:none;';
|
|
3908
|
+
const text = document.createElement('span');
|
|
3909
|
+
text.textContent = `Earn ${amount}`;
|
|
3910
|
+
span.appendChild(icon);
|
|
3911
|
+
span.appendChild(text);
|
|
3912
|
+
return span;
|
|
3913
|
+
}
|
|
1970
3914
|
export default SovAds;
|
|
1971
3915
|
//# sourceMappingURL=index.js.map
|