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/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
- refreshInterval: 30, // Default to 30 seconds for variety
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
- this.walletAddress = walletAddress.toLowerCase();
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': '1.0.8'
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
- // Initial state: hidden
763
- container.style.display = 'none';
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
- dummyElement.style.transform = 'scale(1.02)';
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
- dummyElement.style.transform = 'scale(1)';
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: ${mediaType === 'video' ? 'default' : 'pointer'};
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
- // Always ensure container is hidden until loaded
860
- container.style.display = 'none';
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
- if (mediaType === 'video') {
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
- mediaElement.style.cursor = mediaType === 'video' ? 'default' : 'pointer';
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 (mediaType === 'video') {
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
- // Add hover effect
954
- adElement.addEventListener('mouseenter', () => {
955
- adElement.style.transform = 'scale(1.02)';
956
- });
957
- adElement.addEventListener('mouseleave', () => {
958
- adElement.style.transform = 'scale(1)';
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
- async show(consumerId, delay = 3000) {
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(consumerId, delay);
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
- adLabel.textContent = 'Ad';
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
- if (mediaType === 'video') {
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
- if (mediaType === 'video') {
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 (mediaType === 'video') {
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
- // Auto close after 10 seconds
1374
- setTimeout(() => {
1375
- this.hide();
1376
- }, 10000);
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
- async show(consumerId) {
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(consumerId);
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
- bar.appendChild(mediaEl);
1549
- bar.addEventListener('click', handleClick);
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
- dummyElement.style.transform = 'translateY(-2px)';
3123
+ if (!prefersReducedMotion())
3124
+ dummyElement.style.transform = 'translateY(-2px)';
1664
3125
  });
1665
3126
  dummyElement.addEventListener('mouseleave', () => {
1666
3127
  dummyElement.style.background = '#f9f9f9';
1667
- dummyElement.style.transform = 'translateY(0)';
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: ${mediaType === 'video' ? 'default' : 'pointer'};
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
- adElement.style.transform = 'translateY(-2px)';
3227
+ if (!prefersReducedMotion())
3228
+ adElement.style.transform = 'translateY(-2px)';
1758
3229
  });
1759
3230
  adElement.addEventListener('mouseleave', () => {
1760
3231
  adElement.style.background = '#f8f9fa';
1761
- adElement.style.transform = 'translateY(0)';
3232
+ if (!prefersReducedMotion())
3233
+ adElement.style.transform = 'translateY(0)';
1762
3234
  });
1763
- mediaElement.style.cursor = mediaType === 'video' ? 'default' : 'pointer';
1764
- if (mediaType === 'video') {
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
- async show(consumerId) {
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; top: 0; left: 0; width: 100%; height: 100%;
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 container = document.createElement('div');
1892
- container.style.cssText = `
1893
- position: relative; max-width: 90%; max-height: 90%;
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 = '&times;';
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.onclick = () => wrapper.remove();
1906
- const img = document.createElement('img');
1907
- img.src = this.currentAd.bannerUrl;
1908
- img.style.cssText = 'display: block; max-width: 100%; height: auto;';
1909
- img.onload = () => {
1910
- wrapper.style.opacity = '1';
1911
- this.sovads._trackEvent('IMPRESSION', this.currentAd.id, this.currentAd.campaignId);
1912
- };
1913
- container.onclick = () => {
1914
- this.sovads._trackEvent('CLICK', this.currentAd.id, this.currentAd.campaignId);
1915
- window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank');
1916
- wrapper.remove();
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
- container.appendChild(closeBtn);
1919
- container.appendChild(img);
1920
- wrapper.appendChild(container);
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
- async render(consumerId) {
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.innerHTML = `
1957
- <div style="font-weight: bold; font-size: 16px; margin-bottom: 4px;">${this.currentAd.description.slice(0, 40)}...</div>
1958
- <div style="font-size: 12px; color: #666;">Sponsored</div>
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._trackEvent('IMPRESSION', this.currentAd.id, this.currentAd.campaignId);
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
- card.onclick = () => {
3781
+ const handleCardClick = () => {
1965
3782
  this.sovads._trackEvent('CLICK', this.currentAd.id, this.currentAd.campaignId);
1966
- window.open(this.sovads.normalizeUrl(this.currentAd.targetUrl), '_blank');
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