From 8ec3719c533653353e71416672a89aab8c2768ef Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:48:29 +0300 Subject: [PATCH 1/2] feat(onboarding): ship the acquisition and user role funnel steps Register the acquisition and userRole steps in the funnel stepper so Freyja funnels can use them; until now a funnel that listed them rendered nothing. The signup forms keep asking for experience level, so the role step is now a single question: the picked role is saved as the profile job title, "Something else" saves nothing, and users who already have a title skip the step. This drops the experience follow-up, the NOT_ENGINEER override and the local-only fallback when the save failed. Finalize both option lists. Acquisition offers the channels that bring developers to daily.dev (search, AI assistants, word of mouth, creators, GitHub, developer communities, sites and newsletters, app and extension stores), shuffled with Other pinned last, and drops the channels that bring close to no signups. Roles are written as clean job titles, with seniority left to the experience level question. Storybook drops the account-details proposals that were not adopted. --- .../src/components/icons/TikTok/color.svg | 7 - .../src/components/icons/TikTok/index.tsx | 9 - .../focus/CommunitySentimentBreakdown.tsx | 2 +- .../onboarding/shared/FunnelStepper.tsx | 4 + .../onboarding/steps/FunnelAcquisition.tsx | 91 +++--- .../onboarding/steps/FunnelUserRole.spec.tsx | 105 ++----- .../onboarding/steps/FunnelUserRole.tsx | 237 ++++----------- .../src/features/onboarding/types/funnel.ts | 9 +- packages/shared/src/graphql/users.ts | 5 +- packages/shared/src/styles/custom.ts | 3 +- .../OnboardingClickAudit.stories.tsx | 28 +- .../OnboardingIdentitySteps.stories.tsx | 271 ++++-------------- .../onboarding/accountDetailsProposal.tsx | 77 ----- .../onboarding/identityPlayground.tsx | 105 +++---- 14 files changed, 224 insertions(+), 729 deletions(-) delete mode 100644 packages/shared/src/components/icons/TikTok/color.svg delete mode 100644 packages/shared/src/components/icons/TikTok/index.tsx delete mode 100644 packages/storybook/stories/components/onboarding/accountDetailsProposal.tsx diff --git a/packages/shared/src/components/icons/TikTok/color.svg b/packages/shared/src/components/icons/TikTok/color.svg deleted file mode 100644 index 96e1253a99e..00000000000 --- a/packages/shared/src/components/icons/TikTok/color.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/packages/shared/src/components/icons/TikTok/index.tsx b/packages/shared/src/components/icons/TikTok/index.tsx deleted file mode 100644 index a541958654a..00000000000 --- a/packages/shared/src/components/icons/TikTok/index.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import type { IconProps } from '../../Icon'; -import Icon from '../../Icon'; -import ColorIcon from './color.svg'; - -export const TikTokIcon = (props: IconProps): ReactElement => ( - -); diff --git a/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx b/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx index f366179b574..675b1b36da8 100644 --- a/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx +++ b/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx @@ -48,7 +48,7 @@ const providerLabel = (source: string): string => const formatDiscussionCount = (value: number): string => largeNumberFormat(value)?.toLowerCase() ?? `${value}`; -export const SourceBadge = ({ +const SourceBadge = ({ source, className, }: { diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx index 7f03569320d..b1fb3a2bacb 100644 --- a/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx +++ b/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx @@ -39,6 +39,8 @@ import { } from '../steps'; import { FunnelFact } from '../steps/FunnelFact'; import { FunnelCheckout } from '../steps/FunnelCheckout'; +import { FunnelAcquisition } from '../steps/FunnelAcquisition'; +import { FunnelUserRole } from '../steps/FunnelUserRole'; import FunnelLoading from '../steps/FunnelLoading'; import { FunnelStepBackground } from './FunnelStepBackground'; import { FunnelProgressContext } from './FunnelStepDots'; @@ -84,6 +86,8 @@ const stepComponentMap = { [FunnelStepType.PlusCards]: FunnelPlusCards, [FunnelStepType.BrowserExtension]: FunnelBrowserExtension, [FunnelStepType.UploadCv]: FunnelUploadCv, + [FunnelStepType.Acquisition]: FunnelAcquisition, + [FunnelStepType.UserRole]: FunnelUserRole, } as const; function FunnelStepComponent(props: { diff --git a/packages/shared/src/features/onboarding/steps/FunnelAcquisition.tsx b/packages/shared/src/features/onboarding/steps/FunnelAcquisition.tsx index b42169e6641..d23b7f1eca3 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelAcquisition.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelAcquisition.tsx @@ -30,18 +30,15 @@ import { shuffleArray } from '../../../lib/func'; import { acquisitionBrandColors } from '../../../styles/custom'; import type { IconProps } from '../../../components/Icon'; import { IconSize } from '../../../components/Icon'; -import { SourceBadge } from '../../../components/post/focus/CommunitySentimentBreakdown'; -import { ChromeIcon } from '../../../components/icons/Browser/Chrome'; -import { FacebookIcon } from '../../../components/icons/Facebook'; +import { AppIcon } from '../../../components/icons/App'; +import { GitHubIcon } from '../../../components/icons/GitHub'; import { GoogleIcon } from '../../../components/icons/Google'; import { InviteIcon } from '../../../components/icons/Invite'; import { LinkedInIcon } from '../../../components/icons/LinkedIn'; import { MailIcon } from '../../../components/icons/Mail'; -import { MegaphoneIcon } from '../../../components/icons/Megaphone'; import { MenuIcon } from '../../../components/icons/Menu'; import { OpenAIIcon } from '../../../components/icons/OpenAI'; import { RedditIcon } from '../../../components/icons/Reddit'; -import { TikTokIcon } from '../../../components/icons/TikTok'; import { TwitterIcon } from '../../../components/icons/Twitter'; import { YoutubeIcon } from '../../../components/icons/Youtube'; @@ -98,7 +95,7 @@ const brandMark = ( ), }); -// Google and Chrome keep their colour art on white, as their own favicons do. +// Google keeps its colour art on white, as its own favicon does. const faviconMark = (BrandIcon: Icon): ChannelMark => ({ logo: , tile: ( @@ -126,49 +123,9 @@ const CHANNEL_OPTIONS: Array< > = [ { value: AcquisitionChannel.Friend, - label: 'Referred by a friend or colleague', + label: 'Friend or colleague', ...accentMark(InviteIcon, '--theme-brand-default', 'text-brand-default'), }, - { - value: AcquisitionChannel.X, - label: 'X (Twitter)', - ...brandMark(TwitterIcon, acquisitionBrandColors.x, ), - }, - { - value: AcquisitionChannel.Reddit, - label: 'Reddit', - ...brandMark(RedditIcon, acquisitionBrandColors.reddit), - }, - { - value: AcquisitionChannel.LinkedIn, - label: 'LinkedIn', - ...brandMark(LinkedInIcon, acquisitionBrandColors.linkedIn), - }, - { - value: AcquisitionChannel.InstagramFacebook, - label: 'Instagram or Facebook', - ...brandMark(FacebookIcon, acquisitionBrandColors.facebook), - }, - { - value: AcquisitionChannel.YouTube, - label: 'YouTube', - ...brandMark(YoutubeIcon, acquisitionBrandColors.youTube), - }, - { - value: AcquisitionChannel.TikTok, - label: 'TikTok', - ...brandMark(TikTokIcon, acquisitionBrandColors.tikTok), - }, - { - value: AcquisitionChannel.HackerNews, - label: 'Hacker News', - logo: , - tile: ( - - - - ), - }, { value: AcquisitionChannel.SearchEngine, label: 'Search engine', @@ -176,7 +133,7 @@ const CHANNEL_OPTIONS: Array< }, { value: AcquisitionChannel.AI, - label: 'AI search or chat, like ChatGPT', + label: 'AI assistant like ChatGPT', ...brandMark( OpenAIIcon, acquisitionBrandColors.openAI, @@ -189,13 +146,33 @@ const CHANNEL_OPTIONS: Array< ), }, { - value: AcquisitionChannel.ExtensionStore, - label: 'Browser extension store', - ...faviconMark(ChromeIcon), + value: AcquisitionChannel.Creator, + label: 'YouTube, podcast or creator', + ...brandMark(YoutubeIcon, acquisitionBrandColors.youTube), + }, + { + value: AcquisitionChannel.GitHub, + label: 'GitHub', + ...brandMark(GitHubIcon, acquisitionBrandColors.gitHub), + }, + { + value: AcquisitionChannel.Reddit, + label: 'Reddit', + ...brandMark(RedditIcon, acquisitionBrandColors.reddit), + }, + { + value: AcquisitionChannel.X, + label: 'X (Twitter)', + ...brandMark(TwitterIcon, acquisitionBrandColors.x, ), + }, + { + value: AcquisitionChannel.LinkedIn, + label: 'LinkedIn', + ...brandMark(LinkedInIcon, acquisitionBrandColors.linkedIn), }, { value: AcquisitionChannel.NewsletterBlog, - label: 'A newsletter or blog', + label: 'Blog, newsletter or website', ...accentMark( MailIcon, '--theme-accent-water-default', @@ -203,12 +180,12 @@ const CHANNEL_OPTIONS: Array< ), }, { - value: AcquisitionChannel.Advertisement, - label: 'Advertisement or sponsorship', + value: AcquisitionChannel.AppStore, + label: 'App or extension store', ...accentMark( - MegaphoneIcon, - '--theme-accent-ketchup-default', - 'text-accent-ketchup-default', + AppIcon, + '--theme-accent-avocado-default', + 'text-accent-avocado-default', ), }, { diff --git a/packages/shared/src/features/onboarding/steps/FunnelUserRole.spec.tsx b/packages/shared/src/features/onboarding/steps/FunnelUserRole.spec.tsx index 9a1e240e55e..655171acb53 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelUserRole.spec.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelUserRole.spec.tsx @@ -13,17 +13,11 @@ jest.mock('../../../hooks/useProfileForm'); const updateUserProfile = jest.fn((params: UpdateProfileParameters) => params.onUpdateSuccess?.(), ); -const updateUser = jest.fn(); const onTransition = jest.fn(); const renderStep = (user: Record = {}) => { (useAuthContext as jest.Mock).mockReturnValue({ user: { id: 'u1', name: 'Ido', ...user }, - updateUser, - }); - (useProfileForm as jest.Mock).mockReturnValue({ - updateUserProfile, - isLoading: false, }); const step = { @@ -47,112 +41,57 @@ const next = () => describe('FunnelUserRole', () => { beforeEach(() => { jest.clearAllMocks(); - // jsdom does not implement scrolling. - jest.spyOn(window, 'scrollTo').mockImplementation(() => undefined); + (useProfileForm as jest.Mock).mockReturnValue({ + updateUserProfile, + isLoading: false, + }); }); - it('should wait for Continue after a role is picked', () => { + it('should store the picked role as the job title', () => { renderStep(); expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled(); - pick('Developer'); - - expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled(); - expect(screen.getByText('Who are you?')).toBeInTheDocument(); - expect(updateUserProfile).not.toHaveBeenCalled(); - }); - - it('should store the picked experience level for an engineering role', () => { - renderStep(); - - pick('Developer'); - next(); - pick('Experienced, 4-5 years'); + pick('AI engineer'); next(); expect(updateUserProfile).toHaveBeenCalledWith( - expect.objectContaining({ - title: 'Developer', - experienceLevel: 'MORE_THAN_4_YEARS', - }), + expect.objectContaining({ title: 'AI engineer' }), ); - expect(onTransition).toHaveBeenCalledWith({ - type: FunnelStepTransitionType.Complete, - details: { role: 'Developer', experienceLevel: 'MORE_THAN_4_YEARS' }, - }); - }); - - it('should keep a non-engineering role out of the engineer signup conversions', () => { - // `NOT_ENGINEER` is what PixelsContext reads to leave a signup out of the - // engineer_signup events; the years answer goes to the funnel instead. - renderStep(); - - pick('Designer'); - next(); - pick('Experienced, 4-5 years'); - next(); - - expect(updateUserProfile).toHaveBeenCalledWith( - expect.objectContaining({ - title: 'Designer', - experienceLevel: 'NOT_ENGINEER', - }), + expect(updateUserProfile.mock.calls[0][0]).not.toHaveProperty( + 'experienceLevel', ); expect(onTransition).toHaveBeenCalledWith({ type: FunnelStepTransitionType.Complete, - details: { role: 'Designer', experienceLevel: 'MORE_THAN_4_YEARS' }, + details: { role: 'AI engineer' }, }); }); - it('should only ask for the role when the experience level is already on file', () => { - renderStep({ experienceLevel: 'MORE_THAN_2_YEARS' }); + it('should not put "Other" on the profile as a job title', () => { + renderStep(); - pick('Founder'); + pick('Something else'); next(); - const [[params]] = updateUserProfile.mock.calls; - expect(params.title).toBe('Founder'); - expect(params).not.toHaveProperty('experienceLevel'); + expect(updateUserProfile).not.toHaveBeenCalled(); expect(onTransition).toHaveBeenCalledWith({ type: FunnelStepTransitionType.Complete, - details: { role: 'Founder', experienceLevel: 'MORE_THAN_2_YEARS' }, + details: { role: 'Other' }, }); }); - it('should carry the answers forward when the profile cannot be saved yet', () => { - let onError: (() => void) | undefined; - (useProfileForm as jest.Mock).mockImplementation((options) => { - onError = options?.onError; - return { updateUserProfile: () => onError?.(), isLoading: false }; - }); - (useAuthContext as jest.Mock).mockReturnValue({ - user: { id: 'u1', name: 'Ido' }, - updateUser, - }); - const step = { - id: 'user-role', - type: FunnelStepType.UserRole, - isActive: true, - parameters: {}, - transitions: [], - onTransition, - } as unknown as FunnelStepUserRole; - render(); + it('should move on when the title cannot be saved', () => { + (useProfileForm as jest.Mock).mockImplementation((options) => ({ + updateUserProfile: () => options?.onError?.(), + isLoading: false, + })); + renderStep(); pick('Designer'); next(); - pick('Experienced, 4-5 years'); - next(); - expect(updateUser).toHaveBeenCalledWith( - expect.objectContaining({ - title: 'Designer', - experienceLevel: 'NOT_ENGINEER', - }), - ); expect(onTransition).toHaveBeenCalledWith({ type: FunnelStepTransitionType.Complete, - details: { role: 'Designer', experienceLevel: 'MORE_THAN_4_YEARS' }, + details: { role: 'Designer' }, }); }); }); diff --git a/packages/shared/src/features/onboarding/steps/FunnelUserRole.tsx b/packages/shared/src/features/onboarding/steps/FunnelUserRole.tsx index 045ab96d602..b00f910d255 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelUserRole.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelUserRole.tsx @@ -1,9 +1,8 @@ import type { ReactElement } from 'react'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import classNames from 'classnames'; import type { FunnelStepUserRole, FunnelUserRoleOption } from '../types/funnel'; import { FunnelStepTransitionType } from '../types/funnel'; -import type { CheckboxItem } from '../../common/components/FormInputCheckboxGroup'; import { CheckboxGroupBehaviour, FormInputCheckboxGroup, @@ -19,167 +18,72 @@ import { OnboardingHeadline, OnboardingSubheadline, } from '../../../components/onboarding/common'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '../../../components/buttons/Button'; -import { - Typography, - TypographyColor, - TypographyType, -} from '../../../components/typography/Typography'; -import type { UserExperienceLevelKey } from '../../../components/auth/RegistrationFieldsForm'; import { useAuthContext } from '../../../contexts/AuthContext'; import useProfileForm from '../../../hooks/useProfileForm'; -import type { LoggedUser } from '../../../lib/user'; -import { RecruiterUserExperienceLevel } from '../../../lib/user'; const DEFAULT_HEADLINE = 'Who are you?'; -const DEFAULT_EXPERIENCE_HEADLINE = 'How long have you been doing this?'; -const NON_ENGINEER: UserExperienceLevelKey = 'NOT_ENGINEER'; +// Picking it answers the question without putting a job title on the profile. +const OTHER_ROLE = 'Other'; // `value` lands on the profile as the job title, so it is written as one. const DEFAULT_USER_ROLES: FunnelUserRoleOption[] = [ - { value: 'Developer', label: 'Developer', isTechnical: true }, - { - value: 'Engineering leader', - label: 'Engineering leader', - isTechnical: true, - }, - { value: 'DevOps engineer', label: 'DevOps & platform', isTechnical: true }, - { value: 'Data scientist', label: 'Data & ML', isTechnical: true }, - { value: 'Student', label: 'Student', isTechnical: true }, + { value: 'Software engineer', label: 'Software engineer' }, + { value: 'Engineering manager', label: 'Engineering manager' }, { value: 'Founder', label: 'Founder' }, + { value: 'DevOps engineer', label: 'DevOps engineer' }, + { value: 'AI engineer', label: 'AI engineer' }, + { value: 'Data engineer', label: 'Data engineer' }, + { value: 'Student', label: 'Student' }, { value: 'Product manager', label: 'Product manager' }, { value: 'Designer', label: 'Designer' }, - { value: 'Other', label: 'Something else' }, + { value: OTHER_ROLE, label: 'Something else' }, ]; -// One set for every role: the level names without the engineering wording, -// and the years set apart so the rows scan by level. -const EXPERIENCE_LEVELS: Array<{ - value: UserExperienceLevelKey; - label: string; -}> = [ - { value: 'LESS_THAN_1_YEAR', label: 'Aspiring' }, - { value: 'MORE_THAN_1_YEAR', label: 'Entry-level' }, - { value: 'MORE_THAN_2_YEARS', label: 'Mid-level' }, - { value: 'MORE_THAN_4_YEARS', label: 'Experienced' }, - { value: 'MORE_THAN_6_YEARS', label: 'Highly experienced' }, - { value: 'MORE_THAN_10_YEARS', label: "I've suffered enough" }, -]; - -const EXPERIENCE_OPTIONS: CheckboxItem[] = EXPERIENCE_LEVELS.map( - ({ value, label }) => ({ - value, - label, - hint: RecruiterUserExperienceLevel[value], - }), -); - -/** - * `experienceLevel` means *engineering* experience: `NOT_ENGINEER` is what - * gates the `engineer_signup` conversion events in `PixelsContext`. A - * non-engineering role has to keep that value, so its years answer travels in - * the transition details instead of overwriting it. - */ -const getProfileExperienceLevel = ( - role: FunnelUserRoleOption, - level: UserExperienceLevelKey, -): UserExperienceLevelKey => (role.isTechnical ? level : NON_ENGINEER); - function FunnelUserRoleComponent({ id, - parameters: { headline, explainer, cta, roles, experience }, + parameters: { headline, explainer, cta, roles }, onTransition, }: FunnelStepUserRole): ReactElement | null { - const { user, updateUser } = useAuthContext(); + const { user } = useAuthContext(); const [selectedRole, setSelectedRole] = useState(); - const [role, setRole] = useState(); - const [level, setLevel] = useState(); const options = roles?.length ? roles : DEFAULT_USER_ROLES; const headlineHtml = useMemo( () => sanitizeMessage(headline || DEFAULT_HEADLINE), [headline], ); - const pendingAnswerRef = useRef<{ - profile: Pick; - details: { role: string; experienceLevel: UserExperienceLevelKey }; - }>(); - // The API rejects a profile update from a user with no username or name - // unless the same request supplies them, which only account details asks - // for. Keep the answers on the local profile, so that step saves them along - // with the username, rather than stranding the user here. - const { updateUserProfile, isLoading } = useProfileForm({ - onError: () => { - const pending = pendingAnswerRef.current; - if (!user || !pending) { - return; - } - - updateUser({ ...user, ...pending.profile }); + const complete = useCallback( + (role: string) => onTransition({ type: FunnelStepTransitionType.Complete, - details: pending.details, - }); - }, - }); - // Email signups already picked a level on the registration form, so for them - // the role is the only new question. - const [levelOnFile] = useState(() => user?.experienceLevel); - - // Each pane is a screen of its own, so it opens where the first one did - // rather than at whatever offset the roles were scrolled to. - const showPane = useCallback((nextRole?: FunnelUserRoleOption) => { - setRole(nextRole); - setLevel(undefined); - globalThis.scrollTo?.({ top: 0 }); - }, []); - - const complete = useCallback( - (picked: FunnelUserRoleOption, pickedLevel: UserExperienceLevelKey) => { - const profile = { - title: picked.value, - ...(!levelOnFile && { - experienceLevel: getProfileExperienceLevel(picked, pickedLevel), - }), - }; - const details = { role: picked.value, experienceLevel: pickedLevel }; - pendingAnswerRef.current = { profile, details }; - - updateUserProfile({ - ...profile, - // No `refetchBoot`: the hook already merges these fields into the boot - // cache, and a refetch would only delay the transition. - onUpdateSuccess: () => - onTransition({ type: FunnelStepTransitionType.Complete, details }), - }); - }, - [levelOnFile, onTransition, updateUserProfile], + details: { role }, + }), + [onTransition], ); - const onContinue = useCallback(() => { - if (role) { - if (level) { - complete(role, level); - } - return; - } + // The title is a profile nicety, not something the funnel should stall on. + const { updateUserProfile, isLoading } = useProfileForm({ + onError: () => selectedRole && complete(selectedRole.value), + }); + const onContinue = useCallback(() => { if (!selectedRole) { return; } - if (levelOnFile) { - complete(selectedRole, levelOnFile); + if (selectedRole.value === OTHER_ROLE) { + complete(selectedRole.value); return; } - showPane(selectedRole); - }, [complete, level, levelOnFile, role, selectedRole, showPane]); + updateUserProfile({ + title: selectedRole.value, + // No `refetchBoot`: the hook already merges the title into the boot + // cache, and a refetch would only delay the transition. + onUpdateSuccess: () => complete(selectedRole.value), + }); + }, [complete, selectedRole, updateUserProfile]); if (!user) { return null; @@ -190,7 +94,7 @@ function FunnelUserRoleComponent({ isGlass containerClassName="flex w-full flex-1 flex-col items-center overflow-hidden" cta={{ label: cta }} - disabled={role ? !level : !selectedRole} + disabled={!selectedRole} loading={isLoading} onClick={onContinue} > @@ -200,73 +104,36 @@ function FunnelUserRoleComponent({ 'z-1 flex flex-col items-center gap-6 py-6 pt-3', )} > - {role ? ( - <> - - {experience?.headline || DEFAULT_EXPERIENCE_HEADLINE} - -
- - {role.label} - - -
-
- - setLevel(input.at(-1) as UserExperienceLevelKey) - } - options={EXPERIENCE_OPTIONS} - /> -
- - ) : ( - <> - - {!!explainer && ( - {explainer} - )} -
- - setSelectedRole( - options.find(({ value }) => value === input.at(-1)), - ) - } - options={options} - /> -
- + + {!!explainer && ( + {explainer} )} +
+ + setSelectedRole( + options.find(({ value }) => value === input.at(-1)), + ) + } + options={options} + /> +
); } +// A title already on the profile (e.g. from the signup form's extra fields) +// is kept rather than replaced by a broader role. export const FunnelUserRole = withShouldSkipStepGuard( withIsActiveGuard(FunnelUserRoleComponent), () => { const { user } = useAuthContext(); - return { shouldSkip: !!user?.experienceLevel && !!user?.title }; + return { shouldSkip: !!user?.title }; }, ); diff --git a/packages/shared/src/features/onboarding/types/funnel.ts b/packages/shared/src/features/onboarding/types/funnel.ts index 7ad3181a864..f29aed48e60 100644 --- a/packages/shared/src/features/onboarding/types/funnel.ts +++ b/packages/shared/src/features/onboarding/types/funnel.ts @@ -6,7 +6,6 @@ import type { AnonymousUser, LoggedUser, ProfileExtraField, - UserExperienceLevel, } from '../../../lib/user'; import type { AcquisitionChannel } from '../../../graphql/users'; import type { BrowserName } from '../../../lib/func'; @@ -442,12 +441,9 @@ export interface FunnelStepAcquisition } export interface FunnelUserRoleOption { - // Stored as the profile's job title. + // Stored as the profile's job title, except for `Other`. value: string; label: string; - // Other roles are stored as `NOT_ENGINEER`, which keeps them out of the - // engineer_signup conversion events. - isTechnical?: boolean; } export interface FunnelStepUserRole @@ -456,13 +452,10 @@ export interface FunnelStepUserRole explainer?: string; cta?: string; roles?: FunnelUserRoleOption[]; - // The follow-up that replaces the account-details experience dropdown. - experience?: { headline?: string }; }> { type: FunnelStepType.UserRole; onTransition: FunnelStepTransitionCallback<{ role: string; - experienceLevel: keyof typeof UserExperienceLevel; }>; } diff --git a/packages/shared/src/graphql/users.ts b/packages/shared/src/graphql/users.ts index 580b624e9cb..52e16deccfa 100644 --- a/packages/shared/src/graphql/users.ts +++ b/packages/shared/src/graphql/users.ts @@ -820,10 +820,11 @@ export enum AcquisitionChannel { InstagramFacebook = 'instagram_facebook', YouTube = 'youtube', TikTok = 'tiktok', - HackerNews = 'hacker_news', + Creator = 'creator', + GitHub = 'github', SearchEngine = 'search_engine', AI = 'ai', - ExtensionStore = 'extension_store', + AppStore = 'app_store', NewsletterBlog = 'newsletter_blog', Advertisement = 'ad', Other = 'other', diff --git a/packages/shared/src/styles/custom.ts b/packages/shared/src/styles/custom.ts index e8fb1dcfe31..e9b1e84898f 100644 --- a/packages/shared/src/styles/custom.ts +++ b/packages/shared/src/styles/custom.ts @@ -62,9 +62,8 @@ export const acquisitionBrandColors = { x: '#000000', reddit: '#FF4500', linkedIn: '#0A66C2', - facebook: '#1877F2', youTube: '#FF0000', - tikTok: '#000000', + gitHub: '#181717', openAI: '#000000', favicon: '#FFFFFF', glyph: '#FFFFFF', diff --git a/packages/storybook/stories/components/onboarding/OnboardingClickAudit.stories.tsx b/packages/storybook/stories/components/onboarding/OnboardingClickAudit.stories.tsx index 93547ea1223..ce981896a68 100644 --- a/packages/storybook/stories/components/onboarding/OnboardingClickAudit.stories.tsx +++ b/packages/storybook/stories/components/onboarding/OnboardingClickAudit.stories.tsx @@ -76,6 +76,15 @@ const STEPS: AuditStep[] = [ today: ['Type the 6-digit code'], why: 'The code submits itself on the sixth digit, and iOS autofill fills it in one tap. This is the pattern the rest of the funnel should follow.', }, + { + id: 'account-details', + name: 'Account details', + storyId: 'components-onboarding-signup-funnel-steps--account-details', + shownTo: 'Everyone: the email form, or the social form after Google or GitHub', + verdict: 'keep', + today: ['Check the prefilled fields', 'Tap Sign up'], + why: 'A form with several fields needs an explicit submit, and it still asks for the experience level, so there is no single tap to finish on.', + }, { id: 'hear-about-us', name: 'How did you hear about us', @@ -84,26 +93,17 @@ const STEPS: AuditStep[] = [ shownTo: 'Everyone who has not answered it before', verdict: 'keep', today: ['Tap a channel', 'Tap Continue'], - why: 'Decided: keep Continue, so the three new questions behave the same way. Tapping an option selects it, and Continue moves on.', + why: 'Decided: keep Continue, so the two new questions behave the same way. Tapping an option selects it, and Continue moves on.', }, { id: 'who-are-you', - name: 'Who are you + experience', + name: 'Who are you', storyId: 'components-onboarding-identity-steps--who-are-you', isNew: true, - shownTo: 'Everyone without a role and experience on file', + shownTo: 'Everyone without a job title on file', verdict: 'keep', - today: ['Tap a role', 'Tap Continue', 'Tap a level', 'Tap Continue'], - why: 'Decided: keep Continue on both questions, matching How did you hear about us. Email signups, who already gave a level at registration, see only the role question.', - }, - { - id: 'account-details', - name: 'Account details', - storyId: 'components-onboarding-signup-funnel-steps--account-details', - shownTo: 'Email signups, and OAuth signups missing a username', - verdict: 'keep', - today: ['Check the prefilled fields', 'Tap Sign up'], - why: 'A form with several fields needs an explicit submit. The bigger win already landed: with the role step in front of it, a Google or GitHub signup skips this screen entirely.', + today: ['Tap a role', 'Tap Continue'], + why: 'Decided: keep Continue, matching How did you hear about us. The role is saved as the job title, except Something else, which saves nothing.', }, { id: 'pick-tags', diff --git a/packages/storybook/stories/components/onboarding/OnboardingIdentitySteps.stories.tsx b/packages/storybook/stories/components/onboarding/OnboardingIdentitySteps.stories.tsx index 4c21ca1a1f6..23f4cb43c2b 100644 --- a/packages/storybook/stories/components/onboarding/OnboardingIdentitySteps.stories.tsx +++ b/packages/storybook/stories/components/onboarding/OnboardingIdentitySteps.stories.tsx @@ -4,8 +4,6 @@ import React from 'react'; import { fn } from 'storybook/test'; import { FunnelAcquisition } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelAcquisition'; import { FunnelUserRole } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelUserRole'; -import { FunnelProfileForm } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelProfileForm'; -import { ProposedFunnelProfileForm } from './accountDetailsProposal'; import { FunnelStepType } from '@dailydotdev/shared/src/features/onboarding/types/funnel'; import { AcquisitionChannel } from '@dailydotdev/shared/src/graphql/users'; import { OnboardingChromeVariant } from '@dailydotdev/shared/src/lib/featureManagement'; @@ -27,22 +25,15 @@ import { } from './identityPlayground'; /** - * Two steps proposed for the signup onboarding, plus the account-details step - * they are meant to empty out. + * The two identity steps a new user meets right after account details. * - * 1. **How did you hear about us** — the acquisition question that today only - * appears as a feed card behind a `?ua=true` query param, moved to where - * every new user actually passes. - * 2. **Who are you** — a list of roles, and, once a role is picked, the - * experience question the account-details form asks. Between them they write `title` - * and `experienceLevel` to the profile. + * 1. **How did you hear about us**: the acquisition question that otherwise + * only appears as a feed card behind a `?ua=true` query param, moved to + * where every new user passes. Writes `acquisitionChannel`. + * 2. **Who are you**: a list of roles. The pick is saved as the profile's job + * `title`, except Something else, which saves nothing. * - * With those two on the profile, an OAuth signup (Google/GitHub, which supply - * name, email and avatar) has nothing left for the account-details form to - * ask, so that step can drop itself — `skipWhenComplete` on the profileForm - * step. The account-details stories show that side by side. That part is a - * proposal simulated in Storybook (`accountDetailsProposal.tsx`); the shared - * account-details step is unchanged. + * Each step skips itself when its answer is already on the profile. * * Everything here renders the real step components; only auth, the GraphQL * writes and the funnel chrome are faked. @@ -81,14 +72,13 @@ const baseStep: any = { onRegisterStepToSkip: fn(), }; -/** A GitHub signup: name, email and avatar came from the provider. */ +/** A GitHub signup that just finished account details. */ const oauthUser = { name: 'Ido Shamun', username: 'ido', email: 'ido@acme.com', providers: ['github'], title: undefined, - experienceLevel: undefined, acquisitionChannel: undefined, }; @@ -141,90 +131,66 @@ interface HearAboutUsArgs extends StepArgs { skip?: string; } -// The six channels most likely to lead, plus Other. -const COMPACT_CHANNELS = [ - AcquisitionChannel.SearchEngine, - AcquisitionChannel.AI, - AcquisitionChannel.Friend, - AcquisitionChannel.YouTube, - AcquisitionChannel.X, - AcquisitionChannel.Reddit, - AcquisitionChannel.Other, -]; - -const renderHearAboutUs = - (options?: AcquisitionChannel[]) => - ({ chrome, iconStyle, explainer, shuffle, skip }: HearAboutUsArgs) => { - const step = { - ...baseStep, - id: 'acquisition', - type: FunnelStepType.Acquisition, - parameters: { - headline: 'How did you hear about us?', - options, - iconStyle, - explainer, - shuffle, - skip, - }, - }; - - return ( - - - - ); +const renderHearAboutUs = ({ + chrome, + iconStyle, + explainer, + shuffle, + skip, +}: HearAboutUsArgs) => { + const step = { + ...baseStep, + id: 'acquisition', + type: FunnelStepType.Acquisition, + parameters: { + headline: 'How did you hear about us?', + iconStyle, + explainer, + shuffle, + skip, + }, }; + return ( + + + + ); +}; + const hearAboutUsArgs: HearAboutUsArgs = { iconStyle: 'logo', shuffle: false, explainer: '', - skip: '', + skip: 'Skip', }; export const HearAboutUs: Story = { - name: '1. How did you hear about us · all 14', + name: '1. How did you hear about us', argTypes: hearAboutUsArgTypes, args: hearAboutUsArgs, beforeEach: () => bootAsUser(oauthUser), - render: renderHearAboutUs(), -}; - -export const HearAboutUsCompact: Story = { - name: '1b. How did you hear about us · compact 7', - argTypes: hearAboutUsArgTypes, - args: hearAboutUsArgs, - parameters: { - docs: { - description: { - story: - 'The same step with the six channels most likely to lead, plus Other. Everything else matches the full list, so the two can be compared on length alone. The set is the `options` parameter, so a campaign funnel can pick its own (Instagram or Facebook for a Meta campaign, for example).', - }, - }, - }, - beforeEach: () => bootAsUser(oauthUser), - render: renderHearAboutUs(COMPACT_CHANNELS), + render: renderHearAboutUs, }; export const HearAboutUsTiles: Story = { - name: '1c. How did you hear about us · tile icons', + name: '1b. How did you hear about us · tile icons', argTypes: hearAboutUsArgTypes, args: { ...hearAboutUsArgs, iconStyle: 'tile' }, parameters: { docs: { description: { story: - 'Every mark in the same favicon-style rounded square: the brand colour behind a white glyph, with Google and Chrome on white the way their own favicons are. Set by `iconStyle: "tile"`; the other stories switch to it from the controls.', + 'Every mark in the same favicon-style rounded square: the brand colour behind a white glyph, with Google on white the way its own favicon is. Set by `iconStyle: "tile"`; the other stories switch to it from the controls.', }, }, }, beforeEach: () => bootAsUser(oauthUser), - render: renderHearAboutUs(), + render: renderHearAboutUs, }; export const HearAboutUsAnswered: Story = { - name: '1d. Already answered — step skips', + name: '1c. Already answered, step skips', parameters: { docs: { description: { @@ -262,13 +228,11 @@ const roleArgTypes: Meta['argTypes'] = { ...chromeArgTypes, headline: { control: 'text' }, explainer: { control: 'text' }, - experienceHeadline: { control: 'text' }, }; interface RoleArgs extends StepArgs { headline?: string; explainer?: string; - experienceHeadline?: string; } export const WhoAreYou: Story = { @@ -276,68 +240,23 @@ export const WhoAreYou: Story = { argTypes: roleArgTypes, args: { headline: 'Who are you?', - explainer: 'So your feed starts from the right place.', - experienceHeadline: 'How long have you been doing this?', + explainer: '', }, parameters: { docs: { description: { story: - 'Tap a role, then Continue, then tap a level, then Continue: the same select-then-Continue rhythm as "How did you hear about us". Continue stays disabled until something is selected. The experience question opens with its title on top and a Change link back to the roles, which keeps your pick selected. Every role sees the same levels, with the years in gray on the right.', + 'Tap a role, then Continue: the same select-then-Continue rhythm as "How did you hear about us". Continue stays disabled until a role is selected. The role is saved as the profile\'s job title, except Something else, which moves on without saving anything.', }, }, }, beforeEach: () => bootAsUser(oauthUser), - render: ({ - chrome, - headline, - explainer, - experienceHeadline, - }: RoleArgs): ReactElement => { + render: ({ chrome, headline, explainer }: RoleArgs): ReactElement => { const step = { ...baseStep, id: 'user-role', type: FunnelStepType.UserRole, - parameters: { - headline, - explainer, - experience: { headline: experienceHeadline }, - }, - }; - - return ( - - - - ); - }, -}; - -export const WhoAreYouNonTechnical: Story = { - name: '2b. Non-technical role', - parameters: { - docs: { - description: { - story: - 'A trimmed role list. A non-engineering role sees the same levels as everyone, and is saved to the profile as NOT_ENGINEER so it stays out of the engineer_signup conversion events; the years it picked travel in the funnel details.', - }, - }, - }, - beforeEach: () => bootAsUser(oauthUser), - render: ({ chrome }: StepArgs): ReactElement => { - const step = { - ...baseStep, - id: 'user-role-trimmed', - type: FunnelStepType.UserRole, - parameters: { - headline: 'Who are you?', - roles: [ - { value: 'Developer', label: 'Developer', isTechnical: true }, - { value: 'Designer', label: 'Designer' }, - { value: 'Founder', label: 'Founder' }, - { value: 'Other', label: 'Something else' }, - ], - }, + parameters: { headline, explainer }, }; return ( @@ -348,99 +267,30 @@ export const WhoAreYouNonTechnical: Story = { }, }; -export const AccountDetailsToday: Story = { - name: '3. Account details — today', - parameters: { - docs: { - description: { - story: - 'What a Google/GitHub signup sees now. Email, name and username arrive filled from the provider; the experience dropdown is the only thing the screen is really asking for.', - }, - }, - }, - beforeEach: () => bootAsUser(oauthUser), - render: ({ chrome }: StepArgs) => { - const step = { - ...baseStep, - id: 'profile-form', - type: FunnelStepType.ProfileForm, - parameters: { headline: 'Tell us a bit about yourself' }, - }; - - return ( - - - - ); - }, -}; - -export const AccountDetailsWithoutExperience: Story = { - name: '3b. Account details — no experience dropdown', +export const WhoAreYouAnswered: Story = { + name: '2b. Title on file, step skips', parameters: { docs: { description: { story: - 'An email signup who answered the user-role step: the form still has a username to confirm, but the experience dropdown is gone, because the role step already asked it in the words of the role they picked. A Storybook-only wrapper hides it here (`accountDetailsProposal.tsx`); engineering builds it into `RegistrationFieldsForm`.', + "A user who already has a job title (for example from the signup form's extra fields) keeps it: the step reports itself skippable and renders nothing, so a broader role never replaces it.", }, }, }, - beforeEach: () => - bootAsUser({ - ...oauthUser, - providers: [], - username: undefined, - title: 'Designer', - experienceLevel: 'NOT_ENGINEER', - }), + beforeEach: () => bootAsUser({ ...oauthUser, title: 'Staff engineer' }), render: ({ chrome }: StepArgs) => { const step = { ...baseStep, - id: 'profile-form-no-experience', - type: FunnelStepType.ProfileForm, - parameters: { headline: 'Tell us a bit about yourself' }, - }; - - return ( - - - - ); - }, -}; - -export const AccountDetailsSkipped: Story = { - name: '3c. Account details — dropped', - parameters: { - docs: { - description: { - story: - 'The same user after the role step wrote `title` and `experienceLevel`. With `skipWhenComplete` set on the profileForm step, the form has nothing left to ask and takes itself out of the funnel. Proposed behaviour, simulated in Storybook (`accountDetailsProposal.tsx`); engineering builds it into `FunnelProfileForm`.', - }, - }, - }, - beforeEach: () => - bootAsUser({ - ...oauthUser, - title: 'Developer', - experienceLevel: 'MORE_THAN_4_YEARS', - }), - render: ({ chrome }: StepArgs) => { - const step = { - ...baseStep, - id: 'profile-form-skipped', - type: FunnelStepType.ProfileForm, - parameters: { - headline: 'Tell us a bit about yourself', - skipWhenComplete: true, - }, + id: 'user-role-answered', + type: FunnelStepType.UserRole, + parameters: { headline: 'Who are you?' }, }; return ( - + {(onRegisterStepToSkip) => ( - - + @@ -475,7 +325,6 @@ const useThemeClass = (): 'dark' | 'light' => { const MATRIX_STORIES = [ { id: 'hear-about-us', label: 'How did you hear about us' }, { id: 'who-are-you', label: 'Who are you' }, - { id: 'account-details-today', label: 'Account details — today' }, ]; const VIEWPORTS = [ @@ -490,7 +339,7 @@ export const ResponsiveMatrix: Story = { docs: { description: { story: - 'The three steps at three widths. They size themselves from the 440px funnel rail, the same as every other onboarding step, so the content column is identical on all three and only the space around it changes.', + 'The two steps at three widths. They size themselves from the 440px funnel rail, the same as every other onboarding step, so the content column is identical on all three and only the space around it changes.', }, }, }, @@ -502,8 +351,8 @@ export const ResponsiveMatrix: Story = { 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 + // Six 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". // Staggering means the first frame warms the optimizer and the rest are @@ -608,7 +457,7 @@ export const Playground: Story = { docs: { description: { story: - "The real funnel stepper running the new steps in a device frame. Pick who is signing up, the step order, and whether account details drops itself, then click through the frame. The side panel shows each step's status, what landed on the profile, and every Freyja transition and API write as it happens.", + "The real funnel stepper running the two steps in a device frame. Pick who is signing up and the step order, then click through the frame. The side panel shows each step's status, what landed on the profile, and every Freyja transition and API write as it happens.", }, }, }, @@ -628,13 +477,11 @@ export const PlaygroundFrame: Story = { control: { type: 'inline-radio' }, options: ['acquisitionFirst', 'roleFirst'], }, - skipWhenComplete: { control: 'boolean' }, acquisitionSkip: { control: 'boolean' }, }, args: { - scenario: 'github', + scenario: 'newSignup', order: 'acquisitionFirst', - skipWhenComplete: true, acquisitionSkip: false, }, parameters: { diff --git a/packages/storybook/stories/components/onboarding/accountDetailsProposal.tsx b/packages/storybook/stories/components/onboarding/accountDetailsProposal.tsx deleted file mode 100644 index 2ef1c8fab32..00000000000 --- a/packages/storybook/stories/components/onboarding/accountDetailsProposal.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useEffect, useRef } from 'react'; -import { FunnelProfileForm } from '@dailydotdev/shared/src/features/onboarding/steps/FunnelProfileForm'; -import type { FunnelStepProfileForm } from '@dailydotdev/shared/src/features/onboarding/types/funnel'; -import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; - -/** - * Account details the way the identity steps need it, built around the - * production step so the shared funnel code stays as it is. Engineering ports - * both behaviours into `FunnelProfileForm` / `RegistrationFieldsForm`: - * - * - the experience dropdown is gone when the level is already on file, because - * the user-role step asked it (validation passes on the stored value) - * - with `skipWhenComplete`, the step drops out when it has nothing left to ask - * - * Both are decided when the step is shown, so the user's own answers on the - * form can't pull the dropdown or the whole step away mid-submit. - */ - -export type ProposedProfileFormStep = FunnelStepProfileForm & { - parameters: FunnelStepProfileForm['parameters'] & { - skipWhenComplete?: boolean; - }; -}; - -const SCOPE = 'proposed-account-details'; -const HIDE_EXPERIENCE_DROPDOWN = `.${SCOPE} div:has(> input[name="experienceLevel"]) { display: none; }`; - -export const useDecidedOnArrival = ( - isActive: boolean | undefined, - value: boolean, -): boolean => { - const decisionRef = useRef(); - - if (isActive && decisionRef.current === undefined) { - decisionRef.current = value; - } - - return decisionRef.current ?? value; -}; - -export const ProposedFunnelProfileForm = ( - props: ProposedProfileFormStep, -): ReactElement | null => { - const { isActive, type, onRegisterStepToSkip, parameters } = props; - const { user } = useAuthContext(); - const callbackRef = useRef(onRegisterStepToSkip); - callbackRef.current = onRegisterStepToSkip; - const isComplete = - !!user?.email && - !!user?.name && - !!user?.username && - !!user?.experienceLevel; - const shouldSkip = useDecidedOnArrival( - isActive, - !!parameters.skipWhenComplete && isComplete, - ); - const hidesExperience = useDecidedOnArrival( - isActive, - !!user?.experienceLevel, - ); - - useEffect(() => { - callbackRef.current?.(type, shouldSkip); - }, [shouldSkip, type]); - - if (shouldSkip) { - return null; - } - - return ( -
- {hidesExperience && } - -
- ); -}; diff --git a/packages/storybook/stories/components/onboarding/identityPlayground.tsx b/packages/storybook/stories/components/onboarding/identityPlayground.tsx index bcd47b72c39..95d347da69a 100644 --- a/packages/storybook/stories/components/onboarding/identityPlayground.tsx +++ b/packages/storybook/stories/components/onboarding/identityPlayground.tsx @@ -33,11 +33,11 @@ import { import { defaultBootData } from '../../../mock/boot'; import ExtensionProviders from '../../extension/_providers'; import { ChromeArm, ThemeModeSync } from './signupFunnel.mocks'; -import { ProposedFunnelProfileForm } from './accountDetailsProposal'; /** - * The identity-steps playground: the real `FunnelStepper` running the proposed - * steps in a device frame, with the controls and the live readout around it. + * The identity-steps playground: the real `FunnelStepper` running the + * acquisition and user-role steps in a device frame, with the controls and + * the live readout around it. * * The frame is a separate story rendered in an iframe, so the steps respond to * the frame's width (the `tablet:`/`laptop:` breakpoints read the viewport, not @@ -46,9 +46,8 @@ import { ProposedFunnelProfileForm } from './accountDetailsProposal'; * - every Freyja transition, by intercepting the POST the stepper makes * - every profile write, by intercepting the GraphQL mutations * - * Two behaviours are proposals simulated here rather than shipped in shared - * code: account details dropping itself (`accountDetailsProposal.tsx`), and - * moving past a step that has nothing to ask on arrival (`withGuardReport`). + * One behaviour is simulated here rather than shipped in shared code: moving + * past a step that has nothing to ask on arrival (`withGuardReport`). */ const MESSAGE_SOURCE = 'identity-steps-playground'; @@ -92,15 +91,6 @@ export const PLAYGROUND_HANDLERS = [ }, }); }), - graphql.query('GenerateUniqueUsername', ({ variables }) => - HttpResponse.json({ - data: { - generateUniqueUsername: String(variables.name ?? 'dev') - .toLowerCase() - .replace(/[^a-z0-9]+/g, ''), - }, - }), - ), http.post('*/freyja/sessions/:sessionId/transition', async ({ request }) => { const payload = (await request.json()) as Omit< Extract, @@ -111,26 +101,16 @@ export const PLAYGROUND_HANDLERS = [ }), ]; -type ProfileFields = Pick< - LoggedUser, - 'username' | 'title' | 'experienceLevel' | 'acquisitionChannel' ->; +type ProfileFields = Pick; export const PLAYGROUND_SCENARIOS = { - github: { - label: 'New GitHub signup', - description: 'Name, email and username came from GitHub. Nothing else.', + newSignup: { + label: 'New signup', + description: 'Just finished account details. No title or channel yet.', expected: - 'Both questions, then account details drops itself: it has nothing left to ask.', + 'Both questions, in the chosen order. Something else writes no title.', profile: {} as Partial, }, - noUsername: { - label: 'New signup, no username yet', - description: 'Name and email only, so account details still has a job.', - expected: - 'Both questions, then account details appears with no experience dropdown.', - profile: { username: undefined } as Partial, - }, heardBefore: { label: 'Already told us how they found us', description: 'Answered the feed card before, so acquisitionChannel is set.', @@ -140,14 +120,19 @@ export const PLAYGROUND_SCENARIOS = { acquisitionChannel: AcquisitionChannel.Friend, } as Partial, }, + hasTitle: { + label: 'Already has a job title', + description: 'Filled title in the signup extra fields, so title is set.', + expected: '"Who are you" skips itself, even as the very first step.', + profile: { title: 'Staff engineer' } as Partial, + }, returning: { - label: 'Profile already complete', - description: 'Every answer is already on file.', - expected: 'Every step skips; the funnel completes on arrival.', + label: 'Both answers on file', + description: 'title and acquisitionChannel are already set.', + expected: 'Both steps skip; the funnel completes on arrival.', profile: { acquisitionChannel: AcquisitionChannel.Friend, - title: 'Developer', - experienceLevel: 'MORE_THAN_4_YEARS', + title: 'Software engineer', } as Partial, }, } as const; @@ -159,7 +144,6 @@ export const getScenarioUser = ( scenario: PlaygroundScenario, ): Partial => ({ title: undefined, - experienceLevel: undefined, acquisitionChannel: undefined, ...PLAYGROUND_SCENARIOS[scenario].profile, }); @@ -167,20 +151,17 @@ export const getScenarioUser = ( export interface PlaygroundSettings { scenario: PlaygroundScenario; order: PlaygroundOrder; - skipWhenComplete: boolean; acquisitionSkip: boolean; chrome: OnboardingChromeVariant; } const STEP_LABELS: Record = { acquisition: 'How did you hear about us', - 'user-role': 'Who are you + experience', - 'account-details': 'Account details', + 'user-role': 'Who are you', }; const buildFunnel = ({ order, - skipWhenComplete, acquisitionSkip, }: PlaygroundSettings): FunnelJSON => { const next = [ @@ -201,19 +182,12 @@ const buildFunnel = ({ type: FunnelStepType.UserRole, parameters: { headline: 'Who are you?', - explainer: 'So your feed starts from the right place.', }, transitions: next, }; - const accountDetails = { - id: 'account-details', - type: FunnelStepType.ProfileForm, - parameters: { headline: 'Tell us a bit about yourself', skipWhenComplete }, - transitions: next, - }; const steps = (order === 'roleFirst' - ? [role, acquisition, accountDetails] - : [acquisition, role, accountDetails]) as unknown as FunnelStep[]; + ? [role, acquisition] + : [acquisition, role]) as unknown as FunnelStep[]; return { id: 'identity-steps-playground', @@ -270,7 +244,6 @@ const withGuardReport = ( const STEP_OVERRIDES = { [FunnelStepType.Acquisition]: withGuardReport(FunnelAcquisition), [FunnelStepType.UserRole]: withGuardReport(FunnelUserRole), - [FunnelStepType.ProfileForm]: withGuardReport(ProposedFunnelProfileForm), }; const PositionReport = ({ funnel }: { funnel: FunnelJSON }): null => { @@ -615,8 +588,6 @@ const toArgs = (settings: PlaygroundSettings): string => const PROFILE_FIELDS: Array = [ 'acquisitionChannel', 'title', - 'experienceLevel', - 'username', ]; export const PlaygroundPanel = ({ @@ -628,10 +599,9 @@ export const PlaygroundPanel = ({ const [theme, setTheme] = useState<'dark' | 'light'>(); const [device, setDevice] = useState('mobile'); const [settings, setSettings] = useState({ - scenario: 'github', + scenario: 'newSignup', order: 'acquisitionFirst', - skipWhenComplete: true, - acquisitionSkip: false, + acquisitionSkip: true, chrome: OnboardingChromeVariant.Control, }); const [runId, setRunId] = useState(0); @@ -642,10 +612,9 @@ export const PlaygroundPanel = ({ const frame = DEVICES[device]; const stepIds = getFunnelStepIds(settings.order); const scenario = PLAYGROUND_SCENARIOS[settings.scenario]; - const startingProfile = { - username: 'ido', - ...getScenarioUser(settings.scenario), - } as Partial; + const startingProfile = getScenarioUser( + settings.scenario, + ) as Partial; const runKey = `${toArgs(settings)}|${activeTheme}|${device}|${runId}`; const update = ( @@ -690,7 +659,7 @@ export const PlaygroundPanel = ({

Identity steps · playground

- The real funnel stepper running the new steps. Pick who is signing + The real funnel stepper running the two steps. Pick who is signing up, click through the frame, and watch what the funnel decides and what it writes.

@@ -711,7 +680,10 @@ export const PlaygroundPanel = ({
{( Object.entries(PLAYGROUND_SCENARIOS) as Array< - [PlaygroundScenario, (typeof PLAYGROUND_SCENARIOS)['github']] + [ + PlaygroundScenario, + (typeof PLAYGROUND_SCENARIOS)['newSignup'], + ] > ).map(([key, option]) => { const isSelected = key === settings.scenario; @@ -750,17 +722,6 @@ export const PlaygroundPanel = ({ /> -
- update('skipWhenComplete', value === 'on')} - options={[ - { value: 'on', label: 'Drop when complete' }, - { value: 'off', label: 'Always show (today)' }, - ]} - value={settings.skipWhenComplete ? 'on' : 'off'} - /> -
-
update('acquisitionSkip', value === 'on')} From f3e1c7b1423fd24003c8960a4958f437f9b5cfe8 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:28:06 +0300 Subject: [PATCH 2/2] fix(onboarding): clearer store label and Other icon in the acquisition step --- .../onboarding/steps/FunnelAcquisition.tsx | 18 +++++++----------- .../OnboardingIdentitySteps.stories.tsx | 2 +- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/features/onboarding/steps/FunnelAcquisition.tsx b/packages/shared/src/features/onboarding/steps/FunnelAcquisition.tsx index d23b7f1eca3..d72c11c67df 100644 --- a/packages/shared/src/features/onboarding/steps/FunnelAcquisition.tsx +++ b/packages/shared/src/features/onboarding/steps/FunnelAcquisition.tsx @@ -30,13 +30,13 @@ import { shuffleArray } from '../../../lib/func'; import { acquisitionBrandColors } from '../../../styles/custom'; import type { IconProps } from '../../../components/Icon'; import { IconSize } from '../../../components/Icon'; -import { AppIcon } from '../../../components/icons/App'; +import { ChromeIcon } from '../../../components/icons/Browser/Chrome'; +import { CompassIcon } from '../../../components/icons/Compass'; import { GitHubIcon } from '../../../components/icons/GitHub'; import { GoogleIcon } from '../../../components/icons/Google'; import { InviteIcon } from '../../../components/icons/Invite'; import { LinkedInIcon } from '../../../components/icons/LinkedIn'; import { MailIcon } from '../../../components/icons/Mail'; -import { MenuIcon } from '../../../components/icons/Menu'; import { OpenAIIcon } from '../../../components/icons/OpenAI'; import { RedditIcon } from '../../../components/icons/Reddit'; import { TwitterIcon } from '../../../components/icons/Twitter'; @@ -95,7 +95,7 @@ const brandMark = ( ), }); -// Google keeps its colour art on white, as its own favicon does. +// Google and Chrome keep their colour art on white, as their own favicons do. const faviconMark = (BrandIcon: Icon): ChannelMark => ({ logo: , tile: ( @@ -181,20 +181,16 @@ const CHANNEL_OPTIONS: Array< }, { value: AcquisitionChannel.AppStore, - label: 'App or extension store', - ...accentMark( - AppIcon, - '--theme-accent-avocado-default', - 'text-accent-avocado-default', - ), + label: 'Chrome Web Store or app store', + ...faviconMark(ChromeIcon), }, { value: AcquisitionChannel.Other, label: 'Other', - logo: , + logo: , tile: ( - + ), }, diff --git a/packages/storybook/stories/components/onboarding/OnboardingIdentitySteps.stories.tsx b/packages/storybook/stories/components/onboarding/OnboardingIdentitySteps.stories.tsx index 23f4cb43c2b..325c76e39c2 100644 --- a/packages/storybook/stories/components/onboarding/OnboardingIdentitySteps.stories.tsx +++ b/packages/storybook/stories/components/onboarding/OnboardingIdentitySteps.stories.tsx @@ -181,7 +181,7 @@ export const HearAboutUsTiles: Story = { docs: { description: { story: - 'Every mark in the same favicon-style rounded square: the brand colour behind a white glyph, with Google on white the way its own favicon is. Set by `iconStyle: "tile"`; the other stories switch to it from the controls.', + 'Every mark in the same favicon-style rounded square: the brand colour behind a white glyph, with Google and Chrome on white the way their own favicons are. Set by `iconStyle: "tile"`; the other stories switch to it from the controls.', }, }, },