Cleanup
This commit is contained in:
45
packages/billing/package.json
Normal file
45
packages/billing/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@kit/billing",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
".": "./src/create-billing-schema.ts",
|
||||
"./components/*": "./src/components/*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kit/ui": "0.1.0",
|
||||
"lucide-react": "^0.361.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/prettier-config": "0.1.0",
|
||||
"@kit/eslint-config": "0.2.0",
|
||||
"@kit/tailwind-config": "0.1.0",
|
||||
"@kit/tsconfig": "0.1.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@kit/eslint-config/base",
|
||||
"@kit/eslint-config/react"
|
||||
]
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
324
packages/billing/src/components/pricing-table.tsx
Normal file
324
packages/billing/src/components/pricing-table.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CheckCircleIcon, SparklesIcon } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
|
||||
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 { BillingSchema, getPlanIntervals } from '../create-billing-schema';
|
||||
|
||||
type Config = z.infer<typeof BillingSchema>;
|
||||
|
||||
interface Paths {
|
||||
signUp: string;
|
||||
}
|
||||
|
||||
export function PricingTable({
|
||||
config,
|
||||
paths,
|
||||
CheckoutButtonRenderer,
|
||||
}: {
|
||||
config: Config;
|
||||
paths: Paths;
|
||||
|
||||
CheckoutButtonRenderer?: React.ComponentType<{
|
||||
planId: string;
|
||||
highlighted?: boolean;
|
||||
}>;
|
||||
}) {
|
||||
const intervals = getPlanIntervals(config);
|
||||
|
||||
const [planVariant, setPlanVariant] = useState<string>(
|
||||
intervals[0] as string,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-12'}>
|
||||
<div className={'flex justify-center'}>
|
||||
<PlanIntervalSwitcher
|
||||
intervals={intervals}
|
||||
interval={planVariant}
|
||||
setInterval={setPlanVariant}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-start space-y-6 lg:space-y-0' +
|
||||
' justify-center lg:flex-row lg:space-x-4'
|
||||
}
|
||||
>
|
||||
{config.products.map((product) => {
|
||||
const plan = product.plans.find(
|
||||
(item) => item.interval === planVariant,
|
||||
);
|
||||
|
||||
if (!plan || product.hidden) {
|
||||
console.warn(`No plan found for ${product.name}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<PricingItem
|
||||
selectable
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
product={product}
|
||||
paths={paths}
|
||||
CheckoutButton={CheckoutButtonRenderer}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PricingItem(
|
||||
props: React.PropsWithChildren<{
|
||||
paths: {
|
||||
signUp: string;
|
||||
};
|
||||
|
||||
selectable: boolean;
|
||||
|
||||
plan: {
|
||||
id: string;
|
||||
price: string;
|
||||
interval: string;
|
||||
name?: string;
|
||||
href?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
CheckoutButton?: React.ComponentType<{
|
||||
planId: string;
|
||||
highlighted?: boolean;
|
||||
}>;
|
||||
|
||||
product: {
|
||||
name: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
highlighted?: boolean;
|
||||
features: string[];
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
const highlighted = props.product.highlighted ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-cy={'subscription-plan'}
|
||||
className={cn(
|
||||
`
|
||||
relative flex w-full flex-col justify-between space-y-6 rounded-lg
|
||||
p-6 lg:w-4/12 xl:max-w-xs xl:p-8 2xl:w-3/12
|
||||
`,
|
||||
{
|
||||
['dark:border-dark-900 border border-gray-100']: !highlighted,
|
||||
['border-primary border-2']: highlighted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className={'flex flex-col space-y-2.5'}>
|
||||
<div className={'flex items-center space-x-2.5'}>
|
||||
<Heading level={4}>
|
||||
<b className={'font-semibold'}>{props.product.name}</b>
|
||||
</Heading>
|
||||
|
||||
<If condition={props.product.badge}>
|
||||
<div
|
||||
className={cn(
|
||||
`flex space-x-1 rounded-md px-2 py-1 text-xs font-medium`,
|
||||
{
|
||||
['text-primary-foreground bg-primary']: highlighted,
|
||||
['bg-gray-50 text-gray-500 dark:text-gray-800']: !highlighted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<If condition={highlighted}>
|
||||
<SparklesIcon className={'mr-1 h-4 w-4'} />
|
||||
</If>
|
||||
|
||||
<span>{props.product.badge}</span>
|
||||
</div>
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<span className={'text-sm text-gray-500 dark:text-gray-400'}>
|
||||
{props.product.description}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center space-x-1'}>
|
||||
<Price>{props.plan.price}</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>
|
||||
|
||||
<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[];
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<ul className={'flex flex-col space-y-2'}>
|
||||
{props.features.map((feature) => {
|
||||
return (
|
||||
<ListItem key={feature}>
|
||||
<Trans
|
||||
i18nKey={`common:plans.features.${feature}`}
|
||||
defaults={feature}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function Price({ children }: React.PropsWithChildren) {
|
||||
// little trick to re-animate the price when switching plans
|
||||
const key = Math.random();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`animate-in slide-in-from-left-4 fade-in duration-500`}
|
||||
>
|
||||
<span
|
||||
className={'text-2xl font-bold lg:text-3xl xl:text-4xl 2xl:text-5xl'}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListItem({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<li className={'flex items-center space-x-3 font-medium'}>
|
||||
<div>
|
||||
<CheckCircleIcon className={'h-5 text-green-500'} />
|
||||
</div>
|
||||
|
||||
<span className={'text-muted-foreground text-sm'}>{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', {
|
||||
'rounded-r-none border-r-transparent': index === 0,
|
||||
'rounded-l-none': index === props.intervals.length - 1,
|
||||
['hover:bg-gray-50 dark:hover:bg-background/80']: !selected,
|
||||
['text-primary-800 dark:text-primary-500 font-semibold' +
|
||||
' hover:bg-background hover:text-initial']: selected,
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={plan}
|
||||
variant={'outline'}
|
||||
className={className}
|
||||
onClick={() => props.setInterval(plan)}
|
||||
>
|
||||
<span className={'flex items-center space-x-1'}>
|
||||
<If condition={selected}>
|
||||
<CheckCircleIcon className={'h-4 text-green-500'} />
|
||||
</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 (
|
||||
<div className={'bottom-0 left-0 w-full p-0'}>
|
||||
<Link className={'w-full'} href={linkHref}>
|
||||
<Button
|
||||
className={'w-full'}
|
||||
variant={props.highlighted ? 'default' : 'outline'}
|
||||
>
|
||||
<Trans i18nKey={label} defaults={label} />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
packages/billing/src/create-billing-schema.ts
Normal file
95
packages/billing/src/create-billing-schema.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const Interval = z.enum(['month', 'year']);
|
||||
const PaymentType = z.enum(['recurring', 'one-time']);
|
||||
const BillingProvider = z.enum(['stripe']);
|
||||
|
||||
const PlanSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(100),
|
||||
price: z.string().min(1).max(100),
|
||||
trialPeriodDays: z.number().optional(),
|
||||
interval: Interval,
|
||||
perSeat: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
const ProductSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
currency: z.string().optional().default('USD'),
|
||||
plans: z.array(PlanSchema),
|
||||
features: z.array(z.string()),
|
||||
badge: z.string().optional(),
|
||||
highlighted: z.boolean().optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
paymentType: PaymentType.optional().default('recurring'),
|
||||
});
|
||||
|
||||
export const BillingSchema = z
|
||||
.object({
|
||||
products: z.array(ProductSchema),
|
||||
provider: BillingProvider,
|
||||
})
|
||||
.refine((schema) => {
|
||||
// verify dupe product ids
|
||||
const ids = schema.products.map((product) => product.id);
|
||||
|
||||
if (new Set(ids).size !== ids.length) {
|
||||
return {
|
||||
message: 'Duplicate product IDs',
|
||||
path: ['products'],
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.refine((schema) => {
|
||||
// verify dupe plan ids
|
||||
const planIds = schema.products.flatMap((product) =>
|
||||
product.plans.map((plan) => plan.id),
|
||||
);
|
||||
|
||||
if (new Set(planIds).size !== planIds.length) {
|
||||
return {
|
||||
message: 'Duplicate plan IDs',
|
||||
path: ['products'],
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
/**
|
||||
* Create and validate the billing schema
|
||||
* @param config
|
||||
*/
|
||||
export function createBillingSchema(config: z.infer<typeof BillingSchema>) {
|
||||
return BillingSchema.parse(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of billing plans based on the provided configuration.
|
||||
*
|
||||
* @param {Object} config - The configuration object containing product and plan information.
|
||||
* @return {Array} - An array of billing plans.
|
||||
*/
|
||||
export function getBillingPlans(config: z.infer<typeof BillingSchema>) {
|
||||
return config.products.flatMap((product) => product.plans);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the intervals of all plans specified in the given configuration.
|
||||
*
|
||||
* @param {Object} config - The billing configuration containing products and plans.
|
||||
* @returns {Array} - An array of intervals.
|
||||
*/
|
||||
export function getPlanIntervals(config: z.infer<typeof BillingSchema>) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
config.products.flatMap((product) =>
|
||||
product.plans.map((plan) => plan.interval),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
8
packages/billing/tsconfig.json
Normal file
8
packages/billing/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user