Add v2 onboarding import contacts page and unify the onboarding v2 shell (#22212)

<img width="3024" height="1668" alt="CleanShot 2026-06-26 at 13 29
28@2x"
src="https://github.com/user-attachments/assets/9bdd0029-45eb-4ddb-859f-eaab9bb61406"
/>

Adds the new v2 onboarding **Import contacts** step (email + calendar
import), shown right after workspace creation in the v2 flow. The
presentational page was designed in a previous PR; this wires it in and
unifies the shell.

**What changed**
- Reuses and unifies the existing v2 onboarding shell: extracts
`OnboardingV2Layout` + `OnboardingV2Header` (the back + logo header, now
with the free-credits pill), and the `SignInUpV2` workspace-creation
step renders through it (old `SignInUpV2Header` removed).
- New `SyncEmailsV2` route (`/sync/emails-v2`) under `BlankLayout`,
wired to the same OAuth/skip hooks as v1 `SyncEmails`.
- The `SYNC_EMAIL` step routes to the new page only when
`isOnboardingV2` is set (mirrors the existing `WorkspaceActivation` →
`WorkspaceActivationV2` branch); the v1 modal is unchanged for the
non-v2 flow.
- No backend changes — reuses the `SYNC_EMAIL` status and
`skipSyncEmailOnboardingStep` mutation.

**Reviewer notes**
- Connect defaults to `METADATA` (private) visibility to match the "Only
you will be able to see your emails and events" note (v1 had a selector
defaulting to `SHARE_EVERYTHING`).
- The header free-credits pill shows `0` for now (no current-workspace
credits source on the frontend yet).
- The back button is hidden on the import page (no meaningful "back"
after workspace creation); unchanged on the workspace-creation step.
This commit is contained in:
Raphaël Bosi
2026-06-26 14:54:52 +02:00
committed by GitHub
parent 9747e3a7a3
commit 8f7d6c24dd
31 changed files with 1218 additions and 87 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -458,9 +458,12 @@ describe('usePageChangeEffectNavigateLocation — authenticated with no current
});
describe('usePageChangeEffectNavigateLocation — onboarding V2', () => {
const setupWorkspaceActivationV2Case = (loc: AppPath) => {
const setupOnboardingV2Case = (
loc: AppPath,
onboardingStatus: OnboardingStatus,
) => {
setupMockIsMatchingLocation(loc);
setupMockOnboardingStatus(OnboardingStatus.WORKSPACE_ACTIVATION);
setupMockOnboardingStatus(onboardingStatus);
setupMockIsWorkspaceActivationStatusEqualsTo(false);
setupMockHasAccessTokenPair(true);
setupMockIsOnAWorkspace(true);
@@ -477,7 +480,10 @@ describe('usePageChangeEffectNavigateLocation — onboarding V2', () => {
};
it('routes to WorkspaceActivationV2 when onboardingV2 is active and status is WORKSPACE_ACTIVATION', () => {
setupWorkspaceActivationV2Case(AppPath.SignInUpV2);
setupOnboardingV2Case(
AppPath.SignInUpV2,
OnboardingStatus.WORKSPACE_ACTIVATION,
);
expect(usePageChangeEffectNavigateLocation()).toEqual(
AppPath.WorkspaceActivationV2,
@@ -485,7 +491,22 @@ describe('usePageChangeEffectNavigateLocation — onboarding V2', () => {
});
it('does not redirect away from the WorkspaceActivationV2 page during activation', () => {
setupWorkspaceActivationV2Case(AppPath.WorkspaceActivationV2);
setupOnboardingV2Case(
AppPath.WorkspaceActivationV2,
OnboardingStatus.WORKSPACE_ACTIVATION,
);
expect(usePageChangeEffectNavigateLocation()).toBeUndefined();
});
it('routes to SyncEmailsV2 when onboardingV2 is active and status is SYNC_EMAIL', () => {
setupOnboardingV2Case(AppPath.Index, OnboardingStatus.SYNC_EMAIL);
expect(usePageChangeEffectNavigateLocation()).toEqual(AppPath.SyncEmailsV2);
});
it('does not redirect away from the SyncEmailsV2 page', () => {
setupOnboardingV2Case(AppPath.SyncEmailsV2, OnboardingStatus.SYNC_EMAIL);
expect(usePageChangeEffectNavigateLocation()).toBeUndefined();
});
@@ -140,9 +140,9 @@ export const usePageChangeEffectNavigateLocation = () => {
if (
onboardingStatus === OnboardingStatus.SYNC_EMAIL &&
!isMatchingLocation(location, AppPath.SyncEmails)
!someMatchingLocationOf([AppPath.SyncEmails, AppPath.SyncEmailsV2])
) {
return AppPath.SyncEmails;
return isOnboardingV2 ? AppPath.SyncEmailsV2 : AppPath.SyncEmails;
}
if (
@@ -78,6 +78,12 @@ const SyncEmails = lazy(() =>
})),
);
const SyncEmailsV2 = lazy(() =>
import('~/pages/onboarding/SyncEmailsV2').then((module) => ({
default: module.SyncEmailsV2,
})),
);
const InviteTeam = lazy(() =>
import('~/pages/onboarding/InviteTeam').then((module) => ({
default: module.InviteTeam,
@@ -285,6 +291,14 @@ export const useCreateAppRouter = (
</LazyRoute>
}
/>
<Route
path={AppPath.SyncEmailsV2}
element={
<LazyRoute fallback={null}>
<SyncEmailsV2 />
</LazyRoute>
}
/>
<Route
path={AppPath.Authorize}
element={
@@ -5,6 +5,7 @@ export const ONBOARDING_PATHS = [
AppPath.WorkspaceActivationV2,
AppPath.CreateProfile,
AppPath.SyncEmails,
AppPath.SyncEmailsV2,
AppPath.InviteTeam,
AppPath.PlanRequired,
AppPath.PlanRequiredSuccess,
@@ -1,70 +0,0 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { IconChevronLeft } from 'twenty-ui/icon';
import { LightIconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const HEADER_CENTER_WIDTH = 340;
const StyledHeader = styled.div`
align-items: center;
box-sizing: border-box;
display: flex;
justify-content: space-between;
padding: ${themeCssVariables.spacing[8]};
width: 100%;
`;
const StyledSide = styled.div`
align-items: center;
display: flex;
flex: 1 1 0;
min-width: 0;
`;
const StyledLeftSide = styled(StyledSide)`
justify-content: flex-end;
padding-right: ${themeCssVariables.spacing[1]};
`;
const StyledCenter = styled.div`
align-items: center;
display: flex;
flex: 0 1 ${HEADER_CENTER_WIDTH}px;
justify-content: flex-start;
min-width: 0;
`;
const StyledLogo = styled.div`
background-image: url('/images/integrations/twenty-logo.svg');
background-size: cover;
height: ${themeCssVariables.spacing[6]};
opacity: 0.4;
width: ${themeCssVariables.spacing[6]};
`;
type SignInUpV2HeaderProps = {
onBack: () => void;
};
export const SignInUpV2Header = ({ onBack }: SignInUpV2HeaderProps) => {
const { t } = useLingui();
return (
<StyledHeader>
<StyledLeftSide>
<LightIconButton
Icon={IconChevronLeft}
accent="tertiary"
size="medium"
onClick={onBack}
aria-label={t`Go back`}
/>
</StyledLeftSide>
<StyledCenter>
<StyledLogo />
</StyledCenter>
<StyledSide />
</StyledHeader>
);
};
@@ -0,0 +1,145 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronLeft, IconCoins, IconInfoCircle } from 'twenty-ui/icon';
import { LightIconButton } from 'twenty-ui/input';
import { themeCssVariables, useTheme } from 'twenty-ui/theme-constants';
const HEADER_CENTER_WIDTH = 340;
const StyledHeader = styled.div`
align-items: center;
box-sizing: border-box;
display: flex;
justify-content: space-between;
padding: ${themeCssVariables.spacing[8]};
width: 100%;
`;
const StyledSide = styled.div`
align-items: center;
display: flex;
flex: 1 1 0;
min-width: 0;
`;
const StyledLeftSide = styled(StyledSide)`
justify-content: flex-end;
padding-right: ${themeCssVariables.spacing[1]};
`;
const StyledCenter = styled.div`
align-items: center;
display: flex;
flex: 0 1 ${HEADER_CENTER_WIDTH}px;
justify-content: flex-start;
min-width: 0;
`;
const StyledRightSide = styled(StyledSide)`
justify-content: flex-end;
`;
const StyledLogo = styled.div`
background-image: url('/images/integrations/twenty-logo.svg');
background-size: cover;
height: ${themeCssVariables.spacing[6]};
opacity: 0.4;
width: ${themeCssVariables.spacing[6]};
`;
const StyledFreeCredits = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.tertiary};
display: flex;
`;
const StyledCreditsTag = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.tertiary};
border-bottom: 1px solid ${themeCssVariables.border.color.light};
border-bottom-left-radius: ${themeCssVariables.border.radius.pill};
border-left: 1px solid ${themeCssVariables.border.color.light};
border-top: 1px solid ${themeCssVariables.border.color.light};
border-top-left-radius: ${themeCssVariables.border.radius.pill};
box-sizing: border-box;
display: flex;
gap: ${themeCssVariables.spacing[1]};
height: ${themeCssVariables.spacing[6]};
padding: 0 ${themeCssVariables.spacing[2]} 0
${themeCssVariables.spacing['1.5']};
`;
const StyledCreditsCount = styled.span`
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledCreditsLabel = styled.span`
font-size: ${themeCssVariables.font.size.sm};
`;
const StyledInfoTag = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.tertiary};
border: 1px solid ${themeCssVariables.border.color.light};
border-bottom-right-radius: ${themeCssVariables.border.radius.rounded};
border-top-right-radius: ${themeCssVariables.border.radius.rounded};
box-sizing: border-box;
display: flex;
height: ${themeCssVariables.spacing[6]};
justify-content: center;
padding: 0 ${themeCssVariables.spacing['1.5']};
`;
type OnboardingV2HeaderProps = {
onBack?: () => void;
freeCredits?: number;
};
export const OnboardingV2Header = ({
onBack,
freeCredits,
}: OnboardingV2HeaderProps) => {
const { t } = useLingui();
const theme = useTheme();
return (
<StyledHeader>
<StyledLeftSide>
{isDefined(onBack) && (
<LightIconButton
Icon={IconChevronLeft}
accent="tertiary"
size="medium"
onClick={onBack}
aria-label={t`Go back`}
/>
)}
</StyledLeftSide>
<StyledCenter>
<StyledLogo />
</StyledCenter>
<StyledRightSide>
{isDefined(freeCredits) && (
<StyledFreeCredits>
<StyledCreditsTag>
<IconCoins
size={theme.icon.size.md}
color={themeCssVariables.font.color.tertiary}
/>
<StyledCreditsCount>{freeCredits}</StyledCreditsCount>
<StyledCreditsLabel>{t`free credits`}</StyledCreditsLabel>
</StyledCreditsTag>
<StyledInfoTag>
<IconInfoCircle
size={theme.icon.size.md}
color={themeCssVariables.font.color.tertiary}
/>
</StyledInfoTag>
</StyledFreeCredits>
)}
</StyledRightSide>
</StyledHeader>
);
};
@@ -0,0 +1,29 @@
import { OnboardingV2Header } from '@/onboarding/components/OnboardingV2Header';
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledBackground = styled.div`
background: ${themeCssVariables.background.secondary};
display: flex;
flex-direction: column;
height: 100dvh;
width: 100%;
`;
type OnboardingV2LayoutProps = {
children: ReactNode;
onBack?: () => void;
freeCredits?: number;
};
export const OnboardingV2Layout = ({
children,
onBack,
freeCredits,
}: OnboardingV2LayoutProps) => (
<StyledBackground>
<OnboardingV2Header onBack={onBack} freeCredits={freeCredits} />
{children}
</StyledBackground>
);
@@ -0,0 +1,50 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { IconCoins } from 'twenty-ui/icon';
import { themeCssVariables, useTheme } from 'twenty-ui/theme-constants';
const StyledTag = styled.div`
align-items: center;
background-color: ${themeCssVariables.color.green3};
border: 1px solid ${themeCssVariables.color.green4};
border-radius: ${themeCssVariables.border.radius.pill};
box-sizing: border-box;
color: ${themeCssVariables.color.green9};
display: flex;
gap: ${themeCssVariables.spacing[1]};
height: ${themeCssVariables.spacing[6]};
padding: 0 ${themeCssVariables.spacing[2]} 0
${themeCssVariables.spacing['1.5']};
`;
const StyledLabel = styled.span`
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledSuffix = styled.span`
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.regular};
`;
type OnboardingCreditsRewardTagProps = {
amount: number;
};
export const OnboardingCreditsRewardTag = ({
amount,
}: OnboardingCreditsRewardTagProps) => {
const { t } = useLingui();
const theme = useTheme();
return (
<StyledTag>
<IconCoins
size={theme.icon.size.md}
color={themeCssVariables.color.green9}
/>
<StyledLabel>{t`Earn +${amount}`}</StyledLabel>
<StyledSuffix>{t`free credits`}</StyledSuffix>
</StyledTag>
);
};
@@ -0,0 +1,43 @@
import { OnboardingImportPreviewCompanies } from '@/onboarding/components/import-contacts/OnboardingImportPreviewCompanies';
import { OnboardingImportPreviewEmails } from '@/onboarding/components/import-contacts/OnboardingImportPreviewEmails';
import { OnboardingImportPreviewSyncBadge } from '@/onboarding/components/import-contacts/OnboardingImportPreviewSyncBadge';
import { OnboardingImportPrivacyNote } from '@/onboarding/components/import-contacts/OnboardingImportPrivacyNote';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const PREVIEW_WIDTH = 340;
const PREVIEW_HEIGHT = 200;
const StyledCard = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.secondary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.xl};
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[3]};
overflow: hidden;
padding-bottom: ${themeCssVariables.spacing[3]};
width: ${PREVIEW_WIDTH}px;
`;
const StyledColumns = styled.div`
background-color: ${themeCssVariables.border.color.medium};
display: flex;
gap: 1px;
height: ${PREVIEW_HEIGHT}px;
position: relative;
width: 100%;
`;
export const OnboardingImportPreview = () => (
<StyledCard>
<StyledColumns>
<OnboardingImportPreviewEmails />
<OnboardingImportPreviewCompanies />
<OnboardingImportPreviewSyncBadge />
</StyledColumns>
<OnboardingImportPrivacyNote />
</StyledCard>
);
@@ -0,0 +1,98 @@
import { IMPORT_CONTACTS_PREVIEW_COMPANIES } from '@/onboarding/constants/ImportContactsPreviewCompanies';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { getLogoUrlFromDomainName } from 'twenty-shared/utils';
import {
Avatar,
Chip,
ChipAccent,
ChipSize,
ChipVariant,
} from 'twenty-ui/data-display';
import { IconBuildingSkyscraper, IconPlus } from 'twenty-ui/icon';
import { Checkbox } from 'twenty-ui/input';
import { themeCssVariables, useTheme } from 'twenty-ui/theme-constants';
const PREVIEW_ROW_HEIGHT = 32;
const StyledColumn = styled.div`
background-color: ${themeCssVariables.border.color.light};
box-sizing: border-box;
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: 1px;
height: 100%;
min-width: 0;
overflow: hidden;
`;
const StyledRow = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.primary};
box-sizing: border-box;
display: flex;
flex-shrink: 0;
gap: ${themeCssVariables.spacing[1]};
height: ${PREVIEW_ROW_HEIGHT}px;
padding: 0 ${themeCssVariables.spacing[1]};
`;
const StyledHeaderTitle = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.tertiary};
display: flex;
flex: 1 1 0;
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
gap: ${themeCssVariables.spacing[1]};
min-width: 0;
`;
export const OnboardingImportPreviewCompanies = () => {
const { t } = useLingui();
const theme = useTheme();
return (
<StyledColumn>
<StyledRow>
<Checkbox checked={false} hoverable />
<StyledHeaderTitle>
<IconBuildingSkyscraper
size={theme.icon.size.md}
color={themeCssVariables.font.color.tertiary}
/>
{t`Companies`}
</StyledHeaderTitle>
<IconPlus
size={theme.icon.size.md}
color={themeCssVariables.font.color.tertiary}
/>
</StyledRow>
{IMPORT_CONTACTS_PREVIEW_COMPANIES.map((company) => (
<StyledRow key={company.id}>
<Checkbox checked={false} hoverable />
<Chip
label={company.name}
size={ChipSize.Small}
variant={ChipVariant.Transparent}
accent={ChipAccent.TextPrimary}
clickable={false}
leftComponent={
<Avatar
type="squared"
size="md"
placeholder={company.name}
placeholderColorSeed={company.id}
avatarUrl={getAbsoluteImageUrl(
getLogoUrlFromDomainName(company.domainName),
)}
/>
}
/>
</StyledRow>
))}
</StyledColumn>
);
};
@@ -0,0 +1,162 @@
import {
type ImportContactsPreviewCalendarEvent,
IMPORT_CONTACTS_PREVIEW_CALENDAR_EVENTS,
} from '@/onboarding/constants/ImportContactsPreviewCalendarEvents';
import {
type ImportContactsPreviewEmail,
IMPORT_CONTACTS_PREVIEW_EMAILS,
} from '@/onboarding/constants/ImportContactsPreviewEmails';
import { styled } from '@linaria/react';
import { IconStar } from 'twenty-ui/icon';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const EMAIL_ROW_HEIGHT = 32;
const EMAIL_CHECKBOX_SIZE = 12;
const EMAIL_STAR_SIZE = 11;
const StyledColumn = styled.div`
background-color: ${themeCssVariables.border.color.light};
box-sizing: border-box;
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: 1px;
height: 100%;
min-width: 0;
overflow: hidden;
position: relative;
`;
const StyledEmailRow = styled.div<{ isUnread: boolean }>`
align-items: center;
background-color: ${themeCssVariables.background.primary};
box-sizing: border-box;
color: ${({ isUnread }) =>
isUnread
? themeCssVariables.font.color.primary
: themeCssVariables.font.color.tertiary};
display: flex;
flex-shrink: 0;
font-size: ${themeCssVariables.font.size.xs};
gap: ${themeCssVariables.spacing[2]};
height: ${EMAIL_ROW_HEIGHT}px;
padding: 0 ${themeCssVariables.spacing[3]};
white-space: nowrap;
`;
const StyledEmailCheckbox = styled.div`
border: 1px solid ${themeCssVariables.font.color.light};
border-radius: ${themeCssVariables.border.radius.xs};
box-sizing: border-box;
flex-shrink: 0;
height: ${EMAIL_CHECKBOX_SIZE}px;
width: ${EMAIL_CHECKBOX_SIZE}px;
`;
const StyledEmailStar = styled.div`
align-items: center;
display: flex;
flex-shrink: 0;
`;
const StyledEmailSender = styled.span<{ isUnread: boolean }>`
font-weight: ${({ isUnread }) =>
isUnread
? themeCssVariables.font.weight.medium
: themeCssVariables.font.weight.regular};
`;
const StyledEmailSubject = styled.span`
color: ${themeCssVariables.font.color.tertiary};
`;
const StyledEventCard = styled.div<{ color: 'yellow' | 'blue' }>`
background-color: ${({ color }) =>
color === 'yellow'
? themeCssVariables.color.yellow3
: themeCssVariables.color.blue3};
border-left: 2px solid
${({ color }) =>
color === 'yellow'
? themeCssVariables.color.yellow8
: themeCssVariables.color.blue8};
border-radius: ${themeCssVariables.border.radius.sm};
box-shadow: ${themeCssVariables.boxShadow.light};
box-sizing: border-box;
color: ${({ color }) =>
color === 'yellow'
? themeCssVariables.color.yellow11
: themeCssVariables.color.blue11};
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[1]};
padding: ${themeCssVariables.spacing[2]};
position: absolute;
width: 160px;
`;
const StyledEventTitle = styled.span`
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.semiBold};
`;
const StyledEventTime = styled.span`
font-size: ${themeCssVariables.font.size.xs};
`;
const EVENT_CARD_POSITIONS: Record<
ImportContactsPreviewCalendarEvent['color'],
{ top: number; left: number; rotate: number }
> = {
blue: { top: -6, left: 41, rotate: 10 },
yellow: { top: 150, left: 20, rotate: -7 },
};
const EmailRow = ({ email }: { email: ImportContactsPreviewEmail }) => (
<StyledEmailRow isUnread={email.isUnread}>
<StyledEmailCheckbox />
<StyledEmailStar>
<IconStar
size={EMAIL_STAR_SIZE}
color={themeCssVariables.font.color.light}
/>
</StyledEmailStar>
<StyledEmailSender isUnread={email.isUnread}>
{email.sender}
</StyledEmailSender>
<StyledEmailSubject>{email.subject}</StyledEmailSubject>
</StyledEmailRow>
);
const EventCard = ({
event,
}: {
event: ImportContactsPreviewCalendarEvent;
}) => {
const position = EVENT_CARD_POSITIONS[event.color];
return (
<StyledEventCard
color={event.color}
style={{
top: position.top,
left: position.left,
transform: `rotate(${position.rotate}deg)`,
}}
>
<StyledEventTitle>{event.title}</StyledEventTitle>
<StyledEventTime>{event.time}</StyledEventTime>
</StyledEventCard>
);
};
export const OnboardingImportPreviewEmails = () => (
<StyledColumn>
{IMPORT_CONTACTS_PREVIEW_EMAILS.map((email) => (
<EmailRow key={email.id} email={email} />
))}
{IMPORT_CONTACTS_PREVIEW_CALENDAR_EVENTS.map((event) => (
<EventCard key={event.id} event={event} />
))}
</StyledColumn>
);
@@ -0,0 +1,51 @@
import { styled } from '@linaria/react';
import { IconArrowRight, IconGoogle, IconMicrosoft } from 'twenty-ui/icon';
import { themeCssVariables, useTheme } from 'twenty-ui/theme-constants';
const SYNC_BADGE_LOGO_SIZE = 16;
const StyledBadge = styled.div`
align-items: center;
backdrop-filter: blur(20px);
background-color: ${themeCssVariables.background.transparent.secondary};
border: 1px solid ${themeCssVariables.background.transparent.lighter};
border-radius: 0 ${themeCssVariables.border.radius.md}
${themeCssVariables.border.radius.md} 0;
box-shadow: ${themeCssVariables.boxShadow.strong};
box-sizing: border-box;
display: flex;
gap: ${themeCssVariables.spacing[2]};
left: 50%;
padding: ${themeCssVariables.spacing[2]};
position: absolute;
top: 84px;
transform: translateX(-50%);
`;
const StyledDivider = styled.div`
align-self: stretch;
background-color: ${themeCssVariables.border.color.medium};
width: 1px;
`;
const StyledTwentyLogo = styled.img`
height: ${SYNC_BADGE_LOGO_SIZE}px;
width: ${SYNC_BADGE_LOGO_SIZE}px;
`;
export const OnboardingImportPreviewSyncBadge = () => {
const theme = useTheme();
return (
<StyledBadge>
<IconGoogle size={theme.icon.size.md} />
<IconMicrosoft size={theme.icon.size.md} />
<StyledDivider />
<IconArrowRight
size={theme.icon.size.md}
color={themeCssVariables.font.color.tertiary}
/>
<StyledTwentyLogo src="/images/integrations/twenty-logo.svg" alt="" />
</StyledBadge>
);
};
@@ -0,0 +1,28 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { IconLock } from 'twenty-ui/icon';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const PRIVACY_NOTE_ICON_SIZE = 12;
const StyledNote = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.tertiary};
display: flex;
font-size: ${themeCssVariables.font.size.sm};
gap: ${themeCssVariables.spacing[1]};
`;
export const OnboardingImportPrivacyNote = () => {
const { t } = useLingui();
return (
<StyledNote>
<IconLock
size={PRIVACY_NOTE_ICON_SIZE}
color={themeCssVariables.font.color.tertiary}
/>
{t`Only you will be able to see your emails and events`}
</StyledNote>
);
};
@@ -0,0 +1,93 @@
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const BADGE_ASSET_PATH = '/images/onboarding/trust-badges';
const TRUSTED_BY_LOGOS = [
{ name: 'Bayer', src: `${BADGE_ASSET_PATH}/bayer.png` },
{ name: 'PwC', src: `${BADGE_ASSET_PATH}/pwc.png` },
{
name: 'République Française',
src: `${BADGE_ASSET_PATH}/french-republic.png`,
},
];
const StyledRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
justify-content: center;
`;
const StyledBadge = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.pill};
box-sizing: border-box;
color: ${themeCssVariables.font.color.light};
display: flex;
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.semiBold};
gap: ${themeCssVariables.spacing[1]};
height: ${themeCssVariables.spacing[7]};
padding: 0 ${themeCssVariables.spacing[2]} 0 ${themeCssVariables.spacing[1]};
`;
const StyledSeal = styled.img`
height: 20px;
object-fit: contain;
width: 20px;
`;
const StyledLogoCluster = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[1]};
`;
const StyledClusterLogo = styled.img`
height: 18px;
object-fit: contain;
width: auto;
`;
const StyledBadgeLabel = styled.span`
white-space: nowrap;
`;
type TrustBadgeProps = {
label: string;
leading: ReactNode;
};
const TrustBadge = ({ label, leading }: TrustBadgeProps) => (
<StyledBadge>
{leading}
<StyledBadgeLabel>{label}</StyledBadgeLabel>
</StyledBadge>
);
export const OnboardingTrustBadges = () => (
<StyledRow>
<TrustBadge
label="SOC2"
leading={<StyledSeal src={`${BADGE_ASSET_PATH}/soc2.png`} alt="" />}
/>
<TrustBadge
label="+10k"
leading={
<StyledLogoCluster>
{TRUSTED_BY_LOGOS.map((logo) => (
<StyledClusterLogo key={logo.name} src={logo.src} alt={logo.name} />
))}
</StyledLogoCluster>
}
/>
<TrustBadge
label="GDPR"
leading={<StyledSeal src={`${BADGE_ASSET_PATH}/gdpr.png`} alt="" />}
/>
</StyledRow>
);
@@ -0,0 +1,21 @@
export type ImportContactsPreviewCalendarEvent = {
id: string;
title: string;
time: string;
color: 'yellow' | 'blue';
};
export const IMPORT_CONTACTS_PREVIEW_CALENDAR_EVENTS = [
{
id: 'tim-apple-anthropic',
title: 'Tim Apple x Anthropic',
time: '10:00am',
color: 'yellow',
},
{
id: 'dario-amodei',
title: 'Dario Amodei',
time: '3:00pm',
color: 'blue',
},
] satisfies ImportContactsPreviewCalendarEvent[];
@@ -0,0 +1,14 @@
export type ImportContactsPreviewCompany = {
id: string;
name: string;
domainName: string;
};
export const IMPORT_CONTACTS_PREVIEW_COMPANIES = [
{ id: 'figma', name: 'Figma', domainName: 'figma.com' },
{ id: 'anthropic', name: 'Anthropic', domainName: 'anthropic.com' },
{ id: 'notion', name: 'Notion', domainName: 'notion.so' },
{ id: 'airbnb', name: 'Airbnb', domainName: 'airbnb.com' },
{ id: 'linkedin', name: 'LinkedIn', domainName: 'linkedin.com' },
{ id: 'slack', name: 'Slack', domainName: 'slack.com' },
] satisfies ImportContactsPreviewCompany[];
@@ -0,0 +1,54 @@
export type ImportContactsPreviewEmail = {
id: string;
sender: string;
subject: string;
date: string;
isUnread: boolean;
};
export const IMPORT_CONTACTS_PREVIEW_EMAILS = [
{
id: 'acme',
sender: 'Acme Inc.',
subject: 'Insights: The latest in industrial equipment and tools',
date: 'Feb, 26',
isUnread: false,
},
{
id: 'dylan-field',
sender: 'Dylan Field',
subject:
'Lorem ipsum dolor sit amet consectetur. Ac eget eu eget ullamcorper tellus sem scelerisque sit ante.',
date: '12/12/23',
isUnread: false,
},
{
id: 'dario-amodei',
sender: 'Dario Amodei',
subject:
'Delta Weekly News: Learn about important safety tips before you fly!',
date: 'Jan, 26',
isUnread: true,
},
{
id: 'ivan-zhao',
sender: 'Ivan Zhao',
subject: 'Our latest Adventures and Destinations',
date: 'March, 26',
isUnread: false,
},
{
id: 'brian-chesky',
sender: 'Brian Chesky',
subject: 'Insights: Industry trends and best practices',
date: 'Jan, 26',
isUnread: false,
},
{
id: 'stewart-butterfield',
sender: 'Stewart Butterfield',
subject: 'Our Complete list of Recipe Ideas and Restaurant Reviews!',
date: 'Jan, 26',
isUnread: false,
},
] satisfies ImportContactsPreviewEmail[];
@@ -0,0 +1,34 @@
import { useSkipSyncEmailOnboardingStep } from '@/onboarding/hooks/useSkipSyncEmailOnboardingStep';
import { useEffect, useRef } from 'react';
type SyncEmailsAutoSkipEffectProps = {
onError: () => void;
};
export const SyncEmailsAutoSkipEffect = ({
onError,
}: SyncEmailsAutoSkipEffectProps) => {
const skipSyncEmailOnboardingStep = useSkipSyncEmailOnboardingStep();
// oxlint-disable-next-line twenty/no-state-useref
const hasSkippedRef = useRef(false);
useEffect(() => {
if (hasSkippedRef.current) {
return;
}
hasSkippedRef.current = true;
const skip = async () => {
try {
await skipSyncEmailOnboardingStep();
} catch {
onError();
}
};
void skip();
}, [skipSyncEmailOnboardingStep, onError]);
return null;
};
@@ -0,0 +1,16 @@
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { useMutation } from '@apollo/client/react';
import { useCallback } from 'react';
import { SkipSyncEmailOnboardingStepDocument } from '~/generated-metadata/graphql';
export const useSkipSyncEmailOnboardingStep = () => {
const setNextOnboardingStatus = useSetNextOnboardingStatus();
const [skipSyncEmailOnboardingStepMutation] = useMutation(
SkipSyncEmailOnboardingStepDocument,
);
return useCallback(async () => {
await skipSyncEmailOnboardingStepMutation();
setNextOnboardingStatus();
}, [skipSyncEmailOnboardingStepMutation, setNextOnboardingStatus]);
};
@@ -16,7 +16,7 @@ import { SignInUpGlobalScopeForm } from '@/auth/sign-in-up/components/SignInUpGl
import { SignInUpV2StandardContent } from '@/auth/sign-in-up/components/SignInUpV2StandardContent';
import { SignInUpWorkspaceScopeForm } from '@/auth/sign-in-up/components/SignInUpWorkspaceScopeForm';
import { SignInUpSSOIdentityProviderSelection } from '@/auth/sign-in-up/components/internal/SignInUpSSOIdentityProviderSelection';
import { SignInUpV2Header } from '@/auth/sign-in-up/components/internal/SignInUpV2Header';
import { OnboardingV2Layout } from '@/onboarding/components/OnboardingV2Layout';
import { SignInUpWorkspaceCreationFormV2 } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceCreationFormV2';
import { SignInUpWorkspaceScopeFormEffect } from '@/auth/sign-in-up/components/internal/SignInUpWorkspaceScopeFormEffect';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
@@ -188,21 +188,20 @@ export const SignInUpV2 = () => {
workspacePublicData,
]);
return (
return signInUpStep === SignInUpStep.WorkspaceCreation ? (
<OnboardingV2Layout
onBack={!isCreatingWorkspace ? onClickOnLogo : undefined}
>
<ModalContent isVerticallyCentered isHorizontallyCentered>
{signInUpForm}
</ModalContent>
</OnboardingV2Layout>
) : (
<StyledBackground>
{signInUpStep === SignInUpStep.EmailVerification ? (
<ModalContent isVerticallyCentered isHorizontallyCentered>
<EmailVerificationSent email={searchParams.get('email')} />
</ModalContent>
) : signInUpStep === SignInUpStep.WorkspaceCreation ? (
<>
{!isCreatingWorkspace ? (
<SignInUpV2Header onBack={onClickOnLogo} />
) : null}
<ModalContent isVerticallyCentered isHorizontallyCentered>
{signInUpForm}
</ModalContent>
</>
) : (
<SignInUpV2StandardContent
workspacePublicData={workspacePublicData}
@@ -0,0 +1,148 @@
import { OnboardingCreditsRewardTag } from '@/onboarding/components/import-contacts/OnboardingCreditsRewardTag';
import { OnboardingImportPreview } from '@/onboarding/components/import-contacts/OnboardingImportPreview';
import { OnboardingTrustBadges } from '@/onboarding/components/import-contacts/OnboardingTrustBadges';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconGoogle, IconMicrosoft } from 'twenty-ui/icon';
import { MainButton } from 'twenty-ui/input';
import { themeCssVariables, useTheme } from 'twenty-ui/theme-constants';
const CONTENT_BLOCK_WIDTH = 340;
const DEFAULT_CREDITS_REWARD = 2;
const StyledPage = 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 StyledTitle = styled.h1`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.xl};
font-weight: ${themeCssVariables.font.weight.semiBold};
margin: 0;
`;
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 StyledMiddle = styled.div`
align-items: center;
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[5]};
`;
const StyledFooter = styled.div`
align-items: flex-end;
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
width: ${CONTENT_BLOCK_WIDTH}px;
`;
const StyledButtons = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
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 ImportContactsProps = {
creditsReward?: number;
onContinueWithGoogle?: () => void;
onContinueWithMicrosoft?: () => void;
onSkip?: () => void;
};
export const ImportContacts = ({
creditsReward = DEFAULT_CREDITS_REWARD,
onContinueWithGoogle,
onContinueWithMicrosoft,
onSkip,
}: ImportContactsProps) => {
const { t } = useLingui();
const theme = useTheme();
return (
<StyledPage>
<StyledHeading>
<StyledTitle>{t`Import your contacts`}</StyledTitle>
<StyledSubtitle>
{t`Connect your email and calendar to see your entire network instantly. Takes only 30 seconds.`}
</StyledSubtitle>
<StyledCreditsRow>
<OnboardingCreditsRewardTag amount={creditsReward} />
</StyledCreditsRow>
</StyledHeading>
<StyledMiddle>
<OnboardingTrustBadges />
<OnboardingImportPreview />
</StyledMiddle>
<StyledFooter>
<StyledButtons>
{isDefined(onContinueWithMicrosoft) && (
<MainButton
title={t`Continue with Microsoft`}
fullWidth
onClick={onContinueWithMicrosoft}
Icon={() => <IconMicrosoft size={theme.icon.size.sm} />}
/>
)}
{isDefined(onContinueWithGoogle) && (
<MainButton
title={t`Continue with Google`}
fullWidth
onClick={onContinueWithGoogle}
Icon={() => <IconGoogle size={theme.icon.size.sm} />}
/>
)}
</StyledButtons>
{isDefined(onSkip) && (
<StyledSkipButton type="button" onClick={onSkip}>
{t`Skip`}
</StyledSkipButton>
)}
</StyledFooter>
</StyledPage>
);
};
@@ -0,0 +1,86 @@
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState';
import { isGoogleMessagingEnabledState } from '@/client-config/states/isGoogleMessagingEnabledState';
import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicrosoftCalendarEnabledState';
import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState';
import { OnboardingV2Layout } from '@/onboarding/components/OnboardingV2Layout';
import { SyncEmailsAutoSkipEffect } from '@/onboarding/effect-components/SyncEmailsAutoSkipEffect';
import { useSkipSyncEmailOnboardingStep } from '@/onboarding/hooks/useSkipSyncEmailOnboardingStep';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useCallback, useState } from 'react';
import { AppPath, ConnectedAccountProvider } from 'twenty-shared/types';
import { ImportContacts } from '~/pages/onboarding/ImportContacts';
import {
CalendarChannelVisibility,
MessageChannelVisibility,
} from '~/generated/graphql';
const IMPORT_CONTACTS_FREE_CREDITS = 0;
export const SyncEmailsV2 = () => {
const { triggerApisOAuth } = useTriggerApisOAuth();
const skipSyncEmailOnboardingStep = useSkipSyncEmailOnboardingStep();
const [hasAutoSkipFailed, setHasAutoSkipFailed] = useState(false);
const isGoogleMessagingEnabled = useAtomStateValue(
isGoogleMessagingEnabledState,
);
const isMicrosoftMessagingEnabled = useAtomStateValue(
isMicrosoftMessagingEnabledState,
);
const isGoogleCalendarEnabled = useAtomStateValue(
isGoogleCalendarEnabledState,
);
const isMicrosoftCalendarEnabled = useAtomStateValue(
isMicrosoftCalendarEnabledState,
);
const isGoogleProviderEnabled =
isGoogleMessagingEnabled || isGoogleCalendarEnabled;
const isMicrosoftProviderEnabled =
isMicrosoftMessagingEnabled || isMicrosoftCalendarEnabled;
const hasProviderEnabled =
isGoogleProviderEnabled || isMicrosoftProviderEnabled;
const isClientConfigLoaded = useAtomStateValue(
clientConfigApiStatusState,
).isLoadedOnce;
const connectWithProvider = (provider: ConnectedAccountProvider) =>
triggerApisOAuth(provider, {
redirectLocation: AppPath.Index,
messageVisibility: MessageChannelVisibility.METADATA,
calendarVisibility: CalendarChannelVisibility.METADATA,
skipMessageChannelConfiguration: true,
});
const handleAutoSkipError = useCallback(() => {
setHasAutoSkipFailed(true);
}, []);
if (!isClientConfigLoaded) {
return null;
}
if (!hasProviderEnabled && !hasAutoSkipFailed) {
return <SyncEmailsAutoSkipEffect onError={handleAutoSkipError} />;
}
return (
<OnboardingV2Layout freeCredits={IMPORT_CONTACTS_FREE_CREDITS}>
<ImportContacts
onContinueWithGoogle={
isGoogleProviderEnabled
? () => connectWithProvider(ConnectedAccountProvider.GOOGLE)
: undefined
}
onContinueWithMicrosoft={
isMicrosoftProviderEnabled
? () => connectWithProvider(ConnectedAccountProvider.MICROSOFT)
: undefined
}
onSkip={skipSyncEmailOnboardingStep}
/>
</OnboardingV2Layout>
);
};
@@ -0,0 +1,43 @@
import { styled } from '@linaria/react';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { action } from 'storybook/actions';
import { within } from 'storybook/test';
import { ImportContacts } from '~/pages/onboarding/ImportContacts';
const StyledViewport = styled.div`
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
`;
const meta: Meta<typeof ImportContacts> = {
title: 'Pages/Onboarding/ImportContacts',
component: ImportContacts,
parameters: { layout: 'fullscreen' },
args: {
creditsReward: 2,
onContinueWithGoogle: action('continue-with-google'),
onContinueWithMicrosoft: action('continue-with-microsoft'),
onSkip: action('skip'),
},
decorators: [
(Story) => (
<StyledViewport>
<Story />
</StyledViewport>
),
],
};
export default meta;
type Story = StoryObj<typeof ImportContacts>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Import your contacts');
},
};
@@ -0,0 +1,49 @@
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 { OnboardingStatus } from '~/generated-metadata/graphql';
import { GET_CURRENT_USER } from '~/modules/users/graphql/queries/getCurrentUser';
import { SyncEmailsV2 } from '~/pages/onboarding/SyncEmailsV2';
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/SyncEmailsV2',
component: SyncEmailsV2,
decorators: [PageDecorator],
args: { routePath: AppPath.SyncEmailsV2 },
parameters: {
msw: {
handlers: [
graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => {
return HttpResponse.json({
data: {
currentUser: mockedOnboardingUserData(
OnboardingStatus.SYNC_EMAIL,
),
},
});
}),
graphqlMocks.handlers,
],
},
},
};
export default meta;
export type Story = StoryObj<typeof SyncEmailsV2>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement.ownerDocument.body);
await canvas.findByText('Import your contacts');
},
};
@@ -4,4 +4,5 @@ export const UNTESTED_APP_PATHS = [
AppPath.Settings,
AppPath.Developers,
AppPath.WorkspaceActivationV2,
AppPath.SyncEmailsV2,
];
@@ -12,6 +12,7 @@ export enum AppPath {
WorkspaceActivationV2 = '/workspace-activation-v2',
CreateProfile = '/create/profile',
SyncEmails = '/sync/emails',
SyncEmailsV2 = '/sync/emails-v2',
InviteTeam = '/invite-team',
PlanRequired = '/plan-required',
PlanRequiredSuccess = '/plan-required/payment-success',