@technoapple/ga4 1.0.4 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/node.js.yml +31 -31
- package/.prettierignore +1 -1
- package/LICENSE +21 -21
- package/README.md +386 -48
- package/REQUIREMENTS.md +548 -0
- package/babel.config.js +5 -5
- package/build/main/ga4/ga4.d.ts +13 -0
- package/build/main/ga4/ga4.js +24 -1
- package/build/main/helpers/debounce.d.ts +5 -0
- package/build/main/helpers/debounce.js +23 -0
- package/build/main/helpers/delegate.d.ts +8 -0
- package/build/main/helpers/delegate.js +37 -0
- package/build/main/helpers/dom-ready.d.ts +1 -0
- package/build/main/helpers/dom-ready.js +13 -0
- package/build/main/helpers/parse-url.d.ts +11 -0
- package/build/main/helpers/parse-url.js +32 -0
- package/build/main/helpers/session.d.ts +4 -0
- package/build/main/helpers/session.js +50 -0
- package/build/main/index.d.ts +9 -0
- package/build/main/index.js +19 -2
- package/build/main/plugins/clean-url-tracker.d.ts +17 -0
- package/build/main/plugins/clean-url-tracker.js +105 -0
- package/build/main/plugins/event-tracker.d.ts +27 -0
- package/build/main/plugins/event-tracker.js +76 -0
- package/build/main/plugins/impression-tracker.d.ts +32 -0
- package/build/main/plugins/impression-tracker.js +202 -0
- package/build/main/plugins/index.d.ts +8 -0
- package/build/main/plugins/index.js +20 -0
- package/build/main/plugins/media-query-tracker.d.ts +20 -0
- package/build/main/plugins/media-query-tracker.js +96 -0
- package/build/main/plugins/outbound-form-tracker.d.ts +17 -0
- package/build/main/plugins/outbound-form-tracker.js +55 -0
- package/build/main/plugins/outbound-link-tracker.d.ts +19 -0
- package/build/main/plugins/outbound-link-tracker.js +63 -0
- package/build/main/plugins/page-visibility-tracker.d.ts +24 -0
- package/build/main/plugins/page-visibility-tracker.js +93 -0
- package/build/main/plugins/url-change-tracker.d.ts +20 -0
- package/build/main/plugins/url-change-tracker.js +76 -0
- package/build/main/types/plugins.d.ts +78 -0
- package/build/main/types/plugins.js +3 -0
- package/build/tsconfig.tsbuildinfo +1 -1
- package/docs/examples/react.md +95 -0
- package/docs/examples/vanilla.md +65 -0
- package/docs/examples/vue.md +87 -0
- package/jest.config.ts +195 -195
- package/package.json +56 -56
- package/src/dataLayer.ts +85 -85
- package/src/ga4/ga4.ts +69 -40
- package/src/ga4/ga4option.ts +4 -4
- package/src/ga4/index.ts +4 -4
- package/src/helpers/debounce.ts +28 -0
- package/src/helpers/delegate.ts +51 -0
- package/src/helpers/dom-ready.ts +7 -0
- package/src/helpers/parse-url.ts +37 -0
- package/src/helpers/session.ts +39 -0
- package/src/index.ts +34 -7
- package/src/plugins/clean-url-tracker.ts +112 -0
- package/src/plugins/event-tracker.ts +90 -0
- package/src/plugins/impression-tracker.ts +230 -0
- package/src/plugins/index.ts +8 -0
- package/src/plugins/media-query-tracker.ts +116 -0
- package/src/plugins/outbound-form-tracker.ts +65 -0
- package/src/plugins/outbound-link-tracker.ts +72 -0
- package/src/plugins/page-visibility-tracker.ts +104 -0
- package/src/plugins/url-change-tracker.ts +84 -0
- package/src/types/dataLayer.ts +9 -9
- package/src/types/global.ts +12 -12
- package/src/types/gtag.ts +259 -259
- package/src/types/plugins.ts +98 -0
- package/src/util.ts +18 -18
- package/test/dataLayer.spec.ts +55 -55
- package/test/ga4.spec.ts +36 -36
- package/tsconfig.json +28 -28
- package/tsconfig.module.json +11 -11
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { GA4Plugin, SendFunction, EventTrackerOptions } from '../types/plugins';
|
|
2
|
+
import { delegate, DelegateHandle } from '../helpers/delegate';
|
|
3
|
+
|
|
4
|
+
function kebabToSnake(str: string): string {
|
|
5
|
+
return str.replace(/-/g, '_');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function getAttributeParams(element: Element, prefix: string): Record<string, unknown> {
|
|
9
|
+
const params: Record<string, unknown> = {};
|
|
10
|
+
const reservedSuffixes = ['on', 'event-name'];
|
|
11
|
+
|
|
12
|
+
for (let i = 0; i < element.attributes.length; i++) {
|
|
13
|
+
const attr = element.attributes[i];
|
|
14
|
+
if (!attr.name.startsWith(prefix)) continue;
|
|
15
|
+
|
|
16
|
+
const suffix = attr.name.slice(prefix.length);
|
|
17
|
+
if (reservedSuffixes.includes(suffix)) continue;
|
|
18
|
+
|
|
19
|
+
params[kebabToSnake(suffix)] = attr.value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return params;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Declarative event tracking via HTML `data-ga4-*` attributes.
|
|
27
|
+
*
|
|
28
|
+
* Listens for DOM events on elements with `data-ga4-on` attributes
|
|
29
|
+
* and sends GA4 events based on attribute values.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```html
|
|
33
|
+
* <button
|
|
34
|
+
* data-ga4-on="click"
|
|
35
|
+
* data-ga4-event-name="video_play"
|
|
36
|
+
* data-ga4-video-title="My Video">
|
|
37
|
+
* Play
|
|
38
|
+
* </button>
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export class EventTracker implements GA4Plugin {
|
|
42
|
+
private delegates: DelegateHandle[] = [];
|
|
43
|
+
private send: SendFunction;
|
|
44
|
+
private events: string[];
|
|
45
|
+
private attributePrefix: string;
|
|
46
|
+
private hitFilter?: EventTrackerOptions['hitFilter'];
|
|
47
|
+
|
|
48
|
+
constructor(send: SendFunction, options?: EventTrackerOptions) {
|
|
49
|
+
this.send = send;
|
|
50
|
+
this.events = options?.events ?? ['click'];
|
|
51
|
+
this.attributePrefix = options?.attributePrefix ?? 'data-ga4-';
|
|
52
|
+
this.hitFilter = options?.hitFilter;
|
|
53
|
+
|
|
54
|
+
const selector = `[${this.attributePrefix}on]`;
|
|
55
|
+
|
|
56
|
+
this.events.forEach((eventType) => {
|
|
57
|
+
const handle = delegate(
|
|
58
|
+
document,
|
|
59
|
+
eventType,
|
|
60
|
+
selector,
|
|
61
|
+
(event, element) => this.handleEvent(event, element),
|
|
62
|
+
{ composed: true, useCapture: true }
|
|
63
|
+
);
|
|
64
|
+
this.delegates.push(handle);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private handleEvent(event: Event, element: Element): void {
|
|
69
|
+
const prefix = this.attributePrefix;
|
|
70
|
+
const onAttr = element.getAttribute(`${prefix}on`);
|
|
71
|
+
|
|
72
|
+
if (onAttr !== event.type) return;
|
|
73
|
+
|
|
74
|
+
const eventName = element.getAttribute(`${prefix}event-name`) || event.type;
|
|
75
|
+
let params = getAttributeParams(element, prefix);
|
|
76
|
+
|
|
77
|
+
if (this.hitFilter) {
|
|
78
|
+
const filtered = this.hitFilter(params, element, event);
|
|
79
|
+
if (filtered === null) return;
|
|
80
|
+
params = filtered;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.send(eventName, params);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
remove(): void {
|
|
87
|
+
this.delegates.forEach((d) => d.destroy());
|
|
88
|
+
this.delegates = [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { GA4Plugin, SendFunction, ImpressionTrackerOptions, ImpressionElementConfig } from '../types/plugins';
|
|
2
|
+
import { domReady } from '../helpers/dom-ready';
|
|
3
|
+
|
|
4
|
+
interface NormalizedItem {
|
|
5
|
+
id: string;
|
|
6
|
+
threshold: number;
|
|
7
|
+
trackFirstImpressionOnly: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Tracks when specific DOM elements become visible in the viewport
|
|
12
|
+
* using `IntersectionObserver`.
|
|
13
|
+
*
|
|
14
|
+
* Useful for tracking ad impressions, CTA visibility, or any
|
|
15
|
+
* element that enters the user's viewport.
|
|
16
|
+
*/
|
|
17
|
+
export class ImpressionTracker implements GA4Plugin {
|
|
18
|
+
private send: SendFunction;
|
|
19
|
+
private rootMargin: string;
|
|
20
|
+
private attributePrefix: string;
|
|
21
|
+
private eventName: string;
|
|
22
|
+
private hitFilter?: ImpressionTrackerOptions['hitFilter'];
|
|
23
|
+
private items: NormalizedItem[] = [];
|
|
24
|
+
private elementMap: Record<string, Element | null> = {};
|
|
25
|
+
private thresholdMap: Record<number, IntersectionObserver> = {};
|
|
26
|
+
private mutationObserver: MutationObserver | null = null;
|
|
27
|
+
private impressedIds: Set<string> = new Set();
|
|
28
|
+
private supported: boolean;
|
|
29
|
+
|
|
30
|
+
constructor(send: SendFunction, options?: ImpressionTrackerOptions) {
|
|
31
|
+
this.supported =
|
|
32
|
+
typeof IntersectionObserver !== 'undefined' &&
|
|
33
|
+
typeof MutationObserver !== 'undefined';
|
|
34
|
+
|
|
35
|
+
this.send = send;
|
|
36
|
+
this.rootMargin = options?.rootMargin ?? '0px';
|
|
37
|
+
this.attributePrefix = options?.attributePrefix ?? 'data-ga4-';
|
|
38
|
+
this.eventName = options?.eventName ?? 'element_impression';
|
|
39
|
+
this.hitFilter = options?.hitFilter;
|
|
40
|
+
|
|
41
|
+
if (!this.supported) return;
|
|
42
|
+
|
|
43
|
+
this.handleIntersectionChanges = this.handleIntersectionChanges.bind(this);
|
|
44
|
+
this.handleDomMutations = this.handleDomMutations.bind(this);
|
|
45
|
+
|
|
46
|
+
const elements = options?.elements;
|
|
47
|
+
domReady(() => {
|
|
48
|
+
if (elements && elements.length > 0) {
|
|
49
|
+
this.observeElements(elements);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
observeElements(elements: Array<string | ImpressionElementConfig>): void {
|
|
55
|
+
if (!this.supported) return;
|
|
56
|
+
|
|
57
|
+
const newItems = this.normalizeElements(elements);
|
|
58
|
+
this.items = this.items.concat(newItems);
|
|
59
|
+
|
|
60
|
+
newItems.forEach((item) => {
|
|
61
|
+
const observer = this.getObserverForThreshold(item.threshold);
|
|
62
|
+
const element = document.getElementById(item.id);
|
|
63
|
+
this.elementMap[item.id] = element;
|
|
64
|
+
if (element) {
|
|
65
|
+
observer.observe(element);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (!this.mutationObserver && document.body) {
|
|
70
|
+
this.mutationObserver = new MutationObserver(this.handleDomMutations);
|
|
71
|
+
this.mutationObserver.observe(document.body, {
|
|
72
|
+
childList: true,
|
|
73
|
+
subtree: true,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
unobserveElements(elements: Array<string | ImpressionElementConfig>): void {
|
|
79
|
+
if (!this.supported) return;
|
|
80
|
+
|
|
81
|
+
const idsToRemove = new Set(
|
|
82
|
+
elements.map((el) => (typeof el === 'string' ? el : el.id))
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
this.items = this.items.filter((item) => {
|
|
86
|
+
if (idsToRemove.has(item.id)) {
|
|
87
|
+
const element = this.elementMap[item.id];
|
|
88
|
+
if (element && this.thresholdMap[item.threshold]) {
|
|
89
|
+
this.thresholdMap[item.threshold].unobserve(element);
|
|
90
|
+
}
|
|
91
|
+
delete this.elementMap[item.id];
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (this.items.length === 0) {
|
|
98
|
+
this.disconnectAll();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
unobserveAllElements(): void {
|
|
103
|
+
this.disconnectAll();
|
|
104
|
+
this.items = [];
|
|
105
|
+
this.elementMap = {};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private normalizeElements(elements: Array<string | ImpressionElementConfig>): NormalizedItem[] {
|
|
109
|
+
return elements.map((el) => {
|
|
110
|
+
if (typeof el === 'string') {
|
|
111
|
+
return { id: el, threshold: 0, trackFirstImpressionOnly: true };
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
id: el.id,
|
|
115
|
+
threshold: el.threshold ?? 0,
|
|
116
|
+
trackFirstImpressionOnly: el.trackFirstImpressionOnly ?? true,
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private getObserverForThreshold(threshold: number): IntersectionObserver {
|
|
122
|
+
if (!this.thresholdMap[threshold]) {
|
|
123
|
+
this.thresholdMap[threshold] = new IntersectionObserver(
|
|
124
|
+
this.handleIntersectionChanges,
|
|
125
|
+
{
|
|
126
|
+
rootMargin: this.rootMargin,
|
|
127
|
+
threshold: [threshold],
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return this.thresholdMap[threshold];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private handleIntersectionChanges(entries: IntersectionObserverEntry[]): void {
|
|
135
|
+
entries.forEach((entry) => {
|
|
136
|
+
if (!entry.isIntersecting) return;
|
|
137
|
+
|
|
138
|
+
const id = entry.target.id;
|
|
139
|
+
if (!id) return;
|
|
140
|
+
|
|
141
|
+
const item = this.items.find((i) => i.id === id);
|
|
142
|
+
if (!item) return;
|
|
143
|
+
if (item.trackFirstImpressionOnly && this.impressedIds.has(id)) return;
|
|
144
|
+
|
|
145
|
+
this.impressedIds.add(id);
|
|
146
|
+
|
|
147
|
+
const prefix = this.attributePrefix;
|
|
148
|
+
const attrParams: Record<string, unknown> = {};
|
|
149
|
+
for (let i = 0; i < entry.target.attributes.length; i++) {
|
|
150
|
+
const attr = entry.target.attributes[i];
|
|
151
|
+
if (attr.name.startsWith(prefix)) {
|
|
152
|
+
const key = attr.name.slice(prefix.length).replace(/-/g, '_');
|
|
153
|
+
attrParams[key] = attr.value;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let params: Record<string, unknown> = {
|
|
158
|
+
element_id: id,
|
|
159
|
+
...attrParams,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
if (this.hitFilter) {
|
|
163
|
+
const filtered = this.hitFilter(params, entry.target);
|
|
164
|
+
if (filtered === null) return;
|
|
165
|
+
params = filtered;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
this.send(this.eventName, params);
|
|
169
|
+
|
|
170
|
+
if (item.trackFirstImpressionOnly && this.thresholdMap[item.threshold]) {
|
|
171
|
+
this.thresholdMap[item.threshold].unobserve(entry.target);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private handleDomMutations(mutations: MutationRecord[]): void {
|
|
177
|
+
mutations.forEach((mutation) => {
|
|
178
|
+
mutation.addedNodes.forEach((node) => {
|
|
179
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
180
|
+
this.walkNodeTree(node as Element, (id) => {
|
|
181
|
+
const element = document.getElementById(id);
|
|
182
|
+
if (element) {
|
|
183
|
+
this.elementMap[id] = element;
|
|
184
|
+
const item = this.items.find((i) => i.id === id);
|
|
185
|
+
if (item && this.thresholdMap[item.threshold]) {
|
|
186
|
+
this.thresholdMap[item.threshold].observe(element);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
mutation.removedNodes.forEach((node) => {
|
|
193
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
194
|
+
this.walkNodeTree(node as Element, (id) => {
|
|
195
|
+
const element = this.elementMap[id];
|
|
196
|
+
if (element) {
|
|
197
|
+
const item = this.items.find((i) => i.id === id);
|
|
198
|
+
if (item && this.thresholdMap[item.threshold]) {
|
|
199
|
+
this.thresholdMap[item.threshold].unobserve(element);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
this.elementMap[id] = null;
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private walkNodeTree(node: Element, callback: (id: string) => void): void {
|
|
209
|
+
if (node.id && node.id in this.elementMap) {
|
|
210
|
+
callback(node.id);
|
|
211
|
+
}
|
|
212
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
213
|
+
this.walkNodeTree(node.children[i], callback);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private disconnectAll(): void {
|
|
218
|
+
Object.values(this.thresholdMap).forEach((observer) => observer.disconnect());
|
|
219
|
+
this.thresholdMap = {};
|
|
220
|
+
if (this.mutationObserver) {
|
|
221
|
+
this.mutationObserver.disconnect();
|
|
222
|
+
this.mutationObserver = null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
remove(): void {
|
|
227
|
+
this.unobserveAllElements();
|
|
228
|
+
this.impressedIds.clear();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { EventTracker } from './event-tracker';
|
|
2
|
+
export { OutboundLinkTracker } from './outbound-link-tracker';
|
|
3
|
+
export { OutboundFormTracker } from './outbound-form-tracker';
|
|
4
|
+
export { PageVisibilityTracker } from './page-visibility-tracker';
|
|
5
|
+
export { UrlChangeTracker } from './url-change-tracker';
|
|
6
|
+
export { ImpressionTracker } from './impression-tracker';
|
|
7
|
+
export { CleanUrlTracker } from './clean-url-tracker';
|
|
8
|
+
export { MediaQueryTracker } from './media-query-tracker';
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { GA4Plugin, SendFunction, MediaQueryTrackerOptions, MediaQueryDefinition } from '../types/plugins';
|
|
2
|
+
import { debounce, DebouncedFunction } from '../helpers/debounce';
|
|
3
|
+
|
|
4
|
+
interface TrackedQuery {
|
|
5
|
+
definition: MediaQueryDefinition;
|
|
6
|
+
mql: MediaQueryList;
|
|
7
|
+
currentValue: string;
|
|
8
|
+
listener: ((event: MediaQueryListEvent) => void);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getMatchingValue(definition: MediaQueryDefinition): string {
|
|
12
|
+
for (const item of definition.items) {
|
|
13
|
+
if (window.matchMedia(item.media).matches) {
|
|
14
|
+
return item.name;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return '(not set)';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Tracks CSS media query breakpoint matching and changes.
|
|
22
|
+
*
|
|
23
|
+
* Fires an event whenever the active breakpoint changes
|
|
24
|
+
* (e.g. from "mobile" to "desktop"), debounced to avoid
|
|
25
|
+
* rapid-fire events during window resizing.
|
|
26
|
+
*/
|
|
27
|
+
export class MediaQueryTracker implements GA4Plugin {
|
|
28
|
+
private send: SendFunction;
|
|
29
|
+
private changeTimeout: number;
|
|
30
|
+
private eventName: string;
|
|
31
|
+
private changeTemplate?: MediaQueryTrackerOptions['changeTemplate'];
|
|
32
|
+
private hitFilter?: MediaQueryTrackerOptions['hitFilter'];
|
|
33
|
+
private trackedQueries: TrackedQuery[] = [];
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
+
private debouncedHandlers: DebouncedFunction<any>[] = [];
|
|
36
|
+
|
|
37
|
+
constructor(send: SendFunction, options?: MediaQueryTrackerOptions) {
|
|
38
|
+
this.send = send;
|
|
39
|
+
this.changeTimeout = options?.changeTimeout ?? 1000;
|
|
40
|
+
this.eventName = options?.eventName ?? 'media_query_change';
|
|
41
|
+
this.changeTemplate = options?.changeTemplate;
|
|
42
|
+
this.hitFilter = options?.hitFilter;
|
|
43
|
+
|
|
44
|
+
if (typeof window.matchMedia !== 'function') return;
|
|
45
|
+
|
|
46
|
+
const definitions = options?.definitions ?? [];
|
|
47
|
+
definitions.forEach((definition) => {
|
|
48
|
+
this.trackDefinition(definition);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private trackDefinition(definition: MediaQueryDefinition): void {
|
|
53
|
+
const initialValue = getMatchingValue(definition);
|
|
54
|
+
|
|
55
|
+
definition.items.forEach((item) => {
|
|
56
|
+
const mql = window.matchMedia(item.media);
|
|
57
|
+
|
|
58
|
+
const debouncedHandler = debounce(() => {
|
|
59
|
+
const newValue = getMatchingValue(definition);
|
|
60
|
+
const tracked = this.trackedQueries.find(
|
|
61
|
+
(tq) => tq.definition === definition
|
|
62
|
+
);
|
|
63
|
+
if (!tracked || newValue === tracked.currentValue) return;
|
|
64
|
+
|
|
65
|
+
const oldValue = tracked.currentValue;
|
|
66
|
+
tracked.currentValue = newValue;
|
|
67
|
+
|
|
68
|
+
const changeLabel = this.changeTemplate
|
|
69
|
+
? this.changeTemplate(oldValue, newValue)
|
|
70
|
+
: `${oldValue} => ${newValue}`;
|
|
71
|
+
|
|
72
|
+
let params: Record<string, unknown> = {
|
|
73
|
+
media_query_name: definition.name,
|
|
74
|
+
media_query_value: newValue,
|
|
75
|
+
media_query_change: changeLabel,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (this.hitFilter) {
|
|
79
|
+
const filtered = this.hitFilter(params);
|
|
80
|
+
if (filtered === null) return;
|
|
81
|
+
params = filtered;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
this.send(this.eventName, params);
|
|
85
|
+
}, this.changeTimeout);
|
|
86
|
+
|
|
87
|
+
this.debouncedHandlers.push(debouncedHandler);
|
|
88
|
+
|
|
89
|
+
const listener = () => {
|
|
90
|
+
debouncedHandler();
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
if (typeof mql.addEventListener === 'function') {
|
|
94
|
+
mql.addEventListener('change', listener);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.trackedQueries.push({
|
|
98
|
+
definition,
|
|
99
|
+
mql,
|
|
100
|
+
currentValue: initialValue,
|
|
101
|
+
listener,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
remove(): void {
|
|
107
|
+
this.trackedQueries.forEach(({ mql, listener }) => {
|
|
108
|
+
if (typeof mql.removeEventListener === 'function') {
|
|
109
|
+
mql.removeEventListener('change', listener);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
this.debouncedHandlers.forEach((d) => d.cancel());
|
|
113
|
+
this.trackedQueries = [];
|
|
114
|
+
this.debouncedHandlers = [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { GA4Plugin, SendFunction, OutboundFormTrackerOptions } from '../types/plugins';
|
|
2
|
+
import { delegate, DelegateHandle } from '../helpers/delegate';
|
|
3
|
+
import { parseUrl } from '../helpers/parse-url';
|
|
4
|
+
|
|
5
|
+
function defaultShouldTrack(form: HTMLFormElement, parseUrlFn: (url: string) => { hostname: string; protocol: string; href: string }): boolean {
|
|
6
|
+
const action = form.action;
|
|
7
|
+
if (!action) return false;
|
|
8
|
+
const url = parseUrlFn(action);
|
|
9
|
+
return url.hostname !== location.hostname && url.protocol.startsWith('http');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Automatically tracks form submissions to external domains.
|
|
14
|
+
*
|
|
15
|
+
* Sends an event when a form's `action` attribute points to a
|
|
16
|
+
* different hostname than the current page.
|
|
17
|
+
*/
|
|
18
|
+
export class OutboundFormTracker implements GA4Plugin {
|
|
19
|
+
private delegateHandle: DelegateHandle;
|
|
20
|
+
private send: SendFunction;
|
|
21
|
+
private eventName: string;
|
|
22
|
+
private shouldTrackOutboundForm: NonNullable<OutboundFormTrackerOptions['shouldTrackOutboundForm']>;
|
|
23
|
+
private hitFilter?: OutboundFormTrackerOptions['hitFilter'];
|
|
24
|
+
|
|
25
|
+
constructor(send: SendFunction, options?: OutboundFormTrackerOptions) {
|
|
26
|
+
this.send = send;
|
|
27
|
+
this.eventName = options?.eventName ?? 'outbound_form_submit';
|
|
28
|
+
this.shouldTrackOutboundForm = options?.shouldTrackOutboundForm ?? defaultShouldTrack;
|
|
29
|
+
this.hitFilter = options?.hitFilter;
|
|
30
|
+
|
|
31
|
+
const formSelector = options?.formSelector ?? 'form';
|
|
32
|
+
|
|
33
|
+
this.delegateHandle = delegate(
|
|
34
|
+
document,
|
|
35
|
+
'submit',
|
|
36
|
+
formSelector,
|
|
37
|
+
(event, element) => this.handleFormSubmit(event, element as HTMLFormElement),
|
|
38
|
+
{ composed: true, useCapture: true }
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private handleFormSubmit(event: Event, form: HTMLFormElement): void {
|
|
43
|
+
if (!this.shouldTrackOutboundForm(form, parseUrl)) return;
|
|
44
|
+
|
|
45
|
+
const url = parseUrl(form.action);
|
|
46
|
+
|
|
47
|
+
let params: Record<string, unknown> = {
|
|
48
|
+
form_action: url.href,
|
|
49
|
+
form_domain: url.hostname,
|
|
50
|
+
outbound: true,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (this.hitFilter) {
|
|
54
|
+
const filtered = this.hitFilter(params, form, event);
|
|
55
|
+
if (filtered === null) return;
|
|
56
|
+
params = filtered;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
this.send(this.eventName, params);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
remove(): void {
|
|
63
|
+
this.delegateHandle.destroy();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { GA4Plugin, SendFunction, OutboundLinkTrackerOptions } from '../types/plugins';
|
|
2
|
+
import { delegate, DelegateHandle } from '../helpers/delegate';
|
|
3
|
+
import { parseUrl } from '../helpers/parse-url';
|
|
4
|
+
|
|
5
|
+
function defaultShouldTrack(link: Element, parseUrlFn: (url: string) => { hostname: string; protocol: string; href: string }): boolean {
|
|
6
|
+
const href = link.getAttribute('href') || link.getAttribute('xlink:href');
|
|
7
|
+
if (!href) return false;
|
|
8
|
+
const url = parseUrlFn(href);
|
|
9
|
+
return url.hostname !== location.hostname && url.protocol.startsWith('http');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Automatically tracks clicks on outbound links (links to external domains).
|
|
14
|
+
*
|
|
15
|
+
* Sends an event when a user clicks a link whose hostname differs
|
|
16
|
+
* from the current page's hostname.
|
|
17
|
+
*/
|
|
18
|
+
export class OutboundLinkTracker implements GA4Plugin {
|
|
19
|
+
private delegates: DelegateHandle[] = [];
|
|
20
|
+
private send: SendFunction;
|
|
21
|
+
private events: string[];
|
|
22
|
+
private linkSelector: string;
|
|
23
|
+
private eventName: string;
|
|
24
|
+
private shouldTrackOutboundLink: NonNullable<OutboundLinkTrackerOptions['shouldTrackOutboundLink']>;
|
|
25
|
+
private hitFilter?: OutboundLinkTrackerOptions['hitFilter'];
|
|
26
|
+
|
|
27
|
+
constructor(send: SendFunction, options?: OutboundLinkTrackerOptions) {
|
|
28
|
+
this.send = send;
|
|
29
|
+
this.events = options?.events ?? ['click'];
|
|
30
|
+
this.linkSelector = options?.linkSelector ?? 'a, area';
|
|
31
|
+
this.eventName = options?.eventName ?? 'outbound_link_click';
|
|
32
|
+
this.shouldTrackOutboundLink = options?.shouldTrackOutboundLink ?? defaultShouldTrack;
|
|
33
|
+
this.hitFilter = options?.hitFilter;
|
|
34
|
+
|
|
35
|
+
this.events.forEach((eventType) => {
|
|
36
|
+
const handle = delegate(
|
|
37
|
+
document,
|
|
38
|
+
eventType,
|
|
39
|
+
this.linkSelector,
|
|
40
|
+
(event, element) => this.handleLinkInteraction(event, element),
|
|
41
|
+
{ composed: true, useCapture: true }
|
|
42
|
+
);
|
|
43
|
+
this.delegates.push(handle);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private handleLinkInteraction(event: Event, element: Element): void {
|
|
48
|
+
if (!this.shouldTrackOutboundLink(element, parseUrl)) return;
|
|
49
|
+
|
|
50
|
+
const href = element.getAttribute('href') || element.getAttribute('xlink:href') || '';
|
|
51
|
+
const url = parseUrl(href);
|
|
52
|
+
|
|
53
|
+
let params: Record<string, unknown> = {
|
|
54
|
+
link_url: url.href,
|
|
55
|
+
link_domain: url.hostname,
|
|
56
|
+
outbound: true,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (this.hitFilter) {
|
|
60
|
+
const filtered = this.hitFilter(params, element, event);
|
|
61
|
+
if (filtered === null) return;
|
|
62
|
+
params = filtered;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.send(this.eventName, params);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
remove(): void {
|
|
69
|
+
this.delegates.forEach((d) => d.destroy());
|
|
70
|
+
this.delegates = [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { GA4Plugin, SendFunction, PageVisibilityTrackerOptions } from '../types/plugins';
|
|
2
|
+
import { isSessionExpired, updateSessionTimestamp } from '../helpers/session';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tracks how long a page is in the visible state vs. hidden (background tab).
|
|
6
|
+
*
|
|
7
|
+
* Sends a `page_visibility` event each time the visibility state changes,
|
|
8
|
+
* reporting how long the page was in the previous state.
|
|
9
|
+
* Optionally sends a new `page_view` when the page becomes visible
|
|
10
|
+
* again after the session timeout has elapsed.
|
|
11
|
+
*/
|
|
12
|
+
export class PageVisibilityTracker implements GA4Plugin {
|
|
13
|
+
private send: SendFunction;
|
|
14
|
+
private sendInitialPageview: boolean;
|
|
15
|
+
private sessionTimeout: number;
|
|
16
|
+
private eventName: string;
|
|
17
|
+
private hitFilter?: PageVisibilityTrackerOptions['hitFilter'];
|
|
18
|
+
private lastChangeTime: number;
|
|
19
|
+
private isVisible: boolean;
|
|
20
|
+
private boundVisibilityChange: () => void;
|
|
21
|
+
private boundBeforeUnload: () => void;
|
|
22
|
+
|
|
23
|
+
constructor(send: SendFunction, options?: PageVisibilityTrackerOptions) {
|
|
24
|
+
this.send = send;
|
|
25
|
+
this.sendInitialPageview = options?.sendInitialPageview ?? false;
|
|
26
|
+
this.sessionTimeout = options?.sessionTimeout ?? 30;
|
|
27
|
+
this.eventName = options?.eventName ?? 'page_visibility';
|
|
28
|
+
this.hitFilter = options?.hitFilter;
|
|
29
|
+
|
|
30
|
+
this.lastChangeTime = Date.now();
|
|
31
|
+
this.isVisible = document.visibilityState === 'visible';
|
|
32
|
+
|
|
33
|
+
this.boundVisibilityChange = this.onVisibilityChange.bind(this);
|
|
34
|
+
this.boundBeforeUnload = this.onBeforeUnload.bind(this);
|
|
35
|
+
|
|
36
|
+
document.addEventListener('visibilitychange', this.boundVisibilityChange);
|
|
37
|
+
window.addEventListener('beforeunload', this.boundBeforeUnload);
|
|
38
|
+
|
|
39
|
+
updateSessionTimestamp('pageVisibility');
|
|
40
|
+
|
|
41
|
+
if (this.sendInitialPageview && this.isVisible) {
|
|
42
|
+
this.send('page_view', {
|
|
43
|
+
page_path: location.pathname,
|
|
44
|
+
page_location: location.href,
|
|
45
|
+
page_title: document.title,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private onVisibilityChange(): void {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const duration = now - this.lastChangeTime;
|
|
53
|
+
const previousState = this.isVisible ? 'visible' : 'hidden';
|
|
54
|
+
|
|
55
|
+
let params: Record<string, unknown> = {
|
|
56
|
+
visibility_state: previousState,
|
|
57
|
+
visibility_duration: duration,
|
|
58
|
+
page_path: location.pathname,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (this.hitFilter) {
|
|
62
|
+
const filtered = this.hitFilter(params);
|
|
63
|
+
if (filtered === null) {
|
|
64
|
+
this.lastChangeTime = now;
|
|
65
|
+
this.isVisible = document.visibilityState === 'visible';
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
params = filtered;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.send(this.eventName, params);
|
|
72
|
+
|
|
73
|
+
// If page becomes visible again, check session expiry
|
|
74
|
+
if (document.visibilityState === 'visible' && !this.isVisible) {
|
|
75
|
+
if (isSessionExpired('pageVisibility', this.sessionTimeout)) {
|
|
76
|
+
this.send('page_view', {
|
|
77
|
+
page_path: location.pathname,
|
|
78
|
+
page_location: location.href,
|
|
79
|
+
page_title: document.title,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
updateSessionTimestamp('pageVisibility');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.lastChangeTime = now;
|
|
86
|
+
this.isVisible = document.visibilityState === 'visible';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private onBeforeUnload(): void {
|
|
90
|
+
if (this.isVisible) {
|
|
91
|
+
const duration = Date.now() - this.lastChangeTime;
|
|
92
|
+
this.send(this.eventName, {
|
|
93
|
+
visibility_state: 'visible',
|
|
94
|
+
visibility_duration: duration,
|
|
95
|
+
page_path: location.pathname,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
remove(): void {
|
|
101
|
+
document.removeEventListener('visibilitychange', this.boundVisibilityChange);
|
|
102
|
+
window.removeEventListener('beforeunload', this.boundBeforeUnload);
|
|
103
|
+
}
|
|
104
|
+
}
|