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:
davidherranandClaude Opus 4.8 committed 2026-06-02 06:53:04 -05:00
1 parent 3a94e7c003
commit 1ee8288c7e
17 files changed
+981 -2

No files matched your search

+2 -1
View File
@@ -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">