diff --git a/packages/twenty-front/src/App.tsx b/packages/twenty-front/src/App.tsx
index c892a64025..1725a277c8 100644
--- a/packages/twenty-front/src/App.tsx
+++ b/packages/twenty-front/src/App.tsx
@@ -7,9 +7,11 @@ import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { DefaultPageTitle } from '~/DefaultPageTitle';
import { CommandMenuEffect } from '~/effect-components/CommandMenuEffect';
import { GotoHotkeysEffect } from '~/effect-components/GotoHotkeysEffect';
+import { ChooseYourPlan } from '~/pages/auth/ChooseYourPlan.tsx';
import { CreateProfile } from '~/pages/auth/CreateProfile';
import { CreateWorkspace } from '~/pages/auth/CreateWorkspace';
import { PasswordReset } from '~/pages/auth/PasswordReset';
+import { PaymentSuccess } from '~/pages/auth/PaymentSuccess.tsx';
import { PlanRequired } from '~/pages/auth/PlanRequired';
import { SignInUp } from '~/pages/auth/SignInUp';
import { VerifyEffect } from '~/pages/auth/VerifyEffect';
@@ -48,6 +50,7 @@ export const App = () => {
const isNewRecordBoardEnabled = useIsFeatureEnabled(
'IS_NEW_RECORD_BOARD_ENABLED',
);
+ const isSelfBillingEnabled = useIsFeatureEnabled('IS_SELF_BILLING_ENABLED');
return (
<>
@@ -63,7 +66,16 @@ export const App = () => {
} />
} />
} />
- } />
+ :
+ }
+ />
+ }
+ />
} />
} />
} />
diff --git a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx
index 0db11f40ff..b86a15c6d4 100644
--- a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx
+++ b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx
@@ -89,7 +89,8 @@ export const PageChangeEffect = () => {
navigate(AppPath.PlanRequired);
} else if (
onboardingStatus === OnboardingStatus.OngoingWorkspaceActivation &&
- !isMatchingLocation(AppPath.CreateWorkspace)
+ !isMatchingLocation(AppPath.CreateWorkspace) &&
+ !isMatchingLocation(AppPath.PlanRequiredSuccess)
) {
navigate(AppPath.CreateWorkspace);
} else if (
diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx
index 1973079ae0..2e092eaa2b 100644
--- a/packages/twenty-front/src/generated/graphql.tsx
+++ b/packages/twenty-front/src/generated/graphql.tsx
@@ -60,6 +60,7 @@ export type AuthTokens = {
export type Billing = {
__typename?: 'Billing';
+ billingFreeTrialDurationInDays?: Maybe;
billingUrl: Scalars['String'];
isBillingEnabled: Scalars['Boolean'];
};
@@ -69,6 +70,11 @@ export type BooleanFieldComparison = {
isNot?: InputMaybe;
};
+export type CheckoutEntity = {
+ __typename?: 'CheckoutEntity';
+ url: Scalars['String'];
+};
+
export type ClientConfig = {
__typename?: 'ClientConfig';
authProviders: AuthProviders;
@@ -220,6 +226,7 @@ export type Mutation = {
__typename?: 'Mutation';
activateWorkspace: Workspace;
challenge: LoginToken;
+ checkout: CheckoutEntity;
createEvent: Analytics;
createOneObject: Object;
createOneRefreshToken: RefreshToken;
@@ -254,6 +261,12 @@ export type MutationChallengeArgs = {
};
+export type MutationCheckoutArgs = {
+ recurringInterval: Scalars['String'];
+ successUrlPath?: InputMaybe;
+};
+
+
export type MutationCreateEventArgs = {
data: Scalars['JSON'];
type: Scalars['String'];
@@ -362,6 +375,20 @@ export type PageInfo = {
startCursor?: Maybe;
};
+export type ProductPriceEntity = {
+ __typename?: 'ProductPriceEntity';
+ created: Scalars['Float'];
+ recurringInterval: Scalars['String'];
+ stripePriceId: Scalars['String'];
+ unitAmount: Scalars['Float'];
+};
+
+export type ProductPricesEntity = {
+ __typename?: 'ProductPricesEntity';
+ productPrices: Array;
+ totalNumberOfPrices: Scalars['Int'];
+};
+
export type Query = {
__typename?: 'Query';
checkUserExists: UserExists;
@@ -370,6 +397,7 @@ export type Query = {
currentUser: User;
currentWorkspace: Workspace;
findWorkspaceFromInviteHash: Workspace;
+ getProductPrices: ProductPricesEntity;
getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal;
getTimelineThreadsFromPersonId: TimelineThreadsWithTotal;
object: Object;
@@ -393,6 +421,11 @@ export type QueryFindWorkspaceFromInviteHashArgs = {
};
+export type QueryGetProductPricesArgs = {
+ product: Scalars['String'];
+};
+
+
export type QueryGetTimelineThreadsFromCompanyIdArgs = {
companyId: Scalars['ID'];
page: Scalars['Int'];
@@ -830,10 +863,25 @@ export type ValidatePasswordResetTokenQueryVariables = Exact<{
export type ValidatePasswordResetTokenQuery = { __typename?: 'Query', validatePasswordResetToken: { __typename?: 'ValidatePasswordResetToken', id: string, email: string } };
+export type CheckoutMutationVariables = Exact<{
+ recurringInterval: Scalars['String'];
+ successUrlPath?: InputMaybe;
+}>;
+
+
+export type CheckoutMutation = { __typename?: 'Mutation', checkout: { __typename?: 'CheckoutEntity', url: string } };
+
+export type GetProductPricesQueryVariables = Exact<{
+ product: Scalars['String'];
+}>;
+
+
+export type GetProductPricesQuery = { __typename?: 'Query', getProductPrices: { __typename?: 'ProductPricesEntity', productPrices: Array<{ __typename?: 'ProductPriceEntity', created: number, recurringInterval: string, stripePriceId: string, unitAmount: number }> } };
+
export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>;
-export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl: string }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null } } };
+export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl: string, billingFreeTrialDurationInDays?: number | null }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null } } };
export type UploadFileMutationVariables = Exact<{
file: Scalars['Upload'];
@@ -1514,6 +1562,80 @@ export function useValidatePasswordResetTokenLazyQuery(baseOptions?: Apollo.Lazy
export type ValidatePasswordResetTokenQueryHookResult = ReturnType;
export type ValidatePasswordResetTokenLazyQueryHookResult = ReturnType;
export type ValidatePasswordResetTokenQueryResult = Apollo.QueryResult;
+export const CheckoutDocument = gql`
+ mutation Checkout($recurringInterval: String!, $successUrlPath: String) {
+ checkout(recurringInterval: $recurringInterval, successUrlPath: $successUrlPath) {
+ url
+ }
+}
+ `;
+export type CheckoutMutationFn = Apollo.MutationFunction;
+
+/**
+ * __useCheckoutMutation__
+ *
+ * To run a mutation, you first call `useCheckoutMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useCheckoutMutation` returns a tuple that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - An object with fields that represent the current status of the mutation's execution
+ *
+ * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
+ *
+ * @example
+ * const [checkoutMutation, { data, loading, error }] = useCheckoutMutation({
+ * variables: {
+ * recurringInterval: // value for 'recurringInterval'
+ * successUrlPath: // value for 'successUrlPath'
+ * },
+ * });
+ */
+export function useCheckoutMutation(baseOptions?: Apollo.MutationHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useMutation(CheckoutDocument, options);
+ }
+export type CheckoutMutationHookResult = ReturnType;
+export type CheckoutMutationResult = Apollo.MutationResult;
+export type CheckoutMutationOptions = Apollo.BaseMutationOptions;
+export const GetProductPricesDocument = gql`
+ query GetProductPrices($product: String!) {
+ getProductPrices(product: $product) {
+ productPrices {
+ created
+ recurringInterval
+ stripePriceId
+ unitAmount
+ }
+ }
+}
+ `;
+
+/**
+ * __useGetProductPricesQuery__
+ *
+ * To run a query within a React component, call `useGetProductPricesQuery` and pass it any options that fit your needs.
+ * When your component renders, `useGetProductPricesQuery` returns an object from Apollo Client that contains loading, error, and data properties
+ * you can use to render your UI.
+ *
+ * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
+ *
+ * @example
+ * const { data, loading, error } = useGetProductPricesQuery({
+ * variables: {
+ * product: // value for 'product'
+ * },
+ * });
+ */
+export function useGetProductPricesQuery(baseOptions: Apollo.QueryHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useQuery(GetProductPricesDocument, options);
+ }
+export function useGetProductPricesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useLazyQuery(GetProductPricesDocument, options);
+ }
+export type GetProductPricesQueryHookResult = ReturnType;
+export type GetProductPricesLazyQueryHookResult = ReturnType;
+export type GetProductPricesQueryResult = Apollo.QueryResult;
export const GetClientConfigDocument = gql`
query GetClientConfig {
clientConfig {
@@ -1524,6 +1646,7 @@ export const GetClientConfigDocument = gql`
billing {
isBillingEnabled
billingUrl
+ billingFreeTrialDurationInDays
}
signInPrefilled
signUpDisabled
diff --git a/packages/twenty-front/src/modules/auth/components/Modal.tsx b/packages/twenty-front/src/modules/auth/components/Modal.tsx
index db7d03d86a..3a94292702 100644
--- a/packages/twenty-front/src/modules/auth/components/Modal.tsx
+++ b/packages/twenty-front/src/modules/auth/components/Modal.tsx
@@ -11,7 +11,7 @@ const StyledContent = styled(UIModal.Content)`
type AuthModalProps = { children: React.ReactNode };
export const AuthModal = ({ children }: AuthModalProps) => (
-
+
{children}
);
diff --git a/packages/twenty-front/src/modules/auth/components/Title.tsx b/packages/twenty-front/src/modules/auth/components/Title.tsx
index 9ef55d43d3..4fe541db96 100644
--- a/packages/twenty-front/src/modules/auth/components/Title.tsx
+++ b/packages/twenty-front/src/modules/auth/components/Title.tsx
@@ -5,24 +5,30 @@ import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEase
type TitleProps = React.PropsWithChildren & {
animate?: boolean;
+ withMarginTop?: boolean;
};
-const StyledTitle = styled.div`
+const StyledTitle = styled.div>`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.xl};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(4)};
- margin-top: ${({ theme }) => theme.spacing(4)};
+ margin-top: ${({ theme, withMarginTop }) =>
+ withMarginTop ? theme.spacing(4) : 0};
`;
-export const Title = ({ children, animate = false }: TitleProps) => {
+export const Title = ({
+ children,
+ animate = false,
+ withMarginTop = true,
+}: TitleProps) => {
if (animate) {
return (
-
+
{children}
);
}
- return {children};
+ return {children};
};
diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/FooterNote.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/FooterNote.tsx
index 8ef2ee7245..c9d5066d95 100644
--- a/packages/twenty-front/src/modules/auth/sign-in-up/components/FooterNote.tsx
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/FooterNote.tsx
@@ -8,6 +8,7 @@ const StyledContainer = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
+ max-width: 280px;
text-align: center;
`;
diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx
index a3307724a3..bc1997f2d4 100644
--- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx
+++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx
@@ -30,10 +30,6 @@ const StyledContentContainer = styled.div`
width: 200px;
`;
-const StyledFooterNote = styled(FooterNote)`
- max-width: 280px;
-`;
-
const StyledForm = styled.form`
align-items: center;
display: flex;
@@ -89,12 +85,8 @@ export const SignInUpForm = () => {
return 'Continue';
}
- return signInUpMode === SignInUpMode.SignIn
- ? 'Sign in'
- : form.formState.isSubmitting
- ? 'Creating workspace'
- : 'Sign up';
- }, [signInUpMode, signInUpStep, form.formState.isSubmitting]);
+ return signInUpMode === SignInUpMode.SignIn ? 'Sign in' : 'Sign up';
+ }, [signInUpMode, signInUpStep]);
const title = useMemo(() => {
if (signInUpMode === SignInUpMode.Invite) {
@@ -242,10 +234,10 @@ export const SignInUpForm = () => {
Forgot your password?
) : (
-
+
By using Twenty, you agree to the Terms of Service and Data Processing
Agreement.
-
+
)}
>
);
diff --git a/packages/twenty-front/src/modules/billing/components/SubscriptionBenefit.tsx b/packages/twenty-front/src/modules/billing/components/SubscriptionBenefit.tsx
new file mode 100644
index 0000000000..490d8f3047
--- /dev/null
+++ b/packages/twenty-front/src/modules/billing/components/SubscriptionBenefit.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+
+import { IconCheck } from '@/ui/display/icon';
+
+const StyledBenefitContainer = styled.div`
+ color: ${({ theme }) => theme.font.color.secondary};
+ display: flex;
+ flex-direction: row;
+ gap: ${({ theme }) => theme.spacing(2)};
+`;
+
+const StyledCheckContainer = styled.div`
+ align-items: center;
+ background-color: ${({ theme }) => theme.background.tertiary};
+ border-radius: 50%;
+ display: flex;
+ height: 16px;
+ justify-content: center;
+ width: 16px;
+`;
+type SubscriptionBenefitProps = {
+ children: React.ReactNode;
+};
+export const SubscriptionBenefit = ({ children }: SubscriptionBenefitProps) => {
+ const theme = useTheme();
+ return (
+
+
+
+
+ {children}
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/billing/components/SubscriptionCard.tsx b/packages/twenty-front/src/modules/billing/components/SubscriptionCard.tsx
new file mode 100644
index 0000000000..fd323391c0
--- /dev/null
+++ b/packages/twenty-front/src/modules/billing/components/SubscriptionCard.tsx
@@ -0,0 +1,41 @@
+import styled from '@emotion/styled';
+
+import { SubscriptionCardPrice } from '@/billing/components/SubscriptionCardPrice.tsx';
+import { capitalize } from '~/utils/string/capitalize.ts';
+
+type SubscriptionCardProps = {
+ type?: string;
+ price: number;
+ info: string;
+};
+
+const StyledSubscriptionCardContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+const StyledTypeContainer = styled.div`
+ color: ${({ theme }) => theme.font.color.secondary};
+ font-size: ${({ theme }) => theme.font.size.sm};
+ display: flex;
+`;
+
+const StyledInfoContainer = styled.div`
+ color: ${({ theme }) => theme.font.color.tertiary};
+ font-size: ${({ theme }) => theme.font.size.sm};
+ display: flex;
+`;
+
+export const SubscriptionCard = ({
+ type,
+ price,
+ info,
+}: SubscriptionCardProps) => {
+ return (
+
+ {capitalize(type || '')}
+
+ {info}
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/billing/components/SubscriptionCardPrice.tsx b/packages/twenty-front/src/modules/billing/components/SubscriptionCardPrice.tsx
new file mode 100644
index 0000000000..8091c72ab3
--- /dev/null
+++ b/packages/twenty-front/src/modules/billing/components/SubscriptionCardPrice.tsx
@@ -0,0 +1,33 @@
+import styled from '@emotion/styled';
+
+type SubscriptionCardPriceProps = {
+ price: number;
+};
+const StyledSubscriptionCardPriceContainer = styled.div`
+ align-items: baseline;
+ display: flex;
+ gap: ${({ theme }) => theme.betweenSiblingsGap};
+ margin: ${({ theme }) => theme.spacing(1)} 0
+ ${({ theme }) => theme.spacing(2)};
+`;
+const StyledPriceSpan = styled.span`
+ color: ${({ theme }) => theme.font.color.primary};
+ font-size: ${({ theme }) => theme.font.size.xl};
+ font-weight: ${({ theme }) => theme.font.weight.semiBold};
+`;
+const StyledSeatSpan = styled.span`
+ color: ${({ theme }) => theme.font.color.light};
+ font-size: ${({ theme }) => theme.font.size.md};
+ font-weight: ${({ theme }) => theme.font.weight.medium};
+`;
+export const SubscriptionCardPrice = ({
+ price,
+}: SubscriptionCardPriceProps) => {
+ return (
+
+ ${price}
+ /
+ seat
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/billing/graphql/checkout.ts b/packages/twenty-front/src/modules/billing/graphql/checkout.ts
new file mode 100644
index 0000000000..e2742d1074
--- /dev/null
+++ b/packages/twenty-front/src/modules/billing/graphql/checkout.ts
@@ -0,0 +1,12 @@
+import { gql } from '@apollo/client';
+
+export const CHECKOUT = gql`
+ mutation Checkout($recurringInterval: String!, $successUrlPath: String) {
+ checkout(
+ recurringInterval: $recurringInterval
+ successUrlPath: $successUrlPath
+ ) {
+ url
+ }
+ }
+`;
diff --git a/packages/twenty-front/src/modules/billing/graphql/getProductPrices.ts b/packages/twenty-front/src/modules/billing/graphql/getProductPrices.ts
new file mode 100644
index 0000000000..353e75512d
--- /dev/null
+++ b/packages/twenty-front/src/modules/billing/graphql/getProductPrices.ts
@@ -0,0 +1,14 @@
+import { gql } from '@apollo/client';
+
+export const GET_PRODUCT_PRICES = gql`
+ query GetProductPrices($product: String!) {
+ getProductPrices(product: $product) {
+ productPrices {
+ created
+ recurringInterval
+ stripePriceId
+ unitAmount
+ }
+ }
+ }
+`;
diff --git a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts
index 3f7eb573c8..aca84198a5 100644
--- a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts
+++ b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts
@@ -10,6 +10,7 @@ export const GET_CLIENT_CONFIG = gql`
billing {
isBillingEnabled
billingUrl
+ billingFreeTrialDurationInDays
}
signInPrefilled
signUpDisabled
diff --git a/packages/twenty-front/src/modules/types/AppPath.ts b/packages/twenty-front/src/modules/types/AppPath.ts
index e35563a1ef..e450d0fe62 100644
--- a/packages/twenty-front/src/modules/types/AppPath.ts
+++ b/packages/twenty-front/src/modules/types/AppPath.ts
@@ -10,6 +10,7 @@ export enum AppPath {
CreateWorkspace = '/create/workspace',
CreateProfile = '/create/profile',
PlanRequired = '/plan-required',
+ PlanRequiredSuccess = '/plan-required/payment-success',
// Onboarded
Index = '/',
diff --git a/packages/twenty-front/src/modules/ui/input/button/components/MainButton.tsx b/packages/twenty-front/src/modules/ui/input/button/components/MainButton.tsx
index 4d75be9ae8..c872cef410 100644
--- a/packages/twenty-front/src/modules/ui/input/button/components/MainButton.tsx
+++ b/packages/twenty-front/src/modules/ui/input/button/components/MainButton.tsx
@@ -9,11 +9,14 @@ type Variant = 'primary' | 'secondary';
type Props = {
title: string;
fullWidth?: boolean;
+ width?: number;
variant?: Variant;
soon?: boolean;
} & React.ComponentProps<'button'>;
-const StyledButton = styled.button>`
+const StyledButton = styled.button<
+ Pick
+>`
align-items: center;
background: ${({ theme, variant, disabled }) => {
if (disabled) {
@@ -75,7 +78,8 @@ const StyledButton = styled.button>`
justify-content: center;
outline: none;
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(3)};
- width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
+ width: ${({ fullWidth, width }) =>
+ fullWidth ? '100%' : width ? `${width}px` : 'auto'};
${({ theme, variant }) => {
switch (variant) {
case 'secondary':
@@ -101,6 +105,7 @@ type MainButtonProps = Props & {
export const MainButton = ({
Icon,
title,
+ width,
fullWidth = false,
variant = 'primary',
type,
@@ -112,7 +117,7 @@ export const MainButton = ({
return (
{Icon && }
{title}
diff --git a/packages/twenty-front/src/modules/ui/input/button/components/__stories__/MainButton.stories.tsx b/packages/twenty-front/src/modules/ui/input/button/components/__stories__/MainButton.stories.tsx
index 2ca12fa026..e5d8cb2bad 100644
--- a/packages/twenty-front/src/modules/ui/input/button/components/__stories__/MainButton.stories.tsx
+++ b/packages/twenty-front/src/modules/ui/input/button/components/__stories__/MainButton.stories.tsx
@@ -42,6 +42,10 @@ export const FullWidth: Story = {
args: { fullWidth: true },
};
+export const Width: Story = {
+ args: { width: 200 },
+};
+
export const Secondary: Story = {
args: { title: 'A secondary Button', variant: 'secondary' },
};
diff --git a/packages/twenty-front/src/modules/ui/input/components/CardPicker.tsx b/packages/twenty-front/src/modules/ui/input/components/CardPicker.tsx
new file mode 100644
index 0000000000..b0d42ad010
--- /dev/null
+++ b/packages/twenty-front/src/modules/ui/input/components/CardPicker.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import styled from '@emotion/styled';
+
+import { Radio } from '@/ui/input/components/Radio.tsx';
+const StyledSubscriptionCardContainer = styled.button`
+ background-color: ${({ theme }) => theme.background.secondary};
+ border: 1px solid ${({ theme }) => theme.border.color.medium};
+ border-radius: ${({ theme }) => theme.border.radius.md};
+ display: flex;
+ padding: ${({ theme }) => theme.spacing(4)} ${({ theme }) => theme.spacing(3)};
+ position: relative;
+ width: 100%;
+ :hover {
+ cursor: pointer;
+ background: ${({ theme }) => theme.background.tertiary};
+ }
+`;
+
+const StyledRadioContainer = styled.div`
+ position: absolute;
+ right: ${({ theme }) => theme.spacing(2)};
+ top: ${({ theme }) => theme.spacing(2)};
+`;
+
+type CardPickerProps = {
+ children: React.ReactNode;
+ handleChange?: () => void;
+ checked?: boolean;
+};
+
+export const CardPicker = ({
+ children,
+ checked,
+ handleChange,
+}: CardPickerProps) => {
+ return (
+
+
+
+
+ {children}
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx
index d3cb26c65c..3c872dd171 100644
--- a/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx
+++ b/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx
@@ -74,7 +74,6 @@ export const DefaultLayout = ({ children }: DefaultLayoutProps) => {
const theme = useTheme();
const widowsWidth = useScreenSize().width;
const isMatchingLocation = useIsMatchingLocation();
-
const showAuthModal = useMemo(() => {
return (
(onboardingStatus && onboardingStatus !== OnboardingStatus.Completed) ||
diff --git a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts
index 1a85d16de4..31e49745b2 100644
--- a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts
+++ b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts
@@ -3,4 +3,5 @@ export type FeatureFlagKey =
| 'IS_CALENDAR_ENABLED'
| 'IS_MESSAGING_ENABLED'
| 'IS_NEW_RECORD_BOARD_ENABLED'
- | 'IS_QUICK_ACTIONS_ENABLED';
+ | 'IS_QUICK_ACTIONS_ENABLED'
+ | 'IS_SELF_BILLING_ENABLED';
diff --git a/packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx b/packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx
new file mode 100644
index 0000000000..321e305aa7
--- /dev/null
+++ b/packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx
@@ -0,0 +1,133 @@
+import React, { useState } from 'react';
+import styled from '@emotion/styled';
+import { useRecoilValue } from 'recoil';
+
+import { SubTitle } from '@/auth/components/SubTitle.tsx';
+import { Title } from '@/auth/components/Title.tsx';
+import { SubscriptionBenefit } from '@/billing/components/SubscriptionBenefit.tsx';
+import { SubscriptionCard } from '@/billing/components/SubscriptionCard.tsx';
+import { billingState } from '@/client-config/states/billingState.ts';
+import { AppPath } from '@/types/AppPath.ts';
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar.tsx';
+import { MainButton } from '@/ui/input/button/components/MainButton.tsx';
+import { CardPicker } from '@/ui/input/components/CardPicker.tsx';
+import {
+ ProductPriceEntity,
+ useCheckoutMutation,
+ useGetProductPricesQuery,
+} from '~/generated/graphql.tsx';
+
+const StyledChoosePlanContainer = styled.div`
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ margin: ${({ theme }) => theme.spacing(8)} 0
+ ${({ theme }) => theme.spacing(2)};
+ gap: ${({ theme }) => theme.spacing(2)};
+`;
+
+const StyledBenefitsContainer = styled.div`
+ background-color: ${({ theme }) => theme.background.secondary};
+ border: 1px solid ${({ theme }) => theme.border.color.medium};
+ border-radius: ${({ theme }) => theme.border.radius.md};
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ gap: 16px;
+ padding: ${({ theme }) => theme.spacing(4)} ${({ theme }) => theme.spacing(3)};
+ margin-bottom: ${({ theme }) => theme.spacing(8)};
+`;
+
+export const ChooseYourPlan = () => {
+ const billing = useRecoilValue(billingState);
+
+ const [planSelected, setPlanSelected] = useState('month');
+
+ const { enqueueSnackBar } = useSnackBar();
+
+ const { data: prices } = useGetProductPricesQuery({
+ variables: { product: 'base-plan' },
+ });
+
+ const [checkout] = useCheckoutMutation();
+
+ const handlePlanChange = (type?: string) => {
+ return () => {
+ if (type && planSelected !== type) {
+ setPlanSelected(type);
+ }
+ };
+ };
+
+ const computeInfo = (
+ price: ProductPriceEntity,
+ prices: ProductPriceEntity[],
+ ): string => {
+ if (price.recurringInterval !== 'year') {
+ return 'Cancel anytime';
+ }
+ const monthPrice = prices.filter(
+ (price) => price.recurringInterval === 'month',
+ )?.[0];
+ if (monthPrice && monthPrice.unitAmount && price.unitAmount) {
+ return `Save $${(12 * monthPrice.unitAmount - price.unitAmount) / 100}`;
+ }
+ return 'Cancel anytime';
+ };
+
+ const handleButtonClick = async () => {
+ const { data } = await checkout({
+ variables: {
+ recurringInterval: planSelected,
+ successUrlPath: AppPath.PlanRequiredSuccess,
+ },
+ });
+ if (!data?.checkout.url) {
+ enqueueSnackBar(
+ 'Checkout session error. Please retry or contact Twenty team',
+ {
+ variant: 'error',
+ },
+ );
+ return;
+ }
+ window.location.replace(data.checkout.url);
+ };
+
+ return (
+ prices?.getProductPrices?.productPrices && (
+ <>
+ Choose your Plan
+
+ Enjoy a {billing?.billingFreeTrialDurationInDays}-day free trial
+
+
+ {prices.getProductPrices.productPrices.map((price, index) => (
+
+
+
+ ))}
+
+
+ Full access
+ Unlimited contacts
+ Email integration
+ Custom objects
+ API & Webhooks
+ Frequent updates
+ And much more
+
+
+ >
+ )
+ );
+};
diff --git a/packages/twenty-front/src/pages/auth/CreateProfile.tsx b/packages/twenty-front/src/pages/auth/CreateProfile.tsx
index 423d9e530f..edbaa99bcf 100644
--- a/packages/twenty-front/src/pages/auth/CreateProfile.tsx
+++ b/packages/twenty-front/src/pages/auth/CreateProfile.tsx
@@ -141,7 +141,7 @@ export const CreateProfile = () => {
return (
<>
- Create profile
+ Create profile
How you'll be identified on the app.
diff --git a/packages/twenty-front/src/pages/auth/CreateWorkspace.tsx b/packages/twenty-front/src/pages/auth/CreateWorkspace.tsx
index 8d82f3c56f..ff605c5e3d 100644
--- a/packages/twenty-front/src/pages/auth/CreateWorkspace.tsx
+++ b/packages/twenty-front/src/pages/auth/CreateWorkspace.tsx
@@ -3,6 +3,7 @@ import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod';
+import { Key } from 'ts-key-enum';
import { z } from 'zod';
import { SubTitle } from '@/auth/components/SubTitle';
@@ -13,12 +14,11 @@ import { FIND_MANY_OBJECT_METADATA_ITEMS } from '@/object-metadata/graphql/queri
import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient';
import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader';
import { AppPath } from '@/types/AppPath';
-import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { H2Title } from '@/ui/display/typography/components/H2Title';
+import { Loader } from '@/ui/feedback/loader/components/Loader.tsx';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { MainButton } from '@/ui/input/button/components/MainButton';
import { TextInput } from '@/ui/input/components/TextInput';
-import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
import { useActivateWorkspaceMutation } from '~/generated/graphql';
@@ -57,7 +57,6 @@ export const CreateWorkspace = () => {
control,
handleSubmit,
formState: { isValid, isSubmitting },
- getValues,
} = useForm