@cross-deck/web 0.6.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.d.mts CHANGED
@@ -148,6 +148,15 @@ interface AutoTrackOptions {
148
148
  pageViews: boolean;
149
149
  /** Auto-attach os/browser/locale/screen/etc to every event's `properties`. Default true (browser only). */
150
150
  deviceInfo: boolean;
151
+ /**
152
+ * Click autocapture — fire `element.clicked` for every interactive
153
+ * click on the page. Default true. Mixpanel/Amplitude pattern. Powers
154
+ * Crossdeck's funnel-attribution USP ("clicked X then converted").
155
+ * Privacy: skips form inputs / password fields / [class~="cd-noTrack"]
156
+ * subtrees. Override on individual elements with data-cd-event="custom"
157
+ * or data-cd-prop-* for custom property tagging.
158
+ */
159
+ clicks: boolean;
151
160
  }
152
161
  /** Minimal interface for any pluggable key-value persistence. */
153
162
  interface KeyValueStorage {
@@ -402,7 +411,20 @@ declare class CrossdeckClient {
402
411
  * — matches the resolveCrossdeckCustomerId precedence on the server.
403
412
  */
404
413
  private identityQueryParams;
405
- /** Pick the right identity hint to embed on a queued event. */
414
+ /**
415
+ * Embed every known identity axis on the event. Earlier this returned
416
+ * just the highest-priority hint (cdcust → developerUserId → anonymousId)
417
+ * to keep payloads small, but that leaked into analytics: once a user
418
+ * was logged in, every subsequent page.viewed shipped without
419
+ * anonymousId, and `uniqExact(anonymous_id)` on the warehouse side
420
+ * counted 0 visitors for the entire authenticated app.
421
+ *
422
+ * Bank-grade rule: the server is the single source of truth on
423
+ * dedup. Send everything we know; let CH count by whichever axis
424
+ * matches the question. Each field is at most 32 bytes — sending
425
+ * three on every event costs ~80 bytes per request, which is
426
+ * trivial compared to the analytics correctness it buys.
427
+ */
406
428
  private identityHintForEvent;
407
429
  private mintEventId;
408
430
  }
package/dist/index.d.ts CHANGED
@@ -148,6 +148,15 @@ interface AutoTrackOptions {
148
148
  pageViews: boolean;
149
149
  /** Auto-attach os/browser/locale/screen/etc to every event's `properties`. Default true (browser only). */
150
150
  deviceInfo: boolean;
151
+ /**
152
+ * Click autocapture — fire `element.clicked` for every interactive
153
+ * click on the page. Default true. Mixpanel/Amplitude pattern. Powers
154
+ * Crossdeck's funnel-attribution USP ("clicked X then converted").
155
+ * Privacy: skips form inputs / password fields / [class~="cd-noTrack"]
156
+ * subtrees. Override on individual elements with data-cd-event="custom"
157
+ * or data-cd-prop-* for custom property tagging.
158
+ */
159
+ clicks: boolean;
151
160
  }
152
161
  /** Minimal interface for any pluggable key-value persistence. */
153
162
  interface KeyValueStorage {
@@ -402,7 +411,20 @@ declare class CrossdeckClient {
402
411
  * — matches the resolveCrossdeckCustomerId precedence on the server.
403
412
  */
404
413
  private identityQueryParams;
405
- /** Pick the right identity hint to embed on a queued event. */
414
+ /**
415
+ * Embed every known identity axis on the event. Earlier this returned
416
+ * just the highest-priority hint (cdcust → developerUserId → anonymousId)
417
+ * to keep payloads small, but that leaked into analytics: once a user
418
+ * was logged in, every subsequent page.viewed shipped without
419
+ * anonymousId, and `uniqExact(anonymous_id)` on the warehouse side
420
+ * counted 0 visitors for the entire authenticated app.
421
+ *
422
+ * Bank-grade rule: the server is the single source of truth on
423
+ * dedup. Send everything we know; let CH count by whichever axis
424
+ * matches the question. Each field is at most 32 bytes — sending
425
+ * three on every event costs ~80 bytes per request, which is
426
+ * trivial compared to the analytics correctness it buys.
427
+ */
406
428
  private identityHintForEvent;
407
429
  private mintEventId;
408
430
  }
package/dist/index.mjs CHANGED
@@ -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,6 +130,49 @@ 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";
@@ -605,7 +659,8 @@ function parseUserAgent(ua) {
605
659
  var DEFAULT_AUTO_TRACK = {
606
660
  sessions: true,
607
661
  pageViews: true,
608
- deviceInfo: true
662
+ deviceInfo: true,
663
+ clicks: true
609
664
  };
610
665
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
611
666
  var EMPTY_ACQUISITION = {
@@ -627,6 +682,7 @@ var AutoTracker = class {
627
682
  if (!isBrowserSafe()) return;
628
683
  if (this.cfg.sessions) this.installSessionTracking();
629
684
  if (this.cfg.pageViews) this.installPageViewTracking();
685
+ if (this.cfg.clicks) this.installClickTracking();
630
686
  }
631
687
  uninstall() {
632
688
  while (this.cleanups.length) {
@@ -721,11 +777,19 @@ var AutoTracker = class {
721
777
  installPageViewTracking() {
722
778
  const w = globalThis.window;
723
779
  const doc = globalThis.document;
724
- const fire = () => {
780
+ let lastFiredAt = 0;
781
+ let lastFiredUrl = "";
782
+ const DEDUP_WINDOW_MS = 250;
783
+ const fire = (force = false) => {
725
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;
726
790
  this.track("page.viewed", {
727
791
  path: loc.pathname,
728
- url: loc.href,
792
+ url,
729
793
  search: loc.search || void 0,
730
794
  hash: loc.hash || void 0,
731
795
  title: doc.title,
@@ -747,7 +811,7 @@ var AutoTracker = class {
747
811
  }
748
812
  w.history.pushState = patchedPush;
749
813
  w.history.replaceState = patchedReplace;
750
- const onPopState = () => fire();
814
+ const onPopState = () => fire(true);
751
815
  w.addEventListener("popstate", onPopState);
752
816
  this.cleanups.push(() => {
753
817
  if (w.history.pushState === patchedPush) {
@@ -759,7 +823,156 @@ var AutoTracker = class {
759
823
  w.removeEventListener("popstate", onPopState);
760
824
  });
761
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
+ }
762
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
+ }
763
976
  function isBrowserSafe() {
764
977
  return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
765
978
  }
@@ -879,6 +1092,7 @@ var CrossdeckClient = class {
879
1092
  message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
880
1093
  });
881
1094
  }
1095
+ const localDevMode = isLocalHostname();
882
1096
  const storage = options.storage ?? detectDefaultStorage();
883
1097
  const persistIdentity = options.persistIdentity ?? true;
884
1098
  const autoTrack = resolveAutoTrack(options.autoTrack);
@@ -905,8 +1119,18 @@ var CrossdeckClient = class {
905
1119
  const http = new HttpClient({
906
1120
  publicKey: opts.publicKey,
907
1121
  baseUrl: opts.baseUrl,
908
- 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
909
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
+ }
910
1134
  const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
911
1135
  const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
912
1136
  typeof globalThis.document !== "undefined";
@@ -959,7 +1183,7 @@ var CrossdeckClient = class {
959
1183
  this.state.uninstallUnloadFlush = installUnloadFlush(() => {
960
1184
  void this.flush({ keepalive: true }).catch(() => void 0);
961
1185
  });
962
- if (opts.autoHeartbeat) {
1186
+ if (opts.autoHeartbeat && !localDevMode) {
963
1187
  void this.heartbeat().catch(() => void 0);
964
1188
  }
965
1189
  }
@@ -1193,6 +1417,12 @@ var CrossdeckClient = class {
1193
1417
  */
1194
1418
  reset() {
1195
1419
  if (!this.state) return;
1420
+ if (this.state.developerUserId) {
1421
+ try {
1422
+ this.track("user.signed_out", { auto: true });
1423
+ } catch {
1424
+ }
1425
+ }
1196
1426
  this.state.autoTracker?.uninstall();
1197
1427
  this.state.identity.reset();
1198
1428
  this.state.entitlements.clear();
@@ -1273,14 +1503,30 @@ var CrossdeckClient = class {
1273
1503
  if (s.developerUserId) return { userId: s.developerUserId };
1274
1504
  return { anonymousId: s.identity.anonymousId };
1275
1505
  }
1276
- /** 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
+ */
1277
1520
  identityHintForEvent() {
1278
1521
  const s = this.requireStarted();
1522
+ const hint = {
1523
+ anonymousId: s.identity.anonymousId
1524
+ };
1525
+ if (s.developerUserId) hint.developerUserId = s.developerUserId;
1279
1526
  if (s.identity.crossdeckCustomerId) {
1280
- return { crossdeckCustomerId: s.identity.crossdeckCustomerId };
1527
+ hint.crossdeckCustomerId = s.identity.crossdeckCustomerId;
1281
1528
  }
1282
- if (s.developerUserId) return { developerUserId: s.developerUserId };
1283
- return { anonymousId: s.identity.anonymousId };
1529
+ return hint;
1284
1530
  }
1285
1531
  mintEventId() {
1286
1532
  const ts = Date.now().toString(36);
@@ -1293,9 +1539,21 @@ function inferEnvFromKey(publicKey) {
1293
1539
  if (publicKey.startsWith("cd_pub_live_")) return "production";
1294
1540
  return null;
1295
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
+ }
1296
1554
  function resolveAutoTrack(input) {
1297
1555
  if (input === false) {
1298
- return { sessions: false, pageViews: false, deviceInfo: false };
1556
+ return { sessions: false, pageViews: false, deviceInfo: false, clicks: false };
1299
1557
  }
1300
1558
  if (input === void 0 || input === true) {
1301
1559
  return { ...DEFAULT_AUTO_TRACK };
@@ -1303,7 +1561,8 @@ function resolveAutoTrack(input) {
1303
1561
  return {
1304
1562
  sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
1305
1563
  pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
1306
- deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
1564
+ deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
1565
+ clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
1307
1566
  };
1308
1567
  }
1309
1568
  function installUnloadFlush(onUnload) {