Skip to content
Open
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
39 changes: 33 additions & 6 deletions packages/shared/src/components/auth/SignupWidget.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
/**
Expand All @@ -41,6 +42,7 @@ interface SignupWidgetProps {
* ranking without putting the rail into a scroll. The legal strip stays.
*/
dense?: boolean;
centered?: boolean;
className?: string;
}

Expand All @@ -57,18 +59,29 @@ export function SignupWidget({
description,
trigger,
dense,
centered = false,
className,
children,
}: SignupWidgetProps): ReactElement {
const { showLogin } = useAuthContext();

return (
<div
className={classNames(
'flex flex-col',
!dense && 'rounded-16 border border-border-subtlest-tertiary p-4',
!dense && 'rounded-16 border border-border-subtlest-tertiary',
!dense && !centered && 'p-4',
centered &&
'relative isolate items-center overflow-hidden bg-background-subtle px-5 py-8 text-center tablet:px-8 tablet:py-10',
className,
)}
>
{centered && (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 -z-1 h-40 bg-gradient-to-b from-theme-overlay-active-cabbage to-transparent"
/>
)}
<style>
{`@keyframes signup-widget-gradient-shift {
0% { background-position: 0% 50%; }
Expand All @@ -79,7 +92,8 @@ export function SignupWidget({
<h3
className={classNames(
'font-bold',
dense ? 'typo-callout' : 'typo-title3',
!centered && (dense ? 'typo-callout' : 'typo-title3'),
centered && 'max-w-lg text-balance typo-title2 tablet:typo-title1',
)}
style={gradientStyle}
>
Expand All @@ -88,19 +102,27 @@ export function SignupWidget({
<p
className={classNames(
'text-text-tertiary',
dense ? 'mt-1 typo-caption1' : 'mt-2 typo-footnote',
!centered && (dense ? 'mt-1 typo-caption1' : 'mt-2 typo-footnote'),
centered && 'mt-3 max-w-sm text-balance typo-callout',
)}
>
{description}
</p>
<div className={dense ? 'mt-3' : 'mt-4'}>
{children}
<div
className={classNames(
centered && 'mt-6 w-full max-w-[26.25rem]',
!centered && (dense ? 'mt-3' : 'mt-4'),
)}
>
<AuthOptions
ignoreMessages
formRef={null as unknown as React.MutableRefObject<HTMLFormElement>}
trigger={trigger}
simplified
defaultDisplay={AuthDisplay.OnboardingSignup}
forceDefaultDisplay
signupStyle={centered ? 'singlePrimary' : undefined}
onAuthStateUpdate={(props) => {
showLogin({
trigger,
Expand All @@ -115,7 +137,12 @@ export function SignupWidget({
variant: ButtonVariant.Primary,
size: dense ? ButtonSize.Small : ButtonSize.Medium,
}}
className={{ container: dense ? denseContainer : undefined }}
className={{
container: classNames(
centered && '!min-h-0 !overflow-visible',
!centered && dense && denseContainer,
),
}}
hideLoginLink
compact
/>
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/components/post/BasePostContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { BasePostContentProps } from './common';
import { PostHeaderActions } from './PostHeaderActions';
import { PostAnsweredQuestions } from './PostAnsweredQuestions';
import { ButtonSize } from '../buttons/common';
import { PostSignupWidget } from './PostSignupWidget';

const Custom404 = dynamic(
() => import(/* webpackChunkName: "custom404" */ '../Custom404'),
Expand Down Expand Up @@ -65,6 +66,7 @@ export function BasePostContent({
</GoBackHeaderMobile>
)}
{children}
{!!post?.id && <PostSignupWidget post={post} inline className="my-6" />}
{isPostPage && <PostAnsweredQuestions post={post} className="mt-6" />}
{aboveComments}
{!!engagementProps && (
Expand Down
197 changes: 197 additions & 0 deletions packages/shared/src/components/post/PostSignupWidget.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
onClick={() =>
onAuthStateUpdate({ defaultDisplay: Display.Registration })
}
>
Continue with email
</button>
);
},
}));

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<AuthContextData>;
} = {}) => {
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(
<TestBootProvider
client={client}
gb={gb}
auth={{ isLoggedIn: false, showLogin, ...auth }}
>
<PostSignupWidget post={currentPost} inline={inline} />
</TestBootProvider>,
);
};

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();
});
45 changes: 41 additions & 4 deletions packages/shared/src/components/post/PostSignupWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
<PostTopicSignup
key={`${article.id}:${tag}`}
post={article}
tag={tag}
className={className}
/>
) : null;
}

if (inline || !isEnabled) {
return null;
}

Expand All @@ -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}
/>
);
}
Loading
Loading