@jwiedeman/gtm-kit-remix 1.0.1
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 +440 -0
- package/dist/index.cjs +30 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +262 -0
- package/dist/index.d.ts +262 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
package/README.md
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
# @react-gtm-kit/remix
|
|
2
|
+
|
|
3
|
+
[](https://github.com/jwiedeman/react-gtm-kit/actions/workflows/ci.yml)
|
|
4
|
+
[](https://codecov.io/gh/jwiedeman/react-gtm-kit)
|
|
5
|
+
[](https://www.npmjs.com/package/@react-gtm-kit/remix)
|
|
6
|
+
[](https://bundlephobia.com/package/@react-gtm-kit/remix)
|
|
7
|
+
[](https://www.typescriptlang.org/)
|
|
8
|
+
[](https://opensource.org/licenses/MIT)
|
|
9
|
+
[](https://remix.run/)
|
|
10
|
+
|
|
11
|
+
**Remix adapter for Google Tag Manager. Route tracking included.**
|
|
12
|
+
|
|
13
|
+
The Remix adapter for GTM Kit - provides components and hooks optimized for Remix applications.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install @react-gtm-kit/core @react-gtm-kit/remix
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
yarn add @react-gtm-kit/core @react-gtm-kit/remix
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pnpm add @react-gtm-kit/core @react-gtm-kit/remix
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
### Step 1: Add to Root
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
// app/root.tsx
|
|
39
|
+
import { GtmProvider, GtmScripts, useTrackPageViews } from '@react-gtm-kit/remix';
|
|
40
|
+
|
|
41
|
+
function PageViewTracker() {
|
|
42
|
+
useTrackPageViews();
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default function App() {
|
|
47
|
+
return (
|
|
48
|
+
<html>
|
|
49
|
+
<head>
|
|
50
|
+
<Meta />
|
|
51
|
+
<Links />
|
|
52
|
+
<GtmScripts containers="GTM-XXXXXX" />
|
|
53
|
+
</head>
|
|
54
|
+
<body>
|
|
55
|
+
<GtmProvider config={{ containers: 'GTM-XXXXXX' }}>
|
|
56
|
+
<PageViewTracker />
|
|
57
|
+
<Outlet />
|
|
58
|
+
</GtmProvider>
|
|
59
|
+
<Scripts />
|
|
60
|
+
</body>
|
|
61
|
+
</html>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Step 2: Push Events
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
// app/routes/products.$id.tsx
|
|
70
|
+
import { useGtmPush } from '@react-gtm-kit/remix';
|
|
71
|
+
|
|
72
|
+
export default function ProductPage() {
|
|
73
|
+
const push = useGtmPush();
|
|
74
|
+
|
|
75
|
+
const handlePurchase = () => {
|
|
76
|
+
push({ event: 'purchase', value: 49.99 });
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return <button onClick={handlePurchase}>Buy Now</button>;
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**That's it!** GTM is running with automatic page tracking.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Features
|
|
88
|
+
|
|
89
|
+
| Feature | Description |
|
|
90
|
+
|---------|-------------|
|
|
91
|
+
| **Remix Native** | Built specifically for Remix |
|
|
92
|
+
| **Auto Page Tracking** | `useTrackPageViews` hook for route changes |
|
|
93
|
+
| **Server Scripts** | `GtmScripts` component for SSR |
|
|
94
|
+
| **React Router v6** | Uses Remix's routing system |
|
|
95
|
+
| **TypeScript** | Full type definitions included |
|
|
96
|
+
| **Consent Mode v2** | Built-in GDPR compliance |
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Components
|
|
101
|
+
|
|
102
|
+
### `<GtmScripts />`
|
|
103
|
+
|
|
104
|
+
Server-side component that renders GTM script tags. Place in your document head.
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
import { GtmScripts } from '@react-gtm-kit/remix';
|
|
108
|
+
|
|
109
|
+
<GtmScripts
|
|
110
|
+
containers="GTM-XXXXXX"
|
|
111
|
+
host="https://www.googletagmanager.com" // optional
|
|
112
|
+
dataLayerName="dataLayer" // optional
|
|
113
|
+
scriptAttributes={{ nonce: '...' }} // optional: CSP
|
|
114
|
+
/>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### `<GtmProvider />`
|
|
118
|
+
|
|
119
|
+
Client-side provider that manages GTM state and provides context.
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
import { GtmProvider } from '@react-gtm-kit/remix';
|
|
123
|
+
|
|
124
|
+
<GtmProvider
|
|
125
|
+
config={{ containers: 'GTM-XXXXXX' }}
|
|
126
|
+
onBeforeInit={(client) => {
|
|
127
|
+
// Set consent defaults here
|
|
128
|
+
}}
|
|
129
|
+
onAfterInit={(client) => {
|
|
130
|
+
// Called after GTM initializes
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
{children}
|
|
134
|
+
</GtmProvider>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Hooks
|
|
140
|
+
|
|
141
|
+
### `useTrackPageViews()`
|
|
142
|
+
|
|
143
|
+
Automatically tracks page views on route changes.
|
|
144
|
+
|
|
145
|
+
```tsx
|
|
146
|
+
import { useTrackPageViews } from '@react-gtm-kit/remix';
|
|
147
|
+
|
|
148
|
+
function PageViewTracker() {
|
|
149
|
+
useTrackPageViews({
|
|
150
|
+
eventName: 'page_view', // default
|
|
151
|
+
trackInitialPageView: true, // default
|
|
152
|
+
customData: { app_version: '1.0' }, // optional
|
|
153
|
+
transformEvent: (data) => ({ // optional
|
|
154
|
+
...data,
|
|
155
|
+
user_id: getCurrentUserId()
|
|
156
|
+
})
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### `useGtmPush()`
|
|
164
|
+
|
|
165
|
+
Get the push function for sending events.
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
import { useGtmPush } from '@react-gtm-kit/remix';
|
|
169
|
+
|
|
170
|
+
function MyComponent() {
|
|
171
|
+
const push = useGtmPush();
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<button onClick={() => push({ event: 'click', button: 'cta' })}>
|
|
175
|
+
Click Me
|
|
176
|
+
</button>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### `useGtm()`
|
|
182
|
+
|
|
183
|
+
Get the full GTM context.
|
|
184
|
+
|
|
185
|
+
```tsx
|
|
186
|
+
import { useGtm } from '@react-gtm-kit/remix';
|
|
187
|
+
|
|
188
|
+
function MyComponent() {
|
|
189
|
+
const { push, client, updateConsent } = useGtm();
|
|
190
|
+
|
|
191
|
+
return <div>GTM Ready: {client.isInitialized() ? 'Yes' : 'No'}</div>;
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### `useGtmConsent()`
|
|
196
|
+
|
|
197
|
+
Access consent management functions.
|
|
198
|
+
|
|
199
|
+
```tsx
|
|
200
|
+
import { useGtmConsent } from '@react-gtm-kit/remix';
|
|
201
|
+
|
|
202
|
+
function CookieBanner() {
|
|
203
|
+
const { updateConsent } = useGtmConsent();
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<button onClick={() => updateConsent({ analytics_storage: 'granted' })}>
|
|
207
|
+
Accept
|
|
208
|
+
</button>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### `useGtmClient()`
|
|
214
|
+
|
|
215
|
+
Get the raw GTM client instance.
|
|
216
|
+
|
|
217
|
+
### `useGtmReady()`
|
|
218
|
+
|
|
219
|
+
Get a function that resolves when GTM is loaded.
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## Consent Mode v2 (GDPR)
|
|
224
|
+
|
|
225
|
+
```tsx
|
|
226
|
+
// app/root.tsx
|
|
227
|
+
import { GtmProvider, GtmScripts, useGtmConsent } from '@react-gtm-kit/remix';
|
|
228
|
+
import { consentPresets } from '@react-gtm-kit/core';
|
|
229
|
+
|
|
230
|
+
function CookieBanner() {
|
|
231
|
+
const { updateConsent } = useGtmConsent();
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<div className="cookie-banner">
|
|
235
|
+
<p>We use cookies to improve your experience.</p>
|
|
236
|
+
<button onClick={() => updateConsent({
|
|
237
|
+
ad_storage: 'granted',
|
|
238
|
+
analytics_storage: 'granted',
|
|
239
|
+
ad_user_data: 'granted',
|
|
240
|
+
ad_personalization: 'granted'
|
|
241
|
+
})}>
|
|
242
|
+
Accept All
|
|
243
|
+
</button>
|
|
244
|
+
<button onClick={() => updateConsent({
|
|
245
|
+
ad_storage: 'denied',
|
|
246
|
+
analytics_storage: 'denied',
|
|
247
|
+
ad_user_data: 'denied',
|
|
248
|
+
ad_personalization: 'denied'
|
|
249
|
+
})}>
|
|
250
|
+
Reject All
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export default function App() {
|
|
257
|
+
return (
|
|
258
|
+
<html>
|
|
259
|
+
<head>
|
|
260
|
+
<GtmScripts containers="GTM-XXXXXX" />
|
|
261
|
+
</head>
|
|
262
|
+
<body>
|
|
263
|
+
<GtmProvider
|
|
264
|
+
config={{ containers: 'GTM-XXXXXX' }}
|
|
265
|
+
onBeforeInit={(client) => {
|
|
266
|
+
// Deny by default for EU users
|
|
267
|
+
client.setConsentDefaults(consentPresets.eeaDefault, { region: ['EEA'] });
|
|
268
|
+
}}
|
|
269
|
+
>
|
|
270
|
+
<Outlet />
|
|
271
|
+
<CookieBanner />
|
|
272
|
+
</GtmProvider>
|
|
273
|
+
</body>
|
|
274
|
+
</html>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## CSP (Content Security Policy)
|
|
282
|
+
|
|
283
|
+
For strict CSP configurations, generate and pass a nonce:
|
|
284
|
+
|
|
285
|
+
```tsx
|
|
286
|
+
// app/root.tsx
|
|
287
|
+
import { GtmScripts } from '@react-gtm-kit/remix';
|
|
288
|
+
|
|
289
|
+
export async function loader() {
|
|
290
|
+
const nonce = generateNonce(); // Your nonce generation
|
|
291
|
+
return json({ nonce });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export default function App() {
|
|
295
|
+
const { nonce } = useLoaderData<typeof loader>();
|
|
296
|
+
|
|
297
|
+
return (
|
|
298
|
+
<html>
|
|
299
|
+
<head>
|
|
300
|
+
<GtmScripts
|
|
301
|
+
containers="GTM-XXXXXX"
|
|
302
|
+
scriptAttributes={{ nonce }}
|
|
303
|
+
/>
|
|
304
|
+
</head>
|
|
305
|
+
<body>
|
|
306
|
+
{/* ... */}
|
|
307
|
+
</body>
|
|
308
|
+
</html>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
---
|
|
314
|
+
|
|
315
|
+
## Multiple Containers
|
|
316
|
+
|
|
317
|
+
```tsx
|
|
318
|
+
<GtmScripts
|
|
319
|
+
containers={[
|
|
320
|
+
{ id: 'GTM-MAIN' },
|
|
321
|
+
{ id: 'GTM-ADS', queryParams: { gtm_auth: 'abc', gtm_preview: 'env-1' } }
|
|
322
|
+
]}
|
|
323
|
+
/>
|
|
324
|
+
|
|
325
|
+
<GtmProvider
|
|
326
|
+
config={{
|
|
327
|
+
containers: [
|
|
328
|
+
{ id: 'GTM-MAIN' },
|
|
329
|
+
{ id: 'GTM-ADS', queryParams: { gtm_auth: 'abc', gtm_preview: 'env-1' } }
|
|
330
|
+
]
|
|
331
|
+
}}
|
|
332
|
+
>
|
|
333
|
+
{children}
|
|
334
|
+
</GtmProvider>
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
## Full Example
|
|
340
|
+
|
|
341
|
+
```tsx
|
|
342
|
+
// app/root.tsx
|
|
343
|
+
import {
|
|
344
|
+
Links,
|
|
345
|
+
Meta,
|
|
346
|
+
Outlet,
|
|
347
|
+
Scripts,
|
|
348
|
+
ScrollRestoration,
|
|
349
|
+
} from "@remix-run/react";
|
|
350
|
+
import {
|
|
351
|
+
GtmProvider,
|
|
352
|
+
GtmScripts,
|
|
353
|
+
useTrackPageViews,
|
|
354
|
+
useGtmConsent
|
|
355
|
+
} from '@react-gtm-kit/remix';
|
|
356
|
+
import { consentPresets } from '@react-gtm-kit/core';
|
|
357
|
+
|
|
358
|
+
const GTM_ID = 'GTM-XXXXXX';
|
|
359
|
+
|
|
360
|
+
function PageViewTracker() {
|
|
361
|
+
useTrackPageViews();
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function CookieBanner() {
|
|
366
|
+
const { updateConsent } = useGtmConsent();
|
|
367
|
+
|
|
368
|
+
return (
|
|
369
|
+
<div className="fixed bottom-0 left-0 right-0 bg-gray-800 text-white p-4">
|
|
370
|
+
<div className="max-w-7xl mx-auto flex justify-between items-center">
|
|
371
|
+
<p>We use cookies to improve your experience.</p>
|
|
372
|
+
<div className="space-x-2">
|
|
373
|
+
<button
|
|
374
|
+
onClick={() => updateConsent({
|
|
375
|
+
ad_storage: 'granted',
|
|
376
|
+
analytics_storage: 'granted',
|
|
377
|
+
ad_user_data: 'granted',
|
|
378
|
+
ad_personalization: 'granted'
|
|
379
|
+
})}
|
|
380
|
+
className="bg-blue-500 px-4 py-2 rounded"
|
|
381
|
+
>
|
|
382
|
+
Accept All
|
|
383
|
+
</button>
|
|
384
|
+
<button
|
|
385
|
+
onClick={() => updateConsent({
|
|
386
|
+
ad_storage: 'denied',
|
|
387
|
+
analytics_storage: 'denied'
|
|
388
|
+
})}
|
|
389
|
+
className="bg-gray-600 px-4 py-2 rounded"
|
|
390
|
+
>
|
|
391
|
+
Reject
|
|
392
|
+
</button>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export default function App() {
|
|
400
|
+
return (
|
|
401
|
+
<html lang="en">
|
|
402
|
+
<head>
|
|
403
|
+
<meta charSet="utf-8" />
|
|
404
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
405
|
+
<Meta />
|
|
406
|
+
<Links />
|
|
407
|
+
<GtmScripts containers={GTM_ID} />
|
|
408
|
+
</head>
|
|
409
|
+
<body>
|
|
410
|
+
<GtmProvider
|
|
411
|
+
config={{ containers: GTM_ID }}
|
|
412
|
+
onBeforeInit={(client) => {
|
|
413
|
+
client.setConsentDefaults(consentPresets.eeaDefault, { region: ['EEA'] });
|
|
414
|
+
}}
|
|
415
|
+
>
|
|
416
|
+
<PageViewTracker />
|
|
417
|
+
<Outlet />
|
|
418
|
+
<CookieBanner />
|
|
419
|
+
</GtmProvider>
|
|
420
|
+
<ScrollRestoration />
|
|
421
|
+
<Scripts />
|
|
422
|
+
</body>
|
|
423
|
+
</html>
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
430
|
+
## Requirements
|
|
431
|
+
|
|
432
|
+
- Remix 2.0+
|
|
433
|
+
- React 18+
|
|
434
|
+
- `@react-gtm-kit/core` (peer dependency)
|
|
435
|
+
|
|
436
|
+
---
|
|
437
|
+
|
|
438
|
+
## License
|
|
439
|
+
|
|
440
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var react = require('react');
|
|
4
|
+
var gtmKit = require('@jwiedeman/gtm-kit');
|
|
5
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
6
|
+
var react$1 = require('@remix-run/react');
|
|
7
|
+
|
|
8
|
+
var g=react.createContext(null);function S({config:e,children:r,onBeforeInit:i,onAfterInit:o}){let s=react.useRef(null),n=react.useRef(!1);s.current||(s.current=gtmKit.createGtmClient(e));let t=s.current;react.useEffect(()=>{if(!n.current)return i&&i(t),t.init(),n.current=!0,o&&o(t),()=>{let a=setTimeout(()=>{document.querySelector("[data-gtm-kit-provider]")||(t.teardown(),s.current=null,n.current=!1);},100);clearTimeout(a);}},[t,i,o]);let p=react.useMemo(()=>({client:t,push:a=>t.push(a),setConsentDefaults:(a,c)=>t.setConsentDefaults(a,c),updateConsent:(a,c)=>t.updateConsent(a,c),whenReady:()=>t.whenReady()}),[t]);return jsxRuntime.jsx(g.Provider,{value:p,children:jsxRuntime.jsx("div",{"data-gtm-kit-provider":"",style:{display:"contents"},children:r})})}var u=()=>{let e=react.useContext(g);if(!e)throw new Error('[gtm-kit] useGtm() was called outside of a GtmProvider. Make sure to wrap your app with <GtmProvider config={{ containers: "GTM-XXXXXX" }}>.');return e},V=()=>u(),d=()=>u().push,D=()=>{let{setConsentDefaults:e,updateConsent:r}=u();return {setConsentDefaults:e,updateConsent:r}},k=()=>u().client,L=()=>u().whenReady;function T(e={}){let{eventName:r="page_view",trackInitialPageView:i=!0,customData:o={},transformEvent:s}=e,n=react$1.useLocation(),t=d(),p=react.useRef(null),a=react.useRef(!0);react.useEffect(()=>{let c=n.pathname+n.search+n.hash;if(c===p.current)return;if(a.current&&!i){a.current=!1,p.current=c;return}a.current=!1,p.current=c;let l={event:r,page_path:n.pathname,page_search:n.search,page_hash:n.hash,page_url:typeof window!="undefined"?window.location.href:c,...o},f=s?s(l):l;t(f);},[n,t,r,i,o,s]);}function m(e){return e.replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(/"/g,'\\"').replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/</g,"\\x3c").replace(/>/g,"\\x3e").replace(/\u2028/g,"\\u2028").replace(/\u2029/g,"\\u2029")}function E(e){return typeof e=="string"?[{id:e}]:Array.isArray(e)?e.map(r=>typeof r=="string"?{id:r}:r):[e]}function I(e,r,i,o){let s=r.endsWith("/")?r.slice(0,-1):r,n=new URLSearchParams;if(n.set("id",e),i!=="dataLayer"&&n.set("l",i),o)for(let[t,p]of Object.entries(o))t!=="id"&&t!=="l"&&n.set(t,String(p));return `${s}/gtm.js?${n.toString()}`}function N({containers:e,host:r="https://www.googletagmanager.com",dataLayerName:i="dataLayer",scriptAttributes:o={}}){let s=E(e),n=m(i),t=o.nonce?m(o.nonce):"",p=`
|
|
9
|
+
window['${n}'] = window['${n}'] || [];
|
|
10
|
+
${s.map(c=>{let l=m(c.id);return `
|
|
11
|
+
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
|
12
|
+
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
|
13
|
+
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
|
14
|
+
'${m(I(c.id,r,i,c.queryParams))}';${t?`j.nonce='${t}';`:""}f.parentNode.insertBefore(j,f);
|
|
15
|
+
})(window,document,'script','${n}','${l}');
|
|
16
|
+
`}).join(`
|
|
17
|
+
`)}
|
|
18
|
+
`.trim(),a=gtmKit.createNoscriptMarkup(s,{host:r});return jsxRuntime.jsxs(jsxRuntime.Fragment,{children:[jsxRuntime.jsx("script",{...o,dangerouslySetInnerHTML:{__html:p}}),jsxRuntime.jsx("noscript",{dangerouslySetInnerHTML:{__html:a}})]})}
|
|
19
|
+
|
|
20
|
+
exports.GtmContext = g;
|
|
21
|
+
exports.GtmProvider = S;
|
|
22
|
+
exports.GtmScripts = N;
|
|
23
|
+
exports.useGtm = V;
|
|
24
|
+
exports.useGtmClient = k;
|
|
25
|
+
exports.useGtmConsent = D;
|
|
26
|
+
exports.useGtmPush = d;
|
|
27
|
+
exports.useGtmReady = L;
|
|
28
|
+
exports.useTrackPageViews = T;
|
|
29
|
+
//# sourceMappingURL=out.js.map
|
|
30
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/provider.tsx","../src/route-tracker.tsx","../src/scripts.tsx"],"names":["createContext","useContext","useEffect","useRef","useMemo","createGtmClient","jsx","GtmContext","GtmProvider","config","children","onBeforeInit","onAfterInit","clientRef","initializedRef","client","timer","contextValue","value","state","options","useGtmContext","context","useGtm","useGtmPush","useGtmConsent","setConsentDefaults","updateConsent","useGtmClient","useGtmReady","useLocation","useTrackPageViews","eventName","trackInitialPageView","customData","transformEvent","location","push","lastPathRef","isFirstRenderRef","currentPath","pageViewData","eventData","createNoscriptMarkup","Fragment","jsxs","escapeJsString","normalizeContainers","containers","c","buildGtmScriptUrl","containerId","host","dataLayerName","queryParams","normalizedHost","params","key","GtmScripts","scriptAttributes","containerConfigs","safeDataLayerName","safeNonce","inlineScript","safeContainerId","noscriptHtml"],"mappings":"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","sourcesContent":["import React, { createContext, useContext, useEffect, useRef, useMemo, type ReactNode } from 'react';\nimport {\n createGtmClient,\n type ConsentRegionOptions,\n type ConsentState,\n type CreateGtmClientOptions,\n type DataLayerValue,\n type GtmClient,\n type ScriptLoadState\n} from '@jwiedeman/gtm-kit';\n\n/**\n * Props for the GTM Provider component.\n */\nexport interface GtmProviderProps {\n /** GTM client configuration */\n config: CreateGtmClientOptions;\n\n /** Child components */\n children: ReactNode;\n\n /**\n * Callback executed before GTM initialization.\n * Use this to set consent defaults.\n */\n onBeforeInit?: (client: GtmClient) => void;\n\n /**\n * Callback executed after GTM initialization.\n */\n onAfterInit?: (client: GtmClient) => void;\n}\n\n/**\n * The GTM context value containing all GTM functionality.\n */\nexport interface GtmContextValue {\n /** The underlying GTM client instance */\n client: GtmClient;\n /** Push a value to the data layer */\n push: (value: DataLayerValue) => void;\n /** Set consent defaults (must be called before init) */\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Update consent state */\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Returns a promise that resolves when all GTM scripts are loaded */\n whenReady: () => Promise<ScriptLoadState[]>;\n}\n\n/**\n * Consent-specific API subset.\n */\nexport interface GtmConsentApi {\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n}\n\n/**\n * The GTM context for Remix.\n */\nexport const GtmContext = createContext<GtmContextValue | null>(null);\n\n/**\n * GTM Provider component for Remix.\n * Handles StrictMode correctly and provides GTM context to children.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head />\n * <body>\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <Outlet />\n * </GtmProvider>\n * </body>\n * </html>\n * );\n * }\n * ```\n */\nexport function GtmProvider({ config, children, onBeforeInit, onAfterInit }: GtmProviderProps): React.ReactElement {\n // Create client once and store in ref to survive StrictMode remounts\n const clientRef = useRef<GtmClient | null>(null);\n const initializedRef = useRef(false);\n\n // Create client on first render only\n if (!clientRef.current) {\n clientRef.current = createGtmClient(config);\n }\n\n const client = clientRef.current;\n\n // Initialize GTM (handles StrictMode correctly)\n useEffect(() => {\n // Skip if already initialized (StrictMode protection)\n if (initializedRef.current) {\n return;\n }\n\n // Call onBeforeInit hook for consent defaults\n if (onBeforeInit) {\n onBeforeInit(client);\n }\n\n // Initialize GTM\n client.init();\n initializedRef.current = true;\n\n // Call onAfterInit hook\n if (onAfterInit) {\n onAfterInit(client);\n }\n\n // Cleanup on unmount\n return () => {\n // Don't teardown immediately in StrictMode\n // Only teardown if we're truly unmounting\n const timer = setTimeout(() => {\n if (!document.querySelector('[data-gtm-kit-provider]')) {\n client.teardown();\n clientRef.current = null;\n initializedRef.current = false;\n }\n }, 100);\n\n // Clear the timeout on cleanup\n clearTimeout(timer);\n };\n }, [client, onBeforeInit, onAfterInit]);\n\n // Memoize context value\n const contextValue = useMemo<GtmContextValue>(\n () => ({\n client,\n push: (value: DataLayerValue) => client.push(value),\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) =>\n client.setConsentDefaults(state, options),\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => client.updateConsent(state, options),\n whenReady: () => client.whenReady()\n }),\n [client]\n );\n\n return (\n <GtmContext.Provider value={contextValue}>\n <div data-gtm-kit-provider=\"\" style={{ display: 'contents' }}>\n {children}\n </div>\n </GtmContext.Provider>\n );\n}\n\n/**\n * Internal helper to get the GTM context with proper error handling.\n */\nconst useGtmContext = (): GtmContextValue => {\n const context = useContext(GtmContext);\n if (!context) {\n throw new Error(\n '[gtm-kit] useGtm() was called outside of a GtmProvider. ' +\n 'Make sure to wrap your app with <GtmProvider config={{ containers: \"GTM-XXXXXX\" }}>.'\n );\n }\n return context;\n};\n\n/**\n * Hook to access the full GTM context.\n *\n * @example\n * ```tsx\n * import { useGtm } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const { push, client } = useGtm();\n *\n * return (\n * <button onClick={() => push({ event: 'click' })}>\n * Track\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtm = (): GtmContextValue => {\n return useGtmContext();\n};\n\n/**\n * Hook to get just the push function.\n *\n * @example\n * ```tsx\n * import { useGtmPush } from '@jwiedeman/gtm-kit-remix';\n *\n * function BuyButton() {\n * const push = useGtmPush();\n *\n * return (\n * <button onClick={() => push({ event: 'purchase', value: 99 })}>\n * Buy\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmPush = (): ((value: DataLayerValue) => void) => {\n return useGtmContext().push;\n};\n\n/**\n * Hook to access consent management functions.\n *\n * @example\n * ```tsx\n * import { useGtmConsent } from '@jwiedeman/gtm-kit-remix';\n *\n * function CookieBanner() {\n * const { updateConsent } = useGtmConsent();\n *\n * return (\n * <button onClick={() => updateConsent({ analytics_storage: 'granted' })}>\n * Accept\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmConsent = (): GtmConsentApi => {\n const { setConsentDefaults, updateConsent } = useGtmContext();\n return { setConsentDefaults, updateConsent };\n};\n\n/**\n * Hook to get the raw GTM client instance.\n *\n * @example\n * ```tsx\n * import { useGtmClient } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const client = useGtmClient();\n * return <div>{client.isInitialized() ? 'Ready' : 'Loading'}</div>;\n * }\n * ```\n */\nexport const useGtmClient = (): GtmClient => {\n return useGtmContext().client;\n};\n\n/**\n * Hook to get the whenReady function.\n *\n * @example\n * ```tsx\n * import { useGtmReady } from '@jwiedeman/gtm-kit-remix';\n * import { useEffect } from 'react';\n *\n * function MyComponent() {\n * const whenReady = useGtmReady();\n *\n * useEffect(() => {\n * whenReady().then(() => console.log('GTM ready!'));\n * }, [whenReady]);\n *\n * return <div>Loading...</div>;\n * }\n * ```\n */\nexport const useGtmReady = (): (() => Promise<ScriptLoadState[]>) => {\n return useGtmContext().whenReady;\n};\n","import { useEffect, useRef } from 'react';\nimport { useLocation } from '@remix-run/react';\nimport { useGtmPush } from './provider';\n\n/**\n * Options for the useTrackPageViews hook.\n */\nexport interface UseTrackPageViewsOptions {\n /**\n * The event name to use for page view events.\n * @default 'page_view'\n */\n eventName?: string;\n\n /**\n * Whether to track the initial page load.\n * @default true\n */\n trackInitialPageView?: boolean;\n\n /**\n * Custom data to include with each page view event.\n */\n customData?: Record<string, unknown>;\n\n /**\n * Callback to transform the page view event data before pushing.\n * Use this to add custom properties or modify the event.\n */\n transformEvent?: (data: PageViewData) => Record<string, unknown>;\n}\n\n/**\n * Data included with each page view event.\n */\nexport interface PageViewData {\n event: string;\n page_path: string;\n page_search: string;\n page_hash: string;\n page_url: string;\n [key: string]: unknown;\n}\n\n/**\n * Hook to automatically track page views on route changes.\n * Uses Remix's useLocation to detect navigation.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider, useTrackPageViews } from '@jwiedeman/gtm-kit-remix';\n *\n * function PageViewTracker() {\n * useTrackPageViews();\n * return null;\n * }\n *\n * export default function App() {\n * return (\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <PageViewTracker />\n * <Outlet />\n * </GtmProvider>\n * );\n * }\n * ```\n *\n * @example With custom options\n * ```tsx\n * useTrackPageViews({\n * eventName: 'virtual_page_view',\n * customData: { app_version: '1.0.0' },\n * transformEvent: (data) => ({\n * ...data,\n * user_id: getCurrentUserId()\n * })\n * });\n * ```\n */\nexport function useTrackPageViews(options: UseTrackPageViewsOptions = {}): void {\n const { eventName = 'page_view', trackInitialPageView = true, customData = {}, transformEvent } = options;\n\n const location = useLocation();\n const push = useGtmPush();\n const lastPathRef = useRef<string | null>(null);\n const isFirstRenderRef = useRef(true);\n\n useEffect(() => {\n const currentPath = location.pathname + location.search + location.hash;\n\n // Skip if this is the same path (prevents double-firing)\n if (currentPath === lastPathRef.current) {\n return;\n }\n\n // Skip initial page view if configured\n if (isFirstRenderRef.current && !trackInitialPageView) {\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n return;\n }\n\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n\n // Build page view data\n const pageViewData: PageViewData = {\n event: eventName,\n page_path: location.pathname,\n page_search: location.search,\n page_hash: location.hash,\n page_url: typeof window !== 'undefined' ? window.location.href : currentPath,\n ...customData\n };\n\n // Apply transform if provided\n const eventData = transformEvent ? transformEvent(pageViewData) : pageViewData;\n\n // Push to GTM\n push(eventData);\n }, [location, push, eventName, trackInitialPageView, customData, transformEvent]);\n}\n","import React from 'react';\nimport { createNoscriptMarkup, type ContainerConfigInput, type ContainerDescriptor } from '@jwiedeman/gtm-kit';\n\n/**\n * Escape a string for safe use in JavaScript string literals.\n * Prevents XSS when interpolating user-provided values into inline scripts.\n */\nfunction escapeJsString(value: string): string {\n return value\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/'/g, \"\\\\'\")\n .replace(/\"/g, '\\\\\"')\n .replace(/\\n/g, '\\\\n')\n .replace(/\\r/g, '\\\\r')\n .replace(/</g, '\\\\x3c')\n .replace(/>/g, '\\\\x3e')\n .replace(/\\u2028/g, '\\\\u2028')\n .replace(/\\u2029/g, '\\\\u2029');\n}\n\n/**\n * Props for the GtmScripts component.\n */\nexport interface GtmScriptsProps {\n /**\n * GTM container ID(s).\n */\n containers: ContainerConfigInput | ContainerConfigInput[];\n\n /**\n * Custom GTM host URL.\n * @default 'https://www.googletagmanager.com'\n */\n host?: string;\n\n /**\n * Custom dataLayer name.\n * @default 'dataLayer'\n */\n dataLayerName?: string;\n\n /**\n * Script attributes (e.g., nonce for CSP).\n */\n scriptAttributes?: Record<string, string>;\n}\n\n/**\n * Normalize container config to array format.\n */\nfunction normalizeContainers(containers: ContainerConfigInput | ContainerConfigInput[]): ContainerDescriptor[] {\n if (typeof containers === 'string') {\n return [{ id: containers }];\n }\n if (!Array.isArray(containers)) {\n return [containers];\n }\n return containers.map((c) => (typeof c === 'string' ? { id: c } : c));\n}\n\n/**\n * Server component that renders GTM script tags for Remix.\n * Use this in your root.tsx to add GTM scripts.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmScripts } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head>\n * <GtmScripts containers=\"GTM-XXXXXX\" />\n * </head>\n * <body>\n * <Outlet />\n * </body>\n * </html>\n * );\n * }\n * ```\n *\n * @example With CSP nonce\n * ```tsx\n * <GtmScripts\n * containers=\"GTM-XXXXXX\"\n * scriptAttributes={{ nonce: 'your-csp-nonce' }}\n * />\n * ```\n */\n/**\n * Build the GTM script URL for a container.\n */\nfunction buildGtmScriptUrl(\n containerId: string,\n host: string,\n dataLayerName: string,\n queryParams?: Record<string, string | number | boolean>\n): string {\n const normalizedHost = host.endsWith('/') ? host.slice(0, -1) : host;\n const params = new URLSearchParams();\n params.set('id', containerId);\n\n if (dataLayerName !== 'dataLayer') {\n params.set('l', dataLayerName);\n }\n\n if (queryParams) {\n for (const [key, value] of Object.entries(queryParams)) {\n if (key !== 'id' && key !== 'l') {\n params.set(key, String(value));\n }\n }\n }\n\n return `${normalizedHost}/gtm.js?${params.toString()}`;\n}\n\nexport function GtmScripts({\n containers,\n host = 'https://www.googletagmanager.com',\n dataLayerName = 'dataLayer',\n scriptAttributes = {}\n}: GtmScriptsProps): React.ReactElement {\n const containerConfigs = normalizeContainers(containers);\n\n // Escape values for safe use in JavaScript string literals\n const safeDataLayerName = escapeJsString(dataLayerName);\n const safeNonce = scriptAttributes.nonce ? escapeJsString(scriptAttributes.nonce) : '';\n\n // Generate inline script for dataLayer initialization and GTM loading\n const inlineScript = `\n window['${safeDataLayerName}'] = window['${safeDataLayerName}'] || [];\n ${containerConfigs\n .map((config) => {\n const safeContainerId = escapeJsString(config.id);\n const scriptSrc = escapeJsString(buildGtmScriptUrl(config.id, host, dataLayerName, config.queryParams));\n return `\n (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':\n new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],\n j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=\n '${scriptSrc}';${safeNonce ? `j.nonce='${safeNonce}';` : ''}f.parentNode.insertBefore(j,f);\n })(window,document,'script','${safeDataLayerName}','${safeContainerId}');\n `;\n })\n .join('\\n')}\n `.trim();\n\n // Generate noscript HTML using the core package\n const noscriptHtml = createNoscriptMarkup(containerConfigs, { host });\n\n return (\n <>\n <script {...scriptAttributes} dangerouslySetInnerHTML={{ __html: inlineScript }} />\n <noscript dangerouslySetInnerHTML={{ __html: noscriptHtml }} />\n </>\n );\n}\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import React, { ReactNode } from 'react';
|
|
2
|
+
import { CreateGtmClientOptions, GtmClient, DataLayerValue, ConsentState, ConsentRegionOptions, ScriptLoadState, ContainerConfigInput } from '@jwiedeman/gtm-kit';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Props for the GTM Provider component.
|
|
6
|
+
*/
|
|
7
|
+
interface GtmProviderProps {
|
|
8
|
+
/** GTM client configuration */
|
|
9
|
+
config: CreateGtmClientOptions;
|
|
10
|
+
/** Child components */
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
/**
|
|
13
|
+
* Callback executed before GTM initialization.
|
|
14
|
+
* Use this to set consent defaults.
|
|
15
|
+
*/
|
|
16
|
+
onBeforeInit?: (client: GtmClient) => void;
|
|
17
|
+
/**
|
|
18
|
+
* Callback executed after GTM initialization.
|
|
19
|
+
*/
|
|
20
|
+
onAfterInit?: (client: GtmClient) => void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The GTM context value containing all GTM functionality.
|
|
24
|
+
*/
|
|
25
|
+
interface GtmContextValue {
|
|
26
|
+
/** The underlying GTM client instance */
|
|
27
|
+
client: GtmClient;
|
|
28
|
+
/** Push a value to the data layer */
|
|
29
|
+
push: (value: DataLayerValue) => void;
|
|
30
|
+
/** Set consent defaults (must be called before init) */
|
|
31
|
+
setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;
|
|
32
|
+
/** Update consent state */
|
|
33
|
+
updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;
|
|
34
|
+
/** Returns a promise that resolves when all GTM scripts are loaded */
|
|
35
|
+
whenReady: () => Promise<ScriptLoadState[]>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Consent-specific API subset.
|
|
39
|
+
*/
|
|
40
|
+
interface GtmConsentApi {
|
|
41
|
+
setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;
|
|
42
|
+
updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The GTM context for Remix.
|
|
46
|
+
*/
|
|
47
|
+
declare const GtmContext: React.Context<GtmContextValue | null>;
|
|
48
|
+
/**
|
|
49
|
+
* GTM Provider component for Remix.
|
|
50
|
+
* Handles StrictMode correctly and provides GTM context to children.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```tsx
|
|
54
|
+
* // app/root.tsx
|
|
55
|
+
* import { GtmProvider } from '@jwiedeman/gtm-kit-remix';
|
|
56
|
+
*
|
|
57
|
+
* export default function App() {
|
|
58
|
+
* return (
|
|
59
|
+
* <html>
|
|
60
|
+
* <head />
|
|
61
|
+
* <body>
|
|
62
|
+
* <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>
|
|
63
|
+
* <Outlet />
|
|
64
|
+
* </GtmProvider>
|
|
65
|
+
* </body>
|
|
66
|
+
* </html>
|
|
67
|
+
* );
|
|
68
|
+
* }
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
declare function GtmProvider({ config, children, onBeforeInit, onAfterInit }: GtmProviderProps): React.ReactElement;
|
|
72
|
+
/**
|
|
73
|
+
* Hook to access the full GTM context.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```tsx
|
|
77
|
+
* import { useGtm } from '@jwiedeman/gtm-kit-remix';
|
|
78
|
+
*
|
|
79
|
+
* function MyComponent() {
|
|
80
|
+
* const { push, client } = useGtm();
|
|
81
|
+
*
|
|
82
|
+
* return (
|
|
83
|
+
* <button onClick={() => push({ event: 'click' })}>
|
|
84
|
+
* Track
|
|
85
|
+
* </button>
|
|
86
|
+
* );
|
|
87
|
+
* }
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
declare const useGtm: () => GtmContextValue;
|
|
91
|
+
/**
|
|
92
|
+
* Hook to get just the push function.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```tsx
|
|
96
|
+
* import { useGtmPush } from '@jwiedeman/gtm-kit-remix';
|
|
97
|
+
*
|
|
98
|
+
* function BuyButton() {
|
|
99
|
+
* const push = useGtmPush();
|
|
100
|
+
*
|
|
101
|
+
* return (
|
|
102
|
+
* <button onClick={() => push({ event: 'purchase', value: 99 })}>
|
|
103
|
+
* Buy
|
|
104
|
+
* </button>
|
|
105
|
+
* );
|
|
106
|
+
* }
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
declare const useGtmPush: () => ((value: DataLayerValue) => void);
|
|
110
|
+
/**
|
|
111
|
+
* Hook to access consent management functions.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```tsx
|
|
115
|
+
* import { useGtmConsent } from '@jwiedeman/gtm-kit-remix';
|
|
116
|
+
*
|
|
117
|
+
* function CookieBanner() {
|
|
118
|
+
* const { updateConsent } = useGtmConsent();
|
|
119
|
+
*
|
|
120
|
+
* return (
|
|
121
|
+
* <button onClick={() => updateConsent({ analytics_storage: 'granted' })}>
|
|
122
|
+
* Accept
|
|
123
|
+
* </button>
|
|
124
|
+
* );
|
|
125
|
+
* }
|
|
126
|
+
* ```
|
|
127
|
+
*/
|
|
128
|
+
declare const useGtmConsent: () => GtmConsentApi;
|
|
129
|
+
/**
|
|
130
|
+
* Hook to get the raw GTM client instance.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```tsx
|
|
134
|
+
* import { useGtmClient } from '@jwiedeman/gtm-kit-remix';
|
|
135
|
+
*
|
|
136
|
+
* function MyComponent() {
|
|
137
|
+
* const client = useGtmClient();
|
|
138
|
+
* return <div>{client.isInitialized() ? 'Ready' : 'Loading'}</div>;
|
|
139
|
+
* }
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
declare const useGtmClient: () => GtmClient;
|
|
143
|
+
/**
|
|
144
|
+
* Hook to get the whenReady function.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```tsx
|
|
148
|
+
* import { useGtmReady } from '@jwiedeman/gtm-kit-remix';
|
|
149
|
+
* import { useEffect } from 'react';
|
|
150
|
+
*
|
|
151
|
+
* function MyComponent() {
|
|
152
|
+
* const whenReady = useGtmReady();
|
|
153
|
+
*
|
|
154
|
+
* useEffect(() => {
|
|
155
|
+
* whenReady().then(() => console.log('GTM ready!'));
|
|
156
|
+
* }, [whenReady]);
|
|
157
|
+
*
|
|
158
|
+
* return <div>Loading...</div>;
|
|
159
|
+
* }
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
declare const useGtmReady: () => (() => Promise<ScriptLoadState[]>);
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Options for the useTrackPageViews hook.
|
|
166
|
+
*/
|
|
167
|
+
interface UseTrackPageViewsOptions {
|
|
168
|
+
/**
|
|
169
|
+
* The event name to use for page view events.
|
|
170
|
+
* @default 'page_view'
|
|
171
|
+
*/
|
|
172
|
+
eventName?: string;
|
|
173
|
+
/**
|
|
174
|
+
* Whether to track the initial page load.
|
|
175
|
+
* @default true
|
|
176
|
+
*/
|
|
177
|
+
trackInitialPageView?: boolean;
|
|
178
|
+
/**
|
|
179
|
+
* Custom data to include with each page view event.
|
|
180
|
+
*/
|
|
181
|
+
customData?: Record<string, unknown>;
|
|
182
|
+
/**
|
|
183
|
+
* Callback to transform the page view event data before pushing.
|
|
184
|
+
* Use this to add custom properties or modify the event.
|
|
185
|
+
*/
|
|
186
|
+
transformEvent?: (data: PageViewData) => Record<string, unknown>;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Data included with each page view event.
|
|
190
|
+
*/
|
|
191
|
+
interface PageViewData {
|
|
192
|
+
event: string;
|
|
193
|
+
page_path: string;
|
|
194
|
+
page_search: string;
|
|
195
|
+
page_hash: string;
|
|
196
|
+
page_url: string;
|
|
197
|
+
[key: string]: unknown;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Hook to automatically track page views on route changes.
|
|
201
|
+
* Uses Remix's useLocation to detect navigation.
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* ```tsx
|
|
205
|
+
* // app/root.tsx
|
|
206
|
+
* import { GtmProvider, useTrackPageViews } from '@jwiedeman/gtm-kit-remix';
|
|
207
|
+
*
|
|
208
|
+
* function PageViewTracker() {
|
|
209
|
+
* useTrackPageViews();
|
|
210
|
+
* return null;
|
|
211
|
+
* }
|
|
212
|
+
*
|
|
213
|
+
* export default function App() {
|
|
214
|
+
* return (
|
|
215
|
+
* <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>
|
|
216
|
+
* <PageViewTracker />
|
|
217
|
+
* <Outlet />
|
|
218
|
+
* </GtmProvider>
|
|
219
|
+
* );
|
|
220
|
+
* }
|
|
221
|
+
* ```
|
|
222
|
+
*
|
|
223
|
+
* @example With custom options
|
|
224
|
+
* ```tsx
|
|
225
|
+
* useTrackPageViews({
|
|
226
|
+
* eventName: 'virtual_page_view',
|
|
227
|
+
* customData: { app_version: '1.0.0' },
|
|
228
|
+
* transformEvent: (data) => ({
|
|
229
|
+
* ...data,
|
|
230
|
+
* user_id: getCurrentUserId()
|
|
231
|
+
* })
|
|
232
|
+
* });
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
235
|
+
declare function useTrackPageViews(options?: UseTrackPageViewsOptions): void;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Props for the GtmScripts component.
|
|
239
|
+
*/
|
|
240
|
+
interface GtmScriptsProps {
|
|
241
|
+
/**
|
|
242
|
+
* GTM container ID(s).
|
|
243
|
+
*/
|
|
244
|
+
containers: ContainerConfigInput | ContainerConfigInput[];
|
|
245
|
+
/**
|
|
246
|
+
* Custom GTM host URL.
|
|
247
|
+
* @default 'https://www.googletagmanager.com'
|
|
248
|
+
*/
|
|
249
|
+
host?: string;
|
|
250
|
+
/**
|
|
251
|
+
* Custom dataLayer name.
|
|
252
|
+
* @default 'dataLayer'
|
|
253
|
+
*/
|
|
254
|
+
dataLayerName?: string;
|
|
255
|
+
/**
|
|
256
|
+
* Script attributes (e.g., nonce for CSP).
|
|
257
|
+
*/
|
|
258
|
+
scriptAttributes?: Record<string, string>;
|
|
259
|
+
}
|
|
260
|
+
declare function GtmScripts({ containers, host, dataLayerName, scriptAttributes }: GtmScriptsProps): React.ReactElement;
|
|
261
|
+
|
|
262
|
+
export { GtmConsentApi, GtmContext, GtmContextValue, GtmProvider, GtmProviderProps, GtmScripts, GtmScriptsProps, UseTrackPageViewsOptions, useGtm, useGtmClient, useGtmConsent, useGtmPush, useGtmReady, useTrackPageViews };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import React, { ReactNode } from 'react';
|
|
2
|
+
import { CreateGtmClientOptions, GtmClient, DataLayerValue, ConsentState, ConsentRegionOptions, ScriptLoadState, ContainerConfigInput } from '@jwiedeman/gtm-kit';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Props for the GTM Provider component.
|
|
6
|
+
*/
|
|
7
|
+
interface GtmProviderProps {
|
|
8
|
+
/** GTM client configuration */
|
|
9
|
+
config: CreateGtmClientOptions;
|
|
10
|
+
/** Child components */
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
/**
|
|
13
|
+
* Callback executed before GTM initialization.
|
|
14
|
+
* Use this to set consent defaults.
|
|
15
|
+
*/
|
|
16
|
+
onBeforeInit?: (client: GtmClient) => void;
|
|
17
|
+
/**
|
|
18
|
+
* Callback executed after GTM initialization.
|
|
19
|
+
*/
|
|
20
|
+
onAfterInit?: (client: GtmClient) => void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The GTM context value containing all GTM functionality.
|
|
24
|
+
*/
|
|
25
|
+
interface GtmContextValue {
|
|
26
|
+
/** The underlying GTM client instance */
|
|
27
|
+
client: GtmClient;
|
|
28
|
+
/** Push a value to the data layer */
|
|
29
|
+
push: (value: DataLayerValue) => void;
|
|
30
|
+
/** Set consent defaults (must be called before init) */
|
|
31
|
+
setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;
|
|
32
|
+
/** Update consent state */
|
|
33
|
+
updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;
|
|
34
|
+
/** Returns a promise that resolves when all GTM scripts are loaded */
|
|
35
|
+
whenReady: () => Promise<ScriptLoadState[]>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Consent-specific API subset.
|
|
39
|
+
*/
|
|
40
|
+
interface GtmConsentApi {
|
|
41
|
+
setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;
|
|
42
|
+
updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The GTM context for Remix.
|
|
46
|
+
*/
|
|
47
|
+
declare const GtmContext: React.Context<GtmContextValue | null>;
|
|
48
|
+
/**
|
|
49
|
+
* GTM Provider component for Remix.
|
|
50
|
+
* Handles StrictMode correctly and provides GTM context to children.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```tsx
|
|
54
|
+
* // app/root.tsx
|
|
55
|
+
* import { GtmProvider } from '@jwiedeman/gtm-kit-remix';
|
|
56
|
+
*
|
|
57
|
+
* export default function App() {
|
|
58
|
+
* return (
|
|
59
|
+
* <html>
|
|
60
|
+
* <head />
|
|
61
|
+
* <body>
|
|
62
|
+
* <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>
|
|
63
|
+
* <Outlet />
|
|
64
|
+
* </GtmProvider>
|
|
65
|
+
* </body>
|
|
66
|
+
* </html>
|
|
67
|
+
* );
|
|
68
|
+
* }
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
declare function GtmProvider({ config, children, onBeforeInit, onAfterInit }: GtmProviderProps): React.ReactElement;
|
|
72
|
+
/**
|
|
73
|
+
* Hook to access the full GTM context.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```tsx
|
|
77
|
+
* import { useGtm } from '@jwiedeman/gtm-kit-remix';
|
|
78
|
+
*
|
|
79
|
+
* function MyComponent() {
|
|
80
|
+
* const { push, client } = useGtm();
|
|
81
|
+
*
|
|
82
|
+
* return (
|
|
83
|
+
* <button onClick={() => push({ event: 'click' })}>
|
|
84
|
+
* Track
|
|
85
|
+
* </button>
|
|
86
|
+
* );
|
|
87
|
+
* }
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
declare const useGtm: () => GtmContextValue;
|
|
91
|
+
/**
|
|
92
|
+
* Hook to get just the push function.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```tsx
|
|
96
|
+
* import { useGtmPush } from '@jwiedeman/gtm-kit-remix';
|
|
97
|
+
*
|
|
98
|
+
* function BuyButton() {
|
|
99
|
+
* const push = useGtmPush();
|
|
100
|
+
*
|
|
101
|
+
* return (
|
|
102
|
+
* <button onClick={() => push({ event: 'purchase', value: 99 })}>
|
|
103
|
+
* Buy
|
|
104
|
+
* </button>
|
|
105
|
+
* );
|
|
106
|
+
* }
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
declare const useGtmPush: () => ((value: DataLayerValue) => void);
|
|
110
|
+
/**
|
|
111
|
+
* Hook to access consent management functions.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```tsx
|
|
115
|
+
* import { useGtmConsent } from '@jwiedeman/gtm-kit-remix';
|
|
116
|
+
*
|
|
117
|
+
* function CookieBanner() {
|
|
118
|
+
* const { updateConsent } = useGtmConsent();
|
|
119
|
+
*
|
|
120
|
+
* return (
|
|
121
|
+
* <button onClick={() => updateConsent({ analytics_storage: 'granted' })}>
|
|
122
|
+
* Accept
|
|
123
|
+
* </button>
|
|
124
|
+
* );
|
|
125
|
+
* }
|
|
126
|
+
* ```
|
|
127
|
+
*/
|
|
128
|
+
declare const useGtmConsent: () => GtmConsentApi;
|
|
129
|
+
/**
|
|
130
|
+
* Hook to get the raw GTM client instance.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```tsx
|
|
134
|
+
* import { useGtmClient } from '@jwiedeman/gtm-kit-remix';
|
|
135
|
+
*
|
|
136
|
+
* function MyComponent() {
|
|
137
|
+
* const client = useGtmClient();
|
|
138
|
+
* return <div>{client.isInitialized() ? 'Ready' : 'Loading'}</div>;
|
|
139
|
+
* }
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
declare const useGtmClient: () => GtmClient;
|
|
143
|
+
/**
|
|
144
|
+
* Hook to get the whenReady function.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```tsx
|
|
148
|
+
* import { useGtmReady } from '@jwiedeman/gtm-kit-remix';
|
|
149
|
+
* import { useEffect } from 'react';
|
|
150
|
+
*
|
|
151
|
+
* function MyComponent() {
|
|
152
|
+
* const whenReady = useGtmReady();
|
|
153
|
+
*
|
|
154
|
+
* useEffect(() => {
|
|
155
|
+
* whenReady().then(() => console.log('GTM ready!'));
|
|
156
|
+
* }, [whenReady]);
|
|
157
|
+
*
|
|
158
|
+
* return <div>Loading...</div>;
|
|
159
|
+
* }
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
declare const useGtmReady: () => (() => Promise<ScriptLoadState[]>);
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Options for the useTrackPageViews hook.
|
|
166
|
+
*/
|
|
167
|
+
interface UseTrackPageViewsOptions {
|
|
168
|
+
/**
|
|
169
|
+
* The event name to use for page view events.
|
|
170
|
+
* @default 'page_view'
|
|
171
|
+
*/
|
|
172
|
+
eventName?: string;
|
|
173
|
+
/**
|
|
174
|
+
* Whether to track the initial page load.
|
|
175
|
+
* @default true
|
|
176
|
+
*/
|
|
177
|
+
trackInitialPageView?: boolean;
|
|
178
|
+
/**
|
|
179
|
+
* Custom data to include with each page view event.
|
|
180
|
+
*/
|
|
181
|
+
customData?: Record<string, unknown>;
|
|
182
|
+
/**
|
|
183
|
+
* Callback to transform the page view event data before pushing.
|
|
184
|
+
* Use this to add custom properties or modify the event.
|
|
185
|
+
*/
|
|
186
|
+
transformEvent?: (data: PageViewData) => Record<string, unknown>;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Data included with each page view event.
|
|
190
|
+
*/
|
|
191
|
+
interface PageViewData {
|
|
192
|
+
event: string;
|
|
193
|
+
page_path: string;
|
|
194
|
+
page_search: string;
|
|
195
|
+
page_hash: string;
|
|
196
|
+
page_url: string;
|
|
197
|
+
[key: string]: unknown;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Hook to automatically track page views on route changes.
|
|
201
|
+
* Uses Remix's useLocation to detect navigation.
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* ```tsx
|
|
205
|
+
* // app/root.tsx
|
|
206
|
+
* import { GtmProvider, useTrackPageViews } from '@jwiedeman/gtm-kit-remix';
|
|
207
|
+
*
|
|
208
|
+
* function PageViewTracker() {
|
|
209
|
+
* useTrackPageViews();
|
|
210
|
+
* return null;
|
|
211
|
+
* }
|
|
212
|
+
*
|
|
213
|
+
* export default function App() {
|
|
214
|
+
* return (
|
|
215
|
+
* <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>
|
|
216
|
+
* <PageViewTracker />
|
|
217
|
+
* <Outlet />
|
|
218
|
+
* </GtmProvider>
|
|
219
|
+
* );
|
|
220
|
+
* }
|
|
221
|
+
* ```
|
|
222
|
+
*
|
|
223
|
+
* @example With custom options
|
|
224
|
+
* ```tsx
|
|
225
|
+
* useTrackPageViews({
|
|
226
|
+
* eventName: 'virtual_page_view',
|
|
227
|
+
* customData: { app_version: '1.0.0' },
|
|
228
|
+
* transformEvent: (data) => ({
|
|
229
|
+
* ...data,
|
|
230
|
+
* user_id: getCurrentUserId()
|
|
231
|
+
* })
|
|
232
|
+
* });
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
235
|
+
declare function useTrackPageViews(options?: UseTrackPageViewsOptions): void;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Props for the GtmScripts component.
|
|
239
|
+
*/
|
|
240
|
+
interface GtmScriptsProps {
|
|
241
|
+
/**
|
|
242
|
+
* GTM container ID(s).
|
|
243
|
+
*/
|
|
244
|
+
containers: ContainerConfigInput | ContainerConfigInput[];
|
|
245
|
+
/**
|
|
246
|
+
* Custom GTM host URL.
|
|
247
|
+
* @default 'https://www.googletagmanager.com'
|
|
248
|
+
*/
|
|
249
|
+
host?: string;
|
|
250
|
+
/**
|
|
251
|
+
* Custom dataLayer name.
|
|
252
|
+
* @default 'dataLayer'
|
|
253
|
+
*/
|
|
254
|
+
dataLayerName?: string;
|
|
255
|
+
/**
|
|
256
|
+
* Script attributes (e.g., nonce for CSP).
|
|
257
|
+
*/
|
|
258
|
+
scriptAttributes?: Record<string, string>;
|
|
259
|
+
}
|
|
260
|
+
declare function GtmScripts({ containers, host, dataLayerName, scriptAttributes }: GtmScriptsProps): React.ReactElement;
|
|
261
|
+
|
|
262
|
+
export { GtmConsentApi, GtmContext, GtmContextValue, GtmProvider, GtmProviderProps, GtmScripts, GtmScriptsProps, UseTrackPageViewsOptions, useGtm, useGtmClient, useGtmConsent, useGtmPush, useGtmReady, useTrackPageViews };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createContext, useRef, useEffect, useMemo, useContext } from 'react';
|
|
2
|
+
import { createGtmClient, createNoscriptMarkup } from '@jwiedeman/gtm-kit';
|
|
3
|
+
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
4
|
+
import { useLocation } from '@remix-run/react';
|
|
5
|
+
|
|
6
|
+
var g=createContext(null);function S({config:e,children:r,onBeforeInit:i,onAfterInit:o}){let s=useRef(null),n=useRef(!1);s.current||(s.current=createGtmClient(e));let t=s.current;useEffect(()=>{if(!n.current)return i&&i(t),t.init(),n.current=!0,o&&o(t),()=>{let a=setTimeout(()=>{document.querySelector("[data-gtm-kit-provider]")||(t.teardown(),s.current=null,n.current=!1);},100);clearTimeout(a);}},[t,i,o]);let p=useMemo(()=>({client:t,push:a=>t.push(a),setConsentDefaults:(a,c)=>t.setConsentDefaults(a,c),updateConsent:(a,c)=>t.updateConsent(a,c),whenReady:()=>t.whenReady()}),[t]);return jsx(g.Provider,{value:p,children:jsx("div",{"data-gtm-kit-provider":"",style:{display:"contents"},children:r})})}var u=()=>{let e=useContext(g);if(!e)throw new Error('[gtm-kit] useGtm() was called outside of a GtmProvider. Make sure to wrap your app with <GtmProvider config={{ containers: "GTM-XXXXXX" }}>.');return e},V=()=>u(),d=()=>u().push,D=()=>{let{setConsentDefaults:e,updateConsent:r}=u();return {setConsentDefaults:e,updateConsent:r}},k=()=>u().client,L=()=>u().whenReady;function T(e={}){let{eventName:r="page_view",trackInitialPageView:i=!0,customData:o={},transformEvent:s}=e,n=useLocation(),t=d(),p=useRef(null),a=useRef(!0);useEffect(()=>{let c=n.pathname+n.search+n.hash;if(c===p.current)return;if(a.current&&!i){a.current=!1,p.current=c;return}a.current=!1,p.current=c;let l={event:r,page_path:n.pathname,page_search:n.search,page_hash:n.hash,page_url:typeof window!="undefined"?window.location.href:c,...o},f=s?s(l):l;t(f);},[n,t,r,i,o,s]);}function m(e){return e.replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(/"/g,'\\"').replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/</g,"\\x3c").replace(/>/g,"\\x3e").replace(/\u2028/g,"\\u2028").replace(/\u2029/g,"\\u2029")}function E(e){return typeof e=="string"?[{id:e}]:Array.isArray(e)?e.map(r=>typeof r=="string"?{id:r}:r):[e]}function I(e,r,i,o){let s=r.endsWith("/")?r.slice(0,-1):r,n=new URLSearchParams;if(n.set("id",e),i!=="dataLayer"&&n.set("l",i),o)for(let[t,p]of Object.entries(o))t!=="id"&&t!=="l"&&n.set(t,String(p));return `${s}/gtm.js?${n.toString()}`}function N({containers:e,host:r="https://www.googletagmanager.com",dataLayerName:i="dataLayer",scriptAttributes:o={}}){let s=E(e),n=m(i),t=o.nonce?m(o.nonce):"",p=`
|
|
7
|
+
window['${n}'] = window['${n}'] || [];
|
|
8
|
+
${s.map(c=>{let l=m(c.id);return `
|
|
9
|
+
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
|
10
|
+
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
|
11
|
+
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
|
12
|
+
'${m(I(c.id,r,i,c.queryParams))}';${t?`j.nonce='${t}';`:""}f.parentNode.insertBefore(j,f);
|
|
13
|
+
})(window,document,'script','${n}','${l}');
|
|
14
|
+
`}).join(`
|
|
15
|
+
`)}
|
|
16
|
+
`.trim(),a=createNoscriptMarkup(s,{host:r});return jsxs(Fragment,{children:[jsx("script",{...o,dangerouslySetInnerHTML:{__html:p}}),jsx("noscript",{dangerouslySetInnerHTML:{__html:a}})]})}
|
|
17
|
+
|
|
18
|
+
export { g as GtmContext, S as GtmProvider, N as GtmScripts, V as useGtm, k as useGtmClient, D as useGtmConsent, d as useGtmPush, L as useGtmReady, T as useTrackPageViews };
|
|
19
|
+
//# sourceMappingURL=out.js.map
|
|
20
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/provider.tsx","../src/route-tracker.tsx","../src/scripts.tsx"],"names":["createContext","useContext","useEffect","useRef","useMemo","createGtmClient","jsx","GtmContext","GtmProvider","config","children","onBeforeInit","onAfterInit","clientRef","initializedRef","client","timer","contextValue","value","state","options","useGtmContext","context","useGtm","useGtmPush","useGtmConsent","setConsentDefaults","updateConsent","useGtmClient","useGtmReady","useLocation","useTrackPageViews","eventName","trackInitialPageView","customData","transformEvent","location","push","lastPathRef","isFirstRenderRef","currentPath","pageViewData","eventData","createNoscriptMarkup","Fragment","jsxs","escapeJsString","normalizeContainers","containers","c","buildGtmScriptUrl","containerId","host","dataLayerName","queryParams","normalizedHost","params","key","GtmScripts","scriptAttributes","containerConfigs","safeDataLayerName","safeNonce","inlineScript","safeContainerId","noscriptHtml"],"mappings":"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","sourcesContent":["import React, { createContext, useContext, useEffect, useRef, useMemo, type ReactNode } from 'react';\nimport {\n createGtmClient,\n type ConsentRegionOptions,\n type ConsentState,\n type CreateGtmClientOptions,\n type DataLayerValue,\n type GtmClient,\n type ScriptLoadState\n} from '@jwiedeman/gtm-kit';\n\n/**\n * Props for the GTM Provider component.\n */\nexport interface GtmProviderProps {\n /** GTM client configuration */\n config: CreateGtmClientOptions;\n\n /** Child components */\n children: ReactNode;\n\n /**\n * Callback executed before GTM initialization.\n * Use this to set consent defaults.\n */\n onBeforeInit?: (client: GtmClient) => void;\n\n /**\n * Callback executed after GTM initialization.\n */\n onAfterInit?: (client: GtmClient) => void;\n}\n\n/**\n * The GTM context value containing all GTM functionality.\n */\nexport interface GtmContextValue {\n /** The underlying GTM client instance */\n client: GtmClient;\n /** Push a value to the data layer */\n push: (value: DataLayerValue) => void;\n /** Set consent defaults (must be called before init) */\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Update consent state */\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n /** Returns a promise that resolves when all GTM scripts are loaded */\n whenReady: () => Promise<ScriptLoadState[]>;\n}\n\n/**\n * Consent-specific API subset.\n */\nexport interface GtmConsentApi {\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) => void;\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => void;\n}\n\n/**\n * The GTM context for Remix.\n */\nexport const GtmContext = createContext<GtmContextValue | null>(null);\n\n/**\n * GTM Provider component for Remix.\n * Handles StrictMode correctly and provides GTM context to children.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head />\n * <body>\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <Outlet />\n * </GtmProvider>\n * </body>\n * </html>\n * );\n * }\n * ```\n */\nexport function GtmProvider({ config, children, onBeforeInit, onAfterInit }: GtmProviderProps): React.ReactElement {\n // Create client once and store in ref to survive StrictMode remounts\n const clientRef = useRef<GtmClient | null>(null);\n const initializedRef = useRef(false);\n\n // Create client on first render only\n if (!clientRef.current) {\n clientRef.current = createGtmClient(config);\n }\n\n const client = clientRef.current;\n\n // Initialize GTM (handles StrictMode correctly)\n useEffect(() => {\n // Skip if already initialized (StrictMode protection)\n if (initializedRef.current) {\n return;\n }\n\n // Call onBeforeInit hook for consent defaults\n if (onBeforeInit) {\n onBeforeInit(client);\n }\n\n // Initialize GTM\n client.init();\n initializedRef.current = true;\n\n // Call onAfterInit hook\n if (onAfterInit) {\n onAfterInit(client);\n }\n\n // Cleanup on unmount\n return () => {\n // Don't teardown immediately in StrictMode\n // Only teardown if we're truly unmounting\n const timer = setTimeout(() => {\n if (!document.querySelector('[data-gtm-kit-provider]')) {\n client.teardown();\n clientRef.current = null;\n initializedRef.current = false;\n }\n }, 100);\n\n // Clear the timeout on cleanup\n clearTimeout(timer);\n };\n }, [client, onBeforeInit, onAfterInit]);\n\n // Memoize context value\n const contextValue = useMemo<GtmContextValue>(\n () => ({\n client,\n push: (value: DataLayerValue) => client.push(value),\n setConsentDefaults: (state: ConsentState, options?: ConsentRegionOptions) =>\n client.setConsentDefaults(state, options),\n updateConsent: (state: ConsentState, options?: ConsentRegionOptions) => client.updateConsent(state, options),\n whenReady: () => client.whenReady()\n }),\n [client]\n );\n\n return (\n <GtmContext.Provider value={contextValue}>\n <div data-gtm-kit-provider=\"\" style={{ display: 'contents' }}>\n {children}\n </div>\n </GtmContext.Provider>\n );\n}\n\n/**\n * Internal helper to get the GTM context with proper error handling.\n */\nconst useGtmContext = (): GtmContextValue => {\n const context = useContext(GtmContext);\n if (!context) {\n throw new Error(\n '[gtm-kit] useGtm() was called outside of a GtmProvider. ' +\n 'Make sure to wrap your app with <GtmProvider config={{ containers: \"GTM-XXXXXX\" }}>.'\n );\n }\n return context;\n};\n\n/**\n * Hook to access the full GTM context.\n *\n * @example\n * ```tsx\n * import { useGtm } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const { push, client } = useGtm();\n *\n * return (\n * <button onClick={() => push({ event: 'click' })}>\n * Track\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtm = (): GtmContextValue => {\n return useGtmContext();\n};\n\n/**\n * Hook to get just the push function.\n *\n * @example\n * ```tsx\n * import { useGtmPush } from '@jwiedeman/gtm-kit-remix';\n *\n * function BuyButton() {\n * const push = useGtmPush();\n *\n * return (\n * <button onClick={() => push({ event: 'purchase', value: 99 })}>\n * Buy\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmPush = (): ((value: DataLayerValue) => void) => {\n return useGtmContext().push;\n};\n\n/**\n * Hook to access consent management functions.\n *\n * @example\n * ```tsx\n * import { useGtmConsent } from '@jwiedeman/gtm-kit-remix';\n *\n * function CookieBanner() {\n * const { updateConsent } = useGtmConsent();\n *\n * return (\n * <button onClick={() => updateConsent({ analytics_storage: 'granted' })}>\n * Accept\n * </button>\n * );\n * }\n * ```\n */\nexport const useGtmConsent = (): GtmConsentApi => {\n const { setConsentDefaults, updateConsent } = useGtmContext();\n return { setConsentDefaults, updateConsent };\n};\n\n/**\n * Hook to get the raw GTM client instance.\n *\n * @example\n * ```tsx\n * import { useGtmClient } from '@jwiedeman/gtm-kit-remix';\n *\n * function MyComponent() {\n * const client = useGtmClient();\n * return <div>{client.isInitialized() ? 'Ready' : 'Loading'}</div>;\n * }\n * ```\n */\nexport const useGtmClient = (): GtmClient => {\n return useGtmContext().client;\n};\n\n/**\n * Hook to get the whenReady function.\n *\n * @example\n * ```tsx\n * import { useGtmReady } from '@jwiedeman/gtm-kit-remix';\n * import { useEffect } from 'react';\n *\n * function MyComponent() {\n * const whenReady = useGtmReady();\n *\n * useEffect(() => {\n * whenReady().then(() => console.log('GTM ready!'));\n * }, [whenReady]);\n *\n * return <div>Loading...</div>;\n * }\n * ```\n */\nexport const useGtmReady = (): (() => Promise<ScriptLoadState[]>) => {\n return useGtmContext().whenReady;\n};\n","import { useEffect, useRef } from 'react';\nimport { useLocation } from '@remix-run/react';\nimport { useGtmPush } from './provider';\n\n/**\n * Options for the useTrackPageViews hook.\n */\nexport interface UseTrackPageViewsOptions {\n /**\n * The event name to use for page view events.\n * @default 'page_view'\n */\n eventName?: string;\n\n /**\n * Whether to track the initial page load.\n * @default true\n */\n trackInitialPageView?: boolean;\n\n /**\n * Custom data to include with each page view event.\n */\n customData?: Record<string, unknown>;\n\n /**\n * Callback to transform the page view event data before pushing.\n * Use this to add custom properties or modify the event.\n */\n transformEvent?: (data: PageViewData) => Record<string, unknown>;\n}\n\n/**\n * Data included with each page view event.\n */\nexport interface PageViewData {\n event: string;\n page_path: string;\n page_search: string;\n page_hash: string;\n page_url: string;\n [key: string]: unknown;\n}\n\n/**\n * Hook to automatically track page views on route changes.\n * Uses Remix's useLocation to detect navigation.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmProvider, useTrackPageViews } from '@jwiedeman/gtm-kit-remix';\n *\n * function PageViewTracker() {\n * useTrackPageViews();\n * return null;\n * }\n *\n * export default function App() {\n * return (\n * <GtmProvider config={{ containers: 'GTM-XXXXXX' }}>\n * <PageViewTracker />\n * <Outlet />\n * </GtmProvider>\n * );\n * }\n * ```\n *\n * @example With custom options\n * ```tsx\n * useTrackPageViews({\n * eventName: 'virtual_page_view',\n * customData: { app_version: '1.0.0' },\n * transformEvent: (data) => ({\n * ...data,\n * user_id: getCurrentUserId()\n * })\n * });\n * ```\n */\nexport function useTrackPageViews(options: UseTrackPageViewsOptions = {}): void {\n const { eventName = 'page_view', trackInitialPageView = true, customData = {}, transformEvent } = options;\n\n const location = useLocation();\n const push = useGtmPush();\n const lastPathRef = useRef<string | null>(null);\n const isFirstRenderRef = useRef(true);\n\n useEffect(() => {\n const currentPath = location.pathname + location.search + location.hash;\n\n // Skip if this is the same path (prevents double-firing)\n if (currentPath === lastPathRef.current) {\n return;\n }\n\n // Skip initial page view if configured\n if (isFirstRenderRef.current && !trackInitialPageView) {\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n return;\n }\n\n isFirstRenderRef.current = false;\n lastPathRef.current = currentPath;\n\n // Build page view data\n const pageViewData: PageViewData = {\n event: eventName,\n page_path: location.pathname,\n page_search: location.search,\n page_hash: location.hash,\n page_url: typeof window !== 'undefined' ? window.location.href : currentPath,\n ...customData\n };\n\n // Apply transform if provided\n const eventData = transformEvent ? transformEvent(pageViewData) : pageViewData;\n\n // Push to GTM\n push(eventData);\n }, [location, push, eventName, trackInitialPageView, customData, transformEvent]);\n}\n","import React from 'react';\nimport { createNoscriptMarkup, type ContainerConfigInput, type ContainerDescriptor } from '@jwiedeman/gtm-kit';\n\n/**\n * Escape a string for safe use in JavaScript string literals.\n * Prevents XSS when interpolating user-provided values into inline scripts.\n */\nfunction escapeJsString(value: string): string {\n return value\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/'/g, \"\\\\'\")\n .replace(/\"/g, '\\\\\"')\n .replace(/\\n/g, '\\\\n')\n .replace(/\\r/g, '\\\\r')\n .replace(/</g, '\\\\x3c')\n .replace(/>/g, '\\\\x3e')\n .replace(/\\u2028/g, '\\\\u2028')\n .replace(/\\u2029/g, '\\\\u2029');\n}\n\n/**\n * Props for the GtmScripts component.\n */\nexport interface GtmScriptsProps {\n /**\n * GTM container ID(s).\n */\n containers: ContainerConfigInput | ContainerConfigInput[];\n\n /**\n * Custom GTM host URL.\n * @default 'https://www.googletagmanager.com'\n */\n host?: string;\n\n /**\n * Custom dataLayer name.\n * @default 'dataLayer'\n */\n dataLayerName?: string;\n\n /**\n * Script attributes (e.g., nonce for CSP).\n */\n scriptAttributes?: Record<string, string>;\n}\n\n/**\n * Normalize container config to array format.\n */\nfunction normalizeContainers(containers: ContainerConfigInput | ContainerConfigInput[]): ContainerDescriptor[] {\n if (typeof containers === 'string') {\n return [{ id: containers }];\n }\n if (!Array.isArray(containers)) {\n return [containers];\n }\n return containers.map((c) => (typeof c === 'string' ? { id: c } : c));\n}\n\n/**\n * Server component that renders GTM script tags for Remix.\n * Use this in your root.tsx to add GTM scripts.\n *\n * @example\n * ```tsx\n * // app/root.tsx\n * import { GtmScripts } from '@jwiedeman/gtm-kit-remix';\n *\n * export default function App() {\n * return (\n * <html>\n * <head>\n * <GtmScripts containers=\"GTM-XXXXXX\" />\n * </head>\n * <body>\n * <Outlet />\n * </body>\n * </html>\n * );\n * }\n * ```\n *\n * @example With CSP nonce\n * ```tsx\n * <GtmScripts\n * containers=\"GTM-XXXXXX\"\n * scriptAttributes={{ nonce: 'your-csp-nonce' }}\n * />\n * ```\n */\n/**\n * Build the GTM script URL for a container.\n */\nfunction buildGtmScriptUrl(\n containerId: string,\n host: string,\n dataLayerName: string,\n queryParams?: Record<string, string | number | boolean>\n): string {\n const normalizedHost = host.endsWith('/') ? host.slice(0, -1) : host;\n const params = new URLSearchParams();\n params.set('id', containerId);\n\n if (dataLayerName !== 'dataLayer') {\n params.set('l', dataLayerName);\n }\n\n if (queryParams) {\n for (const [key, value] of Object.entries(queryParams)) {\n if (key !== 'id' && key !== 'l') {\n params.set(key, String(value));\n }\n }\n }\n\n return `${normalizedHost}/gtm.js?${params.toString()}`;\n}\n\nexport function GtmScripts({\n containers,\n host = 'https://www.googletagmanager.com',\n dataLayerName = 'dataLayer',\n scriptAttributes = {}\n}: GtmScriptsProps): React.ReactElement {\n const containerConfigs = normalizeContainers(containers);\n\n // Escape values for safe use in JavaScript string literals\n const safeDataLayerName = escapeJsString(dataLayerName);\n const safeNonce = scriptAttributes.nonce ? escapeJsString(scriptAttributes.nonce) : '';\n\n // Generate inline script for dataLayer initialization and GTM loading\n const inlineScript = `\n window['${safeDataLayerName}'] = window['${safeDataLayerName}'] || [];\n ${containerConfigs\n .map((config) => {\n const safeContainerId = escapeJsString(config.id);\n const scriptSrc = escapeJsString(buildGtmScriptUrl(config.id, host, dataLayerName, config.queryParams));\n return `\n (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':\n new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],\n j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=\n '${scriptSrc}';${safeNonce ? `j.nonce='${safeNonce}';` : ''}f.parentNode.insertBefore(j,f);\n })(window,document,'script','${safeDataLayerName}','${safeContainerId}');\n `;\n })\n .join('\\n')}\n `.trim();\n\n // Generate noscript HTML using the core package\n const noscriptHtml = createNoscriptMarkup(containerConfigs, { host });\n\n return (\n <>\n <script {...scriptAttributes} dangerouslySetInnerHTML={{ __html: inlineScript }} />\n <noscript dangerouslySetInnerHTML={{ __html: noscriptHtml }} />\n </>\n );\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jwiedeman/gtm-kit-remix",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Remix adapter for GTM Kit - Google Tag Manager integration with route tracking.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/jwiedeman/GTM-Kit.git",
|
|
8
|
+
"directory": "packages/remix"
|
|
9
|
+
},
|
|
10
|
+
"author": "jwiedeman",
|
|
11
|
+
"keywords": [
|
|
12
|
+
"gtm",
|
|
13
|
+
"google-tag-manager",
|
|
14
|
+
"remix",
|
|
15
|
+
"routes"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "dist/index.cjs",
|
|
23
|
+
"module": "dist/index.js",
|
|
24
|
+
"types": "dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js",
|
|
29
|
+
"require": "./dist/index.cjs"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsup",
|
|
37
|
+
"clean": "rm -rf dist",
|
|
38
|
+
"lint": "eslint --max-warnings=0 \"src/**/*.{ts,tsx}\"",
|
|
39
|
+
"test": "jest --config ./jest.config.cjs --runInBand",
|
|
40
|
+
"typecheck": "tsc --noEmit"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@jwiedeman/gtm-kit": "^1.0.1"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@remix-run/react": "^2.0.0",
|
|
47
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@remix-run/react": "^2.8.1",
|
|
51
|
+
"@testing-library/jest-dom": "^6.4.2",
|
|
52
|
+
"@testing-library/react": "^14.2.1",
|
|
53
|
+
"@types/react": "^18.3.0",
|
|
54
|
+
"@types/react-dom": "^18.3.0",
|
|
55
|
+
"react": "^18.3.1",
|
|
56
|
+
"react-dom": "^18.3.1",
|
|
57
|
+
"tslib": "^2.6.2"
|
|
58
|
+
}
|
|
59
|
+
}
|