Update billing schema and enhance configuration display

Updated the billing schema to include a more descriptive line item and an optional tiers element. Also, billing configuration was refactored and displayed more prominently in the UI. The plan feature listing now utilizes checkmarks to denote each feature and the product details are more clearly displayed.
This commit is contained in:
giancarlo
2024-04-05 19:23:03 +08:00
parent 1cef5ac3db
commit 9492b035a0
6 changed files with 327 additions and 213 deletions

View File

@@ -1,174 +1,8 @@
import { BillingProviderSchema, createBillingSchema } from '@kit/billing';
/*
Replace this file with your own billing configuration file.
Copy it from billing.sample.config.ts and update the configuration to match your billing provider and products.
This file will never be overwritten by git updates
*/
import sampleSchema from './billing.sample.config';
// The billing provider to use. This should be set in the environment variables
// and should match the provider in the database. We also add it here so we can validate
// your configuration against the selected provider at build time.
const provider = BillingProviderSchema.parse(
process.env.NEXT_PUBLIC_BILLING_PROVIDER,
);
export default createBillingSchema({
// also update config.billing_provider in the DB to match the selected
provider,
// products configuration
products: [
{
id: 'lifetime',
name: 'Lifetime',
description: 'The perfect plan for a lifetime',
currency: 'USD',
features: ['Feature 1', 'Feature 2', 'Feature 3'],
plans: [
{
name: 'Lifetime',
id: 'lifetime',
paymentType: 'one-time',
lineItems: [
{
id: '324643',
name: 'Base',
description: 'Base plan',
cost: 999.99,
type: 'base',
},
],
},
],
},
{
id: 'starter',
name: 'Starter',
description: 'The perfect plan to get started',
currency: 'USD',
badge: `Value`,
plans: [
{
name: 'Starter Monthly',
id: 'starter-monthly',
trialPeriod: 7,
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: '55476',
name: 'Base',
description: 'Base plan',
cost: 9.99,
type: 'base',
},
],
},
{
name: 'Starter Yearly',
id: 'starter-yearly',
paymentType: 'recurring',
interval: 'year',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe1',
name: 'Base',
description: 'Base plan',
cost: 99.99,
type: 'base',
},
],
},
],
features: ['Feature 1', 'Feature 2', 'Feature 3'],
},
{
id: 'pro',
name: 'Pro',
badge: `Popular`,
highlighted: true,
description: 'The perfect plan for professionals',
currency: 'USD',
plans: [
{
name: 'Pro Monthly',
id: 'pro-monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe2',
name: 'Base',
description: 'Base plan',
cost: 19.99,
type: 'base',
},
],
},
{
name: 'Pro Yearly',
id: 'pro-yearly',
paymentType: 'recurring',
interval: 'year',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe3',
name: 'Base',
description: 'Base plan',
cost: 199.99,
type: 'base',
},
],
},
],
features: [
'Feature 1',
'Feature 2',
'Feature 3',
'Feature 4',
'Feature 5',
],
},
{
id: 'enterprise',
name: 'Enterprise',
description: 'The perfect plan for enterprises',
currency: 'USD',
plans: [
{
name: 'Enterprise Monthly',
id: 'enterprise-monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe4',
name: 'Base',
description: 'Base plan',
cost: 29.99,
type: 'base',
},
],
},
{
name: 'Enterprise Yearly',
id: 'enterprise-yearly',
paymentType: 'recurring',
interval: 'year',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe5',
name: 'Base',
description: 'Base plan',
cost: 299.99,
type: 'base',
},
],
},
],
features: [
'Feature 1',
'Feature 2',
'Feature 3',
'Feature 4',
'Feature 5',
'Feature 6',
'Feature 7',
],
},
],
});
export default sampleSchema;

View File

@@ -0,0 +1,186 @@
/**
* This is a sample billing configuration file. You should copy this file to `billing.config.ts` and then replace
* the configuration with your own billing provider and products.
*/
import { BillingProviderSchema, createBillingSchema } from '@kit/billing';
// The billing provider to use. This should be set in the environment variables
// and should match the provider in the database. We also add it here so we can validate
// your configuration against the selected provider at build time.
const provider = BillingProviderSchema.parse(
process.env.NEXT_PUBLIC_BILLING_PROVIDER,
);
export default createBillingSchema({
// also update config.billing_provider in the DB to match the selected
provider,
// products configuration
products: [
{
id: 'lifetime',
name: 'Lifetime',
description: 'The perfect plan for a lifetime',
currency: 'USD',
features: ['Feature 1', 'Feature 2', 'Feature 3'],
plans: [
{
name: 'Lifetime',
id: 'lifetime',
paymentType: 'one-time',
lineItems: [
{
id: '324643',
name: 'Base',
cost: 999.99,
type: 'base',
},
],
},
],
},
{
id: 'starter',
name: 'Starter',
description: 'The perfect plan to get started',
currency: 'USD',
badge: `Value`,
plans: [
{
name: 'Starter Monthly',
id: 'starter-monthly',
trialPeriod: 7,
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: '55476',
name: 'Base',
cost: 9.99,
type: 'base',
},
{
id: '324644',
name: 'Addon 1',
cost: 99.99,
type: 'metered',
unit: 'GB',
included: 10,
},
{
id: '324645',
name: 'Addon 2',
cost: 9.99,
type: 'per-seat',
included: 5,
},
],
},
{
name: 'Starter Yearly',
id: 'starter-yearly',
paymentType: 'recurring',
interval: 'year',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe1',
name: 'Base',
cost: 99.99,
type: 'base',
},
],
},
],
features: ['Feature 1', 'Feature 2', 'Feature 3'],
},
{
id: 'pro',
name: 'Pro',
badge: `Popular`,
highlighted: true,
description: 'The perfect plan for professionals',
currency: 'USD',
plans: [
{
name: 'Pro Monthly',
id: 'pro-monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe2',
name: 'Base',
cost: 19.99,
type: 'base',
},
],
},
{
name: 'Pro Yearly',
id: 'pro-yearly',
paymentType: 'recurring',
interval: 'year',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe3',
name: 'Base',
cost: 199.99,
type: 'base',
},
],
},
],
features: [
'Feature 1',
'Feature 2',
'Feature 3',
'Feature 4',
'Feature 5',
],
},
{
id: 'enterprise',
name: 'Enterprise',
description: 'The perfect plan for enterprises',
currency: 'USD',
plans: [
{
name: 'Enterprise Monthly',
id: 'enterprise-monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe4',
name: 'Base',
cost: 29.99,
type: 'base',
},
],
},
{
name: 'Enterprise Yearly',
id: 'enterprise-yearly',
paymentType: 'recurring',
interval: 'year',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe5',
name: 'Base',
cost: 299.99,
type: 'base',
},
],
},
],
features: [
'Feature 1',
'Feature 2',
'Feature 3',
'Feature 4',
'Feature 5',
'Feature 6',
'Feature 7',
],
},
],
});

View File

@@ -26,7 +26,9 @@ export const LineItemSchema = z
.min(1),
description: z
.string({
description: 'Description of the line item. Displayed to the user.',
description:
'Description of the line item. Displayed to the user and will replace the auto-generated description inferred' +
' from the line item. This is useful if you want to provide a more detailed description to the user.',
})
.optional(),
cost: z
@@ -46,11 +48,33 @@ export const LineItemSchema = z
description: 'Included amount of the line item. Displayed to the user.',
})
.optional(),
tiers: z
.array(
z.object({
upTo: z
.number({
description:
'Up to this amount the cost is the base cost. Displayed to the user.',
})
.min(0),
cost: z
.number({
description:
'Cost of the line item after the upTo amount. Displayed to the user.',
})
.min(0),
}),
)
.optional(),
})
.refine((data) => data.type !== 'metered' || (data.unit && data.included), {
message: 'Metered line items must have a unit and included amount',
path: ['type', 'unit', 'included'],
});
.refine(
(data) =>
data.type !== 'metered' || (data.unit && data.included !== undefined),
{
message: 'Metered line items must have a unit and included amount',
path: ['type', 'unit', 'included'],
},
);
export const PlanSchema = z
.object({

View File

@@ -1,3 +1,4 @@
import { Plus, PlusCircle } from 'lucide-react';
import { z } from 'zod';
import { LineItemSchema } from '@kit/billing';
@@ -5,6 +6,9 @@ import { formatCurrency } from '@kit/shared/utils';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
const className =
'flex text-secondary-foreground items-center justify-between text-sm';
export function LineItemDetails(
props: React.PropsWithChildren<{
lineItems: z.infer<typeof LineItemSchema>[];
@@ -12,22 +16,42 @@ export function LineItemDetails(
selectedInterval?: string | undefined;
}>,
) {
const className =
'flex items-center justify-between text-sm border-b border-dashed pb-2 last:border-transparent';
return (
<div className={'flex flex-col space-y-2'}>
<div className={'flex flex-col space-y-1 px-1'}>
{props.lineItems.map((item) => {
// If the item has a description, we render it as a simple text
// and pass the item as values to the translation so we can use
// the item properties in the translation.
if (item.description) {
return (
<div key={item.id} className={className}>
<span className={'flex items-center space-x-1.5'}>
<Plus className={'w-4'} />
<Trans
i18nKey={item.description}
values={item}
defaults={item.description}
/>
</span>
</div>
);
}
switch (item.type) {
case 'base':
return (
<div key={item.id} className={className}>
<span className={'flex space-x-2'}>
<span>
<Trans i18nKey={'billing:basePlan'} />
<span className={'flex items-center space-x-1'}>
<span className={'flex items-center space-x-1.5'}>
<PlusCircle className={'w-4'} />
<span>
<Trans i18nKey={'billing:basePlan'} />
</span>
</span>
<span>/</span>
<span>-</span>
<span>
<If
@@ -50,8 +74,12 @@ export function LineItemDetails(
case 'per-seat':
return (
<div key={item.id} className={className}>
<span>
<Trans i18nKey={'billing:perTeamMember'} />
<span className={'flex items-center space-x-1.5'}>
<PlusCircle className={'w-4'} />
<span>
<Trans i18nKey={'billing:perTeamMember'} />
</span>
</span>
<span className={'font-semibold'}>
@@ -63,14 +91,18 @@ export function LineItemDetails(
case 'metered':
return (
<div key={item.id} className={className}>
<span className={'flex items-center space-x-2'}>
<span>
<Trans
i18nKey={'billing:perUnit'}
values={{
unit: item.unit,
}}
/>
<span className={'flex items-center space-x-1'}>
<span className={'flex items-center space-x-1.5'}>
<PlusCircle className={'w-4'} />
<span>
<Trans
i18nKey={'billing:perUnit'}
values={{
unit: item.unit,
}}
/>
</span>
</span>
{item.included ? (

View File

@@ -34,6 +34,7 @@ import {
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';
@@ -410,8 +411,10 @@ function PlanDetails({
</p>
</div>
<div className={'flex flex-col space-y-1'}>
<span className={'font-semibold'}>
<Separator />
<div className={'flex flex-col space-y-2'}>
<span className={'text-sm font-semibold'}>
<Trans i18nKey={'billing:detailsLabel'} />
</span>
@@ -423,16 +426,16 @@ function PlanDetails({
</div>
<div className={'flex flex-col space-y-2'}>
<span className={'font-semibold'}>
<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-2 text-sm'}>
<div key={item} className={'flex items-center space-x-1 text-sm'}>
<CheckCircle className={'h-4 text-green-500'} />
<span className={'text-muted-foreground'}>
<span className={'text-secondary-foreground'}>
<Trans i18nKey={`billing:features.${item}`} defaults={item} />
</span>
</div>

View File

@@ -5,8 +5,14 @@ import { useState } from 'react';
import Link from 'next/link';
import { ArrowRight, CheckCircle, Sparkles } from 'lucide-react';
import { z } from 'zod';
import { BillingConfig, getBaseLineItem, getPlanIntervals } from '@kit/billing';
import {
BillingConfig,
LineItemSchema,
getBaseLineItem,
getPlanIntervals,
} from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
@@ -15,6 +21,8 @@ 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;
}
@@ -23,9 +31,11 @@ export function PricingTable({
config,
paths,
CheckoutButtonRenderer,
displayPlanDetails = true,
}: {
config: BillingConfig;
paths: Paths;
displayPlanDetails?: boolean;
CheckoutButtonRenderer?: React.ComponentType<{
planId: string;
@@ -83,6 +93,7 @@ export function PricingTable({
baseLineItem={basePlan}
product={product}
paths={paths}
displayPlanDetails={displayPlanDetails}
CheckoutButton={CheckoutButtonRenderer}
/>
);
@@ -95,6 +106,7 @@ export function PricingTable({
function PricingItem(
props: React.PropsWithChildren<{
className?: string;
displayPlanDetails: boolean;
paths: {
signUp: string;
@@ -109,6 +121,7 @@ function PricingItem(
plan: {
id: string;
lineItems: z.infer<typeof LineItemSchema>[];
interval?: string;
name?: string;
href?: string;
@@ -132,13 +145,19 @@ function PricingItem(
) {
const highlighted = props.product.highlighted ?? false;
// we want to exclude the base plan from the list of line items
// since we are displaying the base plan separately as the main price
const lineItemsToDisplay = props.plan.lineItems.filter((item) => {
return item.type !== 'base';
});
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-8 lg:w-4/12 xl:max-w-[22rem] xl:p-10`,
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,
},
@@ -212,12 +231,30 @@ function PricingItem(
</If>
</div>
<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}>
@@ -286,18 +323,16 @@ function ListItem({
}>) {
return (
<li className={'flex items-center space-x-1.5'}>
<div>
<CheckCircle
className={cn('h-4', {
['text-primary-foreground']: highlighted,
['text-green-600']: !highlighted,
})}
/>
</div>
<CheckCircle
className={cn('h-4', {
['text-primary-foreground']: highlighted,
['text-green-600']: !highlighted,
})}
/>
<span
className={cn('text-sm', {
['text-muted-foreground']: !highlighted,
['text-secondary-foreground']: !highlighted,
['text-primary-foreground']: highlighted,
})}
>