@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.mjs CHANGED
@@ -65,6 +65,9 @@ var HttpClient = class {
65
65
  * - JSON parse failure on a 2xx (treated as `internal_error`)
66
66
  */
67
67
  async request(method, path, options = {}) {
68
+ if (this.config.localDevMode) {
69
+ return synthesizeLocalDevResponse(path);
70
+ }
68
71
  const url = this.buildUrl(path, options.query);
69
72
  const headers = {
70
73
  Authorization: `Bearer ${this.config.publicKey}`,
@@ -107,6 +110,14 @@ var HttpClient = class {
107
110
  });
108
111
  }
109
112
  }
113
+ /**
114
+ * Whether this client is in localhost dev-mode short-circuit. Used
115
+ * by other SDK pieces (event-queue) to skip network-bound work
116
+ * entirely rather than going through synthesizeLocalDevResponse.
117
+ */
118
+ get isLocalDevMode() {
119
+ return this.config.localDevMode === true;
120
+ }
110
121
  buildUrl(path, query) {
111
122
  const base = this.config.baseUrl.replace(/\/+$/, "");
112
123
  const cleanPath = path.startsWith("/") ? path : `/${path}`;
@@ -122,6 +133,49 @@ var HttpClient = class {
122
133
  return url;
123
134
  }
124
135
  };
136
+ var cachedLocalCdcust = null;
137
+ function synthesizeLocalDevResponse(path) {
138
+ if (path.startsWith("/sdk/heartbeat")) {
139
+ return {
140
+ object: "heartbeat",
141
+ ok: true,
142
+ projectId: "proj_local_dev",
143
+ appId: "app_local_dev",
144
+ platform: "web",
145
+ env: "sandbox",
146
+ serverTime: Date.now()
147
+ };
148
+ }
149
+ if (path.startsWith("/identity/alias")) {
150
+ if (!cachedLocalCdcust) {
151
+ const tail = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().replace(/-/g, "").slice(0, 16) : Math.random().toString(36).slice(2, 18);
152
+ cachedLocalCdcust = `cdcust_local_${tail}`;
153
+ }
154
+ return {
155
+ object: "alias_result",
156
+ crossdeckCustomerId: cachedLocalCdcust,
157
+ linked: [],
158
+ mergePending: false,
159
+ env: "sandbox"
160
+ };
161
+ }
162
+ if (path.startsWith("/entitlements")) {
163
+ return {
164
+ object: "list",
165
+ data: [],
166
+ crossdeckCustomerId: cachedLocalCdcust ?? "",
167
+ env: "sandbox"
168
+ };
169
+ }
170
+ if (path.startsWith("/events")) {
171
+ return {
172
+ object: "list",
173
+ received: 0,
174
+ env: "sandbox"
175
+ };
176
+ }
177
+ return {};
178
+ }
125
179
 
126
180
  // src/identity.ts
127
181
  var KEY_ANON = "anon_id";
@@ -608,7 +662,8 @@ function parseUserAgent(ua) {
608
662
  var DEFAULT_AUTO_TRACK = {
609
663
  sessions: true,
610
664
  pageViews: true,
611
- deviceInfo: true
665
+ deviceInfo: true,
666
+ clicks: true
612
667
  };
613
668
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
614
669
  var EMPTY_ACQUISITION = {
@@ -630,6 +685,7 @@ var AutoTracker = class {
630
685
  if (!isBrowserSafe()) return;
631
686
  if (this.cfg.sessions) this.installSessionTracking();
632
687
  if (this.cfg.pageViews) this.installPageViewTracking();
688
+ if (this.cfg.clicks) this.installClickTracking();
633
689
  }
634
690
  uninstall() {
635
691
  while (this.cleanups.length) {
@@ -724,11 +780,19 @@ var AutoTracker = class {
724
780
  installPageViewTracking() {
725
781
  const w = globalThis.window;
726
782
  const doc = globalThis.document;
727
- const fire = () => {
783
+ let lastFiredAt = 0;
784
+ let lastFiredUrl = "";
785
+ const DEDUP_WINDOW_MS = 250;
786
+ const fire = (force = false) => {
728
787
  const loc = w.location;
788
+ const url = loc.href;
789
+ const now = Date.now();
790
+ if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
791
+ lastFiredAt = now;
792
+ lastFiredUrl = url;
729
793
  this.track("page.viewed", {
730
794
  path: loc.pathname,
731
- url: loc.href,
795
+ url,
732
796
  search: loc.search || void 0,
733
797
  hash: loc.hash || void 0,
734
798
  title: doc.title,
@@ -750,7 +814,7 @@ var AutoTracker = class {
750
814
  }
751
815
  w.history.pushState = patchedPush;
752
816
  w.history.replaceState = patchedReplace;
753
- const onPopState = () => fire();
817
+ const onPopState = () => fire(true);
754
818
  w.addEventListener("popstate", onPopState);
755
819
  this.cleanups.push(() => {
756
820
  if (w.history.pushState === patchedPush) {
@@ -762,7 +826,156 @@ var AutoTracker = class {
762
826
  w.removeEventListener("popstate", onPopState);
763
827
  });
764
828
  }
829
+ // ---------- click autocapture ----------
830
+ /**
831
+ * Global click tracking — Mixpanel / Amplitude style autocapture.
832
+ * Fires `element.clicked` for every interactive click with the
833
+ * target element's selector path, text content, tag, href, data-*
834
+ * attributes, and viewport coordinates. Powers the funnel /
835
+ * attribution USP: "users who clicked X then converted within
836
+ * 7 days." Default ON because behavioural attribution is the
837
+ * core product promise.
838
+ *
839
+ * Privacy guardrails:
840
+ * - Skip clicks ON inputs / textareas / selects (form interaction
841
+ * isn't button telemetry; the dev should track form submits
842
+ * deliberately via track('form_submitted'))
843
+ * - Skip clicks INSIDE [type="password"] and password-class
844
+ * elements
845
+ * - Skip clicks inside elements opted out via class="cd-noTrack"
846
+ * or data-cd-noTrack attribute (Mixpanel's exact opt-out
847
+ * idiom — most devs already know it)
848
+ * - Capture text content but cap at 64 chars and trim — never
849
+ * more than what you'd see on a button label
850
+ *
851
+ * Volume guardrails:
852
+ * - Coalesce double-clicks within 100ms (React's synthetic click
853
+ * pattern + browser's native dblclick can fire twice)
854
+ * - Listen on document at capture phase so we see the click
855
+ * before any framework's own handlers stop propagation
856
+ */
857
+ installClickTracking() {
858
+ const w = globalThis.window;
859
+ const doc = globalThis.document;
860
+ let lastFiredAt = 0;
861
+ let lastFiredTarget = null;
862
+ const COALESCE_MS = 100;
863
+ const TEXT_CAP = 64;
864
+ const onClick = (ev) => {
865
+ const target = ev.target;
866
+ if (!target || !(target instanceof Element)) return;
867
+ const now = Date.now();
868
+ if (target === lastFiredTarget && now - lastFiredAt < COALESCE_MS) return;
869
+ lastFiredAt = now;
870
+ lastFiredTarget = target;
871
+ const actionable = closestActionable(target);
872
+ const clicked = actionable || target;
873
+ if (isFormInput(clicked)) return;
874
+ if (isInOptedOut(clicked)) return;
875
+ if (isInsidePasswordField(clicked)) return;
876
+ const tag = clicked.tagName.toLowerCase();
877
+ const text = trimText(extractText(clicked), TEXT_CAP);
878
+ const href = clicked.href || void 0;
879
+ const linkTarget = clicked.target || void 0;
880
+ const elementId = clicked.id || void 0;
881
+ const role = clicked.getAttribute("role") || void 0;
882
+ const ariaLabel = clicked.getAttribute("aria-label") || void 0;
883
+ const selector = buildSelector(clicked);
884
+ const dataAttrs = collectDataAttrs(clicked);
885
+ const isLink = tag === "a" && !!href;
886
+ const explicitName = clicked.getAttribute("data-cd-event");
887
+ const props = {
888
+ selector,
889
+ tag,
890
+ text,
891
+ elementId,
892
+ role,
893
+ ariaLabel,
894
+ href,
895
+ isLink,
896
+ linkTarget,
897
+ viewportX: ev.clientX,
898
+ viewportY: ev.clientY,
899
+ pageX: ev.pageX,
900
+ pageY: ev.pageY,
901
+ ...dataAttrs
902
+ };
903
+ for (const k of Object.keys(props)) {
904
+ if (props[k] === void 0 || props[k] === null || props[k] === "") delete props[k];
905
+ }
906
+ this.track(explicitName || "element.clicked", props);
907
+ };
908
+ doc.addEventListener("click", onClick, { capture: true, passive: true });
909
+ this.cleanups.push(() => {
910
+ doc.removeEventListener("click", onClick, { capture: true });
911
+ });
912
+ }
765
913
  };
914
+ function closestActionable(el) {
915
+ 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;
916
+ }
917
+ function isFormInput(el) {
918
+ if (!(el instanceof HTMLElement)) return false;
919
+ const tag = el.tagName.toLowerCase();
920
+ if (tag === "textarea" || tag === "select") return true;
921
+ if (tag === "input") {
922
+ const type = (el.type || "").toLowerCase();
923
+ return type !== "button" && type !== "submit" && type !== "image" && type !== "reset";
924
+ }
925
+ return false;
926
+ }
927
+ function isInOptedOut(el) {
928
+ if (el.closest("[data-cd-noTrack], [data-cd-no-track], .cd-noTrack, .cd-no-track")) return true;
929
+ return false;
930
+ }
931
+ function isInsidePasswordField(el) {
932
+ if (el.closest('input[type="password"]')) return true;
933
+ return false;
934
+ }
935
+ function extractText(el) {
936
+ const aria = el.getAttribute("aria-label");
937
+ if (aria) return aria.replace(/\s+/g, " ").trim();
938
+ if (el instanceof HTMLInputElement && el.value) return el.value;
939
+ const text = (el.textContent || "").replace(/\s+/g, " ").trim();
940
+ return text;
941
+ }
942
+ function trimText(s, cap) {
943
+ if (s.length <= cap) return s;
944
+ return s.slice(0, cap - 1) + "\u2026";
945
+ }
946
+ function buildSelector(el) {
947
+ const parts = [];
948
+ let cur = el;
949
+ let depth = 0;
950
+ while (cur && cur.nodeName.toLowerCase() !== "body" && depth < 5) {
951
+ let part = cur.nodeName.toLowerCase();
952
+ if (cur.id) {
953
+ parts.unshift(`${part}#${cur.id}`);
954
+ break;
955
+ }
956
+ if (cur.classList.length > 0) {
957
+ const cls = Array.from(cur.classList).filter((c) => !c.startsWith("cd-")).slice(0, 2).join(".");
958
+ if (cls) part += `.${cls}`;
959
+ }
960
+ parts.unshift(part);
961
+ cur = cur.parentElement;
962
+ depth++;
963
+ }
964
+ return parts.join(" > ");
965
+ }
966
+ function collectDataAttrs(el) {
967
+ const out = {};
968
+ if (!(el instanceof HTMLElement)) return out;
969
+ for (const name of el.getAttributeNames()) {
970
+ if (!name.startsWith("data-")) continue;
971
+ if (name === "data-cd-noTrack" || name === "data-cd-no-track") continue;
972
+ if (name === "data-cd-event") continue;
973
+ const value = el.getAttribute(name) || "";
974
+ const key = name.replace(/^data-cd-prop-/, "").replace(/^data-/, "");
975
+ out[key] = value;
976
+ }
977
+ return out;
978
+ }
766
979
  function isBrowserSafe() {
767
980
  return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
768
981
  }
@@ -882,6 +1095,7 @@ var CrossdeckClient = class {
882
1095
  message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
883
1096
  });
884
1097
  }
1098
+ const localDevMode = isLocalHostname();
885
1099
  const storage = options.storage ?? detectDefaultStorage();
886
1100
  const persistIdentity = options.persistIdentity ?? true;
887
1101
  const autoTrack = resolveAutoTrack(options.autoTrack);
@@ -908,8 +1122,18 @@ var CrossdeckClient = class {
908
1122
  const http = new HttpClient({
909
1123
  publicKey: opts.publicKey,
910
1124
  baseUrl: opts.baseUrl,
911
- sdkVersion: opts.sdkVersion
1125
+ sdkVersion: opts.sdkVersion,
1126
+ // Localhost auto-route: HttpClient short-circuits every request
1127
+ // to a successful no-op response when localDevMode is set.
1128
+ // SDK methods continue to work locally; nothing reaches the
1129
+ // server.
1130
+ localDevMode
912
1131
  });
1132
+ if (localDevMode) {
1133
+ console.log(
1134
+ "[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."
1135
+ );
1136
+ }
913
1137
  const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
914
1138
  const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
915
1139
  typeof globalThis.document !== "undefined";
@@ -962,7 +1186,7 @@ var CrossdeckClient = class {
962
1186
  this.state.uninstallUnloadFlush = installUnloadFlush(() => {
963
1187
  void this.flush({ keepalive: true }).catch(() => void 0);
964
1188
  });
965
- if (opts.autoHeartbeat) {
1189
+ if (opts.autoHeartbeat && !localDevMode) {
966
1190
  void this.heartbeat().catch(() => void 0);
967
1191
  }
968
1192
  }
@@ -1196,6 +1420,12 @@ var CrossdeckClient = class {
1196
1420
  */
1197
1421
  reset() {
1198
1422
  if (!this.state) return;
1423
+ if (this.state.developerUserId) {
1424
+ try {
1425
+ this.track("user.signed_out", { auto: true });
1426
+ } catch {
1427
+ }
1428
+ }
1199
1429
  this.state.autoTracker?.uninstall();
1200
1430
  this.state.identity.reset();
1201
1431
  this.state.entitlements.clear();
@@ -1276,14 +1506,30 @@ var CrossdeckClient = class {
1276
1506
  if (s.developerUserId) return { userId: s.developerUserId };
1277
1507
  return { anonymousId: s.identity.anonymousId };
1278
1508
  }
1279
- /** Pick the right identity hint to embed on a queued event. */
1509
+ /**
1510
+ * Embed every known identity axis on the event. Earlier this returned
1511
+ * just the highest-priority hint (cdcust → developerUserId → anonymousId)
1512
+ * to keep payloads small, but that leaked into analytics: once a user
1513
+ * was logged in, every subsequent page.viewed shipped without
1514
+ * anonymousId, and `uniqExact(anonymous_id)` on the warehouse side
1515
+ * counted 0 visitors for the entire authenticated app.
1516
+ *
1517
+ * Bank-grade rule: the server is the single source of truth on
1518
+ * dedup. Send everything we know; let CH count by whichever axis
1519
+ * matches the question. Each field is at most 32 bytes — sending
1520
+ * three on every event costs ~80 bytes per request, which is
1521
+ * trivial compared to the analytics correctness it buys.
1522
+ */
1280
1523
  identityHintForEvent() {
1281
1524
  const s = this.requireStarted();
1525
+ const hint = {
1526
+ anonymousId: s.identity.anonymousId
1527
+ };
1528
+ if (s.developerUserId) hint.developerUserId = s.developerUserId;
1282
1529
  if (s.identity.crossdeckCustomerId) {
1283
- return { crossdeckCustomerId: s.identity.crossdeckCustomerId };
1530
+ hint.crossdeckCustomerId = s.identity.crossdeckCustomerId;
1284
1531
  }
1285
- if (s.developerUserId) return { developerUserId: s.developerUserId };
1286
- return { anonymousId: s.identity.anonymousId };
1532
+ return hint;
1287
1533
  }
1288
1534
  mintEventId() {
1289
1535
  const ts = Date.now().toString(36);
@@ -1296,9 +1542,21 @@ function inferEnvFromKey(publicKey) {
1296
1542
  if (publicKey.startsWith("cd_pub_live_")) return "production";
1297
1543
  return null;
1298
1544
  }
1545
+ function isLocalHostname() {
1546
+ const w = globalThis.window;
1547
+ const hostname = w?.location?.hostname;
1548
+ if (!hostname) return false;
1549
+ if (hostname === "localhost" || hostname === "127.0.0.1") return true;
1550
+ if (hostname === "::1" || hostname === "[::1]") return true;
1551
+ if (hostname.endsWith(".local")) return true;
1552
+ if (/^10\./.test(hostname)) return true;
1553
+ if (/^192\.168\./.test(hostname)) return true;
1554
+ if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname)) return true;
1555
+ return false;
1556
+ }
1299
1557
  function resolveAutoTrack(input) {
1300
1558
  if (input === false) {
1301
- return { sessions: false, pageViews: false, deviceInfo: false };
1559
+ return { sessions: false, pageViews: false, deviceInfo: false, clicks: false };
1302
1560
  }
1303
1561
  if (input === void 0 || input === true) {
1304
1562
  return { ...DEFAULT_AUTO_TRACK };
@@ -1306,7 +1564,8 @@ function resolveAutoTrack(input) {
1306
1564
  return {
1307
1565
  sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
1308
1566
  pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
1309
- deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
1567
+ deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
1568
+ clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
1310
1569
  };
1311
1570
  }
1312
1571
  function installUnloadFlush(onUnload) {