diff --git a/frontend/editor/src/core/components/AppProviders.tsx b/frontend/editor/src/core/components/AppProviders.tsx index ede9ed266a..f050ae7f9b 100644 --- a/frontend/editor/src/core/components/AppProviders.tsx +++ b/frontend/editor/src/core/components/AppProviders.tsx @@ -27,6 +27,7 @@ import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestra import { PageEditorProvider } from "@app/contexts/PageEditorContext"; import { BannerProvider } from "@app/contexts/BannerContext"; import ErrorBoundary from "@app/components/shared/ErrorBoundary"; +import { usePosthogTracking } from "@app/hooks/usePosthogTracking"; import { useScarfTracking } from "@app/hooks/useScarfTracking"; import { useAppInitialization } from "@app/hooks/useAppInitialization"; import { useLogoAssets } from "@app/hooks/useLogoAssets"; @@ -43,6 +44,11 @@ function ScarfTrackingInitializer() { return null; } +function PosthogTrackingInitializer() { + usePosthogTracking(); + return null; +} + // Component to run app-level initialization (must be inside AppProviders for context access) function AppInitializer() { useAppInitialization(); @@ -122,6 +128,7 @@ export function AppProviders({ retryOptions={appConfigRetryOptions} {...appConfigProviderProps} > + diff --git a/frontend/editor/src/core/hooks/usePosthogTracking.test.tsx b/frontend/editor/src/core/hooks/usePosthogTracking.test.tsx new file mode 100644 index 0000000000..41f5fad7dc --- /dev/null +++ b/frontend/editor/src/core/hooks/usePosthogTracking.test.tsx @@ -0,0 +1,76 @@ +import { ReactNode } from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +const posthogState = vi.hoisted(() => ({ loaded: false })); +const posthogMock = vi.hoisted(() => ({ + get __loaded() { + return posthogState.loaded; + }, + init: vi.fn(() => { + posthogState.loaded = true; + }), + opt_out_capturing: vi.fn(), + opt_in_capturing: vi.fn(), + set_config: vi.fn(), + has_opted_in_capturing: vi.fn(() => false), +})); + +vi.mock("posthog-js", () => ({ + default: posthogMock, +})); + +import { usePosthogTracking } from "@app/hooks/usePosthogTracking"; + +describe("usePosthogTracking", () => { + beforeEach(() => { + posthogState.loaded = false; + posthogMock.init.mockClear(); + posthogMock.opt_out_capturing.mockClear(); + posthogMock.opt_in_capturing.mockClear(); + posthogMock.set_config.mockClear(); + vi.stubEnv("VITE_PUBLIC_POSTHOG_KEY", "test-key"); + vi.stubEnv("VITE_PUBLIC_POSTHOG_HOST", "https://eu.i.posthog.com"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("does not initialize PostHog when analytics is disabled", async () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + renderHook(() => usePosthogTracking(), { wrapper }); + + await waitFor(() => { + expect(posthogMock.init).not.toHaveBeenCalled(); + }); + }); + + it("initializes PostHog when analytics is enabled", async () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + renderHook(() => usePosthogTracking(), { wrapper }); + + await waitFor(() => { + expect(posthogMock.init).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/editor/src/core/hooks/usePosthogTracking.ts b/frontend/editor/src/core/hooks/usePosthogTracking.ts new file mode 100644 index 0000000000..a8037ec4a7 --- /dev/null +++ b/frontend/editor/src/core/hooks/usePosthogTracking.ts @@ -0,0 +1,83 @@ +import { useEffect } from "react"; +import posthog from "posthog-js"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; + +function applyPosthogConsent(): void { + if (typeof window === "undefined" || !posthog.__loaded) { + return; + } + + const optedIn = + window.CookieConsent?.acceptedService?.("posthog", "analytics") || false; + + if (optedIn) { + posthog.set_config({ persistence: "localStorage+cookie" }); + posthog.opt_in_capturing(); + return; + } + + posthog.opt_out_capturing(); + posthog.set_config({ persistence: "memory" }); +} + +function ensurePosthogInitialized(): boolean { + if (typeof window === "undefined") { + return false; + } + + const posthogKey = import.meta.env.VITE_PUBLIC_POSTHOG_KEY; + const posthogHost = import.meta.env.VITE_PUBLIC_POSTHOG_HOST; + + if (!posthogKey || !posthogHost) { + return false; + } + + if (!posthog.__loaded) { + posthog.init(posthogKey, { + api_host: posthogHost, + defaults: "2025-05-24", + capture_exceptions: true, + debug: false, + opt_out_capturing_by_default: true, + persistence: "memory", + cross_subdomain_cookie: false, + }); + } + + return true; +} + +export function usePosthogTracking(): void { + const { config } = useAppConfig(); + + useEffect(() => { + const analyticsEnabled = config?.enableAnalytics === true; + const posthogEnabled = analyticsEnabled && config?.enablePosthog !== false; + + if (!posthogEnabled) { + if (posthog.__loaded) { + posthog.opt_out_capturing(); + posthog.set_config({ persistence: "memory" }); + } + return; + } + + if (!ensurePosthogInitialized()) { + return; + } + + applyPosthogConsent(); + + const handleConsentChange = () => { + applyPosthogConsent(); + }; + + window.addEventListener("cc:onConsent", handleConsentChange); + window.addEventListener("cc:onChange", handleConsentChange); + + return () => { + window.removeEventListener("cc:onConsent", handleConsentChange); + window.removeEventListener("cc:onChange", handleConsentChange); + }; + }, [config?.enableAnalytics, config?.enablePosthog]); +} diff --git a/frontend/editor/src/index.tsx b/frontend/editor/src/index.tsx index 017138cebc..d776918bd1 100644 --- a/frontend/editor/src/index.tsx +++ b/frontend/editor/src/index.tsx @@ -13,8 +13,6 @@ import { ColorSchemeScript } from "@mantine/core"; import { BrowserRouter } from "react-router-dom"; import App from "@app/App"; import "@app/i18n"; // Initialize i18next -import posthog from "posthog-js"; -import { PostHogProvider } from "@posthog/react"; import { BASE_PATH } from "@app/constants/app"; import { startEagerWasmCompilation } from "@app/services/wasmPrecompiler"; @@ -35,33 +33,6 @@ if (typeof window !== "undefined") { } } -posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, { - api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, - defaults: "2025-05-24", - capture_exceptions: true, // This enables capturing exceptions using Error Tracking, set to false if you don't want this - debug: false, - opt_out_capturing_by_default: true, // Opt-out by default, controlled by cookie consent - persistence: "memory", // No cookies/localStorage written until user opts in - cross_subdomain_cookie: false, -}); - -function updatePosthogConsent() { - if (!posthog.__loaded) return; - const optIn = - window.CookieConsent?.acceptedService?.("posthog", "analytics") || false; - if (optIn) { - posthog.set_config({ persistence: "localStorage+cookie" }); - posthog.opt_in_capturing(); - } else { - posthog.opt_out_capturing(); - posthog.set_config({ persistence: "memory" }); - } - console.log("Updated PostHog consent: ", optIn ? "opted in" : "opted out"); -} - -window.addEventListener("cc:onConsent", updatePosthogConsent); -window.addEventListener("cc:onChange", updatePosthogConsent); - const container = document.getElementById("root"); if (!container) { throw new Error("Root container missing in index.html"); @@ -71,10 +42,8 @@ const root = ReactDOM.createRoot(container); // Finds the root DOM element root.render( - - - - - + + + , );