Files
medreport_mrb2b/packages/billing/gateway/src/components/plan-picker.tsx
Danel Kungla 0c2cfe6d18 prettier fix
2025-09-19 17:22:36 +03:00

501 lines
16 KiB
TypeScript

'use client';
import { useMemo } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { ArrowRight, CheckCircle } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import {
BillingConfig,
type LineItemSchema,
getPlanIntervals,
getPrimaryLineItem,
getProductPlanPair,
} from '@kit/billing';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
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';
import { PlanCostDisplay } from './plan-cost-display';
export function PlanPicker(
props: React.PropsWithChildren<{
config: BillingConfig;
onSubmit: (data: { planId: string; productId: string }) => void;
canStartTrial?: boolean;
pending?: boolean;
}>,
) {
const { t } = useTranslation(`billing`);
const intervals = useMemo(
() => getPlanIntervals(props.config),
[props.config],
) as string[];
const form = useForm({
reValidateMode: 'onChange',
mode: 'onChange',
resolver: zodResolver(
z
.object({
planId: z.string(),
productId: z.string(),
interval: z.string().optional(),
})
.refine(
(data) => {
try {
const { product, plan } = getProductPlanPair(
props.config,
data.planId,
);
return product && plan;
} catch {
return false;
}
},
{ message: t('noPlanChosen'), path: ['planId'] },
),
),
defaultValues: {
interval: intervals[0],
planId: '',
productId: '',
},
});
const selectedInterval = useWatch({
name: 'interval',
control: form.control,
});
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]);
// display the period picker if the selected plan is recurring or if no plan is selected
const isRecurringPlan =
selectedPlan?.paymentType === 'recurring' || !selectedPlan;
// Always filter out hidden products
const visibleProducts = props.config.products.filter(
(product) => !product.hidden,
);
return (
<Form {...form}>
<div
className={'flex flex-col gap-y-4 lg:flex-row lg:gap-x-4 lg:gap-y-0'}
>
<form
className={'flex w-full max-w-xl flex-col gap-y-8'}
onSubmit={form.handleSubmit(props.onSubmit)}
>
<If condition={intervals.length}>
<div
className={cn('transition-all', {
['pointer-events-none opacity-50']: !isRecurringPlan,
})}
>
<FormField
name={'interval'}
render={({ field }) => {
return (
<FormItem className={'flex flex-col gap-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(
'focus-within:border-primary flex items-center gap-x-2.5 rounded-md border px-2.5 py-2 transition-colors',
{
['bg-muted border-input']: selected,
['hover:border-input border-transparent']:
!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>
</If>
<FormField
name={'planId'}
render={({ field }) => (
<FormItem className={'flex flex-col gap-4'}>
<FormLabel>
<Trans i18nKey={'common:planPickerLabel'} />
</FormLabel>
<FormControl>
<RadioGroup value={field.value} name={field.name}>
{visibleProducts.map((product) => {
const plan = product.plans.find((item) => {
if (item.paymentType === 'one-time') {
return true;
}
return item.interval === selectedInterval;
});
if (!plan || plan.custom) {
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 gap-y-3 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 gap-y-3 lg:flex-row lg:items-center lg:space-y-0 lg:space-x-4 lg:text-right'
}
>
<div>
<Price key={plan.id}>
<PlanCostDisplay
primaryLineItem={primaryLineItem}
currencyCode={product.currency}
interval={selectedInterval}
alwaysDisplayMonthlyPrice={true}
/>
</Price>
<div>
<span className={'text-muted-foreground'}>
<If
condition={
plan.paymentType === 'recurring'
}
fallback={
<Trans i18nKey={`billing:lifetime`} />
}
>
<Trans i18nKey={`billing:perMonth`} />
</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('redirectingToPayment')
) : (
<>
<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'}>
<span className={'text-sm font-medium'}>
<b>
<Trans
i18nKey={`billing:plans.${selectedProduct.id}.name`}
defaults={selectedProduct.name}
/>
</b>{' '}
<If condition={isRecurring}>
/ <Trans i18nKey={`billing:billingInterval.${selectedInterval}`} />
</If>
</span>
<p>
<span className={'text-muted-foreground text-sm'}>
<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 gap-x-2 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-semibold tracking-tight duration-500'
}
>
{props.children}
</span>
);
}