sovads-sdk 1.0.9 → 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 +229 -93
- package/dist/index.d.ts +483 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2021 -81
- package/dist/index.js.map +1 -1
- package/package.json +6 -7
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;
|
|
@@ -556,7 +611,7 @@ export class SovAds {
|
|
|
556
611
|
headers: {
|
|
557
612
|
'Content-Type': 'application/json',
|
|
558
613
|
'Cache-Control': 'no-cache',
|
|
559
|
-
'X-SovAds-SDK-Version':
|
|
614
|
+
'X-SovAds-SDK-Version': SDK_VERSION,
|
|
560
615
|
},
|
|
561
616
|
body: envelope,
|
|
562
617
|
keepalive: true,
|
|
@@ -679,6 +734,89 @@ export class SovAds {
|
|
|
679
734
|
getConfig() {
|
|
680
735
|
return this.config;
|
|
681
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
|
+
}
|
|
682
820
|
/**
|
|
683
821
|
* Log interaction (public method for components)
|
|
684
822
|
*/
|
|
@@ -726,7 +864,927 @@ export class SovAds {
|
|
|
726
864
|
destroy() {
|
|
727
865
|
this.renderObservers.forEach((observer) => observer.disconnect());
|
|
728
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 };
|
|
729
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;
|
|
730
1788
|
}
|
|
731
1789
|
// Banner Component
|
|
732
1790
|
export class Banner {
|
|
@@ -736,6 +1794,7 @@ export class Banner {
|
|
|
736
1794
|
this.hasTrackedImpression = false;
|
|
737
1795
|
this.isRendering = false;
|
|
738
1796
|
this.refreshTimer = null;
|
|
1797
|
+
this.lazyLoadObserver = null;
|
|
739
1798
|
this.lastAdId = null;
|
|
740
1799
|
this.retryCount = 0;
|
|
741
1800
|
this.maxRetries = 3;
|
|
@@ -759,8 +1818,15 @@ export class Banner {
|
|
|
759
1818
|
this.isRendering = false;
|
|
760
1819
|
return;
|
|
761
1820
|
}
|
|
762
|
-
//
|
|
763
|
-
|
|
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
|
+
}
|
|
764
1830
|
// Lazy loading: wait for container to be in viewport
|
|
765
1831
|
if (this.sovads.getConfig().lazyLoad && !forceRefresh) {
|
|
766
1832
|
const isInViewport = await this.checkViewport(container);
|
|
@@ -776,6 +1842,7 @@ export class Banner {
|
|
|
776
1842
|
consumerId,
|
|
777
1843
|
placement: this.slotConfig.placementId || 'banner',
|
|
778
1844
|
size: this.slotConfig.size,
|
|
1845
|
+
attached: this.slotConfig.attached === true,
|
|
779
1846
|
});
|
|
780
1847
|
this.hasTrackedImpression = false;
|
|
781
1848
|
// Skip if same ad (rotation disabled or same ad returned)
|
|
@@ -829,11 +1896,15 @@ export class Banner {
|
|
|
829
1896
|
window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
|
|
830
1897
|
});
|
|
831
1898
|
dummyElement.addEventListener('mouseenter', () => {
|
|
832
|
-
|
|
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)';
|
|
833
1903
|
dummyElement.style.background = '#f0f0f0';
|
|
834
1904
|
});
|
|
835
1905
|
dummyElement.addEventListener('mouseleave', () => {
|
|
836
|
-
|
|
1906
|
+
if (!prefersReducedMotion())
|
|
1907
|
+
dummyElement.style.transform = 'scale(1)';
|
|
837
1908
|
dummyElement.style.background = '#f9f9f9';
|
|
838
1909
|
});
|
|
839
1910
|
container.appendChild(dummyElement);
|
|
@@ -844,20 +1915,37 @@ export class Banner {
|
|
|
844
1915
|
container.innerHTML = '';
|
|
845
1916
|
adElement.className = 'sovads-banner';
|
|
846
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');
|
|
847
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';
|
|
848
1930
|
adElement.style.cssText = `
|
|
1931
|
+
position: relative;
|
|
849
1932
|
border: 1px solid #333;
|
|
850
1933
|
border-radius: 8px;
|
|
851
1934
|
overflow: hidden;
|
|
852
|
-
cursor: ${
|
|
1935
|
+
cursor: ${useButtonCta ? 'default' : 'pointer'};
|
|
853
1936
|
transition: transform 0.2s ease;
|
|
854
1937
|
max-width: 100%;
|
|
855
1938
|
width: 100%;
|
|
856
1939
|
box-sizing: border-box;
|
|
857
1940
|
opacity: 0;
|
|
858
1941
|
`;
|
|
859
|
-
//
|
|
860
|
-
|
|
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
|
+
}
|
|
861
1949
|
const handleVisibilityTracking = (renderInfo) => {
|
|
862
1950
|
this.sovads.setupRenderObserver(adElement, this.currentAd.id, (isVisible) => {
|
|
863
1951
|
renderInfo.viewportVisible = isVisible;
|
|
@@ -889,7 +1977,15 @@ export class Banner {
|
|
|
889
1977
|
});
|
|
890
1978
|
};
|
|
891
1979
|
let mediaElement;
|
|
892
|
-
|
|
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') {
|
|
893
1989
|
const video = document.createElement('video');
|
|
894
1990
|
video.src = this.currentAd.bannerUrl;
|
|
895
1991
|
video.muted = true;
|
|
@@ -911,9 +2007,21 @@ export class Banner {
|
|
|
911
2007
|
img.addEventListener('error', handleRenderError, { once: true });
|
|
912
2008
|
mediaElement = img;
|
|
913
2009
|
}
|
|
914
|
-
|
|
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';
|
|
915
2015
|
mediaElement.style.maxWidth = '100%';
|
|
916
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
|
+
}
|
|
917
2025
|
this.sovads._trackEvent('CLICK', this.currentAd.id, this.currentAd.campaignId, {
|
|
918
2026
|
rendered: true,
|
|
919
2027
|
viewportVisible: true,
|
|
@@ -927,7 +2035,11 @@ export class Banner {
|
|
|
927
2035
|
});
|
|
928
2036
|
window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
|
|
929
2037
|
};
|
|
930
|
-
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).
|
|
931
2043
|
const ctaButton = document.createElement('button');
|
|
932
2044
|
ctaButton.type = 'button';
|
|
933
2045
|
ctaButton.textContent = 'Learn more';
|
|
@@ -947,17 +2059,61 @@ export class Banner {
|
|
|
947
2059
|
adElement.appendChild(ctaButton);
|
|
948
2060
|
}
|
|
949
2061
|
else {
|
|
2062
|
+
// Legacy: whole element is the click target. Backwards compatible.
|
|
950
2063
|
adElement.addEventListener('click', handleClickThrough);
|
|
951
2064
|
adElement.appendChild(mediaElement);
|
|
952
2065
|
}
|
|
953
|
-
//
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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',
|
|
959
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
|
+
}
|
|
960
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
|
+
}
|
|
961
2117
|
// Set up auto-refresh if enabled
|
|
962
2118
|
this.setupAutoRefresh(consumerId);
|
|
963
2119
|
}
|
|
@@ -1015,14 +2171,21 @@ export class Banner {
|
|
|
1015
2171
|
this.render(consumerId);
|
|
1016
2172
|
return;
|
|
1017
2173
|
}
|
|
2174
|
+
// Disconnect any previous observer before creating a new one
|
|
2175
|
+
if (this.lazyLoadObserver) {
|
|
2176
|
+
this.lazyLoadObserver.disconnect();
|
|
2177
|
+
this.lazyLoadObserver = null;
|
|
2178
|
+
}
|
|
1018
2179
|
const observer = new IntersectionObserver((entries) => {
|
|
1019
2180
|
entries.forEach((entry) => {
|
|
1020
2181
|
if (entry.isIntersecting && !this.isRendering) {
|
|
1021
2182
|
observer.disconnect();
|
|
2183
|
+
this.lazyLoadObserver = null;
|
|
1022
2184
|
this.render(consumerId);
|
|
1023
2185
|
}
|
|
1024
2186
|
});
|
|
1025
2187
|
}, { rootMargin: '50px' });
|
|
2188
|
+
this.lazyLoadObserver = observer;
|
|
1026
2189
|
observer.observe(container);
|
|
1027
2190
|
}
|
|
1028
2191
|
setupAutoRefresh(consumerId) {
|
|
@@ -1044,9 +2207,12 @@ export class Banner {
|
|
|
1044
2207
|
clearInterval(this.refreshTimer);
|
|
1045
2208
|
this.refreshTimer = null;
|
|
1046
2209
|
}
|
|
2210
|
+
if (this.lazyLoadObserver) {
|
|
2211
|
+
this.lazyLoadObserver.disconnect();
|
|
2212
|
+
this.lazyLoadObserver = null;
|
|
2213
|
+
}
|
|
1047
2214
|
}
|
|
1048
2215
|
}
|
|
1049
|
-
// Popup Component
|
|
1050
2216
|
export class Popup {
|
|
1051
2217
|
constructor(sovads) {
|
|
1052
2218
|
this.currentAd = null;
|
|
@@ -1056,6 +2222,13 @@ export class Popup {
|
|
|
1056
2222
|
this.maxRetries = 3;
|
|
1057
2223
|
this.storageKeyLastShown = 'sovads_popup_last_shown';
|
|
1058
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;
|
|
1059
2232
|
this.sovads = sovads;
|
|
1060
2233
|
}
|
|
1061
2234
|
canShowByFrequencyCap() {
|
|
@@ -1086,7 +2259,27 @@ export class Popup {
|
|
|
1086
2259
|
// Ignore storage access issues.
|
|
1087
2260
|
}
|
|
1088
2261
|
}
|
|
1089
|
-
|
|
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;
|
|
1090
2283
|
// Prevent concurrent shows
|
|
1091
2284
|
if (this.isShowing) {
|
|
1092
2285
|
if (this.sovads.getConfig().debug) {
|
|
@@ -1103,16 +2296,17 @@ export class Popup {
|
|
|
1103
2296
|
this.isShowing = true;
|
|
1104
2297
|
try {
|
|
1105
2298
|
this.currentAd = await this.sovads.loadAd({
|
|
1106
|
-
consumerId,
|
|
2299
|
+
consumerId: opts.consumerId,
|
|
1107
2300
|
placement: 'popup',
|
|
1108
2301
|
size: window.innerWidth < 640 ? '320x100' : '360x120',
|
|
2302
|
+
attached: opts.attached === true,
|
|
1109
2303
|
});
|
|
1110
2304
|
if (!this.currentAd) {
|
|
1111
2305
|
if (this.retryCount < this.maxRetries) {
|
|
1112
2306
|
this.retryCount++;
|
|
1113
2307
|
await new Promise(resolve => setTimeout(resolve, 1000 * this.retryCount));
|
|
1114
2308
|
this.isShowing = false;
|
|
1115
|
-
return this.show(
|
|
2309
|
+
return this.show(opts);
|
|
1116
2310
|
}
|
|
1117
2311
|
if (this.sovads.getConfig().debug) {
|
|
1118
2312
|
console.log('No popup ad available after retries');
|
|
@@ -1127,7 +2321,7 @@ export class Popup {
|
|
|
1127
2321
|
this.renderPopup();
|
|
1128
2322
|
this.markShown();
|
|
1129
2323
|
this.isShowing = false;
|
|
1130
|
-
}, delay);
|
|
2324
|
+
}, opts.delay ?? 3000);
|
|
1131
2325
|
}
|
|
1132
2326
|
catch (error) {
|
|
1133
2327
|
if (this.sovads.getConfig().debug) {
|
|
@@ -1161,6 +2355,13 @@ export class Popup {
|
|
|
1161
2355
|
// Create non-blocking sticky container
|
|
1162
2356
|
const wrapper = document.createElement('div');
|
|
1163
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');
|
|
1164
2365
|
wrapper.style.cssText = `
|
|
1165
2366
|
position: fixed;
|
|
1166
2367
|
right: 16px;
|
|
@@ -1218,7 +2419,12 @@ export class Popup {
|
|
|
1218
2419
|
closeBtn.addEventListener('click', () => {
|
|
1219
2420
|
this.hide();
|
|
1220
2421
|
});
|
|
1221
|
-
// 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);
|
|
1222
2428
|
const adLabel = document.createElement('div');
|
|
1223
2429
|
adLabel.style.cssText = `
|
|
1224
2430
|
position: absolute;
|
|
@@ -1230,7 +2436,13 @@ export class Popup {
|
|
|
1230
2436
|
text-transform: uppercase;
|
|
1231
2437
|
letter-spacing: 0.5px;
|
|
1232
2438
|
`;
|
|
1233
|
-
|
|
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
|
+
}
|
|
1234
2446
|
// Handle dummy ads
|
|
1235
2447
|
if (this.currentAd.isDummy) {
|
|
1236
2448
|
const dummyContent = document.createElement('div');
|
|
@@ -1265,6 +2477,7 @@ export class Popup {
|
|
|
1265
2477
|
this.popupElement.appendChild(dummyContent);
|
|
1266
2478
|
wrapper.appendChild(this.popupElement);
|
|
1267
2479
|
document.body.appendChild(wrapper);
|
|
2480
|
+
this.bindEscHandler();
|
|
1268
2481
|
// Auto close after 10 seconds
|
|
1269
2482
|
setTimeout(() => {
|
|
1270
2483
|
this.hide();
|
|
@@ -1283,7 +2496,20 @@ export class Popup {
|
|
|
1283
2496
|
trackPopupImpression(false, renderTime);
|
|
1284
2497
|
};
|
|
1285
2498
|
let mediaElement;
|
|
1286
|
-
|
|
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') {
|
|
1287
2513
|
const video = document.createElement('video');
|
|
1288
2514
|
video.src = this.currentAd.bannerUrl;
|
|
1289
2515
|
video.muted = true;
|
|
@@ -1338,7 +2564,11 @@ export class Popup {
|
|
|
1338
2564
|
window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
|
|
1339
2565
|
this.hide();
|
|
1340
2566
|
};
|
|
1341
|
-
|
|
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) {
|
|
1342
2572
|
mediaElement.style.cursor = 'default';
|
|
1343
2573
|
}
|
|
1344
2574
|
else {
|
|
@@ -1349,7 +2579,7 @@ export class Popup {
|
|
|
1349
2579
|
this.popupElement.appendChild(adLabel);
|
|
1350
2580
|
this.popupElement.appendChild(closeBtn);
|
|
1351
2581
|
this.popupElement.appendChild(mediaElement);
|
|
1352
|
-
if (
|
|
2582
|
+
if (useButtonCta) {
|
|
1353
2583
|
const ctaButton = document.createElement('button');
|
|
1354
2584
|
ctaButton.type = 'button';
|
|
1355
2585
|
ctaButton.textContent = 'Learn more';
|
|
@@ -1368,14 +2598,66 @@ export class Popup {
|
|
|
1368
2598
|
ctaButton.addEventListener('click', handleClickThrough);
|
|
1369
2599
|
this.popupElement.appendChild(ctaButton);
|
|
1370
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
|
+
}
|
|
1371
2621
|
wrapper.appendChild(this.popupElement);
|
|
1372
2622
|
document.body.appendChild(wrapper);
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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);
|
|
1377
2653
|
}
|
|
1378
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
|
+
}
|
|
1379
2661
|
const overlay = document.querySelector('.sovads-popup-overlay');
|
|
1380
2662
|
if (overlay) {
|
|
1381
2663
|
try {
|
|
@@ -1397,7 +2679,6 @@ export class Popup {
|
|
|
1397
2679
|
this.currentAd = null;
|
|
1398
2680
|
}
|
|
1399
2681
|
}
|
|
1400
|
-
// BottomBar Component
|
|
1401
2682
|
export class BottomBar {
|
|
1402
2683
|
constructor(sovads) {
|
|
1403
2684
|
this.barElement = null;
|
|
@@ -1405,9 +2686,30 @@ export class BottomBar {
|
|
|
1405
2686
|
this.isVisible = false;
|
|
1406
2687
|
this.retryCount = 0;
|
|
1407
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;
|
|
1408
2695
|
this.sovads = sovads;
|
|
1409
2696
|
}
|
|
1410
|
-
|
|
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;
|
|
1411
2713
|
if (this.isVisible) {
|
|
1412
2714
|
if (this.sovads.getConfig().debug) {
|
|
1413
2715
|
console.warn('BottomBar already visible');
|
|
@@ -1416,15 +2718,16 @@ export class BottomBar {
|
|
|
1416
2718
|
}
|
|
1417
2719
|
try {
|
|
1418
2720
|
this.currentAd = await this.sovads.loadAd({
|
|
1419
|
-
consumerId,
|
|
2721
|
+
consumerId: opts.consumerId,
|
|
1420
2722
|
placement: 'bottom-bar',
|
|
1421
2723
|
size: 'full-width',
|
|
2724
|
+
attached: opts.attached === true,
|
|
1422
2725
|
});
|
|
1423
2726
|
if (!this.currentAd) {
|
|
1424
2727
|
if (this.retryCount < this.maxRetries) {
|
|
1425
2728
|
this.retryCount++;
|
|
1426
2729
|
await new Promise(resolve => setTimeout(resolve, 1000 * this.retryCount));
|
|
1427
|
-
return this.show(
|
|
2730
|
+
return this.show(opts);
|
|
1428
2731
|
}
|
|
1429
2732
|
if (this.sovads.getConfig().debug) {
|
|
1430
2733
|
console.log('No bottom‑bar ad available after retries');
|
|
@@ -1460,6 +2763,10 @@ export class BottomBar {
|
|
|
1460
2763
|
// wrapper fixed bottom
|
|
1461
2764
|
const wrapper = document.createElement('div');
|
|
1462
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');
|
|
1463
2770
|
wrapper.style.cssText = `
|
|
1464
2771
|
position: fixed;
|
|
1465
2772
|
left: 0;
|
|
@@ -1545,13 +2852,113 @@ export class BottomBar {
|
|
|
1545
2852
|
this.hide();
|
|
1546
2853
|
};
|
|
1547
2854
|
bar.appendChild(closeBtn);
|
|
1548
|
-
|
|
1549
|
-
|
|
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
|
+
}
|
|
1550
2942
|
wrapper.appendChild(bar);
|
|
1551
2943
|
document.body.appendChild(wrapper);
|
|
1552
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
|
+
}
|
|
1553
2956
|
}
|
|
1554
2957
|
hide() {
|
|
2958
|
+
if (this.escHandler) {
|
|
2959
|
+
document.removeEventListener('keydown', this.escHandler);
|
|
2960
|
+
this.escHandler = null;
|
|
2961
|
+
}
|
|
1555
2962
|
if (this.barElement && this.barElement.isConnected) {
|
|
1556
2963
|
this.barElement.remove();
|
|
1557
2964
|
}
|
|
@@ -1568,6 +2975,7 @@ export class Sidebar {
|
|
|
1568
2975
|
this.hasTrackedImpression = false;
|
|
1569
2976
|
this.isRendering = false;
|
|
1570
2977
|
this.refreshTimer = null;
|
|
2978
|
+
this.lazyLoadObserver = null;
|
|
1571
2979
|
this.lastAdId = null;
|
|
1572
2980
|
this.retryCount = 0;
|
|
1573
2981
|
this.maxRetries = 3;
|
|
@@ -1591,6 +2999,11 @@ export class Sidebar {
|
|
|
1591
2999
|
this.isRendering = false;
|
|
1592
3000
|
return;
|
|
1593
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);
|
|
1594
3007
|
// Lazy loading: wait for container to be in viewport
|
|
1595
3008
|
if (this.sovads.getConfig().lazyLoad && !forceRefresh) {
|
|
1596
3009
|
const isInViewport = await this.checkViewport(container);
|
|
@@ -1605,6 +3018,7 @@ export class Sidebar {
|
|
|
1605
3018
|
consumerId,
|
|
1606
3019
|
placement: this.slotConfig.placementId || 'sidebar',
|
|
1607
3020
|
size: this.slotConfig.size,
|
|
3021
|
+
attached: this.slotConfig.attached === true,
|
|
1608
3022
|
});
|
|
1609
3023
|
this.hasTrackedImpression = false;
|
|
1610
3024
|
// Skip if same ad (rotation disabled or same ad returned)
|
|
@@ -1660,11 +3074,13 @@ export class Sidebar {
|
|
|
1660
3074
|
});
|
|
1661
3075
|
dummyElement.addEventListener('mouseenter', () => {
|
|
1662
3076
|
dummyElement.style.background = '#f0f0f0';
|
|
1663
|
-
|
|
3077
|
+
if (!prefersReducedMotion())
|
|
3078
|
+
dummyElement.style.transform = 'translateY(-2px)';
|
|
1664
3079
|
});
|
|
1665
3080
|
dummyElement.addEventListener('mouseleave', () => {
|
|
1666
3081
|
dummyElement.style.background = '#f9f9f9';
|
|
1667
|
-
|
|
3082
|
+
if (!prefersReducedMotion())
|
|
3083
|
+
dummyElement.style.transform = 'translateY(0)';
|
|
1668
3084
|
});
|
|
1669
3085
|
container.appendChild(dummyElement);
|
|
1670
3086
|
this.isRendering = false;
|
|
@@ -1674,14 +3090,21 @@ export class Sidebar {
|
|
|
1674
3090
|
container.innerHTML = '';
|
|
1675
3091
|
adElement.className = 'sovads-sidebar';
|
|
1676
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');
|
|
1677
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';
|
|
1678
3100
|
adElement.style.cssText = `
|
|
3101
|
+
position: relative;
|
|
1679
3102
|
background: #f8f9fa;
|
|
1680
3103
|
border: 1px solid #e9ecef;
|
|
1681
3104
|
border-radius: 8px;
|
|
1682
3105
|
padding: 15px;
|
|
1683
3106
|
margin-bottom: 15px;
|
|
1684
|
-
cursor: ${
|
|
3107
|
+
cursor: ${useButtonCta ? 'default' : 'pointer'};
|
|
1685
3108
|
transition: all 0.2s ease;
|
|
1686
3109
|
opacity: 0;
|
|
1687
3110
|
`;
|
|
@@ -1751,17 +3174,20 @@ export class Sidebar {
|
|
|
1751
3174
|
});
|
|
1752
3175
|
window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
|
|
1753
3176
|
};
|
|
1754
|
-
// Add hover effect
|
|
3177
|
+
// Add hover effect. Phase 7: bg change always fires; translateY only
|
|
3178
|
+
// when motion is allowed.
|
|
1755
3179
|
adElement.addEventListener('mouseenter', () => {
|
|
1756
3180
|
adElement.style.background = '#e9ecef';
|
|
1757
|
-
|
|
3181
|
+
if (!prefersReducedMotion())
|
|
3182
|
+
adElement.style.transform = 'translateY(-2px)';
|
|
1758
3183
|
});
|
|
1759
3184
|
adElement.addEventListener('mouseleave', () => {
|
|
1760
3185
|
adElement.style.background = '#f8f9fa';
|
|
1761
|
-
|
|
3186
|
+
if (!prefersReducedMotion())
|
|
3187
|
+
adElement.style.transform = 'translateY(0)';
|
|
1762
3188
|
});
|
|
1763
|
-
mediaElement.style.cursor =
|
|
1764
|
-
if (
|
|
3189
|
+
mediaElement.style.cursor = useButtonCta ? 'default' : 'pointer';
|
|
3190
|
+
if (useButtonCta) {
|
|
1765
3191
|
const ctaButton = document.createElement('button');
|
|
1766
3192
|
ctaButton.type = 'button';
|
|
1767
3193
|
ctaButton.textContent = 'Learn more';
|
|
@@ -1785,7 +3211,33 @@ export class Sidebar {
|
|
|
1785
3211
|
adElement.addEventListener('click', handleClickThrough);
|
|
1786
3212
|
adElement.appendChild(mediaElement);
|
|
1787
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);
|
|
1788
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
|
+
}
|
|
1789
3241
|
// Set up auto-refresh if enabled
|
|
1790
3242
|
this.setupAutoRefresh(consumerId);
|
|
1791
3243
|
}
|
|
@@ -1840,14 +3292,20 @@ export class Sidebar {
|
|
|
1840
3292
|
this.render(consumerId);
|
|
1841
3293
|
return;
|
|
1842
3294
|
}
|
|
3295
|
+
if (this.lazyLoadObserver) {
|
|
3296
|
+
this.lazyLoadObserver.disconnect();
|
|
3297
|
+
this.lazyLoadObserver = null;
|
|
3298
|
+
}
|
|
1843
3299
|
const observer = new IntersectionObserver((entries) => {
|
|
1844
3300
|
entries.forEach((entry) => {
|
|
1845
3301
|
if (entry.isIntersecting && !this.isRendering) {
|
|
1846
3302
|
observer.disconnect();
|
|
3303
|
+
this.lazyLoadObserver = null;
|
|
1847
3304
|
this.render(consumerId);
|
|
1848
3305
|
}
|
|
1849
3306
|
});
|
|
1850
3307
|
}, { rootMargin: '50px' });
|
|
3308
|
+
this.lazyLoadObserver = observer;
|
|
1851
3309
|
observer.observe(container);
|
|
1852
3310
|
}
|
|
1853
3311
|
setupAutoRefresh(consumerId) {
|
|
@@ -1868,33 +3326,147 @@ export class Sidebar {
|
|
|
1868
3326
|
clearInterval(this.refreshTimer);
|
|
1869
3327
|
this.refreshTimer = null;
|
|
1870
3328
|
}
|
|
3329
|
+
if (this.lazyLoadObserver) {
|
|
3330
|
+
this.lazyLoadObserver.disconnect();
|
|
3331
|
+
this.lazyLoadObserver = null;
|
|
3332
|
+
}
|
|
1871
3333
|
}
|
|
1872
3334
|
}
|
|
1873
|
-
// Default export for easy importing
|
|
1874
|
-
// Overlay Component
|
|
1875
3335
|
export class Overlay {
|
|
1876
3336
|
constructor(sovads) {
|
|
1877
3337
|
this.currentAd = null;
|
|
1878
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';
|
|
1879
3347
|
this.sovads = sovads;
|
|
1880
3348
|
}
|
|
1881
|
-
|
|
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() {
|
|
1882
3429
|
if (!this.currentAd)
|
|
1883
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
|
+
};
|
|
1884
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');
|
|
1885
3454
|
wrapper.style.cssText = `
|
|
1886
|
-
position: fixed;
|
|
3455
|
+
position: fixed; inset: 0;
|
|
1887
3456
|
background: rgba(0,0,0,0.5); z-index: 10001;
|
|
1888
3457
|
display: flex; align-items: center; justify-content: center;
|
|
1889
3458
|
opacity: 0; transition: opacity 0.3s ease;
|
|
1890
3459
|
`;
|
|
1891
|
-
const
|
|
1892
|
-
|
|
1893
|
-
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;
|
|
1894
3463
|
background: white; border-radius: 12px; overflow: hidden;
|
|
1895
3464
|
box-shadow: 0 20px 40px rgba(0,0,0,0.4);
|
|
3465
|
+
display: flex; flex-direction: column;
|
|
1896
3466
|
`;
|
|
1897
3467
|
const closeBtn = document.createElement('button');
|
|
3468
|
+
closeBtn.type = 'button';
|
|
3469
|
+
closeBtn.setAttribute('aria-label', 'Close advertisement');
|
|
1898
3470
|
closeBtn.innerHTML = '×';
|
|
1899
3471
|
closeBtn.style.cssText = `
|
|
1900
3472
|
position: absolute; top: 10px; right: 10px;
|
|
@@ -1902,74 +3474,442 @@ export class Overlay {
|
|
|
1902
3474
|
width: 30px; height: 30px; border-radius: 15px;
|
|
1903
3475
|
cursor: pointer; z-index: 11; font-size: 20px;
|
|
1904
3476
|
`;
|
|
1905
|
-
closeBtn.
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
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();
|
|
1917
3530
|
};
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
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';
|
|
1921
3616
|
document.body.appendChild(wrapper);
|
|
1922
3617
|
this.overlayElement = wrapper;
|
|
1923
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
|
+
}
|
|
1924
3636
|
}
|
|
1925
|
-
// 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.
|
|
1926
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
|
+
}
|
|
1927
3647
|
}
|
|
1928
|
-
// NativeCard Component
|
|
1929
3648
|
export class NativeCard {
|
|
1930
3649
|
constructor(sovads, containerId) {
|
|
1931
3650
|
this.currentAd = null;
|
|
1932
3651
|
this.sovads = sovads;
|
|
1933
3652
|
this.containerId = containerId;
|
|
1934
3653
|
}
|
|
1935
|
-
|
|
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
|
+
}
|
|
1936
3669
|
const container = document.getElementById(this.containerId);
|
|
1937
3670
|
if (!container)
|
|
1938
3671
|
return;
|
|
1939
3672
|
container.style.display = 'none';
|
|
1940
3673
|
this.currentAd = await this.sovads.loadAd({
|
|
1941
|
-
consumerId,
|
|
3674
|
+
consumerId: opts.consumerId,
|
|
1942
3675
|
placement: 'native',
|
|
3676
|
+
attached: opts.attached === true,
|
|
1943
3677
|
});
|
|
1944
3678
|
if (!this.currentAd)
|
|
1945
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';
|
|
1946
3687
|
const card = document.createElement('div');
|
|
1947
3688
|
card.style.cssText = `
|
|
1948
3689
|
display: flex; gap: 16px; padding: 16px;
|
|
1949
3690
|
background: white; border: 1px solid #eee; border-radius: 12px;
|
|
1950
|
-
cursor: pointer; position: relative;
|
|
3691
|
+
cursor: ${useButtonCta ? 'default' : 'pointer'}; position: relative;
|
|
1951
3692
|
`;
|
|
3693
|
+
// Phase 7: a11y landmark.
|
|
3694
|
+
card.setAttribute('role', 'region');
|
|
3695
|
+
card.setAttribute('aria-label', 'Advertisement');
|
|
1952
3696
|
const img = document.createElement('img');
|
|
1953
3697
|
img.src = this.currentAd.bannerUrl;
|
|
1954
3698
|
img.style.cssText = 'width: 80px; height: 80px; object-fit: cover; border-radius: 8px;';
|
|
1955
3699
|
const content = document.createElement('div');
|
|
1956
|
-
content.
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
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;
|
|
1960
3721
|
img.onload = () => {
|
|
1961
3722
|
container.style.display = 'block';
|
|
1962
|
-
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
|
+
});
|
|
1963
3734
|
};
|
|
1964
|
-
|
|
3735
|
+
const handleCardClick = () => {
|
|
1965
3736
|
this.sovads._trackEvent('CLICK', this.currentAd.id, this.currentAd.campaignId);
|
|
1966
|
-
|
|
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');
|
|
1967
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
|
+
}
|
|
1968
3770
|
card.appendChild(img);
|
|
1969
3771
|
card.appendChild(content);
|
|
1970
3772
|
container.innerHTML = '';
|
|
1971
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
|
+
}
|
|
1972
3871
|
}
|
|
1973
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
|
+
}
|
|
1974
3914
|
export default SovAds;
|
|
1975
3915
|
//# sourceMappingURL=index.js.map
|