59672b71b8
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. -->
225 lines
6.6 KiB
TypeScript
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,
|
|
};
|
|
};
|