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
@@ -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,
};
};