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