Skip to content

Commit e6367b5

Browse files
committed
feat: add support for cookieless analytics
1 parent c49d666 commit e6367b5

5 files changed

Lines changed: 172 additions & 47 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
'use client'
2+
3+
import { useEffect } from 'react'
4+
import { useCookieConsent } from '../gdpr/contexts/useCookieConsent'
5+
import { CookieCategoryName } from '../gdpr/types'
6+
import {
7+
loadGoogleAnalytics,
8+
setGoogleConsentDefault,
9+
updateAnalyticsConsent,
10+
} from './consent-mode'
11+
12+
export type GoogleAnalyticsWithConsentProps = {
13+
gaId: string
14+
}
15+
16+
/**
17+
* Loads Google Analytics (GA4) and drives it with Google Consent Mode v2.
18+
*
19+
* GA is always loaded, but consent defaults to `denied`, so GA sends anonymous
20+
* cookieless pings (aggregate pageview counts, no tracking cookies) until the
21+
* visitor grants the Analytics cookie category. When consent changes, a
22+
* `consent update` upgrades or downgrades GA accordingly.
23+
*
24+
* This is framework-agnostic (no `next/script`), so it works in Next.js apps
25+
* and in the plain-React sphinx injection alike. Render it inside a
26+
* `CookieConsentProvider`.
27+
*/
28+
export function GoogleAnalyticsWithConsent({ gaId }: GoogleAnalyticsWithConsentProps) {
29+
const { consent } = useCookieConsent()
30+
const analyticsGranted = consent[CookieCategoryName.Analytics]
31+
32+
useEffect(() => {
33+
if (!gaId) {
34+
return
35+
}
36+
37+
// The consent default must be set before GA's config call.
38+
setGoogleConsentDefault()
39+
loadGoogleAnalytics(gaId)
40+
}, [gaId])
41+
42+
useEffect(() => {
43+
updateAnalyticsConsent(analyticsGranted)
44+
}, [analyticsGranted])
45+
46+
return null
47+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* Framework-agnostic helpers for Google Analytics (GA4) + Google Consent Mode v2.
3+
*
4+
* These contain no React or Next.js dependencies so they can be reused from
5+
* Next.js apps and plain-React/MPA contexts (e.g. the sphinx injection) alike.
6+
*/
7+
8+
export type GoogleConsentState = 'granted' | 'denied'
9+
10+
export type GoogleConsentSettings = {
11+
ad_storage?: GoogleConsentState
12+
ad_user_data?: GoogleConsentState
13+
ad_personalization?: GoogleConsentState
14+
analytics_storage?: GoogleConsentState
15+
wait_for_update?: number
16+
}
17+
18+
type GtagFn = (...args: unknown[]) => void
19+
20+
type AnalyticsWindow = Window & {
21+
dataLayer?: unknown[]
22+
gtag?: GtagFn
23+
}
24+
25+
const GA_SCRIPT_ID = 'quantinuum-google-analytics'
26+
27+
/**
28+
* The default consent state: everything denied. In this state GA runs in
29+
* "cookieless ping" mode, recording anonymous, aggregate statistics (such as
30+
* pageview counts) without setting any tracking cookies or storing an
31+
* identifier.
32+
*/
33+
export const DEFAULT_GOOGLE_CONSENT: Required<Omit<GoogleConsentSettings, 'wait_for_update'>> = {
34+
ad_storage: 'denied',
35+
ad_user_data: 'denied',
36+
ad_personalization: 'denied',
37+
analytics_storage: 'denied',
38+
}
39+
40+
/** Ensures `window.dataLayer` / `window.gtag` exist and returns the gtag function. */
41+
export function ensureGtag(): GtagFn | undefined {
42+
if (typeof window === 'undefined') {
43+
return undefined
44+
}
45+
46+
const analyticsWindow = window as AnalyticsWindow
47+
analyticsWindow.dataLayer = analyticsWindow.dataLayer || []
48+
analyticsWindow.gtag =
49+
analyticsWindow.gtag ||
50+
((...args: unknown[]) => {
51+
analyticsWindow.dataLayer?.push(args)
52+
})
53+
54+
return analyticsWindow.gtag
55+
}
56+
57+
/**
58+
* Sets the Google Consent Mode v2 default. This MUST run before GA's `config`
59+
* call. Everything defaults to `denied` so GA sends anonymous cookieless pings
60+
* until the visitor consents; pass `overrides` to adjust individual signals.
61+
*/
62+
export function setGoogleConsentDefault(overrides: GoogleConsentSettings = {}): void {
63+
const gtag = ensureGtag()
64+
if (!gtag) {
65+
return
66+
}
67+
68+
gtag('consent', 'default', {
69+
...DEFAULT_GOOGLE_CONSENT,
70+
wait_for_update: 500,
71+
...overrides,
72+
})
73+
}
74+
75+
/** Updates the analytics consent state after the visitor makes a choice. */
76+
export function updateAnalyticsConsent(granted: boolean): void {
77+
const gtag = ensureGtag()
78+
if (!gtag) {
79+
return
80+
}
81+
82+
gtag('consent', 'update', {
83+
analytics_storage: granted ? 'granted' : 'denied',
84+
})
85+
}
86+
87+
/**
88+
* Initialises GA (`gtag('js')` + `gtag('config')`) and injects the gtag.js
89+
* script tag. Safe to call more than once: the script is only appended once.
90+
*/
91+
export function loadGoogleAnalytics(gaId: string): void {
92+
if (typeof document === 'undefined') {
93+
return
94+
}
95+
96+
const gtag = ensureGtag()
97+
if (!gtag) {
98+
return
99+
}
100+
101+
gtag('js', new Date())
102+
gtag('config', gaId)
103+
104+
const scriptUrl = new URL('https://www.googletagmanager.com/gtag/js')
105+
scriptUrl.searchParams.set('id', gaId)
106+
107+
const existingScript = document.getElementById(GA_SCRIPT_ID) as HTMLScriptElement | null
108+
if (existingScript) {
109+
if (existingScript.src !== scriptUrl.toString()) {
110+
existingScript.src = scriptUrl.toString()
111+
}
112+
return
113+
}
114+
115+
const script = document.createElement('script')
116+
script.id = GA_SCRIPT_ID
117+
script.async = true
118+
script.src = scriptUrl.toString()
119+
document.head.appendChild(script)
120+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './consent-mode'
2+
export * from './GoogleAnalyticsWithConsent'

‎documentation-ui/src/custom/docs/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export * from './components/header'
66
export { CodeCopy } from './components/header/CodeCopy'
77
export * from './components/page'
88
export * from './components/gdpr'
9+
export * from './components/analytics'

‎sphinx-ui/react/src/injectNav.tsx‎

Lines changed: 2 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,11 @@
11

2-
import { CookieCategoryName, CookieConditional, CookieConsentManager, CookieConsentProvider, DocsNavBar } from "@quantinuum/documentation-ui";
3-
import { useEffect } from "react";
2+
import { CookieConsentManager, CookieConsentProvider, DocsNavBar, GoogleAnalyticsWithConsent } from "@quantinuum/documentation-ui";
43
import { createRoot } from "react-dom/client";
54

65
const GA_ID = __NEXT_PUBLIC_GA_ID__;
76

87
const tailwindScopeClassName = 'use-tailwind';
98

10-
type AnalyticsWindow = Window & {
11-
dataLayer?: unknown[][]
12-
gtag?: (...args: unknown[]) => void
13-
}
14-
15-
const GoogleAnalytics = ({ gaId }: { gaId: string }) => {
16-
useEffect(() => {
17-
if (!gaId) return
18-
19-
const analyticsWindow = window as AnalyticsWindow
20-
analyticsWindow.dataLayer = analyticsWindow.dataLayer || []
21-
analyticsWindow.gtag =
22-
analyticsWindow.gtag ||
23-
((...args: unknown[]) => {
24-
analyticsWindow.dataLayer?.push(args)
25-
})
26-
27-
analyticsWindow.gtag('js', new Date())
28-
analyticsWindow.gtag('config', gaId)
29-
30-
const scriptId = 'quantinuum-google-analytics'
31-
const scriptUrl = new URL('https://www.googletagmanager.com/gtag/js')
32-
scriptUrl.searchParams.set('id', gaId)
33-
34-
const existingScript = document.getElementById(scriptId) as HTMLScriptElement | null
35-
if (existingScript) {
36-
if (existingScript.src !== scriptUrl.toString()) {
37-
existingScript.src = scriptUrl.toString()
38-
}
39-
return
40-
}
41-
42-
const script = document.createElement('script')
43-
script.id = scriptId
44-
script.async = true
45-
script.src = scriptUrl.toString()
46-
document.head.appendChild(script)
47-
}, [gaId])
48-
49-
return null
50-
}
51-
529
const isTailwindDialogPortalElement = (element: Element): element is HTMLElement => {
5310
if (!(element instanceof HTMLElement)) return false
5411

@@ -107,9 +64,7 @@ const observeTailwindDialogPortalElements = () => {
10764
<div className={tailwindScopeClassName}>
10865
<div className="antialiased" style={{ fontFamily: `Inter, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"` }}>
10966
<CookieConsentProvider version={1}>
110-
<CookieConditional category={CookieCategoryName.Analytics} fallback={null}>
111-
{analyticsEnabled && GA_ID && <GoogleAnalytics gaId={GA_ID} />}
112-
</CookieConditional>
67+
{analyticsEnabled && GA_ID && <GoogleAnalyticsWithConsent gaId={GA_ID} />}
11368
<DocsNavBar activePath="/" />
11469
<CookieConsentManager />
11570
</CookieConsentProvider>

0 commit comments

Comments
 (0)