diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000000..08b820e501 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1 @@ +66e89b3e30d258d1249b914309ba1b8d24b2deb2:src/internals/ssr.template.js:generic-api-key:10 diff --git a/docusaurus.config.js b/docusaurus.config.js index 4fd36ce7b0..4fa27a87cd 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -77,6 +77,7 @@ const config = { themes: ["@docusaurus/theme-mermaid"], plugins: [ + require.resolve("./plugins/consent/index"), () => ({ name: "resolve-react", configureWebpack() { diff --git a/package.json b/package.json index 5f8ce56d15..b9d5f87499 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "framer-motion": "^10.18.0", "gray-matter": "4.0.3", "js-yaml": "^4.1.0", + "posthog-js": "1.420.0", "prism-react-renderer": "2.4.0", "react": "^19.0.0", "react-calendly": "4.3.1", diff --git a/plugins/consent/client.ts b/plugins/consent/client.ts new file mode 100644 index 0000000000..ccc39589fb --- /dev/null +++ b/plugins/consent/client.ts @@ -0,0 +1,132 @@ +import ExecutionEnvironment from "@docusaurus/ExecutionEnvironment" +import type { ClientModule } from "@docusaurus/types" +import posthog from "posthog-js" + +import { GOOGLE_ADS_ID, POSTHOG_TOKEN } from "./config" + +type CookiebotConsent = { + marketing?: boolean + statistics?: boolean + method?: "explicit" | "implied" | null +} + +type ConsentWindow = Window & { + Cookiebot?: { + consent?: CookiebotConsent + hasResponse?: boolean + } + dataLayer?: unknown[] + gtag?: (...args: unknown[]) => void + posthog?: typeof posthog +} + +let postHogInitialized = false +let currentPageCaptured = false + +if (ExecutionEnvironment.canUseDOM) { + const consentWindow = window as ConsentWindow + + const applyGoogleAdsConsent = () => { + if ( + (navigator as Navigator & { globalPrivacyControl?: boolean }) + .globalPrivacyControl === true || + consentWindow.Cookiebot?.consent?.marketing !== true || + document.querySelector("script[data-qdb-google-ads]") + ) { + return + } + + consentWindow.dataLayer = consentWindow.dataLayer || [] + consentWindow.gtag = + consentWindow.gtag || + function (...args: unknown[]) { + consentWindow.dataLayer?.push(args) + } + consentWindow.gtag("js", new Date()) + consentWindow.gtag("config", GOOGLE_ADS_ID) + + const googleAdsScript = document.createElement("script") + googleAdsScript.async = true + googleAdsScript.src = `https://www.googletagmanager.com/gtag/js?id=${GOOGLE_ADS_ID}` + googleAdsScript.setAttribute("data-qdb-google-ads", "") + document.head.appendChild(googleAdsScript) + } + + const initialize = () => { + if (!postHogInitialized) { + posthog.init(POSTHOG_TOKEN, { + api_host: "https://us.i.posthog.com", + capture_pageview: false, + cookieless_mode: "on_reject", + opt_out_capturing_by_default: true, + }) + postHogInitialized = true + } + } + + const applyCookiebotConsent = (fromCookiebotEvent = false) => { + const cookiebot = consentWindow.Cookiebot + const statistics = cookiebot?.consent?.statistics + + // Cookiebot's category values default to false before its stored state is + // ready. Trust a mount-time snapshot only when Cookiebot marks it as a + // response (including a stored rejection) or as implied consent. Its + // lifecycle events are authoritative even when no response was required. + const hasSettledConsent = + fromCookiebotEvent || + cookiebot?.hasResponse === true || + cookiebot?.consent?.method === "implied" + if (!hasSettledConsent) return + + if (typeof statistics !== "boolean") return + + applyGoogleAdsConsent() + initialize() + consentWindow.posthog = posthog + if (statistics) { + posthog.opt_in_capturing({ captureEventName: null }) + } else { + posthog.opt_out_capturing() + } + + if (!currentPageCaptured) { + currentPageCaptured = true + posthog.capture("$pageview", { + $current_url: window.location.href, + $referrer: document.referrer, + }) + } + } + + const handleCookiebotEvent = () => applyCookiebotConsent(true) + + const cookiebotEvents = [ + "CookiebotOnConsentReady", + "CookiebotOnAccept", + "CookiebotOnDecline", + ] + cookiebotEvents.forEach((event) => + window.addEventListener(event, handleCookiebotEvent), + ) + applyCookiebotConsent() +} + +const clientModule: ClientModule = { + onRouteDidUpdate({ location, previousLocation }) { + if (!postHogInitialized || !previousLocation) return + + const nextPath = location.pathname + location.search + location.hash + const previousPath = + previousLocation.pathname + + previousLocation.search + + previousLocation.hash + if (nextPath === previousPath) return + + posthog.capture("$pageview", { + $current_url: new URL(nextPath, window.location.origin).href, + $referrer: new URL(previousPath, window.location.origin).href, + }) + }, +} + +export default clientModule diff --git a/plugins/consent/config.js b/plugins/consent/config.js new file mode 100644 index 0000000000..a0cd08e476 --- /dev/null +++ b/plugins/consent/config.js @@ -0,0 +1,21 @@ +/** + * Consent configuration shared by every documentation page. + * + * Cookiebot owns regional behavior and consent. Google Ads uses Basic Consent + * Mode and is loaded only after marketing consent. Wherever Cookiebot asks + * for statistics consent, PostHog uses normal analytics only after acceptance + * and otherwise runs in its native cookieless mode. Outside the banner + * distribution it uses normal analytics. + */ + +/** Public identifiers; all of these values ship in the page source. */ +const POSTHOG_TOKEN = "phc_GnFGGyhLRvRDKO6iN6eJRAypiKymw9LGf7GlAtZnaKx" // gitleaks:allow — public client key +const COOKIEBOT_CBID = "947be9a7-2d22-4dbf-8964-1b1a954da422" + +const GOOGLE_ADS_ID = "AW-11258045331" + +module.exports = { + POSTHOG_TOKEN, + COOKIEBOT_CBID, + GOOGLE_ADS_ID, +} diff --git a/plugins/consent/index.js b/plugins/consent/index.js new file mode 100644 index 0000000000..9f7a41a077 --- /dev/null +++ b/plugins/consent/index.js @@ -0,0 +1,27 @@ +const path = require("path") +const { COOKIEBOT_CBID } = require("./config") + +module.exports = () => ({ + name: "questdb-consent", + + getClientModules() { + return [path.resolve(__dirname, "client.ts")] + }, + + injectHtmlTags() { + return { + headTags: [ + { + tagName: "script", + attributes: { + id: "Cookiebot", + async: true, + src: "https://consent.cookiebot.com/uc.js", + "data-cbid": COOKIEBOT_CBID, + "data-blockingmode": "manual", + }, + }, + ], + } + }, +}) 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/theme/Footer/index.tsx b/src/theme/Footer/index.tsx index 16bc06b5e0..3a3618d731 100644 --- a/src/theme/Footer/index.tsx +++ b/src/theme/Footer/index.tsx @@ -1,12 +1,6 @@ import customFields from "../../config/customFields" import styles from "./styles.module.css" -type Props = { - href?: string - label: string - to?: string -} - const Footer = () => { return (