From 230d176fbe57167bc0a62084521e7664a50266cd Mon Sep 17 00:00:00 2001 From: avivkeller Date: Fri, 30 Oct 2026 16:13:44 +0100 Subject: [PATCH] chore: ads --- components/CarbonAd/SideBar.jsx | 45 +++++++++ components/CarbonAd/TopBar.jsx | 71 +++++++++++++ components/CarbonAd/index.module.css | 144 +++++++++++++++++++++++++++ components/NavBar.jsx | 37 ++++--- components/SideBar.jsx | 2 + eslint.config.mjs | 4 + hooks/useCarbonAd.mjs | 67 +++++++++++++ scripts/html/doc-kit.config.mjs | 2 + 8 files changed, 356 insertions(+), 16 deletions(-) create mode 100644 components/CarbonAd/SideBar.jsx create mode 100644 components/CarbonAd/TopBar.jsx create mode 100644 components/CarbonAd/index.module.css create mode 100644 hooks/useCarbonAd.mjs diff --git a/components/CarbonAd/SideBar.jsx b/components/CarbonAd/SideBar.jsx new file mode 100644 index 00000000..a58611d2 --- /dev/null +++ b/components/CarbonAd/SideBar.jsx @@ -0,0 +1,45 @@ +import classNames from 'classnames'; + +import useCarbonAd, { adColors } from '../../hooks/useCarbonAd.mjs'; + +import styles from './index.module.css'; + +/** + * Carbon ad card for the docs sidebar. It is rendered inside the sidebar + * island, so it needs no island of its own. The sidebar collapses to a select + * on small screens, where no ad is requested. + */ +export default function SideBarAd() { + const { ad, ref } = useCarbonAd('CWBI6K77', '(min-width: 56rem)'); + + if (!ad) return null; + + return ( + + ); +} diff --git a/components/CarbonAd/TopBar.jsx b/components/CarbonAd/TopBar.jsx new file mode 100644 index 00000000..7ac52050 --- /dev/null +++ b/components/CarbonAd/TopBar.jsx @@ -0,0 +1,71 @@ +import { useState } from 'react'; +import { X } from 'lucide-preact'; + +import useCarbonAd, { adColors } from '../../hooks/useCarbonAd.mjs'; +import withIsland from '../../node_modules/@doc-kit/generator-react/src/html/ui/islands/withIsland.jsx'; + +import styles from './index.module.css'; + +const DISMISSED_KEY = 'top-bar-ad-dismissed'; + +/** @param {{ onClose: () => void }} props */ +const Ad = ({ onClose }) => { + const { ad, ref } = useCarbonAd('CWBI623E'); + + if (!ad) return null; + + return ( + + ); +}; + +/** + * Full-width Carbon ad shown under the navigation bar. Closing it hides it for + * the rest of the visit, without requesting (and so counting) another ad. + */ +const 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); + }} + /> + ); +}; + +export default withIsland(TopBarAd, { name: 'TopBarAd', on: { idle: true } }); diff --git a/components/CarbonAd/index.module.css b/components/CarbonAd/index.module.css new file mode 100644 index 00000000..ff70206b --- /dev/null +++ b/components/CarbonAd/index.module.css @@ -0,0 +1,144 @@ +@reference "../../styles/index.css"; + +/* Colors, copy and images are the advertiser's, as approved, and must not be + changed: each creative sets its palette through `adColors`. */ +.topBar, +.sideBar { + @apply relative + bg-(--ad-bg) + text-(--ad-text) + dark:bg-(--ad-bg-dark) + dark:text-(--ad-text-dark); + + & a { + @apply text-inherit + no-underline!; + } +} + +.badge { + @apply shrink-0 + rounded-md + border + border-current + px-2 + py-0.5 + text-sm + opacity-70 + hover:opacity-100; +} + +/* A slim, single-line bar: the creative centred, the label and close button + tucked away at the end */ +.topBar { + @apply flex + items-center + gap-2 + px-4 + py-1.5 + text-sm + md:px-8; + + & .badge { + @apply rounded + px-1 + py-0 + text-[0.625rem] + leading-4 + tracking-wide + uppercase; + } +} + +.topBarLink { + @apply flex + flex-1 + items-center + justify-center + gap-3; +} + +.logo { + @apply size-6 + shrink-0; +} + +.cta { + @apply hidden + shrink-0 + rounded-full + bg-(--ad-cta-bg) + px-3 + py-0.5 + text-xs + font-semibold + text-(--ad-cta-text) + transition-opacity + hover:opacity-90 + sm:block + dark:bg-(--ad-cta-bg-dark) + dark:text-(--ad-cta-text-dark); +} + +.close { + @apply flex + shrink-0 + cursor-pointer + rounded-full + p-0.5 + opacity-60 + transition-opacity + hover:opacity-100; +} + +.sideBar { + /* Sits under the navigation groups rather than above them */ + @apply order-last + overflow-hidden + rounded-xl; +} + +.sideBarLink { + @apply flex + flex-col; +} + +.image { + @apply aspect-square + w-full + object-cover; +} + +.sideBarBody { + @apply flex + flex-col + items-start + gap-2 + p-4 + text-sm; +} + +.title { + @apply text-base + font-semibold; +} + +.sideBarCta { + @apply mt-2 + font-semibold + rounded-full + border-2 + border-current + px-4 + py-1.5; +} + +.overlay { + @apply absolute + top-3 + right-3 + border-transparent + bg-black/60 + text-white + opacity-100; +} diff --git a/components/NavBar.jsx b/components/NavBar.jsx index 9fa6d3d6..9376a2c8 100644 --- a/components/NavBar.jsx +++ b/components/NavBar.jsx @@ -9,29 +9,34 @@ import Logo from '#theme/Logo'; import SearchBox from '@doc-kit/generator-react/html/ui/components/SearchBox/index.jsx'; import ThemeToggle from '@doc-kit/generator-react/html/ui/components/ThemeToggle.jsx'; +import TopBarAd from '#theme/TopBarAd'; const versionBase = new URL(baseURL).pathname; /** - * NavBar component that displays the headings, search, etc. + * NavBar component that displays the headings, search, etc., followed by the + * top bar ad. */ export default ({ metadata }) => { return ( - - - - + - - - + + + + + + + + ); }; diff --git a/components/SideBar.jsx b/components/SideBar.jsx index d728e754..dd133cb4 100644 --- a/components/SideBar.jsx +++ b/components/SideBar.jsx @@ -5,6 +5,7 @@ import { sidebar } from '#theme/local/site'; import { version } from '#theme/config'; import { toPublicLink } from '../utils/helpers/urls.mjs'; import versions from '../versions.json' with { type: 'json' }; +import SideBarAd from './CarbonAd/SideBar.jsx'; import withIsland from '../node_modules/@doc-kit/generator-react/src/html/ui/islands/withIsland.jsx'; /** @param {string} url */ @@ -53,6 +54,7 @@ const Sidebar = ({ metadata }) => ( /> )} + ); diff --git a/eslint.config.mjs b/eslint.config.mjs index 7cd6ace1..6f02fbfd 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -23,6 +23,10 @@ export default [ globals: globals.browser, }, }, + { + files: ['hooks/**/*.mjs'], + languageOptions: { globals: globals.browser }, + }, { ignores: [ 'node_modules/', diff --git a/hooks/useCarbonAd.mjs b/hooks/useCarbonAd.mjs new file mode 100644 index 00000000..11d5e5df --- /dev/null +++ b/hooks/useCarbonAd.mjs @@ -0,0 +1,67 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * @param {Record} ad + */ +export const adColors = ad => ({ + '--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, +}); + +/** + * @param {string} zone + * @param {string} [media] + */ +export default function useCarbonAd(zone, media) { + const [ad, setAd] = useState(null); + const ref = useRef(null); + + useEffect(() => { + if (media && !window.matchMedia(media).matches) return; + + const controller = new AbortController(); + + fetch(`https://srv.carbonads.net/ads/${zone}.json`, { + signal: controller.signal, + }) + .then(response => (response.ok ? response.json() : null)) + .then(data => 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/scripts/html/doc-kit.config.mjs b/scripts/html/doc-kit.config.mjs index 51cee2b4..2fc10f59 100644 --- a/scripts/html/doc-kit.config.mjs +++ b/scripts/html/doc-kit.config.mjs @@ -104,6 +104,7 @@ export default { '#theme/BlogLayout': join(ROOT, 'layouts/Blog/index.jsx'), '#theme/Blog/Byline': join(ROOT, 'components/Blog/Byline/index.jsx'), '#theme/Navigation': join(ROOT, 'components/NavBar.jsx'), + '#theme/TopBarAd': join(ROOT, 'components/CarbonAd/TopBar.jsx'), '#theme/Footer': join(ROOT, 'components/Footer/index.jsx'), '#theme/Logo': join(ROOT, 'components/Icons/Webpack.jsx'), '#theme/Home/Hero': join(ROOT, 'components/HomePage/Hero/index.jsx'), @@ -130,6 +131,7 @@ export default { BlogLayout: '#theme/BlogLayout', Byline: '#theme/Blog/Byline', MetaBar: '#theme/Metabar', + TopBarAd: '#theme/TopBarAd', StackBlitzPreview: '#theme/StackBlitzPreview', }, bundler: createViteBundler({