feat: add subscription selection page

This commit is contained in:
moonrailgun 2024-11-09 20:28:27 +08:00
parent fffc989336
commit 843a581d42
8 changed files with 335 additions and 6 deletions

View File

@ -0,0 +1,166 @@
import { Check } from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import React from 'react';
import { useTranslation } from '@i18next-toolkit/react';
import { useEvent } from '@/hooks/useEvent';
import { defaultErrorHandler, trpc } from '@/api/trpc';
import { useCurrentWorkspaceId } from '@/store/user';
import { cn } from '@/utils/style';
import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
import { LuInfo } from 'react-icons/lu';
interface SubscriptionSelectionProps {
tier: 'FREE' | 'PRO' | 'TEAM' | 'UNLIMITED' | undefined;
}
export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> =
React.memo((props) => {
const { tier } = props;
const workspaceId = useCurrentWorkspaceId();
const { t } = useTranslation();
const checkoutMutation = trpc.billing.checkout.useMutation({
onError: defaultErrorHandler,
});
const handleCheckoutSubscribe = useEvent(
async (tier: 'free' | 'pro' | 'team') => {
const { url } = await checkoutMutation.mutateAsync({
workspaceId,
tier,
redirectUrl: location.href,
});
location.href = url;
}
);
const plans = [
{
id: 'FREE',
name: t('Free'),
price: 0,
features: [
t('Basic trial'),
t('Basic Usage'),
t('Up to 3 websites'),
t('Up to 3 surveys'),
t('Up to 3 feed channels'),
t('100K website events per month'),
t('100K monitor execution per month'),
t('10K feed event per month'),
t('Discord Community Support'),
],
onClick: () => handleCheckoutSubscribe('free'),
},
{
id: 'PRO',
name: 'Pro',
price: 19.99,
features: [
t('Sufficient for most situations'),
t('Priority access to advanced features'),
t('Up to 10 websites'),
t('Up to 20 surveys'),
t('Up to 20 feed channels'),
t('1M website events per month'),
t('1M monitor execution per month'),
t('100K feed events per month'),
t('Discord Community Support'),
],
onClick: () => handleCheckoutSubscribe('pro'),
},
{
id: 'TEAM',
name: 'Team',
price: 99.99,
features: [
t('Fully sufficient'),
t('Priority access to advanced features'),
t('Unlimited websites'),
t('Unlimited surveys'),
t('Unlimited feed channels'),
t('20M website events per month'),
t('20M monitor execution per month'),
t('1M feed events per month'),
t('Priority email support'),
],
onClick: () => handleCheckoutSubscribe('team'),
},
];
return (
<div className="container mx-auto px-4 py-8">
<h1 className="mb-8 text-center text-3xl font-bold">
{t('Subscription Plan')}
</h1>
<Alert className="mb-4">
<LuInfo className="h-4 w-4" />
<AlertTitle>{t('Current Plan')}</AlertTitle>
<AlertDescription>
{t('Your Current Plan is:')}{' '}
<span className="font-bold">{tier}</span>
</AlertDescription>
</Alert>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{plans.map((plan) => {
const isCurrent = plan.id === tier;
return (
<Card
key={plan.name}
className={cn('flex flex-col', isCurrent && 'border-primary')}
>
<CardHeader>
<CardTitle>{plan.name}</CardTitle>
<CardDescription>${plan.price} per month</CardDescription>
</CardHeader>
<CardContent className="flex-grow">
<ul className="space-y-2">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center">
<Check className="mr-2 h-4 w-4 text-green-500" />
{feature}
</li>
))}
</ul>
</CardContent>
<CardFooter>
{isCurrent ? (
<Button className="w-full" disabled variant="outline">
{t('Current')}
</Button>
) : (
<Button
className="w-full"
disabled={checkoutMutation.isLoading}
onClick={plan.onClick}
>
{t('{{action}} to {{plan}}', {
action:
plans.indexOf(plan) <
plans.findIndex((p) => p.id === tier)
? t('Downgrade')
: t('Upgrade'),
plan: plan.name,
})}
</Button>
)}
</CardFooter>
</Card>
);
})}
</div>
</div>
);
});
SubscriptionSelection.displayName = 'SubscriptionSelection';

View File

@ -22,6 +22,9 @@ export function useGlobalConfig(): AppRouterOutput['global']['config'] {
{ {
staleTime: 1000 * 60 * 60 * 1, // 1 hour staleTime: 1000 * 60 * 60 * 1, // 1 hour
onSuccess(data) { onSuccess(data) {
/**
* Call anonymous telemetry if not disabled
*/
if (data.disableAnonymousTelemetry !== true) { if (data.disableAnonymousTelemetry !== true) {
callAnonymousTelemetry(); callAnonymousTelemetry();
} }

View File

@ -34,6 +34,7 @@ import { Route as SettingsWorkspaceImport } from './routes/settings/workspace'
import { Route as SettingsUsageImport } from './routes/settings/usage' import { Route as SettingsUsageImport } from './routes/settings/usage'
import { Route as SettingsProfileImport } from './routes/settings/profile' import { Route as SettingsProfileImport } from './routes/settings/profile'
import { Route as SettingsNotificationsImport } from './routes/settings/notifications' import { Route as SettingsNotificationsImport } from './routes/settings/notifications'
import { Route as SettingsBillingImport } from './routes/settings/billing'
import { Route as SettingsAuditLogImport } from './routes/settings/auditLog' import { Route as SettingsAuditLogImport } from './routes/settings/auditLog'
import { Route as SettingsApiKeyImport } from './routes/settings/apiKey' import { Route as SettingsApiKeyImport } from './routes/settings/apiKey'
import { Route as PageAddImport } from './routes/page/add' import { Route as PageAddImport } from './routes/page/add'
@ -167,6 +168,11 @@ const SettingsNotificationsRoute = SettingsNotificationsImport.update({
getParentRoute: () => SettingsRoute, getParentRoute: () => SettingsRoute,
} as any) } as any)
const SettingsBillingRoute = SettingsBillingImport.update({
path: '/billing',
getParentRoute: () => SettingsRoute,
} as any)
const SettingsAuditLogRoute = SettingsAuditLogImport.update({ const SettingsAuditLogRoute = SettingsAuditLogImport.update({
path: '/auditLog', path: '/auditLog',
getParentRoute: () => SettingsRoute, getParentRoute: () => SettingsRoute,
@ -326,6 +332,10 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsAuditLogImport preLoaderRoute: typeof SettingsAuditLogImport
parentRoute: typeof SettingsImport parentRoute: typeof SettingsImport
} }
'/settings/billing': {
preLoaderRoute: typeof SettingsBillingImport
parentRoute: typeof SettingsImport
}
'/settings/notifications': { '/settings/notifications': {
preLoaderRoute: typeof SettingsNotificationsImport preLoaderRoute: typeof SettingsNotificationsImport
parentRoute: typeof SettingsImport parentRoute: typeof SettingsImport
@ -423,6 +433,7 @@ export const routeTree = rootRoute.addChildren([
SettingsRoute.addChildren([ SettingsRoute.addChildren([
SettingsApiKeyRoute, SettingsApiKeyRoute,
SettingsAuditLogRoute, SettingsAuditLogRoute,
SettingsBillingRoute,
SettingsNotificationsRoute, SettingsNotificationsRoute,
SettingsProfileRoute, SettingsProfileRoute,
SettingsUsageRoute, SettingsUsageRoute,

View File

@ -2,6 +2,7 @@ import { CommonHeader } from '@/components/CommonHeader';
import { CommonList } from '@/components/CommonList'; import { CommonList } from '@/components/CommonList';
import { CommonWrapper } from '@/components/CommonWrapper'; import { CommonWrapper } from '@/components/CommonWrapper';
import { Layout } from '@/components/layout'; import { Layout } from '@/components/layout';
import { useGlobalConfig } from '@/hooks/useConfig';
import { routeAuthBeforeLoad } from '@/utils/route'; import { routeAuthBeforeLoad } from '@/utils/route';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
import { import {
@ -9,6 +10,7 @@ import {
useNavigate, useNavigate,
useRouterState, useRouterState,
} from '@tanstack/react-router'; } from '@tanstack/react-router';
import { compact } from 'lodash-es';
import { useEffect } from 'react'; import { useEffect } from 'react';
export const Route = createFileRoute('/settings')({ export const Route = createFileRoute('/settings')({
@ -22,8 +24,9 @@ function PageComponent() {
const pathname = useRouterState({ const pathname = useRouterState({
select: (state) => state.location.pathname, select: (state) => state.location.pathname,
}); });
const { enableBilling } = useGlobalConfig();
const items = [ const items = compact([
{ {
id: 'profile', id: 'profile',
title: t('Profile'), title: t('Profile'),
@ -54,7 +57,12 @@ function PageComponent() {
title: t('Usage'), title: t('Usage'),
href: '/settings/usage', href: '/settings/usage',
}, },
]; enableBilling && {
id: 'billing',
title: t('Billing'),
href: '/settings/billing',
},
]);
useEffect(() => { useEffect(() => {
if (pathname === Route.fullPath) { if (pathname === Route.fullPath) {

View File

@ -0,0 +1,124 @@
import { routeAuthBeforeLoad } from '@/utils/route';
import { createFileRoute } from '@tanstack/react-router';
import { useTranslation } from '@i18next-toolkit/react';
import { CommonWrapper } from '@/components/CommonWrapper';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useMemo } from 'react';
import {
defaultErrorHandler,
defaultSuccessHandler,
trpc,
} from '../../api/trpc';
import { useCurrentWorkspace, useCurrentWorkspaceId } from '../../store/user';
import { CommonHeader } from '@/components/CommonHeader';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import dayjs from 'dayjs';
import { formatNumber } from '@/utils/common';
import { UsageCard } from '@/components/UsageCard';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useEvent } from '@/hooks/useEvent';
import { SubscriptionSelection } from '@/components/billing/SubscriptionSelection';
export const Route = createFileRoute('/settings/billing')({
beforeLoad: routeAuthBeforeLoad,
component: PageComponent,
});
function PageComponent() {
const workspaceId = useCurrentWorkspaceId();
const { t } = useTranslation();
const { data: currentTier } = trpc.billing.currentTier.useQuery({
workspaceId,
});
const checkoutMutation = trpc.billing.checkout.useMutation({
onError: defaultErrorHandler,
});
const changePlanMutation = trpc.billing.changePlan.useMutation({
onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler,
});
const cancelSubscriptionMutation =
trpc.billing.cancelSubscription.useMutation({
onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler,
});
const { data, refetch, isInitialLoading, isLoading } =
trpc.billing.currentSubscription.useQuery({
workspaceId,
});
const handleChangeSubscribe = useEvent(
async (tier: 'free' | 'pro' | 'team') => {
await changePlanMutation.mutateAsync({
workspaceId,
tier,
});
refetch();
}
);
const plan = data ? (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Button
loading={changePlanMutation.isLoading}
onClick={() => handleChangeSubscribe('free')}
>
Change plan to Free
</Button>
<Button
loading={changePlanMutation.isLoading}
onClick={() => handleChangeSubscribe('pro')}
>
Change plan to Pro
</Button>
<Button
loading={changePlanMutation.isLoading}
onClick={() => handleChangeSubscribe('team')}
>
Change plan to Team
</Button>
</div>
<div>
<Button
loading={cancelSubscriptionMutation.isLoading}
onClick={() =>
cancelSubscriptionMutation.mutateAsync({
workspaceId,
})
}
>
Cancel
</Button>
</div>
</div>
) : (
<div className="flex gap-2">
<SubscriptionSelection tier={currentTier} />
</div>
);
return (
<CommonWrapper header={<CommonHeader title={t('Billing')} />}>
<ScrollArea className="h-full overflow-hidden p-4">
<div className="flex flex-col gap-2">
<div>
<div>Current: {JSON.stringify(data)}</div>
<Button loading={isLoading} onClick={() => refetch()}>
Refresh
</Button>
</div>
<Separator className="my-2" />
{isInitialLoading === false && plan}
</div>
</ScrollArea>
</CommonWrapper>
);
}

View File

@ -21,6 +21,7 @@ import {
getWorkspaceUsage, getWorkspaceUsage,
} from '../../model/billing/workspace.js'; } from '../../model/billing/workspace.js';
import { getTierLimit, TierLimitSchema } from '../../model/billing/limit.js'; import { getTierLimit, TierLimitSchema } from '../../model/billing/limit.js';
import { WorkspaceSubscriptionTier } from '@prisma/client';
export const billingRouter = router({ export const billingRouter = router({
usage: workspaceProcedure usage: workspaceProcedure
@ -66,6 +67,20 @@ export const billingRouter = router({
return getTierLimit(tier); return getTierLimit(tier);
}), }),
currentTier: workspaceProcedure
.meta(
buildBillingOpenapi({
method: 'GET',
path: '/currentTier',
description: 'get workspace current tier',
})
)
.output(z.nativeEnum(WorkspaceSubscriptionTier))
.query(({ input }) => {
const { workspaceId } = input;
return getWorkspaceSubscription(workspaceId);
}),
currentSubscription: workspaceProcedure currentSubscription: workspaceProcedure
.meta( .meta(
buildBillingOpenapi({ buildBillingOpenapi({
@ -99,7 +114,7 @@ export const billingRouter = router({
checkout: workspaceOwnerProcedure checkout: workspaceOwnerProcedure
.input( .input(
z.object({ z.object({
tier: z.string(), tier: z.enum(['free', 'pro', 'team']),
redirectUrl: z.string().optional(), redirectUrl: z.string().optional(),
}) })
) )
@ -114,7 +129,7 @@ export const billingRouter = router({
const checkout = await createCheckoutBilling( const checkout = await createCheckoutBilling(
workspaceId, workspaceId,
userId, userId,
input.tier as SubscriptionTierType, input.tier,
redirectUrl redirectUrl
); );

View File

@ -24,9 +24,10 @@ export const globalRouter = router({
disableAnonymousTelemetry: z.boolean(), disableAnonymousTelemetry: z.boolean(),
customTrackerScriptName: z.string().optional(), customTrackerScriptName: z.string().optional(),
authProvider: z.array(z.string()), authProvider: z.array(z.string()),
enableBilling: z.boolean(),
}) })
) )
.query(async ({ input }) => { .query(async () => {
return { return {
allowRegister: env.allowRegister, allowRegister: env.allowRegister,
websiteId: env.websiteId, websiteId: env.websiteId,
@ -36,6 +37,7 @@ export const globalRouter = router({
disableAnonymousTelemetry: env.disableAnonymousTelemetry, disableAnonymousTelemetry: env.disableAnonymousTelemetry,
customTrackerScriptName: env.customTrackerScriptName, customTrackerScriptName: env.customTrackerScriptName,
authProvider: env.auth.provider, authProvider: env.auth.provider,
enableBilling: env.billing.enable,
}; };
}), }),
}); });

View File

@ -47,7 +47,7 @@ export const env = {
}, },
}, },
billing: { billing: {
enable: process.env.ENABLE_BILLING, enable: checkEnvTrusty(process.env.ENABLE_BILLING),
lemonSqueezy: { lemonSqueezy: {
signatureSecret: process.env.LEMON_SQUEEZY_SIGNATURE_SECRET ?? '', signatureSecret: process.env.LEMON_SQUEEZY_SIGNATURE_SECRET ?? '',
apiKey: process.env.LEMON_SQUEEZY_API_KEY ?? '', apiKey: process.env.LEMON_SQUEEZY_API_KEY ?? '',