@noirtrack/sdk 0.2.0 → 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 +15 -1
- package/README.md +2 -2
- package/dist/core/ingest.d.ts +4 -0
- package/dist/core/ingest.js +6 -1
- package/dist/web.d.ts +2 -2
- package/dist/web.js +177 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,11 +2,25 @@
|
|
|
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
|
+
|
|
13
|
+
## 0.2.1
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **Scroll goals on tall sections.** `data-noir-scroll-threshold` is the fraction of the *element* visible at once, so a section taller than the viewport could never reach it and the goal never fired. The effective threshold is now capped to what's reachable for the element's height, so a tall section fires once it has essentially filled the screen. Short elements are unaffected. The same fix ships in the hosted snippet.
|
|
18
|
+
|
|
5
19
|
## 0.2.0
|
|
6
20
|
|
|
7
21
|
### Changed
|
|
8
22
|
|
|
9
|
-
- **Breaking (web client):** `block` is now a boolean. Turn on the
|
|
23
|
+
- **Breaking (web client):** `block` is now a boolean. Turn on the firewall with `createClient({ block: true })` instead of `block: 'redirect' | 'overlay'`. Whether it monitors or blocks, and how a block looks (redirect or overlay), are now set per site in Firewall settings, with no code change. If you passed `block: 'redirect'` or `block: 'overlay'`, change it to `block: true`.
|
|
10
24
|
|
|
11
25
|
### Added
|
|
12
26
|
|
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ noir.identify('user_123', { email });
|
|
|
26
26
|
noir.revenue({ checkoutId: 'cs_test_123' });
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
Pageviews and route changes are automatic. Events are batched and sent with `sendBeacon`. Set `cookieless: true` for a no-cookie mode, or `block: true` to turn on the
|
|
29
|
+
Pageviews and route changes are automatic. Events are batched and sent with `sendBeacon`. Set `cookieless: true` for a no-cookie mode, or `block: true` to turn on the firewall. Whether it monitors or blocks, and how a block looks (redirect or overlay), are set in your Firewall settings, with no code change. Safe to import in SSR (it does nothing without a window). Also: `view(path?)`, `reset()`, `flush()`, `links.decorate(url)`.
|
|
30
30
|
|
|
31
31
|
## React Native
|
|
32
32
|
|
|
@@ -95,7 +95,7 @@ The guards resolve the visitor IP from `CF-Connecting-IP`, `True-Client-IP`, the
|
|
|
95
95
|
| `timeoutMs` | `800` | all | Abort a call after this, then fail open. |
|
|
96
96
|
| `autoPageviews` | `true` | web | Capture initial + SPA pageviews. |
|
|
97
97
|
| `cookieless` | `false` | web | No cookies; ids in sessionStorage. |
|
|
98
|
-
| `block` | `false` | web | Turn on the
|
|
98
|
+
| `block` | `false` | web | Turn on the firewall (Monitor/Block set in dashboard). |
|
|
99
99
|
| `flushIntervalMs` / `maxQueueSize` | `5000` / `10` | web, RN | Batch flush tuning. |
|
|
100
100
|
| `storage` | in-memory | RN | Async store (pass AsyncStorage). |
|
|
101
101
|
| `onBlock` / `blockStatus` / `blockedPage` | `block` / `403` / none | adapters | Block handling. |
|
package/dist/core/ingest.d.ts
CHANGED
|
@@ -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
|
package/dist/core/ingest.js
CHANGED
|
@@ -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.d.ts
CHANGED
|
@@ -11,8 +11,8 @@ export interface WebClientOptions {
|
|
|
11
11
|
cookieless?: boolean;
|
|
12
12
|
/** Track on localhost too (off by default, like the hosted script). */
|
|
13
13
|
allowLocalhost?: boolean;
|
|
14
|
-
/** Turn on the
|
|
15
|
-
*
|
|
14
|
+
/** Turn on the firewall on the first view. Whether it monitors or blocks, and how a block looks
|
|
15
|
+
* (redirect or overlay), are set per site in Firewall settings. Off by default. */
|
|
16
16
|
block?: boolean;
|
|
17
17
|
/** Optional redirect target if none is set in Firewall settings. */
|
|
18
18
|
blockedPage?: string;
|
package/dist/web.js
CHANGED
|
@@ -70,6 +70,11 @@ function initDeclarativeGoals(fire) {
|
|
|
70
70
|
}, true);
|
|
71
71
|
if (!('IntersectionObserver' in window))
|
|
72
72
|
return;
|
|
73
|
+
// Granular thresholds so the callback runs across the whole scroll range; the actual trigger is
|
|
74
|
+
// computed live per element (see below), so we can't rely on a single fixed threshold.
|
|
75
|
+
const STEPS = [];
|
|
76
|
+
for (let s = 0; s <= 1.0001; s += 0.05)
|
|
77
|
+
STEPS.push(Math.round(s * 100) / 100);
|
|
73
78
|
const observed = new WeakSet();
|
|
74
79
|
const observeScroll = (el) => {
|
|
75
80
|
if (observed.has(el))
|
|
@@ -78,15 +83,25 @@ function initDeclarativeGoals(fire) {
|
|
|
78
83
|
const name = el.getAttribute('data-noir-scroll');
|
|
79
84
|
if (!name)
|
|
80
85
|
return;
|
|
81
|
-
let
|
|
82
|
-
if (!(
|
|
83
|
-
|
|
86
|
+
let requested = parseFloat(el.getAttribute('data-noir-scroll-threshold') ?? '');
|
|
87
|
+
if (!(requested > 0 && requested <= 1))
|
|
88
|
+
requested = 0.5;
|
|
84
89
|
const delay = parseInt(el.getAttribute('data-noir-scroll-delay') ?? '', 10) || 0;
|
|
85
90
|
const obs = new IntersectionObserver((entries) => {
|
|
86
91
|
for (const entry of entries) {
|
|
87
|
-
if (entry.isIntersecting
|
|
92
|
+
if (!entry.isIntersecting)
|
|
93
|
+
continue;
|
|
94
|
+
// A section taller than the viewport can never expose `requested` of itself at once —
|
|
95
|
+
// the most ever visible is viewportHeight/elementHeight. Compare against that reachable
|
|
96
|
+
// max (×0.9) so a tall element still fires once it has essentially filled the screen,
|
|
97
|
+
// instead of never firing. Short elements keep their requested threshold. // why
|
|
98
|
+
const elHeight = entry.boundingClientRect.height;
|
|
99
|
+
const viewport = entry.rootBounds?.height || window.innerHeight || 0;
|
|
100
|
+
const reachable = elHeight > 0 && viewport > 0 ? (viewport / elHeight) * 0.9 : 1;
|
|
101
|
+
const effective = Math.min(requested, reachable);
|
|
102
|
+
if (entry.intersectionRatio >= effective) {
|
|
88
103
|
obs.disconnect(); // once per element per page
|
|
89
|
-
const meta = { scroll_percentage: scrollPercent(), threshold };
|
|
104
|
+
const meta = { scroll_percentage: scrollPercent(), threshold: requested };
|
|
90
105
|
if (delay > 0)
|
|
91
106
|
setTimeout(() => fire(name, meta), delay);
|
|
92
107
|
else
|
|
@@ -94,7 +109,7 @@ function initDeclarativeGoals(fire) {
|
|
|
94
109
|
return;
|
|
95
110
|
}
|
|
96
111
|
}
|
|
97
|
-
}, { threshold });
|
|
112
|
+
}, { threshold: STEPS });
|
|
98
113
|
obs.observe(el);
|
|
99
114
|
};
|
|
100
115
|
const scan = () => document.querySelectorAll('[data-noir-scroll]').forEach(observeScroll);
|
|
@@ -118,6 +133,7 @@ function noopClient(cookieless) {
|
|
|
118
133
|
return {
|
|
119
134
|
view: noop,
|
|
120
135
|
event: noop,
|
|
136
|
+
outbound: noop,
|
|
121
137
|
identify: noop,
|
|
122
138
|
revenue: noop,
|
|
123
139
|
reset: noop,
|
|
@@ -135,6 +151,12 @@ export function createClient(options) {
|
|
|
135
151
|
return noopClient(cookieless);
|
|
136
152
|
const endpoint = resolveEndpoint(options.endpoint);
|
|
137
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;
|
|
138
160
|
// Visitor + session ids. With cookies: persistent vid (365d) + short sid (30m). Cookieless:
|
|
139
161
|
// sessionStorage only, so nothing is written to the device.
|
|
140
162
|
function id(kind) {
|
|
@@ -166,6 +188,7 @@ export function createClient(options) {
|
|
|
166
188
|
path: pathOverride ?? location.pathname + location.search,
|
|
167
189
|
referrer: document.referrer || null,
|
|
168
190
|
screen: window.screen ? `${screen.width}x${screen.height}` : null,
|
|
191
|
+
lang: navigator.language || null,
|
|
169
192
|
utm: {
|
|
170
193
|
source: params.get('utm_source'),
|
|
171
194
|
medium: params.get('utm_medium'),
|
|
@@ -198,7 +221,7 @@ export function createClient(options) {
|
|
|
198
221
|
}
|
|
199
222
|
},
|
|
200
223
|
context,
|
|
201
|
-
disabled: () => blocked,
|
|
224
|
+
disabled: () => blocked || !allowed,
|
|
202
225
|
deliver(url, body) {
|
|
203
226
|
const json = JSON.stringify(body);
|
|
204
227
|
if (navigator.sendBeacon) {
|
|
@@ -220,7 +243,10 @@ export function createClient(options) {
|
|
|
220
243
|
};
|
|
221
244
|
const ingest = createIngest(platform);
|
|
222
245
|
const client = { ...ingest, cookieless };
|
|
223
|
-
|
|
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;
|
|
224
250
|
// Soft block on the first view: ask the server, then enforce. /api/v1/check already RECORDS
|
|
225
251
|
// this pageview (stamped with the verdict), so when blocking is on we must NOT also call
|
|
226
252
|
// ingest.view() for the first view, or it would be counted twice.
|
|
@@ -254,6 +280,23 @@ export function createClient(options) {
|
|
|
254
280
|
if (options.autoGoals ?? true) {
|
|
255
281
|
initDeclarativeGoals((name, meta) => ingest.event(name, meta));
|
|
256
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);
|
|
257
300
|
// Presence heartbeat — keep this visitor in the realtime count while the tab is visible
|
|
258
301
|
// (parity with the hosted snippet). Paused when hidden; re-sent the instant it regains focus.
|
|
259
302
|
const heartbeat = () => {
|
|
@@ -262,9 +305,135 @@ export function createClient(options) {
|
|
|
262
305
|
};
|
|
263
306
|
document.addEventListener('visibilitychange', heartbeat);
|
|
264
307
|
setInterval(heartbeat, 45_000);
|
|
308
|
+
};
|
|
309
|
+
if (!blocked) {
|
|
310
|
+
if (consentGate)
|
|
311
|
+
initConsent(endpoint, options.publicKey, startTracking);
|
|
312
|
+
else
|
|
313
|
+
startTracking();
|
|
265
314
|
}
|
|
266
315
|
return client;
|
|
267
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
|
+
}
|
|
268
437
|
function overlay() {
|
|
269
438
|
const el = document.createElement('div');
|
|
270
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.
|
|
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": {
|