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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { import {
addSubscriptionToWorkspace, addSubscriptionToWorkspace,
cancelSubscription,
createClaimableCustomPlan, createClaimableCustomPlan,
} from '@/test/utils/databaseActions' } from '@/test/utils/databaseActions'
import test, { expect } from '@playwright/test' 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="Free workspace"')
await page.click('text=Settings & Members') await page.click('text=Settings & Members')
await page.click('text=Billing & Usage') 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 expect(page.locator('text="Storage"')).toBeHidden()
await page.getByText('Members', { exact: true }).click() await page.getByText('Members', { exact: true }).click()
await expect( 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="3,500"')
await page.click('button >> text="2"') await page.click('button >> text="2"')
await page.click('button >> text="4"') await page.click('button >> text="4"')
await page.locator('label span').first().click()
await expect(page.locator('text="$73"')).toBeVisible() await expect(page.locator('text="$73"')).toBeVisible()
await page.click('button >> text=Upgrade >> nth=0') await page.click('button >> text=Upgrade >> nth=0')
await page.getByLabel('Company name').fill('Company LLC') 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=$73.00 >> nth=0')).toBeVisible()
await expect(page.locator('text=$30.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 expect(page.locator('text=$4.00 >> nth=0')).toBeVisible()
await addSubscriptionToWorkspace( const stripeId = await addSubscriptionToWorkspace(
planChangeWorkspaceId, planChangeWorkspaceId,
[ [
{ {
price: process.env.STRIPE_STARTER_PRICE_ID, price: process.env.STRIPE_STARTER_MONTHLY_PRICE_ID,
quantity: 1, quantity: 1,
}, },
], ],
@@ -158,8 +160,8 @@ test('plan changes should work', async ({ page }) => {
await page.click('text=Billing & Usage') await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 2,000"')).toBeVisible() await expect(page.locator('text="/ 2,000"')).toBeVisible()
await expect(page.locator('text="/ 2 GB"')).toBeVisible() await expect(page.locator('text="/ 2 GB"')).toBeVisible()
await expect(page.locator('button >> text="2,000"')).toBeVisible() await expect(page.getByText('/ 2,000')).toBeVisible()
await expect(page.locator('button >> text="2"')).toBeVisible() await expect(page.getByText('/ 2 GB')).toBeVisible()
await page.click('button >> text="2,000"') await page.click('button >> text="2,000"')
await page.click('button >> text="3,500"') await page.click('button >> text="3,500"')
await page.click('button >> text="2"') 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="$73"')).toBeVisible()
await expect(page.locator('text="/ 3,500"')).toBeVisible() await expect(page.locator('text="/ 3,500"')).toBeVisible()
await expect(page.locator('text="/ 4 GB"')).toBeVisible() await expect(page.locator('text="/ 4 GB"')).toBeVisible()
await expect(page.locator('button >> text="3,500"')).toBeVisible() await expect(page.getByRole('button', { name: '3,500' })).toBeVisible()
await expect(page.locator('button >> text="4"')).toBeVisible() await expect(page.getByRole('button', { name: '4' })).toBeVisible()
// Upgrade to PRO // Upgrade to PRO
await page.click('button >> text="10,000"') 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="10"')
await page.click('button >> text="12"') await page.click('button >> text="15"')
await expect(page.locator('text="$133"')).toBeVisible() await expect(page.locator('text="$247"')).toBeVisible()
await page.click('button >> text=Upgrade') await page.click('button >> text=Upgrade')
await expect( await expect(
page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0') page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0')
@@ -195,25 +197,20 @@ test('plan changes should work', async ({ page }) => {
page.waitForNavigation(), page.waitForNavigation(),
page.click('text="Billing portal"'), 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 expect(page.locator('text="Add payment method"')).toBeVisible()
await cancelSubscription(stripeId)
// Cancel subscription // Cancel subscription
await page.goto('/typebots') await page.goto('/typebots')
await page.click('text=Settings & Members') await page.click('text=Settings & Members')
await page.click('text=Billing & Usage') 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( await expect(
page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0') page.getByTestId('current-subscription').getByTestId('pro-plan-tag')
).toBeVisible({ timeout: 20 * 1000 }) ).toBeVisible()
await expect(page.getByText('Will be cancelled on')).toBeVisible()
}) })
test('should display invoices', async ({ page }) => { 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=Settings & Members')
await page.click('text=Billing & Usage') await page.click('text=Billing & Usage')
await expect(page.locator('text="Invoices"')).toBeVisible() 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() 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 { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { Plan } from '@typebot.io/prisma' import { Plan } from '@typebot.io/prisma'
import React from 'react' import React from 'react'
import { InvoicesList } from './InvoicesList' import { InvoicesList } from './InvoicesList'
import { StripeClimateLogo } from './StripeClimateLogo'
import { TextLink } from '@/components/TextLink'
import { ChangePlanForm } from './ChangePlanForm' import { ChangePlanForm } from './ChangePlanForm'
import { UsageProgressBars } from './UsageProgressBars' import { UsageProgressBars } from './UsageProgressBars'
import { CurrentSubscriptionSummary } from './CurrentSubscriptionSummary' import { CurrentSubscriptionSummary } from './CurrentSubscriptionSummary'
import { useScopedI18n } from '@/locales'
export const BillingSettingsLayout = () => { export const BillingSettingsLayout = () => {
const scopedT = useScopedI18n('billing')
const { workspace, refreshWorkspace } = useWorkspace() const { workspace, refreshWorkspace } = useWorkspace()
if (!workspace) return null if (!workspace) return null
@@ -19,19 +15,7 @@ export const BillingSettingsLayout = () => {
<Stack spacing="10" w="full"> <Stack spacing="10" w="full">
<UsageProgressBars workspace={workspace} /> <UsageProgressBars workspace={workspace} />
<Stack spacing="4"> <Stack spacing="4">
<CurrentSubscriptionSummary <CurrentSubscriptionSummary workspace={workspace} />
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>
{workspace.plan !== Plan.CUSTOM && {workspace.plan !== Plan.CUSTOM &&
workspace.plan !== Plan.LIFETIME && workspace.plan !== Plan.LIFETIME &&
workspace.plan !== Plan.UNLIMITED && 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 { Plan } from '@typebot.io/prisma'
import { TextLink } from '@/components/TextLink' import { TextLink } from '@/components/TextLink'
import { useToast } from '@/hooks/useToast' import { useToast } from '@/hooks/useToast'
@@ -12,22 +12,33 @@ import { useUser } from '@/features/account/hooks/useUser'
import { StarterPlanPricingCard } from './StarterPlanPricingCard' import { StarterPlanPricingCard } from './StarterPlanPricingCard'
import { ProPlanPricingCard } from './ProPlanPricingCard' import { ProPlanPricingCard } from './ProPlanPricingCard'
import { useScopedI18n } from '@/locales' import { useScopedI18n } from '@/locales'
import { StripeClimateLogo } from './StripeClimateLogo'
type Props = { type Props = {
workspace: Pick<Workspace, 'id' | 'stripeId' | 'plan'> workspace: Workspace
onUpgradeSuccess: () => void onUpgradeSuccess: () => void
} }
export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => { export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
const scopedT = useScopedI18n('billing') const scopedT = useScopedI18n('billing')
const { user } = useUser() const { user } = useUser()
const { showToast } = useToast() const { showToast } = useToast()
const [preCheckoutPlan, setPreCheckoutPlan] = const [preCheckoutPlan, setPreCheckoutPlan] =
useState<PreCheckoutModalProps['selectedSubscription']>() useState<PreCheckoutModalProps['selectedSubscription']>()
const [isYearly, setIsYearly] = useState(true)
const { data } = trpc.billing.getSubscription.useQuery({ const { data } = trpc.billing.getSubscription.useQuery(
workspaceId: workspace.id, {
}) workspaceId: workspace.id,
},
{
onSuccess: ({ subscription }) => {
if (isYearly === false) return
setIsYearly(subscription?.isYearly ?? true)
},
}
)
const { mutate: updateSubscription, isLoading: isUpdatingSubscription } = const { mutate: updateSubscription, isLoading: isUpdatingSubscription } =
trpc.billing.updateSubscription.useMutation({ trpc.billing.updateSubscription.useMutation({
@@ -67,8 +78,9 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
additionalChats: selectedChatsLimitIndex, additionalChats: selectedChatsLimitIndex,
additionalStorage: selectedStorageLimitIndex, additionalStorage: selectedStorageLimitIndex,
currency: currency:
data?.subscription.currency ?? data?.subscription?.currency ??
(guessIfUserIsEuropean() ? 'eur' : 'usd'), (guessIfUserIsEuropean() ? 'eur' : 'usd'),
isYearly,
} as const } as const
if (workspace.stripeId) { if (workspace.stripeId) {
updateSubscription(newSubscription) updateSubscription(newSubscription)
@@ -77,8 +89,19 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
} }
} }
if (data?.subscription?.cancelDate) return null
return ( return (
<Stack spacing={6}> <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 && ( {!workspace.stripeId && (
<ParentModalProvider> <ParentModalProvider>
<PreCheckoutModal <PreCheckoutModal
@@ -89,41 +112,45 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
/> />
</ParentModalProvider> </ParentModalProvider>
)} )}
<HStack alignItems="stretch" spacing="4" w="full"> {data && (
<StarterPlanPricingCard <Stack align="flex-end" spacing={6}>
initialChatsLimitIndex={ <HStack>
workspace?.plan === Plan.STARTER <Text>Monthly</Text>
? data?.subscription.additionalChatsIndex <Switch
: 0 isChecked={isYearly}
} onChange={() => setIsYearly(!isYearly)}
initialStorageLimitIndex={ />
workspace?.plan === Plan.STARTER <HStack>
? data?.subscription.additionalStorageIndex <Text>Yearly</Text>
: 0 <Tag colorScheme="blue">16% off</Tag>
} </HStack>
onPayClick={(props) => </HStack>
handlePayClick({ ...props, plan: Plan.STARTER }) <HStack alignItems="stretch" spacing="4" w="full">
} <StarterPlanPricingCard
isLoading={isUpdatingSubscription} workspace={workspace}
currency={data?.subscription.currency} 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"> <Text color="gray.500">
{scopedT('customLimit.preLink')}{' '} {scopedT('customLimit.preLink')}{' '}
<TextLink href={'https://typebot.io/enterprise-lead-form'} isExternal> <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 { Text, HStack, Stack, Heading } from '@chakra-ui/react'
import { useToast } from '@/hooks/useToast'
import { Plan } from '@typebot.io/prisma' import { Plan } from '@typebot.io/prisma'
import React from 'react' import React from 'react'
import { PlanTag } from './PlanTag' import { PlanTag } from './PlanTag'
@@ -10,25 +9,14 @@ import { useScopedI18n } from '@/locales'
type Props = { type Props = {
workspace: Pick<Workspace, 'id' | 'plan' | 'stripeId'> workspace: Pick<Workspace, 'id' | 'plan' | 'stripeId'>
onCancelSuccess: () => void
} }
export const CurrentSubscriptionSummary = ({ export const CurrentSubscriptionSummary = ({ workspace }: Props) => {
workspace,
onCancelSuccess,
}: Props) => {
const scopedT = useScopedI18n('billing.currentSubscription') const scopedT = useScopedI18n('billing.currentSubscription')
const { showToast } = useToast()
const { mutate: cancelSubscription, isLoading: isCancelling } = const { data } = trpc.billing.getSubscription.useQuery({
trpc.billing.cancelSubscription.useMutation({ workspaceId: workspace.id,
onError: (error) => { })
showToast({
description: error.message,
})
},
onSuccess: onCancelSuccess,
})
const isSubscribed = const isSubscribed =
(workspace.plan === Plan.STARTER || workspace.plan === Plan.PRO) && (workspace.plan === Plan.STARTER || workspace.plan === Plan.PRO) &&
@@ -39,36 +27,15 @@ export const CurrentSubscriptionSummary = ({
<Heading fontSize="3xl">{scopedT('heading')}</Heading> <Heading fontSize="3xl">{scopedT('heading')}</Heading>
<HStack data-testid="current-subscription"> <HStack data-testid="current-subscription">
<Text>{scopedT('subheading')} </Text> <Text>{scopedT('subheading')} </Text>
{isCancelling ? ( <PlanTag plan={workspace.plan} />
<Spinner color="gray.500" size="xs" /> {data?.subscription?.cancelDate && (
) : ( <Text fontSize="sm">
<> (Will be cancelled on {data.subscription.cancelDate.toDateString()})
<PlanTag plan={workspace.plan} /> </Text>
{isSubscribed && (
<Link
as="button"
color="gray.500"
textDecor="underline"
fontSize="sm"
onClick={() =>
cancelSubscription({ workspaceId: workspace.id })
}
>
{scopedT('cancelLink')}
</Link>
)}
</>
)} )}
</HStack> </HStack>
{isSubscribed && !isCancelling && ( {isSubscribed && <BillingPortalButton workspaceId={workspace.id} />}
<>
<Stack spacing="4">
<Text fontSize="sm">{scopedT('billingPortalDescription')}</Text>
<BillingPortalButton workspaceId={workspace.id} />
</Stack>
</>
)}
</Stack> </Stack>
) )
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -103,8 +103,6 @@ export default {
'billing.currentSubscription.heading': 'Subscription', 'billing.currentSubscription.heading': 'Subscription',
'billing.currentSubscription.subheading': 'Current workspace subscription:', 'billing.currentSubscription.subheading': 'Current workspace subscription:',
'billing.currentSubscription.cancelLink': 'Cancel my 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.heading': 'Invoices',
'billing.invoices.empty': 'No invoices found for this workspace.', 'billing.invoices.empty': 'No invoices found for this workspace.',
'billing.invoices.paidAt': 'Paid at', 'billing.invoices.paidAt': 'Paid at',

View File

@@ -109,8 +109,6 @@ export default defineLocale({
'billing.currentSubscription.heading': 'Abonnement', 'billing.currentSubscription.heading': 'Abonnement',
'billing.currentSubscription.subheading': 'Abonnement actuel du workspace :', 'billing.currentSubscription.subheading': 'Abonnement actuel du workspace :',
'billing.currentSubscription.cancelLink': "Annuler l'abonnement", '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.heading': 'Factures',
'billing.invoices.empty': 'Aucune facture trouvée pour ce workspace.', 'billing.invoices.empty': 'Aucune facture trouvée pour ce workspace.',
'billing.invoices.paidAt': 'Payé le', 'billing.invoices.paidAt': 'Payé le',

View File

@@ -109,8 +109,6 @@ export default defineLocale({
'billing.currentSubscription.subheading': 'billing.currentSubscription.subheading':
'Assinatura atual do espaço de trabalho:', 'Assinatura atual do espaço de trabalho:',
'billing.currentSubscription.cancelLink': 'Cancelar minha assinatura', '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.heading': 'Faturas',
'billing.invoices.empty': 'billing.invoices.empty':
'Nenhuma fatura encontrada para este espaço de trabalho.', 'Nenhuma fatura encontrada para este espaço de trabalho.',

View File

@@ -52,6 +52,19 @@ export const addSubscriptionToWorkspace = async (
...metadata, ...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 = ( 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> <details><summary><h4>Stripe</h4></summary>
<p> <p>
| Parameter | Default | Description | | Parameter | Default | Description |
| ---------------------------------- | ------- | --------------------------- | | --------------------------------------- | ------- | ------------------------------------------- |
| NEXT_PUBLIC_STRIPE_PUBLIC_KEY | | Stripe public key | | NEXT_PUBLIC_STRIPE_PUBLIC_KEY | | Stripe public key |
| STRIPE_SECRET_KEY | | Stripe secret key | | STRIPE_SECRET_KEY | | Stripe secret key |
| STRIPE_PRO_PRICE_ID | | Pro plan price id | | STRIPE_STARTER_PRODUCT_ID | | Starter plan product ID |
| STRIPE_STARTER_PRICE_ID | | Starter plan price id | | STRIPE_STARTER_MONTHLY_PRICE_ID | | Starter monthly plan price id |
| STRIPE_ADDITIONAL_CHATS_PRICE_ID | | Additional chats price id | | STRIPE_STARTER_YEARLY_PRICE_ID | | Starter yearly plan price id |
| STRIPE_ADDITIONAL_STORAGE_PRICE_ID | | Additional storage price id | | STRIPE_PRO_PRODUCT_ID | | Pro plan product ID |
| STRIPE_WEBHOOK_SECRET | | Stripe Webhook secret | | 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> </p></details>

View File

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

View File

@@ -2,41 +2,36 @@ import Icon, { IconProps } from '@chakra-ui/icon'
import React from 'react' import React from 'react'
export const Logo = (props: IconProps) => ( export const Logo = (props: IconProps) => (
<Icon <Icon w="50px" h="50px" viewBox="0 0 800 800" {...props}>
viewBox="0 0 500 500" <rect width="800" height="800" rx="80" fill={'#0042DA'} />
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect width="500" height="500" rx="75" fill={'#0042DA'} />
<rect <rect
x="438.709" x="650"
y="170.968" y="293"
width="64.5161" width="85.4704"
height="290.323" height="384.617"
rx="32.2581" rx="20"
transform="rotate(90 438.709 170.968)" transform="rotate(90 650 293)"
fill="#FF8E20" fill="#FF8E20"
/> />
<path <path
fillRule="evenodd" fillRule="evenodd"
clipRule="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" fill="#FF8E20"
/> />
<rect <rect
x="61.29" x="150"
y="332.259" y="506.677"
width="64.5161" width="85.4704"
height="290.323" height="384.617"
rx="32.2581" rx="20"
transform="rotate(-90 61.29 332.259)" transform="rotate(-90 150 506.677)"
fill={'white'} fill={'white'}
/> />
<path <path
fillRule="evenodd" fillRule="evenodd"
clipRule="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'} fill={'white'}
/> />
</Icon> </Icon>

View File

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

View File

@@ -49,7 +49,7 @@ export const IntroducingChatApps = () => {
textAlign="center" textAlign="center"
data-aos="fade" data-aos="fade"
> >
Introducing Conversational Apps Replace your old school forms with chatbots
</Heading> </Heading>
<Text <Text
textAlign="center" 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 Link from 'next/link'
import React from 'react' import React from 'react'
import { PricingCard } from './PricingCard' import { PricingCard } from './PricingCard'
import { chatsLimit } from '@typebot.io/lib/pricing'
export const FreePlanCard = () => ( export const FreePlanCard = () => (
<PricingCard <PricingCard
@@ -13,7 +14,10 @@ export const FreePlanCard = () => (
'Unlimited typebots', 'Unlimited typebots',
<> <>
<Text> <Text>
<chakra.span fontWeight="bold">300 chats</chakra.span> included <chakra.span fontWeight="bold">
{chatsLimit.FREE.totalIncluded}
</chakra.span>{' '}
chats/month
</Text> </Text>
&nbsp; &nbsp;
<Tooltip <Tooltip
@@ -37,7 +41,7 @@ export const FreePlanCard = () => (
as={Link} as={Link}
href="https://app.typebot.io/register" href="https://app.typebot.io/register"
variant="outline" variant="outline"
colorScheme="blue" colorScheme="gray"
size="lg" size="lg"
w="full" w="full"
fontWeight="extrabold" fontWeight="extrabold"

View File

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

View File

@@ -9,7 +9,6 @@ import {
VStack, VStack,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import * as React from 'react' import * as React from 'react'
import { useEffect, useState } from 'react'
import { formatPrice } from '@typebot.io/lib/pricing' import { formatPrice } from '@typebot.io/lib/pricing'
import { CheckCircleIcon } from '../../../assets/icons/CheckCircleIcon' import { CheckCircleIcon } from '../../../assets/icons/CheckCircleIcon'
import { Card, CardProps } from './Card' import { Card, CardProps } from './Card'
@@ -34,12 +33,8 @@ export const PricingCard = ({
...rest ...rest
}: PricingCardProps) => { }: PricingCardProps) => {
const { features, price, name } = data const { features, price, name } = data
const [formattedPrice, setFormattedPrice] = useState(price)
const accentColor = useColorModeValue('blue.500', 'white') const accentColor = useColorModeValue('blue.500', 'white')
const formattedPrice = typeof price === 'number' ? formatPrice(price) : price
useEffect(() => {
setFormattedPrice(typeof price === 'number' ? formatPrice(price) : price)
}, [price])
return ( return (
<Card rounded="xl" bgColor="gray.800" {...rest}> <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 { HelpCircleIcon } from 'assets/icons/HelpCircleIcon'
import { Plan } from '@typebot.io/prisma' import { Plan } from '@typebot.io/prisma'
import Link from 'next/link' import Link from 'next/link'
import React, { useEffect, useState } from 'react' import React, { useState } from 'react'
import { parseNumberWithCommas } from '@typebot.io/lib' 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' import { PricingCard } from './PricingCard'
export const ProPlanCard = () => { type Props = {
const [price, setPrice] = useState(89) isYearly: boolean
}
export const ProPlanCard = ({ isYearly }: Props) => {
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] = const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
useState<number>(0) useState<number>(0)
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] = const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
useState<number>(0) useState<number>(0)
useEffect(() => { const price =
setPrice( computePrice(
computePrice( Plan.PRO,
Plan.PRO, selectedChatsLimitIndex ?? 0,
selectedChatsLimitIndex ?? 0, selectedStorageLimitIndex ?? 0,
selectedStorageLimitIndex ?? 0 isYearly ? 'yearly' : 'monthly'
) ?? NaN ) ?? NaN
)
}, [selectedChatsLimitIndex, selectedStorageLimitIndex])
return ( return (
<PricingCard <PricingCard
@@ -44,7 +49,10 @@ export const ProPlanCard = () => {
featureLabel: 'Everything in Personal, plus:', featureLabel: 'Everything in Personal, plus:',
features: [ features: [
<Text key="seats"> <Text key="seats">
<chakra.span fontWeight="bold">5 seats</chakra.span> included <chakra.span fontWeight="bold">
{seatsLimit.PRO.totalIncluded} seats
</chakra.span>{' '}
included
</Text>, </Text>,
<HStack key="chats" spacing={1.5}> <HStack key="chats" spacing={1.5}>
<Menu> <Menu>
@@ -57,50 +65,20 @@ export const ProPlanCard = () => {
> >
{selectedChatsLimitIndex !== undefined {selectedChatsLimitIndex !== undefined
? parseNumberWithCommas( ? parseNumberWithCommas(
chatsLimit.PRO.totalIncluded + chatsLimit.PRO.graduatedPrice[selectedChatsLimitIndex]
chatsLimit.PRO.increaseStep.amount * .totalIncluded
selectedChatsLimitIndex
) )
: undefined} : undefined}
</MenuButton> </MenuButton>
<MenuList> <MenuList>
{selectedChatsLimitIndex !== 0 && ( {chatsLimit.PRO.graduatedPrice.map((price, index) => (
<MenuItem onClick={() => setSelectedChatsLimitIndex(0)}> <MenuItem
{parseNumberWithCommas(chatsLimit.PRO.totalIncluded)} key={index}
onClick={() => setSelectedChatsLimitIndex(index)}
>
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem> </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> </MenuList>
</Menu>{' '} </Menu>{' '}
<Text>chats/mo</Text> <Text>chats/mo</Text>
@@ -126,50 +104,20 @@ export const ProPlanCard = () => {
> >
{selectedStorageLimitIndex !== undefined {selectedStorageLimitIndex !== undefined
? parseNumberWithCommas( ? parseNumberWithCommas(
storageLimit.PRO.totalIncluded + storageLimit.PRO.graduatedPrice[selectedStorageLimitIndex]
storageLimit.PRO.increaseStep.amount * .totalIncluded
selectedStorageLimitIndex
) )
: undefined} : undefined}
</MenuButton> </MenuButton>
<MenuList> <MenuList>
{selectedStorageLimitIndex !== 0 && ( {storageLimit.PRO.graduatedPrice.map((price, index) => (
<MenuItem onClick={() => setSelectedStorageLimitIndex(0)}> <MenuItem
{parseNumberWithCommas(storageLimit.PRO.totalIncluded)} key={index}
onClick={() => setSelectedStorageLimitIndex(index)}
>
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem> </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> </MenuList>
</Menu>{' '} </Menu>{' '}
<Text>GB of storage</Text> <Text>GB of storage</Text>
@@ -194,7 +142,7 @@ export const ProPlanCard = () => {
button={ button={
<Button <Button
as={Link} 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" colorScheme="blue"
size="lg" size="lg"
w="full" w="full"

View File

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

View File

@@ -1,39 +1,30 @@
import { import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
DarkMode, DarkMode,
Flex, Flex,
Stack, Stack,
Box,
Heading, Heading,
VStack, VStack,
Text, Text,
HStack, HStack,
Switch,
Tag,
} from '@chakra-ui/react' } from '@chakra-ui/react'
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 { SocialMetaTags } from 'components/common/SocialMetaTags' import { SocialMetaTags } from 'components/common/SocialMetaTags'
import { BackgroundPolygons } from 'components/Homepage/Hero/BackgroundPolygons' import { BackgroundPolygons } from 'components/Homepage/Hero/BackgroundPolygons'
import { PlanComparisonTables } from 'components/PricingPage/PlanComparisonTables' import { PlanComparisonTables } from 'components/PricingPage/PlanComparisonTables'
import { useEffect, useState } from 'react' import { useState } from 'react'
import { formatPrice, prices } from '@typebot.io/lib/pricing'
import { StripeClimateLogo } from 'assets/logos/StripeClimateLogo' import { StripeClimateLogo } from 'assets/logos/StripeClimateLogo'
import { FreePlanCard } from 'components/PricingPage/FreePlanCard' import { FreePlanCard } from 'components/PricingPage/FreePlanCard'
import { StarterPlanCard } from 'components/PricingPage/StarterPlanCard' import { StarterPlanCard } from 'components/PricingPage/StarterPlanCard'
import { ProPlanCard } from 'components/PricingPage/ProPlanCard' import { ProPlanCard } from 'components/PricingPage/ProPlanCard'
import { TextLink } from 'components/common/TextLink' import { TextLink } from 'components/common/TextLink'
import { EnterprisePlanCard } from 'components/PricingPage/EnterprisePlanCard'
import { Faq } from 'components/PricingPage/Faq'
const Pricing = () => { const Pricing = () => {
const [starterPrice, setStarterPrice] = useState('$39') const [isYearly, setIsYearly] = useState(true)
const [proPrice, setProPrice] = useState('$89')
useEffect(() => {
setStarterPrice(formatPrice(prices.STARTER))
setProPrice(formatPrice(prices.PRO))
}, [])
return ( return (
<Stack overflowX="hidden" bgColor="gray.900"> <Stack overflowX="hidden" bgColor="gray.900">
@@ -52,20 +43,31 @@ const Pricing = () => {
</DarkMode> </DarkMode>
<VStack spacing={'24'} mt={[20, 32]} w="full"> <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> <VStack>
<Heading fontSize="6xl">Plans fit for you</Heading> <Heading fontSize={{ base: '4xl', xl: '6xl' }}>
<Text maxW="900px" fontSize="xl" textAlign="center"> Plans fit for you
</Heading>
<Text
maxW="900px"
textAlign="center"
fontSize={{ base: 'lg', xl: 'xl' }}
>
Whether you&apos;re a{' '} Whether you&apos;re a{' '}
<Text as="span" color="orange.200" fontWeight="bold"> <Text as="span" color="orange.200" fontWeight="bold">
solo business owner solo business owner
</Text>{' '} </Text>
or a{' '} , a{' '}
<Text as="span" color="blue.200" fontWeight="bold"> <Text as="span" color="blue.200" fontWeight="bold">
growing startup growing startup
</Text>{' '}
or a{' '}
<Text as="span" fontWeight="bold">
large company
</Text> </Text>
, Typebot is here to help you build high-performing bots for the , Typebot is here to help you build high-performing chat forms
right price. Pay for as little or as much usage as you need. for the right price. Pay for as little or as much usage as you
need.
</Text> </Text>
</VStack> </VStack>
@@ -85,38 +87,41 @@ const Pricing = () => {
</TextLink> </TextLink>
</Text> </Text>
</HStack> </HStack>
<Stack <Stack align="flex-end" maxW="1200px" w="full" spacing={4}>
direction={['column', 'row']} <HStack>
alignItems={['stretch']} <Text>Monthly</Text>
spacing={10} <Switch
px={[4, 0]} isChecked={isYearly}
w="full" onChange={() => setIsYearly(!isYearly)}
maxW="1200px" />
> <HStack>
<FreePlanCard /> <Text>Yearly</Text>
<StarterPlanCard /> <Tag colorScheme="blue">16% off</Tag>
<ProPlanCard /> </HStack>
</HStack>
<Stack
direction={['column', 'row']}
alignItems={['stretch']}
spacing={10}
w="full"
maxW="1200px"
>
<FreePlanCard />
<StarterPlanCard isYearly={isYearly} />
<ProPlanCard isYearly={isYearly} />
</Stack>
</Stack> </Stack>
<Text fontSize="lg">
Need custom limits? Specific features?{' '} <EnterprisePlanCard />
<TextLink href={'https://typebot.io/enterprise-lead-form'}>
Let&apos;s chat!
</TextLink>
</Text>
</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 <PlanComparisonTables />
starterPrice={starterPrice}
proPrice={proPrice}
/>
</Stack> </Stack>
<VStack w="full" spacing="10"> <Faq />
<Heading textAlign="center">Frequently asked questions</Heading>
<Faq />
</VStack>
</VStack> </VStack>
</VStack> </VStack>
</Flex> </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 export default Pricing

View File

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

View File

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