@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 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
+ ```
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ import { TagComponent } from './tag_component.js';
2
+
3
+ export { createBuyerIntentSDK, buyerIntent } from './sdk.js';
4
+ export { TagComponent };
5
+
6
+ export const BuyerIntent = { TagComponent };
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
@@ -0,0 +1,6 @@
1
+ export {
2
+ buyerIntent,
3
+ createBuyerIntentSDK,
4
+ BuyerIntent,
5
+ TagComponent
6
+ } from './browser/index.js';
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
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ createActivityHandler,
3
+ createNextRouteHandler,
4
+ createDevLoggerProducer,
5
+ topicName
6
+ } from './handlers.js';
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
+ }