Add install your first apps onboarding V2 step (#22347)

https://github.com/user-attachments/assets/5326d48f-1842-4db1-bc7c-94852145c035


<img width="838" height="754" alt="CleanShot 2026-06-30 at 16 25 05@2x"
src="https://github.com/user-attachments/assets/5c7d53d7-4d65-4e35-aed1-edf0c104e140"
/>


Adds an "Install your first apps" step to the V2 onboarding, shown right
after import-contacts. It lets users opt into installing marketplace
apps (Call recorder and People Data Labs for now) during onboarding.

- New backend `OnboardingStatus.APPS_INSTALLATION` (between SYNC_EMAIL
and PROFILE_CREATION); V1 auto-skips it.
- The primary button sends the selected app ids to the server via
`triggerInstallAppsOnboardingStep`, which enqueues a dedicated job that
installs them asynchronously so onboarding isn't blocked. Skip continues
without installing.
- The workspace is credited per app on successful installation. Credits
are env-driven via `ONBOARDING_INSTALL_APPS_CREDITS_REWARD_PER_APP`,
shown as "Earn +N free credits (1 per tool)".

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22347?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-07-01 14:25:16 +02:00
committed by GitHub
parent 93b8f66794
commit 2e6077383b
45 changed files with 1336 additions and 16 deletions
@@ -0,0 +1,234 @@
import { OnboardingCreditsRewardTag } from '@/onboarding/components/import-contacts/OnboardingCreditsRewardTag';
import { type OnboardingInstallableApp } from '@/onboarding/types/OnboardingInstallableApp';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/data-display';
import { IconCheck, IconPlus } from 'twenty-ui/icon';
import { IconButton, MainButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
const CONTENT_BLOCK_WIDTH = 340;
const StyledContent = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.secondary};
box-sizing: border-box;
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: ${themeCssVariables.spacing[14]};
min-height: 0;
overflow-y: auto;
padding: ${themeCssVariables.spacing[16]} ${themeCssVariables.spacing[8]};
width: 100%;
`;
const StyledHeading = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
width: ${CONTENT_BLOCK_WIDTH}px;
`;
const StyledTitleRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledTitle = styled.h1`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.xl};
font-weight: ${themeCssVariables.font.weight.semiBold};
margin: 0;
`;
const StyledBetaTag = styled.span`
align-items: center;
background-color: ${themeCssVariables.background.tertiary};
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.pill};
box-sizing: border-box;
color: ${themeCssVariables.font.color.tertiary};
display: flex;
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.medium};
height: ${themeCssVariables.spacing[6]};
padding: 0 ${themeCssVariables.spacing[2]};
`;
const StyledSubtitle = styled.p`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.md};
margin: 0;
`;
const StyledCreditsRow = styled.div`
display: flex;
padding-top: ${themeCssVariables.spacing[1]};
`;
const StyledCard = styled.div`
background-color: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
display: flex;
flex-direction: column;
width: ${CONTENT_BLOCK_WIDTH}px;
`;
const StyledAppRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[3]};
padding: ${themeCssVariables.spacing[4]};
& + & {
border-top: 1px solid ${themeCssVariables.border.color.light};
}
`;
const StyledAppText = styled.div`
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: ${themeCssVariables.spacing[1]};
min-width: 0;
`;
const StyledAppLabel = styled.span`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledAppDescription = styled.span`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.sm};
`;
const StyledFooter = styled.div`
align-items: flex-end;
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
width: ${CONTENT_BLOCK_WIDTH}px;
`;
const StyledInstallButton = styled.div`
width: 100%;
`;
const StyledSkipButton = styled.button`
background-color: transparent;
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.md};
color: ${themeCssVariables.font.color.tertiary};
cursor: pointer;
font-family: ${themeCssVariables.font.family};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.semiBold};
height: ${themeCssVariables.spacing[8]};
padding: 0 ${themeCssVariables.spacing[5]};
`;
type InstallAppsProps = {
apps: (OnboardingInstallableApp & { logo: string | null })[];
selectedUniversalIdentifiers: string[];
creditsRewardPerApp?: number;
isCompleting: boolean;
onToggleApp: (universalIdentifier: string) => void;
onInstall: () => void;
onSkip: () => void;
};
export const InstallApps = ({
apps,
selectedUniversalIdentifiers,
creditsRewardPerApp,
isCompleting,
onToggleApp,
onInstall,
onSkip,
}: InstallAppsProps) => {
const { t } = useLingui();
return (
<StyledContent>
<StyledHeading>
<StyledTitleRow>
<StyledTitle>{t`Install your first apps`}</StyledTitle>
<StyledBetaTag>{t`Beta`}</StyledBetaTag>
</StyledTitleRow>
<StyledSubtitle>
{t`Get the most out of your CRM by installing some apps`}
</StyledSubtitle>
{isDefined(creditsRewardPerApp) && (
<StyledCreditsRow>
<OnboardingCreditsRewardTag
amount={creditsRewardPerApp * apps.length}
suffix={t`free credits (${creditsRewardPerApp} per tool)`}
/>
</StyledCreditsRow>
)}
</StyledHeading>
<StyledCard>
{apps.map((app) => {
const labelText = t(app.label);
const isSelected = selectedUniversalIdentifiers.includes(
app.universalIdentifier,
);
return (
<StyledAppRow key={app.universalIdentifier}>
<Avatar
avatarUrl={getAbsoluteImageUrl(app.logo)}
placeholder={labelText}
placeholderColorSeed={app.universalIdentifier}
size="md"
type="squared"
/>
<StyledAppText>
<StyledAppLabel>{labelText}</StyledAppLabel>
<StyledAppDescription>
{t(app.description)}
</StyledAppDescription>
</StyledAppText>
<IconButton
Icon={isSelected ? IconCheck : IconPlus}
variant="secondary"
accent={isSelected ? 'blue' : 'default'}
ariaLabel={
isSelected ? t`Deselect ${labelText}` : t`Select ${labelText}`
}
onClick={() => onToggleApp(app.universalIdentifier)}
/>
</StyledAppRow>
);
})}
</StyledCard>
<StyledFooter>
<StyledInstallButton>
<MainButton
title={t`Install`}
onClick={onInstall}
disabled={isCompleting}
fullWidth
/>
</StyledInstallButton>
<StyledSkipButton
type="button"
onClick={onSkip}
disabled={isCompleting}
>
{t`Skip`}
</StyledSkipButton>
</StyledFooter>
</StyledContent>
);
};
@@ -0,0 +1,56 @@
import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State';
import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
import { useMarketplaceApps } from '@/marketplace/hooks/useMarketplaceApps';
import { OnboardingV2Layout } from '@/onboarding/components/OnboardingV2Layout';
import { ONBOARDING_INSTALLABLE_APPS } from '@/onboarding/constants/OnboardingInstallableApps';
import { InstallAppsAutoSkipEffect } from '@/onboarding/effect-components/InstallAppsAutoSkipEffect';
import { useInstallOnboardingApps } from '@/onboarding/hooks/useInstallOnboardingApps';
import { useOnboardingFreeCreditsTotal } from '@/onboarding/hooks/useOnboardingFreeCreditsTotal';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useCallback, useState } from 'react';
import { InstallApps } from '~/pages/onboarding/InstallApps';
export const InstallAppsV2 = () => {
const isOnboardingV2 = useAtomStateValue(isOnboardingV2State);
const { data: marketplaceApps } = useMarketplaceApps();
const onboardingConfig = useAtomStateValue(onboardingConfigState);
const freeCreditsTotal = useOnboardingFreeCreditsTotal();
const [hasAutoSkipFailed, setHasAutoSkipFailed] = useState(false);
const {
selectedUniversalIdentifiers,
isCompleting,
toggleApp,
installSelectedAppsAndContinue,
skip,
} = useInstallOnboardingApps();
const handleAutoSkipError = useCallback(() => {
setHasAutoSkipFailed(true);
}, []);
if (!isOnboardingV2 && !hasAutoSkipFailed) {
return <InstallAppsAutoSkipEffect onError={handleAutoSkipError} />;
}
const apps = ONBOARDING_INSTALLABLE_APPS.map((app) => ({
...app,
logo:
marketplaceApps.find(
(marketplaceApp) => marketplaceApp.id === app.universalIdentifier,
)?.logo ?? null,
}));
return (
<OnboardingV2Layout freeCredits={freeCreditsTotal}>
<InstallApps
apps={apps}
selectedUniversalIdentifiers={selectedUniversalIdentifiers}
creditsRewardPerApp={onboardingConfig?.installAppsCreditsRewardPerApp}
isCompleting={isCompleting}
onToggleApp={toggleApp}
onInstall={installSelectedAppsAndContinue}
onSkip={skip}
/>
</OnboardingV2Layout>
);
};
@@ -94,7 +94,11 @@ export const WorkspaceActivationV2 = () => {
}
hasTriggeredRef.current = true;
setOnboardingFreeCredits({ importContacts: 0, inviteTeam: 0 });
setOnboardingFreeCredits({
importContacts: 0,
inviteTeam: 0,
installApps: 0,
});
void activate();
}, [activate, currentWorkspace, setOnboardingFreeCredits]);
@@ -0,0 +1,67 @@
import { getOperationName } from '~/utils/getOperationName';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { HttpResponse, graphql } from 'msw';
import { within } from 'storybook/test';
import { AppPath } from 'twenty-shared/types';
import { isOnboardingV2State } from '@/auth/states/isOnboardingV2State';
import { FIND_MANY_MARKETPLACE_APPS } from '@/marketplace/graphql/queries/findManyMarketplaceApps';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { OnboardingStatus } from '~/generated-metadata/graphql';
import { GET_CURRENT_USER } from '~/modules/users/graphql/queries/getCurrentUser';
import { InstallAppsV2 } from '~/pages/onboarding/InstallAppsV2';
import {
PageDecorator,
type PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedOnboardingUserData } from '~/testing/mock-data/users';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Onboarding/InstallAppsV2',
component: InstallAppsV2,
decorators: [
(Story) => {
jotaiStore.set(isOnboardingV2State.atom, true);
return <Story />;
},
PageDecorator,
],
args: { routePath: AppPath.InstallAppsV2 },
parameters: {
msw: {
handlers: [
graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => {
return HttpResponse.json({
data: {
currentUser: mockedOnboardingUserData(
OnboardingStatus.APPS_INSTALLATION,
),
},
});
}),
graphql.query(
getOperationName(FIND_MANY_MARKETPLACE_APPS) ?? '',
() => {
return HttpResponse.json({
data: { findManyMarketplaceApps: [] },
});
},
),
graphqlMocks.handlers,
],
},
},
};
export default meta;
export type Story = StoryObj<typeof InstallAppsV2>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement.ownerDocument.body);
await canvas.findByText('Install your first apps');
},
};