diff --git a/.template.env b/.template.env index 2a84c5a3..d10e6f42 100644 --- a/.template.env +++ b/.template.env @@ -39,3 +39,7 @@ VITE_RUNNING_ENV: dev VITE_BACKEND_URL: http://localhost:4000 VITE_HMR_PORT: 5173 + +# Optional: enables Google Analytics (GA4) page-view tracking in the frontend when set. +# Disabled by default, and never runs in `VITE_RUNNING_ENV: dev`. +VITE_GA4_MEASUREMENT_ID: "" diff --git a/documentation/frontend/frontend.md b/documentation/frontend/frontend.md index a937f4e2..13dfb820 100644 --- a/documentation/frontend/frontend.md +++ b/documentation/frontend/frontend.md @@ -67,6 +67,13 @@ Some DB fields use MariaDB `DECIMAL` and are represented as `Decimal` in Prisma- - Prefer explicit parsing/conversion at usage points if numeric operations are required. - `estimate_temp` additionally has validator bounds to avoid DB errors from out-of-range values (`-999.9` to `999.9`). +**Analytics (optional)** + +The frontend can optionally send GA4 page-view events if configured. + +- Set `VITE_GA4_MEASUREMENT_ID` to your GA4 measurement id (for example `G-XXXXXXXXXX`). +- Tracking is disabled by default and never runs when `VITE_RUNNING_ENV` is `dev`. + **Detailed documentation for editingComponents** - [RadioSelector](./components/editingComponents/RadioSelector.md) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4cd68d7a..6cdaf1ab 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,19 @@ import { Container } from '@mui/material' import Grid from '@mui/material/Grid2' -import { Outlet } from 'react-router-dom' +import { Outlet, useLocation } from 'react-router-dom' import { NavBar } from './components/NavBar' import { Notification, NotificationContextProvider } from './components/Notification' import { Footer } from './Footer' +import { useEffect } from 'react' +import { trackPageView } from './util/analytics' const App = () => { + const location = useLocation() + + useEffect(() => { + trackPageView(`${location.pathname}${location.search}`) + }, [location.pathname, location.search]) + return ( diff --git a/frontend/src/tests/mocks/config.ts b/frontend/src/tests/mocks/config.ts index 85c15131..b02aae5e 100644 --- a/frontend/src/tests/mocks/config.ts +++ b/frontend/src/tests/mocks/config.ts @@ -1,3 +1,4 @@ export const ENV = 'dev' export const BACKEND_URL = 'http://localhost' export const ENABLE_WRITE = true +export const GA4_MEASUREMENT_ID = undefined diff --git a/frontend/src/util/analytics.ts b/frontend/src/util/analytics.ts new file mode 100644 index 00000000..f759747e --- /dev/null +++ b/frontend/src/util/analytics.ts @@ -0,0 +1,56 @@ +import { ENV, GA4_MEASUREMENT_ID } from './config' + +declare global { + interface Window { + dataLayer?: unknown[] + gtag?: (...args: unknown[]) => void + } +} + +const canTrack = () => { + if (ENV === 'dev') return false + if (!GA4_MEASUREMENT_ID) return false + + // Honor "Do Not Track" signals when present. + const win = window as Window & { doNotTrack?: string } + const dnt = navigator.doNotTrack === '1' || win.doNotTrack === '1' + if (dnt) return false + + return true +} + +let initialized = false + +const ensureGtagLoaded = () => { + if (initialized) return + if (!canTrack()) return + + if (typeof document === 'undefined') return + + const srcId = 'ga4-gtag-src' + if (!document.getElementById(srcId)) { + const script = document.createElement('script') + script.id = srcId + script.async = true + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(GA4_MEASUREMENT_ID!)}` + document.head.appendChild(script) + } + + window.dataLayer = window.dataLayer ?? [] + window.gtag = + window.gtag ?? + ((...args: unknown[]) => { + window.dataLayer?.push(args) + }) + + window.gtag('js', new Date()) + window.gtag('config', GA4_MEASUREMENT_ID, { send_page_view: false, anonymize_ip: true }) + + initialized = true +} + +export const trackPageView = (path: string) => { + if (!canTrack()) return + ensureGtagLoaded() + window.gtag?.('event', 'page_view', { page_path: path }) +} diff --git a/frontend/src/util/config.ts b/frontend/src/util/config.ts index a9c212a6..8484732a 100755 --- a/frontend/src/util/config.ts +++ b/frontend/src/util/config.ts @@ -1,3 +1,4 @@ export const ENV = import.meta.env.VITE_RUNNING_ENV as 'dev' | 'staging' | 'prod' export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL as string export const ENABLE_WRITE = import.meta.env.VITE_ENABLE_WRITE === 'true' +export const GA4_MEASUREMENT_ID = import.meta.env.VITE_GA4_MEASUREMENT_ID as string | undefined