sovads-sdk 1.0.9 → 1.1.1
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 +490 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2067 -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.1';
|
|
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,
|
|
@@ -28,6 +42,52 @@ export class SovAds {
|
|
|
28
42
|
if (this.config.debug) {
|
|
29
43
|
console.log('SovAds SDK initialized:', this.config);
|
|
30
44
|
}
|
|
45
|
+
// Fire-and-forget heartbeat so the publisher dashboard can show an
|
|
46
|
+
// "SDK detected" badge even before any campaign serves an ad to this
|
|
47
|
+
// site. The server throttles writes (10-minute window) so we don't
|
|
48
|
+
// generate a DB write on every page load.
|
|
49
|
+
this.sendHeartbeat();
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Lightweight "I'm alive" ping to `/api/sites/heartbeat`. Best-effort:
|
|
53
|
+
* never blocks SDK init, never retries, never surfaces errors to the
|
|
54
|
+
* host page. The server is responsible for write-throttling so we can
|
|
55
|
+
* call this freely on every constructor.
|
|
56
|
+
*/
|
|
57
|
+
sendHeartbeat() {
|
|
58
|
+
if (typeof window === 'undefined')
|
|
59
|
+
return;
|
|
60
|
+
// Resolve siteId without forcing a network round-trip if we can avoid
|
|
61
|
+
// it. `detectSiteId()` is idempotent and will fall back to the
|
|
62
|
+
// configured value when present.
|
|
63
|
+
void this.detectSiteId().then((siteId) => {
|
|
64
|
+
if (!siteId)
|
|
65
|
+
return;
|
|
66
|
+
// Skip unregistered / dev placeholder IDs — the server would reject
|
|
67
|
+
// them anyway, so save the round-trip.
|
|
68
|
+
if (siteId.startsWith('temp_'))
|
|
69
|
+
return;
|
|
70
|
+
try {
|
|
71
|
+
const payload = JSON.stringify({
|
|
72
|
+
siteId,
|
|
73
|
+
sdkVersion: SDK_VERSION,
|
|
74
|
+
href: window.location.href,
|
|
75
|
+
});
|
|
76
|
+
const url = `${this.config.apiUrl}/api/sites/heartbeat`;
|
|
77
|
+
// `keepalive` lets the request finish even if the page unloads
|
|
78
|
+
// shortly after init (e.g. SPA route change), so the heartbeat
|
|
79
|
+
// doesn't get cancelled.
|
|
80
|
+
fetch(url, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'Content-Type': 'application/json' },
|
|
83
|
+
body: payload,
|
|
84
|
+
keepalive: true,
|
|
85
|
+
}).catch(() => { });
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* never propagate heartbeat failures */
|
|
89
|
+
}
|
|
90
|
+
}).catch(() => { });
|
|
31
91
|
}
|
|
32
92
|
/**
|
|
33
93
|
* Identifies the current viewer with a wallet address.
|
|
@@ -36,7 +96,9 @@ export class SovAds {
|
|
|
36
96
|
identify(walletAddress) {
|
|
37
97
|
if (!walletAddress || typeof walletAddress !== 'string')
|
|
38
98
|
return;
|
|
39
|
-
|
|
99
|
+
const next = walletAddress.toLowerCase();
|
|
100
|
+
const changed = next !== this.walletAddress;
|
|
101
|
+
this.walletAddress = next;
|
|
40
102
|
try {
|
|
41
103
|
if (typeof window !== 'undefined' && window.localStorage) {
|
|
42
104
|
localStorage.setItem('sovads_wallet_address', this.walletAddress);
|
|
@@ -48,6 +110,34 @@ export class SovAds {
|
|
|
48
110
|
if (this.config.debug) {
|
|
49
111
|
console.log('SovAds Identity set:', this.walletAddress);
|
|
50
112
|
}
|
|
113
|
+
if (changed) {
|
|
114
|
+
this.notifyIdentityListeners();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Subscribe to wallet-identity changes. Fires once immediately if a wallet
|
|
119
|
+
* is already known, then on every subsequent `identify()` call that changes
|
|
120
|
+
* the address. Returns an unsubscribe function.
|
|
121
|
+
*/
|
|
122
|
+
onIdentify(cb) {
|
|
123
|
+
this.identityListeners.add(cb);
|
|
124
|
+
// Fire synchronously if we already have an address \u2014 lets callers treat
|
|
125
|
+
// "already connected" and "connects later" the same way.
|
|
126
|
+
if (this.walletAddress) {
|
|
127
|
+
try {
|
|
128
|
+
cb(this.walletAddress);
|
|
129
|
+
}
|
|
130
|
+
catch { /* swallow */ }
|
|
131
|
+
}
|
|
132
|
+
return () => { this.identityListeners.delete(cb); };
|
|
133
|
+
}
|
|
134
|
+
notifyIdentityListeners() {
|
|
135
|
+
for (const cb of this.identityListeners) {
|
|
136
|
+
try {
|
|
137
|
+
cb(this.walletAddress);
|
|
138
|
+
}
|
|
139
|
+
catch { /* swallow */ }
|
|
140
|
+
}
|
|
51
141
|
}
|
|
52
142
|
loadPersistedIdentity() {
|
|
53
143
|
try {
|
|
@@ -307,6 +397,12 @@ export class SovAds {
|
|
|
307
397
|
}
|
|
308
398
|
inferMediaTypeFromUrl(url) {
|
|
309
399
|
const value = (url || '').toLowerCase();
|
|
400
|
+
// Streaming URLs (YouTube/Vimeo/TikTok) are rendered via iframe by the
|
|
401
|
+
// Banner/Popup renderers \u2014 treat them as 'video' here so downstream code
|
|
402
|
+
// knows not to try a hover/click handler, but the actual <iframe> swap
|
|
403
|
+
// happens at render time via toStreamingEmbed().
|
|
404
|
+
if (toStreamingEmbed(value))
|
|
405
|
+
return 'video';
|
|
310
406
|
const videoExts = ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.m3u8'];
|
|
311
407
|
return videoExts.some((ext) => value.includes(ext)) ? 'video' : 'image';
|
|
312
408
|
}
|
|
@@ -356,6 +452,11 @@ export class SovAds {
|
|
|
356
452
|
if (wallet) {
|
|
357
453
|
url.searchParams.append('wallet', wallet);
|
|
358
454
|
}
|
|
455
|
+
// Opt-in: ask for attached CTA tasks + serve out-of-budget banners
|
|
456
|
+
// (the viewer can still earn via attached CTAs through the points fallback).
|
|
457
|
+
if (options.attached) {
|
|
458
|
+
url.searchParams.append('attached', '1');
|
|
459
|
+
}
|
|
359
460
|
const endpoint = url.toString();
|
|
360
461
|
const response = await this.fetchWithRetry(endpoint);
|
|
361
462
|
const duration = Date.now() - startTime;
|
|
@@ -556,7 +657,7 @@ export class SovAds {
|
|
|
556
657
|
headers: {
|
|
557
658
|
'Content-Type': 'application/json',
|
|
558
659
|
'Cache-Control': 'no-cache',
|
|
559
|
-
'X-SovAds-SDK-Version':
|
|
660
|
+
'X-SovAds-SDK-Version': SDK_VERSION,
|
|
560
661
|
},
|
|
561
662
|
body: envelope,
|
|
562
663
|
keepalive: true,
|
|
@@ -679,6 +780,89 @@ export class SovAds {
|
|
|
679
780
|
getConfig() {
|
|
680
781
|
return this.config;
|
|
681
782
|
}
|
|
783
|
+
/**
|
|
784
|
+
* Submit a CTA-task completion (POLL / VISIT_URL / SIGN_MESSAGE) on behalf
|
|
785
|
+
* of the current viewer. Uses plain fetch (no retry) to avoid double-submitting
|
|
786
|
+
* an idempotent task; rate-limit/dedupe is enforced server-side.
|
|
787
|
+
*/
|
|
788
|
+
async submitTaskCompletion(params) {
|
|
789
|
+
try {
|
|
790
|
+
const endpoint = `${this.config.apiUrl}/api/tasks/complete`;
|
|
791
|
+
const body = {
|
|
792
|
+
taskId: params.taskId,
|
|
793
|
+
wallet: this.walletAddress || undefined,
|
|
794
|
+
fingerprint: this.fingerprint,
|
|
795
|
+
proof: params.proof || {},
|
|
796
|
+
};
|
|
797
|
+
const response = await fetch(endpoint, {
|
|
798
|
+
method: 'POST',
|
|
799
|
+
headers: { 'Content-Type': 'application/json' },
|
|
800
|
+
body: JSON.stringify(body),
|
|
801
|
+
});
|
|
802
|
+
let data = null;
|
|
803
|
+
try {
|
|
804
|
+
data = (await response.json());
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
// server returned non-JSON; treat as error
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
ok: response.ok,
|
|
811
|
+
status: response.status,
|
|
812
|
+
awarded: data?.awarded,
|
|
813
|
+
error: data?.error,
|
|
814
|
+
data,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
catch (err) {
|
|
818
|
+
return {
|
|
819
|
+
ok: false,
|
|
820
|
+
status: 0,
|
|
821
|
+
error: err instanceof Error ? err.message : 'submit failed',
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Public accessor for the current wallet address (read-only).
|
|
827
|
+
* CTA renderers use this to suppress wallet-bound rewards on anonymous viewers.
|
|
828
|
+
*/
|
|
829
|
+
getWalletAddress() {
|
|
830
|
+
return this.walletAddress;
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Fetch this viewer's completion / eligibility status for every active task
|
|
834
|
+
* of a campaign. Used by the attached-CTA panel to mark already-completed
|
|
835
|
+
* tasks with a \u2713 badge after the wallet connects. Returns a Map keyed by
|
|
836
|
+
* taskId so callers can do O(1) lookups; tasks missing from the map are
|
|
837
|
+
* assumed eligible.
|
|
838
|
+
*/
|
|
839
|
+
async fetchTaskStatuses(campaignId) {
|
|
840
|
+
const out = new Map();
|
|
841
|
+
if (!campaignId)
|
|
842
|
+
return out;
|
|
843
|
+
try {
|
|
844
|
+
const url = new URL(`${this.config.apiUrl}/api/tasks/status`);
|
|
845
|
+
url.searchParams.set('campaignId', campaignId);
|
|
846
|
+
if (this.walletAddress)
|
|
847
|
+
url.searchParams.set('wallet', this.walletAddress);
|
|
848
|
+
if (this.fingerprint)
|
|
849
|
+
url.searchParams.set('fingerprint', this.fingerprint);
|
|
850
|
+
const response = await fetch(url.toString(), { method: 'GET' });
|
|
851
|
+
if (!response.ok)
|
|
852
|
+
return out;
|
|
853
|
+
const json = (await response.json());
|
|
854
|
+
const tasks = Array.isArray(json?.tasks) ? json.tasks : [];
|
|
855
|
+
for (const t of tasks) {
|
|
856
|
+
if (t && typeof t.id === 'string')
|
|
857
|
+
out.set(t.id, t);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
catch (err) {
|
|
861
|
+
if (this.config.debug)
|
|
862
|
+
console.warn('[SovAds] fetchTaskStatuses failed', err);
|
|
863
|
+
}
|
|
864
|
+
return out;
|
|
865
|
+
}
|
|
682
866
|
/**
|
|
683
867
|
* Log interaction (public method for components)
|
|
684
868
|
*/
|
|
@@ -726,7 +910,927 @@ export class SovAds {
|
|
|
726
910
|
destroy() {
|
|
727
911
|
this.renderObservers.forEach((observer) => observer.disconnect());
|
|
728
912
|
this.renderObservers.clear();
|
|
913
|
+
this.unitListeners.forEach((entry) => {
|
|
914
|
+
window.removeEventListener('message', entry.listener);
|
|
915
|
+
try {
|
|
916
|
+
entry.iframe.remove();
|
|
917
|
+
}
|
|
918
|
+
catch { /* noop */ }
|
|
919
|
+
});
|
|
920
|
+
this.unitListeners.clear();
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Mount a standalone unit iframe (BANNER / POLL / FEEDBACK / SURVEY) into
|
|
924
|
+
* `containerId`. Forwards lifecycle/interaction events from the iframe
|
|
925
|
+
* (via postMessage protocol) to the supplied `onEvent` callback.
|
|
926
|
+
*
|
|
927
|
+
* Returns an object with `unmount()` for cleanup.
|
|
928
|
+
*/
|
|
929
|
+
mountUnit(containerId, options) {
|
|
930
|
+
const container = document.getElementById(containerId);
|
|
931
|
+
if (!container) {
|
|
932
|
+
if (this.config.debug)
|
|
933
|
+
console.error(`SovAds.mountUnit: container #${containerId} not found`);
|
|
934
|
+
return { slotId: '', unmount: () => { } };
|
|
935
|
+
}
|
|
936
|
+
const slotId = options.slotId || `sa-${Math.random().toString(36).slice(2, 10)}`;
|
|
937
|
+
const apiBase = this.config.apiUrl;
|
|
938
|
+
const params = new URLSearchParams();
|
|
939
|
+
params.set('slotId', slotId);
|
|
940
|
+
if (this.siteId)
|
|
941
|
+
params.set('siteId', this.siteId);
|
|
942
|
+
if (options.kind)
|
|
943
|
+
params.set('kind', options.kind);
|
|
944
|
+
if (options.location)
|
|
945
|
+
params.set('location', options.location);
|
|
946
|
+
if (options.placement)
|
|
947
|
+
params.set('placement', options.placement);
|
|
948
|
+
if (options.size)
|
|
949
|
+
params.set('size', options.size);
|
|
950
|
+
const wallet = options.wallet || this.walletAddress;
|
|
951
|
+
if (wallet)
|
|
952
|
+
params.set('wallet', wallet);
|
|
953
|
+
// If siteId wasn't set yet, detect lazily and rebuild the URL once.
|
|
954
|
+
const buildSrc = (sid) => {
|
|
955
|
+
const p = new URLSearchParams(params);
|
|
956
|
+
p.set('siteId', sid);
|
|
957
|
+
return `${apiBase}/r/unit?${p.toString()}`;
|
|
958
|
+
};
|
|
959
|
+
const iframe = document.createElement('iframe');
|
|
960
|
+
iframe.setAttribute('title', 'SovAds Unit');
|
|
961
|
+
iframe.setAttribute('loading', 'lazy');
|
|
962
|
+
// Sandboxed: allow scripts + same-origin (for fetch to apiBase via CORS) +
|
|
963
|
+
// popups for banner link clicks. No top-navigation, no forms, no plugins.
|
|
964
|
+
iframe.setAttribute('sandbox', 'allow-scripts allow-popups allow-popups-to-escape-sandbox');
|
|
965
|
+
iframe.style.width = '100%';
|
|
966
|
+
iframe.style.border = '0';
|
|
967
|
+
iframe.style.display = 'block';
|
|
968
|
+
iframe.style.minHeight = options.minHeight || '120px';
|
|
969
|
+
iframe.style.background = 'transparent';
|
|
970
|
+
container.appendChild(iframe);
|
|
971
|
+
void this.detectSiteId().then((sid) => {
|
|
972
|
+
iframe.src = buildSrc(sid);
|
|
973
|
+
});
|
|
974
|
+
// Phase 6 \u2014 derive the expected iframe origin once so the postMessage
|
|
975
|
+
// listener can reject events from any other window. Without this check
|
|
976
|
+
// any same-tab iframe could forge { source: 'sovads-unit', \u2026 } messages
|
|
977
|
+
// and trigger our onEvent handler / iframe resize. We tolerate a parse
|
|
978
|
+
// failure (e.g. relative apiUrl) by skipping the check rather than
|
|
979
|
+
// breaking publishers who configure unusual API URLs.
|
|
980
|
+
let expectedOrigin = null;
|
|
981
|
+
try {
|
|
982
|
+
expectedOrigin = new URL(apiBase, typeof window !== 'undefined' ? window.location.href : undefined).origin;
|
|
983
|
+
}
|
|
984
|
+
catch {
|
|
985
|
+
expectedOrigin = null;
|
|
986
|
+
}
|
|
987
|
+
const listener = (ev) => {
|
|
988
|
+
// Origin check: only trust messages from the iframe we mounted.
|
|
989
|
+
if (expectedOrigin && ev.origin !== expectedOrigin)
|
|
990
|
+
return;
|
|
991
|
+
const data = ev.data;
|
|
992
|
+
if (!data || typeof data !== 'object')
|
|
993
|
+
return;
|
|
994
|
+
if (data.source !== 'sovads-unit')
|
|
995
|
+
return;
|
|
996
|
+
if (data.slotId !== slotId)
|
|
997
|
+
return;
|
|
998
|
+
const type = String(data.type);
|
|
999
|
+
const payload = (data.payload || {});
|
|
1000
|
+
// Auto-resize iframe in response to RESIZE messages
|
|
1001
|
+
if (type === 'RESIZE' && typeof payload.height === 'number') {
|
|
1002
|
+
iframe.style.height = `${payload.height}px`;
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
options.onEvent?.({ type: type, payload, slotId });
|
|
1006
|
+
}
|
|
1007
|
+
catch (e) {
|
|
1008
|
+
if (this.config.debug)
|
|
1009
|
+
console.error('SovAds.mountUnit onEvent threw', e);
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
window.addEventListener('message', listener);
|
|
1013
|
+
this.unitListeners.set(slotId, { listener, iframe });
|
|
1014
|
+
const unmount = () => {
|
|
1015
|
+
window.removeEventListener('message', listener);
|
|
1016
|
+
try {
|
|
1017
|
+
iframe.remove();
|
|
1018
|
+
}
|
|
1019
|
+
catch { /* noop */ }
|
|
1020
|
+
this.unitListeners.delete(slotId);
|
|
1021
|
+
};
|
|
1022
|
+
return { slotId, unmount };
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
export function toStreamingEmbed(url) {
|
|
1026
|
+
if (!url)
|
|
1027
|
+
return null;
|
|
1028
|
+
const trimmed = url.trim();
|
|
1029
|
+
if (!trimmed)
|
|
1030
|
+
return null;
|
|
1031
|
+
// YouTube: youtu.be/{id}, youtube.com/watch?v={id}, youtube.com/shorts/{id},
|
|
1032
|
+
// youtube.com/embed/{id}.
|
|
1033
|
+
const yt = trimmed.match(/(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|shorts\/|embed\/|v\/))([A-Za-z0-9_-]{6,})/i);
|
|
1034
|
+
if (yt && yt[1]) {
|
|
1035
|
+
return {
|
|
1036
|
+
provider: 'youtube',
|
|
1037
|
+
// playsinline + modestbranding + rel=0 keeps the embed quiet and clean;
|
|
1038
|
+
// no autoplay by default (browsers block autoplay-with-sound anyway).
|
|
1039
|
+
embedUrl: `https://www.youtube.com/embed/${yt[1]}?rel=0&modestbranding=1&playsinline=1`,
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
// Vimeo: vimeo.com/{id} or player.vimeo.com/video/{id}.
|
|
1043
|
+
const vm = trimmed.match(/vimeo\.com\/(?:video\/)?(\d+)/i);
|
|
1044
|
+
if (vm && vm[1]) {
|
|
1045
|
+
return {
|
|
1046
|
+
provider: 'vimeo',
|
|
1047
|
+
embedUrl: `https://player.vimeo.com/video/${vm[1]}?byline=0&portrait=0&title=0`,
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
// TikTok: tiktok.com/@user/video/{id}.
|
|
1051
|
+
const tt = trimmed.match(/tiktok\.com\/(?:@[^/]+\/)?video\/(\d+)/i);
|
|
1052
|
+
if (tt && tt[1]) {
|
|
1053
|
+
return {
|
|
1054
|
+
provider: 'tiktok',
|
|
1055
|
+
embedUrl: `https://www.tiktok.com/embed/v2/${tt[1]}`,
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
return null;
|
|
1059
|
+
}
|
|
1060
|
+
/** Build a sandboxed `<iframe>` for a streaming embed URL. Shared by Banner
|
|
1061
|
+
* and Popup so both surfaces behave identically. */
|
|
1062
|
+
export function buildStreamingIframe(embed, alt) {
|
|
1063
|
+
const iframe = document.createElement('iframe');
|
|
1064
|
+
iframe.src = embed.embedUrl;
|
|
1065
|
+
iframe.title = alt || 'Sponsored video';
|
|
1066
|
+
iframe.setAttribute('frameborder', '0');
|
|
1067
|
+
iframe.setAttribute('allow', 'accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share');
|
|
1068
|
+
iframe.allowFullscreen = true;
|
|
1069
|
+
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
|
|
1070
|
+
iframe.style.cssText =
|
|
1071
|
+
'width:100%;aspect-ratio:16/9;height:auto;display:block;border:0;background:#000;';
|
|
1072
|
+
iframe.dataset.sovadsProvider = embed.provider;
|
|
1073
|
+
return iframe;
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Build the media element for an ad. Single source of truth for the
|
|
1077
|
+
* image / video / streaming-iframe switch that used to be duplicated across
|
|
1078
|
+
* Banner, Sidebar, Popup and BottomBar.
|
|
1079
|
+
*
|
|
1080
|
+
* The caller is responsible for:
|
|
1081
|
+
* - Attaching `load` / `loadeddata` / `error` listeners (for impression timing).
|
|
1082
|
+
* - Mounting the returned `element` into the DOM.
|
|
1083
|
+
* - Adding a click handler — either on the element (when `clickable=true`)
|
|
1084
|
+
* or on an external "Learn more" button (always, when false).
|
|
1085
|
+
*/
|
|
1086
|
+
export function mountMedia(opts) {
|
|
1087
|
+
const { ad, style } = opts;
|
|
1088
|
+
const streamingEmbed = toStreamingEmbed(ad.bannerUrl);
|
|
1089
|
+
if (streamingEmbed) {
|
|
1090
|
+
const iframe = buildStreamingIframe(streamingEmbed, ad.description);
|
|
1091
|
+
if (style)
|
|
1092
|
+
iframe.style.cssText = style;
|
|
1093
|
+
return { element: iframe, kind: 'streaming', clickable: false };
|
|
1094
|
+
}
|
|
1095
|
+
const mediaType = ad.mediaType === 'video' ? 'video' : 'image';
|
|
1096
|
+
if (mediaType === 'video') {
|
|
1097
|
+
const video = document.createElement('video');
|
|
1098
|
+
video.src = ad.bannerUrl;
|
|
1099
|
+
video.muted = true;
|
|
1100
|
+
video.autoplay = true;
|
|
1101
|
+
video.loop = true;
|
|
1102
|
+
video.playsInline = true;
|
|
1103
|
+
video.controls = true;
|
|
1104
|
+
video.style.cssText = style || 'width:100%;height:auto;display:block;border-radius:4px;';
|
|
1105
|
+
return { element: video, kind: 'video', clickable: false };
|
|
1106
|
+
}
|
|
1107
|
+
const img = document.createElement('img');
|
|
1108
|
+
img.src = ad.bannerUrl;
|
|
1109
|
+
img.alt = ad.description || 'Sponsored';
|
|
1110
|
+
img.style.cssText = style || 'width:100%;height:auto;display:block;max-width:100%;object-fit:contain;';
|
|
1111
|
+
return { element: img, kind: 'image', clickable: true };
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Build a compact "Sponsored" disclosure badge.
|
|
1115
|
+
*
|
|
1116
|
+
* Phase 0 only EXPORTS the helper — components do not mount it yet. Phase 2
|
|
1117
|
+
* wires it into every render path, opt-out via `new SovAds({ disclosureLabel: false })`.
|
|
1118
|
+
*
|
|
1119
|
+
* The returned span uses `aria-label="Advertisement"` (the FTC-recommended
|
|
1120
|
+
* explicit term) and is sized to remain legible (11px min, 1.0 contrast on
|
|
1121
|
+
* standard backgrounds). Position is the caller's responsibility.
|
|
1122
|
+
*/
|
|
1123
|
+
export function buildDisclosureBadge(opts) {
|
|
1124
|
+
const label = opts?.label ?? 'Sponsored';
|
|
1125
|
+
const variant = opts?.variant ?? 'dark';
|
|
1126
|
+
const badge = document.createElement('span');
|
|
1127
|
+
badge.className = 'sovads-disclosure';
|
|
1128
|
+
badge.setAttribute('role', 'note');
|
|
1129
|
+
badge.setAttribute('aria-label', 'Advertisement');
|
|
1130
|
+
// Phase 5: badge fg/bg pull from CSS variables when defined; otherwise
|
|
1131
|
+
// fall back to today's defaults so existing publishers see no change.
|
|
1132
|
+
const bg = variant === 'dark'
|
|
1133
|
+
? 'var(--sovads-disclosure-bg-dark, rgba(255,255,255,0.92))'
|
|
1134
|
+
: 'var(--sovads-disclosure-bg-light, rgba(0,0,0,0.62))';
|
|
1135
|
+
const fg = variant === 'dark'
|
|
1136
|
+
? 'var(--sovads-accent, #2D2D2D)'
|
|
1137
|
+
: 'var(--sovads-on-accent-strong, #FFFFFF)';
|
|
1138
|
+
badge.style.cssText =
|
|
1139
|
+
`display:inline-flex;align-items:center;gap:4px;` +
|
|
1140
|
+
`font-size:11px;font-weight:600;line-height:1;` +
|
|
1141
|
+
`padding:3px 6px;border-radius:3px;letter-spacing:0.02em;` +
|
|
1142
|
+
`background:${bg};color:${fg};` +
|
|
1143
|
+
`font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;`;
|
|
1144
|
+
badge.textContent = opts?.advertiser ? `${label} · ${opts.advertiser}` : label;
|
|
1145
|
+
return badge;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Phase 2 \u2014 resolve the effective disclosure setting from a 3-level cascade:
|
|
1149
|
+
* slot-override \u2192 SovAdsConfig.disclosureLabel \u2192 default (true \u2192 'Sponsored').
|
|
1150
|
+
*
|
|
1151
|
+
* Returns the resolved label string, or `null` if disclosure is explicitly
|
|
1152
|
+
* disabled (which callers should treat as "do not render"). Centralised here
|
|
1153
|
+
* so every component reads the rule the same way.
|
|
1154
|
+
*/
|
|
1155
|
+
export function resolveDisclosureLabel(slotOverride, configValue) {
|
|
1156
|
+
const layered = slotOverride !== undefined ? slotOverride : configValue;
|
|
1157
|
+
if (layered === false)
|
|
1158
|
+
return null;
|
|
1159
|
+
if (typeof layered === 'string' && layered.trim().length > 0)
|
|
1160
|
+
return layered;
|
|
1161
|
+
return 'Sponsored';
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Phase 2 \u2014 small helper that builds AND positions a disclosure badge over
|
|
1165
|
+
* the top-left of an ad surface (absolute positioning). Caller must ensure
|
|
1166
|
+
* the parent has `position: relative` (or another non-static positioning
|
|
1167
|
+
* context). Returns `null` when disclosure is disabled \u2014 caller should
|
|
1168
|
+
* handle that as "do not append".
|
|
1169
|
+
*/
|
|
1170
|
+
export function buildPositionedDisclosure(opts) {
|
|
1171
|
+
const label = resolveDisclosureLabel(opts.slotOverride, opts.configValue);
|
|
1172
|
+
if (label === null)
|
|
1173
|
+
return null;
|
|
1174
|
+
const badge = buildDisclosureBadge({
|
|
1175
|
+
label,
|
|
1176
|
+
advertiser: opts.advertiser,
|
|
1177
|
+
variant: opts.variant,
|
|
1178
|
+
});
|
|
1179
|
+
const pos = opts.position ?? 'top-left';
|
|
1180
|
+
badge.style.position = 'absolute';
|
|
1181
|
+
badge.style.top = '6px';
|
|
1182
|
+
if (pos === 'top-left')
|
|
1183
|
+
badge.style.left = '6px';
|
|
1184
|
+
else
|
|
1185
|
+
badge.style.right = '6px';
|
|
1186
|
+
badge.style.zIndex = '2';
|
|
1187
|
+
return badge;
|
|
1188
|
+
}
|
|
1189
|
+
// ============================================================================
|
|
1190
|
+
// Phase 3 \u2014 CLS (Cumulative Layout Shift) reservation.
|
|
1191
|
+
//
|
|
1192
|
+
// Today every component sets `container.style.display = 'none'` before the
|
|
1193
|
+
// async ad fetch and only shows the box on `<img>` load. That guarantees
|
|
1194
|
+
// CLS: the page content below the slot is pulled up while the ad fetches
|
|
1195
|
+
// and gets shoved back down when the image decodes. Lighthouse penalises
|
|
1196
|
+
// this hard (it's the dominant CLS source for most ad-supported pages).
|
|
1197
|
+
//
|
|
1198
|
+
// Phase 3 fix: when the publisher tells us the slot size (e.g. '300x250',
|
|
1199
|
+
// '728x90'), we reserve the exact aspect-ratio box on the container BEFORE
|
|
1200
|
+
// the fetch. Layout stays put; media fades in. No size known \u2192 we keep
|
|
1201
|
+
// today's hide-then-show behaviour for backcompat.
|
|
1202
|
+
// ============================================================================
|
|
1203
|
+
/**
|
|
1204
|
+
* Parse an IAB-style size string ('300x250', '728x90', '160x600', etc.) into
|
|
1205
|
+
* a {width, height} pair. Returns null when the string is malformed so the
|
|
1206
|
+
* caller falls back to legacy behaviour rather than throwing.
|
|
1207
|
+
*/
|
|
1208
|
+
export function parseAdSize(size) {
|
|
1209
|
+
if (!size || typeof size !== 'string')
|
|
1210
|
+
return null;
|
|
1211
|
+
const m = size.trim().toLowerCase().match(/^(\d{1,5})\s*x\s*(\d{1,5})$/);
|
|
1212
|
+
if (!m)
|
|
1213
|
+
return null;
|
|
1214
|
+
const width = Number.parseInt(m[1], 10);
|
|
1215
|
+
const height = Number.parseInt(m[2], 10);
|
|
1216
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width === 0 || height === 0)
|
|
1217
|
+
return null;
|
|
1218
|
+
return { width, height };
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Reserve a CLS-safe box on the slot container before the ad fetch starts.
|
|
1222
|
+
* Sets `aspect-ratio` so the browser knows the box's intrinsic shape and
|
|
1223
|
+
* `max-width` so the slot never grows past the IAB size on large viewports.
|
|
1224
|
+
* The container stays visible (no `display: none`) so the page below it
|
|
1225
|
+
* keeps its final position.
|
|
1226
|
+
*
|
|
1227
|
+
* Returns true when reservation was applied. The caller should skip the
|
|
1228
|
+
* legacy hide-then-show dance only when this returns true.
|
|
1229
|
+
*/
|
|
1230
|
+
export function reserveAdSlot(container, size) {
|
|
1231
|
+
const parsed = parseAdSize(size);
|
|
1232
|
+
if (!parsed)
|
|
1233
|
+
return false;
|
|
1234
|
+
// aspect-ratio is supported in every browser shipped since 2021. The
|
|
1235
|
+
// `width: 100%` + `max-width` combo lets the slot fluidly shrink on
|
|
1236
|
+
// narrow viewports while never exceeding its IAB declared width.
|
|
1237
|
+
container.style.aspectRatio = `${parsed.width} / ${parsed.height}`;
|
|
1238
|
+
container.style.width = '100%';
|
|
1239
|
+
container.style.maxWidth = `${parsed.width}px`;
|
|
1240
|
+
// A subtle neutral placeholder background so the reserved box is visible
|
|
1241
|
+
// to debug + matches what most ad networks show. Uses CSS variables so
|
|
1242
|
+
// Phase 5 theming will override automatically.
|
|
1243
|
+
if (!container.style.backgroundColor) {
|
|
1244
|
+
container.style.backgroundColor = 'var(--sovads-placeholder-bg, transparent)';
|
|
1245
|
+
}
|
|
1246
|
+
return true;
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Phase 7 \u2014 returns true when the user / OS prefers reduced motion. Used
|
|
1250
|
+
* by hover-scale and translate animations so we don't trigger vestibular
|
|
1251
|
+
* discomfort for users who've asked the system to dial back animation.
|
|
1252
|
+
* Falls back to `false` (= motion allowed) when matchMedia isn't available
|
|
1253
|
+
* so server-side rendering / older browsers see the same animation as today.
|
|
1254
|
+
*/
|
|
1255
|
+
export function prefersReducedMotion() {
|
|
1256
|
+
try {
|
|
1257
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
1258
|
+
return false;
|
|
1259
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
1260
|
+
}
|
|
1261
|
+
catch {
|
|
1262
|
+
return false;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Mount the attached-CTA panel for a given surface. Thin wrapper around
|
|
1267
|
+
* `renderAttachedCtas` that components can call without re-implementing the
|
|
1268
|
+
* try/catch + debug-log boilerplate. Phase 1 routes every component through
|
|
1269
|
+
* this helper.
|
|
1270
|
+
*
|
|
1271
|
+
* The `surface` arg is passed through to `onCtaComplete` callers via the
|
|
1272
|
+
* existing AttachedCtaCompleteEvent (no new field today) and used as a hint
|
|
1273
|
+
* for layout — POPUP / NATIVE / SIDEBAR stack vertically, BOTTOM_BAR may
|
|
1274
|
+
* render inline (decided at the call site).
|
|
1275
|
+
*/
|
|
1276
|
+
export function mountCtaPanel(opts) {
|
|
1277
|
+
// Wrap the host's onComplete so we can fire a CTA_COMPLETE interaction
|
|
1278
|
+
// event tagged with the originating surface. Backward-compat: existing
|
|
1279
|
+
// analytics consumers ignore unknown elementType values.
|
|
1280
|
+
const wrappedOnComplete = (ev) => {
|
|
1281
|
+
try {
|
|
1282
|
+
opts.sovads?.logInteraction('CTA_COMPLETE', {
|
|
1283
|
+
adId: undefined,
|
|
1284
|
+
campaignId: ev.campaignId,
|
|
1285
|
+
taskId: ev.taskId,
|
|
1286
|
+
kind: ev.kind,
|
|
1287
|
+
elementType: opts.surface,
|
|
1288
|
+
ok: ev.ok,
|
|
1289
|
+
status: ev.status,
|
|
1290
|
+
awarded: ev.awarded,
|
|
1291
|
+
error: ev.error,
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
catch {
|
|
1295
|
+
/* analytics best-effort */
|
|
1296
|
+
}
|
|
1297
|
+
try {
|
|
1298
|
+
opts.onComplete?.(ev);
|
|
1299
|
+
}
|
|
1300
|
+
catch { /* host handler threw */ }
|
|
1301
|
+
};
|
|
1302
|
+
try {
|
|
1303
|
+
renderAttachedCtas({
|
|
1304
|
+
container: opts.container,
|
|
1305
|
+
sovads: opts.sovads,
|
|
1306
|
+
tasks: opts.tasks,
|
|
1307
|
+
campaignId: opts.campaignId,
|
|
1308
|
+
bannerClickActive: opts.bannerClickActive,
|
|
1309
|
+
onComplete: wrappedOnComplete,
|
|
1310
|
+
preview: opts.preview,
|
|
1311
|
+
layout: opts.layout,
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
catch (e) {
|
|
1315
|
+
if (opts.sovads?.getConfig().debug) {
|
|
1316
|
+
console.error(`[SovAds] mountCtaPanel(${opts.surface}) failed`, e);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
// ============================================================================
|
|
1321
|
+
// Mounts a compact CTA panel beneath a banner whenever the server returns
|
|
1322
|
+
// `attachedTasks` (only happens when the slot was opened with `attached: true`).
|
|
1323
|
+
//
|
|
1324
|
+
// Supported task kinds:
|
|
1325
|
+
// - VISIT_URL \u2192 single "primary" button; opens url, then submits after dwell
|
|
1326
|
+
// - SIGN_MESSAGE \u2192 single "primary" button; emits onCtaComplete with
|
|
1327
|
+
// needsSignature so the host page can sign via its own wallet
|
|
1328
|
+
// (the SDK does not currently hold a signer)
|
|
1329
|
+
// - POLL \u2192 stacked option buttons (one click = one submit)
|
|
1330
|
+
//
|
|
1331
|
+
// When `bannerClickActive=false` (campaign out of token budget), the reward
|
|
1332
|
+
// badge swaps "+N G$" \u2192 "+N pts*" to reflect the points-fallback that
|
|
1333
|
+
// /api/tasks/complete will apply.
|
|
1334
|
+
// ============================================================================
|
|
1335
|
+
/**
|
|
1336
|
+
* Public renderer for the attached-CTA panel.
|
|
1337
|
+
*
|
|
1338
|
+
* Two modes:
|
|
1339
|
+
* - Live (default): mounts the panel with real click handlers; clicking
|
|
1340
|
+
* POLL/VISIT_URL/SIGN_MESSAGE submits via `sovads.submitTaskCompletion`.
|
|
1341
|
+
* - Preview: pass `preview: true` (and omit `sovads`). Renders the same DOM
|
|
1342
|
+
* but disables click handlers and submission — used by the create-campaign
|
|
1343
|
+
* page and the advertiser review queue so the advertiser sees the exact
|
|
1344
|
+
* button the viewer will see, with no risk of side-effects.
|
|
1345
|
+
*/
|
|
1346
|
+
export function renderAttachedCtas(opts) {
|
|
1347
|
+
const { container, sovads, tasks, campaignId, bannerClickActive, onComplete, preview } = opts;
|
|
1348
|
+
const requestedLayout = opts.layout ?? 'stack';
|
|
1349
|
+
// 'auto' resolves at render time: 2 tasks side-by-side, otherwise stack.
|
|
1350
|
+
// 1 task in a row would look identical to stack; 3+ tasks side-by-side in
|
|
1351
|
+
// a 300px-wide Banner would each end up ~90px wide with a truncated label,
|
|
1352
|
+
// so we cap auto-inline at exactly 2.
|
|
1353
|
+
const layout = requestedLayout === 'auto'
|
|
1354
|
+
? (tasks.length === 2 ? 'inline' : 'stack')
|
|
1355
|
+
: requestedLayout;
|
|
1356
|
+
if (!tasks.length)
|
|
1357
|
+
return;
|
|
1358
|
+
if (!preview && !sovads) {
|
|
1359
|
+
// Live mode requires a real SovAds instance.
|
|
1360
|
+
console.error('[SovAds] renderAttachedCtas: `sovads` is required when `preview` is not true');
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
const panel = document.createElement('div');
|
|
1364
|
+
panel.className = 'sovads-cta-panel';
|
|
1365
|
+
panel.setAttribute('data-campaign-id', campaignId);
|
|
1366
|
+
panel.setAttribute('data-layout', layout);
|
|
1367
|
+
// Record what the caller asked for separately from the resolved value so
|
|
1368
|
+
// host pages can style/debug the auto-switch independently.
|
|
1369
|
+
if (requestedLayout === 'auto')
|
|
1370
|
+
panel.setAttribute('data-layout-requested', 'auto');
|
|
1371
|
+
if (preview)
|
|
1372
|
+
panel.setAttribute('data-preview', '1');
|
|
1373
|
+
// No card chrome \u2014 the buttons themselves carry all the visual weight.
|
|
1374
|
+
// Tight spacing so the banner + CTAs read as one cohesive component:
|
|
1375
|
+
// 2px gap to the banner above, 4px between stacked CTA buttons.
|
|
1376
|
+
// In 'inline' layout the panel becomes a horizontal row of equal-width
|
|
1377
|
+
// items so it can sit next to the media element (used by BottomBar).
|
|
1378
|
+
panel.style.cssText = layout === 'inline'
|
|
1379
|
+
? `
|
|
1380
|
+
margin: 0;
|
|
1381
|
+
color: var(--sovads-accent, #2D2D2D);
|
|
1382
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
1383
|
+
font-size: 13px;
|
|
1384
|
+
display: flex;
|
|
1385
|
+
flex-direction: row;
|
|
1386
|
+
align-items: stretch;
|
|
1387
|
+
gap: 6px;
|
|
1388
|
+
box-sizing: border-box;
|
|
1389
|
+
width: 100%;
|
|
1390
|
+
`
|
|
1391
|
+
: `
|
|
1392
|
+
margin-top: 2px;
|
|
1393
|
+
color: var(--sovads-accent, #2D2D2D);
|
|
1394
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
1395
|
+
font-size: 13px;
|
|
1396
|
+
display: flex;
|
|
1397
|
+
flex-direction: column;
|
|
1398
|
+
gap: 4px;
|
|
1399
|
+
box-sizing: border-box;
|
|
1400
|
+
width: 100%;
|
|
1401
|
+
`;
|
|
1402
|
+
container.appendChild(panel);
|
|
1403
|
+
const renderBudgetNotice = () => {
|
|
1404
|
+
if (bannerClickActive)
|
|
1405
|
+
return;
|
|
1406
|
+
const notice = document.createElement('div');
|
|
1407
|
+
notice.textContent = 'Earn SovPoints by completing a quick task below.';
|
|
1408
|
+
notice.style.cssText =
|
|
1409
|
+
'font-size:11px;color:#8a6d3b;background:#fff8e1;border:1px solid #ffe082;border-radius:6px;padding:6px 8px;';
|
|
1410
|
+
panel.appendChild(notice);
|
|
1411
|
+
};
|
|
1412
|
+
// Renders the list of CTA buttons into the panel. `statusByTask` is keyed
|
|
1413
|
+
// by taskId; tasks present in the map with `completionsUsed > 0` (or with a
|
|
1414
|
+
// verified/paid completion) are rendered as a disabled button with a \u2713
|
|
1415
|
+
// corner badge. Missing entries are assumed eligible.
|
|
1416
|
+
const mountTasks = (statusByTask) => {
|
|
1417
|
+
panel.innerHTML = '';
|
|
1418
|
+
renderBudgetNotice();
|
|
1419
|
+
for (const task of tasks) {
|
|
1420
|
+
const status = statusByTask.get(task.id);
|
|
1421
|
+
const done = isTaskDone(status);
|
|
1422
|
+
const row = buildTaskRow(task, {
|
|
1423
|
+
sovads,
|
|
1424
|
+
bannerClickActive,
|
|
1425
|
+
onComplete,
|
|
1426
|
+
preview: !!preview,
|
|
1427
|
+
gsLogoUrl: resolveGsLogoUrl(sovads),
|
|
1428
|
+
done,
|
|
1429
|
+
});
|
|
1430
|
+
// In inline layout the row needs to flex so multiple tasks share width
|
|
1431
|
+
// equally. In stack mode (default) we leave width alone so the row
|
|
1432
|
+
// grows to fill the column \u2014 byte-identical to today.
|
|
1433
|
+
if (layout === 'inline') {
|
|
1434
|
+
row.style.flex = '1 1 0';
|
|
1435
|
+
row.style.minWidth = '0';
|
|
1436
|
+
}
|
|
1437
|
+
panel.appendChild(row);
|
|
1438
|
+
}
|
|
1439
|
+
};
|
|
1440
|
+
// Preview mode (advertiser-side rendering) never gates on wallet \u2014 the
|
|
1441
|
+
// advertiser must always see the live button layout.
|
|
1442
|
+
if (preview) {
|
|
1443
|
+
mountTasks(new Map());
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
const wallet = sovads.getWalletAddress();
|
|
1447
|
+
if (!wallet) {
|
|
1448
|
+
// Lazy-load: no wallet yet \u2192 show a compact connect-wallet hint and
|
|
1449
|
+
// subscribe to identity changes. When the host page calls
|
|
1450
|
+
// `sovads.identify(addr)` we fetch completion status and swap in the
|
|
1451
|
+
// real CTA buttons.
|
|
1452
|
+
const placeholder = document.createElement('div');
|
|
1453
|
+
placeholder.style.cssText =
|
|
1454
|
+
'display:flex;align-items:center;justify-content:center;gap:6px;font-size:12px;font-weight:600;' +
|
|
1455
|
+
'color:var(--sovads-text-muted, #666);' +
|
|
1456
|
+
'background:var(--sovads-surface, #FAFAF8);' +
|
|
1457
|
+
'border:1px dashed var(--sovads-border-soft, #E5E5E5);' +
|
|
1458
|
+
'border-radius:6px;padding:10px 12px;';
|
|
1459
|
+
placeholder.innerHTML =
|
|
1460
|
+
'<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:#ffb020;"></span>' +
|
|
1461
|
+
'Connect your wallet to unlock rewards';
|
|
1462
|
+
panel.appendChild(placeholder);
|
|
1463
|
+
let unsub = null;
|
|
1464
|
+
unsub = sovads.onIdentify(async (next) => {
|
|
1465
|
+
if (!next)
|
|
1466
|
+
return;
|
|
1467
|
+
if (unsub) {
|
|
1468
|
+
unsub();
|
|
1469
|
+
unsub = null;
|
|
1470
|
+
}
|
|
1471
|
+
const statusByTask = await sovads.fetchTaskStatuses(campaignId);
|
|
1472
|
+
mountTasks(statusByTask);
|
|
1473
|
+
});
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
// Wallet already known on first render \u2014 render an immediate skeleton-free
|
|
1477
|
+
// pass then patch with completion status once it arrives. We render the
|
|
1478
|
+
// buttons first so the panel doesn't flash empty space; done-badges appear
|
|
1479
|
+
// a tick later when the status fetch resolves.
|
|
1480
|
+
mountTasks(new Map());
|
|
1481
|
+
sovads.fetchTaskStatuses(campaignId).then((statusByTask) => {
|
|
1482
|
+
if (statusByTask.size === 0)
|
|
1483
|
+
return;
|
|
1484
|
+
mountTasks(statusByTask);
|
|
1485
|
+
}).catch(() => { });
|
|
1486
|
+
}
|
|
1487
|
+
/** A task counts as "done" for badge purposes when the viewer has any
|
|
1488
|
+
* successful (verified/paid) completion OR has hit `maxPerWallet`. We use
|
|
1489
|
+
* the status response's `eligibility.completionsUsed` plus the completions
|
|
1490
|
+
* array (looking for non-failed records) so a single click immediately
|
|
1491
|
+
* reflects as done on the next render. */
|
|
1492
|
+
function isTaskDone(status) {
|
|
1493
|
+
if (!status)
|
|
1494
|
+
return false;
|
|
1495
|
+
const used = status.eligibility?.completionsUsed ?? 0;
|
|
1496
|
+
if (used > 0)
|
|
1497
|
+
return true;
|
|
1498
|
+
const successful = (status.completions ?? []).some((c) => c && c.status !== 'failed' && c.status !== 'rejected');
|
|
1499
|
+
return successful;
|
|
1500
|
+
}
|
|
1501
|
+
/** Build an absolute URL to the G$ logo asset served from /public.
|
|
1502
|
+
* Falls back to a relative path so the asset still works when the SDK is
|
|
1503
|
+
* used inside the sovads frontend itself (preview mode, no instance). */
|
|
1504
|
+
function resolveGsLogoUrl(sovads) {
|
|
1505
|
+
try {
|
|
1506
|
+
const apiBase = sovads?.getConfig()?.apiUrl;
|
|
1507
|
+
if (apiBase)
|
|
1508
|
+
return `${apiBase.replace(/\/$/, '')}/6961.png`;
|
|
1509
|
+
}
|
|
1510
|
+
catch {
|
|
1511
|
+
/* ignore */
|
|
1512
|
+
}
|
|
1513
|
+
return '/6961.png';
|
|
1514
|
+
}
|
|
1515
|
+
/** Returns the combined reward amount as a single number. Points and G$ are
|
|
1516
|
+
* 1:1 in this system (G$ falls back to points when the campaign budget is
|
|
1517
|
+
* exhausted), so we display them as one value with the G$ icon. */
|
|
1518
|
+
function totalReward(task) {
|
|
1519
|
+
return (task.rewardPoints || 0) + (task.rewardGs || 0);
|
|
1520
|
+
}
|
|
1521
|
+
function buildTaskRow(task, ctx) {
|
|
1522
|
+
const row = document.createElement('div');
|
|
1523
|
+
// `position:relative` so the absolute \u2713 corner badge anchors to the row.
|
|
1524
|
+
row.style.cssText = 'position:relative;display:flex;flex-direction:column;gap:2px;';
|
|
1525
|
+
const reward = totalReward(task);
|
|
1526
|
+
const status = document.createElement('div');
|
|
1527
|
+
status.style.cssText = 'font-size:11px;color:#666;min-height:0;';
|
|
1528
|
+
// Phase 7: status text changes mid-submit ('Submitting\u2026', 'Thanks! +N',
|
|
1529
|
+
// 'Submit failed', etc.). Mark the node as a polite live region so screen
|
|
1530
|
+
// readers announce updates without stealing focus.
|
|
1531
|
+
status.setAttribute('role', 'status');
|
|
1532
|
+
status.setAttribute('aria-live', 'polite');
|
|
1533
|
+
status.setAttribute('aria-atomic', 'true');
|
|
1534
|
+
const setStatus = (text, tone = 'info') => {
|
|
1535
|
+
status.textContent = text;
|
|
1536
|
+
status.style.color = tone === 'ok' ? '#1f7a3a' : tone === 'err' ? '#a02020' : '#666';
|
|
1537
|
+
};
|
|
1538
|
+
const submit = async (proof, button) => {
|
|
1539
|
+
if (!ctx.sovads)
|
|
1540
|
+
return;
|
|
1541
|
+
if (button) {
|
|
1542
|
+
button.disabled = true;
|
|
1543
|
+
button.style.opacity = '0.6';
|
|
1544
|
+
button.style.cursor = 'wait';
|
|
1545
|
+
}
|
|
1546
|
+
setStatus('Submitting\u2026');
|
|
1547
|
+
const result = await ctx.sovads.submitTaskCompletion({ taskId: task.id, proof });
|
|
1548
|
+
if (result.ok) {
|
|
1549
|
+
const aw = result.awarded;
|
|
1550
|
+
const total = aw ? (aw.points || 0) + (aw.gs || 0) : reward;
|
|
1551
|
+
setStatus(`Thanks! +${total}`, 'ok');
|
|
1552
|
+
}
|
|
1553
|
+
else {
|
|
1554
|
+
setStatus(result.error || `Submit failed (${result.status})`, 'err');
|
|
1555
|
+
if (button) {
|
|
1556
|
+
button.disabled = false;
|
|
1557
|
+
button.style.opacity = '1';
|
|
1558
|
+
button.style.cursor = 'pointer';
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
try {
|
|
1562
|
+
ctx.onComplete?.({
|
|
1563
|
+
taskId: task.id,
|
|
1564
|
+
campaignId: task.campaignId,
|
|
1565
|
+
kind: task.kind,
|
|
1566
|
+
ok: result.ok,
|
|
1567
|
+
status: result.status,
|
|
1568
|
+
awarded: result.awarded,
|
|
1569
|
+
error: result.error,
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
catch {
|
|
1573
|
+
/* host handler threw - swallow */
|
|
1574
|
+
}
|
|
1575
|
+
};
|
|
1576
|
+
// In preview mode buttons render but do nothing on click. We keep the status
|
|
1577
|
+
// placeholder so the live and preview layouts match pixel-for-pixel.
|
|
1578
|
+
const wireClick = (btn, handler) => {
|
|
1579
|
+
if (ctx.preview || ctx.done) {
|
|
1580
|
+
btn.style.cursor = 'default';
|
|
1581
|
+
btn.setAttribute('aria-disabled', 'true');
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
btn.addEventListener('click', handler);
|
|
1585
|
+
};
|
|
1586
|
+
// Apply the "already completed" visual treatment: faded button + \u2713
|
|
1587
|
+
// corner badge anchored to the row. We intentionally keep the original
|
|
1588
|
+
// label so the viewer remembers what they did, just dimmed.
|
|
1589
|
+
const applyDoneStyling = (btn) => {
|
|
1590
|
+
if (!ctx.done)
|
|
1591
|
+
return;
|
|
1592
|
+
btn.disabled = true;
|
|
1593
|
+
btn.style.opacity = '0.55';
|
|
1594
|
+
btn.style.cursor = 'default';
|
|
1595
|
+
btn.style.pointerEvents = 'none';
|
|
1596
|
+
};
|
|
1597
|
+
if (task.kind === 'VISIT_URL') {
|
|
1598
|
+
const btn = makeButton(task.buttonLabel || task.label || 'Visit link', 'primary', {
|
|
1599
|
+
reward,
|
|
1600
|
+
gsLogoUrl: ctx.gsLogoUrl,
|
|
1601
|
+
});
|
|
1602
|
+
wireClick(btn, async () => {
|
|
1603
|
+
const target = task.url;
|
|
1604
|
+
if (target) {
|
|
1605
|
+
try {
|
|
1606
|
+
window.open(target, '_blank', 'noopener,noreferrer');
|
|
1607
|
+
}
|
|
1608
|
+
catch {
|
|
1609
|
+
/* popup blocked - submission still proceeds */
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
const dwell = Math.max(0, task.minDwellMs ?? 3000);
|
|
1613
|
+
if (dwell > 0) {
|
|
1614
|
+
setStatus(`Keep the tab open for ${Math.round(dwell / 1000)}s\u2026`);
|
|
1615
|
+
await new Promise((r) => setTimeout(r, dwell));
|
|
1616
|
+
}
|
|
1617
|
+
await submit({ dwellMs: dwell }, btn);
|
|
1618
|
+
});
|
|
1619
|
+
applyDoneStyling(btn);
|
|
1620
|
+
row.appendChild(btn);
|
|
1621
|
+
}
|
|
1622
|
+
else if (task.kind === 'SIGN_MESSAGE') {
|
|
1623
|
+
const btn = makeButton(task.buttonLabel || task.label || 'Sign to claim', 'primary', {
|
|
1624
|
+
reward,
|
|
1625
|
+
gsLogoUrl: ctx.gsLogoUrl,
|
|
1626
|
+
});
|
|
1627
|
+
wireClick(btn, async () => {
|
|
1628
|
+
setStatus('Awaiting signature from your wallet\u2026');
|
|
1629
|
+
btn.disabled = true;
|
|
1630
|
+
btn.style.opacity = '0.6';
|
|
1631
|
+
btn.style.cursor = 'wait';
|
|
1632
|
+
try {
|
|
1633
|
+
ctx.onComplete?.({
|
|
1634
|
+
taskId: task.id,
|
|
1635
|
+
campaignId: task.campaignId,
|
|
1636
|
+
kind: task.kind,
|
|
1637
|
+
ok: false,
|
|
1638
|
+
status: 0,
|
|
1639
|
+
needsSignature: { message: task.signMessage || task.label },
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
catch {
|
|
1643
|
+
/* host handler threw - swallow */
|
|
1644
|
+
}
|
|
1645
|
+
});
|
|
1646
|
+
applyDoneStyling(btn);
|
|
1647
|
+
row.appendChild(btn);
|
|
729
1648
|
}
|
|
1649
|
+
else if (task.kind === 'POLL') {
|
|
1650
|
+
const optionsList = task.options ?? [];
|
|
1651
|
+
if (optionsList.length === 0) {
|
|
1652
|
+
setStatus('Poll has no options.', 'err');
|
|
1653
|
+
row.appendChild(status);
|
|
1654
|
+
return row;
|
|
1655
|
+
}
|
|
1656
|
+
// Reward chip floats above the option row (we can't stamp +N on every
|
|
1657
|
+
// option button without it reading like each pick rewards multiple times).
|
|
1658
|
+
const rewardChip = makeRewardChip(reward, ctx.gsLogoUrl);
|
|
1659
|
+
const rewardWrap = document.createElement('div');
|
|
1660
|
+
rewardWrap.style.cssText = 'display:flex;justify-content:flex-end;margin-bottom:2px;';
|
|
1661
|
+
rewardWrap.appendChild(rewardChip);
|
|
1662
|
+
row.appendChild(rewardWrap);
|
|
1663
|
+
// Layout heuristic: 2 short labels (\u2264 18 chars) \u2192 side-by-side pair so the
|
|
1664
|
+
// viewer reads them as a real binary choice. Otherwise stack vertically
|
|
1665
|
+
// and emphasise the first option as primary, the rest as secondary so the
|
|
1666
|
+
// group reads as a single decision tree, not a wall of equal buttons.
|
|
1667
|
+
const allShort = optionsList.every((o) => (o.label || '').length <= 18);
|
|
1668
|
+
const horizontal = optionsList.length === 2 && allShort;
|
|
1669
|
+
const optionsWrap = document.createElement('div');
|
|
1670
|
+
optionsWrap.style.cssText = horizontal
|
|
1671
|
+
? 'display:flex;flex-direction:row;gap:4px;align-items:stretch;'
|
|
1672
|
+
: 'display:flex;flex-direction:column;gap:3px;';
|
|
1673
|
+
optionsList.forEach((opt, idx) => {
|
|
1674
|
+
const variant = horizontal
|
|
1675
|
+
? 'secondary'
|
|
1676
|
+
: idx === 0
|
|
1677
|
+
? 'primary'
|
|
1678
|
+
: 'secondary';
|
|
1679
|
+
const optBtn = makeButton(opt.label, variant);
|
|
1680
|
+
if (horizontal) {
|
|
1681
|
+
optBtn.style.flex = '1 1 0';
|
|
1682
|
+
optBtn.style.minWidth = '0';
|
|
1683
|
+
optBtn.style.whiteSpace = 'normal';
|
|
1684
|
+
}
|
|
1685
|
+
wireClick(optBtn, async () => {
|
|
1686
|
+
for (const child of Array.from(optionsWrap.children)) {
|
|
1687
|
+
;
|
|
1688
|
+
child.disabled = true;
|
|
1689
|
+
child.style.opacity = '0.6';
|
|
1690
|
+
child.style.cursor = 'default';
|
|
1691
|
+
}
|
|
1692
|
+
// Phase 5: themable selected-option swatch.
|
|
1693
|
+
optBtn.style.background = 'var(--sovads-accent, #2D2D2D)';
|
|
1694
|
+
optBtn.style.color = 'var(--sovads-on-accent, #F5F3F0)';
|
|
1695
|
+
optBtn.style.opacity = '1';
|
|
1696
|
+
await submit({ answer: opt.id });
|
|
1697
|
+
});
|
|
1698
|
+
applyDoneStyling(optBtn);
|
|
1699
|
+
optionsWrap.appendChild(optBtn);
|
|
1700
|
+
});
|
|
1701
|
+
row.appendChild(optionsWrap);
|
|
1702
|
+
}
|
|
1703
|
+
// ✓ corner badge when the viewer has already completed this task. Anchored
|
|
1704
|
+
// to the row corner so it sits over the button(s) regardless of layout
|
|
1705
|
+
// (single button, side-by-side poll, or vertical poll).
|
|
1706
|
+
if (ctx.done) {
|
|
1707
|
+
const badge = document.createElement('span');
|
|
1708
|
+
badge.title = 'You\u2019ve already completed this';
|
|
1709
|
+
badge.setAttribute('aria-label', 'completed');
|
|
1710
|
+
badge.textContent = '\u2713';
|
|
1711
|
+
badge.style.cssText =
|
|
1712
|
+
'position:absolute;top:-6px;right:-6px;z-index:2;' +
|
|
1713
|
+
'display:inline-flex;align-items:center;justify-content:center;' +
|
|
1714
|
+
'width:20px;height:20px;border-radius:50%;' +
|
|
1715
|
+
'background:var(--sovads-success, #22c55e);color:#fff;font-size:12px;font-weight:800;line-height:1;' +
|
|
1716
|
+
'border:2px solid var(--sovads-surface, #FAFAF8);box-shadow:0 1px 2px rgba(0,0,0,0.15);' +
|
|
1717
|
+
'pointer-events:none;';
|
|
1718
|
+
row.appendChild(badge);
|
|
1719
|
+
setStatus('Already completed', 'ok');
|
|
1720
|
+
}
|
|
1721
|
+
row.appendChild(status);
|
|
1722
|
+
return row;
|
|
1723
|
+
}
|
|
1724
|
+
function makeRewardChip(amount, gsLogoUrl) {
|
|
1725
|
+
const chip = document.createElement('span');
|
|
1726
|
+
chip.style.cssText =
|
|
1727
|
+
'display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:700;' +
|
|
1728
|
+
'color:var(--sovads-accent, #2D2D2D);background:var(--sovads-on-accent, #F5F3F0);' +
|
|
1729
|
+
'border:1px solid var(--sovads-accent, #2D2D2D);' +
|
|
1730
|
+
'padding:3px 8px;border-radius:999px;line-height:1;';
|
|
1731
|
+
const num = document.createElement('span');
|
|
1732
|
+
num.textContent = `+${amount}`;
|
|
1733
|
+
chip.appendChild(num);
|
|
1734
|
+
const img = document.createElement('img');
|
|
1735
|
+
img.src = gsLogoUrl;
|
|
1736
|
+
img.alt = 'G$';
|
|
1737
|
+
img.width = 14;
|
|
1738
|
+
img.height = 14;
|
|
1739
|
+
img.style.cssText = 'width:14px;height:14px;object-fit:contain;display:block;';
|
|
1740
|
+
chip.appendChild(img);
|
|
1741
|
+
return chip;
|
|
1742
|
+
}
|
|
1743
|
+
function makeButton(label, variant, reward) {
|
|
1744
|
+
const btn = document.createElement('button');
|
|
1745
|
+
btn.type = 'button';
|
|
1746
|
+
const isPrimary = variant === 'primary';
|
|
1747
|
+
btn.style.cssText = `
|
|
1748
|
+
display: ${reward ? 'flex' : 'inline-flex'};
|
|
1749
|
+
align-items: center;
|
|
1750
|
+
justify-content: ${reward ? 'space-between' : 'center'};
|
|
1751
|
+
gap: 8px;
|
|
1752
|
+
border: 1px solid var(--sovads-accent, #2D2D2D);
|
|
1753
|
+
border-radius: 6px;
|
|
1754
|
+
padding: 10px 12px;
|
|
1755
|
+
font-size: 13px;
|
|
1756
|
+
font-weight: 600;
|
|
1757
|
+
line-height: 1.2;
|
|
1758
|
+
cursor: pointer;
|
|
1759
|
+
transition: background 0.15s ease, color 0.15s ease, transform 0.05s ease;
|
|
1760
|
+
width: 100%;
|
|
1761
|
+
box-sizing: border-box;
|
|
1762
|
+
text-align: ${reward ? 'left' : 'center'};
|
|
1763
|
+
background: ${isPrimary ? 'var(--sovads-accent, #2D2D2D)' : 'var(--sovads-surface, #FAFAF8)'};
|
|
1764
|
+
color: ${isPrimary ? 'var(--sovads-on-accent, #F5F3F0)' : 'var(--sovads-accent, #2D2D2D)'};
|
|
1765
|
+
`;
|
|
1766
|
+
if (reward) {
|
|
1767
|
+
const labelEl = document.createElement('span');
|
|
1768
|
+
labelEl.textContent = label;
|
|
1769
|
+
labelEl.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
|
|
1770
|
+
btn.appendChild(labelEl);
|
|
1771
|
+
const chip = document.createElement('span');
|
|
1772
|
+
// Inverted chip vs the button surface so the reward always pops.
|
|
1773
|
+
const chipBg = isPrimary
|
|
1774
|
+
? 'var(--sovads-on-accent, #F5F3F0)'
|
|
1775
|
+
: 'var(--sovads-accent, #2D2D2D)';
|
|
1776
|
+
const chipFg = isPrimary
|
|
1777
|
+
? 'var(--sovads-accent, #2D2D2D)'
|
|
1778
|
+
: 'var(--sovads-on-accent, #F5F3F0)';
|
|
1779
|
+
chip.style.cssText = `
|
|
1780
|
+
display:inline-flex;align-items:center;gap:4px;
|
|
1781
|
+
font-size:11px;font-weight:700;
|
|
1782
|
+
background:${chipBg};color:${chipFg};
|
|
1783
|
+
padding:3px 8px;border-radius:999px;line-height:1;
|
|
1784
|
+
flex-shrink:0;
|
|
1785
|
+
`;
|
|
1786
|
+
const num = document.createElement('span');
|
|
1787
|
+
num.textContent = `+${reward.reward}`;
|
|
1788
|
+
chip.appendChild(num);
|
|
1789
|
+
const img = document.createElement('img');
|
|
1790
|
+
img.src = reward.gsLogoUrl;
|
|
1791
|
+
img.alt = 'G$';
|
|
1792
|
+
img.width = 14;
|
|
1793
|
+
img.height = 14;
|
|
1794
|
+
img.style.cssText = 'width:14px;height:14px;object-fit:contain;display:block;';
|
|
1795
|
+
chip.appendChild(img);
|
|
1796
|
+
btn.appendChild(chip);
|
|
1797
|
+
}
|
|
1798
|
+
else {
|
|
1799
|
+
btn.textContent = label;
|
|
1800
|
+
}
|
|
1801
|
+
// Lightweight hover/active feedback so it visibly behaves like a button.
|
|
1802
|
+
// Phase 5: themable hover swatches (caller can override via
|
|
1803
|
+
// --sovads-accent-hover / --sovads-surface-hover).
|
|
1804
|
+
btn.addEventListener('mouseenter', () => {
|
|
1805
|
+
if (btn.disabled)
|
|
1806
|
+
return;
|
|
1807
|
+
btn.style.background = isPrimary
|
|
1808
|
+
? 'var(--sovads-accent-hover, #1A1A1A)'
|
|
1809
|
+
: 'var(--sovads-surface-hover, #EFEDE7)';
|
|
1810
|
+
});
|
|
1811
|
+
btn.addEventListener('mouseleave', () => {
|
|
1812
|
+
if (btn.disabled)
|
|
1813
|
+
return;
|
|
1814
|
+
btn.style.background = isPrimary
|
|
1815
|
+
? 'var(--sovads-accent, #2D2D2D)'
|
|
1816
|
+
: 'var(--sovads-surface, #FAFAF8)';
|
|
1817
|
+
});
|
|
1818
|
+
btn.addEventListener('mousedown', () => {
|
|
1819
|
+
if (btn.disabled)
|
|
1820
|
+
return;
|
|
1821
|
+
// Phase 7: skip the press animation entirely when the user prefers
|
|
1822
|
+
// reduced motion. Hover background change still happens (it's not a
|
|
1823
|
+
// movement) so the button keeps its hover affordance.
|
|
1824
|
+
if (prefersReducedMotion())
|
|
1825
|
+
return;
|
|
1826
|
+
btn.style.transform = 'translateY(1px)';
|
|
1827
|
+
});
|
|
1828
|
+
btn.addEventListener('mouseup', () => {
|
|
1829
|
+
if (prefersReducedMotion())
|
|
1830
|
+
return;
|
|
1831
|
+
btn.style.transform = 'translateY(0)';
|
|
1832
|
+
});
|
|
1833
|
+
return btn;
|
|
730
1834
|
}
|
|
731
1835
|
// Banner Component
|
|
732
1836
|
export class Banner {
|
|
@@ -736,6 +1840,7 @@ export class Banner {
|
|
|
736
1840
|
this.hasTrackedImpression = false;
|
|
737
1841
|
this.isRendering = false;
|
|
738
1842
|
this.refreshTimer = null;
|
|
1843
|
+
this.lazyLoadObserver = null;
|
|
739
1844
|
this.lastAdId = null;
|
|
740
1845
|
this.retryCount = 0;
|
|
741
1846
|
this.maxRetries = 3;
|
|
@@ -759,8 +1864,15 @@ export class Banner {
|
|
|
759
1864
|
this.isRendering = false;
|
|
760
1865
|
return;
|
|
761
1866
|
}
|
|
762
|
-
//
|
|
763
|
-
|
|
1867
|
+
// Phase 3: when the publisher declared a slot size, reserve the
|
|
1868
|
+
// CLS-safe aspect-ratio box BEFORE the fetch so the page layout
|
|
1869
|
+
// doesn't jump when the ad eventually loads. When no size is given
|
|
1870
|
+
// we fall back to the legacy hide-then-show behaviour for backcompat.
|
|
1871
|
+
const sizeReserved = reserveAdSlot(container, this.slotConfig.size);
|
|
1872
|
+
if (!sizeReserved) {
|
|
1873
|
+
// Legacy: hide until media loads.
|
|
1874
|
+
container.style.display = 'none';
|
|
1875
|
+
}
|
|
764
1876
|
// Lazy loading: wait for container to be in viewport
|
|
765
1877
|
if (this.sovads.getConfig().lazyLoad && !forceRefresh) {
|
|
766
1878
|
const isInViewport = await this.checkViewport(container);
|
|
@@ -776,6 +1888,7 @@ export class Banner {
|
|
|
776
1888
|
consumerId,
|
|
777
1889
|
placement: this.slotConfig.placementId || 'banner',
|
|
778
1890
|
size: this.slotConfig.size,
|
|
1891
|
+
attached: this.slotConfig.attached === true,
|
|
779
1892
|
});
|
|
780
1893
|
this.hasTrackedImpression = false;
|
|
781
1894
|
// Skip if same ad (rotation disabled or same ad returned)
|
|
@@ -829,11 +1942,15 @@ export class Banner {
|
|
|
829
1942
|
window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
|
|
830
1943
|
});
|
|
831
1944
|
dummyElement.addEventListener('mouseenter', () => {
|
|
832
|
-
|
|
1945
|
+
// Phase 7: keep the bg change (informative), drop the scale
|
|
1946
|
+
// (movement) when the user prefers reduced motion.
|
|
1947
|
+
if (!prefersReducedMotion())
|
|
1948
|
+
dummyElement.style.transform = 'scale(1.02)';
|
|
833
1949
|
dummyElement.style.background = '#f0f0f0';
|
|
834
1950
|
});
|
|
835
1951
|
dummyElement.addEventListener('mouseleave', () => {
|
|
836
|
-
|
|
1952
|
+
if (!prefersReducedMotion())
|
|
1953
|
+
dummyElement.style.transform = 'scale(1)';
|
|
837
1954
|
dummyElement.style.background = '#f9f9f9';
|
|
838
1955
|
});
|
|
839
1956
|
container.appendChild(dummyElement);
|
|
@@ -844,20 +1961,37 @@ export class Banner {
|
|
|
844
1961
|
container.innerHTML = '';
|
|
845
1962
|
adElement.className = 'sovads-banner';
|
|
846
1963
|
adElement.setAttribute('data-ad-id', this.currentAd.id);
|
|
1964
|
+
// Phase 7: announce the unit as an advertisement to AT users so it
|
|
1965
|
+
// can be navigated to / skipped past with rotor + landmark shortcuts.
|
|
1966
|
+
adElement.setAttribute('role', 'region');
|
|
1967
|
+
adElement.setAttribute('aria-label', 'Advertisement');
|
|
847
1968
|
const mediaType = this.currentAd.mediaType === 'video' ? 'video' : 'image';
|
|
1969
|
+
// Phase 2: resolve click-target + disclosure once for this render pass.
|
|
1970
|
+
// Default for Banner stays 'media' = today's behaviour (backcompat).
|
|
1971
|
+
// Video / streaming embeds always render an explicit Learn-more button
|
|
1972
|
+
// regardless of clickTarget, because the iframe / <video> controls
|
|
1973
|
+
// intercept pointer events.
|
|
1974
|
+
const slotClickTarget = this.slotConfig.clickTarget ?? 'media';
|
|
1975
|
+
const useButtonCta = slotClickTarget === 'button' || mediaType === 'video';
|
|
848
1976
|
adElement.style.cssText = `
|
|
1977
|
+
position: relative;
|
|
849
1978
|
border: 1px solid #333;
|
|
850
1979
|
border-radius: 8px;
|
|
851
1980
|
overflow: hidden;
|
|
852
|
-
cursor: ${
|
|
1981
|
+
cursor: ${useButtonCta ? 'default' : 'pointer'};
|
|
853
1982
|
transition: transform 0.2s ease;
|
|
854
1983
|
max-width: 100%;
|
|
855
1984
|
width: 100%;
|
|
856
1985
|
box-sizing: border-box;
|
|
857
1986
|
opacity: 0;
|
|
858
1987
|
`;
|
|
859
|
-
//
|
|
860
|
-
|
|
1988
|
+
// Phase 3: hide-until-loaded ONLY when we didn't reserve a CLS-safe
|
|
1989
|
+
// box up front. When `sizeReserved` is true the container is already
|
|
1990
|
+
// showing a placeholder of the right shape; hiding it now would
|
|
1991
|
+
// re-introduce the layout shift we just prevented.
|
|
1992
|
+
if (!sizeReserved) {
|
|
1993
|
+
container.style.display = 'none';
|
|
1994
|
+
}
|
|
861
1995
|
const handleVisibilityTracking = (renderInfo) => {
|
|
862
1996
|
this.sovads.setupRenderObserver(adElement, this.currentAd.id, (isVisible) => {
|
|
863
1997
|
renderInfo.viewportVisible = isVisible;
|
|
@@ -889,7 +2023,15 @@ export class Banner {
|
|
|
889
2023
|
});
|
|
890
2024
|
};
|
|
891
2025
|
let mediaElement;
|
|
892
|
-
|
|
2026
|
+
const streamingEmbed = toStreamingEmbed(this.currentAd.bannerUrl);
|
|
2027
|
+
if (streamingEmbed) {
|
|
2028
|
+
// Streaming platform (YouTube/Vimeo/TikTok) — sandboxed iframe.
|
|
2029
|
+
const iframe = buildStreamingIframe(streamingEmbed, this.currentAd.description);
|
|
2030
|
+
iframe.addEventListener('load', handleRenderSuccess, { once: true });
|
|
2031
|
+
iframe.addEventListener('error', handleRenderError, { once: true });
|
|
2032
|
+
mediaElement = iframe;
|
|
2033
|
+
}
|
|
2034
|
+
else if (mediaType === 'video') {
|
|
893
2035
|
const video = document.createElement('video');
|
|
894
2036
|
video.src = this.currentAd.bannerUrl;
|
|
895
2037
|
video.muted = true;
|
|
@@ -911,9 +2053,21 @@ export class Banner {
|
|
|
911
2053
|
img.addEventListener('error', handleRenderError, { once: true });
|
|
912
2054
|
mediaElement = img;
|
|
913
2055
|
}
|
|
914
|
-
|
|
2056
|
+
// Streaming iframes can't be made clickable as a whole — the iframe
|
|
2057
|
+
// intercepts pointer events for its own player UI. Video <video> tags
|
|
2058
|
+
// get an external "Learn more" button below. Only plain images get the
|
|
2059
|
+
// banner-as-link cursor.
|
|
2060
|
+
mediaElement.style.cursor = mediaType === 'video' || streamingEmbed ? 'default' : 'pointer';
|
|
915
2061
|
mediaElement.style.maxWidth = '100%';
|
|
916
2062
|
const handleClickThrough = () => {
|
|
2063
|
+
// When the campaign is out of token budget, banner click-through is
|
|
2064
|
+
// suppressed — viewers earn via the attached CTAs instead.
|
|
2065
|
+
if (this.currentAd.bannerClickActive === false) {
|
|
2066
|
+
if (this.sovads.getConfig().debug) {
|
|
2067
|
+
console.log('[SovAds] banner click suppressed (budget exhausted, attached CTAs active)');
|
|
2068
|
+
}
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
917
2071
|
this.sovads._trackEvent('CLICK', this.currentAd.id, this.currentAd.campaignId, {
|
|
918
2072
|
rendered: true,
|
|
919
2073
|
viewportVisible: true,
|
|
@@ -927,7 +2081,11 @@ export class Banner {
|
|
|
927
2081
|
});
|
|
928
2082
|
window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
|
|
929
2083
|
};
|
|
930
|
-
if (
|
|
2084
|
+
if (useButtonCta) {
|
|
2085
|
+
// Explicit "Learn more" button is the only click target. Used for:
|
|
2086
|
+
// - video / streaming embeds (player intercepts pointer events)
|
|
2087
|
+
// - slots configured with `clickTarget: 'button'` (Phase 2 opt-in
|
|
2088
|
+
// for higher-quality click traffic).
|
|
931
2089
|
const ctaButton = document.createElement('button');
|
|
932
2090
|
ctaButton.type = 'button';
|
|
933
2091
|
ctaButton.textContent = 'Learn more';
|
|
@@ -947,17 +2105,61 @@ export class Banner {
|
|
|
947
2105
|
adElement.appendChild(ctaButton);
|
|
948
2106
|
}
|
|
949
2107
|
else {
|
|
2108
|
+
// Legacy: whole element is the click target. Backwards compatible.
|
|
950
2109
|
adElement.addEventListener('click', handleClickThrough);
|
|
951
2110
|
adElement.appendChild(mediaElement);
|
|
952
2111
|
}
|
|
953
|
-
//
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
2112
|
+
// Phase 2: mount the Sponsored disclosure on every render unless
|
|
2113
|
+
// explicitly suppressed by config or slot override.
|
|
2114
|
+
const disclosure = buildPositionedDisclosure({
|
|
2115
|
+
slotOverride: this.slotConfig.disclosureLabel,
|
|
2116
|
+
configValue: this.sovads.getConfig().disclosureLabel,
|
|
2117
|
+
advertiser: this.sovads.getConfig().advertiserName,
|
|
2118
|
+
variant: 'dark',
|
|
2119
|
+
position: 'top-left',
|
|
959
2120
|
});
|
|
2121
|
+
if (disclosure)
|
|
2122
|
+
adElement.appendChild(disclosure);
|
|
2123
|
+
// Add hover effect
|
|
2124
|
+
// Only animate the whole element when the whole element is the click
|
|
2125
|
+
// target. With an explicit button the user's pointer is over the button,
|
|
2126
|
+
// not the media, so the scale would be misleading. Phase 7: also skip
|
|
2127
|
+
// the animation entirely when the user has asked the OS for reduced
|
|
2128
|
+
// motion \u2014 the click affordance is already in the cursor.
|
|
2129
|
+
if (!useButtonCta && !prefersReducedMotion()) {
|
|
2130
|
+
adElement.addEventListener('mouseenter', () => {
|
|
2131
|
+
adElement.style.transform = 'scale(1.02)';
|
|
2132
|
+
});
|
|
2133
|
+
adElement.addEventListener('mouseleave', () => {
|
|
2134
|
+
adElement.style.transform = 'scale(1)';
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
960
2137
|
container.appendChild(adElement);
|
|
2138
|
+
// Mount attached CTAs under the banner when the slot opted in and the
|
|
2139
|
+
// server returned at least one. When bannerClickActive=false this is the
|
|
2140
|
+
// only way the viewer can earn from this impression.
|
|
2141
|
+
if (this.slotConfig.attached === true &&
|
|
2142
|
+
Array.isArray(this.currentAd.attachedTasks) &&
|
|
2143
|
+
this.currentAd.attachedTasks.length > 0) {
|
|
2144
|
+
try {
|
|
2145
|
+
renderAttachedCtas({
|
|
2146
|
+
container,
|
|
2147
|
+
sovads: this.sovads,
|
|
2148
|
+
tasks: this.currentAd.attachedTasks,
|
|
2149
|
+
campaignId: this.currentAd.campaignId,
|
|
2150
|
+
bannerClickActive: this.currentAd.bannerClickActive !== false,
|
|
2151
|
+
onComplete: this.slotConfig.onCtaComplete,
|
|
2152
|
+
// 2 tasks → horizontal row (saves the second line under a thin
|
|
2153
|
+
// banner); 1 or 3+ tasks → stack as before.
|
|
2154
|
+
layout: 'auto',
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
2157
|
+
catch (e) {
|
|
2158
|
+
if (this.sovads.getConfig().debug) {
|
|
2159
|
+
console.error('[SovAds] renderAttachedCtas failed', e);
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
961
2163
|
// Set up auto-refresh if enabled
|
|
962
2164
|
this.setupAutoRefresh(consumerId);
|
|
963
2165
|
}
|
|
@@ -1015,14 +2217,21 @@ export class Banner {
|
|
|
1015
2217
|
this.render(consumerId);
|
|
1016
2218
|
return;
|
|
1017
2219
|
}
|
|
2220
|
+
// Disconnect any previous observer before creating a new one
|
|
2221
|
+
if (this.lazyLoadObserver) {
|
|
2222
|
+
this.lazyLoadObserver.disconnect();
|
|
2223
|
+
this.lazyLoadObserver = null;
|
|
2224
|
+
}
|
|
1018
2225
|
const observer = new IntersectionObserver((entries) => {
|
|
1019
2226
|
entries.forEach((entry) => {
|
|
1020
2227
|
if (entry.isIntersecting && !this.isRendering) {
|
|
1021
2228
|
observer.disconnect();
|
|
2229
|
+
this.lazyLoadObserver = null;
|
|
1022
2230
|
this.render(consumerId);
|
|
1023
2231
|
}
|
|
1024
2232
|
});
|
|
1025
2233
|
}, { rootMargin: '50px' });
|
|
2234
|
+
this.lazyLoadObserver = observer;
|
|
1026
2235
|
observer.observe(container);
|
|
1027
2236
|
}
|
|
1028
2237
|
setupAutoRefresh(consumerId) {
|
|
@@ -1044,9 +2253,12 @@ export class Banner {
|
|
|
1044
2253
|
clearInterval(this.refreshTimer);
|
|
1045
2254
|
this.refreshTimer = null;
|
|
1046
2255
|
}
|
|
2256
|
+
if (this.lazyLoadObserver) {
|
|
2257
|
+
this.lazyLoadObserver.disconnect();
|
|
2258
|
+
this.lazyLoadObserver = null;
|
|
2259
|
+
}
|
|
1047
2260
|
}
|
|
1048
2261
|
}
|
|
1049
|
-
// Popup Component
|
|
1050
2262
|
export class Popup {
|
|
1051
2263
|
constructor(sovads) {
|
|
1052
2264
|
this.currentAd = null;
|
|
@@ -1056,6 +2268,13 @@ export class Popup {
|
|
|
1056
2268
|
this.maxRetries = 3;
|
|
1057
2269
|
this.storageKeyLastShown = 'sovads_popup_last_shown';
|
|
1058
2270
|
this.storageKeySessionCount = 'sovads_popup_session_count';
|
|
2271
|
+
/** Phase 1: remembered across the show \u2192 renderPopup boundary so the CTA
|
|
2272
|
+
* mount has access to the original opts without changing renderPopup's
|
|
2273
|
+
* signature (kept private to preserve subclass compatibility). */
|
|
2274
|
+
this.currentOpts = {};
|
|
2275
|
+
/** Phase 7: keyboard escape hatch. Bound once per show() so we can
|
|
2276
|
+
* removeEventListener on hide() and avoid listener leaks. */
|
|
2277
|
+
this.escHandler = null;
|
|
1059
2278
|
this.sovads = sovads;
|
|
1060
2279
|
}
|
|
1061
2280
|
canShowByFrequencyCap() {
|
|
@@ -1086,7 +2305,27 @@ export class Popup {
|
|
|
1086
2305
|
// Ignore storage access issues.
|
|
1087
2306
|
}
|
|
1088
2307
|
}
|
|
1089
|
-
|
|
2308
|
+
/**
|
|
2309
|
+
* Show the popup. Two call shapes (both supported \u2014 backwards compatible):
|
|
2310
|
+
*
|
|
2311
|
+
* popup.show() // defaults
|
|
2312
|
+
* popup.show('consumer-id', 3000) // legacy positional
|
|
2313
|
+
* popup.show({ consumerId, delay, attached, onCtaComplete }) // recommended
|
|
2314
|
+
*/
|
|
2315
|
+
async show(consumerIdOrOpts, delay) {
|
|
2316
|
+
// Normalise the two call shapes into a single opts object. Old positional
|
|
2317
|
+
// calls take precedence over delay defaults to preserve today's semantics.
|
|
2318
|
+
let opts;
|
|
2319
|
+
if (typeof consumerIdOrOpts === 'string' || consumerIdOrOpts === undefined) {
|
|
2320
|
+
opts = {
|
|
2321
|
+
consumerId: consumerIdOrOpts,
|
|
2322
|
+
delay: delay ?? 3000,
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
else {
|
|
2326
|
+
opts = { delay: 3000, ...consumerIdOrOpts };
|
|
2327
|
+
}
|
|
2328
|
+
this.currentOpts = opts;
|
|
1090
2329
|
// Prevent concurrent shows
|
|
1091
2330
|
if (this.isShowing) {
|
|
1092
2331
|
if (this.sovads.getConfig().debug) {
|
|
@@ -1103,16 +2342,17 @@ export class Popup {
|
|
|
1103
2342
|
this.isShowing = true;
|
|
1104
2343
|
try {
|
|
1105
2344
|
this.currentAd = await this.sovads.loadAd({
|
|
1106
|
-
consumerId,
|
|
2345
|
+
consumerId: opts.consumerId,
|
|
1107
2346
|
placement: 'popup',
|
|
1108
2347
|
size: window.innerWidth < 640 ? '320x100' : '360x120',
|
|
2348
|
+
attached: opts.attached === true,
|
|
1109
2349
|
});
|
|
1110
2350
|
if (!this.currentAd) {
|
|
1111
2351
|
if (this.retryCount < this.maxRetries) {
|
|
1112
2352
|
this.retryCount++;
|
|
1113
2353
|
await new Promise(resolve => setTimeout(resolve, 1000 * this.retryCount));
|
|
1114
2354
|
this.isShowing = false;
|
|
1115
|
-
return this.show(
|
|
2355
|
+
return this.show(opts);
|
|
1116
2356
|
}
|
|
1117
2357
|
if (this.sovads.getConfig().debug) {
|
|
1118
2358
|
console.log('No popup ad available after retries');
|
|
@@ -1127,7 +2367,7 @@ export class Popup {
|
|
|
1127
2367
|
this.renderPopup();
|
|
1128
2368
|
this.markShown();
|
|
1129
2369
|
this.isShowing = false;
|
|
1130
|
-
}, delay);
|
|
2370
|
+
}, opts.delay ?? 3000);
|
|
1131
2371
|
}
|
|
1132
2372
|
catch (error) {
|
|
1133
2373
|
if (this.sovads.getConfig().debug) {
|
|
@@ -1161,6 +2401,13 @@ export class Popup {
|
|
|
1161
2401
|
// Create non-blocking sticky container
|
|
1162
2402
|
const wrapper = document.createElement('div');
|
|
1163
2403
|
wrapper.className = 'sovads-popup-overlay';
|
|
2404
|
+
// Phase 7: dialog semantics for AT. `aria-modal=false` because the
|
|
2405
|
+
// popup deliberately does NOT block page interaction — viewers should
|
|
2406
|
+
// be able to keep reading and tab past it. Focus is not trapped here
|
|
2407
|
+
// for the same reason.
|
|
2408
|
+
wrapper.setAttribute('role', 'dialog');
|
|
2409
|
+
wrapper.setAttribute('aria-modal', 'false');
|
|
2410
|
+
wrapper.setAttribute('aria-label', 'Advertisement');
|
|
1164
2411
|
wrapper.style.cssText = `
|
|
1165
2412
|
position: fixed;
|
|
1166
2413
|
right: 16px;
|
|
@@ -1218,7 +2465,12 @@ export class Popup {
|
|
|
1218
2465
|
closeBtn.addEventListener('click', () => {
|
|
1219
2466
|
this.hide();
|
|
1220
2467
|
});
|
|
1221
|
-
// Add "Ad" message text below logo
|
|
2468
|
+
// Add "Ad" message text below logo. Phase 2: label text is configurable
|
|
2469
|
+
// via SovAdsConfig.disclosureLabel / show({ disclosureLabel }). Passing
|
|
2470
|
+
// `false` hides this badge entirely. (advertiserName isn't relevant here
|
|
2471
|
+
// — this is the legacy inline label; the SDK-wide advertiser hint only
|
|
2472
|
+
// shows up via buildPositionedDisclosure, used by Banner/Sidebar.)
|
|
2473
|
+
const popupDisclosureLabel = resolveDisclosureLabel(this.currentOpts.disclosureLabel, this.sovads.getConfig().disclosureLabel);
|
|
1222
2474
|
const adLabel = document.createElement('div');
|
|
1223
2475
|
adLabel.style.cssText = `
|
|
1224
2476
|
position: absolute;
|
|
@@ -1230,7 +2482,13 @@ export class Popup {
|
|
|
1230
2482
|
text-transform: uppercase;
|
|
1231
2483
|
letter-spacing: 0.5px;
|
|
1232
2484
|
`;
|
|
1233
|
-
|
|
2485
|
+
if (popupDisclosureLabel) {
|
|
2486
|
+
adLabel.textContent = popupDisclosureLabel;
|
|
2487
|
+
}
|
|
2488
|
+
else {
|
|
2489
|
+
// Suppressed by config \u2014 detach so it never enters the DOM.
|
|
2490
|
+
adLabel.style.display = 'none';
|
|
2491
|
+
}
|
|
1234
2492
|
// Handle dummy ads
|
|
1235
2493
|
if (this.currentAd.isDummy) {
|
|
1236
2494
|
const dummyContent = document.createElement('div');
|
|
@@ -1265,6 +2523,7 @@ export class Popup {
|
|
|
1265
2523
|
this.popupElement.appendChild(dummyContent);
|
|
1266
2524
|
wrapper.appendChild(this.popupElement);
|
|
1267
2525
|
document.body.appendChild(wrapper);
|
|
2526
|
+
this.bindEscHandler();
|
|
1268
2527
|
// Auto close after 10 seconds
|
|
1269
2528
|
setTimeout(() => {
|
|
1270
2529
|
this.hide();
|
|
@@ -1283,7 +2542,20 @@ export class Popup {
|
|
|
1283
2542
|
trackPopupImpression(false, renderTime);
|
|
1284
2543
|
};
|
|
1285
2544
|
let mediaElement;
|
|
1286
|
-
|
|
2545
|
+
const streamingEmbed = toStreamingEmbed(this.currentAd.bannerUrl);
|
|
2546
|
+
if (streamingEmbed) {
|
|
2547
|
+
const iframe = buildStreamingIframe(streamingEmbed, this.currentAd.description);
|
|
2548
|
+
iframe.style.borderRadius = '8px';
|
|
2549
|
+
iframe.addEventListener('load', () => {
|
|
2550
|
+
if (this.popupElement)
|
|
2551
|
+
this.popupElement.style.opacity = '1';
|
|
2552
|
+
const renderTime = Date.now() - renderStartTime;
|
|
2553
|
+
trackPopupImpression(true, renderTime);
|
|
2554
|
+
}, { once: true });
|
|
2555
|
+
iframe.addEventListener('error', handleMediaError, { once: true });
|
|
2556
|
+
mediaElement = iframe;
|
|
2557
|
+
}
|
|
2558
|
+
else if (mediaType === 'video') {
|
|
1287
2559
|
const video = document.createElement('video');
|
|
1288
2560
|
video.src = this.currentAd.bannerUrl;
|
|
1289
2561
|
video.muted = true;
|
|
@@ -1338,7 +2610,11 @@ export class Popup {
|
|
|
1338
2610
|
window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
|
|
1339
2611
|
this.hide();
|
|
1340
2612
|
};
|
|
1341
|
-
|
|
2613
|
+
// Phase 2: caller can force a button click target instead of the
|
|
2614
|
+
// legacy click-the-whole-image behaviour.
|
|
2615
|
+
const popupClickTarget = this.currentOpts.clickTarget ?? 'media';
|
|
2616
|
+
const useButtonCta = popupClickTarget === 'button' || mediaType === 'video' || !!streamingEmbed;
|
|
2617
|
+
if (useButtonCta) {
|
|
1342
2618
|
mediaElement.style.cursor = 'default';
|
|
1343
2619
|
}
|
|
1344
2620
|
else {
|
|
@@ -1349,7 +2625,7 @@ export class Popup {
|
|
|
1349
2625
|
this.popupElement.appendChild(adLabel);
|
|
1350
2626
|
this.popupElement.appendChild(closeBtn);
|
|
1351
2627
|
this.popupElement.appendChild(mediaElement);
|
|
1352
|
-
if (
|
|
2628
|
+
if (useButtonCta) {
|
|
1353
2629
|
const ctaButton = document.createElement('button');
|
|
1354
2630
|
ctaButton.type = 'button';
|
|
1355
2631
|
ctaButton.textContent = 'Learn more';
|
|
@@ -1368,14 +2644,66 @@ export class Popup {
|
|
|
1368
2644
|
ctaButton.addEventListener('click', handleClickThrough);
|
|
1369
2645
|
this.popupElement.appendChild(ctaButton);
|
|
1370
2646
|
}
|
|
2647
|
+
// Phase 1: mount attached CTAs inside the popup card when opted in.
|
|
2648
|
+
if (this.currentOpts.attached === true &&
|
|
2649
|
+
Array.isArray(this.currentAd.attachedTasks) &&
|
|
2650
|
+
this.currentAd.attachedTasks.length > 0) {
|
|
2651
|
+
const ctaSlot = document.createElement('div');
|
|
2652
|
+
ctaSlot.style.cssText = 'margin-top:10px;';
|
|
2653
|
+
this.popupElement.appendChild(ctaSlot);
|
|
2654
|
+
mountCtaPanel({
|
|
2655
|
+
container: ctaSlot,
|
|
2656
|
+
sovads: this.sovads,
|
|
2657
|
+
surface: 'POPUP',
|
|
2658
|
+
tasks: this.currentAd.attachedTasks,
|
|
2659
|
+
campaignId: this.currentAd.campaignId,
|
|
2660
|
+
bannerClickActive: this.currentAd.bannerClickActive !== false,
|
|
2661
|
+
onComplete: this.currentOpts.onCtaComplete,
|
|
2662
|
+
// 2 tasks → horizontal row to keep the popup compact; otherwise
|
|
2663
|
+
// stack so 3+ tasks don't crush their labels.
|
|
2664
|
+
layout: 'auto',
|
|
2665
|
+
});
|
|
2666
|
+
}
|
|
1371
2667
|
wrapper.appendChild(this.popupElement);
|
|
1372
2668
|
document.body.appendChild(wrapper);
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
2669
|
+
this.bindEscHandler();
|
|
2670
|
+
// Auto close after 10 seconds \u2014 but only when there are no CTAs to
|
|
2671
|
+
// complete. If the viewer might be mid-interaction with an attached task
|
|
2672
|
+
// (typing, signing, waiting for dwell), keep the card open until they
|
|
2673
|
+
// dismiss it manually.
|
|
2674
|
+
const hasCtas = this.currentOpts.attached === true &&
|
|
2675
|
+
Array.isArray(this.currentAd?.attachedTasks) &&
|
|
2676
|
+
(this.currentAd?.attachedTasks?.length ?? 0) > 0;
|
|
2677
|
+
if (!hasCtas) {
|
|
2678
|
+
setTimeout(() => {
|
|
2679
|
+
this.hide();
|
|
2680
|
+
}, 10000);
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
/** Phase 7: keyboard escape hatch. The popup is a non-modal sticky card,
|
|
2684
|
+
* so we don't trap focus — but pressing Esc anywhere should dismiss it.
|
|
2685
|
+
* Bound on each renderPopup() so re-shows install a fresh handler, and
|
|
2686
|
+
* always paired with removeEventListener in hide(). */
|
|
2687
|
+
bindEscHandler() {
|
|
2688
|
+
if (typeof document === 'undefined')
|
|
2689
|
+
return;
|
|
2690
|
+
if (this.escHandler) {
|
|
2691
|
+
document.removeEventListener('keydown', this.escHandler);
|
|
2692
|
+
}
|
|
2693
|
+
this.escHandler = (ev) => {
|
|
2694
|
+
if (ev.key === 'Escape' || ev.key === 'Esc') {
|
|
2695
|
+
this.hide();
|
|
2696
|
+
}
|
|
2697
|
+
};
|
|
2698
|
+
document.addEventListener('keydown', this.escHandler);
|
|
1377
2699
|
}
|
|
1378
2700
|
hide() {
|
|
2701
|
+
// Phase 7: detach the Esc key listener before tearing down the DOM so
|
|
2702
|
+
// we don't leak handlers across show → hide cycles.
|
|
2703
|
+
if (this.escHandler) {
|
|
2704
|
+
document.removeEventListener('keydown', this.escHandler);
|
|
2705
|
+
this.escHandler = null;
|
|
2706
|
+
}
|
|
1379
2707
|
const overlay = document.querySelector('.sovads-popup-overlay');
|
|
1380
2708
|
if (overlay) {
|
|
1381
2709
|
try {
|
|
@@ -1397,7 +2725,6 @@ export class Popup {
|
|
|
1397
2725
|
this.currentAd = null;
|
|
1398
2726
|
}
|
|
1399
2727
|
}
|
|
1400
|
-
// BottomBar Component
|
|
1401
2728
|
export class BottomBar {
|
|
1402
2729
|
constructor(sovads) {
|
|
1403
2730
|
this.barElement = null;
|
|
@@ -1405,9 +2732,30 @@ export class BottomBar {
|
|
|
1405
2732
|
this.isVisible = false;
|
|
1406
2733
|
this.retryCount = 0;
|
|
1407
2734
|
this.maxRetries = 3;
|
|
2735
|
+
/** Phase 1: remembered across show \u2192 renderBar so the CTA mount has
|
|
2736
|
+
* access to the original opts. */
|
|
2737
|
+
this.currentOpts = {};
|
|
2738
|
+
/** Phase 7: keyboard escape hatch. Bound when the bar is appended to
|
|
2739
|
+
* the DOM, removed in hide() to avoid listener leaks. */
|
|
2740
|
+
this.escHandler = null;
|
|
1408
2741
|
this.sovads = sovads;
|
|
1409
2742
|
}
|
|
1410
|
-
|
|
2743
|
+
/**
|
|
2744
|
+
* Show the bottom bar. Two call shapes (both supported \u2014 backwards compatible):
|
|
2745
|
+
*
|
|
2746
|
+
* bottomBar.show()
|
|
2747
|
+
* bottomBar.show('consumer-id') // legacy positional
|
|
2748
|
+
* bottomBar.show({ consumerId, attached, onCtaComplete }) // recommended
|
|
2749
|
+
*/
|
|
2750
|
+
async show(consumerIdOrOpts) {
|
|
2751
|
+
let opts;
|
|
2752
|
+
if (typeof consumerIdOrOpts === 'string' || consumerIdOrOpts === undefined) {
|
|
2753
|
+
opts = { consumerId: consumerIdOrOpts };
|
|
2754
|
+
}
|
|
2755
|
+
else {
|
|
2756
|
+
opts = { ...consumerIdOrOpts };
|
|
2757
|
+
}
|
|
2758
|
+
this.currentOpts = opts;
|
|
1411
2759
|
if (this.isVisible) {
|
|
1412
2760
|
if (this.sovads.getConfig().debug) {
|
|
1413
2761
|
console.warn('BottomBar already visible');
|
|
@@ -1416,15 +2764,16 @@ export class BottomBar {
|
|
|
1416
2764
|
}
|
|
1417
2765
|
try {
|
|
1418
2766
|
this.currentAd = await this.sovads.loadAd({
|
|
1419
|
-
consumerId,
|
|
2767
|
+
consumerId: opts.consumerId,
|
|
1420
2768
|
placement: 'bottom-bar',
|
|
1421
2769
|
size: 'full-width',
|
|
2770
|
+
attached: opts.attached === true,
|
|
1422
2771
|
});
|
|
1423
2772
|
if (!this.currentAd) {
|
|
1424
2773
|
if (this.retryCount < this.maxRetries) {
|
|
1425
2774
|
this.retryCount++;
|
|
1426
2775
|
await new Promise(resolve => setTimeout(resolve, 1000 * this.retryCount));
|
|
1427
|
-
return this.show(
|
|
2776
|
+
return this.show(opts);
|
|
1428
2777
|
}
|
|
1429
2778
|
if (this.sovads.getConfig().debug) {
|
|
1430
2779
|
console.log('No bottom‑bar ad available after retries');
|
|
@@ -1460,6 +2809,10 @@ export class BottomBar {
|
|
|
1460
2809
|
// wrapper fixed bottom
|
|
1461
2810
|
const wrapper = document.createElement('div');
|
|
1462
2811
|
wrapper.className = 'sovads-bottom-bar';
|
|
2812
|
+
// Phase 7: region landmark + label so AT users can jump straight to /
|
|
2813
|
+
// past the bar with rotor navigation.
|
|
2814
|
+
wrapper.setAttribute('role', 'region');
|
|
2815
|
+
wrapper.setAttribute('aria-label', 'Advertisement');
|
|
1463
2816
|
wrapper.style.cssText = `
|
|
1464
2817
|
position: fixed;
|
|
1465
2818
|
left: 0;
|
|
@@ -1545,13 +2898,113 @@ export class BottomBar {
|
|
|
1545
2898
|
this.hide();
|
|
1546
2899
|
};
|
|
1547
2900
|
bar.appendChild(closeBtn);
|
|
1548
|
-
|
|
1549
|
-
|
|
2901
|
+
// Phase 2: resolve click-target + disclosure once.
|
|
2902
|
+
// BottomBar default stays 'media' (bar-wide click) for full backwards
|
|
2903
|
+
// compatibility. Publishers worried about accidental clicks should set
|
|
2904
|
+
// `{ clickTarget: 'button' }` in the show() options, which renders an
|
|
2905
|
+
// explicit "Learn more" button inside the bar instead.
|
|
2906
|
+
const barClickTarget = this.currentOpts.clickTarget ?? 'media';
|
|
2907
|
+
const useButtonCta = barClickTarget === 'button' || mediaType === 'video';
|
|
2908
|
+
// Mount disclosure once \u2014 same badge regardless of CTA layout.
|
|
2909
|
+
// We anchor it inside `bar` because `wrapper` is the fullscreen rail.
|
|
2910
|
+
const disclosure = buildPositionedDisclosure({
|
|
2911
|
+
slotOverride: this.currentOpts.disclosureLabel,
|
|
2912
|
+
configValue: this.sovads.getConfig().disclosureLabel,
|
|
2913
|
+
advertiser: this.sovads.getConfig().advertiserName,
|
|
2914
|
+
variant: 'light',
|
|
2915
|
+
position: 'top-left',
|
|
2916
|
+
});
|
|
2917
|
+
if (disclosure)
|
|
2918
|
+
bar.appendChild(disclosure);
|
|
2919
|
+
// Phase 1: when CTAs are requested + returned, lay media + CTA panel in a
|
|
2920
|
+
// horizontal row. Media keeps its own click target (so the existing
|
|
2921
|
+
// banner-click path still works); CTA buttons get their own click handlers
|
|
2922
|
+
// and we suppress the bar-wide click handler so taps on a poll option
|
|
2923
|
+
// don't double-fire as a banner click + redirect.
|
|
2924
|
+
const hasCtas = this.currentOpts.attached === true &&
|
|
2925
|
+
Array.isArray(this.currentAd.attachedTasks) &&
|
|
2926
|
+
this.currentAd.attachedTasks.length > 0;
|
|
2927
|
+
if (hasCtas) {
|
|
2928
|
+
const row = document.createElement('div');
|
|
2929
|
+
row.style.cssText = 'display:flex;flex-direction:row;gap:12px;align-items:center;width:100%;';
|
|
2930
|
+
const mediaCol = document.createElement('div');
|
|
2931
|
+
mediaCol.style.cssText = `flex:1 1 auto;min-width:0;cursor:${useButtonCta ? 'default' : 'pointer'};`;
|
|
2932
|
+
mediaCol.appendChild(mediaEl);
|
|
2933
|
+
// Phase 2: only the media column is clickable when clickTarget='media'.
|
|
2934
|
+
// With 'button', media is silent and viewers must use the inline CTA
|
|
2935
|
+
// panel (which has its own per-task buttons).
|
|
2936
|
+
if (!useButtonCta) {
|
|
2937
|
+
mediaCol.addEventListener('click', handleClick);
|
|
2938
|
+
}
|
|
2939
|
+
row.appendChild(mediaCol);
|
|
2940
|
+
const ctaCol = document.createElement('div');
|
|
2941
|
+
ctaCol.style.cssText = 'flex:0 0 auto;min-width:160px;max-width:50%;';
|
|
2942
|
+
// Stop propagation so taps on CTA buttons don't bubble into the bar's
|
|
2943
|
+
// legacy click handler (kept off in this branch, but defence-in-depth).
|
|
2944
|
+
ctaCol.addEventListener('click', (e) => e.stopPropagation());
|
|
2945
|
+
row.appendChild(ctaCol);
|
|
2946
|
+
bar.appendChild(row);
|
|
2947
|
+
mountCtaPanel({
|
|
2948
|
+
container: ctaCol,
|
|
2949
|
+
sovads: this.sovads,
|
|
2950
|
+
surface: 'BOTTOM_BAR',
|
|
2951
|
+
tasks: this.currentAd.attachedTasks,
|
|
2952
|
+
campaignId: this.currentAd.campaignId,
|
|
2953
|
+
bannerClickActive: this.currentAd.bannerClickActive !== false,
|
|
2954
|
+
onComplete: this.currentOpts.onCtaComplete,
|
|
2955
|
+
layout: 'inline',
|
|
2956
|
+
});
|
|
2957
|
+
}
|
|
2958
|
+
else if (useButtonCta) {
|
|
2959
|
+
// No attached tasks but caller wants an explicit click target. Render
|
|
2960
|
+
// the media + a "Learn more" pill underneath, same as Banner/Popup.
|
|
2961
|
+
bar.style.cursor = 'default';
|
|
2962
|
+
bar.appendChild(mediaEl);
|
|
2963
|
+
const ctaButton = document.createElement('button');
|
|
2964
|
+
ctaButton.type = 'button';
|
|
2965
|
+
ctaButton.textContent = 'Learn more';
|
|
2966
|
+
ctaButton.style.cssText = `
|
|
2967
|
+
display:block;
|
|
2968
|
+
margin: 8px auto 0;
|
|
2969
|
+
border: none;
|
|
2970
|
+
border-radius: 6px;
|
|
2971
|
+
background: #111;
|
|
2972
|
+
color: #fff;
|
|
2973
|
+
font-size: 12px;
|
|
2974
|
+
font-weight: 600;
|
|
2975
|
+
padding: 8px 16px;
|
|
2976
|
+
cursor: pointer;
|
|
2977
|
+
`;
|
|
2978
|
+
ctaButton.addEventListener('click', (e) => {
|
|
2979
|
+
e.stopPropagation();
|
|
2980
|
+
handleClick();
|
|
2981
|
+
});
|
|
2982
|
+
bar.appendChild(ctaButton);
|
|
2983
|
+
}
|
|
2984
|
+
else {
|
|
2985
|
+
bar.appendChild(mediaEl);
|
|
2986
|
+
bar.addEventListener('click', handleClick);
|
|
2987
|
+
}
|
|
1550
2988
|
wrapper.appendChild(bar);
|
|
1551
2989
|
document.body.appendChild(wrapper);
|
|
1552
2990
|
this.barElement = wrapper;
|
|
2991
|
+
// Phase 7: dismiss on Esc. The bar is non-modal so we don't trap focus,
|
|
2992
|
+
// but the keyboard still needs an explicit escape hatch.
|
|
2993
|
+
if (typeof document !== 'undefined') {
|
|
2994
|
+
if (this.escHandler)
|
|
2995
|
+
document.removeEventListener('keydown', this.escHandler);
|
|
2996
|
+
this.escHandler = (ev) => {
|
|
2997
|
+
if (ev.key === 'Escape' || ev.key === 'Esc')
|
|
2998
|
+
this.hide();
|
|
2999
|
+
};
|
|
3000
|
+
document.addEventListener('keydown', this.escHandler);
|
|
3001
|
+
}
|
|
1553
3002
|
}
|
|
1554
3003
|
hide() {
|
|
3004
|
+
if (this.escHandler) {
|
|
3005
|
+
document.removeEventListener('keydown', this.escHandler);
|
|
3006
|
+
this.escHandler = null;
|
|
3007
|
+
}
|
|
1555
3008
|
if (this.barElement && this.barElement.isConnected) {
|
|
1556
3009
|
this.barElement.remove();
|
|
1557
3010
|
}
|
|
@@ -1568,6 +3021,7 @@ export class Sidebar {
|
|
|
1568
3021
|
this.hasTrackedImpression = false;
|
|
1569
3022
|
this.isRendering = false;
|
|
1570
3023
|
this.refreshTimer = null;
|
|
3024
|
+
this.lazyLoadObserver = null;
|
|
1571
3025
|
this.lastAdId = null;
|
|
1572
3026
|
this.retryCount = 0;
|
|
1573
3027
|
this.maxRetries = 3;
|
|
@@ -1591,6 +3045,11 @@ export class Sidebar {
|
|
|
1591
3045
|
this.isRendering = false;
|
|
1592
3046
|
return;
|
|
1593
3047
|
}
|
|
3048
|
+
// Phase 3: CLS reservation. See Banner.render() for the rationale.
|
|
3049
|
+
// Sidebars are usually fixed-width but variable-height, so reserving
|
|
3050
|
+
// the IAB aspect ratio still prevents the column re-flow that today
|
|
3051
|
+
// happens when the ad image loads.
|
|
3052
|
+
reserveAdSlot(container, this.slotConfig.size);
|
|
1594
3053
|
// Lazy loading: wait for container to be in viewport
|
|
1595
3054
|
if (this.sovads.getConfig().lazyLoad && !forceRefresh) {
|
|
1596
3055
|
const isInViewport = await this.checkViewport(container);
|
|
@@ -1605,6 +3064,7 @@ export class Sidebar {
|
|
|
1605
3064
|
consumerId,
|
|
1606
3065
|
placement: this.slotConfig.placementId || 'sidebar',
|
|
1607
3066
|
size: this.slotConfig.size,
|
|
3067
|
+
attached: this.slotConfig.attached === true,
|
|
1608
3068
|
});
|
|
1609
3069
|
this.hasTrackedImpression = false;
|
|
1610
3070
|
// Skip if same ad (rotation disabled or same ad returned)
|
|
@@ -1660,11 +3120,13 @@ export class Sidebar {
|
|
|
1660
3120
|
});
|
|
1661
3121
|
dummyElement.addEventListener('mouseenter', () => {
|
|
1662
3122
|
dummyElement.style.background = '#f0f0f0';
|
|
1663
|
-
|
|
3123
|
+
if (!prefersReducedMotion())
|
|
3124
|
+
dummyElement.style.transform = 'translateY(-2px)';
|
|
1664
3125
|
});
|
|
1665
3126
|
dummyElement.addEventListener('mouseleave', () => {
|
|
1666
3127
|
dummyElement.style.background = '#f9f9f9';
|
|
1667
|
-
|
|
3128
|
+
if (!prefersReducedMotion())
|
|
3129
|
+
dummyElement.style.transform = 'translateY(0)';
|
|
1668
3130
|
});
|
|
1669
3131
|
container.appendChild(dummyElement);
|
|
1670
3132
|
this.isRendering = false;
|
|
@@ -1674,14 +3136,21 @@ export class Sidebar {
|
|
|
1674
3136
|
container.innerHTML = '';
|
|
1675
3137
|
adElement.className = 'sovads-sidebar';
|
|
1676
3138
|
adElement.setAttribute('data-ad-id', this.currentAd.id);
|
|
3139
|
+
// Phase 7: a11y — see Banner.render() for rationale.
|
|
3140
|
+
adElement.setAttribute('role', 'region');
|
|
3141
|
+
adElement.setAttribute('aria-label', 'Advertisement');
|
|
1677
3142
|
const mediaType = this.currentAd.mediaType === 'video' ? 'video' : 'image';
|
|
3143
|
+
// Phase 2: same click-target / disclosure pattern as Banner.
|
|
3144
|
+
const slotClickTarget = this.slotConfig.clickTarget ?? 'media';
|
|
3145
|
+
const useButtonCta = slotClickTarget === 'button' || mediaType === 'video';
|
|
1678
3146
|
adElement.style.cssText = `
|
|
3147
|
+
position: relative;
|
|
1679
3148
|
background: #f8f9fa;
|
|
1680
3149
|
border: 1px solid #e9ecef;
|
|
1681
3150
|
border-radius: 8px;
|
|
1682
3151
|
padding: 15px;
|
|
1683
3152
|
margin-bottom: 15px;
|
|
1684
|
-
cursor: ${
|
|
3153
|
+
cursor: ${useButtonCta ? 'default' : 'pointer'};
|
|
1685
3154
|
transition: all 0.2s ease;
|
|
1686
3155
|
opacity: 0;
|
|
1687
3156
|
`;
|
|
@@ -1751,17 +3220,20 @@ export class Sidebar {
|
|
|
1751
3220
|
});
|
|
1752
3221
|
window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
|
|
1753
3222
|
};
|
|
1754
|
-
// Add hover effect
|
|
3223
|
+
// Add hover effect. Phase 7: bg change always fires; translateY only
|
|
3224
|
+
// when motion is allowed.
|
|
1755
3225
|
adElement.addEventListener('mouseenter', () => {
|
|
1756
3226
|
adElement.style.background = '#e9ecef';
|
|
1757
|
-
|
|
3227
|
+
if (!prefersReducedMotion())
|
|
3228
|
+
adElement.style.transform = 'translateY(-2px)';
|
|
1758
3229
|
});
|
|
1759
3230
|
adElement.addEventListener('mouseleave', () => {
|
|
1760
3231
|
adElement.style.background = '#f8f9fa';
|
|
1761
|
-
|
|
3232
|
+
if (!prefersReducedMotion())
|
|
3233
|
+
adElement.style.transform = 'translateY(0)';
|
|
1762
3234
|
});
|
|
1763
|
-
mediaElement.style.cursor =
|
|
1764
|
-
if (
|
|
3235
|
+
mediaElement.style.cursor = useButtonCta ? 'default' : 'pointer';
|
|
3236
|
+
if (useButtonCta) {
|
|
1765
3237
|
const ctaButton = document.createElement('button');
|
|
1766
3238
|
ctaButton.type = 'button';
|
|
1767
3239
|
ctaButton.textContent = 'Learn more';
|
|
@@ -1785,7 +3257,33 @@ export class Sidebar {
|
|
|
1785
3257
|
adElement.addEventListener('click', handleClickThrough);
|
|
1786
3258
|
adElement.appendChild(mediaElement);
|
|
1787
3259
|
}
|
|
3260
|
+
// Phase 2: mount disclosure badge after media so it stacks on top.
|
|
3261
|
+
const disclosure = buildPositionedDisclosure({
|
|
3262
|
+
slotOverride: this.slotConfig.disclosureLabel,
|
|
3263
|
+
configValue: this.sovads.getConfig().disclosureLabel,
|
|
3264
|
+
advertiser: this.sovads.getConfig().advertiserName,
|
|
3265
|
+
variant: 'light',
|
|
3266
|
+
position: 'top-left',
|
|
3267
|
+
});
|
|
3268
|
+
if (disclosure)
|
|
3269
|
+
adElement.appendChild(disclosure);
|
|
1788
3270
|
container.appendChild(adElement);
|
|
3271
|
+
// Phase 1: mount attached CTAs under the sidebar ad when the slot opted
|
|
3272
|
+
// in and the server returned at least one task. Same semantics as Banner.
|
|
3273
|
+
if (this.slotConfig.attached === true &&
|
|
3274
|
+
Array.isArray(this.currentAd.attachedTasks) &&
|
|
3275
|
+
this.currentAd.attachedTasks.length > 0) {
|
|
3276
|
+
mountCtaPanel({
|
|
3277
|
+
container,
|
|
3278
|
+
sovads: this.sovads,
|
|
3279
|
+
surface: 'SIDEBAR',
|
|
3280
|
+
tasks: this.currentAd.attachedTasks,
|
|
3281
|
+
campaignId: this.currentAd.campaignId,
|
|
3282
|
+
bannerClickActive: this.currentAd.bannerClickActive !== false,
|
|
3283
|
+
onComplete: this.slotConfig.onCtaComplete,
|
|
3284
|
+
layout: 'stack',
|
|
3285
|
+
});
|
|
3286
|
+
}
|
|
1789
3287
|
// Set up auto-refresh if enabled
|
|
1790
3288
|
this.setupAutoRefresh(consumerId);
|
|
1791
3289
|
}
|
|
@@ -1840,14 +3338,20 @@ export class Sidebar {
|
|
|
1840
3338
|
this.render(consumerId);
|
|
1841
3339
|
return;
|
|
1842
3340
|
}
|
|
3341
|
+
if (this.lazyLoadObserver) {
|
|
3342
|
+
this.lazyLoadObserver.disconnect();
|
|
3343
|
+
this.lazyLoadObserver = null;
|
|
3344
|
+
}
|
|
1843
3345
|
const observer = new IntersectionObserver((entries) => {
|
|
1844
3346
|
entries.forEach((entry) => {
|
|
1845
3347
|
if (entry.isIntersecting && !this.isRendering) {
|
|
1846
3348
|
observer.disconnect();
|
|
3349
|
+
this.lazyLoadObserver = null;
|
|
1847
3350
|
this.render(consumerId);
|
|
1848
3351
|
}
|
|
1849
3352
|
});
|
|
1850
3353
|
}, { rootMargin: '50px' });
|
|
3354
|
+
this.lazyLoadObserver = observer;
|
|
1851
3355
|
observer.observe(container);
|
|
1852
3356
|
}
|
|
1853
3357
|
setupAutoRefresh(consumerId) {
|
|
@@ -1868,33 +3372,147 @@ export class Sidebar {
|
|
|
1868
3372
|
clearInterval(this.refreshTimer);
|
|
1869
3373
|
this.refreshTimer = null;
|
|
1870
3374
|
}
|
|
3375
|
+
if (this.lazyLoadObserver) {
|
|
3376
|
+
this.lazyLoadObserver.disconnect();
|
|
3377
|
+
this.lazyLoadObserver = null;
|
|
3378
|
+
}
|
|
1871
3379
|
}
|
|
1872
3380
|
}
|
|
1873
|
-
// Default export for easy importing
|
|
1874
|
-
// Overlay Component
|
|
1875
3381
|
export class Overlay {
|
|
1876
3382
|
constructor(sovads) {
|
|
1877
3383
|
this.currentAd = null;
|
|
1878
3384
|
this.overlayElement = null;
|
|
3385
|
+
this.isShowing = false;
|
|
3386
|
+
this.currentOpts = {};
|
|
3387
|
+
this.escHandler = null;
|
|
3388
|
+
this.previousBodyOverflow = '';
|
|
3389
|
+
// Subclasses (Interstitial) override these to get independent caps.
|
|
3390
|
+
this.storageKeyLastShown = 'sovads_overlay_last_shown';
|
|
3391
|
+
this.storageKeySessionCount = 'sovads_overlay_session_count';
|
|
3392
|
+
this.placement = 'overlay';
|
|
1879
3393
|
this.sovads = sovads;
|
|
1880
3394
|
}
|
|
1881
|
-
|
|
3395
|
+
canShowByFrequencyCap() {
|
|
3396
|
+
try {
|
|
3397
|
+
// Reuse Popup's tuning knobs \u2014 publishers shouldn't have to think about
|
|
3398
|
+
// a second set of dials for the overlay surface.
|
|
3399
|
+
const minIntervalMs = (this.sovads.getConfig().popupMinIntervalMinutes || 30) * 60 * 1000;
|
|
3400
|
+
const sessionMax = this.sovads.getConfig().popupSessionMax || 1;
|
|
3401
|
+
const now = Date.now();
|
|
3402
|
+
const lastShown = Number(localStorage.getItem(this.storageKeyLastShown) || 0);
|
|
3403
|
+
const sessionCount = Number(sessionStorage.getItem(this.storageKeySessionCount) || 0);
|
|
3404
|
+
if (sessionCount >= sessionMax)
|
|
3405
|
+
return false;
|
|
3406
|
+
if (lastShown > 0 && now - lastShown < minIntervalMs)
|
|
3407
|
+
return false;
|
|
3408
|
+
return true;
|
|
3409
|
+
}
|
|
3410
|
+
catch {
|
|
3411
|
+
return true;
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
markShown() {
|
|
3415
|
+
try {
|
|
3416
|
+
const now = Date.now();
|
|
3417
|
+
const currentSessionCount = Number(sessionStorage.getItem(this.storageKeySessionCount) || 0);
|
|
3418
|
+
localStorage.setItem(this.storageKeyLastShown, String(now));
|
|
3419
|
+
sessionStorage.setItem(this.storageKeySessionCount, String(currentSessionCount + 1));
|
|
3420
|
+
}
|
|
3421
|
+
catch {
|
|
3422
|
+
// Ignore storage failures (private mode, quota, etc).
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3425
|
+
/**
|
|
3426
|
+
* Show the overlay. Two call shapes (both supported \u2014 backwards compatible):
|
|
3427
|
+
*
|
|
3428
|
+
* overlay.show() // defaults
|
|
3429
|
+
* overlay.show('consumer-id') // legacy positional
|
|
3430
|
+
* overlay.show({ consumerId, attached, onCtaComplete, ... })
|
|
3431
|
+
*/
|
|
3432
|
+
async show(consumerIdOrOpts) {
|
|
3433
|
+
let opts;
|
|
3434
|
+
if (typeof consumerIdOrOpts === 'string' || consumerIdOrOpts === undefined) {
|
|
3435
|
+
opts = { consumerId: consumerIdOrOpts };
|
|
3436
|
+
}
|
|
3437
|
+
else {
|
|
3438
|
+
opts = { ...consumerIdOrOpts };
|
|
3439
|
+
}
|
|
3440
|
+
this.currentOpts = opts;
|
|
3441
|
+
if (this.isShowing) {
|
|
3442
|
+
if (this.sovads.getConfig().debug) {
|
|
3443
|
+
console.warn('Overlay show already in progress');
|
|
3444
|
+
}
|
|
3445
|
+
return;
|
|
3446
|
+
}
|
|
3447
|
+
if (!this.canShowByFrequencyCap()) {
|
|
3448
|
+
if (this.sovads.getConfig().debug) {
|
|
3449
|
+
console.log('Overlay skipped due to frequency cap');
|
|
3450
|
+
}
|
|
3451
|
+
return;
|
|
3452
|
+
}
|
|
3453
|
+
this.isShowing = true;
|
|
3454
|
+
try {
|
|
3455
|
+
this.currentAd = await this.sovads.loadAd({
|
|
3456
|
+
consumerId: opts.consumerId,
|
|
3457
|
+
placement: this.placement,
|
|
3458
|
+
attached: opts.attached === true,
|
|
3459
|
+
});
|
|
3460
|
+
if (!this.currentAd) {
|
|
3461
|
+
this.isShowing = false;
|
|
3462
|
+
return;
|
|
3463
|
+
}
|
|
3464
|
+
this.renderOverlay();
|
|
3465
|
+
this.markShown();
|
|
3466
|
+
}
|
|
3467
|
+
catch (err) {
|
|
3468
|
+
if (this.sovads.getConfig().debug) {
|
|
3469
|
+
console.error('Overlay show failed:', err);
|
|
3470
|
+
}
|
|
3471
|
+
this.isShowing = false;
|
|
3472
|
+
}
|
|
3473
|
+
}
|
|
3474
|
+
renderOverlay() {
|
|
1882
3475
|
if (!this.currentAd)
|
|
1883
3476
|
return;
|
|
3477
|
+
const renderStart = Date.now();
|
|
3478
|
+
let impressionTracked = false;
|
|
3479
|
+
const trackImp = (rendered) => {
|
|
3480
|
+
if (impressionTracked || !this.currentAd || this.currentAd.isDummy)
|
|
3481
|
+
return;
|
|
3482
|
+
impressionTracked = true;
|
|
3483
|
+
this.sovads._trackEvent('IMPRESSION', this.currentAd.id, this.currentAd.campaignId, {
|
|
3484
|
+
rendered,
|
|
3485
|
+
viewportVisible: true,
|
|
3486
|
+
renderTime: Date.now() - renderStart,
|
|
3487
|
+
});
|
|
3488
|
+
this.sovads.logInteraction('IMPRESSION', {
|
|
3489
|
+
adId: this.currentAd.id,
|
|
3490
|
+
campaignId: this.currentAd.campaignId,
|
|
3491
|
+
elementType: 'OVERLAY',
|
|
3492
|
+
metadata: { renderTime: Date.now() - renderStart },
|
|
3493
|
+
});
|
|
3494
|
+
};
|
|
1884
3495
|
const wrapper = document.createElement('div');
|
|
3496
|
+
wrapper.className = 'sovads-overlay';
|
|
3497
|
+
wrapper.setAttribute('role', 'dialog');
|
|
3498
|
+
wrapper.setAttribute('aria-modal', 'true');
|
|
3499
|
+
wrapper.setAttribute('aria-label', 'Advertisement');
|
|
1885
3500
|
wrapper.style.cssText = `
|
|
1886
|
-
position: fixed;
|
|
3501
|
+
position: fixed; inset: 0;
|
|
1887
3502
|
background: rgba(0,0,0,0.5); z-index: 10001;
|
|
1888
3503
|
display: flex; align-items: center; justify-content: center;
|
|
1889
3504
|
opacity: 0; transition: opacity 0.3s ease;
|
|
1890
3505
|
`;
|
|
1891
|
-
const
|
|
1892
|
-
|
|
1893
|
-
position: relative; max-width: 90%; max-height:
|
|
3506
|
+
const card = document.createElement('div');
|
|
3507
|
+
card.style.cssText = `
|
|
3508
|
+
position: relative; max-width: 90%; max-height: 90vh;
|
|
1894
3509
|
background: white; border-radius: 12px; overflow: hidden;
|
|
1895
3510
|
box-shadow: 0 20px 40px rgba(0,0,0,0.4);
|
|
3511
|
+
display: flex; flex-direction: column;
|
|
1896
3512
|
`;
|
|
1897
3513
|
const closeBtn = document.createElement('button');
|
|
3514
|
+
closeBtn.type = 'button';
|
|
3515
|
+
closeBtn.setAttribute('aria-label', 'Close advertisement');
|
|
1898
3516
|
closeBtn.innerHTML = '×';
|
|
1899
3517
|
closeBtn.style.cssText = `
|
|
1900
3518
|
position: absolute; top: 10px; right: 10px;
|
|
@@ -1902,74 +3520,442 @@ export class Overlay {
|
|
|
1902
3520
|
width: 30px; height: 30px; border-radius: 15px;
|
|
1903
3521
|
cursor: pointer; z-index: 11; font-size: 20px;
|
|
1904
3522
|
`;
|
|
1905
|
-
closeBtn.
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
3523
|
+
closeBtn.addEventListener('click', (e) => {
|
|
3524
|
+
e.stopPropagation();
|
|
3525
|
+
this.hide();
|
|
3526
|
+
});
|
|
3527
|
+
// Phase 2: resolve clickTarget + disclosure.
|
|
3528
|
+
const mediaType = this.currentAd.mediaType === 'video' ? 'video' : 'image';
|
|
3529
|
+
const clickTarget = this.currentOpts.clickTarget ?? 'media';
|
|
3530
|
+
const useButtonCta = clickTarget === 'button' || mediaType === 'video';
|
|
3531
|
+
let mediaEl;
|
|
3532
|
+
if (mediaType === 'video') {
|
|
3533
|
+
const video = document.createElement('video');
|
|
3534
|
+
video.src = this.currentAd.bannerUrl;
|
|
3535
|
+
video.muted = true;
|
|
3536
|
+
video.autoplay = true;
|
|
3537
|
+
video.loop = true;
|
|
3538
|
+
video.playsInline = true;
|
|
3539
|
+
video.controls = true;
|
|
3540
|
+
video.style.cssText = 'display:block; max-width:100%; height:auto;';
|
|
3541
|
+
video.addEventListener('loadeddata', () => {
|
|
3542
|
+
wrapper.style.opacity = '1';
|
|
3543
|
+
trackImp(true);
|
|
3544
|
+
}, { once: true });
|
|
3545
|
+
video.addEventListener('error', () => trackImp(false), { once: true });
|
|
3546
|
+
mediaEl = video;
|
|
3547
|
+
}
|
|
3548
|
+
else {
|
|
3549
|
+
const img = document.createElement('img');
|
|
3550
|
+
img.src = this.currentAd.bannerUrl;
|
|
3551
|
+
img.alt = this.currentAd.description;
|
|
3552
|
+
img.style.cssText = 'display:block; max-width:100%; height:auto;';
|
|
3553
|
+
img.addEventListener('load', () => {
|
|
3554
|
+
wrapper.style.opacity = '1';
|
|
3555
|
+
trackImp(true);
|
|
3556
|
+
}, { once: true });
|
|
3557
|
+
img.addEventListener('error', () => trackImp(false), { once: true });
|
|
3558
|
+
mediaEl = img;
|
|
3559
|
+
}
|
|
3560
|
+
const handleClick = () => {
|
|
3561
|
+
if (!this.currentAd)
|
|
3562
|
+
return;
|
|
3563
|
+
this.sovads._trackEvent('CLICK', this.currentAd.id, this.currentAd.campaignId, {
|
|
3564
|
+
rendered: true,
|
|
3565
|
+
viewportVisible: true,
|
|
3566
|
+
renderTime: Date.now() - renderStart,
|
|
3567
|
+
});
|
|
3568
|
+
this.sovads.logInteraction('CLICK', {
|
|
3569
|
+
adId: this.currentAd.id,
|
|
3570
|
+
campaignId: this.currentAd.campaignId,
|
|
3571
|
+
elementType: 'OVERLAY',
|
|
3572
|
+
metadata: { renderTime: Date.now() - renderStart },
|
|
3573
|
+
});
|
|
3574
|
+
window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
|
|
3575
|
+
this.hide();
|
|
1917
3576
|
};
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
3577
|
+
if (useButtonCta) {
|
|
3578
|
+
mediaEl.style.cursor = 'default';
|
|
3579
|
+
}
|
|
3580
|
+
else {
|
|
3581
|
+
mediaEl.style.cursor = 'pointer';
|
|
3582
|
+
mediaEl.addEventListener('click', handleClick);
|
|
3583
|
+
}
|
|
3584
|
+
card.appendChild(closeBtn);
|
|
3585
|
+
card.appendChild(mediaEl);
|
|
3586
|
+
if (useButtonCta) {
|
|
3587
|
+
const ctaButton = document.createElement('button');
|
|
3588
|
+
ctaButton.type = 'button';
|
|
3589
|
+
ctaButton.textContent = 'Learn more';
|
|
3590
|
+
ctaButton.style.cssText = `
|
|
3591
|
+
display:block;
|
|
3592
|
+
width: calc(100% - 24px);
|
|
3593
|
+
margin: 12px;
|
|
3594
|
+
border: none;
|
|
3595
|
+
border-radius: 6px;
|
|
3596
|
+
background: #111;
|
|
3597
|
+
color: #fff;
|
|
3598
|
+
font-size: 13px;
|
|
3599
|
+
font-weight: 600;
|
|
3600
|
+
padding: 10px 16px;
|
|
3601
|
+
cursor: pointer;
|
|
3602
|
+
`;
|
|
3603
|
+
ctaButton.addEventListener('click', (e) => {
|
|
3604
|
+
e.stopPropagation();
|
|
3605
|
+
handleClick();
|
|
3606
|
+
});
|
|
3607
|
+
card.appendChild(ctaButton);
|
|
3608
|
+
}
|
|
3609
|
+
// Phase 1: attached CTA panel.
|
|
3610
|
+
if (this.currentOpts.attached === true &&
|
|
3611
|
+
Array.isArray(this.currentAd.attachedTasks) &&
|
|
3612
|
+
this.currentAd.attachedTasks.length > 0) {
|
|
3613
|
+
const ctaSlot = document.createElement('div');
|
|
3614
|
+
ctaSlot.style.cssText = 'padding: 0 12px 12px;';
|
|
3615
|
+
ctaSlot.addEventListener('click', (e) => e.stopPropagation());
|
|
3616
|
+
card.appendChild(ctaSlot);
|
|
3617
|
+
mountCtaPanel({
|
|
3618
|
+
container: ctaSlot,
|
|
3619
|
+
sovads: this.sovads,
|
|
3620
|
+
surface: 'OVERLAY',
|
|
3621
|
+
tasks: this.currentAd.attachedTasks,
|
|
3622
|
+
campaignId: this.currentAd.campaignId,
|
|
3623
|
+
bannerClickActive: this.currentAd.bannerClickActive !== false,
|
|
3624
|
+
onComplete: this.currentOpts.onCtaComplete,
|
|
3625
|
+
layout: 'stack',
|
|
3626
|
+
});
|
|
3627
|
+
}
|
|
3628
|
+
// Phase 2: disclosure badge anchored over the card.
|
|
3629
|
+
const disclosure = buildPositionedDisclosure({
|
|
3630
|
+
slotOverride: this.currentOpts.disclosureLabel,
|
|
3631
|
+
configValue: this.sovads.getConfig().disclosureLabel,
|
|
3632
|
+
advertiser: this.sovads.getConfig().advertiserName,
|
|
3633
|
+
variant: 'light',
|
|
3634
|
+
position: 'top-left',
|
|
3635
|
+
});
|
|
3636
|
+
if (disclosure)
|
|
3637
|
+
card.appendChild(disclosure);
|
|
3638
|
+
wrapper.appendChild(card);
|
|
3639
|
+
// Backdrop click dismiss \u2014 only when the click was on the wrapper itself
|
|
3640
|
+
// (not bubbled from the card). Default on; opt-out via dismissOnBackdrop:false.
|
|
3641
|
+
const dismissOnBackdrop = this.currentOpts.dismissOnBackdrop !== false;
|
|
3642
|
+
if (dismissOnBackdrop) {
|
|
3643
|
+
wrapper.addEventListener('click', (e) => {
|
|
3644
|
+
if (e.target === wrapper)
|
|
3645
|
+
this.hide();
|
|
3646
|
+
});
|
|
3647
|
+
}
|
|
3648
|
+
// ESC dismiss \u2014 default on. We keep the handler reference so hide() can
|
|
3649
|
+
// detach it cleanly (no listener leaks across multiple show/hide cycles).
|
|
3650
|
+
const dismissOnEscape = this.currentOpts.dismissOnEscape !== false;
|
|
3651
|
+
if (dismissOnEscape) {
|
|
3652
|
+
this.escHandler = (e) => {
|
|
3653
|
+
if (e.key === 'Escape')
|
|
3654
|
+
this.hide();
|
|
3655
|
+
};
|
|
3656
|
+
window.addEventListener('keydown', this.escHandler);
|
|
3657
|
+
}
|
|
3658
|
+
// Scroll lock \u2014 stash the previous overflow so hide() can restore it
|
|
3659
|
+
// even when another script has changed body.style.overflow in the meantime.
|
|
3660
|
+
this.previousBodyOverflow = document.body.style.overflow;
|
|
3661
|
+
document.body.style.overflow = 'hidden';
|
|
1921
3662
|
document.body.appendChild(wrapper);
|
|
1922
3663
|
this.overlayElement = wrapper;
|
|
1923
3664
|
}
|
|
3665
|
+
hide() {
|
|
3666
|
+
if (this.overlayElement && this.overlayElement.isConnected) {
|
|
3667
|
+
try {
|
|
3668
|
+
this.overlayElement.remove();
|
|
3669
|
+
}
|
|
3670
|
+
catch { /* swallow */ }
|
|
3671
|
+
}
|
|
3672
|
+
this.overlayElement = null;
|
|
3673
|
+
if (this.escHandler) {
|
|
3674
|
+
window.removeEventListener('keydown', this.escHandler);
|
|
3675
|
+
this.escHandler = null;
|
|
3676
|
+
}
|
|
3677
|
+
// Restore scroll. Only touch body.style.overflow when we actually set it,
|
|
3678
|
+
// so we don't clobber a publisher's own scroll-lock for an unrelated modal.
|
|
3679
|
+
document.body.style.overflow = this.previousBodyOverflow;
|
|
3680
|
+
this.isShowing = false;
|
|
3681
|
+
}
|
|
1924
3682
|
}
|
|
1925
|
-
// Interstitial Component (Full page ad before content)
|
|
3683
|
+
// Interstitial Component (Full page ad before content). Same machinery as
|
|
3684
|
+
// Overlay but with its own frequency-cap storage so the two surfaces don't
|
|
3685
|
+
// share a counter.
|
|
1926
3686
|
export class Interstitial extends Overlay {
|
|
3687
|
+
constructor() {
|
|
3688
|
+
super(...arguments);
|
|
3689
|
+
this.storageKeyLastShown = 'sovads_interstitial_last_shown';
|
|
3690
|
+
this.storageKeySessionCount = 'sovads_interstitial_session_count';
|
|
3691
|
+
this.placement = 'interstitial';
|
|
3692
|
+
}
|
|
1927
3693
|
}
|
|
1928
|
-
// NativeCard Component
|
|
1929
3694
|
export class NativeCard {
|
|
1930
3695
|
constructor(sovads, containerId) {
|
|
1931
3696
|
this.currentAd = null;
|
|
1932
3697
|
this.sovads = sovads;
|
|
1933
3698
|
this.containerId = containerId;
|
|
1934
3699
|
}
|
|
1935
|
-
|
|
3700
|
+
/**
|
|
3701
|
+
* Render the native card. Two call shapes (backwards compatible):
|
|
3702
|
+
*
|
|
3703
|
+
* nativeCard.render()
|
|
3704
|
+
* nativeCard.render('consumer-id') // legacy positional
|
|
3705
|
+
* nativeCard.render({ consumerId, attached, onCtaComplete }) // recommended
|
|
3706
|
+
*/
|
|
3707
|
+
async render(consumerIdOrOpts) {
|
|
3708
|
+
let opts;
|
|
3709
|
+
if (typeof consumerIdOrOpts === 'string' || consumerIdOrOpts === undefined) {
|
|
3710
|
+
opts = { consumerId: consumerIdOrOpts };
|
|
3711
|
+
}
|
|
3712
|
+
else {
|
|
3713
|
+
opts = { ...consumerIdOrOpts };
|
|
3714
|
+
}
|
|
1936
3715
|
const container = document.getElementById(this.containerId);
|
|
1937
3716
|
if (!container)
|
|
1938
3717
|
return;
|
|
1939
3718
|
container.style.display = 'none';
|
|
1940
3719
|
this.currentAd = await this.sovads.loadAd({
|
|
1941
|
-
consumerId,
|
|
3720
|
+
consumerId: opts.consumerId,
|
|
1942
3721
|
placement: 'native',
|
|
3722
|
+
attached: opts.attached === true,
|
|
1943
3723
|
});
|
|
1944
3724
|
if (!this.currentAd)
|
|
1945
3725
|
return;
|
|
3726
|
+
// Phase 2: resolve disclosure + click-target once for this render pass.
|
|
3727
|
+
// NativeCard renders the disclosure as an inline text line (not the
|
|
3728
|
+
// floating badge), so we don't pass `advertiserName` here — the badge
|
|
3729
|
+
// helper handles that. The headline already names the campaign anyway.
|
|
3730
|
+
const nativeDisclosureLabel = resolveDisclosureLabel(opts.disclosureLabel, this.sovads.getConfig().disclosureLabel);
|
|
3731
|
+
const nativeClickTarget = opts.clickTarget ?? 'media';
|
|
3732
|
+
const useButtonCta = nativeClickTarget === 'button';
|
|
1946
3733
|
const card = document.createElement('div');
|
|
1947
3734
|
card.style.cssText = `
|
|
1948
3735
|
display: flex; gap: 16px; padding: 16px;
|
|
1949
3736
|
background: white; border: 1px solid #eee; border-radius: 12px;
|
|
1950
|
-
cursor: pointer; position: relative;
|
|
3737
|
+
cursor: ${useButtonCta ? 'default' : 'pointer'}; position: relative;
|
|
1951
3738
|
`;
|
|
3739
|
+
// Phase 7: a11y landmark.
|
|
3740
|
+
card.setAttribute('role', 'region');
|
|
3741
|
+
card.setAttribute('aria-label', 'Advertisement');
|
|
1952
3742
|
const img = document.createElement('img');
|
|
1953
3743
|
img.src = this.currentAd.bannerUrl;
|
|
1954
3744
|
img.style.cssText = 'width: 80px; height: 80px; object-fit: cover; border-radius: 8px;';
|
|
1955
3745
|
const content = document.createElement('div');
|
|
1956
|
-
content.
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
3746
|
+
content.style.cssText = 'flex:1 1 auto;min-width:0;';
|
|
3747
|
+
// Phase 1 nit: only append ellipsis when actually truncated.
|
|
3748
|
+
const rawDesc = this.currentAd.description || '';
|
|
3749
|
+
const headline = rawDesc.length > 40 ? `${rawDesc.slice(0, 40)}\u2026` : rawDesc;
|
|
3750
|
+
const headlineEl = document.createElement('div');
|
|
3751
|
+
headlineEl.style.cssText = 'font-weight: bold; font-size: 16px; margin-bottom: 4px;';
|
|
3752
|
+
headlineEl.textContent = headline;
|
|
3753
|
+
content.appendChild(headlineEl);
|
|
3754
|
+
// Disclosure: configurable text, or omitted entirely when disclosureLabel:false.
|
|
3755
|
+
if (nativeDisclosureLabel) {
|
|
3756
|
+
const discEl = document.createElement('div');
|
|
3757
|
+
discEl.style.cssText = 'font-size: 12px; color: #666;';
|
|
3758
|
+
discEl.textContent = nativeDisclosureLabel;
|
|
3759
|
+
content.appendChild(discEl);
|
|
3760
|
+
}
|
|
3761
|
+
// Phase 6: gate the IMPRESSION on actual viewport visibility, not just
|
|
3762
|
+
// image decode. Before this fix, NativeCards placed below-the-fold fired
|
|
3763
|
+
// a paid IMPRESSION the moment the browser decoded the image \u2014 even
|
|
3764
|
+
// when the viewer never scrolled to see them. We now wait until the
|
|
3765
|
+
// observer reports the card is on screen, and we only fire once.
|
|
3766
|
+
let nativeImpressionFired = false;
|
|
1960
3767
|
img.onload = () => {
|
|
1961
3768
|
container.style.display = 'block';
|
|
1962
|
-
this.sovads.
|
|
3769
|
+
this.sovads.setupRenderObserver(card, this.currentAd.id, (isVisible) => {
|
|
3770
|
+
if (!isVisible || nativeImpressionFired)
|
|
3771
|
+
return;
|
|
3772
|
+
nativeImpressionFired = true;
|
|
3773
|
+
this.sovads._trackEvent('IMPRESSION', this.currentAd.id, this.currentAd.campaignId);
|
|
3774
|
+
this.sovads.logInteraction('IMPRESSION', {
|
|
3775
|
+
adId: this.currentAd.id,
|
|
3776
|
+
campaignId: this.currentAd.campaignId,
|
|
3777
|
+
elementType: 'NATIVE',
|
|
3778
|
+
});
|
|
3779
|
+
});
|
|
1963
3780
|
};
|
|
1964
|
-
|
|
3781
|
+
const handleCardClick = () => {
|
|
1965
3782
|
this.sovads._trackEvent('CLICK', this.currentAd.id, this.currentAd.campaignId);
|
|
1966
|
-
|
|
3783
|
+
this.sovads.logInteraction('CLICK', {
|
|
3784
|
+
adId: this.currentAd.id,
|
|
3785
|
+
campaignId: this.currentAd.campaignId,
|
|
3786
|
+
elementType: 'NATIVE',
|
|
3787
|
+
});
|
|
3788
|
+
window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank', 'noopener,noreferrer');
|
|
1967
3789
|
};
|
|
3790
|
+
if (useButtonCta) {
|
|
3791
|
+
// 'button' mode: explicit "Learn more" pill, card itself is silent.
|
|
3792
|
+
const ctaButton = document.createElement('button');
|
|
3793
|
+
ctaButton.type = 'button';
|
|
3794
|
+
ctaButton.textContent = 'Learn more \u2192';
|
|
3795
|
+
ctaButton.style.cssText = `
|
|
3796
|
+
margin-top: 6px;
|
|
3797
|
+
border: none;
|
|
3798
|
+
border-radius: 6px;
|
|
3799
|
+
background: #111;
|
|
3800
|
+
color: #fff;
|
|
3801
|
+
font-size: 12px;
|
|
3802
|
+
font-weight: 600;
|
|
3803
|
+
padding: 6px 12px;
|
|
3804
|
+
cursor: pointer;
|
|
3805
|
+
align-self: flex-start;
|
|
3806
|
+
`;
|
|
3807
|
+
ctaButton.addEventListener('click', (e) => {
|
|
3808
|
+
e.stopPropagation();
|
|
3809
|
+
handleCardClick();
|
|
3810
|
+
});
|
|
3811
|
+
content.appendChild(ctaButton);
|
|
3812
|
+
}
|
|
3813
|
+
else {
|
|
3814
|
+
card.onclick = handleCardClick;
|
|
3815
|
+
}
|
|
1968
3816
|
card.appendChild(img);
|
|
1969
3817
|
card.appendChild(content);
|
|
1970
3818
|
container.innerHTML = '';
|
|
1971
3819
|
container.appendChild(card);
|
|
3820
|
+
// Phase 1: mount attached CTAs underneath the card body.
|
|
3821
|
+
if (opts.attached === true &&
|
|
3822
|
+
Array.isArray(this.currentAd.attachedTasks) &&
|
|
3823
|
+
this.currentAd.attachedTasks.length > 0) {
|
|
3824
|
+
const ctaSlot = document.createElement('div');
|
|
3825
|
+
ctaSlot.style.cssText = 'margin-top:8px;';
|
|
3826
|
+
// CTA buttons must not bubble into card.onclick (which would open the ad).
|
|
3827
|
+
ctaSlot.addEventListener('click', (e) => e.stopPropagation());
|
|
3828
|
+
container.appendChild(ctaSlot);
|
|
3829
|
+
mountCtaPanel({
|
|
3830
|
+
container: ctaSlot,
|
|
3831
|
+
sovads: this.sovads,
|
|
3832
|
+
surface: 'NATIVE',
|
|
3833
|
+
tasks: this.currentAd.attachedTasks,
|
|
3834
|
+
campaignId: this.currentAd.campaignId,
|
|
3835
|
+
bannerClickActive: this.currentAd.bannerClickActive !== false,
|
|
3836
|
+
onComplete: opts.onCtaComplete,
|
|
3837
|
+
// 2 tasks → horizontal row (native cards are narrow but tall enough
|
|
3838
|
+
// for a single CTA row); 1 or 3+ stay stacked.
|
|
3839
|
+
layout: 'auto',
|
|
3840
|
+
});
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
3844
|
+
export class CtaUnit {
|
|
3845
|
+
constructor(sovads, containerId) {
|
|
3846
|
+
this.currentAd = null;
|
|
3847
|
+
this.isRendering = false;
|
|
3848
|
+
this.sovads = sovads;
|
|
3849
|
+
this.containerId = containerId;
|
|
3850
|
+
}
|
|
3851
|
+
async render(consumerIdOrOpts) {
|
|
3852
|
+
let opts;
|
|
3853
|
+
if (typeof consumerIdOrOpts === 'string' || consumerIdOrOpts === undefined) {
|
|
3854
|
+
opts = { consumerId: consumerIdOrOpts };
|
|
3855
|
+
}
|
|
3856
|
+
else {
|
|
3857
|
+
opts = { ...consumerIdOrOpts };
|
|
3858
|
+
}
|
|
3859
|
+
if (this.isRendering) {
|
|
3860
|
+
if (this.sovads.getConfig().debug) {
|
|
3861
|
+
console.warn(`CtaUnit render already in progress for ${this.containerId}`);
|
|
3862
|
+
}
|
|
3863
|
+
return;
|
|
3864
|
+
}
|
|
3865
|
+
const container = document.getElementById(this.containerId);
|
|
3866
|
+
if (!container) {
|
|
3867
|
+
console.error(`Container with id "${this.containerId}" not found`);
|
|
3868
|
+
return;
|
|
3869
|
+
}
|
|
3870
|
+
this.isRendering = true;
|
|
3871
|
+
container.style.display = 'none';
|
|
3872
|
+
try {
|
|
3873
|
+
this.currentAd = await this.sovads.loadAd({
|
|
3874
|
+
consumerId: opts.consumerId,
|
|
3875
|
+
placement: 'cta',
|
|
3876
|
+
attached: true,
|
|
3877
|
+
});
|
|
3878
|
+
if (!this.currentAd) {
|
|
3879
|
+
container.style.display = 'none';
|
|
3880
|
+
return;
|
|
3881
|
+
}
|
|
3882
|
+
// No-tasks \u2192 nothing to render. Hide the slot entirely so the publisher
|
|
3883
|
+
// page doesn't reserve empty space.
|
|
3884
|
+
const tasks = this.currentAd.attachedTasks;
|
|
3885
|
+
if (!Array.isArray(tasks) || tasks.length === 0) {
|
|
3886
|
+
container.style.display = 'none';
|
|
3887
|
+
return;
|
|
3888
|
+
}
|
|
3889
|
+
container.innerHTML = '';
|
|
3890
|
+
container.style.display = 'block';
|
|
3891
|
+
// Fire a single IMPRESSION on render so advertiser dashboards see the slot
|
|
3892
|
+
// even though no banner media was shown.
|
|
3893
|
+
this.sovads._trackEvent('IMPRESSION', this.currentAd.id, this.currentAd.campaignId, {
|
|
3894
|
+
rendered: true,
|
|
3895
|
+
viewportVisible: true,
|
|
3896
|
+
renderTime: 0,
|
|
3897
|
+
});
|
|
3898
|
+
mountCtaPanel({
|
|
3899
|
+
container,
|
|
3900
|
+
sovads: this.sovads,
|
|
3901
|
+
surface: 'NATIVE',
|
|
3902
|
+
tasks,
|
|
3903
|
+
campaignId: this.currentAd.campaignId,
|
|
3904
|
+
bannerClickActive: this.currentAd.bannerClickActive !== false,
|
|
3905
|
+
onComplete: opts.onCtaComplete,
|
|
3906
|
+
layout: opts.layout ?? 'stack',
|
|
3907
|
+
});
|
|
3908
|
+
}
|
|
3909
|
+
catch (e) {
|
|
3910
|
+
if (this.sovads.getConfig().debug) {
|
|
3911
|
+
console.error('CtaUnit render failed:', e);
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
finally {
|
|
3915
|
+
this.isRendering = false;
|
|
3916
|
+
}
|
|
1972
3917
|
}
|
|
1973
3918
|
}
|
|
3919
|
+
// ============================================================================
|
|
3920
|
+
// GoodDollar reward icon
|
|
3921
|
+
// ----------------------------------------------------------------------------
|
|
3922
|
+
// Inlined as a base64 data URI so the SDK is asset-self-contained: any
|
|
3923
|
+
// consumer (publisher page, advertiser preview, e2e test) gets the icon
|
|
3924
|
+
// without an extra HTTP request or hosting concern.
|
|
3925
|
+
// Source: sdk/assets/g-dollar.png (64x64 PNG, ~2.9 KB).
|
|
3926
|
+
// ============================================================================
|
|
3927
|
+
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==';
|
|
3928
|
+
/**
|
|
3929
|
+
* Build a compact reward badge ("Earn <icon> <amount>") that ad surfaces
|
|
3930
|
+
* overlay to surface the viewer-reward. Returns an HTMLElement; caller is
|
|
3931
|
+
* responsible for absolute positioning over the creative.
|
|
3932
|
+
*/
|
|
3933
|
+
export function buildRewardBadge(opts) {
|
|
3934
|
+
const amount = opts?.amount ?? '0.5';
|
|
3935
|
+
const variant = opts?.variant ?? 'dark';
|
|
3936
|
+
const bg = variant === 'dark' ? '#2D2D2D' : 'rgba(255,255,255,0.95)';
|
|
3937
|
+
const fg = variant === 'dark' ? '#FFFFFF' : '#2D2D2D';
|
|
3938
|
+
const span = document.createElement('span');
|
|
3939
|
+
span.className = 'sovads-reward-badge';
|
|
3940
|
+
span.setAttribute('role', 'note');
|
|
3941
|
+
span.setAttribute('aria-label', `Earn ${amount} GoodDollar`);
|
|
3942
|
+
span.style.cssText =
|
|
3943
|
+
`display:inline-flex;align-items:center;gap:4px;` +
|
|
3944
|
+
`padding:2px 6px 2px 4px;border-radius:999px;` +
|
|
3945
|
+
`font-size:11px;font-weight:800;line-height:1;letter-spacing:0.02em;` +
|
|
3946
|
+
`background:${bg};color:${fg};` +
|
|
3947
|
+
`font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;`;
|
|
3948
|
+
const icon = document.createElement('img');
|
|
3949
|
+
icon.src = GOOD_DOLLAR_ICON_DATA_URI;
|
|
3950
|
+
icon.alt = '';
|
|
3951
|
+
icon.width = 12;
|
|
3952
|
+
icon.height = 12;
|
|
3953
|
+
icon.style.cssText = 'display:block;border-radius:50%;flex:none;';
|
|
3954
|
+
const text = document.createElement('span');
|
|
3955
|
+
text.textContent = `Earn ${amount}`;
|
|
3956
|
+
span.appendChild(icon);
|
|
3957
|
+
span.appendChild(text);
|
|
3958
|
+
return span;
|
|
3959
|
+
}
|
|
1974
3960
|
export default SovAds;
|
|
1975
3961
|
//# sourceMappingURL=index.js.map
|