Repository: MatteoGabriele/vue-gtag (ts)

Issue:
UTM Campaign Tracking
How to use this plugin to track UTM campaign? As far as I know, this does not work with Vue Router. Is there any option to make it work with this plugin?
Track landing page view before router redirect
In our app we have router redirect to sign in page if user's auth token has expired. If we want additionally track UTM parameters (utm_source, utm_medium, etc), we pass it in the URL. For example:

```
/dashboard?utm_source=push&utm_medium=welcome
```

If a user is signed in, the VueGtag takes this URL with utm_* params and tracks them correctly.
If a user is not signed in and we redirect it immediately to /sign-up, and it tracks the latter URL. Probably to the race condition.

Of course possible solutions are:
- Pass utm_* params to redirected URL. However there is not the only such case and dragging querystring through all redirects is a bit ugly in a large app.
- Wait for the tracking event to happen and redirect then. We tried:
```
Vue.use(VueGtag, {
  config: { id: "..." },
  onAfterTrack () {
     redirectNow()
  }
}, router)
``` 
But for some reason ```onAfterTrack()``` is *never* called (vue-gtag version 1.16.1). Moreover it's not clear if it is called always. For example when adblocker is active, or what if there is a network latency or Analytics service down. 

If there any recommendation how to handle such race conditions in a best way or do we miss something? 
  

Pull request description:
feat(pageview): set utm manually
sets utm tags manually when using pageview

closes #517 
closes #351 
closes #405

Patch:
diff --git a/package.json b/package.json
index 021fc3f..54cc860 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 8641130..9c529f1 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/utils.ts b/src/utils.ts
index 903a85a..1820d4b 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -79,3 +79,67 @@ export function deepMerge<T extends DeepMergeable, U extends DeepMergeable>(
 
   return output as T & U;
 }
+
+export function urlQueryReplace(queryParams: Record<string, string>): 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<string, string>;
+type UseUtmParams = {
+  utmParams: QueryParams;
+  cleanQueryParams: QueryParams;
+  cleanUrl: string;
+};
+
+export function useUtmParams(url: string): UseUtmParams {
+  const urlObject = new URL(url);
+  const utmParams: Record<string, string> = {};
+  const params: string[] = [];
+  const cleanQueryParams: Record<string, string> = {};
+
+  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}`;
+}


Test patch:
diff --git a/src/tests/api/pageview.test.ts b/src/tests/api/pageview.test.ts
index 8c9cc50..3b7f134 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 0ab9e72..71b97e0 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 df1dc67..624acb1 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=",
+      );
+    });
+  });
 });