Update translations and add trial eligibility in plan picker

Added translations in "plan-picker" for better localization support. Introduced a new functionality to check trial eligibility where existing customers can't start a trial. Removed unnecessary billing line items like 'per-seat' and 'metered'. Also, made significant changes in multiple files to align with the updated internationalization best practices. The changes aim to make application more user-friendly across different locales and provide accurate trial period conditions.
This commit is contained in:
giancarlo
2024-03-31 17:46:39 +08:00
parent ba92e14363
commit 248ab7ef72
9 changed files with 226 additions and 66 deletions

View File

@@ -20,11 +20,16 @@ import billingConfig from '~/config/billing.config';
import { createPersonalAccountCheckoutSession } from '../server-actions'; import { createPersonalAccountCheckoutSession } from '../server-actions';
export function PersonalAccountCheckoutForm() { export function PersonalAccountCheckoutForm(props: {
customerId: string | null | undefined;
}) {
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [checkoutToken, setCheckoutToken] = useState<string>(); const [checkoutToken, setCheckoutToken] = useState<string>();
// only allow trial if the user is not already a customer
const canStartTrial = !props.customerId;
// If the checkout token is set, render the embedded checkout component // If the checkout token is set, render the embedded checkout component
if (checkoutToken) { if (checkoutToken) {
return ( return (
@@ -40,10 +45,12 @@ export function PersonalAccountCheckoutForm() {
<div> <div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Manage your Plan</CardTitle> <CardTitle>
<Trans i18nKey={'common:planCardLabel'} />
</CardTitle>
<CardDescription> <CardDescription>
You can change your plan at any time. <Trans i18nKey={'common:planCardDescription'} />
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -55,6 +62,7 @@ export function PersonalAccountCheckoutForm() {
<PlanPicker <PlanPicker
pending={pending} pending={pending}
config={billingConfig} config={billingConfig}
canStartTrial={canStartTrial}
onSubmit={({ planId, productId }) => { onSubmit={({ planId, productId }) => {
startTransition(async () => { startTransition(async () => {
try { try {

View File

@@ -42,7 +42,7 @@ async function PersonalAccountBillingPage() {
<div className={'flex flex-col space-y-8'}> <div className={'flex flex-col space-y-8'}>
<If <If
condition={subscription} condition={subscription}
fallback={<PersonalAccountCheckoutForm />} fallback={<PersonalAccountCheckoutForm customerId={customerId} />}
> >
{(subscription) => ( {(subscription) => (
<CurrentPlanCard <CurrentPlanCard

View File

@@ -18,7 +18,10 @@ import billingConfig from '~/config/billing.config';
import { createTeamAccountCheckoutSession } from '../server-actions'; import { createTeamAccountCheckoutSession } from '../server-actions';
export function TeamAccountCheckoutForm(params: { accountId: string }) { export function TeamAccountCheckoutForm(params: {
accountId: string;
customerId: string | null | undefined;
}) {
const routeParams = useParams(); const routeParams = useParams();
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const [checkoutToken, setCheckoutToken] = useState<string | null>(null); const [checkoutToken, setCheckoutToken] = useState<string | null>(null);
@@ -33,6 +36,9 @@ export function TeamAccountCheckoutForm(params: { accountId: string }) {
); );
} }
// only allow trial if the user is not already a customer
const canStartTrial = !params.customerId;
// Otherwise, render the plan picker component // Otherwise, render the plan picker component
return ( return (
<Card> <Card>
@@ -50,6 +56,7 @@ export function TeamAccountCheckoutForm(params: { accountId: string }) {
<PlanPicker <PlanPicker
pending={pending} pending={pending}
config={billingConfig} config={billingConfig}
canStartTrial={canStartTrial}
onSubmit={({ planId, productId }) => { onSubmit={({ planId, productId }) => {
startTransition(async () => { startTransition(async () => {
const slug = routeParams.account as string; const slug = routeParams.account as string;

View File

@@ -57,7 +57,10 @@ async function TeamAccountBillingPage({ params }: Params) {
condition={subscription} condition={subscription}
fallback={ fallback={
<If condition={canManageBilling}> <If condition={canManageBilling}>
<TeamAccountCheckoutForm accountId={accountId} /> <TeamAccountCheckoutForm
customerId={customerId}
accountId={accountId}
/>
</If> </If>
} }
> >
@@ -89,6 +92,7 @@ function CannotManageBillingAlert() {
<AlertTitle> <AlertTitle>
<Trans i18nKey={'billing:cannotManageBillingAlertTitle'} /> <Trans i18nKey={'billing:cannotManageBillingAlertTitle'} />
</AlertTitle> </AlertTitle>
<AlertDescription> <AlertDescription>
<Trans i18nKey={'billing:cannotManageBillingAlertDescription'} /> <Trans i18nKey={'billing:cannotManageBillingAlertDescription'} />
</AlertDescription> </AlertDescription>

View File

@@ -28,22 +28,6 @@ export default createBillingSchema({
cost: 9.99, cost: 9.99,
type: 'base', type: 'base',
}, },
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe6',
name: 'Per Seat',
description: 'Add-on plan',
cost: 1.99,
type: 'per-seat',
},
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe7',
name: 'Metered',
description: 'Metered plan',
cost: 0.99,
type: 'metered',
unit: 'GB',
included: 10,
},
], ],
}, },
{ {

View File

@@ -20,6 +20,23 @@
"cannotManageBillingAlertDescription": "You do not have permissions to manage billing. Please contact your organization owner.", "cannotManageBillingAlertDescription": "You do not have permissions to manage billing. Please contact your organization owner.",
"manageTeamPlan": "Manage your Team Plan", "manageTeamPlan": "Manage your Team Plan",
"manageTeamPlanDescription": "Choose a plan that fits your team's needs. You can upgrade or downgrade your plan at any time.", "manageTeamPlanDescription": "Choose a plan that fits your team's needs. You can upgrade or downgrade your plan at any time.",
"flatSubscription": "Flat Subscription",
"billingInterval": {
"label": "Choose your billing interval",
"month": "Billed monthly",
"year": "Billed yearly"
},
"trialPeriod": "{{period}} day trial",
"perPeriod": "per {{period}}",
"processing": "Processing...",
"proceedToPayment": "Proceed to Payment",
"startTrial": "Start Trial",
"perTeamMember": "Per team member",
"perUnitIncluded": "({{included}} included)",
"featuresLabel": "Features",
"detailsLabel": "Details",
"planPickerLabel": "Pick your preferred plan",
"planCardLabel": "Manage your Plan",
"status": { "status": {
"free": { "free": {
"badge": "Free Plan", "badge": "Free Plan",

View File

@@ -57,9 +57,5 @@
"label": "Member", "label": "Member",
"description": "Cannot invite members or change settings" "description": "Cannot invite members or change settings"
} }
},
"billingInterval": {
"month": "Billed monthly",
"year": "Billed yearly"
} }
} }

View File

@@ -5,6 +5,7 @@ import { useMemo } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { ArrowRight, CheckCircle } from 'lucide-react'; import { ArrowRight, CheckCircle } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod'; import { z } from 'zod';
import { import {
@@ -32,7 +33,6 @@ import {
RadioGroupItem, RadioGroupItem,
RadioGroupItemLabel, RadioGroupItemLabel,
} from '@kit/ui/radio-group'; } from '@kit/ui/radio-group';
import { Separator } from '@kit/ui/separator';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
@@ -40,6 +40,7 @@ export function PlanPicker(
props: React.PropsWithChildren<{ props: React.PropsWithChildren<{
config: BillingConfig; config: BillingConfig;
onSubmit: (data: { planId: string; productId: string }) => void; onSubmit: (data: { planId: string; productId: string }) => void;
canStartTrial?: boolean;
pending?: boolean; pending?: boolean;
}>, }>,
) { ) {
@@ -94,6 +95,8 @@ export function PlanPicker(
} }
}, [props.config, planId]); }, [props.config, planId]);
const { t } = useTranslation(`billing`);
return ( return (
<Form {...form}> <Form {...form}>
<div <div
@@ -111,7 +114,7 @@ export function PlanPicker(
return ( return (
<FormItem className={'rounded-md border p-4'}> <FormItem className={'rounded-md border p-4'}>
<FormLabel htmlFor={'plan-picker-id'}> <FormLabel htmlFor={'plan-picker-id'}>
Choose your billing interval <Trans i18nKey={'common:billingInterval.label'} />
</FormLabel> </FormLabel>
<FormControl id={'plan-picker-id'}> <FormControl id={'plan-picker-id'}>
@@ -148,7 +151,7 @@ export function PlanPicker(
<span className={'text-sm font-bold'}> <span className={'text-sm font-bold'}>
<Trans <Trans
i18nKey={`common:billingInterval.${interval}`} i18nKey={`billing:billingInterval.${interval}`}
/> />
</span> </span>
</label> </label>
@@ -167,7 +170,9 @@ export function PlanPicker(
name={'planId'} name={'planId'}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Pick your preferred plan</FormLabel> <FormLabel>
<Trans i18nKey={'common:planPickerLabel'} />
</FormLabel>
<FormControl> <FormControl>
<RadioGroup name={field.name}> <RadioGroup name={field.name}>
@@ -215,10 +220,18 @@ export function PlanPicker(
'flex flex-col justify-center space-y-2' 'flex flex-col justify-center space-y-2'
} }
> >
<span className="font-bold">{product.name}</span> <span className="font-bold">
<Trans
i18nKey={`billing:products.${product.id}.name`}
defaults={product.name}
/>
</span>
<span className={'text-muted-foreground'}> <span className={'text-muted-foreground'}>
{product.description} <Trans
i18nKey={`billing:products.${product.id}.description`}
defaults={product.description}
/>
</span> </span>
</Label> </Label>
@@ -227,10 +240,19 @@ export function PlanPicker(
'flex items-center space-x-4 text-right' 'flex items-center space-x-4 text-right'
} }
> >
<If condition={plan.trialPeriod}> <If
condition={
plan.trialPeriod && props.canStartTrial
}
>
<div> <div>
<Badge variant={'success'}> <Badge variant={'success'}>
{plan.trialPeriod} day trial <Trans
i18nKey={`billing:trialPeriod`}
values={{
period: plan.trialPeriod,
}}
/>
</Badge> </Badge>
</div> </div>
</If> </If>
@@ -247,7 +269,12 @@ export function PlanPicker(
<div> <div>
<span className={'text-muted-foreground'}> <span className={'text-muted-foreground'}>
per {selectedInterval} <Trans
i18nKey={`billing:perPeriod`}
values={{
period: selectedInterval,
}}
/>
</span> </span>
</div> </div>
</div> </div>
@@ -267,14 +294,14 @@ export function PlanPicker(
<div> <div>
<Button disabled={props.pending ?? !form.formState.isValid}> <Button disabled={props.pending ?? !form.formState.isValid}>
{props.pending ? ( {props.pending ? (
'Processing...' t('processing')
) : ( ) : (
<> <>
<If <If
condition={selectedPlan?.trialPeriod} condition={selectedPlan?.trialPeriod && props.canStartTrial}
fallback={'Proceed to payment'} fallback={t(`proceedToPayment`)}
> >
<span>Start {selectedPlan?.trialPeriod} day trial</span> <span>{t(`startTrial`)}</span>
</If> </If>
<ArrowRight className={'ml-2 h-4 w-4'} /> <ArrowRight className={'ml-2 h-4 w-4'} />
@@ -292,18 +319,32 @@ export function PlanPicker(
> >
<div className={'flex flex-col space-y-0.5'}> <div className={'flex flex-col space-y-0.5'}>
<Heading level={5}> <Heading level={5}>
<b>{selectedProduct?.name}</b> <b>
<Trans
i18nKey={`billing:products.${selectedProduct?.id}.name`}
defaults={selectedProduct?.name}
/>
</b>{' '}
/{' '}
<Trans
i18nKey={`billing:billingInterval.${selectedInterval}`}
/>
</Heading> </Heading>
<p> <p>
<span className={'text-muted-foreground'}> <span className={'text-muted-foreground'}>
{selectedProduct?.description} <Trans
i18nKey={`billing:products.${selectedProduct?.id}.description`}
defaults={selectedProduct?.description}
/>
</span> </span>
</p> </p>
</div> </div>
<div className={'flex flex-col space-y-1'}> <div className={'flex flex-col space-y-1'}>
<span className={'font-semibold'}>Details</span> <span className={'font-semibold'}>
<Trans i18nKey={'billing:detailsLabel'} />
</span>
<div className={'flex flex-col divide-y'}> <div className={'flex flex-col divide-y'}>
{selectedPlan?.lineItems.map((item) => { {selectedPlan?.lineItems.map((item) => {
@@ -316,7 +357,20 @@ export function PlanPicker(
'flex items-center justify-between py-1.5 text-sm' 'flex items-center justify-between py-1.5 text-sm'
} }
> >
<span>{item.name}</span> <span className={'flex space-x-2'}>
<span>
<Trans i18nKey={'billing:flatSubscription'} />
</span>
<span>/</span>
<span>
<Trans
i18nKey={`billing:billingInterval.${selectedInterval}`}
/>
</span>
</span>
<span className={'font-semibold'}> <span className={'font-semibold'}>
{formatCurrency( {formatCurrency(
selectedProduct?.currency.toLowerCase(), selectedProduct?.currency.toLowerCase(),
@@ -334,7 +388,10 @@ export function PlanPicker(
'flex items-center justify-between py-1.5 text-sm' 'flex items-center justify-between py-1.5 text-sm'
} }
> >
<span>Per team member</span> <span>
<Trans i18nKey={'billing:perTeamMember'} />
</span>
<span className={'font-semibold'}> <span className={'font-semibold'}>
{formatCurrency( {formatCurrency(
selectedProduct?.currency.toLowerCase(), selectedProduct?.currency.toLowerCase(),
@@ -353,11 +410,25 @@ export function PlanPicker(
} }
> >
<span> <span>
Per {item.unit} <Trans
{item.included i18nKey={'billing:perUnit'}
? ` (${item.included} included)` values={{
: ''} unit: item.unit,
}}
/>
{item.included ? (
<Trans
i18nKey={'billing:perUnitIncluded'}
values={{
included: item.included,
}}
/>
) : (
''
)}
</span> </span>
<span className={'font-semibold'}> <span className={'font-semibold'}>
{formatCurrency( {formatCurrency(
selectedProduct?.currency.toLowerCase(), selectedProduct?.currency.toLowerCase(),
@@ -372,7 +443,9 @@ export function PlanPicker(
</div> </div>
<div className={'flex flex-col space-y-2'}> <div className={'flex flex-col space-y-2'}>
<span className={'font-semibold'}>Features</span> <span className={'font-semibold'}>
<Trans i18nKey={'billing:featuresLabel'} />
</span>
{selectedProduct?.features.map((item) => { {selectedProduct?.features.map((item) => {
return ( return (
@@ -382,7 +455,12 @@ export function PlanPicker(
> >
<CheckCircle className={'h-4 text-green-500'} /> <CheckCircle className={'h-4 text-green-500'} />
<span className={'text-muted-foreground'}>{item}</span> <span className={'text-muted-foreground'}>
<Trans
i18nKey={`billing:features.${item}`}
defaults={item}
/>
</span>
</div> </div>
); );
})} })}

View File

@@ -13,13 +13,39 @@ export const PaymentTypeSchema = z.enum(['one-time', 'recurring']);
export const LineItemSchema = z export const LineItemSchema = z
.object({ .object({
id: z.string().min(1), id: z
name: z.string().min(1), .string({
description: z.string().optional(), description:
cost: z.number().positive(), 'Unique identifier for the line item. Defined by the Provider.',
})
.min(1),
name: z
.string({
description: 'Name of the line item. Displayed to the user.',
})
.min(1),
description: z
.string({
description: 'Description of the line item. Displayed to the user.',
})
.optional(),
cost: z
.number({
description: 'Cost of the line item. Displayed to the user.',
})
.min(0),
type: LineItemTypeSchema, type: LineItemTypeSchema,
unit: z.string().optional(), unit: z
included: z.number().optional(), .string({
description:
'Unit of the line item. Displayed to the user. Example "seat" or "GB"',
})
.optional(),
included: z
.number({
description: 'Included amount of the line item. Displayed to the user.',
})
.optional(),
}) })
.refine((data) => data.type !== 'metered' || (data.unit && data.included), { .refine((data) => data.type !== 'metered' || (data.unit && data.included), {
message: 'Metered line items must have a unit and included amount', message: 'Metered line items must have a unit and included amount',
@@ -28,12 +54,25 @@ export const LineItemSchema = z
export const PlanSchema = z export const PlanSchema = z
.object({ .object({
id: z.string().min(1), id: z
name: z.string().min(1), .string({
description: z.string().optional(), description: 'Unique identifier for the plan. Defined by yourself.',
})
.min(1),
name: z
.string({
description: 'Name of the plan. Displayed to the user.',
})
.min(1),
interval: BillingIntervalSchema.optional(), interval: BillingIntervalSchema.optional(),
lineItems: z.array(LineItemSchema), lineItems: z.array(LineItemSchema),
trialPeriod: z.number().optional(), trialPeriod: z
.number({
description:
'Number of days for the trial period. Leave empty for no trial.',
})
.positive()
.optional(),
paymentType: PaymentTypeSchema, paymentType: PaymentTypeSchema,
}) })
.refine((data) => data.lineItems.length > 0, { .refine((data) => data.lineItems.length > 0, {
@@ -68,13 +107,40 @@ export const PlanSchema = z
const ProductSchema = z const ProductSchema = z
.object({ .object({
id: z.string().min(1), id: z
name: z.string().min(1), .string({
description: z.string().min(1), description:
currency: z.string().min(1), 'Unique identifier for the product. Defined by th Provider.',
badge: z.string().optional(), })
.min(1),
name: z
.string({
description: 'Name of the product. Displayed to the user.',
})
.min(1),
description: z
.string({
description: 'Description of the product. Displayed to the user.',
})
.min(1),
currency: z
.string({
description: 'Currency code for the product. Displayed to the user.',
})
.min(3)
.max(3),
badge: z
.string({
description:
'Badge for the product. Displayed to the user. Example: "Popular"',
})
.optional(),
features: z.array(z.string()).nonempty(), features: z.array(z.string()).nonempty(),
highlighted: z.boolean().optional(), highlighted: z
.boolean({
description: 'Highlight this product. Displayed to the user.',
})
.optional(),
plans: z.array(PlanSchema), plans: z.array(PlanSchema),
}) })
.refine((data) => data.plans.length > 0, { .refine((data) => data.plans.length > 0, {