@noirtrack/sdk 0.2.1 → 0.3.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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to `@noirtrack/sdk` are documented here. This project follows semantic versioning (while pre-1.0, a minor bump may include breaking changes).
4
4
 
5
+ ## 0.3.0
6
+
7
+ ### Added
8
+
9
+ - **Outbound link tracking (web client).** Clicks on links that leave your site are recorded automatically as `$outbound` events with the destination, matching the hosted snippet. Powers the new Outbound links breakdown.
10
+ - **Cookie consent gate (web client).** When you enable a consent banner in the dashboard (Settings → Cookie consent), a non-cookieless client holds all tracking behind it for visitors in the regions you choose, until they accept — no code change, the client reads the setting on load.
11
+ - **Language.** The web client now sends the visitor's `navigator.language`, so the dashboard's Languages breakdown reflects real traffic.
12
+
5
13
  ## 0.2.1
6
14
 
7
15
  ### Fixed
@@ -6,6 +6,8 @@ export interface IngestContext {
6
6
  path: string;
7
7
  referrer: string | null;
8
8
  screen: string | null;
9
+ /** Visitor's primary language (navigator.language); normalised server-side. */
10
+ lang?: string | null;
9
11
  utm: {
10
12
  source: string | null;
11
13
  medium: string | null;
@@ -38,6 +40,8 @@ export interface Ingest {
38
40
  view(path?: string): void;
39
41
  /** Record a custom event. */
40
42
  event(name: string, props?: Props): void;
43
+ /** Record a click that leaves the site (an `$outbound` event; the destination rides `path`). */
44
+ outbound(url: string): void;
41
45
  /**
42
46
  * Attach customer info to the current visitor. Pass one object — `userId`, `name`, `email`, and any
43
47
  * custom fields — matching the snippet's `noir('identify', {…})`. The legacy `(userId, traits)` form
@@ -17,6 +17,7 @@ export function createIngest(platform) {
17
17
  path: ctx.path,
18
18
  referrer: ctx.referrer,
19
19
  screen: ctx.screen,
20
+ lang: ctx.lang ?? null,
20
21
  visitor_id: platform.visitorId(),
21
22
  session_id: platform.sessionId(),
22
23
  utm: ctx.utm,
@@ -58,6 +59,10 @@ export function createIngest(platform) {
58
59
  function event(name, props) {
59
60
  enqueue(payload(name, undefined, props));
60
61
  }
62
+ // Outbound click → `$outbound` event with the destination in `path` (parity with the hosted snippet).
63
+ function outbound(url) {
64
+ enqueue(payload('$outbound', url));
65
+ }
61
66
  function identify(user, traits = {}) {
62
67
  // One object — { userId, name, email, …custom } — matches the snippet. Legacy (userId, traits)
63
68
  // is normalised to the same shape so both forms send an identical `userId` field.
@@ -121,5 +126,5 @@ export function createIngest(platform) {
121
126
  }
122
127
  : null);
123
128
  }
124
- return { view, event, identify, revenue, reset, flush, ping, links, shield, check };
129
+ return { view, event, outbound, identify, revenue, reset, flush, ping, links, shield, check };
125
130
  }
package/dist/web.js CHANGED
@@ -133,6 +133,7 @@ function noopClient(cookieless) {
133
133
  return {
134
134
  view: noop,
135
135
  event: noop,
136
+ outbound: noop,
136
137
  identify: noop,
137
138
  revenue: noop,
138
139
  reset: noop,
@@ -150,6 +151,12 @@ export function createClient(options) {
150
151
  return noopClient(cookieless);
151
152
  const endpoint = resolveEndpoint(options.endpoint);
152
153
  const blocked = isBot() || (!options.allowLocalhost && /^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname)) || window.top !== window.self;
154
+ // Consent gate (dashboard-driven, parity with the hosted snippet). When the site enables a banner
155
+ // in Settings → Cookie consent, a non-cookieless client holds ALL tracking until this visitor's
156
+ // region is cleared or they accept. Cookieless stores nothing on the device, so it skips the gate.
157
+ // `allowed` gates every send via platform.disabled() below; initConsent() flips it open.
158
+ const consentGate = !cookieless;
159
+ let allowed = !consentGate;
153
160
  // Visitor + session ids. With cookies: persistent vid (365d) + short sid (30m). Cookieless:
154
161
  // sessionStorage only, so nothing is written to the device.
155
162
  function id(kind) {
@@ -181,6 +188,7 @@ export function createClient(options) {
181
188
  path: pathOverride ?? location.pathname + location.search,
182
189
  referrer: document.referrer || null,
183
190
  screen: window.screen ? `${screen.width}x${screen.height}` : null,
191
+ lang: navigator.language || null,
184
192
  utm: {
185
193
  source: params.get('utm_source'),
186
194
  medium: params.get('utm_medium'),
@@ -213,7 +221,7 @@ export function createClient(options) {
213
221
  }
214
222
  },
215
223
  context,
216
- disabled: () => blocked,
224
+ disabled: () => blocked || !allowed,
217
225
  deliver(url, body) {
218
226
  const json = JSON.stringify(body);
219
227
  if (navigator.sendBeacon) {
@@ -235,7 +243,10 @@ export function createClient(options) {
235
243
  };
236
244
  const ingest = createIngest(platform);
237
245
  const client = { ...ingest, cookieless };
238
- if (!blocked) {
246
+ // Everything that tracks lives here so the consent gate can hold it until the visitor accepts.
247
+ // Opening `allowed` first is what lets the gated senders (view/event/ping/…) fire.
248
+ const startTracking = () => {
249
+ allowed = true;
239
250
  // Soft block on the first view: ask the server, then enforce. /api/v1/check already RECORDS
240
251
  // this pageview (stamped with the verdict), so when blocking is on we must NOT also call
241
252
  // ingest.view() for the first view, or it would be counted twice.
@@ -269,6 +280,23 @@ export function createClient(options) {
269
280
  if (options.autoGoals ?? true) {
270
281
  initDeclarativeGoals((name, meta) => ingest.event(name, meta));
271
282
  }
283
+ // Outbound link tracking — clicks that leave the site become `$outbound` events (parity with
284
+ // the hosted snippet). Same-site and non-http(s) links (mailto:, tel:) are skipped.
285
+ document.addEventListener('click', (e) => {
286
+ const a = e.target?.closest?.('a[href]');
287
+ if (!a)
288
+ return;
289
+ let url;
290
+ try {
291
+ url = new URL(a.href, location.href);
292
+ }
293
+ catch {
294
+ return;
295
+ }
296
+ if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.host === location.host)
297
+ return;
298
+ ingest.outbound(url.host + url.pathname);
299
+ }, true);
272
300
  // Presence heartbeat — keep this visitor in the realtime count while the tab is visible
273
301
  // (parity with the hosted snippet). Paused when hidden; re-sent the instant it regains focus.
274
302
  const heartbeat = () => {
@@ -277,9 +305,135 @@ export function createClient(options) {
277
305
  };
278
306
  document.addEventListener('visibilitychange', heartbeat);
279
307
  setInterval(heartbeat, 45_000);
308
+ };
309
+ if (!blocked) {
310
+ if (consentGate)
311
+ initConsent(endpoint, options.publicKey, startTracking);
312
+ else
313
+ startTracking();
280
314
  }
281
315
  return client;
282
316
  }
317
+ const CONSENT_KEY = 'noir_consent';
318
+ function readConsentDecision() {
319
+ try {
320
+ return JSON.parse(localStorage.getItem(CONSENT_KEY) || 'null');
321
+ }
322
+ catch {
323
+ return null;
324
+ }
325
+ }
326
+ function writeConsentDecision(decision, reaskDays) {
327
+ try {
328
+ localStorage.setItem(CONSENT_KEY, JSON.stringify({ d: decision, exp: Date.now() + reaskDays * 86_400_000 }));
329
+ }
330
+ catch {
331
+ /* storage may be unavailable */
332
+ }
333
+ }
334
+ /**
335
+ * Ask the server whether THIS visitor needs the banner, honouring a fresh stored decision first so a
336
+ * returning decider skips the round-trip. A "not required" result is cached briefly so an ungated site
337
+ * doesn't call on every load, while still picking up a newly-enabled banner within ~30 min. Fail-open,
338
+ * matching the SDK's philosophy: a consent-endpoint hiccup opens the gate rather than losing analytics.
339
+ */
340
+ function initConsent(endpoint, publicKey, start) {
341
+ const rec = readConsentDecision();
342
+ if (rec && rec.exp && rec.exp > Date.now()) {
343
+ if (rec.d === 'a')
344
+ start(); // accepted & fresh → track; declined → stay silent
345
+ return;
346
+ }
347
+ void fetch(`${endpoint}/api/v1/consent`, {
348
+ method: 'POST',
349
+ headers: { 'Content-Type': 'application/json' },
350
+ body: JSON.stringify({ site_key: publicKey }),
351
+ })
352
+ .then((r) => r.json())
353
+ .then((res) => {
354
+ if (!res || !res.required) {
355
+ writeConsentDecision('a', 1 / 48); // ~30 min: re-check so an enabled banner reaches seen visitors
356
+ start();
357
+ return;
358
+ }
359
+ renderConsentBanner(res.banner ?? {}, start);
360
+ })
361
+ .catch(() => start());
362
+ }
363
+ // Brand indigo (NoirTrack --primary ≈ hsl(243 75% 59%)). Self-contained banner that adapts to the
364
+ // visitor's site theme (prefers-color-scheme), so it reads well on any light or dark host page.
365
+ const CONSENT_ACCENT = '#5048e5';
366
+ const CONSENT_SHIELD = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="${CONSENT_ACCENT}" stroke-width="2" ` +
367
+ 'stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/></svg>';
368
+ function renderConsentBanner(cfg, onAccept) {
369
+ const pos = cfg.position || 'bottom';
370
+ const box = pos === 'bottom-left' || pos === 'bottom-right';
371
+ let dark = false;
372
+ try {
373
+ dark = !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
374
+ }
375
+ catch {
376
+ /* matchMedia may be unavailable */
377
+ }
378
+ const cardBg = dark ? '#0f172a' : '#ffffff';
379
+ const textCol = dark ? '#e2e8f0' : '#334155';
380
+ const ring = dark ? 'rgba(255,255,255,0.10)' : 'rgba(15,23,42,0.08)';
381
+ const declineBorder = dark ? 'rgba(255,255,255,0.16)' : '#cbd5e1';
382
+ const wrap = document.createElement('div');
383
+ const pin = box
384
+ ? 'bottom:16px;' + (pos === 'bottom-left' ? 'left:16px;' : 'right:16px;') + 'max-width:400px;'
385
+ : 'left:16px;right:16px;' + (pos === 'top' ? 'top:16px;' : 'bottom:16px;') + 'max-width:920px;margin:0 auto;';
386
+ wrap.setAttribute('style', 'position:fixed;z-index:2147483646;' +
387
+ pin +
388
+ 'font:14px/1.55 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;display:flex;gap:14px;align-items:center;flex-wrap:wrap;' +
389
+ `background:${cardBg};color:${textCol};border:1px solid ${ring};border-radius:16px;padding:16px 18px;` +
390
+ `box-shadow:0 12px 42px -10px rgba(0,0,0,${dark ? '0.65' : '0.22'});`);
391
+ const icon = document.createElement('span');
392
+ icon.setAttribute('style', 'display:flex;align-items:center;justify-content:center;width:34px;height:34px;flex:none;border-radius:10px;background:rgba(80,72,229,0.14);');
393
+ icon.innerHTML = CONSENT_SHIELD; // hardcoded constant (no user data) — safe
394
+ const msg = document.createElement('div');
395
+ msg.setAttribute('style', 'flex:1 1 240px;min-width:0;');
396
+ msg.textContent = cfg.message || 'We use privacy-friendly analytics. No personal data is sold or shared.';
397
+ if (cfg.privacyUrl) {
398
+ const a = document.createElement('a');
399
+ a.href = cfg.privacyUrl;
400
+ a.target = '_blank';
401
+ a.rel = 'noopener';
402
+ a.textContent = 'Privacy';
403
+ a.setAttribute('style', `margin-left:6px;color:${CONSENT_ACCENT};font-weight:500;text-decoration:underline;`);
404
+ msg.appendChild(a);
405
+ }
406
+ const btns = document.createElement('div');
407
+ btns.setAttribute('style', 'display:flex;gap:8px;flex:0 0 auto;');
408
+ const decline = document.createElement('button');
409
+ decline.type = 'button';
410
+ decline.textContent = cfg.decline || 'Decline';
411
+ decline.setAttribute('style', `padding:9px 15px;border:1px solid ${declineBorder};background:transparent;color:${textCol};border-radius:10px;font:inherit;font-weight:500;cursor:pointer;`);
412
+ const accept = document.createElement('button');
413
+ accept.type = 'button';
414
+ accept.textContent = cfg.accept || 'Accept';
415
+ accept.setAttribute('style', `padding:9px 15px;border:0;background:${CONSENT_ACCENT};color:#fff;border-radius:10px;font:inherit;font-weight:600;cursor:pointer;box-shadow:0 1px 2px rgba(0,0,0,0.15);`);
416
+ const close = () => {
417
+ if (wrap.parentNode)
418
+ wrap.parentNode.removeChild(wrap);
419
+ };
420
+ const reask = cfg.reaskDays || 180;
421
+ accept.addEventListener('click', () => {
422
+ writeConsentDecision('a', reask);
423
+ close();
424
+ onAccept();
425
+ });
426
+ decline.addEventListener('click', () => {
427
+ writeConsentDecision('d', reask); // stay silent until the re-ask window lapses
428
+ close();
429
+ });
430
+ btns.appendChild(decline);
431
+ btns.appendChild(accept);
432
+ wrap.appendChild(icon);
433
+ wrap.appendChild(msg);
434
+ wrap.appendChild(btns);
435
+ (document.body || document.documentElement).appendChild(wrap);
436
+ }
283
437
  function overlay() {
284
438
  const el = document.createElement('div');
285
439
  el.setAttribute('style', 'position:fixed;inset:0;z-index:2147483647;background:#0a0a0a;color:#fff;' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noirtrack/sdk",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "NoirTrack server SDK. Block bots and bad traffic before your app renders, and record goals, revenue, and identify from your backend with one secret key. Framework-agnostic core plus Next.js, Express, and fetch/edge adapters.",
5
5
  "homepage": "https://noirtrack.com/docs",
6
6
  "bugs": {