Refine billing schema and enhance visuals of PricingTable

Removed redundant validation for 'lifetime' subscription plans in billing schema. Extensively updated the UI, layout and visuals of PricingTable component to offer a more user-friendly and aesthetically pleasing interface, improving overall user experience.
This commit is contained in:
giancarlo
2024-04-05 11:49:09 +08:00
parent 220a23e185
commit d64c620f69
2 changed files with 105 additions and 78 deletions

View File

@@ -104,21 +104,6 @@ export const PlanSchema = z
path: ['lineItems'], path: ['lineItems'],
}, },
) )
.refine(
(data) => {
if (data.paymentType === 'one-time') {
const meteredItems = data.lineItems.filter(
(item) => item.type === 'metered',
);
return meteredItems.length === 0;
}
},
{
message: 'One-time plans must not have metered line items',
path: ['paymentType', 'lineItems'],
},
)
.refine( .refine(
(data) => { (data) => {
if (data.paymentType === 'one-time') { if (data.paymentType === 'one-time') {
@@ -126,6 +111,8 @@ export const PlanSchema = z
return baseItems.length === 0; return baseItems.length === 0;
} }
return true;
}, },
{ {
message: 'One-time plans must not have non-base line items', message: 'One-time plans must not have non-base line items',

View File

@@ -4,10 +4,11 @@ import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { CheckCircle, Sparkles } from 'lucide-react'; import { ArrowRight, CheckCircle, Sparkles } from 'lucide-react';
import { BillingConfig, getBaseLineItem, getPlanIntervals } from '@kit/billing'; import { BillingConfig, getBaseLineItem, getPlanIntervals } from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils'; import { formatCurrency } from '@kit/shared/utils';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading'; import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
@@ -49,25 +50,36 @@ export function PricingTable({
<div <div
className={ className={
'flex flex-col items-start space-y-6 lg:space-y-0' + 'flex flex-col items-start space-y-6 lg:space-y-0' +
' justify-center space-x-2 lg:flex-row' ' justify-center lg:flex-row'
} }
> >
{config.products.map((product) => { {config.products.map((product, index) => {
const plan = product.plans.find((plan) => plan.interval === interval); 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) { if (!plan) {
console.warn(`No plan found for ${product.name}`); return null;
return;
} }
const basePlan = getBaseLineItem(config, plan.id); const basePlan = getBaseLineItem(config, plan.id);
return ( return (
<PricingItem <PricingItem
className={cn('border-b border-r border-t', {
['rounded-l-lg border-l']: isFirst,
['rounded-r-lg']: isLast,
})}
selectable selectable
key={plan.id} key={plan.id}
plan={{ ...plan, interval }} plan={plan}
baseLineItem={basePlan} baseLineItem={basePlan}
product={product} product={product}
paths={paths} paths={paths}
@@ -82,6 +94,8 @@ export function PricingTable({
function PricingItem( function PricingItem(
props: React.PropsWithChildren<{ props: React.PropsWithChildren<{
className?: string;
paths: { paths: {
signUp: string; signUp: string;
}; };
@@ -95,7 +109,7 @@ function PricingItem(
plan: { plan: {
id: string; id: string;
interval: string; interval?: string;
name?: string; name?: string;
href?: string; href?: string;
label?: string; label?: string;
@@ -122,57 +136,80 @@ function PricingItem(
<div <div
data-cy={'subscription-plan'} data-cy={'subscription-plan'}
className={cn( className={cn(
` props.className,
relative flex w-full flex-col justify-between space-y-6 rounded-lg `s-full flex flex-1 grow flex-col items-stretch justify-between space-y-8 self-stretch
border p-6 lg:w-4/12 xl:max-w-xs xl:p-8 2xl:w-3/12 p-8 lg:w-4/12 xl:max-w-[22rem] xl:p-10`,
`, {
['border-primary border-2']: highlighted,
},
)} )}
> >
<div className={'flex flex-col space-y-2.5'}> <div className={'flex flex-col space-y-6'}>
<div className={'flex items-center space-x-2.5'}> <div className={'flex flex-col space-y-2'}>
<Heading level={4}> <div className={'flex items-center space-x-4'}>
<b className={'font-semibold'}>{props.product.name}</b> <Heading level={4}>
</Heading> <b className={'font-bold'}>{props.product.name}</b>
</Heading>
<If condition={props.product.badge}> <If condition={props.product.badge}>
<div <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-muted-foreground`)}>
<Trans
i18nKey={props.product.description}
defaults={props.product.description}
/>
</span>
</div>
<div className={'flex items-center space-x-1'}>
<Price>
{formatCurrency(props.product.currency, props.baseLineItem.cost)}
</Price>
<If condition={props.plan.name}>
<span
className={cn( className={cn(
`flex space-x-1 rounded-md px-2 py-1 text-xs font-medium`, `text-muted-foreground flex items-center space-x-1 text-base lowercase`,
{
['text-primary-foreground bg-primary']: highlighted,
['text-muted-foreground bg-gray-50']: !highlighted,
},
)} )}
> >
<If condition={highlighted}> <span>/</span>
<Sparkles className={'mr-1 h-4 w-4'} />
</If>
<span>{props.product.badge}</span> <span className={'text-sm'}>
</div> <If
condition={props.plan.interval}
fallback={<Trans i18nKey={'billing:lifetime'} />}
>
{(interval) => (
<Trans i18nKey={`billing:billingInterval.${interval}`} />
)}
</If>
</span>
</span>
</If> </If>
</div> </div>
<span className={'text-sm text-gray-500 dark:text-gray-400'}> <div className={'text-current'}>
{props.product.description} <FeaturesList features={props.product.features} />
</span> </div>
</div>
<div className={'flex items-center space-x-1'}>
<Price>
{formatCurrency(props.product.currency, props.baseLineItem.cost)}
</Price>
<If condition={props.plan.name}>
<span className={cn(`text-muted-foreground text-base lowercase`)}>
<span>/</span>
<span>{props.plan.interval}</span>
</span>
</If>
</div>
<div className={'text-current'}>
<FeaturesList features={props.product.features} />
</div> </div>
<If condition={props.selectable}> <If condition={props.selectable}>
@@ -227,7 +264,7 @@ function Price({ children }: React.PropsWithChildren) {
> >
<span <span
className={ className={
'flex items-center text-2xl font-bold lg:text-3xl xl:text-4xl 2xl:text-5xl' 'flex items-center text-2xl font-bold lg:text-3xl xl:text-4xl'
} }
> >
{children} {children}
@@ -238,12 +275,12 @@ function Price({ children }: React.PropsWithChildren) {
function ListItem({ children }: React.PropsWithChildren) { function ListItem({ children }: React.PropsWithChildren) {
return ( return (
<li className={'flex items-center space-x-3 font-medium'}> <li className={'flex items-center space-x-1.5'}>
<div> <div>
<CheckCircle className={'h-5 text-green-500'} /> <CheckCircle className={'h-4 text-green-600'} />
</div> </div>
<span className={'text-muted-foreground text-sm'}>{children}</span> <span className={'text-secondary-foreground text-sm'}>{children}</span>
</li> </li>
); );
} }
@@ -312,15 +349,18 @@ function DefaultCheckoutButton(
const label = props.plan.label ?? 'common:getStarted'; const label = props.plan.label ?? 'common:getStarted';
return ( return (
<div className={'bottom-0 left-0 w-full p-0'}> <Link className={'w-full'} href={linkHref}>
<Link className={'w-full'} href={linkHref}> <Button
<Button size={'lg'}
className={'w-full'} className={'w-full'}
variant={props.highlighted ? 'default' : 'outline'} variant={props.highlighted ? 'default' : 'outline'}
> >
<span>
<Trans i18nKey={label} defaults={label} /> <Trans i18nKey={label} defaults={label} />
</Button> </span>
</Link>
</div> <ArrowRight className={'ml-2 h-4'} />
</Button>
</Link>
); );
} }