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