sovads-sdk 1.0.9 → 1.1.0

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