Skip to content

Commit

Permalink
feat: add subscription selection page
Browse files Browse the repository at this point in the history
  • Loading branch information
moonrailgun committed Nov 9, 2024
1 parent fffc989 commit 843a581
Show file tree
Hide file tree
Showing 8 changed files with 335 additions and 6 deletions.
166 changes: 166 additions & 0 deletions src/client/components/billing/SubscriptionSelection.tsx
Original file line number Diff line number Diff line change
@@ -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';
3 changes: 3 additions & 0 deletions src/client/hooks/useConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
11 changes: 11 additions & 0 deletions src/client/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -423,6 +433,7 @@ export const routeTree = rootRoute.addChildren([
SettingsRoute.addChildren([
SettingsApiKeyRoute,
SettingsAuditLogRoute,
SettingsBillingRoute,
SettingsNotificationsRoute,
SettingsProfileRoute,
SettingsUsageRoute,
Expand Down
12 changes: 10 additions & 2 deletions src/client/routes/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ 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 {
createFileRoute,
useNavigate,
useRouterState,
} from '@tanstack/react-router';
import { compact } from 'lodash-es';
import { useEffect } from 'react';

export const Route = createFileRoute('/settings')({
Expand All @@ -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'),
Expand Down Expand Up @@ -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) {
Expand Down
124 changes: 124 additions & 0 deletions src/client/routes/settings/billing.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
Loading

0 comments on commit 843a581

Please sign in to comment.