⚡ (billing) Automatic usage-based billing (#924)
BREAKING CHANGE: Stripe environment variables simplified. Check out the new configs to adapt your existing system. Closes #906 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ### Summary by CodeRabbit **New Features:** - Introduced a usage-based billing system, providing more flexibility and options for users. - Integrated with Stripe for a smoother and more secure payment process. - Enhanced the user interface with improvements to the billing, workspace, and pricing pages for a more intuitive experience. **Improvements:** - Simplified the billing logic, removing additional chats and yearly billing for a more streamlined user experience. - Updated email notifications to keep users informed about their usage and limits. - Improved pricing and currency formatting for better clarity and understanding. **Testing:** - Updated tests and specifications to ensure the reliability of new features and improvements. **Note:** These changes aim to provide a more flexible and user-friendly billing system, with clearer pricing and improved notifications. Users should find the new system more intuitive and easier to navigate. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
import { Stack, HStack, Text, Switch, Tag } from '@chakra-ui/react'
|
||||
import { Stack, HStack, Text } from '@chakra-ui/react'
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
import { TextLink } from '@/components/TextLink'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { guessIfUserIsEuropean } from '@typebot.io/lib/pricing'
|
||||
import { Workspace } from '@typebot.io/schemas'
|
||||
import { PreCheckoutModal, PreCheckoutModalProps } from './PreCheckoutModal'
|
||||
import { useState } from 'react'
|
||||
@@ -13,6 +12,7 @@ import { StarterPlanPricingCard } from './StarterPlanPricingCard'
|
||||
import { ProPlanPricingCard } from './ProPlanPricingCard'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
import { StripeClimateLogo } from './StripeClimateLogo'
|
||||
import { guessIfUserIsEuropean } from '@typebot.io/lib/billing/guessIfUserIsEuropean'
|
||||
|
||||
type Props = {
|
||||
workspace: Workspace
|
||||
@@ -26,21 +26,12 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => {
|
||||
const { showToast } = useToast()
|
||||
const [preCheckoutPlan, setPreCheckoutPlan] =
|
||||
useState<PreCheckoutModalProps['selectedSubscription']>()
|
||||
const [isYearly, setIsYearly] = useState(true)
|
||||
|
||||
const trpcContext = trpc.useContext()
|
||||
|
||||
const { data, refetch } = trpc.billing.getSubscription.useQuery(
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
{
|
||||
onSuccess: ({ subscription }) => {
|
||||
if (isYearly === false) return
|
||||
setIsYearly(subscription?.isYearly ?? true)
|
||||
},
|
||||
}
|
||||
)
|
||||
const { data, refetch } = trpc.billing.getSubscription.useQuery({
|
||||
workspaceId: workspace.id,
|
||||
})
|
||||
|
||||
const { mutate: updateSubscription, isLoading: isUpdatingSubscription } =
|
||||
trpc.billing.updateSubscription.useMutation({
|
||||
@@ -65,23 +56,15 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => {
|
||||
},
|
||||
})
|
||||
|
||||
const handlePayClick = async ({
|
||||
plan,
|
||||
selectedChatsLimitIndex,
|
||||
}: {
|
||||
plan: 'STARTER' | 'PRO'
|
||||
selectedChatsLimitIndex: number
|
||||
}) => {
|
||||
if (!user || selectedChatsLimitIndex === undefined) return
|
||||
const handlePayClick = async (plan: 'STARTER' | 'PRO') => {
|
||||
if (!user) return
|
||||
|
||||
const newSubscription = {
|
||||
plan,
|
||||
workspaceId: workspace.id,
|
||||
additionalChats: selectedChatsLimitIndex,
|
||||
currency:
|
||||
data?.subscription?.currency ??
|
||||
(guessIfUserIsEuropean() ? 'eur' : 'usd'),
|
||||
isYearly,
|
||||
} as const
|
||||
if (workspace.stripeId) {
|
||||
updateSubscription({
|
||||
@@ -122,26 +105,11 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => {
|
||||
)}
|
||||
{data && (
|
||||
<Stack align="flex-end" spacing={6}>
|
||||
<HStack>
|
||||
<Text>Monthly</Text>
|
||||
<Switch
|
||||
isChecked={isYearly}
|
||||
onChange={() => setIsYearly(!isYearly)}
|
||||
/>
|
||||
<HStack>
|
||||
<Text>Yearly</Text>
|
||||
<Tag colorScheme="blue">16% off</Tag>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<HStack alignItems="stretch" spacing="4" w="full">
|
||||
{excludedPlans?.includes('STARTER') ? null : (
|
||||
<StarterPlanPricingCard
|
||||
workspace={workspace}
|
||||
currentSubscription={{ isYearly: data.subscription?.isYearly }}
|
||||
onPayClick={(props) =>
|
||||
handlePayClick({ ...props, plan: Plan.STARTER })
|
||||
}
|
||||
isYearly={isYearly}
|
||||
currentPlan={workspace.plan}
|
||||
onPayClick={() => handlePayClick(Plan.STARTER)}
|
||||
isLoading={isUpdatingSubscription}
|
||||
currency={data.subscription?.currency}
|
||||
/>
|
||||
@@ -149,12 +117,8 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => {
|
||||
|
||||
{excludedPlans?.includes('PRO') ? null : (
|
||||
<ProPlanPricingCard
|
||||
workspace={workspace}
|
||||
currentSubscription={{ isYearly: data.subscription?.isYearly }}
|
||||
onPayClick={(props) =>
|
||||
handlePayClick({ ...props, plan: Plan.PRO })
|
||||
}
|
||||
isYearly={isYearly}
|
||||
currentPlan={workspace.plan}
|
||||
onPayClick={() => handlePayClick(Plan.PRO)}
|
||||
isLoading={isUpdatingSubscription}
|
||||
currency={data.subscription?.currency}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
Stack,
|
||||
ModalFooter,
|
||||
Heading,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from '@chakra-ui/react'
|
||||
import { proChatTiers } from '@typebot.io/lib/billing/constants'
|
||||
import { formatPrice } from '@typebot.io/lib/billing/formatPrice'
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const ChatsProTiersModal = ({ isOpen, onClose }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<Heading size="lg">Chats pricing table</Heading>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody as={Stack} spacing="6">
|
||||
<TableContainer>
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th isNumeric>Max chats</Th>
|
||||
<Th isNumeric>Price per month</Th>
|
||||
<Th isNumeric>Price per 1k chats</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{proChatTiers.map((tier, index) => {
|
||||
const pricePerMonth =
|
||||
proChatTiers
|
||||
.slice(0, index + 1)
|
||||
.reduce(
|
||||
(acc, slicedTier) =>
|
||||
acc + (slicedTier.flat_amount ?? 0),
|
||||
0
|
||||
) / 100
|
||||
return (
|
||||
<Tr key={tier.up_to}>
|
||||
<Td isNumeric>
|
||||
{tier.up_to === 'inf'
|
||||
? '2,000,000+'
|
||||
: tier.up_to.toLocaleString()}
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
{index === 0 ? 'included' : formatPrice(pricePerMonth)}
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
{index === proChatTiers.length - 1
|
||||
? formatPrice(4.42, { maxFractionDigits: 2 })
|
||||
: index === 0
|
||||
? 'included'
|
||||
: formatPrice(
|
||||
(((pricePerMonth * 100) /
|
||||
((tier.up_to as number) -
|
||||
(proChatTiers.at(0)?.up_to as number))) *
|
||||
1000) /
|
||||
100,
|
||||
{ maxFractionDigits: 2 }
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
)
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</ModalBody>
|
||||
<ModalFooter />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -12,8 +12,8 @@ type FeaturesListProps = { features: (string | JSX.Element)[] } & ListProps
|
||||
export const FeaturesList = ({ features, ...props }: FeaturesListProps) => (
|
||||
<UnorderedList listStyleType="none" spacing={2} {...props}>
|
||||
{features.map((feat, idx) => (
|
||||
<Flex as={ListItem} key={idx} alignItems="center">
|
||||
<ListIcon as={CheckIcon} />
|
||||
<Flex as={ListItem} key={idx}>
|
||||
<ListIcon as={CheckIcon} mt="1.5" />
|
||||
{feat}
|
||||
</Flex>
|
||||
))}
|
||||
|
||||
@@ -25,9 +25,7 @@ export type PreCheckoutModalProps = {
|
||||
| {
|
||||
plan: 'STARTER' | 'PRO'
|
||||
workspaceId: string
|
||||
additionalChats: number
|
||||
currency: 'eur' | 'usd'
|
||||
isYearly: boolean
|
||||
}
|
||||
| undefined
|
||||
existingCompany?: string
|
||||
|
||||
@@ -3,226 +3,152 @@ import {
|
||||
Heading,
|
||||
chakra,
|
||||
HStack,
|
||||
Menu,
|
||||
MenuButton,
|
||||
Button,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Text,
|
||||
Tooltip,
|
||||
Flex,
|
||||
Tag,
|
||||
useColorModeValue,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronLeftIcon } from '@/components/icons'
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isDefined, parseNumberWithCommas } from '@typebot.io/lib'
|
||||
import {
|
||||
chatsLimit,
|
||||
computePrice,
|
||||
formatPrice,
|
||||
getChatsLimit,
|
||||
} from '@typebot.io/lib/pricing'
|
||||
import { FeaturesList } from './FeaturesList'
|
||||
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
|
||||
import { useI18n, useScopedI18n } from '@/locales'
|
||||
import { Workspace } from '@typebot.io/schemas'
|
||||
import { formatPrice } from '@typebot.io/lib/billing/formatPrice'
|
||||
import { ChatsProTiersModal } from './ChatsProTiersModal'
|
||||
import { prices } from '@typebot.io/lib/billing/constants'
|
||||
|
||||
type Props = {
|
||||
workspace: Pick<
|
||||
Workspace,
|
||||
| 'additionalChatsIndex'
|
||||
| 'plan'
|
||||
| 'customChatsLimit'
|
||||
| 'customStorageLimit'
|
||||
| 'stripeId'
|
||||
>
|
||||
currentSubscription: {
|
||||
isYearly?: boolean
|
||||
}
|
||||
currentPlan: Plan
|
||||
currency?: 'usd' | 'eur'
|
||||
isLoading: boolean
|
||||
isYearly: boolean
|
||||
onPayClick: (props: { selectedChatsLimitIndex: number }) => void
|
||||
onPayClick: () => void
|
||||
}
|
||||
|
||||
export const ProPlanPricingCard = ({
|
||||
workspace,
|
||||
currentSubscription,
|
||||
currentPlan,
|
||||
currency,
|
||||
isLoading,
|
||||
isYearly,
|
||||
onPayClick,
|
||||
}: Props) => {
|
||||
const t = useI18n()
|
||||
const scopedT = useScopedI18n('billing.pricingCard')
|
||||
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
|
||||
useState<number>()
|
||||
|
||||
useEffect(() => {
|
||||
if (isDefined(selectedChatsLimitIndex)) return
|
||||
if (workspace.plan !== Plan.PRO) {
|
||||
setSelectedChatsLimitIndex(0)
|
||||
return
|
||||
}
|
||||
setSelectedChatsLimitIndex(workspace.additionalChatsIndex ?? 0)
|
||||
}, [selectedChatsLimitIndex, workspace.additionalChatsIndex, workspace.plan])
|
||||
|
||||
const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined
|
||||
|
||||
const isCurrentPlan =
|
||||
chatsLimit[Plan.PRO].graduatedPrice[selectedChatsLimitIndex ?? 0]
|
||||
.totalIncluded === workspaceChatsLimit &&
|
||||
isYearly === currentSubscription?.isYearly
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
|
||||
const getButtonLabel = () => {
|
||||
if (selectedChatsLimitIndex === undefined) return ''
|
||||
if (workspace?.plan === Plan.PRO) {
|
||||
if (isCurrentPlan) return scopedT('upgradeButton.current')
|
||||
|
||||
if (selectedChatsLimitIndex !== workspace.additionalChatsIndex)
|
||||
return t('update')
|
||||
}
|
||||
if (currentPlan === Plan.PRO) return scopedT('upgradeButton.current')
|
||||
return t('upgrade')
|
||||
}
|
||||
|
||||
const handlePayClick = async () => {
|
||||
if (selectedChatsLimitIndex === undefined) return
|
||||
onPayClick({
|
||||
selectedChatsLimitIndex,
|
||||
})
|
||||
}
|
||||
|
||||
const price =
|
||||
computePrice(
|
||||
Plan.PRO,
|
||||
selectedChatsLimitIndex ?? 0,
|
||||
isYearly ? 'yearly' : 'monthly'
|
||||
) ?? NaN
|
||||
|
||||
return (
|
||||
<Flex
|
||||
p="6"
|
||||
pos="relative"
|
||||
h="full"
|
||||
flexDir="column"
|
||||
flex="1"
|
||||
flexShrink={0}
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('blue.500', 'blue.300')}
|
||||
rounded="lg"
|
||||
>
|
||||
<Flex justifyContent="center">
|
||||
<Tag
|
||||
pos="absolute"
|
||||
top="-10px"
|
||||
colorScheme="blue"
|
||||
bg={useColorModeValue('blue.500', 'blue.400')}
|
||||
variant="solid"
|
||||
fontWeight="semibold"
|
||||
style={{ marginTop: 0 }}
|
||||
>
|
||||
{scopedT('pro.mostPopularLabel')}
|
||||
</Tag>
|
||||
</Flex>
|
||||
<Stack justifyContent="space-between" h="full">
|
||||
<Stack spacing="4" mt={2}>
|
||||
<Heading fontSize="2xl">
|
||||
{scopedT('heading', {
|
||||
plan: (
|
||||
<chakra.span color={useColorModeValue('blue.400', 'blue.300')}>
|
||||
Pro
|
||||
</chakra.span>
|
||||
),
|
||||
})}
|
||||
</Heading>
|
||||
<Text>{scopedT('pro.description')}</Text>
|
||||
</Stack>
|
||||
<Stack spacing="4">
|
||||
<Heading>
|
||||
{formatPrice(price, currency)}
|
||||
<chakra.span fontSize="md">{scopedT('perMonth')}</chakra.span>
|
||||
</Heading>
|
||||
<Text fontWeight="bold">
|
||||
<Tooltip
|
||||
label={
|
||||
<FeaturesList
|
||||
features={[
|
||||
scopedT('starter.brandingRemoved'),
|
||||
scopedT('starter.fileUploadBlock'),
|
||||
scopedT('starter.createFolders'),
|
||||
]}
|
||||
spacing="0"
|
||||
/>
|
||||
}
|
||||
hasArrow
|
||||
placement="top"
|
||||
>
|
||||
<chakra.span textDecoration="underline" cursor="pointer">
|
||||
{scopedT('pro.everythingFromStarter')}
|
||||
</chakra.span>
|
||||
</Tooltip>
|
||||
{scopedT('plus')}
|
||||
</Text>
|
||||
<FeaturesList
|
||||
features={[
|
||||
scopedT('pro.includedSeats'),
|
||||
<HStack key="test">
|
||||
<Text>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
rightIcon={<ChevronLeftIcon transform="rotate(-90deg)" />}
|
||||
size="sm"
|
||||
isLoading={selectedChatsLimitIndex === undefined}
|
||||
>
|
||||
{selectedChatsLimitIndex !== undefined
|
||||
? parseNumberWithCommas(
|
||||
chatsLimit.PRO.graduatedPrice[
|
||||
selectedChatsLimitIndex
|
||||
].totalIncluded
|
||||
)
|
||||
: undefined}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{chatsLimit.PRO.graduatedPrice.map((price, index) => (
|
||||
<MenuItem
|
||||
key={index}
|
||||
onClick={() => setSelectedChatsLimitIndex(index)}
|
||||
>
|
||||
{parseNumberWithCommas(price.totalIncluded)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>{' '}
|
||||
{scopedT('chatsPerMonth')}
|
||||
</Text>
|
||||
<MoreInfoTooltip>{scopedT('chatsTooltip')}</MoreInfoTooltip>
|
||||
</HStack>,
|
||||
scopedT('pro.whatsAppIntegration'),
|
||||
scopedT('pro.customDomains'),
|
||||
scopedT('pro.analytics'),
|
||||
]}
|
||||
/>
|
||||
<Stack spacing={3}>
|
||||
{isYearly && workspace.stripeId && !isCurrentPlan && (
|
||||
<Heading mt="0" fontSize="md">
|
||||
You pay {formatPrice(price * 12, currency)} / year
|
||||
<>
|
||||
<ChatsProTiersModal isOpen={isOpen} onClose={onClose} />{' '}
|
||||
<Flex
|
||||
p="6"
|
||||
pos="relative"
|
||||
h="full"
|
||||
flexDir="column"
|
||||
flex="1"
|
||||
flexShrink={0}
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('blue.500', 'blue.300')}
|
||||
rounded="lg"
|
||||
>
|
||||
<Flex justifyContent="center">
|
||||
<Tag
|
||||
pos="absolute"
|
||||
top="-10px"
|
||||
colorScheme="blue"
|
||||
bg={useColorModeValue('blue.500', 'blue.400')}
|
||||
variant="solid"
|
||||
fontWeight="semibold"
|
||||
style={{ marginTop: 0 }}
|
||||
>
|
||||
{scopedT('pro.mostPopularLabel')}
|
||||
</Tag>
|
||||
</Flex>
|
||||
<Stack justifyContent="space-between" h="full">
|
||||
<Stack spacing="4" mt={2}>
|
||||
<Heading fontSize="2xl">
|
||||
{scopedT('heading', {
|
||||
plan: (
|
||||
<chakra.span
|
||||
color={useColorModeValue('blue.400', 'blue.300')}
|
||||
>
|
||||
Pro
|
||||
</chakra.span>
|
||||
),
|
||||
})}
|
||||
</Heading>
|
||||
<Text>{scopedT('pro.description')}</Text>
|
||||
</Stack>
|
||||
<Stack spacing="8">
|
||||
<Stack spacing="4">
|
||||
<Heading>
|
||||
{formatPrice(prices.PRO, { currency })}
|
||||
<chakra.span fontSize="md">{scopedT('perMonth')}</chakra.span>
|
||||
</Heading>
|
||||
)}
|
||||
<Text fontWeight="bold">
|
||||
<Tooltip
|
||||
label={
|
||||
<FeaturesList
|
||||
features={[
|
||||
scopedT('starter.brandingRemoved'),
|
||||
scopedT('starter.fileUploadBlock'),
|
||||
scopedT('starter.createFolders'),
|
||||
]}
|
||||
spacing="0"
|
||||
/>
|
||||
}
|
||||
hasArrow
|
||||
placement="top"
|
||||
>
|
||||
<chakra.span textDecoration="underline" cursor="pointer">
|
||||
{scopedT('pro.everythingFromStarter')}
|
||||
</chakra.span>
|
||||
</Tooltip>
|
||||
{scopedT('plus')}
|
||||
</Text>
|
||||
<FeaturesList
|
||||
features={[
|
||||
scopedT('pro.includedSeats'),
|
||||
<Stack key="starter-chats" spacing={1}>
|
||||
<HStack key="test">
|
||||
<Text>10,000 {scopedT('chatsPerMonth')}</Text>
|
||||
<MoreInfoTooltip>
|
||||
{scopedT('chatsTooltip')}
|
||||
</MoreInfoTooltip>
|
||||
</HStack>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={useColorModeValue('gray.500', 'gray.400')}
|
||||
>
|
||||
Extra chats:{' '}
|
||||
<Button size="xs" variant="outline" onClick={onOpen}>
|
||||
See tiers
|
||||
</Button>
|
||||
</Text>
|
||||
</Stack>,
|
||||
scopedT('pro.whatsAppIntegration'),
|
||||
scopedT('pro.customDomains'),
|
||||
scopedT('pro.analytics'),
|
||||
]}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
onClick={handlePayClick}
|
||||
onClick={onPayClick}
|
||||
isLoading={isLoading}
|
||||
isDisabled={isCurrentPlan}
|
||||
isDisabled={currentPlan === Plan.PRO}
|
||||
>
|
||||
{getButtonLabel()}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,174 +3,96 @@ import {
|
||||
Heading,
|
||||
chakra,
|
||||
HStack,
|
||||
Menu,
|
||||
MenuButton,
|
||||
Button,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronLeftIcon } from '@/components/icons'
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isDefined, parseNumberWithCommas } from '@typebot.io/lib'
|
||||
import {
|
||||
chatsLimit,
|
||||
computePrice,
|
||||
formatPrice,
|
||||
getChatsLimit,
|
||||
} from '@typebot.io/lib/pricing'
|
||||
import { FeaturesList } from './FeaturesList'
|
||||
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
|
||||
import { useI18n, useScopedI18n } from '@/locales'
|
||||
import { Workspace } from '@typebot.io/schemas'
|
||||
import { formatPrice } from '@typebot.io/lib/billing/formatPrice'
|
||||
import { prices } from '@typebot.io/lib/billing/constants'
|
||||
|
||||
type Props = {
|
||||
workspace: Pick<
|
||||
Workspace,
|
||||
| 'additionalChatsIndex'
|
||||
| 'plan'
|
||||
| 'customChatsLimit'
|
||||
| 'customStorageLimit'
|
||||
| 'stripeId'
|
||||
>
|
||||
currentSubscription: {
|
||||
isYearly?: boolean
|
||||
}
|
||||
currentPlan: Plan
|
||||
currency?: 'eur' | 'usd'
|
||||
isLoading?: boolean
|
||||
isYearly: boolean
|
||||
onPayClick: (props: { selectedChatsLimitIndex: number }) => void
|
||||
onPayClick: () => void
|
||||
}
|
||||
|
||||
export const StarterPlanPricingCard = ({
|
||||
workspace,
|
||||
currentSubscription,
|
||||
currentPlan,
|
||||
isLoading,
|
||||
currency,
|
||||
isYearly,
|
||||
onPayClick,
|
||||
}: Props) => {
|
||||
const t = useI18n()
|
||||
const scopedT = useScopedI18n('billing.pricingCard')
|
||||
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
|
||||
useState<number>()
|
||||
|
||||
useEffect(() => {
|
||||
if (isDefined(selectedChatsLimitIndex)) return
|
||||
if (workspace.plan !== Plan.STARTER) {
|
||||
setSelectedChatsLimitIndex(0)
|
||||
return
|
||||
}
|
||||
setSelectedChatsLimitIndex(workspace.additionalChatsIndex ?? 0)
|
||||
}, [selectedChatsLimitIndex, workspace.additionalChatsIndex, workspace.plan])
|
||||
|
||||
const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined
|
||||
|
||||
const isCurrentPlan =
|
||||
chatsLimit[Plan.STARTER].graduatedPrice[selectedChatsLimitIndex ?? 0]
|
||||
.totalIncluded === workspaceChatsLimit &&
|
||||
isYearly === currentSubscription?.isYearly
|
||||
|
||||
const getButtonLabel = () => {
|
||||
if (selectedChatsLimitIndex === undefined) return ''
|
||||
if (workspace?.plan === Plan.PRO) return t('downgrade')
|
||||
if (workspace?.plan === Plan.STARTER) {
|
||||
if (isCurrentPlan) return scopedT('upgradeButton.current')
|
||||
|
||||
if (
|
||||
selectedChatsLimitIndex !== workspace.additionalChatsIndex ||
|
||||
isYearly !== currentSubscription?.isYearly
|
||||
)
|
||||
return t('update')
|
||||
}
|
||||
if (currentPlan === Plan.PRO) return t('downgrade')
|
||||
if (currentPlan === Plan.STARTER) return scopedT('upgradeButton.current')
|
||||
return t('upgrade')
|
||||
}
|
||||
|
||||
const handlePayClick = async () => {
|
||||
if (selectedChatsLimitIndex === undefined) return
|
||||
onPayClick({
|
||||
selectedChatsLimitIndex,
|
||||
})
|
||||
}
|
||||
|
||||
const price =
|
||||
computePrice(
|
||||
Plan.STARTER,
|
||||
selectedChatsLimitIndex ?? 0,
|
||||
isYearly ? 'yearly' : 'monthly'
|
||||
) ?? NaN
|
||||
|
||||
return (
|
||||
<Stack spacing={6} p="6" rounded="lg" borderWidth="1px" flex="1" h="full">
|
||||
<Stack
|
||||
spacing={6}
|
||||
p="6"
|
||||
rounded="lg"
|
||||
borderWidth="1px"
|
||||
flex="1"
|
||||
h="full"
|
||||
justifyContent="space-between"
|
||||
pt="8"
|
||||
>
|
||||
<Stack spacing="4">
|
||||
<Heading fontSize="2xl">
|
||||
{scopedT('heading', {
|
||||
plan: <chakra.span color="orange.400">Starter</chakra.span>,
|
||||
})}
|
||||
</Heading>
|
||||
<Text>{scopedT('starter.description')}</Text>
|
||||
<Heading>
|
||||
{formatPrice(price, currency)}
|
||||
<chakra.span fontSize="md">{scopedT('perMonth')}</chakra.span>
|
||||
</Heading>
|
||||
<Stack>
|
||||
<Stack spacing="4">
|
||||
<Heading fontSize="2xl">
|
||||
{scopedT('heading', {
|
||||
plan: <chakra.span color="orange.400">Starter</chakra.span>,
|
||||
})}
|
||||
</Heading>
|
||||
<Text>{scopedT('starter.description')}</Text>
|
||||
</Stack>
|
||||
<Heading>
|
||||
{formatPrice(prices.STARTER, { currency })}
|
||||
<chakra.span fontSize="md">{scopedT('perMonth')}</chakra.span>
|
||||
</Heading>
|
||||
</Stack>
|
||||
|
||||
<FeaturesList
|
||||
features={[
|
||||
scopedT('starter.includedSeats'),
|
||||
<HStack key="test">
|
||||
<Text>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
rightIcon={<ChevronLeftIcon transform="rotate(-90deg)" />}
|
||||
size="sm"
|
||||
isLoading={selectedChatsLimitIndex === undefined}
|
||||
>
|
||||
{selectedChatsLimitIndex !== undefined
|
||||
? parseNumberWithCommas(
|
||||
chatsLimit.STARTER.graduatedPrice[
|
||||
selectedChatsLimitIndex
|
||||
].totalIncluded
|
||||
)
|
||||
: undefined}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{chatsLimit.STARTER.graduatedPrice.map((price, index) => (
|
||||
<MenuItem
|
||||
key={index}
|
||||
onClick={() => setSelectedChatsLimitIndex(index)}
|
||||
>
|
||||
{parseNumberWithCommas(price.totalIncluded)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>{' '}
|
||||
{scopedT('chatsPerMonth')}
|
||||
<Stack key="starter-chats" spacing={0}>
|
||||
<HStack>
|
||||
<Text>2,000 {scopedT('chatsPerMonth')}</Text>
|
||||
<MoreInfoTooltip>{scopedT('chatsTooltip')}</MoreInfoTooltip>
|
||||
</HStack>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={useColorModeValue('gray.500', 'gray.400')}
|
||||
>
|
||||
Extra chats: $10 per 500
|
||||
</Text>
|
||||
<MoreInfoTooltip>{scopedT('chatsTooltip')}</MoreInfoTooltip>
|
||||
</HStack>,
|
||||
</Stack>,
|
||||
scopedT('starter.brandingRemoved'),
|
||||
scopedT('starter.fileUploadBlock'),
|
||||
scopedT('starter.createFolders'),
|
||||
]}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack>
|
||||
{isYearly && workspace.stripeId && !isCurrentPlan && (
|
||||
<Heading mt="0" fontSize="md">
|
||||
You pay: {formatPrice(price * 12, currency)} / year
|
||||
</Heading>
|
||||
)}
|
||||
<Button
|
||||
colorScheme="orange"
|
||||
variant="outline"
|
||||
onClick={handlePayClick}
|
||||
isLoading={isLoading}
|
||||
isDisabled={isCurrentPlan}
|
||||
>
|
||||
{getButtonLabel()}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Button
|
||||
colorScheme="orange"
|
||||
variant="outline"
|
||||
onClick={onPayClick}
|
||||
isLoading={isLoading}
|
||||
isDisabled={currentPlan === Plan.STARTER}
|
||||
>
|
||||
{getButtonLabel()}
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ import { AlertIcon } from '@/components/icons'
|
||||
import { Workspace } from '@typebot.io/prisma'
|
||||
import React from 'react'
|
||||
import { parseNumberWithCommas } from '@typebot.io/lib'
|
||||
import { getChatsLimit } from '@typebot.io/lib/pricing'
|
||||
import { defaultQueryOptions, trpc } from '@/lib/trpc'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
import { getChatsLimit } from '@typebot.io/lib/billing/getChatsLimit'
|
||||
|
||||
type Props = {
|
||||
workspace: Workspace
|
||||
@@ -65,7 +65,7 @@ export const UsageProgressBars = ({ workspace }: Props) => {
|
||||
</Tooltip>
|
||||
)}
|
||||
<Text fontSize="sm" fontStyle="italic" color="gray.500">
|
||||
{scopedT('chats.resetInfo')}
|
||||
(Resets on {data?.resetsAt.toLocaleDateString()})
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
@@ -90,9 +90,8 @@ export const UsageProgressBars = ({ workspace }: Props) => {
|
||||
h="5px"
|
||||
value={chatsPercentage}
|
||||
rounded="full"
|
||||
hasStripe
|
||||
isIndeterminate={isLoading}
|
||||
colorScheme={totalChatsUsed >= workspaceChatsLimit ? 'red' : 'blue'}
|
||||
colorScheme={'blue'}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
Reference in New Issue
Block a user