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 : ( + + )} );