From 49e8359c43f14ca79433a5cfba3ec12f63158572 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 18 Sep 2026 11:44:41 +0200 Subject: [PATCH 1/2] feat(post): add topic-based signup experiment --- .../src/components/auth/SignupWidget.tsx | 5 +- .../src/components/post/BasePostContent.tsx | 2 + .../components/post/PostSignupWidget.spec.tsx | 197 ++++++++++++++++++ .../src/components/post/PostSignupWidget.tsx | 45 +++- .../src/components/post/PostTopicSignup.tsx | 110 ++++++++++ .../src/components/post/PostWidgets.tsx | 2 +- .../src/components/post/SquadPostWidgets.tsx | 2 +- .../post/collection/CollectionPostWidgets.tsx | 2 +- .../components/post/focus/PostFocusCard.tsx | 2 + .../shared/src/graphql/postTopicSignup.ts | 45 ++++ packages/shared/src/lib/featureManagement.ts | 1 + packages/shared/src/lib/log.ts | 1 + 12 files changed, 406 insertions(+), 8 deletions(-) create mode 100644 packages/shared/src/components/post/PostSignupWidget.spec.tsx create mode 100644 packages/shared/src/components/post/PostTopicSignup.tsx create mode 100644 packages/shared/src/graphql/postTopicSignup.ts diff --git a/packages/shared/src/components/auth/SignupWidget.tsx b/packages/shared/src/components/auth/SignupWidget.tsx index 814899db099..e8b5826dbb1 100644 --- a/packages/shared/src/components/auth/SignupWidget.tsx +++ b/packages/shared/src/components/auth/SignupWidget.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import classNames from 'classnames'; import { useAuthContext } from '../../contexts/AuthContext'; @@ -33,6 +33,7 @@ const denseContainer = interface SignupWidgetProps { title: string; description: string; + children?: ReactNode; /** Which surface is asking, for the analytics on the resulting signup. */ trigger: AuthTriggersType; /** @@ -58,6 +59,7 @@ export function SignupWidget({ trigger, dense, className, + children, }: SignupWidgetProps): ReactElement { const { showLogin } = useAuthContext(); @@ -93,6 +95,7 @@ export function SignupWidget({ > {description}

+ {children}
import(/* webpackChunkName: "custom404" */ '../Custom404'), @@ -65,6 +66,7 @@ export function BasePostContent({ )} {children} + {!!post?.id && } {isPostPage && } {aboveComments} {!!engagementProps && ( diff --git a/packages/shared/src/components/post/PostSignupWidget.spec.tsx b/packages/shared/src/components/post/PostSignupWidget.spec.tsx new file mode 100644 index 00000000000..30022690b94 --- /dev/null +++ b/packages/shared/src/components/post/PostSignupWidget.spec.tsx @@ -0,0 +1,197 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { GrowthBook } from '@growthbook/growthbook-react'; +import { useInView } from 'react-intersection-observer'; +import nock from 'nock'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { mockGraphQL } from '../../../__tests__/helpers/graphql'; +import post from '../../../__tests__/fixture/post'; +import loggedUser from '../../../__tests__/fixture/loggedUser'; +import type { AuthContextData } from '../../contexts/AuthContext'; +import { TAG_TITLES_QUERY } from '../../graphql/keywords'; +import { POST_TOPIC_SIGNUP_QUERY } from '../../graphql/postTopicSignup'; +import { AuthTriggers } from '../../lib/auth'; +import { AuthDisplay } from '../auth/common'; +import { PostSignupWidget } from './PostSignupWidget'; + +jest.mock('react-intersection-observer', () => ({ + ...jest.requireActual('react-intersection-observer'), + useInView: jest.fn(), +})); + +jest.mock('../auth/AuthOptions', () => ({ + __esModule: true, + default: ({ + onAuthStateUpdate, + }: { + onAuthStateUpdate: (props: { defaultDisplay: string }) => void; + }) => { + const { AuthDisplay: Display } = jest.requireActual('../auth/common'); + return ( + + ); + }, +})); + +const mockUseInView = useInView as jest.Mock; +const showLogin = jest.fn(); +const examplePost = { + ...post, + id: 'related-post', + title: 'A practical web development guide', + commentsPermalink: '/posts/related-post', +}; + +beforeEach(() => { + jest.clearAllMocks(); + nock.cleanAll(); + mockUseInView.mockReturnValue([jest.fn(), true]); +}); + +const renderWidget = ({ + topicEnabled = true, + genericEnabled = true, + inline = true, + currentPost = post, + auth = {}, +}: { + topicEnabled?: boolean; + genericEnabled?: boolean; + inline?: boolean; + currentPost?: typeof post; + auth?: Partial; +} = {}) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const gb = new GrowthBook({ + features: { + post_topic_signup: { defaultValue: topicEnabled }, + post_signup_widget: { defaultValue: genericEnabled }, + }, + }); + + return render( + + + , + ); +}; + +const mockPreviews = (currentPost = post, fail = false) => { + mockGraphQL({ + request: { query: TAG_TITLES_QUERY }, + result: { + data: { + tags: [{ value: 'webdev', flags: { title: 'Web Development' } }], + }, + }, + }); + mockGraphQL({ + request: { + query: POST_TOPIC_SIGNUP_QUERY, + variables: { post: currentPost.id, tag: 'webdev', first: 2 }, + }, + result: fail + ? { errors: [{ message: 'Preview unavailable' }] } + : { data: { similarPosts: [examplePost] } }, + }); +}; + +it('keeps the existing sidebar offer when the topic experiment is off', () => { + renderWidget({ topicEnabled: false, inline: false }); + expect(screen.getByText('Want your personalized dev feed?')).toBeVisible(); + expect(mockUseInView).not.toHaveBeenCalled(); +}); + +it('does not add an inline offer to the control', () => { + renderWidget({ topicEnabled: false }); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + expect(mockUseInView).not.toHaveBeenCalled(); +}); + +it.each([{ user: loggedUser }, { isAuthReady: false }])( + 'does not show or fetch an offer for an ineligible visitor: %j', + (auth) => { + renderWidget({ auth }); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + expect(mockUseInView).not.toHaveBeenCalled(); + }, +); + +it('avoids a second signup card in the sidebar during the experiment', () => { + renderWidget({ inline: false }); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + expect(mockUseInView).not.toHaveBeenCalled(); +}); + +it('falls back to the existing offer for posts without topics', () => { + renderWidget({ inline: false, currentPost: { ...post, tags: [] } }); + expect(screen.getByText('Want your personalized dev feed?')).toBeVisible(); +}); + +it('uses the raw topic before it is visible without fetching previews', () => { + mockUseInView.mockReturnValue([jest.fn(), false]); + mockPreviews(); + renderWidget(); + expect(screen.getByText('Get more posts about #webdev')).toBeVisible(); + expect(nock.pendingMocks()).toHaveLength(2); +}); + +it('shows backend topic titles and public previews while preserving signup', async () => { + mockPreviews(); + renderWidget(); + expect( + await screen.findByText('Get more posts about Web Development'), + ).toBeVisible(); + expect( + await screen.findByRole('link', { name: /practical web/ }), + ).toHaveAttribute('href', examplePost.commentsPermalink); + fireEvent.click(screen.getByRole('button', { name: 'Continue with email' })); + expect(showLogin).toHaveBeenCalledWith({ + trigger: AuthTriggers.PostPage, + options: { + isLogin: false, + defaultDisplay: AuthDisplay.Registration, + formValues: undefined, + }, + }); +}); + +it('uses the underlying article topic on shared posts', async () => { + mockPreviews(); + renderWidget({ + currentPost: { + ...post, + id: 'share', + tags: [], + sharedPost: { ...post, title: 'Shared article', image: '/article.png' }, + }, + }); + expect( + await screen.findByRole('link', { name: /practical web/ }), + ).toBeVisible(); + await waitFor(() => expect(nock.isDone()).toBe(true)); +}); + +it('keeps signup usable when recommendations fail', async () => { + mockPreviews(post, true); + renderWidget(); + await waitFor(() => expect(nock.isDone()).toBe(true)); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Continue with email' }), + ).toBeEnabled(); +}); diff --git a/packages/shared/src/components/post/PostSignupWidget.tsx b/packages/shared/src/components/post/PostSignupWidget.tsx index cdb797f6b68..b3a3e8f99fe 100644 --- a/packages/shared/src/components/post/PostSignupWidget.tsx +++ b/packages/shared/src/components/post/PostSignupWidget.tsx @@ -2,19 +2,55 @@ import type { ReactElement } from 'react'; import React from 'react'; import { useAuthContext } from '../../contexts/AuthContext'; import { useConditionalFeature } from '../../hooks/useConditionalFeature'; -import { featurePostSignupWidget } from '../../lib/featureManagement'; +import { + featurePostSignupWidget, + featurePostTopicSignup, +} from '../../lib/featureManagement'; import { AuthTriggers } from '../../lib/auth'; import { SignupWidget } from '../auth/SignupWidget'; +import type { Post } from '../../graphql/posts'; +import { PostTopicSignup } from './PostTopicSignup'; -export function PostSignupWidget(): ReactElement | null { +interface PostSignupWidgetProps { + post: Post; + inline?: boolean; + className?: string; +} + +export function PostSignupWidget({ + post, + inline = false, + className, +}: PostSignupWidgetProps): ReactElement | null { const { user, isAuthReady } = useAuthContext(); + const article = post.sharedPost ?? post; + const tag = article.tags?.find((value) => value.trim().length > 0); const shouldEvaluate = isAuthReady && !user; + const { value: isTopicEnabled } = useConditionalFeature({ + feature: featurePostTopicSignup, + shouldEvaluate: shouldEvaluate && !!tag, + }); const { value: isEnabled } = useConditionalFeature({ feature: featurePostSignupWidget, - shouldEvaluate, + shouldEvaluate: shouldEvaluate && !inline && !isTopicEnabled, }); - if (!shouldEvaluate || !isEnabled) { + if (!shouldEvaluate) { + return null; + } + + if (isTopicEnabled && tag) { + return inline ? ( + + ) : null; + } + + if (inline || !isEnabled) { return null; } @@ -23,6 +59,7 @@ export function PostSignupWidget(): ReactElement | null { title="Want your personalized dev feed?" description="Millions of developers rely on daily.dev for tech news, tools, and discussions that actually matter." trigger={AuthTriggers.PostPage} + className={className} /> ); } diff --git a/packages/shared/src/components/post/PostTopicSignup.tsx b/packages/shared/src/components/post/PostTopicSignup.tsx new file mode 100644 index 00000000000..6625f25d3c8 --- /dev/null +++ b/packages/shared/src/components/post/PostTopicSignup.tsx @@ -0,0 +1,110 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useInView } from 'react-intersection-observer'; +import type { Post } from '../../graphql/posts'; +import { + POST_TOPIC_SIGNUP_PREVIEW_COUNT, + postTopicSignupQueryOptions, +} from '../../graphql/postTopicSignup'; +import { tagTitlesQueryOptions } from '../../graphql/keywords'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useLogContext } from '../../contexts/LogContext'; +import useLogEventOnce from '../../hooks/log/useLogEventOnce'; +import { AuthTriggers } from '../../lib/auth'; +import { LogEvent, TargetType } from '../../lib/log'; +import { SignupWidget } from '../auth/SignupWidget'; +import Link from '../utilities/Link'; + +interface PostTopicSignupProps { + post: Post; + tag: string; + className?: string; +} + +export function PostTopicSignup({ + post, + tag, + className, +}: PostTopicSignupProps): ReactElement { + const { tokenRefreshed } = useAuthContext(); + const { logEvent } = useLogContext(); + const [ref, inView] = useInView({ triggerOnce: true }); + const enabled = tokenRefreshed && inView; + const { data: titles } = useQuery({ + ...tagTitlesQueryOptions(), + enabled, + }); + const { data } = useQuery({ + ...postTopicSignupQueryOptions(post.id, tag), + enabled, + }); + const topic = titles?.[tag] || `#${tag}`; + const previews = (data?.similarPosts ?? []) + .filter( + (preview, index, posts) => + preview.id !== post.id && + posts.findIndex(({ id }) => id === preview.id) === index, + ) + .slice(0, POST_TOPIC_SIGNUP_PREVIEW_COUNT); + const extra = JSON.stringify({ origin: 'post topic signup', tag }); + + useLogEventOnce( + () => ({ + event_name: LogEvent.Impression, + target_type: TargetType.PostTopicSignup, + target_id: post.id, + extra, + }), + { condition: inView }, + ); + + return ( + + ); +} diff --git a/packages/shared/src/components/post/PostWidgets.tsx b/packages/shared/src/components/post/PostWidgets.tsx index c63abe71173..5f7908ecded 100644 --- a/packages/shared/src/components/post/PostWidgets.tsx +++ b/packages/shared/src/components/post/PostWidgets.tsx @@ -164,7 +164,7 @@ export function PostWidgets({ return ( - {!hideSignupWidget && } + {!hideSignupWidget && } {withAd(PostWidgetPosition.Source, sourceCard)} {withAd( PostWidgetPosition.Creator, diff --git a/packages/shared/src/components/post/SquadPostWidgets.tsx b/packages/shared/src/components/post/SquadPostWidgets.tsx index a90eb5f1088..875fa223fca 100644 --- a/packages/shared/src/components/post/SquadPostWidgets.tsx +++ b/packages/shared/src/components/post/SquadPostWidgets.tsx @@ -37,7 +37,7 @@ export function SquadPostWidgets({ return ( - + {!isUserSource && (isSquadSource ? ( { return ( - + + {showCommunitySentiment && ( diff --git a/packages/shared/src/graphql/postTopicSignup.ts b/packages/shared/src/graphql/postTopicSignup.ts new file mode 100644 index 00000000000..e12e451ccb6 --- /dev/null +++ b/packages/shared/src/graphql/postTopicSignup.ts @@ -0,0 +1,45 @@ +import { gql } from 'graphql-request'; +import type { Post } from './posts'; +import { gqlBatchRequest } from './batch'; +import { StaleTime } from '../lib/query'; + +export const POST_TOPIC_SIGNUP_PREVIEW_COUNT = 2; + +export type PostTopicSignupPreview = Pick< + Post, + 'id' | 'title' | 'commentsPermalink' +> & { + source?: { name: string } | null; +}; + +export interface PostTopicSignupData { + similarPosts: PostTopicSignupPreview[]; +} + +export const POST_TOPIC_SIGNUP_QUERY = gql` + query PostTopicSignup($post: ID!, $tag: String!, $first: Int!) { + similarPosts: randomSimilarPostsByTags( + post: $post + tags: [$tag] + first: $first + ) { + id + title + commentsPermalink + source { + name + } + } + } +`; + +export const postTopicSignupQueryOptions = (postId: string, tag: string) => ({ + queryKey: ['postTopicSignup', postId, tag], + queryFn: () => + gqlBatchRequest(POST_TOPIC_SIGNUP_QUERY, { + post: postId, + tag, + first: POST_TOPIC_SIGNUP_PREVIEW_COUNT, + }), + staleTime: StaleTime.OneHour, +}); diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 813e0a0615f..0cce685d978 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -186,6 +186,7 @@ export const featureOnboardingPersonas = new Feature( ); export const featurePostSignupWidget = new Feature('post_signup_widget', false); +export const featurePostTopicSignup = new Feature('post_topic_signup', false); export const featureShortcutsHub = new Feature('shortcuts_hub_v2', false); diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index a9c2e4fd7bd..b87f8040d48 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -643,6 +643,7 @@ export enum TargetType { AdvertiseHereCta = 'advertise here cta', ExtensionPromo = 'extension promo', ProfileWorldToggle = 'profile world toggle', + PostTopicSignup = 'post topic signup', } export enum TargetId { From ccfda3bb8af5675295552c763587de59ab2a689a Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 18 Sep 2026 12:52:20 +0200 Subject: [PATCH 2/2] style(post): center and refine topic signup offer --- .../src/components/auth/SignupWidget.tsx | 34 ++++++++++++++++--- .../src/components/post/PostTopicSignup.tsx | 11 ++++-- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/components/auth/SignupWidget.tsx b/packages/shared/src/components/auth/SignupWidget.tsx index e8b5826dbb1..222212011b0 100644 --- a/packages/shared/src/components/auth/SignupWidget.tsx +++ b/packages/shared/src/components/auth/SignupWidget.tsx @@ -42,6 +42,7 @@ interface SignupWidgetProps { * ranking without putting the rail into a scroll. The legal strip stays. */ dense?: boolean; + centered?: boolean; className?: string; } @@ -58,6 +59,7 @@ export function SignupWidget({ description, trigger, dense, + centered = false, className, children, }: SignupWidgetProps): ReactElement { @@ -67,10 +69,19 @@ export function SignupWidget({
+ {centered && ( +
+ )}