Skip to content
Merged
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
71 changes: 71 additions & 0 deletions packages/shared/src/components/cards/ad/AdCard.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@ import type { AdCardProps } from './common/common';
import { TestBootProvider } from '../../../../__tests__/helpers/boot';
import { ActiveFeedContext } from '../../../contexts';
import { businessWebsiteUrl } from '../../../lib/constants';
import { useFeature } from '../../GrowthBookProvider';
import { AdLabelVariant, featureAdLabel } from '../../../lib/featureManagement';

jest.mock('../../GrowthBookProvider', () => ({
...(jest.requireActual('../../GrowthBookProvider') as Record<
string,
unknown
>),
useFeature: jest.fn(),
}));

const mockUseFeature = jest.mocked(useFeature);

const mockAdLabelVariant = (variant: AdLabelVariant): void => {
mockUseFeature.mockImplementation(((feature: { id: string }) =>
feature?.id === featureAdLabel.id ? variant : undefined) as never);
};

const defaultProps: AdCardProps = {
ad,
Expand All @@ -21,6 +38,8 @@ const defaultProps: AdCardProps = {

beforeEach(() => {
jest.clearAllMocks();
// clearAllMocks keeps implementations, so reset the arm for every test.
mockAdLabelVariant(AdLabelVariant.Control);
});

const renderListComponent = (
Expand Down Expand Up @@ -232,6 +251,58 @@ it('should render Promoted attribution in signal variant', async () => {
expect(await screen.findByText(promotedMatcher)).toBeInTheDocument();
});

const adLabelMatcher = (_: string, element?: Element | null): boolean =>
getNormalizedText(element) === 'Ad';

describe('ad_label experiment', () => {
const referralAd = { ...ad, referralLink: 'https://example.com/referral' };

it('should replace the advertiser attribution with "Ad" on the grid card', async () => {
mockAdLabelVariant(AdLabelVariant.Ad);
renderGridComponent({ ad: referralAd });

expect(await screen.findByText(adLabelMatcher)).toBeInTheDocument();
expect(screen.queryByText(promotedMatcher)).not.toBeInTheDocument();
});

it('should drop the advertiser referral link with the "Ad" label', async () => {
mockAdLabelVariant(AdLabelVariant.Ad);
renderListComponent({ ad: referralAd });

const attribution = await screen.findByText(adLabelMatcher);
expect(attribution.closest('a')).toBeNull();
});

it('should keep the advertise link on the ad arm', async () => {
mockAdLabelVariant(AdLabelVariant.Ad);
renderGridComponent({ ad: referralAd });

expect(
await screen.findByRole('link', { name: 'Advertise here' }),
).toBeInTheDocument();
});

it('should remove the advertise link on the ad_only arm', async () => {
mockAdLabelVariant(AdLabelVariant.AdOnly);
renderListComponent({ ad: referralAd });

await screen.findByText(adLabelMatcher);
expect(
screen.queryByRole('link', { name: 'Advertise here' }),
).not.toBeInTheDocument();
});

it('should keep the control wording when the flag is off', async () => {
mockAdLabelVariant(AdLabelVariant.Control);
renderGridComponent({ ad: referralAd });

expect(await screen.findByText(promotedMatcher)).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'Advertise here' }),
).toBeInTheDocument();
});
});

it('should render advertise link on list ad', () => {
renderListComponent();

Expand Down
14 changes: 9 additions & 5 deletions packages/shared/src/components/cards/ad/AdGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { adImprovementsV3Feature } from '../../../lib/featureManagement';
import { TargetId } from '../../../lib/log';
import { AdvertiseLink } from './common/AdvertiseLink';
import { useFeedCardGlassActions } from '../../../hooks/useFeedCardGlassActions';
import { useAdLabel } from '../../../features/monetization/useAdLabel';

export const AdGrid = forwardRef<HTMLElement, AdCardProps>(function AdGrid(
{ ad, onLinkClick, domProps, index, feedIndex },
Expand All @@ -37,6 +38,7 @@ export const AdGrid = forwardRef<HTMLElement, AdCardProps>(function AdGrid(
const { isPlus } = usePlusSubscription();
const adImprovementsV3 = useFeature(adImprovementsV3Feature);
const useGlass = useFeedCardGlassActions();
const { showAdvertiseLink } = useAdLabel();
const { ref } = useAutoRotatingAds(
ad,
index,
Expand Down Expand Up @@ -80,11 +82,13 @@ export const AdGrid = forwardRef<HTMLElement, AdCardProps>(function AdGrid(
{ad.callToAction}
</Button>
)}
<AdvertiseLink
targetId={TargetId.AdCard}
buttonStyle
size={ButtonSize.Small}
/>
{showAdvertiseLink && (
<AdvertiseLink
targetId={TargetId.AdCard}
buttonStyle
size={ButtonSize.Small}
/>
)}
<div className="ml-auto flex items-center gap-2">
{!isPlus && (
<RemoveAd
Expand Down
14 changes: 9 additions & 5 deletions packages/shared/src/components/cards/ad/AdList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { useFeature } from '../../GrowthBookProvider';
import { adImprovementsV3Feature } from '../../../lib/featureManagement';
import { TargetId } from '../../../lib/log';
import { AdvertiseLink } from './common/AdvertiseLink';
import { useAdLabel } from '../../../features/monetization/useAdLabel';

const getLinkProps = ({
ad,
Expand All @@ -53,6 +54,7 @@ export const AdList = forwardRef<HTMLElement, AdCardProps>(function AdCard(
): ReactElement {
const { isPlus } = usePlusSubscription();
const adImprovementsV3 = useFeature(adImprovementsV3Feature);
const { showAdvertiseLink } = useAdLabel();
const { ref } = useAutoRotatingAds(
ad,
index,
Expand Down Expand Up @@ -104,11 +106,13 @@ export const AdList = forwardRef<HTMLElement, AdCardProps>(function AdCard(
{ad.callToAction}
</Button>
)}
<AdvertiseLink
targetId={TargetId.AdCard}
buttonStyle
size={ButtonSize.Small}
/>
{showAdvertiseLink && (
<AdvertiseLink
targetId={TargetId.AdCard}
buttonStyle
size={ButtonSize.Small}
/>
)}
<div className="ml-auto">
{!isPlus && (
<RemoveAd
Expand Down
10 changes: 7 additions & 3 deletions packages/shared/src/components/cards/ad/common/AdAttribution.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ReactElement } from 'react';
import classNames from 'classnames';
import type { Ad } from '../../../../graphql/posts';
import { useScrambler } from '../../../../hooks/useScrambler';
import { useAdLabel } from '../../../../features/monetization/useAdLabel';

interface AdClassName {
main?: string;
Expand All @@ -18,16 +19,19 @@ export default function AdAttribution({
ad,
className,
}: AdAttributionProps): ReactElement {
const { hideAdvertiser } = useAdLabel();
const elementClass = classNames(
'text-text-quaternary no-underline',
className?.typo ?? 'typo-footnote',
className?.main,
);

const text = ad.referralLink ? `Promoted by ${ad.source}` : 'Promoted';
const promotedText = useScrambler(text);
const controlText = ad.referralLink ? `Promoted by ${ad.source}` : 'Promoted';
const promotedText = useScrambler(hideAdvertiser ? 'Ad' : controlText);

if (ad.referralLink) {
// The referral link points at the advertiser, so it only ships with the
// control wording that already names them.
if (ad.referralLink && !hideAdvertiser) {
return (
<a
href={ad.referralLink}
Expand Down
17 changes: 17 additions & 0 deletions packages/shared/src/features/monetization/useAdLabel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useFeature } from '../../components/GrowthBookProvider';
import { AdLabelVariant, featureAdLabel } from '../../lib/featureManagement';

interface UseAdLabel {
/** Treatments disclose with a plain "Ad" instead of naming the advertiser. */
hideAdvertiser: boolean;
showAdvertiseLink: boolean;
}

export const useAdLabel = (): UseAdLabel => {
const variant = useFeature(featureAdLabel);

return {
hideAdvertiser: !!variant && variant !== AdLabelVariant.Control,
showAdvertiseLink: variant !== AdLabelVariant.AdOnly,
};
};
15 changes: 15 additions & 0 deletions packages/shared/src/lib/featureManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,21 @@ export const boostSettingsFeature = new Feature('boost_settings', {

export const adImprovementsV3Feature = new Feature('ad_improvements_v3', false);

// Experiment: ad disclosure wording. Control names the advertiser ("Promoted by
// Vercel"); the treatments drop the advertiser and disclose with a plain "Ad",
// with the strictest arm also removing the "Advertise here" self-promo so the
// card carries a single disclosure. Measured on ad CTR (see `adLogEvent`).
// Default MUST stay Control — GrowthBook ramps the arms.
export enum AdLabelVariant {
Control = 'control',
Ad = 'ad',
AdOnly = 'ad_only',
}
export const featureAdLabel = new Feature<AdLabelVariant>(
'ad_label',
AdLabelVariant.Control,
);

export const featureYearInReview = new Feature('year_in_review_2025', false);

export const featureProfileCompletionIndicator = new Feature(
Expand Down
15 changes: 15 additions & 0 deletions packages/storybook/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ const config: StorybookConfig = {
__dirname,
'../mock/GrowthBookProvider.tsx',
);
const ConditionalFeatureMockPath = path.resolve(
__dirname,
'../mock/hooks/useConditionalFeature.ts',
);

return mergeConfig(config, {
core: {
Expand All @@ -45,7 +49,18 @@ const config: StorybookConfig = {
),
'next/router': path.resolve(__dirname, '../mock/next-router.ts'),
'./GrowthBookProvider': GrowthBookMockPath,
'../GrowthBookProvider': GrowthBookMockPath,
'../../GrowthBookProvider': GrowthBookMockPath,
'../../../GrowthBookProvider': GrowthBookMockPath,
'../components/GrowthBookProvider': GrowthBookMockPath,
'../../components/GrowthBookProvider': GrowthBookMockPath,
'../../../components/GrowthBookProvider': GrowthBookMockPath,
'./useConditionalFeature': ConditionalFeatureMockPath,
'../useConditionalFeature': ConditionalFeatureMockPath,
'../hooks/useConditionalFeature': ConditionalFeatureMockPath,
'../../hooks/useConditionalFeature': ConditionalFeatureMockPath,
'../../../hooks/useConditionalFeature': ConditionalFeatureMockPath,
'../../../../hooks/useConditionalFeature': ConditionalFeatureMockPath,
'@dailydotdev/shared/src/lib/boot': path.resolve(
__dirname,
'../mock/boot.ts',
Expand Down
38 changes: 34 additions & 4 deletions packages/storybook/mock/GrowthBookProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,38 @@
import React, { createContext, useContext } from 'react';
import type { PropsWithChildren, ReactElement } from 'react';
import { fn } from 'storybook/test';
import * as actual from '@dailydotdev/shared/src/components/GrowthBookProvider';

export const useFeature = fn(actual.useFeature)
.mockName('useFeature')
.mockReturnValue('control');
type FeatureLike = { id: string };
type FeatureOverrideValues = Record<string, unknown>;

const FeatureOverridesContext = createContext<FeatureOverrideValues>({});

/**
* Pins specific flags for one subtree, so a single story can render several
* experiment arms next to each other. Flags left out keep the `control` value
* every other story relies on.
*/
export const FeatureOverrides = ({
values,
children,
}: PropsWithChildren<{ values: FeatureOverrideValues }>): ReactElement => (
<FeatureOverridesContext.Provider value={values}>
{children}
</FeatureOverridesContext.Provider>
);

/** Returns the story-pinned value for a flag, or undefined when unpinned. */
export const useFeatureOverride = (feature?: FeatureLike): unknown => {
const overrides = useContext(FeatureOverridesContext);

return feature && feature.id in overrides ? overrides[feature.id] : undefined;
};

export const useFeature = fn((feature?: FeatureLike) => {
// eslint-disable-next-line react-hooks/rules-of-hooks -- runs during render
const override = useFeatureOverride(feature);

return override === undefined ? 'control' : override;
}).mockName('useFeature');

export * from '@dailydotdev/shared/src/components/GrowthBookProvider';
20 changes: 18 additions & 2 deletions packages/storybook/mock/hooks/useConditionalFeature.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
export const useConditionalFeature = () => {
import { useFeatureOverride } from '../GrowthBookProvider';

type FeatureLike = { id: string; defaultValue: unknown };

/**
* Mirrors the real hook, minus GrowthBook: a story-pinned value when one is
* provided (see `FeatureOverrides`), otherwise the flag's own default — which
* is what the unmocked hook resolves to without a GrowthBook instance.
*/
export const useConditionalFeature = ({
feature,
}: {
feature: FeatureLike;
shouldEvaluate?: boolean;
}): { value: unknown; isLoading: boolean } => {
const override = useFeatureOverride(feature);

return {
value: 'control',
value: override === undefined ? feature?.defaultValue : override,
isLoading: false,
};
};
Loading
Loading