@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.cjs
CHANGED
|
@@ -78,7 +78,7 @@ function typeMapForStatus(status) {
|
|
|
78
78
|
|
|
79
79
|
// src/http.ts
|
|
80
80
|
var SDK_NAME = "@cross-deck/web";
|
|
81
|
-
var SDK_VERSION = "0.
|
|
81
|
+
var SDK_VERSION = "0.6.0";
|
|
82
82
|
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
83
83
|
var HttpClient = class {
|
|
84
84
|
constructor(config) {
|
|
@@ -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,24 +162,73 @@ 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";
|
|
157
211
|
var KEY_CDCUST = "cdcust_id";
|
|
158
212
|
var IdentityStore = class {
|
|
159
|
-
constructor(
|
|
160
|
-
this.
|
|
213
|
+
constructor(primary, prefix, secondary) {
|
|
214
|
+
this.primary = primary;
|
|
161
215
|
this.prefix = prefix;
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
216
|
+
this.secondary = secondary ?? null;
|
|
217
|
+
const anonFromPrimary = primary.getItem(prefix + KEY_ANON);
|
|
218
|
+
const cdcustFromPrimary = primary.getItem(prefix + KEY_CDCUST);
|
|
219
|
+
const anonFromSecondary = this.secondary?.getItem(prefix + KEY_ANON) ?? null;
|
|
220
|
+
const cdcustFromSecondary = this.secondary?.getItem(prefix + KEY_CDCUST) ?? null;
|
|
221
|
+
const anon = anonFromPrimary ?? anonFromSecondary;
|
|
222
|
+
const cdcust = cdcustFromPrimary ?? cdcustFromSecondary;
|
|
166
223
|
this.state = {
|
|
167
|
-
anonymousId:
|
|
168
|
-
crossdeckCustomerId:
|
|
224
|
+
anonymousId: anon ?? this.mintAnonymousId(),
|
|
225
|
+
crossdeckCustomerId: cdcust
|
|
169
226
|
};
|
|
170
|
-
if (!
|
|
171
|
-
|
|
227
|
+
if (!anonFromPrimary || !anonFromSecondary) {
|
|
228
|
+
this.writeBoth(prefix + KEY_ANON, this.state.anonymousId);
|
|
229
|
+
}
|
|
230
|
+
if (cdcust && (!cdcustFromPrimary || !cdcustFromSecondary)) {
|
|
231
|
+
this.writeBoth(prefix + KEY_CDCUST, cdcust);
|
|
172
232
|
}
|
|
173
233
|
}
|
|
174
234
|
/** Return the persisted anonymous device ID (always set). */
|
|
@@ -182,7 +242,7 @@ var IdentityStore = class {
|
|
|
182
242
|
/** Persist a newly-resolved Crossdeck customer ID. */
|
|
183
243
|
setCrossdeckCustomerId(value) {
|
|
184
244
|
this.state.crossdeckCustomerId = value;
|
|
185
|
-
this.
|
|
245
|
+
this.writeBoth(this.prefix + KEY_CDCUST, value);
|
|
186
246
|
}
|
|
187
247
|
/**
|
|
188
248
|
* Wipe persisted identity. Called by reset() — used when an end-user
|
|
@@ -190,13 +250,13 @@ var IdentityStore = class {
|
|
|
190
250
|
* pre-login session is a fresh customer in the identity graph.
|
|
191
251
|
*/
|
|
192
252
|
reset() {
|
|
193
|
-
this.
|
|
194
|
-
this.
|
|
253
|
+
this.deleteBoth(this.prefix + KEY_ANON);
|
|
254
|
+
this.deleteBoth(this.prefix + KEY_CDCUST);
|
|
195
255
|
this.state = {
|
|
196
256
|
anonymousId: this.mintAnonymousId(),
|
|
197
257
|
crossdeckCustomerId: null
|
|
198
258
|
};
|
|
199
|
-
this.
|
|
259
|
+
this.writeBoth(this.prefix + KEY_ANON, this.state.anonymousId);
|
|
200
260
|
}
|
|
201
261
|
/**
|
|
202
262
|
* Generate an anonymousId. Crockford-ish base32 timestamp + random
|
|
@@ -208,6 +268,30 @@ var IdentityStore = class {
|
|
|
208
268
|
const rand = randomChars(10);
|
|
209
269
|
return `anon_${ts}${rand}`;
|
|
210
270
|
}
|
|
271
|
+
writeBoth(key, value) {
|
|
272
|
+
try {
|
|
273
|
+
this.primary.setItem(key, value);
|
|
274
|
+
} catch {
|
|
275
|
+
}
|
|
276
|
+
if (this.secondary) {
|
|
277
|
+
try {
|
|
278
|
+
this.secondary.setItem(key, value);
|
|
279
|
+
} catch {
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
deleteBoth(key) {
|
|
284
|
+
try {
|
|
285
|
+
this.primary.removeItem(key);
|
|
286
|
+
} catch {
|
|
287
|
+
}
|
|
288
|
+
if (this.secondary) {
|
|
289
|
+
try {
|
|
290
|
+
this.secondary.removeItem(key);
|
|
291
|
+
} catch {
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
211
295
|
};
|
|
212
296
|
function randomChars(count) {
|
|
213
297
|
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
@@ -433,6 +517,59 @@ var MemoryStorage = class {
|
|
|
433
517
|
this.store.delete(key);
|
|
434
518
|
}
|
|
435
519
|
};
|
|
520
|
+
var CookieStorage = class {
|
|
521
|
+
constructor(options) {
|
|
522
|
+
this.maxAgeSec = options?.maxAgeSec ?? 63072e3;
|
|
523
|
+
this.secure = options?.secure ?? defaultSecure();
|
|
524
|
+
this.sameSite = options?.sameSite ?? "Lax";
|
|
525
|
+
}
|
|
526
|
+
getItem(key) {
|
|
527
|
+
if (!hasDocument()) return null;
|
|
528
|
+
const doc = globalThis.document;
|
|
529
|
+
const cookies = doc.cookie ? doc.cookie.split(/;\s*/) : [];
|
|
530
|
+
const prefix = encodeURIComponent(key) + "=";
|
|
531
|
+
for (const c of cookies) {
|
|
532
|
+
if (c.startsWith(prefix)) {
|
|
533
|
+
try {
|
|
534
|
+
return decodeURIComponent(c.slice(prefix.length));
|
|
535
|
+
} catch {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
setItem(key, value) {
|
|
543
|
+
if (!hasDocument()) return;
|
|
544
|
+
const doc = globalThis.document;
|
|
545
|
+
const parts = [
|
|
546
|
+
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
|
|
547
|
+
"Path=/",
|
|
548
|
+
`Max-Age=${this.maxAgeSec}`,
|
|
549
|
+
`SameSite=${this.sameSite}`
|
|
550
|
+
];
|
|
551
|
+
if (this.secure) parts.push("Secure");
|
|
552
|
+
try {
|
|
553
|
+
doc.cookie = parts.join("; ");
|
|
554
|
+
} catch {
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
removeItem(key) {
|
|
558
|
+
if (!hasDocument()) return;
|
|
559
|
+
const doc = globalThis.document;
|
|
560
|
+
const parts = [
|
|
561
|
+
`${encodeURIComponent(key)}=`,
|
|
562
|
+
"Path=/",
|
|
563
|
+
"Max-Age=0",
|
|
564
|
+
`SameSite=${this.sameSite}`
|
|
565
|
+
];
|
|
566
|
+
if (this.secure) parts.push("Secure");
|
|
567
|
+
try {
|
|
568
|
+
doc.cookie = parts.join("; ");
|
|
569
|
+
} catch {
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
};
|
|
436
573
|
function detectDefaultStorage() {
|
|
437
574
|
try {
|
|
438
575
|
const ls = globalThis.localStorage;
|
|
@@ -446,6 +583,17 @@ function detectDefaultStorage() {
|
|
|
446
583
|
}
|
|
447
584
|
return new MemoryStorage();
|
|
448
585
|
}
|
|
586
|
+
function defaultSecure() {
|
|
587
|
+
try {
|
|
588
|
+
const loc = globalThis.location;
|
|
589
|
+
return loc?.protocol === "https:";
|
|
590
|
+
} catch {
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function hasDocument() {
|
|
595
|
+
return typeof globalThis.document !== "undefined";
|
|
596
|
+
}
|
|
449
597
|
|
|
450
598
|
// src/device-info.ts
|
|
451
599
|
function isBrowser() {
|
|
@@ -543,9 +691,18 @@ function parseUserAgent(ua) {
|
|
|
543
691
|
var DEFAULT_AUTO_TRACK = {
|
|
544
692
|
sessions: true,
|
|
545
693
|
pageViews: true,
|
|
546
|
-
deviceInfo: true
|
|
694
|
+
deviceInfo: true,
|
|
695
|
+
clicks: true
|
|
547
696
|
};
|
|
548
697
|
var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
|
|
698
|
+
var EMPTY_ACQUISITION = {
|
|
699
|
+
utm_source: "",
|
|
700
|
+
utm_medium: "",
|
|
701
|
+
utm_campaign: "",
|
|
702
|
+
utm_content: "",
|
|
703
|
+
utm_term: "",
|
|
704
|
+
referrer: ""
|
|
705
|
+
};
|
|
549
706
|
var AutoTracker = class {
|
|
550
707
|
constructor(cfg, track) {
|
|
551
708
|
this.cfg = cfg;
|
|
@@ -557,6 +714,7 @@ var AutoTracker = class {
|
|
|
557
714
|
if (!isBrowserSafe()) return;
|
|
558
715
|
if (this.cfg.sessions) this.installSessionTracking();
|
|
559
716
|
if (this.cfg.pageViews) this.installPageViewTracking();
|
|
717
|
+
if (this.cfg.clicks) this.installClickTracking();
|
|
560
718
|
}
|
|
561
719
|
uninstall() {
|
|
562
720
|
while (this.cleanups.length) {
|
|
@@ -581,6 +739,18 @@ var AutoTracker = class {
|
|
|
581
739
|
get currentSessionId() {
|
|
582
740
|
return this.session?.sessionId ?? null;
|
|
583
741
|
}
|
|
742
|
+
/**
|
|
743
|
+
* Per-session acquisition context — utm_* + referrer, captured once
|
|
744
|
+
* at session start. Returns empty strings when there's no session
|
|
745
|
+
* (Node, before init, after uninstall) so callers can spread without
|
|
746
|
+
* conditional logic. Bank-grade rule: capture once, attach to every
|
|
747
|
+
* event of the session, don't re-read on every track() (the URL
|
|
748
|
+
* changes via SPA pushState; the source-of-record is the URL we
|
|
749
|
+
* landed on).
|
|
750
|
+
*/
|
|
751
|
+
get currentAcquisition() {
|
|
752
|
+
return this.session?.acquisition ?? EMPTY_ACQUISITION;
|
|
753
|
+
}
|
|
584
754
|
// ---------- sessions ----------
|
|
585
755
|
installSessionTracking() {
|
|
586
756
|
this.session = this.startNewSession();
|
|
@@ -618,7 +788,8 @@ var AutoTracker = class {
|
|
|
618
788
|
sessionId: mintSessionId(),
|
|
619
789
|
startedAt: Date.now(),
|
|
620
790
|
hiddenAt: null,
|
|
621
|
-
endedSent: false
|
|
791
|
+
endedSent: false,
|
|
792
|
+
acquisition: captureAcquisition()
|
|
622
793
|
};
|
|
623
794
|
}
|
|
624
795
|
emitSessionStart() {
|
|
@@ -638,11 +809,19 @@ var AutoTracker = class {
|
|
|
638
809
|
installPageViewTracking() {
|
|
639
810
|
const w = globalThis.window;
|
|
640
811
|
const doc = globalThis.document;
|
|
641
|
-
|
|
812
|
+
let lastFiredAt = 0;
|
|
813
|
+
let lastFiredUrl = "";
|
|
814
|
+
const DEDUP_WINDOW_MS = 250;
|
|
815
|
+
const fire = (force = false) => {
|
|
642
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;
|
|
643
822
|
this.track("page.viewed", {
|
|
644
823
|
path: loc.pathname,
|
|
645
|
-
url
|
|
824
|
+
url,
|
|
646
825
|
search: loc.search || void 0,
|
|
647
826
|
hash: loc.hash || void 0,
|
|
648
827
|
title: doc.title,
|
|
@@ -664,7 +843,7 @@ var AutoTracker = class {
|
|
|
664
843
|
}
|
|
665
844
|
w.history.pushState = patchedPush;
|
|
666
845
|
w.history.replaceState = patchedReplace;
|
|
667
|
-
const onPopState = () => fire();
|
|
846
|
+
const onPopState = () => fire(true);
|
|
668
847
|
w.addEventListener("popstate", onPopState);
|
|
669
848
|
this.cleanups.push(() => {
|
|
670
849
|
if (w.history.pushState === patchedPush) {
|
|
@@ -676,7 +855,156 @@ var AutoTracker = class {
|
|
|
676
855
|
w.removeEventListener("popstate", onPopState);
|
|
677
856
|
});
|
|
678
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
|
+
}
|
|
679
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
|
+
}
|
|
680
1008
|
function isBrowserSafe() {
|
|
681
1009
|
return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
|
|
682
1010
|
}
|
|
@@ -684,6 +1012,26 @@ function mintSessionId() {
|
|
|
684
1012
|
const ts = Date.now().toString(36);
|
|
685
1013
|
return `sess_${ts}${randomChars(10)}`;
|
|
686
1014
|
}
|
|
1015
|
+
function captureAcquisition() {
|
|
1016
|
+
if (!isBrowserSafe()) return { ...EMPTY_ACQUISITION };
|
|
1017
|
+
const result = { ...EMPTY_ACQUISITION };
|
|
1018
|
+
try {
|
|
1019
|
+
const w = globalThis.window;
|
|
1020
|
+
const params = new URLSearchParams(w.location.search ?? "");
|
|
1021
|
+
result.utm_source = params.get("utm_source") ?? "";
|
|
1022
|
+
result.utm_medium = params.get("utm_medium") ?? "";
|
|
1023
|
+
result.utm_campaign = params.get("utm_campaign") ?? "";
|
|
1024
|
+
result.utm_content = params.get("utm_content") ?? "";
|
|
1025
|
+
result.utm_term = params.get("utm_term") ?? "";
|
|
1026
|
+
} catch {
|
|
1027
|
+
}
|
|
1028
|
+
try {
|
|
1029
|
+
const doc = globalThis.document;
|
|
1030
|
+
if (typeof doc.referrer === "string") result.referrer = doc.referrer;
|
|
1031
|
+
} catch {
|
|
1032
|
+
}
|
|
1033
|
+
return result;
|
|
1034
|
+
}
|
|
687
1035
|
|
|
688
1036
|
// src/debug.ts
|
|
689
1037
|
var SENSITIVE_KEY_PATTERNS = [
|
|
@@ -776,6 +1124,7 @@ var CrossdeckClient = class {
|
|
|
776
1124
|
message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
|
|
777
1125
|
});
|
|
778
1126
|
}
|
|
1127
|
+
const localDevMode = isLocalHostname();
|
|
779
1128
|
const storage = options.storage ?? detectDefaultStorage();
|
|
780
1129
|
const persistIdentity = options.persistIdentity ?? true;
|
|
781
1130
|
const autoTrack = resolveAutoTrack(options.autoTrack);
|
|
@@ -802,10 +1151,23 @@ var CrossdeckClient = class {
|
|
|
802
1151
|
const http = new HttpClient({
|
|
803
1152
|
publicKey: opts.publicKey,
|
|
804
1153
|
baseUrl: opts.baseUrl,
|
|
805
|
-
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
|
|
806
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
|
+
}
|
|
807
1166
|
const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
|
|
808
|
-
const
|
|
1167
|
+
const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
|
|
1168
|
+
typeof globalThis.document !== "undefined";
|
|
1169
|
+
const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
|
|
1170
|
+
const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
|
|
809
1171
|
const entitlements = new EntitlementCache();
|
|
810
1172
|
const events = new EventQueue({
|
|
811
1173
|
http,
|
|
@@ -853,7 +1215,7 @@ var CrossdeckClient = class {
|
|
|
853
1215
|
this.state.uninstallUnloadFlush = installUnloadFlush(() => {
|
|
854
1216
|
void this.flush({ keepalive: true }).catch(() => void 0);
|
|
855
1217
|
});
|
|
856
|
-
if (opts.autoHeartbeat) {
|
|
1218
|
+
if (opts.autoHeartbeat && !localDevMode) {
|
|
857
1219
|
void this.heartbeat().catch(() => void 0);
|
|
858
1220
|
}
|
|
859
1221
|
}
|
|
@@ -986,6 +1348,15 @@ var CrossdeckClient = class {
|
|
|
986
1348
|
const enriched = { ...s.deviceInfo };
|
|
987
1349
|
const sessionId = s.autoTracker?.currentSessionId;
|
|
988
1350
|
if (sessionId) enriched.sessionId = sessionId;
|
|
1351
|
+
const acquisition = s.autoTracker?.currentAcquisition;
|
|
1352
|
+
if (acquisition) {
|
|
1353
|
+
if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
|
|
1354
|
+
if (acquisition.utm_medium) enriched.utm_medium = acquisition.utm_medium;
|
|
1355
|
+
if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
|
|
1356
|
+
if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
|
|
1357
|
+
if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
|
|
1358
|
+
if (acquisition.referrer) enriched.referrer = acquisition.referrer;
|
|
1359
|
+
}
|
|
989
1360
|
if (properties) Object.assign(enriched, properties);
|
|
990
1361
|
const event = {
|
|
991
1362
|
eventId: this.mintEventId(),
|
|
@@ -1078,6 +1449,12 @@ var CrossdeckClient = class {
|
|
|
1078
1449
|
*/
|
|
1079
1450
|
reset() {
|
|
1080
1451
|
if (!this.state) return;
|
|
1452
|
+
if (this.state.developerUserId) {
|
|
1453
|
+
try {
|
|
1454
|
+
this.track("user.signed_out", { auto: true });
|
|
1455
|
+
} catch {
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1081
1458
|
this.state.autoTracker?.uninstall();
|
|
1082
1459
|
this.state.identity.reset();
|
|
1083
1460
|
this.state.entitlements.clear();
|
|
@@ -1158,14 +1535,30 @@ var CrossdeckClient = class {
|
|
|
1158
1535
|
if (s.developerUserId) return { userId: s.developerUserId };
|
|
1159
1536
|
return { anonymousId: s.identity.anonymousId };
|
|
1160
1537
|
}
|
|
1161
|
-
/**
|
|
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
|
+
*/
|
|
1162
1552
|
identityHintForEvent() {
|
|
1163
1553
|
const s = this.requireStarted();
|
|
1554
|
+
const hint = {
|
|
1555
|
+
anonymousId: s.identity.anonymousId
|
|
1556
|
+
};
|
|
1557
|
+
if (s.developerUserId) hint.developerUserId = s.developerUserId;
|
|
1164
1558
|
if (s.identity.crossdeckCustomerId) {
|
|
1165
|
-
|
|
1559
|
+
hint.crossdeckCustomerId = s.identity.crossdeckCustomerId;
|
|
1166
1560
|
}
|
|
1167
|
-
|
|
1168
|
-
return { anonymousId: s.identity.anonymousId };
|
|
1561
|
+
return hint;
|
|
1169
1562
|
}
|
|
1170
1563
|
mintEventId() {
|
|
1171
1564
|
const ts = Date.now().toString(36);
|
|
@@ -1178,9 +1571,21 @@ function inferEnvFromKey(publicKey) {
|
|
|
1178
1571
|
if (publicKey.startsWith("cd_pub_live_")) return "production";
|
|
1179
1572
|
return null;
|
|
1180
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
|
+
}
|
|
1181
1586
|
function resolveAutoTrack(input) {
|
|
1182
1587
|
if (input === false) {
|
|
1183
|
-
return { sessions: false, pageViews: false, deviceInfo: false };
|
|
1588
|
+
return { sessions: false, pageViews: false, deviceInfo: false, clicks: false };
|
|
1184
1589
|
}
|
|
1185
1590
|
if (input === void 0 || input === true) {
|
|
1186
1591
|
return { ...DEFAULT_AUTO_TRACK };
|
|
@@ -1188,7 +1593,8 @@ function resolveAutoTrack(input) {
|
|
|
1188
1593
|
return {
|
|
1189
1594
|
sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
|
|
1190
1595
|
pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
|
|
1191
|
-
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
|
|
1596
|
+
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
|
|
1597
|
+
clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
|
|
1192
1598
|
};
|
|
1193
1599
|
}
|
|
1194
1600
|
function installUnloadFlush(onUnload) {
|