From 16317159fe81ef8347c4def73e50230fe47e7422 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 27 Jul 2026 16:45:11 +0300 Subject: [PATCH 1/2] feat(ads): disclose ads as "Ad" behind the ad_label experiment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Control names the advertiser under the creative ("Promoted by Vercel"). The treatments hide the advertiser and disclose with a plain "Ad", so we can measure what the disclosure wording does to ad CTR. Arms: control, ad (advertise link kept), ad_only (advertise link dropped from the feed cards). Default stays control — GrowthBook ramps the arms. CTR needs no new instrumentation: the cards already emit the impression and click events built by adLogEvent with target_type "ad". Co-Authored-By: Claude Opus 5 --- .../src/components/cards/ad/AdCard.spec.tsx | 71 +++++++++++++++++++ .../shared/src/components/cards/ad/AdGrid.tsx | 14 ++-- .../shared/src/components/cards/ad/AdList.tsx | 14 ++-- .../cards/ad/common/AdAttribution.tsx | 10 ++- .../src/features/monetization/useAdLabel.ts | 17 +++++ packages/shared/src/lib/featureManagement.ts | 15 ++++ 6 files changed, 128 insertions(+), 13 deletions(-) create mode 100644 packages/shared/src/features/monetization/useAdLabel.ts diff --git a/packages/shared/src/components/cards/ad/AdCard.spec.tsx b/packages/shared/src/components/cards/ad/AdCard.spec.tsx index d543379dfe4..b1f9f14d801 100644 --- a/packages/shared/src/components/cards/ad/AdCard.spec.tsx +++ b/packages/shared/src/components/cards/ad/AdCard.spec.tsx @@ -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, @@ -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 = ( @@ -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(); diff --git a/packages/shared/src/components/cards/ad/AdGrid.tsx b/packages/shared/src/components/cards/ad/AdGrid.tsx index 5268bcb5b54..ba092630753 100644 --- a/packages/shared/src/components/cards/ad/AdGrid.tsx +++ b/packages/shared/src/components/cards/ad/AdGrid.tsx @@ -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(function AdGrid( { ad, onLinkClick, domProps, index, feedIndex }, @@ -37,6 +38,7 @@ export const AdGrid = forwardRef(function AdGrid( const { isPlus } = usePlusSubscription(); const adImprovementsV3 = useFeature(adImprovementsV3Feature); const useGlass = useFeedCardGlassActions(); + const { showAdvertiseLink } = useAdLabel(); const { ref } = useAutoRotatingAds( ad, index, @@ -80,11 +82,13 @@ export const AdGrid = forwardRef(function AdGrid( {ad.callToAction} )} - + {showAdvertiseLink && ( + + )}
{!isPlus && ( (function AdCard( ): ReactElement { const { isPlus } = usePlusSubscription(); const adImprovementsV3 = useFeature(adImprovementsV3Feature); + const { showAdvertiseLink } = useAdLabel(); const { ref } = useAutoRotatingAds( ad, index, @@ -104,11 +106,13 @@ export const AdList = forwardRef(function AdCard( {ad.callToAction} )} - + {showAdvertiseLink && ( + + )}
{!isPlus && ( { + const variant = useFeature(featureAdLabel); + + return { + hideAdvertiser: !!variant && variant !== AdLabelVariant.Control, + showAdvertiseLink: variant !== AdLabelVariant.AdOnly, + }; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index a7d61e52f0f..ccb84f64001 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -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( + 'ad_label', + AdLabelVariant.Control, +); + export const featureYearInReview = new Feature('year_in_review_2025', false); export const featureProfileCompletionIndicator = new Feature( From 20cf0d1ac44fcfd4b27f664bd9cbc3e25fe164ae Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 27 Jul 2026 16:45:11 +0300 Subject: [PATCH 2/2] chore(storybook): add the ad label experiment gallery Every ad surface (feed grid + list, signal card, post sidebar card and inline, ad as comment) and card use case (no CTA, no referral link, network creative, long copy, matching tags, Plus) rendered per arm. The GrowthBook mock gains a FeatureOverrides provider so one story can pin a different flag value per subtree; the useConditionalFeature mock reads the same overrides and is now aliased like the provider, falling back to the flag's own default so existing stories are unaffected. Co-Authored-By: Claude Opus 5 --- packages/storybook/.storybook/main.ts | 15 + .../storybook/mock/GrowthBookProvider.tsx | 38 +- .../mock/hooks/useConditionalFeature.ts | 20 +- .../stories/experiments/AdLabel.stories.tsx | 427 ++++++++++++++++++ .../stories/experiments/adLabel.mocks.tsx | 277 ++++++++++++ 5 files changed, 771 insertions(+), 6 deletions(-) create mode 100644 packages/storybook/stories/experiments/AdLabel.stories.tsx create mode 100644 packages/storybook/stories/experiments/adLabel.mocks.tsx diff --git a/packages/storybook/.storybook/main.ts b/packages/storybook/.storybook/main.ts index cb956bc7f7a..1f6ac08af5c 100644 --- a/packages/storybook/.storybook/main.ts +++ b/packages/storybook/.storybook/main.ts @@ -25,6 +25,10 @@ const config: StorybookConfig = { __dirname, '../mock/GrowthBookProvider.tsx', ); + const ConditionalFeatureMockPath = path.resolve( + __dirname, + '../mock/hooks/useConditionalFeature.ts', + ); return mergeConfig(config, { core: { @@ -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', diff --git a/packages/storybook/mock/GrowthBookProvider.tsx b/packages/storybook/mock/GrowthBookProvider.tsx index 700e1dbf5f1..27af2d990a9 100644 --- a/packages/storybook/mock/GrowthBookProvider.tsx +++ b/packages/storybook/mock/GrowthBookProvider.tsx @@ -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; + +const FeatureOverridesContext = createContext({}); + +/** + * 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 => ( + + {children} + +); + +/** 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'; diff --git a/packages/storybook/mock/hooks/useConditionalFeature.ts b/packages/storybook/mock/hooks/useConditionalFeature.ts index 27eb3c1d200..0f677aaf389 100644 --- a/packages/storybook/mock/hooks/useConditionalFeature.ts +++ b/packages/storybook/mock/hooks/useConditionalFeature.ts @@ -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, }; }; diff --git a/packages/storybook/stories/experiments/AdLabel.stories.tsx b/packages/storybook/stories/experiments/AdLabel.stories.tsx new file mode 100644 index 00000000000..454346b973f --- /dev/null +++ b/packages/storybook/stories/experiments/AdLabel.stories.tsx @@ -0,0 +1,427 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { AdGrid } from '@dailydotdev/shared/src/components/cards/ad/AdGrid'; +import { AdList } from '@dailydotdev/shared/src/components/cards/ad/AdList'; +import { SignalAdList } from '@dailydotdev/shared/src/components/cards/ad/SignalAdList'; +import { PostSidebarAdWidget } from '@dailydotdev/shared/src/components/post/PostSidebarAdWidget'; +import { AdAsComment } from '@dailydotdev/shared/src/components/comments/AdAsComment'; +import type { Ad } from '@dailydotdev/shared/src/graphql/posts'; +import { + adImprovementsV3Feature, + featureFeedCardGlassActions, +} from '@dailydotdev/shared/src/lib/featureManagement'; +import { + AdProviders, + Arm, + arms, + baseAd, + carbonAd, + COMMENT_POST_ID, + longCopyAd, + noCtaAd, + noop, + noReferralAd, + Page, + Section, + SIDEBAR_POST_ID, + taggedAd, + type ArmConfig, +} from './adLabel.mocks'; + +// --------------------------------------------------------------------------- +// Experiment: `ad_label` +// +// Control names the advertiser under the ad ("Promoted by Vercel"). The +// treatments hide the advertiser and disclose with a plain "Ad", to see how +// disclosure wording moves ad CTR. Every ad surface and card use case is laid +// out here, control first, so the arms can be compared side by side. +// --------------------------------------------------------------------------- + +const feedProps = { index: 0, feedIndex: 0, onLinkClick: noop }; + +const ArmRow = ({ + render, + className, + overrides, +}: { + render: (arm: ArmConfig) => ReactElement; + className?: string; + overrides?: Record; +}): ReactElement => ( +
+ {arms.map((arm) => ( + + {render(arm)} + + ))} +
+); + +const meta: Meta = { + title: 'Experiments/Ad label', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'A/B test `ad_label`: hide the advertiser on ad cards and disclose with a plain "Ad" instead of "Promoted by {advertiser}". Primary metric is ad CTR (clicks / impressions on `target_type: "ad"` events).', + }, + }, + }, +}; + +export default meta; + +export const Brief: StoryObj = { + name: '0. Experiment brief', + render: () => ( + +
+

Ad label experiment

+

+ Ad cards currently name the advertiser under the creative + ("Promoted by Vercel") and carry an "Advertise + here" self-promo. The hypothesis is that the advertiser name + reads as a disclosure of a paid placement twice over, and that a + single neutral "Ad" label lets the creative do the work — + moving CTR. +

+
+ Flag + + ad_label (string) — default: control + +
+
+ Arms +
    + {arms.map((arm) => ( +
  • + {arm.variant} — {arm.summary} +
  • + ))} +
+
+
+ Measurement +

+ All arms already emit the ad events built by adLogEvent{' '} + — impression and click with{' '} + target_type: "ad" and{' '} + target_id: ad.source. CTR = ad clicks / ad impressions, + split by experiment arm. Guardrails: revenue per mille, and the{' '} + advertise here cta click rate, which variant B removes + from the card entirely. +

+
+
+ Coverage today +
    +
  • + Feed grid + list cards — label swap and the "Advertise + here" removal. +
  • +
  • + Signal card and post sidebar widget — label swap only; both still + print the company name elsewhere on the card. +
  • +
  • + Ad as comment — not covered; it builds its own + attribution string. +
  • +
  • + The advertiser logo stays in every arm. Dropping it too would be a + stronger "de-brand the ad" test. +
  • +
+
+
+
+ ), +}; + +export const FeedGrid: StoryObj = { + name: '1. Feed card — grid', + render: () => ( + + +
+ } + /> +
+
+ } + /> +
+
+
+ ), +}; + +export const FeedList: StoryObj = { + name: '2. Feed card — list', + render: () => ( + + +
+ } + /> +
+
+
+ ), +}; + +export const SignalCard: StoryObj = { + name: '3. Signal feed card', + render: () => ( + + +
+ Advertiser still named. This card prints the + company next to the avatar, so the treatments turn the line into + "Vercel · Ad". Hiding the advertiser here needs the name + and avatar handled too — a product decision for the rollout. + + } + > + } + /> +
+
+
+ ), +}; + +export const SidebarWidget: StoryObj = { + name: '4. Post sidebar widget', + render: () => ( + + +
+ Advertiser still named. The company sits on its + own bold line above the attribution. The "Advertise + here" removal is scoped to the feed cards, so the link stays + here in every arm. + + } + > + } + /> +
+
+ ( + + )} + /> +
+
+
+ ), +}; + +export const AdComment: StoryObj = { + name: '5. Ad as comment', + render: () => ( + + +
+ Not covered by the flag today. This component + builds its own "Promoted by {'{advertiser}'}" string + instead of using the shared AdAttribution, so all + three arms look identical. Moving it onto the shared component is + part of the product implementation. + + } + > + } + /> +
+
+
+ ), +}; + +const useCases: Array<{ + title: string; + description: string; + ad: Ad; + overrides?: Record; + isPlus?: boolean; +}> = [ + { + title: 'No call to action', + description: + 'Without `callToAction` the row starts with the advertise link — so variant B leaves only "Remove".', + ad: noCtaAd, + }, + { + title: 'No referral link', + description: + 'Control degrades to a bare "Promoted" (no advertiser to name), which makes it the closest arm to the treatments.', + ad: noReferralAd, + }, + { + title: 'Network creative (Carbon)', + description: + 'Carbon and EthicalAds creatives render contained over a blurred copy of themselves.', + ad: carbonAd, + }, + { + title: 'Long copy', + description: 'A description long enough to push the attribution down.', + ad: longCopyAd, + }, + { + title: 'Matching tags (ad_improvements_v3)', + description: + 'With `ad_improvements_v3` on, matched tags render between the copy and the attribution.', + ad: taggedAd, + overrides: { [adImprovementsV3Feature.id]: true }, + }, + { + title: 'Plus subscriber', + description: 'Plus members never see the "Remove" upsell.', + ad: baseAd, + isPlus: true, + }, +]; + +export const UseCases: StoryObj = { + name: '6. Use cases', + render: () => ( + + {useCases.map((useCase) => ( + +
+ } + /> +
+
+ ))} +
+ ), +}; + +export const ControlOnly: StoryObj = { + name: 'Reference — control, every surface', + render: () => ( + + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ ), +}; + +export const Treatment: StoryObj = { + name: 'Reference — variant B, every surface', + render: () => ( + + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+ ), +}; diff --git a/packages/storybook/stories/experiments/adLabel.mocks.tsx b/packages/storybook/stories/experiments/adLabel.mocks.tsx new file mode 100644 index 00000000000..1c1f566c742 --- /dev/null +++ b/packages/storybook/stories/experiments/adLabel.mocks.tsx @@ -0,0 +1,277 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useMemo } from 'react'; +import classNames from 'classnames'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthContextProvider } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import { ActiveFeedContext } from '@dailydotdev/shared/src/contexts'; +import SettingsContext from '@dailydotdev/shared/src/contexts/SettingsContext'; +import { + generateQueryKey, + RequestKey, +} from '@dailydotdev/shared/src/lib/query'; +import type { Ad } from '@dailydotdev/shared/src/graphql/posts'; +import { + adImprovementsV3Feature, + AdLabelVariant, + featureAdLabel, + featureAutorotateAds, + featureFeedCardGlassActions, +} from '@dailydotdev/shared/src/lib/featureManagement'; +import { FeatureOverrides } from '../../mock/GrowthBookProvider'; + +export const noop = (): void => undefined; + +// `providers` is what makes AuthContext treat this as a signed-in user. +const user = { id: 'sb-user', name: 'Dev Dana', providers: ['github'] }; + +// Feed cards read layout settings through SettingsContext, which defaults to +// null outside the app shell. +const settings = { + insaneMode: false, + spaciness: 'roomy', + loadedSettings: true, + openNewTab: false, +}; + +/** Both ad widgets read their ad from the query cache, keyed by post id. */ +export const SIDEBAR_POST_ID = 'sb-post-sidebar'; +export const COMMENT_POST_ID = 'sb-post-comment'; + +// --------------------------------------------------------------------------- +// Ad fixtures — one per use case a reviewer needs to see. +// --------------------------------------------------------------------------- + +export const baseAd: Ad = { + company: 'Vercel', + source: 'Vercel', + tagLine: 'Deploy without the ops.', + description: + 'Ship your Next.js app in seconds. Zero-config deploys, preview URLs on every push.', + image: + 'https://media.daily.dev/image/upload/v1675852969/squads/c0457b66-e89b-4fc0-b06d-48f920c7caa2.jpg', + link: 'https://vercel.com', + referralLink: 'https://vercel.com', + companyLogo: 'https://svgl.app/library/vercel.svg', + callToAction: 'Start deploying', + adDomain: 'vercel.com', + providerId: 'sb-provider', +}; + +export const noCtaAd: Ad = { ...baseAd, callToAction: undefined }; + +/** No referral link — control says a bare "Promoted", with no advertiser. */ +export const noReferralAd: Ad = { ...baseAd, referralLink: undefined }; + +/** Carbon and EthicalAds creatives render contained over a blurred backdrop. */ +export const carbonAd: Ad = { + ...baseAd, + company: 'Carbon', + source: 'Carbon', + companyLogo: undefined, + description: + 'A network-served creative that keeps its original aspect ratio.', +}; + +export const longCopyAd: Ad = { + ...baseAd, + description: + 'Observability for teams that ship daily: distributed tracing, log search, RUM and synthetic checks in one place, with alerts that route to the on-call engineer who owns the service.', +}; + +export const taggedAd: Ad = { + ...baseAd, + matchingTags: ['nextjs', 'react', 'devops', 'webdev'], +}; + +// --------------------------------------------------------------------------- +// Providers +// --------------------------------------------------------------------------- + +interface AdProvidersProps { + children: ReactNode; + /** Plus subscribers never see the "Remove" upsell. */ + isPlus?: boolean; + /** Ad served to `PostSidebarAdWidget` / `AdAsComment` via the query cache. */ + widgetAd?: Ad; +} + +export const AdProviders = ({ + children, + isPlus = false, + widgetAd = baseAd, +}: AdProvidersProps): ReactElement => { + const LogContext = getLogContextStatic(); + const queryClient = useMemo(() => { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false }, + }, + }); + + // Seeded for both the signed-in and anonymous key, so the widgets read + // their ad from the cache instead of hitting the (unmocked) ad server. + [user, undefined].forEach((keyUser) => { + client.setQueryData( + generateQueryKey( + RequestKey.Ads, + keyUser, + SIDEBAR_POST_ID, + 'post-sidebar', + ), + widgetAd, + ); + client.setQueryData( + generateQueryKey(RequestKey.Ads, keyUser, COMMENT_POST_ID), + widgetAd, + ); + }); + + return client; + }, [widgetAd]); + + return ( + + ''} + updateUser={noop as never} + refetchBoot={noop as never} + visit={{ visitId: 'sb', sessionId: 'sb' } as never} + accessToken={null as never} + squads={[]} + feeds={undefined} + geo={{} as never} + isAndroidApp={false} + > + + + + {children} + + + + + + ); +}; + +// --------------------------------------------------------------------------- +// Experiment arms +// --------------------------------------------------------------------------- + +export interface ArmConfig { + variant: AdLabelVariant; + name: string; + summary: string; +} + +export const arms: ArmConfig[] = [ + { + variant: AdLabelVariant.Control, + name: 'Control', + summary: '"Promoted by {advertiser}" + "Advertise here"', + }, + { + variant: AdLabelVariant.Ad, + name: 'Variant A — ad', + summary: 'Advertiser hidden, disclosed as "Ad". "Advertise here" stays.', + }, + { + variant: AdLabelVariant.AdOnly, + name: 'Variant B — ad_only', + summary: 'Advertiser hidden, disclosed as "Ad". "Advertise here" removed.', + }, +]; + +// `control` is the blanket mock value for every flag, which would leave +// autorotation on a NaN timer and force the v3 tag row on. Pin the flags that +// change the ad card to their production defaults, so only `ad_label` differs. +const baseOverrides: Record = { + [featureAutorotateAds.id]: 0, + [adImprovementsV3Feature.id]: false, + [featureFeedCardGlassActions.id]: false, +}; + +interface ArmProps { + arm: ArmConfig; + className?: string; + /** Extra flags to pin for this column, e.g. glass actions or v3 tags. */ + overrides?: Record; + children: ReactNode; +} + +export const Arm = ({ + arm, + className, + overrides, + children, +}: ArmProps): ReactElement => ( +
+
+ {arm.name} + + ad_label = {arm.variant} + + {arm.summary} +
+ + {children} + +
+); + +interface SectionProps { + title: string; + description?: ReactNode; + note?: ReactNode; + children: ReactNode; +} + +export const Section = ({ + title, + description, + note, + children, +}: SectionProps): ReactElement => ( +
+
+

{title}

+ {description && ( +

+ {description} +

+ )} + {note && ( +

+ {note} +

+ )} +
+ {children} +
+); + +export const Page = ({ children }: { children: ReactNode }): ReactElement => ( +
+ {children} +
+);