@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.cjs CHANGED
@@ -78,7 +78,7 @@ function typeMapForStatus(status) {
78
78
 
79
79
  // src/http.ts
80
80
  var SDK_NAME = "@cross-deck/web";
81
- var SDK_VERSION = "0.5.0";
81
+ var SDK_VERSION = "0.6.0";
82
82
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
83
83
  var HttpClient = class {
84
84
  constructor(config) {
@@ -94,6 +94,9 @@ var HttpClient = class {
94
94
  * - JSON parse failure on a 2xx (treated as `internal_error`)
95
95
  */
96
96
  async request(method, path, options = {}) {
97
+ if (this.config.localDevMode) {
98
+ return synthesizeLocalDevResponse(path);
99
+ }
97
100
  const url = this.buildUrl(path, options.query);
98
101
  const headers = {
99
102
  Authorization: `Bearer ${this.config.publicKey}`,
@@ -136,6 +139,14 @@ var HttpClient = class {
136
139
  });
137
140
  }
138
141
  }
142
+ /**
143
+ * Whether this client is in localhost dev-mode short-circuit. Used
144
+ * by other SDK pieces (event-queue) to skip network-bound work
145
+ * entirely rather than going through synthesizeLocalDevResponse.
146
+ */
147
+ get isLocalDevMode() {
148
+ return this.config.localDevMode === true;
149
+ }
139
150
  buildUrl(path, query) {
140
151
  const base = this.config.baseUrl.replace(/\/+$/, "");
141
152
  const cleanPath = path.startsWith("/") ? path : `/${path}`;
@@ -151,24 +162,73 @@ var HttpClient = class {
151
162
  return url;
152
163
  }
153
164
  };
165
+ var cachedLocalCdcust = null;
166
+ function synthesizeLocalDevResponse(path) {
167
+ if (path.startsWith("/sdk/heartbeat")) {
168
+ return {
169
+ object: "heartbeat",
170
+ ok: true,
171
+ projectId: "proj_local_dev",
172
+ appId: "app_local_dev",
173
+ platform: "web",
174
+ env: "sandbox",
175
+ serverTime: Date.now()
176
+ };
177
+ }
178
+ if (path.startsWith("/identity/alias")) {
179
+ if (!cachedLocalCdcust) {
180
+ const tail = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().replace(/-/g, "").slice(0, 16) : Math.random().toString(36).slice(2, 18);
181
+ cachedLocalCdcust = `cdcust_local_${tail}`;
182
+ }
183
+ return {
184
+ object: "alias_result",
185
+ crossdeckCustomerId: cachedLocalCdcust,
186
+ linked: [],
187
+ mergePending: false,
188
+ env: "sandbox"
189
+ };
190
+ }
191
+ if (path.startsWith("/entitlements")) {
192
+ return {
193
+ object: "list",
194
+ data: [],
195
+ crossdeckCustomerId: cachedLocalCdcust ?? "",
196
+ env: "sandbox"
197
+ };
198
+ }
199
+ if (path.startsWith("/events")) {
200
+ return {
201
+ object: "list",
202
+ received: 0,
203
+ env: "sandbox"
204
+ };
205
+ }
206
+ return {};
207
+ }
154
208
 
155
209
  // src/identity.ts
156
210
  var KEY_ANON = "anon_id";
157
211
  var KEY_CDCUST = "cdcust_id";
158
212
  var IdentityStore = class {
159
- constructor(storage, prefix) {
160
- this.storage = storage;
213
+ constructor(primary, prefix, secondary) {
214
+ this.primary = primary;
161
215
  this.prefix = prefix;
162
- const stored = {
163
- anon: storage.getItem(prefix + KEY_ANON),
164
- cdcust: storage.getItem(prefix + KEY_CDCUST)
165
- };
216
+ this.secondary = secondary ?? null;
217
+ const anonFromPrimary = primary.getItem(prefix + KEY_ANON);
218
+ const cdcustFromPrimary = primary.getItem(prefix + KEY_CDCUST);
219
+ const anonFromSecondary = this.secondary?.getItem(prefix + KEY_ANON) ?? null;
220
+ const cdcustFromSecondary = this.secondary?.getItem(prefix + KEY_CDCUST) ?? null;
221
+ const anon = anonFromPrimary ?? anonFromSecondary;
222
+ const cdcust = cdcustFromPrimary ?? cdcustFromSecondary;
166
223
  this.state = {
167
- anonymousId: stored.anon ?? this.mintAnonymousId(),
168
- crossdeckCustomerId: stored.cdcust
224
+ anonymousId: anon ?? this.mintAnonymousId(),
225
+ crossdeckCustomerId: cdcust
169
226
  };
170
- if (!stored.anon) {
171
- storage.setItem(prefix + KEY_ANON, this.state.anonymousId);
227
+ if (!anonFromPrimary || !anonFromSecondary) {
228
+ this.writeBoth(prefix + KEY_ANON, this.state.anonymousId);
229
+ }
230
+ if (cdcust && (!cdcustFromPrimary || !cdcustFromSecondary)) {
231
+ this.writeBoth(prefix + KEY_CDCUST, cdcust);
172
232
  }
173
233
  }
174
234
  /** Return the persisted anonymous device ID (always set). */
@@ -182,7 +242,7 @@ var IdentityStore = class {
182
242
  /** Persist a newly-resolved Crossdeck customer ID. */
183
243
  setCrossdeckCustomerId(value) {
184
244
  this.state.crossdeckCustomerId = value;
185
- this.storage.setItem(this.prefix + KEY_CDCUST, value);
245
+ this.writeBoth(this.prefix + KEY_CDCUST, value);
186
246
  }
187
247
  /**
188
248
  * Wipe persisted identity. Called by reset() — used when an end-user
@@ -190,13 +250,13 @@ var IdentityStore = class {
190
250
  * pre-login session is a fresh customer in the identity graph.
191
251
  */
192
252
  reset() {
193
- this.storage.removeItem(this.prefix + KEY_ANON);
194
- this.storage.removeItem(this.prefix + KEY_CDCUST);
253
+ this.deleteBoth(this.prefix + KEY_ANON);
254
+ this.deleteBoth(this.prefix + KEY_CDCUST);
195
255
  this.state = {
196
256
  anonymousId: this.mintAnonymousId(),
197
257
  crossdeckCustomerId: null
198
258
  };
199
- this.storage.setItem(this.prefix + KEY_ANON, this.state.anonymousId);
259
+ this.writeBoth(this.prefix + KEY_ANON, this.state.anonymousId);
200
260
  }
201
261
  /**
202
262
  * Generate an anonymousId. Crockford-ish base32 timestamp + random
@@ -208,6 +268,30 @@ var IdentityStore = class {
208
268
  const rand = randomChars(10);
209
269
  return `anon_${ts}${rand}`;
210
270
  }
271
+ writeBoth(key, value) {
272
+ try {
273
+ this.primary.setItem(key, value);
274
+ } catch {
275
+ }
276
+ if (this.secondary) {
277
+ try {
278
+ this.secondary.setItem(key, value);
279
+ } catch {
280
+ }
281
+ }
282
+ }
283
+ deleteBoth(key) {
284
+ try {
285
+ this.primary.removeItem(key);
286
+ } catch {
287
+ }
288
+ if (this.secondary) {
289
+ try {
290
+ this.secondary.removeItem(key);
291
+ } catch {
292
+ }
293
+ }
294
+ }
211
295
  };
212
296
  function randomChars(count) {
213
297
  const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
@@ -433,6 +517,59 @@ var MemoryStorage = class {
433
517
  this.store.delete(key);
434
518
  }
435
519
  };
520
+ var CookieStorage = class {
521
+ constructor(options) {
522
+ this.maxAgeSec = options?.maxAgeSec ?? 63072e3;
523
+ this.secure = options?.secure ?? defaultSecure();
524
+ this.sameSite = options?.sameSite ?? "Lax";
525
+ }
526
+ getItem(key) {
527
+ if (!hasDocument()) return null;
528
+ const doc = globalThis.document;
529
+ const cookies = doc.cookie ? doc.cookie.split(/;\s*/) : [];
530
+ const prefix = encodeURIComponent(key) + "=";
531
+ for (const c of cookies) {
532
+ if (c.startsWith(prefix)) {
533
+ try {
534
+ return decodeURIComponent(c.slice(prefix.length));
535
+ } catch {
536
+ return null;
537
+ }
538
+ }
539
+ }
540
+ return null;
541
+ }
542
+ setItem(key, value) {
543
+ if (!hasDocument()) return;
544
+ const doc = globalThis.document;
545
+ const parts = [
546
+ `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
547
+ "Path=/",
548
+ `Max-Age=${this.maxAgeSec}`,
549
+ `SameSite=${this.sameSite}`
550
+ ];
551
+ if (this.secure) parts.push("Secure");
552
+ try {
553
+ doc.cookie = parts.join("; ");
554
+ } catch {
555
+ }
556
+ }
557
+ removeItem(key) {
558
+ if (!hasDocument()) return;
559
+ const doc = globalThis.document;
560
+ const parts = [
561
+ `${encodeURIComponent(key)}=`,
562
+ "Path=/",
563
+ "Max-Age=0",
564
+ `SameSite=${this.sameSite}`
565
+ ];
566
+ if (this.secure) parts.push("Secure");
567
+ try {
568
+ doc.cookie = parts.join("; ");
569
+ } catch {
570
+ }
571
+ }
572
+ };
436
573
  function detectDefaultStorage() {
437
574
  try {
438
575
  const ls = globalThis.localStorage;
@@ -446,6 +583,17 @@ function detectDefaultStorage() {
446
583
  }
447
584
  return new MemoryStorage();
448
585
  }
586
+ function defaultSecure() {
587
+ try {
588
+ const loc = globalThis.location;
589
+ return loc?.protocol === "https:";
590
+ } catch {
591
+ return false;
592
+ }
593
+ }
594
+ function hasDocument() {
595
+ return typeof globalThis.document !== "undefined";
596
+ }
449
597
 
450
598
  // src/device-info.ts
451
599
  function isBrowser() {
@@ -543,9 +691,18 @@ function parseUserAgent(ua) {
543
691
  var DEFAULT_AUTO_TRACK = {
544
692
  sessions: true,
545
693
  pageViews: true,
546
- deviceInfo: true
694
+ deviceInfo: true,
695
+ clicks: true
547
696
  };
548
697
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
698
+ var EMPTY_ACQUISITION = {
699
+ utm_source: "",
700
+ utm_medium: "",
701
+ utm_campaign: "",
702
+ utm_content: "",
703
+ utm_term: "",
704
+ referrer: ""
705
+ };
549
706
  var AutoTracker = class {
550
707
  constructor(cfg, track) {
551
708
  this.cfg = cfg;
@@ -557,6 +714,7 @@ var AutoTracker = class {
557
714
  if (!isBrowserSafe()) return;
558
715
  if (this.cfg.sessions) this.installSessionTracking();
559
716
  if (this.cfg.pageViews) this.installPageViewTracking();
717
+ if (this.cfg.clicks) this.installClickTracking();
560
718
  }
561
719
  uninstall() {
562
720
  while (this.cleanups.length) {
@@ -581,6 +739,18 @@ var AutoTracker = class {
581
739
  get currentSessionId() {
582
740
  return this.session?.sessionId ?? null;
583
741
  }
742
+ /**
743
+ * Per-session acquisition context — utm_* + referrer, captured once
744
+ * at session start. Returns empty strings when there's no session
745
+ * (Node, before init, after uninstall) so callers can spread without
746
+ * conditional logic. Bank-grade rule: capture once, attach to every
747
+ * event of the session, don't re-read on every track() (the URL
748
+ * changes via SPA pushState; the source-of-record is the URL we
749
+ * landed on).
750
+ */
751
+ get currentAcquisition() {
752
+ return this.session?.acquisition ?? EMPTY_ACQUISITION;
753
+ }
584
754
  // ---------- sessions ----------
585
755
  installSessionTracking() {
586
756
  this.session = this.startNewSession();
@@ -618,7 +788,8 @@ var AutoTracker = class {
618
788
  sessionId: mintSessionId(),
619
789
  startedAt: Date.now(),
620
790
  hiddenAt: null,
621
- endedSent: false
791
+ endedSent: false,
792
+ acquisition: captureAcquisition()
622
793
  };
623
794
  }
624
795
  emitSessionStart() {
@@ -638,11 +809,19 @@ var AutoTracker = class {
638
809
  installPageViewTracking() {
639
810
  const w = globalThis.window;
640
811
  const doc = globalThis.document;
641
- const fire = () => {
812
+ let lastFiredAt = 0;
813
+ let lastFiredUrl = "";
814
+ const DEDUP_WINDOW_MS = 250;
815
+ const fire = (force = false) => {
642
816
  const loc = w.location;
817
+ const url = loc.href;
818
+ const now = Date.now();
819
+ if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
820
+ lastFiredAt = now;
821
+ lastFiredUrl = url;
643
822
  this.track("page.viewed", {
644
823
  path: loc.pathname,
645
- url: loc.href,
824
+ url,
646
825
  search: loc.search || void 0,
647
826
  hash: loc.hash || void 0,
648
827
  title: doc.title,
@@ -664,7 +843,7 @@ var AutoTracker = class {
664
843
  }
665
844
  w.history.pushState = patchedPush;
666
845
  w.history.replaceState = patchedReplace;
667
- const onPopState = () => fire();
846
+ const onPopState = () => fire(true);
668
847
  w.addEventListener("popstate", onPopState);
669
848
  this.cleanups.push(() => {
670
849
  if (w.history.pushState === patchedPush) {
@@ -676,7 +855,156 @@ var AutoTracker = class {
676
855
  w.removeEventListener("popstate", onPopState);
677
856
  });
678
857
  }
858
+ // ---------- click autocapture ----------
859
+ /**
860
+ * Global click tracking — Mixpanel / Amplitude style autocapture.
861
+ * Fires `element.clicked` for every interactive click with the
862
+ * target element's selector path, text content, tag, href, data-*
863
+ * attributes, and viewport coordinates. Powers the funnel /
864
+ * attribution USP: "users who clicked X then converted within
865
+ * 7 days." Default ON because behavioural attribution is the
866
+ * core product promise.
867
+ *
868
+ * Privacy guardrails:
869
+ * - Skip clicks ON inputs / textareas / selects (form interaction
870
+ * isn't button telemetry; the dev should track form submits
871
+ * deliberately via track('form_submitted'))
872
+ * - Skip clicks INSIDE [type="password"] and password-class
873
+ * elements
874
+ * - Skip clicks inside elements opted out via class="cd-noTrack"
875
+ * or data-cd-noTrack attribute (Mixpanel's exact opt-out
876
+ * idiom — most devs already know it)
877
+ * - Capture text content but cap at 64 chars and trim — never
878
+ * more than what you'd see on a button label
879
+ *
880
+ * Volume guardrails:
881
+ * - Coalesce double-clicks within 100ms (React's synthetic click
882
+ * pattern + browser's native dblclick can fire twice)
883
+ * - Listen on document at capture phase so we see the click
884
+ * before any framework's own handlers stop propagation
885
+ */
886
+ installClickTracking() {
887
+ const w = globalThis.window;
888
+ const doc = globalThis.document;
889
+ let lastFiredAt = 0;
890
+ let lastFiredTarget = null;
891
+ const COALESCE_MS = 100;
892
+ const TEXT_CAP = 64;
893
+ const onClick = (ev) => {
894
+ const target = ev.target;
895
+ if (!target || !(target instanceof Element)) return;
896
+ const now = Date.now();
897
+ if (target === lastFiredTarget && now - lastFiredAt < COALESCE_MS) return;
898
+ lastFiredAt = now;
899
+ lastFiredTarget = target;
900
+ const actionable = closestActionable(target);
901
+ const clicked = actionable || target;
902
+ if (isFormInput(clicked)) return;
903
+ if (isInOptedOut(clicked)) return;
904
+ if (isInsidePasswordField(clicked)) return;
905
+ const tag = clicked.tagName.toLowerCase();
906
+ const text = trimText(extractText(clicked), TEXT_CAP);
907
+ const href = clicked.href || void 0;
908
+ const linkTarget = clicked.target || void 0;
909
+ const elementId = clicked.id || void 0;
910
+ const role = clicked.getAttribute("role") || void 0;
911
+ const ariaLabel = clicked.getAttribute("aria-label") || void 0;
912
+ const selector = buildSelector(clicked);
913
+ const dataAttrs = collectDataAttrs(clicked);
914
+ const isLink = tag === "a" && !!href;
915
+ const explicitName = clicked.getAttribute("data-cd-event");
916
+ const props = {
917
+ selector,
918
+ tag,
919
+ text,
920
+ elementId,
921
+ role,
922
+ ariaLabel,
923
+ href,
924
+ isLink,
925
+ linkTarget,
926
+ viewportX: ev.clientX,
927
+ viewportY: ev.clientY,
928
+ pageX: ev.pageX,
929
+ pageY: ev.pageY,
930
+ ...dataAttrs
931
+ };
932
+ for (const k of Object.keys(props)) {
933
+ if (props[k] === void 0 || props[k] === null || props[k] === "") delete props[k];
934
+ }
935
+ this.track(explicitName || "element.clicked", props);
936
+ };
937
+ doc.addEventListener("click", onClick, { capture: true, passive: true });
938
+ this.cleanups.push(() => {
939
+ doc.removeEventListener("click", onClick, { capture: true });
940
+ });
941
+ }
679
942
  };
943
+ function closestActionable(el) {
944
+ 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;
945
+ }
946
+ function isFormInput(el) {
947
+ if (!(el instanceof HTMLElement)) return false;
948
+ const tag = el.tagName.toLowerCase();
949
+ if (tag === "textarea" || tag === "select") return true;
950
+ if (tag === "input") {
951
+ const type = (el.type || "").toLowerCase();
952
+ return type !== "button" && type !== "submit" && type !== "image" && type !== "reset";
953
+ }
954
+ return false;
955
+ }
956
+ function isInOptedOut(el) {
957
+ if (el.closest("[data-cd-noTrack], [data-cd-no-track], .cd-noTrack, .cd-no-track")) return true;
958
+ return false;
959
+ }
960
+ function isInsidePasswordField(el) {
961
+ if (el.closest('input[type="password"]')) return true;
962
+ return false;
963
+ }
964
+ function extractText(el) {
965
+ const aria = el.getAttribute("aria-label");
966
+ if (aria) return aria.replace(/\s+/g, " ").trim();
967
+ if (el instanceof HTMLInputElement && el.value) return el.value;
968
+ const text = (el.textContent || "").replace(/\s+/g, " ").trim();
969
+ return text;
970
+ }
971
+ function trimText(s, cap) {
972
+ if (s.length <= cap) return s;
973
+ return s.slice(0, cap - 1) + "\u2026";
974
+ }
975
+ function buildSelector(el) {
976
+ const parts = [];
977
+ let cur = el;
978
+ let depth = 0;
979
+ while (cur && cur.nodeName.toLowerCase() !== "body" && depth < 5) {
980
+ let part = cur.nodeName.toLowerCase();
981
+ if (cur.id) {
982
+ parts.unshift(`${part}#${cur.id}`);
983
+ break;
984
+ }
985
+ if (cur.classList.length > 0) {
986
+ const cls = Array.from(cur.classList).filter((c) => !c.startsWith("cd-")).slice(0, 2).join(".");
987
+ if (cls) part += `.${cls}`;
988
+ }
989
+ parts.unshift(part);
990
+ cur = cur.parentElement;
991
+ depth++;
992
+ }
993
+ return parts.join(" > ");
994
+ }
995
+ function collectDataAttrs(el) {
996
+ const out = {};
997
+ if (!(el instanceof HTMLElement)) return out;
998
+ for (const name of el.getAttributeNames()) {
999
+ if (!name.startsWith("data-")) continue;
1000
+ if (name === "data-cd-noTrack" || name === "data-cd-no-track") continue;
1001
+ if (name === "data-cd-event") continue;
1002
+ const value = el.getAttribute(name) || "";
1003
+ const key = name.replace(/^data-cd-prop-/, "").replace(/^data-/, "");
1004
+ out[key] = value;
1005
+ }
1006
+ return out;
1007
+ }
680
1008
  function isBrowserSafe() {
681
1009
  return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
682
1010
  }
@@ -684,6 +1012,26 @@ function mintSessionId() {
684
1012
  const ts = Date.now().toString(36);
685
1013
  return `sess_${ts}${randomChars(10)}`;
686
1014
  }
1015
+ function captureAcquisition() {
1016
+ if (!isBrowserSafe()) return { ...EMPTY_ACQUISITION };
1017
+ const result = { ...EMPTY_ACQUISITION };
1018
+ try {
1019
+ const w = globalThis.window;
1020
+ const params = new URLSearchParams(w.location.search ?? "");
1021
+ result.utm_source = params.get("utm_source") ?? "";
1022
+ result.utm_medium = params.get("utm_medium") ?? "";
1023
+ result.utm_campaign = params.get("utm_campaign") ?? "";
1024
+ result.utm_content = params.get("utm_content") ?? "";
1025
+ result.utm_term = params.get("utm_term") ?? "";
1026
+ } catch {
1027
+ }
1028
+ try {
1029
+ const doc = globalThis.document;
1030
+ if (typeof doc.referrer === "string") result.referrer = doc.referrer;
1031
+ } catch {
1032
+ }
1033
+ return result;
1034
+ }
687
1035
 
688
1036
  // src/debug.ts
689
1037
  var SENSITIVE_KEY_PATTERNS = [
@@ -776,6 +1124,7 @@ var CrossdeckClient = class {
776
1124
  message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
777
1125
  });
778
1126
  }
1127
+ const localDevMode = isLocalHostname();
779
1128
  const storage = options.storage ?? detectDefaultStorage();
780
1129
  const persistIdentity = options.persistIdentity ?? true;
781
1130
  const autoTrack = resolveAutoTrack(options.autoTrack);
@@ -802,10 +1151,23 @@ var CrossdeckClient = class {
802
1151
  const http = new HttpClient({
803
1152
  publicKey: opts.publicKey,
804
1153
  baseUrl: opts.baseUrl,
805
- sdkVersion: opts.sdkVersion
1154
+ sdkVersion: opts.sdkVersion,
1155
+ // Localhost auto-route: HttpClient short-circuits every request
1156
+ // to a successful no-op response when localDevMode is set.
1157
+ // SDK methods continue to work locally; nothing reaches the
1158
+ // server.
1159
+ localDevMode
806
1160
  });
1161
+ if (localDevMode) {
1162
+ console.log(
1163
+ "[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."
1164
+ );
1165
+ }
807
1166
  const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
808
- const identity = new IdentityStore(effectiveStorage, opts.storagePrefix);
1167
+ const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
1168
+ typeof globalThis.document !== "undefined";
1169
+ const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
1170
+ const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
809
1171
  const entitlements = new EntitlementCache();
810
1172
  const events = new EventQueue({
811
1173
  http,
@@ -853,7 +1215,7 @@ var CrossdeckClient = class {
853
1215
  this.state.uninstallUnloadFlush = installUnloadFlush(() => {
854
1216
  void this.flush({ keepalive: true }).catch(() => void 0);
855
1217
  });
856
- if (opts.autoHeartbeat) {
1218
+ if (opts.autoHeartbeat && !localDevMode) {
857
1219
  void this.heartbeat().catch(() => void 0);
858
1220
  }
859
1221
  }
@@ -986,6 +1348,15 @@ var CrossdeckClient = class {
986
1348
  const enriched = { ...s.deviceInfo };
987
1349
  const sessionId = s.autoTracker?.currentSessionId;
988
1350
  if (sessionId) enriched.sessionId = sessionId;
1351
+ const acquisition = s.autoTracker?.currentAcquisition;
1352
+ if (acquisition) {
1353
+ if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
1354
+ if (acquisition.utm_medium) enriched.utm_medium = acquisition.utm_medium;
1355
+ if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
1356
+ if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
1357
+ if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
1358
+ if (acquisition.referrer) enriched.referrer = acquisition.referrer;
1359
+ }
989
1360
  if (properties) Object.assign(enriched, properties);
990
1361
  const event = {
991
1362
  eventId: this.mintEventId(),
@@ -1078,6 +1449,12 @@ var CrossdeckClient = class {
1078
1449
  */
1079
1450
  reset() {
1080
1451
  if (!this.state) return;
1452
+ if (this.state.developerUserId) {
1453
+ try {
1454
+ this.track("user.signed_out", { auto: true });
1455
+ } catch {
1456
+ }
1457
+ }
1081
1458
  this.state.autoTracker?.uninstall();
1082
1459
  this.state.identity.reset();
1083
1460
  this.state.entitlements.clear();
@@ -1158,14 +1535,30 @@ var CrossdeckClient = class {
1158
1535
  if (s.developerUserId) return { userId: s.developerUserId };
1159
1536
  return { anonymousId: s.identity.anonymousId };
1160
1537
  }
1161
- /** Pick the right identity hint to embed on a queued event. */
1538
+ /**
1539
+ * Embed every known identity axis on the event. Earlier this returned
1540
+ * just the highest-priority hint (cdcust → developerUserId → anonymousId)
1541
+ * to keep payloads small, but that leaked into analytics: once a user
1542
+ * was logged in, every subsequent page.viewed shipped without
1543
+ * anonymousId, and `uniqExact(anonymous_id)` on the warehouse side
1544
+ * counted 0 visitors for the entire authenticated app.
1545
+ *
1546
+ * Bank-grade rule: the server is the single source of truth on
1547
+ * dedup. Send everything we know; let CH count by whichever axis
1548
+ * matches the question. Each field is at most 32 bytes — sending
1549
+ * three on every event costs ~80 bytes per request, which is
1550
+ * trivial compared to the analytics correctness it buys.
1551
+ */
1162
1552
  identityHintForEvent() {
1163
1553
  const s = this.requireStarted();
1554
+ const hint = {
1555
+ anonymousId: s.identity.anonymousId
1556
+ };
1557
+ if (s.developerUserId) hint.developerUserId = s.developerUserId;
1164
1558
  if (s.identity.crossdeckCustomerId) {
1165
- return { crossdeckCustomerId: s.identity.crossdeckCustomerId };
1559
+ hint.crossdeckCustomerId = s.identity.crossdeckCustomerId;
1166
1560
  }
1167
- if (s.developerUserId) return { developerUserId: s.developerUserId };
1168
- return { anonymousId: s.identity.anonymousId };
1561
+ return hint;
1169
1562
  }
1170
1563
  mintEventId() {
1171
1564
  const ts = Date.now().toString(36);
@@ -1178,9 +1571,21 @@ function inferEnvFromKey(publicKey) {
1178
1571
  if (publicKey.startsWith("cd_pub_live_")) return "production";
1179
1572
  return null;
1180
1573
  }
1574
+ function isLocalHostname() {
1575
+ const w = globalThis.window;
1576
+ const hostname = w?.location?.hostname;
1577
+ if (!hostname) return false;
1578
+ if (hostname === "localhost" || hostname === "127.0.0.1") return true;
1579
+ if (hostname === "::1" || hostname === "[::1]") return true;
1580
+ if (hostname.endsWith(".local")) return true;
1581
+ if (/^10\./.test(hostname)) return true;
1582
+ if (/^192\.168\./.test(hostname)) return true;
1583
+ if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname)) return true;
1584
+ return false;
1585
+ }
1181
1586
  function resolveAutoTrack(input) {
1182
1587
  if (input === false) {
1183
- return { sessions: false, pageViews: false, deviceInfo: false };
1588
+ return { sessions: false, pageViews: false, deviceInfo: false, clicks: false };
1184
1589
  }
1185
1590
  if (input === void 0 || input === true) {
1186
1591
  return { ...DEFAULT_AUTO_TRACK };
@@ -1188,7 +1593,8 @@ function resolveAutoTrack(input) {
1188
1593
  return {
1189
1594
  sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
1190
1595
  pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
1191
- deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
1596
+ deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
1597
+ clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
1192
1598
  };
1193
1599
  }
1194
1600
  function installUnloadFlush(onUnload) {