diff --git a/docs/pages/_app.js b/docs/pages/_app.js index b2da57eb2ea405..d9bbb06f10588f 100644 --- a/docs/pages/_app.js +++ b/docs/pages/_app.js @@ -26,6 +26,7 @@ import DocsStyledEngineProvider from 'docs/src/modules/utils/StyledEngineProvide import createEmotionCache from 'docs/src/createEmotionCache'; import findActivePage from 'docs/src/modules/utils/findActivePage'; import getProductInfoFromUrl from 'docs/src/modules/utils/getProductInfoFromUrl'; +import { AnalyticsProvider } from 'docs/src/modules/components/AnalyticsProvider'; import { DocsProvider } from '@mui/docs/DocsProvider'; import { mapTranslations } from '@mui/docs/i18n'; import SvgMuiLogomark, { @@ -428,8 +429,10 @@ function AppWrapper(props) { - {children} - + + {children} + + diff --git a/docs/pages/_document.js b/docs/pages/_document.js index 873bbba1fa2bd2..f3c2d3b3a9025a 100644 --- a/docs/pages/_document.js +++ b/docs/pages/_document.js @@ -15,6 +15,7 @@ const PRODUCTION_GA = process.env.DEPLOY_ENV === 'production' || process.env.DEPLOY_ENV === 'staging'; const GOOGLE_ANALYTICS_ID_V4 = PRODUCTION_GA ? 'G-5NXDQLC2ZK' : 'G-XJ83JQEK7J'; +const APOLLO_TRACKING_ID = PRODUCTION_GA ? '691c2e920c5e20000d7801b6' : 'dev-id'; export default class MyDocument extends Document { render() { @@ -104,10 +105,47 @@ export default class MyDocument extends Document { window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} window.gtag = gtag; + +${/* Set default consent to denied (Google Consent Mode v2) */ ''} +gtag('consent', 'default', { + 'ad_storage': 'denied', + 'ad_user_data': 'denied', + 'ad_personalization': 'denied', + 'analytics_storage': 'denied', + 'wait_for_update': 500 +}); +gtag('set', 'ads_data_redaction', true); +gtag('set', 'url_passthrough', true); + gtag('js', new Date()); gtag('config', '${GOOGLE_ANALYTICS_ID_V4}', { send_page_view: false, }); + +${/* Apollo initialization - called by AnalyticsProvider when consent is granted */ ''} +window.initApollo = function() { + if (window.apolloInitialized) return; + window.apolloInitialized = true; + var n = Math.random().toString(36).substring(7), + o = document.createElement('script'); + o.src = 'https://assets.apollo.io/micro/website-tracker/tracker.iife.js?nocache=' + n; + o.async = true; + o.defer = true; + o.onload = function () { + window.trackingFunctions.onLoad({ appId: '${APOLLO_TRACKING_ID}' }); + }; + document.head.appendChild(o); +}; + +${/* Check localStorage for existing consent and initialize if already granted */ ''} +(function() { + try { + var consent = localStorage.getItem('docs-cookie-consent'); + if (consent === 'analytics') { + window.initApollo(); + } + } catch (e) {} +})(); `, }} /> diff --git a/docs/src/BrandingCssVarsProvider.tsx b/docs/src/BrandingCssVarsProvider.tsx index cb5e731d886cde..334c87f12da180 100644 --- a/docs/src/BrandingCssVarsProvider.tsx +++ b/docs/src/BrandingCssVarsProvider.tsx @@ -127,15 +127,11 @@ export function resetDocsSpacing() { } } -export default function BrandingCssVarsProvider(props: { - children: React.ReactNode; - direction?: 'ltr' | 'rtl'; -}) { - const { direction = 'ltr', children } = props; - const { asPath } = useRouter(); - const { canonicalAs } = pathnameToLanguage(asPath); - const theme = React.useMemo(() => { - return createTheme({ +const themeCache: Map> = new Map(); +function getTheme(direction: 'ltr' | 'rtl') { + let cachedTheme = themeCache.get(direction); + if (!cachedTheme) { + cachedTheme = createTheme({ cssVariables: { cssVarPrefix: 'muidocs', colorSchemeSelector: 'data-mui-color-scheme', @@ -143,7 +139,35 @@ export default function BrandingCssVarsProvider(props: { direction, ...themeOptions, }); - }, [direction]); + themeCache.set(direction, cachedTheme); + } + return cachedTheme!; +} + +export function BrandingCssThemeProvider({ + children, + direction = 'ltr', + forceThemeRerender = false, +}: React.PropsWithChildren<{ direction?: 'ltr' | 'rtl'; forceThemeRerender?: boolean }>) { + return ( + + {children} + + ); +} + +export default function BrandingCssVarsProvider(props: { + children: React.ReactNode; + direction?: 'ltr' | 'rtl'; +}) { + const { direction = 'ltr', children } = props; + const { asPath } = useRouter(); + const { canonicalAs } = pathnameToLanguage(asPath); useEnhancedEffect(() => { const nextPaletteColors = JSON.parse(getCookie('paletteColors') || 'null'); if (nextPaletteColors) { @@ -164,10 +188,7 @@ export default function BrandingCssVarsProvider(props: { } }, [direction]); return ( - @@ -175,6 +196,6 @@ export default function BrandingCssVarsProvider(props: { {children} - + ); } diff --git a/docs/src/modules/components/AnalyticsProvider.tsx b/docs/src/modules/components/AnalyticsProvider.tsx new file mode 100644 index 00000000000000..09c725d7429eed --- /dev/null +++ b/docs/src/modules/components/AnalyticsProvider.tsx @@ -0,0 +1,219 @@ +import * as React from 'react'; +import Button from '@mui/material/Button'; +import Fade from '@mui/material/Fade'; +import Paper from '@mui/material/Paper'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import useLocalStorageState from '@mui/utils/useLocalStorageState'; +import { alpha } from '@mui/system'; +import Portal from '@mui/material/Portal'; +import TrapFocus from '@mui/material/Unstable_TrapFocus'; +import CookieOutlinedIcon from '@mui/icons-material/CookieOutlined'; +import { BrandingCssThemeProvider } from 'docs/src/BrandingCssVarsProvider'; + +const COOKIE_CONSENT_KEY = 'docs-cookie-consent'; + +type ConsentStatus = 'analytics' | 'essential' | null; + +function getDoNotTrack(): boolean { + if (typeof window === 'undefined') { + return false; + } + // Check for Do Not Track (DNT) + return navigator.doNotTrack === '1' || (window as { doNotTrack?: string }).doNotTrack === '1'; +} + +// DNT doesn't change during a session, so we can use a simple external store +const subscribeToNothing = () => () => {}; +const getDoNotTrackSnapshot = () => getDoNotTrack(); +const getDoNotTrackServerSnapshot = () => true; // Assume DNT until we know the actual value + +function useDoNotTrack(): boolean { + return React.useSyncExternalStore( + subscribeToNothing, + getDoNotTrackSnapshot, + getDoNotTrackServerSnapshot, + ); +} + +interface AnalyticsContextValue { + consentStatus: ConsentStatus; + hasAnalyticsConsent: boolean; + needsConsent: boolean; + setAnalyticsConsent: () => void; + setEssentialOnly: () => void; +} + +const AnalyticsContext = React.createContext({ + consentStatus: null, + hasAnalyticsConsent: false, + needsConsent: false, + setAnalyticsConsent: () => {}, + setEssentialOnly: () => {}, +}); + +export function useAnalyticsConsent() { + return React.useContext(AnalyticsContext); +} + +export function CookieConsentDialog() { + const { needsConsent, setAnalyticsConsent, setEssentialOnly } = useAnalyticsConsent(); + const [show, setShow] = React.useState(false); + + React.useEffect(() => { + if (needsConsent) { + // Double rAF to ensure the initial opacity: 0 state is painted before transitioning + const frame = requestAnimationFrame(() => { + requestAnimationFrame(() => { + setShow(true); + }); + }); + return () => cancelAnimationFrame(frame); + } + setShow(false); + return undefined; + }, [needsConsent]); + + return ( + + + + ({ + position: 'fixed', + bottom: 0, + right: 0, + p: 2, + m: 2, + maxWidth: 340, + pointerEvents: 'auto', + boxShadow: theme.shadows[2], + zIndex: theme.zIndex.snackbar, + ...theme.applyDarkStyles({ + bgcolor: 'primaryDark.900', + }), + })} + > + + + ({ + borderRadius: '50%', + bgcolor: alpha(theme.palette.primary.main, 0.1), + p: 1, + display: 'inline-block', + width: 40, + height: 40, + mb: -1, + alignSelf: { xs: 'center', sm: 'flex-start' }, + })} + > + + + + + Cookie Preferences + + + We use cookies to understand site usage and improve our content. This includes + third-party analytics. + + + + + + + + + + + + + + ); +} + +function updateGoogleConsent(hasAnalytics: boolean) { + if (typeof window !== 'undefined' && typeof window.gtag === 'function') { + window.gtag('consent', 'update', { + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied', + analytics_storage: hasAnalytics ? 'granted' : 'denied', + }); + + // Initialize Apollo when analytics consent is granted + const win = window as typeof window & { initApollo?: () => void }; + if (hasAnalytics && typeof win.initApollo === 'function') { + win.initApollo(); + } + } +} + +export function AnalyticsProvider({ children }: { children: React.ReactNode }) { + const [consentStatus, setConsentStatus] = useLocalStorageState(COOKIE_CONSENT_KEY, null); + const doNotTrack = useDoNotTrack(); + + // Respect Do Not Track - don't show dialog and treat as essential only + const needsConsent = consentStatus === null && !doNotTrack; + + // Update Google consent when status changes or on mount if already set + React.useEffect(() => { + if (doNotTrack) { + // DNT is enabled - always deny analytics + updateGoogleConsent(false); + } else if (consentStatus !== null) { + updateGoogleConsent(consentStatus === 'analytics'); + } + }, [consentStatus, doNotTrack]); + + const setAnalyticsConsent = React.useCallback(() => { + setConsentStatus('analytics'); + }, [setConsentStatus]); + + const setEssentialOnly = React.useCallback(() => { + setConsentStatus('essential'); + }, [setConsentStatus]); + + const contextValue = React.useMemo( + () => ({ + consentStatus: doNotTrack ? 'essential' : (consentStatus as ConsentStatus), + hasAnalyticsConsent: !doNotTrack && consentStatus === 'analytics', + needsConsent, + setAnalyticsConsent, + setEssentialOnly, + }), + [consentStatus, doNotTrack, needsConsent, setAnalyticsConsent, setEssentialOnly], + ); + + return ( + + {children} + + + + + ); +}