Skip to content

feat(analytics): add GA4 + GTM tracking with GDPR-compliant cookie consent #46

@arielbvergara

Description

@arielbvergara

Overview

Set up GA4 via GTM with a custom cookie consent banner to track key conversions (contact form, appointment booking), understand traffic sources, and measure content performance — fully GDPR-compliant for EU markets (NL/DE/EN).

Pre-requisites (Manual Steps)

Before any code work:

  1. Create a GA4 property at analytics.google.com → Admin → Create → Property (timezone: Amsterdam, currency: EUR). Add a Web data stream. Copy the Measurement ID (G-XXXXXXXXXX).
  2. Create a GTM container at tagmanager.google.com → Create Account → Container (Web). Copy the Container ID (GTM-XXXXXXX).
  3. Configure GTM container (after code is deployed):
    • Enable Consent Overview under Admin → Container Settings
    • Create a Google tag (Measurement ID) firing on All Pages; require analytics_storage consent
    • Create Data Layer Variables: page_locale, service_selected, appointment_date
    • Create Custom Event triggers: CE - contact_form_submitted, CE - appointment_booked
    • Create GA4 Event tags for each trigger with corresponding parameters
    • Publish the container
  4. Add NEXT_PUBLIC_GTM_ID=GTM-XXXXXXX to .env.local and production environment

Implementation Tasks

Task 1 — Core Analytics Files (New)

  • /frontend/lib/analytics-constants.ts — centralize event names, consent storage key, and consent values as named constants (no magic strings)
  • /frontend/types/analytics.d.ts — augment global Window type with dataLayer and gtag
  • /frontend/lib/analytics.ts — all dataLayer.push calls centralized here:
    • trackContactFormSubmitted({ locale, serviceSelected })
    • trackAppointmentBooked({ locale, serviceSelected, appointmentDate }) — date only (YYYY-MM-DD), no time
    • trackCtaClicked({ locale, ctaLabel, ctaLocation })
    • updateConsentState(granted) — pushes consent update + calls gtag('consent', 'update', ...)
    • All tracking functions guard on hasAnalyticsConsent() before pushing
  • /frontend/lib/hooks/useConsent.ts — client hook managing consent state via localStorage with cross-tab sync via storage events

Task 2 — CSP Update

File: /frontend/proxy.ts

Update three CSP directives:

  • script-src: add https://www.googletagmanager.com
  • connect-src: add https://www.google-analytics.com https://analytics.google.com
  • frame-src: add https://www.googletagmanager.com
  • img-src: add https://www.google-analytics.com

Task 3 — GTM Components (New Server Components)

  • /frontend/components/analytics/GtmScript.tsx — Server Component that reads nonce from headers(), renders inline <script nonce={nonce}> setting Consent Mode v2 defaults (all denied), then <Script strategy="afterInteractive" nonce={nonce}> for the GTM loader. Returns null if NEXT_PUBLIC_GTM_ID not set.
  • /frontend/components/analytics/GtmNoScript.tsx — Server Component rendering the <noscript><iframe> fallback (first child of <body>)

Task 4 — Cookie Consent Banner

  • /frontend/components/ui/CookieConsentBanner.tsx — Client Component using useConsent hook and useTranslations('cookieConsent'). Fixed bottom bar with Decline + Accept buttons. Renders null if consent is already resolved.
  • /frontend/messages/nl.json, en.json, de.json — add "cookieConsent" i18n namespace with keys: dialogLabel, message, privacyLink, accept, decline

Task 5 — Layout Integration

File: /frontend/app/[locale]/layout.tsx

<html ...>
  <body className="antialiased">
    <GtmNoScript />
    <GtmScript />
    <NextIntlClientProvider locale={locale} messages={messages}>
      {children}
      <CookieConsentBanner />
    </NextIntlClientProvider>
  </body>
</html>

Task 6 — Conversion Tracking

  • /frontend/components/ui/ContactForm.tsx — add useLocale + call trackContactFormSubmitted({ locale, serviceSelected: formData.service }) after setSubmitted(true) on the success path
  • /frontend/components/ui/AppointmentForm.tsx — call trackAppointmentBooked({ locale, serviceSelected, appointmentDate: selectedDatetime.split('T')[0] }) after setSubmitted(true) on the success path

Task 7 — CTA Click Tracking

  • /frontend/components/analytics/TrackedCtaButton.tsx — thin Client Component wrapper around Button accepting ctaLabel and ctaLocation props, calls trackCtaClicked on click
  • Update primary CTA buttons in Hero.tsx and LandingCta.tsx to use TrackedCtaButton

Task 8 — Tests

File: /frontend/lib/__tests__/analytics.test.ts

Following naming convention {MethodName}_Should{Action}_When{Condition}:

  • trackContactFormSubmitted_ShouldPushEvent_WhenConsentIsAccepted
  • trackContactFormSubmitted_ShouldNotPushEvent_WhenConsentIsDeclined
  • trackContactFormSubmitted_ShouldNotPushEvent_WhenConsentIsPending
  • trackAppointmentBooked_ShouldPushEventWithDate_WhenConsentIsAccepted
  • trackAppointmentBooked_ShouldNotIncludePII_WhenConsentIsAccepted
  • hasAnalyticsConsent_ShouldReturnTrue_WhenLocalStorageValueIsAccepted
  • hasAnalyticsConsent_ShouldReturnFalse_WhenLocalStorageValueIsDeclined
  • hasAnalyticsConsent_ShouldReturnFalse_WhenLocalStorageIsEmpty
  • updateConsentState_ShouldPushConsentUpdateEvent_WhenGrantedIsTrue
  • updateConsentState_ShouldCallGtagConsentUpdate_WhenGtagIsPresent

Event Schemas (No PII)

Event Properties
contact_form_submitted page_locale, service_selected
appointment_booked page_locale, service_selected, appointment_date (YYYY-MM-DD only)
cta_clicked page_locale, cta_label, cta_location

Verification Checklist

  • pnpm typecheck — no TS errors
  • pnpm test — all unit tests pass
  • CSP headers in DevTools include GTM/GA4 origins, no CSP violations in console
  • GTM Preview mode: gtm.js fires on load; no conversion events fire before consent
  • After Accept: consent_update fires; after form submit: correct events fire with no PII
  • Consent persists across page reloads; banner reappears after clearing localStorage
  • GA4 DebugView shows events with correct parameters
  • All three locale routes (/nl/, /en/, /de/) show correct page_locale in events

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions