Files
myeasycms-v2/packages/billing/gateway/src/components/pricing-table.tsx
giancarlo ab1e90f093 Update Billing Provider and Refactor Pricing UI
Updated the billing provider in the environment configuration to use 'stripe' instead of 'lemon-squeezy'. Multiple changes were also made to UI components related to pricing, including better data handling for different billing tiers and enhanced visualization of selected options. These revisions aim to both enhance the user experience and ensure compatibility with the new billing provider.
2024-04-07 18:12:45 +08:00

459 lines
12 KiB
TypeScript

'use client';
import { useState } from 'react';
import Link from 'next/link';
import { ArrowRight, CheckCircle, Sparkles } from 'lucide-react';
import { z } from 'zod';
import {
BillingConfig,
LineItemSchema,
getPlanIntervals,
getPrimaryLineItem,
} from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { LineItemDetails } from './line-item-details';
interface Paths {
signUp: string;
}
export function PricingTable({
config,
paths,
CheckoutButtonRenderer,
displayPlanDetails = true,
}: {
config: BillingConfig;
paths: Paths;
displayPlanDetails?: boolean;
CheckoutButtonRenderer?: React.ComponentType<{
planId: string;
highlighted?: boolean;
}>;
}) {
const intervals = getPlanIntervals(config).filter(Boolean) as string[];
const [interval, setInterval] = useState(intervals[0]!);
return (
<div className={'flex flex-col space-y-8'}>
<div className={'flex justify-center'}>
{intervals.length ? (
<PlanIntervalSwitcher
intervals={intervals}
interval={interval}
setInterval={setInterval}
/>
) : null}
</div>
<div
className={
'flex flex-col items-start space-y-6 lg:space-y-0' +
' justify-center lg:flex-row'
}
>
{config.products.map((product, index) => {
const isFirst = index === 0;
const isLast = index === config.products.length - 1;
const plan = product.plans.find((plan) => {
if (plan.paymentType === 'recurring') {
return plan.interval === interval;
}
return plan;
});
if (!plan) {
return null;
}
const primaryLineItem = getPrimaryLineItem(config, plan.id);
if (!primaryLineItem) {
throw new Error(`Base line item was not found`);
}
return (
<PricingItem
className={cn('border-b border-r border-t', {
['rounded-l-lg border-l']: isFirst,
['rounded-r-lg']: isLast,
})}
selectable
key={plan.id}
plan={plan}
primaryLineItem={primaryLineItem}
product={product}
paths={paths}
displayPlanDetails={displayPlanDetails}
CheckoutButton={CheckoutButtonRenderer}
/>
);
})}
</div>
</div>
);
}
function PricingItem(
props: React.PropsWithChildren<{
className?: string;
displayPlanDetails: boolean;
paths: {
signUp: string;
};
selectable: boolean;
primaryLineItem: z.infer<typeof LineItemSchema>;
plan: {
id: string;
lineItems: z.infer<typeof LineItemSchema>[];
interval?: string;
name?: string;
href?: string;
label?: string;
};
CheckoutButton?: React.ComponentType<{
planId: string;
highlighted?: boolean;
}>;
product: {
name: string;
currency: string;
description: string;
badge?: string;
highlighted?: boolean;
features: string[];
};
}>,
) {
const highlighted = props.product.highlighted ?? false;
// we want to exclude the primary plan from the list of line items
// since we are displaying the primary line item separately as the main price
const lineItemsToDisplay = props.plan.lineItems.filter((item) => {
return item.id !== props.primaryLineItem.id;
});
return (
<div
data-cy={'subscription-plan'}
className={cn(
props.className,
`s-full flex flex-1 grow flex-col items-stretch
justify-between space-y-8 self-stretch p-6 lg:w-4/12 xl:max-w-[22rem] xl:p-8`,
{
['bg-primary text-primary-foreground border-primary']: highlighted,
},
)}
>
<div className={'flex flex-col space-y-6'}>
<div className={'flex flex-col space-y-2'}>
<div className={'flex items-center space-x-4'}>
<Heading level={4}>
<b className={'font-bold'}>
<Trans
i18nKey={props.product.name}
defaults={props.product.name}
/>
</b>
</Heading>
<If condition={props.product.badge}>
<Badge
variant={'default'}
className={cn({
['border-primary-foreground']: highlighted,
})}
>
<If condition={highlighted}>
<Sparkles className={'h-3'} />
</If>
<span>
<Trans
i18nKey={props.product.badge}
defaults={props.product.badge}
/>
</span>
</Badge>
</If>
</div>
<span
className={cn(`text-sm text-current`, {
['text-muted-foreground']: !highlighted,
})}
>
<Trans
i18nKey={props.product.description}
defaults={props.product.description}
/>
</span>
</div>
<div className={'flex flex-col space-y-1'}>
<Price>
{formatCurrency(props.product.currency, props.primaryLineItem.cost)}
</Price>
<If condition={props.plan.name}>
<span
className={cn(
`animate-in slide-in-from-left-4 fade-in flex items-center space-x-0.5 capitalize`,
{
['text-muted-foreground']: !highlighted,
},
)}
>
<span className={'text-sm'}>
<If
condition={props.plan.interval}
fallback={<Trans i18nKey={'billing:lifetime'} />}
>
{(interval) => (
<Trans i18nKey={`billing:billingInterval.${interval}`} />
)}
</If>
</span>
<If condition={props.primaryLineItem.type !== 'flat'}>
<span>/</span>
<span
className={cn(
`animate-in slide-in-from-left-4 fade-in text-sm capitalize`,
{
['text-muted-foreground']: !highlighted,
},
)}
>
<If condition={props.primaryLineItem.type === 'per-seat'}>
<Trans i18nKey={'billing:perTeamMember'} />
</If>
<If condition={props.primaryLineItem.unit}>
<Trans
i18nKey={'billing:perUnit'}
values={{
unit: props.primaryLineItem.unit,
}}
/>
</If>
</span>
</If>
</span>
</If>
</div>
<div className={'flex flex-col space-y-2'}>
<h6 className={'text-sm font-semibold'}>
<Trans i18nKey={'billing:featuresLabel'} />
</h6>
<FeaturesList
highlighted={highlighted}
features={props.product.features}
/>
</div>
<If condition={props.displayPlanDetails && lineItemsToDisplay.length}>
<div className={'flex flex-col space-y-2'}>
<h6 className={'text-sm font-semibold'}>
<Trans i18nKey={'billing:detailsLabel'} />
</h6>
<LineItemDetails
selectedInterval={props.plan.interval}
currency={props.product.currency}
lineItems={lineItemsToDisplay}
/>
</div>
</If>
</div>
<If condition={props.selectable}>
<If
condition={props.plan.id && props.CheckoutButton}
fallback={
<DefaultCheckoutButton
signUpPath={props.paths.signUp}
highlighted={highlighted}
plan={props.plan}
/>
}
>
{(CheckoutButton) => (
<CheckoutButton highlighted={highlighted} planId={props.plan.id} />
)}
</If>
</If>
</div>
);
}
function FeaturesList(
props: React.PropsWithChildren<{
features: string[];
highlighted?: boolean;
}>,
) {
return (
<ul className={'flex flex-col space-y-2'}>
{props.features.map((feature) => {
return (
<ListItem highlighted={props.highlighted} key={feature}>
<Trans
i18nKey={`common:plans.features.${feature}`}
defaults={feature}
/>
</ListItem>
);
})}
</ul>
);
}
function Price({ children }: React.PropsWithChildren) {
return (
<div
className={`animate-in slide-in-from-left-4 fade-in items-center duration-500`}
>
<span
className={
'flex items-center text-2xl font-bold lg:text-3xl xl:text-4xl'
}
>
{children}
</span>
</div>
);
}
function ListItem({
children,
highlighted,
}: React.PropsWithChildren<{
highlighted?: boolean;
}>) {
return (
<li className={'flex items-center space-x-1.5'}>
<CheckCircle
className={cn('h-4', {
['text-primary-foreground']: highlighted,
['text-green-600']: !highlighted,
})}
/>
<span
className={cn('text-sm', {
['text-secondary-foreground']: !highlighted,
['text-primary-foreground']: highlighted,
})}
>
{children}
</span>
</li>
);
}
function PlanIntervalSwitcher(
props: React.PropsWithChildren<{
intervals: string[];
interval: string;
setInterval: (interval: string) => void;
}>,
) {
return (
<div className={'flex'}>
{props.intervals.map((plan, index) => {
const selected = plan === props.interval;
const className = cn(
'focus:!ring-0 !outline-none animate-in transition-all fade-in',
{
'rounded-r-none border-r-transparent': index === 0,
'rounded-l-none': index === props.intervals.length - 1,
['hover:text-current hover:bg-muted']: !selected,
['font-semibold cursor-default hover:text-initial hover:bg-background border-primary']:
selected,
},
);
return (
<Button
key={plan}
variant={'outline'}
className={className}
onClick={() => props.setInterval(plan)}
>
<span className={'flex items-center space-x-1'}>
<If condition={selected}>
<CheckCircle className={'animate-in fade-in zoom-in-90 h-4'} />
</If>
<span className={'capitalize'}>
<Trans
i18nKey={`common:plans.interval.${plan}`}
defaults={plan}
/>
</span>
</span>
</Button>
);
})}
</div>
);
}
function DefaultCheckoutButton(
props: React.PropsWithChildren<{
plan: {
id: string;
href?: string;
label?: string;
};
signUpPath: string;
highlighted?: boolean;
}>,
) {
const linkHref =
props.plan.href ?? `${props.signUpPath}?utm_source=${props.plan.id}` ?? '';
const label = props.plan.label ?? 'common:getStarted';
return (
<Link className={'w-full'} href={linkHref}>
<Button
size={'lg'}
className={'w-full'}
variant={props.highlighted ? 'secondary' : 'outline'}
>
<span>
<Trans i18nKey={label} defaults={label} />
</span>
<ArrowRight className={'ml-2 h-4'} />
</Button>
</Link>
);
}