@technoapple/ga4 1.0.3 → 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/dataLayer.d.ts +1 -1
- package/build/main/dataLayer.js +60 -10
- 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 -52
- package/src/dataLayer.ts +85 -23
- 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 -40
- package/test/ga4.spec.ts +36 -36
- package/tsconfig.json +28 -28
- package/tsconfig.module.json +11 -11
package/src/ga4/ga4.ts
CHANGED
|
@@ -1,41 +1,70 @@
|
|
|
1
|
-
import { ga4Option } from "./ga4option";
|
|
2
|
-
import { DataLayerObject } from "../types/dataLayer";
|
|
3
|
-
import { KeyValueParams, gtag } from "../types/gtag";
|
|
4
|
-
import {} from
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
private
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
1
|
+
import { ga4Option } from "./ga4option";
|
|
2
|
+
import { DataLayerObject } from "../types/dataLayer";
|
|
3
|
+
import { KeyValueParams, gtag } from "../types/gtag";
|
|
4
|
+
import { GA4Plugin, SendFunction } from "../types/plugins";
|
|
5
|
+
import {} from '../types/global';
|
|
6
|
+
|
|
7
|
+
class ga4 {
|
|
8
|
+
|
|
9
|
+
private static instance: ga4;
|
|
10
|
+
private _plugins: GA4Plugin[] = [];
|
|
11
|
+
|
|
12
|
+
private constructor() {
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
public init(option:ga4Option){
|
|
16
|
+
window.dataLayer = window.dataLayer || Array<DataLayerObject>;
|
|
17
|
+
window.gtag = window.gtag || function() {
|
|
18
|
+
window.dataLayer.push(arguments);
|
|
19
|
+
}
|
|
20
|
+
window.gtag('js', new Date());
|
|
21
|
+
window.gtag('config', option.targetId);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public static getInstance():ga4 {
|
|
25
|
+
if (!ga4.instance) {
|
|
26
|
+
ga4.instance = new ga4();
|
|
27
|
+
}
|
|
28
|
+
return ga4.instance;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public send(eventName:string, eventParameters: KeyValueParams ): boolean {
|
|
32
|
+
|
|
33
|
+
window.gtag('event', eventName, eventParameters);
|
|
34
|
+
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Register a plugin with the GA4 instance.
|
|
40
|
+
* @param PluginClass The plugin class constructor
|
|
41
|
+
* @param options Plugin-specific configuration options
|
|
42
|
+
* @returns The plugin instance (call `.remove()` to unregister)
|
|
43
|
+
*/
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
|
+
public use<T extends GA4Plugin>(
|
|
46
|
+
PluginClass: new (send: SendFunction, options?: any) => T,
|
|
47
|
+
options?: any
|
|
48
|
+
): T {
|
|
49
|
+
const send: SendFunction = (eventName: string, params: Record<string, unknown>) => {
|
|
50
|
+
window.gtag('event', eventName, params as KeyValueParams);
|
|
51
|
+
};
|
|
52
|
+
const plugin = new PluginClass(send, options);
|
|
53
|
+
this._plugins.push(plugin);
|
|
54
|
+
return plugin;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Remove all registered plugins and clean up their listeners.
|
|
59
|
+
*/
|
|
60
|
+
public removeAll(): void {
|
|
61
|
+
this._plugins.forEach(p => p.remove());
|
|
62
|
+
this._plugins = [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get gtag() : gtag {
|
|
66
|
+
return window.gtag;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
41
70
|
export {ga4};
|
package/src/ga4/ga4option.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
interface ga4Option {
|
|
2
|
-
targetId:string;
|
|
3
|
-
}
|
|
4
|
-
|
|
1
|
+
interface ga4Option {
|
|
2
|
+
targetId:string;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
5
|
export {ga4Option};
|
package/src/ga4/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {ga4} from './ga4';
|
|
2
|
-
|
|
3
|
-
const ga = ga4.getInstance();
|
|
4
|
-
|
|
1
|
+
import {ga4} from './ga4';
|
|
2
|
+
|
|
3
|
+
const ga = ga4.getInstance();
|
|
4
|
+
|
|
5
5
|
export {ga};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface DebouncedFunction<T extends (...args: never[]) => void> {
|
|
2
|
+
(...args: Parameters<T>): void;
|
|
3
|
+
cancel(): void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function debounce<T extends (...args: never[]) => void>(
|
|
7
|
+
fn: T,
|
|
8
|
+
delay: number
|
|
9
|
+
): DebouncedFunction<T> {
|
|
10
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
11
|
+
|
|
12
|
+
const debounced = function (this: unknown, ...args: Parameters<T>) {
|
|
13
|
+
if (timer !== null) clearTimeout(timer);
|
|
14
|
+
timer = setTimeout(() => {
|
|
15
|
+
timer = null;
|
|
16
|
+
fn.apply(this, args);
|
|
17
|
+
}, delay);
|
|
18
|
+
} as DebouncedFunction<T>;
|
|
19
|
+
|
|
20
|
+
debounced.cancel = () => {
|
|
21
|
+
if (timer !== null) {
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
timer = null;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return debounced;
|
|
28
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface DelegateHandle {
|
|
2
|
+
destroy(): void;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface DelegateOptions {
|
|
6
|
+
composed?: boolean;
|
|
7
|
+
useCapture?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function delegate(
|
|
11
|
+
target: EventTarget,
|
|
12
|
+
eventType: string,
|
|
13
|
+
selector: string,
|
|
14
|
+
handler: (event: Event, element: Element) => void,
|
|
15
|
+
options?: DelegateOptions
|
|
16
|
+
): DelegateHandle {
|
|
17
|
+
const useCapture = options?.useCapture ?? false;
|
|
18
|
+
|
|
19
|
+
const listener = (event: Event) => {
|
|
20
|
+
let element: Element | null = event.target as Element | null;
|
|
21
|
+
|
|
22
|
+
// Handle composed events (shadow DOM)
|
|
23
|
+
if (options?.composed && typeof event.composedPath === 'function') {
|
|
24
|
+
const path = event.composedPath();
|
|
25
|
+
for (const node of path) {
|
|
26
|
+
if (node === target) break;
|
|
27
|
+
if (node instanceof Element && node.matches(selector)) {
|
|
28
|
+
handler(event, node);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
while (element && element !== target) {
|
|
36
|
+
if (element.matches(selector)) {
|
|
37
|
+
handler(event, element);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
element = element.parentElement;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
target.addEventListener(eventType, listener, useCapture);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
destroy() {
|
|
48
|
+
target.removeEventListener(eventType, listener, useCapture);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface ParsedUrl {
|
|
2
|
+
href: string;
|
|
3
|
+
protocol: string;
|
|
4
|
+
hostname: string;
|
|
5
|
+
port: string;
|
|
6
|
+
pathname: string;
|
|
7
|
+
search: string;
|
|
8
|
+
hash: string;
|
|
9
|
+
origin: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parseUrl(url: string): ParsedUrl {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = new URL(url, location.href);
|
|
15
|
+
return {
|
|
16
|
+
href: parsed.href,
|
|
17
|
+
protocol: parsed.protocol,
|
|
18
|
+
hostname: parsed.hostname,
|
|
19
|
+
port: parsed.port,
|
|
20
|
+
pathname: parsed.pathname,
|
|
21
|
+
search: parsed.search,
|
|
22
|
+
hash: parsed.hash,
|
|
23
|
+
origin: parsed.origin,
|
|
24
|
+
};
|
|
25
|
+
} catch {
|
|
26
|
+
return {
|
|
27
|
+
href: url,
|
|
28
|
+
protocol: '',
|
|
29
|
+
hostname: '',
|
|
30
|
+
port: '',
|
|
31
|
+
pathname: url,
|
|
32
|
+
search: '',
|
|
33
|
+
hash: '',
|
|
34
|
+
origin: '',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const SESSION_KEY_PREFIX = 'ga4_session_';
|
|
2
|
+
|
|
3
|
+
export function isSessionExpired(key: string, timeoutMinutes: number): boolean {
|
|
4
|
+
try {
|
|
5
|
+
const stored = sessionStorage.getItem(SESSION_KEY_PREFIX + key);
|
|
6
|
+
if (!stored) return true;
|
|
7
|
+
const timestamp = parseInt(stored, 10);
|
|
8
|
+
if (isNaN(timestamp)) return true;
|
|
9
|
+
return (Date.now() - timestamp) > timeoutMinutes * 60 * 1000;
|
|
10
|
+
} catch {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function updateSessionTimestamp(key: string): void {
|
|
16
|
+
try {
|
|
17
|
+
sessionStorage.setItem(SESSION_KEY_PREFIX + key, String(Date.now()));
|
|
18
|
+
} catch {
|
|
19
|
+
// sessionStorage may be unavailable or full
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getSessionValue<T>(key: string): T | null {
|
|
24
|
+
try {
|
|
25
|
+
const stored = sessionStorage.getItem(SESSION_KEY_PREFIX + key);
|
|
26
|
+
if (stored === null) return null;
|
|
27
|
+
return JSON.parse(stored) as T;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function setSessionValue(key: string, value: unknown): void {
|
|
34
|
+
try {
|
|
35
|
+
sessionStorage.setItem(SESSION_KEY_PREFIX + key, JSON.stringify(value));
|
|
36
|
+
} catch {
|
|
37
|
+
// sessionStorage may be unavailable or full
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,34 @@
|
|
|
1
|
-
import {ga} from './ga4/index';
|
|
2
|
-
import {get} from './dataLayer';
|
|
3
|
-
|
|
4
|
-
const dataLayerHelper = { get };
|
|
5
|
-
const ga4 = ga;
|
|
6
|
-
|
|
7
|
-
export {ga4, dataLayerHelper};
|
|
1
|
+
import {ga} from './ga4/index';
|
|
2
|
+
import {get} from './dataLayer';
|
|
3
|
+
|
|
4
|
+
const dataLayerHelper = { get };
|
|
5
|
+
const ga4 = ga;
|
|
6
|
+
|
|
7
|
+
export {ga4, dataLayerHelper};
|
|
8
|
+
|
|
9
|
+
// Plugin exports
|
|
10
|
+
export { EventTracker } from './plugins/event-tracker';
|
|
11
|
+
export { OutboundLinkTracker } from './plugins/outbound-link-tracker';
|
|
12
|
+
export { OutboundFormTracker } from './plugins/outbound-form-tracker';
|
|
13
|
+
export { PageVisibilityTracker } from './plugins/page-visibility-tracker';
|
|
14
|
+
export { UrlChangeTracker } from './plugins/url-change-tracker';
|
|
15
|
+
export { ImpressionTracker } from './plugins/impression-tracker';
|
|
16
|
+
export { CleanUrlTracker } from './plugins/clean-url-tracker';
|
|
17
|
+
export { MediaQueryTracker } from './plugins/media-query-tracker';
|
|
18
|
+
|
|
19
|
+
// Type exports
|
|
20
|
+
export type {
|
|
21
|
+
GA4Plugin,
|
|
22
|
+
SendFunction,
|
|
23
|
+
EventTrackerOptions,
|
|
24
|
+
OutboundLinkTrackerOptions,
|
|
25
|
+
OutboundFormTrackerOptions,
|
|
26
|
+
PageVisibilityTrackerOptions,
|
|
27
|
+
UrlChangeTrackerOptions,
|
|
28
|
+
ImpressionTrackerOptions,
|
|
29
|
+
ImpressionElementConfig,
|
|
30
|
+
CleanUrlTrackerOptions,
|
|
31
|
+
MediaQueryTrackerOptions,
|
|
32
|
+
MediaQueryDefinition,
|
|
33
|
+
MediaQueryDefinitionItem,
|
|
34
|
+
} from './types/plugins';
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { GA4Plugin, SendFunction, CleanUrlTrackerOptions } from '../types/plugins';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normalizes URLs before they are sent with `page_view` events.
|
|
5
|
+
*
|
|
6
|
+
* Intercepts `gtag()` calls for `config` and `page_view` events
|
|
7
|
+
* and cleans the `page_location` and `page_path` parameters
|
|
8
|
+
* (strip query params, normalize trailing slashes, apply custom filters).
|
|
9
|
+
*/
|
|
10
|
+
export class CleanUrlTracker implements GA4Plugin {
|
|
11
|
+
private opts: CleanUrlTrackerOptions;
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
13
|
+
private originalGtag: Function | null = null;
|
|
14
|
+
|
|
15
|
+
constructor(_send: SendFunction, options?: CleanUrlTrackerOptions) {
|
|
16
|
+
this.opts = {
|
|
17
|
+
stripQuery: options?.stripQuery ?? false,
|
|
18
|
+
queryParamsAllowlist: options?.queryParamsAllowlist,
|
|
19
|
+
queryParamsDenylist: options?.queryParamsDenylist,
|
|
20
|
+
trailingSlash: options?.trailingSlash,
|
|
21
|
+
urlFilter: options?.urlFilter,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
if (typeof window !== 'undefined' && window.gtag) {
|
|
25
|
+
this.originalGtag = window.gtag;
|
|
26
|
+
const self = this;
|
|
27
|
+
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
+
(window as any).gtag = function () {
|
|
30
|
+
// eslint-disable-next-line prefer-rest-params
|
|
31
|
+
const args = Array.prototype.slice.call(arguments);
|
|
32
|
+
|
|
33
|
+
if (args.length >= 3 && typeof args[2] === 'object' && args[2] !== null) {
|
|
34
|
+
const isPageView = args[0] === 'event' && args[1] === 'page_view';
|
|
35
|
+
const isConfig = args[0] === 'config';
|
|
36
|
+
|
|
37
|
+
if (isPageView || isConfig) {
|
|
38
|
+
args[2] = self.cleanParams({ ...args[2] });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return self.originalGtag!.apply(window, args);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private cleanParams(params: Record<string, unknown>): Record<string, unknown> {
|
|
48
|
+
if (typeof params.page_location === 'string') {
|
|
49
|
+
params.page_location = this.cleanUrl(params.page_location);
|
|
50
|
+
}
|
|
51
|
+
if (typeof params.page_path === 'string') {
|
|
52
|
+
params.page_path = this.cleanPath(params.page_path);
|
|
53
|
+
}
|
|
54
|
+
return params;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
cleanUrl(url: string): string {
|
|
58
|
+
try {
|
|
59
|
+
const u = new URL(url);
|
|
60
|
+
u.pathname = this.cleanPath(u.pathname);
|
|
61
|
+
|
|
62
|
+
if (this.opts.stripQuery) {
|
|
63
|
+
if (this.opts.queryParamsAllowlist && this.opts.queryParamsAllowlist.length > 0) {
|
|
64
|
+
const allowed = new URLSearchParams();
|
|
65
|
+
this.opts.queryParamsAllowlist.forEach((param) => {
|
|
66
|
+
if (u.searchParams.has(param)) {
|
|
67
|
+
allowed.set(param, u.searchParams.get(param)!);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
u.search = allowed.toString() ? '?' + allowed.toString() : '';
|
|
71
|
+
} else {
|
|
72
|
+
u.search = '';
|
|
73
|
+
}
|
|
74
|
+
} else if (this.opts.queryParamsDenylist && this.opts.queryParamsDenylist.length > 0) {
|
|
75
|
+
this.opts.queryParamsDenylist.forEach((param) => {
|
|
76
|
+
u.searchParams.delete(param);
|
|
77
|
+
});
|
|
78
|
+
u.search = u.searchParams.toString() ? '?' + u.searchParams.toString() : '';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let result = u.toString();
|
|
82
|
+
if (this.opts.urlFilter) {
|
|
83
|
+
result = this.opts.urlFilter(result);
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
} catch {
|
|
87
|
+
return url;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
cleanPath(path: string): string {
|
|
92
|
+
let result = path;
|
|
93
|
+
|
|
94
|
+
if (this.opts.trailingSlash === 'remove') {
|
|
95
|
+
result = result.length > 1 ? result.replace(/\/+$/, '') : result;
|
|
96
|
+
} else if (this.opts.trailingSlash === 'add') {
|
|
97
|
+
if (!result.endsWith('/') && !result.split('/').pop()?.includes('.')) {
|
|
98
|
+
result += '/';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
remove(): void {
|
|
106
|
+
if (this.originalGtag) {
|
|
107
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
108
|
+
(window as any).gtag = this.originalGtag;
|
|
109
|
+
this.originalGtag = null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -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
|
+
}
|