Skip to content

Commit cd75b37

Browse files
committed
feat(analytics): intégration Matomo via wrapper maison
Provider, hook useMatomo et validation Zod des env VITE_MATOMO ; tracking inerte hors production.
1 parent a811627 commit cd75b37

11 files changed

Lines changed: 350 additions & 0 deletions

File tree

.env.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Matomo (mesure d'audience) — toutes les variables sont optionnelles.
2+
# Sans VITE_MATOMO_URL + VITE_MATOMO_SITE_ID, l'analytics est désactivé.
3+
# Le tracking n'est actif qu'en build production (jamais en dev ni en test).
4+
VITE_MATOMO_URL=
5+
VITE_MATOMO_SITE_ID=
6+
7+
# Optionnel : identifiant de funnel.
8+
VITE_MATOMO_FUNNEL_ID=
9+
10+
# Optionnel : dimensions personnalisées, format VITE_MATOMO_DIMENSION_<NOM>_ID=<id numérique>.
11+
# VITE_MATOMO_DIMENSION_PROFIL_ID=1
12+
13+
# Optionnel : logge les events en console sans les envoyer (debug en dev).
14+
VITE_DEBUG_MATOMO=false

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Routes, Route } from "react-router-dom";
22
import { ROUTES } from "@shared/config/routes.config";
33
import { ErrorBoundary } from "@shared/components/ErrorBoundary";
44
import { Layout } from "@shared/components/layout/Layout";
5+
import { Matomo } from "@shared/analytics";
56
import { HomePage } from "@features/home/pages/HomePage";
67
import { SimulateursIndexPage } from "@features/simulateurs/pages/SimulateursIndexPage";
78
import { DocumentationReglementairePage } from "@features/documentation/pages/DocumentationReglementairePage";
@@ -17,6 +18,7 @@ import { GestionCookiesPage } from "@features/legal/pages/GestionCookiesPage";
1718
function App() {
1819
return (
1920
<Layout>
21+
<Matomo />
2022
<ErrorBoundary fallback={<ErrorFallbackPage />}>
2123
<Routes>
2224
<Route path={ROUTES.HOME} element={<HomePage />} />

src/shared/analytics/Matomo.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Provider monté une fois à la racine. Init unique + page view à chaque changement de route.
2+
// Inerte hors production ; ne rend rien.
3+
4+
import { useEffect } from "react";
5+
import { useLocation } from "react-router-dom";
6+
import { matomoSettings } from "./matomo.env";
7+
import { initMatomo } from "./matomo.client";
8+
import { useMatomo } from "./useMatomo";
9+
10+
export function Matomo(): null {
11+
const { enabled, debug, env } = matomoSettings;
12+
const { trackPageView } = useMatomo();
13+
const location = useLocation();
14+
15+
useEffect(() => {
16+
if (enabled && env.url && env.siteId) initMatomo(env.url, env.siteId);
17+
}, [enabled, env.url, env.siteId]);
18+
19+
useEffect(() => {
20+
if (!enabled && !debug) return;
21+
const customUrl = `${window.location.origin}${location.pathname}${location.search}`;
22+
trackPageView(customUrl);
23+
}, [enabled, debug, location.pathname, location.search, trackPageView]);
24+
25+
return null;
26+
}

src/shared/analytics/events.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Constantes d'events Matomo centralisées (évite les chaînes magiques).
2+
// Funnel adapté au flux réel ODICE : formulaire pleine-page, pas wizard multi-étapes.
3+
4+
export const MATOMO_EVENT_CATEGORY = "Simulateur PPA";
5+
6+
export const MATOMO_EVENTS = {
7+
SIMULATEUR_OUVERT: "simulateur_ouvert",
8+
SIMULATION_LANCEE: "simulation_lancee",
9+
RESULTAT_AFFICHE: "resultat_affiche",
10+
REINITIALISATION: "reinitialisation",
11+
} as const;
12+
13+
export type MatomoEventName = (typeof MATOMO_EVENTS)[keyof typeof MATOMO_EVENTS];

src/shared/analytics/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// API publique du module analytics.
2+
3+
export { Matomo } from "./Matomo";
4+
export { useMatomo, type UseMatomo } from "./useMatomo";
5+
export { MATOMO_EVENTS, MATOMO_EVENT_CATEGORY, type MatomoEventName } from "./events";
6+
export { matomoSettings, parseMatomoEnv, type MatomoSettings } from "./matomo.env";
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Wrapper bas niveau autour de window._paq (équivalent de ce que fait @socialgouv/matomo-next).
2+
// Injecte le snippet officiel une seule fois et expose push().
3+
4+
declare global {
5+
interface Window {
6+
_paq?: unknown[][];
7+
}
8+
}
9+
10+
let initialised = false;
11+
12+
function getPaq(): unknown[][] {
13+
if (typeof window === "undefined") return [];
14+
window._paq = window._paq ?? [];
15+
return window._paq;
16+
}
17+
18+
export function push(args: unknown[]): void {
19+
getPaq().push(args);
20+
}
21+
22+
// Init unique : configure le tracker et charge matomo.js. Garde anti double-init.
23+
export function initMatomo(url: string, siteId: string): void {
24+
if (initialised || typeof document === "undefined") return;
25+
initialised = true;
26+
27+
const base = url.endsWith("/") ? url : `${url}/`;
28+
const paq = getPaq();
29+
paq.push(["enableLinkTracking"]);
30+
paq.push(["setTrackerUrl", `${base}matomo.php`]);
31+
paq.push(["setSiteId", siteId]);
32+
33+
const script = document.createElement("script");
34+
script.async = true;
35+
script.src = `${base}matomo.js`;
36+
const firstScript = document.getElementsByTagName("script")[0];
37+
firstScript?.parentNode?.insertBefore(script, firstScript);
38+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { parseMatomoEnv } from "./matomo.env";
3+
4+
const CORE = {
5+
VITE_MATOMO_URL: "https://matomo.exemple.fr/",
6+
VITE_MATOMO_SITE_ID: "42",
7+
};
8+
9+
describe("parseMatomoEnv", () => {
10+
it("active le tracking quand url + siteId présents et build production", () => {
11+
const settings = parseMatomoEnv(CORE, true);
12+
expect(settings.enabled).toBe(true);
13+
expect(settings.env.url).toBe("https://matomo.exemple.fr/");
14+
expect(settings.env.siteId).toBe("42");
15+
});
16+
17+
it("désactive le tracking hors production même avec les env", () => {
18+
expect(parseMatomoEnv(CORE, false).enabled).toBe(false);
19+
});
20+
21+
it("désactive le tracking si url ou siteId manquant", () => {
22+
expect(parseMatomoEnv({ VITE_MATOMO_URL: CORE.VITE_MATOMO_URL }, true).enabled).toBe(false);
23+
expect(parseMatomoEnv({}, true).enabled).toBe(false);
24+
});
25+
26+
it("collecte les dimensions personnalisées VITE_MATOMO_DIMENSION_*_ID", () => {
27+
const settings = parseMatomoEnv(
28+
{ ...CORE, VITE_MATOMO_DIMENSION_PROFIL_ID: "3", VITE_MATOMO_DIMENSION_ZONE_ID: "5" },
29+
true,
30+
);
31+
expect(settings.env.dimensions).toEqual({ profil: 3, zone: 5 });
32+
});
33+
34+
it("interprète VITE_DEBUG_MATOMO", () => {
35+
expect(parseMatomoEnv({ VITE_DEBUG_MATOMO: "true" }, false).debug).toBe(true);
36+
expect(parseMatomoEnv({}, false).debug).toBe(false);
37+
});
38+
39+
it("désactive proprement sur configuration invalide sans lever d'exception", () => {
40+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
41+
const settings = parseMatomoEnv(
42+
{ VITE_MATOMO_URL: "pas-une-url", VITE_MATOMO_SITE_ID: "1" },
43+
true,
44+
);
45+
expect(settings.enabled).toBe(false);
46+
expect(warn).toHaveBeenCalled();
47+
warn.mockRestore();
48+
});
49+
});

src/shared/analytics/matomo.env.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Lecture + validation Zod des variables Matomo exposées au bundle (VITE_*).
2+
// SPA statique : aucun secret ici (cf. CLAUDE.md "Variables d'environnement").
3+
4+
import { z } from "zod";
5+
6+
// Coerce "true"/true vers boolean ; undefined -> false.
7+
const booleanFromEnv = z
8+
.union([z.boolean(), z.string()])
9+
.optional()
10+
.transform((value) => value === true || value === "true");
11+
12+
const matomoEnvSchema = z.object({
13+
url: z.url().optional(),
14+
siteId: z.string().min(1).optional(),
15+
funnelId: z.string().min(1).optional(),
16+
debug: booleanFromEnv,
17+
// Dimensions personnalisées : nom logique -> id numérique Matomo.
18+
dimensions: z.record(z.string(), z.coerce.number().int().positive()),
19+
});
20+
21+
export type MatomoEnv = z.infer<typeof matomoEnvSchema>;
22+
23+
export interface MatomoSettings {
24+
env: MatomoEnv;
25+
/** init + envoi réel : env complètes ET build production. */
26+
enabled: boolean;
27+
/** log des events sans envoi (activable en dev via VITE_DEBUG_MATOMO). */
28+
debug: boolean;
29+
}
30+
31+
const DIMENSION_KEY = /^VITE_MATOMO_DIMENSION_(.+)_ID$/;
32+
33+
const DISABLED: MatomoSettings = {
34+
env: { debug: false, dimensions: {} },
35+
enabled: false,
36+
debug: false,
37+
};
38+
39+
// Fonction pure (testable) : parse un environnement brut et décide de l'activation.
40+
export function parseMatomoEnv(
41+
rawEnv: Record<string, string | undefined>,
42+
isProd: boolean,
43+
): MatomoSettings {
44+
const dimensions: Record<string, string | undefined> = {};
45+
for (const key of Object.keys(rawEnv)) {
46+
const match = DIMENSION_KEY.exec(key);
47+
if (match) dimensions[match[1].toLowerCase()] = rawEnv[key];
48+
}
49+
50+
const result = matomoEnvSchema.safeParse({
51+
url: rawEnv.VITE_MATOMO_URL,
52+
siteId: rawEnv.VITE_MATOMO_SITE_ID,
53+
funnelId: rawEnv.VITE_MATOMO_FUNNEL_ID,
54+
debug: rawEnv.VITE_DEBUG_MATOMO,
55+
dimensions,
56+
});
57+
58+
if (!result.success) {
59+
// Analytics ne doit jamais casser l'app : on désactive sur configuration invalide.
60+
console.warn("[ODICE matomo] configuration invalide, analytics désactivé", result.error.issues);
61+
return DISABLED;
62+
}
63+
64+
const env = result.data;
65+
const hasCore = Boolean(env.url && env.siteId);
66+
return { env, enabled: isProd && hasCore, debug: env.debug };
67+
}
68+
69+
export const matomoSettings: MatomoSettings = parseMatomoEnv(
70+
import.meta.env as unknown as Record<string, string | undefined>,
71+
import.meta.env.PROD,
72+
);
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, it, vi, beforeEach } from "vitest";
2+
import { renderHook, act } from "@testing-library/react";
3+
4+
const pushMock = vi.fn();
5+
6+
// Force le mode activé pour tester la logique d'envoi.
7+
vi.mock("./matomo.env", () => ({
8+
matomoSettings: { enabled: true, debug: false, env: { debug: false, dimensions: {} } },
9+
}));
10+
vi.mock("./matomo.client", () => ({ push: (args: unknown[]) => pushMock(args) }));
11+
12+
import { useMatomo } from "./useMatomo";
13+
import { MATOMO_EVENTS, MATOMO_EVENT_CATEGORY } from "./events";
14+
15+
describe("useMatomo (activé)", () => {
16+
beforeEach(() => pushMock.mockClear());
17+
18+
it("pose puis supprime les dimensions autour de l'event (set -> track -> delete)", () => {
19+
const { result } = renderHook(() => useMatomo());
20+
act(() => {
21+
result.current.trackEvent(MATOMO_EVENTS.SIMULATION_LANCEE, undefined, { 3: "abattoir" });
22+
});
23+
24+
const calls = pushMock.mock.calls.map((call) => call[0] as unknown[]);
25+
expect(calls[0]).toEqual(["setCustomDimension", 3, "abattoir"]);
26+
expect(calls[1]).toEqual([
27+
"trackEvent",
28+
MATOMO_EVENT_CATEGORY,
29+
MATOMO_EVENTS.SIMULATION_LANCEE,
30+
]);
31+
expect(calls[2]).toEqual(["deleteCustomDimension", 3]);
32+
});
33+
34+
it("pose setCustomUrl avant trackPageView", () => {
35+
const { result } = renderHook(() => useMatomo());
36+
act(() => {
37+
result.current.trackPageView("https://odice.fr/simulateurs");
38+
});
39+
40+
const calls = pushMock.mock.calls.map((call) => call[0] as unknown[]);
41+
expect(calls[0]).toEqual(["setCustomUrl", "https://odice.fr/simulateurs"]);
42+
expect(calls[1]).toEqual(["trackPageView"]);
43+
});
44+
});

src/shared/analytics/useMatomo.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Helpers typés autour de _paq. No-op hors production ; log sans envoi en mode debug.
2+
3+
import { useCallback } from "react";
4+
import { matomoSettings } from "./matomo.env";
5+
import { push } from "./matomo.client";
6+
import { MATOMO_EVENT_CATEGORY, type MatomoEventName } from "./events";
7+
8+
interface TrackEventOptions {
9+
name?: string;
10+
value?: number;
11+
}
12+
13+
// dimensionId Matomo -> valeur.
14+
type CustomDimensions = Record<number, string>;
15+
16+
export interface UseMatomo {
17+
trackEvent: (
18+
event: MatomoEventName,
19+
options?: TrackEventOptions,
20+
customDimensions?: CustomDimensions,
21+
) => void;
22+
trackPageView: (customUrl?: string) => void;
23+
enableHeatmaps: () => void;
24+
}
25+
26+
export function useMatomo(): UseMatomo {
27+
const { enabled, debug } = matomoSettings;
28+
29+
const trackEvent = useCallback<UseMatomo["trackEvent"]>(
30+
(event, options = {}, customDimensions = {}) => {
31+
if (debug) console.info("[ODICE matomo] trackEvent", { event, options, customDimensions });
32+
if (!enabled) return;
33+
try {
34+
const dimensionIds = Object.keys(customDimensions).map(Number);
35+
// set -> track -> delete : sinon les dimensions fuitent sur les events suivants.
36+
for (const id of dimensionIds) push(["setCustomDimension", id, customDimensions[id]]);
37+
push(
38+
["trackEvent", MATOMO_EVENT_CATEGORY, event, options.name, options.value].filter(
39+
(value) => value !== undefined,
40+
),
41+
);
42+
for (const id of dimensionIds) push(["deleteCustomDimension", id]);
43+
} catch (error) {
44+
console.warn("[ODICE matomo] trackEvent a échoué", error);
45+
}
46+
},
47+
[enabled, debug],
48+
);
49+
50+
const trackPageView = useCallback<UseMatomo["trackPageView"]>(
51+
(customUrl) => {
52+
if (debug) console.info("[ODICE matomo] trackPageView", { customUrl });
53+
if (!enabled) return;
54+
try {
55+
if (customUrl) push(["setCustomUrl", customUrl]);
56+
push(["trackPageView"]);
57+
} catch (error) {
58+
console.warn("[ODICE matomo] trackPageView a échoué", error);
59+
}
60+
},
61+
[enabled, debug],
62+
);
63+
64+
const enableHeatmaps = useCallback<UseMatomo["enableHeatmaps"]>(() => {
65+
if (!enabled) return;
66+
try {
67+
push(["HeatmapSessionRecording::enable"]);
68+
} catch (error) {
69+
console.warn("[ODICE matomo] enableHeatmaps a échoué", error);
70+
}
71+
}, [enabled]);
72+
73+
return { trackEvent, trackPageView, enableHeatmaps };
74+
}

0 commit comments

Comments
 (0)