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:
@@ -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');
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user