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..ef3862c8c31 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(); @@ -168,25 +170,30 @@ export const MentionedToolsWidget = ({ } const highlightedWordResult = getHighlightedWordConfig(postTags); + const visibleTools = compact ? mentionedTools.slice(0, 2) : mentionedTools; + const hiddenToolsCount = mentionedTools.length - visibleTools.length; return ( <>
Sponsored tools -
- {mentionedTools.map((tool) => { +
+ {visibleTools.map((tool) => { const isSponsored = tool.isSponsored && hasAnySponsoredTag(postTags); const isInStack = isToolInStack(tool.name); @@ -201,7 +208,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 +284,15 @@ export const MentionedToolsWidget = ({ return toolItem; })} + {hiddenToolsCount > 0 && ( + + +{hiddenToolsCount} more tools mentioned + + )}
diff --git a/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.spec.tsx b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.spec.tsx index 214811cb8d0..6034850567b 100644 --- a/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.spec.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.spec.tsx @@ -8,29 +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 { 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) => ({ @@ -68,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, @@ -144,21 +137,18 @@ 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')]), + buildResponse([buildHighlight('h1', 'Anonymous headline')]), ); renderWidget(); - expect(screen.queryByText('Happening Now')).not.toBeInTheDocument(); - expect( - screen.queryByTestId('postPageHighlightWidget'), - ).not.toBeInTheDocument(); + expect(await screen.findByText('Anonymous headline')).toBeInTheDocument(); }); it('points "Read all" to /highlights with the first highlight id', async () => { diff --git a/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx index 8717308efd0..544488bae8e 100644 --- a/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx +++ b/packages/shared/src/components/cards/highlight/HighlightPostSidebarWidget.tsx @@ -10,19 +10,18 @@ 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'; 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 => { @@ -33,16 +32,12 @@ const prefersReducedMotion = (): boolean => { }; export const HighlightPostSidebarWidget = (): ReactElement | null => { - const { user } = useAuthContext(); const { logEvent } = useLogContext(); - const { value: isEnabled } = useConditionalFeature({ - feature: featurePostPageHighlights, - shouldEvaluate: !!user, - }); + const { isAnonPostExperience } = useAnonymousPostExperience(); + const feedName = isAnonPostExperience ? ANONYMOUS_FEED_NAME : FEED_NAME; const { data } = useQuery({ ...majorHeadlinesQueryOptions({ first: HIGHLIGHTS_LIMIT }), - enabled: isEnabled && !!user, refetchInterval: ONE_MINUTE, }); @@ -57,13 +52,13 @@ 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( () => feedHighlightsLogEvent(LogEvent.Impression, { - feedName: FEED_NAME, + feedName, action: 'impression', count: highlights.length, highlightIds: highlights.map((h) => h.id), @@ -123,7 +118,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 +132,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/modals/streaks/StreakRecoverModal.spec.tsx b/packages/shared/src/components/modals/streaks/StreakRecoverModal.spec.tsx index e74d882e9fc..4887febc4bf 100644 --- a/packages/shared/src/components/modals/streaks/StreakRecoverModal.spec.tsx +++ b/packages/shared/src/components/modals/streaks/StreakRecoverModal.spec.tsx @@ -201,7 +201,7 @@ it('should render and fetch initial data if logged user can recover streak', asy expect(haveFetched).toBeTruthy(); // and rendered - const popup = screen.queryByTestId('streak-recover-modal-heading'); + const popup = await screen.findByTestId('streak-recover-modal-heading'); expect(popup).toBeInTheDocument(); }); @@ -253,7 +253,7 @@ it('Should have no cost for first time recovery', async () => { await waitForNock(); // rendered - const popupHeader = screen.queryByTestId('streak-recover-modal-heading'); + const popupHeader = await screen.findByTestId('streak-recover-modal-heading'); expect(popupHeader).toBeInTheDocument(); // expect cost to be 0 @@ -281,7 +281,7 @@ it('Should have cost of 100 Cores for 2nd+ time recovery', async () => { await waitForNock(); // rendered - const popupHeader = screen.queryByTestId('streak-recover-modal-heading'); + const popupHeader = await screen.findByTestId('streak-recover-modal-heading'); expect(popupHeader).toBeInTheDocument(); // expect cost to be 100 @@ -309,7 +309,7 @@ it('Should show buy Cores message if user does not have enough Cores', async () await waitForNock(); // rendered - const popupHeader = screen.queryByTestId('streak-recover-modal-heading'); + const popupHeader = await screen.findByTestId('streak-recover-modal-heading'); expect(popupHeader).toBeInTheDocument(); // expect not enough Cores message @@ -343,7 +343,7 @@ it('Should show success message on recover', async () => { await waitForNock(); // rendered - const popupHeader = screen.queryByTestId('streak-recover-modal-heading'); + const popupHeader = await screen.findByTestId('streak-recover-modal-heading'); expect(popupHeader).toBeInTheDocument(); // button is there 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 new file mode 100644 index 00000000000..942873ffa69 --- /dev/null +++ b/packages/shared/src/components/post/BuildYourFeedWidget.tsx @@ -0,0 +1,109 @@ +import type { ReactElement } 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'; +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 [isClient, setIsClient] = useState(false); + 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, + })); + + useEffect(() => { + setIsClient(true); + }, []); + + 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. +

+
+ + {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 `