Files
twenty/packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts
T
Raphaël Bosi 59672b71b8 Add a book-a-call onboarding step for qualified leads (#23521)
https://github.com/user-attachments/assets/76d5a14e-53bd-4195-963b-bf9bb265c8c1



Large-company signups either self-serve a small plan or drop off at the
paywall without sales ever seeing them. This adds an embedded Cal.com
booking step to onboarding, shown only to leads worth a call.

The step sits between Invite Team and the plan step: the lead has built
out a workspace by then, and sales gets a chance before checkout. It is
always skippable, and a successful booking advances automatically.

Qualification reuses the employee count from the People Data Labs
enrichment added in #23199. `ONBOARDING_BOOK_CALL_MIN_EMPLOYEE_COUNT`
sets the bar; leaving it unset means the step never appears.
`CALENDAR_BOOKING_PAGE_ID` must also be configured, so the step can
never strand someone on an empty embed.

Enrichment is no longer gated on `IS_ONBOARDING_AI_CHAT_ENABLED`, since
the book-a-call step is now a second consumer of it.
`PEOPLE_DATA_LABS_API_KEY` remains the instance-level switch.

The existing `/book-call` page is reused: it moves into the onboarding
shell and its footer switches between Skip (as a step) and the back link
(when reached from the plan page).


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23521?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. -->
2026-08-05 11:34:06 +00:00

225 lines
6.6 KiB
TypeScript

import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
import { isBookCallOnboardingStepEnabledState } from '@/client-config/states/isBookCallOnboardingStepEnabledState';
import { isCompanyEnrichmentEnabledState } from '@/client-config/states/isCompanyEnrichmentEnabledState';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
import { onboardingFreeCreditsState } from '@/onboarding/states/onboardingFreeCreditsState';
import { waitForCompanyEnrichmentSettlement } from '@/onboarding/utils/waitForCompanyEnrichmentSettlement';
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 { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
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 { useStore } from 'jotai';
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 { 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 { enqueueSuccessSnackBar } = useSnackBar();
const { sendInvitation } = useCreateWorkspaceInvitation();
const setNextOnboardingStatus = useSetNextOnboardingStatus();
const setOnboardingFreeCredits = useSetAtomState(onboardingFreeCreditsState);
const onboardingConfig = useAtomStateValue(onboardingConfigState);
const isBookCallOnboardingStepEnabled = useAtomStateValue(
isBookCallOnboardingStepEnabledState,
);
const isCompanyEnrichmentEnabled = useAtomStateValue(
isCompanyEnrichmentEnabledState,
);
const store = useStore();
const [isNavigating, setIsNavigating] = useState(false);
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 onSubmit: SubmitHandler<InviteTeamFormInput> = useCallback(
async (data) => {
const emails = Array.from(
new Set(
data.emails
.map((emailData) => emailData.email.trim())
.filter((email) => email.length > 0),
),
);
setIsNavigating(true);
try {
// Only wait when enrichment is actually going to run, otherwise the
// settlement never resolves and every submit burns the full timeout.
const companyEnrichmentSettlement =
isBookCallOnboardingStepEnabled && isCompanyEnrichmentEnabled
? waitForCompanyEnrichmentSettlement({ store })
: Promise.resolve();
const result = await sendInvitation({ emails });
if (isDefined(result.error)) {
throw result.error;
}
const creditsRewardPerUser =
onboardingConfig?.inviteTeamCreditsRewardPerUser ?? 0;
setOnboardingFreeCredits((current) => ({
...current,
inviteTeam: emails.length * creditsRewardPerUser,
}));
if (emails.length > 0) {
enqueueSuccessSnackBar({
message: t`Invite link sent to email addresses`,
options: {
duration: 2000,
},
});
}
await companyEnrichmentSettlement;
setNextOnboardingStatus();
} catch (error) {
setIsNavigating(false);
throw error;
}
},
[
enqueueSuccessSnackBar,
isBookCallOnboardingStepEnabled,
isCompanyEnrichmentEnabled,
onboardingConfig?.inviteTeamCreditsRewardPerUser,
sendInvitation,
setNextOnboardingStatus,
setOnboardingFreeCredits,
store,
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,
getPlaceholder,
isValid,
isSubmitting,
isNavigating,
};
};