@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/CHANGELOG.md +35 -0
- package/README.md +19 -0
- package/dist/index.cjs +435 -29
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +53 -3
- package/dist/index.d.ts +53 -3
- package/dist/index.mjs +435 -29
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +435 -29
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +435 -29
- package/dist/react.mjs.map +1 -1
- package/package.json +1 -1
package/dist/react.mjs
CHANGED
|
@@ -49,7 +49,7 @@ function typeMapForStatus(status) {
|
|
|
49
49
|
|
|
50
50
|
// src/http.ts
|
|
51
51
|
var SDK_NAME = "@cross-deck/web";
|
|
52
|
-
var SDK_VERSION = "0.
|
|
52
|
+
var SDK_VERSION = "0.6.0";
|
|
53
53
|
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
54
54
|
var HttpClient = class {
|
|
55
55
|
constructor(config) {
|
|
@@ -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,24 +133,73 @@ 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";
|
|
128
182
|
var KEY_CDCUST = "cdcust_id";
|
|
129
183
|
var IdentityStore = class {
|
|
130
|
-
constructor(
|
|
131
|
-
this.
|
|
184
|
+
constructor(primary, prefix, secondary) {
|
|
185
|
+
this.primary = primary;
|
|
132
186
|
this.prefix = prefix;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
187
|
+
this.secondary = secondary ?? null;
|
|
188
|
+
const anonFromPrimary = primary.getItem(prefix + KEY_ANON);
|
|
189
|
+
const cdcustFromPrimary = primary.getItem(prefix + KEY_CDCUST);
|
|
190
|
+
const anonFromSecondary = this.secondary?.getItem(prefix + KEY_ANON) ?? null;
|
|
191
|
+
const cdcustFromSecondary = this.secondary?.getItem(prefix + KEY_CDCUST) ?? null;
|
|
192
|
+
const anon = anonFromPrimary ?? anonFromSecondary;
|
|
193
|
+
const cdcust = cdcustFromPrimary ?? cdcustFromSecondary;
|
|
137
194
|
this.state = {
|
|
138
|
-
anonymousId:
|
|
139
|
-
crossdeckCustomerId:
|
|
195
|
+
anonymousId: anon ?? this.mintAnonymousId(),
|
|
196
|
+
crossdeckCustomerId: cdcust
|
|
140
197
|
};
|
|
141
|
-
if (!
|
|
142
|
-
|
|
198
|
+
if (!anonFromPrimary || !anonFromSecondary) {
|
|
199
|
+
this.writeBoth(prefix + KEY_ANON, this.state.anonymousId);
|
|
200
|
+
}
|
|
201
|
+
if (cdcust && (!cdcustFromPrimary || !cdcustFromSecondary)) {
|
|
202
|
+
this.writeBoth(prefix + KEY_CDCUST, cdcust);
|
|
143
203
|
}
|
|
144
204
|
}
|
|
145
205
|
/** Return the persisted anonymous device ID (always set). */
|
|
@@ -153,7 +213,7 @@ var IdentityStore = class {
|
|
|
153
213
|
/** Persist a newly-resolved Crossdeck customer ID. */
|
|
154
214
|
setCrossdeckCustomerId(value) {
|
|
155
215
|
this.state.crossdeckCustomerId = value;
|
|
156
|
-
this.
|
|
216
|
+
this.writeBoth(this.prefix + KEY_CDCUST, value);
|
|
157
217
|
}
|
|
158
218
|
/**
|
|
159
219
|
* Wipe persisted identity. Called by reset() — used when an end-user
|
|
@@ -161,13 +221,13 @@ var IdentityStore = class {
|
|
|
161
221
|
* pre-login session is a fresh customer in the identity graph.
|
|
162
222
|
*/
|
|
163
223
|
reset() {
|
|
164
|
-
this.
|
|
165
|
-
this.
|
|
224
|
+
this.deleteBoth(this.prefix + KEY_ANON);
|
|
225
|
+
this.deleteBoth(this.prefix + KEY_CDCUST);
|
|
166
226
|
this.state = {
|
|
167
227
|
anonymousId: this.mintAnonymousId(),
|
|
168
228
|
crossdeckCustomerId: null
|
|
169
229
|
};
|
|
170
|
-
this.
|
|
230
|
+
this.writeBoth(this.prefix + KEY_ANON, this.state.anonymousId);
|
|
171
231
|
}
|
|
172
232
|
/**
|
|
173
233
|
* Generate an anonymousId. Crockford-ish base32 timestamp + random
|
|
@@ -179,6 +239,30 @@ var IdentityStore = class {
|
|
|
179
239
|
const rand = randomChars(10);
|
|
180
240
|
return `anon_${ts}${rand}`;
|
|
181
241
|
}
|
|
242
|
+
writeBoth(key, value) {
|
|
243
|
+
try {
|
|
244
|
+
this.primary.setItem(key, value);
|
|
245
|
+
} catch {
|
|
246
|
+
}
|
|
247
|
+
if (this.secondary) {
|
|
248
|
+
try {
|
|
249
|
+
this.secondary.setItem(key, value);
|
|
250
|
+
} catch {
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
deleteBoth(key) {
|
|
255
|
+
try {
|
|
256
|
+
this.primary.removeItem(key);
|
|
257
|
+
} catch {
|
|
258
|
+
}
|
|
259
|
+
if (this.secondary) {
|
|
260
|
+
try {
|
|
261
|
+
this.secondary.removeItem(key);
|
|
262
|
+
} catch {
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
182
266
|
};
|
|
183
267
|
function randomChars(count) {
|
|
184
268
|
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
@@ -404,6 +488,59 @@ var MemoryStorage = class {
|
|
|
404
488
|
this.store.delete(key);
|
|
405
489
|
}
|
|
406
490
|
};
|
|
491
|
+
var CookieStorage = class {
|
|
492
|
+
constructor(options) {
|
|
493
|
+
this.maxAgeSec = options?.maxAgeSec ?? 63072e3;
|
|
494
|
+
this.secure = options?.secure ?? defaultSecure();
|
|
495
|
+
this.sameSite = options?.sameSite ?? "Lax";
|
|
496
|
+
}
|
|
497
|
+
getItem(key) {
|
|
498
|
+
if (!hasDocument()) return null;
|
|
499
|
+
const doc = globalThis.document;
|
|
500
|
+
const cookies = doc.cookie ? doc.cookie.split(/;\s*/) : [];
|
|
501
|
+
const prefix = encodeURIComponent(key) + "=";
|
|
502
|
+
for (const c of cookies) {
|
|
503
|
+
if (c.startsWith(prefix)) {
|
|
504
|
+
try {
|
|
505
|
+
return decodeURIComponent(c.slice(prefix.length));
|
|
506
|
+
} catch {
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
setItem(key, value) {
|
|
514
|
+
if (!hasDocument()) return;
|
|
515
|
+
const doc = globalThis.document;
|
|
516
|
+
const parts = [
|
|
517
|
+
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
|
|
518
|
+
"Path=/",
|
|
519
|
+
`Max-Age=${this.maxAgeSec}`,
|
|
520
|
+
`SameSite=${this.sameSite}`
|
|
521
|
+
];
|
|
522
|
+
if (this.secure) parts.push("Secure");
|
|
523
|
+
try {
|
|
524
|
+
doc.cookie = parts.join("; ");
|
|
525
|
+
} catch {
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
removeItem(key) {
|
|
529
|
+
if (!hasDocument()) return;
|
|
530
|
+
const doc = globalThis.document;
|
|
531
|
+
const parts = [
|
|
532
|
+
`${encodeURIComponent(key)}=`,
|
|
533
|
+
"Path=/",
|
|
534
|
+
"Max-Age=0",
|
|
535
|
+
`SameSite=${this.sameSite}`
|
|
536
|
+
];
|
|
537
|
+
if (this.secure) parts.push("Secure");
|
|
538
|
+
try {
|
|
539
|
+
doc.cookie = parts.join("; ");
|
|
540
|
+
} catch {
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
};
|
|
407
544
|
function detectDefaultStorage() {
|
|
408
545
|
try {
|
|
409
546
|
const ls = globalThis.localStorage;
|
|
@@ -417,6 +554,17 @@ function detectDefaultStorage() {
|
|
|
417
554
|
}
|
|
418
555
|
return new MemoryStorage();
|
|
419
556
|
}
|
|
557
|
+
function defaultSecure() {
|
|
558
|
+
try {
|
|
559
|
+
const loc = globalThis.location;
|
|
560
|
+
return loc?.protocol === "https:";
|
|
561
|
+
} catch {
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
function hasDocument() {
|
|
566
|
+
return typeof globalThis.document !== "undefined";
|
|
567
|
+
}
|
|
420
568
|
|
|
421
569
|
// src/device-info.ts
|
|
422
570
|
function isBrowser() {
|
|
@@ -514,9 +662,18 @@ function parseUserAgent(ua) {
|
|
|
514
662
|
var DEFAULT_AUTO_TRACK = {
|
|
515
663
|
sessions: true,
|
|
516
664
|
pageViews: true,
|
|
517
|
-
deviceInfo: true
|
|
665
|
+
deviceInfo: true,
|
|
666
|
+
clicks: true
|
|
518
667
|
};
|
|
519
668
|
var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
|
|
669
|
+
var EMPTY_ACQUISITION = {
|
|
670
|
+
utm_source: "",
|
|
671
|
+
utm_medium: "",
|
|
672
|
+
utm_campaign: "",
|
|
673
|
+
utm_content: "",
|
|
674
|
+
utm_term: "",
|
|
675
|
+
referrer: ""
|
|
676
|
+
};
|
|
520
677
|
var AutoTracker = class {
|
|
521
678
|
constructor(cfg, track) {
|
|
522
679
|
this.cfg = cfg;
|
|
@@ -528,6 +685,7 @@ var AutoTracker = class {
|
|
|
528
685
|
if (!isBrowserSafe()) return;
|
|
529
686
|
if (this.cfg.sessions) this.installSessionTracking();
|
|
530
687
|
if (this.cfg.pageViews) this.installPageViewTracking();
|
|
688
|
+
if (this.cfg.clicks) this.installClickTracking();
|
|
531
689
|
}
|
|
532
690
|
uninstall() {
|
|
533
691
|
while (this.cleanups.length) {
|
|
@@ -552,6 +710,18 @@ var AutoTracker = class {
|
|
|
552
710
|
get currentSessionId() {
|
|
553
711
|
return this.session?.sessionId ?? null;
|
|
554
712
|
}
|
|
713
|
+
/**
|
|
714
|
+
* Per-session acquisition context — utm_* + referrer, captured once
|
|
715
|
+
* at session start. Returns empty strings when there's no session
|
|
716
|
+
* (Node, before init, after uninstall) so callers can spread without
|
|
717
|
+
* conditional logic. Bank-grade rule: capture once, attach to every
|
|
718
|
+
* event of the session, don't re-read on every track() (the URL
|
|
719
|
+
* changes via SPA pushState; the source-of-record is the URL we
|
|
720
|
+
* landed on).
|
|
721
|
+
*/
|
|
722
|
+
get currentAcquisition() {
|
|
723
|
+
return this.session?.acquisition ?? EMPTY_ACQUISITION;
|
|
724
|
+
}
|
|
555
725
|
// ---------- sessions ----------
|
|
556
726
|
installSessionTracking() {
|
|
557
727
|
this.session = this.startNewSession();
|
|
@@ -589,7 +759,8 @@ var AutoTracker = class {
|
|
|
589
759
|
sessionId: mintSessionId(),
|
|
590
760
|
startedAt: Date.now(),
|
|
591
761
|
hiddenAt: null,
|
|
592
|
-
endedSent: false
|
|
762
|
+
endedSent: false,
|
|
763
|
+
acquisition: captureAcquisition()
|
|
593
764
|
};
|
|
594
765
|
}
|
|
595
766
|
emitSessionStart() {
|
|
@@ -609,11 +780,19 @@ var AutoTracker = class {
|
|
|
609
780
|
installPageViewTracking() {
|
|
610
781
|
const w = globalThis.window;
|
|
611
782
|
const doc = globalThis.document;
|
|
612
|
-
|
|
783
|
+
let lastFiredAt = 0;
|
|
784
|
+
let lastFiredUrl = "";
|
|
785
|
+
const DEDUP_WINDOW_MS = 250;
|
|
786
|
+
const fire = (force = false) => {
|
|
613
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;
|
|
614
793
|
this.track("page.viewed", {
|
|
615
794
|
path: loc.pathname,
|
|
616
|
-
url
|
|
795
|
+
url,
|
|
617
796
|
search: loc.search || void 0,
|
|
618
797
|
hash: loc.hash || void 0,
|
|
619
798
|
title: doc.title,
|
|
@@ -635,7 +814,7 @@ var AutoTracker = class {
|
|
|
635
814
|
}
|
|
636
815
|
w.history.pushState = patchedPush;
|
|
637
816
|
w.history.replaceState = patchedReplace;
|
|
638
|
-
const onPopState = () => fire();
|
|
817
|
+
const onPopState = () => fire(true);
|
|
639
818
|
w.addEventListener("popstate", onPopState);
|
|
640
819
|
this.cleanups.push(() => {
|
|
641
820
|
if (w.history.pushState === patchedPush) {
|
|
@@ -647,7 +826,156 @@ var AutoTracker = class {
|
|
|
647
826
|
w.removeEventListener("popstate", onPopState);
|
|
648
827
|
});
|
|
649
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
|
+
}
|
|
650
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
|
+
}
|
|
651
979
|
function isBrowserSafe() {
|
|
652
980
|
return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
|
|
653
981
|
}
|
|
@@ -655,6 +983,26 @@ function mintSessionId() {
|
|
|
655
983
|
const ts = Date.now().toString(36);
|
|
656
984
|
return `sess_${ts}${randomChars(10)}`;
|
|
657
985
|
}
|
|
986
|
+
function captureAcquisition() {
|
|
987
|
+
if (!isBrowserSafe()) return { ...EMPTY_ACQUISITION };
|
|
988
|
+
const result = { ...EMPTY_ACQUISITION };
|
|
989
|
+
try {
|
|
990
|
+
const w = globalThis.window;
|
|
991
|
+
const params = new URLSearchParams(w.location.search ?? "");
|
|
992
|
+
result.utm_source = params.get("utm_source") ?? "";
|
|
993
|
+
result.utm_medium = params.get("utm_medium") ?? "";
|
|
994
|
+
result.utm_campaign = params.get("utm_campaign") ?? "";
|
|
995
|
+
result.utm_content = params.get("utm_content") ?? "";
|
|
996
|
+
result.utm_term = params.get("utm_term") ?? "";
|
|
997
|
+
} catch {
|
|
998
|
+
}
|
|
999
|
+
try {
|
|
1000
|
+
const doc = globalThis.document;
|
|
1001
|
+
if (typeof doc.referrer === "string") result.referrer = doc.referrer;
|
|
1002
|
+
} catch {
|
|
1003
|
+
}
|
|
1004
|
+
return result;
|
|
1005
|
+
}
|
|
658
1006
|
|
|
659
1007
|
// src/debug.ts
|
|
660
1008
|
var SENSITIVE_KEY_PATTERNS = [
|
|
@@ -747,6 +1095,7 @@ var CrossdeckClient = class {
|
|
|
747
1095
|
message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
|
|
748
1096
|
});
|
|
749
1097
|
}
|
|
1098
|
+
const localDevMode = isLocalHostname();
|
|
750
1099
|
const storage = options.storage ?? detectDefaultStorage();
|
|
751
1100
|
const persistIdentity = options.persistIdentity ?? true;
|
|
752
1101
|
const autoTrack = resolveAutoTrack(options.autoTrack);
|
|
@@ -773,10 +1122,23 @@ var CrossdeckClient = class {
|
|
|
773
1122
|
const http = new HttpClient({
|
|
774
1123
|
publicKey: opts.publicKey,
|
|
775
1124
|
baseUrl: opts.baseUrl,
|
|
776
|
-
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
|
|
777
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
|
+
}
|
|
778
1137
|
const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
|
|
779
|
-
const
|
|
1138
|
+
const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
|
|
1139
|
+
typeof globalThis.document !== "undefined";
|
|
1140
|
+
const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
|
|
1141
|
+
const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
|
|
780
1142
|
const entitlements = new EntitlementCache();
|
|
781
1143
|
const events = new EventQueue({
|
|
782
1144
|
http,
|
|
@@ -824,7 +1186,7 @@ var CrossdeckClient = class {
|
|
|
824
1186
|
this.state.uninstallUnloadFlush = installUnloadFlush(() => {
|
|
825
1187
|
void this.flush({ keepalive: true }).catch(() => void 0);
|
|
826
1188
|
});
|
|
827
|
-
if (opts.autoHeartbeat) {
|
|
1189
|
+
if (opts.autoHeartbeat && !localDevMode) {
|
|
828
1190
|
void this.heartbeat().catch(() => void 0);
|
|
829
1191
|
}
|
|
830
1192
|
}
|
|
@@ -957,6 +1319,15 @@ var CrossdeckClient = class {
|
|
|
957
1319
|
const enriched = { ...s.deviceInfo };
|
|
958
1320
|
const sessionId = s.autoTracker?.currentSessionId;
|
|
959
1321
|
if (sessionId) enriched.sessionId = sessionId;
|
|
1322
|
+
const acquisition = s.autoTracker?.currentAcquisition;
|
|
1323
|
+
if (acquisition) {
|
|
1324
|
+
if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
|
|
1325
|
+
if (acquisition.utm_medium) enriched.utm_medium = acquisition.utm_medium;
|
|
1326
|
+
if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
|
|
1327
|
+
if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
|
|
1328
|
+
if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
|
|
1329
|
+
if (acquisition.referrer) enriched.referrer = acquisition.referrer;
|
|
1330
|
+
}
|
|
960
1331
|
if (properties) Object.assign(enriched, properties);
|
|
961
1332
|
const event = {
|
|
962
1333
|
eventId: this.mintEventId(),
|
|
@@ -1049,6 +1420,12 @@ var CrossdeckClient = class {
|
|
|
1049
1420
|
*/
|
|
1050
1421
|
reset() {
|
|
1051
1422
|
if (!this.state) return;
|
|
1423
|
+
if (this.state.developerUserId) {
|
|
1424
|
+
try {
|
|
1425
|
+
this.track("user.signed_out", { auto: true });
|
|
1426
|
+
} catch {
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1052
1429
|
this.state.autoTracker?.uninstall();
|
|
1053
1430
|
this.state.identity.reset();
|
|
1054
1431
|
this.state.entitlements.clear();
|
|
@@ -1129,14 +1506,30 @@ var CrossdeckClient = class {
|
|
|
1129
1506
|
if (s.developerUserId) return { userId: s.developerUserId };
|
|
1130
1507
|
return { anonymousId: s.identity.anonymousId };
|
|
1131
1508
|
}
|
|
1132
|
-
/**
|
|
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
|
+
*/
|
|
1133
1523
|
identityHintForEvent() {
|
|
1134
1524
|
const s = this.requireStarted();
|
|
1525
|
+
const hint = {
|
|
1526
|
+
anonymousId: s.identity.anonymousId
|
|
1527
|
+
};
|
|
1528
|
+
if (s.developerUserId) hint.developerUserId = s.developerUserId;
|
|
1135
1529
|
if (s.identity.crossdeckCustomerId) {
|
|
1136
|
-
|
|
1530
|
+
hint.crossdeckCustomerId = s.identity.crossdeckCustomerId;
|
|
1137
1531
|
}
|
|
1138
|
-
|
|
1139
|
-
return { anonymousId: s.identity.anonymousId };
|
|
1532
|
+
return hint;
|
|
1140
1533
|
}
|
|
1141
1534
|
mintEventId() {
|
|
1142
1535
|
const ts = Date.now().toString(36);
|
|
@@ -1149,9 +1542,21 @@ function inferEnvFromKey(publicKey) {
|
|
|
1149
1542
|
if (publicKey.startsWith("cd_pub_live_")) return "production";
|
|
1150
1543
|
return null;
|
|
1151
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
|
+
}
|
|
1152
1557
|
function resolveAutoTrack(input) {
|
|
1153
1558
|
if (input === false) {
|
|
1154
|
-
return { sessions: false, pageViews: false, deviceInfo: false };
|
|
1559
|
+
return { sessions: false, pageViews: false, deviceInfo: false, clicks: false };
|
|
1155
1560
|
}
|
|
1156
1561
|
if (input === void 0 || input === true) {
|
|
1157
1562
|
return { ...DEFAULT_AUTO_TRACK };
|
|
@@ -1159,7 +1564,8 @@ function resolveAutoTrack(input) {
|
|
|
1159
1564
|
return {
|
|
1160
1565
|
sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
|
|
1161
1566
|
pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
|
|
1162
|
-
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
|
|
1567
|
+
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
|
|
1568
|
+
clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
|
|
1163
1569
|
};
|
|
1164
1570
|
}
|
|
1165
1571
|
function installUnloadFlush(onUnload) {
|