SocialGouv / matomo-next

Matomo for Next.js applications
Apache License 2.0
151 stars 20 forks source link

NextJs 13 & app router #99

Open revolunet opened 1 year ago

revolunet commented 1 year ago

Looks like the new "app router" in NextJS@13 removed the router.events.

A fix is required to handle this news navigation pattern

https://github.com/vercel/next.js/discussions/42016

some example implementation : https://github.com/SocialGouv/mda/pull/286

vincentwinkel commented 1 year ago

any update?

colonder commented 11 months ago

What about this approach, maybe this could be used? https://sdorra.dev/posts/2022-11-11-next-with-fathom

eivindml commented 9 months ago

Also looking into this. Anyone got it working?

colonder commented 9 months ago

I created such component but I used it just as a standalone one placed in the code like any other. Any help to make it align with current implementation and make it backwards-compatible will be appreciated.

"use client";

import { usePathname, useSearchParams } from "next/navigation";
import React, { useEffect, useState } from "react";
import { Suspense } from 'react'
import { getCookieConsentValue } from "react-cookie-consent";

declare global {
  interface Window {
    _paq: any;
  }
}

const isExcludedUrl = (url: string, patterns: string[]): boolean => {
  let excluded = false;
  patterns.forEach((pattern) => {
    if (new RegExp(pattern).exec(url) !== null) {
      excluded = true;
    }
  });
  return excluded;
};

interface InitSettings {
  url?: string;
  siteId?: string;
  jsTrackerFile?: string;
  phpTrackerFile?: string;
  excludeUrlsPatterns?: string[];
  onRouteChangeStart?: (path: string) => void;
  onRouteChangeComplete?: (path: string) => void;
  onInitialization?: () => void;
}

interface Dimensions {
  dimension1?: string;
  dimension2?: string;
  dimension3?: string;
  dimension4?: string;
  dimension5?: string;
  dimension6?: string;
  dimension7?: string;
  dimension8?: string;
  dimension9?: string;
  dimension10?: string;
}

// to push custom events
export function push(
  args: (
    | Dimensions
    | number[]
    | string[]
    | number
    | string
    | null
    | undefined
  )[]
): void {
  if (!window._paq) {
    window._paq = [];
  }
  window._paq.push(args);
}

const startsWith = (str: string, needle: string) => {
  return str.substring(0, needle.length) === needle;
};

function Tracker({
  url,
  siteId,
  jsTrackerFile = "matomo.js",
  phpTrackerFile = "matomo.php",
  excludeUrlsPatterns = [],
  onRouteChangeStart = undefined,
  onRouteChangeComplete = undefined,
  onInitialization = undefined,
}: InitSettings) {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const [prevPath, setPrevPath] = useState(pathname);

  useEffect(() => {
    window._paq = window._paq !== null ? window._paq : [];
    if (!url) {
      console.warn("Matomo disabled, please provide matomo url");
      return;
    }
    // order is important -_- so campaign are detected
    const excludedUrl = typeof window !== "undefined" && isExcludedUrl(window.location.pathname, excludeUrlsPatterns);

    if (onInitialization) onInitialization();

    if (getCookieConsentValue("haczykowskaConsent")) {
      push(["forgetUserOptOut"]);
      push(["rememberCookieConsentGiven"]);
    } else {
      push(["optUserOut"]);
      push(["forgetCookieConsentGiven"]);
      push(["requireCookieConsent"]);
    }
    push(["enableHeartBeatTimer"]);
    push(["disableQueueRequest"]);
    push(["enableLinkTracking"]);
    push(["setTrackerUrl", `${url}/${phpTrackerFile}`]);
    push(["setSiteId", siteId]);

    if (excludedUrl) {
      if (typeof window !== "undefined") {
        console.log(`matomo: exclude track ${window.location.pathname}`);
      }
    } else {
      push(["trackPageView"]);
    }

    /**
     * for initial loading we use the location.pathname
     * as the first url visited.
     * Once user navigate across the site,
     * we rely on Router.pathname
     */
    const scriptElement = document.createElement("script");
    const refElement = document.getElementsByTagName("script")[0];
    scriptElement.type = "text/javascript";
    scriptElement.async = true;
    scriptElement.defer = true;
    scriptElement.src = `${url}/${jsTrackerFile}`;
    if (refElement.parentNode) {
      refElement.parentNode.insertBefore(scriptElement, refElement);
    }
  }, [])

  useEffect(() => {
    if (!pathname || getCookieConsentValue("haczykowskaConsent")) {
      return;
    }

    if (!prevPath) {
      return setPrevPath(pathname);
    }

    push(["setReferrerUrl", `${prevPath}`]);
    push(["setCustomUrl", pathname]);
    push(["deleteCustomVariables", "page"]);
    setPrevPath(pathname);
    if (onRouteChangeStart) onRouteChangeStart(pathname);
    // In order to ensure that the page title had been updated,
    // we delayed pushing the tracking to the next tick.
    setTimeout(() => {
      push(["setDocumentTitle", document.title]);
      if (!!searchParams) {
        push(["trackSiteSearch", searchParams.get("keyword") ?? ""]);
      } else {
        push(["trackPageView"]);
      }
    }, 0);

    if (onRouteChangeComplete) onRouteChangeComplete(pathname);

  }, [pathname, searchParams, prevPath, excludeUrlsPatterns, onRouteChangeComplete, onRouteChangeStart]);

  return null;
}

const MatomoTracker = (props: InitSettings) => {
  return (
    <Suspense fallback={null}>
      <Tracker {...props} />
    </Suspense>
  )
}

export default MatomoTracker
eivindml commented 9 months ago

Thanks for the snippet. It does work, but it looks like it tracks page views very sporadically?

colonder commented 9 months ago

Maybe, I noticed that too. Eventually, I abandoned Matomo and implemented Google Tag Manager, so feel free to modify my snippet however you like, maybe you will make it work as it should be.

laem commented 4 months ago

This is an attempt to set up Matomo for a simpler case, with no GDPR consent banner.

Might need some adjustments, I'll watch the Matomo events.

https://github.com/betagouv/reno/blob/master/utils/Matomo.tsx

JacquesVergine commented 4 months ago

Thanks laem, very useful, I used your code attempt, and it is tracking. However, and I might be wrong, it doesn't seem to track the different paths, I can only see the root path in Matomo.

I've pushed a custom URL to Matomo and now it's working. On line 26: push(['setCustomUrl', pathName + searchParamsString]);

laem commented 4 months ago

Thanks ! I can see different paths on my dashboard, but it may only be the initial page paths, not the subsequent ones. Thanks !

Capture d’écran 2024-03-27 à 17 23 07

jerommiole commented 3 months ago

Thanks @laem ! your repo is is very useful 😁

Check the repo here https://github.com/betagouv/reno/blob/master/utils/Matomo.tsx, and on line 26 add: push(['setCustomUrl', pathName + searchParamsString]);

laem commented 3 months ago

Thanks, done !

ziaq commented 1 month ago

Implementation with prevention of double tracking on the first site visit.

'use client'

import { init, push } from '@socialgouv/matomo-next';
import { usePathname } from 'next/navigation';
import { useEffect, useRef  } from 'react';

const MATOMO_URL = 'https://your-domain.matomo.cloud/';
const MATOMO_SITE_ID = '1';

export function MatomoAnalytics() {
  const pathname = usePathname();
  const isInitialLoad = useRef(true);

  useEffect(() => {
    init({ url: MATOMO_URL, siteId: MATOMO_SITE_ID });
    return () => push(['HeatmapSessionRecording::disable']);
  }, []);

  useEffect(() => {
    if (isInitialLoad.current) {
      isInitialLoad.current = false;

    } else {
      if (pathname) {
        push(['setCustomUrl', pathname]);
        push(['trackPageView']);
      }
    }
  }, [pathname])

  return null
}