From db7d8172f7ebad82abcc3e06b11dff6d3e73979c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:53:07 +0200 Subject: [PATCH] Add v2 onboarding invite team page (#22229) CleanShot 2026-06-26 at 18 09
47@2x Adds the v2 onboarding **Invite team** page (`INVITE_TEAM`), shown right after the create-profile step for the onboarding-v2 cohort. It renders full-screen under `BlankLayout` via the shared `OnboardingV2Layout`, matching the Figma (340px column, email inputs with inline remove, dark Invite, Skip). Reuses all v1 invite-team logic via a new `useInviteTeam` hook (v1 `InviteTeam` now consumes it too; its UI is unchanged). Routing mirrors `SyncEmailsV2`/`CreateProfileV2`: new `AppPath.InviteTeamV2`, lazy route, and an `isOnboardingV2`-gated branch in `usePageChangeEffectNavigateLocation` (+ tests and a Storybook story). No backend changes. Review in cubic --- ...sePageChangeEffectNavigateLocation.test.ts | 12 ++ .../usePageChangeEffectNavigateLocation.ts | 4 +- .../effect-components/PageChangeEffect.tsx | 3 +- .../modules/app/hooks/useCreateAppRouter.tsx | 14 ++ .../modules/auth/constants/OnboardingPaths.ts | 1 + .../client-config/hooks/useClientConfig.ts | 4 + .../states/onboardingConfigState.ts | 7 + .../client-config/types/ClientConfig.ts | 2 + .../client-config/types/OnboardingConfig.ts | 5 + .../OnboardingCreditsRewardTag.tsx | 9 +- .../modules/onboarding/hooks/useInviteTeam.ts | 188 ++++++++++++++++++ .../src/pages/onboarding/ImportContacts.tsx | 11 +- .../src/pages/onboarding/InviteTeam.tsx | 172 ++-------------- .../src/pages/onboarding/InviteTeamV2.tsx | 162 +++++++++++++++ .../src/pages/onboarding/SyncEmailsV2.tsx | 3 + .../__stories__/InviteTeamV2.stories.tsx | 49 +++++ .../src/testing/constants/UntestedAppPaths.ts | 1 + .../src/testing/mock-data/config.ts | 5 + .../client-config.controller.spec.ts | 5 + .../client-config/client-config.entity.ts | 10 + .../services/client-config.service.spec.ts | 8 + .../services/client-config.service.ts | 18 ++ .../twenty-config/config-variables.ts | 34 ++++ packages/twenty-shared/src/types/AppPath.ts | 1 + 24 files changed, 559 insertions(+), 169 deletions(-) create mode 100644 packages/twenty-front/src/modules/client-config/states/onboardingConfigState.ts create mode 100644 packages/twenty-front/src/modules/client-config/types/OnboardingConfig.ts create mode 100644 packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts create mode 100644 packages/twenty-front/src/pages/onboarding/InviteTeamV2.tsx create mode 100644 packages/twenty-front/src/pages/onboarding/__stories__/InviteTeamV2.stories.tsx diff --git a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts index 597e08cec9..9cb9f1be46 100644 --- a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts +++ b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts @@ -527,4 +527,16 @@ describe('usePageChangeEffectNavigateLocation — onboarding V2', () => { expect(usePageChangeEffectNavigateLocation()).toBeUndefined(); }); + + it('routes to InviteTeamV2 when onboardingV2 is active and status is INVITE_TEAM', () => { + setupOnboardingV2Case(AppPath.Index, OnboardingStatus.INVITE_TEAM); + + expect(usePageChangeEffectNavigateLocation()).toEqual(AppPath.InviteTeamV2); + }); + + it('does not redirect away from the InviteTeamV2 page', () => { + setupOnboardingV2Case(AppPath.InviteTeamV2, OnboardingStatus.INVITE_TEAM); + + expect(usePageChangeEffectNavigateLocation()).toBeUndefined(); + }); }); diff --git a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts index 60884c0749..7e4381f8f0 100644 --- a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts +++ b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts @@ -147,9 +147,9 @@ export const usePageChangeEffectNavigateLocation = () => { if ( onboardingStatus === OnboardingStatus.INVITE_TEAM && - !isMatchingLocation(location, AppPath.InviteTeam) + !someMatchingLocationOf([AppPath.InviteTeam, AppPath.InviteTeamV2]) ) { - return AppPath.InviteTeam; + return isOnboardingV2 ? AppPath.InviteTeamV2 : AppPath.InviteTeam; } if ( diff --git a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx index c6a2a7c1c3..e8a3d548bb 100644 --- a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx @@ -335,7 +335,8 @@ export const PageChangeEffect = () => { }); break; } - case isMatchingLocation(location, AppPath.InviteTeam): { + case isMatchingLocation(location, AppPath.InviteTeam): + case isMatchingLocation(location, AppPath.InviteTeamV2): { resetFocusStackToFocusItem({ focusStackItem: { focusId: PageFocusId.InviteTeam, diff --git a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx index 26a1f96172..38d051085c 100644 --- a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx +++ b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx @@ -98,6 +98,12 @@ const InviteTeam = lazy(() => })), ); +const InviteTeamV2 = lazy(() => + import('~/pages/onboarding/InviteTeamV2').then((module) => ({ + default: module.InviteTeamV2, + })), +); + const ChooseYourPlan = lazy(() => import('~/pages/onboarding/ChooseYourPlan').then((module) => ({ default: module.ChooseYourPlan, @@ -324,6 +330,14 @@ export const useCreateAppRouter = ( } /> + + + + } + /> { const setCaptcha = useSetAtomState(captchaState); const setApiConfig = useSetAtomState(apiConfigState); + const setOnboardingConfig = useSetAtomState(onboardingConfigState); const setCanManageFeatureFlags = useSetAtomState(canManageFeatureFlagsState); @@ -175,6 +177,7 @@ export const useClientConfig = (): UseClientConfigResult => { }); setApiConfig(clientConfig?.api); + setOnboardingConfig(clientConfig?.onboarding); setDomainConfiguration({ defaultSubdomain: clientConfig?.defaultSubdomain, frontDomain: clientConfig?.frontDomain, @@ -221,6 +224,7 @@ export const useClientConfig = (): UseClientConfigResult => { }, [ setAiModels, setApiConfig, + setOnboardingConfig, setAppVersion, setAuthProviders, setBilling, diff --git a/packages/twenty-front/src/modules/client-config/states/onboardingConfigState.ts b/packages/twenty-front/src/modules/client-config/states/onboardingConfigState.ts new file mode 100644 index 0000000000..6499b546d8 --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/states/onboardingConfigState.ts @@ -0,0 +1,7 @@ +import { type OnboardingConfig } from '@/client-config/types/OnboardingConfig'; +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const onboardingConfigState = createAtomState({ + key: 'onboardingConfigState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts index 0bb5eb2765..c251fb16ae 100644 --- a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts @@ -9,6 +9,7 @@ import { type Sentry, type Support, } from '~/generated-metadata/graphql'; +import { type OnboardingConfig } from '@/client-config/types/OnboardingConfig'; export type ClientConfig = { appVersion?: string; @@ -36,6 +37,7 @@ export type ClientConfig = { isCloudflareIntegrationEnabled: boolean; isClickHouseConfigured: boolean; isWorkspaceSchemaDDLLocked: boolean; + onboarding: OnboardingConfig; publicFeatureFlags: Array; sentry: Sentry; signInPrefilled: boolean; diff --git a/packages/twenty-front/src/modules/client-config/types/OnboardingConfig.ts b/packages/twenty-front/src/modules/client-config/types/OnboardingConfig.ts new file mode 100644 index 0000000000..d27420553b --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/types/OnboardingConfig.ts @@ -0,0 +1,5 @@ +export type OnboardingConfig = { + importContactsCreditsReward: number; + inviteTeamMaxCreditsReward: number; + inviteTeamCreditsRewardPerUser: number; +}; diff --git a/packages/twenty-front/src/modules/onboarding/components/import-contacts/OnboardingCreditsRewardTag.tsx b/packages/twenty-front/src/modules/onboarding/components/import-contacts/OnboardingCreditsRewardTag.tsx index 862b32fa4e..8b775c16fe 100644 --- a/packages/twenty-front/src/modules/onboarding/components/import-contacts/OnboardingCreditsRewardTag.tsx +++ b/packages/twenty-front/src/modules/onboarding/components/import-contacts/OnboardingCreditsRewardTag.tsx @@ -1,5 +1,6 @@ import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; +import { isDefined } from 'twenty-shared/utils'; import { IconCoins } from 'twenty-ui/icon'; import { themeCssVariables, useTheme } from 'twenty-ui/theme-constants'; @@ -29,10 +30,12 @@ const StyledSuffix = styled.span` type OnboardingCreditsRewardTagProps = { amount: number; + perUserAmount?: number; }; export const OnboardingCreditsRewardTag = ({ amount, + perUserAmount, }: OnboardingCreditsRewardTagProps) => { const { t } = useLingui(); const theme = useTheme(); @@ -44,7 +47,11 @@ export const OnboardingCreditsRewardTag = ({ color={themeCssVariables.color.green9} /> {t`Earn +${amount}`} - {t`free credits`} + + {isDefined(perUserAmount) + ? t`free credits (${perUserAmount} per user)` + : t`free credits`} + ); }; diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts b/packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts new file mode 100644 index 0000000000..abf6d7ebe6 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/hooks/useInviteTeam.ts @@ -0,0 +1,188 @@ +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState'; +import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus'; +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 { 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 { 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 { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; +import { z } from 'zod'; + +const validationSchema = z.object({ + emails: z.array(z.object({ email: z.union([z.literal(''), z.email()]) })), +}); + +type InviteTeamFormInput = z.infer; + +export const useInviteTeam = () => { + const { t } = useLingui(); + const { copyToClipboard } = useCopyToClipboard(); + const { enqueueSuccessSnackBar } = useSnackBar(); + const { sendInvitation } = useCreateWorkspaceInvitation(); + const setNextOnboardingStatus = useSetNextOnboardingStatus(); + const currentWorkspace = useAtomStateValue(currentWorkspaceState); + const calendarBookingPageId = useAtomStateValue(calendarBookingPageIdState); + const hasCalendarBooking = isDefined(calendarBookingPageId); + + const { + control, + handleSubmit, + watch, + reset, + formState: { isValid, isSubmitting, isDirty }, + } = useForm({ + 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 copyInviteLink = () => { + if (isDefined(currentWorkspace?.inviteHash)) { + const inviteLink = `${window.location.origin}/invite/${currentWorkspace?.inviteHash}`; + copyToClipboard(inviteLink, t`Link copied to clipboard`); + } + }; + + const onSubmit: SubmitHandler = useCallback( + async (data) => { + const emails = Array.from( + new Set( + data.emails + .map((emailData) => emailData.email.trim()) + .filter((email) => email.length > 0), + ), + ); + + const result = await sendInvitation({ emails }); + + if (isDefined(result.error)) { + throw result.error; + } + + if (emails.length > 0) { + enqueueSuccessSnackBar({ + message: t`Invite link sent to email addresses`, + options: { + duration: 2000, + }, + }); + } + + setNextOnboardingStatus(); + }, + [enqueueSuccessSnackBar, sendInvitation, setNextOnboardingStatus, 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, + copyInviteLink, + getPlaceholder, + hasPrefilledSuggestions, + hasCalendarBooking, + isValid, + isSubmitting, + currentWorkspace, + }; +}; diff --git a/packages/twenty-front/src/pages/onboarding/ImportContacts.tsx b/packages/twenty-front/src/pages/onboarding/ImportContacts.tsx index ccacd5a64c..af521583ca 100644 --- a/packages/twenty-front/src/pages/onboarding/ImportContacts.tsx +++ b/packages/twenty-front/src/pages/onboarding/ImportContacts.tsx @@ -9,7 +9,6 @@ import { MainButton } from 'twenty-ui/input'; import { themeCssVariables, useTheme } from 'twenty-ui/theme-constants'; const CONTENT_BLOCK_WIDTH = 340; -const DEFAULT_CREDITS_REWARD = 2; const StyledPage = styled.div` align-items: center; @@ -93,7 +92,7 @@ type ImportContactsProps = { }; export const ImportContacts = ({ - creditsReward = DEFAULT_CREDITS_REWARD, + creditsReward, onContinueWithGoogle, onContinueWithMicrosoft, onSkip, @@ -108,9 +107,11 @@ export const ImportContacts = ({ {t`Connect your email and calendar to see your entire network instantly. Takes only 30 seconds.`} - - - + {isDefined(creditsReward) && ( + + + + )} diff --git a/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx b/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx index 4657190e63..d856f48508 100644 --- a/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx +++ b/packages/twenty-front/src/pages/onboarding/InviteTeam.tsx @@ -1,36 +1,17 @@ import { SubTitle } from '@/auth/components/SubTitle'; import { Title } from '@/auth/components/Title'; -import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; -import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState'; -import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus'; -import { PageFocusId } from '@/types/PageFocusId'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useInviteTeam } from '@/onboarding/hooks/useInviteTeam'; import { TextInput } from '@/ui/input/components/TextInput'; import { ModalContent } from 'twenty-ui/surfaces'; -import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; import { styled } from '@linaria/react'; -import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, useLingui } from '@lingui/react/macro'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { - Controller, - type SubmitHandler, - useFieldArray, - useForm, -} from 'react-hook-form'; -import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { useQuery } from '@apollo/client/react'; -import { Key } from 'ts-key-enum'; +import { Controller } from 'react-hook-form'; import { isDefined } from 'twenty-shared/utils'; import { IconCopy } from 'twenty-ui/icon'; import { SeparatorLineText } from 'twenty-ui/typography'; import { LightButton, MainButton } from 'twenty-ui/input'; import { ClickToActionLink } from 'twenty-ui/navigation'; import { themeCssVariables } from 'twenty-ui/theme-constants'; -import { z } from 'zod'; -import { GetInviteSuggestionsDocument } from '~/generated-metadata/graphql'; -import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; -import { useCreateWorkspaceInvitation } from '@/workspace-invitation/hooks/useCreateWorkspaceInvitation'; const StyledAnimatedContainer = styled.div` display: flex; @@ -57,151 +38,22 @@ const StyledActionSkipLinkContainer = styled.div` margin: ${themeCssVariables.spacing[3]} 0 0; `; -const validationSchema = z.object({ - emails: z.array(z.object({ email: z.union([z.literal(''), z.email()]) })), -}); - -type FormInput = z.infer; - export const InviteTeam = () => { const { t } = useLingui(); - const { copyToClipboard } = useCopyToClipboard(); - const { enqueueSuccessSnackBar } = useSnackBar(); - const { sendInvitation } = useCreateWorkspaceInvitation(); - const setNextOnboardingStatus = useSetNextOnboardingStatus(); - const currentWorkspace = useAtomStateValue(currentWorkspaceState); - const calendarBookingPageId = useAtomStateValue(calendarBookingPageIdState); - const hasCalendarBooking = isDefined(calendarBookingPageId); - const { control, + fields, handleSubmit, - watch, - reset, - formState: { isValid, isSubmitting, isDirty }, - } = useForm({ - 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: '' }, - ], - }); - }, [ + onSubmit, + handleSkip, + copyInviteLink, + getPlaceholder, hasPrefilledSuggestions, - hasInviteSuggestions, - inviteSuggestions, - isDirty, - reset, - ]); - - 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); - } - }); - - 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 copyInviteLink = () => { - if (isDefined(currentWorkspace?.inviteHash)) { - const inviteLink = `${window.location.origin}/invite/${currentWorkspace?.inviteHash}`; - copyToClipboard(inviteLink, t`Link copied to clipboard`); - } - }; - - const onSubmit: SubmitHandler = useCallback( - async (data) => { - const emails = Array.from( - new Set( - data.emails - .map((emailData) => emailData.email.trim()) - .filter((email) => email.length > 0), - ), - ); - - const result = await sendInvitation({ emails }); - - if (isDefined(result.error)) { - throw result.error; - } - - if (emails.length > 0) { - enqueueSuccessSnackBar({ - message: t`Invite link sent to email addresses`, - options: { - duration: 2000, - }, - }); - } - - setNextOnboardingStatus(); - }, - [enqueueSuccessSnackBar, sendInvitation, setNextOnboardingStatus, t], - ); - - const handleSkip = async () => { - await onSubmit({ emails: [] }); - }; - - useHotkeysOnFocusedElement({ - keys: Key.Enter, - callback: () => { - handleSubmit(onSubmit)(); - }, - focusId: PageFocusId.InviteTeam, - dependencies: [handleSubmit, onSubmit], - }); + hasCalendarBooking, + isValid, + isSubmitting, + currentWorkspace, + } = useInviteTeam(); return ( diff --git a/packages/twenty-front/src/pages/onboarding/InviteTeamV2.tsx b/packages/twenty-front/src/pages/onboarding/InviteTeamV2.tsx new file mode 100644 index 0000000000..07552a7929 --- /dev/null +++ b/packages/twenty-front/src/pages/onboarding/InviteTeamV2.tsx @@ -0,0 +1,162 @@ +import { onboardingConfigState } from '@/client-config/states/onboardingConfigState'; +import { OnboardingCreditsRewardTag } from '@/onboarding/components/import-contacts/OnboardingCreditsRewardTag'; +import { OnboardingV2Layout } from '@/onboarding/components/OnboardingV2Layout'; +import { useInviteTeam } from '@/onboarding/hooks/useInviteTeam'; +import { TextInput } from '@/ui/input/components/TextInput'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { Controller } from 'react-hook-form'; +import { isDefined } from 'twenty-shared/utils'; +import { IconX } from 'twenty-ui/icon'; +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; + background-color: ${themeCssVariables.background.secondary}; + box-sizing: border-box; + display: flex; + flex: 1 1 0; + flex-direction: column; + gap: ${themeCssVariables.spacing[14]}; + min-height: 0; + overflow-y: auto; + padding: ${themeCssVariables.spacing[16]} ${themeCssVariables.spacing[8]}; + width: 100%; +`; + +const StyledHeading = styled.div` + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[4]}; + width: ${CONTENT_BLOCK_WIDTH}px; +`; + +const StyledTitle = styled.h1` + color: ${themeCssVariables.font.color.primary}; + font-size: ${themeCssVariables.font.size.xl}; + font-weight: ${themeCssVariables.font.weight.semiBold}; + margin: 0; +`; + +const StyledSubtitle = styled.p` + color: ${themeCssVariables.font.color.secondary}; + font-size: ${themeCssVariables.font.size.md}; + margin: 0; +`; + +const StyledCreditsRow = styled.div` + display: flex; + padding-top: ${themeCssVariables.spacing[1]}; +`; + +const StyledForm = styled.div` + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[4]}; + width: ${CONTENT_BLOCK_WIDTH}px; +`; + +const StyledFooter = styled.div` + align-items: flex-end; + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[4]}; + width: ${CONTENT_BLOCK_WIDTH}px; +`; + +const StyledSkipButton = styled.button` + background-color: transparent; + border: 1px solid ${themeCssVariables.border.color.light}; + border-radius: ${themeCssVariables.border.radius.md}; + color: ${themeCssVariables.font.color.tertiary}; + cursor: pointer; + font-family: ${themeCssVariables.font.family}; + font-size: ${themeCssVariables.font.size.md}; + font-weight: ${themeCssVariables.font.weight.semiBold}; + height: ${themeCssVariables.spacing[8]}; + padding: 0 ${themeCssVariables.spacing[5]}; +`; + +export const InviteTeamV2 = () => { + const { t } = useLingui(); + const { + control, + fields, + remove, + handleSubmit, + onSubmit, + handleSkip, + getPlaceholder, + isValid, + isSubmitting, + } = useInviteTeam(); + const onboardingConfig = useAtomStateValue(onboardingConfigState); + const creditsReward = onboardingConfig?.inviteTeamMaxCreditsReward; + const creditsRewardPerUser = onboardingConfig?.inviteTeamCreditsRewardPerUser; + + return ( + + + + {t`Invite your team`} + + {t`Get the most out of your workspace by inviting your team.`} + + {isDefined(creditsReward) && ( + + + + )} + + + + {fields.map((field, index) => ( + ( + remove(index)} + noErrorHelper + fullWidth + /> + )} + /> + ))} + + + + + + {t`Skip`} + + + + + ); +}; diff --git a/packages/twenty-front/src/pages/onboarding/SyncEmailsV2.tsx b/packages/twenty-front/src/pages/onboarding/SyncEmailsV2.tsx index af9b387b59..ef277b8432 100644 --- a/packages/twenty-front/src/pages/onboarding/SyncEmailsV2.tsx +++ b/packages/twenty-front/src/pages/onboarding/SyncEmailsV2.tsx @@ -3,6 +3,7 @@ import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCal import { isGoogleMessagingEnabledState } from '@/client-config/states/isGoogleMessagingEnabledState'; import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicrosoftCalendarEnabledState'; import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState'; +import { onboardingConfigState } from '@/client-config/states/onboardingConfigState'; import { OnboardingV2Layout } from '@/onboarding/components/OnboardingV2Layout'; import { SyncEmailsAutoSkipEffect } from '@/onboarding/effect-components/SyncEmailsAutoSkipEffect'; import { useSkipSyncEmailOnboardingStep } from '@/onboarding/hooks/useSkipSyncEmailOnboardingStep'; @@ -45,6 +46,7 @@ export const SyncEmailsV2 = () => { const isClientConfigLoaded = useAtomStateValue( clientConfigApiStatusState, ).isLoadedOnce; + const onboardingConfig = useAtomStateValue(onboardingConfigState); const connectWithProvider = (provider: ConnectedAccountProvider) => triggerApisOAuth(provider, { @@ -69,6 +71,7 @@ export const SyncEmailsV2 = () => { return ( connectWithProvider(ConnectedAccountProvider.GOOGLE) diff --git a/packages/twenty-front/src/pages/onboarding/__stories__/InviteTeamV2.stories.tsx b/packages/twenty-front/src/pages/onboarding/__stories__/InviteTeamV2.stories.tsx new file mode 100644 index 0000000000..74980a21e4 --- /dev/null +++ b/packages/twenty-front/src/pages/onboarding/__stories__/InviteTeamV2.stories.tsx @@ -0,0 +1,49 @@ +import { getOperationName } from '~/utils/getOperationName'; +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { HttpResponse, graphql } from 'msw'; +import { within } from 'storybook/test'; +import { AppPath } from 'twenty-shared/types'; + +import { OnboardingStatus } from '~/generated-metadata/graphql'; +import { GET_CURRENT_USER } from '~/modules/users/graphql/queries/getCurrentUser'; +import { InviteTeamV2 } from '~/pages/onboarding/InviteTeamV2'; +import { + PageDecorator, + type PageDecoratorArgs, +} from '~/testing/decorators/PageDecorator'; +import { graphqlMocks } from '~/testing/graphqlMocks'; +import { mockedOnboardingUserData } from '~/testing/mock-data/users'; + +const meta: Meta = { + title: 'Pages/Onboarding/InviteTeamV2', + component: InviteTeamV2, + decorators: [PageDecorator], + args: { routePath: AppPath.InviteTeamV2 }, + parameters: { + msw: { + handlers: [ + graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => { + return HttpResponse.json({ + data: { + currentUser: mockedOnboardingUserData( + OnboardingStatus.INVITE_TEAM, + ), + }, + }); + }), + graphqlMocks.handlers, + ], + }, + }, +}; + +export default meta; + +export type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement.ownerDocument.body); + await canvas.findByText('Invite your team'); + }, +}; diff --git a/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts b/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts index 485a0b3cea..1f02879df7 100644 --- a/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts +++ b/packages/twenty-front/src/testing/constants/UntestedAppPaths.ts @@ -6,6 +6,7 @@ export const UNTESTED_APP_PATHS = [ AppPath.WorkspaceActivationV2, AppPath.CreateProfileV2, AppPath.SyncEmailsV2, + AppPath.InviteTeamV2, // Public, unauthenticated redirect route handled in useCreateAppRouter — not // part of the onboarding/auth page-change navigation matrix. AppPath.Dpa, diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index 46327ede86..8897d77300 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -44,6 +44,11 @@ export const mockedClientConfig: ClientConfig = { siteKey: 'MOCKED_SITE_KEY', }, api: { mutationMaximumAffectedRecords: 100 }, + onboarding: { + importContactsCreditsReward: 2, + inviteTeamMaxCreditsReward: 9, + inviteTeamCreditsRewardPerUser: 3, + }, canManageFeatureFlags: true, publicFeatureFlags: [], isMicrosoftMessagingEnabled: true, diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts index 270ce944ae..1dc990f132 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts @@ -85,6 +85,11 @@ describe('ClientConfigController', () => { api: { mutationMaximumAffectedRecords: 100, }, + onboarding: { + importContactsCreditsReward: 2, + inviteTeamMaxCreditsReward: 9, + inviteTeamCreditsRewardPerUser: 3, + }, isAttachmentPreviewEnabled: true, analyticsEnabled: false, canManageFeatureFlags: true, diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index 03cf88fdaf..a576edebbb 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -208,6 +208,14 @@ export class ApiConfig { mutationMaximumAffectedRecords: number; } +export class OnboardingConfig { + importContactsCreditsReward: number; + + inviteTeamMaxCreditsReward: number; + + inviteTeamCreditsRewardPerUser: number; +} + @ObjectType() export class PublicFeatureFlagMetadata { @Field(() => String) @@ -291,6 +299,8 @@ export class ClientConfig { @Field(() => ApiConfig) api: ApiConfig; + onboarding: OnboardingConfig; + @Field(() => Boolean) canManageFeatureFlags: boolean; diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts index 8ec1607498..d5ea09a8a7 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts @@ -92,6 +92,9 @@ describe('ClientConfigService', () => { CAPTCHA_DRIVER: CaptchaDriverType.GOOGLE_RECAPTCHA, CAPTCHA_SITE_KEY: 'site-key-123', MUTATION_MAXIMUM_AFFECTED_RECORDS: 1000, + ONBOARDING_IMPORT_CONTACTS_CREDITS_REWARD: 2_000_000, + ONBOARDING_INVITE_TEAM_MAX_CREDITS_REWARD: 9_000_000, + ONBOARDING_INVITE_TEAM_CREDITS_REWARD_PER_USER: 3_000_000, IS_ATTACHMENT_PREVIEW_ENABLED: true, ANALYTICS_ENABLED: true, MESSAGING_PROVIDER_MICROSOFT_ENABLED: false, @@ -165,6 +168,11 @@ describe('ClientConfigService', () => { api: { mutationMaximumAffectedRecords: 1000, }, + onboarding: { + importContactsCreditsReward: 2, + inviteTeamMaxCreditsReward: 9, + inviteTeamCreditsRewardPerUser: 3, + }, isAttachmentPreviewEnabled: true, analyticsEnabled: true, canManageFeatureFlags: true, diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts index 41678b8f1d..fee2f38342 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts @@ -15,6 +15,7 @@ import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type'; import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util'; import { AUTO_SELECT_FAST_MODEL_ID, AUTO_SELECT_SMART_MODEL_ID, @@ -218,6 +219,23 @@ export class ClientConfigService { 'MUTATION_MAXIMUM_AFFECTED_RECORDS', ), }, + onboarding: { + importContactsCreditsReward: toDisplayCredits( + this.twentyConfigService.get( + 'ONBOARDING_IMPORT_CONTACTS_CREDITS_REWARD', + ), + ), + inviteTeamMaxCreditsReward: toDisplayCredits( + this.twentyConfigService.get( + 'ONBOARDING_INVITE_TEAM_MAX_CREDITS_REWARD', + ), + ), + inviteTeamCreditsRewardPerUser: toDisplayCredits( + this.twentyConfigService.get( + 'ONBOARDING_INVITE_TEAM_CREDITS_REWARD_PER_USER', + ), + ), + }, isAttachmentPreviewEnabled: this.twentyConfigService.get( 'IS_ATTACHMENT_PREVIEW_ENABLED', ), diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 7258f0c4cd..e6d002142d 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -5,6 +5,7 @@ import { IsDateString, IsDefined, IsEnum, + IsInt, IsNotEmpty, IsOptional, IsString, @@ -932,6 +933,39 @@ export class ConfigVariables { @IsOptional() BILLING_USAGE_CAP_CLICKHOUSE_ENABLED = false; + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.BILLING_CONFIG, + description: + 'Free credits granted for completing the import-contacts onboarding step (in microCredits)', + type: ConfigVariableType.NUMBER, + }) + @CastToPositiveNumber() + @IsInt() + @IsOptional() + ONBOARDING_IMPORT_CONTACTS_CREDITS_REWARD = 2_000_000; + + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.BILLING_CONFIG, + description: + 'Maximum free credits granted for completing the invite-team onboarding step (in microCredits)', + type: ConfigVariableType.NUMBER, + }) + @CastToPositiveNumber() + @IsInt() + @IsOptional() + ONBOARDING_INVITE_TEAM_MAX_CREDITS_REWARD = 9_000_000; + + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.BILLING_CONFIG, + description: + 'Free credits granted per user invited during the invite-team onboarding step (in microCredits)', + type: ConfigVariableType.NUMBER, + }) + @CastToPositiveNumber() + @IsInt() + @IsOptional() + ONBOARDING_INVITE_TEAM_CREDITS_REWARD_PER_USER = 3_000_000; + @ConfigVariablesMetadata({ group: ConfigVariablesGroup.SERVER_CONFIG, description: 'Url for the frontend application', diff --git a/packages/twenty-shared/src/types/AppPath.ts b/packages/twenty-shared/src/types/AppPath.ts index db44f77c06..2c52555df6 100644 --- a/packages/twenty-shared/src/types/AppPath.ts +++ b/packages/twenty-shared/src/types/AppPath.ts @@ -15,6 +15,7 @@ export enum AppPath { SyncEmails = '/sync/emails', SyncEmailsV2 = '/sync/emails-v2', InviteTeam = '/invite-team', + InviteTeamV2 = '/invite-team-v2', PlanRequired = '/plan-required', PlanRequiredSuccess = '/plan-required/payment-success', BookCallDecision = '/book-call-decision',