This commit updates the naming convention of icons from Lucide-React, moving some package dependencies to "peerDependencies" in 'team-accounts', 'admin' and 'auth'. Additionally, it includes tweaks to the development server command in apps/web package.json and adds a logger reference to the shared package. Furthermore, cleanup work has been performed within the features and UI packages, and new scripts to interact with Stripe have been added to the root package.json.
325 lines
7.8 KiB
TypeScript
325 lines
7.8 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
|
|
import Link from 'next/link';
|
|
|
|
import { CheckCircle, Sparkles } 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}>
|
|
<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>{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>
|
|
<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>
|
|
);
|
|
}
|