From 79afc9d94e0ee86354141a16b6737e013d402761 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 31 May 2026 23:38:39 +0300 Subject: [PATCH 1/7] feat: improve anonymous post conversion Co-authored-by: Cursor --- .../components/auth/AuthenticationBanner.tsx | 35 +++-- .../components/auth/PostTopicAuthBanner.tsx | 74 ++++++++++ .../components/brand/MentionedToolsWidget.tsx | 23 +++- .../HighlightPostSidebarWidget.spec.tsx | 41 ++++++ .../highlight/HighlightPostSidebarWidget.tsx | 15 +- .../components/post/BuildYourFeedWidget.tsx | 102 ++++++++++++++ .../src/components/post/PostEngagements.tsx | 15 +- .../src/components/post/PostTopicChips.tsx | 30 ++++ .../src/components/post/PostWidgets.spec.tsx | 129 ++++++++++++++++++ .../src/components/post/PostWidgets.tsx | 20 ++- .../src/components/post/SquadPostWidgets.tsx | 17 ++- .../post/anonymousPostExperience.ts | 26 ++++ .../post/collection/CollectionPostWidgets.tsx | 18 ++- .../post/useAnonymousPostExperience.spec.ts | 89 ++++++++++++ .../hooks/post/useAnonymousPostExperience.ts | 22 +++ packages/shared/src/lib/featureManagement.ts | 4 + packages/webapp/pages/posts/[id]/index.tsx | 16 ++- 17 files changed, 632 insertions(+), 44 deletions(-) create mode 100644 packages/shared/src/components/auth/PostTopicAuthBanner.tsx create mode 100644 packages/shared/src/components/post/BuildYourFeedWidget.tsx create mode 100644 packages/shared/src/components/post/PostTopicChips.tsx create mode 100644 packages/shared/src/components/post/PostWidgets.spec.tsx create mode 100644 packages/shared/src/components/post/anonymousPostExperience.ts create mode 100644 packages/shared/src/hooks/post/useAnonymousPostExperience.spec.ts create mode 100644 packages/shared/src/hooks/post/useAnonymousPostExperience.ts diff --git a/packages/shared/src/components/auth/AuthenticationBanner.tsx b/packages/shared/src/components/auth/AuthenticationBanner.tsx index ab8b74e6c58..d8fd030b38b 100644 --- a/packages/shared/src/components/auth/AuthenticationBanner.tsx +++ b/packages/shared/src/components/auth/AuthenticationBanner.tsx @@ -14,20 +14,39 @@ import { cloudinaryAuthBannerBackground1440w as laptopBg, cloudinaryAuthBannerBackground1920w as desktopBg, } from '../../lib/image'; -import { AuthDisplay } from './common'; +import { AuthDisplay, type AuthOptionsProps, type AuthProps } from './common'; +import type { AuthTriggersType } from '../../lib/auth'; const Section = classed('div', 'flex flex-col'); interface AuthenticationBannerProps extends PropsWithChildren { compact?: boolean; + onAuthStateUpdate?: AuthOptionsProps['onAuthStateUpdate']; + targetId?: string; + trigger?: AuthTriggersType; } export function AuthenticationBanner({ children, compact, + onAuthStateUpdate, + targetId, + trigger = AuthTriggers.Onboarding, }: AuthenticationBannerProps): ReactElement { const { showLogin } = useAuthContext(); + const handleAuthStateUpdate = (props: Partial): void => { + onAuthStateUpdate?.(props); + showLogin({ + trigger, + options: { + isLogin: !!props.isLoginFlow, + defaultDisplay: props.defaultDisplay, + formValues: props.email ? { email: props.email } : undefined, + }, + }); + }; + return ( } - trigger={AuthTriggers.Onboarding} + trigger={trigger} simplified defaultDisplay={AuthDisplay.OnboardingSignup} forceDefaultDisplay + targetId={targetId} className={{ onboardingSignup: compact ? '!gap-3' : '!gap-4', }} - onAuthStateUpdate={(props) => { - showLogin({ - trigger: AuthTriggers.Onboarding, - options: { - isLogin: !!props.isLoginFlow, - defaultDisplay: props.defaultDisplay, - formValues: props.email ? { email: props.email } : undefined, - }, - }); - }} + onAuthStateUpdate={handleAuthStateUpdate} onboardingSignupButton={{ variant: ButtonVariant.Primary, }} diff --git a/packages/shared/src/components/auth/PostTopicAuthBanner.tsx b/packages/shared/src/components/auth/PostTopicAuthBanner.tsx new file mode 100644 index 00000000000..49682131fa4 --- /dev/null +++ b/packages/shared/src/components/auth/PostTopicAuthBanner.tsx @@ -0,0 +1,74 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useMemo } from 'react'; +import { useActivePostContext } from '../../contexts/ActivePostContext'; +import useLogEventOnce from '../../hooks/log/useLogEventOnce'; +import { AuthTriggers } from '../../lib/auth'; +import { LogEvent, Origin, TargetType } from '../../lib/log'; +import { useLogContext } from '../../contexts/LogContext'; +import { + getPostTopicLabel, + getPostTopicTags, + getPostTopicTargetId, +} from '../post/anonymousPostExperience'; +import { PostTopicChips } from '../post/PostTopicChips'; +import { AuthenticationBanner } from './AuthenticationBanner'; + +interface PostTopicAuthBannerProps { + compact?: boolean; +} + +export const PostTopicAuthBanner = ({ + compact, +}: PostTopicAuthBannerProps): ReactElement => { + const activePost = useActivePostContext()?.activePost; + const { logEvent } = useLogContext(); + const topics = getPostTopicTags(activePost); + const topicLabel = getPostTopicLabel(topics); + const targetId = getPostTopicTargetId(activePost); + const extra = useMemo( + () => + JSON.stringify({ + origin: Origin.ArticlePage, + post_id: activePost?.id, + surface: 'post_topic_auth_banner', + tags: activePost?.tags?.slice(0, topics.length) ?? [], + }), + [activePost?.id, activePost?.tags, topics.length], + ); + + useLogEventOnce(() => ({ + event_name: LogEvent.Impression, + target_id: 'post_topic_auth_banner', + target_type: TargetType.ArticleAnonymousCTA, + extra, + })); + + const onAuthStateUpdate = useCallback(() => { + logEvent({ + event_name: LogEvent.ClickArticleAnonymousCTA, + target_id: 'post_topic_auth_banner', + target_type: TargetType.ArticleAnonymousCTA, + extra, + }); + }, [extra, logEvent]); + + return ( + +
+

+ Build a feed around {topicLabel} +

+

+ daily.dev turns this post into a personalized stream of stories, + discussions, and tools from developers who care about the same topics. +

+ +
+
+ ); +}; diff --git a/packages/shared/src/components/brand/MentionedToolsWidget.tsx b/packages/shared/src/components/brand/MentionedToolsWidget.tsx index 46485c0c5d8..b8dfc24ae0b 100644 --- a/packages/shared/src/components/brand/MentionedToolsWidget.tsx +++ b/packages/shared/src/components/brand/MentionedToolsWidget.tsx @@ -34,6 +34,7 @@ interface Tool { interface MentionedToolsWidgetProps { postTags: string[]; className?: string; + compact?: boolean; } /** @@ -45,6 +46,7 @@ interface MentionedToolsWidgetProps { export const MentionedToolsWidget = ({ postTags, className, + compact, }: MentionedToolsWidgetProps): ReactElement | null => { const router = useRouter(); const { user, showLogin } = useAuthContext(); @@ -167,18 +169,20 @@ export const MentionedToolsWidget = ({ return null; } + const visibleTools = compact ? mentionedTools.slice(0, 2) : mentionedTools; const highlightedWordResult = getHighlightedWordConfig(postTags); return ( <>
@@ -186,7 +190,7 @@ export const MentionedToolsWidget = ({
- {mentionedTools.map((tool) => { + {visibleTools.map((tool) => { const isSponsored = tool.isSponsored && hasAnySponsoredTag(postTags); const isInStack = isToolInStack(tool.name); @@ -201,7 +205,10 @@ export const MentionedToolsWidget = ({ ? () => handleSponsoredToolHover(tool.name) : undefined } - className="group flex h-12 w-full cursor-pointer items-center justify-between gap-3 rounded-12 px-3 text-left transition-colors hover:bg-surface-hover" + className={classNames( + 'group flex w-full cursor-pointer items-center justify-between gap-3 rounded-12 px-3 text-left transition-colors hover:bg-surface-hover', + compact ? 'h-10' : 'h-12', + )} >
{tool.icon ? ( @@ -274,6 +281,14 @@ export const MentionedToolsWidget = ({ return toolItem; })} + {compact && mentionedTools.length > visibleTools.length && ( + + +{mentionedTools.length - visibleTools.length} more tools + + )}
diff --git a/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.spec.tsx index 214811cb8d0..03a3c338e1c 100644 --- a/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.spec.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.spec.tsx @@ -13,6 +13,10 @@ import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; import { useLogContext } from '../../../contexts/LogContext'; import { gqlClient } from '../../../graphql/common'; import { ONE_HOUR } from '../../../lib/time'; +import { + featureAnonymousPostExperience, + featurePostPageHighlights, +} from '../../../lib/featureManagement'; jest.mock('../../../lib/constants', () => ({ webappUrl: '/', @@ -161,6 +165,43 @@ describe('HighlightPostSidebarWidget', () => { ).not.toBeInTheDocument(); }); + it('renders for anonymous users when the anonymous post experience is enabled', async () => { + mockUseAuthContext.mockReturnValue({ + isAuthReady: true, + user: undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + mockUseConditionalFeature.mockImplementation( + ({ feature, shouldEvaluate }) => { + if (feature.id === featureAnonymousPostExperience.id) { + return { + value: !!shouldEvaluate, + isLoading: false, + }; + } + + if (feature.id === featurePostPageHighlights.id) { + return { + value: false, + isLoading: false, + }; + } + + return { + value: false, + isLoading: false, + }; + }, + ); + mockGqlRequest.mockResolvedValue( + buildResponse([buildHighlight('h1', 'Anonymous headline')]), + ); + + renderWidget(); + + expect(await screen.findByText('Anonymous headline')).toBeInTheDocument(); + }); + it('points "Read all" to /highlights with the first highlight id', async () => { mockGqlRequest.mockResolvedValue( buildResponse([buildHighlight('first', 'First headline')]), diff --git a/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx index 8717308efd0..d3c38bed647 100644 --- a/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx @@ -18,11 +18,13 @@ import { LogEvent, Origin } from '../../../lib/log'; import { feedHighlightsLogEvent } from '../../../lib/feed'; import useLogEventOnce from '../../../hooks/log/useLogEventOnce'; import { ONE_HOUR, ONE_MINUTE } from '../../../lib/time'; +import { useAnonymousPostExperience } from '../../../hooks/post/useAnonymousPostExperience'; const HIGHLIGHTS_LIMIT = 10; const ROTATION_INTERVAL_MS = 6000; const FADE_DURATION_MS = 500; const FEED_NAME = 'post-page-highlights'; +const ANONYMOUS_FEED_NAME = 'anonymous-post-page-highlights'; const MAX_HIGHLIGHT_AGE_MS = 24 * ONE_HOUR; const prefersReducedMotion = (): boolean => { @@ -35,14 +37,17 @@ const prefersReducedMotion = (): boolean => { export const HighlightPostSidebarWidget = (): ReactElement | null => { const { user } = useAuthContext(); const { logEvent } = useLogContext(); - const { value: isEnabled } = useConditionalFeature({ + const { isAnonPostExperience } = useAnonymousPostExperience(); + const { value: isHighlightsEnabled } = useConditionalFeature({ feature: featurePostPageHighlights, shouldEvaluate: !!user, }); + const isEnabled = isHighlightsEnabled || isAnonPostExperience; + const feedName = isAnonPostExperience ? ANONYMOUS_FEED_NAME : FEED_NAME; const { data } = useQuery({ ...majorHeadlinesQueryOptions({ first: HIGHLIGHTS_LIMIT }), - enabled: isEnabled && !!user, + enabled: isEnabled && (!!user || isAnonPostExperience), refetchInterval: ONE_MINUTE, }); @@ -63,7 +68,7 @@ export const HighlightPostSidebarWidget = (): ReactElement | null => { useLogEventOnce( () => feedHighlightsLogEvent(LogEvent.Impression, { - feedName: FEED_NAME, + feedName, action: 'impression', count: highlights.length, highlightIds: highlights.map((h) => h.id), @@ -123,7 +128,7 @@ export const HighlightPostSidebarWidget = (): ReactElement | null => { const onHighlightClick = () => { logEvent( feedHighlightsLogEvent(LogEvent.Click, { - feedName: FEED_NAME, + feedName, action: 'highlight_click', position: index + 1, count: highlights.length, @@ -137,7 +142,7 @@ export const HighlightPostSidebarWidget = (): ReactElement | null => { const onReadAllClick = () => { logEvent( feedHighlightsLogEvent(LogEvent.Click, { - feedName: FEED_NAME, + feedName, action: 'read_all_click', count: highlights.length, highlightIds: highlights.map((h) => h.id), diff --git a/packages/shared/src/components/post/BuildYourFeedWidget.tsx b/packages/shared/src/components/post/BuildYourFeedWidget.tsx new file mode 100644 index 00000000000..38e19df8b85 --- /dev/null +++ b/packages/shared/src/components/post/BuildYourFeedWidget.tsx @@ -0,0 +1,102 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useMemo } from 'react'; +import { useActivePostContext } from '../../contexts/ActivePostContext'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useLogContext } from '../../contexts/LogContext'; +import useLogEventOnce from '../../hooks/log/useLogEventOnce'; +import { AuthTriggers } from '../../lib/auth'; +import { LogEvent, Origin, TargetType } from '../../lib/log'; +import { ButtonSize, ButtonVariant } from '../buttons/Button'; +import AuthOptions from '../auth/AuthOptions'; +import { AuthDisplay, type AuthProps } from '../auth/common'; +import { WidgetContainer } from '../widgets/common'; +import { + getPostTopicLabel, + getPostTopicTags, + getPostTopicTargetId, +} from './anonymousPostExperience'; +import { PostTopicChips } from './PostTopicChips'; + +export const BuildYourFeedWidget = (): ReactElement => { + const activePost = useActivePostContext()?.activePost; + const { showLogin } = useAuthContext(); + const { logEvent } = useLogContext(); + const topics = getPostTopicTags(activePost); + const topicLabel = getPostTopicLabel(topics); + const targetId = getPostTopicTargetId(activePost); + const extra = useMemo( + () => + JSON.stringify({ + origin: Origin.ArticlePage, + post_id: activePost?.id, + surface: 'build_your_feed_widget', + tags: activePost?.tags?.slice(0, topics.length) ?? [], + }), + [activePost?.id, activePost?.tags, topics.length], + ); + + useLogEventOnce(() => ({ + event_name: LogEvent.Impression, + target_id: 'build_your_feed_widget', + target_type: TargetType.ArticleAnonymousCTA, + extra, + })); + + const onAuthStateUpdate = useCallback( + (props: Partial) => { + logEvent({ + event_name: LogEvent.ClickArticleAnonymousCTA, + target_id: 'build_your_feed_widget', + target_type: TargetType.ArticleAnonymousCTA, + extra, + }); + + showLogin({ + trigger: AuthTriggers.PostPage, + options: { + isLogin: !!props.isLoginFlow, + defaultDisplay: props.defaultDisplay, + formValues: props.email ? { email: props.email } : undefined, + }, + }); + }, + [extra, logEvent, showLogin], + ); + + return ( + +
+

+ Get a feed for {topicLabel} +

+

+ Sign up to turn this post into a daily.dev feed tuned to your stack, + interests, and the developers discussing them right now. +

+
+ + } + hideLoginLink + ignoreMessages + onAuthStateUpdate={onAuthStateUpdate} + onboardingSignupButton={{ + size: ButtonSize.Medium, + variant: ButtonVariant.Primary, + }} + simplified + targetId={targetId} + trigger={AuthTriggers.PostPage} + className={{ + onboardingSignup: '!gap-3', + }} + /> +
+ ); +}; diff --git a/packages/shared/src/components/post/PostEngagements.tsx b/packages/shared/src/components/post/PostEngagements.tsx index b3797e253c2..32e7eced5f5 100644 --- a/packages/shared/src/components/post/PostEngagements.tsx +++ b/packages/shared/src/components/post/PostEngagements.tsx @@ -32,6 +32,7 @@ import { usePlusSubscription } from '../../hooks/usePlusSubscription'; import SocialBar from '../cards/socials/SocialBar'; import { PostContentReminder } from './common/PostContentReminder'; import { useSettingsContext } from '../../contexts/SettingsContext'; +import { useAnonymousPostExperience } from '../../hooks/post/useAnonymousPostExperience'; const AuthorOnboarding = dynamic( () => import(/* webpackChunkName: "authorOnboarding" */ './AuthorOnboarding'), @@ -63,7 +64,8 @@ function PostEngagements({ useSettingsContext(); const { user, showLogin } = useAuthContext(); const { isPlus } = usePlusSubscription(); - const commentRef = useRef(); + const { isAnonPostExperience } = useAnonymousPostExperience(); + const commentRef = useRef(null); const [authorOnboarding, setAuthorOnboarding] = useState(false); const [permissionNotificationCommentId, setPermissionNotificationCommentId] = useState(); @@ -91,6 +93,7 @@ function PostEngagements({ setPermissionNotificationCommentId(comment.id); if ( + post.source && isSourcePublicSquad(post.source) && !post.source?.currentMember && !isJoinSquadBannerDismissed @@ -136,7 +139,9 @@ function PostEngagements({ icon={ } onClick={() => @@ -161,7 +166,7 @@ function PostEngagements({ shouldHandleCommentQuery CommentInputOrModal={CommentInputOrModal} /> - {!isPlus && } + {!isPlus && !isAnonPostExperience && } showLogin({ trigger: AuthTriggers.Author })) + !user + ? () => showLogin({ trigger: AuthTriggers.Author }) + : undefined } /> )} diff --git a/packages/shared/src/components/post/PostTopicChips.tsx b/packages/shared/src/components/post/PostTopicChips.tsx new file mode 100644 index 00000000000..5fb579eda19 --- /dev/null +++ b/packages/shared/src/components/post/PostTopicChips.tsx @@ -0,0 +1,30 @@ +import classNames from 'classnames'; +import type { ReactElement } from 'react'; +import React from 'react'; + +interface PostTopicChipsProps { + topics: string[]; + className?: string; +} + +export const PostTopicChips = ({ + topics, + className, +}: PostTopicChipsProps): ReactElement | null => { + if (!topics.length) { + return null; + } + + return ( +
+ {topics.map((topic) => ( + + {topic} + + ))} +
+ ); +}; diff --git a/packages/shared/src/components/post/PostWidgets.spec.tsx b/packages/shared/src/components/post/PostWidgets.spec.tsx new file mode 100644 index 00000000000..587f207f2d9 --- /dev/null +++ b/packages/shared/src/components/post/PostWidgets.spec.tsx @@ -0,0 +1,129 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import AuthContext from '../../contexts/AuthContext'; +import type { Post } from '../../graphql/posts'; +import { useAnonymousPostExperience } from '../../hooks/post/useAnonymousPostExperience'; +import { Origin } from '../../lib/log'; +import { PostWidgets } from './PostWidgets'; + +jest.mock('../../hooks/post/useAnonymousPostExperience', () => ({ + useAnonymousPostExperience: jest.fn(), +})); + +jest.mock('./BuildYourFeedWidget', () => ({ + BuildYourFeedWidget: (): ReactElement => ( +
+ ), +})); + +jest.mock('./PostSignupWidget', () => ({ + PostSignupWidget: (): ReactElement => ( +
+ ), +})); + +jest.mock('./PostSidebarAdWidget', () => ({ + PostSidebarAdWidget: (): ReactElement => ( +
+ ), +})); + +jest.mock('../brand/MentionedToolsWidget', () => ({ + MentionedToolsWidget: ({ compact }: { compact?: boolean }): ReactElement => ( +
+ ), +})); + +jest.mock('../ShareBar', () => ({ + __esModule: true, + default: (): ReactElement =>
, +})); + +jest.mock('../ShareMobile', () => ({ + ShareMobile: (): ReactElement =>
, +})); + +jest.mock('../cards/highlight/HighlightPostSidebarWidget', () => ({ + HighlightPostSidebarWidget: (): ReactElement => ( +
+ ), +})); + +jest.mock('../widgets/FeaturedArchives', () => ({ + FeaturedArchives: (): ReactElement =>
, +})); + +jest.mock('../footer', () => ({ + FooterLinks: (): ReactElement =>
, +})); + +const mockUseAnonymousPostExperience = jest.mocked(useAnonymousPostExperience); + +const post = { + id: 'p1', + tags: ['react', 'typescript'], + commentsPermalink: '/posts/p1#comments', +} as Post; + +const renderComponent = (): void => { + render( + + + , + ); +}; + +describe('PostWidgets', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows the topic feed widget and hides low-intent widgets in the anonymous experience', () => { + mockUseAnonymousPostExperience.mockReturnValue({ + isAnonPostExperience: true, + isLoading: false, + }); + + renderComponent(); + + expect(screen.getByTestId('build-your-feed-widget')).toBeInTheDocument(); + expect(screen.queryByTestId('post-signup-widget')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('post-sidebar-ad-widget'), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('featured-archives')).not.toBeInTheDocument(); + expect(screen.getByTestId('tools-widget')).toHaveAttribute( + 'data-compact', + 'true', + ); + }); + + it('keeps the existing widgets outside the anonymous experience', () => { + mockUseAnonymousPostExperience.mockReturnValue({ + isAnonPostExperience: false, + isLoading: false, + }); + + renderComponent(); + + expect(screen.getByTestId('post-signup-widget')).toBeInTheDocument(); + expect(screen.getByTestId('post-sidebar-ad-widget')).toBeInTheDocument(); + expect(screen.getByTestId('featured-archives')).toBeInTheDocument(); + expect(screen.getByTestId('tools-widget')).toHaveAttribute( + 'data-compact', + 'false', + ); + }); +}); diff --git a/packages/shared/src/components/post/PostWidgets.tsx b/packages/shared/src/components/post/PostWidgets.tsx index 27bf7aa79c4..846b2e90f8a 100644 --- a/packages/shared/src/components/post/PostWidgets.tsx +++ b/packages/shared/src/components/post/PostWidgets.tsx @@ -18,6 +18,8 @@ import { FeaturedArchives } from '../widgets/FeaturedArchives'; import { MentionedToolsWidget } from '../brand/MentionedToolsWidget'; import { PostSignupWidget } from './PostSignupWidget'; import { HighlightPostSidebarWidget } from '../cards/highlight/HighlightPostSidebarWidget'; +import { useAnonymousPostExperience } from '../../hooks/post/useAnonymousPostExperience'; +import { BuildYourFeedWidget } from './BuildYourFeedWidget'; const UserEntityCard = dynamic( /* webpackChunkName: "userEntityCard" */ () => @@ -53,6 +55,7 @@ export function PostWidgets({ origin, }: PostWidgetsProps): ReactElement { const { tokenRefreshed } = useContext(AuthContext); + const { isAnonPostExperience } = useAnonymousPostExperience(); const { source } = post; const cardClasses = 'w-full bg-transparent'; @@ -83,7 +86,7 @@ export function PostWidgets({ return ( - + {isAnonPostExperience ? : } {sourceCard} {creator && ( )} - + )} + - {tokenRefreshed && } - + {!isAnonPostExperience && } ); diff --git a/packages/shared/src/components/post/SquadPostWidgets.tsx b/packages/shared/src/components/post/SquadPostWidgets.tsx index d209c32c9bc..cebc516daff 100644 --- a/packages/shared/src/components/post/SquadPostWidgets.tsx +++ b/packages/shared/src/components/post/SquadPostWidgets.tsx @@ -18,6 +18,8 @@ import { PostSidebarAdWidget } from './PostSidebarAdWidget'; import { FeaturedArchives } from '../widgets/FeaturedArchives'; import { PostSignupWidget } from './PostSignupWidget'; import { HighlightPostSidebarWidget } from '../cards/highlight/HighlightPostSidebarWidget'; +import { useAnonymousPostExperience } from '../../hooks/post/useAnonymousPostExperience'; +import { BuildYourFeedWidget } from './BuildYourFeedWidget'; export function SquadPostWidgets({ onCopyPostLink, @@ -26,6 +28,7 @@ export function SquadPostWidgets({ className, }: PostWidgetsProps): ReactElement { const { tokenRefreshed } = useContext(AuthContext); + const { isAnonPostExperience } = useAnonymousPostExperience(); const { source } = post; const isUserSource = source ? isSourceUserSource(source) : false; const isSquadSource = source?.type === SourceType.Squad; @@ -36,7 +39,7 @@ export function SquadPostWidgets({ return ( - + {isAnonPostExperience ? : } {!isUserSource && (isSquadSource ? ( )} - + {!isAnonPostExperience && ( + + )} {canShare && ( <> @@ -79,7 +84,7 @@ export function SquadPostWidgets({ )} {tokenRefreshed && } - + {!isAnonPostExperience && } ); diff --git a/packages/shared/src/components/post/anonymousPostExperience.ts b/packages/shared/src/components/post/anonymousPostExperience.ts new file mode 100644 index 00000000000..9001df8a5c9 --- /dev/null +++ b/packages/shared/src/components/post/anonymousPostExperience.ts @@ -0,0 +1,26 @@ +import type { Post } from '../../graphql/posts'; +import { formatKeyword } from '../../lib/strings'; + +export const anonymousPostExperienceTagLimit = 3; + +export const getPostTopicTags = ( + post?: Pick, + limit = anonymousPostExperienceTagLimit, +): string[] => { + return (post?.tags ?? []) + .filter((tag): tag is string => !!tag) + .slice(0, limit) + .map(formatKeyword); +}; + +export const getPostTopicLabel = (topics: string[]): string => { + if (!topics.length) { + return 'the tech you care about'; + } + + return topics.join(', '); +}; + +export const getPostTopicTargetId = ( + post?: Pick, +): string | undefined => (post?.tags?.length ? post.tags.join(',') : post?.id); diff --git a/packages/shared/src/components/post/collection/CollectionPostWidgets.tsx b/packages/shared/src/components/post/collection/CollectionPostWidgets.tsx index fe2605c75bc..da0d8a46d8b 100644 --- a/packages/shared/src/components/post/collection/CollectionPostWidgets.tsx +++ b/packages/shared/src/components/post/collection/CollectionPostWidgets.tsx @@ -12,6 +12,8 @@ import { PostSidebarAdWidget } from '../PostSidebarAdWidget'; import { FeaturedArchives } from '../../widgets/FeaturedArchives'; import { PostSignupWidget } from '../PostSignupWidget'; import { HighlightPostSidebarWidget } from '../../cards/highlight/HighlightPostSidebarWidget'; +import { useAnonymousPostExperience } from '../../../hooks/post/useAnonymousPostExperience'; +import { BuildYourFeedWidget } from '../BuildYourFeedWidget'; export const CollectionPostWidgets = ({ onCopyPostLink, @@ -19,18 +21,22 @@ export const CollectionPostWidgets = ({ origin, className, }: PostWidgetsProps): ReactElement => { + const { isAnonPostExperience } = useAnonymousPostExperience(); + return ( - + {isAnonPostExperience ? : } - + {!isAnonPostExperience && ( + + )} - + {!isAnonPostExperience && } ); diff --git a/packages/shared/src/hooks/post/useAnonymousPostExperience.spec.ts b/packages/shared/src/hooks/post/useAnonymousPostExperience.spec.ts new file mode 100644 index 00000000000..5e23b5317a6 --- /dev/null +++ b/packages/shared/src/hooks/post/useAnonymousPostExperience.spec.ts @@ -0,0 +1,89 @@ +import { renderHook } from '@testing-library/react'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useConditionalFeature } from '../useConditionalFeature'; +import { useAnonymousPostExperience } from './useAnonymousPostExperience'; +import loggedUser from '../../../__tests__/fixture/loggedUser'; + +jest.mock('../../contexts/AuthContext', () => ({ + useAuthContext: jest.fn(), +})); + +jest.mock('../useConditionalFeature', () => ({ + useConditionalFeature: jest.fn(), +})); + +const mockUseAuthContext = jest.mocked(useAuthContext); +const mockUseConditionalFeature = jest.mocked(useConditionalFeature); + +describe('useAnonymousPostExperience', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const setFeature = (value: boolean): void => { + mockUseConditionalFeature.mockImplementation(({ shouldEvaluate }) => ({ + value: shouldEvaluate ? value : false, + isLoading: false, + })); + }; + + it('skips evaluation when auth is not ready', () => { + mockUseAuthContext.mockReturnValue({ + isAuthReady: false, + user: undefined, + } as never); + setFeature(true); + + const { result } = renderHook(() => useAnonymousPostExperience()); + + expect(result.current.isAnonPostExperience).toBe(false); + expect(mockUseConditionalFeature.mock.calls[0][0].shouldEvaluate).toBe( + false, + ); + }); + + it('skips evaluation for logged-in users', () => { + mockUseAuthContext.mockReturnValue({ + isAuthReady: true, + user: loggedUser, + } as never); + setFeature(true); + + const { result } = renderHook(() => useAnonymousPostExperience()); + + expect(result.current.isAnonPostExperience).toBe(false); + expect(mockUseConditionalFeature.mock.calls[0][0].shouldEvaluate).toBe( + false, + ); + }); + + it('returns true for anonymous users when the flag is enabled', () => { + mockUseAuthContext.mockReturnValue({ + isAuthReady: true, + user: undefined, + } as never); + setFeature(true); + + const { result } = renderHook(() => useAnonymousPostExperience()); + + expect(result.current.isAnonPostExperience).toBe(true); + expect(mockUseConditionalFeature.mock.calls[0][0].shouldEvaluate).toBe( + true, + ); + }); + + it('returns false for anonymous users when the flag is disabled', () => { + mockUseAuthContext.mockReturnValue({ + isAuthReady: true, + user: undefined, + } as never); + setFeature(false); + + const { result } = renderHook(() => useAnonymousPostExperience()); + + expect(result.current.isAnonPostExperience).toBe(false); + expect(mockUseConditionalFeature.mock.calls[0][0].shouldEvaluate).toBe( + true, + ); + }); +}); diff --git a/packages/shared/src/hooks/post/useAnonymousPostExperience.ts b/packages/shared/src/hooks/post/useAnonymousPostExperience.ts new file mode 100644 index 00000000000..5369da56398 --- /dev/null +++ b/packages/shared/src/hooks/post/useAnonymousPostExperience.ts @@ -0,0 +1,22 @@ +import { useAuthContext } from '../../contexts/AuthContext'; +import { featureAnonymousPostExperience } from '../../lib/featureManagement'; +import { useConditionalFeature } from '../useConditionalFeature'; + +interface UseAnonymousPostExperience { + isAnonPostExperience: boolean; + isLoading: boolean; +} + +export const useAnonymousPostExperience = (): UseAnonymousPostExperience => { + const { isAuthReady, user } = useAuthContext(); + const shouldEvaluate = isAuthReady && !user; + const { value: isEnabled, isLoading } = useConditionalFeature({ + feature: featureAnonymousPostExperience, + shouldEvaluate, + }); + + return { + isAnonPostExperience: shouldEvaluate && isEnabled, + isLoading, + }; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 132e1e71734..7c2faa19d10 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -36,6 +36,10 @@ export const featurePostPageHighlights = new Feature( 'post_page_highlights', false, ); +export const featureAnonymousPostExperience = new Feature( + 'anonymous_post_experience', + false, +); // @ts-expect-error stale feature without default export const plusTakeoverContent = new Feature<{ diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index fce81b9431d..682d087d756 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -48,6 +48,7 @@ import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; import useDebounceFn from '@dailydotdev/shared/src/hooks/useDebounceFn'; import { useEngagementAdsContext } from '@dailydotdev/shared/src/contexts/EngagementAdsContext'; import { CompanionDemoWidget } from '@dailydotdev/shared/src/components/post/CompanionDemoWidget'; +import { useAnonymousPostExperience } from '@dailydotdev/shared/src/hooks/post/useAnonymousPostExperience'; import { getPageSeoTitles } from '../../../components/layouts/utils'; import { getLayout } from '../../../components/layouts/MainLayout'; import FooterNavBarLayout from '../../../components/layouts/FooterNavBarLayout'; @@ -92,6 +93,12 @@ const PostAuthBanner = dynamic(() => ).then((module) => module.PostAuthBanner), ); +const PostTopicAuthBanner = dynamic(() => + import( + /* webpackChunkName: "postTopicAuthBanner" */ '@dailydotdev/shared/src/components/auth/PostTopicAuthBanner' + ).then((module) => module.PostTopicAuthBanner), +); + const BriefPostContent = dynamic(() => import( /* webpackChunkName: "lazyBriefPostContent" */ '@dailydotdev/shared/src/components/post/brief/BriefPostContent' @@ -186,6 +193,7 @@ export const PostPage = ({ const router = useRouter(); const isFallback = false; const { shouldShowAuthBanner } = useOnboardingActions(); + const { isAnonPostExperience } = useAnonymousPostExperience(); const isLaptop = useViewSize(ViewSize.Laptop); const { post, isError, isLoading } = usePostById({ id, @@ -288,7 +296,13 @@ export const PostPage = ({ }, }} /> - {shouldShowAuthBanner && isLaptop && } + {shouldShowAuthBanner && + isLaptop && + (isAnonPostExperience ? ( + + ) : ( + + ))} From 782b775aeb8bc625c8619ae3aa7a64d581b61e03 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 31 May 2026 23:51:40 +0300 Subject: [PATCH 2/7] chore: enable anonymous post experience Co-authored-by: Cursor --- packages/shared/src/lib/featureManagement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 7c2faa19d10..8291a43ef04 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -38,7 +38,7 @@ export const featurePostPageHighlights = new Feature( ); export const featureAnonymousPostExperience = new Feature( 'anonymous_post_experience', - false, + true, ); // @ts-expect-error stale feature without default From 1f382c0baace2f046170fb6acb19a40f3d7b2ddf Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 1 Jun 2026 00:00:59 +0300 Subject: [PATCH 3/7] fix: show post conversion layout by default Co-authored-by: Cursor --- .../HighlightPostSidebarWidget.spec.tsx | 71 +++---------------- .../highlight/HighlightPostSidebarWidget.tsx | 12 +--- .../src/components/post/PostEngagements.tsx | 6 +- .../src/components/post/PostWidgets.spec.tsx | 19 +++-- .../src/components/post/PostWidgets.tsx | 12 ++-- .../src/components/post/SquadPostWidgets.tsx | 10 +-- .../post/collection/CollectionPostWidgets.tsx | 10 +-- .../post/useAnonymousPostExperience.spec.ts | 49 ++----------- .../hooks/post/useAnonymousPostExperience.ts | 13 +--- 9 files changed, 50 insertions(+), 152 deletions(-) diff --git a/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.spec.tsx index 03a3c338e1c..6034850567b 100644 --- a/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.spec.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.spec.tsx @@ -8,33 +8,26 @@ import { } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { HighlightPostSidebarWidget } from './HighlightPostSidebarWidget'; -import { useAuthContext } from '../../../contexts/AuthContext'; -import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; import { useLogContext } from '../../../contexts/LogContext'; import { gqlClient } from '../../../graphql/common'; import { ONE_HOUR } from '../../../lib/time'; -import { - featureAnonymousPostExperience, - featurePostPageHighlights, -} from '../../../lib/featureManagement'; +import { useAnonymousPostExperience } from '../../../hooks/post/useAnonymousPostExperience'; jest.mock('../../../lib/constants', () => ({ webappUrl: '/', isDevelopment: false, })); -jest.mock('../../../contexts/AuthContext'); -jest.mock('../../../hooks/useConditionalFeature'); jest.mock('../../../contexts/LogContext'); +jest.mock('../../../hooks/post/useAnonymousPostExperience'); jest.mock('../../../graphql/common', () => ({ gqlClient: { request: jest.fn(), }, })); -const mockUseAuthContext = jest.mocked(useAuthContext); -const mockUseConditionalFeature = jest.mocked(useConditionalFeature); const mockUseLogContext = jest.mocked(useLogContext); +const mockUseAnonymousPostExperience = jest.mocked(useAnonymousPostExperience); const mockGqlRequest = jest.mocked(gqlClient.request); const buildHighlight = (id: string, headline: string, hoursAgo = 1) => ({ @@ -72,13 +65,9 @@ describe('HighlightPostSidebarWidget', () => { beforeEach(() => { jest.clearAllMocks(); mockGqlRequest.mockReset(); - mockUseAuthContext.mockReturnValue({ - user: { id: 'u1' }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); - mockUseConditionalFeature.mockReturnValue({ - value: true, - isLoading: false, + mockUseAnonymousPostExperience.mockReturnValue({ + isAnonPostExperience: false, + isPostPageExperience: true, }); mockUseLogContext.mockReturnValue({ logEvent, @@ -148,51 +137,11 @@ describe('HighlightPostSidebarWidget', () => { ).not.toBeInTheDocument(); }); - it('returns null when feature flag is off', () => { - mockUseConditionalFeature.mockReturnValue({ - value: false, - isLoading: false, + it('renders for anonymous users by default', async () => { + mockUseAnonymousPostExperience.mockReturnValue({ + isAnonPostExperience: true, + isPostPageExperience: true, }); - mockGqlRequest.mockResolvedValue( - buildResponse([buildHighlight('h1', 'Hidden headline')]), - ); - - renderWidget(); - - expect(screen.queryByText('Happening Now')).not.toBeInTheDocument(); - expect( - screen.queryByTestId('postPageHighlightWidget'), - ).not.toBeInTheDocument(); - }); - - it('renders for anonymous users when the anonymous post experience is enabled', async () => { - mockUseAuthContext.mockReturnValue({ - isAuthReady: true, - user: undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); - mockUseConditionalFeature.mockImplementation( - ({ feature, shouldEvaluate }) => { - if (feature.id === featureAnonymousPostExperience.id) { - return { - value: !!shouldEvaluate, - isLoading: false, - }; - } - - if (feature.id === featurePostPageHighlights.id) { - return { - value: false, - isLoading: false, - }; - } - - return { - value: false, - isLoading: false, - }; - }, - ); mockGqlRequest.mockResolvedValue( buildResponse([buildHighlight('h1', 'Anonymous headline')]), ); diff --git a/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx index d3c38bed647..544488bae8e 100644 --- a/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx @@ -10,9 +10,6 @@ import { } from '../../../graphql/highlights'; import { RelativeTime } from '../../utilities/RelativeTime'; import Link from '../../utilities/Link'; -import { useAuthContext } from '../../../contexts/AuthContext'; -import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; -import { featurePostPageHighlights } from '../../../lib/featureManagement'; import { useLogContext } from '../../../contexts/LogContext'; import { LogEvent, Origin } from '../../../lib/log'; import { feedHighlightsLogEvent } from '../../../lib/feed'; @@ -35,19 +32,12 @@ const prefersReducedMotion = (): boolean => { }; export const HighlightPostSidebarWidget = (): ReactElement | null => { - const { user } = useAuthContext(); const { logEvent } = useLogContext(); const { isAnonPostExperience } = useAnonymousPostExperience(); - const { value: isHighlightsEnabled } = useConditionalFeature({ - feature: featurePostPageHighlights, - shouldEvaluate: !!user, - }); - const isEnabled = isHighlightsEnabled || isAnonPostExperience; const feedName = isAnonPostExperience ? ANONYMOUS_FEED_NAME : FEED_NAME; const { data } = useQuery({ ...majorHeadlinesQueryOptions({ first: HIGHLIGHTS_LIMIT }), - enabled: isEnabled && (!!user || isAnonPostExperience), refetchInterval: ONE_MINUTE, }); @@ -62,7 +52,7 @@ export const HighlightPostSidebarWidget = (): ReactElement | null => { const fadeOutTimeoutRef = useRef | null>(null); const hasHighlights = highlights.length > 0; - const shouldRender = isEnabled && hasHighlights; + const shouldRender = hasHighlights; const canRotate = shouldRender && highlights.length > 1 && !isPaused; useLogEventOnce( diff --git a/packages/shared/src/components/post/PostEngagements.tsx b/packages/shared/src/components/post/PostEngagements.tsx index 32e7eced5f5..fd552b0d911 100644 --- a/packages/shared/src/components/post/PostEngagements.tsx +++ b/packages/shared/src/components/post/PostEngagements.tsx @@ -28,7 +28,6 @@ import { AdAsComment } from '../comments/AdAsComment'; import { Typography, TypographyType } from '../typography/Typography'; import { Button, ButtonIconPosition, ButtonSize } from '../buttons/Button'; import { TimeSortIcon } from '../icons/Sort/Time'; -import { usePlusSubscription } from '../../hooks/usePlusSubscription'; import SocialBar from '../cards/socials/SocialBar'; import { PostContentReminder } from './common/PostContentReminder'; import { useSettingsContext } from '../../contexts/SettingsContext'; @@ -63,8 +62,7 @@ function PostEngagements({ const { sortCommentsBy: sortBy, updateSortCommentsBy: setSortBy } = useSettingsContext(); const { user, showLogin } = useAuthContext(); - const { isPlus } = usePlusSubscription(); - const { isAnonPostExperience } = useAnonymousPostExperience(); + const { isPostPageExperience } = useAnonymousPostExperience(); const commentRef = useRef(null); const [authorOnboarding, setAuthorOnboarding] = useState(false); const [permissionNotificationCommentId, setPermissionNotificationCommentId] = @@ -166,7 +164,7 @@ function PostEngagements({ shouldHandleCommentQuery CommentInputOrModal={CommentInputOrModal} /> - {!isPlus && !isAnonPostExperience && } + {!isPostPageExperience && } { it('shows the topic feed widget and hides low-intent widgets in the anonymous experience', () => { mockUseAnonymousPostExperience.mockReturnValue({ isAnonPostExperience: true, - isLoading: false, + isPostPageExperience: true, }); renderComponent(); @@ -110,20 +110,25 @@ describe('PostWidgets', () => { ); }); - it('keeps the existing widgets outside the anonymous experience', () => { + it('keeps signup hidden but applies layout cleanup for signed-in users', () => { mockUseAnonymousPostExperience.mockReturnValue({ isAnonPostExperience: false, - isLoading: false, + isPostPageExperience: true, }); renderComponent(); - expect(screen.getByTestId('post-signup-widget')).toBeInTheDocument(); - expect(screen.getByTestId('post-sidebar-ad-widget')).toBeInTheDocument(); - expect(screen.getByTestId('featured-archives')).toBeInTheDocument(); + expect( + screen.queryByTestId('build-your-feed-widget'), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('post-signup-widget')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('post-sidebar-ad-widget'), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('featured-archives')).not.toBeInTheDocument(); expect(screen.getByTestId('tools-widget')).toHaveAttribute( 'data-compact', - 'false', + 'true', ); }); }); diff --git a/packages/shared/src/components/post/PostWidgets.tsx b/packages/shared/src/components/post/PostWidgets.tsx index 846b2e90f8a..8ebd8ca7e7c 100644 --- a/packages/shared/src/components/post/PostWidgets.tsx +++ b/packages/shared/src/components/post/PostWidgets.tsx @@ -16,7 +16,6 @@ import EntityCardSkeleton from '../cards/entity/EntityCardSkeleton'; import { PostSidebarAdWidget } from './PostSidebarAdWidget'; import { FeaturedArchives } from '../widgets/FeaturedArchives'; import { MentionedToolsWidget } from '../brand/MentionedToolsWidget'; -import { PostSignupWidget } from './PostSignupWidget'; import { HighlightPostSidebarWidget } from '../cards/highlight/HighlightPostSidebarWidget'; import { useAnonymousPostExperience } from '../../hooks/post/useAnonymousPostExperience'; import { BuildYourFeedWidget } from './BuildYourFeedWidget'; @@ -55,7 +54,8 @@ export function PostWidgets({ origin, }: PostWidgetsProps): ReactElement { const { tokenRefreshed } = useContext(AuthContext); - const { isAnonPostExperience } = useAnonymousPostExperience(); + const { isAnonPostExperience, isPostPageExperience } = + useAnonymousPostExperience(); const { source } = post; const cardClasses = 'w-full bg-transparent'; @@ -86,7 +86,7 @@ export function PostWidgets({ return ( - {isAnonPostExperience ? : } + {isAnonPostExperience && } {sourceCard} {creator && ( )} - {!isAnonPostExperience && ( + {!isPostPageExperience && ( )} @@ -115,7 +115,7 @@ export function PostWidgets({ /> {tokenRefreshed && } - {!isAnonPostExperience && } + {!isPostPageExperience && } ); diff --git a/packages/shared/src/components/post/SquadPostWidgets.tsx b/packages/shared/src/components/post/SquadPostWidgets.tsx index cebc516daff..683d1f97f2a 100644 --- a/packages/shared/src/components/post/SquadPostWidgets.tsx +++ b/packages/shared/src/components/post/SquadPostWidgets.tsx @@ -16,7 +16,6 @@ import UserEntityCard from '../cards/entity/UserEntityCard'; import type { UserShortProfile } from '../../lib/user'; import { PostSidebarAdWidget } from './PostSidebarAdWidget'; import { FeaturedArchives } from '../widgets/FeaturedArchives'; -import { PostSignupWidget } from './PostSignupWidget'; import { HighlightPostSidebarWidget } from '../cards/highlight/HighlightPostSidebarWidget'; import { useAnonymousPostExperience } from '../../hooks/post/useAnonymousPostExperience'; import { BuildYourFeedWidget } from './BuildYourFeedWidget'; @@ -28,7 +27,8 @@ export function SquadPostWidgets({ className, }: PostWidgetsProps): ReactElement { const { tokenRefreshed } = useContext(AuthContext); - const { isAnonPostExperience } = useAnonymousPostExperience(); + const { isAnonPostExperience, isPostPageExperience } = + useAnonymousPostExperience(); const { source } = post; const isUserSource = source ? isSourceUserSource(source) : false; const isSquadSource = source?.type === SourceType.Squad; @@ -39,7 +39,7 @@ export function SquadPostWidgets({ return ( - {isAnonPostExperience ? : } + {isAnonPostExperience && } {!isUserSource && (isSquadSource ? ( )} - {!isAnonPostExperience && ( + {!isPostPageExperience && ( {tokenRefreshed && } - {!isAnonPostExperience && } + {!isPostPageExperience && } ); diff --git a/packages/shared/src/components/post/collection/CollectionPostWidgets.tsx b/packages/shared/src/components/post/collection/CollectionPostWidgets.tsx index da0d8a46d8b..a06b16cfcd9 100644 --- a/packages/shared/src/components/post/collection/CollectionPostWidgets.tsx +++ b/packages/shared/src/components/post/collection/CollectionPostWidgets.tsx @@ -10,7 +10,6 @@ import type { PostWidgetsProps } from '../PostWidgets'; import { FooterLinks } from '../../footer'; import { PostSidebarAdWidget } from '../PostSidebarAdWidget'; import { FeaturedArchives } from '../../widgets/FeaturedArchives'; -import { PostSignupWidget } from '../PostSignupWidget'; import { HighlightPostSidebarWidget } from '../../cards/highlight/HighlightPostSidebarWidget'; import { useAnonymousPostExperience } from '../../../hooks/post/useAnonymousPostExperience'; import { BuildYourFeedWidget } from '../BuildYourFeedWidget'; @@ -21,17 +20,18 @@ export const CollectionPostWidgets = ({ origin, className, }: PostWidgetsProps): ReactElement => { - const { isAnonPostExperience } = useAnonymousPostExperience(); + const { isAnonPostExperience, isPostPageExperience } = + useAnonymousPostExperience(); return ( - {isAnonPostExperience ? : } + {isAnonPostExperience && } - {!isAnonPostExperience && ( + {!isPostPageExperience && ( - {!isAnonPostExperience && } + {!isPostPageExperience && } ); diff --git a/packages/shared/src/hooks/post/useAnonymousPostExperience.spec.ts b/packages/shared/src/hooks/post/useAnonymousPostExperience.spec.ts index 5e23b5317a6..913465834df 100644 --- a/packages/shared/src/hooks/post/useAnonymousPostExperience.spec.ts +++ b/packages/shared/src/hooks/post/useAnonymousPostExperience.spec.ts @@ -1,6 +1,5 @@ import { renderHook } from '@testing-library/react'; import { useAuthContext } from '../../contexts/AuthContext'; -import { useConditionalFeature } from '../useConditionalFeature'; import { useAnonymousPostExperience } from './useAnonymousPostExperience'; import loggedUser from '../../../__tests__/fixture/loggedUser'; @@ -8,82 +7,46 @@ jest.mock('../../contexts/AuthContext', () => ({ useAuthContext: jest.fn(), })); -jest.mock('../useConditionalFeature', () => ({ - useConditionalFeature: jest.fn(), -})); - const mockUseAuthContext = jest.mocked(useAuthContext); -const mockUseConditionalFeature = jest.mocked(useConditionalFeature); describe('useAnonymousPostExperience', () => { beforeEach(() => { jest.clearAllMocks(); }); - const setFeature = (value: boolean): void => { - mockUseConditionalFeature.mockImplementation(({ shouldEvaluate }) => ({ - value: shouldEvaluate ? value : false, - isLoading: false, - })); - }; - - it('skips evaluation when auth is not ready', () => { + it('keeps the post page experience on even while auth is not ready', () => { mockUseAuthContext.mockReturnValue({ isAuthReady: false, user: undefined, } as never); - setFeature(true); const { result } = renderHook(() => useAnonymousPostExperience()); expect(result.current.isAnonPostExperience).toBe(false); - expect(mockUseConditionalFeature.mock.calls[0][0].shouldEvaluate).toBe( - false, - ); + expect(result.current.isPostPageExperience).toBe(true); }); - it('skips evaluation for logged-in users', () => { + it('keeps layout changes on for logged-in users without anonymous CTAs', () => { mockUseAuthContext.mockReturnValue({ isAuthReady: true, user: loggedUser, } as never); - setFeature(true); const { result } = renderHook(() => useAnonymousPostExperience()); expect(result.current.isAnonPostExperience).toBe(false); - expect(mockUseConditionalFeature.mock.calls[0][0].shouldEvaluate).toBe( - false, - ); + expect(result.current.isPostPageExperience).toBe(true); }); - it('returns true for anonymous users when the flag is enabled', () => { + it('returns the anonymous experience for logged-out users by default', () => { mockUseAuthContext.mockReturnValue({ isAuthReady: true, user: undefined, } as never); - setFeature(true); const { result } = renderHook(() => useAnonymousPostExperience()); expect(result.current.isAnonPostExperience).toBe(true); - expect(mockUseConditionalFeature.mock.calls[0][0].shouldEvaluate).toBe( - true, - ); - }); - - it('returns false for anonymous users when the flag is disabled', () => { - mockUseAuthContext.mockReturnValue({ - isAuthReady: true, - user: undefined, - } as never); - setFeature(false); - - const { result } = renderHook(() => useAnonymousPostExperience()); - - expect(result.current.isAnonPostExperience).toBe(false); - expect(mockUseConditionalFeature.mock.calls[0][0].shouldEvaluate).toBe( - true, - ); + expect(result.current.isPostPageExperience).toBe(true); }); }); diff --git a/packages/shared/src/hooks/post/useAnonymousPostExperience.ts b/packages/shared/src/hooks/post/useAnonymousPostExperience.ts index 5369da56398..44303c00409 100644 --- a/packages/shared/src/hooks/post/useAnonymousPostExperience.ts +++ b/packages/shared/src/hooks/post/useAnonymousPostExperience.ts @@ -1,22 +1,15 @@ import { useAuthContext } from '../../contexts/AuthContext'; -import { featureAnonymousPostExperience } from '../../lib/featureManagement'; -import { useConditionalFeature } from '../useConditionalFeature'; interface UseAnonymousPostExperience { isAnonPostExperience: boolean; - isLoading: boolean; + isPostPageExperience: boolean; } export const useAnonymousPostExperience = (): UseAnonymousPostExperience => { const { isAuthReady, user } = useAuthContext(); - const shouldEvaluate = isAuthReady && !user; - const { value: isEnabled, isLoading } = useConditionalFeature({ - feature: featureAnonymousPostExperience, - shouldEvaluate, - }); return { - isAnonPostExperience: shouldEvaluate && isEnabled, - isLoading, + isAnonPostExperience: isAuthReady && !user, + isPostPageExperience: true, }; }; From 11ceb4fa202eb0e4a3b25e3a534fc251dcbfba62 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 1 Jun 2026 00:43:39 +0300 Subject: [PATCH 4/7] feat: redesign post page experience Rework post pages into a topic-aware experience shell so visitors can see article context, related feed value, and community discussion before signing up. Co-authored-by: Cursor --- .../components/brand/MentionedToolsWidget.tsx | 16 +- .../src/components/post/BasePostContent.tsx | 16 +- .../components/post/BuildYourFeedWidget.tsx | 47 +++-- .../components/post/MobilePostFloatingBar.tsx | 6 +- .../post/MobilePostFloatingBar.v2.tsx | 6 +- .../src/components/post/PostContent.tsx | 194 ++++++------------ .../post/SocialTwitterPostContent.tsx | 189 +++++++++-------- .../src/components/post/SquadPostContent.tsx | 113 +++++----- .../post/collection/CollectionPostContent.tsx | 136 ++++++------ .../post/experience/ConversationHubHeader.tsx | 32 +++ .../experience/PersonalizedFeedPreview.tsx | 46 +++++ .../post/experience/PostContextRail.spec.tsx | 102 +++++++++ .../post/experience/PostContextRail.tsx | 67 ++++++ .../post/experience/PostExperienceLayout.tsx | 41 ++++ .../components/post/experience/PostHero.tsx | 150 ++++++++++++++ .../post/experience/PostInsightPanel.spec.tsx | 70 +++++++ .../post/experience/PostInsightPanel.tsx | 87 ++++++++ .../components/post/poll/PollPostContent.tsx | 188 ++++++++--------- .../src/hooks/useEngagementBarV2.spec.tsx | 86 +------- .../shared/src/hooks/useEngagementBarV2.ts | 11 +- packages/shared/src/lib/featureManagement.ts | 12 +- 21 files changed, 1037 insertions(+), 578 deletions(-) create mode 100644 packages/shared/src/components/post/experience/ConversationHubHeader.tsx create mode 100644 packages/shared/src/components/post/experience/PersonalizedFeedPreview.tsx create mode 100644 packages/shared/src/components/post/experience/PostContextRail.spec.tsx create mode 100644 packages/shared/src/components/post/experience/PostContextRail.tsx create mode 100644 packages/shared/src/components/post/experience/PostExperienceLayout.tsx create mode 100644 packages/shared/src/components/post/experience/PostHero.tsx create mode 100644 packages/shared/src/components/post/experience/PostInsightPanel.spec.tsx create mode 100644 packages/shared/src/components/post/experience/PostInsightPanel.tsx diff --git a/packages/shared/src/components/brand/MentionedToolsWidget.tsx b/packages/shared/src/components/brand/MentionedToolsWidget.tsx index b8dfc24ae0b..ef3862c8c31 100644 --- a/packages/shared/src/components/brand/MentionedToolsWidget.tsx +++ b/packages/shared/src/components/brand/MentionedToolsWidget.tsx @@ -169,15 +169,16 @@ export const MentionedToolsWidget = ({ return null; } - const visibleTools = compact ? mentionedTools.slice(0, 2) : mentionedTools; const highlightedWordResult = getHighlightedWordConfig(postTags); + const visibleTools = compact ? mentionedTools.slice(0, 2) : mentionedTools; + const hiddenToolsCount = mentionedTools.length - visibleTools.length; return ( <>
@@ -189,7 +190,9 @@ export const MentionedToolsWidget = ({ Sponsored tools -
+
{visibleTools.map((tool) => { const isSponsored = tool.isSponsored && hasAnySponsoredTag(postTags); @@ -281,12 +284,13 @@ export const MentionedToolsWidget = ({ return toolItem; })} - {compact && mentionedTools.length > visibleTools.length && ( + {hiddenToolsCount > 0 && ( - +{mentionedTools.length - visibleTools.length} more tools + +{hiddenToolsCount} more tools mentioned )}
diff --git a/packages/shared/src/components/post/BasePostContent.tsx b/packages/shared/src/components/post/BasePostContent.tsx index 0e1d81a904b..60f4cd0a3a8 100644 --- a/packages/shared/src/components/post/BasePostContent.tsx +++ b/packages/shared/src/components/post/BasePostContent.tsx @@ -6,6 +6,7 @@ import PostEngagements from './PostEngagements'; import type { BasePostContentProps } from './common'; import { PostHeaderActions } from './PostHeaderActions'; import { ButtonSize } from '../buttons/common'; +import { ConversationHubHeader } from './experience/ConversationHubHeader'; const Custom404 = dynamic( () => import(/* webpackChunkName: "custom404" */ '../Custom404'), @@ -63,12 +64,15 @@ export function BasePostContent({ )} {children} {!!engagementProps && ( - + <> + {isPostPage && } + + )} ); diff --git a/packages/shared/src/components/post/BuildYourFeedWidget.tsx b/packages/shared/src/components/post/BuildYourFeedWidget.tsx index 38e19df8b85..942873ffa69 100644 --- a/packages/shared/src/components/post/BuildYourFeedWidget.tsx +++ b/packages/shared/src/components/post/BuildYourFeedWidget.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useActivePostContext } from '../../contexts/ActivePostContext'; import { useAuthContext } from '../../contexts/AuthContext'; import { useLogContext } from '../../contexts/LogContext'; @@ -21,6 +21,7 @@ export const BuildYourFeedWidget = (): ReactElement => { const activePost = useActivePostContext()?.activePost; const { showLogin } = useAuthContext(); const { logEvent } = useLogContext(); + const [isClient, setIsClient] = useState(false); const topics = getPostTopicTags(activePost); const topicLabel = getPostTopicLabel(topics); const targetId = getPostTopicTargetId(activePost); @@ -42,6 +43,10 @@ export const BuildYourFeedWidget = (): ReactElement => { extra, })); + useEffect(() => { + setIsClient(true); + }, []); + const onAuthStateUpdate = useCallback( (props: Partial) => { logEvent({ @@ -78,25 +83,27 @@ export const BuildYourFeedWidget = (): ReactElement => {

- } - hideLoginLink - ignoreMessages - onAuthStateUpdate={onAuthStateUpdate} - onboardingSignupButton={{ - size: ButtonSize.Medium, - variant: ButtonVariant.Primary, - }} - simplified - targetId={targetId} - trigger={AuthTriggers.PostPage} - className={{ - onboardingSignup: '!gap-3', - }} - /> + {isClient && ( + } + hideLoginLink + ignoreMessages + onAuthStateUpdate={onAuthStateUpdate} + onboardingSignupButton={{ + size: ButtonSize.Medium, + variant: ButtonVariant.Primary, + }} + simplified + targetId={targetId} + trigger={AuthTriggers.PostPage} + /> + )} ); }; diff --git a/packages/shared/src/components/post/MobilePostFloatingBar.tsx b/packages/shared/src/components/post/MobilePostFloatingBar.tsx index 4f595daff2a..6fb435066a6 100644 --- a/packages/shared/src/components/post/MobilePostFloatingBar.tsx +++ b/packages/shared/src/components/post/MobilePostFloatingBar.tsx @@ -41,9 +41,9 @@ export interface MobilePostFloatingBarProps { // + `px-2` spreads the icons edge-to-edge with a small pad so the outermost // icons don't kiss the rounded corner. const containerClasses = classNames( - 'flex w-full items-center justify-between rounded-16 border border-border-subtlest-tertiary px-2 py-1', - 'bg-surface-float backdrop-blur-[2.5rem]', - 'shadow-[0_0.25rem_1.5rem_0_var(--theme-shadow-shadow1)]', + 'flex w-full items-center justify-between rounded-24 border border-border-subtlest-tertiary px-2 py-1.5', + 'bg-background-default/90 backdrop-blur-[2.5rem]', + 'shadow-2', ); // `QuaternaryButton` renders its children inside a sibling `