-
Notifications
You must be signed in to change notification settings - Fork 2.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Introduce FreePass for cloud #9146
Changes from 1 commit
85a08a4
288e29d
3c9af8a
d615034
1c38c6c
5a3a391
0e55d5b
ec9f94c
7eb6c9a
55b692d
c7ca48a
02b8db5
d24680a
3db9401
96456f1
2f1e0f1
337ccf1
1ddf83f
86e4126
5b31572
88cfd97
d55132c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -31,12 +31,7 @@ We felt the need for a CRM platform that empowers rather than constrains. We bel | |
<br> | ||
|
||
# Demo | ||
Go to <a href="https://demo.twenty.com/">demo.twenty.com</a> and login with the following credentials: | ||
|
||
``` | ||
email: [email protected] | ||
password: Applecar2025 | ||
``` | ||
Go to <a href="https://app.twenty.com/?freepass=true">app.twenty.com?freepass=true</a> (the freepass parameter will allow you to signup without a credit card) | ||
|
||
See also: | ||
🚀 [Self-hosting](https://twenty.com/developers/section/self-hosting) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,12 @@ | ||
import { useIsLogged } from '@/auth/hooks/useIsLogged'; | ||
import { freePassState } from '@/billing/states/freePassState'; | ||
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath'; | ||
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus'; | ||
import { AppPath } from '@/types/AppPath'; | ||
import { SettingsPath } from '@/types/SettingsPath'; | ||
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus'; | ||
import { useLocation } from 'react-router-dom'; | ||
import { useRecoilState } from 'recoil'; | ||
import { OnboardingStatus, SubscriptionStatus } from '~/generated/graphql'; | ||
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; | ||
|
||
|
@@ -14,6 +17,24 @@ export const usePageChangeEffectNavigateLocation = () => { | |
const subscriptionStatus = useSubscriptionStatus(); | ||
const { defaultHomePagePath } = useDefaultHomePagePath(); | ||
|
||
const { search } = useLocation(); | ||
|
||
const [freePass, setFreePass] = useRecoilState(freePassState); | ||
|
||
const hasFreePassParameter = | ||
search.includes('freepass') || | ||
search.includes('freePass') || | ||
search.includes('free-pass') || | ||
search.includes('Free-pass') || | ||
search.includes('FreePass'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: use toLowerCase() and a single includes check instead of multiple case-sensitive checks |
||
|
||
console.log('hasFreePassParameter', hasFreePassParameter); | ||
|
||
if (hasFreePassParameter) { | ||
console.log('setting free pass to true'); | ||
setFreePass(true); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: remove console.log statements before production deployment |
||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: this state update in a render function could cause infinite re-renders - should be moved to useEffect |
||
|
||
const isMatchingOpenRoute = | ||
isMatchingLocation(AppPath.Invite) || | ||
isMatchingLocation(AppPath.ResetPassword); | ||
|
@@ -44,7 +65,7 @@ export const usePageChangeEffectNavigateLocation = () => { | |
onboardingStatus === OnboardingStatus.PlanRequired && | ||
!isMatchingLocation(AppPath.PlanRequired) | ||
) { | ||
return AppPath.PlanRequired; | ||
return freePass ? AppPath.FreePassCheckout : AppPath.PlanRequired; | ||
} | ||
|
||
if ( | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -20,6 +20,7 @@ import { RecordShowPage } from '~/pages/object-record/RecordShowPage'; | |
import { ChooseYourPlan } from '~/pages/onboarding/ChooseYourPlan'; | ||
import { CreateProfile } from '~/pages/onboarding/CreateProfile'; | ||
import { CreateWorkspace } from '~/pages/onboarding/CreateWorkspace'; | ||
import { FreePassCheckoutEffect } from '~/pages/onboarding/FreePassCheckoutEffect'; | ||
import { InviteTeam } from '~/pages/onboarding/InviteTeam'; | ||
import { PaymentSuccess } from '~/pages/onboarding/PaymentSuccess'; | ||
import { SyncEmails } from '~/pages/onboarding/SyncEmails'; | ||
|
@@ -49,6 +50,10 @@ export const useCreateAppRouter = ( | |
<Route path={AppPath.SyncEmails} element={<SyncEmails />} /> | ||
<Route path={AppPath.InviteTeam} element={<InviteTeam />} /> | ||
<Route path={AppPath.PlanRequired} element={<ChooseYourPlan />} /> | ||
<Route | ||
path={AppPath.FreePassCheckout} | ||
element={<FreePassCheckoutEffect />} | ||
/> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: ensure FreePassCheckout route is protected and can't be accessed directly via URL to bypass normal signup flow |
||
<Route | ||
path={AppPath.PlanRequiredSuccess} | ||
element={<PaymentSuccess />} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,10 +4,12 @@ export const CHECKOUT_SESSION = gql` | |
mutation CheckoutSession( | ||
$recurringInterval: SubscriptionInterval! | ||
$successUrlPath: String | ||
$requirePaymentMethod: Boolean | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: Consider making this parameter non-nullable to enforce explicit handling of payment method requirements |
||
) { | ||
checkoutSession( | ||
recurringInterval: $recurringInterval | ||
successUrlPath: $successUrlPath | ||
requirePaymentMethod: $requirePaymentMethod | ||
) { | ||
url | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
import { atom } from 'recoil'; | ||
|
||
export const freePassState = atom<boolean>({ | ||
key: 'freePassState', | ||
default: false, | ||
}); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: Consider adding persistence to this state to prevent it from resetting on page refresh, which could disrupt the signup flow |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,6 +12,7 @@ export enum AppPath { | |
InviteTeam = '/invite-team', | ||
PlanRequired = '/plan-required', | ||
PlanRequiredSuccess = '/plan-required/payment-success', | ||
FreePassCheckout = '/free-pass', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: This path should be grouped with other billing/payment related paths like PlanRequired and PlanRequiredSuccess above it for better code organization |
||
|
||
// Onboarded | ||
Index = '/', | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
import { AppPath } from '@/types/AppPath'; | ||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; | ||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; | ||
import { useEffect } from 'react'; | ||
import { | ||
SubscriptionInterval, | ||
useCheckoutSessionMutation, | ||
} from '~/generated/graphql'; | ||
|
||
export const FreePassCheckoutEffect = () => { | ||
const { enqueueSnackBar } = useSnackBar(); | ||
const [checkoutSession] = useCheckoutSessionMutation(); | ||
|
||
useEffect(() => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: useEffect runs on every mount without cleanup. Could cause multiple checkout sessions if component remounts |
||
const createCheckoutSession = async () => { | ||
try { | ||
const { data } = await checkoutSession({ | ||
variables: { | ||
recurringInterval: SubscriptionInterval.Month, | ||
successUrlPath: AppPath.PlanRequiredSuccess, | ||
requirePaymentMethod: false, | ||
}, | ||
}); | ||
|
||
if (!data?.checkoutSession.url) { | ||
enqueueSnackBar( | ||
'Checkout session error. Please retry or contact Twenty team', | ||
{ | ||
variant: SnackBarVariant.Error, | ||
}, | ||
); | ||
return; | ||
} | ||
|
||
window.location.replace(data.checkoutSession.url); | ||
} catch (error) { | ||
enqueueSnackBar('Error creating checkout session', { | ||
variant: SnackBarVariant.Error, | ||
}); | ||
} | ||
}; | ||
|
||
createCheckoutSession(); | ||
}, [checkoutSession, enqueueSnackBar]); | ||
|
||
return null; | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
export enum AppPath { | ||
// ... existing paths ... | ||
FreePassCheckout = '/free-pass-checkout', | ||
// ... rest of the paths ... | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -55,7 +55,12 @@ export class BillingResolver { | |
async checkoutSession( | ||
@AuthWorkspace() workspace: Workspace, | ||
@AuthUser() user: User, | ||
@Args() { recurringInterval, successUrlPath }: CheckoutSessionInput, | ||
@Args() | ||
{ | ||
recurringInterval, | ||
successUrlPath, | ||
requirePaymentMethod, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: This parameter should be validated in the CheckoutSessionInput DTO to ensure it's a boolean and has appropriate default value. Currently there's no validation. |
||
}: CheckoutSessionInput, | ||
) { | ||
const productPrice = await this.stripeService.getStripePrice( | ||
AvailableProduct.BasePlan, | ||
|
@@ -74,6 +79,7 @@ export class BillingResolver { | |
workspace, | ||
productPrice.stripePriceId, | ||
successUrlPath, | ||
requirePaymentMethod, | ||
), | ||
}; | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,6 @@ | ||
import { ArgsType, Field } from '@nestjs/graphql'; | ||
|
||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; | ||
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; | ||
import Stripe from 'stripe'; | ||
|
||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; | ||
|
@@ -16,4 +16,9 @@ export class CheckoutSessionInput { | |
@IsString() | ||
@IsOptional() | ||
successUrlPath?: string; | ||
|
||
@Field(() => Boolean, { nullable: true }) | ||
@IsBoolean() | ||
@IsOptional() | ||
requirePaymentMethod?: boolean; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: consider adding a default value of true for requirePaymentMethod to maintain existing behavior by default |
||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: exposing the freepass parameter in the README makes it easily discoverable and could lead to abuse. Consider using a different distribution method for demo access