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