mirror of
https://github.com/lingble/twenty.git
synced 2025-11-03 06:07:56 +00:00
39 create subscription and success modale (#4208)
* Init add choose your plan page component * Update price format * Add billing refund trial duration env variable * Add billing benefits * Add Button * Call checkout endpoint * Fix theme color * Add Payment success modale * Add loader to createWorkspace submit button * Fix lint * Fix dark mode * Code review returns * Use a resolver for front requests * Fix 'create workspace' loader at sign up * Fix 'create workspace' with enter key bug
This commit is contained in:
@@ -7,9 +7,11 @@ import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
|||||||
import { DefaultPageTitle } from '~/DefaultPageTitle';
|
import { DefaultPageTitle } from '~/DefaultPageTitle';
|
||||||
import { CommandMenuEffect } from '~/effect-components/CommandMenuEffect';
|
import { CommandMenuEffect } from '~/effect-components/CommandMenuEffect';
|
||||||
import { GotoHotkeysEffect } from '~/effect-components/GotoHotkeysEffect';
|
import { GotoHotkeysEffect } from '~/effect-components/GotoHotkeysEffect';
|
||||||
|
import { ChooseYourPlan } from '~/pages/auth/ChooseYourPlan.tsx';
|
||||||
import { CreateProfile } from '~/pages/auth/CreateProfile';
|
import { CreateProfile } from '~/pages/auth/CreateProfile';
|
||||||
import { CreateWorkspace } from '~/pages/auth/CreateWorkspace';
|
import { CreateWorkspace } from '~/pages/auth/CreateWorkspace';
|
||||||
import { PasswordReset } from '~/pages/auth/PasswordReset';
|
import { PasswordReset } from '~/pages/auth/PasswordReset';
|
||||||
|
import { PaymentSuccess } from '~/pages/auth/PaymentSuccess.tsx';
|
||||||
import { PlanRequired } from '~/pages/auth/PlanRequired';
|
import { PlanRequired } from '~/pages/auth/PlanRequired';
|
||||||
import { SignInUp } from '~/pages/auth/SignInUp';
|
import { SignInUp } from '~/pages/auth/SignInUp';
|
||||||
import { VerifyEffect } from '~/pages/auth/VerifyEffect';
|
import { VerifyEffect } from '~/pages/auth/VerifyEffect';
|
||||||
@@ -48,6 +50,7 @@ export const App = () => {
|
|||||||
const isNewRecordBoardEnabled = useIsFeatureEnabled(
|
const isNewRecordBoardEnabled = useIsFeatureEnabled(
|
||||||
'IS_NEW_RECORD_BOARD_ENABLED',
|
'IS_NEW_RECORD_BOARD_ENABLED',
|
||||||
);
|
);
|
||||||
|
const isSelfBillingEnabled = useIsFeatureEnabled('IS_SELF_BILLING_ENABLED');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -63,7 +66,16 @@ export const App = () => {
|
|||||||
<Route path={AppPath.ResetPassword} element={<PasswordReset />} />
|
<Route path={AppPath.ResetPassword} element={<PasswordReset />} />
|
||||||
<Route path={AppPath.CreateWorkspace} element={<CreateWorkspace />} />
|
<Route path={AppPath.CreateWorkspace} element={<CreateWorkspace />} />
|
||||||
<Route path={AppPath.CreateProfile} element={<CreateProfile />} />
|
<Route path={AppPath.CreateProfile} element={<CreateProfile />} />
|
||||||
<Route path={AppPath.PlanRequired} element={<PlanRequired />} />
|
<Route
|
||||||
|
path={AppPath.PlanRequired}
|
||||||
|
element={
|
||||||
|
isSelfBillingEnabled ? <ChooseYourPlan /> : <PlanRequired />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={AppPath.PlanRequiredSuccess}
|
||||||
|
element={<PaymentSuccess />}
|
||||||
|
/>
|
||||||
<Route path={AppPath.Index} element={<DefaultHomePage />} />
|
<Route path={AppPath.Index} element={<DefaultHomePage />} />
|
||||||
<Route path={AppPath.TasksPage} element={<Tasks />} />
|
<Route path={AppPath.TasksPage} element={<Tasks />} />
|
||||||
<Route path={AppPath.Impersonate} element={<ImpersonateEffect />} />
|
<Route path={AppPath.Impersonate} element={<ImpersonateEffect />} />
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ export const PageChangeEffect = () => {
|
|||||||
navigate(AppPath.PlanRequired);
|
navigate(AppPath.PlanRequired);
|
||||||
} else if (
|
} else if (
|
||||||
onboardingStatus === OnboardingStatus.OngoingWorkspaceActivation &&
|
onboardingStatus === OnboardingStatus.OngoingWorkspaceActivation &&
|
||||||
!isMatchingLocation(AppPath.CreateWorkspace)
|
!isMatchingLocation(AppPath.CreateWorkspace) &&
|
||||||
|
!isMatchingLocation(AppPath.PlanRequiredSuccess)
|
||||||
) {
|
) {
|
||||||
navigate(AppPath.CreateWorkspace);
|
navigate(AppPath.CreateWorkspace);
|
||||||
} else if (
|
} else if (
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export type AuthTokens = {
|
|||||||
|
|
||||||
export type Billing = {
|
export type Billing = {
|
||||||
__typename?: 'Billing';
|
__typename?: 'Billing';
|
||||||
|
billingFreeTrialDurationInDays?: Maybe<Scalars['Float']>;
|
||||||
billingUrl: Scalars['String'];
|
billingUrl: Scalars['String'];
|
||||||
isBillingEnabled: Scalars['Boolean'];
|
isBillingEnabled: Scalars['Boolean'];
|
||||||
};
|
};
|
||||||
@@ -69,6 +70,11 @@ export type BooleanFieldComparison = {
|
|||||||
isNot?: InputMaybe<Scalars['Boolean']>;
|
isNot?: InputMaybe<Scalars['Boolean']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CheckoutEntity = {
|
||||||
|
__typename?: 'CheckoutEntity';
|
||||||
|
url: Scalars['String'];
|
||||||
|
};
|
||||||
|
|
||||||
export type ClientConfig = {
|
export type ClientConfig = {
|
||||||
__typename?: 'ClientConfig';
|
__typename?: 'ClientConfig';
|
||||||
authProviders: AuthProviders;
|
authProviders: AuthProviders;
|
||||||
@@ -220,6 +226,7 @@ export type Mutation = {
|
|||||||
__typename?: 'Mutation';
|
__typename?: 'Mutation';
|
||||||
activateWorkspace: Workspace;
|
activateWorkspace: Workspace;
|
||||||
challenge: LoginToken;
|
challenge: LoginToken;
|
||||||
|
checkout: CheckoutEntity;
|
||||||
createEvent: Analytics;
|
createEvent: Analytics;
|
||||||
createOneObject: Object;
|
createOneObject: Object;
|
||||||
createOneRefreshToken: RefreshToken;
|
createOneRefreshToken: RefreshToken;
|
||||||
@@ -254,6 +261,12 @@ export type MutationChallengeArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationCheckoutArgs = {
|
||||||
|
recurringInterval: Scalars['String'];
|
||||||
|
successUrlPath?: InputMaybe<Scalars['String']>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationCreateEventArgs = {
|
export type MutationCreateEventArgs = {
|
||||||
data: Scalars['JSON'];
|
data: Scalars['JSON'];
|
||||||
type: Scalars['String'];
|
type: Scalars['String'];
|
||||||
@@ -362,6 +375,20 @@ export type PageInfo = {
|
|||||||
startCursor?: Maybe<Scalars['ConnectionCursor']>;
|
startCursor?: Maybe<Scalars['ConnectionCursor']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProductPriceEntity = {
|
||||||
|
__typename?: 'ProductPriceEntity';
|
||||||
|
created: Scalars['Float'];
|
||||||
|
recurringInterval: Scalars['String'];
|
||||||
|
stripePriceId: Scalars['String'];
|
||||||
|
unitAmount: Scalars['Float'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductPricesEntity = {
|
||||||
|
__typename?: 'ProductPricesEntity';
|
||||||
|
productPrices: Array<ProductPriceEntity>;
|
||||||
|
totalNumberOfPrices: Scalars['Int'];
|
||||||
|
};
|
||||||
|
|
||||||
export type Query = {
|
export type Query = {
|
||||||
__typename?: 'Query';
|
__typename?: 'Query';
|
||||||
checkUserExists: UserExists;
|
checkUserExists: UserExists;
|
||||||
@@ -370,6 +397,7 @@ export type Query = {
|
|||||||
currentUser: User;
|
currentUser: User;
|
||||||
currentWorkspace: Workspace;
|
currentWorkspace: Workspace;
|
||||||
findWorkspaceFromInviteHash: Workspace;
|
findWorkspaceFromInviteHash: Workspace;
|
||||||
|
getProductPrices: ProductPricesEntity;
|
||||||
getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal;
|
getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal;
|
||||||
getTimelineThreadsFromPersonId: TimelineThreadsWithTotal;
|
getTimelineThreadsFromPersonId: TimelineThreadsWithTotal;
|
||||||
object: Object;
|
object: Object;
|
||||||
@@ -393,6 +421,11 @@ export type QueryFindWorkspaceFromInviteHashArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type QueryGetProductPricesArgs = {
|
||||||
|
product: Scalars['String'];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type QueryGetTimelineThreadsFromCompanyIdArgs = {
|
export type QueryGetTimelineThreadsFromCompanyIdArgs = {
|
||||||
companyId: Scalars['ID'];
|
companyId: Scalars['ID'];
|
||||||
page: Scalars['Int'];
|
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 ValidatePasswordResetTokenQuery = { __typename?: 'Query', validatePasswordResetToken: { __typename?: 'ValidatePasswordResetToken', id: string, email: string } };
|
||||||
|
|
||||||
|
export type CheckoutMutationVariables = Exact<{
|
||||||
|
recurringInterval: Scalars['String'];
|
||||||
|
successUrlPath?: InputMaybe<Scalars['String']>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
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 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<{
|
export type UploadFileMutationVariables = Exact<{
|
||||||
file: Scalars['Upload'];
|
file: Scalars['Upload'];
|
||||||
@@ -1514,6 +1562,80 @@ export function useValidatePasswordResetTokenLazyQuery(baseOptions?: Apollo.Lazy
|
|||||||
export type ValidatePasswordResetTokenQueryHookResult = ReturnType<typeof useValidatePasswordResetTokenQuery>;
|
export type ValidatePasswordResetTokenQueryHookResult = ReturnType<typeof useValidatePasswordResetTokenQuery>;
|
||||||
export type ValidatePasswordResetTokenLazyQueryHookResult = ReturnType<typeof useValidatePasswordResetTokenLazyQuery>;
|
export type ValidatePasswordResetTokenLazyQueryHookResult = ReturnType<typeof useValidatePasswordResetTokenLazyQuery>;
|
||||||
export type ValidatePasswordResetTokenQueryResult = Apollo.QueryResult<ValidatePasswordResetTokenQuery, ValidatePasswordResetTokenQueryVariables>;
|
export type ValidatePasswordResetTokenQueryResult = Apollo.QueryResult<ValidatePasswordResetTokenQuery, ValidatePasswordResetTokenQueryVariables>;
|
||||||
|
export const CheckoutDocument = gql`
|
||||||
|
mutation Checkout($recurringInterval: String!, $successUrlPath: String) {
|
||||||
|
checkout(recurringInterval: $recurringInterval, successUrlPath: $successUrlPath) {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
export type CheckoutMutationFn = Apollo.MutationFunction<CheckoutMutation, CheckoutMutationVariables>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __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<CheckoutMutation, CheckoutMutationVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useMutation<CheckoutMutation, CheckoutMutationVariables>(CheckoutDocument, options);
|
||||||
|
}
|
||||||
|
export type CheckoutMutationHookResult = ReturnType<typeof useCheckoutMutation>;
|
||||||
|
export type CheckoutMutationResult = Apollo.MutationResult<CheckoutMutation>;
|
||||||
|
export type CheckoutMutationOptions = Apollo.BaseMutationOptions<CheckoutMutation, CheckoutMutationVariables>;
|
||||||
|
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<GetProductPricesQuery, GetProductPricesQueryVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useQuery<GetProductPricesQuery, GetProductPricesQueryVariables>(GetProductPricesDocument, options);
|
||||||
|
}
|
||||||
|
export function useGetProductPricesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetProductPricesQuery, GetProductPricesQueryVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useLazyQuery<GetProductPricesQuery, GetProductPricesQueryVariables>(GetProductPricesDocument, options);
|
||||||
|
}
|
||||||
|
export type GetProductPricesQueryHookResult = ReturnType<typeof useGetProductPricesQuery>;
|
||||||
|
export type GetProductPricesLazyQueryHookResult = ReturnType<typeof useGetProductPricesLazyQuery>;
|
||||||
|
export type GetProductPricesQueryResult = Apollo.QueryResult<GetProductPricesQuery, GetProductPricesQueryVariables>;
|
||||||
export const GetClientConfigDocument = gql`
|
export const GetClientConfigDocument = gql`
|
||||||
query GetClientConfig {
|
query GetClientConfig {
|
||||||
clientConfig {
|
clientConfig {
|
||||||
@@ -1524,6 +1646,7 @@ export const GetClientConfigDocument = gql`
|
|||||||
billing {
|
billing {
|
||||||
isBillingEnabled
|
isBillingEnabled
|
||||||
billingUrl
|
billingUrl
|
||||||
|
billingFreeTrialDurationInDays
|
||||||
}
|
}
|
||||||
signInPrefilled
|
signInPrefilled
|
||||||
signUpDisabled
|
signUpDisabled
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const StyledContent = styled(UIModal.Content)`
|
|||||||
type AuthModalProps = { children: React.ReactNode };
|
type AuthModalProps = { children: React.ReactNode };
|
||||||
|
|
||||||
export const AuthModal = ({ children }: AuthModalProps) => (
|
export const AuthModal = ({ children }: AuthModalProps) => (
|
||||||
<UIModal isOpen={true}>
|
<UIModal isOpen={true} padding={'none'}>
|
||||||
<StyledContent>{children}</StyledContent>
|
<StyledContent>{children}</StyledContent>
|
||||||
</UIModal>
|
</UIModal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,24 +5,30 @@ import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEase
|
|||||||
|
|
||||||
type TitleProps = React.PropsWithChildren & {
|
type TitleProps = React.PropsWithChildren & {
|
||||||
animate?: boolean;
|
animate?: boolean;
|
||||||
|
withMarginTop?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const StyledTitle = styled.div`
|
const StyledTitle = styled.div<Pick<TitleProps, 'withMarginTop'>>`
|
||||||
color: ${({ theme }) => theme.font.color.primary};
|
color: ${({ theme }) => theme.font.color.primary};
|
||||||
font-size: ${({ theme }) => theme.font.size.xl};
|
font-size: ${({ theme }) => theme.font.size.xl};
|
||||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||||
margin-bottom: ${({ theme }) => theme.spacing(4)};
|
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) {
|
if (animate) {
|
||||||
return (
|
return (
|
||||||
<StyledTitle>
|
<StyledTitle withMarginTop={withMarginTop}>
|
||||||
<AnimatedEaseIn>{children}</AnimatedEaseIn>
|
<AnimatedEaseIn>{children}</AnimatedEaseIn>
|
||||||
</StyledTitle>
|
</StyledTitle>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <StyledTitle>{children}</StyledTitle>;
|
return <StyledTitle withMarginTop={withMarginTop}>{children}</StyledTitle>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const StyledContainer = styled.div`
|
|||||||
color: ${({ theme }) => theme.font.color.tertiary};
|
color: ${({ theme }) => theme.font.color.tertiary};
|
||||||
display: flex;
|
display: flex;
|
||||||
font-size: ${({ theme }) => theme.font.size.sm};
|
font-size: ${({ theme }) => theme.font.size.sm};
|
||||||
|
max-width: 280px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ const StyledContentContainer = styled.div`
|
|||||||
width: 200px;
|
width: 200px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledFooterNote = styled(FooterNote)`
|
|
||||||
max-width: 280px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledForm = styled.form`
|
const StyledForm = styled.form`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -89,12 +85,8 @@ export const SignInUpForm = () => {
|
|||||||
return 'Continue';
|
return 'Continue';
|
||||||
}
|
}
|
||||||
|
|
||||||
return signInUpMode === SignInUpMode.SignIn
|
return signInUpMode === SignInUpMode.SignIn ? 'Sign in' : 'Sign up';
|
||||||
? 'Sign in'
|
}, [signInUpMode, signInUpStep]);
|
||||||
: form.formState.isSubmitting
|
|
||||||
? 'Creating workspace'
|
|
||||||
: 'Sign up';
|
|
||||||
}, [signInUpMode, signInUpStep, form.formState.isSubmitting]);
|
|
||||||
|
|
||||||
const title = useMemo(() => {
|
const title = useMemo(() => {
|
||||||
if (signInUpMode === SignInUpMode.Invite) {
|
if (signInUpMode === SignInUpMode.Invite) {
|
||||||
@@ -242,10 +234,10 @@ export const SignInUpForm = () => {
|
|||||||
Forgot your password?
|
Forgot your password?
|
||||||
</ActionLink>
|
</ActionLink>
|
||||||
) : (
|
) : (
|
||||||
<StyledFooterNote>
|
<FooterNote>
|
||||||
By using Twenty, you agree to the Terms of Service and Data Processing
|
By using Twenty, you agree to the Terms of Service and Data Processing
|
||||||
Agreement.
|
Agreement.
|
||||||
</StyledFooterNote>
|
</FooterNote>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<StyledBenefitContainer>
|
||||||
|
<StyledCheckContainer>
|
||||||
|
<IconCheck color={theme.grayScale.gray50} size={14} />
|
||||||
|
</StyledCheckContainer>
|
||||||
|
{children}
|
||||||
|
</StyledBenefitContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<StyledSubscriptionCardContainer>
|
||||||
|
<StyledTypeContainer>{capitalize(type || '')}</StyledTypeContainer>
|
||||||
|
<SubscriptionCardPrice price={price} />
|
||||||
|
<StyledInfoContainer>{info}</StyledInfoContainer>
|
||||||
|
</StyledSubscriptionCardContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<StyledSubscriptionCardPriceContainer>
|
||||||
|
<StyledPriceSpan>${price}</StyledPriceSpan>
|
||||||
|
<StyledSeatSpan>/</StyledSeatSpan>
|
||||||
|
<StyledSeatSpan>seat</StyledSeatSpan>
|
||||||
|
</StyledSubscriptionCardPriceContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -10,6 +10,7 @@ export const GET_CLIENT_CONFIG = gql`
|
|||||||
billing {
|
billing {
|
||||||
isBillingEnabled
|
isBillingEnabled
|
||||||
billingUrl
|
billingUrl
|
||||||
|
billingFreeTrialDurationInDays
|
||||||
}
|
}
|
||||||
signInPrefilled
|
signInPrefilled
|
||||||
signUpDisabled
|
signUpDisabled
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export enum AppPath {
|
|||||||
CreateWorkspace = '/create/workspace',
|
CreateWorkspace = '/create/workspace',
|
||||||
CreateProfile = '/create/profile',
|
CreateProfile = '/create/profile',
|
||||||
PlanRequired = '/plan-required',
|
PlanRequired = '/plan-required',
|
||||||
|
PlanRequiredSuccess = '/plan-required/payment-success',
|
||||||
|
|
||||||
// Onboarded
|
// Onboarded
|
||||||
Index = '/',
|
Index = '/',
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ type Variant = 'primary' | 'secondary';
|
|||||||
type Props = {
|
type Props = {
|
||||||
title: string;
|
title: string;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
|
width?: number;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
soon?: boolean;
|
soon?: boolean;
|
||||||
} & React.ComponentProps<'button'>;
|
} & React.ComponentProps<'button'>;
|
||||||
|
|
||||||
const StyledButton = styled.button<Pick<Props, 'fullWidth' | 'variant'>>`
|
const StyledButton = styled.button<
|
||||||
|
Pick<Props, 'fullWidth' | 'width' | 'variant'>
|
||||||
|
>`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: ${({ theme, variant, disabled }) => {
|
background: ${({ theme, variant, disabled }) => {
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
@@ -75,7 +78,8 @@ const StyledButton = styled.button<Pick<Props, 'fullWidth' | 'variant'>>`
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
outline: none;
|
outline: none;
|
||||||
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(3)};
|
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 }) => {
|
${({ theme, variant }) => {
|
||||||
switch (variant) {
|
switch (variant) {
|
||||||
case 'secondary':
|
case 'secondary':
|
||||||
@@ -101,6 +105,7 @@ type MainButtonProps = Props & {
|
|||||||
export const MainButton = ({
|
export const MainButton = ({
|
||||||
Icon,
|
Icon,
|
||||||
title,
|
title,
|
||||||
|
width,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
variant = 'primary',
|
variant = 'primary',
|
||||||
type,
|
type,
|
||||||
@@ -112,7 +117,7 @@ export const MainButton = ({
|
|||||||
return (
|
return (
|
||||||
<StyledButton
|
<StyledButton
|
||||||
className={className}
|
className={className}
|
||||||
{...{ disabled, fullWidth, onClick, type, variant }}
|
{...{ disabled, fullWidth, width, onClick, type, variant }}
|
||||||
>
|
>
|
||||||
{Icon && <Icon size={theme.icon.size.sm} />}
|
{Icon && <Icon size={theme.icon.size.sm} />}
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export const FullWidth: Story = {
|
|||||||
args: { fullWidth: true },
|
args: { fullWidth: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Width: Story = {
|
||||||
|
args: { width: 200 },
|
||||||
|
};
|
||||||
|
|
||||||
export const Secondary: Story = {
|
export const Secondary: Story = {
|
||||||
args: { title: 'A secondary Button', variant: 'secondary' },
|
args: { title: 'A secondary Button', variant: 'secondary' },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<StyledSubscriptionCardContainer onClick={handleChange}>
|
||||||
|
<StyledRadioContainer>
|
||||||
|
<Radio checked={checked} />
|
||||||
|
</StyledRadioContainer>
|
||||||
|
{children}
|
||||||
|
</StyledSubscriptionCardContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -74,7 +74,6 @@ export const DefaultLayout = ({ children }: DefaultLayoutProps) => {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const widowsWidth = useScreenSize().width;
|
const widowsWidth = useScreenSize().width;
|
||||||
const isMatchingLocation = useIsMatchingLocation();
|
const isMatchingLocation = useIsMatchingLocation();
|
||||||
|
|
||||||
const showAuthModal = useMemo(() => {
|
const showAuthModal = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
(onboardingStatus && onboardingStatus !== OnboardingStatus.Completed) ||
|
(onboardingStatus && onboardingStatus !== OnboardingStatus.Completed) ||
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export type FeatureFlagKey =
|
|||||||
| 'IS_CALENDAR_ENABLED'
|
| 'IS_CALENDAR_ENABLED'
|
||||||
| 'IS_MESSAGING_ENABLED'
|
| 'IS_MESSAGING_ENABLED'
|
||||||
| 'IS_NEW_RECORD_BOARD_ENABLED'
|
| 'IS_NEW_RECORD_BOARD_ENABLED'
|
||||||
| 'IS_QUICK_ACTIONS_ENABLED';
|
| 'IS_QUICK_ACTIONS_ENABLED'
|
||||||
|
| 'IS_SELF_BILLING_ENABLED';
|
||||||
|
|||||||
133
packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx
Normal file
133
packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx
Normal file
@@ -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 && (
|
||||||
|
<>
|
||||||
|
<Title withMarginTop={false}>Choose your Plan</Title>
|
||||||
|
<SubTitle>
|
||||||
|
Enjoy a {billing?.billingFreeTrialDurationInDays}-day free trial
|
||||||
|
</SubTitle>
|
||||||
|
<StyledChoosePlanContainer>
|
||||||
|
{prices.getProductPrices.productPrices.map((price, index) => (
|
||||||
|
<CardPicker
|
||||||
|
checked={price.recurringInterval === planSelected}
|
||||||
|
handleChange={handlePlanChange(price.recurringInterval)}
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
<SubscriptionCard
|
||||||
|
type={price.recurringInterval}
|
||||||
|
price={price.unitAmount / 100}
|
||||||
|
info={computeInfo(price, prices.getProductPrices.productPrices)}
|
||||||
|
/>
|
||||||
|
</CardPicker>
|
||||||
|
))}
|
||||||
|
</StyledChoosePlanContainer>
|
||||||
|
<StyledBenefitsContainer>
|
||||||
|
<SubscriptionBenefit>Full access</SubscriptionBenefit>
|
||||||
|
<SubscriptionBenefit>Unlimited contacts</SubscriptionBenefit>
|
||||||
|
<SubscriptionBenefit>Email integration</SubscriptionBenefit>
|
||||||
|
<SubscriptionBenefit>Custom objects</SubscriptionBenefit>
|
||||||
|
<SubscriptionBenefit>API & Webhooks</SubscriptionBenefit>
|
||||||
|
<SubscriptionBenefit>Frequent updates</SubscriptionBenefit>
|
||||||
|
<SubscriptionBenefit>And much more</SubscriptionBenefit>
|
||||||
|
</StyledBenefitsContainer>
|
||||||
|
<MainButton title="Continue" onClick={handleButtonClick} width={200} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -141,7 +141,7 @@ export const CreateProfile = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title>Create profile</Title>
|
<Title withMarginTop={false}>Create profile</Title>
|
||||||
<SubTitle>How you'll be identified on the app.</SubTitle>
|
<SubTitle>How you'll be identified on the app.</SubTitle>
|
||||||
<StyledContentContainer>
|
<StyledContentContainer>
|
||||||
<StyledSectionContainer>
|
<StyledSectionContainer>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Controller, SubmitHandler, useForm } from 'react-hook-form';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Key } from 'ts-key-enum';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { SubTitle } from '@/auth/components/SubTitle';
|
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 { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient';
|
||||||
import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader';
|
import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader';
|
||||||
import { AppPath } from '@/types/AppPath';
|
import { AppPath } from '@/types/AppPath';
|
||||||
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
|
|
||||||
import { H2Title } from '@/ui/display/typography/components/H2Title';
|
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 { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||||
import { MainButton } from '@/ui/input/button/components/MainButton';
|
import { MainButton } from '@/ui/input/button/components/MainButton';
|
||||||
import { TextInput } from '@/ui/input/components/TextInput';
|
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 { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
|
||||||
import { useActivateWorkspaceMutation } from '~/generated/graphql';
|
import { useActivateWorkspaceMutation } from '~/generated/graphql';
|
||||||
|
|
||||||
@@ -57,7 +57,6 @@ export const CreateWorkspace = () => {
|
|||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { isValid, isSubmitting },
|
formState: { isValid, isSubmitting },
|
||||||
getValues,
|
|
||||||
} = useForm<Form>({
|
} = useForm<Form>({
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -99,28 +98,19 @@ export const CreateWorkspace = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === Key.Enter) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleSubmit(onSubmit)();
|
handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useScopedHotkeys(
|
|
||||||
'enter',
|
|
||||||
() => {
|
|
||||||
onSubmit(getValues());
|
|
||||||
},
|
|
||||||
PageHotkeyScope.CreateWokspace,
|
|
||||||
[onSubmit],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (onboardingStatus !== OnboardingStatus.OngoingWorkspaceActivation) {
|
if (onboardingStatus !== OnboardingStatus.OngoingWorkspaceActivation) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title>Create your workspace</Title>
|
<Title withMarginTop={false}>Create your workspace</Title>
|
||||||
<SubTitle>
|
<SubTitle>
|
||||||
A shared environment where you will be able to manage your customer
|
A shared environment where you will be able to manage your customer
|
||||||
relations with your team.
|
relations with your team.
|
||||||
@@ -162,6 +152,7 @@ export const CreateWorkspace = () => {
|
|||||||
title="Continue"
|
title="Continue"
|
||||||
onClick={handleSubmit(onSubmit)}
|
onClick={handleSubmit(onSubmit)}
|
||||||
disabled={!isValid || isSubmitting}
|
disabled={!isValid || isSubmitting}
|
||||||
|
Icon={() => isSubmitting && <Loader />}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</StyledButtonContainer>
|
</StyledButtonContainer>
|
||||||
|
|||||||
53
packages/twenty-front/src/pages/auth/PaymentSuccess.tsx
Normal file
53
packages/twenty-front/src/pages/auth/PaymentSuccess.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTheme } from '@emotion/react';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
|
import { SubTitle } from '@/auth/components/SubTitle.tsx';
|
||||||
|
import { Title } from '@/auth/components/Title.tsx';
|
||||||
|
import { AppPath } from '@/types/AppPath.ts';
|
||||||
|
import { IconCheck } from '@/ui/display/icon';
|
||||||
|
import { MainButton } from '@/ui/input/button/components/MainButton.tsx';
|
||||||
|
import { RGBA } from '@/ui/theme/constants/Rgba.ts';
|
||||||
|
import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn.tsx';
|
||||||
|
|
||||||
|
const StyledCheckContainer = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px solid ${(props) => props.color};
|
||||||
|
border-radius: ${({ theme }) => theme.border.radius.rounded};
|
||||||
|
box-shadow: ${(props) =>
|
||||||
|
props.color && `-4px 4px 0 -2px ${RGBA(props.color, 1)}`};
|
||||||
|
height: 36px;
|
||||||
|
width: 36px;
|
||||||
|
margin-bottom: ${({ theme }) => theme.spacing(4)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledButtonContainer = styled.div`
|
||||||
|
margin-top: ${({ theme }) => theme.spacing(8)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PaymentSuccess = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const theme = useTheme();
|
||||||
|
const handleButtonClick = () => {
|
||||||
|
navigate(AppPath.CreateWorkspace);
|
||||||
|
};
|
||||||
|
const color =
|
||||||
|
theme.name === 'light' ? theme.grayScale.gray90 : theme.grayScale.gray10;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AnimatedEaseIn>
|
||||||
|
<StyledCheckContainer color={color}>
|
||||||
|
<IconCheck color={color} size={24} stroke={3} />
|
||||||
|
</StyledCheckContainer>
|
||||||
|
</AnimatedEaseIn>
|
||||||
|
<Title>All set!</Title>
|
||||||
|
<SubTitle>Your account has been activated.</SubTitle>
|
||||||
|
<StyledButtonContainer>
|
||||||
|
<MainButton title="Start" onClick={handleButtonClick} width={200} />
|
||||||
|
</StyledButtonContainer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from 'react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
|
|
||||||
@@ -6,13 +7,12 @@ import { SubTitle } from '@/auth/components/SubTitle';
|
|||||||
import { Title } from '@/auth/components/Title';
|
import { Title } from '@/auth/components/Title';
|
||||||
import { billingState } from '@/client-config/states/billingState';
|
import { billingState } from '@/client-config/states/billingState';
|
||||||
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
|
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
|
||||||
import { MainButton } from '@/ui/input/button/components/MainButton';
|
import { MainButton } from '@/ui/input/button/components/MainButton.tsx';
|
||||||
import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn';
|
import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn';
|
||||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||||
|
|
||||||
const StyledButtonContainer = styled.div`
|
const StyledButtonContainer = styled.div`
|
||||||
margin-top: ${({ theme }) => theme.spacing(8)};
|
margin-top: ${({ theme }) => theme.spacing(8)};
|
||||||
width: 200px;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const PlanRequired = () => {
|
export const PlanRequired = () => {
|
||||||
@@ -36,7 +36,11 @@ export const PlanRequired = () => {
|
|||||||
Please select a subscription plan before proceeding to sign in.
|
Please select a subscription plan before proceeding to sign in.
|
||||||
</SubTitle>
|
</SubTitle>
|
||||||
<StyledButtonContainer>
|
<StyledButtonContainer>
|
||||||
<MainButton title="Get started" onClick={handleButtonClick} fullWidth />
|
<MainButton
|
||||||
|
title="Get started"
|
||||||
|
onClick={handleButtonClick}
|
||||||
|
width={200}
|
||||||
|
/>
|
||||||
</StyledButtonContainer>
|
</StyledButtonContainer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const mockedClientConfig = {
|
|||||||
billing: {
|
billing: {
|
||||||
isBillingEnabled: true,
|
isBillingEnabled: true,
|
||||||
billingUrl: '',
|
billingUrl: '',
|
||||||
|
billingFreeTrialDurationInDays: 10,
|
||||||
__typename: 'Billing',
|
__typename: 'Billing',
|
||||||
},
|
},
|
||||||
__typename: 'ClientConfig',
|
__typename: 'ClientConfig',
|
||||||
|
|||||||
@@ -1,31 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
Headers,
|
Headers,
|
||||||
Req,
|
Req,
|
||||||
RawBodyRequest,
|
RawBodyRequest,
|
||||||
Logger,
|
Logger,
|
||||||
Post,
|
Post,
|
||||||
Res,
|
Res,
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
|
||||||
import {
|
import { BillingService, WebhookEvent } from 'src/core/billing/billing.service';
|
||||||
AvailableProduct,
|
|
||||||
BillingService,
|
|
||||||
PriceData,
|
|
||||||
RecurringInterval,
|
|
||||||
WebhookEvent,
|
|
||||||
} from 'src/core/billing/billing.service';
|
|
||||||
import { StripeService } from 'src/core/billing/stripe/stripe.service';
|
import { StripeService } from 'src/core/billing/stripe/stripe.service';
|
||||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
|
||||||
import { AuthUser } from 'src/decorators/auth/auth-user.decorator';
|
|
||||||
import { User } from 'src/core/user/user.entity';
|
|
||||||
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
|
|
||||||
|
|
||||||
@Controller('billing')
|
@Controller('billing')
|
||||||
export class BillingController {
|
export class BillingController {
|
||||||
@@ -34,96 +20,8 @@ export class BillingController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly stripeService: StripeService,
|
private readonly stripeService: StripeService,
|
||||||
private readonly billingService: BillingService,
|
private readonly billingService: BillingService,
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('/product-prices/:product')
|
|
||||||
async get(
|
|
||||||
@Param() params: { product: AvailableProduct },
|
|
||||||
@Res() res: Response<PriceData | { error: string }>,
|
|
||||||
) {
|
|
||||||
const stripeProductId = this.billingService.getProductStripeId(
|
|
||||||
params.product,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!stripeProductId) {
|
|
||||||
res.status(404).send({
|
|
||||||
error: `Product '${
|
|
||||||
params.product
|
|
||||||
}' not found, available products are ['${Object.values(
|
|
||||||
AvailableProduct,
|
|
||||||
).join("','")}']`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(await this.billingService.getProductPrices(stripeProductId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Post('/checkout')
|
|
||||||
async post(
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@Body() body: { recurringInterval: RecurringInterval },
|
|
||||||
@Res() res: Response,
|
|
||||||
) {
|
|
||||||
const productId = this.billingService.getProductStripeId(
|
|
||||||
AvailableProduct.BasePlan,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!productId) {
|
|
||||||
res
|
|
||||||
.status(404)
|
|
||||||
.send(
|
|
||||||
'BasePlan productId not found, please check your BILLING_STRIPE_BASE_PLAN_PRODUCT_ID env variable',
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const productPrices = await this.billingService.getProductPrices(productId);
|
|
||||||
const recurringInterval = body.recurringInterval;
|
|
||||||
const priceId = productPrices[recurringInterval]?.id;
|
|
||||||
|
|
||||||
if (!priceId) {
|
|
||||||
res
|
|
||||||
.status(404)
|
|
||||||
.send(
|
|
||||||
`BasePlan priceId not found, please check body.recurringInterval and product '${AvailableProduct.BasePlan}' prices`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const frontBaseUrl = this.environmentService.getFrontBaseUrl();
|
|
||||||
const session = await this.stripeService.stripe.checkout.sessions.create({
|
|
||||||
line_items: [
|
|
||||||
{
|
|
||||||
price: priceId,
|
|
||||||
quantity: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
mode: 'subscription',
|
|
||||||
subscription_data: {
|
|
||||||
metadata: {
|
|
||||||
workspaceId: user.defaultWorkspace.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
customer_email: user.email,
|
|
||||||
success_url: frontBaseUrl,
|
|
||||||
cancel_url: frontBaseUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!session.url) {
|
|
||||||
res.status(400).send('Error: missing checkout.session.url');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.logger.log(`Stripe Checkout Session Url Redirection: ${session.url}`);
|
|
||||||
|
|
||||||
res.redirect(303, session.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('/webhooks')
|
@Post('/webhooks')
|
||||||
async handleWebhooks(
|
async handleWebhooks(
|
||||||
@Headers('stripe-signature') signature: string,
|
@Headers('stripe-signature') signature: string,
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { BillingController } from 'src/core/billing/billing.controller';
|
import { BillingController } from 'src/core/billing/billing.controller';
|
||||||
import { EnvironmentModule } from 'src/integrations/environment/environment.module';
|
|
||||||
import { BillingService } from 'src/core/billing/billing.service';
|
import { BillingService } from 'src/core/billing/billing.service';
|
||||||
import { StripeModule } from 'src/core/billing/stripe/stripe.module';
|
import { StripeModule } from 'src/core/billing/stripe/stripe.module';
|
||||||
import { BillingSubscription } from 'src/core/billing/entities/billing-subscription.entity';
|
import { BillingSubscription } from 'src/core/billing/entities/billing-subscription.entity';
|
||||||
import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subscription-item.entity';
|
import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subscription-item.entity';
|
||||||
import { Workspace } from 'src/core/workspace/workspace.entity';
|
import { Workspace } from 'src/core/workspace/workspace.entity';
|
||||||
|
import { BillingResolver } from 'src/core/billing/billing.resolver';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -18,6 +18,6 @@ import { Workspace } from 'src/core/workspace/workspace.entity';
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
controllers: [BillingController],
|
controllers: [BillingController],
|
||||||
providers: [EnvironmentModule, BillingService],
|
providers: [BillingService, BillingResolver],
|
||||||
})
|
})
|
||||||
export class BillingModule {}
|
export class BillingModule {}
|
||||||
|
|||||||
76
packages/twenty-server/src/core/billing/billing.resolver.ts
Normal file
76
packages/twenty-server/src/core/billing/billing.resolver.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||||
|
import { UseGuards } from '@nestjs/common';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AvailableProduct,
|
||||||
|
BillingService,
|
||||||
|
} from 'src/core/billing/billing.service';
|
||||||
|
import { ProductInput } from 'src/core/billing/dto/product.input';
|
||||||
|
import { assert } from 'src/utils/assert';
|
||||||
|
import { ProductPricesEntity } from 'src/core/billing/dto/product-prices.entity';
|
||||||
|
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
|
||||||
|
import { AuthUser } from 'src/decorators/auth/auth-user.decorator';
|
||||||
|
import { User } from 'src/core/user/user.entity';
|
||||||
|
import { CheckoutInput } from 'src/core/billing/dto/checkout.input';
|
||||||
|
import { CheckoutEntity } from 'src/core/billing/dto/checkout.entity';
|
||||||
|
|
||||||
|
@Resolver()
|
||||||
|
export class BillingResolver {
|
||||||
|
constructor(private readonly billingService: BillingService) {}
|
||||||
|
|
||||||
|
@Query(() => ProductPricesEntity)
|
||||||
|
async getProductPrices(@Args() { product }: ProductInput) {
|
||||||
|
const stripeProductId = this.billingService.getProductStripeId(product);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
stripeProductId,
|
||||||
|
`Product '${product}' not found, available products are ['${Object.values(
|
||||||
|
AvailableProduct,
|
||||||
|
).join("','")}']`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const productPrices =
|
||||||
|
await this.billingService.getProductPrices(stripeProductId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalNumberOfPrices: productPrices.length,
|
||||||
|
productPrices: productPrices,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => CheckoutEntity)
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async checkout(
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@Args() { recurringInterval, successUrlPath }: CheckoutInput,
|
||||||
|
) {
|
||||||
|
const stripeProductId = this.billingService.getProductStripeId(
|
||||||
|
AvailableProduct.BasePlan,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
stripeProductId,
|
||||||
|
'BasePlan productId not found, please check your BILLING_STRIPE_BASE_PLAN_PRODUCT_ID env variable',
|
||||||
|
);
|
||||||
|
|
||||||
|
const productPrices =
|
||||||
|
await this.billingService.getProductPrices(stripeProductId);
|
||||||
|
|
||||||
|
const stripePriceId = productPrices.filter(
|
||||||
|
(price) => price.recurringInterval === recurringInterval,
|
||||||
|
)?.[0]?.stripePriceId;
|
||||||
|
|
||||||
|
assert(
|
||||||
|
stripePriceId,
|
||||||
|
`BasePlan priceId not found, please check body.recurringInterval and product '${AvailableProduct.BasePlan}' prices`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: await this.billingService.checkout(
|
||||||
|
user,
|
||||||
|
stripePriceId,
|
||||||
|
successUrlPath,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
@@ -9,17 +9,13 @@ import { StripeService } from 'src/core/billing/stripe/stripe.service';
|
|||||||
import { BillingSubscription } from 'src/core/billing/entities/billing-subscription.entity';
|
import { BillingSubscription } from 'src/core/billing/entities/billing-subscription.entity';
|
||||||
import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subscription-item.entity';
|
import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subscription-item.entity';
|
||||||
import { Workspace } from 'src/core/workspace/workspace.entity';
|
import { Workspace } from 'src/core/workspace/workspace.entity';
|
||||||
|
import { ProductPriceEntity } from 'src/core/billing/dto/product-price.entity';
|
||||||
|
import { User } from 'src/core/user/user.entity';
|
||||||
|
import { assert } from 'src/utils/assert';
|
||||||
|
|
||||||
export type PriceData = Partial<
|
|
||||||
Record<Stripe.Price.Recurring.Interval, Stripe.Price>
|
|
||||||
>;
|
|
||||||
export enum AvailableProduct {
|
export enum AvailableProduct {
|
||||||
BasePlan = 'base-plan',
|
BasePlan = 'base-plan',
|
||||||
}
|
}
|
||||||
export enum RecurringInterval {
|
|
||||||
MONTH = 'month',
|
|
||||||
YEAR = 'year',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum WebhookEvent {
|
export enum WebhookEvent {
|
||||||
CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated',
|
CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated',
|
||||||
@@ -27,6 +23,7 @@ export enum WebhookEvent {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingService {
|
export class BillingService {
|
||||||
|
protected readonly logger = new Logger(BillingService.name);
|
||||||
constructor(
|
constructor(
|
||||||
private readonly stripeService: StripeService,
|
private readonly stripeService: StripeService,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
@@ -53,23 +50,57 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
formatProductPrices(prices: Stripe.Price[]) {
|
formatProductPrices(prices: Stripe.Price[]) {
|
||||||
const result: PriceData = {};
|
const result: Record<string, ProductPriceEntity> = {};
|
||||||
|
|
||||||
prices.forEach((item) => {
|
prices.forEach((item) => {
|
||||||
const recurringInterval = item.recurring?.interval;
|
const interval = item.recurring?.interval;
|
||||||
|
|
||||||
if (!recurringInterval) {
|
if (!interval || !item.unit_amount) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!result[recurringInterval] ||
|
!result[interval] ||
|
||||||
item.created > (result[recurringInterval]?.created || 0)
|
item.created > (result[interval]?.created || 0)
|
||||||
) {
|
) {
|
||||||
result[recurringInterval] = item;
|
result[interval] = {
|
||||||
|
unitAmount: item.unit_amount,
|
||||||
|
recurringInterval: interval,
|
||||||
|
created: item.created,
|
||||||
|
stripePriceId: item.id,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return Object.values(result).sort((a, b) => a.unitAmount - b.unitAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkout(user: User, priceId: string, successUrlPath?: string) {
|
||||||
|
const frontBaseUrl = this.environmentService.getFrontBaseUrl();
|
||||||
|
const session = await this.stripeService.stripe.checkout.sessions.create({
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price: priceId,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mode: 'subscription',
|
||||||
|
subscription_data: {
|
||||||
|
metadata: {
|
||||||
|
workspaceId: user.defaultWorkspace.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
customer_email: user.email,
|
||||||
|
success_url: successUrlPath
|
||||||
|
? frontBaseUrl + successUrlPath
|
||||||
|
: frontBaseUrl,
|
||||||
|
cancel_url: frontBaseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(session.url, 'Error: missing checkout.session.url');
|
||||||
|
|
||||||
|
this.logger.log(`Stripe Checkout Session Url Redirection: ${session.url}`);
|
||||||
|
|
||||||
|
return session.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createBillingSubscription(
|
async createBillingSubscription(
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class CheckoutEntity {
|
||||||
|
@Field(() => String)
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ArgsType, Field } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
|
@ArgsType()
|
||||||
|
export class CheckoutInput {
|
||||||
|
@Field(() => String)
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
recurringInterval: Stripe.Price.Recurring.Interval;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
successUrlPath?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class ProductPriceEntity {
|
||||||
|
@Field(() => String)
|
||||||
|
recurringInterval: Stripe.Price.Recurring.Interval;
|
||||||
|
|
||||||
|
@Field(() => Number)
|
||||||
|
unitAmount: number;
|
||||||
|
|
||||||
|
@Field(() => Number)
|
||||||
|
created: number;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
stripePriceId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { ProductPriceEntity } from 'src/core/billing/dto/product-price.entity';
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class ProductPricesEntity {
|
||||||
|
@Field(() => Int)
|
||||||
|
totalNumberOfPrices: number;
|
||||||
|
|
||||||
|
@Field(() => [ProductPriceEntity])
|
||||||
|
productPrices: ProductPriceEntity[];
|
||||||
|
}
|
||||||
13
packages/twenty-server/src/core/billing/dto/product.input.ts
Normal file
13
packages/twenty-server/src/core/billing/dto/product.input.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { ArgsType, Field } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
import { AvailableProduct } from 'src/core/billing/billing.service';
|
||||||
|
|
||||||
|
@ArgsType()
|
||||||
|
export class ProductInput {
|
||||||
|
@Field(() => String)
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
product: AvailableProduct;
|
||||||
|
}
|
||||||
@@ -28,6 +28,9 @@ class Billing {
|
|||||||
|
|
||||||
@Field(() => String)
|
@Field(() => String)
|
||||||
billingUrl: string;
|
billingUrl: string;
|
||||||
|
|
||||||
|
@Field(() => Number, { nullable: true })
|
||||||
|
billingFreeTrialDurationInDays: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export class ClientConfigResolver {
|
|||||||
billing: {
|
billing: {
|
||||||
isBillingEnabled: this.environmentService.isBillingEnabled(),
|
isBillingEnabled: this.environmentService.isBillingEnabled(),
|
||||||
billingUrl: this.environmentService.getBillingUrl(),
|
billingUrl: this.environmentService.getBillingUrl(),
|
||||||
|
billingFreeTrialDurationInDays:
|
||||||
|
this.environmentService.getBillingFreeTrialDurationInDays(),
|
||||||
},
|
},
|
||||||
signInPrefilled: this.environmentService.isSignInPrefilled(),
|
signInPrefilled: this.environmentService.isSignInPrefilled(),
|
||||||
signUpDisabled: this.environmentService.isSignUpDisabled(),
|
signUpDisabled: this.environmentService.isSignUpDisabled(),
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ export class EnvironmentService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getBillingFreeTrialDurationInDays(): number {
|
||||||
|
return (
|
||||||
|
this.configService.get<number>('BILLING_FREE_TRIAL_DURATION_IN_DAYS') ?? 7
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
isTelemetryEnabled(): boolean {
|
isTelemetryEnabled(): boolean {
|
||||||
return this.configService.get<boolean>('TELEMETRY_ENABLED') ?? true;
|
return this.configService.get<boolean>('TELEMETRY_ENABLED') ?? true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ export class EnvironmentVariables {
|
|||||||
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
|
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
|
||||||
BILLING_STRIPE_BASE_PLAN_PRODUCT_ID?: string;
|
BILLING_STRIPE_BASE_PLAN_PRODUCT_ID?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@CastToPositiveNumber()
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
|
||||||
|
BILLING_FREE_TRIAL_DURATION_IN_DAYS?: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
|
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
|
||||||
BILLING_STRIPE_API_KEY?: string;
|
BILLING_STRIPE_API_KEY?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user