diff --git a/package.json b/package.json index 021fc3fa..54cc860a 100644 --- a/package.json +++ b/package.json @@ -47,9 +47,7 @@ "main": "./dist/vue-gtag.js", "module": "./dist/vue-gtag.js", "types": "./dist/vue-gtag.d.ts", - "files": [ - "dist" - ], + "files": ["dist"], "bugs": { "url": "https://github.com/MatteoGabriele/vue-gtag/issues" }, diff --git a/src/api/pageview.ts b/src/api/pageview.ts index 8641130d..9c529f10 100644 --- a/src/api/pageview.ts +++ b/src/api/pageview.ts @@ -1,19 +1,18 @@ import { query } from "@/api/query"; +import { set } from "@/api/set"; import { getSettings } from "@/core/settings"; import type { GtagConfigParams } from "@/types/gtag"; import type { Route } from "@/types/settings"; +import { + getPathWithBase, + hasUtmParams, + urlQueryReplace, + useUtmParams, +} from "@/utils"; export type Pageview = GtagConfigParams; - export type PageviewParams = string | Route | Pageview; -function getPathWithBase(path: string, base: string): string { - const normalizedBase = base.endsWith("/") ? base : `${base}/`; - const normalizedPath = path.startsWith("/") ? path.substring(1) : path; - - return `${normalizedBase}${normalizedPath}`; -} - export function pageview(params: PageviewParams) { const { pageTracker } = getSettings(); @@ -28,7 +27,7 @@ export function pageview(params: PageviewParams) { const path = pageTracker?.useRouteFullPath ? params.fullPath : params.path; template = { - ...(params.name ? { page_title: params.name as string } : {}), + ...(params.name ? { page_title: params.name.toString() } : {}), page_path: pageTracker?.useRouterBasePath ? getPathWithBase(path, base) : path, @@ -49,5 +48,17 @@ export function pageview(params: PageviewParams) { template.page_path = template.page_path.slice(0, -1); } + if (hasUtmParams(template.page_location)) { + const { utmParams, cleanUrl, cleanQueryParams } = useUtmParams( + template.page_location, + ); + + template.page_location = cleanUrl; + + urlQueryReplace(cleanQueryParams); + + set("campaign", utmParams); + } + query("event", "page_view", template); } diff --git a/src/tests/api/pageview.test.ts b/src/tests/api/pageview.test.ts index 8c9cc502..3b7f1349 100644 --- a/src/tests/api/pageview.test.ts +++ b/src/tests/api/pageview.test.ts @@ -1,6 +1,7 @@ import { pageview } from "@/api/pageview"; import { query } from "@/api/query"; import { resetSettings, updateSettings } from "@/core/settings"; +import * as utils from "@/utils"; import { type Router, createRouter, createWebHistory } from "vue-router"; vi.mock("@/api/query"); @@ -11,6 +12,8 @@ describe("pageview", () => { beforeEach(async () => { resetSettings(); + vi.spyOn(utils, "urlQueryReplace"); + router = createRouter({ history: createWebHistory("/base-path"), routes: [ @@ -120,6 +123,30 @@ describe("pageview", () => { ); }); + it("should send utm parameters", () => { + pageview({ + page_path: "/", + page_location: + "http://localhost:3000/?foo=1&utm_source=google&utm_medium=cpc&utm_campaign=summer_sale&bar=2", + }); + + expect(query).toHaveBeenNthCalledWith(1, "set", "campaign", { + source: "google", + medium: "cpc", + id: "summer_sale", + }); + + expect(query).toHaveBeenNthCalledWith( + 2, + "event", + "page_view", + expect.objectContaining({ + page_path: "/", + page_location: "http://localhost:3000/?foo=1&bar=2", + }), + ); + }); + describe("pageTracker enabled", () => { beforeEach(() => { updateSettings({ @@ -127,6 +154,25 @@ describe("pageview", () => { }); }); + it("should clear the query from utm params", async () => { + await router.push({ + query: { + foo: "2", + utm_source: "google", + utm_medium: "cpc", + utm_campaign: "summer_sale", + bar: "2", + }, + }); + + pageview(router.currentRoute.value); + + expect(utils.urlQueryReplace).toHaveBeenCalledWith({ + foo: "2", + bar: "2", + }); + }); + it("should track a page path using a route", () => { pageview(router.currentRoute.value); diff --git a/src/index.test.ts b/src/tests/index.test.ts similarity index 95% rename from src/index.test.ts rename to src/tests/index.test.ts index 0ab9e727..71b97e09 100644 --- a/src/index.test.ts +++ b/src/tests/index.test.ts @@ -1,4 +1,4 @@ -import * as plugin from "./index"; +import * as plugin from "@/index"; describe("index", () => { it("should have the following exports", () => { diff --git a/src/utils.test.ts b/src/tests/utils.test.ts similarity index 54% rename from src/utils.test.ts rename to src/tests/utils.test.ts index df1dc67c..624acb1c 100644 --- a/src/utils.test.ts +++ b/src/tests/utils.test.ts @@ -1,5 +1,5 @@ +import * as utils from "@/utils"; import flushPromises from "flush-promises"; -import * as utils from "./utils"; const defaultUrl = "https://www.googletagmanager.com/gtag/js?id=12345678"; @@ -73,4 +73,67 @@ describe("utils", () => { expect(scripts[0].getAttribute("type")).toEqual("text/partytown"); }); }); + + describe("urlQueryReplace", () => { + const originalLocation = window.location; + const originalHistory = window.history; + + beforeEach(() => { + Object.defineProperty(window, "location", { + value: { + ...originalLocation, + href: "https://example.com/page?oldParam=value&utm_source=test", + }, + writable: true, + }); + + // Mock window.history.replaceState + window.history.replaceState = vi.fn(); + }); + + afterEach(() => { + // Restore window.location + Object.defineProperty(window, "location", { + value: originalLocation, + writable: true, + }); + window.history.replaceState = originalHistory.replaceState; + }); + + it("should replace URL query parameters without page refresh", () => { + const newQueryParams = { + newParam: "newValue", + anotherParam: "anotherValue", + }; + + utils.urlQueryReplace(newQueryParams); + + // Check if history.replaceState was called correctly + expect(window.history.replaceState).toHaveBeenCalledWith( + {}, + "", + "https://example.com/page?newParam=newValue&anotherParam=anotherValue", + ); + }); + + it("should clear all existing query parameters", () => { + utils.urlQueryReplace({}); + + expect(window.history.replaceState).toHaveBeenCalledWith( + {}, + "", + "https://example.com/page", + ); + }); + + it("should handle empty parameters", () => { + utils.urlQueryReplace({ param: "" }); + + expect(window.history.replaceState).toHaveBeenCalledWith( + {}, + "", + "https://example.com/page?param=", + ); + }); + }); }); diff --git a/src/utils.ts b/src/utils.ts index 903a85a9..1820d4b5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -79,3 +79,67 @@ export function deepMerge( return output as T & U; } + +export function urlQueryReplace(queryParams: Record): void { + if (isServer()) { + return; + } + + const url = new URL(window.location.href); + + url.search = ""; + + for (const [key, value] of Object.entries(queryParams)) { + url.searchParams.set(key, value); + } + + window.history.replaceState({}, "", url.toString()); +} + +const UTM_PREFIX = "utm_"; + +type QueryParams = Record; +type UseUtmParams = { + utmParams: QueryParams; + cleanQueryParams: QueryParams; + cleanUrl: string; +}; + +export function useUtmParams(url: string): UseUtmParams { + const urlObject = new URL(url); + const utmParams: Record = {}; + const params: string[] = []; + const cleanQueryParams: Record = {}; + + urlObject.searchParams.forEach((value, key) => { + if (key.includes(UTM_PREFIX)) { + // Replace "campaign" with "id" to match Google Analytics campaign parameter naming + utmParams[key.replace(UTM_PREFIX, "").replace("campaign", "id")] = value; + params.push(key); + } else { + cleanQueryParams[key] = value; + } + }); + + for (const utmParam of params) { + urlObject.searchParams.delete(utmParam); + } + + return { + utmParams, + cleanQueryParams, + cleanUrl: urlObject.toString(), + }; +} + +export function hasUtmParams(url: string): boolean { + const utmRegex = new RegExp(`[?&]${UTM_PREFIX}`); + return !!url.match(utmRegex); +} + +export function getPathWithBase(path: string, base: string): string { + const normalizedBase = base.endsWith("/") ? base : `${base}/`; + const normalizedPath = path.startsWith("/") ? path.substring(1) : path; + + return `${normalizedBase}${normalizedPath}`; +}