2
0

Add usage-based new pricing plans

This commit is contained in:
Baptiste Arnaud
2022-09-17 16:37:33 +02:00
committed by Baptiste Arnaud
parent 6a1eaea700
commit 898367a33b
144 changed files with 4631 additions and 1624 deletions

View File

@ -1,4 +1,5 @@
import { DashboardFolder, WorkspaceRole } from 'db'
import { env } from 'utils'
import {
Flex,
Heading,
@ -160,9 +161,13 @@ export const FolderContent = ({ folder }: Props) => {
return (
<Flex w="full" flex="1" justify="center">
{typebots && !isTypebotLoading && user && folder === null && (
<OnboardingModal totalTypebots={typebots.length} />
)}
{typebots &&
!isTypebotLoading &&
user &&
folder === null &&
env('E2E_TEST') !== 'true' && (
<OnboardingModal totalTypebots={typebots.length} />
)}
<Stack w="1000px" spacing={6}>
<Skeleton isLoaded={folder?.name !== undefined}>
<Heading as="h1">{folder?.name}</Heading>

View File

@ -1,7 +1,9 @@
import { Button, HStack, Tag, useDisclosure, Text } from '@chakra-ui/react'
import { FolderPlusIcon } from 'assets/icons'
import { UpgradeModal } from 'components/shared/modals/UpgradeModal'
import { LimitReached } from 'components/shared/modals/UpgradeModal/UpgradeModal'
import {
LimitReached,
ChangePlanModal,
} from 'components/shared/modals/ChangePlanModal'
import { useWorkspace } from 'contexts/WorkspaceContext'
import React from 'react'
import { isFreePlan } from 'services/workspace'
@ -26,7 +28,7 @@ export const CreateFolderButton = ({ isLoading, onClick }: Props) => {
<Text>Create a folder</Text>
{isFreePlan(workspace) && <Tag colorScheme="orange">Pro</Tag>}
</HStack>
<UpgradeModal
<ChangePlanModal
isOpen={isOpen}
onClose={onClose}
type={LimitReached.FOLDER}

View File

@ -0,0 +1,34 @@
import { Stack } from '@chakra-ui/react'
import { ChangePlanForm } from 'components/shared/ChangePlanForm'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db'
import React from 'react'
import { CurrentSubscriptionContent } from './CurrentSubscriptionContent'
import { InvoicesList } from './InvoicesList'
import { UsageContent } from './UsageContent/UsageContent'
export const BillingContent = () => {
const { workspace, refreshWorkspace } = useWorkspace()
if (!workspace) return null
return (
<Stack spacing="10" w="full">
<CurrentSubscriptionContent
plan={workspace.plan}
stripeId={workspace.stripeId}
onCancelSuccess={() =>
refreshWorkspace({
plan: Plan.FREE,
additionalChatsIndex: 0,
additionalStorageIndex: 0,
})
}
/>
<UsageContent workspace={workspace} />
{workspace.plan !== Plan.LIFETIME && workspace.plan !== Plan.OFFERED && (
<ChangePlanForm />
)}
{workspace.stripeId && <InvoicesList workspace={workspace} />}
</Stack>
)
}

View File

@ -0,0 +1,77 @@
import {
Text,
HStack,
Link,
Spinner,
Stack,
Flex,
Button,
} from '@chakra-ui/react'
import { PlanTag } from 'components/shared/PlanTag'
import { Plan } from 'db'
import React, { useState } from 'react'
import { cancelSubscriptionQuery } from './queries/cancelSubscriptionQuery'
type CurrentSubscriptionContentProps = {
plan: Plan
stripeId?: string | null
onCancelSuccess: () => void
}
export const CurrentSubscriptionContent = ({
plan,
stripeId,
onCancelSuccess,
}: CurrentSubscriptionContentProps) => {
const [isCancelling, setIsCancelling] = useState(false)
const [isRedirectingToBillingPortal, setIsRedirectingToBillingPortal] =
useState(false)
const cancelSubscription = async () => {
if (!stripeId) return
setIsCancelling(true)
await cancelSubscriptionQuery(stripeId)
onCancelSuccess()
setIsCancelling(false)
}
if (isCancelling) return <Spinner colorScheme="gray" />
return (
<Stack gap="2">
<HStack>
<Text>Current workspace subscription: </Text>
<PlanTag plan={plan} />
</HStack>
{(plan === Plan.STARTER || plan === Plan.PRO) && stripeId && (
<>
<Stack gap="1">
<Text fontSize="sm">
Need to change payment method or billing information? Head over to
your billing portal:
</Text>
<Button
as={Link}
href={`/api/stripe/billing-portal?stripeId=${stripeId}`}
onClick={() => setIsRedirectingToBillingPortal(true)}
isLoading={isRedirectingToBillingPortal}
>
Billing Portal
</Button>
</Stack>
<Flex>
<Link
as="button"
color="gray.500"
textDecor="underline"
fontSize="sm"
onClick={cancelSubscription}
>
Cancel my subscription
</Link>
</Flex>
</>
)}
</Stack>
)
}

View File

@ -0,0 +1,97 @@
import {
Stack,
Heading,
Checkbox,
Skeleton,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
IconButton,
Text,
} from '@chakra-ui/react'
import { DownloadIcon, FileIcon } from 'assets/icons'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { Workspace } from 'db'
import React from 'react'
import { useInvoicesQuery } from './queries/useInvoicesQuery'
type Props = {
workspace: Workspace
}
export const InvoicesList = ({ workspace }: Props) => {
const { invoices, isLoading } = useInvoicesQuery(workspace.stripeId)
return (
<Stack spacing={6}>
<Heading fontSize="3xl">Invoices</Heading>
{invoices.length === 0 && !isLoading ? (
<Text>No invoices found for this workspace.</Text>
) : (
<TableContainer>
<Table>
<Thead>
<Tr>
<Th w="0" />
<Th>#</Th>
<Th>Paid at</Th>
<Th>Subtotal</Th>
<Th w="0" />
</Tr>
</Thead>
<Tbody>
{invoices?.map((invoice) => (
<Tr key={invoice.id}>
<Td>
<FileIcon />
</Td>
<Td>{invoice.id}</Td>
<Td>{new Date(invoice.date * 1000).toDateString()}</Td>
<Td>{getFormattedPrice(invoice.amount, invoice.currency)}</Td>
<Td>
<IconButton
as={NextChakraLink}
size="xs"
icon={<DownloadIcon />}
variant="outline"
href={invoice.url}
isExternal
aria-label={'Download invoice'}
/>
</Td>
</Tr>
))}
{isLoading &&
Array.from({ length: 3 }).map((_, idx) => (
<Tr key={idx}>
<Td>
<Checkbox isDisabled />
</Td>
<Td>
<Skeleton h="5px" />
</Td>
<Td>
<Skeleton h="5px" />
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
)}
</Stack>
)
}
const getFormattedPrice = (amount: number, currency: string) => {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
})
return formatter.format(amount / 100)
}

View File

@ -0,0 +1,158 @@
import {
Stack,
Flex,
Heading,
Progress,
Text,
Skeleton,
HStack,
Tooltip,
} from '@chakra-ui/react'
import { AlertIcon } from 'assets/icons'
import { Plan, Workspace } from 'db'
import React from 'react'
import { getChatsLimit, getStorageLimit, parseNumberWithCommas } from 'utils'
import { storageToReadable } from './helpers'
import { useUsage } from './useUsage'
type Props = {
workspace: Workspace
}
export const UsageContent = ({ workspace }: Props) => {
const { data, isLoading } = useUsage(workspace.id)
const totalChatsUsed = data?.totalChatsUsed ?? 0
const totalStorageUsed = data?.totalStorageUsed ?? 0
const workspaceChatsLimit = getChatsLimit(workspace)
const workspaceStorageLimit = getStorageLimit(workspace)
const workspaceStorageLimitGigabites =
workspaceStorageLimit * 1024 * 1024 * 1024
const chatsPercentage = Math.round(
(totalChatsUsed / workspaceChatsLimit) * 100
)
const storagePercentage = Math.round(
(totalStorageUsed / workspaceStorageLimitGigabites) * 100
)
return (
<Stack spacing={6}>
<Heading fontSize="3xl">Usage</Heading>
<Stack spacing={3}>
<Flex justifyContent="space-between">
<HStack>
<Heading fontSize="xl" as="h3">
Chats
</Heading>
{chatsPercentage >= 80 && (
<Tooltip
placement="top"
rounded="md"
p="3"
label={
<Text>
Your typebots are popular! You will soon reach your plan's
chats limit. 🚀
<br />
<br />
Make sure to <strong>update your plan</strong> to increase
this limit and continue chatting with your users.
</Text>
}
>
<span>
<AlertIcon color="orange.500" />
</span>
</Tooltip>
)}
<Text fontSize="sm" fontStyle="italic" color="gray.500">
(resets on 1st of every month)
</Text>
</HStack>
<HStack>
<Skeleton
fontWeight="bold"
isLoaded={!isLoading}
h={isLoading ? '5px' : 'auto'}
>
{parseNumberWithCommas(totalChatsUsed)}
</Skeleton>
<Text>/ {parseNumberWithCommas(workspaceChatsLimit)}</Text>
</HStack>
</Flex>
<Progress
h="5px"
value={chatsPercentage}
rounded="full"
hasStripe
isIndeterminate={isLoading}
colorScheme={totalChatsUsed >= workspaceChatsLimit ? 'red' : 'blue'}
/>
</Stack>
{workspace.plan !== Plan.FREE && (
<Stack spacing={3}>
<Flex justifyContent="space-between">
<HStack>
<Heading fontSize="xl" as="h3">
Storage
</Heading>
{storagePercentage >= 80 && (
<Tooltip
placement="top"
rounded="md"
p="3"
label={
<Text>
Your typebots are popular! You will soon reach your plan's
storage limit. 🚀
<br />
<br />
Make sure to <strong>update your plan</strong> in order to
continue collecting uploaded files. You can also{' '}
<strong>delete files</strong> to free up space.
</Text>
}
>
<span>
<AlertIcon color="orange.500" />
</span>
</Tooltip>
)}
</HStack>
<Heading
fontSize="xl"
as="h3"
display="inline-flex"
alignItems="center"
gap="2"
></Heading>
<HStack>
<Skeleton
fontWeight="bold"
isLoaded={!isLoading}
h={isLoading ? '5px' : 'auto'}
>
{storageToReadable(totalStorageUsed)}
</Skeleton>
<Text>/ {workspaceStorageLimit} GB</Text>
</HStack>
</Flex>
<Progress
value={storagePercentage}
h="5px"
colorScheme={
totalStorageUsed >= workspaceStorageLimitGigabites
? 'red'
: 'blue'
}
rounded="full"
hasStripe
isIndeterminate={isLoading}
/>
</Stack>
)}
</Stack>
)
}

View File

@ -0,0 +1,7 @@
export const storageToReadable = (bytes: number) => {
if (bytes == 0) {
return '0'
}
const e = Math.floor(Math.log(bytes) / Math.log(1024))
return (bytes / Math.pow(1024, e)).toFixed(2) + ' ' + ' KMGTP'.charAt(e) + 'B'
}

View File

@ -0,0 +1 @@
export { UsageContent } from './UsageContent'

View File

@ -0,0 +1,16 @@
import { fetcher } from 'services/utils'
import useSWR from 'swr'
import { env } from 'utils'
export const useUsage = (workspaceId?: string) => {
const { data, error } = useSWR<
{ totalChatsUsed: number; totalStorageUsed: number },
Error
>(workspaceId ? `/api/workspaces/${workspaceId}/usage` : null, fetcher, {
dedupingInterval: env('E2E_TEST') === 'enabled' ? 0 : undefined,
})
return {
data,
isLoading: !error && !data,
}
}

View File

@ -0,0 +1 @@
export { BillingContent } from './BillingContent'

View File

@ -0,0 +1,7 @@
import { sendRequest } from 'utils'
export const cancelSubscriptionQuery = (stripeId: string) =>
sendRequest({
url: `api/stripe/subscription?stripeId=${stripeId}`,
method: 'DELETE',
})

View File

@ -0,0 +1,7 @@
import { sendRequest } from 'utils'
export const redirectToBillingPortal = ({
workspaceId,
}: {
workspaceId: string
}) => sendRequest(`/api/stripe/billing-portal?workspaceId=${workspaceId}`)

View File

@ -0,0 +1,24 @@
import { fetcher } from 'services/utils'
import useSWR from 'swr'
import { env } from 'utils'
type Invoice = {
id: string
url: string
date: number
currency: string
amount: number
}
export const useInvoicesQuery = (stripeId?: string | null) => {
const { data, error } = useSWR<{ invoices: Invoice[] }, Error>(
stripeId ? `/api/stripe/invoices?stripeId=${stripeId}` : null,
fetcher,
{
dedupingInterval: env('E2E_TEST') === 'enabled' ? 0 : undefined,
}
)
return {
invoices: data?.invoices ?? [],
isLoading: !error && !data,
}
}

View File

@ -1,76 +0,0 @@
import { Stack, HStack, Button, Text, Tag } from '@chakra-ui/react'
import { ExternalLinkIcon } from 'assets/icons'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { UpgradeButton } from 'components/shared/buttons/UpgradeButton'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db'
import React from 'react'
export const BillingForm = () => {
const { workspace } = useWorkspace()
return (
<Stack spacing="6" w="full">
<HStack>
<Text>Current workspace subscription: </Text>
<PlanTag plan={workspace?.plan} />
</HStack>
{workspace &&
!([Plan.TEAM, Plan.LIFETIME, Plan.OFFERED] as Plan[]).includes(
workspace.plan
) && (
<HStack>
{workspace?.plan === Plan.FREE && (
<UpgradeButton colorScheme="orange" variant="outline" w="full">
Upgrade to Pro plan
</UpgradeButton>
)}
{workspace?.plan !== Plan.TEAM && (
<UpgradeButton
colorScheme="purple"
variant="outline"
w="full"
plan={Plan.TEAM}
>
Upgrade to Team plan
</UpgradeButton>
)}
</HStack>
)}
{workspace?.stripeId && (
<>
<Text>
To manage your subscription and download invoices, head over to your
Stripe portal:
</Text>
<Button
as={NextChakraLink}
href={`/api/stripe/customer-portal?workspaceId=${workspace.id}`}
isExternal
colorScheme="blue"
rightIcon={<ExternalLinkIcon />}
>
Stripe Portal
</Button>
</>
)}
</Stack>
)
}
const PlanTag = ({ plan }: { plan?: Plan }) => {
switch (plan) {
case Plan.TEAM: {
return <Tag colorScheme="purple">Team</Tag>
}
case Plan.LIFETIME:
case Plan.OFFERED:
case Plan.PRO: {
return <Tag colorScheme="orange">Personal Pro</Tag>
}
default: {
return <Tag colorScheme="gray">Free</Tag>
}
}
}

View File

@ -2,7 +2,7 @@ import { HStack, SkeletonCircle, SkeletonText, Stack } from '@chakra-ui/react'
import { UnlockPlanInfo } from 'components/shared/Info'
import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan, WorkspaceInvitation, WorkspaceRole } from 'db'
import { WorkspaceInvitation, WorkspaceRole } from 'db'
import React from 'react'
import {
deleteInvitation,
@ -13,6 +13,7 @@ import {
useMembers,
} from 'services/workspace'
import { AddMemberForm } from './AddMemberForm'
import { checkCanInviteMember } from './helpers'
import { MemberItem } from './MemberItem'
export const MembersList = () => {
@ -78,14 +79,19 @@ export const MembersList = () => {
})
}
const canInviteNewMember = checkCanInviteMember({
plan: workspace?.plan,
currentMembersCount: [...(members ?? []), ...(invitations ?? [])].length,
})
return (
<Stack w="full">
{workspace?.plan !== Plan.TEAM && (
<Stack w="full" gap="3">
{!canInviteNewMember && (
<UnlockPlanInfo
contentLabel={
'Upgrade to team plan for a collaborative workspace, unlimited team members, and advanced permissions.'
}
plan={Plan.TEAM}
contentLabel={`
Upgrade your plan to work with more team members, and unlock awesome
power features 🚀
`}
/>
)}
{workspace?.id && canEdit && (
@ -94,7 +100,7 @@ export const MembersList = () => {
onNewInvitation={handleNewInvitation}
onNewMember={handleNewMember}
isLoading={isLoading}
isLocked={workspace.plan !== Plan.TEAM}
isLocked={!canInviteNewMember}
/>
)}
{members?.map((member) => (

View File

@ -0,0 +1,15 @@
import { Plan } from 'db'
import { seatsLimit } from 'utils'
export function checkCanInviteMember({
plan,
currentMembersCount,
}: {
plan: string | undefined
currentMembersCount?: number
}) {
if (!plan || !currentMembersCount) return false
if (plan !== Plan.STARTER && plan !== Plan.PRO) return false
return seatsLimit[plan].totalIncluded > currentMembersCount
}

View File

@ -18,7 +18,7 @@ import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { User, Workspace } from 'db'
import { useState } from 'react'
import { BillingForm } from './BillingForm'
import { BillingContent } from './BillingContent'
import { MembersList } from './MembersList'
import { MyAccountForm } from './MyAccountForm'
import { EditorSettingsForm } from './EditorSettingsForm'
@ -50,13 +50,12 @@ export const WorkspaceSettingsModal = ({
return (
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay />
<ModalContent h="600px" flexDir="row">
<ModalContent minH="600px" flexDir="row">
<Stack
spacing={8}
w="250px"
w="200px"
py="6"
borderRightWidth={1}
h="full"
justifyContent="space-between"
>
<Stack spacing={8}>
@ -134,7 +133,7 @@ export const WorkspaceSettingsModal = ({
justifyContent="flex-start"
pl="4"
>
Billing
Billing & Usage
</Button>
)}
</Stack>
@ -174,7 +173,7 @@ const SettingsContent = ({
case 'members':
return <MembersList />
case 'billing':
return <BillingForm />
return <BillingContent />
default:
return null
}