🛂 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:
@ -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' }
|
||||
})
|
@ -33,6 +33,7 @@ export const createCheckoutSession = authenticatedProcedure
|
||||
value: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
isYearly: z.boolean(),
|
||||
})
|
||||
)
|
||||
.output(
|
||||
@ -52,14 +53,11 @@ export const createCheckoutSession = authenticatedProcedure
|
||||
returnUrl,
|
||||
additionalChats,
|
||||
additionalStorage,
|
||||
isYearly,
|
||||
},
|
||||
ctx: { user },
|
||||
}) => {
|
||||
if (
|
||||
!process.env.STRIPE_SECRET_KEY ||
|
||||
!process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID ||
|
||||
!process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
|
||||
)
|
||||
if (!process.env.STRIPE_SECRET_KEY)
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Stripe environment variables are missing',
|
||||
@ -120,7 +118,8 @@ export const createCheckoutSession = authenticatedProcedure
|
||||
line_items: parseSubscriptionItems(
|
||||
plan,
|
||||
additionalChats,
|
||||
additionalStorage
|
||||
additionalStorage,
|
||||
isYearly
|
||||
),
|
||||
})
|
||||
|
||||
|
@ -5,6 +5,7 @@ import { WorkspaceRole } from '@typebot.io/prisma'
|
||||
import Stripe from 'stripe'
|
||||
import { z } from 'zod'
|
||||
import { subscriptionSchema } from '@typebot.io/schemas/features/billing/subscription'
|
||||
import { priceIds } from '@typebot.io/lib/pricing'
|
||||
|
||||
export const getSubscription = authenticatedProcedure
|
||||
.meta({
|
||||
@ -23,15 +24,11 @@ export const getSubscription = authenticatedProcedure
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
subscription: subscriptionSchema,
|
||||
subscription: subscriptionSchema.or(z.null()),
|
||||
})
|
||||
)
|
||||
.query(async ({ input: { workspaceId }, ctx: { user } }) => {
|
||||
if (
|
||||
!process.env.STRIPE_SECRET_KEY ||
|
||||
!process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID ||
|
||||
!process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
|
||||
)
|
||||
if (!process.env.STRIPE_SECRET_KEY)
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Stripe environment variables are missing',
|
||||
@ -43,10 +40,9 @@ export const getSubscription = authenticatedProcedure
|
||||
},
|
||||
})
|
||||
if (!workspace?.stripeId)
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Workspace not found',
|
||||
})
|
||||
return {
|
||||
subscription: null,
|
||||
}
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: '2022-11-15',
|
||||
})
|
||||
@ -58,24 +54,34 @@ export const getSubscription = authenticatedProcedure
|
||||
const subscription = subscriptions?.data.shift()
|
||||
|
||||
if (!subscription)
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Subscription not found',
|
||||
})
|
||||
return {
|
||||
subscription: null,
|
||||
}
|
||||
|
||||
return {
|
||||
subscription: {
|
||||
additionalChatsIndex:
|
||||
subscription?.items.data.find(
|
||||
(item) =>
|
||||
item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID
|
||||
)?.quantity ?? 0,
|
||||
additionalStorageIndex:
|
||||
subscription.items.data.find(
|
||||
(item) =>
|
||||
item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
|
||||
)?.quantity ?? 0,
|
||||
isYearly: subscription.items.data.some((item) => {
|
||||
return (
|
||||
priceIds.STARTER.chats.yearly === item.price.id ||
|
||||
priceIds.STARTER.storage.yearly === item.price.id ||
|
||||
priceIds.PRO.chats.yearly === item.price.id ||
|
||||
priceIds.PRO.storage.yearly === item.price.id
|
||||
)
|
||||
}),
|
||||
currency: subscription.currency as 'usd' | 'eur',
|
||||
cancelDate: subscription.cancel_at
|
||||
? new Date(subscription.cancel_at * 1000)
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export const chatPriceIds = [priceIds.STARTER.chats.monthly]
|
||||
.concat(priceIds.STARTER.chats.yearly)
|
||||
.concat(priceIds.PRO.chats.monthly)
|
||||
.concat(priceIds.PRO.chats.yearly)
|
||||
|
||||
export const storagePriceIds = [priceIds.STARTER.storage.monthly]
|
||||
.concat(priceIds.STARTER.storage.yearly)
|
||||
.concat(priceIds.PRO.storage.monthly)
|
||||
.concat(priceIds.PRO.storage.yearly)
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { router } from '@/helpers/server/trpc'
|
||||
import { cancelSubscription } from './cancelSubscription'
|
||||
import { createCheckoutSession } from './createCheckoutSession'
|
||||
import { getBillingPortalUrl } from './getBillingPortalUrl'
|
||||
import { getSubscription } from './getSubscription'
|
||||
@ -10,7 +9,6 @@ import { updateSubscription } from './updateSubscription'
|
||||
export const billingRouter = router({
|
||||
getBillingPortalUrl,
|
||||
listInvoices,
|
||||
cancelSubscription,
|
||||
createCheckoutSession,
|
||||
updateSubscription,
|
||||
getSubscription,
|
||||
|
@ -7,6 +7,12 @@ import { workspaceSchema } from '@typebot.io/schemas'
|
||||
import Stripe from 'stripe'
|
||||
import { isDefined } from '@typebot.io/lib'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
getChatsLimit,
|
||||
getStorageLimit,
|
||||
priceIds,
|
||||
} from '@typebot.io/lib/pricing'
|
||||
import { chatPriceIds, storagePriceIds } from './getSubscription'
|
||||
|
||||
export const updateSubscription = authenticatedProcedure
|
||||
.meta({
|
||||
@ -25,6 +31,7 @@ export const updateSubscription = authenticatedProcedure
|
||||
additionalChats: z.number(),
|
||||
additionalStorage: z.number(),
|
||||
currency: z.enum(['usd', 'eur']),
|
||||
isYearly: z.boolean(),
|
||||
})
|
||||
)
|
||||
.output(
|
||||
@ -40,14 +47,11 @@ export const updateSubscription = authenticatedProcedure
|
||||
additionalChats,
|
||||
additionalStorage,
|
||||
currency,
|
||||
isYearly,
|
||||
},
|
||||
ctx: { user },
|
||||
}) => {
|
||||
if (
|
||||
!process.env.STRIPE_SECRET_KEY ||
|
||||
!process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID ||
|
||||
!process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
|
||||
)
|
||||
if (!process.env.STRIPE_SECRET_KEY)
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Stripe environment variables are missing',
|
||||
@ -70,42 +74,48 @@ export const updateSubscription = authenticatedProcedure
|
||||
customer: workspace.stripeId,
|
||||
})
|
||||
const subscription = data[0] as Stripe.Subscription | undefined
|
||||
const currentStarterPlanItemId = subscription?.items.data.find(
|
||||
(item) => item.price.id === process.env.STRIPE_STARTER_PRICE_ID
|
||||
)?.id
|
||||
const currentProPlanItemId = subscription?.items.data.find(
|
||||
(item) => item.price.id === process.env.STRIPE_PRO_PRICE_ID
|
||||
const currentPlanItemId = subscription?.items.data.find((item) =>
|
||||
[
|
||||
process.env.STRIPE_STARTER_PRODUCT_ID,
|
||||
process.env.STRIPE_PRO_PRODUCT_ID,
|
||||
].includes(item.price.product.toString())
|
||||
)?.id
|
||||
const currentAdditionalChatsItemId = subscription?.items.data.find(
|
||||
(item) => item.price.id === process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID
|
||||
(item) => chatPriceIds.includes(item.price.id)
|
||||
)?.id
|
||||
const currentAdditionalStorageItemId = subscription?.items.data.find(
|
||||
(item) =>
|
||||
item.price.id === process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID
|
||||
(item) => storagePriceIds.includes(item.price.id)
|
||||
)?.id
|
||||
const frequency = isYearly ? 'yearly' : 'monthly'
|
||||
|
||||
const items = [
|
||||
{
|
||||
id: currentStarterPlanItemId ?? currentProPlanItemId,
|
||||
price:
|
||||
plan === Plan.STARTER
|
||||
? process.env.STRIPE_STARTER_PRICE_ID
|
||||
: process.env.STRIPE_PRO_PRICE_ID,
|
||||
id: currentPlanItemId,
|
||||
price: priceIds[plan].base[frequency],
|
||||
quantity: 1,
|
||||
},
|
||||
additionalChats === 0 && !currentAdditionalChatsItemId
|
||||
? undefined
|
||||
: {
|
||||
id: currentAdditionalChatsItemId,
|
||||
price: process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID,
|
||||
quantity: additionalChats,
|
||||
price: priceIds[plan].chats[frequency],
|
||||
quantity: getChatsLimit({
|
||||
plan,
|
||||
additionalChatsIndex: additionalChats,
|
||||
customChatsLimit: null,
|
||||
}),
|
||||
deleted: subscription ? additionalChats === 0 : undefined,
|
||||
},
|
||||
additionalStorage === 0 && !currentAdditionalStorageItemId
|
||||
? undefined
|
||||
: {
|
||||
id: currentAdditionalStorageItemId,
|
||||
price: process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID,
|
||||
quantity: additionalStorage,
|
||||
price: priceIds[plan].storage[frequency],
|
||||
quantity: getStorageLimit({
|
||||
plan,
|
||||
additionalStorageIndex: additionalStorage,
|
||||
customStorageLimit: null,
|
||||
}),
|
||||
deleted: subscription ? additionalStorage === 0 : undefined,
|
||||
},
|
||||
].filter(isDefined)
|
||||
@ -126,6 +136,9 @@ export const updateSubscription = authenticatedProcedure
|
||||
items,
|
||||
currency,
|
||||
default_payment_method: paymentMethods[0].id,
|
||||
automatic_tax: {
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import {
|
||||
addSubscriptionToWorkspace,
|
||||
cancelSubscription,
|
||||
createClaimableCustomPlan,
|
||||
} from '@/test/utils/databaseActions'
|
||||
import test, { expect } from '@playwright/test'
|
||||
@ -75,7 +76,7 @@ test('should display valid usage', async ({ page }) => {
|
||||
await page.click('text="Free workspace"')
|
||||
await page.click('text=Settings & Members')
|
||||
await page.click('text=Billing & Usage')
|
||||
await expect(page.locator('text="/ 300"')).toBeVisible()
|
||||
await expect(page.locator('text="/ 200"')).toBeVisible()
|
||||
await expect(page.locator('text="Storage"')).toBeHidden()
|
||||
await page.getByText('Members', { exact: true }).click()
|
||||
await expect(
|
||||
@ -132,6 +133,7 @@ test('plan changes should work', async ({ page }) => {
|
||||
await page.click('button >> text="3,500"')
|
||||
await page.click('button >> text="2"')
|
||||
await page.click('button >> text="4"')
|
||||
await page.locator('label span').first().click()
|
||||
await expect(page.locator('text="$73"')).toBeVisible()
|
||||
await page.click('button >> text=Upgrade >> nth=0')
|
||||
await page.getByLabel('Company name').fill('Company LLC')
|
||||
@ -141,11 +143,11 @@ test('plan changes should work', async ({ page }) => {
|
||||
await expect(page.locator('text=$73.00 >> nth=0')).toBeVisible()
|
||||
await expect(page.locator('text=$30.00 >> nth=0')).toBeVisible()
|
||||
await expect(page.locator('text=$4.00 >> nth=0')).toBeVisible()
|
||||
await addSubscriptionToWorkspace(
|
||||
const stripeId = await addSubscriptionToWorkspace(
|
||||
planChangeWorkspaceId,
|
||||
[
|
||||
{
|
||||
price: process.env.STRIPE_STARTER_PRICE_ID,
|
||||
price: process.env.STRIPE_STARTER_MONTHLY_PRICE_ID,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
@ -158,8 +160,8 @@ test('plan changes should work', async ({ page }) => {
|
||||
await page.click('text=Billing & Usage')
|
||||
await expect(page.locator('text="/ 2,000"')).toBeVisible()
|
||||
await expect(page.locator('text="/ 2 GB"')).toBeVisible()
|
||||
await expect(page.locator('button >> text="2,000"')).toBeVisible()
|
||||
await expect(page.locator('button >> text="2"')).toBeVisible()
|
||||
await expect(page.getByText('/ 2,000')).toBeVisible()
|
||||
await expect(page.getByText('/ 2 GB')).toBeVisible()
|
||||
await page.click('button >> text="2,000"')
|
||||
await page.click('button >> text="3,500"')
|
||||
await page.click('button >> text="2"')
|
||||
@ -176,15 +178,15 @@ test('plan changes should work', async ({ page }) => {
|
||||
await expect(page.locator('text="$73"')).toBeVisible()
|
||||
await expect(page.locator('text="/ 3,500"')).toBeVisible()
|
||||
await expect(page.locator('text="/ 4 GB"')).toBeVisible()
|
||||
await expect(page.locator('button >> text="3,500"')).toBeVisible()
|
||||
await expect(page.locator('button >> text="4"')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '3,500' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '4' })).toBeVisible()
|
||||
|
||||
// Upgrade to PRO
|
||||
await page.click('button >> text="10,000"')
|
||||
await page.click('button >> text="14,000"')
|
||||
await page.click('button >> text="25,000"')
|
||||
await page.click('button >> text="10"')
|
||||
await page.click('button >> text="12"')
|
||||
await expect(page.locator('text="$133"')).toBeVisible()
|
||||
await page.click('button >> text="15"')
|
||||
await expect(page.locator('text="$247"')).toBeVisible()
|
||||
await page.click('button >> text=Upgrade')
|
||||
await expect(
|
||||
page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0')
|
||||
@ -195,25 +197,20 @@ test('plan changes should work', async ({ page }) => {
|
||||
page.waitForNavigation(),
|
||||
page.click('text="Billing portal"'),
|
||||
])
|
||||
await expect(page.getByText('$247.00 per month')).toBeVisible()
|
||||
await expect(page.getByText('(×25000)')).toBeVisible()
|
||||
await expect(page.getByText('(×15)')).toBeVisible()
|
||||
await expect(page.locator('text="Add payment method"')).toBeVisible()
|
||||
await cancelSubscription(stripeId)
|
||||
|
||||
// Cancel subscription
|
||||
await page.goto('/typebots')
|
||||
await page.click('text=Settings & Members')
|
||||
await page.click('text=Billing & Usage')
|
||||
await expect(page.locator('[data-testid="current-subscription"]')).toHaveText(
|
||||
'Current workspace subscription: ProCancel my subscription'
|
||||
)
|
||||
await page.click('button >> text="Cancel my subscription"')
|
||||
await expect(page.locator('[data-testid="current-subscription"]')).toHaveText(
|
||||
'Current workspace subscription: Free'
|
||||
)
|
||||
|
||||
// Upgrade again to PRO
|
||||
await page.getByRole('button', { name: 'Upgrade' }).nth(1).click()
|
||||
await expect(
|
||||
page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0')
|
||||
).toBeVisible({ timeout: 20 * 1000 })
|
||||
page.getByTestId('current-subscription').getByTestId('pro-plan-tag')
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('Will be cancelled on')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display invoices', async ({ page }) => {
|
||||
@ -228,7 +225,7 @@ test('should display invoices', async ({ page }) => {
|
||||
await page.click('text=Settings & Members')
|
||||
await page.click('text=Billing & Usage')
|
||||
await expect(page.locator('text="Invoices"')).toBeVisible()
|
||||
await expect(page.locator('tr')).toHaveCount(3)
|
||||
await expect(page.locator('tr')).toHaveCount(2)
|
||||
await expect(page.locator('text="$39.00"')).toBeVisible()
|
||||
})
|
||||
|
||||
|
@ -1,17 +1,13 @@
|
||||
import { HStack, Stack, Text } from '@chakra-ui/react'
|
||||
import { Stack } from '@chakra-ui/react'
|
||||
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
import React from 'react'
|
||||
import { InvoicesList } from './InvoicesList'
|
||||
import { StripeClimateLogo } from './StripeClimateLogo'
|
||||
import { TextLink } from '@/components/TextLink'
|
||||
import { ChangePlanForm } from './ChangePlanForm'
|
||||
import { UsageProgressBars } from './UsageProgressBars'
|
||||
import { CurrentSubscriptionSummary } from './CurrentSubscriptionSummary'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
|
||||
export const BillingSettingsLayout = () => {
|
||||
const scopedT = useScopedI18n('billing')
|
||||
const { workspace, refreshWorkspace } = useWorkspace()
|
||||
|
||||
if (!workspace) return null
|
||||
@ -19,19 +15,7 @@ export const BillingSettingsLayout = () => {
|
||||
<Stack spacing="10" w="full">
|
||||
<UsageProgressBars workspace={workspace} />
|
||||
<Stack spacing="4">
|
||||
<CurrentSubscriptionSummary
|
||||
workspace={workspace}
|
||||
onCancelSuccess={refreshWorkspace}
|
||||
/>
|
||||
<HStack maxW="500px">
|
||||
<StripeClimateLogo />
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{scopedT('contribution.preLink')}{' '}
|
||||
<TextLink href="https://climate.stripe.com/5VCRAq" isExternal>
|
||||
{scopedT('contribution.link')}
|
||||
</TextLink>
|
||||
</Text>
|
||||
</HStack>
|
||||
<CurrentSubscriptionSummary workspace={workspace} />
|
||||
{workspace.plan !== Plan.CUSTOM &&
|
||||
workspace.plan !== Plan.LIFETIME &&
|
||||
workspace.plan !== Plan.UNLIMITED &&
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Stack, HStack, Text } from '@chakra-ui/react'
|
||||
import { Stack, HStack, Text, Switch, Tag } from '@chakra-ui/react'
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
import { TextLink } from '@/components/TextLink'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
@ -12,22 +12,33 @@ import { useUser } from '@/features/account/hooks/useUser'
|
||||
import { StarterPlanPricingCard } from './StarterPlanPricingCard'
|
||||
import { ProPlanPricingCard } from './ProPlanPricingCard'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
import { StripeClimateLogo } from './StripeClimateLogo'
|
||||
|
||||
type Props = {
|
||||
workspace: Pick<Workspace, 'id' | 'stripeId' | 'plan'>
|
||||
workspace: Workspace
|
||||
onUpgradeSuccess: () => void
|
||||
}
|
||||
|
||||
export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
|
||||
const scopedT = useScopedI18n('billing')
|
||||
|
||||
const { user } = useUser()
|
||||
const { showToast } = useToast()
|
||||
const [preCheckoutPlan, setPreCheckoutPlan] =
|
||||
useState<PreCheckoutModalProps['selectedSubscription']>()
|
||||
const [isYearly, setIsYearly] = useState(true)
|
||||
|
||||
const { data } = trpc.billing.getSubscription.useQuery({
|
||||
workspaceId: workspace.id,
|
||||
})
|
||||
const { data } = trpc.billing.getSubscription.useQuery(
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
{
|
||||
onSuccess: ({ subscription }) => {
|
||||
if (isYearly === false) return
|
||||
setIsYearly(subscription?.isYearly ?? true)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const { mutate: updateSubscription, isLoading: isUpdatingSubscription } =
|
||||
trpc.billing.updateSubscription.useMutation({
|
||||
@ -67,8 +78,9 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
|
||||
additionalChats: selectedChatsLimitIndex,
|
||||
additionalStorage: selectedStorageLimitIndex,
|
||||
currency:
|
||||
data?.subscription.currency ??
|
||||
data?.subscription?.currency ??
|
||||
(guessIfUserIsEuropean() ? 'eur' : 'usd'),
|
||||
isYearly,
|
||||
} as const
|
||||
if (workspace.stripeId) {
|
||||
updateSubscription(newSubscription)
|
||||
@ -77,8 +89,19 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (data?.subscription?.cancelDate) return null
|
||||
|
||||
return (
|
||||
<Stack spacing={6}>
|
||||
<HStack maxW="500px">
|
||||
<StripeClimateLogo />
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{scopedT('contribution.preLink')}{' '}
|
||||
<TextLink href="https://climate.stripe.com/5VCRAq" isExternal>
|
||||
{scopedT('contribution.link')}
|
||||
</TextLink>
|
||||
</Text>
|
||||
</HStack>
|
||||
{!workspace.stripeId && (
|
||||
<ParentModalProvider>
|
||||
<PreCheckoutModal
|
||||
@ -89,41 +112,45 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
|
||||
/>
|
||||
</ParentModalProvider>
|
||||
)}
|
||||
<HStack alignItems="stretch" spacing="4" w="full">
|
||||
<StarterPlanPricingCard
|
||||
initialChatsLimitIndex={
|
||||
workspace?.plan === Plan.STARTER
|
||||
? data?.subscription.additionalChatsIndex
|
||||
: 0
|
||||
}
|
||||
initialStorageLimitIndex={
|
||||
workspace?.plan === Plan.STARTER
|
||||
? data?.subscription.additionalStorageIndex
|
||||
: 0
|
||||
}
|
||||
onPayClick={(props) =>
|
||||
handlePayClick({ ...props, plan: Plan.STARTER })
|
||||
}
|
||||
isLoading={isUpdatingSubscription}
|
||||
currency={data?.subscription.currency}
|
||||
/>
|
||||
{data && (
|
||||
<Stack align="flex-end" spacing={6}>
|
||||
<HStack>
|
||||
<Text>Monthly</Text>
|
||||
<Switch
|
||||
isChecked={isYearly}
|
||||
onChange={() => setIsYearly(!isYearly)}
|
||||
/>
|
||||
<HStack>
|
||||
<Text>Yearly</Text>
|
||||
<Tag colorScheme="blue">16% off</Tag>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<HStack alignItems="stretch" spacing="4" w="full">
|
||||
<StarterPlanPricingCard
|
||||
workspace={workspace}
|
||||
currentSubscription={{ isYearly: data.subscription?.isYearly }}
|
||||
onPayClick={(props) =>
|
||||
handlePayClick({ ...props, plan: Plan.STARTER })
|
||||
}
|
||||
isYearly={isYearly}
|
||||
isLoading={isUpdatingSubscription}
|
||||
currency={data.subscription?.currency}
|
||||
/>
|
||||
|
||||
<ProPlanPricingCard
|
||||
workspace={workspace}
|
||||
currentSubscription={{ isYearly: data.subscription?.isYearly }}
|
||||
onPayClick={(props) =>
|
||||
handlePayClick({ ...props, plan: Plan.PRO })
|
||||
}
|
||||
isYearly={isYearly}
|
||||
isLoading={isUpdatingSubscription}
|
||||
currency={data.subscription?.currency}
|
||||
/>
|
||||
</HStack>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<ProPlanPricingCard
|
||||
initialChatsLimitIndex={
|
||||
workspace?.plan === Plan.PRO
|
||||
? data?.subscription.additionalChatsIndex
|
||||
: 0
|
||||
}
|
||||
initialStorageLimitIndex={
|
||||
workspace?.plan === Plan.PRO
|
||||
? data?.subscription.additionalStorageIndex
|
||||
: 0
|
||||
}
|
||||
onPayClick={(props) => handlePayClick({ ...props, plan: Plan.PRO })}
|
||||
isLoading={isUpdatingSubscription}
|
||||
currency={data?.subscription.currency}
|
||||
/>
|
||||
</HStack>
|
||||
<Text color="gray.500">
|
||||
{scopedT('customLimit.preLink')}{' '}
|
||||
<TextLink href={'https://typebot.io/enterprise-lead-form'} isExternal>
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { Text, HStack, Link, Spinner, Stack, Heading } from '@chakra-ui/react'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { Text, HStack, Stack, Heading } from '@chakra-ui/react'
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
import React from 'react'
|
||||
import { PlanTag } from './PlanTag'
|
||||
@ -10,25 +9,14 @@ import { useScopedI18n } from '@/locales'
|
||||
|
||||
type Props = {
|
||||
workspace: Pick<Workspace, 'id' | 'plan' | 'stripeId'>
|
||||
onCancelSuccess: () => void
|
||||
}
|
||||
|
||||
export const CurrentSubscriptionSummary = ({
|
||||
workspace,
|
||||
onCancelSuccess,
|
||||
}: Props) => {
|
||||
export const CurrentSubscriptionSummary = ({ workspace }: Props) => {
|
||||
const scopedT = useScopedI18n('billing.currentSubscription')
|
||||
const { showToast } = useToast()
|
||||
|
||||
const { mutate: cancelSubscription, isLoading: isCancelling } =
|
||||
trpc.billing.cancelSubscription.useMutation({
|
||||
onError: (error) => {
|
||||
showToast({
|
||||
description: error.message,
|
||||
})
|
||||
},
|
||||
onSuccess: onCancelSuccess,
|
||||
})
|
||||
const { data } = trpc.billing.getSubscription.useQuery({
|
||||
workspaceId: workspace.id,
|
||||
})
|
||||
|
||||
const isSubscribed =
|
||||
(workspace.plan === Plan.STARTER || workspace.plan === Plan.PRO) &&
|
||||
@ -39,36 +27,15 @@ export const CurrentSubscriptionSummary = ({
|
||||
<Heading fontSize="3xl">{scopedT('heading')}</Heading>
|
||||
<HStack data-testid="current-subscription">
|
||||
<Text>{scopedT('subheading')} </Text>
|
||||
{isCancelling ? (
|
||||
<Spinner color="gray.500" size="xs" />
|
||||
) : (
|
||||
<>
|
||||
<PlanTag plan={workspace.plan} />
|
||||
{isSubscribed && (
|
||||
<Link
|
||||
as="button"
|
||||
color="gray.500"
|
||||
textDecor="underline"
|
||||
fontSize="sm"
|
||||
onClick={() =>
|
||||
cancelSubscription({ workspaceId: workspace.id })
|
||||
}
|
||||
>
|
||||
{scopedT('cancelLink')}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
<PlanTag plan={workspace.plan} />
|
||||
{data?.subscription?.cancelDate && (
|
||||
<Text fontSize="sm">
|
||||
(Will be cancelled on {data.subscription.cancelDate.toDateString()})
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{isSubscribed && !isCancelling && (
|
||||
<>
|
||||
<Stack spacing="4">
|
||||
<Text fontSize="sm">{scopedT('billingPortalDescription')}</Text>
|
||||
<BillingPortalButton workspaceId={workspace.id} />
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
{isSubscribed && <BillingPortalButton workspaceId={workspace.id} />}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ export type PreCheckoutModalProps = {
|
||||
additionalChats: number
|
||||
additionalStorage: number
|
||||
currency: 'eur' | 'usd'
|
||||
isYearly: boolean
|
||||
}
|
||||
| undefined
|
||||
existingCompany?: string
|
||||
|
@ -15,10 +15,9 @@ import {
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronLeftIcon } from '@/components/icons'
|
||||
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { parseNumberWithCommas } from '@typebot.io/lib'
|
||||
import { isDefined, parseNumberWithCommas } from '@typebot.io/lib'
|
||||
import {
|
||||
chatsLimit,
|
||||
computePrice,
|
||||
@ -30,12 +29,23 @@ import {
|
||||
import { FeaturesList } from './FeaturesList'
|
||||
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
|
||||
import { useI18n, useScopedI18n } from '@/locales'
|
||||
import { Workspace } from '@typebot.io/schemas'
|
||||
|
||||
type Props = {
|
||||
initialChatsLimitIndex?: number
|
||||
initialStorageLimitIndex?: number
|
||||
workspace: Pick<
|
||||
Workspace,
|
||||
| 'additionalChatsIndex'
|
||||
| 'additionalStorageIndex'
|
||||
| 'plan'
|
||||
| 'customChatsLimit'
|
||||
| 'customStorageLimit'
|
||||
>
|
||||
currentSubscription: {
|
||||
isYearly?: boolean
|
||||
}
|
||||
currency?: 'usd' | 'eur'
|
||||
isLoading: boolean
|
||||
isYearly: boolean
|
||||
onPayClick: (props: {
|
||||
selectedChatsLimitIndex: number
|
||||
selectedStorageLimitIndex: number
|
||||
@ -43,15 +53,15 @@ type Props = {
|
||||
}
|
||||
|
||||
export const ProPlanPricingCard = ({
|
||||
initialChatsLimitIndex,
|
||||
initialStorageLimitIndex,
|
||||
workspace,
|
||||
currentSubscription,
|
||||
currency,
|
||||
isLoading,
|
||||
isYearly,
|
||||
onPayClick,
|
||||
}: Props) => {
|
||||
const t = useI18n()
|
||||
const scopedT = useScopedI18n('billing.pricingCard')
|
||||
const { workspace } = useWorkspace()
|
||||
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
|
||||
useState<number>()
|
||||
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
|
||||
@ -59,20 +69,23 @@ export const ProPlanPricingCard = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedChatsLimitIndex === undefined &&
|
||||
initialChatsLimitIndex !== undefined
|
||||
isDefined(selectedChatsLimitIndex) ||
|
||||
isDefined(selectedStorageLimitIndex)
|
||||
)
|
||||
setSelectedChatsLimitIndex(initialChatsLimitIndex)
|
||||
if (
|
||||
selectedStorageLimitIndex === undefined &&
|
||||
initialStorageLimitIndex !== undefined
|
||||
)
|
||||
setSelectedStorageLimitIndex(initialStorageLimitIndex)
|
||||
return
|
||||
if (workspace.plan !== Plan.PRO) {
|
||||
setSelectedChatsLimitIndex(0)
|
||||
setSelectedStorageLimitIndex(0)
|
||||
return
|
||||
}
|
||||
setSelectedChatsLimitIndex(workspace.additionalChatsIndex ?? 0)
|
||||
setSelectedStorageLimitIndex(workspace.additionalStorageIndex ?? 0)
|
||||
}, [
|
||||
initialChatsLimitIndex,
|
||||
initialStorageLimitIndex,
|
||||
selectedChatsLimitIndex,
|
||||
selectedStorageLimitIndex,
|
||||
workspace.additionalChatsIndex,
|
||||
workspace.additionalStorageIndex,
|
||||
workspace?.plan,
|
||||
])
|
||||
|
||||
const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined
|
||||
@ -81,14 +94,11 @@ export const ProPlanPricingCard = ({
|
||||
: undefined
|
||||
|
||||
const isCurrentPlan =
|
||||
chatsLimit[Plan.PRO].totalIncluded +
|
||||
chatsLimit[Plan.PRO].increaseStep.amount *
|
||||
(selectedChatsLimitIndex ?? 0) ===
|
||||
workspaceChatsLimit &&
|
||||
storageLimit[Plan.PRO].totalIncluded +
|
||||
storageLimit[Plan.PRO].increaseStep.amount *
|
||||
(selectedStorageLimitIndex ?? 0) ===
|
||||
workspaceStorageLimit
|
||||
chatsLimit[Plan.PRO].graduatedPrice[selectedChatsLimitIndex ?? 0]
|
||||
.totalIncluded === workspaceChatsLimit &&
|
||||
storageLimit[Plan.PRO].graduatedPrice[selectedStorageLimitIndex ?? 0]
|
||||
.totalIncluded === workspaceStorageLimit &&
|
||||
isYearly === currentSubscription?.isYearly
|
||||
|
||||
const getButtonLabel = () => {
|
||||
if (
|
||||
@ -100,8 +110,8 @@ export const ProPlanPricingCard = ({
|
||||
if (isCurrentPlan) return scopedT('upgradeButton.current')
|
||||
|
||||
if (
|
||||
selectedChatsLimitIndex !== initialChatsLimitIndex ||
|
||||
selectedStorageLimitIndex !== initialStorageLimitIndex
|
||||
selectedChatsLimitIndex !== workspace.additionalChatsIndex ||
|
||||
selectedStorageLimitIndex !== workspace.additionalStorageIndex
|
||||
)
|
||||
return t('update')
|
||||
}
|
||||
@ -149,7 +159,11 @@ export const ProPlanPricingCard = ({
|
||||
<Stack spacing="4" mt={2}>
|
||||
<Heading fontSize="2xl">
|
||||
{scopedT('heading', {
|
||||
plan: <chakra.span color="blue.400">Pro</chakra.span>,
|
||||
plan: (
|
||||
<chakra.span color={useColorModeValue('blue.400', 'blue.300')}>
|
||||
Pro
|
||||
</chakra.span>
|
||||
),
|
||||
})}
|
||||
</Heading>
|
||||
<Text>{scopedT('pro.description')}</Text>
|
||||
@ -160,7 +174,8 @@ export const ProPlanPricingCard = ({
|
||||
computePrice(
|
||||
Plan.PRO,
|
||||
selectedChatsLimitIndex ?? 0,
|
||||
selectedStorageLimitIndex ?? 0
|
||||
selectedStorageLimitIndex ?? 0,
|
||||
isYearly ? 'yearly' : 'monthly'
|
||||
) ?? NaN,
|
||||
currency
|
||||
)}
|
||||
@ -201,50 +216,21 @@ export const ProPlanPricingCard = ({
|
||||
>
|
||||
{selectedChatsLimitIndex !== undefined
|
||||
? parseNumberWithCommas(
|
||||
chatsLimit.PRO.totalIncluded +
|
||||
chatsLimit.PRO.increaseStep.amount *
|
||||
selectedChatsLimitIndex
|
||||
chatsLimit.PRO.graduatedPrice[
|
||||
selectedChatsLimitIndex
|
||||
].totalIncluded
|
||||
)
|
||||
: undefined}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{selectedChatsLimitIndex !== 0 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(0)}>
|
||||
{parseNumberWithCommas(chatsLimit.PRO.totalIncluded)}
|
||||
{chatsLimit.PRO.graduatedPrice.map((price, index) => (
|
||||
<MenuItem
|
||||
key={index}
|
||||
onClick={() => setSelectedChatsLimitIndex(index)}
|
||||
>
|
||||
{parseNumberWithCommas(price.totalIncluded)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 1 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(1)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.PRO.totalIncluded +
|
||||
chatsLimit.PRO.increaseStep.amount
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 2 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(2)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.PRO.totalIncluded +
|
||||
chatsLimit.PRO.increaseStep.amount * 2
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 3 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(3)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.PRO.totalIncluded +
|
||||
chatsLimit.PRO.increaseStep.amount * 3
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 4 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(4)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.PRO.totalIncluded +
|
||||
chatsLimit.PRO.increaseStep.amount * 4
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>{' '}
|
||||
{scopedT('chatsPerMonth')}
|
||||
@ -262,62 +248,21 @@ export const ProPlanPricingCard = ({
|
||||
>
|
||||
{selectedStorageLimitIndex !== undefined
|
||||
? parseNumberWithCommas(
|
||||
storageLimit.PRO.totalIncluded +
|
||||
storageLimit.PRO.increaseStep.amount *
|
||||
selectedStorageLimitIndex
|
||||
storageLimit.PRO.graduatedPrice[
|
||||
selectedStorageLimitIndex
|
||||
].totalIncluded
|
||||
)
|
||||
: undefined}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{selectedStorageLimitIndex !== 0 && (
|
||||
{storageLimit.PRO.graduatedPrice.map((price, index) => (
|
||||
<MenuItem
|
||||
onClick={() => setSelectedStorageLimitIndex(0)}
|
||||
key={index}
|
||||
onClick={() => setSelectedStorageLimitIndex(index)}
|
||||
>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.PRO.totalIncluded
|
||||
)}
|
||||
{parseNumberWithCommas(price.totalIncluded)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 1 && (
|
||||
<MenuItem
|
||||
onClick={() => setSelectedStorageLimitIndex(1)}
|
||||
>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.PRO.totalIncluded +
|
||||
storageLimit.PRO.increaseStep.amount
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 2 && (
|
||||
<MenuItem
|
||||
onClick={() => setSelectedStorageLimitIndex(2)}
|
||||
>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.PRO.totalIncluded +
|
||||
storageLimit.PRO.increaseStep.amount * 2
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 3 && (
|
||||
<MenuItem
|
||||
onClick={() => setSelectedStorageLimitIndex(3)}
|
||||
>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.PRO.totalIncluded +
|
||||
storageLimit.PRO.increaseStep.amount * 3
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 4 && (
|
||||
<MenuItem
|
||||
onClick={() => setSelectedStorageLimitIndex(4)}
|
||||
>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.PRO.totalIncluded +
|
||||
storageLimit.PRO.increaseStep.amount * 4
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>{' '}
|
||||
{scopedT('storageLimit')}
|
||||
|
@ -11,10 +11,9 @@ import {
|
||||
Text,
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronLeftIcon } from '@/components/icons'
|
||||
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { parseNumberWithCommas } from '@typebot.io/lib'
|
||||
import { isDefined, parseNumberWithCommas } from '@typebot.io/lib'
|
||||
import {
|
||||
chatsLimit,
|
||||
computePrice,
|
||||
@ -26,12 +25,23 @@ import {
|
||||
import { FeaturesList } from './FeaturesList'
|
||||
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
|
||||
import { useI18n, useScopedI18n } from '@/locales'
|
||||
import { Workspace } from '@typebot.io/schemas'
|
||||
|
||||
type Props = {
|
||||
initialChatsLimitIndex?: number
|
||||
initialStorageLimitIndex?: number
|
||||
workspace: Pick<
|
||||
Workspace,
|
||||
| 'additionalChatsIndex'
|
||||
| 'additionalStorageIndex'
|
||||
| 'plan'
|
||||
| 'customChatsLimit'
|
||||
| 'customStorageLimit'
|
||||
>
|
||||
currentSubscription: {
|
||||
isYearly?: boolean
|
||||
}
|
||||
currency?: 'eur' | 'usd'
|
||||
isLoading?: boolean
|
||||
isYearly: boolean
|
||||
onPayClick: (props: {
|
||||
selectedChatsLimitIndex: number
|
||||
selectedStorageLimitIndex: number
|
||||
@ -39,15 +49,15 @@ type Props = {
|
||||
}
|
||||
|
||||
export const StarterPlanPricingCard = ({
|
||||
initialChatsLimitIndex,
|
||||
initialStorageLimitIndex,
|
||||
workspace,
|
||||
currentSubscription,
|
||||
isLoading,
|
||||
currency,
|
||||
isYearly,
|
||||
onPayClick,
|
||||
}: Props) => {
|
||||
const t = useI18n()
|
||||
const scopedT = useScopedI18n('billing.pricingCard')
|
||||
const { workspace } = useWorkspace()
|
||||
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
|
||||
useState<number>()
|
||||
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
|
||||
@ -55,20 +65,23 @@ export const StarterPlanPricingCard = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedChatsLimitIndex === undefined &&
|
||||
initialChatsLimitIndex !== undefined
|
||||
isDefined(selectedChatsLimitIndex) ||
|
||||
isDefined(selectedStorageLimitIndex)
|
||||
)
|
||||
setSelectedChatsLimitIndex(initialChatsLimitIndex)
|
||||
if (
|
||||
selectedStorageLimitIndex === undefined &&
|
||||
initialStorageLimitIndex !== undefined
|
||||
)
|
||||
setSelectedStorageLimitIndex(initialStorageLimitIndex)
|
||||
return
|
||||
if (workspace.plan !== Plan.STARTER) {
|
||||
setSelectedChatsLimitIndex(0)
|
||||
setSelectedStorageLimitIndex(0)
|
||||
return
|
||||
}
|
||||
setSelectedChatsLimitIndex(workspace.additionalChatsIndex ?? 0)
|
||||
setSelectedStorageLimitIndex(workspace.additionalStorageIndex ?? 0)
|
||||
}, [
|
||||
initialChatsLimitIndex,
|
||||
initialStorageLimitIndex,
|
||||
selectedChatsLimitIndex,
|
||||
selectedStorageLimitIndex,
|
||||
workspace.additionalChatsIndex,
|
||||
workspace.additionalStorageIndex,
|
||||
workspace?.plan,
|
||||
])
|
||||
|
||||
const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined
|
||||
@ -77,14 +90,11 @@ export const StarterPlanPricingCard = ({
|
||||
: undefined
|
||||
|
||||
const isCurrentPlan =
|
||||
chatsLimit[Plan.STARTER].totalIncluded +
|
||||
chatsLimit[Plan.STARTER].increaseStep.amount *
|
||||
(selectedChatsLimitIndex ?? 0) ===
|
||||
workspaceChatsLimit &&
|
||||
storageLimit[Plan.STARTER].totalIncluded +
|
||||
storageLimit[Plan.STARTER].increaseStep.amount *
|
||||
(selectedStorageLimitIndex ?? 0) ===
|
||||
workspaceStorageLimit
|
||||
chatsLimit[Plan.STARTER].graduatedPrice[selectedChatsLimitIndex ?? 0]
|
||||
.totalIncluded === workspaceChatsLimit &&
|
||||
storageLimit[Plan.STARTER].graduatedPrice[selectedStorageLimitIndex ?? 0]
|
||||
.totalIncluded === workspaceStorageLimit &&
|
||||
isYearly === currentSubscription?.isYearly
|
||||
|
||||
const getButtonLabel = () => {
|
||||
if (
|
||||
@ -97,8 +107,9 @@ export const StarterPlanPricingCard = ({
|
||||
if (isCurrentPlan) return scopedT('upgradeButton.current')
|
||||
|
||||
if (
|
||||
selectedChatsLimitIndex !== initialChatsLimitIndex ||
|
||||
selectedStorageLimitIndex !== initialStorageLimitIndex
|
||||
selectedChatsLimitIndex !== workspace.additionalChatsIndex ||
|
||||
selectedStorageLimitIndex !== workspace.additionalStorageIndex ||
|
||||
isYearly !== currentSubscription?.isYearly
|
||||
)
|
||||
return t('update')
|
||||
}
|
||||
@ -131,7 +142,8 @@ export const StarterPlanPricingCard = ({
|
||||
computePrice(
|
||||
Plan.STARTER,
|
||||
selectedChatsLimitIndex ?? 0,
|
||||
selectedStorageLimitIndex ?? 0
|
||||
selectedStorageLimitIndex ?? 0,
|
||||
isYearly ? 'yearly' : 'monthly'
|
||||
) ?? NaN,
|
||||
currency
|
||||
)}
|
||||
@ -151,52 +163,21 @@ export const StarterPlanPricingCard = ({
|
||||
>
|
||||
{selectedChatsLimitIndex !== undefined
|
||||
? parseNumberWithCommas(
|
||||
chatsLimit.STARTER.totalIncluded +
|
||||
chatsLimit.STARTER.increaseStep.amount *
|
||||
selectedChatsLimitIndex
|
||||
chatsLimit.STARTER.graduatedPrice[
|
||||
selectedChatsLimitIndex
|
||||
].totalIncluded
|
||||
)
|
||||
: undefined}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{selectedChatsLimitIndex !== 0 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(0)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.STARTER.totalIncluded
|
||||
)}
|
||||
{chatsLimit.STARTER.graduatedPrice.map((price, index) => (
|
||||
<MenuItem
|
||||
key={index}
|
||||
onClick={() => setSelectedChatsLimitIndex(index)}
|
||||
>
|
||||
{parseNumberWithCommas(price.totalIncluded)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 1 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(1)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.STARTER.totalIncluded +
|
||||
chatsLimit.STARTER.increaseStep.amount
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 2 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(2)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.STARTER.totalIncluded +
|
||||
chatsLimit.STARTER.increaseStep.amount * 2
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 3 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(3)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.STARTER.totalIncluded +
|
||||
chatsLimit.STARTER.increaseStep.amount * 3
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 4 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(4)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.STARTER.totalIncluded +
|
||||
chatsLimit.STARTER.increaseStep.amount * 4
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>{' '}
|
||||
{scopedT('chatsPerMonth')}
|
||||
@ -214,52 +195,21 @@ export const StarterPlanPricingCard = ({
|
||||
>
|
||||
{selectedStorageLimitIndex !== undefined
|
||||
? parseNumberWithCommas(
|
||||
storageLimit.STARTER.totalIncluded +
|
||||
storageLimit.STARTER.increaseStep.amount *
|
||||
selectedStorageLimitIndex
|
||||
storageLimit.STARTER.graduatedPrice[
|
||||
selectedStorageLimitIndex
|
||||
].totalIncluded
|
||||
)
|
||||
: undefined}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{selectedStorageLimitIndex !== 0 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(0)}>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.STARTER.totalIncluded
|
||||
)}
|
||||
{storageLimit.STARTER.graduatedPrice.map((price, index) => (
|
||||
<MenuItem
|
||||
key={index}
|
||||
onClick={() => setSelectedStorageLimitIndex(index)}
|
||||
>
|
||||
{parseNumberWithCommas(price.totalIncluded)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 1 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(1)}>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.STARTER.totalIncluded +
|
||||
storageLimit.STARTER.increaseStep.amount
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 2 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(2)}>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.STARTER.totalIncluded +
|
||||
storageLimit.STARTER.increaseStep.amount * 2
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 3 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(3)}>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.STARTER.totalIncluded +
|
||||
storageLimit.STARTER.increaseStep.amount * 3
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 4 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(4)}>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.STARTER.totalIncluded +
|
||||
storageLimit.STARTER.increaseStep.amount * 4
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>{' '}
|
||||
{scopedT('storageLimit')}
|
||||
|
@ -1,16 +1,19 @@
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
import {
|
||||
getChatsLimit,
|
||||
getStorageLimit,
|
||||
priceIds,
|
||||
} from '@typebot.io/lib/pricing'
|
||||
|
||||
export const parseSubscriptionItems = (
|
||||
plan: Plan,
|
||||
plan: 'STARTER' | 'PRO',
|
||||
additionalChats: number,
|
||||
additionalStorage: number
|
||||
) =>
|
||||
[
|
||||
additionalStorage: number,
|
||||
isYearly: boolean
|
||||
) => {
|
||||
const frequency = isYearly ? 'yearly' : 'monthly'
|
||||
return [
|
||||
{
|
||||
price:
|
||||
plan === Plan.STARTER
|
||||
? process.env.STRIPE_STARTER_PRICE_ID
|
||||
: process.env.STRIPE_PRO_PRICE_ID,
|
||||
price: priceIds[plan].base[frequency],
|
||||
quantity: 1,
|
||||
},
|
||||
]
|
||||
@ -18,8 +21,12 @@ export const parseSubscriptionItems = (
|
||||
additionalChats > 0
|
||||
? [
|
||||
{
|
||||
price: process.env.STRIPE_ADDITIONAL_CHATS_PRICE_ID,
|
||||
quantity: additionalChats,
|
||||
price: priceIds[plan].chats[frequency],
|
||||
quantity: getChatsLimit({
|
||||
plan,
|
||||
additionalChatsIndex: additionalChats,
|
||||
customChatsLimit: null,
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []
|
||||
@ -28,9 +35,14 @@ export const parseSubscriptionItems = (
|
||||
additionalStorage > 0
|
||||
? [
|
||||
{
|
||||
price: process.env.STRIPE_ADDITIONAL_STORAGE_PRICE_ID,
|
||||
quantity: additionalStorage,
|
||||
price: priceIds[plan].storage[frequency],
|
||||
quantity: getStorageLimit({
|
||||
plan,
|
||||
additionalStorageIndex: additionalStorage,
|
||||
customStorageLimit: null,
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []
|
||||
)
|
||||
}
|
||||
|
@ -26,10 +26,11 @@ export const DashboardPage = () => {
|
||||
useState<PreCheckoutModalProps['selectedSubscription']>()
|
||||
|
||||
useEffect(() => {
|
||||
const { subscribePlan, chats, storage } = query as {
|
||||
const { subscribePlan, chats, storage, isYearly } = query as {
|
||||
subscribePlan: Plan | undefined
|
||||
chats: string | undefined
|
||||
storage: string | undefined
|
||||
isYearly: string | undefined
|
||||
}
|
||||
if (workspace && subscribePlan && user && workspace.plan === 'FREE') {
|
||||
setIsLoading(true)
|
||||
@ -39,6 +40,7 @@ export const DashboardPage = () => {
|
||||
additionalChats: chats ? parseInt(chats) : 0,
|
||||
additionalStorage: storage ? parseInt(storage) : 0,
|
||||
currency: guessIfUserIsEuropean() ? 'eur' : 'usd',
|
||||
isYearly: isYearly === 'false' ? false : true,
|
||||
})
|
||||
}
|
||||
}, [query, user, workspace])
|
||||
|
@ -103,8 +103,6 @@ export default {
|
||||
'billing.currentSubscription.heading': 'Subscription',
|
||||
'billing.currentSubscription.subheading': 'Current workspace subscription:',
|
||||
'billing.currentSubscription.cancelLink': 'Cancel my subscription',
|
||||
'billing.currentSubscription.billingPortalDescription':
|
||||
'Need to change payment method or billing information? Head over to your billing portal:',
|
||||
'billing.invoices.heading': 'Invoices',
|
||||
'billing.invoices.empty': 'No invoices found for this workspace.',
|
||||
'billing.invoices.paidAt': 'Paid at',
|
||||
|
@ -109,8 +109,6 @@ export default defineLocale({
|
||||
'billing.currentSubscription.heading': 'Abonnement',
|
||||
'billing.currentSubscription.subheading': 'Abonnement actuel du workspace :',
|
||||
'billing.currentSubscription.cancelLink': "Annuler l'abonnement",
|
||||
'billing.currentSubscription.billingPortalDescription':
|
||||
'Besoin de changer votre mode de paiement ou vos informations de facturation ? Rendez-vous sur votre portail de facturation :',
|
||||
'billing.invoices.heading': 'Factures',
|
||||
'billing.invoices.empty': 'Aucune facture trouvée pour ce workspace.',
|
||||
'billing.invoices.paidAt': 'Payé le',
|
||||
|
@ -109,8 +109,6 @@ export default defineLocale({
|
||||
'billing.currentSubscription.subheading':
|
||||
'Assinatura atual do espaço de trabalho:',
|
||||
'billing.currentSubscription.cancelLink': 'Cancelar minha assinatura',
|
||||
'billing.currentSubscription.billingPortalDescription':
|
||||
'Precisa alterar o método de pagamento ou as informações de cobrança? Acesse seu portal de cobrança:',
|
||||
'billing.invoices.heading': 'Faturas',
|
||||
'billing.invoices.empty':
|
||||
'Nenhuma fatura encontrada para este espaço de trabalho.',
|
||||
|
@ -52,6 +52,19 @@ export const addSubscriptionToWorkspace = async (
|
||||
...metadata,
|
||||
},
|
||||
})
|
||||
return stripeId
|
||||
}
|
||||
|
||||
export const cancelSubscription = async (stripeId: string) => {
|
||||
const currentSubscriptionId = (
|
||||
await stripe.subscriptions.list({
|
||||
customer: stripeId,
|
||||
})
|
||||
).data.shift()?.id
|
||||
if (currentSubscriptionId)
|
||||
await stripe.subscriptions.update(currentSubscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
})
|
||||
}
|
||||
|
||||
export const createCollaboration = (
|
||||
|
@ -201,15 +201,26 @@ The related environment variables are listed here but you are probably not inter
|
||||
<details><summary><h4>Stripe</h4></summary>
|
||||
<p>
|
||||
|
||||
| Parameter | Default | Description |
|
||||
| ---------------------------------- | ------- | --------------------------- |
|
||||
| NEXT_PUBLIC_STRIPE_PUBLIC_KEY | | Stripe public key |
|
||||
| STRIPE_SECRET_KEY | | Stripe secret key |
|
||||
| STRIPE_PRO_PRICE_ID | | Pro plan price id |
|
||||
| STRIPE_STARTER_PRICE_ID | | Starter plan price id |
|
||||
| STRIPE_ADDITIONAL_CHATS_PRICE_ID | | Additional chats price id |
|
||||
| STRIPE_ADDITIONAL_STORAGE_PRICE_ID | | Additional storage price id |
|
||||
| STRIPE_WEBHOOK_SECRET | | Stripe Webhook secret |
|
||||
| Parameter | Default | Description |
|
||||
| --------------------------------------- | ------- | ------------------------------------------- |
|
||||
| NEXT_PUBLIC_STRIPE_PUBLIC_KEY | | Stripe public key |
|
||||
| STRIPE_SECRET_KEY | | Stripe secret key |
|
||||
| STRIPE_STARTER_PRODUCT_ID | | Starter plan product ID |
|
||||
| STRIPE_STARTER_MONTHLY_PRICE_ID | | Starter monthly plan price id |
|
||||
| STRIPE_STARTER_YEARLY_PRICE_ID | | Starter yearly plan price id |
|
||||
| STRIPE_PRO_PRODUCT_ID | | Pro plan product ID |
|
||||
| STRIPE_PRO_MONTHLY_PRICE_ID | | Pro monthly plan price id |
|
||||
| STRIPE_PRO_YEARLY_PRICE_ID | | Pro yearly plan price id |
|
||||
| STRIPE_STARTER_CHATS_MONTHLY_PRICE_ID | | Starter Additional chats monthly price id |
|
||||
| STRIPE_STARTER_CHATS_YEARLY_PRICE_ID | | Starter Additional chats yearly price id |
|
||||
| STRIPE_PRO_CHATS_MONTHLY_PRICE_ID | | Pro Additional chats monthly price id |
|
||||
| STRIPE_PRO_CHATS_YEARLY_PRICE_ID | | Pro Additional chats yearly price id |
|
||||
| STRIPE_STARTER_STORAGE_MONTHLY_PRICE_ID | | Starter Additional storage monthly price id |
|
||||
| STRIPE_STARTER_STORAGE_YEARLY_PRICE_ID | | Starter Additional storage yearly price id |
|
||||
| STRIPE_PRO_STORAGE_MONTHLY_PRICE_ID | | Pro Additional storage monthly price id |
|
||||
| STRIPE_PRO_STORAGE_YEARLY_PRICE_ID | | Pro Additional storage yearly price id |
|
||||
| STRIPE_ADDITIONAL_STORAGE_PRICE_ID | | Additional storage price id |
|
||||
| STRIPE_WEBHOOK_SECRET | | Stripe Webhook secret |
|
||||
|
||||
</p></details>
|
||||
|
||||
|
@ -4382,10 +4382,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/billing/subscription": {
|
||||
"delete": {
|
||||
"operationId": "mutation.billing.cancelSubscription",
|
||||
"summary": "Cancel current subscription",
|
||||
"/billing/subscription/checkout": {
|
||||
"post": {
|
||||
"operationId": "mutation.billing.createCheckoutSession",
|
||||
"summary": "Create checkout session to create a new subscription",
|
||||
"tags": [
|
||||
"Billing"
|
||||
],
|
||||
@ -4394,16 +4394,85 @@
|
||||
"Authorization": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "workspaceId",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"company": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspaceId": {
|
||||
"type": "string"
|
||||
},
|
||||
"prefilledEmail": {
|
||||
"type": "string"
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"usd",
|
||||
"eur"
|
||||
]
|
||||
},
|
||||
"plan": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"STARTER",
|
||||
"PRO"
|
||||
]
|
||||
},
|
||||
"returnUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalChats": {
|
||||
"type": "number"
|
||||
},
|
||||
"additionalStorage": {
|
||||
"type": "number"
|
||||
},
|
||||
"vat": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"value"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"isYearly": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email",
|
||||
"company",
|
||||
"workspaceId",
|
||||
"currency",
|
||||
"plan",
|
||||
"returnUrl",
|
||||
"additionalChats",
|
||||
"additionalStorage",
|
||||
"isYearly"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
},
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
@ -4412,15 +4481,12 @@
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"success"
|
||||
]
|
||||
"checkoutUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
"checkoutUrl"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@ -4431,7 +4497,9 @@
|
||||
"$ref": "#/components/responses/error"
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/billing/subscription": {
|
||||
"patch": {
|
||||
"operationId": "mutation.billing.updateSubscription",
|
||||
"summary": "Update subscription",
|
||||
@ -4472,6 +4540,9 @@
|
||||
"usd",
|
||||
"eur"
|
||||
]
|
||||
},
|
||||
"isYearly": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@ -4479,7 +4550,8 @@
|
||||
"plan",
|
||||
"additionalChats",
|
||||
"additionalStorage",
|
||||
"currency"
|
||||
"currency",
|
||||
"isYearly"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@ -4635,28 +4707,38 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subscription": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"additionalChatsIndex": {
|
||||
"type": "number"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"isYearly": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"eur",
|
||||
"usd"
|
||||
]
|
||||
},
|
||||
"cancelDate": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"isYearly",
|
||||
"currency"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"additionalStorageIndex": {
|
||||
"type": "number"
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
{
|
||||
"enum": [
|
||||
"eur",
|
||||
"usd"
|
||||
]
|
||||
"null"
|
||||
],
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"additionalChatsIndex",
|
||||
"additionalStorageIndex",
|
||||
"currency"
|
||||
],
|
||||
"additionalProperties": false
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@ -4673,119 +4755,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/billing/subscription/checkout": {
|
||||
"post": {
|
||||
"operationId": "mutation.billing.createCheckoutSession",
|
||||
"summary": "Create checkout session to create a new subscription",
|
||||
"tags": [
|
||||
"Billing"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"Authorization": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"company": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspaceId": {
|
||||
"type": "string"
|
||||
},
|
||||
"prefilledEmail": {
|
||||
"type": "string"
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"usd",
|
||||
"eur"
|
||||
]
|
||||
},
|
||||
"plan": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"STARTER",
|
||||
"PRO"
|
||||
]
|
||||
},
|
||||
"returnUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalChats": {
|
||||
"type": "number"
|
||||
},
|
||||
"additionalStorage": {
|
||||
"type": "number"
|
||||
},
|
||||
"vat": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"value"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email",
|
||||
"company",
|
||||
"workspaceId",
|
||||
"currency",
|
||||
"plan",
|
||||
"returnUrl",
|
||||
"additionalChats",
|
||||
"additionalStorage"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"checkoutUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"checkoutUrl"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"$ref": "#/components/responses/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/billing/usage": {
|
||||
"get": {
|
||||
"operationId": "query.billing.getUsage",
|
||||
|
@ -2,41 +2,36 @@ import Icon, { IconProps } from '@chakra-ui/icon'
|
||||
import React from 'react'
|
||||
|
||||
export const Logo = (props: IconProps) => (
|
||||
<Icon
|
||||
viewBox="0 0 500 500"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<rect width="500" height="500" rx="75" fill={'#0042DA'} />
|
||||
<Icon w="50px" h="50px" viewBox="0 0 800 800" {...props}>
|
||||
<rect width="800" height="800" rx="80" fill={'#0042DA'} />
|
||||
<rect
|
||||
x="438.709"
|
||||
y="170.968"
|
||||
width="64.5161"
|
||||
height="290.323"
|
||||
rx="32.2581"
|
||||
transform="rotate(90 438.709 170.968)"
|
||||
x="650"
|
||||
y="293"
|
||||
width="85.4704"
|
||||
height="384.617"
|
||||
rx="20"
|
||||
transform="rotate(90 650 293)"
|
||||
fill="#FF8E20"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M93.5481 235.484C111.364 235.484 125.806 221.041 125.806 203.226C125.806 185.41 111.364 170.968 93.5481 170.968C75.7325 170.968 61.29 185.41 61.29 203.226C61.29 221.041 75.7325 235.484 93.5481 235.484Z"
|
||||
d="M192.735 378.47C216.337 378.47 235.47 359.337 235.47 335.735C235.47 312.133 216.337 293 192.735 293C169.133 293 150 312.133 150 335.735C150 359.337 169.133 378.47 192.735 378.47Z"
|
||||
fill="#FF8E20"
|
||||
/>
|
||||
<rect
|
||||
x="61.29"
|
||||
y="332.259"
|
||||
width="64.5161"
|
||||
height="290.323"
|
||||
rx="32.2581"
|
||||
transform="rotate(-90 61.29 332.259)"
|
||||
x="150"
|
||||
y="506.677"
|
||||
width="85.4704"
|
||||
height="384.617"
|
||||
rx="20"
|
||||
transform="rotate(-90 150 506.677)"
|
||||
fill={'white'}
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M406.451 267.742C388.635 267.742 374.193 282.184 374.193 300C374.193 317.815 388.635 332.258 406.451 332.258C424.267 332.258 438.709 317.815 438.709 300C438.709 282.184 424.267 267.742 406.451 267.742Z"
|
||||
d="M607.265 421.206C583.663 421.206 564.53 440.34 564.53 463.942C564.53 487.544 583.663 506.677 607.265 506.677C630.867 506.677 650 487.544 650 463.942C650 440.34 630.867 421.206 607.265 421.206Z"
|
||||
fill={'white'}
|
||||
/>
|
||||
</Icon>
|
||||
|
@ -33,7 +33,7 @@ export const Hero = () => {
|
||||
bgClip="text"
|
||||
data-aos="fade-up"
|
||||
>
|
||||
Open-source conversational forms builder
|
||||
Build advanced chatbots visually
|
||||
</Heading>
|
||||
<Text
|
||||
fontSize={['lg', 'xl']}
|
||||
|
@ -49,7 +49,7 @@ export const IntroducingChatApps = () => {
|
||||
textAlign="center"
|
||||
data-aos="fade"
|
||||
>
|
||||
Introducing Conversational Apps
|
||||
Replace your old school forms with chatbots
|
||||
</Heading>
|
||||
<Text
|
||||
textAlign="center"
|
||||
|
@ -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>
|
||||
)
|
86
apps/landing-page/components/PricingPage/Faq.tsx
Normal file
86
apps/landing-page/components/PricingPage/Faq.tsx
Normal 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'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'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'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>
|
||||
)
|
@ -3,6 +3,7 @@ import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon'
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
import { PricingCard } from './PricingCard'
|
||||
import { chatsLimit } from '@typebot.io/lib/pricing'
|
||||
|
||||
export const FreePlanCard = () => (
|
||||
<PricingCard
|
||||
@ -13,7 +14,10 @@ export const FreePlanCard = () => (
|
||||
'Unlimited typebots',
|
||||
<>
|
||||
<Text>
|
||||
<chakra.span fontWeight="bold">300 chats</chakra.span> included
|
||||
<chakra.span fontWeight="bold">
|
||||
{chatsLimit.FREE.totalIncluded}
|
||||
</chakra.span>{' '}
|
||||
chats/month
|
||||
</Text>
|
||||
|
||||
<Tooltip
|
||||
@ -37,7 +41,7 @@ export const FreePlanCard = () => (
|
||||
as={Link}
|
||||
href="https://app.typebot.io/register"
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
colorScheme="gray"
|
||||
size="lg"
|
||||
w="full"
|
||||
fontWeight="extrabold"
|
||||
|
@ -8,7 +8,6 @@ import {
|
||||
Td,
|
||||
Text,
|
||||
Stack,
|
||||
StackProps,
|
||||
HStack,
|
||||
Tooltip,
|
||||
chakra,
|
||||
@ -19,367 +18,383 @@ import { CheckIcon } from 'assets/icons/CheckIcon'
|
||||
import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon'
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
import Link from 'next/link'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { chatsLimit, formatPrice, storageLimit } from '@typebot.io/lib/pricing'
|
||||
import React from 'react'
|
||||
import {
|
||||
chatsLimit,
|
||||
formatPrice,
|
||||
prices,
|
||||
seatsLimit,
|
||||
storageLimit,
|
||||
} from '@typebot.io/lib/pricing'
|
||||
import { parseNumberWithCommas } from '@typebot.io/lib'
|
||||
|
||||
type Props = {
|
||||
starterPrice: string
|
||||
proPrice: string
|
||||
} & StackProps
|
||||
|
||||
export const PlanComparisonTables = ({
|
||||
starterPrice,
|
||||
proPrice,
|
||||
...props
|
||||
}: Props) => {
|
||||
const [additionalChatsPrice, setAdditionalChatsPrice] = useState(
|
||||
`$${chatsLimit.STARTER.increaseStep.price}`
|
||||
)
|
||||
const [additionalStoragePrice, setAdditionalStoragePrice] = useState(
|
||||
`$${storageLimit.STARTER.increaseStep.price}`
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setAdditionalChatsPrice(formatPrice(chatsLimit.STARTER.increaseStep.price))
|
||||
setAdditionalStoragePrice(
|
||||
formatPrice(storageLimit.STARTER.increaseStep.price)
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Stack spacing="12" {...props}>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th fontWeight="bold" color="white" w="400px">
|
||||
Usage
|
||||
</Th>
|
||||
<Th>Free</Th>
|
||||
<Th color="orange.200">Starter</Th>
|
||||
<Th color="blue.200">Pro</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
<Tr>
|
||||
<Td>Total bots</Td>
|
||||
<Td>Unlimited</Td>
|
||||
<Td>Unlimited</Td>
|
||||
<Td>Unlimited</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Chats</Td>
|
||||
<Td>300 / month</Td>
|
||||
<Td>2,000 / month</Td>
|
||||
<Td>10,000 / month</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Additional Chats</Td>
|
||||
<Td />
|
||||
<Td>{additionalChatsPrice} per 500</Td>
|
||||
<Td>{additionalChatsPrice} per 1,000</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Storage</Td>
|
||||
<Td />
|
||||
<Td>2 GB</Td>
|
||||
<Td>10 GB</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Additional Storage</Td>
|
||||
<Td />
|
||||
<Td>{additionalStoragePrice} per 1 GB</Td>
|
||||
<Td>{additionalStoragePrice} per 1 GB</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Members</Td>
|
||||
<Td>Just you</Td>
|
||||
<Td>2 seats</Td>
|
||||
<Td>5 seats</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Guests</Td>
|
||||
<Td>Unlimited</Td>
|
||||
<Td>Unlimited</Td>
|
||||
<Td>Unlimited</Td>
|
||||
</Tr>
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th fontWeight="bold" color="white" w="400px">
|
||||
Features
|
||||
</Th>
|
||||
<Th>Free</Th>
|
||||
<Th color="orange.200">Starter</Th>
|
||||
<Th color="blue.200">Pro</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
<Tr>
|
||||
<TdWithTooltip
|
||||
text="20+ blocks"
|
||||
tooltip="Includes display bubbles (text, image, video, embed), question inputs (email, url, phone number...) and logic blocks (conditions, set variables...)"
|
||||
/>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Starter templates</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Webhooks</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Google Sheets</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Google Analytics</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Send emails</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Zapier</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Pabbly Connect</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Make.com</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Custom Javascript & CSS</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Export CSV</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>File upload inputs</Td>
|
||||
<Td />
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<TdWithTooltip
|
||||
text="Folders"
|
||||
tooltip="Organize your typebots into folders"
|
||||
/>
|
||||
<Td />
|
||||
<Td>Unlimited</Td>
|
||||
<Td>Unlimited</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Remove branding</Td>
|
||||
<Td />
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Custom domains</Td>
|
||||
<Td />
|
||||
<Td />
|
||||
<Td>Unlimited</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<TdWithTooltip
|
||||
text="In-depth analytics"
|
||||
tooltip="Analytics graph that shows your form drop-off rate, submission rate, and more."
|
||||
/>
|
||||
<Td />
|
||||
<Td />
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th fontWeight="bold" color="white" w="400px">
|
||||
Support
|
||||
</Th>
|
||||
<Th>Free</Th>
|
||||
<Th color="orange.200">Starter</Th>
|
||||
<Th color="blue.200">Pro</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
<Tr>
|
||||
<Td>Priority support</Td>
|
||||
<Td />
|
||||
<Td />
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Feature request priority</Td>
|
||||
<Td />
|
||||
<Td />
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Stack
|
||||
direction={['column', 'row']}
|
||||
spacing={4}
|
||||
w="full"
|
||||
justify="space-around"
|
||||
>
|
||||
<Stack spacing={4}>
|
||||
<Heading as="h3" size="md">
|
||||
Personal
|
||||
</Heading>
|
||||
<Heading as="h3">Free</Heading>
|
||||
<Link href="https://app.typebot.io/register">
|
||||
<Button variant="outline">Get started</Button>
|
||||
</Link>
|
||||
</Stack>
|
||||
<Stack spacing={4}>
|
||||
<Heading as="h3" size="md" color="orange.200">
|
||||
Starter
|
||||
</Heading>
|
||||
<Heading as="h3">
|
||||
{starterPrice} <chakra.span fontSize="lg">/ month</chakra.span>
|
||||
</Heading>
|
||||
<Link
|
||||
href={`https://app.typebot.io/register?subscribePlan=${Plan.STARTER}`}
|
||||
>
|
||||
<Button>Subscribe</Button>
|
||||
</Link>
|
||||
</Stack>
|
||||
<Stack spacing={4}>
|
||||
<Heading as="h3" size="md" color="blue.200">
|
||||
Pro
|
||||
</Heading>
|
||||
<Heading as="h3">
|
||||
{proPrice} <chakra.span fontSize="lg">/ month</chakra.span>
|
||||
</Heading>
|
||||
<Link
|
||||
href={`https://app.typebot.io/register?subscribePlan=${Plan.PRO}`}
|
||||
>
|
||||
<Button>Subscribe</Button>
|
||||
</Link>
|
||||
</Stack>
|
||||
export const PlanComparisonTables = () => (
|
||||
<Stack spacing="12">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th fontWeight="bold" color="white" w="400px">
|
||||
Usage
|
||||
</Th>
|
||||
<Th>Free</Th>
|
||||
<Th color="orange.200">Starter</Th>
|
||||
<Th color="blue.200">Pro</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
<Tr>
|
||||
<Td>Total bots</Td>
|
||||
<Td>Unlimited</Td>
|
||||
<Td>Unlimited</Td>
|
||||
<Td>Unlimited</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Chats</Td>
|
||||
<Td>{chatsLimit.FREE.totalIncluded} / month</Td>
|
||||
<Td>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.STARTER.graduatedPrice[0].totalIncluded
|
||||
)}{' '}
|
||||
/ month
|
||||
</Td>
|
||||
<Td>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.PRO.graduatedPrice[0].totalIncluded
|
||||
)}{' '}
|
||||
/ month
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Additional Chats</Td>
|
||||
<Td />
|
||||
<Td>
|
||||
{formatPrice(chatsLimit.STARTER.graduatedPrice[1].price)} per{' '}
|
||||
{chatsLimit.STARTER.graduatedPrice[1].totalIncluded -
|
||||
chatsLimit.STARTER.graduatedPrice[0].totalIncluded}
|
||||
</Td>
|
||||
<Td>
|
||||
{formatPrice(chatsLimit.PRO.graduatedPrice[1].price)} per{' '}
|
||||
{chatsLimit.PRO.graduatedPrice[1].totalIncluded -
|
||||
chatsLimit.PRO.graduatedPrice[0].totalIncluded}
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Storage</Td>
|
||||
<Td />
|
||||
<Td>2 GB</Td>
|
||||
<Td>10 GB</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Additional Storage</Td>
|
||||
<Td />
|
||||
<Td>
|
||||
{formatPrice(storageLimit.STARTER.graduatedPrice[1].price)} per{' '}
|
||||
{storageLimit.STARTER.graduatedPrice[1].totalIncluded -
|
||||
storageLimit.STARTER.graduatedPrice[0].totalIncluded}{' '}
|
||||
GB
|
||||
</Td>
|
||||
<Td>
|
||||
{formatPrice(storageLimit.PRO.graduatedPrice[1].price)} per{' '}
|
||||
{storageLimit.PRO.graduatedPrice[1].totalIncluded -
|
||||
storageLimit.PRO.graduatedPrice[0].totalIncluded}{' '}
|
||||
GB
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Members</Td>
|
||||
<Td>Just you</Td>
|
||||
<Td>{seatsLimit.STARTER.totalIncluded} seats</Td>
|
||||
<Td>{seatsLimit.PRO.totalIncluded} seats</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Guests</Td>
|
||||
<Td>Unlimited</Td>
|
||||
<Td>Unlimited</Td>
|
||||
<Td>Unlimited</Td>
|
||||
</Tr>
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th fontWeight="bold" color="white" w="400px">
|
||||
Features
|
||||
</Th>
|
||||
<Th>Free</Th>
|
||||
<Th color="orange.200">Starter</Th>
|
||||
<Th color="blue.200">Pro</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
<Tr>
|
||||
<TdWithTooltip
|
||||
text="20+ blocks"
|
||||
tooltip="Includes display bubbles (text, image, video, embed), question inputs (email, url, phone number...) and logic blocks (conditions, set variables...)"
|
||||
/>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Starter templates</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Webhooks</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Google Sheets</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Google Analytics</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Send emails</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Zapier</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Pabbly Connect</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Make.com</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Custom Javascript & CSS</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Export CSV</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>File upload inputs</Td>
|
||||
<Td />
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<TdWithTooltip
|
||||
text="Folders"
|
||||
tooltip="Organize your typebots into folders"
|
||||
/>
|
||||
<Td />
|
||||
<Td>Unlimited</Td>
|
||||
<Td>Unlimited</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Remove branding</Td>
|
||||
<Td />
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Custom domains</Td>
|
||||
<Td />
|
||||
<Td />
|
||||
<Td>Unlimited</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<TdWithTooltip
|
||||
text="In-depth analytics"
|
||||
tooltip="Analytics graph that shows your form drop-off rate, submission rate, and more."
|
||||
/>
|
||||
<Td />
|
||||
<Td />
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th fontWeight="bold" color="white" w="400px">
|
||||
Support
|
||||
</Th>
|
||||
<Th>Free</Th>
|
||||
<Th color="orange.200">Starter</Th>
|
||||
<Th color="blue.200">Pro</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
<Tr>
|
||||
<Td>Priority support</Td>
|
||||
<Td />
|
||||
<Td />
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Feature request priority</Td>
|
||||
<Td />
|
||||
<Td />
|
||||
<Td>
|
||||
<CheckIcon />
|
||||
</Td>
|
||||
</Tr>
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Stack
|
||||
direction={['column', 'row']}
|
||||
spacing={4}
|
||||
w="full"
|
||||
justify="space-around"
|
||||
>
|
||||
<Stack spacing={4}>
|
||||
<Heading as="h3" size="md">
|
||||
Personal
|
||||
</Heading>
|
||||
<Heading as="h3">Free</Heading>
|
||||
<Link href="https://app.typebot.io/register">
|
||||
<Button variant="outline" colorScheme="gray">
|
||||
Get started
|
||||
</Button>
|
||||
</Link>
|
||||
</Stack>
|
||||
<Stack spacing={4}>
|
||||
<Heading as="h3" size="md" color="orange.200">
|
||||
Starter
|
||||
</Heading>
|
||||
<Heading as="h3">
|
||||
{formatPrice(prices.STARTER)}{' '}
|
||||
<chakra.span fontSize="lg">/ month</chakra.span>
|
||||
</Heading>
|
||||
<Link
|
||||
href={`https://app.typebot.io/register?subscribePlan=${Plan.STARTER}`}
|
||||
>
|
||||
<Button variant="outline" colorScheme="orange">
|
||||
Subscribe
|
||||
</Button>
|
||||
</Link>
|
||||
</Stack>
|
||||
<Stack spacing={4}>
|
||||
<Heading as="h3" size="md" color="blue.200">
|
||||
Pro
|
||||
</Heading>
|
||||
<Heading as="h3">
|
||||
{formatPrice(prices.PRO)}{' '}
|
||||
<chakra.span fontSize="lg">/ month</chakra.span>
|
||||
</Heading>
|
||||
<Link
|
||||
href={`https://app.typebot.io/register?subscribePlan=${Plan.PRO}`}
|
||||
>
|
||||
<Button>Subscribe</Button>
|
||||
</Link>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
</Stack>
|
||||
)
|
||||
|
||||
const TdWithTooltip = ({
|
||||
text,
|
||||
|
@ -9,7 +9,6 @@ import {
|
||||
VStack,
|
||||
} from '@chakra-ui/react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatPrice } from '@typebot.io/lib/pricing'
|
||||
import { CheckCircleIcon } from '../../../assets/icons/CheckCircleIcon'
|
||||
import { Card, CardProps } from './Card'
|
||||
@ -34,12 +33,8 @@ export const PricingCard = ({
|
||||
...rest
|
||||
}: PricingCardProps) => {
|
||||
const { features, price, name } = data
|
||||
const [formattedPrice, setFormattedPrice] = useState(price)
|
||||
const accentColor = useColorModeValue('blue.500', 'white')
|
||||
|
||||
useEffect(() => {
|
||||
setFormattedPrice(typeof price === 'number' ? formatPrice(price) : price)
|
||||
}, [price])
|
||||
const formattedPrice = typeof price === 'number' ? formatPrice(price) : price
|
||||
|
||||
return (
|
||||
<Card rounded="xl" bgColor="gray.800" {...rest}>
|
||||
|
@ -13,28 +13,33 @@ import { ChevronDownIcon } from 'assets/icons/ChevronDownIcon'
|
||||
import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon'
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
import Link from 'next/link'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { parseNumberWithCommas } from '@typebot.io/lib'
|
||||
import { chatsLimit, computePrice, storageLimit } from '@typebot.io/lib/pricing'
|
||||
import {
|
||||
chatsLimit,
|
||||
computePrice,
|
||||
seatsLimit,
|
||||
storageLimit,
|
||||
} from '@typebot.io/lib/pricing'
|
||||
import { PricingCard } from './PricingCard'
|
||||
|
||||
export const ProPlanCard = () => {
|
||||
const [price, setPrice] = useState(89)
|
||||
type Props = {
|
||||
isYearly: boolean
|
||||
}
|
||||
|
||||
export const ProPlanCard = ({ isYearly }: Props) => {
|
||||
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
|
||||
useState<number>(0)
|
||||
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
|
||||
useState<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
setPrice(
|
||||
computePrice(
|
||||
Plan.PRO,
|
||||
selectedChatsLimitIndex ?? 0,
|
||||
selectedStorageLimitIndex ?? 0
|
||||
) ?? NaN
|
||||
)
|
||||
}, [selectedChatsLimitIndex, selectedStorageLimitIndex])
|
||||
const price =
|
||||
computePrice(
|
||||
Plan.PRO,
|
||||
selectedChatsLimitIndex ?? 0,
|
||||
selectedStorageLimitIndex ?? 0,
|
||||
isYearly ? 'yearly' : 'monthly'
|
||||
) ?? NaN
|
||||
|
||||
return (
|
||||
<PricingCard
|
||||
@ -44,7 +49,10 @@ export const ProPlanCard = () => {
|
||||
featureLabel: 'Everything in Personal, plus:',
|
||||
features: [
|
||||
<Text key="seats">
|
||||
<chakra.span fontWeight="bold">5 seats</chakra.span> included
|
||||
<chakra.span fontWeight="bold">
|
||||
{seatsLimit.PRO.totalIncluded} seats
|
||||
</chakra.span>{' '}
|
||||
included
|
||||
</Text>,
|
||||
<HStack key="chats" spacing={1.5}>
|
||||
<Menu>
|
||||
@ -57,50 +65,20 @@ export const ProPlanCard = () => {
|
||||
>
|
||||
{selectedChatsLimitIndex !== undefined
|
||||
? parseNumberWithCommas(
|
||||
chatsLimit.PRO.totalIncluded +
|
||||
chatsLimit.PRO.increaseStep.amount *
|
||||
selectedChatsLimitIndex
|
||||
chatsLimit.PRO.graduatedPrice[selectedChatsLimitIndex]
|
||||
.totalIncluded
|
||||
)
|
||||
: undefined}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{selectedChatsLimitIndex !== 0 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(0)}>
|
||||
{parseNumberWithCommas(chatsLimit.PRO.totalIncluded)}
|
||||
{chatsLimit.PRO.graduatedPrice.map((price, index) => (
|
||||
<MenuItem
|
||||
key={index}
|
||||
onClick={() => setSelectedChatsLimitIndex(index)}
|
||||
>
|
||||
{parseNumberWithCommas(price.totalIncluded)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 1 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(1)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.PRO.totalIncluded +
|
||||
chatsLimit.PRO.increaseStep.amount
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 2 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(2)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.PRO.totalIncluded +
|
||||
chatsLimit.PRO.increaseStep.amount * 2
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 3 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(3)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.PRO.totalIncluded +
|
||||
chatsLimit.PRO.increaseStep.amount * 3
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 4 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(4)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.PRO.totalIncluded +
|
||||
chatsLimit.PRO.increaseStep.amount * 4
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>{' '}
|
||||
<Text>chats/mo</Text>
|
||||
@ -126,50 +104,20 @@ export const ProPlanCard = () => {
|
||||
>
|
||||
{selectedStorageLimitIndex !== undefined
|
||||
? parseNumberWithCommas(
|
||||
storageLimit.PRO.totalIncluded +
|
||||
storageLimit.PRO.increaseStep.amount *
|
||||
selectedStorageLimitIndex
|
||||
storageLimit.PRO.graduatedPrice[selectedStorageLimitIndex]
|
||||
.totalIncluded
|
||||
)
|
||||
: undefined}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{selectedStorageLimitIndex !== 0 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(0)}>
|
||||
{parseNumberWithCommas(storageLimit.PRO.totalIncluded)}
|
||||
{storageLimit.PRO.graduatedPrice.map((price, index) => (
|
||||
<MenuItem
|
||||
key={index}
|
||||
onClick={() => setSelectedStorageLimitIndex(index)}
|
||||
>
|
||||
{parseNumberWithCommas(price.totalIncluded)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 1 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(1)}>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.PRO.totalIncluded +
|
||||
storageLimit.PRO.increaseStep.amount
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 2 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(2)}>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.PRO.totalIncluded +
|
||||
storageLimit.PRO.increaseStep.amount * 2
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 3 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(3)}>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.PRO.totalIncluded +
|
||||
storageLimit.PRO.increaseStep.amount * 3
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 4 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(4)}>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.PRO.totalIncluded +
|
||||
storageLimit.PRO.increaseStep.amount * 4
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>{' '}
|
||||
<Text>GB of storage</Text>
|
||||
@ -194,7 +142,7 @@ export const ProPlanCard = () => {
|
||||
button={
|
||||
<Button
|
||||
as={Link}
|
||||
href={`https://app.typebot.io/register?subscribePlan=${Plan.PRO}&chats=${selectedChatsLimitIndex}&storage=${selectedStorageLimitIndex}`}
|
||||
href={`https://app.typebot.io/register?subscribePlan=${Plan.PRO}&chats=${selectedChatsLimitIndex}&storage=${selectedStorageLimitIndex}&isYearly=${isYearly}`}
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
w="full"
|
||||
|
@ -13,28 +13,32 @@ import { ChevronDownIcon } from 'assets/icons/ChevronDownIcon'
|
||||
import { HelpCircleIcon } from 'assets/icons/HelpCircleIcon'
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
import Link from 'next/link'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { parseNumberWithCommas } from '@typebot.io/lib'
|
||||
import { chatsLimit, computePrice, storageLimit } from '@typebot.io/lib/pricing'
|
||||
import {
|
||||
chatsLimit,
|
||||
computePrice,
|
||||
seatsLimit,
|
||||
storageLimit,
|
||||
} from '@typebot.io/lib/pricing'
|
||||
import { PricingCard } from './PricingCard'
|
||||
|
||||
export const StarterPlanCard = () => {
|
||||
const [price, setPrice] = useState(39)
|
||||
|
||||
type Props = {
|
||||
isYearly: boolean
|
||||
}
|
||||
export const StarterPlanCard = ({ isYearly }: Props) => {
|
||||
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
|
||||
useState<number>(0)
|
||||
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
|
||||
useState<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
setPrice(
|
||||
computePrice(
|
||||
Plan.STARTER,
|
||||
selectedChatsLimitIndex ?? 0,
|
||||
selectedStorageLimitIndex ?? 0
|
||||
) ?? NaN
|
||||
)
|
||||
}, [selectedChatsLimitIndex, selectedStorageLimitIndex])
|
||||
const price =
|
||||
computePrice(
|
||||
Plan.STARTER,
|
||||
selectedChatsLimitIndex ?? 0,
|
||||
selectedStorageLimitIndex ?? 0,
|
||||
isYearly ? 'yearly' : 'monthly'
|
||||
) ?? NaN
|
||||
|
||||
return (
|
||||
<PricingCard
|
||||
@ -44,7 +48,10 @@ export const StarterPlanCard = () => {
|
||||
featureLabel: 'Everything in Personal, plus:',
|
||||
features: [
|
||||
<Text key="seats">
|
||||
<chakra.span fontWeight="bold">2 seats</chakra.span> included
|
||||
<chakra.span fontWeight="bold">
|
||||
{seatsLimit.STARTER.totalIncluded} seats
|
||||
</chakra.span>{' '}
|
||||
included
|
||||
</Text>,
|
||||
<HStack key="chats" spacing={1.5}>
|
||||
<Menu>
|
||||
@ -56,49 +63,19 @@ export const StarterPlanCard = () => {
|
||||
colorScheme="orange"
|
||||
>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.STARTER.totalIncluded +
|
||||
chatsLimit.STARTER.increaseStep.amount *
|
||||
selectedChatsLimitIndex
|
||||
chatsLimit.STARTER.graduatedPrice[selectedChatsLimitIndex]
|
||||
.totalIncluded
|
||||
)}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{selectedChatsLimitIndex !== 0 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(0)}>
|
||||
{parseNumberWithCommas(chatsLimit.STARTER.totalIncluded)}
|
||||
{chatsLimit.STARTER.graduatedPrice.map((price, index) => (
|
||||
<MenuItem
|
||||
key={index}
|
||||
onClick={() => setSelectedChatsLimitIndex(index)}
|
||||
>
|
||||
{parseNumberWithCommas(price.totalIncluded)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 1 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(1)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.STARTER.totalIncluded +
|
||||
chatsLimit.STARTER.increaseStep.amount
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 2 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(2)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.STARTER.totalIncluded +
|
||||
chatsLimit.STARTER.increaseStep.amount * 2
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 3 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(3)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.STARTER.totalIncluded +
|
||||
chatsLimit.STARTER.increaseStep.amount * 3
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedChatsLimitIndex !== 4 && (
|
||||
<MenuItem onClick={() => setSelectedChatsLimitIndex(4)}>
|
||||
{parseNumberWithCommas(
|
||||
chatsLimit.STARTER.totalIncluded +
|
||||
chatsLimit.STARTER.increaseStep.amount * 4
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>{' '}
|
||||
<Text>chats/mo</Text>
|
||||
@ -125,50 +102,21 @@ export const StarterPlanCard = () => {
|
||||
>
|
||||
{selectedStorageLimitIndex !== undefined
|
||||
? parseNumberWithCommas(
|
||||
storageLimit.STARTER.totalIncluded +
|
||||
storageLimit.STARTER.increaseStep.amount *
|
||||
selectedStorageLimitIndex
|
||||
storageLimit.STARTER.graduatedPrice[
|
||||
selectedStorageLimitIndex
|
||||
].totalIncluded
|
||||
)
|
||||
: undefined}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{selectedStorageLimitIndex !== 0 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(0)}>
|
||||
{parseNumberWithCommas(storageLimit.STARTER.totalIncluded)}
|
||||
{storageLimit.STARTER.graduatedPrice.map((price, index) => (
|
||||
<MenuItem
|
||||
key={index}
|
||||
onClick={() => setSelectedStorageLimitIndex(index)}
|
||||
>
|
||||
{parseNumberWithCommas(price.totalIncluded)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 1 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(1)}>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.STARTER.totalIncluded +
|
||||
storageLimit.STARTER.increaseStep.amount
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 2 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(2)}>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.STARTER.totalIncluded +
|
||||
storageLimit.STARTER.increaseStep.amount * 2
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 3 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(3)}>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.STARTER.totalIncluded +
|
||||
storageLimit.STARTER.increaseStep.amount * 3
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedStorageLimitIndex !== 4 && (
|
||||
<MenuItem onClick={() => setSelectedStorageLimitIndex(4)}>
|
||||
{parseNumberWithCommas(
|
||||
storageLimit.STARTER.totalIncluded +
|
||||
storageLimit.STARTER.increaseStep.amount * 4
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>{' '}
|
||||
<Text>GB of storage</Text>
|
||||
@ -194,12 +142,13 @@ export const StarterPlanCard = () => {
|
||||
button={
|
||||
<Button
|
||||
as={Link}
|
||||
href={`https://app.typebot.io/register?subscribePlan=${Plan.STARTER}&chats=${selectedChatsLimitIndex}&storage=${selectedStorageLimitIndex}`}
|
||||
colorScheme="blue"
|
||||
href={`https://app.typebot.io/register?subscribePlan=${Plan.STARTER}&chats=${selectedChatsLimitIndex}&storage=${selectedStorageLimitIndex}&isYearly=${isYearly}`}
|
||||
colorScheme="orange"
|
||||
size="lg"
|
||||
w="full"
|
||||
fontWeight="extrabold"
|
||||
py={{ md: '8' }}
|
||||
variant="outline"
|
||||
>
|
||||
Subscribe now
|
||||
</Button>
|
||||
|
@ -1,39 +1,30 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
DarkMode,
|
||||
Flex,
|
||||
Stack,
|
||||
Box,
|
||||
Heading,
|
||||
VStack,
|
||||
Text,
|
||||
HStack,
|
||||
Switch,
|
||||
Tag,
|
||||
} from '@chakra-ui/react'
|
||||
import { Footer } from 'components/common/Footer'
|
||||
import { Header } from 'components/common/Header/Header'
|
||||
import { SocialMetaTags } from 'components/common/SocialMetaTags'
|
||||
import { BackgroundPolygons } from 'components/Homepage/Hero/BackgroundPolygons'
|
||||
import { PlanComparisonTables } from 'components/PricingPage/PlanComparisonTables'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatPrice, prices } from '@typebot.io/lib/pricing'
|
||||
import { useState } from 'react'
|
||||
import { StripeClimateLogo } from 'assets/logos/StripeClimateLogo'
|
||||
import { FreePlanCard } from 'components/PricingPage/FreePlanCard'
|
||||
import { StarterPlanCard } from 'components/PricingPage/StarterPlanCard'
|
||||
import { ProPlanCard } from 'components/PricingPage/ProPlanCard'
|
||||
import { TextLink } from 'components/common/TextLink'
|
||||
import { EnterprisePlanCard } from 'components/PricingPage/EnterprisePlanCard'
|
||||
import { Faq } from 'components/PricingPage/Faq'
|
||||
|
||||
const Pricing = () => {
|
||||
const [starterPrice, setStarterPrice] = useState('$39')
|
||||
const [proPrice, setProPrice] = useState('$89')
|
||||
|
||||
useEffect(() => {
|
||||
setStarterPrice(formatPrice(prices.STARTER))
|
||||
setProPrice(formatPrice(prices.PRO))
|
||||
}, [])
|
||||
const [isYearly, setIsYearly] = useState(true)
|
||||
|
||||
return (
|
||||
<Stack overflowX="hidden" bgColor="gray.900">
|
||||
@ -52,20 +43,31 @@ const Pricing = () => {
|
||||
</DarkMode>
|
||||
|
||||
<VStack spacing={'24'} mt={[20, 32]} w="full">
|
||||
<Stack align="center" spacing="12" w="full">
|
||||
<Stack align="center" spacing="12" w="full" px={4}>
|
||||
<VStack>
|
||||
<Heading fontSize="6xl">Plans fit for you</Heading>
|
||||
<Text maxW="900px" fontSize="xl" textAlign="center">
|
||||
<Heading fontSize={{ base: '4xl', xl: '6xl' }}>
|
||||
Plans fit for you
|
||||
</Heading>
|
||||
<Text
|
||||
maxW="900px"
|
||||
textAlign="center"
|
||||
fontSize={{ base: 'lg', xl: 'xl' }}
|
||||
>
|
||||
Whether you're a{' '}
|
||||
<Text as="span" color="orange.200" fontWeight="bold">
|
||||
solo business owner
|
||||
</Text>{' '}
|
||||
or a{' '}
|
||||
</Text>
|
||||
, a{' '}
|
||||
<Text as="span" color="blue.200" fontWeight="bold">
|
||||
growing startup
|
||||
</Text>{' '}
|
||||
or a{' '}
|
||||
<Text as="span" fontWeight="bold">
|
||||
large company
|
||||
</Text>
|
||||
, Typebot is here to help you build high-performing bots for the
|
||||
right price. Pay for as little or as much usage as you need.
|
||||
, Typebot is here to help you build high-performing chat forms
|
||||
for the right price. Pay for as little or as much usage as you
|
||||
need.
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
@ -85,38 +87,41 @@ const Pricing = () => {
|
||||
</TextLink>
|
||||
</Text>
|
||||
</HStack>
|
||||
<Stack
|
||||
direction={['column', 'row']}
|
||||
alignItems={['stretch']}
|
||||
spacing={10}
|
||||
px={[4, 0]}
|
||||
w="full"
|
||||
maxW="1200px"
|
||||
>
|
||||
<FreePlanCard />
|
||||
<StarterPlanCard />
|
||||
<ProPlanCard />
|
||||
<Stack align="flex-end" maxW="1200px" w="full" spacing={4}>
|
||||
<HStack>
|
||||
<Text>Monthly</Text>
|
||||
<Switch
|
||||
isChecked={isYearly}
|
||||
onChange={() => setIsYearly(!isYearly)}
|
||||
/>
|
||||
<HStack>
|
||||
<Text>Yearly</Text>
|
||||
<Tag colorScheme="blue">16% off</Tag>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<Stack
|
||||
direction={['column', 'row']}
|
||||
alignItems={['stretch']}
|
||||
spacing={10}
|
||||
w="full"
|
||||
maxW="1200px"
|
||||
>
|
||||
<FreePlanCard />
|
||||
<StarterPlanCard isYearly={isYearly} />
|
||||
<ProPlanCard isYearly={isYearly} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Text fontSize="lg">
|
||||
Need custom limits? Specific features?{' '}
|
||||
<TextLink href={'https://typebot.io/enterprise-lead-form'}>
|
||||
Let's chat!
|
||||
</TextLink>
|
||||
</Text>
|
||||
|
||||
<EnterprisePlanCard />
|
||||
</Stack>
|
||||
|
||||
<VStack maxW="1200px" w="full" spacing={[12, 20]} px="4">
|
||||
<Stack w="full" spacing={10} display={['none', 'flex']}>
|
||||
<Heading>Compare plans & features</Heading>
|
||||
<PlanComparisonTables
|
||||
starterPrice={starterPrice}
|
||||
proPrice={proPrice}
|
||||
/>
|
||||
<PlanComparisonTables />
|
||||
</Stack>
|
||||
<VStack w="full" spacing="10">
|
||||
<Heading textAlign="center">Frequently asked questions</Heading>
|
||||
<Faq />
|
||||
</VStack>
|
||||
<Faq />
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Flex>
|
||||
@ -125,75 +130,4 @@ const Pricing = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const Faq = () => {
|
||||
return (
|
||||
<Accordion w="full" allowToggle defaultIndex={0}>
|
||||
<AccordionItem>
|
||||
<Heading as="h2">
|
||||
<AccordionButton py="6">
|
||||
<Box flex="1" textAlign="left" fontSize="2xl">
|
||||
What happens once I reach the monthly chats limit?
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</Heading>
|
||||
<AccordionPanel pb={4}>
|
||||
You will receive an email notification once you reached 80% of this
|
||||
limit. Then, once you reach 100%, the bot will be closed to new users.
|
||||
Upgrading your limit will automatically reopen the bot.
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem>
|
||||
<Heading as="h2">
|
||||
<AccordionButton py="6">
|
||||
<Box flex="1" textAlign="left" fontSize="2xl">
|
||||
What happens once I reach the storage limit?
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</Heading>
|
||||
<AccordionPanel pb={4}>
|
||||
You will receive an email notification once you reached 80% of this
|
||||
limit. Then, once you reach 100%, your users will still be able to
|
||||
chat with your bot but their uploads won't be stored anymore. You
|
||||
will need to upgrade the limit or free up some space to continue
|
||||
collecting your users' 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'll figure things out 😀
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
export default Pricing
|
||||
|
@ -3,26 +3,68 @@ import { Plan } from '@typebot.io/prisma'
|
||||
|
||||
const infinity = -1
|
||||
|
||||
export const priceIds = {
|
||||
[Plan.STARTER]: {
|
||||
base: {
|
||||
monthly: process.env.STRIPE_STARTER_MONTHLY_PRICE_ID,
|
||||
yearly: process.env.STRIPE_STARTER_YEARLY_PRICE_ID,
|
||||
},
|
||||
chats: {
|
||||
monthly: process.env.STRIPE_STARTER_CHATS_MONTHLY_PRICE_ID,
|
||||
yearly: process.env.STRIPE_STARTER_CHATS_YEARLY_PRICE_ID,
|
||||
},
|
||||
storage: {
|
||||
monthly: process.env.STRIPE_STARTER_STORAGE_MONTHLY_PRICE_ID,
|
||||
yearly: process.env.STRIPE_STARTER_STORAGE_YEARLY_PRICE_ID,
|
||||
},
|
||||
},
|
||||
[Plan.PRO]: {
|
||||
base: {
|
||||
monthly: process.env.STRIPE_PRO_MONTHLY_PRICE_ID,
|
||||
yearly: process.env.STRIPE_PRO_YEARLY_PRICE_ID,
|
||||
},
|
||||
chats: {
|
||||
monthly: process.env.STRIPE_PRO_CHATS_MONTHLY_PRICE_ID,
|
||||
yearly: process.env.STRIPE_PRO_CHATS_YEARLY_PRICE_ID,
|
||||
},
|
||||
storage: {
|
||||
monthly: process.env.STRIPE_PRO_STORAGE_MONTHLY_PRICE_ID,
|
||||
yearly: process.env.STRIPE_PRO_STORAGE_YEARLY_PRICE_ID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const prices = {
|
||||
[Plan.STARTER]: 39,
|
||||
[Plan.PRO]: 89,
|
||||
} as const
|
||||
|
||||
export const chatsLimit = {
|
||||
[Plan.FREE]: { totalIncluded: 300 },
|
||||
[Plan.FREE]: { totalIncluded: 200 },
|
||||
[Plan.STARTER]: {
|
||||
totalIncluded: 2000,
|
||||
increaseStep: {
|
||||
amount: 500,
|
||||
price: 10,
|
||||
},
|
||||
graduatedPrice: [
|
||||
{ totalIncluded: 2000, price: 0 },
|
||||
{
|
||||
totalIncluded: 2500,
|
||||
price: 10,
|
||||
},
|
||||
{
|
||||
totalIncluded: 3000,
|
||||
price: 20,
|
||||
},
|
||||
{
|
||||
totalIncluded: 3500,
|
||||
price: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
[Plan.PRO]: {
|
||||
totalIncluded: 10000,
|
||||
increaseStep: {
|
||||
amount: 1000,
|
||||
price: 10,
|
||||
},
|
||||
graduatedPrice: [
|
||||
{ totalIncluded: 10000, price: 0 },
|
||||
{ totalIncluded: 15000, price: 50 },
|
||||
{ totalIncluded: 25000, price: 150 },
|
||||
{ totalIncluded: 50000, price: 400 },
|
||||
],
|
||||
},
|
||||
[Plan.CUSTOM]: {
|
||||
totalIncluded: 2000,
|
||||
@ -39,18 +81,38 @@ export const chatsLimit = {
|
||||
export const storageLimit = {
|
||||
[Plan.FREE]: { totalIncluded: 0 },
|
||||
[Plan.STARTER]: {
|
||||
totalIncluded: 2,
|
||||
increaseStep: {
|
||||
amount: 1,
|
||||
price: 2,
|
||||
},
|
||||
graduatedPrice: [
|
||||
{ totalIncluded: 2, price: 0 },
|
||||
{
|
||||
totalIncluded: 3,
|
||||
price: 2,
|
||||
},
|
||||
{
|
||||
totalIncluded: 4,
|
||||
price: 4,
|
||||
},
|
||||
{
|
||||
totalIncluded: 5,
|
||||
price: 6,
|
||||
},
|
||||
],
|
||||
},
|
||||
[Plan.PRO]: {
|
||||
totalIncluded: 10,
|
||||
increaseStep: {
|
||||
amount: 1,
|
||||
price: 2,
|
||||
},
|
||||
graduatedPrice: [
|
||||
{ totalIncluded: 10, price: 0 },
|
||||
{
|
||||
totalIncluded: 15,
|
||||
price: 8,
|
||||
},
|
||||
{
|
||||
totalIncluded: 25,
|
||||
price: 24,
|
||||
},
|
||||
{
|
||||
totalIncluded: 40,
|
||||
price: 49,
|
||||
},
|
||||
],
|
||||
},
|
||||
[Plan.CUSTOM]: {
|
||||
totalIncluded: 2,
|
||||
@ -86,13 +148,12 @@ export const getChatsLimit = ({
|
||||
customChatsLimit,
|
||||
}: Pick<Workspace, 'additionalChatsIndex' | 'plan' | 'customChatsLimit'>) => {
|
||||
if (customChatsLimit) return customChatsLimit
|
||||
const { totalIncluded } = chatsLimit[plan]
|
||||
const increaseStep =
|
||||
const totalIncluded =
|
||||
plan === Plan.STARTER || plan === Plan.PRO
|
||||
? chatsLimit[plan].increaseStep
|
||||
: { amount: 0 }
|
||||
? chatsLimit[plan].graduatedPrice[additionalChatsIndex].totalIncluded
|
||||
: chatsLimit[plan].totalIncluded
|
||||
if (totalIncluded === infinity) return infinity
|
||||
return totalIncluded + increaseStep.amount * additionalChatsIndex
|
||||
return totalIncluded
|
||||
}
|
||||
|
||||
export const getStorageLimit = ({
|
||||
@ -104,12 +165,11 @@ export const getStorageLimit = ({
|
||||
'additionalStorageIndex' | 'plan' | 'customStorageLimit'
|
||||
>) => {
|
||||
if (customStorageLimit) return customStorageLimit
|
||||
const { totalIncluded } = storageLimit[plan]
|
||||
const increaseStep =
|
||||
const totalIncluded =
|
||||
plan === Plan.STARTER || plan === Plan.PRO
|
||||
? storageLimit[plan].increaseStep
|
||||
: { amount: 0 }
|
||||
return totalIncluded + increaseStep.amount * additionalStorageIndex
|
||||
? storageLimit[plan].graduatedPrice[additionalStorageIndex].totalIncluded
|
||||
: storageLimit[plan].totalIncluded
|
||||
return totalIncluded
|
||||
}
|
||||
|
||||
export const getSeatsLimit = ({
|
||||
@ -139,20 +199,15 @@ export const isSeatsLimitReached = ({
|
||||
export const computePrice = (
|
||||
plan: Plan,
|
||||
selectedTotalChatsIndex: number,
|
||||
selectedTotalStorageIndex: number
|
||||
selectedTotalStorageIndex: number,
|
||||
frequency: 'monthly' | 'yearly'
|
||||
) => {
|
||||
if (plan !== Plan.STARTER && plan !== Plan.PRO) return
|
||||
const {
|
||||
increaseStep: { price: chatsPrice },
|
||||
} = chatsLimit[plan]
|
||||
const {
|
||||
increaseStep: { price: storagePrice },
|
||||
} = storageLimit[plan]
|
||||
return (
|
||||
const price =
|
||||
prices[plan] +
|
||||
selectedTotalChatsIndex * chatsPrice +
|
||||
selectedTotalStorageIndex * storagePrice
|
||||
)
|
||||
chatsLimit[plan].graduatedPrice[selectedTotalChatsIndex].price +
|
||||
storageLimit[plan].graduatedPrice[selectedTotalStorageIndex].price
|
||||
return frequency === 'monthly' ? price : price - price * 0.16
|
||||
}
|
||||
|
||||
const europeanUnionCountryCodes = [
|
||||
@ -202,13 +257,15 @@ const europeanUnionExclusiveLanguageCodes = [
|
||||
'bg',
|
||||
]
|
||||
|
||||
export const guessIfUserIsEuropean = () =>
|
||||
window.navigator.languages.some((language) => {
|
||||
export const guessIfUserIsEuropean = () => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return window.navigator.languages.some((language) => {
|
||||
const [languageCode, countryCode] = language.split('-')
|
||||
return countryCode
|
||||
? europeanUnionCountryCodes.includes(countryCode)
|
||||
: europeanUnionExclusiveLanguageCodes.includes(languageCode)
|
||||
})
|
||||
}
|
||||
|
||||
export const formatPrice = (price: number, currency?: 'eur' | 'usd') => {
|
||||
const isEuropean = guessIfUserIsEuropean()
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const subscriptionSchema = z.object({
|
||||
additionalChatsIndex: z.number(),
|
||||
additionalStorageIndex: z.number(),
|
||||
isYearly: z.boolean(),
|
||||
currency: z.enum(['eur', 'usd']),
|
||||
cancelDate: z.date().optional(),
|
||||
})
|
||||
|
||||
export type Subscription = z.infer<typeof subscriptionSchema>
|
||||
|
Reference in New Issue
Block a user