sovads-sdk 1.0.8 → 1.1.0

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