@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
|
@@ -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
|
+
}
|
package/src/autotrack.js
ADDED
|
@@ -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
|
+
}
|
package/src/deeplinks.js
ADDED
|
@@ -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";
|