From 9442f2d47fb85aaf9bdd429016e2f5adb77b8e32 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Tue, 10 Mar 2026 19:19:50 +0100 Subject: [PATCH] feat: useAdsQuery --- .../cards/squad/SquadsDirectoryFeed.tsx | 8 +- .../src/components/comments/AdAsComment.tsx | 13 +-- .../components/post/PostSidebarAdWidget.tsx | 14 +-- .../src/features/monetization/useAdQuery.ts | 45 +++++++ .../src/features/monetization/useFetchAd.ts | 34 +++--- .../src/hooks/feed/useAutoRotatingAds.ts | 6 +- packages/shared/src/hooks/useFeed.ts | 6 +- packages/shared/src/lib/ads.spec.ts | 101 ++++++++++++++++ packages/shared/src/lib/ads.ts | 110 ++++++++++++++++-- 9 files changed, 285 insertions(+), 52 deletions(-) create mode 100644 packages/shared/src/features/monetization/useAdQuery.ts create mode 100644 packages/shared/src/lib/ads.spec.ts diff --git a/packages/shared/src/components/cards/squad/SquadsDirectoryFeed.tsx b/packages/shared/src/components/cards/squad/SquadsDirectoryFeed.tsx index db8888e82b2..12793f169db 100644 --- a/packages/shared/src/components/cards/squad/SquadsDirectoryFeed.tsx +++ b/packages/shared/src/components/cards/squad/SquadsDirectoryFeed.tsx @@ -1,7 +1,6 @@ import type { ReactElement, ReactNode } from 'react'; import React, { useMemo } from 'react'; import { useInView } from 'react-intersection-observer'; -import { useQuery } from '@tanstack/react-query'; import type { Squad } from '../../../graphql/sources'; import type { SourcesQueryProps } from '../../../hooks/source/useSources'; import { @@ -21,11 +20,12 @@ import type { HorizontalScrollTitleProps } from '../../HorizontalScroll/Horizont import { HorizontalScrollTitle } from '../../HorizontalScroll/HorizontalScrollHeader'; import { generateQueryKey, RequestKey } from '../../../lib/query'; import { useAuthContext } from '../../../contexts/AuthContext'; -import { fetchDirectoryAd } from '../../../lib/ads'; +import { AdPlacement } from '../../../lib/ads'; import { LogExtraContextProvider } from '../../../contexts/LogExtraContext'; import type { Ad } from '../../../graphql/posts'; import { AdPixel } from '../ad/common/AdPixel'; import { TargetType } from '../../../lib/log'; +import { useAdQuery } from '../../../features/monetization/useAdQuery'; interface SquadHorizontalListProps { title: HorizontalScrollTitleProps; @@ -97,14 +97,14 @@ export function SquadsDirectoryFeed({ const { isFetched } = result; const isMobile = useViewSize(ViewSize.MobileL); const isLoading = !isFetched || (!inView && !result.data); - const { data: ad, isLoading: isLoadingAd } = useQuery({ + const { data: ad, isLoading: isLoadingAd } = useAdQuery({ + placement: AdPlacement.SquadDirectory, queryKey: generateQueryKey( RequestKey.Ads, user, 'squads_directory', title.copy, ), - queryFn: fetchDirectoryAd, enabled: firstItemShouldBeAd && isAuthReady && !user?.isPlus, }); const { squad: squadAd } = useSquad({ handle: ad?.data?.source?.handle }); diff --git a/packages/shared/src/components/comments/AdAsComment.tsx b/packages/shared/src/components/comments/AdAsComment.tsx index aaa8f47f724..4ecae5b0c91 100644 --- a/packages/shared/src/components/comments/AdAsComment.tsx +++ b/packages/shared/src/components/comments/AdAsComment.tsx @@ -1,6 +1,5 @@ import type { ReactElement } from 'react'; import React, { useCallback, useEffect } from 'react'; -import { useQuery } from '@tanstack/react-query'; import { TruncateText } from '../utilities'; import AdLink from '../cards/ad/common/AdLink'; import { adLogEvent } from '../../lib/feed'; @@ -11,13 +10,13 @@ import { generateQueryKey, RequestKey, StaleTime } from '../../lib/query'; import { useAuthContext } from '../../contexts/AuthContext'; import { cloudinaryPostImageCoverPlaceholder } from '../../lib/image'; import { AdPixel } from '../cards/ad/common/AdPixel'; -import { AdActions, fetchCommentAd } from '../../lib/ads'; +import { AdActions, AdPlacement } from '../../lib/ads'; import { usePlusSubscription } from '../../hooks/usePlusSubscription'; import { RemoveAd } from '../cards/ad/common/RemoveAd'; import { AdRefresh } from '../cards/ad/common/AdRefresh'; import { ButtonVariant } from '../buttons/common'; import { MiniCloseIcon } from '../icons'; -import type { Ad } from '../../graphql/posts'; +import { useAdQuery } from '../../features/monetization/useAdQuery'; import { ImpressionStatus } from '../../hooks/feed/useLogImpression'; import { useScrambler } from '../../hooks/useScrambler'; @@ -34,13 +33,9 @@ export const AdAsComment = ({ postId }: AdAsCommentProps): ReactElement => { refetch, isFetching, isRefetching, - } = useQuery({ + } = useAdQuery({ + placement: AdPlacement.PostComment, queryKey: generateQueryKey(RequestKey.Ads, user, postId), - queryFn: fetchCommentAd, - enabled: true, - refetchOnMount: false, - refetchOnReconnect: false, - refetchOnWindowFocus: false, staleTime: StaleTime.OneHour, }); diff --git a/packages/shared/src/components/post/PostSidebarAdWidget.tsx b/packages/shared/src/components/post/PostSidebarAdWidget.tsx index c4de0133bbc..678197d8562 100644 --- a/packages/shared/src/components/post/PostSidebarAdWidget.tsx +++ b/packages/shared/src/components/post/PostSidebarAdWidget.tsx @@ -1,6 +1,5 @@ import type { ReactElement } from 'react'; import React, { useCallback, useEffect } from 'react'; -import { useQuery } from '@tanstack/react-query'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import EntityCard from '../cards/entity/EntityCard'; import EntityCardSkeleton from '../cards/entity/EntityCardSkeleton'; @@ -10,11 +9,10 @@ import { getAdFaviconImageLink } from '../cards/ad/common/getAdFaviconImageLink' import { useFeature } from '../GrowthBookProvider'; import { useAuthContext } from '../../contexts/AuthContext'; import { useLogContext } from '../../contexts/LogContext'; -import type { Ad } from '../../graphql/posts'; -import { useFetchAd } from '../../features/monetization/useFetchAd'; +import { useAdQuery } from '../../features/monetization/useAdQuery'; import { usePlusSubscription } from '../../hooks/usePlusSubscription'; import { ImpressionStatus } from '../../hooks/feed/useLogImpression'; -import { AdActions } from '../../lib/ads'; +import { AdActions, AdPlacement } from '../../lib/ads'; import { adLogEvent } from '../../lib/feed'; import { adImprovementsV3Feature } from '../../lib/featureManagement'; import { generateQueryKey, RequestKey, StaleTime } from '../../lib/query'; @@ -39,16 +37,12 @@ export function PostSidebarAdWidget({ const { user } = useAuthContext(); const { isPlus } = usePlusSubscription(); const { logEvent } = useLogContext(); - const { fetchAd } = useFetchAd(); const adImprovementsV3 = useFeature(adImprovementsV3Feature); - const { data: ad, isPending } = useQuery({ + const { data: ad, isPending } = useAdQuery({ + placement: AdPlacement.PostSidebar, queryKey: generateQueryKey(RequestKey.Ads, user, postId, 'post-sidebar'), - queryFn: () => fetchAd({ active: false }), enabled: !isPlus, - refetchOnMount: false, - refetchOnReconnect: false, - refetchOnWindowFocus: false, staleTime: StaleTime.OneHour, }); diff --git a/packages/shared/src/features/monetization/useAdQuery.ts b/packages/shared/src/features/monetization/useAdQuery.ts new file mode 100644 index 00000000000..16f4ed21041 --- /dev/null +++ b/packages/shared/src/features/monetization/useAdQuery.ts @@ -0,0 +1,45 @@ +import type { QueryKey } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { useFeature } from '../../components/GrowthBookProvider'; +import type { Ad } from '../../graphql/posts'; +import { fetchAdByPlacement, resolveAdFetchOptions } from '../../lib/ads'; +import type { FetchAdByPlacementOptions } from '../../lib/ads'; +import { featurePostBoostAds } from '../../lib/featureManagement'; + +interface UseAdQueryOptions { + queryKey: QueryKey; + enabled?: boolean; + staleTime?: number; + placement: FetchAdByPlacementOptions['placement']; + active?: boolean; +} + +export const useAdQuery = ({ + queryKey, + enabled = true, + staleTime, + placement, + active, +}: UseAdQueryOptions) => { + const boostsEnabled = useFeature(featurePostBoostAds); + const fetchOptions = useMemo( + () => + resolveAdFetchOptions({ + placement, + active, + boostsEnabled, + }), + [placement, active, boostsEnabled], + ); + + return useQuery({ + queryKey, + queryFn: () => fetchAdByPlacement(fetchOptions), + enabled, + staleTime, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }); +}; diff --git a/packages/shared/src/features/monetization/useFetchAd.ts b/packages/shared/src/features/monetization/useFetchAd.ts index 11f721ec29b..43b528f8f06 100644 --- a/packages/shared/src/features/monetization/useFetchAd.ts +++ b/packages/shared/src/features/monetization/useFetchAd.ts @@ -1,30 +1,34 @@ import { useCallback } from 'react'; import { useFeature } from '../../components/GrowthBookProvider'; import type { Ad } from '../../graphql/posts'; -import { fetchAd } from '../../lib/ads'; +import { + AdPlacement, + fetchAdByPlacement, + resolveAdFetchOptions, +} from '../../lib/ads'; import { featurePostBoostAds } from '../../lib/featureManagement'; interface UseFetchAds { - fetchAd: (params: { active?: boolean }) => Promise; + fetchAd: (params: { + active?: boolean; + placement?: AdPlacement; + }) => Promise; } export const useFetchAd = (): UseFetchAds => { - const isEnabled = useFeature(featurePostBoostAds); + const boostsEnabled = useFeature(featurePostBoostAds); const fetchAdQuery: UseFetchAds['fetchAd'] = useCallback( - ({ active }) => { - const params = new URLSearchParams({ - active: active ? 'true' : 'false', - }); - - if (isEnabled) { - params.append('allow_post_boost', 'true'); - params.append('allow_squad_boost', 'true'); - } - - return fetchAd(params); + ({ active, placement = AdPlacement.Feed }) => { + return fetchAdByPlacement( + resolveAdFetchOptions({ + placement, + active, + boostsEnabled, + }), + ); }, - [isEnabled], + [boostsEnabled], ); return { diff --git a/packages/shared/src/hooks/feed/useAutoRotatingAds.ts b/packages/shared/src/hooks/feed/useAutoRotatingAds.ts index a759e51e5ac..66e65e8034f 100644 --- a/packages/shared/src/hooks/feed/useAutoRotatingAds.ts +++ b/packages/shared/src/hooks/feed/useAutoRotatingAds.ts @@ -13,6 +13,7 @@ import { featureAutorotateAds } from '../../lib/featureManagement'; import type { Ad } from '../../graphql/posts'; import { generateAdLogEventKey } from './useLogImpression'; import { disabledRefetch } from '../../lib/func'; +import { AdPlacement } from '../../lib/ads'; import { RequestKey } from '../../lib/query'; import { useFetchAd } from '../../features/monetization/useFetchAd'; @@ -54,7 +55,10 @@ export const useAutoRotatingAds = ( ); const fetchNewAd = useCallback(async (): Promise => { - const newAd = await fetchAd({ active: true }); + const newAd = await fetchAd({ + placement: AdPlacement.Feed, + active: true, + }); if (!newAd) { return null; } diff --git a/packages/shared/src/hooks/useFeed.ts b/packages/shared/src/hooks/useFeed.ts index 8ab0a10f77b..404da0b454b 100644 --- a/packages/shared/src/hooks/useFeed.ts +++ b/packages/shared/src/hooks/useFeed.ts @@ -24,6 +24,7 @@ import type { FeedAdTemplate } from '../lib/feed'; import { featureFeedAdTemplate } from '../lib/featureManagement'; import { cloudinaryPostImageCoverPlaceholder } from '../lib/image'; import { AD_PLACEHOLDER_SOURCE_ID } from '../lib/constants'; +import { AdPlacement } from '../lib/ads'; import { SharedFeedPage } from '../components/utilities'; import { useTranslation } from './translation/useTranslation'; import { useFetchAd } from '../features/monetization/useFetchAd'; @@ -210,7 +211,10 @@ export default function useFeed( const adsQuery = useInfiniteQuery({ queryKey: [RequestKey.Ads, ...feedQueryKey], queryFn: async ({ pageParam }) => { - const ad = await fetchAd({ active: !!pageParam }); + const ad = await fetchAd({ + placement: AdPlacement.Feed, + active: !!pageParam, + }); if (!ad) { return { diff --git a/packages/shared/src/lib/ads.spec.ts b/packages/shared/src/lib/ads.spec.ts new file mode 100644 index 00000000000..e629a088ddb --- /dev/null +++ b/packages/shared/src/lib/ads.spec.ts @@ -0,0 +1,101 @@ +import ad from '../../__tests__/fixture/ad'; +import { AdPlacement, fetchAdByPlacement, resolveAdFetchOptions } from './ads'; + +describe('ads', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue([ad]), + headers: { + get: (name: string) => + name === 'x-generation-id' ? 'gen-123' : undefined, + }, + } as unknown as Response) as typeof fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + jest.clearAllMocks(); + }); + + describe('resolveAdFetchOptions', () => { + it('should enable boost flags for feed placements', () => { + expect( + resolveAdFetchOptions({ + placement: AdPlacement.Feed, + active: true, + boostsEnabled: true, + }), + ).toEqual({ + placement: AdPlacement.Feed, + active: true, + allowPostBoost: true, + allowSquadBoost: true, + }); + }); + + it('should keep comment placement minimal', () => { + expect( + resolveAdFetchOptions({ + placement: AdPlacement.PostComment, + boostsEnabled: true, + }), + ).toEqual({ + placement: AdPlacement.PostComment, + }); + }); + + it('should always enable squad boost for directory placement', () => { + expect( + resolveAdFetchOptions({ + placement: AdPlacement.SquadDirectory, + boostsEnabled: false, + }), + ).toEqual({ + placement: AdPlacement.SquadDirectory, + allowSquadBoost: true, + }); + }); + }); + + describe('fetchAdByPlacement', () => { + it('should fetch feed ads from the general ads endpoint', async () => { + const result = await fetchAdByPlacement({ + placement: AdPlacement.Feed, + active: true, + allowPostBoost: true, + allowSquadBoost: true, + }); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:3000/v1/a?active=true&allow_post_boost=true&allow_squad_boost=true', + { credentials: 'include' }, + ); + expect(result).toEqual({ ...ad, generationId: 'gen-123' }); + }); + + it('should fetch comment ads from the post ads endpoint', async () => { + await fetchAdByPlacement({ + placement: AdPlacement.PostComment, + }); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:3000/v1/a/post', + { credentials: 'include' }, + ); + }); + + it('should fetch directory ads from the squads directory endpoint', async () => { + await fetchAdByPlacement({ + placement: AdPlacement.SquadDirectory, + allowSquadBoost: true, + }); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:3000/v1/a/squads_directory?allow_squad_boost=true', + { credentials: 'include' }, + ); + }); + }); +}); diff --git a/packages/shared/src/lib/ads.ts b/packages/shared/src/lib/ads.ts index eae39250efb..fed26654a64 100644 --- a/packages/shared/src/lib/ads.ts +++ b/packages/shared/src/lib/ads.ts @@ -7,6 +7,20 @@ export enum AdActions { Impression = 'impression', } +export enum AdPlacement { + Feed = 'feed', + PostSidebar = 'post-sidebar', + PostComment = 'post-comment', + SquadDirectory = 'squad-directory', +} + +export interface FetchAdByPlacementOptions { + placement: AdPlacement; + active?: boolean; + allowPostBoost?: boolean; + allowSquadBoost?: boolean; +} + const skadiGenerationIdHeader = 'x-generation-id'; const addGenerationIdHeader = ({ @@ -32,8 +46,15 @@ const addGenerationIdHeader = ({ }; }; -export const fetchAd = async (params: URLSearchParams): Promise => { - const res = await fetch(`${apiUrl}/v1/a?${params.toString()}`, { +const fetchAdRequest = async ({ + path, + params, +}: { + path: string; + params?: URLSearchParams; +}): Promise => { + const query = params?.toString(); + const res = await fetch(`${apiUrl}${path}${query ? `?${query}` : ''}`, { credentials: 'include', }); @@ -41,19 +62,84 @@ export const fetchAd = async (params: URLSearchParams): Promise => { return addGenerationIdHeader({ ad: ads[0], res }); }; +export const resolveAdFetchOptions = ({ + placement = AdPlacement.Feed, + active, + boostsEnabled = false, +}: { + placement?: AdPlacement; + active?: boolean; + boostsEnabled?: boolean; +}): FetchAdByPlacementOptions => { + switch (placement) { + case AdPlacement.PostComment: + return { placement }; + case AdPlacement.SquadDirectory: + return { + placement, + allowSquadBoost: true, + }; + case AdPlacement.PostSidebar: + case AdPlacement.Feed: + default: + return { + placement, + active, + allowPostBoost: boostsEnabled, + allowSquadBoost: boostsEnabled, + }; + } +}; + +export const fetchAdByPlacement = async ({ + placement, + active = false, + allowPostBoost = false, + allowSquadBoost = false, +}: FetchAdByPlacementOptions): Promise => { + switch (placement) { + case AdPlacement.PostComment: + return fetchAdRequest({ path: '/v1/a/post' }); + case AdPlacement.SquadDirectory: { + const params = new URLSearchParams(); + if (allowSquadBoost) { + params.set('allow_squad_boost', 'true'); + } + + return fetchAdRequest({ path: '/v1/a/squads_directory', params }); + } + case AdPlacement.PostSidebar: + case AdPlacement.Feed: + default: { + const params = new URLSearchParams({ + active: active ? 'true' : 'false', + }); + + if (allowPostBoost) { + params.append('allow_post_boost', 'true'); + } + + if (allowSquadBoost) { + params.append('allow_squad_boost', 'true'); + } + + return fetchAdRequest({ path: '/v1/a', params }); + } + } +}; + +export const fetchAd = async (params: URLSearchParams): Promise => + fetchAdRequest({ path: '/v1/a', params }); + export const fetchCommentAd = async (): Promise => { - const res = await fetch(`${apiUrl}/v1/a/post`, { - credentials: 'include', + return fetchAdByPlacement({ + placement: AdPlacement.PostComment, }); - const ads = (await res.json()) as Ad[]; - return addGenerationIdHeader({ ad: ads[0], res }); }; export const fetchDirectoryAd = async (): Promise => { - const res = await fetch( - `${apiUrl}/v1/a/squads_directory?allow_squad_boost=true`, - { credentials: 'include' }, - ); - const ads = (await res.json()) as Ad[]; - return addGenerationIdHeader({ ad: ads[0], res }); + return fetchAdByPlacement({ + placement: AdPlacement.SquadDirectory, + allowSquadBoost: true, + }); };