@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/index.mjs
CHANGED
|
@@ -46,7 +46,7 @@ function typeMapForStatus(status) {
|
|
|
46
46
|
|
|
47
47
|
// src/http.ts
|
|
48
48
|
var SDK_NAME = "@cross-deck/web";
|
|
49
|
-
var SDK_VERSION = "0.
|
|
49
|
+
var SDK_VERSION = "0.6.0";
|
|
50
50
|
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
51
51
|
var HttpClient = class {
|
|
52
52
|
constructor(config) {
|
|
@@ -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,24 +130,73 @@ 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";
|
|
125
179
|
var KEY_CDCUST = "cdcust_id";
|
|
126
180
|
var IdentityStore = class {
|
|
127
|
-
constructor(
|
|
128
|
-
this.
|
|
181
|
+
constructor(primary, prefix, secondary) {
|
|
182
|
+
this.primary = primary;
|
|
129
183
|
this.prefix = prefix;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
184
|
+
this.secondary = secondary ?? null;
|
|
185
|
+
const anonFromPrimary = primary.getItem(prefix + KEY_ANON);
|
|
186
|
+
const cdcustFromPrimary = primary.getItem(prefix + KEY_CDCUST);
|
|
187
|
+
const anonFromSecondary = this.secondary?.getItem(prefix + KEY_ANON) ?? null;
|
|
188
|
+
const cdcustFromSecondary = this.secondary?.getItem(prefix + KEY_CDCUST) ?? null;
|
|
189
|
+
const anon = anonFromPrimary ?? anonFromSecondary;
|
|
190
|
+
const cdcust = cdcustFromPrimary ?? cdcustFromSecondary;
|
|
134
191
|
this.state = {
|
|
135
|
-
anonymousId:
|
|
136
|
-
crossdeckCustomerId:
|
|
192
|
+
anonymousId: anon ?? this.mintAnonymousId(),
|
|
193
|
+
crossdeckCustomerId: cdcust
|
|
137
194
|
};
|
|
138
|
-
if (!
|
|
139
|
-
|
|
195
|
+
if (!anonFromPrimary || !anonFromSecondary) {
|
|
196
|
+
this.writeBoth(prefix + KEY_ANON, this.state.anonymousId);
|
|
197
|
+
}
|
|
198
|
+
if (cdcust && (!cdcustFromPrimary || !cdcustFromSecondary)) {
|
|
199
|
+
this.writeBoth(prefix + KEY_CDCUST, cdcust);
|
|
140
200
|
}
|
|
141
201
|
}
|
|
142
202
|
/** Return the persisted anonymous device ID (always set). */
|
|
@@ -150,7 +210,7 @@ var IdentityStore = class {
|
|
|
150
210
|
/** Persist a newly-resolved Crossdeck customer ID. */
|
|
151
211
|
setCrossdeckCustomerId(value) {
|
|
152
212
|
this.state.crossdeckCustomerId = value;
|
|
153
|
-
this.
|
|
213
|
+
this.writeBoth(this.prefix + KEY_CDCUST, value);
|
|
154
214
|
}
|
|
155
215
|
/**
|
|
156
216
|
* Wipe persisted identity. Called by reset() — used when an end-user
|
|
@@ -158,13 +218,13 @@ var IdentityStore = class {
|
|
|
158
218
|
* pre-login session is a fresh customer in the identity graph.
|
|
159
219
|
*/
|
|
160
220
|
reset() {
|
|
161
|
-
this.
|
|
162
|
-
this.
|
|
221
|
+
this.deleteBoth(this.prefix + KEY_ANON);
|
|
222
|
+
this.deleteBoth(this.prefix + KEY_CDCUST);
|
|
163
223
|
this.state = {
|
|
164
224
|
anonymousId: this.mintAnonymousId(),
|
|
165
225
|
crossdeckCustomerId: null
|
|
166
226
|
};
|
|
167
|
-
this.
|
|
227
|
+
this.writeBoth(this.prefix + KEY_ANON, this.state.anonymousId);
|
|
168
228
|
}
|
|
169
229
|
/**
|
|
170
230
|
* Generate an anonymousId. Crockford-ish base32 timestamp + random
|
|
@@ -176,6 +236,30 @@ var IdentityStore = class {
|
|
|
176
236
|
const rand = randomChars(10);
|
|
177
237
|
return `anon_${ts}${rand}`;
|
|
178
238
|
}
|
|
239
|
+
writeBoth(key, value) {
|
|
240
|
+
try {
|
|
241
|
+
this.primary.setItem(key, value);
|
|
242
|
+
} catch {
|
|
243
|
+
}
|
|
244
|
+
if (this.secondary) {
|
|
245
|
+
try {
|
|
246
|
+
this.secondary.setItem(key, value);
|
|
247
|
+
} catch {
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
deleteBoth(key) {
|
|
252
|
+
try {
|
|
253
|
+
this.primary.removeItem(key);
|
|
254
|
+
} catch {
|
|
255
|
+
}
|
|
256
|
+
if (this.secondary) {
|
|
257
|
+
try {
|
|
258
|
+
this.secondary.removeItem(key);
|
|
259
|
+
} catch {
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
179
263
|
};
|
|
180
264
|
function randomChars(count) {
|
|
181
265
|
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
@@ -401,6 +485,59 @@ var MemoryStorage = class {
|
|
|
401
485
|
this.store.delete(key);
|
|
402
486
|
}
|
|
403
487
|
};
|
|
488
|
+
var CookieStorage = class {
|
|
489
|
+
constructor(options) {
|
|
490
|
+
this.maxAgeSec = options?.maxAgeSec ?? 63072e3;
|
|
491
|
+
this.secure = options?.secure ?? defaultSecure();
|
|
492
|
+
this.sameSite = options?.sameSite ?? "Lax";
|
|
493
|
+
}
|
|
494
|
+
getItem(key) {
|
|
495
|
+
if (!hasDocument()) return null;
|
|
496
|
+
const doc = globalThis.document;
|
|
497
|
+
const cookies = doc.cookie ? doc.cookie.split(/;\s*/) : [];
|
|
498
|
+
const prefix = encodeURIComponent(key) + "=";
|
|
499
|
+
for (const c of cookies) {
|
|
500
|
+
if (c.startsWith(prefix)) {
|
|
501
|
+
try {
|
|
502
|
+
return decodeURIComponent(c.slice(prefix.length));
|
|
503
|
+
} catch {
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
setItem(key, value) {
|
|
511
|
+
if (!hasDocument()) return;
|
|
512
|
+
const doc = globalThis.document;
|
|
513
|
+
const parts = [
|
|
514
|
+
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
|
|
515
|
+
"Path=/",
|
|
516
|
+
`Max-Age=${this.maxAgeSec}`,
|
|
517
|
+
`SameSite=${this.sameSite}`
|
|
518
|
+
];
|
|
519
|
+
if (this.secure) parts.push("Secure");
|
|
520
|
+
try {
|
|
521
|
+
doc.cookie = parts.join("; ");
|
|
522
|
+
} catch {
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
removeItem(key) {
|
|
526
|
+
if (!hasDocument()) return;
|
|
527
|
+
const doc = globalThis.document;
|
|
528
|
+
const parts = [
|
|
529
|
+
`${encodeURIComponent(key)}=`,
|
|
530
|
+
"Path=/",
|
|
531
|
+
"Max-Age=0",
|
|
532
|
+
`SameSite=${this.sameSite}`
|
|
533
|
+
];
|
|
534
|
+
if (this.secure) parts.push("Secure");
|
|
535
|
+
try {
|
|
536
|
+
doc.cookie = parts.join("; ");
|
|
537
|
+
} catch {
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
};
|
|
404
541
|
function detectDefaultStorage() {
|
|
405
542
|
try {
|
|
406
543
|
const ls = globalThis.localStorage;
|
|
@@ -414,6 +551,17 @@ function detectDefaultStorage() {
|
|
|
414
551
|
}
|
|
415
552
|
return new MemoryStorage();
|
|
416
553
|
}
|
|
554
|
+
function defaultSecure() {
|
|
555
|
+
try {
|
|
556
|
+
const loc = globalThis.location;
|
|
557
|
+
return loc?.protocol === "https:";
|
|
558
|
+
} catch {
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
function hasDocument() {
|
|
563
|
+
return typeof globalThis.document !== "undefined";
|
|
564
|
+
}
|
|
417
565
|
|
|
418
566
|
// src/device-info.ts
|
|
419
567
|
function isBrowser() {
|
|
@@ -511,9 +659,18 @@ function parseUserAgent(ua) {
|
|
|
511
659
|
var DEFAULT_AUTO_TRACK = {
|
|
512
660
|
sessions: true,
|
|
513
661
|
pageViews: true,
|
|
514
|
-
deviceInfo: true
|
|
662
|
+
deviceInfo: true,
|
|
663
|
+
clicks: true
|
|
515
664
|
};
|
|
516
665
|
var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
|
|
666
|
+
var EMPTY_ACQUISITION = {
|
|
667
|
+
utm_source: "",
|
|
668
|
+
utm_medium: "",
|
|
669
|
+
utm_campaign: "",
|
|
670
|
+
utm_content: "",
|
|
671
|
+
utm_term: "",
|
|
672
|
+
referrer: ""
|
|
673
|
+
};
|
|
517
674
|
var AutoTracker = class {
|
|
518
675
|
constructor(cfg, track) {
|
|
519
676
|
this.cfg = cfg;
|
|
@@ -525,6 +682,7 @@ var AutoTracker = class {
|
|
|
525
682
|
if (!isBrowserSafe()) return;
|
|
526
683
|
if (this.cfg.sessions) this.installSessionTracking();
|
|
527
684
|
if (this.cfg.pageViews) this.installPageViewTracking();
|
|
685
|
+
if (this.cfg.clicks) this.installClickTracking();
|
|
528
686
|
}
|
|
529
687
|
uninstall() {
|
|
530
688
|
while (this.cleanups.length) {
|
|
@@ -549,6 +707,18 @@ var AutoTracker = class {
|
|
|
549
707
|
get currentSessionId() {
|
|
550
708
|
return this.session?.sessionId ?? null;
|
|
551
709
|
}
|
|
710
|
+
/**
|
|
711
|
+
* Per-session acquisition context — utm_* + referrer, captured once
|
|
712
|
+
* at session start. Returns empty strings when there's no session
|
|
713
|
+
* (Node, before init, after uninstall) so callers can spread without
|
|
714
|
+
* conditional logic. Bank-grade rule: capture once, attach to every
|
|
715
|
+
* event of the session, don't re-read on every track() (the URL
|
|
716
|
+
* changes via SPA pushState; the source-of-record is the URL we
|
|
717
|
+
* landed on).
|
|
718
|
+
*/
|
|
719
|
+
get currentAcquisition() {
|
|
720
|
+
return this.session?.acquisition ?? EMPTY_ACQUISITION;
|
|
721
|
+
}
|
|
552
722
|
// ---------- sessions ----------
|
|
553
723
|
installSessionTracking() {
|
|
554
724
|
this.session = this.startNewSession();
|
|
@@ -586,7 +756,8 @@ var AutoTracker = class {
|
|
|
586
756
|
sessionId: mintSessionId(),
|
|
587
757
|
startedAt: Date.now(),
|
|
588
758
|
hiddenAt: null,
|
|
589
|
-
endedSent: false
|
|
759
|
+
endedSent: false,
|
|
760
|
+
acquisition: captureAcquisition()
|
|
590
761
|
};
|
|
591
762
|
}
|
|
592
763
|
emitSessionStart() {
|
|
@@ -606,11 +777,19 @@ var AutoTracker = class {
|
|
|
606
777
|
installPageViewTracking() {
|
|
607
778
|
const w = globalThis.window;
|
|
608
779
|
const doc = globalThis.document;
|
|
609
|
-
|
|
780
|
+
let lastFiredAt = 0;
|
|
781
|
+
let lastFiredUrl = "";
|
|
782
|
+
const DEDUP_WINDOW_MS = 250;
|
|
783
|
+
const fire = (force = false) => {
|
|
610
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;
|
|
611
790
|
this.track("page.viewed", {
|
|
612
791
|
path: loc.pathname,
|
|
613
|
-
url
|
|
792
|
+
url,
|
|
614
793
|
search: loc.search || void 0,
|
|
615
794
|
hash: loc.hash || void 0,
|
|
616
795
|
title: doc.title,
|
|
@@ -632,7 +811,7 @@ var AutoTracker = class {
|
|
|
632
811
|
}
|
|
633
812
|
w.history.pushState = patchedPush;
|
|
634
813
|
w.history.replaceState = patchedReplace;
|
|
635
|
-
const onPopState = () => fire();
|
|
814
|
+
const onPopState = () => fire(true);
|
|
636
815
|
w.addEventListener("popstate", onPopState);
|
|
637
816
|
this.cleanups.push(() => {
|
|
638
817
|
if (w.history.pushState === patchedPush) {
|
|
@@ -644,7 +823,156 @@ var AutoTracker = class {
|
|
|
644
823
|
w.removeEventListener("popstate", onPopState);
|
|
645
824
|
});
|
|
646
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
|
+
}
|
|
647
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
|
+
}
|
|
648
976
|
function isBrowserSafe() {
|
|
649
977
|
return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
|
|
650
978
|
}
|
|
@@ -652,6 +980,26 @@ function mintSessionId() {
|
|
|
652
980
|
const ts = Date.now().toString(36);
|
|
653
981
|
return `sess_${ts}${randomChars(10)}`;
|
|
654
982
|
}
|
|
983
|
+
function captureAcquisition() {
|
|
984
|
+
if (!isBrowserSafe()) return { ...EMPTY_ACQUISITION };
|
|
985
|
+
const result = { ...EMPTY_ACQUISITION };
|
|
986
|
+
try {
|
|
987
|
+
const w = globalThis.window;
|
|
988
|
+
const params = new URLSearchParams(w.location.search ?? "");
|
|
989
|
+
result.utm_source = params.get("utm_source") ?? "";
|
|
990
|
+
result.utm_medium = params.get("utm_medium") ?? "";
|
|
991
|
+
result.utm_campaign = params.get("utm_campaign") ?? "";
|
|
992
|
+
result.utm_content = params.get("utm_content") ?? "";
|
|
993
|
+
result.utm_term = params.get("utm_term") ?? "";
|
|
994
|
+
} catch {
|
|
995
|
+
}
|
|
996
|
+
try {
|
|
997
|
+
const doc = globalThis.document;
|
|
998
|
+
if (typeof doc.referrer === "string") result.referrer = doc.referrer;
|
|
999
|
+
} catch {
|
|
1000
|
+
}
|
|
1001
|
+
return result;
|
|
1002
|
+
}
|
|
655
1003
|
|
|
656
1004
|
// src/debug.ts
|
|
657
1005
|
var SENSITIVE_KEY_PATTERNS = [
|
|
@@ -744,6 +1092,7 @@ var CrossdeckClient = class {
|
|
|
744
1092
|
message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
|
|
745
1093
|
});
|
|
746
1094
|
}
|
|
1095
|
+
const localDevMode = isLocalHostname();
|
|
747
1096
|
const storage = options.storage ?? detectDefaultStorage();
|
|
748
1097
|
const persistIdentity = options.persistIdentity ?? true;
|
|
749
1098
|
const autoTrack = resolveAutoTrack(options.autoTrack);
|
|
@@ -770,10 +1119,23 @@ var CrossdeckClient = class {
|
|
|
770
1119
|
const http = new HttpClient({
|
|
771
1120
|
publicKey: opts.publicKey,
|
|
772
1121
|
baseUrl: opts.baseUrl,
|
|
773
|
-
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
|
|
774
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
|
+
}
|
|
775
1134
|
const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
|
|
776
|
-
const
|
|
1135
|
+
const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
|
|
1136
|
+
typeof globalThis.document !== "undefined";
|
|
1137
|
+
const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
|
|
1138
|
+
const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
|
|
777
1139
|
const entitlements = new EntitlementCache();
|
|
778
1140
|
const events = new EventQueue({
|
|
779
1141
|
http,
|
|
@@ -821,7 +1183,7 @@ var CrossdeckClient = class {
|
|
|
821
1183
|
this.state.uninstallUnloadFlush = installUnloadFlush(() => {
|
|
822
1184
|
void this.flush({ keepalive: true }).catch(() => void 0);
|
|
823
1185
|
});
|
|
824
|
-
if (opts.autoHeartbeat) {
|
|
1186
|
+
if (opts.autoHeartbeat && !localDevMode) {
|
|
825
1187
|
void this.heartbeat().catch(() => void 0);
|
|
826
1188
|
}
|
|
827
1189
|
}
|
|
@@ -954,6 +1316,15 @@ var CrossdeckClient = class {
|
|
|
954
1316
|
const enriched = { ...s.deviceInfo };
|
|
955
1317
|
const sessionId = s.autoTracker?.currentSessionId;
|
|
956
1318
|
if (sessionId) enriched.sessionId = sessionId;
|
|
1319
|
+
const acquisition = s.autoTracker?.currentAcquisition;
|
|
1320
|
+
if (acquisition) {
|
|
1321
|
+
if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
|
|
1322
|
+
if (acquisition.utm_medium) enriched.utm_medium = acquisition.utm_medium;
|
|
1323
|
+
if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
|
|
1324
|
+
if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
|
|
1325
|
+
if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
|
|
1326
|
+
if (acquisition.referrer) enriched.referrer = acquisition.referrer;
|
|
1327
|
+
}
|
|
957
1328
|
if (properties) Object.assign(enriched, properties);
|
|
958
1329
|
const event = {
|
|
959
1330
|
eventId: this.mintEventId(),
|
|
@@ -1046,6 +1417,12 @@ var CrossdeckClient = class {
|
|
|
1046
1417
|
*/
|
|
1047
1418
|
reset() {
|
|
1048
1419
|
if (!this.state) return;
|
|
1420
|
+
if (this.state.developerUserId) {
|
|
1421
|
+
try {
|
|
1422
|
+
this.track("user.signed_out", { auto: true });
|
|
1423
|
+
} catch {
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1049
1426
|
this.state.autoTracker?.uninstall();
|
|
1050
1427
|
this.state.identity.reset();
|
|
1051
1428
|
this.state.entitlements.clear();
|
|
@@ -1126,14 +1503,30 @@ var CrossdeckClient = class {
|
|
|
1126
1503
|
if (s.developerUserId) return { userId: s.developerUserId };
|
|
1127
1504
|
return { anonymousId: s.identity.anonymousId };
|
|
1128
1505
|
}
|
|
1129
|
-
/**
|
|
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
|
+
*/
|
|
1130
1520
|
identityHintForEvent() {
|
|
1131
1521
|
const s = this.requireStarted();
|
|
1522
|
+
const hint = {
|
|
1523
|
+
anonymousId: s.identity.anonymousId
|
|
1524
|
+
};
|
|
1525
|
+
if (s.developerUserId) hint.developerUserId = s.developerUserId;
|
|
1132
1526
|
if (s.identity.crossdeckCustomerId) {
|
|
1133
|
-
|
|
1527
|
+
hint.crossdeckCustomerId = s.identity.crossdeckCustomerId;
|
|
1134
1528
|
}
|
|
1135
|
-
|
|
1136
|
-
return { anonymousId: s.identity.anonymousId };
|
|
1529
|
+
return hint;
|
|
1137
1530
|
}
|
|
1138
1531
|
mintEventId() {
|
|
1139
1532
|
const ts = Date.now().toString(36);
|
|
@@ -1146,9 +1539,21 @@ function inferEnvFromKey(publicKey) {
|
|
|
1146
1539
|
if (publicKey.startsWith("cd_pub_live_")) return "production";
|
|
1147
1540
|
return null;
|
|
1148
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
|
+
}
|
|
1149
1554
|
function resolveAutoTrack(input) {
|
|
1150
1555
|
if (input === false) {
|
|
1151
|
-
return { sessions: false, pageViews: false, deviceInfo: false };
|
|
1556
|
+
return { sessions: false, pageViews: false, deviceInfo: false, clicks: false };
|
|
1152
1557
|
}
|
|
1153
1558
|
if (input === void 0 || input === true) {
|
|
1154
1559
|
return { ...DEFAULT_AUTO_TRACK };
|
|
@@ -1156,7 +1561,8 @@ function resolveAutoTrack(input) {
|
|
|
1156
1561
|
return {
|
|
1157
1562
|
sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
|
|
1158
1563
|
pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
|
|
1159
|
-
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
|
|
1564
|
+
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
|
|
1565
|
+
clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
|
|
1160
1566
|
};
|
|
1161
1567
|
}
|
|
1162
1568
|
function installUnloadFlush(onUnload) {
|