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

Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: Bruno Bergher <me@brunobergher.com>
Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com>
This commit is contained in:
roomote[bot] 2025-09-16 14:34:19 -04:00 committed by GitHub
parent 6d8de53c8f
commit 2819120216
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 347 additions and 49 deletions

View file

@ -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": {

View file

@ -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>

View file

@ -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>
)
}

View file

@ -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
}
}

View file

@ -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()
}
})
return () => {
unsubscribe()
}
}
}, [isInitialized])
// No explicit cleanup needed for posthog-js v1.231.0
}, [])
// 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>
)

View file

@ -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>
)
}

View file

@ -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)
}

View file

@ -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

View file

@ -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"

33
pnpm-lock.yaml generated
View file

@ -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(react@18.3.1)
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
@ -317,6 +320,9 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.17)
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}
postcss@8.5.3:
resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.4:
resolution: {integrity: sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==}
engines: {node: ^10 || ^12 || >=14}
@ -8341,6 +8343,12 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-cookie-consent@9.0.0:
resolution: {integrity: sha512-Blyj+m+Zz7SFHYqT18p16EANgnSg2sIyU6Yp3vk83AnOnSW7qnehPkUe4+8+qxztJrNmCH5GP+VHsWzAKVOoZA==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16'
react-dom@18.3.1:
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/typography@0.5.16(tailwindcss@3.4.17)':
@ -13834,7 +13842,7 @@ snapshots:
sirv: 3.0.1
tinyglobby: 0.2.14
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
'@vitest/utils@3.2.4':
dependencies:
@ -18353,12 +18361,6 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.3:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.4:
dependencies:
nanoid: 3.3.11
@ -18562,6 +18564,11 @@ snapshots:
strip-json-comments: 2.0.1
optional: true
react-cookie-consent@9.0.0(react@18.3.1):
dependencies:
js-cookie: 2.2.1
react: 18.3.1
react-dom@18.3.1(react@18.3.1):
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