@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/react.cjs CHANGED
@@ -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,6 +158,49 @@ 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";
@@ -633,7 +687,8 @@ function parseUserAgent(ua) {
633
687
  var DEFAULT_AUTO_TRACK = {
634
688
  sessions: true,
635
689
  pageViews: true,
636
- deviceInfo: true
690
+ deviceInfo: true,
691
+ clicks: true
637
692
  };
638
693
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
639
694
  var EMPTY_ACQUISITION = {
@@ -655,6 +710,7 @@ var AutoTracker = class {
655
710
  if (!isBrowserSafe()) return;
656
711
  if (this.cfg.sessions) this.installSessionTracking();
657
712
  if (this.cfg.pageViews) this.installPageViewTracking();
713
+ if (this.cfg.clicks) this.installClickTracking();
658
714
  }
659
715
  uninstall() {
660
716
  while (this.cleanups.length) {
@@ -749,11 +805,19 @@ var AutoTracker = class {
749
805
  installPageViewTracking() {
750
806
  const w = globalThis.window;
751
807
  const doc = globalThis.document;
752
- const fire = () => {
808
+ let lastFiredAt = 0;
809
+ let lastFiredUrl = "";
810
+ const DEDUP_WINDOW_MS = 250;
811
+ const fire = (force = false) => {
753
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;
754
818
  this.track("page.viewed", {
755
819
  path: loc.pathname,
756
- url: loc.href,
820
+ url,
757
821
  search: loc.search || void 0,
758
822
  hash: loc.hash || void 0,
759
823
  title: doc.title,
@@ -775,7 +839,7 @@ var AutoTracker = class {
775
839
  }
776
840
  w.history.pushState = patchedPush;
777
841
  w.history.replaceState = patchedReplace;
778
- const onPopState = () => fire();
842
+ const onPopState = () => fire(true);
779
843
  w.addEventListener("popstate", onPopState);
780
844
  this.cleanups.push(() => {
781
845
  if (w.history.pushState === patchedPush) {
@@ -787,7 +851,156 @@ var AutoTracker = class {
787
851
  w.removeEventListener("popstate", onPopState);
788
852
  });
789
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
+ }
790
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
+ }
791
1004
  function isBrowserSafe() {
792
1005
  return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
793
1006
  }
@@ -907,6 +1120,7 @@ var CrossdeckClient = class {
907
1120
  message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
908
1121
  });
909
1122
  }
1123
+ const localDevMode = isLocalHostname();
910
1124
  const storage = options.storage ?? detectDefaultStorage();
911
1125
  const persistIdentity = options.persistIdentity ?? true;
912
1126
  const autoTrack = resolveAutoTrack(options.autoTrack);
@@ -933,8 +1147,18 @@ var CrossdeckClient = class {
933
1147
  const http = new HttpClient({
934
1148
  publicKey: opts.publicKey,
935
1149
  baseUrl: opts.baseUrl,
936
- 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
937
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
+ }
938
1162
  const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
939
1163
  const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
940
1164
  typeof globalThis.document !== "undefined";
@@ -987,7 +1211,7 @@ var CrossdeckClient = class {
987
1211
  this.state.uninstallUnloadFlush = installUnloadFlush(() => {
988
1212
  void this.flush({ keepalive: true }).catch(() => void 0);
989
1213
  });
990
- if (opts.autoHeartbeat) {
1214
+ if (opts.autoHeartbeat && !localDevMode) {
991
1215
  void this.heartbeat().catch(() => void 0);
992
1216
  }
993
1217
  }
@@ -1221,6 +1445,12 @@ var CrossdeckClient = class {
1221
1445
  */
1222
1446
  reset() {
1223
1447
  if (!this.state) return;
1448
+ if (this.state.developerUserId) {
1449
+ try {
1450
+ this.track("user.signed_out", { auto: true });
1451
+ } catch {
1452
+ }
1453
+ }
1224
1454
  this.state.autoTracker?.uninstall();
1225
1455
  this.state.identity.reset();
1226
1456
  this.state.entitlements.clear();
@@ -1301,14 +1531,30 @@ var CrossdeckClient = class {
1301
1531
  if (s.developerUserId) return { userId: s.developerUserId };
1302
1532
  return { anonymousId: s.identity.anonymousId };
1303
1533
  }
1304
- /** 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
+ */
1305
1548
  identityHintForEvent() {
1306
1549
  const s = this.requireStarted();
1550
+ const hint = {
1551
+ anonymousId: s.identity.anonymousId
1552
+ };
1553
+ if (s.developerUserId) hint.developerUserId = s.developerUserId;
1307
1554
  if (s.identity.crossdeckCustomerId) {
1308
- return { crossdeckCustomerId: s.identity.crossdeckCustomerId };
1555
+ hint.crossdeckCustomerId = s.identity.crossdeckCustomerId;
1309
1556
  }
1310
- if (s.developerUserId) return { developerUserId: s.developerUserId };
1311
- return { anonymousId: s.identity.anonymousId };
1557
+ return hint;
1312
1558
  }
1313
1559
  mintEventId() {
1314
1560
  const ts = Date.now().toString(36);
@@ -1321,9 +1567,21 @@ function inferEnvFromKey(publicKey) {
1321
1567
  if (publicKey.startsWith("cd_pub_live_")) return "production";
1322
1568
  return null;
1323
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
+ }
1324
1582
  function resolveAutoTrack(input) {
1325
1583
  if (input === false) {
1326
- return { sessions: false, pageViews: false, deviceInfo: false };
1584
+ return { sessions: false, pageViews: false, deviceInfo: false, clicks: false };
1327
1585
  }
1328
1586
  if (input === void 0 || input === true) {
1329
1587
  return { ...DEFAULT_AUTO_TRACK };
@@ -1331,7 +1589,8 @@ function resolveAutoTrack(input) {
1331
1589
  return {
1332
1590
  sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
1333
1591
  pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
1334
- deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
1592
+ deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
1593
+ clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
1335
1594
  };
1336
1595
  }
1337
1596
  function installUnloadFlush(onUnload) {