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. Review in cubic --- .../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 (