diff --git a/apps/web-docs/.gitkeep b/apps/web-docs/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/apps/web-docs/README.md b/apps/web-docs/README.md deleted file mode 100644 index 1333ed77b7e..00000000000 --- a/apps/web-docs/README.md +++ /dev/null @@ -1 +0,0 @@ -TODO diff --git a/apps/web-roo-code/package.json b/apps/web-roo-code/package.json index 6aa808c9c2b..ceda6f62960 100644 --- a/apps/web-roo-code/package.json +++ b/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": { diff --git a/apps/web-roo-code/src/app/layout.tsx b/apps/web-roo-code/src/app/layout.tsx index ac33c8920f1..08105980496 100644 --- a/apps/web-roo-code/src/app/layout.tsx +++ b/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 }) /> - {/* Google tag (gtag.js) */} -
{children} + diff --git a/apps/web-roo-code/src/components/CookieConsentWrapper.tsx b/apps/web-roo-code/src/components/CookieConsentWrapper.tsx new file mode 100644 index 00000000000..23b8f5a28f5 --- /dev/null +++ b/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(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 ( +
+ +
+ + Like most of the internet, we use cookies. Are you OK with that? +
+
+
+ ) +} diff --git a/apps/web-roo-code/src/components/providers/google-analytics-provider.tsx b/apps/web-roo-code/src/components/providers/google-analytics-provider.tsx new file mode 100644 index 00000000000..7bbe26d5bfb --- /dev/null +++ b/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 */} + + {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 + } +} diff --git a/apps/web-roo-code/src/components/providers/posthog-provider.tsx b/apps/web-roo-code/src/components/providers/posthog-provider.tsx index a0c23cf989d..d172fd8f182 100644 --- a/apps/web-roo-code/src/components/providers/posthog-provider.tsx +++ b/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 ( - - - + {isInitialized && ( + + + + )} {children} ) diff --git a/apps/web-roo-code/src/components/providers/providers.tsx b/apps/web-roo-code/src/components/providers/providers.tsx index a0e77b38e23..acbeeb4e147 100644 --- a/apps/web-roo-code/src/components/providers/providers.tsx +++ b/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 ( - - - {children} - - + + + + {children} + + + ) } diff --git a/apps/web-roo-code/src/lib/analytics/consent-manager.ts b/apps/web-roo-code/src/lib/analytics/consent-manager.ts new file mode 100644 index 00000000000..10ef71ee702 --- /dev/null +++ b/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) +} diff --git a/packages/types/src/cookie-consent.ts b/packages/types/src/cookie-consent.ts new file mode 100644 index 00000000000..b1e97c656d3 --- /dev/null +++ b/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 diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 38b8c750f7b..7a7d5059eb0 100644 --- a/packages/types/src/index.ts +++ b/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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b2570da6b6..048141704ef 100644 --- a/pnpm-lock.yaml +++ b/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(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