feat: add subscription selection page
This commit is contained in:
parent
fffc989336
commit
843a581d42
166
src/client/components/billing/SubscriptionSelection.tsx
Normal file
166
src/client/components/billing/SubscriptionSelection.tsx
Normal 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';
|
@ -22,6 +22,9 @@ export function useGlobalConfig(): AppRouterOutput['global']['config'] {
|
||||
{
|
||||
staleTime: 1000 * 60 * 60 * 1, // 1 hour
|
||||
onSuccess(data) {
|
||||
/**
|
||||
* Call anonymous telemetry if not disabled
|
||||
*/
|
||||
if (data.disableAnonymousTelemetry !== true) {
|
||||
callAnonymousTelemetry();
|
||||
}
|
||||
|
@ -34,6 +34,7 @@ import { Route as SettingsWorkspaceImport } from './routes/settings/workspace'
|
||||
import { Route as SettingsUsageImport } from './routes/settings/usage'
|
||||
import { Route as SettingsProfileImport } from './routes/settings/profile'
|
||||
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 SettingsApiKeyImport } from './routes/settings/apiKey'
|
||||
import { Route as PageAddImport } from './routes/page/add'
|
||||
@ -167,6 +168,11 @@ const SettingsNotificationsRoute = SettingsNotificationsImport.update({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
} as any)
|
||||
|
||||
const SettingsBillingRoute = SettingsBillingImport.update({
|
||||
path: '/billing',
|
||||
getParentRoute: () => SettingsRoute,
|
||||
} as any)
|
||||
|
||||
const SettingsAuditLogRoute = SettingsAuditLogImport.update({
|
||||
path: '/auditLog',
|
||||
getParentRoute: () => SettingsRoute,
|
||||
@ -326,6 +332,10 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SettingsAuditLogImport
|
||||
parentRoute: typeof SettingsImport
|
||||
}
|
||||
'/settings/billing': {
|
||||
preLoaderRoute: typeof SettingsBillingImport
|
||||
parentRoute: typeof SettingsImport
|
||||
}
|
||||
'/settings/notifications': {
|
||||
preLoaderRoute: typeof SettingsNotificationsImport
|
||||
parentRoute: typeof SettingsImport
|
||||
@ -423,6 +433,7 @@ export const routeTree = rootRoute.addChildren([
|
||||
SettingsRoute.addChildren([
|
||||
SettingsApiKeyRoute,
|
||||
SettingsAuditLogRoute,
|
||||
SettingsBillingRoute,
|
||||
SettingsNotificationsRoute,
|
||||
SettingsProfileRoute,
|
||||
SettingsUsageRoute,
|
||||
|
@ -2,6 +2,7 @@ import { CommonHeader } from '@/components/CommonHeader';
|
||||
import { CommonList } from '@/components/CommonList';
|
||||
import { CommonWrapper } from '@/components/CommonWrapper';
|
||||
import { Layout } from '@/components/layout';
|
||||
import { useGlobalConfig } from '@/hooks/useConfig';
|
||||
import { routeAuthBeforeLoad } from '@/utils/route';
|
||||
import { useTranslation } from '@i18next-toolkit/react';
|
||||
import {
|
||||
@ -9,6 +10,7 @@ import {
|
||||
useNavigate,
|
||||
useRouterState,
|
||||
} from '@tanstack/react-router';
|
||||
import { compact } from 'lodash-es';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const Route = createFileRoute('/settings')({
|
||||
@ -22,8 +24,9 @@ function PageComponent() {
|
||||
const pathname = useRouterState({
|
||||
select: (state) => state.location.pathname,
|
||||
});
|
||||
const { enableBilling } = useGlobalConfig();
|
||||
|
||||
const items = [
|
||||
const items = compact([
|
||||
{
|
||||
id: 'profile',
|
||||
title: t('Profile'),
|
||||
@ -54,7 +57,12 @@ function PageComponent() {
|
||||
title: t('Usage'),
|
||||
href: '/settings/usage',
|
||||
},
|
||||
];
|
||||
enableBilling && {
|
||||
id: 'billing',
|
||||
title: t('Billing'),
|
||||
href: '/settings/billing',
|
||||
},
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname === Route.fullPath) {
|
||||
|
124
src/client/routes/settings/billing.tsx
Normal file
124
src/client/routes/settings/billing.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -21,6 +21,7 @@ import {
|
||||
getWorkspaceUsage,
|
||||
} from '../../model/billing/workspace.js';
|
||||
import { getTierLimit, TierLimitSchema } from '../../model/billing/limit.js';
|
||||
import { WorkspaceSubscriptionTier } from '@prisma/client';
|
||||
|
||||
export const billingRouter = router({
|
||||
usage: workspaceProcedure
|
||||
@ -66,6 +67,20 @@ export const billingRouter = router({
|
||||
|
||||
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
|
||||
.meta(
|
||||
buildBillingOpenapi({
|
||||
@ -99,7 +114,7 @@ export const billingRouter = router({
|
||||
checkout: workspaceOwnerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
tier: z.string(),
|
||||
tier: z.enum(['free', 'pro', 'team']),
|
||||
redirectUrl: z.string().optional(),
|
||||
})
|
||||
)
|
||||
@ -114,7 +129,7 @@ export const billingRouter = router({
|
||||
const checkout = await createCheckoutBilling(
|
||||
workspaceId,
|
||||
userId,
|
||||
input.tier as SubscriptionTierType,
|
||||
input.tier,
|
||||
redirectUrl
|
||||
);
|
||||
|
||||
|
@ -24,9 +24,10 @@ export const globalRouter = router({
|
||||
disableAnonymousTelemetry: z.boolean(),
|
||||
customTrackerScriptName: z.string().optional(),
|
||||
authProvider: z.array(z.string()),
|
||||
enableBilling: z.boolean(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
.query(async () => {
|
||||
return {
|
||||
allowRegister: env.allowRegister,
|
||||
websiteId: env.websiteId,
|
||||
@ -36,6 +37,7 @@ export const globalRouter = router({
|
||||
disableAnonymousTelemetry: env.disableAnonymousTelemetry,
|
||||
customTrackerScriptName: env.customTrackerScriptName,
|
||||
authProvider: env.auth.provider,
|
||||
enableBilling: env.billing.enable,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
@ -47,7 +47,7 @@ export const env = {
|
||||
},
|
||||
},
|
||||
billing: {
|
||||
enable: process.env.ENABLE_BILLING,
|
||||
enable: checkEnvTrusty(process.env.ENABLE_BILLING),
|
||||
lemonSqueezy: {
|
||||
signatureSecret: process.env.LEMON_SQUEEZY_SIGNATURE_SECRET ?? '',
|
||||
apiKey: process.env.LEMON_SQUEEZY_API_KEY ?? '',
|
||||
|
Loading…
Reference in New Issue
Block a user