diff --git a/packages/shared/src/components/auth/AuthOptionsInner.tsx b/packages/shared/src/components/auth/AuthOptionsInner.tsx index 825d36e355c..2586eb8d254 100644 --- a/packages/shared/src/components/auth/AuthOptionsInner.tsx +++ b/packages/shared/src/components/auth/AuthOptionsInner.tsx @@ -136,6 +136,8 @@ function AuthOptionsInner({ onboardingSignupButton, hideLoginLink, compact, + splitSignupStyle, + preferGithub, autoTriggerProvider, socialProviderScopes, }: AuthOptionsProps): ReactElement { @@ -770,6 +772,8 @@ function AuthOptionsInner({ onboardingSignupButton={onboardingSignupButton} hideLoginLink={hideLoginLink} compact={compact} + splitSignupStyle={splitSignupStyle} + preferGithub={preferGithub} /> diff --git a/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx b/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx index 2a7cad999f8..23f5423c82e 100644 --- a/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx +++ b/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx @@ -1,11 +1,12 @@ import type { ReactElement } from 'react'; import React, { useEffect } from 'react'; +import classNames from 'classnames'; import type { AuthFormProps } from './common'; import { providerMap } from './common'; import OrDivider from './OrDivider'; import { useLogContext } from '../../contexts/LogContext'; import type { AuthTriggersType } from '../../lib/auth'; -import { AuthEventNames } from '../../lib/auth'; +import { AuthEventNames, AuthTriggers } from '../../lib/auth'; import type { ButtonProps } from '../buttons/Button'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { isIOSNative } from '../../lib/func'; @@ -36,6 +37,8 @@ interface OnboardingRegistrationFormProps extends AuthFormProps { onboardingSignupButton?: ButtonProps<'button'>; hideLoginLink?: boolean; compact?: boolean; + splitSignupStyle?: boolean; + preferGithub?: boolean; } export const isWebView = (): boolean => { @@ -87,14 +90,17 @@ export const isWebView = (): boolean => { return isInAppBrowser || advancedInAppDetection(); }; -const getSignupProviders = () => { +const getSignupProviders = (preferGithub: boolean) => { if (isIOSNative()) { return [providerMap.google, providerMap.apple]; } if (isWebView()) { return [providerMap.github]; } - return [providerMap.google, providerMap.github]; + // Developer-first audiences convert better when GitHub leads the OAuth list. + return preferGithub + ? [providerMap.github, providerMap.google] + : [providerMap.google, providerMap.github]; }; export const OnboardingRegistrationForm = ({ @@ -108,8 +114,14 @@ export const OnboardingRegistrationForm = ({ onboardingSignupButton, hideLoginLink, compact, + splitSignupStyle = false, + preferGithub, }: OnboardingRegistrationFormProps): ReactElement => { const { logEvent } = useLogContext(); + const isOnboardingTrigger = trigger === AuthTriggers.Onboarding; + const signupProviders = getSignupProviders( + preferGithub ?? isOnboardingTrigger, + ); const trackOpenSignup = () => { logEvent({ @@ -130,13 +142,97 @@ export const OnboardingRegistrationForm = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const tertiarySignupButtonClass = + '!w-full !border !border-border-subtlest-tertiary !text-white'; + + const getEmailButtonClass = (): string => { + if (compact) { + return 'mb-4'; + } + if (isOnboardingTrigger && !splitSignupStyle) { + return 'mb-3'; + } + return 'mb-8'; + }; + + const emailButtonLabel = splitSignupStyle + ? 'Create account' + : 'Continue with email'; + + const emailButton = ( + + ); + + const getMemberAlreadyContainerClass = (): string => { + if (isOnboardingTrigger) { + return 'mx-auto mt-5 text-center text-text-secondary typo-callout'; + } + return 'mx-auto mt-6 text-center text-text-secondary typo-callout'; + }; + + const memberAlready = !hideLoginLink && !splitSignupStyle && ( + onExistingEmail?.('')} + className={{ + container: getMemberAlreadyContainerClass(), + login: '!text-inherit', + }} + /> + ); + + const splitSignInSection = splitSignupStyle && !hideLoginLink && ( +
+

+ Already have an account? +

+ +
+ ); + const disclaimer = ( + + ); + return (
    - {getSignupProviders().map((provider) => ( + {signupProviders.map((provider) => (
  • ))} @@ -156,36 +254,26 @@ export const OnboardingRegistrationForm = ({ className={{ text: 'text-text-tertiary typo-footnote', }} - label="OR" + label={isOnboardingTrigger ? 'or' : 'OR'} /> -
    - {!hideLoginLink && ( - onExistingEmail?.('')} - className={{ - container: - 'mx-auto mt-6 text-center text-text-secondary typo-callout', - login: '!text-inherit', - }} - /> - )} - - -
    + {emailButton} + {splitSignInSection} + {memberAlready} +
+ ) : ( +
+ {memberAlready} + {disclaimer} + {emailButton} +
+ )} ); }; diff --git a/packages/shared/src/components/auth/common.tsx b/packages/shared/src/components/auth/common.tsx index c29c527d399..d9f6eb88bc4 100644 --- a/packages/shared/src/components/auth/common.tsx +++ b/packages/shared/src/components/auth/common.tsx @@ -126,6 +126,10 @@ export interface AuthOptionsProps { onboardingSignupButton?: ButtonProps<'button'>; hideLoginLink?: boolean; compact?: boolean; + /** X-style split onboarding: "Sign up with", "Create account", Sign in button */ + splitSignupStyle?: boolean; + /** Order GitHub before Google in the OAuth provider list (developer-first). */ + preferGithub?: boolean; autoTriggerProvider?: string; socialProviderScopes?: string[]; } diff --git a/packages/shared/src/features/onboarding/components/OnboardingSignupHero.spec.tsx b/packages/shared/src/features/onboarding/components/OnboardingSignupHero.spec.tsx new file mode 100644 index 00000000000..2d0910dd762 --- /dev/null +++ b/packages/shared/src/features/onboarding/components/OnboardingSignupHero.spec.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { OnboardingSignupHero } from './OnboardingSignupHero'; + +jest.mock('../../../contexts/SettingsContext', () => ({ + ThemeMode: { Dark: 'dark' }, + useSettingsContext: () => ({ applyThemeMode: jest.fn() }), +})); + +jest.mock('../../../components/Logo', () => ({ + __esModule: true, + default: () =>
, + LogoPosition: { Relative: 'relative' }, +})); + +jest.mock('../../../components/footer/FooterLinks', () => ({ + FooterLinks: () =>
, +})); + +jest.mock('../../../components/auth/SignupDisclaimer', () => ({ + __esModule: true, + default: () =>
, +})); + +// Isolate the shell from the (gql/context-heavy) background blocks. +jest.mock('./signupHero/HeroBackgroundLayer', () => ({ + HeroBackgroundLayer: ({ + background, + imageMode, + }: { + background: string; + imageMode: string; + }) => ( +
+ ), +})); + +const renderHero = ( + props: Partial> = {}, +) => + render( + +
+ , + ); + +describe('OnboardingSignupHero', () => { + it('passes background and image mode to the background layer', () => { + renderHero({ background: 'desk', imageMode: 'colors' }); + const layer = screen.getByTestId('bg-layer'); + expect(layer).toHaveAttribute('data-background', 'desk'); + expect(layer).toHaveAttribute('data-image-mode', 'colors'); + }); + + it('renders aurora orbs by default', () => { + renderHero(); + expect(screen.getByTestId('hero-orbs')).toBeInTheDocument(); + }); + + it('omits aurora orbs when showOrbs is false', () => { + renderHero({ showOrbs: false }); + expect(screen.queryByTestId('hero-orbs')).not.toBeInTheDocument(); + }); + + it('renders the halo and vignette as part of the desk background', () => { + renderHero({ background: 'desk' }); + expect(screen.getByTestId('hero-halo')).toBeInTheDocument(); + }); + + it('omits the halo for the cards background', () => { + renderHero({ background: 'cards' }); + expect(screen.queryByTestId('hero-halo')).not.toBeInTheDocument(); + }); + + it('renders the form and headline', () => { + renderHero({ headline: 'Hello devs' }); + expect(screen.getByTestId('auth-form')).toBeInTheDocument(); + expect(screen.getByText('Hello devs')).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/features/onboarding/components/OnboardingSignupHero.tsx b/packages/shared/src/features/onboarding/components/OnboardingSignupHero.tsx new file mode 100644 index 00000000000..bc4bbf65d1e --- /dev/null +++ b/packages/shared/src/features/onboarding/components/OnboardingSignupHero.tsx @@ -0,0 +1,251 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect } from 'react'; +import classNames from 'classnames'; +import Logo, { LogoPosition } from '../../../components/Logo'; +import { FooterLinks } from '../../../components/footer/FooterLinks'; +import SignupDisclaimer from '../../../components/auth/SignupDisclaimer'; +import { + ThemeMode, + useSettingsContext, +} from '../../../contexts/SettingsContext'; +import type { + FunnelSignupHeroBackground, + FunnelSignupHeroImageMode, +} from '../types/funnel'; +import { HERO_STYLES } from './signupHero/heroStyles'; +import { HeroBackgroundLayer } from './signupHero/HeroBackgroundLayer'; +import { AuroraOrbs } from './signupHero/HeroDecorations'; + +// ============================================================= +// Onboarding signup hero — a shell that composes individually +// toggleable building blocks (background, orbs) driven by funnel +// parameters. There is intentionally no runtime switcher: every +// block is selected by the props passed in from the step. +// +// The halo/vignette is a legibility treatment intrinsic to the +// backgrounds that need it (desk photo, split layout) rather than a +// standalone toggle. +// ============================================================= + +type Props = { + children: ReactNode; + isFormExpanded?: boolean; + headline?: string | null; + background?: FunnelSignupHeroBackground; + imageMode?: FunnelSignupHeroImageMode; + showOrbs?: boolean; + forceDarkTheme?: boolean; +}; + +const DEFAULT_HEADLINE = 'The homepage every developer deserves.'; +const SIGNUP_CONTENT_MAX_W = 'max-w-[360px]'; + +export const OnboardingSignupHero = ({ + children, + isFormExpanded = false, + headline = DEFAULT_HEADLINE, + background = 'cards', + imageMode = 'image', + showOrbs = true, + forceDarkTheme = true, +}: Props): ReactElement => { + const { applyThemeMode } = useSettingsContext(); + + useEffect(() => { + if (!forceDarkTheme) { + return undefined; + } + applyThemeMode(ThemeMode.Dark); + return () => { + applyThemeMode(); + }; + }, [applyThemeMode, forceDarkTheme]); + + const isSplitLayout = background === 'split'; + const isDeskVariant = background === 'desk'; + const showOrbsLayer = showOrbs; + + const splitSignupColumn = ( + <> +
+
+ + + {!isFormExpanded && headline && ( +

+ {headline} +

+ )} + + {children} +
+
+ +
+
+ +
+ +
+ + ); + + return ( +
+