Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/shared/src/components/FeedItemComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ import { isSourceSquadOrMachine } from '../graphql/sources';
import { HighlightGrid } from './cards/highlight/HighlightGrid';
import { HighlightList } from './cards/highlight/HighlightList';
import { getHighlightIds, getHighlightIdsKey } from '../graphql/highlights';
import { ExploreAdMockupCard } from '../features/exploreAdMockup/ExploreAdMockupCard';
import {
exploreAdMockupEnabled,
isExploreMockupFeed,
} from '../features/exploreAdMockup/config';

export type FeedItemComponentProps = {
item: FeedItem;
Expand Down Expand Up @@ -481,6 +486,19 @@ function FeedItemComponent({

switch (item.type) {
case FeedItemType.Ad: {
// Campaign mockup: replace every Explore-feed ad slot with a randomized
// advertiser card from the active campaigns (see features/exploreAdMockup).
if (exploreAdMockupEnabled && isExploreMockupFeed(feedName)) {
return (
<ExploreAdMockupCard
ref={inViewRef}
isList={shouldUseListFeedLayout || shouldUseListMode}
feedIndex={index}
onLinkClick={(ad: Ad) => onAdAction(AdActions.Click, ad)}
/>
);
}

const AdComponent = AdTag as React.ForwardRefExoticComponent<
AdCardProps & React.RefAttributes<Element>
>;
Expand Down
169 changes: 169 additions & 0 deletions packages/shared/src/features/exploreAdMockup/ExploreAdMockupCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import type { ReactElement, Ref } from 'react';
import React, { forwardRef, useState } from 'react';
import classNames from 'classnames';
import {
Card,
CardImage,
CardSpace,
CardTextContainer,
CardTitle,
} from '../../components/cards/common/Card';
import {
CardContent,
CardImage as ListCardImage,
CardTextContainer as ListCardTextContainer,
CardTitle as ListCardTitle,
} from '../../components/cards/common/list/ListCard';
import FeedItemContainer from '../../components/cards/common/list/FeedItemContainer';
import AdLink from '../../components/cards/ad/common/AdLink';
import { AdFavicon } from '../../components/cards/ad/common/AdFavicon';
import { AdImage } from '../../components/cards/ad/common/AdImage';
import { AdvertiseLink } from '../../components/cards/ad/common/AdvertiseLink';
import { RemoveAd } from '../../components/cards/ad/common/RemoveAd';
import PostTags from '../../components/cards/common/PostTags';
import { ButtonSize, ButtonVariant } from '../../components/buttons/common';
import { usePlusSubscription } from '../../hooks/usePlusSubscription';
import { combinedClicks } from '../../lib/click';
import { TargetId } from '../../lib/log';
import type { Ad } from '../../graphql/posts';
import type { ResolvedCreative } from './types';
import { buildAd, getCreatives, pickCreative } from './creative';

type ExploreAdMockupCardProps = {
isList?: boolean;
// Feed position, used to spread consecutive slots across different creatives.
feedIndex?: number;
onLinkClick?: (ad: Ad) => unknown;
// Force a specific creative (Storybook / tests). Omit for the randomizer.
creative?: ResolvedCreative;
};

const Attribution = ({
name,
className,
}: {
name: string;
className?: string;
}): ReactElement => (
<div
className={classNames(
'font-normal text-text-quaternary typo-footnote',
className,
)}
>
Promoted by {name}
</div>
);

// The advertiser head card that replaces every ad slot on the Explore feed.
// Structure mirrors the real AdGrid/AdList so it reads as a native ad slot —
// including the "Advertise here" / "Remove" footer and NO CTA button (Carbon /
// EthicalAds feed ads have none; the whole card links to the campaign). It
// additionally always renders the tags and a "Promoted by {advertiser}"
// attribution. Brand + copy come from the resolved creative, randomized per
// mount unless one is passed in.
export const ExploreAdMockupCard = forwardRef<
HTMLElement,
ExploreAdMockupCardProps
>(function ExploreAdMockupCard(
{ isList = false, feedIndex = 0, onLinkClick, creative },
forwardedRef,
): ReactElement | null {
const { isPlus } = usePlusSubscription();
const [base] = useState(() =>
Math.floor(Math.random() * Math.max(getCreatives().length, 1)),
);
const chosen = creative ?? pickCreative(base + feedIndex);

if (!chosen) {
return null;
}

const { campaign, placement } = chosen;
const { brand } = campaign;
const ad = buildAd(chosen);

if (isList) {
return (
<FeedItemContainer
domProps={{}}
ref={forwardedRef as Ref<HTMLElement>}
data-testid="adItem"
linkProps={{
href: ad.link,
target: '_blank',
rel: 'noopener',
title: ad.description,
...combinedClicks(() => onLinkClick?.(ad)),
}}
>
<CardContent>
<ListCardTextContainer className="mr-4 flex-1">
<ListCardTitle className="!mt-0 typo-title3">
<AdFavicon ad={ad} className="mx-0 !mt-0 mb-2" />
{ad.description}
</ListCardTitle>
<PostTags post={{ tags: placement.tags }} />
<Attribution name={brand.name} className="mt-2 block" />
</ListCardTextContainer>
<AdImage ad={ad} ImageComponent={ListCardImage} />
</CardContent>
<div className="z-1 flex items-center pt-2">
<AdvertiseLink
targetId={TargetId.AdCard}
buttonStyle
size={ButtonSize.Small}
/>
<div className="ml-auto">
{!isPlus && (
<RemoveAd
size={ButtonSize.Small}
className="!font-normal typo-footnote"
/>
)}
</div>
</div>
</FeedItemContainer>
);
}

return (
// `min-h-card` matches the grid-card baseline the feed's
// `content-visibility: auto` estimate assumes (`contain-intrinsic-size:
// auto 24rem`). Without it the ad cell's height drifts from the estimate
// and blanks into a hole as content-visibility skips/repaints on scroll.
<Card
data-testid="adItem"
className="min-h-card"
ref={forwardedRef as Ref<HTMLElement>}
>
<AdLink ad={ad} onLinkClick={onLinkClick} />
<AdFavicon ad={ad} className="mx-4" />
<CardTextContainer className="flex-1">
<CardTitle className="typo-title3">{ad.description}</CardTitle>
<CardSpace />
<PostTags post={{ tags: placement.tags }} className="!items-end" />
<Attribution name={brand.name} />
</CardTextContainer>
<AdImage className="mx-1 mb-0" ad={ad} ImageComponent={CardImage} />
<CardTextContainer className="!mx-1 my-1">
<div className="flex items-center">
<AdvertiseLink
targetId={TargetId.AdCard}
buttonStyle
size={ButtonSize.Small}
/>
<div className="ml-auto flex items-center gap-2">
{!isPlus && (
<RemoveAd
variant={ButtonVariant.Tertiary}
size={ButtonSize.Small}
className="!font-normal typo-footnote"
/>
)}
</div>
</div>
</CardTextContainer>
</Card>
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import type { AdvertiserCampaign } from '../../types';
import { googleCloudLogoDataUri } from './logo';
import {
bigQueryImage,
cloudComputeImage,
cloudRunImage,
cloudSqlImage,
geminiImage,
genericGcpImage,
} from './images';

// UTM strings are copied verbatim from the campaign asset tracker
// (EXT - Daily.Dev Asset Tracker.xlsx). Ad-server macros like `%epid!` /
// `{device}` are intentionally left unresolved — that mirrors what the ad
// server receives.
const utm = (campaign: string): string =>
`utm_source=DailyDev&utm_medium=display&utm_campaign=Cloud-SS-DR-GCP-1713658-GCP-DR-NA-US-en-DailyDev-Display-Banner-All-%epid!-%ecid!-${campaign}&utm_content={device}-{adgroupid}-{network}-{targetid}-{loc_physical_ms}-{campaignid}`;

// Google Cloud campaign: 6 products × 2 headline versions from the tracker.
// Add a new advertiser by copying this file's shape into a sibling campaign
// folder and registering it in ../../registry.ts.
export const googleCloudCampaign: AdvertiserCampaign = {
id: 'google-cloud',
brand: {
name: 'Google Cloud',
logo: googleCloudLogoDataUri,
},
placements: [
{
id: 'google-cloud/generic-gcp-v1',
product: 'Generic GCP',
headline: '$300 Free Credits to Build on Google Cloud',
image: genericGcpImage,
landingPage: 'https://cloud.google.com/free',
utm: utm('GenericGCP'),
tags: ['cloud', 'google-cloud', 'devops'],
},
{
id: 'google-cloud/generic-gcp-v2',
product: 'Generic GCP',
headline: '$300 Free Credits for Your Google Cloud Projects',
image: genericGcpImage,
landingPage: 'https://cloud.google.com/free',
utm: utm('GenericGCP'),
tags: ['cloud', 'google-cloud', 'devops'],
},
{
id: 'google-cloud/gemini-agent-platform-v1',
product: 'Gemini Enterprise Agent Platform',
headline: 'Build Agents and Models on One Platform',
image: geminiImage,
landingPage:
'https://cloud.google.com/products/gemini-enterprise-agent-platform',
utm: utm('AIEngineers'),
tags: ['ai', 'agents', 'machine-learning'],
},
{
id: 'google-cloud/gemini-agent-platform-v2',
product: 'Gemini Enterprise Agent Platform',
headline: 'Ship Agents Faster',
image: geminiImage,
landingPage:
'https://cloud.google.com/products/gemini-enterprise-agent-platform',
utm: utm('AIEngineers'),
tags: ['ai', 'agents', 'machine-learning'],
},
{
id: 'google-cloud/cloud-run-v1',
product: 'Cloud Run',
headline: 'Go from Code to Production URL in Seconds',
image: cloudRunImage,
landingPage: 'https://cloud.google.com/run',
utm: utm('Web&Dev-CloudRun'),
tags: ['serverless', 'devops', 'containers'],
},
{
id: 'google-cloud/cloud-run-v2',
product: 'Cloud Run',
headline: 'Host LLMs in Production With On-Demand GPUs',
image: cloudRunImage,
landingPage: 'https://cloud.google.com/run',
utm: utm('Web&Dev-CloudRun'),
tags: ['serverless', 'gpu', 'ai'],
},
{
id: 'google-cloud/cloud-compute-v1',
product: 'Cloud Compute',
headline: 'Custom VMs From 1 to 96 vCPUs With 99.95% Uptime',
image: cloudComputeImage,
landingPage: 'https://cloud.google.com/compute',
utm: utm('Web&Dev-CloudCompute'),
tags: ['cloud', 'infrastructure', 'vm'],
},
{
id: 'google-cloud/cloud-compute-v2',
product: 'Cloud Compute',
headline: 'Save Up to 91% on Cloud Compute With Spot VMs',
image: cloudComputeImage,
landingPage: 'https://cloud.google.com/compute',
utm: utm('Web&Dev-CloudCompute'),
tags: ['cloud', 'infrastructure', 'vm'],
},
{
id: 'google-cloud/cloud-sql-v1',
product: 'Cloud SQL',
headline: 'Fully Managed MySQL, PostgreSQL, and SQL Server',
image: cloudSqlImage,
landingPage: 'https://cloud.google.com/sql',
utm: utm('Web&Dev-CloudSQL'),
tags: ['databases', 'sql', 'postgres'],
},
{
id: 'google-cloud/cloud-sql-v2',
product: 'Cloud SQL',
headline: '99.99% Uptime for MySQL and PostgreSQL Databases',
image: cloudSqlImage,
landingPage: 'https://cloud.google.com/sql',
utm: utm('Web&Dev-CloudSQL'),
tags: ['databases', 'sql', 'postgres'],
},
{
id: 'google-cloud/bigquery-v1',
product: 'BigQuery',
headline: 'Train ML Models With SQL You Already Know',
image: bigQueryImage,
landingPage: 'https://cloud.google.com/bigquery',
utm: utm('AIPractitioners'),
tags: ['data', 'analytics', 'sql'],
},
{
id: 'google-cloud/bigquery-v2',
product: 'BigQuery',
headline: 'Cut Data Warehouse Costs by 54%',
image: bigQueryImage,
landingPage: 'https://cloud.google.com/bigquery',
utm: utm('AIPractitioners'),
tags: ['data', 'analytics', 'sql'],
},
],
};

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Official Google Cloud logo (the four-color cloud mark), sourced from
// techicons.dev / devicon (googlecloud-original). Serialized as a data URI on a
// white square so it reads as a round favicon inside the ProfilePicture used by
// the ad card. Colors are SVG fills, outside the `no-custom-color` rule.
//
// Scaled to 0.84 and centered so the mark isn't clipped by the circular crop.
const logoSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" fill="#ffffff"/><g transform="translate(10.24 10.24) scale(0.84)"><path fill="#ea4535" d="M80.6 40.3h.4l-.2-.2 14-14v-.3c-11.8-10.4-28.1-14-43.2-9.5C36.5 20.8 24.9 32.8 20.7 48c.2-.1.5-.2.8-.2 5.2-3.4 11.4-5.4 17.9-5.4 2.2 0 4.3.2 6.4.6.1-.1.2-.1.3-.1 9-9.9 24.2-11.1 34.6-2.6h-.1z"/><path fill="#557ebf" d="M108.1 47.8c-2.3-8.5-7.1-16.2-13.8-22.1L80 39.9c6 4.9 9.5 12.3 9.3 20v2.5c16.9 0 16.9 25.2 0 25.2H63.9v20h-.1l.1.2h25.4c14.6.1 27.5-9.3 31.8-23.1 4.3-13.8-1-28.8-13-36.9z"/><path fill="#36a852" d="M39 107.9h26.3V87.7H39c-1.9 0-3.7-.4-5.4-1.1l-15.2 14.6v.2c6 4.3 13.2 6.6 20.7 6.6z"/><path fill="#f9bc15" d="M40.2 41.9c-14.9.1-28.1 9.3-32.9 22.8-4.8 13.6 0 28.5 11.8 37.3l15.6-14.9c-8.6-3.7-10.6-14.5-4-20.8 6.6-6.4 17.8-4.4 21.7 3.8L68 55.2C61.4 46.9 51.1 42 40.2 42.1z"/></g></svg>`;

export const googleCloudLogoDataUri = `data:image/svg+xml,${encodeURIComponent(
logoSvg,
)}`;
28 changes: 28 additions & 0 deletions packages/shared/src/features/exploreAdMockup/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { OtherFeedPage } from '../../lib/query';
import { activeCampaigns } from './registry';

// DEMO TOGGLE — when enabled, EVERY ad slot on the Explore feed is replaced
// with a randomized advertiser card drawn from the active campaigns (see
// registry.ts). This is the "filter" that makes every ad on Explore an
// exclusive mockup placement.
//
// NOTE: this is a hardcoded sales-demo switch, not a GrowthBook experiment.
// It is `true` on purpose for the mockup. Set it to `false` to restore normal
// ad serving, and do not ship it enabled to a production audience.
export const exploreAdMockupEnabled = true;

// Brand logo used for the lightweight placeholder ad materialized in the feed
// stream before the card randomizes its own creative at render time.
export const exploreAdMockupPlaceholderLogo =
activeCampaigns[0]?.brand.logo ?? '';

const exploreFeeds = new Set<string>([
OtherFeedPage.Explore,
OtherFeedPage.ExploreLatest,
OtherFeedPage.ExploreDiscussed,
OtherFeedPage.ExploreUpvoted,
OtherFeedPage.ExploreTag,
]);

export const isExploreMockupFeed = (feedName?: string): boolean =>
!!feedName && exploreFeeds.has(feedName);
Loading
Loading