diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 3224c704..e2fc632c 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -326,7 +326,7 @@ const config = { }, }, ], - process.env.POSTHOG_API_KEY && [ + [ "posthog-docusaurus", { apiKey: process.env.POSTHOG_API_KEY, @@ -334,7 +334,8 @@ const config = { uiHost: 'https://us.posthog.com', enableInDevelopment: false, capturePageLeave: true, - cookieless_mode: 'always', + defaults: '2026-01-30', + cookieless_mode: 'on_reject', }, ], ].filter(Boolean), diff --git a/website/src/components/consent/CookieConsentBanner.js b/website/src/components/consent/CookieConsentBanner.js new file mode 100644 index 00000000..a741cbdb --- /dev/null +++ b/website/src/components/consent/CookieConsentBanner.js @@ -0,0 +1,120 @@ +/** + * Consent gate for PostHog when using cookieless_mode: "on_reject". + * Accept enables cookies, session replay, and full SDK features; decline keeps cookieless counting. + * + * @see https://posthog.com/docs/tutorials/cookieless-tracking + * @see https://posthog.com/tutorials/react-cookie-banner + * + * If consent stays pending (user ignores the banner), PostHog captures nothing until + * they choose. After AUTO_DECLINE_MS we call opt_out_capturing() so the banner hides + * and cookieless visit counting can run—same as clicking Decline. + */ +import React, {useEffect, useState} from 'react'; +import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; +import styles from './styles.module.css'; + +const POLL_MS = 50; +const POLL_MAX = 200; + +/** Silence after this long is treated as decline (cookieless only). */ +const AUTO_DECLINE_MS = 30_000; + +export default function CookieConsentBanner() { + const [consent, setConsent] = useState( + /** @type {'unknown' | 'pending' | 'granted' | 'denied' | 'skip'} */ ('unknown'), + ); + + useEffect(() => { + if (!ExecutionEnvironment.canUseDOM) return undefined; + + let cancelled = false; + let tries = 0; + const id = window.setInterval(() => { + tries += 1; + const ph = window.posthog; + + if (cancelled) { + window.clearInterval(id); + return; + } + + if (!ph) { + if (tries >= POLL_MAX) { + window.clearInterval(id); + setConsent('skip'); + } + return; + } + + if (typeof ph.get_explicit_consent_status !== 'function') { + if (tries >= POLL_MAX) { + window.clearInterval(id); + setConsent('skip'); + } + return; + } + + window.clearInterval(id); + setConsent(ph.get_explicit_consent_status()); + }, POLL_MS); + + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, []); + + useEffect(() => { + if (!ExecutionEnvironment.canUseDOM) return undefined; + if (consent !== 'pending') return undefined; + + const id = window.setTimeout(() => { + window.posthog?.opt_out_capturing?.(); + setConsent('denied'); + }, AUTO_DECLINE_MS); + + return () => window.clearTimeout(id); + }, [consent]); + + if (consent !== 'pending') { + return null; + } + + const handleAccept = () => { + window.posthog?.opt_in_capturing?.(); + setConsent('granted'); + }; + + const handleDecline = () => { + window.posthog?.opt_out_capturing?.(); + setConsent('denied'); + }; + + return ( +
+
+ +
+ + +
+
+
+ ); +} diff --git a/website/src/components/consent/styles.module.css b/website/src/components/consent/styles.module.css new file mode 100644 index 00000000..ecac11bd --- /dev/null +++ b/website/src/components/consent/styles.module.css @@ -0,0 +1,104 @@ +.banner { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 400; + padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom, 0)); + display: flex; + justify-content: center; + pointer-events: none; + animation: cookieBannerIn 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@keyframes cookieBannerIn { + from { + opacity: 0; + transform: translateY(100%); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.inner { + pointer-events: auto; + max-width: 42rem; + width: 100%; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem 1rem; + padding: 0.85rem 1.1rem; + border-radius: 12px; + background: var(--ifm-background-surface-color); + color: var(--ifm-font-color-base); + border: 1px solid var(--ifm-color-emphasis-200); + box-shadow: + 0 -4px 24px rgba(0, 0, 0, 0.08), + 0 -1px 0 rgba(0, 0, 0, 0.04); +} + +html[data-theme='dark'] .inner { + box-shadow: + 0 -4px 28px rgba(0, 0, 0, 0.35), + 0 -1px 0 rgba(255, 255, 255, 0.06); +} + +.text { + margin: 0; + flex: 1 1 12rem; + font-size: 0.875rem; + line-height: 1.45; + color: var(--ifm-color-emphasis-800); +} + +html[data-theme='dark'] .text { + color: var(--ifm-color-emphasis-700); +} + +.actions { + display: flex; + flex-shrink: 0; + gap: 0.5rem; +} + +.btnPrimary, +.btnSecondary { + font-family: var(--ifm-font-family-base); + font-size: 0.8125rem; + font-weight: 600; + padding: 0.45rem 0.9rem; + border-radius: 8px; + cursor: pointer; + border: 1px solid transparent; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; +} + +.btnPrimary { + background: var(--ifm-color-primary); + color: var(--ifm-font-color-base-inverse); +} + +.btnPrimary:hover { + filter: brightness(1.05); +} + +.btnSecondary { + background: transparent; + color: var(--ifm-font-color-base); + border: none; +} + +.btnSecondary:hover { + background: var(--ifm-color-emphasis-100); +} + +html[data-theme='dark'] .btnSecondary:hover { + background: var(--ifm-color-emphasis-200); +} diff --git a/website/src/theme/Root.js b/website/src/theme/Root.js index 50ba9317..111a8ce9 100644 --- a/website/src/theme/Root.js +++ b/website/src/theme/Root.js @@ -5,6 +5,8 @@ import React from 'react'; import { Toaster } from 'react-hot-toast'; import NavbarEnhancements from '@site/src/components/navigation/NavbarEnhancements'; +import CookieConsentBanner from '@site/src/components/consent/CookieConsentBanner'; + export default function Root({children}) { return ( <> @@ -77,6 +79,7 @@ export default function Root({children}) { }, }} /> + {children} );