Skip to content
Open
13 changes: 13 additions & 0 deletions packages/shared/src/components/FeedItemComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 ? (
<PreferredSearchCard fallback={<PlaceholderTag />} />
) : (
<PlaceholderTag />
);
}
default:
return <PlaceholderTag />;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/components/post/PostContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export function PostContentRaw({
isBannerVisible,
isPostPage,
getWidgetRailAd,
widgetsLeading,
contentLeading,
renderSummarySegments,
aboveComments,
Expand Down Expand Up @@ -301,6 +302,7 @@ export function PostContentRaw({
origin={origin}
onCopyPostLink={onCopyPostLink}
getRailAd={getWidgetRailAd}
leading={widgetsLeading}
/>
);

Expand Down
6 changes: 6 additions & 0 deletions packages/shared/src/components/post/PostWidgets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -67,6 +68,8 @@ export type PostWidgetsProps = Omit<PostHeaderActionsProps, 'contextMenuId'> &
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. */
Expand Down Expand Up @@ -103,6 +106,7 @@ export function PostWidgets({
hideSignupWidget = false,
hideToc = false,
getRailAd,
leading,
trailing,
hideAdWidget,
}: PostWidgetsProps): ReactElement {
Expand Down Expand Up @@ -163,6 +167,8 @@ export function PostWidgets({

return (
<PageWidgets className={className}>
{leading}
<PreferGoogleSourceAction />
{!hideSignupWidget && <PostSignupWidget />}
{withAd(PostWidgetPosition.Source, sourceCard)}
{withAd(
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/components/post/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Button
{...linkProps}
className={classNames('whitespace-nowrap', className)}
disabled={!useDeeplink && !isReady}
icon={<GoogleIcon secondary />}
onClick={onAdd}
size={size}
variant={variant}
>
{label}
</Button>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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, useDeeplink, 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 (
<div className="w-full">
<PreferGoogleButton
className="w-full"
isReady={isReady}
useDeeplink={useDeeplink}
onAdd={onAdd}
size={ButtonSize.Small}
variant={ButtonVariant.Float}
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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';

/**
* The daily.dev favicon, rebuilt in CSS: the shipped icon is a PNG in each
* app's `public/`, which a shared component cannot reach, and this preview
* needs the real one — Google puts a site's actual favicon beside its result,
* so the bare white logo mark read as neither Google nor daily.dev.
*
* The two colours are sampled from `favicon-32x32.png` and are deliberately
* literals: they mirror an image asset, not a theme token, so they must not
* follow the app's light/dark switch.
*/
const Favicon = (): ReactElement => (
<span
className="flex size-4 shrink-0 items-center justify-center rounded-4"
style={{ background: 'linear-gradient(180deg, #0e1217 0%, #af27dc 100%)' }}
>
<LogoIcon className={{ container: 'size-2.5', group: 'fill-white' }} />
</span>
);

/**
* 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 and costs
* the feed no image request. The G mark is Google's own, unmodified.
*
* Deliberately light in both themes. This is a picture *of Google*, not a piece
* of our UI — themed dark it stopped reading as a search result at all, which
* is the one job it has. Hence the literal colours: Google's own result palette
* rather than our tokens, which would flip with the app.
*/
export const SearchPreview = ({
className,
query = 'cursor agent mode review',
title = 'Cursor agent mode: three weeks in production',
}: {
className?: string;
query?: string;
title?: string;
}): ReactElement => (
<div
className={classNames(
'relative overflow-hidden rounded-12 border',
className,
)}
style={{ background: '#ffffff', borderColor: '#dfe1e5' }}
aria-hidden
>
<div
className="flex items-center gap-2 border-b px-3 py-2"
style={{ borderColor: '#ecedef' }}
>
<GoogleIcon secondary className="size-4 shrink-0" />
<span className="truncate typo-footnote" style={{ color: '#5f6368' }}>
{query}
</span>
</div>
<div className="flex flex-col gap-1.5 px-3 pb-3 pt-2.5">
<div className="flex items-center gap-2">
<Favicon />
<span className="typo-caption1" style={{ color: '#202124' }}>
daily.dev
</span>
<span
className="rounded-6 px-1.5 py-0.5 font-bold typo-caption2"
style={{ background: '#e6f4ea', color: '#137333' }}
>
Preferred
</span>
</div>
<span className="typo-callout" style={{ color: '#1a0dab' }}>
{title}
</span>
<span
className="h-1.5 w-11/12 rounded-4"
style={{ background: '#ecedef' }}
/>
<span
className="h-1.5 w-2/3 rounded-4"
style={{ background: '#ecedef' }}
/>
<div className="opacity-60 mt-2 flex flex-col gap-1.5">
<span
className="h-1.5 w-1/3 rounded-4"
style={{ background: '#ecedef' }}
/>
<span
className="h-1.5 w-10/12 rounded-4"
style={{ background: '#ecedef' }}
/>
</div>
</div>
</div>
);

/**
* 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, useDeeplink, 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 (
<Card className="p-4">
<Header tagColor="cabbage" tagText="Google" onClose={onDismiss} />
<CardTitle className="typo-title3">
See daily.dev in your Google results
</CardTitle>
<SearchPreview className="my-3" />
<PreferGoogleButton
className="mt-auto w-full"
isReady={isReady}
useDeeplink={useDeeplink}
onAdd={onAdd}
size={ButtonSize.Small}
variant={ButtonVariant.Primary}
/>
</Card>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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, hasFailed, addPreferredSource } = useGooglePreferredSource();

return (
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 flex-1 flex-col">
<Typography
color={TypographyColor.Tertiary}
type={TypographyType.Callout}
>
Preferred source on Google
</Typography>
<Typography
color={TypographyColor.Quaternary}
type={TypographyType.Footnote}
>
Show daily.dev more often in Top Stories and AI Overviews.
</Typography>
</div>
<PreferGoogleButton
isReady={isReady}
useDeeplink={hasFailed}
label="Add"
onAdd={addPreferredSource}
size={ButtonSize.Small}
variant={ButtonVariant.Primary}
/>
</div>
);
}
4 changes: 4 additions & 0 deletions packages/shared/src/components/post/preferredSources/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './PreferGoogleButton';
export * from './PreferGoogleSourceAction';
export * from './PreferredSearchCard';
export * from './PreferredSourceSetting';
Loading
Loading