feat(analytics): GA4 with GDPR Consent Mode v2
Google Analytics integration, off by default and GDPR-compliant for EU:
- src/lib/analytics/gtag.ts: typed event helpers + consent control. Every
function is a safe no-op when NEXT_PUBLIC_GA_ID is unset.
- GoogleAnalytics.tsx: loads gtag.js with Consent Mode v2, all storage
defaulting to "denied". anonymize_ip on, send_page_view off.
- ConsentBanner.tsx: on-brand cookie banner, localized to all 5 locales,
persists choice for one year, flips analytics_storage to granted on accept.
- PageViewTracker.tsx: fires page_view on App Router client navigation
(inside Suspense for useSearchParams).
- Key conversion events wired: ai_consultation_submitted (primary funnel
goal) and ai_chat_opened.
- Consent strings added to messages/{en,it,vec,es,de}.json.
Build plumbing:
- NEXT_PUBLIC_GA_ID inlined at build time via Dockerfile ARG +
docker-compose build.args (NEXT_PUBLIC_* must exist during next build,
not just runtime).
- Nginx CSP extended to allow googletagmanager.com + google-analytics.com.
- env template documents NEXT_PUBLIC_GA_ID (empty = analytics disabled).
Verified: production build inlines the Measurement ID into the client
bundle; site builds cleanly both with and without the ID set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,9 @@ import NavigationManager from "@/components/layout/NavigationManager";
|
||||
import SilentObserver from "@/components/ai/SilentObserver";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
import CartDrawer from "@/components/layout/CartDrawer";
|
||||
import GoogleAnalytics from "@/components/analytics/GoogleAnalytics";
|
||||
import PageViewTracker from "@/components/analytics/PageViewTracker";
|
||||
import ConsentBanner from "@/components/analytics/ConsentBanner";
|
||||
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages, setRequestLocale } from 'next-intl/server';
|
||||
@@ -173,15 +176,24 @@ export default async function RootLayout({
|
||||
<NavigationManager />
|
||||
</Suspense>
|
||||
|
||||
{/* Analytics — page-view tracker needs Suspense (useSearchParams) */}
|
||||
<Suspense fallback={null}>
|
||||
<PageViewTracker />
|
||||
</Suspense>
|
||||
|
||||
<div className="flex-grow w-full flex flex-col relative">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<Footer locale={locale} />
|
||||
<SilentObserver />
|
||||
<ConsentBanner />
|
||||
|
||||
</NextIntlClientProvider>
|
||||
|
||||
{/* GA4 loader (Consent Mode v2). No-ops when NEXT_PUBLIC_GA_ID unset. */}
|
||||
<GoogleAnalytics />
|
||||
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Calendar, User, Building2, Mail, Phone, MessageSquare, ArrowRight, CheckCircle2, Sparkles, ChevronDown } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { trackEvent } from "@/lib/analytics/gtag";
|
||||
|
||||
// ── Data from the AI tool execute ──
|
||||
interface ConsultationData {
|
||||
@@ -272,6 +273,12 @@ export default function ConsultationScheduler({ data }: { data: ConsultationData
|
||||
setTicketId(result.ticketId);
|
||||
setSubmitted(true);
|
||||
|
||||
// GA4 conversion event — the primary funnel goal.
|
||||
trackEvent({
|
||||
name: "ai_consultation_submitted",
|
||||
params: { industry: data.industry, ticketId: result.ticketId },
|
||||
});
|
||||
|
||||
// Also dispatch the event for any external integrations
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("flux:consultation-submitted", { detail: { ...payload, ticketId: result.ticketId } })
|
||||
|
||||
@@ -19,6 +19,7 @@ import EquipmentConfigurator from "./EquipmentConfigurator";
|
||||
import EfficiencyCard from "./EfficiencyCard";
|
||||
|
||||
import { getAiSessionId } from "@/lib/aiSessionId";
|
||||
import { trackEvent } from "@/lib/analytics/gtag";
|
||||
|
||||
export default function SilentObserver() {
|
||||
const {
|
||||
@@ -313,7 +314,7 @@ export default function SilentObserver() {
|
||||
<div className={`fixed inset-0 z-50 flex pointer-events-none px-3 pb-3 md:p-6 transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] ${isWideMode ? "items-center justify-center" : "items-end justify-center md:justify-end"}`} style={{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }}>
|
||||
<AnimatePresence mode="wait">
|
||||
{!isAiExpanded ? (
|
||||
<motion.button key="pill" layoutId="flux-ai-shell" onClick={toggleAi}
|
||||
<motion.button key="pill" layoutId="flux-ai-shell" onClick={() => { trackEvent({ name: "ai_chat_opened", params: { section: currentSection } }); toggleAi(); }}
|
||||
initial={{ opacity: 0, scale: 0.8, filter: "blur(10px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={{ opacity: 0, scale: 0.9, filter: "blur(10px)" }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
className="pointer-events-auto flex items-center gap-3 px-5 py-3.5 rounded-full cursor-pointer select-none bg-white/70 dark:bg-[#1D1D1F]/80 backdrop-blur-2xl backdrop-saturate-150 border border-white/60 dark:border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.08)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.5)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.12)] dark:hover:shadow-[0_12px_40px_rgba(0,0,0,0.6)] hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 group">
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
// src/components/analytics/ConsentBanner.tsx
|
||||
// -----------------------------------------------------------------------------
|
||||
// GDPR / ePrivacy cookie consent banner. On-brand (FLUX cyan), minimal, and
|
||||
// localized through next-intl. Shows only when:
|
||||
// - analytics is configured (NEXT_PUBLIC_GA_ID present), AND
|
||||
// - the visitor has not yet made a choice (no consent cookie).
|
||||
//
|
||||
// Accept -> consent granted, GA starts tracking, first page_view fires.
|
||||
// Decline -> consent denied, GA stays cookieless.
|
||||
// The choice is remembered for one year.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
analyticsEnabled,
|
||||
readStoredConsent,
|
||||
storeConsent,
|
||||
updateConsent,
|
||||
pageview,
|
||||
} from "@/lib/analytics/gtag";
|
||||
|
||||
export default function ConsentBanner() {
|
||||
const t = useTranslations("Consent");
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!analyticsEnabled()) return;
|
||||
if (readStoredConsent() === null) setVisible(true);
|
||||
else if (readStoredConsent() === "granted") {
|
||||
// Returning visitor who already consented — re-grant for this session.
|
||||
updateConsent("granted");
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const choose = (granted: boolean) => {
|
||||
const choice = granted ? "granted" : "denied";
|
||||
storeConsent(choice);
|
||||
updateConsent(choice);
|
||||
if (granted && typeof window !== "undefined") {
|
||||
pageview(window.location.pathname + window.location.search, document.title);
|
||||
}
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-live="polite"
|
||||
aria-label={t("title")}
|
||||
className="fixed bottom-4 left-4 right-4 z-[300] mx-auto max-w-2xl rounded-2xl border border-black/10 bg-white/95 p-5 shadow-2xl backdrop-blur-xl md:left-6 md:right-auto md:bottom-6"
|
||||
>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-[#1D1D1F]">{t("title")}</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-[#6E6E73]">
|
||||
{t("body")}{" "}
|
||||
<a
|
||||
href="/privacy"
|
||||
className="underline decoration-[#00B8CC] underline-offset-2 hover:text-[#1D1D1F]"
|
||||
>
|
||||
{t("learnMore")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
onClick={() => choose(false)}
|
||||
className="rounded-full border border-black/15 px-4 py-2 text-xs font-medium text-[#1D1D1F] transition-colors hover:bg-black/5"
|
||||
>
|
||||
{t("decline")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => choose(true)}
|
||||
className="rounded-full bg-[#1D1D1F] px-5 py-2 text-xs font-medium text-white transition-colors hover:bg-[#000]"
|
||||
>
|
||||
{t("accept")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
// src/components/analytics/GoogleAnalytics.tsx
|
||||
// -----------------------------------------------------------------------------
|
||||
// Loads gtag.js with Consent Mode v2. Renders NOTHING and loads NOTHING when
|
||||
// NEXT_PUBLIC_GA_ID is unset, so the site is unaffected until the client
|
||||
// provides their Measurement ID.
|
||||
//
|
||||
// Consent defaults to "denied" for all storage. The ConsentBanner flips
|
||||
// analytics_storage to "granted" once the visitor accepts. This is the
|
||||
// Google-recommended GDPR pattern: the tag loads but stores no cookies and
|
||||
// no personal data until consent is given.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import Script from "next/script";
|
||||
import { GA_MEASUREMENT_ID, analyticsEnabled } from "@/lib/analytics/gtag";
|
||||
|
||||
export default function GoogleAnalytics() {
|
||||
if (!analyticsEnabled()) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 1. Consent Mode v2 defaults + gtag bootstrap. Must run BEFORE the
|
||||
gtag.js library so the default consent state is set first. */}
|
||||
<Script id="ga-consent-default" strategy="afterInteractive">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
window.gtag = gtag;
|
||||
gtag('consent', 'default', {
|
||||
'analytics_storage': 'denied',
|
||||
'ad_storage': 'denied',
|
||||
'ad_user_data': 'denied',
|
||||
'ad_personalization': 'denied',
|
||||
'wait_for_update': 500
|
||||
});
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${GA_MEASUREMENT_ID}', {
|
||||
'anonymize_ip': true,
|
||||
'send_page_view': false
|
||||
});
|
||||
`}
|
||||
</Script>
|
||||
|
||||
{/* 2. The actual GA4 library. */}
|
||||
<Script
|
||||
id="ga-lib"
|
||||
strategy="afterInteractive"
|
||||
src={`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
// src/components/analytics/PageViewTracker.tsx
|
||||
// -----------------------------------------------------------------------------
|
||||
// GA4's gtag does not auto-track client-side route changes in the Next.js App
|
||||
// Router (we set send_page_view:false in the config). This component fires a
|
||||
// page_view on every pathname/search change so SPA navigation is measured.
|
||||
//
|
||||
// Safe no-op when analytics is disabled. Must live inside a Suspense boundary
|
||||
// because it reads useSearchParams (a requirement under ISR).
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { pageview, analyticsEnabled } from "@/lib/analytics/gtag";
|
||||
|
||||
export default function PageViewTracker() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!analyticsEnabled()) return;
|
||||
const qs = searchParams?.toString();
|
||||
const url = qs ? `${pathname}?${qs}` : pathname;
|
||||
pageview(url, typeof document !== "undefined" ? document.title : undefined);
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// src/lib/analytics/gtag.ts
|
||||
// -----------------------------------------------------------------------------
|
||||
// Google Analytics 4 integration — typed event helpers + GDPR consent control.
|
||||
//
|
||||
// Design:
|
||||
// - The Measurement ID comes from NEXT_PUBLIC_GA_ID. When it is unset (e.g.
|
||||
// local dev, or before the client provides it), every function here is a
|
||||
// safe no-op — nothing loads, nothing tracks, no errors.
|
||||
// - Consent Mode v2 is used. Analytics storage defaults to "denied"; the
|
||||
// consent banner flips it to "granted" only after the visitor accepts.
|
||||
// Until then GA runs in cookieless "modeling" mode (GDPR-compliant).
|
||||
// - All calls are guarded for SSR (typeof window) so they're safe to call
|
||||
// from anywhere, including event handlers in shared components.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_ID ?? "";
|
||||
|
||||
/** True when a Measurement ID is configured. */
|
||||
export const analyticsEnabled = (): boolean => GA_MEASUREMENT_ID.length > 0;
|
||||
|
||||
// The gtag function is injected by the loader script. We declare it loosely
|
||||
// so call sites stay clean without pulling in @types/gtag.
|
||||
type GtagFn = (...args: unknown[]) => void;
|
||||
|
||||
function gtag(...args: unknown[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const w = window as unknown as { gtag?: GtagFn; dataLayer?: unknown[] };
|
||||
if (typeof w.gtag === "function") {
|
||||
w.gtag(...args);
|
||||
} else if (Array.isArray(w.dataLayer)) {
|
||||
// Buffer until gtag.js finishes loading; the snippet replays dataLayer.
|
||||
w.dataLayer.push(args);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Consent (GDPR / ePrivacy) ────────────────────────────────────────────────
|
||||
|
||||
export const CONSENT_COOKIE = "flux_consent";
|
||||
|
||||
export type ConsentChoice = "granted" | "denied";
|
||||
|
||||
/** Push a consent update into Consent Mode v2. */
|
||||
export function updateConsent(choice: ConsentChoice): void {
|
||||
if (!analyticsEnabled()) return;
|
||||
gtag("consent", "update", {
|
||||
analytics_storage: choice,
|
||||
// We don't run ads; keep ad signals denied regardless.
|
||||
ad_storage: "denied",
|
||||
ad_user_data: "denied",
|
||||
ad_personalization: "denied",
|
||||
});
|
||||
}
|
||||
|
||||
/** Read the persisted consent choice (client-only). Returns null if unset. */
|
||||
export function readStoredConsent(): ConsentChoice | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const match = document.cookie
|
||||
.split("; ")
|
||||
.find((c) => c.startsWith(`${CONSENT_COOKIE}=`));
|
||||
if (!match) return null;
|
||||
const value = match.split("=")[1];
|
||||
return value === "granted" || value === "denied" ? value : null;
|
||||
}
|
||||
|
||||
/** Persist the consent choice for one year. */
|
||||
export function storeConsent(choice: ConsentChoice): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const oneYear = 60 * 60 * 24 * 365;
|
||||
const secure = window.location.protocol === "https:" ? "; Secure" : "";
|
||||
document.cookie = `${CONSENT_COOKIE}=${choice}; Max-Age=${oneYear}; Path=/; SameSite=Lax${secure}`;
|
||||
}
|
||||
|
||||
// ── Page views ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function pageview(url: string, title?: string): void {
|
||||
if (!analyticsEnabled()) return;
|
||||
gtag("event", "page_view", {
|
||||
page_path: url,
|
||||
page_title: title,
|
||||
page_location: typeof window !== "undefined" ? window.location.href : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Domain events ────────────────────────────────────────────────────────────
|
||||
// One typed helper per meaningful action. Keeps event names consistent so the
|
||||
// GA4 dashboard stays clean and conversions are easy to define.
|
||||
|
||||
export type FluxEvent =
|
||||
| { name: "ai_chat_opened"; params?: { section?: string } }
|
||||
| { name: "ai_consultation_submitted"; params?: { industry?: string; ticketId?: string } }
|
||||
| { name: "parts_order_submitted"; params?: { itemCount?: number } }
|
||||
| { name: "application_viewed"; params: { slug: string } }
|
||||
| { name: "case_study_viewed"; params: { nodeId?: string; application?: string } }
|
||||
| { name: "global_map_node_opened"; params: { nodeType?: string; application?: string } }
|
||||
| { name: "language_changed"; params: { from?: string; to: string } }
|
||||
| { name: "contact_cta_clicked"; params?: { location?: string } };
|
||||
|
||||
export function trackEvent(event: FluxEvent): void {
|
||||
if (!analyticsEnabled()) return;
|
||||
gtag("event", event.name, "params" in event ? event.params : undefined);
|
||||
}
|
||||
Reference in New Issue
Block a user