From 100cf538584d247d98f991918d179802029eda2d Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 25 Aug 2026 12:29:03 +0300 Subject: [PATCH 01/13] Consent stack for the docs, with PostHog gated on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /docs is served same-origin under questdb.com, so the Cookiebot consent cookie and banner are shared with the main site; this adds the same load-bearing script order to every page's head: Consent Mode v2 defaults (all denied), the Cookiebot CMP in manual blocking mode, a bridge event, then PostHog and the Google Ads tag. PostHog previously reached these pages through Netlify's snippet injection, invisible in this repo and ungated — session replay and cookies for every visitor regardless of consent. It now initializes here instead, with cookieless_mode 'on_reject' and an opt-in/opt-out gate driven by the consent state: statistics consent means full capture, anything else means cookieless. The snippet is byte-identical to the one production serves today. The Netlify snippet injection must be removed when this deploys, or PostHog initializes twice. The footer gains a "Cookie settings" link that reopens the banner — the consent withdrawal path, and the opt-out mechanism for visitors whose region never showed a banner. Subscribe and YouTubeEmbed are deleted: neither is imported, registered in MDXComponents, nor referenced by any document, and both carried PostHog calls that predate this gating. Co-Authored-By: Claude Fable 5 --- src/components/Subscribe/index.tsx | 118 ---------------------- src/components/Subscribe/style.module.css | 67 ------------ src/components/YouTube/index.tsx | 95 ----------------- src/internals/ssr.template.js | 69 +++++++++++++ src/theme/Footer/index.tsx | 10 ++ src/theme/Footer/styles.module.css | 10 ++ 6 files changed, 89 insertions(+), 280 deletions(-) delete mode 100644 src/components/Subscribe/index.tsx delete mode 100644 src/components/Subscribe/style.module.css delete mode 100644 src/components/YouTube/index.tsx diff --git a/src/components/Subscribe/index.tsx b/src/components/Subscribe/index.tsx deleted file mode 100644 index 42d618cf4d..0000000000 --- a/src/components/Subscribe/index.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { ReactNode, useState } from "react" -import type { FormEvent } from "react" -import { CSSTransition, TransitionGroup } from "react-transition-group" - -import Input from "../../theme/Input" -import Button from "../../theme/Button" -import type { Props as ButtonProps } from "../../theme/Button" -import style from "./style.module.css" -import clsx from "clsx" -import emailPattern from "../../utils/emailPattern" - -type Provider = "newsletter" - -type Props = { - placeholder?: string - submitButtonText?: string - submitButtonVariant?: ButtonProps["variant"] - className?: string - classNameInputs?: string - renderSubmitButton?: (props: { - loading: boolean - defaultLoader: ReactNode - }) => ReactNode - eventTag?: string -} - -const providers: { [key in Provider]: string } = { - newsletter: - "https://questdb.us7.list-manage.com/subscribe/post?u=f692ae4038a31e8ae997a0f29&id=bdd4ec2744", -} - -const Spinner = () => - -const Subscribe = ({ - placeholder = "Email address", - submitButtonText = "SUBMIT", - submitButtonVariant, - className, - classNameInputs, - renderSubmitButton, - eventTag = "newsletter_form_submitted", -}: Props) => { - const [loading, setLoading] = useState(false) - const [sent, setSent] = useState(false) - - const onSubmit = async (event: FormEvent) => { - event.preventDefault() - - setLoading(true) - - const target = event.target as HTMLFormElement - const email = new FormData(target).get("email") as string - - try { - await fetch( - `${providers.newsletter}&EMAIL=${encodeURIComponent(email)}`, - { method: "GET" }, - ) - } catch (e) { - console.error("Subscription failed with error:", e.message || e) - } - - if (typeof window !== "undefined" && window.posthog) { - window.posthog.identify(email, { email }) - window.posthog.capture(eventTag, { email }) - } else { - console.error("PostHog is not available.") - } - - setLoading(false) - setSent(true) - } - - return ( -
- - - {sent ? ( -

- Thank you, we will be in touch soon! -

- ) : ( -
- - - {typeof renderSubmitButton === "function" ? ( - renderSubmitButton({ loading, defaultLoader: }) - ) : ( - - )} -
- )} -
-
-
- ) -} - -export default Subscribe diff --git a/src/components/Subscribe/style.module.css b/src/components/Subscribe/style.module.css deleted file mode 100644 index aa218aeefd..0000000000 --- a/src/components/Subscribe/style.module.css +++ /dev/null @@ -1,67 +0,0 @@ -.root { - width: 100%; -} - -.inputs { - display: grid; - gap: 1rem; -} - -@media screen and (min-width: 600px) { - .inputs { - grid-template-columns: 4fr 2fr; - } -} - -.input { - color: var(--theme-input-text-color); - background-color: var(--theme-input-bg-color); - padding: 1rem; - font-size: var(--font-size-small); - width: 100%; -} - -.input::placeholder { - color: var(--theme-input-text-color); -} - -html[data-theme="light"] .input { - background-color: var(--theme-card-secondary-bg-color); -} - -.submit { - white-space: nowrap; -} - -.loader { - position: absolute; - width: 20px; - height: 20px; -} - -.loader:after { - content: " "; - display: block; - width: 14px; - height: 14px; - margin: 0; - border-radius: 50%; - border: 3px solid transparent; - border-color: var(--palette-white) transparent var(--palette-white) - transparent; - animation: loader 1.2s linear infinite; -} - -.success { - font-size: var(--font-size-large); - font-weight: var(--ifm-font-weight-bold); -} - -@keyframes loader { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -} diff --git a/src/components/YouTube/index.tsx b/src/components/YouTube/index.tsx deleted file mode 100644 index ee4437fc48..0000000000 --- a/src/components/YouTube/index.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useEffect, useState } from "react" - -export type YouTubeEmbedProps = { - videoId?: string -} - -const YouTubeEmbed = ({ videoId = "sz20YJ-KE1U" }: YouTubeEmbedProps) => { - const [iframeLoaded, setIframeLoaded] = useState(true) - const embedUrl = `https://www.youtube.com/embed/${videoId}` - - useEffect(() => { - const handleVideoStart = () => { - try { - posthog.capture("video_started", { videoId }) - } catch (error) { - console.error("PostHog event capture failed:", error) - } - } - - const iframeElement = document.querySelector( - 'iframe[src*="youtube.com/embed"]', - ) as HTMLIFrameElement | null - - if (iframeElement) { - iframeElement.addEventListener("load", handleVideoStart) - - // Check if the iframe actually loaded - if (iframeElement.contentWindow) { - setIframeLoaded(true) - } else { - setIframeLoaded(false) - } - } - - return () => { - if (iframeElement) { - iframeElement.removeEventListener("load", handleVideoStart) - } - } - }, [videoId]) - - if (!iframeLoaded) { - return ( -
-

The video could not be loaded. It may be blocked by a browser extension.

- - Watch on YouTube - -
- ) - } - - return ( -
- -
- ) -} - -const styles: { - videoContainer: React.CSSProperties - iframe: React.CSSProperties - fallbackContainer: React.CSSProperties -} = { - videoContainer: { - position: "relative", - paddingBottom: "56.25%", // 16:9 - paddingTop: "25px", - height: 0, - }, - iframe: { - position: "absolute", - top: 0, - left: 0, - width: "100%", - height: "100%", - paddingTop: ".5rem", - paddingBottom: "1.5rem", - }, - fallbackContainer: { - textAlign: "center", - padding: "20px", - border: "1px solid #ccc", - borderRadius: "4px", - }, -} - -export default YouTubeEmbed diff --git a/src/internals/ssr.template.js b/src/internals/ssr.template.js index 78b99e293b..e27c1cc0c6 100644 --- a/src/internals/ssr.template.js +++ b/src/internals/ssr.template.js @@ -1,8 +1,77 @@ +/* + * Consent stack, mirrored from questdb.io (src/lib/consent.ts). Order is + * load-bearing: Consent Mode defaults, then the Cookiebot CMP, then the + * bridge event, then the Google Ads tag. /docs is served same-origin under + * questdb.com via a proxy rewrite, so the consent cookie is shared with the + * main site and one banner covers both. + * There is deliberately no gtag('consent','update') here: Cookiebot sends + * that to Google itself on every consent submission. + */ +const CONSENT_HEAD = ` + + + + + + +` + module.exports = ({ customFields, favicon, organizationName, url }) => ` +${CONSENT_HEAD} diff --git a/src/theme/Footer/index.tsx b/src/theme/Footer/index.tsx index 16bc06b5e0..c003f6c773 100644 --- a/src/theme/Footer/index.tsx +++ b/src/theme/Footer/index.tsx @@ -33,6 +33,16 @@ const Footer = () => { Terms + diff --git a/src/theme/Footer/styles.module.css b/src/theme/Footer/styles.module.css index c06aac4fe3..2cd8bb1416 100644 --- a/src/theme/Footer/styles.module.css +++ b/src/theme/Footer/styles.module.css @@ -83,3 +83,13 @@ color: var(--palette-pink); text-decoration: none; } + +/* Consent withdrawal path; a button because it reopens the banner in place. */ +.linkButton { + composes: link; + background: none; + border: 0; + padding: 0; + font-family: inherit; + cursor: pointer; +} From 6b6569eb5b4632adfd8c13b5dbe52bbfe886b66e Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 25 Aug 2026 13:16:38 +0300 Subject: [PATCH 02/13] remove double-publishing events --- src/internals/ssr.template.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/internals/ssr.template.js b/src/internals/ssr.template.js index e27c1cc0c6..5995a5bb41 100644 --- a/src/internals/ssr.template.js +++ b/src/internals/ssr.template.js @@ -47,13 +47,23 @@ const CONSENT_HEAD = ` (function () { function apply(c) { if (c && c.statistics) { - posthog.opt_in_capturing(); + posthog.opt_in_capturing({ captureEventName: null }); } else { posthog.opt_out_capturing(); - posthog.reset(); } } - apply((window.Cookiebot && window.Cookiebot.consent) || null); + var initial = window.Cookiebot && window.Cookiebot.consent; + if (initial) { + apply(initial); + } else { + var optedIn = false; + try { + optedIn = localStorage.getItem('__ph_opt_in_out_phc_GnFGGyhLRvRDKO6iN6eJRAypiKymw9LGf7GlAtZnaKx') === '1'; + } catch (e) {} + if (!optedIn) { + posthog.opt_out_capturing(); + } + } window.addEventListener('questdb:consent', function (e) { apply(e.detail || {}); }); From fa9067bfbb5102f8dbf135f46664fee3a9e9de44 Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 25 Aug 2026 15:15:52 +0300 Subject: [PATCH 03/13] Post-review hardening of the docs consent stack - uc.js is now async: it was a render-blocking third-party script second in head on an SEO-critical site. Safe because the PostHog gate falls back to the persisted opt-in state when Cookiebot has not answered yet. - The gate keys on Cookiebot.hasResponse rather than the consent object, which exists (undecided) from the moment uc.js runs. - wait_for_update raised to 2000ms to match the main site. - Footer hides "Cookie settings" until Cookiebot exists. - theme/Input and utils/emailPattern removed: orphaned by the Subscribe deletion, zero remaining references. Co-Authored-By: Claude Fable 5 --- src/internals/ssr.template.js | 10 +++---- src/theme/Footer/index.tsx | 34 +++++++++++++++------- src/theme/Input/index.tsx | 48 ------------------------------- src/theme/Input/styles.module.css | 23 --------------- src/utils/emailPattern.ts | 1 - 5 files changed, 29 insertions(+), 87 deletions(-) delete mode 100644 src/theme/Input/index.tsx delete mode 100644 src/theme/Input/styles.module.css delete mode 100644 src/utils/emailPattern.ts diff --git a/src/internals/ssr.template.js b/src/internals/ssr.template.js index 5995a5bb41..c83b3074ab 100644 --- a/src/internals/ssr.template.js +++ b/src/internals/ssr.template.js @@ -19,12 +19,12 @@ const CONSENT_HEAD = ` 'functionality_storage': 'denied', 'personalization_storage': 'denied', 'security_storage': 'granted', - 'wait_for_update': 500 + 'wait_for_update': 2000 }); gtag('set', 'ads_data_redaction', true); gtag('set', 'url_passthrough', false); - + - + - + diff --git a/src/theme/Footer/index.tsx b/src/theme/Footer/index.tsx index bcf2da570c..dc9b9f871e 100644 --- a/src/theme/Footer/index.tsx +++ b/src/theme/Footer/index.tsx @@ -17,8 +17,10 @@ const Footer = () => { } } check() - window.addEventListener("questdb:consent", check) - return () => window.removeEventListener("questdb:consent", check) + const events = ["CookiebotOnLoad", "CookiebotOnConsentReady"] + events.forEach((event) => window.addEventListener(event, check)) + return () => + events.forEach((event) => window.removeEventListener(event, check)) }, []) return (