@kywi-software/js 0.13.0 → 0.15.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/kywi.js CHANGED
@@ -21,14 +21,22 @@ var Kywi = (() => {
21
21
  // src/index.ts
22
22
  var src_exports = {};
23
23
  __export(src_exports, {
24
+ AUDIENCE_PIN_COOKIE: () => AUDIENCE_PIN_COOKIE,
25
+ CONSENT_COOKIE: () => CONSENT_COOKIE,
26
+ COOKIE_CONSENT_CATEGORY: () => COOKIE_CONSENT_CATEGORY,
24
27
  Kywi: () => Kywi,
25
28
  NAV_BREAKPOINT: () => NAV_BREAKPOINT,
29
+ SWITCHER_PIN_PREFIX: () => SWITCHER_PIN_PREFIX,
26
30
  bootAudienceEngine: () => bootAudienceEngine,
27
31
  bootExperiments: () => bootExperiments,
32
+ clearDeniedCookies: () => clearDeniedCookies,
28
33
  enhanceNav: () => enhanceNav,
34
+ getConsentState: () => getConsentState,
35
+ hasConsent: () => hasConsent,
29
36
  initNavMenus: () => initNavMenus,
30
37
  openTransparencyPanel: () => openTransparencyPanel,
31
- renderTransparencyPanel: () => renderTransparencyPanel
38
+ renderTransparencyPanel: () => renderTransparencyPanel,
39
+ setConsent: () => setConsent
32
40
  });
33
41
 
34
42
  // src/context.ts
@@ -904,7 +912,9 @@ var Kywi = (() => {
904
912
  el.removeAttribute("aria-busy");
905
913
  const skeleton = el.querySelector(".kywi-variant-skeleton");
906
914
  if (skeleton) skeleton.remove();
907
- const target = winningAudienceId ?? "default";
915
+ const requested = winningAudienceId ?? "default";
916
+ const hasMatch = arms.some((child) => child.getAttribute("data-kywi-variant") === requested);
917
+ const target = hasMatch ? requested : "default";
908
918
  el.setAttribute("data-variant", target);
909
919
  for (const child of arms) {
910
920
  const h = child;
@@ -999,14 +1009,26 @@ var Kywi = (() => {
999
1009
  async function pinAudience(audienceId, opts = {}) {
1000
1010
  const apiBase = opts.apiBase ?? DEFAULT_API_BASE;
1001
1011
  try {
1002
- await fetch(`${apiBase}/kywi/audience`, {
1012
+ const res = await fetch(`${apiBase}/kywi/audience`, {
1003
1013
  method: "POST",
1004
1014
  headers: { "Content-Type": "application/json", ...opts.csrfToken ? { "X-CSRF-Token": opts.csrfToken } : {} },
1005
- body: JSON.stringify({ audienceId })
1015
+ body: JSON.stringify({ audienceId, source: "switcher" })
1006
1016
  });
1017
+ if (!res.ok) return false;
1018
+ const body = await res.json().catch(() => null);
1019
+ return body?.data?.persisted !== false;
1007
1020
  } catch {
1021
+ return false;
1008
1022
  }
1009
1023
  }
1024
+ function showSwitcherError(group) {
1025
+ group.parentElement?.querySelector(".kywi-transparency-switcher-error")?.remove();
1026
+ const note = document.createElement("p");
1027
+ note.className = "kywi-transparency-switcher-error";
1028
+ note.setAttribute("role", "status");
1029
+ note.textContent = "Couldn't save that choice. It won't be remembered on the next page.";
1030
+ group.parentElement?.appendChild(note);
1031
+ }
1010
1032
  function reload() {
1011
1033
  try {
1012
1034
  window.location.reload();
@@ -1111,7 +1133,12 @@ var Kywi = (() => {
1111
1133
  const isCurrent = option.id === state.audienceId;
1112
1134
  const optionBtn = button("kywi-transparency-option", option.name, () => {
1113
1135
  if (isCurrent) return;
1114
- void pinAudience(option.id, { apiBase: state.apiBase, csrfToken: state.csrfToken }).then(reload);
1136
+ void pinAudience(option.id, { apiBase: state.apiBase, csrfToken: state.csrfToken }).then(
1137
+ (persisted) => {
1138
+ if (persisted) reload();
1139
+ else showSwitcherError(group);
1140
+ }
1141
+ );
1115
1142
  });
1116
1143
  optionBtn.dataset.kywiAudienceId = option.id;
1117
1144
  optionBtn.setAttribute("aria-pressed", String(isCurrent));
@@ -1242,6 +1269,74 @@ var Kywi = (() => {
1242
1269
  return { audienceId: m("audience-id"), experimentId: m("experiment-id"), variantId: m("variant-id"), visitorId: m("visitor-id") ?? "" };
1243
1270
  }
1244
1271
 
1272
+ // src/consent.ts
1273
+ var CONSENT_COOKIE = "kywi_consent";
1274
+ var AUDIENCE_PIN_COOKIE = "kywi_audience";
1275
+ var SWITCHER_PIN_PREFIX = "switcher:";
1276
+ var COOKIE_CONSENT_CATEGORY = {
1277
+ kywi_consent: "essential",
1278
+ kywi_optout: "essential",
1279
+ kywi_preview_init: "essential",
1280
+ kywi_signals: "personalization",
1281
+ kywi_visitor: "personalization",
1282
+ kywi_utm: "personalization",
1283
+ kywi_audience: "personalization",
1284
+ kywi_known: "personalization"
1285
+ };
1286
+ function getConsentState() {
1287
+ const cookies = readCookies();
1288
+ const raw = cookies.get(CONSENT_COOKIE);
1289
+ if (!raw) return null;
1290
+ let parsed;
1291
+ try {
1292
+ parsed = JSON.parse(raw);
1293
+ } catch {
1294
+ return null;
1295
+ }
1296
+ if (typeof parsed !== "object" || parsed === null) return null;
1297
+ const obj = parsed;
1298
+ return {
1299
+ essential: true,
1300
+ analytics: obj.analytics === true,
1301
+ marketing: obj.marketing === true,
1302
+ personalization: obj.personalization === true
1303
+ };
1304
+ }
1305
+ function setConsent(prefs) {
1306
+ const state = {
1307
+ essential: true,
1308
+ analytics: prefs.analytics ?? false,
1309
+ marketing: prefs.marketing ?? false,
1310
+ personalization: prefs.personalization ?? false
1311
+ };
1312
+ setCookie(CONSENT_COOKIE, JSON.stringify(state), 31536e3);
1313
+ clearDeniedCookies(state);
1314
+ }
1315
+ function clearDeniedCookies(state) {
1316
+ if (typeof document === "undefined") return [];
1317
+ const cookies = readCookies();
1318
+ const deleted = [];
1319
+ for (const [name, category] of Object.entries(COOKIE_CONSENT_CATEGORY)) {
1320
+ if (category === "essential") continue;
1321
+ if (state && state[category] === true) continue;
1322
+ if (isDeliberatePreference(name, cookies.get(name))) continue;
1323
+ deleteCookie(name);
1324
+ deleted.push(name);
1325
+ }
1326
+ return deleted;
1327
+ }
1328
+ function isDeliberatePreference(name, value) {
1329
+ if (name !== AUDIENCE_PIN_COOKIE || value === void 0) return false;
1330
+ return value.startsWith(SWITCHER_PIN_PREFIX) && value.length > SWITCHER_PIN_PREFIX.length;
1331
+ }
1332
+ function hasConsent(category, options = {}) {
1333
+ if (options.requireConsent === false) return true;
1334
+ if (category === "essential") return true;
1335
+ const state = getConsentState();
1336
+ if (!state) return false;
1337
+ return state[category] === true;
1338
+ }
1339
+
1245
1340
  // src/audience/adapter-runner.ts
1246
1341
  var CACHE_PREFIX = "kywi_adapter_";
1247
1342
  function getCached(adapterId) {
@@ -1587,9 +1682,12 @@ var Kywi = (() => {
1587
1682
  return;
1588
1683
  }
1589
1684
  const cookies = readCookies();
1685
+ const consentGate = config.requireConsent === void 0 ? {} : { requireConsent: config.requireConsent };
1686
+ const mayPersonalize = hasConsent("personalization", consentGate);
1687
+ const storedVisitorId = mayPersonalize ? cookies.get(COOKIE_NAMES.VISITOR) : void 0;
1590
1688
  let merged = {
1591
1689
  ...serverSignals,
1592
- identity: { visitorId: serverMeta.visitorId || cookies.get(COOKIE_NAMES.VISITOR) || "", isKnown: cookies.has(COOKIE_NAMES.KNOWN), maLeadId: null },
1690
+ identity: { visitorId: serverMeta.visitorId || storedVisitorId || "", isKnown: mayPersonalize && cookies.has(COOKIE_NAMES.KNOWN), maLeadId: null },
1593
1691
  meta: { isOptedOut: cookies.has(COOKIE_NAMES.OPTOUT), isPreview: false, previewAudienceId: null, resolvedAt: "server" }
1594
1692
  };
1595
1693
  if (config.adapters && config.adapters.length > 0) {
@@ -1597,7 +1695,8 @@ var Kywi = (() => {
1597
1695
  merged.adapters = { ...merged.adapters, ...adapterResults };
1598
1696
  }
1599
1697
  const result = evaluateClientSide(audiences, merged, currentPath, pageCategory);
1600
- const pinnedId = cookies.get(COOKIE_NAMES.AUDIENCE) ?? null;
1698
+ const rawPin = cookies.get(COOKIE_NAMES.AUDIENCE) ?? null;
1699
+ const pinnedId = rawPin?.startsWith("switcher:") ? rawPin.slice("switcher:".length) || null : rawPin;
1601
1700
  const isPinned = !result.isOptedOut && pinnedId !== null && activeAudiences.some((a) => a.id === pinnedId);
1602
1701
  const effectiveAudienceId = isPinned ? pinnedId : result.winningAudienceId;
1603
1702
  patchVariantContainers(effectiveAudienceId);
@@ -1614,11 +1713,14 @@ var Kywi = (() => {
1614
1713
  renderPersonalizationBadge({ audienceName: nameOf(effectiveAudienceId) });
1615
1714
  populateDataLayer({ visitorId: result.signals.identity.visitorId, audienceId: effectiveAudienceId, experimentId: serverMeta.experimentId, variantId: serverMeta.variantId, isPreview: false, isOptedOut: result.isOptedOut });
1616
1715
  fireKywiReady();
1617
- if (!isPinned && result.winningAudienceId && result.winningAudienceId !== serverMeta.audienceId) {
1716
+ if (!isPinned && mayPersonalize && result.winningAudienceId && result.winningAudienceId !== serverMeta.audienceId) {
1618
1717
  fetch(`${apiBase}/kywi/audience`, {
1619
1718
  method: "POST",
1620
1719
  headers: { "Content-Type": "application/json", ...Kywi.context?.csrfToken ? { "X-CSRF-Token": Kywi.context.csrfToken } : {} },
1621
- body: JSON.stringify({ audienceId: result.winningAudienceId })
1720
+ // Explicitly the AUTOMATIC write, so the server applies the
1721
+ // personalization gate to it (kywi-cms#91). The transparency panel's
1722
+ // hand-operated switcher sends source:'switcher' and is exempt.
1723
+ body: JSON.stringify({ audienceId: result.winningAudienceId, source: "boot" })
1622
1724
  }).catch(() => {
1623
1725
  });
1624
1726
  }