@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.cjs CHANGED
@@ -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,6 +162,49 @@ 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";
@@ -637,7 +691,8 @@ function parseUserAgent(ua) {
637
691
  var DEFAULT_AUTO_TRACK = {
638
692
  sessions: true,
639
693
  pageViews: true,
640
- deviceInfo: true
694
+ deviceInfo: true,
695
+ clicks: true
641
696
  };
642
697
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
643
698
  var EMPTY_ACQUISITION = {
@@ -659,6 +714,7 @@ var AutoTracker = class {
659
714
  if (!isBrowserSafe()) return;
660
715
  if (this.cfg.sessions) this.installSessionTracking();
661
716
  if (this.cfg.pageViews) this.installPageViewTracking();
717
+ if (this.cfg.clicks) this.installClickTracking();
662
718
  }
663
719
  uninstall() {
664
720
  while (this.cleanups.length) {
@@ -753,11 +809,19 @@ var AutoTracker = class {
753
809
  installPageViewTracking() {
754
810
  const w = globalThis.window;
755
811
  const doc = globalThis.document;
756
- const fire = () => {
812
+ let lastFiredAt = 0;
813
+ let lastFiredUrl = "";
814
+ const DEDUP_WINDOW_MS = 250;
815
+ const fire = (force = false) => {
757
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;
758
822
  this.track("page.viewed", {
759
823
  path: loc.pathname,
760
- url: loc.href,
824
+ url,
761
825
  search: loc.search || void 0,
762
826
  hash: loc.hash || void 0,
763
827
  title: doc.title,
@@ -779,7 +843,7 @@ var AutoTracker = class {
779
843
  }
780
844
  w.history.pushState = patchedPush;
781
845
  w.history.replaceState = patchedReplace;
782
- const onPopState = () => fire();
846
+ const onPopState = () => fire(true);
783
847
  w.addEventListener("popstate", onPopState);
784
848
  this.cleanups.push(() => {
785
849
  if (w.history.pushState === patchedPush) {
@@ -791,7 +855,156 @@ var AutoTracker = class {
791
855
  w.removeEventListener("popstate", onPopState);
792
856
  });
793
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
+ }
794
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
+ }
795
1008
  function isBrowserSafe() {
796
1009
  return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
797
1010
  }
@@ -911,6 +1124,7 @@ var CrossdeckClient = class {
911
1124
  message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
912
1125
  });
913
1126
  }
1127
+ const localDevMode = isLocalHostname();
914
1128
  const storage = options.storage ?? detectDefaultStorage();
915
1129
  const persistIdentity = options.persistIdentity ?? true;
916
1130
  const autoTrack = resolveAutoTrack(options.autoTrack);
@@ -937,8 +1151,18 @@ var CrossdeckClient = class {
937
1151
  const http = new HttpClient({
938
1152
  publicKey: opts.publicKey,
939
1153
  baseUrl: opts.baseUrl,
940
- 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
941
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
+ }
942
1166
  const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
943
1167
  const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
944
1168
  typeof globalThis.document !== "undefined";
@@ -991,7 +1215,7 @@ var CrossdeckClient = class {
991
1215
  this.state.uninstallUnloadFlush = installUnloadFlush(() => {
992
1216
  void this.flush({ keepalive: true }).catch(() => void 0);
993
1217
  });
994
- if (opts.autoHeartbeat) {
1218
+ if (opts.autoHeartbeat && !localDevMode) {
995
1219
  void this.heartbeat().catch(() => void 0);
996
1220
  }
997
1221
  }
@@ -1225,6 +1449,12 @@ var CrossdeckClient = class {
1225
1449
  */
1226
1450
  reset() {
1227
1451
  if (!this.state) return;
1452
+ if (this.state.developerUserId) {
1453
+ try {
1454
+ this.track("user.signed_out", { auto: true });
1455
+ } catch {
1456
+ }
1457
+ }
1228
1458
  this.state.autoTracker?.uninstall();
1229
1459
  this.state.identity.reset();
1230
1460
  this.state.entitlements.clear();
@@ -1305,14 +1535,30 @@ var CrossdeckClient = class {
1305
1535
  if (s.developerUserId) return { userId: s.developerUserId };
1306
1536
  return { anonymousId: s.identity.anonymousId };
1307
1537
  }
1308
- /** 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
+ */
1309
1552
  identityHintForEvent() {
1310
1553
  const s = this.requireStarted();
1554
+ const hint = {
1555
+ anonymousId: s.identity.anonymousId
1556
+ };
1557
+ if (s.developerUserId) hint.developerUserId = s.developerUserId;
1311
1558
  if (s.identity.crossdeckCustomerId) {
1312
- return { crossdeckCustomerId: s.identity.crossdeckCustomerId };
1559
+ hint.crossdeckCustomerId = s.identity.crossdeckCustomerId;
1313
1560
  }
1314
- if (s.developerUserId) return { developerUserId: s.developerUserId };
1315
- return { anonymousId: s.identity.anonymousId };
1561
+ return hint;
1316
1562
  }
1317
1563
  mintEventId() {
1318
1564
  const ts = Date.now().toString(36);
@@ -1325,9 +1571,21 @@ function inferEnvFromKey(publicKey) {
1325
1571
  if (publicKey.startsWith("cd_pub_live_")) return "production";
1326
1572
  return null;
1327
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
+ }
1328
1586
  function resolveAutoTrack(input) {
1329
1587
  if (input === false) {
1330
- return { sessions: false, pageViews: false, deviceInfo: false };
1588
+ return { sessions: false, pageViews: false, deviceInfo: false, clicks: false };
1331
1589
  }
1332
1590
  if (input === void 0 || input === true) {
1333
1591
  return { ...DEFAULT_AUTO_TRACK };
@@ -1335,7 +1593,8 @@ function resolveAutoTrack(input) {
1335
1593
  return {
1336
1594
  sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
1337
1595
  pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
1338
- deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
1596
+ deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
1597
+ clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
1339
1598
  };
1340
1599
  }
1341
1600
  function installUnloadFlush(onUnload) {