@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.
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -0,0 +1,172 @@
1
+ # Using Nurdd with Shopify
2
+
3
+ Track marketing performance on your Shopify store — installs of intent
4
+ (visits from campaigns), signups, and **purchases** — all attributed back to
5
+ the Nurdd link a shopper originally clicked.
6
+
7
+ You **don't** need a separate SDK. You reuse `@nurdd/web-sdk` on your
8
+ storefront and add two small glue pieces for checkout, where the SDK can't
9
+ run. Three steps, ~10 minutes.
10
+
11
+ ## What you get
12
+
13
+ | Surface | What's tracked | How |
14
+ | ------- | -------------- | --- |
15
+ | Storefront (theme) | page views, events, signups, campaign landing | SDK in `theme.liquid` |
16
+ | Checkout | purchases (client-side) | Custom Pixel |
17
+ | Order (server) | purchases (reliable) | `orders/create` webhook |
18
+
19
+ ## Prerequisites
20
+
21
+ - Your **public SDK key** (`sdk_pk_…`) and **API URL** from the Nurdd dashboard.
22
+ - Access to your store's theme code (Online Store → Themes → Edit code).
23
+ - For purchases: access to **Settings → Customer events** and/or the ability
24
+ to create a webhook (Settings → Notifications → Webhooks, or the Admin API).
25
+
26
+ ---
27
+
28
+ ## Step 1 — Storefront
29
+
30
+ Add the SDK to your theme. Open `Online Store → Themes → … → Edit code →
31
+ layout/theme.liquid` and paste this just before `</head>`:
32
+
33
+ ```html
34
+ <script src="https://unpkg.com/@nurdd/web-sdk/dist/nurdd-sdk.umd.js"></script>
35
+ <script>
36
+ var nurdd = Nurdd.initSdk({
37
+ sdkKey: "sdk_pk_xxx",
38
+ baseUrl: "https://api.nurdd.club",
39
+ });
40
+
41
+ // Tie activity to a logged-in customer.
42
+ {% if customer %}
43
+ nurdd.identify("{{ customer.id }}", { email: "{{ customer.email }}" });
44
+ {% endif %}
45
+
46
+ // Carry the campaign into checkout so the purchase can be attributed.
47
+ nurdd.attachToShopifyCart();
48
+ </script>
49
+ ```
50
+
51
+ **Routing campaign traffic:** point your Nurdd links so shoppers land on the
52
+ store with the campaign code in the URL — either `?sdk_short_code=YOURCODE`
53
+ or `?nurdd_shortcode=YOURCODE`. The SDK reads it on arrival, persists it, and
54
+ `attachToShopifyCart()` writes it onto the cart so it survives all the way to
55
+ the order.
56
+
57
+ > **When to call `attachToShopifyCart()`:** any time before checkout — on page
58
+ > load (as above) is fine, but calling it again right before "Add to cart"
59
+ > guarantees the attribute is set even for shoppers who landed on a page with
60
+ > an empty cart. It's a no-op when there's no campaign code, so it's safe to
61
+ > call repeatedly.
62
+
63
+ ### Track events and signups
64
+
65
+ ```html
66
+ <script>
67
+ // a custom event
68
+ nurdd.track("add_to_cart", { product_id: "{{ product.id }}" });
69
+
70
+ // a signup / account creation
71
+ nurdd.signup
72
+ ? nurdd.signup({ externalUserId: "{{ customer.id }}", email: "{{ customer.email }}" })
73
+ : nurdd.conversion("signup");
74
+ </script>
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Step 2 — Checkout pixel (client-side purchase)
80
+
81
+ The full SDK can't run on Shopify's checkout (sandboxed — no
82
+ `window`/`localStorage`), so use a small pixel instead:
83
+
84
+ 1. Go to **Settings → Customer events → Add custom pixel**, name it `Nurdd`.
85
+ 2. Paste the contents of [`checkout-pixel.js`](./checkout-pixel.js).
86
+ 3. Set `NURDD_API` and `SDK_KEY` at the top (and `CART_ATTRIBUTE` if you
87
+ changed the default).
88
+ 4. **Save**, then **Connect**.
89
+
90
+ It fires a `purchase` conversion on `checkout_completed`, attributed to the
91
+ campaign code stored on the cart.
92
+
93
+ ---
94
+
95
+ ## Step 3 — Order webhook (reliable purchase)
96
+
97
+ Client pixels can be blocked by ad-blockers or fail to fire. For dependable
98
+ revenue attribution, also record orders **server-side**:
99
+
100
+ 1. Create an **Order creation** (`orders/create`) webhook (JSON) pointing at
101
+ your backend route (Settings → Notifications → Webhooks, or the Admin API).
102
+ 2. Set `SHOPIFY_WEBHOOK_SECRET` in your backend environment.
103
+ 3. Use [`orders-webhook.example.js`](./orders-webhook.example.js) — it verifies
104
+ the Shopify HMAC, reads the campaign code from the order's `note_attributes`,
105
+ and records a `purchase` conversion. Plug in your own `recordConversion(...)`.
106
+
107
+ Run the pixel **and** the webhook together: the pixel gives instant
108
+ client-side data, the webhook guarantees you never miss a paid order.
109
+
110
+ ---
111
+
112
+ ## How attribution flows
113
+
114
+ ```
115
+ Nurdd link (?sdk_short_code=ABC)
116
+
117
+
118
+ Storefront ── SDK persists "ABC" ── attachToShopifyCart() → cart attribute
119
+
120
+
121
+ Checkout ── pixel reads cart attribute → purchase conversion (ABC)
122
+
123
+
124
+ Order ── orders/create webhook reads attribute → purchase conversion (ABC) ← reliable
125
+ ```
126
+
127
+ ---
128
+
129
+ ## Verify it's working
130
+
131
+ 1. **Storefront:** open your store with `?sdk_short_code=TEST123`, open the
132
+ browser console, and run `Nurdd.getSdk().attributedShortCode()` — it should
133
+ return `"TEST123"`.
134
+ 2. **Cart attribute:** add an item, then load `/cart.js` in the browser — you
135
+ should see `attributes: { nurdd_short_code: "TEST123" }`.
136
+ 3. **Pixel:** in Customer events, the pixel shows recent activity; complete a
137
+ test order and confirm a request to `/api/sdk/v1/conversions` fires.
138
+ 4. **Webhook:** check your backend logs for the `orders/create` delivery.
139
+
140
+ ## Troubleshooting
141
+
142
+ | Symptom | Likely cause |
143
+ | ------- | ------------ |
144
+ | `attributedShortCode()` is `null` | The landing URL had no `sdk_short_code` / `nurdd_shortcode` param, or the SDK loaded after the redirect stripped it. |
145
+ | Cart attribute missing on the order | `attachToShopifyCart()` ran before a cart existed — call it again at "Add to cart". |
146
+ | Pixel fires but purchase isn't attributed | The cart attribute name in the pixel doesn't match `attachToShopifyCart()`'s (`nurdd_short_code`). |
147
+ | Nothing recorded at all | The backend `/api/sdk/v1/conversions` endpoint must exist and accept your public key. |
148
+
149
+ ## Multiple campaigns
150
+
151
+ A shopper can arrive via several Nurdd links before buying. `attachToShopifyCart()`
152
+ **accumulates** the codes — the `nurdd_short_code` cart attribute holds a
153
+ de-duplicated, comma-separated list in **first-touch → last-touch** order
154
+ (e.g. `spring25,welcome10`). The checkout pixel and the webhook:
155
+
156
+ - credit the purchase to the **last-touch** code by default (change to the
157
+ first element for first-touch), and
158
+ - send the full list as `attribution.short_codes` so you can do multi-touch
159
+ attribution server-side.
160
+
161
+ This avoids double-counting revenue (one purchase = one conversion) while
162
+ preserving every campaign the shopper interacted with.
163
+
164
+ ## Notes
165
+
166
+ - **Hydrogen (headless Shopify):** you don't need any of this — it's a normal
167
+ React/Remix app, so use `@nurdd/web-sdk/react` directly, checkout included.
168
+ - **Standard vs. Plus:** these steps work on standard Shopify. Shopify Plus can
169
+ additionally customize checkout, but the pixel + webhook approach works on
170
+ both and is recommended.
171
+ - The public SDK key is safe to expose in the theme and pixel — it's designed
172
+ to be client-side.
@@ -0,0 +1,63 @@
1
+ // Nurdd × Shopify — Checkout pixel
2
+ // =================================
3
+ // The full Nurdd SDK can't run on Shopify's checkout (it's a sandboxed
4
+ // environment with no window/localStorage). This small snippet does the one
5
+ // job that matters there: record the purchase when an order completes.
6
+ //
7
+ // SETUP
8
+ // 1. In Shopify admin: Settings → Customer events → Add custom pixel.
9
+ // 2. Paste this file's contents, set NURDD_API + SDK_KEY below, Save & Connect.
10
+ //
11
+ // It reads the campaign code from the cart attribute that
12
+ // `sdk.attachToShopifyCart()` set on the storefront, so the purchase is
13
+ // attributed to the original campaign.
14
+
15
+ const NURDD_API = "https://api.nurdd.club"; // your Nurdd API URL
16
+ const SDK_KEY = "sdk_pk_xxx"; // your public SDK key
17
+ const CART_ATTRIBUTE = "nurdd_short_code"; // must match attachToShopifyCart()
18
+
19
+ analytics.subscribe("checkout_completed", (event) => {
20
+ const checkout = (event && event.data && event.data.checkout) || {};
21
+
22
+ // Custom cart attributes arrive as [{ key, value }, ...]. The shopper may
23
+ // have touched several campaigns, so the attribute holds a comma-separated
24
+ // list in first-touch → last-touch order.
25
+ const attributes = checkout.attributes || [];
26
+ const match = attributes.find((a) => a.key === CART_ATTRIBUTE);
27
+ const shortCodes = (match && match.value ? String(match.value).split(",") : [])
28
+ .map((s) => s.trim())
29
+ .filter(Boolean);
30
+
31
+ // Credit the purchase to the most recent campaign (last-touch). Switch to
32
+ // shortCodes[0] for first-touch. The full list is passed for multi-touch.
33
+ const primary = shortCodes.length ? shortCodes[shortCodes.length - 1] : null;
34
+
35
+ const total = checkout.totalPrice && checkout.totalPrice.amount;
36
+ const currency =
37
+ checkout.currencyCode ||
38
+ (checkout.totalPrice && checkout.totalPrice.currencyCode) ||
39
+ undefined;
40
+
41
+ fetch(`${NURDD_API}/api/sdk/v1/conversions`, {
42
+ method: "POST",
43
+ headers: {
44
+ "Content-Type": "application/json",
45
+ "X-SDK-Key": SDK_KEY,
46
+ "X-SDK-Platform": "web",
47
+ },
48
+ keepalive: true,
49
+ body: JSON.stringify({
50
+ conversion_type: "purchase",
51
+ value: total != null ? Number(total) : undefined,
52
+ currency,
53
+ short_code: primary || undefined,
54
+ attribution: {
55
+ source: "shopify_checkout",
56
+ order_id: checkout.order && checkout.order.id,
57
+ short_codes: shortCodes, // every campaign touched, first → last
58
+ },
59
+ }),
60
+ }).catch(() => {
61
+ // Best-effort; the orders/create webhook is the reliable fallback.
62
+ });
63
+ });
@@ -0,0 +1,69 @@
1
+ // Nurdd × Shopify — orders/create webhook (server-side)
2
+ // =====================================================
3
+ // The most reliable way to attribute Shopify purchases: it runs on your
4
+ // backend, so it survives ad-blockers and the checkout sandbox entirely.
5
+ // Shopify calls it on every new order; you read the campaign code that
6
+ // `sdk.attachToShopifyCart()` stored and record a conversion.
7
+ //
8
+ // SETUP
9
+ // 1. Shopify admin → Settings → Notifications → Webhooks (or via the API):
10
+ // create an "Order creation" webhook (JSON) pointing at this route.
11
+ // 2. Set SHOPIFY_WEBHOOK_SECRET in your environment (the webhook's secret).
12
+ // 3. Mount with a RAW body parser so HMAC verification works:
13
+ // app.post(
14
+ // "/webhooks/shopify/orders",
15
+ // express.raw({ type: "application/json" }),
16
+ // shopifyOrderWebhook
17
+ // );
18
+
19
+ import crypto from "node:crypto";
20
+
21
+ const CART_ATTRIBUTE = "nurdd_short_code"; // must match attachToShopifyCart()
22
+
23
+ export function shopifyOrderWebhook(req, res) {
24
+ const secret = process.env.SHOPIFY_WEBHOOK_SECRET || "";
25
+ const sent = req.get("X-Shopify-Hmac-Sha256") || "";
26
+
27
+ // Verify the request really came from Shopify (HMAC over the raw body).
28
+ const digest = crypto.createHmac("sha256", secret).update(req.body).digest("base64");
29
+ const a = Buffer.from(sent);
30
+ const b = Buffer.from(digest);
31
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
32
+ return res.status(401).send("invalid hmac");
33
+ }
34
+
35
+ let order;
36
+ try {
37
+ order = JSON.parse(req.body.toString("utf8"));
38
+ } catch {
39
+ return res.status(400).send("bad json");
40
+ }
41
+
42
+ // Cart attributes appear on the order as note_attributes [{ name, value }].
43
+ // The shopper may have touched several campaigns, so the value is a
44
+ // comma-separated list in first-touch → last-touch order.
45
+ const attrs = order.note_attributes || [];
46
+ const found = attrs.find((x) => x.name === CART_ATTRIBUTE);
47
+ const shortCodes = (found && found.value ? String(found.value).split(",") : [])
48
+ .map((s) => s.trim())
49
+ .filter(Boolean);
50
+ const shortCode = shortCodes.length ? shortCodes[shortCodes.length - 1] : null; // last-touch
51
+
52
+ // --- Record the conversion ---------------------------------------------
53
+ // Plug in your attribution service / table here. Example shape:
54
+ //
55
+ // await recordConversion({
56
+ // conversionType: "purchase",
57
+ // value: Number(order.total_price),
58
+ // currency: order.currency,
59
+ // shortCode, // attributes to the original campaign
60
+ // externalUserId: order.customer?.id?.toString(),
61
+ // email: order.email,
62
+ // orderId: order.id,
63
+ // occurredAt: order.created_at,
64
+ // });
65
+ //
66
+ // Acknowledge fast (within Shopify's timeout); do heavy work async.
67
+
68
+ return res.status(200).send("ok");
69
+ }
package/package.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "@nurdd/web-sdk",
3
+ "version": "1.0.0",
4
+ "description": "Framework-agnostic attribution & analytics SDK for the browser. Works with Vanilla JS, React, Next.js, Vue, Nuxt, Angular, Remix, and SvelteKit.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "module": "./src/index.js",
8
+ "browser": "./dist/nurdd-sdk.umd.js",
9
+ "unpkg": "./dist/nurdd-sdk.umd.js",
10
+ "jsdelivr": "./dist/nurdd-sdk.umd.js",
11
+ "types": "./types/index.d.ts",
12
+ "sideEffects": false,
13
+ "exports": {
14
+ ".": {
15
+ "types": "./types/index.d.ts",
16
+ "browser": "./dist/nurdd-sdk.umd.js",
17
+ "import": "./src/index.js",
18
+ "default": "./src/index.js"
19
+ },
20
+ "./react": {
21
+ "types": "./types/react.d.ts",
22
+ "import": "./adapters/react.js",
23
+ "default": "./adapters/react.js"
24
+ },
25
+ "./vue": {
26
+ "types": "./types/vue.d.ts",
27
+ "import": "./adapters/vue.js",
28
+ "default": "./adapters/vue.js"
29
+ },
30
+ "./angular": {
31
+ "types": "./types/angular.d.ts",
32
+ "import": "./adapters/angular.js",
33
+ "default": "./adapters/angular.js"
34
+ },
35
+ "./next": {
36
+ "types": "./types/next.d.ts",
37
+ "import": "./adapters/next.js",
38
+ "default": "./adapters/next.js"
39
+ },
40
+ "./nuxt": {
41
+ "types": "./types/nuxt.d.ts",
42
+ "import": "./adapters/nuxt.js",
43
+ "default": "./adapters/nuxt.js"
44
+ },
45
+ "./umd": "./dist/nurdd-sdk.umd.js"
46
+ },
47
+ "files": [
48
+ "src",
49
+ "adapters",
50
+ "dist",
51
+ "types",
52
+ "integrations",
53
+ "README.md"
54
+ ],
55
+ "scripts": {
56
+ "build": "node scripts/build-umd.mjs"
57
+ },
58
+ "publishConfig": {
59
+ "access": "public"
60
+ },
61
+ "keywords": [
62
+ "attribution",
63
+ "analytics",
64
+ "tracking",
65
+ "sdk",
66
+ "deep-link",
67
+ "campaign",
68
+ "referral",
69
+ "react",
70
+ "vue",
71
+ "angular",
72
+ "next",
73
+ "nuxt",
74
+ "remix"
75
+ ],
76
+ "peerDependencies": {
77
+ "react": ">=16.8.0",
78
+ "vue": ">=3.0.0",
79
+ "@angular/core": ">=12.0.0"
80
+ },
81
+ "peerDependenciesMeta": {
82
+ "react": { "optional": true },
83
+ "vue": { "optional": true },
84
+ "@angular/core": { "optional": true }
85
+ },
86
+ "license": "MIT"
87
+ }
@@ -0,0 +1,88 @@
1
+ import { isBrowser } from "./env.js";
2
+
3
+ // Wires up automatic event tracking that every framework benefits from:
4
+ //
5
+ // - SPA page views: works for React Router, Vue Router, Next.js, Nuxt,
6
+ // Angular, Remix etc. by monkey-patching `history.pushState` and
7
+ // `history.replaceState` and listening to `popstate`.
8
+ // - Outbound clicks: any `<a>` whose href leaves the current origin.
9
+ // - Page unload: flush queued events via the beacon channel.
10
+ //
11
+ // All of this is opt-in via `config.autotrack`.
12
+
13
+ export function setupAutotrack({ sdk, config, queue, logger }) {
14
+ if (!isBrowser) return () => {};
15
+ const offFns = [];
16
+
17
+ if (config.autotrack.pageViews !== false) {
18
+ offFns.push(trackPageViews(sdk));
19
+ }
20
+ if (config.autotrack.outboundClicks) {
21
+ offFns.push(trackOutboundClicks(sdk));
22
+ }
23
+ if (config.autotrack.flushOnUnload !== false) {
24
+ offFns.push(flushOnUnload(queue));
25
+ }
26
+
27
+ return () => offFns.forEach((fn) => { try { fn(); } catch (e) { logger.warn(e); } });
28
+ }
29
+
30
+ function trackPageViews(sdk) {
31
+ const fire = () => {
32
+ sdk.track("page_view", {
33
+ url: window.location.href,
34
+ path: window.location.pathname,
35
+ search: window.location.search,
36
+ title: typeof document !== "undefined" ? document.title : null,
37
+ referrer: typeof document !== "undefined" ? document.referrer || null : null,
38
+ });
39
+ };
40
+ // Initial view.
41
+ fire();
42
+
43
+ const origPush = history.pushState;
44
+ const origReplace = history.replaceState;
45
+ history.pushState = function (...args) {
46
+ const r = origPush.apply(this, args);
47
+ queueMicrotask(fire);
48
+ return r;
49
+ };
50
+ history.replaceState = function (...args) {
51
+ const r = origReplace.apply(this, args);
52
+ queueMicrotask(fire);
53
+ return r;
54
+ };
55
+ window.addEventListener("popstate", fire);
56
+ return () => {
57
+ history.pushState = origPush;
58
+ history.replaceState = origReplace;
59
+ window.removeEventListener("popstate", fire);
60
+ };
61
+ }
62
+
63
+ function trackOutboundClicks(sdk) {
64
+ const handler = (e) => {
65
+ const a = e.target?.closest?.("a[href]");
66
+ if (!a) return;
67
+ let host = "";
68
+ try { host = new URL(a.href, window.location.href).host; } catch { return; }
69
+ if (!host || host === window.location.host) return;
70
+ sdk.track("outbound_click", { url: a.href, text: (a.textContent || "").trim().slice(0, 256) });
71
+ };
72
+ document.addEventListener("click", handler, { capture: true });
73
+ return () => document.removeEventListener("click", handler, { capture: true });
74
+ }
75
+
76
+ function flushOnUnload(queue) {
77
+ const handler = () => queue.flushBeacon();
78
+ // `pagehide` is preferred over `unload` (works on bfcache).
79
+ window.addEventListener("pagehide", handler);
80
+ window.addEventListener("beforeunload", handler);
81
+ document.addEventListener("visibilitychange", () => {
82
+ if (document.visibilityState === "hidden") queue.flushBeacon();
83
+ });
84
+ return () => {
85
+ window.removeEventListener("pagehide", handler);
86
+ window.removeEventListener("beforeunload", handler);
87
+ };
88
+ }
@@ -0,0 +1,79 @@
1
+ import { isBrowser } from "./env.js";
2
+ import { readUtmFromLocation } from "./utm.js";
3
+
4
+ // Deferred deep link resolver. On first launch / first SDK init, asks
5
+ // the backend "did this device just click a short link that hasn't yet
6
+ // been claimed?" If yes, the listener receives the deep link path and
7
+ // the host app routes to it.
8
+ //
9
+ // Resolution sources, in priority order:
10
+ // 1. UTM params on the current URL (short_code present) → server claim
11
+ // 2. Fingerprint match via /links/deferred
12
+
13
+ export class DeepLinkResolver {
14
+ constructor({ transport, logger }) {
15
+ this.transport = transport;
16
+ this.logger = logger;
17
+ this.listeners = new Set();
18
+ this.lastResolved = null;
19
+ }
20
+
21
+ onResolved(listener) {
22
+ this.listeners.add(listener);
23
+ // Fire late subscribers with the cached value so framework
24
+ // adapters don't miss the first resolution.
25
+ if (this.lastResolved) {
26
+ try { listener(this.lastResolved); } catch (e) { this.logger.warn("deep link listener threw", e); }
27
+ }
28
+ return () => this.listeners.delete(listener);
29
+ }
30
+
31
+ async resolve({ device }) {
32
+ const params = isBrowser ? readUtmFromLocation() : {};
33
+ let resolved = null;
34
+
35
+ if (params.short_code) {
36
+ const link = await this.transport.request(`/api/sdk/v1/links/${encodeURIComponent(params.short_code)}`);
37
+ if (link.ok && link.data?.link) {
38
+ resolved = {
39
+ short_code: link.data.link.short_code,
40
+ deep_link_path: link.data.link.deep_link_path || null,
41
+ destination_url: link.data.link.destination_url || null,
42
+ utm: link.data.link.utm || {},
43
+ metadata: link.data.link.metadata || {},
44
+ source: "url",
45
+ };
46
+ }
47
+ }
48
+
49
+ if (!resolved) {
50
+ const res = await this.transport.request("/api/sdk/v1/links/deferred", {
51
+ method: "POST",
52
+ body: {
53
+ short_code: params.short_code || "__resolve__",
54
+ platform: "web",
55
+ device_id: device?.device_id,
56
+ },
57
+ });
58
+ if (res.ok && res.data?.deferred_deep_link) {
59
+ const d = res.data.deferred_deep_link;
60
+ resolved = {
61
+ short_code: d.short_code,
62
+ deep_link_path: d.deep_link_path,
63
+ destination_url: null,
64
+ utm: {},
65
+ metadata: {},
66
+ source: "deferred",
67
+ };
68
+ }
69
+ }
70
+
71
+ if (resolved) {
72
+ this.lastResolved = resolved;
73
+ for (const l of this.listeners) {
74
+ try { l(resolved); } catch (e) { this.logger.warn("deep link listener threw", e); }
75
+ }
76
+ }
77
+ return resolved;
78
+ }
79
+ }
package/src/device.js ADDED
@@ -0,0 +1,78 @@
1
+ import { isBrowser, SDK_VERSION } from "./env.js";
2
+ import { uuid, fnv1a } from "./utils.js";
3
+
4
+ const DEVICE_ID_KEY = "device_id";
5
+
6
+ // Builds the device payload the backend expects on every event. The
7
+ // backend re-validates and fills in anything we miss server-side.
8
+ export function collectDevice({ storage, appVersion }) {
9
+ let deviceId = storage.getString(DEVICE_ID_KEY);
10
+ if (!deviceId) {
11
+ deviceId = uuid();
12
+ storage.setString(DEVICE_ID_KEY, deviceId);
13
+ }
14
+
15
+ if (!isBrowser) {
16
+ return {
17
+ device_id: deviceId,
18
+ platform: "server",
19
+ sdk_version: SDK_VERSION,
20
+ app_version: appVersion || null,
21
+ };
22
+ }
23
+
24
+ const nav = window.navigator || {};
25
+ const screen = window.screen || {};
26
+ const locale = nav.language || nav.userLanguage || null;
27
+ let timezone = null;
28
+ try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || null; } catch { /* ignore */ }
29
+
30
+ return {
31
+ device_id: deviceId,
32
+ platform: "web",
33
+ os_name: parseOsName(nav.userAgent || ""),
34
+ os_version: null,
35
+ model: null,
36
+ manufacturer: null,
37
+ app_version: appVersion || null,
38
+ sdk_version: SDK_VERSION,
39
+ locale,
40
+ timezone,
41
+ attributes: {
42
+ user_agent: nav.userAgent || null,
43
+ screen: `${screen.width || 0}x${screen.height || 0}`,
44
+ viewport: `${window.innerWidth || 0}x${window.innerHeight || 0}`,
45
+ color_depth: screen.colorDepth || null,
46
+ pixel_ratio: window.devicePixelRatio || 1,
47
+ hardware_concurrency: nav.hardwareConcurrency || null,
48
+ do_not_track: nav.doNotTrack === "1" || nav.doNotTrack === "yes",
49
+ },
50
+ };
51
+ }
52
+
53
+ function parseOsName(ua) {
54
+ if (/Windows NT/i.test(ua)) return "Windows";
55
+ if (/Mac OS X/i.test(ua)) return "macOS";
56
+ if (/Android/i.test(ua)) return "Android";
57
+ if (/iPhone|iPad|iPod/i.test(ua)) return "iOS";
58
+ if (/Linux/i.test(ua)) return "Linux";
59
+ if (/CrOS/i.test(ua)) return "ChromeOS";
60
+ return null;
61
+ }
62
+
63
+ // Local fingerprint hint. The server computes the authoritative
64
+ // fingerprint from IP+UA, but we provide a stable client value so
65
+ // clicks and installs can also match in pure-frontend flows.
66
+ export function clientFingerprint(device) {
67
+ const a = device.attributes || {};
68
+ return fnv1a([
69
+ device.platform,
70
+ device.os_name,
71
+ device.locale,
72
+ device.timezone,
73
+ a.screen,
74
+ a.color_depth,
75
+ a.pixel_ratio,
76
+ a.hardware_concurrency,
77
+ ].join("|"));
78
+ }
package/src/env.js ADDED
@@ -0,0 +1,21 @@
1
+ // Runtime detection so the same module works in browsers, SSR, web workers,
2
+ // React Native (via metro shimming `window`), and any other JS host.
3
+ export const isBrowser =
4
+ typeof window !== "undefined" &&
5
+ typeof document !== "undefined" &&
6
+ typeof navigator !== "undefined";
7
+
8
+ export const hasLocalStorage = (() => {
9
+ try {
10
+ if (!isBrowser) return false;
11
+ const k = "__nurdd_probe__";
12
+ window.localStorage.setItem(k, "1");
13
+ window.localStorage.removeItem(k);
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ })();
19
+
20
+ export const SDK_NAME = "web";
21
+ export const SDK_VERSION = "1.0.0";