@zerocost/sdk 0.7.0 → 0.9.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.cjs CHANGED
@@ -146,23 +146,24 @@ var POSITION_STYLES = {
146
146
  "sidebar-left": "position:fixed;top:50%;left:24px;transform:translateY(-50%);z-index:9999;",
147
147
  "sidebar-right": "position:fixed;top:50%;right:24px;transform:translateY(-50%);z-index:9999;"
148
148
  };
149
- var FORMAT_PRIORITY = ["video-widget", "sponsored-card", "sidebar-display", "tooltip-ad", "inline-text"];
149
+ var FORMAT_PRIORITY = ["video-widget", "tooltip-ad", "sponsored-card", "sidebar-display", "inline-text"];
150
+ var AUTO_SLOT_ID = "zerocost-auto-slot";
150
151
  var WidgetModule = class {
151
152
  constructor(client) {
152
153
  this.client = client;
153
154
  }
154
155
  mounted = /* @__PURE__ */ new Map();
155
- async autoInjectWithConfig(display) {
156
+ async autoInjectWithConfig(display, widget) {
156
157
  try {
157
- const configs = this.normalizeConfigs(display);
158
- const chosen = this.pickOneFormat(configs);
159
- if (!chosen) {
160
- this.client.log("No ad formats enabled. Skipping injection.");
158
+ const selected = this.resolveSelectedWidget(display, widget);
159
+ this.clearAutoInjectedSlots();
160
+ if (!selected || !selected.enabled) {
161
+ this.client.log("No enabled widget format found. Skipping injection.");
161
162
  return;
162
163
  }
163
- this.client.log(`Auto-inject: rendering "${chosen.format}" only.`);
164
- await this.mountSingleFormat(chosen.format, chosen.config);
165
- this.client.log("\u2713 Ad slot injected.");
164
+ this.client.log(`Auto-inject: rendering configured format "${selected.format}" only.`);
165
+ await this.mountSingleFormat(selected);
166
+ this.client.log("\u2713 Single ad slot injected.");
166
167
  } catch (err) {
167
168
  this.client.log(`Widget autoInject error: ${err}`);
168
169
  }
@@ -170,21 +171,39 @@ var WidgetModule = class {
170
171
  async autoInject() {
171
172
  try {
172
173
  this.client.log("Fetching ad placements from server...");
173
- const { display } = await this.client.request("/get-placements");
174
- await this.autoInjectWithConfig(display);
174
+ const { display, widget } = await this.client.request("/get-placements");
175
+ await this.autoInjectWithConfig(display, widget);
175
176
  } catch (err) {
176
177
  this.client.log(`Widget autoInject error: ${err}`);
177
178
  }
178
179
  }
179
- pickOneFormat(configs) {
180
+ resolveSelectedWidget(display, widget) {
181
+ if (widget?.enabled && widget?.format) {
182
+ return {
183
+ format: widget.format,
184
+ position: widget.position || "bottom-right",
185
+ theme: widget.theme || "dark",
186
+ autoplay: widget.autoplay ?? widget.format === "video-widget",
187
+ enabled: true
188
+ };
189
+ }
190
+ const configs = this.normalizeConfigs(display);
180
191
  for (const fmt of FORMAT_PRIORITY) {
181
192
  const cfg = configs[fmt];
182
- if (cfg?.enabled) return { format: fmt, config: cfg };
193
+ if (cfg?.enabled) {
194
+ return {
195
+ format: fmt,
196
+ position: cfg.position,
197
+ theme: cfg.theme,
198
+ autoplay: cfg.autoplay,
199
+ enabled: true
200
+ };
201
+ }
183
202
  }
184
203
  return null;
185
204
  }
186
205
  normalizeConfigs(display) {
187
- if (display && "video-widget" in display) {
206
+ if (display && typeof display === "object" && "video-widget" in display) {
188
207
  return display;
189
208
  }
190
209
  const pos = display?.position || "bottom-right";
@@ -193,22 +212,58 @@ var WidgetModule = class {
193
212
  "video-widget": { position: pos, theme, autoplay: true, enabled: true },
194
213
  "tooltip-ad": { position: pos, theme, autoplay: false, enabled: false },
195
214
  "sponsored-card": { position: pos, theme, autoplay: false, enabled: false },
215
+ "sidebar-display": { position: pos, theme, autoplay: false, enabled: false },
196
216
  "inline-text": { position: "after-paragraph-1", theme, autoplay: false, enabled: false }
197
217
  };
198
218
  }
199
- async mountSingleFormat(format, cfg) {
200
- const elId = `zerocost-${format}`;
201
- if (document.getElementById(elId)) return;
202
- if (format === "inline-text") return;
203
- const el = document.createElement("div");
204
- el.id = elId;
205
- el.setAttribute("data-zerocost", "");
206
- el.setAttribute("data-format", format);
207
- const posStyle = POSITION_STYLES[cfg.position] || POSITION_STYLES["bottom-right"];
208
- const maxW = format === "video-widget" ? "max-width:200px;" : format === "sponsored-card" || format === "sidebar-display" ? "max-width:176px;" : "max-width:320px;";
209
- el.setAttribute("style", posStyle + maxW);
210
- document.body.appendChild(el);
211
- await this.mount(elId, { format, refreshInterval: 30, theme: cfg.theme, autoplay: cfg.autoplay ?? true });
219
+ async mountSingleFormat(config) {
220
+ const isInline = config.format === "inline-text";
221
+ const targetElementId = isInline ? this.ensureInlineTarget(config.position) : AUTO_SLOT_ID;
222
+ if (!targetElementId) {
223
+ this.client.log("Inline target not found. Skipping inline ad render.");
224
+ return;
225
+ }
226
+ if (!isInline) {
227
+ let el = document.getElementById(AUTO_SLOT_ID);
228
+ if (!el) {
229
+ el = document.createElement("div");
230
+ el.id = AUTO_SLOT_ID;
231
+ document.body.appendChild(el);
232
+ }
233
+ const posStyle = POSITION_STYLES[config.position] || POSITION_STYLES["bottom-right"];
234
+ const maxW = config.format === "video-widget" ? "max-width:200px;" : config.format === "sponsored-card" || config.format === "sidebar-display" ? "max-width:176px;" : "max-width:320px;";
235
+ el.setAttribute("style", `${posStyle}${maxW}`);
236
+ el.setAttribute("data-zerocost", "");
237
+ el.setAttribute("data-format", config.format);
238
+ }
239
+ await this.mount(targetElementId, {
240
+ format: config.format,
241
+ refreshInterval: 0,
242
+ // Config polling handles re-rendering
243
+ theme: config.theme,
244
+ autoplay: config.autoplay,
245
+ position: config.position
246
+ });
247
+ }
248
+ ensureInlineTarget(position) {
249
+ const paragraphMatch = /after-paragraph-(\d+)/.exec(position || "");
250
+ const index = paragraphMatch ? Number(paragraphMatch[1]) : 1;
251
+ const paragraphs = Array.from(document.querySelectorAll("p"));
252
+ const anchor = paragraphs[Math.max(0, Math.min(paragraphs.length - 1, index - 1))];
253
+ if (!anchor) return null;
254
+ const existing = document.getElementById(AUTO_SLOT_ID);
255
+ if (existing) existing.remove();
256
+ const inlineId = `${AUTO_SLOT_ID}-inline`;
257
+ let target = document.getElementById(inlineId);
258
+ if (!target) {
259
+ target = document.createElement("div");
260
+ target.id = inlineId;
261
+ target.setAttribute("data-zerocost", "");
262
+ target.setAttribute("data-format", "inline-text");
263
+ target.style.margin = "12px 0";
264
+ anchor.insertAdjacentElement("afterend", target);
265
+ }
266
+ return inlineId;
212
267
  }
213
268
  async mount(targetElementId, options = {}) {
214
269
  const el = document.getElementById(targetElementId);
@@ -217,36 +272,25 @@ var WidgetModule = class {
217
272
  const refreshMs = (options.refreshInterval ?? 30) * 1e3;
218
273
  const theme = options.theme || "dark";
219
274
  const format = options.format || "video-widget";
220
- const autoplay = options.autoplay ?? false;
275
+ const autoplay = options.autoplay ?? format === "video-widget";
221
276
  const render = async () => {
222
277
  try {
223
- const formatMap = {
224
- "video-widget": "video",
225
- "sponsored-card": "display",
226
- "sidebar-display": "display",
227
- "tooltip-ad": "native",
228
- "inline-text": "native"
229
- };
230
278
  const body = {
231
- format: formatMap[format] || format,
279
+ widget_style: format,
232
280
  theme,
233
- autoplay
281
+ autoplay,
282
+ position: options.position
234
283
  };
235
284
  const data = await this.client.request("/serve-widget", body);
236
285
  const ad = data.ad;
237
286
  if (!ad || !data.html) {
238
- this.client.log(`No ad inventory available for format "${format}". Slot "${targetElementId}" is empty \u2014 ads will appear automatically when inventory is added by partners.`);
287
+ this.client.log(`No ad inventory available for configured format "${format}".`);
239
288
  el.innerHTML = "";
240
289
  return;
241
290
  }
242
291
  el.innerHTML = data.html;
243
292
  el.setAttribute("data-zerocost-ad-id", ad.id);
244
- const video = el.querySelector("video");
245
- if (video) {
246
- video.muted = true;
247
- video.play().catch(() => {
248
- });
249
- }
293
+ this.ensureVideoPlayback(el);
250
294
  const ctas = el.querySelectorAll("[data-zc-cta]");
251
295
  ctas.forEach((cta) => {
252
296
  cta.addEventListener("click", () => {
@@ -262,10 +306,7 @@ var WidgetModule = class {
262
306
  closeBtn.addEventListener("click", (e) => {
263
307
  e.preventDefault();
264
308
  e.stopPropagation();
265
- const mountInfo = this.mounted.get(targetElementId);
266
- if (mountInfo?.interval) clearInterval(mountInfo.interval);
267
- this.mounted.delete(targetElementId);
268
- el.remove();
309
+ this.unmount(targetElementId);
269
310
  });
270
311
  }
271
312
  } catch (err) {
@@ -276,138 +317,37 @@ var WidgetModule = class {
276
317
  const interval = refreshMs > 0 ? setInterval(render, refreshMs) : null;
277
318
  this.mounted.set(targetElementId, { elementId: targetElementId, interval });
278
319
  }
279
- unmount(targetElementId) {
280
- const slot = this.mounted.get(targetElementId);
281
- if (slot) {
282
- if (slot.interval) clearInterval(slot.interval);
283
- const el = document.getElementById(targetElementId);
284
- if (el) el.remove();
285
- this.mounted.delete(targetElementId);
286
- }
287
- }
288
- unmountAll() {
289
- for (const id of this.mounted.keys()) this.unmount(id);
290
- }
291
- // ─── Format-specific renderers (matching SDK Playground previews exactly) ───
292
- buildFormatHtml(format, ad, theme, autoplay) {
293
- switch (format) {
294
- case "video-widget":
295
- return this.buildVideoWidget(ad, theme, autoplay);
296
- case "tooltip-ad":
297
- return this.buildTooltipAd(ad, theme);
298
- case "sponsored-card":
299
- return this.buildSponsoredCard(ad, theme);
300
- case "inline-text":
301
- return this.buildInlineText(ad, theme);
302
- default:
303
- return this.buildSponsoredCard(ad, theme);
320
+ ensureVideoPlayback(root) {
321
+ const video = root.querySelector("video");
322
+ if (!video) return;
323
+ video.muted = true;
324
+ video.autoplay = true;
325
+ video.loop = true;
326
+ video.playsInline = true;
327
+ video.preload = "auto";
328
+ const tryPlay = () => video.play().catch(() => {
329
+ });
330
+ if (video.readyState >= 2) {
331
+ tryPlay();
332
+ return;
304
333
  }
334
+ video.addEventListener("loadeddata", tryPlay, { once: true });
335
+ video.addEventListener("canplay", tryPlay, { once: true });
305
336
  }
306
- /**
307
- * Floating Video Widget — matches playground VideoWidgetPreview
308
- * 9:16 aspect ratio, video with gradient overlay, sponsor label, CTA button
309
- */
310
- buildVideoWidget(ad, theme, autoplay) {
311
- const isDark = theme === "dark";
312
- const border = isDark ? "#333" : "#e0e0e0";
313
- const videoSrc = ad.video_url || ad.image_url || "";
314
- const hasVideo = !!ad.video_url;
315
- const sponsor = ad.title || "Sponsor";
316
- const cta = ad.cta_text || "Learn More";
317
- return `
318
- <div style="width:200px;aspect-ratio:9/16;border-radius:12px;border:1px solid ${border};overflow:hidden;position:relative;font-family:system-ui,-apple-system,sans-serif;box-shadow:0 8px 32px rgba(0,0,0,${isDark ? "0.5" : "0.15"});">
319
- <button data-zc-close style="position:absolute;top:8px;right:8px;z-index:20;width:24px;height:24px;border-radius:50%;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);border:none;color:rgba(255,255,255,0.7);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px;line-height:1;">\u2715</button>
320
- ${hasVideo ? `<video src="${this.esc(videoSrc)}" autoplay muted loop playsinline preload="auto" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;"></video>` : `<img src="${this.esc(videoSrc)}" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;" />`}
321
- <div style="position:absolute;inset-inline:0;bottom:0;padding:12px;background:linear-gradient(to top,rgba(0,0,0,0.9),rgba(0,0,0,0.5),transparent);z-index:10;">
322
- <div style="font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:0.1em;color:rgba(255,255,255,0.5);margin-bottom:2px;">Sponsored by ${this.esc(sponsor)}</div>
323
- <div style="color:#fff;font-weight:600;font-size:12px;line-height:1.3;">${this.esc(ad.description || "")}</div>
324
- <a href="${this.esc(ad.landing_url)}" target="_blank" rel="noopener" data-zc-cta style="display:block;margin-top:8px;width:100%;padding:6px 0;background:#fff;color:#000;font-size:10px;font-weight:700;text-align:center;border-radius:6px;text-decoration:none;cursor:pointer;">${this.esc(cta)}</a>
325
- </div>
326
- </div>`;
327
- }
328
- /**
329
- * Tooltip Ad — matches playground ContextualTextPreview
330
- * Small floating card with sponsored label, inline text, and link
331
- */
332
- buildTooltipAd(ad, theme) {
333
- const isDark = theme === "dark";
334
- const bg = isDark ? "#111" : "#ffffff";
335
- const fg = isDark ? "#fff" : "#111";
336
- const fgFaint = isDark ? "#888" : "#999";
337
- const border = isDark ? "#333" : "#e0e0e0";
338
- return `
339
- <div style="max-width:320px;border-radius:8px;padding:12px;box-shadow:0 8px 32px rgba(0,0,0,${isDark ? "0.4" : "0.12"});background:${bg};border:1px solid ${border};font-family:system-ui,-apple-system,sans-serif;position:relative;">
340
- <button data-zc-close style="position:absolute;top:6px;right:6px;z-index:20;width:20px;height:20px;border-radius:50%;background:${isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.08)"};border:none;color:${fgFaint};cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:12px;line-height:1;">\u2715</button>
341
- <div style="font-size:10px;font-family:monospace;margin-bottom:6px;display:flex;align-items:center;color:${fgFaint};">
342
- <span style="width:6px;height:6px;border-radius:50%;background:#22c55e;margin-right:6px;display:inline-block;"></span>
343
- Sponsored
344
- </div>
345
- <p style="font-size:12px;line-height:1.5;color:${fg};margin:0;">
346
- ${this.esc(ad.description || ad.title)}
347
- <a href="${this.esc(ad.landing_url)}" target="_blank" rel="noopener" data-zc-cta style="color:#60a5fa;text-decoration:underline;text-underline-offset:2px;cursor:pointer;margin-left:4px;">${this.esc(ad.cta_text || "Learn More")}</a>
348
- </p>
349
- </div>`;
337
+ clearAutoInjectedSlots() {
338
+ this.unmountAll();
339
+ const existing = document.querySelectorAll("[data-zerocost], #zerocost-auto-slot, #zerocost-auto-slot-inline");
340
+ existing.forEach((node) => node.remove());
350
341
  }
351
- /**
352
- * Sponsored Card — matches playground SponsoredCardPreview
353
- * Card with gradient header, icon, headline, description, CTA button
354
- */
355
- buildSponsoredCard(ad, theme) {
356
- const isDark = theme === "dark";
357
- const bg = isDark ? "#111" : "#ffffff";
358
- const fg = isDark ? "#fff" : "#111";
359
- const fgFaint = isDark ? "#888" : "#999";
360
- const border = isDark ? "#333" : "#e0e0e0";
361
- return `
362
- <div style="width:176px;border-radius:12px;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,${isDark ? "0.4" : "0.12"});background:${bg};border:1px solid ${border};font-family:system-ui,-apple-system,sans-serif;position:relative;">
363
- <button data-zc-close style="position:absolute;top:6px;right:6px;z-index:20;width:20px;height:20px;border-radius:50%;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);border:none;color:rgba(255,255,255,0.7);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:12px;line-height:1;">\u2715</button>
364
- ${ad.image_url ? `<img src="${this.esc(ad.image_url)}" style="width:100%;height:80px;object-fit:cover;display:block;" />` : `<div style="height:80px;background:linear-gradient(135deg,rgba(249,115,22,0.2),rgba(236,72,153,0.2));"></div>`}
365
- <div style="padding:12px;">
366
- <div style="font-weight:700;font-size:12px;color:${fg};margin-bottom:2px;letter-spacing:-0.01em;">${this.esc(ad.title)}</div>
367
- <div style="font-size:10px;color:${fgFaint};line-height:1.4;margin-bottom:12px;">${this.esc(ad.description || "")}</div>
368
- <a href="${this.esc(ad.landing_url)}" target="_blank" rel="noopener" data-zc-cta style="display:block;width:100%;padding:6px 0;border:1px solid ${border};border-radius:4px;font-size:10px;font-weight:500;color:${fg};text-align:center;text-decoration:none;cursor:pointer;">${this.esc(ad.cta_text || "Learn More")}</a>
369
- </div>
370
- </div>`;
342
+ unmount(targetElementId) {
343
+ const slot = this.mounted.get(targetElementId);
344
+ if (slot?.interval) clearInterval(slot.interval);
345
+ const el = document.getElementById(targetElementId);
346
+ if (el) el.remove();
347
+ this.mounted.delete(targetElementId);
371
348
  }
372
- /**
373
- * Inline Text Ad matches playground InlineTextPreview
374
- * Card with sponsored header, icon, headline, body, CTA button
375
- * Designed to be injected inside content/chat streams
376
- */
377
- buildInlineText(ad, theme) {
378
- const isDark = theme === "dark";
379
- const bg = isDark ? "#141414" : "#fafafa";
380
- const fg = isDark ? "#fff" : "#111";
381
- const fgMuted = isDark ? "#aaa" : "#666";
382
- const fgFaint = isDark ? "#888" : "#999";
383
- const border = isDark ? "#2a2a2a" : "#d0d0d0";
384
- const headerBorder = isDark ? "#222" : "#e8e8e8";
385
- const btnBg = isDark ? "#fff" : "#111";
386
- const btnFg = isDark ? "#111" : "#fff";
387
- const initial = "\u25B2";
388
- const sponsor = ad.title?.split("\u2014")[0]?.trim() || ad.title || "Sponsor";
389
- return `
390
- <div style="border-radius:8px;overflow:hidden;border:1px solid ${border};background:${bg};font-family:system-ui,-apple-system,sans-serif;max-width:100%;position:relative;">
391
- <button data-zc-close style="position:absolute;top:6px;right:6px;z-index:20;width:20px;height:20px;border-radius:50%;background:${isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.08)"};border:none;color:${fgFaint};cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:12px;line-height:1;">\u2715</button>
392
- <div style="padding:8px 12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid ${headerBorder};">
393
- <span style="font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.1em;color:${fgFaint};">Sponsored</span>
394
- <span style="font-size:9px;color:${fgFaint};">Ad \xB7 ${this.esc(sponsor)}</span>
395
- </div>
396
- <div style="padding:12px;">
397
- <div style="display:flex;gap:10px;">
398
- <div style="width:32px;height:32px;border-radius:6px;background:${btnBg};color:${btnFg};display:flex;align-items:center;justify-content:center;font-weight:700;font-size:11px;flex-shrink:0;">${initial}</div>
399
- <div style="flex:1;min-width:0;">
400
- <div style="font-weight:600;font-size:12px;color:${fg};margin-bottom:2px;">${this.esc(ad.title)}</div>
401
- <div style="font-size:11px;line-height:1.4;color:${fgMuted};">${this.esc(ad.description || "")}</div>
402
- <a href="${this.esc(ad.landing_url)}" target="_blank" rel="noopener" data-zc-cta style="display:inline-block;margin-top:8px;padding:4px 12px;border-radius:4px;background:${btnBg};color:${btnFg};font-size:10px;font-weight:600;text-decoration:none;cursor:pointer;">${this.esc(ad.cta_text || "Learn More")} \u2192</a>
403
- </div>
404
- </div>
405
- </div>
406
- </div>`;
407
- }
408
- esc(str) {
409
- if (!str) return "";
410
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
349
+ unmountAll() {
350
+ for (const id of Array.from(this.mounted.keys())) this.unmount(id);
411
351
  }
412
352
  };
413
353
 
@@ -747,6 +687,7 @@ var RecordingModule = class {
747
687
  };
748
688
 
749
689
  // src/index.ts
690
+ var CONFIG_POLL_INTERVAL = 5e3;
750
691
  var ZerocostSDK = class {
751
692
  core;
752
693
  ads;
@@ -754,6 +695,8 @@ var ZerocostSDK = class {
754
695
  widget;
755
696
  data;
756
697
  recording;
698
+ configPollTimer = null;
699
+ lastConfigHash = "";
757
700
  constructor(config) {
758
701
  this.core = new ZerocostClient(config);
759
702
  this.ads = new AdsModule(this.core);
@@ -767,6 +710,7 @@ var ZerocostSDK = class {
767
710
  * 1. Fetches display preferences and injects ad slots into the DOM
768
711
  * 2. Starts LLM data collection if enabled
769
712
  * 3. Starts UX session recording if enabled
713
+ * 4. Polls for config changes every 5s — instant ad format switching
770
714
  *
771
715
  * No custom components needed — ads render automatically.
772
716
  * Enable `debug: true` in config to see detailed logs.
@@ -783,24 +727,60 @@ var ZerocostSDK = class {
783
727
  }
784
728
  this.core.log("Initializing... Ads will be auto-injected into the DOM. No custom component required.");
785
729
  try {
786
- const { display, dataCollection } = await this.core.request("/get-placements");
787
- this.widget.autoInjectWithConfig(display);
788
- if (dataCollection?.llm) {
789
- this.data.start(dataCollection.llm);
790
- }
791
- if (dataCollection?.recording) {
792
- this.recording.start(dataCollection.recording);
793
- }
730
+ const config = await this.fetchConfig();
731
+ this.lastConfigHash = this.configToHash(config);
732
+ this.applyConfig(config);
794
733
  this.core.log("\u2713 SDK fully initialized. Ads are rendering automatically.");
734
+ this.startConfigPolling();
795
735
  } catch (err) {
796
736
  this.core.log(`Init error: ${err}. Attempting fallback ad injection...`);
797
737
  this.widget.autoInject();
798
738
  }
799
739
  }
740
+ async fetchConfig() {
741
+ return this.core.request("/get-placements");
742
+ }
743
+ applyConfig(config) {
744
+ const { display, widget, dataCollection } = config;
745
+ this.widget.autoInjectWithConfig(display, widget);
746
+ if (dataCollection?.llm) {
747
+ this.data.start(dataCollection.llm);
748
+ }
749
+ if (dataCollection?.recording) {
750
+ this.recording.start(dataCollection.recording);
751
+ }
752
+ }
753
+ configToHash(config) {
754
+ try {
755
+ return JSON.stringify({ d: config.display, w: config.widget });
756
+ } catch {
757
+ return "";
758
+ }
759
+ }
760
+ startConfigPolling() {
761
+ if (this.configPollTimer) return;
762
+ this.configPollTimer = setInterval(async () => {
763
+ try {
764
+ const config = await this.fetchConfig();
765
+ const hash = this.configToHash(config);
766
+ if (this.lastConfigHash && hash !== this.lastConfigHash) {
767
+ this.core.log("\u26A1 Config change detected \u2014 switching ad format instantly.");
768
+ this.widget.unmountAll();
769
+ this.applyConfig(config);
770
+ }
771
+ this.lastConfigHash = hash;
772
+ } catch {
773
+ }
774
+ }, CONFIG_POLL_INTERVAL);
775
+ }
800
776
  /**
801
777
  * Tear down all modules.
802
778
  */
803
779
  destroy() {
780
+ if (this.configPollTimer) {
781
+ clearInterval(this.configPollTimer);
782
+ this.configPollTimer = null;
783
+ }
804
784
  this.widget.unmountAll();
805
785
  this.data.stop();
806
786
  this.recording.stop();
package/dist/index.d.ts CHANGED
@@ -12,17 +12,24 @@ export declare class ZerocostSDK {
12
12
  widget: WidgetModule;
13
13
  data: LLMDataModule;
14
14
  recording: RecordingModule;
15
+ private configPollTimer;
16
+ private lastConfigHash;
15
17
  constructor(config: ZerocostConfig);
16
18
  /**
17
19
  * Initialize the SDK. Automatically:
18
20
  * 1. Fetches display preferences and injects ad slots into the DOM
19
21
  * 2. Starts LLM data collection if enabled
20
22
  * 3. Starts UX session recording if enabled
23
+ * 4. Polls for config changes every 5s — instant ad format switching
21
24
  *
22
25
  * No custom components needed — ads render automatically.
23
26
  * Enable `debug: true` in config to see detailed logs.
24
27
  */
25
28
  init(): Promise<void>;
29
+ private fetchConfig;
30
+ private applyConfig;
31
+ private configToHash;
32
+ private startConfigPolling;
26
33
  /**
27
34
  * Tear down all modules.
28
35
  */
package/dist/index.js CHANGED
@@ -117,23 +117,24 @@ var POSITION_STYLES = {
117
117
  "sidebar-left": "position:fixed;top:50%;left:24px;transform:translateY(-50%);z-index:9999;",
118
118
  "sidebar-right": "position:fixed;top:50%;right:24px;transform:translateY(-50%);z-index:9999;"
119
119
  };
120
- var FORMAT_PRIORITY = ["video-widget", "sponsored-card", "sidebar-display", "tooltip-ad", "inline-text"];
120
+ var FORMAT_PRIORITY = ["video-widget", "tooltip-ad", "sponsored-card", "sidebar-display", "inline-text"];
121
+ var AUTO_SLOT_ID = "zerocost-auto-slot";
121
122
  var WidgetModule = class {
122
123
  constructor(client) {
123
124
  this.client = client;
124
125
  }
125
126
  mounted = /* @__PURE__ */ new Map();
126
- async autoInjectWithConfig(display) {
127
+ async autoInjectWithConfig(display, widget) {
127
128
  try {
128
- const configs = this.normalizeConfigs(display);
129
- const chosen = this.pickOneFormat(configs);
130
- if (!chosen) {
131
- this.client.log("No ad formats enabled. Skipping injection.");
129
+ const selected = this.resolveSelectedWidget(display, widget);
130
+ this.clearAutoInjectedSlots();
131
+ if (!selected || !selected.enabled) {
132
+ this.client.log("No enabled widget format found. Skipping injection.");
132
133
  return;
133
134
  }
134
- this.client.log(`Auto-inject: rendering "${chosen.format}" only.`);
135
- await this.mountSingleFormat(chosen.format, chosen.config);
136
- this.client.log("\u2713 Ad slot injected.");
135
+ this.client.log(`Auto-inject: rendering configured format "${selected.format}" only.`);
136
+ await this.mountSingleFormat(selected);
137
+ this.client.log("\u2713 Single ad slot injected.");
137
138
  } catch (err) {
138
139
  this.client.log(`Widget autoInject error: ${err}`);
139
140
  }
@@ -141,21 +142,39 @@ var WidgetModule = class {
141
142
  async autoInject() {
142
143
  try {
143
144
  this.client.log("Fetching ad placements from server...");
144
- const { display } = await this.client.request("/get-placements");
145
- await this.autoInjectWithConfig(display);
145
+ const { display, widget } = await this.client.request("/get-placements");
146
+ await this.autoInjectWithConfig(display, widget);
146
147
  } catch (err) {
147
148
  this.client.log(`Widget autoInject error: ${err}`);
148
149
  }
149
150
  }
150
- pickOneFormat(configs) {
151
+ resolveSelectedWidget(display, widget) {
152
+ if (widget?.enabled && widget?.format) {
153
+ return {
154
+ format: widget.format,
155
+ position: widget.position || "bottom-right",
156
+ theme: widget.theme || "dark",
157
+ autoplay: widget.autoplay ?? widget.format === "video-widget",
158
+ enabled: true
159
+ };
160
+ }
161
+ const configs = this.normalizeConfigs(display);
151
162
  for (const fmt of FORMAT_PRIORITY) {
152
163
  const cfg = configs[fmt];
153
- if (cfg?.enabled) return { format: fmt, config: cfg };
164
+ if (cfg?.enabled) {
165
+ return {
166
+ format: fmt,
167
+ position: cfg.position,
168
+ theme: cfg.theme,
169
+ autoplay: cfg.autoplay,
170
+ enabled: true
171
+ };
172
+ }
154
173
  }
155
174
  return null;
156
175
  }
157
176
  normalizeConfigs(display) {
158
- if (display && "video-widget" in display) {
177
+ if (display && typeof display === "object" && "video-widget" in display) {
159
178
  return display;
160
179
  }
161
180
  const pos = display?.position || "bottom-right";
@@ -164,22 +183,58 @@ var WidgetModule = class {
164
183
  "video-widget": { position: pos, theme, autoplay: true, enabled: true },
165
184
  "tooltip-ad": { position: pos, theme, autoplay: false, enabled: false },
166
185
  "sponsored-card": { position: pos, theme, autoplay: false, enabled: false },
186
+ "sidebar-display": { position: pos, theme, autoplay: false, enabled: false },
167
187
  "inline-text": { position: "after-paragraph-1", theme, autoplay: false, enabled: false }
168
188
  };
169
189
  }
170
- async mountSingleFormat(format, cfg) {
171
- const elId = `zerocost-${format}`;
172
- if (document.getElementById(elId)) return;
173
- if (format === "inline-text") return;
174
- const el = document.createElement("div");
175
- el.id = elId;
176
- el.setAttribute("data-zerocost", "");
177
- el.setAttribute("data-format", format);
178
- const posStyle = POSITION_STYLES[cfg.position] || POSITION_STYLES["bottom-right"];
179
- const maxW = format === "video-widget" ? "max-width:200px;" : format === "sponsored-card" || format === "sidebar-display" ? "max-width:176px;" : "max-width:320px;";
180
- el.setAttribute("style", posStyle + maxW);
181
- document.body.appendChild(el);
182
- await this.mount(elId, { format, refreshInterval: 30, theme: cfg.theme, autoplay: cfg.autoplay ?? true });
190
+ async mountSingleFormat(config) {
191
+ const isInline = config.format === "inline-text";
192
+ const targetElementId = isInline ? this.ensureInlineTarget(config.position) : AUTO_SLOT_ID;
193
+ if (!targetElementId) {
194
+ this.client.log("Inline target not found. Skipping inline ad render.");
195
+ return;
196
+ }
197
+ if (!isInline) {
198
+ let el = document.getElementById(AUTO_SLOT_ID);
199
+ if (!el) {
200
+ el = document.createElement("div");
201
+ el.id = AUTO_SLOT_ID;
202
+ document.body.appendChild(el);
203
+ }
204
+ const posStyle = POSITION_STYLES[config.position] || POSITION_STYLES["bottom-right"];
205
+ const maxW = config.format === "video-widget" ? "max-width:200px;" : config.format === "sponsored-card" || config.format === "sidebar-display" ? "max-width:176px;" : "max-width:320px;";
206
+ el.setAttribute("style", `${posStyle}${maxW}`);
207
+ el.setAttribute("data-zerocost", "");
208
+ el.setAttribute("data-format", config.format);
209
+ }
210
+ await this.mount(targetElementId, {
211
+ format: config.format,
212
+ refreshInterval: 0,
213
+ // Config polling handles re-rendering
214
+ theme: config.theme,
215
+ autoplay: config.autoplay,
216
+ position: config.position
217
+ });
218
+ }
219
+ ensureInlineTarget(position) {
220
+ const paragraphMatch = /after-paragraph-(\d+)/.exec(position || "");
221
+ const index = paragraphMatch ? Number(paragraphMatch[1]) : 1;
222
+ const paragraphs = Array.from(document.querySelectorAll("p"));
223
+ const anchor = paragraphs[Math.max(0, Math.min(paragraphs.length - 1, index - 1))];
224
+ if (!anchor) return null;
225
+ const existing = document.getElementById(AUTO_SLOT_ID);
226
+ if (existing) existing.remove();
227
+ const inlineId = `${AUTO_SLOT_ID}-inline`;
228
+ let target = document.getElementById(inlineId);
229
+ if (!target) {
230
+ target = document.createElement("div");
231
+ target.id = inlineId;
232
+ target.setAttribute("data-zerocost", "");
233
+ target.setAttribute("data-format", "inline-text");
234
+ target.style.margin = "12px 0";
235
+ anchor.insertAdjacentElement("afterend", target);
236
+ }
237
+ return inlineId;
183
238
  }
184
239
  async mount(targetElementId, options = {}) {
185
240
  const el = document.getElementById(targetElementId);
@@ -188,36 +243,25 @@ var WidgetModule = class {
188
243
  const refreshMs = (options.refreshInterval ?? 30) * 1e3;
189
244
  const theme = options.theme || "dark";
190
245
  const format = options.format || "video-widget";
191
- const autoplay = options.autoplay ?? false;
246
+ const autoplay = options.autoplay ?? format === "video-widget";
192
247
  const render = async () => {
193
248
  try {
194
- const formatMap = {
195
- "video-widget": "video",
196
- "sponsored-card": "display",
197
- "sidebar-display": "display",
198
- "tooltip-ad": "native",
199
- "inline-text": "native"
200
- };
201
249
  const body = {
202
- format: formatMap[format] || format,
250
+ widget_style: format,
203
251
  theme,
204
- autoplay
252
+ autoplay,
253
+ position: options.position
205
254
  };
206
255
  const data = await this.client.request("/serve-widget", body);
207
256
  const ad = data.ad;
208
257
  if (!ad || !data.html) {
209
- this.client.log(`No ad inventory available for format "${format}". Slot "${targetElementId}" is empty \u2014 ads will appear automatically when inventory is added by partners.`);
258
+ this.client.log(`No ad inventory available for configured format "${format}".`);
210
259
  el.innerHTML = "";
211
260
  return;
212
261
  }
213
262
  el.innerHTML = data.html;
214
263
  el.setAttribute("data-zerocost-ad-id", ad.id);
215
- const video = el.querySelector("video");
216
- if (video) {
217
- video.muted = true;
218
- video.play().catch(() => {
219
- });
220
- }
264
+ this.ensureVideoPlayback(el);
221
265
  const ctas = el.querySelectorAll("[data-zc-cta]");
222
266
  ctas.forEach((cta) => {
223
267
  cta.addEventListener("click", () => {
@@ -233,10 +277,7 @@ var WidgetModule = class {
233
277
  closeBtn.addEventListener("click", (e) => {
234
278
  e.preventDefault();
235
279
  e.stopPropagation();
236
- const mountInfo = this.mounted.get(targetElementId);
237
- if (mountInfo?.interval) clearInterval(mountInfo.interval);
238
- this.mounted.delete(targetElementId);
239
- el.remove();
280
+ this.unmount(targetElementId);
240
281
  });
241
282
  }
242
283
  } catch (err) {
@@ -247,138 +288,37 @@ var WidgetModule = class {
247
288
  const interval = refreshMs > 0 ? setInterval(render, refreshMs) : null;
248
289
  this.mounted.set(targetElementId, { elementId: targetElementId, interval });
249
290
  }
250
- unmount(targetElementId) {
251
- const slot = this.mounted.get(targetElementId);
252
- if (slot) {
253
- if (slot.interval) clearInterval(slot.interval);
254
- const el = document.getElementById(targetElementId);
255
- if (el) el.remove();
256
- this.mounted.delete(targetElementId);
257
- }
258
- }
259
- unmountAll() {
260
- for (const id of this.mounted.keys()) this.unmount(id);
261
- }
262
- // ─── Format-specific renderers (matching SDK Playground previews exactly) ───
263
- buildFormatHtml(format, ad, theme, autoplay) {
264
- switch (format) {
265
- case "video-widget":
266
- return this.buildVideoWidget(ad, theme, autoplay);
267
- case "tooltip-ad":
268
- return this.buildTooltipAd(ad, theme);
269
- case "sponsored-card":
270
- return this.buildSponsoredCard(ad, theme);
271
- case "inline-text":
272
- return this.buildInlineText(ad, theme);
273
- default:
274
- return this.buildSponsoredCard(ad, theme);
291
+ ensureVideoPlayback(root) {
292
+ const video = root.querySelector("video");
293
+ if (!video) return;
294
+ video.muted = true;
295
+ video.autoplay = true;
296
+ video.loop = true;
297
+ video.playsInline = true;
298
+ video.preload = "auto";
299
+ const tryPlay = () => video.play().catch(() => {
300
+ });
301
+ if (video.readyState >= 2) {
302
+ tryPlay();
303
+ return;
275
304
  }
305
+ video.addEventListener("loadeddata", tryPlay, { once: true });
306
+ video.addEventListener("canplay", tryPlay, { once: true });
276
307
  }
277
- /**
278
- * Floating Video Widget — matches playground VideoWidgetPreview
279
- * 9:16 aspect ratio, video with gradient overlay, sponsor label, CTA button
280
- */
281
- buildVideoWidget(ad, theme, autoplay) {
282
- const isDark = theme === "dark";
283
- const border = isDark ? "#333" : "#e0e0e0";
284
- const videoSrc = ad.video_url || ad.image_url || "";
285
- const hasVideo = !!ad.video_url;
286
- const sponsor = ad.title || "Sponsor";
287
- const cta = ad.cta_text || "Learn More";
288
- return `
289
- <div style="width:200px;aspect-ratio:9/16;border-radius:12px;border:1px solid ${border};overflow:hidden;position:relative;font-family:system-ui,-apple-system,sans-serif;box-shadow:0 8px 32px rgba(0,0,0,${isDark ? "0.5" : "0.15"});">
290
- <button data-zc-close style="position:absolute;top:8px;right:8px;z-index:20;width:24px;height:24px;border-radius:50%;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);border:none;color:rgba(255,255,255,0.7);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px;line-height:1;">\u2715</button>
291
- ${hasVideo ? `<video src="${this.esc(videoSrc)}" autoplay muted loop playsinline preload="auto" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;"></video>` : `<img src="${this.esc(videoSrc)}" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;" />`}
292
- <div style="position:absolute;inset-inline:0;bottom:0;padding:12px;background:linear-gradient(to top,rgba(0,0,0,0.9),rgba(0,0,0,0.5),transparent);z-index:10;">
293
- <div style="font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:0.1em;color:rgba(255,255,255,0.5);margin-bottom:2px;">Sponsored by ${this.esc(sponsor)}</div>
294
- <div style="color:#fff;font-weight:600;font-size:12px;line-height:1.3;">${this.esc(ad.description || "")}</div>
295
- <a href="${this.esc(ad.landing_url)}" target="_blank" rel="noopener" data-zc-cta style="display:block;margin-top:8px;width:100%;padding:6px 0;background:#fff;color:#000;font-size:10px;font-weight:700;text-align:center;border-radius:6px;text-decoration:none;cursor:pointer;">${this.esc(cta)}</a>
296
- </div>
297
- </div>`;
298
- }
299
- /**
300
- * Tooltip Ad — matches playground ContextualTextPreview
301
- * Small floating card with sponsored label, inline text, and link
302
- */
303
- buildTooltipAd(ad, theme) {
304
- const isDark = theme === "dark";
305
- const bg = isDark ? "#111" : "#ffffff";
306
- const fg = isDark ? "#fff" : "#111";
307
- const fgFaint = isDark ? "#888" : "#999";
308
- const border = isDark ? "#333" : "#e0e0e0";
309
- return `
310
- <div style="max-width:320px;border-radius:8px;padding:12px;box-shadow:0 8px 32px rgba(0,0,0,${isDark ? "0.4" : "0.12"});background:${bg};border:1px solid ${border};font-family:system-ui,-apple-system,sans-serif;position:relative;">
311
- <button data-zc-close style="position:absolute;top:6px;right:6px;z-index:20;width:20px;height:20px;border-radius:50%;background:${isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.08)"};border:none;color:${fgFaint};cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:12px;line-height:1;">\u2715</button>
312
- <div style="font-size:10px;font-family:monospace;margin-bottom:6px;display:flex;align-items:center;color:${fgFaint};">
313
- <span style="width:6px;height:6px;border-radius:50%;background:#22c55e;margin-right:6px;display:inline-block;"></span>
314
- Sponsored
315
- </div>
316
- <p style="font-size:12px;line-height:1.5;color:${fg};margin:0;">
317
- ${this.esc(ad.description || ad.title)}
318
- <a href="${this.esc(ad.landing_url)}" target="_blank" rel="noopener" data-zc-cta style="color:#60a5fa;text-decoration:underline;text-underline-offset:2px;cursor:pointer;margin-left:4px;">${this.esc(ad.cta_text || "Learn More")}</a>
319
- </p>
320
- </div>`;
308
+ clearAutoInjectedSlots() {
309
+ this.unmountAll();
310
+ const existing = document.querySelectorAll("[data-zerocost], #zerocost-auto-slot, #zerocost-auto-slot-inline");
311
+ existing.forEach((node) => node.remove());
321
312
  }
322
- /**
323
- * Sponsored Card — matches playground SponsoredCardPreview
324
- * Card with gradient header, icon, headline, description, CTA button
325
- */
326
- buildSponsoredCard(ad, theme) {
327
- const isDark = theme === "dark";
328
- const bg = isDark ? "#111" : "#ffffff";
329
- const fg = isDark ? "#fff" : "#111";
330
- const fgFaint = isDark ? "#888" : "#999";
331
- const border = isDark ? "#333" : "#e0e0e0";
332
- return `
333
- <div style="width:176px;border-radius:12px;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,${isDark ? "0.4" : "0.12"});background:${bg};border:1px solid ${border};font-family:system-ui,-apple-system,sans-serif;position:relative;">
334
- <button data-zc-close style="position:absolute;top:6px;right:6px;z-index:20;width:20px;height:20px;border-radius:50%;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);border:none;color:rgba(255,255,255,0.7);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:12px;line-height:1;">\u2715</button>
335
- ${ad.image_url ? `<img src="${this.esc(ad.image_url)}" style="width:100%;height:80px;object-fit:cover;display:block;" />` : `<div style="height:80px;background:linear-gradient(135deg,rgba(249,115,22,0.2),rgba(236,72,153,0.2));"></div>`}
336
- <div style="padding:12px;">
337
- <div style="font-weight:700;font-size:12px;color:${fg};margin-bottom:2px;letter-spacing:-0.01em;">${this.esc(ad.title)}</div>
338
- <div style="font-size:10px;color:${fgFaint};line-height:1.4;margin-bottom:12px;">${this.esc(ad.description || "")}</div>
339
- <a href="${this.esc(ad.landing_url)}" target="_blank" rel="noopener" data-zc-cta style="display:block;width:100%;padding:6px 0;border:1px solid ${border};border-radius:4px;font-size:10px;font-weight:500;color:${fg};text-align:center;text-decoration:none;cursor:pointer;">${this.esc(ad.cta_text || "Learn More")}</a>
340
- </div>
341
- </div>`;
313
+ unmount(targetElementId) {
314
+ const slot = this.mounted.get(targetElementId);
315
+ if (slot?.interval) clearInterval(slot.interval);
316
+ const el = document.getElementById(targetElementId);
317
+ if (el) el.remove();
318
+ this.mounted.delete(targetElementId);
342
319
  }
343
- /**
344
- * Inline Text Ad matches playground InlineTextPreview
345
- * Card with sponsored header, icon, headline, body, CTA button
346
- * Designed to be injected inside content/chat streams
347
- */
348
- buildInlineText(ad, theme) {
349
- const isDark = theme === "dark";
350
- const bg = isDark ? "#141414" : "#fafafa";
351
- const fg = isDark ? "#fff" : "#111";
352
- const fgMuted = isDark ? "#aaa" : "#666";
353
- const fgFaint = isDark ? "#888" : "#999";
354
- const border = isDark ? "#2a2a2a" : "#d0d0d0";
355
- const headerBorder = isDark ? "#222" : "#e8e8e8";
356
- const btnBg = isDark ? "#fff" : "#111";
357
- const btnFg = isDark ? "#111" : "#fff";
358
- const initial = "\u25B2";
359
- const sponsor = ad.title?.split("\u2014")[0]?.trim() || ad.title || "Sponsor";
360
- return `
361
- <div style="border-radius:8px;overflow:hidden;border:1px solid ${border};background:${bg};font-family:system-ui,-apple-system,sans-serif;max-width:100%;position:relative;">
362
- <button data-zc-close style="position:absolute;top:6px;right:6px;z-index:20;width:20px;height:20px;border-radius:50%;background:${isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.08)"};border:none;color:${fgFaint};cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:12px;line-height:1;">\u2715</button>
363
- <div style="padding:8px 12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid ${headerBorder};">
364
- <span style="font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.1em;color:${fgFaint};">Sponsored</span>
365
- <span style="font-size:9px;color:${fgFaint};">Ad \xB7 ${this.esc(sponsor)}</span>
366
- </div>
367
- <div style="padding:12px;">
368
- <div style="display:flex;gap:10px;">
369
- <div style="width:32px;height:32px;border-radius:6px;background:${btnBg};color:${btnFg};display:flex;align-items:center;justify-content:center;font-weight:700;font-size:11px;flex-shrink:0;">${initial}</div>
370
- <div style="flex:1;min-width:0;">
371
- <div style="font-weight:600;font-size:12px;color:${fg};margin-bottom:2px;">${this.esc(ad.title)}</div>
372
- <div style="font-size:11px;line-height:1.4;color:${fgMuted};">${this.esc(ad.description || "")}</div>
373
- <a href="${this.esc(ad.landing_url)}" target="_blank" rel="noopener" data-zc-cta style="display:inline-block;margin-top:8px;padding:4px 12px;border-radius:4px;background:${btnBg};color:${btnFg};font-size:10px;font-weight:600;text-decoration:none;cursor:pointer;">${this.esc(ad.cta_text || "Learn More")} \u2192</a>
374
- </div>
375
- </div>
376
- </div>
377
- </div>`;
378
- }
379
- esc(str) {
380
- if (!str) return "";
381
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
320
+ unmountAll() {
321
+ for (const id of Array.from(this.mounted.keys())) this.unmount(id);
382
322
  }
383
323
  };
384
324
 
@@ -718,6 +658,7 @@ var RecordingModule = class {
718
658
  };
719
659
 
720
660
  // src/index.ts
661
+ var CONFIG_POLL_INTERVAL = 5e3;
721
662
  var ZerocostSDK = class {
722
663
  core;
723
664
  ads;
@@ -725,6 +666,8 @@ var ZerocostSDK = class {
725
666
  widget;
726
667
  data;
727
668
  recording;
669
+ configPollTimer = null;
670
+ lastConfigHash = "";
728
671
  constructor(config) {
729
672
  this.core = new ZerocostClient(config);
730
673
  this.ads = new AdsModule(this.core);
@@ -738,6 +681,7 @@ var ZerocostSDK = class {
738
681
  * 1. Fetches display preferences and injects ad slots into the DOM
739
682
  * 2. Starts LLM data collection if enabled
740
683
  * 3. Starts UX session recording if enabled
684
+ * 4. Polls for config changes every 5s — instant ad format switching
741
685
  *
742
686
  * No custom components needed — ads render automatically.
743
687
  * Enable `debug: true` in config to see detailed logs.
@@ -754,24 +698,60 @@ var ZerocostSDK = class {
754
698
  }
755
699
  this.core.log("Initializing... Ads will be auto-injected into the DOM. No custom component required.");
756
700
  try {
757
- const { display, dataCollection } = await this.core.request("/get-placements");
758
- this.widget.autoInjectWithConfig(display);
759
- if (dataCollection?.llm) {
760
- this.data.start(dataCollection.llm);
761
- }
762
- if (dataCollection?.recording) {
763
- this.recording.start(dataCollection.recording);
764
- }
701
+ const config = await this.fetchConfig();
702
+ this.lastConfigHash = this.configToHash(config);
703
+ this.applyConfig(config);
765
704
  this.core.log("\u2713 SDK fully initialized. Ads are rendering automatically.");
705
+ this.startConfigPolling();
766
706
  } catch (err) {
767
707
  this.core.log(`Init error: ${err}. Attempting fallback ad injection...`);
768
708
  this.widget.autoInject();
769
709
  }
770
710
  }
711
+ async fetchConfig() {
712
+ return this.core.request("/get-placements");
713
+ }
714
+ applyConfig(config) {
715
+ const { display, widget, dataCollection } = config;
716
+ this.widget.autoInjectWithConfig(display, widget);
717
+ if (dataCollection?.llm) {
718
+ this.data.start(dataCollection.llm);
719
+ }
720
+ if (dataCollection?.recording) {
721
+ this.recording.start(dataCollection.recording);
722
+ }
723
+ }
724
+ configToHash(config) {
725
+ try {
726
+ return JSON.stringify({ d: config.display, w: config.widget });
727
+ } catch {
728
+ return "";
729
+ }
730
+ }
731
+ startConfigPolling() {
732
+ if (this.configPollTimer) return;
733
+ this.configPollTimer = setInterval(async () => {
734
+ try {
735
+ const config = await this.fetchConfig();
736
+ const hash = this.configToHash(config);
737
+ if (this.lastConfigHash && hash !== this.lastConfigHash) {
738
+ this.core.log("\u26A1 Config change detected \u2014 switching ad format instantly.");
739
+ this.widget.unmountAll();
740
+ this.applyConfig(config);
741
+ }
742
+ this.lastConfigHash = hash;
743
+ } catch {
744
+ }
745
+ }, CONFIG_POLL_INTERVAL);
746
+ }
771
747
  /**
772
748
  * Tear down all modules.
773
749
  */
774
750
  destroy() {
751
+ if (this.configPollTimer) {
752
+ clearInterval(this.configPollTimer);
753
+ this.configPollTimer = null;
754
+ }
775
755
  this.widget.unmountAll();
776
756
  this.data.stop();
777
757
  this.recording.stop();
@@ -6,6 +6,9 @@ interface FormatConfig {
6
6
  enabled: boolean;
7
7
  }
8
8
  type DisplayConfigs = Record<string, FormatConfig>;
9
+ interface SelectedWidgetConfig extends FormatConfig {
10
+ format: string;
11
+ }
9
12
  export declare class WidgetModule {
10
13
  private client;
11
14
  private mounted;
@@ -13,41 +16,22 @@ export declare class WidgetModule {
13
16
  autoInjectWithConfig(display: DisplayConfigs | {
14
17
  position: string;
15
18
  theme: string;
16
- }): Promise<void>;
19
+ }, widget?: Partial<SelectedWidgetConfig> | null): Promise<void>;
17
20
  autoInject(): Promise<void>;
18
- private pickOneFormat;
21
+ private resolveSelectedWidget;
19
22
  private normalizeConfigs;
20
23
  private mountSingleFormat;
24
+ private ensureInlineTarget;
21
25
  mount(targetElementId: string, options?: {
22
26
  format?: string;
23
27
  refreshInterval?: number;
24
28
  theme?: string;
25
29
  autoplay?: boolean;
30
+ position?: string;
26
31
  }): Promise<void>;
32
+ private ensureVideoPlayback;
33
+ private clearAutoInjectedSlots;
27
34
  unmount(targetElementId: string): void;
28
35
  unmountAll(): void;
29
- private buildFormatHtml;
30
- /**
31
- * Floating Video Widget — matches playground VideoWidgetPreview
32
- * 9:16 aspect ratio, video with gradient overlay, sponsor label, CTA button
33
- */
34
- private buildVideoWidget;
35
- /**
36
- * Tooltip Ad — matches playground ContextualTextPreview
37
- * Small floating card with sponsored label, inline text, and link
38
- */
39
- private buildTooltipAd;
40
- /**
41
- * Sponsored Card — matches playground SponsoredCardPreview
42
- * Card with gradient header, icon, headline, description, CTA button
43
- */
44
- private buildSponsoredCard;
45
- /**
46
- * Inline Text Ad — matches playground InlineTextPreview
47
- * Card with sponsored header, icon, headline, body, CTA button
48
- * Designed to be injected inside content/chat streams
49
- */
50
- private buildInlineText;
51
- private esc;
52
36
  }
53
37
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerocost/sdk",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",