Repository navigation
feat(components): add Carbon Ads top bar and sidebar #8489
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <aside | ||
| ref={ref} | ||
| className="carbon-side" | ||
| style={adColors(ad)} | ||
| aria-label="Advertisement" | ||
| > | ||
| <a | ||
| href={ad.statlink} | ||
| target="_blank" | ||
| rel="sponsored noopener noreferrer" | ||
| className="carbon-side-link" | ||
| > | ||
| <img src={ad.largeImage} alt={ad.company} className="carbon-image" /> | ||
| <span className="carbon-side-body"> | ||
| <strong className="carbon-title">{ad.title}</strong> | ||
| <span>{ad.description}</span> | ||
| <span className="carbon-side-cta">{ad.callToAction}</span> | ||
| </span> | ||
| </a> | ||
| <a href={ad.ad_via_link} className="carbon-badge carbon-overlay"> | ||
| Ad | ||
| </a> | ||
| </aside> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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"); | ||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Move the argument (ad key) to a higher level variable |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (!ad) { | ||||||||||||||||||||||
| return null; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return ( | ||||||||||||||||||||||
| <aside | ||||||||||||||||||||||
| ref={ref} | ||||||||||||||||||||||
| className="carbon-top" | ||||||||||||||||||||||
| style={adColors(ad)} | ||||||||||||||||||||||
| aria-label="Advertisement" | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
| <a | ||||||||||||||||||||||
| href={ad.statlink} | ||||||||||||||||||||||
| target="_blank" | ||||||||||||||||||||||
| rel="sponsored noopener noreferrer" | ||||||||||||||||||||||
| className="carbon-top-link" | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
| <img src={ad.image} alt={ad.company} className="carbon-logo" /> | ||||||||||||||||||||||
| <span>{ad.description}</span> | ||||||||||||||||||||||
| <span className="carbon-cta">{ad.callToAction}</span> | ||||||||||||||||||||||
| </a> | ||||||||||||||||||||||
| <a href={ad.ad_via_link} className="carbon-badge"> | ||||||||||||||||||||||
| Ad | ||||||||||||||||||||||
| </a> | ||||||||||||||||||||||
| <button | ||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||
| onClick={onClose} | ||||||||||||||||||||||
| className="carbon-close" | ||||||||||||||||||||||
| aria-label="Close advertisement" | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
| <CloseIcon | ||||||||||||||||||||||
| width={14} | ||||||||||||||||||||||
| height={14} | ||||||||||||||||||||||
| aria-hidden="true" | ||||||||||||||||||||||
| className="fill-current" | ||||||||||||||||||||||
| /> | ||||||||||||||||||||||
| </button> | ||||||||||||||||||||||
| </aside> | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Ad.propTypes = { | ||||||||||||||||||||||
| onClose: PropTypes.func.isRequired, | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
Comment on lines
+51
to
+60
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||||
| export default function TopBarAd() { | ||||||||||||||||||||||
| // There is no storage while rendering on the server. | ||||||||||||||||||||||
| const [dismissed, setDismissed] = useState( | ||||||||||||||||||||||
| () => globalThis.sessionStorage?.getItem(DISMISSED_KEY) === "true", | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
|
Comment on lines
+63
to
+65
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard the Accessing Proposed fix
Based on learnings: flag storage calls only when nothing handles thrown exceptions. Committable suggestion
Source: Learnings |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (dismissed) { | ||||||||||||||||||||||
| return null; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return ( | ||||||||||||||||||||||
| <Ad | ||||||||||||||||||||||
| onClose={() => { | ||||||||||||||||||||||
| sessionStorage.setItem(DISMISSED_KEY, "true"); | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle a storage failure in the close handler.
Wrap both accesses in Proposed fix
Based on learnings: flag storage calls only when nothing handles thrown exceptions. Committable suggestion
Source: Learnings |
||||||||||||||||||||||
| setDismissed(true); | ||||||||||||||||||||||
| }} | ||||||||||||||||||||||
| /> | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, string>} 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 }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Restrict the top-bar ad to
mdand wider.useCarbonAd("CWBI623E")has nomediaargument. Phone-width visitors therefore request and see the banner. The PR states that ads are requested only atmdand above. Pass"(min-width: 768px)"as the second argument.This also removes the phone-width request and impression. A viewport that narrows after the fetch still shows the banner. Hide it with a CSS
max-md:hiddenclass if that matters.Proposed fix
Committable suggestion