2
0

(billing) Automatic usage-based billing (#924)

BREAKING CHANGE: Stripe environment variables simplified. Check out the
new configs to adapt your existing system.

Closes #906





<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
### Summary by CodeRabbit

**New Features:**
- Introduced a usage-based billing system, providing more flexibility
and options for users.
- Integrated with Stripe for a smoother and more secure payment process.
- Enhanced the user interface with improvements to the billing,
workspace, and pricing pages for a more intuitive experience.

**Improvements:**
- Simplified the billing logic, removing additional chats and yearly
billing for a more streamlined user experience.
- Updated email notifications to keep users informed about their usage
and limits.
- Improved pricing and currency formatting for better clarity and
understanding.

**Testing:**
- Updated tests and specifications to ensure the reliability of new
features and improvements.

**Note:** These changes aim to provide a more flexible and user-friendly
billing system, with clearer pricing and improved notifications. Users
should find the new system more intuitive and easier to navigate.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Baptiste Arnaud
2023-10-17 08:03:30 +02:00
committed by GitHub
parent a8c2deb258
commit 797751b418
55 changed files with 1589 additions and 1497 deletions

View File

@@ -4,7 +4,6 @@ import { TRPCError } from '@trpc/server'
import { Plan } from '@typebot.io/prisma'
import Stripe from 'stripe'
import { z } from 'zod'
import { parseSubscriptionItems } from '../helpers/parseSubscriptionItems'
import { isAdminWriteWorkspaceForbidden } from '@/features/workspace/helpers/isAdminWriteWorkspaceForbidden'
import { env } from '@typebot.io/env'
@@ -26,14 +25,12 @@ export const createCheckoutSession = authenticatedProcedure
currency: z.enum(['usd', 'eur']),
plan: z.enum([Plan.STARTER, Plan.PRO]),
returnUrl: z.string(),
additionalChats: z.number(),
vat: z
.object({
type: z.string(),
value: z.string(),
})
.optional(),
isYearly: z.boolean(),
})
)
.output(
@@ -43,17 +40,7 @@ export const createCheckoutSession = authenticatedProcedure
)
.mutation(
async ({
input: {
vat,
email,
company,
workspaceId,
currency,
plan,
returnUrl,
additionalChats,
isYearly,
},
input: { vat, email, company, workspaceId, currency, plan, returnUrl },
ctx: { user },
}) => {
if (!env.STRIPE_SECRET_KEY)
@@ -116,8 +103,6 @@ export const createCheckoutSession = authenticatedProcedure
currency,
plan,
returnUrl,
additionalChats,
isYearly,
})
if (!checkoutUrl)
@@ -138,22 +123,12 @@ type Props = {
currency: 'usd' | 'eur'
plan: 'STARTER' | 'PRO'
returnUrl: string
additionalChats: number
isYearly: boolean
userId: string
}
export const createCheckoutSessionUrl =
(stripe: Stripe) =>
async ({
customerId,
workspaceId,
currency,
plan,
returnUrl,
additionalChats,
isYearly,
}: Props) => {
async ({ customerId, workspaceId, currency, plan, returnUrl }: Props) => {
const session = await stripe.checkout.sessions.create({
success_url: `${returnUrl}?stripe=${plan}&success=true`,
cancel_url: `${returnUrl}?stripe=cancel`,
@@ -167,12 +142,25 @@ export const createCheckoutSessionUrl =
metadata: {
workspaceId,
plan,
additionalChats,
},
currency,
billing_address_collection: 'required',
automatic_tax: { enabled: true },
line_items: parseSubscriptionItems(plan, additionalChats, isYearly),
line_items: [
{
price:
plan === 'STARTER'
? env.STRIPE_STARTER_PRICE_ID
: env.STRIPE_PRO_PRICE_ID,
quantity: 1,
},
{
price:
plan === 'STARTER'
? env.STRIPE_STARTER_CHATS_PRICE_ID
: env.STRIPE_PRO_CHATS_PRICE_ID,
},
],
})
return session.url

View File

@@ -5,7 +5,6 @@ import Stripe from 'stripe'
import { z } from 'zod'
import { subscriptionSchema } from '@typebot.io/schemas/features/billing/subscription'
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
import { priceIds } from '@typebot.io/lib/api/pricing'
import { env } from '@typebot.io/env'
export const getSubscription = authenticatedProcedure
@@ -75,15 +74,14 @@ export const getSubscription = authenticatedProcedure
return {
subscription: {
currentBillingPeriod:
subscriptionSchema.shape.currentBillingPeriod.parse({
start: new Date(currentSubscription.current_period_start),
end: new Date(currentSubscription.current_period_end),
}),
status: subscriptionSchema.shape.status.parse(
currentSubscription.status
),
isYearly: currentSubscription.items.data.some((item) => {
return (
priceIds.STARTER.chats.yearly === item.price.id ||
priceIds.PRO.chats.yearly === item.price.id
)
}),
currency: currentSubscription.currency as 'usd' | 'eur',
cancelDate: currentSubscription.cancel_at
? new Date(currentSubscription.cancel_at * 1000)
@@ -91,8 +89,3 @@ export const getSubscription = authenticatedProcedure
},
}
})
export const chatPriceIds = [priceIds.STARTER.chats.monthly]
.concat(priceIds.STARTER.chats.yearly)
.concat(priceIds.PRO.chats.monthly)
.concat(priceIds.PRO.chats.yearly)

View File

@@ -3,6 +3,8 @@ import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
import { env } from '@typebot.io/env'
import Stripe from 'stripe'
export const getUsage = authenticatedProcedure
.meta({
@@ -19,13 +21,15 @@ export const getUsage = authenticatedProcedure
workspaceId: z.string(),
})
)
.output(z.object({ totalChatsUsed: z.number() }))
.output(z.object({ totalChatsUsed: z.number(), resetsAt: z.date() }))
.query(async ({ input: { workspaceId }, ctx: { user } }) => {
const workspace = await prisma.workspace.findFirst({
where: {
id: workspaceId,
},
select: {
stripeId: true,
plan: true,
members: {
select: {
userId: true,
@@ -42,19 +46,63 @@ export const getUsage = authenticatedProcedure
message: 'Workspace not found',
})
const now = new Date()
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
if (
!env.STRIPE_SECRET_KEY ||
!workspace.stripeId ||
(workspace.plan !== 'STARTER' && workspace.plan !== 'PRO')
) {
const now = new Date()
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const totalChatsUsed = await prisma.result.count({
where: {
typebotId: { in: workspace.typebots.map((typebot) => typebot.id) },
hasStarted: true,
createdAt: {
gte: firstDayOfMonth,
},
},
})
const firstDayOfNextMonth = new Date(
firstDayOfMonth.getFullYear(),
firstDayOfMonth.getMonth() + 1,
1
)
return { totalChatsUsed, resetsAt: firstDayOfNextMonth }
}
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
const subscriptions = await stripe.subscriptions.list({
customer: workspace.stripeId,
})
const currentSubscription = subscriptions.data
.filter((sub) => ['past_due', 'active'].includes(sub.status))
.sort((a, b) => a.created - b.created)
.shift()
if (!currentSubscription)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `No subscription found on workspace: ${workspaceId}`,
})
const totalChatsUsed = await prisma.result.count({
where: {
typebotId: { in: workspace.typebots.map((typebot) => typebot.id) },
hasStarted: true,
createdAt: {
gte: firstDayOfMonth,
gte: new Date(currentSubscription.current_period_start * 1000),
},
},
})
return {
totalChatsUsed,
resetsAt: new Date(currentSubscription.current_period_end * 1000),
}
})

View File

@@ -64,12 +64,12 @@ export const listInvoices = authenticatedProcedure
.filter(
(invoice) => isDefined(invoice.invoice_pdf) && isDefined(invoice.id)
)
.map((i) => ({
id: i.number as string,
url: i.invoice_pdf as string,
amount: i.subtotal,
currency: i.currency,
date: i.status_transitions.paid_at,
.map((invoice) => ({
id: invoice.number as string,
url: invoice.invoice_pdf as string,
amount: invoice.subtotal,
currency: invoice.currency,
date: invoice.status_transitions.paid_at,
})),
}
})

View File

@@ -5,15 +5,10 @@ import { TRPCError } from '@trpc/server'
import { Plan } from '@typebot.io/prisma'
import { workspaceSchema } from '@typebot.io/schemas'
import Stripe from 'stripe'
import { isDefined } from '@typebot.io/lib'
import { z } from 'zod'
import { getChatsLimit } from '@typebot.io/lib/pricing'
import { chatPriceIds } from './getSubscription'
import { createCheckoutSessionUrl } from './createCheckoutSession'
import { isAdminWriteWorkspaceForbidden } from '@/features/workspace/helpers/isAdminWriteWorkspaceForbidden'
import { getUsage } from '@typebot.io/lib/api/getUsage'
import { env } from '@typebot.io/env'
import { priceIds } from '@typebot.io/lib/api/pricing'
export const updateSubscription = authenticatedProcedure
.meta({
@@ -30,9 +25,7 @@ export const updateSubscription = authenticatedProcedure
returnUrl: z.string(),
workspaceId: z.string(),
plan: z.enum([Plan.STARTER, Plan.PRO]),
additionalChats: z.number(),
currency: z.enum(['usd', 'eur']),
isYearly: z.boolean(),
})
)
.output(
@@ -43,14 +36,7 @@ export const updateSubscription = authenticatedProcedure
)
.mutation(
async ({
input: {
workspaceId,
plan,
additionalChats,
currency,
isYearly,
returnUrl,
},
input: { workspaceId, plan, currency, returnUrl },
ctx: { user },
}) => {
if (!env.STRIPE_SECRET_KEY)
@@ -81,6 +67,7 @@ export const updateSubscription = authenticatedProcedure
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2022-11-15',
})
@@ -91,39 +78,57 @@ export const updateSubscription = authenticatedProcedure
})
const subscription = data[0] as Stripe.Subscription | undefined
const currentPlanItemId = subscription?.items.data.find((item) =>
[env.STRIPE_STARTER_PRODUCT_ID, env.STRIPE_PRO_PRODUCT_ID].includes(
item.price.product.toString()
[env.STRIPE_STARTER_PRICE_ID, env.STRIPE_PRO_PRICE_ID].includes(
item.price.id
)
)?.id
const currentAdditionalChatsItemId = subscription?.items.data.find(
(item) => chatPriceIds.includes(item.price.id)
const currentUsageItemId = subscription?.items.data.find(
(item) =>
item.price.id === env.STRIPE_STARTER_CHATS_PRICE_ID ||
item.price.id === env.STRIPE_PRO_CHATS_PRICE_ID
)?.id
const frequency = isYearly ? 'yearly' : 'monthly'
const items = [
{
id: currentPlanItemId,
price: priceIds[plan].base[frequency],
price:
plan === Plan.STARTER
? env.STRIPE_STARTER_PRICE_ID
: env.STRIPE_PRO_PRICE_ID,
quantity: 1,
},
additionalChats === 0 && !currentAdditionalChatsItemId
? undefined
: {
id: currentAdditionalChatsItemId,
price: priceIds[plan].chats[frequency],
quantity: getChatsLimit({
plan,
additionalChatsIndex: additionalChats,
customChatsLimit: null,
}),
deleted: subscription ? additionalChats === 0 : undefined,
},
].filter(isDefined)
{
id: currentUsageItemId,
price:
plan === Plan.STARTER
? env.STRIPE_STARTER_CHATS_PRICE_ID
: env.STRIPE_PRO_CHATS_PRICE_ID,
},
]
if (subscription) {
if (plan === 'STARTER') {
const totalChatsUsed = await prisma.result.count({
where: {
typebot: { workspaceId },
hasStarted: true,
createdAt: {
gte: new Date(subscription.current_period_start * 1000),
},
},
})
if (totalChatsUsed >= 4000) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
"You have collected more than 4000 chats during this billing cycle. You can't downgrade to the Starter.",
})
}
}
await stripe.subscriptions.update(subscription.id, {
items,
proration_behavior: 'always_invoice',
proration_behavior:
plan === 'PRO' ? 'always_invoice' : 'create_prorations',
})
} else {
const checkoutUrl = await createCheckoutSessionUrl(stripe)({
@@ -133,31 +138,16 @@ export const updateSubscription = authenticatedProcedure
currency,
plan,
returnUrl,
additionalChats,
isYearly,
})
return { checkoutUrl }
}
let isQuarantined = workspace.isQuarantined
if (isQuarantined) {
const newChatsLimit = getChatsLimit({
plan,
additionalChatsIndex: additionalChats,
customChatsLimit: null,
})
const { totalChatsUsed } = await getUsage(prisma)(workspaceId)
if (totalChatsUsed < newChatsLimit) isQuarantined = false
}
const updatedWorkspace = await prisma.workspace.update({
where: { id: workspaceId },
data: {
plan,
additionalChatsIndex: additionalChats,
isQuarantined,
isQuarantined: false,
},
})
@@ -168,7 +158,6 @@ export const updateSubscription = authenticatedProcedure
userId: user.id,
data: {
plan,
additionalChatsIndex: additionalChats,
},
},
])

View File

@@ -116,29 +116,25 @@ test('plan changes should work', async ({ page }) => {
await page.click('text=Plan Change Workspace')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await page.click('button >> text="2,000"')
await page.click('button >> text="3,500"')
await page.click('button >> text="2"')
await page.click('button >> text="4"')
await page.locator('label span').first().click()
await expect(page.locator('text="$73"')).toBeVisible()
await expect(page.locator('text="$39"')).toBeVisible()
await page.click('button >> text=Upgrade >> nth=0')
await page.getByLabel('Company name').fill('Company LLC')
await page.getByRole('button', { name: 'Go to checkout' }).click()
await page.waitForNavigation()
expect(page.url()).toContain('https://checkout.stripe.com')
await expect(page.locator('text=$73.00 >> nth=0')).toBeVisible()
await expect(page.locator('text=$30.00 >> nth=0')).toBeVisible()
await expect(page.locator('text=$4.00 >> nth=0')).toBeVisible()
await expect(page.locator('text=$39 >> nth=0')).toBeVisible()
const stripeId = await addSubscriptionToWorkspace(
planChangeWorkspaceId,
[
{
price: env.STRIPE_STARTER_MONTHLY_PRICE_ID,
price: env.STRIPE_STARTER_PRICE_ID,
quantity: 1,
},
{
price: env.STRIPE_STARTER_CHATS_PRICE_ID,
},
],
{ plan: Plan.STARTER, additionalChatsIndex: 0 }
{ plan: Plan.STARTER }
)
// Update plan with additional quotas
@@ -147,30 +143,9 @@ test('plan changes should work', async ({ page }) => {
await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 2,000"')).toBeVisible()
await expect(page.getByText('/ 2,000')).toBeVisible()
await page.click('button >> text="2,000"')
await page.click('button >> text="3,500"')
await page.click('button >> text="2"')
await page.click('button >> text="4"')
await expect(page.locator('text="$73"')).toBeVisible()
await page.click('button >> text=Update')
await expect(
page.locator(
'text="Workspace STARTER plan successfully updated 🎉" >> nth=0'
)
).toBeVisible()
await page.click('text="Members"')
await page.click('text="Billing & Usage"')
await expect(page.locator('text="$73"')).toBeVisible()
await expect(page.locator('text="/ 3,500"')).toBeVisible()
await expect(page.getByRole('button', { name: '3,500' })).toBeVisible()
await expect(page.getByRole('button', { name: '4' })).toBeVisible()
// Upgrade to PRO
await page.click('button >> text="10,000"')
await page.click('button >> text="25,000"')
await page.click('button >> text="10"')
await page.click('button >> text="15"')
await expect(page.locator('text="$247"')).toBeVisible()
await expect(page.locator('text="$89"')).toBeVisible()
await page.click('button >> text=Upgrade')
await expect(
page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0')
@@ -181,11 +156,12 @@ test('plan changes should work', async ({ page }) => {
page.waitForNavigation(),
page.click('text="Billing portal"'),
])
await expect(page.getByText('$247.00 per month')).toBeVisible({
await expect(page.getByText('$39.00')).toBeVisible({
timeout: 10000,
})
await expect(page.getByText('$50.00')).toBeVisible({
timeout: 10000,
})
await expect(page.getByText('(×25000)')).toBeVisible()
await expect(page.getByText('(×15)')).toBeVisible()
await expect(page.locator('text="Add payment method"')).toBeVisible()
await cancelSubscription(stripeId)
@@ -212,9 +188,8 @@ test('should display invoices', async ({ page }) => {
await page.click('text=Billing & Usage')
await expect(page.locator('text="Invoices"')).toBeVisible()
await expect(page.locator('tr')).toHaveCount(4)
await expect(page.locator('text="$39.00"')).toBeVisible()
await expect(page.locator('text="$34.00"')).toBeVisible()
await expect(page.locator('text="$174.00"')).toBeVisible()
await expect(page.getByText('$39.00')).toBeVisible()
await expect(page.getByText('$50.00')).toBeVisible()
})
test('custom plans should work', async ({ page }) => {

View File

@@ -1,9 +1,8 @@
import { Stack, HStack, Text, Switch, Tag } from '@chakra-ui/react'
import { Stack, HStack, Text } from '@chakra-ui/react'
import { Plan } from '@typebot.io/prisma'
import { TextLink } from '@/components/TextLink'
import { useToast } from '@/hooks/useToast'
import { trpc } from '@/lib/trpc'
import { guessIfUserIsEuropean } from '@typebot.io/lib/pricing'
import { Workspace } from '@typebot.io/schemas'
import { PreCheckoutModal, PreCheckoutModalProps } from './PreCheckoutModal'
import { useState } from 'react'
@@ -13,6 +12,7 @@ import { StarterPlanPricingCard } from './StarterPlanPricingCard'
import { ProPlanPricingCard } from './ProPlanPricingCard'
import { useScopedI18n } from '@/locales'
import { StripeClimateLogo } from './StripeClimateLogo'
import { guessIfUserIsEuropean } from '@typebot.io/lib/billing/guessIfUserIsEuropean'
type Props = {
workspace: Workspace
@@ -26,21 +26,12 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => {
const { showToast } = useToast()
const [preCheckoutPlan, setPreCheckoutPlan] =
useState<PreCheckoutModalProps['selectedSubscription']>()
const [isYearly, setIsYearly] = useState(true)
const trpcContext = trpc.useContext()
const { data, refetch } = trpc.billing.getSubscription.useQuery(
{
workspaceId: workspace.id,
},
{
onSuccess: ({ subscription }) => {
if (isYearly === false) return
setIsYearly(subscription?.isYearly ?? true)
},
}
)
const { data, refetch } = trpc.billing.getSubscription.useQuery({
workspaceId: workspace.id,
})
const { mutate: updateSubscription, isLoading: isUpdatingSubscription } =
trpc.billing.updateSubscription.useMutation({
@@ -65,23 +56,15 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => {
},
})
const handlePayClick = async ({
plan,
selectedChatsLimitIndex,
}: {
plan: 'STARTER' | 'PRO'
selectedChatsLimitIndex: number
}) => {
if (!user || selectedChatsLimitIndex === undefined) return
const handlePayClick = async (plan: 'STARTER' | 'PRO') => {
if (!user) return
const newSubscription = {
plan,
workspaceId: workspace.id,
additionalChats: selectedChatsLimitIndex,
currency:
data?.subscription?.currency ??
(guessIfUserIsEuropean() ? 'eur' : 'usd'),
isYearly,
} as const
if (workspace.stripeId) {
updateSubscription({
@@ -122,26 +105,11 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => {
)}
{data && (
<Stack align="flex-end" spacing={6}>
<HStack>
<Text>Monthly</Text>
<Switch
isChecked={isYearly}
onChange={() => setIsYearly(!isYearly)}
/>
<HStack>
<Text>Yearly</Text>
<Tag colorScheme="blue">16% off</Tag>
</HStack>
</HStack>
<HStack alignItems="stretch" spacing="4" w="full">
{excludedPlans?.includes('STARTER') ? null : (
<StarterPlanPricingCard
workspace={workspace}
currentSubscription={{ isYearly: data.subscription?.isYearly }}
onPayClick={(props) =>
handlePayClick({ ...props, plan: Plan.STARTER })
}
isYearly={isYearly}
currentPlan={workspace.plan}
onPayClick={() => handlePayClick(Plan.STARTER)}
isLoading={isUpdatingSubscription}
currency={data.subscription?.currency}
/>
@@ -149,12 +117,8 @@ export const ChangePlanForm = ({ workspace, excludedPlans }: Props) => {
{excludedPlans?.includes('PRO') ? null : (
<ProPlanPricingCard
workspace={workspace}
currentSubscription={{ isYearly: data.subscription?.isYearly }}
onPayClick={(props) =>
handlePayClick({ ...props, plan: Plan.PRO })
}
isYearly={isYearly}
currentPlan={workspace.plan}
onPayClick={() => handlePayClick(Plan.PRO)}
isLoading={isUpdatingSubscription}
currency={data.subscription?.currency}
/>

View File

@@ -0,0 +1,91 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Stack,
ModalFooter,
Heading,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
} from '@chakra-ui/react'
import { proChatTiers } from '@typebot.io/lib/billing/constants'
import { formatPrice } from '@typebot.io/lib/billing/formatPrice'
type Props = {
isOpen: boolean
onClose: () => void
}
export const ChatsProTiersModal = ({ isOpen, onClose }: Props) => {
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>
<Heading size="lg">Chats pricing table</Heading>
</ModalHeader>
<ModalCloseButton />
<ModalBody as={Stack} spacing="6">
<TableContainer>
<Table variant="simple">
<Thead>
<Tr>
<Th isNumeric>Max chats</Th>
<Th isNumeric>Price per month</Th>
<Th isNumeric>Price per 1k chats</Th>
</Tr>
</Thead>
<Tbody>
{proChatTiers.map((tier, index) => {
const pricePerMonth =
proChatTiers
.slice(0, index + 1)
.reduce(
(acc, slicedTier) =>
acc + (slicedTier.flat_amount ?? 0),
0
) / 100
return (
<Tr key={tier.up_to}>
<Td isNumeric>
{tier.up_to === 'inf'
? '2,000,000+'
: tier.up_to.toLocaleString()}
</Td>
<Td isNumeric>
{index === 0 ? 'included' : formatPrice(pricePerMonth)}
</Td>
<Td isNumeric>
{index === proChatTiers.length - 1
? formatPrice(4.42, { maxFractionDigits: 2 })
: index === 0
? 'included'
: formatPrice(
(((pricePerMonth * 100) /
((tier.up_to as number) -
(proChatTiers.at(0)?.up_to as number))) *
1000) /
100,
{ maxFractionDigits: 2 }
)}
</Td>
</Tr>
)
})}
</Tbody>
</Table>
</TableContainer>
</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
)
}

View File

@@ -12,8 +12,8 @@ type FeaturesListProps = { features: (string | JSX.Element)[] } & ListProps
export const FeaturesList = ({ features, ...props }: FeaturesListProps) => (
<UnorderedList listStyleType="none" spacing={2} {...props}>
{features.map((feat, idx) => (
<Flex as={ListItem} key={idx} alignItems="center">
<ListIcon as={CheckIcon} />
<Flex as={ListItem} key={idx}>
<ListIcon as={CheckIcon} mt="1.5" />
{feat}
</Flex>
))}

View File

@@ -25,9 +25,7 @@ export type PreCheckoutModalProps = {
| {
plan: 'STARTER' | 'PRO'
workspaceId: string
additionalChats: number
currency: 'eur' | 'usd'
isYearly: boolean
}
| undefined
existingCompany?: string

View File

@@ -3,226 +3,152 @@ import {
Heading,
chakra,
HStack,
Menu,
MenuButton,
Button,
MenuList,
MenuItem,
Text,
Tooltip,
Flex,
Tag,
useColorModeValue,
useDisclosure,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from '@/components/icons'
import { Plan } from '@typebot.io/prisma'
import { useEffect, useState } from 'react'
import { isDefined, parseNumberWithCommas } from '@typebot.io/lib'
import {
chatsLimit,
computePrice,
formatPrice,
getChatsLimit,
} from '@typebot.io/lib/pricing'
import { FeaturesList } from './FeaturesList'
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
import { useI18n, useScopedI18n } from '@/locales'
import { Workspace } from '@typebot.io/schemas'
import { formatPrice } from '@typebot.io/lib/billing/formatPrice'
import { ChatsProTiersModal } from './ChatsProTiersModal'
import { prices } from '@typebot.io/lib/billing/constants'
type Props = {
workspace: Pick<
Workspace,
| 'additionalChatsIndex'
| 'plan'
| 'customChatsLimit'
| 'customStorageLimit'
| 'stripeId'
>
currentSubscription: {
isYearly?: boolean
}
currentPlan: Plan
currency?: 'usd' | 'eur'
isLoading: boolean
isYearly: boolean
onPayClick: (props: { selectedChatsLimitIndex: number }) => void
onPayClick: () => void
}
export const ProPlanPricingCard = ({
workspace,
currentSubscription,
currentPlan,
currency,
isLoading,
isYearly,
onPayClick,
}: Props) => {
const t = useI18n()
const scopedT = useScopedI18n('billing.pricingCard')
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
useState<number>()
useEffect(() => {
if (isDefined(selectedChatsLimitIndex)) return
if (workspace.plan !== Plan.PRO) {
setSelectedChatsLimitIndex(0)
return
}
setSelectedChatsLimitIndex(workspace.additionalChatsIndex ?? 0)
}, [selectedChatsLimitIndex, workspace.additionalChatsIndex, workspace.plan])
const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined
const isCurrentPlan =
chatsLimit[Plan.PRO].graduatedPrice[selectedChatsLimitIndex ?? 0]
.totalIncluded === workspaceChatsLimit &&
isYearly === currentSubscription?.isYearly
const { isOpen, onOpen, onClose } = useDisclosure()
const getButtonLabel = () => {
if (selectedChatsLimitIndex === undefined) return ''
if (workspace?.plan === Plan.PRO) {
if (isCurrentPlan) return scopedT('upgradeButton.current')
if (selectedChatsLimitIndex !== workspace.additionalChatsIndex)
return t('update')
}
if (currentPlan === Plan.PRO) return scopedT('upgradeButton.current')
return t('upgrade')
}
const handlePayClick = async () => {
if (selectedChatsLimitIndex === undefined) return
onPayClick({
selectedChatsLimitIndex,
})
}
const price =
computePrice(
Plan.PRO,
selectedChatsLimitIndex ?? 0,
isYearly ? 'yearly' : 'monthly'
) ?? NaN
return (
<Flex
p="6"
pos="relative"
h="full"
flexDir="column"
flex="1"
flexShrink={0}
borderWidth="1px"
borderColor={useColorModeValue('blue.500', 'blue.300')}
rounded="lg"
>
<Flex justifyContent="center">
<Tag
pos="absolute"
top="-10px"
colorScheme="blue"
bg={useColorModeValue('blue.500', 'blue.400')}
variant="solid"
fontWeight="semibold"
style={{ marginTop: 0 }}
>
{scopedT('pro.mostPopularLabel')}
</Tag>
</Flex>
<Stack justifyContent="space-between" h="full">
<Stack spacing="4" mt={2}>
<Heading fontSize="2xl">
{scopedT('heading', {
plan: (
<chakra.span color={useColorModeValue('blue.400', 'blue.300')}>
Pro
</chakra.span>
),
})}
</Heading>
<Text>{scopedT('pro.description')}</Text>
</Stack>
<Stack spacing="4">
<Heading>
{formatPrice(price, currency)}
<chakra.span fontSize="md">{scopedT('perMonth')}</chakra.span>
</Heading>
<Text fontWeight="bold">
<Tooltip
label={
<FeaturesList
features={[
scopedT('starter.brandingRemoved'),
scopedT('starter.fileUploadBlock'),
scopedT('starter.createFolders'),
]}
spacing="0"
/>
}
hasArrow
placement="top"
>
<chakra.span textDecoration="underline" cursor="pointer">
{scopedT('pro.everythingFromStarter')}
</chakra.span>
</Tooltip>
{scopedT('plus')}
</Text>
<FeaturesList
features={[
scopedT('pro.includedSeats'),
<HStack key="test">
<Text>
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronLeftIcon transform="rotate(-90deg)" />}
size="sm"
isLoading={selectedChatsLimitIndex === undefined}
>
{selectedChatsLimitIndex !== undefined
? parseNumberWithCommas(
chatsLimit.PRO.graduatedPrice[
selectedChatsLimitIndex
].totalIncluded
)
: undefined}
</MenuButton>
<MenuList>
{chatsLimit.PRO.graduatedPrice.map((price, index) => (
<MenuItem
key={index}
onClick={() => setSelectedChatsLimitIndex(index)}
>
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem>
))}
</MenuList>
</Menu>{' '}
{scopedT('chatsPerMonth')}
</Text>
<MoreInfoTooltip>{scopedT('chatsTooltip')}</MoreInfoTooltip>
</HStack>,
scopedT('pro.whatsAppIntegration'),
scopedT('pro.customDomains'),
scopedT('pro.analytics'),
]}
/>
<Stack spacing={3}>
{isYearly && workspace.stripeId && !isCurrentPlan && (
<Heading mt="0" fontSize="md">
You pay {formatPrice(price * 12, currency)} / year
<>
<ChatsProTiersModal isOpen={isOpen} onClose={onClose} />{' '}
<Flex
p="6"
pos="relative"
h="full"
flexDir="column"
flex="1"
flexShrink={0}
borderWidth="1px"
borderColor={useColorModeValue('blue.500', 'blue.300')}
rounded="lg"
>
<Flex justifyContent="center">
<Tag
pos="absolute"
top="-10px"
colorScheme="blue"
bg={useColorModeValue('blue.500', 'blue.400')}
variant="solid"
fontWeight="semibold"
style={{ marginTop: 0 }}
>
{scopedT('pro.mostPopularLabel')}
</Tag>
</Flex>
<Stack justifyContent="space-between" h="full">
<Stack spacing="4" mt={2}>
<Heading fontSize="2xl">
{scopedT('heading', {
plan: (
<chakra.span
color={useColorModeValue('blue.400', 'blue.300')}
>
Pro
</chakra.span>
),
})}
</Heading>
<Text>{scopedT('pro.description')}</Text>
</Stack>
<Stack spacing="8">
<Stack spacing="4">
<Heading>
{formatPrice(prices.PRO, { currency })}
<chakra.span fontSize="md">{scopedT('perMonth')}</chakra.span>
</Heading>
)}
<Text fontWeight="bold">
<Tooltip
label={
<FeaturesList
features={[
scopedT('starter.brandingRemoved'),
scopedT('starter.fileUploadBlock'),
scopedT('starter.createFolders'),
]}
spacing="0"
/>
}
hasArrow
placement="top"
>
<chakra.span textDecoration="underline" cursor="pointer">
{scopedT('pro.everythingFromStarter')}
</chakra.span>
</Tooltip>
{scopedT('plus')}
</Text>
<FeaturesList
features={[
scopedT('pro.includedSeats'),
<Stack key="starter-chats" spacing={1}>
<HStack key="test">
<Text>10,000 {scopedT('chatsPerMonth')}</Text>
<MoreInfoTooltip>
{scopedT('chatsTooltip')}
</MoreInfoTooltip>
</HStack>
<Text
fontSize="sm"
color={useColorModeValue('gray.500', 'gray.400')}
>
Extra chats:{' '}
<Button size="xs" variant="outline" onClick={onOpen}>
See tiers
</Button>
</Text>
</Stack>,
scopedT('pro.whatsAppIntegration'),
scopedT('pro.customDomains'),
scopedT('pro.analytics'),
]}
/>
</Stack>
<Button
colorScheme="blue"
variant="outline"
onClick={handlePayClick}
onClick={onPayClick}
isLoading={isLoading}
isDisabled={isCurrentPlan}
isDisabled={currentPlan === Plan.PRO}
>
{getButtonLabel()}
</Button>
</Stack>
</Stack>
</Stack>
</Flex>
</Flex>
</>
)
}

View File

@@ -3,174 +3,96 @@ import {
Heading,
chakra,
HStack,
Menu,
MenuButton,
Button,
MenuList,
MenuItem,
Text,
useColorModeValue,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from '@/components/icons'
import { Plan } from '@typebot.io/prisma'
import { useEffect, useState } from 'react'
import { isDefined, parseNumberWithCommas } from '@typebot.io/lib'
import {
chatsLimit,
computePrice,
formatPrice,
getChatsLimit,
} from '@typebot.io/lib/pricing'
import { FeaturesList } from './FeaturesList'
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
import { useI18n, useScopedI18n } from '@/locales'
import { Workspace } from '@typebot.io/schemas'
import { formatPrice } from '@typebot.io/lib/billing/formatPrice'
import { prices } from '@typebot.io/lib/billing/constants'
type Props = {
workspace: Pick<
Workspace,
| 'additionalChatsIndex'
| 'plan'
| 'customChatsLimit'
| 'customStorageLimit'
| 'stripeId'
>
currentSubscription: {
isYearly?: boolean
}
currentPlan: Plan
currency?: 'eur' | 'usd'
isLoading?: boolean
isYearly: boolean
onPayClick: (props: { selectedChatsLimitIndex: number }) => void
onPayClick: () => void
}
export const StarterPlanPricingCard = ({
workspace,
currentSubscription,
currentPlan,
isLoading,
currency,
isYearly,
onPayClick,
}: Props) => {
const t = useI18n()
const scopedT = useScopedI18n('billing.pricingCard')
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
useState<number>()
useEffect(() => {
if (isDefined(selectedChatsLimitIndex)) return
if (workspace.plan !== Plan.STARTER) {
setSelectedChatsLimitIndex(0)
return
}
setSelectedChatsLimitIndex(workspace.additionalChatsIndex ?? 0)
}, [selectedChatsLimitIndex, workspace.additionalChatsIndex, workspace.plan])
const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined
const isCurrentPlan =
chatsLimit[Plan.STARTER].graduatedPrice[selectedChatsLimitIndex ?? 0]
.totalIncluded === workspaceChatsLimit &&
isYearly === currentSubscription?.isYearly
const getButtonLabel = () => {
if (selectedChatsLimitIndex === undefined) return ''
if (workspace?.plan === Plan.PRO) return t('downgrade')
if (workspace?.plan === Plan.STARTER) {
if (isCurrentPlan) return scopedT('upgradeButton.current')
if (
selectedChatsLimitIndex !== workspace.additionalChatsIndex ||
isYearly !== currentSubscription?.isYearly
)
return t('update')
}
if (currentPlan === Plan.PRO) return t('downgrade')
if (currentPlan === Plan.STARTER) return scopedT('upgradeButton.current')
return t('upgrade')
}
const handlePayClick = async () => {
if (selectedChatsLimitIndex === undefined) return
onPayClick({
selectedChatsLimitIndex,
})
}
const price =
computePrice(
Plan.STARTER,
selectedChatsLimitIndex ?? 0,
isYearly ? 'yearly' : 'monthly'
) ?? NaN
return (
<Stack spacing={6} p="6" rounded="lg" borderWidth="1px" flex="1" h="full">
<Stack
spacing={6}
p="6"
rounded="lg"
borderWidth="1px"
flex="1"
h="full"
justifyContent="space-between"
pt="8"
>
<Stack spacing="4">
<Heading fontSize="2xl">
{scopedT('heading', {
plan: <chakra.span color="orange.400">Starter</chakra.span>,
})}
</Heading>
<Text>{scopedT('starter.description')}</Text>
<Heading>
{formatPrice(price, currency)}
<chakra.span fontSize="md">{scopedT('perMonth')}</chakra.span>
</Heading>
<Stack>
<Stack spacing="4">
<Heading fontSize="2xl">
{scopedT('heading', {
plan: <chakra.span color="orange.400">Starter</chakra.span>,
})}
</Heading>
<Text>{scopedT('starter.description')}</Text>
</Stack>
<Heading>
{formatPrice(prices.STARTER, { currency })}
<chakra.span fontSize="md">{scopedT('perMonth')}</chakra.span>
</Heading>
</Stack>
<FeaturesList
features={[
scopedT('starter.includedSeats'),
<HStack key="test">
<Text>
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronLeftIcon transform="rotate(-90deg)" />}
size="sm"
isLoading={selectedChatsLimitIndex === undefined}
>
{selectedChatsLimitIndex !== undefined
? parseNumberWithCommas(
chatsLimit.STARTER.graduatedPrice[
selectedChatsLimitIndex
].totalIncluded
)
: undefined}
</MenuButton>
<MenuList>
{chatsLimit.STARTER.graduatedPrice.map((price, index) => (
<MenuItem
key={index}
onClick={() => setSelectedChatsLimitIndex(index)}
>
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem>
))}
</MenuList>
</Menu>{' '}
{scopedT('chatsPerMonth')}
<Stack key="starter-chats" spacing={0}>
<HStack>
<Text>2,000 {scopedT('chatsPerMonth')}</Text>
<MoreInfoTooltip>{scopedT('chatsTooltip')}</MoreInfoTooltip>
</HStack>
<Text
fontSize="sm"
color={useColorModeValue('gray.500', 'gray.400')}
>
Extra chats: $10 per 500
</Text>
<MoreInfoTooltip>{scopedT('chatsTooltip')}</MoreInfoTooltip>
</HStack>,
</Stack>,
scopedT('starter.brandingRemoved'),
scopedT('starter.fileUploadBlock'),
scopedT('starter.createFolders'),
]}
/>
</Stack>
<Stack>
{isYearly && workspace.stripeId && !isCurrentPlan && (
<Heading mt="0" fontSize="md">
You pay: {formatPrice(price * 12, currency)} / year
</Heading>
)}
<Button
colorScheme="orange"
variant="outline"
onClick={handlePayClick}
isLoading={isLoading}
isDisabled={isCurrentPlan}
>
{getButtonLabel()}
</Button>
</Stack>
<Button
colorScheme="orange"
variant="outline"
onClick={onPayClick}
isLoading={isLoading}
isDisabled={currentPlan === Plan.STARTER}
>
{getButtonLabel()}
</Button>
</Stack>
)
}

View File

@@ -12,9 +12,9 @@ import { AlertIcon } from '@/components/icons'
import { Workspace } from '@typebot.io/prisma'
import React from 'react'
import { parseNumberWithCommas } from '@typebot.io/lib'
import { getChatsLimit } from '@typebot.io/lib/pricing'
import { defaultQueryOptions, trpc } from '@/lib/trpc'
import { useScopedI18n } from '@/locales'
import { getChatsLimit } from '@typebot.io/lib/billing/getChatsLimit'
type Props = {
workspace: Workspace
@@ -65,7 +65,7 @@ export const UsageProgressBars = ({ workspace }: Props) => {
</Tooltip>
)}
<Text fontSize="sm" fontStyle="italic" color="gray.500">
{scopedT('chats.resetInfo')}
(Resets on {data?.resetsAt.toLocaleDateString()})
</Text>
</HStack>
@@ -90,9 +90,8 @@ export const UsageProgressBars = ({ workspace }: Props) => {
h="5px"
value={chatsPercentage}
rounded="full"
hasStripe
isIndeterminate={isLoading}
colorScheme={totalChatsUsed >= workspaceChatsLimit ? 'red' : 'blue'}
colorScheme={'blue'}
/>
</Stack>
</Stack>

View File

@@ -1,29 +0,0 @@
import { getChatsLimit } from '@typebot.io/lib/pricing'
import { priceIds } from '@typebot.io/lib/api/pricing'
export const parseSubscriptionItems = (
plan: 'STARTER' | 'PRO',
additionalChats: number,
isYearly: boolean
) => {
const frequency = isYearly ? 'yearly' : 'monthly'
return [
{
price: priceIds[plan].base[frequency],
quantity: 1,
},
].concat(
additionalChats > 0
? [
{
price: priceIds[plan].chats[frequency],
quantity: getChatsLimit({
plan,
additionalChatsIndex: additionalChats,
customChatsLimit: null,
}),
},
]
: []
)
}

View File

@@ -10,12 +10,12 @@ import { Stack, VStack, Spinner, Text } from '@chakra-ui/react'
import { Plan } from '@typebot.io/prisma'
import { useRouter } from 'next/router'
import { useState, useEffect } from 'react'
import { guessIfUserIsEuropean } from '@typebot.io/lib/pricing'
import { DashboardHeader } from './DashboardHeader'
import { FolderContent } from '@/features/folders/components/FolderContent'
import { TypebotDndProvider } from '@/features/folders/TypebotDndProvider'
import { ParentModalProvider } from '@/features/graph/providers/ParentModalProvider'
import { trpc } from '@/lib/trpc'
import { guessIfUserIsEuropean } from '@typebot.io/lib/billing/guessIfUserIsEuropean'
export const DashboardPage = () => {
const scopedT = useScopedI18n('dashboard')
@@ -33,13 +33,11 @@ export const DashboardPage = () => {
})
useEffect(() => {
const { subscribePlan, chats, isYearly, claimCustomPlan } =
router.query as {
subscribePlan: Plan | undefined
chats: string | undefined
isYearly: string | undefined
claimCustomPlan: string | undefined
}
const { subscribePlan, claimCustomPlan } = router.query as {
subscribePlan: Plan | undefined
chats: string | undefined
claimCustomPlan: string | undefined
}
if (claimCustomPlan && user?.email && workspace) {
setIsLoading(true)
createCustomCheckoutSession({
@@ -53,9 +51,7 @@ export const DashboardPage = () => {
setPreCheckoutPlan({
plan: subscribePlan as 'PRO' | 'STARTER',
workspaceId: workspace.id,
additionalChats: chats ? parseInt(chats) : 0,
currency: guessIfUserIsEuropean() ? 'eur' : 'usd',
isYearly: isYearly === 'false' ? false : true,
})
}
}, [createCustomCheckoutSession, router.query, user, workspace])

View File

@@ -18,7 +18,6 @@ import { useMemo } from 'react'
import { useStats } from '../hooks/useStats'
import { ResultsProvider } from '../ResultsProvider'
import { ResultsTableContainer } from './ResultsTableContainer'
import { UsageAlertBanners } from './UsageAlertBanners'
export const ResultsPage = () => {
const router = useRouter()
@@ -56,7 +55,6 @@ export const ResultsPage = () => {
}
/>
<TypebotHeader />
{workspace && <UsageAlertBanners workspace={workspace} />}
<Flex
h="full"
w="full"

View File

@@ -1,50 +0,0 @@
import { UnlockPlanAlertInfo } from '@/components/UnlockPlanAlertInfo'
import { trpc } from '@/lib/trpc'
import { Flex } from '@chakra-ui/react'
import { Workspace } from '@typebot.io/schemas'
import { useMemo } from 'react'
import { getChatsLimit } from '@typebot.io/lib/pricing'
const ALERT_CHATS_PERCENT_THRESHOLD = 80
type Props = {
workspace: Workspace
}
export const UsageAlertBanners = ({ workspace }: Props) => {
const { data: usageData } = trpc.billing.getUsage.useQuery({
workspaceId: workspace?.id,
})
const chatsLimitPercentage = useMemo(() => {
if (!usageData?.totalChatsUsed || !workspace?.plan) return 0
return Math.round(
(usageData.totalChatsUsed /
getChatsLimit({
additionalChatsIndex: workspace.additionalChatsIndex,
plan: workspace.plan,
customChatsLimit: workspace.customChatsLimit,
})) *
100
)
}, [
usageData?.totalChatsUsed,
workspace?.additionalChatsIndex,
workspace?.customChatsLimit,
workspace?.plan,
])
return (
<>
{chatsLimitPercentage > ALERT_CHATS_PERCENT_THRESHOLD && (
<Flex p="4">
<UnlockPlanAlertInfo status="warning" buttonLabel="Upgrade">
Your workspace collected <strong>{chatsLimitPercentage}%</strong> of
your total chats limit this month. Upgrade your plan to continue
chatting with your customers beyond this limit.
</UnlockPlanAlertInfo>
</Flex>
)}
</>
)
}

View File

@@ -8,7 +8,6 @@ import {
import { UnlockPlanAlertInfo } from '@/components/UnlockPlanAlertInfo'
import { WorkspaceInvitation, WorkspaceRole } from '@typebot.io/prisma'
import React from 'react'
import { getSeatsLimit, isSeatsLimitReached } from '@typebot.io/lib/pricing'
import { AddMemberForm } from './AddMemberForm'
import { MemberItem } from './MemberItem'
import { isDefined } from '@typebot.io/lib'
@@ -21,6 +20,7 @@ import { updateMemberQuery } from '../queries/updateMemberQuery'
import { Member } from '../types'
import { useWorkspace } from '../WorkspaceProvider'
import { useScopedI18n } from '@/locales'
import { getSeatsLimit } from '@typebot.io/lib/billing/getSeatsLimit'
export const MembersList = () => {
const scopedT = useScopedI18n('workspace.membersList')
@@ -92,13 +92,9 @@ export const MembersList = () => {
const seatsLimit = workspace ? getSeatsLimit(workspace) : undefined
const canInviteNewMember =
workspace &&
!isSeatsLimitReached({
plan: workspace?.plan,
customSeatsLimit: workspace?.customSeatsLimit,
existingMembersAndInvitationsCount: currentMembersCount,
})
const canInviteNewMember = workspace
? currentMembersCount < (seatsLimit as number)
: false
return (
<Stack w="full" spacing={3}>

View File

@@ -46,23 +46,22 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
const metadata = session.metadata as unknown as
| {
plan: 'STARTER' | 'PRO'
additionalChats: string
workspaceId: string
userId: string
}
| { claimableCustomPlanId: string; userId: string }
if ('plan' in metadata) {
const { workspaceId, plan, additionalChats } = metadata
if (!workspaceId || !plan || !additionalChats)
const { workspaceId, plan } = metadata
if (!workspaceId || !plan)
return res
.status(500)
.send({ message: `Couldn't retrieve valid metadata` })
const workspace = await prisma.workspace.update({
where: { id: workspaceId },
data: {
plan,
stripeId: session.customer as string,
additionalChatsIndex: parseInt(additionalChats),
isQuarantined: false,
},
include: {
@@ -84,7 +83,6 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
userId: user.id,
data: {
plan,
additionalChatsIndex: parseInt(additionalChats),
},
},
])
@@ -119,7 +117,6 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
userId,
data: {
plan: Plan.CUSTOM,
additionalChatsIndex: 0,
},
},
])
@@ -148,7 +145,6 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
},
data: {
plan: Plan.FREE,
additionalChatsIndex: 0,
customChatsLimit: null,
customStorageLimit: null,
customSeatsLimit: null,
@@ -172,7 +168,6 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
userId: user.id,
data: {
plan: Plan.FREE,
additionalChatsIndex: 0,
},
},
])

View File

@@ -8,7 +8,7 @@ import {
} from '@typebot.io/lib/api'
import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser'
import { sendWorkspaceMemberInvitationEmail } from '@typebot.io/emails'
import { isSeatsLimitReached } from '@typebot.io/lib/pricing'
import { getSeatsLimit } from '@typebot.io/lib/billing/getSeatsLimit'
import { env } from '@typebot.io/env'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
@@ -37,11 +37,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}),
])
if (
isSeatsLimitReached({
existingMembersAndInvitationsCount:
existingMembersCount + existingInvitationsCount,
...workspace,
})
getSeatsLimit(workspace) <=
existingMembersCount + existingInvitationsCount
)
return res.status(400).send('Seats limit reached')
if (existingUser) {

View File

@@ -18,7 +18,7 @@ const stripe = new Stripe(env.STRIPE_SECRET_KEY ?? '', {
export const addSubscriptionToWorkspace = async (
workspaceId: string,
items: Stripe.SubscriptionCreateParams.Item[],
metadata: Pick<Workspace, 'additionalChatsIndex' | 'plan'>
metadata: Pick<Workspace, 'plan'>
) => {
const { id: stripeId } = await stripe.customers.create({
email: 'test-user@gmail.com',

View File

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

View File

@@ -229,14 +229,10 @@
"CUSTOM",
"UNLIMITED"
]
},
"additionalChatsIndex": {
"type": "number"
}
},
"required": [
"plan",
"additionalChatsIndex"
"plan"
],
"additionalProperties": false
}
@@ -643,6 +639,14 @@
"youtube",
"vimeo"
]
},
"height": {
"anyOf": [
{
"type": "number"
},
{}
]
}
},
"additionalProperties": false
@@ -5032,6 +5036,14 @@
"youtube",
"vimeo"
]
},
"height": {
"anyOf": [
{
"type": "number"
},
{}
]
}
},
"additionalProperties": false
@@ -9062,6 +9074,14 @@
"youtube",
"vimeo"
]
},
"height": {
"anyOf": [
{
"type": "number"
},
{}
]
}
},
"additionalProperties": false
@@ -13232,6 +13252,14 @@
"youtube",
"vimeo"
]
},
"height": {
"anyOf": [
{
"type": "number"
},
{}
]
}
},
"additionalProperties": false
@@ -17282,6 +17310,14 @@
"youtube",
"vimeo"
]
},
"height": {
"anyOf": [
{
"type": "number"
},
{}
]
}
},
"additionalProperties": false
@@ -21387,6 +21423,14 @@
"youtube",
"vimeo"
]
},
"height": {
"anyOf": [
{
"type": "number"
},
{}
]
}
},
"additionalProperties": false
@@ -25555,6 +25599,14 @@
"youtube",
"vimeo"
]
},
"height": {
"anyOf": [
{
"type": "number"
},
{}
]
}
},
"additionalProperties": false
@@ -30419,9 +30471,6 @@
"returnUrl": {
"type": "string"
},
"additionalChats": {
"type": "number"
},
"vat": {
"type": "object",
"properties": {
@@ -30437,9 +30486,6 @@
"value"
],
"additionalProperties": false
},
"isYearly": {
"type": "boolean"
}
},
"required": [
@@ -30448,9 +30494,7 @@
"workspaceId",
"currency",
"plan",
"returnUrl",
"additionalChats",
"isYearly"
"returnUrl"
],
"additionalProperties": false
}
@@ -30516,27 +30560,19 @@
"PRO"
]
},
"additionalChats": {
"type": "number"
},
"currency": {
"type": "string",
"enum": [
"usd",
"eur"
]
},
"isYearly": {
"type": "boolean"
}
},
"required": [
"returnUrl",
"workspaceId",
"plan",
"additionalChats",
"currency",
"isYearly"
"currency"
],
"additionalProperties": false
}
@@ -30706,8 +30742,23 @@
{
"type": "object",
"properties": {
"isYearly": {
"type": "boolean"
"currentBillingPeriod": {
"type": "object",
"properties": {
"start": {
"type": "string",
"format": "date-time"
},
"end": {
"type": "string",
"format": "date-time"
}
},
"required": [
"start",
"end"
],
"additionalProperties": false
},
"currency": {
"type": "string",
@@ -30729,7 +30780,7 @@
}
},
"required": [
"isYearly",
"currentBillingPeriod",
"currency",
"status"
],
@@ -30790,10 +30841,15 @@
"properties": {
"totalChatsUsed": {
"type": "number"
},
"resetsAt": {
"type": "string",
"format": "date-time"
}
},
"required": [
"totalChatsUsed"
"totalChatsUsed",
"resetsAt"
],
"additionalProperties": false
}

View File

@@ -238,6 +238,14 @@
"youtube",
"vimeo"
]
},
"height": {
"anyOf": [
{
"type": "number"
},
{}
]
}
},
"additionalProperties": false
@@ -4196,6 +4204,14 @@
"youtube",
"vimeo"
]
},
"height": {
"anyOf": [
{
"type": "number"
},
{}
]
}
},
"additionalProperties": false
@@ -5674,9 +5690,6 @@
},
"additionalProperties": false
}
},
"displayStream": {
"type": "boolean"
}
},
"required": [

View File

@@ -0,0 +1,91 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Stack,
ModalFooter,
Heading,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
} from '@chakra-ui/react'
import { proChatTiers } from '@typebot.io/lib/billing/constants'
import { formatPrice } from '@typebot.io/lib/billing/formatPrice'
type Props = {
isOpen: boolean
onClose: () => void
}
export const ChatsProTiersModal = ({ isOpen, onClose }: Props) => {
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>
<Heading size="lg">Chats pricing table</Heading>
</ModalHeader>
<ModalCloseButton />
<ModalBody as={Stack} spacing="6">
<TableContainer>
<Table variant="simple">
<Thead>
<Tr>
<Th isNumeric>Max chats</Th>
<Th isNumeric>Price per month</Th>
<Th isNumeric>Price per 1k chats</Th>
</Tr>
</Thead>
<Tbody>
{proChatTiers.map((tier, index) => {
const pricePerMonth =
proChatTiers
.slice(0, index + 1)
.reduce(
(acc, slicedTier) =>
acc + (slicedTier.flat_amount ?? 0),
0
) / 100
return (
<Tr key={tier.up_to}>
<Td isNumeric>
{tier.up_to === 'inf'
? '2,000,000+'
: tier.up_to.toLocaleString()}
</Td>
<Td isNumeric>
{index === 0 ? 'included' : formatPrice(pricePerMonth)}
</Td>
<Td isNumeric>
{index === proChatTiers.length - 1
? formatPrice(4.42, { maxFractionDigits: 2 })
: index === 0
? 'included'
: formatPrice(
(((pricePerMonth * 100) /
((tier.up_to as number) -
(proChatTiers.at(0)?.up_to as number))) *
1000) /
100,
{ maxFractionDigits: 2 }
)}
</Td>
</Tr>
)
})}
</Tbody>
</Table>
</TableContainer>
</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
)
}

View File

@@ -1,88 +1,74 @@
import { Heading, VStack, SimpleGrid, Stack, Text } from '@chakra-ui/react'
import { Heading, VStack, Stack, Text, Wrap, WrapItem } 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>
You will receive a heads up email when you reach 80% of your monthly
limit. Once you have reached the limit, you will receive another email
alert. Your bots will continue to run. You will be kindly asked to
upgrade your subscription. If you don&apos;t provide an answer after
~48h, your bots will be closed for the remaining of the month. For a
FREE workspace, If you exceed 600 chats, your bots will be
automatically closed.
</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>
<Wrap spacing={10}>
<WrapItem maxW="500px">
<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>
</WrapItem>
<WrapItem maxW="500px">
<Stack borderWidth={1} p="8" rounded="lg" spacing={4}>
<Heading as="h2" fontSize="2xl">
What happens once I reach the included chats limit?
</Heading>
<Text>
That&apos;s amazing, your bots are working full speed. 🚀
<br />
<br />
You will first receive a heads up email when you reach 80% of your
included limit. Once you have reached 100%, you will receive another
email notification.
<br />
<br />
After that, your chat limit be automatically upgraded to the next
tier.
</Text>
</Stack>
</WrapItem>
<WrapItem maxW="500px">
<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>
</WrapItem>
<WrapItem maxW="500px">
<Stack borderWidth={1} p="8" rounded="lg" spacing={4}>
<Heading as="h2" fontSize="2xl">
Do you offer annual payments?
</Heading>
<Text>
No, because subscriptions pricing is based on chats usage, we can
only offer monthly plans.
</Text>
</Stack>
</WrapItem>
</Wrap>
</VStack>
)

View File

@@ -3,7 +3,7 @@ import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon'
import Link from 'next/link'
import React from 'react'
import { PricingCard } from './PricingCard'
import { chatsLimit } from '@typebot.io/lib/pricing'
import { chatsLimits } from '@typebot.io/lib/billing/constants'
export const FreePlanCard = () => (
<PricingCard
@@ -14,9 +14,7 @@ export const FreePlanCard = () => (
'Unlimited typebots',
<>
<Text>
<chakra.span fontWeight="bold">
{chatsLimit.FREE.totalIncluded}
</chakra.span>{' '}
<chakra.span fontWeight="bold">{chatsLimits.FREE}</chakra.span>{' '}
chats/month
</Text>
&nbsp;

View File

@@ -19,15 +19,19 @@ import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon'
import { Plan } from '@typebot.io/prisma'
import Link from 'next/link'
import React from 'react'
import {
chatsLimit,
formatPrice,
prices,
seatsLimit,
} from '@typebot.io/lib/pricing'
import { parseNumberWithCommas } from '@typebot.io/lib'
import {
chatsLimits,
prices,
seatsLimits,
} from '@typebot.io/lib/billing/constants'
import { formatPrice } from '@typebot.io/lib/billing/formatPrice'
export const PlanComparisonTables = () => (
type Props = {
onChatsTiersClick: () => void
}
export const PlanComparisonTables = ({ onChatsTiersClick }: Props) => (
<Stack spacing="12">
<TableContainer>
<Table>
@@ -50,32 +54,23 @@ export const PlanComparisonTables = () => (
</Tr>
<Tr>
<Td>Chats</Td>
<Td>{chatsLimit.FREE.totalIncluded} / month</Td>
<Td>
{parseNumberWithCommas(
chatsLimit.STARTER.graduatedPrice[0].totalIncluded
)}{' '}
/ month
</Td>
<Td>
{parseNumberWithCommas(
chatsLimit.PRO.graduatedPrice[0].totalIncluded
)}{' '}
/ month
</Td>
<Td>{chatsLimits.FREE} / month</Td>
<Td>{parseNumberWithCommas(chatsLimits.STARTER)} / month</Td>
<Td>{parseNumberWithCommas(chatsLimits.PRO)} / month</Td>
</Tr>
<Tr>
<Td>Additional Chats</Td>
<Td />
<Td>{formatPrice(10)} per 500 chats</Td>
<Td>
{formatPrice(chatsLimit.STARTER.graduatedPrice[1].price)} per{' '}
{chatsLimit.STARTER.graduatedPrice[1].totalIncluded -
chatsLimit.STARTER.graduatedPrice[0].totalIncluded}
</Td>
<Td>
{formatPrice(chatsLimit.PRO.graduatedPrice[1].price)} per{' '}
{chatsLimit.PRO.graduatedPrice[1].totalIncluded -
chatsLimit.PRO.graduatedPrice[0].totalIncluded}
<Button
variant="outline"
size="xs"
onClick={onChatsTiersClick}
colorScheme="gray"
>
See tiers
</Button>
</Td>
</Tr>
<Tr>
@@ -87,8 +82,8 @@ export const PlanComparisonTables = () => (
<Tr>
<Td>Members</Td>
<Td>Just you</Td>
<Td>{seatsLimit.STARTER.totalIncluded} seats</Td>
<Td>{seatsLimit.PRO.totalIncluded} seats</Td>
<Td>{seatsLimits.STARTER} seats</Td>
<Td>{seatsLimits.PRO} seats</Td>
</Tr>
<Tr>
<Td>Guests</Td>
@@ -276,6 +271,14 @@ export const PlanComparisonTables = () => (
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>WhatsApp integration</Td>
<Td />
<Td />
<Td>
<CheckIcon />
</Td>
</Tr>
<Tr>
<Td>Custom domains</Td>
<Td />

View File

@@ -9,9 +9,9 @@ import {
VStack,
} from '@chakra-ui/react'
import * as React from 'react'
import { formatPrice } from '@typebot.io/lib/pricing'
import { CheckCircleIcon } from '../../../assets/icons/CheckCircleIcon'
import { Card, CardProps } from './Card'
import { formatPrice } from '@typebot.io/lib/billing/formatPrice'
export interface PricingCardData {
features: React.ReactNode[]

View File

@@ -4,107 +4,75 @@ import {
Text,
Button,
HStack,
Menu,
MenuButton,
MenuItem,
MenuList,
Stack,
Link,
} from '@chakra-ui/react'
import { ChevronDownIcon } from 'assets/icons/ChevronDownIcon'
import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon'
import { Plan } from '@typebot.io/prisma'
import Link from 'next/link'
import React, { useState } from 'react'
import { parseNumberWithCommas } from '@typebot.io/lib'
import { chatsLimit, computePrice, seatsLimit } from '@typebot.io/lib/pricing'
import React from 'react'
import { PricingCard } from './PricingCard'
import { prices, seatsLimits } from '@typebot.io/lib/billing/constants'
type Props = {
isYearly: boolean
onChatsTiersClick: () => void
}
export const ProPlanCard = ({ isYearly }: Props) => {
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
useState<number>(0)
const price =
computePrice(
Plan.PRO,
selectedChatsLimitIndex ?? 0,
isYearly ? 'yearly' : 'monthly'
) ?? NaN
return (
<PricingCard
data={{
price,
name: 'Pro',
featureLabel: 'Everything in Personal, plus:',
features: [
<Text key="seats">
<chakra.span fontWeight="bold">
{seatsLimit.PRO.totalIncluded} seats
</chakra.span>{' '}
included
</Text>,
<HStack key="chats" spacing={1.5}>
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronDownIcon />}
size="sm"
variant="outline"
isLoading={selectedChatsLimitIndex === undefined}
>
{selectedChatsLimitIndex !== undefined
? parseNumberWithCommas(
chatsLimit.PRO.graduatedPrice[selectedChatsLimitIndex]
.totalIncluded
)
: undefined}
</MenuButton>
<MenuList>
{chatsLimit.PRO.graduatedPrice.map((price, index) => (
<MenuItem
key={index}
onClick={() => setSelectedChatsLimitIndex(index)}
>
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem>
))}
</MenuList>
</Menu>{' '}
<Text>chats/mo</Text>
export const ProPlanCard = ({ onChatsTiersClick }: Props) => (
<PricingCard
data={{
price: prices.PRO,
name: 'Pro',
featureLabel: 'Everything in Personal, plus:',
features: [
<Text key="seats">
<chakra.span fontWeight="bold">{seatsLimits.PRO} seats</chakra.span>{' '}
included
</Text>,
<Stack key="chats" spacing={0}>
<HStack spacing={1.5}>
<Text>10,000 chats/mo</Text>
<Tooltip
hasArrow
placement="top"
label="A chat is counted whenever a user starts a discussion. It is
independant of the number of messages he sends and receives."
independant of the number of messages he sends and receives."
>
<chakra.span cursor="pointer" h="7">
<HelpCircleIcon />
</chakra.span>
</Tooltip>
</HStack>,
'WhatsApp integration',
'Custom domains',
'In-depth analytics',
],
}}
borderWidth="3px"
borderColor="blue.200"
button={
<Button
as={Link}
href={`https://app.typebot.io/register?subscribePlan=${Plan.PRO}&chats=${selectedChatsLimitIndex}&isYearly=${isYearly}`}
colorScheme="blue"
size="lg"
w="full"
fontWeight="extrabold"
py={{ md: '8' }}
>
Subscribe now
</Button>
}
/>
)
}
</HStack>
<Text fontSize="sm" color="gray.400">
Extra chats:{' '}
<Button
variant="outline"
size="xs"
colorScheme="gray"
onClick={onChatsTiersClick}
>
See tiers
</Button>
</Text>
</Stack>,
'WhatsApp integration',
'Custom domains',
'In-depth analytics',
],
}}
borderWidth="3px"
borderColor="blue.200"
button={
<Button
as={Link}
href={`https://app.typebot.io/register?subscribePlan=${Plan.PRO}`}
colorScheme="blue"
size="lg"
w="full"
fontWeight="extrabold"
py={{ md: '8' }}
>
Subscribe now
</Button>
}
/>
)

View File

@@ -1,87 +1,43 @@
import {
chakra,
Tooltip,
Text,
HStack,
Menu,
MenuButton,
Button,
MenuItem,
MenuList,
} from '@chakra-ui/react'
import { ChevronDownIcon } from 'assets/icons/ChevronDownIcon'
import { chakra, Tooltip, Text, HStack, Button, Stack } from '@chakra-ui/react'
import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon'
import { Plan } from '@typebot.io/prisma'
import Link from 'next/link'
import React, { useState } from 'react'
import { parseNumberWithCommas } from '@typebot.io/lib'
import { chatsLimit, computePrice, seatsLimit } from '@typebot.io/lib/pricing'
import React from 'react'
import { PricingCard } from './PricingCard'
import { prices, seatsLimits } from '@typebot.io/lib/billing/constants'
type Props = {
isYearly: boolean
}
export const StarterPlanCard = ({ isYearly }: Props) => {
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
useState<number>(0)
const price =
computePrice(
Plan.STARTER,
selectedChatsLimitIndex ?? 0,
isYearly ? 'yearly' : 'monthly'
) ?? NaN
export const StarterPlanCard = () => {
return (
<PricingCard
data={{
price,
price: prices.STARTER,
name: 'Starter',
featureLabel: 'Everything in Personal, plus:',
features: [
<Text key="seats">
<chakra.span fontWeight="bold">
{seatsLimit.STARTER.totalIncluded} seats
{seatsLimits.STARTER} seats
</chakra.span>{' '}
included
</Text>,
<HStack key="chats" spacing={1.5}>
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronDownIcon />}
size="sm"
variant="outline"
colorScheme="orange"
>
{parseNumberWithCommas(
chatsLimit.STARTER.graduatedPrice[selectedChatsLimitIndex]
.totalIncluded
)}
</MenuButton>
<MenuList>
{chatsLimit.STARTER.graduatedPrice.map((price, index) => (
<MenuItem
key={index}
onClick={() => setSelectedChatsLimitIndex(index)}
>
{parseNumberWithCommas(price.totalIncluded)}
</MenuItem>
))}
</MenuList>
</Menu>{' '}
<Text>chats/mo</Text>
<Tooltip
hasArrow
placement="top"
label="A chat is counted whenever a user starts a discussion. It is
<Stack key="chats" spacing={0}>
<HStack spacing={1.5}>
<Text>2,000 chats/mo</Text>
<Tooltip
hasArrow
placement="top"
label="A chat is counted whenever a user starts a discussion. It is
independant of the number of messages he sends and receives."
>
<chakra.span cursor="pointer" h="7">
<HelpCircleIcon />
</chakra.span>
</Tooltip>
</HStack>,
>
<chakra.span cursor="pointer" h="7">
<HelpCircleIcon />
</chakra.span>
</Tooltip>
</HStack>
<Text fontSize="sm" color="gray.400">
Extra chats: $10 per 500
</Text>
</Stack>,
'Branding removed',
'Collect files from users',
'Create folders',
@@ -92,7 +48,7 @@ export const StarterPlanCard = ({ isYearly }: Props) => {
button={
<Button
as={Link}
href={`https://app.typebot.io/register?subscribePlan=${Plan.STARTER}&chats=${selectedChatsLimitIndex}&isYearly=${isYearly}`}
href={`https://app.typebot.io/register?subscribePlan=${Plan.STARTER}`}
colorScheme="orange"
size="lg"
w="full"

View File

@@ -6,15 +6,13 @@ import {
VStack,
Text,
HStack,
Switch,
Tag,
useDisclosure,
} from '@chakra-ui/react'
import { Footer } from 'components/common/Footer'
import { Header } from 'components/common/Header/Header'
import { SocialMetaTags } from 'components/common/SocialMetaTags'
import { BackgroundPolygons } from 'components/Homepage/Hero/BackgroundPolygons'
import { PlanComparisonTables } from 'components/PricingPage/PlanComparisonTables'
import { useState } from 'react'
import { StripeClimateLogo } from 'assets/logos/StripeClimateLogo'
import { FreePlanCard } from 'components/PricingPage/FreePlanCard'
import { StarterPlanCard } from 'components/PricingPage/StarterPlanCard'
@@ -22,12 +20,14 @@ import { ProPlanCard } from 'components/PricingPage/ProPlanCard'
import { TextLink } from 'components/common/TextLink'
import { EnterprisePlanCard } from 'components/PricingPage/EnterprisePlanCard'
import { Faq } from 'components/PricingPage/Faq'
import { ChatsProTiersModal } from 'components/PricingPage/ChatsProTiersModal'
const Pricing = () => {
const [isYearly, setIsYearly] = useState(true)
const { isOpen, onOpen, onClose } = useDisclosure()
return (
<Stack overflowX="hidden" bgColor="gray.900">
<ChatsProTiersModal isOpen={isOpen} onClose={onClose} />
<Flex
pos="relative"
flexDir="column"
@@ -87,30 +87,16 @@ const Pricing = () => {
</TextLink>
</Text>
</HStack>
<Stack align="flex-end" maxW="1200px" w="full" spacing={4}>
<HStack>
<Text>Monthly</Text>
<Switch
isChecked={isYearly}
onChange={() => setIsYearly(!isYearly)}
/>
<HStack>
<Text>Yearly</Text>
<Tag colorScheme="blue">16% off</Tag>
</HStack>
</HStack>
<Stack
direction={['column', 'row']}
alignItems={['stretch']}
spacing={10}
w="full"
maxW="1200px"
>
<FreePlanCard />
<StarterPlanCard isYearly={isYearly} />
<ProPlanCard isYearly={isYearly} />
</Stack>
<Stack
direction={['column', 'row']}
alignItems={['stretch']}
spacing={10}
w="full"
maxW="1200px"
>
<FreePlanCard />
<StarterPlanCard />
<ProPlanCard onChatsTiersClick={onOpen} />
</Stack>
<EnterprisePlanCard />
@@ -119,7 +105,7 @@ const Pricing = () => {
<VStack maxW="1200px" w="full" spacing={[12, 20]} px="4">
<Stack w="full" spacing={10} display={['none', 'flex']}>
<Heading>Compare plans & features</Heading>
<PlanComparisonTables />
<PlanComparisonTables onChatsTiersClick={onOpen} />
</Stack>
<Faq />
</VStack>