@nurdd/web-sdk 1.0.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/README.md +434 -0
- package/adapters/angular.js +48 -0
- package/adapters/next.js +25 -0
- package/adapters/nuxt.js +38 -0
- package/adapters/react.js +87 -0
- package/adapters/vue.js +67 -0
- package/dist/nurdd-sdk.umd.js +1132 -0
- package/dist/package.json +3 -0
- package/integrations/shopify/README.md +172 -0
- package/integrations/shopify/checkout-pixel.js +63 -0
- package/integrations/shopify/orders-webhook.example.js +69 -0
- package/package.json +87 -0
- package/src/autotrack.js +88 -0
- package/src/deeplinks.js +79 -0
- package/src/device.js +78 -0
- package/src/env.js +21 -0
- package/src/identity.js +57 -0
- package/src/index.js +12 -0
- package/src/logger.js +12 -0
- package/src/queue.js +102 -0
- package/src/sdk.js +342 -0
- package/src/session.js +83 -0
- package/src/storage.js +48 -0
- package/src/transport.js +105 -0
- package/src/utils.js +56 -0
- package/src/utm.js +46 -0
- package/types/angular.d.ts +30 -0
- package/types/index.d.ts +172 -0
- package/types/next.d.ts +10 -0
- package/types/nuxt.d.ts +5 -0
- package/types/react.d.ts +28 -0
- package/types/vue.d.ts +21 -0
package/src/identity.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const IDENTITY_KEY = "identity";
|
|
2
|
+
|
|
3
|
+
// Identity manager — keeps the latest known user identity for this
|
|
4
|
+
// device and pushes it to the backend on change.
|
|
5
|
+
export class IdentityManager {
|
|
6
|
+
constructor({ storage, transport, logger }) {
|
|
7
|
+
this.storage = storage;
|
|
8
|
+
this.transport = transport;
|
|
9
|
+
this.logger = logger;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
current() { return this.storage.getJson(IDENTITY_KEY, null); }
|
|
13
|
+
|
|
14
|
+
// Idempotent identify call. The backend upserts; the client only
|
|
15
|
+
// POSTs when something has actually changed.
|
|
16
|
+
async identify({ externalUserId, email, phone, attributes }) {
|
|
17
|
+
if (!externalUserId) throw new Error("[nurdd] externalUserId is required to identify");
|
|
18
|
+
const existing = this.current();
|
|
19
|
+
const next = { externalUserId, email: email || null, phone: phone || null, attributes: attributes || {} };
|
|
20
|
+
const unchanged =
|
|
21
|
+
existing &&
|
|
22
|
+
existing.externalUserId === next.externalUserId &&
|
|
23
|
+
existing.email === next.email &&
|
|
24
|
+
existing.phone === next.phone;
|
|
25
|
+
this.storage.setJson(IDENTITY_KEY, next);
|
|
26
|
+
if (unchanged) return { ok: true, identity: existing, cached: true };
|
|
27
|
+
|
|
28
|
+
const res = await this.transport.request("/api/sdk/v1/identities", {
|
|
29
|
+
method: "POST",
|
|
30
|
+
body: {
|
|
31
|
+
external_user_id: externalUserId,
|
|
32
|
+
email: email || undefined,
|
|
33
|
+
phone: phone || undefined,
|
|
34
|
+
attributes: attributes || {},
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
return { ok: res.ok, identity: res.data?.identity || null };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Clears the cached identity. Use on logout. Device id is unchanged
|
|
41
|
+
// so anonymous activity after logout remains stitched to this device.
|
|
42
|
+
reset() {
|
|
43
|
+
this.storage.remove(IDENTITY_KEY);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Server-side merge for "logged-in user was previously this anonymous
|
|
47
|
+
// user" flows.
|
|
48
|
+
async merge({ primaryExternalUserId, secondaryExternalUserId }) {
|
|
49
|
+
return this.transport.request("/api/sdk/v1/identities/merge", {
|
|
50
|
+
method: "POST",
|
|
51
|
+
body: {
|
|
52
|
+
primary_external_user_id: primaryExternalUserId,
|
|
53
|
+
secondary_external_user_id: secondaryExternalUserId,
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Public entry point — what consumers see by default.
|
|
2
|
+
export {
|
|
3
|
+
NurddSdk,
|
|
4
|
+
createSdk,
|
|
5
|
+
initSdk,
|
|
6
|
+
getSdk,
|
|
7
|
+
hasDefaultSdk,
|
|
8
|
+
setDefaultSdk,
|
|
9
|
+
} from "./sdk.js";
|
|
10
|
+
|
|
11
|
+
export { SDK_VERSION, SDK_NAME, isBrowser } from "./env.js";
|
|
12
|
+
export { UTM_KEYS } from "./utm.js";
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Minimal, level-gated logger. `debug` defaults to OFF so the SDK is
|
|
2
|
+
// silent in production unless the host explicitly opts in.
|
|
3
|
+
export function createLogger({ enabled = false, prefix = "[nurdd]" } = {}) {
|
|
4
|
+
const noop = () => {};
|
|
5
|
+
if (!enabled) return { debug: noop, info: noop, warn: console?.warn?.bind(console) || noop, error: console?.error?.bind(console) || noop };
|
|
6
|
+
return {
|
|
7
|
+
debug: console.debug?.bind(console, prefix) || noop,
|
|
8
|
+
info: console.info?.bind(console, prefix) || noop,
|
|
9
|
+
warn: console.warn?.bind(console, prefix) || noop,
|
|
10
|
+
error: console.error?.bind(console, prefix) || noop,
|
|
11
|
+
};
|
|
12
|
+
}
|
package/src/queue.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Offline-first event queue. Events are persisted to storage as soon as
|
|
2
|
+
// they're tracked so a tab crash or unload doesn't lose them. A flush
|
|
3
|
+
// timer ships them as a batch; on failure the batch is requeued.
|
|
4
|
+
//
|
|
5
|
+
// The queue is intentionally simple: a single sorted list. For very
|
|
6
|
+
// high volumes (>1k events/min) consider sharding by event kind.
|
|
7
|
+
|
|
8
|
+
const QUEUE_KEY = "event_queue";
|
|
9
|
+
|
|
10
|
+
export class EventQueue {
|
|
11
|
+
constructor({ storage, transport, logger, flushIntervalMs = 5_000, maxBatchSize = 50, onFlush }) {
|
|
12
|
+
this.storage = storage;
|
|
13
|
+
this.transport = transport;
|
|
14
|
+
this.logger = logger;
|
|
15
|
+
this.flushIntervalMs = flushIntervalMs;
|
|
16
|
+
this.maxBatchSize = maxBatchSize;
|
|
17
|
+
this.onFlush = onFlush || (() => {});
|
|
18
|
+
this.timer = null;
|
|
19
|
+
this.flushing = false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
start() {
|
|
23
|
+
if (this.timer) return;
|
|
24
|
+
this.timer = setInterval(() => this.flush().catch(() => {}), this.flushIntervalMs);
|
|
25
|
+
// Don't keep a node test process alive; this is a noop in browsers.
|
|
26
|
+
if (typeof this.timer.unref === "function") this.timer.unref();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
stop() {
|
|
30
|
+
if (this.timer) clearInterval(this.timer);
|
|
31
|
+
this.timer = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
enqueue(event) {
|
|
35
|
+
const list = this._read();
|
|
36
|
+
list.push(event);
|
|
37
|
+
this._write(list);
|
|
38
|
+
// Opportunistic flush so tiny apps don't wait for the timer.
|
|
39
|
+
if (list.length >= this.maxBatchSize) this.flush().catch(() => {});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
size() { return this._read().length; }
|
|
43
|
+
|
|
44
|
+
async flush() {
|
|
45
|
+
if (this.flushing) return { ok: true, skipped: true };
|
|
46
|
+
const all = this._read();
|
|
47
|
+
if (all.length === 0) return { ok: true, count: 0 };
|
|
48
|
+
|
|
49
|
+
this.flushing = true;
|
|
50
|
+
try {
|
|
51
|
+
const batch = all.slice(0, this.maxBatchSize);
|
|
52
|
+
const remaining = all.slice(batch.length);
|
|
53
|
+
|
|
54
|
+
const res = await this.transport.request("/api/sdk/v1/events/batch", {
|
|
55
|
+
method: "POST",
|
|
56
|
+
body: { events: batch },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!res.ok && res.status !== 207) {
|
|
60
|
+
// Transport gave up after retries — keep the batch for later.
|
|
61
|
+
this.logger.warn("flush failed, will retry later", res.error || res.status);
|
|
62
|
+
return { ok: false, count: 0, error: res.error };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 207 multi-status returns per-event success; we only drop the
|
|
66
|
+
// events the backend accepted, requeue the rest.
|
|
67
|
+
const accepted = new Set(
|
|
68
|
+
(res.data?.results || [])
|
|
69
|
+
.filter((r) => r.ok)
|
|
70
|
+
.map((r) => r.event_id)
|
|
71
|
+
.filter(Boolean),
|
|
72
|
+
);
|
|
73
|
+
const rejectedFromBatch = batch.filter(
|
|
74
|
+
(e) => e.event_id && !accepted.has(e.event_id),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// If we have per-event ids, only requeue the rejected ones; if not,
|
|
78
|
+
// assume all succeeded on a 2xx response.
|
|
79
|
+
const requeue =
|
|
80
|
+
accepted.size > 0 || rejectedFromBatch.length > 0
|
|
81
|
+
? [...rejectedFromBatch.filter((e) => !accepted.has(e.event_id)), ...remaining]
|
|
82
|
+
: remaining;
|
|
83
|
+
|
|
84
|
+
this._write(requeue);
|
|
85
|
+
this.onFlush({ sent: batch.length - rejectedFromBatch.length, queued: requeue.length });
|
|
86
|
+
return { ok: true, count: batch.length };
|
|
87
|
+
} finally {
|
|
88
|
+
this.flushing = false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Ship whatever is queued without retries — used on page unload.
|
|
93
|
+
flushBeacon() {
|
|
94
|
+
const all = this._read();
|
|
95
|
+
if (all.length === 0) return;
|
|
96
|
+
const ok = this.transport.deliverBeacon("/api/sdk/v1/events/batch", { events: all });
|
|
97
|
+
if (ok) this._write([]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_read() { return this.storage.getJson(QUEUE_KEY, []) || []; }
|
|
101
|
+
_write(list) { this.storage.setJson(QUEUE_KEY, list); }
|
|
102
|
+
}
|
package/src/sdk.js
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { createLogger } from "./logger.js";
|
|
2
|
+
import { createStorage } from "./storage.js";
|
|
3
|
+
import { Transport } from "./transport.js";
|
|
4
|
+
import { EventQueue } from "./queue.js";
|
|
5
|
+
import { SessionManager } from "./session.js";
|
|
6
|
+
import { IdentityManager } from "./identity.js";
|
|
7
|
+
import { DeepLinkResolver } from "./deeplinks.js";
|
|
8
|
+
import { setupAutotrack } from "./autotrack.js";
|
|
9
|
+
import { collectDevice, clientFingerprint } from "./device.js";
|
|
10
|
+
import { readUtmFromLocation, stripUtmFromUrl } from "./utm.js";
|
|
11
|
+
import { isBrowser, SDK_VERSION } from "./env.js";
|
|
12
|
+
import { uuid, nowIso, mergeOptions } from "./utils.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULTS = {
|
|
15
|
+
sdkKey: "",
|
|
16
|
+
baseUrl: "",
|
|
17
|
+
appVersion: null,
|
|
18
|
+
debug: false,
|
|
19
|
+
flushIntervalMs: 5_000,
|
|
20
|
+
maxBatchSize: 50,
|
|
21
|
+
sessionTimeoutMs: 30 * 60_000,
|
|
22
|
+
autotrack: {
|
|
23
|
+
pageViews: true,
|
|
24
|
+
outboundClicks: false,
|
|
25
|
+
flushOnUnload: true,
|
|
26
|
+
},
|
|
27
|
+
stripUtmFromUrl: true,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Persisted campaign code attributed to this browser. Set when a Nurdd
|
|
31
|
+
// link is resolved (from the URL or a deferred match) and stamped onto
|
|
32
|
+
// later conversions so signups/purchases credit the original campaign.
|
|
33
|
+
const ATTRIBUTED_SHORT_CODE_KEY = "attributed_short_code";
|
|
34
|
+
|
|
35
|
+
// Framework-agnostic SDK. A single instance is enough for an entire
|
|
36
|
+
// page / app — `getDefaultSdk()` is provided for the common case but
|
|
37
|
+
// you can also keep multiple instances side by side (multi-tenant).
|
|
38
|
+
export class NurddSdk {
|
|
39
|
+
constructor(options = {}) {
|
|
40
|
+
this.config = mergeOptions(DEFAULTS, options);
|
|
41
|
+
if (!this.config.sdkKey) throw new Error("[nurdd] sdkKey is required");
|
|
42
|
+
if (!this.config.baseUrl) throw new Error("[nurdd] baseUrl is required");
|
|
43
|
+
|
|
44
|
+
this.logger = createLogger({ enabled: this.config.debug });
|
|
45
|
+
this.storage = createStorage({ prefix: `nurdd:${this.config.sdkKey}:` });
|
|
46
|
+
this.transport = new Transport({
|
|
47
|
+
baseUrl: this.config.baseUrl,
|
|
48
|
+
sdkKey: this.config.sdkKey,
|
|
49
|
+
logger: this.logger,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
this.device = collectDevice({ storage: this.storage, appVersion: this.config.appVersion });
|
|
53
|
+
this.fingerprint = clientFingerprint(this.device);
|
|
54
|
+
|
|
55
|
+
this.queue = new EventQueue({
|
|
56
|
+
storage: this.storage,
|
|
57
|
+
transport: this.transport,
|
|
58
|
+
logger: this.logger,
|
|
59
|
+
flushIntervalMs: this.config.flushIntervalMs,
|
|
60
|
+
maxBatchSize: this.config.maxBatchSize,
|
|
61
|
+
});
|
|
62
|
+
this.session = new SessionManager({
|
|
63
|
+
storage: this.storage, transport: this.transport, logger: this.logger,
|
|
64
|
+
timeoutMs: this.config.sessionTimeoutMs,
|
|
65
|
+
});
|
|
66
|
+
this.identity = new IdentityManager({
|
|
67
|
+
storage: this.storage, transport: this.transport, logger: this.logger,
|
|
68
|
+
});
|
|
69
|
+
this.deepLinks = new DeepLinkResolver({ transport: this.transport, logger: this.logger });
|
|
70
|
+
|
|
71
|
+
this._initPromise = null;
|
|
72
|
+
this._teardownAutotrack = null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Idempotent. Safe to call multiple times — only does work once.
|
|
76
|
+
async init() {
|
|
77
|
+
if (this._initPromise) return this._initPromise;
|
|
78
|
+
this._initPromise = (async () => {
|
|
79
|
+
// The /init handshake gives the SDK clock-skew, supported
|
|
80
|
+
// platforms and feature flags. It's also a fast way to
|
|
81
|
+
// validate the SDK key before the first event flies.
|
|
82
|
+
const init = await this.transport.request("/api/sdk/v1/init", { method: "GET" });
|
|
83
|
+
if (!init.ok) {
|
|
84
|
+
this.logger.error("init failed", init.error || init.status);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Capture session, UTM, referrer.
|
|
88
|
+
const utm = isBrowser ? readUtmFromLocation() : {};
|
|
89
|
+
const session = this.session.touch({ utm });
|
|
90
|
+
await this.session.openServerSession({
|
|
91
|
+
device: this.device,
|
|
92
|
+
externalUserId: this.identity.current()?.externalUserId,
|
|
93
|
+
}).catch(() => {});
|
|
94
|
+
|
|
95
|
+
// Persist the campaign code as soon as it's known — from the
|
|
96
|
+
// landing URL immediately, and again if a deferred match resolves
|
|
97
|
+
// — so conversions can attribute even after the URL is stripped.
|
|
98
|
+
if (utm.short_code) this.storage.setString(ATTRIBUTED_SHORT_CODE_KEY, utm.short_code);
|
|
99
|
+
this.deepLinks.onResolved((link) => {
|
|
100
|
+
if (link?.short_code) this.storage.setString(ATTRIBUTED_SHORT_CODE_KEY, link.short_code);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Resolve deferred deep link in the background — listeners
|
|
104
|
+
// will be notified if anything matches.
|
|
105
|
+
this.deepLinks.resolve({ device: this.device }).catch(() => {});
|
|
106
|
+
|
|
107
|
+
// Strip UTM so a refresh doesn't double-count.
|
|
108
|
+
if (this.config.stripUtmFromUrl && isBrowser) stripUtmFromUrl();
|
|
109
|
+
|
|
110
|
+
this.queue.start();
|
|
111
|
+
this._teardownAutotrack = setupAutotrack({
|
|
112
|
+
sdk: this, config: this.config, queue: this.queue, logger: this.logger,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return { server: init.data, session };
|
|
116
|
+
})();
|
|
117
|
+
return this._initPromise;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- Public surface ----------------------------------------------------
|
|
121
|
+
// Every method is safe to call before `init()` resolves; events are
|
|
122
|
+
// queued locally and shipped once the queue's first flush ticks.
|
|
123
|
+
|
|
124
|
+
track(eventName, properties = {}, options = {}) {
|
|
125
|
+
if (!eventName) {
|
|
126
|
+
this.logger.warn("track() called without an event name");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const session = this.session.touch();
|
|
130
|
+
const identity = this.identity.current();
|
|
131
|
+
const event = {
|
|
132
|
+
event_name: eventName,
|
|
133
|
+
event_id: options.eventId || uuid(),
|
|
134
|
+
occurred_at: options.occurredAt || nowIso(),
|
|
135
|
+
platform: "web",
|
|
136
|
+
sdk_version: SDK_VERSION,
|
|
137
|
+
app_version: this.config.appVersion,
|
|
138
|
+
session_id: session?.serverId || null,
|
|
139
|
+
properties,
|
|
140
|
+
device: this.device,
|
|
141
|
+
user: identity ? {
|
|
142
|
+
external_user_id: identity.externalUserId,
|
|
143
|
+
email: identity.email || undefined,
|
|
144
|
+
phone: identity.phone || undefined,
|
|
145
|
+
attributes: identity.attributes || {},
|
|
146
|
+
} : undefined,
|
|
147
|
+
revenue: typeof options.revenue === "number" ? options.revenue : undefined,
|
|
148
|
+
currency: options.currency || undefined,
|
|
149
|
+
attribution_lookup: options.attributionLookup || undefined,
|
|
150
|
+
};
|
|
151
|
+
this.queue.enqueue(event);
|
|
152
|
+
return event;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
page(name, properties = {}) {
|
|
156
|
+
return this.track("page_view", { name: name || null, ...properties });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async identify(externalUserId, traits = {}) {
|
|
160
|
+
const result = await this.identity.identify({
|
|
161
|
+
externalUserId,
|
|
162
|
+
email: traits.email,
|
|
163
|
+
phone: traits.phone,
|
|
164
|
+
attributes: traits.attributes || {},
|
|
165
|
+
});
|
|
166
|
+
// Also push a server-side session row if we don't have one yet
|
|
167
|
+
// so the new identity is linked to today's session.
|
|
168
|
+
this.session.openServerSession({
|
|
169
|
+
device: this.device, externalUserId,
|
|
170
|
+
}).catch(() => {});
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
reset() { this.identity.reset(); }
|
|
175
|
+
|
|
176
|
+
async conversion(conversionType, payload = {}) {
|
|
177
|
+
// Auto-attribute to the campaign this browser came from unless the
|
|
178
|
+
// caller passed an explicit shortCode.
|
|
179
|
+
const shortCode = payload.shortCode || this.attributedShortCode() || undefined;
|
|
180
|
+
const res = await this.transport.request("/api/sdk/v1/conversions", {
|
|
181
|
+
method: "POST",
|
|
182
|
+
body: {
|
|
183
|
+
conversion_type: conversionType,
|
|
184
|
+
value: payload.value,
|
|
185
|
+
currency: payload.currency,
|
|
186
|
+
short_code: shortCode,
|
|
187
|
+
campaign_id: payload.campaignId,
|
|
188
|
+
attribution: payload.attribution,
|
|
189
|
+
fingerprint: this.fingerprint,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
return res.data?.conversion || null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The campaign code attributed to this browser, if any.
|
|
196
|
+
attributedShortCode() {
|
|
197
|
+
return this.storage.getString(ATTRIBUTED_SHORT_CODE_KEY);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Shopify helper. Writes the attributed campaign code into the cart as a
|
|
201
|
+
// cart attribute, so it travels with the order through checkout (where
|
|
202
|
+
// the SDK can't run) and can be read by your `orders/create` webhook to
|
|
203
|
+
// attribute the purchase. Storefront-only — call it on the cart/product
|
|
204
|
+
// page, e.g. right before "Add to cart" or on the cart page.
|
|
205
|
+
//
|
|
206
|
+
// A shopper can arrive via several campaigns before buying, so codes
|
|
207
|
+
// ACCUMULATE: the attribute holds a comma-separated, de-duplicated list
|
|
208
|
+
// in first-touch → last-touch order. The checkout pixel / webhook then
|
|
209
|
+
// decide which to credit.
|
|
210
|
+
async attachToShopifyCart({ attributeName = "nurdd_short_code" } = {}) {
|
|
211
|
+
if (!isBrowser) return false;
|
|
212
|
+
const shortCode = this.attributedShortCode();
|
|
213
|
+
if (!shortCode) return false;
|
|
214
|
+
try {
|
|
215
|
+
// Read any codes already on the cart so we append rather than
|
|
216
|
+
// overwrite previous campaigns.
|
|
217
|
+
let codes = [];
|
|
218
|
+
try {
|
|
219
|
+
const cartRes = await fetch("/cart.js", { headers: { Accept: "application/json" } });
|
|
220
|
+
if (cartRes.ok) {
|
|
221
|
+
const cart = await cartRes.json();
|
|
222
|
+
const raw = cart.attributes && cart.attributes[attributeName];
|
|
223
|
+
if (raw) codes = String(raw).split(",").map((s) => s.trim()).filter(Boolean);
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
// No cart yet / read failed — start a fresh list.
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (codes.includes(shortCode)) return true; // already recorded
|
|
230
|
+
codes.push(shortCode);
|
|
231
|
+
|
|
232
|
+
const res = await fetch("/cart/update.js", {
|
|
233
|
+
method: "POST",
|
|
234
|
+
headers: { "Content-Type": "application/json" },
|
|
235
|
+
body: JSON.stringify({ attributes: { [attributeName]: codes.join(",") } }),
|
|
236
|
+
});
|
|
237
|
+
if (!res.ok) this.logger.warn("attachToShopifyCart: cart update failed", res.status);
|
|
238
|
+
return res.ok;
|
|
239
|
+
} catch (e) {
|
|
240
|
+
this.logger.warn("attachToShopifyCart failed", e);
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async click({ shortCode, campaignId, kind = "click", metadata } = {}) {
|
|
246
|
+
const res = await this.transport.request("/api/sdk/v1/clicks", {
|
|
247
|
+
method: "POST",
|
|
248
|
+
body: {
|
|
249
|
+
short_code: shortCode,
|
|
250
|
+
campaign_id: campaignId,
|
|
251
|
+
kind, metadata,
|
|
252
|
+
platform: "web",
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
return res.data?.click || null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Short-link helpers
|
|
259
|
+
async createShortLink(payload) {
|
|
260
|
+
const res = await this.transport.request("/api/sdk/v1/links", {
|
|
261
|
+
method: "POST",
|
|
262
|
+
body: payload,
|
|
263
|
+
});
|
|
264
|
+
return res.data?.link || null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async resolveShortLink(code) {
|
|
268
|
+
const res = await this.transport.request(`/api/sdk/v1/links/${encodeURIComponent(code)}`);
|
|
269
|
+
return res.data?.link || null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Deferred deep link API — listen for resolutions from anywhere
|
|
273
|
+
// in the app. Returns an unsubscribe function.
|
|
274
|
+
onDeepLink(listener) { return this.deepLinks.onResolved(listener); }
|
|
275
|
+
|
|
276
|
+
// Referrals
|
|
277
|
+
async issueReferralCode(payload) {
|
|
278
|
+
const res = await this.transport.request("/api/sdk/v1/referrals/codes", {
|
|
279
|
+
method: "POST", body: payload,
|
|
280
|
+
});
|
|
281
|
+
return res.data?.referral_code || null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async recordReferral(payload) {
|
|
285
|
+
const res = await this.transport.request("/api/sdk/v1/referrals", {
|
|
286
|
+
method: "POST", body: payload,
|
|
287
|
+
});
|
|
288
|
+
return res.data?.referral || null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Lifecycle
|
|
292
|
+
async flush() { return this.queue.flush(); }
|
|
293
|
+
|
|
294
|
+
async endSession() { return this.session.end(); }
|
|
295
|
+
|
|
296
|
+
destroy() {
|
|
297
|
+
this.queue.stop();
|
|
298
|
+
if (typeof this._teardownAutotrack === "function") this._teardownAutotrack();
|
|
299
|
+
this._teardownAutotrack = null;
|
|
300
|
+
this._initPromise = null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Diagnostics
|
|
304
|
+
info() {
|
|
305
|
+
return {
|
|
306
|
+
sdkVersion: SDK_VERSION,
|
|
307
|
+
device: this.device,
|
|
308
|
+
fingerprint: this.fingerprint,
|
|
309
|
+
session: this.session.current(),
|
|
310
|
+
identity: this.identity.current(),
|
|
311
|
+
queueSize: this.queue.size(),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Singleton convenience for the most common case — one SDK per page.
|
|
317
|
+
let defaultInstance = null;
|
|
318
|
+
|
|
319
|
+
export function createSdk(options) {
|
|
320
|
+
return new NurddSdk(options);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function initSdk(options) {
|
|
324
|
+
if (defaultInstance) return defaultInstance;
|
|
325
|
+
defaultInstance = new NurddSdk(options);
|
|
326
|
+
defaultInstance.init().catch((e) => defaultInstance.logger.error("init failed", e));
|
|
327
|
+
return defaultInstance;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function getSdk() {
|
|
331
|
+
if (!defaultInstance) {
|
|
332
|
+
throw new Error("[nurdd] SDK has not been initialised. Call initSdk({ sdkKey, baseUrl }) first.");
|
|
333
|
+
}
|
|
334
|
+
return defaultInstance;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function hasDefaultSdk() { return Boolean(defaultInstance); }
|
|
338
|
+
|
|
339
|
+
export function setDefaultSdk(instance) {
|
|
340
|
+
defaultInstance = instance;
|
|
341
|
+
return defaultInstance;
|
|
342
|
+
}
|
package/src/session.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { nowIso, uuid } from "./utils.js";
|
|
2
|
+
import { isBrowser } from "./env.js";
|
|
3
|
+
|
|
4
|
+
const SESSION_KEY = "session";
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 30 * 60_000; // 30 min idle = new session
|
|
6
|
+
|
|
7
|
+
// Tracks the current logical session. A session ends when the tab has
|
|
8
|
+
// been idle for longer than `timeoutMs` since the last tracked event,
|
|
9
|
+
// or when `end()` is called explicitly. New sessions reuse the device
|
|
10
|
+
// id but get a fresh session id.
|
|
11
|
+
|
|
12
|
+
export class SessionManager {
|
|
13
|
+
constructor({ storage, transport, logger, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
14
|
+
this.storage = storage;
|
|
15
|
+
this.transport = transport;
|
|
16
|
+
this.logger = logger;
|
|
17
|
+
this.timeoutMs = timeoutMs;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_read() { return this.storage.getJson(SESSION_KEY, null); }
|
|
21
|
+
_write(s) { this.storage.setJson(SESSION_KEY, s); }
|
|
22
|
+
_clear() { this.storage.remove(SESSION_KEY); }
|
|
23
|
+
|
|
24
|
+
// Returns the active session, starting a new one if none exists or
|
|
25
|
+
// the previous one has expired. Updates last-touch on every call.
|
|
26
|
+
touch({ utm, referrer } = {}) {
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
const existing = this._read();
|
|
29
|
+
if (existing && now - existing.lastTouchedAt < this.timeoutMs) {
|
|
30
|
+
existing.lastTouchedAt = now;
|
|
31
|
+
this._write(existing);
|
|
32
|
+
return existing;
|
|
33
|
+
}
|
|
34
|
+
const fresh = {
|
|
35
|
+
id: uuid(),
|
|
36
|
+
startedAt: now,
|
|
37
|
+
lastTouchedAt: now,
|
|
38
|
+
isFirst: !existing,
|
|
39
|
+
utm: utm || {},
|
|
40
|
+
referrer: referrer || (isBrowser ? document.referrer || null : null),
|
|
41
|
+
serverId: null,
|
|
42
|
+
};
|
|
43
|
+
this._write(fresh);
|
|
44
|
+
return fresh;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
current() { return this._read(); }
|
|
48
|
+
|
|
49
|
+
// Calls /sessions/start on the backend so we get a server-side
|
|
50
|
+
// session row. The local session id is replaced with the server's
|
|
51
|
+
// canonical id so subsequent event payloads can reference it.
|
|
52
|
+
async openServerSession({ device, externalUserId }) {
|
|
53
|
+
const session = this.touch();
|
|
54
|
+
if (session.serverId) return session;
|
|
55
|
+
const res = await this.transport.request("/api/sdk/v1/sessions/start", {
|
|
56
|
+
method: "POST",
|
|
57
|
+
body: {
|
|
58
|
+
device_id: device.device_id,
|
|
59
|
+
external_user_id: externalUserId || undefined,
|
|
60
|
+
referrer: session.referrer,
|
|
61
|
+
utm: session.utm,
|
|
62
|
+
started_at: nowIso(),
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
if (res.ok && res.data?.session?.id) {
|
|
66
|
+
session.serverId = res.data.session.id;
|
|
67
|
+
this._write(session);
|
|
68
|
+
}
|
|
69
|
+
return session;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async end() {
|
|
73
|
+
const session = this._read();
|
|
74
|
+
if (!session?.serverId) { this._clear(); return null; }
|
|
75
|
+
const duration = Date.now() - session.startedAt;
|
|
76
|
+
await this.transport.request("/api/sdk/v1/sessions/end", {
|
|
77
|
+
method: "POST",
|
|
78
|
+
body: { session_id: session.serverId, duration_ms: duration, ended_at: nowIso() },
|
|
79
|
+
}).catch(() => {});
|
|
80
|
+
this._clear();
|
|
81
|
+
return session;
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/storage.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { hasLocalStorage, isBrowser } from "./env.js";
|
|
2
|
+
import { safeJsonParse } from "./utils.js";
|
|
3
|
+
|
|
4
|
+
// Tiered storage. localStorage is preferred (persists across tabs and
|
|
5
|
+
// across browser restarts); when unavailable (private mode, embedded
|
|
6
|
+
// webviews, SSR) we fall back to an in-memory map so the SDK keeps
|
|
7
|
+
// functioning without crashing.
|
|
8
|
+
|
|
9
|
+
class MemoryStorage {
|
|
10
|
+
constructor() { this.map = new Map(); }
|
|
11
|
+
get(k) { return this.map.get(k) ?? null; }
|
|
12
|
+
set(k, v) { this.map.set(k, v); }
|
|
13
|
+
remove(k) { this.map.delete(k); }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class LocalStorageBacked {
|
|
17
|
+
constructor(prefix) { this.prefix = prefix; }
|
|
18
|
+
_key(k) { return `${this.prefix}${k}`; }
|
|
19
|
+
get(k) {
|
|
20
|
+
try { return window.localStorage.getItem(this._key(k)); }
|
|
21
|
+
catch { return null; }
|
|
22
|
+
}
|
|
23
|
+
set(k, v) {
|
|
24
|
+
try { window.localStorage.setItem(this._key(k), v); }
|
|
25
|
+
catch { /* quota / private mode */ }
|
|
26
|
+
}
|
|
27
|
+
remove(k) {
|
|
28
|
+
try { window.localStorage.removeItem(this._key(k)); }
|
|
29
|
+
catch { /* noop */ }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createStorage({ prefix = "nurdd:" } = {}) {
|
|
34
|
+
const backing = hasLocalStorage ? new LocalStorageBacked(prefix) : new MemoryStorage();
|
|
35
|
+
return {
|
|
36
|
+
getString(key) { return backing.get(key); },
|
|
37
|
+
setString(key, value) { backing.set(key, value); },
|
|
38
|
+
getJson(key, fallback = null) {
|
|
39
|
+
const raw = backing.get(key);
|
|
40
|
+
if (raw == null) return fallback;
|
|
41
|
+
return safeJsonParse(raw, fallback);
|
|
42
|
+
},
|
|
43
|
+
setJson(key, value) { backing.set(key, JSON.stringify(value)); },
|
|
44
|
+
remove(key) { backing.remove(key); },
|
|
45
|
+
isPersistent: hasLocalStorage,
|
|
46
|
+
isBrowser,
|
|
47
|
+
};
|
|
48
|
+
}
|