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 };