services/tracking/tracking.jsxjavascript
import throttle from "lodash.throttle";
import {
  createContext,
  useEffect,
  useCallback,
  useContext,
  useRef,
  useMemo,
  useState,
} from "react";

/* 
  The function "TrackingProvider" inherits the functionality to send user activity to the clicksolutions matomo backend.
  A short documentary for those who need to adjust the tracking functionality...

  The functional component consists of:
    TrackingContext: Used to access the tracking utility function in several components

    TrackingProvider: @siteId: ID of the matomo project
                      @sessionTimeout: Inactivity time (in seconds) until a new user session will be created
    
    setTrackingObject: Inserts tracking script into the app - connects to matomo

    updateLastActivity:: sets last activity timestamp

    isSessionExpired: checks if inactivity time exceeds @sessionTimeout

    resetSession: Creates new user session

    trackPageView: Tracks pageview (@pageUrl and @pageTitle required)

    trackEvent: Tracks user event (@eventCategory, @eventAction and @eventName required)
*/

const MESSAGES = {
  missingSiteId: "Matomo tracking deactivated: Missing 'siteId' as constructor parameter.",
  useTrackingError: "useTracking must be used within a TrackingProvider",
  noSessionTimeout:
    "Matomo tracking session timeout is set to 0. Inactivity on the app won't lead to new user visits.",
  stopSessionWarning: (timeout) =>
    `Detected inactivity for ${timeout} seconds... Stopping matomo session.`,
  resetSessionWarning: "Detected activity after session timeout.. Setting up a new matomo session.",
  trackPageViewArgumentError: (received) =>
    `Error in function 'trackPageView': Expected 2 arguments ('pageUrl', 'pageTitle'), but received ${received}.`,
  trackPageViewMissingUrl: (url) =>
    `Error in function 'trackPageView': Expected 'pageUrl' to be set, but received ${url}.`,
  trackPageViewMissingTitle: (title) =>
    `Error in function 'trackPageView': Expected 'pageTitle' to be set, but received ${title}.`,
  trackEventArgumentError: (received) =>
    `Error in function 'trackEvent': Expected 3 arguments ('eventCategory', 'eventAction', 'eventName'), but received ${received}.`,
  trackEventMissingCategory: (category) =>
    `Error in function 'trackEvent': Expected 'eventCategory' to be set, but received ${category}.`,
  trackEventMissingAction: (action) =>
    `Error in function 'trackEvent': Expected 'eventAction' to be set, but received ${action}.`,
  trackEventMissingName: (name) =>
    `Error in function 'trackEvent': Expected 'eventName' to be set, but received ${name}.`,
  trackingInitializationError: (error) =>
    `Error in function 'setTrackingObject': Failed to initialize tracking object. Error details: ${error}`,
};

const TrackingContext = createContext(null);

const TrackingProvider = ({ children }) => {
  const siteId = import.meta.env.VITE_MATOMO_SITE_ID;
  const sessionTimeout = parseInt(import.meta.env.VITE_MATOMO_SESSION_TIMEOUT) || 120;

  if (!siteId) {
    // eslint-disable-next-line no-console
    console.warn(MESSAGES.missingSiteId);
  }

  const [sessionExpired, setSessionExpired] = useState(false);
  // ref-changes can't trigger re-renders so it's safe to ignore
  // eslint-disable-next-line react-hooks/purity
  const lastActivityRef = useRef(Date.now());
  const expiredRef = useRef(false);
  const setupRef = useRef(false);

  /**
   * Initializes the Matomo tracking object and injects the tracking script into the DOM.
   * Only runs once if not already initialized.
   */
  const setTrackingObject = useCallback(() => {
    if (!siteId || setupRef.current) return;

    try {
      const url = "https://mt.click-solutions.com/";
      window._paq = window._paq || [];
      window._paq.push(["setSiteId", siteId]);
      window._paq.push(["setTrackerUrl", `${url}matomo.php`]);

      if (!document.getElementById("matomo-script")) {
        const script = document.createElement("script");
        script.id = "matomo-script";
        script.async = true;
        script.src = `${url}matomo.js`;

        const firstScript = document.getElementsByTagName("script")[0];
        firstScript.parentNode.insertBefore(script, firstScript);
      }

      setupRef.current = true;
    } catch (e) {
      // eslint-disable-next-line no-console
      console.error(MESSAGES.trackingInitializationError(e));
    }
  }, [siteId]);

  /**
   * Creates a new Matomo session by triggering the `new_visit` flag.
   * Should be called after inactivity timeout when user returns.
   */
  const resetSession = useCallback(() => {
    if (!siteId) return;

    // eslint-disable-next-line no-console
    console.warn(MESSAGES.resetSessionWarning);
    window._paq.push(["appendToTrackingUrl", "new_visit=1"]);
    window._paq.push(["trackPageView"]);
    window._paq.push(["appendToTrackingUrl", ""]);

    lastActivityRef.current = Date.now();
  }, [siteId]);

  /**
   * Tracks a page view in Matomo using a custom URL and title.
   *
   * @param {string} pageUrl - (Required) The URL of the current page.
   * @param {string} pageTitle - (Required) The title of the current page.
   */
  const trackPageView = useCallback(
    (pageUrl, pageTitle) => {
      if (!window._paq || !siteId) return;
      const setArgs = 2 - [pageUrl, pageTitle].filter((a) => !a).length;

      if (setArgs !== 2) {
        // eslint-disable-next-line no-console
        console.error(MESSAGES.trackPageViewArgumentError(setArgs));
        return;
      }
      if (typeof pageUrl !== "string") {
        // eslint-disable-next-line no-console
        console.error(MESSAGES.trackPageViewMissingUrl(pageUrl));
        return;
      }
      if (typeof pageTitle !== "string") {
        // eslint-disable-next-line no-console
        console.error(MESSAGES.trackPageViewMissingTitle(pageTitle));
        return;
      }

      window._paq.push(["setCustomUrl", pageUrl]);
      window._paq.push(["setDocumentTitle", pageTitle]);
      window._paq.push(["trackPageView"]);
    },
    [siteId]
  );

  /**
   * Tracks a custom event in Matomo.
   *
   * @param {string} eventCategory - (Required) General category of the event (e.g., 'Button').
   * @param {string} eventAction - (Required) The action performed (e.g., 'Click').
   * @param {string} eventName - (Required) Specific label for the event (e.g., 'SignUp CTA').
   */
  const trackEvent = useCallback(
    (eventCategory, eventAction, eventName) => {
      if (!window._paq || !siteId) return;
      const setArgs = 3 - [eventCategory, eventAction, eventName].filter((a) => !a).length;

      if (setArgs !== 3) {
        // eslint-disable-next-line no-console
        console.error(MESSAGES.trackEventArgumentError(setArgs));
        return;
      }
      if (typeof eventCategory !== "string") {
        // eslint-disable-next-line no-console
        console.error(MESSAGES.trackEventMissingCategory(eventCategory));
        return;
      }
      if (typeof eventAction !== "string") {
        // eslint-disable-next-line no-console
        console.error(MESSAGES.trackEventMissingAction(eventAction));
        return;
      }
      if (typeof eventName !== "string") {
        // eslint-disable-next-line no-console
        console.error(MESSAGES.trackEventMissingName(eventName));
        return;
      }

      window._paq.push(["trackEvent", eventCategory, eventAction, eventName]);
    },
    [siteId]
  );

  useEffect(() => {
    setTrackingObject();
  }, [setTrackingObject]);

  const updateLastActivityRaw = useCallback(() => {
    if (expiredRef.current) {
      resetSession();
      expiredRef.current = false;
      setSessionExpired(false);
    }
    lastActivityRef.current = Date.now();
  }, [resetSession]);

  const updateLastActivity = useMemo(
    () => throttle(updateLastActivityRaw, 500),
    [updateLastActivityRaw]
  );

  useEffect(() => {
    if (!siteId) return;
    if (sessionTimeout === 0) {
      // eslint-disable-next-line no-console
      console.warn(MESSAGES.noSessionTimeout);
      return;
    }

    const events = [
      "click",
      "mousemove",
      "keydown",
      "touchstart",
      "touchmove",
      "touchend",
      "touchcancel",
      "scroll",
      "pointerdown",
      "pointermove",
    ];

    events.forEach((e) => window.addEventListener(e, updateLastActivity));

    const checker = setInterval(() => {
      const diffSec = (Date.now() - lastActivityRef.current) / 1000;
      if (!expiredRef.current && diffSec >= sessionTimeout) {
        // eslint-disable-next-line no-console
        console.warn(MESSAGES.stopSessionWarning(sessionTimeout));
        expiredRef.current = true;
        setSessionExpired(true);
      }
    }, 1000);

    return () => {
      events.forEach((e) => window.removeEventListener(e, updateLastActivity));

      updateLastActivity.cancel();
      clearInterval(checker);
    };
  }, [siteId, sessionTimeout, resetSession, updateLastActivity]);

  return (
    <TrackingContext.Provider value={{ trackPageView, trackEvent, resetSession, sessionExpired }}>
      {children}
    </TrackingContext.Provider>
  );
};

/**
 * React hook to access tracking functions (page views, events, session reset).
 * Must be used within a `<TrackingProvider>`.
 *
 * @returns {{ trackPageView: Function, trackEvent: Function, resetSession: Function, sessionExpired: Boolean }}
 */
const useTracking = () => {
  const ctx = useContext(TrackingContext);
  if (!ctx) {
    // eslint-disable-next-line no-console
    console.error(MESSAGES.useTrackingError);
  }
  return ctx;
};

/* eslint-disable-next-line react-refresh/only-export-components */
export { TrackingProvider, useTracking };