From 7b4b2a4fd66c297669e5c0562e04ac2f36d3f433 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Tue, 10 Nov 2026 10:57:30 -0500 Subject: [PATCH] feat(components): add Carbon Ads top bar and sidebar Show Carbon Ads on documentation pages: a dismissible banner above the page content and a card at the bottom of the sidebar (desktop only). Ads are fetched from Carbon's JSON API, and views are only beaconed once at least half of the ad has been visible for a second. Assisted-by: Cursor --- cspell.json | 3 + eslint.config.mjs | 1 + src/components/CarbonAd/SideBar.jsx | 40 +++++++++ src/components/CarbonAd/TopBar.jsx | 79 ++++++++++++++++++ src/components/CarbonAd/carbon-ad.css | 106 ++++++++++++++++++++++++ src/components/CarbonAd/useCarbonAd.mjs | 92 ++++++++++++++++++++ src/components/Page/Page.jsx | 2 + src/components/Sidebar/Sidebar.jsx | 4 + 8 files changed, 327 insertions(+) create mode 100644 src/components/CarbonAd/SideBar.jsx create mode 100644 src/components/CarbonAd/TopBar.jsx create mode 100644 src/components/CarbonAd/carbon-ad.css create mode 100644 src/components/CarbonAd/useCarbonAd.mjs diff --git a/cspell.json b/cspell.json index b1a00cd33785..ed506a4d358b 100644 --- a/cspell.json +++ b/cspell.json @@ -79,6 +79,7 @@ "creativecommons", "cssmodules", "customise", + "CWBI", "deamdify", "debugids", "dedupDepth", @@ -192,6 +193,8 @@ "spdy", "sponsorkit", "ssgrun", + "statlink", + "statview", "styl", "subchunks", "Subdirs", diff --git a/eslint.config.mjs b/eslint.config.mjs index cbf86c880be0..f7b6c65e1d2d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,6 +28,7 @@ export default defineConfig([ extends: [configs["browser/recommended-outdated-module"]], files: [ "src/components/**/*.{js,jsx}", + "src/components/CarbonAd/useCarbonAd.mjs", "src/utilities/test-local-storage.js", "src/*.jsx", "src/sw.js", diff --git a/src/components/CarbonAd/SideBar.jsx b/src/components/CarbonAd/SideBar.jsx new file mode 100644 index 000000000000..91143fac852b --- /dev/null +++ b/src/components/CarbonAd/SideBar.jsx @@ -0,0 +1,40 @@ +import useCarbonAd, { adColors } from "./useCarbonAd.mjs"; +import "./carbon-ad.css"; + +/** + * Carbon ad card at the bottom of the docs sidebar. The sidebar is hidden + * below the `md` breakpoint, and the ad is neither shown nor requested there. + */ +export default function SideBarAd() { + const { ad, ref } = useCarbonAd("CWBI6K77", "(min-width: 768px)"); + + if (!ad) { + return null; + } + + return ( + + ); +} diff --git a/src/components/CarbonAd/TopBar.jsx b/src/components/CarbonAd/TopBar.jsx new file mode 100644 index 000000000000..4979e231867f --- /dev/null +++ b/src/components/CarbonAd/TopBar.jsx @@ -0,0 +1,79 @@ +import PropTypes from "prop-types"; +import { useState } from "react"; +import CloseIcon from "../../styles/icons/cross.svg"; +import useCarbonAd, { adColors } from "./useCarbonAd.mjs"; +import "./carbon-ad.css"; + +const DISMISSED_KEY = "top-bar-ad-dismissed"; + +/** + * Carbon banner above the page content. Closing it hides the banner for the + * rest of the visit and does not request another ad. + */ +function Ad({ onClose }) { + const { ad, ref } = useCarbonAd("CWBI623E"); + + if (!ad) { + return null; + } + + return ( + + ); +} + +Ad.propTypes = { + onClose: PropTypes.func.isRequired, +}; + +export default function TopBarAd() { + // There is no storage while rendering on the server. + const [dismissed, setDismissed] = useState( + () => globalThis.sessionStorage?.getItem(DISMISSED_KEY) === "true", + ); + + if (dismissed) { + return null; + } + + return ( + { + sessionStorage.setItem(DISMISSED_KEY, "true"); + setDismissed(true); + }} + /> + ); +} diff --git a/src/components/CarbonAd/carbon-ad.css b/src/components/CarbonAd/carbon-ad.css new file mode 100644 index 000000000000..2c1b805f2e33 --- /dev/null +++ b/src/components/CarbonAd/carbon-ad.css @@ -0,0 +1,106 @@ +@reference "../../styles/tailwind.css"; + +/* Colors, copy and images belong to the approved creative. Each ad sets its + palette through the custom properties from `adColors`. These names must stay + global: CSS modules would rename them and they would no longer match. */ +.carbon-top, +.carbon-side { + @apply relative + bg-(--ad-bg) + text-(color:--ad-text) + print:hidden + dark:bg-(--ad-bg-dark) + dark:text-(color:--ad-text-dark); + + & a { + @apply text-inherit no-underline; + } +} + +.carbon-badge { + @apply shrink-0 + rounded-sm + border + border-current + px-1.5 + text-xs + leading-4 + opacity-70 + hover:opacity-100; +} + +.carbon-top { + @apply mb-4 flex items-center gap-3 rounded-md px-4 py-2 text-sm lg:min-h-14; +} + +.carbon-top-link { + @apply flex min-w-0 flex-1 items-center gap-3; +} + +.carbon-logo { + @apply size-8 shrink-0 rounded-sm; +} + +.carbon-cta { + @apply hidden + shrink-0 + rounded-md + bg-(--ad-cta-bg) + px-3 + py-1 + text-xs + font-semibold + whitespace-nowrap + text-(color:--ad-cta-text) + sm:block + dark:bg-(--ad-cta-bg-dark) + dark:text-(color:--ad-cta-text-dark); +} + +.carbon-close { + @apply flex + shrink-0 + cursor-pointer + items-center + rounded-full + border-none + bg-transparent + p-0.5 + text-inherit + opacity-60 + transition-opacity + hover:opacity-100; +} + +.carbon-side { + @apply mt-6 w-full overflow-hidden rounded-lg; +} + +.carbon-side-link { + @apply flex flex-col; +} + +.carbon-image { + @apply aspect-square w-full object-cover; +} + +.carbon-side-body { + @apply flex flex-col items-start gap-1 p-3.5 text-xs; +} + +.carbon-title { + @apply font-semibold; +} + +.carbon-side-cta { + @apply mt-2 rounded-full border border-current px-3 py-1 font-semibold; +} + +.carbon-overlay { + @apply absolute + top-2 + right-2 + border-transparent + bg-neutral-950/50 + text-white; +} diff --git a/src/components/CarbonAd/useCarbonAd.mjs b/src/components/CarbonAd/useCarbonAd.mjs new file mode 100644 index 000000000000..2eaf07c9ad5b --- /dev/null +++ b/src/components/CarbonAd/useCarbonAd.mjs @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Advertiser colors are part of the approved creative and are applied as + * custom properties. Dark values fall back to the light ones. + * + * @param {Record} ad + * @returns {import("react").CSSProperties} + */ +export function adColors(ad) { + return { + "--ad-bg": ad.backgroundColor, + "--ad-bg-dark": ad.backgroundColorDark ?? ad.backgroundColor, + "--ad-text": ad.textColor, + "--ad-text-dark": ad.textColorDark ?? ad.textColor, + "--ad-cta-bg": ad.ctaBackgroundColor, + "--ad-cta-bg-dark": ad.ctaBackgroundColorDark ?? ad.ctaBackgroundColor, + "--ad-cta-text": ad.ctaTextColor, + "--ad-cta-text-dark": ad.ctaTextColorDark ?? ad.ctaTextColor, + }; +} + +/** + * Request one Carbon ad and send its view beacon once at least half of the + * ad has been visible for one continuous second. + * + * A `media` query that does not match skips the request entirely. The hook + * fetches once per mount, so callers remount it when the page changes. + * + * @param {string} zone + * @param {string} [media] + */ +export default function useCarbonAd(zone, media) { + const [ad, setAd] = useState(null); + const ref = useRef(null); + + useEffect(() => { + if (media && globalThis.matchMedia?.(media).matches !== true) { + return; + } + + if (typeof globalThis.fetch !== "function") { + return; + } + + const controller = new AbortController(); + + globalThis + .fetch(`https://srv.carbonads.net/ads/${zone}.json`, { + signal: controller.signal, + }) + .then((response) => (response.ok ? response.json() : null)) + .then((data) => { + if (data?.ads?.[0]?.statlink) { + setAd(data.ads[0]); + } + }) + .catch(() => {}); + + return () => controller.abort(); + }, [zone, media]); + + useEffect(() => { + if (!ad?.statview || !ref.current) { + return; + } + + let timer; + const observer = new IntersectionObserver( + (entries) => { + clearTimeout(timer); + + if (entries.at(-1).intersectionRatio >= 0.5) { + timer = setTimeout(() => { + new Image().src = ad.statview; + observer.disconnect(); + }, 1000); + } + }, + { threshold: 0.5 }, + ); + + observer.observe(ref.current); + + return () => { + clearTimeout(timer); + observer.disconnect(); + }; + }, [ad]); + + return { ad, ref }; +} diff --git a/src/components/Page/Page.jsx b/src/components/Page/Page.jsx index c73ff2248cc2..0b58db565032 100644 --- a/src/components/Page/Page.jsx +++ b/src/components/Page/Page.jsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { useLocation } from "react-router"; // Import Components +import TopBarAd from "../CarbonAd/TopBar.jsx"; import Contributors from "../Contributors/Contributors.jsx"; import Link from "../Link/Link.jsx"; import Markdown from "../Markdown/Markdown.jsx"; @@ -120,6 +121,7 @@ export default function Page(props) { id="main-content" className="flex-auto relative overflow-x-hidden py-[1.5em] px-[1em] md:flex-[3] md:p-[1.5em]" > + {pathname.includes("/printable") ? null : }

{title}

{rest.date && pathname.startsWith("/blog/") && !isBlogIndex && ( diff --git a/src/components/Sidebar/Sidebar.jsx b/src/components/Sidebar/Sidebar.jsx index 5bd75b6591a6..c7e1a5dab83a 100644 --- a/src/components/Sidebar/Sidebar.jsx +++ b/src/components/Sidebar/Sidebar.jsx @@ -3,6 +3,7 @@ import PropTypes from "prop-types"; import { useEffect, useState } from "react"; import DownIcon from "../../styles/icons/chevron-down.svg"; import LoadingIcon from "../../styles/icons/loading.svg"; +import SideBarAd from "../CarbonAd/SideBar.jsx"; import Print from "../Print/Print.jsx"; import SidebarItem from "../SidebarItem/SidebarItem.jsx"; @@ -89,6 +90,9 @@ export default function Sidebar({ className = "", pages, currentPage }) { ); })} + {currentPage?.includes("/printable") ? null : ( + + )} );