Add v2 onboarding invite team page (#22229)

<img width="3024" height="1500" alt="CleanShot 2026-06-26 at 18 09
47@2x"
src="https://github.com/user-attachments/assets/e91f30a5-2763-42a0-9abf-d9fa8400870c"
/>


Adds the v2 onboarding **Invite team** page (`INVITE_TEAM`), shown right
after the create-profile step for the onboarding-v2 cohort. It renders
full-screen under `BlankLayout` via the shared `OnboardingV2Layout`,
matching the Figma (340px column, email inputs with inline remove, dark
Invite, Skip).

Reuses all v1 invite-team logic via a new `useInviteTeam` hook (v1
`InviteTeam` now consumes it too; its UI is unchanged). Routing mirrors
`SyncEmailsV2`/`CreateProfileV2`: new `AppPath.InviteTeamV2`, lazy
route, and an `isOnboardingV2`-gated branch in
`usePageChangeEffectNavigateLocation` (+ tests and a Storybook story).

No backend changes.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22229?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-06-29 13:53:07 +02:00
committed by GitHub
parent 921c528cc3
commit db7d8172f7
24 changed files with 559 additions and 169 deletions
@@ -527,4 +527,16 @@ describe('usePageChangeEffectNavigateLocation — onboarding V2', () => {
expect(usePageChangeEffectNavigateLocation()).toBeUndefined();
});
it('routes to InviteTeamV2 when onboardingV2 is active and status is INVITE_TEAM', () => {
setupOnboardingV2Case(AppPath.Index, OnboardingStatus.INVITE_TEAM);
expect(usePageChangeEffectNavigateLocation()).toEqual(AppPath.InviteTeamV2);
});
it('does not redirect away from the InviteTeamV2 page', () => {
setupOnboardingV2Case(AppPath.InviteTeamV2, OnboardingStatus.INVITE_TEAM);
expect(usePageChangeEffectNavigateLocation()).toBeUndefined();
});
});
@@ -147,9 +147,9 @@ export const usePageChangeEffectNavigateLocation = () => {
if (
onboardingStatus === OnboardingStatus.INVITE_TEAM &&
!isMatchingLocation(location, AppPath.InviteTeam)
!someMatchingLocationOf([AppPath.InviteTeam, AppPath.InviteTeamV2])
) {
return AppPath.InviteTeam;
return isOnboardingV2 ? AppPath.InviteTeamV2 : AppPath.InviteTeam;
}
if (
@@ -335,7 +335,8 @@ export const PageChangeEffect = () => {
});
break;
}
case isMatchingLocation(location, AppPath.InviteTeam): {
case isMatchingLocation(location, AppPath.InviteTeam):
case isMatchingLocation(location, AppPath.InviteTeamV2): {
resetFocusStackToFocusItem({
focusStackItem: {
focusId: PageFocusId.InviteTeam,
@@ -98,6 +98,12 @@ const InviteTeam = lazy(() =>
})),
);
const InviteTeamV2 = lazy(() =>
import('~/pages/onboarding/InviteTeamV2').then((module) => ({
default: module.InviteTeamV2,
})),
);
const ChooseYourPlan = lazy(() =>
import('~/pages/onboarding/ChooseYourPlan').then((module) => ({
default: module.ChooseYourPlan,
@@ -324,6 +330,14 @@ export const useCreateAppRouter = (
</LazyRoute>
}
/>
<Route
path={AppPath.InviteTeamV2}
element={
<LazyRoute fallback={null}>
<InviteTeamV2 />
</LazyRoute>
}
/>
<Route
path={AppPath.Authorize}
element={
@@ -8,6 +8,7 @@ export const ONBOARDING_PATHS = [
AppPath.SyncEmails,
AppPath.SyncEmailsV2,
AppPath.InviteTeam,
AppPath.InviteTeamV2,
AppPath.PlanRequired,
AppPath.PlanRequiredSuccess,
AppPath.BookCallDecision,
@@ -1,5 +1,6 @@
import { aiModelsState } from '@/client-config/states/aiModelsState';
import { apiConfigState } from '@/client-config/states/apiConfigState';
import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
import { appVersionState } from '@/client-config/states/appVersionState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { billingState } from '@/client-config/states/billingState';
@@ -69,6 +70,7 @@ export const useClientConfig = (): UseClientConfigResult => {
const setCaptcha = useSetAtomState(captchaState);
const setApiConfig = useSetAtomState(apiConfigState);
const setOnboardingConfig = useSetAtomState(onboardingConfigState);
const setCanManageFeatureFlags = useSetAtomState(canManageFeatureFlagsState);
@@ -175,6 +177,7 @@ export const useClientConfig = (): UseClientConfigResult => {
});
setApiConfig(clientConfig?.api);
setOnboardingConfig(clientConfig?.onboarding);
setDomainConfiguration({
defaultSubdomain: clientConfig?.defaultSubdomain,
frontDomain: clientConfig?.frontDomain,
@@ -221,6 +224,7 @@ export const useClientConfig = (): UseClientConfigResult => {
}, [
setAiModels,
setApiConfig,
setOnboardingConfig,
setAppVersion,
setAuthProviders,
setBilling,
@@ -0,0 +1,7 @@
import { type OnboardingConfig } from '@/client-config/types/OnboardingConfig';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const onboardingConfigState = createAtomState<OnboardingConfig | null>({
key: 'onboardingConfigState',
defaultValue: null,
});
@@ -9,6 +9,7 @@ import {
type Sentry,
type Support,
} from '~/generated-metadata/graphql';
import { type OnboardingConfig } from '@/client-config/types/OnboardingConfig';
export type ClientConfig = {
appVersion?: string;
@@ -36,6 +37,7 @@ export type ClientConfig = {
isCloudflareIntegrationEnabled: boolean;
isClickHouseConfigured: boolean;
isWorkspaceSchemaDDLLocked: boolean;
onboarding: OnboardingConfig;
publicFeatureFlags: Array<PublicFeatureFlag>;
sentry: Sentry;
signInPrefilled: boolean;
@@ -0,0 +1,5 @@
export type OnboardingConfig = {
importContactsCreditsReward: number;
inviteTeamMaxCreditsReward: number;
inviteTeamCreditsRewardPerUser: number;
};
@@ -1,5 +1,6 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconCoins } from 'twenty-ui/icon';
import { themeCssVariables, useTheme } from 'twenty-ui/theme-constants';
@@ -29,10 +30,12 @@ const StyledSuffix = styled.span`
type OnboardingCreditsRewardTagProps = {
amount: number;
perUserAmount?: number;
};
export const OnboardingCreditsRewardTag = ({
amount,
perUserAmount,
}: OnboardingCreditsRewardTagProps) => {
const { t } = useLingui();
const theme = useTheme();
@@ -44,7 +47,11 @@ export const OnboardingCreditsRewardTag = ({
color={themeCssVariables.color.green9}
/>
<StyledLabel>{t`Earn +${amount}`}</StyledLabel>
<StyledSuffix>{t`free credits`}</StyledSuffix>
<StyledSuffix>
{isDefined(perUserAmount)
? t`free credits (${perUserAmount} per user)`
: t`free credits`}
</StyledSuffix>
</StyledTag>
);
};
@@ -0,0 +1,188 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { PageFocusId } from '@/types/PageFocusId';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useCreateWorkspaceInvitation } from '@/workspace-invitation/hooks/useCreateWorkspaceInvitation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { useQuery } from '@apollo/client/react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { type SubmitHandler, useFieldArray, useForm } from 'react-hook-form';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import { GetInviteSuggestionsDocument } from '~/generated-metadata/graphql';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { z } from 'zod';
const validationSchema = z.object({
emails: z.array(z.object({ email: z.union([z.literal(''), z.email()]) })),
});
type InviteTeamFormInput = z.infer<typeof validationSchema>;
export const useInviteTeam = () => {
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const { enqueueSuccessSnackBar } = useSnackBar();
const { sendInvitation } = useCreateWorkspaceInvitation();
const setNextOnboardingStatus = useSetNextOnboardingStatus();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const calendarBookingPageId = useAtomStateValue(calendarBookingPageIdState);
const hasCalendarBooking = isDefined(calendarBookingPageId);
const {
control,
handleSubmit,
watch,
reset,
formState: { isValid, isSubmitting, isDirty },
} = useForm<InviteTeamFormInput>({
mode: 'onChange',
defaultValues: {
emails: [{ email: '' }, { email: '' }, { email: '' }],
},
resolver: zodResolver(validationSchema),
});
const { fields, append, remove } = useFieldArray({
control,
name: 'emails',
});
const [hasPrefilledSuggestions, setHasPrefilledSuggestions] = useState(false);
const { data: inviteSuggestionsData } = useQuery(
GetInviteSuggestionsDocument,
{
fetchPolicy: 'cache-first',
},
);
const inviteSuggestions = useMemo(
() => inviteSuggestionsData?.getInviteSuggestions ?? [],
[inviteSuggestionsData],
);
const hasInviteSuggestions = inviteSuggestions.length > 0;
useEffect(() => {
if (hasPrefilledSuggestions || !hasInviteSuggestions || isDirty) {
return;
}
setHasPrefilledSuggestions(true);
reset({
emails: [
...inviteSuggestions.map((suggestion) => ({ email: suggestion.email })),
{ email: '' },
],
});
}, [
hasPrefilledSuggestions,
hasInviteSuggestions,
inviteSuggestions,
isDirty,
reset,
]);
useEffect(() => {
const subscription = watch(({ emails }) => {
if (!emails) {
return;
}
const emailValues = emails.map((email) => email?.email);
if (emailValues[emailValues.length - 1] !== '') {
append({ email: '' });
}
if (
emailValues.length > 3 &&
emailValues[emailValues.length - 2] === ''
) {
remove(emailValues.length - 1);
}
});
return () => subscription.unsubscribe();
}, [watch, append, remove]);
const getPlaceholder = (emailIndex: number) => {
if (emailIndex === 0) {
return 'tim@apple.com';
}
if (emailIndex === 1) {
return 'phil@apple.com';
}
if (emailIndex === 2) {
return 'jony@apple.com';
}
return 'craig@apple.com';
};
const copyInviteLink = () => {
if (isDefined(currentWorkspace?.inviteHash)) {
const inviteLink = `${window.location.origin}/invite/${currentWorkspace?.inviteHash}`;
copyToClipboard(inviteLink, t`Link copied to clipboard`);
}
};
const onSubmit: SubmitHandler<InviteTeamFormInput> = useCallback(
async (data) => {
const emails = Array.from(
new Set(
data.emails
.map((emailData) => emailData.email.trim())
.filter((email) => email.length > 0),
),
);
const result = await sendInvitation({ emails });
if (isDefined(result.error)) {
throw result.error;
}
if (emails.length > 0) {
enqueueSuccessSnackBar({
message: t`Invite link sent to email addresses`,
options: {
duration: 2000,
},
});
}
setNextOnboardingStatus();
},
[enqueueSuccessSnackBar, sendInvitation, setNextOnboardingStatus, t],
);
const handleSkip = async () => {
await onSubmit({ emails: [] });
};
useHotkeysOnFocusedElement({
keys: Key.Enter,
callback: () => {
handleSubmit(onSubmit)();
},
focusId: PageFocusId.InviteTeam,
dependencies: [handleSubmit, onSubmit],
});
return {
control,
fields,
remove,
handleSubmit,
onSubmit,
handleSkip,
copyInviteLink,
getPlaceholder,
hasPrefilledSuggestions,
hasCalendarBooking,
isValid,
isSubmitting,
currentWorkspace,
};
};
@@ -9,7 +9,6 @@ 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;
@@ -93,7 +92,7 @@ type ImportContactsProps = {
};
export const ImportContacts = ({
creditsReward = DEFAULT_CREDITS_REWARD,
creditsReward,
onContinueWithGoogle,
onContinueWithMicrosoft,
onSkip,
@@ -108,9 +107,11 @@ export const ImportContacts = ({
<StyledSubtitle>
{t`Connect your email and calendar to see your entire network instantly. Takes only 30 seconds.`}
</StyledSubtitle>
<StyledCreditsRow>
<OnboardingCreditsRewardTag amount={creditsReward} />
</StyledCreditsRow>
{isDefined(creditsReward) && (
<StyledCreditsRow>
<OnboardingCreditsRewardTag amount={creditsReward} />
</StyledCreditsRow>
)}
</StyledHeading>
<StyledMiddle>
@@ -1,36 +1,17 @@
import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { PageFocusId } from '@/types/PageFocusId';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useInviteTeam } from '@/onboarding/hooks/useInviteTeam';
import { TextInput } from '@/ui/input/components/TextInput';
import { ModalContent } from 'twenty-ui/surfaces';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { styled } from '@linaria/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Controller,
type SubmitHandler,
useFieldArray,
useForm,
} from 'react-hook-form';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useQuery } from '@apollo/client/react';
import { Key } from 'ts-key-enum';
import { Controller } from 'react-hook-form';
import { isDefined } from 'twenty-shared/utils';
import { IconCopy } from 'twenty-ui/icon';
import { SeparatorLineText } from 'twenty-ui/typography';
import { LightButton, MainButton } from 'twenty-ui/input';
import { ClickToActionLink } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { z } from 'zod';
import { GetInviteSuggestionsDocument } from '~/generated-metadata/graphql';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { useCreateWorkspaceInvitation } from '@/workspace-invitation/hooks/useCreateWorkspaceInvitation';
const StyledAnimatedContainer = styled.div`
display: flex;
@@ -57,151 +38,22 @@ const StyledActionSkipLinkContainer = styled.div`
margin: ${themeCssVariables.spacing[3]} 0 0;
`;
const validationSchema = z.object({
emails: z.array(z.object({ email: z.union([z.literal(''), z.email()]) })),
});
type FormInput = z.infer<typeof validationSchema>;
export const InviteTeam = () => {
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const { enqueueSuccessSnackBar } = useSnackBar();
const { sendInvitation } = useCreateWorkspaceInvitation();
const setNextOnboardingStatus = useSetNextOnboardingStatus();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const calendarBookingPageId = useAtomStateValue(calendarBookingPageIdState);
const hasCalendarBooking = isDefined(calendarBookingPageId);
const {
control,
fields,
handleSubmit,
watch,
reset,
formState: { isValid, isSubmitting, isDirty },
} = useForm<FormInput>({
mode: 'onChange',
defaultValues: {
emails: [{ email: '' }, { email: '' }, { email: '' }],
},
resolver: zodResolver(validationSchema),
});
const { fields, append, remove } = useFieldArray({
control,
name: 'emails',
});
const [hasPrefilledSuggestions, setHasPrefilledSuggestions] = useState(false);
const { data: inviteSuggestionsData } = useQuery(
GetInviteSuggestionsDocument,
{
fetchPolicy: 'cache-first',
},
);
const inviteSuggestions = useMemo(
() => inviteSuggestionsData?.getInviteSuggestions ?? [],
[inviteSuggestionsData],
);
const hasInviteSuggestions = inviteSuggestions.length > 0;
useEffect(() => {
if (hasPrefilledSuggestions || !hasInviteSuggestions || isDirty) {
return;
}
setHasPrefilledSuggestions(true);
reset({
emails: [
...inviteSuggestions.map((suggestion) => ({ email: suggestion.email })),
{ email: '' },
],
});
}, [
onSubmit,
handleSkip,
copyInviteLink,
getPlaceholder,
hasPrefilledSuggestions,
hasInviteSuggestions,
inviteSuggestions,
isDirty,
reset,
]);
watch(({ emails }) => {
if (!emails) {
return;
}
const emailValues = emails.map((email) => email?.email);
if (emailValues[emailValues.length - 1] !== '') {
append({ email: '' });
}
if (emailValues.length > 3 && emailValues[emailValues.length - 2] === '') {
remove(emailValues.length - 1);
}
});
const getPlaceholder = (emailIndex: number) => {
if (emailIndex === 0) {
return 'tim@apple.com';
}
if (emailIndex === 1) {
return 'phil@apple.com';
}
if (emailIndex === 2) {
return 'jony@apple.com';
}
return 'craig@apple.com';
};
const copyInviteLink = () => {
if (isDefined(currentWorkspace?.inviteHash)) {
const inviteLink = `${window.location.origin}/invite/${currentWorkspace?.inviteHash}`;
copyToClipboard(inviteLink, t`Link copied to clipboard`);
}
};
const onSubmit: SubmitHandler<FormInput> = useCallback(
async (data) => {
const emails = Array.from(
new Set(
data.emails
.map((emailData) => emailData.email.trim())
.filter((email) => email.length > 0),
),
);
const result = await sendInvitation({ emails });
if (isDefined(result.error)) {
throw result.error;
}
if (emails.length > 0) {
enqueueSuccessSnackBar({
message: t`Invite link sent to email addresses`,
options: {
duration: 2000,
},
});
}
setNextOnboardingStatus();
},
[enqueueSuccessSnackBar, sendInvitation, setNextOnboardingStatus, t],
);
const handleSkip = async () => {
await onSubmit({ emails: [] });
};
useHotkeysOnFocusedElement({
keys: Key.Enter,
callback: () => {
handleSubmit(onSubmit)();
},
focusId: PageFocusId.InviteTeam,
dependencies: [handleSubmit, onSubmit],
});
hasCalendarBooking,
isValid,
isSubmitting,
currentWorkspace,
} = useInviteTeam();
return (
<ModalContent isVerticallyCentered isHorizontallyCentered>
@@ -0,0 +1,162 @@
import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
import { OnboardingCreditsRewardTag } from '@/onboarding/components/import-contacts/OnboardingCreditsRewardTag';
import { OnboardingV2Layout } from '@/onboarding/components/OnboardingV2Layout';
import { useInviteTeam } from '@/onboarding/hooks/useInviteTeam';
import { TextInput } from '@/ui/input/components/TextInput';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { Controller } from 'react-hook-form';
import { isDefined } from 'twenty-shared/utils';
import { IconX } from 'twenty-ui/icon';
import { MainButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const CONTENT_BLOCK_WIDTH = 340;
const INVITE_TEAM_FREE_CREDITS = 0;
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 StyledForm = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
width: ${CONTENT_BLOCK_WIDTH}px;
`;
const StyledFooter = styled.div`
align-items: flex-end;
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
width: ${CONTENT_BLOCK_WIDTH}px;
`;
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]};
`;
export const InviteTeamV2 = () => {
const { t } = useLingui();
const {
control,
fields,
remove,
handleSubmit,
onSubmit,
handleSkip,
getPlaceholder,
isValid,
isSubmitting,
} = useInviteTeam();
const onboardingConfig = useAtomStateValue(onboardingConfigState);
const creditsReward = onboardingConfig?.inviteTeamMaxCreditsReward;
const creditsRewardPerUser = onboardingConfig?.inviteTeamCreditsRewardPerUser;
return (
<OnboardingV2Layout freeCredits={INVITE_TEAM_FREE_CREDITS}>
<StyledPage>
<StyledHeading>
<StyledTitle>{t`Invite your team`}</StyledTitle>
<StyledSubtitle>
{t`Get the most out of your workspace by inviting your team.`}
</StyledSubtitle>
{isDefined(creditsReward) && (
<StyledCreditsRow>
<OnboardingCreditsRewardTag
amount={creditsReward}
perUserAmount={creditsRewardPerUser}
/>
</StyledCreditsRow>
)}
</StyledHeading>
<StyledForm>
{fields.map((field, index) => (
<Controller
key={field.id}
name={`emails.${index}.email`}
control={control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<TextInput
autoFocus={index === 0}
type="email"
value={value}
placeholder={getPlaceholder(index)}
onBlur={onBlur}
error={error?.message}
onChange={onChange}
RightIcon={IconX}
onRightIconClick={() => remove(index)}
noErrorHelper
fullWidth
/>
)}
/>
))}
</StyledForm>
<StyledFooter>
<MainButton
title={t`Invite`}
disabled={!isValid || isSubmitting}
onClick={handleSubmit(onSubmit)}
fullWidth
/>
<StyledSkipButton type="button" onClick={handleSkip}>
{t`Skip`}
</StyledSkipButton>
</StyledFooter>
</StyledPage>
</OnboardingV2Layout>
);
};
@@ -3,6 +3,7 @@ import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCal
import { isGoogleMessagingEnabledState } from '@/client-config/states/isGoogleMessagingEnabledState';
import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicrosoftCalendarEnabledState';
import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState';
import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
import { OnboardingV2Layout } from '@/onboarding/components/OnboardingV2Layout';
import { SyncEmailsAutoSkipEffect } from '@/onboarding/effect-components/SyncEmailsAutoSkipEffect';
import { useSkipSyncEmailOnboardingStep } from '@/onboarding/hooks/useSkipSyncEmailOnboardingStep';
@@ -45,6 +46,7 @@ export const SyncEmailsV2 = () => {
const isClientConfigLoaded = useAtomStateValue(
clientConfigApiStatusState,
).isLoadedOnce;
const onboardingConfig = useAtomStateValue(onboardingConfigState);
const connectWithProvider = (provider: ConnectedAccountProvider) =>
triggerApisOAuth(provider, {
@@ -69,6 +71,7 @@ export const SyncEmailsV2 = () => {
return (
<OnboardingV2Layout freeCredits={IMPORT_CONTACTS_FREE_CREDITS}>
<ImportContacts
creditsReward={onboardingConfig?.importContactsCreditsReward}
onContinueWithGoogle={
isGoogleProviderEnabled
? () => connectWithProvider(ConnectedAccountProvider.GOOGLE)
@@ -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 { InviteTeamV2 } from '~/pages/onboarding/InviteTeamV2';
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/InviteTeamV2',
component: InviteTeamV2,
decorators: [PageDecorator],
args: { routePath: AppPath.InviteTeamV2 },
parameters: {
msw: {
handlers: [
graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => {
return HttpResponse.json({
data: {
currentUser: mockedOnboardingUserData(
OnboardingStatus.INVITE_TEAM,
),
},
});
}),
graphqlMocks.handlers,
],
},
},
};
export default meta;
export type Story = StoryObj<typeof InviteTeamV2>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement.ownerDocument.body);
await canvas.findByText('Invite your team');
},
};
@@ -6,6 +6,7 @@ export const UNTESTED_APP_PATHS = [
AppPath.WorkspaceActivationV2,
AppPath.CreateProfileV2,
AppPath.SyncEmailsV2,
AppPath.InviteTeamV2,
// Public, unauthenticated redirect route handled in useCreateAppRouter — not
// part of the onboarding/auth page-change navigation matrix.
AppPath.Dpa,
@@ -44,6 +44,11 @@ export const mockedClientConfig: ClientConfig = {
siteKey: 'MOCKED_SITE_KEY',
},
api: { mutationMaximumAffectedRecords: 100 },
onboarding: {
importContactsCreditsReward: 2,
inviteTeamMaxCreditsReward: 9,
inviteTeamCreditsRewardPerUser: 3,
},
canManageFeatureFlags: true,
publicFeatureFlags: [],
isMicrosoftMessagingEnabled: true,
@@ -85,6 +85,11 @@ describe('ClientConfigController', () => {
api: {
mutationMaximumAffectedRecords: 100,
},
onboarding: {
importContactsCreditsReward: 2,
inviteTeamMaxCreditsReward: 9,
inviteTeamCreditsRewardPerUser: 3,
},
isAttachmentPreviewEnabled: true,
analyticsEnabled: false,
canManageFeatureFlags: true,
@@ -208,6 +208,14 @@ export class ApiConfig {
mutationMaximumAffectedRecords: number;
}
export class OnboardingConfig {
importContactsCreditsReward: number;
inviteTeamMaxCreditsReward: number;
inviteTeamCreditsRewardPerUser: number;
}
@ObjectType()
export class PublicFeatureFlagMetadata {
@Field(() => String)
@@ -291,6 +299,8 @@ export class ClientConfig {
@Field(() => ApiConfig)
api: ApiConfig;
onboarding: OnboardingConfig;
@Field(() => Boolean)
canManageFeatureFlags: boolean;
@@ -92,6 +92,9 @@ describe('ClientConfigService', () => {
CAPTCHA_DRIVER: CaptchaDriverType.GOOGLE_RECAPTCHA,
CAPTCHA_SITE_KEY: 'site-key-123',
MUTATION_MAXIMUM_AFFECTED_RECORDS: 1000,
ONBOARDING_IMPORT_CONTACTS_CREDITS_REWARD: 2_000_000,
ONBOARDING_INVITE_TEAM_MAX_CREDITS_REWARD: 9_000_000,
ONBOARDING_INVITE_TEAM_CREDITS_REWARD_PER_USER: 3_000_000,
IS_ATTACHMENT_PREVIEW_ENABLED: true,
ANALYTICS_ENABLED: true,
MESSAGING_PROVIDER_MICROSOFT_ENABLED: false,
@@ -165,6 +168,11 @@ describe('ClientConfigService', () => {
api: {
mutationMaximumAffectedRecords: 1000,
},
onboarding: {
importContactsCreditsReward: 2,
inviteTeamMaxCreditsReward: 9,
inviteTeamCreditsRewardPerUser: 3,
},
isAttachmentPreviewEnabled: true,
analyticsEnabled: true,
canManageFeatureFlags: true,
@@ -15,6 +15,7 @@ import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
import {
AUTO_SELECT_FAST_MODEL_ID,
AUTO_SELECT_SMART_MODEL_ID,
@@ -218,6 +219,23 @@ export class ClientConfigService {
'MUTATION_MAXIMUM_AFFECTED_RECORDS',
),
},
onboarding: {
importContactsCreditsReward: toDisplayCredits(
this.twentyConfigService.get(
'ONBOARDING_IMPORT_CONTACTS_CREDITS_REWARD',
),
),
inviteTeamMaxCreditsReward: toDisplayCredits(
this.twentyConfigService.get(
'ONBOARDING_INVITE_TEAM_MAX_CREDITS_REWARD',
),
),
inviteTeamCreditsRewardPerUser: toDisplayCredits(
this.twentyConfigService.get(
'ONBOARDING_INVITE_TEAM_CREDITS_REWARD_PER_USER',
),
),
},
isAttachmentPreviewEnabled: this.twentyConfigService.get(
'IS_ATTACHMENT_PREVIEW_ENABLED',
),
@@ -5,6 +5,7 @@ import {
IsDateString,
IsDefined,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
@@ -932,6 +933,39 @@ export class ConfigVariables {
@IsOptional()
BILLING_USAGE_CAP_CLICKHOUSE_ENABLED = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BILLING_CONFIG,
description:
'Free credits granted for completing the import-contacts onboarding step (in microCredits)',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@IsInt()
@IsOptional()
ONBOARDING_IMPORT_CONTACTS_CREDITS_REWARD = 2_000_000;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BILLING_CONFIG,
description:
'Maximum free credits granted for completing the invite-team onboarding step (in microCredits)',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@IsInt()
@IsOptional()
ONBOARDING_INVITE_TEAM_MAX_CREDITS_REWARD = 9_000_000;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BILLING_CONFIG,
description:
'Free credits granted per user invited during the invite-team onboarding step (in microCredits)',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@IsInt()
@IsOptional()
ONBOARDING_INVITE_TEAM_CREDITS_REWARD_PER_USER = 3_000_000;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
description: 'Url for the frontend application',
@@ -15,6 +15,7 @@ export enum AppPath {
SyncEmails = '/sync/emails',
SyncEmailsV2 = '/sync/emails-v2',
InviteTeam = '/invite-team',
InviteTeamV2 = '/invite-team-v2',
PlanRequired = '/plan-required',
PlanRequiredSuccess = '/plan-required/payment-success',
BookCallDecision = '/book-call-decision',