Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/pages/_app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -428,8 +429,10 @@ function AppWrapper(props) {
<DemoContext.Provider value={demoContextValue}>
<ThemeProvider>
<DocsStyledEngineProvider cacheLtr={emotionCache}>
{children}
<GoogleAnalytics />
<AnalyticsProvider>
{children}
<GoogleAnalytics />
</AnalyticsProvider>
</DocsStyledEngineProvider>
</ThemeProvider>
</DemoContext.Provider>
Expand Down
38 changes: 38 additions & 0 deletions docs/pages/_document.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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) {}
})();
`,
}}
/>
Expand Down
51 changes: 36 additions & 15 deletions docs/src/BrandingCssVarsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,23 +127,47 @@ 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<string, ReturnType<typeof createTheme>> = new Map();
function getTheme(direction: 'ltr' | 'rtl') {
let cachedTheme = themeCache.get(direction);
if (!cachedTheme) {
cachedTheme = createTheme({
cssVariables: {
cssVarPrefix: 'muidocs',
colorSchemeSelector: 'data-mui-color-scheme',
},
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 (
<ThemeProvider
theme={getTheme(direction)}
disableTransitionOnChange
// TODO: remove `forceThemeRerender` once custom theme on some demos rely on CSS variables instead of `theme.palette.mode`
forceThemeRerender={forceThemeRerender}
>
{children}
</ThemeProvider>
);
}

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) {
Expand All @@ -164,17 +188,14 @@ export default function BrandingCssVarsProvider(props: {
}
}, [direction]);
return (
<ThemeProvider
theme={theme}
disableTransitionOnChange
// TODO: remove `forceThemeRerender` once custom theme on some demos rely on CSS variables instead of `theme.palette.mode`
<BrandingCssThemeProvider
forceThemeRerender={canonicalAs.startsWith('/x/') || canonicalAs.startsWith('/toolpad/')}
>
<NextNProgressBar />
<CssBaseline />
<SkipLink />
<MarkdownLinks />
{children}
</ThemeProvider>
</BrandingCssThemeProvider>
);
}
219 changes: 219 additions & 0 deletions docs/src/modules/components/AnalyticsProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<AnalyticsContextValue>({
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 (
<Portal>
<TrapFocus open={needsConsent} disableAutoFocus disableEnforceFocus>
<Fade in={show} unmountOnExit>
<Paper
role="dialog"
aria-modal="false"
aria-labelledby="cookie-consent-dialog-title"
aria-describedby="cookie-consent-dialog-description"
variant="outlined"
tabIndex={-1}
sx={(theme) => ({
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',
}),
})}
>
<Stack direction="column" spacing={3} sx={{ justifyContent: 'flex-start' }}>
<Stack
spacing={1}
sx={{ flexShrink: 1, alignSelf: { xs: 'flex-start', sm: 'center' } }}
>
<Box
sx={(theme) => ({
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' },
})}
>
<CookieOutlinedIcon color="primary" strokeWidth={1.5} />
</Box>
<Stack spacing={0.5}>
<Typography
variant="subtitle2"
id="cookie-consent-dialog-title"
textAlign={{ xs: 'center', sm: 'start' }}
>
Cookie Preferences
</Typography>
<Typography
id="cookie-consent-dialog-description"
variant="body2"
textAlign={{ xs: 'center', sm: 'start' }}
>
We use cookies to understand site usage and improve our content. This includes
third-party analytics.
</Typography>
</Stack>
</Stack>

<Stack direction="row" spacing={1} sx={{ justifyContent: 'flex-start' }}>
<Button onClick={setAnalyticsConsent} variant="contained" size="small">
Allow analytics
</Button>
<Button onClick={setEssentialOnly} size="small">
Essential only
</Button>
</Stack>
</Stack>
</Paper>
</Fade>
</TrapFocus>
</Portal>
);
}

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<AnalyticsContextValue>(
() => ({
consentStatus: doNotTrack ? 'essential' : (consentStatus as ConsentStatus),
hasAnalyticsConsent: !doNotTrack && consentStatus === 'analytics',
needsConsent,
setAnalyticsConsent,
setEssentialOnly,
}),
[consentStatus, doNotTrack, needsConsent, setAnalyticsConsent, setEssentialOnly],
);

return (
<AnalyticsContext.Provider value={contextValue}>
{children}
<BrandingCssThemeProvider>
<CookieConsentDialog />
</BrandingCssThemeProvider>
</AnalyticsContext.Provider>
);
}
Loading