Jelajahi Sumber

feat: add GDPR-compliant cookie consent banner (#8022)

Co-authored-by: Roo Code <[email protected]>
Co-authored-by: Bruno Bergher <[email protected]>
Co-authored-by: Matt Rubens <[email protected]>
roomote[bot] 3 bulan lalu
induk
melakukan
2819120216

+ 2 - 0
apps/web-roo-code/package.json

@@ -28,11 +28,13 @@
 		"next-themes": "^0.4.6",
 		"posthog-js": "^1.248.1",
 		"react": "^18.3.1",
+		"react-cookie-consent": "^9.0.0",
 		"react-dom": "^18.3.1",
 		"react-icons": "^5.5.0",
 		"recharts": "^2.15.3",
 		"tailwind-merge": "^3.3.0",
 		"tailwindcss-animate": "^1.0.7",
+		"tldts": "^6.1.86",
 		"zod": "^3.25.61"
 	},
 	"devDependencies": {

+ 2 - 11
apps/web-roo-code/src/app/layout.tsx

@@ -1,8 +1,8 @@
 import React from "react"
 import type { Metadata } from "next"
 import { Inter } from "next/font/google"
-import Script from "next/script"
 import { SEO } from "@/lib/seo"
+import { CookieConsentWrapper } from "@/components/CookieConsentWrapper"
 
 import { Providers } from "@/components/providers"
 
@@ -93,22 +93,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
 				/>
 			</head>
 			<body className={inter.className}>
-				{/* Google tag (gtag.js) */}
-				<Script src="https://www.googletagmanager.com/gtag/js?id=AW-17391954825" strategy="afterInteractive" />
-				<Script id="google-analytics" strategy="afterInteractive">
-					{`
-						window.dataLayer = window.dataLayer || [];
-						function gtag(){dataLayer.push(arguments);}
-						gtag('js', new Date());
-						gtag('config', 'AW-17391954825');
-					`}
-				</Script>
 				<div itemScope itemType="https://schema.org/WebSite">
 					<link itemProp="url" href={SEO.url} />
 					<meta itemProp="name" content={SEO.name} />
 				</div>
 				<Providers>
 					<Shell>{children}</Shell>
+					<CookieConsentWrapper />
 				</Providers>
 			</body>
 		</html>

+ 111 - 0
apps/web-roo-code/src/components/CookieConsentWrapper.tsx

@@ -0,0 +1,111 @@
+"use client"
+
+import React, { useState, useEffect } from "react"
+import ReactCookieConsent from "react-cookie-consent"
+import { Cookie } from "lucide-react"
+import { getDomain } from "tldts"
+import { CONSENT_COOKIE_NAME } from "@roo-code/types"
+import { dispatchConsentEvent } from "@/lib/analytics/consent-manager"
+
+/**
+ * GDPR-compliant cookie consent banner component
+ * Handles both the UI and consent event dispatching
+ */
+export function CookieConsentWrapper() {
+	const [cookieDomain, setCookieDomain] = useState<string | null>(null)
+
+	useEffect(() => {
+		// Get the appropriate domain using tldts
+		if (typeof window !== "undefined") {
+			const domain = getDomain(window.location.hostname)
+			setCookieDomain(domain)
+		}
+	}, [])
+
+	const handleAccept = () => {
+		dispatchConsentEvent(true)
+	}
+
+	const handleDecline = () => {
+		dispatchConsentEvent(false)
+	}
+
+	const extraCookieOptions = cookieDomain
+		? {
+				domain: cookieDomain,
+			}
+		: {}
+
+	const containerClasses = `
+		fixed bottom-2 left-2 right-2 z-[999]
+		bg-black/95 dark:bg-white/95
+		text-white dark:text-black
+		border-t-neutral-800 dark:border-t-gray-200
+		backdrop-blur-xl
+		border-t
+		font-semibold
+		rounded-t-lg
+		px-4 py-4 md:px-8 md:py-4
+		flex flex-wrap items-center justify-between gap-4
+		text-sm font-sans
+	`.trim()
+
+	const buttonWrapperClasses = `
+		flex
+		flex-row-reverse
+		items-center
+		gap-2
+	`.trim()
+
+	const acceptButtonClasses = `
+		bg-white text-black border-neutral-800
+		dark:bg-black dark:text-white dark:border-gray-200
+		hover:opacity-50
+		transition-opacity
+		rounded-md
+		px-4 py-2 mr-2
+		text-sm font-bold
+		cursor-pointer
+		focus:outline-none focus:ring-2 focus:ring-offset-2
+	`.trim()
+
+	const declineButtonClasses = `
+		dark:bg-white dark:text-black dark:border-gray-200
+		bg-black text-white border-neutral-800
+		hover:opacity-50
+		border border-border
+		transition-opacity
+		rounded-md
+		px-4 py-2
+		text-sm font-bold
+		cursor-pointer
+		focus:outline-none focus:ring-2 focus:ring-offset-2
+	`.trim()
+
+	return (
+		<div role="banner" aria-label="Cookie consent banner" aria-live="polite">
+			<ReactCookieConsent
+				location="bottom"
+				buttonText="Accept"
+				declineButtonText="Decline"
+				cookieName={CONSENT_COOKIE_NAME}
+				expires={365}
+				enableDeclineButton={true}
+				onAccept={handleAccept}
+				onDecline={handleDecline}
+				containerClasses={containerClasses}
+				buttonClasses={acceptButtonClasses}
+				buttonWrapperClasses={buttonWrapperClasses}
+				declineButtonClasses={declineButtonClasses}
+				extraCookieOptions={extraCookieOptions}
+				disableStyles={true}
+				ariaAcceptLabel={`Accept`}
+				ariaDeclineLabel={`Decline`}>
+				<div className="flex items-center gap-2">
+					<Cookie className="size-5 hidden md:block" />
+					<span>Like most of the internet, we use cookies. Are you OK with that?</span>
+				</div>
+			</ReactCookieConsent>
+		</div>
+	)
+}

+ 92 - 0
apps/web-roo-code/src/components/providers/google-analytics-provider.tsx

@@ -0,0 +1,92 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import Script from "next/script"
+import { hasConsent, onConsentChange } from "@/lib/analytics/consent-manager"
+
+// Google Tag Manager ID
+const GTM_ID = "AW-17391954825"
+
+/**
+ * Google Analytics Provider
+ * Only loads Google Tag Manager after user gives consent
+ */
+export function GoogleAnalyticsProvider({ children }: { children: React.ReactNode }) {
+	const [shouldLoad, setShouldLoad] = useState(false)
+
+	useEffect(() => {
+		// Check initial consent status
+		if (hasConsent()) {
+			setShouldLoad(true)
+			initializeGoogleAnalytics()
+		}
+
+		// Listen for consent changes
+		const unsubscribe = onConsentChange((consented) => {
+			if (consented && !shouldLoad) {
+				setShouldLoad(true)
+				initializeGoogleAnalytics()
+			}
+		})
+
+		return unsubscribe
+	}, [shouldLoad])
+
+	const initializeGoogleAnalytics = () => {
+		// Initialize the dataLayer and gtag function
+		if (typeof window !== "undefined") {
+			window.dataLayer = window.dataLayer || []
+			window.gtag = function (...args: GtagArgs) {
+				window.dataLayer.push(args)
+			}
+			window.gtag("js", new Date())
+			window.gtag("config", GTM_ID)
+		}
+	}
+
+	// Only render Google Analytics scripts if consent is given
+	if (!shouldLoad) {
+		return <>{children}</>
+	}
+
+	return (
+		<>
+			{/* Google tag (gtag.js) - Only loads after consent */}
+			<Script
+				src={`https://www.googletagmanager.com/gtag/js?id=${GTM_ID}`}
+				strategy="afterInteractive"
+				onLoad={() => {
+					console.log("Google Analytics loaded with consent")
+				}}
+			/>
+			<Script id="google-analytics-init" strategy="afterInteractive">
+				{`
+					window.dataLayer = window.dataLayer || [];
+					function gtag(){dataLayer.push(arguments);}
+					gtag('js', new Date());
+					gtag('config', '${GTM_ID}');
+				`}
+			</Script>
+			{children}
+		</>
+	)
+}
+
+// Type definitions for Google Analytics
+type GtagArgs = ["js", Date] | ["config", string, GtagConfig?] | ["event", string, GtagEventParameters?]
+
+interface GtagConfig {
+	[key: string]: unknown
+}
+
+interface GtagEventParameters {
+	[key: string]: unknown
+}
+
+// Declare global types for TypeScript
+declare global {
+	interface Window {
+		dataLayer: GtagArgs[]
+		gtag: (...args: GtagArgs) => void
+	}
+}

+ 42 - 20
apps/web-roo-code/src/components/providers/posthog-provider.tsx

@@ -3,16 +3,15 @@
 import { usePathname, useSearchParams } from "next/navigation"
 import posthog from "posthog-js"
 import { PostHogProvider as OriginalPostHogProvider } from "posthog-js/react"
-import { useEffect, Suspense } from "react"
+import { useEffect, Suspense, useState } from "react"
+import { hasConsent, onConsentChange } from "@/lib/analytics/consent-manager"
 
-// Create a separate component for analytics tracking that uses useSearchParams
 function PageViewTracker() {
 	const pathname = usePathname()
 	const searchParams = useSearchParams()
 
 	// Track page views
 	useEffect(() => {
-		// Only track page views if PostHog is properly initialized
 		if (pathname && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
 			let url = window.location.origin + pathname
 			if (searchParams && searchParams.toString()) {
@@ -29,8 +28,10 @@ function PageViewTracker() {
 }
 
 export function PostHogProvider({ children }: { children: React.ReactNode }) {
+	const [isInitialized, setIsInitialized] = useState(false)
+
 	useEffect(() => {
-		// Initialize PostHog only on the client side
+		// Initialize PostHog only on the client side AND when consent is given
 		if (typeof window !== "undefined") {
 			const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY
 			const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST
@@ -51,27 +52,48 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
 				)
 			}
 
-			posthog.init(posthogKey, {
-				api_host: posthogHost || "https://us.i.posthog.com",
-				capture_pageview: false, // We'll handle this manually
-				loaded: (posthogInstance) => {
-					if (process.env.NODE_ENV === "development") {
-						// Log to console in development
-						posthogInstance.debug()
-					}
-				},
-				respect_dnt: true, // Respect Do Not Track
+			const initializePosthog = () => {
+				if (!isInitialized) {
+					posthog.init(posthogKey, {
+						api_host: posthogHost || "https://us.i.posthog.com",
+						capture_pageview: false,
+						loaded: (posthogInstance) => {
+							if (process.env.NODE_ENV === "development") {
+								posthogInstance.debug()
+							}
+						},
+						respect_dnt: true, // Respect Do Not Track
+					})
+					setIsInitialized(true)
+				}
+			}
+
+			// Check initial consent status
+			if (hasConsent()) {
+				initializePosthog()
+			}
+
+			// Listen for consent changes
+			const unsubscribe = onConsentChange((consented) => {
+				if (consented && !isInitialized) {
+					initializePosthog()
+				}
 			})
-		}
 
-		// No explicit cleanup needed for posthog-js v1.231.0
-	}, [])
+			return () => {
+				unsubscribe()
+			}
+		}
+	}, [isInitialized])
 
+	// Only provide PostHog context if it's initialized
 	return (
 		<OriginalPostHogProvider client={posthog}>
-			<Suspense fallback={null}>
-				<PageViewTracker />
-			</Suspense>
+			{isInitialized && (
+				<Suspense fallback={null}>
+					<PageViewTracker />
+				</Suspense>
+			)}
 			{children}
 		</OriginalPostHogProvider>
 	)

+ 8 - 5
apps/web-roo-code/src/components/providers/providers.tsx

@@ -4,17 +4,20 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
 import { ThemeProvider } from "next-themes"
 
 import { PostHogProvider } from "./posthog-provider"
+import { GoogleAnalyticsProvider } from "./google-analytics-provider"
 
 const queryClient = new QueryClient()
 
 export const Providers = ({ children }: { children: React.ReactNode }) => {
 	return (
 		<QueryClientProvider client={queryClient}>
-			<PostHogProvider>
-				<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
-					{children}
-				</ThemeProvider>
-			</PostHogProvider>
+			<GoogleAnalyticsProvider>
+				<PostHogProvider>
+					<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
+						{children}
+					</ThemeProvider>
+				</PostHogProvider>
+			</GoogleAnalyticsProvider>
 		</QueryClientProvider>
 	)
 }

+ 47 - 0
apps/web-roo-code/src/lib/analytics/consent-manager.ts

@@ -0,0 +1,47 @@
+/**
+ * Simple consent event system
+ * Dispatches events when cookie consent changes
+ */
+
+import { getCookieConsentValue } from "react-cookie-consent"
+import { CONSENT_COOKIE_NAME } from "@roo-code/types"
+
+export const CONSENT_EVENT = "cookieConsentChanged"
+
+/**
+ * Check if user has given consent for analytics cookies
+ * Uses react-cookie-consent's built-in function
+ */
+export function hasConsent(): boolean {
+	if (typeof window === "undefined") return false
+	return getCookieConsentValue(CONSENT_COOKIE_NAME) === "true"
+}
+
+/**
+ * Dispatch a consent change event
+ */
+export function dispatchConsentEvent(consented: boolean): void {
+	if (typeof window !== "undefined") {
+		const event = new CustomEvent(CONSENT_EVENT, {
+			detail: { consented },
+		})
+		window.dispatchEvent(event)
+	}
+}
+
+/**
+ * Listen for consent changes
+ */
+export function onConsentChange(callback: (consented: boolean) => void): () => void {
+	if (typeof window === "undefined") {
+		return () => {}
+	}
+
+	const handler = (event: Event) => {
+		const customEvent = event as CustomEvent<{ consented: boolean }>
+		callback(customEvent.detail.consented)
+	}
+
+	window.addEventListener(CONSENT_EVENT, handler)
+	return () => window.removeEventListener(CONSENT_EVENT, handler)
+}

+ 22 - 0
packages/types/src/cookie-consent.ts

@@ -0,0 +1,22 @@
+/**
+ * Cookie consent constants and types
+ * Shared across all Roo Code repositories
+ */
+
+/**
+ * The name of the cookie that stores user's consent preference
+ * Used by react-cookie-consent library
+ */
+export const CONSENT_COOKIE_NAME = "roo-code-cookie-consent"
+
+/**
+ * Possible values for the consent cookie
+ */
+export type ConsentCookieValue = "true" | "false"
+
+/**
+ * Cookie consent event names for communication between components
+ */
+export const COOKIE_CONSENT_EVENTS = {
+	CHANGED: "cookieConsentChanged",
+} as const

+ 1 - 0
packages/types/src/index.ts

@@ -1,6 +1,7 @@
 export * from "./api.js"
 export * from "./cloud.js"
 export * from "./codebase-index.js"
+export * from "./cookie-consent.js"
 export * from "./events.js"
 export * from "./experiment.js"
 export * from "./followup.js"

+ 20 - 13
pnpm-lock.yaml

@@ -302,6 +302,9 @@ importers:
       react:
         specifier: ^18.3.1
         version: 18.3.1
+      react-cookie-consent:
+        specifier: ^9.0.0
+        version: 9.0.0([email protected])
       react-dom:
         specifier: ^18.3.1
         version: 18.3.1([email protected])
@@ -317,6 +320,9 @@ importers:
       tailwindcss-animate:
         specifier: ^1.0.7
         version: 1.0.7([email protected])
+      tldts:
+        specifier: ^6.1.86
+        version: 6.1.86
       zod:
         specifier: ^3.25.61
         version: 3.25.61
@@ -8171,10 +8177,6 @@ packages:
     resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
     engines: {node: ^10 || ^12 || >=14}
 
-  [email protected]:
-    resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
-    engines: {node: ^10 || ^12 || >=14}
-
   [email protected]:
     resolution: {integrity: sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==}
     engines: {node: ^10 || ^12 || >=14}
@@ -8341,6 +8343,12 @@ packages:
     resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
     hasBin: true
 
+  [email protected]:
+    resolution: {integrity: sha512-Blyj+m+Zz7SFHYqT18p16EANgnSg2sIyU6Yp3vk83AnOnSW7qnehPkUe4+8+qxztJrNmCH5GP+VHsWzAKVOoZA==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      react: '>=16'
+
   [email protected]:
     resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
     peerDependencies:
@@ -13269,7 +13277,7 @@ snapshots:
       '@alloc/quick-lru': 5.2.0
       '@tailwindcss/node': 4.1.8
       '@tailwindcss/oxide': 4.1.8
-      postcss: 8.5.3
+      postcss: 8.5.4
       tailwindcss: 4.1.8
 
   '@tailwindcss/[email protected]([email protected])':
@@ -13834,7 +13842,7 @@ snapshots:
       sirv: 3.0.1
       tinyglobby: 0.2.14
       tinyrainbow: 2.0.0
-      vitest: 3.2.4(@types/[email protected])(@types/node@24.2.1)(@vitest/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
+      vitest: 3.2.4(@types/[email protected])(@types/node@20.17.57)(@vitest/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
 
   '@vitest/[email protected]':
     dependencies:
@@ -18353,12 +18361,6 @@ snapshots:
       picocolors: 1.1.1
       source-map-js: 1.2.1
 
-  [email protected]:
-    dependencies:
-      nanoid: 3.3.11
-      picocolors: 1.1.1
-      source-map-js: 1.2.1
-
   [email protected]:
     dependencies:
       nanoid: 3.3.11
@@ -18562,6 +18564,11 @@ snapshots:
       strip-json-comments: 2.0.1
     optional: true
 
+  [email protected]([email protected]):
+    dependencies:
+      js-cookie: 2.2.1
+      react: 18.3.1
+
   [email protected]([email protected]):
     dependencies:
       loose-envify: 1.4.0
@@ -19792,7 +19799,7 @@ snapshots:
       source-map: 0.8.0-beta.0
       sucrase: 3.35.0
       tinyexec: 0.3.2
-      tinyglobby: 0.2.13
+      tinyglobby: 0.2.14
       tree-kill: 1.2.2
     optionalDependencies:
       postcss: 8.5.4