Refactor and improve billing module
The billing module has been refined and enhanced to include deeper validation and detailing of billing plans and products. The checkout session creation process was revised to handle more complex scenarios, incorporating better parsing and validation. Additional validations were added for the plan and product schemas, improving product details extraction, and rearranging of module exports was made for better organization. The code refactor allows easier future modifications and upgrades for recurring and one-time payments with nuanced product configurations.
This commit is contained in:
@@ -78,7 +78,7 @@ function buildLazyComponent<
|
||||
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
{/* @ts-ignore */}
|
||||
{/* @ts-expect-error */}
|
||||
<LoadedComponent
|
||||
onClose={props.onClose}
|
||||
checkoutToken={props.checkoutToken}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BillingSchema } from '@kit/billing';
|
||||
import {
|
||||
BillingSchema,
|
||||
RecurringPlanSchema,
|
||||
getPlanIntervals,
|
||||
getProductPlanPairFromId,
|
||||
} from '@kit/billing';
|
||||
import { formatCurrency } from '@kit/shared/utils';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
@@ -28,15 +35,14 @@ import { cn } from '@kit/ui/utils';
|
||||
export function PlanPicker(
|
||||
props: React.PropsWithChildren<{
|
||||
config: z.infer<typeof BillingSchema>;
|
||||
onSubmit: (data: { planId: string }) => void;
|
||||
onSubmit: (data: { planId: string; productId: string }) => void;
|
||||
pending?: boolean;
|
||||
}>,
|
||||
) {
|
||||
const intervals = props.config.products.reduce<string[]>((acc, item) => {
|
||||
return Array.from(
|
||||
new Set([...acc, ...item.plans.map((plan) => plan.interval)]),
|
||||
);
|
||||
}, []);
|
||||
const intervals = useMemo(
|
||||
() => getPlanIntervals(props.config),
|
||||
[props.config],
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
reValidateMode: 'onChange',
|
||||
@@ -44,20 +50,17 @@ export function PlanPicker(
|
||||
resolver: zodResolver(
|
||||
z
|
||||
.object({
|
||||
planId: z.string(),
|
||||
interval: z.string(),
|
||||
planId: z.string().min(1),
|
||||
interval: z.string().min(1),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const planFound = props.config.products
|
||||
.flatMap((item) => item.plans)
|
||||
.some((plan) => plan.id === data.planId);
|
||||
const { product, plan } = getProductPlanPairFromId(
|
||||
props.config,
|
||||
data.planId,
|
||||
);
|
||||
|
||||
if (!planFound) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return intervals.includes(data.interval);
|
||||
return product && plan;
|
||||
},
|
||||
{ message: `Please pick a plan to continue`, path: ['planId'] },
|
||||
),
|
||||
@@ -65,6 +68,7 @@ export function PlanPicker(
|
||||
defaultValues: {
|
||||
interval: intervals[0],
|
||||
planId: '',
|
||||
productId: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,9 +85,11 @@ export function PlanPicker(
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className={'rounded-md border p-4'}>
|
||||
<FormLabel>Choose your billing interval</FormLabel>
|
||||
<FormLabel htmlFor={'plan-picker-id'}>
|
||||
Choose your billing interval
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<FormControl id={'plan-picker-id'}>
|
||||
<RadioGroup name={field.name} value={field.value}>
|
||||
<div className={'flex space-x-2.5'}>
|
||||
{intervals.map((interval) => {
|
||||
@@ -91,6 +97,7 @@ export function PlanPicker(
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={interval}
|
||||
key={interval}
|
||||
className={cn(
|
||||
'hover:bg-muted flex items-center space-x-2 rounded-md border border-transparent px-4 py-2',
|
||||
@@ -107,6 +114,7 @@ export function PlanPicker(
|
||||
form.setValue('planId', '', {
|
||||
shouldValidate: true,
|
||||
});
|
||||
|
||||
form.setValue('interval', interval, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
@@ -138,25 +146,38 @@ export function PlanPicker(
|
||||
|
||||
<FormControl>
|
||||
<RadioGroup name={field.name}>
|
||||
{props.config.products.map((item) => {
|
||||
const variant = item.plans.find(
|
||||
(plan) => plan.interval === selectedInterval,
|
||||
);
|
||||
{props.config.products.map((product) => {
|
||||
const plan =
|
||||
product.paymentType === 'one-time'
|
||||
? product.plans[0]
|
||||
: product.plans.find((item) => {
|
||||
if (
|
||||
'recurring' in item &&
|
||||
(item as z.infer<typeof RecurringPlanSchema>)
|
||||
.recurring.interval === selectedInterval
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
});
|
||||
|
||||
if (!variant) {
|
||||
throw new Error('No plan found');
|
||||
if (!plan) {
|
||||
throw new Error('Plan not found');
|
||||
}
|
||||
|
||||
return (
|
||||
<RadioGroupItemLabel
|
||||
selected={field.value === variant.id}
|
||||
key={variant.id}
|
||||
selected={field.value === plan.id}
|
||||
key={plan.id}
|
||||
>
|
||||
<RadioGroupItem
|
||||
id={variant.id}
|
||||
value={variant.id}
|
||||
id={plan.id}
|
||||
value={plan.id}
|
||||
onClick={() => {
|
||||
form.setValue('planId', variant.id, {
|
||||
form.setValue('planId', plan.id, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
|
||||
form.setValue('productId', product.id, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
@@ -166,23 +187,23 @@ export function PlanPicker(
|
||||
className={'flex w-full items-center justify-between'}
|
||||
>
|
||||
<Label
|
||||
htmlFor={variant.id}
|
||||
htmlFor={plan.id}
|
||||
className={'flex flex-col justify-center space-y-2'}
|
||||
>
|
||||
<span className="font-bold">{item.name}</span>
|
||||
<span className="font-bold">{product.name}</span>
|
||||
|
||||
<span className={'text-muted-foreground'}>
|
||||
{item.description}
|
||||
{product.description}
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<div className={'text-right'}>
|
||||
<div>
|
||||
<Price key={variant.id}>
|
||||
<Price key={plan.id}>
|
||||
<span>
|
||||
{formatCurrency(
|
||||
item.currency.toLowerCase(),
|
||||
variant.price,
|
||||
product.currency.toLowerCase(),
|
||||
plan.price,
|
||||
)}
|
||||
</span>
|
||||
</Price>
|
||||
@@ -190,7 +211,7 @@ export function PlanPicker(
|
||||
|
||||
<div>
|
||||
<span className={'text-muted-foreground'}>
|
||||
per {variant.interval}
|
||||
per {selectedInterval}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,7 +228,7 @@ export function PlanPicker(
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button disabled={props.pending || !form.formState.isValid}>
|
||||
<Button disabled={props.pending ?? !form.formState.isValid}>
|
||||
{props.pending ? (
|
||||
'Processing...'
|
||||
) : (
|
||||
|
||||
@@ -7,7 +7,13 @@ import Link from 'next/link';
|
||||
import { CheckCircle, Sparkles } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BillingSchema, getPlanIntervals } from '@kit/billing';
|
||||
import {
|
||||
BillingSchema,
|
||||
RecurringPlanInterval,
|
||||
RecurringPlanSchema,
|
||||
getPlanIntervals,
|
||||
} from '@kit/billing';
|
||||
import { formatCurrency } from '@kit/shared/utils';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { If } from '@kit/ui/if';
|
||||
@@ -15,6 +21,7 @@ import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
type Config = z.infer<typeof BillingSchema>;
|
||||
type Interval = z.infer<typeof RecurringPlanInterval>;
|
||||
|
||||
interface Paths {
|
||||
signUp: string;
|
||||
@@ -33,20 +40,20 @@ export function PricingTable({
|
||||
highlighted?: boolean;
|
||||
}>;
|
||||
}) {
|
||||
const intervals = getPlanIntervals(config);
|
||||
const intervals = getPlanIntervals(config).filter(Boolean);
|
||||
|
||||
const [planVariant, setPlanVariant] = useState<string>(
|
||||
intervals[0] as string,
|
||||
);
|
||||
const [interval, setInterval] = useState<Interval>(intervals[0]!);
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-12'}>
|
||||
<div className={'flex justify-center'}>
|
||||
<PlanIntervalSwitcher
|
||||
intervals={intervals}
|
||||
interval={planVariant}
|
||||
setInterval={setPlanVariant}
|
||||
/>
|
||||
{intervals.length ? (
|
||||
<PlanIntervalSwitcher
|
||||
intervals={intervals}
|
||||
interval={interval}
|
||||
setInterval={setInterval}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -56,21 +63,28 @@ export function PricingTable({
|
||||
}
|
||||
>
|
||||
{config.products.map((product) => {
|
||||
const plan = product.plans.find(
|
||||
(item) => item.interval === planVariant,
|
||||
const plan = product.plans.find((item) =>
|
||||
'recurring' in item
|
||||
? (item as z.infer<typeof RecurringPlanSchema>).recurring
|
||||
.interval === interval
|
||||
: true,
|
||||
);
|
||||
|
||||
if (!plan || product.hidden) {
|
||||
if (!plan) {
|
||||
console.warn(`No plan found for ${product.name}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (product.hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PricingItem
|
||||
selectable
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
plan={{ ...plan, interval }}
|
||||
product={product}
|
||||
paths={paths}
|
||||
CheckoutButton={CheckoutButtonRenderer}
|
||||
@@ -92,7 +106,7 @@ function PricingItem(
|
||||
|
||||
plan: {
|
||||
id: string;
|
||||
price: string;
|
||||
price: number;
|
||||
interval: string;
|
||||
name?: string;
|
||||
href?: string;
|
||||
@@ -158,8 +172,7 @@ function PricingItem(
|
||||
|
||||
<div className={'flex items-center space-x-1'}>
|
||||
<Price>
|
||||
<span className={'text-base'}>{props.product.currency}</span>
|
||||
{props.plan.price}
|
||||
{formatCurrency(props.product.currency, props.plan.price)}
|
||||
</Price>
|
||||
|
||||
<If condition={props.plan.name}>
|
||||
@@ -249,9 +262,9 @@ function ListItem({ children }: React.PropsWithChildren) {
|
||||
|
||||
function PlanIntervalSwitcher(
|
||||
props: React.PropsWithChildren<{
|
||||
intervals: string[];
|
||||
interval: string;
|
||||
setInterval: (interval: string) => void;
|
||||
intervals: Interval[];
|
||||
interval: Interval;
|
||||
setInterval: (interval: Interval) => void;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user