From 96e5d0f3ed4a3dbe02eb612c6e6a1788164bf7c9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?=
<71827178+bosiraphael@users.noreply.github.com>
Date: Tue, 30 Jun 2026 14:20:50 +0200
Subject: [PATCH] Track total onboarding free credits in an atom (#22348)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../modules/onboarding/hooks/useInviteTeam.ts | 28 ++++++++++-
.../hooks/useOnboardingFreeCreditsTotal.ts | 10 ++++
.../states/onboardingFreeCreditsState.ts | 13 +++++
.../onboarding/types/OnboardingFreeCredits.ts | 4 ++
.../src/pages/onboarding/ChooseYourPlanV2.tsx | 6 +--
.../src/pages/onboarding/CreateProfileV2.tsx | 4 +-
.../src/pages/onboarding/InviteTeamV2.tsx | 5 +-
.../src/pages/onboarding/SyncEmailsV2.tsx | 49 ++++++++++++++-----
.../onboarding/WorkspaceActivationV2.tsx | 5 +-
9 files changed, 105 insertions(+), 19 deletions(-)
create mode 100644 packages/twenty-front/src/modules/onboarding/hooks/useOnboardingFreeCreditsTotal.ts
create mode 100644 packages/twenty-front/src/modules/onboarding/states/onboardingFreeCreditsState.ts
create mode 100644 packages/twenty-front/src/modules/onboarding/types/OnboardingFreeCredits.ts
diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts b/packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts
index abf6d7ebe6..f815bcf050 100644
--- a/packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts
+++ b/packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts
@@ -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 () => {
diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useOnboardingFreeCreditsTotal.ts b/packages/twenty-front/src/modules/onboarding/hooks/useOnboardingFreeCreditsTotal.ts
new file mode 100644
index 0000000000..51fb750147
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/hooks/useOnboardingFreeCreditsTotal.ts
@@ -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
+ );
+};
diff --git a/packages/twenty-front/src/modules/onboarding/states/onboardingFreeCreditsState.ts b/packages/twenty-front/src/modules/onboarding/states/onboardingFreeCreditsState.ts
new file mode 100644
index 0000000000..6292d2afef
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/states/onboardingFreeCreditsState.ts
@@ -0,0 +1,13 @@
+import { type OnboardingFreeCredits } from '@/onboarding/types/OnboardingFreeCredits';
+import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
+
+export const onboardingFreeCreditsState =
+ createAtomState({
+ key: 'onboardingFreeCreditsState',
+ defaultValue: { importContacts: 0, inviteTeam: 0 },
+ useLocalStorage: true,
+ localStorageOptions: { getOnInit: true },
+ validateInitFn: (payload) =>
+ Number.isFinite(payload.importContacts) &&
+ Number.isFinite(payload.inviteTeam),
+ });
diff --git a/packages/twenty-front/src/modules/onboarding/types/OnboardingFreeCredits.ts b/packages/twenty-front/src/modules/onboarding/types/OnboardingFreeCredits.ts
new file mode 100644
index 0000000000..05fa6a4648
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/types/OnboardingFreeCredits.ts
@@ -0,0 +1,4 @@
+export type OnboardingFreeCredits = {
+ importContacts: number;
+ inviteTeam: number;
+};
diff --git a/packages/twenty-front/src/pages/onboarding/ChooseYourPlanV2.tsx b/packages/twenty-front/src/pages/onboarding/ChooseYourPlanV2.tsx
index fc1e625794..4604b24b08 100644
--- a/packages/twenty-front/src/pages/onboarding/ChooseYourPlanV2.tsx
+++ b/packages/twenty-front/src/pages/onboarding/ChooseYourPlanV2.tsx
@@ -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 (
-
+
{isDefined(billing) && isPlansLoaded ? (
;
export const CreateProfileV2 = () => {
const { t } = useLingui();
const setNextOnboardingStatus = useSetNextOnboardingStatus();
+ const freeCreditsTotal = useOnboardingFreeCreditsTotal();
usePrefetchInviteSuggestions();
@@ -211,7 +213,7 @@ export const CreateProfileV2 = () => {
});
return (
-
+
{t`Create profile`}
diff --git a/packages/twenty-front/src/pages/onboarding/InviteTeamV2.tsx b/packages/twenty-front/src/pages/onboarding/InviteTeamV2.tsx
index 07552a7929..ea6ae88aac 100644
--- a/packages/twenty-front/src/pages/onboarding/InviteTeamV2.tsx
+++ b/packages/twenty-front/src/pages/onboarding/InviteTeamV2.tsx
@@ -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 (
-
+
{t`Invite your team`}
diff --git a/packages/twenty-front/src/pages/onboarding/SyncEmailsV2.tsx b/packages/twenty-front/src/pages/onboarding/SyncEmailsV2.tsx
index ef277b8432..3cc25660bd 100644
--- a/packages/twenty-front/src/pages/onboarding/SyncEmailsV2.tsx
+++ b/packages/twenty-front/src/pages/onboarding/SyncEmailsV2.tsx
@@ -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 (
-
+
{
? () => connectWithProvider(ConnectedAccountProvider.MICROSOFT)
: undefined
}
- onSkip={skipSyncEmailOnboardingStep}
+ onSkip={handleSkip}
/>
);
diff --git a/packages/twenty-front/src/pages/onboarding/WorkspaceActivationV2.tsx b/packages/twenty-front/src/pages/onboarding/WorkspaceActivationV2.tsx
index d4c7207b90..b2dc4b6b43 100644
--- a/packages/twenty-front/src/pages/onboarding/WorkspaceActivationV2.tsx
+++ b/packages/twenty-front/src/pages/onboarding/WorkspaceActivationV2.tsx
@@ -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 (