2
0

🛂 Add new yearly plans and graduated pricing

BREAKING CHANGE: Stripe environment variables have changed. New ones are required. Check out the new Stripe configuration in the
docs.

Closes #457
This commit is contained in:
Baptiste Arnaud
2023-04-13 11:39:10 +02:00
parent 39d0dba18c
commit 2cbf8348c3
33 changed files with 1257 additions and 1399 deletions

View File

@ -1,70 +0,0 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { Plan, WorkspaceRole } from '@typebot.io/prisma'
import Stripe from 'stripe'
import { z } from 'zod'
export const cancelSubscription = authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/billing/subscription',
protect: true,
summary: 'Cancel current subscription',
tags: ['Billing'],
},
})
.input(
z.object({
workspaceId: z.string(),
})
)
.output(
z.object({
message: z.literal('success'),
})
)
.mutation(async ({ input: { workspaceId }, ctx: { user } }) => {
if (
!process.env.STRIPE_SECRET_KEY ||
!process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID ||
!process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe environment variables are missing',
})
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
members: { some: { userId: user.id, role: WorkspaceRole.ADMIN } },
},
})
if (!workspace?.stripeId)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
const currentSubscriptionId = (
await stripe.subscriptions.list({
customer: workspace.stripeId,
})
).data.shift()?.id
if (currentSubscriptionId)
await stripe.subscriptions.del(currentSubscriptionId)
await prisma.workspace.update({
where: { id: workspace.id },
data: {
plan: Plan.FREE,
additionalChatsIndex: 0,
additionalStorageIndex: 0,
},
})
return { message: 'success' }
})

View File

@ -33,6 +33,7 @@ export const createCheckoutSession = authenticatedProcedure
value: z.string(),
})
.optional(),
isYearly: z.boolean(),
})
)
.output(
@ -52,14 +53,11 @@ export const createCheckoutSession = authenticatedProcedure
returnUrl,
additionalChats,
additionalStorage,
isYearly,
},
ctx: { user },
}) => {
if (
!process.env.STRIPE_SECRET_KEY ||
!process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID ||
!process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)
if (!process.env.STRIPE_SECRET_KEY)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe environment variables are missing',
@ -120,7 +118,8 @@ export const createCheckoutSession = authenticatedProcedure
line_items: parseSubscriptionItems(
plan,
additionalChats,
additionalStorage
additionalStorage,
isYearly
),
})

View File

@ -5,6 +5,7 @@ import { WorkspaceRole } from '@typebot.io/prisma'
import Stripe from 'stripe'
import { z } from 'zod'
import { subscriptionSchema } from '@typebot.io/schemas/features/billing/subscription'
import { priceIds } from '@typebot.io/lib/pricing'
export const getSubscription = authenticatedProcedure
.meta({
@ -23,15 +24,11 @@ export const getSubscription = authenticatedProcedure
)
.output(
z.object({
subscription: subscriptionSchema,
subscription: subscriptionSchema.or(z.null()),
})
)
.query(async ({ input: { workspaceId }, ctx: { user } }) => {
if (
!process.env.STRIPE_SECRET_KEY ||
!process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID ||
!process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)
if (!process.env.STRIPE_SECRET_KEY)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe environment variables are missing',
@ -43,10 +40,9 @@ export const getSubscription = authenticatedProcedure
},
})
if (!workspace?.stripeId)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
return {
subscription: null,
}
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
@ -58,24 +54,34 @@ export const getSubscription = authenticatedProcedure
const subscription = subscriptions?.data.shift()
if (!subscription)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Subscription not found',
})
return {
subscription: null,
}
return {
subscription: {
additionalChatsIndex:
subscription?.items.data.find(
(item) =>
item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID
)?.quantity ?? 0,
additionalStorageIndex:
subscription.items.data.find(
(item) =>
item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)?.quantity ?? 0,
isYearly: subscription.items.data.some((item) => {
return (
priceIds.STARTER.chats.yearly === item.price.id ||
priceIds.STARTER.storage.yearly === item.price.id ||
priceIds.PRO.chats.yearly === item.price.id ||
priceIds.PRO.storage.yearly === item.price.id
)
}),
currency: subscription.currency as 'usd' | 'eur',
cancelDate: subscription.cancel_at
? new Date(subscription.cancel_at * 1000)
: undefined,
},
}
})
export const chatPriceIds = [priceIds.STARTER.chats.monthly]
.concat(priceIds.STARTER.chats.yearly)
.concat(priceIds.PRO.chats.monthly)
.concat(priceIds.PRO.chats.yearly)
export const storagePriceIds = [priceIds.STARTER.storage.monthly]
.concat(priceIds.STARTER.storage.yearly)
.concat(priceIds.PRO.storage.monthly)
.concat(priceIds.PRO.storage.yearly)

View File

@ -1,5 +1,4 @@
import { router } from '@/helpers/server/trpc'
import { cancelSubscription } from './cancelSubscription'
import { createCheckoutSession } from './createCheckoutSession'
import { getBillingPortalUrl } from './getBillingPortalUrl'
import { getSubscription } from './getSubscription'
@ -10,7 +9,6 @@ import { updateSubscription } from './updateSubscription'
export const billingRouter = router({
getBillingPortalUrl,
listInvoices,
cancelSubscription,
createCheckoutSession,
updateSubscription,
getSubscription,

View File

@ -7,6 +7,12 @@ import { workspaceSchema } from '@typebot.io/schemas'
import Stripe from 'stripe'
import { isDefined } from '@typebot.io/lib'
import { z } from 'zod'
import {
getChatsLimit,
getStorageLimit,
priceIds,
} from '@typebot.io/lib/pricing'
import { chatPriceIds, storagePriceIds } from './getSubscription'
export const updateSubscription = authenticatedProcedure
.meta({
@ -25,6 +31,7 @@ export const updateSubscription = authenticatedProcedure
additionalChats: z.number(),
additionalStorage: z.number(),
currency: z.enum(['usd', 'eur']),
isYearly: z.boolean(),
})
)
.output(
@ -40,14 +47,11 @@ export const updateSubscription = authenticatedProcedure
additionalChats,
additionalStorage,
currency,
isYearly,
},
ctx: { user },
}) => {
if (
!process.env.STRIPE_SECRET_KEY ||
!process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID ||
!process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
)
if (!process.env.STRIPE_SECRET_KEY)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Stripe environment variables are missing',
@ -70,42 +74,48 @@ export const updateSubscription = authenticatedProcedure
customer: workspace.stripeId,
})
const subscription = data[0] as Stripe.Subscription | undefined
const currentStarterPlanItemId = subscription?.items.data.find(
(item) => item.price.id === process.env.STRIPE_STARTER_PRICE_ID
)?.id
const currentProPlanItemId = subscription?.items.data.find(
(item) => item.price.id === process.env.STRIPE_PRO_PRICE_ID
const currentPlanItemId = subscription?.items.data.find((item) =>
[
process.env.STRIPE_STARTER_PRODUCT_ID,
process.env.STRIPE_PRO_PRODUCT_ID,
].includes(item.price.product.toString())
)?.id
const currentAdditionalChatsItemId = subscription?.items.data.find(
(item) => item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID
(item) => chatPriceIds.includes(item.price.id)
)?.id
const currentAdditionalStorageItemId = subscription?.items.data.find(
(item) =>
item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
(item) => storagePriceIds.includes(item.price.id)
)?.id
const frequency = isYearly ? 'yearly' : 'monthly'
const items = [
{
id: currentStarterPlanItemId ?? currentProPlanItemId,
price:
plan === Plan.STARTER
? process.env.STRIPE_STARTER_PRICE_ID
: process.env.STRIPE_PRO_PRICE_ID,
id: currentPlanItemId,
price: priceIds[plan].base[frequency],
quantity: 1,
},
additionalChats === 0 && !currentAdditionalChatsItemId
? undefined
: {
id: currentAdditionalChatsItemId,
price: process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID,
quantity: additionalChats,
price: priceIds[plan].chats[frequency],
quantity: getChatsLimit({
plan,
additionalChatsIndex: additionalChats,
customChatsLimit: null,
}),
deleted: subscription ? additionalChats === 0 : undefined,
},
additionalStorage === 0 && !currentAdditionalStorageItemId
? undefined
: {
id: currentAdditionalStorageItemId,
price: process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID,
quantity: additionalStorage,
price: priceIds[plan].storage[frequency],
quantity: getStorageLimit({
plan,
additionalStorageIndex: additionalStorage,
customStorageLimit: null,
}),
deleted: subscription ? additionalStorage === 0 : undefined,
},
].filter(isDefined)
@ -126,6 +136,9 @@ export const updateSubscription = authenticatedProcedure
items,
currency,
default_payment_method: paymentMethods[0].id,
automatic_tax: {
enabled: true,
},
})
}

View File

@ -1,5 +1,6 @@
import {
addSubscriptionToWorkspace,
cancelSubscription,
createClaimableCustomPlan,
} from '@/test/utils/databaseActions'
import test, { expect } from '@playwright/test'
@ -75,7 +76,7 @@ test('should display valid usage', async ({ page }) => {
await page.click('text="Free workspace"')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 300"')).toBeVisible()
await expect(page.locator('text="/ 200"')).toBeVisible()
await expect(page.locator('text="Storage"')).toBeHidden()
await page.getByText('Members', { exact: true }).click()
await expect(
@ -132,6 +133,7 @@ test('plan changes should work', async ({ page }) => {
await page.click('button >> text="3,500"')
await page.click('button >> text="2"')
await page.click('button >> text="4"')
await page.locator('label span').first().click()
await expect(page.locator('text="$73"')).toBeVisible()
await page.click('button >> text=Upgrade >> nth=0')
await page.getByLabel('Company name').fill('Company LLC')
@ -141,11 +143,11 @@ test('plan changes should work', async ({ page }) => {
await expect(page.locator('text=$73.00 >> nth=0')).toBeVisible()
await expect(page.locator('text=$30.00 >> nth=0')).toBeVisible()
await expect(page.locator('text=$4.00 >> nth=0')).toBeVisible()
await addSubscriptionToWorkspace(
const stripeId = await addSubscriptionToWorkspace(
planChangeWorkspaceId,
[
{
price: process.env.STRIPE_STARTER_PRICE_ID,
price: process.env.STRIPE_STARTER_MONTHLY_PRICE_ID,
quantity: 1,
},
],
@ -158,8 +160,8 @@ test('plan changes should work', async ({ page }) => {
await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 2,000"')).toBeVisible()
await expect(page.locator('text="/ 2 GB"')).toBeVisible()
await expect(page.locator('button >> text="2,000"')).toBeVisible()
await expect(page.locator('button >> text="2"')).toBeVisible()
await expect(page.getByText('/ 2,000')).toBeVisible()
await expect(page.getByText('/ 2 GB')).toBeVisible()
await page.click('button >> text="2,000"')
await page.click('button >> text="3,500"')
await page.click('button >> text="2"')
@ -176,15 +178,15 @@ test('plan changes should work', async ({ page }) => {
await expect(page.locator('text="$73"')).toBeVisible()
await expect(page.locator('text="/ 3,500"')).toBeVisible()
await expect(page.locator('text="/ 4 GB"')).toBeVisible()
await expect(page.locator('button >> text="3,500"')).toBeVisible()
await expect(page.locator('button >> text="4"')).toBeVisible()
await expect(page.getByRole('button', { name: '3,500' })).toBeVisible()
await expect(page.getByRole('button', { name: '4' })).toBeVisible()
// Upgrade to PRO
await page.click('button >> text="10,000"')
await page.click('button >> text="14,000"')
await page.click('button >> text="25,000"')
await page.click('button >> text="10"')
await page.click('button >> text="12"')
await expect(page.locator('text="$133"')).toBeVisible()
await page.click('button >> text="15"')
await expect(page.locator('text="$247"')).toBeVisible()
await page.click('button >> text=Upgrade')
await expect(
page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0')
@ -195,25 +197,20 @@ test('plan changes should work', async ({ page }) => {
page.waitForNavigation(),
page.click('text="Billing portal"'),
])
await expect(page.getByText('$247.00 per month')).toBeVisible()
await expect(page.getByText('(×25000)')).toBeVisible()
await expect(page.getByText('(×15)')).toBeVisible()
await expect(page.locator('text="Add payment method"')).toBeVisible()
await cancelSubscription(stripeId)
// Cancel subscription
await page.goto('/typebots')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('[data-testid="current-subscription"]')).toHaveText(
'Current workspace subscription: ProCancel my subscription'
)
await page.click('button >> text="Cancel my subscription"')
await expect(page.locator('[data-testid="current-subscription"]')).toHaveText(
'Current workspace subscription: Free'
)
// Upgrade again to PRO
await page.getByRole('button', { name: 'Upgrade' }).nth(1).click()
await expect(
page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0')
).toBeVisible({ timeout: 20 * 1000 })
page.getByTestId('current-subscription').getByTestId('pro-plan-tag')
).toBeVisible()
await expect(page.getByText('Will be cancelled on')).toBeVisible()
})
test('should display invoices', async ({ page }) => {
@ -228,7 +225,7 @@ test('should display invoices', async ({ page }) => {
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="Invoices"')).toBeVisible()
await expect(page.locator('tr')).toHaveCount(3)
await expect(page.locator('tr')).toHaveCount(2)
await expect(page.locator('text="$39.00"')).toBeVisible()
})

View File

@ -1,17 +1,13 @@
import { HStack, Stack, Text } from '@chakra-ui/react'
import { Stack } from '@chakra-ui/react'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { Plan } from '@typebot.io/prisma'
import React from 'react'
import { InvoicesList } from './InvoicesList'
import { StripeClimateLogo } from './StripeClimateLogo'
import { TextLink } from '@/components/TextLink'
import { ChangePlanForm } from './ChangePlanForm'
import { UsageProgressBars } from './UsageProgressBars'
import { CurrentSubscriptionSummary } from './CurrentSubscriptionSummary'
import { useScopedI18n } from '@/locales'
export const BillingSettingsLayout = () => {
const scopedT = useScopedI18n('billing')
const { workspace, refreshWorkspace } = useWorkspace()
if (!workspace) return null
@ -19,19 +15,7 @@ export const BillingSettingsLayout = () => {
<Stack spacing="10" w="full">
<UsageProgressBars workspace={workspace} />
<Stack spacing="4">
<CurrentSubscriptionSummary
workspace={workspace}
onCancelSuccess={refreshWorkspace}
/>
<HStack maxW="500px">
<StripeClimateLogo />
<Text fontSize="xs" color="gray.500">
{scopedT('contribution.preLink')}{' '}
<TextLink href="https://climate.stripe.com/5VCRAq" isExternal>
{scopedT('contribution.link')}
</TextLink>
</Text>
</HStack>
<CurrentSubscriptionSummary workspace={workspace} />
{workspace.plan !== Plan.CUSTOM &&
workspace.plan !== Plan.LIFETIME &&
workspace.plan !== Plan.UNLIMITED &&

View File

@ -1,4 +1,4 @@
import { Stack, HStack, Text } from '@chakra-ui/react'
import { Stack, HStack, Text, Switch, Tag } from '@chakra-ui/react'
import { Plan } from '@typebot.io/prisma'
import { TextLink } from '@/components/TextLink'
import { useToast } from '@/hooks/useToast'
@ -12,22 +12,33 @@ import { useUser } from '@/features/account/hooks/useUser'
import { StarterPlanPricingCard } from './StarterPlanPricingCard'
import { ProPlanPricingCard } from './ProPlanPricingCard'
import { useScopedI18n } from '@/locales'
import { StripeClimateLogo } from './StripeClimateLogo'
type Props = {
workspace: Pick<Workspace, 'id' | 'stripeId' | 'plan'>
workspace: Workspace
onUpgradeSuccess: () => void
}
export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
const scopedT = useScopedI18n('billing')
const { user } = useUser()
const { showToast } = useToast()
const [preCheckoutPlan, setPreCheckoutPlan] =
useState<PreCheckoutModalProps['selectedSubscription']>()
const [isYearly, setIsYearly] = useState(true)
const { data } = trpc.billing.getSubscription.useQuery({
workspaceId: workspace.id,
})
const { data } = trpc.billing.getSubscription.useQuery(
{
workspaceId: workspace.id,
},
{
onSuccess: ({ subscription }) => {
if (isYearly === false) return
setIsYearly(subscription?.isYearly ?? true)
},
}
)
const { mutate: updateSubscription, isLoading: isUpdatingSubscription } =
trpc.billing.updateSubscription.useMutation({
@ -67,8 +78,9 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
additionalChats: selectedChatsLimitIndex,
additionalStorage: selectedStorageLimitIndex,
currency:
data?.subscription.currency ??
data?.subscription?.currency ??
(guessIfUserIsEuropean() ? 'eur' : 'usd'),
isYearly,
} as const
if (workspace.stripeId) {
updateSubscription(newSubscription)
@ -77,8 +89,19 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
}
}
if (data?.subscription?.cancelDate) return null
return (
<Stack spacing={6}>
<HStack maxW="500px">
<StripeClimateLogo />
<Text fontSize="xs" color="gray.500">
{scopedT('contribution.preLink')}{' '}
<TextLink href="https://climate.stripe.com/5VCRAq" isExternal>
{scopedT('contribution.link')}
</TextLink>
</Text>
</HStack>
{!workspace.stripeId && (
<ParentModalProvider>
<PreCheckoutModal
@ -89,41 +112,45 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
/>
</ParentModalProvider>
)}
<HStack alignItems="stretch" spacing="4" w="full">
<StarterPlanPricingCard
initialChatsLimitIndex={
workspace?.plan === Plan.STARTER
? data?.subscription.additionalChatsIndex
: 0
}
initialStorageLimitIndex={
workspace?.plan === Plan.STARTER
? data?.subscription.additionalStorageIndex
: 0
}
onPayClick={(props) =>
handlePayClick({ ...props, plan: Plan.STARTER })
}
isLoading={isUpdatingSubscription}
currency={data?.subscription.currency}
/>
{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">
<StarterPlanPricingCard
workspace={workspace}
currentSubscription={{ isYearly: data.subscription?.isYearly }}
onPayClick={(props) =>
handlePayClick({ ...props, plan: Plan.STARTER })
}
isYearly={isYearly}
isLoading={isUpdatingSubscription}
currency={data.subscription?.currency}
/>
<ProPlanPricingCard
workspace={workspace}
currentSubscription={{ isYearly: data.subscription?.isYearly }}
onPayClick={(props) =>
handlePayClick({ ...props, plan: Plan.PRO })
}
isYearly={isYearly}
isLoading={isUpdatingSubscription}
currency={data.subscription?.currency}
/>
</HStack>
</Stack>
)}
<ProPlanPricingCard
initialChatsLimitIndex={
workspace?.plan === Plan.PRO
? data?.subscription.additionalChatsIndex
: 0
}
initialStorageLimitIndex={
workspace?.plan === Plan.PRO
? data?.subscription.additionalStorageIndex
: 0
}
onPayClick={(props) => handlePayClick({ ...props, plan: Plan.PRO })}
isLoading={isUpdatingSubscription}
currency={data?.subscription.currency}
/>
</HStack>
<Text color="gray.500">
{scopedT('customLimit.preLink')}{' '}
<TextLink href={'https://typebot.io/enterprise-lead-form'} isExternal>

View File

@ -1,5 +1,4 @@
import { Text, HStack, Link, Spinner, Stack, Heading } from '@chakra-ui/react'
import { useToast } from '@/hooks/useToast'
import { Text, HStack, Stack, Heading } from '@chakra-ui/react'
import { Plan } from '@typebot.io/prisma'
import React from 'react'
import { PlanTag } from './PlanTag'
@ -10,25 +9,14 @@ import { useScopedI18n } from '@/locales'
type Props = {
workspace: Pick<Workspace, 'id' | 'plan' | 'stripeId'>
onCancelSuccess: () => void
}
export const CurrentSubscriptionSummary = ({
workspace,
onCancelSuccess,
}: Props) => {
export const CurrentSubscriptionSummary = ({ workspace }: Props) => {
const scopedT = useScopedI18n('billing.currentSubscription')
const { showToast } = useToast()
const { mutate: cancelSubscription, isLoading: isCancelling } =
trpc.billing.cancelSubscription.useMutation({
onError: (error) => {
showToast({
description: error.message,
})
},
onSuccess: onCancelSuccess,
})
const { data } = trpc.billing.getSubscription.useQuery({
workspaceId: workspace.id,
})
const isSubscribed =
(workspace.plan === Plan.STARTER || workspace.plan === Plan.PRO) &&
@ -39,36 +27,15 @@ export const CurrentSubscriptionSummary = ({
<Heading fontSize="3xl">{scopedT('heading')}</Heading>
<HStack data-testid="current-subscription">
<Text>{scopedT('subheading')} </Text>
{isCancelling ? (
<Spinner color="gray.500" size="xs" />
) : (
<>
<PlanTag plan={workspace.plan} />
{isSubscribed && (
<Link
as="button"
color="gray.500"
textDecor="underline"
fontSize="sm"
onClick={() =>
cancelSubscription({ workspaceId: workspace.id })
}
>
{scopedT('cancelLink')}
</Link>
)}
</>
<PlanTag plan={workspace.plan} />
{data?.subscription?.cancelDate && (
<Text fontSize="sm">
(Will be cancelled on {data.subscription.cancelDate.toDateString()})
</Text>
)}
</HStack>
{isSubscribed && !isCancelling && (
<>
<Stack spacing="4">
<Text fontSize="sm">{scopedT('billingPortalDescription')}</Text>
<BillingPortalButton workspaceId={workspace.id} />
</Stack>
</>
)}
{isSubscribed && <BillingPortalButton workspaceId={workspace.id} />}
</Stack>
)
}

View File

@ -28,6 +28,7 @@ export type PreCheckoutModalProps = {
additionalChats: number
additionalStorage: number
currency: 'eur' | 'usd'
isYearly: boolean
}
| undefined
existingCompany?: string

View File

@ -15,10 +15,9 @@ import {
useColorModeValue,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from '@/components/icons'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { Plan } from '@typebot.io/prisma'
import { useEffect, useState } from 'react'
import { parseNumberWithCommas } from '@typebot.io/lib'
import { isDefined, parseNumberWithCommas } from '@typebot.io/lib'
import {
chatsLimit,
computePrice,
@ -30,12 +29,23 @@ import {
import { FeaturesList } from './FeaturesList'
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
import { useI18n, useScopedI18n } from '@/locales'
import { Workspace } from '@typebot.io/schemas'
type Props = {
initialChatsLimitIndex?: number
initialStorageLimitIndex?: number
workspace: Pick<
Workspace,
| 'additionalChatsIndex'
| 'additionalStorageIndex'
| 'plan'
| 'customChatsLimit'
| 'customStorageLimit'
>
currentSubscription: {
isYearly?: boolean
}
currency?: 'usd' | 'eur'
isLoading: boolean
isYearly: boolean
onPayClick: (props: {
selectedChatsLimitIndex: number
selectedStorageLimitIndex: number
@ -43,15 +53,15 @@ type Props = {
}
export const ProPlanPricingCard = ({
initialChatsLimitIndex,
initialStorageLimitIndex,
workspace,
currentSubscription,
currency,
isLoading,
isYearly,
onPayClick,
}: Props) => {
const t = useI18n()
const scopedT = useScopedI18n('billing.pricingCard')
const { workspace } = useWorkspace()
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
useState<number>()
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
@ -59,20 +69,23 @@ export const ProPlanPricingCard = ({
useEffect(() => {
if (
selectedChatsLimitIndex === undefined &&
initialChatsLimitIndex !== undefined
isDefined(selectedChatsLimitIndex) ||
isDefined(selectedStorageLimitIndex)
)
setSelectedChatsLimitIndex(initialChatsLimitIndex)
if (
selectedStorageLimitIndex === undefined &&
initialStorageLimitIndex !== undefined
)
setSelectedStorageLimitIndex(initialStorageLimitIndex)
return
if (workspace.plan !== Plan.PRO) {
setSelectedChatsLimitIndex(0)
setSelectedStorageLimitIndex(0)
return
}
setSelectedChatsLimitIndex(workspace.additionalChatsIndex ?? 0)
setSelectedStorageLimitIndex(workspace.additionalStorageIndex ?? 0)
}, [
initialChatsLimitIndex,
initialStorageLimitIndex,
selectedChatsLimitIndex,
selectedStorageLimitIndex,
workspace.additionalChatsIndex,
workspace.additionalStorageIndex,
workspace?.plan,
])
const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined
@ -81,14 +94,11 @@ export const ProPlanPricingCard = ({
: undefined
const isCurrentPlan =
chatsLimit[Plan.PRO].totalIncluded +
chatsLimit[Plan.PRO].increaseStep.amount *
(selectedChatsLimitIndex ?? 0) ===
workspaceChatsLimit &&
storageLimit[Plan.PRO].totalIncluded +
storageLimit[Plan.PRO].increaseStep.amount *
(selectedStorageLimitIndex ?? 0) ===
workspaceStorageLimit
chatsLimit[Plan.PRO].graduatedPrice[selectedChatsLimitIndex ?? 0]
.totalIncluded === workspaceChatsLimit &&
storageLimit[Plan.PRO].graduatedPrice[selectedStorageLimitIndex ?? 0]
.totalIncluded === workspaceStorageLimit &&
isYearly === currentSubscription?.isYearly
const getButtonLabel = () => {
if (
@ -100,8 +110,8 @@ export const ProPlanPricingCard = ({
if (isCurrentPlan) return scopedT('upgradeButton.current')
if (
selectedChatsLimitIndex !== initialChatsLimitIndex ||
selectedStorageLimitIndex !== initialStorageLimitIndex
selectedChatsLimitIndex !== workspace.additionalChatsIndex ||
selectedStorageLimitIndex !== workspace.additionalStorageIndex
)
return t('update')
}
@ -149,7 +159,11 @@ export const ProPlanPricingCard = ({
<Stack spacing="4" mt={2}>
<Heading fontSize="2xl">
{scopedT('heading', {
plan: <chakra.span color="blue.400">Pro</chakra.span>,
plan: (
<chakra.span color={useColorModeValue('blue.400', 'blue.300')}>
Pro
</chakra.span>
),
})}
</Heading>
<Text>{scopedT('pro.description')}</Text>
@ -160,7 +174,8 @@ export const ProPlanPricingCard = ({
computePrice(
Plan.PRO,
selectedChatsLimitIndex ?? 0,
selectedStorageLimitIndex ?? 0
selectedStorageLimitIndex ?? 0,
isYearly ? 'yearly' : 'monthly'
) ?? NaN,
currency
)}
@ -201,50 +216,21 @@ export const ProPlanPricingCard = ({
>
{selectedChatsLimitIndex !== undefined
? parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount *
selectedChatsLimitIndex
chatsLimit.PRO.graduatedPrice[
selectedChatsLimitIndex
].totalIncluded
)
: undefined}
</MenuButton>
<MenuList>
{selectedChatsLimitIndex !== 0 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(0)}>
{parseNumberWithCommas(chatsLimit.PRO.totalIncluded)}
{chatsLimit.PRO.graduatedPrice.map((price, index) => (
<MenuItem
key={index}
onClick={() => setSelectedChatsLimitIndex(index)}
>
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 1 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(1)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 2 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(2)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 3 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(3)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 4 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(4)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount * 4
)}
</MenuItem>
)}
))}
</MenuList>
</Menu>{' '}
{scopedT('chatsPerMonth')}
@ -262,62 +248,21 @@ export const ProPlanPricingCard = ({
>
{selectedStorageLimitIndex !== undefined
? parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount *
selectedStorageLimitIndex
storageLimit.PRO.graduatedPrice[
selectedStorageLimitIndex
].totalIncluded
)
: undefined}
</MenuButton>
<MenuList>
{selectedStorageLimitIndex !== 0 && (
{storageLimit.PRO.graduatedPrice.map((price, index) => (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(0)}
key={index}
onClick={() => setSelectedStorageLimitIndex(index)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded
)}
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 1 && (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(1)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 2 && (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(2)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 3 && (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(3)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 4 && (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(4)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount * 4
)}
</MenuItem>
)}
))}
</MenuList>
</Menu>{' '}
{scopedT('storageLimit')}

View File

@ -11,10 +11,9 @@ import {
Text,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from '@/components/icons'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { Plan } from '@typebot.io/prisma'
import { useEffect, useState } from 'react'
import { parseNumberWithCommas } from '@typebot.io/lib'
import { isDefined, parseNumberWithCommas } from '@typebot.io/lib'
import {
chatsLimit,
computePrice,
@ -26,12 +25,23 @@ import {
import { FeaturesList } from './FeaturesList'
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
import { useI18n, useScopedI18n } from '@/locales'
import { Workspace } from '@typebot.io/schemas'
type Props = {
initialChatsLimitIndex?: number
initialStorageLimitIndex?: number
workspace: Pick<
Workspace,
| 'additionalChatsIndex'
| 'additionalStorageIndex'
| 'plan'
| 'customChatsLimit'
| 'customStorageLimit'
>
currentSubscription: {
isYearly?: boolean
}
currency?: 'eur' | 'usd'
isLoading?: boolean
isYearly: boolean
onPayClick: (props: {
selectedChatsLimitIndex: number
selectedStorageLimitIndex: number
@ -39,15 +49,15 @@ type Props = {
}
export const StarterPlanPricingCard = ({
initialChatsLimitIndex,
initialStorageLimitIndex,
workspace,
currentSubscription,
isLoading,
currency,
isYearly,
onPayClick,
}: Props) => {
const t = useI18n()
const scopedT = useScopedI18n('billing.pricingCard')
const { workspace } = useWorkspace()
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
useState<number>()
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
@ -55,20 +65,23 @@ export const StarterPlanPricingCard = ({
useEffect(() => {
if (
selectedChatsLimitIndex === undefined &&
initialChatsLimitIndex !== undefined
isDefined(selectedChatsLimitIndex) ||
isDefined(selectedStorageLimitIndex)
)
setSelectedChatsLimitIndex(initialChatsLimitIndex)
if (
selectedStorageLimitIndex === undefined &&
initialStorageLimitIndex !== undefined
)
setSelectedStorageLimitIndex(initialStorageLimitIndex)
return
if (workspace.plan !== Plan.STARTER) {
setSelectedChatsLimitIndex(0)
setSelectedStorageLimitIndex(0)
return
}
setSelectedChatsLimitIndex(workspace.additionalChatsIndex ?? 0)
setSelectedStorageLimitIndex(workspace.additionalStorageIndex ?? 0)
}, [
initialChatsLimitIndex,
initialStorageLimitIndex,
selectedChatsLimitIndex,
selectedStorageLimitIndex,
workspace.additionalChatsIndex,
workspace.additionalStorageIndex,
workspace?.plan,
])
const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined
@ -77,14 +90,11 @@ export const StarterPlanPricingCard = ({
: undefined
const isCurrentPlan =
chatsLimit[Plan.STARTER].totalIncluded +
chatsLimit[Plan.STARTER].increaseStep.amount *
(selectedChatsLimitIndex ?? 0) ===
workspaceChatsLimit &&
storageLimit[Plan.STARTER].totalIncluded +
storageLimit[Plan.STARTER].increaseStep.amount *
(selectedStorageLimitIndex ?? 0) ===
workspaceStorageLimit
chatsLimit[Plan.STARTER].graduatedPrice[selectedChatsLimitIndex ?? 0]
.totalIncluded === workspaceChatsLimit &&
storageLimit[Plan.STARTER].graduatedPrice[selectedStorageLimitIndex ?? 0]
.totalIncluded === workspaceStorageLimit &&
isYearly === currentSubscription?.isYearly
const getButtonLabel = () => {
if (
@ -97,8 +107,9 @@ export const StarterPlanPricingCard = ({
if (isCurrentPlan) return scopedT('upgradeButton.current')
if (
selectedChatsLimitIndex !== initialChatsLimitIndex ||
selectedStorageLimitIndex !== initialStorageLimitIndex
selectedChatsLimitIndex !== workspace.additionalChatsIndex ||
selectedStorageLimitIndex !== workspace.additionalStorageIndex ||
isYearly !== currentSubscription?.isYearly
)
return t('update')
}
@ -131,7 +142,8 @@ export const StarterPlanPricingCard = ({
computePrice(
Plan.STARTER,
selectedChatsLimitIndex ?? 0,
selectedStorageLimitIndex ?? 0
selectedStorageLimitIndex ?? 0,
isYearly ? 'yearly' : 'monthly'
) ?? NaN,
currency
)}
@ -151,52 +163,21 @@ export const StarterPlanPricingCard = ({
>
{selectedChatsLimitIndex !== undefined
? parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount *
selectedChatsLimitIndex
chatsLimit.STARTER.graduatedPrice[
selectedChatsLimitIndex
].totalIncluded
)
: undefined}
</MenuButton>
<MenuList>
{selectedChatsLimitIndex !== 0 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(0)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded
)}
{chatsLimit.STARTER.graduatedPrice.map((price, index) => (
<MenuItem
key={index}
onClick={() => setSelectedChatsLimitIndex(index)}
>
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 1 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(1)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 2 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(2)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 3 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(3)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 4 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(4)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount * 4
)}
</MenuItem>
)}
))}
</MenuList>
</Menu>{' '}
{scopedT('chatsPerMonth')}
@ -214,52 +195,21 @@ export const StarterPlanPricingCard = ({
>
{selectedStorageLimitIndex !== undefined
? parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount *
selectedStorageLimitIndex
storageLimit.STARTER.graduatedPrice[
selectedStorageLimitIndex
].totalIncluded
)
: undefined}
</MenuButton>
<MenuList>
{selectedStorageLimitIndex !== 0 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(0)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded
)}
{storageLimit.STARTER.graduatedPrice.map((price, index) => (
<MenuItem
key={index}
onClick={() => setSelectedStorageLimitIndex(index)}
>
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 1 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(1)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 2 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(2)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 3 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(3)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 4 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(4)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount * 4
)}
</MenuItem>
)}
))}
</MenuList>
</Menu>{' '}
{scopedT('storageLimit')}

View File

@ -1,16 +1,19 @@
import { Plan } from '@typebot.io/prisma'
import {
getChatsLimit,
getStorageLimit,
priceIds,
} from '@typebot.io/lib/pricing'
export const parseSubscriptionItems = (
plan: Plan,
plan: 'STARTER' | 'PRO',
additionalChats: number,
additionalStorage: number
) =>
[
additionalStorage: number,
isYearly: boolean
) => {
const frequency = isYearly ? 'yearly' : 'monthly'
return [
{
price:
plan === Plan.STARTER
? process.env.STRIPE_STARTER_PRICE_ID
: process.env.STRIPE_PRO_PRICE_ID,
price: priceIds[plan].base[frequency],
quantity: 1,
},
]
@ -18,8 +21,12 @@ export const parseSubscriptionItems = (
additionalChats > 0
? [
{
price: process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID,
quantity: additionalChats,
price: priceIds[plan].chats[frequency],
quantity: getChatsLimit({
plan,
additionalChatsIndex: additionalChats,
customChatsLimit: null,
}),
},
]
: []
@ -28,9 +35,14 @@ export const parseSubscriptionItems = (
additionalStorage > 0
? [
{
price: process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID,
quantity: additionalStorage,
price: priceIds[plan].storage[frequency],
quantity: getStorageLimit({
plan,
additionalStorageIndex: additionalStorage,
customStorageLimit: null,
}),
},
]
: []
)
}

View File

@ -26,10 +26,11 @@ export const DashboardPage = () => {
useState<PreCheckoutModalProps['selectedSubscription']>()
useEffect(() => {
const { subscribePlan, chats, storage } = query as {
const { subscribePlan, chats, storage, isYearly } = query as {
subscribePlan: Plan | undefined
chats: string | undefined
storage: string | undefined
isYearly: string | undefined
}
if (workspace && subscribePlan && user && workspace.plan === 'FREE') {
setIsLoading(true)
@ -39,6 +40,7 @@ export const DashboardPage = () => {
additionalChats: chats ? parseInt(chats) : 0,
additionalStorage: storage ? parseInt(storage) : 0,
currency: guessIfUserIsEuropean() ? 'eur' : 'usd',
isYearly: isYearly === 'false' ? false : true,
})
}
}, [query, user, workspace])

View File

@ -103,8 +103,6 @@ export default {
'billing.currentSubscription.heading': 'Subscription',
'billing.currentSubscription.subheading': 'Current workspace subscription:',
'billing.currentSubscription.cancelLink': 'Cancel my subscription',
'billing.currentSubscription.billingPortalDescription':
'Need to change payment method or billing information? Head over to your billing portal:',
'billing.invoices.heading': 'Invoices',
'billing.invoices.empty': 'No invoices found for this workspace.',
'billing.invoices.paidAt': 'Paid at',

View File

@ -109,8 +109,6 @@ export default defineLocale({
'billing.currentSubscription.heading': 'Abonnement',
'billing.currentSubscription.subheading': 'Abonnement actuel du workspace :',
'billing.currentSubscription.cancelLink': "Annuler l'abonnement",
'billing.currentSubscription.billingPortalDescription':
'Besoin de changer votre mode de paiement ou vos informations de facturation ? Rendez-vous sur votre portail de facturation :',
'billing.invoices.heading': 'Factures',
'billing.invoices.empty': 'Aucune facture trouvée pour ce workspace.',
'billing.invoices.paidAt': 'Payé le',

View File

@ -109,8 +109,6 @@ export default defineLocale({
'billing.currentSubscription.subheading':
'Assinatura atual do espaço de trabalho:',
'billing.currentSubscription.cancelLink': 'Cancelar minha assinatura',
'billing.currentSubscription.billingPortalDescription':
'Precisa alterar o método de pagamento ou as informações de cobrança? Acesse seu portal de cobrança:',
'billing.invoices.heading': 'Faturas',
'billing.invoices.empty':
'Nenhuma fatura encontrada para este espaço de trabalho.',

View File

@ -52,6 +52,19 @@ export const addSubscriptionToWorkspace = async (
...metadata,
},
})
return stripeId
}
export const cancelSubscription = async (stripeId: string) => {
const currentSubscriptionId = (
await stripe.subscriptions.list({
customer: stripeId,
})
).data.shift()?.id
if (currentSubscriptionId)
await stripe.subscriptions.update(currentSubscriptionId, {
cancel_at_period_end: true,
})
}
export const createCollaboration = (

View File

@ -201,15 +201,26 @@ The related environment variables are listed here but you are probably not inter
<details><summary><h4>Stripe</h4></summary>
<p>
| Parameter | Default | Description |
| ---------------------------------- | ------- | --------------------------- |
| NEXT_PUBLIC_STRIPE_PUBLIC_KEY | | Stripe public key |
| STRIPE_SECRET_KEY | | Stripe secret key |
| STRIPE_PRO_PRICE_ID | | Pro plan price id |
| STRIPE_STARTER_PRICE_ID | | Starter plan price id |
| STRIPE_ADDITIONAL_CHATS_PRICE_ID | | Additional chats price id |
| STRIPE_ADDITIONAL_STORAGE_PRICE_ID | | Additional storage price id |
| STRIPE_WEBHOOK_SECRET | | Stripe Webhook secret |
| Parameter | Default | Description |
| --------------------------------------- | ------- | ------------------------------------------- |
| NEXT_PUBLIC_STRIPE_PUBLIC_KEY | | Stripe public key |
| STRIPE_SECRET_KEY | | Stripe secret key |
| STRIPE_STARTER_PRODUCT_ID | | Starter plan product ID |
| STRIPE_STARTER_MONTHLY_PRICE_ID | | Starter monthly plan price id |
| STRIPE_STARTER_YEARLY_PRICE_ID | | Starter yearly plan price id |
| STRIPE_PRO_PRODUCT_ID | | Pro plan product ID |
| STRIPE_PRO_MONTHLY_PRICE_ID | | Pro monthly plan price id |
| STRIPE_PRO_YEARLY_PRICE_ID | | Pro yearly plan price id |
| STRIPE_STARTER_CHATS_MONTHLY_PRICE_ID | | Starter Additional chats monthly price id |
| STRIPE_STARTER_CHATS_YEARLY_PRICE_ID | | Starter Additional chats yearly price id |
| STRIPE_PRO_CHATS_MONTHLY_PRICE_ID | | Pro Additional chats monthly price id |
| STRIPE_PRO_CHATS_YEARLY_PRICE_ID | | Pro Additional chats yearly price id |
| STRIPE_STARTER_STORAGE_MONTHLY_PRICE_ID | | Starter Additional storage monthly price id |
| STRIPE_STARTER_STORAGE_YEARLY_PRICE_ID | | Starter Additional storage yearly price id |
| STRIPE_PRO_STORAGE_MONTHLY_PRICE_ID | | Pro Additional storage monthly price id |
| STRIPE_PRO_STORAGE_YEARLY_PRICE_ID | | Pro Additional storage yearly price id |
| STRIPE_ADDITIONAL_STORAGE_PRICE_ID | | Additional storage price id |
| STRIPE_WEBHOOK_SECRET | | Stripe Webhook secret |
</p></details>

View File

@ -4382,10 +4382,10 @@
}
}
},
"/billing/subscription": {
"delete": {
"operationId": "mutation.billing.cancelSubscription",
"summary": "Cancel current subscription",
"/billing/subscription/checkout": {
"post": {
"operationId": "mutation.billing.createCheckoutSession",
"summary": "Create checkout session to create a new subscription",
"tags": [
"Billing"
],
@ -4394,16 +4394,85 @@
"Authorization": []
}
],
"parameters": [
{
"name": "workspaceId",
"in": "query",
"required": true,
"schema": {
"type": "string"
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"company": {
"type": "string"
},
"workspaceId": {
"type": "string"
},
"prefilledEmail": {
"type": "string"
},
"currency": {
"type": "string",
"enum": [
"usd",
"eur"
]
},
"plan": {
"type": "string",
"enum": [
"STARTER",
"PRO"
]
},
"returnUrl": {
"type": "string"
},
"additionalChats": {
"type": "number"
},
"additionalStorage": {
"type": "number"
},
"vat": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": [
"type",
"value"
],
"additionalProperties": false
},
"isYearly": {
"type": "boolean"
}
},
"required": [
"email",
"company",
"workspaceId",
"currency",
"plan",
"returnUrl",
"additionalChats",
"additionalStorage",
"isYearly"
],
"additionalProperties": false
}
}
}
],
},
"parameters": [],
"responses": {
"200": {
"description": "Successful response",
@ -4412,15 +4481,12 @@
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"enum": [
"success"
]
"checkoutUrl": {
"type": "string"
}
},
"required": [
"message"
"checkoutUrl"
],
"additionalProperties": false
}
@ -4431,7 +4497,9 @@
"$ref": "#/components/responses/error"
}
}
},
}
},
"/billing/subscription": {
"patch": {
"operationId": "mutation.billing.updateSubscription",
"summary": "Update subscription",
@ -4472,6 +4540,9 @@
"usd",
"eur"
]
},
"isYearly": {
"type": "boolean"
}
},
"required": [
@ -4479,7 +4550,8 @@
"plan",
"additionalChats",
"additionalStorage",
"currency"
"currency",
"isYearly"
],
"additionalProperties": false
}
@ -4635,28 +4707,38 @@
"type": "object",
"properties": {
"subscription": {
"type": "object",
"properties": {
"additionalChatsIndex": {
"type": "number"
"anyOf": [
{
"type": "object",
"properties": {
"isYearly": {
"type": "boolean"
},
"currency": {
"type": "string",
"enum": [
"eur",
"usd"
]
},
"cancelDate": {
"type": "string",
"format": "date-time"
}
},
"required": [
"isYearly",
"currency"
],
"additionalProperties": false
},
"additionalStorageIndex": {
"type": "number"
},
"currency": {
"type": "string",
{
"enum": [
"eur",
"usd"
]
"null"
],
"nullable": true
}
},
"required": [
"additionalChatsIndex",
"additionalStorageIndex",
"currency"
],
"additionalProperties": false
]
}
},
"required": [
@ -4673,119 +4755,6 @@
}
}
},
"/billing/subscription/checkout": {
"post": {
"operationId": "mutation.billing.createCheckoutSession",
"summary": "Create checkout session to create a new subscription",
"tags": [
"Billing"
],
"security": [
{
"Authorization": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"company": {
"type": "string"
},
"workspaceId": {
"type": "string"
},
"prefilledEmail": {
"type": "string"
},
"currency": {
"type": "string",
"enum": [
"usd",
"eur"
]
},
"plan": {
"type": "string",
"enum": [
"STARTER",
"PRO"
]
},
"returnUrl": {
"type": "string"
},
"additionalChats": {
"type": "number"
},
"additionalStorage": {
"type": "number"
},
"vat": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": [
"type",
"value"
],
"additionalProperties": false
}
},
"required": [
"email",
"company",
"workspaceId",
"currency",
"plan",
"returnUrl",
"additionalChats",
"additionalStorage"
],
"additionalProperties": false
}
}
}
},
"parameters": [],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"checkoutUrl": {
"type": "string"
}
},
"required": [
"checkoutUrl"
],
"additionalProperties": false
}
}
}
},
"default": {
"$ref": "#/components/responses/error"
}
}
}
},
"/billing/usage": {
"get": {
"operationId": "query.billing.getUsage",

View File

@ -2,41 +2,36 @@ import Icon, { IconProps } from '@chakra-ui/icon'
import React from 'react'
export const Logo = (props: IconProps) => (
<Icon
viewBox="0 0 500 500"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect width="500" height="500" rx="75" fill={'#0042DA'} />
<Icon w="50px" h="50px" viewBox="0 0 800 800" {...props}>
<rect width="800" height="800" rx="80" fill={'#0042DA'} />
<rect
x="438.709"
y="170.968"
width="64.5161"
height="290.323"
rx="32.2581"
transform="rotate(90 438.709 170.968)"
x="650"
y="293"
width="85.4704"
height="384.617"
rx="20"
transform="rotate(90 650 293)"
fill="#FF8E20"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M93.5481 235.484C111.364 235.484 125.806 221.041 125.806 203.226C125.806 185.41 111.364 170.968 93.5481 170.968C75.7325 170.968 61.29 185.41 61.29 203.226C61.29 221.041 75.7325 235.484 93.5481 235.484Z"
d="M192.735 378.47C216.337 378.47 235.47 359.337 235.47 335.735C235.47 312.133 216.337 293 192.735 293C169.133 293 150 312.133 150 335.735C150 359.337 169.133 378.47 192.735 378.47Z"
fill="#FF8E20"
/>
<rect
x="61.29"
y="332.259"
width="64.5161"
height="290.323"
rx="32.2581"
transform="rotate(-90 61.29 332.259)"
x="150"
y="506.677"
width="85.4704"
height="384.617"
rx="20"
transform="rotate(-90 150 506.677)"
fill={'white'}
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M406.451 267.742C388.635 267.742 374.193 282.184 374.193 300C374.193 317.815 388.635 332.258 406.451 332.258C424.267 332.258 438.709 317.815 438.709 300C438.709 282.184 424.267 267.742 406.451 267.742Z"
d="M607.265 421.206C583.663 421.206 564.53 440.34 564.53 463.942C564.53 487.544 583.663 506.677 607.265 506.677C630.867 506.677 650 487.544 650 463.942C650 440.34 630.867 421.206 607.265 421.206Z"
fill={'white'}
/>
</Icon>

View File

@ -33,7 +33,7 @@ export const Hero = () => {
bgClip="text"
data-aos="fade-up"
>
Open-source conversational forms builder
Build advanced chatbots visually
</Heading>
<Text
fontSize={['lg', 'xl']}

View File

@ -49,7 +49,7 @@ export const IntroducingChatApps = () => {
textAlign="center"
data-aos="fade"
>
Introducing Conversational Apps
Replace your old school forms with chatbots
</Heading>
<Text
textAlign="center"

View File

@ -0,0 +1,57 @@
import {
Stack,
Heading,
Button,
List,
ListItem,
ListIcon,
Text,
Link,
} from '@chakra-ui/react'
import { CheckCircleIcon } from 'assets/icons'
export const EnterprisePlanCard = () => (
<Stack
direction={['column', 'row']}
align="center"
p="10"
rounded="lg"
bgColor="gray.800"
borderWidth="2px"
spacing={10}
>
<Stack maxW="300px" spacing={4}>
<Heading fontSize="xl">Enterprise</Heading>
<Text>
Ideal for large companies looking to generate leads and automate
customer support at scale
</Text>
<Text fontSize="lg">
<Button
as={Link}
href="https://typebot.io/enterprise-lead-form"
isExternal
variant="outline"
>
Get a quote
</Button>
</Text>
</Stack>
<Stack flex="1">
<List spacing="4">
<ListItem fontWeight="medium" display="flex" alignItems="center">
<ListIcon fontSize="xl" as={CheckCircleIcon} marginEnd={2} />
Custom chats limits & seats for all your team
</ListItem>
<ListItem fontWeight="medium" display="flex" alignItems="center">
<ListIcon fontSize="xl" as={CheckCircleIcon} marginEnd={2} />
SSO & Granular access rights
</ListItem>
<ListItem fontWeight="medium" display="flex" alignItems="center">
<ListIcon fontSize="xl" as={CheckCircleIcon} marginEnd={2} />
Yearly contract with dedicated support representative
</ListItem>
</List>
</Stack>
</Stack>
)

View File

@ -0,0 +1,86 @@
import { Heading, VStack, SimpleGrid, Stack, Text } from '@chakra-ui/react'
export const Faq = () => (
<VStack w="full" spacing="10">
<Heading textAlign="center">Frequently asked questions</Heading>
<SimpleGrid columns={[1, 2]} spacing={10}>
<Stack borderWidth={1} p="8" rounded="lg" spacing={4}>
<Heading as="h2" fontSize="2xl">
What is considered a monthly chat?
</Heading>
<Text>
A chat is counted whenever a user starts a discussion. It is
independant of the number of messages he sends and receives. For
example if a user starts a discussion and sends 10 messages to the
bot, it will count as 1 chat. If the user chats again later and its
session is remembered, it will not be counted as a new chat. <br />
<br />
An easy way to think about it: 1 chat equals to a row in your Results
table
</Text>
</Stack>
<Stack borderWidth={1} p="8" rounded="lg" spacing={4}>
<Heading as="h2" fontSize="2xl">
What happens once I reach the monthly chats limit?
</Heading>
<Text>
When you exceed the number of chats included in your plan, you will
receive a heads up by email. There won&apos;t be any immediate
additional charges and your bots will continue to run. If you continue
to exceed the limit, you will be kindly asked you to upgrade your
subscription.
</Text>
</Stack>
<Stack borderWidth={1} p="8" rounded="lg" spacing={4}>
<Heading as="h2" fontSize="2xl">
What is considered as storage?
</Heading>
<Text>
You accumulate storage for every file that your user upload into your
bot. If you delete the associated result, it will free up the used
space.
</Text>
</Stack>
<Stack borderWidth={1} p="8" rounded="lg" spacing={4}>
<Heading as="h2" fontSize="2xl">
What happens once I reach the storage limit?
</Heading>
<Text>
When you exceed the storage size included in your plan, you will
receive a heads up by email. There won&apos;t be any immediate
additional charges and your bots will continue to store new files. If
you continue to exceed the limit, you will be kindly asked you to
upgrade your subscription.
</Text>
</Stack>
<Stack borderWidth={1} p="8" rounded="lg" spacing={4}>
<Heading as="h2" fontSize="2xl">
Can I cancel or change my subscription any time?
</Heading>
<Text>
Yes, you can cancel, upgrade or downgrade your subscription at any
time. There is no minimum time commitment or lock-in.
<br />
<br />
When you upgrade or downgrade your subscription, you&apos;ll get
access to the new options right away. Your next invoice will have a
prorated amount.
</Text>
</Stack>
<Stack borderWidth={1} p="8" rounded="lg" spacing={4}>
<Heading as="h2" fontSize="2xl">
Do you offer annual payments?
</Heading>
<Text>
Yes. Starter and Pro plans can be purchased with monthly or annual
billing.
<br />
<br />
Annual plans are cheaper and give you a 16% discount compared to
monthly payments. Enterprise plans are only available with annual
billing.
</Text>
</Stack>
</SimpleGrid>
</VStack>
)

View File

@ -3,6 +3,7 @@ import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon'
import Link from 'next/link'
import React from 'react'
import { PricingCard } from './PricingCard'
import { chatsLimit } from '@typebot.io/lib/pricing'
export const FreePlanCard = () => (
<PricingCard
@ -13,7 +14,10 @@ export const FreePlanCard = () => (
'Unlimited typebots',
<>
<Text>
<chakra.span fontWeight="bold">300 chats</chakra.span> included
<chakra.span fontWeight="bold">
{chatsLimit.FREE.totalIncluded}
</chakra.span>{' '}
chats/month
</Text>
&nbsp;
<Tooltip
@ -37,7 +41,7 @@ export const FreePlanCard = () => (
as={Link}
href="https://app.typebot.io/register"
variant="outline"
colorScheme="blue"
colorScheme="gray"
size="lg"
w="full"
fontWeight="extrabold"

View File

@ -8,7 +8,6 @@ import {
Td,
Text,
Stack,
StackProps,
HStack,
Tooltip,
chakra,
@ -19,367 +18,383 @@ import { CheckIcon } from 'assets/icons/CheckIcon'
import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon'
import { Plan } from '@typebot.io/prisma'
import Link from 'next/link'
import React, { useEffect, useState } from 'react'
import { chatsLimit, formatPrice, storageLimit } from '@typebot.io/lib/pricing'
import React from 'react'
import {
chatsLimit,
formatPrice,
prices,
seatsLimit,
storageLimit,
} from '@typebot.io/lib/pricing'
import { parseNumberWithCommas } from '@typebot.io/lib'
type Props = {
starterPrice: string
proPrice: string
} & StackProps
export const PlanComparisonTables = ({
starterPrice,
proPrice,
...props
}: Props) => {
const [additionalChatsPrice, setAdditionalChatsPrice] = useState(
`$${chatsLimit.STARTER.increaseStep.price}`
)
const [additionalStoragePrice, setAdditionalStoragePrice] = useState(
`$${storageLimit.STARTER.increaseStep.price}`
)
useEffect(() => {
setAdditionalChatsPrice(formatPrice(chatsLimit.STARTER.increaseStep.price))
setAdditionalStoragePrice(
formatPrice(storageLimit.STARTER.increaseStep.price)
)
}, [])
return (
<Stack spacing="12" {...props}>
<TableContainer>
<Table>
<Thead>
<Tr>
<Th fontWeight="bold" color="white" w="400px">
Usage
</Th>
<Th>Free</Th>
<Th color="orange.200">Starter</Th>
<Th color="blue.200">Pro</Th>
</Tr>
</Thead>
<Tbody>
<Tr>
<Td>Total bots</Td>
<Td>Unlimited</Td>
<Td>Unlimited</Td>
<Td>Unlimited</Td>
</Tr>
<Tr>
<Td>Chats</Td>
<Td>300 / month</Td>
<Td>2,000 / month</Td>
<Td>10,000 / month</Td>
</Tr>
<Tr>
<Td>Additional Chats</Td>
<Td />
<Td>{additionalChatsPrice} per 500</Td>
<Td>{additionalChatsPrice} 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>{additionalStoragePrice} per 1 GB</Td>
<Td>{additionalStoragePrice} per 1 GB</Td>
</Tr>
<Tr>
<Td>Members</Td>
<Td>Just you</Td>
<Td>2 seats</Td>
<Td>5 seats</Td>
</Tr>
<Tr>
<Td>Guests</Td>
<Td>Unlimited</Td>
<Td>Unlimited</Td>
<Td>Unlimited</Td>
</Tr>
</Tbody>
</Table>
</TableContainer>
<TableContainer>
<Table>
<Thead>
<Tr>
<Th fontWeight="bold" color="white" w="400px">
Features
</Th>
<Th>Free</Th>
<Th color="orange.200">Starter</Th>
<Th color="blue.200">Pro</Th>
</Tr>
</Thead>
<Tbody>
<Tr>
<TdWithTooltip
text="20+ blocks"
tooltip="Includes display bubbles (text, image, video, embed), question inputs (email, url, phone number...) and logic blocks (conditions, set variables...)"
/>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Starter templates</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Webhooks</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Google Sheets</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Google Analytics</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Send emails</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Zapier</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Pabbly Connect</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Make.com</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Custom Javascript & CSS</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Export CSV</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>File upload inputs</Td>
<Td />
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<TdWithTooltip
text="Folders"
tooltip="Organize your typebots into folders"
/>
<Td />
<Td>Unlimited</Td>
<Td>Unlimited</Td>
</Tr>
<Tr>
<Td>Remove branding</Td>
<Td />
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Custom domains</Td>
<Td />
<Td />
<Td>Unlimited</Td>
</Tr>
<Tr>
<TdWithTooltip
text="In-depth analytics"
tooltip="Analytics graph that shows your form drop-off rate, submission rate, and more."
/>
<Td />
<Td />
<Td>
<CheckIcon />
</Td>
</Tr>
</Tbody>
</Table>
</TableContainer>
<TableContainer>
<Table>
<Thead>
<Tr>
<Th fontWeight="bold" color="white" w="400px">
Support
</Th>
<Th>Free</Th>
<Th color="orange.200">Starter</Th>
<Th color="blue.200">Pro</Th>
</Tr>
</Thead>
<Tbody>
<Tr>
<Td>Priority support</Td>
<Td />
<Td />
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Feature request priority</Td>
<Td />
<Td />
<Td>
<CheckIcon />
</Td>
</Tr>
</Tbody>
</Table>
</TableContainer>
<Stack
direction={['column', 'row']}
spacing={4}
w="full"
justify="space-around"
>
<Stack spacing={4}>
<Heading as="h3" size="md">
Personal
</Heading>
<Heading as="h3">Free</Heading>
<Link href="https://app.typebot.io/register">
<Button variant="outline">Get started</Button>
</Link>
</Stack>
<Stack spacing={4}>
<Heading as="h3" size="md" color="orange.200">
Starter
</Heading>
<Heading as="h3">
{starterPrice} <chakra.span fontSize="lg">/ month</chakra.span>
</Heading>
<Link
href={`https://app.typebot.io/register?subscribePlan=${Plan.STARTER}`}
>
<Button>Subscribe</Button>
</Link>
</Stack>
<Stack spacing={4}>
<Heading as="h3" size="md" color="blue.200">
Pro
</Heading>
<Heading as="h3">
{proPrice} <chakra.span fontSize="lg">/ month</chakra.span>
</Heading>
<Link
href={`https://app.typebot.io/register?subscribePlan=${Plan.PRO}`}
>
<Button>Subscribe</Button>
</Link>
</Stack>
export const PlanComparisonTables = () => (
<Stack spacing="12">
<TableContainer>
<Table>
<Thead>
<Tr>
<Th fontWeight="bold" color="white" w="400px">
Usage
</Th>
<Th>Free</Th>
<Th color="orange.200">Starter</Th>
<Th color="blue.200">Pro</Th>
</Tr>
</Thead>
<Tbody>
<Tr>
<Td>Total bots</Td>
<Td>Unlimited</Td>
<Td>Unlimited</Td>
<Td>Unlimited</Td>
</Tr>
<Tr>
<Td>Chats</Td>
<Td>{chatsLimit.FREE.totalIncluded} / month</Td>
<Td>
{parseNumberWithCommas(
chatsLimit.STARTER.graduatedPrice[0].totalIncluded
)}{' '}
/ month
</Td>
<Td>
{parseNumberWithCommas(
chatsLimit.PRO.graduatedPrice[0].totalIncluded
)}{' '}
/ month
</Td>
</Tr>
<Tr>
<Td>Additional Chats</Td>
<Td />
<Td>
{formatPrice(chatsLimit.STARTER.graduatedPrice[1].price)} per{' '}
{chatsLimit.STARTER.graduatedPrice[1].totalIncluded -
chatsLimit.STARTER.graduatedPrice[0].totalIncluded}
</Td>
<Td>
{formatPrice(chatsLimit.PRO.graduatedPrice[1].price)} per{' '}
{chatsLimit.PRO.graduatedPrice[1].totalIncluded -
chatsLimit.PRO.graduatedPrice[0].totalIncluded}
</Td>
</Tr>
<Tr>
<Td>Storage</Td>
<Td />
<Td>2 GB</Td>
<Td>10 GB</Td>
</Tr>
<Tr>
<Td>Additional Storage</Td>
<Td />
<Td>
{formatPrice(storageLimit.STARTER.graduatedPrice[1].price)} per{' '}
{storageLimit.STARTER.graduatedPrice[1].totalIncluded -
storageLimit.STARTER.graduatedPrice[0].totalIncluded}{' '}
GB
</Td>
<Td>
{formatPrice(storageLimit.PRO.graduatedPrice[1].price)} per{' '}
{storageLimit.PRO.graduatedPrice[1].totalIncluded -
storageLimit.PRO.graduatedPrice[0].totalIncluded}{' '}
GB
</Td>
</Tr>
<Tr>
<Td>Members</Td>
<Td>Just you</Td>
<Td>{seatsLimit.STARTER.totalIncluded} seats</Td>
<Td>{seatsLimit.PRO.totalIncluded} seats</Td>
</Tr>
<Tr>
<Td>Guests</Td>
<Td>Unlimited</Td>
<Td>Unlimited</Td>
<Td>Unlimited</Td>
</Tr>
</Tbody>
</Table>
</TableContainer>
<TableContainer>
<Table>
<Thead>
<Tr>
<Th fontWeight="bold" color="white" w="400px">
Features
</Th>
<Th>Free</Th>
<Th color="orange.200">Starter</Th>
<Th color="blue.200">Pro</Th>
</Tr>
</Thead>
<Tbody>
<Tr>
<TdWithTooltip
text="20+ blocks"
tooltip="Includes display bubbles (text, image, video, embed), question inputs (email, url, phone number...) and logic blocks (conditions, set variables...)"
/>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Starter templates</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Webhooks</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Google Sheets</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Google Analytics</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Send emails</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Zapier</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Pabbly Connect</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Make.com</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Custom Javascript & CSS</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Export CSV</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>File upload inputs</Td>
<Td />
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<TdWithTooltip
text="Folders"
tooltip="Organize your typebots into folders"
/>
<Td />
<Td>Unlimited</Td>
<Td>Unlimited</Td>
</Tr>
<Tr>
<Td>Remove branding</Td>
<Td />
<Td>
<CheckIcon />
</Td>
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Custom domains</Td>
<Td />
<Td />
<Td>Unlimited</Td>
</Tr>
<Tr>
<TdWithTooltip
text="In-depth analytics"
tooltip="Analytics graph that shows your form drop-off rate, submission rate, and more."
/>
<Td />
<Td />
<Td>
<CheckIcon />
</Td>
</Tr>
</Tbody>
</Table>
</TableContainer>
<TableContainer>
<Table>
<Thead>
<Tr>
<Th fontWeight="bold" color="white" w="400px">
Support
</Th>
<Th>Free</Th>
<Th color="orange.200">Starter</Th>
<Th color="blue.200">Pro</Th>
</Tr>
</Thead>
<Tbody>
<Tr>
<Td>Priority support</Td>
<Td />
<Td />
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Feature request priority</Td>
<Td />
<Td />
<Td>
<CheckIcon />
</Td>
</Tr>
</Tbody>
</Table>
</TableContainer>
<Stack
direction={['column', 'row']}
spacing={4}
w="full"
justify="space-around"
>
<Stack spacing={4}>
<Heading as="h3" size="md">
Personal
</Heading>
<Heading as="h3">Free</Heading>
<Link href="https://app.typebot.io/register">
<Button variant="outline" colorScheme="gray">
Get started
</Button>
</Link>
</Stack>
<Stack spacing={4}>
<Heading as="h3" size="md" color="orange.200">
Starter
</Heading>
<Heading as="h3">
{formatPrice(prices.STARTER)}{' '}
<chakra.span fontSize="lg">/ month</chakra.span>
</Heading>
<Link
href={`https://app.typebot.io/register?subscribePlan=${Plan.STARTER}`}
>
<Button variant="outline" colorScheme="orange">
Subscribe
</Button>
</Link>
</Stack>
<Stack spacing={4}>
<Heading as="h3" size="md" color="blue.200">
Pro
</Heading>
<Heading as="h3">
{formatPrice(prices.PRO)}{' '}
<chakra.span fontSize="lg">/ month</chakra.span>
</Heading>
<Link
href={`https://app.typebot.io/register?subscribePlan=${Plan.PRO}`}
>
<Button>Subscribe</Button>
</Link>
</Stack>
</Stack>
)
}
</Stack>
)
const TdWithTooltip = ({
text,

View File

@ -9,7 +9,6 @@ import {
VStack,
} from '@chakra-ui/react'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { formatPrice } from '@typebot.io/lib/pricing'
import { CheckCircleIcon } from '../../../assets/icons/CheckCircleIcon'
import { Card, CardProps } from './Card'
@ -34,12 +33,8 @@ export const PricingCard = ({
...rest
}: PricingCardProps) => {
const { features, price, name } = data
const [formattedPrice, setFormattedPrice] = useState(price)
const accentColor = useColorModeValue('blue.500', 'white')
useEffect(() => {
setFormattedPrice(typeof price === 'number' ? formatPrice(price) : price)
}, [price])
const formattedPrice = typeof price === 'number' ? formatPrice(price) : price
return (
<Card rounded="xl" bgColor="gray.800" {...rest}>

View File

@ -13,28 +13,33 @@ import { ChevronDownIcon } from 'assets/icons/ChevronDownIcon'
import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon'
import { Plan } from '@typebot.io/prisma'
import Link from 'next/link'
import React, { useEffect, useState } from 'react'
import React, { useState } from 'react'
import { parseNumberWithCommas } from '@typebot.io/lib'
import { chatsLimit, computePrice, storageLimit } from '@typebot.io/lib/pricing'
import {
chatsLimit,
computePrice,
seatsLimit,
storageLimit,
} from '@typebot.io/lib/pricing'
import { PricingCard } from './PricingCard'
export const ProPlanCard = () => {
const [price, setPrice] = useState(89)
type Props = {
isYearly: boolean
}
export const ProPlanCard = ({ isYearly }: Props) => {
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
useState<number>(0)
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
useState<number>(0)
useEffect(() => {
setPrice(
computePrice(
Plan.PRO,
selectedChatsLimitIndex ?? 0,
selectedStorageLimitIndex ?? 0
) ?? NaN
)
}, [selectedChatsLimitIndex, selectedStorageLimitIndex])
const price =
computePrice(
Plan.PRO,
selectedChatsLimitIndex ?? 0,
selectedStorageLimitIndex ?? 0,
isYearly ? 'yearly' : 'monthly'
) ?? NaN
return (
<PricingCard
@ -44,7 +49,10 @@ export const ProPlanCard = () => {
featureLabel: 'Everything in Personal, plus:',
features: [
<Text key="seats">
<chakra.span fontWeight="bold">5 seats</chakra.span> included
<chakra.span fontWeight="bold">
{seatsLimit.PRO.totalIncluded} seats
</chakra.span>{' '}
included
</Text>,
<HStack key="chats" spacing={1.5}>
<Menu>
@ -57,50 +65,20 @@ export const ProPlanCard = () => {
>
{selectedChatsLimitIndex !== undefined
? parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount *
selectedChatsLimitIndex
chatsLimit.PRO.graduatedPrice[selectedChatsLimitIndex]
.totalIncluded
)
: undefined}
</MenuButton>
<MenuList>
{selectedChatsLimitIndex !== 0 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(0)}>
{parseNumberWithCommas(chatsLimit.PRO.totalIncluded)}
{chatsLimit.PRO.graduatedPrice.map((price, index) => (
<MenuItem
key={index}
onClick={() => setSelectedChatsLimitIndex(index)}
>
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 1 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(1)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 2 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(2)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 3 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(3)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 4 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(4)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount * 4
)}
</MenuItem>
)}
))}
</MenuList>
</Menu>{' '}
<Text>chats/mo</Text>
@ -126,50 +104,20 @@ export const ProPlanCard = () => {
>
{selectedStorageLimitIndex !== undefined
? parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount *
selectedStorageLimitIndex
storageLimit.PRO.graduatedPrice[selectedStorageLimitIndex]
.totalIncluded
)
: undefined}
</MenuButton>
<MenuList>
{selectedStorageLimitIndex !== 0 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(0)}>
{parseNumberWithCommas(storageLimit.PRO.totalIncluded)}
{storageLimit.PRO.graduatedPrice.map((price, index) => (
<MenuItem
key={index}
onClick={() => setSelectedStorageLimitIndex(index)}
>
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 1 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(1)}>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 2 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(2)}>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 3 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(3)}>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 4 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(4)}>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount * 4
)}
</MenuItem>
)}
))}
</MenuList>
</Menu>{' '}
<Text>GB of storage</Text>
@ -194,7 +142,7 @@ export const ProPlanCard = () => {
button={
<Button
as={Link}
href={`https://app.typebot.io/register?subscribePlan=${Plan.PRO}&chats=${selectedChatsLimitIndex}&storage=${selectedStorageLimitIndex}`}
href={`https://app.typebot.io/register?subscribePlan=${Plan.PRO}&chats=${selectedChatsLimitIndex}&storage=${selectedStorageLimitIndex}&isYearly=${isYearly}`}
colorScheme="blue"
size="lg"
w="full"

View File

@ -13,28 +13,32 @@ import { ChevronDownIcon } from 'assets/icons/ChevronDownIcon'
import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon'
import { Plan } from '@typebot.io/prisma'
import Link from 'next/link'
import React, { useEffect, useState } from 'react'
import React, { useState } from 'react'
import { parseNumberWithCommas } from '@typebot.io/lib'
import { chatsLimit, computePrice, storageLimit } from '@typebot.io/lib/pricing'
import {
chatsLimit,
computePrice,
seatsLimit,
storageLimit,
} from '@typebot.io/lib/pricing'
import { PricingCard } from './PricingCard'
export const StarterPlanCard = () => {
const [price, setPrice] = useState(39)
type Props = {
isYearly: boolean
}
export const StarterPlanCard = ({ isYearly }: Props) => {
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
useState<number>(0)
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
useState<number>(0)
useEffect(() => {
setPrice(
computePrice(
Plan.STARTER,
selectedChatsLimitIndex ?? 0,
selectedStorageLimitIndex ?? 0
) ?? NaN
)
}, [selectedChatsLimitIndex, selectedStorageLimitIndex])
const price =
computePrice(
Plan.STARTER,
selectedChatsLimitIndex ?? 0,
selectedStorageLimitIndex ?? 0,
isYearly ? 'yearly' : 'monthly'
) ?? NaN
return (
<PricingCard
@ -44,7 +48,10 @@ export const StarterPlanCard = () => {
featureLabel: 'Everything in Personal, plus:',
features: [
<Text key="seats">
<chakra.span fontWeight="bold">2 seats</chakra.span> included
<chakra.span fontWeight="bold">
{seatsLimit.STARTER.totalIncluded} seats
</chakra.span>{' '}
included
</Text>,
<HStack key="chats" spacing={1.5}>
<Menu>
@ -56,49 +63,19 @@ export const StarterPlanCard = () => {
colorScheme="orange"
>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount *
selectedChatsLimitIndex
chatsLimit.STARTER.graduatedPrice[selectedChatsLimitIndex]
.totalIncluded
)}
</MenuButton>
<MenuList>
{selectedChatsLimitIndex !== 0 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(0)}>
{parseNumberWithCommas(chatsLimit.STARTER.totalIncluded)}
{chatsLimit.STARTER.graduatedPrice.map((price, index) => (
<MenuItem
key={index}
onClick={() => setSelectedChatsLimitIndex(index)}
>
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 1 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(1)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 2 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(2)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 3 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(3)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 4 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(4)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount * 4
)}
</MenuItem>
)}
))}
</MenuList>
</Menu>{' '}
<Text>chats/mo</Text>
@ -125,50 +102,21 @@ export const StarterPlanCard = () => {
>
{selectedStorageLimitIndex !== undefined
? parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount *
selectedStorageLimitIndex
storageLimit.STARTER.graduatedPrice[
selectedStorageLimitIndex
].totalIncluded
)
: undefined}
</MenuButton>
<MenuList>
{selectedStorageLimitIndex !== 0 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(0)}>
{parseNumberWithCommas(storageLimit.STARTER.totalIncluded)}
{storageLimit.STARTER.graduatedPrice.map((price, index) => (
<MenuItem
key={index}
onClick={() => setSelectedStorageLimitIndex(index)}
>
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 1 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(1)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 2 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(2)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 3 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(3)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 4 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(4)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount * 4
)}
</MenuItem>
)}
))}
</MenuList>
</Menu>{' '}
<Text>GB of storage</Text>
@ -194,12 +142,13 @@ export const StarterPlanCard = () => {
button={
<Button
as={Link}
href={`https://app.typebot.io/register?subscribePlan=${Plan.STARTER}&chats=${selectedChatsLimitIndex}&storage=${selectedStorageLimitIndex}`}
colorScheme="blue"
href={`https://app.typebot.io/register?subscribePlan=${Plan.STARTER}&chats=${selectedChatsLimitIndex}&storage=${selectedStorageLimitIndex}&isYearly=${isYearly}`}
colorScheme="orange"
size="lg"
w="full"
fontWeight="extrabold"
py={{ md: '8' }}
variant="outline"
>
Subscribe now
</Button>

View File

@ -1,39 +1,30 @@
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
DarkMode,
Flex,
Stack,
Box,
Heading,
VStack,
Text,
HStack,
Switch,
Tag,
} from '@chakra-ui/react'
import { Footer } from 'components/common/Footer'
import { Header } from 'components/common/Header/Header'
import { SocialMetaTags } from 'components/common/SocialMetaTags'
import { BackgroundPolygons } from 'components/Homepage/Hero/BackgroundPolygons'
import { PlanComparisonTables } from 'components/PricingPage/PlanComparisonTables'
import { useEffect, useState } from 'react'
import { formatPrice, prices } from '@typebot.io/lib/pricing'
import { useState } from 'react'
import { StripeClimateLogo } from 'assets/logos/StripeClimateLogo'
import { FreePlanCard } from 'components/PricingPage/FreePlanCard'
import { StarterPlanCard } from 'components/PricingPage/StarterPlanCard'
import { ProPlanCard } from 'components/PricingPage/ProPlanCard'
import { TextLink } from 'components/common/TextLink'
import { EnterprisePlanCard } from 'components/PricingPage/EnterprisePlanCard'
import { Faq } from 'components/PricingPage/Faq'
const Pricing = () => {
const [starterPrice, setStarterPrice] = useState('$39')
const [proPrice, setProPrice] = useState('$89')
useEffect(() => {
setStarterPrice(formatPrice(prices.STARTER))
setProPrice(formatPrice(prices.PRO))
}, [])
const [isYearly, setIsYearly] = useState(true)
return (
<Stack overflowX="hidden" bgColor="gray.900">
@ -52,20 +43,31 @@ const Pricing = () => {
</DarkMode>
<VStack spacing={'24'} mt={[20, 32]} w="full">
<Stack align="center" spacing="12" w="full">
<Stack align="center" spacing="12" w="full" px={4}>
<VStack>
<Heading fontSize="6xl">Plans fit for you</Heading>
<Text maxW="900px" fontSize="xl" textAlign="center">
<Heading fontSize={{ base: '4xl', xl: '6xl' }}>
Plans fit for you
</Heading>
<Text
maxW="900px"
textAlign="center"
fontSize={{ base: 'lg', xl: 'xl' }}
>
Whether you&apos;re a{' '}
<Text as="span" color="orange.200" fontWeight="bold">
solo business owner
</Text>{' '}
or a{' '}
</Text>
, a{' '}
<Text as="span" color="blue.200" fontWeight="bold">
growing startup
</Text>{' '}
or a{' '}
<Text as="span" fontWeight="bold">
large company
</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.
, Typebot is here to help you build high-performing chat forms
for the right price. Pay for as little or as much usage as you
need.
</Text>
</VStack>
@ -85,38 +87,41 @@ const Pricing = () => {
</TextLink>
</Text>
</HStack>
<Stack
direction={['column', 'row']}
alignItems={['stretch']}
spacing={10}
px={[4, 0]}
w="full"
maxW="1200px"
>
<FreePlanCard />
<StarterPlanCard />
<ProPlanCard />
<Stack align="flex-end" maxW="1200px" w="full" spacing={4}>
<HStack>
<Text>Monthly</Text>
<Switch
isChecked={isYearly}
onChange={() => setIsYearly(!isYearly)}
/>
<HStack>
<Text>Yearly</Text>
<Tag colorScheme="blue">16% off</Tag>
</HStack>
</HStack>
<Stack
direction={['column', 'row']}
alignItems={['stretch']}
spacing={10}
w="full"
maxW="1200px"
>
<FreePlanCard />
<StarterPlanCard isYearly={isYearly} />
<ProPlanCard isYearly={isYearly} />
</Stack>
</Stack>
<Text fontSize="lg">
Need custom limits? Specific features?{' '}
<TextLink href={'https://typebot.io/enterprise-lead-form'}>
Let&apos;s chat!
</TextLink>
</Text>
<EnterprisePlanCard />
</Stack>
<VStack maxW="1200px" w="full" spacing={[12, 20]} px="4">
<Stack w="full" spacing={10} display={['none', 'flex']}>
<Heading>Compare plans & features</Heading>
<PlanComparisonTables
starterPrice={starterPrice}
proPrice={proPrice}
/>
<PlanComparisonTables />
</Stack>
<VStack w="full" spacing="10">
<Heading textAlign="center">Frequently asked questions</Heading>
<Faq />
</VStack>
<Faq />
</VStack>
</VStack>
</Flex>
@ -125,75 +130,4 @@ const Pricing = () => {
)
}
const Faq = () => {
return (
<Accordion w="full" allowToggle defaultIndex={0}>
<AccordionItem>
<Heading as="h2">
<AccordionButton py="6">
<Box flex="1" textAlign="left" fontSize="2xl">
What happens once I reach the monthly chats limit?
</Box>
<AccordionIcon />
</AccordionButton>
</Heading>
<AccordionPanel pb={4}>
You will receive an email notification once you reached 80% of this
limit. Then, once you reach 100%, the bot will be closed to new users.
Upgrading your limit will automatically reopen the bot.
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<Heading as="h2">
<AccordionButton py="6">
<Box flex="1" textAlign="left" fontSize="2xl">
What happens once I reach the storage limit?
</Box>
<AccordionIcon />
</AccordionButton>
</Heading>
<AccordionPanel pb={4}>
You will receive an email notification once you reached 80% of this
limit. Then, once you reach 100%, your users will still be able to
chat with your bot but their uploads won&apos;t be stored anymore. You
will need to upgrade the limit or free up some space to continue
collecting your users&apos; files.
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<Heading as="h2">
<AccordionButton py="6">
<Box flex="1" textAlign="left" fontSize="2xl">
Why is there no trial?
</Box>
<AccordionIcon />
</AccordionButton>
</Heading>
<AccordionPanel pb={4}>
For now, Typebot offers a Freemium based business model. My goal is to
make sure you have time to create awesome bots and collect valuable
results. If you need advanced features then you can upgrade any time.
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<Heading as="h2">
<AccordionButton py="6">
<Box flex="1" textAlign="left" fontSize="2xl">
If I change my mind, can I get a refund?
</Box>
<AccordionIcon />
</AccordionButton>
</Heading>
<AccordionPanel pb={4}>
Sure! Just{' '}
<TextLink href="mailto:baptiste@typebot.io">
shoot me an email
</TextLink>{' '}
and we&apos;ll figure things out 😀
</AccordionPanel>
</AccordionItem>
</Accordion>
)
}
export default Pricing

View File

@ -3,26 +3,68 @@ import { Plan } from '@typebot.io/prisma'
const infinity = -1
export const priceIds = {
[Plan.STARTER]: {
base: {
monthly: process.env.STRIPE_STARTER_MONTHLY_PRICE_ID,
yearly: process.env.STRIPE_STARTER_YEARLY_PRICE_ID,
},
chats: {
monthly: process.env.STRIPE_STARTER_CHATS_MONTHLY_PRICE_ID,
yearly: process.env.STRIPE_STARTER_CHATS_YEARLY_PRICE_ID,
},
storage: {
monthly: process.env.STRIPE_STARTER_STORAGE_MONTHLY_PRICE_ID,
yearly: process.env.STRIPE_STARTER_STORAGE_YEARLY_PRICE_ID,
},
},
[Plan.PRO]: {
base: {
monthly: process.env.STRIPE_PRO_MONTHLY_PRICE_ID,
yearly: process.env.STRIPE_PRO_YEARLY_PRICE_ID,
},
chats: {
monthly: process.env.STRIPE_PRO_CHATS_MONTHLY_PRICE_ID,
yearly: process.env.STRIPE_PRO_CHATS_YEARLY_PRICE_ID,
},
storage: {
monthly: process.env.STRIPE_PRO_STORAGE_MONTHLY_PRICE_ID,
yearly: process.env.STRIPE_PRO_STORAGE_YEARLY_PRICE_ID,
},
},
}
export const prices = {
[Plan.STARTER]: 39,
[Plan.PRO]: 89,
} as const
export const chatsLimit = {
[Plan.FREE]: { totalIncluded: 300 },
[Plan.FREE]: { totalIncluded: 200 },
[Plan.STARTER]: {
totalIncluded: 2000,
increaseStep: {
amount: 500,
price: 10,
},
graduatedPrice: [
{ totalIncluded: 2000, price: 0 },
{
totalIncluded: 2500,
price: 10,
},
{
totalIncluded: 3000,
price: 20,
},
{
totalIncluded: 3500,
price: 30,
},
],
},
[Plan.PRO]: {
totalIncluded: 10000,
increaseStep: {
amount: 1000,
price: 10,
},
graduatedPrice: [
{ totalIncluded: 10000, price: 0 },
{ totalIncluded: 15000, price: 50 },
{ totalIncluded: 25000, price: 150 },
{ totalIncluded: 50000, price: 400 },
],
},
[Plan.CUSTOM]: {
totalIncluded: 2000,
@ -39,18 +81,38 @@ export const chatsLimit = {
export const storageLimit = {
[Plan.FREE]: { totalIncluded: 0 },
[Plan.STARTER]: {
totalIncluded: 2,
increaseStep: {
amount: 1,
price: 2,
},
graduatedPrice: [
{ totalIncluded: 2, price: 0 },
{
totalIncluded: 3,
price: 2,
},
{
totalIncluded: 4,
price: 4,
},
{
totalIncluded: 5,
price: 6,
},
],
},
[Plan.PRO]: {
totalIncluded: 10,
increaseStep: {
amount: 1,
price: 2,
},
graduatedPrice: [
{ totalIncluded: 10, price: 0 },
{
totalIncluded: 15,
price: 8,
},
{
totalIncluded: 25,
price: 24,
},
{
totalIncluded: 40,
price: 49,
},
],
},
[Plan.CUSTOM]: {
totalIncluded: 2,
@ -86,13 +148,12 @@ export const getChatsLimit = ({
customChatsLimit,
}: Pick<Workspace, 'additionalChatsIndex' | 'plan' | 'customChatsLimit'>) => {
if (customChatsLimit) return customChatsLimit
const { totalIncluded } = chatsLimit[plan]
const increaseStep =
const totalIncluded =
plan === Plan.STARTER || plan === Plan.PRO
? chatsLimit[plan].increaseStep
: { amount: 0 }
? chatsLimit[plan].graduatedPrice[additionalChatsIndex].totalIncluded
: chatsLimit[plan].totalIncluded
if (totalIncluded === infinity) return infinity
return totalIncluded + increaseStep.amount * additionalChatsIndex
return totalIncluded
}
export const getStorageLimit = ({
@ -104,12 +165,11 @@ export const getStorageLimit = ({
'additionalStorageIndex' | 'plan' | 'customStorageLimit'
>) => {
if (customStorageLimit) return customStorageLimit
const { totalIncluded } = storageLimit[plan]
const increaseStep =
const totalIncluded =
plan === Plan.STARTER || plan === Plan.PRO
? storageLimit[plan].increaseStep
: { amount: 0 }
return totalIncluded + increaseStep.amount * additionalStorageIndex
? storageLimit[plan].graduatedPrice[additionalStorageIndex].totalIncluded
: storageLimit[plan].totalIncluded
return totalIncluded
}
export const getSeatsLimit = ({
@ -139,20 +199,15 @@ export const isSeatsLimitReached = ({
export const computePrice = (
plan: Plan,
selectedTotalChatsIndex: number,
selectedTotalStorageIndex: number
selectedTotalStorageIndex: number,
frequency: 'monthly' | 'yearly'
) => {
if (plan !== Plan.STARTER && plan !== Plan.PRO) return
const {
increaseStep: { price: chatsPrice },
} = chatsLimit[plan]
const {
increaseStep: { price: storagePrice },
} = storageLimit[plan]
return (
const price =
prices[plan] +
selectedTotalChatsIndex * chatsPrice +
selectedTotalStorageIndex * storagePrice
)
chatsLimit[plan].graduatedPrice[selectedTotalChatsIndex].price +
storageLimit[plan].graduatedPrice[selectedTotalStorageIndex].price
return frequency === 'monthly' ? price : price - price * 0.16
}
const europeanUnionCountryCodes = [
@ -202,13 +257,15 @@ const europeanUnionExclusiveLanguageCodes = [
'bg',
]
export const guessIfUserIsEuropean = () =>
window.navigator.languages.some((language) => {
export const guessIfUserIsEuropean = () => {
if (typeof window === 'undefined') return false
return window.navigator.languages.some((language) => {
const [languageCode, countryCode] = language.split('-')
return countryCode
? europeanUnionCountryCodes.includes(countryCode)
: europeanUnionExclusiveLanguageCodes.includes(languageCode)
})
}
export const formatPrice = (price: number, currency?: 'eur' | 'usd') => {
const isEuropean = guessIfUserIsEuropean()

View File

@ -1,9 +1,9 @@
import { z } from 'zod'
export const subscriptionSchema = z.object({
additionalChatsIndex: z.number(),
additionalStorageIndex: z.number(),
isYearly: z.boolean(),
currency: z.enum(['eur', 'usd']),
cancelDate: z.date().optional(),
})
export type Subscription = z.infer<typeof subscriptionSchema>