diff --git a/packages/extension/src/newtab/HijackingLoginStrip.spec.tsx b/packages/extension/src/newtab/HijackingLoginStrip.spec.tsx index 087c8a528b..90aa7d28ba 100644 --- a/packages/extension/src/newtab/HijackingLoginStrip.spec.tsx +++ b/packages/extension/src/newtab/HijackingLoginStrip.spec.tsx @@ -1,8 +1,13 @@ import React from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { AuthContextData } from '@dailydotdev/shared/src/contexts/AuthContext'; import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks'; +import { useSignBack } from '@dailydotdev/shared/src/hooks/auth/useSignBack'; +import { SocialProvider } from '@dailydotdev/shared/src/components/auth/common'; +import { HijackingVariant } from '@dailydotdev/shared/src/lib/featureManagement'; import { AuthTriggers } from '@dailydotdev/shared/src/lib/auth'; import { onboardingUrl } from '@dailydotdev/shared/src/lib/constants'; import { LogEvent, TargetType } from '@dailydotdev/shared/src/lib/log'; @@ -14,12 +19,41 @@ jest.mock('@dailydotdev/shared/src/contexts/AuthContext', () => ({ useAuthContext: jest.fn(), })); +jest.mock('@dailydotdev/shared/src/hooks', () => ({ + ...jest.requireActual('@dailydotdev/shared/src/hooks'), + useConditionalFeature: jest.fn(), +})); + +jest.mock('@dailydotdev/shared/src/hooks/auth/useSignBack', () => ({ + useSignBack: jest.fn(), +})); + +const signupHref = (() => { + const url = new URL(onboardingUrl); + url.searchParams.append('r', 'extension'); + + return url.toString(); +})(); + +const loginHref = (() => { + const url = new URL(onboardingUrl); + url.searchParams.append('r', 'extension'); + url.searchParams.append('action', 'login'); + + return url.toString(); +})(); + const LogContext = getLogContextStatic(); const mockUseAuthContext = useAuthContext as jest.MockedFunction< typeof useAuthContext >; +const mockUseSignBack = useSignBack as jest.MockedFunction; +const mockUseConditionalFeature = useConditionalFeature as jest.MockedFunction< + typeof useConditionalFeature +>; const logEvent = jest.fn(); const showLogin = jest.fn(); +const assignMock = jest.fn(); const defaultAuthContext = { user: undefined, @@ -53,6 +87,16 @@ const defaultAuthContext = { isFunnel: false, } satisfies AuthContextData; +const setVariant = ( + variant: HijackingVariant, + { isLoading = false }: { isLoading?: boolean } = {}, +): void => { + mockUseConditionalFeature.mockReturnValue({ + value: variant, + isLoading, + }); +}; + const renderComponent = ( authContext: Partial = {}, ): ReturnType => { @@ -61,71 +105,321 @@ const renderComponent = ( ...authContext, }); - return render( - - - , + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + const Wrapper = ({ + children, + }: { + children: React.ReactNode; + }): React.ReactElement => ( + + + {children} + + ); + + return render(, { wrapper: Wrapper }); }; +beforeAll(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...window.location, assign: assignMock }, + }); +}); + beforeEach(() => { jest.clearAllMocks(); + setVariant(HijackingVariant.Default); + mockUseSignBack.mockReturnValue({ + isLoaded: true, + signBack: undefined, + provider: undefined, + onUpdateSignBack: jest.fn(), + }); }); describe('HijackingLoginStrip', () => { - it('shows a login CTA for logged out users', () => { - renderComponent(); - - expect( - screen.getByText('Unlock the full daily.dev experience'), - ).toBeVisible(); - expect( - screen.getByText('Log in to pick up where you left off.'), - ).toBeVisible(); - - const cta = screen.getByRole('button', { name: 'Log in to continue' }); - fireEvent.click(cta); - - expect(logEvent).toHaveBeenCalledWith({ - event_name: LogEvent.Click, - target_type: TargetType.LoginButton, - target_id: 'hijacking', + it('renders nothing while the experiment is loading', () => { + setVariant(HijackingVariant.Default, { isLoading: true }); + + const { container } = renderComponent(); + + expect(container).toBeEmptyDOMElement(); + }); + + describe('default variant', () => { + it('shows the original banner with a log in CTA for logged out users', () => { + renderComponent(); + + expect( + screen.getByRole('heading', { + name: 'Unlock the full daily.dev experience', + }), + ).toBeVisible(); + expect( + screen.getByText('Log in to pick up where you left off.'), + ).toBeVisible(); + + fireEvent.click( + screen.getByRole('button', { name: 'Log in to continue' }), + ); + + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.Click, + target_type: TargetType.LoginButton, + target_id: 'hijacking', + }); + expect(showLogin).toHaveBeenCalledWith({ + trigger: AuthTriggers.Onboarding, + options: { isLogin: true }, + }); + }); + + it('shows an onboarding CTA for logged in users who still need onboarding', () => { + renderComponent({ user: loggedUser, isLoggedIn: true }); + + expect( + screen.getByText(/You still have a few onboarding steps left/), + ).toBeVisible(); + + const cta = screen.getByRole('link', { name: 'Continue onboarding' }); + + expect(cta).toHaveAttribute('href', signupHref); + + fireEvent.click(cta); + + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.Click, + target_type: TargetType.LoginButton, + target_id: 'hijacking', + }); + expect(showLogin).not.toHaveBeenCalled(); + }); + }); + + describe('cta variant', () => { + beforeEach(() => { + setVariant(HijackingVariant.CTA); + }); + + it('redirects to the webapp onboarding from the cat stage hero CTAs', () => { + renderComponent(); + + expect( + screen.getByRole('heading', { + name: 'Own your new tab. Make it your dev briefing.', + }), + ).toBeVisible(); + + fireEvent.click(screen.getByRole('button', { name: 'Sign up' })); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.Click, + target_type: TargetType.SignupButton, + target_id: 'hijacking', + }); + expect(assignMock).toHaveBeenCalledWith(signupHref); + + fireEvent.click(screen.getByRole('button', { name: 'Log in' })); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.Click, + target_type: TargetType.LoginButton, + target_id: 'hijacking', + }); + expect(assignMock).toHaveBeenCalledWith(loginHref); }); - expect(showLogin).toHaveBeenCalledWith({ - trigger: AuthTriggers.Onboarding, - options: { isLogin: true }, + + it('logs a signup impression for new visitors', () => { + renderComponent(); + + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.Impression, + target_type: TargetType.SignupButton, + target_id: 'hijacking', + }); }); }); - it('shows an onboarding CTA for logged in users who still need onboarding', () => { - renderComponent({ user: loggedUser, isLoggedIn: true }); + describe('auth variant', () => { + beforeEach(() => { + setVariant(HijackingVariant.Auth); + }); + + it('redirects social and email sign-up options to the webapp onboarding', () => { + renderComponent(); + + expect( + screen.getByRole('heading', { + name: 'Where developers make every tab count.', + }), + ).toBeVisible(); + + fireEvent.click( + screen.getByRole('button', { name: 'Continue with GitHub' }), + ); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.Click, + target_type: TargetType.SignupButton, + target_id: 'hijacking', + }); + expect(assignMock).toHaveBeenCalledWith(signupHref); + + fireEvent.click( + screen.getByRole('button', { name: 'Signup using email' }), + ); + expect(assignMock).toHaveBeenCalledWith(signupHref); + }); + + it('redirects the log in link to the webapp onboarding login flow', () => { + renderComponent(); + + fireEvent.click(screen.getByRole('button', { name: 'Log in' })); + + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.Click, + target_type: TargetType.LoginButton, + target_id: 'hijacking', + }); + expect(assignMock).toHaveBeenCalledWith(loginHref); + }); + + it('logs a signup impression for new visitors', () => { + renderComponent(); + + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.Impression, + target_type: TargetType.SignupButton, + target_id: 'hijacking', + }); + }); + + it('waits for remembered account storage before logging the impression', () => { + let signBackState: ReturnType = { + isLoaded: false, + signBack: undefined, + provider: undefined, + onUpdateSignBack: jest.fn(), + }; + + mockUseSignBack.mockImplementation(() => signBackState); + + const { rerender } = renderComponent(); + + expect(logEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ event_name: LogEvent.Impression }), + ); + + signBackState = { + isLoaded: true, + signBack: { + name: 'Tsahi Matsliah', + email: 'tsahi@daily.dev', + image: 'https://daily.dev/tsahi.png', + }, + provider: SocialProvider.Google, + onUpdateSignBack: jest.fn(), + }; + + rerender(); + + expect( + screen.getByRole('heading', { name: /Welcome back, Tsahi/ }), + ).toBeVisible(); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.Impression, + target_type: TargetType.LoginButton, + target_id: 'hijacking', + }); + expect(logEvent).not.toHaveBeenCalledWith({ + event_name: LogEvent.Impression, + target_type: TargetType.SignupButton, + target_id: 'hijacking', + }); + }); + + it('offers a welcome-back "Continue as" that redirects to the login flow', () => { + mockUseSignBack.mockReturnValue({ + isLoaded: true, + signBack: { + name: 'Tsahi Matsliah', + email: 'tsahi@daily.dev', + image: 'https://daily.dev/tsahi.png', + }, + provider: SocialProvider.Google, + onUpdateSignBack: jest.fn(), + }); + + renderComponent(); + + expect( + screen.getByRole('heading', { name: /Welcome back, Tsahi/ }), + ).toBeVisible(); + expect(screen.getByText('tsahi@daily.dev')).toBeVisible(); + + fireEvent.click( + screen.getByRole('button', { name: /Continue as Tsahi/ }), + ); + + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.Click, + target_type: TargetType.LoginButton, + target_id: 'hijacking', + }); + expect(assignMock).toHaveBeenCalledWith(loginHref); + }); + + it('lets remembered users create a different account', () => { + mockUseSignBack.mockReturnValue({ + isLoaded: true, + signBack: { + name: 'Tsahi Matsliah', + email: 'tsahi@daily.dev', + image: 'https://daily.dev/tsahi.png', + }, + provider: SocialProvider.Google, + onUpdateSignBack: jest.fn(), + }); + + renderComponent(); + + fireEvent.click( + screen.getByRole('button', { name: 'Create an account' }), + ); + + expect(assignMock).toHaveBeenCalledWith(signupHref); + }); + + it('shows an onboarding CTA for logged in users who still need onboarding', () => { + renderComponent({ user: loggedUser, isLoggedIn: true }); - expect( - screen.getByText( - 'You still have a few onboarding steps left. Finish them to unlock the full experience.', - ), - ).toBeVisible(); + expect( + screen.getByText( + 'Finish onboarding to unlock the full daily.dev experience.', + ), + ).toBeVisible(); - const cta = screen.getByRole('link', { name: 'Continue onboarding' }); - const expectedUrl = new URL(onboardingUrl); - expectedUrl.searchParams.append('r', 'extension'); + const cta = screen.getByRole('link', { name: /Continue/ }); - expect(cta).toHaveAttribute('href', expectedUrl.toString()); + expect(cta).toHaveAttribute('href', signupHref); - fireEvent.click(cta); + fireEvent.click(cta); - expect(logEvent).toHaveBeenCalledWith({ - event_name: LogEvent.Click, - target_type: TargetType.LoginButton, - target_id: 'hijacking', + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.Click, + target_type: TargetType.LoginButton, + target_id: 'hijacking', + }); + expect(showLogin).not.toHaveBeenCalled(); }); - expect(showLogin).not.toHaveBeenCalled(); }); }); diff --git a/packages/extension/src/newtab/HijackingLoginStrip.tsx b/packages/extension/src/newtab/HijackingLoginStrip.tsx index 7185dbc58a..ed8562ee06 100644 --- a/packages/extension/src/newtab/HijackingLoginStrip.tsx +++ b/packages/extension/src/newtab/HijackingLoginStrip.tsx @@ -1,28 +1,231 @@ -import type { ReactElement } from 'react'; -import React from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useRef } from 'react'; import classNames from 'classnames'; import { Button, + ButtonSize, ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; +import { ClickableText } from '@dailydotdev/shared/src/components/buttons/ClickableText'; +import { OnboardingRegistrationForm } from '@dailydotdev/shared/src/components/auth/OnboardingRegistrationForm'; +import { + ProfileImageSize, + ProfilePicture, +} from '@dailydotdev/shared/src/components/ProfilePicture'; +import { + OnboardingActions, + providerMap, + type SocialProvider, +} from '@dailydotdev/shared/src/components/auth/common'; +import { onboardingGradientClasses } from '@dailydotdev/shared/src/components/onboarding/common'; import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks'; +import { useSignBack } from '@dailydotdev/shared/src/hooks/auth/useSignBack'; import { AuthTriggers } from '@dailydotdev/shared/src/lib/auth'; import { onboardingUrl } from '@dailydotdev/shared/src/lib/constants'; +import { + cloudinaryOnboardingFullBackgroundDesktop, + cloudinaryOnboardingFullBackgroundMobile, + cloudinaryReadingReminderCat, +} from '@dailydotdev/shared/src/lib/image'; +import { + featureHijackingVariants, + HijackingVariant, +} from '@dailydotdev/shared/src/lib/featureManagement'; import { LogEvent, TargetType } from '@dailydotdev/shared/src/lib/log'; import feedStyles from '@dailydotdev/shared/src/components/Feed.module.css'; -import { cloudinaryReadingReminderCat } from '@dailydotdev/shared/src/lib/image'; +import LogoIcon from '@dailydotdev/shared/src/svg/LogoIcon'; +import LogoText from '@dailydotdev/shared/src/svg/LogoText'; + +type CoverVariant = 'continue' | 'signin' | 'onboarding'; + +const primaryCta = + 'transition-transform duration-200 ease-out hover:-translate-y-0.5'; + +const glassCta = + '!border-white/20 !bg-white/[0.06] !text-white backdrop-blur-sm transition-colors duration-200 hover:!bg-white/[0.12]'; + +// The extension can't run social OAuth from its own origin (the API rejects it +// with a 403). Instead we hand auth off to the webapp onboarding flow, which +// runs on a trusted origin and auto-triggers the relevant auth screen. +const buildOnboardingHref = (action?: OnboardingActions): string => { + const base = new URL(onboardingUrl); + base.searchParams.append('r', 'extension'); + if (action) { + base.searchParams.append('action', action); + } + + return base.toString(); +}; + +const onboardingHref = buildOnboardingHref(); +const loginHref = buildOnboardingHref(OnboardingActions.Login); + +function BrandLockup(): ReactElement { + return ( + + + + + ); +} + +function CatHeroImage(): ReactElement { + return ( +
+ Sleeping cat on laptop +
+ ); +} + +interface SigninHeroProps { + onSignup: () => void; + onLogin: () => void; +} + +type HeroActionButtonsProps = SigninHeroProps; + +function HeroActionButtons({ + onSignup, + onLogin, +}: HeroActionButtonsProps): ReactElement { + return ( +
+ + +
+ ); +} + +function CatStageHero({ onSignup, onLogin }: SigninHeroProps): ReactElement { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ +

+ Own your new tab. Make it your dev briefing. +

+
+

+ Sign in and daily.dev will remember the topics, saves, upvotes, + and discussions that matter to you. +

+ +
+
+ +
+
+
+
+ ); +} + +function OnboardingSignupHero({ + onSignup, + onLogin, +}: SigninHeroProps): ReactElement { + return ( +
+
+ + + + + +
+
+
+
+
+
+
+

+ Where developers make every tab count. +

+

+ Sign in to turn daily.dev into your personalized feed, reputation, + saves, and community in every new tab. +

+
+
+ +
+
+
+
+ ); +} -export default function HijackingLoginStrip(): ReactElement { +const SigninHeroMap = { + [HijackingVariant.CTA]: CatStageHero, + [HijackingVariant.Auth]: OnboardingSignupHero, +} satisfies Record< + HijackingVariant.CTA | HijackingVariant.Auth, + (props: SigninHeroProps) => ReactElement +>; + +function DefaultHijackingStrip(): ReactElement { const { showLogin, user } = useAuthContext(); const { logEvent } = useLogContext(); const isLoggedOut = !user; - const onboardingHref = (() => { - const base = new URL(onboardingUrl); - base.searchParams.append('r', 'extension'); - - return base.toString(); - })(); const logHijackingClick = (): void => { logEvent({ @@ -93,3 +296,185 @@ export default function HijackingLoginStrip(): ReactElement { ); } + +function HijackingHeroStrip({ + variant: experimentVariant, +}: { + variant: HijackingVariant.CTA | HijackingVariant.Auth; +}): ReactElement { + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + const { signBack, provider, isLoaded: isSignBackLoaded } = useSignBack(); + const hasLoggedImpression = useRef(false); + + const isLoggedOut = !user; + const hasContinueAs = isLoggedOut && isSignBackLoaded && !!signBack?.name; + const firstName = signBack?.name?.split(' ')[0] ?? signBack?.name; + const socialProvider = + provider && provider !== 'password' + ? (provider as SocialProvider) + : undefined; + const providerIcon = socialProvider + ? providerMap[socialProvider]?.icon + : undefined; + + const variant: CoverVariant = (() => { + if (!isLoggedOut) { + return 'onboarding'; + } + + return hasContinueAs ? 'continue' : 'signin'; + })(); + const isReadyToLogImpression = !isLoggedOut || isSignBackLoaded; + + const logClick = (targetType: TargetType): void => { + logEvent({ + event_name: LogEvent.Click, + target_type: targetType, + target_id: 'hijacking', + }); + }; + + useEffect(() => { + if (!isReadyToLogImpression) { + return; + } + + if (hasLoggedImpression.current) { + return; + } + hasLoggedImpression.current = true; + + logEvent({ + event_name: LogEvent.Impression, + target_type: + variant === 'signin' ? TargetType.SignupButton : TargetType.LoginButton, + target_id: 'hijacking', + }); + }, [isReadyToLogImpression, variant, logEvent]); + + const onSignup = (): void => { + logClick(TargetType.SignupButton); + window.location.assign(onboardingHref); + }; + + const onLogin = (): void => { + logClick(TargetType.LoginButton); + window.location.assign(loginHref); + }; + const SigninHero = SigninHeroMap[experimentVariant]; + + const chrome = (children: ReactNode): ReactElement => ( +
+
+
+
{children}
+
+
+ ); + + if (variant === 'onboarding') { + return chrome( +
+ +

+ Let's jump back in! +

+

+ Finish onboarding to unlock the full daily.dev experience. +

+ +
, + ); + } + + if (variant === 'continue' && signBack) { + return chrome( +
+
+ + {!!providerIcon && ( + + {providerIcon} + + )} +
+

+ Welcome back, {firstName}! +

+ {!!signBack?.email && ( +

{signBack.email}

+ )} + +
+ Not you? + + Use another account + +
+
+ New here? + + Create an account + +
+
, + ); + } + + return ; +} + +export default function HijackingLoginStrip(): ReactElement | null { + const { value, isLoading } = useConditionalFeature({ + feature: featureHijackingVariants, + shouldEvaluate: true, + }); + + if (isLoading) { + return null; + } + + const hijackingVariant = value as HijackingVariant; + + if ( + hijackingVariant === HijackingVariant.CTA || + hijackingVariant === HijackingVariant.Auth + ) { + return ; + } + + return ; +} diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index ce3fcde32a..e3ba54fbf3 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -210,6 +210,16 @@ export const featureFeedChips = new Feature( FeedChipsVariant.None, ); +export enum HijackingVariant { + Default = 'default', + CTA = 'cta', + Auth = 'auth', +} +export const featureHijackingVariants = new Feature( + 'hijacking_variants', + HijackingVariant.Default, +); + export const featureLayoutV2 = new Feature('layout_v2', false); export const featureEngagementBarV2 = new Feature('engagement_bar_v2', false); diff --git a/packages/shared/src/styles/base.css b/packages/shared/src/styles/base.css index c0d5403440..37eb23d294 100644 --- a/packages/shared/src/styles/base.css +++ b/packages/shared/src/styles/base.css @@ -1080,6 +1080,57 @@ meter::-webkit-meter-bar { background: rgb(255 0 168 / 35%); } + .top-hero-aurora { + background: + radial-gradient( + 80% 95% at 50% 122%, + color-mix(in srgb, var(--theme-accent-cabbage-default), transparent 18%) + 0%, + transparent 62% + ), + radial-gradient( + 64% 82% at 16% 128%, + color-mix(in srgb, var(--theme-accent-onion-default), transparent 32%) 0%, + transparent 58% + ), + radial-gradient( + 64% 82% at 84% 128%, + color-mix(in srgb, var(--theme-accent-onion-default), transparent 32%) 0%, + transparent 58% + ); + } + + .top-hero-stage { + background: + linear-gradient( + 118deg, + transparent 0%, + rgb(255 255 255 / 5%) 42%, + transparent 58% + ), + radial-gradient( + 78% 88% at 76% 112%, + color-mix(in srgb, var(--theme-accent-cabbage-default), transparent 6%) + 0%, + transparent 58% + ), + radial-gradient( + 70% 84% at 16% 112%, + color-mix(in srgb, var(--theme-accent-onion-default), transparent 20%) 0%, + transparent 62% + ), + radial-gradient( + 62% 72% at 92% 4%, + color-mix(in srgb, var(--theme-accent-bacon-default), transparent 54%) 0%, + transparent 58% + ), + radial-gradient( + 42% 48% at 26% 16%, + rgb(255 255 255 / 8%) 0%, + transparent 64% + ); + } + @keyframes enable-notification-bell-ring { 0%, 100% {