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:
+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