@cross-deck/web 0.5.0 → 0.7.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.mjs CHANGED
@@ -46,7 +46,7 @@ function typeMapForStatus(status) {
46
46
 
47
47
  // src/http.ts
48
48
  var SDK_NAME = "@cross-deck/web";
49
- var SDK_VERSION = "0.5.0";
49
+ var SDK_VERSION = "0.6.0";
50
50
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
51
51
  var HttpClient = class {
52
52
  constructor(config) {
@@ -62,6 +62,9 @@ var HttpClient = class {
62
62
  * - JSON parse failure on a 2xx (treated as `internal_error`)
63
63
  */
64
64
  async request(method, path, options = {}) {
65
+ if (this.config.localDevMode) {
66
+ return synthesizeLocalDevResponse(path);
67
+ }
65
68
  const url = this.buildUrl(path, options.query);
66
69
  const headers = {
67
70
  Authorization: `Bearer ${this.config.publicKey}`,
@@ -104,6 +107,14 @@ var HttpClient = class {
104
107
  });
105
108
  }
106
109
  }
110
+ /**
111
+ * Whether this client is in localhost dev-mode short-circuit. Used
112
+ * by other SDK pieces (event-queue) to skip network-bound work
113
+ * entirely rather than going through synthesizeLocalDevResponse.
114
+ */
115
+ get isLocalDevMode() {
116
+ return this.config.localDevMode === true;
117
+ }
107
118
  buildUrl(path, query) {
108
119
  const base = this.config.baseUrl.replace(/\/+$/, "");
109
120
  const cleanPath = path.startsWith("/") ? path : `/${path}`;
@@ -119,24 +130,73 @@ var HttpClient = class {
119
130
  return url;
120
131
  }
121
132
  };
133
+ var cachedLocalCdcust = null;
134
+ function synthesizeLocalDevResponse(path) {
135
+ if (path.startsWith("/sdk/heartbeat")) {
136
+ return {
137
+ object: "heartbeat",
138
+ ok: true,
139
+ projectId: "proj_local_dev",
140
+ appId: "app_local_dev",
141
+ platform: "web",
142
+ env: "sandbox",
143
+ serverTime: Date.now()
144
+ };
145
+ }
146
+ if (path.startsWith("/identity/alias")) {
147
+ if (!cachedLocalCdcust) {
148
+ const tail = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().replace(/-/g, "").slice(0, 16) : Math.random().toString(36).slice(2, 18);
149
+ cachedLocalCdcust = `cdcust_local_${tail}`;
150
+ }
151
+ return {
152
+ object: "alias_result",
153
+ crossdeckCustomerId: cachedLocalCdcust,
154
+ linked: [],
155
+ mergePending: false,
156
+ env: "sandbox"
157
+ };
158
+ }
159
+ if (path.startsWith("/entitlements")) {
160
+ return {
161
+ object: "list",
162
+ data: [],
163
+ crossdeckCustomerId: cachedLocalCdcust ?? "",
164
+ env: "sandbox"
165
+ };
166
+ }
167
+ if (path.startsWith("/events")) {
168
+ return {
169
+ object: "list",
170
+ received: 0,
171
+ env: "sandbox"
172
+ };
173
+ }
174
+ return {};
175
+ }
122
176
 
123
177
  // src/identity.ts
124
178
  var KEY_ANON = "anon_id";
125
179
  var KEY_CDCUST = "cdcust_id";
126
180
  var IdentityStore = class {
127
- constructor(storage, prefix) {
128
- this.storage = storage;
181
+ constructor(primary, prefix, secondary) {
182
+ this.primary = primary;
129
183
  this.prefix = prefix;
130
- const stored = {
131
- anon: storage.getItem(prefix + KEY_ANON),
132
- cdcust: storage.getItem(prefix + KEY_CDCUST)
133
- };
184
+ this.secondary = secondary ?? null;
185
+ const anonFromPrimary = primary.getItem(prefix + KEY_ANON);
186
+ const cdcustFromPrimary = primary.getItem(prefix + KEY_CDCUST);
187
+ const anonFromSecondary = this.secondary?.getItem(prefix + KEY_ANON) ?? null;
188
+ const cdcustFromSecondary = this.secondary?.getItem(prefix + KEY_CDCUST) ?? null;
189
+ const anon = anonFromPrimary ?? anonFromSecondary;
190
+ const cdcust = cdcustFromPrimary ?? cdcustFromSecondary;
134
191
  this.state = {
135
- anonymousId: stored.anon ?? this.mintAnonymousId(),
136
- crossdeckCustomerId: stored.cdcust
192
+ anonymousId: anon ?? this.mintAnonymousId(),
193
+ crossdeckCustomerId: cdcust
137
194
  };
138
- if (!stored.anon) {
139
- storage.setItem(prefix + KEY_ANON, this.state.anonymousId);
195
+ if (!anonFromPrimary || !anonFromSecondary) {
196
+ this.writeBoth(prefix + KEY_ANON, this.state.anonymousId);
197
+ }
198
+ if (cdcust && (!cdcustFromPrimary || !cdcustFromSecondary)) {
199
+ this.writeBoth(prefix + KEY_CDCUST, cdcust);
140
200
  }
141
201
  }
142
202
  /** Return the persisted anonymous device ID (always set). */
@@ -150,7 +210,7 @@ var IdentityStore = class {
150
210
  /** Persist a newly-resolved Crossdeck customer ID. */
151
211
  setCrossdeckCustomerId(value) {
152
212
  this.state.crossdeckCustomerId = value;
153
- this.storage.setItem(this.prefix + KEY_CDCUST, value);
213
+ this.writeBoth(this.prefix + KEY_CDCUST, value);
154
214
  }
155
215
  /**
156
216
  * Wipe persisted identity. Called by reset() — used when an end-user
@@ -158,13 +218,13 @@ var IdentityStore = class {
158
218
  * pre-login session is a fresh customer in the identity graph.
159
219
  */
160
220
  reset() {
161
- this.storage.removeItem(this.prefix + KEY_ANON);
162
- this.storage.removeItem(this.prefix + KEY_CDCUST);
221
+ this.deleteBoth(this.prefix + KEY_ANON);
222
+ this.deleteBoth(this.prefix + KEY_CDCUST);
163
223
  this.state = {
164
224
  anonymousId: this.mintAnonymousId(),
165
225
  crossdeckCustomerId: null
166
226
  };
167
- this.storage.setItem(this.prefix + KEY_ANON, this.state.anonymousId);
227
+ this.writeBoth(this.prefix + KEY_ANON, this.state.anonymousId);
168
228
  }
169
229
  /**
170
230
  * Generate an anonymousId. Crockford-ish base32 timestamp + random
@@ -176,6 +236,30 @@ var IdentityStore = class {
176
236
  const rand = randomChars(10);
177
237
  return `anon_${ts}${rand}`;
178
238
  }
239
+ writeBoth(key, value) {
240
+ try {
241
+ this.primary.setItem(key, value);
242
+ } catch {
243
+ }
244
+ if (this.secondary) {
245
+ try {
246
+ this.secondary.setItem(key, value);
247
+ } catch {
248
+ }
249
+ }
250
+ }
251
+ deleteBoth(key) {
252
+ try {
253
+ this.primary.removeItem(key);
254
+ } catch {
255
+ }
256
+ if (this.secondary) {
257
+ try {
258
+ this.secondary.removeItem(key);
259
+ } catch {
260
+ }
261
+ }
262
+ }
179
263
  };
180
264
  function randomChars(count) {
181
265
  const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
@@ -401,6 +485,59 @@ var MemoryStorage = class {
401
485
  this.store.delete(key);
402
486
  }
403
487
  };
488
+ var CookieStorage = class {
489
+ constructor(options) {
490
+ this.maxAgeSec = options?.maxAgeSec ?? 63072e3;
491
+ this.secure = options?.secure ?? defaultSecure();
492
+ this.sameSite = options?.sameSite ?? "Lax";
493
+ }
494
+ getItem(key) {
495
+ if (!hasDocument()) return null;
496
+ const doc = globalThis.document;
497
+ const cookies = doc.cookie ? doc.cookie.split(/;\s*/) : [];
498
+ const prefix = encodeURIComponent(key) + "=";
499
+ for (const c of cookies) {
500
+ if (c.startsWith(prefix)) {
501
+ try {
502
+ return decodeURIComponent(c.slice(prefix.length));
503
+ } catch {
504
+ return null;
505
+ }
506
+ }
507
+ }
508
+ return null;
509
+ }
510
+ setItem(key, value) {
511
+ if (!hasDocument()) return;
512
+ const doc = globalThis.document;
513
+ const parts = [
514
+ `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
515
+ "Path=/",
516
+ `Max-Age=${this.maxAgeSec}`,
517
+ `SameSite=${this.sameSite}`
518
+ ];
519
+ if (this.secure) parts.push("Secure");
520
+ try {
521
+ doc.cookie = parts.join("; ");
522
+ } catch {
523
+ }
524
+ }
525
+ removeItem(key) {
526
+ if (!hasDocument()) return;
527
+ const doc = globalThis.document;
528
+ const parts = [
529
+ `${encodeURIComponent(key)}=`,
530
+ "Path=/",
531
+ "Max-Age=0",
532
+ `SameSite=${this.sameSite}`
533
+ ];
534
+ if (this.secure) parts.push("Secure");
535
+ try {
536
+ doc.cookie = parts.join("; ");
537
+ } catch {
538
+ }
539
+ }
540
+ };
404
541
  function detectDefaultStorage() {
405
542
  try {
406
543
  const ls = globalThis.localStorage;
@@ -414,6 +551,17 @@ function detectDefaultStorage() {
414
551
  }
415
552
  return new MemoryStorage();
416
553
  }
554
+ function defaultSecure() {
555
+ try {
556
+ const loc = globalThis.location;
557
+ return loc?.protocol === "https:";
558
+ } catch {
559
+ return false;
560
+ }
561
+ }
562
+ function hasDocument() {
563
+ return typeof globalThis.document !== "undefined";
564
+ }
417
565
 
418
566
  // src/device-info.ts
419
567
  function isBrowser() {
@@ -511,9 +659,18 @@ function parseUserAgent(ua) {
511
659
  var DEFAULT_AUTO_TRACK = {
512
660
  sessions: true,
513
661
  pageViews: true,
514
- deviceInfo: true
662
+ deviceInfo: true,
663
+ clicks: true
515
664
  };
516
665
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
666
+ var EMPTY_ACQUISITION = {
667
+ utm_source: "",
668
+ utm_medium: "",
669
+ utm_campaign: "",
670
+ utm_content: "",
671
+ utm_term: "",
672
+ referrer: ""
673
+ };
517
674
  var AutoTracker = class {
518
675
  constructor(cfg, track) {
519
676
  this.cfg = cfg;
@@ -525,6 +682,7 @@ var AutoTracker = class {
525
682
  if (!isBrowserSafe()) return;
526
683
  if (this.cfg.sessions) this.installSessionTracking();
527
684
  if (this.cfg.pageViews) this.installPageViewTracking();
685
+ if (this.cfg.clicks) this.installClickTracking();
528
686
  }
529
687
  uninstall() {
530
688
  while (this.cleanups.length) {
@@ -549,6 +707,18 @@ var AutoTracker = class {
549
707
  get currentSessionId() {
550
708
  return this.session?.sessionId ?? null;
551
709
  }
710
+ /**
711
+ * Per-session acquisition context — utm_* + referrer, captured once
712
+ * at session start. Returns empty strings when there's no session
713
+ * (Node, before init, after uninstall) so callers can spread without
714
+ * conditional logic. Bank-grade rule: capture once, attach to every
715
+ * event of the session, don't re-read on every track() (the URL
716
+ * changes via SPA pushState; the source-of-record is the URL we
717
+ * landed on).
718
+ */
719
+ get currentAcquisition() {
720
+ return this.session?.acquisition ?? EMPTY_ACQUISITION;
721
+ }
552
722
  // ---------- sessions ----------
553
723
  installSessionTracking() {
554
724
  this.session = this.startNewSession();
@@ -586,7 +756,8 @@ var AutoTracker = class {
586
756
  sessionId: mintSessionId(),
587
757
  startedAt: Date.now(),
588
758
  hiddenAt: null,
589
- endedSent: false
759
+ endedSent: false,
760
+ acquisition: captureAcquisition()
590
761
  };
591
762
  }
592
763
  emitSessionStart() {
@@ -606,11 +777,19 @@ var AutoTracker = class {
606
777
  installPageViewTracking() {
607
778
  const w = globalThis.window;
608
779
  const doc = globalThis.document;
609
- const fire = () => {
780
+ let lastFiredAt = 0;
781
+ let lastFiredUrl = "";
782
+ const DEDUP_WINDOW_MS = 250;
783
+ const fire = (force = false) => {
610
784
  const loc = w.location;
785
+ const url = loc.href;
786
+ const now = Date.now();
787
+ if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
788
+ lastFiredAt = now;
789
+ lastFiredUrl = url;
611
790
  this.track("page.viewed", {
612
791
  path: loc.pathname,
613
- url: loc.href,
792
+ url,
614
793
  search: loc.search || void 0,
615
794
  hash: loc.hash || void 0,
616
795
  title: doc.title,
@@ -632,7 +811,7 @@ var AutoTracker = class {
632
811
  }
633
812
  w.history.pushState = patchedPush;
634
813
  w.history.replaceState = patchedReplace;
635
- const onPopState = () => fire();
814
+ const onPopState = () => fire(true);
636
815
  w.addEventListener("popstate", onPopState);
637
816
  this.cleanups.push(() => {
638
817
  if (w.history.pushState === patchedPush) {
@@ -644,7 +823,156 @@ var AutoTracker = class {
644
823
  w.removeEventListener("popstate", onPopState);
645
824
  });
646
825
  }
826
+ // ---------- click autocapture ----------
827
+ /**
828
+ * Global click tracking — Mixpanel / Amplitude style autocapture.
829
+ * Fires `element.clicked` for every interactive click with the
830
+ * target element's selector path, text content, tag, href, data-*
831
+ * attributes, and viewport coordinates. Powers the funnel /
832
+ * attribution USP: "users who clicked X then converted within
833
+ * 7 days." Default ON because behavioural attribution is the
834
+ * core product promise.
835
+ *
836
+ * Privacy guardrails:
837
+ * - Skip clicks ON inputs / textareas / selects (form interaction
838
+ * isn't button telemetry; the dev should track form submits
839
+ * deliberately via track('form_submitted'))
840
+ * - Skip clicks INSIDE [type="password"] and password-class
841
+ * elements
842
+ * - Skip clicks inside elements opted out via class="cd-noTrack"
843
+ * or data-cd-noTrack attribute (Mixpanel's exact opt-out
844
+ * idiom — most devs already know it)
845
+ * - Capture text content but cap at 64 chars and trim — never
846
+ * more than what you'd see on a button label
847
+ *
848
+ * Volume guardrails:
849
+ * - Coalesce double-clicks within 100ms (React's synthetic click
850
+ * pattern + browser's native dblclick can fire twice)
851
+ * - Listen on document at capture phase so we see the click
852
+ * before any framework's own handlers stop propagation
853
+ */
854
+ installClickTracking() {
855
+ const w = globalThis.window;
856
+ const doc = globalThis.document;
857
+ let lastFiredAt = 0;
858
+ let lastFiredTarget = null;
859
+ const COALESCE_MS = 100;
860
+ const TEXT_CAP = 64;
861
+ const onClick = (ev) => {
862
+ const target = ev.target;
863
+ if (!target || !(target instanceof Element)) return;
864
+ const now = Date.now();
865
+ if (target === lastFiredTarget && now - lastFiredAt < COALESCE_MS) return;
866
+ lastFiredAt = now;
867
+ lastFiredTarget = target;
868
+ const actionable = closestActionable(target);
869
+ const clicked = actionable || target;
870
+ if (isFormInput(clicked)) return;
871
+ if (isInOptedOut(clicked)) return;
872
+ if (isInsidePasswordField(clicked)) return;
873
+ const tag = clicked.tagName.toLowerCase();
874
+ const text = trimText(extractText(clicked), TEXT_CAP);
875
+ const href = clicked.href || void 0;
876
+ const linkTarget = clicked.target || void 0;
877
+ const elementId = clicked.id || void 0;
878
+ const role = clicked.getAttribute("role") || void 0;
879
+ const ariaLabel = clicked.getAttribute("aria-label") || void 0;
880
+ const selector = buildSelector(clicked);
881
+ const dataAttrs = collectDataAttrs(clicked);
882
+ const isLink = tag === "a" && !!href;
883
+ const explicitName = clicked.getAttribute("data-cd-event");
884
+ const props = {
885
+ selector,
886
+ tag,
887
+ text,
888
+ elementId,
889
+ role,
890
+ ariaLabel,
891
+ href,
892
+ isLink,
893
+ linkTarget,
894
+ viewportX: ev.clientX,
895
+ viewportY: ev.clientY,
896
+ pageX: ev.pageX,
897
+ pageY: ev.pageY,
898
+ ...dataAttrs
899
+ };
900
+ for (const k of Object.keys(props)) {
901
+ if (props[k] === void 0 || props[k] === null || props[k] === "") delete props[k];
902
+ }
903
+ this.track(explicitName || "element.clicked", props);
904
+ };
905
+ doc.addEventListener("click", onClick, { capture: true, passive: true });
906
+ this.cleanups.push(() => {
907
+ doc.removeEventListener("click", onClick, { capture: true });
908
+ });
909
+ }
647
910
  };
911
+ function closestActionable(el) {
912
+ return el.closest("[data-cd-event]") || el.closest("[data-cd-noTrack]") || el.closest("button, a, [role='button'], [role='link'], input[type='button'], input[type='submit']") || null;
913
+ }
914
+ function isFormInput(el) {
915
+ if (!(el instanceof HTMLElement)) return false;
916
+ const tag = el.tagName.toLowerCase();
917
+ if (tag === "textarea" || tag === "select") return true;
918
+ if (tag === "input") {
919
+ const type = (el.type || "").toLowerCase();
920
+ return type !== "button" && type !== "submit" && type !== "image" && type !== "reset";
921
+ }
922
+ return false;
923
+ }
924
+ function isInOptedOut(el) {
925
+ if (el.closest("[data-cd-noTrack], [data-cd-no-track], .cd-noTrack, .cd-no-track")) return true;
926
+ return false;
927
+ }
928
+ function isInsidePasswordField(el) {
929
+ if (el.closest('input[type="password"]')) return true;
930
+ return false;
931
+ }
932
+ function extractText(el) {
933
+ const aria = el.getAttribute("aria-label");
934
+ if (aria) return aria.replace(/\s+/g, " ").trim();
935
+ if (el instanceof HTMLInputElement && el.value) return el.value;
936
+ const text = (el.textContent || "").replace(/\s+/g, " ").trim();
937
+ return text;
938
+ }
939
+ function trimText(s, cap) {
940
+ if (s.length <= cap) return s;
941
+ return s.slice(0, cap - 1) + "\u2026";
942
+ }
943
+ function buildSelector(el) {
944
+ const parts = [];
945
+ let cur = el;
946
+ let depth = 0;
947
+ while (cur && cur.nodeName.toLowerCase() !== "body" && depth < 5) {
948
+ let part = cur.nodeName.toLowerCase();
949
+ if (cur.id) {
950
+ parts.unshift(`${part}#${cur.id}`);
951
+ break;
952
+ }
953
+ if (cur.classList.length > 0) {
954
+ const cls = Array.from(cur.classList).filter((c) => !c.startsWith("cd-")).slice(0, 2).join(".");
955
+ if (cls) part += `.${cls}`;
956
+ }
957
+ parts.unshift(part);
958
+ cur = cur.parentElement;
959
+ depth++;
960
+ }
961
+ return parts.join(" > ");
962
+ }
963
+ function collectDataAttrs(el) {
964
+ const out = {};
965
+ if (!(el instanceof HTMLElement)) return out;
966
+ for (const name of el.getAttributeNames()) {
967
+ if (!name.startsWith("data-")) continue;
968
+ if (name === "data-cd-noTrack" || name === "data-cd-no-track") continue;
969
+ if (name === "data-cd-event") continue;
970
+ const value = el.getAttribute(name) || "";
971
+ const key = name.replace(/^data-cd-prop-/, "").replace(/^data-/, "");
972
+ out[key] = value;
973
+ }
974
+ return out;
975
+ }
648
976
  function isBrowserSafe() {
649
977
  return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
650
978
  }
@@ -652,6 +980,26 @@ function mintSessionId() {
652
980
  const ts = Date.now().toString(36);
653
981
  return `sess_${ts}${randomChars(10)}`;
654
982
  }
983
+ function captureAcquisition() {
984
+ if (!isBrowserSafe()) return { ...EMPTY_ACQUISITION };
985
+ const result = { ...EMPTY_ACQUISITION };
986
+ try {
987
+ const w = globalThis.window;
988
+ const params = new URLSearchParams(w.location.search ?? "");
989
+ result.utm_source = params.get("utm_source") ?? "";
990
+ result.utm_medium = params.get("utm_medium") ?? "";
991
+ result.utm_campaign = params.get("utm_campaign") ?? "";
992
+ result.utm_content = params.get("utm_content") ?? "";
993
+ result.utm_term = params.get("utm_term") ?? "";
994
+ } catch {
995
+ }
996
+ try {
997
+ const doc = globalThis.document;
998
+ if (typeof doc.referrer === "string") result.referrer = doc.referrer;
999
+ } catch {
1000
+ }
1001
+ return result;
1002
+ }
655
1003
 
656
1004
  // src/debug.ts
657
1005
  var SENSITIVE_KEY_PATTERNS = [
@@ -744,6 +1092,7 @@ var CrossdeckClient = class {
744
1092
  message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
745
1093
  });
746
1094
  }
1095
+ const localDevMode = isLocalHostname();
747
1096
  const storage = options.storage ?? detectDefaultStorage();
748
1097
  const persistIdentity = options.persistIdentity ?? true;
749
1098
  const autoTrack = resolveAutoTrack(options.autoTrack);
@@ -770,10 +1119,23 @@ var CrossdeckClient = class {
770
1119
  const http = new HttpClient({
771
1120
  publicKey: opts.publicKey,
772
1121
  baseUrl: opts.baseUrl,
773
- sdkVersion: opts.sdkVersion
1122
+ sdkVersion: opts.sdkVersion,
1123
+ // Localhost auto-route: HttpClient short-circuits every request
1124
+ // to a successful no-op response when localDevMode is set.
1125
+ // SDK methods continue to work locally; nothing reaches the
1126
+ // server.
1127
+ localDevMode
774
1128
  });
1129
+ if (localDevMode) {
1130
+ console.log(
1131
+ "[crossdeck] Localhost detected \u2014 running in dev mode (no network calls). Set publicKey: 'cd_pub_test_\u2026' and deploy to a real domain to test against the Crossdeck Sandbox."
1132
+ );
1133
+ }
775
1134
  const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
776
- const identity = new IdentityStore(effectiveStorage, opts.storagePrefix);
1135
+ const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
1136
+ typeof globalThis.document !== "undefined";
1137
+ const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
1138
+ const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
777
1139
  const entitlements = new EntitlementCache();
778
1140
  const events = new EventQueue({
779
1141
  http,
@@ -821,7 +1183,7 @@ var CrossdeckClient = class {
821
1183
  this.state.uninstallUnloadFlush = installUnloadFlush(() => {
822
1184
  void this.flush({ keepalive: true }).catch(() => void 0);
823
1185
  });
824
- if (opts.autoHeartbeat) {
1186
+ if (opts.autoHeartbeat && !localDevMode) {
825
1187
  void this.heartbeat().catch(() => void 0);
826
1188
  }
827
1189
  }
@@ -954,6 +1316,15 @@ var CrossdeckClient = class {
954
1316
  const enriched = { ...s.deviceInfo };
955
1317
  const sessionId = s.autoTracker?.currentSessionId;
956
1318
  if (sessionId) enriched.sessionId = sessionId;
1319
+ const acquisition = s.autoTracker?.currentAcquisition;
1320
+ if (acquisition) {
1321
+ if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
1322
+ if (acquisition.utm_medium) enriched.utm_medium = acquisition.utm_medium;
1323
+ if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
1324
+ if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
1325
+ if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
1326
+ if (acquisition.referrer) enriched.referrer = acquisition.referrer;
1327
+ }
957
1328
  if (properties) Object.assign(enriched, properties);
958
1329
  const event = {
959
1330
  eventId: this.mintEventId(),
@@ -1046,6 +1417,12 @@ var CrossdeckClient = class {
1046
1417
  */
1047
1418
  reset() {
1048
1419
  if (!this.state) return;
1420
+ if (this.state.developerUserId) {
1421
+ try {
1422
+ this.track("user.signed_out", { auto: true });
1423
+ } catch {
1424
+ }
1425
+ }
1049
1426
  this.state.autoTracker?.uninstall();
1050
1427
  this.state.identity.reset();
1051
1428
  this.state.entitlements.clear();
@@ -1126,14 +1503,30 @@ var CrossdeckClient = class {
1126
1503
  if (s.developerUserId) return { userId: s.developerUserId };
1127
1504
  return { anonymousId: s.identity.anonymousId };
1128
1505
  }
1129
- /** Pick the right identity hint to embed on a queued event. */
1506
+ /**
1507
+ * Embed every known identity axis on the event. Earlier this returned
1508
+ * just the highest-priority hint (cdcust → developerUserId → anonymousId)
1509
+ * to keep payloads small, but that leaked into analytics: once a user
1510
+ * was logged in, every subsequent page.viewed shipped without
1511
+ * anonymousId, and `uniqExact(anonymous_id)` on the warehouse side
1512
+ * counted 0 visitors for the entire authenticated app.
1513
+ *
1514
+ * Bank-grade rule: the server is the single source of truth on
1515
+ * dedup. Send everything we know; let CH count by whichever axis
1516
+ * matches the question. Each field is at most 32 bytes — sending
1517
+ * three on every event costs ~80 bytes per request, which is
1518
+ * trivial compared to the analytics correctness it buys.
1519
+ */
1130
1520
  identityHintForEvent() {
1131
1521
  const s = this.requireStarted();
1522
+ const hint = {
1523
+ anonymousId: s.identity.anonymousId
1524
+ };
1525
+ if (s.developerUserId) hint.developerUserId = s.developerUserId;
1132
1526
  if (s.identity.crossdeckCustomerId) {
1133
- return { crossdeckCustomerId: s.identity.crossdeckCustomerId };
1527
+ hint.crossdeckCustomerId = s.identity.crossdeckCustomerId;
1134
1528
  }
1135
- if (s.developerUserId) return { developerUserId: s.developerUserId };
1136
- return { anonymousId: s.identity.anonymousId };
1529
+ return hint;
1137
1530
  }
1138
1531
  mintEventId() {
1139
1532
  const ts = Date.now().toString(36);
@@ -1146,9 +1539,21 @@ function inferEnvFromKey(publicKey) {
1146
1539
  if (publicKey.startsWith("cd_pub_live_")) return "production";
1147
1540
  return null;
1148
1541
  }
1542
+ function isLocalHostname() {
1543
+ const w = globalThis.window;
1544
+ const hostname = w?.location?.hostname;
1545
+ if (!hostname) return false;
1546
+ if (hostname === "localhost" || hostname === "127.0.0.1") return true;
1547
+ if (hostname === "::1" || hostname === "[::1]") return true;
1548
+ if (hostname.endsWith(".local")) return true;
1549
+ if (/^10\./.test(hostname)) return true;
1550
+ if (/^192\.168\./.test(hostname)) return true;
1551
+ if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname)) return true;
1552
+ return false;
1553
+ }
1149
1554
  function resolveAutoTrack(input) {
1150
1555
  if (input === false) {
1151
- return { sessions: false, pageViews: false, deviceInfo: false };
1556
+ return { sessions: false, pageViews: false, deviceInfo: false, clicks: false };
1152
1557
  }
1153
1558
  if (input === void 0 || input === true) {
1154
1559
  return { ...DEFAULT_AUTO_TRACK };
@@ -1156,7 +1561,8 @@ function resolveAutoTrack(input) {
1156
1561
  return {
1157
1562
  sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
1158
1563
  pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
1159
- deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
1564
+ deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
1565
+ clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
1160
1566
  };
1161
1567
  }
1162
1568
  function installUnloadFlush(onUnload) {