From 240a39a2f67c4a5a3b4b2439184e5ff469d07e28 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 25 Sep 2026 14:45:52 +0300 Subject: [PATCH 1/4] feat(auth): give the public pages the post page's signup banner The pinned card lost its experiment against the post page's banner, so the public pages (explore, tags, tag pages, squads, sources, source pages, leaderboard, best-of, worlds, watercooler, profiles) drop the inline cover strip and get that banner instead: the full-width bar pinned to the bottom of the window for anonymous laptop visitors, with the same personalized referrer, social and geo variants the post page renders. PublicPageSignupBanner already mounted it behind public_signup_banner on five of these pages; it now mounts everywhere unconditionally, the flag goes, and it carries the same clearance the post page adds so the end of each page stays reachable above the bar. On the explore hub it also takes the sponsor dock's bottom slot, so the dock is disabled while the banner shows. The strip, its target id and the cover strip's button pair are removed; the extension's new tab still uses HijackingCoverStrip. Co-Authored-By: Claude Fable 5.1 --- .../shared/src/components/MainFeedLayout.tsx | 23 ++- .../auth/ExploreSignupStrip.spec.tsx | 142 ------------------ .../components/auth/ExploreSignupStrip.tsx | 84 ----------- .../components/auth/HijackingCoverStrip.tsx | 60 +------- .../auth/PublicPageSignupBanner.spec.tsx | 68 +++++++++ .../auth/PublicPageSignupBanner.tsx | 28 ++-- .../squads/layout/SquadDirectoryLayout.tsx | 5 +- .../src/components/tags/TagTopicPage.tsx | 2 - .../src/components/tags/TagsDirectoryPage.tsx | 2 - packages/shared/src/lib/featureManagement.ts | 5 - packages/shared/src/lib/log.ts | 1 - .../layouts/ProfileLayout/index.tsx | 2 - packages/webapp/pages/posts/best-of/index.tsx | 4 +- packages/webapp/pages/sources/[source].tsx | 7 +- packages/webapp/pages/sources/index.tsx | 2 - packages/webapp/pages/users.tsx | 2 - packages/webapp/pages/watercooler.tsx | 4 +- packages/webapp/pages/world/index.tsx | 4 +- 18 files changed, 108 insertions(+), 337 deletions(-) delete mode 100644 packages/shared/src/components/auth/ExploreSignupStrip.spec.tsx delete mode 100644 packages/shared/src/components/auth/ExploreSignupStrip.tsx create mode 100644 packages/shared/src/components/auth/PublicPageSignupBanner.spec.tsx diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index 35097198f94..7f01d2d9632 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, + usePublicPageSignupBanner, +} from './auth/PublicPageSignupBanner'; import FeedContext from '../contexts/FeedContext'; import AuthContext from '../contexts/AuthContext'; import type { LoggedUser } from '../lib/user'; @@ -853,9 +856,14 @@ export default function MainFeedLayout({ // 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. + // Both pin to the window's bottom edge, so an anonymous visitor gets the + // signup banner or the sponsor dock, never both. + const isSignupBannerVisible = usePublicPageSignupBanner(); + const showSignupBanner = + !isExtension && isExploreHub && isSignupBannerVisible; const sponsorStrip = useSponsorStripFeed({ feedName, - disableAds: feedProps?.disableAds, + disableAds: feedProps?.disableAds || showSignupBanner, }); const v2ActionButtons = feedProps?.actionButtons; const showFeedV2PageHeader = @@ -887,16 +895,6 @@ export default function MainFeedLayout({ - {!isExtension && isExploreHub && ( -
- -
- )} {isAnyExplore && !showExploreV2PageHeader && } {isSearchOn && !isSearchPageLaptop && search} {isSearchOn && !isSearchPageLaptop && ( @@ -953,6 +951,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/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/PublicPageSignupBanner.spec.tsx b/packages/shared/src/components/auth/PublicPageSignupBanner.spec.tsx new file mode 100644 index 00000000000..6b124595638 --- /dev/null +++ b/packages/shared/src/components/auth/PublicPageSignupBanner.spec.tsx @@ -0,0 +1,68 @@ +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'; + +jest.mock('../../hooks/useViewSize', () => ({ + ...jest.requireActual('../../hooks/useViewSize'), + useViewSize: jest.fn(), +})); + +jest.mock('./PostAuthBanner', () => ({ + PostAuthBanner: () =>
, +})); + +const mockUseViewSize = useViewSize as jest.Mock; + +const renderComponent = (auth = {}) => + render( + + + , + ); + +beforeEach(() => { + jest.clearAllMocks(); + mockUseViewSize.mockReturnValue(true); +}); + +describe('PublicPageSignupBanner', () => { + it('should render the banner and its clearance for anonymous laptop visitors', () => { + const { container } = renderComponent(); + + expect(screen.getByTestId('post-auth-banner')).toBeInTheDocument(); + expect(container.firstElementChild).toHaveClass('h-72'); + }); + + it('should render nothing for logged-in users', () => { + const { container } = renderComponent({ + isLoggedIn: true, + user: { id: 'u1' }, + }); + + expect(container).toBeEmptyDOMElement(); + }); + + 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..00435421a4c 100644 --- a/packages/shared/src/components/auth/PublicPageSignupBanner.tsx +++ b/packages/shared/src/components/auth/PublicPageSignupBanner.tsx @@ -1,22 +1,30 @@ import type { ReactElement } from 'react'; import React from 'react'; -import { useConditionalFeature, useViewSize, ViewSize } from '../../hooks'; +import { useViewSize, ViewSize } from '../../hooks'; import { useOnboardingActions } from '../../hooks/auth'; -import { featurePublicSignupBanner } from '../../lib/featureManagement'; import { PostAuthBanner } from './PostAuthBanner'; -export function PublicPageSignupBanner(): ReactElement | null { +export const usePublicPageSignupBanner = (): boolean => { const isLaptop = useViewSize(ViewSize.Laptop); const { shouldShowAuthBanner } = useOnboardingActions(); - const shouldEvaluate = shouldShowAuthBanner && isLaptop; - const { value: isEnabled } = useConditionalFeature({ - feature: featurePublicSignupBanner, - shouldEvaluate, - }); - if (!shouldEvaluate || !isEnabled) { + return shouldShowAuthBanner && isLaptop; +}; + +// 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 = usePublicPageSignupBanner(); + + if (!shouldShow) { return null; } - return ; + return ( + <> +
+ + + ); } diff --git a/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx b/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx index 4182913f09f..8f6f66f9c74 100644 --- a/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx +++ b/packages/shared/src/components/squads/layout/SquadDirectoryLayout.tsx @@ -16,7 +16,7 @@ import { useSquadDirectoryLayout } from './useSquadDirectoryLayout'; import { squadCategoriesPaths } from '../../../lib/constants'; import { useLayoutVariant } from '../../../hooks/layout/useLayoutVariant'; import { pageHeaderClassName } from '../../layout/PageHeader'; -import { ExploreSignupStrip } from '../../auth/ExploreSignupStrip'; +import { PublicPageSignupBanner } from '../../auth/PublicPageSignupBanner'; type SquadDirectoryLayoutProps = PropsWithChildren & ComponentProps<'section'>; @@ -110,8 +110,6 @@ export const SquadDirectoryLayout = ( {isDiscover && (
)} - -
{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 */}
- {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 20b74573d73..2cd833dde97 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, supportedTypesForPrivateSources, @@ -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 && (
+ ); } From fe40fc7980abbcb73ea7eb52bd04e8f478e4058e Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 25 Sep 2026 15:10:56 +0300 Subject: [PATCH 2/4] fix(auth): apply the review on the public pages' signup banner - PostAuthBanner loads lazily from PublicPageSignupBanner, as the post page does, so the auth form tree stays out of the feed layout's chunk for members and the extension. - The banner logs one impression per visit under its own target id, so the surface stays measurable now that the strip's id is gone. - Source-file imports instead of the hooks barrels. - The feed layout's new lines move above the sponsor strip's comment, which describes the hook call under it. Co-Authored-By: Claude Fable 5.1 --- .../shared/src/components/MainFeedLayout.tsx | 6 ++--- .../auth/PublicPageSignupBanner.spec.tsx | 23 +++++++++++++++++-- .../auth/PublicPageSignupBanner.tsx | 23 ++++++++++++++++--- packages/shared/src/lib/log.ts | 1 + 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index 7f01d2d9632..ef30b526395 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -853,14 +853,14 @@ export default function MainFeedLayout({ chipsTopContent ); - // 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. // Both pin to the window's bottom edge, so an anonymous visitor gets the // signup banner or the sponsor dock, never both. const isSignupBannerVisible = usePublicPageSignupBanner(); const showSignupBanner = !isExtension && isExploreHub && isSignupBannerVisible; + // 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 || showSignupBanner, diff --git a/packages/shared/src/components/auth/PublicPageSignupBanner.spec.tsx b/packages/shared/src/components/auth/PublicPageSignupBanner.spec.tsx index 6b124595638..4f442c0610e 100644 --- a/packages/shared/src/components/auth/PublicPageSignupBanner.spec.tsx +++ b/packages/shared/src/components/auth/PublicPageSignupBanner.spec.tsx @@ -4,6 +4,7 @@ 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'), @@ -14,7 +15,21 @@ jest.mock('./PostAuthBanner', () => ({ PostAuthBanner: () =>
, })); +// 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( @@ -26,6 +41,7 @@ const renderComponent = (auth = {}) => user: undefined, ...auth, }} + log={{ logEvent }} > , @@ -37,11 +53,13 @@ beforeEach(() => { }); describe('PublicPageSignupBanner', () => { - it('should render the banner and its clearance for anonymous laptop visitors', () => { + it('should render the banner and its clearance for anonymous laptop visitors', async () => { const { container } = renderComponent(); - expect(screen.getByTestId('post-auth-banner')).toBeInTheDocument(); + expect(await screen.findByTestId('post-auth-banner')).toBeInTheDocument(); expect(container.firstElementChild).toHaveClass('h-72'); + expect(logEvent).toHaveBeenCalledTimes(1); + expect(logEvent).toHaveBeenCalledWith(impression); }); it('should render nothing for logged-in users', () => { @@ -51,6 +69,7 @@ describe('PublicPageSignupBanner', () => { }); expect(container).toBeEmptyDOMElement(); + expect(logEvent).not.toHaveBeenCalled(); }); it('should render nothing until boot answers', () => { diff --git a/packages/shared/src/components/auth/PublicPageSignupBanner.tsx b/packages/shared/src/components/auth/PublicPageSignupBanner.tsx index 00435421a4c..110f704c97e 100644 --- a/packages/shared/src/components/auth/PublicPageSignupBanner.tsx +++ b/packages/shared/src/components/auth/PublicPageSignupBanner.tsx @@ -1,8 +1,16 @@ import type { ReactElement } from 'react'; import React from 'react'; -import { useViewSize, ViewSize } from '../../hooks'; -import { useOnboardingActions } from '../../hooks/auth'; -import { PostAuthBanner } from './PostAuthBanner'; +import dynamic from 'next/dynamic'; +import { useViewSize, ViewSize } from '../../hooks/useViewSize'; +import { useOnboardingActions } from '../../hooks/auth/useOnboardingActions'; +import useLogEventOnce from '../../hooks/log/useLogEventOnce'; +import { LogEvent, TargetId, TargetType } from '../../lib/log'; + +const PostAuthBanner = dynamic(() => + import(/* webpackChunkName: "postAuthBanner" */ './PostAuthBanner').then( + (mod) => mod.PostAuthBanner, + ), +); export const usePublicPageSignupBanner = (): boolean => { const isLaptop = useViewSize(ViewSize.Laptop); @@ -17,6 +25,15 @@ export const usePublicPageSignupBanner = (): boolean => { export function PublicPageSignupBanner(): ReactElement | null { const shouldShow = usePublicPageSignupBanner(); + useLogEventOnce( + () => ({ + event_name: LogEvent.Impression, + target_type: TargetType.SignupButton, + target_id: TargetId.PublicPageSignupBanner, + }), + { condition: shouldShow }, + ); + if (!shouldShow) { return null; } diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 295baba9c57..1022630eb45 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -654,6 +654,7 @@ export enum TargetId { SearchReferralBadge = 'search referral badge', InviteBanner = 'invite banner', PostStrip = 'post strip', + PublicPageSignupBanner = 'public page signup banner', InviteProfileMenu = 'invite in profile menu', SearchActivation = 'search activation', // Referral campaign From 7eeec81c78342ed360ff3181e8a3c3f77fe3b6a7 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 28 Sep 2026 10:23:05 +0300 Subject: [PATCH 3/4] fix(feed): keep the sponsor dock out of the signup banner's way before boot The dock's gate took the banner through disableAds and only once boot had answered, so a returning anonymous visitor, whose GrowthBook payload is ready from the boot cache first, was enrolled in sponsor_strip and shown the dock for the moment before the banner replaced it. The gate now takes its own `suppressed` input, set while auth is unknown or anonymous on a laptop explore hub, so the flag is never evaluated for someone who may get the banner. The banner also threads its target id down to AuthOptions, so provider clicks on the public pages report under the same id as the impression. Co-Authored-By: Claude Fable 5.1 --- .../shared/src/components/MainFeedLayout.tsx | 14 ++- .../components/auth/AuthenticationBanner.tsx | 3 + .../src/components/auth/PostAuthBanner.tsx | 28 +++++- .../auth/PublicPageSignupBanner.spec.tsx | 9 +- .../auth/PublicPageSignupBanner.tsx | 2 +- .../personalized/GeoPersonalizedBanner.tsx | 4 +- .../personalized/SocialPersonalizedBanner.tsx | 4 +- .../personalized/UserPersonalizedBanner.tsx | 6 +- .../sponsorStrip/useSponsorStrip.spec.tsx | 17 +++- .../sponsorStrip/useSponsorStrip.ts | 13 ++- .../sponsorStrip/useSponsorStripFeed.spec.tsx | 12 ++- .../sponsorStrip/useSponsorStripFeed.ts | 4 +- packages/webapp/__tests__/ExplorePage.tsx | 91 +++++++++++++++++++ 13 files changed, 186 insertions(+), 21 deletions(-) create mode 100644 packages/webapp/__tests__/ExplorePage.tsx diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index ef30b526395..70ecb0d46f3 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -252,7 +252,7 @@ export default function MainFeedLayout({ }: MainFeedLayoutProps): ReactElement { useScrollRestoration(); const { sortingEnabled, loadedSettings } = useContext(SettingsContext); - const { user, isTokenValid } = useContext(AuthContext); + const { user, isTokenValid, isAuthReady } = useContext(AuthContext); const { alerts } = useContext(AlertContext); const { numCards: feedSpacinessCards } = useContext(FeedContext); const feedWidthStyle = { @@ -853,17 +853,23 @@ 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. const isSignupBannerVisible = usePublicPageSignupBanner(); const showSignupBanner = !isExtension && isExploreHub && isSignupBannerVisible; + // 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 mayShowSignupBanner = + !isExtension && isExploreHub && isLaptop && (!isAuthReady || !user); // 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 || showSignupBanner, + disableAds: feedProps?.disableAds, + suppressed: mayShowSignupBanner, }); const v2ActionButtons = feedProps?.actionButtons; const showFeedV2PageHeader = 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/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 index 4f442c0610e..0e4cdbf8faf 100644 --- a/packages/shared/src/components/auth/PublicPageSignupBanner.spec.tsx +++ b/packages/shared/src/components/auth/PublicPageSignupBanner.spec.tsx @@ -12,7 +12,9 @@ jest.mock('../../hooks/useViewSize', () => ({ })); jest.mock('./PostAuthBanner', () => ({ - PostAuthBanner: () =>
, + PostAuthBanner: ({ targetId }: { targetId?: string }) => ( +
+ ), })); // The banner loads lazily; the suite-wide dynamic mock resolves a tick late. @@ -56,7 +58,10 @@ 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')).toBeInTheDocument(); + 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); diff --git a/packages/shared/src/components/auth/PublicPageSignupBanner.tsx b/packages/shared/src/components/auth/PublicPageSignupBanner.tsx index 110f704c97e..dc2e4b467ee 100644 --- a/packages/shared/src/components/auth/PublicPageSignupBanner.tsx +++ b/packages/shared/src/components/auth/PublicPageSignupBanner.tsx @@ -41,7 +41,7 @@ export function PublicPageSignupBanner(): ReactElement | null { 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 ( - + - 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/webapp/__tests__/ExplorePage.tsx b/packages/webapp/__tests__/ExplorePage.tsx new file mode 100644 index 00000000000..85d1662a0e1 --- /dev/null +++ b/packages/webapp/__tests__/ExplorePage.tsx @@ -0,0 +1,91 @@ +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 { 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/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(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 sponsor dock to members', async () => { + render(tree({ isAuthReady: true, isLoggedIn: true, user: defaultUser })); + + expect(screen.queryByText(bannerHeadline)).not.toBeInTheDocument(); + expect(sponsorStripEvaluations()).toContain(true); +}); From 413b1c5456e728c10f6eeb7cfbc4172c025f0a07 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 28 Sep 2026 20:06:56 +0300 Subject: [PATCH 4/4] refactor(auth): one gate for the signup banner and what yields to it The feed layout spelled the banner's policy out twice, once through the banner's hook and once inline for the sponsor dock's suppression, and the two could drift. The banner now exports a single gate with both readings (may show, should show) and the layout derives both flags from it. Co-Authored-By: Claude Fable 5.1 --- .../shared/src/components/MainFeedLayout.tsx | 13 ++++++------- .../auth/PublicPageSignupBanner.tsx | 19 ++++++++++++++----- packages/webapp/__tests__/ExplorePage.tsx | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index 70ecb0d46f3..ffba4ce4fc3 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -32,7 +32,7 @@ import { AskSearchBanner } from './marketing/banners/AskSearchBanner'; import { FeedEngagementBanner } from './brand/FeedEngagementBanner'; import { PublicPageSignupBanner, - usePublicPageSignupBanner, + usePublicPageSignupBannerGate, } from './auth/PublicPageSignupBanner'; import FeedContext from '../contexts/FeedContext'; import AuthContext from '../contexts/AuthContext'; @@ -252,7 +252,7 @@ export default function MainFeedLayout({ }: MainFeedLayoutProps): ReactElement { useScrollRestoration(); const { sortingEnabled, loadedSettings } = useContext(SettingsContext); - const { user, isTokenValid, isAuthReady } = useContext(AuthContext); + const { user, isTokenValid } = useContext(AuthContext); const { alerts } = useContext(AlertContext); const { numCards: feedSpacinessCards } = useContext(FeedContext); const feedWidthStyle = { @@ -853,16 +853,15 @@ export default function MainFeedLayout({ chipsTopContent ); - const isSignupBannerVisible = usePublicPageSignupBanner(); - const showSignupBanner = - !isExtension && isExploreHub && isSignupBannerVisible; // 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 mayShowSignupBanner = - !isExtension && isExploreHub && isLaptop && (!isAuthReady || !user); + 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. diff --git a/packages/shared/src/components/auth/PublicPageSignupBanner.tsx b/packages/shared/src/components/auth/PublicPageSignupBanner.tsx index dc2e4b467ee..72f46767e64 100644 --- a/packages/shared/src/components/auth/PublicPageSignupBanner.tsx +++ b/packages/shared/src/components/auth/PublicPageSignupBanner.tsx @@ -2,7 +2,7 @@ import type { ReactElement } from 'react'; import React from 'react'; import dynamic from 'next/dynamic'; import { useViewSize, ViewSize } from '../../hooks/useViewSize'; -import { useOnboardingActions } from '../../hooks/auth/useOnboardingActions'; +import { useAuthContext } from '../../contexts/AuthContext'; import useLogEventOnce from '../../hooks/log/useLogEventOnce'; import { LogEvent, TargetId, TargetType } from '../../lib/log'; @@ -12,18 +12,27 @@ const PostAuthBanner = dynamic(() => ), ); -export const usePublicPageSignupBanner = (): boolean => { +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 { isAuthReady, user } = useAuthContext(); + const mayShow = isLaptop && (!isAuthReady || !user); - return shouldShowAuthBanner && isLaptop; + 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 = usePublicPageSignupBanner(); + const { shouldShow } = usePublicPageSignupBannerGate(); useLogEventOnce( () => ({ diff --git a/packages/webapp/__tests__/ExplorePage.tsx b/packages/webapp/__tests__/ExplorePage.tsx index 85d1662a0e1..43f351a9703 100644 --- a/packages/webapp/__tests__/ExplorePage.tsx +++ b/packages/webapp/__tests__/ExplorePage.tsx @@ -10,6 +10,7 @@ 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'; @@ -21,6 +22,11 @@ jest.mock('@dailydotdev/shared/src/hooks/useConditionalFeature', () => ({ 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(), @@ -41,6 +47,7 @@ beforeAll(async () => { beforeEach(() => { jest.clearAllMocks(); nock.cleanAll(); + jest.mocked(checkIsExtension).mockReturnValue(false); jest.mocked(useViewSize).mockReturnValue(true); jest.mocked(useViewSizeClient).mockReturnValue(true); mockFeature.mockImplementation(({ feature }) => ({ @@ -83,6 +90,14 @@ it('should give an anonymous laptop visitor the signup banner and never the spon 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 }));