react-marketing-tools 1.0.0-beta.3 → 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 +296 -54
- package/dist/attribution/attribution.d.ts +11 -4
- package/dist/chunks/core.js +95 -81
- package/dist/core/types.d.ts +11 -1
- package/dist/core.d.ts +1 -1
- package/dist/server/aiCrawlers.d.ts +37 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +106 -83
- package/package.json +11 -3
package/README.md
CHANGED
|
@@ -1,146 +1,383 @@
|
|
|
1
1
|
# React Marketing Tools
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/react-marketing-tools)
|
|
4
4
|
[](./LICENSE)
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
>
|
|
14
|
-
>
|
|
6
|
+
Send one event from your React app and it reaches Google Tag Manager, Google Analytics 4 and the Meta Pixel at once.
|
|
7
|
+
The library keeps the visitor's privacy choices, remembers which campaign brought them, strips personal data out of
|
|
8
|
+
events, and can send the same events again from your server so they still arrive when a browser blocks tracking.
|
|
9
|
+
|
|
10
|
+
**[Try it in the playground](https://bronz3beard.github.io/react-marketing-tools/)**: press a button and see exactly
|
|
11
|
+
what each of those services would receive. Nothing is sent anywhere.
|
|
12
|
+
|
|
13
|
+
> **Setting this up with an AI assistant?** There's a prompt written for that:
|
|
14
|
+
> [Set this up with an AI assistant](#set-this-up-with-an-ai-assistant). It interviews you, writes the wiring, and
|
|
15
|
+
> tells you what to click in each service.
|
|
16
|
+
|
|
17
|
+
> **Coming from 0.4?** 1.0 is a rewrite, and the API is different. The
|
|
18
|
+
> [migration guide](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/migration-v1.md) maps the old
|
|
19
|
+
> names to the new ones, and the
|
|
20
|
+
> [changelog](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/CHANGELOG.md) lists everything that
|
|
21
|
+
> changed.
|
|
22
|
+
|
|
23
|
+
## Contents
|
|
24
|
+
|
|
25
|
+
- [What you need before you start](#what-you-need-before-you-start)
|
|
26
|
+
- [Install](#install)
|
|
27
|
+
- [Set this up with an AI assistant](#set-this-up-with-an-ai-assistant)
|
|
28
|
+
- [Quick start](#quick-start)
|
|
29
|
+
- [1. Create the analytics instance](#1-create-the-analytics-instance)
|
|
30
|
+
- [2. Hand it to your app](#2-hand-it-to-your-app)
|
|
31
|
+
- [3. Track an event](#3-track-an-event)
|
|
32
|
+
- [Track clicks without writing code](#track-clicks-without-writing-code)
|
|
33
|
+
- [Follow a multi-step flow](#follow-a-multi-step-flow)
|
|
34
|
+
- [Tell the library who the visitor is](#tell-the-library-who-the-visitor-is)
|
|
35
|
+
- [Record what the visitor consented to](#record-what-the-visitor-consented-to)
|
|
36
|
+
- [Recognise a returning visitor](#recognise-a-returning-visitor)
|
|
37
|
+
- [Report how fast your pages are](#report-how-fast-your-pages-are)
|
|
38
|
+
- [Send events from your server](#send-events-from-your-server)
|
|
39
|
+
- [A purchase confirmed by a payment webhook](#a-purchase-confirmed-by-a-payment-webhook)
|
|
40
|
+
- [Send the Pixel's events from your server too](#send-the-pixels-events-from-your-server-too)
|
|
41
|
+
- [Use it without React](#use-it-without-react)
|
|
42
|
+
- [What each service receives](#what-each-service-receives)
|
|
43
|
+
- [Documentation](#documentation)
|
|
44
|
+
- [License](#license)
|
|
45
|
+
|
|
46
|
+
## What you need before you start
|
|
47
|
+
|
|
48
|
+
This library sends events to accounts you already have. It doesn't create or configure them for you, and it isn't a
|
|
49
|
+
replacement for them. Before you install it, set up the ones you want to use:
|
|
50
|
+
|
|
51
|
+
| You want | What you need first | What the library needs from it |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| Google Tag Manager | A container, with the tags and triggers that decide what happens to each event | The container ID, like `GTM-XXXXXXX` |
|
|
54
|
+
| Google Analytics 4 | A property with a web data stream | The measurement ID, like `G-XXXXXXX` |
|
|
55
|
+
| Meta Pixel | A dataset (pixel) in Meta Events Manager | The pixel ID, a long number |
|
|
56
|
+
| Events sent from your server to GA4 | An API secret on that same data stream | The secret, kept on your server |
|
|
57
|
+
| Events sent from your server to Meta | A Conversions API access token in Events Manager | The token, kept on your server |
|
|
58
|
+
|
|
59
|
+
Two things worth knowing:
|
|
60
|
+
|
|
61
|
+
- **With Tag Manager, your container still decides what happens.** The library puts each event into the dataLayer, the
|
|
62
|
+
list of events Tag Manager watches. Until you add a trigger and a tag for an event name, the event is recorded but
|
|
63
|
+
goes nowhere.
|
|
64
|
+
- **With Google Analytics 4, events arrive on their own.** They show up as events with the name you tracked. Using the
|
|
65
|
+
names Google recommends, such as `purchase` or `sign_up`, fills in GA4's built-in reports; your own names appear in
|
|
66
|
+
reports and explorations once you use them.
|
|
67
|
+
|
|
68
|
+
You only configure the services you use. Many teams start with Tag Manager alone and add the rest later.
|
|
15
69
|
|
|
16
70
|
## Install
|
|
17
71
|
|
|
18
72
|
```sh
|
|
19
|
-
npm install react-marketing-tools
|
|
73
|
+
npm install react-marketing-tools
|
|
20
74
|
```
|
|
21
75
|
|
|
22
|
-
|
|
76
|
+
Works with React 18 and 19. The package is published as ES modules, the `import` style of JavaScript, so it works in
|
|
77
|
+
every current bundler and in Node.js 22.12 or later; it can't be loaded with `require()`.
|
|
78
|
+
|
|
79
|
+
## Set this up with an AI assistant
|
|
80
|
+
|
|
81
|
+
There's a prompt you can paste into any AI coding assistant. It reads your project, asks what you want to measure and
|
|
82
|
+
which analytics accounts you have, then writes the setup, a checklist of what to click in Google Tag Manager, Google
|
|
83
|
+
Analytics and Meta, and the steps to check it all worked.
|
|
84
|
+
|
|
85
|
+
**[Get the prompt](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/agent-setup.md)**, paste it into
|
|
86
|
+
your assistant, and answer its questions. That page also lists the six things to check before you trust what it wrote,
|
|
87
|
+
and an `AGENTS.md` block to keep in your repo so later sessions don't re-guess the conventions this one chose.
|
|
23
88
|
|
|
24
|
-
|
|
89
|
+
The prompt is tested: three assistants set up a Next.js app and a Vite app from scratch, and what they got wrong is
|
|
90
|
+
what those rules are there to prevent.
|
|
25
91
|
|
|
26
|
-
|
|
92
|
+
Working by hand is just as quick for a simple setup: the [quick start](#quick-start) below, or the
|
|
93
|
+
[integration walkthrough](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/integration-walkthrough.md)
|
|
94
|
+
for an app that already has analytics in it.
|
|
95
|
+
|
|
96
|
+
## Quick start
|
|
97
|
+
|
|
98
|
+
Adding this to an app that already exists, with analytics already in it? Follow the
|
|
99
|
+
[integration walkthrough](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/integration-walkthrough.md)
|
|
100
|
+
instead: same steps, plus how to prove events arrive and how to retire the library you're replacing.
|
|
101
|
+
|
|
102
|
+
### 1. Create the analytics instance
|
|
27
103
|
|
|
28
104
|
```ts
|
|
29
105
|
// analytics.ts
|
|
30
106
|
import { createAnalytics } from 'react-marketing-tools'
|
|
31
107
|
|
|
32
108
|
export const analytics = createAnalytics({
|
|
33
|
-
|
|
109
|
+
// Nothing is stored or sent until you record the visitor's choice; see "Record what the visitor consented to".
|
|
110
|
+
consent: 'denied',
|
|
34
111
|
gtm: { containerId: 'GTM-XXXXXXX' },
|
|
35
112
|
ga4: { measurementId: 'G-XXXXXXX' },
|
|
36
113
|
metaPixel: { pixelId: '1234567890123456' },
|
|
37
114
|
})
|
|
38
115
|
```
|
|
39
116
|
|
|
40
|
-
|
|
117
|
+
Creating the instance does nothing on its own: no scripts load and no cookies are written until your app starts it,
|
|
118
|
+
which the provider below does. That makes this file safe to import anywhere, including in server-rendered pages.
|
|
119
|
+
|
|
120
|
+
### 2. Hand it to your app
|
|
41
121
|
|
|
42
122
|
```tsx
|
|
43
123
|
// main.tsx
|
|
124
|
+
import { StrictMode } from 'react'
|
|
125
|
+
import { createRoot } from 'react-dom/client'
|
|
44
126
|
import { AnalyticsProvider } from 'react-marketing-tools'
|
|
45
127
|
import { analytics } from './analytics'
|
|
128
|
+
import { App } from './App'
|
|
46
129
|
|
|
47
130
|
createRoot(document.getElementById('root')!).render(
|
|
48
|
-
<
|
|
49
|
-
<
|
|
50
|
-
|
|
131
|
+
<StrictMode>
|
|
132
|
+
<AnalyticsProvider analytics={analytics}>
|
|
133
|
+
<App />
|
|
134
|
+
</AnalyticsProvider>
|
|
135
|
+
</StrictMode>,
|
|
51
136
|
)
|
|
52
137
|
```
|
|
53
138
|
|
|
54
|
-
|
|
139
|
+
The provider loads the vendor scripts once your app is running in the browser. Anything you track before that is held
|
|
140
|
+
and sent in order afterwards, so you never lose an event that happened during startup.
|
|
141
|
+
|
|
142
|
+
### 3. Track an event
|
|
55
143
|
|
|
56
144
|
```tsx
|
|
145
|
+
// SignUpButton.tsx
|
|
57
146
|
import { useAnalytics } from 'react-marketing-tools'
|
|
58
147
|
|
|
59
148
|
export const SignUpButton = () => {
|
|
60
149
|
const { track } = useAnalytics()
|
|
61
150
|
|
|
62
|
-
|
|
63
|
-
|
|
151
|
+
return (
|
|
152
|
+
<button onClick={() => track('sign_up', { method: 'google' })}>
|
|
153
|
+
Sign up
|
|
154
|
+
</button>
|
|
155
|
+
)
|
|
64
156
|
}
|
|
65
157
|
```
|
|
66
158
|
|
|
67
|
-
|
|
159
|
+
That one call reaches all three services: Tag Manager gets a `sign_up` event, Google Analytics 4 gets `sign_up`, and
|
|
160
|
+
the Meta Pixel gets `CompleteRegistration`, the name Meta uses for the same thing. All three carry the same event ID,
|
|
161
|
+
so when the same action arrives twice, from the browser and from your server, Meta counts it once.
|
|
68
162
|
|
|
69
|
-
|
|
70
|
-
|
|
163
|
+
## Track clicks without writing code
|
|
164
|
+
|
|
165
|
+
Turn it on once, and then mark the elements you care about. No analytics code in your components:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
// analytics.ts
|
|
169
|
+
import { createAnalytics } from 'react-marketing-tools'
|
|
170
|
+
|
|
171
|
+
export const analytics = createAnalytics({
|
|
172
|
+
consent: 'denied',
|
|
173
|
+
gtm: { containerId: 'GTM-XXXXXXX' },
|
|
174
|
+
autocapture: { clicks: true },
|
|
175
|
+
})
|
|
71
176
|
```
|
|
72
177
|
|
|
73
|
-
|
|
178
|
+
```tsx
|
|
179
|
+
// PricingPage.tsx
|
|
180
|
+
export const PricingPage = () => (
|
|
181
|
+
<section>
|
|
182
|
+
<h1>Pricing</h1>
|
|
183
|
+
<button
|
|
184
|
+
data-analytics-event="cta_click"
|
|
185
|
+
data-analytics-param-location="pricing_header"
|
|
186
|
+
data-analytics-param-plan="pro"
|
|
187
|
+
>
|
|
188
|
+
Start free trial
|
|
189
|
+
</button>
|
|
190
|
+
</section>
|
|
191
|
+
)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
A click anywhere inside that button, including on an icon or text inside it, sends the same event as
|
|
195
|
+
`track('cta_click', { location: 'pricing_header', plan: 'pro' })`.
|
|
196
|
+
|
|
197
|
+
- `data-analytics-event` is the event name. Every `data-analytics-param-*` attribute becomes one detail on the event,
|
|
198
|
+
with dashes turned into underscores: `data-analytics-param-button-text` arrives as `button_text`.
|
|
199
|
+
- Values are text. For numbers, such as a price you want to add up, call `track()` instead.
|
|
200
|
+
- It works for elements added later, for example after a route change, and for links as well as buttons.
|
|
201
|
+
- If several marked elements are nested, the closest one to the click wins.
|
|
202
|
+
|
|
203
|
+
## Follow a multi-step flow
|
|
204
|
+
|
|
205
|
+
A journey groups the steps of a flow such as a checkout, so you can see where people drop out:
|
|
206
|
+
|
|
207
|
+
```tsx
|
|
208
|
+
// Checkout.tsx
|
|
209
|
+
import { useState } from 'react'
|
|
210
|
+
import { useAnalytics } from 'react-marketing-tools'
|
|
211
|
+
|
|
212
|
+
export const Checkout = () => {
|
|
213
|
+
const { journey } = useAnalytics()
|
|
214
|
+
// Create it once for this flow, not on every render: each journey has its own ID.
|
|
215
|
+
const [checkout] = useState(() => journey('checkout'))
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<>
|
|
219
|
+
<button onClick={() => checkout.step('shipping')}>Continue to payment</button>
|
|
220
|
+
<button onClick={() => checkout.complete({ value: 42, currency: 'USD' })}>Pay</button>
|
|
221
|
+
<button onClick={() => checkout.abandon('changed_mind')}>Cancel</button>
|
|
222
|
+
</>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The first call also sends a `journey_start` event, and every event in the flow carries the same journey ID. In Google
|
|
228
|
+
Analytics 4 you can then build a funnel from `journey_start`, `journey_step` and `journey_complete`.
|
|
229
|
+
|
|
230
|
+
## Tell the library who the visitor is
|
|
74
231
|
|
|
75
232
|
```ts
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
233
|
+
// after your sign-in code succeeds
|
|
234
|
+
import { analytics } from './analytics'
|
|
235
|
+
|
|
236
|
+
analytics.identify('user-42', { email: 'ada@example.com' })
|
|
79
237
|
```
|
|
80
238
|
|
|
81
|
-
|
|
239
|
+
The ID is your own user ID: it goes to Tag Manager and Google Analytics 4 so you can join sessions to accounts. The
|
|
240
|
+
email address is not sent to Google. It goes only to Meta, which uses contact details to match a visitor to a Facebook
|
|
241
|
+
or Instagram account, and it's scrambled into an unreadable fingerprint (a hash) before it's sent. Call
|
|
242
|
+
`analytics.reset()` when someone signs out.
|
|
243
|
+
|
|
244
|
+
## Record what the visitor consented to
|
|
82
245
|
|
|
83
246
|
```ts
|
|
84
|
-
|
|
247
|
+
// from your cookie banner, when the visitor answers
|
|
248
|
+
import { analytics } from './analytics'
|
|
85
249
|
|
|
86
|
-
|
|
250
|
+
analytics.consent.update({ analytics: 'granted', ads: 'granted' })
|
|
87
251
|
```
|
|
88
252
|
|
|
89
|
-
|
|
253
|
+
Until this is called, the instance follows the `consent` value you passed when you created it. The library passes the
|
|
254
|
+
choice to Google's Consent Mode, which is how Google's tags are told what a visitor agreed to, and to the Meta Pixel,
|
|
255
|
+
which holds events until it's allowed to send them. The same choice controls what the library itself stores in the
|
|
256
|
+
browser. Both parts of the choice can be set separately if your banner asks separately.
|
|
257
|
+
|
|
258
|
+
## Recognise a returning visitor
|
|
90
259
|
|
|
91
260
|
```ts
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
261
|
+
import { analytics } from './analytics'
|
|
262
|
+
|
|
263
|
+
// inside an async function, any time after your app has started
|
|
264
|
+
const visitorId = await analytics.getVisitorId()
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
This is a random ID kept in the browser, given only to visitors who consented to analytics, so you can join a person's
|
|
268
|
+
visits in your own systems. It's never sent to Google Analytics. You can swap it for a browser fingerprint if you want
|
|
269
|
+
to recognise people who clear their cookies; see [Visitor ID](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/visitor-id.md).
|
|
270
|
+
|
|
271
|
+
## Report how fast your pages are
|
|
272
|
+
|
|
273
|
+
Core Web Vitals are Google's three measures of page experience: how fast the main content appears, how quickly the page
|
|
274
|
+
responds to a tap or click, and how much the layout jumps around. Install Google's measuring library and pass it your
|
|
275
|
+
instance:
|
|
276
|
+
|
|
277
|
+
```sh
|
|
278
|
+
npm install web-vitals@^6
|
|
95
279
|
```
|
|
96
280
|
|
|
97
|
-
|
|
98
|
-
|
|
281
|
+
```ts
|
|
282
|
+
// main.tsx, after the render call
|
|
283
|
+
import { trackWebVitals } from 'react-marketing-tools/web-vitals'
|
|
284
|
+
import { analytics } from './analytics'
|
|
285
|
+
|
|
286
|
+
void trackWebVitals(analytics)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Each measurement arrives as an event named `LCP`, `INP` or `CLS` in Google Analytics 4 and Tag Manager. They're never
|
|
290
|
+
sent to Meta.
|
|
291
|
+
|
|
292
|
+
## Send events from your server
|
|
293
|
+
|
|
294
|
+
Some things happen where the browser can't see them, such as a payment your payment provider confirms minutes later.
|
|
295
|
+
Other events simply never make it, because an extension or browser setting blocks the tracking scripts. For both, send
|
|
296
|
+
the event from your server.
|
|
99
297
|
|
|
100
|
-
|
|
101
|
-
`react-marketing-tools/server`:
|
|
298
|
+
### A purchase confirmed by a payment webhook
|
|
102
299
|
|
|
103
300
|
```ts
|
|
301
|
+
// inside your payment webhook, where `order` is the order you just confirmed
|
|
104
302
|
import { sendMeasurementProtocolEvent } from 'react-marketing-tools/server'
|
|
105
303
|
|
|
106
304
|
await sendMeasurementProtocolEvent({
|
|
107
305
|
measurementId: 'G-XXXXXXX',
|
|
108
306
|
apiSecret: process.env.GA4_API_SECRET!,
|
|
109
|
-
|
|
110
|
-
|
|
307
|
+
// Saved when the visitor started checkout, with readGa4Cookies() from the same import.
|
|
308
|
+
clientId: order.ga4ClientId,
|
|
309
|
+
events: [
|
|
310
|
+
{
|
|
311
|
+
name: 'purchase',
|
|
312
|
+
params: { transaction_id: order.id, value: order.total, currency: 'USD' },
|
|
313
|
+
},
|
|
314
|
+
],
|
|
111
315
|
})
|
|
112
316
|
```
|
|
113
317
|
|
|
114
|
-
`sendConversionsApiEvent`
|
|
115
|
-
|
|
318
|
+
The same import has `sendConversionsApiEvent` for Meta, which scrambles customer details into hashes the way Meta
|
|
319
|
+
requires before sending them.
|
|
320
|
+
|
|
321
|
+
### Send the Pixel's events from your server too
|
|
322
|
+
|
|
323
|
+
Point the page at an address on your own site, and mount the handler there. Every event the Pixel receives is then sent
|
|
324
|
+
from your server as well, and Meta counts each one once:
|
|
116
325
|
|
|
117
326
|
```ts
|
|
118
327
|
// analytics.ts
|
|
119
|
-
|
|
328
|
+
import { createAnalytics } from 'react-marketing-tools'
|
|
329
|
+
|
|
330
|
+
export const analytics = createAnalytics({
|
|
331
|
+
consent: 'denied',
|
|
332
|
+
metaPixel: { pixelId: '1234567890123456' },
|
|
333
|
+
server: { endpoint: '/api/track' },
|
|
334
|
+
})
|
|
335
|
+
```
|
|
120
336
|
|
|
121
|
-
|
|
337
|
+
```ts
|
|
338
|
+
// app/api/track/route.ts — a Next.js route; any server that speaks Request and Response works
|
|
122
339
|
import { createTrackHandler } from 'react-marketing-tools/server'
|
|
123
340
|
|
|
124
341
|
export const POST = createTrackHandler({
|
|
125
342
|
allowedOrigins: ['https://shop.example.com'],
|
|
126
|
-
meta: {
|
|
343
|
+
meta: {
|
|
344
|
+
pixelId: '1234567890123456',
|
|
345
|
+
accessToken: process.env.META_CAPI_TOKEN!,
|
|
346
|
+
},
|
|
127
347
|
})
|
|
128
348
|
```
|
|
129
349
|
|
|
130
|
-
##
|
|
350
|
+
## Use it without React
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
// analytics.ts
|
|
354
|
+
import { createAnalytics } from 'react-marketing-tools/core'
|
|
355
|
+
|
|
356
|
+
export const analytics = createAnalytics({
|
|
357
|
+
consent: 'granted',
|
|
358
|
+
gtm: { containerId: 'GTM-XXXXXXX' },
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
analytics.start() // the React provider does this for you
|
|
362
|
+
analytics.track('sign_up', { method: 'google' })
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
## What each service receives
|
|
131
366
|
|
|
132
|
-
| | Google Tag Manager | Google Analytics 4 | Meta Pixel |
|
|
367
|
+
| | Google Tag Manager | Google Analytics 4 | Meta Pixel | Your server, for Meta |
|
|
133
368
|
| --- | --- | --- | --- | --- |
|
|
134
|
-
| `track()` | dataLayer
|
|
135
|
-
| `identify()` |
|
|
136
|
-
| `consent.update()` | Consent Mode
|
|
137
|
-
|
|
|
369
|
+
| `track()` | the event in the dataLayer, with an event ID | the event through Google's tag | Meta's name for the event, with the same event ID | the same event again, so it still arrives when the browser is blocked |
|
|
370
|
+
| `identify()` | your user ID | your user ID | contact details, hashed by the Pixel | contact details, hashed by your server |
|
|
371
|
+
| `consent.update()` | Google Consent Mode | Google Consent Mode | permission to send, or to hold | only sends with permission to share data with ad platforms |
|
|
372
|
+
| Campaign the visitor came from | the campaign on every event | read from the page address by Google's tag | the Meta click ID | the Meta click and browser IDs |
|
|
138
373
|
|
|
139
374
|
## Documentation
|
|
140
375
|
|
|
141
376
|
- [All docs](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/README.md)
|
|
142
377
|
- [Getting started](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/getting-started.md)
|
|
143
|
-
- [
|
|
378
|
+
- [Integration walkthrough](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/integration-walkthrough.md): add it to an existing app, step by step, and prove events arrive
|
|
379
|
+
- [React](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/react.md): provider, hook, single-page apps
|
|
380
|
+
- [Next.js](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/nextjs.md): a complete App Router setup, client and server
|
|
144
381
|
- [Tracking events](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/tracking-events.md): naming rules, page views, journeys, click autocapture, Web Vitals, users, personal data, errors
|
|
145
382
|
- [Configuration](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/configuration.md)
|
|
146
383
|
- [Consent](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/consent.md): Consent Mode v2 and Global Privacy Control
|
|
@@ -149,11 +386,16 @@ export const POST = createTrackHandler({
|
|
|
149
386
|
- [Google Tag Manager](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/google-tag-manager.md)
|
|
150
387
|
- [Google Analytics 4](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/google-analytics-4.md)
|
|
151
388
|
- [Meta Pixel](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/meta-pixel.md)
|
|
389
|
+
- [Other tools](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/custom-destinations.md): PostHog, Umami, Plausible, your own endpoint
|
|
390
|
+
- [Measuring AI activity](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/ai-traffic.md): AI features, visits from AI assistants, AI crawlers
|
|
152
391
|
- [Server-side tagging](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/server-side-tagging.md)
|
|
153
392
|
- [GA4 Measurement Protocol](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/measurement-protocol.md): GA4 events from your server
|
|
154
393
|
- [Meta Conversions API](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/meta-conversions-api.md): Meta events from your server
|
|
394
|
+
- [Error tracking](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/error-tracking.md): report errors to Google Analytics 4
|
|
155
395
|
- [Debugging](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/debugging.md): see what's sent, and fix common problems
|
|
156
396
|
- [Migrating from 0.4](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/migration-v1.md)
|
|
397
|
+
- [API summary](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/api-summary.md): every option, method and limit on one page
|
|
398
|
+
- [Set up with an AI assistant](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/agent-setup.md): a prompt that interviews you and writes the setup
|
|
157
399
|
- [Changelog](https://github.com/bronz3beard/react-marketing-tools/blob/main/docs/CHANGELOG.md)
|
|
158
400
|
|
|
159
401
|
## License
|
|
@@ -1,13 +1,20 @@
|
|
|
1
|
-
import type { Attribution, CampaignParam } from '../core/types.js';
|
|
1
|
+
import type { AiSources, Attribution, CampaignParam } from '../core/types.js';
|
|
2
2
|
export declare const CAMPAIGN_PARAMS: readonly CampaignParam[];
|
|
3
|
+
/** Your label for the AI assistant a referrer belongs to: an exact hostname match, or a subdomain of one. */
|
|
4
|
+
export declare const matchAiSource: ({ referrer, aiSources, }: {
|
|
5
|
+
referrer?: string;
|
|
6
|
+
aiSources?: AiSources;
|
|
7
|
+
}) => string | undefined;
|
|
3
8
|
/**
|
|
4
|
-
* The campaign behind a visit, from its landing URL. `undefined` when the URL carries
|
|
5
|
-
*
|
|
9
|
+
* The campaign behind a visit, from its landing URL, or the AI assistant that sent it. `undefined` when the URL carries
|
|
10
|
+
* no campaign params and the referrer isn't one of `aiSources`, so a plain navigation never replaces an earlier touch.
|
|
11
|
+
* Values are email-redacted (email tools put addresses in `utm_term`).
|
|
6
12
|
*/
|
|
7
|
-
export declare const parseAttribution: ({ url, referrer, capturedAt, }: {
|
|
13
|
+
export declare const parseAttribution: ({ url, referrer, capturedAt, aiSources, }: {
|
|
8
14
|
url: string;
|
|
9
15
|
referrer?: string;
|
|
10
16
|
capturedAt: number;
|
|
17
|
+
aiSources?: AiSources;
|
|
11
18
|
}) => Attribution | undefined;
|
|
12
19
|
/** Stored attribution is untrusted input: anything that isn't a well-formed touch is discarded. */
|
|
13
20
|
export declare const toAttribution: (value: unknown) => Attribution | undefined;
|
package/dist/chunks/core.js
CHANGED
|
@@ -23,75 +23,87 @@ var a = [
|
|
|
23
23
|
if (!URL.canParse(e)) return;
|
|
24
24
|
let { origin: t, pathname: n } = new URL(e);
|
|
25
25
|
return t + n;
|
|
26
|
-
}, c = ({
|
|
26
|
+
}, c = ({ referrer: e, aiSources: t }) => {
|
|
27
|
+
if (!e || !t || !URL.canParse(e)) return;
|
|
28
|
+
let n = new URL(e).hostname.toLowerCase();
|
|
29
|
+
return Object.entries(t).find(([, e]) => e.some((e) => {
|
|
30
|
+
let t = e.toLowerCase();
|
|
31
|
+
return n === t || n.endsWith(`.${t}`);
|
|
32
|
+
}))?.[0];
|
|
33
|
+
}, l = ({ url: t, referrer: n, capturedAt: r, aiSources: i }) => {
|
|
27
34
|
if (!URL.canParse(t)) return;
|
|
28
|
-
let { searchParams:
|
|
29
|
-
let t =
|
|
35
|
+
let { searchParams: l } = new URL(t), u = a.flatMap((e) => {
|
|
36
|
+
let t = l.get(e)?.trim();
|
|
30
37
|
return t ? [[e, t]] : [];
|
|
38
|
+
}), d = c({
|
|
39
|
+
referrer: n,
|
|
40
|
+
aiSources: i
|
|
31
41
|
});
|
|
32
|
-
if (
|
|
33
|
-
let { params:
|
|
42
|
+
if (u.length === 0 && !d) return;
|
|
43
|
+
let { params: f } = e(Object.fromEntries(u)), p = Object.fromEntries(Object.entries(f).map(([e, t]) => [e, String(t).slice(0, o)])), m = n ? s(n) : void 0;
|
|
34
44
|
return {
|
|
35
|
-
...
|
|
45
|
+
...p,
|
|
36
46
|
landing_page: s(t) ?? t,
|
|
37
|
-
...
|
|
47
|
+
...m ? { referrer: m } : {},
|
|
48
|
+
...d ? { ai_source: d } : {},
|
|
38
49
|
captured_at: r
|
|
39
50
|
};
|
|
40
|
-
},
|
|
41
|
-
if (
|
|
51
|
+
}, u = (e) => typeof e == "object" && !!e, d = (e) => {
|
|
52
|
+
if (u(e) && typeof e.captured_at == "number" && typeof e.landing_page == "string") return {
|
|
42
53
|
...Object.fromEntries(a.flatMap((t) => typeof e[t] == "string" ? [[t, e[t]]] : [])),
|
|
43
54
|
landing_page: e.landing_page,
|
|
44
55
|
...typeof e.referrer == "string" ? { referrer: e.referrer } : {},
|
|
56
|
+
...typeof e.ai_source == "string" ? { ai_source: e.ai_source } : {},
|
|
45
57
|
captured_at: e.captured_at
|
|
46
58
|
};
|
|
47
|
-
},
|
|
59
|
+
}, f = ({ fbcCookie: e, touch: t }) => e ?? (t?.fbclid ? `fb.1.${t.captured_at}.${t.fbclid}` : void 0), p = "rmt:attribution:first", m = "rmt:attribution:last", h = 864e5, g = (e, t) => {
|
|
48
60
|
try {
|
|
49
61
|
return t(window[e]);
|
|
50
62
|
} catch {
|
|
51
63
|
return;
|
|
52
64
|
}
|
|
53
|
-
},
|
|
65
|
+
}, _ = (e, t) => g(e, (e) => {
|
|
54
66
|
let n = e.getItem(t);
|
|
55
|
-
return n === null ? void 0 :
|
|
67
|
+
return n === null ? void 0 : d(JSON.parse(n));
|
|
56
68
|
}), ee = ({ ttlDays: e }) => {
|
|
57
|
-
let t, n, r = (t) => Date.now() - t.captured_at < e *
|
|
69
|
+
let t, n, r = (t) => Date.now() - t.captured_at < e * h;
|
|
58
70
|
return {
|
|
59
71
|
observe(e) {
|
|
60
72
|
e && (t ??= e, n = e);
|
|
61
73
|
},
|
|
62
74
|
restore() {
|
|
63
|
-
let e =
|
|
64
|
-
e && r(e) && (!t || e.captured_at <= t.captured_at) && (t = e), n ??=
|
|
75
|
+
let e = _("localStorage", p);
|
|
76
|
+
e && r(e) && (!t || e.captured_at <= t.captured_at) && (t = e), n ??= _("sessionStorage", m);
|
|
65
77
|
},
|
|
66
78
|
persist() {
|
|
67
|
-
|
|
79
|
+
g("localStorage", (e) => t && r(t) ? e.setItem(p, JSON.stringify(t)) : e.removeItem(p)), n && g("sessionStorage", (e) => e.setItem(m, JSON.stringify(n)));
|
|
68
80
|
},
|
|
69
81
|
erase() {
|
|
70
|
-
|
|
82
|
+
g("localStorage", (e) => e.removeItem(p)), g("sessionStorage", (e) => e.removeItem(m));
|
|
71
83
|
},
|
|
72
84
|
get: () => ({
|
|
73
85
|
firstTouch: t,
|
|
74
86
|
lastTouch: n
|
|
75
87
|
})
|
|
76
88
|
};
|
|
77
|
-
},
|
|
89
|
+
}, v = "data-analytics-event", y = "data-analytics-param-", b = (e) => {
|
|
78
90
|
if (!(e instanceof Element)) return;
|
|
79
|
-
let t = e.closest(`[${
|
|
91
|
+
let t = e.closest(`[${v}]`), n = t?.getAttribute(v);
|
|
80
92
|
if (t && n) return {
|
|
81
93
|
name: n,
|
|
82
|
-
params: Object.fromEntries(Array.from(t.attributes).filter((e) => e.name.startsWith(
|
|
94
|
+
params: Object.fromEntries(Array.from(t.attributes).filter((e) => e.name.startsWith(y)).map((e) => [e.name.slice(21).replaceAll("-", "_"), e.value]))
|
|
83
95
|
};
|
|
84
|
-
},
|
|
96
|
+
}, te = (e) => {
|
|
85
97
|
document.addEventListener("click", (t) => {
|
|
86
|
-
let n =
|
|
98
|
+
let n = b(t.target);
|
|
87
99
|
n && e(n.name, n.params);
|
|
88
100
|
}, { capture: !0 });
|
|
89
|
-
},
|
|
101
|
+
}, ne = [
|
|
90
102
|
"analytics",
|
|
91
103
|
"ads",
|
|
92
104
|
"adUserData",
|
|
93
105
|
"adPersonalization"
|
|
94
|
-
],
|
|
106
|
+
], x = (e) => e === "granted" || e === "denied", S = ({ consent: e, gpc: t }) => {
|
|
95
107
|
let n = t ? "denied" : e;
|
|
96
108
|
return {
|
|
97
109
|
analytics: e,
|
|
@@ -99,7 +111,7 @@ var a = [
|
|
|
99
111
|
adUserData: n,
|
|
100
112
|
adPersonalization: n
|
|
101
113
|
};
|
|
102
|
-
},
|
|
114
|
+
}, re = (e, t) => ({
|
|
103
115
|
analytics: t.analytics ?? e.analytics,
|
|
104
116
|
ads: t.ads ?? e.ads,
|
|
105
117
|
adUserData: t.adUserData ?? t.ads ?? e.adUserData,
|
|
@@ -109,7 +121,7 @@ var a = [
|
|
|
109
121
|
ad_storage: e.ads,
|
|
110
122
|
ad_user_data: e.adUserData,
|
|
111
123
|
ad_personalization: e.adPersonalization
|
|
112
|
-
}), w = (e) => Object.values(e).includes("denied"),
|
|
124
|
+
}), w = (e) => Object.values(e).includes("denied"), ie = () => typeof navigator < "u" && navigator.globalPrivacyControl === !0, T = () => window.dataLayer ??= [], E = () => Array.from(document.scripts).some((e) => URL.canParse(e.src) && new URL(e.src).pathname.endsWith("/gtag/js")), D = () => window.gtag ??= function() {
|
|
113
125
|
T().push(arguments);
|
|
114
126
|
}, O = ({ consent: e, waitForUpdate: t }) => {
|
|
115
127
|
D()("consent", "default", {
|
|
@@ -125,8 +137,8 @@ var a = [
|
|
|
125
137
|
}, j = (e) => Array.from(document.scripts).some((t) => t.src === e), M = ({ src: e, nonce: t }) => {
|
|
126
138
|
let n = document.createElement("script");
|
|
127
139
|
n.async = !0, n.src = e, t && n.setAttribute("nonce", t), document.head.append(n);
|
|
128
|
-
},
|
|
129
|
-
if (!
|
|
140
|
+
}, ae = /^G-[A-Z0-9]+$/, oe = ({ measurementId: e, pageViews: t = "auto", loadScript: n = !0, serverContainerUrl: r, waitForUpdate: i = 500, nonce: a }) => {
|
|
141
|
+
if (!ae.test(e)) throw Error(`[react-marketing-tools] ga4.measurementId must look like "G-XXXXXXX" (received ${JSON.stringify(e)}).`);
|
|
130
142
|
r !== void 0 && A(r, "ga4.serverContainerUrl");
|
|
131
143
|
let o = `https://www.googletagmanager.com/gtag/js?id=${e}`, s = (e, t) => r ? {
|
|
132
144
|
...e,
|
|
@@ -276,7 +288,7 @@ var a = [
|
|
|
276
288
|
ph: t.phone,
|
|
277
289
|
fn: t.firstName,
|
|
278
290
|
ln: t.lastName
|
|
279
|
-
}).filter(([, e]) => e !== void 0)), q = (e) => e.adUserData === "granted" ? "grant" : "revoke",
|
|
291
|
+
}).filter(([, e]) => e !== void 0)), q = (e) => e.adUserData === "granted" ? "grant" : "revoke", se = ({ pixelId: e, pageViews: t = "auto", loadScript: n = !0, nonce: r }) => {
|
|
280
292
|
if (!H.test(e)) throw Error(`[react-marketing-tools] metaPixel.pixelId must be the numeric pixel ID (received ${JSON.stringify(e)}).`);
|
|
281
293
|
return {
|
|
282
294
|
name: "metaPixel",
|
|
@@ -304,10 +316,10 @@ var a = [
|
|
|
304
316
|
constructor(e, t, n) {
|
|
305
317
|
super(`[react-marketing-tools] ${t}`, n), this.name = "AnalyticsError", this.code = e;
|
|
306
318
|
}
|
|
307
|
-
},
|
|
308
|
-
if (!
|
|
319
|
+
}, ce = (e) => e.startsWith("/") && !e.startsWith("//") || URL.canParse(e) && new URL(e).protocol === "https:", le = ({ endpoint: e, relayPageViews: t, visitorId: n, onError: r }) => {
|
|
320
|
+
if (!ce(e)) throw Error(`[react-marketing-tools] server.endpoint must be a path on your site, such as '/api/track', or an https:// URL (received ${JSON.stringify(e)}).`);
|
|
309
321
|
let a = "denied", o, s = (e) => {
|
|
310
|
-
let t = i(document.cookie, "_fbp"), r =
|
|
322
|
+
let t = i(document.cookie, "_fbp"), r = f({
|
|
311
323
|
fbcCookie: i(document.cookie, "_fbc"),
|
|
312
324
|
touch: e.attribution
|
|
313
325
|
}), a = o?.userId ?? n();
|
|
@@ -367,31 +379,31 @@ var a = [
|
|
|
367
379
|
o = void 0;
|
|
368
380
|
}
|
|
369
381
|
};
|
|
370
|
-
}, Y = () => typeof crypto.randomUUID == "function" ? crypto.randomUUID() : "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (e) => (Number(e) ^ crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(1))[0] & 15 >> Number(e) / 4).toString(16)), X = "rmt:vid",
|
|
382
|
+
}, Y = () => typeof crypto.randomUUID == "function" ? crypto.randomUUID() : "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (e) => (Number(e) ^ crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(1))[0] & 15 >> Number(e) / 4).toString(16)), X = "rmt:vid", ue = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, de = () => {
|
|
371
383
|
try {
|
|
372
384
|
let e = localStorage.getItem(X);
|
|
373
|
-
return e !== null &&
|
|
385
|
+
return e !== null && ue.test(e) ? e : void 0;
|
|
374
386
|
} catch {
|
|
375
387
|
return;
|
|
376
388
|
}
|
|
377
|
-
},
|
|
389
|
+
}, Z = (e) => {
|
|
378
390
|
try {
|
|
379
391
|
e ? localStorage.setItem(X, e) : localStorage.removeItem(X);
|
|
380
392
|
} catch {}
|
|
381
|
-
},
|
|
393
|
+
}, fe = () => {
|
|
382
394
|
let e, t = (t) => {
|
|
383
395
|
if (t.analytics !== "granted") return;
|
|
384
|
-
let n =
|
|
385
|
-
return n ? e = n : (e ??= Y(),
|
|
396
|
+
let n = de();
|
|
397
|
+
return n ? e = n : (e ??= Y(), Z(e), e);
|
|
386
398
|
};
|
|
387
399
|
return {
|
|
388
400
|
peek: t,
|
|
389
401
|
get: (e) => Promise.resolve(t(e)),
|
|
390
402
|
erase() {
|
|
391
|
-
e = void 0,
|
|
403
|
+
e = void 0, Z(void 0);
|
|
392
404
|
}
|
|
393
405
|
};
|
|
394
|
-
},
|
|
406
|
+
}, pe = ({ fingerprint: e, onError: t }) => {
|
|
395
407
|
let n, r, i = (e) => e.analytics === "granted" && e.ads === "granted";
|
|
396
408
|
return {
|
|
397
409
|
peek: (e) => i(e) ? r : void 0,
|
|
@@ -407,16 +419,16 @@ var a = [
|
|
|
407
419
|
n = void 0, r = void 0;
|
|
408
420
|
}
|
|
409
421
|
};
|
|
410
|
-
},
|
|
422
|
+
}, me = ({ config: e, onError: t }) => {
|
|
411
423
|
if (e !== !1) {
|
|
412
|
-
if (e === "random") return
|
|
413
|
-
if (typeof e?.fingerprint == "function") return
|
|
424
|
+
if (e === "random") return fe();
|
|
425
|
+
if (typeof e?.fingerprint == "function") return pe({
|
|
414
426
|
fingerprint: e.fingerprint,
|
|
415
427
|
onError: t
|
|
416
428
|
});
|
|
417
429
|
throw Error(`[react-marketing-tools] visitorId must be 'random', false or { fingerprint: () => Promise<string> } (received ${JSON.stringify(e)}).`);
|
|
418
430
|
}
|
|
419
|
-
},
|
|
431
|
+
}, he = ({ name: e, track: t, fail: n }) => {
|
|
420
432
|
let r = {
|
|
421
433
|
journey_id: Y(),
|
|
422
434
|
journey_name: e
|
|
@@ -446,14 +458,14 @@ var a = [
|
|
|
446
458
|
}));
|
|
447
459
|
}
|
|
448
460
|
};
|
|
449
|
-
},
|
|
461
|
+
}, Q = () => typeof window < "u", ge = 90, $ = (e) => {
|
|
450
462
|
if (e.consent !== "granted" && e.consent !== "denied") throw Error(`[react-marketing-tools] createAnalytics: "consent" must be 'granted' or 'denied' (received ${JSON.stringify(e.consent)}).`);
|
|
451
|
-
},
|
|
452
|
-
|
|
453
|
-
let { debug: o = !1, onError: s = console.error, respectGpc:
|
|
463
|
+
}, _e = (a) => {
|
|
464
|
+
$(a);
|
|
465
|
+
let { debug: o = !1, onError: s = console.error, respectGpc: c = !0 } = a, u = S({
|
|
454
466
|
consent: a.consent,
|
|
455
|
-
gpc:
|
|
456
|
-
}),
|
|
467
|
+
gpc: c && ie()
|
|
468
|
+
}), d = u, p, m = me({
|
|
457
469
|
config: a.visitorId ?? "random",
|
|
458
470
|
onError: s
|
|
459
471
|
}), h = () => {}, g = new Promise((e) => {
|
|
@@ -463,29 +475,30 @@ var a = [
|
|
|
463
475
|
...a.gtm,
|
|
464
476
|
nonce: a.nonce
|
|
465
477
|
})] : [],
|
|
466
|
-
...a.ga4 ? [
|
|
478
|
+
...a.ga4 ? [oe({
|
|
467
479
|
...a.ga4,
|
|
468
480
|
nonce: a.nonce
|
|
469
481
|
})] : [],
|
|
470
|
-
...a.metaPixel ? [
|
|
482
|
+
...a.metaPixel ? [se({
|
|
471
483
|
...a.metaPixel,
|
|
472
484
|
nonce: a.nonce
|
|
473
485
|
})] : [],
|
|
474
|
-
...a.server ? [
|
|
486
|
+
...a.server ? [le({
|
|
475
487
|
...a.server,
|
|
476
488
|
relayPageViews: !a.metaPixel || a.metaPixel.pageViews === "manual",
|
|
477
|
-
visitorId: () => m?.peek(
|
|
489
|
+
visitorId: () => m?.peek(d),
|
|
478
490
|
onError: s
|
|
479
491
|
})] : [],
|
|
480
492
|
...a.destinations ?? []
|
|
481
|
-
], v = [], y = !1,
|
|
482
|
-
w || (w = !0,
|
|
493
|
+
], v = [], y = !1, b = a.attribution === !1 ? void 0 : ee({ ttlDays: typeof a.attribution == "object" && a.attribution.ttlDays || ge }), C = typeof a.attribution == "object" ? a.attribution.aiSources : void 0, w = !1, T = () => {
|
|
494
|
+
w || (w = !0, b?.observe(l({
|
|
483
495
|
url: location.href,
|
|
484
496
|
referrer: document.referrer,
|
|
485
|
-
capturedAt: Date.now()
|
|
497
|
+
capturedAt: Date.now(),
|
|
498
|
+
aiSources: C
|
|
486
499
|
})));
|
|
487
|
-
}, E = () => y &&
|
|
488
|
-
y && a.server && m?.get(
|
|
500
|
+
}, E = () => y && d.analytics === "granted", D = () => {
|
|
501
|
+
y && a.server && m?.get(d);
|
|
489
502
|
}, O = (e) => {
|
|
490
503
|
if (o) throw e;
|
|
491
504
|
s(e);
|
|
@@ -506,7 +519,7 @@ var a = [
|
|
|
506
519
|
for (let e of t(i)) O(new J("invalid_param", `event "${n}" ${e}`));
|
|
507
520
|
let s = e(i), c = a?.meta || void 0, l = c?.params && e(c.params), u = [...s.redactedKeys, ...l?.redactedKeys.map((e) => `meta.${e}`) ?? []];
|
|
508
521
|
u.length > 0 && O(new J("pii_redacted", `event "${n}": personal data redacted from ${u.join(", ")}`)), T();
|
|
509
|
-
let d =
|
|
522
|
+
let d = b?.get().lastTouch;
|
|
510
523
|
return {
|
|
511
524
|
name: n,
|
|
512
525
|
params: s.params,
|
|
@@ -522,7 +535,7 @@ var a = [
|
|
|
522
535
|
timestamp: Date.now()
|
|
523
536
|
};
|
|
524
537
|
}, M = (e, t = {}, n) => {
|
|
525
|
-
if (
|
|
538
|
+
if (!Q()) return;
|
|
526
539
|
let r = j({
|
|
527
540
|
name: e,
|
|
528
541
|
params: t,
|
|
@@ -532,23 +545,24 @@ var a = [
|
|
|
532
545
|
};
|
|
533
546
|
return {
|
|
534
547
|
start() {
|
|
535
|
-
!y &&
|
|
548
|
+
!y && Q() && (y = !0, T(), E() && (b?.restore(), b?.persist()), D(), k((e) => e.start({
|
|
536
549
|
consent: u,
|
|
537
550
|
identity: p
|
|
538
|
-
})), v.splice(0).forEach(k), a.autocapture?.clicks &&
|
|
551
|
+
})), v.splice(0).forEach(k), a.autocapture?.clicks && te(M), h());
|
|
539
552
|
},
|
|
540
553
|
track: M,
|
|
541
|
-
journey: (e) =>
|
|
554
|
+
journey: (e) => he({
|
|
542
555
|
name: e,
|
|
543
556
|
track: M,
|
|
544
557
|
fail: O
|
|
545
558
|
}),
|
|
546
559
|
page(e = {}) {
|
|
547
|
-
if (
|
|
548
|
-
T(),
|
|
560
|
+
if (!Q()) return;
|
|
561
|
+
T(), b?.observe(l({
|
|
549
562
|
url: location.href,
|
|
550
|
-
capturedAt: Date.now()
|
|
551
|
-
|
|
563
|
+
capturedAt: Date.now(),
|
|
564
|
+
aiSources: C
|
|
565
|
+
})), E() && b?.persist();
|
|
552
566
|
let t = j({
|
|
553
567
|
name: "page_view",
|
|
554
568
|
params: {
|
|
@@ -560,7 +574,7 @@ var a = [
|
|
|
560
574
|
t && A((e) => e.page ? e.page(t) : e.track(t));
|
|
561
575
|
},
|
|
562
576
|
identify(e, t = {}) {
|
|
563
|
-
if (
|
|
577
|
+
if (!Q()) return;
|
|
564
578
|
if (!e || n(e)) {
|
|
565
579
|
O(new J("invalid_user_id", "identify() needs a non-empty user id that is not personal data such as an email address"));
|
|
566
580
|
return;
|
|
@@ -572,27 +586,27 @@ var a = [
|
|
|
572
586
|
p = r, A((e) => e.identify?.(r));
|
|
573
587
|
},
|
|
574
588
|
reset() {
|
|
575
|
-
|
|
589
|
+
Q() && (p = void 0, A((e) => e.reset?.()));
|
|
576
590
|
},
|
|
577
591
|
consent: {
|
|
578
592
|
update(e) {
|
|
579
|
-
if (
|
|
580
|
-
let t = Object.entries(e).filter(([e, t]) => t !== void 0 && !(
|
|
593
|
+
if (!Q()) return;
|
|
594
|
+
let t = Object.entries(e).filter(([e, t]) => t !== void 0 && !(ne.includes(e) && x(t)));
|
|
581
595
|
if (t.length > 0) {
|
|
582
596
|
O(new J("invalid_consent", `consent.update() ignored: ${t.map(([e, t]) => `${e}=${JSON.stringify(t)}`).join(", ")}. Use analytics, ads, adUserData or adPersonalization with 'granted' or 'denied'.`));
|
|
583
597
|
return;
|
|
584
598
|
}
|
|
585
|
-
let n =
|
|
586
|
-
|
|
599
|
+
let n = d, r = re(d, e);
|
|
600
|
+
d = r, n.analytics === "granted" && r.analytics === "denied" ? (b?.erase(), m?.erase()) : E() && (b?.restore(), b?.persist()), D(), A((e) => e.consent?.(r));
|
|
587
601
|
},
|
|
588
|
-
get: () =>
|
|
602
|
+
get: () => d
|
|
589
603
|
},
|
|
590
604
|
getAttribution() {
|
|
591
|
-
if (
|
|
605
|
+
if (!Q()) return {};
|
|
592
606
|
T();
|
|
593
|
-
let e =
|
|
594
|
-
if (
|
|
595
|
-
let t = i(document.cookie, "_fbp"), n =
|
|
607
|
+
let e = b?.get() ?? {};
|
|
608
|
+
if (d.adUserData !== "granted") return e;
|
|
609
|
+
let t = i(document.cookie, "_fbp"), n = f({
|
|
596
610
|
fbcCookie: i(document.cookie, "_fbc"),
|
|
597
611
|
touch: e.lastTouch
|
|
598
612
|
});
|
|
@@ -603,9 +617,9 @@ var a = [
|
|
|
603
617
|
};
|
|
604
618
|
},
|
|
605
619
|
getVisitorId() {
|
|
606
|
-
return
|
|
620
|
+
return !Q() || !m ? Promise.resolve(void 0) : g.then(() => m.get(d));
|
|
607
621
|
}
|
|
608
622
|
};
|
|
609
623
|
};
|
|
610
624
|
//#endregion
|
|
611
|
-
export { J as n,
|
|
625
|
+
export { J as n, _e as t };
|
package/dist/core/types.d.ts
CHANGED
|
@@ -25,12 +25,20 @@ export type TrackOptions = {
|
|
|
25
25
|
};
|
|
26
26
|
};
|
|
27
27
|
export type CampaignParam = 'utm_source' | 'utm_medium' | 'utm_campaign' | 'utm_term' | 'utm_content' | 'utm_id' | 'utm_source_platform' | 'utm_creative_format' | 'utm_marketing_tactic' | 'gclid' | 'gbraid' | 'wbraid' | 'dclid' | 'fbclid' | 'msclkid' | 'ttclid' | 'li_fat_id' | 'twclid';
|
|
28
|
+
/**
|
|
29
|
+
* Labels for visits that arrive from AI assistants, as `{ label: [hostname, …] }`, for example
|
|
30
|
+
* `{ chatgpt: ['chatgpt.com', 'chat.openai.com'], perplexity: ['perplexity.ai'] }`. A referrer matches a hostname
|
|
31
|
+
* exactly or as a subdomain of it. The list is yours to keep up to date: the library ships none.
|
|
32
|
+
*/
|
|
33
|
+
export type AiSources = Record<string, string[]>;
|
|
28
34
|
/** Where a visit came from: the campaign params and ad click IDs of its landing URL. */
|
|
29
35
|
export type Attribution = Partial<Record<CampaignParam, string>> & {
|
|
30
36
|
/** Origin and path of the landing page, without its query string. */
|
|
31
37
|
landing_page: string;
|
|
32
38
|
/** Origin and path of the referring page, when there was one. */
|
|
33
39
|
referrer?: string;
|
|
40
|
+
/** Your label for the AI assistant this visit came from, when the referrer matched `attribution.aiSources`. */
|
|
41
|
+
ai_source?: string;
|
|
34
42
|
/** Milliseconds since the Unix epoch when the visit was captured. */
|
|
35
43
|
captured_at: number;
|
|
36
44
|
};
|
|
@@ -162,10 +170,12 @@ export type AnalyticsConfig = {
|
|
|
162
170
|
respectGpc?: boolean;
|
|
163
171
|
/**
|
|
164
172
|
* Capture UTM params and ad click IDs from landing URLs. Defaults to `true`. First and last touch are stored in the
|
|
165
|
-
* browser only with analytics consent; `ttlDays` (default 90) is how long a first touch is kept.
|
|
173
|
+
* browser only with analytics consent; `ttlDays` (default 90) is how long a first touch is kept. `aiSources` labels
|
|
174
|
+
* visits that arrive from AI assistants.
|
|
166
175
|
*/
|
|
167
176
|
attribution?: boolean | {
|
|
168
177
|
ttlDays?: number;
|
|
178
|
+
aiSources?: AiSources;
|
|
169
179
|
};
|
|
170
180
|
/** A stable ID for this visitor, only with consent and never sent to GA4. Defaults to `'random'`. */
|
|
171
181
|
visitorId?: VisitorIdConfig;
|
package/dist/core.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { createAnalytics } from './core/createAnalytics.js';
|
|
2
2
|
export { AnalyticsError } from './core/errors.js';
|
|
3
3
|
export type { AnalyticsErrorCode } from './core/errors.js';
|
|
4
|
-
export type { Analytics, AnalyticsConfig, AnalyticsEvent, Attribution, AttributionSnapshot, CampaignParam, ConsentState, ConsentStatus, ConsentUpdate, Destination, EventParams, Ga4Config, GtmConfig, Identity, IdentityTraits, Journey, MetaPixelConfig, ServerRelayConfig, TrackOptions, VisitorIdConfig, } from './core/types.js';
|
|
4
|
+
export type { AiSources, Analytics, AnalyticsConfig, AnalyticsEvent, Attribution, AttributionSnapshot, CampaignParam, ConsentState, ConsentStatus, ConsentUpdate, Destination, EventParams, Ga4Config, GtmConfig, Identity, IdentityTraits, Journey, MetaPixelConfig, ServerRelayConfig, TrackOptions, VisitorIdConfig, } from './core/types.js';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { MeasurementProtocolOptions, MeasurementProtocolResult } from './measurementProtocol.js';
|
|
2
|
+
/**
|
|
3
|
+
* The AI crawlers you want to recognise, as `{ label: [user-agent fragment, …] }`, for example
|
|
4
|
+
* `{ gptbot: ['GPTBot'], claudebot: ['ClaudeBot'] }`. Matching ignores case. The list is yours to keep up to date:
|
|
5
|
+
* crawlers come and go, so the library ships none.
|
|
6
|
+
*/
|
|
7
|
+
export type AiAgents = Record<string, string[]>;
|
|
8
|
+
/**
|
|
9
|
+
* How this traffic is kept out of your visitor reports. Crawler hits are not people, and mixing them in inflates every
|
|
10
|
+
* number you report.
|
|
11
|
+
*
|
|
12
|
+
* - `'separate-property'`: `measurementId` is a GA4 property used only for crawlers.
|
|
13
|
+
* - `{ trafficType }`: the events carry that `traffic_type` value, and you exclude it with a data filter in
|
|
14
|
+
* **Admin → Data settings → Data filters** of the property you send to.
|
|
15
|
+
*/
|
|
16
|
+
export type AiCrawlerReporting = 'separate-property' | {
|
|
17
|
+
trafficType: string;
|
|
18
|
+
};
|
|
19
|
+
export type AiCrawlerOptions = Omit<MeasurementProtocolOptions, 'consent'> & {
|
|
20
|
+
/** Your label for the crawler, from `matchAiAgent()`. */
|
|
21
|
+
agent: string;
|
|
22
|
+
keepSeparate: AiCrawlerReporting;
|
|
23
|
+
};
|
|
24
|
+
/** Your label for the crawler behind a user agent, or undefined when it isn't one you listed. */
|
|
25
|
+
export declare const matchAiAgent: ({ userAgent, agents, }: {
|
|
26
|
+
userAgent: string;
|
|
27
|
+
agents: AiAgents;
|
|
28
|
+
}) => string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Sends a crawler's visit to GA4, tagged so it can never be mistaken for a person: every event carries your `ai_agent`
|
|
31
|
+
* label and a `traffic_type`. You must say how the traffic is kept separate, because crawler hits in a visitor property
|
|
32
|
+
* quietly ruin sessions, engagement and conversion rates.
|
|
33
|
+
*
|
|
34
|
+
* Crawlers have no GA4 cookie, so pick the `clientId` yourself: one per crawler (`crawler.gptbot`) counts each crawler
|
|
35
|
+
* as one visitor, a random one per request counts every fetch separately.
|
|
36
|
+
*/
|
|
37
|
+
export declare const sendAiCrawlerEvent: (options: AiCrawlerOptions) => Promise<MeasurementProtocolResult>;
|
package/dist/server.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { META_GRAPH_API_VERSION, sendConversionsApiEvent, } from './server/conversionsApi.js';
|
|
2
2
|
export type { ConversionsApiEvent, ConversionsApiOptions, ConversionsApiResult, } from './server/conversionsApi.js';
|
|
3
|
+
export { matchAiAgent, sendAiCrawlerEvent } from './server/aiCrawlers.js';
|
|
4
|
+
export type { AiAgents, AiCrawlerOptions, AiCrawlerReporting, } from './server/aiCrawlers.js';
|
|
3
5
|
export { createTrackHandler } from './server/createTrackHandler.js';
|
|
4
6
|
export type { TrackHandlerOptions } from './server/createTrackHandler.js';
|
|
5
7
|
export { readGa4Cookies } from './server/ga4Cookies.js';
|
package/dist/server.js
CHANGED
|
@@ -78,7 +78,87 @@ var a = async (e) => {
|
|
|
78
78
|
body: u,
|
|
79
79
|
warnings: o
|
|
80
80
|
};
|
|
81
|
-
}, y =
|
|
81
|
+
}, y = {
|
|
82
|
+
global: "https://www.google-analytics.com",
|
|
83
|
+
eu: "https://region1.google-analytics.com"
|
|
84
|
+
}, b = /^G-[A-Z0-9]+$/, x = 25, S = (e) => /* @__PURE__ */ TypeError(`[react-marketing-tools] sendMeasurementProtocolEvent: ${e}`), C = ({ measurementId: e, apiSecret: t, clientId: i, userId: a, events: o }) => {
|
|
85
|
+
if (!b.test(e)) throw S(`measurementId must look like "G-XXXXXXX" (received ${JSON.stringify(e)})`);
|
|
86
|
+
if (!t) throw S("apiSecret is required");
|
|
87
|
+
if (!i) throw S("clientId is required; read it from the _ga cookie with readGa4Cookies()");
|
|
88
|
+
if (a !== void 0 && (!a || n(a))) throw S("userId must be your own identifier, not personal data such as an email address");
|
|
89
|
+
if (o.length === 0 || o.length > x) throw S(`send between 1 and ${x} events per request (received ${o.length})`);
|
|
90
|
+
for (let { name: e } of o) {
|
|
91
|
+
let t = r(e);
|
|
92
|
+
if (t) throw S(t);
|
|
93
|
+
}
|
|
94
|
+
}, w = ({ event: { name: n, params: r = {} }, sessionId: i, warnings: a }) => {
|
|
95
|
+
for (let e of t(r)) a.push(`event "${n}" ${e}`);
|
|
96
|
+
let { params: o, redactedKeys: s } = e(r);
|
|
97
|
+
return s.length > 0 && a.push(`event "${n}": personal data redacted from ${s.join(", ")}`), {
|
|
98
|
+
name: n,
|
|
99
|
+
params: {
|
|
100
|
+
...i && { session_id: i },
|
|
101
|
+
engagement_time_msec: 1,
|
|
102
|
+
...o
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}, T = ({ adUserData: e, adPersonalization: t }) => ({
|
|
106
|
+
...e && { ad_user_data: e.toUpperCase() },
|
|
107
|
+
...t && { ad_personalization: t.toUpperCase() }
|
|
108
|
+
}), E = async (e) => {
|
|
109
|
+
C(e);
|
|
110
|
+
let { measurementId: t, apiSecret: n, clientId: r, sessionId: i, userId: a, events: o, consent: s, validate: c = !1, region: l = "global" } = e, u = [], d = {
|
|
111
|
+
client_id: r,
|
|
112
|
+
...a && { user_id: a },
|
|
113
|
+
...s && { consent: T(s) },
|
|
114
|
+
events: o.map((e) => w({
|
|
115
|
+
event: e,
|
|
116
|
+
sessionId: i,
|
|
117
|
+
warnings: u
|
|
118
|
+
}))
|
|
119
|
+
}, f = new URL(c ? "/debug/mp/collect" : "/mp/collect", y[l]);
|
|
120
|
+
f.searchParams.set("measurement_id", t), f.searchParams.set("api_secret", n);
|
|
121
|
+
let p = await fetch(f, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "content-type": "application/json" },
|
|
124
|
+
body: JSON.stringify(d)
|
|
125
|
+
});
|
|
126
|
+
if (!c) return {
|
|
127
|
+
ok: p.ok,
|
|
128
|
+
status: p.status,
|
|
129
|
+
warnings: u
|
|
130
|
+
};
|
|
131
|
+
let { validationMessages: m = [] } = await p.json().catch(() => ({}));
|
|
132
|
+
return {
|
|
133
|
+
ok: p.ok && m.length === 0,
|
|
134
|
+
status: p.status,
|
|
135
|
+
warnings: u,
|
|
136
|
+
validationMessages: m
|
|
137
|
+
};
|
|
138
|
+
}, D = ({ userAgent: e, agents: t }) => {
|
|
139
|
+
let n = e.toLowerCase();
|
|
140
|
+
return Object.entries(t).find(([, e]) => e.some((e) => n.includes(e.toLowerCase())))?.[0];
|
|
141
|
+
}, O = (e) => /* @__PURE__ */ TypeError(`[react-marketing-tools] sendAiCrawlerEvent: ${e}`), k = ["traffic_type", "ai_agent"], A = async (e) => {
|
|
142
|
+
let { agent: t, keepSeparate: n, events: r, ...i } = e;
|
|
143
|
+
if (!t) throw O("agent is required");
|
|
144
|
+
let a = n === "separate-property" ? "ai_crawler" : n?.trafficType;
|
|
145
|
+
if (!a) throw O("keepSeparate is required: 'separate-property' when measurementId is a crawler-only property, or { trafficType } matching a GA4 data filter. Crawler hits must never land in a visitor property untagged.");
|
|
146
|
+
let o = r.flatMap((e) => k.filter((t) => e.params?.[t] !== void 0).map((t) => `event "${e.name}" param "${t}" was replaced: sendAiCrawlerEvent sets it`)), s = await E({
|
|
147
|
+
...i,
|
|
148
|
+
events: r.map((e) => ({
|
|
149
|
+
...e,
|
|
150
|
+
params: {
|
|
151
|
+
...e.params,
|
|
152
|
+
traffic_type: a,
|
|
153
|
+
ai_agent: t
|
|
154
|
+
}
|
|
155
|
+
}))
|
|
156
|
+
});
|
|
157
|
+
return {
|
|
158
|
+
...s,
|
|
159
|
+
warnings: [...o, ...s.warnings]
|
|
160
|
+
};
|
|
161
|
+
}, j = 16384, M = /^\d+$/, N = [
|
|
82
162
|
"externalId",
|
|
83
163
|
"email",
|
|
84
164
|
"phone",
|
|
@@ -86,44 +166,44 @@ var a = async (e) => {
|
|
|
86
166
|
"lastName",
|
|
87
167
|
"fbc",
|
|
88
168
|
"fbp"
|
|
89
|
-
],
|
|
90
|
-
if (e.length === 0) throw
|
|
91
|
-
for (let t of e) if (!URL.canParse(t) || new URL(t).origin !== t) throw
|
|
92
|
-
if (!
|
|
93
|
-
if (!t.accessToken) throw
|
|
94
|
-
},
|
|
95
|
-
if (Number(e.headers.get("content-length")) >
|
|
169
|
+
], P = (e) => /* @__PURE__ */ TypeError(`[react-marketing-tools] createTrackHandler: ${e}`), F = ({ allowedOrigins: e, meta: t }) => {
|
|
170
|
+
if (e.length === 0) throw P("allowedOrigins needs at least one origin, e.g. 'https://shop.example.com'");
|
|
171
|
+
for (let t of e) if (!URL.canParse(t) || new URL(t).origin !== t) throw P(`allowedOrigins entries must be origins such as 'https://shop.example.com', without a path or trailing slash (received ${JSON.stringify(t)})`);
|
|
172
|
+
if (!M.test(t.pixelId)) throw P(`meta.pixelId must be the numeric pixel ID (received ${JSON.stringify(t.pixelId)})`);
|
|
173
|
+
if (!t.accessToken) throw P("meta.accessToken is required");
|
|
174
|
+
}, I = (e, t) => new Response(t, { status: e }), L = async (e) => {
|
|
175
|
+
if (Number(e.headers.get("content-length")) > j) return;
|
|
96
176
|
if (!e.body) return "";
|
|
97
177
|
let t = e.body.getReader(), n = new TextDecoder(), r = "", i = 0;
|
|
98
178
|
for (;;) {
|
|
99
179
|
let { done: e, value: a } = await t.read();
|
|
100
180
|
if (e) return r + n.decode();
|
|
101
|
-
if (i += a.byteLength, i >
|
|
181
|
+
if (i += a.byteLength, i > j) {
|
|
102
182
|
await t.cancel();
|
|
103
183
|
return;
|
|
104
184
|
}
|
|
105
185
|
r += n.decode(a, { stream: !0 });
|
|
106
186
|
}
|
|
107
|
-
},
|
|
187
|
+
}, R = (e) => typeof e == "object" && !!e && !Array.isArray(e), z = (e) => typeof e == "string" && e.length > 0, B = (e) => typeof e == "string" && URL.canParse(e) && ["http:", "https:"].includes(new URL(e).protocol), V = (e) => {
|
|
108
188
|
let t;
|
|
109
189
|
try {
|
|
110
190
|
t = JSON.parse(e);
|
|
111
191
|
} catch {
|
|
112
192
|
return;
|
|
113
193
|
}
|
|
114
|
-
if (!
|
|
194
|
+
if (!R(t) || t.v !== 1) return;
|
|
115
195
|
let { eventName: n, eventId: r, customData: i, eventSourceUrl: a, consent: o, userData: s } = t;
|
|
116
|
-
if (
|
|
196
|
+
if (z(n) && z(r) && R(i) && B(a) && R(o) && (o.adUserData === "granted" || o.adUserData === "denied") && R(s) && !N.some((e) => s[e] !== void 0 && typeof s[e] != "string")) return {
|
|
117
197
|
v: 1,
|
|
118
198
|
eventName: n,
|
|
119
199
|
eventId: r,
|
|
120
200
|
customData: i,
|
|
121
201
|
eventSourceUrl: a,
|
|
122
202
|
consent: { adUserData: o.adUserData },
|
|
123
|
-
userData: Object.fromEntries(
|
|
203
|
+
userData: Object.fromEntries(N.filter((e) => s[e] !== void 0).map((e) => [e, s[e]]))
|
|
124
204
|
};
|
|
125
|
-
},
|
|
126
|
-
|
|
205
|
+
}, H = (e) => {
|
|
206
|
+
F(e);
|
|
127
207
|
let { meta: t, onError: n = console.error } = e, r = new Set(e.allowedOrigins);
|
|
128
208
|
return async (e) => {
|
|
129
209
|
if (e.method !== "POST") return new Response("Use POST", {
|
|
@@ -131,14 +211,14 @@ var a = async (e) => {
|
|
|
131
211
|
headers: { allow: "POST" }
|
|
132
212
|
});
|
|
133
213
|
let i = e.headers.get("origin");
|
|
134
|
-
if (!i || !r.has(i)) return
|
|
135
|
-
let a = await
|
|
136
|
-
if (a === void 0) return
|
|
137
|
-
let o =
|
|
138
|
-
if (!o) return
|
|
214
|
+
if (!i || !r.has(i)) return I(403, "Origin not allowed");
|
|
215
|
+
let a = await L(e);
|
|
216
|
+
if (a === void 0) return I(413, "Body too large");
|
|
217
|
+
let o = V(a);
|
|
218
|
+
if (!o) return I(400, "Invalid event");
|
|
139
219
|
let s = e.headers.get("user-agent");
|
|
140
|
-
if (!s) return
|
|
141
|
-
if (o.consent.adUserData !== "granted") return
|
|
220
|
+
if (!s) return I(400, "Missing User-Agent");
|
|
221
|
+
if (o.consent.adUserData !== "granted") return I(204);
|
|
142
222
|
try {
|
|
143
223
|
let r = await v({
|
|
144
224
|
...t,
|
|
@@ -154,76 +234,19 @@ var a = async (e) => {
|
|
|
154
234
|
customData: o.customData
|
|
155
235
|
}]
|
|
156
236
|
});
|
|
157
|
-
if (r.ok) return
|
|
237
|
+
if (r.ok) return I(204);
|
|
158
238
|
n(Error(`[react-marketing-tools] createTrackHandler: Meta rejected "${o.eventName}" (HTTP ${r.status})`, { cause: r.body }));
|
|
159
239
|
} catch (e) {
|
|
160
240
|
n(Error(`[react-marketing-tools] createTrackHandler: could not send "${o.eventName}" to Meta`, { cause: e }));
|
|
161
241
|
}
|
|
162
|
-
return
|
|
242
|
+
return I(502, "Meta did not accept the event");
|
|
163
243
|
};
|
|
164
|
-
},
|
|
244
|
+
}, U = ({ cookieHeader: e, measurementId: t }) => {
|
|
165
245
|
let n = /^GA\d\.\d\.(\d+\.\d+)$/.exec(i(e, "_ga") ?? "")?.[1], r = i(e, `_ga_${t.replace(/^G-/, "")}`) ?? "", a = /^GS1\.\d\.(\d+)\./.exec(r)?.[1] ?? /^GS2\.\d\.s(\d+)/.exec(r)?.[1];
|
|
166
246
|
return {
|
|
167
247
|
...n && { clientId: n },
|
|
168
248
|
...a && { sessionId: a }
|
|
169
249
|
};
|
|
170
|
-
}, M = {
|
|
171
|
-
global: "https://www.google-analytics.com",
|
|
172
|
-
eu: "https://region1.google-analytics.com"
|
|
173
|
-
}, N = /^G-[A-Z0-9]+$/, P = 25, F = (e) => /* @__PURE__ */ TypeError(`[react-marketing-tools] sendMeasurementProtocolEvent: ${e}`), I = ({ measurementId: e, apiSecret: t, clientId: i, userId: a, events: o }) => {
|
|
174
|
-
if (!N.test(e)) throw F(`measurementId must look like "G-XXXXXXX" (received ${JSON.stringify(e)})`);
|
|
175
|
-
if (!t) throw F("apiSecret is required");
|
|
176
|
-
if (!i) throw F("clientId is required; read it from the _ga cookie with readGa4Cookies()");
|
|
177
|
-
if (a !== void 0 && (!a || n(a))) throw F("userId must be your own identifier, not personal data such as an email address");
|
|
178
|
-
if (o.length === 0 || o.length > P) throw F(`send between 1 and ${P} events per request (received ${o.length})`);
|
|
179
|
-
for (let { name: e } of o) {
|
|
180
|
-
let t = r(e);
|
|
181
|
-
if (t) throw F(t);
|
|
182
|
-
}
|
|
183
|
-
}, L = ({ event: { name: n, params: r = {} }, sessionId: i, warnings: a }) => {
|
|
184
|
-
for (let e of t(r)) a.push(`event "${n}" ${e}`);
|
|
185
|
-
let { params: o, redactedKeys: s } = e(r);
|
|
186
|
-
return s.length > 0 && a.push(`event "${n}": personal data redacted from ${s.join(", ")}`), {
|
|
187
|
-
name: n,
|
|
188
|
-
params: {
|
|
189
|
-
...i && { session_id: i },
|
|
190
|
-
engagement_time_msec: 1,
|
|
191
|
-
...o
|
|
192
|
-
}
|
|
193
|
-
};
|
|
194
|
-
}, R = ({ adUserData: e, adPersonalization: t }) => ({
|
|
195
|
-
...e && { ad_user_data: e.toUpperCase() },
|
|
196
|
-
...t && { ad_personalization: t.toUpperCase() }
|
|
197
|
-
}), z = async (e) => {
|
|
198
|
-
I(e);
|
|
199
|
-
let { measurementId: t, apiSecret: n, clientId: r, sessionId: i, userId: a, events: o, consent: s, validate: c = !1, region: l = "global" } = e, u = [], d = {
|
|
200
|
-
client_id: r,
|
|
201
|
-
...a && { user_id: a },
|
|
202
|
-
...s && { consent: R(s) },
|
|
203
|
-
events: o.map((e) => L({
|
|
204
|
-
event: e,
|
|
205
|
-
sessionId: i,
|
|
206
|
-
warnings: u
|
|
207
|
-
}))
|
|
208
|
-
}, f = new URL(c ? "/debug/mp/collect" : "/mp/collect", M[l]);
|
|
209
|
-
f.searchParams.set("measurement_id", t), f.searchParams.set("api_secret", n);
|
|
210
|
-
let p = await fetch(f, {
|
|
211
|
-
method: "POST",
|
|
212
|
-
headers: { "content-type": "application/json" },
|
|
213
|
-
body: JSON.stringify(d)
|
|
214
|
-
});
|
|
215
|
-
if (!c) return {
|
|
216
|
-
ok: p.ok,
|
|
217
|
-
status: p.status,
|
|
218
|
-
warnings: u
|
|
219
|
-
};
|
|
220
|
-
let { validationMessages: m = [] } = await p.json().catch(() => ({}));
|
|
221
|
-
return {
|
|
222
|
-
ok: p.ok && m.length === 0,
|
|
223
|
-
status: p.status,
|
|
224
|
-
warnings: u,
|
|
225
|
-
validationMessages: m
|
|
226
|
-
};
|
|
227
250
|
};
|
|
228
251
|
//#endregion
|
|
229
|
-
export { f as META_GRAPH_API_VERSION,
|
|
252
|
+
export { f as META_GRAPH_API_VERSION, H as createTrackHandler, D as matchAiAgent, U as readGa4Cookies, A as sendAiCrawlerEvent, v as sendConversionsApiEvent, E as sendMeasurementProtocolEvent };
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-marketing-tools",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.0.0
|
|
4
|
+
"version": "1.0.0",
|
|
5
5
|
"description": "One track() call for Google Tag Manager, Google Analytics 4 and the Meta Pixel, with Consent Mode v2, UTM attribution and personal-data redaction built in. React bindings included.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "bronz3beard <exempli.gratia.webdesign@gmail.com> (https://www.heyrory.com/)",
|
|
8
|
-
"homepage": "https://
|
|
8
|
+
"homepage": "https://bronz3beard.github.io/react-marketing-tools/",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
11
|
"url": "git+https://github.com/bronz3beard/react-marketing-tools.git"
|
|
@@ -32,7 +32,14 @@
|
|
|
32
32
|
"gdpr",
|
|
33
33
|
"utm",
|
|
34
34
|
"attribution",
|
|
35
|
-
"server-side-tagging"
|
|
35
|
+
"server-side-tagging",
|
|
36
|
+
"conversions-api",
|
|
37
|
+
"measurement-protocol",
|
|
38
|
+
"web-vitals",
|
|
39
|
+
"consent",
|
|
40
|
+
"privacy",
|
|
41
|
+
"nextjs",
|
|
42
|
+
"typescript"
|
|
36
43
|
],
|
|
37
44
|
"main": "./dist/index.js",
|
|
38
45
|
"types": "./dist/index.d.ts",
|
|
@@ -69,6 +76,7 @@
|
|
|
69
76
|
"scripts": {
|
|
70
77
|
"build": "vite build && tsc -p tsconfig.build.json",
|
|
71
78
|
"build:watch": "vite build --watch",
|
|
79
|
+
"check:api-summary": "node scripts/check-api-summary.mjs",
|
|
72
80
|
"check:links": "node scripts/check-links.mjs",
|
|
73
81
|
"check:package": "publint --strict && attw --pack . --profile esm-only",
|
|
74
82
|
"check:size": "node scripts/check-size.mjs",
|