diff --git a/packages/extension/jest.config.js b/packages/extension/jest.config.js index 1a63a40f2a9..4fc74d78bdc 100644 --- a/packages/extension/jest.config.js +++ b/packages/extension/jest.config.js @@ -22,5 +22,6 @@ module.exports = { '\\.svg$': '/__mocks__/svgrMock.ts', '\\.css$': 'identity-obj-proxy', 'react-markdown': '/__mocks__/reactMarkdownMock.tsx', + '^edge-aura/react$': '/../shared/__mocks__/edgeAuraMock.tsx', }, }; diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json index 5b73cad80ad..bca3c911786 100644 --- a/packages/extension/tsconfig.json +++ b/packages/extension/tsconfig.json @@ -17,7 +17,13 @@ "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "react" + "jsx": "react", + "paths": { + // See packages/shared/tsconfig.json — this package compiles shared's + // sources under `moduleResolution: node`, which cannot follow the + // package `exports` subpath the bundler resolves at build time. + "edge-aura/react": ["../shared/node_modules/edge-aura/dist/react.d.ts"] + } }, "include": [ "custom.d.ts", diff --git a/packages/shared/__mocks__/edgeAuraMock.tsx b/packages/shared/__mocks__/edgeAuraMock.tsx new file mode 100644 index 00000000000..a6236d5c278 --- /dev/null +++ b/packages/shared/__mocks__/edgeAuraMock.tsx @@ -0,0 +1,6 @@ +/** + * `edge-aura` ships ESM only and paints into a 2D canvas context that jsdom + * does not implement. The aura is a decorative overlay with no role in any + * assertion, so tests render nothing in its place. + */ +export const EdgeAura = (): null => null; diff --git a/packages/shared/jest.config.js b/packages/shared/jest.config.js index 6f94d77408b..66ae01819d4 100644 --- a/packages/shared/jest.config.js +++ b/packages/shared/jest.config.js @@ -20,5 +20,6 @@ module.exports = { '\\.css$': 'identity-obj-proxy', 'react-markdown': '/__mocks__/reactMarkdownMock.tsx', 'react-turnstile': 'identity-obj-proxy', + '^edge-aura/react$': '/__mocks__/edgeAuraMock.tsx', }, }; diff --git a/packages/shared/package.json b/packages/shared/package.json index 0972a29239c..0813f35eda4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -127,6 +127,7 @@ "@tiptap/starter-kit": "^3.22.5", "check-password-strength": "^2.0.10", "cmdk": "^1.0.0", + "edge-aura": "0.6.0", "emojibase-data": "^17.0.0", "fetch-event-stream": "^0.1.6", "graphql-ws": "^5.5.5", diff --git a/packages/shared/src/components/GrowthBookProvider.tsx b/packages/shared/src/components/GrowthBookProvider.tsx index 998ff7dff3a..9c30fb82f79 100644 --- a/packages/shared/src/components/GrowthBookProvider.tsx +++ b/packages/shared/src/components/GrowthBookProvider.tsx @@ -209,3 +209,7 @@ export const useFeature: GetFeatureValue = (feature) => export const useGrowthBookContext = (): GrowthBookContextValue => useContext(GrowthBookContext); + +// Re-exported so harnesses (Storybook, tests) can pin an experiment arm without +// depending on the GrowthBook package directly. +export { GrowthBookContext }; diff --git a/packages/shared/src/components/auth/AuthHeader.spec.tsx b/packages/shared/src/components/auth/AuthHeader.spec.tsx new file mode 100644 index 00000000000..5352e6f9778 --- /dev/null +++ b/packages/shared/src/components/auth/AuthHeader.spec.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import AuthHeader from './AuthHeader'; +import { onboardingHeadlineClasses } from '../onboarding/common'; + +// `onboardingHeadline` exists so the two screens that sit in FRONT of the +// onboarding funnel (account details, verify email) can take the funnel's +// headline scale. It is deliberately separate from `simplified`, which eleven +// other surfaces set — including the paid /helloworld funnel and the recruiter +// sign-in modals — so these assert both directions. +describe('AuthHeader', () => { + it('takes the funnel headline scale when onboardingHeadline is set', () => { + render( + , + ); + + const heading = screen.getByRole('heading', { name: 'Verify your email' }); + + onboardingHeadlineClasses.split(' ').forEach((className) => { + expect(heading).toHaveClass(className); + }); + }); + + it('keeps the original simplified scale for every other surface', () => { + render(); + + const heading = screen.getByRole('heading', { name: 'Sign up' }); + + expect(heading).toHaveClass('typo-title2'); + expect(heading).not.toHaveClass('typo-title1'); + }); +}); diff --git a/packages/shared/src/components/auth/AuthHeader.tsx b/packages/shared/src/components/auth/AuthHeader.tsx index dc6d5f28eab..743649f4363 100644 --- a/packages/shared/src/components/auth/AuthHeader.tsx +++ b/packages/shared/src/components/auth/AuthHeader.tsx @@ -10,15 +10,24 @@ import { Button, ButtonVariant } from '../buttons/Button'; import { ArrowIcon } from '../icons'; import { Modal } from '../modals/common/Modal'; import { ModalHeaderKind } from '../modals/common/types'; +import { onboardingHeadlineClasses } from '../onboarding/common'; export interface AuthHeaderProps extends ComponentProps<'h2'> { simplified?: boolean; + /** + * Post-signup onboarding only: take the funnel's headline scale, so the + * signup and verify-email screens read as the same flow as the steps that + * follow them. Deliberately separate from `simplified`, which eleven other + * surfaces set — including the paid funnel and the recruiter modals. + */ + onboardingHeadline?: boolean; title: string; onBack?: (e: MouseEvent | KeyboardEvent | FormEvent) => void; } function AuthHeader({ simplified = false, + onboardingHeadline = false, title, className, onBack, @@ -28,7 +37,11 @@ function AuthHeader({ return (

{title}

diff --git a/packages/shared/src/components/auth/AuthOptionsInner.tsx b/packages/shared/src/components/auth/AuthOptionsInner.tsx index 10146a45f2f..09965ca66e5 100644 --- a/packages/shared/src/components/auth/AuthOptionsInner.tsx +++ b/packages/shared/src/components/auth/AuthOptionsInner.tsx @@ -142,6 +142,8 @@ function AuthOptionsInner({ ignoreMessages = false, onboardingSignupButton, hideLoginLink, + hideSignupDisclaimer, + isOnboardingFunnel, compact, splitSignupStyle, preferGithub, @@ -783,6 +785,7 @@ function AuthOptionsInner({ formRef={formRef} simplified={simplified} showHeadline={!hideRegistrationHeadline} + isOnboardingFunnel={isOnboardingFunnel} extraFields={registrationExtraFields} hints={registrationHints} onBack={ @@ -856,6 +859,7 @@ function AuthOptionsInner({ className={className} onboardingSignupButton={onboardingSignupButton} hideLoginLink={hideLoginLink} + hideSignupDisclaimer={hideSignupDisclaimer} compact={compact} splitSignupStyle={splitSignupStyle} preferGithub={preferGithub} @@ -876,6 +880,7 @@ function AuthOptionsInner({ onProviderClick={onProviderClick} isProviderLoading={isSocialAuthLoading} simplified={simplified} + isOnboardingFunnel={isOnboardingFunnel} onShowLoginOptions={() => { if (!isLoginFlow && onAuthStateUpdate) { onAuthStateUpdate({ isLoginFlow: true }); @@ -915,8 +920,13 @@ function AuthOptionsInner({ - + { const res = await betterAuthVerifyEmailOTP(email, code); diff --git a/packages/shared/src/components/auth/AuthSignBack.tsx b/packages/shared/src/components/auth/AuthSignBack.tsx index 470934127c9..5b58c227f86 100644 --- a/packages/shared/src/components/auth/AuthSignBack.tsx +++ b/packages/shared/src/components/auth/AuthSignBack.tsx @@ -21,6 +21,7 @@ interface AuthSignBackProps extends AuthFormProps { isProviderLoading?: boolean; loginFormProps?: LoginFormProps; onShowLoginOptions?: () => void; + isOnboardingFunnel?: boolean; } export const AuthSignBack = ({ @@ -32,6 +33,7 @@ export const AuthSignBack = ({ simplified, onShowLoginOptions, isConnectedAccount, + isOnboardingFunnel, }: AuthSignBackProps): ReactElement | null => { const { signBack, provider, isLoaded } = useSignBack(); const socialProvider = @@ -107,7 +109,11 @@ export const AuthSignBack = ({ return ( - +

Log in to access your account diff --git a/packages/shared/src/components/auth/EmailCodeVerification.tsx b/packages/shared/src/components/auth/EmailCodeVerification.tsx index e9509ec2620..eedba38dbd6 100644 --- a/packages/shared/src/components/auth/EmailCodeVerification.tsx +++ b/packages/shared/src/components/auth/EmailCodeVerification.tsx @@ -1,7 +1,12 @@ import type { ReactElement } from 'react'; import React, { useRef, useState } from 'react'; import classNames from 'classnames'; -import { Button, ButtonVariant } from '../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import ConditionalWrapper from '../ConditionalWrapper'; +import { + FunnelGlassBar, + funnelGlassBarCta, +} from '../../features/onboarding/shared/FunnelGlassBar'; import type { AuthFormProps } from './common'; import AuthForm from './AuthForm'; import { AuthEventNames } from '../../lib/auth'; @@ -22,6 +27,7 @@ interface EmailCodeVerificationProps extends AuthFormProps { className?: string; onVerifyCode?: (code: string) => Promise; onResendCode?: () => Promise; + isOnboardingFunnel?: boolean; } const noop = (): void => undefined; @@ -32,6 +38,7 @@ function EmailCodeVerification({ className, onVerifyCode, onResendCode, + isOnboardingFunnel, }: EmailCodeVerificationProps): ReactElement { const { email } = useAuthData(); const { logEvent } = useLogContext(); @@ -170,14 +177,22 @@ function EmailCodeVerification({ - + + ); } diff --git a/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx b/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx index 7abc740ac87..f64835f6672 100644 --- a/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx +++ b/packages/shared/src/components/auth/OnboardingRegistrationForm.tsx @@ -37,6 +37,7 @@ interface OnboardingRegistrationFormProps extends AuthFormProps { className?: ClassName; onboardingSignupButton?: ButtonProps<'button'>; hideLoginLink?: boolean; + hideSignupDisclaimer?: boolean; compact?: boolean; splitSignupStyle?: boolean; preferGithub?: boolean; @@ -115,6 +116,7 @@ export const OnboardingRegistrationForm = ({ trigger, onboardingSignupButton, hideLoginLink, + hideSignupDisclaimer, compact, splitSignupStyle = false, preferGithub, @@ -223,7 +225,7 @@ export const OnboardingRegistrationForm = ({ /> ); - const disclaimer = ( + const disclaimer = hideSignupDisclaimer ? null : ( ); diff --git a/packages/shared/src/components/auth/RegistrationFieldsForm.tsx b/packages/shared/src/components/auth/RegistrationFieldsForm.tsx index 336905133b2..befdc5d7dc2 100644 --- a/packages/shared/src/components/auth/RegistrationFieldsForm.tsx +++ b/packages/shared/src/components/auth/RegistrationFieldsForm.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import classNames from 'classnames'; import { TextField } from '../fields/TextField'; import { PasswordField } from '../fields/PasswordField'; import { Checkbox } from '../fields/Checkbox'; @@ -13,6 +14,7 @@ import { Button, ButtonVariant } from '../buttons/Button'; import ImageInput from '../fields/ImageInput'; import { useGenerateUsername } from '../../hooks'; import { labels } from '../../lib'; +import { AtIcon, MailIcon, UserIcon } from '../icons'; export type UserExperienceLevelKey = keyof typeof UserExperienceLevel; @@ -42,6 +44,8 @@ export interface RegistrationFieldsFormProps { // Optional extra profile fields to render, driven by the onboarding funnel // (campaign cohorts). Empty/undefined renders the default fields only. extraFields?: ProfileExtraField[]; + /** Suppresses the form's own submit button, for a docked ` + {!formId && ( + + )} ); }; diff --git a/packages/shared/src/components/auth/RegistrationForm.spec.tsx b/packages/shared/src/components/auth/RegistrationForm.spec.tsx new file mode 100644 index 00000000000..65b9a2d91a3 --- /dev/null +++ b/packages/shared/src/components/auth/RegistrationForm.spec.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import RegistrationForm from './RegistrationForm'; +import { AuthTriggers } from '../../lib/auth'; + +jest.mock('@marsidev/react-turnstile', () => ({ + Turnstile: () =>

, +})); + +jest.mock('./SignupDisclaimer', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../../contexts/LogContext', () => ({ + useLogContext: () => ({ logEvent: jest.fn() }), +})); + +jest.mock('../../contexts/AuthDataContext', () => ({ + useAuthData: () => ({ email: 'ada@daily.dev' }), +})); + +// Stub the two query-backed hooks at their source modules rather than through +// the `hooks` barrel — the barrel re-exports the whole hook surface, and +// replacing it takes out everything else the form's fields pull from it. +jest.mock('../../hooks/onboarding/useGenerateUsername', () => ({ + useGenerateUsername: () => ({ + username: '', + setUsername: jest.fn(), + isLoading: false, + }), +})); + +jest.mock('../../hooks/onboarding/useCheckExistingEmail', () => ({ + useCheckExistingEmail: () => ({ + email: { isCheckPending: false, alreadyExists: false }, + onEmailCheck: jest.fn(), + }), +})); + +const renderForm = (props = {}) => + render( + + + , + ); + +describe('RegistrationForm', () => { + // The consent line lives with the button that creates the account, not on the + // funnel shell — that shell also serves sign-back, login and verify-email, + // where the account already exists and the notice would be wrong. + it('shows the signup disclaimer in the onboarding funnel', () => { + renderForm({ isOnboardingFunnel: true }); + + expect(screen.getByTestId('disclaimer')).toBeInTheDocument(); + }); + + it('leaves the disclaimer to the caller everywhere else', () => { + renderForm(); + + expect(screen.queryByTestId('disclaimer')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/auth/RegistrationForm.tsx b/packages/shared/src/components/auth/RegistrationForm.tsx index 58b42b164a2..8dc82f75279 100644 --- a/packages/shared/src/components/auth/RegistrationForm.tsx +++ b/packages/shared/src/components/auth/RegistrationForm.tsx @@ -27,6 +27,7 @@ import type { CloseModalFunc } from '../modals/common'; import TokenInput from './TokenField'; import AuthForm from './AuthForm'; import AuthHeader from './AuthHeader'; +import SignupDisclaimer from './SignupDisclaimer'; import { Checkbox } from '../fields/Checkbox'; import { useLogContext } from '../../contexts/LogContext'; import { useGenerateUsername, useCheckExistingEmail } from '../../hooks'; @@ -43,6 +44,10 @@ import { onboardingGradientClasses } from '../onboarding/common'; import { useAuthData } from '../../contexts/AuthDataContext'; import { authAtom } from '../../features/onboarding/store/onboarding.store'; import { FunnelTargetId } from '../../features/onboarding/types/funnelEvents'; +import { + FunnelGlassBar, + funnelGlassBarCta, +} from '../../features/onboarding/shared/FunnelGlassBar'; import { Loader } from '../Loader'; import { labels } from '../../lib'; @@ -68,6 +73,9 @@ export interface RegistrationFormProps extends AuthFormProps { // form. The onboarding funnel already shows this copy on the signup wall, so // it hides it here to avoid duplicating the message on the email step. showHeadline?: boolean; + // Post-signup onboarding only: the funnel's headline scale, and the glass bar + // around Sign up, so this screen matches the steps after it. + isOnboardingFunnel?: boolean; } export type RegistrationFormValues = Omit< @@ -92,6 +100,7 @@ const RegistrationForm = ({ headerTitle = 'Sign up', extraFields = [], showHeadline = true, + isOnboardingFunnel, }: RegistrationFormProps): ReactElement => { const { email } = useAuthData(); const { logEvent } = useLogContext(); @@ -308,7 +317,11 @@ const RegistrationForm = ({ return (
{!isAuthenticating && ( - + )}
)} - + + + {/* Consent belongs to the button that creates the account, so it + travels with this form instead of being docked on the funnel + shell — that shell also serves sign-back, login and + verify-email, where the account already exists. The funnel's + signup wall suppresses its own inline copy, which makes this the + notice shown before the account is created, at every width. */} + {isOnboardingFunnel && ( + + )}
diff --git a/packages/shared/src/components/auth/common.tsx b/packages/shared/src/components/auth/common.tsx index b3a416e12c9..8d660c42ad6 100644 --- a/packages/shared/src/components/auth/common.tsx +++ b/packages/shared/src/components/auth/common.tsx @@ -143,4 +143,12 @@ export interface AuthOptionsProps { * registration form (e.g. when the onboarding funnel already shows that copy * on the signup wall). */ hideRegistrationHeadline?: boolean; + /** Hide the "By continuing, you agree to…" strip under the signup options. */ + hideSignupDisclaimer?: boolean; + /** + * The funnel's headline scale and glass CTA. Explicit rather than read from + * `FunnelProgressContext`, because these screens render before the stepper + * mounts. Not folded into `simplified`, which eleven other surfaces set. + */ + isOnboardingFunnel?: boolean; } diff --git a/packages/shared/src/components/feeds/FeedPreviewControls.tsx b/packages/shared/src/components/feeds/FeedPreviewControls.tsx index 75ae4e7f478..8715498e577 100644 --- a/packages/shared/src/components/feeds/FeedPreviewControls.tsx +++ b/packages/shared/src/components/feeds/FeedPreviewControls.tsx @@ -3,13 +3,18 @@ import React from 'react'; import type { ReactElement } from 'react-markdown/lib/react-markdown'; import { Button } from '../buttons/Button'; import { ButtonVariant, ButtonIconPosition } from '../buttons/common'; -import { ArrowIcon } from '../icons'; +import { ArrowIcon, EyeCancelIcon, EyeIcon } from '../icons'; import type { Origin } from '../../lib/log'; import { LogEvent } from '../../lib/log'; import { useLogContext } from '../../contexts/LogContext'; export type FeedPreviewControlsControlsProps = { isOpen: boolean; + // The onboarding funnel renders this as one of the tag pills above it — a + // quiet reveal on the divider, the eye carrying the open/closed state — so + // it doesn't compete with the step's docked CTA. Off elsewhere, where the + // original primary button stands. + isTagStyle?: boolean; isDisabled?: boolean; textDisabled: string; origin: Origin; @@ -18,6 +23,7 @@ export type FeedPreviewControlsControlsProps = { export const FeedPreviewControls = ({ isOpen, + isTagStyle, isDisabled = false, textDisabled, origin, @@ -25,28 +31,53 @@ export const FeedPreviewControls = ({ }: FeedPreviewControlsControlsProps): ReactElement => { const { logEvent } = useLogContext(); - return ( -
-
- + const label = isDisabled + ? textDisabled + : `${isOpen ? 'Hide' : 'Show'} feed preview`; + + return ( +
+
+ {isTagStyle ? ( + // `btn-tag` plus the default size is exactly what TagElement renders, + // so the control sits in the same family as the tags it belongs to. + + ) : ( + + )}
); diff --git a/packages/shared/src/components/fields/CardCheckbox.tsx b/packages/shared/src/components/fields/CardCheckbox.tsx index 57db1da758d..1bf0f0f6dcc 100644 --- a/packages/shared/src/components/fields/CardCheckbox.tsx +++ b/packages/shared/src/components/fields/CardCheckbox.tsx @@ -1,4 +1,4 @@ -import type { InputHTMLAttributes, ReactElement } from 'react'; +import type { ComponentType, ReactElement } from 'react'; import React from 'react'; import classNames from 'classnames'; import { @@ -6,7 +6,8 @@ import { TypographyColor, TypographyType, } from '../typography/Typography'; -import { ChecklistAIcon } from '../icons'; +import { VIcon } from '../icons'; +import { IconSize } from '../Icon'; import { FunnelTargetId } from '../../features/onboarding/types/funnelEvents'; interface CustomCheckboxProps { @@ -14,75 +15,75 @@ interface CustomCheckboxProps { title: string; description: string; onCheckboxToggle: () => void; - inputProps?: InputHTMLAttributes; className?: string; + icon?: ComponentType<{ size?: IconSize; secondary?: boolean }>; } -const border = { - checked: ` - radial-gradient( - circle at top left, - var(--theme-accent-onion-subtlest), - var(--theme-accent-cabbage-default) - ) - `, - unchecked: ` - radial-gradient( - circle at top left, - var(--theme-border-subtlest-tertiary), - var(--theme-border-subtlest-tertiary) - ) - `, -}; - export const CardCheckbox = ({ checked, title, description, onCheckboxToggle, - inputProps = {}, className, + icon: Icon, }: CustomCheckboxProps): ReactElement => { return ( -
- -
+ + + {checked && } + + ); }; diff --git a/packages/shared/src/components/fields/SearchField.tsx b/packages/shared/src/components/fields/SearchField.tsx index 771a8ff44eb..f95e6b0e1de 100644 --- a/packages/shared/src/components/fields/SearchField.tsx +++ b/packages/shared/src/components/fields/SearchField.tsx @@ -43,6 +43,12 @@ export interface SearchFieldProps showIcon?: boolean; fieldType?: 'primary' | 'secondary'; rightButtonProps?: ButtonProps<'button'> | false; + /** + * Float surface instead of the page background, for a field on a bare page: + * filling it with the page colour leaves only the hairline border to say it + * is an input, so it reads as a gap rather than a control. + */ + isFloating?: boolean; } const ButtonIcon = ({ @@ -67,6 +73,7 @@ export const SearchField = forwardRef(function SearchField( fieldSize = 'large', readOnly, fieldType = 'primary', + isFloating, className, autoFocus, type, @@ -118,8 +125,9 @@ export const SearchField = forwardRef(function SearchField( className={classNames( // Border width + background only — the resting border *color* is the Float // hairline from `.field` so the search field matches every other field. - 'items-center !border !bg-background-default', - // The base `.field:hover` background is blocked by `!bg-background-default`, + 'items-center !border', + isFloating ? '!bg-surface-float' : '!bg-background-default', + // The base `.field:hover` background is blocked by the background above, // so the search field needs its own hover feedback. Brighten the border and // tint the surface, scoped to `:not(.focused)` so it never overrides the // focus ring while the field is active. diff --git a/packages/shared/src/components/filters/helpers.ts b/packages/shared/src/components/filters/helpers.ts index f9926e2651e..a13e0cb5955 100644 --- a/packages/shared/src/components/filters/helpers.ts +++ b/packages/shared/src/components/filters/helpers.ts @@ -23,9 +23,9 @@ export const getContentCurationList = ( export const getAdvancedContentTypes = ( titles: string[], advancedSettings: AdvancedSettings[], -) => +): AdvancedSettings[] => titles .map((title) => advancedSettings?.find((advanced) => advanced.title === title), ) - .filter(Boolean); + .filter((advanced): advanced is AdvancedSettings => !!advanced); diff --git a/packages/shared/src/components/onboarding/ContentTypes/ContentTypes.tsx b/packages/shared/src/components/onboarding/ContentTypes/ContentTypes.tsx index bdd1b358eeb..ed3f7d9c801 100644 --- a/packages/shared/src/components/onboarding/ContentTypes/ContentTypes.tsx +++ b/packages/shared/src/components/onboarding/ContentTypes/ContentTypes.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; +import classNames from 'classnames'; import useFeedSettings from '../../../hooks/useFeedSettings'; import { useAdvancedSettings } from '../../../hooks'; @@ -10,12 +11,49 @@ import { } from '../../filters/helpers'; import { CardCheckbox } from '../../fields/CardCheckbox'; import { TOGGLEABLE_TYPES } from '../../feeds/FeedSettings/sections/FeedSettingsContentPreferencesSection'; +import { OnboardingHeadline } from '../common'; +import { withoutRetiredTitles } from './helpers'; +import { + DiscussIcon, + DocsIcon, + HotIcon, + ImageIcon, + MegaphoneIcon, + NumberedListIcon, + PlayIcon, + PollIcon, + SquadIcon, + StoryIcon, + TLDRIcon, + TwitterIcon, +} from '../../icons'; + +// Keyed by title, as the rest of this data is. An unmapped title renders +// without an icon rather than breaking. +const contentTypeIcon: Record = { + Comparisons: TLDRIcon, + Listicles: NumberedListIcon, + Memes: ImageIcon, + News: HotIcon, + Opinions: DiscussIcon, + Releases: MegaphoneIcon, + Squads: SquadIcon, + Stories: StoryIcon, + Tutorials: DocsIcon, + Videos: PlayIcon, + Polls: PollIcon, + Social: TwitterIcon, +}; interface ContentTypesProps { headline?: string; + isOnboarding?: boolean; } -export const ContentTypes = ({ headline }: ContentTypesProps): ReactElement => { +export const ContentTypes = ({ + headline, + isOnboarding, +}: ContentTypesProps): ReactElement => { const { advancedSettings } = useFeedSettings(); const { selectedSettings, @@ -25,8 +63,12 @@ export const ContentTypes = ({ headline }: ContentTypesProps): ReactElement => { } = useAdvancedSettings(); const contentSourceList = useMemo( - () => getContentSourceList(advancedSettings), - [advancedSettings], + () => + withoutRetiredTitles( + getContentSourceList(advancedSettings), + isOnboarding, + ), + [advancedSettings, isOnboarding], ); const contentCurationList = useMemo( @@ -40,31 +82,55 @@ export const ContentTypes = ({ headline }: ContentTypesProps): ReactElement => { advancedSettings, ); - return contentCurationList.concat(listedTypes); - }, [contentCurationList, advancedSettings]); + return withoutRetiredTitles( + contentCurationList.concat(listedTypes), + isOnboarding, + ); + }, [contentCurationList, advancedSettings, isOnboarding]); - const classes = '!h-[8.25rem] max-w-80'; + // `h-full` so every row is one height; the paid funnel keeps main's card. + const classes = isOnboarding ? 'h-full w-full' : '!h-[8.25rem] max-w-80'; + const defaultHeadline = + headline || 'What kind of posts would you like to see on your feed?'; return ( -
-

- {headline || 'What kind of posts would you like to see on your feed?'} -

-
- {contentSourceList?.map(({ id, title, description, options }) => ( - onToggleSource(options.source)} - checked={!checkSourceBlocked(options.source)} - title={title} - description={description} - inputProps={{ - checked: !checkSourceBlocked(options.source), - name: `advancedSettings-${id}`, - }} - /> - ))} +
+ {isOnboarding ? ( + {defaultHeadline} + ) : ( +

+ {defaultHeadline} +

+ )} +
+ {contentSourceList?.map(({ id, title, description, options }) => { + if (!options?.source) { + return null; + } + + const { source } = options; + + return ( + onToggleSource(source)} + checked={!checkSourceBlocked(source)} + title={title} + description={description} + icon={contentTypeIcon[title]} + /> + ); + })} {contentCurationAndVideoList.map( ({ id, title, description, defaultEnabledState }) => ( { checked={selectedSettings[id] ?? defaultEnabledState} title={title} description={description} - inputProps={{ - checked: selectedSettings[id] ?? defaultEnabledState, - name: `advancedSettings-${id}`, - }} + icon={contentTypeIcon[title]} /> ), )} diff --git a/packages/shared/src/components/onboarding/ContentTypes/helpers.spec.ts b/packages/shared/src/components/onboarding/ContentTypes/helpers.spec.ts new file mode 100644 index 00000000000..38dd3ffab61 --- /dev/null +++ b/packages/shared/src/components/onboarding/ContentTypes/helpers.spec.ts @@ -0,0 +1,81 @@ +import { getContentTypeNotEmpty, withoutRetiredTitles } from './helpers'; +import type { AdvancedSettings } from '../../../graphql/feedSettings'; +import { AdvancedSettingsGroup } from '../../../graphql/feedSettings'; + +// The two titles the funnel retires, verbatim from the `advancedSettings` +// query. They join on display copy, so a backend rename un-retires the cards +// silently — this fixture is the tripwire: update it to match a rename and +// these tests go red, which is the signal to re-key the filter. +const RETIRED = ['Community picks', 'Standups']; + +const settings: AdvancedSettings[] = [ + { + id: 8, + title: 'Community picks', + description: 'Posts submitted by community members.', + defaultEnabledState: true, + group: AdvancedSettingsGroup.ContentSource, + options: { source: { id: 'community', handle: 'community' } }, + }, + { + id: 25, + title: 'Standups', + description: 'Live standup posts.', + defaultEnabledState: true, + group: AdvancedSettingsGroup.ContentTypes, + }, + { + id: 9, + title: 'News', + description: 'Reports on tech industry events.', + defaultEnabledState: true, + group: AdvancedSettingsGroup.ContentCuration, + }, +] as AdvancedSettings[]; + +describe('withoutRetiredTitles', () => { + it('drops the retired titles in the onboarding funnel', () => { + const kept = withoutRetiredTitles(settings, true).map(({ title }) => title); + + expect(kept).toEqual(['News']); + }); + + it('keeps them everywhere else', () => { + expect(withoutRetiredTitles(settings, false)).toHaveLength(3); + expect(withoutRetiredTitles(settings)).toHaveLength(3); + }); + + it('still matches the titles it is meant to retire', () => { + const titles = settings.map(({ title }) => title); + + RETIRED.forEach((title) => expect(titles).toContain(title)); + }); +}); + +describe('getContentTypeNotEmpty', () => { + const checkSourceBlocked = () => true; + + // The gate has to see the same list the step draws. Counting a retired card + // would let the CTA enable with nothing visibly selected. + it('does not count a retired card as a selection in the funnel', () => { + expect( + getContentTypeNotEmpty({ + advancedSettings: settings, + selectedSettings: { 8: true, 25: true, 9: false }, + checkSourceBlocked, + isOnboarding: true, + }), + ).toBe(false); + }); + + it('counts a card the step does draw', () => { + expect( + getContentTypeNotEmpty({ + advancedSettings: settings, + selectedSettings: { 8: false, 25: false, 9: true }, + checkSourceBlocked, + isOnboarding: true, + }), + ).toBe(true); + }); +}); diff --git a/packages/shared/src/components/onboarding/ContentTypes/helpers.ts b/packages/shared/src/components/onboarding/ContentTypes/helpers.ts index c4a389c6401..82137d743bd 100644 --- a/packages/shared/src/components/onboarding/ContentTypes/helpers.ts +++ b/packages/shared/src/components/onboarding/ContentTypes/helpers.ts @@ -7,19 +7,40 @@ import { import type { Source } from '../../../graphql/sources'; import { TOGGLEABLE_TYPES } from '../../feeds/FeedSettings/sections/FeedSettingsContentPreferencesSection'; +// Keyed on display copy, as `TOGGLEABLE_TYPES`, `getAdvancedContentTypes` and +// `contentTypeIcon` already are — `AdvancedSettings.id` is an autoincrement row +// id. A backend rename un-retires these; `helpers.spec.ts` is the tripwire. +const RETIRED_CONTENT_TITLES = ['Community picks', 'Standups']; + +export const withoutRetiredTitles = ( + items: T[], + isOnboarding?: boolean, +): T[] => + isOnboarding + ? items.filter(({ title }) => !RETIRED_CONTENT_TITLES.includes(title)) + : items; + interface GetContentTypeNotEmptyProps { advancedSettings: AdvancedSettings[]; selectedSettings: Record; checkSourceBlocked: (source: Source) => boolean; + isOnboarding?: boolean; } export const getContentTypeNotEmpty = ({ advancedSettings, selectedSettings, checkSourceBlocked, + isOnboarding, }: GetContentTypeNotEmptyProps): boolean => { - const contentSourceList = getContentSourceList(advancedSettings); - const contentCurationList = getContentCurationList(advancedSettings); + const contentSourceList = withoutRetiredTitles( + getContentSourceList(advancedSettings), + isOnboarding, + ); + const contentCurationList = withoutRetiredTitles( + getContentCurationList(advancedSettings), + isOnboarding, + ); const advancedSettingsSelected = (settings: AdvancedSettings[]) => settings @@ -31,14 +52,16 @@ export const getContentTypeNotEmpty = ({ const advancedSettingsCurationListSelected = advancedSettingsSelected(contentCurationList); - const listedTypes = getAdvancedContentTypes( - TOGGLEABLE_TYPES, - advancedSettings, + const listedTypes = withoutRetiredTitles( + getAdvancedContentTypes(TOGGLEABLE_TYPES, advancedSettings), + isOnboarding, ); const selectedSomeListedTypes = advancedSettingsSelected(listedTypes); const sourceListSelected = contentSourceList - .map(({ options }) => options.source) + // The rendered cards skip entries without a source, so the gate must too. + .map(({ options }) => options?.source) + .filter((source): source is Source => !!source) .some((source) => !checkSourceBlocked(source)); return ( diff --git a/packages/shared/src/components/onboarding/EditTag.tsx b/packages/shared/src/components/onboarding/EditTag.tsx index 68addf75b75..4761afe9eff 100644 --- a/packages/shared/src/components/onboarding/EditTag.tsx +++ b/packages/shared/src/components/onboarding/EditTag.tsx @@ -2,7 +2,7 @@ import type { ReactElement } from 'react'; import React, { useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import { FeedPreviewControls } from '../feeds'; -import { REQUIRED_TAGS_THRESHOLD } from './common'; +import { OnboardingHeadline, REQUIRED_TAGS_THRESHOLD } from './common'; import { Origin } from '../../lib/log'; import Feed from '../Feed'; import { OtherFeedPage, RequestKey } from '../../lib/query'; @@ -20,6 +20,12 @@ import { useConditionalFeature } from '../../hooks/useConditionalFeature'; import { featureOnboardingPersonas } from '../../lib/featureManagement'; import { subscribePersonaSelection } from './onboardingPopBus'; +// The column cap needs a matching width cap: the feed only caps its own width +// above 2156px, so a capped preview would otherwise stretch its cards. +const PREVIEW_MAX_COLUMNS = 3; +// 3 × 21.25rem + 2 × 2rem of gutter. +const PREVIEW_MAX_WIDTH = 'tablet:max-w-[70.75rem]'; + interface EditTagProps { feedSettings: FeedSettings; userId: string; @@ -27,6 +33,7 @@ interface EditTagProps { requiredTags?: number; hidePreview?: boolean; featuredTags?: string[]; + isOnboarding?: boolean; } export const EditTag = ({ feedSettings, @@ -35,6 +42,7 @@ export const EditTag = ({ requiredTags = REQUIRED_TAGS_THRESHOLD, hidePreview, featuredTags, + isOnboarding, }: EditTagProps): ReactElement => { const isMobile = useViewSize(ViewSize.MobileL); const [isPreviewVisible, setPreviewVisible] = useState(false); @@ -83,9 +91,13 @@ export const EditTag = ({ return ( <> -

- {resolvedHeadline} -

+ {isOnboarding ? ( + {resolvedHeadline} + ) : ( +

+ {resolvedHeadline} +

+ )} {showPersonas && ( <>

@@ -96,13 +108,22 @@ export const EditTag = ({ )}

{!hidePreview && ( )} {!hidePreview && isPreviewEnabled && isPreviewVisible && ( - +

Change your tag selection until you're happy with your feed preview.

{ const isChrome = checkIsChromeOnly(); return ( @@ -32,14 +44,29 @@ export const OnboardingPWA = ({ disablePictureInPicture controls={false} /> -
- - {headline || 'Add daily.dev to Home Screen'} - - - Tap “Add to Home Screen” below to get daily.dev at your fingertips, - anytime you need it. - +
+ {isOnboarding ? ( + <> + + {headline || DEFAULT_PWA_HEADLINE} + + {PWA_EXPLAINER} + + ) : ( + <> + + {headline || DEFAULT_PWA_HEADLINE} + + + {PWA_EXPLAINER} + + + )}
); diff --git a/packages/shared/src/components/onboarding/OnboardingReadingReminder.tsx b/packages/shared/src/components/onboarding/OnboardingReadingReminder.tsx new file mode 100644 index 00000000000..51638a7cc22 --- /dev/null +++ b/packages/shared/src/components/onboarding/OnboardingReadingReminder.tsx @@ -0,0 +1,167 @@ +import type { KeyboardEvent, ReactElement } from 'react'; +import React, { useRef } from 'react'; +import classNames from 'classnames'; +import { ClickableText } from '../buttons/ClickableText'; +import { VIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { TimezoneDropdown } from '../widgets/TimezoneDropdown'; +import { HourDropdown } from '../fields/HourDropdown'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import type { ReadingReminderState } from './useReadingReminder'; +import { OnboardingHeadline, OnboardingSubheadline } from './common'; + +const readingReminderOptions = [ + { label: '09:00', hint: 'With coffee', emoji: '☕️', value: '9' }, + { label: '12:00', hint: 'Over lunch', emoji: '🥪', value: '12' }, + { label: '17:00', hint: 'Winding down', emoji: '🛋️', value: '17' }, + { label: 'Custom', hint: 'Pick a time', emoji: '⏰', value: 'custom' }, +]; + +interface OnboardingReadingReminderProps { + headline?: string; + state: ReadingReminderState; +} + +export const OnboardingReadingReminder = ({ + headline, + state, +}: OnboardingReadingReminderProps): ReactElement => { + const { + customTimeIndex, + isEditingTimezone, + setCustomTimeIndex, + setIsEditingTimezone, + setTimeOption, + setUserTimeZone, + timeOption, + userTimeZone, + } = state; + const optionRefs = useRef>([]); + + // APG radio pattern: one tab stop for the group, arrows move selection. + const onOptionKeyDown = (event: KeyboardEvent) => { + const offset = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[ + event.key + ]; + + if (!offset) { + return; + } + + event.preventDefault(); + const current = readingReminderOptions.findIndex( + ({ value }) => value === timeOption, + ); + const { length } = readingReminderOptions; + const next = (current + offset + length) % length; + setTimeOption(readingReminderOptions[next].value); + optionRefs.current[next]?.focus(); + }; + + return ( + <> +
+ + {headline || 'When do you need that reading nudge?'} + + + Devs who turn this on build a reading habit that sticks. + +
+ +
+ {readingReminderOptions.map(({ label, hint, emoji, value }, index) => { + const isSelected = timeOption === value; + + return ( + + ); + })} +
+ + {timeOption === 'custom' && ( + + )} + + + Times are in {userTimeZone}{' '} + {isEditingTimezone ? ( + + ) : ( + setIsEditingTimezone(true)} + > + change + + )} + + + ); +}; diff --git a/packages/shared/src/components/onboarding/common.ts b/packages/shared/src/components/onboarding/common.ts index 7de61bf91e5..1c404ff09f4 100644 --- a/packages/shared/src/components/onboarding/common.ts +++ b/packages/shared/src/components/onboarding/common.ts @@ -20,6 +20,21 @@ export const OnboardingTitle = classed( 'text-center typo-title2 font-bold px-4', ); +export const OnboardingSubheadline = classed( + 'p', + // Not `text-balance`: it equalises line lengths and leaves every line short + // of the measure, which reads as manual line breaks. + 'mx-auto w-full max-w-[27.5rem] text-center text-text-secondary typo-body [text-wrap:pretty]', +); + +// The colour must be the explicit token: `.invert` flips a subtree's theme by +// swapping CSS variables, so an inherited colour sails through unchanged. +// Exported as a string too, for the `h2` in `AuthHeader`. +export const onboardingHeadlineClasses = + 'mx-auto w-full max-w-[27.5rem] text-center font-bold text-text-primary typo-title1'; + +export const OnboardingHeadline = classed('h1', onboardingHeadlineClasses); + export const onboardingGradientClasses = 'font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent-bacon-default to-accent-cabbage-default'; export const OnboardingTitleGradient = classed('h1', onboardingGradientClasses); diff --git a/packages/shared/src/components/onboarding/useReadingReminder.ts b/packages/shared/src/components/onboarding/useReadingReminder.ts new file mode 100644 index 00000000000..8d93d19a45c --- /dev/null +++ b/packages/shared/src/components/onboarding/useReadingReminder.ts @@ -0,0 +1,89 @@ +import { useContext, useEffect, useRef, useState } from 'react'; +import { usePushNotificationMutation } from '../../hooks/notifications'; +import { LogEvent, NotificationPromptSource, TargetType } from '../../lib/log'; +import { usePersonalizedDigest } from '../../hooks'; +import { UserPersonalizedDigestType } from '../../graphql/users'; +import { useLogContext } from '../../contexts/LogContext'; +import AuthContext from '../../contexts/AuthContext'; +import { getUserInitialTimezone } from '../../lib/timezones'; + +interface UseReadingReminderProps { + onClickNext: (options?: { skipped?: boolean }) => void; +} + +export const useReadingReminder = ({ + onClickNext, +}: UseReadingReminderProps) => { + const { user } = useContext(AuthContext); + const { logEvent } = useLogContext(); + const [loading, setLoading] = useState(false); + const [userTimeZone, setUserTimeZone] = useState( + getUserInitialTimezone({ + userTimezone: user?.timezone, + update: true, + }), + ); + const [timeOption, setTimeOption] = useState('9'); + const [customTimeIndex, setCustomTimeIndex] = useState(8); + const [isEditingTimezone, setIsEditingTimezone] = useState(false); + const isLogged = useRef(false); + const { onEnablePush } = usePushNotificationMutation(); + const { subscribePersonalizedDigest } = usePersonalizedDigest(); + + useEffect(() => { + if (!isLogged.current) { + isLogged.current = true; + logEvent({ + event_name: LogEvent.Impression, + target_type: TargetType.ReadingReminder, + }); + } + }, [logEvent]); + + const onSkip = () => { + logEvent({ + event_name: LogEvent.SkipReadingReminder, + }); + onClickNext({ skipped: true }); + }; + + const onSubmit = async () => { + if (loading) { + return; + } + setLoading(true); + const selectedHour = + timeOption === 'custom' ? customTimeIndex : parseInt(timeOption, 10); + logEvent({ + event_name: LogEvent.ScheduleReadingReminder, + extra: JSON.stringify({ + hour: selectedHour, + timezone: userTimeZone, + }), + }); + subscribePersonalizedDigest({ + hour: selectedHour, + type: UserPersonalizedDigestType.ReadingReminder, + }); + onEnablePush(NotificationPromptSource.ReadingReminder).then(() => { + onClickNext(); + setLoading(false); + }); + }; + + return { + customTimeIndex, + isEditingTimezone, + loading, + onSkip, + onSubmit, + setCustomTimeIndex, + setIsEditingTimezone, + setTimeOption, + setUserTimeZone, + timeOption, + userTimeZone, + }; +}; + +export type ReadingReminderState = ReturnType; diff --git a/packages/shared/src/components/plus/PlusListItem.tsx b/packages/shared/src/components/plus/PlusListItem.tsx index ac508f2ce31..58c2a3c3fc8 100644 --- a/packages/shared/src/components/plus/PlusListItem.tsx +++ b/packages/shared/src/components/plus/PlusListItem.tsx @@ -57,9 +57,12 @@ export const PlusListItem = ({ return ( ( ( baseFeedSettings.default, ); +interface FeedLayoutProviderProps { + /** + * Upper bound on the column count, for a feed in a narrower box than its + * page. Without it the count comes purely from the viewport, so an embedded + * feed divides its own width by a full page's worth of columns. + */ + maxNumCards?: number; +} + export function FeedLayoutProvider({ children, -}: PropsWithChildren): ReactElement { + maxNumCards, +}: PropsWithChildren): ReactElement { const { sidebarExpanded } = useSettingsContext(); const { sidebarRendered } = useSidebarRendered(); const { isPlus } = usePlusSubscription(); @@ -193,8 +203,27 @@ export function FeedLayoutProvider({ defaultFeedSettings, ); + const cappedSettings = useMemo(() => { + if (!maxNumCards) { + return currentSettings; + } + + const { numCards } = currentSettings; + + return { + ...currentSettings, + numCards: Object.entries(numCards).reduce( + (acc, [spaciness, count]) => ({ + ...acc, + [spaciness]: Math.min(count, maxNumCards), + }), + {} as FeedContextData['numCards'], + ), + }; + }, [currentSettings, maxNumCards]); + return ( - + {children} ); diff --git a/packages/shared/src/features/onboarding/components/OnboardingPlusControl.tsx b/packages/shared/src/features/onboarding/components/OnboardingPlusControl.tsx index 97a67b0da34..301d61e9634 100644 --- a/packages/shared/src/features/onboarding/components/OnboardingPlusControl.tsx +++ b/packages/shared/src/features/onboarding/components/OnboardingPlusControl.tsx @@ -13,6 +13,10 @@ import { ElementPlaceholder } from '../../../components/ElementPlaceholder'; import { ListItemPlaceholder } from '../../../components/widgets/ListItemPlaceholder'; import type { FunnelStepPlusCards } from '../types/funnel'; import { useFunnelAnnualPricing } from '../hooks/useFunnelAnnualPricing'; +import { + OnboardingHeadline, + OnboardingSubheadline, +} from '../../../components/onboarding/common'; const switchSkeletonItems = Array.from({ length: 2 }, (_, i) => i); const PlusSkeleton = (): ReactElement => ( @@ -45,40 +49,73 @@ const PlusSkeleton = (): ReactElement => ( type Parameters = Pick; interface OnboardingPlusControlProps extends Parameters { - onSkip?: () => void; - onComplete?: () => void; + onSkip: () => void; + onComplete: () => void; + /** Onboarding-only: the shared funnel header. The paid funnel keeps its own. */ + isOnboarding?: boolean; } export const OnboardingPlusControl = ({ parameters: { headline, explainer, free, plus }, onSkip, onComplete, + isOnboarding, }: OnboardingPlusControlProps): ReactElement => { const isLaptop = useViewSize(ViewSize.Laptop); const { item } = useFunnelAnnualPricing(); return ( -
+
- - {headline || 'Fast-track your growth'} - - - {explainer || - `Work smarter, learn faster, and stay ahead with AI tools, custom - feeds, and pro features. Because copy-pasting code isn't a - long-term strategy.`} - + {isOnboarding ? ( + <> + {/* The shared onboarding type: one size on every step and every + breakpoint, on the same 440px measure. The paid funnel's own + header below scales itself by viewport instead. */} + + {headline || 'Fast-track your growth'} + + + {explainer || + `Work smarter, learn faster, and stay ahead with AI tools, custom + feeds, and pro features. Because copy-pasting code isn't a + long-term strategy.`} + + + ) : ( + <> + + {headline || 'Fast-track your growth'} + + + {explainer || + `Work smarter, learn faster, and stay ahead with AI tools, custom + feeds, and pro features. Because copy-pasting code isn't a + long-term strategy.`} + + + )}
{item ? ( , ); +// Same hero, wrapped in the post-signup funnel's progress context — which is +// how the component knows it is on `/onboarding` rather than the paid funnel. +const renderHeroInFunnel = ( + props: Partial> = {}, +) => + render( + + +
+ + , + ); + describe('OnboardingSignupHero', () => { const mockUseViewSize = useViewSize as jest.MockedFunction< typeof useViewSize @@ -192,5 +212,15 @@ describe('OnboardingSignupHero', () => { ).not.toBeInTheDocument(); expect(screen.getByTestId('logo')).toBeInTheDocument(); }); + + // The shell carries no footer chrome and no consent line of its own: it + // also serves sign-back and verify-email, where the account already exists. + // The Terms/Privacy notice belongs to RegistrationForm, which renders it + // under the button that creates the account. + it('renders neither footer links nor a disclaimer in the funnel', () => { + renderHeroInFunnel({ isFormExpanded: true }); + expect(screen.queryByTestId('footer')).not.toBeInTheDocument(); + expect(screen.queryByTestId('disclaimer')).not.toBeInTheDocument(); + }); }); }); diff --git a/packages/shared/src/features/onboarding/components/OnboardingSignupHero.tsx b/packages/shared/src/features/onboarding/components/OnboardingSignupHero.tsx index 94c01837809..765506ba18b 100644 --- a/packages/shared/src/features/onboarding/components/OnboardingSignupHero.tsx +++ b/packages/shared/src/features/onboarding/components/OnboardingSignupHero.tsx @@ -4,6 +4,8 @@ import classNames from 'classnames'; import Logo, { LogoPosition } from '../../../components/Logo'; import { FooterLinks } from '../../../components/footer/FooterLinks'; import SignupDisclaimer from '../../../components/auth/SignupDisclaimer'; +import { FunnelStepTopBar } from '../shared/FunnelStepTopBar'; +import { useIsOnboardingFunnel } from '../shared/FunnelStepDots'; import { OnboardingHeader } from '../../../components/onboarding/OnboardingHeader'; import { wrapperMaxWidth } from '../../../components/onboarding/common'; import { @@ -71,6 +73,9 @@ export const OnboardingSignupHero = ({ forceDarkTheme = true, }: Props): ReactElement => { const { applyThemeMode } = useSettingsContext(); + // Only the post-signup funnel swaps the marketing shell for the funnel's + // chrome; /helloworld's landing keeps this component as it was. + const isOnboarding = useIsOnboardingFunnel(); const isMobile = useViewSize(ViewSize.MobileL); useEffect(() => { @@ -96,14 +101,30 @@ export const OnboardingSignupHero = ({ if (isFormExpanded) { return (
-
- -
-
+ {/* This is the post-signup funnel's account-details screen, so it takes + the funnel's own canvas and top bar rather than the marketing shell: + same flat surface and same 24px logo offset as the seven steps that + follow it, and none of the footer chrome. The paid funnel's landing + keeps the shell below. */} + {isOnboarding ? ( + + ) : ( +
+ +
+ )} +
-
-
- -
- -
-
-
- -
-
- -
-
+ {/* No footer chrome in the funnel. The Terms/Privacy line is not here + either — it belongs to the form that creates the account, so + RegistrationForm renders it under its own CTA and it can't leak onto + the login and verify-email screens this shell also serves. */} + {!isOnboarding && ( + <> +
+
+ +
+ +
+
+
+ +
+
+ +
+
+ + )}
); } diff --git a/packages/shared/src/features/onboarding/components/UploadCv.tsx b/packages/shared/src/features/onboarding/components/UploadCv.tsx index 4b4a2abe40e..56f1f916fa8 100644 --- a/packages/shared/src/features/onboarding/components/UploadCv.tsx +++ b/packages/shared/src/features/onboarding/components/UploadCv.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React from 'react'; +import classNames from 'classnames'; import type { MutationStatus } from '@tanstack/react-query'; import { DragDrop } from '../../../components/fields/DragDrop'; import { fileValidation } from '../../profile/hooks/useUploadCv'; @@ -17,6 +18,10 @@ import { TypographyType, TypographyColor, } from '../../../components/typography/Typography'; +import { + OnboardingHeadline, + OnboardingSubheadline, +} from '../../../components/onboarding/common'; import { Image } from '../../../components/image/Image'; import { anchorDefaultRel } from '../../../lib/strings'; @@ -35,6 +40,9 @@ interface UploadCvProps { }; onFilesDrop: (files: File[]) => void; status: MutationStatus; + // The post-signup funnel wraps this step in its own rail, which supplies the + // width cap and gutter; the paid funnel has neither, so it keeps them here. + isOnboarding?: boolean; } export const UploadCv = ({ @@ -46,26 +54,43 @@ export const UploadCv = ({ linkedin, onFilesDrop, status, + isOnboarding, }: UploadCvProps): ReactElement => { return ( -
- - {headline} - +
+ {/* Only the title pair follows the funnel's shared type scale — the rest + of the step is production's markup unchanged. */} + {isOnboarding ? ( + <> + {headline} + {description} + + ) : ( + <> + + {headline} + + + {description} + + + )} - - {description} - -
+
- + {linkedin.headline} @@ -118,7 +152,7 @@ export const UploadCv = ({ {linkedin.headline}
diff --git a/packages/shared/src/features/onboarding/shared/FunnelGlassBar.tsx b/packages/shared/src/features/onboarding/shared/FunnelGlassBar.tsx new file mode 100644 index 00000000000..fa06b43b94b --- /dev/null +++ b/packages/shared/src/features/onboarding/shared/FunnelGlassBar.tsx @@ -0,0 +1,29 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; + +interface FunnelGlassBarProps { + children: ReactNode; + className?: string; +} + +/** Pair with `funnelGlassBarCta` on a `ButtonSize.Medium` child. */ +export function FunnelGlassBar({ + children, + className, +}: FunnelGlassBarProps): ReactElement { + return ( +
+ {children} +
+ ); +} + +export const funnelGlassBarCta = 'flex-1 whitespace-nowrap !px-3 tablet:!px-6'; diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepBackground.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepBackground.tsx index 075320d0962..97a60742aac 100644 --- a/packages/shared/src/features/onboarding/shared/FunnelStepBackground.tsx +++ b/packages/shared/src/features/onboarding/shared/FunnelStepBackground.tsx @@ -1,13 +1,25 @@ -import type { ReactElement, ComponentProps } from 'react'; -import React, { useMemo } from 'react'; +import type { CSSProperties, ReactElement, ComponentProps } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import classNames from 'classnames'; +import dynamic from 'next/dynamic'; +import type { EdgeAuraOptions } from 'edge-aura'; import type { FunnelStep } from '../types/funnel'; import { FunnelStepType, FunnelBackgroundVariant } from '../types/funnel'; import { useIsLightTheme } from '../../../hooks/utils'; +import { useViewSize, ViewSize } from '../../../hooks'; import { isFunnelPricingV2 } from '../steps/FunnelPricing/common'; +import { useOnboardingChrome } from './useOnboardingChrome'; + +// Dynamic so the canvas engine stays out of the control cohort's bundle and the +// extension's — this module renders on every funnel step. +const EdgeAura = dynamic( + () => import('edge-aura/react').then((mod) => mod.EdgeAura), + { ssr: false }, +); interface StepBackgroundProps extends ComponentProps<'div'> { step: FunnelStep; + isOnboarding?: boolean; } const variantToClassName: Record = { @@ -27,6 +39,37 @@ const variantToClassName: Record = { [FunnelBackgroundVariant.Burger]: 'bg-accent-burger-flat', }; +// Above the funnel's own layers (content z-2, docked CTA z-3). +const AURA_STYLE: CSSProperties = { zIndex: 4 }; + +// The engine's own 350ms crossfade runs at each end of this hold. +const PULSE_HOLD_MS = 620; + +// Opal's shape leaned into lavender, so the crossfade reads as the same ring +// flushing rather than a swap to the stock `ultraviolet`. +const PULSE_PALETTE: Array<[number, [number, number, number]]> = [ + [0, [124, 100, 232]], + [0.2, [156, 118, 250]], + [0.42, [198, 158, 255]], + [0.6, [172, 128, 248]], + [0.82, [128, 104, 226]], + [1, [124, 100, 232]], +]; + +// Steps that drop the funnel's per-step wash for one surface across the flow. +const onboardingSteps = [ + FunnelStepType.ProfileForm, + FunnelStepType.EditTags, + FunnelStepType.ContentTypes, + FunnelStepType.ReadingReminder, + FunnelStepType.InstallPwa, + FunnelStepType.BrowserExtension, + // Also rendered by the paid funnel; the `isOnboarding` gate covers these. + FunnelStepType.Pricing, + FunnelStepType.UploadCv, + FunnelStepType.PlusCards, +]; + const getVariantFromStep = (step: FunnelStep): FunnelBackgroundVariant => { if (!step) { return FunnelBackgroundVariant.Default; @@ -56,6 +99,7 @@ const getVariantFromStep = (step: FunnelStep): FunnelBackgroundVariant => { }; const hiddenBgSteps = [FunnelStepType.Checkout]; +const tallTopGradientSteps = [FunnelStepType.EditTags]; const alwaysDarkSteps = [ FunnelStepType.Signup, FunnelStepType.Checkout, @@ -63,12 +107,11 @@ const alwaysDarkSteps = [ FunnelStepType.HeroLanding, FunnelStepType.BrowserExtension, ]; -const tallTopGradientSteps = [FunnelStepType.EditTags]; - export const FunnelStepBackground = ({ children, className, step, + isOnboarding, }: StepBackgroundProps): ReactElement => { const isLightMode = useIsLightTheme(); const isPricingV2 = @@ -76,10 +119,13 @@ export const FunnelStepBackground = ({ const isStepForcedTo = useMemo( () => ({ - dark: alwaysDarkSteps.includes(step.type), + // The iOS share-sheet footage only exists dark. + dark: + alwaysDarkSteps.includes(step.type) || + (!!isOnboarding && step.type === FunnelStepType.InstallPwa), light: isPricingV2, }), - [isPricingV2, step.type], + [isOnboarding, isPricingV2, step.type], ); const bgClassName = useMemo(() => { @@ -90,9 +136,58 @@ export const FunnelStepBackground = ({ ); }, [step]); + const isOnboardingStep = + !!isOnboarding && onboardingSteps.includes(step.type); + // Aura and dots are one experiment arm; the flat surface is the control. + const { hasAura: isAuraArm } = useOnboardingChrome(isOnboarding); + const hasAura = isOnboardingStep && isAuraArm; + const isMobile = useViewSize(ViewSize.MobileXL); + // The engine only needs the value to change to fire a swell. + const [stepPulse, setStepPulse] = useState(0); + const [isPulsing, setIsPulsing] = useState(false); + useEffect(() => { + setStepPulse((count) => count + 1); + setIsPulsing(true); + const timeout = setTimeout(() => setIsPulsing(false), PULSE_HOLD_MS); + + return () => clearTimeout(timeout); + }, [step.id]); + const auraOptions = useMemo( + () => ({ + geometry: { + band: isMobile ? 44 : 76, + coreSigmaBase: 1.8, + coreSigmaVar: 0.8, + innerSoftBase: 1.25, + innerSoftVar: 0.95, + // Else the square screen corner is left dark outside the ring's arc. + cornerFill: true, + cornerRadius: 28, + }, + motion: { + // Co-prime periods, so the crests never settle into a repeating shape. + rotateIdleS: 13, + hueDriftPeriodS: 17, + highlight: { arcDeg: 100, periodS: 19, min: 0.22 }, + decay: 2.1, + hueDriftDeg: 26, + }, + input: { + savedPulseEnergy: 1.25, + }, + palette: { + // The engine defaults to 'light', so it has to follow the funnel. + background: isLightMode ? 'light' : 'dark', + ringAlpha: 0.7, + pastel: 0.5, + }, + }), + [isLightMode, isMobile], + ); const shouldShowBg = - !isPricingV2 && !hiddenBgSteps.some((type) => type === step.type); - const shouldUseTallTopGradient = tallTopGradientSteps.includes(step.type); + !isPricingV2 && + !isOnboardingStep && + !hiddenBgSteps.some((type) => type === step.type); const needInvertedColors = (isStepForcedTo.dark && isLightMode) || @@ -106,11 +201,21 @@ export const FunnelStepBackground = ({ )} >
{children}
+ {/* Above the content, so the ring reads as the screen's own edge glowing. + The engine's canvas is fixed and click-through. */} + {hasAura && ( + + )} {shouldShowBg && (
& { - cta?: { - label?: string; - note?: string; - animation?: string; +export type FunnelStepCtaWrapperProps = ButtonProps<'button'> & + // Some steps link out instead of transitioning, so the CTA can be an anchor. + Pick, 'href' | 'target' | 'rel'> & { + cta?: { + label?: string; + note?: string; + animation?: string; + }; + containerClassName?: string; + skip?: ButtonProps<'button'> & { + cta?: string; + }; + isGlass?: boolean; + /** Rendered in the sticky rail above the bar, so it travels with the CTA. */ + docked?: ReactNode; }; - containerClassName?: string; - skip?: ButtonProps<'button'> & { - cta?: string; - }; -}; + +/** 32rem = 440px of content plus the px-6 gutter on each side. */ +export const funnelStepRail = 'mx-auto w-full max-w-[32rem] px-6'; + +// One default per branch; the glass branch is onboarding-only by construction. +const DEFAULT_CTA_LABEL = 'Next'; +const DEFAULT_ONBOARDING_CTA_LABEL = 'Continue'; export function FunnelStepCtaWrapper({ children, @@ -35,8 +50,15 @@ export function FunnelStepCtaWrapper({ cta, skip, containerClassName, + isGlass, + docked, ...props }: FunnelStepCtaWrapperProps): ReactElement { + const { cta: skipLabel, ...skipProps } = skip ?? {}; + // Gated here rather than per caller: the glass bar brings its own logo and + // skip, which would double the stepper Header's on the paid funnel. + const isOnboarding = useIsOnboardingFunnel(); + const hasGlass = isGlass && isOnboarding; const note = useMemo(() => { if (!cta?.note) { return null; @@ -54,33 +76,78 @@ export function FunnelStepCtaWrapper({ ); }, [cta?.note]); - return ( -
-
{children}
-
- {note} - - {skip && ( + if (!hasGlass) { + return ( +
+
+ {children} +
+
+ {note} - )} + {skip && ( + + )} +
+
+ ); + } + + return ( +
+ +
{children}
+
+ {/* Scrim so content scrolling past the bar dissolves into the page. */} +
+
+ {note} + {/* The rail is click-through, so a docked control opts back in. */} + {!!docked &&
{docked}
} + + + + +
); diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepDots.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepDots.tsx new file mode 100644 index 00000000000..a68d3b78755 --- /dev/null +++ b/packages/shared/src/features/onboarding/shared/FunnelStepDots.tsx @@ -0,0 +1,68 @@ +import type { ReactElement } from 'react'; +import React, { createContext, useContext } from 'react'; +import classNames from 'classnames'; +import type { FunnelPosition } from '../types/funnel'; +import { useOnboardingChrome } from './useOnboardingChrome'; + +export interface FunnelProgressValue { + chapters: { steps: number }[]; + position: FunnelPosition; + /** True only inside `/onboarding`; shared steps read it to opt in. */ + isOnboarding?: boolean; +} + +/** `null` means no funnel around me — a step rendered alone shows no dots. */ +export const FunnelProgressContext = createContext( + null, +); + +export const useIsOnboardingFunnel = (): boolean => + !!useContext(FunnelProgressContext)?.isOnboarding; + +export function FunnelStepDots({ + className, +}: { + className?: string; +}): ReactElement | null { + const progress = useContext(FunnelProgressContext); + const { hasDots } = useOnboardingChrome(progress?.isOnboarding); + const total = progress?.chapters?.[progress.position.chapter]?.steps ?? 0; + + // A single dot communicates nothing. + if (!progress || !hasDots || total < 2) { + return null; + } + + const { step: currentIndex } = progress.position; + + return ( +
+ {Array.from({ length: total }, (_, index) => { + const isCurrent = index === currentIndex; + const isVisited = index < currentIndex; + + return ( + + ); + })} +
+ ); +} diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepTopBar.spec.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepTopBar.spec.tsx new file mode 100644 index 00000000000..a19b9ae69d1 --- /dev/null +++ b/packages/shared/src/features/onboarding/shared/FunnelStepTopBar.spec.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { FunnelStepTopBar } from './FunnelStepTopBar'; + +jest.mock('../../../hooks', () => ({ + ...jest.requireActual('../../../hooks'), + useViewSize: () => false, +})); + +describe('FunnelStepTopBar', () => { + // The strip shrink-wrapped to the logo under a parent that centres its + // children, which slid the mark into the middle of the screen and onto the + // step's headline. It has to declare its own width to be immune to that. + it('takes the full width of a centring parent', () => { + render( +
+ +
, + ); + const strip = screen + .getByRole('button', { name: 'Skip' }) + .closest('.sticky'); + + expect(strip).toHaveClass('w-full'); + }); + + it('renders no skip button unless one is given', () => { + render(); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('renders the skip button with its label and handler', () => { + const onClick = jest.fn(); + render(); + const skip = screen.getByRole('button', { name: 'Skip for now' }); + + skip.click(); + + expect(onClick).toHaveBeenCalledTimes(1); + // Browser default is submit; the funnel steps render inside forms. + expect(skip).toHaveAttribute('type', 'button'); + }); + + // `Logo`'s `linkDisabled` only adds `pointer-events-none`, so it keeps the + // href and the tab stop — one Tab + Enter walked the user out of the funnel. + // The mark is drawn as decoration instead. + it('draws the logo as decoration, with no link to tab into', () => { + render(); + + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepTopBar.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepTopBar.tsx new file mode 100644 index 00000000000..0d096e43103 --- /dev/null +++ b/packages/shared/src/features/onboarding/shared/FunnelStepTopBar.tsx @@ -0,0 +1,70 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { ButtonProps } from '../../../components/buttons/Button'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import LogoIcon from '../../../svg/LogoIcon'; +import LogoText from '../../../svg/LogoText'; +import { useViewSize, ViewSize } from '../../../hooks'; +import { FunnelTargetId } from '../types/funnelEvents'; + +export interface FunnelStepTopBarProps { + skip?: ButtonProps<'button'> & { cta?: string }; +} + +const desktopLogoHeight = 'laptop:h-6'; + +export function FunnelStepTopBar({ + skip, +}: FunnelStepTopBarProps): ReactElement { + const { cta: skipLabel, ...skipProps } = skip ?? {}; + const isLaptop = useViewSize(ViewSize.Laptop); + + return ( + // `w-full` is load-bearing: the sign-back shell centres its children, and + // without it the strip shrink-wraps to the logo and drifts to mid-screen. +
+ {/* Row height is the logo's, not the taller skip button's, so the space + above the wordmark equals the gutter beside it. */} +
+ {/* Drawn directly rather than through `Logo`: its `linkDisabled` only + adds `pointer-events-none`, keeping the href and the tab stop, so + Tab + Enter left the funnel. */} + + + + {skip && ( + + )} +
+
+ ); +} diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx index 660895b5420..7f03569320d 100644 --- a/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx +++ b/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx @@ -11,6 +11,7 @@ import type { import { stepsWithOnlySkipHeader, stepsFullWidth, + stepsFullWidthOnboarding, FunnelStepType, COMPLETED_STEP_ID, FunnelStepTransitionType, @@ -40,6 +41,7 @@ import { FunnelFact } from '../steps/FunnelFact'; import { FunnelCheckout } from '../steps/FunnelCheckout'; import FunnelLoading from '../steps/FunnelLoading'; import { FunnelStepBackground } from './FunnelStepBackground'; +import { FunnelProgressContext } from './FunnelStepDots'; import { useStepTransition } from '../hooks/useStepTransition'; import { FunnelRegistration } from '../steps/FunnelRegistration'; import type { FunnelSession } from '../types/funnelBoot'; @@ -59,6 +61,7 @@ export interface FunnelStepperProps { showCookieBanner?: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- step types have heterogeneous props and are selected by step.type at runtime stepComponentOverrides?: Partial>>; + isOnboarding?: boolean; } const stepComponentMap = { @@ -110,6 +113,7 @@ export const FunnelStepper = ({ showCookieBanner, onComplete, stepComponentOverrides, + isOnboarding, }: FunnelStepperProps): ReactElement | null => { const steps = useMemo( () => funnel?.chapters?.flatMap((chapter) => chapter?.steps), @@ -142,6 +146,11 @@ export const FunnelStepper = ({ }); const shouldSkipRef = useRef>>({}); + const funnelProgress = useMemo( + () => ({ chapters, position, isOnboarding }), + [chapters, position, isOnboarding], + ); + const currentNavigationRef = useRef({ step, position }); currentNavigationRef.current = { step, position }; @@ -218,11 +227,15 @@ export const FunnelStepper = ({ const hasBanner = !!funnel?.parameters?.banner?.stepsToDisplay?.includes( step.id, ); + // Onboarding carries this chrome in the step's CTA wrapper instead. const hasHeader = - step.parameters.shouldShowHeader || - stepsWithHeader.some((type) => type === step.type); + !isOnboarding && + (step.parameters.shouldShowHeader || + stepsWithHeader.some((type) => type === step.type)); const hasCookieConsent = isCookieBannerActive && showBanner; - const isFullWidth = stepsFullWidth.includes(step.type); + const isFullWidth = + stepsFullWidth.includes(step.type) || + (!!isOnboarding && stepsFullWidthOnboarding.includes(step.type)); return { hasHeader, @@ -232,6 +245,7 @@ export const FunnelStepper = ({ }; }, [ isCookieBannerActive, + isOnboarding, showBanner, step.id, step.type, @@ -264,62 +278,64 @@ export const FunnelStepper = ({ {layout.hasCookieConsent && ( )} - -
- {layout.hasBanner && funnel.parameters.banner && ( - - )} -
{ - onTransition({ type: FunnelStepTransitionType.Skip }); - }} - isSkipDisabled={isTransitioning} - showBackButton={back.hasTarget && !hasOnlySkipButton} - showSkipButton={shouldShowHeaderSkip} - showProgressBar={!hasOnlySkipButton} - /> - - - {steps?.map((funnelStep: FunnelStep) => { - const isActive = funnelStep?.id === step?.id; - return ( -
- -
- ); + + +
+ {layout.hasBanner && funnel.parameters.banner && ( + + )} +
- -
+ currentChapter={position.chapter} + currentStep={position.step} + onBack={back.navigate} + onSkip={() => { + onTransition({ type: FunnelStepTransitionType.Skip }); + }} + isSkipDisabled={isTransitioning} + showBackButton={back.hasTarget && !hasOnlySkipButton} + showSkipButton={shouldShowHeaderSkip} + showProgressBar={!hasOnlySkipButton} + /> + + + {steps?.map((funnelStep: FunnelStep) => { + const isActive = funnelStep?.id === step?.id; + return ( +
+ +
+ ); + })} +
+
+
+
); diff --git a/packages/shared/src/features/onboarding/shared/useOnboardingChrome.ts b/packages/shared/src/features/onboarding/shared/useOnboardingChrome.ts new file mode 100644 index 00000000000..e0f4a11aa28 --- /dev/null +++ b/packages/shared/src/features/onboarding/shared/useOnboardingChrome.ts @@ -0,0 +1,25 @@ +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { + featureOnboardingChrome, + OnboardingChromeVariant, +} from '../../../lib/featureManagement'; + +interface OnboardingChrome { + /** The animated edge-aura frame around the viewport. */ + hasAura: boolean; + /** The progress dots under the docked CTA. */ + hasDots: boolean; +} + +/** Aura and dots ship as one arm, so both read from one flag. */ +export const useOnboardingChrome = ( + isOnboarding?: boolean, +): OnboardingChrome => { + const { value } = useConditionalFeature({ + feature: featureOnboardingChrome, + shouldEvaluate: !!isOnboarding, + }); + const isAura = !!isOnboarding && value === OnboardingChromeVariant.Aura; + + return { hasAura: isAura, hasDots: isAura }; +}; diff --git a/packages/shared/src/features/onboarding/steps/FunnelBrowserExtension.tsx b/packages/shared/src/features/onboarding/steps/FunnelBrowserExtension.tsx index 55444fcc8f1..69fe47b84b4 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelBrowserExtension.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelBrowserExtension.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React, { useEffect } from 'react'; +import classNames from 'classnames'; import { useRouter } from 'next/router'; import type { FunnelStepBrowserExtension } from '../types/funnel'; import { FunnelStepTransitionType } from '../types/funnel'; @@ -18,16 +19,23 @@ import { TypographyType, } from '../../../components/typography/Typography'; import { Button } from '../../../components/buttons/Button'; +import { ButtonVariant } from '../../../components/buttons/common'; import { downloadBrowserExtension } from '../../../lib/constants'; import { ChromeIcon, EdgeIcon } from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; import { LogEvent, TargetType } from '../../../lib/log'; import { anchorDefaultRel } from '../../../lib/strings'; -import { ButtonVariant } from '../../../components/buttons/common'; import { FunnelTargetId } from '../types/funnelEvents'; import { withIsActiveGuard } from '../shared/withActiveGuard'; import { sanitizeMessage } from '../lib/utils'; import { withShouldSkipStepGuard } from '../shared/withShouldSkipStepGuard'; import { PlusTrustReviews } from '../../../components/plus/PlusTrustReviews'; +import { + OnboardingHeadline, + OnboardingSubheadline, +} from '../../../components/onboarding/common'; +import { FunnelStepCtaWrapper, funnelStepRail } from '../shared'; +import { useIsOnboardingFunnel } from '../shared/FunnelStepDots'; const BROWSER_EXTENSION_DEFAULTS = { headline: 'Transform every new tab into a learning powerhouse', @@ -54,98 +62,169 @@ const BrowserExtension = ({ const { applyThemeMode } = useSettingsContext(); const isEdge = browserName === BrowserName.Edge; const browserLabel = isEdge ? 'Edge' : 'Chrome'; - const ctaText = cta.replace('{browser}', browserLabel); + const isOnboarding = useIsOnboardingFunnel(); + // Only swap the default; a Freyja-provided cta wins. + const ctaTemplate = + isOnboarding && cta === BROWSER_EXTENSION_DEFAULTS.cta + ? 'Add to {browser}' + : cta; + const ctaText = ctaTemplate.replace('{browser}', browserLabel); const showReviews = showReviewsParam && !isEdge; - // The step is designed for a dark surface (dark funnel gradient, dark - // footage). FunnelStepBackground tries to force that with an `invert` class, - // but Tailwind's invert core plugin is disabled repo-wide, so in light mode - // the copy kept its light-theme colors and turned unreadable. Apply the dark - // theme for as long as the step is mounted, like the hero step does. + // `FunnelStepBackground` forces dark with `invert`, but Tailwind's invert + // plugin is disabled repo-wide, so light mode left the copy unreadable. useEffect(() => { applyThemeMode(ThemeMode.Dark); return () => applyThemeMode(); }, [applyThemeMode]); + const onDownload = () => { + logEvent({ + event_name: LogEvent.DownloadExtension, + target_id: isEdge ? TargetType.Edge : TargetType.Chrome, + }); + onTransition?.({ + type: FunnelStepTransitionType.Complete, + details: { browserName }, + }); + }; + + const reviews = showReviews && ( +
+ + + Loved by millions of developers + +
+ ); + + const footage = ( +
+
+ ); + + // The paid funnel keeps its own inline CTA and takes its skip from the + // stepper header — this step is in `stepsWithOnlySkipHeader`. Docking the CTA + // here regardless would give `/helloworld` a second Skip button under a moved + // download button, so this branch is main's markup unchanged. + if (!isOnboarding) { + return ( +
+
+ + + + {reviews} + +
+ {footage} +
+ ); + } + return ( -
-
- + ) : ( + + ) + } + onClick={onDownload} + rel={anchorDefaultRel} + skip={{ + cta: 'Skip', + onClick: () => onTransition?.({ type: FunnelStepTransitionType.Skip }), + }} + tag="a" + target="_blank" + containerClassName="flex flex-col" + > +
+ - - - {showReviews && ( -
- - - Loved by millions of developers - -
- )} + {reviews}
-
-
-
+ {footage} + ); }; diff --git a/packages/shared/src/features/onboarding/steps/FunnelContentTypes.tsx b/packages/shared/src/features/onboarding/steps/FunnelContentTypes.tsx index 3fa98d2267e..f52c817eac5 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelContentTypes.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelContentTypes.tsx @@ -1,9 +1,11 @@ import type { ReactElement } from 'react'; import React, { useCallback } from 'react'; +import classNames from 'classnames'; import type { FunnelStepContentTypes } from '../types/funnel'; import { FunnelStepTransitionType } from '../types/funnel'; import { ContentTypes } from '../../../components/onboarding'; -import { FunnelStepCtaWrapper } from '../shared'; +import { FunnelStepCtaWrapper, funnelStepRail } from '../shared'; +import { useIsOnboardingFunnel } from '../shared/FunnelStepDots'; import { withIsActiveGuard } from '../shared/withActiveGuard'; import { useAuthContext } from '../../../contexts/AuthContext'; import { getContentTypeNotEmpty } from '../../../components/onboarding/ContentTypes/helpers'; @@ -14,6 +16,7 @@ function FunnelContentTypesComponent({ parameters: { headline, cta }, onTransition, }: FunnelStepContentTypes): ReactElement | null { + const isOnboarding = useIsOnboardingFunnel(); const { isLoggedIn } = useAuthContext(); const { advancedSettings } = useFeedSettings(); const { selectedSettings, checkSourceBlocked } = useAdvancedSettings(); @@ -26,7 +29,10 @@ function FunnelContentTypesComponent({ return null; } + // Same `isOnboarding` the cards use, so the gate can only ever count types + // the step actually rendered. const isDisabled = !getContentTypeNotEmpty({ + isOnboarding, advancedSettings, selectedSettings, checkSourceBlocked, @@ -34,13 +40,29 @@ function FunnelContentTypesComponent({ return ( -
- + {/* single-column on mobile, so the cards sit on the CTA rail; from tablet + up the grid needs more room than the rail and stays centered instead */} +
+
); diff --git a/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx b/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx index 77af111c0ad..d4d44947e18 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelEditTags.tsx @@ -5,15 +5,17 @@ import type { FunnelStepEditTags } from '../types/funnel'; import { FunnelStepTransitionType } from '../types/funnel'; import { EditTag } from '../../../components/onboarding'; import { useAuthContext } from '../../../contexts/AuthContext'; -import { FunnelStepCtaWrapper } from '../shared'; +import { FunnelStepCtaWrapper, funnelStepRail } from '../shared'; import useFeedSettings from '../../../hooks/useFeedSettings'; import { withIsActiveGuard } from '../shared/withActiveGuard'; +import { useIsOnboardingFunnel } from '../shared/FunnelStepDots'; function FunnelEditTagsComponent({ parameters: { headline, cta, minimumRequirement, featuredTags }, onTransition, }: FunnelStepEditTags): ReactElement | null { const { feedSettings } = useFeedSettings(); + const isOnboarding = useIsOnboardingFunnel(); const { user, trackingId } = useAuthContext(); const handleComplete = () => { onTransition({ @@ -32,18 +34,35 @@ function FunnelEditTagsComponent({ return ( -
+
onTransition({ type: FunnelStepTransitionType.Complete })} > -
- +
+
); diff --git a/packages/shared/src/features/onboarding/steps/FunnelOrganicSignup.tsx b/packages/shared/src/features/onboarding/steps/FunnelOrganicSignup.tsx index 10e66c7ea51..5e0a4f7bca1 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelOrganicSignup.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelOrganicSignup.tsx @@ -7,6 +7,7 @@ import { FunnelStepTransitionType } from '../types/funnel'; import { OnboardingHeadline } from '../../../components/auth'; import { FooterLinks } from '../../../components/footer'; import AuthOptions from '../../../components/auth/AuthOptions'; +import { useIsOnboardingFunnel } from '../shared/FunnelStepDots'; import { AuthTriggers } from '../../../lib/auth'; import { ButtonSize, ButtonVariant } from '../../../components/buttons/common'; import { useViewSize, ViewSize } from '../../../hooks'; @@ -74,6 +75,8 @@ export const FunnelOrganicSignup = withIsActiveGuard( const isMobile = useViewSize(ViewSize.MobileL); const setAuth = useSetAtom(authAtom); const { isLoggedIn, isAuthReady, user } = useAuthContext(); + // This step is shared with the paid funnel, which keeps its own chrome. + const isOnboarding = useIsOnboardingFunnel(); const { isOnboardingActionsReady, isOnboardingComplete } = useOnboardingActions(); const [authDisplay, setAuthDisplay] = useState( @@ -250,6 +253,10 @@ export const FunnelOrganicSignup = withIsActiveGuard( )} )} - {!isMobileRevamp && ( + {!isMobileRevamp && !isOnboarding && ( { const { user } = useAuthContext(); const { version = 'V1' } = parameters; + // Only the header type follows the funnel's shared scale; the paid funnel + // keeps its own. + const isOnboarding = useIsOnboardingFunnel(); const transitionToNext = useCallback( ({ skip }: { skip: boolean }) => { @@ -42,7 +47,18 @@ const PlusCards = ({ onTransition, parameters }: FunnelStepPlusCards) => { case 'v2': return ; default: - return ; + // The Plus step keeps production's layout, so it has no CTA wrapper to + // carry the strip — it renders it itself. + return isOnboarding ? ( +
+ transitionToNext({ skip: true }) }} + /> + +
+ ) : ( + + ); } }; diff --git a/packages/shared/src/features/onboarding/steps/FunnelProfileForm.tsx b/packages/shared/src/features/onboarding/steps/FunnelProfileForm.tsx index 172f803aeff..293e375f398 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelProfileForm.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelProfileForm.tsx @@ -1,6 +1,8 @@ import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; +import classNames from 'classnames'; import { useAuthContext } from '../../../contexts/AuthContext'; +import { OnboardingHeadline } from '../../../components/onboarding/common'; import { Typography, TypographyType, @@ -11,12 +13,20 @@ import type { FunnelStepProfileForm } from '../types/funnel'; import { FunnelStepTransitionType } from '../types/funnel'; import useProfileForm from '../../../hooks/useProfileForm'; import { withIsActiveGuard } from '../shared/withActiveGuard'; -import { sanitizeMessage } from '../shared'; +import { useIsOnboardingFunnel } from '../shared/FunnelStepDots'; +import { + funnelStepRail, + FunnelStepCtaWrapper, + sanitizeMessage, +} from '../shared'; + +const PROFILE_FORM_ID = 'funnel-profile-form'; function InnerFunnelProfileForm({ parameters: { headline, extraFields }, onTransition, }: FunnelStepProfileForm): ReactElement | null { + const isOnboarding = useIsOnboardingFunnel(); const headlineHtml = useMemo(() => sanitizeMessage(headline), [headline]); const { user, refetchBoot } = useAuthContext(); const { updateUserProfile, hint, onUpdateHint } = useProfileForm({ @@ -47,25 +57,60 @@ function InnerFunnelProfileForm({ }); }; + const fields = ( + + ); + + // `RegistrationFieldsForm` renders its own submit here, so the CTA wrapper + // would add a second, inert "Sign up". + if (!isOnboarding) { + return ( +
+
+ {headline && ( + + )} + {fields} +
+
+ ); + } + return ( -
-
+ +
{headline && ( - )} - + {fields}
-
+ ); } diff --git a/packages/shared/src/features/onboarding/steps/FunnelReadingReminder.spec.tsx b/packages/shared/src/features/onboarding/steps/FunnelReadingReminder.spec.tsx new file mode 100644 index 00000000000..9c3b2ebeff8 --- /dev/null +++ b/packages/shared/src/features/onboarding/steps/FunnelReadingReminder.spec.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { FunnelReadingReminder } from './FunnelReadingReminder'; +import type { FunnelStepReadingReminder } from '../types/funnel'; +import { FunnelStepType } from '../types/funnel'; +import { FunnelProgressContext } from '../shared/FunnelStepDots'; +import { useReadingReminder } from '../../../components/onboarding/useReadingReminder'; + +jest.mock('../../../components/onboarding/useReadingReminder', () => ({ + useReadingReminder: jest.fn(() => ({ + customTimeIndex: 8, + isEditingTimezone: false, + loading: false, + onSkip: jest.fn(), + onSubmit: jest.fn(), + setCustomTimeIndex: jest.fn(), + setIsEditingTimezone: jest.fn(), + setTimeOption: jest.fn(), + setUserTimeZone: jest.fn(), + timeOption: '9', + userTimeZone: 'UTC', + })), +})); + +// The paid funnel's screen calls the same hook internally; rendering the real +// one here would make "how many times did the step call it" unanswerable. +jest.mock('../../../components/onboarding', () => ({ + ReadingReminder: () =>
, +})); + +jest.mock('../../../contexts/PushNotificationContext', () => ({ + usePushNotificationContext: () => ({ + isPushSupported: true, + isInitialized: true, + }), +})); + +jest.mock('../../../hooks', () => ({ + ...jest.requireActual('../../../hooks'), + useViewSize: () => true, +})); + +const defaultProps: FunnelStepReadingReminder = { + id: 'reading-reminder', + type: FunnelStepType.ReadingReminder, + transitions: [], + isActive: true, + onTransition: jest.fn(), + parameters: { headline: 'When do you want to read?' }, +}; + +const renderStep = (isOnboarding: boolean) => + render( + + + , + ); + +describe('FunnelReadingReminder', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // `useReadingReminder` logs an impression and mounts the push/digest + // mutations, and the paid funnel's own ReadingReminder runs the same effects. + // Calling the hook above the branch doubled both on /helloworld. + it('does not call useReadingReminder outside the onboarding funnel', () => { + renderStep(false); + + expect(screen.getByTestId('legacy-reading-reminder')).toBeInTheDocument(); + expect(useReadingReminder).not.toHaveBeenCalled(); + }); + + it('calls useReadingReminder once inside the onboarding funnel', () => { + renderStep(true); + + expect( + screen.queryByTestId('legacy-reading-reminder'), + ).not.toBeInTheDocument(); + expect(useReadingReminder).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared/src/features/onboarding/steps/FunnelReadingReminder.tsx b/packages/shared/src/features/onboarding/steps/FunnelReadingReminder.tsx index 0ace2d88f5c..ed2a70def6d 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelReadingReminder.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelReadingReminder.tsx @@ -1,26 +1,77 @@ import type { ReactElement } from 'react'; import React from 'react'; +import classNames from 'classnames'; import type { FunnelStepReadingReminder } from '../types/funnel'; import { FunnelStepTransitionType } from '../types/funnel'; import { ReadingReminder } from '../../../components/onboarding'; +import { OnboardingReadingReminder } from '../../../components/onboarding/OnboardingReadingReminder'; +import { useReadingReminder } from '../../../components/onboarding/useReadingReminder'; import { withIsActiveGuard } from '../shared/withActiveGuard'; import { useViewSize, ViewSize } from '../../../hooks'; import { usePushNotificationContext } from '../../../contexts/PushNotificationContext'; import { withShouldSkipStepGuard } from '../shared/withShouldSkipStepGuard'; +import { FunnelStepCtaWrapper, funnelStepRail } from '../shared'; +import { useIsOnboardingFunnel } from '../shared/FunnelStepDots'; + +interface OnboardingReadingReminderStepProps { + headline?: string; + onClickNext: () => void; +} + +// `useReadingReminder` logs an impression and mounts the push/digest mutations +// on mount, and the paid funnel's `ReadingReminder` runs the same effects +// internally — so the hook has to live on the branch that renders it, not above +// the fork, or `/helloworld` logs the step twice per view. +function OnboardingReadingReminderStep({ + headline, + onClickNext, +}: OnboardingReadingReminderStepProps): ReactElement { + const state = useReadingReminder({ onClickNext }); + + return ( + +
+ +
+
+ ); +} function FunnelReadingReminderComponent({ parameters: { headline }, onTransition, }: FunnelStepReadingReminder): ReactElement | null { + const isOnboarding = useIsOnboardingFunnel(); + const onClickNext = () => + onTransition({ type: FunnelStepTransitionType.Complete }); + + // The paid funnel's screen owns its own Submit / "I'll do it later" buttons, + // so it needs no CTA wrapper — this is main's markup unchanged. + if (!isOnboarding) { + return ( +
+ +
+ ); + } + return ( -
- - onTransition({ type: FunnelStepTransitionType.Complete }) - } - /> -
+ ); } diff --git a/packages/shared/src/features/onboarding/steps/FunnelUploadCv.tsx b/packages/shared/src/features/onboarding/steps/FunnelUploadCv.tsx index 8c903275b07..53ddf058e0f 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelUploadCv.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelUploadCv.tsx @@ -1,9 +1,11 @@ import React, { useRef, useState, useEffect } from 'react'; +import classNames from 'classnames'; import type { ReactElement } from 'react'; import type { FunnelStepUploadCv } from '../types/funnel'; import { FunnelStepTransitionType } from '../types/funnel'; import { useAuthContext } from '../../../contexts/AuthContext'; -import { FunnelStepCtaWrapper } from '../shared'; +import { FunnelStepCtaWrapper, funnelStepRail } from '../shared'; +import { useIsOnboardingFunnel } from '../shared/FunnelStepDots'; import { withIsActiveGuard } from '../shared/withActiveGuard'; import { withShouldSkipStepGuard } from '../shared/withShouldSkipStepGuard'; import { UploadCv } from '../components/UploadCv'; @@ -16,6 +18,8 @@ function FunnelUploadCvComponent({ onTransition, }: FunnelStepUploadCv): ReactElement | null { const { user } = useAuthContext(); + // Shared with the paid funnel, which keeps its original chrome. + const isOnboarding = useIsOnboardingFunnel(); const { onUpload, status, isSuccess } = useUploadCv({ shouldOpenModal: false, }); @@ -34,15 +38,42 @@ function FunnelUploadCvComponent({ return ( onTransition({ type: FunnelStepTransitionType.Skip }), + }, + })} onClick={handleComplete} - containerClassName="flex w-full flex-1 flex-col items-center justify-center overflow-hidden" + containerClassName={ + isOnboarding + ? 'flex w-full flex-1 flex-col items-center overflow-hidden' + : 'flex w-full flex-1 flex-col items-center justify-center overflow-hidden' + } > - onUpload(file)} - status={status} - /> +
+ onUpload(file)} + status={status} + /> +
); } diff --git a/packages/shared/src/features/onboarding/steps/paidFunnelParity.spec.tsx b/packages/shared/src/features/onboarding/steps/paidFunnelParity.spec.tsx new file mode 100644 index 00000000000..d18d3fad15f --- /dev/null +++ b/packages/shared/src/features/onboarding/steps/paidFunnelParity.spec.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { FunnelEditTags } from './FunnelEditTags'; +import { FunnelBrowserExtension } from './FunnelBrowserExtension'; +import { FunnelProgressContext } from '../shared/FunnelStepDots'; +import { FunnelStepType } from '../types/funnel'; + +jest.mock('../../../hooks/useFeedSettings', () => ({ + __esModule: true, + default: () => ({ feedSettings: { includeTags: [] } }), +})); + +jest.mock('../../../contexts/AuthContext', () => ({ + __esModule: true, + default: { Consumer: null }, + useAuthContext: () => ({ user: { id: 'u1' }, trackingId: 't1' }), +})); + +jest.mock('../../../components/onboarding', () => ({ + EditTag: () =>
, +})); + +jest.mock( + '../../../components/onboarding/Extension/useOnboardingExtension', + () => ({ + useOnboardingExtension: () => ({ + browserName: 'chrome', + shouldShowExtensionOnboarding: true, + isReady: true, + }), + }), +); + +jest.mock('../../../contexts/SettingsContext', () => ({ + ThemeMode: { Dark: 'dark' }, + useSettingsContext: () => ({ applyThemeMode: jest.fn() }), +})); + +jest.mock('../../../contexts/LogContext', () => ({ + useLogContext: () => ({ logEvent: jest.fn() }), +})); + +jest.mock('next/router', () => ({ useRouter: () => ({ query: {} }) })); + +const renderInFunnel = (ui: React.ReactElement, isOnboarding: boolean) => + render( + + {ui} + , + ); + +const editTags = ( + +); + +const browserExtension = ( + +); + +// Every one of these is a leak that reached `/helloworld` in an earlier +// revision. The paid funnel's steps must keep main's chrome: no docked glass +// CTA, and no second Skip on a step whose skip comes from the stepper header. +describe('paid funnel parity', () => { + describe('edit tags', () => { + // Main takes the CTA out of the accessibility tree with `aria-hidden` while + // it is unusable, rather than showing a disabled button the way the funnel + // does — which is also why it has no accessible name to query by. + it('hides the CTA below the minimum instead of disabling it', () => { + renderInFunnel(editTags, false); + const cta = screen.getByRole('button', { hidden: true }); + + expect(cta).toHaveAttribute('aria-hidden', 'true'); + expect(cta).not.toBeDisabled(); + expect(cta).toHaveClass('opacity-0', 'pointer-events-none'); + }); + + it('disables the CTA in the onboarding funnel', () => { + renderInFunnel(editTags, true); + + expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); + }); + }); + + describe('browser extension', () => { + it('renders no skip button of its own outside the funnel', () => { + renderInFunnel(browserExtension, false); + + expect( + screen.queryByRole('button', { name: 'Skip' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: /Get it for Chrome/ }), + ).toBeInTheDocument(); + }); + + it('docks the CTA and its own skip inside the funnel', () => { + renderInFunnel(browserExtension, true); + + expect(screen.getByRole('button', { name: 'Skip' })).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /Add to Chrome/ }), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/shared/src/features/onboarding/types/funnel.ts b/packages/shared/src/features/onboarding/types/funnel.ts index 24867266dea..01a253200fb 100644 --- a/packages/shared/src/features/onboarding/types/funnel.ts +++ b/packages/shared/src/features/onboarding/types/funnel.ts @@ -488,3 +488,8 @@ export const stepsFullWidth: Array = [ FunnelStepType.InstallPwa, FunnelStepType.UploadCv, ]; +// These size themselves from `funnelStepRail`, which the stepper's narrower +// column would clamp. +export const stepsFullWidthOnboarding: Array = [ + FunnelStepType.ProfileForm, +]; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 8973137660b..9adbac484d8 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -246,6 +246,17 @@ export const featureHijackingVariants = new Feature( HijackingVariant.Default, ); +export enum OnboardingChromeVariant { + /** Control: the flat page surface, no progress dots. */ + Control = 'control', + /** Variant: the animated edge-aura frame plus dots under the CTA. */ + Aura = 'aura', +} +export const featureOnboardingChrome = new Feature( + 'onboarding_chrome', + OnboardingChromeVariant.Control, +); + export const featureLayoutV2 = new Feature('layout_v2', false); export const featureEngagementBarV2 = new Feature('engagement_bar_v2', false); diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index db9a698343e..4c9b3a8fe51 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -17,7 +17,14 @@ "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve" + "jsx": "preserve", + "paths": { + // `moduleResolution: node` predates package `exports` maps, so TS cannot + // follow edge-aura's "./react" subpath on its own. Bundlers do read the + // map, so the import specifier stays `edge-aura/react` and only the + // type lookup is pointed at the file behind it. + "edge-aura/react": ["./node_modules/edge-aura/dist/react.d.ts"] + } }, "include": [ "next-env.d.ts", diff --git a/packages/storybook/stories/components/onboarding/SignupFunnelSteps.stories.tsx b/packages/storybook/stories/components/onboarding/SignupFunnelSteps.stories.tsx new file mode 100644 index 00000000000..db648c2184d --- /dev/null +++ b/packages/storybook/stories/components/onboarding/SignupFunnelSteps.stories.tsx @@ -0,0 +1,683 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { fn } from 'storybook/test'; +import { FunnelProfileForm } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelProfileForm'; +import { FunnelEditTags } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelEditTags'; +import { FunnelContentTypes } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelContentTypes'; +import { FunnelReadingReminder } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelReadingReminder'; +import { FunnelInstallPwa } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelInstallPwa'; +import { FunnelBrowserExtension } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelBrowserExtension'; +import { FunnelUploadCv } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelUploadCv'; +import { FunnelPlusCards } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelPlusCards'; +import { exportLinkedIn } from '@dailydotdev/shared/src/lib/image'; +import { FunnelPaymentPricingContext } from '@dailydotdev/shared/src/contexts/payment/context'; +import { mockPricing } from './FunnelPricing.stories'; +import { FunnelStepType } from '@dailydotdev/shared/src/features/onboarding/types/funnel'; +import { OnboardingChromeVariant } from '@dailydotdev/shared/src/lib/featureManagement'; +import { + FunnelStepShell, + fakeIOSUserAgent, + FEED_PREVIEW_HANDLER, + FEED_SETTINGS_HANDLERS, + MockPlusPaymentProvider, +} from './signupFunnel.mocks'; +import { + OnboardingHeadline, + OnboardingSubheadline, +} from '@dailydotdev/shared/src/components/onboarding/common'; + +/** + * The steps a user walks through after registering on `/onboarding`, mounted + * one per story so the CTA placement can be compared across them. The two + * overview stories put them side by side in equal-width frames — mobile at + * 390px, desktop at 1440px — and every step's CTA bar should hit the same left + * and right edge. + */ +/** + * Every step story takes the experiment arm as an arg, so the overview pages can + * render the same nine steps twice — once per arm — by appending it to the + * iframe URL. + */ +interface StepArgs { + chrome?: OnboardingChromeVariant; +} + +const chromeArgTypes: Meta['argTypes'] = { + chrome: { + control: { type: 'inline-radio' }, + options: Object.values(OnboardingChromeVariant), + }, +}; + +const meta: Meta = { + title: 'Components/Onboarding/Signup funnel steps', + argTypes: chromeArgTypes, + args: { chrome: OnboardingChromeVariant.Control }, + // No `themes` parameter on purpose: it fights the global decorator, and the + // funnel's own always-dark steps then invert into the wrong direction. Use + // the toolbar's theme switcher instead. + parameters: { + layout: 'fullscreen', + // The feed-settings mutations are optimistic: `onMutate` flips the card and + // `onError` rolls it straight back. Without a resolving endpoint every + // toggle would snap back a frame later and the steps could not be clicked + // through at all. These resolve the writes so selection sticks. + msw: { handlers: [...FEED_SETTINGS_HANDLERS, FEED_PREVIEW_HANDLER] }, + }, +}; + +export default meta; + +type Story = StoryObj; + +// The step components take their own slice of the FunnelStep union; the stories +// build plain objects and let each component read what it needs. +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- story fixtures +const baseStep: any = { + isActive: true, + transitions: [], + onTransition: fn(), + onRegisterStepToSkip: fn(), +}; + +export const AccountDetails: Story = { + name: '1. Account details', + render: ({ chrome }: StepArgs) => { + const step = { + ...baseStep, + id: 'profile-form', + type: FunnelStepType.ProfileForm, + parameters: { headline: 'Tell us a bit about yourself' }, + }; + + return ( + + + + ); + }, +}; + +export const PickTags: Story = { + name: '3. Pick tags', + // Five tags are pre-selected, so raising the requirement above 5 shows the + // gated state where the CTA bar fades out until enough tags are picked. + argTypes: { + ...chromeArgTypes, + minimumRequirement: { control: { type: 'number', min: 1 } }, + }, + args: { minimumRequirement: 5 }, + render: ({ + chrome, + minimumRequirement, + }: StepArgs & { minimumRequirement?: number }) => { + const step = { + ...baseStep, + id: 'edit-tags', + type: FunnelStepType.EditTags, + parameters: { + headline: 'Pick tags that are relevant to you', + minimumRequirement, + }, + }; + + return ( + + + + ); + }, +}; + +export const ContentTypes: Story = { + name: '4. Content types', + render: ({ chrome }: StepArgs) => { + const step = { + ...baseStep, + id: 'content-types', + type: FunnelStepType.ContentTypes, + parameters: { + headline: 'What kind of posts would you like to see on your feed?', + }, + }; + + return ( + + + + ); + }, +}; + +export const ReadingReminder: Story = { + name: '5. Reading reminder', + parameters: { + // The step renders on mobile only (`ViewSize.MobileXL` is true below 500px). + docs: { description: { story: 'Renders below 500px viewport width.' } }, + }, + render: ({ chrome }: StepArgs) => { + const step = { + ...baseStep, + id: 'reading-reminder', + type: FunnelStepType.ReadingReminder, + parameters: { headline: 'When do you need that reading nudge?' }, + }; + + return ( + + + + ); + }, +}; + +export const InstallPwa: Story = { + name: '6. Install PWA', + decorators: [ + (Story) => { + // iOS Safari only; faked so the step can be reviewed on desktop. + fakeIOSUserAgent(); + return ; + }, + ], + render: ({ chrome }: StepArgs) => { + const step = { + ...baseStep, + id: 'install-pwa', + type: FunnelStepType.InstallPwa, + parameters: { headline: 'Add daily.dev to Home Screen' }, + }; + + return ( + + + + ); + }, +}; + +export const BrowserExtension: Story = { + name: '9. Browser extension', + render: ({ chrome }: StepArgs) => { + const step = { + ...baseStep, + id: 'browser-extension', + type: FunnelStepType.BrowserExtension, + parameters: { + headline: 'Transform every new tab into a learning powerhouse', + explainer: + 'Unlock the power of every new tab with daily.dev extension. Personalized feed, developer communities, AI search and more!', + cta: 'Add to {browser}', + skip: 'Dare to skip? You might miss out.', + showReviews: false, + }, + }; + + return ( + + + + ); + }, +}; + +export const UploadCv: Story = { + name: '7. Upload CV', + render: ({ chrome }: StepArgs) => { + const step = { + ...baseStep, + id: 'upload-cv', + type: FunnelStepType.UploadCv, + parameters: { + headline: 'Your next job should apply to you', + description: + 'Upload your CV so we quietly match you with roles you might actually want. Nothing is shared without your ok.', + dragDropDescription: 'Drag & Drop your CV or', + ctaDesktop: 'Browse files', + ctaMobile: 'Upload CV', + linkedin: { + cta: 'Go to your LinkedIn profile', + image: exportLinkedIn, + headline: 'Export from LinkedIn', + explainer: "Here's how to get your CV from LinkedIn:", + steps: [ + 'Go to your LinkedIn profile', + 'Click "Resources" \u2192 "Save to PDF"', + 'Download the file and upload it here', + ], + }, + }, + }; + + return ( + + + + ); + }, +}; + +export const PlusCards: Story = { + name: '8. Plus', + render: ({ chrome }: StepArgs) => { + const step = { + ...baseStep, + id: 'plus-cards', + type: FunnelStepType.PlusCards, + parameters: { + headline: 'Fast-track your growth', + explainer: + "Work smarter, learn faster, and stay ahead with AI tools, custom feeds, and pro features. Because copy-pasting code isn't a long-term strategy.", + }, + }; + + return ( + + + + + + + + ); + }, +}; + +// The rail caps at 440px of content inside a 32rem (512px) box, so that is +// where every CTA should start and end. Below 512px the cap does nothing and +// only the rail's px-6 is left. +const railInset = (frameWidth: number) => + Math.max(24, (frameWidth - 512) / 2 + 24); + +const useThemeClass = (): 'dark' | 'light' => { + const [theme, setTheme] = React.useState<'dark' | 'light'>('light'); + + React.useEffect(() => { + const read = () => + setTheme( + document.documentElement.classList.contains('dark') ? 'dark' : 'light', + ); + read(); + const observer = new MutationObserver(read); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'], + }); + + return () => observer.disconnect(); + }, []); + + return theme; +}; + +// The order a user actually meets them. +const OVERVIEW_STEPS = [ + { id: 'account-details', label: '1. Account details' }, + { id: 'verify-email', label: '2. Verify email' }, + { id: 'pick-tags', label: '3. Pick tags' }, + { id: 'content-types', label: '4. Content types' }, + // Both of these render on mobile only in the real funnel, so their desktop + // frames are legitimately empty. + { id: 'reading-reminder', label: '5. Reading reminder', isMobileOnly: true }, + { id: 'install-pwa', label: '6. Install PWA', isMobileOnly: true }, + { id: 'upload-cv', label: '7. Upload CV' }, + { id: 'plus-cards', label: '8. Plus' }, + { id: 'browser-extension', label: '9. Browser extension' }, +]; + +interface StepFramesProps { + // Which arm of the onboarding-chrome experiment the frames render. + arm?: OnboardingChromeVariant; + width: number; + height: number; + // Desktop frames are rendered at their real width and scaled down, so six of + // them can still be compared side by side. + scale?: number; + showGuides?: boolean; + title: string; + description: string; +} + +const StepFrames = ({ + arm = OnboardingChromeVariant.Control, + width, + height, + scale = 1, + showGuides, + title, + description, +}: StepFramesProps): ReactElement => { + // The frames are separate documents, so the toolbar's theme has to be + // forwarded to them explicitly. The theme decorator sets the class after the + // story renders, hence the observer rather than a render-time read. + const theme = useThemeClass(); + const inset = railInset(width); + const [reloadKey, setReloadKey] = React.useState(0); + // Mount the frames one at a time. + // + // Nine iframes booting at once each pull the same module graph, and a cold + // Vite server answers that by re-running dep optimization — which invalidates + // the URLs the in-flight requests are already using. Every frame then dies + // with "Failed to fetch dynamically imported module". Reloading them together + // just repeats the stampede. Staggering means the first frame warms the + // optimizer and the rest are served from a settled graph. + const [mounted, setMounted] = React.useState(1); + React.useEffect(() => { + setMounted(1); + }, [reloadKey]); + React.useEffect(() => { + if (mounted >= OVERVIEW_STEPS.length) { + return undefined; + } + const timeout = setTimeout(() => setMounted((count) => count + 1), 450); + + return () => clearTimeout(timeout); + }, [mounted]); + // ViewSize.MobileXL — the cut-off the mobile-only steps guard on. + const isMobileFrame = width < 500; + + return ( +
+
+
+

{title}

+

{description}

+
+ +
+
+ {OVERVIEW_STEPS.map(({ id, label, isMobileOnly }, frameIndex) => ( +
+
+ {label} + {isMobileOnly && !isMobileFrame && ( + + {' '} + — mobile only, skipped here + + )} +
+
+
+ {frameIndex < mounted ? ( +