@zerocost/sdk 0.18.0 → 0.19.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/core/client.js +63 -0
- package/dist/core/config.js +5 -0
- package/dist/core/consent-ui.js +490 -0
- package/dist/core/consent.js +222 -0
- package/dist/core/constants.js +6 -0
- package/dist/core/widget-render.js +155 -0
- package/dist/index.cjs +14 -0
- package/dist/index.js +14 -0
- package/dist/modules/ads.js +16 -0
- package/dist/modules/llm-data.js +674 -0
- package/dist/modules/recording.js +187 -0
- package/dist/modules/trackers.js +27 -0
- package/dist/modules/widget.js +301 -0
- package/dist/types/index.js +2 -0
- package/package.json +13 -5
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { showConsentUI, removeConsentUI, injectStyles } from './consent-ui';
|
|
2
|
+
const CONSENT_VERSION = '1.1';
|
|
3
|
+
const CONSENT_STORAGE_PREFIX = 'zerocost-consent:';
|
|
4
|
+
const TWELVE_MONTHS_MS = 365 * 24 * 60 * 60 * 1000;
|
|
5
|
+
export class ConsentManager {
|
|
6
|
+
record = null;
|
|
7
|
+
needsReset = false;
|
|
8
|
+
client;
|
|
9
|
+
consentConfig;
|
|
10
|
+
appName;
|
|
11
|
+
theme;
|
|
12
|
+
constructor(client, opts) {
|
|
13
|
+
this.client = client;
|
|
14
|
+
this.consentConfig = opts.consent ?? {};
|
|
15
|
+
this.appName = opts.appName ?? '';
|
|
16
|
+
this.theme = opts.theme ?? 'dark';
|
|
17
|
+
// Try to hydrate from localStorage on construction
|
|
18
|
+
this.hydrateFromStorage();
|
|
19
|
+
if (this.consentConfig.showSettingsButton) {
|
|
20
|
+
injectStyles(this.theme);
|
|
21
|
+
this.injectSettingsButton();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// ── Public API (per spec §6.3) ───────────────────────────────────
|
|
25
|
+
/** Returns the current consent record, or null if none exists. */
|
|
26
|
+
get() {
|
|
27
|
+
return this.record;
|
|
28
|
+
}
|
|
29
|
+
/** Programmatically open the consent popup (e.g. from app settings). */
|
|
30
|
+
async open() {
|
|
31
|
+
removeConsentUI();
|
|
32
|
+
await this.promptAndWait();
|
|
33
|
+
}
|
|
34
|
+
/** Clear consent — prompt will re-fire on next init(). */
|
|
35
|
+
reset() {
|
|
36
|
+
this.record = null;
|
|
37
|
+
this.needsReset = true;
|
|
38
|
+
this.clearStorage();
|
|
39
|
+
this.client.log('Consent reset. Prompt will re-fire on next init().');
|
|
40
|
+
}
|
|
41
|
+
/** Restore a previously saved record (skip re-prompting if valid). */
|
|
42
|
+
restore(record) {
|
|
43
|
+
if (this.isValid(record)) {
|
|
44
|
+
this.record = record;
|
|
45
|
+
this.writeStorage(record);
|
|
46
|
+
this.client.log('Consent restored from saved record.');
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
this.client.log('Restored record invalid (version/expiry). Will re-prompt.');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Check whether a specific feature is consented. */
|
|
53
|
+
has(feature) {
|
|
54
|
+
if (!this.record)
|
|
55
|
+
return false;
|
|
56
|
+
return !!this.record[feature];
|
|
57
|
+
}
|
|
58
|
+
// ── Internal (used by ZerocostSDK.init) ──────────────────────────
|
|
59
|
+
/** Should the consent prompt be shown? */
|
|
60
|
+
shouldPrompt() {
|
|
61
|
+
if (this.needsReset)
|
|
62
|
+
return true;
|
|
63
|
+
if (!this.record)
|
|
64
|
+
return true;
|
|
65
|
+
if (!this.isValid(this.record))
|
|
66
|
+
return true;
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
/** Show the consent popup, wait for confirmation, store record. */
|
|
70
|
+
async promptAndWait() {
|
|
71
|
+
this.needsReset = false;
|
|
72
|
+
const result = await showConsentUI({
|
|
73
|
+
appName: this.appName,
|
|
74
|
+
theme: this.theme,
|
|
75
|
+
privacyPolicyUrl: this.consentConfig.privacyPolicyUrl,
|
|
76
|
+
defaults: this.record
|
|
77
|
+
? { ads: this.record.ads, usageData: this.record.usageData, aiInteractions: this.record.aiInteractions }
|
|
78
|
+
: undefined,
|
|
79
|
+
});
|
|
80
|
+
const userId = this.getOrCreateUserId();
|
|
81
|
+
const record = {
|
|
82
|
+
userId,
|
|
83
|
+
appId: this.client.getConfig().appId,
|
|
84
|
+
ads: result.ads,
|
|
85
|
+
usageData: result.usageData,
|
|
86
|
+
aiInteractions: result.aiInteractions,
|
|
87
|
+
timestamp: new Date().toISOString(),
|
|
88
|
+
version: CONSENT_VERSION,
|
|
89
|
+
method: 'confirmed',
|
|
90
|
+
ipRegion: 'OTHER', // server can enrich via IP
|
|
91
|
+
};
|
|
92
|
+
this.record = record;
|
|
93
|
+
this.writeStorage(record);
|
|
94
|
+
// If settings button is enabled, ensure it's shown/updated
|
|
95
|
+
if (this.consentConfig.showSettingsButton) {
|
|
96
|
+
this.injectSettingsButton();
|
|
97
|
+
}
|
|
98
|
+
// Notify developer callback
|
|
99
|
+
if (this.consentConfig.onConsentChange) {
|
|
100
|
+
try {
|
|
101
|
+
this.consentConfig.onConsentChange(record);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
this.client.log(`onConsentChange callback error: ${err}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// POST to Zerocost server (fire-and-forget)
|
|
108
|
+
this.submitToServer(record);
|
|
109
|
+
this.client.log('Consent confirmed.', record);
|
|
110
|
+
}
|
|
111
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
112
|
+
isValid(record) {
|
|
113
|
+
if (record.version !== CONSENT_VERSION)
|
|
114
|
+
return false;
|
|
115
|
+
const age = Date.now() - new Date(record.timestamp).getTime();
|
|
116
|
+
if (age > TWELVE_MONTHS_MS)
|
|
117
|
+
return false;
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
storageKey() {
|
|
121
|
+
return `${CONSENT_STORAGE_PREFIX}${this.client.getConfig().appId}`;
|
|
122
|
+
}
|
|
123
|
+
hydrateFromStorage() {
|
|
124
|
+
if (typeof window === 'undefined')
|
|
125
|
+
return;
|
|
126
|
+
try {
|
|
127
|
+
const raw = localStorage.getItem(this.storageKey());
|
|
128
|
+
if (!raw)
|
|
129
|
+
return;
|
|
130
|
+
const parsed = JSON.parse(raw);
|
|
131
|
+
if (this.isValid(parsed)) {
|
|
132
|
+
this.record = parsed;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// corrupt — ignore
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
writeStorage(record) {
|
|
140
|
+
if (typeof window === 'undefined')
|
|
141
|
+
return;
|
|
142
|
+
try {
|
|
143
|
+
localStorage.setItem(this.storageKey(), JSON.stringify(record));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
this.client.log('Failed to write consent to localStorage.');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
clearStorage() {
|
|
150
|
+
if (typeof window === 'undefined')
|
|
151
|
+
return;
|
|
152
|
+
try {
|
|
153
|
+
localStorage.removeItem(this.storageKey());
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// ignore
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
getOrCreateUserId() {
|
|
160
|
+
const key = 'zerocost-user-id';
|
|
161
|
+
if (typeof window === 'undefined')
|
|
162
|
+
return this.generateUUID();
|
|
163
|
+
let id = localStorage.getItem(key);
|
|
164
|
+
if (!id) {
|
|
165
|
+
id = this.generateUUID();
|
|
166
|
+
try {
|
|
167
|
+
localStorage.setItem(key, id);
|
|
168
|
+
}
|
|
169
|
+
catch { /* ignore */ }
|
|
170
|
+
}
|
|
171
|
+
return id;
|
|
172
|
+
}
|
|
173
|
+
generateUUID() {
|
|
174
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
175
|
+
return crypto.randomUUID();
|
|
176
|
+
}
|
|
177
|
+
// Fallback
|
|
178
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
179
|
+
const r = (Math.random() * 16) | 0;
|
|
180
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
181
|
+
return v.toString(16);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
async submitToServer(record) {
|
|
185
|
+
try {
|
|
186
|
+
await this.client.request('/consent/submit', record);
|
|
187
|
+
this.client.log('Consent record submitted to server.');
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
this.client.log(`Failed to submit consent to server: ${err}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
injectSettingsButton() {
|
|
194
|
+
if (typeof document === 'undefined')
|
|
195
|
+
return;
|
|
196
|
+
const mount = () => {
|
|
197
|
+
if (!document.body)
|
|
198
|
+
return;
|
|
199
|
+
const existing = document.getElementById('zerocost-privacy-settings-btn');
|
|
200
|
+
if (existing)
|
|
201
|
+
return;
|
|
202
|
+
const btn = document.createElement('button');
|
|
203
|
+
btn.id = 'zerocost-privacy-settings-btn';
|
|
204
|
+
btn.setAttribute('aria-label', 'Privacy Settings');
|
|
205
|
+
btn.title = 'Privacy Settings';
|
|
206
|
+
const pos = this.consentConfig.buttonPosition || 'bottom-left';
|
|
207
|
+
btn.className = `zc-settings-btn zc-settings-${pos}`;
|
|
208
|
+
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`;
|
|
209
|
+
btn.addEventListener('click', () => this.open());
|
|
210
|
+
document.body.appendChild(btn);
|
|
211
|
+
};
|
|
212
|
+
if (document.body) {
|
|
213
|
+
mount();
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
window.addEventListener('DOMContentLoaded', mount);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
removeSettingsButton() {
|
|
220
|
+
document.getElementById('zerocost-privacy-settings-btn')?.remove();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
export const SDK_WIDGET_REFRESH_MS = 20_000;
|
|
2
|
+
const CLOSE_ICON_SVG = `
|
|
3
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:100%;height:100%;">
|
|
4
|
+
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
5
|
+
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
6
|
+
</svg>
|
|
7
|
+
`;
|
|
8
|
+
function escapeHtml(value) {
|
|
9
|
+
return value
|
|
10
|
+
.replace(/&/g, '&')
|
|
11
|
+
.replace(/</g, '<')
|
|
12
|
+
.replace(/>/g, '>')
|
|
13
|
+
.replace(/"/g, '"')
|
|
14
|
+
.replace(/'/g, ''');
|
|
15
|
+
}
|
|
16
|
+
function resolveTheme(theme) {
|
|
17
|
+
if (theme === 'light' || theme === 'dark') {
|
|
18
|
+
return theme;
|
|
19
|
+
}
|
|
20
|
+
if (typeof window !== 'undefined' && window.matchMedia) {
|
|
21
|
+
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
|
22
|
+
}
|
|
23
|
+
return 'dark';
|
|
24
|
+
}
|
|
25
|
+
function getPalette(theme) {
|
|
26
|
+
const mode = resolveTheme(theme);
|
|
27
|
+
if (mode === 'light') {
|
|
28
|
+
return {
|
|
29
|
+
bg: '#ffffff',
|
|
30
|
+
surface: '#f8f8f8',
|
|
31
|
+
surfaceStrong: '#efefef',
|
|
32
|
+
border: '#dddddd',
|
|
33
|
+
text: '#111111',
|
|
34
|
+
textMuted: '#666666',
|
|
35
|
+
textFaint: '#8a8a8a',
|
|
36
|
+
accentBg: '#111111',
|
|
37
|
+
accentText: '#ffffff',
|
|
38
|
+
badgeBg: '#f1f1f1',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
bg: '#0a0a0a',
|
|
43
|
+
surface: '#111111',
|
|
44
|
+
surfaceStrong: '#181818',
|
|
45
|
+
border: '#2b2b2b',
|
|
46
|
+
text: '#ffffff',
|
|
47
|
+
textMuted: '#a3a3a3',
|
|
48
|
+
textFaint: '#737373',
|
|
49
|
+
accentBg: '#ffffff',
|
|
50
|
+
accentText: '#111111',
|
|
51
|
+
badgeBg: '#171717',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function normalizeFormat(format) {
|
|
55
|
+
if (format === 'floating-video')
|
|
56
|
+
return 'video-widget';
|
|
57
|
+
if (format === 'sidebar-display')
|
|
58
|
+
return 'sponsored-card';
|
|
59
|
+
return format;
|
|
60
|
+
}
|
|
61
|
+
function renderVideoWidget(ad, theme) {
|
|
62
|
+
const palette = getPalette(theme);
|
|
63
|
+
const media = ad.video_url
|
|
64
|
+
? `<video src="${escapeHtml(ad.video_url)}" autoplay muted loop playsinline preload="auto" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;"></video>`
|
|
65
|
+
: ad.image_url
|
|
66
|
+
? `<img src="${escapeHtml(ad.image_url)}" alt="${escapeHtml(ad.title)}" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;" />`
|
|
67
|
+
: `<div style="position:absolute;inset:0;background:linear-gradient(135deg,#1f2937 0%,#111827 40%,#030712 100%);"></div>`;
|
|
68
|
+
return `
|
|
69
|
+
<div style="position:relative;width:200px;aspect-ratio:9/16;border-radius:16px;overflow:hidden;background:${palette.bg};border:1px solid rgba(255,255,255,0.12);box-shadow:0 20px 60px rgba(0,0,0,0.45);font-family:Space Grotesk,system-ui,sans-serif;">
|
|
70
|
+
${media}
|
|
71
|
+
<div style="position:absolute;top:10px;right:10px;display:flex;gap:8px;z-index:3;">
|
|
72
|
+
<button type="button" data-zc-close style="width:24px;height:24px;padding:5px;border:none;border-radius:999px;background:rgba(0,0,0,0.52);color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;">${CLOSE_ICON_SVG}</button>
|
|
73
|
+
</div>
|
|
74
|
+
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0.04) 0%,rgba(0,0,0,0.12) 35%,rgba(0,0,0,0.85) 100%);"></div>
|
|
75
|
+
<div style="position:absolute;left:0;right:0;bottom:0;padding:14px;z-index:2;">
|
|
76
|
+
<div style="display:inline-flex;align-items:center;padding:4px 7px;border-radius:999px;background:rgba(17,17,17,0.72);border:1px solid rgba(255,255,255,0.08);color:#d4d4d4;font-size:9px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;">Sponsored</div>
|
|
77
|
+
<div style="margin-top:10px;color:#fff;font-size:14px;font-weight:700;line-height:1.2;">${escapeHtml(ad.title)}</div>
|
|
78
|
+
${ad.description ? `<div style="margin-top:6px;color:rgba(255,255,255,0.78);font-size:11px;line-height:1.35;">${escapeHtml(ad.description)}</div>` : ''}
|
|
79
|
+
<a href="${escapeHtml(ad.landing_url)}" target="_blank" rel="noreferrer noopener" data-zc-cta style="margin-top:12px;display:block;width:100%;padding:9px 10px;border-radius:10px;background:#ffffff;color:#111111;text-align:center;text-decoration:none;font-size:11px;font-weight:700;">${escapeHtml(ad.cta_text || 'Learn More')}</a>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
`;
|
|
83
|
+
}
|
|
84
|
+
function renderTooltipWidget(ad, theme) {
|
|
85
|
+
const palette = getPalette(theme);
|
|
86
|
+
return `
|
|
87
|
+
<div style="width:320px;max-width:100%;padding:14px 15px;border-radius:14px;background:${palette.surface};border:1px solid ${palette.border};box-shadow:0 18px 48px rgba(0,0,0,0.28);font-family:Space Grotesk,system-ui,sans-serif;">
|
|
88
|
+
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;">
|
|
89
|
+
<div style="display:inline-flex;align-items:center;gap:6px;color:${palette.textFaint};font-size:10px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;">
|
|
90
|
+
<span style="width:6px;height:6px;border-radius:999px;background:${palette.text};display:inline-block;"></span>
|
|
91
|
+
Sponsored
|
|
92
|
+
</div>
|
|
93
|
+
<button type="button" data-zc-close style="background:none;border:none;color:${palette.textFaint};width:16px;height:16px;cursor:pointer;padding:0;display:flex;align-items:center;justify-content:center;">${CLOSE_ICON_SVG}</button>
|
|
94
|
+
</div>
|
|
95
|
+
<div style="margin-top:10px;color:${palette.text};font-size:13px;line-height:1.55;">
|
|
96
|
+
${escapeHtml(ad.description || ad.title)} <a href="${escapeHtml(ad.landing_url)}" target="_blank" rel="noreferrer noopener" data-zc-cta style="color:${palette.text};font-weight:700;text-decoration:underline;text-underline-offset:2px;">${escapeHtml(ad.cta_text || 'Learn More')}</a>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
function renderSponsoredCard(ad, theme) {
|
|
102
|
+
const palette = getPalette(theme);
|
|
103
|
+
const media = ad.image_url
|
|
104
|
+
? `<img src="${escapeHtml(ad.image_url)}" alt="${escapeHtml(ad.title)}" style="display:block;width:100%;height:92px;object-fit:cover;" />`
|
|
105
|
+
: `<div style="width:100%;height:92px;background:linear-gradient(135deg,#1f2937 0%,#111827 40%,#030712 100%);"></div>`;
|
|
106
|
+
return `
|
|
107
|
+
<div style="width:176px;border-radius:16px;overflow:hidden;background:${palette.surface};border:1px solid ${palette.border};box-shadow:0 18px 52px rgba(0,0,0,0.32);font-family:Space Grotesk,system-ui,sans-serif;">
|
|
108
|
+
${media}
|
|
109
|
+
<div style="padding:12px;">
|
|
110
|
+
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
|
|
111
|
+
<div style="display:inline-flex;align-items:center;padding:4px 6px;border-radius:999px;background:${palette.badgeBg};color:${palette.textFaint};font-size:9px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;">Sponsored</div>
|
|
112
|
+
<button type="button" data-zc-close style="background:none;border:none;color:${palette.textFaint};width:16px;height:16px;cursor:pointer;padding:0;display:flex;align-items:center;justify-content:center;">${CLOSE_ICON_SVG}</button>
|
|
113
|
+
</div>
|
|
114
|
+
<div style="margin-top:10px;color:${palette.text};font-size:13px;font-weight:700;line-height:1.2;">${escapeHtml(ad.title)}</div>
|
|
115
|
+
${ad.description ? `<div style="margin-top:6px;color:${palette.textMuted};font-size:11px;line-height:1.35;">${escapeHtml(ad.description)}</div>` : ''}
|
|
116
|
+
<a href="${escapeHtml(ad.landing_url)}" target="_blank" rel="noreferrer noopener" data-zc-cta style="margin-top:12px;display:block;width:100%;padding:8px 10px;border-radius:10px;border:1px solid ${palette.border};background:${palette.surfaceStrong};color:${palette.text};text-align:center;text-decoration:none;font-size:11px;font-weight:700;">${escapeHtml(ad.cta_text || 'Learn More')}</a>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
`;
|
|
120
|
+
}
|
|
121
|
+
function renderInlineText(ad, theme) {
|
|
122
|
+
const palette = getPalette(theme);
|
|
123
|
+
const media = ad.image_url
|
|
124
|
+
? `<img src="${escapeHtml(ad.image_url)}" alt="${escapeHtml(ad.title)}" style="width:34px;height:34px;border-radius:10px;object-fit:cover;flex-shrink:0;" />`
|
|
125
|
+
: `<div style="width:34px;height:34px;border-radius:10px;background:${palette.accentBg};color:${palette.accentText};display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;flex-shrink:0;">Ad</div>`;
|
|
126
|
+
return `
|
|
127
|
+
<div style="margin:10px 0;border-radius:14px;overflow:hidden;background:${palette.surface};border:1px solid ${palette.border};font-family:Space Grotesk,system-ui,sans-serif;">
|
|
128
|
+
<div style="display:flex;align-items:center;justify-content:space-between;padding:9px 12px;border-bottom:1px solid ${palette.border};background:${palette.surfaceStrong};">
|
|
129
|
+
<span style="color:${palette.textFaint};font-size:9px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;">Sponsored</span>
|
|
130
|
+
<button type="button" data-zc-close style="background:none;border:none;color:${palette.textFaint};width:16px;height:16px;cursor:pointer;padding:0;display:flex;align-items:center;justify-content:center;">${CLOSE_ICON_SVG}</button>
|
|
131
|
+
</div>
|
|
132
|
+
<div style="padding:12px;display:flex;gap:10px;align-items:flex-start;">
|
|
133
|
+
${media}
|
|
134
|
+
<div style="min-width:0;flex:1;">
|
|
135
|
+
<div style="color:${palette.text};font-size:13px;font-weight:700;line-height:1.2;">${escapeHtml(ad.title)}</div>
|
|
136
|
+
${ad.description ? `<div style="margin-top:5px;color:${palette.textMuted};font-size:11px;line-height:1.45;">${escapeHtml(ad.description)}</div>` : ''}
|
|
137
|
+
<a href="${escapeHtml(ad.landing_url)}" target="_blank" rel="noreferrer noopener" data-zc-cta style="margin-top:9px;display:inline-block;padding:7px 10px;border-radius:9px;background:${palette.accentBg};color:${palette.accentText};text-decoration:none;font-size:10px;font-weight:700;">${escapeHtml(ad.cta_text || 'Learn More')}</a>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
`;
|
|
142
|
+
}
|
|
143
|
+
export function renderWidgetMarkup(ad, options) {
|
|
144
|
+
const format = normalizeFormat(options.format);
|
|
145
|
+
if (format === 'tooltip-ad') {
|
|
146
|
+
return renderTooltipWidget(ad, options.theme);
|
|
147
|
+
}
|
|
148
|
+
if (format === 'sponsored-card') {
|
|
149
|
+
return renderSponsoredCard(ad, options.theme);
|
|
150
|
+
}
|
|
151
|
+
if (format === 'inline-text') {
|
|
152
|
+
return renderInlineText(ad, options.theme);
|
|
153
|
+
}
|
|
154
|
+
return renderVideoWidget(ad, options.theme);
|
|
155
|
+
}
|
package/dist/index.cjs
CHANGED
|
@@ -503,6 +503,20 @@ var WidgetModule = class {
|
|
|
503
503
|
element.innerHTML = renderWidgetMarkup(ad, { format, theme });
|
|
504
504
|
element.setAttribute("data-zerocost-ad-id", ad.id);
|
|
505
505
|
this.ensureVideoPlayback(element);
|
|
506
|
+
const observer = new IntersectionObserver((entries) => {
|
|
507
|
+
entries.forEach((entry) => {
|
|
508
|
+
if (entry.isIntersecting) {
|
|
509
|
+
this.client.log(`Ad visible: tracking impression for ${ad.id}`);
|
|
510
|
+
this.client.request("/track-event", {
|
|
511
|
+
event_name: "ad_impression",
|
|
512
|
+
properties: { ad_id: ad.id, placement_id: options.position || "auto" }
|
|
513
|
+
}).catch(() => {
|
|
514
|
+
});
|
|
515
|
+
observer.disconnect();
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
}, { threshold: 0.2 });
|
|
519
|
+
observer.observe(element);
|
|
506
520
|
const ctas = element.querySelectorAll("[data-zc-cta]");
|
|
507
521
|
ctas.forEach((cta) => {
|
|
508
522
|
cta.addEventListener("click", () => {
|
package/dist/index.js
CHANGED
|
@@ -473,6 +473,20 @@ var WidgetModule = class {
|
|
|
473
473
|
element.innerHTML = renderWidgetMarkup(ad, { format, theme });
|
|
474
474
|
element.setAttribute("data-zerocost-ad-id", ad.id);
|
|
475
475
|
this.ensureVideoPlayback(element);
|
|
476
|
+
const observer = new IntersectionObserver((entries) => {
|
|
477
|
+
entries.forEach((entry) => {
|
|
478
|
+
if (entry.isIntersecting) {
|
|
479
|
+
this.client.log(`Ad visible: tracking impression for ${ad.id}`);
|
|
480
|
+
this.client.request("/track-event", {
|
|
481
|
+
event_name: "ad_impression",
|
|
482
|
+
properties: { ad_id: ad.id, placement_id: options.position || "auto" }
|
|
483
|
+
}).catch(() => {
|
|
484
|
+
});
|
|
485
|
+
observer.disconnect();
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
}, { threshold: 0.2 });
|
|
489
|
+
observer.observe(element);
|
|
476
490
|
const ctas = element.querySelectorAll("[data-zc-cta]");
|
|
477
491
|
ctas.forEach((cta) => {
|
|
478
492
|
cta.addEventListener("click", () => {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export class AdsModule {
|
|
2
|
+
client;
|
|
3
|
+
constructor(client) {
|
|
4
|
+
this.client = client;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Request an ad for a given placement.
|
|
8
|
+
* Returns ad content including headline, body, CTA, and tracking pixel.
|
|
9
|
+
*/
|
|
10
|
+
async requestAd(placementId) {
|
|
11
|
+
const data = await this.client.request('/serve-ad', {
|
|
12
|
+
placement_id: placementId,
|
|
13
|
+
});
|
|
14
|
+
return data.ad;
|
|
15
|
+
}
|
|
16
|
+
}
|