From db7d8172f7ebad82abcc3e06b11dff6d3e73979c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?=
<71827178+bosiraphael@users.noreply.github.com>
Date: Mon, 29 Jun 2026 13:53:07 +0200
Subject: [PATCH] Add v2 onboarding invite team page (#22229)
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.
---
...sePageChangeEffectNavigateLocation.test.ts | 12 ++
.../usePageChangeEffectNavigateLocation.ts | 4 +-
.../effect-components/PageChangeEffect.tsx | 3 +-
.../modules/app/hooks/useCreateAppRouter.tsx | 14 ++
.../modules/auth/constants/OnboardingPaths.ts | 1 +
.../client-config/hooks/useClientConfig.ts | 4 +
.../states/onboardingConfigState.ts | 7 +
.../client-config/types/ClientConfig.ts | 2 +
.../client-config/types/OnboardingConfig.ts | 5 +
.../OnboardingCreditsRewardTag.tsx | 9 +-
.../modules/onboarding/hooks/useInviteTeam.ts | 188 ++++++++++++++++++
.../src/pages/onboarding/ImportContacts.tsx | 11 +-
.../src/pages/onboarding/InviteTeam.tsx | 172 ++--------------
.../src/pages/onboarding/InviteTeamV2.tsx | 162 +++++++++++++++
.../src/pages/onboarding/SyncEmailsV2.tsx | 3 +
.../__stories__/InviteTeamV2.stories.tsx | 49 +++++
.../src/testing/constants/UntestedAppPaths.ts | 1 +
.../src/testing/mock-data/config.ts | 5 +
.../client-config.controller.spec.ts | 5 +
.../client-config/client-config.entity.ts | 10 +
.../services/client-config.service.spec.ts | 8 +
.../services/client-config.service.ts | 18 ++
.../twenty-config/config-variables.ts | 34 ++++
packages/twenty-shared/src/types/AppPath.ts | 1 +
24 files changed, 559 insertions(+), 169 deletions(-)
create mode 100644 packages/twenty-front/src/modules/client-config/states/onboardingConfigState.ts
create mode 100644 packages/twenty-front/src/modules/client-config/types/OnboardingConfig.ts
create mode 100644 packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts
create mode 100644 packages/twenty-front/src/pages/onboarding/InviteTeamV2.tsx
create mode 100644 packages/twenty-front/src/pages/onboarding/__stories__/InviteTeamV2.stories.tsx
diff --git a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts
index 597e08cec9..9cb9f1be46 100644
--- a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts
+++ b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts
@@ -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();
+ });
});
diff --git a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts
index 60884c0749..7e4381f8f0 100644
--- a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts
+++ b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts
@@ -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 (
diff --git a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx
index c6a2a7c1c3..e8a3d548bb 100644
--- a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx
+++ b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx
@@ -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,
diff --git a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx
index 26a1f96172..38d051085c 100644
--- a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx
+++ b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx
@@ -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 = (
}
/>
+
+
+
+ }
+ />
{
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,
diff --git a/packages/twenty-front/src/modules/client-config/states/onboardingConfigState.ts b/packages/twenty-front/src/modules/client-config/states/onboardingConfigState.ts
new file mode 100644
index 0000000000..6499b546d8
--- /dev/null
+++ b/packages/twenty-front/src/modules/client-config/states/onboardingConfigState.ts
@@ -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({
+ key: 'onboardingConfigState',
+ defaultValue: null,
+});
diff --git a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts
index 0bb5eb2765..c251fb16ae 100644
--- a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts
+++ b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts
@@ -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;
sentry: Sentry;
signInPrefilled: boolean;
diff --git a/packages/twenty-front/src/modules/client-config/types/OnboardingConfig.ts b/packages/twenty-front/src/modules/client-config/types/OnboardingConfig.ts
new file mode 100644
index 0000000000..d27420553b
--- /dev/null
+++ b/packages/twenty-front/src/modules/client-config/types/OnboardingConfig.ts
@@ -0,0 +1,5 @@
+export type OnboardingConfig = {
+ importContactsCreditsReward: number;
+ inviteTeamMaxCreditsReward: number;
+ inviteTeamCreditsRewardPerUser: number;
+};
diff --git a/packages/twenty-front/src/modules/onboarding/components/import-contacts/OnboardingCreditsRewardTag.tsx b/packages/twenty-front/src/modules/onboarding/components/import-contacts/OnboardingCreditsRewardTag.tsx
index 862b32fa4e..8b775c16fe 100644
--- a/packages/twenty-front/src/modules/onboarding/components/import-contacts/OnboardingCreditsRewardTag.tsx
+++ b/packages/twenty-front/src/modules/onboarding/components/import-contacts/OnboardingCreditsRewardTag.tsx
@@ -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}
/>
{t`Earn +${amount}`}
- {t`free credits`}
+
+ {isDefined(perUserAmount)
+ ? t`free credits (${perUserAmount} per user)`
+ : t`free credits`}
+
);
};
diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts b/packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts
new file mode 100644
index 0000000000..abf6d7ebe6
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts
@@ -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;
+
+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({
+ 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 = 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,
+ };
+};
diff --git a/packages/twenty-front/src/pages/onboarding/ImportContacts.tsx b/packages/twenty-front/src/pages/onboarding/ImportContacts.tsx
index ccacd5a64c..af521583ca 100644
--- a/packages/twenty-front/src/pages/onboarding/ImportContacts.tsx
+++ b/packages/twenty-front/src/pages/onboarding/ImportContacts.tsx
@@ -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 = ({
{t`Connect your email and calendar to see your entire network instantly. Takes only 30 seconds.`}
-
-
-
+ {isDefined(creditsReward) && (
+
+
+
+ )}
diff --git a/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx b/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx
index 4657190e63..d856f48508 100644
--- a/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx
+++ b/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx
@@ -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;
-
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({
- 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 = 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 (
diff --git a/packages/twenty-front/src/pages/onboarding/InviteTeamV2.tsx b/packages/twenty-front/src/pages/onboarding/InviteTeamV2.tsx
new file mode 100644
index 0000000000..07552a7929
--- /dev/null
+++ b/packages/twenty-front/src/pages/onboarding/InviteTeamV2.tsx
@@ -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 (
+
+
+
+ {t`Invite your team`}
+
+ {t`Get the most out of your workspace by inviting your team.`}
+
+ {isDefined(creditsReward) && (
+
+
+
+ )}
+
+
+
+ {fields.map((field, index) => (
+ (
+ remove(index)}
+ noErrorHelper
+ fullWidth
+ />
+ )}
+ />
+ ))}
+
+
+
+
+
+ {t`Skip`}
+
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/pages/onboarding/SyncEmailsV2.tsx b/packages/twenty-front/src/pages/onboarding/SyncEmailsV2.tsx
index af9b387b59..ef277b8432 100644
--- a/packages/twenty-front/src/pages/onboarding/SyncEmailsV2.tsx
+++ b/packages/twenty-front/src/pages/onboarding/SyncEmailsV2.tsx
@@ -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 (
connectWithProvider(ConnectedAccountProvider.GOOGLE)
diff --git a/packages/twenty-front/src/pages/onboarding/__stories__/InviteTeamV2.stories.tsx b/packages/twenty-front/src/pages/onboarding/__stories__/InviteTeamV2.stories.tsx
new file mode 100644
index 0000000000..74980a21e4
--- /dev/null
+++ b/packages/twenty-front/src/pages/onboarding/__stories__/InviteTeamV2.stories.tsx
@@ -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 = {
+ 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;
+
+export const Default: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement.ownerDocument.body);
+ await canvas.findByText('Invite your team');
+ },
+};
diff --git a/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts b/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts
index 485a0b3cea..1f02879df7 100644
--- a/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts
+++ b/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts
@@ -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,
diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts
index 46327ede86..8897d77300 100644
--- a/packages/twenty-front/src/testing/mock-data/config.ts
+++ b/packages/twenty-front/src/testing/mock-data/config.ts
@@ -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,
diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts
index 270ce944ae..1dc990f132 100644
--- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts
@@ -85,6 +85,11 @@ describe('ClientConfigController', () => {
api: {
mutationMaximumAffectedRecords: 100,
},
+ onboarding: {
+ importContactsCreditsReward: 2,
+ inviteTeamMaxCreditsReward: 9,
+ inviteTeamCreditsRewardPerUser: 3,
+ },
isAttachmentPreviewEnabled: true,
analyticsEnabled: false,
canManageFeatureFlags: true,
diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts
index 03cf88fdaf..a576edebbb 100644
--- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts
+++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts
@@ -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;
diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts
index 8ec1607498..d5ea09a8a7 100644
--- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts
@@ -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,
diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts
index 41678b8f1d..fee2f38342 100644
--- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts
@@ -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',
),
diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts
index 7258f0c4cd..e6d002142d 100644
--- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts
+++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts
@@ -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',
diff --git a/packages/twenty-shared/src/types/AppPath.ts b/packages/twenty-shared/src/types/AppPath.ts
index db44f77c06..2c52555df6 100644
--- a/packages/twenty-shared/src/types/AppPath.ts
+++ b/packages/twenty-shared/src/types/AppPath.ts
@@ -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',