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