Skip to content

Commit 2819120

Browse files
roomote[bot]roomotebrunoberghermrubens
authored
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]>
1 parent 6d8de53 commit 2819120

File tree

10 files changed

+347
-49
lines changed

10 files changed

+347
-49
lines changed

apps/web-roo-code/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,13 @@
2828
"next-themes": "^0.4.6",
2929
"posthog-js": "^1.248.1",
3030
"react": "^18.3.1",
31+
"react-cookie-consent": "^9.0.0",
3132
"react-dom": "^18.3.1",
3233
"react-icons": "^5.5.0",
3334
"recharts": "^2.15.3",
3435
"tailwind-merge": "^3.3.0",
3536
"tailwindcss-animate": "^1.0.7",
37+
"tldts": "^6.1.86",
3638
"zod": "^3.25.61"
3739
},
3840
"devDependencies": {

apps/web-roo-code/src/app/layout.tsx

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import React from "react"
22
import type { Metadata } from "next"
33
import { Inter } from "next/font/google"
4-
import Script from "next/script"
54
import { SEO } from "@/lib/seo"
5+
import { CookieConsentWrapper } from "@/components/CookieConsentWrapper"
66

77
import { Providers } from "@/components/providers"
88

@@ -93,22 +93,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
9393
/>
9494
</head>
9595
<body className={inter.className}>
96-
{/* Google tag (gtag.js) */}
97-
<Script src="https://www.googletagmanager.com/gtag/js?id=AW-17391954825" strategy="afterInteractive" />
98-
<Script id="google-analytics" strategy="afterInteractive">
99-
{`
100-
window.dataLayer = window.dataLayer || [];
101-
function gtag(){dataLayer.push(arguments);}
102-
gtag('js', new Date());
103-
gtag('config', 'AW-17391954825');
104-
`}
105-
</Script>
10696
<div itemScope itemType="https://schema.org/WebSite">
10797
<link itemProp="url" href={SEO.url} />
10898
<meta itemProp="name" content={SEO.name} />
10999
</div>
110100
<Providers>
111101
<Shell>{children}</Shell>
102+
<CookieConsentWrapper />
112103
</Providers>
113104
</body>
114105
</html>
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"use client"
2+
3+
import React, { useState, useEffect } from "react"
4+
import ReactCookieConsent from "react-cookie-consent"
5+
import { Cookie } from "lucide-react"
6+
import { getDomain } from "tldts"
7+
import { CONSENT_COOKIE_NAME } from "@roo-code/types"
8+
import { dispatchConsentEvent } from "@/lib/analytics/consent-manager"
9+
10+
/**
11+
* GDPR-compliant cookie consent banner component
12+
* Handles both the UI and consent event dispatching
13+
*/
14+
export function CookieConsentWrapper() {
15+
const [cookieDomain, setCookieDomain] = useState<string | null>(null)
16+
17+
useEffect(() => {
18+
// Get the appropriate domain using tldts
19+
if (typeof window !== "undefined") {
20+
const domain = getDomain(window.location.hostname)
21+
setCookieDomain(domain)
22+
}
23+
}, [])
24+
25+
const handleAccept = () => {
26+
dispatchConsentEvent(true)
27+
}
28+
29+
const handleDecline = () => {
30+
dispatchConsentEvent(false)
31+
}
32+
33+
const extraCookieOptions = cookieDomain
34+
? {
35+
domain: cookieDomain,
36+
}
37+
: {}
38+
39+
const containerClasses = `
40+
fixed bottom-2 left-2 right-2 z-[999]
41+
bg-black/95 dark:bg-white/95
42+
text-white dark:text-black
43+
border-t-neutral-800 dark:border-t-gray-200
44+
backdrop-blur-xl
45+
border-t
46+
font-semibold
47+
rounded-t-lg
48+
px-4 py-4 md:px-8 md:py-4
49+
flex flex-wrap items-center justify-between gap-4
50+
text-sm font-sans
51+
`.trim()
52+
53+
const buttonWrapperClasses = `
54+
flex
55+
flex-row-reverse
56+
items-center
57+
gap-2
58+
`.trim()
59+
60+
const acceptButtonClasses = `
61+
bg-white text-black border-neutral-800
62+
dark:bg-black dark:text-white dark:border-gray-200
63+
hover:opacity-50
64+
transition-opacity
65+
rounded-md
66+
px-4 py-2 mr-2
67+
text-sm font-bold
68+
cursor-pointer
69+
focus:outline-none focus:ring-2 focus:ring-offset-2
70+
`.trim()
71+
72+
const declineButtonClasses = `
73+
dark:bg-white dark:text-black dark:border-gray-200
74+
bg-black text-white border-neutral-800
75+
hover:opacity-50
76+
border border-border
77+
transition-opacity
78+
rounded-md
79+
px-4 py-2
80+
text-sm font-bold
81+
cursor-pointer
82+
focus:outline-none focus:ring-2 focus:ring-offset-2
83+
`.trim()
84+
85+
return (
86+
<div role="banner" aria-label="Cookie consent banner" aria-live="polite">
87+
<ReactCookieConsent
88+
location="bottom"
89+
buttonText="Accept"
90+
declineButtonText="Decline"
91+
cookieName={CONSENT_COOKIE_NAME}
92+
expires={365}
93+
enableDeclineButton={true}
94+
onAccept={handleAccept}
95+
onDecline={handleDecline}
96+
containerClasses={containerClasses}
97+
buttonClasses={acceptButtonClasses}
98+
buttonWrapperClasses={buttonWrapperClasses}
99+
declineButtonClasses={declineButtonClasses}
100+
extraCookieOptions={extraCookieOptions}
101+
disableStyles={true}
102+
ariaAcceptLabel={`Accept`}
103+
ariaDeclineLabel={`Decline`}>
104+
<div className="flex items-center gap-2">
105+
<Cookie className="size-5 hidden md:block" />
106+
<span>Like most of the internet, we use cookies. Are you OK with that?</span>
107+
</div>
108+
</ReactCookieConsent>
109+
</div>
110+
)
111+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"use client"
2+
3+
import { useEffect, useState } from "react"
4+
import Script from "next/script"
5+
import { hasConsent, onConsentChange } from "@/lib/analytics/consent-manager"
6+
7+
// Google Tag Manager ID
8+
const GTM_ID = "AW-17391954825"
9+
10+
/**
11+
* Google Analytics Provider
12+
* Only loads Google Tag Manager after user gives consent
13+
*/
14+
export function GoogleAnalyticsProvider({ children }: { children: React.ReactNode }) {
15+
const [shouldLoad, setShouldLoad] = useState(false)
16+
17+
useEffect(() => {
18+
// Check initial consent status
19+
if (hasConsent()) {
20+
setShouldLoad(true)
21+
initializeGoogleAnalytics()
22+
}
23+
24+
// Listen for consent changes
25+
const unsubscribe = onConsentChange((consented) => {
26+
if (consented && !shouldLoad) {
27+
setShouldLoad(true)
28+
initializeGoogleAnalytics()
29+
}
30+
})
31+
32+
return unsubscribe
33+
}, [shouldLoad])
34+
35+
const initializeGoogleAnalytics = () => {
36+
// Initialize the dataLayer and gtag function
37+
if (typeof window !== "undefined") {
38+
window.dataLayer = window.dataLayer || []
39+
window.gtag = function (...args: GtagArgs) {
40+
window.dataLayer.push(args)
41+
}
42+
window.gtag("js", new Date())
43+
window.gtag("config", GTM_ID)
44+
}
45+
}
46+
47+
// Only render Google Analytics scripts if consent is given
48+
if (!shouldLoad) {
49+
return <>{children}</>
50+
}
51+
52+
return (
53+
<>
54+
{/* Google tag (gtag.js) - Only loads after consent */}
55+
<Script
56+
src={`https://www.googletagmanager.com/gtag/js?id=${GTM_ID}`}
57+
strategy="afterInteractive"
58+
onLoad={() => {
59+
console.log("Google Analytics loaded with consent")
60+
}}
61+
/>
62+
<Script id="google-analytics-init" strategy="afterInteractive">
63+
{`
64+
window.dataLayer = window.dataLayer || [];
65+
function gtag(){dataLayer.push(arguments);}
66+
gtag('js', new Date());
67+
gtag('config', '${GTM_ID}');
68+
`}
69+
</Script>
70+
{children}
71+
</>
72+
)
73+
}
74+
75+
// Type definitions for Google Analytics
76+
type GtagArgs = ["js", Date] | ["config", string, GtagConfig?] | ["event", string, GtagEventParameters?]
77+
78+
interface GtagConfig {
79+
[key: string]: unknown
80+
}
81+
82+
interface GtagEventParameters {
83+
[key: string]: unknown
84+
}
85+
86+
// Declare global types for TypeScript
87+
declare global {
88+
interface Window {
89+
dataLayer: GtagArgs[]
90+
gtag: (...args: GtagArgs) => void
91+
}
92+
}

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

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,15 @@
33
import { usePathname, useSearchParams } from "next/navigation"
44
import posthog from "posthog-js"
55
import { PostHogProvider as OriginalPostHogProvider } from "posthog-js/react"
6-
import { useEffect, Suspense } from "react"
6+
import { useEffect, Suspense, useState } from "react"
7+
import { hasConsent, onConsentChange } from "@/lib/analytics/consent-manager"
78

8-
// Create a separate component for analytics tracking that uses useSearchParams
99
function PageViewTracker() {
1010
const pathname = usePathname()
1111
const searchParams = useSearchParams()
1212

1313
// Track page views
1414
useEffect(() => {
15-
// Only track page views if PostHog is properly initialized
1615
if (pathname && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
1716
let url = window.location.origin + pathname
1817
if (searchParams && searchParams.toString()) {
@@ -29,8 +28,10 @@ function PageViewTracker() {
2928
}
3029

3130
export function PostHogProvider({ children }: { children: React.ReactNode }) {
31+
const [isInitialized, setIsInitialized] = useState(false)
32+
3233
useEffect(() => {
33-
// Initialize PostHog only on the client side
34+
// Initialize PostHog only on the client side AND when consent is given
3435
if (typeof window !== "undefined") {
3536
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY
3637
const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST
@@ -51,27 +52,48 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
5152
)
5253
}
5354

54-
posthog.init(posthogKey, {
55-
api_host: posthogHost || "https://us.i.posthog.com",
56-
capture_pageview: false, // We'll handle this manually
57-
loaded: (posthogInstance) => {
58-
if (process.env.NODE_ENV === "development") {
59-
// Log to console in development
60-
posthogInstance.debug()
61-
}
62-
},
63-
respect_dnt: true, // Respect Do Not Track
55+
const initializePosthog = () => {
56+
if (!isInitialized) {
57+
posthog.init(posthogKey, {
58+
api_host: posthogHost || "https://us.i.posthog.com",
59+
capture_pageview: false,
60+
loaded: (posthogInstance) => {
61+
if (process.env.NODE_ENV === "development") {
62+
posthogInstance.debug()
63+
}
64+
},
65+
respect_dnt: true, // Respect Do Not Track
66+
})
67+
setIsInitialized(true)
68+
}
69+
}
70+
71+
// Check initial consent status
72+
if (hasConsent()) {
73+
initializePosthog()
74+
}
75+
76+
// Listen for consent changes
77+
const unsubscribe = onConsentChange((consented) => {
78+
if (consented && !isInitialized) {
79+
initializePosthog()
80+
}
6481
})
65-
}
6682

67-
// No explicit cleanup needed for posthog-js v1.231.0
68-
}, [])
83+
return () => {
84+
unsubscribe()
85+
}
86+
}
87+
}, [isInitialized])
6988

89+
// Only provide PostHog context if it's initialized
7090
return (
7191
<OriginalPostHogProvider client={posthog}>
72-
<Suspense fallback={null}>
73-
<PageViewTracker />
74-
</Suspense>
92+
{isInitialized && (
93+
<Suspense fallback={null}>
94+
<PageViewTracker />
95+
</Suspense>
96+
)}
7597
{children}
7698
</OriginalPostHogProvider>
7799
)

apps/web-roo-code/src/components/providers/providers.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,20 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
44
import { ThemeProvider } from "next-themes"
55

66
import { PostHogProvider } from "./posthog-provider"
7+
import { GoogleAnalyticsProvider } from "./google-analytics-provider"
78

89
const queryClient = new QueryClient()
910

1011
export const Providers = ({ children }: { children: React.ReactNode }) => {
1112
return (
1213
<QueryClientProvider client={queryClient}>
13-
<PostHogProvider>
14-
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
15-
{children}
16-
</ThemeProvider>
17-
</PostHogProvider>
14+
<GoogleAnalyticsProvider>
15+
<PostHogProvider>
16+
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
17+
{children}
18+
</ThemeProvider>
19+
</PostHogProvider>
20+
</GoogleAnalyticsProvider>
1821
</QueryClientProvider>
1922
)
2023
}

0 commit comments

Comments
 (0)