@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.cjs
CHANGED
|
@@ -74,7 +74,7 @@ function typeMapForStatus(status) {
|
|
|
74
74
|
|
|
75
75
|
// src/http.ts
|
|
76
76
|
var SDK_NAME = "@cross-deck/web";
|
|
77
|
-
var SDK_VERSION = "0.
|
|
77
|
+
var SDK_VERSION = "0.6.0";
|
|
78
78
|
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
79
79
|
var HttpClient = class {
|
|
80
80
|
constructor(config) {
|
|
@@ -90,6 +90,9 @@ var HttpClient = class {
|
|
|
90
90
|
* - JSON parse failure on a 2xx (treated as `internal_error`)
|
|
91
91
|
*/
|
|
92
92
|
async request(method, path, options = {}) {
|
|
93
|
+
if (this.config.localDevMode) {
|
|
94
|
+
return synthesizeLocalDevResponse(path);
|
|
95
|
+
}
|
|
93
96
|
const url = this.buildUrl(path, options.query);
|
|
94
97
|
const headers = {
|
|
95
98
|
Authorization: `Bearer ${this.config.publicKey}`,
|
|
@@ -132,6 +135,14 @@ var HttpClient = class {
|
|
|
132
135
|
});
|
|
133
136
|
}
|
|
134
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Whether this client is in localhost dev-mode short-circuit. Used
|
|
140
|
+
* by other SDK pieces (event-queue) to skip network-bound work
|
|
141
|
+
* entirely rather than going through synthesizeLocalDevResponse.
|
|
142
|
+
*/
|
|
143
|
+
get isLocalDevMode() {
|
|
144
|
+
return this.config.localDevMode === true;
|
|
145
|
+
}
|
|
135
146
|
buildUrl(path, query) {
|
|
136
147
|
const base = this.config.baseUrl.replace(/\/+$/, "");
|
|
137
148
|
const cleanPath = path.startsWith("/") ? path : `/${path}`;
|
|
@@ -147,24 +158,73 @@ var HttpClient = class {
|
|
|
147
158
|
return url;
|
|
148
159
|
}
|
|
149
160
|
};
|
|
161
|
+
var cachedLocalCdcust = null;
|
|
162
|
+
function synthesizeLocalDevResponse(path) {
|
|
163
|
+
if (path.startsWith("/sdk/heartbeat")) {
|
|
164
|
+
return {
|
|
165
|
+
object: "heartbeat",
|
|
166
|
+
ok: true,
|
|
167
|
+
projectId: "proj_local_dev",
|
|
168
|
+
appId: "app_local_dev",
|
|
169
|
+
platform: "web",
|
|
170
|
+
env: "sandbox",
|
|
171
|
+
serverTime: Date.now()
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (path.startsWith("/identity/alias")) {
|
|
175
|
+
if (!cachedLocalCdcust) {
|
|
176
|
+
const tail = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().replace(/-/g, "").slice(0, 16) : Math.random().toString(36).slice(2, 18);
|
|
177
|
+
cachedLocalCdcust = `cdcust_local_${tail}`;
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
object: "alias_result",
|
|
181
|
+
crossdeckCustomerId: cachedLocalCdcust,
|
|
182
|
+
linked: [],
|
|
183
|
+
mergePending: false,
|
|
184
|
+
env: "sandbox"
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (path.startsWith("/entitlements")) {
|
|
188
|
+
return {
|
|
189
|
+
object: "list",
|
|
190
|
+
data: [],
|
|
191
|
+
crossdeckCustomerId: cachedLocalCdcust ?? "",
|
|
192
|
+
env: "sandbox"
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (path.startsWith("/events")) {
|
|
196
|
+
return {
|
|
197
|
+
object: "list",
|
|
198
|
+
received: 0,
|
|
199
|
+
env: "sandbox"
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return {};
|
|
203
|
+
}
|
|
150
204
|
|
|
151
205
|
// src/identity.ts
|
|
152
206
|
var KEY_ANON = "anon_id";
|
|
153
207
|
var KEY_CDCUST = "cdcust_id";
|
|
154
208
|
var IdentityStore = class {
|
|
155
|
-
constructor(
|
|
156
|
-
this.
|
|
209
|
+
constructor(primary, prefix, secondary) {
|
|
210
|
+
this.primary = primary;
|
|
157
211
|
this.prefix = prefix;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
212
|
+
this.secondary = secondary ?? null;
|
|
213
|
+
const anonFromPrimary = primary.getItem(prefix + KEY_ANON);
|
|
214
|
+
const cdcustFromPrimary = primary.getItem(prefix + KEY_CDCUST);
|
|
215
|
+
const anonFromSecondary = this.secondary?.getItem(prefix + KEY_ANON) ?? null;
|
|
216
|
+
const cdcustFromSecondary = this.secondary?.getItem(prefix + KEY_CDCUST) ?? null;
|
|
217
|
+
const anon = anonFromPrimary ?? anonFromSecondary;
|
|
218
|
+
const cdcust = cdcustFromPrimary ?? cdcustFromSecondary;
|
|
162
219
|
this.state = {
|
|
163
|
-
anonymousId:
|
|
164
|
-
crossdeckCustomerId:
|
|
220
|
+
anonymousId: anon ?? this.mintAnonymousId(),
|
|
221
|
+
crossdeckCustomerId: cdcust
|
|
165
222
|
};
|
|
166
|
-
if (!
|
|
167
|
-
|
|
223
|
+
if (!anonFromPrimary || !anonFromSecondary) {
|
|
224
|
+
this.writeBoth(prefix + KEY_ANON, this.state.anonymousId);
|
|
225
|
+
}
|
|
226
|
+
if (cdcust && (!cdcustFromPrimary || !cdcustFromSecondary)) {
|
|
227
|
+
this.writeBoth(prefix + KEY_CDCUST, cdcust);
|
|
168
228
|
}
|
|
169
229
|
}
|
|
170
230
|
/** Return the persisted anonymous device ID (always set). */
|
|
@@ -178,7 +238,7 @@ var IdentityStore = class {
|
|
|
178
238
|
/** Persist a newly-resolved Crossdeck customer ID. */
|
|
179
239
|
setCrossdeckCustomerId(value) {
|
|
180
240
|
this.state.crossdeckCustomerId = value;
|
|
181
|
-
this.
|
|
241
|
+
this.writeBoth(this.prefix + KEY_CDCUST, value);
|
|
182
242
|
}
|
|
183
243
|
/**
|
|
184
244
|
* Wipe persisted identity. Called by reset() — used when an end-user
|
|
@@ -186,13 +246,13 @@ var IdentityStore = class {
|
|
|
186
246
|
* pre-login session is a fresh customer in the identity graph.
|
|
187
247
|
*/
|
|
188
248
|
reset() {
|
|
189
|
-
this.
|
|
190
|
-
this.
|
|
249
|
+
this.deleteBoth(this.prefix + KEY_ANON);
|
|
250
|
+
this.deleteBoth(this.prefix + KEY_CDCUST);
|
|
191
251
|
this.state = {
|
|
192
252
|
anonymousId: this.mintAnonymousId(),
|
|
193
253
|
crossdeckCustomerId: null
|
|
194
254
|
};
|
|
195
|
-
this.
|
|
255
|
+
this.writeBoth(this.prefix + KEY_ANON, this.state.anonymousId);
|
|
196
256
|
}
|
|
197
257
|
/**
|
|
198
258
|
* Generate an anonymousId. Crockford-ish base32 timestamp + random
|
|
@@ -204,6 +264,30 @@ var IdentityStore = class {
|
|
|
204
264
|
const rand = randomChars(10);
|
|
205
265
|
return `anon_${ts}${rand}`;
|
|
206
266
|
}
|
|
267
|
+
writeBoth(key, value) {
|
|
268
|
+
try {
|
|
269
|
+
this.primary.setItem(key, value);
|
|
270
|
+
} catch {
|
|
271
|
+
}
|
|
272
|
+
if (this.secondary) {
|
|
273
|
+
try {
|
|
274
|
+
this.secondary.setItem(key, value);
|
|
275
|
+
} catch {
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
deleteBoth(key) {
|
|
280
|
+
try {
|
|
281
|
+
this.primary.removeItem(key);
|
|
282
|
+
} catch {
|
|
283
|
+
}
|
|
284
|
+
if (this.secondary) {
|
|
285
|
+
try {
|
|
286
|
+
this.secondary.removeItem(key);
|
|
287
|
+
} catch {
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
207
291
|
};
|
|
208
292
|
function randomChars(count) {
|
|
209
293
|
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
@@ -429,6 +513,59 @@ var MemoryStorage = class {
|
|
|
429
513
|
this.store.delete(key);
|
|
430
514
|
}
|
|
431
515
|
};
|
|
516
|
+
var CookieStorage = class {
|
|
517
|
+
constructor(options) {
|
|
518
|
+
this.maxAgeSec = options?.maxAgeSec ?? 63072e3;
|
|
519
|
+
this.secure = options?.secure ?? defaultSecure();
|
|
520
|
+
this.sameSite = options?.sameSite ?? "Lax";
|
|
521
|
+
}
|
|
522
|
+
getItem(key) {
|
|
523
|
+
if (!hasDocument()) return null;
|
|
524
|
+
const doc = globalThis.document;
|
|
525
|
+
const cookies = doc.cookie ? doc.cookie.split(/;\s*/) : [];
|
|
526
|
+
const prefix = encodeURIComponent(key) + "=";
|
|
527
|
+
for (const c of cookies) {
|
|
528
|
+
if (c.startsWith(prefix)) {
|
|
529
|
+
try {
|
|
530
|
+
return decodeURIComponent(c.slice(prefix.length));
|
|
531
|
+
} catch {
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
setItem(key, value) {
|
|
539
|
+
if (!hasDocument()) return;
|
|
540
|
+
const doc = globalThis.document;
|
|
541
|
+
const parts = [
|
|
542
|
+
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
|
|
543
|
+
"Path=/",
|
|
544
|
+
`Max-Age=${this.maxAgeSec}`,
|
|
545
|
+
`SameSite=${this.sameSite}`
|
|
546
|
+
];
|
|
547
|
+
if (this.secure) parts.push("Secure");
|
|
548
|
+
try {
|
|
549
|
+
doc.cookie = parts.join("; ");
|
|
550
|
+
} catch {
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
removeItem(key) {
|
|
554
|
+
if (!hasDocument()) return;
|
|
555
|
+
const doc = globalThis.document;
|
|
556
|
+
const parts = [
|
|
557
|
+
`${encodeURIComponent(key)}=`,
|
|
558
|
+
"Path=/",
|
|
559
|
+
"Max-Age=0",
|
|
560
|
+
`SameSite=${this.sameSite}`
|
|
561
|
+
];
|
|
562
|
+
if (this.secure) parts.push("Secure");
|
|
563
|
+
try {
|
|
564
|
+
doc.cookie = parts.join("; ");
|
|
565
|
+
} catch {
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
};
|
|
432
569
|
function detectDefaultStorage() {
|
|
433
570
|
try {
|
|
434
571
|
const ls = globalThis.localStorage;
|
|
@@ -442,6 +579,17 @@ function detectDefaultStorage() {
|
|
|
442
579
|
}
|
|
443
580
|
return new MemoryStorage();
|
|
444
581
|
}
|
|
582
|
+
function defaultSecure() {
|
|
583
|
+
try {
|
|
584
|
+
const loc = globalThis.location;
|
|
585
|
+
return loc?.protocol === "https:";
|
|
586
|
+
} catch {
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
function hasDocument() {
|
|
591
|
+
return typeof globalThis.document !== "undefined";
|
|
592
|
+
}
|
|
445
593
|
|
|
446
594
|
// src/device-info.ts
|
|
447
595
|
function isBrowser() {
|
|
@@ -539,9 +687,18 @@ function parseUserAgent(ua) {
|
|
|
539
687
|
var DEFAULT_AUTO_TRACK = {
|
|
540
688
|
sessions: true,
|
|
541
689
|
pageViews: true,
|
|
542
|
-
deviceInfo: true
|
|
690
|
+
deviceInfo: true,
|
|
691
|
+
clicks: true
|
|
543
692
|
};
|
|
544
693
|
var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
|
|
694
|
+
var EMPTY_ACQUISITION = {
|
|
695
|
+
utm_source: "",
|
|
696
|
+
utm_medium: "",
|
|
697
|
+
utm_campaign: "",
|
|
698
|
+
utm_content: "",
|
|
699
|
+
utm_term: "",
|
|
700
|
+
referrer: ""
|
|
701
|
+
};
|
|
545
702
|
var AutoTracker = class {
|
|
546
703
|
constructor(cfg, track) {
|
|
547
704
|
this.cfg = cfg;
|
|
@@ -553,6 +710,7 @@ var AutoTracker = class {
|
|
|
553
710
|
if (!isBrowserSafe()) return;
|
|
554
711
|
if (this.cfg.sessions) this.installSessionTracking();
|
|
555
712
|
if (this.cfg.pageViews) this.installPageViewTracking();
|
|
713
|
+
if (this.cfg.clicks) this.installClickTracking();
|
|
556
714
|
}
|
|
557
715
|
uninstall() {
|
|
558
716
|
while (this.cleanups.length) {
|
|
@@ -577,6 +735,18 @@ var AutoTracker = class {
|
|
|
577
735
|
get currentSessionId() {
|
|
578
736
|
return this.session?.sessionId ?? null;
|
|
579
737
|
}
|
|
738
|
+
/**
|
|
739
|
+
* Per-session acquisition context — utm_* + referrer, captured once
|
|
740
|
+
* at session start. Returns empty strings when there's no session
|
|
741
|
+
* (Node, before init, after uninstall) so callers can spread without
|
|
742
|
+
* conditional logic. Bank-grade rule: capture once, attach to every
|
|
743
|
+
* event of the session, don't re-read on every track() (the URL
|
|
744
|
+
* changes via SPA pushState; the source-of-record is the URL we
|
|
745
|
+
* landed on).
|
|
746
|
+
*/
|
|
747
|
+
get currentAcquisition() {
|
|
748
|
+
return this.session?.acquisition ?? EMPTY_ACQUISITION;
|
|
749
|
+
}
|
|
580
750
|
// ---------- sessions ----------
|
|
581
751
|
installSessionTracking() {
|
|
582
752
|
this.session = this.startNewSession();
|
|
@@ -614,7 +784,8 @@ var AutoTracker = class {
|
|
|
614
784
|
sessionId: mintSessionId(),
|
|
615
785
|
startedAt: Date.now(),
|
|
616
786
|
hiddenAt: null,
|
|
617
|
-
endedSent: false
|
|
787
|
+
endedSent: false,
|
|
788
|
+
acquisition: captureAcquisition()
|
|
618
789
|
};
|
|
619
790
|
}
|
|
620
791
|
emitSessionStart() {
|
|
@@ -634,11 +805,19 @@ var AutoTracker = class {
|
|
|
634
805
|
installPageViewTracking() {
|
|
635
806
|
const w = globalThis.window;
|
|
636
807
|
const doc = globalThis.document;
|
|
637
|
-
|
|
808
|
+
let lastFiredAt = 0;
|
|
809
|
+
let lastFiredUrl = "";
|
|
810
|
+
const DEDUP_WINDOW_MS = 250;
|
|
811
|
+
const fire = (force = false) => {
|
|
638
812
|
const loc = w.location;
|
|
813
|
+
const url = loc.href;
|
|
814
|
+
const now = Date.now();
|
|
815
|
+
if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
|
|
816
|
+
lastFiredAt = now;
|
|
817
|
+
lastFiredUrl = url;
|
|
639
818
|
this.track("page.viewed", {
|
|
640
819
|
path: loc.pathname,
|
|
641
|
-
url
|
|
820
|
+
url,
|
|
642
821
|
search: loc.search || void 0,
|
|
643
822
|
hash: loc.hash || void 0,
|
|
644
823
|
title: doc.title,
|
|
@@ -660,7 +839,7 @@ var AutoTracker = class {
|
|
|
660
839
|
}
|
|
661
840
|
w.history.pushState = patchedPush;
|
|
662
841
|
w.history.replaceState = patchedReplace;
|
|
663
|
-
const onPopState = () => fire();
|
|
842
|
+
const onPopState = () => fire(true);
|
|
664
843
|
w.addEventListener("popstate", onPopState);
|
|
665
844
|
this.cleanups.push(() => {
|
|
666
845
|
if (w.history.pushState === patchedPush) {
|
|
@@ -672,7 +851,156 @@ var AutoTracker = class {
|
|
|
672
851
|
w.removeEventListener("popstate", onPopState);
|
|
673
852
|
});
|
|
674
853
|
}
|
|
854
|
+
// ---------- click autocapture ----------
|
|
855
|
+
/**
|
|
856
|
+
* Global click tracking — Mixpanel / Amplitude style autocapture.
|
|
857
|
+
* Fires `element.clicked` for every interactive click with the
|
|
858
|
+
* target element's selector path, text content, tag, href, data-*
|
|
859
|
+
* attributes, and viewport coordinates. Powers the funnel /
|
|
860
|
+
* attribution USP: "users who clicked X then converted within
|
|
861
|
+
* 7 days." Default ON because behavioural attribution is the
|
|
862
|
+
* core product promise.
|
|
863
|
+
*
|
|
864
|
+
* Privacy guardrails:
|
|
865
|
+
* - Skip clicks ON inputs / textareas / selects (form interaction
|
|
866
|
+
* isn't button telemetry; the dev should track form submits
|
|
867
|
+
* deliberately via track('form_submitted'))
|
|
868
|
+
* - Skip clicks INSIDE [type="password"] and password-class
|
|
869
|
+
* elements
|
|
870
|
+
* - Skip clicks inside elements opted out via class="cd-noTrack"
|
|
871
|
+
* or data-cd-noTrack attribute (Mixpanel's exact opt-out
|
|
872
|
+
* idiom — most devs already know it)
|
|
873
|
+
* - Capture text content but cap at 64 chars and trim — never
|
|
874
|
+
* more than what you'd see on a button label
|
|
875
|
+
*
|
|
876
|
+
* Volume guardrails:
|
|
877
|
+
* - Coalesce double-clicks within 100ms (React's synthetic click
|
|
878
|
+
* pattern + browser's native dblclick can fire twice)
|
|
879
|
+
* - Listen on document at capture phase so we see the click
|
|
880
|
+
* before any framework's own handlers stop propagation
|
|
881
|
+
*/
|
|
882
|
+
installClickTracking() {
|
|
883
|
+
const w = globalThis.window;
|
|
884
|
+
const doc = globalThis.document;
|
|
885
|
+
let lastFiredAt = 0;
|
|
886
|
+
let lastFiredTarget = null;
|
|
887
|
+
const COALESCE_MS = 100;
|
|
888
|
+
const TEXT_CAP = 64;
|
|
889
|
+
const onClick = (ev) => {
|
|
890
|
+
const target = ev.target;
|
|
891
|
+
if (!target || !(target instanceof Element)) return;
|
|
892
|
+
const now = Date.now();
|
|
893
|
+
if (target === lastFiredTarget && now - lastFiredAt < COALESCE_MS) return;
|
|
894
|
+
lastFiredAt = now;
|
|
895
|
+
lastFiredTarget = target;
|
|
896
|
+
const actionable = closestActionable(target);
|
|
897
|
+
const clicked = actionable || target;
|
|
898
|
+
if (isFormInput(clicked)) return;
|
|
899
|
+
if (isInOptedOut(clicked)) return;
|
|
900
|
+
if (isInsidePasswordField(clicked)) return;
|
|
901
|
+
const tag = clicked.tagName.toLowerCase();
|
|
902
|
+
const text = trimText(extractText(clicked), TEXT_CAP);
|
|
903
|
+
const href = clicked.href || void 0;
|
|
904
|
+
const linkTarget = clicked.target || void 0;
|
|
905
|
+
const elementId = clicked.id || void 0;
|
|
906
|
+
const role = clicked.getAttribute("role") || void 0;
|
|
907
|
+
const ariaLabel = clicked.getAttribute("aria-label") || void 0;
|
|
908
|
+
const selector = buildSelector(clicked);
|
|
909
|
+
const dataAttrs = collectDataAttrs(clicked);
|
|
910
|
+
const isLink = tag === "a" && !!href;
|
|
911
|
+
const explicitName = clicked.getAttribute("data-cd-event");
|
|
912
|
+
const props = {
|
|
913
|
+
selector,
|
|
914
|
+
tag,
|
|
915
|
+
text,
|
|
916
|
+
elementId,
|
|
917
|
+
role,
|
|
918
|
+
ariaLabel,
|
|
919
|
+
href,
|
|
920
|
+
isLink,
|
|
921
|
+
linkTarget,
|
|
922
|
+
viewportX: ev.clientX,
|
|
923
|
+
viewportY: ev.clientY,
|
|
924
|
+
pageX: ev.pageX,
|
|
925
|
+
pageY: ev.pageY,
|
|
926
|
+
...dataAttrs
|
|
927
|
+
};
|
|
928
|
+
for (const k of Object.keys(props)) {
|
|
929
|
+
if (props[k] === void 0 || props[k] === null || props[k] === "") delete props[k];
|
|
930
|
+
}
|
|
931
|
+
this.track(explicitName || "element.clicked", props);
|
|
932
|
+
};
|
|
933
|
+
doc.addEventListener("click", onClick, { capture: true, passive: true });
|
|
934
|
+
this.cleanups.push(() => {
|
|
935
|
+
doc.removeEventListener("click", onClick, { capture: true });
|
|
936
|
+
});
|
|
937
|
+
}
|
|
675
938
|
};
|
|
939
|
+
function closestActionable(el) {
|
|
940
|
+
return el.closest("[data-cd-event]") || el.closest("[data-cd-noTrack]") || el.closest("button, a, [role='button'], [role='link'], input[type='button'], input[type='submit']") || null;
|
|
941
|
+
}
|
|
942
|
+
function isFormInput(el) {
|
|
943
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
944
|
+
const tag = el.tagName.toLowerCase();
|
|
945
|
+
if (tag === "textarea" || tag === "select") return true;
|
|
946
|
+
if (tag === "input") {
|
|
947
|
+
const type = (el.type || "").toLowerCase();
|
|
948
|
+
return type !== "button" && type !== "submit" && type !== "image" && type !== "reset";
|
|
949
|
+
}
|
|
950
|
+
return false;
|
|
951
|
+
}
|
|
952
|
+
function isInOptedOut(el) {
|
|
953
|
+
if (el.closest("[data-cd-noTrack], [data-cd-no-track], .cd-noTrack, .cd-no-track")) return true;
|
|
954
|
+
return false;
|
|
955
|
+
}
|
|
956
|
+
function isInsidePasswordField(el) {
|
|
957
|
+
if (el.closest('input[type="password"]')) return true;
|
|
958
|
+
return false;
|
|
959
|
+
}
|
|
960
|
+
function extractText(el) {
|
|
961
|
+
const aria = el.getAttribute("aria-label");
|
|
962
|
+
if (aria) return aria.replace(/\s+/g, " ").trim();
|
|
963
|
+
if (el instanceof HTMLInputElement && el.value) return el.value;
|
|
964
|
+
const text = (el.textContent || "").replace(/\s+/g, " ").trim();
|
|
965
|
+
return text;
|
|
966
|
+
}
|
|
967
|
+
function trimText(s, cap) {
|
|
968
|
+
if (s.length <= cap) return s;
|
|
969
|
+
return s.slice(0, cap - 1) + "\u2026";
|
|
970
|
+
}
|
|
971
|
+
function buildSelector(el) {
|
|
972
|
+
const parts = [];
|
|
973
|
+
let cur = el;
|
|
974
|
+
let depth = 0;
|
|
975
|
+
while (cur && cur.nodeName.toLowerCase() !== "body" && depth < 5) {
|
|
976
|
+
let part = cur.nodeName.toLowerCase();
|
|
977
|
+
if (cur.id) {
|
|
978
|
+
parts.unshift(`${part}#${cur.id}`);
|
|
979
|
+
break;
|
|
980
|
+
}
|
|
981
|
+
if (cur.classList.length > 0) {
|
|
982
|
+
const cls = Array.from(cur.classList).filter((c) => !c.startsWith("cd-")).slice(0, 2).join(".");
|
|
983
|
+
if (cls) part += `.${cls}`;
|
|
984
|
+
}
|
|
985
|
+
parts.unshift(part);
|
|
986
|
+
cur = cur.parentElement;
|
|
987
|
+
depth++;
|
|
988
|
+
}
|
|
989
|
+
return parts.join(" > ");
|
|
990
|
+
}
|
|
991
|
+
function collectDataAttrs(el) {
|
|
992
|
+
const out = {};
|
|
993
|
+
if (!(el instanceof HTMLElement)) return out;
|
|
994
|
+
for (const name of el.getAttributeNames()) {
|
|
995
|
+
if (!name.startsWith("data-")) continue;
|
|
996
|
+
if (name === "data-cd-noTrack" || name === "data-cd-no-track") continue;
|
|
997
|
+
if (name === "data-cd-event") continue;
|
|
998
|
+
const value = el.getAttribute(name) || "";
|
|
999
|
+
const key = name.replace(/^data-cd-prop-/, "").replace(/^data-/, "");
|
|
1000
|
+
out[key] = value;
|
|
1001
|
+
}
|
|
1002
|
+
return out;
|
|
1003
|
+
}
|
|
676
1004
|
function isBrowserSafe() {
|
|
677
1005
|
return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
|
|
678
1006
|
}
|
|
@@ -680,6 +1008,26 @@ function mintSessionId() {
|
|
|
680
1008
|
const ts = Date.now().toString(36);
|
|
681
1009
|
return `sess_${ts}${randomChars(10)}`;
|
|
682
1010
|
}
|
|
1011
|
+
function captureAcquisition() {
|
|
1012
|
+
if (!isBrowserSafe()) return { ...EMPTY_ACQUISITION };
|
|
1013
|
+
const result = { ...EMPTY_ACQUISITION };
|
|
1014
|
+
try {
|
|
1015
|
+
const w = globalThis.window;
|
|
1016
|
+
const params = new URLSearchParams(w.location.search ?? "");
|
|
1017
|
+
result.utm_source = params.get("utm_source") ?? "";
|
|
1018
|
+
result.utm_medium = params.get("utm_medium") ?? "";
|
|
1019
|
+
result.utm_campaign = params.get("utm_campaign") ?? "";
|
|
1020
|
+
result.utm_content = params.get("utm_content") ?? "";
|
|
1021
|
+
result.utm_term = params.get("utm_term") ?? "";
|
|
1022
|
+
} catch {
|
|
1023
|
+
}
|
|
1024
|
+
try {
|
|
1025
|
+
const doc = globalThis.document;
|
|
1026
|
+
if (typeof doc.referrer === "string") result.referrer = doc.referrer;
|
|
1027
|
+
} catch {
|
|
1028
|
+
}
|
|
1029
|
+
return result;
|
|
1030
|
+
}
|
|
683
1031
|
|
|
684
1032
|
// src/debug.ts
|
|
685
1033
|
var SENSITIVE_KEY_PATTERNS = [
|
|
@@ -772,6 +1120,7 @@ var CrossdeckClient = class {
|
|
|
772
1120
|
message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
|
|
773
1121
|
});
|
|
774
1122
|
}
|
|
1123
|
+
const localDevMode = isLocalHostname();
|
|
775
1124
|
const storage = options.storage ?? detectDefaultStorage();
|
|
776
1125
|
const persistIdentity = options.persistIdentity ?? true;
|
|
777
1126
|
const autoTrack = resolveAutoTrack(options.autoTrack);
|
|
@@ -798,10 +1147,23 @@ var CrossdeckClient = class {
|
|
|
798
1147
|
const http = new HttpClient({
|
|
799
1148
|
publicKey: opts.publicKey,
|
|
800
1149
|
baseUrl: opts.baseUrl,
|
|
801
|
-
sdkVersion: opts.sdkVersion
|
|
1150
|
+
sdkVersion: opts.sdkVersion,
|
|
1151
|
+
// Localhost auto-route: HttpClient short-circuits every request
|
|
1152
|
+
// to a successful no-op response when localDevMode is set.
|
|
1153
|
+
// SDK methods continue to work locally; nothing reaches the
|
|
1154
|
+
// server.
|
|
1155
|
+
localDevMode
|
|
802
1156
|
});
|
|
1157
|
+
if (localDevMode) {
|
|
1158
|
+
console.log(
|
|
1159
|
+
"[crossdeck] Localhost detected \u2014 running in dev mode (no network calls). Set publicKey: 'cd_pub_test_\u2026' and deploy to a real domain to test against the Crossdeck Sandbox."
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
803
1162
|
const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
|
|
804
|
-
const
|
|
1163
|
+
const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
|
|
1164
|
+
typeof globalThis.document !== "undefined";
|
|
1165
|
+
const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
|
|
1166
|
+
const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
|
|
805
1167
|
const entitlements = new EntitlementCache();
|
|
806
1168
|
const events = new EventQueue({
|
|
807
1169
|
http,
|
|
@@ -849,7 +1211,7 @@ var CrossdeckClient = class {
|
|
|
849
1211
|
this.state.uninstallUnloadFlush = installUnloadFlush(() => {
|
|
850
1212
|
void this.flush({ keepalive: true }).catch(() => void 0);
|
|
851
1213
|
});
|
|
852
|
-
if (opts.autoHeartbeat) {
|
|
1214
|
+
if (opts.autoHeartbeat && !localDevMode) {
|
|
853
1215
|
void this.heartbeat().catch(() => void 0);
|
|
854
1216
|
}
|
|
855
1217
|
}
|
|
@@ -982,6 +1344,15 @@ var CrossdeckClient = class {
|
|
|
982
1344
|
const enriched = { ...s.deviceInfo };
|
|
983
1345
|
const sessionId = s.autoTracker?.currentSessionId;
|
|
984
1346
|
if (sessionId) enriched.sessionId = sessionId;
|
|
1347
|
+
const acquisition = s.autoTracker?.currentAcquisition;
|
|
1348
|
+
if (acquisition) {
|
|
1349
|
+
if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
|
|
1350
|
+
if (acquisition.utm_medium) enriched.utm_medium = acquisition.utm_medium;
|
|
1351
|
+
if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
|
|
1352
|
+
if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
|
|
1353
|
+
if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
|
|
1354
|
+
if (acquisition.referrer) enriched.referrer = acquisition.referrer;
|
|
1355
|
+
}
|
|
985
1356
|
if (properties) Object.assign(enriched, properties);
|
|
986
1357
|
const event = {
|
|
987
1358
|
eventId: this.mintEventId(),
|
|
@@ -1074,6 +1445,12 @@ var CrossdeckClient = class {
|
|
|
1074
1445
|
*/
|
|
1075
1446
|
reset() {
|
|
1076
1447
|
if (!this.state) return;
|
|
1448
|
+
if (this.state.developerUserId) {
|
|
1449
|
+
try {
|
|
1450
|
+
this.track("user.signed_out", { auto: true });
|
|
1451
|
+
} catch {
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1077
1454
|
this.state.autoTracker?.uninstall();
|
|
1078
1455
|
this.state.identity.reset();
|
|
1079
1456
|
this.state.entitlements.clear();
|
|
@@ -1154,14 +1531,30 @@ var CrossdeckClient = class {
|
|
|
1154
1531
|
if (s.developerUserId) return { userId: s.developerUserId };
|
|
1155
1532
|
return { anonymousId: s.identity.anonymousId };
|
|
1156
1533
|
}
|
|
1157
|
-
/**
|
|
1534
|
+
/**
|
|
1535
|
+
* Embed every known identity axis on the event. Earlier this returned
|
|
1536
|
+
* just the highest-priority hint (cdcust → developerUserId → anonymousId)
|
|
1537
|
+
* to keep payloads small, but that leaked into analytics: once a user
|
|
1538
|
+
* was logged in, every subsequent page.viewed shipped without
|
|
1539
|
+
* anonymousId, and `uniqExact(anonymous_id)` on the warehouse side
|
|
1540
|
+
* counted 0 visitors for the entire authenticated app.
|
|
1541
|
+
*
|
|
1542
|
+
* Bank-grade rule: the server is the single source of truth on
|
|
1543
|
+
* dedup. Send everything we know; let CH count by whichever axis
|
|
1544
|
+
* matches the question. Each field is at most 32 bytes — sending
|
|
1545
|
+
* three on every event costs ~80 bytes per request, which is
|
|
1546
|
+
* trivial compared to the analytics correctness it buys.
|
|
1547
|
+
*/
|
|
1158
1548
|
identityHintForEvent() {
|
|
1159
1549
|
const s = this.requireStarted();
|
|
1550
|
+
const hint = {
|
|
1551
|
+
anonymousId: s.identity.anonymousId
|
|
1552
|
+
};
|
|
1553
|
+
if (s.developerUserId) hint.developerUserId = s.developerUserId;
|
|
1160
1554
|
if (s.identity.crossdeckCustomerId) {
|
|
1161
|
-
|
|
1555
|
+
hint.crossdeckCustomerId = s.identity.crossdeckCustomerId;
|
|
1162
1556
|
}
|
|
1163
|
-
|
|
1164
|
-
return { anonymousId: s.identity.anonymousId };
|
|
1557
|
+
return hint;
|
|
1165
1558
|
}
|
|
1166
1559
|
mintEventId() {
|
|
1167
1560
|
const ts = Date.now().toString(36);
|
|
@@ -1174,9 +1567,21 @@ function inferEnvFromKey(publicKey) {
|
|
|
1174
1567
|
if (publicKey.startsWith("cd_pub_live_")) return "production";
|
|
1175
1568
|
return null;
|
|
1176
1569
|
}
|
|
1570
|
+
function isLocalHostname() {
|
|
1571
|
+
const w = globalThis.window;
|
|
1572
|
+
const hostname = w?.location?.hostname;
|
|
1573
|
+
if (!hostname) return false;
|
|
1574
|
+
if (hostname === "localhost" || hostname === "127.0.0.1") return true;
|
|
1575
|
+
if (hostname === "::1" || hostname === "[::1]") return true;
|
|
1576
|
+
if (hostname.endsWith(".local")) return true;
|
|
1577
|
+
if (/^10\./.test(hostname)) return true;
|
|
1578
|
+
if (/^192\.168\./.test(hostname)) return true;
|
|
1579
|
+
if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname)) return true;
|
|
1580
|
+
return false;
|
|
1581
|
+
}
|
|
1177
1582
|
function resolveAutoTrack(input) {
|
|
1178
1583
|
if (input === false) {
|
|
1179
|
-
return { sessions: false, pageViews: false, deviceInfo: false };
|
|
1584
|
+
return { sessions: false, pageViews: false, deviceInfo: false, clicks: false };
|
|
1180
1585
|
}
|
|
1181
1586
|
if (input === void 0 || input === true) {
|
|
1182
1587
|
return { ...DEFAULT_AUTO_TRACK };
|
|
@@ -1184,7 +1589,8 @@ function resolveAutoTrack(input) {
|
|
|
1184
1589
|
return {
|
|
1185
1590
|
sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
|
|
1186
1591
|
pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
|
|
1187
|
-
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
|
|
1592
|
+
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
|
|
1593
|
+
clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
|
|
1188
1594
|
};
|
|
1189
1595
|
}
|
|
1190
1596
|
function installUnloadFlush(onUnload) {
|