diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index 35097198f94..ffba4ce4fc3 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -30,7 +30,10 @@ import { useFeeds } from '../hooks/feed/useFeeds'; import { WebappShortcutsRow } from '../features/shortcuts/components/WebappShortcutsRow'; import { AskSearchBanner } from './marketing/banners/AskSearchBanner'; import { FeedEngagementBanner } from './brand/FeedEngagementBanner'; -import { ExploreSignupStrip } from './auth/ExploreSignupStrip'; +import { + PublicPageSignupBanner, + usePublicPageSignupBannerGate, +} from './auth/PublicPageSignupBanner'; import FeedContext from '../contexts/FeedContext'; import AuthContext from '../contexts/AuthContext'; import type { LoggedUser } from '../lib/user'; @@ -850,12 +853,22 @@ export default function MainFeedLayout({ chipsTopContent ); + // Both pin to the window's bottom edge, so an anonymous visitor gets the + // signup banner or the sponsor dock, never both. Auth unknown counts as + // "banner may show": the boot cache readies GrowthBook before the remote + // boot answers, and the dock must not enroll a visitor it is about to + // leave. + const signupBannerGate = usePublicPageSignupBannerGate(); + const hasSignupBannerSlot = !isExtension && isExploreHub; + const mayShowSignupBanner = hasSignupBannerSlot && signupBannerGate.mayShow; + const showSignupBanner = hasSignupBannerSlot && signupBannerGate.shouldShow; // Read here rather than inside the feed or the strip: this is the one place // that owns both, so the card can only ever go missing on a surface that is // mounting the strip — with headlines in it — in the card's place. const sponsorStrip = useSponsorStripFeed({ feedName, disableAds: feedProps?.disableAds, + suppressed: mayShowSignupBanner, }); const v2ActionButtons = feedProps?.actionButtons; const showFeedV2PageHeader = @@ -887,16 +900,6 @@ export default function MainFeedLayout({ - {!isExtension && isExploreHub && ( -
- -
- )} {isAnyExplore && !showExploreV2PageHeader && } {isSearchOn && !isSearchPageLaptop && search} {isSearchOn && !isSearchPageLaptop && ( @@ -953,6 +956,7 @@ export default function MainFeedLayout({ ) )} {children} + {showSignupBanner && }
{/* Docked outside the page container so it spans the feed column and pins to the window, and mounted here rather than in each app's diff --git a/packages/shared/src/components/auth/AuthenticationBanner.tsx b/packages/shared/src/components/auth/AuthenticationBanner.tsx index b71b15efacc..c51ae141a38 100644 --- a/packages/shared/src/components/auth/AuthenticationBanner.tsx +++ b/packages/shared/src/components/auth/AuthenticationBanner.tsx @@ -19,11 +19,13 @@ const Section = classed('div', 'flex flex-col'); interface AuthenticationBannerProps extends PropsWithChildren { compact?: boolean; + targetId?: string; } export function AuthenticationBanner({ children, compact, + targetId, }: AuthenticationBannerProps): ReactElement { const { showLogin } = useAuthContext(); @@ -75,6 +77,7 @@ export function AuthenticationBanner({ ignoreMessages formRef={null as unknown as React.MutableRefObject} trigger={AuthTriggers.Onboarding} + targetId={targetId} simplified defaultDisplay={AuthDisplay.OnboardingSignup} forceDefaultDisplay diff --git a/packages/shared/src/components/auth/ExploreSignupStrip.spec.tsx b/packages/shared/src/components/auth/ExploreSignupStrip.spec.tsx deleted file mode 100644 index afbc7f3ba88..00000000000 --- a/packages/shared/src/components/auth/ExploreSignupStrip.spec.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import React from 'react'; -import { QueryClient } from '@tanstack/react-query'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { TestBootProvider } from '../../../__tests__/helpers/boot'; -import { ExploreSignupStrip } from './ExploreSignupStrip'; -import { hijackingCoverStripMinHeight } from './HijackingCoverStrip'; -import { useViewSize } from '../../hooks/useViewSize'; -import { AuthTriggers } from '../../lib/auth'; -import { LogEvent, TargetId, TargetType } from '../../lib/log'; - -jest.mock('../../hooks/useViewSize', () => ({ - ...jest.requireActual('../../hooks/useViewSize'), - useViewSize: jest.fn(), -})); - -const mockUseViewSize = useViewSize as jest.Mock; -const logEvent = jest.fn(); -const showLogin = jest.fn(); - -const tree = (auth = {}) => ( - - - -); - -const renderComponent = (auth = {}) => render(tree(auth)); - -const impression = { - event_name: LogEvent.Impression, - target_type: TargetType.SignupButton, - target_id: TargetId.ExploreStrip, -}; - -beforeEach(() => { - jest.clearAllMocks(); - mockUseViewSize.mockReturnValue(true); -}); - -describe('ExploreSignupStrip', () => { - it('should render the strip for anonymous visitors', () => { - renderComponent(); - - expect( - screen.getByRole('heading', { - name: 'Unlock the full daily.dev experience', - }), - ).toBeInTheDocument(); - expect(logEvent).toHaveBeenCalledWith(impression); - }); - - it('should render nothing for logged-in users', () => { - const { container } = renderComponent({ - isLoggedIn: true, - user: { id: 'u1' }, - }); - - expect(container).toBeEmptyDOMElement(); - expect(logEvent).not.toHaveBeenCalled(); - }); - - it('should hold the slot at the strip height until boot answers', () => { - const { container } = renderComponent({ isAuthReady: false }); - - expect(screen.queryByRole('heading')).not.toBeInTheDocument(); - expect(container.firstElementChild?.firstElementChild).toHaveClass( - hijackingCoverStripMinHeight, - ); - expect(logEvent).not.toHaveBeenCalled(); - }); - - it('should not hold the slot for a member known from the boot cache', () => { - const { container } = renderComponent({ - isAuthReady: false, - user: { id: 'u1' }, - }); - - expect(container).toBeEmptyDOMElement(); - }); - - it('should render nothing and log nothing below the tablet breakpoint', () => { - mockUseViewSize.mockReturnValue(false); - const { container } = renderComponent(); - - expect(container).toBeEmptyDOMElement(); - expect(logEvent).not.toHaveBeenCalled(); - }); - - it('should log one impression once the viewport reaches tablet', () => { - mockUseViewSize.mockReturnValue(false); - const { rerender } = renderComponent(); - - mockUseViewSize.mockReturnValue(true); - rerender(tree()); - rerender(tree()); - - expect(logEvent).toHaveBeenCalledTimes(1); - expect(logEvent).toHaveBeenCalledWith(impression); - }); - - it('should open signup inline and log the click', async () => { - renderComponent(); - - await userEvent.click(screen.getByRole('button', { name: /Sign up/ })); - - expect(logEvent).toHaveBeenCalledWith({ - event_name: LogEvent.Click, - target_type: TargetType.SignupButton, - target_id: TargetId.ExploreStrip, - }); - expect(showLogin).toHaveBeenCalledWith({ - trigger: AuthTriggers.Onboarding, - options: { isLogin: false }, - }); - }); - - it('should open login inline and log the click', async () => { - renderComponent(); - - await userEvent.click(screen.getByRole('button', { name: 'Log in' })); - - expect(logEvent).toHaveBeenCalledWith({ - event_name: LogEvent.Click, - target_type: TargetType.LoginButton, - target_id: TargetId.ExploreStrip, - }); - expect(showLogin).toHaveBeenCalledWith({ - trigger: AuthTriggers.Onboarding, - options: { isLogin: true }, - }); - }); -}); diff --git a/packages/shared/src/components/auth/ExploreSignupStrip.tsx b/packages/shared/src/components/auth/ExploreSignupStrip.tsx deleted file mode 100644 index 6e1c442036c..00000000000 --- a/packages/shared/src/components/auth/ExploreSignupStrip.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import classNames from 'classnames'; -import type { HijackingCoverCopy } from './HijackingCoverStrip'; -import { - HijackingCoverAuthActions, - HijackingCoverStrip, - HijackingCoverStripPlaceholder, -} from './HijackingCoverStrip'; -import { useAuthContext } from '../../contexts/AuthContext'; -import { useLogContext } from '../../contexts/LogContext'; -import useLogEventOnce from '../../hooks/log/useLogEventOnce'; -import { useViewSize, ViewSize } from '../../hooks/useViewSize'; -import { AuthTriggers } from '../../lib/auth'; -import { LogEvent, TargetId, TargetType } from '../../lib/log'; - -// The new tab control strip's signed-out copy. -const copy: HijackingCoverCopy = { - heading: 'Unlock the full daily.dev experience', - body: 'Log in to pick up where you left off.', -}; - -// The new tab's cover strip for anonymous visitors, tablet and up. -export function ExploreSignupStrip({ - className, -}: { - className?: string; -}): ReactElement | null { - const { isAuthReady, user, showLogin } = useAuthContext(); - const { logEvent } = useLogContext(); - const isTablet = useViewSize(ViewSize.Tablet); - const isAnonymous = isAuthReady && !user; - - useLogEventOnce( - () => ({ - event_name: LogEvent.Impression, - target_type: TargetType.SignupButton, - target_id: TargetId.ExploreStrip, - }), - { condition: isAnonymous && isTablet }, - ); - - if (user) { - return null; - } - - // Holds the strip's slot in the server HTML until boot answers. - if (!isAuthReady) { - return ( - - ); - } - - if (!isTablet) { - return null; - } - - const onAuthClick = (isLogin: boolean) => (): void => { - logEvent({ - event_name: LogEvent.Click, - target_type: isLogin ? TargetType.LoginButton : TargetType.SignupButton, - target_id: TargetId.ExploreStrip, - }); - - showLogin({ trigger: AuthTriggers.Onboarding, options: { isLogin } }); - }; - - return ( - - } - /> - ); -} diff --git a/packages/shared/src/components/auth/HijackingCoverStrip.tsx b/packages/shared/src/components/auth/HijackingCoverStrip.tsx index c87a7631091..5fe887b448a 100644 --- a/packages/shared/src/components/auth/HijackingCoverStrip.tsx +++ b/packages/shared/src/components/auth/HijackingCoverStrip.tsx @@ -1,7 +1,6 @@ import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import classNames from 'classnames'; -import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { cloudinaryHijackingCoverArt } from '../../lib/image'; export const hijackingPrimaryCta = @@ -15,54 +14,10 @@ export interface HijackingCoverCopy { body: string; } -interface HijackingCoverAuthActionsProps { - signup: string; - login: string; - onSignupClick: () => void; - onLoginClick: () => void; -} - -// The sign up / log in pair for surfaces that offer both. -export function HijackingCoverAuthActions({ - signup, - login, - onSignupClick, - onLoginClick, -}: HijackingCoverAuthActionsProps): ReactElement { - return ( - <> - - - - ); -} - const coverArtPosition = { objectPosition: '50% 62%' }; // The card's height without a sizer. -export const hijackingCoverStripMinHeight = 'min-h-[14rem]'; +const hijackingCoverStripMinHeight = 'min-h-[14rem]'; interface HijackingCoverCardProps { children: ReactNode; @@ -152,16 +107,3 @@ export function HijackingCoverStrip({ ); } - -// The same box as the strip without a sizer, empty: holds its slot. -export function HijackingCoverStripPlaceholder({ - className, -}: { - className?: string; -}): ReactElement { - return ( -
-
-
- ); -} diff --git a/packages/shared/src/components/auth/PostAuthBanner.tsx b/packages/shared/src/components/auth/PostAuthBanner.tsx index 69907d17d28..86d4757026c 100644 --- a/packages/shared/src/components/auth/PostAuthBanner.tsx +++ b/packages/shared/src/components/auth/PostAuthBanner.tsx @@ -25,10 +25,12 @@ const GeoPersonalizedBanner = dynamic( interface PostAuthBannerProps { compact?: boolean; + targetId?: string; } export const PostAuthBanner = ({ compact, + targetId, }: PostAuthBannerProps = {}): ReactElement => { const searchParams = useSearchParams(); const { geo } = useAuthContext(); @@ -36,17 +38,35 @@ export const PostAuthBanner = ({ const userId = searchParams?.get('userid'); if (userId) { - return ; + return ( + + ); } const social = getSocialReferrer(); if (social) { - return ; + return ( + + ); } if (geo?.region) { - return ; + return ( + + ); } - return ; + return ; }; diff --git a/packages/shared/src/components/auth/PublicPageSignupBanner.spec.tsx b/packages/shared/src/components/auth/PublicPageSignupBanner.spec.tsx new file mode 100644 index 00000000000..0e4cdbf8faf --- /dev/null +++ b/packages/shared/src/components/auth/PublicPageSignupBanner.spec.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { QueryClient } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { PublicPageSignupBanner } from './PublicPageSignupBanner'; +import { useViewSize } from '../../hooks/useViewSize'; +import { LogEvent, TargetId, TargetType } from '../../lib/log'; + +jest.mock('../../hooks/useViewSize', () => ({ + ...jest.requireActual('../../hooks/useViewSize'), + useViewSize: jest.fn(), +})); + +jest.mock('./PostAuthBanner', () => ({ + PostAuthBanner: ({ targetId }: { targetId?: string }) => ( +
+ ), +})); + +// The banner loads lazily; the suite-wide dynamic mock resolves a tick late. +jest.mock('next/dynamic', () => () => { + const { PostAuthBanner } = jest.requireMock('./PostAuthBanner'); + + return PostAuthBanner; +}); + +const mockUseViewSize = useViewSize as jest.Mock; +const logEvent = jest.fn(); + +const impression = { + event_name: LogEvent.Impression, + target_type: TargetType.SignupButton, + target_id: TargetId.PublicPageSignupBanner, +}; + +const renderComponent = (auth = {}) => + render( + + + , + ); + +beforeEach(() => { + jest.clearAllMocks(); + mockUseViewSize.mockReturnValue(true); +}); + +describe('PublicPageSignupBanner', () => { + it('should render the banner and its clearance for anonymous laptop visitors', async () => { + const { container } = renderComponent(); + + expect(await screen.findByTestId('post-auth-banner')).toHaveAttribute( + 'data-target-id', + TargetId.PublicPageSignupBanner, + ); + expect(container.firstElementChild).toHaveClass('h-72'); + expect(logEvent).toHaveBeenCalledTimes(1); + expect(logEvent).toHaveBeenCalledWith(impression); + }); + + it('should render nothing for logged-in users', () => { + const { container } = renderComponent({ + isLoggedIn: true, + user: { id: 'u1' }, + }); + + expect(container).toBeEmptyDOMElement(); + expect(logEvent).not.toHaveBeenCalled(); + }); + + it('should render nothing until boot answers', () => { + const { container } = renderComponent({ isAuthReady: false }); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should render nothing below the laptop breakpoint', () => { + mockUseViewSize.mockReturnValue(false); + const { container } = renderComponent(); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/packages/shared/src/components/auth/PublicPageSignupBanner.tsx b/packages/shared/src/components/auth/PublicPageSignupBanner.tsx index 7b5e585ae45..72f46767e64 100644 --- a/packages/shared/src/components/auth/PublicPageSignupBanner.tsx +++ b/packages/shared/src/components/auth/PublicPageSignupBanner.tsx @@ -1,22 +1,56 @@ import type { ReactElement } from 'react'; import React from 'react'; -import { useConditionalFeature, useViewSize, ViewSize } from '../../hooks'; -import { useOnboardingActions } from '../../hooks/auth'; -import { featurePublicSignupBanner } from '../../lib/featureManagement'; -import { PostAuthBanner } from './PostAuthBanner'; +import dynamic from 'next/dynamic'; +import { useViewSize, ViewSize } from '../../hooks/useViewSize'; +import { useAuthContext } from '../../contexts/AuthContext'; +import useLogEventOnce from '../../hooks/log/useLogEventOnce'; +import { LogEvent, TargetId, TargetType } from '../../lib/log'; -export function PublicPageSignupBanner(): ReactElement | null { +const PostAuthBanner = dynamic(() => + import(/* webpackChunkName: "postAuthBanner" */ './PostAuthBanner').then( + (mod) => mod.PostAuthBanner, + ), +); + +interface PublicPageSignupBannerGate { + /** Auth is unknown or anonymous on a laptop: the banner may end up showing. */ + mayShow: boolean; + shouldShow: boolean; +} + +// The one gate for the banner and for whatever else has to yield the +// window's bottom edge to it, so the two can never drift apart. +export const usePublicPageSignupBannerGate = (): PublicPageSignupBannerGate => { const isLaptop = useViewSize(ViewSize.Laptop); - const { shouldShowAuthBanner } = useOnboardingActions(); - const shouldEvaluate = shouldShowAuthBanner && isLaptop; - const { value: isEnabled } = useConditionalFeature({ - feature: featurePublicSignupBanner, - shouldEvaluate, - }); - - if (!shouldEvaluate || !isEnabled) { + const { isAuthReady, user } = useAuthContext(); + const mayShow = isLaptop && (!isAuthReady || !user); + + return { mayShow, shouldShow: mayShow && isAuthReady }; +}; + +// The post page's bottom banner on the public pages. It pins to the window, +// so it goes at the end of the page where its spacer keeps the last of the +// content reachable above it. +export function PublicPageSignupBanner(): ReactElement | null { + const { shouldShow } = usePublicPageSignupBannerGate(); + + useLogEventOnce( + () => ({ + event_name: LogEvent.Impression, + target_type: TargetType.SignupButton, + target_id: TargetId.PublicPageSignupBanner, + }), + { condition: shouldShow }, + ); + + if (!shouldShow) { return null; } - return ; + return ( + <> +
+ + + ); } diff --git a/packages/shared/src/components/marketing/banners/personalized/GeoPersonalizedBanner.tsx b/packages/shared/src/components/marketing/banners/personalized/GeoPersonalizedBanner.tsx index 4c54902ad0c..af49ea8e02b 100644 --- a/packages/shared/src/components/marketing/banners/personalized/GeoPersonalizedBanner.tsx +++ b/packages/shared/src/components/marketing/banners/personalized/GeoPersonalizedBanner.tsx @@ -6,14 +6,16 @@ import { AuthenticationBanner, OnboardingHeadline } from '../../../auth'; const GeoPersonalizedBanner = ({ geo, compact, + targetId, }: { geo: string; compact?: boolean; + targetId?: string; }): ReactElement => { const country = geoToCountry(geo); return ( - + { const gradient = socialGradient[site]; return ( - + { const key = generateQueryKey(RequestKey.ReferringUser); const { data: user, isError } = useQuery({ @@ -19,13 +21,13 @@ const UserPersonalizedBanner = ({ }); if (isError) { - return ; + return ; } const name = user?.name ? user?.name.split(' ')[0] : user?.username; return ( - + )} - -
{children} + ); diff --git a/packages/shared/src/components/tags/TagTopicPage.tsx b/packages/shared/src/components/tags/TagTopicPage.tsx index 2b5329ee404..6d6d41e20f6 100644 --- a/packages/shared/src/components/tags/TagTopicPage.tsx +++ b/packages/shared/src/components/tags/TagTopicPage.tsx @@ -73,7 +73,6 @@ import { EntitySectionHeading } from '../entity/EntitySectionHeading'; import { EntityRailWithFade } from '../entity/EntityRailWithFade'; import { TagPageNavbar } from './TagPageNavbar'; import { PublicPageSignupBanner } from '../auth/PublicPageSignupBanner'; -import { ExploreSignupStrip } from '../auth/ExploreSignupStrip'; import { largeNumberFormat } from '../../lib/numberFormat'; import { webappUrl } from '../../lib/constants'; import { @@ -399,7 +398,6 @@ export const TagTopicPage = ({ />
- {/* Hero cover — centered on the page; content below spans full width. */}
{!engagementStripCreative && } diff --git a/packages/shared/src/components/tags/TagsDirectoryPage.tsx b/packages/shared/src/components/tags/TagsDirectoryPage.tsx index 1b7753e8142..c2d6d7f9c3d 100644 --- a/packages/shared/src/components/tags/TagsDirectoryPage.tsx +++ b/packages/shared/src/components/tags/TagsDirectoryPage.tsx @@ -13,7 +13,6 @@ import { TagPageNavbar } from './TagPageNavbar'; import { TagDirectory } from './TagDirectory'; import { TagDirectoryFilter } from './TagDirectoryFilter'; import { PublicPageSignupBanner } from '../auth/PublicPageSignupBanner'; -import { ExploreSignupStrip } from '../auth/ExploreSignupStrip'; import { Typography, TypographyColor, @@ -118,7 +117,6 @@ export function TagsDirectoryPage({ />
- {/* Hero */}
- renderHook(() => useSponsorStrip({ feedName, disableAds }), { +}: { + feedName?: string; + disableAds?: boolean; + suppressed?: boolean; + isPlus?: boolean; +} = {}) => + renderHook(() => useSponsorStrip({ feedName, disableAds, suppressed }), { wrapper: ({ children }) => ( { mockViewSize.mockReturnValue(true); }); +it('should not evaluate the flag while another surface may take the bottom edge', () => { + expect(render({ suppressed: true }).result.current).toBe(false); + expect(mockFeature).toHaveBeenCalledWith( + expect.objectContaining({ shouldEvaluate: false }), + ); +}); + it('should be on for a feed that can show the Happening Now card', () => { expect(render().result.current).toBe(true); }); diff --git a/packages/shared/src/features/monetization/sponsorStrip/useSponsorStrip.ts b/packages/shared/src/features/monetization/sponsorStrip/useSponsorStrip.ts index 70bb46f55c6..5cf26342f57 100644 --- a/packages/shared/src/features/monetization/sponsorStrip/useSponsorStrip.ts +++ b/packages/shared/src/features/monetization/sponsorStrip/useSponsorStrip.ts @@ -9,6 +9,12 @@ interface UseSponsorStripProps { feedName?: string; /** The feed's own ads switch (feed previews, squads) — never inventory. */ disableAds?: boolean; + /** + * Another surface may take the window's bottom edge (the signup banner). + * The flag is not evaluated while set, so nobody who could end up with + * that surface is enrolled in the strip's experiment. + */ + suppressed?: boolean; } /** @@ -22,6 +28,7 @@ const isSponsorStripFeed = (feedName?: string): boolean => export const useSponsorStrip = ({ feedName, disableAds, + suppressed, }: UseSponsorStripProps = {}): boolean => { const { isPlus } = usePlusSubscription(); // `useViewSizeClient`, not `useViewSize`: the latter reads matchMedia in its @@ -31,7 +38,11 @@ export const useSponsorStrip = ({ const isTablet = useViewSizeClient(ViewSize.Tablet); // A logo wall on a phone costs more feed than it can hold logos. const isEligible = - !isPlus && !disableAds && isTablet && isSponsorStripFeed(feedName); + !isPlus && + !disableAds && + !suppressed && + isTablet && + isSponsorStripFeed(feedName); const { value: isOn } = useConditionalFeature({ feature: featureSponsorStrip, shouldEvaluate: isEligible, diff --git a/packages/shared/src/features/monetization/sponsorStrip/useSponsorStripFeed.spec.tsx b/packages/shared/src/features/monetization/sponsorStrip/useSponsorStripFeed.spec.tsx index dab23fff9e0..5d9a7272e5d 100644 --- a/packages/shared/src/features/monetization/sponsorStrip/useSponsorStripFeed.spec.tsx +++ b/packages/shared/src/features/monetization/sponsorStrip/useSponsorStripFeed.spec.tsx @@ -17,8 +17,8 @@ const settled = (headlines: StatuslineItem[]) => ({ isSettled: true, }); -const render = () => - renderHook(() => useSponsorStripFeed({ feedName: 'my-feed' })); +const render = (props: { suppressed?: boolean } = {}) => + renderHook(() => useSponsorStripFeed({ feedName: 'my-feed', ...props })); beforeEach(() => { jest.clearAllMocks(); @@ -26,6 +26,14 @@ beforeEach(() => { mockHeadlines.mockReturnValue(settled([headline])); }); +it('should hand the suppression to the strip gate', () => { + render({ suppressed: true }); + + expect(mockStrip).toHaveBeenCalledWith( + expect.objectContaining({ suppressed: true }), + ); +}); + it('should not query headlines when the strip is off', () => { mockStrip.mockReturnValue(false); render(); diff --git a/packages/shared/src/features/monetization/sponsorStrip/useSponsorStripFeed.ts b/packages/shared/src/features/monetization/sponsorStrip/useSponsorStripFeed.ts index cc10d7b583e..d665d7ab62b 100644 --- a/packages/shared/src/features/monetization/sponsorStrip/useSponsorStripFeed.ts +++ b/packages/shared/src/features/monetization/sponsorStrip/useSponsorStripFeed.ts @@ -5,6 +5,7 @@ import { useStripHeadlines } from './useStripHeadlines'; interface UseSponsorStripFeedProps { feedName?: string; disableAds?: boolean; + suppressed?: boolean; } interface UseSponsorStripFeed { @@ -26,8 +27,9 @@ interface UseSponsorStripFeed { export const useSponsorStripFeed = ({ feedName, disableAds, + suppressed, }: UseSponsorStripFeedProps): UseSponsorStripFeed => { - const isEnabled = useSponsorStrip({ feedName, disableAds }); + const isEnabled = useSponsorStrip({ feedName, disableAds, suppressed }); const { headlines, isSettled } = useStripHeadlines(isEnabled); return { diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 82865d3b410..fb4a71ce0f2 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -306,11 +306,6 @@ export const featureFeedContentVisibility = new Feature( false, ); -export const featurePublicSignupBanner = new Feature( - 'public_signup_banner', - false, -); - // Experiment: the post page's full-width signup banner against the cover // card pinned over the bottom of the window at the content's width. export const featurePostSignupStrip = new Feature('post_signup_strip', false); diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 31e3541cf52..c24e5adfb6b 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -656,8 +656,8 @@ export enum TargetId { FeedbackOpen = 'feedback open', SearchReferralBadge = 'search referral badge', InviteBanner = 'invite banner', - ExploreStrip = 'explore strip', PostStrip = 'post strip', + PublicPageSignupBanner = 'public page signup banner', MobileHeader = 'mobile header', InviteProfileMenu = 'invite in profile menu', SearchActivation = 'search activation', diff --git a/packages/webapp/__tests__/ExplorePage.tsx b/packages/webapp/__tests__/ExplorePage.tsx new file mode 100644 index 00000000000..43f351a9703 --- /dev/null +++ b/packages/webapp/__tests__/ExplorePage.tsx @@ -0,0 +1,106 @@ +import nock from 'nock'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; +import type { AuthContextData } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; +import { + useViewSize, + useViewSizeClient, +} from '@dailydotdev/shared/src/hooks/useViewSize'; +import { checkIsExtension } from '@dailydotdev/shared/src/lib/func'; +import { TestBootProvider } from '@dailydotdev/shared/__tests__/helpers/boot'; +import defaultUser from '@dailydotdev/shared/__tests__/fixture/loggedUser'; +import Posts from '../pages/posts/index'; + +jest.setTimeout(30000); + +jest.mock('@dailydotdev/shared/src/hooks/useConditionalFeature', () => ({ + __esModule: true, + useConditionalFeature: jest.fn(), +})); + +jest.mock('@dailydotdev/shared/src/lib/func', () => ({ + ...jest.requireActual('@dailydotdev/shared/src/lib/func'), + checkIsExtension: jest.fn(() => false), +})); + +jest.mock('@dailydotdev/shared/src/hooks/useViewSize', () => ({ + ...jest.requireActual('@dailydotdev/shared/src/hooks/useViewSize'), + useViewSize: jest.fn(), + useViewSizeClient: jest.fn(), +})); + +const mockFeature = jest.mocked(useConditionalFeature); + +const sponsorStripEvaluations = () => + mockFeature.mock.calls + .filter(([args]) => args.feature.id === 'sponsor_strip') + .map(([args]) => args.shouldEvaluate); + +beforeAll(async () => { + await import('@dailydotdev/shared/src/components/MainFeedLayout'); +}); + +beforeEach(() => { + jest.clearAllMocks(); + nock.cleanAll(); + jest.mocked(checkIsExtension).mockReturnValue(false); + jest.mocked(useViewSize).mockReturnValue(true); + jest.mocked(useViewSizeClient).mockReturnValue(true); + mockFeature.mockImplementation(({ feature }) => ({ + value: feature.defaultValue, + isLoading: false, + })); + jest.mocked(useRouter).mockImplementation( + () => + ({ + pathname: '/posts', + query: {}, + replace: jest.fn(), + push: jest.fn(), + isReady: true, + } as unknown as NextRouter), + ); +}); + +const tree = (auth: Partial) => ( + + {Posts.getLayout(, {}, Posts.layoutProps)} + +); + +const bannerHeadline = 'Where developers suffer together'; + +it('should give an anonymous laptop visitor the signup banner and never the sponsor dock', async () => { + const { rerender } = render(tree({ isAuthReady: false })); + + expect(screen.queryByText(bannerHeadline)).not.toBeInTheDocument(); + expect(sponsorStripEvaluations()).not.toContain(true); + + rerender(tree({ isAuthReady: true })); + + expect(await screen.findByText(bannerHeadline)).toBeInTheDocument(); + expect(screen.queryByTestId('sponsorStrip')).not.toBeInTheDocument(); + expect(sponsorStripEvaluations()).not.toContain(true); +}); + +it('should leave the extension new tab alone', async () => { + jest.mocked(checkIsExtension).mockReturnValue(true); + render(tree({ isAuthReady: true })); + + expect(screen.queryByText(bannerHeadline)).not.toBeInTheDocument(); + expect(sponsorStripEvaluations()).toContain(true); +}); + +it('should leave the sponsor dock to members', async () => { + render(tree({ isAuthReady: true, isLoggedIn: true, user: defaultUser })); + + expect(screen.queryByText(bannerHeadline)).not.toBeInTheDocument(); + expect(sponsorStripEvaluations()).toContain(true); +}); diff --git a/packages/webapp/components/layouts/ProfileLayout/index.tsx b/packages/webapp/components/layouts/ProfileLayout/index.tsx index 00b92eef294..460ebe9facf 100644 --- a/packages/webapp/components/layouts/ProfileLayout/index.tsx +++ b/packages/webapp/components/layouts/ProfileLayout/index.tsx @@ -22,7 +22,6 @@ import { useProfile } from '@dailydotdev/shared/src/hooks/profile/useProfile'; import { useTrackQuestClientEvent } from '@dailydotdev/shared/src/hooks/useTrackQuestClientEvent'; import CustomAuthBanner from '@dailydotdev/shared/src/components/auth/CustomAuthBanner'; import { PublicPageSignupBanner } from '@dailydotdev/shared/src/components/auth/PublicPageSignupBanner'; -import { ExploreSignupStrip } from '@dailydotdev/shared/src/components/auth/ExploreSignupStrip'; import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; import { LogEvent, TargetType } from '@dailydotdev/shared/src/lib/log'; @@ -158,7 +157,6 @@ export default function ProfileLayout({ )}
- {children}
+ ); diff --git a/packages/webapp/pages/sources/index.tsx b/packages/webapp/pages/sources/index.tsx index 630c75f044b..10fa3f205cc 100644 --- a/packages/webapp/pages/sources/index.tsx +++ b/packages/webapp/pages/sources/index.tsx @@ -25,7 +25,6 @@ import { ExploreHubHeader } from '@dailydotdev/shared/src/components/header/Expl import { PageWrapperLayout } from '@dailydotdev/shared/src/components/layout/PageWrapperLayout'; import { SourceTopList } from '@dailydotdev/shared/src/components/cards/Leaderboard'; import { PublicPageSignupBanner } from '@dailydotdev/shared/src/components/auth/PublicPageSignupBanner'; -import { ExploreSignupStrip } from '@dailydotdev/shared/src/components/auth/ExploreSignupStrip'; import { getLayout } from '../../components/layouts/MainLayout'; import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; import { defaultOpenGraph } from '../../next-seo'; @@ -133,7 +132,6 @@ const SourcesPage = ({ }} /> - {!isV2Laptop && (
diff --git a/packages/webapp/pages/users.tsx b/packages/webapp/pages/users.tsx index 1a46ccbbdf8..6264db9764e 100644 --- a/packages/webapp/pages/users.tsx +++ b/packages/webapp/pages/users.tsx @@ -24,7 +24,6 @@ import { CompanyTopList } from '@dailydotdev/shared/src/components/cards/Leaderb import type { PopularHotTakes } from '@dailydotdev/shared/src/components/cards/Leaderboard/PopularHotTakesList'; import { PopularHotTakesList } from '@dailydotdev/shared/src/components/cards/Leaderboard/PopularHotTakesList'; import { PublicPageSignupBanner } from '@dailydotdev/shared/src/components/auth/PublicPageSignupBanner'; -import { ExploreSignupStrip } from '@dailydotdev/shared/src/components/auth/ExploreSignupStrip'; import { getLayout as getFooterNavBarLayout } from '../components/layouts/FooterNavBarLayout'; import { getLayout } from '../components/layouts/MainLayout'; import { defaultOpenGraph } from '../next-seo'; @@ -93,7 +92,6 @@ const LeaderboardPage = ({ <> {isV2Laptop && } - {!isV2Laptop && (
diff --git a/packages/webapp/pages/watercooler.tsx b/packages/webapp/pages/watercooler.tsx index 1d0a9128afe..11f01273dbc 100644 --- a/packages/webapp/pages/watercooler.tsx +++ b/packages/webapp/pages/watercooler.tsx @@ -3,7 +3,7 @@ import React, { useMemo } from 'react'; import type { NextSeoProps } from 'next-seo'; import dynamic from 'next/dynamic'; import Feed from '@dailydotdev/shared/src/components/Feed'; -import { ExploreSignupStrip } from '@dailydotdev/shared/src/components/auth/ExploreSignupStrip'; +import { PublicPageSignupBanner } from '@dailydotdev/shared/src/components/auth/PublicPageSignupBanner'; import { SOURCE_FEED_QUERY, baseFeedSupportedTypes, @@ -90,7 +90,6 @@ const WatercoolerPage = (): ReactElement => { return (
-
{squad.name} @@ -126,6 +125,7 @@ const WatercoolerPage = (): ReactElement => { hideTags disableListFrame /> + ); }; diff --git a/packages/webapp/pages/world/index.tsx b/packages/webapp/pages/world/index.tsx index 2c8af63cc85..f1e3018e63e 100644 --- a/packages/webapp/pages/world/index.tsx +++ b/packages/webapp/pages/world/index.tsx @@ -9,7 +9,7 @@ import { ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; import { ProgressBar } from '@dailydotdev/shared/src/components/fields/ProgressBar'; -import { ExploreSignupStrip } from '@dailydotdev/shared/src/components/auth/ExploreSignupStrip'; +import { PublicPageSignupBanner } from '@dailydotdev/shared/src/components/auth/PublicPageSignupBanner'; import { LayoutHeader } from '@dailydotdev/shared/src/components/layout/common'; import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; import { @@ -371,7 +371,6 @@ function WorldIndexPage(): ReactElement { <> {isV2 && }
- {!isV2 && (
+ ); }