@g2crowd/buyer-intent-provider-sdk 0.1.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 +243 -0
- package/browser/core.js +135 -0
- package/browser/index.js +6 -0
- package/browser/sdk.js +411 -0
- package/browser/tag_component.js +34 -0
- package/index.d.ts +145 -0
- package/index.js +6 -0
- package/package.json +47 -0
- package/server/handlers.js +274 -0
- package/server/index.js +6 -0
- package/server.js +113 -0
package/README.md
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# Buyer Intent JS SDK
|
|
2
|
+
|
|
3
|
+
Buyer Intent tracking SDK that emits a pageview when a `buyer-intent-tag` element appears. It supports tagging pageview properties during rendering and sends events via `sendBeacon` for best-effort delivery during navigation.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @g2crowd/buyer-intent-provider-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
If you plan to use the built-in Kafka connection (via `kafkaBrokers`), install the peer dependency:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install kafkajs
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start (Next.js)
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
// pages/_app.tsx
|
|
21
|
+
import type { AppProps } from 'next/app';
|
|
22
|
+
import { BuyerIntent } from '@g2crowd/buyer-intent-provider-sdk';
|
|
23
|
+
|
|
24
|
+
export default function App({ Component, pageProps }: AppProps) {
|
|
25
|
+
return (
|
|
26
|
+
<BuyerIntent.TagComponent
|
|
27
|
+
productIds={[123]}
|
|
28
|
+
categoryIds={[45]}
|
|
29
|
+
tag="products.show"
|
|
30
|
+
sourceLocation="ProductsController#show"
|
|
31
|
+
origin="g2.com"
|
|
32
|
+
activityEndpoint="/api/activity/events"
|
|
33
|
+
userType="standard"
|
|
34
|
+
>
|
|
35
|
+
<Component {...pageProps} />
|
|
36
|
+
</BuyerIntent.TagComponent>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Backend (Next.js Route)
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// app/api/activity/events/route.ts
|
|
45
|
+
import { createNextRouteHandler } from '@g2crowd/buyer-intent-provider-sdk/server';
|
|
46
|
+
|
|
47
|
+
const POST = createNextRouteHandler({
|
|
48
|
+
kafkaBrokers: [process.env.KAFKA_BROKER || 'localhost:9092'],
|
|
49
|
+
partnerId: process.env.PARTNER_ID,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export { POST };
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Barebones Server
|
|
56
|
+
|
|
57
|
+
This package ships a tiny server that hosts `POST /activity/events`.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
PARTNER_ID=partner-a KAFKA_BROKERS=broker1:9092,broker2:9092 \
|
|
61
|
+
buyer-intent-server
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Environment variables:
|
|
65
|
+
|
|
66
|
+
- `PORT` (default: 3000)
|
|
67
|
+
- `PARTNER_ID`
|
|
68
|
+
- `TOPIC_PREFIX` (optional)
|
|
69
|
+
- `KAFKA_BROKERS` (comma-separated)
|
|
70
|
+
- `KAFKA_TOPIC` (optional override)
|
|
71
|
+
- `KAFKA_CLIENT_ID` (optional)
|
|
72
|
+
- `USE_DEV_LOGGER=true` (logs instead of Kafka)
|
|
73
|
+
|
|
74
|
+
## Kafka Setup (Partner Checklist)
|
|
75
|
+
|
|
76
|
+
Provide these values to your partners so they can publish to their scoped topic:
|
|
77
|
+
|
|
78
|
+
- Kafka broker list (host:port)
|
|
79
|
+
- Topic name (scoped to their tenant)
|
|
80
|
+
- Auth mechanism (SASL user/pass or mTLS)
|
|
81
|
+
- TLS requirements (on/off, CA certs if needed)
|
|
82
|
+
|
|
83
|
+
By default, the SDK derives the topic from `partnerId` using `intent_events_<partnerId>`.
|
|
84
|
+
|
|
85
|
+
Example using a prebuilt producer:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
// app/api/activity/events/route.ts
|
|
89
|
+
import { Kafka } from 'kafkajs';
|
|
90
|
+
import { createNextRouteHandler } from '@g2crowd/buyer-intent-provider-sdk/server';
|
|
91
|
+
|
|
92
|
+
const kafka = new Kafka({
|
|
93
|
+
clientId: 'partner-app',
|
|
94
|
+
brokers: (process.env.KAFKA_BROKERS || '').split(','),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const producer = kafka.producer({
|
|
98
|
+
allowAutoTopicCreation: false,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const POST = createNextRouteHandler({
|
|
102
|
+
producer,
|
|
103
|
+
partnerId: process.env.PARTNER_ID,
|
|
104
|
+
topicPrefix: process.env.TOPIC_PREFIX,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export { POST };
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Dev Adapter (No Kafka)
|
|
111
|
+
|
|
112
|
+
If you want to test locally without Kafka, use the dev logger producer.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
// app/api/activity/events/route.ts
|
|
116
|
+
import {
|
|
117
|
+
createNextRouteHandler,
|
|
118
|
+
createDevLoggerProducer,
|
|
119
|
+
} from '@g2crowd/buyer-intent-provider-sdk/server';
|
|
120
|
+
|
|
121
|
+
const POST = createNextRouteHandler({
|
|
122
|
+
producer: createDevLoggerProducer(),
|
|
123
|
+
partnerId: 'dev',
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export { POST };
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Tag Page Properties
|
|
130
|
+
|
|
131
|
+
Wrap content with `BuyerIntent.TagComponent` to emit tags and trigger a single pageview when it appears. No pageview is emitted without this element.
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
import { buyerIntent, BuyerIntent } from '@g2crowd/buyer-intent-provider-sdk';
|
|
135
|
+
|
|
136
|
+
<BuyerIntent.TagComponent
|
|
137
|
+
productIds={[123, 456]}
|
|
138
|
+
categoryIds={[78]}
|
|
139
|
+
tag="products.show"
|
|
140
|
+
sourceLocation="ProductsController#show"
|
|
141
|
+
context={{ page: 'product' }}
|
|
142
|
+
>
|
|
143
|
+
<div />
|
|
144
|
+
</BuyerIntent.TagComponent>;
|
|
145
|
+
|
|
146
|
+
buyerIntent.setVisitProperties({
|
|
147
|
+
ip: '203.0.113.10',
|
|
148
|
+
referrer: 'https://example.com/',
|
|
149
|
+
landing_page: 'https://example.com/products/abc',
|
|
150
|
+
user_agent: 'Mozilla/5.0',
|
|
151
|
+
utm_source: 'newsletter',
|
|
152
|
+
utm_medium: 'email',
|
|
153
|
+
utm_campaign: 'spring_launch',
|
|
154
|
+
utm_term: 'buyer-intent',
|
|
155
|
+
utm_content: 'cta-button',
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## API
|
|
160
|
+
|
|
161
|
+
### trackPageview()
|
|
162
|
+
|
|
163
|
+
Manually trigger a pageview (sent via `sendBeacon`).
|
|
164
|
+
|
|
165
|
+
### trackTaggedPageview(element)
|
|
166
|
+
|
|
167
|
+
Triggers a pageview for a specific tag element and only once per element.
|
|
168
|
+
|
|
169
|
+
### tagPage(options)
|
|
170
|
+
|
|
171
|
+
Accumulates properties to be attached to the next pageview.
|
|
172
|
+
|
|
173
|
+
### BuyerIntent.TagComponent
|
|
174
|
+
|
|
175
|
+
React component that renders tags into the DOM and triggers a single pageview when it appears.
|
|
176
|
+
|
|
177
|
+
### setBaseProperties(options)
|
|
178
|
+
|
|
179
|
+
Sets base properties like `origin`, `distinctId`, and `userType`.
|
|
180
|
+
|
|
181
|
+
### setActivityEndpoint(url)
|
|
182
|
+
|
|
183
|
+
Updates the endpoint used for event delivery.
|
|
184
|
+
|
|
185
|
+
### setVisitProperties(options)
|
|
186
|
+
|
|
187
|
+
Adds or overrides visit properties to be attached to the next pageview.
|
|
188
|
+
|
|
189
|
+
### trackNextRouter(router)
|
|
190
|
+
|
|
191
|
+
Wires Next.js `Router.events` to trigger pageview events on route changes (emits only when tag elements exist).
|
|
192
|
+
|
|
193
|
+
### createActivityHandler(options)
|
|
194
|
+
|
|
195
|
+
Returns a framework-agnostic handler that validates, enriches, and publishes events to Kafka.
|
|
196
|
+
|
|
197
|
+
### createNextRouteHandler(options)
|
|
198
|
+
|
|
199
|
+
Next.js Route Handler wrapper around `createActivityHandler`.
|
|
200
|
+
|
|
201
|
+
### createDevLoggerProducer(options)
|
|
202
|
+
|
|
203
|
+
Dev-only producer that logs messages instead of sending to Kafka.
|
|
204
|
+
|
|
205
|
+
### topicName(options)
|
|
206
|
+
|
|
207
|
+
Builds a topic name from a `partnerId` and optional `prefix`.
|
|
208
|
+
|
|
209
|
+
## Event Payload
|
|
210
|
+
|
|
211
|
+
The SDK sends the following JSON body to `activityEndpoint`:
|
|
212
|
+
|
|
213
|
+
```json
|
|
214
|
+
{
|
|
215
|
+
"name": "$view",
|
|
216
|
+
"properties": {
|
|
217
|
+
"product_ids": [123],
|
|
218
|
+
"category_ids": [78],
|
|
219
|
+
"tag": "products.show",
|
|
220
|
+
"url": "https://example.com/products/abc",
|
|
221
|
+
"user_type": "standard",
|
|
222
|
+
"distinct_id": "visitor-uuid",
|
|
223
|
+
"origin": "g2.com",
|
|
224
|
+
"source_location": "ProductsController#show",
|
|
225
|
+
"context": {
|
|
226
|
+
"page": "product"
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
"visit": {
|
|
230
|
+
"properties": {
|
|
231
|
+
"landing_page": "https://example.com/products/abc",
|
|
232
|
+
"referrer": "https://example.com/",
|
|
233
|
+
"user_agent": "Mozilla/5.0",
|
|
234
|
+
"ip": "203.0.113.10",
|
|
235
|
+
"utm_source": "newsletter",
|
|
236
|
+
"utm_medium": "email",
|
|
237
|
+
"utm_campaign": "spring_launch",
|
|
238
|
+
"utm_term": "buyer-intent",
|
|
239
|
+
"utm_content": "cta-button"
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
```
|
package/browser/core.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
function uniqueMerge(existing, incoming) {
|
|
2
|
+
const values = new Set([...(existing || []), ...(incoming || [])]);
|
|
3
|
+
return Array.from(values).filter(
|
|
4
|
+
(value) => value !== undefined && value !== null
|
|
5
|
+
);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function compactObject(value) {
|
|
9
|
+
return Object.fromEntries(
|
|
10
|
+
Object.entries(value).filter(([_key, entry]) => entry !== undefined)
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createBuyerIntentCore() {
|
|
15
|
+
const pageState = {
|
|
16
|
+
productIds: [],
|
|
17
|
+
categoryIds: [],
|
|
18
|
+
tag: undefined,
|
|
19
|
+
sourceLocation: undefined,
|
|
20
|
+
context: {}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const baseState = {
|
|
24
|
+
origin: undefined,
|
|
25
|
+
distinctId: undefined,
|
|
26
|
+
userType: 'standard'
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const visitState = {
|
|
30
|
+
properties: {}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function setBaseProperties(options = {}) {
|
|
34
|
+
if (options.origin) {
|
|
35
|
+
baseState.origin = options.origin;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (options.distinctId) {
|
|
39
|
+
baseState.distinctId = options.distinctId;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (options.userType) {
|
|
43
|
+
baseState.userType = options.userType;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function setVisitProperties(options = {}) {
|
|
48
|
+
if (options && typeof options === 'object') {
|
|
49
|
+
visitState.properties = { ...visitState.properties, ...options };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function tagPage(options = {}) {
|
|
54
|
+
pageState.productIds = uniqueMerge(
|
|
55
|
+
pageState.productIds,
|
|
56
|
+
options.productIds || []
|
|
57
|
+
);
|
|
58
|
+
pageState.categoryIds = uniqueMerge(
|
|
59
|
+
pageState.categoryIds,
|
|
60
|
+
options.categoryIds || []
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
if (options.tag) {
|
|
64
|
+
pageState.tag = options.tag;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (options.sourceLocation) {
|
|
68
|
+
pageState.sourceLocation = options.sourceLocation;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (options.context && typeof options.context === 'object') {
|
|
72
|
+
pageState.context = { ...pageState.context, ...options.context };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resetPageState() {
|
|
77
|
+
pageState.productIds = [];
|
|
78
|
+
pageState.categoryIds = [];
|
|
79
|
+
pageState.tag = undefined;
|
|
80
|
+
pageState.sourceLocation = undefined;
|
|
81
|
+
pageState.context = {};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildPayload({
|
|
85
|
+
name,
|
|
86
|
+
url,
|
|
87
|
+
origin,
|
|
88
|
+
distinctId,
|
|
89
|
+
userType,
|
|
90
|
+
visit = {}
|
|
91
|
+
}) {
|
|
92
|
+
const resolvedContext = Object.keys(pageState.context).length
|
|
93
|
+
? pageState.context
|
|
94
|
+
: undefined;
|
|
95
|
+
const properties = compactObject({
|
|
96
|
+
product_ids: pageState.productIds,
|
|
97
|
+
category_ids: pageState.categoryIds,
|
|
98
|
+
tag: pageState.tag,
|
|
99
|
+
url,
|
|
100
|
+
user_type: userType || baseState.userType,
|
|
101
|
+
distinct_id: distinctId || baseState.distinctId,
|
|
102
|
+
origin: origin || baseState.origin,
|
|
103
|
+
source_location: pageState.sourceLocation,
|
|
104
|
+
context: resolvedContext
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const visitProperties = compactObject({
|
|
108
|
+
landing_page: visit.landingPage,
|
|
109
|
+
referrer: visit.referrer,
|
|
110
|
+
user_agent: visit.userAgent,
|
|
111
|
+
utm_source: visit.utmParams?.utm_source,
|
|
112
|
+
utm_medium: visit.utmParams?.utm_medium,
|
|
113
|
+
utm_campaign: visit.utmParams?.utm_campaign,
|
|
114
|
+
utm_term: visit.utmParams?.utm_term,
|
|
115
|
+
utm_content: visit.utmParams?.utm_content,
|
|
116
|
+
...visitState.properties
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return compactObject({
|
|
120
|
+
name,
|
|
121
|
+
properties,
|
|
122
|
+
visit: Object.keys(visitProperties).length
|
|
123
|
+
? { properties: visitProperties }
|
|
124
|
+
: undefined
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
setBaseProperties,
|
|
130
|
+
setVisitProperties,
|
|
131
|
+
tagPage,
|
|
132
|
+
resetPageState,
|
|
133
|
+
buildPayload
|
|
134
|
+
};
|
|
135
|
+
}
|
package/browser/index.js
ADDED
package/browser/sdk.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { createBuyerIntentCore } from './core.js';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_ACTIVITY_ENDPOINT = '/activity/events';
|
|
4
|
+
const DEFAULT_PAGEVIEW_NAME = '$view';
|
|
5
|
+
const DISTINCT_ID_STORAGE_KEY = 'buyer_intent_distinct_id';
|
|
6
|
+
|
|
7
|
+
const firedTagElements = new WeakSet();
|
|
8
|
+
|
|
9
|
+
function generateId() {
|
|
10
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
11
|
+
return crypto.randomUUID();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return `bi-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readStoredDistinctId() {
|
|
18
|
+
if (typeof window === 'undefined') {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
return window.localStorage.getItem(DISTINCT_ID_STORAGE_KEY);
|
|
24
|
+
} catch (_error) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function storeDistinctId(value) {
|
|
30
|
+
if (typeof window === 'undefined') {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
window.localStorage.setItem(DISTINCT_ID_STORAGE_KEY, value);
|
|
36
|
+
} catch (_error) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sendWithBeacon(url, payload) {
|
|
42
|
+
if (
|
|
43
|
+
typeof navigator !== 'undefined' &&
|
|
44
|
+
typeof navigator.sendBeacon === 'function'
|
|
45
|
+
) {
|
|
46
|
+
const blob = new Blob([JSON.stringify(payload)], {
|
|
47
|
+
type: 'application/json'
|
|
48
|
+
});
|
|
49
|
+
const sent = navigator.sendBeacon(url, blob);
|
|
50
|
+
if (sent) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (typeof fetch !== 'undefined') {
|
|
56
|
+
fetch(url, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'Content-Type': 'application/json' },
|
|
59
|
+
body: JSON.stringify(payload),
|
|
60
|
+
keepalive: true
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseJson(value) {
|
|
68
|
+
if (!value) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(value);
|
|
74
|
+
} catch (_error) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseArray(value) {
|
|
80
|
+
if (!value) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const parsed = parseJson(value);
|
|
85
|
+
if (Array.isArray(parsed)) {
|
|
86
|
+
return parsed;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (typeof value === 'string') {
|
|
90
|
+
return value
|
|
91
|
+
.split(',')
|
|
92
|
+
.map((entry) => entry.trim())
|
|
93
|
+
.filter(Boolean);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createBuyerIntentSDK() {
|
|
100
|
+
const core = createBuyerIntentCore();
|
|
101
|
+
let activityEndpoint = DEFAULT_ACTIVITY_ENDPOINT;
|
|
102
|
+
let origin = null;
|
|
103
|
+
let pageviewName = DEFAULT_PAGEVIEW_NAME;
|
|
104
|
+
let distinctId = null;
|
|
105
|
+
let userType = 'standard';
|
|
106
|
+
let lastPageKey = null;
|
|
107
|
+
let initialLandingPage = null;
|
|
108
|
+
let initialReferrer = null;
|
|
109
|
+
let initialUtmParams = null;
|
|
110
|
+
let observerStarted = false;
|
|
111
|
+
|
|
112
|
+
function ensureDistinctId() {
|
|
113
|
+
if (distinctId) {
|
|
114
|
+
return distinctId;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const stored = readStoredDistinctId();
|
|
118
|
+
if (stored) {
|
|
119
|
+
distinctId = stored;
|
|
120
|
+
return stored;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const generated = generateId();
|
|
124
|
+
distinctId = generated;
|
|
125
|
+
storeDistinctId(generated);
|
|
126
|
+
return generated;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readUtmParams(urlValue) {
|
|
130
|
+
if (!urlValue) {
|
|
131
|
+
return {};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const url = new URL(urlValue, window.location.origin);
|
|
135
|
+
const params = url.searchParams;
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
utm_source: params.get('utm_source') || undefined,
|
|
139
|
+
utm_medium: params.get('utm_medium') || undefined,
|
|
140
|
+
utm_campaign: params.get('utm_campaign') || undefined,
|
|
141
|
+
utm_term: params.get('utm_term') || undefined,
|
|
142
|
+
utm_content: params.get('utm_content') || undefined
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildVisitContext() {
|
|
147
|
+
if (typeof window === 'undefined') {
|
|
148
|
+
return {};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const landingPage = initialLandingPage || window.location.href;
|
|
152
|
+
return {
|
|
153
|
+
landingPage,
|
|
154
|
+
referrer: initialReferrer || document.referrer || undefined,
|
|
155
|
+
userAgent:
|
|
156
|
+
typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
|
|
157
|
+
utmParams: initialUtmParams || readUtmParams(landingPage)
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readDomTagElement(element) {
|
|
162
|
+
if (!element) {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (element.tagName === 'SCRIPT') {
|
|
167
|
+
const parsed =
|
|
168
|
+
parseJson(element.textContent || '') ||
|
|
169
|
+
parseJson(element.getAttribute('data-buyer-intent'));
|
|
170
|
+
if (parsed && typeof parsed === 'object') {
|
|
171
|
+
return parsed;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const dataJson = parseJson(element.getAttribute('data-buyer-intent'));
|
|
176
|
+
if (dataJson && typeof dataJson === 'object') {
|
|
177
|
+
return dataJson;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const dataset = element.dataset || {};
|
|
181
|
+
return {
|
|
182
|
+
productIds: parseArray(dataset.productIds),
|
|
183
|
+
categoryIds: parseArray(dataset.categoryIds),
|
|
184
|
+
tag: dataset.tag,
|
|
185
|
+
sourceLocation: dataset.sourceLocation,
|
|
186
|
+
context: parseJson(dataset.context)
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function readDomTags(elements) {
|
|
191
|
+
if (typeof document === 'undefined') {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const found = elements || new Set();
|
|
196
|
+
const legacy = document.getElementById('buyer-intent-tags');
|
|
197
|
+
if (legacy) {
|
|
198
|
+
found.add(legacy);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
document.querySelectorAll('[data-buyer-intent]').forEach((element) => {
|
|
202
|
+
found.add(element);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
return Array.from(found)
|
|
206
|
+
.map((element) => ({ element, tag: readDomTagElement(element) }))
|
|
207
|
+
.filter((entry) => entry.tag && typeof entry.tag === 'object');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function applyElementConfig(element) {
|
|
211
|
+
if (!element || !element.dataset) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const {
|
|
216
|
+
origin: originValue,
|
|
217
|
+
userType: userTypeValue,
|
|
218
|
+
distinctId: distinctIdValue,
|
|
219
|
+
activityEndpoint: endpointValue
|
|
220
|
+
} = element.dataset;
|
|
221
|
+
|
|
222
|
+
if (endpointValue) {
|
|
223
|
+
setActivityEndpoint(endpointValue);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (originValue || userTypeValue || distinctIdValue) {
|
|
227
|
+
setBaseProperties({
|
|
228
|
+
origin: originValue,
|
|
229
|
+
userType: userTypeValue,
|
|
230
|
+
distinctId: distinctIdValue
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function applyDomTags(element) {
|
|
236
|
+
const elements = element ? new Set([element]) : undefined;
|
|
237
|
+
const entries = readDomTags(elements);
|
|
238
|
+
entries.forEach(({ element: tagElement, tag }) => {
|
|
239
|
+
applyElementConfig(tagElement);
|
|
240
|
+
core.tagPage(tag);
|
|
241
|
+
});
|
|
242
|
+
return entries.length > 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function trackPageview(element) {
|
|
246
|
+
if (typeof window === 'undefined') {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const pageKey = `${window.location.pathname}${window.location.search}`;
|
|
251
|
+
if (pageKey === lastPageKey) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
lastPageKey = pageKey;
|
|
256
|
+
const hasTags = applyDomTags(element);
|
|
257
|
+
if (!hasTags) {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
const payload = core.buildPayload({
|
|
261
|
+
name: pageviewName,
|
|
262
|
+
url: window.location.href,
|
|
263
|
+
origin: origin || window.location.hostname,
|
|
264
|
+
distinctId: ensureDistinctId(),
|
|
265
|
+
userType,
|
|
266
|
+
visit: buildVisitContext()
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const sent = sendWithBeacon(activityEndpoint, payload);
|
|
270
|
+
core.resetPageState();
|
|
271
|
+
return sent;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function init(options = {}) {
|
|
275
|
+
activityEndpoint = options.activityEndpoint || activityEndpoint;
|
|
276
|
+
origin = options.origin || origin;
|
|
277
|
+
pageviewName = options.pageviewName || pageviewName;
|
|
278
|
+
distinctId = options.distinctId || distinctId;
|
|
279
|
+
userType = options.userType || userType;
|
|
280
|
+
|
|
281
|
+
if (typeof window !== 'undefined' && !initialLandingPage) {
|
|
282
|
+
initialLandingPage = window.location.href;
|
|
283
|
+
initialReferrer = document.referrer || undefined;
|
|
284
|
+
initialUtmParams = readUtmParams(initialLandingPage);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
core.setBaseProperties({
|
|
288
|
+
origin: origin || undefined,
|
|
289
|
+
distinctId: distinctId || undefined,
|
|
290
|
+
userType
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function setActivityEndpoint(value) {
|
|
295
|
+
activityEndpoint = value || activityEndpoint;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function setBaseProperties(options = {}) {
|
|
299
|
+
if (options.origin) {
|
|
300
|
+
origin = options.origin;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (options.distinctId) {
|
|
304
|
+
distinctId = options.distinctId;
|
|
305
|
+
storeDistinctId(options.distinctId);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (options.userType) {
|
|
309
|
+
userType = options.userType;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
core.setBaseProperties({
|
|
313
|
+
origin: origin || undefined,
|
|
314
|
+
distinctId: distinctId || undefined,
|
|
315
|
+
userType
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function tagPage(options = {}) {
|
|
320
|
+
core.tagPage(options);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function setVisitProperties(options = {}) {
|
|
324
|
+
core.setVisitProperties(options);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function trackNextRouter(nextRouter) {
|
|
328
|
+
if (!nextRouter || !nextRouter.events) {
|
|
329
|
+
return () => {};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const handler = () => trackPageview();
|
|
333
|
+
nextRouter.events.on('routeChangeComplete', handler);
|
|
334
|
+
return () => nextRouter.events.off('routeChangeComplete', handler);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function trackTaggedPageview(element) {
|
|
338
|
+
if (!element || typeof element !== 'object') {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (firedTagElements.has(element)) {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
firedTagElements.add(element);
|
|
347
|
+
return trackPageview(element);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function observeTagElements() {
|
|
351
|
+
if (
|
|
352
|
+
observerStarted ||
|
|
353
|
+
typeof document === 'undefined' ||
|
|
354
|
+
typeof MutationObserver === 'undefined'
|
|
355
|
+
) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
observerStarted = true;
|
|
360
|
+
|
|
361
|
+
const handleElement = (element) => {
|
|
362
|
+
if (
|
|
363
|
+
element &&
|
|
364
|
+
element.matches &&
|
|
365
|
+
element.matches('buyer-intent-tag,[data-buyer-intent]')
|
|
366
|
+
) {
|
|
367
|
+
trackTaggedPageview(element);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (element && element.querySelectorAll) {
|
|
371
|
+
element
|
|
372
|
+
.querySelectorAll('buyer-intent-tag,[data-buyer-intent]')
|
|
373
|
+
.forEach((nested) => trackTaggedPageview(nested));
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const observer = new MutationObserver((mutations) => {
|
|
378
|
+
mutations.forEach((mutation) => {
|
|
379
|
+
mutation.addedNodes.forEach((node) => {
|
|
380
|
+
if (node.nodeType === 1) {
|
|
381
|
+
handleElement(node);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
observer.observe(document.documentElement || document.body, {
|
|
388
|
+
childList: true,
|
|
389
|
+
subtree: true
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
document
|
|
393
|
+
.querySelectorAll('buyer-intent-tag,[data-buyer-intent]')
|
|
394
|
+
.forEach((element) => trackTaggedPageview(element));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
observeTagElements();
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
init,
|
|
401
|
+
trackPageview,
|
|
402
|
+
trackTaggedPageview,
|
|
403
|
+
tagPage,
|
|
404
|
+
setBaseProperties,
|
|
405
|
+
setVisitProperties,
|
|
406
|
+
setActivityEndpoint,
|
|
407
|
+
trackNextRouter
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export const buyerIntent = createBuyerIntentSDK();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export function TagComponent({
|
|
4
|
+
productIds,
|
|
5
|
+
categoryIds,
|
|
6
|
+
tag,
|
|
7
|
+
sourceLocation,
|
|
8
|
+
context,
|
|
9
|
+
origin,
|
|
10
|
+
activityEndpoint,
|
|
11
|
+
userType,
|
|
12
|
+
distinctId,
|
|
13
|
+
children
|
|
14
|
+
}) {
|
|
15
|
+
const payload = {
|
|
16
|
+
productIds,
|
|
17
|
+
categoryIds,
|
|
18
|
+
tag,
|
|
19
|
+
sourceLocation,
|
|
20
|
+
context
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return React.createElement(
|
|
24
|
+
'buyer-intent-tag',
|
|
25
|
+
{
|
|
26
|
+
'data-buyer-intent': JSON.stringify(payload),
|
|
27
|
+
'data-origin': origin,
|
|
28
|
+
'data-activity-endpoint': activityEndpoint,
|
|
29
|
+
'data-user-type': userType,
|
|
30
|
+
'data-distinct-id': distinctId
|
|
31
|
+
},
|
|
32
|
+
children
|
|
33
|
+
);
|
|
34
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type * as React from 'react';
|
|
2
|
+
|
|
3
|
+
export type BuyerIntentInitOptions = {
|
|
4
|
+
activityEndpoint?: string;
|
|
5
|
+
origin?: string;
|
|
6
|
+
pageviewName?: string;
|
|
7
|
+
autoPageview?: boolean;
|
|
8
|
+
distinctId?: string;
|
|
9
|
+
userType?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type BuyerIntentTagOptions = {
|
|
13
|
+
productIds?: number[];
|
|
14
|
+
categoryIds?: number[];
|
|
15
|
+
tag?: string;
|
|
16
|
+
sourceLocation?: string;
|
|
17
|
+
context?: Record<string, unknown>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type BuyerIntentBaseOptions = {
|
|
21
|
+
origin?: string;
|
|
22
|
+
distinctId?: string;
|
|
23
|
+
userType?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type BuyerIntentVisitProperties = {
|
|
27
|
+
ip?: string;
|
|
28
|
+
referrer?: string;
|
|
29
|
+
landing_page?: string;
|
|
30
|
+
user_agent?: string;
|
|
31
|
+
utm_source?: string;
|
|
32
|
+
utm_medium?: string;
|
|
33
|
+
utm_campaign?: string;
|
|
34
|
+
utm_term?: string;
|
|
35
|
+
utm_content?: string;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type BuyerIntentSDK = {
|
|
40
|
+
init: (options?: BuyerIntentInitOptions) => void;
|
|
41
|
+
trackPageview: () => boolean;
|
|
42
|
+
trackTaggedPageview: (element: Element) => boolean;
|
|
43
|
+
tagPage: (options?: BuyerIntentTagOptions) => void;
|
|
44
|
+
setBaseProperties: (options?: BuyerIntentBaseOptions) => void;
|
|
45
|
+
setVisitProperties: (options?: BuyerIntentVisitProperties) => void;
|
|
46
|
+
setActivityEndpoint: (endpoint: string) => void;
|
|
47
|
+
trackNextRouter: (nextRouter: {
|
|
48
|
+
events?: {
|
|
49
|
+
on: (event: string, handler: () => void) => void;
|
|
50
|
+
off: (event: string, handler: () => void) => void;
|
|
51
|
+
};
|
|
52
|
+
}) => () => void;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function createBuyerIntentSDK(): BuyerIntentSDK;
|
|
56
|
+
export const buyerIntent: BuyerIntentSDK;
|
|
57
|
+
|
|
58
|
+
export type TagComponentProps = {
|
|
59
|
+
productIds?: number[];
|
|
60
|
+
categoryIds?: number[];
|
|
61
|
+
tag?: string;
|
|
62
|
+
sourceLocation?: string;
|
|
63
|
+
context?: Record<string, unknown>;
|
|
64
|
+
origin?: string;
|
|
65
|
+
activityEndpoint?: string;
|
|
66
|
+
userType?: string;
|
|
67
|
+
distinctId?: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const TagComponent: React.FC<TagComponentProps>;
|
|
71
|
+
|
|
72
|
+
export const BuyerIntent: {
|
|
73
|
+
TagComponent: React.FC<TagComponentProps>;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type ActivityHandlerOptions = {
|
|
77
|
+
topic?: string;
|
|
78
|
+
partnerId?: string;
|
|
79
|
+
topicPrefix?: string;
|
|
80
|
+
kafkaBrokers?: string[];
|
|
81
|
+
kafkaClientId?: string;
|
|
82
|
+
producer?: {
|
|
83
|
+
send: (payload: {
|
|
84
|
+
topic: string;
|
|
85
|
+
messages: Array<{ value: string }>;
|
|
86
|
+
}) => Promise<unknown>;
|
|
87
|
+
};
|
|
88
|
+
nowFn?: () => string;
|
|
89
|
+
uuidFn?: () => string;
|
|
90
|
+
getIp?: (args: {
|
|
91
|
+
headers?: Record<string, string> | Headers;
|
|
92
|
+
request?: { ip?: string };
|
|
93
|
+
}) => string | undefined;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type ActivityHandlerResponse = {
|
|
97
|
+
status: number;
|
|
98
|
+
body: { ok: boolean; error?: string };
|
|
99
|
+
setCookies: Array<{
|
|
100
|
+
name: string;
|
|
101
|
+
value: string;
|
|
102
|
+
options: { httpOnly: boolean; sameSite: string };
|
|
103
|
+
}>;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export function createActivityHandler(
|
|
107
|
+
options?: ActivityHandlerOptions
|
|
108
|
+
): (args: {
|
|
109
|
+
body: {
|
|
110
|
+
name: string;
|
|
111
|
+
properties?: Record<string, unknown>;
|
|
112
|
+
visit?: { properties?: Record<string, unknown> };
|
|
113
|
+
};
|
|
114
|
+
headers?: Record<string, string> | Headers;
|
|
115
|
+
cookies?:
|
|
116
|
+
| Record<string, string>
|
|
117
|
+
| { get: (name: string) => { value?: string } | string | undefined };
|
|
118
|
+
request?: { ip?: string };
|
|
119
|
+
}) => Promise<ActivityHandlerResponse>;
|
|
120
|
+
|
|
121
|
+
export function createNextRouteHandler(
|
|
122
|
+
options?: ActivityHandlerOptions
|
|
123
|
+
): (req: {
|
|
124
|
+
json: () => Promise<{
|
|
125
|
+
name: string;
|
|
126
|
+
properties?: Record<string, unknown>;
|
|
127
|
+
visit?: { properties?: Record<string, unknown> };
|
|
128
|
+
}>;
|
|
129
|
+
headers: Headers;
|
|
130
|
+
cookies: { get: (name: string) => { value?: string } | string | undefined };
|
|
131
|
+
}) => Promise<unknown>;
|
|
132
|
+
|
|
133
|
+
export function createDevLoggerProducer(options?: {
|
|
134
|
+
log?: (message: string, payload: { topic: string; value: string }) => void;
|
|
135
|
+
}): {
|
|
136
|
+
send: (payload: {
|
|
137
|
+
topic: string;
|
|
138
|
+
messages: Array<{ value: string }>;
|
|
139
|
+
}) => Promise<unknown>;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export function topicName(options?: {
|
|
143
|
+
partnerId?: string;
|
|
144
|
+
prefix?: string;
|
|
145
|
+
}): string | undefined;
|
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@g2crowd/buyer-intent-provider-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Buyer intent tracking SDK with pageview defaults",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./server": "./server/index.js"
|
|
10
|
+
},
|
|
11
|
+
"types": "index.d.ts",
|
|
12
|
+
"files": [
|
|
13
|
+
"index.js",
|
|
14
|
+
"index.d.ts",
|
|
15
|
+
"browser",
|
|
16
|
+
"server.js",
|
|
17
|
+
"server",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --experimental-vm-modules ./node_modules/.bin/jest --config ./jest.config.cjs"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"buyer-intent-server": "server.js"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/g2crowd/buyer-intent-provider-js"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"kafkajs": "^2.2.4",
|
|
36
|
+
"react": ">=17"
|
|
37
|
+
},
|
|
38
|
+
"sideEffects": false,
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@testing-library/dom": "^9.3.4",
|
|
41
|
+
"@testing-library/react": "^14.3.1",
|
|
42
|
+
"jest": "^30.2.0",
|
|
43
|
+
"jest-environment-jsdom": "^30.2.0",
|
|
44
|
+
"react": "^18.2.0",
|
|
45
|
+
"react-dom": "^18.2.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
function compactObject(value) {
|
|
2
|
+
return Object.fromEntries(
|
|
3
|
+
Object.entries(value).filter(([_key, entry]) => entry !== undefined)
|
|
4
|
+
);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function generateId() {
|
|
8
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
9
|
+
return crypto.randomUUID();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return `bi-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseUtmParams(urlValue, originFallback) {
|
|
16
|
+
if (!urlValue) {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const url = new URL(urlValue, originFallback || 'https://example.com');
|
|
21
|
+
const params = url.searchParams;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
utm_source: params.get('utm_source') || undefined,
|
|
25
|
+
utm_medium: params.get('utm_medium') || undefined,
|
|
26
|
+
utm_campaign: params.get('utm_campaign') || undefined,
|
|
27
|
+
utm_term: params.get('utm_term') || undefined,
|
|
28
|
+
utm_content: params.get('utm_content') || undefined
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readHeader(headers, name) {
|
|
33
|
+
if (!headers) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (typeof headers.get === 'function') {
|
|
38
|
+
return headers.get(name);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const key = name.toLowerCase();
|
|
42
|
+
return headers[key];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readCookie(cookies, name) {
|
|
46
|
+
if (!cookies) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (typeof cookies.get === 'function') {
|
|
51
|
+
const value = cookies.get(name);
|
|
52
|
+
if (value && typeof value === 'object') {
|
|
53
|
+
return value.value;
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return cookies[name];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function defaultGetIp({ headers, request }) {
|
|
62
|
+
const forwarded = readHeader(headers, 'x-forwarded-for');
|
|
63
|
+
if (forwarded) {
|
|
64
|
+
return forwarded.split(',')[0].trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (request && request.ip) {
|
|
68
|
+
return request.ip;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ensureProducer({ producer, kafkaBrokers, kafkaClientId }) {
|
|
75
|
+
if (producer) {
|
|
76
|
+
return producer;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!kafkaBrokers || !kafkaBrokers.length) {
|
|
80
|
+
throw new Error('kafkaBrokers is required when no producer is provided');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
async connect() {
|
|
85
|
+
if (this._producer) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const module = await import('kafkajs');
|
|
89
|
+
const kafka = new module.Kafka({
|
|
90
|
+
clientId: kafkaClientId || 'buyer-intent-sdk',
|
|
91
|
+
brokers: kafkaBrokers
|
|
92
|
+
});
|
|
93
|
+
this._producer = kafka.producer();
|
|
94
|
+
await this._producer.connect();
|
|
95
|
+
},
|
|
96
|
+
async send(payload) {
|
|
97
|
+
await this.connect();
|
|
98
|
+
return this._producer.send(payload);
|
|
99
|
+
},
|
|
100
|
+
async disconnect() {
|
|
101
|
+
if (this._producer) {
|
|
102
|
+
await this._producer.disconnect();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildCompositeEvent({
|
|
109
|
+
body,
|
|
110
|
+
now,
|
|
111
|
+
eventId,
|
|
112
|
+
visitTokens,
|
|
113
|
+
visitProperties
|
|
114
|
+
}) {
|
|
115
|
+
return {
|
|
116
|
+
event: {
|
|
117
|
+
id: eventId,
|
|
118
|
+
name: body.name,
|
|
119
|
+
time: now,
|
|
120
|
+
properties: body.properties || {}
|
|
121
|
+
},
|
|
122
|
+
visit: {
|
|
123
|
+
visit_token: visitTokens.visitToken,
|
|
124
|
+
visitor_token: visitTokens.visitorToken,
|
|
125
|
+
started_at: now,
|
|
126
|
+
created_at: now,
|
|
127
|
+
properties: visitProperties
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function topicName({ partnerId, prefix } = {}) {
|
|
133
|
+
if (!partnerId) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const safePartnerId = String(partnerId)
|
|
138
|
+
.toLowerCase()
|
|
139
|
+
.replace(/[^a-z0-9-_]/g, '-');
|
|
140
|
+
const safePrefix = prefix
|
|
141
|
+
? String(prefix).replace(/[^a-z0-9-_]/gi, '')
|
|
142
|
+
: 'intent_events_';
|
|
143
|
+
return `${safePrefix}${safePartnerId}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function createActivityHandler(options = {}) {
|
|
147
|
+
const topic =
|
|
148
|
+
options.topic ||
|
|
149
|
+
topicName({
|
|
150
|
+
partnerId: options.partnerId,
|
|
151
|
+
prefix: options.topicPrefix
|
|
152
|
+
}) ||
|
|
153
|
+
'enduser_events';
|
|
154
|
+
const nowFn = options.nowFn || (() => new Date().toISOString());
|
|
155
|
+
const uuidFn = options.uuidFn || generateId;
|
|
156
|
+
const getIp = options.getIp || defaultGetIp;
|
|
157
|
+
const producer = ensureProducer({
|
|
158
|
+
producer: options.producer,
|
|
159
|
+
kafkaBrokers: options.kafkaBrokers,
|
|
160
|
+
kafkaClientId: options.kafkaClientId
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return async function handle({ body, headers, cookies, request }) {
|
|
164
|
+
if (!body || !body.name) {
|
|
165
|
+
return {
|
|
166
|
+
status: 400,
|
|
167
|
+
body: { ok: false, error: 'invalid_event' },
|
|
168
|
+
setCookies: []
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const visitToken = readCookie(cookies, 'visit_token') || uuidFn();
|
|
173
|
+
const visitorToken = readCookie(cookies, 'visitor_token') || uuidFn();
|
|
174
|
+
const now = nowFn();
|
|
175
|
+
|
|
176
|
+
const incomingVisitProps = (body.visit && body.visit.properties) || {};
|
|
177
|
+
const landingPage =
|
|
178
|
+
incomingVisitProps.landing_page ||
|
|
179
|
+
(body.properties && body.properties.url) ||
|
|
180
|
+
readHeader(headers, 'referer');
|
|
181
|
+
const referrer =
|
|
182
|
+
incomingVisitProps.referrer ||
|
|
183
|
+
readHeader(headers, 'referer') ||
|
|
184
|
+
undefined;
|
|
185
|
+
const userAgent =
|
|
186
|
+
incomingVisitProps.user_agent ||
|
|
187
|
+
readHeader(headers, 'user-agent') ||
|
|
188
|
+
undefined;
|
|
189
|
+
const utmParams =
|
|
190
|
+
parseUtmParams(landingPage, body.properties && body.properties.url) || {};
|
|
191
|
+
|
|
192
|
+
const visitProperties = compactObject({
|
|
193
|
+
landing_page: landingPage,
|
|
194
|
+
referrer,
|
|
195
|
+
user_agent: userAgent,
|
|
196
|
+
ip: incomingVisitProps.ip || getIp({ headers, request }),
|
|
197
|
+
utm_source: incomingVisitProps.utm_source || utmParams.utm_source,
|
|
198
|
+
utm_medium: incomingVisitProps.utm_medium || utmParams.utm_medium,
|
|
199
|
+
utm_campaign: incomingVisitProps.utm_campaign || utmParams.utm_campaign,
|
|
200
|
+
utm_term: incomingVisitProps.utm_term || utmParams.utm_term,
|
|
201
|
+
utm_content: incomingVisitProps.utm_content || utmParams.utm_content,
|
|
202
|
+
...incomingVisitProps
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const compositeEvent = buildCompositeEvent({
|
|
206
|
+
body,
|
|
207
|
+
now,
|
|
208
|
+
eventId: uuidFn(),
|
|
209
|
+
visitTokens: { visitToken, visitorToken },
|
|
210
|
+
visitProperties
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await producer.send({
|
|
214
|
+
topic,
|
|
215
|
+
messages: [{ value: JSON.stringify(compositeEvent) }]
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
status: 200,
|
|
220
|
+
body: { ok: true },
|
|
221
|
+
setCookies: [
|
|
222
|
+
{
|
|
223
|
+
name: 'visit_token',
|
|
224
|
+
value: visitToken,
|
|
225
|
+
options: { httpOnly: true, sameSite: 'lax' }
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: 'visitor_token',
|
|
229
|
+
value: visitorToken,
|
|
230
|
+
options: { httpOnly: true, sameSite: 'lax' }
|
|
231
|
+
}
|
|
232
|
+
]
|
|
233
|
+
};
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function createNextRouteHandler(options = {}) {
|
|
238
|
+
const handler = createActivityHandler(options);
|
|
239
|
+
|
|
240
|
+
return async function POST(req) {
|
|
241
|
+
const body = await req.json();
|
|
242
|
+
const result = await handler({
|
|
243
|
+
body,
|
|
244
|
+
headers: req.headers,
|
|
245
|
+
cookies: req.cookies,
|
|
246
|
+
request: req
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const { NextResponse } = await import('next/server');
|
|
250
|
+
const response = NextResponse.json(result.body, { status: result.status });
|
|
251
|
+
|
|
252
|
+
result.setCookies.forEach((cookie) => {
|
|
253
|
+
response.cookies.set(cookie.name, cookie.value, cookie.options);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return response;
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function createDevLoggerProducer(options = {}) {
|
|
261
|
+
const log = options.log || console.log;
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
async send(payload) {
|
|
265
|
+
const message =
|
|
266
|
+
payload.messages && payload.messages[0] && payload.messages[0].value;
|
|
267
|
+
log('[buyer-intent-sdk] kafka message', {
|
|
268
|
+
topic: payload.topic,
|
|
269
|
+
value: message
|
|
270
|
+
});
|
|
271
|
+
return Promise.resolve();
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
}
|
package/server/index.js
ADDED
package/server.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import {
|
|
4
|
+
createActivityHandler,
|
|
5
|
+
createDevLoggerProducer
|
|
6
|
+
} from './server/handlers.js';
|
|
7
|
+
|
|
8
|
+
function parseCookies(headerValue) {
|
|
9
|
+
if (!headerValue) {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return headerValue.split(';').reduce((acc, pair) => {
|
|
14
|
+
const [rawKey, ...rawValue] = pair.trim().split('=');
|
|
15
|
+
if (!rawKey) {
|
|
16
|
+
return acc;
|
|
17
|
+
}
|
|
18
|
+
acc[rawKey] = decodeURIComponent(rawValue.join('=') || '');
|
|
19
|
+
return acc;
|
|
20
|
+
}, {});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readRequestBody(req) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
let data = '';
|
|
26
|
+
req.on('data', (chunk) => {
|
|
27
|
+
data += chunk;
|
|
28
|
+
});
|
|
29
|
+
req.on('end', () => resolve(data));
|
|
30
|
+
req.on('error', reject);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildHandlerOptions() {
|
|
35
|
+
const brokers = process.env.KAFKA_BROKERS
|
|
36
|
+
? process.env.KAFKA_BROKERS.split(',')
|
|
37
|
+
.map((value) => value.trim())
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
: undefined;
|
|
40
|
+
const useDevLogger = process.env.USE_DEV_LOGGER === 'true';
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
kafkaBrokers: brokers,
|
|
44
|
+
kafkaClientId: process.env.KAFKA_CLIENT_ID,
|
|
45
|
+
partnerId: process.env.PARTNER_ID,
|
|
46
|
+
topicPrefix: process.env.TOPIC_PREFIX,
|
|
47
|
+
topic: process.env.KAFKA_TOPIC,
|
|
48
|
+
producer: useDevLogger ? createDevLoggerProducer() : undefined
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function handleRequest(req, res, handler) {
|
|
53
|
+
if (req.method !== 'POST' || req.url !== '/activity/events') {
|
|
54
|
+
res.statusCode = 404;
|
|
55
|
+
res.end();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let body;
|
|
60
|
+
try {
|
|
61
|
+
const rawBody = await readRequestBody(req);
|
|
62
|
+
body = rawBody ? JSON.parse(rawBody) : {};
|
|
63
|
+
} catch (_error) {
|
|
64
|
+
res.statusCode = 400;
|
|
65
|
+
res.setHeader('Content-Type', 'application/json');
|
|
66
|
+
res.end(JSON.stringify({ ok: false, error: 'invalid_json' }));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const result = await handler({
|
|
72
|
+
body,
|
|
73
|
+
headers: req.headers,
|
|
74
|
+
cookies: parseCookies(req.headers.cookie),
|
|
75
|
+
request: req
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const cookieHeaders = result.setCookies.map((cookie) => {
|
|
79
|
+
const base = `${cookie.name}=${encodeURIComponent(
|
|
80
|
+
cookie.value
|
|
81
|
+
)}; Path=/; SameSite=Lax`;
|
|
82
|
+
return cookie.options.httpOnly ? `${base}; HttpOnly` : base;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
res.statusCode = result.status;
|
|
86
|
+
res.setHeader('Content-Type', 'application/json');
|
|
87
|
+
if (cookieHeaders.length) {
|
|
88
|
+
res.setHeader('Set-Cookie', cookieHeaders);
|
|
89
|
+
}
|
|
90
|
+
res.end(JSON.stringify(result.body));
|
|
91
|
+
} catch (_error) {
|
|
92
|
+
res.statusCode = 500;
|
|
93
|
+
res.setHeader('Content-Type', 'application/json');
|
|
94
|
+
res.end(JSON.stringify({ ok: false, error: 'server_error' }));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function startServer() {
|
|
99
|
+
const handler = createActivityHandler(buildHandlerOptions());
|
|
100
|
+
const port = Number(process.env.PORT || 3000);
|
|
101
|
+
|
|
102
|
+
const server = http.createServer((req, res) => {
|
|
103
|
+
handleRequest(req, res, handler);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
server.listen(port);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const isMain = process.argv[1] === fileURLToPath(import.meta.url);
|
|
110
|
+
|
|
111
|
+
if (isMain) {
|
|
112
|
+
startServer();
|
|
113
|
+
}
|