diff --git a/packages/extension/src/newtab/HijackingLoginStrip.spec.tsx b/packages/extension/src/newtab/HijackingLoginStrip.spec.tsx index 087c8a528b..0db8b2d666 100644 --- a/packages/extension/src/newtab/HijackingLoginStrip.spec.tsx +++ b/packages/extension/src/newtab/HijackingLoginStrip.spec.tsx @@ -1,8 +1,14 @@ 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 { useSignBack } from '@dailydotdev/shared/src/hooks/auth/useSignBack'; +import { + AuthDisplay, + SocialProvider, +} from '@dailydotdev/shared/src/components/auth/common'; 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,10 +20,54 @@ jest.mock('@dailydotdev/shared/src/contexts/AuthContext', () => ({ useAuthContext: jest.fn(), })); +jest.mock('@dailydotdev/shared/src/hooks/auth/useSignBack', () => ({ + useSignBack: jest.fn(), +})); + +jest.mock('@dailydotdev/shared/src/components/auth/AuthOptions', () => { + const { AuthDisplay: MockAuthDisplay } = jest.requireActual( + '@dailydotdev/shared/src/components/auth/common', + ); + + return { + __esModule: true, + default: ({ + onAuthStateUpdate, + }: { + onAuthStateUpdate?: (props: { defaultDisplay?: string }) => void; + }) => ( +
+ + + + +

By continuing, you agree to the Terms of Service

+
+ ), + }; +}); + const LogContext = getLogContextStatic(); const mockUseAuthContext = useAuthContext as jest.MockedFunction< typeof useAuthContext >; +const mockUseSignBack = useSignBack as jest.MockedFunction; const logEvent = jest.fn(); const showLogin = jest.fn(); @@ -61,40 +111,155 @@ 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 }); }; beforeEach(() => { jest.clearAllMocks(); + mockUseSignBack.mockReturnValue({ + isLoaded: true, + signBack: undefined, + provider: undefined, + onUpdateSignBack: jest.fn(), + }); }); describe('HijackingLoginStrip', () => { - it('shows a login CTA for logged out users', () => { + it('encourages signup for logged out users without a remembered account', () => { + renderComponent(); + + expect( + screen.getByRole('heading', { + name: 'Where developers make every tab count.', + }), + ).toBeVisible(); + + expect( + screen.getByRole('button', { name: 'Continue with Google' }), + ).toBeVisible(); + expect( + screen.getByRole('button', { name: 'Continue with GitHub' }), + ).toBeVisible(); + expect( + screen.getByRole('button', { name: 'Continue with email' }), + ).toBeVisible(); + expect( + screen.getByText(/By continuing, you agree to the Terms of Service/), + ).toBeVisible(); + + fireEvent.click( + screen.getByRole('button', { name: 'Continue with email' }), + ); + + expect(showLogin).toHaveBeenCalledWith({ + trigger: AuthTriggers.Onboarding, + options: { + isLogin: false, + defaultDisplay: AuthDisplay.Registration, + formValues: undefined, + }, + }); + }); + + 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.toHaveBeenCalled(); + + 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.getByText('Unlock the full daily.dev experience'), + 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" for users with a remembered 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(); + expect( - screen.getByText('Log in to pick up where you left off.'), + screen.getByRole('heading', { name: /Welcome back, Tsahi/ }), ).toBeVisible(); + expect(screen.getByText('tsahi@daily.dev')).toBeVisible(); - const cta = screen.getByRole('button', { name: 'Log in to continue' }); + const cta = screen.getByRole('button', { name: /Continue as Tsahi/ }); fireEvent.click(cta); expect(logEvent).toHaveBeenCalledWith({ - event_name: LogEvent.Click, + event_name: LogEvent.Impression, target_type: TargetType.LoginButton, target_id: 'hijacking', }); @@ -104,16 +269,38 @@ describe('HijackingLoginStrip', () => { }); }); + 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(showLogin).toHaveBeenCalledWith({ + trigger: AuthTriggers.Onboarding, + options: { isLogin: false }, + }); + }); + 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.', + 'Finish onboarding to unlock the full daily.dev experience.', ), ).toBeVisible(); - const cta = screen.getByRole('link', { name: 'Continue onboarding' }); + const cta = screen.getByRole('link', { name: /Continue/ }); const expectedUrl = new URL(onboardingUrl); expectedUrl.searchParams.append('r', 'extension'); diff --git a/packages/extension/src/newtab/HijackingLoginStrip.tsx b/packages/extension/src/newtab/HijackingLoginStrip.tsx index 7185dbc58a..2508a53dae 100644 --- a/packages/extension/src/newtab/HijackingLoginStrip.tsx +++ b/packages/extension/src/newtab/HijackingLoginStrip.tsx @@ -1,22 +1,253 @@ -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 AuthOptions from '@dailydotdev/shared/src/components/auth/AuthOptions'; +import { + ProfileImageSize, + ProfilePicture, +} from '@dailydotdev/shared/src/components/ProfilePicture'; +import { + AuthDisplay, + type AuthOptionsProps, + 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 { 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 { 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'; +type SigninHeroVariation = 'catStage' | 'onboardingSignup'; + +const signinHeroVariation: SigninHeroVariation = 'onboardingSignup'; + +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]'; + +function BrandLockup(): ReactElement { + return ( + + + + + ); +} + +function CatHeroImage(): ReactElement { + return ( +
+ Sleeping cat on laptop +
+ ); +} + +interface SigninHeroProps { + onSignupClick: () => void; + onLoginClick: () => void; + formRef: AuthOptionsProps['formRef']; + onAuthStateUpdate: AuthOptionsProps['onAuthStateUpdate']; +} + +type HeroActionButtonsProps = Pick< + SigninHeroProps, + 'onSignupClick' | 'onLoginClick' +>; + +function HeroActionButtons({ + onSignupClick, + onLoginClick, +}: HeroActionButtonsProps): ReactElement { + return ( +
+ + +
+ ); +} + +function CatStageHero({ + onSignupClick, + onLoginClick, +}: 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({ + formRef, + onAuthStateUpdate, +}: 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. +

+
+
+ +
+
+
+
+ ); +} + +const SigninHeroVariationMap = { + catStage: CatStageHero, + onboardingSignup: OnboardingSignupHero, +} satisfies Record< + SigninHeroVariation, + (props: SigninHeroProps) => ReactElement +>; export default function HijackingLoginStrip(): ReactElement { const { showLogin, user } = useAuthContext(); const { logEvent } = useLogContext(); + const { signBack, provider, isLoaded: isSignBackLoaded } = useSignBack(); + const hasLoggedImpression = useRef(false); + const authFormRef = useRef( + null, + ) as unknown as AuthOptionsProps['formRef']; + 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 onboardingHref = (() => { const base = new URL(onboardingUrl); base.searchParams.append('r', 'extension'); @@ -24,72 +255,161 @@ export default function HijackingLoginStrip(): ReactElement { return base.toString(); })(); - const logHijackingClick = (): void => { + const logClick = (targetType: TargetType): void => { logEvent({ event_name: LogEvent.Click, - target_type: TargetType.LoginButton, + target_type: targetType, target_id: 'hijacking', }); }; - return ( -
-
-
-
-
-
-
-
-
-
-

- Unlock the full daily.dev experience -

-

- {isLoggedOut - ? 'Log in to pick up where you left off.' - : 'You still have a few onboarding steps left. Finish them to unlock the full experience.'} -

- {isLoggedOut ? ( - - ) : ( - - )} -
-
-
- Sleeping cat on laptop -
-
-
+ 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 onSignupClick = (): void => { + logClick(TargetType.SignupButton); + showLogin({ + trigger: AuthTriggers.Onboarding, + options: { isLogin: false }, + }); + }; + + const onLoginClick = (): void => { + logClick(TargetType.LoginButton); + showLogin({ + trigger: AuthTriggers.Onboarding, + options: { isLogin: true }, + }); + }; + const onAuthStateUpdate: AuthOptionsProps['onAuthStateUpdate'] = (props) => { + showLogin({ + trigger: AuthTriggers.Onboarding, + options: { + isLogin: !!props.isLoginFlow, + defaultDisplay: props.defaultDisplay, + formValues: props.email ? { email: props.email } : undefined, + }, + }); + }; + const SigninHero = SigninHeroVariationMap[signinHeroVariation]; + + 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 ( + + ); } 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% {