2
0

(lp) Add new pricing page

This commit is contained in:
Baptiste Arnaud
2022-09-18 19:01:37 +02:00
committed by Baptiste Arnaud
parent d8b1d8ad59
commit c94a6581be
18 changed files with 346 additions and 255 deletions

View File

@ -13,21 +13,23 @@ export const BillingContent = () => {
if (!workspace) return null if (!workspace) return null
return ( return (
<Stack spacing="10" w="full"> <Stack spacing="10" w="full">
<CurrentSubscriptionContent
plan={workspace.plan}
stripeId={workspace.stripeId}
onCancelSuccess={() =>
refreshWorkspace({
plan: Plan.FREE,
additionalChatsIndex: 0,
additionalStorageIndex: 0,
})
}
/>
<UsageContent workspace={workspace} /> <UsageContent workspace={workspace} />
{workspace.plan !== Plan.LIFETIME && workspace.plan !== Plan.OFFERED && ( <Stack gap="2">
<ChangePlanForm /> <CurrentSubscriptionContent
)} plan={workspace.plan}
stripeId={workspace.stripeId}
onCancelSuccess={() =>
refreshWorkspace({
plan: Plan.FREE,
additionalChatsIndex: 0,
additionalStorageIndex: 0,
})
}
/>
{workspace.plan !== Plan.LIFETIME &&
workspace.plan !== Plan.OFFERED && <ChangePlanForm />}
</Stack>
{workspace.stripeId && <InvoicesList workspace={workspace} />} {workspace.stripeId && <InvoicesList workspace={workspace} />}
</Stack> </Stack>
) )

View File

@ -4,8 +4,8 @@ import {
Link, Link,
Spinner, Spinner,
Stack, Stack,
Flex,
Button, Button,
Heading,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { PlanTag } from 'components/shared/PlanTag' import { PlanTag } from 'components/shared/PlanTag'
import { Plan } from 'db' import { Plan } from 'db'
@ -35,15 +35,29 @@ export const CurrentSubscriptionContent = ({
setIsCancelling(false) setIsCancelling(false)
} }
const isSubscribed = (plan === Plan.STARTER || plan === Plan.PRO) && stripeId
if (isCancelling) return <Spinner colorScheme="gray" /> if (isCancelling) return <Spinner colorScheme="gray" />
return ( return (
<Stack gap="2"> <Stack gap="2">
<Heading fontSize="3xl">Subscription</Heading>
<HStack> <HStack>
<Text>Current workspace subscription: </Text> <Text>Current workspace subscription: </Text>
<PlanTag plan={plan} /> <PlanTag plan={plan} />
{isSubscribed && (
<Link
as="button"
color="gray.500"
textDecor="underline"
fontSize="sm"
onClick={cancelSubscription}
>
Cancel my subscription
</Link>
)}
</HStack> </HStack>
{(plan === Plan.STARTER || plan === Plan.PRO) && stripeId && ( {isSubscribed && (
<> <>
<Stack gap="1"> <Stack gap="1">
<Text fontSize="sm"> <Text fontSize="sm">
@ -59,17 +73,6 @@ export const CurrentSubscriptionContent = ({
Billing Portal Billing Portal
</Button> </Button>
</Stack> </Stack>
<Flex>
<Link
as="button"
color="gray.500"
textDecor="underline"
fontSize="sm"
onClick={cancelSubscription}
>
Cancel my subscription
</Link>
</Flex>
</> </>
)} )}
</Stack> </Stack>

View File

@ -60,16 +60,7 @@ export const ChangePlanForm = () => {
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
<HStack <HStack alignItems="stretch" spacing="4" w="full">
alignItems="stretch"
spacing="4"
w="full"
pt={
workspace?.plan === Plan.STARTER || workspace?.plan === Plan.PRO
? '10'
: '0'
}
>
<StarterPlanContent <StarterPlanContent
initialChatsLimitIndex={ initialChatsLimitIndex={
workspace?.plan === Plan.STARTER ? data?.additionalChatsIndex : 0 workspace?.plan === Plan.STARTER ? data?.additionalChatsIndex : 0

View File

@ -23,10 +23,11 @@ import {
getStorageLimit, getStorageLimit,
storageLimit, storageLimit,
parseNumberWithCommas, parseNumberWithCommas,
formatPrice,
computePrice,
} from 'utils' } from 'utils'
import { MoreInfoTooltip } from '../MoreInfoTooltip' import { MoreInfoTooltip } from '../MoreInfoTooltip'
import { FeaturesList } from './components/FeaturesList' import { FeaturesList } from './components/FeaturesList'
import { computePrice, formatPrice } from './helpers'
type ProPlanContentProps = { type ProPlanContentProps = {
initialChatsLimitIndex?: number initialChatsLimitIndex?: number
@ -72,8 +73,6 @@ export const ProPlanContent = ({
? getStorageLimit(workspace) ? getStorageLimit(workspace)
: undefined : undefined
console.log('workspaceChatsLimit', workspaceChatsLimit)
console.log('workspaceStorageLimit', workspace)
const isCurrentPlan = const isCurrentPlan =
chatsLimit[Plan.PRO].totalIncluded + chatsLimit[Plan.PRO].totalIncluded +
chatsLimit[Plan.PRO].increaseStep.amount * chatsLimit[Plan.PRO].increaseStep.amount *

View File

@ -20,10 +20,11 @@ import {
getStorageLimit, getStorageLimit,
storageLimit, storageLimit,
parseNumberWithCommas, parseNumberWithCommas,
computePrice,
formatPrice,
} from 'utils' } from 'utils'
import { MoreInfoTooltip } from '../MoreInfoTooltip' import { MoreInfoTooltip } from '../MoreInfoTooltip'
import { FeaturesList } from './components/FeaturesList' import { FeaturesList } from './components/FeaturesList'
import { computePrice, formatPrice } from './helpers'
type StarterPlanContentProps = { type StarterPlanContentProps = {
initialChatsLimitIndex?: number initialChatsLimitIndex?: number

View File

@ -1,86 +0,0 @@
import { Plan } from 'db'
import { chatsLimit, prices, storageLimit } from 'utils'
export const computePrice = (
plan: Plan,
selectedTotalChatsIndex: number,
selectedTotalStorageIndex: number
) => {
if (plan !== Plan.STARTER && plan !== Plan.PRO) return
const {
increaseStep: { price: chatsPrice },
} = chatsLimit[plan]
const {
increaseStep: { price: storagePrice },
} = storageLimit[plan]
return (
prices[plan] +
selectedTotalChatsIndex * chatsPrice +
selectedTotalStorageIndex * storagePrice
)
}
const europeanUnionCountryCodes = [
'AT',
'BE',
'BG',
'CY',
'CZ',
'DE',
'DK',
'EE',
'ES',
'FI',
'FR',
'GR',
'HR',
'HU',
'IE',
'IT',
'LT',
'LU',
'LV',
'MT',
'NL',
'PL',
'PT',
'RO',
'SE',
'SI',
'SK',
]
const europeanUnionExclusiveLanguageCodes = [
'fr',
'de',
'it',
'el',
'pl',
'fi',
'nl',
'hr',
'cs',
'hu',
'ro',
'sl',
'sv',
'bg',
]
export const guessIfUserIsEuropean = () =>
navigator.languages.some((language) => {
const [languageCode, countryCode] = language.split('-')
return countryCode
? europeanUnionCountryCodes.includes(countryCode)
: europeanUnionExclusiveLanguageCodes.includes(languageCode)
})
export const formatPrice = (price: number) => {
const isEuropean = guessIfUserIsEuropean()
const formatter = new Intl.NumberFormat(isEuropean ? 'fr-FR' : 'en-US', {
style: 'currency',
currency: isEuropean ? 'EUR' : 'USD',
maximumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501)
})
return formatter.format(price)
}

View File

@ -1,7 +1,12 @@
import { loadStripe } from '@stripe/stripe-js/pure' import { loadStripe } from '@stripe/stripe-js/pure'
import { Plan, User } from 'db' import { Plan, User } from 'db'
import { env, isDefined, isEmpty, sendRequest } from 'utils' import {
import { guessIfUserIsEuropean } from '../helpers' env,
guessIfUserIsEuropean,
isDefined,
isEmpty,
sendRequest,
} from 'utils'
type UpgradeProps = { type UpgradeProps = {
user: User user: User

View File

@ -155,7 +155,6 @@ const updateSubscription = async (req: NextApiRequest) => {
} }
: undefined, : undefined,
].filter(isDefined) ].filter(isDefined)
console.log(items)
await stripe.subscriptions.update(subscription.id, { await stripe.subscriptions.update(subscription.id, {
items, items,
}) })
@ -171,7 +170,6 @@ const updateSubscription = async (req: NextApiRequest) => {
const cancelSubscription = const cancelSubscription =
(req: NextApiRequest, res: NextApiResponse) => async (userId: string) => { (req: NextApiRequest, res: NextApiResponse) => async (userId: string) => {
console.log(req.query.stripeId, userId)
const stripeId = req.query.stripeId as string | undefined const stripeId = req.query.stripeId as string | undefined
if (!stripeId) return badRequest(res) if (!stripeId) return badRequest(res)
if (!process.env.STRIPE_SECRET_KEY) if (!process.env.STRIPE_SECRET_KEY)
@ -189,9 +187,7 @@ const cancelSubscription =
const existingSubscription = await stripe.subscriptions.list({ const existingSubscription = await stripe.subscriptions.list({
customer: workspace.stripeId, customer: workspace.stripeId,
}) })
console.log('yes')
await stripe.subscriptions.del(existingSubscription.data[0].id) await stripe.subscriptions.del(existingSubscription.data[0].id)
console.log('deleted')
await prisma.workspace.update({ await prisma.workspace.update({
where: { id: workspace.id }, where: { id: workspace.id },
data: { data: {

View File

@ -18,12 +18,12 @@ const DashboardPage = () => {
const { workspace } = useWorkspace() const { workspace } = useWorkspace()
useEffect(() => { useEffect(() => {
const subscribePlan = query.subscribePlan as 'pro' | 'starter' | undefined const subscribePlan = query.subscribePlan as Plan | undefined
if (workspace && subscribePlan && user && workspace.plan === 'FREE') { if (workspace && subscribePlan && user && workspace.plan === 'FREE') {
setIsLoading(true) setIsLoading(true)
pay({ pay({
user, user,
plan: subscribePlan === 'pro' ? Plan.PRO : Plan.STARTER, plan: subscribePlan,
workspaceId: workspace.id, workspaceId: workspace.id,
additionalChats: 0, additionalChats: 0,
additionalStorage: 0, additionalStorage: 0,

View File

@ -34,8 +34,6 @@ const ResultsPage = () => {
}) })
const { data: usageData } = useUsage(workspace?.id) const { data: usageData } = useUsage(workspace?.id)
console.log(workspace?.id, usageData)
const chatsLimitPercentage = useMemo(() => { const chatsLimitPercentage = useMemo(() => {
if (!usageData?.totalChatsUsed || !workspace?.plan) return 0 if (!usageData?.totalChatsUsed || !workspace?.plan) return 0
return Math.round( return Math.round(
@ -53,7 +51,6 @@ const ResultsPage = () => {
]) ])
const storageLimitPercentage = useMemo(() => { const storageLimitPercentage = useMemo(() => {
console.log(usageData?.totalStorageUsed)
if (!usageData?.totalStorageUsed || !workspace?.plan) return 0 if (!usageData?.totalStorageUsed || !workspace?.plan) return 0
return Math.round( return Math.round(
(usageData.totalStorageUsed / (usageData.totalStorageUsed /

View File

@ -18,16 +18,19 @@ import {
import { CheckIcon } from 'assets/icons/CheckIcon' import { CheckIcon } from 'assets/icons/CheckIcon'
import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon' import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon'
import { NextChakraLink } from 'components/common/nextChakraAdapters/NextChakraLink' import { NextChakraLink } from 'components/common/nextChakraAdapters/NextChakraLink'
import { Plan } from 'db'
import React from 'react' import React from 'react'
type Props = { type Props = {
prices: { starterPrice: string
personalPro: '$39' | '39€' | '' proPrice: string
team: '$99' | '99€' | ''
}
} & StackProps } & StackProps
export const PlanComparisonTables = ({ prices, ...props }: Props) => { export const PlanComparisonTables = ({
starterPrice,
proPrice,
...props
}: Props) => {
return ( return (
<Stack spacing="12" {...props}> <Stack spacing="12" {...props}>
<TableContainer> <TableContainer>
@ -37,29 +40,47 @@ export const PlanComparisonTables = ({ prices, ...props }: Props) => {
<Th fontWeight="bold" color="white" w="400px"> <Th fontWeight="bold" color="white" w="400px">
Usage Usage
</Th> </Th>
<Th>Personal</Th> <Th>Free</Th>
<Th color="orange.200">Personal Pro</Th> <Th color="orange.200">Starter</Th>
<Th color="purple.200">Team</Th> <Th color="purple.200">Pro</Th>
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
<Tr> <Tr>
<Td>Forms</Td> <Td>Total bots</Td>
<Td>Unlimited</Td> <Td>Unlimited</Td>
<Td>Unlimited</Td> <Td>Unlimited</Td>
<Td>Unlimited</Td> <Td>Unlimited</Td>
</Tr> </Tr>
<Tr> <Tr>
<Td>Form submissions</Td> <Td>Chats</Td>
<Td>Unlimited</Td> <Td>300 / month</Td>
<Td>Unlimited</Td> <Td>2,000 / month</Td>
<Td>Unlimited</Td> <Td>10,000 / month</Td>
</Tr>
<Tr>
<Td>Additional Chats</Td>
<Td />
<Td>$10 per 500</Td>
<Td>$10 per 1,000</Td>
</Tr>
<Tr>
<Td>Storage</Td>
<Td />
<Td>2 GB</Td>
<Td>10 GB</Td>
</Tr>
<Tr>
<Td>Additional Storage</Td>
<Td />
<Td>$5 per 1 GB</Td>
<Td>$5 per 1 GB</Td>
</Tr> </Tr>
<Tr> <Tr>
<Td>Members</Td> <Td>Members</Td>
<Td>Just you</Td> <Td>Just you</Td>
<Td>Just you</Td> <Td>2 seats</Td>
<Td>Unlimited</Td> <Td>5 seats</Td>
</Tr> </Tr>
<Tr> <Tr>
<Td>Guests</Td> <Td>Guests</Td>
@ -67,12 +88,6 @@ export const PlanComparisonTables = ({ prices, ...props }: Props) => {
<Td>Unlimited</Td> <Td>Unlimited</Td>
<Td>Unlimited</Td> <Td>Unlimited</Td>
</Tr> </Tr>
<Tr>
<Td>File uploads</Td>
<Td>5 MB</Td>
<Td>Unlimited</Td>
<Td>Unlimited</Td>
</Tr>
</Tbody> </Tbody>
</Table> </Table>
</TableContainer> </TableContainer>
@ -83,9 +98,9 @@ export const PlanComparisonTables = ({ prices, ...props }: Props) => {
<Th fontWeight="bold" color="white" w="400px"> <Th fontWeight="bold" color="white" w="400px">
Features Features
</Th> </Th>
<Th>Personal</Th> <Th>Free</Th>
<Th color="orange.200">Personal Pro</Th> <Th color="orange.200">Starter</Th>
<Th color="purple.200">Team</Th> <Th color="purple.200">Pro</Th>
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
@ -234,12 +249,6 @@ export const PlanComparisonTables = ({ prices, ...props }: Props) => {
<CheckIcon /> <CheckIcon />
</Td> </Td>
</Tr> </Tr>
<Tr>
<Td>Custom domains</Td>
<Td />
<Td>Unlimited</Td>
<Td>Unlimited</Td>
</Tr>
<Tr> <Tr>
<TdWithTooltip <TdWithTooltip
text="Folders" text="Folders"
@ -260,17 +269,10 @@ export const PlanComparisonTables = ({ prices, ...props }: Props) => {
</Td> </Td>
</Tr> </Tr>
<Tr> <Tr>
<TdWithTooltip <Td>Custom domains</Td>
text="Incomplete submissions"
tooltip="You get to see the form submission even if it was not fully completed by your user."
/>
<Td /> <Td />
<Td> <Td />
<CheckIcon /> <Td>Unlimited</Td>
</Td>
<Td>
<CheckIcon />
</Td>
</Tr> </Tr>
<Tr> <Tr>
<TdWithTooltip <TdWithTooltip
@ -278,9 +280,7 @@ export const PlanComparisonTables = ({ prices, ...props }: Props) => {
tooltip="Analytics graph that shows your form drop-off rate, submission rate, and more." tooltip="Analytics graph that shows your form drop-off rate, submission rate, and more."
/> />
<Td /> <Td />
<Td> <Td />
<CheckIcon />
</Td>
<Td> <Td>
<CheckIcon /> <CheckIcon />
</Td> </Td>
@ -295,18 +295,16 @@ export const PlanComparisonTables = ({ prices, ...props }: Props) => {
<Th fontWeight="bold" color="white" w="400px"> <Th fontWeight="bold" color="white" w="400px">
Support Support
</Th> </Th>
<Th>Personal</Th> <Th>Free</Th>
<Th color="orange.200">Personal Pro</Th> <Th color="orange.200">Starter</Th>
<Th color="purple.200">Team</Th> <Th color="blue.200">Pro</Th>
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
<Tr> <Tr>
<Td>Priority support</Td> <Td>Priority support</Td>
<Td /> <Td />
<Td> <Td />
<CheckIcon />
</Td>
<Td> <Td>
<CheckIcon /> <CheckIcon />
</Td> </Td>
@ -314,9 +312,7 @@ export const PlanComparisonTables = ({ prices, ...props }: Props) => {
<Tr> <Tr>
<Td>Feature request priority</Td> <Td>Feature request priority</Td>
<Td /> <Td />
<Td> <Td />
<CheckIcon />
</Td>
<Td> <Td>
<CheckIcon /> <CheckIcon />
</Td> </Td>
@ -344,28 +340,27 @@ export const PlanComparisonTables = ({ prices, ...props }: Props) => {
</Stack> </Stack>
<Stack spacing={4}> <Stack spacing={4}>
<Heading as="h3" size="md" color="orange.200"> <Heading as="h3" size="md" color="orange.200">
Personal Pro Starter
</Heading> </Heading>
<Heading as="h3"> <Heading as="h3">
{prices.personalPro}{' '} {starterPrice} <chakra.span fontSize="lg">/ month</chakra.span>
<chakra.span fontSize="lg">/ month</chakra.span>
</Heading> </Heading>
<NextChakraLink <NextChakraLink
href="https://app.typebot.io/register?subscribePlan=pro" href={`https://app.typebot.io/register?subscribePlan=${Plan.STARTER}`}
_hover={{ textDecor: 'none' }} _hover={{ textDecor: 'none' }}
> >
<Button>Subscribe</Button> <Button>Subscribe</Button>
</NextChakraLink> </NextChakraLink>
</Stack> </Stack>
<Stack spacing={4}> <Stack spacing={4}>
<Heading as="h3" size="md" color="purple.200"> <Heading as="h3" size="md" color="blue.200">
Team Pro
</Heading> </Heading>
<Heading as="h3"> <Heading as="h3">
{prices.team} <chakra.span fontSize="lg">/ month</chakra.span> {proPrice} <chakra.span fontSize="lg">/ month</chakra.span>
</Heading> </Heading>
<NextChakraLink <NextChakraLink
href="https://app.typebot.io/register?subscribePlan=team" href={`https://app.typebot.io/register?subscribePlan=${Plan.PRO}`}
_hover={{ textDecor: 'none' }} _hover={{ textDecor: 'none' }}
> >
<Button>Subscribe</Button> <Button>Subscribe</Button>

View File

@ -13,7 +13,7 @@ import { CheckCircleIcon } from '../../../assets/icons/CheckCircleIcon'
import { Card, CardProps } from './Card' import { Card, CardProps } from './Card'
export interface PricingCardData { export interface PricingCardData {
features: string[] features: React.ReactNode[]
name: string name: string
price: string price: string
featureLabel?: string featureLabel?: string
@ -23,10 +23,16 @@ interface PricingCardProps extends CardProps {
data: PricingCardData data: PricingCardData
icon?: JSX.Element icon?: JSX.Element
button: React.ReactElement button: React.ReactElement
isMostPopular?: boolean
} }
export const PricingCard = (props: PricingCardProps) => { export const PricingCard = ({
const { data, icon, button, ...rest } = props data,
icon,
button,
isMostPopular,
...rest
}: PricingCardProps) => {
const { features, price, name } = data const { features, price, name } = data
const accentColor = useColorModeValue('blue.500', 'white') const accentColor = useColorModeValue('blue.500', 'white')
@ -62,7 +68,12 @@ export const PricingCard = (props: PricingCardProps) => {
<Text fontWeight="bold">{data.featureLabel}</Text> <Text fontWeight="bold">{data.featureLabel}</Text>
)} )}
{features.map((feature, index) => ( {features.map((feature, index) => (
<ListItem fontWeight="medium" key={index}> <ListItem
fontWeight="medium"
key={index}
display="flex"
alignItems="center"
>
<ListIcon <ListIcon
fontSize="xl" fontSize="xl"
as={CheckCircleIcon} as={CheckCircleIcon}

View File

@ -20,7 +20,8 @@
"next": "12.3.0", "next": "12.3.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"utils": "workspace:*" "utils": "workspace:*",
"db": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.19.0", "@babel/core": "7.19.0",

View File

@ -10,7 +10,11 @@ import {
Box, Box,
Heading, Heading,
VStack, VStack,
Text,
chakra,
Tooltip,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon'
import { Footer } from 'components/common/Footer' import { Footer } from 'components/common/Footer'
import { Header } from 'components/common/Header/Header' import { Header } from 'components/common/Header/Header'
import { NextChakraLink } from 'components/common/nextChakraAdapters/NextChakraLink' import { NextChakraLink } from 'components/common/nextChakraAdapters/NextChakraLink'
@ -20,22 +24,17 @@ import { PlanComparisonTables } from 'components/PricingPage/PlanComparisonTable
import { PricingCard } from 'components/PricingPage/PricingCard' import { PricingCard } from 'components/PricingPage/PricingCard'
import { ActionButton } from 'components/PricingPage/PricingCard/ActionButton' import { ActionButton } from 'components/PricingPage/PricingCard/ActionButton'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { formatPrice, prices } from 'utils'
import { Plan } from 'db'
const Pricing = () => { const Pricing = () => {
const [price, setPrice] = useState<{ const [starterPrice, setStarterPrice] = useState('$39')
personalPro: '$39' | '39€' | '' const [proPrice, setProPrice] = useState('$89')
team: '$99' | '99€' | ''
}>({
personalPro: '',
team: '',
})
useEffect(() => { useEffect(() => {
setPrice( if (typeof window === 'undefined') return
navigator.languages.find((l) => l.includes('fr')) setStarterPrice(formatPrice(prices.STARTER))
? { personalPro: '39€', team: '99€' } setProPrice(formatPrice(prices.PRO))
: { personalPro: '$39', team: '$99' }
)
}, []) }, [])
return ( return (
@ -54,13 +53,28 @@ const Pricing = () => {
<Header /> <Header />
</DarkMode> </DarkMode>
<VStack spacing={40} w="full"> <VStack spacing={'24'} mt={[20, 32]} w="full">
<Stack align="center" spacing="6">
<Heading fontSize="6xl">Plans fit for you</Heading>
<Text maxW="900px" fontSize="xl" textAlign="center">
Whether you're a{' '}
<Text as="span" color="orange.200" fontWeight="bold">
solo business owner
</Text>{' '}
or a{' '}
<Text as="span" color="blue.200" fontWeight="bold">
growing startup
</Text>
, Typebot is here to help you build high-performing bots for the
right price. Pay for as little or as much usage as you need.
</Text>
</Stack>
<Stack <Stack
direction={['column', 'row']} direction={['column', 'row']}
alignItems={['stretch']} alignItems={['stretch']}
spacing={10} spacing={10}
px={[4, 0]} px={[4, 0]}
mt={[20, 32]}
w="full" w="full"
maxW="1200px" maxW="1200px"
> >
@ -70,7 +84,7 @@ const Pricing = () => {
name: 'Personal', name: 'Personal',
features: [ features: [
'Unlimited typebots', 'Unlimited typebots',
'Unlimited responses', '300 chats included',
'Native integrations', 'Native integrations',
'Webhooks', 'Webhooks',
'Custom Javascript & CSS', 'Custom Javascript & CSS',
@ -87,58 +101,134 @@ const Pricing = () => {
/> />
<PricingCard <PricingCard
data={{ data={{
price: price.personalPro, price: starterPrice,
name: 'Personal Pro', name: 'Starter',
featureLabel: 'Everything in Personal, plus:', featureLabel: 'Everything in Personal, plus:',
features: [ features: [
<Text key="seats">
<chakra.span fontWeight="bold">2 seats</chakra.span>{' '}
included
</Text>,
<>
<Text>
<chakra.span fontWeight="bold">2,000 chats</chakra.span>{' '}
included
</Text>
&nbsp;
<Tooltip
hasArrow
placement="top"
label="A chat is counted whenever a user starts a discussion. It is
independant of the number of messages he sends and receives."
>
<chakra.span cursor="pointer" h="7">
<HelpCircleIcon />
</chakra.span>
</Tooltip>
</>,
<>
<Text>
<chakra.span fontWeight="bold">2 GB chats</chakra.span>{' '}
included
</Text>
&nbsp;
<Tooltip
hasArrow
placement="top"
label="You accumulate storage for every file that your user upload
into your bot. If you delete the result, it will free up the
space."
>
<chakra.span cursor="pointer" h="7">
<HelpCircleIcon />
</chakra.span>
</Tooltip>
</>,
'Branding removed', 'Branding removed',
'View incomplete submissions', 'Collect files from users',
'In-depth drop off analytics', 'Create folders',
'Custom domains',
'Organize typebots in folders',
'File upload input',
], ],
}} }}
borderWidth="3px" borderWidth="1px"
borderColor="orange.200" borderColor="orange.200"
button={ button={
<NextChakraLink <NextChakraLink
href="https://app.typebot.io/register?subscribePlan=pro" href={`https://app.typebot.io/register?subscribePlan=${Plan.STARTER}`}
_hover={{ textDecor: 'none' }}
>
<ActionButton colorScheme="orange">
Subscribe now
</ActionButton>
</NextChakraLink>
}
/>
<PricingCard
data={{
price: price.team,
name: 'Team',
featureLabel: 'Everything in Pro, plus:',
features: [
'Unlimited team members',
'Collaborative workspace',
'Custom roles',
],
}}
borderWidth="3px"
borderColor="purple.200"
button={
<NextChakraLink
href="https://app.typebot.io/register?subscribePlan=team"
_hover={{ textDecor: 'none' }} _hover={{ textDecor: 'none' }}
> >
<ActionButton>Subscribe now</ActionButton> <ActionButton>Subscribe now</ActionButton>
</NextChakraLink> </NextChakraLink>
} }
/> />
<PricingCard
data={{
price: proPrice,
name: 'Pro',
featureLabel: 'Everything in Starter, plus:',
features: [
<Text key="seats">
<chakra.span fontWeight="bold">5 seats</chakra.span>{' '}
included
</Text>,
<>
<Text>
<chakra.span fontWeight="bold">10,000 chats</chakra.span>{' '}
included
</Text>
&nbsp;
<Tooltip
hasArrow
placement="top"
label="A chat is counted whenever a user starts a discussion. It is
independant of the number of messages he sends and receives."
>
<chakra.span cursor="pointer" h="7">
<HelpCircleIcon />
</chakra.span>
</Tooltip>
</>,
<>
<Text>
<chakra.span fontWeight="bold">10 GB chats</chakra.span>{' '}
included
</Text>
&nbsp;
<Tooltip
hasArrow
placement="top"
label="You accumulate storage for every file that your user upload
into your bot. If you delete the result, it will free up the
space."
>
<chakra.span cursor="pointer" h="7">
<HelpCircleIcon />
</chakra.span>
</Tooltip>
</>,
'Custom domains',
'In-depth analytics',
],
}}
borderWidth="3px"
borderColor="blue.200"
button={
<NextChakraLink
href={`https://app.typebot.io/register?subscribePlan=${Plan.PRO}`}
_hover={{ textDecor: 'none' }}
>
<ActionButton>Subscribe now</ActionButton>
</NextChakraLink>
}
isMostPopular
/>
</Stack> </Stack>
<VStack maxW="1200px" w="full" spacing={[12, 20]} px="4"> <VStack maxW="1200px" w="full" spacing={[12, 20]} px="4">
<Stack w="full" spacing={10} display={['none', 'flex']}> <Stack w="full" spacing={10} display={['none', 'flex']}>
<Heading>Compare plans & features</Heading> <Heading>Compare plans & features</Heading>
<PlanComparisonTables prices={price} /> <PlanComparisonTables
starterPrice={starterPrice}
proPrice={proPrice}
/>
</Stack> </Stack>
<VStack w="full" spacing="10"> <VStack w="full" spacing="10">
<Heading textAlign="center">Frequently asked questions</Heading> <Heading textAlign="center">Frequently asked questions</Heading>

View File

@ -10,9 +10,8 @@
"scripts": { "scripts": {
"docker:up": "docker compose -f docker-compose.dev.yml up -d", "docker:up": "docker compose -f docker-compose.dev.yml up -d",
"docker:nuke": "docker compose -f docker-compose.dev.yml down --volumes --remove-orphans", "docker:nuke": "docker compose -f docker-compose.dev.yml down --volumes --remove-orphans",
"dev:prepare": "turbo run build --scope=bot-engine --no-deps --include-dependencies && turbo run build --scope=typebot-js --no-deps", "dev": "pnpm docker:up && NEXT_PUBLIC_E2E_TEST=false turbo run dev --filter=builder... --filter=viewer... --parallel --no-cache",
"dev": "pnpm docker:up && pnpm dev:prepare && NEXT_PUBLIC_E2E_TEST=false turbo run dev --filter=builder --filter=viewer --parallel --no-cache", "dev:mocking": "pnpm docker:up && NEXT_PUBLIC_E2E_TEST=true turbo run dev --filter=builder... --filter=viewer... --parallel --no-cache",
"dev:mocking": "pnpm docker:up && pnpm dev:prepare && NEXT_PUBLIC_E2E_TEST=true turbo run dev --filter=builder --filter=viewer --parallel --no-cache",
"build": "pnpm docker:up && turbo run build", "build": "pnpm docker:up && turbo run build",
"build:builder": "turbo run build --filter=builder... && ENVSH_ENV=./apps/builder/.env.docker ENVSH_OUTPUT=./apps/builder/public/__env.js bash env.sh", "build:builder": "turbo run build --filter=builder... && ENVSH_ENV=./apps/builder/.env.docker ENVSH_OUTPUT=./apps/builder/public/__env.js bash env.sh",
"build:viewer": "turbo run build --filter=viewer... && ENVSH_ENV=./apps/viewer/.env.docker ENVSH_OUTPUT=./apps/viewer/public/__env.js bash env.sh", "build:viewer": "turbo run build --filter=viewer... && ENVSH_ENV=./apps/viewer/.env.docker ENVSH_OUTPUT=./apps/viewer/public/__env.js bash env.sh",

View File

@ -5,6 +5,7 @@
"unpkg": "dist/index.umd.min.js", "unpkg": "dist/index.umd.min.js",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"scripts": { "scripts": {
"dev": "pnpm rollup -c --watch",
"build": "pnpm lint && rollup -c", "build": "pnpm lint && rollup -c",
"lint": "eslint src --ext .ts && eslint tests --ext .ts", "lint": "eslint src --ext .ts && eslint tests --ext .ts",
"test": "pnpm jest" "test": "pnpm jest"

View File

@ -83,3 +83,87 @@ export const getStorageLimit = ({
: { amount: 0 } : { amount: 0 }
return totalIncluded + increaseStep.amount * additionalStorageIndex return totalIncluded + increaseStep.amount * additionalStorageIndex
} }
export const computePrice = (
plan: Plan,
selectedTotalChatsIndex: number,
selectedTotalStorageIndex: number
) => {
if (plan !== Plan.STARTER && plan !== Plan.PRO) return
const {
increaseStep: { price: chatsPrice },
} = chatsLimit[plan]
const {
increaseStep: { price: storagePrice },
} = storageLimit[plan]
return (
prices[plan] +
selectedTotalChatsIndex * chatsPrice +
selectedTotalStorageIndex * storagePrice
)
}
const europeanUnionCountryCodes = [
'AT',
'BE',
'BG',
'CY',
'CZ',
'DE',
'DK',
'EE',
'ES',
'FI',
'FR',
'GR',
'HR',
'HU',
'IE',
'IT',
'LT',
'LU',
'LV',
'MT',
'NL',
'PL',
'PT',
'RO',
'SE',
'SI',
'SK',
]
const europeanUnionExclusiveLanguageCodes = [
'fr',
'de',
'it',
'el',
'pl',
'fi',
'nl',
'hr',
'cs',
'hu',
'ro',
'sl',
'sv',
'bg',
]
export const guessIfUserIsEuropean = () =>
navigator.languages.some((language) => {
const [languageCode, countryCode] = language.split('-')
return countryCode
? europeanUnionCountryCodes.includes(countryCode)
: europeanUnionExclusiveLanguageCodes.includes(languageCode)
})
export const formatPrice = (price: number) => {
const isEuropean = guessIfUserIsEuropean()
const formatter = new Intl.NumberFormat(isEuropean ? 'fr-FR' : 'en-US', {
style: 'currency',
currency: isEuropean ? 'EUR' : 'USD',
maximumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501)
})
return formatter.format(price)
}

2
pnpm-lock.yaml generated
View File

@ -264,6 +264,7 @@ importers:
autoprefixer: 10.4.8 autoprefixer: 10.4.8
bot-engine: workspace:* bot-engine: workspace:*
cross-env: ^7.0.3 cross-env: ^7.0.3
db: workspace:*
eslint: 8.23.0 eslint: 8.23.0
eslint-config-next: 12.3.0 eslint-config-next: 12.3.0
eslint-plugin-react: ^7.31.8 eslint-plugin-react: ^7.31.8
@ -285,6 +286,7 @@ importers:
'@emotion/styled': 11.10.4_fegg7422thxjtv2g43ohoqlm7a '@emotion/styled': 11.10.4_fegg7422thxjtv2g43ohoqlm7a
aos: 2.3.4 aos: 2.3.4
bot-engine: link:../../packages/bot-engine bot-engine: link:../../packages/bot-engine
db: link:../../packages/db
focus-visible: 5.2.0 focus-visible: 5.2.0
framer-motion: 7.3.2_biqbaboplfbrettd7655fr4n2y framer-motion: 7.3.2_biqbaboplfbrettd7655fr4n2y
models: link:../../packages/models models: link:../../packages/models