import gtag from "./gtag"; import format from "./format"; /* Links https://developers.google.com/gtagjs/reference/api https://developers.google.com/tag-platform/gtagjs/reference */ export interface GaOptions { cookieUpdate?: boolean; // true cookieExpires?: number; // Default two years 63072000 cookieDomain?: string; // auto cookieFlags?: string; userId?: string; clientId?: string; anonymizeIp?: boolean; contentGroup1?: string; contentGroup2?: string; contentGroup3?: string; contentGroup4?: string; contentGroup5?: string; allowAdFeatures?: boolean; allowAdPersonalizationSignals?: boolean; nonInteraction?: boolean; page?: string; } export interface UaEventOptions { action: string; category: string; label?: string; value?: number; nonInteraction?: boolean; transport?: 'beacon' | 'xhr' | 'image'; } export interface InitOptions { trackingId: string; gaOptions?: GaOptions | any; gtagOptions?: any; // New parameter } export class GA4 { isInitialized!: boolean; _testMode!: boolean; _currentMeasurementId!: string; _hasLoadedGA!: boolean; _isQueuing!: boolean; _queueGtag!: any[]; constructor() { this.reset(); } reset = (): void => { this.isInitialized = false; this._testMode = false; this._currentMeasurementId = ""; this._hasLoadedGA = false; this._isQueuing = false; this._queueGtag = []; }; _gtag = (...args: any[]): void => { if (!this._testMode) { if (this._isQueuing) { this._queueGtag.push(args); } else { gtag(...args); } } else { this._queueGtag.push(args); } }; gtag(...args: any[]): void { this._gtag(...args); } _loadGA = ( GA_MEASUREMENT_ID: string, nonce?: string, gtagUrl: string = "https://www.googletagmanager.com/gtag/js" ): void => { if (typeof window === "undefined" || typeof document === "undefined") { return; } if (!this._hasLoadedGA) { // Global Site Tag (gtag.js) - Google Analytics const script = document.createElement("script"); script.async = true; script.src = `${gtagUrl}?id=${GA_MEASUREMENT_ID}`; if (nonce) { script.setAttribute("nonce", nonce); } document.body.appendChild(script); window.dataLayer = window.dataLayer || []; window.gtag = function gtag() { window.dataLayer.push(arguments); }; this._hasLoadedGA = true; } }; _toGtagOptions = (gaOptions?: GaOptions): Record | undefined => { if (!gaOptions) { return; } const mapFields: Record = { // Old https://developers.google.com/analytics/devguides/collection/analyticsjs/field-reference#cookieUpdate // New https://developers.google.com/analytics/devguides/collection/gtagjs/cookies-user-id#cookie_update cookieUpdate: "cookie_update", cookieExpires: "cookie_expires", cookieDomain: "cookie_domain", cookieFlags: "cookie_flags", // must be in set method? userId: "user_id", clientId: "client_id", anonymizeIp: "anonymize_ip", // https://support.google.com/analytics/answer/2853546?hl=en#zippy=%2Cin-this-article contentGroup1: "content_group1", contentGroup2: "content_group2", contentGroup3: "content_group3", contentGroup4: "content_group4", contentGroup5: "content_group5", // https://support.google.com/analytics/answer/9050852?hl=en allowAdFeatures: "allow_google_signals", allowAdPersonalizationSignals: "allow_ad_personalization_signals", nonInteraction: "non_interaction", page: "page_path", hitCallback: "event_callback", }; const gtagOptions = Object.entries(gaOptions).reduce( (prev, [key, value]) => { if (mapFields[key]) { prev[mapFields[key]] = value; } else { prev[key] = value; } return prev; }, {} as Record ); return gtagOptions; }; /** * * @param {InitOptions[]|string} GA_MEASUREMENT_ID * @param {Object} [options] * @param {string} [options.nonce] * @param {boolean} [options.testMode=false] * @param {string} [options.gtagUrl=https://www.googletagmanager.com/gtag/js] * @param {GaOptions|any} [options.gaOptions] * @param {Object} [options.gtagOptions] New parameter */ initialize = (GA_MEASUREMENT_ID: InitOptions[] | string, options: { nonce?: string; testMode?: boolean; gtagUrl?: string; gaOptions?: GaOptions | any; gtagOptions?: any; } = {}): void => { if (!GA_MEASUREMENT_ID) { throw new Error("Require GA_MEASUREMENT_ID"); } const initConfigs: InitOptions[] = typeof GA_MEASUREMENT_ID === "string" ? [{ trackingId: GA_MEASUREMENT_ID }] : GA_MEASUREMENT_ID; this._currentMeasurementId = initConfigs[0].trackingId; const { gaOptions, gtagOptions, nonce, testMode = false, gtagUrl, } = options; this._testMode = testMode; if (!testMode) { this._loadGA(this._currentMeasurementId, nonce, gtagUrl); } if (!this.isInitialized) { this._gtag("js", new Date()); initConfigs.forEach((config) => { const mergedGtagOptions = { ...this._toGtagOptions({ ...gaOptions, ...config.gaOptions }), ...gtagOptions, ...config.gtagOptions, }; if (Object.keys(mergedGtagOptions).length) { this._gtag("config", config.trackingId, mergedGtagOptions); } else { this._gtag("config", config.trackingId); } }); } this.isInitialized = true; if (!testMode) { const queues = [...this._queueGtag]; this._queueGtag = []; this._isQueuing = false; while (queues.length) { const queue = queues.shift(); this._gtag(...queue); if (queue[0] === "get") { this._isQueuing = true; } } } }; set = (fieldsObject: any): void => { if (!fieldsObject) { console.warn("`fieldsObject` is required in .set()"); return; } if (typeof fieldsObject !== "object") { console.warn("Expected `fieldsObject` arg to be an Object"); return; } if (Object.keys(fieldsObject).length === 0) { console.warn("empty `fieldsObject` given to .set()"); } this._gaCommand("set", fieldsObject); }; _gaCommandSendEvent = ( eventCategory: string, eventAction: string, eventLabel?: string, eventValue?: number, fieldsObject?: any ): void => { this._gtag("event", eventAction, { event_category: eventCategory, event_label: eventLabel, value: eventValue, ...(fieldsObject && { non_interaction: fieldsObject.nonInteraction }), ...this._toGtagOptions(fieldsObject), }); }; _gaCommandSendEventParameters = (...args: any[]): void => { if (typeof args[0] === "string") { this._gaCommandSendEvent(...(args.slice(1) as [string, string, string?, number?, any?])); } else { const { eventCategory, eventAction, eventLabel, eventValue, // eslint-disable-next-line no-unused-vars hitType, ...rest } = args[0]; this._gaCommandSendEvent( eventCategory, eventAction, eventLabel, eventValue, rest ); } }; _gaCommandSendTiming = ( timingCategory: string, timingVar: string, timingValue: number, timingLabel?: string ): void => { this._gtag("event", "timing_complete", { name: timingVar, value: timingValue, event_category: timingCategory, event_label: timingLabel, }); }; _gaCommandSendPageview = (page?: string, fieldsObject?: any): void => { if (fieldsObject && Object.keys(fieldsObject).length) { const { title, location, ...rest } = this._toGtagOptions(fieldsObject) || {}; this._gtag("event", "page_view", { ...(page && { page_path: page }), ...(title && { page_title: title }), ...(location && { page_location: location }), ...rest, }); } else if (page) { this._gtag("event", "page_view", { page_path: page }); } else { this._gtag("event", "page_view"); } }; _gaCommandSendPageviewParameters = (...args: any[]): void => { if (typeof args[0] === "string") { this._gaCommandSendPageview(...args.slice(1)); } else { const { page, // eslint-disable-next-line no-unused-vars hitType, ...rest } = args[0]; this._gaCommandSendPageview(page, rest); } }; // https://developers.google.com/analytics/devguides/collection/analyticsjs/command-queue-reference#send _gaCommandSend = (...args: any[]): void => { const hitType = typeof args[0] === "string" ? args[0] : args[0].hitType; switch (hitType) { case "event": this._gaCommandSendEventParameters(...args); break; case "pageview": this._gaCommandSendPageviewParameters(...args); break; case "timing": this._gaCommandSendTiming(...(args.slice(1) as [string, string, number, string?])); break; case "screenview": case "transaction": case "item": case "social": case "exception": console.warn(`Unsupported send command: ${hitType}`); break; default: console.warn(`Send command doesn't exist: ${hitType}`); } }; _gaCommandSet = (...args: any[]): void => { if (typeof args[0] === "string") { args[0] = { [args[0]]: args[1] }; } this._gtag("set", this._toGtagOptions(args[0])); }; _gaCommand = (command: string, ...args: any[]): void => { switch (command) { case "send": this._gaCommandSend(...args); break; case "set": this._gaCommandSet(...args); break; default: console.warn(`Command doesn't exist: ${command}`); } }; ga = (...args: any[]): any => { if (typeof args[0] === "string") { this._gaCommand(...(args as [string, ...any[]])); } else { const [readyCallback] = args; this._gtag("get", this._currentMeasurementId, "client_id", (clientId: string) => { this._isQueuing = false; const queues = this._queueGtag; readyCallback({ get: (property: string) => property === "clientId" ? clientId : property === "trackingId" ? this._currentMeasurementId : property === "apiVersion" ? "1" : undefined, }); while (queues.length) { const queue = queues.shift(); this._gtag(...queue); } }); this._isQueuing = true; } return this.ga; }; /** * @param {UaEventOptions|string} optionsOrName * @param {Object} [params] */ event = (optionsOrName: UaEventOptions | string, params?: any): void => { if (typeof optionsOrName === "string") { this._gtag("event", optionsOrName, this._toGtagOptions(params)); } else { const { action, category, label, value, nonInteraction, transport } = optionsOrName; if (!category || !action) { console.warn("args.category AND args.action are required in event()"); return; } // Required Fields const fieldObject = { hitType: "event", eventCategory: format(category), eventAction: format(action), }; // Optional Fields if (label) { (fieldObject as any).eventLabel = format(label); } if (typeof value !== "undefined") { if (typeof value !== "number") { console.warn("Expected `args.value` arg to be a Number."); } else { (fieldObject as any).eventValue = value; } } if (typeof nonInteraction !== "undefined") { if (typeof nonInteraction !== "boolean") { console.warn("`args.nonInteraction` must be a boolean."); } else { (fieldObject as any).nonInteraction = nonInteraction; } } if (typeof transport !== "undefined") { if (typeof transport !== "string") { console.warn("`args.transport` must be a string."); } else { if (["beacon", "xhr", "image"].indexOf(transport) === -1) { console.warn( "`args.transport` must be either one of these values: `beacon`, `xhr` or `image`" ); } (fieldObject as any).transport = transport; } } this._gaCommand("send", fieldObject); } }; send = (fieldObject: any): void => { this._gaCommand("send", fieldObject); }; } export default new GA4();