From 843a581d429b11cb6959e6aee2d50eb9e144f3e5 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Sat, 9 Nov 2024 20:28:27 +0800 Subject: [PATCH] feat: add subscription selection page --- .../billing/SubscriptionSelection.tsx | 166 ++++++++++++++++++ src/client/hooks/useConfig.ts | 3 + src/client/routeTree.gen.ts | 11 ++ src/client/routes/settings.tsx | 12 +- src/client/routes/settings/billing.tsx | 124 +++++++++++++ src/server/trpc/routers/billing.ts | 19 +- src/server/trpc/routers/global.ts | 4 +- src/server/utils/env.ts | 2 +- 8 files changed, 335 insertions(+), 6 deletions(-) create mode 100644 src/client/components/billing/SubscriptionSelection.tsx create mode 100644 src/client/routes/settings/billing.tsx diff --git a/src/client/components/billing/SubscriptionSelection.tsx b/src/client/components/billing/SubscriptionSelection.tsx new file mode 100644 index 0000000..abdc5d0 --- /dev/null +++ b/src/client/components/billing/SubscriptionSelection.tsx @@ -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 = + 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 ( +
+

+ {t('Subscription Plan')} +

+ + + + {t('Current Plan')} + + {t('Your Current Plan is:')}{' '} + {tier} + + + +
+ {plans.map((plan) => { + const isCurrent = plan.id === tier; + + return ( + + + {plan.name} + ${plan.price} per month + + +
    + {plan.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ + {isCurrent ? ( + + ) : ( + + )} + +
+ ); + })} +
+
+ ); + }); +SubscriptionSelection.displayName = 'SubscriptionSelection'; diff --git a/src/client/hooks/useConfig.ts b/src/client/hooks/useConfig.ts index f30d38e..2a010b9 100644 --- a/src/client/hooks/useConfig.ts +++ b/src/client/hooks/useConfig.ts @@ -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(); } diff --git a/src/client/routeTree.gen.ts b/src/client/routeTree.gen.ts index 3057d8e..8a40214 100644 --- a/src/client/routeTree.gen.ts +++ b/src/client/routeTree.gen.ts @@ -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, diff --git a/src/client/routes/settings.tsx b/src/client/routes/settings.tsx index db7a767..4c6ab7d 100644 --- a/src/client/routes/settings.tsx +++ b/src/client/routes/settings.tsx @@ -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) { diff --git a/src/client/routes/settings/billing.tsx b/src/client/routes/settings/billing.tsx new file mode 100644 index 0000000..d0dbfaf --- /dev/null +++ b/src/client/routes/settings/billing.tsx @@ -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 ? ( +
+
+ + + +
+ +
+ +
+
+ ) : ( +
+ +
+ ); + + return ( + }> + +
+
+
Current: {JSON.stringify(data)}
+ + +
+ + + + {isInitialLoading === false && plan} +
+
+
+ ); +} diff --git a/src/server/trpc/routers/billing.ts b/src/server/trpc/routers/billing.ts index ee4609d..3d30f95 100644 --- a/src/server/trpc/routers/billing.ts +++ b/src/server/trpc/routers/billing.ts @@ -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 ); diff --git a/src/server/trpc/routers/global.ts b/src/server/trpc/routers/global.ts index 7289121..c1fa45e 100644 --- a/src/server/trpc/routers/global.ts +++ b/src/server/trpc/routers/global.ts @@ -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, }; }), }); diff --git a/src/server/utils/env.ts b/src/server/utils/env.ts index 82dbfdf..b80113f 100644 --- a/src/server/utils/env.ts +++ b/src/server/utils/env.ts @@ -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 ?? '',