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:
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user