Track total onboarding free credits in an atom (#22348)
The v2 onboarding header shows a "free credits" counter, but every page
fed it a hard-coded `0`, so it never reflected the credits the workspace
would actually receive. This tracks the running total based on the
choices made at each step.
- New `onboardingFreeCreditsState` atom (`{ importContacts, inviteTeam
}`, localStorage-backed) + `useOnboardingFreeCreditsTotal` to sum it
into the header.
- Connecting email sets the import-contacts reward (persisted so it
survives the OAuth redirect); inviting teammates sets `min(count ×
perUser, max)` on submit. Skipping a step contributes 0; the atom resets
at onboarding start.
- Counter scope is import-contacts + invite-team rewards only
(display-credit units already exposed via `onboardingConfigState`).
Plan/trial credits are out of scope.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22348?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:
@@ -1,10 +1,13 @@
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
|
||||
import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
|
||||
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
|
||||
import { onboardingFreeCreditsState } from '@/onboarding/states/onboardingFreeCreditsState';
|
||||
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';
|
||||
@@ -29,6 +32,8 @@ export const useInviteTeam = () => {
|
||||
const { enqueueSuccessSnackBar } = useSnackBar();
|
||||
const { sendInvitation } = useCreateWorkspaceInvitation();
|
||||
const setNextOnboardingStatus = useSetNextOnboardingStatus();
|
||||
const setOnboardingFreeCredits = useSetAtomState(onboardingFreeCreditsState);
|
||||
const onboardingConfig = useAtomStateValue(onboardingConfigState);
|
||||
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
|
||||
const calendarBookingPageId = useAtomStateValue(calendarBookingPageIdState);
|
||||
const hasCalendarBooking = isDefined(calendarBookingPageId);
|
||||
@@ -143,6 +148,19 @@ export const useInviteTeam = () => {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
const creditsRewardPerUser =
|
||||
onboardingConfig?.inviteTeamCreditsRewardPerUser ?? 0;
|
||||
const maxCreditsReward =
|
||||
onboardingConfig?.inviteTeamMaxCreditsReward ?? 0;
|
||||
|
||||
setOnboardingFreeCredits((current) => ({
|
||||
...current,
|
||||
inviteTeam: Math.min(
|
||||
emails.length * creditsRewardPerUser,
|
||||
maxCreditsReward,
|
||||
),
|
||||
}));
|
||||
|
||||
if (emails.length > 0) {
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Invite link sent to email addresses`,
|
||||
@@ -154,7 +172,15 @@ export const useInviteTeam = () => {
|
||||
|
||||
setNextOnboardingStatus();
|
||||
},
|
||||
[enqueueSuccessSnackBar, sendInvitation, setNextOnboardingStatus, t],
|
||||
[
|
||||
enqueueSuccessSnackBar,
|
||||
onboardingConfig?.inviteTeamCreditsRewardPerUser,
|
||||
onboardingConfig?.inviteTeamMaxCreditsReward,
|
||||
sendInvitation,
|
||||
setNextOnboardingStatus,
|
||||
setOnboardingFreeCredits,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSkip = async () => {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { onboardingFreeCreditsState } from '@/onboarding/states/onboardingFreeCreditsState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
|
||||
export const useOnboardingFreeCreditsTotal = () => {
|
||||
const onboardingFreeCredits = useAtomStateValue(onboardingFreeCreditsState);
|
||||
|
||||
return (
|
||||
onboardingFreeCredits.importContacts + onboardingFreeCredits.inviteTeam
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type OnboardingFreeCredits } from '@/onboarding/types/OnboardingFreeCredits';
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const onboardingFreeCreditsState =
|
||||
createAtomState<OnboardingFreeCredits>({
|
||||
key: 'onboardingFreeCreditsState',
|
||||
defaultValue: { importContacts: 0, inviteTeam: 0 },
|
||||
useLocalStorage: true,
|
||||
localStorageOptions: { getOnInit: true },
|
||||
validateInitFn: (payload) =>
|
||||
Number.isFinite(payload.importContacts) &&
|
||||
Number.isFinite(payload.inviteTeam),
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export type OnboardingFreeCredits = {
|
||||
importContacts: number;
|
||||
inviteTeam: number;
|
||||
};
|
||||
@@ -1,14 +1,13 @@
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
|
||||
import { OnboardingV2Layout } from '@/onboarding/components/OnboardingV2Layout';
|
||||
import { useOnboardingFreeCreditsTotal } from '@/onboarding/hooks/useOnboardingFreeCreditsTotal';
|
||||
import { usePlans } from '@/settings/billing/hooks/usePlans';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { UpgradeFreeTrial } from '~/pages/onboarding/UpgradeFreeTrial';
|
||||
|
||||
const UPGRADE_TRIAL_FREE_CREDITS = 0;
|
||||
|
||||
const StyledPlaceholder = styled.div`
|
||||
flex: 1 1 0;
|
||||
`;
|
||||
@@ -17,9 +16,10 @@ export const ChooseYourPlanV2 = () => {
|
||||
const { isPlansLoaded } = usePlans();
|
||||
const billing = useAtomStateValue(billingState);
|
||||
const onboardingConfig = useAtomStateValue(onboardingConfigState);
|
||||
const freeCreditsTotal = useOnboardingFreeCreditsTotal();
|
||||
|
||||
return (
|
||||
<OnboardingV2Layout freeCredits={UPGRADE_TRIAL_FREE_CREDITS}>
|
||||
<OnboardingV2Layout freeCredits={freeCreditsTotal}>
|
||||
{isDefined(billing) && isPlansLoaded ? (
|
||||
<UpgradeFreeTrial
|
||||
billing={billing}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
|
||||
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState';
|
||||
import { OnboardingProfilePictureUploader } from '@/onboarding/components/OnboardingProfilePictureUploader';
|
||||
import { OnboardingV2Layout } from '@/onboarding/components/OnboardingV2Layout';
|
||||
import { useOnboardingFreeCreditsTotal } from '@/onboarding/hooks/useOnboardingFreeCreditsTotal';
|
||||
import { usePrefetchInviteSuggestions } from '@/onboarding/hooks/usePrefetchInviteSuggestions';
|
||||
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
|
||||
import { useUpdateWorkspaceMemberSettings } from '@/settings/profile/hooks/useUpdateWorkspaceMemberSettings';
|
||||
@@ -104,6 +105,7 @@ type Form = z.infer<typeof validationSchema>;
|
||||
export const CreateProfileV2 = () => {
|
||||
const { t } = useLingui();
|
||||
const setNextOnboardingStatus = useSetNextOnboardingStatus();
|
||||
const freeCreditsTotal = useOnboardingFreeCreditsTotal();
|
||||
|
||||
usePrefetchInviteSuggestions();
|
||||
|
||||
@@ -211,7 +213,7 @@ export const CreateProfileV2 = () => {
|
||||
});
|
||||
|
||||
return (
|
||||
<OnboardingV2Layout>
|
||||
<OnboardingV2Layout freeCredits={freeCreditsTotal}>
|
||||
<StyledContent>
|
||||
<StyledHeading>
|
||||
<StyledTitle>{t`Create profile`}</StyledTitle>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { onboardingConfigState } from '@/client-config/states/onboardingConfigSt
|
||||
import { OnboardingCreditsRewardTag } from '@/onboarding/components/import-contacts/OnboardingCreditsRewardTag';
|
||||
import { OnboardingV2Layout } from '@/onboarding/components/OnboardingV2Layout';
|
||||
import { useInviteTeam } from '@/onboarding/hooks/useInviteTeam';
|
||||
import { useOnboardingFreeCreditsTotal } from '@/onboarding/hooks/useOnboardingFreeCreditsTotal';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
@@ -13,7 +14,6 @@ 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;
|
||||
@@ -98,9 +98,10 @@ export const InviteTeamV2 = () => {
|
||||
const onboardingConfig = useAtomStateValue(onboardingConfigState);
|
||||
const creditsReward = onboardingConfig?.inviteTeamMaxCreditsReward;
|
||||
const creditsRewardPerUser = onboardingConfig?.inviteTeamCreditsRewardPerUser;
|
||||
const freeCreditsTotal = useOnboardingFreeCreditsTotal();
|
||||
|
||||
return (
|
||||
<OnboardingV2Layout freeCredits={INVITE_TEAM_FREE_CREDITS}>
|
||||
<OnboardingV2Layout freeCredits={freeCreditsTotal}>
|
||||
<StyledPage>
|
||||
<StyledHeading>
|
||||
<StyledTitle>{t`Invite your team`}</StyledTitle>
|
||||
|
||||
@@ -6,9 +6,12 @@ import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicro
|
||||
import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
|
||||
import { OnboardingV2Layout } from '@/onboarding/components/OnboardingV2Layout';
|
||||
import { SyncEmailsAutoSkipEffect } from '@/onboarding/effect-components/SyncEmailsAutoSkipEffect';
|
||||
import { useOnboardingFreeCreditsTotal } from '@/onboarding/hooks/useOnboardingFreeCreditsTotal';
|
||||
import { useSkipSyncEmailOnboardingStep } from '@/onboarding/hooks/useSkipSyncEmailOnboardingStep';
|
||||
import { onboardingFreeCreditsState } from '@/onboarding/states/onboardingFreeCreditsState';
|
||||
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { AppPath, ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
import { ImportContacts } from '~/pages/onboarding/ImportContacts';
|
||||
@@ -17,11 +20,11 @@ import {
|
||||
MessageChannelVisibility,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
const IMPORT_CONTACTS_FREE_CREDITS = 0;
|
||||
|
||||
export const SyncEmailsV2 = () => {
|
||||
const { triggerApisOAuth } = useTriggerApisOAuth();
|
||||
const skipSyncEmailOnboardingStep = useSkipSyncEmailOnboardingStep();
|
||||
const setOnboardingFreeCredits = useSetAtomState(onboardingFreeCreditsState);
|
||||
const freeCreditsTotal = useOnboardingFreeCreditsTotal();
|
||||
const [hasAutoSkipFailed, setHasAutoSkipFailed] = useState(false);
|
||||
|
||||
const isGoogleMessagingEnabled = useAtomStateValue(
|
||||
@@ -48,13 +51,37 @@ export const SyncEmailsV2 = () => {
|
||||
).isLoadedOnce;
|
||||
const onboardingConfig = useAtomStateValue(onboardingConfigState);
|
||||
|
||||
const connectWithProvider = (provider: ConnectedAccountProvider) =>
|
||||
triggerApisOAuth(provider, {
|
||||
redirectLocation: AppPath.Index,
|
||||
messageVisibility: MessageChannelVisibility.METADATA,
|
||||
calendarVisibility: CalendarChannelVisibility.METADATA,
|
||||
skipMessageChannelConfiguration: true,
|
||||
});
|
||||
const connectWithProvider = async (provider: ConnectedAccountProvider) => {
|
||||
setOnboardingFreeCredits((current) => ({
|
||||
...current,
|
||||
importContacts: onboardingConfig?.importContactsCreditsReward ?? 0,
|
||||
}));
|
||||
|
||||
try {
|
||||
await triggerApisOAuth(provider, {
|
||||
redirectLocation: AppPath.Index,
|
||||
messageVisibility: MessageChannelVisibility.METADATA,
|
||||
calendarVisibility: CalendarChannelVisibility.METADATA,
|
||||
skipMessageChannelConfiguration: true,
|
||||
});
|
||||
} catch (error) {
|
||||
setOnboardingFreeCredits((current) => ({
|
||||
...current,
|
||||
importContacts: 0,
|
||||
}));
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
setOnboardingFreeCredits((current) => ({
|
||||
...current,
|
||||
importContacts: 0,
|
||||
}));
|
||||
|
||||
return skipSyncEmailOnboardingStep();
|
||||
};
|
||||
|
||||
const handleAutoSkipError = useCallback(() => {
|
||||
setHasAutoSkipFailed(true);
|
||||
@@ -69,7 +96,7 @@ export const SyncEmailsV2 = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingV2Layout freeCredits={IMPORT_CONTACTS_FREE_CREDITS}>
|
||||
<OnboardingV2Layout freeCredits={freeCreditsTotal}>
|
||||
<ImportContacts
|
||||
creditsReward={onboardingConfig?.importContactsCreditsReward}
|
||||
onContinueWithGoogle={
|
||||
@@ -82,7 +109,7 @@ export const SyncEmailsV2 = () => {
|
||||
? () => connectWithProvider(ConnectedAccountProvider.MICROSOFT)
|
||||
: undefined
|
||||
}
|
||||
onSkip={skipSyncEmailOnboardingStep}
|
||||
onSkip={handleSkip}
|
||||
/>
|
||||
</OnboardingV2Layout>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SignInUpWorkspaceCreationLoader } from '@/auth/sign-in-up/components/Si
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { isCreatingWorkspaceState } from '@/auth/states/isCreatingWorkspaceState';
|
||||
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
|
||||
import { onboardingFreeCreditsState } from '@/onboarding/states/onboardingFreeCreditsState';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
@@ -47,6 +48,7 @@ export const WorkspaceActivationV2 = () => {
|
||||
const [hasFailed, setHasFailed] = useState(false);
|
||||
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
|
||||
const setIsCreatingWorkspace = useSetAtomState(isCreatingWorkspaceState);
|
||||
const setOnboardingFreeCredits = useSetAtomState(onboardingFreeCreditsState);
|
||||
|
||||
const activate = useCallback(async () => {
|
||||
setHasFailed(false);
|
||||
@@ -92,8 +94,9 @@ export const WorkspaceActivationV2 = () => {
|
||||
}
|
||||
|
||||
hasTriggeredRef.current = true;
|
||||
setOnboardingFreeCredits({ importContacts: 0, inviteTeam: 0 });
|
||||
void activate();
|
||||
}, [activate, currentWorkspace]);
|
||||
}, [activate, currentWorkspace, setOnboardingFreeCredits]);
|
||||
|
||||
if (!hasFailed) {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user