Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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 @@ -423,8 +424,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>
);
}
217 changes: 217 additions & 0 deletions docs/src/modules/components/AnalyticsProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
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',
Comment thread
dav-is marked this conversation as resolved.
zIndex: theme.zIndex.snackbar,
...theme.applyDarkStyles({
bgcolor: 'primaryDark.800',
Comment thread
dav-is marked this conversation as resolved.
Outdated
}),
})}
>
<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' }}
>
mui.com relies on cookies to improve your experience.
Comment thread
dav-is marked this conversation as resolved.
Outdated
</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