Add Lemon Squeezy Billing System

This commit is contained in:
giancarlo
2024-04-01 21:43:18 +08:00
parent 84a4b45bcd
commit 8784a40a69
59 changed files with 424 additions and 74 deletions

View File

@@ -0,0 +1,41 @@
'use client';
import { ArrowUpRight } from 'lucide-react';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
export function BillingPortalCard() {
return (
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey="billing:billingPortalCardTitle" />
</CardTitle>
<CardDescription>
<Trans i18nKey="billing:billingPortalCardDescription" />
</CardDescription>
</CardHeader>
<CardContent className={'space-y-2'}>
<div>
<Button>
<span>
<Trans i18nKey="billing:billingPortalCardButton" />
</span>
<ArrowUpRight className={'h-4'} />
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,97 @@
'use client';
import Link from 'next/link';
import { Check, ChevronRight } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { Trans } from '@kit/ui/trans';
/**
* Retrieves the session status for a Stripe checkout session.
* Since we should only arrive here for a successful checkout, we only check
* for the `paid` status.
*
* @param {Stripe.Checkout.Session['status']} status - The status of the Stripe checkout session.
* @param {string} customerEmail - The email address of the customer associated with the session.
*
* @returns {ReactElement} - The component to render based on the session status.
*/
export function BillingSessionStatus({
customerEmail,
redirectPath,
}: React.PropsWithChildren<{
customerEmail: string;
redirectPath: string;
}>) {
return (
<SuccessSessionStatus
redirectPath={redirectPath}
customerEmail={customerEmail}
/>
);
}
function SuccessSessionStatus({
customerEmail,
redirectPath,
}: React.PropsWithChildren<{
customerEmail: string;
redirectPath: string;
}>) {
return (
<section
data-test={'payment-return-success'}
className={
'fade-in mx-auto max-w-xl rounded-xl border p-16 xl:drop-shadow-sm' +
' dark:border-dark-800 border-gray-100' +
' bg-background animate-in slide-in-from-bottom-8 ease-out' +
' zoom-in-50 dark:shadow-primary/40 duration-1000 dark:shadow-2xl'
}
>
<div
className={
'flex flex-col items-center justify-center space-y-4 text-center'
}
>
<Check
className={
'h-16 w-16 rounded-full bg-green-500 p-1 text-white ring-8' +
' ring-green-500/30 dark:ring-green-500/50'
}
/>
<Heading level={3}>
<span className={'mr-4 font-semibold'}>
<Trans i18nKey={'billing:checkoutSuccessTitle'} />
</span>
🎉
</Heading>
<div
className={'flex flex-col space-y-4 text-gray-500 dark:text-gray-400'}
>
<p>
<Trans
i18nKey={'billing:checkoutSuccessDescription'}
values={{ customerEmail }}
/>
</p>
</div>
<Button data-test={'checkout-success-back-button'} variant={'outline'}>
<Link href={redirectPath}>
<span className={'flex items-center space-x-2.5'}>
<span>
<Trans i18nKey={'billing:checkoutSuccessBackButton'} />
</span>
<ChevronRight className={'h-4'} />
</span>
</Link>
</Button>
</div>
</section>
);
}

View File

@@ -0,0 +1,94 @@
import { BadgeCheck } from 'lucide-react';
import { BillingConfig, getProductPlanPairByVariantId } from '@kit/billing';
import { Database } from '@kit/supabase/database';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { Trans } from '@kit/ui/trans';
import { CurrentPlanBadge } from './current-plan-badge';
import { LineItemDetails } from './line-item-details';
type Order = Database['public']['Tables']['orders']['Row'];
type LineItem = Database['public']['Tables']['order_items']['Row'];
interface Props {
order: Order & {
items: LineItem[];
};
config: BillingConfig;
}
export function CurrentLifetimeOrderCard({
order,
config,
}: React.PropsWithChildren<Props>) {
const lineItems = order.items;
const firstLineItem = lineItems[0];
if (!firstLineItem) {
throw new Error('No line items found in subscription');
}
const { product, plan } = getProductPlanPairByVariantId(
config,
firstLineItem.variant_id,
);
if (!product || !plan) {
throw new Error(
'Product or plan not found. Did you forget to add it to the billing config?',
);
}
const productLineItems = plan.lineItems;
return (
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey="billing:planCardTitle" />
</CardTitle>
<CardDescription>
<Trans i18nKey="billing:planCardDescription" />
</CardDescription>
</CardHeader>
<CardContent className={'space-y-3 text-sm'}>
<div className={'flex flex-col space-y-1'}>
<div className={'flex items-center space-x-2 text-lg font-semibold'}>
<BadgeCheck
className={
's-6 fill-green-500 text-white dark:fill-white dark:text-black'
}
/>
<span>{product.name}</span>
<CurrentPlanBadge status={order.status} />
</div>
</div>
<div>
<div className="flex flex-col space-y-0.5">
<span className="font-semibold">
<Trans i18nKey="billing:detailsLabel" />
</span>
<LineItemDetails
lineItems={productLineItems}
currency={order.currency}
/>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,54 @@
import { Database } from '@kit/supabase/database';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Trans } from '@kit/ui/trans';
export function CurrentPlanAlert(
props: React.PropsWithoutRef<{
status: Database['public']['Enums']['subscription_status'];
}>,
) {
let variant: 'success' | 'warning' | 'destructive';
const prefix = 'billing:status';
const text = `${prefix}.${props.status}.description`;
const title = `${prefix}.${props.status}.heading`;
switch (props.status) {
case 'active':
variant = 'success';
break;
case 'trialing':
variant = 'success';
break;
case 'past_due':
variant = 'destructive';
break;
case 'canceled':
variant = 'destructive';
break;
case 'unpaid':
variant = 'destructive';
break;
case 'incomplete':
variant = 'warning';
break;
case 'incomplete_expired':
variant = 'destructive';
break;
case 'paused':
variant = 'warning';
break;
}
return (
<Alert variant={variant}>
<AlertTitle>
<Trans i18nKey={title} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={text} />
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,52 @@
import { Database } from '@kit/supabase/database';
import { Badge } from '@kit/ui/badge';
import { Trans } from '@kit/ui/trans';
type Status =
| Database['public']['Enums']['subscription_status']
| Database['public']['Enums']['payment_status'];
export function CurrentPlanBadge(
props: React.PropsWithoutRef<{
status: Status;
}>,
) {
let variant: 'success' | 'warning' | 'destructive';
const text = `billing:status.${props.status}.badge`;
switch (props.status) {
case 'active':
case 'succeeded':
variant = 'success';
break;
case 'trialing':
variant = 'success';
break;
case 'past_due':
case 'failed':
variant = 'destructive';
break;
case 'canceled':
variant = 'destructive';
break;
case 'unpaid':
variant = 'destructive';
break;
case 'incomplete':
case 'pending':
variant = 'warning';
break;
case 'incomplete_expired':
variant = 'destructive';
break;
case 'paused':
variant = 'warning';
break;
}
return (
<Badge variant={variant}>
<Trans i18nKey={text} />
</Badge>
);
}

View File

@@ -0,0 +1,140 @@
import { formatDate } from 'date-fns';
import { BadgeCheck } from 'lucide-react';
import { BillingConfig, getProductPlanPairByVariantId } from '@kit/billing';
import { Database } from '@kit/supabase/database';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { CurrentPlanAlert } from './current-plan-alert';
import { CurrentPlanBadge } from './current-plan-badge';
import { LineItemDetails } from './line-item-details';
type Subscription = Database['public']['Tables']['subscriptions']['Row'];
type LineItem = Database['public']['Tables']['subscription_items']['Row'];
interface Props {
subscription: Subscription & {
items: LineItem[];
};
config: BillingConfig;
}
export function CurrentSubscriptionCard({
subscription,
config,
}: React.PropsWithChildren<Props>) {
const lineItems = subscription.items;
const firstLineItem = lineItems[0];
if (!firstLineItem) {
throw new Error('No line items found in subscription');
}
const { product, plan } = getProductPlanPairByVariantId(
config,
firstLineItem.variant_id,
);
if (!product || !plan) {
throw new Error(
'Product or plan not found. Did you forget to add it to the billing config?',
);
}
const productLineItems = plan.lineItems;
return (
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey="billing:planCardTitle" />
</CardTitle>
<CardDescription>
<Trans i18nKey="billing:planCardDescription" />
</CardDescription>
</CardHeader>
<CardContent className={'space-y-3 text-sm'}>
<div className={'flex flex-col space-y-1'}>
<div className={'flex items-center space-x-2 text-lg font-semibold'}>
<BadgeCheck
className={
's-6 fill-green-500 text-white dark:fill-white dark:text-black'
}
/>
<span>{product.name}</span>
<CurrentPlanBadge status={subscription.status} />
</div>
</div>
{/*
Only show the alert if the subscription requires action
(e.g. trial ending soon, subscription canceled, etc.)
*/}
<If condition={!subscription.active}>
<div>
<CurrentPlanAlert status={subscription.status} />
</div>
</If>
<div>
<If condition={subscription.status === 'trialing'}>
<div className="flex flex-col space-y-0.5">
<span className="font-semibold">
<Trans i18nKey="billing:trialEndsOn" />
</span>
<div className={'text-muted-foreground'}>
<span>
{subscription.trial_ends_at
? formatDate(subscription.trial_ends_at, 'P')
: ''}
</span>
</div>
</div>
</If>
<If condition={subscription.cancel_at_period_end}>
<div className="flex flex-col space-y-0.5">
<span className="font-semibold">
<Trans i18nKey="billing:cancelSubscriptionDate" />
</span>
<div className={'text-muted-foreground'}>
<span>
{formatDate(subscription.period_ends_at ?? '', 'P')}
</span>
</div>
</div>
</If>
<div className="flex flex-col space-y-0.5">
<span className="font-semibold">
<Trans i18nKey="billing:detailsLabel" />
</span>
<LineItemDetails
lineItems={productLineItems}
currency={subscription.currency}
selectedInterval={firstLineItem.interval}
/>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,92 @@
import { Suspense, forwardRef, lazy, memo, useMemo } from 'react';
import { Database } from '@kit/supabase/database';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
type BillingProvider = Database['public']['Enums']['billing_provider'];
const Fallback = <LoadingOverlay fullPage={false} />;
export function EmbeddedCheckout(
props: React.PropsWithChildren<{
checkoutToken: string;
provider: BillingProvider;
onClose?: () => void;
}>,
) {
const CheckoutComponent = useMemo(
() => loadCheckoutComponent(props.provider),
[props.provider],
);
return (
<CheckoutComponent
onClose={props.onClose}
checkoutToken={props.checkoutToken}
/>
);
}
function loadCheckoutComponent(provider: BillingProvider) {
switch (provider) {
case 'stripe': {
return buildLazyComponent(() => {
return import('@kit/stripe/components').then(({ StripeCheckout }) => {
return {
default: StripeCheckout,
};
});
});
}
case 'lemon-squeezy': {
throw new Error('Lemon Squeezy is not yet supported');
}
case 'paddle': {
throw new Error('Paddle is not yet supported');
}
default:
throw new Error(`Unsupported provider: ${provider as string}`);
}
}
function buildLazyComponent<
Component extends React.ComponentType<{
onClose: (() => unknown) | undefined;
checkoutToken: string;
}>,
>(
load: () => Promise<{
default: Component;
}>,
fallback = Fallback,
) {
let LoadedComponent: ReturnType<typeof lazy<Component>> | null = null;
const LazyComponent = forwardRef<
React.ElementRef<'div'>,
{
onClose: (() => unknown) | undefined;
checkoutToken: string;
}
>(function LazyDynamicComponent(props, ref) {
if (!LoadedComponent) {
LoadedComponent = lazy(load);
}
return (
<Suspense fallback={fallback}>
{/* @ts-expect-error: weird TS */}
<LoadedComponent
onClose={props.onClose}
checkoutToken={props.checkoutToken}
ref={ref}
/>
</Suspense>
);
});
return memo(LazyComponent);
}

View File

@@ -0,0 +1,7 @@
export * from './plan-picker';
export * from './current-subscription-card';
export * from './current-lifetime-order-card';
export * from './embedded-checkout';
export * from './billing-session-status';
export * from './billing-portal-card';
export * from './pricing-table';

View File

@@ -0,0 +1,101 @@
import { z } from 'zod';
import { LineItemSchema } from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
export function LineItemDetails(
props: React.PropsWithChildren<{
lineItems: z.infer<typeof LineItemSchema>[];
currency: string;
selectedInterval?: string | undefined;
}>,
) {
return (
<div className={'flex flex-col divide-y'}>
{props.lineItems.map((item) => {
switch (item.type) {
case 'base':
return (
<div
key={item.id}
className={'flex items-center justify-between py-1.5 text-sm'}
>
<span className={'flex space-x-2'}>
<span>
<Trans i18nKey={'billing:basePlan'} />
</span>
<span>/</span>
<span>
<If
condition={props.selectedInterval}
fallback={<Trans i18nKey={'billing:lifetime'} />}
>
<Trans
i18nKey={`billing:billingInterval.${props.selectedInterval}`}
/>
</If>
</span>
</span>
<span className={'font-semibold'}>
{formatCurrency(props?.currency.toLowerCase(), item.cost)}
</span>
</div>
);
case 'per-seat':
return (
<div
key={item.id}
className={'flex items-center justify-between py-1.5 text-sm'}
>
<span>
<Trans i18nKey={'billing:perTeamMember'} />
</span>
<span className={'font-semibold'}>
{formatCurrency(props.currency.toLowerCase(), item.cost)}
</span>
</div>
);
case 'metered':
return (
<div
key={item.id}
className={'flex items-center justify-between py-1.5 text-sm'}
>
<span>
<Trans
i18nKey={'billing:perUnit'}
values={{
unit: item.unit,
}}
/>
{item.included ? (
<Trans
i18nKey={'billing:perUnitIncluded'}
values={{
included: item.included,
}}
/>
) : (
''
)}
</span>
<span className={'font-semibold'}>
{formatCurrency(props?.currency.toLowerCase(), item.cost)}
</span>
</div>
);
}
})}
</div>
);
}

View File

@@ -0,0 +1,452 @@
'use client';
import { useMemo } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { ArrowRight, CheckCircle } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import {
BillingConfig,
LineItemSchema,
getBaseLineItem,
getPlanIntervals,
getProductPlanPair,
} from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
import { Label } from '@kit/ui/label';
import {
RadioGroup,
RadioGroupItem,
RadioGroupItemLabel,
} from '@kit/ui/radio-group';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { LineItemDetails } from './line-item-details';
export function PlanPicker(
props: React.PropsWithChildren<{
config: BillingConfig;
onSubmit: (data: { planId: string; productId: string }) => void;
canStartTrial?: boolean;
pending?: boolean;
}>,
) {
const intervals = useMemo(
() => getPlanIntervals(props.config),
[props.config],
) as string[];
const form = useForm({
reValidateMode: 'onChange',
mode: 'onChange',
resolver: zodResolver(
z
.object({
planId: z.string().min(1),
productId: z.string().min(1),
interval: z.string().min(1),
})
.refine(
(data) => {
try {
const { product, plan } = getProductPlanPair(
props.config,
data.planId,
);
return product && plan;
} catch {
return false;
}
},
{ message: `Please pick a plan to continue`, path: ['planId'] },
),
),
defaultValues: {
interval: intervals[0],
planId: '',
productId: '',
},
});
const { interval: selectedInterval } = form.watch();
const planId = form.getValues('planId');
const { plan: selectedPlan, product: selectedProduct } = useMemo(() => {
try {
return getProductPlanPair(props.config, planId);
} catch {
return {
plan: null,
product: null,
};
}
}, [props.config, planId]);
const { t } = useTranslation(`billing`);
// display the period picker if the selected plan is recurring or if no plan is selected
const isRecurringPlan =
selectedPlan?.paymentType === 'recurring' || !selectedPlan;
return (
<Form {...form}>
<div
className={
'flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0'
}
>
<form
className={'flex w-full max-w-xl flex-col space-y-4'}
onSubmit={form.handleSubmit(props.onSubmit)}
>
<div
className={cn('transition-all', {
['pointer-events-none opacity-50']: !isRecurringPlan,
})}
>
<FormField
name={'interval'}
render={({ field }) => {
return (
<FormItem className={'rounded-md border p-4'}>
<FormLabel htmlFor={'plan-picker-id'}>
<Trans i18nKey={'common:billingInterval.label'} />
</FormLabel>
<FormControl id={'plan-picker-id'}>
<RadioGroup name={field.name} value={field.value}>
<div className={'flex space-x-2.5'}>
{intervals.map((interval) => {
const selected = field.value === interval;
return (
<label
htmlFor={interval}
key={interval}
className={cn(
'hover:bg-muted flex items-center space-x-2 rounded-md border border-transparent px-4 py-2',
{
['border-border']: selected,
['hover:bg-muted']: !selected,
},
)}
>
<RadioGroupItem
id={interval}
value={interval}
onClick={() => {
form.setValue('planId', '', {
shouldValidate: true,
});
form.setValue('productId', '', {
shouldValidate: true,
});
form.setValue('interval', interval, {
shouldValidate: true,
});
}}
/>
<span className={'text-sm font-bold'}>
<Trans
i18nKey={`billing:billingInterval.${interval}`}
/>
</span>
</label>
);
})}
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<FormField
name={'planId'}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:planPickerLabel'} />
</FormLabel>
<FormControl>
<RadioGroup name={field.name}>
{props.config.products.map((product) => {
const plan = product.plans.find((item) => {
if (item.paymentType === 'one-time') {
return true;
}
return item.interval === selectedInterval;
});
if (!plan) {
return null;
}
const baseLineItem = getBaseLineItem(
props.config,
plan.id,
);
return (
<RadioGroupItemLabel
selected={field.value === plan.id}
key={plan.id}
>
<RadioGroupItem
id={plan.id}
value={plan.id}
onClick={() => {
form.setValue('planId', plan.id, {
shouldValidate: true,
});
form.setValue('productId', product.id, {
shouldValidate: true,
});
}}
/>
<div
className={
'flex w-full items-center justify-between'
}
>
<Label
htmlFor={plan.id}
className={
'flex flex-col justify-center space-y-2'
}
>
<span className="font-bold">
<Trans
i18nKey={`billing:products.${product.id}.name`}
defaults={product.name}
/>
</span>
<span className={'text-muted-foreground'}>
<Trans
i18nKey={`billing:products.${product.id}.description`}
defaults={product.description}
/>
</span>
</Label>
<div
className={
'flex items-center space-x-4 text-right'
}
>
<If
condition={
plan.trialPeriod && props.canStartTrial
}
>
<div>
<Badge variant={'success'}>
<Trans
i18nKey={`billing:trialPeriod`}
values={{
period: plan.trialPeriod,
}}
/>
</Badge>
</div>
</If>
<div>
<Price key={plan.id}>
<span>
{formatCurrency(
product.currency.toLowerCase(),
baseLineItem.cost,
)}
</span>
</Price>
<div>
<span className={'text-muted-foreground'}>
<If
condition={
plan.paymentType === 'recurring'
}
fallback={
<Trans i18nKey={`billing:lifetime`} />
}
>
<Trans
i18nKey={`billing:perPeriod`}
values={{
period: selectedInterval,
}}
/>
</If>
</span>
</div>
</div>
</div>
</div>
</RadioGroupItemLabel>
);
})}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<Button disabled={props.pending ?? !form.formState.isValid}>
{props.pending ? (
t('processing')
) : (
<>
<If
condition={selectedPlan?.trialPeriod && props.canStartTrial}
fallback={t(`proceedToPayment`)}
>
<span>{t(`startTrial`)}</span>
</If>
<ArrowRight className={'ml-2 h-4 w-4'} />
</>
)}
</Button>
</div>
</form>
{selectedPlan && selectedInterval && selectedProduct ? (
<PlanDetails
selectedInterval={selectedInterval}
selectedPlan={selectedPlan}
selectedProduct={selectedProduct}
/>
) : null}
</div>
</Form>
);
}
function PlanDetails({
selectedProduct,
selectedInterval,
selectedPlan,
}: {
selectedProduct: {
id: string;
name: string;
description: string;
currency: string;
features: string[];
};
selectedInterval: string;
selectedPlan: {
lineItems: z.infer<typeof LineItemSchema>[];
paymentType: string;
};
}) {
const isRecurring = selectedPlan.paymentType === 'recurring';
return (
<div
className={
'fade-in animate-in zoom-in-90 flex w-full flex-col space-y-4 rounded-lg border p-4'
}
>
<div className={'flex flex-col space-y-0.5'}>
<Heading level={5}>
<b>
<Trans
i18nKey={`billing:products.${selectedProduct.id}.name`}
defaults={selectedProduct.name}
/>
</b>{' '}
<If condition={isRecurring}>
/ <Trans i18nKey={`billing:billingInterval.${selectedInterval}`} />
</If>
</Heading>
<p>
<span className={'text-muted-foreground'}>
<Trans
i18nKey={`billing:products.${selectedProduct.id}.description`}
defaults={selectedProduct.description}
/>
</span>
</p>
</div>
<div className={'flex flex-col space-y-1'}>
<span className={'font-semibold'}>
<Trans i18nKey={'billing:detailsLabel'} />
</span>
<LineItemDetails
lineItems={selectedPlan.lineItems ?? []}
selectedInterval={isRecurring ? selectedInterval : undefined}
currency={selectedProduct.currency}
/>
</div>
<div className={'flex flex-col space-y-2'}>
<span className={'font-semibold'}>
<Trans i18nKey={'billing:featuresLabel'} />
</span>
{selectedProduct.features.map((item) => {
return (
<div key={item} className={'flex items-center space-x-2 text-sm'}>
<CheckCircle className={'h-4 text-green-500'} />
<span className={'text-muted-foreground'}>
<Trans i18nKey={`billing:features.${item}`} defaults={item} />
</span>
</div>
);
})}
</div>
</div>
);
}
function Price(props: React.PropsWithChildren) {
return (
<span
className={
'animate-in slide-in-from-left-4 fade-in text-xl font-bold duration-500'
}
>
{props.children}
</span>
);
}

View File

@@ -0,0 +1,326 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { CheckCircle, Sparkles } from 'lucide-react';
import { BillingConfig, getBaseLineItem, getPlanIntervals } from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
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';
interface Paths {
signUp: string;
}
export function PricingTable({
config,
paths,
CheckoutButtonRenderer,
}: {
config: BillingConfig;
paths: Paths;
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-12'}>
<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 space-x-2 lg:flex-row'
}
>
{config.products.map((product) => {
const plan = product.plans.find((plan) => plan.interval === interval);
if (!plan) {
console.warn(`No plan found for ${product.name}`);
return;
}
const basePlan = getBaseLineItem(config, plan.id);
return (
<PricingItem
selectable
key={plan.id}
plan={{ ...plan, interval }}
baseLineItem={basePlan}
product={product}
paths={paths}
CheckoutButton={CheckoutButtonRenderer}
/>
);
})}
</div>
</div>
);
}
function PricingItem(
props: React.PropsWithChildren<{
paths: {
signUp: string;
};
selectable: boolean;
baseLineItem: {
id: string;
cost: number;
};
plan: {
id: string;
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;
return (
<div
data-cy={'subscription-plan'}
className={cn(
`
relative flex w-full flex-col justify-between space-y-6 rounded-lg
border p-6 lg:w-4/12 xl:max-w-xs xl:p-8 2xl:w-3/12
`,
)}
>
<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,
['text-muted-foreground bg-gray-50']: !highlighted,
},
)}
>
<If condition={highlighted}>
<Sparkles 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>
{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>
<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 items-center duration-500`}
>
<span
className={
'flex items-center 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>
<CheckCircle 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}>
<CheckCircle 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>
);
}