@kywi-software/js 0.23.0 → 0.25.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.esm.js CHANGED
@@ -15,6 +15,26 @@ function readContext() {
15
15
  };
16
16
  }
17
17
 
18
+ // src/site-scope.ts
19
+ function currentSiteId() {
20
+ if (typeof document === "undefined") return "";
21
+ return document.querySelector('meta[name="kywi:site-id"]')?.getAttribute("content") || "";
22
+ }
23
+ function withSiteId(url) {
24
+ if (!isSameOrigin(url)) return url;
25
+ const siteId = currentSiteId();
26
+ if (!siteId || /[?&]siteId=/.test(url)) return url;
27
+ const [path, hash] = url.split("#", 2);
28
+ const joined = `${path}${path.includes("?") ? "&" : "?"}siteId=${encodeURIComponent(siteId)}`;
29
+ return hash === void 0 ? joined : `${joined}#${hash}`;
30
+ }
31
+ function isSameOrigin(url) {
32
+ if (url.startsWith("/")) return !url.startsWith("//");
33
+ const origin = typeof window !== "undefined" ? window.location?.origin : void 0;
34
+ if (!origin || origin === "null") return false;
35
+ return url === origin || url.startsWith(`${origin}/`);
36
+ }
37
+
18
38
  // src/entity.ts
19
39
  function createEntity(type, basePath, csrfToken, data) {
20
40
  const fields = { ...data };
@@ -34,7 +54,7 @@ function createEntity(type, basePath, csrfToken, data) {
34
54
  async save() {
35
55
  const id = fields["id"];
36
56
  const url = id ? `${basePath}/content/${type}/${id}` : `${basePath}/content/${type}`;
37
- const res = await fetch(url, { method: id ? "PUT" : "POST", headers, body: JSON.stringify(Object.fromEntries(dirty)) });
57
+ const res = await fetch(withSiteId(url), { method: id ? "PUT" : "POST", headers, body: JSON.stringify(Object.fromEntries(dirty)) });
38
58
  const json = await res.json();
39
59
  if (!res.ok || json.error) {
40
60
  errors.push(json.error?.message ?? "Save failed");
@@ -46,14 +66,14 @@ function createEntity(type, basePath, csrfToken, data) {
46
66
  },
47
67
  async delete() {
48
68
  const id = fields["id"];
49
- await fetch(`${basePath}/content/${type}/${id}`, { method: "DELETE", headers });
69
+ await fetch(withSiteId(`${basePath}/content/${type}/${id}`), { method: "DELETE", headers });
50
70
  },
51
71
  getErrors() {
52
72
  return [...errors];
53
73
  },
54
74
  async loadBy(field, value) {
55
75
  const url = field === "id" ? `${basePath}/content/${type}/${value}` : `${basePath}/content/${type}?${field}=${encodeURIComponent(value)}`;
56
- const res = await fetch(url, { headers });
76
+ const res = await fetch(withSiteId(url), { headers });
57
77
  const json = await res.json();
58
78
  if (!res.ok || json.error) {
59
79
  errors.push(json.error?.message ?? "Load failed");
@@ -172,7 +192,7 @@ var FeedBuilder = class {
172
192
  const agg = { function: fn };
173
193
  if (field) agg["field"] = field;
174
194
  body["aggregate"] = agg;
175
- const res = await fetch(`${this.basePath}/feeds/aggregate`, {
195
+ const res = await fetch(withSiteId(`${this.basePath}/feeds/aggregate`), {
176
196
  method: "POST",
177
197
  headers: this.buildHeaders(),
178
198
  body: JSON.stringify(body)
@@ -182,7 +202,7 @@ var FeedBuilder = class {
182
202
  }
183
203
  async getQuery() {
184
204
  const body = this.buildBody();
185
- const res = await fetch(`${this.basePath}/feeds/query`, {
205
+ const res = await fetch(withSiteId(`${this.basePath}/feeds/query`), {
186
206
  method: "POST",
187
207
  headers: this.buildHeaders(),
188
208
  body: JSON.stringify(body)
@@ -471,6 +491,248 @@ function initNavMenus(root = document) {
471
491
  };
472
492
  }
473
493
 
494
+ // src/cookies.ts
495
+ var COOKIE_NAMES = {
496
+ SIGNALS: "kywi_signals",
497
+ VISITOR: "kywi_visitor",
498
+ UTM: "kywi_utm",
499
+ AUDIENCE: "kywi_audience",
500
+ OPTOUT: "kywi_optout",
501
+ KNOWN: "kywi_known",
502
+ PREVIEW_INIT: "kywi_preview_init",
503
+ BUCKET: "kywi_bucket"
504
+ };
505
+ function readCookies() {
506
+ const map = /* @__PURE__ */ new Map();
507
+ if (typeof document === "undefined") return map;
508
+ for (const pair of document.cookie.split(";")) {
509
+ const trimmed = pair.trim();
510
+ const eqIdx = trimmed.indexOf("=");
511
+ if (eqIdx < 0) continue;
512
+ map.set(trimmed.slice(0, eqIdx), decodeURIComponent(trimmed.slice(eqIdx + 1)));
513
+ }
514
+ return map;
515
+ }
516
+ function setCookie(name, value, maxAge) {
517
+ let str = `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax`;
518
+ if (maxAge !== void 0 && maxAge >= 0) str += `; Max-Age=${maxAge}`;
519
+ document.cookie = str;
520
+ }
521
+ function deleteCookie(name) {
522
+ document.cookie = `${name}=; Path=/; Max-Age=0`;
523
+ }
524
+
525
+ // src/consent.ts
526
+ var CONSENT_COOKIE = "kywi_consent";
527
+ var CONSENT_EVENT = "kywi:consent";
528
+ var CONSENT_REOPEN_EVENT = "kywi:consent-reopen";
529
+ function reopenConsent() {
530
+ if (typeof window === "undefined" || typeof CustomEvent === "undefined") return;
531
+ window.dispatchEvent(new CustomEvent(CONSENT_REOPEN_EVENT));
532
+ }
533
+ var AUDIENCE_PIN_COOKIE = "kywi_audience";
534
+ var SWITCHER_PIN_PREFIX = "switcher:";
535
+ var COOKIE_CONSENT_CATEGORY = {
536
+ kywi_consent: "essential",
537
+ kywi_optout: "essential",
538
+ kywi_preview_init: "essential",
539
+ kywi_bucket: "essential",
540
+ kywi_entry: "essential",
541
+ kywi_utm_visit: "essential",
542
+ kywi_selfid: "essential",
543
+ kywi_signals: "personalization",
544
+ kywi_visitor: "personalization",
545
+ kywi_utm: "personalization",
546
+ kywi_audience: "personalization",
547
+ kywi_known: "personalization"
548
+ };
549
+ function getConsentState() {
550
+ const cookies = readCookies();
551
+ const raw = cookies.get(CONSENT_COOKIE);
552
+ if (!raw) return null;
553
+ let parsed;
554
+ try {
555
+ parsed = JSON.parse(raw);
556
+ } catch {
557
+ return null;
558
+ }
559
+ if (typeof parsed !== "object" || parsed === null) return null;
560
+ const obj = parsed;
561
+ return {
562
+ essential: true,
563
+ analytics: obj.analytics === true,
564
+ marketing: obj.marketing === true,
565
+ personalization: obj.personalization === true
566
+ };
567
+ }
568
+ function setConsent(prefs) {
569
+ const state = {
570
+ essential: true,
571
+ analytics: prefs.analytics ?? false,
572
+ marketing: prefs.marketing ?? false,
573
+ personalization: prefs.personalization ?? false
574
+ };
575
+ setCookie(CONSENT_COOKIE, JSON.stringify(state), 31536e3);
576
+ clearDeniedCookies(state);
577
+ announceConsent(state);
578
+ }
579
+ function announceConsent(state) {
580
+ if (typeof window === "undefined" || typeof CustomEvent === "undefined") return;
581
+ try {
582
+ window.dispatchEvent(new CustomEvent(CONSENT_EVENT, { detail: state }));
583
+ } catch {
584
+ }
585
+ }
586
+ function clearDeniedCookies(state) {
587
+ if (typeof document === "undefined") return [];
588
+ const cookies = readCookies();
589
+ const deleted = [];
590
+ for (const [name, category] of Object.entries(COOKIE_CONSENT_CATEGORY)) {
591
+ if (category === "essential") continue;
592
+ if (state && state[category] === true) continue;
593
+ if (isDeliberatePreference(name, cookies.get(name))) continue;
594
+ deleteCookie(name);
595
+ deleted.push(name);
596
+ }
597
+ return deleted;
598
+ }
599
+ function isDeliberatePreference(name, value) {
600
+ if (name !== AUDIENCE_PIN_COOKIE || value === void 0) return false;
601
+ return value.startsWith(SWITCHER_PIN_PREFIX) && value.length > SWITCHER_PIN_PREFIX.length;
602
+ }
603
+ function hasConsent(category, options = {}) {
604
+ if (options.requireConsent === false) return true;
605
+ if (category === "essential") return true;
606
+ const state = getConsentState();
607
+ if (!state) return false;
608
+ return state[category] === true;
609
+ }
610
+
611
+ // src/experiments/pending.ts
612
+ var PENDING_EVENTS_META = "kywi:pending-events";
613
+ var PENDING_STORAGE_KEY = "kywi:pending-events";
614
+ var EMPTY = () => ({ exposures: [], matched: [] });
615
+ var memory = EMPTY();
616
+ var consentListener = null;
617
+ function hasValidSiteId(o) {
618
+ return o.siteId === void 0 || typeof o.siteId === "string" && o.siteId.length > 0;
619
+ }
620
+ function isExposure(v) {
621
+ if (typeof v !== "object" || v === null) return false;
622
+ const o = v;
623
+ return typeof o.experimentId === "string" && o.experimentId.length > 0 && (typeof o.variantKey === "string" || o.variantKey === null) && typeof o.at === "number" && hasValidSiteId(o);
624
+ }
625
+ function isMatch(v) {
626
+ if (typeof v !== "object" || v === null) return false;
627
+ const o = v;
628
+ return typeof o.audienceId === "string" && o.audienceId.length > 0 && typeof o.at === "number" && typeof o.sig === "string" && o.sig.length > 0 && hasValidSiteId(o);
629
+ }
630
+ function parsePending(raw) {
631
+ if (!raw) return EMPTY();
632
+ try {
633
+ const parsed = JSON.parse(raw);
634
+ const exposures = Array.isArray(parsed?.exposures) ? parsed.exposures.filter(isExposure) : [];
635
+ const matched = Array.isArray(parsed?.matched) ? parsed.matched.filter(isMatch) : [];
636
+ return { exposures, matched };
637
+ } catch {
638
+ return EMPTY();
639
+ }
640
+ }
641
+ function readStorage() {
642
+ try {
643
+ return parsePending(sessionStorage.getItem(PENDING_STORAGE_KEY));
644
+ } catch {
645
+ return EMPTY();
646
+ }
647
+ }
648
+ function writeStorage(events) {
649
+ try {
650
+ if (isEmpty(events)) sessionStorage.removeItem(PENDING_STORAGE_KEY);
651
+ else sessionStorage.setItem(PENDING_STORAGE_KEY, JSON.stringify(events));
652
+ } catch {
653
+ }
654
+ }
655
+ function isEmpty(events) {
656
+ return events.exposures.length === 0 && events.matched.length === 0;
657
+ }
658
+ function merge(into, from) {
659
+ const expKey = (e) => `${e.siteId ?? ""}|${e.experimentId}`;
660
+ const audKey = (m) => `${m.siteId ?? ""}|${m.audienceId}`;
661
+ const byExperiment = new Map(into.exposures.map((e) => [expKey(e), e]));
662
+ for (const e of from.exposures) if (!byExperiment.has(expKey(e))) byExperiment.set(expKey(e), e);
663
+ const byAudience = new Map(into.matched.map((m) => [audKey(m), m]));
664
+ for (const m of from.matched) if (!byAudience.has(audKey(m))) byAudience.set(audKey(m), m);
665
+ return { exposures: [...byExperiment.values()], matched: [...byAudience.values()] };
666
+ }
667
+ function stampSite(events) {
668
+ const siteId = currentSiteId();
669
+ if (!siteId) return events;
670
+ return {
671
+ exposures: events.exposures.map((e) => e.siteId ? e : { ...e, siteId }),
672
+ matched: events.matched.map((m) => m.siteId ? m : { ...m, siteId })
673
+ };
674
+ }
675
+ function pendingEvents() {
676
+ return merge(memory, readStorage());
677
+ }
678
+ function queuePendingEvents(events) {
679
+ memory = merge(pendingEvents(), stampSite(events));
680
+ writeStorage(memory);
681
+ }
682
+ function clearQueue() {
683
+ memory = EMPTY();
684
+ writeStorage(memory);
685
+ }
686
+ function urlForSite(url, siteId) {
687
+ if (!siteId) return withSiteId(url);
688
+ const origin = typeof window !== "undefined" ? window.location?.origin : void 0;
689
+ const sameOrigin = url.startsWith("/") && !url.startsWith("//") || !!origin && origin !== "null" && (url === origin || url.startsWith(`${origin}/`));
690
+ if (!sameOrigin || /[?&]siteId=/.test(url)) return url;
691
+ return `${url}${url.includes("?") ? "&" : "?"}siteId=${encodeURIComponent(siteId)}`;
692
+ }
693
+ function post(url, siteId, body, csrfToken) {
694
+ try {
695
+ void fetch(urlForSite(url, siteId), {
696
+ method: "POST",
697
+ headers: { "Content-Type": "application/json", ...csrfToken ? { "X-CSRF-Token": csrfToken } : {} },
698
+ body: JSON.stringify(body),
699
+ keepalive: true
700
+ }).catch(() => {
701
+ });
702
+ } catch {
703
+ }
704
+ }
705
+ function flushPendingEvents(config) {
706
+ const gate = config.requireConsent === void 0 ? {} : { requireConsent: config.requireConsent };
707
+ const queue = pendingEvents();
708
+ if (isEmpty(queue)) return 0;
709
+ if (!hasConsent("personalization", gate) || !hasConsent("analytics", gate)) {
710
+ if (getConsentState() !== null) clearQueue();
711
+ return 0;
712
+ }
713
+ for (const e of queue.exposures) {
714
+ post(`${config.apiBase}/kywi/experiments/exposure`, e.siteId, { experimentId: e.experimentId, at: e.at }, config.csrfToken);
715
+ }
716
+ for (const m of queue.matched) {
717
+ post(`${config.apiBase}/kywi/analytics/matched`, m.siteId, { audienceId: m.audienceId, at: m.at, sig: m.sig }, config.csrfToken);
718
+ }
719
+ clearQueue();
720
+ return queue.exposures.length + queue.matched.length;
721
+ }
722
+ function bootPendingEvents(config) {
723
+ if (typeof document !== "undefined") {
724
+ const raw = document.querySelector(`meta[name="${PENDING_EVENTS_META}"]`)?.getAttribute("content");
725
+ queuePendingEvents(parsePending(raw));
726
+ }
727
+ flushPendingEvents(config);
728
+ if (typeof window === "undefined") return;
729
+ if (consentListener) window.removeEventListener(CONSENT_EVENT, consentListener);
730
+ consentListener = () => {
731
+ flushPendingEvents(config);
732
+ };
733
+ window.addEventListener(CONSENT_EVENT, consentListener);
734
+ }
735
+
474
736
  // src/experiments/index.ts
475
737
  function apiBaseFrom(ctx) {
476
738
  return ctx.basePath && ctx.basePath !== "/" ? `${ctx.basePath}/api/v1` : "/api/v1";
@@ -497,7 +759,7 @@ function installFormListener() {
497
759
  }
498
760
  function beacon(url, body, csrfToken) {
499
761
  try {
500
- void fetch(url, {
762
+ void fetch(withSiteId(url), {
501
763
  method: "POST",
502
764
  headers: { "Content-Type": "application/json", ...csrfToken ? { "X-CSRF-Token": csrfToken } : {} },
503
765
  body: JSON.stringify(body),
@@ -523,6 +785,7 @@ function bootExperiments(config = {}) {
523
785
  if (experimentId) {
524
786
  beacon(`${apiBase}/kywi/experiments/exposure`, { experimentId, visitorId }, csrf);
525
787
  }
788
+ bootPendingEvents({ apiBase, ...csrf ? { csrfToken: csrf } : {} });
526
789
  if (config.autoPageview !== false) {
527
790
  trackConversion({ type: "pageview", url: currentPath });
528
791
  }
@@ -539,7 +802,7 @@ function bootExperiments(config = {}) {
539
802
  // src/audience/selfid-collector.ts
540
803
  var SELFID_KEY = "kywi_selfid";
541
804
  function collectSelfId() {
542
- const raw = readStorage(SELFID_KEY);
805
+ const raw = readStorage2(SELFID_KEY);
543
806
  if (!raw) return {};
544
807
  try {
545
808
  const parsed = JSON.parse(raw);
@@ -549,7 +812,9 @@ function collectSelfId() {
549
812
  return {};
550
813
  }
551
814
  }
552
- function storeSelfId(responses) {
815
+ function storeSelfId(responses, gate = {}) {
816
+ const category = COOKIE_CONSENT_CATEGORY[SELFID_KEY] ?? "personalization";
817
+ if (!hasConsent(category, gate)) return;
553
818
  try {
554
819
  localStorage.setItem(SELFID_KEY, JSON.stringify(responses));
555
820
  } catch {
@@ -561,7 +826,7 @@ function clearStoredSelfId() {
561
826
  } catch {
562
827
  }
563
828
  }
564
- function readStorage(key) {
829
+ function readStorage2(key) {
565
830
  try {
566
831
  return localStorage.getItem(key);
567
832
  } catch {
@@ -608,7 +873,7 @@ async function clearSelfId(options = {}) {
608
873
  async function submit(fields, options) {
609
874
  const csrfToken = resolveCsrfToken(options.csrfToken);
610
875
  try {
611
- const res = await fetch(`${resolveApiBase(options.apiBase)}/kywi/self-id`, {
876
+ const res = await fetch(withSiteId(`${resolveApiBase(options.apiBase)}/kywi/self-id`), {
612
877
  method: "POST",
613
878
  headers: { "Content-Type": "application/json", ...csrfToken ? { "X-CSRF-Token": csrfToken } : {} },
614
879
  body: JSON.stringify({
@@ -882,106 +1147,6 @@ function entryPageFor(currentPath) {
882
1147
  return search ? `${currentPath}${search}` : currentPath;
883
1148
  }
884
1149
 
885
- // src/cookies.ts
886
- var COOKIE_NAMES = {
887
- SIGNALS: "kywi_signals",
888
- VISITOR: "kywi_visitor",
889
- UTM: "kywi_utm",
890
- AUDIENCE: "kywi_audience",
891
- OPTOUT: "kywi_optout",
892
- KNOWN: "kywi_known",
893
- PREVIEW_INIT: "kywi_preview_init"
894
- };
895
- function readCookies() {
896
- const map = /* @__PURE__ */ new Map();
897
- if (typeof document === "undefined") return map;
898
- for (const pair of document.cookie.split(";")) {
899
- const trimmed = pair.trim();
900
- const eqIdx = trimmed.indexOf("=");
901
- if (eqIdx < 0) continue;
902
- map.set(trimmed.slice(0, eqIdx), decodeURIComponent(trimmed.slice(eqIdx + 1)));
903
- }
904
- return map;
905
- }
906
- function setCookie(name, value, maxAge) {
907
- let str = `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax`;
908
- if (maxAge !== void 0 && maxAge >= 0) str += `; Max-Age=${maxAge}`;
909
- document.cookie = str;
910
- }
911
- function deleteCookie(name) {
912
- document.cookie = `${name}=; Path=/; Max-Age=0`;
913
- }
914
-
915
- // src/consent.ts
916
- var CONSENT_COOKIE = "kywi_consent";
917
- var AUDIENCE_PIN_COOKIE = "kywi_audience";
918
- var SWITCHER_PIN_PREFIX = "switcher:";
919
- var COOKIE_CONSENT_CATEGORY = {
920
- kywi_consent: "essential",
921
- kywi_optout: "essential",
922
- kywi_preview_init: "essential",
923
- kywi_signals: "personalization",
924
- kywi_visitor: "personalization",
925
- kywi_utm: "personalization",
926
- kywi_audience: "personalization",
927
- kywi_known: "personalization",
928
- kywi_entry: "personalization",
929
- kywi_selfid: "personalization"
930
- };
931
- function getConsentState() {
932
- const cookies = readCookies();
933
- const raw = cookies.get(CONSENT_COOKIE);
934
- if (!raw) return null;
935
- let parsed;
936
- try {
937
- parsed = JSON.parse(raw);
938
- } catch {
939
- return null;
940
- }
941
- if (typeof parsed !== "object" || parsed === null) return null;
942
- const obj = parsed;
943
- return {
944
- essential: true,
945
- analytics: obj.analytics === true,
946
- marketing: obj.marketing === true,
947
- personalization: obj.personalization === true
948
- };
949
- }
950
- function setConsent(prefs) {
951
- const state = {
952
- essential: true,
953
- analytics: prefs.analytics ?? false,
954
- marketing: prefs.marketing ?? false,
955
- personalization: prefs.personalization ?? false
956
- };
957
- setCookie(CONSENT_COOKIE, JSON.stringify(state), 31536e3);
958
- clearDeniedCookies(state);
959
- }
960
- function clearDeniedCookies(state) {
961
- if (typeof document === "undefined") return [];
962
- const cookies = readCookies();
963
- const deleted = [];
964
- for (const [name, category] of Object.entries(COOKIE_CONSENT_CATEGORY)) {
965
- if (category === "essential") continue;
966
- if (state && state[category] === true) continue;
967
- if (isDeliberatePreference(name, cookies.get(name))) continue;
968
- deleteCookie(name);
969
- deleted.push(name);
970
- }
971
- return deleted;
972
- }
973
- function isDeliberatePreference(name, value) {
974
- if (name !== AUDIENCE_PIN_COOKIE || value === void 0) return false;
975
- return value.startsWith(SWITCHER_PIN_PREFIX) && value.length > SWITCHER_PIN_PREFIX.length;
976
- }
977
- function hasConsent(category, options = {}) {
978
- if (options.requireConsent === false) return true;
979
- if (category === "essential") return true;
980
- const state = getConsentState();
981
- if (!state) return false;
982
- return state[category] === true;
983
- }
984
-
985
1150
  // src/audience/behavioral-collector.ts
986
1151
  var SESSION_KEY = "kywi_session";
987
1152
  var TOTAL_VIEWS_KEY = "kywi_total_views";
@@ -1036,7 +1201,7 @@ function collectBehavioral(currentPath, category, gate = {}) {
1036
1201
  }
1037
1202
 
1038
1203
  // src/audience/evaluate.ts
1039
- function merge(base, partial) {
1204
+ function merge2(base, partial) {
1040
1205
  return {
1041
1206
  utm: partial.utm ?? base.utm,
1042
1207
  referrer: partial.referrer ?? base.referrer,
@@ -1050,10 +1215,10 @@ function merge(base, partial) {
1050
1215
  };
1051
1216
  }
1052
1217
  function evaluateClientSide(audiences, serverSignals, currentPath, pageCategory, gate = {}) {
1053
- let signals = merge(createEmptySignals(), serverSignals);
1218
+ let signals = merge2(createEmptySignals(), serverSignals);
1054
1219
  const client = collectBehavioral(currentPath, pageCategory, gate);
1055
1220
  const selfId = collectSelfId();
1056
- signals = merge(signals, { session: client.session, behavioral: client.behavioral, query: collectQuery(), selfId, meta: { ...signals.meta, resolvedAt: "client" } });
1221
+ signals = merge2(signals, { session: client.session, behavioral: client.behavioral, query: collectQuery(), selfId, meta: { ...signals.meta, resolvedAt: "client" } });
1057
1222
  const { winningAudienceId, allMatchedIds } = resolveAudiences(audiences, signals);
1058
1223
  const isOptedOut2 = signals.meta.isOptedOut;
1059
1224
  return { winningAudienceId: isOptedOut2 ? null : winningAudienceId, allMatchedIds, signals, isOptedOut: isOptedOut2 };
@@ -1091,14 +1256,14 @@ var SWITCHER_LABEL_ID = "kywi-transparency-switcher-label";
1091
1256
  var DEFAULT_API_BASE2 = "/api/v1";
1092
1257
  var lastState = null;
1093
1258
  var forcedOpen = false;
1094
- function readStorage2(key) {
1259
+ function readStorage3(key) {
1095
1260
  try {
1096
1261
  return localStorage.getItem(key);
1097
1262
  } catch {
1098
1263
  return null;
1099
1264
  }
1100
1265
  }
1101
- function writeStorage(key, value) {
1266
+ function writeStorage2(key, value) {
1102
1267
  try {
1103
1268
  localStorage.setItem(key, value);
1104
1269
  } catch {
@@ -1111,15 +1276,15 @@ function removeStorage(key) {
1111
1276
  }
1112
1277
  }
1113
1278
  function isCollapsed() {
1114
- if (readStorage2(LEGACY_DISMISSED_KEY) === "1") {
1279
+ if (readStorage3(LEGACY_DISMISSED_KEY) === "1") {
1115
1280
  removeStorage(LEGACY_DISMISSED_KEY);
1116
- writeStorage(COLLAPSED_KEY, "1");
1281
+ writeStorage2(COLLAPSED_KEY, "1");
1117
1282
  return true;
1118
1283
  }
1119
- return readStorage2(COLLAPSED_KEY) !== "0";
1284
+ return readStorage3(COLLAPSED_KEY) !== "0";
1120
1285
  }
1121
1286
  function setCollapsed(collapsed) {
1122
- writeStorage(COLLAPSED_KEY, collapsed ? "1" : "0");
1287
+ writeStorage2(COLLAPSED_KEY, collapsed ? "1" : "0");
1123
1288
  }
1124
1289
  function optOut() {
1125
1290
  setCookie(COOKIE_NAMES.OPTOUT, "1", 365 * 24 * 3600);
@@ -1137,7 +1302,7 @@ function hasPin() {
1137
1302
  async function pinAudience(audienceId, opts = {}) {
1138
1303
  const apiBase = opts.apiBase ?? DEFAULT_API_BASE2;
1139
1304
  try {
1140
- const res = await fetch(`${apiBase}/kywi/audience`, {
1305
+ const res = await fetch(withSiteId(`${apiBase}/kywi/audience`), {
1141
1306
  method: "POST",
1142
1307
  headers: { "Content-Type": "application/json", ...opts.csrfToken ? { "X-CSRF-Token": opts.csrfToken } : {} },
1143
1308
  body: JSON.stringify({ audienceId, source: "switcher" })
@@ -1350,14 +1515,18 @@ function setPreviewAudienceId(id) {
1350
1515
  }
1351
1516
  function clearPreview() {
1352
1517
  sessionStorage.removeItem(PREVIEW_KEY);
1518
+ deleteCookie(COOKIE_NAMES.PREVIEW_INIT);
1519
+ }
1520
+ function previewAudienceFromCookie(value) {
1521
+ if (!value) return null;
1522
+ const parts = value.split(".");
1523
+ if (parts.length < 3) return null;
1524
+ const id = parts[0];
1525
+ return id.length > 0 ? id : null;
1353
1526
  }
1354
1527
  function checkPreviewInit() {
1355
- const cookies = readCookies();
1356
- const initId = cookies.get(COOKIE_NAMES.PREVIEW_INIT);
1357
- if (initId) {
1358
- setPreviewAudienceId(initId);
1359
- deleteCookie(COOKIE_NAMES.PREVIEW_INIT);
1360
- }
1528
+ const initId = previewAudienceFromCookie(readCookies().get(COOKIE_NAMES.PREVIEW_INIT));
1529
+ if (initId) setPreviewAudienceId(initId);
1361
1530
  }
1362
1531
  function renderPreviewBanner(audienceName2) {
1363
1532
  document.querySelector(".kywi-preview-banner")?.remove();
@@ -1418,7 +1587,7 @@ async function runClientAdapters(adapters, currentSignals, apiBase = "/api/v1")
1418
1587
  if (cached) return cached;
1419
1588
  let result;
1420
1589
  if (adapter.side === "server-proxy") {
1421
- const res = await fetch(`${apiBase}/kywi/adapters/${adapter.id}`, {
1590
+ const res = await fetch(withSiteId(`${apiBase}/kywi/adapters/${adapter.id}`), {
1422
1591
  method: "POST",
1423
1592
  headers: { "Content-Type": "application/json" },
1424
1593
  body: JSON.stringify({ signals: currentSignals })
@@ -1437,16 +1606,18 @@ async function runClientAdapters(adapters, currentSignals, apiBase = "/api/v1")
1437
1606
  }
1438
1607
 
1439
1608
  // src/audience/selfid-submit.ts
1440
- function handleSelfIdSubmit(responses, audiences, currentPath) {
1441
- storeSelfId(responses);
1609
+ function handleSelfIdSubmit(responses, audiences, currentPath, gate = {}) {
1610
+ storeSelfId(responses, gate);
1442
1611
  if (audiences && currentPath) {
1443
1612
  const cookies = readCookies();
1444
1613
  const serverMeta = readServerMeta();
1614
+ const mayPersonalize = hasConsent("personalization", gate);
1615
+ const storedVisitorId = mayPersonalize ? cookies.get(COOKIE_NAMES.VISITOR) : void 0;
1445
1616
  const serverSignals = {
1446
- identity: { visitorId: serverMeta.visitorId || cookies.get(COOKIE_NAMES.VISITOR) || "", isKnown: cookies.has(COOKIE_NAMES.KNOWN), maLeadId: null },
1617
+ identity: { visitorId: serverMeta.visitorId || storedVisitorId || "", isKnown: mayPersonalize && cookies.has(COOKIE_NAMES.KNOWN), maLeadId: null },
1447
1618
  meta: { isOptedOut: cookies.has(COOKIE_NAMES.OPTOUT), isPreview: false, previewAudienceId: null, resolvedAt: "server" }
1448
1619
  };
1449
- const result = evaluateClientSide(audiences, serverSignals, currentPath);
1620
+ const result = evaluateClientSide(audiences, serverSignals, currentPath, void 0, gate);
1450
1621
  patchVariantContainers(result.winningAudienceId);
1451
1622
  populateDataLayer({
1452
1623
  visitorId: result.signals.identity.visitorId,
@@ -1518,10 +1689,28 @@ function createSelfIdWidget(config) {
1518
1689
  function markShown() {
1519
1690
  localStorage.setItem(SHOWN_KEY, String(Date.now()));
1520
1691
  }
1692
+ function consentGate() {
1693
+ return config.requireConsent === void 0 ? {} : { requireConsent: config.requireConsent };
1694
+ }
1695
+ function patchClientSide(responses) {
1696
+ handleSelfIdSubmit(responses, config.audiences ?? [], config.currentPath ?? "/", consentGate());
1697
+ }
1521
1698
  function handleSubmit(responses) {
1522
- storeSelfId(responses);
1699
+ if (config.serverSubmit !== false) {
1700
+ markShown();
1701
+ const options = {
1702
+ ...config.apiBase !== void 0 ? { apiBase: config.apiBase } : {},
1703
+ ...config.csrfToken !== void 0 ? { csrfToken: config.csrfToken } : {}
1704
+ };
1705
+ void setSelfId(responses, options).then((result) => {
1706
+ if (!result || !result.persisted) patchClientSide(responses);
1707
+ }).catch(() => {
1708
+ });
1709
+ return;
1710
+ }
1711
+ storeSelfId(responses, consentGate());
1523
1712
  try {
1524
- handleSelfIdSubmit(responses, config.audiences ?? [], config.currentPath ?? "/");
1713
+ patchClientSide(responses);
1525
1714
  } finally {
1526
1715
  markShown();
1527
1716
  }
@@ -1774,7 +1963,7 @@ async function bootAudienceEngine(config) {
1774
1963
  populateDataLayer({ visitorId: result.signals.identity.visitorId, audienceId: effectiveAudienceId, experimentId: serverMeta.experimentId, variantId: serverMeta.variantId, isPreview: false, isOptedOut: result.isOptedOut });
1775
1964
  fireKywiReady();
1776
1965
  if (!isPinned && mayPersonalize && result.winningAudienceId && result.winningAudienceId !== serverMeta.audienceId) {
1777
- fetch(`${apiBase}/kywi/audience`, {
1966
+ fetch(withSiteId(`${apiBase}/kywi/audience`), {
1778
1967
  method: "POST",
1779
1968
  headers: { "Content-Type": "application/json", ...Kywi.context?.csrfToken ? { "X-CSRF-Token": Kywi.context.csrfToken } : {} },
1780
1969
  // Explicitly the AUTOMATIC write, so the server applies the
@@ -1785,7 +1974,15 @@ async function bootAudienceEngine(config) {
1785
1974
  });
1786
1975
  }
1787
1976
  if (config.selfIdWidget) {
1788
- const widget = createSelfIdWidget(config.selfIdWidget);
1977
+ const widget = createSelfIdWidget({
1978
+ // The API base and CSRF token this boot resolved, so the widget's server
1979
+ // submit posts where the rest of the runtime does (review fix 4); a
1980
+ // value the widget config sets itself still wins.
1981
+ apiBase,
1982
+ csrfToken: Kywi.context?.csrfToken ?? null,
1983
+ ...config.selfIdWidget,
1984
+ ...config.requireConsent === void 0 ? {} : { requireConsent: config.requireConsent }
1985
+ });
1789
1986
  if (widget.shouldShow()) {
1790
1987
  const doRender = () => {
1791
1988
  const mount = resolveSelfIdMount(config.selfIdWidget.displayMode);
@@ -1871,6 +2068,11 @@ var Kywi = {
1871
2068
  setSelfId,
1872
2069
  /** Forget the visitor's self-identification and un-pin their audience. */
1873
2070
  clearSelfId,
2071
+ /**
2072
+ * Re-open the cookie-consent banner so the visitor can change an earlier
2073
+ * decision (kywi-cms#191) — for a site's own "Cookie settings" link.
2074
+ */
2075
+ reopenConsent,
1874
2076
  getEntity: null,
1875
2077
  getFeed: null,
1876
2078
  renderFeed: null,
@@ -1904,6 +2106,8 @@ if (typeof document !== "undefined") {
1904
2106
  export {
1905
2107
  AUDIENCE_PIN_COOKIE,
1906
2108
  CONSENT_COOKIE,
2109
+ CONSENT_EVENT,
2110
+ CONSENT_REOPEN_EVENT,
1907
2111
  COOKIE_CONSENT_CATEGORY,
1908
2112
  Kywi,
1909
2113
  NAV_BREAKPOINT,
@@ -1918,6 +2122,7 @@ export {
1918
2122
  initNavMenus,
1919
2123
  openTransparencyPanel,
1920
2124
  renderTransparencyPanel,
2125
+ reopenConsent,
1921
2126
  setConsent,
1922
2127
  setSelfId
1923
2128
  };