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