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