diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index 95d1321a2e8..ce6524321c3 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -17,6 +17,7 @@ import { useFeedLayout } from '../hooks'; import { CollectionList } from './cards/collection/CollectionList'; import { FeedItemType } from './cards/common/common'; import { AdGrid } from './cards/ad/AdGrid'; +import { PreferredSearchCard } from './post/preferredSources'; import { AdList } from './cards/ad/AdList'; import { SignalAdList } from './cards/ad/SignalAdList'; import type { AdCardProps } from './cards/ad/common/common'; @@ -489,6 +490,18 @@ function FeedItemComponent({ /> ); } + case FeedItemType.Placeholder: { + // An ad position the ad server could not fill renders a grey card. + // Offer something of ours in that space instead — never in a slot real + // content would have taken. + const isAdSlot = typeof item.index === 'number'; + + return isAdSlot ? ( + } /> + ) : ( + + ); + } default: return ; } diff --git a/packages/shared/src/components/marketing/banners/HomepageTopBanners.tsx b/packages/shared/src/components/marketing/banners/HomepageTopBanners.tsx index 2741cf351bc..94cae7d0f42 100644 --- a/packages/shared/src/components/marketing/banners/HomepageTopBanners.tsx +++ b/packages/shared/src/components/marketing/banners/HomepageTopBanners.tsx @@ -15,6 +15,10 @@ import { ActionType } from '../../../graphql/actions'; import { useAuthContext } from '../../../contexts/AuthContext'; import { uploadCvBgMobile } from '../../../lib/image'; import { useJobsFeature } from '../../../hooks/useJobsFeature'; +import { usePreferredSource } from '../../../hooks/usePreferredSource'; +import { GoogleIcon } from '../../icons'; +import { IconSize } from '../../Icon'; +import { ButtonVariant } from '../../buttons/common'; const illustrationFrameClass = '!m-0 flex h-24 w-32 shrink-0 items-center justify-center self-center tablet:h-28 tablet:w-36'; @@ -35,6 +39,22 @@ const CvIllustration = (): ReactElement => ( ); +// Deliberately not `illustrationFrameClass`: that frame is wider than it is +// tall (w-32 around a size-24 tile), which leaves 16px of slack on each side. +// Added to the section's own pl-3 that put 28px to the left of the tile against +// 8px above and below it. A square frame removes the slack, and `!pl-2` on the +// section trims the remaining left padding to match `py-2`. +const GoogleIllustration = (): ReactElement => ( +
+ + + +
+); + const CompactReminderCat = (): ReactElement => ( ); @@ -44,6 +64,7 @@ export const useHomepageTopBannersVisibility = ({ }: { enabled?: boolean } = {}): { showReminder: boolean; showCv: boolean; + showPreferredSource: boolean; hasAny: boolean; } => { const { isLoggedIn, isAuthReady } = useAuthContext(); @@ -52,10 +73,18 @@ export const useHomepageTopBannersVisibility = ({ enabled: isEnabled, }); const { shouldShow: shouldShowCv } = useUploadCv(); + const { isEligible: showPreferredSource } = usePreferredSource({ + placement: 'homepage hero', + }); const enabled = isEnabled && isAuthReady && isLoggedIn; const showReminder = enabled && reminder.shouldShow; const showCv = enabled && shouldShowCv; - return { showReminder, showCv, hasAny: showReminder || showCv }; + return { + showReminder, + showCv, + showPreferredSource, + hasAny: showReminder || showCv || showPreferredSource, + }; }; type HomepageTopBannersProps = { @@ -75,6 +104,7 @@ export const HomepageTopBanners = ({ }); const { completeAction } = useActions(); const fileInputRef = useRef(null); + const preferredSource = usePreferredSource({ placement: 'homepage hero' }); if (!isAuthReady || !isLoggedIn) { return null; @@ -99,6 +129,25 @@ export const HomepageTopBanners = ({ ); } + // Last of the three: the reminder and the CV upload are both about the + // reader's own routine, and this one is a favour to us. + if (preferredSource.isEligible) { + cards.push( + } + onCtaClick={preferredSource.onAdd} + onClose={preferredSource.onDismiss} + />, + ); + } + if (shouldShowCv) { cards.push( ) => void; + /** Inline actions under the title, e.g. a one-tap button. */ + actions?: React.ReactNode; } // Real-time popup, laid out like a feed row (NotificationItem): the avatar with @@ -19,6 +21,7 @@ export function InAppNotificationItem({ avatars, targetUrl, onClick, + actions, }: InAppNotificationItemProps): ReactElement | null { const { title: memoizedTitle, isReady } = useObjectPurify({ title }); @@ -42,12 +45,15 @@ export function InAppNotificationItem({ {/* A div (not a

): the sanitized title can itself be wrapped in a

, and a

inside a

is invalid and gets auto-closed by the browser, breaking the layout. */} -

+
+
+ {actions && {actions}} +
); } diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index cc70a88de81..a5f1322d691 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React, { useMemo } from 'react'; import classNames from 'classnames'; import { useRouter } from 'next/router'; @@ -68,6 +68,8 @@ export interface NotificationItemProps | 'hasThanks' > { isUnread?: boolean; + /** Inline actions under the description, e.g. a one-tap button. */ + actions?: ReactNode; targetUrl: string; createdAt?: Date; onClick?: ( @@ -186,6 +188,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { referenceId, hasThanks, createdAt, + actions, } = props; const { @@ -435,6 +438,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null {
)} {!timeAnchor && timeNode &&
{timeNode}
} + {actions && {actions}} {type === NotificationType.UserFollow && ( diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx index cbbb3dcd328..37dfae8a6dd 100644 --- a/packages/shared/src/components/post/PostContent.tsx +++ b/packages/shared/src/components/post/PostContent.tsx @@ -92,6 +92,7 @@ export function PostContentRaw({ isBannerVisible, isPostPage, getWidgetRailAd, + widgetsLeading, contentLeading, renderSummarySegments, aboveComments, @@ -314,6 +315,7 @@ export function PostContentRaw({ origin={origin} onCopyPostLink={onCopyPostLink} getRailAd={getWidgetRailAd} + leading={widgetsLeading} /> ); diff --git a/packages/shared/src/components/post/PostWidgets.tsx b/packages/shared/src/components/post/PostWidgets.tsx index f42748fe9ea..1385685b407 100644 --- a/packages/shared/src/components/post/PostWidgets.tsx +++ b/packages/shared/src/components/post/PostWidgets.tsx @@ -17,6 +17,7 @@ import { PostSidebarAdWidget } from './PostSidebarAdWidget'; import { FeaturedArchives } from '../widgets/FeaturedArchives'; import { MentionedToolsWidget } from '../brand/MentionedToolsWidget'; import { PostSignupWidget } from './PostSignupWidget'; +import { PreferGoogleSourceAction } from './preferredSources'; import { HighlightPostSidebarWidget } from '../cards/highlight/HighlightPostSidebarWidget'; const UserEntityCard = dynamic( @@ -67,6 +68,8 @@ export type PostWidgetsProps = Omit & hideToc?: boolean; /** Renders a slot after the widget at each position. */ getRailAd?: (position: PostWidgetPosition) => ReactNode; + /** Rendered first, above every other widget. */ + leading?: ReactNode; /** Rendered last, below the footer links. */ trailing?: ReactNode; /** Drops the internal sidebar ad — for templates carrying their own. */ @@ -103,6 +106,7 @@ export function PostWidgets({ hideSignupWidget = false, hideToc = false, getRailAd, + leading, trailing, hideAdWidget, }: PostWidgetsProps): ReactElement { @@ -163,6 +167,8 @@ export function PostWidgets({ return ( + {leading} + {!hideSignupWidget && } {withAd(PostWidgetPosition.Source, sourceCard)} {withAd( diff --git a/packages/shared/src/components/post/common.tsx b/packages/shared/src/components/post/common.tsx index 9b9bb526448..6306f86ab30 100644 --- a/packages/shared/src/components/post/common.tsx +++ b/packages/shared/src/components/post/common.tsx @@ -97,6 +97,8 @@ export interface PostContentProps * extensions. */ getWidgetRailAd?: (position: PostWidgetPosition) => ReactNode; + /** Rendered at the very top of the widget column. */ + widgetsLeading?: ReactNode; /** * Replaces the default TLDR paragraph so an ad template can interleave * units between summary segments. Like every ad prop here: only the diff --git a/packages/shared/src/components/post/preferredSources/PreferGoogleButton.tsx b/packages/shared/src/components/post/preferredSources/PreferGoogleButton.tsx new file mode 100644 index 00000000000..bba393cb274 --- /dev/null +++ b/packages/shared/src/components/post/preferredSources/PreferGoogleButton.tsx @@ -0,0 +1,66 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { ButtonProps } from '../../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { GoogleIcon } from '../../icons'; +import { + getPreferredSourceUrl, + DAILY_DEV_DOMAIN, +} from '../../../lib/preferredSources'; + +export type PreferGoogleButtonProps = Pick< + ButtonProps<'button'>, + 'size' | 'variant' +> & { + label?: string; + className?: string; + /** + * Falls back to the deeplink instead of Google's script. Required anywhere + * the script cannot run or has not initialised. + */ + useDeeplink?: boolean; + isReady?: boolean; + onAdd?: () => void; +}; + +/** + * Adds daily.dev to the reader's Google preferred sources. + * + * Google's own button renders in an iframe we cannot theme, so this drives the + * documented JS API from our own `Button` — same outcome, our design system. + * Google sets no wording rule for a custom badge; the binding constraint is the + * G mark itself, which must stay full-colour and unmodified. + */ +export function PreferGoogleButton({ + label = 'Add as preferred source', + size = ButtonSize.Small, + variant = ButtonVariant.Primary, + className, + useDeeplink = false, + isReady = true, + onAdd, +}: PreferGoogleButtonProps): ReactElement { + const linkProps = useDeeplink + ? ({ + tag: 'a', + href: getPreferredSourceUrl(DAILY_DEV_DOMAIN), + target: '_blank', + rel: 'noopener noreferrer', + } as const) + : {}; + + return ( + + ); +} diff --git a/packages/shared/src/components/post/preferredSources/PreferGoogleSourceAction.tsx b/packages/shared/src/components/post/preferredSources/PreferGoogleSourceAction.tsx new file mode 100644 index 00000000000..c9534c7ea63 --- /dev/null +++ b/packages/shared/src/components/post/preferredSources/PreferGoogleSourceAction.tsx @@ -0,0 +1,40 @@ +import type { ReactElement } from 'react'; +import React, { useEffect } from 'react'; +import { ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { usePreferredSource } from '../../../hooks/usePreferredSource'; +import { PreferGoogleButton } from './PreferGoogleButton'; + +/** + * The widget-column ask, at the very top of the rail — above the source card, + * the signup widget and the ad slot. No margin of its own: PageWidgets already + * sets the gap between rail items, and an extra one here would push the whole + * column down. + */ +export function PreferGoogleSourceAction(): ReactElement | null { + const { isEligible, isReady, onAdd, onImpression } = usePreferredSource({ + placement: 'post widgets', + }); + + useEffect(() => { + if (isEligible) { + onImpression(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- once per appearance + }, [isEligible]); + + if (!isEligible) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/packages/shared/src/components/post/preferredSources/PreferredSearchCard.tsx b/packages/shared/src/components/post/preferredSources/PreferredSearchCard.tsx new file mode 100644 index 00000000000..21084adece0 --- /dev/null +++ b/packages/shared/src/components/post/preferredSources/PreferredSearchCard.tsx @@ -0,0 +1,102 @@ +import type { ReactElement } from 'react'; +import React, { useEffect } from 'react'; +import classNames from 'classnames'; +import { Card, CardTitle } from '../../cards/common/Card'; +import { Header } from '../../marketing/cta/common'; +import { ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { GoogleIcon } from '../../icons'; +import LogoIcon from '../../../svg/LogoIcon'; +import { usePreferredSource } from '../../../hooks/usePreferredSource'; +import { PreferGoogleButton } from './PreferGoogleButton'; + +/** + * A glimpse of a Google results page with daily.dev marked Preferred. Drawn in + * CSS rather than shipped as an asset: it stays crisp at any density, follows + * the theme, and costs the feed no image request. The G mark is Google's own, + * unmodified. + */ +export const SearchPreview = ({ + className, + query = 'cursor agent mode review', +}: { + className?: string; + query?: string; +}): ReactElement => ( +
+
+ + + {query} + +
+
+
+ + + + daily.dev + + Preferred + +
+ + Cursor agent mode: three weeks in production + + + +
+ + +
+
+
+
+); + +/** + * Fills a feed ad position the ad server could not fill. It never takes a slot + * from real content — only one that would otherwise render a grey placeholder. + */ +export function PreferredSearchCard({ + fallback = null, +}: { + /** Rendered instead when the prompt is not eligible — e.g. the placeholder. */ + fallback?: ReactElement | null; +} = {}): ReactElement | null { + const { isEligible, isReady, onAdd, onDismiss, onImpression } = + usePreferredSource({ placement: 'feed ad fallback' }); + + useEffect(() => { + if (isEligible) { + onImpression(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- once per appearance + }, [isEligible]); + + if (!isEligible) { + return fallback; + } + + return ( + +
+ + See daily.dev in your Google results + + + + + ); +} diff --git a/packages/shared/src/components/post/preferredSources/PreferredSourceSetting.tsx b/packages/shared/src/components/post/preferredSources/PreferredSourceSetting.tsx new file mode 100644 index 00000000000..aee5735ff18 --- /dev/null +++ b/packages/shared/src/components/post/preferredSources/PreferredSourceSetting.tsx @@ -0,0 +1,50 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { useGooglePreferredSource } from '../../../hooks/useGooglePreferredSource'; +import { PreferGoogleButton } from './PreferGoogleButton'; + +/** + * The permanent home for the ask. + * + * Every other surface goes quiet after one click or one dismissal, which would + * otherwise leave a reader who changed their mind with no way back. This row + * ignores that state and is always available. + * + * A button, not a toggle: Google exposes no way to read whether the reader + * already added us, and a switch would promise a state we cannot show. + */ +export function PreferredSourceSetting(): ReactElement { + const { isReady, addPreferredSource } = useGooglePreferredSource(); + + return ( +
+
+ + Preferred source on Google + + + Show daily.dev more often in Top Stories and AI Overviews. + +
+ +
+ ); +} diff --git a/packages/shared/src/components/post/preferredSources/index.ts b/packages/shared/src/components/post/preferredSources/index.ts new file mode 100644 index 00000000000..5e27fb18ad0 --- /dev/null +++ b/packages/shared/src/components/post/preferredSources/index.ts @@ -0,0 +1,4 @@ +export * from './PreferGoogleButton'; +export * from './PreferGoogleSourceAction'; +export * from './PreferredSearchCard'; +export * from './PreferredSourceSetting'; diff --git a/packages/shared/src/hooks/useGooglePreferredSource.ts b/packages/shared/src/hooks/useGooglePreferredSource.ts new file mode 100644 index 00000000000..1063a5e5992 --- /dev/null +++ b/packages/shared/src/hooks/useGooglePreferredSource.ts @@ -0,0 +1,93 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ThemeMode, useSettingsContext } from '../contexts/SettingsContext'; +import { + PREFERRED_SOURCE_SCRIPT_ID, + PREFERRED_SOURCE_SRC, +} from '../lib/preferredSources'; + +type PreferredSourceTheme = 'light' | 'dark'; + +type PreferredSourceApi = { + init: (options: { theme?: PreferredSourceTheme; lang?: string }) => void; + addPreferredSource: () => void; +}; + +declare global { + // eslint-disable-next-line no-var, vars-on-top + var PREFERRED_SOURCE: Array<(api: PreferredSourceApi) => void> | undefined; +} + +const resolveTheme = (mode: ThemeMode): PreferredSourceTheme => { + if (mode === ThemeMode.Auto) { + return globalThis.matchMedia?.('(prefers-color-scheme: light)').matches + ? 'light' + : 'dark'; + } + + return mode === ThemeMode.Light ? 'light' : 'dark'; +}; + +/** + * Loads Google's publisher script in `manual` mode and hands back a trigger. + * + * Manual mode is not optional for us. The documented drop-in — an empty + * `
` — is scanned once, when the script + * loads, and every post page in the webapp is reached by a client-side route + * change long after that. The auto-scan would find nothing. Manual mode also + * lets us keep our own `Button` instead of the iframe Google renders, which + * cannot inherit our tokens and adds a per-embed layout shift. + * + * The script is fetched on demand rather than from ``, so a post page that + * never shows the widget never pays for it. + */ +export const useGooglePreferredSource = ({ + enabled = true, + lang, +}: { + enabled?: boolean; + lang?: string; +} = {}): { isReady: boolean; addPreferredSource: () => void } => { + const { themeMode } = useSettingsContext(); + const [isReady, setIsReady] = useState(false); + const apiRef = useRef(); + + useEffect(() => { + if (!enabled || typeof window === 'undefined') { + return undefined; + } + + let cancelled = false; + + const onApi = (api: PreferredSourceApi) => { + if (cancelled) { + return; + } + + apiRef.current = api; + api.init({ theme: resolveTheme(themeMode), lang }); + setIsReady(true); + }; + + globalThis.PREFERRED_SOURCE = globalThis.PREFERRED_SOURCE || []; + globalThis.PREFERRED_SOURCE.push(onApi); + + if (!document.getElementById(PREFERRED_SOURCE_SCRIPT_ID)) { + const script = document.createElement('script'); + script.id = PREFERRED_SOURCE_SCRIPT_ID; + script.src = PREFERRED_SOURCE_SRC; + script.async = true; + script.setAttribute('preferred-sources-control', 'manual'); + document.head.appendChild(script); + } + + return () => { + cancelled = true; + }; + }, [enabled, lang, themeMode]); + + const addPreferredSource = useCallback(() => { + apiRef.current?.addPreferredSource(); + }, []); + + return { isReady, addPreferredSource }; +}; diff --git a/packages/shared/src/hooks/usePreferredSource.ts b/packages/shared/src/hooks/usePreferredSource.ts new file mode 100644 index 00000000000..d385b3dfda3 --- /dev/null +++ b/packages/shared/src/hooks/usePreferredSource.ts @@ -0,0 +1,127 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { useConditionalFeature } from './useConditionalFeature'; +import usePersistentContext from './usePersistentContext'; +import { useAuthContext } from '../contexts/AuthContext'; +import { useLogContext } from '../contexts/LogContext'; +import { featurePreferredSource } from '../lib/featureManagement'; +import { LogEvent, TargetType } from '../lib/log'; +import type { PreferredSourceState } from '../lib/preferredSources'; +import { + PREFERRED_SOURCE_FORCE_KEY, + PREFERRED_SOURCE_STATE_KEY, +} from '../lib/preferredSources'; +import { useGooglePreferredSource } from './useGooglePreferredSource'; + +export type UsePreferredSourceProps = { + /** Which surface is asking. Goes out with every event. */ + placement: string; + /** Extra gate on top of the flag and the global state. */ + shouldEvaluate?: boolean; +}; + +export type UsePreferredSource = { + /** The flag is on and the reader has not answered yet. */ + isEligible: boolean; + isReady: boolean; + /** Opens Google's flow, logs the click and silences every other surface. */ + onAdd: () => void; + /** Silences every surface without opening Google. */ + onDismiss: () => void; + onImpression: () => void; +}; + +/** + * The one gate every Preferred Sources surface goes through. + * + * Because Google has no read API, "already added" is our own optimistic state: + * a reader who clicks is treated as done even if they abandon Google's dialog. + * That is the right trade — asking again is worse than counting one non-answer + * as a yes — and it is why the settings row exists as a permanent way back in. + */ +export const usePreferredSource = ({ + placement, + shouldEvaluate = true, +}: UsePreferredSourceProps): UsePreferredSource => { + const router = useRouter(); + // REVIEW AFFORDANCE — remove before merge. The flag is off by default, so a + // Vercel preview would show nothing; `?preferredSource=1` forces the gate on + // so the placements can be reviewed without a GrowthBook rule. + // + // Sticky for the tab, deliberately: the param survives a full page load but + // not client-side navigation, so opening a post from the feed (which is a + // modal over the feed, with no query string of its own) would silently drop + // it and the reviewer would see nothing. sessionStorage carries it across + // every route until the tab closes. + // + // Read in an effect rather than during render: the server has no + // sessionStorage, so reading it inline would make the first client render + // disagree with the server HTML and trip a hydration error. + const [isForced, setIsForced] = useState(false); + const isForcedParam = router?.query?.preferredSource === '1'; + + useEffect(() => { + if (isForcedParam) { + globalThis.sessionStorage?.setItem(PREFERRED_SOURCE_FORCE_KEY, '1'); + setIsForced(true); + return; + } + + setIsForced( + globalThis.sessionStorage?.getItem(PREFERRED_SOURCE_FORCE_KEY) === '1', + ); + }, [isForcedParam]); + const { isAuthReady } = useAuthContext(); + const { logEvent } = useLogContext(); + const [state, setState, isStateLoaded] = + usePersistentContext( + PREFERRED_SOURCE_STATE_KEY, + null, + ); + + // Deliberately not gated on being signed in. Post pages and the feed are + // public, and a reader who arrived from Google — the one person for whom + // this ask is self-interested rather than a favour — is usually signed out. + // Capping is local-storage based, so it works for them too. + const gate = isAuthReady && shouldEvaluate; + const { value: isEnabled } = useConditionalFeature({ + feature: featurePreferredSource, + shouldEvaluate: gate, + }); + + const isEligible = + gate && (!!isEnabled || isForced) && isStateLoaded && !state; + + const { isReady, addPreferredSource } = useGooglePreferredSource({ + enabled: isEligible, + }); + + const onImpression = useCallback(() => { + logEvent({ + event_name: LogEvent.ImpressionPreferredSource, + target_type: TargetType.PreferredSource, + target_id: placement, + }); + }, [logEvent, placement]); + + const onAdd = useCallback(() => { + logEvent({ + event_name: LogEvent.ClickPreferredSource, + target_type: TargetType.PreferredSource, + target_id: placement, + }); + addPreferredSource(); + setState('added'); + }, [addPreferredSource, logEvent, placement, setState]); + + const onDismiss = useCallback(() => { + logEvent({ + event_name: LogEvent.DismissPreferredSource, + target_type: TargetType.PreferredSource, + target_id: placement, + }); + setState('dismissed'); + }, [logEvent, placement, setState]); + + return { isEligible, isReady, onAdd, onDismiss, onImpression }; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 258912c6013..85e0cfc1ea3 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -346,3 +346,8 @@ export const featureCommentFirstAction = new Feature( // Kill switch for the batched GraphQL transport (`graphql/batch.ts`). Off is // the control: the API only accepts batched bodies once its own change ships. export const featureGqlBatching = new Feature('gql_batching', false); + +// Google Preferred Sources. One flag for every surface: the ask is the same +// ask everywhere, and the capping is global, so splitting it per placement +// would let a reader meet it twice after silencing it once. +export const featurePreferredSource = new Feature('preferred_source', false); diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index abe11d9baea..80ed7b225ec 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -115,6 +115,12 @@ export enum LogEvent { EmptyAdsenseSlot = 'empty adsense slot', AdsenseSlotError = 'adsense slot error', AdsenseTestMode = 'adsense test mode', + // Google Preferred Sources. Google reports nothing back — no read API, and + // no Search Console dimension — so these two events are the only measurement + // this feature will ever have. + ImpressionPreferredSource = 'impression preferred source', + ClickPreferredSource = 'click preferred source', + DismissPreferredSource = 'dismiss preferred source', OpenSmartComposer = 'open smart composer', CloseSmartComposer = 'close smart composer', SubmitSmartComposer = 'submit smart composer', @@ -544,6 +550,7 @@ export enum TargetType { PromotionalBanner = 'promotion_banner', MarketingCtaPopover = 'promotion_popover', MarketingCtaPopoverSmall = 'promotion_popover_small', + PreferredSource = 'preferred source', MarketingCtaPlus = 'promotion_plus', MarketingCtaBrief = 'promotion_briefing', MarketingCtaHelpGuide = 'promotion_help_guide', diff --git a/packages/shared/src/lib/preferredSources.ts b/packages/shared/src/lib/preferredSources.ts new file mode 100644 index 00000000000..73df7fbeb70 --- /dev/null +++ b/packages/shared/src/lib/preferredSources.ts @@ -0,0 +1,83 @@ +/** + * Google Preferred Sources — https://developers.google.com/search/docs/appearance/preferred-sources + * + * A reader can mark a site as "preferred" and Google then surfaces it more often + * in Top Stories, AI Overviews and AI Mode. Two integration routes exist, and the + * difference between them decides everything about where we can put this: + * + * - The official JS button adds *the domain that hosts the button*. On daily.dev + * that is always daily.dev, never the publisher whose article the reader is on. + * - The deeplink takes a `q` param, so it can target *any* domain — including the + * source of the post being read. + * + * Only domain and subdomain level sites are eligible; a path like example.com/blog + * is not, which is why `normalizePreferredSourceDomain` rejects anything with one. + */ + +export const PREFERRED_SOURCE_SCRIPT_ID = 'google-preferred-source'; +export const PREFERRED_SOURCE_SRC = + 'https://news.google.com/swg/js/v1/publisher.js'; +export const PREFERRED_SOURCE_DEEPLINK = + 'https://www.google.com/preferences/source'; + +export const DAILY_DEV_DOMAIN = 'daily.dev'; + +/** + * Reduces whatever the API handed us — a bare host, a full URL, a host with + * `www.` — to the host Google expects, or null when the value can never be + * eligible (empty, path-bearing, or not a hostname at all). + */ +export const normalizePreferredSourceDomain = ( + value?: string, +): string | null => { + if (!value) { + return null; + } + + const trimmed = value.trim().toLowerCase(); + + if (!trimmed) { + return null; + } + + let host = trimmed; + + if (host.includes('://')) { + try { + host = new URL(host).hostname; + } catch { + return null; + } + } else if (host.includes('/')) { + // A bare host with a path is a subdirectory, which Google does not accept. + return null; + } + + host = host.replace(/^www\./, ''); + + // Hostname, not an IP or a single label: at least one dot, no spaces, and a + // TLD of two or more letters. + if (!/^[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}$/.test(host)) { + return null; + } + + return host; +}; + +export const getPreferredSourceUrl = (domain: string): string => + `${PREFERRED_SOURCE_DEEPLINK}?q=${encodeURIComponent(domain)}`; + +/** + * One key for every surface. Google exposes no way to read whether a reader + * already added us, so this is the only "done" signal we will ever have: it is + * written optimistically on click, and it silences every prompt at once. + */ +export const PREFERRED_SOURCE_STATE_KEY = 'preferred_source_state'; + +/** REVIEW AFFORDANCE — remove before merge. See usePreferredSource. */ +export const PREFERRED_SOURCE_FORCE_KEY = 'preferred_source_force'; + +export type PreferredSourceState = 'added' | 'dismissed'; + +/** Identifies our own notification / quest so the frontend can decorate it. */ +export const PREFERRED_SOURCE_REFERENCE_ID = 'google_preferred_source'; diff --git a/packages/storybook/.storybook/preview.tsx b/packages/storybook/.storybook/preview.tsx index 267f120534f..1bdff80f362 100644 --- a/packages/storybook/.storybook/preview.tsx +++ b/packages/storybook/.storybook/preview.tsx @@ -19,6 +19,7 @@ const preview: Preview = { 'Components', 'Pages', 'Open Graph', + 'Preferred Sources', 'Experiments', 'Extension', ], diff --git a/packages/storybook/stories/open-graph/ogStoryLayout.tsx b/packages/storybook/stories/open-graph/ogStoryLayout.tsx index 71405b036eb..1601891abc6 100644 --- a/packages/storybook/stories/open-graph/ogStoryLayout.tsx +++ b/packages/storybook/stories/open-graph/ogStoryLayout.tsx @@ -178,7 +178,9 @@ export const Bullets = ({ {mark} - {item} + + {item} + ))} diff --git a/packages/storybook/stories/preferred-sources/Measurement.stories.tsx b/packages/storybook/stories/preferred-sources/Measurement.stories.tsx new file mode 100644 index 00000000000..e9ff653c103 --- /dev/null +++ b/packages/storybook/stories/preferred-sources/Measurement.stories.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Bullets, + CodeBlock, + Divider, + Heading, + Muted, + Page, + PageHeader, + SpecTable, +} from '../open-graph/ogStoryLayout'; + +const Measurement = (): React.ReactElement => ( + + + Worth being blunt before anyone builds a business case on this: Google + publishes almost nothing that a publisher can act on, and the one headline + number it does publish is unfalsifiable as stated. + + + The public numbers, and what each is worth + + + So the honest framing for whichever option we ship: this is a goodwill and + positioning bet with a plausible traffic upside, not a measurable traffic + channel. If we present it internally as the latter, we will be asked for + numbers that do not exist. + + + + + What we can measure ourselves + + Our own click is the only number anyone will have, which makes + instrumenting it non-optional. Both components take an{' '} + onClick/onAdd callback for exactly this. + + {`// LogEvent additions +ClickPreferredSource = 'click preferred source', +ImpressionPreferredSource = 'impression preferred source', + +// extra: { target: 'dailydev' | 'source', placement, copy, domain? } +// placement: 'post_page' | 'new_tab' | 'sidebar' | 'settings' | 'footer' | ...`} + + + + + Three ways this goes wrong + + + The one that is not a UI change + + Google has now publicly endorsed the idea daily.dev has been arguing since + day one: readers should choose their sources, rather than have an + algorithm choose for them. Google shipped a preferences panel buried in + search settings; we shipped a whole product. That comparison writes + itself, and it is the cheapest and highest-ceiling thing on this page — an + email, a blog post, and a campaign, with no engineering dependency at all. + + +); + +const meta: Meta = { + title: 'Preferred Sources/5. Measurement & Risks', + component: Measurement, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Default: StoryObj = { name: 'Measurement' }; diff --git a/packages/storybook/stories/preferred-sources/Mechanism.stories.tsx b/packages/storybook/stories/preferred-sources/Mechanism.stories.tsx new file mode 100644 index 00000000000..acc76a99128 --- /dev/null +++ b/packages/storybook/stories/preferred-sources/Mechanism.stories.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Bullets, + CodeBlock, + Divider, + Heading, + Muted, + Page, + PageHeader, + SpecTable, +} from '../open-graph/ogStoryLayout'; + +const Mechanism = (): React.ReactElement => ( + + + Everything below is from Google’s Search Central documentation for + publishers, checked against how our webapp is actually put together. The + gotchas are the reason we cannot use the copy-paste snippet Google leads + with. + + + + The official button — adds the hosting domain + + + Google’s headline integration is two lines. It renders a localised, + Google-styled button that adds the current site and returns the reader to + the page they were on. + + {` + + + +
`}
+ + data-theme takes light (default) or{' '} + dark; data-lang overrides the auto-detected + language. There is no attribute for which domain to add — the + script reads the origin it is running on. + + + The deeplink — adds any domain + + A plain URL into Google’s source preferences tool. No script, no iframe, + no third-party bytes, and the q parameter takes any eligible + host. + + {`https://www.google.com/preferences/source?q=towardsdatascience.com`} + + This is the only route that can express “prefer the publisher of this + post” from a daily.dev page. It costs a tab switch instead of an in-page + confirmation — the one real downside versus the script. + + + + + The four gotchas + + script has run. An empty
mounted by React is never scanned, so nothing renders.', + 'Load with preferred-sources-control="manual" and drive it from the PREFERRED_SOURCE callback queue, calling addPreferredSource() from our own click handler.', + ], + [ + 'The rendered button is an iframe — one per embed', + 'It cannot inherit our tokens, typography or radii, it will not match the buttons beside it, and it reserves no space until it loads, so it shifts the sidebar.', + 'Use the advanced JS API with our own