diff --git a/packages/shared/src/graphql/quests.ts b/packages/shared/src/graphql/quests.ts index ee56bcbc14..e17dd77d78 100644 --- a/packages/shared/src/graphql/quests.ts +++ b/packages/shared/src/graphql/quests.ts @@ -410,6 +410,22 @@ export const TRACK_QUEST_EVENT_MUTATION = gql` } `; +export const TRACK_SHARED_POST_CLICK_MUTATION = gql` + mutation TrackSharedPostClick( + $referringUserId: ID! + $postId: ID! + $campaign: String! + ) { + trackSharedPostClick( + referringUserId: $referringUserId + postId: $postId + campaign: $campaign + ) { + _ + } + } +`; + export const QUEST_UPDATE_SUBSCRIPTION = gql` subscription QuestUpdate { questUpdate { @@ -434,3 +450,19 @@ export const trackQuestClientEvent = async ( ): Promise => { await gqlClient.request(TRACK_QUEST_EVENT_MUTATION, { eventType }); }; + +export const trackSharedPostClick = async ({ + referringUserId, + postId, + campaign, +}: { + referringUserId: string; + postId: string; + campaign: string; +}): Promise => { + await gqlClient.request(TRACK_SHARED_POST_CLICK_MUTATION, { + referringUserId, + postId, + campaign, + }); +}; diff --git a/packages/shared/src/hooks/index.ts b/packages/shared/src/hooks/index.ts index dbf7a6619e..779b363c94 100644 --- a/packages/shared/src/hooks/index.ts +++ b/packages/shared/src/hooks/index.ts @@ -29,6 +29,7 @@ export * from './useBookmarkProvider'; export * from './usePlusSubscription'; export * from './useClaimQuestReward'; export * from './useQuestDashboard'; +export * from './useShareLinkClick'; export * from './onboarding/useCheckExistingEmail'; export * from './onboarding/useGenerateUsername'; export * from './post/useBlockPostPanel'; diff --git a/packages/shared/src/hooks/useShareLinkClick.spec.tsx b/packages/shared/src/hooks/useShareLinkClick.spec.tsx new file mode 100644 index 0000000000..21c7ccc7b1 --- /dev/null +++ b/packages/shared/src/hooks/useShareLinkClick.spec.tsx @@ -0,0 +1,204 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; +import { useAuthContext } from '../contexts/AuthContext'; +import { trackSharedPostClick } from '../graphql/quests'; +import { ReferralCampaignKey } from '../lib/referral'; +import { + getShareLinkClickKey, + isShareLinkClickCampaign, + shouldTrackShareLinkClick, + useShareLinkClick, +} from './useShareLinkClick'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +jest.mock('../contexts/AuthContext', () => ({ + useAuthContext: jest.fn(), +})); + +jest.mock('../graphql/quests', () => ({ + ...jest.requireActual('../graphql/quests'), + trackSharedPostClick: jest.fn(), +})); + +const mockUseRouter = useRouter as jest.MockedFunction; +const mockUseAuthContext = useAuthContext as jest.MockedFunction< + typeof useAuthContext +>; +const mockTrackSharedPostClick = trackSharedPostClick as jest.MockedFunction< + typeof trackSharedPostClick +>; + +const postId = 'post-1'; +const referringUserId = 'sharer-1'; + +const setRouterQuery = (query: NextRouter['query']) => { + mockUseRouter.mockReturnValue({ + query, + } as unknown as NextRouter); +}; + +const setViewer = ({ + userId, + isAuthReady = true, +}: { + userId?: string | null; + isAuthReady?: boolean; +} = {}) => { + mockUseAuthContext.mockReturnValue({ + user: userId ? { id: userId } : null, + isAuthReady, + } as unknown as ReturnType); +}; + +describe('share link click helpers', () => { + it('should identify click campaigns and build stable keys', () => { + expect(isShareLinkClickCampaign(ReferralCampaignKey.SharePost)).toBe(true); + expect(isShareLinkClickCampaign(ReferralCampaignKey.ShareSlack)).toBe(true); + expect(isShareLinkClickCampaign(ReferralCampaignKey.ShareComment)).toBe( + false, + ); + expect( + getShareLinkClickKey({ + referringUserId, + postId, + campaign: ReferralCampaignKey.SharePost, + }), + ).toBe(`${referringUserId}:${postId}:${ReferralCampaignKey.SharePost}`); + }); + + it('should reject incomplete and self-click attribution', () => { + expect( + shouldTrackShareLinkClick({ + campaign: ReferralCampaignKey.SharePost, + referringUserId, + postId, + userId: 'visitor-1', + }), + ).toBe(true); + expect( + shouldTrackShareLinkClick({ + campaign: ReferralCampaignKey.SharePost, + referringUserId, + postId, + userId: referringUserId, + }), + ).toBe(false); + expect( + shouldTrackShareLinkClick({ + campaign: ReferralCampaignKey.ShareProfile, + referringUserId, + postId, + }), + ).toBe(false); + expect( + shouldTrackShareLinkClick({ + campaign: ReferralCampaignKey.SharePost, + postId, + }), + ).toBe(false); + }); +}); + +describe('useShareLinkClick', () => { + beforeEach(() => { + jest.clearAllMocks(); + setViewer(); + setRouterQuery({ + cid: ReferralCampaignKey.SharePost, + userid: referringUserId, + }); + mockTrackSharedPostClick.mockResolvedValue(undefined); + }); + + it('should track an eligible anonymous post share click once', async () => { + const { rerender } = renderHook( + (props: { postId?: string }) => useShareLinkClick(props), + { + initialProps: { postId }, + }, + ); + + await waitFor(() => { + expect(mockTrackSharedPostClick).toHaveBeenCalledTimes(1); + }); + + expect(mockTrackSharedPostClick).toHaveBeenCalledWith({ + referringUserId, + postId, + campaign: ReferralCampaignKey.SharePost, + }); + + rerender({ postId }); + + expect(mockTrackSharedPostClick).toHaveBeenCalledTimes(1); + }); + + it('should use the first query param value and ignore unrelated route changes', async () => { + const { rerender } = renderHook( + (props: { postId?: string }) => useShareLinkClick(props), + { + initialProps: { postId }, + }, + ); + + await waitFor(() => { + expect(mockTrackSharedPostClick).toHaveBeenCalledTimes(1); + }); + + setRouterQuery({ + cid: [ReferralCampaignKey.SharePost, ReferralCampaignKey.ShareProfile], + userid: [referringUserId, 'other-sharer'], + unrelated: '1', + }); + rerender({ postId }); + + expect(mockTrackSharedPostClick).toHaveBeenCalledTimes(1); + }); + + it('should skip self-clicks', () => { + setViewer({ userId: referringUserId }); + + renderHook(() => useShareLinkClick({ postId })); + + expect(mockTrackSharedPostClick).not.toHaveBeenCalled(); + }); + + it('should skip when attribution is incomplete or auth is not ready', () => { + setRouterQuery({ userid: referringUserId }); + renderHook(() => useShareLinkClick({ postId })); + + setRouterQuery({ + cid: ReferralCampaignKey.SharePost, + userid: referringUserId, + }); + renderHook(() => useShareLinkClick({})); + + setViewer({ isAuthReady: false }); + renderHook(() => useShareLinkClick({ postId })); + + expect(mockTrackSharedPostClick).not.toHaveBeenCalled(); + }); + + it('should not retry after a failed tracking request on rerender', async () => { + mockTrackSharedPostClick.mockRejectedValue(new Error('network error')); + + const { rerender } = renderHook( + (props: { postId?: string }) => useShareLinkClick(props), + { + initialProps: { postId }, + }, + ); + + await waitFor(() => { + expect(mockTrackSharedPostClick).toHaveBeenCalledTimes(1); + }); + + rerender({ postId }); + + expect(mockTrackSharedPostClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared/src/hooks/useShareLinkClick.ts b/packages/shared/src/hooks/useShareLinkClick.ts new file mode 100644 index 0000000000..394ed61913 --- /dev/null +++ b/packages/shared/src/hooks/useShareLinkClick.ts @@ -0,0 +1,89 @@ +import { useEffect, useRef } from 'react'; +import { useRouter } from 'next/router'; +import { useAuthContext } from '../contexts/AuthContext'; +import { trackSharedPostClick } from '../graphql/quests'; +import { getFirstQueryParam } from '../lib/func'; +import { ReferralCampaignKey } from '../lib/referral'; + +const SHARE_LINK_CLICK_CAMPAIGNS = new Set([ + ReferralCampaignKey.SharePost, + ReferralCampaignKey.ShareSlack, +]); + +export const isShareLinkClickCampaign = ( + campaign?: string | null, +): campaign is ReferralCampaignKey => + !!campaign && SHARE_LINK_CLICK_CAMPAIGNS.has(campaign); + +interface UseShareLinkClickProps { + postId?: string | null; + enabled?: boolean; +} + +export const getShareLinkClickKey = ({ + referringUserId, + postId, + campaign, +}: { + referringUserId: string; + postId: string; + campaign: string; +}): string => `${referringUserId}:${postId}:${campaign}`; + +export const shouldTrackShareLinkClick = ({ + campaign, + referringUserId, + postId, + userId, +}: { + campaign?: string | null; + referringUserId?: string | null; + postId?: string | null; + userId?: string | null; +}): boolean => + isShareLinkClickCampaign(campaign) && + !!referringUserId && + !!postId && + referringUserId !== userId; + +export const useShareLinkClick = ({ + postId, + enabled = true, +}: UseShareLinkClickProps): void => { + const { user, isAuthReady } = useAuthContext(); + const router = useRouter(); + const trackedKeysRef = useRef(new Set()); + const campaign = getFirstQueryParam(router.query.cid); + const referringUserId = getFirstQueryParam(router.query.userid); + + useEffect(() => { + if ( + !enabled || + !isAuthReady || + !isShareLinkClickCampaign(campaign) || + !referringUserId || + !postId || + referringUserId === user?.id + ) { + return; + } + + const clickKey = getShareLinkClickKey({ + referringUserId, + postId, + campaign, + }); + + if (trackedKeysRef.current.has(clickKey)) { + return; + } + + trackedKeysRef.current.add(clickKey); + + trackSharedPostClick({ + referringUserId, + postId, + campaign, + }).catch(() => undefined); + }, [campaign, enabled, isAuthReady, postId, referringUserId, user?.id]); +}; diff --git a/packages/webapp/__tests__/PostPage.tsx b/packages/webapp/__tests__/PostPage.tsx index 14fb1037c8..72d56c847c 100644 --- a/packages/webapp/__tests__/PostPage.tsx +++ b/packages/webapp/__tests__/PostPage.tsx @@ -51,6 +51,8 @@ import { FEED_SETTINGS_QUERY, REMOVE_FILTERS_FROM_FEED_MUTATION, } from '@dailydotdev/shared/src/graphql/feedSettings'; +import { TRACK_SHARED_POST_CLICK_MUTATION } from '@dailydotdev/shared/src/graphql/quests'; +import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral'; import { TestBootProvider } from '@dailydotdev/shared/__tests__/helpers/boot'; import * as hooks from '@dailydotdev/shared/src/hooks/useViewSize'; import { UserVoteEntity } from '@dailydotdev/shared/src/hooks'; @@ -378,6 +380,46 @@ it('should format read time when available', async () => { expect(el).toHaveTextContent('8m read time'); }); +it('should track attributed shared post clicks', async () => { + const onTrack = jest.fn(); + const shareUserId = 'share-user'; + + mockRouter({ + query: { + cid: ReferralCampaignKey.SharePost, + userid: shareUserId, + }, + }); + + renderPost({}, [ + createPostMock(), + createCommentsMock(), + { + request: { + query: TRACK_SHARED_POST_CLICK_MUTATION, + variables: { + referringUserId: shareUserId, + postId: defaultPost.id, + campaign: ReferralCampaignKey.SharePost, + }, + }, + result: () => { + onTrack(); + + return { + data: { + trackSharedPostClick: { _: true }, + }, + }; + }, + }, + ]); + + await waitFor(() => { + expect(onTrack).toHaveBeenCalledTimes(1); + }); +}); + it('should hide read time when not available', async () => { renderPost({}, [ createPostMock({ readTime: undefined }), diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index 05f9585300..0845f206e2 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -49,6 +49,7 @@ import { useEventListener, useJoinReferral, usePostById, + useShareLinkClick, useViewSize, ViewSize, } from '@dailydotdev/shared/src/hooks'; @@ -220,6 +221,7 @@ export const PostPage = ({ }, }); useSlackShareReturn({ post }); + useShareLinkClick({ postId: post?.id }); const queryClient = useQueryClient(); const postError = (isError ? queryClient.getQueryState(getPostByIdKey(id))?.error