-
Notifications
You must be signed in to change notification settings - Fork 94
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add subscription selection page
- Loading branch information
1 parent
fffc989
commit 843a581
Showing
8 changed files
with
335 additions
and
6 deletions.
There are no files selected for viewing
166 changes: 166 additions & 0 deletions
166
src/client/components/billing/SubscriptionSelection.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
Oops, something went wrong.