The pricing-table.tsx and plan-picker.tsx components under billing have been updated to improve UI spacing, layout, and text translations. The revisions allow for better localization through the use of the useTranslation hook from react-i18next. Layout changes include adjustment in spacing and removal of Sparkles icon. Certain product details are refactored to use translation keys for easier language support.
493 lines
16 KiB
TypeScript
493 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import { useMemo } from 'react';
|
|
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { ArrowRight, CheckCircle } from 'lucide-react';
|
|
import { useForm } from 'react-hook-form';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { z } from 'zod';
|
|
|
|
import {
|
|
BillingConfig,
|
|
LineItemSchema,
|
|
getPlanIntervals,
|
|
getPrimaryLineItem,
|
|
getProductPlanPair,
|
|
} from '@kit/billing';
|
|
import { formatCurrency } from '@kit/shared/utils';
|
|
import { Badge } from '@kit/ui/badge';
|
|
import { Button } from '@kit/ui/button';
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from '@kit/ui/form';
|
|
import { Heading } from '@kit/ui/heading';
|
|
import { If } from '@kit/ui/if';
|
|
import { Label } from '@kit/ui/label';
|
|
import {
|
|
RadioGroup,
|
|
RadioGroupItem,
|
|
RadioGroupItemLabel,
|
|
} from '@kit/ui/radio-group';
|
|
import { Separator } from '@kit/ui/separator';
|
|
import { Trans } from '@kit/ui/trans';
|
|
import { cn } from '@kit/ui/utils';
|
|
|
|
import { LineItemDetails } from './line-item-details';
|
|
|
|
export function PlanPicker(
|
|
props: React.PropsWithChildren<{
|
|
config: BillingConfig;
|
|
onSubmit: (data: { planId: string; productId: string }) => void;
|
|
canStartTrial?: boolean;
|
|
pending?: boolean;
|
|
}>,
|
|
) {
|
|
const intervals = useMemo(
|
|
() => getPlanIntervals(props.config),
|
|
[props.config],
|
|
) as string[];
|
|
|
|
const form = useForm({
|
|
reValidateMode: 'onChange',
|
|
mode: 'onChange',
|
|
resolver: zodResolver(
|
|
z
|
|
.object({
|
|
planId: z.string().min(1),
|
|
productId: z.string().min(1),
|
|
interval: z.string().min(1),
|
|
})
|
|
.refine(
|
|
(data) => {
|
|
try {
|
|
const { product, plan } = getProductPlanPair(
|
|
props.config,
|
|
data.planId,
|
|
);
|
|
|
|
return product && plan;
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
{ message: `Please pick a plan to continue`, path: ['planId'] },
|
|
),
|
|
),
|
|
defaultValues: {
|
|
interval: intervals[0],
|
|
planId: '',
|
|
productId: '',
|
|
},
|
|
});
|
|
|
|
const { interval: selectedInterval } = form.watch();
|
|
const planId = form.getValues('planId');
|
|
|
|
const { plan: selectedPlan, product: selectedProduct } = useMemo(() => {
|
|
try {
|
|
return getProductPlanPair(props.config, planId);
|
|
} catch {
|
|
return {
|
|
plan: null,
|
|
product: null,
|
|
};
|
|
}
|
|
}, [props.config, planId]);
|
|
|
|
const { t } = useTranslation(`billing`);
|
|
|
|
// display the period picker if the selected plan is recurring or if no plan is selected
|
|
const isRecurringPlan =
|
|
selectedPlan?.paymentType === 'recurring' || !selectedPlan;
|
|
|
|
return (
|
|
<Form {...form}>
|
|
<div
|
|
className={
|
|
'flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0'
|
|
}
|
|
>
|
|
<form
|
|
className={'flex w-full max-w-xl flex-col space-y-4'}
|
|
onSubmit={form.handleSubmit(props.onSubmit)}
|
|
>
|
|
<div
|
|
className={cn('transition-all', {
|
|
['pointer-events-none opacity-50']: !isRecurringPlan,
|
|
})}
|
|
>
|
|
<FormField
|
|
name={'interval'}
|
|
render={({ field }) => {
|
|
return (
|
|
<FormItem className={'rounded-md border p-4'}>
|
|
<FormLabel htmlFor={'plan-picker-id'}>
|
|
<Trans i18nKey={'common:billingInterval.label'} />
|
|
</FormLabel>
|
|
|
|
<FormControl id={'plan-picker-id'}>
|
|
<RadioGroup name={field.name} value={field.value}>
|
|
<div className={'flex space-x-2.5'}>
|
|
{intervals.map((interval) => {
|
|
const selected = field.value === interval;
|
|
|
|
return (
|
|
<label
|
|
htmlFor={interval}
|
|
key={interval}
|
|
className={cn(
|
|
'hover:bg-secondary flex items-center space-x-2 rounded-md border border-transparent px-4 py-2',
|
|
{
|
|
['border-primary bg-secondary']: selected,
|
|
['hover:bg-secondary']: !selected,
|
|
},
|
|
)}
|
|
>
|
|
<RadioGroupItem
|
|
id={interval}
|
|
value={interval}
|
|
onClick={() => {
|
|
form.setValue('interval', interval, {
|
|
shouldValidate: true,
|
|
});
|
|
|
|
if (selectedProduct) {
|
|
const plan = selectedProduct.plans.find(
|
|
(item) => item.interval === interval,
|
|
);
|
|
|
|
form.setValue('planId', plan?.id ?? '', {
|
|
shouldValidate: true,
|
|
shouldDirty: true,
|
|
shouldTouch: true,
|
|
});
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<span
|
|
className={cn('text-sm', {
|
|
['cursor-pointer']: !selected,
|
|
})}
|
|
>
|
|
<Trans
|
|
i18nKey={`billing:billingInterval.${interval}`}
|
|
/>
|
|
</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</RadioGroup>
|
|
</FormControl>
|
|
|
|
<FormMessage />
|
|
</FormItem>
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<FormField
|
|
name={'planId'}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>
|
|
<Trans i18nKey={'common:planPickerLabel'} />
|
|
</FormLabel>
|
|
|
|
<FormControl>
|
|
<RadioGroup value={field.value} name={field.name}>
|
|
{props.config.products.map((product) => {
|
|
const plan = product.plans.find((item) => {
|
|
if (item.paymentType === 'one-time') {
|
|
return true;
|
|
}
|
|
|
|
return item.interval === selectedInterval;
|
|
});
|
|
|
|
if (!plan) {
|
|
return null;
|
|
}
|
|
|
|
const planId = plan.id;
|
|
const selected = field.value === planId;
|
|
|
|
const primaryLineItem = getPrimaryLineItem(
|
|
props.config,
|
|
planId,
|
|
);
|
|
|
|
if (!primaryLineItem) {
|
|
throw new Error(`Base line item was not found`);
|
|
}
|
|
|
|
return (
|
|
<RadioGroupItemLabel
|
|
selected={selected}
|
|
key={primaryLineItem.id}
|
|
>
|
|
<RadioGroupItem
|
|
data-test-plan={plan.id}
|
|
key={plan.id + selected}
|
|
id={plan.id}
|
|
value={plan.id}
|
|
onClick={() => {
|
|
if (selected) {
|
|
return;
|
|
}
|
|
|
|
form.setValue('planId', planId, {
|
|
shouldValidate: true,
|
|
});
|
|
|
|
form.setValue('productId', product.id, {
|
|
shouldValidate: true,
|
|
});
|
|
}}
|
|
/>
|
|
|
|
<div
|
|
className={
|
|
'flex w-full flex-col content-center space-y-2 lg:flex-row lg:items-center lg:justify-between lg:space-y-0'
|
|
}
|
|
>
|
|
<Label
|
|
htmlFor={plan.id}
|
|
className={
|
|
'flex flex-col justify-center space-y-2'
|
|
}
|
|
>
|
|
<div className={'flex items-center space-x-2.5'}>
|
|
<span className="font-semibold">
|
|
<Trans
|
|
i18nKey={`billing:plans.${product.id}.name`}
|
|
defaults={product.name}
|
|
/>
|
|
</span>
|
|
|
|
<If
|
|
condition={
|
|
plan.trialDays && props.canStartTrial
|
|
}
|
|
>
|
|
<div>
|
|
<Badge
|
|
className={'px-1 py-0.5 text-xs'}
|
|
variant={'success'}
|
|
>
|
|
<Trans
|
|
i18nKey={`billing:trialPeriod`}
|
|
values={{
|
|
period: plan.trialDays,
|
|
}}
|
|
/>
|
|
</Badge>
|
|
</div>
|
|
</If>
|
|
</div>
|
|
|
|
<span className={'text-muted-foreground'}>
|
|
<Trans
|
|
i18nKey={`billing:plans.${product.id}.description`}
|
|
defaults={product.description}
|
|
/>
|
|
</span>
|
|
</Label>
|
|
|
|
<div
|
|
className={
|
|
'flex flex-col space-y-2 lg:flex-row lg:items-center lg:space-x-4 lg:space-y-0 lg:text-right'
|
|
}
|
|
>
|
|
<div>
|
|
<Price key={plan.id}>
|
|
<span>
|
|
{formatCurrency(
|
|
product.currency.toLowerCase(),
|
|
primaryLineItem.cost,
|
|
)}
|
|
</span>
|
|
</Price>
|
|
|
|
<div>
|
|
<span className={'text-muted-foreground'}>
|
|
<If
|
|
condition={
|
|
plan.paymentType === 'recurring'
|
|
}
|
|
fallback={
|
|
<Trans i18nKey={`billing:lifetime`} />
|
|
}
|
|
>
|
|
<Trans
|
|
i18nKey={`billing:perPeriod`}
|
|
values={{
|
|
period: selectedInterval,
|
|
}}
|
|
/>
|
|
</If>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</RadioGroupItemLabel>
|
|
);
|
|
})}
|
|
</RadioGroup>
|
|
</FormControl>
|
|
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<div>
|
|
<Button
|
|
data-test="checkout-submit-button"
|
|
disabled={props.pending ?? !form.formState.isValid}
|
|
>
|
|
{props.pending ? (
|
|
t('processing')
|
|
) : (
|
|
<>
|
|
<If
|
|
condition={selectedPlan?.trialDays && props.canStartTrial}
|
|
fallback={t(`proceedToPayment`)}
|
|
>
|
|
<span>{t(`startTrial`)}</span>
|
|
</If>
|
|
|
|
<ArrowRight className={'ml-2 h-4 w-4'} />
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
|
|
{selectedPlan && selectedInterval && selectedProduct ? (
|
|
<PlanDetails
|
|
selectedInterval={selectedInterval}
|
|
selectedPlan={selectedPlan}
|
|
selectedProduct={selectedProduct}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</Form>
|
|
);
|
|
}
|
|
|
|
function PlanDetails({
|
|
selectedProduct,
|
|
selectedInterval,
|
|
selectedPlan,
|
|
}: {
|
|
selectedProduct: {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
currency: string;
|
|
features: string[];
|
|
};
|
|
|
|
selectedInterval: string;
|
|
|
|
selectedPlan: {
|
|
lineItems: z.infer<typeof LineItemSchema>[];
|
|
paymentType: string;
|
|
};
|
|
}) {
|
|
const isRecurring = selectedPlan.paymentType === 'recurring';
|
|
|
|
// trick to force animation on re-render
|
|
const key = Math.random();
|
|
|
|
return (
|
|
<div
|
|
key={key}
|
|
className={
|
|
'fade-in animate-in zoom-in-95 flex w-full flex-col space-y-4 py-2 lg:px-8'
|
|
}
|
|
>
|
|
<div className={'flex flex-col space-y-0.5'}>
|
|
<Heading level={5}>
|
|
<b>
|
|
<Trans
|
|
i18nKey={`billing:plans.${selectedProduct.id}.name`}
|
|
defaults={selectedProduct.name}
|
|
/>
|
|
</b>{' '}
|
|
<If condition={isRecurring}>
|
|
/ <Trans i18nKey={`billing:billingInterval.${selectedInterval}`} />
|
|
</If>
|
|
</Heading>
|
|
|
|
<p>
|
|
<span className={'text-muted-foreground'}>
|
|
<Trans
|
|
i18nKey={`billing:plans.${selectedProduct.id}.description`}
|
|
defaults={selectedProduct.description}
|
|
/>
|
|
</span>
|
|
</p>
|
|
</div>
|
|
|
|
<If condition={selectedPlan.lineItems.length > 0}>
|
|
<Separator />
|
|
|
|
<div className={'flex flex-col space-y-2'}>
|
|
<span className={'text-sm font-semibold'}>
|
|
<Trans i18nKey={'billing:detailsLabel'} />
|
|
</span>
|
|
|
|
<LineItemDetails
|
|
lineItems={selectedPlan.lineItems ?? []}
|
|
selectedInterval={isRecurring ? selectedInterval : undefined}
|
|
currency={selectedProduct.currency}
|
|
/>
|
|
</div>
|
|
</If>
|
|
|
|
<Separator />
|
|
|
|
<div className={'flex flex-col space-y-2'}>
|
|
<span className={'text-sm font-semibold'}>
|
|
<Trans i18nKey={'billing:featuresLabel'} />
|
|
</span>
|
|
|
|
{selectedProduct.features.map((item) => {
|
|
return (
|
|
<div key={item} className={'flex items-center space-x-1 text-sm'}>
|
|
<CheckCircle className={'h-4 text-green-500'} />
|
|
|
|
<span className={'text-secondary-foreground'}>
|
|
<Trans i18nKey={item} defaults={item} />
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Price(props: React.PropsWithChildren) {
|
|
return (
|
|
<span
|
|
className={
|
|
'animate-in slide-in-from-left-4 fade-in text-xl font-bold duration-500'
|
|
}
|
|
>
|
|
{props.children}
|
|
</span>
|
|
);
|
|
}
|